添加antdv-next-admin作为脚手架准备重构前端web
This commit is contained in:
parent
6ab474bb7f
commit
b2d286035b
14
antdv-next-admin/.editorconfig
Normal file
14
antdv-next-admin/.editorconfig
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# EditorConfig is awesome: https://EditorConfig.org
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
5
antdv-next-admin/.env
Normal file
5
antdv-next-admin/.env
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Application Title
|
||||||
|
VITE_APP_TITLE=Antdv Next Admin
|
||||||
|
|
||||||
|
# API Base URL
|
||||||
|
VITE_API_BASE_URL=/api
|
||||||
7
antdv-next-admin/.env.development
Normal file
7
antdv-next-admin/.env.development
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Development Environment
|
||||||
|
|
||||||
|
# Enable Mock Data
|
||||||
|
VITE_USE_MOCK=true
|
||||||
|
|
||||||
|
# API Base URL (use mock in development)
|
||||||
|
VITE_API_BASE_URL=/api
|
||||||
7
antdv-next-admin/.env.production
Normal file
7
antdv-next-admin/.env.production
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Production Environment
|
||||||
|
|
||||||
|
# Enable Mock Data for demo (GitHub Pages is static, so we must NOT call /api)
|
||||||
|
VITE_USE_MOCK=true
|
||||||
|
|
||||||
|
# API Base URL (unused in mock mode; keep empty to avoid accidental /api calls)
|
||||||
|
VITE_API_BASE_URL=
|
||||||
35
antdv-next-admin/.github/workflows/build.yml
vendored
Normal file
35
antdv-next-admin/.github/workflows/build.yml
vendored
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
name: Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install rolldown native bindings
|
||||||
|
run: pnpm add @rolldown/binding-linux-x64-gnu -D
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm run build
|
||||||
68
antdv-next-admin/.github/workflows/deploy.yml
vendored
Normal file
68
antdv-next-admin/.github/workflows/deploy.yml
vendored
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
name: Deploy to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main # 当推送到 main 分支时触发
|
||||||
|
|
||||||
|
# 允许手动触发
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# 设置 GITHUB_TOKEN 的权限
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
# 只允许一个并发部署
|
||||||
|
concurrency:
|
||||||
|
group: "pages"
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install rolldown native bindings
|
||||||
|
run: pnpm add @rolldown/binding-linux-x64-gnu -D
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm run build
|
||||||
|
env:
|
||||||
|
NODE_ENV: production
|
||||||
|
|
||||||
|
- name: Setup Pages
|
||||||
|
uses: actions/configure-pages@v4
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: "./dist"
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
steps:
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
31
antdv-next-admin/.gitignore
vendored
Normal file
31
antdv-next-admin/.gitignore
vendored
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
.claude
|
||||||
|
.agents
|
||||||
19
antdv-next-admin/.oxfmtrc.json
Normal file
19
antdv-next-admin/.oxfmtrc.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"insertFinalNewline": true,
|
||||||
|
"sortImports": {
|
||||||
|
"groups": [
|
||||||
|
"type-import",
|
||||||
|
["value-builtin", "value-external"],
|
||||||
|
"value-internal",
|
||||||
|
["value-parent", "value-sibling", "value-index"],
|
||||||
|
"unknown"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
29
antdv-next-admin/.oxlintrc.json
Normal file
29
antdv-next-admin/.oxlintrc.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["vue", "typescript", "import"],
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error",
|
||||||
|
"suspicious": "warn",
|
||||||
|
"perf": "warn"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-unused-vars": "warn",
|
||||||
|
"typescript/no-explicit-any": "warn",
|
||||||
|
"typescript/no-unnecessary-type-assertion": "warn",
|
||||||
|
"import/no-duplicates": "error"
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["**/*.vue"],
|
||||||
|
"rules": {
|
||||||
|
"vue/no-unused-vars": "warn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["mock/**/*"],
|
||||||
|
"rules": {
|
||||||
|
"no-console": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
251
antdv-next-admin/AGENTS.md
Normal file
251
antdv-next-admin/AGENTS.md
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
# Antdv Next Admin - Agent Guidelines
|
||||||
|
|
||||||
|
A Vue 3 + TypeScript + Ant Design Vue admin scaffold with RBAC, theming, i18n, and mock APIs.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── api/ # API layer - organized by domain (auth.ts, user.ts)
|
||||||
|
├── assets/styles/ # Global styles (variables.css, animations.css, global.css)
|
||||||
|
├── components/ # Reusable components (Layout/, Permission/, etc.)
|
||||||
|
├── composables/ # Composition functions (usePermission.ts, useWatermark.ts)
|
||||||
|
├── directives/ # Custom Vue directives (permission.ts)
|
||||||
|
├── locales/ # i18n translations (zh-CN.ts, en-US.ts)
|
||||||
|
├── router/ # Vue Router (routes.ts, guards.ts, utils.ts)
|
||||||
|
├── stores/ # Pinia stores - one per domain (auth.ts, theme.ts, layout.ts)
|
||||||
|
├── types/ # TypeScript interfaces/types (auth.ts, api.ts, router.ts)
|
||||||
|
├── utils/ # Pure utility functions (request.ts, storage.ts, helpers.ts)
|
||||||
|
└── views/ # Page components (dashboard/, system/, examples/)
|
||||||
|
|
||||||
|
mock/
|
||||||
|
├── data/ # Mock datasets (users.data.ts, roles.data.ts)
|
||||||
|
└── handlers/ # Mock API handlers (auth.mock.ts, user.mock.ts)
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── e2e/ # End-to-end tests (*.spec.ts) - templates for future Playwright setup
|
||||||
|
└── unit/ # Unit tests (*.spec.ts) - templates for future Vitest setup
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
|
### Essential Commands
|
||||||
|
```bash
|
||||||
|
npm install # Install all dependencies
|
||||||
|
npm run dev # Start dev server at http://localhost:3000 (with mock APIs)
|
||||||
|
npm run build # Type check + production build → dist/
|
||||||
|
npm run preview # Preview production build locally
|
||||||
|
npm run type-check # Run vue-tsc --noEmit (NO auto-fix)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-commit Requirements
|
||||||
|
**BEFORE any commit or PR:**
|
||||||
|
1. Run `npm run type-check` - must exit 0 with no errors
|
||||||
|
2. Run `npm run build` - must complete successfully
|
||||||
|
3. For RBAC/auth changes: manually verify login with `admin/123456` and `user/123456`
|
||||||
|
|
||||||
|
### Testing Notes
|
||||||
|
- **No test runner configured yet** - Playwright/Vitest dependencies are NOT installed
|
||||||
|
- Test files in `tests/` are **templates** for future setup
|
||||||
|
- To add tests later: install test framework first, update package.json scripts, then write tests
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
### Formatting (EditorConfig)
|
||||||
|
- **Indentation**: 2 spaces (NO tabs)
|
||||||
|
- **Line endings**: LF (Unix-style)
|
||||||
|
- **Encoding**: UTF-8
|
||||||
|
- **Final newline**: required
|
||||||
|
- **Trailing whitespace**: trimmed (except in .md files)
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
- **Strict mode enabled** (`tsconfig.json`): all strict checks ON
|
||||||
|
- **Path aliases**: use `@/` for `src/` (e.g., `import { useAuthStore } from '@/stores/auth'`)
|
||||||
|
- **Type annotations**: explicit return types for public functions/composables
|
||||||
|
- **Type definitions**: place shared types in `src/types/`, domain-specific types near usage
|
||||||
|
- **No type suppression**: NEVER use `as any`, `@ts-ignore`, or `@ts-expect-error`
|
||||||
|
|
||||||
|
### Vue Component Style
|
||||||
|
**Component naming:**
|
||||||
|
- **Files**: PascalCase for reusable components (`NotificationPanel.vue`, `ThemeToggle.vue`)
|
||||||
|
- **Views**: route-based folders with `index.vue` (`src/views/dashboard/index.vue`)
|
||||||
|
|
||||||
|
**Component structure (Composition API only):**
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<!-- Template using script setup's reactive state -->
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import type { User } from '@/types/auth'
|
||||||
|
|
||||||
|
// Props
|
||||||
|
interface Props {
|
||||||
|
userId: string
|
||||||
|
mode?: 'edit' | 'view'
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
mode: 'view'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits<{
|
||||||
|
save: [user: User]
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// State
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const loading = ref(false)
|
||||||
|
const user = computed(() => authStore.user)
|
||||||
|
|
||||||
|
// Methods (prefer explicit function declarations)
|
||||||
|
function handleSave() {
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Component-specific styles */
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Ordering
|
||||||
|
Group imports in this order (blank line between groups):
|
||||||
|
```ts
|
||||||
|
// 1. Vue core
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
// 2. Third-party libraries
|
||||||
|
import { message } from 'antdv-next'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
// 3. Project imports (@/ alias)
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { login, getUserInfo } from '@/api/auth'
|
||||||
|
import type { User, LoginParams } from '@/types/auth'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
| Type | Convention | Example |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| Components | PascalCase | `NotificationPanel.vue`, `TabBar.vue` |
|
||||||
|
| Composables | `useXxx.ts` | `usePermission.ts`, `useFullscreen.ts` |
|
||||||
|
| Stores | Domain-based | `auth.ts`, `permission.ts`, `theme.ts` |
|
||||||
|
| Types/Interfaces | PascalCase | `User`, `LoginParams`, `ApiResponse<T>` |
|
||||||
|
| Functions | camelCase | `getUserInfo()`, `checkPermission()` |
|
||||||
|
| Constants | SCREAMING_SNAKE_CASE | `TOKEN_KEY`, `API_BASE_URL` |
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- **Try/catch**: wrap all async operations with meaningful error messages
|
||||||
|
- **Axios interceptors**: global error handling in `src/utils/request.ts`
|
||||||
|
- **User feedback**: use `message.error()` or `notification.error()` from antdv-next
|
||||||
|
```ts
|
||||||
|
try {
|
||||||
|
const response = await getUserInfo()
|
||||||
|
// Success path
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user info:', error)
|
||||||
|
message.error('获取用户信息失败')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Management (Pinia)
|
||||||
|
- **Setup stores only** (NOT options API)
|
||||||
|
- **One store per domain** - no god-objects
|
||||||
|
- **Store structure pattern:**
|
||||||
|
```ts
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
// State (ref)
|
||||||
|
const token = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Getters (computed)
|
||||||
|
const isLoggedIn = computed(() => !!token.value)
|
||||||
|
|
||||||
|
// Actions (functions)
|
||||||
|
const setToken = (newToken: string | null) => {
|
||||||
|
token.value = newToken
|
||||||
|
}
|
||||||
|
|
||||||
|
return { token, isLoggedIn, setToken }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission System Usage
|
||||||
|
**Directive (in templates):**
|
||||||
|
```vue
|
||||||
|
<!-- Single permission (OR logic by default) -->
|
||||||
|
<a-button v-permission="'user.create'">Create</a-button>
|
||||||
|
|
||||||
|
<!-- Multiple permissions (OR logic) -->
|
||||||
|
<a-button v-permission="['user.edit', 'user.delete']">Actions</a-button>
|
||||||
|
|
||||||
|
<!-- ALL permissions required (AND logic) -->
|
||||||
|
<a-button v-permission.all="['user.edit', 'user.approve']">Approve</a-button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Composable (in script):**
|
||||||
|
```ts
|
||||||
|
const { can, canAll, hasRole } = usePermission()
|
||||||
|
|
||||||
|
if (can('user.create')) {
|
||||||
|
// User has permission
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canAll(['user.edit', 'user.approve'])) {
|
||||||
|
// User has ALL permissions
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration & Environment
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
- **Development** (`.env.development`): `VITE_USE_MOCK=true`, `VITE_API_BASE_URL=/api`
|
||||||
|
- **Production** (`.env.production`): `VITE_USE_MOCK=false`, set real API URL
|
||||||
|
- **Never commit secrets** - use `.env.local` for sensitive values (gitignored)
|
||||||
|
|
||||||
|
### Mock API System
|
||||||
|
- **Auto-enabled in dev** via `vite-plugin-mock-dev-server`
|
||||||
|
- **Handlers**: `mock/handlers/*.mock.ts` define endpoints
|
||||||
|
- **Data**: `mock/data/*.data.ts` contain sample datasets
|
||||||
|
- **Prefix**: all mock APIs use `/api` prefix (e.g., `/api/auth/login`)
|
||||||
|
|
||||||
|
## Common Pitfalls to Avoid
|
||||||
|
|
||||||
|
1. **No linter configured** - manually match nearby code style
|
||||||
|
2. **Don't suppress TypeScript errors** - fix the root cause instead
|
||||||
|
3. **Test files are templates** - don't try to run them without installing test frameworks
|
||||||
|
4. **Mock users**: `admin/123456` has full permissions, `user/123456` has limited permissions
|
||||||
|
5. **Dynamic routes**: permissions control route visibility via `src/router/guards.ts`
|
||||||
|
6. **KeepAlive caching**: managed by `tabs` store - check cached component names
|
||||||
|
|
||||||
|
## Commit Guidelines
|
||||||
|
|
||||||
|
**Use Conventional Commits:**
|
||||||
|
```
|
||||||
|
type(scope): summary
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
feat(auth): add biometric login support
|
||||||
|
fix(permission): correct role-based route filtering
|
||||||
|
refactor(layout): extract sidebar menu logic to composable
|
||||||
|
docs(readme): update installation instructions
|
||||||
|
```
|
||||||
|
|
||||||
|
**Commit types:** `feat`, `fix`, `refactor`, `docs`, `style`, `test`, `chore`, `perf`
|
||||||
|
|
||||||
|
## Pull Request Checklist
|
||||||
|
|
||||||
|
- [ ] `npm run type-check` passes
|
||||||
|
- [ ] `npm run build` succeeds
|
||||||
|
- [ ] Manually tested login flow (if auth-related)
|
||||||
|
- [ ] Manually verified permissions (if RBAC-related)
|
||||||
|
- [ ] Screenshots/GIFs included (for UI changes)
|
||||||
|
- [ ] Commit messages follow Conventional Commits
|
||||||
|
- [ ] Changes are scoped (no unrelated refactors mixed in)
|
||||||
343
antdv-next-admin/CLAUDE.md
Normal file
343
antdv-next-admin/CLAUDE.md
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**antdv-next-admin** is a modern Vue 3 + TypeScript admin scaffold built on:
|
||||||
|
- **antdv-next** (Ant Design Vue) - UI component library
|
||||||
|
- **Pinia** - State management
|
||||||
|
- **Vue Router** - Routing with dynamic route generation
|
||||||
|
- **vue-i18n** - Internationalization (Chinese/English)
|
||||||
|
- **Vite** - Build tool
|
||||||
|
- Full RBAC permission system with dynamic routes
|
||||||
|
- Mock data system for development
|
||||||
|
|
||||||
|
## Environment Requirements
|
||||||
|
|
||||||
|
- Node.js >= 16
|
||||||
|
- npm >= 8
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
npm run dev # Start dev server on http://localhost:3000
|
||||||
|
|
||||||
|
# Building
|
||||||
|
npm run build # Production build
|
||||||
|
npm run build:check # Type check before build
|
||||||
|
npm run preview # Preview production build
|
||||||
|
|
||||||
|
# Type Checking
|
||||||
|
npm run type-check # TypeScript type checking
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: This project currently has no test or lint scripts configured.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### State Management (Pinia Stores)
|
||||||
|
|
||||||
|
All stores use the **setup syntax** pattern. Located in `src/stores/`:
|
||||||
|
|
||||||
|
- **auth** - Authentication, user info, roles, permissions. Includes demo mode for development.
|
||||||
|
- **permission** - Dynamic route generation based on user roles/permissions
|
||||||
|
- **theme** - Theme mode (light/dark/auto), primary color (6 presets), CSS variable management
|
||||||
|
- **layout** - Layout mode (vertical/horizontal), sidebar state, mobile detection
|
||||||
|
- **tabs** - Multi-tab system with KeepAlive caching, affix tabs, right-click menu
|
||||||
|
- **settings** - User preferences (animations, gray mode, menu theme, etc.)
|
||||||
|
- **notification** - Notification panel state
|
||||||
|
|
||||||
|
**Key Pattern**: Store initialization happens in router guards. Auth store includes both demo mode (mock) and production mode (real API) login flows.
|
||||||
|
|
||||||
|
### Routing System
|
||||||
|
|
||||||
|
Routes are organized in three categories (`src/router/routes.ts`):
|
||||||
|
|
||||||
|
1. **staticRoutes** - No auth required (login, error pages)
|
||||||
|
2. **basicRoutes** - Require auth (dashboard, profile)
|
||||||
|
3. **asyncRoutes** - Require specific permissions (system management, etc.)
|
||||||
|
|
||||||
|
**Dynamic Route Generation**:
|
||||||
|
- Routes are generated in `src/router/guards.ts` during navigation
|
||||||
|
- Permission store (`generateRoutes`) filters async routes based on user roles/permissions
|
||||||
|
- Routes are added dynamically with `router.addRoute()` after successful login
|
||||||
|
- First navigation to dynamic route may redirect to 404, then recover by regenerating routes
|
||||||
|
|
||||||
|
**Route Meta Fields**:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
title: string // i18n key for page title
|
||||||
|
icon?: string // Icon component name (e.g., 'DashboardOutlined')
|
||||||
|
requiresAuth?: boolean // Default true
|
||||||
|
requiredPermissions?: string[] // Permission codes required
|
||||||
|
requiredRoles?: string[] // Role codes required
|
||||||
|
hidden?: boolean // Hide from menu
|
||||||
|
affix?: boolean // Pin tab (can't be closed)
|
||||||
|
order?: number // Menu sort order
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission System
|
||||||
|
|
||||||
|
Three ways to check permissions:
|
||||||
|
|
||||||
|
1. **Directive** (`src/directives/permission.ts`):
|
||||||
|
```vue
|
||||||
|
<a-button v-permission="'user.create'">Create</a-button>
|
||||||
|
<a-button v-permission="['user.edit', 'user.delete']">Actions</a-button>
|
||||||
|
<a-button v-permission.all="['user.edit', 'user.approve']">Approve</a-button>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Composable** (`src/composables/usePermission.ts`):
|
||||||
|
```typescript
|
||||||
|
const { can, canAll } = usePermission()
|
||||||
|
if (can('user.create')) { /* ... */ }
|
||||||
|
if (canAll(['user.edit', 'user.approve'])) { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Component** (`src/components/Permission/PermissionButton.vue`):
|
||||||
|
```vue
|
||||||
|
<PermissionButton permission="user.create">
|
||||||
|
<a-button>Create User</a-button>
|
||||||
|
</PermissionButton>
|
||||||
|
```
|
||||||
|
|
||||||
|
### API & Request Handling
|
||||||
|
|
||||||
|
**Base Service**: `src/utils/request.ts` - Axios instance with interceptors
|
||||||
|
|
||||||
|
- Auto-adds Bearer token from auth store
|
||||||
|
- Handles 401 (logout + redirect to login), 403 (forbidden), 404, 500
|
||||||
|
- Response interceptor checks `res.code` field (expects 200)
|
||||||
|
- All API calls use the `request` helper with typed responses
|
||||||
|
|
||||||
|
**Mock System** (`mock/` directory):
|
||||||
|
- Enabled via `VITE_USE_MOCK=true` in `.env.development`
|
||||||
|
- Two-layer structure: `data/` (mock data sources) + `handlers/` (request handlers)
|
||||||
|
- Available mock APIs: auth, users, roles, permissions, dashboard
|
||||||
|
- Supports pagination, search, CRUD operations
|
||||||
|
|
||||||
|
### Pro Components
|
||||||
|
|
||||||
|
**ProTable** (`src/components/Pro/ProTable/`):
|
||||||
|
- Configuration-based table with search form, toolbar, pagination
|
||||||
|
- Column types defined via `ProTableColumn` interface (see `src/types/pro.ts`)
|
||||||
|
- Supports value types: text, date, dateTime, tag, badge, money, percent, avatar, etc.
|
||||||
|
- Search types: input, select, dateRange, datePicker, etc.
|
||||||
|
- Built-in features: column resizing, fixed headers, sorting, actions column
|
||||||
|
- **Important**: Uses CSS variables for scrollbar width alignment (see scrollbar.ts utility)
|
||||||
|
- Two rendering modes:
|
||||||
|
- `scroll-mode`: Table handles its own scrolling
|
||||||
|
- `fill-mode`: Parent container scrolls, table fills height with `fixedHeader`
|
||||||
|
|
||||||
|
**ProForm** (`src/components/Pro/ProForm/`):
|
||||||
|
- Configuration-based form with validation
|
||||||
|
- Form item types: input, password, textarea, number, select, radio, checkbox, switch, datePicker, etc.
|
||||||
|
- Grid layout support with `colSpan` and responsive `cols`
|
||||||
|
- Dynamic options via `request` function
|
||||||
|
- Custom rendering via `render` prop
|
||||||
|
|
||||||
|
**Type Definitions**: Always reference `src/types/pro.ts` for column/form configurations.
|
||||||
|
|
||||||
|
### Icons
|
||||||
|
|
||||||
|
Two icon systems are available:
|
||||||
|
|
||||||
|
1. **Ant Design Icons** (`@antdv-next/icons`):
|
||||||
|
```vue
|
||||||
|
import { UserOutlined } from '@antdv-next/icons'
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Iconify** (`@iconify/vue`):
|
||||||
|
- Component: `src/components/Icon/index.vue`
|
||||||
|
- Picker: `src/components/IconPicker/index.vue`
|
||||||
|
- Supports online/offline modes with local icon sets (ion, mdi, ri)
|
||||||
|
- Use `<Icon icon="mdi:home" />` syntax
|
||||||
|
|
||||||
|
### Theme System
|
||||||
|
|
||||||
|
Themes use **CSS variables** defined in `src/assets/styles/variables.css`:
|
||||||
|
|
||||||
|
- 6 preset primary colors: blue (default), green, purple, red, orange, cyan
|
||||||
|
- Dark/light/auto modes
|
||||||
|
- CSS variables follow pattern: `--ant-primary-color`, `--bg-color`, `--text-color`, etc.
|
||||||
|
- Theme store dynamically updates CSS variables on document root
|
||||||
|
- Sidebar supports independent dark/light theme (via `--sidebar-bg-color` variables)
|
||||||
|
|
||||||
|
### Internationalization
|
||||||
|
|
||||||
|
**System**: vue-i18n with locale files in `src/locales/`
|
||||||
|
|
||||||
|
- `zh-CN.ts` - Chinese (default)
|
||||||
|
- `en-US.ts` - English
|
||||||
|
- Access via `$t('key')` in templates or `t('key')` from `useI18n()`
|
||||||
|
- Helper: `src/utils/i18n.ts` - `resolveLocaleText()` for dynamic text resolution
|
||||||
|
|
||||||
|
### Charts & Visualization
|
||||||
|
|
||||||
|
**ECharts Integration**: The project includes `echarts` and `vue-echarts` for data visualization in the dashboard. Use the `<v-chart>` component from `vue-echarts` for rendering charts.
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
- `Ctrl/Cmd + K` - Open global menu search
|
||||||
|
|
||||||
|
## Development Guidelines
|
||||||
|
|
||||||
|
### File Naming & Structure
|
||||||
|
|
||||||
|
- Components: **PascalCase** (e.g., `AdminLayout.vue`)
|
||||||
|
- Files: **kebab-case** (e.g., `use-permission.ts`)
|
||||||
|
- Path alias: Use `@/` for `src/` (configured in vite.config.ts and tsconfig.json)
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
|
||||||
|
- **Strict mode enabled** - All code must be type-safe
|
||||||
|
- Types organized in `src/types/`: api.ts, auth.ts, router.ts, layout.ts, pro.ts
|
||||||
|
- Use `type` for object shapes, `interface` for extensible contracts
|
||||||
|
- Route type: `AppRouteRecordRaw` (extends Vue Router's `RouteRecordRaw`)
|
||||||
|
- API responses: `ApiResponse<T>` pattern
|
||||||
|
|
||||||
|
### Adding New Pages
|
||||||
|
|
||||||
|
1. Create view in `src/views/[module]/`
|
||||||
|
2. Add route to appropriate category in `src/router/routes.ts`
|
||||||
|
3. Add i18n keys to `src/locales/zh-CN.ts` and `en-US.ts`
|
||||||
|
4. If requires permissions, set `meta.requiredPermissions` or `meta.requiredRoles`
|
||||||
|
5. Router guards will handle dynamic route injection
|
||||||
|
|
||||||
|
### Adding New Stores
|
||||||
|
|
||||||
|
Follow the **setup syntax** pattern:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
export const useMyStore = defineStore('my-store', () => {
|
||||||
|
// State
|
||||||
|
const data = ref<MyType | null>(null)
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
const processedData = computed(() => /* ... */)
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const fetchData = async () => { /* ... */ }
|
||||||
|
|
||||||
|
return { data, processedData, fetchData }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Export from `src/stores/index.ts` for centralized access.
|
||||||
|
|
||||||
|
### Working with ProTable
|
||||||
|
|
||||||
|
Always define columns using the `ProTableColumn` type:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { ProTableColumn } from '@/types/pro'
|
||||||
|
|
||||||
|
const columns: ProTableColumn[] = [
|
||||||
|
{
|
||||||
|
title: 'Name',
|
||||||
|
dataIndex: 'name',
|
||||||
|
search: true, // Enable search
|
||||||
|
searchType: 'input', // Search field type
|
||||||
|
valueType: 'text' // Display type
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
dataIndex: 'status',
|
||||||
|
valueType: 'tag',
|
||||||
|
valueEnum: {
|
||||||
|
active: { text: 'Active', status: 'success' },
|
||||||
|
inactive: { text: 'Inactive', status: 'default' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Known Issue**: ProTable had scrollbar alignment bugs that were fixed by:
|
||||||
|
- Using CSS variables for dynamic scrollbar width (`--actual-scrollbar-width`)
|
||||||
|
- Scrollbar detection utility in `src/utils/scrollbar.ts`
|
||||||
|
- When modifying table layout, verify scrollbar placeholder alignment
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
**Demo Mode** (development):
|
||||||
|
- Credentials: `admin/123456` or `user/123456`
|
||||||
|
- No real backend, uses mock data from auth store
|
||||||
|
- Token stored in localStorage
|
||||||
|
|
||||||
|
**Production Mode**:
|
||||||
|
- Set `VITE_USE_MOCK=false` and `VITE_API_BASE_URL` in `.env.production`
|
||||||
|
- Uses real API calls via `src/api/auth.ts`
|
||||||
|
|
||||||
|
### Mock Data Development
|
||||||
|
|
||||||
|
To add new mock endpoints:
|
||||||
|
|
||||||
|
1. Create data source in `mock/data/[entity].data.ts`
|
||||||
|
2. Create handler in `mock/handlers/[entity].mock.ts`
|
||||||
|
3. Mock server auto-reloads, accessible at `/api/*` prefix
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Conditional Rendering by Permission
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
if (authStore.hasAnyPermission(['user.edit', 'user.delete'])) {
|
||||||
|
// Show actions
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessing Current Route in Components
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
const route = useRoute()
|
||||||
|
console.log(route.meta.title)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Tab Operations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useTabsStore } from '@/stores/tabs'
|
||||||
|
const tabsStore = useTabsStore()
|
||||||
|
|
||||||
|
tabsStore.closeTab(path) // Close specific tab
|
||||||
|
tabsStore.closeOtherTabs(path) // Close all except this
|
||||||
|
tabsStore.closeAllTabs() // Close all closeable tabs
|
||||||
|
tabsStore.refreshTab(path) // Refresh tab content
|
||||||
|
```
|
||||||
|
|
||||||
|
### Theme Changes
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
|
||||||
|
themeStore.setThemeMode('dark') // 'light' | 'dark' | 'auto'
|
||||||
|
themeStore.setPrimaryColor('#1890ff') // Any valid color
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- **Never commit** environment-specific values to `.env` files
|
||||||
|
- **Router guards** handle most auth/permission logic - avoid duplicating checks
|
||||||
|
- **CSS variables** are the preferred method for theming - avoid hardcoded colors
|
||||||
|
- **ProTable fixedHeader mode** requires parent container with fixed height
|
||||||
|
- **Dynamic routes** are regenerated on each login - changes to `asyncRoutes` require re-login
|
||||||
|
- **Tabs state** persists in localStorage via settings store
|
||||||
|
- **Mock mode** is determined by `VITE_USE_MOCK` env variable, checked at runtime
|
||||||
|
|
||||||
|
## Default Accounts
|
||||||
|
|
||||||
|
Development mode credentials:
|
||||||
|
|
||||||
|
- Admin: `admin / 123456`
|
||||||
|
- User: `user / 123456`
|
||||||
93
antdv-next-admin/DEPLOYMENT.md
Normal file
93
antdv-next-admin/DEPLOYMENT.md
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
# GitHub Pages 部署指南
|
||||||
|
|
||||||
|
本项目已配置为可自动部署到 GitHub Pages。
|
||||||
|
|
||||||
|
## 📦 部署地址
|
||||||
|
|
||||||
|
- **生产环境**: https://yelog.github.io/antdv-next-admin/
|
||||||
|
|
||||||
|
## 🚀 自动部署
|
||||||
|
|
||||||
|
项目使用 GitHub Actions 实现自动化部署:
|
||||||
|
|
||||||
|
1. 当代码推送到 `main` 分支时,会自动触发部署流程
|
||||||
|
2. GitHub Actions 会自动构建项目并部署到 GitHub Pages
|
||||||
|
3. 部署完成后,可以通过上述地址访问
|
||||||
|
|
||||||
|
## ⚙️ 配置说明
|
||||||
|
|
||||||
|
### 1. Vite 配置
|
||||||
|
```typescript
|
||||||
|
// vite.config.ts
|
||||||
|
base: process.env.NODE_ENV === 'production' ? '/antdv-next-admin/' : '/'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. GitHub Actions
|
||||||
|
- 工作流文件: `.github/workflows/deploy.yml`
|
||||||
|
- 触发条件: 推送到 main 分支或手动触发
|
||||||
|
- 构建命令: `npm run build`
|
||||||
|
|
||||||
|
### 3. SPA 路由支持
|
||||||
|
- `public/404.html`: 处理 404 重定向
|
||||||
|
- `index.html`: 接收重定向并恢复路由
|
||||||
|
- `public/.nojekyll`: 禁用 Jekyll 处理
|
||||||
|
|
||||||
|
## 📝 手动部署步骤
|
||||||
|
|
||||||
|
如果需要手动部署:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 构建项目
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 2. 进入构建目录
|
||||||
|
cd dist
|
||||||
|
|
||||||
|
# 3. 初始化 git 仓库
|
||||||
|
git init
|
||||||
|
git add -A
|
||||||
|
git commit -m 'deploy'
|
||||||
|
|
||||||
|
# 4. 推送到 gh-pages 分支
|
||||||
|
git push -f git@github.com:yelog/antdv-next-admin.git main:gh-pages
|
||||||
|
|
||||||
|
# 5. 返回项目根目录
|
||||||
|
cd -
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 GitHub 仓库设置
|
||||||
|
|
||||||
|
确保在 GitHub 仓库设置中:
|
||||||
|
|
||||||
|
1. 进入仓库 Settings → Pages
|
||||||
|
2. Source 选择 "GitHub Actions"
|
||||||
|
3. 等待首次部署完成
|
||||||
|
|
||||||
|
## 📊 查看部署状态
|
||||||
|
|
||||||
|
- 在 GitHub 仓库的 "Actions" 标签页查看部署进度
|
||||||
|
- 绿色勾号表示部署成功
|
||||||
|
- 红色叉号表示部署失败,点击查看日志
|
||||||
|
|
||||||
|
## 🐛 常见问题
|
||||||
|
|
||||||
|
### 1. 404 错误
|
||||||
|
- 确保 `base` 配置正确
|
||||||
|
- 检查 GitHub Pages 设置是否正确
|
||||||
|
|
||||||
|
### 2. 路由不工作
|
||||||
|
- 确保 `404.html` 和 `index.html` 中的重定向脚本存在
|
||||||
|
- 检查浏览器控制台是否有错误
|
||||||
|
|
||||||
|
### 3. 样式/资源 404
|
||||||
|
- 确保 `base` 路径配置正确
|
||||||
|
- 检查构建后的资源路径是否正确
|
||||||
|
|
||||||
|
## 🔐 权限说明
|
||||||
|
|
||||||
|
GitHub Actions 需要以下权限:
|
||||||
|
- `contents: read` - 读取代码
|
||||||
|
- `pages: write` - 写入 Pages
|
||||||
|
- `id-token: write` - 身份验证
|
||||||
|
|
||||||
|
这些权限已在 workflow 文件中配置。
|
||||||
21
antdv-next-admin/LICENSE
Normal file
21
antdv-next-admin/LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 yelog
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
BIN
antdv-next-admin/docs/images/screenshot.png
Normal file
BIN
antdv-next-admin/docs/images/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 730 KiB |
23
antdv-next-admin/index.html
Normal file
23
antdv-next-admin/index.html
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/png" href="/logo.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Antdv Next Admin</title>
|
||||||
|
<!-- GitHub Pages SPA 路由重定向处理 -->
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var redirect = sessionStorage.redirect;
|
||||||
|
delete sessionStorage.redirect;
|
||||||
|
if (redirect && redirect !== location.href) {
|
||||||
|
history.replaceState(null, null, redirect);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
219
antdv-next-admin/mock/data/config.data.ts
Normal file
219
antdv-next-admin/mock/data/config.data.ts
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
import type { SysConfig } from '@/types/config';
|
||||||
|
|
||||||
|
export const sysConfigs: SysConfig[] = [
|
||||||
|
// basic
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Site Name',
|
||||||
|
key: 'site.name',
|
||||||
|
value: 'Antdv Next Admin',
|
||||||
|
valueType: 'string',
|
||||||
|
group: 'basic',
|
||||||
|
description: 'System display name',
|
||||||
|
builtIn: true,
|
||||||
|
sort: 1,
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Site Description',
|
||||||
|
key: 'site.description',
|
||||||
|
value: 'Vue3 + Ant Design Vue Admin System',
|
||||||
|
valueType: 'string',
|
||||||
|
group: 'basic',
|
||||||
|
description: 'Site SEO description',
|
||||||
|
builtIn: true,
|
||||||
|
sort: 2,
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'Copyright',
|
||||||
|
key: 'site.copyright',
|
||||||
|
value: '© 2024 Antdv Next Admin',
|
||||||
|
valueType: 'string',
|
||||||
|
group: 'basic',
|
||||||
|
description: 'Footer copyright text',
|
||||||
|
builtIn: false,
|
||||||
|
sort: 3,
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
name: 'System Version',
|
||||||
|
key: 'site.version',
|
||||||
|
value: '1.0.0',
|
||||||
|
valueType: 'string',
|
||||||
|
group: 'basic',
|
||||||
|
description: 'Current system version',
|
||||||
|
builtIn: true,
|
||||||
|
sort: 4,
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
|
||||||
|
// security
|
||||||
|
{
|
||||||
|
id: '10',
|
||||||
|
name: 'Max Login Attempts',
|
||||||
|
key: 'security.maxLoginAttempts',
|
||||||
|
value: '5',
|
||||||
|
valueType: 'number',
|
||||||
|
group: 'security',
|
||||||
|
description: 'Lock account after N failed login attempts',
|
||||||
|
builtIn: true,
|
||||||
|
sort: 1,
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '11',
|
||||||
|
name: 'Lock Duration (min)',
|
||||||
|
key: 'security.lockDuration',
|
||||||
|
value: '30',
|
||||||
|
valueType: 'number',
|
||||||
|
group: 'security',
|
||||||
|
description: 'Account lock duration in minutes',
|
||||||
|
builtIn: true,
|
||||||
|
sort: 2,
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '12',
|
||||||
|
name: 'Token Expiry (hours)',
|
||||||
|
key: 'security.tokenExpiry',
|
||||||
|
value: '24',
|
||||||
|
valueType: 'number',
|
||||||
|
group: 'security',
|
||||||
|
description: 'Login token expiration time',
|
||||||
|
builtIn: true,
|
||||||
|
sort: 3,
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '13',
|
||||||
|
name: 'Enable Captcha',
|
||||||
|
key: 'security.captchaEnabled',
|
||||||
|
value: 'true',
|
||||||
|
valueType: 'boolean',
|
||||||
|
group: 'security',
|
||||||
|
description: 'Require captcha for login',
|
||||||
|
builtIn: false,
|
||||||
|
sort: 4,
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '14',
|
||||||
|
name: 'Min Password Length',
|
||||||
|
key: 'security.minPasswordLength',
|
||||||
|
value: '6',
|
||||||
|
valueType: 'number',
|
||||||
|
group: 'security',
|
||||||
|
description: 'Minimum password character count',
|
||||||
|
builtIn: true,
|
||||||
|
sort: 5,
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
|
||||||
|
// upload
|
||||||
|
{
|
||||||
|
id: '20',
|
||||||
|
name: 'Max Upload Size (MB)',
|
||||||
|
key: 'upload.maxSize',
|
||||||
|
value: '10',
|
||||||
|
valueType: 'number',
|
||||||
|
group: 'upload',
|
||||||
|
description: 'Maximum single file upload size',
|
||||||
|
builtIn: true,
|
||||||
|
sort: 1,
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '21',
|
||||||
|
name: 'Allowed Upload Types',
|
||||||
|
key: 'upload.allowedTypes',
|
||||||
|
value: 'jpg,jpeg,png,gif,pdf,doc,docx,xls,xlsx,zip',
|
||||||
|
valueType: 'string',
|
||||||
|
group: 'upload',
|
||||||
|
description: 'Allowed file extensions, comma separated',
|
||||||
|
builtIn: true,
|
||||||
|
sort: 2,
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '22',
|
||||||
|
name: 'Image Compression Quality',
|
||||||
|
key: 'upload.imageQuality',
|
||||||
|
value: '80',
|
||||||
|
valueType: 'number',
|
||||||
|
group: 'upload',
|
||||||
|
description: 'Auto compression quality for uploaded images (0-100)',
|
||||||
|
builtIn: false,
|
||||||
|
sort: 3,
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
|
||||||
|
// notification
|
||||||
|
{
|
||||||
|
id: '30',
|
||||||
|
name: 'Enable Email Notification',
|
||||||
|
key: 'notify.emailEnabled',
|
||||||
|
value: 'true',
|
||||||
|
valueType: 'boolean',
|
||||||
|
group: 'notification',
|
||||||
|
description: 'Enable email notification feature',
|
||||||
|
builtIn: false,
|
||||||
|
sort: 1,
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '31',
|
||||||
|
name: 'SMTP Server',
|
||||||
|
key: 'notify.smtpHost',
|
||||||
|
value: 'smtp.example.com',
|
||||||
|
valueType: 'string',
|
||||||
|
group: 'notification',
|
||||||
|
description: 'SMTP server address for sending emails',
|
||||||
|
builtIn: false,
|
||||||
|
sort: 2,
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '32',
|
||||||
|
name: 'SMTP Port',
|
||||||
|
key: 'notify.smtpPort',
|
||||||
|
value: '465',
|
||||||
|
valueType: 'number',
|
||||||
|
group: 'notification',
|
||||||
|
description: 'SMTP server port',
|
||||||
|
builtIn: false,
|
||||||
|
sort: 3,
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '33',
|
||||||
|
name: 'Sender Email',
|
||||||
|
key: 'notify.senderEmail',
|
||||||
|
value: 'noreply@example.com',
|
||||||
|
valueType: 'string',
|
||||||
|
group: 'notification',
|
||||||
|
description: 'System notification sender email',
|
||||||
|
builtIn: false,
|
||||||
|
sort: 4,
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
];
|
||||||
100
antdv-next-admin/mock/data/dashboard.data.ts
Normal file
100
antdv-next-admin/mock/data/dashboard.data.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
|
||||||
|
// Statistics data
|
||||||
|
export const mockStats = {
|
||||||
|
totalUsers: 12458,
|
||||||
|
totalOrders: 8946,
|
||||||
|
totalRevenue: 456789.56,
|
||||||
|
conversionRate: 3.24,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sales trend data (last 12 months)
|
||||||
|
export const mockSalesTrend = Array.from({ length: 12 }, (_, index) => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setMonth(date.getMonth() - (11 - index));
|
||||||
|
return {
|
||||||
|
month: date.toLocaleDateString('en-US', { year: 'numeric', month: '2-digit' }),
|
||||||
|
sales: faker.number.int({ min: 20000, max: 80000 }),
|
||||||
|
orders: faker.number.int({ min: 500, max: 2000 }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// User distribution by city
|
||||||
|
export const mockUserDistribution = [
|
||||||
|
{ city: 'Beijing', value: 2341 },
|
||||||
|
{ city: 'Shanghai', value: 2156 },
|
||||||
|
{ city: 'Guangzhou', value: 1876 },
|
||||||
|
{ city: 'Shenzhen', value: 1654 },
|
||||||
|
{ city: 'Hangzhou', value: 1432 },
|
||||||
|
{ city: 'Chengdu', value: 1289 },
|
||||||
|
{ city: 'Other', value: 2710 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Recent activities
|
||||||
|
export const mockActivities = Array.from({ length: 10 }, (_, index) => ({
|
||||||
|
id: faker.string.uuid(),
|
||||||
|
user: faker.person.fullName(),
|
||||||
|
avatar: faker.image.avatar(),
|
||||||
|
action: faker.helpers.arrayElement([
|
||||||
|
'Created a new user',
|
||||||
|
'Updated role permissions',
|
||||||
|
'Deleted expired data',
|
||||||
|
'Exported reports',
|
||||||
|
'Updated system settings',
|
||||||
|
'Uploaded a new file',
|
||||||
|
]),
|
||||||
|
timestamp: faker.date.recent({ days: 7 }).toISOString(),
|
||||||
|
type: faker.helpers.arrayElement(['success', 'info', 'warning', 'error']),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Chart data for different visualizations
|
||||||
|
export const mockChartData = {
|
||||||
|
// Line chart - Sales trend
|
||||||
|
lineChart: {
|
||||||
|
xAxis: mockSalesTrend.map((item) => item.month),
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'Sales',
|
||||||
|
data: mockSalesTrend.map((item) => item.sales),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Orders',
|
||||||
|
data: mockSalesTrend.map((item) => item.orders),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Bar chart - Monthly comparison
|
||||||
|
barChart: {
|
||||||
|
xAxis: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'Current Year',
|
||||||
|
data: [820, 932, 901, 934, 1290, 1330],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Last Year',
|
||||||
|
data: [720, 832, 801, 834, 1190, 1230],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pie chart - User distribution
|
||||||
|
pieChart: {
|
||||||
|
data: mockUserDistribution.map((item) => ({
|
||||||
|
name: item.city,
|
||||||
|
value: item.value,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Area chart - Traffic trend
|
||||||
|
areaChart: {
|
||||||
|
xAxis: Array.from({ length: 24 }, (_, i) => `${i}:00`),
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'Visits',
|
||||||
|
data: Array.from({ length: 24 }, () => faker.number.int({ min: 100, max: 1000 })),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
224
antdv-next-admin/mock/data/dept.data.ts
Normal file
224
antdv-next-admin/mock/data/dept.data.ts
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
import type { Department } from '@/types/dept';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 部门数据(扁平结构,用于增删改查)
|
||||||
|
*/
|
||||||
|
export const departments: Department[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: '总公司',
|
||||||
|
parentId: null,
|
||||||
|
leader: '张总',
|
||||||
|
phone: '13800000001',
|
||||||
|
email: 'ceo@example.com',
|
||||||
|
sort: 1,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
// 一级部门
|
||||||
|
{
|
||||||
|
id: '10',
|
||||||
|
name: '技术研发部',
|
||||||
|
parentId: '1',
|
||||||
|
leader: '李工',
|
||||||
|
phone: '13800000010',
|
||||||
|
email: 'tech@example.com',
|
||||||
|
sort: 1,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '11',
|
||||||
|
name: '产品设计部',
|
||||||
|
parentId: '1',
|
||||||
|
leader: '王设计',
|
||||||
|
phone: '13800000011',
|
||||||
|
email: 'design@example.com',
|
||||||
|
sort: 2,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '12',
|
||||||
|
name: '市场营销部',
|
||||||
|
parentId: '1',
|
||||||
|
leader: '赵市场',
|
||||||
|
phone: '13800000012',
|
||||||
|
email: 'market@example.com',
|
||||||
|
sort: 3,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '13',
|
||||||
|
name: '人力资源部',
|
||||||
|
parentId: '1',
|
||||||
|
leader: '孙HR',
|
||||||
|
phone: '13800000013',
|
||||||
|
email: 'hr@example.com',
|
||||||
|
sort: 4,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '14',
|
||||||
|
name: '财务部',
|
||||||
|
parentId: '1',
|
||||||
|
leader: '周财务',
|
||||||
|
phone: '13800000014',
|
||||||
|
email: 'finance@example.com',
|
||||||
|
sort: 5,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
// 二级部门 - 技术研发部下属
|
||||||
|
{
|
||||||
|
id: '101',
|
||||||
|
name: '前端开发组',
|
||||||
|
parentId: '10',
|
||||||
|
leader: '陈前端',
|
||||||
|
phone: '13800000101',
|
||||||
|
sort: 1,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '102',
|
||||||
|
name: '后端开发组',
|
||||||
|
parentId: '10',
|
||||||
|
leader: '刘后端',
|
||||||
|
phone: '13800000102',
|
||||||
|
sort: 2,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '103',
|
||||||
|
name: '测试组',
|
||||||
|
parentId: '10',
|
||||||
|
leader: '吴测试',
|
||||||
|
phone: '13800000103',
|
||||||
|
sort: 3,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '104',
|
||||||
|
name: '运维组',
|
||||||
|
parentId: '10',
|
||||||
|
leader: '郑运维',
|
||||||
|
phone: '13800000104',
|
||||||
|
sort: 4,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
// 二级部门 - 产品设计部下属
|
||||||
|
{
|
||||||
|
id: '111',
|
||||||
|
name: 'UI设计组',
|
||||||
|
parentId: '11',
|
||||||
|
leader: '钱UI',
|
||||||
|
phone: '13800000111',
|
||||||
|
sort: 1,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '112',
|
||||||
|
name: '产品策划组',
|
||||||
|
parentId: '11',
|
||||||
|
leader: '冯策划',
|
||||||
|
phone: '13800000112',
|
||||||
|
sort: 2,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
// 二级部门 - 市场营销部下属
|
||||||
|
{
|
||||||
|
id: '121',
|
||||||
|
name: '品牌推广组',
|
||||||
|
parentId: '12',
|
||||||
|
leader: '韩品牌',
|
||||||
|
phone: '13800000121',
|
||||||
|
sort: 1,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '122',
|
||||||
|
name: '渠道销售组',
|
||||||
|
parentId: '12',
|
||||||
|
leader: '杨销售',
|
||||||
|
phone: '13800000122',
|
||||||
|
sort: 2,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
// 二级部门 - 人力资源部下属
|
||||||
|
{
|
||||||
|
id: '131',
|
||||||
|
name: '招聘组',
|
||||||
|
parentId: '13',
|
||||||
|
leader: '朱招聘',
|
||||||
|
sort: 1,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-01-01 00:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '132',
|
||||||
|
name: '培训组',
|
||||||
|
parentId: '13',
|
||||||
|
leader: '秦培训',
|
||||||
|
sort: 2,
|
||||||
|
status: 'disabled',
|
||||||
|
remark: '暂停运营',
|
||||||
|
createTime: '2024-01-01 00:00:00',
|
||||||
|
updateTime: '2024-06-01 00:00:00',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将扁平数据构建为树形结构
|
||||||
|
*/
|
||||||
|
export function buildDeptTree(list: Department[]): Department[] {
|
||||||
|
const map = new Map<string, Department>();
|
||||||
|
const roots: Department[] = [];
|
||||||
|
|
||||||
|
list.forEach((item) => {
|
||||||
|
map.set(item.id, { ...item, children: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
map.forEach((item) => {
|
||||||
|
if (item.parentId && map.has(item.parentId)) {
|
||||||
|
map.get(item.parentId)!.children!.push(item);
|
||||||
|
} else if (!item.parentId) {
|
||||||
|
roots.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 递归排序
|
||||||
|
const sortTree = (nodes: Department[]) => {
|
||||||
|
nodes.sort((a, b) => a.sort - b.sort);
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
if (n.children?.length) sortTree(n.children);
|
||||||
|
else delete n.children;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
sortTree(roots);
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
233
antdv-next-admin/mock/data/dict.data.ts
Normal file
233
antdv-next-admin/mock/data/dict.data.ts
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
import type { DictType, DictData } from '@/types/dict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字典类型数据
|
||||||
|
*/
|
||||||
|
export const dictTypes: DictType[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: '用户状态',
|
||||||
|
code: 'user_status',
|
||||||
|
description: '用户账号状态',
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: '性别',
|
||||||
|
code: 'gender',
|
||||||
|
description: '用户性别',
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: '订单状态',
|
||||||
|
code: 'order_status',
|
||||||
|
description: '订单状态',
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
name: '支付方式',
|
||||||
|
code: 'payment_method',
|
||||||
|
description: '支付方式',
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
name: '通知类型',
|
||||||
|
code: 'notification_type',
|
||||||
|
description: '系统通知类型',
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字典数据
|
||||||
|
*/
|
||||||
|
export const dictData: DictData[] = [
|
||||||
|
// 用户状态
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
typeCode: 'user_status',
|
||||||
|
label: '正常',
|
||||||
|
value: 'active',
|
||||||
|
sort: 1,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
typeCode: 'user_status',
|
||||||
|
label: '禁用',
|
||||||
|
value: 'inactive',
|
||||||
|
sort: 2,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
typeCode: 'user_status',
|
||||||
|
label: '锁定',
|
||||||
|
value: 'locked',
|
||||||
|
sort: 3,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
// 性别
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
typeCode: 'gender',
|
||||||
|
label: '男',
|
||||||
|
value: 'male',
|
||||||
|
sort: 1,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
typeCode: 'gender',
|
||||||
|
label: '女',
|
||||||
|
value: 'female',
|
||||||
|
sort: 2,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
typeCode: 'gender',
|
||||||
|
label: '未知',
|
||||||
|
value: 'unknown',
|
||||||
|
sort: 3,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
// 订单状态
|
||||||
|
{
|
||||||
|
id: '7',
|
||||||
|
typeCode: 'order_status',
|
||||||
|
label: '待支付',
|
||||||
|
value: 'pending',
|
||||||
|
sort: 1,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '8',
|
||||||
|
typeCode: 'order_status',
|
||||||
|
label: '已支付',
|
||||||
|
value: 'paid',
|
||||||
|
sort: 2,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '9',
|
||||||
|
typeCode: 'order_status',
|
||||||
|
label: '配送中',
|
||||||
|
value: 'shipping',
|
||||||
|
sort: 3,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '10',
|
||||||
|
typeCode: 'order_status',
|
||||||
|
label: '已完成',
|
||||||
|
value: 'completed',
|
||||||
|
sort: 4,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '11',
|
||||||
|
typeCode: 'order_status',
|
||||||
|
label: '已取消',
|
||||||
|
value: 'cancelled',
|
||||||
|
sort: 5,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
// 支付方式
|
||||||
|
{
|
||||||
|
id: '12',
|
||||||
|
typeCode: 'payment_method',
|
||||||
|
label: '支付宝',
|
||||||
|
value: 'alipay',
|
||||||
|
sort: 1,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '13',
|
||||||
|
typeCode: 'payment_method',
|
||||||
|
label: '微信支付',
|
||||||
|
value: 'wechat',
|
||||||
|
sort: 2,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '14',
|
||||||
|
typeCode: 'payment_method',
|
||||||
|
label: '银行卡',
|
||||||
|
value: 'bank_card',
|
||||||
|
sort: 3,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
// 通知类型
|
||||||
|
{
|
||||||
|
id: '15',
|
||||||
|
typeCode: 'notification_type',
|
||||||
|
label: '系统通知',
|
||||||
|
value: 'system',
|
||||||
|
sort: 1,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '16',
|
||||||
|
typeCode: 'notification_type',
|
||||||
|
label: '订单通知',
|
||||||
|
value: 'order',
|
||||||
|
sort: 2,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '17',
|
||||||
|
typeCode: 'notification_type',
|
||||||
|
label: '活动通知',
|
||||||
|
value: 'activity',
|
||||||
|
sort: 3,
|
||||||
|
status: 'enabled',
|
||||||
|
createTime: '2024-01-01 10:00:00',
|
||||||
|
updateTime: '2024-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
];
|
||||||
79
antdv-next-admin/mock/data/file.data.ts
Normal file
79
antdv-next-admin/mock/data/file.data.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import type { SysFile } from '@/types/file';
|
||||||
|
|
||||||
|
const exts = ['jpg', 'png', 'pdf', 'docx', 'xlsx', 'zip', 'mp4', 'txt', 'pptx', 'svg'];
|
||||||
|
const mimeMap: Record<string, string> = {
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
png: 'image/png',
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
zip: 'application/zip',
|
||||||
|
mp4: 'video/mp4',
|
||||||
|
txt: 'text/plain',
|
||||||
|
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
svg: 'image/svg+xml',
|
||||||
|
};
|
||||||
|
const storages: SysFile['storage'][] = ['local', 'oss', 'cos'];
|
||||||
|
const uploaders = ['admin', 'zhangsan', 'lisi', 'wangwu'];
|
||||||
|
const fileNames: Record<string, string[]> = {
|
||||||
|
jpg: ['产品封面图', '用户头像', '活动海报', '团队合照', '办公环境'],
|
||||||
|
png: ['系统Logo', '二维码', '图标素材', '截图', '水印模板'],
|
||||||
|
pdf: ['用户手册', '合同模板', '年度报告', '技术文档', '发票'],
|
||||||
|
docx: ['需求文档', '会议纪要', '工作总结', '项目方案', '操作指南'],
|
||||||
|
xlsx: ['员工花名册', '财务报表', '数据统计', '考勤记录', '库存清单'],
|
||||||
|
zip: ['项目源码', '资源包', '备份文件', '部署包', '日志归档'],
|
||||||
|
mp4: ['产品演示', '培训视频', '操作教程', '宣传片', '会议录像'],
|
||||||
|
txt: ['配置说明', '更新日志', '临时笔记', '导入模板', '错误日志'],
|
||||||
|
pptx: ['季度汇报', '产品介绍', '培训课件', '方案演示', '年终总结'],
|
||||||
|
svg: ['图标文件', '流程图', '架构图', '组织结构图', '数据图表'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function randomDate(start: string, end: string) {
|
||||||
|
const s = new Date(start).getTime();
|
||||||
|
const e = new Date(end).getTime();
|
||||||
|
const d = new Date(s + Math.random() * (e - s));
|
||||||
|
return d.toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomSize(ext: string): number {
|
||||||
|
const ranges: Record<string, [number, number]> = {
|
||||||
|
jpg: [50_000, 5_000_000],
|
||||||
|
png: [20_000, 3_000_000],
|
||||||
|
pdf: [100_000, 20_000_000],
|
||||||
|
docx: [30_000, 10_000_000],
|
||||||
|
xlsx: [20_000, 15_000_000],
|
||||||
|
zip: [500_000, 100_000_000],
|
||||||
|
mp4: [5_000_000, 500_000_000],
|
||||||
|
txt: [100, 500_000],
|
||||||
|
pptx: [200_000, 50_000_000],
|
||||||
|
svg: [1_000, 200_000],
|
||||||
|
};
|
||||||
|
const [min, max] = ranges[ext] || [1000, 1_000_000];
|
||||||
|
return Math.floor(min + Math.random() * (max - min));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sysFiles: SysFile[] = [];
|
||||||
|
|
||||||
|
let id = 1;
|
||||||
|
for (const ext of exts) {
|
||||||
|
const names = fileNames[ext];
|
||||||
|
for (const name of names) {
|
||||||
|
const storage = storages[Math.floor(Math.random() * storages.length)];
|
||||||
|
const originalName = `${name}.${ext}`;
|
||||||
|
sysFiles.push({
|
||||||
|
id: String(id++),
|
||||||
|
name: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`,
|
||||||
|
originalName,
|
||||||
|
path: `/uploads/${ext}/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`,
|
||||||
|
size: randomSize(ext),
|
||||||
|
mimeType: mimeMap[ext] || 'application/octet-stream',
|
||||||
|
ext,
|
||||||
|
storage,
|
||||||
|
uploader: uploaders[Math.floor(Math.random() * uploaders.length)],
|
||||||
|
remark: '',
|
||||||
|
createTime: randomDate('2024-01-01', '2024-12-31'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sysFiles.sort((a, b) => b.createTime.localeCompare(a.createTime));
|
||||||
128
antdv-next-admin/mock/data/log.data.ts
Normal file
128
antdv-next-admin/mock/data/log.data.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import type { OperationLog, LoginLog } from '@/types/log';
|
||||||
|
|
||||||
|
const modules = [
|
||||||
|
'userManagement',
|
||||||
|
'roleManagement',
|
||||||
|
'menuManagement',
|
||||||
|
'dictionary',
|
||||||
|
'systemLogin',
|
||||||
|
'profile',
|
||||||
|
'dashboard',
|
||||||
|
];
|
||||||
|
const actions: OperationLog['action'][] = [
|
||||||
|
'login',
|
||||||
|
'logout',
|
||||||
|
'create',
|
||||||
|
'update',
|
||||||
|
'delete',
|
||||||
|
'export',
|
||||||
|
'other',
|
||||||
|
];
|
||||||
|
const browsers = ['Chrome 120', 'Firefox 121', 'Safari 17', 'Edge 120'];
|
||||||
|
const osList = ['Windows 11', 'macOS 14', 'Ubuntu 22.04', 'iOS 17'];
|
||||||
|
const ips = [
|
||||||
|
'192.168.1.100',
|
||||||
|
'192.168.1.101',
|
||||||
|
'10.0.0.50',
|
||||||
|
'172.16.0.88',
|
||||||
|
'192.168.2.200',
|
||||||
|
'10.10.1.33',
|
||||||
|
];
|
||||||
|
const usernames = ['admin', 'user', 'zhangsan', 'lisi', 'wangwu'];
|
||||||
|
|
||||||
|
const actionDescMap: Record<string, string[]> = {
|
||||||
|
login: ['System login'],
|
||||||
|
logout: ['System logout'],
|
||||||
|
create: ['Create user', 'Create role', 'Create menu', 'Create dict type', 'Create dict data'],
|
||||||
|
update: [
|
||||||
|
'Update user info',
|
||||||
|
'Update role permissions',
|
||||||
|
'Update menu config',
|
||||||
|
'Update dict data',
|
||||||
|
'Update profile',
|
||||||
|
'Reset user password',
|
||||||
|
],
|
||||||
|
delete: ['Delete user', 'Delete role', 'Delete menu', 'Delete dict data'],
|
||||||
|
export: ['Export user list', 'Export role list', 'Export operation log'],
|
||||||
|
other: ['View dashboard', 'Refresh cache'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionUrlMap: Record<string, string[]> = {
|
||||||
|
login: ['/api/auth/login'],
|
||||||
|
logout: ['/api/auth/logout'],
|
||||||
|
create: ['/api/user', '/api/role', '/api/permission', '/api/dict/type', '/api/dict/data'],
|
||||||
|
update: ['/api/user/1', '/api/role/1', '/api/permission/1', '/api/dict/data/1', '/api/profile'],
|
||||||
|
delete: ['/api/user/1', '/api/role/1', '/api/permission/1', '/api/dict/data/1'],
|
||||||
|
export: ['/api/user/export', '/api/role/export', '/api/log/export'],
|
||||||
|
other: ['/api/dashboard/stats', '/api/cache/refresh'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function randomItem<T>(arr: T[]): T {
|
||||||
|
return arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTime(daysAgo: number): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - daysAgo);
|
||||||
|
d.setHours(Math.floor(Math.random() * 14) + 8);
|
||||||
|
d.setMinutes(Math.floor(Math.random() * 60));
|
||||||
|
d.setSeconds(Math.floor(Math.random() * 60));
|
||||||
|
return d.toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const operationLogs: OperationLog[] = [];
|
||||||
|
for (let i = 1; i <= 80; i++) {
|
||||||
|
const action = randomItem(actions);
|
||||||
|
const descs = actionDescMap[action];
|
||||||
|
const urls = actionUrlMap[action];
|
||||||
|
const isFail = Math.random() < 0.08;
|
||||||
|
operationLogs.push({
|
||||||
|
id: String(i),
|
||||||
|
username: randomItem(usernames),
|
||||||
|
module: randomItem(modules),
|
||||||
|
action,
|
||||||
|
description: randomItem(descs),
|
||||||
|
method:
|
||||||
|
action === 'create'
|
||||||
|
? 'POST'
|
||||||
|
: action === 'update'
|
||||||
|
? 'PUT'
|
||||||
|
: action === 'delete'
|
||||||
|
? 'DELETE'
|
||||||
|
: 'GET',
|
||||||
|
url: randomItem(urls),
|
||||||
|
ip: randomItem(ips),
|
||||||
|
browser: randomItem(browsers),
|
||||||
|
os: randomItem(osList),
|
||||||
|
status: isFail ? 'fail' : 'success',
|
||||||
|
errorMsg: isFail ? 'Insufficient permissions' : undefined,
|
||||||
|
duration: Math.floor(Math.random() * 500) + 10,
|
||||||
|
createTime: generateTime(Math.floor(i / 6)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
operationLogs.sort((a, b) => b.createTime.localeCompare(a.createTime));
|
||||||
|
|
||||||
|
export const loginLogs: LoginLog[] = [];
|
||||||
|
const loginMessages = [
|
||||||
|
'Login successful',
|
||||||
|
'Login successful',
|
||||||
|
'Login successful',
|
||||||
|
'Wrong password',
|
||||||
|
'Account locked',
|
||||||
|
'Captcha error',
|
||||||
|
];
|
||||||
|
for (let i = 1; i <= 50; i++) {
|
||||||
|
const msg = randomItem(loginMessages);
|
||||||
|
const isSuccess = msg === 'Login successful';
|
||||||
|
loginLogs.push({
|
||||||
|
id: String(i),
|
||||||
|
username: randomItem(usernames),
|
||||||
|
ip: randomItem(ips),
|
||||||
|
browser: randomItem(browsers),
|
||||||
|
os: randomItem(osList),
|
||||||
|
status: isSuccess ? 'success' : 'fail',
|
||||||
|
message: msg,
|
||||||
|
createTime: generateTime(Math.floor(i / 4)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
loginLogs.sort((a, b) => b.createTime.localeCompare(a.createTime));
|
||||||
607
antdv-next-admin/mock/data/permissions.data.ts
Normal file
607
antdv-next-admin/mock/data/permissions.data.ts
Normal file
@ -0,0 +1,607 @@
|
|||||||
|
import type { Permission } from '@/types/auth';
|
||||||
|
|
||||||
|
export const mockPermissions: Permission[] = [
|
||||||
|
// Dashboard Menu
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Dashboard',
|
||||||
|
code: 'dashboard.view',
|
||||||
|
description: 'Dashboard menu',
|
||||||
|
resource: '/dashboard',
|
||||||
|
action: 'view',
|
||||||
|
type: 'menu',
|
||||||
|
path: '/dashboard',
|
||||||
|
component: 'dashboard/index',
|
||||||
|
icon: 'DashboardOutlined',
|
||||||
|
sort: 1,
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Organization & Permissions Menu
|
||||||
|
{
|
||||||
|
id: '60',
|
||||||
|
name: 'Organization & Permissions',
|
||||||
|
code: 'organization.menu',
|
||||||
|
description: 'Organization and permissions root menu',
|
||||||
|
resource: '/organization',
|
||||||
|
action: '*',
|
||||||
|
type: 'menu',
|
||||||
|
path: '/organization',
|
||||||
|
component: 'Layout',
|
||||||
|
icon: 'TeamOutlined',
|
||||||
|
sort: 2,
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '50',
|
||||||
|
name: 'Department Management',
|
||||||
|
code: 'system.dept.view',
|
||||||
|
description: 'Department management menu',
|
||||||
|
resource: '/organization/dept',
|
||||||
|
action: 'view',
|
||||||
|
type: 'menu',
|
||||||
|
parentId: '60',
|
||||||
|
path: '/organization/dept',
|
||||||
|
component: 'system/dept/index',
|
||||||
|
icon: 'ApartmentOutlined',
|
||||||
|
sort: 1,
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '51',
|
||||||
|
name: 'Create Department',
|
||||||
|
code: 'system.dept.create',
|
||||||
|
description: 'Can create department',
|
||||||
|
resource: 'system.dept',
|
||||||
|
action: 'create',
|
||||||
|
type: 'button',
|
||||||
|
parentId: '50',
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '52',
|
||||||
|
name: 'Edit Department',
|
||||||
|
code: 'system.dept.edit',
|
||||||
|
description: 'Can edit department',
|
||||||
|
resource: 'system.dept',
|
||||||
|
action: 'edit',
|
||||||
|
type: 'button',
|
||||||
|
parentId: '50',
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '53',
|
||||||
|
name: 'Delete Department',
|
||||||
|
code: 'system.dept.delete',
|
||||||
|
description: 'Can delete department',
|
||||||
|
resource: 'system.dept',
|
||||||
|
action: 'delete',
|
||||||
|
type: 'button',
|
||||||
|
parentId: '50',
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '11',
|
||||||
|
name: 'User Management',
|
||||||
|
code: 'system.user.view',
|
||||||
|
description: 'User management menu',
|
||||||
|
resource: '/organization/user',
|
||||||
|
action: 'view',
|
||||||
|
type: 'menu',
|
||||||
|
parentId: '60',
|
||||||
|
path: '/organization/user',
|
||||||
|
component: 'system/user/index',
|
||||||
|
icon: 'UserOutlined',
|
||||||
|
sort: 2,
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '12',
|
||||||
|
name: 'Create User',
|
||||||
|
code: 'system.user.create',
|
||||||
|
description: 'Can create users',
|
||||||
|
resource: 'system.user',
|
||||||
|
action: 'create',
|
||||||
|
type: 'button',
|
||||||
|
parentId: '11',
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '13',
|
||||||
|
name: 'Edit User',
|
||||||
|
code: 'system.user.edit',
|
||||||
|
description: 'Can edit users',
|
||||||
|
resource: 'system.user',
|
||||||
|
action: 'edit',
|
||||||
|
type: 'button',
|
||||||
|
parentId: '11',
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '14',
|
||||||
|
name: 'Delete User',
|
||||||
|
code: 'system.user.delete',
|
||||||
|
description: 'Can delete users',
|
||||||
|
resource: 'system.user',
|
||||||
|
action: 'delete',
|
||||||
|
type: 'button',
|
||||||
|
parentId: '11',
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '20',
|
||||||
|
name: 'Role Management',
|
||||||
|
code: 'system.role.view',
|
||||||
|
description: 'Role management menu',
|
||||||
|
resource: '/organization/role',
|
||||||
|
action: 'view',
|
||||||
|
type: 'menu',
|
||||||
|
parentId: '60',
|
||||||
|
path: '/organization/role',
|
||||||
|
component: 'system/role/index',
|
||||||
|
icon: 'TeamOutlined',
|
||||||
|
sort: 3,
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '21',
|
||||||
|
name: 'Create Role',
|
||||||
|
code: 'system.role.create',
|
||||||
|
description: 'Can create roles',
|
||||||
|
resource: 'system.role',
|
||||||
|
action: 'create',
|
||||||
|
type: 'button',
|
||||||
|
parentId: '20',
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '22',
|
||||||
|
name: 'Edit Role',
|
||||||
|
code: 'system.role.edit',
|
||||||
|
description: 'Can edit roles',
|
||||||
|
resource: 'system.role',
|
||||||
|
action: 'edit',
|
||||||
|
type: 'button',
|
||||||
|
parentId: '20',
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '23',
|
||||||
|
name: 'Delete Role',
|
||||||
|
code: 'system.role.delete',
|
||||||
|
description: 'Can delete roles',
|
||||||
|
resource: 'system.role',
|
||||||
|
action: 'delete',
|
||||||
|
type: 'button',
|
||||||
|
parentId: '20',
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '30',
|
||||||
|
name: 'Menu Management',
|
||||||
|
code: 'system.permission.view',
|
||||||
|
description: 'Menu management menu',
|
||||||
|
resource: '/organization/permission',
|
||||||
|
action: 'view',
|
||||||
|
type: 'menu',
|
||||||
|
parentId: '60',
|
||||||
|
path: '/organization/permission',
|
||||||
|
component: 'system/permission/index',
|
||||||
|
icon: 'SafetyOutlined',
|
||||||
|
sort: 4,
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '31',
|
||||||
|
name: 'Create Menu',
|
||||||
|
code: 'system.permission.create',
|
||||||
|
description: 'Can create menu',
|
||||||
|
resource: 'system.permission',
|
||||||
|
action: 'create',
|
||||||
|
type: 'button',
|
||||||
|
parentId: '30',
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '32',
|
||||||
|
name: 'Edit Menu',
|
||||||
|
code: 'system.permission.edit',
|
||||||
|
description: 'Can edit menu',
|
||||||
|
resource: 'system.permission',
|
||||||
|
action: 'edit',
|
||||||
|
type: 'button',
|
||||||
|
parentId: '30',
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '33',
|
||||||
|
name: 'Delete Menu',
|
||||||
|
code: 'system.permission.delete',
|
||||||
|
description: 'Can delete menu',
|
||||||
|
resource: 'system.permission',
|
||||||
|
action: 'delete',
|
||||||
|
type: 'button',
|
||||||
|
parentId: '30',
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// System Management Menu
|
||||||
|
{
|
||||||
|
id: '10',
|
||||||
|
name: 'System Management',
|
||||||
|
code: 'system.menu',
|
||||||
|
description: 'System management root menu',
|
||||||
|
resource: '/system',
|
||||||
|
action: '*',
|
||||||
|
type: 'menu',
|
||||||
|
path: '/system',
|
||||||
|
component: 'Layout',
|
||||||
|
icon: 'SettingOutlined',
|
||||||
|
sort: 3,
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '54',
|
||||||
|
name: 'System Config',
|
||||||
|
code: 'system.config.view',
|
||||||
|
description: 'System config menu',
|
||||||
|
resource: '/system/config',
|
||||||
|
action: 'view',
|
||||||
|
type: 'menu',
|
||||||
|
parentId: '10',
|
||||||
|
path: '/system/config',
|
||||||
|
component: 'system/config/index',
|
||||||
|
icon: 'ControlOutlined',
|
||||||
|
sort: 1,
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '55',
|
||||||
|
name: 'Create Config',
|
||||||
|
code: 'system.config.create',
|
||||||
|
description: 'Can create config',
|
||||||
|
resource: 'system.config',
|
||||||
|
action: 'create',
|
||||||
|
type: 'button',
|
||||||
|
parentId: '54',
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '56',
|
||||||
|
name: 'Edit Config',
|
||||||
|
code: 'system.config.edit',
|
||||||
|
description: 'Can edit config',
|
||||||
|
resource: 'system.config',
|
||||||
|
action: 'edit',
|
||||||
|
type: 'button',
|
||||||
|
parentId: '54',
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '57',
|
||||||
|
name: 'Delete Config',
|
||||||
|
code: 'system.config.delete',
|
||||||
|
description: 'Can delete config',
|
||||||
|
resource: 'system.config',
|
||||||
|
action: 'delete',
|
||||||
|
type: 'button',
|
||||||
|
parentId: '54',
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '34',
|
||||||
|
name: 'Dictionary Management',
|
||||||
|
code: 'system.dict.view',
|
||||||
|
description: 'Dictionary management menu',
|
||||||
|
resource: '/system/dict',
|
||||||
|
action: 'view',
|
||||||
|
type: 'menu',
|
||||||
|
parentId: '10',
|
||||||
|
path: '/system/dict',
|
||||||
|
component: 'system/dict/index',
|
||||||
|
icon: 'BookOutlined',
|
||||||
|
sort: 2,
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '35',
|
||||||
|
name: 'Create Dictionary',
|
||||||
|
code: 'system.dict.create',
|
||||||
|
description: 'Can create dictionary',
|
||||||
|
resource: 'system.dict',
|
||||||
|
action: 'create',
|
||||||
|
type: 'button',
|
||||||
|
parentId: '34',
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '36',
|
||||||
|
name: 'Edit Dictionary',
|
||||||
|
code: 'system.dict.edit',
|
||||||
|
description: 'Can edit dictionary',
|
||||||
|
resource: 'system.dict',
|
||||||
|
action: 'edit',
|
||||||
|
type: 'button',
|
||||||
|
parentId: '34',
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '37',
|
||||||
|
name: 'Delete Dictionary',
|
||||||
|
code: 'system.dict.delete',
|
||||||
|
description: 'Can delete dictionary',
|
||||||
|
resource: 'system.dict',
|
||||||
|
action: 'delete',
|
||||||
|
type: 'button',
|
||||||
|
parentId: '34',
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '58',
|
||||||
|
name: 'File Management',
|
||||||
|
code: 'system.file.view',
|
||||||
|
description: 'File management menu',
|
||||||
|
resource: '/system/file',
|
||||||
|
action: 'view',
|
||||||
|
type: 'menu',
|
||||||
|
parentId: '10',
|
||||||
|
path: '/system/file',
|
||||||
|
component: 'system/file/index',
|
||||||
|
icon: 'FolderOutlined',
|
||||||
|
sort: 3,
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '59',
|
||||||
|
name: 'Delete File',
|
||||||
|
code: 'system.file.delete',
|
||||||
|
description: 'Can delete file',
|
||||||
|
resource: 'system.file',
|
||||||
|
action: 'delete',
|
||||||
|
type: 'button',
|
||||||
|
parentId: '58',
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '38',
|
||||||
|
name: 'System Log',
|
||||||
|
code: 'system.log.view',
|
||||||
|
description: 'System log menu',
|
||||||
|
resource: '/system/log',
|
||||||
|
action: 'view',
|
||||||
|
type: 'menu',
|
||||||
|
parentId: '10',
|
||||||
|
path: '/system/log',
|
||||||
|
component: 'system/log/index',
|
||||||
|
icon: 'FileTextOutlined',
|
||||||
|
sort: 4,
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '39',
|
||||||
|
name: 'Clear Log',
|
||||||
|
code: 'system.log.clear',
|
||||||
|
description: 'Can clear logs',
|
||||||
|
resource: 'system.log',
|
||||||
|
action: 'delete',
|
||||||
|
type: 'button',
|
||||||
|
parentId: '38',
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Examples Menu
|
||||||
|
{
|
||||||
|
id: '40',
|
||||||
|
name: 'Examples',
|
||||||
|
code: 'examples.menu',
|
||||||
|
description: 'Examples root menu',
|
||||||
|
resource: '/examples',
|
||||||
|
action: '*',
|
||||||
|
type: 'menu',
|
||||||
|
path: '/examples',
|
||||||
|
component: 'Layout',
|
||||||
|
icon: 'AppstoreOutlined',
|
||||||
|
sort: 4,
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '41',
|
||||||
|
name: 'Table Example',
|
||||||
|
code: 'examples.table.view',
|
||||||
|
description: 'Table example menu',
|
||||||
|
resource: '/examples/table',
|
||||||
|
action: 'view',
|
||||||
|
type: 'menu',
|
||||||
|
parentId: '40',
|
||||||
|
path: '/examples/table',
|
||||||
|
component: 'examples/table/index',
|
||||||
|
icon: 'TableOutlined',
|
||||||
|
sort: 1,
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '42',
|
||||||
|
name: 'Icon Example',
|
||||||
|
code: 'examples.icon.view',
|
||||||
|
description: 'Icon example menu',
|
||||||
|
resource: '/examples/icon',
|
||||||
|
action: 'view',
|
||||||
|
type: 'menu',
|
||||||
|
parentId: '40',
|
||||||
|
path: '/examples/icon',
|
||||||
|
component: 'examples/icon/index',
|
||||||
|
icon: 'SmileOutlined',
|
||||||
|
sort: 2,
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '43',
|
||||||
|
name: 'Form Example',
|
||||||
|
code: 'examples.form.view',
|
||||||
|
description: 'Form example menu',
|
||||||
|
resource: '/examples/form',
|
||||||
|
action: 'view',
|
||||||
|
type: 'menu',
|
||||||
|
parentId: '40',
|
||||||
|
path: '/examples/form',
|
||||||
|
component: 'examples/form/index',
|
||||||
|
icon: 'FormOutlined',
|
||||||
|
sort: 3,
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '44',
|
||||||
|
name: 'Modal Example',
|
||||||
|
code: 'examples.modal.view',
|
||||||
|
description: 'Modal example menu',
|
||||||
|
resource: '/examples/modal',
|
||||||
|
action: 'view',
|
||||||
|
type: 'menu',
|
||||||
|
parentId: '40',
|
||||||
|
path: '/examples/modal',
|
||||||
|
component: 'examples/modal/index',
|
||||||
|
icon: 'ExpandOutlined',
|
||||||
|
sort: 4,
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '45',
|
||||||
|
name: 'Watermark Example',
|
||||||
|
code: 'examples.watermark.view',
|
||||||
|
description: 'Watermark example menu',
|
||||||
|
resource: '/examples/watermark',
|
||||||
|
action: 'view',
|
||||||
|
type: 'menu',
|
||||||
|
parentId: '40',
|
||||||
|
path: '/examples/watermark',
|
||||||
|
component: 'examples/watermark/index',
|
||||||
|
icon: 'HighlightOutlined',
|
||||||
|
sort: 5,
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '46',
|
||||||
|
name: 'Exception Page',
|
||||||
|
code: 'examples.exception.menu',
|
||||||
|
description: 'Exception root menu',
|
||||||
|
resource: '/examples/exception',
|
||||||
|
action: 'view',
|
||||||
|
type: 'menu',
|
||||||
|
parentId: '40',
|
||||||
|
path: '/examples/exception',
|
||||||
|
component: 'RouteView',
|
||||||
|
icon: 'WarningOutlined',
|
||||||
|
sort: 6,
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: '47',
|
||||||
|
name: '403',
|
||||||
|
code: 'examples.exception.403.view',
|
||||||
|
description: '403 exception page menu',
|
||||||
|
resource: '/examples/exception/403',
|
||||||
|
action: 'view',
|
||||||
|
type: 'menu',
|
||||||
|
parentId: '46',
|
||||||
|
path: '/examples/exception/403',
|
||||||
|
component: 'examples/exception/403',
|
||||||
|
icon: 'StopOutlined',
|
||||||
|
sort: 1,
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '48',
|
||||||
|
name: '404',
|
||||||
|
code: 'examples.exception.404.view',
|
||||||
|
description: '404 exception page menu',
|
||||||
|
resource: '/examples/exception/404',
|
||||||
|
action: 'view',
|
||||||
|
type: 'menu',
|
||||||
|
parentId: '46',
|
||||||
|
path: '/examples/exception/404',
|
||||||
|
component: 'examples/exception/404',
|
||||||
|
icon: 'FileUnknownOutlined',
|
||||||
|
sort: 2,
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '49',
|
||||||
|
name: '500',
|
||||||
|
code: 'examples.exception.500.view',
|
||||||
|
description: '500 exception page menu',
|
||||||
|
resource: '/examples/exception/500',
|
||||||
|
action: 'view',
|
||||||
|
type: 'menu',
|
||||||
|
parentId: '46',
|
||||||
|
path: '/examples/exception/500',
|
||||||
|
component: 'examples/exception/500',
|
||||||
|
icon: 'BugOutlined',
|
||||||
|
sort: 3,
|
||||||
|
status: 'active',
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
40
antdv-next-admin/mock/data/roles.data.ts
Normal file
40
antdv-next-admin/mock/data/roles.data.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import type { Role } from '@/types/auth';
|
||||||
|
|
||||||
|
export const mockRoles: Role[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Administrator',
|
||||||
|
code: 'admin',
|
||||||
|
description: 'System administrator with full access',
|
||||||
|
permissions: [],
|
||||||
|
createdAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Manager',
|
||||||
|
code: 'manager',
|
||||||
|
description: 'Department manager with management permissions',
|
||||||
|
permissions: [],
|
||||||
|
createdAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'User',
|
||||||
|
code: 'user',
|
||||||
|
description: 'Regular user with basic permissions',
|
||||||
|
permissions: [],
|
||||||
|
createdAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
name: 'Guest',
|
||||||
|
code: 'guest',
|
||||||
|
description: 'Guest user with read-only access',
|
||||||
|
permissions: [],
|
||||||
|
createdAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
103
antdv-next-admin/mock/data/users.data.ts
Normal file
103
antdv-next-admin/mock/data/users.data.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import type { User } from "@/types/auth";
|
||||||
|
|
||||||
|
import { faker } from "@faker-js/faker";
|
||||||
|
|
||||||
|
// Generate mock users
|
||||||
|
export const mockUsers: User[] = Array.from({ length: 50 }, () => ({
|
||||||
|
id: faker.string.uuid(),
|
||||||
|
username: faker.internet.username(),
|
||||||
|
email: faker.internet.email(),
|
||||||
|
realName: faker.person.fullName(),
|
||||||
|
avatar: faker.image.avatar(),
|
||||||
|
phone: `1${faker.string.numeric(10)}`,
|
||||||
|
gender: faker.helpers.arrayElement(["male", "female"] as const),
|
||||||
|
birthDate: faker.date
|
||||||
|
.birthdate({ min: 18, max: 65, mode: "age" })
|
||||||
|
.toISOString()
|
||||||
|
.split("T")[0],
|
||||||
|
bio: faker.person.bio(),
|
||||||
|
status: faker.helpers.arrayElement(["active", "inactive"] as const),
|
||||||
|
createdAt: faker.date.past().toISOString(),
|
||||||
|
updatedAt: faker.date.recent().toISOString(),
|
||||||
|
roles: [],
|
||||||
|
permissions: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Admin user
|
||||||
|
export const adminUser: User = {
|
||||||
|
id: "1",
|
||||||
|
username: "admin",
|
||||||
|
email: "admin@example.com",
|
||||||
|
realName: "Administrator",
|
||||||
|
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=admin",
|
||||||
|
phone: "13800138000",
|
||||||
|
gender: "male",
|
||||||
|
birthDate: "1990-01-01",
|
||||||
|
bio: "System Administrator",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2023-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
roles: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "Administrator",
|
||||||
|
code: "admin",
|
||||||
|
description: "System Administrator",
|
||||||
|
permissions: [],
|
||||||
|
createdAt: "2023-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2023-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "All Permissions",
|
||||||
|
code: "*",
|
||||||
|
description: "Has all permissions",
|
||||||
|
resource: "*",
|
||||||
|
action: "*",
|
||||||
|
type: "api",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Regular user
|
||||||
|
export const regularUser: User = {
|
||||||
|
id: "2",
|
||||||
|
username: "user",
|
||||||
|
email: "user@example.com",
|
||||||
|
realName: "Regular User",
|
||||||
|
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=user",
|
||||||
|
phone: "13800138001",
|
||||||
|
gender: "female",
|
||||||
|
birthDate: "1995-05-15",
|
||||||
|
bio: "Regular User",
|
||||||
|
status: "active",
|
||||||
|
createdAt: "2023-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
roles: [
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
name: "User",
|
||||||
|
code: "user",
|
||||||
|
description: "Regular User",
|
||||||
|
permissions: [],
|
||||||
|
createdAt: "2023-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2023-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
name: "View Dashboard",
|
||||||
|
code: "dashboard.view",
|
||||||
|
description: "Can view dashboard",
|
||||||
|
resource: "dashboard",
|
||||||
|
action: "view",
|
||||||
|
type: "menu",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add admin and regular users to the beginning of the array
|
||||||
|
mockUsers.unshift(adminUser, regularUser);
|
||||||
113
antdv-next-admin/mock/handlers/auth.mock.ts
Normal file
113
antdv-next-admin/mock/handlers/auth.mock.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { defineMock } from 'vite-plugin-mock-dev-server';
|
||||||
|
|
||||||
|
import { adminUser, regularUser } from '../data/users.data';
|
||||||
|
|
||||||
|
export default defineMock([
|
||||||
|
// Login
|
||||||
|
{
|
||||||
|
url: '/api/auth/login',
|
||||||
|
method: 'POST',
|
||||||
|
body: (req) => {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
// Validate credentials
|
||||||
|
let user = null;
|
||||||
|
if (username === 'admin' && password === '123456') {
|
||||||
|
user = adminUser;
|
||||||
|
} else if (username === 'user' && password === '123456') {
|
||||||
|
user = regularUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'Login successful',
|
||||||
|
data: {
|
||||||
|
token: `mock-token-${user.id}-${Date.now()}`,
|
||||||
|
refreshToken: `mock-refresh-token-${user.id}-${Date.now()}`,
|
||||||
|
expiresIn: 7200,
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
code: 401,
|
||||||
|
message: 'Invalid username or password',
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
{
|
||||||
|
url: '/api/auth/logout',
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
code: 200,
|
||||||
|
message: 'Logout successful',
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get user info
|
||||||
|
{
|
||||||
|
url: '/api/auth/info',
|
||||||
|
method: 'GET',
|
||||||
|
body: (req) => {
|
||||||
|
// Get token from header
|
||||||
|
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
code: 401,
|
||||||
|
message: 'Unauthorized',
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract user ID from token
|
||||||
|
const userId = token.split('-')[2];
|
||||||
|
const user = userId === '1' ? adminUser : regularUser;
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'Success',
|
||||||
|
data: user,
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Refresh token
|
||||||
|
{
|
||||||
|
url: '/api/auth/refresh',
|
||||||
|
method: 'POST',
|
||||||
|
body: (req) => {
|
||||||
|
const { refreshToken } = req.body;
|
||||||
|
|
||||||
|
if (refreshToken) {
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'Token refreshed',
|
||||||
|
data: {
|
||||||
|
token: `new-mock-token-${Date.now()}`,
|
||||||
|
refreshToken: `new-mock-refresh-token-${Date.now()}`,
|
||||||
|
expiresIn: 7200,
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
code: 401,
|
||||||
|
message: 'Invalid refresh token',
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
114
antdv-next-admin/mock/handlers/config.mock.ts
Normal file
114
antdv-next-admin/mock/handlers/config.mock.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import type { SysConfig } from "@/types/config";
|
||||||
|
|
||||||
|
import { defineMock } from "vite-plugin-mock-dev-server";
|
||||||
|
|
||||||
|
import { sysConfigs } from "../data/config.data";
|
||||||
|
|
||||||
|
export default defineMock([
|
||||||
|
{
|
||||||
|
url: "/api/config/list",
|
||||||
|
method: "GET",
|
||||||
|
body: (req) => {
|
||||||
|
const { name, key, group, page = 1, pageSize = 20 } = req.query;
|
||||||
|
let filtered = [...sysConfigs];
|
||||||
|
|
||||||
|
if (name)
|
||||||
|
filtered = filtered.filter((item) =>
|
||||||
|
item.name.includes(name as string),
|
||||||
|
);
|
||||||
|
if (key)
|
||||||
|
filtered = filtered.filter((item) => item.key.includes(key as string));
|
||||||
|
if (group) filtered = filtered.filter((item) => item.group === group);
|
||||||
|
|
||||||
|
filtered.sort((a, b) => a.sort - b.sort);
|
||||||
|
const start = (Number(page) - 1) * Number(pageSize);
|
||||||
|
const list = filtered.slice(start, start + Number(pageSize));
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "success",
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
list,
|
||||||
|
total: filtered.length,
|
||||||
|
current: Number(page),
|
||||||
|
pageSize: Number(pageSize),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
url: "/api/config/key/:key",
|
||||||
|
method: "GET",
|
||||||
|
body: (req) => {
|
||||||
|
const item = sysConfigs.find((c) => c.key === req.params.key);
|
||||||
|
return item
|
||||||
|
? { code: 200, message: "success", success: true, data: item }
|
||||||
|
: { code: 404, message: "Config not found", success: false };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
url: "/api/config",
|
||||||
|
method: "POST",
|
||||||
|
body: (req) => {
|
||||||
|
const exists = sysConfigs.find((c) => c.key === req.body.key);
|
||||||
|
if (exists)
|
||||||
|
return {
|
||||||
|
code: 400,
|
||||||
|
message: "Config key already exists",
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
const newConfig: SysConfig = {
|
||||||
|
id: String(Date.now()),
|
||||||
|
...req.body,
|
||||||
|
builtIn: false,
|
||||||
|
createTime: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
updateTime: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
};
|
||||||
|
sysConfigs.push(newConfig);
|
||||||
|
return { code: 200, message: "success", success: true, data: newConfig };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
url: "/api/config/:id",
|
||||||
|
method: "PUT",
|
||||||
|
body: (req) => {
|
||||||
|
const index = sysConfigs.findIndex((item) => item.id === req.params.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
sysConfigs[index] = {
|
||||||
|
...sysConfigs[index],
|
||||||
|
...req.body,
|
||||||
|
updateTime: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "success",
|
||||||
|
success: true,
|
||||||
|
data: sysConfigs[index],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { code: 404, message: "Config not found", success: false };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
url: "/api/config/:id",
|
||||||
|
method: "DELETE",
|
||||||
|
body: (req) => {
|
||||||
|
const index = sysConfigs.findIndex((item) => item.id === req.params.id);
|
||||||
|
if (index === -1)
|
||||||
|
return { code: 404, message: "Config not found", success: false };
|
||||||
|
if (sysConfigs[index].builtIn)
|
||||||
|
return {
|
||||||
|
code: 400,
|
||||||
|
message: "Built-in config cannot be deleted",
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
sysConfigs.splice(index, 1);
|
||||||
|
return { code: 200, message: "success", success: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
71
antdv-next-admin/mock/handlers/dashboard.mock.ts
Normal file
71
antdv-next-admin/mock/handlers/dashboard.mock.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { defineMock } from 'vite-plugin-mock-dev-server';
|
||||||
|
|
||||||
|
import {
|
||||||
|
mockStats,
|
||||||
|
mockSalesTrend,
|
||||||
|
mockUserDistribution,
|
||||||
|
mockActivities,
|
||||||
|
mockChartData,
|
||||||
|
} from '../data/dashboard.data';
|
||||||
|
|
||||||
|
export default defineMock([
|
||||||
|
// Get statistics
|
||||||
|
{
|
||||||
|
url: '/api/dashboard/stats',
|
||||||
|
method: 'GET',
|
||||||
|
body: {
|
||||||
|
code: 200,
|
||||||
|
message: 'Success',
|
||||||
|
data: mockStats,
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get sales trend
|
||||||
|
{
|
||||||
|
url: '/api/dashboard/sales-trend',
|
||||||
|
method: 'GET',
|
||||||
|
body: {
|
||||||
|
code: 200,
|
||||||
|
message: 'Success',
|
||||||
|
data: mockSalesTrend,
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get user distribution
|
||||||
|
{
|
||||||
|
url: '/api/dashboard/user-distribution',
|
||||||
|
method: 'GET',
|
||||||
|
body: {
|
||||||
|
code: 200,
|
||||||
|
message: 'Success',
|
||||||
|
data: mockUserDistribution,
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get recent activities
|
||||||
|
{
|
||||||
|
url: '/api/dashboard/activities',
|
||||||
|
method: 'GET',
|
||||||
|
body: {
|
||||||
|
code: 200,
|
||||||
|
message: 'Success',
|
||||||
|
data: mockActivities,
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get chart data
|
||||||
|
{
|
||||||
|
url: '/api/dashboard/chart-data',
|
||||||
|
method: 'GET',
|
||||||
|
body: {
|
||||||
|
code: 200,
|
||||||
|
message: 'Success',
|
||||||
|
data: mockChartData,
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
121
antdv-next-admin/mock/handlers/dept.mock.ts
Normal file
121
antdv-next-admin/mock/handlers/dept.mock.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import type { Department } from "@/types/dept";
|
||||||
|
|
||||||
|
import { defineMock } from "vite-plugin-mock-dev-server";
|
||||||
|
|
||||||
|
import { departments, buildDeptTree } from "../data/dept.data";
|
||||||
|
|
||||||
|
export default defineMock([
|
||||||
|
// 获取部门树
|
||||||
|
{
|
||||||
|
url: "/api/dept/tree",
|
||||||
|
method: "GET",
|
||||||
|
body: (req) => {
|
||||||
|
const { name, status } = req.query;
|
||||||
|
let filtered = [...departments];
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
filtered = filtered.filter((item) =>
|
||||||
|
item.name.includes(name as string),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
filtered = filtered.filter((item) => item.status === status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "success",
|
||||||
|
success: true,
|
||||||
|
data: buildDeptTree(filtered),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取部门列表(扁平)
|
||||||
|
{
|
||||||
|
url: "/api/dept/list",
|
||||||
|
method: "GET",
|
||||||
|
body: (req) => {
|
||||||
|
const { name, status } = req.query;
|
||||||
|
let filtered = [...departments];
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
filtered = filtered.filter((item) =>
|
||||||
|
item.name.includes(name as string),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
filtered = filtered.filter((item) => item.status === status);
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered.sort((a, b) => a.sort - b.sort);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "success",
|
||||||
|
success: true,
|
||||||
|
data: filtered,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建部门
|
||||||
|
{
|
||||||
|
url: "/api/dept",
|
||||||
|
method: "POST",
|
||||||
|
body: (req) => {
|
||||||
|
const newDept: Department = {
|
||||||
|
id: String(Date.now()),
|
||||||
|
...req.body,
|
||||||
|
createTime: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
updateTime: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
};
|
||||||
|
departments.push(newDept);
|
||||||
|
return { code: 200, message: "创建成功", success: true, data: newDept };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新部门
|
||||||
|
{
|
||||||
|
url: "/api/dept/:id",
|
||||||
|
method: "PUT",
|
||||||
|
body: (req) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const index = departments.findIndex((item) => item.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
departments[index] = {
|
||||||
|
...departments[index],
|
||||||
|
...req.body,
|
||||||
|
updateTime: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "更新成功",
|
||||||
|
success: true,
|
||||||
|
data: departments[index],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { code: 404, message: "部门不存在", success: false };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除部门
|
||||||
|
{
|
||||||
|
url: "/api/dept/:id",
|
||||||
|
method: "DELETE",
|
||||||
|
body: (req) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
// 检查是否有子部门
|
||||||
|
const hasChildren = departments.some((item) => item.parentId === id);
|
||||||
|
if (hasChildren) {
|
||||||
|
return { code: 400, message: "存在子部门,无法删除", success: false };
|
||||||
|
}
|
||||||
|
const index = departments.findIndex((item) => item.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
departments.splice(index, 1);
|
||||||
|
return { code: 200, message: "删除成功", success: true };
|
||||||
|
}
|
||||||
|
return { code: 404, message: "部门不存在", success: false };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
289
antdv-next-admin/mock/handlers/dict.mock.ts
Normal file
289
antdv-next-admin/mock/handlers/dict.mock.ts
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
import type { DictType, DictData } from "@/types/dict";
|
||||||
|
|
||||||
|
import { defineMock } from "vite-plugin-mock-dev-server";
|
||||||
|
|
||||||
|
import { dictTypes, dictData } from "../data/dict.data";
|
||||||
|
|
||||||
|
export default defineMock([
|
||||||
|
// 获取所有字典类型
|
||||||
|
{
|
||||||
|
url: "/api/dict/types",
|
||||||
|
method: "GET",
|
||||||
|
body: () => {
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "success",
|
||||||
|
success: true,
|
||||||
|
data: dictTypes,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取字典类型列表(分页)
|
||||||
|
{
|
||||||
|
url: "/api/dict/type/list",
|
||||||
|
method: "GET",
|
||||||
|
body: (req) => {
|
||||||
|
const { name, code, status, page = 1, pageSize = 10 } = req.query;
|
||||||
|
|
||||||
|
let filtered = [...dictTypes];
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
filtered = filtered.filter((item) =>
|
||||||
|
item.name.includes(name as string),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (code) {
|
||||||
|
filtered = filtered.filter((item) =>
|
||||||
|
item.code.includes(code as string),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
filtered = filtered.filter((item) => item.status === status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = (Number(page) - 1) * Number(pageSize);
|
||||||
|
const end = start + Number(pageSize);
|
||||||
|
const list = filtered.slice(start, end);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "success",
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
list,
|
||||||
|
total: filtered.length,
|
||||||
|
current: Number(page),
|
||||||
|
pageSize: Number(pageSize),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建字典类型
|
||||||
|
{
|
||||||
|
url: "/api/dict/type",
|
||||||
|
method: "POST",
|
||||||
|
body: (req) => {
|
||||||
|
const newType: DictType = {
|
||||||
|
id: String(Date.now()),
|
||||||
|
...req.body,
|
||||||
|
createTime: new Date().toISOString(),
|
||||||
|
updateTime: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
dictTypes.push(newType);
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "创建成功",
|
||||||
|
success: true,
|
||||||
|
data: newType,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新字典类型
|
||||||
|
{
|
||||||
|
url: "/api/dict/type/:id",
|
||||||
|
method: "PUT",
|
||||||
|
body: (req) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const index = dictTypes.findIndex((item) => item.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
dictTypes[index] = {
|
||||||
|
...dictTypes[index],
|
||||||
|
...req.body,
|
||||||
|
updateTime: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "更新成功",
|
||||||
|
success: true,
|
||||||
|
data: dictTypes[index],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: "字典类型不存在",
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除字典类型
|
||||||
|
{
|
||||||
|
url: "/api/dict/type/:id",
|
||||||
|
method: "DELETE",
|
||||||
|
body: (req) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const index = dictTypes.findIndex((item) => item.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
dictTypes.splice(index, 1);
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "删除成功",
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: "字典类型不存在",
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取所有字典数据
|
||||||
|
{
|
||||||
|
url: "/api/dict/data/all",
|
||||||
|
method: "GET",
|
||||||
|
body: () => {
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "success",
|
||||||
|
success: true,
|
||||||
|
data: dictData,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取字典数据列表(分页) - 必须在 :typeCode 之前,避免被参数路由匹配
|
||||||
|
{
|
||||||
|
url: "/api/dict/data/list",
|
||||||
|
method: "GET",
|
||||||
|
body: (req) => {
|
||||||
|
const {
|
||||||
|
typeCode,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
status,
|
||||||
|
page = 1,
|
||||||
|
pageSize = 10,
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
let filtered = [...dictData];
|
||||||
|
|
||||||
|
if (typeCode) {
|
||||||
|
filtered = filtered.filter((item) => item.typeCode === typeCode);
|
||||||
|
}
|
||||||
|
if (label) {
|
||||||
|
filtered = filtered.filter((item) =>
|
||||||
|
item.label.includes(label as string),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (value) {
|
||||||
|
filtered = filtered.filter((item) =>
|
||||||
|
item.value.includes(value as string),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
filtered = filtered.filter((item) => item.status === status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = (Number(page) - 1) * Number(pageSize);
|
||||||
|
const end = start + Number(pageSize);
|
||||||
|
const list = filtered.slice(start, end);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "success",
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
list,
|
||||||
|
total: filtered.length,
|
||||||
|
current: Number(page),
|
||||||
|
pageSize: Number(pageSize),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 根据类型获取字典数据
|
||||||
|
{
|
||||||
|
url: "/api/dict/data/:typeCode",
|
||||||
|
method: "GET",
|
||||||
|
body: (req) => {
|
||||||
|
const { typeCode } = req.params;
|
||||||
|
const filtered = dictData.filter(
|
||||||
|
(item) => item.typeCode === typeCode && item.status === "enabled",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "success",
|
||||||
|
success: true,
|
||||||
|
data: filtered,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建字典数据
|
||||||
|
{
|
||||||
|
url: "/api/dict/data",
|
||||||
|
method: "POST",
|
||||||
|
body: (req) => {
|
||||||
|
const newData: DictData = {
|
||||||
|
id: String(Date.now()),
|
||||||
|
...req.body,
|
||||||
|
createTime: new Date().toISOString(),
|
||||||
|
updateTime: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
dictData.push(newData);
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "创建成功",
|
||||||
|
success: true,
|
||||||
|
data: newData,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新字典数据
|
||||||
|
{
|
||||||
|
url: "/api/dict/data/:id",
|
||||||
|
method: "PUT",
|
||||||
|
body: (req) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const index = dictData.findIndex((item) => item.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
dictData[index] = {
|
||||||
|
...dictData[index],
|
||||||
|
...req.body,
|
||||||
|
updateTime: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "更新成功",
|
||||||
|
success: true,
|
||||||
|
data: dictData[index],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: "字典数据不存在",
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除字典数据
|
||||||
|
{
|
||||||
|
url: "/api/dict/data/:id",
|
||||||
|
method: "DELETE",
|
||||||
|
body: (req) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const index = dictData.findIndex((item) => item.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
dictData.splice(index, 1);
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "删除成功",
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: "字典数据不存在",
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
96
antdv-next-admin/mock/handlers/file.mock.ts
Normal file
96
antdv-next-admin/mock/handlers/file.mock.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { defineMock } from "vite-plugin-mock-dev-server";
|
||||||
|
|
||||||
|
import { sysFiles } from "../data/file.data";
|
||||||
|
|
||||||
|
export default defineMock([
|
||||||
|
{
|
||||||
|
url: "/api/file/list",
|
||||||
|
method: "GET",
|
||||||
|
body: (req) => {
|
||||||
|
const { name, ext, storage, page = 1, pageSize = 20 } = req.query;
|
||||||
|
let filtered = [...sysFiles];
|
||||||
|
|
||||||
|
if (name)
|
||||||
|
filtered = filtered.filter((item) =>
|
||||||
|
item.originalName.includes(name as string),
|
||||||
|
);
|
||||||
|
if (ext) filtered = filtered.filter((item) => item.ext === ext);
|
||||||
|
if (storage)
|
||||||
|
filtered = filtered.filter((item) => item.storage === storage);
|
||||||
|
|
||||||
|
const start = (Number(page) - 1) * Number(pageSize);
|
||||||
|
const list = filtered.slice(start, start + Number(pageSize));
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "success",
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
list,
|
||||||
|
total: filtered.length,
|
||||||
|
current: Number(page),
|
||||||
|
pageSize: Number(pageSize),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
url: "/api/file/:id",
|
||||||
|
method: "GET",
|
||||||
|
body: (req) => {
|
||||||
|
const file = sysFiles.find((item) => item.id === req.params.id);
|
||||||
|
if (!file) return { code: 404, message: "文件不存在", success: false };
|
||||||
|
return { code: 200, message: "success", success: true, data: file };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
url: "/api/file/upload",
|
||||||
|
method: "POST",
|
||||||
|
body: (req) => {
|
||||||
|
const { originalName, size, mimeType, storage = "local" } = req.body;
|
||||||
|
|
||||||
|
if (!originalName) {
|
||||||
|
return { code: 400, message: "文件名不能为空", success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = originalName.includes(".")
|
||||||
|
? originalName.split(".").pop()
|
||||||
|
: "";
|
||||||
|
const newFile = {
|
||||||
|
id: `file-${Date.now()}`,
|
||||||
|
originalName,
|
||||||
|
storedName: `${Date.now()}-${originalName}`,
|
||||||
|
size: size || 0,
|
||||||
|
ext: ext || "",
|
||||||
|
mimeType: mimeType || "application/octet-stream",
|
||||||
|
storage,
|
||||||
|
url: `/uploads/${Date.now()}-${originalName}`,
|
||||||
|
uploader: "admin",
|
||||||
|
uploadTime: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
};
|
||||||
|
|
||||||
|
sysFiles.unshift(newFile);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "上传成功",
|
||||||
|
success: true,
|
||||||
|
data: newFile,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
url: "/api/file/:id",
|
||||||
|
method: "DELETE",
|
||||||
|
body: (req) => {
|
||||||
|
const index = sysFiles.findIndex((item) => item.id === req.params.id);
|
||||||
|
if (index === -1)
|
||||||
|
return { code: 404, message: "文件不存在", success: false };
|
||||||
|
sysFiles.splice(index, 1);
|
||||||
|
return { code: 200, message: "删除成功", success: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
139
antdv-next-admin/mock/handlers/log.mock.ts
Normal file
139
antdv-next-admin/mock/handlers/log.mock.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import { defineMock } from "vite-plugin-mock-dev-server";
|
||||||
|
|
||||||
|
import { operationLogs, loginLogs } from "../data/log.data";
|
||||||
|
|
||||||
|
export default defineMock([
|
||||||
|
{
|
||||||
|
url: "/api/log/operation/list",
|
||||||
|
method: "GET",
|
||||||
|
body: (req) => {
|
||||||
|
const {
|
||||||
|
username,
|
||||||
|
module,
|
||||||
|
action,
|
||||||
|
status,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
page = 1,
|
||||||
|
pageSize = 10,
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
let filtered = [...operationLogs];
|
||||||
|
|
||||||
|
if (username) {
|
||||||
|
filtered = filtered.filter((item) =>
|
||||||
|
item.username.includes(username as string),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (module) {
|
||||||
|
filtered = filtered.filter((item) => item.module === module);
|
||||||
|
}
|
||||||
|
if (action) {
|
||||||
|
filtered = filtered.filter((item) => item.action === action);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
filtered = filtered.filter((item) => item.status === status);
|
||||||
|
}
|
||||||
|
if (startTime) {
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(item) => item.createTime >= (startTime as string),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (endTime) {
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(item) => item.createTime <= (endTime as string),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = (Number(page) - 1) * Number(pageSize);
|
||||||
|
const end = start + Number(pageSize);
|
||||||
|
const list = filtered.slice(start, end);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "success",
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
list,
|
||||||
|
total: filtered.length,
|
||||||
|
current: Number(page),
|
||||||
|
pageSize: Number(pageSize),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
url: "/api/log/login/list",
|
||||||
|
method: "GET",
|
||||||
|
body: (req) => {
|
||||||
|
const {
|
||||||
|
username,
|
||||||
|
ip,
|
||||||
|
status,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
page = 1,
|
||||||
|
pageSize = 10,
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
let filtered = [...loginLogs];
|
||||||
|
|
||||||
|
if (username) {
|
||||||
|
filtered = filtered.filter((item) =>
|
||||||
|
item.username.includes(username as string),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ip) {
|
||||||
|
filtered = filtered.filter((item) => item.ip.includes(ip as string));
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
filtered = filtered.filter((item) => item.status === status);
|
||||||
|
}
|
||||||
|
if (startTime) {
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(item) => item.createTime >= (startTime as string),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (endTime) {
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(item) => item.createTime <= (endTime as string),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = (Number(page) - 1) * Number(pageSize);
|
||||||
|
const end = start + Number(pageSize);
|
||||||
|
const list = filtered.slice(start, end);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "success",
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
list,
|
||||||
|
total: filtered.length,
|
||||||
|
current: Number(page),
|
||||||
|
pageSize: Number(pageSize),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
url: "/api/log/operation/clear",
|
||||||
|
method: "DELETE",
|
||||||
|
body: () => {
|
||||||
|
operationLogs.length = 0;
|
||||||
|
return { code: 200, message: "success", success: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
url: "/api/log/login/clear",
|
||||||
|
method: "DELETE",
|
||||||
|
body: () => {
|
||||||
|
loginLogs.length = 0;
|
||||||
|
return { code: 200, message: "success", success: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
342
antdv-next-admin/mock/handlers/permission.mock.ts
Normal file
342
antdv-next-admin/mock/handlers/permission.mock.ts
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
import type { Permission } from '@/types/auth';
|
||||||
|
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import { defineMock } from 'vite-plugin-mock-dev-server';
|
||||||
|
|
||||||
|
import { mockPermissions } from '../data/permissions.data';
|
||||||
|
|
||||||
|
const permissionStore: Permission[] = JSON.parse(JSON.stringify(mockPermissions));
|
||||||
|
|
||||||
|
const deepClone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||||
|
|
||||||
|
function findPermissionById(
|
||||||
|
permissions: Permission[],
|
||||||
|
id: string,
|
||||||
|
parent: Permission | null = null,
|
||||||
|
): { permission: Permission | null; parent: Permission | null } {
|
||||||
|
for (const permission of permissions) {
|
||||||
|
if (permission.id === id) {
|
||||||
|
return { permission, parent };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission.children && permission.children.length > 0) {
|
||||||
|
const result = findPermissionById(permission.children, id, permission);
|
||||||
|
if (result.permission) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { permission: null, parent: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePermissionById(permissions: Permission[], id: string): boolean {
|
||||||
|
const index = permissions.findIndex((permission) => permission.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
permissions.splice(index, 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions.some((permission) => {
|
||||||
|
if (!permission.children || permission.children.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = removePermissionById(permission.children, id);
|
||||||
|
if (removed && permission.children.length === 0) {
|
||||||
|
delete permission.children;
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendPermission(
|
||||||
|
permissions: Permission[],
|
||||||
|
permission: Permission,
|
||||||
|
parentId?: string,
|
||||||
|
): boolean {
|
||||||
|
if (!parentId) {
|
||||||
|
permissions.push(permission);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { permission: parentPermission } = findPermissionById(permissions, parentId);
|
||||||
|
if (!parentPermission) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parentPermission.children) {
|
||||||
|
parentPermission.children = [];
|
||||||
|
}
|
||||||
|
parentPermission.children.push(permission);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterPermissionTree(
|
||||||
|
permissions: Permission[],
|
||||||
|
keyword?: string,
|
||||||
|
type?: string,
|
||||||
|
status?: string,
|
||||||
|
): Permission[] {
|
||||||
|
const normalizedKeyword = keyword?.toLowerCase().trim();
|
||||||
|
|
||||||
|
return permissions.reduce<Permission[]>((result, permission) => {
|
||||||
|
const children = permission.children
|
||||||
|
? filterPermissionTree(permission.children, keyword, type, status)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const matchedKeyword =
|
||||||
|
!normalizedKeyword ||
|
||||||
|
permission.name.toLowerCase().includes(normalizedKeyword) ||
|
||||||
|
String(permission.code || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(normalizedKeyword) ||
|
||||||
|
(permission.path || '').toLowerCase().includes(normalizedKeyword);
|
||||||
|
|
||||||
|
const matchedType = !type || permission.type === type;
|
||||||
|
const matchedStatus = !status || (permission.status || 'active') === status;
|
||||||
|
const matchedSelf = matchedKeyword && matchedType && matchedStatus;
|
||||||
|
|
||||||
|
if (matchedSelf || children.length > 0) {
|
||||||
|
result.push({
|
||||||
|
...permission,
|
||||||
|
children: children.length > 0 ? children : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeQueryValue(value: unknown): string | undefined {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return normalizeQueryValue(value[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineMock([
|
||||||
|
// Get permission list (tree structure)
|
||||||
|
{
|
||||||
|
url: '/api/permissions',
|
||||||
|
method: 'GET',
|
||||||
|
body: (req) => {
|
||||||
|
const query = req.query || {};
|
||||||
|
const keyword = normalizeQueryValue(query.keyword);
|
||||||
|
const type = normalizeQueryValue(query.type);
|
||||||
|
const status = normalizeQueryValue(query.status);
|
||||||
|
const filtered = filterPermissionTree(permissionStore, keyword, type, status);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'Success',
|
||||||
|
data: deepClone(filtered),
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get permission tree (for menu)
|
||||||
|
{
|
||||||
|
url: '/api/permissions/tree',
|
||||||
|
method: 'GET',
|
||||||
|
body: () => {
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'Success',
|
||||||
|
data: deepClone(permissionStore),
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get permission by ID
|
||||||
|
{
|
||||||
|
url: '/api/permissions/:id',
|
||||||
|
method: 'GET',
|
||||||
|
body: (req) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { permission } = findPermissionById(permissionStore, id);
|
||||||
|
|
||||||
|
if (!permission) {
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: 'Permission not found',
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'Success',
|
||||||
|
data: deepClone(permission),
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create permission
|
||||||
|
{
|
||||||
|
url: '/api/permissions',
|
||||||
|
method: 'POST',
|
||||||
|
body: (req) => {
|
||||||
|
const payload = req.body || {};
|
||||||
|
const permission: Permission = {
|
||||||
|
id: faker.string.uuid(),
|
||||||
|
name: payload.name,
|
||||||
|
code: payload.code,
|
||||||
|
description: payload.description || '',
|
||||||
|
resource: payload.resource || payload.path || payload.code,
|
||||||
|
action: payload.action || (payload.type === 'menu' ? 'view' : '*'),
|
||||||
|
type: payload.type || 'menu',
|
||||||
|
parentId: payload.parentId,
|
||||||
|
path: payload.path,
|
||||||
|
component: payload.component,
|
||||||
|
icon: payload.icon,
|
||||||
|
sort: payload.sort ?? 0,
|
||||||
|
status: payload.status || 'active',
|
||||||
|
visible: payload.visible ?? true,
|
||||||
|
children: payload.children && payload.children.length > 0 ? payload.children : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const appended = appendPermission(permissionStore, permission, payload.parentId);
|
||||||
|
if (!appended) {
|
||||||
|
return {
|
||||||
|
code: 400,
|
||||||
|
message: 'Parent permission not found',
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'Permission created successfully',
|
||||||
|
data: deepClone(permission),
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update permission
|
||||||
|
{
|
||||||
|
url: '/api/permissions/:id',
|
||||||
|
method: 'PUT',
|
||||||
|
body: (req) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const payload = req.body || {};
|
||||||
|
const { permission, parent } = findPermissionById(permissionStore, id);
|
||||||
|
|
||||||
|
if (!permission) {
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: 'Permission not found',
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.parentId !== undefined && payload.parentId !== permission.parentId) {
|
||||||
|
const movedPermission = deepClone(permission);
|
||||||
|
removePermissionById(permissionStore, id);
|
||||||
|
movedPermission.parentId = payload.parentId;
|
||||||
|
movedPermission.children = permission.children || [];
|
||||||
|
const appended = appendPermission(permissionStore, movedPermission, payload.parentId);
|
||||||
|
if (!appended) {
|
||||||
|
appendPermission(permissionStore, movedPermission, parent?.id);
|
||||||
|
return {
|
||||||
|
code: 400,
|
||||||
|
message: 'Parent permission not found',
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { permission: nextPermission } = findPermissionById(permissionStore, id);
|
||||||
|
if (!nextPermission) {
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: 'Permission not found',
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = nextPermission.children;
|
||||||
|
Object.assign(nextPermission, payload, { children });
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'Permission updated successfully',
|
||||||
|
data: deepClone(nextPermission),
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete permission
|
||||||
|
{
|
||||||
|
url: '/api/permissions/:id',
|
||||||
|
method: 'DELETE',
|
||||||
|
body: (req) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const removed = removePermissionById(permissionStore, id);
|
||||||
|
|
||||||
|
if (!removed) {
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: 'Permission not found',
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'Permission deleted successfully',
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get user permissions
|
||||||
|
{
|
||||||
|
url: '/api/permissions/user',
|
||||||
|
method: 'GET',
|
||||||
|
body: (req) => {
|
||||||
|
// In a real app, this would be based on the user's roles
|
||||||
|
// For now, return all permissions for admin
|
||||||
|
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||||
|
const userId = token?.split('-')[2];
|
||||||
|
|
||||||
|
if (userId === '1') {
|
||||||
|
// Admin - all permissions
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'Success',
|
||||||
|
data: deepClone(permissionStore),
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Regular user - limited permissions
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'Success',
|
||||||
|
data: deepClone(
|
||||||
|
permissionStore.filter((permission) => permission.code === 'dashboard.view'),
|
||||||
|
),
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
163
antdv-next-admin/mock/handlers/role.mock.ts
Normal file
163
antdv-next-admin/mock/handlers/role.mock.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import { defineMock } from 'vite-plugin-mock-dev-server';
|
||||||
|
|
||||||
|
import { mockRoles } from '../data/roles.data';
|
||||||
|
|
||||||
|
export default defineMock([
|
||||||
|
// Get role list
|
||||||
|
{
|
||||||
|
url: '/api/roles',
|
||||||
|
method: 'GET',
|
||||||
|
body: (req) => {
|
||||||
|
const { current = 1, pageSize = 10, name, code } = req.query;
|
||||||
|
|
||||||
|
// Filter roles
|
||||||
|
let filteredRoles = [...mockRoles];
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
filteredRoles = filteredRoles.filter((role) =>
|
||||||
|
role.name.toLowerCase().includes(name.toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
filteredRoles = filteredRoles.filter((role) =>
|
||||||
|
role.code.toLowerCase().includes(code.toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const start = (Number(current) - 1) * Number(pageSize);
|
||||||
|
const end = start + Number(pageSize);
|
||||||
|
const list = filteredRoles.slice(start, end);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'Success',
|
||||||
|
data: {
|
||||||
|
list,
|
||||||
|
total: filteredRoles.length,
|
||||||
|
current: Number(current),
|
||||||
|
pageSize: Number(pageSize),
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get role by ID
|
||||||
|
{
|
||||||
|
url: '/api/roles/:id',
|
||||||
|
method: 'GET',
|
||||||
|
body: (req) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const role = mockRoles.find((r) => r.id === id);
|
||||||
|
|
||||||
|
if (role) {
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'Success',
|
||||||
|
data: role,
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: 'Role not found',
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create role
|
||||||
|
{
|
||||||
|
url: '/api/roles',
|
||||||
|
method: 'POST',
|
||||||
|
body: (req) => {
|
||||||
|
const roleData = req.body;
|
||||||
|
|
||||||
|
const newRole = {
|
||||||
|
id: faker.string.uuid(),
|
||||||
|
name: roleData.name,
|
||||||
|
code: roleData.code,
|
||||||
|
description: roleData.description || '',
|
||||||
|
permissions: roleData.permissions || [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRoles.push(newRole);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'Role created successfully',
|
||||||
|
data: newRole,
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update role
|
||||||
|
{
|
||||||
|
url: '/api/roles/:id',
|
||||||
|
method: 'PUT',
|
||||||
|
body: (req) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const roleData = req.body;
|
||||||
|
|
||||||
|
const index = mockRoles.findIndex((r) => r.id === id);
|
||||||
|
|
||||||
|
if (index !== -1) {
|
||||||
|
mockRoles[index] = {
|
||||||
|
...mockRoles[index],
|
||||||
|
...roleData,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'Role updated successfully',
|
||||||
|
data: mockRoles[index],
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: 'Role not found',
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete role
|
||||||
|
{
|
||||||
|
url: '/api/roles/:id',
|
||||||
|
method: 'DELETE',
|
||||||
|
body: (req) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const index = mockRoles.findIndex((r) => r.id === id);
|
||||||
|
|
||||||
|
if (index !== -1) {
|
||||||
|
mockRoles.splice(index, 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: 'Role deleted successfully',
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: 'Role not found',
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
234
antdv-next-admin/mock/handlers/user.mock.ts
Normal file
234
antdv-next-admin/mock/handlers/user.mock.ts
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
import { faker } from "@faker-js/faker";
|
||||||
|
import { defineMock } from "vite-plugin-mock-dev-server";
|
||||||
|
|
||||||
|
import { mockUsers } from "../data/users.data";
|
||||||
|
|
||||||
|
export default defineMock([
|
||||||
|
// Get user list (with pagination and search)
|
||||||
|
{
|
||||||
|
url: "/api/users",
|
||||||
|
method: "GET",
|
||||||
|
body: (req) => {
|
||||||
|
const {
|
||||||
|
current = 1,
|
||||||
|
pageSize = 10,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
status,
|
||||||
|
gender,
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
// Filter users
|
||||||
|
let filteredUsers = [...mockUsers];
|
||||||
|
|
||||||
|
if (username) {
|
||||||
|
filteredUsers = filteredUsers.filter((user) =>
|
||||||
|
user.username.toLowerCase().includes(username.toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
filteredUsers = filteredUsers.filter((user) =>
|
||||||
|
user.email.toLowerCase().includes(email.toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
filteredUsers = filteredUsers.filter((user) => user.status === status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gender) {
|
||||||
|
const genderValues = Array.isArray(gender)
|
||||||
|
? gender.map((item) => String(item))
|
||||||
|
: String(gender)
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (genderValues.length > 0) {
|
||||||
|
filteredUsers = filteredUsers.filter((user) =>
|
||||||
|
genderValues.includes(String(user.gender)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const start = (Number(current) - 1) * Number(pageSize);
|
||||||
|
const end = start + Number(pageSize);
|
||||||
|
const list = filteredUsers.slice(start, end);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "Success",
|
||||||
|
data: {
|
||||||
|
list,
|
||||||
|
total: filteredUsers.length,
|
||||||
|
current: Number(current),
|
||||||
|
pageSize: Number(pageSize),
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get user by ID
|
||||||
|
{
|
||||||
|
url: "/api/users/:id",
|
||||||
|
method: "GET",
|
||||||
|
body: (req) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const user = mockUsers.find((u) => u.id === id);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "Success",
|
||||||
|
data: user,
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: "User not found",
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
{
|
||||||
|
url: "/api/users",
|
||||||
|
method: "POST",
|
||||||
|
body: (req) => {
|
||||||
|
const userData = req.body;
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
id: faker.string.uuid(),
|
||||||
|
username: userData.username || `user_${faker.string.alphanumeric(6)}`,
|
||||||
|
email: userData.email || faker.internet.email(),
|
||||||
|
realName: userData.realName || faker.person.fullName(),
|
||||||
|
avatar: userData.avatar || faker.image.avatar(),
|
||||||
|
phone: userData.phone || `1${faker.string.numeric(10)}`,
|
||||||
|
gender: userData.gender || "male",
|
||||||
|
birthDate:
|
||||||
|
userData.birthDate ||
|
||||||
|
faker.date
|
||||||
|
.birthdate({ min: 18, max: 65, mode: "age" })
|
||||||
|
.toISOString()
|
||||||
|
.split("T")[0],
|
||||||
|
bio: userData.bio || "",
|
||||||
|
status: userData.status || "active",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
roles: userData.roles || [],
|
||||||
|
permissions: userData.permissions || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUsers.push(newUser);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "User created successfully",
|
||||||
|
data: newUser,
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update user
|
||||||
|
{
|
||||||
|
url: "/api/users/:id",
|
||||||
|
method: "PUT",
|
||||||
|
body: (req) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const userData = req.body;
|
||||||
|
|
||||||
|
const index = mockUsers.findIndex((u) => u.id === id);
|
||||||
|
|
||||||
|
if (index !== -1) {
|
||||||
|
mockUsers[index] = {
|
||||||
|
...mockUsers[index],
|
||||||
|
...userData,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "User updated successfully",
|
||||||
|
data: mockUsers[index],
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: "User not found",
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete user
|
||||||
|
{
|
||||||
|
url: "/api/users/:id",
|
||||||
|
method: "DELETE",
|
||||||
|
body: (req) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const index = mockUsers.findIndex((u) => u.id === id);
|
||||||
|
|
||||||
|
if (index !== -1) {
|
||||||
|
mockUsers.splice(index, 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "User deleted successfully",
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
code: 404,
|
||||||
|
message: "User not found",
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Batch delete users
|
||||||
|
{
|
||||||
|
url: "/api/users/batch",
|
||||||
|
method: "DELETE",
|
||||||
|
body: (req) => {
|
||||||
|
const { ids } = req.body;
|
||||||
|
|
||||||
|
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||||
|
return {
|
||||||
|
code: 400,
|
||||||
|
message: "Invalid user IDs",
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let deletedCount = 0;
|
||||||
|
ids.forEach((id: string) => {
|
||||||
|
const index = mockUsers.findIndex((u) => u.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
mockUsers.splice(index, 1);
|
||||||
|
deletedCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: `Deleted ${deletedCount} users successfully`,
|
||||||
|
data: { deletedCount },
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
8554
antdv-next-admin/package-lock.json
generated
Normal file
8554
antdv-next-admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
101
antdv-next-admin/package.json
Normal file
101
antdv-next-admin/package.json
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"name": "antdv-next-admin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "A modern Vue 3 admin scaffold based on antdv-next",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"build:check": "vue-tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"type-check": "vue-tsc --noEmit",
|
||||||
|
"lint": "oxlint src mock",
|
||||||
|
"lint:fix": "oxlint --fix src mock",
|
||||||
|
"format": "oxfmt --write src mock",
|
||||||
|
"format:check": "oxfmt --check src mock"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/colors": "^8.0.1",
|
||||||
|
"@antdv-next/icons": "^1.0.6",
|
||||||
|
"@codemirror/autocomplete": "^6.20.1",
|
||||||
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
|
"@codemirror/lang-go": "^6.0.1",
|
||||||
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
|
"@codemirror/lang-java": "^6.0.2",
|
||||||
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
|
"@codemirror/lang-php": "^6.0.2",
|
||||||
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
|
"@codemirror/lang-rust": "^6.0.2",
|
||||||
|
"@codemirror/lang-sql": "^6.10.0",
|
||||||
|
"@codemirror/lang-xml": "^6.1.0",
|
||||||
|
"@codemirror/lang-yaml": "^6.1.3",
|
||||||
|
"@codemirror/language": "^6.12.3",
|
||||||
|
"@codemirror/lint": "^6.9.5",
|
||||||
|
"@codemirror/search": "^6.6.0",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
|
"@codemirror/view": "^6.41.0",
|
||||||
|
"@iconify-json/ion": "^1.2.6",
|
||||||
|
"@iconify-json/mdi": "^1.2.3",
|
||||||
|
"@iconify-json/ri": "^1.2.10",
|
||||||
|
"@iconify/vue": "^5.0.0",
|
||||||
|
"@milkdown/core": "^7.20.0",
|
||||||
|
"@milkdown/ctx": "^7.20.0",
|
||||||
|
"@milkdown/plugin-block": "^7.20.0",
|
||||||
|
"@milkdown/plugin-clipboard": "^7.20.0",
|
||||||
|
"@milkdown/plugin-history": "^7.20.0",
|
||||||
|
"@milkdown/plugin-listener": "^7.20.0",
|
||||||
|
"@milkdown/plugin-prism": "^7.20.0",
|
||||||
|
"@milkdown/plugin-slash": "^7.20.0",
|
||||||
|
"@milkdown/plugin-tooltip": "^7.20.0",
|
||||||
|
"@milkdown/plugin-upload": "^7.20.0",
|
||||||
|
"@milkdown/preset-commonmark": "^7.20.0",
|
||||||
|
"@milkdown/preset-gfm": "^7.20.0",
|
||||||
|
"@milkdown/theme-nord": "^7.20.0",
|
||||||
|
"@milkdown/transformer": "^7.20.0",
|
||||||
|
"@milkdown/vue": "^7.20.0",
|
||||||
|
"@tiptap/extension-image": "^3.22.1",
|
||||||
|
"@tiptap/extension-link": "^3.22.1",
|
||||||
|
"@tiptap/extension-placeholder": "^3.22.1",
|
||||||
|
"@tiptap/starter-kit": "^3.22.1",
|
||||||
|
"@tiptap/vue-3": "^3.22.1",
|
||||||
|
"@uiw/codemirror-theme-dracula": "^4.25.9",
|
||||||
|
"@uiw/codemirror-theme-github": "^4.25.9",
|
||||||
|
"@uiw/codemirror-theme-material": "^4.25.9",
|
||||||
|
"@uiw/codemirror-theme-monokai": "^4.25.9",
|
||||||
|
"@uiw/codemirror-theme-nord": "^4.25.9",
|
||||||
|
"@uiw/codemirror-theme-solarized": "^4.25.9",
|
||||||
|
"@uiw/codemirror-theme-tokyo-night": "^4.25.9",
|
||||||
|
"@uiw/codemirror-themes": "^4.25.9",
|
||||||
|
"antdv-next": "^1.1.8",
|
||||||
|
"axios": "^1.14.0",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
|
"lodash-es": "^4.18.1",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"pinyin-pro": "^3.28.0",
|
||||||
|
"vue": "^3.5.31",
|
||||||
|
"vue-codemirror": "^6.1.1",
|
||||||
|
"vue-echarts": "^8.0.1",
|
||||||
|
"vue-i18n": "^11.3.0",
|
||||||
|
"vue-router": "^5.0.4",
|
||||||
|
"vuedraggable": "^4.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@faker-js/faker": "^10.4.0",
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.4",
|
||||||
|
"oxfmt": "^0.43.0",
|
||||||
|
"oxlint": "^1.58.0",
|
||||||
|
"sass": "^1.69.7",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"typescript": "^6.0.2",
|
||||||
|
"vite": "^8.0.3",
|
||||||
|
"vite-plugin-mock-dev-server": "^2.1.1",
|
||||||
|
"vue-tsc": "^3.2.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
6014
antdv-next-admin/pnpm-lock.yaml
Normal file
6014
antdv-next-admin/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
0
antdv-next-admin/public/.nojekyll
Normal file
0
antdv-next-admin/public/.nojekyll
Normal file
16
antdv-next-admin/public/404.html
Normal file
16
antdv-next-admin/public/404.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Antdv Next Admin</title>
|
||||||
|
<script>
|
||||||
|
// GitHub Pages SPA 路由重定向解决方案
|
||||||
|
// 将路径信息存储到 sessionStorage,然后重定向到首页
|
||||||
|
sessionStorage.redirect = location.href;
|
||||||
|
</script>
|
||||||
|
<meta http-equiv="refresh" content="0;URL='/'">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>Redirecting...</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
antdv-next-admin/public/logo.png
Normal file
BIN
antdv-next-admin/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
10
antdv-next-admin/public/logo.svg
Normal file
10
antdv-next-admin/public/logo.svg
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#1890ff;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#096dd9;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="50" cy="50" r="45" fill="url(#grad)"/>
|
||||||
|
<text x="50" y="65" font-family="Arial, sans-serif" font-size="45" font-weight="bold" fill="white" text-anchor="middle">A</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 502 B |
63
antdv-next-admin/src/App.vue
Normal file
63
antdv-next-admin/src/App.vue
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<a-config-provider
|
||||||
|
:theme="antdThemeConfig"
|
||||||
|
:input="inputConfig"
|
||||||
|
:select="selectConfig"
|
||||||
|
:date-picker="datePickerConfig"
|
||||||
|
:range-picker="datePickerConfig"
|
||||||
|
>
|
||||||
|
<router-view />
|
||||||
|
</a-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { theme as antdTheme, type ThemeConfig } from "antdv-next";
|
||||||
|
import { computed, onMounted } from "vue";
|
||||||
|
|
||||||
|
import { appDefaultSettings } from "./settings";
|
||||||
|
import { useNotificationStore } from "./stores/notification";
|
||||||
|
import { useSettingsStore } from "./stores/settings";
|
||||||
|
import { useThemeStore } from "./stores/theme";
|
||||||
|
import { useWatermarkStore } from "./stores/watermark";
|
||||||
|
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const watermarkStore = useWatermarkStore();
|
||||||
|
const notificationStore = useNotificationStore();
|
||||||
|
|
||||||
|
const antdThemeConfig = computed<ThemeConfig>(() => ({
|
||||||
|
algorithm: themeStore.isDark
|
||||||
|
? antdTheme.darkAlgorithm
|
||||||
|
: antdTheme.defaultAlgorithm,
|
||||||
|
token: {
|
||||||
|
colorPrimary: settingsStore.primaryColorHex,
|
||||||
|
colorLink: settingsStore.primaryColorHex,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const inputConfig = computed(() => appDefaultSettings.input);
|
||||||
|
const selectConfig = computed(
|
||||||
|
() => appDefaultSettings.select as unknown as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
const datePickerConfig = computed(
|
||||||
|
() => appDefaultSettings.datePicker as unknown as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Initialize theme and settings from localStorage
|
||||||
|
themeStore.initTheme();
|
||||||
|
settingsStore.initSettings();
|
||||||
|
watermarkStore.initWatermark();
|
||||||
|
notificationStore.initNotifications();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: var(--color-bg-layout);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
32
antdv-next-admin/src/api/auth.ts
Normal file
32
antdv-next-admin/src/api/auth.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import type { ApiResponse } from '@/types/api';
|
||||||
|
import type { LoginParams, LoginResult, User } from '@/types/auth';
|
||||||
|
|
||||||
|
import { request } from '@/utils/request';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login
|
||||||
|
*/
|
||||||
|
export function login(data: LoginParams): Promise<ApiResponse<LoginResult>> {
|
||||||
|
return request.post('/auth/login', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout
|
||||||
|
*/
|
||||||
|
export function logout(): Promise<ApiResponse<null>> {
|
||||||
|
return request.post('/auth/logout');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user info
|
||||||
|
*/
|
||||||
|
export function getUserInfo(): Promise<ApiResponse<User>> {
|
||||||
|
return request.get('/auth/info');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh token
|
||||||
|
*/
|
||||||
|
export function refreshToken(refreshToken: string): Promise<ApiResponse<LoginResult>> {
|
||||||
|
return request.post('/auth/refresh', { refreshToken });
|
||||||
|
}
|
||||||
111
antdv-next-admin/src/api/config.ts
Normal file
111
antdv-next-admin/src/api/config.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import type { ApiResponse } from "@/types/api";
|
||||||
|
import type { SysConfig, SysConfigQueryParams } from "@/types/config";
|
||||||
|
|
||||||
|
import { request } from "@/utils/request";
|
||||||
|
|
||||||
|
const isMock = import.meta.env.VITE_USE_MOCK === "true";
|
||||||
|
|
||||||
|
const ok = <T>(data: T, message = "success"): ApiResponse<T> => ({
|
||||||
|
code: 200,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = <T = null>(code: number, message: string): ApiResponse<T> => ({
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
data: null as T,
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function getConfigList(params: SysConfigQueryParams): Promise<
|
||||||
|
ApiResponse<{
|
||||||
|
list: SysConfig[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
if (!isMock) return request.get("/config/list", { params });
|
||||||
|
|
||||||
|
const { sysConfigs } = await import("../../mock/data/config.data");
|
||||||
|
const { page = 1, pageSize = 10, key, group } = params;
|
||||||
|
let filtered = [...sysConfigs];
|
||||||
|
|
||||||
|
if (key) filtered = filtered.filter((item) => item.key.includes(key));
|
||||||
|
if (group) filtered = filtered.filter((item) => item.group === group);
|
||||||
|
|
||||||
|
filtered.sort((a, b) => a.sort - b.sort);
|
||||||
|
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const list = filtered.slice(start, start + pageSize);
|
||||||
|
|
||||||
|
return ok({ list, total: filtered.length, page, pageSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConfigByKey(
|
||||||
|
key: string,
|
||||||
|
): Promise<ApiResponse<SysConfig>> {
|
||||||
|
if (!isMock) return request.get(`/config/key/${key}`);
|
||||||
|
|
||||||
|
const { sysConfigs } = await import("../../mock/data/config.data");
|
||||||
|
const config = sysConfigs.find((item) => item.key === key);
|
||||||
|
if (!config) return error(404, "配置不存在");
|
||||||
|
return ok(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createConfig(
|
||||||
|
data: Partial<SysConfig>,
|
||||||
|
): Promise<ApiResponse<SysConfig>> {
|
||||||
|
if (!isMock) return request.post("/config", data);
|
||||||
|
|
||||||
|
const { sysConfigs } = await import("../../mock/data/config.data");
|
||||||
|
const newConfig: SysConfig = {
|
||||||
|
id: String(Date.now()),
|
||||||
|
name: data.name || "",
|
||||||
|
key: data.key || "",
|
||||||
|
value: data.value || "",
|
||||||
|
valueType: data.valueType || "string",
|
||||||
|
group: data.group || "basic",
|
||||||
|
description: data.description,
|
||||||
|
builtIn: false,
|
||||||
|
sort: data.sort || 99,
|
||||||
|
createTime: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
updateTime: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
};
|
||||||
|
sysConfigs.push(newConfig);
|
||||||
|
return ok(newConfig, "创建成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateConfig(
|
||||||
|
id: string,
|
||||||
|
data: Partial<SysConfig>,
|
||||||
|
): Promise<ApiResponse<SysConfig>> {
|
||||||
|
if (!isMock) return request.put(`/config/${id}`, data);
|
||||||
|
|
||||||
|
const { sysConfigs } = await import("../../mock/data/config.data");
|
||||||
|
const index = sysConfigs.findIndex((item) => item.id === id);
|
||||||
|
if (index === -1) return error(404, "配置不存在");
|
||||||
|
|
||||||
|
sysConfigs[index] = {
|
||||||
|
...sysConfigs[index],
|
||||||
|
...data,
|
||||||
|
updateTime: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
};
|
||||||
|
return ok(sysConfigs[index], "更新成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteConfig(id: string): Promise<ApiResponse<void>> {
|
||||||
|
if (!isMock) return request.delete(`/config/${id}`);
|
||||||
|
|
||||||
|
const { sysConfigs } = await import("../../mock/data/config.data");
|
||||||
|
const index = sysConfigs.findIndex((item) => item.id === id);
|
||||||
|
if (index === -1) return error(404, "配置不存在");
|
||||||
|
|
||||||
|
const config = sysConfigs[index];
|
||||||
|
if (config.builtIn) return error(400, "内置配置不可删除");
|
||||||
|
|
||||||
|
sysConfigs.splice(index, 1);
|
||||||
|
return ok(undefined as unknown as void, "删除成功");
|
||||||
|
}
|
||||||
122
antdv-next-admin/src/api/dept.ts
Normal file
122
antdv-next-admin/src/api/dept.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import type { ApiResponse } from "@/types/api";
|
||||||
|
import type { Department, DeptQueryParams } from "@/types/dept";
|
||||||
|
|
||||||
|
import { request } from "@/utils/request";
|
||||||
|
|
||||||
|
const isMock = import.meta.env.VITE_USE_MOCK === "true";
|
||||||
|
|
||||||
|
const ok = <T>(data: T, message = "success"): ApiResponse<T> => ({
|
||||||
|
code: 200,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = <T = null>(code: number, message: string): ApiResponse<T> => ({
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
data: null as T,
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取部门树
|
||||||
|
*/
|
||||||
|
export async function getDeptTree(
|
||||||
|
params?: DeptQueryParams,
|
||||||
|
): Promise<ApiResponse<Department[]>> {
|
||||||
|
if (!isMock) return request.get("/dept/tree", { params });
|
||||||
|
|
||||||
|
const { departments, buildDeptTree } =
|
||||||
|
await import("../../mock/data/dept.data");
|
||||||
|
const { name, status } = params || {};
|
||||||
|
let filtered = [...departments];
|
||||||
|
|
||||||
|
if (name) filtered = filtered.filter((item) => item.name.includes(name));
|
||||||
|
if (status) filtered = filtered.filter((item) => item.status === status);
|
||||||
|
|
||||||
|
return ok(buildDeptTree(filtered));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取部门列表(扁平)
|
||||||
|
*/
|
||||||
|
export async function getDeptList(
|
||||||
|
params?: DeptQueryParams,
|
||||||
|
): Promise<ApiResponse<Department[]>> {
|
||||||
|
if (!isMock) return request.get("/dept/list", { params });
|
||||||
|
|
||||||
|
const { departments } = await import("../../mock/data/dept.data");
|
||||||
|
const { name, status } = params || {};
|
||||||
|
let filtered = [...departments];
|
||||||
|
|
||||||
|
if (name) filtered = filtered.filter((item) => item.name.includes(name));
|
||||||
|
if (status) filtered = filtered.filter((item) => item.status === status);
|
||||||
|
|
||||||
|
filtered.sort((a, b) => a.sort - b.sort);
|
||||||
|
return ok(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建部门
|
||||||
|
*/
|
||||||
|
export async function createDept(
|
||||||
|
data: Partial<Department>,
|
||||||
|
): Promise<ApiResponse<Department>> {
|
||||||
|
if (!isMock) return request.post("/dept", data);
|
||||||
|
|
||||||
|
const { departments } = await import("../../mock/data/dept.data");
|
||||||
|
const newDept: Department = {
|
||||||
|
id: String(Date.now()),
|
||||||
|
name: data.name || "",
|
||||||
|
parentId: data.parentId || null,
|
||||||
|
leader: data.leader,
|
||||||
|
phone: data.phone,
|
||||||
|
email: data.email,
|
||||||
|
sort: data.sort || 0,
|
||||||
|
status: data.status || "enabled",
|
||||||
|
remark: data.remark,
|
||||||
|
createTime: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
updateTime: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
};
|
||||||
|
departments.push(newDept);
|
||||||
|
return ok(newDept, "创建成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新部门
|
||||||
|
*/
|
||||||
|
export async function updateDept(
|
||||||
|
id: string,
|
||||||
|
data: Partial<Department>,
|
||||||
|
): Promise<ApiResponse<Department>> {
|
||||||
|
if (!isMock) return request.put(`/dept/${id}`, data);
|
||||||
|
|
||||||
|
const { departments } = await import("../../mock/data/dept.data");
|
||||||
|
const index = departments.findIndex((item) => item.id === id);
|
||||||
|
if (index === -1) return error(404, "部门不存在");
|
||||||
|
|
||||||
|
departments[index] = {
|
||||||
|
...departments[index],
|
||||||
|
...data,
|
||||||
|
updateTime: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
};
|
||||||
|
return ok(departments[index], "更新成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除部门
|
||||||
|
*/
|
||||||
|
export async function deleteDept(id: string): Promise<ApiResponse<void>> {
|
||||||
|
if (!isMock) return request.delete(`/dept/${id}`);
|
||||||
|
|
||||||
|
const { departments } = await import("../../mock/data/dept.data");
|
||||||
|
const hasChildren = departments.some((item) => item.parentId === id);
|
||||||
|
if (hasChildren) return error(400, "存在子部门,无法删除");
|
||||||
|
|
||||||
|
const index = departments.findIndex((item) => item.id === id);
|
||||||
|
if (index === -1) return error(404, "部门不存在");
|
||||||
|
|
||||||
|
departments.splice(index, 1);
|
||||||
|
return ok(undefined as unknown as void, "删除成功");
|
||||||
|
}
|
||||||
235
antdv-next-admin/src/api/dict.ts
Normal file
235
antdv-next-admin/src/api/dict.ts
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
import type { ApiResponse } from "@/types/api";
|
||||||
|
import type {
|
||||||
|
DictType,
|
||||||
|
DictData,
|
||||||
|
DictQueryParams,
|
||||||
|
DictTypeQueryParams,
|
||||||
|
} from "@/types/dict";
|
||||||
|
|
||||||
|
import { request } from "@/utils/request";
|
||||||
|
|
||||||
|
const isMock = import.meta.env.VITE_USE_MOCK === "true";
|
||||||
|
|
||||||
|
const ok = <T>(data: T, message = "success"): ApiResponse<T> => ({
|
||||||
|
code: 200,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = <T = null>(code: number, message: string): ApiResponse<T> => ({
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
data: null as T,
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有字典类型
|
||||||
|
*/
|
||||||
|
export async function getDictTypes(): Promise<ApiResponse<DictType[]>> {
|
||||||
|
if (!isMock) return request.get("/dict/types");
|
||||||
|
|
||||||
|
const { dictTypes } = await import("../../mock/data/dict.data");
|
||||||
|
return ok(dictTypes.filter((t) => t.status === "enabled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取字典类型列表(分页)
|
||||||
|
*/
|
||||||
|
export async function getDictTypeList(params: DictTypeQueryParams): Promise<
|
||||||
|
ApiResponse<{
|
||||||
|
list: DictType[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
if (!isMock) return request.get("/dict/type/list", { params });
|
||||||
|
|
||||||
|
const { dictTypes } = await import("../../mock/data/dict.data");
|
||||||
|
const { page = 1, pageSize = 10, code, name, status } = params;
|
||||||
|
let filtered = [...dictTypes];
|
||||||
|
|
||||||
|
if (code) filtered = filtered.filter((item) => item.code.includes(code));
|
||||||
|
if (name) filtered = filtered.filter((item) => item.name.includes(name));
|
||||||
|
if (status) filtered = filtered.filter((item) => item.status === status);
|
||||||
|
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const list = filtered.slice(start, start + pageSize);
|
||||||
|
|
||||||
|
return ok({ list, total: filtered.length, page, pageSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建字典类型
|
||||||
|
*/
|
||||||
|
export async function createDictType(
|
||||||
|
data: Partial<DictType>,
|
||||||
|
): Promise<ApiResponse<DictType>> {
|
||||||
|
if (!isMock) return request.post("/dict/type", data);
|
||||||
|
|
||||||
|
const { dictTypes } = await import("../../mock/data/dict.data");
|
||||||
|
const newType: DictType = {
|
||||||
|
id: String(Date.now()),
|
||||||
|
name: data.name || "",
|
||||||
|
code: data.code || "",
|
||||||
|
description: data.description,
|
||||||
|
status: data.status || "enabled",
|
||||||
|
createTime: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
updateTime: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
};
|
||||||
|
dictTypes.push(newType);
|
||||||
|
return ok(newType, "创建成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新字典类型
|
||||||
|
*/
|
||||||
|
export async function updateDictType(
|
||||||
|
id: string,
|
||||||
|
data: Partial<DictType>,
|
||||||
|
): Promise<ApiResponse<DictType>> {
|
||||||
|
if (!isMock) return request.put(`/dict/type/${id}`, data);
|
||||||
|
|
||||||
|
const { dictTypes } = await import("../../mock/data/dict.data");
|
||||||
|
const index = dictTypes.findIndex((item) => item.id === id);
|
||||||
|
if (index === -1) return error(404, "字典类型不存在");
|
||||||
|
|
||||||
|
dictTypes[index] = {
|
||||||
|
...dictTypes[index],
|
||||||
|
...data,
|
||||||
|
updateTime: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
};
|
||||||
|
return ok(dictTypes[index], "更新成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除字典类型
|
||||||
|
*/
|
||||||
|
export async function deleteDictType(id: string): Promise<ApiResponse<void>> {
|
||||||
|
if (!isMock) return request.delete(`/dict/type/${id}`);
|
||||||
|
|
||||||
|
const { dictTypes } = await import("../../mock/data/dict.data");
|
||||||
|
const index = dictTypes.findIndex((item) => item.id === id);
|
||||||
|
if (index === -1) return error(404, "字典类型不存在");
|
||||||
|
|
||||||
|
dictTypes.splice(index, 1);
|
||||||
|
return ok(undefined as unknown as void, "删除成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有字典数据
|
||||||
|
*/
|
||||||
|
export async function getAllDictData(): Promise<ApiResponse<DictData[]>> {
|
||||||
|
if (!isMock) return request.get("/dict/data/all");
|
||||||
|
|
||||||
|
const { dictData } = await import("../../mock/data/dict.data");
|
||||||
|
return ok(dictData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据类型获取字典数据
|
||||||
|
*/
|
||||||
|
export async function getDictDataByType(
|
||||||
|
typeCode: string,
|
||||||
|
): Promise<ApiResponse<DictData[]>> {
|
||||||
|
if (!isMock) return request.get(`/dict/data/${typeCode}`);
|
||||||
|
|
||||||
|
const { dictData } = await import("../../mock/data/dict.data");
|
||||||
|
const filtered = dictData.filter(
|
||||||
|
(d) => d.typeCode === typeCode && d.status === "enabled",
|
||||||
|
);
|
||||||
|
filtered.sort((a, b) => a.sort - b.sort);
|
||||||
|
return ok(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取字典数据列表(分页)
|
||||||
|
*/
|
||||||
|
export async function getDictDataList(params: DictQueryParams): Promise<
|
||||||
|
ApiResponse<{
|
||||||
|
list: DictData[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
if (!isMock) return request.get("/dict/data/list", { params });
|
||||||
|
|
||||||
|
const { dictData } = await import("../../mock/data/dict.data");
|
||||||
|
const { page = 1, pageSize = 10, typeCode, label, value, status } = params;
|
||||||
|
let filtered = [...dictData];
|
||||||
|
|
||||||
|
if (typeCode)
|
||||||
|
filtered = filtered.filter((item) => item.typeCode === typeCode);
|
||||||
|
if (label) filtered = filtered.filter((item) => item.label.includes(label));
|
||||||
|
if (value) filtered = filtered.filter((item) => item.value.includes(value));
|
||||||
|
if (status) filtered = filtered.filter((item) => item.status === status);
|
||||||
|
|
||||||
|
filtered.sort((a, b) => a.sort - b.sort);
|
||||||
|
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const list = filtered.slice(start, start + pageSize);
|
||||||
|
|
||||||
|
return ok({ list, total: filtered.length, page, pageSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建字典数据
|
||||||
|
*/
|
||||||
|
export async function createDictData(
|
||||||
|
data: Partial<DictData>,
|
||||||
|
): Promise<ApiResponse<DictData>> {
|
||||||
|
if (!isMock) return request.post("/dict/data", data);
|
||||||
|
|
||||||
|
const { dictData } = await import("../../mock/data/dict.data");
|
||||||
|
const newData: DictData = {
|
||||||
|
id: String(Date.now()),
|
||||||
|
typeCode: data.typeCode || "",
|
||||||
|
label: data.label || "",
|
||||||
|
value: data.value || "",
|
||||||
|
sort: data.sort || 0,
|
||||||
|
status: data.status || "enabled",
|
||||||
|
remark: data.remark,
|
||||||
|
createTime: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
updateTime: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
};
|
||||||
|
dictData.push(newData);
|
||||||
|
return ok(newData, "创建成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新字典数据
|
||||||
|
*/
|
||||||
|
export async function updateDictData(
|
||||||
|
id: string,
|
||||||
|
data: Partial<DictData>,
|
||||||
|
): Promise<ApiResponse<DictData>> {
|
||||||
|
if (!isMock) return request.put(`/dict/data/${id}`, data);
|
||||||
|
|
||||||
|
const { dictData } = await import("../../mock/data/dict.data");
|
||||||
|
const index = dictData.findIndex((item) => item.id === id);
|
||||||
|
if (index === -1) return error(404, "字典数据不存在");
|
||||||
|
|
||||||
|
dictData[index] = {
|
||||||
|
...dictData[index],
|
||||||
|
...data,
|
||||||
|
updateTime: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
};
|
||||||
|
return ok(dictData[index], "更新成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除字典数据
|
||||||
|
*/
|
||||||
|
export async function deleteDictData(id: string): Promise<ApiResponse<void>> {
|
||||||
|
if (!isMock) return request.delete(`/dict/data/${id}`);
|
||||||
|
|
||||||
|
const { dictData } = await import("../../mock/data/dict.data");
|
||||||
|
const index = dictData.findIndex((item) => item.id === id);
|
||||||
|
if (index === -1) return error(404, "字典数据不存在");
|
||||||
|
|
||||||
|
dictData.splice(index, 1);
|
||||||
|
return ok(undefined as unknown as void, "删除成功");
|
||||||
|
}
|
||||||
58
antdv-next-admin/src/api/file.ts
Normal file
58
antdv-next-admin/src/api/file.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import type { ApiResponse } from "@/types/api";
|
||||||
|
import type { SysFile, SysFileQueryParams } from "@/types/file";
|
||||||
|
|
||||||
|
import { request } from "@/utils/request";
|
||||||
|
|
||||||
|
const isMock = import.meta.env.VITE_USE_MOCK === "true";
|
||||||
|
|
||||||
|
const ok = <T>(data: T, message = "success"): ApiResponse<T> => ({
|
||||||
|
code: 200,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = <T = null>(code: number, message: string): ApiResponse<T> => ({
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
data: null as T,
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function getFileList(params: SysFileQueryParams): Promise<
|
||||||
|
ApiResponse<{
|
||||||
|
list: SysFile[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
if (!isMock) return request.get("/file/list", { params });
|
||||||
|
|
||||||
|
const { sysFiles } = await import("../../mock/data/file.data");
|
||||||
|
const { page = 1, pageSize = 10, name, ext, storage } = params;
|
||||||
|
let filtered = [...sysFiles];
|
||||||
|
|
||||||
|
if (name)
|
||||||
|
filtered = filtered.filter((f) =>
|
||||||
|
f.originalName.toLowerCase().includes(name.toLowerCase()),
|
||||||
|
);
|
||||||
|
if (ext) filtered = filtered.filter((f) => f.ext === ext);
|
||||||
|
if (storage) filtered = filtered.filter((f) => f.storage === storage);
|
||||||
|
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const list = filtered.slice(start, start + pageSize);
|
||||||
|
|
||||||
|
return ok({ list, total: filtered.length, page, pageSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFile(id: string): Promise<ApiResponse<void>> {
|
||||||
|
if (!isMock) return request.delete(`/file/${id}`);
|
||||||
|
|
||||||
|
const { sysFiles } = await import("../../mock/data/file.data");
|
||||||
|
const index = sysFiles.findIndex((f) => f.id === id);
|
||||||
|
if (index === -1) return error(404, "文件不存在");
|
||||||
|
|
||||||
|
sysFiles.splice(index, 1);
|
||||||
|
return ok(undefined, "删除成功");
|
||||||
|
}
|
||||||
98
antdv-next-admin/src/api/log.ts
Normal file
98
antdv-next-admin/src/api/log.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import type { ApiResponse } from "@/types/api";
|
||||||
|
import type {
|
||||||
|
OperationLog,
|
||||||
|
LoginLog,
|
||||||
|
OperationLogQueryParams,
|
||||||
|
LoginLogQueryParams,
|
||||||
|
} from "@/types/log";
|
||||||
|
|
||||||
|
import { request } from "@/utils/request";
|
||||||
|
|
||||||
|
const isMock = import.meta.env.VITE_USE_MOCK === "true";
|
||||||
|
|
||||||
|
const ok = <T>(data: T, message = "success"): ApiResponse<T> => ({
|
||||||
|
code: 200,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取操作日志列表
|
||||||
|
*/
|
||||||
|
export async function getOperationLogList(
|
||||||
|
params: OperationLogQueryParams,
|
||||||
|
): Promise<
|
||||||
|
ApiResponse<{
|
||||||
|
list: OperationLog[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
if (!isMock) return request.get("/log/operation/list", { params });
|
||||||
|
|
||||||
|
const { operationLogs } = await import("../../mock/data/log.data");
|
||||||
|
const { page = 1, pageSize = 10, username, module, action, status } = params;
|
||||||
|
let filtered = [...operationLogs];
|
||||||
|
|
||||||
|
if (username)
|
||||||
|
filtered = filtered.filter((l) => l.username.includes(username));
|
||||||
|
if (module) filtered = filtered.filter((l) => l.module === module);
|
||||||
|
if (action) filtered = filtered.filter((l) => l.action === action);
|
||||||
|
if (status) filtered = filtered.filter((l) => l.status === status);
|
||||||
|
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const list = filtered.slice(start, start + pageSize);
|
||||||
|
|
||||||
|
return ok({ list, total: filtered.length, page, pageSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取登录日志列表
|
||||||
|
*/
|
||||||
|
export async function getLoginLogList(params: LoginLogQueryParams): Promise<
|
||||||
|
ApiResponse<{
|
||||||
|
list: LoginLog[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
if (!isMock) return request.get("/log/login/list", { params });
|
||||||
|
|
||||||
|
const { loginLogs } = await import("../../mock/data/log.data");
|
||||||
|
const { page = 1, pageSize = 10, username, status } = params;
|
||||||
|
let filtered = [...loginLogs];
|
||||||
|
|
||||||
|
if (username)
|
||||||
|
filtered = filtered.filter((l) => l.username.includes(username));
|
||||||
|
if (status) filtered = filtered.filter((l) => l.status === status);
|
||||||
|
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const list = filtered.slice(start, start + pageSize);
|
||||||
|
|
||||||
|
return ok({ list, total: filtered.length, page, pageSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空操作日志
|
||||||
|
*/
|
||||||
|
export async function clearOperationLog(): Promise<ApiResponse<void>> {
|
||||||
|
if (!isMock) return request.delete("/log/operation/clear");
|
||||||
|
|
||||||
|
const { operationLogs } = await import("../../mock/data/log.data");
|
||||||
|
operationLogs.splice(0, operationLogs.length);
|
||||||
|
return ok(undefined, "清空成功");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空登录日志
|
||||||
|
*/
|
||||||
|
export async function clearLoginLog(): Promise<ApiResponse<void>> {
|
||||||
|
if (!isMock) return request.delete("/log/login/clear");
|
||||||
|
|
||||||
|
const { loginLogs } = await import("../../mock/data/log.data");
|
||||||
|
loginLogs.splice(0, loginLogs.length);
|
||||||
|
return ok(undefined, "清空成功");
|
||||||
|
}
|
||||||
158
antdv-next-admin/src/api/permission.ts
Normal file
158
antdv-next-admin/src/api/permission.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import type { ApiResponse } from "@/types/api";
|
||||||
|
import type { Permission } from "@/types/auth";
|
||||||
|
|
||||||
|
import { request } from "@/utils/request";
|
||||||
|
|
||||||
|
const isMock = import.meta.env.VITE_USE_MOCK === "true";
|
||||||
|
|
||||||
|
const ok = <T>(data: T, message = "Success"): ApiResponse<T> => ({
|
||||||
|
code: 200,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const notFound = <T = null>(message = "Not found"): ApiResponse<T> => ({
|
||||||
|
code: 404,
|
||||||
|
message,
|
||||||
|
data: null as T,
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get permission list
|
||||||
|
*/
|
||||||
|
export async function getPermissionList(
|
||||||
|
params?: Record<string, unknown>,
|
||||||
|
): Promise<ApiResponse<Permission[]>> {
|
||||||
|
if (!isMock) return request.get("/permissions", { params });
|
||||||
|
|
||||||
|
const { mockPermissions } = await import("../../mock/data/permissions.data");
|
||||||
|
return ok(mockPermissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get permission tree
|
||||||
|
*/
|
||||||
|
export async function getPermissionTree(): Promise<ApiResponse<Permission[]>> {
|
||||||
|
if (!isMock) return request.get("/permissions/tree");
|
||||||
|
|
||||||
|
const { mockPermissions } = await import("../../mock/data/permissions.data");
|
||||||
|
return ok(mockPermissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get permission by ID
|
||||||
|
*/
|
||||||
|
export async function getPermissionById(
|
||||||
|
id: string,
|
||||||
|
): Promise<ApiResponse<Permission>> {
|
||||||
|
if (!isMock) return request.get(`/permissions/${id}`);
|
||||||
|
|
||||||
|
const { mockPermissions } = await import("../../mock/data/permissions.data");
|
||||||
|
|
||||||
|
const find = (list: Permission[]): Permission | undefined => {
|
||||||
|
for (const p of list) {
|
||||||
|
if (p.id === id) return p;
|
||||||
|
if (p.children?.length) {
|
||||||
|
const hit = find(p.children as Permission[]);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const perm = find(mockPermissions);
|
||||||
|
if (!perm) return notFound("Permission not found");
|
||||||
|
return ok(perm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create permission
|
||||||
|
*/
|
||||||
|
export async function createPermission(
|
||||||
|
data: Partial<Permission>,
|
||||||
|
): Promise<ApiResponse<Permission>> {
|
||||||
|
if (!isMock) return request.post("/permissions", data);
|
||||||
|
|
||||||
|
const { mockPermissions } = await import("../../mock/data/permissions.data");
|
||||||
|
const nowId = String(Date.now());
|
||||||
|
const perm: Permission = {
|
||||||
|
id: nowId,
|
||||||
|
name: data.name || "New Permission",
|
||||||
|
code: data.code || `perm_${nowId}`,
|
||||||
|
description: data.description || "",
|
||||||
|
resource: data.resource || "",
|
||||||
|
action: data.action || "view",
|
||||||
|
type: (data.type as Permission["type"]) || "api",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPermissions.unshift(perm);
|
||||||
|
return ok(perm, "Permission created successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update permission
|
||||||
|
*/
|
||||||
|
export async function updatePermission(
|
||||||
|
id: string,
|
||||||
|
data: Partial<Permission>,
|
||||||
|
): Promise<ApiResponse<Permission>> {
|
||||||
|
if (!isMock) return request.put(`/permissions/${id}`, data);
|
||||||
|
|
||||||
|
const { mockPermissions } = await import("../../mock/data/permissions.data");
|
||||||
|
|
||||||
|
const updateRec = (list: Permission[]): Permission | undefined => {
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
const p = list[i];
|
||||||
|
if (p.id === id) {
|
||||||
|
list[i] = { ...p, ...data } as Permission;
|
||||||
|
return list[i];
|
||||||
|
}
|
||||||
|
if (p.children?.length) {
|
||||||
|
const hit = updateRec(p.children as Permission[]);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updated = updateRec(mockPermissions);
|
||||||
|
if (!updated) return notFound("Permission not found");
|
||||||
|
return ok(updated, "Permission updated successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete permission
|
||||||
|
*/
|
||||||
|
export async function deletePermission(id: string): Promise<ApiResponse<null>> {
|
||||||
|
if (!isMock) return request.delete(`/permissions/${id}`);
|
||||||
|
|
||||||
|
const { mockPermissions } = await import("../../mock/data/permissions.data");
|
||||||
|
|
||||||
|
const deleteRec = (list: Permission[]): boolean => {
|
||||||
|
const idx = list.findIndex((p) => p.id === id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
list.splice(idx, 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (const p of list) {
|
||||||
|
if (p.children?.length && deleteRec(p.children as Permission[]))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!deleteRec(mockPermissions)) return notFound("Permission not found");
|
||||||
|
return ok(null, "Permission deleted successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get permissions for current user
|
||||||
|
*/
|
||||||
|
export async function getUserPermissions(): Promise<ApiResponse<Permission[]>> {
|
||||||
|
if (!isMock) return request.get("/permissions/user");
|
||||||
|
|
||||||
|
const { mockPermissions } = await import("../../mock/data/permissions.data");
|
||||||
|
return ok(mockPermissions);
|
||||||
|
}
|
||||||
125
antdv-next-admin/src/api/role.ts
Normal file
125
antdv-next-admin/src/api/role.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import type { ApiResponse, PageParams, PageResult } from "@/types/api";
|
||||||
|
import type { Role } from "@/types/auth";
|
||||||
|
|
||||||
|
import { request } from "@/utils/request";
|
||||||
|
|
||||||
|
const isMock = import.meta.env.VITE_USE_MOCK === "true";
|
||||||
|
|
||||||
|
const ok = <T>(data: T, message = "Success"): ApiResponse<T> => ({
|
||||||
|
code: 200,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const notFound = <T = null>(message = "Not found"): ApiResponse<T> => ({
|
||||||
|
code: 404,
|
||||||
|
message,
|
||||||
|
data: null as T,
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get role list
|
||||||
|
*/
|
||||||
|
export async function getRoleList(
|
||||||
|
params: PageParams,
|
||||||
|
): Promise<ApiResponse<PageResult<Role>>> {
|
||||||
|
if (!isMock) return request.get("/roles", { params });
|
||||||
|
|
||||||
|
const { mockRoles } = await import("../../mock/data/roles.data");
|
||||||
|
const {
|
||||||
|
current = 1,
|
||||||
|
pageSize = 10,
|
||||||
|
name,
|
||||||
|
code,
|
||||||
|
} = (params || {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
let filtered = [...mockRoles];
|
||||||
|
if (name)
|
||||||
|
filtered = filtered.filter((r) =>
|
||||||
|
r.name?.toLowerCase().includes(String(name).toLowerCase()),
|
||||||
|
);
|
||||||
|
if (code)
|
||||||
|
filtered = filtered.filter((r) =>
|
||||||
|
r.code?.toLowerCase().includes(String(code).toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const cur = Number(current) || 1;
|
||||||
|
const size = Number(pageSize) || 10;
|
||||||
|
const start = (cur - 1) * size;
|
||||||
|
const list = filtered.slice(start, start + size);
|
||||||
|
|
||||||
|
return ok({ list, total: filtered.length, current: cur, pageSize: size });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get role by ID
|
||||||
|
*/
|
||||||
|
export async function getRoleById(id: string): Promise<ApiResponse<Role>> {
|
||||||
|
if (!isMock) return request.get(`/roles/${id}`);
|
||||||
|
|
||||||
|
const { mockRoles } = await import("../../mock/data/roles.data");
|
||||||
|
const role = mockRoles.find((r) => r.id === id);
|
||||||
|
if (!role) return notFound("Role not found");
|
||||||
|
return ok(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create role
|
||||||
|
*/
|
||||||
|
export async function createRole(
|
||||||
|
data: Partial<Role>,
|
||||||
|
): Promise<ApiResponse<Role>> {
|
||||||
|
if (!isMock) return request.post("/roles", data);
|
||||||
|
|
||||||
|
const { mockRoles } = await import("../../mock/data/roles.data");
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const newRole: Role = {
|
||||||
|
id: String(Date.now()),
|
||||||
|
name: data.name || "New Role",
|
||||||
|
code: data.code || `role_${Date.now()}`,
|
||||||
|
description: data.description || "",
|
||||||
|
permissions: data.permissions || [],
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRoles.unshift(newRole);
|
||||||
|
return ok(newRole, "Role created successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update role
|
||||||
|
*/
|
||||||
|
export async function updateRole(
|
||||||
|
id: string,
|
||||||
|
data: Partial<Role>,
|
||||||
|
): Promise<ApiResponse<Role>> {
|
||||||
|
if (!isMock) return request.put(`/roles/${id}`, data);
|
||||||
|
|
||||||
|
const { mockRoles } = await import("../../mock/data/roles.data");
|
||||||
|
const idx = mockRoles.findIndex((r) => r.id === id);
|
||||||
|
if (idx === -1) return notFound("Role not found");
|
||||||
|
|
||||||
|
mockRoles[idx] = {
|
||||||
|
...mockRoles[idx],
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
} as Role;
|
||||||
|
return ok(mockRoles[idx], "Role updated successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete role
|
||||||
|
*/
|
||||||
|
export async function deleteRole(id: string): Promise<ApiResponse<null>> {
|
||||||
|
if (!isMock) return request.delete(`/roles/${id}`);
|
||||||
|
|
||||||
|
const { mockRoles } = await import("../../mock/data/roles.data");
|
||||||
|
const idx = mockRoles.findIndex((r) => r.id === id);
|
||||||
|
if (idx === -1) return notFound("Role not found");
|
||||||
|
|
||||||
|
mockRoles.splice(idx, 1);
|
||||||
|
return ok(null, "Role deleted successfully");
|
||||||
|
}
|
||||||
216
antdv-next-admin/src/api/user.ts
Normal file
216
antdv-next-admin/src/api/user.ts
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import type { ApiResponse, PageParams, PageResult } from "@/types/api";
|
||||||
|
import type { User } from "@/types/auth";
|
||||||
|
|
||||||
|
import { request } from "@/utils/request";
|
||||||
|
|
||||||
|
const isMock = import.meta.env.VITE_USE_MOCK === "true";
|
||||||
|
|
||||||
|
const ok = <T>(data: T, message = "Success"): ApiResponse<T> => ({
|
||||||
|
code: 200,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const notFound = <T = null>(message = "Not found"): ApiResponse<T> => ({
|
||||||
|
code: 404,
|
||||||
|
message,
|
||||||
|
data: null as T,
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user list
|
||||||
|
*/
|
||||||
|
export async function getUserList(
|
||||||
|
params: PageParams,
|
||||||
|
): Promise<ApiResponse<PageResult<User>>> {
|
||||||
|
if (!isMock) return request.get("/users", { params });
|
||||||
|
|
||||||
|
const { mockUsers } = await import("../../mock/data/users.data");
|
||||||
|
|
||||||
|
const {
|
||||||
|
current = 1,
|
||||||
|
pageSize = 10,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
status,
|
||||||
|
gender,
|
||||||
|
} = params || {};
|
||||||
|
|
||||||
|
let filtered = [...mockUsers];
|
||||||
|
if (username)
|
||||||
|
filtered = filtered.filter((u) =>
|
||||||
|
u.username?.toLowerCase().includes(String(username).toLowerCase()),
|
||||||
|
);
|
||||||
|
if (email)
|
||||||
|
filtered = filtered.filter((u) =>
|
||||||
|
u.email?.toLowerCase().includes(String(email).toLowerCase()),
|
||||||
|
);
|
||||||
|
if (gender) {
|
||||||
|
const genderValues = Array.isArray(gender)
|
||||||
|
? gender.map((item) => String(item))
|
||||||
|
: String(gender)
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (genderValues.length > 0) {
|
||||||
|
filtered = filtered.filter((u) =>
|
||||||
|
genderValues.includes(String(u.gender)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (status) filtered = filtered.filter((u) => u.status === status);
|
||||||
|
|
||||||
|
const cur = Number(current) || 1;
|
||||||
|
const size = Number(pageSize) || 10;
|
||||||
|
const start = (cur - 1) * size;
|
||||||
|
const list = filtered.slice(start, start + size);
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
list,
|
||||||
|
total: filtered.length,
|
||||||
|
current: cur,
|
||||||
|
pageSize: size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user by ID
|
||||||
|
*/
|
||||||
|
export async function getUserById(id: string): Promise<ApiResponse<User>> {
|
||||||
|
if (!isMock) return request.get(`/users/${id}`);
|
||||||
|
|
||||||
|
const { mockUsers, adminUser } = await import("../../mock/data/users.data");
|
||||||
|
const user = (id === "1" ? adminUser : mockUsers.find((u) => u.id === id)) as
|
||||||
|
| User
|
||||||
|
| undefined;
|
||||||
|
if (!user) return notFound("User not found");
|
||||||
|
return ok(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create user
|
||||||
|
*/
|
||||||
|
export async function createUser(
|
||||||
|
data: Partial<User>,
|
||||||
|
): Promise<ApiResponse<User>> {
|
||||||
|
if (!isMock) return request.post("/users", data);
|
||||||
|
|
||||||
|
const { mockUsers } = await import("../../mock/data/users.data");
|
||||||
|
const { faker } = await import("@faker-js/faker");
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const newUser: User = {
|
||||||
|
id: faker.string.uuid(),
|
||||||
|
username: data.username || faker.internet.username(),
|
||||||
|
email: data.email || faker.internet.email(),
|
||||||
|
realName: data.realName || faker.person.fullName(),
|
||||||
|
avatar: data.avatar || faker.image.avatar(),
|
||||||
|
phone: data.phone || `1${faker.string.numeric(10)}`,
|
||||||
|
gender: (data.gender as User["gender"]) || "male",
|
||||||
|
birthDate: data.birthDate || "1990-01-01",
|
||||||
|
bio: data.bio || "",
|
||||||
|
status: (data.status as User["status"]) || "active",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
roles: data.roles || [],
|
||||||
|
permissions: data.permissions || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUsers.unshift(newUser);
|
||||||
|
return ok(newUser, "User created successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user
|
||||||
|
*/
|
||||||
|
export async function updateUser(
|
||||||
|
id: string,
|
||||||
|
data: Partial<User>,
|
||||||
|
): Promise<ApiResponse<User>> {
|
||||||
|
if (!isMock) return request.put(`/users/${id}`, data);
|
||||||
|
|
||||||
|
const { mockUsers, adminUser } = await import("../../mock/data/users.data");
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
if (id === "1") {
|
||||||
|
Object.assign(adminUser, data, { updatedAt: now });
|
||||||
|
return ok(adminUser, "User updated successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = mockUsers.findIndex((u) => u.id === id);
|
||||||
|
if (idx === -1) return notFound("User not found");
|
||||||
|
|
||||||
|
mockUsers[idx] = { ...mockUsers[idx], ...data, updatedAt: now } as User;
|
||||||
|
return ok(mockUsers[idx], "User updated successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete user
|
||||||
|
*/
|
||||||
|
export async function deleteUser(id: string): Promise<ApiResponse<null>> {
|
||||||
|
if (!isMock) return request.delete(`/users/${id}`);
|
||||||
|
|
||||||
|
if (id === "1")
|
||||||
|
return {
|
||||||
|
code: 400,
|
||||||
|
message: "Cannot delete admin user",
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mockUsers } = await import("../../mock/data/users.data");
|
||||||
|
const idx = mockUsers.findIndex((u) => u.id === id);
|
||||||
|
if (idx === -1) return notFound("User not found");
|
||||||
|
|
||||||
|
mockUsers.splice(idx, 1);
|
||||||
|
return ok(null, "User deleted successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change password
|
||||||
|
*/
|
||||||
|
export interface ChangePasswordParams {
|
||||||
|
oldPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changePassword(
|
||||||
|
params: ChangePasswordParams,
|
||||||
|
): Promise<ApiResponse<null>> {
|
||||||
|
if (!isMock) return request.post("/users/change-password", params);
|
||||||
|
|
||||||
|
// Mock implementation
|
||||||
|
const { oldPassword, newPassword } = params;
|
||||||
|
|
||||||
|
// Simple validation
|
||||||
|
if (!oldPassword || !newPassword) {
|
||||||
|
return {
|
||||||
|
code: 400,
|
||||||
|
message: "Password cannot be empty",
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldPassword !== "123456") {
|
||||||
|
return {
|
||||||
|
code: 400,
|
||||||
|
message: "Current password is incorrect",
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
return {
|
||||||
|
code: 400,
|
||||||
|
message: "Password must be at least 6 characters",
|
||||||
|
data: null,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(null, "Password changed successfully");
|
||||||
|
}
|
||||||
BIN
antdv-next-admin/src/assets/images/avatar.png
Normal file
BIN
antdv-next-admin/src/assets/images/avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
antdv-next-admin/src/assets/images/logo.png
Normal file
BIN
antdv-next-admin/src/assets/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
313
antdv-next-admin/src/assets/styles/animations.css
Normal file
313
antdv-next-admin/src/assets/styles/animations.css
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
/* Animation Utilities */
|
||||||
|
|
||||||
|
/* ========== Page Transition Animations ========== */
|
||||||
|
|
||||||
|
/* Fade */
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity var(--duration-slow) var(--ease-in-out);
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slide Left */
|
||||||
|
.slide-left-enter-active,
|
||||||
|
.slide-left-leave-active {
|
||||||
|
transition: all var(--duration-slow) var(--ease-in-out);
|
||||||
|
}
|
||||||
|
.slide-left-enter-from {
|
||||||
|
transform: translateX(30px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.slide-left-leave-to {
|
||||||
|
transform: translateX(-30px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slide Right */
|
||||||
|
.slide-right-enter-active,
|
||||||
|
.slide-right-leave-active {
|
||||||
|
transition: all var(--duration-slow) var(--ease-in-out);
|
||||||
|
}
|
||||||
|
.slide-right-enter-from {
|
||||||
|
transform: translateX(-30px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.slide-right-leave-to {
|
||||||
|
transform: translateX(30px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slide Up */
|
||||||
|
.slide-up-enter-active,
|
||||||
|
.slide-up-leave-active {
|
||||||
|
transition: all var(--duration-slow) var(--ease-in-out);
|
||||||
|
}
|
||||||
|
.slide-up-enter-from {
|
||||||
|
transform: translateY(24px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.slide-up-leave-to {
|
||||||
|
transform: translateY(-24px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slide Down */
|
||||||
|
.slide-down-enter-active,
|
||||||
|
.slide-down-leave-active {
|
||||||
|
transition: all var(--duration-slow) var(--ease-in-out);
|
||||||
|
}
|
||||||
|
.slide-down-enter-from {
|
||||||
|
transform: translateY(-24px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.slide-down-leave-to {
|
||||||
|
transform: translateY(24px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zoom */
|
||||||
|
.zoom-enter-active,
|
||||||
|
.zoom-leave-active {
|
||||||
|
transition: all var(--duration-slow) var(--ease-out-back);
|
||||||
|
}
|
||||||
|
.zoom-enter-from,
|
||||||
|
.zoom-leave-to {
|
||||||
|
transform: scale(0.95);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zoom Big */
|
||||||
|
.zoom-big-enter-active,
|
||||||
|
.zoom-big-leave-active {
|
||||||
|
transition: all var(--duration-slow) var(--ease-out-back);
|
||||||
|
}
|
||||||
|
.zoom-big-enter-from {
|
||||||
|
transform: scale(0.8);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.zoom-big-leave-to {
|
||||||
|
transform: scale(1.1);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Keyframe Animations ========== */
|
||||||
|
|
||||||
|
/* Spin */
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton Loading */
|
||||||
|
@keyframes skeleton-loading {
|
||||||
|
0% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 37%, #f0f0f0 63%);
|
||||||
|
background-size: 400% 100%;
|
||||||
|
animation: skeleton-loading 1.4s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark .skeleton {
|
||||||
|
background: linear-gradient(90deg, #2a2a2a 25%, #1f1f1f 37%, #2a2a2a 63%);
|
||||||
|
background-size: 400% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bounce In */
|
||||||
|
@keyframes bounce-in {
|
||||||
|
0% {
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bounce-in {
|
||||||
|
animation: bounce-in var(--duration-slow) var(--ease-out-back);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shake */
|
||||||
|
@keyframes shake {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
10%,
|
||||||
|
30%,
|
||||||
|
50%,
|
||||||
|
70%,
|
||||||
|
90% {
|
||||||
|
transform: translateX(-10px);
|
||||||
|
}
|
||||||
|
20%,
|
||||||
|
40%,
|
||||||
|
60%,
|
||||||
|
80% {
|
||||||
|
transform: translateX(10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shake {
|
||||||
|
animation: shake 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse */
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(24, 144, 255, 0.7);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 10px rgba(24, 144, 255, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(24, 144, 255, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse {
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ripple Effect */
|
||||||
|
@keyframes ripple {
|
||||||
|
to {
|
||||||
|
transform: scale(4);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slide Down */
|
||||||
|
@keyframes slide-down {
|
||||||
|
from {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-down {
|
||||||
|
animation: slide-down var(--duration-slow) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slide Up */
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up {
|
||||||
|
animation: slide-up var(--duration-slow) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade In Down */
|
||||||
|
@keyframes fade-in-down {
|
||||||
|
from {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in-down {
|
||||||
|
animation: fade-in-down var(--duration-slow) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade In Up */
|
||||||
|
@keyframes fade-in-up {
|
||||||
|
from {
|
||||||
|
transform: translateY(10px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in-up {
|
||||||
|
animation: fade-in-up var(--duration-slow) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Utility Classes ========== */
|
||||||
|
|
||||||
|
/* Card Hover Effect */
|
||||||
|
.card-hover {
|
||||||
|
transition: all var(--duration-slow) var(--ease-out);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: var(--shadow-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Hover Effect */
|
||||||
|
.btn-hover {
|
||||||
|
transition: all var(--duration-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-hover:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-hover:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ripple Effect Container */
|
||||||
|
.ripple-effect {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ripple-effect::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ripple-effect:active::after {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
animation: ripple 0.6s ease-out;
|
||||||
|
}
|
||||||
585
antdv-next-admin/src/assets/styles/global.css
Normal file
585
antdv-next-admin/src/assets/styles/global.css
Normal file
@ -0,0 +1,585 @@
|
|||||||
|
/* Global Styles */
|
||||||
|
|
||||||
|
/* ========== CSS Reset ========== */
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Theme Transition ========== */
|
||||||
|
|
||||||
|
html.theme-transition,
|
||||||
|
html.theme-transition *,
|
||||||
|
html.theme-transition *::before,
|
||||||
|
html.theme-transition *::after {
|
||||||
|
transition-property: color, background-color, border-color, box-shadow, fill, stroke;
|
||||||
|
transition-duration: 0.46s;
|
||||||
|
transition-timing-function: var(--ease-in-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保表格元素在 View Transition 期间不会创建独立的层叠上下文 */
|
||||||
|
html.theme-view-transition .ant-table-wrapper,
|
||||||
|
html.theme-view-transition .ant-table,
|
||||||
|
html.theme-view-transition .ant-table-container,
|
||||||
|
html.theme-view-transition .ant-table-content,
|
||||||
|
html.theme-view-transition .ant-table-sticky-holder {
|
||||||
|
contain: none !important;
|
||||||
|
isolation: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html.theme-transition,
|
||||||
|
html.theme-transition *,
|
||||||
|
html.theme-transition *::before,
|
||||||
|
html.theme-transition *::after {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-view-transition::view-transition-old(root),
|
||||||
|
html.theme-view-transition::view-transition-new(root) {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-view-transition::view-transition-old(root),
|
||||||
|
html.theme-view-transition::view-transition-new(root) {
|
||||||
|
mix-blend-mode: normal;
|
||||||
|
will-change: clip-path;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-view-transition::view-transition-old(root) {
|
||||||
|
animation: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-view-transition::view-transition-new(root) {
|
||||||
|
animation: theme-clip-reveal 0.74s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-view-transition.dark::view-transition-old(root) {
|
||||||
|
animation: theme-clip-conceal 0.74s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-view-transition.dark::view-transition-new(root) {
|
||||||
|
animation: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes theme-clip-reveal {
|
||||||
|
from {
|
||||||
|
clip-path: circle(0 at var(--theme-transition-x) var(--theme-transition-y));
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
clip-path: circle(
|
||||||
|
var(--theme-transition-radius) at var(--theme-transition-x) var(--theme-transition-y)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes theme-clip-conceal {
|
||||||
|
from {
|
||||||
|
clip-path: circle(
|
||||||
|
var(--theme-transition-radius) at var(--theme-transition-x) var(--theme-transition-y)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
clip-path: circle(0 at var(--theme-transition-x) var(--theme-transition-y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: var(--line-height-base);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: var(--color-bg-layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Typography ========== */
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
line-height: var(--line-height-tight);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: var(--font-size-4xl);
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: var(--font-size-3xl);
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: var(--font-size-2xl);
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
}
|
||||||
|
h5 {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
h6 {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: var(--line-height-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--duration-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--color-primary-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Lists ========== */
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Scrollbar ========== */
|
||||||
|
|
||||||
|
/* For Webkit browsers */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-border);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For Firefox */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-border) var(--color-bg-layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Utility Classes ========== */
|
||||||
|
|
||||||
|
/* Text Alignment */
|
||||||
|
.text-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text Colors */
|
||||||
|
.text-primary {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.text-secondary {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.text-tertiary {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
.text-success {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
.text-warning {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
.text-error {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
.text-info {
|
||||||
|
color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Font Weights */
|
||||||
|
.font-normal {
|
||||||
|
font-weight: var(--font-weight-normal);
|
||||||
|
}
|
||||||
|
.font-medium {
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
.font-semibold {
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
.font-bold {
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Display */
|
||||||
|
.block {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.inline-block {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.inline {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.inline-flex {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flex Utilities */
|
||||||
|
.flex-row {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.flex-column {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.flex-wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.flex-nowrap {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
.justify-start {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.justify-end {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.justify-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.justify-between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.justify-around {
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
.items-start {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.items-end {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.items-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.items-baseline {
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.items-stretch {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.flex-1 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
.m-0 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.mt-0 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.mr-0 {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
.mb-0 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.ml-0 {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-0 {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.pt-0 {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
.pr-0 {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
.pb-0 {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
.pl-0 {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Margin Spacing */
|
||||||
|
.m-xs {
|
||||||
|
margin: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
.m-sm {
|
||||||
|
margin: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
.m-md {
|
||||||
|
margin: var(--spacing-md);
|
||||||
|
}
|
||||||
|
.m-lg {
|
||||||
|
margin: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
.m-xl {
|
||||||
|
margin: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-xs {
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
.mt-sm {
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
.mt-md {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
}
|
||||||
|
.mt-lg {
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
.mt-xl {
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-xs {
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
.mb-sm {
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
.mb-md {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
.mb-lg {
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
.mb-xl {
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Padding Spacing */
|
||||||
|
.p-xs {
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
.p-sm {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
.p-md {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
.p-lg {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
.p-xl {
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Width */
|
||||||
|
.w-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.w-auto {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Height */
|
||||||
|
.h-full {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.h-auto {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.h-screen {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Border Radius */
|
||||||
|
.rounded-none {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.rounded-sm {
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.rounded {
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
}
|
||||||
|
.rounded-lg {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
.rounded-full {
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cursor */
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cursor-default {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.cursor-not-allowed {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Position */
|
||||||
|
.relative {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.absolute {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
.fixed {
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
.sticky {
|
||||||
|
position: sticky;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overflow */
|
||||||
|
.overflow-auto {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.overflow-hidden {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.overflow-visible {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.overflow-scroll {
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
.transition-all {
|
||||||
|
transition: all var(--duration-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transition-colors {
|
||||||
|
transition:
|
||||||
|
color var(--duration-base) var(--ease-out),
|
||||||
|
background-color var(--duration-base) var(--ease-out),
|
||||||
|
border-color var(--duration-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transition-transform {
|
||||||
|
transition: transform var(--duration-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Card Component ========== */
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--color-bg-container);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
border: 1px solid var(--color-border-secondary);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
transition: all var(--duration-slow) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
box-shadow: var(--shadow-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Page Container ========== */
|
||||||
|
|
||||||
|
.page-container {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: var(--font-size-2xl);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-description {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Loading States ========== */
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Empty States ========== */
|
||||||
|
|
||||||
|
.empty-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--spacing-3xl) var(--spacing-lg);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Responsive ========== */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-container {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
antdv-next-admin/src/assets/styles/tailwind.css
Normal file
16
antdv-next-admin/src/assets/styles/tailwind.css
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
@layer theme, base, antd, components, utilities;
|
||||||
|
|
||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-primary: var(--color-primary);
|
||||||
|
--color-text-primary: var(--color-text-primary);
|
||||||
|
--color-text-secondary: var(--color-text-secondary);
|
||||||
|
--color-bg-container: var(--color-bg-container);
|
||||||
|
--color-bg-layout: var(--color-bg-layout);
|
||||||
|
--color-border: var(--color-border);
|
||||||
|
--color-success: var(--color-success);
|
||||||
|
--color-warning: var(--color-warning);
|
||||||
|
--color-error: var(--color-error);
|
||||||
|
--color-info: var(--color-info);
|
||||||
|
}
|
||||||
249
antdv-next-admin/src/assets/styles/variables.css
Normal file
249
antdv-next-admin/src/assets/styles/variables.css
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
/* CSS Variables - Design Tokens */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ========== Typography ========== */
|
||||||
|
--font-family:
|
||||||
|
'Inter', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', -apple-system,
|
||||||
|
BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
|
||||||
|
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
|
--font-family-number:
|
||||||
|
'DIN Alternate', 'Bahnschrift', 'Inter', 'Roboto', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
|
||||||
|
--font-family-code:
|
||||||
|
'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||||
|
|
||||||
|
/* Font Sizes */
|
||||||
|
--font-size-xs: 12px;
|
||||||
|
--font-size-sm: 14px;
|
||||||
|
--font-size-base: 16px;
|
||||||
|
--font-size-lg: 18px;
|
||||||
|
--font-size-xl: 20px;
|
||||||
|
--font-size-2xl: 24px;
|
||||||
|
--font-size-3xl: 30px;
|
||||||
|
--font-size-4xl: 38px;
|
||||||
|
|
||||||
|
/* Line Heights */
|
||||||
|
--line-height-base: 1.5715;
|
||||||
|
--line-height-tight: 1.3;
|
||||||
|
--line-height-relaxed: 1.8;
|
||||||
|
|
||||||
|
/* Font Weights */
|
||||||
|
--font-weight-normal: 400;
|
||||||
|
--font-weight-medium: 500;
|
||||||
|
--font-weight-semibold: 600;
|
||||||
|
--font-weight-bold: 700;
|
||||||
|
|
||||||
|
/* ========== Spacing ========== */
|
||||||
|
--spacing-xs: 4px;
|
||||||
|
--spacing-sm: 8px;
|
||||||
|
--spacing-md: 16px;
|
||||||
|
--spacing-lg: 24px;
|
||||||
|
--spacing-xl: 32px;
|
||||||
|
--spacing-2xl: 48px;
|
||||||
|
--spacing-3xl: 64px;
|
||||||
|
|
||||||
|
/* ========== Border Radius ========== */
|
||||||
|
--radius-xs: 2px;
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-base: 8px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
--radius-xl: 20px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* ========== Animation Duration ========== */
|
||||||
|
--duration-fast: 0.1s;
|
||||||
|
--duration-base: 0.2s;
|
||||||
|
--duration-slow: 0.3s;
|
||||||
|
--duration-slower: 0.5s;
|
||||||
|
|
||||||
|
/* ========== Easing Functions ========== */
|
||||||
|
--ease-in: cubic-bezier(0.55, 0.055, 0.675, 0.19);
|
||||||
|
--ease-out: cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||||
|
--ease-in-out: cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||||
|
--ease-out-back: cubic-bezier(0.12, 0.4, 0.29, 1.46);
|
||||||
|
--ease-in-back: cubic-bezier(0.71, -0.46, 0.88, 0.6);
|
||||||
|
--ease-in-out-back: cubic-bezier(0.71, -0.46, 0.29, 1.46);
|
||||||
|
|
||||||
|
/* ========== Shadows (Diffused, Professional) ========== */
|
||||||
|
--shadow-1: 0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||||
|
|
||||||
|
--shadow-2: 0 6px 20px rgba(15, 23, 42, 0.06);
|
||||||
|
|
||||||
|
--shadow-3: 0 12px 36px rgba(15, 23, 42, 0.08);
|
||||||
|
|
||||||
|
--shadow-card: 0 6px 24px rgba(15, 23, 42, 0.06);
|
||||||
|
--shadow-card-hover: 0 10px 32px rgba(15, 23, 42, 0.12);
|
||||||
|
--shadow-drawer: 0 8px 40px 0 rgba(0, 0, 0, 0.12);
|
||||||
|
--shadow-modal: 0 12px 48px 0 rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
|
/* ========== Functional Colors ========== */
|
||||||
|
--color-success: #52c41a;
|
||||||
|
--color-success-bg: #f6ffed;
|
||||||
|
--color-success-border: #b7eb8f;
|
||||||
|
|
||||||
|
--color-warning: #faad14;
|
||||||
|
--color-warning-bg: #fffbe6;
|
||||||
|
--color-warning-border: #ffe58f;
|
||||||
|
|
||||||
|
--color-error: #ff4d4f;
|
||||||
|
--color-error-bg: #fff2f0;
|
||||||
|
--color-error-border: #ffccc7;
|
||||||
|
|
||||||
|
--color-info: #1890ff;
|
||||||
|
--color-info-bg: #e6f7ff;
|
||||||
|
--color-info-border: #91d5ff;
|
||||||
|
|
||||||
|
/* ========== Neutral Colors (Light Mode) ========== */
|
||||||
|
--color-text-primary: rgba(0, 0, 0, 0.85);
|
||||||
|
--color-text-secondary: rgba(0, 0, 0, 0.65);
|
||||||
|
--color-text-tertiary: rgba(0, 0, 0, 0.45);
|
||||||
|
--color-text-quaternary: rgba(0, 0, 0, 0.25);
|
||||||
|
|
||||||
|
--color-border: #d9d9d9;
|
||||||
|
--color-border-secondary: #f0f0f0;
|
||||||
|
|
||||||
|
--color-bg-container: #ffffff;
|
||||||
|
--color-bg-layout: #f5f7fa;
|
||||||
|
--color-bg-mask: rgba(0, 0, 0, 0.45);
|
||||||
|
|
||||||
|
/* Fill colors */
|
||||||
|
--color-fill-quaternary: #fafafa;
|
||||||
|
|
||||||
|
/* ========== Z-Index ========== */
|
||||||
|
--z-index-dropdown: 1050;
|
||||||
|
--z-index-modal: 1060;
|
||||||
|
--z-index-drawer: 1070;
|
||||||
|
--z-index-notification: 1080;
|
||||||
|
|
||||||
|
/* ========== Primary Colors (Default: Blue) ========== */
|
||||||
|
/* Provide defaults so styles work even before JS sets data-primary-color */
|
||||||
|
--color-primary-1: #e6f7ff;
|
||||||
|
--color-primary-2: #bae7ff;
|
||||||
|
--color-primary-3: #91d5ff;
|
||||||
|
--color-primary-4: #69c0ff;
|
||||||
|
--color-primary-5: #40a9ff;
|
||||||
|
--color-primary-6: #1890ff;
|
||||||
|
--color-primary-7: #096dd9;
|
||||||
|
--color-primary-8: #0050b3;
|
||||||
|
--color-primary-9: #003a8c;
|
||||||
|
--color-primary-10: #002766;
|
||||||
|
--color-primary: var(--color-primary-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Primary Color Themes ========== */
|
||||||
|
|
||||||
|
/* Blue Theme (Default) */
|
||||||
|
:root[data-primary-color='blue'] {
|
||||||
|
--color-primary-1: #e6f7ff;
|
||||||
|
--color-primary-2: #bae7ff;
|
||||||
|
--color-primary-3: #91d5ff;
|
||||||
|
--color-primary-4: #69c0ff;
|
||||||
|
--color-primary-5: #40a9ff;
|
||||||
|
--color-primary-6: #1890ff;
|
||||||
|
--color-primary-7: #096dd9;
|
||||||
|
--color-primary-8: #0050b3;
|
||||||
|
--color-primary-9: #003a8c;
|
||||||
|
--color-primary-10: #002766;
|
||||||
|
--color-primary: var(--color-primary-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Green Theme */
|
||||||
|
:root[data-primary-color='green'] {
|
||||||
|
--color-primary-1: #f6ffed;
|
||||||
|
--color-primary-2: #d9f7be;
|
||||||
|
--color-primary-3: #b7eb8f;
|
||||||
|
--color-primary-4: #95de64;
|
||||||
|
--color-primary-5: #73d13d;
|
||||||
|
--color-primary-6: #52c41a;
|
||||||
|
--color-primary-7: #389e0d;
|
||||||
|
--color-primary-8: #237804;
|
||||||
|
--color-primary-9: #135200;
|
||||||
|
--color-primary-10: #092b00;
|
||||||
|
--color-primary: var(--color-primary-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Purple Theme */
|
||||||
|
:root[data-primary-color='purple'] {
|
||||||
|
--color-primary-1: #f9f0ff;
|
||||||
|
--color-primary-2: #efdbff;
|
||||||
|
--color-primary-3: #d3adf7;
|
||||||
|
--color-primary-4: #b37feb;
|
||||||
|
--color-primary-5: #9254de;
|
||||||
|
--color-primary-6: #722ed1;
|
||||||
|
--color-primary-7: #531dab;
|
||||||
|
--color-primary-8: #391085;
|
||||||
|
--color-primary-9: #22075e;
|
||||||
|
--color-primary-10: #120338;
|
||||||
|
--color-primary: var(--color-primary-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Red Theme */
|
||||||
|
:root[data-primary-color='red'] {
|
||||||
|
--color-primary-1: #fff1f0;
|
||||||
|
--color-primary-2: #ffccc7;
|
||||||
|
--color-primary-3: #ffa39e;
|
||||||
|
--color-primary-4: #ff7875;
|
||||||
|
--color-primary-5: #ff4d4f;
|
||||||
|
--color-primary-6: #f5222d;
|
||||||
|
--color-primary-7: #cf1322;
|
||||||
|
--color-primary-8: #a8071a;
|
||||||
|
--color-primary-9: #820014;
|
||||||
|
--color-primary-10: #5c0011;
|
||||||
|
--color-primary: var(--color-primary-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Orange Theme */
|
||||||
|
:root[data-primary-color='orange'] {
|
||||||
|
--color-primary-1: #fff7e6;
|
||||||
|
--color-primary-2: #ffe7ba;
|
||||||
|
--color-primary-3: #ffd591;
|
||||||
|
--color-primary-4: #ffc069;
|
||||||
|
--color-primary-5: #ffa940;
|
||||||
|
--color-primary-6: #fa8c16;
|
||||||
|
--color-primary-7: #d46b08;
|
||||||
|
--color-primary-8: #ad4e00;
|
||||||
|
--color-primary-9: #873800;
|
||||||
|
--color-primary-10: #612500;
|
||||||
|
--color-primary: var(--color-primary-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cyan Theme */
|
||||||
|
:root[data-primary-color='cyan'] {
|
||||||
|
--color-primary-1: #e6fffb;
|
||||||
|
--color-primary-2: #b5f5ec;
|
||||||
|
--color-primary-3: #87e8de;
|
||||||
|
--color-primary-4: #5cdbd3;
|
||||||
|
--color-primary-5: #36cfc9;
|
||||||
|
--color-primary-6: #13c2c2;
|
||||||
|
--color-primary-7: #08979c;
|
||||||
|
--color-primary-8: #006d75;
|
||||||
|
--color-primary-9: #00474f;
|
||||||
|
--color-primary-10: #002329;
|
||||||
|
--color-primary: var(--color-primary-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Dark Mode ========== */
|
||||||
|
:root.dark {
|
||||||
|
--color-text-primary: rgba(255, 255, 255, 0.85);
|
||||||
|
--color-text-secondary: rgba(255, 255, 255, 0.65);
|
||||||
|
--color-text-tertiary: rgba(255, 255, 255, 0.45);
|
||||||
|
--color-text-quaternary: rgba(255, 255, 255, 0.25);
|
||||||
|
|
||||||
|
--color-border: #434343;
|
||||||
|
--color-border-secondary: #303030;
|
||||||
|
|
||||||
|
--color-bg-container: #1f1f1f;
|
||||||
|
--color-bg-layout: #141414;
|
||||||
|
--color-bg-mask: rgba(0, 0, 0, 0.65);
|
||||||
|
|
||||||
|
/* Fill colors for dark mode */
|
||||||
|
--color-fill-quaternary: rgba(255, 255, 255, 0.08);
|
||||||
|
|
||||||
|
--shadow-card: 0 8px 28px rgba(0, 0, 0, 0.28);
|
||||||
|
--shadow-card-hover: 0 14px 36px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Gray Mode ========== */
|
||||||
|
html.gray-mode {
|
||||||
|
filter: grayscale(100%);
|
||||||
|
}
|
||||||
6
antdv-next-admin/src/components/Captcha/index.ts
Normal file
6
antdv-next-admin/src/components/Captcha/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import PointCaptcha from './src/PointCaptcha.vue';
|
||||||
|
import PuzzleCaptcha from './src/PuzzleCaptcha.vue';
|
||||||
|
import RotateCaptcha from './src/RotateCaptcha.vue';
|
||||||
|
import SliderCaptcha from './src/SliderCaptcha.vue';
|
||||||
|
|
||||||
|
export { SliderCaptcha, RotateCaptcha, PuzzleCaptcha, PointCaptcha };
|
||||||
246
antdv-next-admin/src/components/Captcha/src/PointCaptcha.vue
Normal file
246
antdv-next-admin/src/components/Captcha/src/PointCaptcha.vue
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="point-captcha"
|
||||||
|
ref="containerRef"
|
||||||
|
:style="{ width: typeof width === 'number' ? width + 'px' : width }"
|
||||||
|
>
|
||||||
|
<div class="point-img-wrapper" :style="{ height: currentHeight + 'px' }">
|
||||||
|
<canvas
|
||||||
|
ref="canvasRef"
|
||||||
|
:width="currentWidth"
|
||||||
|
:height="currentHeight"
|
||||||
|
@click="handleClick"
|
||||||
|
></canvas>
|
||||||
|
<div
|
||||||
|
v-for="(point, index) in clicks"
|
||||||
|
:key="index"
|
||||||
|
class="point-mark"
|
||||||
|
:style="{ left: point.x + 'px', top: point.y + 'px' }"
|
||||||
|
>
|
||||||
|
{{ index + 1 }}
|
||||||
|
</div>
|
||||||
|
<div v-if="isSuccess" class="success-mask">
|
||||||
|
<span class="success-icon">✔</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="point-toolbar">
|
||||||
|
<div class="point-tip">
|
||||||
|
{{ $t('captcha.clickInOrder') }}<span class="highlight">{{ checkPoints.join(',') }}</span>
|
||||||
|
</div>
|
||||||
|
<a-button size="small" @click="reset">{{ $t('captcha.refresh') }}</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
width?: number | string;
|
||||||
|
height?: number | string;
|
||||||
|
src?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
width: '100%',
|
||||||
|
height: 160,
|
||||||
|
src: 'https://picsum.photos/320/160',
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['success', 'fail']);
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
|
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
const clicks = ref<{ x: number; y: number }[]>([]);
|
||||||
|
const checkPoints = ref<string[]>([]);
|
||||||
|
const points = ref<{ x: number; y: number; text: string }[]>([]);
|
||||||
|
const isSuccess = ref(false);
|
||||||
|
const currentWidth = ref(320);
|
||||||
|
const currentHeight = ref(160);
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
const randomNum = (min: number, max: number) => Math.floor(Math.random() * (max - min) + min);
|
||||||
|
const randomColor = (min: number, max: number) => {
|
||||||
|
const r = randomNum(min, max);
|
||||||
|
const g = randomNum(min, max);
|
||||||
|
const b = randomNum(min, max);
|
||||||
|
return `rgb(${r},${g},${b})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = () => {
|
||||||
|
clicks.value = [];
|
||||||
|
points.value = [];
|
||||||
|
checkPoints.value = [];
|
||||||
|
isSuccess.value = false;
|
||||||
|
|
||||||
|
if (!canvasRef.value) return;
|
||||||
|
const ctx = canvasRef.value.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// Calculate dimensions based on container width
|
||||||
|
const ratio = 160 / 320; // Default aspect ratio
|
||||||
|
if (containerRef.value) {
|
||||||
|
const w = containerRef.value.clientWidth;
|
||||||
|
if (w > 0) {
|
||||||
|
currentWidth.value = w;
|
||||||
|
if (typeof props.height === 'number') {
|
||||||
|
currentHeight.value = props.height;
|
||||||
|
} else {
|
||||||
|
currentHeight.value = Math.floor(w * ratio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'Anonymous';
|
||||||
|
img.src = props.src + '?t=' + new Date().getTime();
|
||||||
|
img.onload = () => {
|
||||||
|
ctx.drawImage(img, 0, 0, currentWidth.value, currentHeight.value);
|
||||||
|
|
||||||
|
const pool = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const text = pool[randomNum(0, pool.length)];
|
||||||
|
const fontSize = randomNum(20, 30);
|
||||||
|
const deg = randomNum(-30, 30);
|
||||||
|
|
||||||
|
const gridW = currentWidth.value / 4;
|
||||||
|
const x = randomNum(gridW * i + 10, gridW * (i + 1) - 10);
|
||||||
|
const y = randomNum(30, currentHeight.value - 10);
|
||||||
|
|
||||||
|
points.value.push({ x, y, text });
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(x, y);
|
||||||
|
ctx.rotate((deg * Math.PI) / 180);
|
||||||
|
ctx.fillStyle = randomColor(50, 160);
|
||||||
|
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||||
|
ctx.fillText(text, 0, 0);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = [...points.value].toSorted(() => Math.random() - 0.5).slice(0, 3);
|
||||||
|
checkPoints.value = targets.map((p) => p.text);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
init();
|
||||||
|
|
||||||
|
if (containerRef.value) {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
requestAnimationFrame(() => init());
|
||||||
|
});
|
||||||
|
resizeObserver.observe(containerRef.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (isSuccess.value || clicks.value.length >= 3) return;
|
||||||
|
|
||||||
|
const rect = (e.target as HTMLElement).getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
|
clicks.value.push({ x, y });
|
||||||
|
|
||||||
|
if (clicks.value.length === 3) {
|
||||||
|
verify();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verify = () => {
|
||||||
|
const isCorrect = clicks.value.every((click, index) => {
|
||||||
|
const targetChar = checkPoints.value[index];
|
||||||
|
const targetPoint = points.value.find((p) => p.text === targetChar);
|
||||||
|
if (!targetPoint) return false;
|
||||||
|
|
||||||
|
const dx = click.x - targetPoint.x;
|
||||||
|
const dy = click.y - targetPoint.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
return dist < 30;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isCorrect) {
|
||||||
|
isSuccess.value = true;
|
||||||
|
emit('success');
|
||||||
|
} else {
|
||||||
|
emit('fail');
|
||||||
|
setTimeout(() => {
|
||||||
|
clicks.value = [];
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
init();
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ reset });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.point-captcha {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.point-img-wrapper {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--color-bg-layout);
|
||||||
|
}
|
||||||
|
.point-mark {
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border: 2px solid #fff;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.success-mask {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(255, 255, 255, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.success-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
.point-toolbar {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
height: 40px;
|
||||||
|
background-color: var(--color-bg-container);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.point-tip {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.highlight {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
307
antdv-next-admin/src/components/Captcha/src/PuzzleCaptcha.vue
Normal file
307
antdv-next-admin/src/components/Captcha/src/PuzzleCaptcha.vue
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="puzzle-captcha"
|
||||||
|
ref="containerRef"
|
||||||
|
:style="{ width: typeof width === 'number' ? width + 'px' : width }"
|
||||||
|
>
|
||||||
|
<div class="puzzle-img-wrapper" :style="{ height: currentHeight + 'px' }">
|
||||||
|
<canvas
|
||||||
|
ref="mainCanvasRef"
|
||||||
|
:width="currentWidth"
|
||||||
|
:height="currentHeight"
|
||||||
|
class="puzzle-main"
|
||||||
|
></canvas>
|
||||||
|
<canvas
|
||||||
|
ref="moveCanvasRef"
|
||||||
|
class="puzzle-move"
|
||||||
|
:style="{ left: `${sliderLeft}px`, height: `${currentHeight}px` }"
|
||||||
|
></canvas>
|
||||||
|
<div v-if="loading" class="loading-mask">Loading...</div>
|
||||||
|
<div v-if="isSuccess" class="success-mask">
|
||||||
|
<span class="success-icon">✔</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="puzzle-slider" ref="sliderContainerRef">
|
||||||
|
<div class="slider-track"></div>
|
||||||
|
<div class="slider-handle" :style="{ left: `${sliderLeft}px` }" @mousedown="handleMouseDown">
|
||||||
|
<span>→</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
width?: number | string;
|
||||||
|
height?: number | string;
|
||||||
|
src?: string;
|
||||||
|
tolerance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
width: '100%',
|
||||||
|
height: 160,
|
||||||
|
src: 'https://picsum.photos/320/160',
|
||||||
|
tolerance: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['success', 'fail']);
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
|
const mainCanvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
const moveCanvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
const isMoving = ref(false);
|
||||||
|
const isSuccess = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const sliderLeft = ref(0);
|
||||||
|
const targetX = ref(0);
|
||||||
|
const currentWidth = ref(320);
|
||||||
|
const currentHeight = ref(160);
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
// Puzzle shape params
|
||||||
|
const l = 42;
|
||||||
|
const r = 9;
|
||||||
|
const PI = Math.PI;
|
||||||
|
|
||||||
|
const drawPuzzleShape = (
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
operation: 'fill' | 'clip',
|
||||||
|
) => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
ctx.lineTo(x + l / 2, y);
|
||||||
|
ctx.arc(x + l / 2, y - r + 2, r, 0.72 * PI, 2.26 * PI);
|
||||||
|
ctx.lineTo(x + l, y);
|
||||||
|
ctx.arc(x + l + r - 2, y + l / 2, r, 1.21 * PI, 2.78 * PI);
|
||||||
|
ctx.lineTo(x + l, y + l);
|
||||||
|
ctx.lineTo(x, y + l);
|
||||||
|
ctx.arc(x + r - 2, y + l / 2, r + 0.4, 2.76 * PI, 1.24 * PI, true);
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.globalCompositeOperation = 'destination-over';
|
||||||
|
if (operation === 'fill') {
|
||||||
|
ctx.fill();
|
||||||
|
} else {
|
||||||
|
ctx.clip();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = () => {
|
||||||
|
if (!mainCanvasRef.value || !moveCanvasRef.value) return;
|
||||||
|
const mainCtx = mainCanvasRef.value.getContext('2d');
|
||||||
|
const moveCtx = moveCanvasRef.value.getContext('2d');
|
||||||
|
if (!mainCtx || !moveCtx) return;
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
isSuccess.value = false;
|
||||||
|
sliderLeft.value = 0;
|
||||||
|
|
||||||
|
// Calculate dimensions based on container width while maintaining aspect ratio
|
||||||
|
// Default aspect ratio 2:1 (320:160)
|
||||||
|
const ratio = 160 / 320;
|
||||||
|
if (containerRef.value) {
|
||||||
|
const w = containerRef.value.clientWidth;
|
||||||
|
if (w > 0) {
|
||||||
|
currentWidth.value = w;
|
||||||
|
// If props.height is a number, use it. If string/auto, calculate from ratio
|
||||||
|
if (typeof props.height === 'number') {
|
||||||
|
currentHeight.value = props.height;
|
||||||
|
} else {
|
||||||
|
currentHeight.value = Math.floor(w * ratio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mainCtx.clearRect(0, 0, currentWidth.value, currentHeight.value);
|
||||||
|
moveCtx.clearRect(0, 0, currentWidth.value, currentHeight.value);
|
||||||
|
moveCanvasRef.value.width = currentWidth.value;
|
||||||
|
moveCanvasRef.value.height = currentHeight.value; // Ensure height is set
|
||||||
|
|
||||||
|
// Ensure targetX is within bounds
|
||||||
|
// l + r * 4 is roughly the puzzle piece width
|
||||||
|
const pieceWidth = l + r * 4;
|
||||||
|
const safeWidth = currentWidth.value - pieceWidth;
|
||||||
|
// If container is too small, fallback logic needed, but for now assume min width > pieceWidth
|
||||||
|
targetX.value = Math.floor(Math.random() * (Math.max(0, safeWidth - l) - l) + l);
|
||||||
|
|
||||||
|
const safeHeight = currentHeight.value - l;
|
||||||
|
const targetY = Math.floor(Math.random() * (Math.max(0, safeHeight - l) - l) + l);
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'Anonymous';
|
||||||
|
img.src = props.src + '?t=' + new Date().getTime();
|
||||||
|
img.onload = () => {
|
||||||
|
// Draw puzzle piece
|
||||||
|
drawPuzzleShape(moveCtx, targetX.value, targetY, 'clip');
|
||||||
|
moveCtx.drawImage(img, 0, 0, currentWidth.value, currentHeight.value);
|
||||||
|
|
||||||
|
// Extract puzzle piece
|
||||||
|
const puzzleData = moveCtx.getImageData(
|
||||||
|
targetX.value - r * 2,
|
||||||
|
targetY - r * 2,
|
||||||
|
pieceWidth,
|
||||||
|
pieceWidth,
|
||||||
|
);
|
||||||
|
moveCanvasRef.value!.width = pieceWidth;
|
||||||
|
moveCanvasRef.value!.height = currentHeight.value;
|
||||||
|
moveCtx.putImageData(puzzleData, 0, targetY - r * 2);
|
||||||
|
|
||||||
|
// Draw main background with hole
|
||||||
|
mainCtx.drawImage(img, 0, 0, currentWidth.value, currentHeight.value);
|
||||||
|
drawPuzzleShape(mainCtx, targetX.value, targetY, 'fill');
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
init();
|
||||||
|
|
||||||
|
if (containerRef.value) {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
// Debounce or just re-init
|
||||||
|
// Simple re-init for now
|
||||||
|
requestAnimationFrame(() => init());
|
||||||
|
});
|
||||||
|
resizeObserver.observe(containerRef.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleMouseDown = (evt: MouseEvent) => {
|
||||||
|
if (isSuccess.value || loading.value) return;
|
||||||
|
isMoving.value = true;
|
||||||
|
const startX = evt.clientX;
|
||||||
|
const startLeft = sliderLeft.value;
|
||||||
|
|
||||||
|
const handleMouseMove = (moveEvt: MouseEvent) => {
|
||||||
|
if (!isMoving.value) return;
|
||||||
|
const deltaX = moveEvt.clientX - startX;
|
||||||
|
let newLeft = startLeft + deltaX;
|
||||||
|
// Limit range
|
||||||
|
const maxLeft = currentWidth.value - 40; // slider handle width
|
||||||
|
newLeft = Math.max(0, Math.min(maxLeft, newLeft));
|
||||||
|
sliderLeft.value = newLeft;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
isMoving.value = false;
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
// Validation logic
|
||||||
|
const realTarget = targetX.value - r * 2;
|
||||||
|
if (Math.abs(sliderLeft.value - realTarget) <= props.tolerance) {
|
||||||
|
isSuccess.value = true;
|
||||||
|
emit('success');
|
||||||
|
} else {
|
||||||
|
emit('fail');
|
||||||
|
// Reset animation
|
||||||
|
const animate = () => {
|
||||||
|
if (sliderLeft.value > 0) {
|
||||||
|
sliderLeft.value = Math.max(0, sliderLeft.value - 10);
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
init();
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ reset });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.puzzle-captcha {
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.puzzle-img-wrapper {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--color-bg-layout);
|
||||||
|
}
|
||||||
|
.puzzle-main {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.puzzle-move {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.success-mask {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(255, 255, 255, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.success-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
.puzzle-slider {
|
||||||
|
position: relative;
|
||||||
|
height: 40px;
|
||||||
|
margin-top: 12px;
|
||||||
|
background-color: var(--color-bg-container);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.slider-track {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background-color: var(--color-bg-layout);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
.slider-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: -1px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background-color: var(--color-bg-container);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: 0 0 4px rgba(0, 0, 0, 0.1);
|
||||||
|
cursor: grab;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.slider-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
196
antdv-next-admin/src/components/Captcha/src/RotateCaptcha.vue
Normal file
196
antdv-next-admin/src/components/Captcha/src/RotateCaptcha.vue
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rotate-captcha" :style="{ width: typeof width === 'number' ? width + 'px' : width }">
|
||||||
|
<div class="rotate-img-wrapper">
|
||||||
|
<img
|
||||||
|
:src="src"
|
||||||
|
class="rotate-img"
|
||||||
|
:style="{ transform: `rotate(${currentAngle}deg)` }"
|
||||||
|
alt="captcha"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
<div v-if="isSuccess" class="success-mask">
|
||||||
|
<span class="success-icon">✔</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rotate-slider" ref="sliderRef">
|
||||||
|
<div class="slider-track"></div>
|
||||||
|
<div
|
||||||
|
class="slider-handle"
|
||||||
|
:style="{ left: `${sliderPercent}%` }"
|
||||||
|
@mousedown="handleMouseDown"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
width?: number | string;
|
||||||
|
height?: number | string;
|
||||||
|
src?: string;
|
||||||
|
tolerance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
width: '100%',
|
||||||
|
src: 'https://picsum.photos/300/300',
|
||||||
|
tolerance: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['success', 'fail']);
|
||||||
|
|
||||||
|
const currentAngle = ref(0);
|
||||||
|
const sliderPercent = ref(0);
|
||||||
|
const isMoving = ref(false);
|
||||||
|
const isSuccess = ref(false);
|
||||||
|
const initialAngle = ref(0);
|
||||||
|
const sliderRef = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const init = () => {
|
||||||
|
initialAngle.value = Math.random() * 300 + 30;
|
||||||
|
currentAngle.value = initialAngle.value;
|
||||||
|
sliderPercent.value = 0;
|
||||||
|
isSuccess.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Init
|
||||||
|
init();
|
||||||
|
|
||||||
|
const handleMouseDown = (evt: MouseEvent) => {
|
||||||
|
if (isSuccess.value) return;
|
||||||
|
isMoving.value = true;
|
||||||
|
const startX = evt.clientX;
|
||||||
|
const startPercent = sliderPercent.value;
|
||||||
|
|
||||||
|
const handleMouseMove = (moveEvt: MouseEvent) => {
|
||||||
|
if (!isMoving.value) return;
|
||||||
|
const container = sliderRef.value;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const width = container.clientWidth;
|
||||||
|
const deltaX = moveEvt.clientX - startX;
|
||||||
|
const deltaPercent = (deltaX / width) * 100;
|
||||||
|
|
||||||
|
let newPercent = startPercent + deltaPercent;
|
||||||
|
newPercent = Math.max(0, Math.min(100, newPercent));
|
||||||
|
|
||||||
|
sliderPercent.value = newPercent;
|
||||||
|
// Map 0-100% to 0-360deg to offset initial angle
|
||||||
|
const rotateDelta = (newPercent / 100) * 360;
|
||||||
|
currentAngle.value = initialAngle.value - rotateDelta;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
isMoving.value = false;
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
// Check if angle is close to 0 (360 multiples)
|
||||||
|
let normalizedAngle = currentAngle.value % 360;
|
||||||
|
if (normalizedAngle > 180) normalizedAngle -= 360;
|
||||||
|
if (normalizedAngle < -180) normalizedAngle += 360;
|
||||||
|
|
||||||
|
if (Math.abs(normalizedAngle) <= props.tolerance) {
|
||||||
|
isSuccess.value = true;
|
||||||
|
emit('success');
|
||||||
|
} else {
|
||||||
|
emit('fail');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
init();
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ reset });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rotate-captcha {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.rotate-img-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
background-color: var(--color-bg-layout);
|
||||||
|
}
|
||||||
|
.rotate-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.success-mask {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(82, 196, 26, 0.2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.success-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
color: var(--color-success);
|
||||||
|
text-shadow: 0 0 4px #fff;
|
||||||
|
}
|
||||||
|
.rotate-slider {
|
||||||
|
position: relative;
|
||||||
|
height: 40px;
|
||||||
|
background-color: var(--color-bg-container);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
.slider-track {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
height: 4px;
|
||||||
|
background-color: var(--color-bg-layout);
|
||||||
|
border-radius: 2px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
.slider-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--color-bg-container);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
cursor: grab;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.slider-handle::after {
|
||||||
|
content: '';
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
.slider-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.slider-handle:active::after {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
219
antdv-next-admin/src/components/Captcha/src/SliderCaptcha.vue
Normal file
219
antdv-next-admin/src/components/Captcha/src/SliderCaptcha.vue
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="slider-captcha"
|
||||||
|
:style="{
|
||||||
|
width: typeof width === 'number' ? width + 'px' : width,
|
||||||
|
'--slider-height': typeof height === 'number' ? height + 'px' : height,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="slider-bg" :class="{ success: isSuccess }">
|
||||||
|
<div class="slider-text" :style="{ opacity: isMoving ? 0 : 1 }">
|
||||||
|
{{ isSuccess ? successText : text }}
|
||||||
|
</div>
|
||||||
|
<div class="slider-track" :style="{ width: isSuccess ? '100%' : `${sliderLeft}px` }"></div>
|
||||||
|
<div
|
||||||
|
class="slider-handle"
|
||||||
|
:class="{ success: isSuccess }"
|
||||||
|
:style="{ left: isSuccess ? 'auto' : `${sliderLeft}px`, right: isSuccess ? 0 : 'auto' }"
|
||||||
|
@mousedown="handleMouseDown"
|
||||||
|
@touchstart.prevent="handleTouchStart"
|
||||||
|
>
|
||||||
|
<span v-if="isSuccess">✔</span>
|
||||||
|
<span v-else>→</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
width?: number | string;
|
||||||
|
height?: number | string;
|
||||||
|
text?: string;
|
||||||
|
successText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
width: '100%',
|
||||||
|
height: 40,
|
||||||
|
text: 'Slide to verify',
|
||||||
|
successText: 'Success',
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['success', 'fail']);
|
||||||
|
|
||||||
|
const isMoving = ref(false);
|
||||||
|
const isSuccess = ref(false);
|
||||||
|
const sliderLeft = ref(0);
|
||||||
|
const startX = ref(0);
|
||||||
|
let containerWidth = 0;
|
||||||
|
|
||||||
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
if (isSuccess.value) return;
|
||||||
|
isMoving.value = true;
|
||||||
|
startX.value = e.clientX;
|
||||||
|
|
||||||
|
// Get container width
|
||||||
|
const container = (e.target as HTMLElement).closest('.slider-bg');
|
||||||
|
const handle = (e.target as HTMLElement).closest('.slider-handle');
|
||||||
|
if (container && handle) {
|
||||||
|
containerWidth = container.clientWidth - handle.clientWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!isMoving.value) return;
|
||||||
|
const offset = e.clientX - startX.value;
|
||||||
|
|
||||||
|
if (offset < 0) {
|
||||||
|
sliderLeft.value = 0;
|
||||||
|
} else if (offset > containerWidth) {
|
||||||
|
sliderLeft.value = containerWidth;
|
||||||
|
} else {
|
||||||
|
sliderLeft.value = offset;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
isMoving.value = false;
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
finishDrag();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (e: TouchEvent) => {
|
||||||
|
if (isSuccess.value) return;
|
||||||
|
isMoving.value = true;
|
||||||
|
startX.value = e.touches[0].clientX;
|
||||||
|
|
||||||
|
const container = (e.target as HTMLElement).closest('.slider-bg');
|
||||||
|
const handle = (e.target as HTMLElement).closest('.slider-handle');
|
||||||
|
if (container && handle) {
|
||||||
|
containerWidth = container.clientWidth - handle.clientWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||||
|
document.addEventListener('touchend', handleTouchEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: TouchEvent) => {
|
||||||
|
if (!isMoving.value) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const offset = e.touches[0].clientX - startX.value;
|
||||||
|
|
||||||
|
if (offset < 0) {
|
||||||
|
sliderLeft.value = 0;
|
||||||
|
} else if (offset > containerWidth) {
|
||||||
|
sliderLeft.value = containerWidth;
|
||||||
|
} else {
|
||||||
|
sliderLeft.value = offset;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
isMoving.value = false;
|
||||||
|
document.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
document.removeEventListener('touchend', handleTouchEnd);
|
||||||
|
finishDrag();
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishDrag = () => {
|
||||||
|
if (sliderLeft.value >= containerWidth) {
|
||||||
|
isSuccess.value = true;
|
||||||
|
sliderLeft.value = containerWidth;
|
||||||
|
emit('success');
|
||||||
|
} else {
|
||||||
|
// Reset animation
|
||||||
|
const animate = () => {
|
||||||
|
if (sliderLeft.value > 0) {
|
||||||
|
sliderLeft.value = Math.max(0, sliderLeft.value - 10);
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
} else {
|
||||||
|
emit('fail');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
isSuccess.value = false;
|
||||||
|
isMoving.value = false;
|
||||||
|
sliderLeft.value = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ reset });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.slider-captcha {
|
||||||
|
--slider-height: 40px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.slider-bg {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: var(--slider-height);
|
||||||
|
background-color: var(--color-bg-container);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: var(--slider-height);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.slider-bg.success {
|
||||||
|
background-color: var(--color-success-bg);
|
||||||
|
border-color: var(--color-success-border);
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
.slider-text {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.slider-track {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--color-info-bg);
|
||||||
|
border-right: 1px solid var(--color-info);
|
||||||
|
}
|
||||||
|
.slider-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: var(--slider-height);
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--color-bg-container);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: 0 0 4px rgba(0, 0, 0, 0.1);
|
||||||
|
cursor: grab;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
z-index: 2;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
.slider-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
background-color: var(--color-info);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.slider-handle.success {
|
||||||
|
cursor: default;
|
||||||
|
background-color: var(--color-success);
|
||||||
|
border-color: var(--color-success);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
12
antdv-next-admin/src/components/Captcha/types.ts
Normal file
12
antdv-next-admin/src/components/Captcha/types.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export interface CaptchaProps {
|
||||||
|
width?: number | string;
|
||||||
|
height?: number | string;
|
||||||
|
src?: string;
|
||||||
|
text?: string;
|
||||||
|
successText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Point {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
467
antdv-next-admin/src/components/Editor/index.vue
Normal file
467
antdv-next-admin/src/components/Editor/index.vue
Normal file
@ -0,0 +1,467 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tiptap-editor-wrapper" :class="{ disabled }">
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<div v-if="editor" class="editor-toolbar">
|
||||||
|
<a-space :size="4" wrap>
|
||||||
|
<!-- 文本格式 -->
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
:type="editor.isActive('bold') ? 'primary' : 'default'"
|
||||||
|
@click="editor.chain().focus().toggleBold().run()"
|
||||||
|
>
|
||||||
|
<template #icon><BoldOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
:type="editor.isActive('italic') ? 'primary' : 'default'"
|
||||||
|
@click="editor.chain().focus().toggleItalic().run()"
|
||||||
|
>
|
||||||
|
<template #icon><ItalicOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
:type="editor.isActive('strike') ? 'primary' : 'default'"
|
||||||
|
@click="editor.chain().focus().toggleStrike().run()"
|
||||||
|
>
|
||||||
|
<template #icon><StrikethroughOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-divider type="vertical" />
|
||||||
|
|
||||||
|
<!-- 标题 -->
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
:type="editor.isActive('heading', { level: 1 }) ? 'primary' : 'default'"
|
||||||
|
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
|
||||||
|
>
|
||||||
|
H1
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
:type="editor.isActive('heading', { level: 2 }) ? 'primary' : 'default'"
|
||||||
|
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
|
||||||
|
>
|
||||||
|
H2
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
:type="editor.isActive('heading', { level: 3 }) ? 'primary' : 'default'"
|
||||||
|
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
|
||||||
|
>
|
||||||
|
H3
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-divider type="vertical" />
|
||||||
|
|
||||||
|
<!-- 列表 -->
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
:type="editor.isActive('bulletList') ? 'primary' : 'default'"
|
||||||
|
@click="editor.chain().focus().toggleBulletList().run()"
|
||||||
|
>
|
||||||
|
<template #icon><UnorderedListOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
:type="editor.isActive('orderedList') ? 'primary' : 'default'"
|
||||||
|
@click="editor.chain().focus().toggleOrderedList().run()"
|
||||||
|
>
|
||||||
|
<template #icon><OrderedListOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-divider type="vertical" />
|
||||||
|
|
||||||
|
<!-- 引用和代码 -->
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
:type="editor.isActive('blockquote') ? 'primary' : 'default'"
|
||||||
|
@click="editor.chain().focus().toggleBlockquote().run()"
|
||||||
|
>
|
||||||
|
<template #icon><MessageOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
:type="editor.isActive('codeBlock') ? 'primary' : 'default'"
|
||||||
|
@click="editor.chain().focus().toggleCodeBlock().run()"
|
||||||
|
>
|
||||||
|
<template #icon><CodeOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-divider type="vertical" />
|
||||||
|
|
||||||
|
<!-- 图片和链接 -->
|
||||||
|
<a-upload :show-upload-list="false" :before-upload="handleImageUpload" accept="image/*">
|
||||||
|
<a-button size="small">
|
||||||
|
<template #icon><PictureOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</a-upload>
|
||||||
|
|
||||||
|
<a-button size="small" @click="showLinkModal">
|
||||||
|
<template #icon><LinkOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-divider type="vertical" />
|
||||||
|
|
||||||
|
<!-- 撤销重做 -->
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
:disabled="!editor.can().undo()"
|
||||||
|
@click="editor.chain().focus().undo().run()"
|
||||||
|
>
|
||||||
|
<template #icon><UndoOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
:disabled="!editor.can().redo()"
|
||||||
|
@click="editor.chain().focus().redo().run()"
|
||||||
|
>
|
||||||
|
<template #icon><RedoOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 编辑器内容区 -->
|
||||||
|
<editor-content :editor="editor" class="editor-content" />
|
||||||
|
|
||||||
|
<!-- 链接弹窗 -->
|
||||||
|
<a-modal v-model:open="linkModalVisible" :title="$t('editor.insertLink')" @ok="insertLink">
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-form-item :label="$t('editor.linkUrl')">
|
||||||
|
<a-input v-model:value="linkUrl" placeholder="https://example.com" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item :label="$t('editor.linkText')">
|
||||||
|
<a-input v-model:value="linkText" :placeholder="$t('editor.linkTextPlaceholder')" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { UploadProps } from 'antdv-next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BoldOutlined,
|
||||||
|
ItalicOutlined,
|
||||||
|
StrikethroughOutlined,
|
||||||
|
UnorderedListOutlined,
|
||||||
|
OrderedListOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
CodeOutlined,
|
||||||
|
PictureOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
UndoOutlined,
|
||||||
|
RedoOutlined,
|
||||||
|
} from '@antdv-next/icons';
|
||||||
|
import Image from '@tiptap/extension-image';
|
||||||
|
import Link from '@tiptap/extension-link';
|
||||||
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import { useEditor, EditorContent } from '@tiptap/vue-3';
|
||||||
|
import { message } from 'antdv-next';
|
||||||
|
import { ref, watch, onBeforeUnmount, computed } from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@/locales';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: '',
|
||||||
|
placeholder: '',
|
||||||
|
disabled: false,
|
||||||
|
height: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string];
|
||||||
|
change: [value: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const resolvedPlaceholder = computed(() => props.placeholder || $t('editor.defaultPlaceholder'));
|
||||||
|
|
||||||
|
// 链接弹窗
|
||||||
|
const linkModalVisible = ref(false);
|
||||||
|
const linkUrl = ref('');
|
||||||
|
const linkText = ref('');
|
||||||
|
|
||||||
|
// 初始化编辑器
|
||||||
|
const editor = useEditor({
|
||||||
|
content: props.modelValue,
|
||||||
|
editable: !props.disabled,
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Image.configure({
|
||||||
|
inline: true,
|
||||||
|
allowBase64: true,
|
||||||
|
}),
|
||||||
|
Link.configure({
|
||||||
|
openOnClick: false,
|
||||||
|
HTMLAttributes: {
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener noreferrer',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: resolvedPlaceholder.value,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
const html = editor.getHTML();
|
||||||
|
emit('update:modelValue', html);
|
||||||
|
emit('change', html);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听外部值变化
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newValue) => {
|
||||||
|
if (editor.value && newValue !== editor.value.getHTML()) {
|
||||||
|
editor.value.commands.setContent(newValue, false as unknown as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 监听禁用状态
|
||||||
|
watch(
|
||||||
|
() => props.disabled,
|
||||||
|
(disabled) => {
|
||||||
|
if (editor.value) {
|
||||||
|
editor.value.setEditable(!disabled);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 图片上传
|
||||||
|
const handleImageUpload: UploadProps['beforeUpload'] = async (file) => {
|
||||||
|
if (!editor.value) return false;
|
||||||
|
|
||||||
|
// 检查文件类型
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
message.error($t('editor.imageOnly'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件大小(5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
message.error($t('editor.imageSizeLimit'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 方式1: 转换为 Base64(适合小图片)
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const base64 = e.target?.result as string;
|
||||||
|
editor.value?.chain().focus().setImage({ src: base64 }).run();
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
// 方式2: 上传到服务器(推荐)
|
||||||
|
// const formData = new FormData()
|
||||||
|
// formData.append('file', file)
|
||||||
|
// const response = await fetch('/api/upload', {
|
||||||
|
// method: 'POST',
|
||||||
|
// body: formData
|
||||||
|
// })
|
||||||
|
// const data = await response.json()
|
||||||
|
// editor.value?.chain().focus().setImage({ src: data.url }).run()
|
||||||
|
|
||||||
|
message.success($t('editor.imageInsertSuccess'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Image upload failed:', error);
|
||||||
|
message.error($t('editor.imageUploadFailed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // 阻止默认上传行为
|
||||||
|
};
|
||||||
|
|
||||||
|
// 显示链接弹窗
|
||||||
|
const showLinkModal = () => {
|
||||||
|
const { href } = editor.value?.getAttributes('link') || {};
|
||||||
|
linkUrl.value = href || '';
|
||||||
|
linkText.value =
|
||||||
|
editor.value?.state.doc.textBetween(
|
||||||
|
editor.value.state.selection.from,
|
||||||
|
editor.value.state.selection.to,
|
||||||
|
) || '';
|
||||||
|
linkModalVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 插入链接
|
||||||
|
const insertLink = () => {
|
||||||
|
if (!linkUrl.value) {
|
||||||
|
message.warning($t('editor.enterLinkUrl'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editor.value) return;
|
||||||
|
|
||||||
|
// 如果有选中文本,直接添加链接
|
||||||
|
if (editor.value.state.selection.empty) {
|
||||||
|
// 没有选中文本,插入新链接
|
||||||
|
editor.value
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContent(`<a href="${linkUrl.value}">${linkText.value || linkUrl.value}</a>`)
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
// 有选中文本,添加链接
|
||||||
|
editor.value.chain().focus().setLink({ href: linkUrl.value }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
linkModalVisible.value = false;
|
||||||
|
linkUrl.value = '';
|
||||||
|
linkText.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 组件销毁时销毁编辑器
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
editor.value?.destroy();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.tiptap-editor-wrapper {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-bg-container);
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
|
||||||
|
:deep(.ant-btn-sm) {
|
||||||
|
min-width: 32px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-divider-vertical) {
|
||||||
|
height: 20px;
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content {
|
||||||
|
height: v-bind(height + 'px');
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
:deep(.ProseMirror) {
|
||||||
|
padding: 12px 16px;
|
||||||
|
min-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
/* 占位符样式 */
|
||||||
|
p.is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
color: var(--color-text-placeholder);
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 基础样式 */
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 1.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
padding-left: 1.5em;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 3px solid var(--ant-primary-color);
|
||||||
|
padding-left: 1em;
|
||||||
|
margin: 1em 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 1em 0;
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--ant-primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中样式 */
|
||||||
|
::selection {
|
||||||
|
background: var(--ant-primary-color-deprecated-l-35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
import type { App, Component } from "vue";
|
||||||
|
|
||||||
|
import { Select, DatePicker, DateRangePicker } from "antdv-next";
|
||||||
|
import { defineComponent, h } from "vue";
|
||||||
|
|
||||||
|
import { appDefaultSettings } from "@/settings";
|
||||||
|
|
||||||
|
type AttrMap = Record<string, unknown>;
|
||||||
|
|
||||||
|
const withAllowClearDefault = (
|
||||||
|
name: string,
|
||||||
|
component: Component,
|
||||||
|
getDefaultAllowClear: () => boolean,
|
||||||
|
) => {
|
||||||
|
return defineComponent({
|
||||||
|
name,
|
||||||
|
inheritAttrs: false,
|
||||||
|
setup(_, { attrs, slots }) {
|
||||||
|
return () => {
|
||||||
|
const props = attrs as AttrMap;
|
||||||
|
const allowClear = props.allowClear ?? getDefaultAllowClear();
|
||||||
|
|
||||||
|
return h(component, { ...props, allowClear }, slots);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const SelectWithDefaults = withAllowClearDefault(
|
||||||
|
"ASelectWithDefaults",
|
||||||
|
Select,
|
||||||
|
() => appDefaultSettings.select.allowClear,
|
||||||
|
);
|
||||||
|
|
||||||
|
const DatePickerWithDefaults = withAllowClearDefault(
|
||||||
|
"ADatePickerWithDefaults",
|
||||||
|
DatePicker,
|
||||||
|
() => appDefaultSettings.datePicker.allowClear,
|
||||||
|
);
|
||||||
|
|
||||||
|
const RangePickerWithDefaults = withAllowClearDefault(
|
||||||
|
"ARangePickerWithDefaults",
|
||||||
|
DateRangePicker,
|
||||||
|
() => appDefaultSettings.datePicker.allowClear,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const registerDefaultComponentProps = (app: App) => {
|
||||||
|
app.component("ASelect", SelectWithDefaults);
|
||||||
|
app.component("ADatePicker", DatePickerWithDefaults);
|
||||||
|
app.component("ARangePicker", RangePickerWithDefaults);
|
||||||
|
};
|
||||||
189
antdv-next-admin/src/components/I18nInput/index.vue
Normal file
189
antdv-next-admin/src/components/I18nInput/index.vue
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
<template>
|
||||||
|
<JsonInput
|
||||||
|
v-model:value="innerValue"
|
||||||
|
:display-key="displayLocale"
|
||||||
|
:label-map="localeLabelMap"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:modal-title="modalTitle"
|
||||||
|
:allow-add="false"
|
||||||
|
:allow-delete="false"
|
||||||
|
:allow-sort="false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, type PropType } from 'vue';
|
||||||
|
|
||||||
|
import JsonInput from '@/components/JsonInput/index.vue';
|
||||||
|
import { getLocale, SUPPORTED_LOCALES } from '@/locales';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
value: {
|
||||||
|
type: [String, Object] as PropType<string | Record<string, string>>,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
locale: {
|
||||||
|
type: String,
|
||||||
|
default: () => getLocale(),
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
strictLocales: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:value', 'change']);
|
||||||
|
|
||||||
|
interface LocaleMeta {
|
||||||
|
display: string;
|
||||||
|
flag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localeMetaMap: Record<string, LocaleMeta> = {
|
||||||
|
'zh-CN': { display: '简体中文', flag: '🇨🇳' },
|
||||||
|
'en-US': { display: 'English', flag: '🇺🇸' },
|
||||||
|
'ja-JP': { display: '日本語', flag: '🇯🇵' },
|
||||||
|
'ko-KR': { display: '한국어', flag: '🇰🇷' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableLocales = computed(() =>
|
||||||
|
SUPPORTED_LOCALES.map((locale) => {
|
||||||
|
const meta = localeMetaMap[locale];
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
display: meta?.display || locale,
|
||||||
|
flag: meta?.flag || '🌐',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableLocaleSet = computed(
|
||||||
|
() => new Set(availableLocales.value.map((item) => item.locale)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayLocale = computed(() => {
|
||||||
|
const locale = props.locale || getLocale();
|
||||||
|
if (availableLocaleSet.value.has(locale)) {
|
||||||
|
return locale;
|
||||||
|
}
|
||||||
|
return availableLocales.value[0]?.locale || locale;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate label map for locales
|
||||||
|
const localeLabelMap = computed(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
availableLocales.value.forEach((item) => {
|
||||||
|
map[item.locale] = `${item.flag} ${item.display}`;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
const innerValue = ref<Record<string, string>>({});
|
||||||
|
const valueType = ref<'string' | 'object'>('object');
|
||||||
|
const syncingFromProps = ref(false);
|
||||||
|
|
||||||
|
// Initialize default value with all locales
|
||||||
|
function getDefaultValue(): Record<string, string> {
|
||||||
|
const defaultValue: Record<string, string> = {};
|
||||||
|
availableLocales.value.forEach((item) => {
|
||||||
|
defaultValue[item.locale] = '';
|
||||||
|
});
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecordEqual(a: Record<string, string>, b: Record<string, string>): boolean {
|
||||||
|
const aKeys = Object.keys(a);
|
||||||
|
const bKeys = Object.keys(b);
|
||||||
|
|
||||||
|
if (aKeys.length !== bKeys.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return aKeys.every((key) => a[key] === b[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and normalize value
|
||||||
|
function normalizeValue(
|
||||||
|
value: string | Record<string, string> | null | undefined,
|
||||||
|
): Record<string, string> {
|
||||||
|
let parsed: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
parsed = getDefaultValue();
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(value);
|
||||||
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||||
|
parsed = getDefaultValue();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
parsed = getDefaultValue();
|
||||||
|
}
|
||||||
|
} else if (typeof value === 'object') {
|
||||||
|
parsed = { ...value };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill missing locales with empty string
|
||||||
|
availableLocales.value.forEach((item) => {
|
||||||
|
if (!parsed[item.locale]) {
|
||||||
|
parsed[item.locale] = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove locales not in available list when strict mode is enabled
|
||||||
|
if (props.strictLocales) {
|
||||||
|
Object.keys(parsed).forEach((key) => {
|
||||||
|
if (!availableLocaleSet.value.has(key)) {
|
||||||
|
delete parsed[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for external value changes
|
||||||
|
watch(
|
||||||
|
() => props.value,
|
||||||
|
(newValue) => {
|
||||||
|
valueType.value = typeof newValue === 'string' ? 'string' : 'object';
|
||||||
|
const normalized = normalizeValue(newValue);
|
||||||
|
|
||||||
|
if (isRecordEqual(normalized, innerValue.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncingFromProps.value = true;
|
||||||
|
innerValue.value = normalized;
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Watch for internal value changes and emit
|
||||||
|
watch(
|
||||||
|
() => innerValue.value,
|
||||||
|
(newValue) => {
|
||||||
|
if (syncingFromProps.value) {
|
||||||
|
syncingFromProps.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecordEqual(newValue, normalizeValue(props.value))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const returnValue = valueType.value === 'string' ? JSON.stringify(newValue) : newValue;
|
||||||
|
emit('update:value', returnValue);
|
||||||
|
emit('change', returnValue);
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
114
antdv-next-admin/src/components/Icon/index.vue
Normal file
114
antdv-next-admin/src/components/Icon/index.vue
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="antdvComp"
|
||||||
|
v-if="resolvedKind === 'antdv-next'"
|
||||||
|
class="app-icon"
|
||||||
|
:style="[baseStyle, props.style]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
v-else-if="resolvedKind === 'svg'"
|
||||||
|
class="app-icon app-icon-svg"
|
||||||
|
:style="[baseStyle, props.style]"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<use :href="`#${svgId}`" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<IconifyIcon v-else class="app-icon" :icon="iconifyIcon" :style="[baseStyle, props.style]" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { StyleValue } from 'vue';
|
||||||
|
|
||||||
|
import * as AntdvIcons from '@antdv-next/icons';
|
||||||
|
import { Icon as IconifyIcon } from '@iconify/vue';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
type NormalizedIconKind = 'iconify' | 'antdv-next' | 'svg';
|
||||||
|
type IconKind = NormalizedIconKind | 'antdvNext' | 'antd';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
icon: string;
|
||||||
|
kind?: IconKind;
|
||||||
|
size?: number | string;
|
||||||
|
style?: StyleValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
size: 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stripPrefix = (value: string, prefix: string) => {
|
||||||
|
return value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeKind = (kind?: IconKind): NormalizedIconKind | undefined => {
|
||||||
|
if (!kind) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (kind === 'antdvNext' || kind === 'antd') {
|
||||||
|
return 'antdv-next';
|
||||||
|
}
|
||||||
|
return kind;
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconText = computed(() => props.icon.trim());
|
||||||
|
|
||||||
|
const resolvedKind = computed<NormalizedIconKind>(() => {
|
||||||
|
const forcedKind = normalizeKind(props.kind);
|
||||||
|
if (forcedKind) {
|
||||||
|
return forcedKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iconText.value.startsWith('antdv-next:') || iconText.value.startsWith('antd:')) {
|
||||||
|
return 'antdv-next';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iconText.value.startsWith('svg:')) {
|
||||||
|
return 'svg';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'iconify';
|
||||||
|
});
|
||||||
|
|
||||||
|
const antdvKey = computed(() => {
|
||||||
|
return stripPrefix(stripPrefix(iconText.value, 'antdv-next:'), 'antd:');
|
||||||
|
});
|
||||||
|
|
||||||
|
const antdvComp = computed(() => {
|
||||||
|
const icons = AntdvIcons as Record<string, unknown>;
|
||||||
|
return icons[antdvKey.value] || icons.QuestionOutlined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const svgId = computed(() => stripPrefix(iconText.value, 'svg:'));
|
||||||
|
|
||||||
|
const iconifyIcon = computed(() => stripPrefix(iconText.value, 'iconify:'));
|
||||||
|
|
||||||
|
const sizeCss = computed(() => {
|
||||||
|
return typeof props.size === 'number' ? `${props.size}px` : props.size;
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseStyle = computed(() => ({
|
||||||
|
width: sizeCss.value,
|
||||||
|
height: sizeCss.value,
|
||||||
|
lineHeight: sizeCss.value,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.app-icon {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 1em;
|
||||||
|
min-height: 1em;
|
||||||
|
vertical-align: -0.125em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon-svg {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
14
antdv-next-admin/src/components/IconPicker/CLAUDE.md
Normal file
14
antdv-next-admin/src/components/IconPicker/CLAUDE.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<claude-mem-context>
|
||||||
|
# Recent Activity
|
||||||
|
|
||||||
|
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||||
|
|
||||||
|
### Feb 11, 2026
|
||||||
|
|
||||||
|
| ID | Time | T | Title | Read |
|
||||||
|
| ---- | ------- | --- | ---------------------------------------------------- | ---- |
|
||||||
|
| #360 | 5:43 PM | 🔄 | Removed Unused Popover Configuration Objects | ~224 |
|
||||||
|
| #359 | " | ✅ | IconPicker Icon Size Reduced | ~179 |
|
||||||
|
| #358 | " | 🔵 | IconPicker Component with Online/Offline Icon Search | ~673 |
|
||||||
|
|
||||||
|
</claude-mem-context>
|
||||||
760
antdv-next-admin/src/components/IconPicker/index.vue
Normal file
760
antdv-next-admin/src/components/IconPicker/index.vue
Normal file
@ -0,0 +1,760 @@
|
|||||||
|
<template>
|
||||||
|
<a-popover
|
||||||
|
v-model:open="open"
|
||||||
|
trigger="click"
|
||||||
|
placement="bottomLeft"
|
||||||
|
:overlay-style="{ width: '360px', paddingTop: '4px' }"
|
||||||
|
overlay-class-name="icon-picker-overlay"
|
||||||
|
@open-change="onOpenChange"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<a-input-search
|
||||||
|
v-model:value="inputValue"
|
||||||
|
class="ip-input-trigger"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
:name="inputName"
|
||||||
|
allow-clear
|
||||||
|
@change="onInputChange"
|
||||||
|
>
|
||||||
|
<template #enterButton>
|
||||||
|
<a-button class="ip-addon" @mousedown.prevent.stop="togglePopover">
|
||||||
|
<IconView :icon="inputValue || 'ion:apps-outline'" :size="18" />
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</a-input-search>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<a-form-item-rest>
|
||||||
|
<div class="ip-wrap">
|
||||||
|
<div class="ip-row1">
|
||||||
|
<a-input
|
||||||
|
ref="searchRef"
|
||||||
|
v-model:value="keyword"
|
||||||
|
allow-clear
|
||||||
|
:placeholder="searchPlaceholder"
|
||||||
|
class="ip-search"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<IconView icon="ant-design:search-outlined" color="#999" />
|
||||||
|
</template>
|
||||||
|
</a-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ip-row2">
|
||||||
|
<a-segmented
|
||||||
|
v-model:value="category"
|
||||||
|
:options="categoryOptions"
|
||||||
|
size="small"
|
||||||
|
block
|
||||||
|
class="ip-seg"
|
||||||
|
@change="onCategoryChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ip-grid-container">
|
||||||
|
<div v-if="pageItems.length === 0" class="ip-empty">
|
||||||
|
<a-empty description="No icons" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="ip-grid">
|
||||||
|
<a-tooltip
|
||||||
|
v-for="name in pageItems"
|
||||||
|
:key="name"
|
||||||
|
:mouse-enter-delay="0.5"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div class="ip-tooltip-content">
|
||||||
|
<span class="ip-tooltip-name">{{ name }}</span>
|
||||||
|
<a-tag
|
||||||
|
class="ip-tooltip-tag"
|
||||||
|
:color="getIconMeta(name).color"
|
||||||
|
>
|
||||||
|
{{ getIconMeta(name).label }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ip-item"
|
||||||
|
:class="{ selected: effectiveValue === name }"
|
||||||
|
:style="{ '--hover-color': getIconMeta(name).color }"
|
||||||
|
@click="apply(name)"
|
||||||
|
>
|
||||||
|
<IconView :icon="name" :size="20" />
|
||||||
|
<div class="ip-item-bar" />
|
||||||
|
</button>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ip-footer">
|
||||||
|
<span class="ip-total">{{ filteredTotal }}</span>
|
||||||
|
<a-pagination
|
||||||
|
v-model:current="page"
|
||||||
|
:page-size="props.pageSize"
|
||||||
|
:total="filteredTotal"
|
||||||
|
size="small"
|
||||||
|
show-less-items
|
||||||
|
:show-size-changer="false"
|
||||||
|
class="ip-pagination"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-form-item-rest>
|
||||||
|
</template>
|
||||||
|
</a-popover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import * as AntdvIcons from "@antdv-next/icons";
|
||||||
|
import ion from "@iconify-json/ion/icons.json";
|
||||||
|
import mdi from "@iconify-json/mdi/icons.json";
|
||||||
|
import ri from "@iconify-json/ri/icons.json";
|
||||||
|
import { computed, h, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||||||
|
|
||||||
|
import IconView from "@/components/Icon/index.vue";
|
||||||
|
import { $t } from "@/locales";
|
||||||
|
|
||||||
|
type Category = "all" | "ri" | "mdi" | "ion" | "antdv-next" | "svg" | "online";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: string;
|
||||||
|
value?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
pageSize?: number;
|
||||||
|
svgIcons?: string[];
|
||||||
|
svgPrefix?: string;
|
||||||
|
onlineLimit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IconsJson {
|
||||||
|
icons?: Record<string, unknown>;
|
||||||
|
aliases?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
placeholder: "",
|
||||||
|
pageSize: 36,
|
||||||
|
svgPrefix: "icon-",
|
||||||
|
onlineLimit: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", v: string): void;
|
||||||
|
(e: "update:value", v: string): void;
|
||||||
|
(e: "change", v: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const boundValue = computed(() => props.value ?? props.modelValue ?? "");
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
const editableValue = ref(boundValue.value);
|
||||||
|
const inputSnapshot = ref(editableValue.value);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
boundValue,
|
||||||
|
(value) => {
|
||||||
|
if (!open.value) {
|
||||||
|
editableValue.value = value ?? "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputValue = computed<string>({
|
||||||
|
get: () => editableValue.value,
|
||||||
|
set: (value) => {
|
||||||
|
editableValue.value = value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputName = `iconpicker_${Math.random().toString(36).slice(2)}`;
|
||||||
|
const placeholder = computed(
|
||||||
|
() => props.placeholder || $t("iconPicker.selectIcon"),
|
||||||
|
);
|
||||||
|
const searchPlaceholder = computed(() => $t("iconPicker.searchPlaceholder"));
|
||||||
|
|
||||||
|
const category = ref<Category>("all");
|
||||||
|
const keyword = ref("");
|
||||||
|
const page = ref(1);
|
||||||
|
const searchRef = ref<{ focus?: () => void } | null>(null);
|
||||||
|
|
||||||
|
const iconifyNames = (prefix: string, json: IconsJson) => {
|
||||||
|
const names = [
|
||||||
|
...Object.keys(json.icons || {}),
|
||||||
|
...Object.keys(json.aliases || {}),
|
||||||
|
];
|
||||||
|
return names.map((name) => `${prefix}:${name}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeSvgName = (name: string) => {
|
||||||
|
const value = name.trim();
|
||||||
|
if (!value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value.startsWith("svg:") ? value : `svg:${value}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const localSvgModules = import.meta.glob("../../assets/icons/**/*.svg");
|
||||||
|
|
||||||
|
const extractSvgSymbolName = (path: string) => {
|
||||||
|
const matched = path.match(/\/icons\/(.*)\.svg$/);
|
||||||
|
if (!matched || !matched[1]) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const normalized = matched[1].replace(/\//g, "-");
|
||||||
|
const prefix = props.svgPrefix || "";
|
||||||
|
return normalized.startsWith(prefix) ? normalized : `${prefix}${normalized}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dedupe = (items: string[]) => Array.from(new Set(items));
|
||||||
|
|
||||||
|
const riAll = computed(() => iconifyNames("ri", ri as IconsJson));
|
||||||
|
const mdiAll = computed(() => iconifyNames("mdi", mdi as IconsJson));
|
||||||
|
const ionAll = computed(() => iconifyNames("ion", ion as IconsJson));
|
||||||
|
|
||||||
|
const antdvAll = computed(() =>
|
||||||
|
Object.keys(AntdvIcons)
|
||||||
|
.filter((name) => /(Outlined|Filled|TwoTone)$/.test(name))
|
||||||
|
.map((name) => `antdv-next:${name}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const svgAll = computed(() => {
|
||||||
|
const fromLocal = Object.keys(localSvgModules)
|
||||||
|
.map(extractSvgSymbolName)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(normalizeSvgName);
|
||||||
|
|
||||||
|
const fromProps = (props.svgIcons || [])
|
||||||
|
.map(normalizeSvgName)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return dedupe([...fromProps, ...fromLocal]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const allOfflineIcons = computed(() => {
|
||||||
|
return dedupe([
|
||||||
|
...riAll.value,
|
||||||
|
...mdiAll.value,
|
||||||
|
...ionAll.value,
|
||||||
|
...antdvAll.value,
|
||||||
|
...svgAll.value,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const iconMetaConfig: Record<string, { label: string; color: string }> = {
|
||||||
|
ri: { label: "Remix", color: "#3b82f6" },
|
||||||
|
mdi: { label: "MDI", color: "#10b981" },
|
||||||
|
ion: { label: "Ion", color: "#8b5cf6" },
|
||||||
|
"antdv-next": { label: "Antdv", color: "#ef4444" },
|
||||||
|
svg: { label: "SVG", color: "#f59e0b" },
|
||||||
|
online: { label: "Online", color: "#64748b" },
|
||||||
|
unknown: { label: "Unknown", color: "#9ca3af" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIconMeta = (iconName: string) => {
|
||||||
|
const value = iconName.trim();
|
||||||
|
if (value.startsWith("ri:")) return iconMetaConfig.ri;
|
||||||
|
if (value.startsWith("mdi:")) return iconMetaConfig.mdi;
|
||||||
|
if (value.startsWith("ion:")) return iconMetaConfig.ion;
|
||||||
|
if (value.startsWith("svg:")) return iconMetaConfig.svg;
|
||||||
|
if (value.startsWith("antdv-next:") || value.startsWith("antd:"))
|
||||||
|
return iconMetaConfig["antdv-next"];
|
||||||
|
if (value.includes(":")) return iconMetaConfig.online;
|
||||||
|
return iconMetaConfig.unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onlineIcons = ref<string[]>([]);
|
||||||
|
const onlineLoading = ref(false);
|
||||||
|
const onlineError = ref("");
|
||||||
|
const onlineCache = new Map<string, string[]>();
|
||||||
|
const onlineAbortController = ref<AbortController | null>(null);
|
||||||
|
let onlineTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const shouldSearchOnline = computed(() => {
|
||||||
|
const query = keyword.value.trim();
|
||||||
|
if (query.length < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return category.value === "all";
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetOnlineState = () => {
|
||||||
|
if (onlineAbortController.value) {
|
||||||
|
onlineAbortController.value.abort();
|
||||||
|
onlineAbortController.value = null;
|
||||||
|
}
|
||||||
|
onlineLoading.value = false;
|
||||||
|
onlineError.value = "";
|
||||||
|
onlineIcons.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchOnlineIcons = async (query: string) => {
|
||||||
|
const normalized = query.trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
onlineIcons.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onlineCache.has(normalized)) {
|
||||||
|
onlineIcons.value = onlineCache.get(normalized) || [];
|
||||||
|
onlineError.value = "";
|
||||||
|
onlineLoading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onlineAbortController.value) {
|
||||||
|
onlineAbortController.value.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
onlineAbortController.value = controller;
|
||||||
|
onlineLoading.value = true;
|
||||||
|
onlineError.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://api.iconify.design/search?query=${encodeURIComponent(normalized)}&limit=${props.onlineLimit}`;
|
||||||
|
const response = await fetch(url, { signal: controller.signal });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { icons?: string[] };
|
||||||
|
const icons = Array.isArray(data.icons)
|
||||||
|
? data.icons.filter(
|
||||||
|
(item): item is string => typeof item === "string" && Boolean(item),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
onlineCache.set(normalized, icons);
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onlineIcons.value = icons;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const errorMessage = error instanceof Error ? error.message : undefined;
|
||||||
|
onlineError.value = errorMessage
|
||||||
|
? $t("iconPicker.onlineSearchFailedDetail", { message: errorMessage })
|
||||||
|
: $t("iconPicker.onlineSearchFailed");
|
||||||
|
onlineIcons.value = [];
|
||||||
|
} finally {
|
||||||
|
if (onlineAbortController.value === controller) {
|
||||||
|
onlineAbortController.value = null;
|
||||||
|
}
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
onlineLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleOnlineSearch = () => {
|
||||||
|
if (onlineTimer) {
|
||||||
|
clearTimeout(onlineTimer);
|
||||||
|
onlineTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldSearchOnline.value) {
|
||||||
|
resetOnlineState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = keyword.value.trim();
|
||||||
|
onlineTimer = setTimeout(() => {
|
||||||
|
fetchOnlineIcons(query);
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch([keyword, category], () => {
|
||||||
|
scheduleOnlineSearch();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (onlineTimer) {
|
||||||
|
clearTimeout(onlineTimer);
|
||||||
|
onlineTimer = null;
|
||||||
|
}
|
||||||
|
if (onlineAbortController.value) {
|
||||||
|
onlineAbortController.value.abort();
|
||||||
|
onlineAbortController.value = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const listByCategory = computed<string[]>(() => {
|
||||||
|
switch (category.value) {
|
||||||
|
case "ri":
|
||||||
|
return riAll.value;
|
||||||
|
case "mdi":
|
||||||
|
return mdiAll.value;
|
||||||
|
case "ion":
|
||||||
|
return ionAll.value;
|
||||||
|
case "antdv-next":
|
||||||
|
return antdvAll.value;
|
||||||
|
case "svg":
|
||||||
|
return svgAll.value;
|
||||||
|
default:
|
||||||
|
return keyword.value.trim().length >= 2
|
||||||
|
? dedupe([...allOfflineIcons.value, ...onlineIcons.value])
|
||||||
|
: allOfflineIcons.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const query = keyword.value.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return listByCategory.value;
|
||||||
|
}
|
||||||
|
if (category.value === "online") {
|
||||||
|
return listByCategory.value;
|
||||||
|
}
|
||||||
|
return listByCategory.value.filter((item) =>
|
||||||
|
item.toLowerCase().includes(query),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredTotal = computed(() => filtered.value.length);
|
||||||
|
|
||||||
|
const pageItems = computed(() => {
|
||||||
|
const start = (page.value - 1) * props.pageSize;
|
||||||
|
return filtered.value.slice(start, start + props.pageSize);
|
||||||
|
});
|
||||||
|
|
||||||
|
const allCount = computed(() => allOfflineIcons.value.length);
|
||||||
|
|
||||||
|
const categoryBadgeConfig: Record<string, { name: string; dotColor: string }> =
|
||||||
|
{
|
||||||
|
all: { name: "ALL", dotColor: "#64748b" },
|
||||||
|
ri: { name: "RI", dotColor: "#3b82f6" },
|
||||||
|
mdi: { name: "MDI", dotColor: "#10b981" },
|
||||||
|
ion: { name: "ION", dotColor: "#8b5cf6" },
|
||||||
|
"antdv-next": { name: "Ant", dotColor: "#ef4444" },
|
||||||
|
svg: { name: "SVG", dotColor: "#f59e0b" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCategoryLabel = (key: string, count: number) => {
|
||||||
|
const config = categoryBadgeConfig[key];
|
||||||
|
return h("div", { class: "ip-seg-item" }, [
|
||||||
|
h("div", { class: "ip-seg-line1" }, [
|
||||||
|
h("span", {
|
||||||
|
class: "ip-seg-dot",
|
||||||
|
style: { backgroundColor: config.dotColor },
|
||||||
|
}),
|
||||||
|
h("span", { class: "ip-seg-name" }, config.name),
|
||||||
|
]),
|
||||||
|
h("div", { class: "ip-seg-line2" }, String(count)),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryOptions = computed(() => [
|
||||||
|
{ value: "all", label: renderCategoryLabel("all", allCount.value) },
|
||||||
|
{ value: "ri", label: renderCategoryLabel("ri", riAll.value.length) },
|
||||||
|
{ value: "mdi", label: renderCategoryLabel("mdi", mdiAll.value.length) },
|
||||||
|
{ value: "ion", label: renderCategoryLabel("ion", ionAll.value.length) },
|
||||||
|
{
|
||||||
|
value: "antdv-next",
|
||||||
|
label: renderCategoryLabel("antdv-next", antdvAll.value.length),
|
||||||
|
},
|
||||||
|
{ value: "svg", label: renderCategoryLabel("svg", svgAll.value.length) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const effectiveValue = computed(() => boundValue.value);
|
||||||
|
|
||||||
|
const emitUpdate = (value: string) => {
|
||||||
|
emit("update:value", value);
|
||||||
|
emit("update:modelValue", value);
|
||||||
|
emit("change", value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const focusSearch = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
searchRef.value?.focus?.();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const detectCategoryByIcon = (iconName: string): Category => {
|
||||||
|
if (!iconName) {
|
||||||
|
return "all";
|
||||||
|
}
|
||||||
|
if (iconName.startsWith("ri:")) return "ri";
|
||||||
|
if (iconName.startsWith("mdi:")) return "mdi";
|
||||||
|
if (iconName.startsWith("ion:")) return "ion";
|
||||||
|
if (iconName.startsWith("svg:")) return "svg";
|
||||||
|
if (iconName.startsWith("antdv-next:") || iconName.startsWith("antd:"))
|
||||||
|
return "antdv-next";
|
||||||
|
return "all";
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePopover = () => {
|
||||||
|
open.value = !open.value;
|
||||||
|
if (open.value) {
|
||||||
|
focusSearch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const apply = (name: string) => {
|
||||||
|
editableValue.value = name;
|
||||||
|
inputSnapshot.value = name;
|
||||||
|
emitUpdate(name);
|
||||||
|
open.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onInputChange = () => {
|
||||||
|
inputSnapshot.value = editableValue.value;
|
||||||
|
emitUpdate(editableValue.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCategoryChange = () => {
|
||||||
|
page.value = 1;
|
||||||
|
focusSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpenChange = (next: boolean) => {
|
||||||
|
if (next) {
|
||||||
|
inputSnapshot.value = editableValue.value;
|
||||||
|
category.value = detectCategoryByIcon(boundValue.value.trim());
|
||||||
|
page.value = 1;
|
||||||
|
focusSearch();
|
||||||
|
} else {
|
||||||
|
editableValue.value = inputSnapshot.value;
|
||||||
|
}
|
||||||
|
open.value = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch([category, keyword], () => {
|
||||||
|
page.value = 1;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
/* 主容器 */
|
||||||
|
.ip-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分类栏样式 */
|
||||||
|
.ip-seg {
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
|
||||||
|
/* 强制调整内部 Ant Design 样式 */
|
||||||
|
:deep(.ant-segmented-item-label) {
|
||||||
|
min-height: unset;
|
||||||
|
padding: 4px 6px !important;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 11px !important;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ip-seg-item) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 第一行:点 + 名称 */
|
||||||
|
:deep(.ip-seg-line1) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ip-seg-dot) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 第二行:数量 */
|
||||||
|
:deep(.ip-seg-line2) {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #999;
|
||||||
|
font-size: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 网格容器背景 */
|
||||||
|
.ip-grid-container {
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图标网格防遮挡 */
|
||||||
|
.ip-grid {
|
||||||
|
display: grid;
|
||||||
|
position: relative;
|
||||||
|
grid-auto-rows: 40px;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
height: 305px;
|
||||||
|
|
||||||
|
/* 关键修改:增加内边距,防止 hover 上浮时被 overflow 切掉 */
|
||||||
|
padding: 12px;
|
||||||
|
overflow: hidden auto;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
/* 滚动条样式 */
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.ip-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 280px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图标卡片 */
|
||||||
|
.ip-item {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
/* 关键:防止底部色条超出圆角 */
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 2px rgb(0 0 0 / 2%);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
/* 悬停效果 */
|
||||||
|
&:hover {
|
||||||
|
z-index: 10;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: var(--hover-color, #1677ff);
|
||||||
|
box-shadow: 0 4px 10px rgb(0 0 0 / 8%);
|
||||||
|
|
||||||
|
/* hover 时色条变粗 */
|
||||||
|
.ip-item-bar {
|
||||||
|
height: 4px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中状态 */
|
||||||
|
&.selected {
|
||||||
|
z-index: 5;
|
||||||
|
border-color: #1677ff;
|
||||||
|
background: #e6f7ff;
|
||||||
|
box-shadow: inset 0 0 0 1px #1677ff;
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部色彩条 - 修复版 */
|
||||||
|
.ip-item-bar {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
/* 默认显示 2px 的高度,确保颜色可见 */
|
||||||
|
height: 2px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
/* 稍微透明一点,不抢眼,但能看清颜色 */
|
||||||
|
opacity: 0.7;
|
||||||
|
background-color: var(--hover-color, #ccc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部栏 */
|
||||||
|
.ip-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-total {
|
||||||
|
color: #888;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
b {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 深度选择器修改分页样式,使其更紧凑 */
|
||||||
|
.ip-pagination :deep(.ant-pagination-item),
|
||||||
|
.ip-pagination :deep(.ant-pagination-prev),
|
||||||
|
.ip-pagination :deep(.ant-pagination-next) {
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip 内容 */
|
||||||
|
.ip-tooltip-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-tooltip-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ip-tooltip-tag {
|
||||||
|
height: 18px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
/* 全局样式,确保弹出框内层撑满外层宽度 */
|
||||||
|
.icon-picker-overlay {
|
||||||
|
.ant-popover-inner {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-popover-inner-content {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
667
antdv-next-admin/src/components/JsonInput/JsonFieldTreeList.vue
Normal file
667
antdv-next-admin/src/components/JsonInput/JsonFieldTreeList.vue
Normal file
@ -0,0 +1,667 @@
|
|||||||
|
<template>
|
||||||
|
<div class="field-tree-level">
|
||||||
|
<draggable
|
||||||
|
v-model="fieldOrder"
|
||||||
|
:item-key="getFieldItemKey"
|
||||||
|
handle=".drag-handle"
|
||||||
|
class="field-list"
|
||||||
|
@start="handleDragStart"
|
||||||
|
@end="handleDragEnd"
|
||||||
|
>
|
||||||
|
<template #item="{ element: key }">
|
||||||
|
<div
|
||||||
|
v-if="currentObject && currentObject[key] !== undefined"
|
||||||
|
class="field-node"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="field-row"
|
||||||
|
:class="{
|
||||||
|
'is-dragging': draggingPathKey === getFieldPathKey(key),
|
||||||
|
'is-hovered': hoveredPathKey === getFieldPathKey(key),
|
||||||
|
'is-new-field': isNewField(key),
|
||||||
|
}"
|
||||||
|
:style="{ paddingLeft: `${12 + depth * 20}px` }"
|
||||||
|
@mouseenter="handleHover(getFieldPathKey(key))"
|
||||||
|
@mouseleave="handleHover('')"
|
||||||
|
>
|
||||||
|
<div class="drag-handle" v-if="allowSort">
|
||||||
|
<HolderOutlined />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-button
|
||||||
|
v-if="isObjectField(key)"
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
class="expand-toggle"
|
||||||
|
@click="toggleFieldExpand(key)"
|
||||||
|
>
|
||||||
|
<CaretDownOutlined v-if="isFieldExpanded(key)" />
|
||||||
|
<CaretRightOutlined v-else />
|
||||||
|
</a-button>
|
||||||
|
<div v-else class="expand-placeholder" />
|
||||||
|
|
||||||
|
<div class="field-label-section">
|
||||||
|
<a-input
|
||||||
|
v-if="allowEditKey && !api.isFieldReadonlyByPath(path, key)"
|
||||||
|
:value="key"
|
||||||
|
size="middle"
|
||||||
|
class="field-key-input"
|
||||||
|
:class="{ 'is-new': isNewField(key) }"
|
||||||
|
placeholder="字段名"
|
||||||
|
@update:value="(val: string) => handleKeyChange(key, val)"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
<div class="field-label">
|
||||||
|
{{ api.getFieldLabelByPath(path, key) }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="api.hasLabelMapByPath(path, key)"
|
||||||
|
class="field-key-hint"
|
||||||
|
>
|
||||||
|
{{ key }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-input-section">
|
||||||
|
<template v-if="api.getFieldTypeByPath(path, key) === 'object'">
|
||||||
|
<div class="object-field-wrapper">
|
||||||
|
<span class="object-summary">{{
|
||||||
|
api.getObjectSummaryByPath(path, key)
|
||||||
|
}}</span>
|
||||||
|
<a-button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
@click="toggleFieldExpand(key)"
|
||||||
|
>
|
||||||
|
{{ isFieldExpanded(key) ? "收起" : "展开" }}
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template
|
||||||
|
v-else-if="api.getFieldTypeByPath(path, key) === 'tags'"
|
||||||
|
>
|
||||||
|
<a-select
|
||||||
|
v-model:value="currentObject[key]"
|
||||||
|
mode="tags"
|
||||||
|
size="middle"
|
||||||
|
style="width: 100%"
|
||||||
|
placeholder="输入标签按回车确认"
|
||||||
|
:max-tag-count="2"
|
||||||
|
:disabled="api.isFieldReadonlyByPath(path, key)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template
|
||||||
|
v-else-if="api.getFieldTypeByPath(path, key) === 'boolean'"
|
||||||
|
>
|
||||||
|
<div class="boolean-field-wrapper">
|
||||||
|
<a-switch
|
||||||
|
v-model:checked="currentObject[key]"
|
||||||
|
size="small"
|
||||||
|
:disabled="api.isFieldReadonlyByPath(path, key)"
|
||||||
|
/>
|
||||||
|
<span class="switch-label">
|
||||||
|
{{
|
||||||
|
currentObject[key]
|
||||||
|
? api.getFieldConfigByPath(path, key)?.activeLabel ||
|
||||||
|
"已启用"
|
||||||
|
: api.getFieldConfigByPath(path, key)?.inactiveLabel ||
|
||||||
|
"已禁用"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template
|
||||||
|
v-else-if="api.getFieldTypeByPath(path, key) === 'number'"
|
||||||
|
>
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="currentObject[key]"
|
||||||
|
:controls="false"
|
||||||
|
style="width: 100%"
|
||||||
|
:placeholder="api.getFieldLabelByPath(path, key)"
|
||||||
|
:min="api.getFieldConfigByPath(path, key)?.min"
|
||||||
|
:max="api.getFieldConfigByPath(path, key)?.max"
|
||||||
|
:disabled="api.isFieldDisabledByPath(path, key)"
|
||||||
|
:readonly="api.isFieldReadonlyByPath(path, key)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template
|
||||||
|
v-else-if="api.getFieldTypeByPath(path, key) === 'array'"
|
||||||
|
>
|
||||||
|
<a-textarea
|
||||||
|
:value="api.getArrayFieldTextByPath(path, key)"
|
||||||
|
size="middle"
|
||||||
|
placeholder="JSON: [1, 2, 3]"
|
||||||
|
:auto-size="{ minRows: 1, maxRows: 3 }"
|
||||||
|
:disabled="api.isFieldDisabledByPath(path, key)"
|
||||||
|
:readonly="api.isFieldReadonlyByPath(path, key)"
|
||||||
|
@update:value="api.onArrayTextChangeByPath(path, key, $event)"
|
||||||
|
@blur="api.validateArrayByPath(path, key)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="api.isLongTextFieldByPath(path, key)">
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="currentObject[key]"
|
||||||
|
size="middle"
|
||||||
|
:placeholder="api.getFieldLabelByPath(path, key)"
|
||||||
|
:auto-size="{ minRows: 2, maxRows: 4 }"
|
||||||
|
show-count
|
||||||
|
:maxlength="
|
||||||
|
api.getFieldConfigByPath(path, key)?.maxLength || 500
|
||||||
|
"
|
||||||
|
:disabled="api.isFieldDisabledByPath(path, key)"
|
||||||
|
:readonly="api.isFieldReadonlyByPath(path, key)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<a-input
|
||||||
|
v-model:value="currentObject[key]"
|
||||||
|
size="middle"
|
||||||
|
:placeholder="api.getFieldLabelByPath(path, key)"
|
||||||
|
:allow-clear="!api.isFieldReadonlyByPath(path, key)"
|
||||||
|
:disabled="api.isFieldDisabledByPath(path, key)"
|
||||||
|
:readonly="api.isFieldReadonlyByPath(path, key)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="field-actions"
|
||||||
|
:class="{
|
||||||
|
'is-visible':
|
||||||
|
hoveredPathKey === getFieldPathKey(key) || isNewField(key),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<a-select
|
||||||
|
v-if="allowEditType && !api.isFieldReadonlyByPath(path, key)"
|
||||||
|
:value="api.getFieldTypeByPath(path, key)"
|
||||||
|
size="middle"
|
||||||
|
class="type-selector"
|
||||||
|
:options="fieldTypeOptions"
|
||||||
|
@change="(val: FieldType) => handleTypeChange(key, val)"
|
||||||
|
/>
|
||||||
|
<a-button
|
||||||
|
v-if="allowDelete && !api.isFieldReadonlyByPath(path, key)"
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
@click="removeField(key)"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<JsonFieldTreeList
|
||||||
|
v-if="isObjectField(key) && isFieldExpanded(key)"
|
||||||
|
:path="getFieldPath(key)"
|
||||||
|
:depth="depth + 1"
|
||||||
|
:allow-add="allowAdd"
|
||||||
|
:allow-delete="allowDelete"
|
||||||
|
:allow-sort="allowSort"
|
||||||
|
:allow-edit-key="allowEditKey"
|
||||||
|
:allow-edit-type="allowEditType"
|
||||||
|
:hovered-path-key="hoveredPathKey"
|
||||||
|
:dragging-path-key="draggingPathKey"
|
||||||
|
:api="api"
|
||||||
|
:new-field-keys="newFieldKeys"
|
||||||
|
@hover-change="emit('hover-change', $event)"
|
||||||
|
@request-add-field="emit('request-add-field', $event)"
|
||||||
|
@remove-field="emit('remove-field', $event)"
|
||||||
|
@drag-start="emit('drag-start', $event)"
|
||||||
|
@drag-end="emit('drag-end')"
|
||||||
|
@update-field-key="emit('update-field-key', $event)"
|
||||||
|
@update-field-type="emit('update-field-type', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="allowAdd && currentObject"
|
||||||
|
class="add-field-section"
|
||||||
|
:style="{ paddingLeft: `${12 + depth * 20}px` }"
|
||||||
|
>
|
||||||
|
<a-button
|
||||||
|
type="dashed"
|
||||||
|
size="small"
|
||||||
|
class="add-field-btn"
|
||||||
|
@click="requestAddField"
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
新增字段
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
HolderOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
CaretDownOutlined,
|
||||||
|
CaretRightOutlined,
|
||||||
|
} from "@antdv-next/icons";
|
||||||
|
import { computed, type PropType } from "vue";
|
||||||
|
import draggable from "vuedraggable";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "JsonFieldTreeList",
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FieldType =
|
||||||
|
| "string"
|
||||||
|
| "number"
|
||||||
|
| "boolean"
|
||||||
|
| "tags"
|
||||||
|
| "array"
|
||||||
|
| "object";
|
||||||
|
|
||||||
|
export interface JsonObject {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldConfig {
|
||||||
|
type?: FieldType;
|
||||||
|
component?: "input" | "textarea";
|
||||||
|
label?: string;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
maxLength?: number;
|
||||||
|
activeLabel?: string;
|
||||||
|
inactiveLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JsonTreeEditorApi {
|
||||||
|
getObjectByPath: (path: string[]) => JsonObject | null;
|
||||||
|
getFieldOrderByPath: (path: string[]) => string[];
|
||||||
|
setFieldOrderByPath: (path: string[], order: string[]) => void;
|
||||||
|
getFieldPath: (path: string[], key: string) => string[];
|
||||||
|
getFieldPathKey: (path: string[], key: string) => string;
|
||||||
|
getFieldLabelByPath: (path: string[], key: string) => string;
|
||||||
|
hasLabelMapByPath: (path: string[], key: string) => boolean;
|
||||||
|
getFieldTypeByPath: (path: string[], key: string) => FieldType;
|
||||||
|
getFieldConfigByPath: (
|
||||||
|
path: string[],
|
||||||
|
key: string,
|
||||||
|
) => FieldConfig | undefined;
|
||||||
|
isLongTextFieldByPath: (path: string[], key: string) => boolean;
|
||||||
|
isFieldDisabledByPath: (path: string[], key: string) => boolean;
|
||||||
|
isFieldReadonlyByPath: (path: string[], key: string) => boolean;
|
||||||
|
getObjectSummaryByPath: (path: string[], key: string) => string;
|
||||||
|
getArrayFieldTextByPath: (path: string[], key: string) => string;
|
||||||
|
onArrayTextChangeByPath: (path: string[], key: string, value: string) => void;
|
||||||
|
validateArrayByPath: (path: string[], key: string) => void;
|
||||||
|
isPathExpanded: (path: string[]) => boolean;
|
||||||
|
togglePathExpanded: (path: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoveFieldPayload {
|
||||||
|
path: string[];
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragStartPayload {
|
||||||
|
path: string[];
|
||||||
|
oldIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateFieldKeyPayload {
|
||||||
|
path: string[];
|
||||||
|
oldKey: string;
|
||||||
|
newKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateFieldTypePayload {
|
||||||
|
path: string[];
|
||||||
|
key: string;
|
||||||
|
type: FieldType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
path: {
|
||||||
|
type: Array as PropType<string[]>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
depth: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
allowAdd: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
allowDelete: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
allowSort: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
allowEditKey: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
allowEditType: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
hoveredPathKey: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
draggingPathKey: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
api: {
|
||||||
|
type: Object as PropType<JsonTreeEditorApi>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
newFieldKeys: {
|
||||||
|
type: Array as PropType<string[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "hover-change", pathKey: string): void;
|
||||||
|
(e: "request-add-field", path: string[]): void;
|
||||||
|
(e: "remove-field", payload: RemoveFieldPayload): void;
|
||||||
|
(e: "drag-start", payload: DragStartPayload): void;
|
||||||
|
(e: "drag-end"): void;
|
||||||
|
(e: "update-field-key", payload: UpdateFieldKeyPayload): void;
|
||||||
|
(e: "update-field-type", payload: UpdateFieldTypePayload): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const fieldTypeOptions: Array<{ label: string; value: FieldType }> = [
|
||||||
|
{ label: "文本", value: "string" },
|
||||||
|
{ label: "数字", value: "number" },
|
||||||
|
{ label: "布尔", value: "boolean" },
|
||||||
|
{ label: "标签", value: "tags" },
|
||||||
|
{ label: "数组", value: "array" },
|
||||||
|
{ label: "对象", value: "object" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentObject = computed<JsonObject | null>(() =>
|
||||||
|
props.api.getObjectByPath(props.path),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fieldOrder = computed<string[]>({
|
||||||
|
get: () => props.api.getFieldOrderByPath(props.path),
|
||||||
|
set: (order) => props.api.setFieldOrderByPath(props.path, order),
|
||||||
|
});
|
||||||
|
|
||||||
|
function getFieldItemKey(key: string): string {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFieldPath(key: string): string[] {
|
||||||
|
return props.api.getFieldPath(props.path, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFieldPathKey(key: string): string {
|
||||||
|
return props.api.getFieldPathKey(props.path, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObjectField(key: string): boolean {
|
||||||
|
return props.api.getFieldTypeByPath(props.path, key) === "object";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFieldExpanded(key: string): boolean {
|
||||||
|
return props.api.isPathExpanded(getFieldPath(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFieldExpand(key: string) {
|
||||||
|
props.api.togglePathExpanded(getFieldPath(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNewField(key: string): boolean {
|
||||||
|
return props.newFieldKeys.includes(getFieldPathKey(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeField(key: string) {
|
||||||
|
emit("remove-field", {
|
||||||
|
path: [...props.path],
|
||||||
|
key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestAddField() {
|
||||||
|
emit("request-add-field", [...props.path]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHover(pathKey: string) {
|
||||||
|
emit("hover-change", pathKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragStart(event: { oldIndex?: number }) {
|
||||||
|
emit("drag-start", {
|
||||||
|
path: [...props.path],
|
||||||
|
oldIndex: event.oldIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd() {
|
||||||
|
emit("drag-end");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyChange(oldKey: string, newKey: string) {
|
||||||
|
const trimmedKey = newKey.trim();
|
||||||
|
if (trimmedKey && trimmedKey !== oldKey) {
|
||||||
|
emit("update-field-key", {
|
||||||
|
path: [...props.path],
|
||||||
|
oldKey,
|
||||||
|
newKey: trimmedKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTypeChange(key: string, type: FieldType) {
|
||||||
|
emit("update-field-type", {
|
||||||
|
path: [...props.path],
|
||||||
|
key,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.field-tree-level {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border-secondary);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.is-hovered {
|
||||||
|
background: var(--color-primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-dragging {
|
||||||
|
background: var(--color-primary-bg);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-new-field {
|
||||||
|
background: var(--color-primary-bg);
|
||||||
|
border-left: 3px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 32px;
|
||||||
|
cursor: grab;
|
||||||
|
color: var(--color-text-quaternary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-toggle,
|
||||||
|
.expand-placeholder {
|
||||||
|
width: 22px;
|
||||||
|
height: 32px;
|
||||||
|
min-width: 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-placeholder {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label-section {
|
||||||
|
width: 140px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
line-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-key-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-top: 2px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-key-input {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&.is-new {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: var(--color-bg-container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input-section {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
:deep(.ant-input),
|
||||||
|
:deep(.ant-select),
|
||||||
|
:deep(.ant-input-affix-wrapper),
|
||||||
|
:deep(.ant-input-number) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boolean-field-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-field-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 32px;
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
border: 1px dashed var(--color-border-secondary);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 10px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-summary {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-btn) {
|
||||||
|
padding: 0 6px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-selector {
|
||||||
|
width: 90px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-field-section {
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border-secondary);
|
||||||
|
|
||||||
|
.add-field-btn {
|
||||||
|
border-style: dashed;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.field-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.field-label-section {
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 0;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1035
antdv-next-admin/src/components/JsonInput/index.vue
Normal file
1035
antdv-next-admin/src/components/JsonInput/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
402
antdv-next-admin/src/components/Layout/AICollabPanel.vue
Normal file
402
antdv-next-admin/src/components/Layout/AICollabPanel.vue
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="ai-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="title-wrap">
|
||||||
|
<div class="title">{{ $t('layout.aiAssistantTitle') }}</div>
|
||||||
|
<div class="subtitle">{{ $t('layout.aiAssistantSubtitle') }}</div>
|
||||||
|
</div>
|
||||||
|
<a-button type="text" class="close-btn" @click="emitClose">
|
||||||
|
<CloseOutlined />
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="context-row">
|
||||||
|
<a-tag color="blue">{{ $t('layout.aiCurrentPage') }}: {{ currentPageTitle }}</a-tag>
|
||||||
|
<a-tag>{{ route.path }}</a-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="quick-actions">
|
||||||
|
<div class="section-title">{{ $t('layout.aiQuickActions') }}</div>
|
||||||
|
<a-space wrap size="small">
|
||||||
|
<a-button
|
||||||
|
v-for="item in quickActions"
|
||||||
|
:key="item.id"
|
||||||
|
size="small"
|
||||||
|
@click="applyPrompt(item.prompt)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="messagesBodyRef" class="messages">
|
||||||
|
<template v-if="messages.length > 0">
|
||||||
|
<div
|
||||||
|
v-for="message in messages"
|
||||||
|
:key="message.id"
|
||||||
|
class="message-item"
|
||||||
|
:class="[
|
||||||
|
`is-${message.role}`,
|
||||||
|
{ 'is-streaming': isStreaming && streamingMessageId === message.id },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="message-role">{{ message.role === 'assistant' ? 'AI' : 'You' }}</div>
|
||||||
|
<div class="message-content">{{ message.content }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="message-empty">
|
||||||
|
<div class="empty-title">{{ $t('layout.aiEmptyTitle') }}</div>
|
||||||
|
<div class="empty-desc">{{ $t('layout.aiEmptyDescription') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-wrap">
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="draft"
|
||||||
|
:rows="3"
|
||||||
|
:placeholder="$t('layout.aiInputPlaceholder')"
|
||||||
|
@keydown.enter="handleEnter"
|
||||||
|
/>
|
||||||
|
<div class="input-actions">
|
||||||
|
<span class="hint">{{
|
||||||
|
isStreaming ? $t('common.loading') : $t('layout.aiEnterHint')
|
||||||
|
}}</span>
|
||||||
|
<a-space size="small">
|
||||||
|
<a-button size="small" @click="clearMessages">{{ $t('common.clear') }}</a-button>
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:disabled="!draft.trim() || isStreaming"
|
||||||
|
@click="sendMessage"
|
||||||
|
>
|
||||||
|
{{ $t('common.submit') }}
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CloseOutlined } from '@antdv-next/icons';
|
||||||
|
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
import { $t } from '@/locales';
|
||||||
|
import { resolveLocaleText } from '@/utils/i18n';
|
||||||
|
|
||||||
|
type MessageRole = 'assistant' | 'user';
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
id: number;
|
||||||
|
role: MessageRole;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuickActionItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
prompt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const draft = ref('');
|
||||||
|
const messages = ref<ChatMessage[]>([]);
|
||||||
|
const messagesBodyRef = ref<HTMLElement | null>(null);
|
||||||
|
const isStreaming = ref(false);
|
||||||
|
const streamingMessageId = ref<number | null>(null);
|
||||||
|
let messageId = 0;
|
||||||
|
let streamTimer: number | null = null;
|
||||||
|
|
||||||
|
const currentPageTitle = computed(() => {
|
||||||
|
const routeTitle = typeof route.meta?.title === 'string' ? route.meta.title : '';
|
||||||
|
const routeName = typeof route.name === 'string' ? route.name : route.path;
|
||||||
|
if (routeTitle) {
|
||||||
|
return resolveLocaleText(routeTitle, routeName);
|
||||||
|
}
|
||||||
|
return routeName;
|
||||||
|
});
|
||||||
|
|
||||||
|
const quickActions = computed<QuickActionItem[]>(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'explain',
|
||||||
|
label: $t('layout.aiActionExplain'),
|
||||||
|
prompt: $t('layout.aiActionExplain'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'summary',
|
||||||
|
label: $t('layout.aiActionSummary'),
|
||||||
|
prompt: $t('layout.aiActionSummary'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'risk',
|
||||||
|
label: $t('layout.aiActionRisk'),
|
||||||
|
prompt: $t('layout.aiActionRisk'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'next-step',
|
||||||
|
label: $t('layout.aiActionNextStep'),
|
||||||
|
prompt: $t('layout.aiActionNextStep'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const emitClose = () => {
|
||||||
|
stopStreaming();
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendMessage = (role: MessageRole, content: string) => {
|
||||||
|
messageId += 1;
|
||||||
|
messages.value.push({
|
||||||
|
id: messageId,
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
return messageId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPrompt = (prompt: string) => {
|
||||||
|
draft.value = prompt;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const body = messagesBodyRef.value;
|
||||||
|
if (!body) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.scrollTop = body.scrollHeight;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopStreaming = () => {
|
||||||
|
if (streamTimer !== null) {
|
||||||
|
window.clearInterval(streamTimer);
|
||||||
|
streamTimer = null;
|
||||||
|
}
|
||||||
|
isStreaming.value = false;
|
||||||
|
streamingMessageId.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const streamAssistantReply = (fullReply: string) => {
|
||||||
|
stopStreaming();
|
||||||
|
|
||||||
|
const assistantId = appendMessage('assistant', '');
|
||||||
|
streamingMessageId.value = assistantId;
|
||||||
|
isStreaming.value = true;
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
streamTimer = window.setInterval(() => {
|
||||||
|
const target = messages.value.find((item) => item.id === assistantId);
|
||||||
|
if (!target) {
|
||||||
|
stopStreaming();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = Math.random() > 0.82 ? 2 : 1;
|
||||||
|
index = Math.min(fullReply.length, index + step);
|
||||||
|
target.content = fullReply.slice(0, index);
|
||||||
|
scrollToBottom();
|
||||||
|
|
||||||
|
if (index >= fullReply.length) {
|
||||||
|
stopStreaming();
|
||||||
|
}
|
||||||
|
}, 30);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendMessage = async () => {
|
||||||
|
if (isStreaming.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = draft.value.trim();
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendMessage('user', input);
|
||||||
|
draft.value = '';
|
||||||
|
await nextTick();
|
||||||
|
scrollToBottom();
|
||||||
|
|
||||||
|
const reply = $t('layout.aiDemoReply', { page: currentPageTitle.value });
|
||||||
|
streamAssistantReply(reply);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnter = (event: KeyboardEvent) => {
|
||||||
|
if (event.shiftKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
void sendMessage();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearMessages = () => {
|
||||||
|
stopStreaming();
|
||||||
|
messages.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopStreaming();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ai-panel {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-bg-container);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border-secondary);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-container));
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-wrap {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
padding: 10px 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-role {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item.is-assistant .message-content {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item.is-user .message-content {
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item.is-streaming .message-content::after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 2px;
|
||||||
|
height: 1em;
|
||||||
|
margin-left: 2px;
|
||||||
|
vertical-align: -2px;
|
||||||
|
background: currentColor;
|
||||||
|
animation: ai-stream-caret 1s steps(1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-empty {
|
||||||
|
margin: auto;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrap {
|
||||||
|
border-top: 1px solid var(--color-border-secondary);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-actions {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ai-stream-caret {
|
||||||
|
0%,
|
||||||
|
49% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
955
antdv-next-admin/src/components/Layout/AdminLayout.vue
Normal file
955
antdv-next-admin/src/components/Layout/AdminLayout.vue
Normal file
@ -0,0 +1,955 @@
|
|||||||
|
<template>
|
||||||
|
<a-watermark v-bind="watermarkStore.watermarkProps" class="global-watermark">
|
||||||
|
<a-skip-to-content target="#main-content" />
|
||||||
|
<a-layout
|
||||||
|
class="admin-layout"
|
||||||
|
:class="[settingsStore.layoutMode, { mobile: layoutStore.isMobile }]"
|
||||||
|
>
|
||||||
|
<!-- Vertical Layout -->
|
||||||
|
<template v-if="settingsStore.layoutMode === 'vertical'">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<Sidebar v-if="!layoutStore.pageFullscreen" />
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<a-layout
|
||||||
|
class="layout-main"
|
||||||
|
:style="{
|
||||||
|
marginLeft:
|
||||||
|
layoutStore.isMobile || layoutStore.pageFullscreen
|
||||||
|
? '0px'
|
||||||
|
: `${layoutStore.getCurrentSidebarWidth()}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<Header v-if="!layoutStore.pageFullscreen" />
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<TabBar />
|
||||||
|
|
||||||
|
<!-- Page Content -->
|
||||||
|
<a-layout-content
|
||||||
|
id="main-content"
|
||||||
|
class="page-content"
|
||||||
|
:class="{ 'is-iframe-page': isIframePage }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref="workspaceRef"
|
||||||
|
class="page-workspace"
|
||||||
|
:class="{ 'is-ai-active': isAICollabActive }"
|
||||||
|
>
|
||||||
|
<div class="page-workspace-main">
|
||||||
|
<div class="page-scroll">
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<transition
|
||||||
|
:name="settingsStore.pageAnimation"
|
||||||
|
mode="out-in"
|
||||||
|
>
|
||||||
|
<keep-alive :include="cachedTabs">
|
||||||
|
<component :is="Component" :key="pageViewKey" />
|
||||||
|
</keep-alive>
|
||||||
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition name="ai-panel">
|
||||||
|
<div
|
||||||
|
v-if="isAICollabActive"
|
||||||
|
class="page-workspace-side"
|
||||||
|
:style="{
|
||||||
|
'--ai-side-width': `${effectiveAiPanelWidth + 14}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="page-workspace-resizer"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
@mousedown="startAiResize"
|
||||||
|
/>
|
||||||
|
<AICollabPanel
|
||||||
|
class="page-workspace-ai"
|
||||||
|
:style="{ width: `${effectiveAiPanelWidth}px` }"
|
||||||
|
@close="layoutStore.setAiCollabEnabled(false)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</a-layout-content>
|
||||||
|
</a-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Horizontal Layout -->
|
||||||
|
<template v-else>
|
||||||
|
<a-layout-header
|
||||||
|
v-if="!layoutStore.pageFullscreen"
|
||||||
|
class="horizontal-header"
|
||||||
|
>
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="logo">
|
||||||
|
<img :src="logoImg" alt="Logo" />
|
||||||
|
<span class="logo-title">{{
|
||||||
|
$t("common.appName") || "Antdv Next Admin"
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="menuAreaRef" class="horizontal-menu-area">
|
||||||
|
<a-menu
|
||||||
|
class="horizontal-main-menu"
|
||||||
|
mode="horizontal"
|
||||||
|
:disabled-overflow="true"
|
||||||
|
:selected-keys="horizontalSelectedKeys"
|
||||||
|
:items="visibleHorizontalMenuItems"
|
||||||
|
trigger-sub-menu-action="hover"
|
||||||
|
@click="handleHorizontalMenuClick"
|
||||||
|
/>
|
||||||
|
<a-dropdown
|
||||||
|
v-if="overflowHorizontalMenuItems.length > 0"
|
||||||
|
:menu="overflowMenuProps"
|
||||||
|
:trigger="['hover']"
|
||||||
|
placement="bottomRight"
|
||||||
|
>
|
||||||
|
<a-button type="text" class="horizontal-overflow-trigger">
|
||||||
|
<EllipsisOutlined />
|
||||||
|
</a-button>
|
||||||
|
</a-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<Header :show-breadcrumb="false" :show-collapse-button="false" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref="measureMenuWrapRef"
|
||||||
|
class="horizontal-menu-measure-wrap"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<a-menu
|
||||||
|
class="horizontal-menu-measure"
|
||||||
|
mode="horizontal"
|
||||||
|
:disabled-overflow="true"
|
||||||
|
:items="horizontalMenuItems"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</a-layout-header>
|
||||||
|
|
||||||
|
<a-layout>
|
||||||
|
<a-layout-content class="horizontal-content">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<TabBar />
|
||||||
|
|
||||||
|
<!-- Page Content -->
|
||||||
|
<div
|
||||||
|
class="page-content"
|
||||||
|
:class="{ 'is-iframe-page': isIframePage }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref="workspaceRef"
|
||||||
|
class="page-workspace"
|
||||||
|
:class="{ 'is-ai-active': isAICollabActive }"
|
||||||
|
>
|
||||||
|
<div class="page-workspace-main">
|
||||||
|
<div class="page-scroll">
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<transition
|
||||||
|
:name="settingsStore.pageAnimation"
|
||||||
|
mode="out-in"
|
||||||
|
>
|
||||||
|
<keep-alive :include="cachedTabs">
|
||||||
|
<component :is="Component" :key="pageViewKey" />
|
||||||
|
</keep-alive>
|
||||||
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition name="ai-panel">
|
||||||
|
<div
|
||||||
|
v-if="isAICollabActive"
|
||||||
|
class="page-workspace-side"
|
||||||
|
:style="{
|
||||||
|
'--ai-side-width': `${effectiveAiPanelWidth + 14}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="page-workspace-resizer"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
@mousedown="startAiResize"
|
||||||
|
/>
|
||||||
|
<AICollabPanel
|
||||||
|
class="page-workspace-ai"
|
||||||
|
:style="{ width: `${effectiveAiPanelWidth}px` }"
|
||||||
|
@close="layoutStore.setAiCollabEnabled(false)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-layout-content>
|
||||||
|
</a-layout>
|
||||||
|
</template>
|
||||||
|
</a-layout>
|
||||||
|
</a-watermark>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MenuItem as MenuItemType } from "@/types/router";
|
||||||
|
import type { MenuProps } from "antdv-next";
|
||||||
|
|
||||||
|
import { DownOutlined, EllipsisOutlined } from "@antdv-next/icons";
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
h,
|
||||||
|
nextTick,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
} from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
|
import logoImg from "@/assets/images/logo.png";
|
||||||
|
import { basicRoutes } from "@/router/routes";
|
||||||
|
import { routesToMenuTree } from "@/router/utils";
|
||||||
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
import { usePermissionStore } from "@/stores/permission";
|
||||||
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
|
import { useTabsStore } from "@/stores/tabs";
|
||||||
|
import { useWatermarkStore } from "@/stores/watermark";
|
||||||
|
import { resolveLocaleText } from "@/utils/i18n";
|
||||||
|
import { resolveIcon } from "@/utils/icon";
|
||||||
|
|
||||||
|
import AICollabPanel from "./AICollabPanel.vue";
|
||||||
|
import Header from "./Header.vue";
|
||||||
|
import Sidebar from "./Sidebar.vue";
|
||||||
|
import TabBar from "./TabBar.vue";
|
||||||
|
|
||||||
|
type HorizontalMenuItems = NonNullable<MenuProps["items"]>;
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const layoutStore = useLayoutStore();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const tabsStore = useTabsStore();
|
||||||
|
const permissionStore = usePermissionStore();
|
||||||
|
const watermarkStore = useWatermarkStore();
|
||||||
|
|
||||||
|
const menuAreaRef = ref<HTMLElement>();
|
||||||
|
const measureMenuWrapRef = ref<HTMLElement>();
|
||||||
|
const measuredTopMenuWidths = ref<number[]>([]);
|
||||||
|
const visibleMenuCount = ref(0);
|
||||||
|
const OVERFLOW_TRIGGER_WIDTH = 36;
|
||||||
|
const MIN_AI_PANEL_WIDTH = 320;
|
||||||
|
const MAX_AI_PANEL_WIDTH = 560;
|
||||||
|
const MIN_MAIN_WORKSPACE_WIDTH = 420;
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
let workspaceResizeObserver: ResizeObserver | null = null;
|
||||||
|
let rafId = 0;
|
||||||
|
|
||||||
|
const workspaceRef = ref<HTMLElement | null>(null);
|
||||||
|
const workspaceWidth = ref(0);
|
||||||
|
const isAiResizing = ref(false);
|
||||||
|
|
||||||
|
const cachedTabs = computed(() => tabsStore.cachedTabs);
|
||||||
|
const pageViewKey = computed(() => route.path);
|
||||||
|
|
||||||
|
const isIframePage = computed(() => {
|
||||||
|
return route.path.includes("/iframe/");
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAICollabActive = computed(() => {
|
||||||
|
return (
|
||||||
|
layoutStore.aiCollabEnabled &&
|
||||||
|
!layoutStore.isMobile &&
|
||||||
|
!layoutStore.pageFullscreen
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxAiPanelWidth = computed(() => {
|
||||||
|
if (workspaceWidth.value <= 0) {
|
||||||
|
return MAX_AI_PANEL_WIDTH;
|
||||||
|
}
|
||||||
|
const limitByWorkspace = workspaceWidth.value - MIN_MAIN_WORKSPACE_WIDTH;
|
||||||
|
const capped = Math.min(MAX_AI_PANEL_WIDTH, limitByWorkspace);
|
||||||
|
return Math.max(MIN_AI_PANEL_WIDTH, capped);
|
||||||
|
});
|
||||||
|
|
||||||
|
const effectiveAiPanelWidth = computed(() => {
|
||||||
|
return Math.max(
|
||||||
|
MIN_AI_PANEL_WIDTH,
|
||||||
|
Math.min(layoutStore.aiPanelWidth, maxAiPanelWidth.value),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fallbackMenuItems = computed(() => {
|
||||||
|
const basicChildren = basicRoutes.flatMap((item) => item.children || []);
|
||||||
|
return routesToMenuTree(basicChildren);
|
||||||
|
});
|
||||||
|
|
||||||
|
const menuItems = computed(() => {
|
||||||
|
if (permissionStore.menuTree.length > 0) {
|
||||||
|
return permissionStore.menuTree;
|
||||||
|
}
|
||||||
|
return fallbackMenuItems.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const convertHorizontalMenus = (
|
||||||
|
menus: MenuItemType[],
|
||||||
|
showCustomSubmenuArrow: boolean,
|
||||||
|
): HorizontalMenuItems => {
|
||||||
|
const convert = (list: MenuItemType[]): HorizontalMenuItems => {
|
||||||
|
return list.map((menu) => {
|
||||||
|
const iconComponent = resolveIcon(menu.icon);
|
||||||
|
const text = resolveLocaleText(menu.label, menu.id);
|
||||||
|
const childMenus = menu.children || [];
|
||||||
|
const hasChildren = childMenus.length > 0;
|
||||||
|
const label =
|
||||||
|
hasChildren && showCustomSubmenuArrow
|
||||||
|
? h("span", { class: "horizontal-submenu-label" }, [
|
||||||
|
h("span", { class: "horizontal-submenu-text" }, text),
|
||||||
|
h(DownOutlined, { class: "horizontal-submenu-arrow" }),
|
||||||
|
])
|
||||||
|
: text;
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
key: menu.path || menu.id,
|
||||||
|
label,
|
||||||
|
icon: iconComponent ? h(iconComponent) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasChildren) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
key: menu.id,
|
||||||
|
children: convert(childMenus),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return convert(menus);
|
||||||
|
};
|
||||||
|
|
||||||
|
const horizontalMenuItems = computed<HorizontalMenuItems>(() => {
|
||||||
|
return convertHorizontalMenus(menuItems.value, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dropdownOverflowMenuItems = computed<HorizontalMenuItems>(() => {
|
||||||
|
return convertHorizontalMenus(menuItems.value, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
function isExternalLinkPath(path: string): boolean {
|
||||||
|
return path.startsWith("http://") || path.startsWith("https://");
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMenuByPath(
|
||||||
|
menus: MenuItemType[],
|
||||||
|
targetPath: string,
|
||||||
|
): MenuItemType | null {
|
||||||
|
for (const item of menus) {
|
||||||
|
if ((item.path || item.id) === targetPath) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
const found = findMenuByPath(item.children, targetPath);
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const horizontalSelectedKeys = computed(() => {
|
||||||
|
const currentMenuItem = findMenuByPath(menuItems.value, route.path);
|
||||||
|
|
||||||
|
// Don't set selected state if current menu item is an external link
|
||||||
|
if (
|
||||||
|
currentMenuItem &&
|
||||||
|
currentMenuItem.path &&
|
||||||
|
isExternalLinkPath(currentMenuItem.path)
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [route.path];
|
||||||
|
});
|
||||||
|
const normalizedVisibleMenuCount = computed(() => {
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(visibleMenuCount.value, horizontalMenuItems.value.length),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleHorizontalMenuItems = computed<HorizontalMenuItems>(() => {
|
||||||
|
return horizontalMenuItems.value.slice(0, normalizedVisibleMenuCount.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const overflowHorizontalMenuItems = computed<HorizontalMenuItems>(() => {
|
||||||
|
return dropdownOverflowMenuItems.value.slice(
|
||||||
|
normalizedVisibleMenuCount.value,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleHorizontalMenuClick = ({ key }: { key: string | number }) => {
|
||||||
|
if (typeof key !== "string") return;
|
||||||
|
|
||||||
|
// External links: open in a new tab
|
||||||
|
// No need to change selectedKeys as horizontalSelectedKeys is a computed property
|
||||||
|
// based on route.path, which won't change when opening external links
|
||||||
|
if (key.startsWith("http://") || key.startsWith("https://")) {
|
||||||
|
window.open(key, "_blank", "noopener,noreferrer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal routes
|
||||||
|
if (key.startsWith("/")) {
|
||||||
|
router.push(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const overflowMenuProps = computed(() => ({
|
||||||
|
items: overflowHorizontalMenuItems.value,
|
||||||
|
triggerSubMenuAction: "hover" as const,
|
||||||
|
onClick: handleHorizontalMenuClick,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const updateWorkspaceWidth = () => {
|
||||||
|
workspaceWidth.value = workspaceRef.value?.getBoundingClientRect().width || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncAiPanelWidth = () => {
|
||||||
|
if (!layoutStore.aiCollabEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamped = Math.max(
|
||||||
|
MIN_AI_PANEL_WIDTH,
|
||||||
|
Math.min(layoutStore.aiPanelWidth, maxAiPanelWidth.value),
|
||||||
|
);
|
||||||
|
if (clamped !== layoutStore.aiPanelWidth) {
|
||||||
|
layoutStore.setAiPanelWidth(clamped);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAiResize = () => {
|
||||||
|
if (!isAiResizing.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isAiResizing.value = false;
|
||||||
|
document.body.classList.remove("is-ai-panel-resizing");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAiResizeMove = (event: MouseEvent) => {
|
||||||
|
if (!isAiResizing.value || !workspaceRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = workspaceRef.value.getBoundingClientRect();
|
||||||
|
const nextWidth = rect.right - event.clientX;
|
||||||
|
const clampedWidth = Math.max(
|
||||||
|
MIN_AI_PANEL_WIDTH,
|
||||||
|
Math.min(nextWidth, maxAiPanelWidth.value),
|
||||||
|
);
|
||||||
|
layoutStore.setAiPanelWidth(clampedWidth);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAiResize = (event: MouseEvent) => {
|
||||||
|
if (!isAICollabActive.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
isAiResizing.value = true;
|
||||||
|
document.body.classList.add("is-ai-panel-resizing");
|
||||||
|
};
|
||||||
|
|
||||||
|
const measureHorizontalMenuItemWidths = () => {
|
||||||
|
const wrap = measureMenuWrapRef.value;
|
||||||
|
if (!wrap) {
|
||||||
|
measuredTopMenuWidths.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemElements = wrap.querySelectorAll(
|
||||||
|
".ant-menu-root > .ant-menu-item, .ant-menu-root > .ant-menu-submenu",
|
||||||
|
);
|
||||||
|
measuredTopMenuWidths.value = Array.from(itemElements).map((element) => {
|
||||||
|
return Math.ceil((element as HTMLElement).getBoundingClientRect().width);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const recalculateVisibleMenuCount = () => {
|
||||||
|
const totalCount = horizontalMenuItems.value.length;
|
||||||
|
if (totalCount === 0) {
|
||||||
|
visibleMenuCount.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const areaWidth = menuAreaRef.value?.clientWidth || 0;
|
||||||
|
if (!areaWidth) {
|
||||||
|
visibleMenuCount.value = totalCount;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widths = measuredTopMenuWidths.value;
|
||||||
|
if (widths.length !== totalCount) {
|
||||||
|
visibleMenuCount.value = totalCount;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalWidth = widths.reduce((sum, width) => sum + width, 0);
|
||||||
|
if (totalWidth <= areaWidth) {
|
||||||
|
visibleMenuCount.value = totalCount;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxWidth = Math.max(0, areaWidth - OVERFLOW_TRIGGER_WIDTH);
|
||||||
|
let used = 0;
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
widths.forEach((width) => {
|
||||||
|
if (used + width <= maxWidth) {
|
||||||
|
used += width;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
visibleMenuCount.value = count;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleMenuLayout = () => {
|
||||||
|
if (rafId) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
}
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
rafId = 0;
|
||||||
|
nextTick(() => {
|
||||||
|
measureHorizontalMenuItemWidths();
|
||||||
|
recalculateVisibleMenuCount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
layoutStore.initLayout();
|
||||||
|
scheduleMenuLayout();
|
||||||
|
updateWorkspaceWidth();
|
||||||
|
syncAiPanelWidth();
|
||||||
|
window.addEventListener("mousemove", handleAiResizeMove);
|
||||||
|
window.addEventListener("mouseup", stopAiResize);
|
||||||
|
|
||||||
|
if (typeof ResizeObserver !== "undefined") {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
scheduleMenuLayout();
|
||||||
|
});
|
||||||
|
workspaceResizeObserver = new ResizeObserver(() => {
|
||||||
|
updateWorkspaceWidth();
|
||||||
|
syncAiPanelWidth();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (menuAreaRef.value) resizeObserver.observe(menuAreaRef.value);
|
||||||
|
if (measureMenuWrapRef.value)
|
||||||
|
resizeObserver.observe(measureMenuWrapRef.value);
|
||||||
|
if (workspaceRef.value) workspaceResizeObserver.observe(workspaceRef.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (rafId) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
rafId = 0;
|
||||||
|
}
|
||||||
|
stopAiResize();
|
||||||
|
window.removeEventListener("mousemove", handleAiResizeMove);
|
||||||
|
window.removeEventListener("mouseup", stopAiResize);
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
resizeObserver = null;
|
||||||
|
workspaceResizeObserver?.disconnect();
|
||||||
|
workspaceResizeObserver = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[horizontalMenuItems, dropdownOverflowMenuItems],
|
||||||
|
() => {
|
||||||
|
scheduleMenuLayout();
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => settingsStore.layoutMode,
|
||||||
|
() => {
|
||||||
|
scheduleMenuLayout();
|
||||||
|
nextTick(() => {
|
||||||
|
if (workspaceResizeObserver) {
|
||||||
|
workspaceResizeObserver.disconnect();
|
||||||
|
if (workspaceRef.value) {
|
||||||
|
workspaceResizeObserver.observe(workspaceRef.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateWorkspaceWidth();
|
||||||
|
syncAiPanelWidth();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.path,
|
||||||
|
() => {
|
||||||
|
scheduleMenuLayout();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => layoutStore.isMobile,
|
||||||
|
(isMobile) => {
|
||||||
|
if (isMobile && layoutStore.aiCollabEnabled) {
|
||||||
|
layoutStore.setAiCollabEnabled(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => layoutStore.pageFullscreen,
|
||||||
|
(isFullscreen) => {
|
||||||
|
if (isFullscreen && layoutStore.aiCollabEnabled) {
|
||||||
|
layoutStore.setAiCollabEnabled(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => layoutStore.aiCollabEnabled,
|
||||||
|
(enabled) => {
|
||||||
|
if (enabled) {
|
||||||
|
nextTick(() => {
|
||||||
|
updateWorkspaceWidth();
|
||||||
|
syncAiPanelWidth();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
stopAiResize();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.admin-layout {
|
||||||
|
height: 100vh;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.vertical {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.layout-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: margin-left var(--duration-slow) var(--ease-out);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.horizontal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.horizontal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--spacing-lg);
|
||||||
|
background: var(--color-bg-container);
|
||||||
|
box-shadow: var(--shadow-1);
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 50px;
|
||||||
|
line-height: 49px;
|
||||||
|
border-bottom: 1px solid var(--color-border-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-title {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-menu-area {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
margin-left: var(--spacing-lg);
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-main-menu {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: none;
|
||||||
|
--ant-menu-horizontal-line-height: 49px;
|
||||||
|
--ant-menu-item-height: 49px;
|
||||||
|
height: calc(100% - 1px);
|
||||||
|
line-height: 49px;
|
||||||
|
|
||||||
|
:deep(.ant-menu-horizontal::after) {
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-item),
|
||||||
|
:deep(.ant-menu-submenu) {
|
||||||
|
top: 0;
|
||||||
|
height: 49px;
|
||||||
|
line-height: 49px;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-submenu-title) {
|
||||||
|
height: 49px;
|
||||||
|
line-height: 49px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-item::after),
|
||||||
|
:deep(.ant-menu-submenu::after) {
|
||||||
|
bottom: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-submenu-arrow) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.horizontal-submenu-label) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.horizontal-submenu-arrow) {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-overflow-trigger {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 49px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-menu-measure-wrap {
|
||||||
|
position: absolute;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-menu-measure {
|
||||||
|
border-bottom: none;
|
||||||
|
--ant-menu-horizontal-line-height: 49px;
|
||||||
|
--ant-menu-item-height: 49px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
:deep(.admin-header) {
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
> :deep(.ant-layout) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
|
||||||
|
&.is-iframe-page {
|
||||||
|
.page-scroll {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-workspace {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-workspace-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-workspace-side {
|
||||||
|
width: var(--ai-side-width);
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-workspace-ai {
|
||||||
|
height: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-panel-enter-active {
|
||||||
|
transition:
|
||||||
|
width var(--duration-slow) var(--ease-out),
|
||||||
|
opacity var(--duration-slow) var(--ease-out);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-panel-leave-active {
|
||||||
|
transition:
|
||||||
|
width var(--duration-slow) var(--ease-in),
|
||||||
|
opacity var(--duration-base) var(--ease-in);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-panel-enter-from,
|
||||||
|
.ai-panel-leave-to {
|
||||||
|
width: 0 !important;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-workspace-resizer {
|
||||||
|
width: 10px;
|
||||||
|
margin: 0 2px;
|
||||||
|
cursor: col-resize;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 2px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-primary) 35%,
|
||||||
|
var(--color-border-secondary)
|
||||||
|
);
|
||||||
|
opacity: 0.4;
|
||||||
|
transition: opacity var(--duration-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::before {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-scroll {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-scroll :deep(.page-container) {
|
||||||
|
min-height: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mobile {
|
||||||
|
.layout-main {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body.is-ai-panel-resizing) {
|
||||||
|
cursor: col-resize;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global watermark overlay - must cover fixed elements like Sidebar
|
||||||
|
.global-watermark {
|
||||||
|
:deep(> div:last-child) {
|
||||||
|
position: fixed !important;
|
||||||
|
inset: 0 !important;
|
||||||
|
z-index: 9999 !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
127
antdv-next-admin/src/components/Layout/AvatarDropdown.vue
Normal file
127
antdv-next-admin/src/components/Layout/AvatarDropdown.vue
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<a-dropdown :trigger="['click']" placement="bottomRight" :menu="menuProps">
|
||||||
|
<div
|
||||||
|
class="avatar-dropdown"
|
||||||
|
aria-label="User menu"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<a-avatar :src="authStore.user?.avatar" :size="32">
|
||||||
|
{{ authStore.user?.username?.charAt(0).toUpperCase() }}
|
||||||
|
</a-avatar>
|
||||||
|
<span class="username desktop-only">{{
|
||||||
|
authStore.user?.realName || authStore.user?.username
|
||||||
|
}}</span>
|
||||||
|
<DownOutlined class="dropdown-icon desktop-only" />
|
||||||
|
</div>
|
||||||
|
</a-dropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
DownOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
GithubOutlined,
|
||||||
|
BookOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
} from "@antdv-next/icons";
|
||||||
|
import { Modal } from "antdv-next";
|
||||||
|
import { computed, h } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
|
||||||
|
import { $t } from "@/locales";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const handleMenuClick = ({ key }: { key: string }) => {
|
||||||
|
switch (key) {
|
||||||
|
case "profile":
|
||||||
|
router.push("/profile");
|
||||||
|
break;
|
||||||
|
case "github":
|
||||||
|
window.open("https://github.com/yelog/antdv-next-admin", "_blank");
|
||||||
|
break;
|
||||||
|
case "docs":
|
||||||
|
window.open("https://antdv-next-admin-doc.yelog.org", "_blank");
|
||||||
|
break;
|
||||||
|
case "logout":
|
||||||
|
Modal.confirm({
|
||||||
|
title: $t("layout.logout"),
|
||||||
|
content: $t("layout.logoutConfirm"),
|
||||||
|
okText: $t("common.confirm"),
|
||||||
|
cancelText: $t("common.cancel"),
|
||||||
|
onOk: () => {
|
||||||
|
authStore.logout();
|
||||||
|
router.push("/login");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuProps = computed(() => ({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: "profile",
|
||||||
|
label: $t("layout.profile"),
|
||||||
|
icon: h(UserOutlined),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "github",
|
||||||
|
label: "GitHub",
|
||||||
|
icon: h(GithubOutlined),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "docs",
|
||||||
|
label: $t("layout.documentation"),
|
||||||
|
icon: h(BookOutlined),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "divider",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "logout",
|
||||||
|
label: $t("layout.logout"),
|
||||||
|
icon: h(LogoutOutlined),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onClick: handleMenuClick,
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.avatar-dropdown {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: 0 var(--spacing-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--duration-base) var(--ease-out);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile styles
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
.desktop-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
52
antdv-next-admin/src/components/Layout/Breadcrumb.vue
Normal file
52
antdv-next-admin/src/components/Layout/Breadcrumb.vue
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<a-breadcrumb class="breadcrumb" aria-label="Breadcrumb navigation">
|
||||||
|
<a-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="index">
|
||||||
|
<router-link
|
||||||
|
v-if="item.path && index < breadcrumbs.length - 1"
|
||||||
|
:to="item.path"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</router-link>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
:aria-current="index === breadcrumbs.length - 1 ? 'page' : undefined"
|
||||||
|
>{{ item.label }}</span
|
||||||
|
>
|
||||||
|
</a-breadcrumb-item>
|
||||||
|
</a-breadcrumb>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
|
||||||
|
import { resolveLocaleText } from "@/utils/i18n";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
const matched = route.matched
|
||||||
|
.filter((item) => item.meta && item.meta.title)
|
||||||
|
.filter((item) => item.path !== "/");
|
||||||
|
|
||||||
|
return matched.map((item) => ({
|
||||||
|
label: resolveLocaleText(
|
||||||
|
item.meta.title as string,
|
||||||
|
String(item.name || item.path || "-"),
|
||||||
|
),
|
||||||
|
path: item.path,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.breadcrumb {
|
||||||
|
:deep(.ant-breadcrumb-link) {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
16
antdv-next-admin/src/components/Layout/FullscreenToggle.vue
Normal file
16
antdv-next-admin/src/components/Layout/FullscreenToggle.vue
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<a-tooltip :title="isFullscreen ? $t('layout.exitFullscreen') : $t('layout.fullscreen')">
|
||||||
|
<a-button type="text" class="header-action" @click="toggle">
|
||||||
|
<FullscreenExitOutlined v-if="isFullscreen" />
|
||||||
|
<FullscreenOutlined v-else />
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { FullscreenOutlined, FullscreenExitOutlined } from '@antdv-next/icons';
|
||||||
|
|
||||||
|
import { useFullscreen } from '@/composables/useFullscreen';
|
||||||
|
|
||||||
|
const { isFullscreen, toggle } = useFullscreen();
|
||||||
|
</script>
|
||||||
729
antdv-next-admin/src/components/Layout/GlobalSearch.vue
Normal file
729
antdv-next-admin/src/components/Layout/GlobalSearch.vue
Normal file
@ -0,0 +1,729 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="search-modal">
|
||||||
|
<div v-if="visible" class="global-search-overlay" @click="close">
|
||||||
|
<div class="global-search-content" @click.stop>
|
||||||
|
<div class="search-input-wrapper">
|
||||||
|
<SearchOutlined class="search-icon" />
|
||||||
|
<input
|
||||||
|
ref="searchInputRef"
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
class="search-input"
|
||||||
|
:placeholder="$t('layout.searchPlaceholder')"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
/>
|
||||||
|
<span class="search-tag">ESC</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-body">
|
||||||
|
<!-- Search Results -->
|
||||||
|
<div v-if="searchQuery" class="search-results">
|
||||||
|
<div v-if="searchResults.length > 0">
|
||||||
|
<div
|
||||||
|
v-for="(result, index) in searchResults"
|
||||||
|
:key="result.path"
|
||||||
|
class="search-item"
|
||||||
|
:class="{ active: index === activeIndex }"
|
||||||
|
@click="handleResultClick(result)"
|
||||||
|
@mouseenter="activeIndex = index"
|
||||||
|
>
|
||||||
|
<div class="item-icon">
|
||||||
|
<component
|
||||||
|
:is="getIconComponent(result.icon)"
|
||||||
|
v-if="result.icon"
|
||||||
|
/>
|
||||||
|
<FileOutlined v-else />
|
||||||
|
</div>
|
||||||
|
<div class="item-info">
|
||||||
|
<span
|
||||||
|
class="item-title"
|
||||||
|
v-html="highlightText(result.title, searchQuery)"
|
||||||
|
></span>
|
||||||
|
<span
|
||||||
|
class="item-path"
|
||||||
|
v-html="
|
||||||
|
highlightText(formatPath(result.path), searchQuery)
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<div class="item-actions" @click.stop>
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
class="favorite-btn"
|
||||||
|
@click="toggleFavorite(result.path)"
|
||||||
|
>
|
||||||
|
<StarFilled
|
||||||
|
v-if="isFavorite(result.path)"
|
||||||
|
class="favorite-icon active"
|
||||||
|
/>
|
||||||
|
<StarOutlined v-else class="favorite-icon" />
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<EnterOutlined class="item-enter" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="search-empty">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<SearchOutlined />
|
||||||
|
</div>
|
||||||
|
<p>{{ $t("layout.noSearchResults") }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent History -->
|
||||||
|
<div v-else-if="menuHistory.length > 0" class="search-results">
|
||||||
|
<div class="search-group-header">
|
||||||
|
<ClockCircleOutlined class="header-icon" />
|
||||||
|
<span class="header-title">{{
|
||||||
|
$t("layout.recentMenus") || "最近访问"
|
||||||
|
}}</span>
|
||||||
|
<a-button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
class="clear-btn"
|
||||||
|
@click="clearHistory"
|
||||||
|
>
|
||||||
|
{{ $t("common.clear") || "清空" }}
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in menuHistory"
|
||||||
|
:key="item.path"
|
||||||
|
class="search-item"
|
||||||
|
:class="{ active: index === activeIndex }"
|
||||||
|
@click="handleHistoryClick(item)"
|
||||||
|
@mouseenter="activeIndex = index"
|
||||||
|
>
|
||||||
|
<div class="item-icon">
|
||||||
|
<component
|
||||||
|
:is="getIconComponent(item.icon)"
|
||||||
|
v-if="item.icon"
|
||||||
|
/>
|
||||||
|
<FileOutlined v-else />
|
||||||
|
</div>
|
||||||
|
<div class="item-info">
|
||||||
|
<span class="item-title">{{ item.title }}</span>
|
||||||
|
<span class="item-path">{{ formatPath(item.path) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-actions" @click.stop>
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
class="favorite-btn"
|
||||||
|
@click="toggleFavorite(item.path)"
|
||||||
|
>
|
||||||
|
<StarFilled
|
||||||
|
v-if="isFavorite(item.path)"
|
||||||
|
class="favorite-icon active"
|
||||||
|
/>
|
||||||
|
<StarOutlined v-else class="favorite-icon" />
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<EnterOutlined class="item-enter" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else class="search-empty">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<SearchOutlined />
|
||||||
|
</div>
|
||||||
|
<p>{{ $t("layout.searchPlaceholder") }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-footer">
|
||||||
|
<div class="footer-item">
|
||||||
|
<span class="key-badge">
|
||||||
|
<ArrowUpOutlined />
|
||||||
|
</span>
|
||||||
|
<span class="key-badge">
|
||||||
|
<ArrowDownOutlined />
|
||||||
|
</span>
|
||||||
|
<span class="footer-text">{{
|
||||||
|
$t("common.navigate") || "Navigate"
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="footer-item">
|
||||||
|
<span class="key-badge">
|
||||||
|
<EnterOutlined />
|
||||||
|
</span>
|
||||||
|
<span class="footer-text">{{
|
||||||
|
$t("common.select") || "Select"
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="footer-item">
|
||||||
|
<span class="key-badge">ESC</span>
|
||||||
|
<span class="footer-text">{{
|
||||||
|
$t("common.close") || "Close"
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MenuItem } from "@/types/router";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SearchOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
EnterOutlined,
|
||||||
|
ArrowUpOutlined,
|
||||||
|
ArrowDownOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
StarOutlined,
|
||||||
|
StarFilled,
|
||||||
|
} from "@antdv-next/icons";
|
||||||
|
import { match as pinyinMatch } from "pinyin-pro";
|
||||||
|
import { computed, ref, watch, nextTick, onBeforeUnmount } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
|
||||||
|
import { basicRoutes } from "@/router/routes";
|
||||||
|
import { routesToMenuTree } from "@/router/utils";
|
||||||
|
import { usePermissionStore } from "@/stores/permission";
|
||||||
|
import { useTabsStore } from "@/stores/tabs";
|
||||||
|
import { resolveLocaleText } from "@/utils/i18n";
|
||||||
|
import { resolveIcon } from "@/utils/icon";
|
||||||
|
|
||||||
|
const MENU_HISTORY_KEY = "app-menu-history";
|
||||||
|
|
||||||
|
interface SearchItem {
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
icon?: string;
|
||||||
|
rawTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MenuHistoryItem {
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
icon?: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const permissionStore = usePermissionStore();
|
||||||
|
const tabsStore = useTabsStore();
|
||||||
|
const visible = ref(false);
|
||||||
|
const searchQuery = ref("");
|
||||||
|
const searchResults = ref<SearchItem[]>([]);
|
||||||
|
const activeIndex = ref(0);
|
||||||
|
const searchInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
const menuHistory = ref<MenuHistoryItem[]>([]);
|
||||||
|
|
||||||
|
const fallbackMenus = computed<MenuItem[]>(() => {
|
||||||
|
const basicChildren = basicRoutes.flatMap((route) => route.children || []);
|
||||||
|
return routesToMenuTree(basicChildren);
|
||||||
|
});
|
||||||
|
|
||||||
|
const menuSource = computed<MenuItem[]>(() => {
|
||||||
|
if (permissionStore.menuTree.length > 0) {
|
||||||
|
return permissionStore.menuTree;
|
||||||
|
}
|
||||||
|
return fallbackMenus.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchSource = computed<SearchItem[]>(() => {
|
||||||
|
const items: SearchItem[] = [];
|
||||||
|
|
||||||
|
const traverse = (menus: MenuItem[], parentLabels: string[] = []) => {
|
||||||
|
menus.forEach((menu) => {
|
||||||
|
const currentLabel = resolveLocaleText(menu.label, menu.path);
|
||||||
|
const currentLabels = [...parentLabels, currentLabel];
|
||||||
|
|
||||||
|
if (menu.children && menu.children.length > 0) {
|
||||||
|
// Only recurse into children, skip non-leaf nodes
|
||||||
|
traverse(menu.children, currentLabels);
|
||||||
|
} else if (menu.path) {
|
||||||
|
// Leaf node: show full parent path
|
||||||
|
items.push({
|
||||||
|
path: menu.path,
|
||||||
|
title: currentLabels.join(" > "),
|
||||||
|
icon: menu.icon,
|
||||||
|
rawTitle: menu.label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
traverse(menuSource.value);
|
||||||
|
|
||||||
|
// Deduplicate
|
||||||
|
const uniqueByPath = new Map<string, SearchItem>();
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (!uniqueByPath.has(item.path)) {
|
||||||
|
uniqueByPath.set(item.path, item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(uniqueByPath.values());
|
||||||
|
});
|
||||||
|
|
||||||
|
const getIconComponent = (icon?: string) => resolveIcon(icon);
|
||||||
|
|
||||||
|
const formatPath = (path: string) => {
|
||||||
|
return path.split("/").filter(Boolean).join(" > ");
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeHtml = (text: string): string => {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
};
|
||||||
|
|
||||||
|
const highlightText = (text: string, query: string): string => {
|
||||||
|
if (!query) return escapeHtml(text);
|
||||||
|
|
||||||
|
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const regex = new RegExp(`(${escapedQuery})`, "gi");
|
||||||
|
if (regex.test(text)) {
|
||||||
|
return text.replace(regex, '<span class="highlight">$1</span>');
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = pinyinMatch(text, query);
|
||||||
|
if (matched && matched.length > 0) {
|
||||||
|
const indexSet = new Set(matched);
|
||||||
|
return Array.from(text)
|
||||||
|
.map((char, i) =>
|
||||||
|
indexSet.has(i)
|
||||||
|
? `<span class="highlight">${char}</span>`
|
||||||
|
: escapeHtml(char),
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return escapeHtml(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
if (!searchQuery.value) {
|
||||||
|
searchResults.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = searchQuery.value.toLowerCase();
|
||||||
|
searchResults.value = searchSource.value
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.title.toLowerCase().includes(query) ||
|
||||||
|
item.path.toLowerCase().includes(query) ||
|
||||||
|
pinyinMatch(item.title, query) !== null,
|
||||||
|
)
|
||||||
|
.slice(0, 20);
|
||||||
|
activeIndex.value = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResultClick = (result: SearchItem) => {
|
||||||
|
router.push(result.path);
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFavorite = (path: string) => {
|
||||||
|
const tab = tabsStore.tabs.find((t) => t.path === path);
|
||||||
|
return tab?.favorite ?? false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFavorite = (path: string) => {
|
||||||
|
tabsStore.toggleFavoriteTab(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = searchQuery.value ? searchResults.value : menuHistory.value;
|
||||||
|
if (items.length === 0) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault();
|
||||||
|
activeIndex.value =
|
||||||
|
activeIndex.value > 0 ? activeIndex.value - 1 : items.length - 1;
|
||||||
|
scrollActiveIntoView();
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault();
|
||||||
|
activeIndex.value =
|
||||||
|
activeIndex.value < items.length - 1 ? activeIndex.value + 1 : 0;
|
||||||
|
scrollActiveIntoView();
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
e.preventDefault();
|
||||||
|
if (searchQuery.value) {
|
||||||
|
handleResultClick(searchResults.value[activeIndex.value]);
|
||||||
|
} else {
|
||||||
|
handleHistoryClick(menuHistory.value[activeIndex.value]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollActiveIntoView = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
const activeEl = document.querySelector(".search-item.active");
|
||||||
|
if (activeEl) {
|
||||||
|
activeEl.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMenuHistory = () => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(MENU_HISTORY_KEY);
|
||||||
|
if (saved) {
|
||||||
|
menuHistory.value = JSON.parse(saved);
|
||||||
|
} else {
|
||||||
|
menuHistory.value = [];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
menuHistory.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHistoryClick = (item: MenuHistoryItem) => {
|
||||||
|
router.push(item.path);
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearHistory = () => {
|
||||||
|
menuHistory.value = [];
|
||||||
|
localStorage.removeItem(MENU_HISTORY_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
visible.value = true;
|
||||||
|
searchQuery.value = "";
|
||||||
|
searchResults.value = [];
|
||||||
|
activeIndex.value = 0;
|
||||||
|
loadMenuHistory();
|
||||||
|
nextTick(() => {
|
||||||
|
searchInputRef.value?.focus();
|
||||||
|
});
|
||||||
|
window.addEventListener("keydown", handleGlobalKeydown);
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
visible.value = false;
|
||||||
|
window.removeEventListener("keydown", handleGlobalKeydown);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGlobalKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(searchQuery, () => {
|
||||||
|
if (searchDebounceTimer) {
|
||||||
|
clearTimeout(searchDebounceTimer);
|
||||||
|
}
|
||||||
|
searchDebounceTimer = setTimeout(() => {
|
||||||
|
handleSearch();
|
||||||
|
searchDebounceTimer = null;
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("keydown", handleGlobalKeydown);
|
||||||
|
if (searchDebounceTimer) {
|
||||||
|
clearTimeout(searchDebounceTimer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({ open, close });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.global-search-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 14vh;
|
||||||
|
background-color: rgba(0, 0, 0, 0.45);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search-content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
background-color: var(--color-bg-container);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow:
|
||||||
|
0 16px 48px rgba(0, 0, 0, 0.12),
|
||||||
|
0 8px 16px rgba(0, 0, 0, 0.06);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-border-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 16px 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--color-border-secondary);
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 18px;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--color-text-quaternary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-tag {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--color-bg-layout);
|
||||||
|
border: 1px solid var(--color-border-secondary);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-family-code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-body {
|
||||||
|
padding: 12px 0;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-group-title {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 0 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.1s ease;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
.item-icon {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-path {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-enter {
|
||||||
|
opacity: 1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-right: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
:deep(.highlight) {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-path {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
:deep(.highlight) {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-enter {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 8px;
|
||||||
|
|
||||||
|
.favorite-btn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.favorite-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
transition: color 0.2s;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .favorite-icon:not(.active) {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active .item-actions {
|
||||||
|
.favorite-btn .favorite-icon:not(.active) {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-btn:hover .favorite-icon:not(.active) {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid var(--color-border-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-weight: 500;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 0;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-top: 1px solid var(--color-border-secondary);
|
||||||
|
background-color: var(--color-bg-layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
|
.key-badge {
|
||||||
|
padding: 2px 4px;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--color-bg-container);
|
||||||
|
border: 1px solid var(--color-border-secondary);
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
font-family: var(--font-family-code);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transitions
|
||||||
|
.search-modal-enter-active,
|
||||||
|
.search-modal-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
|
||||||
|
.global-search-content {
|
||||||
|
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-modal-enter-from,
|
||||||
|
.search-modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
.global-search-content {
|
||||||
|
transform: scale(0.95) translateY(10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
457
antdv-next-admin/src/components/Layout/Header.vue
Normal file
457
antdv-next-admin/src/components/Layout/Header.vue
Normal file
@ -0,0 +1,457 @@
|
|||||||
|
<template>
|
||||||
|
<a-layout-header class="admin-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<!-- Collapse Button -->
|
||||||
|
<a-button
|
||||||
|
v-if="showCollapseButton"
|
||||||
|
type="text"
|
||||||
|
class="collapse-btn"
|
||||||
|
:aria-label="
|
||||||
|
layoutStore.collapsed ? 'Expand sidebar' : 'Collapse sidebar'
|
||||||
|
"
|
||||||
|
@click="layoutStore.toggleSidebar"
|
||||||
|
>
|
||||||
|
<MenuFoldOutlined v-if="!layoutStore.collapsed" />
|
||||||
|
<MenuUnfoldOutlined v-else />
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<Breadcrumb v-if="showBreadcrumb" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-right">
|
||||||
|
<!-- Global Search Trigger -->
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
class="header-action search-btn"
|
||||||
|
aria-label="Search"
|
||||||
|
@click="openGlobalSearch"
|
||||||
|
>
|
||||||
|
<SearchOutlined />
|
||||||
|
</a-button>
|
||||||
|
<div class="search-trigger desktop-only" @click="openGlobalSearch">
|
||||||
|
<SearchOutlined class="search-icon" />
|
||||||
|
<span class="search-text">{{ $t("common.search") }}</span>
|
||||||
|
<div class="search-key">
|
||||||
|
<span class="search-key-text">{{ isMac ? "⌘" : "Ctrl" }}</span>
|
||||||
|
<span class="search-key-k">K</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop: Show all actions -->
|
||||||
|
<template v-if="!layoutStore.isMobile">
|
||||||
|
<a-tooltip
|
||||||
|
v-if="layoutStore.aiEntryVisible"
|
||||||
|
:title="
|
||||||
|
layoutStore.aiCollabEnabled
|
||||||
|
? $t('layout.aiCollabDisable')
|
||||||
|
: $t('layout.aiCollabEnable')
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
class="header-action ai-toggle-btn"
|
||||||
|
:class="{ active: layoutStore.aiCollabEnabled }"
|
||||||
|
@click="layoutStore.toggleAiCollab"
|
||||||
|
>
|
||||||
|
<MessageOutlined />
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
|
||||||
|
<!-- Fullscreen Toggle -->
|
||||||
|
<FullscreenToggle />
|
||||||
|
|
||||||
|
<!-- Notifications -->
|
||||||
|
<NotificationPanel />
|
||||||
|
|
||||||
|
<!-- Theme Toggle -->
|
||||||
|
<ThemeToggle />
|
||||||
|
|
||||||
|
<!-- Language Switch -->
|
||||||
|
<LanguageSwitch />
|
||||||
|
|
||||||
|
<!-- Settings -->
|
||||||
|
<a-tooltip :title="$t('settings.title')">
|
||||||
|
<a-button type="text" class="header-action" @click="openSettings">
|
||||||
|
<SettingOutlined />
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<a-divider type="vertical" style="height: 20px; margin: 0 4px" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Mobile: More menu (three dots) -->
|
||||||
|
<a-dropdown
|
||||||
|
v-else
|
||||||
|
:trigger="['click']"
|
||||||
|
placement="bottomRight"
|
||||||
|
:menu="moreMenuProps"
|
||||||
|
>
|
||||||
|
<a-button type="text" class="header-action">
|
||||||
|
<MoreOutlined />
|
||||||
|
</a-button>
|
||||||
|
</a-dropdown>
|
||||||
|
|
||||||
|
<!-- User Avatar Dropdown -->
|
||||||
|
<AvatarDropdown />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Global Search Modal -->
|
||||||
|
<GlobalSearch ref="globalSearchRef" />
|
||||||
|
|
||||||
|
<!-- Settings Drawer -->
|
||||||
|
<SettingsDrawer ref="settingsDrawerRef" />
|
||||||
|
</a-layout-header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
MoreOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
FullscreenOutlined,
|
||||||
|
BulbOutlined,
|
||||||
|
GlobalOutlined,
|
||||||
|
} from "@antdv-next/icons";
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount, h, type VNode } from "vue";
|
||||||
|
|
||||||
|
import { $t, setLocale, LOCALE_NATIVE_LABELS } from "@/locales";
|
||||||
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
import { useThemeStore } from "@/stores/theme";
|
||||||
|
|
||||||
|
import AvatarDropdown from "./AvatarDropdown.vue";
|
||||||
|
import Breadcrumb from "./Breadcrumb.vue";
|
||||||
|
import FullscreenToggle from "./FullscreenToggle.vue";
|
||||||
|
import GlobalSearch from "./GlobalSearch.vue";
|
||||||
|
import LanguageSwitch from "./LanguageSwitch.vue";
|
||||||
|
import NotificationPanel from "./NotificationPanel.vue";
|
||||||
|
import SettingsDrawer from "./SettingsDrawer.vue";
|
||||||
|
import ThemeToggle from "./ThemeToggle.vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
showBreadcrumb?: boolean;
|
||||||
|
showCollapseButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MenuItem {
|
||||||
|
key?: string;
|
||||||
|
label?: string;
|
||||||
|
icon?: VNode;
|
||||||
|
type?: "divider";
|
||||||
|
children?: MenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
showBreadcrumb: true,
|
||||||
|
showCollapseButton: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const layoutStore = useLayoutStore();
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
const globalSearchRef = ref();
|
||||||
|
const settingsDrawerRef = ref();
|
||||||
|
const isMac = ref(false);
|
||||||
|
|
||||||
|
const openGlobalSearch = () => {
|
||||||
|
globalSearchRef.value?.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSettings = () => {
|
||||||
|
settingsDrawerRef.value?.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
if (document.exitFullscreen) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoreMenuClick = ({ key }: { key: string }) => {
|
||||||
|
switch (key) {
|
||||||
|
case "fullscreen":
|
||||||
|
toggleFullscreen();
|
||||||
|
break;
|
||||||
|
case "ai-collab":
|
||||||
|
layoutStore.toggleAiCollab();
|
||||||
|
break;
|
||||||
|
case "theme-light":
|
||||||
|
themeStore.setTheme("light");
|
||||||
|
break;
|
||||||
|
case "theme-dark":
|
||||||
|
themeStore.setTheme("dark");
|
||||||
|
break;
|
||||||
|
case "theme-auto":
|
||||||
|
themeStore.setTheme("system");
|
||||||
|
break;
|
||||||
|
case "lang-zh":
|
||||||
|
setLocale("zh-CN");
|
||||||
|
break;
|
||||||
|
case "lang-en":
|
||||||
|
setLocale("en-US");
|
||||||
|
break;
|
||||||
|
case "lang-ja":
|
||||||
|
setLocale("ja-JP");
|
||||||
|
break;
|
||||||
|
case "lang-ko":
|
||||||
|
setLocale("ko-KR");
|
||||||
|
break;
|
||||||
|
case "settings":
|
||||||
|
openSettings();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const moreMenuProps = computed(() => {
|
||||||
|
const items: MenuItem[] = [
|
||||||
|
{
|
||||||
|
key: "fullscreen",
|
||||||
|
label: $t("layout.fullscreen"),
|
||||||
|
icon: h(FullscreenOutlined),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (layoutStore.aiEntryVisible) {
|
||||||
|
items.push({
|
||||||
|
key: "ai-collab",
|
||||||
|
label: layoutStore.aiCollabEnabled
|
||||||
|
? $t("layout.aiCollabDisable")
|
||||||
|
: $t("layout.aiCollabEnable"),
|
||||||
|
icon: h(MessageOutlined),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(
|
||||||
|
{
|
||||||
|
type: "divider",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "theme",
|
||||||
|
label: $t("layout.theme"),
|
||||||
|
icon: h(BulbOutlined),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "theme-light",
|
||||||
|
label: $t("layout.themeLight"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "theme-dark",
|
||||||
|
label: $t("layout.themeDark"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "theme-auto",
|
||||||
|
label: $t("layout.themeAuto"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "language",
|
||||||
|
label: $t("layout.language"),
|
||||||
|
icon: h(GlobalOutlined),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "lang-zh",
|
||||||
|
label: LOCALE_NATIVE_LABELS["zh-CN"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lang-en",
|
||||||
|
label: LOCALE_NATIVE_LABELS["en-US"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lang-ja",
|
||||||
|
label: LOCALE_NATIVE_LABELS["ja-JP"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "lang-ko",
|
||||||
|
label: LOCALE_NATIVE_LABELS["ko-KR"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "divider",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "settings",
|
||||||
|
label: $t("settings.title"),
|
||||||
|
icon: h(SettingOutlined),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return { items, onClick: handleMoreMenuClick };
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
openGlobalSearch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Simple check for Mac
|
||||||
|
isMac.value = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||||
|
window.addEventListener("keydown", handleKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("keydown", handleKeydown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.admin-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 20px;
|
||||||
|
background: var(--color-bg-container);
|
||||||
|
box-shadow: var(--shadow-1);
|
||||||
|
height: 50px;
|
||||||
|
line-height: 50px;
|
||||||
|
border-bottom: 1px solid var(--color-border-secondary);
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
|
||||||
|
.collapse-btn {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
transition: all var(--duration-base) var(--ease-out);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 6px 0 10px;
|
||||||
|
margin-right: 12px;
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-bg-container);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
|
||||||
|
.search-key {
|
||||||
|
border-color: var(--color-primary-3);
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: var(--color-primary-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-text {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-right: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-key {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 6px;
|
||||||
|
background: var(--color-bg-container);
|
||||||
|
border: 1px solid var(--color-border-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
line-height: 18px;
|
||||||
|
font-family: var(--font-family-code);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-action {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 !important;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-toggle-btn.active {
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure buttons in nested components follow the same style.
|
||||||
|
:deep(.header-action) {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 !important;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.header-action:hover) {
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-divider-vertical) {
|
||||||
|
border-inline-start-color: var(--color-border-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile styles
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header-right {
|
||||||
|
.search-btn {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
49
antdv-next-admin/src/components/Layout/LanguageSwitch.vue
Normal file
49
antdv-next-admin/src/components/Layout/LanguageSwitch.vue
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<a-dropdown :trigger="['click']" placement="bottomRight" :menu="menuProps">
|
||||||
|
<a-button type="text" class="header-action">
|
||||||
|
<GlobalOutlined />
|
||||||
|
</a-button>
|
||||||
|
</a-dropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { GlobalOutlined } from '@antdv-next/icons';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import i18n, { setLocale, LOCALE_NATIVE_LABELS } from '@/locales';
|
||||||
|
|
||||||
|
const currentLocale = computed(() => String(i18n.global.locale.value));
|
||||||
|
const localeOptions = computed(() => [
|
||||||
|
{ label: LOCALE_NATIVE_LABELS['zh-CN'], value: 'zh-CN' },
|
||||||
|
{ label: LOCALE_NATIVE_LABELS['en-US'], value: 'en-US' },
|
||||||
|
{ label: LOCALE_NATIVE_LABELS['ja-JP'], value: 'ja-JP' },
|
||||||
|
{ label: LOCALE_NATIVE_LABELS['ko-KR'], value: 'ko-KR' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleLanguageChange = ({ key }: { key: string | number }) => {
|
||||||
|
const nextLocale = String(key);
|
||||||
|
if (!localeOptions.value.some((item) => item.value === nextLocale)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextLocale === currentLocale.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLocale(nextLocale);
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuProps = computed(() => ({
|
||||||
|
items: localeOptions.value.map((item) => ({
|
||||||
|
key: item.value,
|
||||||
|
label: item.label,
|
||||||
|
})),
|
||||||
|
selectedKeys: [currentLocale.value],
|
||||||
|
onClick: handleLanguageChange,
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
:deep(.ant-menu-item-selected) {
|
||||||
|
background: var(--color-primary-1);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
57
antdv-next-admin/src/components/Layout/MenuItem.vue
Normal file
57
antdv-next-admin/src/components/Layout/MenuItem.vue
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<template v-if="!item.hidden">
|
||||||
|
<!-- Menu Item with Children (SubMenu) -->
|
||||||
|
<a-sub-menu v-if="item.children && item.children.length > 0" :key="item.id">
|
||||||
|
<template #icon>
|
||||||
|
<component :is="iconComponent" v-if="iconComponent" />
|
||||||
|
</template>
|
||||||
|
<template #title>
|
||||||
|
<span>{{ displayLabel }}</span>
|
||||||
|
<a-badge v-if="item.badge" :count="item.badge" :offset="[10, 0]" />
|
||||||
|
</template>
|
||||||
|
<MenuItem
|
||||||
|
v-for="child in item.children"
|
||||||
|
:key="child.id"
|
||||||
|
:item="child"
|
||||||
|
:collapsed="collapsed"
|
||||||
|
/>
|
||||||
|
</a-sub-menu>
|
||||||
|
|
||||||
|
<!-- Single Menu Item -->
|
||||||
|
<a-menu-item v-else :key="item.path || item.id" @click="handleClick">
|
||||||
|
<template #icon>
|
||||||
|
<component :is="iconComponent" v-if="iconComponent" />
|
||||||
|
</template>
|
||||||
|
<span>{{ displayLabel }}</span>
|
||||||
|
<a-badge v-if="item.badge" :count="item.badge" :offset="[10, 0]" />
|
||||||
|
</a-menu-item>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MenuItem as MenuItemType } from '@/types/router';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { resolveLocaleText } from '@/utils/i18n';
|
||||||
|
import { resolveIcon } from '@/utils/icon';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: MenuItemType;
|
||||||
|
collapsed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const router = useRouter();
|
||||||
|
const iconComponent = computed(() => resolveIcon(props.item.icon));
|
||||||
|
const displayLabel = computed(() => {
|
||||||
|
return resolveLocaleText(props.item.label, props.item.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (props.item.path) {
|
||||||
|
router.push(props.item.path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
535
antdv-next-admin/src/components/Layout/NotificationPanel.vue
Normal file
535
antdv-next-admin/src/components/Layout/NotificationPanel.vue
Normal file
@ -0,0 +1,535 @@
|
|||||||
|
<template>
|
||||||
|
<a-popover
|
||||||
|
v-model:open="popoverOpen"
|
||||||
|
trigger="click"
|
||||||
|
placement="bottomRight"
|
||||||
|
:arrow="false"
|
||||||
|
:overlay-style="{ paddingTop: '8px' }"
|
||||||
|
overlay-class-name="notification-popover-overlay"
|
||||||
|
>
|
||||||
|
<a-badge :dot="notificationStore.unreadCount > 0" :offset="[-5, 5]">
|
||||||
|
<a-button type="text" class="header-action">
|
||||||
|
<BellOutlined />
|
||||||
|
</a-button>
|
||||||
|
</a-badge>
|
||||||
|
<template #content>
|
||||||
|
<div class="notification-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<span class="title">{{ $t("layout.notifications") }}</span>
|
||||||
|
<span v-if="notificationStore.unreadCount > 0" class="unread-pill">
|
||||||
|
{{ notificationStore.unreadCount }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<a-space size="small">
|
||||||
|
<a-button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
:disabled="notificationStore.unreadCount === 0"
|
||||||
|
@click="handleMarkAllRead"
|
||||||
|
>
|
||||||
|
{{ $t("layout.markAllRead") }}
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
:disabled="notificationStore.notifications.length === 0"
|
||||||
|
@click="handleClearAll"
|
||||||
|
>
|
||||||
|
{{ $t("layout.clearAll") }}
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-body">
|
||||||
|
<template v-if="displayedNotifications.length > 0">
|
||||||
|
<div
|
||||||
|
v-for="notification in displayedNotifications"
|
||||||
|
:key="notification.id"
|
||||||
|
:class="[
|
||||||
|
'notification-item',
|
||||||
|
{ unread: !notification.read },
|
||||||
|
`tone-${getNotificationTone(notification)}`,
|
||||||
|
]"
|
||||||
|
@click="handleNotificationClick(notification)"
|
||||||
|
>
|
||||||
|
<div class="notification-icon" aria-hidden="true">
|
||||||
|
<component :is="getNotificationIcon(notification)" />
|
||||||
|
<span v-if="!notification.read" class="icon-unread-dot" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notification-content">
|
||||||
|
<div class="notification-meta">
|
||||||
|
<div class="notification-title-row">
|
||||||
|
<div class="notification-title">
|
||||||
|
{{ notification.title }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="notification-time">
|
||||||
|
{{ formatTime(notification.timestamp) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="notification-message">
|
||||||
|
{{ notification.message }}
|
||||||
|
</div>
|
||||||
|
<div class="notification-detail-hint">
|
||||||
|
{{ $t("layout.viewDetails") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
class="notification-remove-btn"
|
||||||
|
:title="$t('common.close')"
|
||||||
|
@click.stop="handleRemoveNotification(notification.id)"
|
||||||
|
>
|
||||||
|
<CloseOutlined />
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="notification-empty">
|
||||||
|
<div class="empty-illustration">
|
||||||
|
<BellOutlined class="empty-icon" />
|
||||||
|
<span class="empty-dot" />
|
||||||
|
</div>
|
||||||
|
<div class="empty-title">{{ $t("layout.noNotifications") }}</div>
|
||||||
|
<div class="empty-subtitle">
|
||||||
|
{{ $t("layout.notificationsEmptyHint") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-footer">
|
||||||
|
<a-button type="link" class="view-all-btn" @click="handleViewAll">
|
||||||
|
{{ $t("layout.viewAllNotifications") }}
|
||||||
|
<RightOutlined />
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-popover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Notification } from "@/types/layout";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BellOutlined,
|
||||||
|
RocketOutlined,
|
||||||
|
MailOutlined,
|
||||||
|
SafetyCertificateOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
} from "@antdv-next/icons";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
|
import router from "@/router";
|
||||||
|
import { useNotificationStore } from "@/stores/notification";
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
const notificationStore = useNotificationStore();
|
||||||
|
const popoverOpen = ref(false);
|
||||||
|
|
||||||
|
const displayedNotifications = computed(() => {
|
||||||
|
return notificationStore.notifications.slice(0, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatTime = (timestamp: number) => {
|
||||||
|
return dayjs(timestamp).fromNow();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNotificationTone = (notification: Notification) => {
|
||||||
|
// Use category if explicitly set
|
||||||
|
if (notification.category) {
|
||||||
|
return notification.category;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to type-based classification
|
||||||
|
switch (notification.type) {
|
||||||
|
case "success":
|
||||||
|
return "task";
|
||||||
|
case "warning":
|
||||||
|
return "security";
|
||||||
|
case "error":
|
||||||
|
return "error";
|
||||||
|
default:
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNotificationIcon = (notification: Notification) => {
|
||||||
|
const tone = getNotificationTone(notification);
|
||||||
|
|
||||||
|
if (tone === "system") return RocketOutlined;
|
||||||
|
if (tone === "message") return MailOutlined;
|
||||||
|
if (tone === "security") return SafetyCertificateOutlined;
|
||||||
|
if (tone === "task") return CheckCircleOutlined;
|
||||||
|
if (tone === "error") return ExclamationCircleOutlined;
|
||||||
|
return BellOutlined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNotificationClick = (notification: Notification) => {
|
||||||
|
notificationStore.markAsRead(notification.id);
|
||||||
|
popoverOpen.value = false;
|
||||||
|
router.push({
|
||||||
|
path: "/notifications",
|
||||||
|
query: {
|
||||||
|
id: notification.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveNotification = (id: string) => {
|
||||||
|
notificationStore.removeNotification(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAllRead = () => {
|
||||||
|
notificationStore.markAllAsRead();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearAll = () => {
|
||||||
|
notificationStore.clearAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewAll = () => {
|
||||||
|
popoverOpen.value = false;
|
||||||
|
router.push("/notifications");
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.notification-panel {
|
||||||
|
width: min(392px, calc(100vw - 32px));
|
||||||
|
max-height: min(560px, calc(100vh - 120px));
|
||||||
|
background: var(--color-bg-container);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border-secondary);
|
||||||
|
background: linear-gradient(180deg, rgba(15, 23, 42, 0.03), transparent);
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unread-pill {
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
|
border: 1px solid
|
||||||
|
color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
max-height: 404px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(15, 23, 42, 0.2) transparent;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(100, 116, 139, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(100, 116, 139, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background var(--duration-base) var(--ease-out),
|
||||||
|
transform var(--duration-base) var(--ease-out);
|
||||||
|
|
||||||
|
& + .notification-item {
|
||||||
|
border-top: 1px solid var(--color-border-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.unread {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-icon {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 27px;
|
||||||
|
width: 27px;
|
||||||
|
height: 27px;
|
||||||
|
border-radius: 9px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-unread-dot {
|
||||||
|
position: absolute;
|
||||||
|
right: -2px;
|
||||||
|
top: -2px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ef4444;
|
||||||
|
box-shadow: 0 0 0 2px var(--color-bg-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.notification-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-title {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-message {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-time {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
line-height: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-detail-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: var(--color-primary);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(2px);
|
||||||
|
transition:
|
||||||
|
opacity var(--duration-base) var(--ease-out),
|
||||||
|
transform var(--duration-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-remove-btn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-top: -2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
opacity: 0.5;
|
||||||
|
transition:
|
||||||
|
opacity var(--duration-base) var(--ease-out),
|
||||||
|
background var(--duration-base) var(--ease-out);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .notification-remove-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .notification-title {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .notification-detail-hint {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone-system .notification-icon {
|
||||||
|
color: #1677ff;
|
||||||
|
background: rgba(22, 119, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone-message .notification-icon {
|
||||||
|
color: #22a06b;
|
||||||
|
background: rgba(34, 160, 107, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone-security .notification-icon {
|
||||||
|
color: #f97316;
|
||||||
|
background: rgba(249, 115, 22, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone-task .notification-icon {
|
||||||
|
color: #0f766e;
|
||||||
|
background: rgba(15, 118, 110, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone-error .notification-icon {
|
||||||
|
color: #dc2626;
|
||||||
|
background: rgba(220, 38, 38, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-empty {
|
||||||
|
padding: 44px 20px 48px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.empty-illustration {
|
||||||
|
position: relative;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: linear-gradient(
|
||||||
|
145deg,
|
||||||
|
rgba(22, 119, 255, 0.16),
|
||||||
|
rgba(22, 119, 255, 0.04)
|
||||||
|
);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-dot {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
top: 14px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ef4444;
|
||||||
|
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-subtitle {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-footer {
|
||||||
|
padding: 10px 14px 12px;
|
||||||
|
border-top: 1px solid var(--color-border-secondary);
|
||||||
|
background: linear-gradient(180deg, transparent, rgba(15, 23, 42, 0.02));
|
||||||
|
|
||||||
|
.view-all-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-primary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.notification-popover-overlay {
|
||||||
|
.ant-popover-inner {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border-secondary);
|
||||||
|
box-shadow:
|
||||||
|
0 28px 72px rgba(15, 23, 42, 0.18),
|
||||||
|
0 6px 18px rgba(15, 23, 42, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-popover-inner-content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark .notification-popover-overlay {
|
||||||
|
.ant-popover-inner {
|
||||||
|
box-shadow:
|
||||||
|
0 28px 72px rgba(0, 0, 0, 0.4),
|
||||||
|
0 6px 18px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
307
antdv-next-admin/src/components/Layout/SettingsDrawer.vue
Normal file
307
antdv-next-admin/src/components/Layout/SettingsDrawer.vue
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
<template>
|
||||||
|
<a-drawer
|
||||||
|
v-model:open="visible"
|
||||||
|
:title="$t('settings.title')"
|
||||||
|
placement="right"
|
||||||
|
:size="320"
|
||||||
|
>
|
||||||
|
<div class="settings-drawer">
|
||||||
|
<!-- Theme Color -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h4>{{ $t("settings.themeColor") }}</h4>
|
||||||
|
<div class="color-picker">
|
||||||
|
<div
|
||||||
|
v-for="color in PRESET_COLORS"
|
||||||
|
:key="color.value"
|
||||||
|
:class="[
|
||||||
|
'color-item',
|
||||||
|
{
|
||||||
|
active:
|
||||||
|
settingsStore.primaryColor === color.value &&
|
||||||
|
!settingsStore.customPrimaryColor,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:style="{ backgroundColor: color.hex }"
|
||||||
|
@click="settingsStore.setPrimaryColor(color.value)"
|
||||||
|
>
|
||||||
|
<CheckOutlined
|
||||||
|
v-if="
|
||||||
|
settingsStore.primaryColor === color.value &&
|
||||||
|
!settingsStore.customPrimaryColor
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<a-color-picker
|
||||||
|
v-model:value="customColor"
|
||||||
|
:presets="colorPresets"
|
||||||
|
@change="handleCustomColorChange"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'color-item',
|
||||||
|
'color-picker-trigger',
|
||||||
|
{ active: !!settingsStore.customPrimaryColor },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<CheckOutlined v-if="settingsStore.customPrimaryColor" />
|
||||||
|
<span v-else class="picker-icon">+</span>
|
||||||
|
</div>
|
||||||
|
</a-color-picker>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar Theme -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h4>{{ $t("settings.sidebarTheme") }}</h4>
|
||||||
|
<a-radio-group v-model:value="settingsStore.sidebarTheme">
|
||||||
|
<a-radio value="light">{{ $t("settings.light") }}</a-radio>
|
||||||
|
<a-radio value="dark">{{ $t("settings.dark") }}</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Layout Mode -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h4>{{ $t("settings.layoutMode") }}</h4>
|
||||||
|
<a-radio-group v-model:value="settingsStore.layoutMode">
|
||||||
|
<a-radio value="vertical">{{ $t("settings.vertical") }}</a-radio>
|
||||||
|
<a-radio value="horizontal">{{ $t("settings.horizontal") }}</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Page Animation -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h4>{{ $t("settings.pageAnimation") }}</h4>
|
||||||
|
<a-select
|
||||||
|
v-model:value="settingsStore.pageAnimation"
|
||||||
|
:options="pageAnimationOptions"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gray Mode -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h4>{{ $t("settings.grayMode") }}</h4>
|
||||||
|
<a-switch v-model:checked="settingsStore.grayMode" />
|
||||||
|
<div class="hint">{{ $t("settings.grayModeHint") }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Remember Tab State -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h4>{{ $t("settings.rememberTabState") }}</h4>
|
||||||
|
<a-switch v-model:checked="settingsStore.rememberTabState" />
|
||||||
|
<div class="hint">{{ $t("settings.rememberTabStateHint") }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI Chat Split Panel -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h4>{{ $t("settings.aiCollab") }}</h4>
|
||||||
|
<a-switch
|
||||||
|
:checked="layoutStore.aiEntryVisible"
|
||||||
|
:disabled="layoutStore.isMobile"
|
||||||
|
@change="handleAiEntryChange"
|
||||||
|
/>
|
||||||
|
<div class="hint">
|
||||||
|
{{
|
||||||
|
layoutStore.isMobile
|
||||||
|
? $t("settings.aiCollabHintMobile")
|
||||||
|
: $t("settings.aiCollabHint")
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="settings-actions">
|
||||||
|
<a-button block @click="handleReset">
|
||||||
|
{{ $t("settings.reset") }}
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PrimaryColor } from "@/types/layout";
|
||||||
|
|
||||||
|
import { CheckOutlined } from "@antdv-next/icons";
|
||||||
|
import { Modal } from "antdv-next";
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
|
||||||
|
import { $t } from "@/locales";
|
||||||
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const layoutStore = useLayoutStore();
|
||||||
|
const customColor = ref(settingsStore.customPrimaryColor || "#1890ff");
|
||||||
|
|
||||||
|
const PRESET_COLORS: Array<{ value: PrimaryColor; hex: string }> = [
|
||||||
|
{ value: "blue", hex: "#1890ff" },
|
||||||
|
{ value: "green", hex: "#52c41a" },
|
||||||
|
{ value: "purple", hex: "#722ed1" },
|
||||||
|
{ value: "red", hex: "#f5222d" },
|
||||||
|
{ value: "orange", hex: "#fa8c16" },
|
||||||
|
{ value: "cyan", hex: "#13c2c2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const colorPresets = [
|
||||||
|
{
|
||||||
|
label: "Preset Colors",
|
||||||
|
colors: PRESET_COLORS.map((c) => c.hex),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const pageAnimationOptions = computed(() => [
|
||||||
|
{ label: $t("settings.fade"), value: "fade" },
|
||||||
|
{ label: $t("settings.slideLeft"), value: "slide-left" },
|
||||||
|
{ label: $t("settings.slideRight"), value: "slide-right" },
|
||||||
|
{ label: $t("settings.slideUp"), value: "slide-up" },
|
||||||
|
{ label: $t("settings.slideDown"), value: "slide-down" },
|
||||||
|
{ label: $t("settings.zoom"), value: "zoom" },
|
||||||
|
{ label: $t("settings.zoomBig"), value: "zoom-big" },
|
||||||
|
{ label: $t("settings.none"), value: "none" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleCustomColorChange = (
|
||||||
|
value: string | { toHexString: () => string },
|
||||||
|
) => {
|
||||||
|
const hex = typeof value === "string" ? value : value.toHexString();
|
||||||
|
settingsStore.setCustomPrimaryColor(hex);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch store changes and save to localStorage
|
||||||
|
watch(
|
||||||
|
() => settingsStore.sidebarTheme,
|
||||||
|
(value) => {
|
||||||
|
settingsStore.setSidebarTheme(value);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => settingsStore.layoutMode,
|
||||||
|
(value) => {
|
||||||
|
settingsStore.setLayoutMode(value);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => settingsStore.pageAnimation,
|
||||||
|
(value) => {
|
||||||
|
settingsStore.setPageAnimation(value);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => settingsStore.grayMode,
|
||||||
|
(value) => {
|
||||||
|
settingsStore.setGrayMode(value);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => settingsStore.rememberTabState,
|
||||||
|
(value) => {
|
||||||
|
settingsStore.setRememberTabState(value);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: $t("settings.confirmReset"),
|
||||||
|
onOk: () => {
|
||||||
|
settingsStore.resetSettings();
|
||||||
|
layoutStore.setAiEntryVisible(true);
|
||||||
|
layoutStore.setAiCollabEnabled(false);
|
||||||
|
customColor.value = "#1890ff";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAiEntryChange = (checked: boolean) => {
|
||||||
|
layoutStore.setAiEntryVisible(checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
visible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
visible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ open, close });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.settings-drawer {
|
||||||
|
.settings-section {
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.color-item {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all var(--duration-slow) var(--ease-out);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: #fff;
|
||||||
|
box-shadow: 0 0 0 2px var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-trigger {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.picker-icon {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--ant-primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-actions {
|
||||||
|
margin-top: var(--spacing-xl);
|
||||||
|
padding-top: var(--spacing-lg);
|
||||||
|
border-top: 1px solid var(--color-border-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
381
antdv-next-admin/src/components/Layout/Sidebar.vue
Normal file
381
antdv-next-admin/src/components/Layout/Sidebar.vue
Normal file
@ -0,0 +1,381 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="showMobileMask" class="sidebar-mask" @click="closeMobileSidebar" />
|
||||||
|
<a-layout-sider
|
||||||
|
v-model:collapsed="layoutStore.collapsed"
|
||||||
|
:class="['admin-sidebar', `theme-${effectiveSidebarTheme}`, { mobile: layoutStore.isMobile }]"
|
||||||
|
:width="layoutStore.sidebarWidth"
|
||||||
|
:collapsed-width="siderCollapsedWidth"
|
||||||
|
:trigger="null"
|
||||||
|
collapsible
|
||||||
|
>
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="sidebar-logo">
|
||||||
|
<img :src="logoImg" alt="Logo" class="logo-img" />
|
||||||
|
<transition name="fade">
|
||||||
|
<span v-show="!layoutStore.collapsed" class="logo-title">
|
||||||
|
{{ $t('common.appName') }}
|
||||||
|
</span>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Menu -->
|
||||||
|
<a-menu
|
||||||
|
v-model:selectedKeys="selectedKeys"
|
||||||
|
v-model:openKeys="openKeys"
|
||||||
|
mode="inline"
|
||||||
|
:theme="effectiveSidebarTheme"
|
||||||
|
:items="antMenuItems"
|
||||||
|
class="sidebar-menu"
|
||||||
|
@click="handleMenuClick"
|
||||||
|
/>
|
||||||
|
</a-layout-sider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { SidebarTheme } from '@/types/layout';
|
||||||
|
import type { MenuItem as MenuItemType } from '@/types/router';
|
||||||
|
import type { MenuProps } from 'antdv-next';
|
||||||
|
|
||||||
|
import { ref, computed, watch, h, nextTick } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import logoImg from '@/assets/images/logo.png';
|
||||||
|
import { basicRoutes } from '@/router/routes';
|
||||||
|
import { routesToMenuTree } from '@/router/utils';
|
||||||
|
import { useLayoutStore } from '@/stores/layout';
|
||||||
|
import { usePermissionStore } from '@/stores/permission';
|
||||||
|
import { useSettingsStore } from '@/stores/settings';
|
||||||
|
import { resolveLocaleText } from '@/utils/i18n';
|
||||||
|
import { resolveIcon } from '@/utils/icon';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const layoutStore = useLayoutStore();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const permissionStore = usePermissionStore();
|
||||||
|
|
||||||
|
const selectedKeys = ref<string[]>([]);
|
||||||
|
const openKeys = ref<string[]>([]);
|
||||||
|
|
||||||
|
const fallbackMenuItems = computed(() => {
|
||||||
|
const basicChildren = basicRoutes.flatMap((r) => r.children || []);
|
||||||
|
return routesToMenuTree(basicChildren);
|
||||||
|
});
|
||||||
|
|
||||||
|
const menuItems = computed(() => {
|
||||||
|
if (permissionStore.menuTree.length > 0) {
|
||||||
|
return permissionStore.menuTree;
|
||||||
|
}
|
||||||
|
return fallbackMenuItems.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const effectiveSidebarTheme = computed<SidebarTheme>(() => {
|
||||||
|
// Allow user to override sidebar theme even in global dark mode
|
||||||
|
// If the user explicitly selects 'light' sidebar, respect that choice
|
||||||
|
return settingsStore.sidebarTheme;
|
||||||
|
});
|
||||||
|
|
||||||
|
const siderCollapsedWidth = computed(() => {
|
||||||
|
return layoutStore.isMobile ? 0 : layoutStore.collapsedWidth;
|
||||||
|
});
|
||||||
|
|
||||||
|
const showMobileMask = computed(() => {
|
||||||
|
return layoutStore.isMobile && !layoutStore.collapsed;
|
||||||
|
});
|
||||||
|
|
||||||
|
const antMenuItems = computed<MenuProps['items']>(() => {
|
||||||
|
const convert = (menus: MenuItemType[]): NonNullable<MenuProps['items']> => {
|
||||||
|
return menus.map((menu) => {
|
||||||
|
const iconComponent = resolveIcon(menu.icon);
|
||||||
|
const item = {
|
||||||
|
key: menu.path || menu.id,
|
||||||
|
label: resolveLocaleText(menu.label, menu.id),
|
||||||
|
icon: iconComponent ? h(iconComponent) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (menu.children && menu.children.length > 0) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
key: menu.id,
|
||||||
|
children: convert(menu.children),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return convert(menuItems.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
function findMenuOpenKeys(
|
||||||
|
menus: MenuItemType[],
|
||||||
|
targetPath: string,
|
||||||
|
parents: string[] = [],
|
||||||
|
): string[] {
|
||||||
|
for (const item of menus) {
|
||||||
|
const menuKey = item.children && item.children.length > 0 ? item.id : item.path || item.id;
|
||||||
|
const currentParents = [...parents, menuKey];
|
||||||
|
|
||||||
|
if ((item.path || item.id) === targetPath) {
|
||||||
|
return parents;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
const matched = findMenuOpenKeys(item.children, targetPath, currentParents);
|
||||||
|
if (matched.length > 0) {
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExternalLinkPath(path: string): boolean {
|
||||||
|
return path.startsWith('http://') || path.startsWith('https://');
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMenuByPath(menus: MenuItemType[], targetPath: string): MenuItemType | null {
|
||||||
|
for (const item of menus) {
|
||||||
|
if ((item.path || item.id) === targetPath) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
const found = findMenuByPath(item.children, targetPath);
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncMenuState = () => {
|
||||||
|
// Find the menu item for current route
|
||||||
|
const currentMenuItem = findMenuByPath(menuItems.value, route.path);
|
||||||
|
|
||||||
|
// Don't set selected state if current menu item is an external link
|
||||||
|
if (currentMenuItem && currentMenuItem.path && isExternalLinkPath(currentMenuItem.path)) {
|
||||||
|
selectedKeys.value = [];
|
||||||
|
} else {
|
||||||
|
selectedKeys.value = [route.path];
|
||||||
|
}
|
||||||
|
|
||||||
|
openKeys.value = findMenuOpenKeys(menuItems.value, route.path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeMobileSidebar = () => {
|
||||||
|
if (layoutStore.isMobile && !layoutStore.collapsed) {
|
||||||
|
layoutStore.setSidebarCollapsed(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuClick = ({ key }: { key: string | number }) => {
|
||||||
|
if (typeof key !== 'string') return;
|
||||||
|
|
||||||
|
// External links: open in a new tab
|
||||||
|
if (key.startsWith('http://') || key.startsWith('https://')) {
|
||||||
|
// Save current selected state before opening external link
|
||||||
|
const currentSelected = [...selectedKeys.value];
|
||||||
|
|
||||||
|
window.open(key, '_blank', 'noopener,noreferrer');
|
||||||
|
closeMobileSidebar();
|
||||||
|
|
||||||
|
// Restore selected state after Menu component updates
|
||||||
|
// This prevents the external link menu item from being shown as selected
|
||||||
|
nextTick(() => {
|
||||||
|
selectedKeys.value = currentSelected;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal routes
|
||||||
|
if (key.startsWith('/')) {
|
||||||
|
router.push(key);
|
||||||
|
closeMobileSidebar();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => route.path, menuItems],
|
||||||
|
() => {
|
||||||
|
syncMenuState();
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.sidebar-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.36);
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
z-index: 100;
|
||||||
|
transition: all var(--duration-slow) var(--ease-out);
|
||||||
|
|
||||||
|
&.mobile {
|
||||||
|
z-index: 110;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.theme-light {
|
||||||
|
background: var(--color-bg-container);
|
||||||
|
|
||||||
|
:deep(.ant-menu) {
|
||||||
|
background: var(--color-bg-container);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
border-bottom-color: var(--color-border-secondary);
|
||||||
|
|
||||||
|
.logo-title {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.theme-dark {
|
||||||
|
background: linear-gradient(180deg, #0b1326 0%, #111d38 58%, #132246 100%);
|
||||||
|
|
||||||
|
:deep(.ant-menu) {
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
.logo-title {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
height: 50px;
|
||||||
|
padding: 0 var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--color-border-secondary);
|
||||||
|
|
||||||
|
.logo-img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-title {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu {
|
||||||
|
border-right: none;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
|
||||||
|
:deep(.ant-menu-item),
|
||||||
|
:deep(.ant-menu-submenu-title) {
|
||||||
|
margin: 4px 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 40px;
|
||||||
|
line-height: 40px;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.theme-dark {
|
||||||
|
.sidebar-menu {
|
||||||
|
:deep(.ant-menu-item),
|
||||||
|
:deep(.ant-menu-submenu-title),
|
||||||
|
:deep(.ant-menu-submenu-arrow) {
|
||||||
|
color: rgba(255, 255, 255, 0.88) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-item .ant-menu-title-content),
|
||||||
|
:deep(.ant-menu-submenu-title .ant-menu-title-content),
|
||||||
|
:deep(.ant-menu-item .anticon),
|
||||||
|
:deep(.ant-menu-submenu-title .anticon) {
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-item:hover),
|
||||||
|
:deep(.ant-menu-submenu-title:hover) {
|
||||||
|
color: #fff !important;
|
||||||
|
background: rgba(255, 255, 255, 0.12) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-item-selected) {
|
||||||
|
color: #fff !important;
|
||||||
|
background: rgba(22, 119, 255, 0.35) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.theme-light {
|
||||||
|
.sidebar-menu {
|
||||||
|
:deep(.ant-menu-item),
|
||||||
|
:deep(.ant-menu-submenu-title) {
|
||||||
|
color: rgba(0, 0, 0, 0.85) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-item .ant-menu-title-content),
|
||||||
|
:deep(.ant-menu-submenu-title .ant-menu-title-content),
|
||||||
|
:deep(.ant-menu-item .anticon),
|
||||||
|
:deep(.ant-menu-submenu-title .anticon),
|
||||||
|
:deep(.ant-menu-submenu-arrow) {
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-item:hover),
|
||||||
|
:deep(.ant-menu-submenu-title:hover) {
|
||||||
|
color: rgba(0, 0, 0, 0.88) !important;
|
||||||
|
background: #f5f7fa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-item-selected) {
|
||||||
|
color: #1677ff !important;
|
||||||
|
background: #e6f4ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-submenu-selected > .ant-menu-submenu-title) {
|
||||||
|
color: #1677ff !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrollbar styles
|
||||||
|
.admin-sidebar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar.theme-dark::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
503
antdv-next-admin/src/components/Layout/TabBar.vue
Normal file
503
antdv-next-admin/src/components/Layout/TabBar.vue
Normal file
@ -0,0 +1,503 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tab-bar" v-if="tabsStore.tabs.length > 0">
|
||||||
|
<div class="tabs-container">
|
||||||
|
<a-tabs
|
||||||
|
v-model:activeKey="activeKey"
|
||||||
|
type="editable-card"
|
||||||
|
:hide-add="true"
|
||||||
|
:items="tabItems"
|
||||||
|
popupClassName="tab-bar-overflow-dropdown"
|
||||||
|
@edit="handleEdit"
|
||||||
|
@change="handleChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="tab-actions">
|
||||||
|
<a-dropdown placement="bottomRight" :menu="activeTabMenuProps" :trigger="['click']">
|
||||||
|
<a-tooltip :title="$t('layout.tabs.moreActions')">
|
||||||
|
<a-button type="text" class="tab-action-btn">
|
||||||
|
<DownOutlined />
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
</a-dropdown>
|
||||||
|
<a-tooltip :title="$t('layout.tabs.refresh')">
|
||||||
|
<a-button type="text" class="tab-action-btn" @click="refreshCurrentTab">
|
||||||
|
<ReloadOutlined />
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tooltip :title="isFullscreen ? $t('layout.exitFullscreen') : $t('layout.fullscreen')">
|
||||||
|
<a-button type="text" class="tab-action-btn" @click="toggleFullscreen">
|
||||||
|
<FullscreenExitOutlined v-if="isFullscreen" />
|
||||||
|
<FullscreenOutlined v-else />
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Tab } from '@/types/layout';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ReloadOutlined,
|
||||||
|
FullscreenOutlined,
|
||||||
|
FullscreenExitOutlined,
|
||||||
|
DownOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
PushpinOutlined,
|
||||||
|
PushpinFilled,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
CloseSquareOutlined,
|
||||||
|
VerticalLeftOutlined,
|
||||||
|
VerticalRightOutlined,
|
||||||
|
StarOutlined,
|
||||||
|
StarFilled,
|
||||||
|
} from '@antdv-next/icons';
|
||||||
|
import { Dropdown } from 'antdv-next';
|
||||||
|
import { computed, h } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { useLayoutStore } from '@/stores/layout';
|
||||||
|
import { useTabsStore } from '@/stores/tabs';
|
||||||
|
import { resolveLocaleText } from '@/utils/i18n';
|
||||||
|
import { resolveIcon } from '@/utils/icon';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const tabsStore = useTabsStore();
|
||||||
|
const layoutStore = useLayoutStore();
|
||||||
|
|
||||||
|
const isFullscreen = computed(() => layoutStore.pageFullscreen);
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
layoutStore.togglePageFullscreen();
|
||||||
|
};
|
||||||
|
|
||||||
|
type TabMenuKey =
|
||||||
|
| 'close'
|
||||||
|
| 'pin'
|
||||||
|
| 'favorite'
|
||||||
|
| 'refresh'
|
||||||
|
| 'closeLeft'
|
||||||
|
| 'closeRight'
|
||||||
|
| 'closeOthers'
|
||||||
|
| 'closeAll';
|
||||||
|
|
||||||
|
const activeKey = computed({
|
||||||
|
get: () => tabsStore.activeTabPath,
|
||||||
|
set: (value) => tabsStore.setActiveTab(value),
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentTab = computed(() => {
|
||||||
|
return tabsStore.activeTab || tabsStore.tabs[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabItems = computed(() => {
|
||||||
|
// Access locale to establish reactivity dependency on language changes
|
||||||
|
const currentLocale = locale.value;
|
||||||
|
// Keep locale dependency but don't use it as a key to avoid forced remounts
|
||||||
|
void currentLocale;
|
||||||
|
|
||||||
|
return tabsStore.tabs.map((tab) => ({
|
||||||
|
key: tab.path,
|
||||||
|
closable: tab.closable,
|
||||||
|
label: h('span', { class: 'tab-label-wrapper' }, [
|
||||||
|
h(
|
||||||
|
Dropdown,
|
||||||
|
{
|
||||||
|
trigger: ['contextmenu'],
|
||||||
|
menu: {
|
||||||
|
items: getTabMenuItems(tab),
|
||||||
|
onClick: ({ key }: { key: string | number }) =>
|
||||||
|
handleContextMenu({ key: String(key) }, tab),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: () => {
|
||||||
|
const icon = getTabIcon(tab);
|
||||||
|
return h('span', { class: 'tab-label' }, [
|
||||||
|
icon ? h(icon, { class: 'tab-menu-icon' }) : null,
|
||||||
|
h('span', { class: 'tab-text' }, getTabLabel(tab)),
|
||||||
|
tab.favorite ? h(StarFilled, { class: 'tab-favorite-icon' }) : null,
|
||||||
|
isTabFixed(tab) ? h(PushpinFilled, { class: 'tab-pin-icon' }) : null,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const isTabFixed = (tab: Tab) => Boolean(tab.affix || tab.pinned);
|
||||||
|
|
||||||
|
const handleEdit = (targetKey: string) => {
|
||||||
|
tabsStore.closeTab(targetKey);
|
||||||
|
syncRouteWithActiveTab();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (key: string) => {
|
||||||
|
const tab = tabsStore.tabs.find((item) => item.path === key);
|
||||||
|
if (tab) {
|
||||||
|
router.push(tab.fullPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasClosableLeftTabs = (tab: Tab) => {
|
||||||
|
const index = tabsStore.tabs.findIndex((item) => item.path === tab.path);
|
||||||
|
if (index <= 0) return false;
|
||||||
|
return tabsStore.tabs.slice(0, index).some((item) => item.closable);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasClosableRightTabs = (tab: Tab) => {
|
||||||
|
const index = tabsStore.tabs.findIndex((item) => item.path === tab.path);
|
||||||
|
if (index < 0 || index >= tabsStore.tabs.length - 1) return false;
|
||||||
|
return tabsStore.tabs.slice(index + 1).some((item) => item.closable);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasClosableOtherTabs = (tab: Tab) => {
|
||||||
|
return tabsStore.tabs.some((item) => item.path !== tab.path && item.closable);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasClosableTabs = computed(() => {
|
||||||
|
return tabsStore.tabs.some((tab) => tab.closable);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTabMenuItems = (tab: Tab) => {
|
||||||
|
// Get the latest tab state from store to ensure reactivity
|
||||||
|
const latestTab = tabsStore.tabs.find((item) => item.path === tab.path) || tab;
|
||||||
|
// Call t() function to get reactive translations
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'close',
|
||||||
|
icon: h(CloseOutlined),
|
||||||
|
label: t('layout.tabs.close'),
|
||||||
|
disabled: !latestTab.closable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pin',
|
||||||
|
icon: h(latestTab.pinned ? PushpinFilled : PushpinOutlined),
|
||||||
|
label: latestTab.pinned ? t('layout.tabs.unpin') : t('layout.tabs.pin'),
|
||||||
|
disabled: Boolean(latestTab.affix),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'favorite',
|
||||||
|
icon: h(latestTab.favorite ? StarFilled : StarOutlined),
|
||||||
|
label: latestTab.favorite ? t('layout.tabs.unfavorite') : t('layout.tabs.favorite'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'refresh',
|
||||||
|
icon: h(ReloadOutlined),
|
||||||
|
label: t('layout.tabs.refresh'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'closeLeft',
|
||||||
|
icon: h(VerticalLeftOutlined),
|
||||||
|
label: t('layout.tabs.closeLeft'),
|
||||||
|
disabled: !hasClosableLeftTabs(tab),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'closeRight',
|
||||||
|
icon: h(VerticalRightOutlined),
|
||||||
|
label: t('layout.tabs.closeRight'),
|
||||||
|
disabled: !hasClosableRightTabs(tab),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'closeOthers',
|
||||||
|
icon: h(CloseCircleOutlined),
|
||||||
|
label: t('layout.tabs.closeOthers'),
|
||||||
|
disabled: !hasClosableOtherTabs(tab),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'closeAll',
|
||||||
|
icon: h(CloseSquareOutlined),
|
||||||
|
label: t('layout.tabs.closeAll'),
|
||||||
|
disabled: !hasClosableTabs.value,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncRouteWithActiveTab = () => {
|
||||||
|
const active =
|
||||||
|
tabsStore.tabs.find((tab) => tab.path === tabsStore.activeTabPath) || tabsStore.tabs[0];
|
||||||
|
if (!active) {
|
||||||
|
if (route.path !== '/dashboard') {
|
||||||
|
router.push('/dashboard');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.path !== active.path || route.fullPath !== active.fullPath) {
|
||||||
|
router.push(active.fullPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContextMenu = (e: { key: string }, tab: Tab) => {
|
||||||
|
const { key } = e;
|
||||||
|
switch (key as TabMenuKey) {
|
||||||
|
case 'close':
|
||||||
|
tabsStore.closeTab(tab.path);
|
||||||
|
syncRouteWithActiveTab();
|
||||||
|
break;
|
||||||
|
case 'pin':
|
||||||
|
tabsStore.togglePinTab(tab.path);
|
||||||
|
break;
|
||||||
|
case 'favorite':
|
||||||
|
tabsStore.toggleFavoriteTab(tab.path);
|
||||||
|
break;
|
||||||
|
case 'refresh':
|
||||||
|
tabsStore.refreshTab(tab.path);
|
||||||
|
break;
|
||||||
|
case 'closeOthers':
|
||||||
|
tabsStore.closeOtherTabs(tab.path);
|
||||||
|
syncRouteWithActiveTab();
|
||||||
|
break;
|
||||||
|
case 'closeAll':
|
||||||
|
tabsStore.closeAllTabs();
|
||||||
|
syncRouteWithActiveTab();
|
||||||
|
break;
|
||||||
|
case 'closeLeft':
|
||||||
|
tabsStore.closeLeftTabs(tab.path);
|
||||||
|
syncRouteWithActiveTab();
|
||||||
|
break;
|
||||||
|
case 'closeRight':
|
||||||
|
tabsStore.closeRightTabs(tab.path);
|
||||||
|
syncRouteWithActiveTab();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeTabMenuProps = computed(() => {
|
||||||
|
// Access locale to establish reactivity dependency on language changes
|
||||||
|
const currentLocale = locale.value;
|
||||||
|
void currentLocale; // Ensure reactivity
|
||||||
|
|
||||||
|
const tab = currentTab.value;
|
||||||
|
return {
|
||||||
|
items: tab ? getTabMenuItems(tab) : [],
|
||||||
|
onClick: ({ key }: { key: string | number }) => {
|
||||||
|
if (tab) {
|
||||||
|
handleContextMenu({ key: String(key) }, tab);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshCurrentTab = () => {
|
||||||
|
const tab = currentTab.value;
|
||||||
|
if (!tab) return;
|
||||||
|
tabsStore.refreshTab(tab.path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTabLabel = (tab: Tab) => {
|
||||||
|
return resolveLocaleText(tab.title, tab.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTabIcon = (tab: Tab) => {
|
||||||
|
return resolveIcon(tab.icon);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 38px;
|
||||||
|
background: var(--color-bg-container);
|
||||||
|
border-bottom: 1px solid var(--color-border-secondary);
|
||||||
|
padding: 0 12px 0 16px;
|
||||||
|
|
||||||
|
.tabs-container {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
:deep(.ant-tabs) {
|
||||||
|
height: 37px;
|
||||||
|
|
||||||
|
.ant-tabs-nav {
|
||||||
|
margin: 0;
|
||||||
|
height: 37px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tabs-nav-wrap,
|
||||||
|
.ant-tabs-nav-list {
|
||||||
|
height: 100%;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tabs-content-holder {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tabs-tab .anticon:not(:last-child) {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tabs-tab {
|
||||||
|
border: 1px solid transparent !important;
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
margin-right: 6px;
|
||||||
|
padding: 0 10px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
.ant-tabs-tab-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-bg-container);
|
||||||
|
border-color: var(--color-border-secondary);
|
||||||
|
|
||||||
|
.ant-tabs-tab-btn {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ant-tabs-tab-active {
|
||||||
|
background: var(--color-primary-1);
|
||||||
|
border-color: var(--color-primary-2);
|
||||||
|
|
||||||
|
.ant-tabs-tab-btn {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tabs-tab-remove {
|
||||||
|
color: var(--color-primary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-primary-7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tabs-tab-remove {
|
||||||
|
margin-left: 6px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
transition: color 0.2s;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode specific overrides moved to global style block below to ensure priority
|
||||||
|
.tab-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
.tab-action-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use :deep() to ensure styles apply even when VNodes are cloned by a-tabs
|
||||||
|
:deep(.tab-label) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 180px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.tab-menu-icon) {
|
||||||
|
font-size: 14px;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.tab-pin-icon) {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.tab-favorite-icon) {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.tab-text) {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
// Global style overrides for dark mode
|
||||||
|
// Using non-scoped style to ensure we can target Ant Design internals reliably
|
||||||
|
.tab-bar-overflow-dropdown {
|
||||||
|
.tab-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 180px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-menu-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-pin-icon,
|
||||||
|
.tab-favorite-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-pin-icon,
|
||||||
|
.tab-favorite-icon {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .tab-bar .ant-tabs-tab.ant-tabs-tab-active {
|
||||||
|
background: rgba(22, 119, 255, 0.25) !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
|
||||||
|
.ant-tabs-tab-btn {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tabs-tab-remove {
|
||||||
|
color: rgba(255, 255, 255, 0.65) !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
108
antdv-next-admin/src/components/Layout/ThemeToggle.vue
Normal file
108
antdv-next-admin/src/components/Layout/ThemeToggle.vue
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<a-tooltip :title="tooltipTitle">
|
||||||
|
<a-button type="text" class="header-action" @click="handleThemeToggle">
|
||||||
|
<span class="theme-icon-wrapper" :class="{ rotating: isRotating }">
|
||||||
|
<MoonOutlined class="theme-icon theme-icon-dark" :class="{ active: themeStore.isDark }" />
|
||||||
|
<SunOutlined class="theme-icon theme-icon-light" :class="{ active: !themeStore.isDark }" />
|
||||||
|
</span>
|
||||||
|
</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { MoonOutlined, SunOutlined } from '@antdv-next/icons';
|
||||||
|
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||||
|
|
||||||
|
import { $t } from '@/locales';
|
||||||
|
import { useThemeStore } from '@/stores/theme';
|
||||||
|
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
const isRotating = ref(false);
|
||||||
|
let rotateTimer: number | null = null;
|
||||||
|
|
||||||
|
const tooltipTitle = computed(() => {
|
||||||
|
return themeStore.isDark ? `${$t('layout.theme')} (Light)` : `${$t('layout.theme')} (Dark)`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetRotateState = () => {
|
||||||
|
if (rotateTimer !== null) {
|
||||||
|
window.clearTimeout(rotateTimer);
|
||||||
|
rotateTimer = null;
|
||||||
|
}
|
||||||
|
isRotating.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleThemeToggle = (event: MouseEvent) => {
|
||||||
|
resetRotateState();
|
||||||
|
isRotating.value = true;
|
||||||
|
rotateTimer = window.setTimeout(() => {
|
||||||
|
isRotating.value = false;
|
||||||
|
rotateTimer = null;
|
||||||
|
}, 620);
|
||||||
|
|
||||||
|
themeStore.setTheme(themeStore.isDark ? 'light' : 'dark', {
|
||||||
|
origin: {
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onBeforeUnmount(resetRotateState);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.theme-icon-wrapper {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-icon-wrapper:hover {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-icon {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.85);
|
||||||
|
transition:
|
||||||
|
opacity 0.22s ease,
|
||||||
|
transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-icon.active {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-icon-wrapper.rotating .theme-icon {
|
||||||
|
animation: icon-rotate 0.62s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes icon-rotate {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg) scale(1);
|
||||||
|
}
|
||||||
|
55% {
|
||||||
|
transform: rotate(170deg) scale(1.05);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.theme-icon-wrapper.rotating .theme-icon {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-icon,
|
||||||
|
.theme-icon-wrapper {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
305
antdv-next-admin/src/components/Milkdown/index.vue
Normal file
305
antdv-next-admin/src/components/Milkdown/index.vue
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
<template>
|
||||||
|
<div class="milkdown-editor" :class="{ dark: isDark }">
|
||||||
|
<div ref="editorRef" class="milkdown-container" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Editor, rootCtx, defaultValueCtx, editorViewOptionsCtx } from '@milkdown/core';
|
||||||
|
import { clipboard } from '@milkdown/plugin-clipboard';
|
||||||
|
import { history } from '@milkdown/plugin-history';
|
||||||
|
import { listener, listenerCtx } from '@milkdown/plugin-listener';
|
||||||
|
import { prism } from '@milkdown/plugin-prism';
|
||||||
|
import { commonmark } from '@milkdown/preset-commonmark';
|
||||||
|
import { gfm } from '@milkdown/preset-gfm';
|
||||||
|
import { nord } from '@milkdown/theme-nord';
|
||||||
|
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
height?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: '',
|
||||||
|
placeholder: '',
|
||||||
|
readonly: false,
|
||||||
|
height: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string];
|
||||||
|
change: [value: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const editorRef = ref<HTMLElement>();
|
||||||
|
const editorInstance = ref<Editor>();
|
||||||
|
const isDark = ref(false);
|
||||||
|
|
||||||
|
// 检测暗色主题
|
||||||
|
const checkDarkTheme = () => {
|
||||||
|
isDark.value = document.documentElement.classList.contains('dark');
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!editorRef.value) return;
|
||||||
|
|
||||||
|
checkDarkTheme();
|
||||||
|
|
||||||
|
const editor = await Editor.make()
|
||||||
|
.config((ctx) => {
|
||||||
|
ctx.set(rootCtx, editorRef.value);
|
||||||
|
ctx.set(defaultValueCtx, props.modelValue);
|
||||||
|
|
||||||
|
// 配置编辑器视图
|
||||||
|
ctx.update(editorViewOptionsCtx, (prev) => ({
|
||||||
|
...prev,
|
||||||
|
editable: () => !props.readonly,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 监听内容变化
|
||||||
|
ctx.get(listenerCtx).markdownUpdated((_editorCtx, markdown, prevMarkdown) => {
|
||||||
|
if (markdown !== prevMarkdown) {
|
||||||
|
emit('update:modelValue', markdown);
|
||||||
|
emit('change', markdown);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const editorWithPlugins = (editor as any)
|
||||||
|
.use(nord)
|
||||||
|
.use(commonmark)
|
||||||
|
.use(gfm)
|
||||||
|
.use(history)
|
||||||
|
.use(clipboard)
|
||||||
|
.use(listener)
|
||||||
|
.use(prism);
|
||||||
|
|
||||||
|
const finalEditor = await editorWithPlugins.create();
|
||||||
|
|
||||||
|
editorInstance.value = finalEditor as Editor;
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
editorInstance.value?.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听外部值变化
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newValue) => {
|
||||||
|
if (
|
||||||
|
editorInstance.value &&
|
||||||
|
newValue !==
|
||||||
|
editorInstance.value.action((ctx) => {
|
||||||
|
return ctx.get(defaultValueCtx);
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
editorInstance.value.action((ctx) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const view = (ctx as any).editorView;
|
||||||
|
if (view) {
|
||||||
|
const state = view.state;
|
||||||
|
const tr = state.tr.replaceWith(0, state.doc.content.size, state.schema.text(newValue));
|
||||||
|
view.dispatch(tr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 监听只读状态
|
||||||
|
watch(
|
||||||
|
() => props.readonly,
|
||||||
|
(readonly) => {
|
||||||
|
editorInstance.value?.action((ctx) => {
|
||||||
|
ctx.update(editorViewOptionsCtx, (prev) => ({
|
||||||
|
...prev,
|
||||||
|
editable: () => !readonly,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.milkdown-editor {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-bg-container);
|
||||||
|
|
||||||
|
&.dark {
|
||||||
|
:deep(.milkdown) {
|
||||||
|
--nord0: #2e3440;
|
||||||
|
--nord1: #3b4252;
|
||||||
|
--nord2: #434c5e;
|
||||||
|
--nord3: #4c566a;
|
||||||
|
--nord4: #d8dee9;
|
||||||
|
--nord5: #e5e9f0;
|
||||||
|
--nord6: #eceff4;
|
||||||
|
--nord7: #8fbcbb;
|
||||||
|
--nord8: #88c0d0;
|
||||||
|
--nord9: #81a1c1;
|
||||||
|
--nord10: #5e81ac;
|
||||||
|
--nord11: #bf616a;
|
||||||
|
--nord12: #d08770;
|
||||||
|
--nord13: #ebcb8b;
|
||||||
|
--nord14: #a3be8c;
|
||||||
|
--nord15: #b48ead;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.milkdown-container {
|
||||||
|
:deep(.milkdown) {
|
||||||
|
padding: 16px;
|
||||||
|
min-height: v-bind(height + 'px');
|
||||||
|
|
||||||
|
// 编辑器样式覆盖
|
||||||
|
.editor {
|
||||||
|
min-height: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 段落
|
||||||
|
p {
|
||||||
|
margin: 0.75em 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
margin: 1em 0 0.5em;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 1.25em;
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
h5 {
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
h6 {
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
padding-left: 1.5em;
|
||||||
|
margin: 0.75em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin: 0.25em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 引用
|
||||||
|
blockquote {
|
||||||
|
border-left: 4px solid var(--color-primary);
|
||||||
|
padding-left: 1em;
|
||||||
|
margin: 1em 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 代码
|
||||||
|
code {
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 1em 0;
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 链接
|
||||||
|
a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1em 0;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分割线
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
margin: 1.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务列表
|
||||||
|
.task-list-item {
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
input {
|
||||||
|
margin-right: 0.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中样式
|
||||||
|
::selection {
|
||||||
|
background: var(--color-primary-deprecated-l-35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<slot v-if="hasPermission" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { usePermission } from '@/composables/usePermission';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
permission?: string | string[];
|
||||||
|
requireAll?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
requireAll: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { canAll, canAny } = usePermission();
|
||||||
|
|
||||||
|
const hasPermission = computed(() => {
|
||||||
|
if (!props.permission) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = Array.isArray(props.permission) ? props.permission : [props.permission];
|
||||||
|
|
||||||
|
if (props.requireAll) {
|
||||||
|
return canAll(permissions);
|
||||||
|
} else {
|
||||||
|
return canAny(permissions);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
189
antdv-next-admin/src/components/Pro/ProChart/index.vue
Normal file
189
antdv-next-admin/src/components/Pro/ProChart/index.vue
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
<template>
|
||||||
|
<div class="pro-chart">
|
||||||
|
<div v-if="title || subTitle || $slots.extra" class="pro-chart-header">
|
||||||
|
<div>
|
||||||
|
<h3 v-if="title" class="pro-chart-title">{{ title }}</h3>
|
||||||
|
<p v-if="subTitle" class="pro-chart-subtitle">{{ subTitle }}</p>
|
||||||
|
</div>
|
||||||
|
<slot name="extra" />
|
||||||
|
</div>
|
||||||
|
<div class="pro-chart-body" :style="{ height: normalizedHeight }">
|
||||||
|
<a-spin v-if="loading" class="pro-chart-spin" />
|
||||||
|
<v-chart
|
||||||
|
v-else
|
||||||
|
:option="mergedOption"
|
||||||
|
:theme="isDark ? 'dark' : undefined"
|
||||||
|
autoresize
|
||||||
|
class="pro-chart-canvas"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ProChartType } from "@/types/pro";
|
||||||
|
|
||||||
|
import { LineChart, BarChart, PieChart, RadarChart } from "echarts/charts";
|
||||||
|
import {
|
||||||
|
TitleComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
LegendComponent,
|
||||||
|
GridComponent,
|
||||||
|
RadarComponent,
|
||||||
|
} from "echarts/components";
|
||||||
|
import { use } from "echarts/core";
|
||||||
|
import { CanvasRenderer } from "echarts/renderers";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import VChart from "vue-echarts";
|
||||||
|
|
||||||
|
import { useThemeStore } from "@/stores/theme";
|
||||||
|
|
||||||
|
use([
|
||||||
|
CanvasRenderer,
|
||||||
|
LineChart,
|
||||||
|
BarChart,
|
||||||
|
PieChart,
|
||||||
|
RadarChart,
|
||||||
|
TitleComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
LegendComponent,
|
||||||
|
GridComponent,
|
||||||
|
RadarComponent,
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface ChartItem {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
type: ProChartType;
|
||||||
|
data: ChartItem[];
|
||||||
|
height?: number | string;
|
||||||
|
title?: string;
|
||||||
|
subTitle?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
option?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
height: 300,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
const isDark = computed(() => themeStore.isDark);
|
||||||
|
|
||||||
|
const normalizedHeight = computed(() => {
|
||||||
|
return typeof props.height === "number" ? `${props.height}px` : props.height;
|
||||||
|
});
|
||||||
|
|
||||||
|
const generatedOption = computed(() => {
|
||||||
|
const { type, data } = props;
|
||||||
|
|
||||||
|
if (type === "pie" || type === "donut") {
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: "item" },
|
||||||
|
legend: { bottom: 0 },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "pie",
|
||||||
|
radius: type === "donut" ? ["45%", "70%"] : "70%",
|
||||||
|
data: data.map((item) => ({ name: item.name, value: item.value })),
|
||||||
|
emphasis: {
|
||||||
|
itemStyle: {
|
||||||
|
shadowBlur: 10,
|
||||||
|
shadowOffsetX: 0,
|
||||||
|
shadowColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "radar") {
|
||||||
|
const indicator = data.map((item) => ({
|
||||||
|
name: item.name,
|
||||||
|
max: item.max ?? 100,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
tooltip: {},
|
||||||
|
radar: { indicator },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "radar",
|
||||||
|
data: [{ value: data.map((item) => item.value) }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// line, bar, area
|
||||||
|
const categories = data.map((item) => item.name);
|
||||||
|
const values = data.map((item) => item.value);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: "axis" },
|
||||||
|
grid: { left: "3%", right: "4%", bottom: "3%", containLabel: true },
|
||||||
|
xAxis: { type: "category", data: categories },
|
||||||
|
yAxis: { type: "value" },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: type === "area" ? "line" : type,
|
||||||
|
data: values,
|
||||||
|
smooth: type === "line" || type === "area",
|
||||||
|
areaStyle: type === "area" ? {} : undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedOption = computed(() => {
|
||||||
|
if (props.option) {
|
||||||
|
return { ...generatedOption.value, ...props.option };
|
||||||
|
}
|
||||||
|
return generatedOption.value;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.pro-chart {
|
||||||
|
.pro-chart-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.pro-chart-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-chart-subtitle {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-chart-body {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-chart-spin {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-chart-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
514
antdv-next-admin/src/components/Pro/ProCodeEditor/index.vue
Normal file
514
antdv-next-admin/src/components/Pro/ProCodeEditor/index.vue
Normal file
@ -0,0 +1,514 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="pro-code-editor"
|
||||||
|
:class="{
|
||||||
|
'is-disabled': disabled,
|
||||||
|
'is-readonly': readonly,
|
||||||
|
'is-dark': computedTheme === 'dark',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div v-if="$slots.toolbar || showDefaultToolbar" class="editor-toolbar">
|
||||||
|
<slot name="toolbar">
|
||||||
|
<a-space v-if="showDefaultToolbar" size="small">
|
||||||
|
<a-select
|
||||||
|
v-if="showLanguageSelect"
|
||||||
|
:value="language"
|
||||||
|
size="small"
|
||||||
|
style="width: 120px"
|
||||||
|
:options="languageOptions"
|
||||||
|
@change="handleLanguageChange"
|
||||||
|
/>
|
||||||
|
<a-button
|
||||||
|
v-if="language === 'json' && !readonly"
|
||||||
|
size="small"
|
||||||
|
@click="formatJson"
|
||||||
|
>
|
||||||
|
{{ $t("codeEditor.format") }}
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
v-if="language === 'json' && !readonly"
|
||||||
|
size="small"
|
||||||
|
@click="minifyJson"
|
||||||
|
>
|
||||||
|
{{ $t("codeEditor.minify") }}
|
||||||
|
</a-button>
|
||||||
|
<a-button size="small" @click="copyToClipboard">
|
||||||
|
{{ $t("codeEditor.copy") }}
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
<div class="editor-container" :style="containerStyle">
|
||||||
|
<codemirror
|
||||||
|
:model-value="modelValue"
|
||||||
|
:style="editorStyle"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
:extensions="extensions"
|
||||||
|
@update="handleUpdate"
|
||||||
|
@focus="handleFocus"
|
||||||
|
@blur="handleBlur"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Codemirror } from "vue-codemirror";
|
||||||
|
import type { Extension } from "@codemirror/state";
|
||||||
|
import {
|
||||||
|
keymap,
|
||||||
|
lineNumbers,
|
||||||
|
highlightActiveLineGutter,
|
||||||
|
highlightSpecialChars,
|
||||||
|
drawSelection,
|
||||||
|
dropCursor,
|
||||||
|
rectangularSelection,
|
||||||
|
crosshairCursor,
|
||||||
|
highlightActiveLine,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
import {
|
||||||
|
defaultKeymap,
|
||||||
|
history,
|
||||||
|
historyKeymap,
|
||||||
|
indentWithTab,
|
||||||
|
} from "@codemirror/commands";
|
||||||
|
import {
|
||||||
|
indentOnInput,
|
||||||
|
syntaxHighlighting,
|
||||||
|
defaultHighlightStyle,
|
||||||
|
bracketMatching,
|
||||||
|
foldGutter,
|
||||||
|
} from "@codemirror/language";
|
||||||
|
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
|
||||||
|
import { oneDark } from "@codemirror/theme-one-dark";
|
||||||
|
import { githubLight, githubDark } from "@uiw/codemirror-theme-github";
|
||||||
|
import { dracula } from "@uiw/codemirror-theme-dracula";
|
||||||
|
import { material, materialDark } from "@uiw/codemirror-theme-material";
|
||||||
|
import { monokai } from "@uiw/codemirror-theme-monokai";
|
||||||
|
import { nord } from "@uiw/codemirror-theme-nord";
|
||||||
|
import { tokyoNight } from "@uiw/codemirror-theme-tokyo-night";
|
||||||
|
import { solarizedLight, solarizedDark } from "@uiw/codemirror-theme-solarized";
|
||||||
|
import { json, jsonParseLinter } from "@codemirror/lang-json";
|
||||||
|
import { javascript } from "@codemirror/lang-javascript";
|
||||||
|
import { html } from "@codemirror/lang-html";
|
||||||
|
import { css } from "@codemirror/lang-css";
|
||||||
|
import { markdown } from "@codemirror/lang-markdown";
|
||||||
|
import { sql } from "@codemirror/lang-sql";
|
||||||
|
import { yaml } from "@codemirror/lang-yaml";
|
||||||
|
import { xml } from "@codemirror/lang-xml";
|
||||||
|
import { python } from "@codemirror/lang-python";
|
||||||
|
import { java } from "@codemirror/lang-java";
|
||||||
|
import { php } from "@codemirror/lang-php";
|
||||||
|
import { rust } from "@codemirror/lang-rust";
|
||||||
|
import { go } from "@codemirror/lang-go";
|
||||||
|
import { linter } from "@codemirror/lint";
|
||||||
|
|
||||||
|
import { message } from "antdv-next";
|
||||||
|
import { computed, ref, watch, type CSSProperties, type PropType } from "vue";
|
||||||
|
|
||||||
|
import { $t } from "@/locales";
|
||||||
|
import { useThemeStore } from "@/stores/theme";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "ProCodeEditor",
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SupportedLanguage =
|
||||||
|
| "json"
|
||||||
|
| "javascript"
|
||||||
|
| "typescript"
|
||||||
|
| "html"
|
||||||
|
| "css"
|
||||||
|
| "markdown"
|
||||||
|
| "sql"
|
||||||
|
| "yaml"
|
||||||
|
| "xml"
|
||||||
|
| "python"
|
||||||
|
| "java"
|
||||||
|
| "php"
|
||||||
|
| "rust"
|
||||||
|
| "go";
|
||||||
|
|
||||||
|
export type EditorTheme =
|
||||||
|
| "auto"
|
||||||
|
| "light"
|
||||||
|
| "dark"
|
||||||
|
| "github"
|
||||||
|
| "githubDark"
|
||||||
|
| "dracula"
|
||||||
|
| "material"
|
||||||
|
| "materialDark"
|
||||||
|
| "monokai"
|
||||||
|
| "nord"
|
||||||
|
| "tokyoNight"
|
||||||
|
| "solarized"
|
||||||
|
| "solarizedDark";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
language: {
|
||||||
|
type: String as PropType<SupportedLanguage>,
|
||||||
|
default: "json",
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
type: String as PropType<EditorTheme>,
|
||||||
|
default: "auto",
|
||||||
|
},
|
||||||
|
readonly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: [Number, String] as PropType<number | "auto">,
|
||||||
|
default: 300,
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
lineNumbers: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
foldGutter: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
showToolbar: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showLanguageSelect: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
formatOnBlur: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: string): void;
|
||||||
|
(e: "change", value: string): void;
|
||||||
|
(e: "focus"): void;
|
||||||
|
(e: "blur"): void;
|
||||||
|
(e: "languageChange", language: SupportedLanguage): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
const emitLanguage = ref<SupportedLanguage>(props.language);
|
||||||
|
|
||||||
|
const languageOptions: Array<{ label: string; value: SupportedLanguage }> = [
|
||||||
|
{ label: "JSON", value: "json" },
|
||||||
|
{ label: "JavaScript", value: "javascript" },
|
||||||
|
{ label: "TypeScript", value: "typescript" },
|
||||||
|
{ label: "HTML", value: "html" },
|
||||||
|
{ label: "CSS", value: "css" },
|
||||||
|
{ label: "Markdown", value: "markdown" },
|
||||||
|
{ label: "SQL", value: "sql" },
|
||||||
|
{ label: "YAML", value: "yaml" },
|
||||||
|
{ label: "XML", value: "xml" },
|
||||||
|
{ label: "Python", value: "python" },
|
||||||
|
{ label: "Java", value: "java" },
|
||||||
|
{ label: "PHP", value: "php" },
|
||||||
|
{ label: "Rust", value: "rust" },
|
||||||
|
{ label: "Go", value: "go" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const computedTheme = computed<"light" | "dark">(() => {
|
||||||
|
if (props.theme === "auto") {
|
||||||
|
return themeStore.isDark ? "dark" : "light";
|
||||||
|
}
|
||||||
|
const lightThemes = ["light", "github", "material", "solarized"];
|
||||||
|
return lightThemes.includes(props.theme) ? "light" : "dark";
|
||||||
|
});
|
||||||
|
|
||||||
|
const showDefaultToolbar = computed(() => {
|
||||||
|
return (
|
||||||
|
props.showToolbar || props.showLanguageSelect || props.language === "json"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerStyle = computed<CSSProperties>(() => {
|
||||||
|
if (props.height === "auto") {
|
||||||
|
return {
|
||||||
|
height: "auto",
|
||||||
|
minHeight: "100px",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
height:
|
||||||
|
typeof props.height === "number" ? `${props.height}px` : props.height,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const editorStyle = computed<CSSProperties>(() => ({
|
||||||
|
height: "100%",
|
||||||
|
fontSize: "13px",
|
||||||
|
}));
|
||||||
|
|
||||||
|
function getLanguageExtension(lang: SupportedLanguage) {
|
||||||
|
switch (lang) {
|
||||||
|
case "json":
|
||||||
|
return json();
|
||||||
|
case "javascript":
|
||||||
|
return javascript();
|
||||||
|
case "typescript":
|
||||||
|
return javascript({ typescript: true });
|
||||||
|
case "html":
|
||||||
|
return html();
|
||||||
|
case "css":
|
||||||
|
return css();
|
||||||
|
case "markdown":
|
||||||
|
return markdown();
|
||||||
|
case "sql":
|
||||||
|
return sql();
|
||||||
|
case "yaml":
|
||||||
|
return yaml();
|
||||||
|
case "xml":
|
||||||
|
return xml();
|
||||||
|
case "python":
|
||||||
|
return python();
|
||||||
|
case "java":
|
||||||
|
return java();
|
||||||
|
case "php":
|
||||||
|
return php();
|
||||||
|
case "rust":
|
||||||
|
return rust();
|
||||||
|
case "go":
|
||||||
|
return go();
|
||||||
|
default:
|
||||||
|
return json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThemeExtension(theme: EditorTheme): Extension[] {
|
||||||
|
const themeMap: Record<string, Extension | undefined> = {
|
||||||
|
github: githubLight,
|
||||||
|
githubDark,
|
||||||
|
dracula,
|
||||||
|
material,
|
||||||
|
materialDark,
|
||||||
|
monokai,
|
||||||
|
nord,
|
||||||
|
tokyoNight,
|
||||||
|
solarized: solarizedLight,
|
||||||
|
solarizedDark,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (theme === "light") return [];
|
||||||
|
if (theme === "dark") return [oneDark];
|
||||||
|
|
||||||
|
const ext = themeMap[theme];
|
||||||
|
return ext ? [ext] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseExtensions = computed<Extension[]>(() => {
|
||||||
|
const ext: Extension[] = [
|
||||||
|
highlightSpecialChars(),
|
||||||
|
history(),
|
||||||
|
drawSelection(),
|
||||||
|
dropCursor(),
|
||||||
|
rectangularSelection(),
|
||||||
|
crosshairCursor(),
|
||||||
|
highlightActiveLine(),
|
||||||
|
keymap.of([
|
||||||
|
...closeBracketsKeymap,
|
||||||
|
...defaultKeymap,
|
||||||
|
...historyKeymap,
|
||||||
|
indentWithTab,
|
||||||
|
]),
|
||||||
|
indentOnInput(),
|
||||||
|
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||||
|
bracketMatching(),
|
||||||
|
closeBrackets(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (props.lineNumbers) {
|
||||||
|
ext.push(lineNumbers());
|
||||||
|
ext.push(highlightActiveLineGutter());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.foldGutter) {
|
||||||
|
ext.push(foldGutter());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.theme === "auto") {
|
||||||
|
if (themeStore.isDark) ext.push(oneDark);
|
||||||
|
} else if (props.theme === "dark") {
|
||||||
|
ext.push(oneDark);
|
||||||
|
} else {
|
||||||
|
ext.push(...getThemeExtension(props.theme));
|
||||||
|
}
|
||||||
|
|
||||||
|
ext.push(getLanguageExtension(emitLanguage.value));
|
||||||
|
|
||||||
|
if (emitLanguage.value === "json") {
|
||||||
|
ext.push(linter(jsonParseLinter()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ext;
|
||||||
|
});
|
||||||
|
|
||||||
|
const extensions = computed(() => baseExtensions.value);
|
||||||
|
|
||||||
|
function handleUpdate(viewUpdate: {
|
||||||
|
state: { doc: { toString: () => string } };
|
||||||
|
}) {
|
||||||
|
if (props.readonly || props.disabled) return;
|
||||||
|
const value = viewUpdate.state.doc.toString();
|
||||||
|
emit("update:modelValue", value);
|
||||||
|
emit("change", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
emit("focus");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
if (props.formatOnBlur && emitLanguage.value === "json" && props.modelValue) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(props.modelValue);
|
||||||
|
const formatted = JSON.stringify(parsed, null, 2);
|
||||||
|
emit("update:modelValue", formatted);
|
||||||
|
emit("change", formatted);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("JSON format on blur failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit("blur");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLanguageChange(lang: SupportedLanguage) {
|
||||||
|
emitLanguage.value = lang;
|
||||||
|
emit("languageChange", lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJson() {
|
||||||
|
if (emitLanguage.value !== "json" || !props.modelValue) return;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(props.modelValue);
|
||||||
|
const formatted = JSON.stringify(parsed, null, 2);
|
||||||
|
emit("update:modelValue", formatted);
|
||||||
|
emit("change", formatted);
|
||||||
|
message.success($t("codeEditor.formatSuccess"));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("JSON format failed:", error);
|
||||||
|
message.error($t("codeEditor.jsonError"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function minifyJson() {
|
||||||
|
if (emitLanguage.value !== "json" || !props.modelValue) return;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(props.modelValue);
|
||||||
|
const minified = JSON.stringify(parsed);
|
||||||
|
emit("update:modelValue", minified);
|
||||||
|
emit("change", minified);
|
||||||
|
message.success($t("codeEditor.minifySuccess"));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("JSON minify failed:", error);
|
||||||
|
message.error($t("codeEditor.jsonError"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard() {
|
||||||
|
if (!props.modelValue) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(props.modelValue);
|
||||||
|
message.success($t("codeEditor.copySuccess"));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Copy to clipboard failed:", error);
|
||||||
|
message.error($t("codeEditor.copyFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.language,
|
||||||
|
(newLang) => {
|
||||||
|
emitLanguage.value = newLang;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.pro-code-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-bg-container);
|
||||||
|
|
||||||
|
&.is-dark {
|
||||||
|
background: #1e1e1e;
|
||||||
|
border-color: #3c3c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border-secondary);
|
||||||
|
background: var(--color-bg-layout);
|
||||||
|
|
||||||
|
.is-dark & {
|
||||||
|
background: #252526;
|
||||||
|
border-bottom-color: #3c3c3c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-container {
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
:deep(.cm-editor) {
|
||||||
|
height: 100%;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.cm-scroller) {
|
||||||
|
font-family: "Monaco", "Menlo", "Consolas", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.cm-content) {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.cm-line) {
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.cm-placeholder) {
|
||||||
|
color: var(--color-text-quaternary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-readonly {
|
||||||
|
.editor-container {
|
||||||
|
:deep(.cm-content) {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.cm-cursor) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
<template>
|
||||||
|
<a-descriptions
|
||||||
|
:column="column"
|
||||||
|
:bordered="bordered"
|
||||||
|
:size="size"
|
||||||
|
:title="title"
|
||||||
|
:layout="layout"
|
||||||
|
>
|
||||||
|
<a-descriptions-item
|
||||||
|
v-for="item in columns"
|
||||||
|
:key="item.dataIndex"
|
||||||
|
:label="item.label"
|
||||||
|
:span="item.span"
|
||||||
|
>
|
||||||
|
<template v-if="item.render">
|
||||||
|
<component :is="item.render(getValue(item), data)" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.valueType">
|
||||||
|
<ValueTypeRender
|
||||||
|
:value="getValue(item)"
|
||||||
|
:type="item.valueType"
|
||||||
|
:enum="item.valueEnum"
|
||||||
|
:record="data"
|
||||||
|
:copyable="item.copyable"
|
||||||
|
:value-type-props="item.valueTypeProps ?? {}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ getValue(item) ?? "-" }}
|
||||||
|
</template>
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ProDescriptionItem } from "@/types/pro";
|
||||||
|
|
||||||
|
import ValueTypeRender from "../ProTable/ValueTypeRender.vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
columns: ProDescriptionItem[];
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
column?: number;
|
||||||
|
bordered?: boolean;
|
||||||
|
size?: "default" | "middle" | "small";
|
||||||
|
title?: string;
|
||||||
|
layout?: "horizontal" | "vertical";
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
column: 2,
|
||||||
|
bordered: false,
|
||||||
|
size: "default",
|
||||||
|
layout: "horizontal",
|
||||||
|
});
|
||||||
|
|
||||||
|
const getValue = (item: ProDescriptionItem) => {
|
||||||
|
const keys = item.dataIndex.split(".");
|
||||||
|
let val: unknown = props.data;
|
||||||
|
for (const key of keys) {
|
||||||
|
if (val == null) return undefined;
|
||||||
|
val = (val as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
122
antdv-next-admin/src/components/Pro/ProDetail/index.vue
Normal file
122
antdv-next-admin/src/components/Pro/ProDetail/index.vue
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<div class="pro-detail">
|
||||||
|
<!-- Header -->
|
||||||
|
<div
|
||||||
|
v-if="title || subTitle || tags?.length || $slots.extra"
|
||||||
|
class="pro-detail-header"
|
||||||
|
>
|
||||||
|
<div class="pro-detail-header-main">
|
||||||
|
<h3 v-if="title" class="pro-detail-title">{{ title }}</h3>
|
||||||
|
<span v-if="subTitle" class="pro-detail-subtitle">{{ subTitle }}</span>
|
||||||
|
<a-tag v-for="tag in tags" :key="tag.text" :color="tag.color">{{
|
||||||
|
tag.text
|
||||||
|
}}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.extra" class="pro-detail-extra">
|
||||||
|
<slot name="extra" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Descriptions -->
|
||||||
|
<ProDescriptions
|
||||||
|
v-if="descriptions?.length && data"
|
||||||
|
:columns="descriptions"
|
||||||
|
:data="data"
|
||||||
|
:column="descriptionColumn"
|
||||||
|
bordered
|
||||||
|
size="small"
|
||||||
|
class="pro-detail-descriptions"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<a-tabs
|
||||||
|
v-if="tabs?.length"
|
||||||
|
v-model:active-key="currentTab"
|
||||||
|
size="small"
|
||||||
|
class="pro-detail-tabs"
|
||||||
|
>
|
||||||
|
<a-tab-pane v-for="tab in tabs" :key="tab.key" :tab="tab.label">
|
||||||
|
<slot :name="`tab-${tab.key}`" />
|
||||||
|
</a-tab-pane>
|
||||||
|
</a-tabs>
|
||||||
|
|
||||||
|
<!-- Default slot (when no tabs) -->
|
||||||
|
<div v-if="!tabs?.length && $slots.default" class="pro-detail-content">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ProDescriptionItem, ProDetailTab } from "@/types/pro";
|
||||||
|
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
|
||||||
|
import ProDescriptions from "../ProDescriptions/index.vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
subTitle?: string;
|
||||||
|
tags?: Array<{ text: string; color?: string }>;
|
||||||
|
descriptions?: ProDescriptionItem[];
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
descriptionColumn?: number;
|
||||||
|
tabs?: ProDetailTab[];
|
||||||
|
activeTab?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
descriptionColumn: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:activeTab"]);
|
||||||
|
|
||||||
|
const currentTab = ref(props.activeTab || props.tabs?.[0]?.key || "");
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.activeTab,
|
||||||
|
(val) => {
|
||||||
|
if (val) currentTab.value = val;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(currentTab, (val) => {
|
||||||
|
emit("update:activeTab", val);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.pro-detail {
|
||||||
|
.pro-detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border-secondary, #f0f0f0);
|
||||||
|
|
||||||
|
.pro-detail-header-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-detail-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-detail-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-detail-descriptions {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
255
antdv-next-admin/src/components/Pro/ProForm/FormItemRender.vue
Normal file
255
antdv-next-admin/src/components/Pro/ProForm/FormItemRender.vue
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
<template>
|
||||||
|
<div class="form-item-render">
|
||||||
|
<!-- Input -->
|
||||||
|
<a-input
|
||||||
|
v-if="item.type === 'input'"
|
||||||
|
v-model:value="modelValue"
|
||||||
|
:placeholder="resolveInputPlaceholder()"
|
||||||
|
v-bind="item.props"
|
||||||
|
@update:value="handleChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Password -->
|
||||||
|
<a-input-password
|
||||||
|
v-else-if="item.type === 'password'"
|
||||||
|
v-model:value="modelValue"
|
||||||
|
:placeholder="resolveInputPlaceholder()"
|
||||||
|
v-bind="item.props"
|
||||||
|
@update:value="handleChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Textarea -->
|
||||||
|
<a-textarea
|
||||||
|
v-else-if="item.type === 'textarea'"
|
||||||
|
v-model:value="modelValue"
|
||||||
|
:placeholder="resolveInputPlaceholder()"
|
||||||
|
:rows="4"
|
||||||
|
v-bind="item.props"
|
||||||
|
@update:value="handleChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Number -->
|
||||||
|
<a-input-number
|
||||||
|
v-else-if="item.type === 'number'"
|
||||||
|
v-model:value="modelValue"
|
||||||
|
:placeholder="resolveInputPlaceholder()"
|
||||||
|
style="width: 100%"
|
||||||
|
v-bind="item.props"
|
||||||
|
@update:value="handleChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Select -->
|
||||||
|
<a-select
|
||||||
|
v-else-if="item.type === 'select'"
|
||||||
|
v-model:value="modelValue"
|
||||||
|
:placeholder="resolveSelectPlaceholder()"
|
||||||
|
:options="resolvedOptions"
|
||||||
|
v-bind="item.props"
|
||||||
|
@update:value="handleChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Radio -->
|
||||||
|
<a-radio-group
|
||||||
|
v-else-if="item.type === 'radio'"
|
||||||
|
v-model:value="modelValue"
|
||||||
|
:options="resolvedOptions"
|
||||||
|
v-bind="item.props"
|
||||||
|
@update:value="handleChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Checkbox -->
|
||||||
|
<a-checkbox-group
|
||||||
|
v-else-if="item.type === 'checkbox'"
|
||||||
|
v-model:value="modelValue"
|
||||||
|
:options="resolvedOptions"
|
||||||
|
v-bind="item.props"
|
||||||
|
@update:value="handleChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Switch -->
|
||||||
|
<a-switch
|
||||||
|
v-else-if="item.type === 'switch'"
|
||||||
|
v-model:checked="modelValue"
|
||||||
|
v-bind="item.props"
|
||||||
|
@update:checked="handleChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Date Picker -->
|
||||||
|
<a-date-picker
|
||||||
|
v-else-if="item.type === 'datePicker'"
|
||||||
|
v-model:value="modelValue"
|
||||||
|
:placeholder="resolveSelectPlaceholder()"
|
||||||
|
style="width: 100%"
|
||||||
|
v-bind="item.props"
|
||||||
|
@update:value="handleChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Time Picker -->
|
||||||
|
<a-time-picker
|
||||||
|
v-else-if="item.type === 'timePicker'"
|
||||||
|
v-model:value="modelValue"
|
||||||
|
:placeholder="resolveSelectPlaceholder()"
|
||||||
|
style="width: 100%"
|
||||||
|
v-bind="item.props"
|
||||||
|
@update:value="handleChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Date Range -->
|
||||||
|
<a-range-picker
|
||||||
|
v-else-if="item.type === 'dateRange'"
|
||||||
|
v-model:value="modelValue"
|
||||||
|
style="width: 100%"
|
||||||
|
v-bind="item.props"
|
||||||
|
@update:value="handleChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Upload -->
|
||||||
|
<ProUpload
|
||||||
|
v-else-if="item.type === 'upload'"
|
||||||
|
:value="modelValue"
|
||||||
|
mode="button"
|
||||||
|
:button-text="item.placeholder"
|
||||||
|
v-bind="item.props"
|
||||||
|
@update:value="handleChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Image Upload -->
|
||||||
|
<ProUpload
|
||||||
|
v-else-if="item.type === 'imageUpload'"
|
||||||
|
:value="modelValue"
|
||||||
|
mode="image"
|
||||||
|
:button-text="item.placeholder"
|
||||||
|
v-bind="item.props"
|
||||||
|
@update:value="handleChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Avatar Upload -->
|
||||||
|
<ProUpload
|
||||||
|
v-else-if="item.type === 'avatarUpload'"
|
||||||
|
:value="modelValue"
|
||||||
|
mode="avatar"
|
||||||
|
v-bind="item.props"
|
||||||
|
@update:value="handleChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Slider -->
|
||||||
|
<a-slider
|
||||||
|
v-else-if="item.type === 'slider'"
|
||||||
|
v-model:value="modelValue"
|
||||||
|
v-bind="item.props"
|
||||||
|
@update:value="handleChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Rate -->
|
||||||
|
<a-rate
|
||||||
|
v-else-if="item.type === 'rate'"
|
||||||
|
v-model:value="modelValue"
|
||||||
|
v-bind="item.props"
|
||||||
|
@update:value="handleChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Cascader -->
|
||||||
|
<a-cascader
|
||||||
|
v-else-if="item.type === 'cascader'"
|
||||||
|
v-model:value="modelValue"
|
||||||
|
:placeholder="resolveSelectPlaceholder()"
|
||||||
|
:options="resolvedOptions"
|
||||||
|
style="width: 100%"
|
||||||
|
v-bind="item.props"
|
||||||
|
@update:value="handleChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Tree Select -->
|
||||||
|
<a-tree-select
|
||||||
|
v-else-if="item.type === 'treeSelect'"
|
||||||
|
v-model:value="modelValue"
|
||||||
|
:placeholder="resolveSelectPlaceholder()"
|
||||||
|
:tree-data="resolvedOptions"
|
||||||
|
style="width: 100%"
|
||||||
|
v-bind="item.props"
|
||||||
|
@update:value="handleChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Custom -->
|
||||||
|
<component
|
||||||
|
v-else-if="item.type === 'custom' && item.render"
|
||||||
|
:is="item.render"
|
||||||
|
v-model:value="modelValue"
|
||||||
|
@update:value="handleChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Default -->
|
||||||
|
<a-input
|
||||||
|
v-else
|
||||||
|
v-model:value="modelValue"
|
||||||
|
:placeholder="resolveInputPlaceholder()"
|
||||||
|
v-bind="item.props"
|
||||||
|
@update:value="handleChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ProFormItem } from "@/types/pro";
|
||||||
|
|
||||||
|
import { ref, watch, computed } from "vue";
|
||||||
|
|
||||||
|
import { $t } from "@/locales";
|
||||||
|
|
||||||
|
import ProUpload from "../ProUpload/index.vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: unknown;
|
||||||
|
item: ProFormItem;
|
||||||
|
formData?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
formData: () => ({}),
|
||||||
|
});
|
||||||
|
const emit = defineEmits(["update:value", "change"]);
|
||||||
|
|
||||||
|
const modelValue = ref(props.value ?? props.item.initialValue);
|
||||||
|
|
||||||
|
const resolvedOptions = computed(() => {
|
||||||
|
if (typeof props.item.options === "function") {
|
||||||
|
return props.item.options(props.formData);
|
||||||
|
}
|
||||||
|
return props.item.options;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.value,
|
||||||
|
(val) => {
|
||||||
|
modelValue.value = val;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(modelValue, (val) => {
|
||||||
|
emit("update:value", val);
|
||||||
|
emit("change", val);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = (value: unknown) => {
|
||||||
|
emit("update:value", value);
|
||||||
|
emit("change", value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveLabel = () => {
|
||||||
|
return String(props.item.label ?? "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveInputPlaceholder = () => {
|
||||||
|
return (
|
||||||
|
props.item.placeholder ||
|
||||||
|
$t("proForm.enterPlaceholder", { label: resolveLabel() })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveSelectPlaceholder = () => {
|
||||||
|
return (
|
||||||
|
props.item.placeholder ||
|
||||||
|
$t("proForm.selectPlaceholder", { label: resolveLabel() })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
192
antdv-next-admin/src/components/Pro/ProForm/index.vue
Normal file
192
antdv-next-admin/src/components/Pro/ProForm/index.vue
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
<template>
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formData"
|
||||||
|
:rules="formRules"
|
||||||
|
v-bind="layout"
|
||||||
|
@finish="handleFinish"
|
||||||
|
class="pro-form"
|
||||||
|
>
|
||||||
|
<a-row :gutter="grid?.gutter || 16">
|
||||||
|
<a-col
|
||||||
|
v-for="item in visibleFormItems"
|
||||||
|
:key="item.name"
|
||||||
|
:span="getColSpan(item)"
|
||||||
|
>
|
||||||
|
<a-form-item
|
||||||
|
:name="item.name"
|
||||||
|
:label="item.label"
|
||||||
|
:tooltip="item.tooltip"
|
||||||
|
:dependencies="item.dependencies"
|
||||||
|
:value-prop-name="item.valuePropName || 'value'"
|
||||||
|
:class="{ 'form-item-required': item.required }"
|
||||||
|
>
|
||||||
|
<FormItemRender
|
||||||
|
v-model:value="formData[item.name]"
|
||||||
|
:item="item"
|
||||||
|
:form-data="formData"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-form-item
|
||||||
|
v-if="$slots.footer"
|
||||||
|
:wrapper-col="{ offset: layout.labelCol?.span || 0 }"
|
||||||
|
class="form-footer"
|
||||||
|
>
|
||||||
|
<slot name="footer"></slot>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ProFormItem, ProFormLayout, ProFormGrid } from "@/types/pro";
|
||||||
|
|
||||||
|
import { ref, computed, watch } from "vue";
|
||||||
|
|
||||||
|
import { $t } from "@/locales";
|
||||||
|
|
||||||
|
import FormItemRender from "./FormItemRender.vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
formItems: ProFormItem[];
|
||||||
|
initialValues?: Record<string, unknown>;
|
||||||
|
layout?: ProFormLayout;
|
||||||
|
grid?: ProFormGrid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
layout: () => ({
|
||||||
|
labelCol: { span: 6 },
|
||||||
|
wrapperCol: { span: 18 },
|
||||||
|
layout: "horizontal",
|
||||||
|
}),
|
||||||
|
grid: () => ({
|
||||||
|
gutter: 16,
|
||||||
|
cols: 1,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["submit", "valuesChange", "finish"]);
|
||||||
|
|
||||||
|
const formRef = ref();
|
||||||
|
const formData = ref<Record<string, unknown>>({});
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const visibleFormItems = computed(() => {
|
||||||
|
return props.formItems.filter((item) => {
|
||||||
|
if (typeof item.hidden === "function") return !item.hidden(formData.value);
|
||||||
|
return !item.hidden;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const formRules = computed(() => {
|
||||||
|
const rules: Record<string, unknown[]> = {};
|
||||||
|
props.formItems.forEach((item) => {
|
||||||
|
const itemRules = [];
|
||||||
|
|
||||||
|
// 如果字段标记为 required,自动添加 required 规则
|
||||||
|
if (item.required) {
|
||||||
|
itemRules.push({
|
||||||
|
required: true,
|
||||||
|
message: $t("proForm.enterPlaceholder", {
|
||||||
|
label: String(item.label ?? ""),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加自定义规则
|
||||||
|
if (item.rules) {
|
||||||
|
itemRules.push(
|
||||||
|
...(Array.isArray(item.rules) ? item.rules : [item.rules]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemRules.length > 0) {
|
||||||
|
rules[item.name] = itemRules;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return rules;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const getColSpan = (item: ProFormItem) => {
|
||||||
|
if (item.colSpan) {
|
||||||
|
return (24 / (props.grid?.cols || 1)) * item.colSpan;
|
||||||
|
}
|
||||||
|
return 24 / (props.grid?.cols || 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinish = (values: Record<string, unknown>) => {
|
||||||
|
emit("finish", values);
|
||||||
|
emit("submit", values);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch initial values
|
||||||
|
watch(
|
||||||
|
() => props.initialValues,
|
||||||
|
(values) => {
|
||||||
|
if (values) {
|
||||||
|
formData.value = { ...values };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Expose methods
|
||||||
|
const validate = async () => {
|
||||||
|
return formRef.value?.validate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFields = () => {
|
||||||
|
formRef.value?.resetFields();
|
||||||
|
formData.value = props.initialValues ? { ...props.initialValues } : {};
|
||||||
|
};
|
||||||
|
|
||||||
|
const setFieldsValue = (values: Record<string, unknown>) => {
|
||||||
|
formData.value = { ...formData.value, ...values };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFieldsValue = () => {
|
||||||
|
return formData.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
validate,
|
||||||
|
resetFields,
|
||||||
|
setFieldsValue,
|
||||||
|
getFieldsValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch form data changes
|
||||||
|
watch(
|
||||||
|
formData,
|
||||||
|
(values) => {
|
||||||
|
emit("valuesChange", values);
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.pro-form {
|
||||||
|
.form-item-required {
|
||||||
|
:deep(.ant-form-item-label > label::before) {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 4px;
|
||||||
|
color: var(--color-error);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: SimSun, sans-serif;
|
||||||
|
line-height: 1;
|
||||||
|
content: "*";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-footer {
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
padding-top: var(--spacing-lg);
|
||||||
|
border-top: 1px solid var(--color-border-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
990
antdv-next-admin/src/components/Pro/ProModal/index.vue
Normal file
990
antdv-next-admin/src/components/Pro/ProModal/index.vue
Normal file
@ -0,0 +1,990 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
v-bind="mergedModalBindings"
|
||||||
|
:open="isOpen"
|
||||||
|
:styles="mergedSemanticStyles"
|
||||||
|
:wrap-class-name="mergedWrapClassName"
|
||||||
|
:style="mergedModalStyle"
|
||||||
|
:title="null"
|
||||||
|
@update:open="handleUpdateOpen"
|
||||||
|
@ok="handleOk"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<div
|
||||||
|
class="pro-modal-titlebar"
|
||||||
|
:class="{ draggable: draggable && !isFullscreen }"
|
||||||
|
@mousedown="handleTitleMouseDown"
|
||||||
|
>
|
||||||
|
<div class="pro-modal-title-content">
|
||||||
|
<component :is="titleRenderComponent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="fullscreenable || showCloseButton"
|
||||||
|
class="pro-modal-title-actions"
|
||||||
|
@mousedown.stop
|
||||||
|
>
|
||||||
|
<a-tooltip
|
||||||
|
v-if="fullscreenable"
|
||||||
|
:title="
|
||||||
|
isFullscreen
|
||||||
|
? $t('layout.exitFullscreen')
|
||||||
|
: $t('layout.fullscreen')
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pro-modal-action-btn"
|
||||||
|
@click.stop="toggleFullscreen"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="isFullscreen ? FullscreenExitOutlined : FullscreenOutlined"
|
||||||
|
:style="{ fontSize: '14px' }"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</a-tooltip>
|
||||||
|
|
||||||
|
<a-tooltip v-if="showCloseButton" :title="$t('common.close')">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pro-modal-action-btn"
|
||||||
|
:disabled="isCloseButtonDisabled"
|
||||||
|
@click.stop="handleCloseClick"
|
||||||
|
>
|
||||||
|
<CloseOutlined :style="{ fontSize: '14px' }" />
|
||||||
|
</button>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="$slots.okText" #okText>
|
||||||
|
<slot name="okText" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="$slots.cancelText" #cancelText>
|
||||||
|
<slot name="cancelText" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="$slots.closeIcon" #closeIcon>
|
||||||
|
<slot name="closeIcon" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="$slots.modalRender" #modalRender="modalRenderScope">
|
||||||
|
<slot name="modalRender" v-bind="modalRenderScope" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="$slots.footer" #footer="footerScope">
|
||||||
|
<slot name="footer" v-bind="footerScope" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<slot />
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ModalProps } from "antdv-next";
|
||||||
|
import type { CSSProperties, VNodeChild } from "vue";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CloseOutlined,
|
||||||
|
FullscreenOutlined,
|
||||||
|
FullscreenExitOutlined,
|
||||||
|
} from "@antdv-next/icons";
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
defineComponent,
|
||||||
|
h,
|
||||||
|
isVNode,
|
||||||
|
nextTick,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
reactive,
|
||||||
|
ref,
|
||||||
|
useAttrs,
|
||||||
|
useSlots,
|
||||||
|
watch,
|
||||||
|
} from "vue";
|
||||||
|
|
||||||
|
import { $t } from "@/locales";
|
||||||
|
|
||||||
|
interface ProModalProps extends ModalProps {
|
||||||
|
draggable?: boolean;
|
||||||
|
resizable?: boolean;
|
||||||
|
fullscreenable?: boolean;
|
||||||
|
minWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModalRect = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResizeDirection = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
|
||||||
|
|
||||||
|
type DragState = {
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
startRect: ModalRect;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResizeState = {
|
||||||
|
direction: ResizeDirection;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
startRect: ModalRect;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EDGE_SIZE = 8;
|
||||||
|
const DEFAULT_WIDTH = 520;
|
||||||
|
const DEFAULT_HEIGHT = 320;
|
||||||
|
const FULLSCREEN_TRANSITION_DURATION = 300;
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<ProModalProps>(), {
|
||||||
|
draggable: true,
|
||||||
|
resizable: true,
|
||||||
|
fullscreenable: true,
|
||||||
|
minWidth: 360,
|
||||||
|
minHeight: 260,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "ok", event: MouseEvent): void;
|
||||||
|
(e: "cancel", event: MouseEvent): void;
|
||||||
|
(e: "update:open", open: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const attrs = useAttrs();
|
||||||
|
const slots = useSlots();
|
||||||
|
|
||||||
|
const instanceWrapClassName = `pro-modal-wrap-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
|
||||||
|
const viewport = reactive({
|
||||||
|
width: typeof window !== "undefined" ? window.innerWidth : 0,
|
||||||
|
height: typeof window !== "undefined" ? window.innerHeight : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rect = reactive<ModalRect>({
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: DEFAULT_WIDTH,
|
||||||
|
height: DEFAULT_HEIGHT,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rectReady = ref(false);
|
||||||
|
const isFullscreen = ref(false);
|
||||||
|
const isAnimating = ref(false);
|
||||||
|
// Track if the modal has been moved/resized by user
|
||||||
|
const isMoved = ref(false);
|
||||||
|
const restoreRect = ref<ModalRect | null>(null);
|
||||||
|
const dragState = ref<DragState | null>(null);
|
||||||
|
const resizeState = ref<ResizeState | null>(null);
|
||||||
|
|
||||||
|
let boundModalElement: HTMLElement | null = null;
|
||||||
|
let isDocumentListening = false;
|
||||||
|
let bodyUserSelectCache = "";
|
||||||
|
let fullscreenAnimationTimer: number | undefined;
|
||||||
|
|
||||||
|
const isOpen = computed(() => Boolean(props.open));
|
||||||
|
const showCloseButton = computed(() => props.closable !== false);
|
||||||
|
const isCloseButtonDisabled = computed(() => {
|
||||||
|
return typeof props.closable === "object" && Boolean(props.closable.disabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const resolvedGetContainer = computed<any>(() => {
|
||||||
|
if (props.getContainer !== undefined) {
|
||||||
|
return props.getContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return document.body;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const titleRenderComponent = defineComponent({
|
||||||
|
name: "ProModalTitleRender",
|
||||||
|
setup() {
|
||||||
|
return () => {
|
||||||
|
const slotNodes = slots.title?.();
|
||||||
|
if (slotNodes && slotNodes.length > 0) {
|
||||||
|
return slotNodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = props.title;
|
||||||
|
if (isVNode(title)) {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof title === "function") {
|
||||||
|
return (title as () => VNodeChild)();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title === null || title === undefined || title === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return h("span", String(title));
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const modalPassThroughProps = computed<ModalProps>(() => {
|
||||||
|
const next = { ...props } as Record<string, unknown>;
|
||||||
|
delete next.draggable;
|
||||||
|
delete next.resizable;
|
||||||
|
delete next.fullscreenable;
|
||||||
|
delete next.minWidth;
|
||||||
|
delete next.minHeight;
|
||||||
|
delete next.open;
|
||||||
|
delete next.wrapClassName;
|
||||||
|
delete next.title;
|
||||||
|
delete next.width;
|
||||||
|
delete next.styles;
|
||||||
|
delete next.closable;
|
||||||
|
delete next.getContainer;
|
||||||
|
return next as ModalProps;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedWrapClassName = computed(() => {
|
||||||
|
return [
|
||||||
|
props.wrapClassName,
|
||||||
|
"pro-modal-wrap",
|
||||||
|
instanceWrapClassName,
|
||||||
|
isFullscreen.value ? "pro-modal-fullscreen" : "",
|
||||||
|
isAnimating.value ? "pro-modal-animating" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
});
|
||||||
|
|
||||||
|
const forwardedAttrs = computed(() => {
|
||||||
|
const next = { ...attrs } as Record<string, unknown>;
|
||||||
|
delete next.style;
|
||||||
|
delete next.wrapClassName;
|
||||||
|
delete next["wrap-class-name"];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const managedModalStyle = computed<CSSProperties>(() => {
|
||||||
|
if (!isOpen.value || !isMoved.value || !rectReady.value) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
position: "fixed",
|
||||||
|
margin: "0",
|
||||||
|
maxWidth: `${viewport.width}px`,
|
||||||
|
paddingBottom: "0",
|
||||||
|
top: `${rect.top}px`,
|
||||||
|
left: `${rect.left}px`,
|
||||||
|
height: `${rect.height}px`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedModalStyle = computed(() => {
|
||||||
|
return [attrs.style as Record<string, unknown>, managedModalStyle.value];
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedModalBindings = computed(() => {
|
||||||
|
const controlledWidth =
|
||||||
|
isMoved.value && rectReady.value ? rect.width : props.width;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...modalPassThroughProps.value,
|
||||||
|
...forwardedAttrs.value,
|
||||||
|
width: controlledWidth,
|
||||||
|
closable: false,
|
||||||
|
getContainer: resolvedGetContainer.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedSemanticStyles = computed(() => {
|
||||||
|
const inputStyles = (props.styles || {}) as Record<string, unknown>;
|
||||||
|
const containerStyle =
|
||||||
|
(inputStyles.container as Record<string, unknown>) || {};
|
||||||
|
const bodyStyle = (inputStyles.body as Record<string, unknown>) || {};
|
||||||
|
return {
|
||||||
|
...inputStyles,
|
||||||
|
container: {
|
||||||
|
...containerStyle,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
height: "100%",
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
...bodyStyle,
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: "auto",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateViewport = () => {
|
||||||
|
viewport.width = window.innerWidth;
|
||||||
|
viewport.height = window.innerHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getModalElement = () => {
|
||||||
|
return document.querySelector(
|
||||||
|
`.${instanceWrapClassName} .ant-modal`,
|
||||||
|
) as HTMLElement | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFullscreenAnimationTimer = () => {
|
||||||
|
if (fullscreenAnimationTimer === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.clearTimeout(fullscreenAnimationTimer);
|
||||||
|
fullscreenAnimationTimer = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFullscreenTransitionDuration = () => {
|
||||||
|
if (
|
||||||
|
typeof window === "undefined" ||
|
||||||
|
typeof window.matchMedia !== "function"
|
||||||
|
) {
|
||||||
|
return FULLSCREEN_TRANSITION_DURATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
|
? 0
|
||||||
|
: FULLSCREEN_TRANSITION_DURATION;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleFullscreenAnimationEnd = (callback: () => void) => {
|
||||||
|
clearFullscreenAnimationTimer();
|
||||||
|
fullscreenAnimationTimer = window.setTimeout(() => {
|
||||||
|
callback();
|
||||||
|
isAnimating.value = false;
|
||||||
|
fullscreenAnimationTimer = undefined;
|
||||||
|
}, getFullscreenTransitionDuration());
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFullscreenRect = (): ModalRect => ({
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: viewport.width,
|
||||||
|
height: viewport.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cloneRect = (target: ModalRect): ModalRect => ({
|
||||||
|
left: target.left,
|
||||||
|
top: target.top,
|
||||||
|
width: target.width,
|
||||||
|
height: target.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number) => {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEffectiveMinWidth = () => {
|
||||||
|
return Math.max(220, Math.min(props.minWidth, viewport.width));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEffectiveMinHeight = () => {
|
||||||
|
return Math.max(180, Math.min(props.minHeight, viewport.height));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clampRect = (target: ModalRect): ModalRect => {
|
||||||
|
const minWidth = getEffectiveMinWidth();
|
||||||
|
const minHeight = getEffectiveMinHeight();
|
||||||
|
|
||||||
|
const width = clamp(target.width, minWidth, viewport.width);
|
||||||
|
const height = clamp(target.height, minHeight, viewport.height);
|
||||||
|
const left = clamp(target.left, 0, Math.max(0, viewport.width - width));
|
||||||
|
const top = clamp(target.top, 0, Math.max(0, viewport.height - height));
|
||||||
|
|
||||||
|
return {
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsePreferredWidth = (width: ModalProps["width"], fallback: number) => {
|
||||||
|
if (typeof width === "number" && Number.isFinite(width)) {
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof width === "string") {
|
||||||
|
const trimmed = width.trim();
|
||||||
|
|
||||||
|
if (trimmed.endsWith("%")) {
|
||||||
|
const ratio = Number.parseFloat(trimmed.slice(0, -1));
|
||||||
|
if (Number.isFinite(ratio)) {
|
||||||
|
return (viewport.width * ratio) / 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number.parseFloat(trimmed);
|
||||||
|
if (Number.isFinite(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncRectFromDom = (resetPosition = false) => {
|
||||||
|
const element = getModalElement();
|
||||||
|
if (!element) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boundModalElement !== element) {
|
||||||
|
unbindModalResizeEvents();
|
||||||
|
bindModalResizeEvents(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always update viewport dimensions first
|
||||||
|
updateViewport();
|
||||||
|
|
||||||
|
const domRect = element.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Initialize rect with current DOM position (which is handled by AntDV initially)
|
||||||
|
const nextRect: ModalRect = {
|
||||||
|
left: domRect.left,
|
||||||
|
top: domRect.top,
|
||||||
|
width: domRect.width || DEFAULT_WIDTH,
|
||||||
|
height: domRect.height || DEFAULT_HEIGHT,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only if we need to force reset (e.g. on manual reset), we calculate center
|
||||||
|
// Otherwise we trust AntDV's initial positioning
|
||||||
|
if (resetPosition && !rectReady.value) {
|
||||||
|
// We just read the position from DOM, no need to recalculate center manually
|
||||||
|
// This avoids the scrollbar-width shift issue because AntDV handles it correctly
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(rect, nextRect);
|
||||||
|
rectReady.value = true;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureRectReady = async (resetPosition = false) => {
|
||||||
|
// Wait for Vue to render the modal into DOM
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// Give a small buffer for AntDV's transition/positioning to apply
|
||||||
|
if (resetPosition) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
let retryCount = 0;
|
||||||
|
const trySync = () => {
|
||||||
|
const synced = syncRectFromDom(resetPosition);
|
||||||
|
if (!synced && retryCount < 10) {
|
||||||
|
retryCount += 1;
|
||||||
|
requestAnimationFrame(trySync);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
trySync();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getResizeDirection = (event: MouseEvent): ResizeDirection | null => {
|
||||||
|
if (!boundModalElement) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalRect = boundModalElement.getBoundingClientRect();
|
||||||
|
const relativeX = event.clientX - modalRect.left;
|
||||||
|
const relativeY = event.clientY - modalRect.top;
|
||||||
|
|
||||||
|
const nearLeft = relativeX >= 0 && relativeX <= EDGE_SIZE;
|
||||||
|
const nearRight =
|
||||||
|
relativeX <= modalRect.width && relativeX >= modalRect.width - EDGE_SIZE;
|
||||||
|
const nearTop = relativeY >= 0 && relativeY <= EDGE_SIZE;
|
||||||
|
const nearBottom =
|
||||||
|
relativeY <= modalRect.height && relativeY >= modalRect.height - EDGE_SIZE;
|
||||||
|
|
||||||
|
if (!nearLeft && !nearRight && !nearTop && !nearBottom) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vertical = nearTop ? "n" : nearBottom ? "s" : "";
|
||||||
|
const horizontal = nearLeft ? "w" : nearRight ? "e" : "";
|
||||||
|
|
||||||
|
const direction = `${vertical}${horizontal}` as ResizeDirection;
|
||||||
|
return direction || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const directionCursorMap: Record<ResizeDirection, string> = {
|
||||||
|
n: "ns-resize",
|
||||||
|
s: "ns-resize",
|
||||||
|
e: "ew-resize",
|
||||||
|
w: "ew-resize",
|
||||||
|
ne: "nesw-resize",
|
||||||
|
sw: "nesw-resize",
|
||||||
|
nw: "nwse-resize",
|
||||||
|
se: "nwse-resize",
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateModalCursor = (direction: ResizeDirection | null) => {
|
||||||
|
if (!boundModalElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!direction || isFullscreen.value || !props.resizable) {
|
||||||
|
boundModalElement.style.cursor = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
boundModalElement.style.cursor = directionCursorMap[direction];
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDocumentListen = () => {
|
||||||
|
if (isDocumentListening) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDocumentListening = true;
|
||||||
|
bodyUserSelectCache = document.body.style.userSelect;
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
document.addEventListener("mousemove", handleDocumentMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleDocumentMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDocumentListen = () => {
|
||||||
|
if (!isDocumentListening) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDocumentListening = false;
|
||||||
|
document.body.style.userSelect = bodyUserSelectCache;
|
||||||
|
document.removeEventListener("mousemove", handleDocumentMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleDocumentMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyResize = (state: ResizeState, deltaX: number, deltaY: number) => {
|
||||||
|
const minWidth = getEffectiveMinWidth();
|
||||||
|
const minHeight = getEffectiveMinHeight();
|
||||||
|
|
||||||
|
let { left, top, width, height } = state.startRect;
|
||||||
|
|
||||||
|
if (state.direction.includes("e")) {
|
||||||
|
width = clamp(
|
||||||
|
state.startRect.width + deltaX,
|
||||||
|
minWidth,
|
||||||
|
viewport.width - state.startRect.left,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.direction.includes("s")) {
|
||||||
|
height = clamp(
|
||||||
|
state.startRect.height + deltaY,
|
||||||
|
minHeight,
|
||||||
|
viewport.height - state.startRect.top,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.direction.includes("w")) {
|
||||||
|
const maxLeft = state.startRect.left + state.startRect.width - minWidth;
|
||||||
|
left = clamp(state.startRect.left + deltaX, 0, maxLeft);
|
||||||
|
width = state.startRect.width + (state.startRect.left - left);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.direction.includes("n")) {
|
||||||
|
const maxTop = state.startRect.top + state.startRect.height - minHeight;
|
||||||
|
top = clamp(state.startRect.top + deltaY, 0, maxTop);
|
||||||
|
height = state.startRect.height + (state.startRect.top - top);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(rect, clampRect({ left, top, width, height }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDocumentMouseMove = (event: MouseEvent) => {
|
||||||
|
if (dragState.value) {
|
||||||
|
const deltaX = event.clientX - dragState.value.startX;
|
||||||
|
const deltaY = event.clientY - dragState.value.startY;
|
||||||
|
|
||||||
|
Object.assign(
|
||||||
|
rect,
|
||||||
|
clampRect({
|
||||||
|
left: dragState.value.startRect.left + deltaX,
|
||||||
|
top: dragState.value.startRect.top + deltaY,
|
||||||
|
width: dragState.value.startRect.width,
|
||||||
|
height: dragState.value.startRect.height,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resizeState.value) {
|
||||||
|
const deltaX = event.clientX - resizeState.value.startX;
|
||||||
|
const deltaY = event.clientY - resizeState.value.startY;
|
||||||
|
applyResize(resizeState.value, deltaX, deltaY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDocumentMouseUp = () => {
|
||||||
|
dragState.value = null;
|
||||||
|
resizeState.value = null;
|
||||||
|
stopDocumentListen();
|
||||||
|
updateModalCursor(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalMouseMove = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
!props.resizable ||
|
||||||
|
isFullscreen.value ||
|
||||||
|
dragState.value ||
|
||||||
|
resizeState.value
|
||||||
|
) {
|
||||||
|
updateModalCursor(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = getResizeDirection(event);
|
||||||
|
updateModalCursor(direction);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalMouseLeave = () => {
|
||||||
|
if (!resizeState.value) {
|
||||||
|
updateModalCursor(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalMouseDown = (event: MouseEvent) => {
|
||||||
|
if (!props.resizable || isFullscreen.value || event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = getResizeDirection(event);
|
||||||
|
if (!direction) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
// Ensure we start from the current visual position (which might be CSS-positioned)
|
||||||
|
syncRectFromDom();
|
||||||
|
isMoved.value = true;
|
||||||
|
|
||||||
|
resizeState.value = {
|
||||||
|
direction,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
startRect: cloneRect(rect),
|
||||||
|
};
|
||||||
|
|
||||||
|
startDocumentListen();
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindModalResizeEvents = (element: HTMLElement) => {
|
||||||
|
boundModalElement = element;
|
||||||
|
element.addEventListener("mousemove", handleModalMouseMove);
|
||||||
|
element.addEventListener("mouseleave", handleModalMouseLeave);
|
||||||
|
element.addEventListener("mousedown", handleModalMouseDown);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unbindModalResizeEvents = () => {
|
||||||
|
if (!boundModalElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
boundModalElement.removeEventListener("mousemove", handleModalMouseMove);
|
||||||
|
boundModalElement.removeEventListener("mouseleave", handleModalMouseLeave);
|
||||||
|
boundModalElement.removeEventListener("mousedown", handleModalMouseDown);
|
||||||
|
boundModalElement.style.cursor = "";
|
||||||
|
boundModalElement = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTitleMouseDown = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
!props.draggable ||
|
||||||
|
isFullscreen.value ||
|
||||||
|
isAnimating.value ||
|
||||||
|
event.button !== 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (target?.closest(".pro-modal-title-actions")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
// Ensure we start from the current visual position and switch to manual mode
|
||||||
|
syncRectFromDom();
|
||||||
|
isMoved.value = true;
|
||||||
|
|
||||||
|
dragState.value = {
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
startRect: cloneRect(rect),
|
||||||
|
};
|
||||||
|
|
||||||
|
startDocumentListen();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFullscreen = async () => {
|
||||||
|
if (!rectReady.value || isAnimating.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure isMoved is true so style bindings (top/left/width/height) are active
|
||||||
|
if (!isMoved.value) {
|
||||||
|
syncRectFromDom();
|
||||||
|
isMoved.value = true;
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = getModalElement();
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
// Force reflow to ensure current state is captured
|
||||||
|
// eslint-disable-next-line no-unused-expressions
|
||||||
|
element.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (!isFullscreen.value) {
|
||||||
|
restoreRect.value = cloneRect(rect);
|
||||||
|
isAnimating.value = true;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
Object.assign(rect, getFullscreenRect());
|
||||||
|
scheduleFullscreenAnimationEnd(() => {
|
||||||
|
isFullscreen.value = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAnimating.value = true;
|
||||||
|
const targetRect = restoreRect.value || {
|
||||||
|
left: (viewport.width - DEFAULT_WIDTH) / 2,
|
||||||
|
top: (viewport.height - DEFAULT_HEIGHT) / 2,
|
||||||
|
width: DEFAULT_WIDTH,
|
||||||
|
height: DEFAULT_HEIGHT,
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
Object.assign(rect, clampRect(targetRect));
|
||||||
|
scheduleFullscreenAnimationEnd(() => {
|
||||||
|
isFullscreen.value = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWindowResize = () => {
|
||||||
|
updateViewport();
|
||||||
|
|
||||||
|
if (!isOpen.value || !rectReady.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFullscreen.value && !isAnimating.value) {
|
||||||
|
Object.assign(rect, getFullscreenRect());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(rect, clampRect(cloneRect(rect)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOk = (event: MouseEvent) => {
|
||||||
|
emit("ok", event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = (event: MouseEvent) => {
|
||||||
|
emit("cancel", event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseClick = (event: MouseEvent) => {
|
||||||
|
if (!showCloseButton.value || isCloseButtonDisabled.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof props.closable === "object") {
|
||||||
|
props.closable.onClose?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("cancel", event);
|
||||||
|
emit("update:open", false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateOpen = (open: boolean) => {
|
||||||
|
emit("update:open", open);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
isOpen,
|
||||||
|
(open) => {
|
||||||
|
if (open) {
|
||||||
|
clearFullscreenAnimationTimer();
|
||||||
|
isAnimating.value = false;
|
||||||
|
isFullscreen.value = false;
|
||||||
|
restoreRect.value = null;
|
||||||
|
isMoved.value = false;
|
||||||
|
ensureRectReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFullscreenAnimationTimer();
|
||||||
|
isAnimating.value = false;
|
||||||
|
dragState.value = null;
|
||||||
|
resizeState.value = null;
|
||||||
|
rectReady.value = false;
|
||||||
|
stopDocumentListen();
|
||||||
|
unbindModalResizeEvents();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.width,
|
||||||
|
() => {
|
||||||
|
if (!isOpen.value || !rectReady.value || isFullscreen.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextWidth = parsePreferredWidth(props.width, rect.width);
|
||||||
|
Object.assign(rect, clampRect({ ...cloneRect(rect), width: nextWidth }));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateViewport();
|
||||||
|
window.addEventListener("resize", handleWindowResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearFullscreenAnimationTimer();
|
||||||
|
stopDocumentListen();
|
||||||
|
unbindModalResizeEvents();
|
||||||
|
window.removeEventListener("resize", handleWindowResize);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.pro-modal-titlebar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&.draggable {
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-modal-title-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-modal-title-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-modal-action-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-modal-wrap {
|
||||||
|
--pro-modal-fullscreen-duration: 0.3s;
|
||||||
|
--pro-modal-fullscreen-ease: var(--ease-in-out);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.ant-modal {
|
||||||
|
top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
margin: 0;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
max-height: calc(100vh - 2 * min(100px, 10vh));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-modal-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-modal-wrap .ant-modal-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-modal-wrap .ant-modal-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-modal-wrap .ant-modal-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-modal-animating .ant-modal {
|
||||||
|
transition-property: top, left, width, height;
|
||||||
|
transition-duration: var(--pro-modal-fullscreen-duration);
|
||||||
|
transition-timing-function: var(--pro-modal-fullscreen-ease);
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-modal-fullscreen .ant-modal {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.pro-modal-animating .ant-modal {
|
||||||
|
transition-duration: 0s !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
70
antdv-next-admin/src/components/Pro/ProSplitLayout/index.vue
Normal file
70
antdv-next-admin/src/components/Pro/ProSplitLayout/index.vue
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
<template>
|
||||||
|
<div class="pro-split-layout" :style="layoutStyle">
|
||||||
|
<div class="pro-split-side" :style="sideStyle">
|
||||||
|
<slot name="side" />
|
||||||
|
</div>
|
||||||
|
<div class="pro-split-main">
|
||||||
|
<slot name="main" />
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
sideWidth?: number | string;
|
||||||
|
sidePosition?: 'left' | 'right';
|
||||||
|
gap?: number | string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
sideWidth: 280,
|
||||||
|
sidePosition: 'left',
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizeSize = (value: number | string) => {
|
||||||
|
return typeof value === 'number' ? `${value}px` : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const layoutStyle = computed(() => ({
|
||||||
|
gap: normalizeSize(props.gap),
|
||||||
|
flexDirection: props.sidePosition === 'right' ? ('row-reverse' as const) : ('row' as const),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sideStyle = computed(() => ({
|
||||||
|
width: normalizeSize(props.sideWidth),
|
||||||
|
minWidth: normalizeSize(props.sideWidth),
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.pro-split-layout {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-split-side {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--color-bg-container);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px 16px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-split-main {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--color-bg-container);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
62
antdv-next-admin/src/components/Pro/ProStatCard/index.vue
Normal file
62
antdv-next-admin/src/components/Pro/ProStatCard/index.vue
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<article
|
||||||
|
class="relative overflow-hidden min-h-[168px] !p-5 rounded-[var(--radius-lg)] border border-[var(--color-border-secondary)] bg-[var(--color-bg-container)] shadow-[var(--shadow-card)] transition-all duration-300 hover:-translate-y-1 hover:shadow-[var(--shadow-card-hover)] md:min-h-[150px]"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-[var(--color-text-tertiary)] mb-2.5">{{ label }}</p>
|
||||||
|
<p
|
||||||
|
class="m-0 text-4xl leading-none font-[var(--font-family-number)] font-bold text-[var(--color-text-primary)] tracking-tight md:text-3xl"
|
||||||
|
>
|
||||||
|
{{ value }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="trend"
|
||||||
|
class="mt-3.5 inline-flex items-center gap-1.5 text-xs font-medium"
|
||||||
|
:class="trendDirection === 'down' ? 'text-error' : 'text-success'"
|
||||||
|
>
|
||||||
|
<RiseOutlined v-if="trendDirection !== 'down'" />
|
||||||
|
<FallOutlined v-else />
|
||||||
|
<span>{{ trend }}</span>
|
||||||
|
</p>
|
||||||
|
<slot name="extra" />
|
||||||
|
<component
|
||||||
|
:is="icon"
|
||||||
|
v-if="icon"
|
||||||
|
class="absolute right-3.5 -bottom-1.5 text-[96px]"
|
||||||
|
:style="{ color: accentColor }"
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Component } from "vue";
|
||||||
|
import type { ProStatCardTone } from "@/types/pro";
|
||||||
|
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { RiseOutlined, FallOutlined } from "@antdv-next/icons";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
trend?: string;
|
||||||
|
trendDirection?: "up" | "down";
|
||||||
|
icon?: Component;
|
||||||
|
tone?: ProStatCardTone;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
trendDirection: "up",
|
||||||
|
tone: "blue",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const accentColors: Record<ProStatCardTone, string> = {
|
||||||
|
blue: "rgba(24, 119, 255, 0.12)",
|
||||||
|
green: "rgba(82, 196, 26, 0.12)",
|
||||||
|
orange: "rgba(250, 140, 22, 0.12)",
|
||||||
|
purple: "rgba(114, 46, 209, 0.12)",
|
||||||
|
red: "rgba(245, 34, 45, 0.12)",
|
||||||
|
cyan: "rgba(19, 194, 194, 0.12)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const accentColor = computed(() => accentColors[props.tone]);
|
||||||
|
</script>
|
||||||
83
antdv-next-admin/src/components/Pro/ProStatus/index.vue
Normal file
83
antdv-next-admin/src/components/Pro/ProStatus/index.vue
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Dot mode -->
|
||||||
|
<span
|
||||||
|
v-if="mode === 'dot'"
|
||||||
|
class="pro-status pro-status-dot"
|
||||||
|
:style="dotStyle"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="pro-status-dot-indicator"
|
||||||
|
:style="{ background: statusColor }"
|
||||||
|
/>
|
||||||
|
{{ statusText }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Tag mode -->
|
||||||
|
<a-tag v-else-if="mode === 'tag'" :color="statusColor">
|
||||||
|
{{ statusText }}
|
||||||
|
</a-tag>
|
||||||
|
|
||||||
|
<!-- Badge mode -->
|
||||||
|
<a-badge v-else :color="statusColor" :text="statusText" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ProStatusMode, ProStatusMap } from "@/types/pro";
|
||||||
|
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string | number;
|
||||||
|
statusMap: ProStatusMap;
|
||||||
|
mode?: ProStatusMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
mode: "dot",
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = computed(() => props.statusMap[String(props.value)]);
|
||||||
|
const statusText = computed(() => config.value?.text ?? String(props.value));
|
||||||
|
const statusColor = computed(
|
||||||
|
() => config.value?.color ?? "var(--color-text-tertiary)",
|
||||||
|
);
|
||||||
|
|
||||||
|
const dotStyle = computed(() => {
|
||||||
|
const c = statusColor.value;
|
||||||
|
return {
|
||||||
|
"--pro-status-color": c,
|
||||||
|
"--pro-status-bg": hexToRgba(c, 0.1),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function hexToRgba(hex: string, alpha: number): string {
|
||||||
|
// Handle named colors by returning a light background
|
||||||
|
if (!hex.startsWith("#"))
|
||||||
|
return `color-mix(in srgb, ${hex} ${alpha * 100}%, transparent)`;
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.pro-status-dot {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--pro-status-color);
|
||||||
|
background: var(--pro-status-bg);
|
||||||
|
|
||||||
|
.pro-status-dot-indicator {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
130
antdv-next-admin/src/components/Pro/ProStepForm/index.vue
Normal file
130
antdv-next-admin/src/components/Pro/ProStepForm/index.vue
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<div class="pro-step-form">
|
||||||
|
<a-steps :current="currentStep" size="small" class="pro-step-form-steps">
|
||||||
|
<a-step
|
||||||
|
v-for="(step, index) in steps"
|
||||||
|
:key="index"
|
||||||
|
:title="step.title"
|
||||||
|
:description="step.description"
|
||||||
|
>
|
||||||
|
<template v-if="step.icon" #icon>
|
||||||
|
<component :is="step.icon" />
|
||||||
|
</template>
|
||||||
|
</a-step>
|
||||||
|
</a-steps>
|
||||||
|
|
||||||
|
<div class="pro-step-form-content">
|
||||||
|
<template v-for="(step, index) in steps" :key="index">
|
||||||
|
<div v-show="currentStep === index">
|
||||||
|
<slot :name="`step-${index}`" :step="step" :index="index" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pro-step-form-actions">
|
||||||
|
<a-space wrap>
|
||||||
|
<a-button :disabled="currentStep === 0" @click="handlePrev">
|
||||||
|
{{ prevText || $t('proStepForm.prev') }}
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
v-if="currentStep < steps.length - 1"
|
||||||
|
type="primary"
|
||||||
|
:loading="loading"
|
||||||
|
@click="handleNext"
|
||||||
|
>
|
||||||
|
{{ nextText || $t('proStepForm.next') }}
|
||||||
|
</a-button>
|
||||||
|
<a-button v-else type="primary" :loading="loading" @click="handleSubmit">
|
||||||
|
{{ submitText || $t('common.submit') }}
|
||||||
|
</a-button>
|
||||||
|
<slot name="extra-actions" :current-step="currentStep" />
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ProStepFormStep } from '@/types/pro';
|
||||||
|
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t: $t } = useI18n();
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
steps: ProStepFormStep[];
|
||||||
|
modelValue?: number;
|
||||||
|
loading?: boolean;
|
||||||
|
prevText?: string;
|
||||||
|
nextText?: string;
|
||||||
|
submitText?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: 0,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', step: number): void;
|
||||||
|
(e: 'next', currentStep: number): void;
|
||||||
|
(e: 'prev', currentStep: number): void;
|
||||||
|
(e: 'submit'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const currentStep = ref(props.modelValue);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(val) => {
|
||||||
|
currentStep.value = val;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(currentStep, (val) => {
|
||||||
|
emit('update:modelValue', val);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
if (currentStep.value > 0) {
|
||||||
|
currentStep.value -= 1;
|
||||||
|
emit('prev', currentStep.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
emit('next', currentStep.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
emit('submit');
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
prev: handlePrev,
|
||||||
|
goTo: (step: number) => {
|
||||||
|
if (step >= 0 && step < props.steps.length) {
|
||||||
|
currentStep.value = step;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.pro-step-form {
|
||||||
|
.pro-step-form-steps {
|
||||||
|
margin-bottom: var(--spacing-lg, 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-step-form-content {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pro-step-form-actions {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--color-border-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
12
antdv-next-admin/src/components/Pro/ProTable/CLAUDE.md
Normal file
12
antdv-next-admin/src/components/Pro/ProTable/CLAUDE.md
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<claude-mem-context>
|
||||||
|
# Recent Activity
|
||||||
|
|
||||||
|
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||||
|
|
||||||
|
### Feb 11, 2026
|
||||||
|
|
||||||
|
| ID | Time | T | Title | Read |
|
||||||
|
| ---- | ------- | --- | ----------------------------------------- | ---- |
|
||||||
|
| #345 | 5:40 PM | 🔵 | ProTable Component Implementation Started | ~426 |
|
||||||
|
|
||||||
|
</claude-mem-context>
|
||||||
175
antdv-next-admin/src/components/Pro/ProTable/ValueTypeRender.vue
Normal file
175
antdv-next-admin/src/components/Pro/ProTable/ValueTypeRender.vue
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
<template>
|
||||||
|
<span class="value-type-render">
|
||||||
|
<!-- Text -->
|
||||||
|
<span v-if="type === 'text'" :class="{ copyable }" @click="handleCopy">
|
||||||
|
{{ value }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Date -->
|
||||||
|
<span v-else-if="type === 'date'">
|
||||||
|
{{ formatDate(value, valueTypeProps.format || "YYYY-MM-DD") }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- DateTime -->
|
||||||
|
<span v-else-if="type === 'dateTime'">
|
||||||
|
{{ formatDate(value, valueTypeProps.format || "YYYY-MM-DD HH:mm:ss") }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Tag -->
|
||||||
|
<a-tag v-else-if="type === 'tag'" :color="getEnumConfig(value)?.color">
|
||||||
|
{{ getEnumConfig(value)?.text || value }}
|
||||||
|
</a-tag>
|
||||||
|
|
||||||
|
<!-- Badge -->
|
||||||
|
<a-badge
|
||||||
|
v-else-if="type === 'badge'"
|
||||||
|
:status="getEnumConfig(value)?.status as any"
|
||||||
|
:text="getEnumConfig(value)?.text || value"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Money -->
|
||||||
|
<span v-else-if="type === 'money'" class="money">
|
||||||
|
{{ valueTypeProps.symbol ?? "¥"
|
||||||
|
}}{{ formatMoney(value, valueTypeProps.precision) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Percent -->
|
||||||
|
<span v-else-if="type === 'percent'">
|
||||||
|
{{ formatPercent(value, valueTypeProps.precision) }}%
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Avatar -->
|
||||||
|
<a-avatar
|
||||||
|
v-else-if="type === 'avatar'"
|
||||||
|
:src="asString(value)"
|
||||||
|
:size="valueTypeProps.size || 32"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Image -->
|
||||||
|
<a-image
|
||||||
|
v-else-if="type === 'image'"
|
||||||
|
:src="asString(value)"
|
||||||
|
:width="valueTypeProps.width || 80"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Link -->
|
||||||
|
<a v-else-if="type === 'link'" :href="asString(value)" target="_blank" class="link">
|
||||||
|
{{ value }}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Progress -->
|
||||||
|
<a-progress
|
||||||
|
v-else-if="type === 'progress'"
|
||||||
|
:percent="asNumber(value)"
|
||||||
|
:status="(asNumber(value) ?? 0) >= 100 ? 'success' : 'active'"
|
||||||
|
v-bind="valueTypeProps"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Default -->
|
||||||
|
<span v-else>{{ value }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ValueType } from "@/types/pro";
|
||||||
|
|
||||||
|
import { message } from "antdv-next";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
import { $t } from "@/locales";
|
||||||
|
import { copyToClipboard } from "@/utils/helpers";
|
||||||
|
|
||||||
|
interface ValueTypeProps {
|
||||||
|
format?: string;
|
||||||
|
symbol?: string;
|
||||||
|
precision?: number;
|
||||||
|
size?: number;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: unknown;
|
||||||
|
type?: ValueType;
|
||||||
|
enum?: Record<string, { text: string; status?: string; color?: string }>;
|
||||||
|
record?: Record<string, unknown>;
|
||||||
|
copyable?: boolean;
|
||||||
|
valueTypeProps?: ValueTypeProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
type: "text",
|
||||||
|
copyable: false,
|
||||||
|
valueTypeProps: () => ({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const asString = (val: unknown): string | undefined => {
|
||||||
|
return typeof val === "string" ? val : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const asNumber = (val: unknown): number | undefined => {
|
||||||
|
return typeof val === "number" ? val : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEnumConfig = (value: unknown) => {
|
||||||
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
|
return props.enum?.[value];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (value: unknown, format: string) => {
|
||||||
|
if (!value) return "-";
|
||||||
|
if (typeof value === "string" || typeof value === "number" || value instanceof Date) {
|
||||||
|
return dayjs(value).format(format);
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatMoney = (value: unknown, precision?: number) => {
|
||||||
|
if (value === null || value === undefined) return "0.00";
|
||||||
|
const p = precision ?? 2;
|
||||||
|
return Number(value)
|
||||||
|
.toFixed(p)
|
||||||
|
.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (value: unknown, precision?: number) => {
|
||||||
|
if (value === null || value === undefined) return "0";
|
||||||
|
const p = precision ?? 2;
|
||||||
|
return Number(value).toFixed(p);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (props.copyable && props.value) {
|
||||||
|
const success = await copyToClipboard(String(props.value));
|
||||||
|
if (success) {
|
||||||
|
message.success($t("common.copySuccess"));
|
||||||
|
} else {
|
||||||
|
message.error($t("common.copyFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.value-type-render {
|
||||||
|
.copyable {
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.money {
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: var(--color-primary);
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user