Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f6bdec168 | |||
| 3b76e57df5 | |||
| eb600d0b6b | |||
| 87e701042f | |||
| 306ed9a527 | |||
| 56d8a3aa49 | |||
| 394294904d | |||
| 89f2c1e2fb | |||
| 770b06aba6 | |||
| 6e4858b498 | |||
| 8cb4369276 | |||
| da1da07474 | |||
| 4851b83c37 | |||
| 88483c26e6 | |||
| 706c60ddb8 | |||
| 7c04f69d1a | |||
| ce90f46504 | |||
| c7379f6b3d | |||
| 7c92f4496e | |||
| 07e8ca247f | |||
| 8a0a98fc11 | |||
| 5f082a9bc9 |
@@ -1,10 +1,4 @@
|
||||
VITE_APP_TITLE=vue-ts-example-2025
|
||||
VITE_APP_BASE=/
|
||||
VITE_APP_BUILD_SOURCE_MAP=true
|
||||
VITE_APP_BUILD_MINIFY=true
|
||||
VITE_APP_BUILD_COMMIT=
|
||||
VITE_APP_BUILD_TIME=
|
||||
VITE_ENABLE_VUE_DEVTOOLS=true
|
||||
VITE_MENU_SHOW_DEMOS=true
|
||||
VITE_MENU_SHOW_ORDER=true
|
||||
VITE_CLOUDFLARE_SERVER_ENABLED=true
|
||||
VITE_BASE=/
|
||||
VITE_BUILD_SOURCE_MAP=true
|
||||
VITE_BUILD_COMMIT=
|
||||
VITE_BUILD_TIME=
|
||||
|
||||
@@ -12,21 +12,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
playwright:
|
||||
runs-on: ubuntu-latest
|
||||
container: mcr.microsoft.com/playwright:v1.56.1-noble
|
||||
steps:
|
||||
- name: ⚙️ 设置 Node 环境
|
||||
uses: yanhao98/composite-actions/setup-node-environment@25eb4dc0c134cc9df2b7c569aa54140a366b45a8
|
||||
# - 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
|
||||
lint-build-and-typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
container: gitea/runner-images:ubuntu-latest-slim # https://github.com/cloudflare/wrangler-action/issues/329#issuecomment-3046747722
|
||||
|
||||
@@ -40,7 +26,10 @@ jobs:
|
||||
- name: 📦 构建项目
|
||||
run: pnpm run build-only
|
||||
env:
|
||||
VITE_APP_BUILD_COMMIT: ${{ github.sha }}
|
||||
VITE_BUILD_COMMIT: ${{ github.sha }}
|
||||
|
||||
- name: 🧪 单元测试
|
||||
run: pnpm run test:unit
|
||||
|
||||
- name: 📊 计算构建大小
|
||||
run: |
|
||||
@@ -52,14 +41,36 @@ jobs:
|
||||
echo "🔹 文件总数: $(find dist -type f | wc -l) 个文件"
|
||||
echo "----------------------------------------"
|
||||
|
||||
- name: 🧪 单元测试
|
||||
run: pnpm exec vitest run
|
||||
|
||||
- name: ✅ 类型检查
|
||||
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
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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
|
||||
@@ -1,86 +0,0 @@
|
||||
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.56.1-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
|
||||
+1
-1
@@ -27,7 +27,7 @@ coverage
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
playwright-test-results/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# 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/how-to.html#node-版本管理器和-gui
|
||||
|
||||
```bash
|
||||
```shell
|
||||
ln -s $(which pnpm) $HOME/.local/bin/pnpm
|
||||
```
|
||||
|
||||
```bash
|
||||
if command -v pnpm >/dev/null 2>&1; then
|
||||
# 如果 pnpm 可用,直接使用它
|
||||
pnpm exec lint-staged
|
||||
else
|
||||
# 如果 pnpm 不可用,使用 $HOME/.local/bin/pnpm
|
||||
# ln -s $(which pnpm) $HOME/.local/bin/pnpm
|
||||
echo "找不到 pnpm,使用 $HOME/.local/bin/pnpm"
|
||||
"$HOME"/.local/bin/pnpm exec lint-staged
|
||||
fi
|
||||
```
|
||||
# if command -v pnpm >/dev/null 2>&1; then
|
||||
# # 如果 pnpm 可用,直接使用它
|
||||
# pnpm exec lint-staged
|
||||
# else
|
||||
# # 如果 pnpm 不可用,使用 $HOME/.local/bin/pnpm
|
||||
# # ln -s $(which pnpm) $HOME/.local/bin/pnpm
|
||||
# echo "找不到 pnpm,使用 $HOME/.local/bin/pnpm"
|
||||
# "$HOME"/.local/bin/pnpm exec lint-staged
|
||||
# fi
|
||||
```
|
||||
+3
-11
@@ -1,13 +1,5 @@
|
||||
# 此钩子在 pre-commit 钩子成功完成后,用于检查提交消息。
|
||||
echo "📝 [Commit-msg] 正在运行 commit-msg 钩子..."
|
||||
|
||||
echo "🟢 检查提交消息:$1"
|
||||
cat $1
|
||||
|
||||
# node -v
|
||||
echo "🟢 [Commit-msg] Node 版本:$(node -v)"
|
||||
|
||||
# pnpm exec commitlint --edit $1
|
||||
time node node_modules/@commitlint/cli/cli.js --edit $1
|
||||
|
||||
echo "📝 [Commit-msg] commit-msg 钩子完成!"
|
||||
echo "检查提交消息:$1"
|
||||
pnpm exec commitlint --edit $1
|
||||
echo "✅ [Commit-msg] commit-msg 钩子完成!"
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
# 此钩子在 git merge 或 git pull 成功完成后运行。
|
||||
echo "🔗 [Post-merge] 正在安装依赖..."
|
||||
time pnpm install
|
||||
echo "🔗 [Post-merge] 依赖安装完成!"
|
||||
pnpm install
|
||||
echo "✅ [Post-merge] 依赖安装完成!"
|
||||
|
||||
+2
-3
@@ -1,5 +1,4 @@
|
||||
# 此钩子在执行 git commit 命令时,在创建提交之前运行。
|
||||
echo "🧹 [Pre-commit] 正在运行 lint-staged..."
|
||||
time pnpm exec lint-staged
|
||||
time pnpm run lint:vue-i18n-extract
|
||||
echo "🧹 [Pre-commit] lint-staged 完成!"
|
||||
pnpm exec lint-staged
|
||||
echo "✅ [Pre-commit] lint-staged 完成!"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# registry=https://registry.npmmirror.com/
|
||||
registry=https://registry.npmmirror.com/
|
||||
|
||||
# https://pnpm.io/zh/npmrc#node-mirrorltreleasedir
|
||||
use-node-version=24.7.0
|
||||
|
||||
+2
-4
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all"
|
||||
"printWidth": 100
|
||||
}
|
||||
|
||||
Vendored
+1
-12
@@ -8,18 +8,7 @@
|
||||
"url": "http://localhost:4730/",
|
||||
"webRoot": "${workspaceFolder}",
|
||||
"firefoxExecutable": "/Applications/Firefox Nightly.app/Contents/MacOS/firefox",
|
||||
"preLaunchTask": "🚀 dev",
|
||||
"pathMappings": [
|
||||
{
|
||||
"url": "http://localhost:4730",
|
||||
"path": "${workspaceFolder}"
|
||||
}
|
||||
],
|
||||
"reAttach": true,
|
||||
"reloadOnChange": {
|
||||
"watch": "${workspaceFolder}/src/**/*.{js,jsx,ts,tsx,vue}",
|
||||
"ignore": "**/node_modules/**"
|
||||
}
|
||||
"preLaunchTask": "🚀 dev"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+2
-29
@@ -1,34 +1,7 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.fixAll.stylelint": "explicit",
|
||||
"source.fixAll.oxc": "explicit",
|
||||
"source.organizeImports": "never"
|
||||
"source.fixAll": "explicit"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
|
||||
"stylelint.enable": true,
|
||||
"stylelint.validate": ["css", "less", "postcss", "scss", "vue"],
|
||||
"scss.lint.unknownAtRules": "ignore",
|
||||
"css.lint.unknownAtRules": "ignore",
|
||||
"less.lint.unknownAtRules": "ignore",
|
||||
|
||||
"eslint.enable": true,
|
||||
"oxc.enable": true,
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
|
||||
"i18n-ally.localesPaths": ["src/locales"],
|
||||
"i18n-ally.sourceLanguage": "zh-CN", // 翻译源语言 (源文件) 根据此语言文件翻译其他语言文件的变量和内容
|
||||
"i18n-ally.displayLanguage": "zh-CN", // 显示语言 (显示文件/翻译文件)
|
||||
"i18n-ally.keystyle": "nested",
|
||||
|
||||
// https://github.com/copilot/share/8a1a019a-0180-80e7-8141-a40be02c4006
|
||||
// "iconify.customCollectionJsonPaths": ["https://example.com/my-icons.json", "./local/icons.json"],
|
||||
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.preferences.autoImportFileExcludePatterns": ["vue-router/auto$"]
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
|
||||
Vendored
+19
-35
@@ -2,48 +2,32 @@
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "dev",
|
||||
"label": "🚀 dev",
|
||||
"detail": "启动开发服务器",
|
||||
"type": "shell",
|
||||
"command": "pnpm run dev",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"pattern": { "regexp": "." },
|
||||
"owner": "vite",
|
||||
"pattern": {
|
||||
"regexp": "."
|
||||
},
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": "VITE.*ready in",
|
||||
"endsPattern": "(Local|Network):.*http"
|
||||
"beginsPattern": ".*VITE.*",
|
||||
"endsPattern": ".*ready in.*"
|
||||
}
|
||||
},
|
||||
"isBackground": true,
|
||||
"presentation": { "reveal": "always", "panel": "dedicated" },
|
||||
"group": { "kind": "build", "isDefault": false }
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "build-only",
|
||||
"label": "🔨 build-only",
|
||||
"detail": "" /* 如果为空或省略,VSCode 将自动使用 package.json 中 scripts[scriptName] 的值作为 detail */,
|
||||
"problemMatcher": ["$tsc"],
|
||||
"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": {
|
||||
"pattern": { "regexp": "." },
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": "wrangler dev",
|
||||
"endsPattern": "Ready on"
|
||||
}
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
},
|
||||
"isBackground": true,
|
||||
"presentation": { "reveal": "always", "panel": "dedicated" },
|
||||
"group": { "kind": "build", "isDefault": false }
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"runOptions": {
|
||||
"instanceLimit": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
- **vite-plugin-fake-server**: Mock API under `/fake-api` (dev only) from `fake/` directory
|
||||
@@ -1,92 +0,0 @@
|
||||
# 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` 之类的命令。
|
||||
|
||||
### 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**
|
||||
- **Form Layout**: When using `NGrid` for form layouts, prefer `NFormItemGi` over nesting `NFormItem` inside `NGridItem` for more concise code.
|
||||
|
||||
```vue
|
||||
<!-- ❌ Avoid: Verbose nesting -->
|
||||
<NGrid :cols="4">
|
||||
<NGridItem>
|
||||
<NFormItem label="Username">
|
||||
<NInput />
|
||||
</NFormItem>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
|
||||
<!-- ✅ Use: Concise and direct -->
|
||||
<NGrid :cols="4">
|
||||
<NFormItemGi label="Username">
|
||||
<NInput />
|
||||
</NFormItemGi>
|
||||
</NGrid>
|
||||
```
|
||||
|
||||
- **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
|
||||
@@ -1,73 +0,0 @@
|
||||
## 依赖管理
|
||||
|
||||
```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,7 +1,64 @@
|
||||
# vue-ts-example-2025
|
||||
|
||||
## 参考资料
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
- [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 官方中文文档中关于静态站点部署的说明。
|
||||
- [vitesse (GitHub)](https://github.com/antfu-collective/vitesse) — Anthony Fu 的 Vite + Vue 3 快速启动模板
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## 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
-11
@@ -6,7 +6,6 @@
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const APP_THEME_MODES: typeof import('./src/stores/app-store')['APP_THEME_MODES']
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const arrayToTree: typeof import('utils4u/array')['arrayToTree']
|
||||
@@ -59,7 +58,6 @@ declare global {
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const isShallow: typeof import('vue')['isShallow']
|
||||
const locales4RouteMessages: typeof import('./src/locales-4-route/_messages-auto-imports')['locales4RouteMessages']
|
||||
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
|
||||
const mapActions: typeof import('pinia')['mapActions']
|
||||
const mapGetters: typeof import('pinia')['mapGetters']
|
||||
@@ -137,7 +135,6 @@ declare global {
|
||||
const until: typeof import('@vueuse/core')['until']
|
||||
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
|
||||
const useAnimate: typeof import('@vueuse/core')['useAnimate']
|
||||
const useAppStore: typeof import('./src/stores/app-store')['useAppStore']
|
||||
const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']
|
||||
const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery']
|
||||
const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter']
|
||||
@@ -225,7 +222,6 @@ declare global {
|
||||
const useMemoize: typeof import('@vueuse/core')['useMemoize']
|
||||
const useMemory: typeof import('@vueuse/core')['useMemory']
|
||||
const useMessage: typeof import('naive-ui')['useMessage']
|
||||
const useMetaLayoutsNMenuOptions: typeof import('./src/composables/useMetaLayoutsMenuOptions')['useMetaLayoutsNMenuOptions']
|
||||
const useModal: typeof import('naive-ui')['useModal']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useMounted: typeof import('@vueuse/core')['useMounted']
|
||||
@@ -334,9 +330,6 @@ declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
// @ts-ignore
|
||||
export type { AppThemeMode } from './src/stores/app-store'
|
||||
import('./src/stores/app-store')
|
||||
}
|
||||
|
||||
// for vue template auto import
|
||||
@@ -344,7 +337,6 @@ import { UnwrapRef } from 'vue'
|
||||
declare module 'vue' {
|
||||
interface GlobalComponents {}
|
||||
interface ComponentCustomProperties {
|
||||
readonly APP_THEME_MODES: UnwrapRef<typeof import('./src/stores/app-store')['APP_THEME_MODES']>
|
||||
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
|
||||
readonly arrayToTree: UnwrapRef<typeof import('utils4u/array')['arrayToTree']>
|
||||
@@ -397,7 +389,6 @@ declare module 'vue' {
|
||||
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
|
||||
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
|
||||
readonly isShallow: UnwrapRef<typeof import('vue')['isShallow']>
|
||||
readonly locales4RouteMessages: UnwrapRef<typeof import('./src/locales-4-route/_messages-auto-imports')['locales4RouteMessages']>
|
||||
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
|
||||
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
|
||||
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
|
||||
@@ -475,7 +466,6 @@ declare module 'vue' {
|
||||
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
|
||||
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
|
||||
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
|
||||
readonly useAppStore: UnwrapRef<typeof import('./src/stores/app-store')['useAppStore']>
|
||||
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>
|
||||
readonly useArrayEvery: UnwrapRef<typeof import('@vueuse/core')['useArrayEvery']>
|
||||
readonly useArrayFilter: UnwrapRef<typeof import('@vueuse/core')['useArrayFilter']>
|
||||
@@ -563,7 +553,6 @@ declare module 'vue' {
|
||||
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
|
||||
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
|
||||
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 useModel: UnwrapRef<typeof import('vue')['useModel']>
|
||||
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
|
||||
|
||||
@@ -1,12 +1,60 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Vue App', () => {
|
||||
test('app renders correctly', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
test('visits the app root url', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.locator('h1')).toHaveText('You did it!')
|
||||
})
|
||||
|
||||
const app = page.locator('#app');
|
||||
await expect(app).toBeVisible();
|
||||
test('displays Vue documentation link', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
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')
|
||||
})
|
||||
|
||||
await page.locator('.app-loading').waitFor({ state: 'detached' });
|
||||
});
|
||||
});
|
||||
test('displays button with initial name state', async ({ page }) => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
+16
-34
@@ -1,17 +1,14 @@
|
||||
import pluginImport from 'eslint-plugin-import';
|
||||
import { globalIgnores } from 'eslint/config';
|
||||
import {
|
||||
defineConfigWithVueTs,
|
||||
vueTsConfigs,
|
||||
configureVueProject,
|
||||
} from '@vue/eslint-config-typescript';
|
||||
import pluginVue from 'eslint-plugin-vue';
|
||||
import pluginVitest from '@vitest/eslint-plugin';
|
||||
import pluginPlaywright from 'eslint-plugin-playwright';
|
||||
import pluginOxlint from 'eslint-plugin-oxlint';
|
||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting';
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginVitest from '@vitest/eslint-plugin'
|
||||
import pluginPlaywright from 'eslint-plugin-playwright'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||
|
||||
configureVueProject({ scriptLangs: ['ts', 'tsx'] });
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
@@ -20,7 +17,7 @@ export default defineConfigWithVueTs(
|
||||
files: ['**/*.{ts,mts,tsx,vue}'],
|
||||
},
|
||||
|
||||
globalIgnores(['worker-configuration.d.ts', '**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
@@ -35,22 +32,7 @@ export default defineConfigWithVueTs(
|
||||
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
||||
},
|
||||
...pluginOxlint.configs['flat/recommended'],
|
||||
{
|
||||
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'],
|
||||
},
|
||||
},
|
||||
skipFormatting,
|
||||
|
||||
{
|
||||
rules: {
|
||||
@@ -63,11 +45,11 @@ export default defineConfigWithVueTs(
|
||||
],
|
||||
'vue/define-macros-order': [
|
||||
'error',
|
||||
{ order: ['defineOptions', 'defineModel', 'defineProps', 'defineEmits', 'defineSlots'] },
|
||||
{
|
||||
order: ['defineOptions', 'defineProps', 'defineEmits', 'defineSlots'],
|
||||
},
|
||||
],
|
||||
'vue/multi-word-component-names': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
skipFormatting,
|
||||
);
|
||||
)
|
||||
|
||||
+35
-113
@@ -1,131 +1,53 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-build-time="%VITE_APP_BUILD_TIME%" data-commit="%VITE_APP_BUILD_COMMIT%">
|
||||
<html lang="zh-CN" data-build-time="%VITE_BUILD_TIME%" data-commit="%VITE_BUILD_COMMIT%">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
<!-- <link rel="icon" href="data:;base64,iVBORw0KGgo=" /> -->
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
|
||||
<!-- viewport-fit=cover, -->
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover, user-scalable=no"
|
||||
/>
|
||||
<script>
|
||||
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>
|
||||
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
|
||||
<title>%VITE_APP_TITLE%</title>
|
||||
|
||||
<title>vue-ts-example-2025</title>
|
||||
<style type="text/css">
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
margin: 0;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style type="text/css">
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@supports (min-height: 100dvh) {
|
||||
#app {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
html .min-h-screen {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
}
|
||||
.page-wrapper {
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<!-- ontouchstart ontouchend -->
|
||||
<body>
|
||||
<div id="app">
|
||||
<style type="text/css">
|
||||
@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 class="page-wrapper" style="display: flex; justify-content: center; align-items: center">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
@@ -134,7 +56,7 @@
|
||||
<!-- <script src="https://unpkg.luckincdn.com/@vant/touch-emulator@1.4.0/dist/index.js"></script> -->
|
||||
</body>
|
||||
<script>
|
||||
(function (d) {
|
||||
;(function (d) {
|
||||
var config = {
|
||||
kitId: 'whk2tto',
|
||||
scriptTimeout: 3000,
|
||||
@@ -142,25 +64,25 @@
|
||||
},
|
||||
h = d.documentElement,
|
||||
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),
|
||||
tk = d.createElement('script'),
|
||||
f = false,
|
||||
s = d.getElementsByTagName('script')[0],
|
||||
a;
|
||||
h.className += ' wf-loading';
|
||||
tk.src = 'https://use.typekit.net/' + config.kitId + '.js';
|
||||
tk.async = true;
|
||||
a
|
||||
h.className += ' wf-loading'
|
||||
tk.src = 'https://use.typekit.net/' + config.kitId + '.js'
|
||||
tk.async = true
|
||||
tk.onload = tk.onreadystatechange = function () {
|
||||
a = this.readyState;
|
||||
if (f || (a && a != 'complete' && a != 'loaded')) return;
|
||||
f = true;
|
||||
clearTimeout(t);
|
||||
a = this.readyState
|
||||
if (f || (a && a != 'complete' && a != 'loaded')) return
|
||||
f = true
|
||||
clearTimeout(t)
|
||||
try {
|
||||
Typekit.load(config);
|
||||
Typekit.load(config)
|
||||
} catch (e) {}
|
||||
};
|
||||
s.parentNode.insertBefore(tk, s);
|
||||
}); /* (document) */
|
||||
}
|
||||
s.parentNode.insertBefore(tk, s)
|
||||
}) /* (document) */
|
||||
</script>
|
||||
</html>
|
||||
|
||||
+50
-70
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"packageManager": "pnpm@10.18.3",
|
||||
"packageManager": "pnpm@10.15.1",
|
||||
"name": "vue-ts-example-2025",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
@@ -8,84 +8,78 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"_all": "run-p build-only type-check lint format:prettier test:unit:DisableWatch",
|
||||
"_all": "run-p build-only format type-check lint",
|
||||
"dev": "vite --port 4730 --host --strictPort",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"build-only": "vite build",
|
||||
"preview": "vite preview --port 4731 --host --strictPort",
|
||||
"wrangler:dev": "wrangler dev --port 4732",
|
||||
"format:prettier": "prettier --write src/",
|
||||
"preview": "vite preview",
|
||||
"preview:wrangler": "pnpm run build && wrangler dev",
|
||||
"test:unit": "vitest",
|
||||
"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",
|
||||
"lint": "run-s lint:*",
|
||||
"lint:vue-i18n-extract": "vue-i18n-extract report --vueFiles './src/**/*.?(ts|tsx|vue)' --languageFiles './src/locales/*.?(json|yml|yaml|js)' --ci",
|
||||
"lint:stylelint": "stylelint \"**/*.{css,less,scss,vue}\" --fix --ignore-path .gitignore",
|
||||
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
|
||||
"lint:eslint": "eslint . --fix",
|
||||
"test:unit:DisableWatch": "vitest --run",
|
||||
"test:playwright:headless": "HEADLESS=true playwright test --quiet",
|
||||
"postinstall": "wrangler types",
|
||||
"_oxlint_cfg": "oxlint . --fix --ignore-path=.gitignore --print-config",
|
||||
"__oxlint_-D": "oxlint . --fix --deny=correctness",
|
||||
"lint": "run-s lint:*",
|
||||
"format": "prettier --write src/",
|
||||
"-wrangler:pages:deploy:preview": "wrangler pages deploy dist --project-name=vue-ts-example-2025 --branch=preview",
|
||||
"-wrangler:pages:deploy:prod": "wrangler pages deploy dist --project-name=vue-ts-example-2025",
|
||||
"-deploy:preview": "run-s build-only wrangler:pages:deploy:preview",
|
||||
"-deploy:prod": "run-s build-only wrangler:pages:deploy:prod",
|
||||
"wrangler:deploy": "pnpm run build && wrangler deploy",
|
||||
"wrangler:versions:upload": "pnpm run build && wrangler versions upload",
|
||||
"cf-typegen": "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"
|
||||
},
|
||||
"lint-staged": {
|
||||
"{server,src,e2e}/**/*.{js,jsx,ts,tsx,vue}": [
|
||||
"{src,e2e}/**/*.{js,jsx,ts,tsx,vue}": [
|
||||
"prettier --write",
|
||||
"eslint --fix",
|
||||
"oxlint --fix"
|
||||
],
|
||||
"{src,packages}/**/*.{css,less,scss,vue}": [
|
||||
"stylelint --fix"
|
||||
]
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vue-tsc": "$vue-tsc"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
"esbuild",
|
||||
"oxc-resolver",
|
||||
"sharp",
|
||||
"vue-demi",
|
||||
"workerd"
|
||||
]
|
||||
"vite": "$vite"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@commitlint/cli": "^20.0.0",
|
||||
"@commitlint/config-conventional": "^20.0.0",
|
||||
"@commitlint/cli": "^19.8.1",
|
||||
"@commitlint/config-conventional": "^19.8.1",
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@pinia/colada": "^0.17.4",
|
||||
"@primeuix/themes": "^1.2.3",
|
||||
"@sa/materials": "workspace:*",
|
||||
"@unhead/vue": "^2.0.14",
|
||||
"@vueuse/core": "^13.9.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"naive-ui": "^2.43.1",
|
||||
"pinia": "^3.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.5.0",
|
||||
"primelocale": "^2.1.7",
|
||||
"primevue": "^4.3.9",
|
||||
"ts-enum-util": "^4.1.0",
|
||||
"utils4u": "^4.2.3",
|
||||
"vue": "^3.5.21",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue-router": "^4.6.3"
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.13.2",
|
||||
"@commitlint/types": "^20.0.0",
|
||||
"@iconify-json/carbon": "^1.2.13",
|
||||
"@iconify-json/clarity": "^1.2.4",
|
||||
"@iconify-json/line-md": "^1.2.11",
|
||||
"@iconify-json/material-symbols": "^1.2.42",
|
||||
"@cloudflare/vite-plugin": "^1.12.4",
|
||||
"@commitlint/types": "^19.8.1",
|
||||
"@intlify/unplugin-vue-i18n": "^11.0.0",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@prettier/plugin-oxc": "^0.0.4",
|
||||
"@primevue/auto-import-resolver": "^4.3.9",
|
||||
"@primevue/metadata": "^4.3.9",
|
||||
"@stylelint-types/stylelint-order": "^7.0.0",
|
||||
"@stylelint-types/stylelint-scss": "^6.11.0",
|
||||
"@tsconfig/node22": "^22.0.2",
|
||||
"@types/html-minifier-terser": "^7.0.2",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/node": "^22.18.1",
|
||||
"@vant/auto-import-resolver": "^1.3.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
@@ -95,50 +89,36 @@
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"consola": "^3.4.2",
|
||||
"eslint": "^9.35.0",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-oxlint": "~1.23.0",
|
||||
"eslint-plugin-oxlint": "~1.14.0",
|
||||
"eslint-plugin-playwright": "^2.2.2",
|
||||
"eslint-plugin-vue": "~10.5.0",
|
||||
"happy-dom": "^20.0.1",
|
||||
"html-minifier-terser": "^7.2.0",
|
||||
"eslint-plugin-vue": "~10.4.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.0.0",
|
||||
"jiti": "^2.5.1",
|
||||
"jsdom": "^26.1.0",
|
||||
"lint-staged": "^16.1.6",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"nprogress": "^0.2.0",
|
||||
"oxlint": "~1.23.0",
|
||||
"postcss-html": "^1.8.0",
|
||||
"oxlint": "~1.14.0",
|
||||
"prettier": "3.6.2",
|
||||
"rollup": "^4.52.5",
|
||||
"sass-embedded": "^1.93.2",
|
||||
"stylelint": "^16.25.0",
|
||||
"stylelint-config-recess-order": "^7.3.0",
|
||||
"stylelint-config-standard": "^39.0.1",
|
||||
"stylelint-config-standard-scss": "^16.0.0",
|
||||
"stylelint-config-standard-vue": "^1.0.0",
|
||||
"stylelint-define-config": "^16.24.0",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"typescript": "~5.9.2",
|
||||
"unocss": "^66.5.1",
|
||||
"unocss-preset-animations": "^1.2.1",
|
||||
"unplugin-auto-import": "^20.1.0",
|
||||
"unplugin-icons": "^22.2.0",
|
||||
"unplugin-vue-components": "^29.2.0",
|
||||
"unplugin-vue-markdown": "^29.2.0",
|
||||
"unplugin-vue-router": "^0.16.0",
|
||||
"vite": "^7.1.5",
|
||||
"vite-plugin-checker": "^0.11.0",
|
||||
"unplugin-vue-components": "^29.0.0",
|
||||
"unplugin-vue-markdown": "^29.1.0",
|
||||
"unplugin-vue-router": "^0.15.0",
|
||||
"vite": "npm:rolldown-vite@^7.1.9",
|
||||
"vite-plugin-checker": "^0.10.3",
|
||||
"vite-plugin-fake-server": "^2.2.0",
|
||||
"vite-plugin-image-optimizer": "^2.0.2",
|
||||
"vite-plugin-vue-devtools": "^8.0.1",
|
||||
"vite-plugin-vue-meta-layouts": "^0.6.1",
|
||||
"vite-plugin-vue-meta-layouts": "^0.6.0",
|
||||
"vite-plugin-webfont-dl": "^3.11.1",
|
||||
"vitest": "^3.2.4",
|
||||
"vue-i18n-extract": "^2.0.7",
|
||||
"vue-macros": "3.1.1",
|
||||
"vue-tsc": "^3.1.0",
|
||||
"wrangler": "^4.37.1"
|
||||
"vue-macros": "3.0.0-beta.23",
|
||||
"vue-tsc": "^3.0.6",
|
||||
"wrangler": "^4.35.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
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';
|
||||
@@ -1,63 +0,0 @@
|
||||
/* @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);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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;
|
||||
@@ -1,5 +0,0 @@
|
||||
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 };
|
||||
@@ -1,238 +0,0 @@
|
||||
<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>
|
||||
@@ -1,68 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import SimpleScrollbar from './index.vue';
|
||||
|
||||
export default SimpleScrollbar;
|
||||
@@ -1,16 +0,0 @@
|
||||
<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>
|
||||
@@ -1,288 +0,0 @@
|
||||
/** 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;
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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 process from 'node:process';
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
import process from 'node:process'
|
||||
|
||||
// const runningInVSCode = process.env.TERM_PROGRAM === 'vscode'
|
||||
const baseURL = 'http://localhost:4731';
|
||||
const baseURL = 'http://localhost:4173'
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
@@ -97,7 +97,7 @@ export default defineConfig({
|
||||
],
|
||||
|
||||
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||
outputDir: 'playwright-test-results/',
|
||||
// outputDir: 'test-results/',
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
@@ -110,4 +110,4 @@ export default defineConfig({
|
||||
port: Number(new URL(baseURL).port),
|
||||
reuseExistingServer: true /* !process.env.CI */,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
Generated
+2039
-4485
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
+16
-81
@@ -1,89 +1,24 @@
|
||||
export default {
|
||||
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/')) {
|
||||
await env.KV.put(
|
||||
'events:api:last-call',
|
||||
`${new Date().toISOString()} ${request.method} ${url.pathname}`,
|
||||
);
|
||||
|
||||
// 获取所有可用的键名
|
||||
const availableKeys = [
|
||||
'events:api:last-call',
|
||||
'events:ws:connection',
|
||||
'events:ws:message',
|
||||
'events:ws:disconnection',
|
||||
];
|
||||
// write a key-value pair
|
||||
await env.KV.put('KEY', 'VALUE')
|
||||
// read a key-value pair
|
||||
const value = await env.KV.get('KEY')
|
||||
// list all key-value pairs
|
||||
const allKeys = await env.KV.list()
|
||||
// delete a key-value pair
|
||||
await env.KV.delete('KEY')
|
||||
|
||||
return Response.json({
|
||||
timestamp: Date.now(),
|
||||
lastApiCall: await env.KV.get('events:api:last-call'),
|
||||
availableKeys: availableKeys,
|
||||
storedData: {
|
||||
apiLastCall: await env.KV.get('events:api:last-call'),
|
||||
wsConnection: await env.KV.get('events:ws:connection'),
|
||||
wsMessage: await env.KV.get('events:ws:message'),
|
||||
wsDisconnection: await env.KV.get('events:ws:disconnection'),
|
||||
},
|
||||
});
|
||||
name: 'Cloudflare',
|
||||
value,
|
||||
valueAfterDelete: await env.KV.get('KEY'),
|
||||
allKeys,
|
||||
})
|
||||
}
|
||||
|
||||
// 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 });
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
} satisfies ExportedHandler<Env>;
|
||||
} satisfies ExportedHandler<Env>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* @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');
|
||||
});
|
||||
});
|
||||
+22
-19
@@ -1,26 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { GlobalThemeOverrides } from 'naive-ui';
|
||||
import { darkTheme } from 'naive-ui';
|
||||
import { RouterView } from 'vue-router';
|
||||
import { ref } from 'vue'
|
||||
|
||||
const appStore = useAppStore();
|
||||
const name = ref('Unknown')
|
||||
|
||||
// https://www.naiveui.com/zh-CN/light/docs/customize-theme
|
||||
const themeOverrides: GlobalThemeOverrides = {
|
||||
common: {},
|
||||
};
|
||||
const getName = async () => {
|
||||
const res = await fetch('/api/')
|
||||
const data = await res.json()
|
||||
name.value = data.name
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DynamicDialog />
|
||||
<ConfirmDialog />
|
||||
<Toast />
|
||||
<n-config-provider
|
||||
:theme-overrides
|
||||
preflight-style-disabled
|
||||
:theme="appStore.isDark ? darkTheme : null"
|
||||
abstract
|
||||
>
|
||||
<RouterView />
|
||||
</n-config-provider>
|
||||
<div>
|
||||
<h1>You did it!</h1>
|
||||
<p>
|
||||
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 />
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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!')
|
||||
})
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 608 B |
@@ -1,160 +0,0 @@
|
||||
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 } = useI18n({
|
||||
inheritLocale: true,
|
||||
useScope: 'local',
|
||||
missing: (locale, key) => {
|
||||
consola.warn(`菜单翻译缺失: locale=${locale}, key=${key}`);
|
||||
return key;
|
||||
},
|
||||
fallbackRoot: true,
|
||||
messages: locales4RouteMessages,
|
||||
});
|
||||
|
||||
// 获取路由表但是不包含布局路由
|
||||
const routes = createGetRoutes(router)();
|
||||
|
||||
const options = computed(() => convertRoutesToNMenuOptions(routes));
|
||||
const selectedKey = ref('');
|
||||
|
||||
watch(
|
||||
() => router.currentRoute.value.path,
|
||||
(newPath) => {
|
||||
selectedKey.value = newPath;
|
||||
menuInstRef.value?.showOption(newPath); // 展开菜单,确保设定的元素被显示,如果不传入 key 会展示当前选中元素
|
||||
},
|
||||
{ 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_MENU_SHOW_DEMOS !== 'true' && route.path.startsWith('/demos')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
// 排序路由,确保父路由总是在子路由之前,同级路由则根据 `meta.order` 排序
|
||||
.sort((a: RouteRecordRaw, b: RouteRecordRaw) => {
|
||||
const pathA = a.path;
|
||||
const pathB = b.path;
|
||||
const segmentsA = pathA.split('/').filter(Boolean);
|
||||
const segmentsB = pathB.split('/').filter(Boolean);
|
||||
const parentAPath = `/${segmentsA.slice(0, -1).join('/')}`;
|
||||
const parentBPath = `/${segmentsB.slice(0, -1).join('/')}`;
|
||||
|
||||
// 如果不是同级路由,则按路径排序,确保父路由在前
|
||||
if (parentAPath !== parentBPath) {
|
||||
return pathA.localeCompare(pathB);
|
||||
}
|
||||
|
||||
// 同级路由,处理 `meta.order`
|
||||
const orderA = a.meta?.order;
|
||||
const orderB = b.meta?.order;
|
||||
const hasOrderA = orderA !== undefined;
|
||||
const hasOrderB = orderB !== undefined;
|
||||
|
||||
// 当一个有 order 而另一个没有时,有 order 的排在前面
|
||||
if (hasOrderA !== hasOrderB) {
|
||||
return hasOrderA ? -1 : 1;
|
||||
}
|
||||
|
||||
// 当两个都有 order 时,按 order 值升序排序
|
||||
if (hasOrderA && hasOrderB) {
|
||||
const orderDiff = orderA - orderB;
|
||||
if (orderDiff !== 0) {
|
||||
return orderDiff;
|
||||
}
|
||||
}
|
||||
|
||||
// order 相同或都没有 order,按路径字母顺序排序
|
||||
return pathA.localeCompare(pathB);
|
||||
});
|
||||
|
||||
// 构建菜单树
|
||||
for (const route of validRoutes) {
|
||||
const pathSegments = route.path.split('/').filter(Boolean);
|
||||
const routeName = route.name as string;
|
||||
|
||||
let text = te(routeName) ? t(routeName) : route.meta?.title || routeName;
|
||||
if (import.meta.env.VITE_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);
|
||||
}
|
||||
}
|
||||
|
||||
return rootMenus;
|
||||
}
|
||||
|
||||
// console.debug('原始路由:', JSON.stringify(routes, null, 0));
|
||||
// console.debug('转换后的菜单:', JSON.stringify(menuOptions.value, null, 0));
|
||||
return {
|
||||
options,
|
||||
selectedKey,
|
||||
// expanded-keys // 展开的子菜单标识符数组,如果设定了,菜单的展开将会进入受控状态,default-expanded-keys 不会生效
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import LanguageSwitchButton from './components/LanguageSwitchButton.vue';
|
||||
import ThemeSwitchButton from './components/ThemeSwitchButton.vue';
|
||||
import ToggleSiderButton from './components/ToggleSiderButton.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 />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,33 +0,0 @@
|
||||
<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>
|
||||
<icon-clarity-language-line w-4.5 h-4.5 />
|
||||
</template>
|
||||
<span>{{ languageLabels[locale] }}</span>
|
||||
</NButton>
|
||||
</NDropdown>
|
||||
</template>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const appStore = useAppStore();
|
||||
|
||||
const themeLabels: Record<AppThemeMode, string> = {
|
||||
light: '浅色',
|
||||
dark: '深色',
|
||||
system: '跟随系统',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NTooltip :disabled="appStore.isMobile" placement="bottom-end">
|
||||
{{ themeLabels[appStore.themeMode] }}
|
||||
<template #trigger>
|
||||
<NButton quaternary @click="appStore.cycleTheme">
|
||||
<icon-line-md-sunny-filled-loop-to-moon-filled-loop-transition
|
||||
v-if="appStore.themeMode === 'light'"
|
||||
w-4.5
|
||||
h-4.5
|
||||
/>
|
||||
<icon-line-md-moon-filled-to-sunny-filled-loop-transition
|
||||
v-else-if="appStore.themeMode === 'dark'"
|
||||
w-4.5
|
||||
h-4.5
|
||||
/>
|
||||
<icon-line-md-computer v-else w-4.5 h-4.5 />
|
||||
</NButton>
|
||||
</template>
|
||||
</NTooltip>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<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">
|
||||
<icon-line-md-menu-fold-right v-if="appStore.sidebarCollapsed" w-4.5 h-4.5 />
|
||||
<icon-line-md-menu-fold-left v-else w-4.5 h-4.5 />
|
||||
</NButton>
|
||||
</template>
|
||||
</NTooltip>
|
||||
</template>
|
||||
@@ -1,27 +0,0 @@
|
||||
<script setup lang="tsx">
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
|
||||
const menuInstRef = useTemplateRef('menuInstRef');
|
||||
const { options, selectedKey } = useMetaLayoutsNMenuOptions({
|
||||
menuInstRef,
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- @update:value="handleMenuUpdate" -->
|
||||
<NMenu
|
||||
mode="vertical"
|
||||
ref="menuInstRef"
|
||||
:collapsed="appStore.sidebarCollapsed"
|
||||
:collapsed-width="64"
|
||||
:icon-size="20"
|
||||
:collapsed-icon-size="24"
|
||||
v-model:value="selectedKey"
|
||||
:options="options"
|
||||
:inverted="false"
|
||||
:root-indent="32"
|
||||
:indent="32"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,62 +0,0 @@
|
||||
<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
|
||||
mode="horizontal"
|
||||
:footer-visible="!false"
|
||||
:tab-visible="!false"
|
||||
scroll-mode="content"
|
||||
:is-mobile="appStore.isMobile"
|
||||
v-model:sider-collapse="appStore.sidebarCollapsed"
|
||||
>
|
||||
<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> -->
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
<!-- <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;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.25s ease-in-out;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<div>AppLayout</div>
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,66 +0,0 @@
|
||||
# `locales-4-route`
|
||||
|
||||
此目录存放专门用于**路由名称**的国际化(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
|
||||
// src/locales-4-route/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
|
||||
// src/locales-4-route/zh-CN.ts
|
||||
export default {
|
||||
// ... 其他翻译
|
||||
DemosApiDemo: 'API 演示',
|
||||
} satisfies PageTitleLocalizations;
|
||||
|
||||
// src/locales-4-route/en-US.ts
|
||||
export default {
|
||||
// ... 其他翻译
|
||||
DemosApiDemo: 'API Demo',
|
||||
} satisfies PageTitleLocalizations;
|
||||
```
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { I18nOptions } from 'vue-i18n';
|
||||
|
||||
const modules = import.meta.glob(['./*.ts', '!./_messages-auto-imports.ts'], {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
});
|
||||
|
||||
type MessageType = Record<string, string>;
|
||||
|
||||
export const locales4RouteMessages: 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>,
|
||||
);
|
||||
@@ -1,10 +0,0 @@
|
||||
export default {
|
||||
Root: 'Index',
|
||||
$Path: '$Path',
|
||||
Demos: 'Demos',
|
||||
DemosApiDemo: 'API Demo',
|
||||
DemosCounterDemo: 'Counter Demo',
|
||||
DemosI18nDemo: 'i18n Demo',
|
||||
DemosWebsocketDemo: 'WebSocket Demo',
|
||||
Home: 'Home',
|
||||
} as const satisfies PageTitleLocalizations;
|
||||
@@ -1,10 +0,0 @@
|
||||
export default {
|
||||
Root: '根 (Gēn)',
|
||||
$Path: '$Path',
|
||||
Demos: '示例演示',
|
||||
DemosApiDemo: 'API 调用示例',
|
||||
DemosCounterDemo: '点击计数器',
|
||||
DemosI18nDemo: '国际化示例',
|
||||
DemosWebsocketDemo: 'WebSocket 示例',
|
||||
Home: '首页',
|
||||
} as const satisfies PageTitleLocalizations;
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"page": {
|
||||
"i18n-demo": {
|
||||
"title": "Vue I18n Demo",
|
||||
"current-language": "Current Language",
|
||||
"change-language": "Change Language",
|
||||
"hello": "Hello, {name}!"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"page": {
|
||||
"i18n-demo": {
|
||||
"title": "Vue I18n 示例",
|
||||
"current-language": "当前语言",
|
||||
"change-language": "切换语言",
|
||||
"hello": "你好, {name}!"
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-8
@@ -1,11 +1,12 @@
|
||||
import './styles/index.ts';
|
||||
import { LogLevels } from 'consola';
|
||||
import App from './App.vue';
|
||||
import { setupPlugins } from './plugins';
|
||||
import './styles'
|
||||
|
||||
consola.level = LogLevels.verbose;
|
||||
// import { LogLevels } from 'consola';
|
||||
// consola.level = LogLevels.verbose;
|
||||
|
||||
/* `import.meta.glob(${g}, { eager: ${isSync} })`; */
|
||||
const autoInstallModules = import.meta.glob('./plugins/!(index).ts', { eager: true });
|
||||
import App from './App.vue'
|
||||
|
||||
setupPlugins(createApp(App), autoInstallModules).mount('#app');
|
||||
const autoInstallModules = import.meta.glob('./plugins/!(index).ts', { eager: true })
|
||||
|
||||
import { setupPlugins } from './plugins'
|
||||
|
||||
setupPlugins(createApp(App), autoInstallModules).mount('#app')
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
definePage({ meta: { hideInMenu: false } });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>Home Page</div>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ path: string }>();
|
||||
defineProps<{ path: string }>()
|
||||
</script>
|
||||
<template>
|
||||
<main flex-1 class="flex flex-col items-center justify-center h-full space-y-4">
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
<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
|
||||
@click="callApi"
|
||||
: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"
|
||||
>
|
||||
<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>
|
||||
@@ -1,176 +0,0 @@
|
||||
<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
|
||||
@touchstart="() => {}"
|
||||
@touchend="() => {}"
|
||||
@click="incrementCount"
|
||||
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"
|
||||
>
|
||||
<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
|
||||
@click="incrementCount"
|
||||
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"
|
||||
>
|
||||
<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 按钮 -->
|
||||
<n-button
|
||||
@click="incrementCount"
|
||||
type="warning"
|
||||
size="large"
|
||||
block
|
||||
strong
|
||||
secondary
|
||||
class="text-lg"
|
||||
>
|
||||
<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)
|
||||
</n-button>
|
||||
|
||||
<!-- 重置按钮 -->
|
||||
<button
|
||||
@click="resetCount"
|
||||
: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]"
|
||||
>
|
||||
<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>
|
||||
@@ -1,34 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
definePage({
|
||||
meta: {
|
||||
order: 1,
|
||||
},
|
||||
});
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
function setLocale(newLocale: 'en-US' | 'zh-CN') {
|
||||
locale.value = newLocale;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<n-h1>{{ t('page.i18n-demo.title') }}</n-h1>
|
||||
|
||||
<n-card :title="t('page.i18n-demo.change-language')">
|
||||
<n-p>
|
||||
{{ t('page.i18n-demo.current-language') }}:
|
||||
<span class="font-bold">{{ locale }}</span>
|
||||
</n-p>
|
||||
|
||||
<n-p>
|
||||
{{ t('page.i18n-demo.hello', { name: 'Kilo' }) }}
|
||||
</n-p>
|
||||
|
||||
<n-space>
|
||||
<n-button type="primary" @click="setLocale('en-US')"> English </n-button>
|
||||
<n-button type="success" @click="setLocale('zh-CN')"> 简体中文 </n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,31 +0,0 @@
|
||||
<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>
|
||||
@@ -1,438 +0,0 @@
|
||||
<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
|
||||
@click="connectWebSocket"
|
||||
: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"
|
||||
>
|
||||
<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
|
||||
@click="disconnectWebSocket"
|
||||
: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"
|
||||
>
|
||||
<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"
|
||||
@keyup.enter="sendMessage"
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
@click="sendMessage"
|
||||
: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"
|
||||
>
|
||||
<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
|
||||
@click="sendMockData"
|
||||
: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未连接时不可用' : '发送随机模拟数据'"
|
||||
>
|
||||
<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
|
||||
@click="exportMessages"
|
||||
class="text-xs text-gray-500 hover:text-blue-500 transition-colors duration-200 flex items-center"
|
||||
title="导出消息"
|
||||
>
|
||||
<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
|
||||
@click="clearMessages"
|
||||
class="text-xs text-gray-500 hover:text-red-500 transition-colors duration-200 flex items-center"
|
||||
title="清空消息"
|
||||
>
|
||||
<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>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>Index Page</h1>
|
||||
</div>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -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,82 +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 type { RouteNamedMap } from 'vue-router/auto-routes';
|
||||
import { routes, handleHotUpdate } 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.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);
|
||||
createLogGuard(router);
|
||||
Object.assign(globalThis, { stack: createStackGuard(router) });
|
||||
}
|
||||
|
||||
declare module 'vue-router' {
|
||||
/* definePage({ meta: { title: '示例演示' } }); */
|
||||
interface RouteMeta {
|
||||
/**
|
||||
* @description 是否在菜单中隐藏
|
||||
*/
|
||||
hideInMenu?: boolean;
|
||||
|
||||
/**
|
||||
* @description 菜单标题
|
||||
* @deprecated //!⚠️请通过多语言标题方案(搜`PageTitleLocalizations`)维护标题
|
||||
*/
|
||||
title?: string;
|
||||
|
||||
/**
|
||||
* @description 使用的布局,设置为 false 则表示不使用布局
|
||||
*/
|
||||
layout?: string | false;
|
||||
|
||||
/**
|
||||
* @description 菜单项是否渲染为可点击链接,默认为 true
|
||||
* - true: 使用 RouterLink 包装,可点击跳转
|
||||
* - false: 仅渲染纯文本标签,不可点击(适用于分组标题)
|
||||
*/
|
||||
link?: boolean;
|
||||
|
||||
/**
|
||||
* @description 菜单排序权重,数值越小越靠前,未设置则按路径字母顺序排序
|
||||
*/
|
||||
order?: number;
|
||||
}
|
||||
}
|
||||
|
||||
export { router, setupLayoutsResult };
|
||||
|
||||
declare global {
|
||||
type PageTitleLocalizations = Record<keyof RouteNamedMap, string>;
|
||||
}
|
||||
|
||||
if (__DEV__) Object.assign(globalThis, { router });
|
||||
// This will update routes at runtime without reloading the page
|
||||
if (import.meta.hot) {
|
||||
handleHotUpdate(router);
|
||||
}
|
||||
@@ -1,25 +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> }) {
|
||||
// https://vue-i18n.intlify.dev/guide/essentials/started.html#registering-the-i18n-plugin
|
||||
const i18n = createI18n({
|
||||
legacy: false, // you must set `false`, to use Composition API
|
||||
locale: navigator.language,
|
||||
fallbackRoot: false,
|
||||
// flatJson: true,
|
||||
missing: (locale, key /* , instance, type */) => {
|
||||
consola.warn(`缺少国际化内容: locale='${locale}', key='${key}'`);
|
||||
return `[${key}]`;
|
||||
},
|
||||
missingWarn: !true,
|
||||
fallbackWarn: !true,
|
||||
messages,
|
||||
});
|
||||
app.use(i18n);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { autoAnimatePlugin } from '@formkit/auto-animate/vue';
|
||||
|
||||
export function install({ app }: { app: import('vue').App<Element> }) {
|
||||
app.use(autoAnimatePlugin); // v-auto-animate="{ duration: 100 }"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { createHead } from '@unhead/vue/client';
|
||||
|
||||
export function install({ app }: { app: import('vue').App<Element> }) {
|
||||
app.use(createHead());
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
export function install({ app }: { app: import('vue').App<Element> }) {
|
||||
app.config.globalProperties.__DEV__ = __DEV__;
|
||||
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);
|
||||
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/
|
||||
+11
-11
@@ -1,24 +1,24 @@
|
||||
/**
|
||||
* https://github.com/antfu-collective/vitesse/blob/47618e72dfba76c77b9b85b94784d739e35c492b/src/modules/README.md
|
||||
*/
|
||||
type UserPlugin = (ctx: UserPluginContext) => void;
|
||||
type AutoInstallModule = { [K: string]: unknown; install?: UserPlugin };
|
||||
type UserPluginContext = { app: import('vue').App<Element> };
|
||||
type UserPlugin = (ctx: UserPluginContext) => void
|
||||
type AutoInstallModule = { [K: string]: unknown; install?: UserPlugin }
|
||||
type UserPluginContext = { app: import('vue').App<Element> }
|
||||
export function setupPlugins(
|
||||
app: import('vue').App,
|
||||
modules: AutoInstallModule | Record<string, unknown>,
|
||||
) {
|
||||
console.group('🔌 Plugins');
|
||||
console.group('🔌 Plugins')
|
||||
for (const path in modules) {
|
||||
const module = modules[path] as AutoInstallModule;
|
||||
const module = modules[path] as AutoInstallModule
|
||||
if (module.install) {
|
||||
module.install({ app });
|
||||
console.debug(`%c✔ ${path}`, 'color: #07a');
|
||||
module.install({ app })
|
||||
console.debug(`%c✔ ${path}`, 'color: #07a')
|
||||
} else {
|
||||
if (typeof module.setupPlugins === 'function') continue;
|
||||
console.warn(`%c✘ ${path} has no install function`, 'color: #f50');
|
||||
if (typeof module.setupPlugins === 'function') continue
|
||||
console.warn(`%c✘ ${path} has no install function`, 'color: #f50')
|
||||
}
|
||||
}
|
||||
console.groupEnd();
|
||||
return app;
|
||||
console.groupEnd()
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -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, {})
|
||||
}
|
||||
@@ -2,13 +2,13 @@
|
||||
* 需要把 <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';
|
||||
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.directive('styleclass', StyleClass)
|
||||
app.use(PrimeVue, {
|
||||
locale: {
|
||||
...zhCN['zh-CN'],
|
||||
@@ -24,5 +24,5 @@ export function install({ app }: { app: import('vue').App<Element> }) {
|
||||
},
|
||||
preset: Aura,
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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'
|
||||
@@ -0,0 +1,17 @@
|
||||
/* 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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useLocalStorage, useMediaQuery, usePreferredColorScheme } from '@vueuse/core';
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, watch } from 'vue';
|
||||
|
||||
export const APP_THEME_MODES = ['light', 'dark', 'system'] as const;
|
||||
export type AppThemeMode = (typeof APP_THEME_MODES)[number];
|
||||
|
||||
const DARK_CLASS = 'app-dark';
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const themeMode = useLocalStorage<AppThemeMode>('app-theme-mode', 'system');
|
||||
const preferredColor = usePreferredColorScheme();
|
||||
|
||||
// 侧边栏展开/收起状态
|
||||
const sidebarCollapsed = useLocalStorage<boolean>('app-sidebar-collapsed', false);
|
||||
|
||||
// 计算实际使用的主题
|
||||
const actualTheme = computed(() =>
|
||||
themeMode.value === 'system'
|
||||
? preferredColor.value === 'dark'
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: themeMode.value,
|
||||
);
|
||||
|
||||
// 是否是暗色主题
|
||||
const isDark = computed(() => actualTheme.value === 'dark');
|
||||
|
||||
// 是否是移动端
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
// 更新 DOM 类名
|
||||
function updateDomClass() {
|
||||
document.documentElement.classList.toggle(DARK_CLASS, isDark.value);
|
||||
}
|
||||
|
||||
// 循环切换主题
|
||||
function cycleTheme() {
|
||||
const currentIndex = APP_THEME_MODES.indexOf(themeMode.value);
|
||||
const nextIndex = (currentIndex + 1) % APP_THEME_MODES.length;
|
||||
themeMode.value = APP_THEME_MODES[nextIndex]!;
|
||||
}
|
||||
|
||||
// 切换侧边栏展开/收起
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
}
|
||||
|
||||
// 监听主题变化,更新 DOM
|
||||
watch(isDark, updateDomClass, { immediate: true });
|
||||
|
||||
return {
|
||||
themeMode,
|
||||
isDark,
|
||||
isMobile,
|
||||
cycleTheme,
|
||||
sidebarCollapsed,
|
||||
toggleSidebar,
|
||||
};
|
||||
});
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useAppStore, import.meta.hot));
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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 }
|
||||
})
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import 'nprogress/nprogress.css'; // <link rel="stylesheet" href="https://testingcf.jsdelivr.net/npm/nprogress/nprogress.css" />
|
||||
import 'nprogress/nprogress.css' // <link rel="stylesheet" href="https://testingcf.jsdelivr.net/npm/nprogress/nprogress.css" />
|
||||
|
||||
//
|
||||
import 'virtual:uno.css';
|
||||
import 'virtual:uno.css'
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
@forward 'scrollbar';
|
||||
@@ -1,24 +0,0 @@
|
||||
@mixin scrollbar($size: 7px, $color: rgba(0, 0, 0, 0.5)) {
|
||||
scrollbar-color: $color transparent;
|
||||
scrollbar-width: thin;
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: $color;
|
||||
border-radius: $size;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background-color: $color;
|
||||
border-radius: $size;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: $size;
|
||||
height: $size;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track-piece {
|
||||
background-color: rgb(0 0 0 / 0%);
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
declare global {
|
||||
const __DEV__: boolean;
|
||||
const __DEV__: boolean
|
||||
}
|
||||
|
||||
declare module 'vue' {
|
||||
export interface ComponentCustomProperties {
|
||||
__DEV__: boolean;
|
||||
__DEV__: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// @ts-check
|
||||
/// <reference types="@stylelint-types/stylelint-scss" />
|
||||
/// <reference types="@stylelint-types/stylelint-order" />
|
||||
|
||||
/**
|
||||
* Stylelint 配置
|
||||
*
|
||||
* @see https://stylelint.io/user-guide/configure
|
||||
*/
|
||||
|
||||
import defineConfig from 'stylelint-define-config'; // [Add support for TypeScript configuration files](https://github.com/stylelint/stylelint/issues/4940)
|
||||
|
||||
export default defineConfig({
|
||||
extends: [
|
||||
'stylelint-config-standard',
|
||||
'stylelint-config-recess-order',
|
||||
'stylelint-config-standard-scss',
|
||||
'stylelint-config-standard-vue/scss',
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.scss'],
|
||||
|
||||
customSyntax: 'postcss-scss',
|
||||
},
|
||||
{
|
||||
files: ['**/*.less'],
|
||||
customSyntax: 'postcss-less',
|
||||
},
|
||||
{
|
||||
files: ['**/*.vue'],
|
||||
customSyntax: 'postcss-html',
|
||||
},
|
||||
],
|
||||
rules: {
|
||||
// 允许非 kebab-case 的 ID(Vue 使用 __ID__ 约定)
|
||||
'selector-id-pattern': null,
|
||||
// >>>>>
|
||||
// 禁用默认的 at-rule-no-unknown,使用 SCSS 专用的规则
|
||||
// 'at-rule-no-unknown': null,
|
||||
// SCSS 专用的 at-rule 规则会自动处理 @include, @mixin 等
|
||||
// 'scss/at-rule-no-unknown': true,
|
||||
// <<<<<
|
||||
},
|
||||
});
|
||||
+1
-2
@@ -6,8 +6,7 @@
|
||||
"src/**/*.vue",
|
||||
"./auto-imports.d.ts",
|
||||
"./typed-router.d.ts",
|
||||
"./components.d.ts",
|
||||
"node_modules/naive-ui/volar.d.ts"
|
||||
"./components.d.ts"
|
||||
],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
|
||||
+1
-6
@@ -7,10 +7,6 @@
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*",
|
||||
"unocss.config.*",
|
||||
"commitlint.config.*",
|
||||
"stylelint.config.*",
|
||||
"vite-plugins",
|
||||
"fake/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
@@ -19,7 +15,6 @@
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"],
|
||||
"allowImportingTsExtensions": true
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+8
-107
@@ -1,15 +1,10 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection ES6UnusedImports
|
||||
// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
|
||||
// It's recommended to commit this file.
|
||||
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
|
||||
|
||||
declare module 'vue-router/auto-resolver' {
|
||||
export type ParamParserCustom = never
|
||||
}
|
||||
|
||||
declare module 'vue-router/auto-routes' {
|
||||
import type {
|
||||
RouteRecordInfo,
|
||||
@@ -23,67 +18,13 @@ declare module 'vue-router/auto-routes' {
|
||||
* Route name map generated by unplugin-vue-router
|
||||
*/
|
||||
export interface RouteNamedMap {
|
||||
'Root': RouteRecordInfo<
|
||||
'Root',
|
||||
'/',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'$Path': RouteRecordInfo<
|
||||
'$Path',
|
||||
'/:path(.*)',
|
||||
{ path: ParamValue<true> },
|
||||
{ path: ParamValue<false> },
|
||||
| never
|
||||
>,
|
||||
'Demos': RouteRecordInfo<
|
||||
'Demos',
|
||||
'/demos',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'DemosApiDemo': RouteRecordInfo<
|
||||
'DemosApiDemo',
|
||||
'/demos/api-demo',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'DemosCounterDemo': RouteRecordInfo<
|
||||
'DemosCounterDemo',
|
||||
'/demos/counter-demo',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'DemosI18nDemo': RouteRecordInfo<
|
||||
'DemosI18nDemo',
|
||||
'/demos/i18n-demo',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'DemosWebsocketDemo': RouteRecordInfo<
|
||||
'DemosWebsocketDemo',
|
||||
'/demos/websocket-demo',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'Home': RouteRecordInfo<
|
||||
'Home',
|
||||
'/Home',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'Root': RouteRecordInfo<'Root', '/', Record<never, never>, Record<never, never>>,
|
||||
'$Path': RouteRecordInfo<'$Path', '/:path(.*)', { path: ParamValue<true> }, { path: ParamValue<false> }>,
|
||||
}
|
||||
|
||||
/**
|
||||
* Route file to route info map by unplugin-vue-router.
|
||||
* Used by the \`sfc-typed-router\` Volar plugin to automatically type \`useRoute()\`.
|
||||
* Used by the volar plugin to automatically type useRoute()
|
||||
*
|
||||
* Each key is a file path relative to the project root with 2 properties:
|
||||
* - routes: union of route names of the possible routes when in this page (passed to useRoute<...>())
|
||||
@@ -93,58 +34,18 @@ declare module 'vue-router/auto-routes' {
|
||||
*/
|
||||
export interface _RouteFileInfoMap {
|
||||
'src/pages/index.page.vue': {
|
||||
routes:
|
||||
| 'Root'
|
||||
views:
|
||||
| never
|
||||
routes: 'Root'
|
||||
views: never
|
||||
}
|
||||
'src/pages/[...path].page.vue': {
|
||||
routes:
|
||||
| '$Path'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/demos/index.page.vue': {
|
||||
routes:
|
||||
| 'Demos'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/demos/api-demo.page.vue': {
|
||||
routes:
|
||||
| 'DemosApiDemo'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/demos/counter-demo.page.vue': {
|
||||
routes:
|
||||
| 'DemosCounterDemo'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/demos/i18n-demo.page.vue': {
|
||||
routes:
|
||||
| 'DemosI18nDemo'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/demos/websocket-demo.page.vue': {
|
||||
routes:
|
||||
| 'DemosWebsocketDemo'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/pages/Home.page.vue': {
|
||||
routes:
|
||||
| 'Home'
|
||||
views:
|
||||
| never
|
||||
routes: '$Path'
|
||||
views: never
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a union of possible route names in a certain route component file.
|
||||
* Used by the \`sfc-typed-router\` Volar plugin to automatically type \`useRoute()\`.
|
||||
* Used by the volar plugin to automatically type useRoute()
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
|
||||
+7
-9
@@ -1,19 +1,17 @@
|
||||
// !请确保在 `main.ts` 文件中添加以下导入语句:import 'virtual:uno.css';
|
||||
// 请确保在 `main.ts` 文件中添加以下导入语句:import 'virtual:uno.css';
|
||||
|
||||
// https://github.dev/unocss/unocss/tree/main/examples/vite-vue3
|
||||
|
||||
import {
|
||||
defineConfig,
|
||||
presetAttributify,
|
||||
presetWind4,
|
||||
transformerDirectives,
|
||||
transformerVariantGroup,
|
||||
} from 'unocss';
|
||||
import { defineConfig, presetAttributify, presetWind4, transformerDirectives, transformerVariantGroup } from 'unocss';
|
||||
import { presetAnimations } from 'unocss-preset-animations';
|
||||
|
||||
export default defineConfig({
|
||||
presets: [
|
||||
presetWind4({ dark: { dark: '.app-dark' } }),
|
||||
presetWind4({
|
||||
dark: {
|
||||
dark: '.app-dark',
|
||||
},
|
||||
}),
|
||||
|
||||
// https://unocss-preset-animations.aelita.me
|
||||
presetAnimations(),
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx';
|
||||
import { getPascalCaseRouteName } from 'unplugin-vue-router';
|
||||
import vueRouter from 'unplugin-vue-router/vite';
|
||||
import type { PluginOption } from 'vite';
|
||||
import VueMacros from 'vue-macros/vite';
|
||||
|
||||
export default [
|
||||
VueMacros({
|
||||
plugins: {
|
||||
vue: vue({ include: [/\.vue$/, /\.md$/] }),
|
||||
vueJsx: vueJsx(),
|
||||
|
||||
// https://uvr.esm.is/guide/configuration.html
|
||||
// https://github.com/posva/unplugin-vue-router
|
||||
// ⚠️ Vue must be placed after VueRouter()
|
||||
vueRouter: vueRouter({
|
||||
exclude: ['**/__*', '**/__*/**/*'],
|
||||
extensions: ['.page.vue', '.page.md'],
|
||||
getRouteName: (routeNode) => getPascalCaseRouteName(routeNode),
|
||||
logs: false,
|
||||
routesFolder: 'src/pages',
|
||||
}),
|
||||
},
|
||||
}),
|
||||
] satisfies PluginOption;
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { PluginOption } from 'vite';
|
||||
import UnoCSS from 'unocss/vite';
|
||||
|
||||
export default [
|
||||
// https://github.com/antfu/unocss
|
||||
// see uno.config.ts for config
|
||||
UnoCSS(),
|
||||
] satisfies PluginOption;
|
||||
@@ -1,18 +0,0 @@
|
||||
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite';
|
||||
import type { PluginOption } from 'vite';
|
||||
|
||||
export default [
|
||||
// https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n
|
||||
VueI18nPlugin({
|
||||
/* options */
|
||||
// locale messages resource pre-compile option
|
||||
include: ['src/locales/**'],
|
||||
|
||||
// https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n#transformi18nblock
|
||||
// transformI18nBlock(src) {
|
||||
// console.debug(`src :>> `, src);
|
||||
// console.debug(`typeof src :>> `, typeof src);
|
||||
// return src as string;
|
||||
// },
|
||||
}),
|
||||
] satisfies PluginOption;
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { PluginOption } from 'vite';
|
||||
import Markdown from 'unplugin-vue-markdown/vite';
|
||||
|
||||
export default [
|
||||
// https://github.com/unplugin/unplugin-vue-markdown
|
||||
Markdown({
|
||||
headEnabled: true,
|
||||
}),
|
||||
] satisfies PluginOption;
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { PluginOption } from 'vite';
|
||||
|
||||
export default [
|
||||
// https://github.com/JohnCampionJr/vite-plugin-vue-layouts?tab=readme-ov-file#configuration
|
||||
// Layouts({ defaultLayout: 'sakai-vue/AppLayout', pagesDirs: [] }),
|
||||
] satisfies PluginOption;
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { PluginOption } from 'vite';
|
||||
import MetaLayouts from 'vite-plugin-vue-meta-layouts';
|
||||
|
||||
export default [
|
||||
// https://github.com/dishait/vite-plugin-vue-meta-layouts
|
||||
MetaLayouts({
|
||||
target: 'src/layouts',
|
||||
excludes: ['**/!(the-)*.vue'], // 排除非 the- 开头的文件。
|
||||
metaName: 'layout',
|
||||
// defaultLayout: 'sakai-vue/AppLayout',
|
||||
// defaultLayout: 'naive-ui/AppLayout',
|
||||
defaultLayout: 'base-layout/the-base-layout',
|
||||
// !⬇️: 当设置为 `sync` 时,注意`import 'virtual:uno.css'`的顺序问题。
|
||||
// importMode: 'sync', // 默认为自动处理,SSG 时为 sync,非 SSG 时为 async
|
||||
skipTopLevelRouteLayout: true, // 打开修复 https://github.com/JohnCampionJr/vite-plugin-vue-layouts/issues/134,默认为 false 关闭
|
||||
}),
|
||||
] satisfies PluginOption;
|
||||
@@ -1,144 +0,0 @@
|
||||
import consola from 'consola';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import AutoImport from 'unplugin-auto-import/vite';
|
||||
import { FileSystemIconLoader } from 'unplugin-icons/loaders';
|
||||
import IconsResolver from 'unplugin-icons/resolver';
|
||||
import Icons from 'unplugin-icons/vite';
|
||||
import Components from 'unplugin-vue-components/vite';
|
||||
import { VueRouterAutoImports } from 'unplugin-vue-router';
|
||||
import { createUtils4uAutoImports } from 'utils4u/auto-imports';
|
||||
import type { ConfigEnv, PluginOption } from 'vite';
|
||||
|
||||
// >>>>>
|
||||
// eslint-disable-next-line import/no-duplicates
|
||||
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers';
|
||||
|
||||
// eslint-disable-next-line import/no-duplicates
|
||||
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers';
|
||||
|
||||
// eslint-disable-next-line import/no-duplicates
|
||||
import { TDesignResolver } from 'unplugin-vue-components/resolvers';
|
||||
|
||||
import { PrimeVueResolver } from '@primevue/auto-import-resolver';
|
||||
|
||||
import { VantResolver } from '@vant/auto-import-resolver';
|
||||
// <<<<<
|
||||
|
||||
function _getNaiveUiComponentNames() {
|
||||
// [dtsTsx](https://github.com/unplugin/unplugin-vue-components/pull/673/files/84e80738885cfe11298f41f070cda94a7a779276)
|
||||
|
||||
// 方法1: 从 web-types.json 读取(推荐)
|
||||
const webTypesPath = path.resolve('node_modules/naive-ui/web-types.json');
|
||||
if (fs.existsSync(webTypesPath)) {
|
||||
const webTypes = JSON.parse(fs.readFileSync(webTypesPath, 'utf-8'));
|
||||
const components = webTypes.contributions.html['vue-components'];
|
||||
const componentNames = components.map((component: { name: string }) => component.name);
|
||||
consola.info('naive-ui components count (from web-types.json):', componentNames.length);
|
||||
return componentNames;
|
||||
}
|
||||
|
||||
// 方法2: 从 volar.d.ts 读取(备选)
|
||||
const volarPath = path.resolve('node_modules/naive-ui/volar.d.ts');
|
||||
if (fs.existsSync(volarPath)) {
|
||||
const volarContent = fs.readFileSync(volarPath, 'utf-8');
|
||||
// 匹配类似 "NAffix: (typeof import('naive-ui'))['NAffix']" 的行
|
||||
const regex = /^\s+(N\w+):/gm;
|
||||
const matches = [...volarContent.matchAll(regex)];
|
||||
const componentNames = matches.map((match) => match[1]);
|
||||
consola.info('naive-ui components count (from volar.d.ts):', componentNames.length);
|
||||
return componentNames;
|
||||
}
|
||||
|
||||
consola.warn('Could not find naive-ui component metadata files');
|
||||
return [];
|
||||
}
|
||||
|
||||
export function loadPlugin(_configEnv: ConfigEnv): PluginOption {
|
||||
return [
|
||||
// https://github.com/antfu/unplugin-auto-import
|
||||
AutoImport({
|
||||
dtsMode: 'overwrite',
|
||||
dirs: [
|
||||
// 'src/utils',
|
||||
'src/composables',
|
||||
'src/stores',
|
||||
// 匹配所有 -auto-imports.ts / -auto-imports.tsx 结尾的文件
|
||||
'src/**/*-auto-imports.{ts,tsx}',
|
||||
],
|
||||
imports: [
|
||||
'vue',
|
||||
'vue-i18n',
|
||||
'pinia',
|
||||
'@vueuse/core',
|
||||
VueRouterAutoImports,
|
||||
createUtils4uAutoImports([]),
|
||||
{
|
||||
'consola/browser': ['consola'],
|
||||
'vue-router/auto': ['useLink'],
|
||||
'naive-ui': [
|
||||
'useModal',
|
||||
'useDialog',
|
||||
'useMessage',
|
||||
'useNotification',
|
||||
'useLoadingBar',
|
||||
// ..._getNaiveUiComponentNames(),
|
||||
],
|
||||
},
|
||||
],
|
||||
vueTemplate: true,
|
||||
}),
|
||||
// https://github.com/antfu/unplugin-vue-components
|
||||
Components({
|
||||
syncMode: 'default',
|
||||
dtsTsx: true,
|
||||
|
||||
// `__`开头的
|
||||
excludeNames: [/^__/],
|
||||
// allow auto load markdown components under `./src/components/`
|
||||
extensions: ['vue', 'md'],
|
||||
|
||||
include: [
|
||||
// https://github.com/unplugin/unplugin-vue-components/blob/c9117ae93f60f81c8b5a41890cb7fa0133f34a12/src/core/unplugin.ts#L17
|
||||
|
||||
/\.vue$/,
|
||||
/\.vue\?vue/,
|
||||
/\.vue\.[tj]sx?\?vue/, // for vue-loader with experimentalInlineMatchResource enabled
|
||||
/\.vue\?v=/,
|
||||
//
|
||||
/\.md$/, // allow auto import and register components used in markdown
|
||||
/\.tsx/,
|
||||
],
|
||||
|
||||
resolvers: [
|
||||
AntDesignVueResolver({
|
||||
importStyle: false, // css in js
|
||||
resolveIcons: true,
|
||||
}),
|
||||
IconsResolver({
|
||||
customCollections: ['svg'],
|
||||
prefix: 'icon' /* <icon-svg:demo /> or <icon-svg-demo /> */,
|
||||
}), // https://github.com/unplugin/unplugin-icons?tab=readme-ov-file#auto-importing
|
||||
TDesignResolver({ esm: true, library: 'mobile-vue' }),
|
||||
VantResolver({ importStyle: true }),
|
||||
PrimeVueResolver(/* { components: { prefix: 'P' } } */),
|
||||
NaiveUiResolver(),
|
||||
],
|
||||
}),
|
||||
|
||||
// https://github.com/unplugin/unplugin-icons?tab=readme-ov-file
|
||||
// https://icon-sets.iconify.design
|
||||
Icons({
|
||||
autoInstall: true,
|
||||
customCollections: {
|
||||
svg: FileSystemIconLoader('src/assets/icons/svgs', (svg) => {
|
||||
return svg.replace(/^<svg /, '<svg fill="currentColor" ');
|
||||
}),
|
||||
},
|
||||
iconCustomizer(collection, icon, properties) {
|
||||
properties.class = 'unplugin-icons';
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { PluginOption } from 'vite';
|
||||
import { minify as minifyHtml } from 'html-minifier-terser';
|
||||
|
||||
function IndexHtmlPlugin(): PluginOption {
|
||||
return {
|
||||
name: 'index-html-plugin',
|
||||
apply: 'build',
|
||||
async transformIndexHtml(html) {
|
||||
console.time('minifyHtml');
|
||||
// 压缩 HTML
|
||||
const minifiedHtml = await minifyHtml(html, {
|
||||
collapseWhitespace: true,
|
||||
removeComments: true,
|
||||
removeRedundantAttributes: true,
|
||||
removeScriptTypeAttributes: true,
|
||||
removeStyleLinkTypeAttributes: true,
|
||||
useShortDoctype: true,
|
||||
minifyCSS: true,
|
||||
minifyJS: true,
|
||||
});
|
||||
console.log();
|
||||
console.timeEnd('minifyHtml');
|
||||
return minifiedHtml;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default [IndexHtmlPlugin()] satisfies PluginOption[];
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { PluginOption } from 'vite';
|
||||
import checker from 'vite-plugin-checker';
|
||||
|
||||
export default [
|
||||
// https://vite-plugin-checker.netlify.app/introduction/introduction.html
|
||||
checker({
|
||||
eslint: {
|
||||
lintCommand: 'eslint "./src/**/*.{js,jsx,ts,tsx,vue}"',
|
||||
useFlatConfig: true,
|
||||
},
|
||||
vueTsc: true,
|
||||
overlay: {
|
||||
initialIsOpen: false,
|
||||
},
|
||||
terminal: true,
|
||||
enableBuild: true,
|
||||
// XXX: pnpm add vls vti -D
|
||||
}),
|
||||
] satisfies PluginOption;
|
||||
@@ -1,16 +0,0 @@
|
||||
import { consola } from 'consola';
|
||||
import type { ConfigEnv, PluginOption } from 'vite';
|
||||
import { vitePluginFakeServer } from 'vite-plugin-fake-server';
|
||||
// https://github.com/condorheroblog/vite-plugin-fake-server?tab=readme-ov-file#usage
|
||||
|
||||
export function loadPlugin(_configEnv: ConfigEnv): PluginOption {
|
||||
if (_configEnv.mode !== 'development') {
|
||||
consola.info('fake server plugin is disabled in non-development mode.');
|
||||
return [];
|
||||
}
|
||||
return vitePluginFakeServer({
|
||||
basename: 'fake-api',
|
||||
enableProd: true,
|
||||
include: 'fake',
|
||||
});
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { PluginOption } from 'vite';
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer';
|
||||
|
||||
export default [
|
||||
// https://github.com/FatehAK/vite-plugin-image-optimizer?tab=readme-ov-file#default-configuration
|
||||
ViteImageOptimizer({
|
||||
/* pass your config */
|
||||
}),
|
||||
] satisfies PluginOption;
|
||||
@@ -1,20 +0,0 @@
|
||||
import consola from 'consola';
|
||||
import type { ConfigEnv, PluginOption } from 'vite';
|
||||
import { loadEnv } from 'vite';
|
||||
import vueDevTools from 'vite-plugin-vue-devtools';
|
||||
|
||||
export function loadPlugin(configEnv: ConfigEnv): PluginOption {
|
||||
const env = loadEnv(configEnv.mode, process.cwd());
|
||||
|
||||
if (configEnv.command === 'build') {
|
||||
consola.info('vue-devtools plugin is not used in build mode.');
|
||||
return [];
|
||||
}
|
||||
|
||||
if (env.VITE_ENABLE_VUE_DEVTOOLS !== 'true') {
|
||||
consola.info('vue-devtools plugin disabled by env');
|
||||
return [];
|
||||
}
|
||||
|
||||
return [vueDevTools()];
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { cloudflare } from '@cloudflare/vite-plugin';
|
||||
import { loadEnv } from 'vite';
|
||||
import type {ConfigEnv, PluginOption} from 'vite';
|
||||
|
||||
export function loadPlugin(_configEnv: ConfigEnv): PluginOption {
|
||||
const env = loadEnv(_configEnv.mode, process.cwd());
|
||||
if (_configEnv.mode === 'test') {
|
||||
console.log('cloudflare plugin disabled in test mode');
|
||||
return [];
|
||||
}
|
||||
if (env.VITE_CLOUDFLARE_SERVER_ENABLED !== 'true') {
|
||||
console.log('cloudflare plugin disabled by env');
|
||||
return [];
|
||||
}
|
||||
return [cloudflare()];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user