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

This commit is contained in:
2026-08-14 21:38:02 +08:00
1600 changed files with 102038 additions and 21181 deletions

View File

@@ -0,0 +1,3 @@
{
"singleQuote": true
}

View File

@@ -0,0 +1,7 @@
---
'@vben/styles': patch
'@vben-core/form-ui': patch
'@vben/web-naive': patch
---
feat(@core/form-ui): 新增 useVbenForm 数组编辑器 VbenFormFieldArray

View File

@@ -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
View File

@@ -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
View File

@@ -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

View File

@@ -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
View File

@@ -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.

View File

@@ -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.

View File

@@ -30,7 +30,7 @@ jobs:
- windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0

View File

@@ -25,7 +25,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0

View File

@@ -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

View File

@@ -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}}'

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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.

View File

@@ -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 }}

View File

@@ -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'

View File

@@ -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
View 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

View File

@@ -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]).+$

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -1,3 +0,0 @@
# 每次 git pull 之后, 安装依赖
pnpm install

View File

@@ -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'],
};

View File

@@ -1 +1 @@
20.14.0
24.16.0

17
.npmrc
View File

@@ -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/

View File

@@ -1,18 +0,0 @@
dist
dev-dist
.local
.output.js
node_modules
.nvmrc
coverage
CODEOWNERS
.nitro
.output
**/*.svg
**/*.sh
public
.npmrc
*-lock.yaml

View File

@@ -1 +0,0 @@
export { default } from '@vben/prettier-config';

View File

@@ -2,3 +2,7 @@ dist
public
__tests__
coverage
.codex
.claude
.agent
.agents

View 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;
});

View 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);
});

View 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);
});

View 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);
});

View 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);
});

View 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);
});

View 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);
});

View 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);
});

View 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);
});

View 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);
});

View 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());
});

View 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);
});

View 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({});
});

View 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")
});

View 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
View File

@@ -0,0 +1,2 @@
out/
release/

46
apps/desktop/README.md Normal file
View 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` 并上传产物,桌面端即可自动更新。

View File

@@ -0,0 +1,78 @@
/**
* electron-builder 打包配置
* 用 JS 配置而不是 ymlmac 公证需要按「环境变量是否配齐」动态开关。
*
* 签名策略(按需求做成条件化,未配置证书时出未签名包且构建不报错):
* - 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;

View File

@@ -0,0 +1,26 @@
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
/**
* electron-vite 构建配置
* 只构建主进程与预加载脚本,不配置 renderer
* - 开发模式渲染层直接加载 web-antd 的 vite dev serverhttp://localhost:5666
* - 打包模式渲染层加载 app://local/web-antd 的生产 dist由主进程自定义协议提供服务
* externalizeDepsPlugindependencieselectron-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
View 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"
}
}

View 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 下 ElectronV8 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View 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();
}

View File

@@ -0,0 +1,24 @@
import { app, nativeImage } from 'electron';
import { getMainWindow } from './window';
/**
* 未读角标:按平台分支实现
* - macDock 图标原生支持文字角标,直接 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, '');
}
}

View 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);
}

View 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();
}

View 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();
});
}

View 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));
});
});
}

View 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/webelectron-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);
});
}

View 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;
}

View 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();
}

View 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);
}
}

View 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;
}
}

View 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();
}
}

View 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 提供的受限 APIcontextBridge / ipcRenderer 等)
*/
/**
* Windows 任务栏角标图:用 canvas 画「红底白字」数字返回 dataURL
* 为什么在 preload 画:主进程没有 DOM/canvaspreload 运行在渲染进程内有 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;

View 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"]
}

View File

@@ -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>
// 生产环境下注入百度统计

View File

@@ -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": {

View File

@@ -1 +0,0 @@
export { default } from '@vben/tailwind-config/postcss';

Binary file not shown.

View File

@@ -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);

View File

@@ -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>;

View File

@@ -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';

View File

@@ -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 });

View File

@@ -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) {

View File

@@ -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,
};
}

View File

@@ -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>();

View File

@@ -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>

View File

@@ -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 '';
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -0,0 +1,5 @@
/**
* 通用统计岛组件导出:紧凑玻璃质感统计小卡(设计对齐提现管理账户卡统计岛)
*/
export { default as StatIslands } from './stat-islands.vue';
export type { StatIslandItem } from './typing';

View 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>

View 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;
}

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
/**
* 卡片视图专用操作栏(与列表交互解耦)
*
* 列表单元格里 TableAction 平铺全部按钮没问题,但卡片 footer 空间小,
* 按钮超过 2 个会触发 TableAction 的两列 grid 换行堆叠,显得杂乱。
* 这里改成 C 端惯例:只露出前 maxVisible 个主操作(默认 1 个,通常是「详情」),
* 其余操作全部合并进「···」下拉菜单。
*
* 权限auth/ 显隐ifShow/ 二次确认popConfirm逻辑不重写
* - 挑主操作前先按与 TableAction 相同的规则过滤,保证露出的一定是可见按钮;
* - 渲染仍复用 TableActionflex=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>

View File

@@ -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';

View 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';

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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);

View File

@@ -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"

View File

@@ -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

View File

@@ -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