Compare commits
320 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 417bb28227 | |||
| 21ac6379eb | |||
| a4a7581031 | |||
| 99d79278fc | |||
| 7934872e5f | |||
| ea20a081c6 | |||
| 3d2699a499 | |||
| 457a57a588 | |||
| 2ae5c50997 | |||
| 4507a5a7d0 | |||
| 17730cfb10 | |||
| c4b93140f0 | |||
| 1a9257249f | |||
| 66280dc6a1 | |||
| 16c93a82a2 | |||
| 16d4b3f749 | |||
| 42f63f7134 | |||
| 9d12927793 | |||
| bb82a6ab83 | |||
| 76d2a45015 | |||
| 935251ee53 | |||
| 5472f1c9b9 | |||
| 744703811a | |||
| be3d59691b | |||
| f6bccd7536 | |||
| 4673d622b6 | |||
| 0e467a1e9d | |||
| 7a5610c0dc | |||
| 80d989f6d0 | |||
| 6097d39752 | |||
| 4bc62221b6 | |||
| c405bc9481 | |||
| 54cc46b33a | |||
| 65ee7cccb3 | |||
| 6da6e59b6d | |||
| 8e6d7488f4 | |||
| 9e050306bb | |||
| 7f1811098f | |||
| 40b3686f2e | |||
| dfc64ecece | |||
| 1df5110034 | |||
| 731a3da55d | |||
| 4e5b3cb96c | |||
| 5e8df2572f | |||
| 7d6474c92b | |||
| 86920da611 | |||
| 3828f12a2d | |||
| f269a25968 | |||
| 4a97eca056 | |||
| e91476f55c | |||
| afd08f05fc | |||
| b2f9b9d3b3 | |||
| 9cb70b7827 | |||
| 52b1e4fb6b | |||
| f101237792 | |||
| c84380836a | |||
| aca2c29946 | |||
| 9d1374d53c | |||
| 814130af34 | |||
| 4a5ae8d2de | |||
| 18fd9cb226 | |||
| 188c813447 | |||
| 737da9c585 | |||
| 80090fdb90 | |||
| abcf706acc | |||
| 760b75083d | |||
| 0b4575b181 | |||
| 57fb6d5256 | |||
| e516dd4ba6 | |||
| 6fc619315a | |||
| a5082ef4e4 | |||
| 9be884be20 | |||
| bbec2a3ff9 | |||
| aa851bb920 | |||
| 5610d3ea20 | |||
| 7a20a45035 | |||
| db3f77d6d3 | |||
| e6731afff5 | |||
| 3affb21d99 | |||
| 2585b70f6b | |||
| a4e162cab0 | |||
| 88cc365935 | |||
| 33801ca211 | |||
| 4f79426206 | |||
| 1185843982 | |||
| 0620651b12 | |||
| a82190d4aa | |||
| d0a7afd5df | |||
| 10fcb40d1d | |||
| c7dccb0664 | |||
| 1bcecabf7b | |||
| b7b508af08 | |||
| 917b3c7324 | |||
| 6a73a274bd | |||
| 82897ea690 | |||
| e04310c2c9 | |||
| d2a21581ff | |||
| a2c49cf600 | |||
| 3429c75600 | |||
| 0f57624cdf | |||
| 68fc6ad1a3 | |||
| b4727a1946 | |||
| f757b70174 | |||
| 3033a8a707 | |||
| fa77b772be | |||
| 2440605fce | |||
| 386b65cd05 | |||
| b6fd7c698c | |||
| 9376f8e68e | |||
| 3a3e9aead5 | |||
| 97009158e8 | |||
| a9b916221c | |||
| ac7cb6a31c | |||
| fdf928028c | |||
| 3f31d188f6 | |||
| c200206487 | |||
| b7aab0a6a5 | |||
| 37a0d59f13 | |||
| 163af5e80c | |||
| 8e148c6408 | |||
| 1f78af72fc | |||
| 169e0984a4 | |||
| be9093fa77 | |||
| 5a6fdc0efd | |||
| 7551eed484 | |||
| a4068df0be | |||
| ccf61ee6c3 | |||
| 3170fe889b | |||
| aa55508ed7 | |||
| 8d052bcac8 | |||
| ce2f5338de | |||
| 966c40a338 | |||
| fd42a4d6d5 | |||
| f883e0bec1 | |||
| 17bdbce8c8 | |||
| 61e86e731d | |||
| ff790c7a3a | |||
| 076b8f3407 | |||
| 269d93187c | |||
| 33676dcc8b | |||
| 290aed74f2 | |||
| fd0543dcc2 | |||
| fcd840d4fe | |||
| 2ba453bbed | |||
| 4b645bb916 | |||
| 0a8f1f8248 | |||
| eaafbef793 | |||
| 9f57c8fe0b | |||
| e912b2c710 | |||
| c0c5e9763e | |||
| 6e915a5977 | |||
| acd7c0db13 | |||
| 1ad46a62fd | |||
| bb02b796aa | |||
| a4ea7ce56e | |||
| 166d76d980 | |||
| f9f82e4d29 | |||
| b4fcde324d | |||
| b669889bb0 | |||
| 8a3b9e03fd | |||
| ec02658ede | |||
| bd9acc06a8 | |||
| f790691d5a | |||
| 838d5cfb6e | |||
| ac544a8ff5 | |||
| e7a4a7aff9 | |||
| 0d1a20d88d | |||
| 19aaddc4e2 | |||
| 1e24193b84 | |||
| cdec0c1a23 | |||
| 94cdecb075 | |||
| 7f0cf5dd8f | |||
| c8b8a3caa4 | |||
| 48eb653f1a | |||
| b3fbfe2d9d | |||
| da80cad976 | |||
| d6e05a8b44 | |||
| 5a196564d0 | |||
| 7ec1751ba6 | |||
| 270a838185 | |||
| 5b54fe5182 | |||
| 21676c11ff | |||
| 75f461df0f | |||
| b623365e38 | |||
| 8130915d0e | |||
| eeb72b24b5 | |||
| 68e320637e | |||
| f81c7614be | |||
| 2874fdfaa7 | |||
| 7dd7ce73bc | |||
| 94d09d0bdd | |||
| b0b65b454c | |||
| c490cb1c8e | |||
| 33e8a4a5d6 | |||
| 35640a2ade | |||
| 8db7ded1b5 | |||
| aa8e3467d3 | |||
| 8ed289a917 | |||
| 88ca601d07 | |||
| 50911ada4e | |||
| d065c90e71 | |||
| ad0a50d8c7 | |||
| d4d9620db2 | |||
| 267bf75bc1 | |||
| 09ec2c7d12 | |||
| ad0df6b140 | |||
| 3269b10bfd | |||
| 047632b75f | |||
| 7830ad0ebb | |||
| a61a22569d | |||
| c27c8544de | |||
| aadf03edd0 | |||
| effb89c4dc | |||
| 62a792b1da | |||
| 2ec6f6c4ba | |||
| a601f2bc55 | |||
| 5b0c0716fd | |||
| 463dcb269a | |||
| 5fdc233dbc | |||
| 9a4ca844f7 | |||
| 0c45e2289f | |||
| e251efff65 | |||
| faef37f457 | |||
| 25b3658576 | |||
| a1160bc394 | |||
| cc49ce2beb | |||
| 63556f7e82 | |||
| 4f55acb3bf | |||
| fb3c15aead | |||
| 8a4733c7d2 | |||
| d0f1ad0702 | |||
| 699b68990e | |||
| 33ee02bce6 | |||
| 83c5e14789 | |||
| a61d3cfe35 | |||
| 6018321d7e | |||
| 91abfda3b2 | |||
| 3fe6045e01 | |||
| 8d4e822e29 | |||
| 6fcd0c54e8 | |||
| c11473edb5 | |||
| 990f2811ae | |||
| 07eae7cab7 | |||
| 3c08e72f8d | |||
| d816110b4b | |||
| c9b75f55cc | |||
| 063fb2641c | |||
| f222641f00 | |||
| 7d2d4675fc | |||
| 51ae1ad017 | |||
| 9aba215fd0 | |||
| a09b1da78d | |||
| 29d3000b50 | |||
| 93ecda7617 | |||
| cec32dceb2 | |||
| 4e43500084 | |||
| a8b340403d | |||
| 768d3f8a88 | |||
| 7b9dee68cc | |||
| f1536ed24c | |||
| 5f62bebcc3 | |||
| a326c455ed | |||
| e94c5fc372 | |||
| 864e3dbb23 | |||
| 0ac7636c74 | |||
| 917301dea6 | |||
| e95d883c23 | |||
| 4949e1c957 | |||
| 18360ac41d | |||
| 8000a39382 | |||
| 8d80a24203 | |||
| 26f3348b28 | |||
| 1afb28cb2b | |||
| e54272b68b | |||
| 642cac1493 | |||
| 437727fdd4 | |||
| 7892c1f019 | |||
| cf48052b82 | |||
| a324856c78 | |||
| fc4bcd99d2 | |||
| a62577d5e4 | |||
| 79fb40da5f | |||
| 64296e1b3e | |||
| aa1618bf55 | |||
| 73c700e25b | |||
| 9c5d9083ad | |||
| 532dbaa5ee | |||
| 4421bdc864 | |||
| ebad5aec08 | |||
| 5ca4edc5d9 | |||
| 6158442e0d | |||
| 6697afba91 | |||
| b9a29c2fb4 | |||
| 816bacfe4e | |||
| ad069bcec8 | |||
| b8fb2e9718 | |||
| 68b0070ac2 | |||
| 69f4bf63ab | |||
| 8b316c85e9 | |||
| 137139af5f | |||
| 985c6b53c8 | |||
| a0f39d401a | |||
| 7b6f7ad2fb | |||
| 4e31d8b80c | |||
| 9b09413010 | |||
| c5b8321ac4 | |||
| 2aef0739ce | |||
| f16a36680b | |||
| 75ada37079 | |||
| 39cc8354cc | |||
| d3d5ebc66b | |||
| f9748b78a3 | |||
| 4fcd3bdcdb | |||
| ac572c6844 | |||
| 48d38950ae | |||
| 4733e59d1a | |||
| 4d721dfb5b | |||
| a005f3f27c | |||
| 264800ad94 | |||
| e50d699a2a |
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"image": "ghcr.io/yanhao98/h-devcontainer:main",
|
||||||
|
"runArgs": [
|
||||||
|
"--hostname=devcontainer-host",
|
||||||
|
"--name=${localWorkspaceFolderBasename}-devcontainer"
|
||||||
|
],
|
||||||
|
"forwardPorts": [4730, 4731, 5901],
|
||||||
|
"portsAttributes": {
|
||||||
|
"4730": { "label": "开发服务器端口", "onAutoForward": "notify" },
|
||||||
|
"4731": { "label": "预览服务器端口", "onAutoForward": "notify" }
|
||||||
|
},
|
||||||
|
"containerEnv": {
|
||||||
|
"TZ": "${localEnv:TZ:Asia/Shanghai}"
|
||||||
|
},
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": ["prettier.prettier-vscode", "vue.volar"],
|
||||||
|
"settings": {
|
||||||
|
// "tasks": { "version": "2.0.0", "tasks": [] },
|
||||||
|
"github.copilot.chat.codeGeneration.instructions": [
|
||||||
|
{
|
||||||
|
"text": "This dev container includes a lightweight Fluxbox based desktop that can be accessed using a VNC viewer or the web. GUI-based commands executed from the built-in VS Code terminal will open on the desktop automatically."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mounts": [
|
||||||
|
"source=${localWorkspaceFolderBasename}-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume",
|
||||||
|
"source=${localWorkspaceFolder}/.devcontainer/lifecycle-scripts.d,target=/usr/local/etc/lifecycle-scripts.d,type=bind,consistency=delegated"
|
||||||
|
],
|
||||||
|
"workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/${localWorkspaceFolderBasename},type=bind,consistency=delegated",
|
||||||
|
"initializeCommand": "docker pull ghcr.io/yanhao98/h-devcontainer:main;",
|
||||||
|
"onCreateCommand": "/usr/local/bin/run-lifecycle-scripts.sh onCreateCommand",
|
||||||
|
"updateContentCommand": "/usr/local/bin/run-lifecycle-scripts.sh updateContentCommand",
|
||||||
|
"postCreateCommand": "/usr/local/bin/run-lifecycle-scripts.sh postCreateCommand",
|
||||||
|
"postStartCommand": "/usr/local/bin/run-lifecycle-scripts.sh postStartCommand",
|
||||||
|
"postAttachCommand": "/usr/local/bin/run-lifecycle-scripts.sh postAttachCommand"
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/zsh -eu
|
||||||
|
h-setup-zh-locale
|
||||||
|
|
||||||
|
h-setup-bun-bin
|
||||||
|
h-setup-pnpm-bin
|
||||||
|
|
||||||
|
h-setup-chromium
|
||||||
|
h-setup-desktop-lite
|
||||||
|
|
||||||
|
h-install-node-modules
|
||||||
|
|
||||||
|
echo "-----------------------------"
|
||||||
|
echo "开发容器已启动并配置完成!"
|
||||||
|
echo "-----------------------------"
|
||||||
@@ -1,4 +1,15 @@
|
|||||||
VITE_BASE=/
|
VITE_APP_BUILD_TIME=NOT_SET
|
||||||
|
VITE_APP_BUILD_COMMIT=NOT_SET
|
||||||
|
|
||||||
VITE_BUILD_SOURCE_MAP=true
|
VITE_BUILD_SOURCE_MAP=true
|
||||||
VITE_BUILD_COMMIT=
|
VITE_BUILD_MINIFY=true
|
||||||
VITE_BUILD_TIME=
|
VITE_CLOUDFLARE_SERVER_ENABLED=true
|
||||||
|
|
||||||
|
VITE_APP_TITLE=vue-ts-example-2025
|
||||||
|
VITE_APP_BASE=/
|
||||||
|
VITE_APP_ENABLE_VUE_DEVTOOLS=true
|
||||||
|
VITE_APP_MENU_SHOW_DEMOS=true
|
||||||
|
VITE_APP_MENU_SHOW_ORDER=true
|
||||||
|
VITE_APP_ENABLE_ROUTER_LOG_GUARD=true
|
||||||
|
VITE_APP_API_URL=/API
|
||||||
|
VITE_APP_PROXY=[["/API","https://jsonplaceholder.typicode.com"]]
|
||||||
|
|||||||
@@ -12,24 +12,37 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-build-and-typecheck:
|
playwright:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: mcr.microsoft.com/playwright:v1.57.0-noble
|
||||||
|
steps:
|
||||||
|
- name: ⚙️ 设置 Node 环境
|
||||||
|
uses: yanhao98/composite-actions/setup-node-environment@faab20ac2f9c85dfce1a4147fca493bf632bd744
|
||||||
|
# - name: 📥 安装 Playwright 浏览器
|
||||||
|
# run: pnpm exec playwright install --with-deps
|
||||||
|
- name: 📦 构建项目
|
||||||
|
run: pnpm run build-only
|
||||||
|
- name: ▶️ 运行 Playwright 测试
|
||||||
|
run: pnpm exec playwright test
|
||||||
|
|
||||||
|
build-and-deploy:
|
||||||
|
needs: playwright
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: gitea/runner-images:ubuntu-latest-slim # https://github.com/cloudflare/wrangler-action/issues/329#issuecomment-3046747722
|
container: gitea/runner-images:ubuntu-latest-slim # https://github.com/cloudflare/wrangler-action/issues/329#issuecomment-3046747722
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 🛠️ 设置Node环境
|
- name: 🛠️ 设置Node环境
|
||||||
uses: yanhao98/composite-actions/setup-node-environment@25eb4dc0c134cc9df2b7c569aa54140a366b45a8
|
uses: yanhao98/composite-actions/setup-node-environment@faab20ac2f9c85dfce1a4147fca493bf632bd744
|
||||||
|
|
||||||
- name: 🔍 静态代码分析
|
- name: 🔍 静态代码分析
|
||||||
run: pnpm run lint
|
run: pnpm run lint
|
||||||
|
|
||||||
- name: 📦 构建项目
|
- name: 📦 构建项目
|
||||||
run: pnpm run build-only
|
run: |
|
||||||
|
export VITE_APP_BUILD_TIME=$(date +"%Y-%m-%d %H:%M:%S")
|
||||||
|
pnpm run build-only
|
||||||
env:
|
env:
|
||||||
VITE_BUILD_COMMIT: ${{ github.sha }}
|
VITE_APP_BUILD_COMMIT: ${{ github.sha }}
|
||||||
|
|
||||||
- name: 🧪 单元测试
|
|
||||||
run: pnpm run test:unit
|
|
||||||
|
|
||||||
- name: 📊 计算构建大小
|
- name: 📊 计算构建大小
|
||||||
run: |
|
run: |
|
||||||
@@ -41,36 +54,14 @@ jobs:
|
|||||||
echo "🔹 文件总数: $(find dist -type f | wc -l) 个文件"
|
echo "🔹 文件总数: $(find dist -type f | wc -l) 个文件"
|
||||||
echo "----------------------------------------"
|
echo "----------------------------------------"
|
||||||
|
|
||||||
|
- name: 🧪 单元测试
|
||||||
|
run: pnpm exec vitest run
|
||||||
|
|
||||||
- name: ✅ 类型检查
|
- name: ✅ 类型检查
|
||||||
run: pnpm run type-check # 要先 build,保证 components.d.ts 存在
|
run: pnpm run type-check # 要先 build,保证 components.d.ts 存在
|
||||||
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: lint-build-and-typecheck
|
|
||||||
if: github.ref == 'refs/heads/main'
|
|
||||||
|
|
||||||
# https://github.com/cloudflare/wrangler-action/issues/329#issuecomment-3046747722
|
|
||||||
container:
|
|
||||||
image: gitea/runner-images:ubuntu-latest-slim
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: 🛠️ 设置Node环境
|
|
||||||
uses: yanhao98/composite-actions/setup-node-environment@25eb4dc0c134cc9df2b7c569aa54140a366b45a8
|
|
||||||
|
|
||||||
- name: 📦 构建项目
|
|
||||||
run: pnpm run build-only
|
|
||||||
env:
|
|
||||||
VITE_BUILD_COMMIT: ${{ github.sha }}
|
|
||||||
|
|
||||||
# - name: 🚀 上传版本到 Cloudflare
|
|
||||||
# uses: cloudflare/wrangler-action@v3
|
|
||||||
# id: upload
|
|
||||||
# with:
|
|
||||||
# apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
# accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
# command: versions upload --tag "${{ github.sha }}" --message "Deploy commit ${{ github.sha }} from ${{ github.ref_name }}"
|
|
||||||
|
|
||||||
- name: 🚀 部署到 Cloudflare
|
- name: 🚀 部署到 Cloudflare
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
uses: cloudflare/wrangler-action@v3
|
uses: cloudflare/wrangler-action@v3
|
||||||
with:
|
with:
|
||||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
env:
|
|
||||||
TZ: Asia/Shanghai
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
playwright:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container: mcr.microsoft.com/playwright:v1.55.0-noble
|
|
||||||
steps:
|
|
||||||
- name: ⚙️ 设置 Node 环境
|
|
||||||
uses: yanhao98/composite-actions/setup-node-environment@25eb4dc0c134cc9df2b7c569aa54140a366b45a8
|
|
||||||
# - name: 📥 安装 Playwright 浏览器
|
|
||||||
# run: pnpm exec playwright install --with-deps
|
|
||||||
- name: 🔄 更新依赖
|
|
||||||
run: pnpm update --latest
|
|
||||||
- name: 📦 构建项目
|
|
||||||
run: pnpm run build-only
|
|
||||||
- name: ▶️ 运行 Playwright 测试
|
|
||||||
run: pnpm exec playwright test
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
name: 测试最新依赖
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
env:
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
|
||||||
|
on:
|
||||||
|
# push:
|
||||||
|
# branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: "30 22 * * *" # 22:30 UTC = 6:30 CST
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: gitea/runner-images:ubuntu-latest-slim # https://github.com/cloudflare/wrangler-action/issues/329#issuecomment-3046747722
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@main
|
||||||
|
with:
|
||||||
|
# fetch-depth: 0 # 0 代表完整检出,semantic-release 需要
|
||||||
|
filter: blob:none # 我们不需要所有 blob,只需要完整的树
|
||||||
|
show-progress: false
|
||||||
|
- run: rm pnpm-lock.yaml
|
||||||
|
- uses: pnpm/action-setup@master # https://github.com/pnpm/action-setup?tab=readme-ov-file#inputs
|
||||||
|
|
||||||
|
- uses: actions/setup-node@main # https://github.com/actions/setup-node?tab=readme-ov-file#usage
|
||||||
|
with:
|
||||||
|
cache: ""
|
||||||
|
|
||||||
|
- run: pnpm up --latest
|
||||||
|
- run: pnpm outdated
|
||||||
|
|
||||||
|
- name: 🔍 静态代码分析
|
||||||
|
run: pnpm run lint
|
||||||
|
|
||||||
|
- name: 📦 构建项目
|
||||||
|
run: pnpm run build-only
|
||||||
|
env:
|
||||||
|
VITE_APP_BUILD_COMMIT: ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: 📊 计算构建大小
|
||||||
|
run: |
|
||||||
|
echo "📊 构建大小统计:"
|
||||||
|
echo "----------------------------------------"
|
||||||
|
echo "🔹 人类可读格式: $(du -sh dist | cut -f1)"
|
||||||
|
echo "🔹 以MB为单位: $(du -sm dist | cut -f1) MB"
|
||||||
|
echo "🔹 以KB为单位: $(du -sk dist | cut -f1) KB"
|
||||||
|
echo "🔹 文件总数: $(find dist -type f | wc -l) 个文件"
|
||||||
|
echo "----------------------------------------"
|
||||||
|
|
||||||
|
- name: 🧪 单元测试
|
||||||
|
run: pnpm exec vitest run
|
||||||
|
|
||||||
|
- name: ✅ 类型检查
|
||||||
|
run: pnpm run type-check # 要先 build,保证 components.d.ts 存在
|
||||||
|
|
||||||
|
playwright:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: mcr.microsoft.com/playwright:v1.57.0-noble
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@main
|
||||||
|
with:
|
||||||
|
# fetch-depth: 0 # 0 代表完整检出,semantic-release 需要
|
||||||
|
filter: blob:none # 我们不需要所有 blob,只需要完整的树
|
||||||
|
show-progress: false
|
||||||
|
|
||||||
|
- run: rm pnpm-lock.yaml
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@master # https://github.com/pnpm/action-setup?tab=readme-ov-file#inputs
|
||||||
|
|
||||||
|
- uses: actions/setup-node@main # https://github.com/actions/setup-node?tab=readme-ov-file#usage
|
||||||
|
with:
|
||||||
|
cache: ""
|
||||||
|
|
||||||
|
- run: pnpm up --latest
|
||||||
|
- run: pnpm outdated
|
||||||
|
|
||||||
|
- name: 📦 构建项目
|
||||||
|
run: pnpm run build-only
|
||||||
|
|
||||||
|
- name: ▶️ 运行 Playwright 测试
|
||||||
|
run: pnpm exec playwright test
|
||||||
+2
-1
@@ -7,6 +7,7 @@ yarn-error.log*
|
|||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
|
.pnpm-store
|
||||||
node_modules
|
node_modules
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dist
|
dist
|
||||||
@@ -27,7 +28,7 @@ coverage
|
|||||||
|
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
test-results/
|
playwright-test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
|
||||||
# wrangler files
|
# wrangler files
|
||||||
|
|||||||
+11
-11
@@ -3,18 +3,18 @@
|
|||||||
- https://typicode.github.io/husky/zh/troubleshoot.html#找不到命令-command-not-found
|
- https://typicode.github.io/husky/zh/troubleshoot.html#找不到命令-command-not-found
|
||||||
- https://typicode.github.io/husky/zh/how-to.html#node-版本管理器和-gui
|
- https://typicode.github.io/husky/zh/how-to.html#node-版本管理器和-gui
|
||||||
|
|
||||||
```shell
|
```bash
|
||||||
ln -s $(which pnpm) $HOME/.local/bin/pnpm
|
ln -s $(which pnpm) $HOME/.local/bin/pnpm
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
```bash
|
||||||
# if command -v pnpm >/dev/null 2>&1; then
|
if command -v pnpm >/dev/null 2>&1; then
|
||||||
# # 如果 pnpm 可用,直接使用它
|
# 如果 pnpm 可用,直接使用它
|
||||||
# pnpm exec lint-staged
|
pnpm exec lint-staged
|
||||||
# else
|
else
|
||||||
# # 如果 pnpm 不可用,使用 $HOME/.local/bin/pnpm
|
# 如果 pnpm 不可用,使用 $HOME/.local/bin/pnpm
|
||||||
# # ln -s $(which pnpm) $HOME/.local/bin/pnpm
|
# ln -s $(which pnpm) $HOME/.local/bin/pnpm
|
||||||
# echo "找不到 pnpm,使用 $HOME/.local/bin/pnpm"
|
echo "找不到 pnpm,使用 $HOME/.local/bin/pnpm"
|
||||||
# "$HOME"/.local/bin/pnpm exec lint-staged
|
"$HOME"/.local/bin/pnpm exec lint-staged
|
||||||
# fi
|
fi
|
||||||
```
|
```
|
||||||
+11
-3
@@ -1,5 +1,13 @@
|
|||||||
# 此钩子在 pre-commit 钩子成功完成后,用于检查提交消息。
|
# 此钩子在 pre-commit 钩子成功完成后,用于检查提交消息。
|
||||||
echo "📝 [Commit-msg] 正在运行 commit-msg 钩子..."
|
echo "📝 [Commit-msg] 正在运行 commit-msg 钩子..."
|
||||||
echo "检查提交消息:$1"
|
|
||||||
pnpm exec commitlint --edit $1
|
echo "🟢 检查提交消息:$1"
|
||||||
echo "✅ [Commit-msg] commit-msg 钩子完成!"
|
cat $1
|
||||||
|
|
||||||
|
# node -v
|
||||||
|
echo "🟢 [Commit-msg] Node 版本:$(node -v)"
|
||||||
|
|
||||||
|
# pnpm exec commitlint --edit $1
|
||||||
|
node node_modules/@commitlint/cli/cli.js --edit $1
|
||||||
|
|
||||||
|
echo "📝 [Commit-msg] commit-msg 钩子完成!"
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# 此钩子在 git merge 或 git pull 成功完成后运行。
|
# 此钩子在 git merge 或 git pull 成功完成后运行。
|
||||||
echo "🔗 [Post-merge] 正在安装依赖..."
|
echo "🔗 [Post-merge] 正在安装依赖..."
|
||||||
pnpm install
|
pnpm install
|
||||||
echo "✅ [Post-merge] 依赖安装完成!"
|
echo "🔗 [Post-merge] 依赖安装完成!"
|
||||||
|
|||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
# 此钩子在执行 git commit 命令时,在创建提交之前运行。
|
# 此钩子在执行 git commit 命令时,在创建提交之前运行。
|
||||||
echo "🧹 [Pre-commit] 正在运行 lint-staged..."
|
echo "🧹 [Pre-commit] 正在运行 lint-staged..."
|
||||||
pnpm exec lint-staged
|
pnpm exec lint-staged
|
||||||
echo "✅ [Pre-commit] lint-staged 完成!"
|
pnpm run lint:vue-i18n-extract
|
||||||
|
# pnpm run type-check
|
||||||
|
echo "🧹 [Pre-commit] lint-staged 完成!"
|
||||||
|
|||||||
@@ -1,9 +1 @@
|
|||||||
registry=https://registry.npmmirror.com/
|
# registry=https://registry.npmmirror.com/
|
||||||
|
|
||||||
# https://pnpm.io/zh/npmrc#node-mirrorltreleasedir
|
|
||||||
use-node-version=24.7.0
|
|
||||||
node-mirror:release=https://npmmirror.com/mirrors/node/ # pnpm config set node-mirror:release=https://npmmirror.com/mirrors/node/
|
|
||||||
node-mirror:rc=https://npmmirror.com/mirrors/node-rc/
|
|
||||||
node-mirror:nightly=https://npmmirror.com/mirrors/node-nightly/
|
|
||||||
|
|
||||||
# shamefully-hoist=true
|
|
||||||
|
|||||||
+5
-2
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/prettierrc",
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
"semi": false,
|
"semi": true,
|
||||||
|
"tabWidth": 2,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"printWidth": 100
|
"printWidth": 100,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"plugins": ["@prettier/plugin-oxc"]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+3
-2
@@ -2,10 +2,11 @@
|
|||||||
"recommendations": [
|
"recommendations": [
|
||||||
"Vue.volar",
|
"Vue.volar",
|
||||||
"vitest.explorer",
|
"vitest.explorer",
|
||||||
"ms-playwright.playwright",
|
|
||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
"EditorConfig.EditorConfig",
|
"EditorConfig.EditorConfig",
|
||||||
"oxc.oxc-vscode",
|
"oxc.oxc-vscode",
|
||||||
"esbenp.prettier-vscode"
|
"prettier.prettier-vscode",
|
||||||
|
"stylelint.vscode-stylelint",
|
||||||
|
"lokalise.i18n-ally"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+12
-1
@@ -8,7 +8,18 @@
|
|||||||
"url": "http://localhost:4730/",
|
"url": "http://localhost:4730/",
|
||||||
"webRoot": "${workspaceFolder}",
|
"webRoot": "${workspaceFolder}",
|
||||||
"firefoxExecutable": "/Applications/Firefox Nightly.app/Contents/MacOS/firefox",
|
"firefoxExecutable": "/Applications/Firefox Nightly.app/Contents/MacOS/firefox",
|
||||||
"preLaunchTask": "🚀 dev"
|
"preLaunchTask": "🚀 dev",
|
||||||
|
"pathMappings": [
|
||||||
|
{
|
||||||
|
"url": "http://localhost:4730",
|
||||||
|
"path": "${workspaceFolder}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"reAttach": true,
|
||||||
|
"reloadOnChange": {
|
||||||
|
"watch": "${workspaceFolder}/src/**/*.{js,jsx,ts,tsx,vue}",
|
||||||
|
"ignore": "**/node_modules/**"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+61
-3
@@ -1,7 +1,65 @@
|
|||||||
{
|
{
|
||||||
"editor.codeActionsOnSave": {
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"source.fixAll": "explicit"
|
"typescript.preferences.autoImportFileExcludePatterns": [
|
||||||
|
"vue-router$",
|
||||||
|
"**/src/composables/**",
|
||||||
|
"**/*-auto-imports.ts",
|
||||||
|
"**/*-auto-imports.types.ts"
|
||||||
|
],
|
||||||
|
"search.exclude": {
|
||||||
|
"public/report-ui-dist": true
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 代码格式化相关配置
|
||||||
|
// ============================================================
|
||||||
|
"prettier.enable": true,
|
||||||
|
"files.readonlyInclude": {
|
||||||
|
"dist/**": true
|
||||||
|
},
|
||||||
|
"eslint.enable": true,
|
||||||
|
"oxc.enable": true,
|
||||||
|
"stylelint.enable": true,
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "explicit",
|
||||||
|
"source.fixAll.eslint": "explicit",
|
||||||
|
"source.fixAll.oxc": "explicit",
|
||||||
|
"source.fixAll.stylelint": "explicit",
|
||||||
|
"source.organizeImports": "explicit"
|
||||||
|
},
|
||||||
|
"stylelint.validate": ["css", "less", "postcss", "scss", "vue"],
|
||||||
|
"scss.lint.unknownAtRules": "ignore",
|
||||||
|
"css.lint.unknownAtRules": "ignore",
|
||||||
|
"less.lint.unknownAtRules": "ignore",
|
||||||
|
"editor.defaultFormatter": "prettier.prettier-vscode",
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "prettier.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[vue]": {
|
||||||
|
"editor.defaultFormatter": "prettier.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[json]": {
|
||||||
|
"editor.defaultFormatter": "prettier.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[jsonc]": {
|
||||||
|
"editor.defaultFormatter": "prettier.prettier-vscode"
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// i18n-ally 配置
|
||||||
|
// ============================================================
|
||||||
|
// >>>>>
|
||||||
|
"i18n-ally.readonly": false,
|
||||||
|
"i18n-ally.namespace": false /* 禁用命名空间(@intlify/unplugin-vue-i18n不支持吧?) */,
|
||||||
|
"i18n-ally.localesPaths": ["src/locales/demo", "src/locales"],
|
||||||
|
// https://github.com/lokalise/i18n-ally/wiki/Path-Matcher
|
||||||
|
// 默认: 🗃 Path Matcher Regex: /^(?<locale>[\w-_]+)(?:.*\/|^).*\.(?<ext>json|ya?ml|json5)$/
|
||||||
|
"i18n-ally.pathMatcher": "{locale}.json",
|
||||||
|
"i18n-ally.enabledParsers": ["json"],
|
||||||
|
"i18n-ally.keystyle": "nested",
|
||||||
|
"i18n-ally.sourceLanguage": "zh-CN", // 翻译源语言 (源文件) 根据此语言文件翻译其他语言文件的变量和内容
|
||||||
|
"i18n-ally.displayLanguage": "zh-CN" // 显示语言 (显示文件/翻译文件)
|
||||||
|
// <<<<<
|
||||||
|
// "iconify.customCollectionJsonPaths": ["https://example.com/my-icons.json", "./local/icons.json"],
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+26
-22
@@ -2,32 +2,36 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "dev",
|
||||||
"label": "🚀 dev",
|
"label": "🚀 dev",
|
||||||
"type": "shell",
|
"detail": "启动开发服务器",
|
||||||
"command": "pnpm run dev",
|
"problemMatcher": [],
|
||||||
"isBackground": true,
|
"isBackground": true,
|
||||||
"problemMatcher": {
|
|
||||||
"owner": "vite",
|
|
||||||
"pattern": {
|
|
||||||
"regexp": "."
|
|
||||||
},
|
|
||||||
"background": {
|
|
||||||
"activeOnStart": true,
|
|
||||||
"beginsPattern": ".*VITE.*",
|
|
||||||
"endsPattern": ".*ready in.*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"group": {
|
|
||||||
"kind": "build",
|
|
||||||
"isDefault": true
|
|
||||||
},
|
|
||||||
"presentation": {
|
"presentation": {
|
||||||
"reveal": "always",
|
"reveal": "always", // 控制运行任务的终端是否显示。可按选项 "revealProblems" 进行替代。默认设置为“始终”。
|
||||||
"panel": "new"
|
"panel": "dedicated" // dedicated:一个任务独占一个终端,方便查看特定任务的日志,不会被其他任务干扰。
|
||||||
},
|
},
|
||||||
"runOptions": {
|
"group": { "kind": "build", "isDefault": false }
|
||||||
"instanceLimit": 1
|
},
|
||||||
}
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "build-only",
|
||||||
|
"label": "🔨 build-only",
|
||||||
|
"detail": "" /* 如果为空或省略,VSCode 将自动使用 package.json 中 scripts[scriptName] 的值作为 detail */,
|
||||||
|
"presentation": { "reveal": "always", "panel": "shared" },
|
||||||
|
"group": { "kind": "none", "isDefault": false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "wrangler:dev",
|
||||||
|
"label": "☁️ wrangler:dev",
|
||||||
|
"detail": "启动 Cloudflare Workers 开发服务器,相当于预览",
|
||||||
|
"dependsOn": ["🔨 build-only"],
|
||||||
|
"problemMatcher": [],
|
||||||
|
"isBackground": true,
|
||||||
|
"presentation": { "reveal": "always", "panel": "dedicated" },
|
||||||
|
"group": { "kind": "build", "isDefault": false }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
- **vite-plugin-fake-server**: Mock API under `/fake-api` (dev only) from `fake/` directory
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
This file provides guidance to AI when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Vue 3 TypeScript application with Vite.
|
||||||
|
|
||||||
|
## 开发服务器
|
||||||
|
|
||||||
|
- **不要启动开发服务器**: 开发服务器通常已经由用户启动。除非特别要求,否则不要执行 `pnpm dev` 之类的命令。
|
||||||
|
- **不要执行 Playwright 测试**: 除非用户明确要求,否则不要运行 Playwright 端到端测试。
|
||||||
|
|
||||||
|
### Routing & Layouts
|
||||||
|
|
||||||
|
- **File-based routing**: Uses `unplugin-vue-router` with `.page.vue` and `.page.md` extensions in `src/pages/`
|
||||||
|
- **Route naming**: Converts to PascalCase (e.g., `user-profile.page.vue` → `UserProfile`)
|
||||||
|
- **Layouts**: `vite-plugin-vue-meta-layouts` with default layout `base-layout/base-layout`
|
||||||
|
|
||||||
|
### Auto-Import Configuration
|
||||||
|
|
||||||
|
Multiple auto-import systems are active:
|
||||||
|
|
||||||
|
- **Vue APIs**: Core Vue, VueUse, Pinia, Vue Router, vue-i18n
|
||||||
|
- **Components**: Auto-registered from multiple UI libraries (Naive UI, PrimeVue)
|
||||||
|
- **Icons**: Uses `unplugin-icons` with `icon-` prefix; custom SVGs from `src/assets/icons/svgs/` available via `icon-svg-filename`
|
||||||
|
|
||||||
|
**IMPORTANT - Auto-Import Limitations**:
|
||||||
|
|
||||||
|
- **Dynamic components**: Auto-imported components cannot be used with `<component :is="..." />` syntax
|
||||||
|
- ❌ Avoid: `<component :is="`icon-${name}`" />`
|
||||||
|
- ✅ Use: `<icon-foo v-if="condition" />` with `v-if`/`v-else-if`/`v-else` directives
|
||||||
|
|
||||||
|
- **Icons in TypeScript/TSX files**: Auto-import for icons does NOT work. You must explicitly import them using the `~icons/` prefix:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import IconMenuRounded from '~icons/material-symbols/menu-rounded';
|
||||||
|
|
||||||
|
// Then use in JSX/TSX
|
||||||
|
const menuOption = {
|
||||||
|
icon: () => <IconMenuRounded />,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI Component Libraries
|
||||||
|
|
||||||
|
Project has multiple UI frameworks configured:
|
||||||
|
|
||||||
|
- **Naive UI**
|
||||||
|
|
||||||
|
- **PrimeVue**:
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
- **UnoCSS**: Wind preset
|
||||||
|
- **SCSS**: Modern compiler API with global imports from `@/styles/scss/global.scss`
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
Pinia stores
|
||||||
|
|
||||||
|
### Cloudflare Workers Integration
|
||||||
|
|
||||||
|
- **Server entry**: `server/index.ts` handles `/api/*` routes with KV storage
|
||||||
|
- **KV binding**: Named `KV`
|
||||||
|
|
||||||
|
### Vite Plugins (notable)
|
||||||
|
|
||||||
|
- **vue-macros**: Enhanced Vue features
|
||||||
|
- **unplugin-vue-markdown**: `.md` files as Vue components with frontmatter
|
||||||
|
|
||||||
|
### Path Aliases
|
||||||
|
|
||||||
|
- `@/` maps to `src/` directory
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
## 依赖管理
|
||||||
|
|
||||||
|
```shell
|
||||||
|
pnpm dedupe
|
||||||
|
```
|
||||||
|
|
||||||
|
去除重复的依赖包。当你的项目中存在多个版本的同一个包时,pnpm dedupe 会尝试将它们合并成尽可能少的版本,从而减少 node_modules 的体积。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dlx taze major --interactive
|
||||||
|
```
|
||||||
|
|
||||||
|
交互式地将项目依赖升级到最新的主要版本,可以逐个选择要升级哪些包。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dlx knip
|
||||||
|
```
|
||||||
|
|
||||||
|
检测项目中未使用的依赖、导出和文件,帮助清理冗余代码。
|
||||||
|
|
||||||
|
## Playwright
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright test
|
||||||
|
```
|
||||||
|
|
||||||
|
- `HEADLESS=true`:强制无头模式
|
||||||
|
- `--ui`:启动 Playwright 的图形用户界面,方便调试测试用例
|
||||||
|
- `--project=chromium`:指定使用 Chromium 浏览器进行测试
|
||||||
|
- `--quiet`:减少输出信息,只显示必要的内容
|
||||||
|
|
||||||
|
## Oxlint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
oxlint . --fix --ignore-path=.gitignore --print-config
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
oxlint . --fix --deny=correctness
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wrangler
|
||||||
|
|
||||||
|
### Pages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wrangler pages deploy dist --project-name=vue-ts-example-2025 --branch=preview
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wrangler pages deploy dist --project-name=vue-ts-example-2025
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wrangler deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wrangler versions upload
|
||||||
|
```
|
||||||
|
|
||||||
|
## 拆包体积分析
|
||||||
|
|
||||||
|
- https://github.com/nonzzz/vite-bundle-analyzer
|
||||||
|
- https://github.com/KusStar/vite-bundle-visualizer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dlx vite-bundle-visualizer -t treemap
|
||||||
|
pnpm dlx vite-bundle-visualizer -t sunburst
|
||||||
|
pnpm dlx vite-bundle-visualizer -t network
|
||||||
|
```
|
||||||
@@ -1,64 +1,7 @@
|
|||||||
# vue-ts-example-2025
|
# vue-ts-example-2025
|
||||||
|
|
||||||
This template should help get you started developing with Vue 3 in Vite.
|
## 参考资料
|
||||||
|
|
||||||
## Recommended IDE Setup
|
- [soybean-admin (GitHub)](https://github.com/soybeanjs/soybean-admin) — 2.0 预览: [预览地址](https://v2-0.soybean-admin-df1.pages.dev/home)
|
||||||
|
- [Vite:静态部署指南(中文)](https://vitejs.cn/vite3-cn/guide/static-deploy.html) — Vite 官方中文文档中关于静态站点部署的说明。
|
||||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
- [vitesse (GitHub)](https://github.com/antfu-collective/vitesse) — Anthony Fu 的 Vite + Vue 3 快速启动模板
|
||||||
|
|
||||||
## Type Support for `.vue` Imports in TS
|
|
||||||
|
|
||||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
|
||||||
|
|
||||||
## Customize configuration
|
|
||||||
|
|
||||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
|
||||||
|
|
||||||
## Project Setup
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Compile and Hot-Reload for Development
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Type-Check, Compile and Minify for Production
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run Unit Tests with [Vitest](https://vitest.dev/)
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm test:unit
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run End-to-End Tests with [Playwright](https://playwright.dev)
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# Install browsers for the first run
|
|
||||||
npx playwright install
|
|
||||||
|
|
||||||
# When testing on CI, must build the project first
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
# Runs the end-to-end tests
|
|
||||||
pnpm test:e2e
|
|
||||||
# Runs the tests only on Chromium
|
|
||||||
pnpm test:e2e --project=chromium
|
|
||||||
# Runs the tests of a specific file
|
|
||||||
pnpm test:e2e tests/example.spec.ts
|
|
||||||
# Runs the tests in debug mode
|
|
||||||
pnpm test:e2e --debug
|
|
||||||
```
|
|
||||||
|
|
||||||
### Lint with [ESLint](https://eslint.org/)
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm lint
|
|
||||||
```
|
|
||||||
|
|||||||
Vendored
+345
-320
@@ -6,330 +6,344 @@
|
|||||||
// biome-ignore lint: disable
|
// biome-ignore lint: disable
|
||||||
export {}
|
export {}
|
||||||
declare global {
|
declare global {
|
||||||
const EffectScope: typeof import('vue')['EffectScope']
|
const ConfirmationService: typeof import('utils4u/primevue').ConfirmationService
|
||||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
const DialogService: typeof import('utils4u/primevue').DialogService
|
||||||
const arrayToTree: typeof import('utils4u/array')['arrayToTree']
|
const EffectScope: typeof import('vue').EffectScope
|
||||||
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
|
const ToastService: typeof import('utils4u/primevue').ToastService
|
||||||
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
|
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
|
||||||
const computed: typeof import('vue')['computed']
|
const arrayToTree: typeof import('utils4u/array').arrayToTree
|
||||||
const computedAsync: typeof import('@vueuse/core')['computedAsync']
|
const asyncComputed: typeof import('@vueuse/core').asyncComputed
|
||||||
const computedEager: typeof import('@vueuse/core')['computedEager']
|
const autoResetRef: typeof import('@vueuse/core').autoResetRef
|
||||||
const computedInject: typeof import('@vueuse/core')['computedInject']
|
const computed: typeof import('vue').computed
|
||||||
const computedWithControl: typeof import('@vueuse/core')['computedWithControl']
|
const computedAsync: typeof import('@vueuse/core').computedAsync
|
||||||
const consola: typeof import('consola/browser')['consola']
|
const computedEager: typeof import('@vueuse/core').computedEager
|
||||||
const controlledComputed: typeof import('@vueuse/core')['controlledComputed']
|
const computedInject: typeof import('@vueuse/core').computedInject
|
||||||
const controlledRef: typeof import('@vueuse/core')['controlledRef']
|
const computedWithControl: typeof import('@vueuse/core').computedWithControl
|
||||||
const convertFileToBase64: typeof import('utils4u/browser')['convertFileToBase64']
|
const consola: typeof import('consola/browser').consola
|
||||||
const createApp: typeof import('vue')['createApp']
|
const controlledComputed: typeof import('@vueuse/core').controlledComputed
|
||||||
const createEventHook: typeof import('@vueuse/core')['createEventHook']
|
const controlledRef: typeof import('@vueuse/core').controlledRef
|
||||||
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
|
const convertFileToBase64: typeof import('utils4u/browser').convertFileToBase64
|
||||||
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
|
const createApp: typeof import('vue').createApp
|
||||||
const createLogGuard: typeof import('utils4u/vue-router')['createLogGuard']
|
const createEventHook: typeof import('@vueuse/core').createEventHook
|
||||||
const createNProgressGuard: typeof import('utils4u/vue-router')['createNProgressGuard']
|
const createGlobalState: typeof import('@vueuse/core').createGlobalState
|
||||||
const createPinia: typeof import('pinia')['createPinia']
|
const createInjectionState: typeof import('@vueuse/core').createInjectionState
|
||||||
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
|
const createLogGuard: typeof import('utils4u/vue-router').createLogGuard
|
||||||
const createRef: typeof import('@vueuse/core')['createRef']
|
const createNProgressGuard: typeof import('utils4u/vue-router').createNProgressGuard
|
||||||
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
|
const createPinia: typeof import('pinia').createPinia
|
||||||
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
|
const createReactiveFn: typeof import('@vueuse/core').createReactiveFn
|
||||||
const createStackGuard: typeof import('utils4u/vue-router')['createStackGuard']
|
const createRef: typeof import('@vueuse/core').createRef
|
||||||
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
|
const createReusableTemplate: typeof import('@vueuse/core').createReusableTemplate
|
||||||
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
|
const createSharedComposable: typeof import('@vueuse/core').createSharedComposable
|
||||||
const customRef: typeof import('vue')['customRef']
|
const createStackGuard: typeof import('utils4u/vue-router').createStackGuard
|
||||||
const debouncedRef: typeof import('@vueuse/core')['debouncedRef']
|
const createTemplatePromise: typeof import('@vueuse/core').createTemplatePromise
|
||||||
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
|
const createUnrefFn: typeof import('@vueuse/core').createUnrefFn
|
||||||
const deepFreeze: typeof import('deep-freeze-es6')['default']
|
const customRef: typeof import('vue').customRef
|
||||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
const debouncedRef: typeof import('@vueuse/core').debouncedRef
|
||||||
const defineComponent: typeof import('vue')['defineComponent']
|
const debouncedWatch: typeof import('@vueuse/core').debouncedWatch
|
||||||
const defineStore: typeof import('pinia')['defineStore']
|
const deepFreeze: typeof import('deep-freeze-es6').default
|
||||||
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
|
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
|
||||||
const effectScope: typeof import('vue')['effectScope']
|
const defineBasicLoader: typeof import('unplugin-vue-router/data-loaders/basic').defineBasicLoader
|
||||||
const extendRef: typeof import('@vueuse/core')['extendRef']
|
const defineComponent: typeof import('vue').defineComponent
|
||||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
const defineStore: typeof import('pinia').defineStore
|
||||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
const eagerComputed: typeof import('@vueuse/core').eagerComputed
|
||||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
const effectScope: typeof import('vue').effectScope
|
||||||
const getCurrentWatcher: typeof import('vue')['getCurrentWatcher']
|
const extendRef: typeof import('@vueuse/core').extendRef
|
||||||
const h: typeof import('vue')['h']
|
const getActivePinia: typeof import('pinia').getActivePinia
|
||||||
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
|
const getCurrentInstance: typeof import('vue').getCurrentInstance
|
||||||
const inject: typeof import('vue')['inject']
|
const getCurrentScope: typeof import('vue').getCurrentScope
|
||||||
const injectLocal: typeof import('@vueuse/core')['injectLocal']
|
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
|
||||||
const isDefined: typeof import('@vueuse/core')['isDefined']
|
const h: typeof import('vue').h
|
||||||
const isProxy: typeof import('vue')['isProxy']
|
const i18nInstance: typeof import('./src/locales-utils/i18n-auto-imports').i18nInstance
|
||||||
const isReactive: typeof import('vue')['isReactive']
|
const i18nRouteMessages: typeof import('./src/locales-utils/route-messages/route-messages-auto-imports').i18nRouteMessages
|
||||||
const isReadonly: typeof import('vue')['isReadonly']
|
const ignorableWatch: typeof import('@vueuse/core').ignorableWatch
|
||||||
const isRef: typeof import('vue')['isRef']
|
const inject: typeof import('vue').inject
|
||||||
const isShallow: typeof import('vue')['isShallow']
|
const injectLocal: typeof import('@vueuse/core').injectLocal
|
||||||
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
|
const isDefined: typeof import('@vueuse/core').isDefined
|
||||||
const mapActions: typeof import('pinia')['mapActions']
|
const isProxy: typeof import('vue').isProxy
|
||||||
const mapGetters: typeof import('pinia')['mapGetters']
|
const isReactive: typeof import('vue').isReactive
|
||||||
const mapState: typeof import('pinia')['mapState']
|
const isReadonly: typeof import('vue').isReadonly
|
||||||
const mapStores: typeof import('pinia')['mapStores']
|
const isRef: typeof import('vue').isRef
|
||||||
const mapWritableState: typeof import('pinia')['mapWritableState']
|
const isShallow: typeof import('vue').isShallow
|
||||||
const markRaw: typeof import('vue')['markRaw']
|
const makeDestructurable: typeof import('@vueuse/core').makeDestructurable
|
||||||
const nextTick: typeof import('vue')['nextTick']
|
const mapActions: typeof import('pinia').mapActions
|
||||||
const onActivated: typeof import('vue')['onActivated']
|
const mapGetters: typeof import('pinia').mapGetters
|
||||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
const mapState: typeof import('pinia').mapState
|
||||||
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
|
const mapStores: typeof import('pinia').mapStores
|
||||||
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
|
const mapWritableState: typeof import('pinia').mapWritableState
|
||||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
const markRaw: typeof import('vue').markRaw
|
||||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
const nextTick: typeof import('vue').nextTick
|
||||||
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
|
const onActivated: typeof import('vue').onActivated
|
||||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
const onBeforeMount: typeof import('vue').onBeforeMount
|
||||||
const onElementRemoval: typeof import('@vueuse/core')['onElementRemoval']
|
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
|
||||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
|
||||||
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
|
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
|
||||||
const onLongPress: typeof import('@vueuse/core')['onLongPress']
|
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
|
||||||
const onMounted: typeof import('vue')['onMounted']
|
const onClickOutside: typeof import('@vueuse/core').onClickOutside
|
||||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
const onDeactivated: typeof import('vue').onDeactivated
|
||||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
const onElementRemoval: typeof import('@vueuse/core').onElementRemoval
|
||||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
const onErrorCaptured: typeof import('vue').onErrorCaptured
|
||||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
const onKeyStroke: typeof import('@vueuse/core').onKeyStroke
|
||||||
const onStartTyping: typeof import('@vueuse/core')['onStartTyping']
|
const onLongPress: typeof import('@vueuse/core').onLongPress
|
||||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
const onMounted: typeof import('vue').onMounted
|
||||||
const onUpdated: typeof import('vue')['onUpdated']
|
const onRenderTracked: typeof import('vue').onRenderTracked
|
||||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
const onRenderTriggered: typeof import('vue').onRenderTriggered
|
||||||
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
|
const onScopeDispose: typeof import('vue').onScopeDispose
|
||||||
const provide: typeof import('vue')['provide']
|
const onServerPrefetch: typeof import('vue').onServerPrefetch
|
||||||
const provideLocal: typeof import('@vueuse/core')['provideLocal']
|
const onStartTyping: typeof import('@vueuse/core').onStartTyping
|
||||||
const reactify: typeof import('@vueuse/core')['reactify']
|
const onUnmounted: typeof import('vue').onUnmounted
|
||||||
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
|
const onUpdated: typeof import('vue').onUpdated
|
||||||
const reactive: typeof import('vue')['reactive']
|
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
|
||||||
const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed']
|
const pausableWatch: typeof import('@vueuse/core').pausableWatch
|
||||||
const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit']
|
const provide: typeof import('vue').provide
|
||||||
const reactivePick: typeof import('@vueuse/core')['reactivePick']
|
const provideLocal: typeof import('@vueuse/core').provideLocal
|
||||||
const readonly: typeof import('vue')['readonly']
|
const reactify: typeof import('@vueuse/core').reactify
|
||||||
const ref: typeof import('vue')['ref']
|
const reactifyObject: typeof import('@vueuse/core').reactifyObject
|
||||||
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
|
const reactive: typeof import('vue').reactive
|
||||||
const refDebounced: typeof import('@vueuse/core')['refDebounced']
|
const reactiveComputed: typeof import('@vueuse/core').reactiveComputed
|
||||||
const refDefault: typeof import('@vueuse/core')['refDefault']
|
const reactiveOmit: typeof import('@vueuse/core').reactiveOmit
|
||||||
const refThrottled: typeof import('@vueuse/core')['refThrottled']
|
const reactivePick: typeof import('@vueuse/core').reactivePick
|
||||||
const refWithControl: typeof import('@vueuse/core')['refWithControl']
|
const readonly: typeof import('vue').readonly
|
||||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
const ref: typeof import('vue').ref
|
||||||
const resolveRef: typeof import('@vueuse/core')['resolveRef']
|
const refAutoReset: typeof import('@vueuse/core').refAutoReset
|
||||||
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
|
const refDebounced: typeof import('@vueuse/core').refDebounced
|
||||||
const setActivePinia: typeof import('pinia')['setActivePinia']
|
const refDefault: typeof import('@vueuse/core').refDefault
|
||||||
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
|
const refManualReset: typeof import('@vueuse/core').refManualReset
|
||||||
const setViewportCSSVars: typeof import('utils4u/browser')['setViewportCSSVars']
|
const refThrottled: typeof import('@vueuse/core').refThrottled
|
||||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
const refWithControl: typeof import('@vueuse/core').refWithControl
|
||||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
const resolveComponent: typeof import('vue').resolveComponent
|
||||||
const shallowRef: typeof import('vue')['shallowRef']
|
const resolveRef: typeof import('@vueuse/core').resolveRef
|
||||||
const showOpenFilePicker: typeof import('utils4u/browser')['showOpenFilePicker']
|
const routeI18nInstance: typeof import('./src/locales-utils/i18n-auto-imports').routeI18nInstance
|
||||||
const storeToRefs: typeof import('pinia')['storeToRefs']
|
const routeI18nT: typeof import('./src/locales-utils/i18n-auto-imports').routeI18nT
|
||||||
const syncRef: typeof import('@vueuse/core')['syncRef']
|
const setActivePinia: typeof import('pinia').setActivePinia
|
||||||
const syncRefs: typeof import('@vueuse/core')['syncRefs']
|
const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
|
||||||
const templateRef: typeof import('@vueuse/core')['templateRef']
|
const setViewportCSSVars: typeof import('utils4u/browser').setViewportCSSVars
|
||||||
const throttledRef: typeof import('@vueuse/core')['throttledRef']
|
const shallowReactive: typeof import('vue').shallowReactive
|
||||||
const throttledWatch: typeof import('@vueuse/core')['throttledWatch']
|
const shallowReadonly: typeof import('vue').shallowReadonly
|
||||||
const toRaw: typeof import('vue')['toRaw']
|
const shallowRef: typeof import('vue').shallowRef
|
||||||
const toReactive: typeof import('@vueuse/core')['toReactive']
|
const showOpenFilePicker: typeof import('utils4u/browser').showOpenFilePicker
|
||||||
const toRef: typeof import('vue')['toRef']
|
const storeToRefs: typeof import('pinia').storeToRefs
|
||||||
const toRefs: typeof import('vue')['toRefs']
|
const syncRef: typeof import('@vueuse/core').syncRef
|
||||||
const toValue: typeof import('vue')['toValue']
|
const syncRefs: typeof import('@vueuse/core').syncRefs
|
||||||
const triggerRef: typeof import('vue')['triggerRef']
|
const templateRef: typeof import('@vueuse/core').templateRef
|
||||||
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
|
const throttledRef: typeof import('@vueuse/core').throttledRef
|
||||||
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
|
const throttledWatch: typeof import('@vueuse/core').throttledWatch
|
||||||
const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted']
|
const toRaw: typeof import('vue').toRaw
|
||||||
const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose']
|
const toReactive: typeof import('@vueuse/core').toReactive
|
||||||
const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted']
|
const toRef: typeof import('vue').toRef
|
||||||
const unref: typeof import('vue')['unref']
|
const toRefs: typeof import('vue').toRefs
|
||||||
const unrefElement: typeof import('@vueuse/core')['unrefElement']
|
const toValue: typeof import('vue').toValue
|
||||||
const until: typeof import('@vueuse/core')['until']
|
const triggerRef: typeof import('vue').triggerRef
|
||||||
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
|
const tryOnBeforeMount: typeof import('@vueuse/core').tryOnBeforeMount
|
||||||
const useAnimate: typeof import('@vueuse/core')['useAnimate']
|
const tryOnBeforeUnmount: typeof import('@vueuse/core').tryOnBeforeUnmount
|
||||||
const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']
|
const tryOnMounted: typeof import('@vueuse/core').tryOnMounted
|
||||||
const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery']
|
const tryOnScopeDispose: typeof import('@vueuse/core').tryOnScopeDispose
|
||||||
const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter']
|
const tryOnUnmounted: typeof import('@vueuse/core').tryOnUnmounted
|
||||||
const useArrayFind: typeof import('@vueuse/core')['useArrayFind']
|
const unref: typeof import('vue').unref
|
||||||
const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex']
|
const unrefElement: typeof import('@vueuse/core').unrefElement
|
||||||
const useArrayFindLast: typeof import('@vueuse/core')['useArrayFindLast']
|
const until: typeof import('@vueuse/core').until
|
||||||
const useArrayIncludes: typeof import('@vueuse/core')['useArrayIncludes']
|
const useActiveElement: typeof import('@vueuse/core').useActiveElement
|
||||||
const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin']
|
const useAnimate: typeof import('@vueuse/core').useAnimate
|
||||||
const useArrayMap: typeof import('@vueuse/core')['useArrayMap']
|
const useAppStore: typeof import('./src/stores/app-store-auto-imports').useAppStore
|
||||||
const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce']
|
const useArrayDifference: typeof import('@vueuse/core').useArrayDifference
|
||||||
const useArraySome: typeof import('@vueuse/core')['useArraySome']
|
const useArrayEvery: typeof import('@vueuse/core').useArrayEvery
|
||||||
const useArrayUnique: typeof import('@vueuse/core')['useArrayUnique']
|
const useArrayFilter: typeof import('@vueuse/core').useArrayFilter
|
||||||
const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue']
|
const useArrayFind: typeof import('@vueuse/core').useArrayFind
|
||||||
const useAsyncState: typeof import('@vueuse/core')['useAsyncState']
|
const useArrayFindIndex: typeof import('@vueuse/core').useArrayFindIndex
|
||||||
const useAttrs: typeof import('vue')['useAttrs']
|
const useArrayFindLast: typeof import('@vueuse/core').useArrayFindLast
|
||||||
const useBase64: typeof import('@vueuse/core')['useBase64']
|
const useArrayIncludes: typeof import('@vueuse/core').useArrayIncludes
|
||||||
const useBattery: typeof import('@vueuse/core')['useBattery']
|
const useArrayJoin: typeof import('@vueuse/core').useArrayJoin
|
||||||
const useBluetooth: typeof import('@vueuse/core')['useBluetooth']
|
const useArrayMap: typeof import('@vueuse/core').useArrayMap
|
||||||
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
|
const useArrayReduce: typeof import('@vueuse/core').useArrayReduce
|
||||||
const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel']
|
const useArraySome: typeof import('@vueuse/core').useArraySome
|
||||||
const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation']
|
const useArrayUnique: typeof import('@vueuse/core').useArrayUnique
|
||||||
const useCached: typeof import('@vueuse/core')['useCached']
|
const useAsyncQueue: typeof import('@vueuse/core').useAsyncQueue
|
||||||
const useClipboard: typeof import('@vueuse/core')['useClipboard']
|
const useAsyncState: typeof import('@vueuse/core').useAsyncState
|
||||||
const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems']
|
const useAttrs: typeof import('vue').useAttrs
|
||||||
const useCloned: typeof import('@vueuse/core')['useCloned']
|
const useAuthStore: typeof import('./src/stores/auth-store-auto-imports').useAuthStore
|
||||||
const useColorMode: typeof import('@vueuse/core')['useColorMode']
|
const useBase64: typeof import('@vueuse/core').useBase64
|
||||||
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
|
const useBattery: typeof import('@vueuse/core').useBattery
|
||||||
const useCountdown: typeof import('@vueuse/core')['useCountdown']
|
const useBluetooth: typeof import('@vueuse/core').useBluetooth
|
||||||
const useCounter: typeof import('@vueuse/core')['useCounter']
|
const useBreakpoints: typeof import('@vueuse/core').useBreakpoints
|
||||||
const useCssModule: typeof import('vue')['useCssModule']
|
const useBroadcastChannel: typeof import('@vueuse/core').useBroadcastChannel
|
||||||
const useCssVar: typeof import('@vueuse/core')['useCssVar']
|
const useBrowserLocation: typeof import('@vueuse/core').useBrowserLocation
|
||||||
const useCssVars: typeof import('vue')['useCssVars']
|
const useCached: typeof import('@vueuse/core').useCached
|
||||||
const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement']
|
const useClipboard: typeof import('@vueuse/core').useClipboard
|
||||||
const useCycleList: typeof import('@vueuse/core')['useCycleList']
|
const useClipboardItems: typeof import('@vueuse/core').useClipboardItems
|
||||||
const useDark: typeof import('@vueuse/core')['useDark']
|
const useCloned: typeof import('@vueuse/core').useCloned
|
||||||
const useDateFormat: typeof import('@vueuse/core')['useDateFormat']
|
const useColorMode: typeof import('@vueuse/core').useColorMode
|
||||||
const useDebounce: typeof import('@vueuse/core')['useDebounce']
|
const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog
|
||||||
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
|
const useCountdown: typeof import('@vueuse/core').useCountdown
|
||||||
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
|
const useCounter: typeof import('@vueuse/core').useCounter
|
||||||
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
|
const useCssModule: typeof import('vue').useCssModule
|
||||||
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
|
const useCssVar: typeof import('@vueuse/core').useCssVar
|
||||||
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
|
const useCssVars: typeof import('vue').useCssVars
|
||||||
const useDevicesList: typeof import('@vueuse/core')['useDevicesList']
|
const useCurrentElement: typeof import('@vueuse/core').useCurrentElement
|
||||||
const useDialog: typeof import('naive-ui')['useDialog']
|
const useCycleList: typeof import('@vueuse/core').useCycleList
|
||||||
const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']
|
const useDark: typeof import('@vueuse/core').useDark
|
||||||
const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']
|
const useDateFormat: typeof import('@vueuse/core').useDateFormat
|
||||||
const useDraggable: typeof import('@vueuse/core')['useDraggable']
|
const useDebounce: typeof import('@vueuse/core').useDebounce
|
||||||
const useDropZone: typeof import('@vueuse/core')['useDropZone']
|
const useDebounceFn: typeof import('@vueuse/core').useDebounceFn
|
||||||
const useElementBounding: typeof import('@vueuse/core')['useElementBounding']
|
const useDebouncedRefHistory: typeof import('@vueuse/core').useDebouncedRefHistory
|
||||||
const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint']
|
const useDeviceMotion: typeof import('@vueuse/core').useDeviceMotion
|
||||||
const useElementHover: typeof import('@vueuse/core')['useElementHover']
|
const useDeviceOrientation: typeof import('@vueuse/core').useDeviceOrientation
|
||||||
const useElementSize: typeof import('@vueuse/core')['useElementSize']
|
const useDevicePixelRatio: typeof import('@vueuse/core').useDevicePixelRatio
|
||||||
const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility']
|
const useDevicesList: typeof import('@vueuse/core').useDevicesList
|
||||||
const useEventBus: typeof import('@vueuse/core')['useEventBus']
|
const useDialog: typeof import('naive-ui').useDialog
|
||||||
const useEventListener: typeof import('@vueuse/core')['useEventListener']
|
const useDisplayMedia: typeof import('@vueuse/core').useDisplayMedia
|
||||||
const useEventSource: typeof import('@vueuse/core')['useEventSource']
|
const useDocumentVisibility: typeof import('@vueuse/core').useDocumentVisibility
|
||||||
const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper']
|
const useDraggable: typeof import('@vueuse/core').useDraggable
|
||||||
const useFavicon: typeof import('@vueuse/core')['useFavicon']
|
const useDropZone: typeof import('@vueuse/core').useDropZone
|
||||||
const useFetch: typeof import('@vueuse/core')['useFetch']
|
const useElementBounding: typeof import('@vueuse/core').useElementBounding
|
||||||
const useFileDialog: typeof import('@vueuse/core')['useFileDialog']
|
const useElementByPoint: typeof import('@vueuse/core').useElementByPoint
|
||||||
const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess']
|
const useElementHover: typeof import('@vueuse/core').useElementHover
|
||||||
const useFocus: typeof import('@vueuse/core')['useFocus']
|
const useElementSize: typeof import('@vueuse/core').useElementSize
|
||||||
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
|
const useElementVisibility: typeof import('@vueuse/core').useElementVisibility
|
||||||
const useFps: typeof import('@vueuse/core')['useFps']
|
const useEventBus: typeof import('@vueuse/core').useEventBus
|
||||||
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
|
const useEventListener: typeof import('@vueuse/core').useEventListener
|
||||||
const useGamepad: typeof import('@vueuse/core')['useGamepad']
|
const useEventSource: typeof import('@vueuse/core').useEventSource
|
||||||
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
|
const useEyeDropper: typeof import('@vueuse/core').useEyeDropper
|
||||||
const useI18n: typeof import('vue-i18n')['useI18n']
|
const useFavicon: typeof import('@vueuse/core').useFavicon
|
||||||
const useId: typeof import('vue')['useId']
|
const useFetch: typeof import('@vueuse/core').useFetch
|
||||||
const useIdle: typeof import('@vueuse/core')['useIdle']
|
const useFileDialog: typeof import('@vueuse/core').useFileDialog
|
||||||
const useImage: typeof import('@vueuse/core')['useImage']
|
const useFileSystemAccess: typeof import('@vueuse/core').useFileSystemAccess
|
||||||
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
|
const useFocus: typeof import('@vueuse/core').useFocus
|
||||||
const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver']
|
const useFocusWithin: typeof import('@vueuse/core').useFocusWithin
|
||||||
const useInterval: typeof import('@vueuse/core')['useInterval']
|
const useFps: typeof import('@vueuse/core').useFps
|
||||||
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
|
const useFullscreen: typeof import('@vueuse/core').useFullscreen
|
||||||
const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
|
const useGamepad: typeof import('@vueuse/core').useGamepad
|
||||||
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
|
const useGeolocation: typeof import('@vueuse/core').useGeolocation
|
||||||
const useLink: typeof import('vue-router/auto')['useLink']
|
const useI18n: typeof import('vue-i18n').useI18n
|
||||||
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
|
const useId: typeof import('vue').useId
|
||||||
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
|
const useIdle: typeof import('@vueuse/core').useIdle
|
||||||
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
|
const useImage: typeof import('@vueuse/core').useImage
|
||||||
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
|
const useInfiniteScroll: typeof import('@vueuse/core').useInfiniteScroll
|
||||||
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
|
const useIntersectionObserver: typeof import('@vueuse/core').useIntersectionObserver
|
||||||
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
|
const useInterval: typeof import('@vueuse/core').useInterval
|
||||||
const useMemoize: typeof import('@vueuse/core')['useMemoize']
|
const useIntervalFn: typeof import('@vueuse/core').useIntervalFn
|
||||||
const useMemory: typeof import('@vueuse/core')['useMemory']
|
const useKeyModifier: typeof import('@vueuse/core').useKeyModifier
|
||||||
const useMessage: typeof import('naive-ui')['useMessage']
|
const useLastChanged: typeof import('@vueuse/core').useLastChanged
|
||||||
const useModal: typeof import('naive-ui')['useModal']
|
const useLoadingBar: typeof import('naive-ui').useLoadingBar
|
||||||
const useModel: typeof import('vue')['useModel']
|
const useLocalStorage: typeof import('@vueuse/core').useLocalStorage
|
||||||
const useMounted: typeof import('@vueuse/core')['useMounted']
|
const useMagicKeys: typeof import('@vueuse/core').useMagicKeys
|
||||||
const useMouse: typeof import('@vueuse/core')['useMouse']
|
const useManualRefHistory: typeof import('@vueuse/core').useManualRefHistory
|
||||||
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
|
const useMediaControls: typeof import('@vueuse/core').useMediaControls
|
||||||
const useMousePressed: typeof import('@vueuse/core')['useMousePressed']
|
const useMediaQuery: typeof import('@vueuse/core').useMediaQuery
|
||||||
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
|
const useMemoize: typeof import('@vueuse/core').useMemoize
|
||||||
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
|
const useMemory: typeof import('@vueuse/core').useMemory
|
||||||
const useNetwork: typeof import('@vueuse/core')['useNetwork']
|
const useMessage: typeof import('naive-ui').useMessage
|
||||||
const useNotification: typeof import('naive-ui')['useNotification']
|
const useMetaLayoutsNMenuOptions: typeof import('./src/composables/useMetaLayoutsMenuOptions').useMetaLayoutsNMenuOptions
|
||||||
const useNow: typeof import('@vueuse/core')['useNow']
|
const useModal: typeof import('naive-ui').useModal
|
||||||
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
|
const useModel: typeof import('vue').useModel
|
||||||
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
|
const useMounted: typeof import('@vueuse/core').useMounted
|
||||||
const useOnline: typeof import('@vueuse/core')['useOnline']
|
const useMouse: typeof import('@vueuse/core').useMouse
|
||||||
const usePageLeave: typeof import('@vueuse/core')['usePageLeave']
|
const useMouseInElement: typeof import('@vueuse/core').useMouseInElement
|
||||||
const useParallax: typeof import('@vueuse/core')['useParallax']
|
const useMousePressed: typeof import('@vueuse/core').useMousePressed
|
||||||
const useParentElement: typeof import('@vueuse/core')['useParentElement']
|
const useMutationObserver: typeof import('@vueuse/core').useMutationObserver
|
||||||
const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']
|
const useNavigatorLanguage: typeof import('@vueuse/core').useNavigatorLanguage
|
||||||
const usePermission: typeof import('@vueuse/core')['usePermission']
|
const useNetwork: typeof import('@vueuse/core').useNetwork
|
||||||
const usePointer: typeof import('@vueuse/core')['usePointer']
|
const useNotification: typeof import('naive-ui').useNotification
|
||||||
const usePointerLock: typeof import('@vueuse/core')['usePointerLock']
|
const useNow: typeof import('@vueuse/core').useNow
|
||||||
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
|
const useObjectUrl: typeof import('@vueuse/core').useObjectUrl
|
||||||
const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme']
|
const useOffsetPagination: typeof import('@vueuse/core').useOffsetPagination
|
||||||
const usePreferredContrast: typeof import('@vueuse/core')['usePreferredContrast']
|
const useOnline: typeof import('@vueuse/core').useOnline
|
||||||
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
|
const usePageLeave: typeof import('@vueuse/core').usePageLeave
|
||||||
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
|
const useParallax: typeof import('@vueuse/core').useParallax
|
||||||
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
|
const useParentElement: typeof import('@vueuse/core').useParentElement
|
||||||
const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
|
const usePerformanceObserver: typeof import('@vueuse/core').usePerformanceObserver
|
||||||
const usePrevious: typeof import('@vueuse/core')['usePrevious']
|
const usePermission: typeof import('@vueuse/core').usePermission
|
||||||
const useRafFn: typeof import('@vueuse/core')['useRafFn']
|
const usePointer: typeof import('@vueuse/core').usePointer
|
||||||
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
|
const usePointerLock: typeof import('@vueuse/core').usePointerLock
|
||||||
const useRefs: typeof import('utils4u/vue-use')['useRefs']
|
const usePointerSwipe: typeof import('@vueuse/core').usePointerSwipe
|
||||||
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
|
const usePreferredColorScheme: typeof import('@vueuse/core').usePreferredColorScheme
|
||||||
const useRoute: typeof import('vue-router')['useRoute']
|
const usePreferredContrast: typeof import('@vueuse/core').usePreferredContrast
|
||||||
const useRouter: typeof import('vue-router')['useRouter']
|
const usePreferredDark: typeof import('@vueuse/core').usePreferredDark
|
||||||
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
|
const usePreferredLanguages: typeof import('@vueuse/core').usePreferredLanguages
|
||||||
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
|
const usePreferredReducedMotion: typeof import('@vueuse/core').usePreferredReducedMotion
|
||||||
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
|
const usePreferredReducedTransparency: typeof import('@vueuse/core').usePreferredReducedTransparency
|
||||||
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
|
const usePrevious: typeof import('@vueuse/core').usePrevious
|
||||||
const useScroll: typeof import('@vueuse/core')['useScroll']
|
const usePrimevueDialogRef: typeof import('utils4u/primevue').usePrimevueDialogRef
|
||||||
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
|
const useRafFn: typeof import('@vueuse/core').useRafFn
|
||||||
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
|
const useRefHistory: typeof import('@vueuse/core').useRefHistory
|
||||||
const useShare: typeof import('@vueuse/core')['useShare']
|
const useRefs: typeof import('utils4u/vue-use').useRefs
|
||||||
const useSlots: typeof import('vue')['useSlots']
|
const useResizeObserver: typeof import('@vueuse/core').useResizeObserver
|
||||||
const useSorted: typeof import('@vueuse/core')['useSorted']
|
const useRoute: typeof import('vue-router').useRoute
|
||||||
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
|
const useRouter: typeof import('vue-router').useRouter
|
||||||
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
|
const useSSRWidth: typeof import('@vueuse/core').useSSRWidth
|
||||||
const useStepper: typeof import('@vueuse/core')['useStepper']
|
const useScreenOrientation: typeof import('@vueuse/core').useScreenOrientation
|
||||||
const useStorage: typeof import('@vueuse/core')['useStorage']
|
const useScreenSafeArea: typeof import('@vueuse/core').useScreenSafeArea
|
||||||
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
|
const useScriptTag: typeof import('@vueuse/core').useScriptTag
|
||||||
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
|
const useScroll: typeof import('@vueuse/core').useScroll
|
||||||
const useSupported: typeof import('@vueuse/core')['useSupported']
|
const useScrollLock: typeof import('@vueuse/core').useScrollLock
|
||||||
const useSwipe: typeof import('@vueuse/core')['useSwipe']
|
const useSessionStorage: typeof import('@vueuse/core').useSessionStorage
|
||||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
const useShare: typeof import('@vueuse/core').useShare
|
||||||
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
|
const useSlots: typeof import('vue').useSlots
|
||||||
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
|
const useSorted: typeof import('@vueuse/core').useSorted
|
||||||
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
|
const useSpeechRecognition: typeof import('@vueuse/core').useSpeechRecognition
|
||||||
const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize']
|
const useSpeechSynthesis: typeof import('@vueuse/core').useSpeechSynthesis
|
||||||
const useThrottle: typeof import('@vueuse/core')['useThrottle']
|
const useStepper: typeof import('@vueuse/core').useStepper
|
||||||
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
|
const useStorage: typeof import('@vueuse/core').useStorage
|
||||||
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
|
const useStorageAsync: typeof import('@vueuse/core').useStorageAsync
|
||||||
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
|
const useStyleTag: typeof import('@vueuse/core').useStyleTag
|
||||||
const useTimeAgoIntl: typeof import('@vueuse/core')['useTimeAgoIntl']
|
const useSupported: typeof import('@vueuse/core').useSupported
|
||||||
const useTimeout: typeof import('@vueuse/core')['useTimeout']
|
const useSwipe: typeof import('@vueuse/core').useSwipe
|
||||||
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
|
const useTemplateRef: typeof import('vue').useTemplateRef
|
||||||
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
|
const useTemplateRefsList: typeof import('@vueuse/core').useTemplateRefsList
|
||||||
const useTimestamp: typeof import('@vueuse/core')['useTimestamp']
|
const useTextDirection: typeof import('@vueuse/core').useTextDirection
|
||||||
const useTitle: typeof import('@vueuse/core')['useTitle']
|
const useTextSelection: typeof import('@vueuse/core').useTextSelection
|
||||||
const useToNumber: typeof import('@vueuse/core')['useToNumber']
|
const useTextareaAutosize: typeof import('@vueuse/core').useTextareaAutosize
|
||||||
const useToString: typeof import('@vueuse/core')['useToString']
|
const useThrottle: typeof import('@vueuse/core').useThrottle
|
||||||
const useToggle: typeof import('@vueuse/core')['useToggle']
|
const useThrottleFn: typeof import('@vueuse/core').useThrottleFn
|
||||||
const useTransition: typeof import('@vueuse/core')['useTransition']
|
const useThrottledRefHistory: typeof import('@vueuse/core').useThrottledRefHistory
|
||||||
const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams']
|
const useTimeAgo: typeof import('@vueuse/core').useTimeAgo
|
||||||
const useUserMedia: typeof import('@vueuse/core')['useUserMedia']
|
const useTimeAgoIntl: typeof import('@vueuse/core').useTimeAgoIntl
|
||||||
const useVModel: typeof import('@vueuse/core')['useVModel']
|
const useTimeout: typeof import('@vueuse/core').useTimeout
|
||||||
const useVModels: typeof import('@vueuse/core')['useVModels']
|
const useTimeoutFn: typeof import('@vueuse/core').useTimeoutFn
|
||||||
const useVibrate: typeof import('@vueuse/core')['useVibrate']
|
const useTimeoutPoll: typeof import('@vueuse/core').useTimeoutPoll
|
||||||
const useVirtualList: typeof import('@vueuse/core')['useVirtualList']
|
const useTimestamp: typeof import('@vueuse/core').useTimestamp
|
||||||
const useWakeLock: typeof import('@vueuse/core')['useWakeLock']
|
const useTitle: typeof import('@vueuse/core').useTitle
|
||||||
const useWebNotification: typeof import('@vueuse/core')['useWebNotification']
|
const useToNumber: typeof import('@vueuse/core').useToNumber
|
||||||
const useWebSocket: typeof import('@vueuse/core')['useWebSocket']
|
const useToString: typeof import('@vueuse/core').useToString
|
||||||
const useWebWorker: typeof import('@vueuse/core')['useWebWorker']
|
const useToggle: typeof import('@vueuse/core').useToggle
|
||||||
const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn']
|
const useTransition: typeof import('@vueuse/core').useTransition
|
||||||
const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus']
|
const useUrlSearchParams: typeof import('@vueuse/core').useUrlSearchParams
|
||||||
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
|
const useUserMedia: typeof import('@vueuse/core').useUserMedia
|
||||||
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
|
const useVModel: typeof import('@vueuse/core').useVModel
|
||||||
const watch: typeof import('vue')['watch']
|
const useVModels: typeof import('@vueuse/core').useVModels
|
||||||
const watchArray: typeof import('@vueuse/core')['watchArray']
|
const useVibrate: typeof import('@vueuse/core').useVibrate
|
||||||
const watchAtMost: typeof import('@vueuse/core')['watchAtMost']
|
const useVirtualList: typeof import('@vueuse/core').useVirtualList
|
||||||
const watchDebounced: typeof import('@vueuse/core')['watchDebounced']
|
const useWakeLock: typeof import('@vueuse/core').useWakeLock
|
||||||
const watchDeep: typeof import('@vueuse/core')['watchDeep']
|
const useWebNotification: typeof import('@vueuse/core').useWebNotification
|
||||||
const watchEffect: typeof import('vue')['watchEffect']
|
const useWebSocket: typeof import('@vueuse/core').useWebSocket
|
||||||
const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable']
|
const useWebWorker: typeof import('@vueuse/core').useWebWorker
|
||||||
const watchImmediate: typeof import('@vueuse/core')['watchImmediate']
|
const useWebWorkerFn: typeof import('@vueuse/core').useWebWorkerFn
|
||||||
const watchOnce: typeof import('@vueuse/core')['watchOnce']
|
const useWindowFocus: typeof import('@vueuse/core').useWindowFocus
|
||||||
const watchPausable: typeof import('@vueuse/core')['watchPausable']
|
const useWindowScroll: typeof import('@vueuse/core').useWindowScroll
|
||||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
const useWindowSize: typeof import('@vueuse/core').useWindowSize
|
||||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
const watch: typeof import('vue').watch
|
||||||
const watchThrottled: typeof import('@vueuse/core')['watchThrottled']
|
const watchArray: typeof import('@vueuse/core').watchArray
|
||||||
const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable']
|
const watchAtMost: typeof import('@vueuse/core').watchAtMost
|
||||||
const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter']
|
const watchDebounced: typeof import('@vueuse/core').watchDebounced
|
||||||
const whenever: typeof import('@vueuse/core')['whenever']
|
const watchDeep: typeof import('@vueuse/core').watchDeep
|
||||||
|
const watchEffect: typeof import('vue').watchEffect
|
||||||
|
const watchIgnorable: typeof import('@vueuse/core').watchIgnorable
|
||||||
|
const watchImmediate: typeof import('@vueuse/core').watchImmediate
|
||||||
|
const watchOnce: typeof import('@vueuse/core').watchOnce
|
||||||
|
const watchPausable: typeof import('@vueuse/core').watchPausable
|
||||||
|
const watchPostEffect: typeof import('vue').watchPostEffect
|
||||||
|
const watchSyncEffect: typeof import('vue').watchSyncEffect
|
||||||
|
const watchThrottled: typeof import('@vueuse/core').watchThrottled
|
||||||
|
const watchTriggerable: typeof import('@vueuse/core').watchTriggerable
|
||||||
|
const watchWithFilter: typeof import('@vueuse/core').watchWithFilter
|
||||||
|
const whenever: typeof import('@vueuse/core').whenever
|
||||||
}
|
}
|
||||||
// for type re-export
|
// for type re-export
|
||||||
declare global {
|
declare global {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||||
import('vue')
|
import('vue')
|
||||||
|
// @ts-ignore
|
||||||
|
export type { AppThemeMode } from './src/stores/app-store-auto-imports'
|
||||||
|
import('./src/stores/app-store-auto-imports')
|
||||||
}
|
}
|
||||||
|
|
||||||
// for vue template auto import
|
// for vue template auto import
|
||||||
@@ -337,7 +351,10 @@ import { UnwrapRef } from 'vue'
|
|||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
interface GlobalComponents {}
|
interface GlobalComponents {}
|
||||||
interface ComponentCustomProperties {
|
interface ComponentCustomProperties {
|
||||||
|
readonly ConfirmationService: UnwrapRef<typeof import('utils4u/primevue')['ConfirmationService']>
|
||||||
|
readonly DialogService: UnwrapRef<typeof import('utils4u/primevue')['DialogService']>
|
||||||
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||||
|
readonly ToastService: UnwrapRef<typeof import('utils4u/primevue')['ToastService']>
|
||||||
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
|
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
|
||||||
readonly arrayToTree: UnwrapRef<typeof import('utils4u/array')['arrayToTree']>
|
readonly arrayToTree: UnwrapRef<typeof import('utils4u/array')['arrayToTree']>
|
||||||
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
|
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
|
||||||
@@ -370,6 +387,7 @@ declare module 'vue' {
|
|||||||
readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>
|
readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>
|
||||||
readonly deepFreeze: UnwrapRef<typeof import('deep-freeze-es6')['default']>
|
readonly deepFreeze: UnwrapRef<typeof import('deep-freeze-es6')['default']>
|
||||||
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
|
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
|
||||||
|
readonly defineBasicLoader: UnwrapRef<typeof import('unplugin-vue-router/data-loaders/basic')['defineBasicLoader']>
|
||||||
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
|
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
|
||||||
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
|
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
|
||||||
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
|
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
|
||||||
@@ -380,6 +398,8 @@ declare module 'vue' {
|
|||||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||||
readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']>
|
readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']>
|
||||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||||
|
readonly i18nInstance: UnwrapRef<typeof import('./src/locales-utils/i18n-auto-imports')['i18nInstance']>
|
||||||
|
readonly i18nRouteMessages: UnwrapRef<typeof import('./src/locales-utils/route-messages/route-messages-auto-imports')['i18nRouteMessages']>
|
||||||
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
|
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
|
||||||
readonly inject: UnwrapRef<typeof import('vue')['inject']>
|
readonly inject: UnwrapRef<typeof import('vue')['inject']>
|
||||||
readonly injectLocal: UnwrapRef<typeof import('@vueuse/core')['injectLocal']>
|
readonly injectLocal: UnwrapRef<typeof import('@vueuse/core')['injectLocal']>
|
||||||
@@ -432,11 +452,13 @@ declare module 'vue' {
|
|||||||
readonly refAutoReset: UnwrapRef<typeof import('@vueuse/core')['refAutoReset']>
|
readonly refAutoReset: UnwrapRef<typeof import('@vueuse/core')['refAutoReset']>
|
||||||
readonly refDebounced: UnwrapRef<typeof import('@vueuse/core')['refDebounced']>
|
readonly refDebounced: UnwrapRef<typeof import('@vueuse/core')['refDebounced']>
|
||||||
readonly refDefault: UnwrapRef<typeof import('@vueuse/core')['refDefault']>
|
readonly refDefault: UnwrapRef<typeof import('@vueuse/core')['refDefault']>
|
||||||
|
readonly refManualReset: UnwrapRef<typeof import('@vueuse/core')['refManualReset']>
|
||||||
readonly refThrottled: UnwrapRef<typeof import('@vueuse/core')['refThrottled']>
|
readonly refThrottled: UnwrapRef<typeof import('@vueuse/core')['refThrottled']>
|
||||||
readonly refWithControl: UnwrapRef<typeof import('@vueuse/core')['refWithControl']>
|
readonly refWithControl: UnwrapRef<typeof import('@vueuse/core')['refWithControl']>
|
||||||
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
|
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
|
||||||
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
|
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
|
||||||
readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>
|
readonly routeI18nInstance: UnwrapRef<typeof import('./src/locales-utils/i18n-auto-imports')['routeI18nInstance']>
|
||||||
|
readonly routeI18nT: UnwrapRef<typeof import('./src/locales-utils/i18n-auto-imports')['routeI18nT']>
|
||||||
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
|
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
|
||||||
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
|
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
|
||||||
readonly setViewportCSSVars: UnwrapRef<typeof import('utils4u/browser')['setViewportCSSVars']>
|
readonly setViewportCSSVars: UnwrapRef<typeof import('utils4u/browser')['setViewportCSSVars']>
|
||||||
@@ -466,6 +488,7 @@ declare module 'vue' {
|
|||||||
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
|
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
|
||||||
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
|
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
|
||||||
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
|
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
|
||||||
|
readonly useAppStore: UnwrapRef<typeof import('./src/stores/app-store-auto-imports')['useAppStore']>
|
||||||
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>
|
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>
|
||||||
readonly useArrayEvery: UnwrapRef<typeof import('@vueuse/core')['useArrayEvery']>
|
readonly useArrayEvery: UnwrapRef<typeof import('@vueuse/core')['useArrayEvery']>
|
||||||
readonly useArrayFilter: UnwrapRef<typeof import('@vueuse/core')['useArrayFilter']>
|
readonly useArrayFilter: UnwrapRef<typeof import('@vueuse/core')['useArrayFilter']>
|
||||||
@@ -481,6 +504,7 @@ declare module 'vue' {
|
|||||||
readonly useAsyncQueue: UnwrapRef<typeof import('@vueuse/core')['useAsyncQueue']>
|
readonly useAsyncQueue: UnwrapRef<typeof import('@vueuse/core')['useAsyncQueue']>
|
||||||
readonly useAsyncState: UnwrapRef<typeof import('@vueuse/core')['useAsyncState']>
|
readonly useAsyncState: UnwrapRef<typeof import('@vueuse/core')['useAsyncState']>
|
||||||
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
|
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
|
||||||
|
readonly useAuthStore: UnwrapRef<typeof import('./src/stores/auth-store-auto-imports')['useAuthStore']>
|
||||||
readonly useBase64: UnwrapRef<typeof import('@vueuse/core')['useBase64']>
|
readonly useBase64: UnwrapRef<typeof import('@vueuse/core')['useBase64']>
|
||||||
readonly useBattery: UnwrapRef<typeof import('@vueuse/core')['useBattery']>
|
readonly useBattery: UnwrapRef<typeof import('@vueuse/core')['useBattery']>
|
||||||
readonly useBluetooth: UnwrapRef<typeof import('@vueuse/core')['useBluetooth']>
|
readonly useBluetooth: UnwrapRef<typeof import('@vueuse/core')['useBluetooth']>
|
||||||
@@ -543,7 +567,6 @@ declare module 'vue' {
|
|||||||
readonly useIntervalFn: UnwrapRef<typeof import('@vueuse/core')['useIntervalFn']>
|
readonly useIntervalFn: UnwrapRef<typeof import('@vueuse/core')['useIntervalFn']>
|
||||||
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>
|
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>
|
||||||
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
|
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
|
||||||
readonly useLink: UnwrapRef<typeof import('vue-router/auto')['useLink']>
|
|
||||||
readonly useLoadingBar: UnwrapRef<typeof import('naive-ui')['useLoadingBar']>
|
readonly useLoadingBar: UnwrapRef<typeof import('naive-ui')['useLoadingBar']>
|
||||||
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
|
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
|
||||||
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
|
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
|
||||||
@@ -553,6 +576,7 @@ declare module 'vue' {
|
|||||||
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
|
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
|
||||||
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
|
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
|
||||||
readonly useMessage: UnwrapRef<typeof import('naive-ui')['useMessage']>
|
readonly useMessage: UnwrapRef<typeof import('naive-ui')['useMessage']>
|
||||||
|
readonly useMetaLayoutsNMenuOptions: UnwrapRef<typeof import('./src/composables/useMetaLayoutsMenuOptions')['useMetaLayoutsNMenuOptions']>
|
||||||
readonly useModal: UnwrapRef<typeof import('naive-ui')['useModal']>
|
readonly useModal: UnwrapRef<typeof import('naive-ui')['useModal']>
|
||||||
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
|
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
|
||||||
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
|
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
|
||||||
@@ -582,6 +606,7 @@ declare module 'vue' {
|
|||||||
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
|
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
|
||||||
readonly usePreferredReducedTransparency: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedTransparency']>
|
readonly usePreferredReducedTransparency: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedTransparency']>
|
||||||
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
|
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
|
||||||
|
readonly usePrimevueDialogRef: UnwrapRef<typeof import('utils4u/primevue')['usePrimevueDialogRef']>
|
||||||
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
|
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
|
||||||
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
|
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
|
||||||
readonly useRefs: UnwrapRef<typeof import('utils4u/vue-use')['useRefs']>
|
readonly useRefs: UnwrapRef<typeof import('utils4u/vue-use')['useRefs']>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { UserConfig } from '@commitlint/types';
|
import type { UserConfig } from '@commitlint/types';
|
||||||
|
|
||||||
|
|
||||||
const Configuration: UserConfig = {
|
const Configuration: UserConfig = {
|
||||||
extends: ['@commitlint/config-conventional'],
|
extends: ['@commitlint/config-conventional'],
|
||||||
formatter: '@commitlint/format',
|
formatter: '@commitlint/format',
|
||||||
|
rules: {
|
||||||
|
'body-max-line-length': [2, 'always', 500],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Configuration;
|
export default Configuration;
|
||||||
|
|||||||
@@ -1,60 +1,12 @@
|
|||||||
import { test, expect } from '@playwright/test'
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
test.describe('Vue App', () => {
|
test.describe('Vue App', () => {
|
||||||
test('visits the app root url', async ({ page }) => {
|
test('app renders correctly', async ({ page }) => {
|
||||||
await page.goto('/')
|
await page.goto('/');
|
||||||
await expect(page.locator('h1')).toHaveText('You did it!')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('displays Vue documentation link', async ({ page }) => {
|
const app = page.locator('#app');
|
||||||
await page.goto('/')
|
await expect(app).toBeVisible();
|
||||||
const link = page.locator('a[href="https://vuejs.org/"]')
|
|
||||||
await expect(link).toBeVisible()
|
|
||||||
await expect(link).toHaveText('vuejs.org')
|
|
||||||
await expect(link).toHaveAttribute('target', '_blank')
|
|
||||||
await expect(link).toHaveAttribute('rel', 'noopener')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('displays button with initial name state', async ({ page }) => {
|
await page.locator('.app-loading').waitFor({ state: 'detached' });
|
||||||
await page.goto('/')
|
});
|
||||||
const button = page.locator('button[aria-label="get name"]')
|
});
|
||||||
await expect(button).toBeVisible()
|
|
||||||
await expect(button).toHaveText('Name from API is: Unknown')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('button click triggers API call', async ({ page }) => {
|
|
||||||
await page.goto('/')
|
|
||||||
|
|
||||||
await page.route('/api/', async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ name: 'Test User' }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const button = page.locator('button[aria-label="get name"]')
|
|
||||||
await button.click()
|
|
||||||
|
|
||||||
await expect(button).toHaveText('Name from API is: Test User')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('handles API error gracefully', async ({ page }) => {
|
|
||||||
await page.goto('/')
|
|
||||||
|
|
||||||
await page.route('/api/', async (route) => {
|
|
||||||
await route.abort('failed')
|
|
||||||
})
|
|
||||||
|
|
||||||
const button = page.locator('button[aria-label="get name"]')
|
|
||||||
await button.click()
|
|
||||||
|
|
||||||
await expect(button).toHaveText('Name from API is: Unknown')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('app-layout is present', async ({ page }) => {
|
|
||||||
await page.goto('/')
|
|
||||||
const appLayout = page.locator('.app-layout')
|
|
||||||
await expect(appLayout).toBeVisible()
|
|
||||||
await expect(appLayout).toContainText('AppLayout')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"extends": "@tsconfig/node22/tsconfig.json",
|
"extends": "@tsconfig/node24/tsconfig.json",
|
||||||
"include": ["./**/*"]
|
"include": ["./**/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
+96
-16
@@ -1,14 +1,22 @@
|
|||||||
import { globalIgnores } from 'eslint/config'
|
import vueI18n from '@intlify/eslint-plugin-vue-i18n';
|
||||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
// import stylistic from '@stylistic/eslint-plugin';
|
||||||
import pluginVue from 'eslint-plugin-vue'
|
import pluginVitest from '@vitest/eslint-plugin';
|
||||||
import pluginVitest from '@vitest/eslint-plugin'
|
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting';
|
||||||
import pluginPlaywright from 'eslint-plugin-playwright'
|
import {
|
||||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
configureVueProject,
|
||||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
defineConfigWithVueTs,
|
||||||
|
vueTsConfigs,
|
||||||
|
} from '@vue/eslint-config-typescript';
|
||||||
|
import pluginImport from 'eslint-plugin-import';
|
||||||
|
import pluginJsonc from 'eslint-plugin-jsonc';
|
||||||
|
import pluginOxlint from 'eslint-plugin-oxlint';
|
||||||
|
import pluginPerfectionist from 'eslint-plugin-perfectionist';
|
||||||
|
import pluginPlaywright from 'eslint-plugin-playwright';
|
||||||
|
import pluginVue from 'eslint-plugin-vue';
|
||||||
|
import { globalIgnores } from 'eslint/config';
|
||||||
|
import jsoncParser from 'jsonc-eslint-parser';
|
||||||
|
|
||||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
configureVueProject({ scriptLangs: ['ts', 'tsx'] });
|
||||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
|
||||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
|
||||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||||
|
|
||||||
export default defineConfigWithVueTs(
|
export default defineConfigWithVueTs(
|
||||||
@@ -17,7 +25,14 @@ export default defineConfigWithVueTs(
|
|||||||
files: ['**/*.{ts,mts,tsx,vue}'],
|
files: ['**/*.{ts,mts,tsx,vue}'],
|
||||||
},
|
},
|
||||||
|
|
||||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
globalIgnores([
|
||||||
|
'worker-configuration.d.ts',
|
||||||
|
'**/dist/**',
|
||||||
|
'**/dist-ssr/**',
|
||||||
|
'**/coverage/**',
|
||||||
|
'**/public/**',
|
||||||
|
'**/-----TEMP-----/**',
|
||||||
|
]),
|
||||||
|
|
||||||
pluginVue.configs['flat/essential'],
|
pluginVue.configs['flat/essential'],
|
||||||
vueTsConfigs.recommended,
|
vueTsConfigs.recommended,
|
||||||
@@ -32,9 +47,61 @@ export default defineConfigWithVueTs(
|
|||||||
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
||||||
},
|
},
|
||||||
...pluginOxlint.configs['flat/recommended'],
|
...pluginOxlint.configs['flat/recommended'],
|
||||||
skipFormatting,
|
|
||||||
|
// https://eslint-plugin-vue-i18n.intlify.dev/started.html#getting-started
|
||||||
|
...vueI18n.configs.recommended,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@intlify/vue-i18n/no-raw-text': 'off',
|
||||||
|
'@intlify/vue-i18n/no-unused-keys': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
src: './src',
|
||||||
|
extensions: ['.js', '.ts', '.tsx', '.vue'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
'vue-i18n': {
|
||||||
|
localeDir: './src/locales/**/*.json',
|
||||||
|
messageSyntaxVersion: '^11.0.0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
plugins: { import: pluginImport },
|
||||||
|
rules: {
|
||||||
|
'import/first': 'error',
|
||||||
|
'import/no-duplicates': 'error',
|
||||||
|
'import/newline-after-import': 'error',
|
||||||
|
'import/no-mutable-exports': 'error',
|
||||||
|
'import/no-named-default': 'error',
|
||||||
|
'import/no-self-import': 'error',
|
||||||
|
'import/no-unresolved': 'off',
|
||||||
|
'import/no-webpack-loader-syntax': 'error',
|
||||||
|
'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{ plugins: { perfectionist: pluginPerfectionist } },
|
||||||
|
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* 启用 sort-keys 规则以强制对象键按字母顺序排序
|
||||||
|
* 原因:
|
||||||
|
* 1. 减少多人协作时的合并冲突
|
||||||
|
* 2. 保持代码一致性,提高可维护性
|
||||||
|
*/
|
||||||
|
files: ['src/locales/**/*.json'],
|
||||||
|
languageOptions: { parser: jsoncParser },
|
||||||
|
plugins: { jsonc: pluginJsonc },
|
||||||
|
rules: { 'jsonc/sort-keys': 'error' },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
// plugins: {
|
||||||
|
// '@stylistic': stylistic,
|
||||||
|
// },
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'vue/block-order': [
|
'vue/block-order': [
|
||||||
@@ -45,11 +112,24 @@ export default defineConfigWithVueTs(
|
|||||||
],
|
],
|
||||||
'vue/define-macros-order': [
|
'vue/define-macros-order': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{ order: ['defineOptions', 'defineModel', 'defineProps', 'defineEmits', 'defineSlots'] },
|
||||||
order: ['defineOptions', 'defineProps', 'defineEmits', 'defineSlots'],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
|
'vue/attributes-order': 'error',
|
||||||
'vue/multi-word-component-names': 'off',
|
'vue/multi-word-component-names': 'off',
|
||||||
|
'vue/padding-line-between-blocks': ['error', 'always'],
|
||||||
|
'vue/component-name-in-template-casing': [
|
||||||
|
'error',
|
||||||
|
'PascalCase',
|
||||||
|
{ registeredComponentsOnly: false, ignores: [] },
|
||||||
|
],
|
||||||
|
// '@stylistic/padding-line-between-statements': [
|
||||||
|
// 'error',
|
||||||
|
// { blankLine: 'always', prev: '*', next: ['const', 'let', 'var'] },
|
||||||
|
// { blankLine: 'always', prev: ['const', 'let', 'var'], next: '*' },
|
||||||
|
// { blankLine: 'any', prev: ['const', 'let', 'var'], next: ['const', 'let', 'var'] },
|
||||||
|
// ],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
|
||||||
|
skipFormatting,
|
||||||
|
);
|
||||||
|
|||||||
+114
-36
@@ -1,62 +1,139 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN" data-build-time="%VITE_BUILD_TIME%" data-commit="%VITE_BUILD_COMMIT%">
|
<html lang="zh-CN" data-build-time="%VITE_APP_BUILD_TIME%" data-commit="%VITE_APP_BUILD_COMMIT%">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
|
||||||
<!-- <link rel="icon" href="data:;base64,iVBORw0KGgo=" /> -->
|
<!-- <link rel="icon" href="data:;base64,iVBORw0KGgo=" /> -->
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
|
||||||
|
<!-- viewport-fit=cover, -->
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover, user-scalable=no"
|
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
<meta name="format-detection" content="telephone=no" />
|
<meta name="format-detection" content="telephone=no" />
|
||||||
<title>vue-ts-example-2025</title>
|
|
||||||
|
<title>%VITE_APP_TITLE%</title>
|
||||||
|
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
|
*,
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
body {
|
body {
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
body {
|
|
||||||
background-color: #000;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style type="text/css">
|
||||||
#app {
|
#app {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@supports (min-height: 100dvh) {
|
@supports (min-height: 100dvh) {
|
||||||
#app {
|
#app {
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
html .min-h-screen {
|
|
||||||
min-height: 100dvh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.page-wrapper {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<!-- ontouchstart ontouchend -->
|
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<div class="page-wrapper" style="display: flex; justify-content: center; align-items: center">
|
<style type="text/css">
|
||||||
Loading...
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.app-loading-spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid rgba(0, 0, 0, 0.08);
|
||||||
|
border-top-color: #333;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background-color: #0f0f0f;
|
||||||
|
}
|
||||||
|
.app-loading-spinner {
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-top-color: #e5e5e5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div
|
||||||
|
class="app-loading"
|
||||||
|
style="
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="app-loading-spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
<!-- <script src="https://testingcf.jsdelivr.net/npm/@vant/touch-emulator/dist/index.min.js"></script> -->
|
<!-- <script src="https://testingcf.jsdelivr.net/npm/@vant/touch-emulator/dist/index.min.js"></script> -->
|
||||||
<!-- .min.js 是 jsDelivr 的特殊处理 -->
|
<!-- .min.js 是 jsDelivr 的特殊处理 -->
|
||||||
<!-- <script src="https://unpkg.luckincdn.com/@vant/touch-emulator@1.4.0/dist/index.js"></script> -->
|
<!-- <script src="https://unpkg.luckincdn.com/@vant/touch-emulator@1.4.0/dist/index.js"></script> -->
|
||||||
</body>
|
|
||||||
<script>
|
<script>
|
||||||
;(function (d) {
|
window.addEventListener('DOMContentLoaded', function () {
|
||||||
|
window.ontouchstart = function () {};
|
||||||
|
window.ontouchend = function () {};
|
||||||
|
});
|
||||||
|
|
||||||
|
window.onloadX = function () {
|
||||||
|
// 禁止双指缩放
|
||||||
|
document.addEventListener('touchstart', function (event) {
|
||||||
|
if (event.touches.length > 1) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 禁止双击放大
|
||||||
|
var lastTouchEnd = 0;
|
||||||
|
document.addEventListener(
|
||||||
|
'touchend',
|
||||||
|
function (event) {
|
||||||
|
var now = new Date().getTime();
|
||||||
|
if (now - lastTouchEnd <= 300) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
lastTouchEnd = now;
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 禁止手势事件
|
||||||
|
document.addEventListener('gesturestart', function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
(function (d) {
|
||||||
var config = {
|
var config = {
|
||||||
kitId: 'whk2tto',
|
kitId: 'whk2tto',
|
||||||
scriptTimeout: 3000,
|
scriptTimeout: 3000,
|
||||||
@@ -64,25 +141,26 @@
|
|||||||
},
|
},
|
||||||
h = d.documentElement,
|
h = d.documentElement,
|
||||||
t = setTimeout(function () {
|
t = setTimeout(function () {
|
||||||
h.className = h.className.replace(/\bwf-loading\b/g, '') + ' wf-inactive'
|
h.className = h.className.replace(/\bwf-loading\b/g, '') + ' wf-inactive';
|
||||||
}, config.scriptTimeout),
|
}, config.scriptTimeout),
|
||||||
tk = d.createElement('script'),
|
tk = d.createElement('script'),
|
||||||
f = false,
|
f = false,
|
||||||
s = d.getElementsByTagName('script')[0],
|
s = d.getElementsByTagName('script')[0],
|
||||||
a
|
a;
|
||||||
h.className += ' wf-loading'
|
h.className += ' wf-loading';
|
||||||
tk.src = 'https://use.typekit.net/' + config.kitId + '.js'
|
tk.src = 'https://use.typekit.net/' + config.kitId + '.js';
|
||||||
tk.async = true
|
tk.async = true;
|
||||||
tk.onload = tk.onreadystatechange = function () {
|
tk.onload = tk.onreadystatechange = function () {
|
||||||
a = this.readyState
|
a = this.readyState;
|
||||||
if (f || (a && a != 'complete' && a != 'loaded')) return
|
if (f || (a && a != 'complete' && a != 'loaded')) return;
|
||||||
f = true
|
f = true;
|
||||||
clearTimeout(t)
|
clearTimeout(t);
|
||||||
try {
|
try {
|
||||||
Typekit.load(config)
|
Typekit.load(config);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
};
|
||||||
s.parentNode.insertBefore(tk, s)
|
s.parentNode.insertBefore(tk, s);
|
||||||
}) /* (document) */
|
}); /* (document) */
|
||||||
</script>
|
</script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+129
-90
@@ -1,124 +1,163 @@
|
|||||||
{
|
{
|
||||||
"packageManager": "pnpm@10.15.1",
|
"packageManager": "pnpm@10.25.0",
|
||||||
"name": "vue-ts-example-2025",
|
"name": "vue-ts-example-2025",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"_devenginesruntime_docs": "https://pnpm.io/zh/package_json#devenginesruntime",
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"devEngines": {
|
||||||
|
"runtime": {
|
||||||
|
"name": "node",
|
||||||
|
"version": "^24.11.1",
|
||||||
|
"onFail": "download"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"_all": "run-p build-only format type-check lint",
|
"all": "run-s lint format:prettier build-only type-check test:unit:DisableWatch",
|
||||||
"dev": "vite --port 4730 --host --strictPort",
|
"dev": "nodemon --delay 280ms --watch vite-plugins --ext ts -x vite -- --port 4730 --host --strictPort",
|
||||||
"build": "run-p type-check \"build-only {@}\" --",
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
"build-only": "vite build",
|
"build-only": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview --port 4731 --host --strictPort",
|
||||||
"preview:wrangler": "pnpm run build && wrangler dev",
|
"wrangler:dev": "wrangler dev --port 4732",
|
||||||
"test:unit": "vitest",
|
"format:prettier": "prettier --config=.prettierrc.json --cache --write --log-level=warn src/",
|
||||||
"test:playwright": "playwright test",
|
|
||||||
"test:playwright:headless": "HEADLESS=true playwright test",
|
|
||||||
"test:playwright:ui": "playwright test --ui",
|
|
||||||
"test:playwright:chromium": "playwright test --project=chromium",
|
|
||||||
"type-check": "vue-tsc --build",
|
"type-check": "vue-tsc --build",
|
||||||
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
|
|
||||||
"lint:eslint": "eslint . --fix",
|
|
||||||
"_oxlint_cfg": "oxlint . --fix --ignore-path=.gitignore --print-config",
|
|
||||||
"__oxlint_-D": "oxlint . --fix --deny=correctness",
|
|
||||||
"lint": "run-s lint:*",
|
"lint": "run-s lint:*",
|
||||||
"format": "prettier --write src/",
|
"lint:vue-i18n-extract": "vue-i18n-extract report --vueFiles './src/**/*.?(ts|tsx|vue)' --languageFiles './src/locales/**/*.?(json|yml|yaml|js)' --ci",
|
||||||
"-wrangler:pages:deploy:preview": "wrangler pages deploy dist --project-name=vue-ts-example-2025 --branch=preview",
|
"lint:stylelint": "stylelint --config=stylelint.config.mjs --cache --output-file=node_modules/.cache/stylelint/stylelint-report.json --cache-location=node_modules/.cache/stylelint/.stylelintcache --fix --ignore-path=.gitignore '**/*.{css,less,scss,vue}'",
|
||||||
"-wrangler:pages:deploy:prod": "wrangler pages deploy dist --project-name=vue-ts-example-2025",
|
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
|
||||||
"-deploy:preview": "run-s build-only wrangler:pages:deploy:preview",
|
"lint:eslint": "eslint . --fix --config=eslint.config.ts --concurrency=auto --env-info --cache --cache-location=node_modules/.cache/eslint/.eslintcache",
|
||||||
"-deploy:prod": "run-s build-only wrangler:pages:deploy:prod",
|
"test:unit:DisableWatch": "vitest --run",
|
||||||
"wrangler:deploy": "pnpm run build && wrangler deploy",
|
"test:playwright:headless": "HEADLESS=true playwright test --quiet",
|
||||||
"wrangler:versions:upload": "pnpm run build && wrangler versions upload",
|
"_stylelint-config": "stylelint --config=stylelint.config.mjs --print-config src/styles/scss/global.scss",
|
||||||
"cf-typegen": "wrangler types",
|
"postinstall": "wrangler types",
|
||||||
"_dep:dedupe": "pnpm dedupe",
|
|
||||||
"_dep:update": "pnpm dlx taze major --interactive",
|
|
||||||
"_sizecheck:Treemap": "pnpm dlx vite-bundle-visualizer -t treemap",
|
|
||||||
"_sizecheck:Sunburst": "pnpm dlx vite-bundle-visualizer -t sunburst",
|
|
||||||
"_sizecheck:Network": "pnpm dlx vite-bundle-visualizer -t network",
|
|
||||||
"_knip": "pnpm dlx knip",
|
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"{src,e2e}/**/*.{js,jsx,ts,tsx,vue}": [
|
"{src,e2e}/**/*.{js,jsx,ts,tsx,vue}": [
|
||||||
"prettier --write",
|
|
||||||
"eslint --fix",
|
"eslint --fix",
|
||||||
"oxlint --fix"
|
"oxlint --fix",
|
||||||
]
|
"prettier --write"
|
||||||
|
],
|
||||||
|
"{src,packages}/**/*.{css,less,scss,vue}": [
|
||||||
|
"stylelint --fix"
|
||||||
|
],
|
||||||
|
"{src/locales-utils,src/locales}/**/*": "node scripts/type-check-for-lint-staged.mjs"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"onlyBuiltDependencies": [
|
||||||
"vite": "$vite"
|
"@parcel/watcher",
|
||||||
}
|
"esbuild",
|
||||||
|
"oxc-resolver",
|
||||||
|
"sharp",
|
||||||
|
"vue-demi",
|
||||||
|
"workerd"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@commitlint/cli": "^19.8.1",
|
"@commitlint/cli": "^20.1.0",
|
||||||
"@commitlint/config-conventional": "^19.8.1",
|
"@commitlint/config-conventional": "^20.0.0",
|
||||||
"@formkit/auto-animate": "^0.9.0",
|
"@formkit/auto-animate": "^0.9.0",
|
||||||
"@pinia/colada": "^0.17.4",
|
"@pinia/colada": "^0.21.0",
|
||||||
"@primeuix/themes": "^1.2.3",
|
"@primeuix/themes": "^2.0.0",
|
||||||
"@unhead/vue": "^2.0.14",
|
"@sa/materials": "workspace:*",
|
||||||
"@vueuse/core": "^13.9.0",
|
"@unhead/vue": "^2.0.19",
|
||||||
"pinia": "^3.0.3",
|
"@vueuse/core": "^14.0.0",
|
||||||
"pinia-plugin-persistedstate": "^4.5.0",
|
"highlight.js": "^11.11.1",
|
||||||
"primelocale": "^2.1.7",
|
"jsonc-eslint-parser": "^2.4.1",
|
||||||
"primevue": "^4.3.9",
|
"lodash-es": "^4.17.21",
|
||||||
"utils4u": "^4.2.3",
|
"naive-ui": "^2.43.2",
|
||||||
"vue": "^3.5.21",
|
"pinia": "^3.0.4",
|
||||||
"vue-i18n": "^11.1.12",
|
"primeicons": "^7.0.0",
|
||||||
"vue-router": "^4.5.1"
|
"primelocale": "^2.2.2",
|
||||||
|
"primevue": "^4.4.1",
|
||||||
|
"ts-enum-util": "^4.1.0",
|
||||||
|
"utils4u": "^5",
|
||||||
|
"vue": "^3.5.24",
|
||||||
|
"vue-i18n": "^11.2.1",
|
||||||
|
"vue-memoize-dict": "^1.1.3",
|
||||||
|
"vue-router": "^4.6.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.12.4",
|
"@cloudflare/vite-plugin": "^1.15.2",
|
||||||
"@commitlint/types": "^19.8.1",
|
"@commitlint/types": "^20.0.0",
|
||||||
"@intlify/unplugin-vue-i18n": "^11.0.0",
|
"@iconify-json/carbon": "^1.2.14",
|
||||||
"@playwright/test": "^1.55.0",
|
"@iconify-json/clarity": "^1.2.4",
|
||||||
"@prettier/plugin-oxc": "^0.0.4",
|
"@iconify-json/line-md": "^1.2.11",
|
||||||
"@primevue/auto-import-resolver": "^4.3.9",
|
"@iconify-json/material-symbols": "^1.2.47",
|
||||||
"@primevue/metadata": "^4.3.9",
|
"@intlify/eslint-plugin-vue-i18n": "^4.1.0",
|
||||||
"@tsconfig/node22": "^22.0.2",
|
"@intlify/unplugin-vue-i18n": "^11.0.1",
|
||||||
"@types/jsdom": "^21.1.7",
|
"@playwright/test": "^1.57.0",
|
||||||
"@types/node": "^22.18.1",
|
"@prettier/plugin-oxc": "^0.1.3",
|
||||||
|
"@primevue/auto-import-resolver": "^4.4.1",
|
||||||
|
"@primevue/metadata": "^4.4.1",
|
||||||
|
"@stylelint-types/stylelint-order": "^7.0.0",
|
||||||
|
"@stylelint-types/stylelint-scss": "^6.11.0",
|
||||||
|
"@stylistic/eslint-plugin": "^5.6.1",
|
||||||
|
"@tsconfig/node24": "^24.0.0",
|
||||||
|
"@types/html-minifier-terser": "^7.0.2",
|
||||||
|
"@types/jsdom": "^27.0.0",
|
||||||
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
"@vant/auto-import-resolver": "^1.3.0",
|
"@vant/auto-import-resolver": "^1.3.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
"@vitejs/plugin-vue-jsx": "^5.1.1",
|
"@vitejs/plugin-vue-jsx": "^5.1.2",
|
||||||
"@vitest/eslint-plugin": "^1.3.9",
|
"@vitest/eslint-plugin": "^1.4.3",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^14.6.0",
|
"@vue/eslint-config-typescript": "^14.6.0",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"eslint": "^9.35.0",
|
"boxen": "^8.0.1",
|
||||||
"eslint-plugin-oxlint": "~1.14.0",
|
"consola": "^3.4.2",
|
||||||
"eslint-plugin-playwright": "^2.2.2",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-vue": "~10.4.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
|
"eslint-plugin-jsonc": "^2.21.0",
|
||||||
|
"eslint-plugin-oxlint": "~1.32.0",
|
||||||
|
"eslint-plugin-perfectionist": "^5.0.0",
|
||||||
|
"eslint-plugin-playwright": "^2.3.0",
|
||||||
|
"eslint-plugin-vue": "~10.6.0",
|
||||||
|
"happy-dom": "^20.0.10",
|
||||||
|
"html-minifier-terser": "^7.2.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jiti": "^2.5.1",
|
"jsdom": "^27.2.0",
|
||||||
"jsdom": "^26.1.0",
|
"lint-staged": "^16.2.7",
|
||||||
"lint-staged": "^16.1.6",
|
"nodemon": "^3.1.11",
|
||||||
"npm-run-all2": "^8.0.4",
|
"npm-run-all2": "^8.0.4",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"oxlint": "~1.14.0",
|
"oxlint": "~1.29.0",
|
||||||
"prettier": "3.6.2",
|
"postcss-html": "^1.8.0",
|
||||||
"typescript": "~5.9.2",
|
"prettier": "3.8.1",
|
||||||
"unocss": "^66.5.1",
|
"rollup": "^4.53.3",
|
||||||
"unocss-preset-animations": "^1.2.1",
|
"sass-embedded": "^1.93.3",
|
||||||
"unplugin-auto-import": "^20.1.0",
|
"sharp": "^0.34.5",
|
||||||
"unplugin-icons": "^22.2.0",
|
"stylelint": "^16.26.0",
|
||||||
"unplugin-vue-components": "^29.0.0",
|
"stylelint-config-recess-order": "^7.4.0",
|
||||||
"unplugin-vue-markdown": "^29.1.0",
|
"stylelint-config-standard": "^39.0.1",
|
||||||
"unplugin-vue-router": "^0.15.0",
|
"stylelint-config-standard-scss": "^16.0.0",
|
||||||
"vite": "npm:rolldown-vite@^7.1.9",
|
"stylelint-config-standard-vue": "^1.0.0",
|
||||||
"vite-plugin-checker": "^0.10.3",
|
"stylelint-define-config": "^16.24.0",
|
||||||
"vite-plugin-fake-server": "^2.2.0",
|
"svgo": "^4.0.0",
|
||||||
"vite-plugin-image-optimizer": "^2.0.2",
|
"tinyglobby": "^0.2.15",
|
||||||
"vite-plugin-vue-devtools": "^8.0.1",
|
"type-fest": "^5.2.0",
|
||||||
"vite-plugin-vue-meta-layouts": "^0.6.0",
|
"typescript": "~5.9.3",
|
||||||
|
"unocss": "^66.5.9",
|
||||||
|
"unocss-preset-animations": "^1.3.0",
|
||||||
|
"unplugin-auto-import": "^20.2.0",
|
||||||
|
"unplugin-icons": "^22.5.0",
|
||||||
|
"unplugin-vue-components": "^30.0.0",
|
||||||
|
"unplugin-vue-markdown": "^29.2.0",
|
||||||
|
"unplugin-vue-router": "^0.19.0",
|
||||||
|
"vite": "^7.2.4",
|
||||||
|
"vite-plugin-checker": "^0.12.0",
|
||||||
|
"vite-plugin-fake-server": "^2.2.2",
|
||||||
|
"vite-plugin-image-optimizer": "^2.0.3",
|
||||||
|
"vite-plugin-vue-devtools": "^8.0.5",
|
||||||
|
"vite-plugin-vue-meta-layouts": "^0.6.1",
|
||||||
"vite-plugin-webfont-dl": "^3.11.1",
|
"vite-plugin-webfont-dl": "^3.11.1",
|
||||||
"vitest": "^3.2.4",
|
"vitest": "^4.0.13",
|
||||||
"vue-macros": "3.0.0-beta.23",
|
"vue-component-type-helpers": "^3.1.4",
|
||||||
"vue-tsc": "^3.0.6",
|
"vue-i18n-extract": "^2.0.7",
|
||||||
"wrangler": "^4.35.0"
|
"vue-macros": "3.1.1",
|
||||||
|
"vue-tsc": "^3.1.8",
|
||||||
|
"wrangler": "^4.50.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "@sa/materials",
|
||||||
|
"version": "1.3.15",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"typesVersions": {
|
||||||
|
"*": {
|
||||||
|
"*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"gen:css-types": "bunx --bun typed-css-modules src --pattern '**/*.module.css'"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"simplebar-vue": "2.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import AdminLayout, { LAYOUT_MAX_Z_INDEX, LAYOUT_SCROLL_EL_ID } from './libs/admin-layout';
|
||||||
|
|
||||||
|
import SimpleScrollbar from './libs/simple-scrollbar';
|
||||||
|
|
||||||
|
export { AdminLayout, LAYOUT_SCROLL_EL_ID, LAYOUT_MAX_Z_INDEX, SimpleScrollbar };
|
||||||
|
export * from './types';
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/* @type */
|
||||||
|
|
||||||
|
.layout-header,
|
||||||
|
.layout-header-placement {
|
||||||
|
height: var(--soy-header-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-header {
|
||||||
|
z-index: var(--soy-header-z-index);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-tab {
|
||||||
|
top: var(--soy-header-height);
|
||||||
|
z-index: var(--soy-tab-z-index);
|
||||||
|
height: var(--soy-tab-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-tab-placement {
|
||||||
|
height: var(--soy-tab-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-sider {
|
||||||
|
z-index: var(--soy-sider-z-index);
|
||||||
|
width: var(--soy-sider-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-mobile-sider {
|
||||||
|
z-index: var(--soy-sider-z-index);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-mobile-sider-mask {
|
||||||
|
z-index: var(--soy-mobile-sider-z-index);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-sider-collapsed {
|
||||||
|
z-index: var(--soy-sider-z-index);
|
||||||
|
width: var(--soy-sider-collapsed-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-footer,
|
||||||
|
.layout-footer-placement {
|
||||||
|
height: var(--soy-footer-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-footer {
|
||||||
|
z-index: var(--soy-footer-z-index);
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-gap {
|
||||||
|
padding-left: var(--soy-sider-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-gap-collapsed {
|
||||||
|
padding-left: var(--soy-sider-collapsed-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider-padding-top {
|
||||||
|
padding-top: var(--soy-header-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider-padding-bottom {
|
||||||
|
padding-bottom: var(--soy-footer-height);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
declare const styles: {
|
||||||
|
readonly 'layout-footer': string;
|
||||||
|
readonly 'layout-footer-placement': string;
|
||||||
|
readonly 'layout-header': string;
|
||||||
|
readonly 'layout-header-placement': string;
|
||||||
|
readonly 'layout-mobile-sider': string;
|
||||||
|
readonly 'layout-mobile-sider-mask': string;
|
||||||
|
readonly 'layout-sider': string;
|
||||||
|
readonly 'layout-sider-collapsed': string;
|
||||||
|
readonly 'layout-tab': string;
|
||||||
|
readonly 'layout-tab-placement': string;
|
||||||
|
readonly 'left-gap': string;
|
||||||
|
readonly 'left-gap-collapsed': string;
|
||||||
|
readonly 'sider-padding-bottom': string;
|
||||||
|
readonly 'sider-padding-top': string;
|
||||||
|
};
|
||||||
|
export = styles;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminLayout from './index.vue';
|
||||||
|
import { LAYOUT_MAX_Z_INDEX, LAYOUT_SCROLL_EL_ID } from './shared';
|
||||||
|
|
||||||
|
export default AdminLayout;
|
||||||
|
export { LAYOUT_SCROLL_EL_ID, LAYOUT_MAX_Z_INDEX };
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { AdminLayoutProps } from '../../types';
|
||||||
|
import { LAYOUT_MAX_Z_INDEX, LAYOUT_SCROLL_EL_ID, createLayoutCssVars } from './shared';
|
||||||
|
import style from './index.module.css';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'AdminLayout',
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<AdminLayoutProps>(), {
|
||||||
|
mode: 'vertical',
|
||||||
|
scrollMode: 'content',
|
||||||
|
scrollElId: LAYOUT_SCROLL_EL_ID,
|
||||||
|
commonClass: 'transition-all-300',
|
||||||
|
fixedTop: true,
|
||||||
|
maxZIndex: LAYOUT_MAX_Z_INDEX,
|
||||||
|
headerVisible: true,
|
||||||
|
headerHeight: 56,
|
||||||
|
tabVisible: true,
|
||||||
|
tabHeight: 48,
|
||||||
|
siderVisible: true,
|
||||||
|
siderCollapse: false,
|
||||||
|
siderWidth: 220,
|
||||||
|
siderCollapsedWidth: 64,
|
||||||
|
footerVisible: true,
|
||||||
|
footerHeight: 48,
|
||||||
|
rightFooter: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const slots = defineSlots<Slots>();
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
/** Update siderCollapse */
|
||||||
|
(e: 'update:siderCollapse', collapse: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SlotFn = (props?: Record<string, unknown>) => any;
|
||||||
|
|
||||||
|
type Slots = {
|
||||||
|
/** Main */
|
||||||
|
default?: SlotFn;
|
||||||
|
/** Header */
|
||||||
|
header?: SlotFn;
|
||||||
|
/** Tab */
|
||||||
|
tab?: SlotFn;
|
||||||
|
/** Sider */
|
||||||
|
sider?: SlotFn;
|
||||||
|
/** Footer */
|
||||||
|
footer?: SlotFn;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cssVars = computed(() => createLayoutCssVars(props));
|
||||||
|
|
||||||
|
// config visible
|
||||||
|
const showHeader = computed(() => Boolean(slots.header) && props.headerVisible);
|
||||||
|
const showTab = computed(() => Boolean(slots.tab) && props.tabVisible);
|
||||||
|
const showSider = computed(() => !props.isMobile && Boolean(slots.sider) && props.siderVisible);
|
||||||
|
const showMobileSider = computed(
|
||||||
|
() => props.isMobile && Boolean(slots.sider) && props.siderVisible,
|
||||||
|
);
|
||||||
|
const showFooter = computed(() => Boolean(slots.footer) && props.footerVisible);
|
||||||
|
|
||||||
|
// scroll mode
|
||||||
|
const isWrapperScroll = computed(() => props.scrollMode === 'wrapper');
|
||||||
|
const isContentScroll = computed(() => props.scrollMode === 'content');
|
||||||
|
|
||||||
|
// layout direction
|
||||||
|
const isVertical = computed(() => props.mode === 'vertical');
|
||||||
|
const isHorizontal = computed(() => props.mode === 'horizontal');
|
||||||
|
|
||||||
|
const fixedHeaderAndTab = computed(
|
||||||
|
() => props.fixedTop || (isHorizontal.value && isWrapperScroll.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
// css
|
||||||
|
const leftGapClass = computed(() => {
|
||||||
|
if (!props.fullContent && showSider.value) {
|
||||||
|
return props.siderCollapse ? style['left-gap-collapsed'] : style['left-gap'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerLeftGapClass = computed(() => (isVertical.value ? leftGapClass.value : ''));
|
||||||
|
|
||||||
|
const footerLeftGapClass = computed(() => {
|
||||||
|
const condition1 = isVertical.value;
|
||||||
|
const condition2 = isHorizontal.value && isWrapperScroll.value && !props.fixedFooter;
|
||||||
|
const condition3 = Boolean(isHorizontal.value && props.rightFooter);
|
||||||
|
|
||||||
|
if (condition1 || condition2 || condition3) {
|
||||||
|
return leftGapClass.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const siderPaddingClass = computed(() => {
|
||||||
|
let cls = '';
|
||||||
|
|
||||||
|
if (showHeader.value && !headerLeftGapClass.value) {
|
||||||
|
cls += style['sider-padding-top'];
|
||||||
|
}
|
||||||
|
if (showFooter.value && !footerLeftGapClass.value) {
|
||||||
|
cls += ` ${style['sider-padding-bottom']}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cls;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleClickMask() {
|
||||||
|
emit('update:siderCollapse', true);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative h-full" :class="[commonClass]" :style="cssVars">
|
||||||
|
<div
|
||||||
|
:id="isWrapperScroll ? scrollElId : undefined"
|
||||||
|
class="h-full flex flex-col"
|
||||||
|
:class="[commonClass, scrollWrapperClass, { 'overflow-y-auto': isWrapperScroll }]"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<template v-if="showHeader">
|
||||||
|
<header
|
||||||
|
v-show="!fullContent"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
:class="[
|
||||||
|
style['layout-header'],
|
||||||
|
commonClass,
|
||||||
|
headerLeftGapClass,
|
||||||
|
{ 'absolute top-0 left-0 w-full': fixedHeaderAndTab },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot name="header"></slot>
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
v-show="!fullContent && fixedHeaderAndTab"
|
||||||
|
class="flex-shrink-0 overflow-hidden"
|
||||||
|
:class="[style['layout-header-placement']]"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Tab -->
|
||||||
|
<template v-if="showTab">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 overflow-hidden"
|
||||||
|
:class="[
|
||||||
|
style['layout-tab'],
|
||||||
|
commonClass,
|
||||||
|
tabClass,
|
||||||
|
{ 'top-0!': fullContent || !showHeader },
|
||||||
|
leftGapClass,
|
||||||
|
{ 'absolute left-0 w-full': fixedHeaderAndTab },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot name="tab"></slot>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-show="fullContent || fixedHeaderAndTab"
|
||||||
|
class="flex-shrink-0 overflow-hidden"
|
||||||
|
:class="[style['layout-tab-placement']]"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Sider -->
|
||||||
|
<template v-if="showSider">
|
||||||
|
<aside
|
||||||
|
v-show="!fullContent"
|
||||||
|
class="absolute left-0 top-0 h-full"
|
||||||
|
:class="[
|
||||||
|
commonClass,
|
||||||
|
siderClass,
|
||||||
|
siderPaddingClass,
|
||||||
|
siderCollapse ? style['layout-sider-collapsed'] : style['layout-sider'],
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot name="sider"></slot>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Mobile Sider -->
|
||||||
|
<template v-if="showMobileSider">
|
||||||
|
<aside
|
||||||
|
class="absolute left-0 top-0 h-full w-0 bg-white dark:bg-gray-800"
|
||||||
|
:class="[
|
||||||
|
commonClass,
|
||||||
|
mobileSiderClass,
|
||||||
|
style['layout-mobile-sider'],
|
||||||
|
siderCollapse ? 'overflow-hidden' : style['layout-sider'],
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot name="sider"></slot>
|
||||||
|
</aside>
|
||||||
|
<div
|
||||||
|
v-show="!siderCollapse"
|
||||||
|
class="absolute left-0 top-0 h-full w-full bg-[rgba(0,0,0,0.2)]"
|
||||||
|
:class="[style['layout-mobile-sider-mask']]"
|
||||||
|
@click="handleClickMask"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main
|
||||||
|
:id="isContentScroll ? scrollElId : undefined"
|
||||||
|
class="flex flex-col flex-grow"
|
||||||
|
:class="[commonClass, contentClass, leftGapClass, { 'overflow-y-auto': isContentScroll }]"
|
||||||
|
>
|
||||||
|
<slot></slot>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<template v-if="showFooter">
|
||||||
|
<footer
|
||||||
|
v-show="!fullContent"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
:class="[
|
||||||
|
style['layout-footer'],
|
||||||
|
commonClass,
|
||||||
|
footerClass,
|
||||||
|
footerLeftGapClass,
|
||||||
|
{ 'absolute left-0 bottom-0 w-full': fixedFooter },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot name="footer"></slot>
|
||||||
|
</footer>
|
||||||
|
<div
|
||||||
|
v-show="!fullContent && fixedFooter"
|
||||||
|
class="flex-shrink-0 overflow-hidden"
|
||||||
|
:class="[style['layout-footer-placement']]"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { AdminLayoutProps, LayoutCssVars, LayoutCssVarsProps } from '../../types';
|
||||||
|
|
||||||
|
/** The id of the scroll element of the layout */
|
||||||
|
export const LAYOUT_SCROLL_EL_ID = '__SCROLL_EL_ID__';
|
||||||
|
|
||||||
|
/** The max z-index of the layout */
|
||||||
|
export const LAYOUT_MAX_Z_INDEX = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create layout css vars by css vars props
|
||||||
|
*
|
||||||
|
* @param props Css vars props
|
||||||
|
*/
|
||||||
|
function createLayoutCssVarsByCssVarsProps(props: LayoutCssVarsProps) {
|
||||||
|
const cssVars: LayoutCssVars = {
|
||||||
|
'--soy-header-height': `${props.headerHeight}px`,
|
||||||
|
'--soy-header-z-index': props.headerZIndex,
|
||||||
|
'--soy-tab-height': `${props.tabHeight}px`,
|
||||||
|
'--soy-tab-z-index': props.tabZIndex,
|
||||||
|
'--soy-sider-width': `${props.siderWidth}px`,
|
||||||
|
'--soy-sider-collapsed-width': `${props.siderCollapsedWidth}px`,
|
||||||
|
'--soy-sider-z-index': props.siderZIndex,
|
||||||
|
'--soy-mobile-sider-z-index': props.mobileSiderZIndex,
|
||||||
|
'--soy-footer-height': `${props.footerHeight}px`,
|
||||||
|
'--soy-footer-z-index': props.footerZIndex
|
||||||
|
};
|
||||||
|
|
||||||
|
return cssVars;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create layout css vars
|
||||||
|
*
|
||||||
|
* @param props
|
||||||
|
*/
|
||||||
|
export function createLayoutCssVars(props: AdminLayoutProps) {
|
||||||
|
const {
|
||||||
|
mode,
|
||||||
|
isMobile,
|
||||||
|
maxZIndex = LAYOUT_MAX_Z_INDEX,
|
||||||
|
headerHeight,
|
||||||
|
tabHeight,
|
||||||
|
siderWidth,
|
||||||
|
siderCollapsedWidth,
|
||||||
|
footerHeight
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const headerZIndex = maxZIndex - 3;
|
||||||
|
const tabZIndex = maxZIndex - 5;
|
||||||
|
const siderZIndex = mode === 'vertical' || isMobile ? maxZIndex - 1 : maxZIndex - 4;
|
||||||
|
const mobileSiderZIndex = isMobile ? maxZIndex - 2 : 0;
|
||||||
|
const footerZIndex = maxZIndex - 5;
|
||||||
|
|
||||||
|
const cssProps: LayoutCssVarsProps = {
|
||||||
|
headerHeight,
|
||||||
|
headerZIndex,
|
||||||
|
tabHeight,
|
||||||
|
tabZIndex,
|
||||||
|
siderWidth,
|
||||||
|
siderZIndex,
|
||||||
|
mobileSiderZIndex,
|
||||||
|
siderCollapsedWidth,
|
||||||
|
footerHeight,
|
||||||
|
footerZIndex
|
||||||
|
};
|
||||||
|
|
||||||
|
return createLayoutCssVarsByCssVarsProps(cssProps);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import SimpleScrollbar from './index.vue';
|
||||||
|
|
||||||
|
export default SimpleScrollbar;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Simplebar from 'simplebar-vue';
|
||||||
|
import 'simplebar-vue/dist/simplebar.min.css';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'SimpleScrollbar',
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full flex-1-hidden">
|
||||||
|
<Simplebar class="h-full">
|
||||||
|
<slot />
|
||||||
|
</Simplebar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
/** Header config */
|
||||||
|
interface AdminLayoutHeaderConfig {
|
||||||
|
/**
|
||||||
|
* Whether header is visible
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
headerVisible?: boolean;
|
||||||
|
/**
|
||||||
|
* Header height
|
||||||
|
*
|
||||||
|
* @default 56px
|
||||||
|
*/
|
||||||
|
headerHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tab config */
|
||||||
|
interface AdminLayoutTabConfig {
|
||||||
|
/**
|
||||||
|
* Whether tab is visible
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
tabVisible?: boolean;
|
||||||
|
/**
|
||||||
|
* Tab class
|
||||||
|
*
|
||||||
|
* @default ''
|
||||||
|
*/
|
||||||
|
tabClass?: string;
|
||||||
|
/**
|
||||||
|
* Tab height
|
||||||
|
*
|
||||||
|
* @default 48px
|
||||||
|
*/
|
||||||
|
tabHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sider config */
|
||||||
|
interface AdminLayoutSiderConfig {
|
||||||
|
/**
|
||||||
|
* Whether sider is visible
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
siderVisible?: boolean;
|
||||||
|
/**
|
||||||
|
* Sider class
|
||||||
|
*
|
||||||
|
* @default ''
|
||||||
|
*/
|
||||||
|
siderClass?: string;
|
||||||
|
/**
|
||||||
|
* Mobile sider class
|
||||||
|
*
|
||||||
|
* @default ''
|
||||||
|
*/
|
||||||
|
mobileSiderClass?: string;
|
||||||
|
/**
|
||||||
|
* Sider collapse status
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
siderCollapse?: boolean;
|
||||||
|
/**
|
||||||
|
* Sider width when collapse is false
|
||||||
|
*
|
||||||
|
* @default '220px'
|
||||||
|
*/
|
||||||
|
siderWidth?: number;
|
||||||
|
/**
|
||||||
|
* Sider width when collapse is true
|
||||||
|
*
|
||||||
|
* @default '64px'
|
||||||
|
*/
|
||||||
|
siderCollapsedWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content config */
|
||||||
|
export interface AdminLayoutContentConfig {
|
||||||
|
/**
|
||||||
|
* Content class
|
||||||
|
*
|
||||||
|
* @default ''
|
||||||
|
*/
|
||||||
|
contentClass?: string;
|
||||||
|
/**
|
||||||
|
* Whether content is full the page
|
||||||
|
*
|
||||||
|
* If true, other elements will be hidden by `display: none`
|
||||||
|
*/
|
||||||
|
fullContent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Footer config */
|
||||||
|
export interface AdminLayoutFooterConfig {
|
||||||
|
/**
|
||||||
|
* Whether footer is visible
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
footerVisible?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether footer is fixed
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
fixedFooter?: boolean;
|
||||||
|
/**
|
||||||
|
* Footer class
|
||||||
|
*
|
||||||
|
* @default ''
|
||||||
|
*/
|
||||||
|
footerClass?: string;
|
||||||
|
/**
|
||||||
|
* Footer height
|
||||||
|
*
|
||||||
|
* @default 48px
|
||||||
|
*/
|
||||||
|
footerHeight?: number;
|
||||||
|
/**
|
||||||
|
* Whether footer is on the right side
|
||||||
|
*
|
||||||
|
* When the layout is vertical, the footer is on the right side
|
||||||
|
*/
|
||||||
|
rightFooter?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout mode
|
||||||
|
*
|
||||||
|
* - Horizontal
|
||||||
|
* - Vertical
|
||||||
|
*/
|
||||||
|
export type LayoutMode = 'horizontal' | 'vertical';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scroll mode when content overflow
|
||||||
|
*
|
||||||
|
* - Wrapper: the layout component's wrapper element has a scrollbar
|
||||||
|
* - Content: the layout component's content element has a scrollbar
|
||||||
|
*
|
||||||
|
* @default 'wrapper'
|
||||||
|
*/
|
||||||
|
export type LayoutScrollMode = 'wrapper' | 'content';
|
||||||
|
|
||||||
|
/** Admin layout props */
|
||||||
|
export interface AdminLayoutProps
|
||||||
|
extends AdminLayoutHeaderConfig,
|
||||||
|
AdminLayoutTabConfig,
|
||||||
|
AdminLayoutSiderConfig,
|
||||||
|
AdminLayoutContentConfig,
|
||||||
|
AdminLayoutFooterConfig {
|
||||||
|
/**
|
||||||
|
* Layout mode
|
||||||
|
*
|
||||||
|
* - {@link LayoutMode}
|
||||||
|
*/
|
||||||
|
mode?: LayoutMode;
|
||||||
|
/** Is mobile layout */
|
||||||
|
isMobile?: boolean;
|
||||||
|
/**
|
||||||
|
* Scroll mode
|
||||||
|
*
|
||||||
|
* - {@link ScrollMode}
|
||||||
|
*/
|
||||||
|
scrollMode?: LayoutScrollMode;
|
||||||
|
/**
|
||||||
|
* The id of the scroll element of the layout
|
||||||
|
*
|
||||||
|
* It can be used to get the corresponding Dom and scroll it
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* use the default id by import
|
||||||
|
* ```ts
|
||||||
|
* import { adminLayoutScrollElId } from '@sa/vue-materials';
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @default
|
||||||
|
* ```ts
|
||||||
|
* const adminLayoutScrollElId = '__ADMIN_LAYOUT_SCROLL_EL_ID__'
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
scrollElId?: string;
|
||||||
|
/** The class of the scroll element */
|
||||||
|
scrollElClass?: string;
|
||||||
|
/** The class of the scroll wrapper element */
|
||||||
|
scrollWrapperClass?: string;
|
||||||
|
/**
|
||||||
|
* The common class of the layout
|
||||||
|
*
|
||||||
|
* Is can be used to configure the transition animation
|
||||||
|
*
|
||||||
|
* @default 'transition-all-300'
|
||||||
|
*/
|
||||||
|
commonClass?: string;
|
||||||
|
/**
|
||||||
|
* Whether fix the header and tab
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
fixedTop?: boolean;
|
||||||
|
/**
|
||||||
|
* The max z-index of the layout
|
||||||
|
*
|
||||||
|
* The z-index of Header,Tab,Sider and Footer will not exceed this value
|
||||||
|
*/
|
||||||
|
maxZIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Kebab<S extends string> = S extends Uncapitalize<S> ? S : `-${Uncapitalize<S>}`;
|
||||||
|
|
||||||
|
type KebabCase<S extends string> = S extends `${infer Start}${infer End}`
|
||||||
|
? `${Uncapitalize<Start>}${KebabCase<Kebab<End>>}`
|
||||||
|
: S;
|
||||||
|
|
||||||
|
type Prefix = '--soy-';
|
||||||
|
|
||||||
|
export type LayoutCssVarsProps = Pick<
|
||||||
|
AdminLayoutProps,
|
||||||
|
'headerHeight' | 'tabHeight' | 'siderWidth' | 'siderCollapsedWidth' | 'footerHeight'
|
||||||
|
> & {
|
||||||
|
headerZIndex?: number;
|
||||||
|
tabZIndex?: number;
|
||||||
|
siderZIndex?: number;
|
||||||
|
mobileSiderZIndex?: number;
|
||||||
|
footerZIndex?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LayoutCssVars = {
|
||||||
|
[K in keyof LayoutCssVarsProps as `${Prefix}${KebabCase<K>}`]: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mode of the tab
|
||||||
|
*
|
||||||
|
* - Button: button style
|
||||||
|
* - Chrome: chrome style
|
||||||
|
*
|
||||||
|
* @default chrome
|
||||||
|
*/
|
||||||
|
export type PageTabMode = 'button' | 'chrome';
|
||||||
|
|
||||||
|
export interface PageTabProps {
|
||||||
|
/** Whether is dark mode */
|
||||||
|
darkMode?: boolean;
|
||||||
|
/**
|
||||||
|
* The mode of the tab
|
||||||
|
*
|
||||||
|
* - {@link TabMode}
|
||||||
|
*/
|
||||||
|
mode?: PageTabMode;
|
||||||
|
/**
|
||||||
|
* The common class of the layout
|
||||||
|
*
|
||||||
|
* Is can be used to configure the transition animation
|
||||||
|
*
|
||||||
|
* @default 'transition-all-300'
|
||||||
|
*/
|
||||||
|
commonClass?: string;
|
||||||
|
/** The class of the button tab */
|
||||||
|
buttonClass?: string;
|
||||||
|
/** The class of the chrome tab */
|
||||||
|
chromeClass?: string;
|
||||||
|
/** Whether the tab is active */
|
||||||
|
active?: boolean;
|
||||||
|
/** The color of the active tab */
|
||||||
|
activeColor?: string;
|
||||||
|
/**
|
||||||
|
* Whether the tab is closable
|
||||||
|
*
|
||||||
|
* Show the close icon when true
|
||||||
|
*/
|
||||||
|
closable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PageTabCssVarsProps = {
|
||||||
|
primaryColor: string;
|
||||||
|
primaryColor1: string;
|
||||||
|
primaryColor2: string;
|
||||||
|
primaryColorOpacity1: string;
|
||||||
|
primaryColorOpacity2: string;
|
||||||
|
primaryColorOpacity3: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageTabCssVars = {
|
||||||
|
[K in keyof PageTabCssVarsProps as `${Prefix}${KebabCase<K>}`]: string | number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"jsx": "preserve",
|
||||||
|
"lib": ["DOM", "ESNext"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["node"],
|
||||||
|
"strict": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test'
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
import process from 'node:process'
|
import process from 'node:process';
|
||||||
|
|
||||||
// const runningInVSCode = process.env.TERM_PROGRAM === 'vscode'
|
// const runningInVSCode = process.env.TERM_PROGRAM === 'vscode'
|
||||||
const baseURL = 'http://localhost:4173'
|
const baseURL = 'http://localhost:4731';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read environment variables from file.
|
* Read environment variables from file.
|
||||||
@@ -97,7 +97,7 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
|
|
||||||
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||||
// outputDir: 'test-results/',
|
outputDir: 'playwright-test-results/',
|
||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run your local dev server before starting the tests */
|
||||||
webServer: {
|
webServer: {
|
||||||
@@ -110,4 +110,4 @@ export default defineConfig({
|
|||||||
port: Number(new URL(baseURL).port),
|
port: Number(new URL(baseURL).port),
|
||||||
reuseExistingServer: true /* !process.env.CI */,
|
reuseExistingServer: true /* !process.env.CI */,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
Generated
+6533
-2891
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
packages:
|
||||||
|
- 'packages/*'
|
||||||
|
# shamefullyHoist: false # https://pnpm.io/zh/settings#shamefullyhoist
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
vue-tsc: $vue-tsc
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type check script for lint-staged
|
||||||
|
* This script ignores file arguments passed by lint-staged and runs type-check on the entire project
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawnSync } from 'child_process';
|
||||||
|
|
||||||
|
const result = spawnSync('pnpm', ['run', 'type-check'], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
});
|
||||||
|
|
||||||
|
process.exit(result.status ?? 1);
|
||||||
+82
-17
@@ -1,24 +1,89 @@
|
|||||||
export default {
|
export default {
|
||||||
async fetch(request, env) {
|
async fetch(request, env) {
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// 本地开发环境延迟处理
|
||||||
|
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 路由处理
|
||||||
if (url.pathname.startsWith('/api/')) {
|
if (url.pathname.startsWith('/api/')) {
|
||||||
// write a key-value pair
|
await env.KV.put(
|
||||||
await env.KV.put('KEY', 'VALUE')
|
'events:api:last-call',
|
||||||
// read a key-value pair
|
`${new Date().toISOString()} ${request.method} ${url.pathname}`,
|
||||||
const value = await env.KV.get('KEY')
|
);
|
||||||
// list all key-value pairs
|
|
||||||
const allKeys = await env.KV.list()
|
// 获取所有可用的键名
|
||||||
// delete a key-value pair
|
const availableKeys = [
|
||||||
await env.KV.delete('KEY')
|
'events:api:last-call',
|
||||||
|
'events:ws:connection',
|
||||||
|
'events:ws:message',
|
||||||
|
'events:ws:disconnection',
|
||||||
|
];
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
name: 'Cloudflare',
|
timestamp: Date.now(),
|
||||||
value,
|
lastApiCall: await env.KV.get('events:api:last-call'),
|
||||||
valueAfterDelete: await env.KV.get('KEY'),
|
availableKeys: availableKeys,
|
||||||
allKeys,
|
storedData: {
|
||||||
})
|
apiLastCall: await env.KV.get('events:api:last-call'),
|
||||||
}
|
wsConnection: await env.KV.get('events:ws:connection'),
|
||||||
return new Response(null, { status: 404 })
|
wsMessage: await env.KV.get('events:ws:message'),
|
||||||
|
wsDisconnection: await env.KV.get('events:ws:disconnection'),
|
||||||
},
|
},
|
||||||
} satisfies ExportedHandler<Env>
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket 连接处理
|
||||||
|
if (url.pathname === '/ws') {
|
||||||
|
const upgradeHeader = request.headers.get('Upgrade');
|
||||||
|
if (upgradeHeader !== 'websocket') {
|
||||||
|
return new Response('Expected websocket', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [client, server] = Object.values(new WebSocketPair());
|
||||||
|
|
||||||
|
// 处理服务器端WebSocket消息
|
||||||
|
server.accept();
|
||||||
|
env.KV.put('events:ws:connection', `${new Date().toISOString()} ${url.pathname}`);
|
||||||
|
|
||||||
|
// accept 后立即发送欢迎消息
|
||||||
|
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
}
|
||||||
|
|
||||||
|
server.send(
|
||||||
|
`欢迎连接到WebSocket服务器!连接时间: ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
server.addEventListener('message', async (event) => {
|
||||||
|
console.log('收到客户端消息:', event.data);
|
||||||
|
await env.KV.put('events:ws:message', `${new Date().toISOString()} ${event.data}`);
|
||||||
|
|
||||||
|
// 回复消息给客户端
|
||||||
|
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
}
|
||||||
|
|
||||||
|
server.send(
|
||||||
|
`服务器收到: ${event.data} (时间: ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })})`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.addEventListener('close', (event) => {
|
||||||
|
console.log('WebSocket连接关闭');
|
||||||
|
console.log('[close] event :>> ', event);
|
||||||
|
env.KV.put('events:ws:disconnection', `${new Date().toISOString()} ${url.pathname}`);
|
||||||
|
server.close(event.code, `连接关闭: ${event.reason}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 101,
|
||||||
|
webSocket: client,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 404 });
|
||||||
|
},
|
||||||
|
} satisfies ExportedHandler<Env>;
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment happy-dom
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* https://pinia.vuejs.org/zh/cookbook/testing.html#unit-testing-components
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { createMemoryHistory, createRouter } from 'vue-router';
|
||||||
|
import App from './App.vue';
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: {
|
||||||
|
template: 'Welcome to the blogging app',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
it('renders RouterView', async () => {
|
||||||
|
router.push('/');
|
||||||
|
await router.isReady();
|
||||||
|
|
||||||
|
const wrapper = mount(App, { global: { plugins: [router, createPinia()] } });
|
||||||
|
expect(wrapper.text()).toContain('Welcome to the blogging app');
|
||||||
|
});
|
||||||
|
});
|
||||||
+12
-23
@@ -1,29 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { RouterView } from 'vue-router';
|
||||||
|
import AppNaiveUIProvider from './AppNaiveUIProvider.vue';
|
||||||
const name = ref('Unknown')
|
|
||||||
|
|
||||||
const getName = async () => {
|
|
||||||
const res = await fetch('/api/')
|
|
||||||
const data = await res.json()
|
|
||||||
name.value = data.name
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<DynamicDialog />
|
||||||
<h1>You did it!</h1>
|
<ConfirmDialog />
|
||||||
<p>
|
<Toast style="z-index: 5000" />
|
||||||
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
|
|
||||||
documentation
|
|
||||||
</p>
|
|
||||||
<button class="green" @click="getName" aria-label="get name">
|
|
||||||
Name from API is: {{ name }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DynamicDialog /> <ConfirmDialog /> <Toast />
|
<AppNaiveUIProvider>
|
||||||
<RouterView />
|
<RouterView v-slot="{ Component }">
|
||||||
|
<Transition name="fade" mode="out-in">
|
||||||
|
<component :is="Component" />
|
||||||
|
</Transition>
|
||||||
|
</RouterView>
|
||||||
|
</AppNaiveUIProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { GlobalThemeOverrides } from 'naive-ui';
|
||||||
|
import { darkTheme, dateZhCN, zhCN } from 'naive-ui';
|
||||||
|
import type { FunctionalComponent } from 'vue';
|
||||||
|
import { createTextVNode } from 'vue';
|
||||||
|
|
||||||
|
const appStore = useAppStore();
|
||||||
|
|
||||||
|
// https://www.naiveui.com/zh-CN/light/docs/customize-theme
|
||||||
|
const themeOverrides: GlobalThemeOverrides = {
|
||||||
|
common: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContextHolder: FunctionalComponent = () => {
|
||||||
|
window.$nLoadingBar = useLoadingBar();
|
||||||
|
window.$nModal = useModal();
|
||||||
|
window.$nDialog = useDialog();
|
||||||
|
window.$nMessage = useMessage();
|
||||||
|
window.$nNotification = useNotification();
|
||||||
|
return createTextVNode();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
declare global {
|
||||||
|
export interface Window {
|
||||||
|
$nLoadingBar?: import('naive-ui').LoadingBarProviderInst;
|
||||||
|
$nModal?: import('naive-ui').ModalProviderInst;
|
||||||
|
$nDialog?: import('naive-ui').DialogProviderInst;
|
||||||
|
$nMessage?: import('naive-ui').MessageProviderInst;
|
||||||
|
$nNotification?: import('naive-ui').NotificationProviderInst;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NConfigProvider
|
||||||
|
:locale="zhCN"
|
||||||
|
:date-locale="dateZhCN"
|
||||||
|
:theme-overrides
|
||||||
|
preflight-style-disabled
|
||||||
|
:theme="appStore.isDark ? darkTheme : null"
|
||||||
|
abstract
|
||||||
|
>
|
||||||
|
<NLoadingBarProvider>
|
||||||
|
<NMessageProvider>
|
||||||
|
<NNotificationProvider>
|
||||||
|
<NModalProvider>
|
||||||
|
<NDialogProvider>
|
||||||
|
<slot></slot>
|
||||||
|
<ContextHolder />
|
||||||
|
</NDialogProvider>
|
||||||
|
</NModalProvider>
|
||||||
|
</NNotificationProvider>
|
||||||
|
</NMessageProvider>
|
||||||
|
</NLoadingBarProvider>
|
||||||
|
</NConfigProvider>
|
||||||
|
</template>
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import App from '../App.vue'
|
|
||||||
|
|
||||||
describe('App', () => {
|
|
||||||
it('mounts renders properly', () => {
|
|
||||||
const wrapper = mount(App)
|
|
||||||
expect(wrapper.text()).toContain('You did it!')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- 中心圆形 -->
|
||||||
|
<circle cx="100" cy="100" r="35" fill="#FDB813"/>
|
||||||
|
|
||||||
|
<!-- 光芒 -->
|
||||||
|
<g stroke="#FDB813" stroke-width="8" stroke-linecap="round">
|
||||||
|
<line x1="100" y1="30" x2="100" y2="50"/>
|
||||||
|
<line x1="141" y1="41" x2="129" y2="59"/>
|
||||||
|
<line x1="170" y1="100" x2="150" y2="100"/>
|
||||||
|
<line x1="141" y1="159" x2="129" y2="141"/>
|
||||||
|
<line x1="100" y1="170" x2="100" y2="150"/>
|
||||||
|
<line x1="59" y1="159" x2="71" y2="141"/>
|
||||||
|
<line x1="30" y1="100" x2="50" y2="100"/>
|
||||||
|
<line x1="59" y1="41" x2="71" y2="59"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 608 B |
@@ -0,0 +1,184 @@
|
|||||||
|
import type { MenuInst, MenuOption } from 'naive-ui';
|
||||||
|
import { createGetRoutes } from 'virtual:meta-layouts';
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
import type { RouteRecordRaw } from 'vue-router';
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
|
import IconMenuRounded from '~icons/material-symbols/menu-rounded';
|
||||||
|
|
||||||
|
export function useMetaLayoutsNMenuOptions({ menuInstRef }: { menuInstRef: Ref<MenuInst | null> }) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { t, te } = routeI18nInstance.global;
|
||||||
|
|
||||||
|
// 获取路由表但是不包含布局路由
|
||||||
|
const routes = createGetRoutes(router)();
|
||||||
|
|
||||||
|
const options = computed(() => convertRoutesToNMenuOptions(routes));
|
||||||
|
const selectedKey = ref('');
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => router.currentRoute.value,
|
||||||
|
(route) => {
|
||||||
|
// 优先使用 activeMenuName(通过路由名称解析为路径),如果没有则使用当前路径
|
||||||
|
const activeMenuPath = route.meta.activeMenuName
|
||||||
|
? router.resolve({ name: route.meta.activeMenuName }).path
|
||||||
|
: route.path;
|
||||||
|
|
||||||
|
selectedKey.value = activeMenuPath;
|
||||||
|
menuInstRef.value?.showOption(activeMenuPath); // 展开菜单,确保设定的元素被显示
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 路由转换为菜单树的辅助函数
|
||||||
|
function convertRoutesToNMenuOptions(routes: Readonly<RouteRecordRaw[]>): MenuOption[] {
|
||||||
|
const orderMaxLength = routes.reduce((max, route) => {
|
||||||
|
const order = route.meta?.order;
|
||||||
|
if (order !== undefined) {
|
||||||
|
const orderLength = String(order).length;
|
||||||
|
return orderLength > max ? orderLength : max;
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const menuMap = new Map<string, MenuOption>();
|
||||||
|
const rootMenus: MenuOption[] = [];
|
||||||
|
|
||||||
|
// 过滤路由
|
||||||
|
const validRoutes = routes.filter((route) => {
|
||||||
|
// 过滤掉不需要显示的路由
|
||||||
|
if (route.meta?.hideInMenu === true || route.meta?.layout === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 过滤掉通配符路径
|
||||||
|
if (route.path.includes('*')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 根据环境变量判断是否显示 /demos 开头的路由
|
||||||
|
if (import.meta.env.VITE_APP_MENU_SHOW_DEMOS !== 'true' && route.path.startsWith('/demos')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 排序路由:先按路径深度分组,再按 order 排序
|
||||||
|
const sortedRoutes = validRoutes.slice().sort((a: RouteRecordRaw, b: RouteRecordRaw) => {
|
||||||
|
const pathA = a.path;
|
||||||
|
const pathB = b.path;
|
||||||
|
|
||||||
|
// 1. 首先按路径深度排序(确保父路由在子路由之前)
|
||||||
|
const depthA = pathA.split('/').filter(Boolean).length;
|
||||||
|
const depthB = pathB.split('/').filter(Boolean).length;
|
||||||
|
if (depthA !== depthB) {
|
||||||
|
return depthA - depthB;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取父路径,判断是否为同一父级下的路由
|
||||||
|
const segmentsA = pathA.split('/').filter(Boolean);
|
||||||
|
const segmentsB = pathB.split('/').filter(Boolean);
|
||||||
|
const parentA = segmentsA.length > 1 ? `/${segmentsA.slice(0, -1).join('/')}` : '/';
|
||||||
|
const parentB = segmentsB.length > 1 ? `/${segmentsB.slice(0, -1).join('/')}` : '/';
|
||||||
|
|
||||||
|
// 如果父路径不同,按父路径字母顺序排序
|
||||||
|
if (parentA !== parentB) {
|
||||||
|
return parentA.localeCompare(parentB);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 同一父级下的路由,按 order 排序
|
||||||
|
const orderA = a.meta?.order;
|
||||||
|
const orderB = b.meta?.order;
|
||||||
|
const hasOrderA = typeof orderA === 'number';
|
||||||
|
const hasOrderB = typeof orderB === 'number';
|
||||||
|
|
||||||
|
// 有 order 的排在没有 order 的前面
|
||||||
|
if (hasOrderA && !hasOrderB) return -1;
|
||||||
|
if (!hasOrderA && hasOrderB) return 1;
|
||||||
|
|
||||||
|
// 都有 order 时,按 order 数值升序排序
|
||||||
|
if (hasOrderA && hasOrderB) {
|
||||||
|
const diff = (orderA as number) - (orderB as number);
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// order 相同或都没有 order,按路径名字母顺序排序
|
||||||
|
return pathA.localeCompare(pathB);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 构建菜单树
|
||||||
|
for (const route of sortedRoutes) {
|
||||||
|
const pathSegments = route.path.split('/').filter(Boolean);
|
||||||
|
const routeName = route.name as string;
|
||||||
|
|
||||||
|
let text = te(routeName) ? t(routeName) : routeName;
|
||||||
|
if (import.meta.env.VITE_APP_MENU_SHOW_ORDER === 'true' && route.meta?.order) {
|
||||||
|
const order = String(route.meta.order).padStart(orderMaxLength, '0');
|
||||||
|
text = `${order}. ${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuOption: MenuOption = {
|
||||||
|
label: () =>
|
||||||
|
route.meta?.link === false ? text : <RouterLink to={route}>{text}</RouterLink>,
|
||||||
|
key: route.path,
|
||||||
|
icon: () => <IconMenuRounded style="width: 1em; height: 1em;" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果是根路径或只有一级路径,直接添加到根菜单
|
||||||
|
if (pathSegments.length === 0 || pathSegments.length === 1) {
|
||||||
|
rootMenus.push(menuOption);
|
||||||
|
menuMap.set(route.path, menuOption);
|
||||||
|
} else {
|
||||||
|
// 多级路径,需要创建或找到父菜单
|
||||||
|
let currentPath = '';
|
||||||
|
for (let i = 0; i < pathSegments.length - 1; i++) {
|
||||||
|
currentPath += `/${pathSegments[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将当前菜单项添加到父菜单
|
||||||
|
const parentPath = currentPath;
|
||||||
|
const parent = menuMap.get(parentPath);
|
||||||
|
if (parent) {
|
||||||
|
if (!parent.children) {
|
||||||
|
parent.children = [];
|
||||||
|
}
|
||||||
|
parent.children.push(menuOption);
|
||||||
|
} else {
|
||||||
|
consola.warn(`未找到父菜单项: ${parentPath},无法将子菜单项添加到其下。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
menuMap.set(route.path, menuOption);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加调试日志
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.debug(
|
||||||
|
'排序后的路由:',
|
||||||
|
sortedRoutes.map((route) => ({
|
||||||
|
path: route.path,
|
||||||
|
name: route.name,
|
||||||
|
order: route.meta?.order,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rootMenus;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.debug(
|
||||||
|
'原始路由:',
|
||||||
|
routes.map((route) => ({
|
||||||
|
path: route.path,
|
||||||
|
name: route.name,
|
||||||
|
order: route.meta?.order,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
console.debug('转换后的菜单:', options.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
options,
|
||||||
|
selectedKey,
|
||||||
|
// expanded-keys // 展开的子菜单标识符数组,如果设定了,菜单的展开将会进入受控状态,default-expanded-keys 不会生效
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import LanguageSwitchButton from './components/LanguageSwitchButton.vue';
|
||||||
|
import ThemeSwitchButton from './components/ThemeSwitchButton.vue';
|
||||||
|
import ToggleSiderButton from './components/ToggleSiderButton.vue';
|
||||||
|
import UserDropdown from './components/UserDropdown.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full flex items-center justify-between px-12px shadow-header dark:shadow-gray-700">
|
||||||
|
<ToggleSiderButton />
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<LanguageSwitchButton />
|
||||||
|
<ThemeSwitchButton />
|
||||||
|
<UserDropdown />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { DropdownOption } from 'naive-ui';
|
||||||
|
|
||||||
|
const { locale, availableLocales } = useI18n({ useScope: 'global' });
|
||||||
|
|
||||||
|
const languageLabels: Record<string, string> = {
|
||||||
|
'en-US': 'English',
|
||||||
|
'zh-CN': '简体中文',
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = computed<DropdownOption[]>(() =>
|
||||||
|
availableLocales.map((lang) => ({
|
||||||
|
label: languageLabels[lang] || lang,
|
||||||
|
key: lang,
|
||||||
|
disabled: locale.value === lang,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSelect(key: string) {
|
||||||
|
locale.value = key;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NDropdown trigger="hover" placement="bottom-end" :options="options" @select="handleSelect">
|
||||||
|
<NButton quaternary class="flex items-center gap-1">
|
||||||
|
<template #icon>
|
||||||
|
<IconClarityLanguageLine w-4.5 h-4.5 />
|
||||||
|
</template>
|
||||||
|
<span>{{ languageLabels[locale] }}</span>
|
||||||
|
</NButton>
|
||||||
|
</NDropdown>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const appStore = useAppStore();
|
||||||
|
|
||||||
|
const themeLabels: Record<AppThemeMode, string> = {
|
||||||
|
light: '浅色',
|
||||||
|
dark: '深色',
|
||||||
|
auto: '跟随系统',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NTooltip :disabled="appStore.isMobile" placement="bottom-end">
|
||||||
|
{{ themeLabels[appStore.themeMode] }}
|
||||||
|
<template #trigger>
|
||||||
|
<NButton quaternary @click="appStore.cycleTheme()">
|
||||||
|
<IconLineMdSunnyFilledLoopToMoonFilledLoopTransition
|
||||||
|
v-if="appStore.themeMode === 'light'"
|
||||||
|
w-4.5
|
||||||
|
h-4.5
|
||||||
|
/>
|
||||||
|
<IconLineMdMoonFilledToSunnyFilledLoopTransition
|
||||||
|
v-else-if="appStore.themeMode === 'dark'"
|
||||||
|
w-4.5
|
||||||
|
h-4.5
|
||||||
|
/>
|
||||||
|
<IconLineMdComputer v-else w-4.5 h-4.5 />
|
||||||
|
</NButton>
|
||||||
|
</template>
|
||||||
|
</NTooltip>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const buttonRef = useTemplateRef('buttonRef');
|
||||||
|
const appStore = useAppStore();
|
||||||
|
|
||||||
|
function toggleCollapsed() {
|
||||||
|
// https://github.com/tusen-ai/naive-ui/issues/3688
|
||||||
|
// hover style 鼠标移出就会消失 如果是点击 button 会聚焦需要失去焦点才会恢复
|
||||||
|
buttonRef.value?.$el.blur();
|
||||||
|
appStore.toggleSidebar();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NTooltip :disabled="appStore.isMobile" placement="bottom-start">
|
||||||
|
{{ appStore.sidebarCollapsed ? '展开菜单' : '收起菜单' }}
|
||||||
|
<template #trigger>
|
||||||
|
<NButton ref="buttonRef" quaternary @click="toggleCollapsed">
|
||||||
|
<IconLineMdMenuFoldRight v-if="appStore.sidebarCollapsed" w-4.5 h-4.5 />
|
||||||
|
<IconLineMdMenuFoldLeft v-else w-4.5 h-4.5 />
|
||||||
|
</NButton>
|
||||||
|
</template>
|
||||||
|
</NTooltip>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="tsx">
|
||||||
|
const router = useRouter();
|
||||||
|
const userStore = useAuthStore();
|
||||||
|
const dialog = useDialog();
|
||||||
|
|
||||||
|
const options = computed(() => [
|
||||||
|
{
|
||||||
|
label: userStore.userInfo?.nickname || userStore.userInfo?.username || '用户',
|
||||||
|
key: 'user',
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider',
|
||||||
|
key: 'd1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '退出登录',
|
||||||
|
key: 'logout',
|
||||||
|
icon: () => <icon-material-symbols-logout class="w-4 h-4" />,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
function handleSelect(key: string) {
|
||||||
|
if (key === 'logout') {
|
||||||
|
dialog.warning({
|
||||||
|
title: '退出登录',
|
||||||
|
content: '确定要退出登录吗?',
|
||||||
|
positiveText: '确定',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: () => {
|
||||||
|
userStore.clearToken('用户退出登录');
|
||||||
|
router.push('/login');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NDropdown :options="options" placement="bottom-end" @select="handleSelect">
|
||||||
|
<NButton quaternary circle>
|
||||||
|
<IconMaterialSymbolsAccountCircle w-5 h-5 />
|
||||||
|
</NButton>
|
||||||
|
</NDropdown>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="tsx">
|
||||||
|
import { useAppStore } from '@/stores/app-store-auto-imports';
|
||||||
|
|
||||||
|
const menuInstRef = useTemplateRef('menuInstRef');
|
||||||
|
const { options, selectedKey } = useMetaLayoutsNMenuOptions({
|
||||||
|
menuInstRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
const appStore = useAppStore();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- @update:value="handleMenuUpdate" -->
|
||||||
|
<NMenu
|
||||||
|
ref="menuInstRef"
|
||||||
|
v-model:value="selectedKey"
|
||||||
|
mode="vertical"
|
||||||
|
:collapsed="appStore.sidebarCollapsed"
|
||||||
|
:collapsed-width="64"
|
||||||
|
:icon-size="20"
|
||||||
|
:collapsed-icon-size="24"
|
||||||
|
:options="options"
|
||||||
|
:inverted="false"
|
||||||
|
:root-indent="32"
|
||||||
|
:indent="32"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { AdminLayout } from '@sa/materials';
|
||||||
|
import BaseLayoutHeader from './base-layout-header/base-layout-header.vue';
|
||||||
|
import BaseLayoutSider from './base-layout-sider.vue';
|
||||||
|
|
||||||
|
const appStore = useAppStore();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AdminLayout
|
||||||
|
v-model:sider-collapse="appStore.sidebarCollapsed"
|
||||||
|
mode="horizontal"
|
||||||
|
:footer-visible="!false"
|
||||||
|
:tab-visible="!false"
|
||||||
|
scroll-mode="content"
|
||||||
|
:is-mobile="appStore.isMobile"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<BaseLayoutHeader />
|
||||||
|
</template>
|
||||||
|
<template #tab>
|
||||||
|
<div
|
||||||
|
class="bg-green-100/28 dark:bg-green-900/28 text-green-900 dark:text-green-100 flex h-full items-center justify-center"
|
||||||
|
>
|
||||||
|
GlobalTab
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #sider>
|
||||||
|
<BaseLayoutSider />
|
||||||
|
</template>
|
||||||
|
<!-- <div>GlobalContent</div> -->
|
||||||
|
<RouterView v-slot="{ Component }">
|
||||||
|
<Transition name="fade" mode="out-in">
|
||||||
|
<component :is="Component" />
|
||||||
|
</Transition>
|
||||||
|
</RouterView>
|
||||||
|
<!-- <div>ThemeDrawer</div> -->
|
||||||
|
<template #footer>
|
||||||
|
<div
|
||||||
|
class="bg-red-100/28 dark:bg-red-900/28 text-red-900 dark:text-red-100 h-full flex items-center justify-center"
|
||||||
|
>
|
||||||
|
GlobalFooter
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</AdminLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
#__SCROLL_EL_ID__ {
|
||||||
|
@include scrollbar;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<script setup lang="ts"></script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="app-layout">
|
|
||||||
<div>AppLayout</div>
|
|
||||||
<router-view />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/* https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n#static-bundle-importing
|
||||||
|
* All i18n resources specified in the plugin `include` option can be loaded
|
||||||
|
* at once using the import syntax
|
||||||
|
*/
|
||||||
|
import messages from '@intlify/unplugin-vue-i18n/messages';
|
||||||
|
|
||||||
|
import { router } from '@/plugins/00.router-plugin';
|
||||||
|
import { createGetRoutes } from 'virtual:meta-layouts';
|
||||||
|
import { createI18n } from 'vue-i18n';
|
||||||
|
import { START_LOCATION } from 'vue-router';
|
||||||
|
|
||||||
|
const locale = useLocalStorage<string>('app-locale', navigator.language);
|
||||||
|
watchEffect(() => {
|
||||||
|
window.document.documentElement.setAttribute('lang', locale.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// https://vue-i18n.intlify.dev/guide/essentials/started.html#registering-the-i18n-plugin
|
||||||
|
export const i18nInstance = createI18n({
|
||||||
|
legacy: false, // you must set `false`, to use Composition API
|
||||||
|
locale: locale.value,
|
||||||
|
fallbackRoot: false,
|
||||||
|
// flatJson: true,
|
||||||
|
missing: (locale, key /* , instance, type */) => {
|
||||||
|
consola.warn(`缺少国际化内容: locale='${locale}', key='${key}'`);
|
||||||
|
return `[${key}]`;
|
||||||
|
},
|
||||||
|
missingWarn: !true,
|
||||||
|
fallbackWarn: !true,
|
||||||
|
messages,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const routeI18nInstance = createI18n({
|
||||||
|
legacy: false, // you must set `false`, to use Composition API
|
||||||
|
locale: locale.value,
|
||||||
|
inheritLocale: true,
|
||||||
|
useScope: 'local',
|
||||||
|
missing: (locale, key) => {
|
||||||
|
consola.warn(`菜单翻译缺失: locale=${locale}, key=${key}`);
|
||||||
|
if (__DEV__) {
|
||||||
|
ToastService.add({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: '菜单翻译缺失',
|
||||||
|
detail: `菜单翻译缺失: locale=${locale}, key=${key}`,
|
||||||
|
life: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
fallbackRoot: true,
|
||||||
|
messages: i18nRouteMessages,
|
||||||
|
});
|
||||||
|
export const routeI18nT = routeI18nInstance.global.t;
|
||||||
|
|
||||||
|
watchEffect(
|
||||||
|
() => {
|
||||||
|
locale.value = i18nInstance.global.locale.value;
|
||||||
|
},
|
||||||
|
{ flush: 'sync' },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => i18nInstance.global.locale.value,
|
||||||
|
() => {
|
||||||
|
const { t, te } = routeI18nInstance.global;
|
||||||
|
|
||||||
|
routeI18nInstance.global.locale.value = i18nInstance.global.locale.value;
|
||||||
|
|
||||||
|
if (router.currentRoute.value.name && router.currentRoute.value !== START_LOCATION) {
|
||||||
|
router.currentRoute.value.meta.title = t(router.currentRoute.value.name as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes = createGetRoutes(router)(); // 获取路由表但是不包含布局路由
|
||||||
|
|
||||||
|
routes.forEach((route) => {
|
||||||
|
const routeName = route.name as string;
|
||||||
|
route.meta.title = te(routeName) ? t(routeName) : routeName;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ immediate: true, flush: 'sync' },
|
||||||
|
);
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# route-messages
|
||||||
|
|
||||||
|
此目录存放专门用于**路由名称**的国际化(i18n)消息。这些消息通过一套自定义的编译时安全机制,为应用的导航菜单提供标题。
|
||||||
|
|
||||||
|
## 解决什么问题?
|
||||||
|
|
||||||
|
`unplugin-vue-router` 的 `definePage()` 宏在编译时执行,无法访问 Vue `<script setup>` 作用域中的运行时变量(如 `t()` 函数)。这使得在路由元信息(`meta`)中直接定义多语言标题变得不可能。
|
||||||
|
|
||||||
|
## 解决方案:自定义的编译时安全机制
|
||||||
|
|
||||||
|
我们采用一种**约定优于配置**的策略,并利用 TypeScript 进行编译时检查,以确保所有菜单标题都已定义。
|
||||||
|
|
||||||
|
**工作流程:**
|
||||||
|
|
||||||
|
1. **`RouteNamedMap` 的生成**:`unplugin-vue-router` 会扫描你的页面组件,并自动生成一个名为 `RouteNamedMap` 的 TypeScript 类型,该类型包含了项目中所有具名路由的 `name`。
|
||||||
|
|
||||||
|
2. **自定义全局类型**:我们定义了一个全局类型 `PageTitleLocalizations`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
declare global {
|
||||||
|
type PageTitleLocalizations = Record<keyof RouteNamedMap, string>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这个自定义类型创建了一个**契约**:任何满足此类型的对象,都**必须**为 `RouteNamedMap` 中的每一个路由名称提供一个字符串类型的键值对。
|
||||||
|
|
||||||
|
3. **编译时检查**:此目录下的每个语言环境文件(如 `en-US.ts`)都必须使用 `satisfies PageTitleLocalizations` 来进行类型断言。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ./en-US.ts
|
||||||
|
export default { ... } satisfies PageTitleLocalizations;
|
||||||
|
```
|
||||||
|
|
||||||
|
这个操作会触发 TypeScript 在**编译时**进行检查。如果你新增了一个具名路由但忘记在此处添加翻译,**TypeScript 编译将会失败**,并明确提示你缺少的键。
|
||||||
|
|
||||||
|
4. **菜单生成**:在运行时,`@/composables/useMetaLayoutsMenuOptions.tsx` 会获取当前路由的 `name`,并使用它作为键(`t(routeName)`)来查找并显示菜单标题。由于有编译时检查,我们可以确信翻译始终存在。
|
||||||
|
|
||||||
|
### 带来的好处
|
||||||
|
|
||||||
|
- **杜绝遗漏**:从根本上解决了菜单项标题缺失或显示为原始键的问题。
|
||||||
|
- **关注点分离**:路由定义只关心路由结构,显示文本则集中在此处管理。
|
||||||
|
|
||||||
|
### 开发者实践指南
|
||||||
|
|
||||||
|
1. **理解路由命名规则**:
|
||||||
|
`unplugin-vue-router` 会根据页面组件的**文件路径**自动生成 `PascalCase` 格式的路由 `name`。
|
||||||
|
- **示例**:
|
||||||
|
- 文件路径:`src/pages/demos/api-demo.page.vue`
|
||||||
|
- 自动生成的路由 `name`:`DemosApiDemo`
|
||||||
|
|
||||||
|
2. **添加对应的翻译**:
|
||||||
|
使用上一步中自动生成的 `name` 作为键,在此目录的每个语言文件中添加翻译。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ./zh-CN.ts
|
||||||
|
export default {
|
||||||
|
// ... 其他翻译
|
||||||
|
DemosApiDemo: 'API 演示',
|
||||||
|
} satisfies PageTitleLocalizations;
|
||||||
|
|
||||||
|
// ./en-US.ts
|
||||||
|
export default {
|
||||||
|
// ... 其他翻译
|
||||||
|
DemosApiDemo: 'API Demo',
|
||||||
|
} satisfies PageTitleLocalizations;
|
||||||
|
```
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/*eslint perfectionist/sort-objects: "error"*/
|
||||||
|
/**
|
||||||
|
* 启用 perfectionist/sort-objects 规则以强制对象键按字母顺序排序
|
||||||
|
* 原因:
|
||||||
|
* 1. 减少多人协作时的合并冲突
|
||||||
|
* 2. 保持代码一致性,提高可维护性
|
||||||
|
*
|
||||||
|
* 运行以下命令自动修复排序:
|
||||||
|
* pnpm exec eslint --fix --no-ignore src/locales-utils/route-messages/
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default {
|
||||||
|
$Path: '$Path',
|
||||||
|
Demos: 'Demos',
|
||||||
|
DemosApiDemo: 'API Demo',
|
||||||
|
DemosCounterDemo: 'Counter Demo',
|
||||||
|
DemosCreate: 'Create Demo',
|
||||||
|
DemosI18nDemo: 'i18n Demo',
|
||||||
|
DemosNaiveUiDemo: 'Naive UI Demo',
|
||||||
|
DemosPrimevueDemo: 'PrimeVue Demo',
|
||||||
|
DemosWebsocketDemo: 'WebSocket Demo',
|
||||||
|
Home: 'Home',
|
||||||
|
Login: 'Login',
|
||||||
|
Root: 'Index',
|
||||||
|
} satisfies PageTitleLocalizations;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { I18nOptions } from 'vue-i18n';
|
||||||
|
|
||||||
|
const modules = import.meta.glob(['./*.ts', '!./route-messages-auto-imports'], {
|
||||||
|
eager: true /* true 为同步,false 为异步 */,
|
||||||
|
import: 'default',
|
||||||
|
});
|
||||||
|
|
||||||
|
type MessageType = Record<string, string>;
|
||||||
|
|
||||||
|
export const i18nRouteMessages: I18nOptions['messages'] = Object.entries(modules).reduce(
|
||||||
|
(messages, [path, mod]) => {
|
||||||
|
const locale = path.replace(/(\.\/|\.ts)/g, '');
|
||||||
|
messages[locale] = mod as MessageType;
|
||||||
|
return messages;
|
||||||
|
},
|
||||||
|
{} as Record<string, MessageType>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/*eslint perfectionist/sort-objects: "error"*/
|
||||||
|
/**
|
||||||
|
* 启用 perfectionist/sort-objects 规则以强制对象键按字母顺序排序
|
||||||
|
* 原因:
|
||||||
|
* 1. 减少多人协作时的合并冲突
|
||||||
|
* 2. 保持代码一致性,提高可维护性
|
||||||
|
*
|
||||||
|
* 运行以下命令自动修复排序:
|
||||||
|
* pnpm exec eslint --fix --no-ignore src/locales-utils/route-messages/
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default {
|
||||||
|
$Path: '$Path',
|
||||||
|
Demos: '示例演示',
|
||||||
|
DemosApiDemo: 'API 调用示例',
|
||||||
|
DemosCounterDemo: '点击计数器',
|
||||||
|
DemosCreate: '创建示例',
|
||||||
|
DemosI18nDemo: '国际化示例',
|
||||||
|
DemosNaiveUiDemo: 'Naive UI 组件示例',
|
||||||
|
DemosPrimevueDemo: 'PrimeVue 组件示例',
|
||||||
|
DemosWebsocketDemo: 'WebSocket 示例',
|
||||||
|
Home: '首页',
|
||||||
|
Login: '登录',
|
||||||
|
Root: '根 (Gēn)',
|
||||||
|
} satisfies PageTitleLocalizations;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"page": {
|
||||||
|
"i18n-demo": {
|
||||||
|
"change-language": "Change Language",
|
||||||
|
"current-language": "Current Language",
|
||||||
|
"hello": "Hello, {name}!",
|
||||||
|
"title": "Vue I18n Demo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"page": {
|
||||||
|
"i18n-demo": {
|
||||||
|
"change-language": "切换语言",
|
||||||
|
"current-language": "当前语言",
|
||||||
|
"hello": "你好, {name}!",
|
||||||
|
"title": "Vue I18n 示例"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
+10
-8
@@ -1,12 +1,14 @@
|
|||||||
import './styles'
|
import './styles/index.ts';
|
||||||
|
|
||||||
// import { LogLevels } from 'consola';
|
import { LogLevels } from 'consola';
|
||||||
// consola.level = LogLevels.verbose;
|
import App from './App.vue';
|
||||||
|
import { setupPlugins } from './plugins';
|
||||||
|
|
||||||
import App from './App.vue'
|
consola.level = LogLevels.verbose;
|
||||||
|
|
||||||
const autoInstallModules = import.meta.glob('./plugins/!(index).ts', { eager: true })
|
const app = createApp(App);
|
||||||
|
if (__DEV__) Object.defineProperty(window, '__APP__', { value: app });
|
||||||
|
setupPlugins(app);
|
||||||
|
|
||||||
import { setupPlugins } from './plugins'
|
await new Promise((resolve) => setTimeout(resolve, 280));
|
||||||
|
app.mount('#app');
|
||||||
setupPlugins(createApp(App), autoInstallModules).mount('#app')
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePage({ meta: { hideInMenu: false } });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>Home Page</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePage({ meta: { ignoreAuth: true, layout: false } });
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const userStore = useAuthStore();
|
||||||
|
const message = useMessage();
|
||||||
|
|
||||||
|
const formValue = ref({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
if (!formValue.value.username || !formValue.value.password) {
|
||||||
|
message.warning('请输入用户名和密码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await userStore.login(formValue.value.username, formValue.value.password);
|
||||||
|
if (result.success) {
|
||||||
|
message.success('登录成功');
|
||||||
|
router.push('/');
|
||||||
|
} else {
|
||||||
|
message.error(result.message || '登录失败');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error('登录异常');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<NCard class="login-card" title="用户登录">
|
||||||
|
<NForm :model="formValue" label-placement="left" label-width="80">
|
||||||
|
<NFormItem label="用户名" path="username">
|
||||||
|
<NInput
|
||||||
|
v-model:value="formValue.username"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
@keyup.enter="handleLogin"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="密码" path="password">
|
||||||
|
<NInput
|
||||||
|
v-model:value="formValue.password"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
@keyup.enter="handleLogin"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem :show-label="false">
|
||||||
|
<NButton type="primary" block :loading="loading" @click="handleLogin"> 登录 </NButton>
|
||||||
|
</NFormItem>
|
||||||
|
</NForm>
|
||||||
|
<div class="login-hint">
|
||||||
|
<NText depth="3">提示:用户名和密码均为 admin</NText>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.login-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 400px;
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-hint {
|
||||||
|
margin-top: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,11 +1,29 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
defineProps<{ path: string }>()
|
defineProps<{ path: string }>();
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
stack?: ReturnType<typeof createStackGuard>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = window?.stack;
|
||||||
|
const canGoBack = stack && stack.length > 1;
|
||||||
|
const router = useRouter();
|
||||||
|
function handleBack() {
|
||||||
|
if (canGoBack) {
|
||||||
|
router.back();
|
||||||
|
} else {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main flex-1 class="flex flex-col items-center justify-center h-full space-y-4">
|
<main flex-1 class="flex flex-col items-center justify-center h-full space-y-4">
|
||||||
<h1>Not Found</h1>
|
<h1>Not Found</h1>
|
||||||
<p>{{ path }} does not exist.</p>
|
<p>{{ path }} does not exist.</p>
|
||||||
<Button @click="$router.back()">Back</Button>
|
<Button @click="handleBack">{{ canGoBack ? 'Back' : 'Home' }}</Button>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
definePage({
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== API 模块 ==========
|
||||||
|
const apiResult = ref<string>('');
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const callApi = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/');
|
||||||
|
const data = await response.json();
|
||||||
|
apiResult.value = JSON.stringify(data, null, 2);
|
||||||
|
} catch (error) {
|
||||||
|
apiResult.value = `Error: ${error}`;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="transition-all duration-500 p-2 sm:p-3 bg-gray-50 dark:bg-gray-900 via-blue-50 dark:via-slate-800 to-slate-100 dark:to-gray-900 bg-gradient-to-br"
|
||||||
|
>
|
||||||
|
<div class="max-w-5xl mx-auto">
|
||||||
|
<!-- API 调用示例 -->
|
||||||
|
<div
|
||||||
|
class="backdrop-blur-sm rounded-2xl shadow-lg border p-4 sm:p-5 hover:shadow-2xl hover:scale-[1.02] transition-all duration-500 cursor-pointer group bg-white dark:bg-gray-800 border-white dark:border-gray-700 hover:bg-white dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg flex items-center justify-center mr-2"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg font-bold text-gray-800 dark:text-gray-100">API 调用示例</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
:disabled="loading"
|
||||||
|
:aria-label="loading ? '正在调用API' : '调用API接口'"
|
||||||
|
class="w-full bg-gradient-to-br from-blue-500 via-blue-600 to-purple-600 text-white font-semibold py-3 px-4 rounded-xl hover:from-blue-600 hover:via-blue-700 hover:to-purple-700 transition-all duration-500 disabled:opacity-50 shadow-lg hover:shadow-2xl transform hover:-translate-y-1 hover:scale-[1.02] text-sm"
|
||||||
|
@click="callApi"
|
||||||
|
>
|
||||||
|
<span v-if="loading" class="flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
调用中...
|
||||||
|
</span>
|
||||||
|
<span v-else>调用 API</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="apiResult"
|
||||||
|
class="mt-4 rounded-lg p-4 border bg-gray-50 dark:bg-gray-800 dark:from-gray-800 dark:to-gray-700 border-gray-200 dark:border-gray-600 bg-gradient-to-r from-gray-50 dark:from-gray-800 to-gray-100 dark:to-gray-700"
|
||||||
|
>
|
||||||
|
<h3 class="font-semibold mb-2 flex items-center text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 mr-2 text-green-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
响应结果:
|
||||||
|
</h3>
|
||||||
|
<pre
|
||||||
|
class="text-sm overflow-x-auto p-3 rounded border text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-600"
|
||||||
|
>{{ apiResult }}</pre
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { NButton } from 'naive-ui';
|
||||||
|
|
||||||
|
definePage({
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 计数器模块 ==========
|
||||||
|
const clickCount = ref(0);
|
||||||
|
|
||||||
|
const incrementCount = () => {
|
||||||
|
clickCount.value++;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetCount = () => {
|
||||||
|
clickCount.value = 0;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="transition-all duration-500 p-2 sm:p-3 bg-gray-50 dark:bg-gray-900 via-blue-50 dark:via-slate-800 to-slate-100 dark:to-gray-900 bg-gradient-to-br"
|
||||||
|
>
|
||||||
|
<div class="max-w-5xl mx-auto">
|
||||||
|
<!-- 计数器示例 -->
|
||||||
|
<div
|
||||||
|
class="mt-4 sm:mt-6 backdrop-blur-sm rounded-2xl shadow-lg border p-4 sm:p-5 bg-white dark:bg-gray-800 border-white dark:border-gray-700 hover:shadow-2xl hover:scale-[1.02] transition-all duration-500"
|
||||||
|
>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="flex items-center mb-2">
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 bg-gradient-to-r from-orange-500 to-red-600 rounded-lg flex items-center justify-center mr-2"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M7 11.5V14m0-2.5v-6a1.5 1.5 0 113 0m-3 6a1.5 1.5 0 00-3 0v2a7.5 7.5 0 0015 0v-5a1.5 1.5 0 00-3 0m-6-3V11m0-5.5v-1a1.5 1.5 0 013 0v1m0 0V11m0-5.5a1.5 1.5 0 013 0v3m0 0V11"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg font-bold text-gray-800 dark:text-gray-100">点击计数器</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 说明文字 -->
|
||||||
|
<div
|
||||||
|
class="text-sm text-gray-600 dark:text-gray-400 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-start">
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 mr-2 mt-0.5 text-blue-500 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<span class="font-semibold text-blue-700 dark:text-blue-300">测试说明:</span>
|
||||||
|
<span class="text-gray-700 dark:text-gray-300"
|
||||||
|
>用于测试移动端连点和页面缩放对按钮点击事件的影响</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center justify-center space-y-4">
|
||||||
|
<!-- 计数显示 -->
|
||||||
|
<div
|
||||||
|
class="w-full p-6 rounded-xl bg-gradient-to-br from-orange-50 to-red-50 dark:from-orange-900/30 dark:to-red-900/30 border-2 border-orange-200 dark:border-orange-700"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300 mb-2">当前点击次数</div>
|
||||||
|
<div
|
||||||
|
class="text-6xl font-bold bg-gradient-to-r from-orange-500 to-red-600 bg-clip-text text-transparent"
|
||||||
|
>
|
||||||
|
{{ clickCount }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="w-full flex flex-col gap-3">
|
||||||
|
<!-- 原生按钮 (带 touch 事件) -->
|
||||||
|
<button
|
||||||
|
class="w-full bg-gradient-to-br from-orange-500 via-orange-600 to-red-600 text-white font-semibold py-4 px-6 rounded-xl hover:from-orange-600 hover:via-orange-700 hover:to-red-700 transition-all duration-500 shadow-lg hover:shadow-2xl transform hover:-translate-y-1 hover:scale-[1.02] text-lg"
|
||||||
|
@touchstart="() => {}"
|
||||||
|
@touchend="() => {}"
|
||||||
|
@click="incrementCount"
|
||||||
|
>
|
||||||
|
<span class="flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
点击 +1 (带 touch)
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 原生按钮 (无 touch 事件) -->
|
||||||
|
<button
|
||||||
|
class="w-full bg-gradient-to-br from-blue-500 via-blue-600 to-purple-600 text-white font-semibold py-4 px-6 rounded-xl hover:from-blue-600 hover:via-blue-700 hover:to-purple-700 transition-all duration-500 shadow-lg hover:shadow-2xl transform hover:-translate-y-1 hover:scale-[1.02] text-lg"
|
||||||
|
@click="incrementCount"
|
||||||
|
>
|
||||||
|
<span class="flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
点击 +1 (无 touch)
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Naive UI 按钮 -->
|
||||||
|
<NButton
|
||||||
|
type="warning"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
strong
|
||||||
|
secondary
|
||||||
|
class="text-lg"
|
||||||
|
@click="incrementCount"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
点击 +1 (Naive UI)
|
||||||
|
</NButton>
|
||||||
|
|
||||||
|
<!-- 重置按钮 -->
|
||||||
|
<button
|
||||||
|
:disabled="clickCount === 0"
|
||||||
|
class="w-full bg-gradient-to-br from-gray-500 via-gray-600 to-gray-700 text-white font-semibold py-3 px-6 rounded-xl hover:from-gray-600 hover:via-gray-700 hover:to-gray-800 transition-all duration-500 disabled:opacity-50 shadow-lg hover:shadow-2xl transform hover:-translate-y-1 hover:scale-[1.02]"
|
||||||
|
@click="resetCount"
|
||||||
|
>
|
||||||
|
<span class="flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
重置计数器
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePage({
|
||||||
|
meta: {
|
||||||
|
hideInMenu: true,
|
||||||
|
activeMenuName: 'Demos',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div></div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePage({ meta: { order: 1 } });
|
||||||
|
const { t, locale } = useI18n({});
|
||||||
|
|
||||||
|
function setLocale(newLocale: 'en-US' | 'zh-CN') {
|
||||||
|
i18nInstance.global.locale.value = newLocale;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-4">
|
||||||
|
<NH1>{{ t('page.i18n-demo.title') }}</NH1>
|
||||||
|
|
||||||
|
<NCard :title="t('page.i18n-demo.change-language')">
|
||||||
|
<NP>
|
||||||
|
{{ t('page.i18n-demo.current-language') }}:
|
||||||
|
<span class="font-bold">{{ locale }}</span>
|
||||||
|
</NP>
|
||||||
|
|
||||||
|
<NP>
|
||||||
|
{{ t('page.i18n-demo.hello', { name: 'Kilo' }) }}
|
||||||
|
</NP>
|
||||||
|
|
||||||
|
<NSpace>
|
||||||
|
<NButton type="primary" @click="setLocale('en-US')"> English </NButton>
|
||||||
|
<NButton type="success" @click="setLocale('zh-CN')"> 简体中文 </NButton>
|
||||||
|
</NSpace>
|
||||||
|
</NCard>
|
||||||
|
|
||||||
|
<!-- 这里响应式有问题: -->
|
||||||
|
<NP> $route.meta.title: {{ $route.meta.title }} </NP>
|
||||||
|
<!-- 这样才正常 -->
|
||||||
|
<NP>
|
||||||
|
routeI18nInstance.global.t($route.name): {{ routeI18nInstance.global.t($route.name) }}
|
||||||
|
</NP>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="tsx">
|
||||||
|
import hljs from 'highlight.js/lib/core';
|
||||||
|
import json from 'highlight.js/lib/languages/json';
|
||||||
|
import type { FunctionalComponent } from 'vue';
|
||||||
|
|
||||||
|
hljs.registerLanguage('json', json);
|
||||||
|
|
||||||
|
definePage({ meta: { link: true } });
|
||||||
|
const FComponent: FunctionalComponent<{ prop: string }> = (props /* context */) => (
|
||||||
|
<>
|
||||||
|
<NBlockquote>
|
||||||
|
函数式组件文档:
|
||||||
|
<a
|
||||||
|
class="text-blue-500 hover:text-blue-600 transition-colors"
|
||||||
|
href="https://cn.vuejs.org/guide/extras/render-function#typing-functional-components"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Render Function & JSX
|
||||||
|
</a>
|
||||||
|
</NBlockquote>
|
||||||
|
<p class="my-4">这是一个函数式组件,它接收到的 prop 值为:</p>
|
||||||
|
<NCode code={JSON.stringify(props, null, 2)} language="json" hljs={hljs} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NCard title="函数式组件(TSX)示例">
|
||||||
|
<FComponent prop="some-prop-value" />
|
||||||
|
</NCard>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MessageType } from 'naive-ui';
|
||||||
|
import { useDialog, useMessage } from 'naive-ui';
|
||||||
|
|
||||||
|
definePage({ meta: {} });
|
||||||
|
|
||||||
|
const message = useMessage();
|
||||||
|
const dialog = useDialog();
|
||||||
|
|
||||||
|
const messageTypes = ['info', 'success', 'warning', 'error', 'loading'] satisfies MessageType[];
|
||||||
|
const dialogTypes = ['info', 'success', 'warning', 'error'] as const;
|
||||||
|
|
||||||
|
const openAllMessages = () => {
|
||||||
|
messageTypes.forEach((type, index) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
message[type](`${index + 1}. 消息内容`, {
|
||||||
|
duration: 3000,
|
||||||
|
closable: true,
|
||||||
|
});
|
||||||
|
}, index * 500);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDialog = (type: (typeof dialogTypes)[number]) => {
|
||||||
|
dialog[type]({
|
||||||
|
title: `${type.charAt(0).toUpperCase() + type.slice(1)} 弹窗`,
|
||||||
|
content: '这是一个命令式 API 创建的弹窗示例。',
|
||||||
|
positiveText: '确定',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: () => {
|
||||||
|
message.success('点击了确定');
|
||||||
|
},
|
||||||
|
onNegativeClick: () => {
|
||||||
|
message.error('点击了取消');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openModal = () => {
|
||||||
|
window.$nModal!.create({
|
||||||
|
title: '命令式 Modal 示例',
|
||||||
|
content: '这是一个命令式 API 创建的 Modal 示例,使用 preset="dialog"。',
|
||||||
|
preset: 'dialog',
|
||||||
|
onPositiveClick: () => {
|
||||||
|
message.success('点击了确定');
|
||||||
|
},
|
||||||
|
onNegativeClick: () => {
|
||||||
|
message.error('点击了取消');
|
||||||
|
},
|
||||||
|
positiveText: '确定',
|
||||||
|
negativeText: '取消',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="naive-ui-demo-page">
|
||||||
|
<NCard>
|
||||||
|
<template #header>Naive UI 组件演示</template>
|
||||||
|
<NAlert title="信息" type="info" :bordered="false">
|
||||||
|
演示 Naive UI 各种组件的使用方法和功能特性
|
||||||
|
</NAlert>
|
||||||
|
<NCard title="Message 消息" class="mt-4">
|
||||||
|
<NSpace>
|
||||||
|
<NButton
|
||||||
|
v-for="(type, index) in messageTypes"
|
||||||
|
:key="type"
|
||||||
|
@click="
|
||||||
|
message[type](`${index + 1}. 消息内容`, {
|
||||||
|
duration: 3000,
|
||||||
|
closable: true,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ `${index + 1}. ${type}` }}
|
||||||
|
</NButton>
|
||||||
|
<NButton @click="openAllMessages"> 一键打开所有 </NButton>
|
||||||
|
</NSpace>
|
||||||
|
</NCard>
|
||||||
|
|
||||||
|
<NCard title="Dialog 弹窗 (命令式 API)" class="mt-4">
|
||||||
|
<NSpace>
|
||||||
|
<NButton v-for="type in dialogTypes" :key="type" @click="openDialog(type)">
|
||||||
|
{{ type }}
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</NCard>
|
||||||
|
|
||||||
|
<NCard title="Modal 弹窗 (命令式 API)" class="mt-4">
|
||||||
|
<NSpace>
|
||||||
|
<NButton @click="openModal"> 打开 Modal </NButton>
|
||||||
|
</NSpace>
|
||||||
|
</NCard>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ToastMessageOptions } from 'primevue/toast';
|
||||||
|
|
||||||
|
definePage({
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tostSeverities = [
|
||||||
|
'secondary',
|
||||||
|
'success',
|
||||||
|
'info' /* 默认 */,
|
||||||
|
'warn',
|
||||||
|
'error',
|
||||||
|
'contrast',
|
||||||
|
undefined,
|
||||||
|
] satisfies ToastMessageOptions['severity'][];
|
||||||
|
|
||||||
|
const openAllToasts = () => {
|
||||||
|
tostSeverities.forEach((severity, index) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
ToastService.add({
|
||||||
|
severity,
|
||||||
|
summary: `severity: ${severity ?? 'default'}`,
|
||||||
|
life: 3000,
|
||||||
|
detail: `${index + 1}. 消息内容`,
|
||||||
|
});
|
||||||
|
}, index * 500);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="prime-vue-demo-page">
|
||||||
|
<Card>
|
||||||
|
<template #title>PrimeVue 组件演示</template>
|
||||||
|
<template #content>
|
||||||
|
<Message severity="info">演示 PrimeVue 各种组件的使用方法和功能特性</Message>
|
||||||
|
|
||||||
|
<Panel header="Toast 消息" class="mt-1.5">
|
||||||
|
<div flex="~ wrap" gap="4">
|
||||||
|
<Button
|
||||||
|
v-for="(severity, index) in tostSeverities"
|
||||||
|
:key="severity ?? 'default'"
|
||||||
|
@click="
|
||||||
|
ToastService.add({
|
||||||
|
severity: severity,
|
||||||
|
summary: `severity: ${severity ?? 'default'}`,
|
||||||
|
life: 3000,
|
||||||
|
detail: '消息内容',
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ `${index + 1}. ${severity ?? 'default'}` }}
|
||||||
|
</Button>
|
||||||
|
<Button @click="openAllToasts"> 一键打开所有 </Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onUnmounted, computed, nextTick } from 'vue';
|
||||||
|
|
||||||
|
definePage({
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== WebSocket 模块 ==========
|
||||||
|
const ws = ref<WebSocket | null>(null);
|
||||||
|
const wsConnected = ref(false);
|
||||||
|
const wsMessages = ref<string[]>([]);
|
||||||
|
const messageInput = ref('');
|
||||||
|
const wsLoading = ref(false);
|
||||||
|
const connectionAttempts = ref(0);
|
||||||
|
const maxReconnectAttempts = 3;
|
||||||
|
const messagesContainer = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const scrollToBottom = async () => {
|
||||||
|
await nextTick();
|
||||||
|
if (messagesContainer.value) {
|
||||||
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectWebSocket = async () => {
|
||||||
|
if (ws.value?.readyState === WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
wsLoading.value = true;
|
||||||
|
connectionAttempts.value++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||||
|
ws.value = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.value.onopen = (event) => {
|
||||||
|
console.log('[onopen] event :>> ', event);
|
||||||
|
wsConnected.value = true;
|
||||||
|
wsLoading.value = false;
|
||||||
|
connectionAttempts.value = 0;
|
||||||
|
wsMessages.value.push(`✅ WebSocket连接已建立 (${new Date().toLocaleTimeString()})`);
|
||||||
|
scrollToBottom();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.value.onmessage = (event) => {
|
||||||
|
console.log('[onmessage] event :>> ', event);
|
||||||
|
wsMessages.value.push(`📨 收到: ${event.data}`);
|
||||||
|
scrollToBottom();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.value.onclose = (event) => {
|
||||||
|
console.log('[onclose] event :>> ', event);
|
||||||
|
wsConnected.value = false;
|
||||||
|
wsLoading.value = false;
|
||||||
|
const reason = event.reason || '连接意外断开';
|
||||||
|
wsMessages.value.push(
|
||||||
|
`❌ WebSocket连接已关闭: ${reason} (${new Date().toLocaleTimeString()})`,
|
||||||
|
);
|
||||||
|
scrollToBottom();
|
||||||
|
|
||||||
|
if (connectionAttempts.value < maxReconnectAttempts && !event.wasClean) {
|
||||||
|
setTimeout(() => {
|
||||||
|
wsMessages.value.push(
|
||||||
|
`🔄 尝试重新连接 (${connectionAttempts.value}/${maxReconnectAttempts})...`,
|
||||||
|
);
|
||||||
|
scrollToBottom();
|
||||||
|
connectWebSocket();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.value.onerror = (error) => {
|
||||||
|
console.error('[onerror] error :>> ', error);
|
||||||
|
wsLoading.value = false;
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
wsMessages.value.push(
|
||||||
|
`⚠️ WebSocket连接错误: ${errorMessage} (${new Date().toLocaleTimeString()})`,
|
||||||
|
);
|
||||||
|
scrollToBottom();
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
wsLoading.value = false;
|
||||||
|
wsMessages.value.push(`❌ 连接失败 (${new Date().toLocaleTimeString()})`);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnectWebSocket = () => {
|
||||||
|
if (ws.value) {
|
||||||
|
ws.value.close(4000, '用户主动断开连接');
|
||||||
|
ws.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendMessage = () => {
|
||||||
|
if (ws.value?.readyState === WebSocket.OPEN && messageInput.value.trim()) {
|
||||||
|
const message = messageInput.value.trim();
|
||||||
|
ws.value.send(message);
|
||||||
|
wsMessages.value.push(`🚀 发送: ${message} (${new Date().toLocaleTimeString()})`);
|
||||||
|
messageInput.value = '';
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendMockData = () => {
|
||||||
|
if (ws.value?.readyState === WebSocket.OPEN) {
|
||||||
|
const mockMessages = [
|
||||||
|
'你好,这是一条测试消息',
|
||||||
|
'WebSocket 连接正常',
|
||||||
|
'实时通信功能演示',
|
||||||
|
'模拟数据发送成功',
|
||||||
|
'Hello World!',
|
||||||
|
'这是一条中文消息',
|
||||||
|
'实时数据传输测试',
|
||||||
|
'WebSocket 功能验证',
|
||||||
|
];
|
||||||
|
|
||||||
|
const randomIndex = Math.floor(Math.random() * mockMessages.length);
|
||||||
|
const randomMessage = mockMessages[randomIndex]!;
|
||||||
|
ws.value.send(randomMessage);
|
||||||
|
wsMessages.value.push(`🚀 发送: ${randomMessage} (${new Date().toLocaleTimeString()})`);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearMessages = async () => {
|
||||||
|
wsMessages.value = [];
|
||||||
|
await scrollToBottom();
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportMessages = () => {
|
||||||
|
const dataStr = JSON.stringify(wsMessages.value, null, 2);
|
||||||
|
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(dataBlob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `websocket-messages-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 计算属性 ==========
|
||||||
|
const canSendMessage = computed(() => wsConnected.value && messageInput.value.trim());
|
||||||
|
const connectionStatusText = computed(() => {
|
||||||
|
if (wsLoading.value) return '连接中...';
|
||||||
|
if (wsConnected.value) return '已连接';
|
||||||
|
return '未连接';
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 生命周期钩子 ==========
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (ws.value) {
|
||||||
|
ws.value.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="transition-all duration-500 p-2 sm:p-3 bg-gray-50 dark:bg-gray-900 via-blue-50 dark:via-slate-800 to-slate-100 dark:to-gray-900 bg-gradient-to-br"
|
||||||
|
>
|
||||||
|
<div class="max-w-5xl mx-auto">
|
||||||
|
<!-- WebSocket 示例 -->
|
||||||
|
<div
|
||||||
|
class="backdrop-blur-sm rounded-2xl shadow-lg border p-4 sm:p-5 hover:shadow-2xl hover:scale-[1.02] transition-all duration-500 cursor-pointer group bg-white dark:bg-gray-800 border-white dark:border-gray-700 hover:bg-white dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 bg-gradient-to-r from-green-500 to-teal-600 rounded-lg flex items-center justify-center mr-2"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 18.5V6M9 9l3-3 3 3m-3 9l3 3-3 3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg font-bold text-gray-800 dark:text-gray-100">WebSocket 示例</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 连接状态和控制按钮 -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<!-- 连接状态显示 -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between mb-3 p-2.5 rounded-lg bg-gray-50 dark:bg-gray-700"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label="WebSocket连接状态"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="relative">
|
||||||
|
<div
|
||||||
|
class="w-4 h-4 rounded-full transition-all duration-500 shadow-lg"
|
||||||
|
:class="
|
||||||
|
wsConnected
|
||||||
|
? 'bg-gradient-to-br from-green-400 to-green-600 animate-pulse'
|
||||||
|
: 'bg-gradient-to-br from-red-400 to-red-600'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="wsLoading"
|
||||||
|
class="absolute inset-0 w-4 h-4 rounded-full bg-gradient-to-br from-yellow-400 to-orange-500 animate-ping"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="wsConnected"
|
||||||
|
class="absolute inset-0 w-4 h-4 rounded-full bg-green-400 animate-ping opacity-20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-800 dark:text-gray-100">
|
||||||
|
{{ connectionStatusText }}
|
||||||
|
</div>
|
||||||
|
<div v-if="connectionAttempts > 0" class="text-xs text-gray-500 dark:text-gray-300">
|
||||||
|
重连次数: {{ connectionAttempts }}/{{ maxReconnectAttempts }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-300">
|
||||||
|
{{ wsConnected ? '🟢 实时通信' : wsLoading ? '🟡 连接中' : '🔴 未连接' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 控制按钮 -->
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
:disabled="wsConnected || wsLoading"
|
||||||
|
:aria-label="
|
||||||
|
wsLoading ? '正在连接WebSocket' : wsConnected ? 'WebSocket已连接' : '连接WebSocket'
|
||||||
|
"
|
||||||
|
class="flex items-center justify-center bg-gradient-to-br from-green-500 via-green-600 to-emerald-600 text-white font-semibold py-2.5 px-4 rounded-xl hover:from-green-600 hover:via-green-700 hover:to-emerald-700 transition-all duration-500 disabled:opacity-50 shadow-lg hover:shadow-2xl transform hover:-translate-y-1 hover:scale-[1.02] text-sm"
|
||||||
|
@click="connectWebSocket"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
v-if="wsLoading"
|
||||||
|
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ wsLoading ? '连接中...' : '连接' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
:disabled="!wsConnected || wsLoading"
|
||||||
|
:aria-label="!wsConnected ? 'WebSocket未连接' : '断开WebSocket连接'"
|
||||||
|
class="flex items-center justify-center bg-gradient-to-br from-red-500 via-red-600 to-pink-600 text-white font-semibold py-2.5 px-4 rounded-xl hover:from-red-600 hover:via-red-700 hover:to-pink-700 transition-all duration-500 disabled:opacity-50 shadow-lg hover:shadow-2xl transform hover:-translate-y-1 hover:scale-[1.02] text-sm"
|
||||||
|
@click="disconnectWebSocket"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
断开
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 发送消息 -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<label class="sr-only" for="messageInput">要发送的消息</label>
|
||||||
|
<input
|
||||||
|
id="messageInput"
|
||||||
|
v-model="messageInput"
|
||||||
|
placeholder="输入要发送的消息..."
|
||||||
|
:disabled="!wsConnected"
|
||||||
|
:aria-describedby="!wsConnected ? 'ws-status' : undefined"
|
||||||
|
class="flex-1 w-full border-2 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:text-gray-500 transition-all duration-300 border-gray-200 dark:border-gray-600 bg-white/60 dark:bg-gray-700/60 backdrop-blur-sm text-gray-800 dark:text-gray-100 disabled:bg-gray-100/60 dark:disabled:bg-gray-800/60 hover:border-gray-300 dark:hover:border-gray-500"
|
||||||
|
@keyup.enter="sendMessage"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
:disabled="!canSendMessage"
|
||||||
|
:aria-label="
|
||||||
|
!wsConnected ? 'WebSocket未连接' : canSendMessage ? '发送消息' : '请输入消息内容'
|
||||||
|
"
|
||||||
|
class="flex items-center bg-gradient-to-br from-blue-500 via-blue-600 to-indigo-600 text-white font-semibold py-2.5 px-4 rounded-xl hover:from-blue-600 hover:via-blue-700 hover:to-indigo-700 transition-all duration-500 disabled:opacity-50 shadow-lg hover:shadow-2xl transform hover:-translate-y-1 hover:scale-[1.02] text-sm"
|
||||||
|
@click="sendMessage"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
发送
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:disabled="!wsConnected"
|
||||||
|
:aria-label="!wsConnected ? 'WebSocket未连接' : '发送随机模拟数据'"
|
||||||
|
class="flex items-center bg-gradient-to-br from-purple-500 via-purple-600 to-violet-600 text-white font-semibold py-2.5 px-4 rounded-xl hover:from-purple-600 hover:via-purple-700 hover:to-violet-700 transition-all duration-500 disabled:opacity-50 shadow-lg hover:shadow-2xl transform hover:-translate-y-1 hover:scale-[1.02] text-sm"
|
||||||
|
:title="!wsConnected ? 'WebSocket未连接时不可用' : '发送随机模拟数据'"
|
||||||
|
@click="sendMockData"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
模拟
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 消息记录 -->
|
||||||
|
<div
|
||||||
|
class="rounded-lg p-3 border bg-gray-50 dark:bg-gray-800 dark:from-gray-800 dark:to-gray-700 border-gray-200 dark:border-gray-600 bg-gradient-to-r from-gray-50 dark:from-gray-800 to-gray-100 dark:to-gray-700"
|
||||||
|
role="log"
|
||||||
|
aria-label="WebSocket消息记录"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between items-center mb-2">
|
||||||
|
<h3 class="text-gray-700 dark:text-gray-200 font-semibold flex items-center text-sm">
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 mr-2 text-blue-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
消息记录:
|
||||||
|
</h3>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button
|
||||||
|
class="text-xs text-gray-500 hover:text-blue-500 transition-colors duration-200 flex items-center"
|
||||||
|
title="导出消息"
|
||||||
|
@click="exportMessages"
|
||||||
|
>
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 10v6m0 0l4-4m-4 4l-4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
导出
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="text-xs text-gray-500 hover:text-red-500 transition-colors duration-200 flex items-center"
|
||||||
|
title="清空消息"
|
||||||
|
@click="clearMessages"
|
||||||
|
>
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref="messagesContainer"
|
||||||
|
class="max-h-48 overflow-y-auto rounded-lg border p-2 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="wsMessages.length === 0"
|
||||||
|
class="text-gray-500 dark:text-gray-400 text-sm text-center py-6"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 mx-auto mb-1 text-gray-300"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
暂无消息
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(message, index) in wsMessages"
|
||||||
|
:key="index"
|
||||||
|
class="text-sm p-2 rounded-lg transition-all duration-300 hover:shadow-lg hover:scale-[1.02] animate-fade-in"
|
||||||
|
:class="
|
||||||
|
message.includes('发送:')
|
||||||
|
? 'bg-gradient-to-r from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/30 text-blue-800 dark:text-blue-200 border-l-4 border-blue-400 dark:border-blue-500'
|
||||||
|
: message.includes('收到:')
|
||||||
|
? 'bg-gradient-to-r from-green-50 to-green-100 dark:from-green-900/30 dark:to-green-800/30 text-green-800 dark:text-green-200 border-l-4 border-green-400 dark:border-green-500'
|
||||||
|
: message.includes('连接已建立')
|
||||||
|
? 'bg-gradient-to-r from-emerald-50 to-emerald-100 dark:from-emerald-900/30 dark:to-emerald-800/30 text-emerald-800 dark:text-emerald-200 border-l-4 border-emerald-400 dark:border-emerald-500'
|
||||||
|
: message.includes('连接已关闭') || message.includes('连接失败')
|
||||||
|
? 'bg-gradient-to-r from-red-50 to-red-100 dark:from-red-900/30 dark:to-red-800/30 text-red-800 dark:text-red-200 border-l-4 border-red-400 dark:border-red-500'
|
||||||
|
: message.includes('尝试重新连接')
|
||||||
|
? 'bg-gradient-to-r from-yellow-50 to-yellow-100 dark:from-yellow-900/30 dark:to-yellow-800/30 text-yellow-800 dark:text-yellow-200 border-l-4 border-yellow-400 dark:border-yellow-500'
|
||||||
|
: 'bg-gray-50 dark:bg-gray-700 text-gray-700 dark:text-gray-200 border-l-4 border-gray-400 dark:border-gray-500'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts"></script>
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div></div>
|
<div>
|
||||||
|
<NButton @click="$router.push({ name: 'DemosCreate' })">DemosCreate</NButton>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export function install({ app }: { app: import('vue').App<Element> }) {
|
||||||
|
app.config.globalProperties.__DEV__ =
|
||||||
|
__DEV__; /* vite.config.ts: define: { __DEV__: JSON.stringify(!isBuild) } */
|
||||||
|
|
||||||
|
app.config.errorHandler = (error, instance, info) => {
|
||||||
|
console.error('Global error:', error);
|
||||||
|
console.error('Component:', instance);
|
||||||
|
console.error('Error Info:', info);
|
||||||
|
// 这里你可以:
|
||||||
|
// 1. 发送错误到日志服务
|
||||||
|
// 2. 显示全局错误提示
|
||||||
|
// 3. 进行错误分析和处理
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { PiniaColada } from '@pinia/colada';
|
||||||
|
// import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
|
||||||
|
export function install({ app }: { app: import('vue').App<Element> }) {
|
||||||
|
app.use(createPinia() /* .use(piniaPluginPersistedstate) */);
|
||||||
|
app.use(PiniaColada, {});
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { DataLoaderPlugin } from 'unplugin-vue-router/data-loaders';
|
||||||
|
import { setupLayouts } from 'virtual:meta-layouts';
|
||||||
|
// import { createGetRoutes, setupLayouts } from 'virtual:generated-layouts';
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import type { Router } from 'vue-router';
|
||||||
|
import { handleHotUpdate, routes } from 'vue-router/auto-routes';
|
||||||
|
|
||||||
|
const setupLayoutsResult = setupLayouts(routes);
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: /* routes ?? */ setupLayoutsResult,
|
||||||
|
scrollBehavior: (_to, _from, savedPosition) => {
|
||||||
|
return savedPosition ?? { left: 0, top: 0 };
|
||||||
|
},
|
||||||
|
strict: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.isReady().then(() => {
|
||||||
|
console.debug('✅ [router is ready]');
|
||||||
|
});
|
||||||
|
|
||||||
|
router.onError((error) => {
|
||||||
|
console.debug('🚨 [router error]:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function install({ app }: { app: import('vue').App<Element> }) {
|
||||||
|
app
|
||||||
|
// 在路由之前注册插件
|
||||||
|
.use(DataLoaderPlugin, { router })
|
||||||
|
// 添加路由会触发初始导航
|
||||||
|
.use(router);
|
||||||
|
}
|
||||||
|
// ========================================================================
|
||||||
|
// =========================== Router Guards ==============================
|
||||||
|
// ========================================================================
|
||||||
|
{
|
||||||
|
// 警告:路由守卫的创建顺序会影响执行流程,请勿调整
|
||||||
|
createNProgressGuard(router);
|
||||||
|
if (import.meta.env.VITE_APP_ENABLE_ROUTER_LOG_GUARD === 'true') createLogGuard(router);
|
||||||
|
Object.assign(window, { stack: createStackGuard(router) });
|
||||||
|
|
||||||
|
// >>>
|
||||||
|
Object.values(
|
||||||
|
import.meta.glob<{
|
||||||
|
createGuard?: (router: Router) => void;
|
||||||
|
}>('./router-guard/*.ts', { eager: true /* true 为同步,false 为异步 */ }),
|
||||||
|
).forEach((module) => {
|
||||||
|
module.createGuard?.(router);
|
||||||
|
});
|
||||||
|
// <<<
|
||||||
|
}
|
||||||
|
|
||||||
|
if (__DEV__) Object.assign(window, { router });
|
||||||
|
|
||||||
|
// This will update routes at runtime without reloading the page
|
||||||
|
if (import.meta.hot) {
|
||||||
|
handleHotUpdate(router);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { router, setupLayoutsResult };
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { RouteNamedMap } from 'vue-router/auto-routes';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
type PageTitleLocalizations = Record<keyof RouteNamedMap, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'vue-router' {
|
||||||
|
/* definePage({ meta: { title: '示例演示' } }); */
|
||||||
|
interface RouteMeta {
|
||||||
|
/**
|
||||||
|
* @description 是否在菜单中隐藏
|
||||||
|
*/
|
||||||
|
hideInMenu?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 菜单标题 // !⚠️通过多语言标题方案(搜`PageTitleLocalizations`)维护标题
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 使用的布局,设置为 false 则表示不使用布局
|
||||||
|
*/
|
||||||
|
layout?: string | false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 菜单项是否渲染为可点击链接,默认为 true
|
||||||
|
* - true: 使用 RouterLink 包装,可点击跳转
|
||||||
|
* - false: 仅渲染纯文本标签,不可点击(适用于分组标题)
|
||||||
|
*/
|
||||||
|
link?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 菜单排序权重,数值越小越靠前,未设置则按路径字母顺序排序
|
||||||
|
*/
|
||||||
|
order?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 是否忽略权限,默认为 false
|
||||||
|
*/
|
||||||
|
ignoreAuth?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 当前路由激活时应该高亮的菜单项(通过路由名称指定)
|
||||||
|
* - 用于隐藏在菜单中的子页面,指定其父级菜单项应该高亮
|
||||||
|
* - 使用路由名称而非路径,提供更好的类型安全和重构友好性
|
||||||
|
* - 例如:`activeMenuName: 'Demos'` 会高亮 Demos 菜单项
|
||||||
|
*/
|
||||||
|
activeMenuName?: keyof RouteNamedMap;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function install({ app }: { app: import('vue').App<Element> }) {
|
||||||
|
app.use(i18nInstance);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { autoAnimatePlugin } from '@formkit/auto-animate/vue';
|
||||||
|
|
||||||
|
export function install({ app }: { app: import('vue').App<Element> }) {
|
||||||
|
app.use(autoAnimatePlugin); // v-auto-animate="{ duration: 100 }"
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 需要把 <DynamicDialog /> <ConfirmDialog /> <Toast /> 放在 App.vue 的 template 中
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Aura from '@primeuix/themes/aura';
|
||||||
|
import zhCN from 'primelocale/zh-CN.json';
|
||||||
|
import PrimeVue from 'primevue/config';
|
||||||
|
import type { PrimeVueConfiguration } from 'primevue/config';
|
||||||
|
import StyleClass from 'primevue/styleclass';
|
||||||
|
import ToastService from 'primevue/toastservice';
|
||||||
|
|
||||||
|
export function install({ app }: { app: import('vue').App<Element> }) {
|
||||||
|
app.directive('styleclass', StyleClass);
|
||||||
|
|
||||||
|
// https://github.com/primefaces/primevue/blob/afe6f58ae55e9caf7f9bc094cd453a21a6113001/packages/core/src/config/PrimeVue.js
|
||||||
|
app.use(PrimeVue, {
|
||||||
|
zIndex: {
|
||||||
|
modal: 5100,
|
||||||
|
overlay: 5000,
|
||||||
|
menu: 5000,
|
||||||
|
tooltip: 5100,
|
||||||
|
},
|
||||||
|
locale: {
|
||||||
|
...zhCN['zh-CN'],
|
||||||
|
completed: '已上传',
|
||||||
|
noFileChosenMessage: '未选择文件',
|
||||||
|
pending: '待上传',
|
||||||
|
}, // usePrimeVue().config.locale
|
||||||
|
theme: {
|
||||||
|
options: {
|
||||||
|
cssLayer: false,
|
||||||
|
darkModeSelector: '.app-dark' /* 'system' */,
|
||||||
|
prefix: 'p',
|
||||||
|
},
|
||||||
|
preset: Aura,
|
||||||
|
},
|
||||||
|
} satisfies PrimeVueConfiguration);
|
||||||
|
app.use(ToastService);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createHead } from '@unhead/vue/client';
|
||||||
|
|
||||||
|
export function install({ app }: { app: import('vue').App<Element> }) {
|
||||||
|
app.use(createHead());
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { autoAnimatePlugin } from '@formkit/auto-animate/vue'
|
|
||||||
import { createHead } from '@unhead/vue/client'
|
|
||||||
|
|
||||||
export function install({ app }: { app: import('vue').App<Element> }) {
|
|
||||||
app.config.globalProperties.__DEV__ = __DEV__
|
|
||||||
|
|
||||||
app.use(autoAnimatePlugin) // v-auto-animate="{ duration: 100 }"
|
|
||||||
|
|
||||||
app.use(createHead())
|
|
||||||
app.config.errorHandler = (error, instance, info) => {
|
|
||||||
console.error('Global error:', error)
|
|
||||||
console.error('Component:', instance)
|
|
||||||
console.error('Error Info:', info)
|
|
||||||
// 这里你可以:
|
|
||||||
// 1. 发送错误到日志服务
|
|
||||||
// 2. 显示全局错误提示
|
|
||||||
// 3. 进行错误分析和处理
|
|
||||||
}
|
|
||||||
|
|
||||||
// if (import.meta.env.MODE === 'development' && '1' === ('2' as never)) {
|
|
||||||
// // TODO: https://github.com/hu3dao/vite-plugin-debug/
|
|
||||||
// // https://eruda.liriliri.io/zh/docs/#快速上手
|
|
||||||
// import('eruda').then(({ default: eruda }) => {
|
|
||||||
// eruda.init({
|
|
||||||
// defaults: {
|
|
||||||
// transparency: 0.9,
|
|
||||||
// },
|
|
||||||
// })
|
|
||||||
// /* eruda.show(); */
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
+21
-16
@@ -1,24 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* https://github.com/antfu-collective/vitesse/blob/47618e72dfba76c77b9b85b94784d739e35c492b/src/modules/README.md
|
* https://github.com/antfu-collective/vitesse/blob/47618e72dfba76c77b9b85b94784d739e35c492b/src/modules/README.md
|
||||||
*/
|
*/
|
||||||
type UserPlugin = (ctx: UserPluginContext) => void
|
type UserPlugin = (ctx: UserPluginContext) => void;
|
||||||
type AutoInstallModule = { [K: string]: unknown; install?: UserPlugin }
|
type AutoInstallModule = { [K: string]: unknown; install?: UserPlugin };
|
||||||
type UserPluginContext = { app: import('vue').App<Element> }
|
type UserPluginContext = { app: import('vue').App<Element> };
|
||||||
export function setupPlugins(
|
|
||||||
app: import('vue').App,
|
const autoInstallModules: AutoInstallModule = import.meta.glob(
|
||||||
modules: AutoInstallModule | Record<string, unknown>,
|
['./*.ts', '!./**/*.types.ts', '!./index.ts'],
|
||||||
) {
|
{
|
||||||
console.group('🔌 Plugins')
|
eager: true /* true 为同步,false 为异步 */,
|
||||||
for (const path in modules) {
|
},
|
||||||
const module = modules[path] as AutoInstallModule
|
);
|
||||||
|
|
||||||
|
export function setupPlugins(app: import('vue').App) {
|
||||||
|
console.group(`🔌 Installing ${Object.keys(autoInstallModules).length} plugins`);
|
||||||
|
for (const path in autoInstallModules) {
|
||||||
|
const module = autoInstallModules[path] as AutoInstallModule;
|
||||||
if (module.install) {
|
if (module.install) {
|
||||||
module.install({ app })
|
module.install({ app });
|
||||||
console.debug(`%c✔ ${path}`, 'color: #07a')
|
console.debug(`%c✔ ${path}`, 'color: #07a');
|
||||||
} else {
|
} else {
|
||||||
if (typeof module.setupPlugins === 'function') continue
|
if (typeof module.setupPlugins === 'function') continue;
|
||||||
console.warn(`%c✘ ${path} has no install function`, 'color: #f50')
|
console.warn(`%c✘ ${path} has no install function`, 'color: #f50');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.groupEnd()
|
console.groupEnd();
|
||||||
return app
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { PiniaColada } from '@pinia/colada'
|
|
||||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
|
||||||
export function install({ app }: { app: import('vue').App<Element> }) {
|
|
||||||
app.use(createPinia().use(piniaPluginPersistedstate))
|
|
||||||
app.use(PiniaColada, {})
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* 需要把 <DynamicDialog /> <ConfirmDialog /> <Toast /> 放在 App.vue 的 template 中
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Aura from '@primeuix/themes/aura'
|
|
||||||
import zhCN from 'primelocale/zh-CN.json'
|
|
||||||
import PrimeVue from 'primevue/config'
|
|
||||||
import StyleClass from 'primevue/styleclass'
|
|
||||||
|
|
||||||
export function install({ app }: { app: import('vue').App<Element> }) {
|
|
||||||
app.directive('styleclass', StyleClass)
|
|
||||||
app.use(PrimeVue, {
|
|
||||||
locale: {
|
|
||||||
...zhCN['zh-CN'],
|
|
||||||
completed: '已上传',
|
|
||||||
noFileChosenMessage: '未选择文件',
|
|
||||||
pending: '待上传',
|
|
||||||
}, // usePrimeVue().config.locale
|
|
||||||
theme: {
|
|
||||||
options: {
|
|
||||||
cssLayer: false,
|
|
||||||
darkModeSelector: '.app-dark' /* 'system' */,
|
|
||||||
prefix: 'p',
|
|
||||||
},
|
|
||||||
preset: Aura,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Router } from 'vue-router';
|
||||||
|
|
||||||
|
export function createGuard(router: Router) {
|
||||||
|
router.beforeEach(async (to /* , from */) => {
|
||||||
|
const userStore = useAuthStore();
|
||||||
|
|
||||||
|
if (to.name === 'Login') {
|
||||||
|
userStore.clearToken('User navigated to login page');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta.ignoreAuth) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userStore.isLoggedIn) {
|
||||||
|
console.debug('🔑 [permission-guard] 用户未登录,重定向到登录页');
|
||||||
|
return { name: 'Login' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.beforeResolve(async (/* to, from */) => {
|
||||||
|
const userStore = useAuthStore();
|
||||||
|
if (userStore.isLoggedIn && !userStore.userInfo) {
|
||||||
|
console.debug('🔑 [permission-guard] 用户信息不存在,尝试获取用户信息');
|
||||||
|
await userStore.fetchUserInfo();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { DataLoaderPlugin } from 'unplugin-vue-router/data-loaders'
|
|
||||||
import { setupLayouts } from 'virtual:meta-layouts'
|
|
||||||
// import { createGetRoutes, setupLayouts } from 'virtual:generated-layouts';
|
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
|
||||||
import { handleHotUpdate, routes } from 'vue-router/auto-routes'
|
|
||||||
|
|
||||||
const setupLayoutsResult = setupLayouts(routes)
|
|
||||||
const router = createRouter({
|
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
|
||||||
routes: /* routes ?? */ setupLayoutsResult,
|
|
||||||
scrollBehavior: (_to, _from, savedPosition) => {
|
|
||||||
return savedPosition ?? { left: 0, top: 0 }
|
|
||||||
},
|
|
||||||
strict: true,
|
|
||||||
})
|
|
||||||
if (import.meta.hot) handleHotUpdate(router)
|
|
||||||
if (__DEV__) Object.assign(globalThis, { router })
|
|
||||||
router.onError((error) => {
|
|
||||||
console.debug('🚨 [router error]:', error)
|
|
||||||
})
|
|
||||||
|
|
||||||
export { router, setupLayoutsResult }
|
|
||||||
export function install({ app }: { app: import('vue').App<Element> }) {
|
|
||||||
app
|
|
||||||
// 在路由之前注册插件
|
|
||||||
.use(DataLoaderPlugin, { router })
|
|
||||||
// 添加路由会触发初始导航
|
|
||||||
.use(router)
|
|
||||||
}
|
|
||||||
// ========================================================================
|
|
||||||
// =========================== Router Guards ==============================
|
|
||||||
// ========================================================================
|
|
||||||
{
|
|
||||||
// 警告:路由守卫的创建顺序会影响执行流程,请勿调整
|
|
||||||
createNProgressGuard(router)
|
|
||||||
createLogGuard(router)
|
|
||||||
Object.assign(globalThis, { stack: createStackGuard(router) })
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
definePage({
|
|
||||||
meta: { },
|
|
||||||
});
|
|
||||||
*/
|
|
||||||
declare module 'vue-router' {
|
|
||||||
interface RouteMeta {
|
|
||||||
/**
|
|
||||||
* @description 是否在菜单中隐藏
|
|
||||||
*/
|
|
||||||
hidden?: boolean
|
|
||||||
/**
|
|
||||||
* @description 菜单标题
|
|
||||||
*/
|
|
||||||
title?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { createGetRoutes } from 'virtual:meta-layouts'
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
/* https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n#static-bundle-importing
|
|
||||||
* All i18n resources specified in the plugin `include` option can be loaded
|
|
||||||
* at once using the import syntax
|
|
||||||
*/
|
|
||||||
import messages from '@intlify/unplugin-vue-i18n/messages'
|
|
||||||
import { createI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
export function install({ app }: { app: import('vue').App<Element> }) {
|
|
||||||
app.use(
|
|
||||||
// https://vue-i18n.intlify.dev/guide/essentials/started.html#registering-the-i18n-plugin
|
|
||||||
createI18n({
|
|
||||||
legacy: false, // you must set `false`, to use Composition API
|
|
||||||
locale: navigator.language,
|
|
||||||
messages,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { BasicColorSchema } from '@vueuse/core';
|
||||||
|
import { useLocalStorage, useMediaQuery } from '@vueuse/core';
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
// >>>>>
|
||||||
|
// https://vueuse.org/core/useColorMode/#advanced-usage
|
||||||
|
const { system, store: themeMode } = useColorMode<BasicColorSchema>({
|
||||||
|
selector: 'html',
|
||||||
|
attribute: 'class',
|
||||||
|
modes: { light: '', dark: 'app-dark', auto: '' },
|
||||||
|
disableTransition: false,
|
||||||
|
initialValue: 'auto',
|
||||||
|
});
|
||||||
|
const { state, next: cycleTheme } = useCycleList(['light', 'dark', 'auto'] as const, {
|
||||||
|
initialValue: themeMode,
|
||||||
|
});
|
||||||
|
watchEffect(() => (themeMode.value = state.value));
|
||||||
|
export type AppThemeMode = typeof themeMode.value;
|
||||||
|
// <<<<<
|
||||||
|
|
||||||
|
export const useAppStore = defineStore('app', () => {
|
||||||
|
// 侧边栏展开/收起状态
|
||||||
|
const sidebarCollapsed = useLocalStorage<boolean>('app-sidebar-collapsed', false);
|
||||||
|
const toggleSidebar = useToggle(sidebarCollapsed);
|
||||||
|
|
||||||
|
// 主题模式
|
||||||
|
const actualTheme = computed(() => (themeMode.value === 'auto' ? system.value : themeMode.value));
|
||||||
|
const isDark = computed(() => actualTheme.value === 'dark');
|
||||||
|
|
||||||
|
// 是否是移动端
|
||||||
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||||
|
|
||||||
|
return {
|
||||||
|
themeMode,
|
||||||
|
isDark,
|
||||||
|
isMobile,
|
||||||
|
cycleTheme,
|
||||||
|
sidebarCollapsed,
|
||||||
|
toggleSidebar,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (import.meta.hot) {
|
||||||
|
import.meta.hot.accept(acceptHMRUpdate(useAppStore, import.meta.hot));
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const token = useLocalStorage<string | null>('auth-token', null);
|
||||||
|
const userInfo = ref<Record<string, any> | null>(null);
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => !!token.value);
|
||||||
|
|
||||||
|
function clearToken(reason?: string) {
|
||||||
|
consola.info('🚮 [auth-store] clear: ', reason);
|
||||||
|
token.value = null;
|
||||||
|
userInfo.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(username: string, password: string) {
|
||||||
|
// 模拟登录延迟
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
// 模拟验证
|
||||||
|
if (username === 'admin' && password === 'admin') {
|
||||||
|
token.value = `mock-token-${Date.now()}`;
|
||||||
|
await fetchUserInfo();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, message: '用户名或密码错误' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUserInfo() {
|
||||||
|
if (!token.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟获取用户信息延迟
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
// 模拟从服务器获取用户信息
|
||||||
|
userInfo.value = {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
nickname: '管理员',
|
||||||
|
roles: ['admin'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, isLoggedIn, userInfo, clearToken, login, fetchUserInfo };
|
||||||
|
});
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { ref, computed } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
|
|
||||||
export const useCounterStore = defineStore('counter', () => {
|
|
||||||
const count = ref(0)
|
|
||||||
const doubleCount = computed(() => count.value * 2)
|
|
||||||
function increment() {
|
|
||||||
count.value++
|
|
||||||
}
|
|
||||||
|
|
||||||
return { count, doubleCount, increment }
|
|
||||||
})
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user