feat: ai agent部分功能
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
This commit is contained in:
3
.changeset/.prettierrc.json
Normal file
3
.changeset/.prettierrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"singleQuote": true
|
||||
}
|
||||
7
.changeset/fancy-ears-walk.md
Normal file
7
.changeset/fancy-ears-walk.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@vben/styles': patch
|
||||
'@vben-core/form-ui': patch
|
||||
'@vben/web-naive': patch
|
||||
---
|
||||
|
||||
feat(@core/form-ui): 新增 useVbenForm 数组编辑器 VbenFormFieldArray
|
||||
@@ -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%)`;历史页面暂不强制回改,新增/改动的卡片必须遵守
|
||||
|
||||
# 全局架构规范
|
||||
|
||||
|
||||
16
.gitattributes
vendored
16
.gitattributes
vendored
@@ -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
|
||||
*.ico binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.svg binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
18
.github/CODEOWNERS
vendored
18
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
4
.github/actions/setup-node/action.yml
vendored
4
.github/actions/setup-node/action.yml
vendored
@@ -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'
|
||||
|
||||
3
.github/config.yml
vendored
3
.github/config.yml
vendored
@@ -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.
|
||||
|
||||
|
||||
2
.github/contributing.md
vendored
2
.github/contributing.md
vendored
@@ -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.
|
||||
|
||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
- windows-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
2
.github/workflows/changeset-version.yml
vendored
2
.github/workflows/changeset-version.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -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}}'
|
||||
|
||||
37
.github/workflows/deploy.yml
vendored
37
.github/workflows/deploy.yml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/draft.yml
vendored
2
.github/workflows/draft.yml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/issue-close-require.yml
vendored
2
.github/workflows/issue-close-require.yml
vendored
@@ -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.
|
||||
|
||||
6
.github/workflows/issue-labeled.yml
vendored
6
.github/workflows/issue-labeled.yml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/lock.yml
vendored
2
.github/workflows/lock.yml
vendored
@@ -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'
|
||||
|
||||
78
.github/workflows/release-tag.yml
vendored
78
.github/workflows/release-tag.yml
vendored
@@ -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.
|
||||
|
||||
19
.github/workflows/rerun.yml
vendored
Normal file
19
.github/workflows/rerun.yml
vendored
Normal file
@@ -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
|
||||
2
.github/workflows/semantic-pull-request.yml
vendored
2
.github/workflows/semantic-pull-request.yml
vendored
@@ -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]).+$
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -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'
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -1,3 +0,0 @@
|
||||
# 每次 git pull 之后, 安装依赖
|
||||
|
||||
pnpm install
|
||||
@@ -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'],
|
||||
};
|
||||
@@ -1 +1 @@
|
||||
20.14.0
|
||||
24.16.0
|
||||
|
||||
17
.npmrc
17
.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/
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
dist
|
||||
dev-dist
|
||||
.local
|
||||
.output.js
|
||||
node_modules
|
||||
.nvmrc
|
||||
coverage
|
||||
CODEOWNERS
|
||||
.nitro
|
||||
.output
|
||||
|
||||
|
||||
**/*.svg
|
||||
**/*.sh
|
||||
|
||||
public
|
||||
.npmrc
|
||||
*-lock.yaml
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from '@vben/prettier-config';
|
||||
@@ -2,3 +2,7 @@ dist
|
||||
public
|
||||
__tests__
|
||||
coverage
|
||||
.codex
|
||||
.claude
|
||||
.agent
|
||||
.agents
|
||||
|
||||
32
apps/backend-mock/api/demo/bigint.ts
Normal file
32
apps/backend-mock/api/demo/bigint.ts
Normal file
@@ -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;
|
||||
});
|
||||
16
apps/backend-mock/api/system/dept/.post.ts
Normal file
16
apps/backend-mock/api/system/dept/.post.ts
Normal file
@@ -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);
|
||||
});
|
||||
16
apps/backend-mock/api/system/dept/[id].delete.ts
Normal file
16
apps/backend-mock/api/system/dept/[id].delete.ts
Normal file
@@ -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);
|
||||
});
|
||||
16
apps/backend-mock/api/system/dept/[id].put.ts
Normal file
16
apps/backend-mock/api/system/dept/[id].put.ts
Normal file
@@ -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);
|
||||
});
|
||||
62
apps/backend-mock/api/system/dept/list.ts
Normal file
62
apps/backend-mock/api/system/dept/list.ts
Normal file
@@ -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<string, any> = {
|
||||
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);
|
||||
});
|
||||
13
apps/backend-mock/api/system/menu/list.ts
Normal file
13
apps/backend-mock/api/system/menu/list.ts
Normal file
@@ -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);
|
||||
});
|
||||
29
apps/backend-mock/api/system/menu/name-exists.ts
Normal file
29
apps/backend-mock/api/system/menu/name-exists.ts
Normal file
@@ -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<string, any> = {};
|
||||
|
||||
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);
|
||||
});
|
||||
29
apps/backend-mock/api/system/menu/path-exists.ts
Normal file
29
apps/backend-mock/api/system/menu/path-exists.ts
Normal file
@@ -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<string, any> = { '/': 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);
|
||||
});
|
||||
84
apps/backend-mock/api/system/role/list.ts
Normal file
84
apps/backend-mock/api/system/role/list.ts
Normal file
@@ -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<string, any> = {
|
||||
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);
|
||||
});
|
||||
85
apps/backend-mock/api/system/user/list.ts
Normal file
85
apps/backend-mock/api/system/user/list.ts
Normal file
@@ -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<string, any> = {
|
||||
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);
|
||||
});
|
||||
12
apps/backend-mock/api/timezone/getTimezone.ts
Normal file
12
apps/backend-mock/api/timezone/getTimezone.ts
Normal file
@@ -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());
|
||||
});
|
||||
11
apps/backend-mock/api/timezone/getTimezoneOptions.ts
Normal file
11
apps/backend-mock/api/timezone/getTimezoneOptions.ts
Normal file
@@ -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);
|
||||
});
|
||||
26
apps/backend-mock/api/timezone/setTimezone.ts
Normal file
26
apps/backend-mock/api/timezone/setTimezone.ts
Normal file
@@ -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({});
|
||||
});
|
||||
14
apps/backend-mock/api/upload.ts
Normal file
14
apps/backend-mock/api/upload.ts
Normal file
@@ -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")
|
||||
});
|
||||
9
apps/backend-mock/utils/timezone-utils.ts
Normal file
9
apps/backend-mock/utils/timezone-utils.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
let mockTimeZone: null | string = null;
|
||||
|
||||
export const setTimezone = (timeZone: string) => {
|
||||
mockTimeZone = timeZone;
|
||||
};
|
||||
|
||||
export const getTimezone = () => {
|
||||
return mockTimeZone;
|
||||
};
|
||||
2
apps/desktop/.gitignore
vendored
Normal file
2
apps/desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
out/
|
||||
release/
|
||||
46
apps/desktop/README.md
Normal file
46
apps/desktop/README.md
Normal file
@@ -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` 并上传产物,桌面端即可自动更新。
|
||||
78
apps/desktop/electron-builder.config.cjs
Normal file
78
apps/desktop/electron-builder.config.cjs
Normal file
@@ -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;
|
||||
26
apps/desktop/electron.vite.config.ts
Normal file
26
apps/desktop/electron.vite.config.ts
Normal file
@@ -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' },
|
||||
},
|
||||
},
|
||||
});
|
||||
27
apps/desktop/package.json
Normal file
27
apps/desktop/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
18
apps/desktop/resources/entitlements.mac.plist
Normal file
18
apps/desktop/resources/entitlements.mac.plist
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- hardenedRuntime 下 Electron(V8 JIT)运行必需的内存权限 -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<!-- IM 音视频通话:摄像头 / 麦克风访问权限 -->
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
apps/desktop/resources/icon.ico
Normal file
BIN
apps/desktop/resources/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
BIN
apps/desktop/resources/icon.png
Normal file
BIN
apps/desktop/resources/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
30
apps/desktop/src/main/auto-launch.ts
Normal file
30
apps/desktop/src/main/auto-launch.ts
Normal file
@@ -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();
|
||||
}
|
||||
24
apps/desktop/src/main/badge.ts
Normal file
24
apps/desktop/src/main/badge.ts
Normal file
@@ -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, '');
|
||||
}
|
||||
}
|
||||
54
apps/desktop/src/main/config.ts
Normal file
54
apps/desktop/src/main/config.ts
Normal file
@@ -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);
|
||||
}
|
||||
81
apps/desktop/src/main/index.ts
Normal file
81
apps/desktop/src/main/index.ts
Normal file
@@ -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();
|
||||
}
|
||||
117
apps/desktop/src/main/ipc.ts
Normal file
117
apps/desktop/src/main/ipc.ts
Normal file
@@ -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<DesktopSettings>) => {
|
||||
assertTrustedSender(event);
|
||||
return updateSettings(patch);
|
||||
});
|
||||
|
||||
// 手动检查更新(渲染端设置页/关于入口)
|
||||
ipcMain.handle('desktop:check-update', (event) => {
|
||||
assertTrustedSender(event);
|
||||
checkForUpdates(true);
|
||||
});
|
||||
|
||||
// 渲染端主动唤起主窗口(如页内提醒的「查看」动作)
|
||||
ipcMain.handle('desktop:focus-window', (event) => {
|
||||
assertTrustedSender(event);
|
||||
showMainWindow();
|
||||
});
|
||||
}
|
||||
75
apps/desktop/src/main/printer.ts
Normal file
75
apps/desktop/src/main/printer.ts
Normal file
@@ -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<PrintResult> {
|
||||
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<PrintResult>((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));
|
||||
});
|
||||
});
|
||||
}
|
||||
108
apps/desktop/src/main/protocol.ts
Normal file
108
apps/desktop/src/main/protocol.ts
Normal file
@@ -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<Response> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
67
apps/desktop/src/main/settings.ts
Normal file
67
apps/desktop/src/main/settings.ts
Normal file
@@ -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<DesktopSettings>;
|
||||
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>): DesktopSettings {
|
||||
const next = { ...getSettings(), ...patch };
|
||||
cache = next;
|
||||
try {
|
||||
writeFileSync(getSettingsFilePath(), JSON.stringify(next, null, 2), 'utf8');
|
||||
} catch (error) {
|
||||
log.warn('写入桌面端设置失败', error);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
21
apps/desktop/src/main/shortcut.ts
Normal file
21
apps/desktop/src/main/shortcut.ts
Normal file
@@ -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();
|
||||
}
|
||||
78
apps/desktop/src/main/tray.ts
Normal file
78
apps/desktop/src/main/tray.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
139
apps/desktop/src/main/updater.ts
Normal file
139
apps/desktop/src/main/updater.ts
Normal file
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
212
apps/desktop/src/main/window.ts
Normal file
212
apps/desktop/src/main/window.ts
Normal file
@@ -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 = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>加载失败</title>
|
||||
<style>
|
||||
body { margin: 0; display: flex; align-items: center; justify-content: center; height: 100vh;
|
||||
font-family: "Microsoft YaHei", "PingFang SC", sans-serif; background: #f5f6f8; color: #333; }
|
||||
.box { text-align: center; }
|
||||
.title { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
|
||||
.desc { font-size: 13px; color: #999; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<div class="title">页面加载失败,正在自动重试…</div>
|
||||
<div class="desc">请检查网络连接(${reason})</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
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();
|
||||
}
|
||||
}
|
||||
102
apps/desktop/src/preload/index.ts
Normal file
102
apps/desktop/src/preload/index.ts
Normal file
@@ -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<string> => ipcRenderer.invoke('desktop:get-version'),
|
||||
/** 发送系统通知;route 为点击通知后要跳转的前端路由 */
|
||||
notify: (options: { title: string; body: string; route?: string }): Promise<boolean> =>
|
||||
ipcRenderer.invoke('desktop:notify', options),
|
||||
/** 设置未读角标数量(0 表示清除);Windows 角标图在此侧 canvas 生成 */
|
||||
setBadge: (count: number): Promise<void> =>
|
||||
ipcRenderer.invoke('desktop:set-badge', {
|
||||
count,
|
||||
image: count > 0 && process.platform === 'win32' ? drawBadgeImage(count) : undefined,
|
||||
}),
|
||||
/** 任务栏闪烁提醒(窗口未聚焦时生效,聚焦后自动清除) */
|
||||
flashFrame: (): Promise<void> => ipcRenderer.invoke('desktop:flash-frame'),
|
||||
/** 读取开机自启状态 */
|
||||
getAutoLaunch: (): Promise<boolean> => ipcRenderer.invoke('desktop:get-auto-launch'),
|
||||
/** 设置开机自启,返回设置后的实际状态 */
|
||||
setAutoLaunch: (enable: boolean): Promise<boolean> =>
|
||||
ipcRenderer.invoke('desktop:set-auto-launch', enable),
|
||||
/** 获取系统打印机列表(打印设置下拉用) */
|
||||
getPrinters: (): Promise<Array<{ name: string; displayName: string; isDefault: boolean }>> =>
|
||||
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<void> => ipcRenderer.invoke('desktop:check-update'),
|
||||
/** 唤起并聚焦主窗口 */
|
||||
focusWindow: (): Promise<void> => 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;
|
||||
18
apps/desktop/tsconfig.json
Normal file
18
apps/desktop/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
|
||||
/>
|
||||
<!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
|
||||
<title><%= VITE_APP_TITLE %></title>
|
||||
<title>%VITE_APP_TITLE%</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<script>
|
||||
// 生产环境下注入百度统计
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/web-antd",
|
||||
"version": "5.5.1",
|
||||
"version": "5.7.0",
|
||||
"homepage": "https://vben.pro",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
@@ -27,6 +27,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@vben/access": "workspace:*",
|
||||
"@vben/common-ui": "workspace:*",
|
||||
"@vben/constants": "workspace:*",
|
||||
@@ -41,15 +42,24 @@
|
||||
"@vben/styles": "workspace:*",
|
||||
"@vben/types": "workspace:*",
|
||||
"@vben/utils": "workspace:*",
|
||||
"@vueup/vue-quill": "^1.2.0",
|
||||
"@vueuse/core": "catalog:",
|
||||
"ant-design-vue": "catalog:",
|
||||
"axios": "^1.10.0",
|
||||
"dayjs": "catalog:",
|
||||
"exceljs": "^4.4.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"js-base64": "^3.7.7",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-vue-next": "^0.487.0",
|
||||
"lunar-typescript": "^1.8.6",
|
||||
"markdown-it": "^14.1.0",
|
||||
"md-editor-v3": "^5.4.5",
|
||||
"pinia": "catalog:",
|
||||
"sortablejs": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"vue-router": "catalog:",
|
||||
"xgplayer": "^3.0.24",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from '@vben/tailwind-config/postcss';
|
||||
BIN
apps/web-antd/public/audio/register-notify.mp3
Normal file
BIN
apps/web-antd/public/audio/register-notify.mp3
Normal file
Binary file not shown.
@@ -3,55 +3,616 @@
|
||||
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
|
||||
*/
|
||||
|
||||
import type { BaseFormComponentType } from '@vben/common-ui';
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
|
||||
import type { Component, SetupContext } from 'vue';
|
||||
import { h } from 'vue';
|
||||
import type {
|
||||
AutoCompleteProps,
|
||||
ButtonProps,
|
||||
CascaderProps,
|
||||
CheckboxGroupProps,
|
||||
CheckboxProps,
|
||||
DatePickerProps,
|
||||
DividerProps,
|
||||
InputNumberProps,
|
||||
InputProps,
|
||||
MentionsProps,
|
||||
RadioGroupProps,
|
||||
RadioProps,
|
||||
RateProps,
|
||||
SelectProps,
|
||||
SpaceProps,
|
||||
SwitchProps,
|
||||
TextAreaProps,
|
||||
TimePickerProps,
|
||||
TreeSelectProps,
|
||||
UploadChangeParam,
|
||||
UploadFile,
|
||||
UploadProps,
|
||||
} from 'ant-design-vue';
|
||||
import type { RangePickerProps } from 'ant-design-vue/es/date-picker';
|
||||
|
||||
import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
import type { Component, Ref } from 'vue';
|
||||
|
||||
import type {
|
||||
ApiComponentSharedProps,
|
||||
BaseFormComponentType,
|
||||
IconPickerProps,
|
||||
} from '@vben/common-ui';
|
||||
import type { Sortable } from '@vben/hooks';
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import {
|
||||
AutoComplete,
|
||||
Button,
|
||||
Cascader,
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
DatePicker,
|
||||
Divider,
|
||||
Input,
|
||||
InputNumber,
|
||||
InputPassword,
|
||||
Mentions,
|
||||
notification,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
RangePicker,
|
||||
Rate,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Textarea,
|
||||
TimePicker,
|
||||
TreeSelect,
|
||||
Upload,
|
||||
} from 'ant-design-vue';
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
defineComponent,
|
||||
h,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
render,
|
||||
unref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import {
|
||||
ApiComponent,
|
||||
globalShareState,
|
||||
IconPicker,
|
||||
VCropper,
|
||||
} from '@vben/common-ui';
|
||||
import { useSortable } from '@vben/hooks';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
import { message, Modal, notification } from 'ant-design-vue';
|
||||
|
||||
import { registerComponent } from '#/components/form/component-map';
|
||||
|
||||
type AdapterUploadProps = UploadProps & {
|
||||
aspectRatio?: string;
|
||||
crop?: boolean;
|
||||
draggable?: boolean;
|
||||
handleChange?: (event: UploadChangeParam) => void;
|
||||
maxSize?: number;
|
||||
onDragSort?: (oldIndex: number, newIndex: number) => void;
|
||||
onHandleChange?: (event: UploadChangeParam) => void;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 所有 antd 组件统一用 defineAsyncComponent 异步加载
|
||||
// ⚠️ 不要再从 'ant-design-vue' 同步 import 这些组件!
|
||||
// 否则会和下面的 const 声明冲突,导致
|
||||
// "Identifier 'Xxx' has already been declared"
|
||||
// ============================================================
|
||||
|
||||
const AutoComplete = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/auto-complete'),
|
||||
);
|
||||
const Button = defineAsyncComponent(() => import('ant-design-vue/es/button'));
|
||||
const Checkbox = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/checkbox'),
|
||||
);
|
||||
const CheckboxGroup = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/checkbox').then((res) => res.CheckboxGroup),
|
||||
);
|
||||
const DatePicker = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/date-picker'),
|
||||
);
|
||||
const Divider = defineAsyncComponent(() => import('ant-design-vue/es/divider'));
|
||||
const Input = defineAsyncComponent(() => import('ant-design-vue/es/input'));
|
||||
const InputNumber = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/input-number'),
|
||||
);
|
||||
const InputPassword = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/input').then((res) => res.InputPassword),
|
||||
);
|
||||
const Mentions = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/mentions'),
|
||||
);
|
||||
const Radio = defineAsyncComponent(() => import('ant-design-vue/es/radio'));
|
||||
const RadioGroup = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/radio').then((res) => res.RadioGroup),
|
||||
);
|
||||
const RangePicker = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/date-picker').then((res) => res.RangePicker),
|
||||
);
|
||||
const Rate = defineAsyncComponent(() => import('ant-design-vue/es/rate'));
|
||||
const Select = defineAsyncComponent(() => import('ant-design-vue/es/select'));
|
||||
const Space = defineAsyncComponent(() => import('ant-design-vue/es/space'));
|
||||
const Switch = defineAsyncComponent(() => import('ant-design-vue/es/switch'));
|
||||
const Textarea = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/input').then((res) => res.Textarea),
|
||||
);
|
||||
const TimePicker = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/time-picker'),
|
||||
);
|
||||
const TreeSelect = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/tree-select'),
|
||||
);
|
||||
const Cascader = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/cascader'),
|
||||
);
|
||||
const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload'));
|
||||
const Image = defineAsyncComponent(() => import('ant-design-vue/es/image'));
|
||||
const PreviewGroup = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/image').then((res) => res.ImagePreviewGroup),
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// withDefaultPlaceholder:给组件注入 i18n placeholder + ref 透传
|
||||
// ============================================================
|
||||
|
||||
const withDefaultPlaceholder = <T extends Component>(
|
||||
component: T,
|
||||
component: Component,
|
||||
type: 'input' | 'select',
|
||||
componentProps: Recordable<any> = {},
|
||||
) => {
|
||||
return (props: any, { attrs, slots }: Omit<SetupContext, 'expose'>) => {
|
||||
const placeholder = props?.placeholder || $t(`ui.placeholder.${type}`);
|
||||
return h(component, { ...props, ...attrs, placeholder }, slots);
|
||||
return defineComponent({
|
||||
name: (component as any).name,
|
||||
inheritAttrs: false,
|
||||
setup: (props: any, { attrs, expose, slots }) => {
|
||||
const placeholder =
|
||||
props?.placeholder ||
|
||||
attrs?.placeholder ||
|
||||
$t(`ui.placeholder.${type}`);
|
||||
// 透传组件暴露的方法
|
||||
const innerRef = ref();
|
||||
expose(
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, key) => innerRef.value?.[key],
|
||||
has: (_target, key) => key in (innerRef.value || {}),
|
||||
},
|
||||
),
|
||||
);
|
||||
return () =>
|
||||
h(
|
||||
component,
|
||||
{ ...componentProps, placeholder, ...props, ...attrs, ref: innerRef },
|
||||
slots,
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
'bmp',
|
||||
'gif',
|
||||
'jpeg',
|
||||
'jpg',
|
||||
'png',
|
||||
'svg',
|
||||
'webp',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 检查是否为图片文件
|
||||
*/
|
||||
function isImageFile(file: UploadFile): boolean {
|
||||
if (file.url) {
|
||||
try {
|
||||
const pathname = new URL(file.url, 'http://localhost').pathname;
|
||||
const ext = pathname.split('.').pop()?.toLowerCase();
|
||||
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||
} catch {
|
||||
const ext = file.url?.split('.').pop()?.toLowerCase();
|
||||
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||
}
|
||||
}
|
||||
if (!file.type) {
|
||||
const ext = file.name?.split('.').pop()?.toLowerCase();
|
||||
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||
}
|
||||
return file.type.startsWith('image/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认的上传按钮插槽
|
||||
*/
|
||||
function createDefaultUploadSlots(listType: string, placeholder: string) {
|
||||
if (listType === 'picture-card') {
|
||||
return { default: () => placeholder };
|
||||
}
|
||||
return {
|
||||
default: () =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
icon: h(IconifyIcon, {
|
||||
icon: 'ant-design:upload-outlined',
|
||||
class: 'mb-1 size-4',
|
||||
}),
|
||||
},
|
||||
() => placeholder,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件的 Base64
|
||||
*/
|
||||
function getBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.addEventListener('load', () => resolve(reader.result as string));
|
||||
reader.addEventListener('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览图片
|
||||
*/
|
||||
async function previewImage(
|
||||
file: UploadFile,
|
||||
visible: Ref<boolean>,
|
||||
fileList: Ref<UploadProps['fileList']>,
|
||||
) {
|
||||
// 非图片文件直接打开链接
|
||||
if (!isImageFile(file)) {
|
||||
const url = file.url || file.preview;
|
||||
if (url) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
message.error($t('ui.formRules.previewWarning'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const [ImageComponent, PreviewGroupComponent] = await Promise.all([
|
||||
Image,
|
||||
PreviewGroup,
|
||||
]);
|
||||
|
||||
// 过滤图片文件并生成预览
|
||||
const imageFiles = (unref(fileList) || []).filter((f) => isImageFile(f));
|
||||
|
||||
for (const imgFile of imageFiles) {
|
||||
if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) {
|
||||
imgFile.preview = await getBase64(imgFile.originFileObj);
|
||||
}
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
let isUnmounted = false;
|
||||
|
||||
const currentIndex = imageFiles.findIndex((f) => f.uid === file.uid);
|
||||
|
||||
const PreviewWrapper = {
|
||||
setup() {
|
||||
return () => {
|
||||
if (isUnmounted) return null;
|
||||
return h(
|
||||
PreviewGroupComponent,
|
||||
{
|
||||
class: 'hidden',
|
||||
preview: {
|
||||
visible: visible.value,
|
||||
current: currentIndex,
|
||||
onVisibleChange: (value: boolean) => {
|
||||
visible.value = value;
|
||||
if (!value) {
|
||||
setTimeout(() => {
|
||||
if (!isUnmounted && container) {
|
||||
isUnmounted = true;
|
||||
render(null, container);
|
||||
container.remove();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
() =>
|
||||
imageFiles.map((imgFile) =>
|
||||
h(ImageComponent, {
|
||||
key: imgFile.uid,
|
||||
src: imgFile.url || imgFile.preview,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
render(h(PreviewWrapper), container);
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片裁剪操作
|
||||
*/
|
||||
function cropImage(file: File, aspectRatio: string | undefined) {
|
||||
return new Promise<Blob | string | undefined>((resolve, reject) => {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
|
||||
let isUnmounted = false;
|
||||
let objectUrl: null | string = null;
|
||||
|
||||
const open = ref(true);
|
||||
const cropperRef = ref<InstanceType<typeof VCropper> | null>(null);
|
||||
|
||||
const closeModal = () => {
|
||||
open.value = false;
|
||||
setTimeout(() => {
|
||||
if (!isUnmounted && container) {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
isUnmounted = true;
|
||||
render(null, container);
|
||||
container.remove();
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const CropperWrapper = {
|
||||
setup() {
|
||||
return () => {
|
||||
if (isUnmounted) return null;
|
||||
if (!objectUrl) {
|
||||
objectUrl = URL.createObjectURL(file);
|
||||
}
|
||||
return h(
|
||||
Modal,
|
||||
{
|
||||
open: open.value,
|
||||
title: h('div', {}, [
|
||||
$t('ui.crop.title'),
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
class: `${aspectRatio ? '' : 'hidden'} ml-2 text-sm text-gray-400 font-normal`,
|
||||
},
|
||||
$t('ui.crop.titleTip', [aspectRatio]),
|
||||
),
|
||||
]),
|
||||
centered: true,
|
||||
width: 548,
|
||||
zIndex: 9999,
|
||||
keyboard: false,
|
||||
maskClosable: false,
|
||||
closable: false,
|
||||
cancelText: $t('common.cancel'),
|
||||
okText: $t('ui.crop.confirm'),
|
||||
destroyOnClose: true,
|
||||
onOk: async () => {
|
||||
const cropper = cropperRef.value;
|
||||
if (!cropper) {
|
||||
reject(new Error('Cropper not found'));
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const dataUrl = await cropper.getCropImage();
|
||||
if (dataUrl) {
|
||||
resolve(dataUrl);
|
||||
} else {
|
||||
reject(new Error($t('ui.crop.errorTip')));
|
||||
}
|
||||
} catch {
|
||||
reject(new Error($t('ui.crop.errorTip')));
|
||||
} finally {
|
||||
closeModal();
|
||||
}
|
||||
},
|
||||
onCancel() {
|
||||
resolve('');
|
||||
closeModal();
|
||||
},
|
||||
},
|
||||
() =>
|
||||
h(VCropper, {
|
||||
ref: (refValue: any) => (cropperRef.value = refValue),
|
||||
img: objectUrl as string,
|
||||
aspectRatio,
|
||||
}),
|
||||
);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
render(h(CropperWrapper), container);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 带预览功能的上传组件
|
||||
*/
|
||||
const withPreviewUpload = () => {
|
||||
return defineComponent({
|
||||
name: 'Upload',
|
||||
emits: ['update:modelValue'],
|
||||
setup(
|
||||
props: any,
|
||||
{ attrs, slots, emit }: { attrs: any; emit: any; slots: any },
|
||||
) {
|
||||
const previewVisible = ref<boolean>(false);
|
||||
const placeholder = attrs?.placeholder || $t('ui.placeholder.upload');
|
||||
const listType = attrs?.listType || attrs?.['list-type'] || 'text';
|
||||
const fileList = ref<UploadProps['fileList']>(
|
||||
attrs?.fileList || attrs?.['file-list'] || [],
|
||||
);
|
||||
|
||||
const maxSize = computed(() => attrs?.maxSize ?? attrs?.['max-size']);
|
||||
const aspectRatio = computed(
|
||||
() => attrs?.aspectRatio ?? attrs?.['aspect-ratio'],
|
||||
);
|
||||
|
||||
const handleBeforeUpload = async (
|
||||
file: UploadFile,
|
||||
originFileList: Array<File>,
|
||||
) => {
|
||||
// 文件大小限制
|
||||
if (maxSize.value && (file.size || 0) / 1024 / 1024 > maxSize.value) {
|
||||
message.error($t('ui.formRules.sizeLimit', [maxSize.value]));
|
||||
file.status = 'removed';
|
||||
return false;
|
||||
}
|
||||
|
||||
// 图片裁剪处理
|
||||
if (
|
||||
attrs.crop &&
|
||||
!attrs.multiple &&
|
||||
originFileList[0] &&
|
||||
isImageFile(file)
|
||||
) {
|
||||
file.status = 'removed';
|
||||
const blob = await cropImage(originFileList[0], aspectRatio.value);
|
||||
if (!blob) {
|
||||
throw new Error($t('ui.crop.errorTip'));
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
|
||||
return attrs.beforeUpload?.(file) ?? true;
|
||||
};
|
||||
|
||||
const handleChange = (event: UploadChangeParam) => {
|
||||
try {
|
||||
attrs.handleChange?.(event);
|
||||
attrs.onHandleChange?.(event);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
fileList.value = event.fileList.filter(
|
||||
(file) => file.status !== 'removed',
|
||||
);
|
||||
emit(
|
||||
'update:modelValue',
|
||||
event.fileList?.length ? fileList.value : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
const handlePreview = async (file: UploadFile) => {
|
||||
previewVisible.value = true;
|
||||
await previewImage(file, previewVisible, fileList);
|
||||
};
|
||||
|
||||
const renderUploadButton = () => {
|
||||
if (attrs.disabled) return null;
|
||||
return isEmpty(slots)
|
||||
? createDefaultUploadSlots(listType, placeholder)
|
||||
: slots;
|
||||
};
|
||||
|
||||
// 拖拽排序
|
||||
const draggable = computed(
|
||||
() => (attrs.draggable ?? false) && !attrs.disabled,
|
||||
);
|
||||
const uploadId = `upload-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const sortableInstance = ref<null | Sortable>(null);
|
||||
|
||||
const styleId = `upload-drag-style-${uploadId}`;
|
||||
|
||||
function injectDragStyle() {
|
||||
if (!document.querySelector(`[id="${styleId}"]`)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
[data-upload-id="${uploadId}"] .ant-upload-list-item { cursor: move; }
|
||||
[data-upload-id="${uploadId}"] .ant-upload-list-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
|
||||
`;
|
||||
document.head?.append(style);
|
||||
}
|
||||
}
|
||||
|
||||
function removeDragStyle() {
|
||||
document.querySelector(`[id="${styleId}"]`)?.remove();
|
||||
}
|
||||
|
||||
async function initSortable(retryCount = 0) {
|
||||
if (!draggable.value) return;
|
||||
|
||||
injectDragStyle();
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const container = document.querySelector(
|
||||
`[data-upload-id="${uploadId}"] .ant-upload-list`,
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (!container) {
|
||||
if (retryCount < 5) {
|
||||
setTimeout(() => initSortable(retryCount + 1), 200);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { initializeSortable } = useSortable(container, {
|
||||
animation: 300,
|
||||
delay: 400,
|
||||
delayOnTouchOnly: true,
|
||||
filter:
|
||||
'.ant-upload-select, .ant-upload-list-item-error, .ant-upload-list-item-uploading',
|
||||
onEnd: (evt: { oldIndex?: number; newIndex?: number }) => {
|
||||
const { oldIndex, newIndex } = evt;
|
||||
if (
|
||||
oldIndex === undefined ||
|
||||
newIndex === undefined ||
|
||||
oldIndex === newIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = [...(fileList.value || [])];
|
||||
const [movedItem] = list.splice(oldIndex, 1);
|
||||
if (movedItem) {
|
||||
list.splice(newIndex, 0, movedItem);
|
||||
fileList.value = list;
|
||||
}
|
||||
|
||||
attrs.onDragSort?.(oldIndex, newIndex);
|
||||
emit('update:modelValue', fileList.value);
|
||||
},
|
||||
});
|
||||
|
||||
sortableInstance.value = await initializeSortable();
|
||||
}
|
||||
|
||||
// 监听表单值变化
|
||||
watch(
|
||||
() => attrs.modelValue,
|
||||
(res) => {
|
||||
fileList.value = res;
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(initSortable);
|
||||
onUnmounted(() => {
|
||||
sortableInstance.value?.destroy();
|
||||
removeDragStyle();
|
||||
});
|
||||
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{ 'data-upload-id': uploadId, class: 'w-full' },
|
||||
h(
|
||||
Upload,
|
||||
{
|
||||
...props,
|
||||
...attrs,
|
||||
fileList: fileList.value,
|
||||
beforeUpload: handleBeforeUpload,
|
||||
onChange: handleChange,
|
||||
onPreview: handlePreview,
|
||||
},
|
||||
renderUploadButton() as any,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 表单组件在这里加
|
||||
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
|
||||
export type ComponentType =
|
||||
| 'ApiCascader'
|
||||
| 'ApiSelect'
|
||||
| 'ApiTreeSelect'
|
||||
| 'AutoComplete'
|
||||
@@ -61,7 +622,8 @@ export type ComponentType =
|
||||
| 'DatePicker'
|
||||
| 'DefaultButton'
|
||||
| 'Divider'
|
||||
| 'GalleryPickLink' // OA 场景表单的图片/文件素材选择(透传 acceptTypes 过滤类型)
|
||||
/** OA 场景表单的图片/文件素材选择(透传 acceptTypes 过滤类型) */
|
||||
| 'GalleryPickLink'
|
||||
| 'IconPicker'
|
||||
| 'Input'
|
||||
| 'InputNumber'
|
||||
@@ -85,66 +647,89 @@ export type ComponentType =
|
||||
| 'UploadDraggerPaste'
|
||||
| BaseFormComponentType;
|
||||
|
||||
/**
|
||||
* 与 {@link ComponentType} 中注册的组件名一一对应,便于 Schema 上 `component` + `componentProps` 联动提示
|
||||
*/
|
||||
export interface ComponentPropsMap {
|
||||
ApiCascader: ApiComponentSharedProps & CascaderProps;
|
||||
ApiSelect: ApiComponentSharedProps & SelectProps;
|
||||
ApiTreeSelect: ApiComponentSharedProps & TreeSelectProps;
|
||||
AutoComplete: AutoCompleteProps;
|
||||
Cascader: CascaderProps;
|
||||
Checkbox: CheckboxProps;
|
||||
CheckboxGroup: CheckboxGroupProps;
|
||||
DatePicker: DatePickerProps;
|
||||
DefaultButton: ButtonProps;
|
||||
Divider: DividerProps;
|
||||
IconPicker: IconPickerProps;
|
||||
Input: InputProps;
|
||||
InputNumber: InputNumberProps;
|
||||
InputPassword: InputProps;
|
||||
Mentions: MentionsProps;
|
||||
PrimaryButton: ButtonProps;
|
||||
Radio: RadioProps;
|
||||
RadioGroup: RadioGroupProps;
|
||||
RangePicker: RangePickerProps;
|
||||
Rate: RateProps;
|
||||
Select: SelectProps;
|
||||
Space: SpaceProps;
|
||||
Switch: SwitchProps;
|
||||
Textarea: TextAreaProps;
|
||||
TimePicker: TimePickerProps;
|
||||
TreeSelect: TreeSelectProps;
|
||||
Upload: AdapterUploadProps;
|
||||
}
|
||||
|
||||
async function initComponentAdapter() {
|
||||
const components: Partial<Record<ComponentType, Component>> = {
|
||||
// 如果你的组件体积比较大,可以使用异步加载
|
||||
// Button: () =>
|
||||
// import('xxx').then((res) => res.Button),
|
||||
ApiSelect: (props, { attrs, slots }) => {
|
||||
return h(
|
||||
ApiComponent,
|
||||
{
|
||||
placeholder: $t('ui.placeholder.select'),
|
||||
...props,
|
||||
...attrs,
|
||||
component: Select,
|
||||
loadingSlot: 'suffixIcon',
|
||||
visibleEvent: 'onDropdownVisibleChange',
|
||||
modelPropName: 'value',
|
||||
},
|
||||
slots,
|
||||
);
|
||||
},
|
||||
ApiTreeSelect: (props, { attrs, slots }) => {
|
||||
return h(
|
||||
ApiComponent,
|
||||
{
|
||||
placeholder: $t('ui.placeholder.select'),
|
||||
...props,
|
||||
...attrs,
|
||||
component: TreeSelect,
|
||||
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
||||
loadingSlot: 'suffixIcon',
|
||||
modelPropName: 'value',
|
||||
optionsPropName: 'treeData',
|
||||
visibleEvent: 'onVisibleChange',
|
||||
},
|
||||
slots,
|
||||
);
|
||||
},
|
||||
|
||||
ApiCascader: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||
component: Cascader,
|
||||
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
||||
loadingSlot: 'suffixIcon',
|
||||
modelPropName: 'value',
|
||||
visibleEvent: 'onVisibleChange',
|
||||
}),
|
||||
ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||
component: Select,
|
||||
loadingSlot: 'suffixIcon',
|
||||
modelPropName: 'value',
|
||||
visibleEvent: 'onVisibleChange',
|
||||
}),
|
||||
ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||
component: TreeSelect,
|
||||
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
||||
loadingSlot: 'suffixIcon',
|
||||
modelPropName: 'value',
|
||||
optionsPropName: 'treeData',
|
||||
visibleEvent: 'onVisibleChange',
|
||||
}),
|
||||
AutoComplete,
|
||||
Cascader,
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
DatePicker,
|
||||
// 自定义默认按钮
|
||||
DefaultButton: (props, { attrs, slots }) => {
|
||||
DefaultButton: (props: any, { attrs, slots }: any) => {
|
||||
return h(Button, { ...props, attrs, type: 'default' }, slots);
|
||||
},
|
||||
Divider,
|
||||
IconPicker: (props, { attrs, slots }) => {
|
||||
return h(
|
||||
IconPicker,
|
||||
{ iconSlot: 'addonAfter', inputComponent: Input, ...props, ...attrs },
|
||||
slots,
|
||||
);
|
||||
},
|
||||
IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
|
||||
iconSlot: 'addonAfter',
|
||||
inputComponent: Input,
|
||||
modelValueProp: 'value',
|
||||
}),
|
||||
Input: withDefaultPlaceholder(Input, 'input'),
|
||||
InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
|
||||
InputNumber: withDefaultPlaceholder(InputNumber, 'input', {
|
||||
style: { width: '100%' },
|
||||
}),
|
||||
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
|
||||
Mentions: withDefaultPlaceholder(Mentions, 'input'),
|
||||
// 自定义主要按钮
|
||||
PrimaryButton: (props, { attrs, slots }) => {
|
||||
PrimaryButton: (props: any, { attrs, slots }: any) => {
|
||||
return h(Button, { ...props, attrs, type: 'primary' }, slots);
|
||||
},
|
||||
Radio,
|
||||
@@ -157,10 +742,10 @@ async function initComponentAdapter() {
|
||||
Textarea: withDefaultPlaceholder(Textarea, 'input'),
|
||||
TimePicker,
|
||||
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
|
||||
Upload,
|
||||
Upload: withPreviewUpload(),
|
||||
};
|
||||
|
||||
// 自动注册自定义组件
|
||||
// 自动注册 components/form 与 views/**/components/form 下的业务表单组件(如 Editor、Avatar)
|
||||
registerComponent(components);
|
||||
// 将组件注册到全局共享状态中
|
||||
globalShareState.setComponents(components);
|
||||
|
||||
@@ -1,49 +1,84 @@
|
||||
import type {
|
||||
VbenFormProps as FormProps,
|
||||
VbenFormSchema as FormSchema,
|
||||
VbenFormProps,
|
||||
FormValues,
|
||||
} from '@vben/common-ui';
|
||||
|
||||
import type { ComponentType } from './component';
|
||||
import type { ComponentPropsMap, ComponentType } from './component';
|
||||
|
||||
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
setupVbenForm<ComponentType>({
|
||||
config: {
|
||||
// ant design vue组件库默认都是 v-model:value
|
||||
baseModelPropName: 'value',
|
||||
async function initSetupVbenForm() {
|
||||
setupVbenForm<ComponentType>({
|
||||
config: {
|
||||
// ant design vue组件库默认都是 v-model:value
|
||||
baseModelPropName: 'value',
|
||||
|
||||
// 一些组件是 v-model:checked 或者 v-model:fileList
|
||||
modelPropNameMap: {
|
||||
Checkbox: 'checked',
|
||||
Radio: 'checked',
|
||||
Switch: 'checked',
|
||||
Upload: 'fileList',
|
||||
UploadImage: 'modelValue',
|
||||
UploadOssFile: 'modelValue',
|
||||
// 一些组件是 v-model:checked 或者 v-model:fileList
|
||||
modelPropNameMap: {
|
||||
Checkbox: 'checked',
|
||||
Radio: 'checked',
|
||||
Switch: 'checked',
|
||||
Upload: 'fileList',
|
||||
UploadImage: 'modelValue',
|
||||
UploadOssFile: 'modelValue',
|
||||
},
|
||||
},
|
||||
},
|
||||
defineRules: {
|
||||
// 输入项目必填国际化适配
|
||||
required: (value, _params, ctx) => {
|
||||
if (value === undefined || value === null || value.length === 0) {
|
||||
return $t('ui.formRules.required', [ctx.label]);
|
||||
}
|
||||
return true;
|
||||
rules: {
|
||||
// 输入项目必填国际化适配
|
||||
required: (value, _params, ctx) => {
|
||||
if (value === undefined || value === null || value.length === 0) {
|
||||
return $t('ui.formRules.required', [ctx.label]);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
// 选择项目必填国际化适配
|
||||
selectRequired: (value, _params, ctx) => {
|
||||
if (value === undefined || value === null) {
|
||||
return $t('ui.formRules.selectRequired', [ctx.label]);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* 大陆手机号:必填 + 11 位且号段 13-19(与后端 UtilsService::checkPhone 一致)
|
||||
* 表单字段 rules: 'mobile' 即可,无需再叠 required
|
||||
*/
|
||||
mobile: (value, _params, ctx) => {
|
||||
const phone = typeof value === 'string' ? value.trim() : value;
|
||||
if (phone === undefined || phone === null || phone === '') {
|
||||
return $t('ui.formRules.required', [ctx.label]);
|
||||
}
|
||||
if (!/^1[3456789]\d{9}$/.test(String(phone))) {
|
||||
return $t('ui.formRules.mobile', [ctx.label]);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
// 选择项目必填国际化适配
|
||||
selectRequired: (value, _params, ctx) => {
|
||||
if (value === undefined || value === null) {
|
||||
return $t('ui.formRules.selectRequired', [ctx.label]);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const useVbenForm = useForm<ComponentType>;
|
||||
function useVbenForm<
|
||||
TFormValues extends FormValues = FormValues,
|
||||
TSubmitValues extends FormValues = TFormValues,
|
||||
>(
|
||||
options: FormProps<
|
||||
ComponentType,
|
||||
ComponentPropsMap,
|
||||
TFormValues,
|
||||
TSubmitValues
|
||||
>,
|
||||
) {
|
||||
return useForm<TFormValues, ComponentType, ComponentPropsMap, TSubmitValues>(
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export { useVbenForm, z };
|
||||
export { initSetupVbenForm, useVbenForm, z };
|
||||
|
||||
export type VbenFormSchema = FormSchema<ComponentType>;
|
||||
export type { VbenFormProps };
|
||||
export type VbenFormSchema<TValues extends FormValues = FormValues> =
|
||||
FormSchema<ComponentType, ComponentPropsMap, TValues>;
|
||||
export type VbenFormProps<
|
||||
TFormValues extends FormValues = FormValues,
|
||||
TSubmitValues extends FormValues = TFormValues,
|
||||
> = FormProps<ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { FormValues } from '@vben/common-ui';
|
||||
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
|
||||
|
||||
import type { ComponentPropsMap, ComponentType } from './component';
|
||||
|
||||
import { h } from 'vue';
|
||||
|
||||
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
|
||||
import {
|
||||
setupVbenVxeTable,
|
||||
useVbenVxeGrid as useGrid,
|
||||
} from '@vben/plugins/vxe-table';
|
||||
|
||||
import { Button, Image } from 'ant-design-vue';
|
||||
|
||||
@@ -33,14 +41,15 @@ setupVbenVxeTable({
|
||||
round: true,
|
||||
showOverflow: true,
|
||||
size: 'small',
|
||||
},
|
||||
} as VxeTableGridOptions,
|
||||
});
|
||||
|
||||
// 表格配置项可以用 cellRender: { name: 'CellImage' },
|
||||
vxeUI.renderer.add('CellImage', {
|
||||
renderTableDefault(_renderOpts, params) {
|
||||
renderTableDefault(renderOpts, params) {
|
||||
const { props } = renderOpts;
|
||||
const { column, row } = params;
|
||||
return h(Image, { src: row[column.field] });
|
||||
return h(Image, { src: row[column.field], ...props });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -62,6 +71,23 @@ setupVbenVxeTable({
|
||||
useVbenForm,
|
||||
});
|
||||
|
||||
export { useVbenVxeGrid };
|
||||
export const useVbenVxeGrid = <
|
||||
T extends Record<string, any>,
|
||||
TFormValues extends FormValues = FormValues,
|
||||
TSubmitValues extends FormValues = TFormValues,
|
||||
>(
|
||||
...rest: Parameters<
|
||||
typeof useGrid<
|
||||
T,
|
||||
ComponentType,
|
||||
ComponentPropsMap,
|
||||
TFormValues,
|
||||
TSubmitValues
|
||||
>
|
||||
>
|
||||
) =>
|
||||
useGrid<T, ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>(
|
||||
...rest,
|
||||
);
|
||||
|
||||
export type * from '@vben/plugins/vxe-table';
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* 该文件可自行根据业务逻辑进行调整
|
||||
*/
|
||||
import type { HttpResponse } from '@vben/request';
|
||||
import type { RequestClientOptions } from '@vben/request';
|
||||
|
||||
import { useAppConfig } from '@vben/hooks';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import {
|
||||
authenticateResponseInterceptor,
|
||||
defaultResponseInterceptor,
|
||||
errorMessageResponseInterceptor,
|
||||
RequestClient,
|
||||
} from '@vben/request';
|
||||
@@ -29,8 +30,9 @@ import {downloadByData} from "#/util/tool";
|
||||
|
||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||
|
||||
function createRequestClient(baseURL: string) {
|
||||
function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||
const client = new RequestClient({
|
||||
...options,
|
||||
baseURL,
|
||||
});
|
||||
|
||||
@@ -174,7 +176,9 @@ function createRequestClient(baseURL: string) {
|
||||
return client;
|
||||
}
|
||||
|
||||
export const requestClient = createRequestClient(apiURL);
|
||||
export const requestClient = createRequestClient(apiURL, {
|
||||
responseReturn: 'result',
|
||||
});
|
||||
|
||||
export const baseRequestClient = new RequestClient({ baseURL: apiURL });
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createApp, watchEffect } from 'vue';
|
||||
|
||||
import { registerAccessDirective } from '@vben/access';
|
||||
import { registerLoadingDirective } from '@vben/common-ui/es/loading';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { initStores } from '@vben/stores';
|
||||
import '@vben/styles';
|
||||
@@ -11,6 +12,7 @@ import { useTitle } from '@vueuse/core';
|
||||
import { $t, setupI18n } from '#/locales';
|
||||
|
||||
import { initComponentAdapter } from './adapter/component';
|
||||
import { initSetupVbenForm } from './adapter/form';
|
||||
import App from './app.vue';
|
||||
import { router } from './router';
|
||||
|
||||
@@ -18,8 +20,26 @@ async function bootstrap(namespace: string) {
|
||||
// 初始化组件适配器
|
||||
await initComponentAdapter();
|
||||
|
||||
// 初始化表单组件
|
||||
await initSetupVbenForm();
|
||||
|
||||
// // 设置弹窗的默认配置
|
||||
// setDefaultModalProps({
|
||||
// fullscreenButton: false,
|
||||
// });
|
||||
// // 设置抽屉的默认配置
|
||||
// setDefaultDrawerProps({
|
||||
// zIndex: 1020,
|
||||
// });
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
// 注册v-loading指令
|
||||
registerLoadingDirective(app, {
|
||||
loading: 'loading', // 在这里可以自定义指令名称,也可以明确提供false表示不注册这个指令
|
||||
spinning: 'spinning',
|
||||
});
|
||||
|
||||
// 国际化 i18n 配置
|
||||
await setupI18n(app);
|
||||
|
||||
@@ -29,9 +49,17 @@ async function bootstrap(namespace: string) {
|
||||
// 安装权限指令
|
||||
registerAccessDirective(app);
|
||||
|
||||
// 初始化 tippy
|
||||
const { initTippy } = await import('@vben/common-ui/es/tippy');
|
||||
initTippy(app);
|
||||
|
||||
// 配置路由及路由守卫
|
||||
app.use(router);
|
||||
|
||||
// 配置Motion插件
|
||||
const { MotionPlugin } = await import('@vben/plugins/motion');
|
||||
app.use(MotionPlugin);
|
||||
|
||||
// 动态更新标题
|
||||
watchEffect(() => {
|
||||
if (preferences.app.dynamicTitle) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { SpreadsheetCommand } from '../types';
|
||||
|
||||
|
||||
|
||||
export function createSetCellValueCommand(
|
||||
|
||||
undo: () => void | Promise<void>,
|
||||
|
||||
redo: () => void | Promise<void>,
|
||||
|
||||
): SpreadsheetCommand {
|
||||
|
||||
import type { SpreadsheetCommand } from '../types';
|
||||
|
||||
export function createSetCellValueCommand(
|
||||
undo: () => void | Promise<void>,
|
||||
redo: () => void | Promise<void>,
|
||||
): SpreadsheetCommand {
|
||||
return {
|
||||
label: 'setCellValue',
|
||||
undo,
|
||||
redo,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { CustomComponentType } from './types';
|
||||
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import type { CustomComponentType } from './types';
|
||||
|
||||
import { getFileNameWithoutExtension, toPascalCase } from '#/util/tool';
|
||||
|
||||
const componentMap = new Map<CustomComponentType | string, Component>();
|
||||
|
||||
@@ -1,346 +1,346 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
|
||||
|
||||
import { useDebounceFn, useVModel } from '@vueuse/core';
|
||||
|
||||
import { Avatar, Empty, Input, Popover, Spin, Tabs, Tag } from 'ant-design-vue';
|
||||
|
||||
|
||||
|
||||
import { searchApiLogUsers } from '#/views/log/api-log/api';
|
||||
|
||||
|
||||
|
||||
defineOptions({
|
||||
|
||||
name: 'ApiLogUserPicker',
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
export type ApiLogUserFilter = {
|
||||
|
||||
id: number;
|
||||
|
||||
user_type: 'admin' | 'patient' | 'doctor';
|
||||
|
||||
platform_type: 0 | 1 | 2;
|
||||
|
||||
name: string;
|
||||
|
||||
role_label: string;
|
||||
|
||||
avatar?: string;
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
type ApiLogUserOption = ApiLogUserFilter;
|
||||
|
||||
|
||||
|
||||
type UserTab = 'admin' | 'patient' | 'doctor';
|
||||
|
||||
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
value?: ApiLogUserFilter;
|
||||
|
||||
disabled?: boolean;
|
||||
|
||||
onPlatformTypeChange?: (platformType: 0 | 1 | 2) => void;
|
||||
|
||||
}>();
|
||||
|
||||
|
||||
|
||||
const emits = defineEmits<{
|
||||
|
||||
'update:value': [value: ApiLogUserFilter | undefined];
|
||||
|
||||
}>();
|
||||
|
||||
|
||||
|
||||
const mValue = useVModel(props, 'value', emits, { passive: true });
|
||||
|
||||
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const searchKeyword = ref('');
|
||||
|
||||
const activeTab = ref<UserTab>('admin');
|
||||
|
||||
const options = ref<ApiLogUserOption[]>([]);
|
||||
|
||||
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
|
||||
|
||||
|
||||
|
||||
const TAB_ITEMS = [
|
||||
|
||||
{ key: 'admin', label: '后台用户' },
|
||||
|
||||
{ key: 'patient', label: '患者端用户' },
|
||||
|
||||
{ key: 'doctor', label: '医生端用户' },
|
||||
|
||||
] as const;
|
||||
|
||||
|
||||
|
||||
const searchPlaceholder = computed(() => {
|
||||
|
||||
if (activeTab.value === 'admin') return '搜索手机号或昵称';
|
||||
|
||||
if (activeTab.value === 'patient') return '搜索用户名字';
|
||||
|
||||
return '搜索名字或手机号';
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
async function fetchOptions() {
|
||||
|
||||
const keyword = searchKeyword.value.trim();
|
||||
|
||||
if (!keyword) {
|
||||
|
||||
options.value = [];
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
|
||||
const result = await searchApiLogUsers({
|
||||
|
||||
user_type: activeTab.value,
|
||||
|
||||
keyword,
|
||||
|
||||
pageSize: 24,
|
||||
|
||||
});
|
||||
|
||||
options.value = result?.items ?? [];
|
||||
|
||||
} finally {
|
||||
|
||||
loading.value = false;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const debouncedFetch = useDebounceFn(fetchOptions, 300);
|
||||
|
||||
|
||||
|
||||
watch([searchKeyword, activeTab], () => {
|
||||
|
||||
debouncedFetch();
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
function selectOption(item: ApiLogUserOption) {
|
||||
|
||||
mValue.value = {
|
||||
|
||||
id: item.id,
|
||||
|
||||
user_type: item.user_type,
|
||||
|
||||
platform_type: item.platform_type,
|
||||
|
||||
name: item.name,
|
||||
|
||||
role_label: item.role_label,
|
||||
|
||||
avatar: item.avatar,
|
||||
|
||||
};
|
||||
|
||||
props.onPlatformTypeChange?.(item.platform_type);
|
||||
|
||||
open.value = false;
|
||||
|
||||
searchKeyword.value = '';
|
||||
|
||||
options.value = [];
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function clearSelection(e: Event) {
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
mValue.value = undefined;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function onOpenChange(next: boolean) {
|
||||
|
||||
if (props.disabled) return;
|
||||
|
||||
open.value = next;
|
||||
|
||||
if (next) {
|
||||
|
||||
searchKeyword.value = '';
|
||||
|
||||
options.value = [];
|
||||
|
||||
nextTick(() => searchInputRef.value?.focus?.());
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
watch(
|
||||
|
||||
() => props.disabled,
|
||||
|
||||
(disabled) => {
|
||||
|
||||
if (disabled) open.value = false;
|
||||
|
||||
},
|
||||
|
||||
);
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
<Popover
|
||||
|
||||
:open="open"
|
||||
|
||||
trigger="click"
|
||||
|
||||
placement="bottomLeft"
|
||||
|
||||
overlay-class-name="api-log-user-picker-popover"
|
||||
|
||||
@open-change="onOpenChange"
|
||||
|
||||
>
|
||||
|
||||
<template #content>
|
||||
|
||||
<div class="api-log-user-panel">
|
||||
|
||||
<Tabs v-model:active-key="activeTab" size="small" class="api-log-user-tabs">
|
||||
|
||||
<Tabs.TabPane
|
||||
|
||||
v-for="tab in TAB_ITEMS"
|
||||
|
||||
:key="tab.key"
|
||||
|
||||
:tab="tab.label"
|
||||
|
||||
/>
|
||||
|
||||
</Tabs>
|
||||
|
||||
<Input
|
||||
|
||||
ref="searchInputRef"
|
||||
|
||||
v-model:value="searchKeyword"
|
||||
|
||||
allow-clear
|
||||
|
||||
:placeholder="searchPlaceholder"
|
||||
|
||||
class="api-log-user-search"
|
||||
|
||||
/>
|
||||
|
||||
<Spin :spinning="loading">
|
||||
|
||||
<div v-if="options.length" class="api-log-user-grid">
|
||||
|
||||
<button
|
||||
|
||||
v-for="item in options"
|
||||
|
||||
:key="`${item.user_type}-${item.id}`"
|
||||
|
||||
type="button"
|
||||
|
||||
class="api-log-user-card"
|
||||
|
||||
:class="{
|
||||
|
||||
active:
|
||||
|
||||
mValue?.id === item.id && mValue?.user_type === item.user_type,
|
||||
|
||||
}"
|
||||
|
||||
@click="selectOption(item)"
|
||||
|
||||
>
|
||||
|
||||
<Avatar :src="item.avatar" :size="30">
|
||||
|
||||
{{ (item.name || '?').charAt(0) }}
|
||||
|
||||
</Avatar>
|
||||
|
||||
<div class="api-log-user-card-id">#{{ item.id }}</div>
|
||||
|
||||
<div class="api-log-user-card-name" :title="item.name">
|
||||
|
||||
{{ item.name }}
|
||||
|
||||
</div>
|
||||
|
||||
<Tag class="api-log-user-card-role" :bordered="false">
|
||||
|
||||
{{ item.role_label }}
|
||||
|
||||
</Tag>
|
||||
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<Empty
|
||||
|
||||
v-else
|
||||
|
||||
:description="searchKeyword.trim() ? '无匹配用户' : '请输入关键词搜索'"
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { useDebounceFn, useVModel } from '@vueuse/core';
|
||||
import { Avatar, Empty, Input, Popover, Spin, Tabs, Tag } from 'ant-design-vue';
|
||||
|
||||
import { searchApiLogUsers } from '#/views/log/api-log/api';
|
||||
|
||||
defineOptions({
|
||||
name: 'ApiLogUserPicker',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
export type ApiLogUserFilter = {
|
||||
id: number;
|
||||
user_type: 'admin' | 'patient' | 'doctor';
|
||||
platform_type: 0 | 1 | 2;
|
||||
name: string;
|
||||
role_label: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
type ApiLogUserOption = ApiLogUserFilter;
|
||||
|
||||
type UserTab = 'admin' | 'patient' | 'doctor';
|
||||
|
||||
const props = defineProps<{
|
||||
value?: ApiLogUserFilter;
|
||||
disabled?: boolean;
|
||||
onPlatformTypeChange?: (platformType: 0 | 1 | 2) => void;
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
'update:value': [value: ApiLogUserFilter | undefined];
|
||||
}>();
|
||||
|
||||
const mValue = useVModel(props, 'value', emits, { passive: true });
|
||||
|
||||
const open = ref(false);
|
||||
const loading = ref(false);
|
||||
const searchKeyword = ref('');
|
||||
const activeTab = ref<UserTab>('admin');
|
||||
const options = ref<ApiLogUserOption[]>([]);
|
||||
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
|
||||
|
||||
const TAB_ITEMS = [
|
||||
{ key: 'admin', label: '后台用户' },
|
||||
{ key: 'patient', label: '患者端用户' },
|
||||
{ key: 'doctor', label: '医生端用户' },
|
||||
] as const;
|
||||
|
||||
const searchPlaceholder = computed(() => {
|
||||
if (activeTab.value === 'admin') return '搜索手机号或昵称';
|
||||
if (activeTab.value === 'patient') return '搜索用户名字';
|
||||
return '搜索名字或手机号';
|
||||
});
|
||||
|
||||
async function fetchOptions() {
|
||||
const keyword = searchKeyword.value.trim();
|
||||
if (!keyword) {
|
||||
options.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await searchApiLogUsers({
|
||||
user_type: activeTab.value,
|
||||
keyword,
|
||||
pageSize: 24,
|
||||
});
|
||||
options.value = result?.items ?? [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedFetch = useDebounceFn(fetchOptions, 300);
|
||||
|
||||
watch([searchKeyword, activeTab], () => {
|
||||
debouncedFetch();
|
||||
});
|
||||
|
||||
function selectOption(item: ApiLogUserOption) {
|
||||
mValue.value = {
|
||||
id: item.id,
|
||||
user_type: item.user_type,
|
||||
platform_type: item.platform_type,
|
||||
name: item.name,
|
||||
role_label: item.role_label,
|
||||
avatar: item.avatar,
|
||||
};
|
||||
props.onPlatformTypeChange?.(item.platform_type);
|
||||
open.value = false;
|
||||
searchKeyword.value = '';
|
||||
options.value = [];
|
||||
}
|
||||
|
||||
function clearSelection(e: Event) {
|
||||
e.stopPropagation();
|
||||
mValue.value = undefined;
|
||||
}
|
||||
|
||||
function onOpenChange(next: boolean) {
|
||||
if (props.disabled) return;
|
||||
open.value = next;
|
||||
if (next) {
|
||||
searchKeyword.value = '';
|
||||
options.value = [];
|
||||
nextTick(() => searchInputRef.value?.focus?.());
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(disabled) => {
|
||||
if (disabled) open.value = false;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover
|
||||
:open="open"
|
||||
trigger="click"
|
||||
placement="bottomLeft"
|
||||
overlay-class-name="api-log-user-picker-popover"
|
||||
@open-change="onOpenChange"
|
||||
>
|
||||
<template #content>
|
||||
<div class="api-log-user-panel">
|
||||
<Tabs v-model:active-key="activeTab" size="small" class="api-log-user-tabs">
|
||||
<Tabs.TabPane
|
||||
v-for="tab in TAB_ITEMS"
|
||||
:key="tab.key"
|
||||
:tab="tab.label"
|
||||
/>
|
||||
</Tabs>
|
||||
<Input
|
||||
ref="searchInputRef"
|
||||
v-model:value="searchKeyword"
|
||||
allow-clear
|
||||
:placeholder="searchPlaceholder"
|
||||
class="api-log-user-search"
|
||||
/>
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="options.length" class="api-log-user-grid">
|
||||
<button
|
||||
v-for="item in options"
|
||||
:key="`${item.user_type}-${item.id}`"
|
||||
type="button"
|
||||
class="api-log-user-card"
|
||||
:class="{
|
||||
active:
|
||||
mValue?.id === item.id && mValue?.user_type === item.user_type,
|
||||
}"
|
||||
@click="selectOption(item)"
|
||||
>
|
||||
<Avatar :src="item.avatar" :size="30">
|
||||
{{ (item.name || '?').charAt(0) }}
|
||||
</Avatar>
|
||||
<div class="api-log-user-card-id">#{{ item.id }}</div>
|
||||
<div class="api-log-user-card-name" :title="item.name">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<Tag class="api-log-user-card-role" :bordered="false">
|
||||
{{ item.role_label }}
|
||||
</Tag>
|
||||
</button>
|
||||
</div>
|
||||
<Empty
|
||||
v-else
|
||||
:description="searchKeyword.trim() ? '无匹配用户' : '请输入关键词搜索'"
|
||||
class="api-log-user-empty"
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
</template>
|
||||
<div class="api-log-user-trigger" :class="{ disabled: disabled }">
|
||||
<div v-if="mValue" class="api-log-selected-card">
|
||||
<Avatar :src="mValue.avatar" :size="36">
|
||||
{{ (mValue.name || '?').charAt(0) }}
|
||||
</Avatar>
|
||||
<div class="api-log-selected-meta">
|
||||
<div class="api-log-selected-name">{{ mValue.name }}</div>
|
||||
<div class="api-log-selected-sub">
|
||||
#{{ mValue.id }} · {{ mValue.role_label }}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="!disabled"
|
||||
class="api-log-clear-btn"
|
||||
@click="clearSelection"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="api-log-trigger-placeholder">请选择操作用户</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './picker-card-theme.scss' as theme;
|
||||
|
||||
.api-log-user-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.api-log-user-trigger.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.api-log-user-trigger:not(.disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.api-log-selected-card {
|
||||
@include theme.picker-selected-card;
|
||||
}
|
||||
|
||||
.api-log-selected-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.api-log-selected-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #1d2129;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.api-log-selected-sub {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.api-log-trigger-placeholder {
|
||||
@include theme.picker-trigger-placeholder;
|
||||
}
|
||||
|
||||
.api-log-clear-btn {
|
||||
@include theme.picker-clear-btn;
|
||||
}
|
||||
|
||||
.dark {
|
||||
.api-log-selected-card {
|
||||
@include theme.picker-selected-card-dark-props;
|
||||
}
|
||||
|
||||
.api-log-selected-name {
|
||||
@include theme.picker-text-primary-dark;
|
||||
}
|
||||
|
||||
.api-log-selected-sub {
|
||||
@include theme.picker-text-secondary-dark;
|
||||
}
|
||||
|
||||
.api-log-trigger-placeholder {
|
||||
@include theme.picker-placeholder-dark-props;
|
||||
}
|
||||
|
||||
.api-log-clear-btn {
|
||||
@include theme.picker-clear-dark-props;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
@use './picker-card-theme.scss' as theme;
|
||||
|
||||
.api-log-user-picker-popover {
|
||||
.ant-popover-inner {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.api-log-user-panel {
|
||||
width: 540px;
|
||||
max-width: 86vw;
|
||||
}
|
||||
|
||||
.api-log-user-tabs {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.api-log-user-search {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.api-log-user-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(118px, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.api-log-user-card {
|
||||
@include theme.picker-card-base;
|
||||
}
|
||||
|
||||
.api-log-user-card-id {
|
||||
@include theme.picker-card-sub;
|
||||
}
|
||||
|
||||
.api-log-user-card-name {
|
||||
@include theme.picker-card-name;
|
||||
}
|
||||
|
||||
.api-log-user-card-role {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
padding: 0 6px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.api-log-user-empty {
|
||||
margin: 16px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .api-log-user-picker-popover {
|
||||
.api-log-user-card {
|
||||
@include theme.picker-card-dark-props;
|
||||
}
|
||||
|
||||
.api-log-user-card-name {
|
||||
@include theme.picker-text-primary-dark;
|
||||
}
|
||||
|
||||
.api-log-user-card-id {
|
||||
@include theme.picker-text-secondary-dark;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { preferences } from '@vben/preferences';
|
||||
|
||||
import { QuillEditor } from '@vueup/vue-quill';
|
||||
// eslint-disable-next-line n/no-extraneous-import
|
||||
import '@vueup/vue-quill/dist/vue-quill.snow.css';
|
||||
import { useVModel } from '@vueuse/core';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
@@ -11,7 +11,9 @@ import { message } from 'ant-design-vue';
|
||||
import { uploadFile } from '#/api/core/upload';
|
||||
import { uploadToOss } from '#/utils/oss-upload';
|
||||
import { prepareImageForUpload } from '#/utils/prepare-image-upload';
|
||||
import { preferences } from '@vben/preferences';
|
||||
|
||||
// eslint-disable-next-line n/no-extraneous-import
|
||||
import '@vueup/vue-quill/dist/vue-quill.snow.css';
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
@@ -140,26 +142,26 @@ const insertImageToEditor = (quill: any, imageUrl: string): void => {
|
||||
};
|
||||
/**
|
||||
* 图片上传函数
|
||||
*
|
||||
*
|
||||
* 该函数根据preferences配置的上传方式,选择使用OSS直传或后端上传:
|
||||
* - 'direct': OSS直传模式,文件直接从浏览器上传到阿里云OSS
|
||||
* - 'backend': 后端上传模式,文件先上传到后端服务器,再由后端上传到OSS
|
||||
*
|
||||
*
|
||||
* 上传方式说明:
|
||||
* 1. OSS直传(direct):
|
||||
* - 调用uploadToOss函数,直接上传到OSS
|
||||
* - 减少服务器负载,上传速度更快
|
||||
* - 支持上传进度回调(如果需要可以添加)
|
||||
*
|
||||
*
|
||||
* 2. 后端上传(backend):
|
||||
* - 调用uploadFile API,通过后端服务器上传
|
||||
* - 兼容现有功能,所有上传逻辑由后端统一处理
|
||||
*
|
||||
*
|
||||
* 兼容性处理:
|
||||
* - 两种上传方式返回的数据结构保持一致:{ url: string }
|
||||
* - 上传成功或失败都会显示相应的提示信息
|
||||
* - 上传完成后,将isUpload标记设置为false,允许下次上传
|
||||
*
|
||||
*
|
||||
* @param {File} file - 要上传的图片文件
|
||||
* @returns {Promise<string>} - 返回上传后的图片URL,如果上传失败返回空字符串
|
||||
*/
|
||||
@@ -205,22 +207,22 @@ const uploadImage = async (file: File): Promise<string> => {
|
||||
key: 'imageUpload',
|
||||
duration: 2,
|
||||
});
|
||||
|
||||
|
||||
// 重置上传标记,允许下次上传
|
||||
isUpload.value = false;
|
||||
|
||||
|
||||
// 返回上传后的图片URL
|
||||
return data.url;
|
||||
} catch (error) {
|
||||
// 处理上传错误
|
||||
console.error('图片上传错误:', error);
|
||||
|
||||
|
||||
// 显示错误提示
|
||||
message.error({ content: '图片上传失败', key: 'imageUpload', duration: 2 });
|
||||
|
||||
|
||||
// 重置上传标记,允许重试
|
||||
isUpload.value = false;
|
||||
|
||||
|
||||
// 返回空字符串,表示上传失败
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Markdown 编辑器表单组件(封装 md-editor-v3)
|
||||
* 经 component-map 自动注册,schema 中以 component: 'MarkdownEditor' 使用(vben form 默认 v-model:value 绑定)
|
||||
* 为什么封装:统一精简工具栏(轻量备注场景不需要图片/公式等重型功能)+ 主题跟随系统亮暗切换,避免各表单重复配置
|
||||
*/
|
||||
import type { ToolbarNames } from 'md-editor-v3';
|
||||
|
||||
import { computed, defineAsyncComponent } from 'vue';
|
||||
|
||||
import { usePreferences } from '@vben/preferences';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
|
||||
import 'md-editor-v3/lib/style.css';
|
||||
|
||||
// 本文件经 component-map 的 eager glob 全量打进主包,md-editor-v3(含 CodeMirror)体积大,
|
||||
// 必须异步加载:外壳进主包、库本体在编辑器真正渲染时才拉取,避免拖累全站首屏
|
||||
const MdEditor = defineAsyncComponent(() =>
|
||||
import('md-editor-v3').then((m) => m.MdEditor),
|
||||
);
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '请输入内容(支持 Markdown 格式)',
|
||||
},
|
||||
/** 编辑器整体高度(px),弹窗场景建议 200-300 */
|
||||
height: {
|
||||
type: Number,
|
||||
default: 240,
|
||||
},
|
||||
});
|
||||
const emits = defineEmits(['update:value']);
|
||||
const mValue = useVModel(props, 'value', emits, {
|
||||
defaultValue: props.value,
|
||||
passive: true,
|
||||
});
|
||||
/** 主题必须跟随系统亮/暗切换,不能写死(与消息详情 MdPreview 的处理一致) */
|
||||
const { isDark } = usePreferences();
|
||||
const theme = computed(() => (isDark.value ? 'dark' : 'light'));
|
||||
/** 同页面可能挂载多个编辑器实例(如弹窗嵌套),随机 id 避免内部 DOM id 冲突 */
|
||||
const editorId = `md-form-${Math.random().toString(36).slice(2, 9)}`;
|
||||
/** 精简工具栏:加粗/斜体/删除线、标题/引用/列表/任务、代码/链接/表格,右侧仅保留预览切换 */
|
||||
const toolbars: ToolbarNames[] = [
|
||||
'bold',
|
||||
'italic',
|
||||
'strikeThrough',
|
||||
'-',
|
||||
'title',
|
||||
'quote',
|
||||
'unorderedList',
|
||||
'orderedList',
|
||||
'task',
|
||||
'-',
|
||||
'code',
|
||||
'link',
|
||||
'table',
|
||||
'=',
|
||||
'preview',
|
||||
];
|
||||
</script>
|
||||
<template>
|
||||
<MdEditor
|
||||
:id="editorId"
|
||||
v-model="mValue"
|
||||
:theme="theme"
|
||||
:placeholder="placeholder"
|
||||
:toolbars="toolbars"
|
||||
:footers="[]"
|
||||
:preview="false"
|
||||
:style="{ height: `${height}px` }"
|
||||
class="md-form-editor"
|
||||
/>
|
||||
</template>
|
||||
<style scoped>
|
||||
/* 圆角与边框贴近 antd 输入组件,融入表单视觉 */
|
||||
.md-form-editor {
|
||||
width: 100%;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 门店多选搜索组件
|
||||
* - 普通输入框输入,下方气泡卡片展示匹配结果(名称 / 拼音首拼)
|
||||
* - 选中后在输入框下方用可关闭 Tag 展示「名称【id】」
|
||||
* - 搜索结果默认高亮第一项,Enter 可直接选中
|
||||
* - 样式使用主题 CSS 变量,自动适配暗色
|
||||
* 门店多选搜索(对齐开方关键字气泡交互)
|
||||
* - Teleport + fixed 锚定,避免搜索栏 overflow 裁切导致「气泡不见」
|
||||
* - 多选:点选后不关闭面板,已选项从列表剔除并以 Tag 展示
|
||||
* - Enter / ↑↓ 键盘选用,Esc 关闭
|
||||
*/
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { LoadingOutlined, SearchOutlined } from '@ant-design/icons-vue';
|
||||
import { useDebounceFn, useVModel } from '@vueuse/core';
|
||||
@@ -28,16 +36,11 @@ export type StoreSearchItem = {
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 已选门店 ID 列表 */
|
||||
value?: number[];
|
||||
/** 限定类型:0诊所 1药店;不传则诊所+药店都可搜 */
|
||||
/** 0诊所 1药店;不传则诊所+药店 */
|
||||
storeType?: 0 | 1;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* 外部已选门店详情(打开绑定弹窗时回填名称用)
|
||||
* 仅用于 Tag 展示,不改变 value
|
||||
*/
|
||||
initialItems?: StoreSearchItem[];
|
||||
}>(),
|
||||
{
|
||||
@@ -49,23 +52,64 @@ const props = withDefaults(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 模块级搜索结果缓存(所有实例共享):key = `${userId}|${storeType}|${keyword}`
|
||||
* 门店数据变更低频,TTL 内相同关键词直接命中缓存零等待,避免每敲一个字都等接口转圈。
|
||||
* key 必须拼登录用户 id:不同账号数据权限不同(店管只能搜到本店可见门店),
|
||||
* 切换账号后不允许在 TTL 内命中上一账号的结果,拼 id 后 key 天然隔离
|
||||
*/
|
||||
const searchResultCache = new Map<
|
||||
string,
|
||||
{ at: number; items: StoreSearchItem[] }
|
||||
>();
|
||||
const SEARCH_CACHE_TTL = 5 * 60 * 1000;
|
||||
const SEARCH_CACHE_MAX = 200;
|
||||
|
||||
function readSearchCache(key: string): null | StoreSearchItem[] {
|
||||
const hit = searchResultCache.get(key);
|
||||
if (!hit) return null;
|
||||
if (Date.now() - hit.at > SEARCH_CACHE_TTL) {
|
||||
searchResultCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return hit.items;
|
||||
}
|
||||
|
||||
function writeSearchCache(key: string, items: StoreSearchItem[]) {
|
||||
// 简单容量控制:超限整体清空(低频触发,无需 LRU)
|
||||
if (searchResultCache.size >= SEARCH_CACHE_MAX) {
|
||||
searchResultCache.clear();
|
||||
}
|
||||
searchResultCache.set(key, { at: Date.now(), items });
|
||||
}
|
||||
|
||||
const emits = defineEmits<{
|
||||
'update:value': [value: number[]];
|
||||
}>();
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
/** 当前登录用户 id(缓存隔离维度;后端返回的 admin 行主键是 id,兜底 vben 的 userId) */
|
||||
function currentUserId(): number | string {
|
||||
const info = userStore.userInfo as null | Record<string, any>;
|
||||
return info?.id ?? info?.userId ?? 0;
|
||||
}
|
||||
|
||||
const mValue = useVModel(props, 'value', emits, {
|
||||
passive: true,
|
||||
defaultValue: [],
|
||||
});
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
const panelRef = ref<HTMLElement | null>(null);
|
||||
const listRef = ref<HTMLElement | null>(null);
|
||||
const searchKeyword = ref('');
|
||||
const loading = ref(false);
|
||||
const showDropdown = ref(false);
|
||||
const highlightIndex = ref(-1);
|
||||
const options = ref<StoreSearchItem[]>([]);
|
||||
/** 已选门店详情(用于 Tag 展示名称) */
|
||||
const selectedMap = ref<Record<number, StoreSearchItem>>({});
|
||||
const panelStyle = ref<Record<string, string>>({});
|
||||
|
||||
const selectedIds = computed(() =>
|
||||
Array.isArray(mValue.value) ? mValue.value.map(Number) : [],
|
||||
@@ -78,38 +122,100 @@ const selectedItems = computed(() =>
|
||||
}),
|
||||
);
|
||||
|
||||
/** 下拉中过滤掉已选 */
|
||||
/** 下拉中过滤掉已选,方便连续多选 */
|
||||
const visibleOptions = computed(() =>
|
||||
options.value.filter((item) => !selectedIds.value.includes(item.id)),
|
||||
);
|
||||
|
||||
/**
|
||||
* 将初始/回填门店写入 selectedMap,便于 Tag 显示真实名称
|
||||
*/
|
||||
function mergeInitialItems(items: StoreSearchItem[]) {
|
||||
if (!items?.length) return;
|
||||
const next = { ...selectedMap.value };
|
||||
for (const item of items) {
|
||||
if (item?.id) {
|
||||
next[item.id] = item;
|
||||
}
|
||||
if (item?.id) next[item.id] = item;
|
||||
}
|
||||
selectedMap.value = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求后端门店搜索;有结果时默认高亮第一项
|
||||
* 相对输入框视口坐标放置面板(与 EntryKeywordBubble 同思路)
|
||||
*/
|
||||
function updatePanelPlacement() {
|
||||
const el = containerRef.value;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const gap = 4;
|
||||
const pad = 8;
|
||||
const spaceBelow = vh - rect.bottom - pad;
|
||||
const spaceAbove = rect.top - pad;
|
||||
const placeTop = spaceBelow < 200 && spaceAbove > spaceBelow;
|
||||
const avail = Math.max(160, placeTop ? spaceAbove : spaceBelow);
|
||||
const maxH = Math.min(320, avail);
|
||||
const width = Math.min(Math.max(rect.width, 360), vw - pad * 2);
|
||||
let left = rect.left;
|
||||
if (left + width > vw - pad) left = vw - pad - width;
|
||||
if (left < pad) left = pad;
|
||||
|
||||
const base: Record<string, string> = {
|
||||
position: 'fixed',
|
||||
zIndex: '4000',
|
||||
width: `${width}px`,
|
||||
maxHeight: `${maxH}px`,
|
||||
left: `${left}px`,
|
||||
right: 'auto',
|
||||
};
|
||||
panelStyle.value = placeTop
|
||||
? {
|
||||
...base,
|
||||
top: 'auto',
|
||||
bottom: `${Math.max(pad, vh - rect.top + gap)}px`,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
top: `${rect.bottom + gap}px`,
|
||||
bottom: 'auto',
|
||||
};
|
||||
}
|
||||
|
||||
async function openPanel() {
|
||||
showDropdown.value = true;
|
||||
await nextTick();
|
||||
updatePanelPlacement();
|
||||
}
|
||||
|
||||
/** 请求序号:只采纳最后一次请求的响应,避免快速输入时慢响应覆盖新结果 */
|
||||
let fetchSeq = 0;
|
||||
|
||||
async function fetchOptions() {
|
||||
const keyword = searchKeyword.value.trim();
|
||||
if (!keyword) {
|
||||
// 递增序号让在途请求作废,避免清空后慢响应又把旧结果写回来
|
||||
fetchSeq += 1;
|
||||
loading.value = false;
|
||||
options.value = [];
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = `${currentUserId()}|${props.storeType ?? 'all'}|${keyword}`;
|
||||
const cachedItems = readSearchCache(cacheKey);
|
||||
if (cachedItems) {
|
||||
// 命中缓存:直接渲染,不发请求不转 loading(体感「秒出」)
|
||||
fetchSeq += 1;
|
||||
loading.value = false;
|
||||
options.value = cachedItems;
|
||||
await openPanel();
|
||||
highlightIndex.value = visibleOptions.value.length > 0 ? 0 : -1;
|
||||
await nextTick();
|
||||
updatePanelPlacement();
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = (fetchSeq += 1);
|
||||
loading.value = true;
|
||||
showDropdown.value = true;
|
||||
await openPanel();
|
||||
try {
|
||||
const res = await searchStoreOption({
|
||||
keyword,
|
||||
@@ -118,14 +224,21 @@ async function fetchOptions() {
|
||||
: {}),
|
||||
limit: 20,
|
||||
});
|
||||
options.value = res?.items ?? [];
|
||||
// 默认高亮第一项,Enter 可直接选中
|
||||
highlightIndex.value = options.value.length > 0 ? 0 : -1;
|
||||
if (seq !== fetchSeq) return;
|
||||
const items = res?.items ?? [];
|
||||
writeSearchCache(cacheKey, items);
|
||||
options.value = items;
|
||||
highlightIndex.value = visibleOptions.value.length > 0 ? 0 : -1;
|
||||
await nextTick();
|
||||
updatePanelPlacement();
|
||||
} catch {
|
||||
if (seq !== fetchSeq) return;
|
||||
options.value = [];
|
||||
highlightIndex.value = -1;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (seq === fetchSeq) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,30 +249,36 @@ function handleInput() {
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
if (visibleOptions.value.length > 0) {
|
||||
showDropdown.value = true;
|
||||
if (highlightIndex.value < 0) {
|
||||
if (searchKeyword.value.trim() && options.value.length > 0) {
|
||||
openPanel();
|
||||
if (highlightIndex.value < 0 && visibleOptions.value.length > 0) {
|
||||
highlightIndex.value = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 选中一项:追加到 value,缓存名称,清空输入继续搜
|
||||
* 多选:追加 ID,保留关键词与面板,不关闭气泡
|
||||
*/
|
||||
function selectOption(item: StoreSearchItem) {
|
||||
if (selectedIds.value.includes(item.id)) return;
|
||||
selectedMap.value = { ...selectedMap.value, [item.id]: item };
|
||||
mValue.value = [...selectedIds.value, item.id];
|
||||
searchKeyword.value = '';
|
||||
options.value = [];
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
// 高亮下一项(列表已剔除刚选的)
|
||||
nextTick(() => {
|
||||
if (visibleOptions.value.length === 0) {
|
||||
highlightIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
highlightIndex.value = Math.min(
|
||||
Math.max(highlightIndex.value, 0),
|
||||
visibleOptions.value.length - 1,
|
||||
);
|
||||
updatePanelPlacement();
|
||||
scrollHighlightIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消某个已选门店
|
||||
*/
|
||||
function removeSelected(id: number) {
|
||||
mValue.value = selectedIds.value.filter((x) => x !== id);
|
||||
const next = { ...selectedMap.value };
|
||||
@@ -167,19 +286,52 @@ function removeSelected(id: number) {
|
||||
selectedMap.value = next;
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
}
|
||||
|
||||
/** 仅改 list 的 scrollTop,表头固定不动 */
|
||||
function scrollHighlightIntoView() {
|
||||
nextTick(() => {
|
||||
const listEl = listRef.value;
|
||||
if (!listEl || highlightIndex.value < 0) return;
|
||||
const el = listEl.querySelector(
|
||||
`[data-store-opt-index="${highlightIndex.value}"]`,
|
||||
) as HTMLElement | null;
|
||||
if (!el) return;
|
||||
const listRect = listEl.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const deltaTop = elRect.top - listRect.top;
|
||||
const deltaBottom = elRect.bottom - listRect.bottom;
|
||||
if (deltaTop < 0) {
|
||||
listEl.scrollTop += deltaTop;
|
||||
} else if (deltaBottom > 0) {
|
||||
listEl.scrollTop += deltaBottom;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!showDropdown.value || visibleOptions.value.length === 0) return;
|
||||
if (!showDropdown.value || visibleOptions.value.length === 0) {
|
||||
if (e.key === 'Escape') closePanel();
|
||||
return;
|
||||
}
|
||||
const len = visibleOptions.value.length;
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
highlightIndex.value = Math.min(
|
||||
Math.max(highlightIndex.value, 0) + 1,
|
||||
visibleOptions.value.length - 1,
|
||||
);
|
||||
// 头尾循环:最后一项再按下 → 回到第一项
|
||||
highlightIndex.value =
|
||||
highlightIndex.value < 0 ? 0 : (highlightIndex.value + 1) % len;
|
||||
scrollHighlightIntoView();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
highlightIndex.value = Math.max(highlightIndex.value - 1, 0);
|
||||
// 头尾循环:第一项再按上 → 跳到最后一项
|
||||
highlightIndex.value =
|
||||
highlightIndex.value <= 0 ? len - 1 : highlightIndex.value - 1;
|
||||
scrollHighlightIntoView();
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
@@ -190,35 +342,42 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
e.preventDefault();
|
||||
closePanel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
|
||||
showDropdown.value = false;
|
||||
highlightIndex.value = -1;
|
||||
}
|
||||
if (!showDropdown.value) return;
|
||||
const t = e.target as Node;
|
||||
if (containerRef.value?.contains(t) || panelRef.value?.contains(t)) return;
|
||||
closePanel();
|
||||
}
|
||||
|
||||
function onWinChange() {
|
||||
if (showDropdown.value) updatePanelPlacement();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
window.addEventListener('resize', onWinChange);
|
||||
window.addEventListener('scroll', onWinChange, true);
|
||||
mergeInitialItems(props.initialItems || []);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
window.removeEventListener('resize', onWinChange);
|
||||
window.removeEventListener('scroll', onWinChange, true);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.storeType,
|
||||
() => {
|
||||
options.value = [];
|
||||
showDropdown.value = false;
|
||||
closePanel();
|
||||
searchKeyword.value = '';
|
||||
highlightIndex.value = -1;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -227,6 +386,17 @@ watch(
|
||||
(items) => mergeInitialItems(items || []),
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(visibleOptions, (list) => {
|
||||
if (!showDropdown.value) return;
|
||||
if (list.length === 0) {
|
||||
highlightIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
if (highlightIndex.value >= list.length) {
|
||||
highlightIndex.value = list.length - 1;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -246,33 +416,50 @@ watch(
|
||||
</template>
|
||||
</Input>
|
||||
|
||||
<!-- 气泡建议列表 -->
|
||||
<div v-if="showDropdown" class="store-multi-search__dropdown">
|
||||
<Spin :spinning="loading">
|
||||
<Empty
|
||||
v-if="!loading && visibleOptions.length === 0"
|
||||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||||
description="暂无匹配门店"
|
||||
class="py-3"
|
||||
/>
|
||||
<div
|
||||
v-for="(item, index) in visibleOptions"
|
||||
:key="item.id"
|
||||
class="store-multi-search__option"
|
||||
:class="{ 'is-active': index === highlightIndex }"
|
||||
@mousedown.prevent="selectOption(item)"
|
||||
>
|
||||
<div class="store-multi-search__option-name">
|
||||
{{ item.name }}【{{ item.id }}】
|
||||
</div>
|
||||
<div v-if="item.shouzimu" class="store-multi-search__option-sub">
|
||||
首拼:{{ item.shouzimu }}
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showDropdown"
|
||||
ref="panelRef"
|
||||
class="store-multi-search__panel"
|
||||
:style="panelStyle"
|
||||
>
|
||||
<div class="store-multi-search__hint">
|
||||
<span>↑↓ 循环切换 · Enter 选用 · Esc 关闭 · 可连续多选</span>
|
||||
<span class="store-multi-search__count">
|
||||
剩 {{ visibleOptions.length }} 条可选
|
||||
</span>
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
<div class="store-multi-search__body">
|
||||
<Spin :spinning="loading">
|
||||
<Empty
|
||||
v-if="!loading && visibleOptions.length === 0"
|
||||
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
||||
description="暂无匹配门店(或已全部选中)"
|
||||
class="py-3"
|
||||
/>
|
||||
<div ref="listRef" class="store-multi-search__list">
|
||||
<button
|
||||
v-for="(item, index) in visibleOptions"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="store-multi-search__option"
|
||||
:class="{ 'is-active': index === highlightIndex }"
|
||||
:data-store-opt-index="index"
|
||||
@mousedown.prevent="selectOption(item)"
|
||||
>
|
||||
<div class="store-multi-search__option-name">
|
||||
{{ item.name }}【{{ item.id }}】
|
||||
</div>
|
||||
<div v-if="item.shouzimu" class="store-multi-search__option-sub">
|
||||
首拼:{{ item.shouzimu }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- 已选 Tag -->
|
||||
<div v-if="selectedItems.length > 0" class="store-multi-search__tags">
|
||||
<Tag
|
||||
v-for="item in selectedItems"
|
||||
@@ -281,47 +468,126 @@ watch(
|
||||
color="blue"
|
||||
@close="() => removeSelected(item.id)"
|
||||
>
|
||||
{{ item.name }}【{{ item.id }}】
|
||||
<!-- 文本单独包一层才能做省略:antd Tag 的内容是裸文本节点,无法直接限宽 -->
|
||||
<span
|
||||
class="store-multi-search__tag-text"
|
||||
:title="`${item.name}【${item.id}】`"
|
||||
>
|
||||
{{ item.name }}【{{ item.id }}】
|
||||
</span>
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 只用框架主题变量,随亮暗/主题色自动适配 */
|
||||
.store-multi-search {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-multi-search__dropdown {
|
||||
position: absolute;
|
||||
z-index: 1050;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--card));
|
||||
.store-multi-search__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
|
||||
/* 单个超长门店名不能撑破容器:限宽 + 省略号,完整名走 title 悬停提示 */
|
||||
:deep(.ant-tag) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
/* 间距交给 gap 控制,去掉 antd 自带右外边距避免换行时右侧不齐 */
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-tag-close-icon) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.store-multi-search__tag-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
/* Teleport 到 body,非 scoped,避免气泡被裁切/丢样式 */
|
||||
.store-multi-search__panel {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 10px;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
border: 1px solid hsl(var(--border));
|
||||
box-shadow:
|
||||
0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
0 8px 24px hsl(0 0% 0% / 0.12),
|
||||
0 2px 8px hsl(0 0% 0% / 0.06);
|
||||
}
|
||||
|
||||
.store-multi-search__hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
}
|
||||
|
||||
.store-multi-search__count {
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 表头固定,仅 body/list 滚动 */
|
||||
.store-multi-search__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.store-multi-search__body .ant-spin-nested-loading,
|
||||
.store-multi-search__body .ant-spin-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.store-multi-search__list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.store-multi-search__option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.is-active {
|
||||
background: hsl(var(--accent-hover));
|
||||
}
|
||||
.store-multi-search__option:hover,
|
||||
.store-multi-search__option.is-active {
|
||||
background: hsl(var(--accent) / 0.85);
|
||||
}
|
||||
|
||||
.store-multi-search__option-name {
|
||||
@@ -336,11 +602,4 @@ watch(
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.store-multi-search__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
|
||||
5
apps/web-antd/src/components/stat-islands/index.ts
Normal file
5
apps/web-antd/src/components/stat-islands/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 通用统计岛组件导出:紧凑玻璃质感统计小卡(设计对齐提现管理账户卡统计岛)
|
||||
*/
|
||||
export { default as StatIslands } from './stat-islands.vue';
|
||||
export type { StatIslandItem } from './typing';
|
||||
166
apps/web-antd/src/components/stat-islands/stat-islands.vue
Normal file
166
apps/web-antd/src/components/stat-islands/stat-islands.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 通用统计岛组件:一行多个紧凑玻璃质感统计小卡
|
||||
*
|
||||
* 设计语言与提现管理账户卡的统计岛(finance/withdrawal AccountInfoCard)完全一致:
|
||||
* 12px 圆角 + 1px 边框 + 主色渐变玻璃底 + 右上角 SVG 图标 + 角落径向荧光 +
|
||||
* DIN 等宽大数字,hover 主色边框浮起。用于替换各页面过大的 AnalysisOverview 大卡。
|
||||
*/
|
||||
import { IconifyIcon as VbenIcon } from '@vben/icons';
|
||||
|
||||
import type { StatIslandItem } from './typing';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
items: StatIslandItem[];
|
||||
/** 每行列数(桌面端),默认 4;窄屏自动降为 2 列 */
|
||||
columns?: number;
|
||||
}>(),
|
||||
{ columns: 4 },
|
||||
);
|
||||
|
||||
/** 金额展示为两位小数;非数字原样透出(如「—」占位) */
|
||||
function formatValue(val: number | string) {
|
||||
const n = Number(val);
|
||||
return Number.isFinite(n) ? n.toFixed(2) : String(val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stat-islands" :style="{ '--stat-cols': columns }">
|
||||
<article
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
class="stat-island"
|
||||
:class="{ 'stat-island--emphasize': item.emphasize }"
|
||||
:aria-label="item.label"
|
||||
>
|
||||
<div class="stat-island-glow" />
|
||||
<VbenIcon class="stat-icon" :icon="item.icon" />
|
||||
<div class="stat-label">{{ item.label }}</div>
|
||||
<div class="stat-val">
|
||||
<span v-if="item.prefix !== ''" class="stat-val-prefix">{{ item.prefix ?? '¥' }}</span>
|
||||
{{ formatValue(item.value) }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stat-islands {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--stat-cols, 4), minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.stat-islands {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* 统计岛:玻璃质感 + 右上角图标 + 淡荧光(与提现管理 stat-island 同规格) */
|
||||
.stat-island {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 14px;
|
||||
cursor: default;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--primary) / 4%),
|
||||
hsl(var(--card, var(--background)))
|
||||
);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-island:hover {
|
||||
border-color: hsl(var(--primary) / 40%);
|
||||
box-shadow:
|
||||
0 8px 20px hsl(var(--primary) / 14%),
|
||||
0 2px 6px rgb(0 0 0 / 6%);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
/* 角落局部高光,强化荧光感 */
|
||||
.stat-island-glow {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
right: -40px;
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle, hsl(var(--primary) / 18%) 0%, transparent 70%);
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-island:hover .stat-island-glow {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 强调项:主色字 + 更强荧光 */
|
||||
.stat-island--emphasize {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--primary) / 10%),
|
||||
hsl(var(--card, var(--background)))
|
||||
);
|
||||
border-color: hsl(var(--primary) / 30%);
|
||||
}
|
||||
|
||||
.stat-island--emphasize .stat-island-glow {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.stat-island--emphasize .stat-val {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* 右上角图标:固定 24x24,主色调 */
|
||||
.stat-icon {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: hsl(var(--primary) / 70%);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-island:hover .stat-icon {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding-right: 32px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.stat-val {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.stat-val-prefix {
|
||||
margin-right: 2px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
</style>
|
||||
19
apps/web-antd/src/components/stat-islands/typing.ts
Normal file
19
apps/web-antd/src/components/stat-islands/typing.ts
Normal file
@@ -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;
|
||||
}
|
||||
88
apps/web-antd/src/components/table-action/card-actions.vue
Normal file
88
apps/web-antd/src/components/table-action/card-actions.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 卡片视图专用操作栏(与列表交互解耦)
|
||||
*
|
||||
* 列表单元格里 TableAction 平铺全部按钮没问题,但卡片 footer 空间小,
|
||||
* 按钮超过 2 个会触发 TableAction 的两列 grid 换行堆叠,显得杂乱。
|
||||
* 这里改成 C 端惯例:只露出前 maxVisible 个主操作(默认 1 个,通常是「详情」),
|
||||
* 其余操作全部合并进「···」下拉菜单。
|
||||
*
|
||||
* 权限(auth)/ 显隐(ifShow)/ 二次确认(popConfirm)逻辑不重写:
|
||||
* - 挑主操作前先按与 TableAction 相同的规则过滤,保证露出的一定是可见按钮;
|
||||
* - 渲染仍复用 TableAction(flex=false 关闭多列 grid),菜单项行为完全一致。
|
||||
*/
|
||||
import type { ActionItem } from './types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { isBoolean, isFunction } from '@vben/utils';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
|
||||
import { Icon } from '#/components/icon';
|
||||
|
||||
import TableAction from './table-action.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
actions?: ActionItem[];
|
||||
dropDownActions?: ActionItem[];
|
||||
/** 露出的主操作数量,其余进「···」菜单 */
|
||||
maxVisible?: number;
|
||||
}>(),
|
||||
{
|
||||
actions: () => [],
|
||||
dropDownActions: () => [],
|
||||
maxVisible: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
|
||||
/** 与 TableAction 同规则判断单个操作是否可见(auth + ifShow) */
|
||||
function isActionVisible(action: ActionItem): boolean {
|
||||
const authPass =
|
||||
(action.auth || []).length === 0 || hasAccessByCodes(action.auth || []);
|
||||
if (!authPass) return false;
|
||||
const ifShow = action.ifShow;
|
||||
if (isBoolean(ifShow)) return ifShow;
|
||||
if (isFunction(ifShow)) return ifShow(action);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 过滤后的可见操作:前 maxVisible 个露出,其余与原下拉操作合并进「···」 */
|
||||
const splitActions = computed(() => {
|
||||
const visible = (props.actions || []).filter((action) =>
|
||||
isActionVisible(action),
|
||||
);
|
||||
return {
|
||||
primary: visible.slice(0, props.maxVisible),
|
||||
// 下拉里的项 TableAction 会再过滤一遍 auth/ifShow,这里直接透传原始项即可
|
||||
rest: [...visible.slice(props.maxVisible), ...(props.dropDownActions || [])],
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableAction
|
||||
:actions="splitActions.primary"
|
||||
:drop-down-actions="splitActions.rest"
|
||||
:flex="false"
|
||||
>
|
||||
<!-- 默认的「···」图标点击区太小,换成 link 文字「更多」,与主操作按钮同视觉 -->
|
||||
<template #more>
|
||||
<Button size="small" type="link" class="card-actions__more">
|
||||
更多
|
||||
<Icon icon="ant-design:down-outlined" class="card-actions__more-icon" />
|
||||
</Button>
|
||||
</template>
|
||||
</TableAction>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 箭头比文字略小一号;左间距由 TableAction 全局的 span+iconify 规则统一控制 */
|
||||
.card-actions__more-icon {
|
||||
font-size: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -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';
|
||||
|
||||
3
apps/web-antd/src/components/view-mode-switch/index.ts
Normal file
3
apps/web-antd/src/components/view-mode-switch/index.ts
Normal file
@@ -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';
|
||||
@@ -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<ViewMode> {
|
||||
// 初始化时同步读取本地记忆,避免先渲染默认视图再闪切
|
||||
const readStored = (): ViewMode => {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (raw === 'list' || raw === 'card') return raw;
|
||||
} catch {
|
||||
// localStorage 不可用(隐私模式等)时静默回退默认值
|
||||
}
|
||||
return defaultMode;
|
||||
};
|
||||
|
||||
const viewMode = ref<ViewMode>(readStored());
|
||||
|
||||
// 切换后立即写回,下次进入页面直接还原
|
||||
watch(viewMode, (mode) => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, mode);
|
||||
} catch {
|
||||
// 写入失败不影响功能,忽略
|
||||
}
|
||||
});
|
||||
|
||||
return viewMode;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 通用「列表/卡片」视图切换组件
|
||||
*
|
||||
* 为什么封装:多个订单列表页都需要同样的切换控件,统一外观(图标+文字的分段控件)
|
||||
* 与交互,配合 useViewMode(storageKey) 使用自带记忆能力。
|
||||
*
|
||||
* 用法:
|
||||
* const viewMode = useViewMode('product-order-view-mode');
|
||||
* <ViewModeSwitch v-model="viewMode" />
|
||||
*/
|
||||
import type { ViewMode } from './use-view-mode';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
defineOptions({ name: 'ViewModeSwitch' });
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** 卡片视图文案,允许按页面语境自定义(如「卡片看板」) */
|
||||
cardLabel?: string;
|
||||
/** 列表视图文案 */
|
||||
listLabel?: string;
|
||||
}>(),
|
||||
{
|
||||
cardLabel: '卡片',
|
||||
listLabel: '列表',
|
||||
},
|
||||
);
|
||||
|
||||
/** 当前视图模式,v-model 双向绑定,记忆逻辑由 useViewMode 承担 */
|
||||
const modelValue = defineModel<ViewMode>({ default: 'list' });
|
||||
|
||||
/** 切换视图:点击已选中项不重复触发 */
|
||||
function switchTo(mode: ViewMode) {
|
||||
if (modelValue.value !== mode) {
|
||||
modelValue.value = mode;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="view-mode-switch" role="radiogroup" aria-label="视图切换">
|
||||
<button
|
||||
type="button"
|
||||
class="vms-item"
|
||||
:class="{ active: modelValue === 'list' }"
|
||||
role="radio"
|
||||
:aria-checked="modelValue === 'list'"
|
||||
@click="switchTo('list')"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:bars-outlined" class="vms-icon" />
|
||||
<span>{{ listLabel }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="vms-item"
|
||||
:class="{ active: modelValue === 'card' }"
|
||||
role="radio"
|
||||
:aria-checked="modelValue === 'card'"
|
||||
@click="switchTo('card')"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:appstore-outlined" class="vms-icon" />
|
||||
<span>{{ cardLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
/* App 风格分段控件:胶囊容器 + 滑块式选中态,颜色全部走主题变量适配暗色 */
|
||||
.view-mode-switch {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
background: hsl(var(--muted) / 60%);
|
||||
border: 1px solid hsl(var(--border) / 60%);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.vms-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 14px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
color 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: 600;
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--background));
|
||||
box-shadow: 0 1px 4px hsl(var(--foreground) / 10%);
|
||||
}
|
||||
}
|
||||
|
||||
.vms-icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
</style>
|
||||
@@ -145,8 +145,9 @@ function openRecords() {
|
||||
>
|
||||
<template #content>
|
||||
<div class="header-vip-bubble">
|
||||
<!-- 诊所端风格会员卡头图 -->
|
||||
<!-- 黑金会员卡头图:与诊所设置/小程序会员卡同一视觉语言 -->
|
||||
<div class="header-vip-hero">
|
||||
<div class="header-vip-hero__glow"></div>
|
||||
<VipBadgeCombo
|
||||
v-if="hasVipVisual"
|
||||
size="lg"
|
||||
@@ -157,8 +158,10 @@ function openRecords() {
|
||||
/>
|
||||
<div v-else class="header-vip-hero__placeholder">V</div>
|
||||
<div class="header-vip-hero__info">
|
||||
<div class="header-vip-hero__title">{{ levelName }}</div>
|
||||
<div class="header-vip-hero__sub">{{ durationLabel }}</div>
|
||||
<div class="header-vip-hero__title-row">
|
||||
<span class="header-vip-hero__title">{{ levelName }}</span>
|
||||
<span class="header-vip-hero__chip">{{ durationLabel }}</span>
|
||||
</div>
|
||||
<div class="header-vip-hero__expire">{{ expireText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -244,19 +247,37 @@ function openRecords() {
|
||||
background: hsl(var(--card, var(--background)));
|
||||
box-shadow: 0 8px 24px hsl(var(--foreground) / 0.12);
|
||||
}
|
||||
/* 黑金卡头:深色卡面亮暗主题通用 */
|
||||
.header-vip-hero {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 18px;
|
||||
background: linear-gradient(135deg, #2c3e50 0%, #3d5a40 50%, #6acdbb 100%);
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(120% 160% at 100% 0%, rgba(212, 175, 55, 0.22) 0%, rgba(212, 175, 55, 0) 45%),
|
||||
linear-gradient(135deg, #23201a 0%, #12100c 55%, #1f1a12 100%);
|
||||
border-bottom: 1px solid rgba(220, 186, 110, 0.3);
|
||||
}
|
||||
.header-vip-hero__glow {
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
right: -40px;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(240, 217, 168, 0.26) 0%, rgba(240, 217, 168, 0) 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.header-vip-hero__placeholder {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
background: radial-gradient(circle, rgba(212, 175, 55, 0.16) 0%, rgba(212, 175, 55, 0.04) 100%);
|
||||
border: 1px solid rgba(220, 186, 110, 0.4);
|
||||
color: #f0d9a8;
|
||||
text-shadow: 0 2px 8px rgba(212, 175, 55, 0.4);
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
@@ -265,23 +286,36 @@ function openRecords() {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header-vip-hero__info {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
color: #fff;
|
||||
}
|
||||
.header-vip-hero__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.header-vip-hero__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: #f5e3bd;
|
||||
letter-spacing: 1px;
|
||||
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.header-vip-hero__sub {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
.header-vip-hero__chip {
|
||||
flex-shrink: 0;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
color: #e8cf9a;
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
border: 1px solid rgba(232, 207, 154, 0.5);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.header-vip-hero__expire {
|
||||
margin-top: 4px;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
opacity: 0.78;
|
||||
color: rgba(240, 224, 190, 0.75);
|
||||
}
|
||||
.header-vip-body {
|
||||
padding: 12px 14px 14px;
|
||||
@@ -342,8 +376,4 @@ function openRecords() {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
html.dark .header-vip-hero,
|
||||
.dark .header-vip-hero {
|
||||
background: linear-gradient(135deg, #1a2332 0%, #243528 50%, #2d6b5f 100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 诊所列表 VIP 单元格:徽标+会员名,点击打开 VIP 入口弹窗
|
||||
* 诊所列表 VIP 单元格:徽标+会员名+到期时间,点击打开 VIP 入口弹窗
|
||||
*/
|
||||
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
defineProps<{
|
||||
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
|
||||
import { formatVipDurationExpireLine } from '#/utils/vip';
|
||||
|
||||
const props = defineProps<{
|
||||
vip?: Record<string, any> | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
|
||||
/** 副行展示时长类型与到期,方便列表一眼看到过期时间 */
|
||||
const subText = computed(() => formatVipDurationExpireLine(props.vip));
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
@@ -29,9 +35,7 @@ const emit = defineEmits<{
|
||||
/>
|
||||
<div class="store-vip-cell__text">
|
||||
<div class="store-vip-cell__name">{{ vip?.level_name || '普通会员' }}</div>
|
||||
<div class="store-vip-cell__sub">
|
||||
{{ vip?.duration_label || '点击管理' }}
|
||||
</div>
|
||||
<div class="store-vip-cell__sub">{{ subText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -56,14 +60,14 @@ const emit = defineEmits<{
|
||||
.store-vip-cell__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground, #1f1f1f);
|
||||
color: hsl(var(--foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.store-vip-cell__sub {
|
||||
font-size: 11px;
|
||||
color: #8c8c8c;
|
||||
color: hsl(var(--muted-foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -72,12 +76,4 @@ html.dark .store-vip-cell:hover,
|
||||
.dark .store-vip-cell:hover {
|
||||
background: rgba(106, 205, 187, 0.2);
|
||||
}
|
||||
html.dark .store-vip-cell__name,
|
||||
.dark .store-vip-cell__name {
|
||||
color: #f4f4f5;
|
||||
}
|
||||
html.dark .store-vip-cell__sub,
|
||||
.dark .store-vip-cell__sub {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -34,7 +34,10 @@ const expireText = computed(() => {
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<!-- 黑金会员卡:与小程序端 vip-member-card 同一套视觉语言,深色卡面亮暗主题通用 -->
|
||||
<div class="store-vip-card">
|
||||
<div class="store-vip-card__glow"></div>
|
||||
<div class="store-vip-card__sheen"></div>
|
||||
<VipBadgeCombo
|
||||
v-if="hasVipVisual"
|
||||
size="xl"
|
||||
@@ -45,11 +48,9 @@ const expireText = computed(() => {
|
||||
/>
|
||||
<div v-else class="store-vip-card__placeholder">V</div>
|
||||
<div class="store-vip-card__info">
|
||||
<div class="store-vip-card__title">
|
||||
{{ vip?.level_name || '普通会员' }}
|
||||
</div>
|
||||
<div class="store-vip-card__sub">
|
||||
{{ vip?.duration_label || '普通会员' }}
|
||||
<div class="store-vip-card__title-row">
|
||||
<span class="store-vip-card__title">{{ vip?.level_name || '普通会员' }}</span>
|
||||
<span class="store-vip-card__chip">{{ vip?.duration_label || '普通会员' }}</span>
|
||||
</div>
|
||||
<div class="store-vip-card__expire">{{ expireText }}</div>
|
||||
</div>
|
||||
@@ -57,50 +58,96 @@ const expireText = computed(() => {
|
||||
</template>
|
||||
<style scoped>
|
||||
.store-vip-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 20px 28px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #2c3e50 0%, #3d5a40 50%, #6acdbb 100%);
|
||||
box-shadow: 0 8px 24px rgba(44, 62, 80, 0.16);
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
border-radius: 18px;
|
||||
/* 深色曜石底 + 右上暗金光晕,比单一渐变更有实体卡层次 */
|
||||
background:
|
||||
radial-gradient(120% 160% at 100% 0%, rgba(212, 175, 55, 0.22) 0%, rgba(212, 175, 55, 0) 45%),
|
||||
linear-gradient(135deg, #23201a 0%, #12100c 55%, #1f1a12 100%);
|
||||
border: 1px solid rgba(220, 186, 110, 0.35);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.28),
|
||||
0 2px 8px rgba(212, 175, 55, 0.12);
|
||||
}
|
||||
/* 右上金色光晕:模拟灯光打在卡面的高光 */
|
||||
.store-vip-card__glow {
|
||||
position: absolute;
|
||||
top: -70px;
|
||||
right: -50px;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(240, 217, 168, 0.26) 0%, rgba(240, 217, 168, 0) 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* 斜切静态流光条:金属光泽感 */
|
||||
.store-vip-card__sheen {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 32%;
|
||||
width: 90px;
|
||||
transform: skewX(-24deg);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0.05) 50%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
.store-vip-card__placeholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: #f0d9a8;
|
||||
text-shadow: 0 2px 8px rgba(212, 175, 55, 0.4);
|
||||
background: radial-gradient(circle, rgba(212, 175, 55, 0.16) 0%, rgba(212, 175, 55, 0.04) 100%);
|
||||
border: 1px solid rgba(220, 186, 110, 0.4);
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 16px rgba(212, 175, 55, 0.15);
|
||||
}
|
||||
.store-vip-card__info {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
color: #fff;
|
||||
}
|
||||
.store-vip-card__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.store-vip-card__title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 6px;
|
||||
color: #f5e3bd;
|
||||
letter-spacing: 1px;
|
||||
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.store-vip-card__sub {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 6px;
|
||||
/* 期限徽章:金字描边小胶囊 */
|
||||
.store-vip-card__chip {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 10px;
|
||||
font-size: 12px;
|
||||
color: #e8cf9a;
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
border: 1px solid rgba(232, 207, 154, 0.5);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.store-vip-card__expire {
|
||||
font-size: 13px;
|
||||
opacity: 0.78;
|
||||
}
|
||||
html.dark .store-vip-card,
|
||||
.dark .store-vip-card {
|
||||
background: linear-gradient(135deg, #1a2332 0%, #243528 50%, #2d6b5f 100%);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
color: rgba(240, 224, 190, 0.75);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useVbenModal } from '@vben/common-ui';
|
||||
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
|
||||
import VipStoreRecordsModal from '#/components/vip/VipStoreRecordsModal.vue';
|
||||
import VipUpgradeModal from '#/components/vip/VipUpgradeModal.vue';
|
||||
import { formatVipExpireAt } from '#/utils/vip';
|
||||
|
||||
const gridApi = ref<any>();
|
||||
const onSuccess = ref<null | (() => void)>(null);
|
||||
@@ -21,6 +22,7 @@ const title = computed(
|
||||
() => `VIP管理 - ${storeName.value || storeId.value || ''}`,
|
||||
);
|
||||
|
||||
/** 当前等级 + 时长类型 */
|
||||
const currentLevelText = computed(() => {
|
||||
const v = vip.value;
|
||||
if (!v) return '当前:普通会员';
|
||||
@@ -32,6 +34,20 @@ const currentLevelText = computed(() => {
|
||||
return duration ? `当前:${level} · ${duration}` : `当前:${level}`;
|
||||
});
|
||||
|
||||
/** 到期时间独立一行,打开弹窗时一眼可见 */
|
||||
const currentExpireText = computed(() => {
|
||||
const v = vip.value;
|
||||
if (!v) return '';
|
||||
const code = String(v.level_code || 'V0').trim() || 'V0';
|
||||
if (code === 'V0') return '';
|
||||
const expire = formatVipExpireAt(v, {
|
||||
lifetimeText: '终身',
|
||||
withPrefix: false,
|
||||
});
|
||||
if (!expire) return '';
|
||||
return expire === '终身' ? '到期时间:终身' : `到期时间:${expire}`;
|
||||
});
|
||||
|
||||
const [UpgradeModalComp, upgradeModalApi] = useVbenModal({
|
||||
connectedComponent: VipUpgradeModal,
|
||||
});
|
||||
@@ -107,7 +123,12 @@ function openRecords() {
|
||||
:level-name="vip?.level_name"
|
||||
:duration-label="vip?.duration_label"
|
||||
/>
|
||||
<div class="vip-hub-current__text">{{ currentLevelText }}</div>
|
||||
<div class="vip-hub-current__meta">
|
||||
<div class="vip-hub-current__text">{{ currentLevelText }}</div>
|
||||
<div v-if="currentExpireText" class="vip-hub-current__expire">
|
||||
{{ currentExpireText }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vip-hub-grid">
|
||||
<button type="button" class="vip-hub-card" @click="openUpgrade">
|
||||
@@ -140,11 +161,21 @@ function openRecords() {
|
||||
border-radius: 10px;
|
||||
background: hsl(var(--muted) / 0.25);
|
||||
}
|
||||
.vip-hub-current__meta {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.vip-hub-current__text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.vip-hub-current__expire {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.vip-hub-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
openVipStore,
|
||||
} from '#/views/system/vip/store/api';
|
||||
|
||||
/** 时长预设 → 时效类型 code(与后端 VipDurationHelper 对齐) */
|
||||
/** 时长预设 → 时效类型 code(与后端 VipDurationHelper 对齐;keep 走当前会员 duration_type) */
|
||||
const PRESET_TO_DURATION: Record<string, string> = {
|
||||
'7d': 'trial',
|
||||
'1w': 'week',
|
||||
@@ -33,6 +33,8 @@ const gridApi = ref<any>();
|
||||
const onSuccess = ref<null | (() => void)>(null);
|
||||
const storeId = ref(0);
|
||||
const storeName = ref('');
|
||||
/** 打开弹窗时带入的当前门店 VIP,用于判断能否「继承剩余时间」 */
|
||||
const currentVip = ref<Record<string, any> | null>(null);
|
||||
const levelId = ref<number | undefined>();
|
||||
const durationPreset = ref<string | undefined>();
|
||||
const customYears = ref<number | undefined>();
|
||||
@@ -46,8 +48,28 @@ const selectedLevel = computed(() =>
|
||||
levels.value.find((i) => i.id === levelId.value),
|
||||
);
|
||||
|
||||
/**
|
||||
* 是否可继承剩余时间:非普通会员(非 V0)且未过期
|
||||
* 普通会员没有可继承的到期时间,不展示/不默认 keep
|
||||
*/
|
||||
const canKeepDuration = computed(() => {
|
||||
const v = currentVip.value;
|
||||
if (!v) return false;
|
||||
const code = String(v.level_code || 'V0').trim() || 'V0';
|
||||
if (code === 'V0') return false;
|
||||
const expireAt = Number(v.expire_at || 0);
|
||||
// expire_at=0 表示终身,视为可继承;>0 须未过期
|
||||
if (expireAt > 0 && expireAt * 1000 <= Date.now()) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
/** 按时长预设解析对应丝带资源 */
|
||||
function ribbonByPreset(preset?: string) {
|
||||
if (preset === 'keep') {
|
||||
const code = String(currentVip.value?.duration_type || '').trim();
|
||||
if (!code) return null;
|
||||
return durationTypes.value.find((i) => i.code === code) || null;
|
||||
}
|
||||
const code = PRESET_TO_DURATION[preset || ''] || '';
|
||||
if (!code) return null;
|
||||
return durationTypes.value.find((i) => i.code === code) || null;
|
||||
@@ -57,16 +79,18 @@ const selectedDurationType = computed(() =>
|
||||
ribbonByPreset(durationPreset.value),
|
||||
);
|
||||
|
||||
/** 时长卡片数据:附带丝带图,避免模板重复查找 */
|
||||
/** 时长卡片:普通会员隐藏 keep;其它预设照常 */
|
||||
const durationCards = computed(() =>
|
||||
presets.value.map((p) => {
|
||||
const ribbon = ribbonByPreset(p.value);
|
||||
return {
|
||||
...p,
|
||||
ribbonUrl: ribbon?.badge_url || '',
|
||||
ribbonName: ribbon?.name || '',
|
||||
};
|
||||
}),
|
||||
presets.value
|
||||
.filter((p) => p.value !== 'keep' || canKeepDuration.value)
|
||||
.map((p) => {
|
||||
const ribbon = ribbonByPreset(p.value);
|
||||
return {
|
||||
...p,
|
||||
ribbonUrl: ribbon?.badge_url || '',
|
||||
ribbonName: ribbon?.name || '',
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const previewBadgeUrl = computed(() => selectedLevel.value?.badge_url || '');
|
||||
@@ -78,10 +102,36 @@ const previewLevelName = computed(
|
||||
);
|
||||
const previewDurationLabel = computed(() => {
|
||||
if (!durationPreset.value) return '请选择时长';
|
||||
if (durationPreset.value === 'keep') {
|
||||
const label = String(currentVip.value?.duration_label || '').trim();
|
||||
const expireHint = formatVipExpireHint(currentVip.value);
|
||||
if (label && expireHint) return `继承剩余:${label}(${expireHint})`;
|
||||
if (label) return `继承剩余:${label}`;
|
||||
return '继承剩余时间(不改动)';
|
||||
}
|
||||
const preset = presets.value.find((p) => p.value === durationPreset.value);
|
||||
return preset?.label || selectedDurationType.value?.name || '请选择时长';
|
||||
});
|
||||
|
||||
/**
|
||||
* 格式化当前会员到期提示(终身 / 已格式化字符串 / 时间戳)
|
||||
*/
|
||||
function formatVipExpireHint(v: Record<string, any> | null | undefined): string {
|
||||
if (!v) return '';
|
||||
if (v.is_lifetime) return '终身';
|
||||
const raw = v.expire_at;
|
||||
if (raw == null || raw === '' || raw === 0) return '';
|
||||
if (typeof raw === 'string' && Number.isNaN(Number(raw))) {
|
||||
return `至 ${raw}`;
|
||||
}
|
||||
const n = Number(raw);
|
||||
if (!(n > 0)) return '';
|
||||
const d = new Date(n * 1000);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const pad = (x: number) => String(x).padStart(2, '0');
|
||||
return `至 ${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
const title = computed(() => `升级VIP - ${storeName.value || storeId.value}`);
|
||||
|
||||
/** 选择会员等级 */
|
||||
@@ -139,6 +189,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
durationPreset.value = undefined;
|
||||
customYears.value = undefined;
|
||||
remark.value = '';
|
||||
currentVip.value = null;
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<Record<string, any>>() || {};
|
||||
@@ -146,6 +197,7 @@ const [Modal, modalApi] = useVbenModal({
|
||||
onSuccess.value = data.onSuccess || null;
|
||||
storeId.value = Number(data.store_id || 0);
|
||||
storeName.value = data.store_name || '';
|
||||
currentVip.value = data.vip || null;
|
||||
const [levelList, presetList, durationList] = await Promise.all([
|
||||
getVipLevelOption(),
|
||||
getVipDurationPresets(),
|
||||
@@ -157,10 +209,14 @@ const [Modal, modalApi] = useVbenModal({
|
||||
value: i.value,
|
||||
}));
|
||||
durationTypes.value = durationList || [];
|
||||
// 回填当前 VIP 等级(便于续期)
|
||||
// 回填当前 VIP 等级(便于续期/改档)
|
||||
if (data.vip?.level_id) {
|
||||
levelId.value = Number(data.vip.level_id);
|
||||
}
|
||||
// 非普通会员:默认选「继承剩余时间」,到期日不改动
|
||||
if (canKeepDuration.value) {
|
||||
durationPreset.value = 'keep';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -170,8 +226,9 @@ watch(durationPreset, (v) => {
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="title" class="w-[50%]">
|
||||
<!-- 预览:徽标卡片 + 丝带(随时长自动适配) -->
|
||||
<!-- 预览:黑金卡面 + 徽标丝带(随时长自动适配) -->
|
||||
<div class="vip-upgrade-preview mb-5">
|
||||
<div class="vip-upgrade-preview__glow"></div>
|
||||
<VipBadgeCombo
|
||||
size="xl"
|
||||
:badge-url="previewBadgeUrl"
|
||||
@@ -273,28 +330,53 @@ watch(durationPreset, (v) => {
|
||||
</Modal>
|
||||
</template>
|
||||
<style scoped>
|
||||
/* 黑金预览卡:与会员卡组件同一视觉语言,深色卡面亮暗主题通用 */
|
||||
.vip-upgrade-preview {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 20px 24px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #2c3e50 0%, #3d5a40 50%, #6acdbb 100%);
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
background:
|
||||
radial-gradient(120% 160% at 100% 0%, rgba(212, 175, 55, 0.22) 0%, rgba(212, 175, 55, 0) 45%),
|
||||
linear-gradient(135deg, #23201a 0%, #12100c 55%, #1f1a12 100%);
|
||||
border: 1px solid rgba(220, 186, 110, 0.35);
|
||||
box-shadow:
|
||||
0 10px 26px rgba(0, 0, 0, 0.24),
|
||||
0 2px 8px rgba(212, 175, 55, 0.12);
|
||||
color: #f5e3bd;
|
||||
}
|
||||
.vip-upgrade-preview__glow {
|
||||
position: absolute;
|
||||
top: -60px;
|
||||
right: -40px;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(240, 217, 168, 0.26) 0%, rgba(240, 217, 168, 0) 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.vip-upgrade-preview__text {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.vip-upgrade-preview__title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 1px;
|
||||
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.vip-upgrade-preview__sub {
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
color: rgba(240, 224, 190, 0.85);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.vip-upgrade-preview__price {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
color: rgba(240, 224, 190, 0.68);
|
||||
}
|
||||
/* 两行 + 横向滑动的单选卡片轨道 */
|
||||
.vip-pick-scroll {
|
||||
@@ -397,11 +479,7 @@ watch(durationPreset, (v) => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* ========== 暗色适配(html.dark / .dark)========== */
|
||||
html.dark .vip-upgrade-preview,
|
||||
.dark .vip-upgrade-preview {
|
||||
background: linear-gradient(135deg, #1a2332 0%, #243528 50%, #2d6b5f 100%);
|
||||
box-shadow: inset 0 0 0 1px rgba(106, 205, 187, 0.18);
|
||||
}
|
||||
/* 黑金预览卡为深色卡面,亮暗主题通用,无需暗色分支 */
|
||||
html.dark .vip-pick-scroll::-webkit-scrollbar-thumb,
|
||||
.dark .vip-pick-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { $t } from '#/locales';
|
||||
|
||||
const appName = computed(() => preferences.app.name);
|
||||
const logo = computed(() => preferences.logo.source);
|
||||
const logoDark = computed(() => preferences.logo.sourceDark);
|
||||
// const logo = 'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/logo.png?Expires=1734595037&OSSAccessKeyId=TMP.3KhpnCAjxkdTGWSj1ZCdYe3e9c6MBfEVZwN1CiHdUKUAT2D1mPEpACYgYMkUiQDVqy9Zqb7VqgUoh21W37FC3CztogHJPN&Signature=KmymeelO2OZxaAmvm2ACDK5UfF8%3D';
|
||||
</script>
|
||||
|
||||
@@ -15,6 +16,7 @@ const logo = computed(() => preferences.logo.source);
|
||||
<AuthPageLayout
|
||||
:app-name="appName"
|
||||
:logo="logo"
|
||||
:logo-dark="logoDark"
|
||||
:page-description="$t('authentication.pageDesc')"
|
||||
:page-title="$t('authentication.pageTitle')"
|
||||
:version="preferences.app.version"
|
||||
|
||||
@@ -1,187 +1,217 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 管理端基础布局:铃铛站内信轮询 + 动作宿主挂载
|
||||
*/
|
||||
import type { NotificationItem } from '@vben/layouts';
|
||||
|
||||
import {computed, onUnmounted, ref, watch} from 'vue';
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
|
||||
|
||||
// import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
|
||||
import { AuthenticationLoginExpiredModal, VbenIcon } from '@vben/common-ui';
|
||||
import { useWatermark } from '@vben/hooks';
|
||||
// import { BookOpenText, CircleHelp, MdiGithub } from '@vben/icons';
|
||||
// import { CircleHelp } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import {
|
||||
BasicLayout,
|
||||
LockScreen,
|
||||
Notification,
|
||||
UserDropdown,
|
||||
} from '@vben/layouts';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
import { useAccessStore, useUserStore } from '@vben/stores';
|
||||
// import { openWindow } from '@vben/utils';
|
||||
|
||||
import {
|
||||
Bell,
|
||||
CalendarClock,
|
||||
Megaphone,
|
||||
Users,
|
||||
} from 'lucide-vue-next';
|
||||
import { notification } from 'ant-design-vue';
|
||||
import { Bell, Users } from 'lucide-vue-next';
|
||||
|
||||
// import { $t } from '#/locales';
|
||||
import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransferFloat.vue';
|
||||
import HeaderVipBadge from '#/components/vip/HeaderVipBadge.vue';
|
||||
import { useAuthStore } from '#/store';
|
||||
import LoginForm from '#/views/_core/authentication/login.vue';
|
||||
import SwitchAccountModal from '#/layouts/components/SwitchAccountModal.vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {getNoticeListApi, readAllApi} from "#/views/notice/api";
|
||||
import {formatTimeToRelative} from "#/util/tool";
|
||||
import {notification} from "ant-design-vue";
|
||||
import { useAuthStore } from '#/store';
|
||||
import { desktop, isElectron } from '#/util/desktop';
|
||||
import { formatTimeToRelative } from '#/util/tool';
|
||||
import LoginForm from '#/views/_core/authentication/login.vue';
|
||||
import NoticeActionHost from '#/views/notice/action/NoticeActionHost.vue';
|
||||
import {
|
||||
emitNoticeRefresh,
|
||||
onNoticeRefresh,
|
||||
openNoticeAction,
|
||||
} from '#/views/notice/action/bus';
|
||||
import { resolveNoticeColor } from '#/views/notice/action/types';
|
||||
import {
|
||||
getNoticeListApi,
|
||||
getNoticeUnreadCountApi,
|
||||
readAllApi,
|
||||
readApi,
|
||||
} from '#/views/notice/api';
|
||||
|
||||
const router = useRouter();
|
||||
const switchAccountModalRef = ref<InstanceType<typeof SwitchAccountModal>>();
|
||||
|
||||
// 样式相关
|
||||
const typeIcons = {
|
||||
0: Megaphone,
|
||||
1: Bell,
|
||||
2: CalendarClock,
|
||||
};
|
||||
|
||||
const typeColors = {
|
||||
0: 'text-sky-500',
|
||||
1: 'text-amber-500',
|
||||
2: 'text-emerald-500',
|
||||
};
|
||||
|
||||
const typeBgColors = {
|
||||
0: 'bg-sky-50',
|
||||
1: 'bg-amber-50',
|
||||
2: 'bg-emerald-50',
|
||||
};
|
||||
|
||||
const typeBorderColors = {
|
||||
0: 'border-sky-200',
|
||||
1: 'border-amber-200',
|
||||
2: 'border-emerald-200',
|
||||
};
|
||||
|
||||
const notifications = ref<NotificationItem[]>([]);
|
||||
const unreadCount = ref(0);
|
||||
|
||||
// 获取消息列表数据
|
||||
/** 拉取未读列表 + 角标数字 */
|
||||
const getNoticeList = async () => {
|
||||
try {
|
||||
const res = await getNoticeListApi({
|
||||
status: 0,
|
||||
});
|
||||
let message = '未读消息';
|
||||
if (res.items.length > 0) {
|
||||
if (res.items.length > notifications.value.length) {
|
||||
if (notifications.value.length === 0) {
|
||||
message = '新消息';
|
||||
}
|
||||
notification.info({
|
||||
message: `您有${message}`,
|
||||
description: `您有 ${res.items.length} 条${message}请注意查收`,
|
||||
duration: 5,
|
||||
const [res, countRes] = await Promise.all([
|
||||
getNoticeListApi({ status: 0, page: 1, pageSize: 20 }),
|
||||
getNoticeUnreadCountApi().catch(() => null),
|
||||
]);
|
||||
const items = res?.items || [];
|
||||
const prevLen = notifications.value.length;
|
||||
if (items.length > prevLen && prevLen > 0) {
|
||||
notification.info({
|
||||
message: '您有新消息',
|
||||
description: `您有 ${items.length} 条未读消息请注意查收`,
|
||||
duration: 5,
|
||||
});
|
||||
// 桌面端且窗口未聚焦时补发系统通知(页内 Toast 此时用户看不到),点击跳消息中心
|
||||
if (isElectron() && !document.hasFocus()) {
|
||||
desktop.notify({
|
||||
title: '您有新消息',
|
||||
body: `您有 ${items.length} 条未读消息请注意查收`,
|
||||
route: '/notice/list',
|
||||
});
|
||||
desktop.flashFrame();
|
||||
}
|
||||
|
||||
notifications.value = res.items.map(item => ({
|
||||
id: item.id,
|
||||
icon: typeIcons[item.type],
|
||||
// 将API返回的数据映射到组件需要的格式
|
||||
message: item.title,
|
||||
title: getMessageTypeName(item.type),
|
||||
date: formatTimeToRelative(item.created_at),
|
||||
color: typeColors[item.type],
|
||||
isRead: item.status
|
||||
}));
|
||||
} else {
|
||||
notifications.value = [];
|
||||
}
|
||||
notifications.value = items.map((item: any) => {
|
||||
const color = resolveNoticeColor(item.type_color);
|
||||
return {
|
||||
id: item.id,
|
||||
// Notification 默认用组件当 icon;我们走自定义 content 槽,这里占位即可
|
||||
icon: Bell as any,
|
||||
message: item.detail || item.type_name || '',
|
||||
title: item.title || '通知',
|
||||
date: formatTimeToRelative(item.created_at),
|
||||
color: color.text,
|
||||
colorBg: color.bg,
|
||||
isRead: Number(item.status) === 1,
|
||||
status: item.status,
|
||||
type_code: item.type_code,
|
||||
type_name: item.type_name,
|
||||
type_icon: item.type_icon,
|
||||
type_color: item.type_color,
|
||||
action_type: item.action_type,
|
||||
action_payload: item.action_payload,
|
||||
detail: item.detail,
|
||||
content: item.content,
|
||||
edit_type: item.edit_type,
|
||||
priority: item.priority,
|
||||
raw: item,
|
||||
};
|
||||
});
|
||||
unreadCount.value = Number(
|
||||
countRes?.count ?? res?.unread_total ?? items.length,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('获取消息列表失败:', error);
|
||||
}
|
||||
};
|
||||
getNoticeList();
|
||||
// 每隔二十秒刷新一次
|
||||
let intervalId = setInterval(getNoticeList, 20_000);
|
||||
|
||||
// 封装定时器重启逻辑
|
||||
const restartInterval = () => {
|
||||
clearInterval(intervalId);
|
||||
getNoticeList(); // 立即获取最新数据
|
||||
getNoticeList();
|
||||
intervalId = setInterval(getNoticeList, 20_000);
|
||||
};
|
||||
|
||||
// 处理跨标签页修改
|
||||
const handleStorageChange = (event) => {
|
||||
const handleStorageChange = (event: StorageEvent) => {
|
||||
if (event.key === 'readStatus') {
|
||||
restartInterval();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理当前标签页修改(核心新增部分)
|
||||
const handleLocalChange = () => {
|
||||
restartInterval();
|
||||
};
|
||||
|
||||
// 添加双重监听
|
||||
window.addEventListener('storage', handleStorageChange); // 跨标签页监听
|
||||
const originalSetItem = localStorage.setItem.bind(localStorage);
|
||||
|
||||
// 拦截当前页的 localStorage.setItem 操作
|
||||
localStorage.setItem = (key, value) => {
|
||||
originalSetItem(key, value);
|
||||
if (key === 'readStatus') {
|
||||
handleLocalChange(); // 手动触发当前页回调
|
||||
restartInterval();
|
||||
}
|
||||
};
|
||||
|
||||
// 组件卸载时清理
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
const stopRefreshListen = onNoticeRefresh(() => restartInterval());
|
||||
|
||||
// 桌面端:未读数变化同步任务栏/Dock 角标(浏览器环境 no-op)
|
||||
watch(unreadCount, (count) => desktop.setBadge(count));
|
||||
|
||||
// 桌面端:系统通知点击后主进程回传目标路由,这里执行实际跳转
|
||||
desktop.onNavigate((route) => {
|
||||
router.push(route);
|
||||
});
|
||||
|
||||
// 桌面端:自动更新事件轻量提示(下载完成后的安装确认由主进程弹窗负责)
|
||||
desktop.onUpdateEvent((payload) => {
|
||||
if (payload.type === 'available') {
|
||||
const version = (payload.data as { version?: string })?.version;
|
||||
notification.info({
|
||||
message: '发现新版本',
|
||||
description: `新版本 ${version ? `v${version} ` : ''}正在后台下载,完成后将提示安装`,
|
||||
duration: 5,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(intervalId);
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
localStorage.setItem = originalSetItem; // 恢复原始方法
|
||||
localStorage.setItem = originalSetItem;
|
||||
stopRefreshListen();
|
||||
// 布局卸载(登出)时清空桌面角标,避免残留未读数
|
||||
desktop.setBadge(0);
|
||||
// 解绑系统通知跳转回调,避免登出后点击残留通知仍对已登出会话执行 router.push
|
||||
desktop.offNavigate();
|
||||
});
|
||||
|
||||
// 消息类型定义
|
||||
const messageTypes = [
|
||||
{ id: -1, name: '全部消息' },
|
||||
{ id: 0, name: '系统公告', icon: Megaphone },
|
||||
{ id: 1, name: '系统通知', icon: Bell },
|
||||
{ id: 2, name: '周报提醒', icon: CalendarClock },
|
||||
];
|
||||
// 根据类型ID获取类型名称
|
||||
const getMessageTypeName = (typeId) => {
|
||||
const type = messageTypes.find(t => t.id === typeId);
|
||||
return type ? type.name : '未知类型';
|
||||
};
|
||||
const goNoticeList = () => {
|
||||
// TODO 跳转通知列表
|
||||
router.push('/notice/list');
|
||||
};
|
||||
|
||||
// 处理消息点击
|
||||
const handleItemClick = (item) => {
|
||||
notifications.value.forEach((value) => {
|
||||
if (value.id === item.id) {
|
||||
value.status = 1;
|
||||
value.isRead = 1;
|
||||
}
|
||||
/** 从铃铛列表移除并扣减角标 */
|
||||
function removeFromBell(item: NotificationItem) {
|
||||
notifications.value = notifications.value.filter((n) => n.id !== item.id);
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1);
|
||||
}
|
||||
|
||||
/** 行点击:立刻取消未读并打开场景 */
|
||||
const handleItemClick = async (item: NotificationItem) => {
|
||||
const raw = (item as any).raw || item;
|
||||
const wasUnread = !item.isRead;
|
||||
item.isRead = true;
|
||||
if (wasUnread) {
|
||||
removeFromBell(item);
|
||||
}
|
||||
await openNoticeAction({
|
||||
...raw,
|
||||
id: item.id,
|
||||
status: wasUnread ? 0 : 1,
|
||||
});
|
||||
router.push(`/notice/detail/${item.id}`);
|
||||
};
|
||||
|
||||
const handleClick = async (item: NotificationItem) => {
|
||||
await handleItemClick(item);
|
||||
};
|
||||
|
||||
/** 勾选已读:只标已读,不打开详情 */
|
||||
async function handleMarkRead(item: NotificationItem) {
|
||||
if (item.isRead) return;
|
||||
item.isRead = true;
|
||||
removeFromBell(item);
|
||||
try {
|
||||
await readApi(Number(item.id));
|
||||
emitNoticeRefresh();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const userStore = useUserStore();
|
||||
const authStore = useAuthStore();
|
||||
const accessStore = useAccessStore();
|
||||
const { destroyWatermark, updateWatermark } = useWatermark();
|
||||
const showDot = computed(() =>
|
||||
notifications.value.some((item) => !item.isRead),
|
||||
);
|
||||
const { isDark } = usePreferences();
|
||||
const showDot = computed(() => unreadCount.value > 0);
|
||||
|
||||
/** 与原先悬浮窗一致:有 doctor_id 且绑定门店才显示传方入口 */
|
||||
const showDoctorTransfer = computed(() => {
|
||||
const info = userStore.userInfo as Record<string, any> | null | undefined;
|
||||
if (!info) return false;
|
||||
@@ -189,6 +219,13 @@ const showDoctorTransfer = computed(() => {
|
||||
});
|
||||
|
||||
const menus = computed(() => [
|
||||
{
|
||||
handler: () => {
|
||||
router.push({ name: 'Profile' });
|
||||
},
|
||||
icon: 'lucide:user',
|
||||
text: $t('page.auth.profile'),
|
||||
},
|
||||
{
|
||||
handler: () => {
|
||||
switchAccountModalRef.value?.openModal();
|
||||
@@ -210,7 +247,20 @@ async function handleLogout() {
|
||||
function handleNoticeClear() {
|
||||
readAllApi().then(() => {
|
||||
notifications.value = [];
|
||||
// 成功后可以重新获取数据或者直接更新本地状态
|
||||
unreadCount.value = 0;
|
||||
notification.success({
|
||||
message: '标记成功',
|
||||
duration: 2,
|
||||
});
|
||||
emitNoticeRefresh();
|
||||
});
|
||||
}
|
||||
|
||||
/** 全部已读:必须走后端 API */
|
||||
function handleMakeAll() {
|
||||
readAllApi().then(() => {
|
||||
notifications.value = [];
|
||||
unreadCount.value = 0;
|
||||
notification.success({
|
||||
message: '标记成功',
|
||||
duration: 2,
|
||||
@@ -218,43 +268,45 @@ function handleNoticeClear() {
|
||||
});
|
||||
}
|
||||
|
||||
function handleMakeAll() {
|
||||
notifications.value.forEach((item) => (item.isRead = true));
|
||||
}
|
||||
watch(
|
||||
() => preferences.app.watermark,
|
||||
async (enable) => {
|
||||
() => ({
|
||||
enable: preferences.app.watermark,
|
||||
isDark: isDark.value,
|
||||
}),
|
||||
async ({ enable }) => {
|
||||
if (enable) {
|
||||
await updateWatermark({
|
||||
// 这里更改水印内容
|
||||
// content: `${userStore.userInfo?.nick_name || import.meta.env.VITE_APP_TITLE}`,
|
||||
content: `${userStore.userInfo?.nick_name}\r\n${userStore.userInfo?.phone || import.meta.env.VITE_APP_TITLE}`,
|
||||
});
|
||||
} else {
|
||||
destroyWatermark();
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicLayout @clear-preferences-and-logout="handleLogout">
|
||||
<!-- index=55:紧挨全局搜索(50)右侧 -->
|
||||
<BasicLayout
|
||||
:avatar
|
||||
:text="userStore.userInfo?.realName"
|
||||
@clear-preferences-and-logout="handleLogout"
|
||||
@logout="handleLogout"
|
||||
>
|
||||
<template #header-right-55>
|
||||
<DoctorTransferFloat v-if="showDoctorTransfer" />
|
||||
</template>
|
||||
<template #user-dropdown>
|
||||
<!-- VIP 徽标放在头像左侧,与用户下拉并排 -->
|
||||
<div class="flex items-center">
|
||||
<HeaderVipBadge />
|
||||
<UserDropdown
|
||||
:avatar
|
||||
:description="userStore.userInfo?.email"
|
||||
:menus
|
||||
:tag-text="userStore.userInfo?.roles?.name"
|
||||
:text="userStore.userInfo?.nick_name"
|
||||
:description="userStore.userInfo?.email"
|
||||
:tag-text="userStore.userInfo?.roles?.name"
|
||||
@clear-preferences-and-logout="handleLogout"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
</div>
|
||||
@@ -262,12 +314,54 @@ watch(
|
||||
<template #notification>
|
||||
<Notification
|
||||
:dot="showDot"
|
||||
:count="unreadCount"
|
||||
:notifications="notifications"
|
||||
@clear="handleNoticeClear"
|
||||
@make-all="handleMakeAll"
|
||||
@read="handleItemClick"
|
||||
@read="handleMarkRead"
|
||||
@view-all="goNoticeList"
|
||||
/>
|
||||
@on-click="handleClick"
|
||||
>
|
||||
<template #content="{ item }">
|
||||
<span
|
||||
v-if="!item.isRead"
|
||||
class="absolute top-2 right-2 size-2 rounded-sm bg-primary"
|
||||
></span>
|
||||
<span
|
||||
class="relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-xl"
|
||||
:class="[(item as any).colorBg || 'bg-muted', (item as any).color]"
|
||||
>
|
||||
<VbenIcon
|
||||
v-if="(item as any).type_icon"
|
||||
:icon="(item as any).type_icon"
|
||||
class="size-5"
|
||||
/>
|
||||
<Bell v-else class="size-5" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1 pr-8 leading-none">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<span
|
||||
v-if="(item as any).type_name"
|
||||
class="truncate text-[11px] font-semibold"
|
||||
:class="(item as any).color"
|
||||
>
|
||||
{{ (item as any).type_name }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="truncate text-sm font-semibold text-foreground">
|
||||
{{ item.title }}
|
||||
</p>
|
||||
<p
|
||||
v-if="item.message"
|
||||
class="mt-1 line-clamp-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ item.message }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ item.date }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</Notification>
|
||||
<NoticeActionHost />
|
||||
</template>
|
||||
<template #extra>
|
||||
<AuthenticationLoginExpiredModal
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { LocaleSetupOptions, SupportedLanguagesType } from '@vben/locales';
|
||||
import type { Locale } from 'ant-design-vue/es/locale';
|
||||
|
||||
import type { App } from 'vue';
|
||||
|
||||
import type { LocaleSetupOptions, SupportedLanguagesType } from '@vben/locales';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user