添加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