添加antdv-next-admin作为脚手架准备重构前端web

This commit is contained in:
李龙龙 2026-05-11 15:22:08 +08:00
parent 6ab474bb7f
commit b2d286035b
211 changed files with 62897 additions and 0 deletions

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

@ -0,0 +1,5 @@
# Application Title
VITE_APP_TITLE=Antdv Next Admin
# API Base URL
VITE_API_BASE_URL=/api

View 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

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

View 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

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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 KiB

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View 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

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

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

View 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, "删除成功");
}

View 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, "删除成功");
}

View 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, "删除成功");
}

View 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, "删除成功");
}

View 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, "清空成功");
}

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

View 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