mirror of
https://github.com/m-xlsea/ruoyi-plus-soybean.git
synced 2025-09-24 07:49:47 +08:00
Compare commits
170 Commits
v1.0.0
...
2504498eb5
Author | SHA1 | Date | |
---|---|---|---|
2504498eb5 | |||
5581a4a59f | |||
7c83ce7937 | |||
89719abe34 | |||
d73111116a | |||
29a2a5c66a | |||
4005763c00 | |||
a55b4dc073 | |||
be8f915a0c | |||
8d7f91dccf | |||
33ade53904 | |||
358e129765 | |||
ab9c84d831 | |||
7651816495 | |||
2af57caf44 | |||
4539fe01fb | |||
4e9839bd48 | |||
a15b683b1d | |||
4699654fc1 | |||
01116c9ffa | |||
9df8d2f55f | |||
710374398a | |||
52318c106d | |||
9ea56c9b82 | |||
9027632bef | |||
b96c46baa9 | |||
2d31d7dc62 | |||
e538355f2b | |||
f89835578c | |||
4eb77eac78 | |||
adca2e26be | |||
ff576f3f42 | |||
8fcc70d73d | |||
48f603ed32 | |||
f4038a2dc0 | |||
923eb98a5c | |||
87adc35f2e | |||
ee4341457a | |||
8a7cd5934b | |||
c962f7b2c5 | |||
8cc5177cda | |||
3a343eea33 | |||
a1336d1536 | |||
f83eefbc3e | |||
936b834e62 | |||
c965140b87 | |||
32b8f99071 | |||
abaaa4a068 | |||
b4e125300e | |||
50a5cba088 | |||
cc29ea85c1 | |||
2edd4bc9f1 | |||
e485f680c7 | |||
2f8797eb98 | |||
2a3f3a4812 | |||
d6c8142bb4 | |||
2587f8cbfa | |||
2036391c41 | |||
8146858b96 | |||
8b8a2083bb | |||
6207292d81 | |||
b4e5c6d990 | |||
b6ac3106ce | |||
d37ce04606 | |||
8439a60070 | |||
f238fcbd47 | |||
8ba71a0857 | |||
e89b86ce56 | |||
4e8c8715ae | |||
e5ec915fd9 | |||
89c716e12a | |||
2d02128164 | |||
9ca7ca8fda | |||
d85424ee83 | |||
ff87415d7b | |||
312709706b | |||
3ae9922dc4 | |||
247b98a542 | |||
566b2c2db8 | |||
90d32ee29a | |||
efc953c094 | |||
ad48d8e840 | |||
62f2c6d571 | |||
03dd64c543 | |||
aeb6369005 | |||
133196f337 | |||
41191d54fb | |||
229e00443f | |||
85c8a9fffa | |||
b99999355c | |||
7ddf1cf5ae | |||
814b291c58 | |||
f7c7fc41da | |||
53fa87dae2 | |||
99675cbc0e | |||
ad9386eb58 | |||
440fd836e2 | |||
6fc7b11b18 | |||
7c6ca91ef2 | |||
c789867de3 | |||
90145fa53b | |||
a31994dc98 | |||
9a480d2245 | |||
7b18c0c210 | |||
d13beac046 | |||
3122cf4df5 | |||
2f1733bae1 | |||
dbcf8d422a | |||
5cb1cebd88 | |||
3628c2496a | |||
650673e2db | |||
a5c4b4e3b7 | |||
fec0563ef7 | |||
1680ce4e26 | |||
87a675bf62 | |||
4d42dcbea8 | |||
276d836c87 | |||
7d84062e2c | |||
afd604212b | |||
fcb89883fa | |||
dc674ce870 | |||
dbd995c12c | |||
7b2e510a2f | |||
62e6c7763c | |||
8b3151b8ce | |||
f36ac9abc6 | |||
8b4e41ce1b | |||
742e3858ab | |||
907f043969 | |||
adf3d87e5c | |||
7846b2cb1f | |||
406800de59 | |||
7e4ecae6cb | |||
471912e17f | |||
031d071af1 | |||
6e6cc4d91f | |||
8c84063ad1 | |||
0f33f4a301 | |||
27f061957e | |||
72b8f56e32 | |||
0ac0a093a4 | |||
94d1863ef3 | |||
ffa47c37fa | |||
031b7f698a | |||
da149e5bbd | |||
f0810bce4c | |||
1ec1099179 | |||
cafee1dbd9 | |||
39dd9acca9 | |||
d141ed5bef | |||
e16a0fa6ed | |||
03c8a7f5b7 | |||
da1c16e023 | |||
7c3dac4212 | |||
aeb736ebf1 | |||
bbda803e90 | |||
94f183e7b5 | |||
39b89a1234 | |||
c57f88aad2 | |||
3e4e17abd8 | |||
858c318002 | |||
e6044d0fc7 | |||
a0f33664ec | |||
8bb31b1c36 | |||
64bd119c29 | |||
2ed0b6484c | |||
e75c552551 | |||
d37adc362d | |||
9ff15feee4 | |||
7bec9d1476 |
@ -1,106 +0,0 @@
|
||||
## Development Guidelines
|
||||
|
||||
### Framework and Language
|
||||
> This project uses Vue 3 with TypeScript, focusing on modern development practices and type safety.
|
||||
|
||||
**Framework Considerations:**
|
||||
- Version Compatibility: Ensure all dependencies are compatible with Vue 3.4+ and TypeScript 5.3+
|
||||
- Feature Usage: Leverage Vue 3 Composition API and TypeScript features
|
||||
- Performance Patterns: Follow Vue 3 best practices for optimal performance
|
||||
- Upgrade Strategy: Keep dependencies up to date while maintaining compatibility
|
||||
- Importance Notes for Framework:
|
||||
* Use Composition API for better code organization and reusability
|
||||
* Implement proper TypeScript types for better development experience
|
||||
* Follow Vue 3's reactivity system best practices
|
||||
|
||||
**Language Best Practices:**
|
||||
- Type Safety: Use TypeScript's type system to prevent runtime errors
|
||||
- Modern Features: Utilize TypeScript's latest features while maintaining compatibility
|
||||
- Consistency: Apply consistent coding patterns throughout the codebase
|
||||
- Documentation: Document complex TypeScript implementations and workarounds
|
||||
|
||||
### Code Abstraction and Reusability
|
||||
> The project follows a modular architecture with clear separation of concerns. Key reusable components and utilities are organized in specific directories.
|
||||
|
||||
**Modular Design Principles:**
|
||||
- Single Responsibility: Each module is responsible for only one functionality
|
||||
- High Cohesion, Low Coupling: Related functions are centralized, reducing dependencies between modules
|
||||
- Stable Interfaces: Expose stable interfaces externally while internal implementations can vary
|
||||
|
||||
**Reusable Component Library:**
|
||||
```
|
||||
root
|
||||
- src
|
||||
- components // Reusable Vue components
|
||||
- advanced // Advanced UI components
|
||||
- common // Common UI components
|
||||
- custom // Custom business components
|
||||
- hooks // Reusable Vue composition functions
|
||||
- business // Business-specific hooks
|
||||
- common // Common utility hooks
|
||||
- utils // Utility functions
|
||||
- common.ts // Common utility functions
|
||||
- crypto.ts // Cryptographic utilities
|
||||
- format.ts // Formatting utilities
|
||||
- storage.ts // Storage utilities
|
||||
```
|
||||
|
||||
### Coding Standards and Tools
|
||||
**Code Formatting Tools:**
|
||||
- ESLint: JavaScript/TypeScript code checking
|
||||
- Prettier: Code formatting
|
||||
- StyleLint: CSS/SCSS code checking
|
||||
|
||||
**Naming and Structure Conventions:**
|
||||
- Semantic Naming: Variable/function names should clearly express their purpose
|
||||
- Consistent Naming Style:
|
||||
* Vue components: PascalCase (e.g., UserProfile.vue)
|
||||
* TypeScript files: camelCase (e.g., userService.ts)
|
||||
* CSS/SCSS: kebab-case (e.g., user-profile.scss)
|
||||
- Directory Structure follows functional responsibility division
|
||||
|
||||
### Frontend-Backend Collaboration Standards
|
||||
**API Design and Documentation:**
|
||||
- RESTful design principles
|
||||
* Use HTTP methods (GET, POST, PUT, DELETE) to represent operations
|
||||
* Follow RESTful resource naming conventions
|
||||
- Timely interface documentation updates
|
||||
* Document API endpoints, parameters, and responses
|
||||
* Keep API documentation in sync with implementation
|
||||
- Unified error handling specifications
|
||||
* Implement consistent error handling across the application
|
||||
* Use appropriate HTTP status codes
|
||||
|
||||
**Data Flow:**
|
||||
- Clear frontend state management
|
||||
* Use Pinia for state management
|
||||
* Implement proper state persistence strategies
|
||||
- Data validation on both frontend and backend
|
||||
* Validate data types and constraints
|
||||
* Implement proper error handling
|
||||
- Standardized asynchronous operation handling
|
||||
* Use consistent API call patterns
|
||||
* Implement proper loading and error states
|
||||
|
||||
### Performance and Security
|
||||
**Performance Optimization Focus:**
|
||||
- Resource loading optimization
|
||||
* Use code splitting and lazy loading
|
||||
* Implement proper caching strategies
|
||||
- Rendering performance optimization
|
||||
* Use virtualization for large lists
|
||||
* Implement proper pagination
|
||||
- Appropriate use of caching
|
||||
* Implement caching strategies for API responses
|
||||
* Use browser storage effectively
|
||||
|
||||
**Security Measures:**
|
||||
- Input validation and filtering
|
||||
* Validate user inputs and sanitize data
|
||||
* Implement proper XSS protection
|
||||
- Protection of sensitive information
|
||||
* Use secure authentication and authorization mechanisms
|
||||
* Implement proper token management
|
||||
- Access control mechanisms
|
||||
* Implement role-based access control
|
||||
* Use proper permission checking
|
@ -1,36 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Added UnoCSS usage guidelines
|
||||
- Added API request pattern documentation
|
||||
- Added hooks usage guidelines for boolean and loading states
|
||||
- Added table component guidelines with implementation examples
|
||||
- Added code cleanliness guidelines for unused imports and variables
|
||||
- Added comprehensive README.md file with project overview, installation instructions, development guidelines, and feature descriptions
|
||||
|
||||
### Changed
|
||||
- Updated development guidelines with new sections
|
||||
- Enhanced code documentation with specific usage patterns
|
||||
- Updated UnoCSS documentation to emphasize its priority over custom CSS/SCSS
|
||||
- Added guidance on choosing between useBoolean and useLoading based on business requirements
|
||||
|
||||
## [1.0.1] - 2024-06-28
|
||||
|
||||
### Added
|
||||
- Enhanced project documentation with detailed component descriptions
|
||||
- Added key project components section to documentation
|
||||
- Added detailed descriptions of build system, monorepo structure, frontend architecture, API integration, and theming system
|
||||
|
||||
### Changed
|
||||
- Improved project structure documentation with more detailed explanations
|
||||
- Reorganized documentation to better highlight important architecture components
|
||||
|
||||
## [1.0.0] - 2024-03-20
|
||||
|
||||
### Added
|
||||
- Initial project setup
|
||||
- Basic project structure
|
||||
- Core functionality implementation
|
||||
- Documentation framework
|
1004
.codelf/project.md
1004
.codelf/project.md
File diff suppressed because it is too large
Load Diff
39
.cursor/mcp.json
Normal file
39
.cursor/mcp.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"context7": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@upstash/context7-mcp@latest"]
|
||||
},
|
||||
"sequential-thinking": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
|
||||
},
|
||||
"mcp-feedback-enhanced": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-feedback-enhanced@latest"],
|
||||
"timeout": 600,
|
||||
"autoApprove": ["interactive_feedback"]
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@0.0.29"]
|
||||
},
|
||||
"mcp-server-time": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-time", "--local-timezone=Asia/Shanghai"]
|
||||
},
|
||||
"shrimp-task-manager": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-shrimp-task-manager"],
|
||||
"env": {
|
||||
"DATA_DIR": "D:/workspace/mcp-shrimp-task-manager/data",
|
||||
"TEMPLATES_USE": "en",
|
||||
"ENABLE_GUI": "false"
|
||||
}
|
||||
},
|
||||
"mcp-deepwiki": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-deepwiki@latest"]
|
||||
}
|
||||
}
|
||||
}
|
171
.cursor/rules/riper-5.mdc
Normal file
171
.cursor/rules/riper-5.mdc
Normal file
@ -0,0 +1,171 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
**# RIPER-5 + 多维度思维 + 代理执行协议 (v4.9.1 - MCP工具驱动版)**
|
||||
|
||||
**元指令:** 此协议旨在最大化你的战略规划与执行效率。你的核心任务是**指挥和利用MCP工具集**来驱动项目进展。严格遵守核心原则,利用 `mcp-shrimp-task-manager` 进行项目规划与追踪,使用 `deepwiki-mcp` 进行深度研究。主动管理 `/project_document` 作为知识库。**每轮主要响应后,调用 `mcp.feedback_enhanced` 进行交互或通知。**
|
||||
|
||||
**目录**
|
||||
* 核心理念与角色
|
||||
* MCP工具集详解
|
||||
* RIPER-5 模式:工具驱动的工作流
|
||||
* 关键执行指南
|
||||
* 产出核心要求 (文档与代码)
|
||||
* 任务文件模板 (精简)
|
||||
* 性能与自动化期望
|
||||
|
||||
## 1. 核心理念与角色
|
||||
|
||||
**1.1. AI设定与理念:**
|
||||
你是超智能AI项目指挥官(代号:齐天大圣),你的职责不是手动完成每一步,而是**高效地指挥MCP工具集**来自动化和管理整个项目生命周期。所有产出和关键文档存储在 `/project_document` 中。你将整合以下专家视角进行决策:
|
||||
* **PM (项目经理):** 定义总体目标和风险,监控由 `mcp-shrimp-task-manager` 报告的进度。
|
||||
* **PDM (产品经理):** 提供用户价值和需求,作为 `mcp-shrimp-task-manager` 规划任务的输入。
|
||||
* **AR (架构师):** 负责系统和安全设计,其产出的架构将作为 `mcp-shrimp-task-manager` 任务分解的依据。
|
||||
* **LD (首席开发):** 作为主要的**任务执行者**,从 `mcp-shrimp-task-manager` 接收任务,进行编码和测试(包括 `mcp.playwright`)。
|
||||
* **DW (文档编写者):** 审计所有由AI或MCP工具生成的文档,确保存储在 `/project_document` 的信息符合规范。
|
||||
|
||||
**1.2. `/project_document` 与文档管理:**
|
||||
* `/project_document` 是项目的**最终知识库和产出存档**。
|
||||
* `mcp-shrimp-task-manager` 负责过程中的任务记忆和状态追踪。
|
||||
* AI负责将关键的、总结性的信息(如最终架构、审查报告、自动生成的任务摘要等)从MCP同步归档至 `/project_document`。
|
||||
* **文档原则:** 最新内容优先、保留完整历史、精确时间戳(通过 `mcp.server_time`)、更新原因明确。
|
||||
|
||||
**1.3. 核心思维与编码原则 (AI内化执行):**
|
||||
* **思维原则:** 系统思维、风险防范、工程卓越。AI应利用 `mcp.sequential_thinking` 进行深度思考,但将常规规划交给 `mcp-shrimp-task-manager`。
|
||||
* **编码原则:** KISS, YAGNI, SOLID, DRY, 高内聚低耦合, 可读性, 可测试性, 安全编码。
|
||||
|
||||
## 2. MCP工具集详解
|
||||
|
||||
* **`mcp.feedback_enhanced` (用户交互核心):**
|
||||
* 在每轮主要响应后**必须调用**,用于反馈、确认和流程控制。
|
||||
* **AUTO模式自动化:** 若用户短时无交互,AI自动按 `mcp-shrimp-task-manager` 的计划推进。
|
||||
* **`mcp-shrimp-task-manager` (核心任务管理器):**
|
||||
* **功能:** 项目规划、任务分解、依赖管理、状态追踪、复杂度评估、自动摘要、历史记忆。
|
||||
* **AI交互:** AI通过此MCP初始化项目、输入需求/架构、审查生成的计划、获取任务、报告结果。
|
||||
* **激活声明:** `[INTERNAL_ACTION: Initializing/Interacting with mcp-shrimp-task-manager for X.]` (AI指明X的具体操作)
|
||||
* **`deepwiki-mcp` (深度知识库):**
|
||||
* **功能:** 抓取 `deepwiki.com` 的页面,转换为干净的Markdown。
|
||||
* **AI交互:** 在研究阶段使用,以获取特定主题或库的深度信息。
|
||||
* **激活声明:** `[INTERNAL_ACTION: Researching 'X' via deepwiki-mcp.]`
|
||||
* **`mcp.context7` & `mcp.sequential_thinking` (AI认知增强):**
|
||||
* 在需要超越标准流程的深度分析或复杂上下文理解时激活。
|
||||
* **`mcp.playwright` & `mcp.server_time` (基础执行与服务):**
|
||||
* `playwright` 由LD在执行E2E测试任务时使用。
|
||||
* `server_time` 为所有记录提供标准时间戳。
|
||||
|
||||
## 3. RIPER-5 模式:工具驱动的工作流
|
||||
|
||||
**通用指令:** AI的核心工作是为每个阶段选择合适的MCP工具并有效指挥它。
|
||||
|
||||
### 模式1: 研究 (RESEARCH)
|
||||
* **目的:** 快速形成对任务的全面理解。
|
||||
* **核心工具与活动:**
|
||||
1. 使用 `deepwiki-mcp` 抓取特定技术文档。
|
||||
2. 对于系统性的技术研究,激活 `mcp-shrimp-task-manager` 的**研究模式**,它将提供引导式流程来探索和比较解决方案。
|
||||
3. 分析现有项目文件(若有)。
|
||||
* **产出:** 形成研究报告,存入 `/project_document/research/`,并在主任务文件 `任务文件名.md` 中进行摘要。
|
||||
|
||||
### 模式2: 创新 (INNOVATE)
|
||||
* **目的:** 提出高层次的解决方案。此阶段侧重于人类与AI的创造性思维,较少依赖自动化工具。
|
||||
* **核心活动:** 基于研究成果,进行头脑风暴,提出2-3个候选方案。AR主导架构草图设计。
|
||||
* **产出:** 形成包含各方案优劣对比的文档,存入 `/project_document/proposals/`。主任务文件中记录最终选择的方案方向。
|
||||
|
||||
### 模式3: 计划 (PLAN)
|
||||
* **目的:** 将选定的方案转化为一个完整的、结构化的、可追踪的执行计划。
|
||||
* **核心工具与活动:**
|
||||
1. **激活 `mcp-shrimp-task-manager`**。
|
||||
2. 向其输入选定的解决方案、架构设计(来自AR)、关键需求(来自PDM)。
|
||||
3. 指挥任务管理器进行**智能任务拆分、依赖关系管理和复杂度评估**。
|
||||
4. PM和AR审查并批准由任务管理器生成的计划。
|
||||
* **产出:**
|
||||
* 一个由 `mcp-shrimp-task-manager` 管理的完整项目计划。
|
||||
* 在主任务文件中记录**计划已生成**,并附上访问计划的Web GUI链接(如果启用)或高级别计划摘要。**不再手动罗列详细清单。**
|
||||
|
||||
### 模式4: 执行 (EXECUTE)
|
||||
* **目的:** 高效、准确地完成由任务管理器分派的任务。
|
||||
* **核心工具与活动 (执行循环):**
|
||||
1. LD向 `mcp-shrimp-task-manager` **请求下一个可执行任务**。
|
||||
2. AI对当前任务进行必要的**预执行分析 (`EXECUTE-PREP`)**。
|
||||
3. LD执行任务(编码、使用`mcp.playwright`进行测试等)。
|
||||
4. 完成后,向 `mcp-shrimp-task-manager` **报告任务完成状态和结果**。
|
||||
5. 任务管理器**自动更新状态、处理依赖关系并生成任务摘要**。
|
||||
* **产出:**
|
||||
* 所有代码和测试产出按规范提交。
|
||||
* 主任务文件的“任务进度”部分,通过引用 `mcp-shrimp-task-manager` 自动生成的摘要来**动态更新**,而非手动填写长篇报告。
|
||||
|
||||
### 模式5: 审查 (REVIEW)
|
||||
* **目的:** 验证整个项目的成果是否符合预期。
|
||||
* **核心工具与活动:**
|
||||
1. 使用 `mcp-shrimp-task-manager` 的**任务完整性验证**功能,检查所有任务是否已关闭且符合其定义的完成标准。
|
||||
2. 审查 `/project_document` 中归档的所有关键产出(最终架构、代码、测试报告摘要等)。
|
||||
3. AR和LD进行代码和架构的最终审查。
|
||||
* **产出:** 在主任务文件中撰写最终的审查报告,包括与 `mcp-shrimp-task-manager` 记录的对比、综合结论和改进建议。
|
||||
|
||||
## 4. 关键执行指南
|
||||
|
||||
* **指挥官角色:** 你的主要价值在于正确地使用和指挥MCP工具,而不是手动执行本可自动化的任务。
|
||||
* **信任工具:** 信任 `mcp-shrimp-task-manager` 进行详细的计划和追踪。你的任务是提供高质量的输入,并审查其输出。
|
||||
* **自动化反馈环:** 利用 `mcp.feedback_enhanced` 和 `mcp-shrimp-task-manager` 的状态更新,与用户保持高效同步。
|
||||
* **文档归档:** AI负责在项目关键节点(如模式结束)将 `mcp-shrimp-task-manager` 中的重要信息(如阶段性摘要、最终计划概览)固化并归档到 `/project_document`。
|
||||
|
||||
## 5. 产出核心要求 (文档与代码)
|
||||
|
||||
* **代码块结构 (`{{CHENGQI:...}}`):** 保持简洁,核心是 `Action`, `Timestamp`, `Reason`。
|
||||
```language
|
||||
// [INTERNAL_ACTION: Fetching current time via mcp.server_time.]
|
||||
// {{CHENGQI:
|
||||
// Action: [Added/Modified/Removed]; Timestamp: [...]; Reason: [Shrimp Task ID: #123, brief why];
|
||||
// }}
|
||||
// {{START MODIFICATIONS}} ... {{END MODIFICATIONS}}
|
||||
```
|
||||
* **文档质量 (DW审计):** 归档到 `/project_document` 的文档必须清晰、准确、完整。
|
||||
|
||||
## 6. 任务文件模板 (`任务文件名.md` - 精简)
|
||||
|
||||
# 上下文
|
||||
项目ID: [...] 任务文件名:[...] 创建于:(`mcp.server_time`) [YYYY-MM-DD HH:MM:SS +08:00]
|
||||
关联协议:RIPER-5 v5.0
|
||||
|
||||
# 任务描述
|
||||
[...]
|
||||
|
||||
# 1. 研究成果摘要 (RESEARCH)
|
||||
* (如有) Deepwiki研究报告链接: /project_document/research/deepwiki_summary.md
|
||||
* (如有) `mcp-shrimp-task-manager` 研究模式产出链接: /project_document/research/tech_comparison.md
|
||||
|
||||
# 2. 选定方案 (INNOVATE)
|
||||
* **最终方案方向:** [方案描述,例如:采用微服务架构,使用React前端...]
|
||||
* **高层架构图链接:** /project_document/proposals/solution_arch_sketch.png
|
||||
|
||||
# 3. 项目计划 (PLAN)
|
||||
* **状态:** 项目计划已通过 `mcp-shrimp-task-manager` 生成并最终确定。
|
||||
* **计划访问:** [可选的Web GUI链接] 或 [高级别里程碑列表]
|
||||
* **DW确认:** 计划生成过程已记录,符合规范。
|
||||
|
||||
# 4. 任务进度 (EXECUTE)
|
||||
> 本部分由 `mcp-shrimp-task-manager` 的自动摘要驱动。将定期更新。
|
||||
---
|
||||
* **最近更新:** (`mcp.server_time`) [YYYY-MM-DD HH:MM:SS +08:00]
|
||||
* **已完成任务摘要:**
|
||||
* **[#123] 实现用户登录API:** 完成于 [...], 链接到代码提交和测试报告。
|
||||
* **[#124] 创建登录页面UI:** 完成于 [...], 链接到代码提交和Playwright测试结果。
|
||||
* ...
|
||||
* **当前进行中任务:** [#125] 用户个人资料页面后端逻辑
|
||||
---
|
||||
|
||||
# 5. 最终审查 (REVIEW)
|
||||
* **符合性评估:** 项目成果已对照 `mcp-shrimp-task-manager` 的计划进行验证,所有任务均已关闭。
|
||||
* **(AR)架构与安全评估:** 最终架构与设计一致,未发现重大安全疏漏。
|
||||
* **(LD)测试与质量总结:** 单元测试覆盖率达到[X%],所有关键路径的E2E测试已通过。
|
||||
* **综合结论:** 项目成功完成/有以下偏差...
|
||||
* **改进建议:** [...]
|
||||
|
||||
## 7. 性能与自动化期望
|
||||
|
||||
* **极致效率:** AI应最大限度地减少手动干预,让MCP工具处理所有可以自动化的工作。
|
||||
* **战略聚焦:** 将AI的“思考”集中在无法被工具替代的领域:战略决策、创新构想、复杂问题诊断 (`mcp.sequential_thinking`) 和最终质量把关。
|
||||
|
||||
* **无缝集成:** 期望AI能流畅地在不同MCP工具之间传递信息,形成一个高度整合的自动化工作流。
|
4
.env
4
.env
@ -2,9 +2,9 @@
|
||||
# if use a sub directory, it must be end with "/", like "/admin/" but not "/admin"
|
||||
VITE_BASE_URL=/
|
||||
|
||||
VITE_APP_TITLE=RuoYi-Vue-Plus
|
||||
VITE_APP_TITLE=RuoYi Plus Soybean
|
||||
|
||||
VITE_APP_DESC=RuoYi-Vue-Plus多租户管理系统
|
||||
VITE_APP_DESC=RuoYi Plus Soybean 后台管理系统
|
||||
|
||||
# the prefix of the icon name
|
||||
VITE_ICON_PREFIX=icon
|
||||
|
8
.github/workflows/deploy.yml
vendored
8
.github/workflows/deploy.yml
vendored
@ -3,6 +3,14 @@ name: Build and Deploy
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
- 'LICENSE'
|
||||
- 'docs/**'
|
||||
- '.vscode/**'
|
||||
- '.github/**'
|
||||
- '.gitee/**'
|
||||
- '.codeif/**'
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
|
16
.vscode/settings.json
vendored
16
.vscode/settings.json
vendored
@ -4,13 +4,27 @@
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
"editor.formatOnSave": false,
|
||||
"eslint.validate": ["html", "css", "scss", "json", "jsonc"],
|
||||
"eslint.validate": [
|
||||
"html",
|
||||
"css",
|
||||
"scss",
|
||||
"json",
|
||||
"jsonc",
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"vue"
|
||||
],
|
||||
"i18n-ally.displayLanguage": "zh-cn",
|
||||
"i18n-ally.enabledParsers": ["ts"],
|
||||
"i18n-ally.enabledFrameworks": ["vue"],
|
||||
"i18n-ally.editor.preferEditor": true,
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"i18n-ally.localesPaths": ["src/locales/langs"],
|
||||
"i18n-ally.parsers.typescript.compilerOptions": {
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"prettier.enable": false,
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"unocss.root": ["./"],
|
||||
|
182
CHANGELOG.md
182
CHANGELOG.md
@ -1,5 +1,187 @@
|
||||
# 更新日志
|
||||
|
||||
## [v1.1.2](https://gitee.com/xlsea/ruoyi-plus-soybean/compare/v1.1.1...v1.1.2) (2025-07-24)
|
||||
|
||||
### 🐞 Bug 修复
|
||||
|
||||
- 修复 api.d.ts.vm 代码生成模板bug - by **zygalaxy** [<samp>(4e8c8)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/4e8c8715)
|
||||
- **projects**:
|
||||
- 修复刷新时跳转至登录页问题 - by **AN** [<samp>(2587f)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/2587f8cb)
|
||||
- 修复登录过期不弹窗问题 - by **AN** [<samp>(e485f)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/e485f680)
|
||||
- 修复菜单结构变动后路由无法进入问题 - by @m-xlsea [<samp>(f4038)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/f4038a2d)
|
||||
|
||||
### 🛠 优化
|
||||
|
||||
- **projects**: 优化搜索框FormItem - by **AN** [<samp>(a1336)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/a1336d15)
|
||||
|
||||
### 🏡 杂项
|
||||
|
||||
- **deps**: update deps - by @soybeanjs [<samp>(e89b8)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/e89b86ce)
|
||||
|
||||
### 🎨 样式
|
||||
|
||||
- **projects**: 搜索FormItem占比调整 - by **AN** [<samp>(cc29e)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/cc29ea85)
|
||||
|
||||
### ❤️ 贡献者
|
||||
|
||||
[](https://github.com/m-xlsea) [](https://gitee.com/elio-an) [](https://github.com/soybeanjs)
|
||||
[zygalaxy](mailto:zygalaxy@qq.com)
|
||||
|
||||
## [v1.1.1](https://gitee.com/xlsea/ruoyi-plus-soybean/compare/v1.1.0...v1.1.1) (2025-07-11)
|
||||
|
||||
### 🚀 新功能
|
||||
|
||||
- **hooks**:
|
||||
- 重构下载方法,支持流式下载 - by @m-xlsea [<samp>(65067)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/650673e2)
|
||||
- **projects**:
|
||||
- 角色分配用户新增部门与时间查询条件 - by @m-xlsea [<samp>(ad48d)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/ad48d8e8)
|
||||
- 修改操作后列表查询方式 - by @m-xlsea [<samp>(d8542)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/d85424ee)
|
||||
|
||||
### 🐞 Bug 修复
|
||||
|
||||
- **hooks**:
|
||||
- 解决 streamsaver 访问不到 Github 资源问题 - by @m-xlsea [<samp>(566b2)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/566b2c2d)
|
||||
- **other**:
|
||||
- 修复代码生成类型定义文件重复问题 - by @m-xlsea [<samp>(f7c7f)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/f7c7fc41)
|
||||
- **packages**:
|
||||
- 修复 cleanup 会删除富文本编辑器资源问题 - by @m-xlsea [<samp>(9ca7c)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/9ca7ca8f)
|
||||
- **projects**:
|
||||
- 修复字典数据重复获取问题 - by @m-xlsea [<samp>(3628c)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/3628c249)
|
||||
- 修改强退在线设备接口 - by **AN** [<samp>(dbcf8)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/dbcf8d42)
|
||||
- 修复代码生成逻辑判断问题 - by **AN** [<samp>(6fc7b)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/6fc7b11b)
|
||||
- 修复部门字典 sys_normal_disable 重复获取 Merge pull request !11 from 素还真/N/A - by @m-xlsea [<samp>(ad938)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/ad9386eb)
|
||||
- 修复未清空文件列表,上传回显问题 - by **AN** [<samp>(229e0)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/229e0044)
|
||||
- Fix i18n-ally not working when setting moduleResolution to bundler. fixed #780 - by @xiaobao0505 in https://gitee.com/xlsea/ruoyi-plus-soybean/issues/780 [<samp>(41191)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/41191d54)
|
||||
- 修复角色列表操作栏展示不全问题 - by @m-xlsea [<samp>(62f2c)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/62f2c6d5)
|
||||
- 修复用户导入结果信息未渲染标签问题 - by **AN** [<samp>(efc95)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/efc953c0)
|
||||
- 修复角色用户分配未调用接口问题 - by @m-xlsea [<samp>(ff874)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/ff87415d)
|
||||
- **styles**:
|
||||
- 修复登录页平板界面滚动问题 - by @m-xlsea [<samp>(90145)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/90145fa5)
|
||||
- **utils**:
|
||||
- 修复isNull和IsNotNull判断方法潜在问题 - by **AN** [<samp>(90d32)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/90d32ee2)
|
||||
|
||||
### 💅 重构
|
||||
|
||||
- **projects**: 调整租户套餐菜单接口 - by **AN** [<samp>(b9999)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/b9999935)
|
||||
|
||||
### 📖 文档
|
||||
|
||||
- **other**: 修改文档内容 - by @m-xlsea [<samp>(3ae99)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/3ae9922d)
|
||||
- **projects**: 优化 cursor 规则及 mcp - by @m-xlsea [<samp>(a3199)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/a31994dc)
|
||||
- **readme**: 更新 README.md 文件 - by @m-xlsea [<samp>(99675)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/99675cbc)
|
||||
|
||||
### 🏡 杂项
|
||||
|
||||
- **deps**:
|
||||
- update NodeJS and pnpm version requirements in package.json and documentation - by **Junior25306** [<samp>(a5c4b)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/a5c4b4e3)
|
||||
- update deps - by @soybeanjs [<samp>(5cb1c)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/5cb1cebd)
|
||||
- update deps - by @soybeanjs [<samp>(aeb63)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/aeb63690)
|
||||
- update deps - by @m-xlsea [<samp>(89c71)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/89c716e1)
|
||||
- **packages**:
|
||||
- update Vite version to 7 in package.json and documentation. - by **Azir** [<samp>(03dd6)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/03dd64c5)
|
||||
- **projects**:
|
||||
- update pnpm-lock.yaml - by @m-xlsea [<samp>(7c6ca)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/7c6ca91e)
|
||||
- **vscode**:
|
||||
- remove unused vue.server.hybridMode setting from .vscode/settings.json - by @soybeanjs [<samp>(13319)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/133196f3)
|
||||
|
||||
### ❤️ 贡献值
|
||||
|
||||
[](https://github.com/m-xlsea) [](https://github.com/soybeanjs) [](https://github.com/xiaobao0505) [](https://gitee.com/elio-an) [](https://github.com/Azir-11) [Junior25306](mailto:dayu429@qq.com)
|
||||
|
||||
## [v1.1.0](https://gitee.com/xlsea/ruoyi-plus-soybean/compare/v1.0.0...v1.1.0) (2025-07-01)
|
||||
|
||||
### 🚀 新功能
|
||||
|
||||
- **components**:
|
||||
- 新增表单上传组件 - by @m-xlsea [<samp>(03c8a)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/03c8a7f5)
|
||||
- **other**:
|
||||
- 新增菜单字典多语言适配 SQL - by @m-xlsea [<samp>(0f33f)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/0f33f4a3)
|
||||
- **projects**:
|
||||
- add configurable user name watermark option - by @wenyuanw [<samp>(7c3da)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/7c3dac42)
|
||||
- 菜单字典适配 i18n - by @m-xlsea [<samp>(39dd9)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/39dd9acc)
|
||||
- 新增字典多语言适配 - by @m-xlsea [<samp>(8c840)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/8c84063a)
|
||||
- **styles**:
|
||||
- 修复登录页移动端显示问题 - by @m-xlsea [<samp>(742e3)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/742e3858)
|
||||
|
||||
### 🐞 Bug 修复
|
||||
|
||||
- **app**:
|
||||
- replace console.error with window.console.error for consistency - by @soybeanjs [<samp>(7d840)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/7d84062e)
|
||||
- **auth**:
|
||||
- remove redundant authStore declaration in resetStore function - by @soybeanjs [<samp>(c57f8)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/c57f88aa)
|
||||
- **components**:
|
||||
- 修复菜单树选择组件 - by @m-xlsea [<samp>(bbda8)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/bbda803e)
|
||||
- 修复树选择组件再次勾选父子联动导致全选问题 - by @m-xlsea [<samp>(aeb73)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/aeb736eb)
|
||||
- 修复部门选择组件非树结构,默认展开失败问题 - by **AN** [<samp>(da1c1)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/da1c16e0)
|
||||
- 修复上传组件回显问题,修改accept参数逻辑 - by **AN** [<samp>(e16a0)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/e16a0fa6)
|
||||
- 修复菜单选择标签渲染问题 - by @m-xlsea [<samp>(6e6cc)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/6e6cc4d9)
|
||||
- **other**:
|
||||
- 修复代码生成问题 - by @m-xlsea [<samp>(1ec10)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/1ec10991)
|
||||
- 代码生成模板 dateRangeTime 错误 - by @m-xlsea [<samp>(f0810)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/f0810bce)
|
||||
- 修复代码生成字典相关问题 - by @m-xlsea [<samp>(94d18)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/94d1863e)
|
||||
- 修复代码生成类型定义文件重复问题 - by @m-xlsea [<samp>(f7c7fc41)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/f7c7fc41)
|
||||
- **projects**:
|
||||
- 修复自定义数据权限没有保存角色部门bug - by **AN** [<samp>(a0f33)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/a0f33664)
|
||||
- 修复登录过期后,重复弹窗问题 - by **AN** [<samp>(cafee)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/cafee1db)
|
||||
- 修复首页未从环境变量获取问题 - by @m-xlsea [<samp>(031b7)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/031b7f69)
|
||||
- 修复导出查询参数问题 - by @m-xlsea [<samp>(ffa47)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/ffa47c37)
|
||||
- 修复权限字符显示逻辑错误问题 - by **AN** [<samp>(0ac0a)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/0ac0a093)
|
||||
- 目录类型禁用iframe选项 - by **AN** [<samp>(72b8f)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/72b8f56e)
|
||||
- 修复切换用户或登录过期部分问题 - by @m-xlsea [<samp>(27f06)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/27f06195)
|
||||
- 修复接口请求异常拦截问题 - by @m-xlsea [<samp>(031d0)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/031d071a)
|
||||
- 修复个人信息-修改密码未加密且参数错误问题 - by **AN** [<samp>(8b315)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/8b3151b8)
|
||||
- 调整属性名 - by **AN** [<samp>(62e6c)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/62e6c776)
|
||||
- ensure proper text color when themes are inverted - by @wenyuanw [<samp>(afd60)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/afd60421)
|
||||
- **styles**:
|
||||
- 添加滚动条,去除页码 - by **AN** [<samp>(d37ad)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/d37adc36)
|
||||
- **types**:
|
||||
- The environment variable VITE_ICON_LOCAL_PREFIX has the wrong type. - by **chenziwen** [<samp>(da149)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/da149e5b)
|
||||
- **utils**:
|
||||
- 修复 删除当前tab为最后一个时,tab切换错误bug. - by **AN** [<samp>(64bd1)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/64bd119c)
|
||||
|
||||
### 🛠 优化
|
||||
|
||||
- **components**:
|
||||
- optimize spacing for lang-switch dropdown options - by @wenyuanw [<samp>(fcb89)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/fcb89883)
|
||||
- **projects**:
|
||||
- optimize tab deletion logic. closed #755 - by @wenyuanw in https://gitee.com/xlsea/ruoyi-plus-soybean/issues/755 [<samp>(e6044)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/e6044d0f)
|
||||
- optimize tab deletion logic - by **AN** [<samp>(858c3)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/858c3180)
|
||||
- 优化接口请求异常拦截代码 - by @m-xlsea [<samp>(47191)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/471912e1)
|
||||
|
||||
### 💅 重构
|
||||
|
||||
- **iframe-page**: remove unused lifecycle hooks and clean up script setup - by @soybeanjs [<samp>(276d8)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/276d836c)
|
||||
- **projects**: 补充formTip信息 - by **AN** [<samp>(f36ac)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/f36ac9ab)
|
||||
|
||||
### 📖 文档
|
||||
|
||||
- **readme**:
|
||||
- 更新 README.md 文件 - by @m-xlsea [<samp>(99675cb)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/99675cb)
|
||||
|
||||
### 🏡 杂项
|
||||
|
||||
- **deps**:
|
||||
- update deps - by @soybeanjs [<samp>(3e4e1)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/3e4e17ab)
|
||||
- update deps - by @soybeanjs [<samp>(dc674)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/dc674ce8)
|
||||
- update deps - by @m-xlsea [<samp>(fec05)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/fec0563e)
|
||||
- **projects**:
|
||||
- 移除未使用代码 - by **AN** [<samp>(d141e)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/d141ed5b)
|
||||
- update deps & fix `moduleResolution` - by @soybeanjs [<samp>(dbd99)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/dbd995c1)
|
||||
|
||||
### 🎨 样式
|
||||
|
||||
- **projects**:
|
||||
- 更换 logo 与加载样式 - by @m-xlsea [<samp>(7e4ec)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/7e4ecae6)
|
||||
- 重构登录页样式 - by @m-xlsea [<samp>(40680)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/406800de)
|
||||
- 修改按钮文本颜色 - by @m-xlsea [<samp>(907f0)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/907f0439)
|
||||
- 优化移动端字体大小 - by @m-xlsea [<samp>(8b4e4)</samp>](https://gitee.com/xlsea/ruoyi-plus-soybean/commit/8b4e41ce)
|
||||
|
||||
### ❤️ 贡献者
|
||||
|
||||
[](https://gitee.com/xlsea) [](https://github.com/soybeanjs) [](https://github.com/wenyuanw) [](https://gitee.com/elio-an) [](https://github.com/chen-ziwen)
|
||||
[](https://gitee.com/wangzhongqi0917) [](https://gitee.com/qq1822213252) [](https://gitee.com/tangzc), [metabytes](https://gitee.com/metabytes)
|
||||
|
||||
|
||||
## [v1.0.0](https://gitee.com/xlsea/ruoyi-plus-soybean/releases/tag/v1.0.0) (2025-06-05)
|
||||
|
||||
### 🚀 新功能
|
||||
|
102
README.md
102
README.md
@ -1,5 +1,6 @@
|
||||
<div align="center">
|
||||
<img src="https://foruda.gitee.com/images/1679673773341074847/178e8451_1766278.png" width="50%" height="50%">
|
||||
<div align="center">
|
||||
<img src="https://docs.ruoyi.xlsea.cn/logo.svg" width="160">
|
||||
<h1>RuoYi-Plus-Soybean</h1>
|
||||
</div>
|
||||
|
||||
<div style="height: 10px; clear: both;"></div>
|
||||
@ -7,21 +8,28 @@
|
||||
<div align="center">
|
||||
<p>一个基于 <a href="https://gitee.com/dromara/RuoYi-Vue-Plus" target="_blank">RuoYi-Vue-Plus</a> 的后端能力和 <a href="https://github.com/soybeanjs/soybean-admin" target="_blank">Soybean Admin</a> 前端特性的现代化多租户管理系统</p>
|
||||
<p>
|
||||
<img src="https://gitee.com/xlsea/ruoyi-plus-soybean/badge/star.svg" alt="Gitee">
|
||||
<img src="https://img.shields.io/github/stars/m-xlsea/ruoyi-plus-soybean" alt="Github">
|
||||
<img src="https://img.shields.io/badge/Vue-3.5-brightgreen" alt="vue">
|
||||
<img src="https://img.shields.io/badge/TypeScript-5.8-blue" alt="typescript">
|
||||
<img src="https://img.shields.io/badge/Vite-6.2-orange" alt="vite">
|
||||
<img src="https://img.shields.io/badge/NaiveUI-2.41-purple" alt="naive-ui">
|
||||
<img src="https://img.shields.io/badge/License-MIT-yellow" alt="license">
|
||||
<a href="https://gitee.com/xlsea/ruoyi-plus-soybean" target="_blank"><img src="https://gitee.com/xlsea/ruoyi-plus-soybean/badge/star.svg" alt="Gitee"></a>
|
||||
<a href="https://gitee.com/dromara/RuoYi-Vue-Plus" target="_blank"><img src="https://gitee.com/xlsea/ruoyi-plus-soybean/badge/fork.svg" alt="Gitee-forks"></a>
|
||||
<a href="https://github.com/m-xlsea/ruoyi-plus-soybean" target="_blank"><img src="https://img.shields.io/github/stars/m-xlsea/ruoyi-plus-soybean" alt="Github"></a>
|
||||
<a href="https://github.com/m-xlsea/ruoyi-plus-soybean" target="_blank"><img src="https://img.shields.io/github/forks/m-xlsea/ruoyi-plus-soybean" alt="Github-forks"></a>
|
||||
<a href="https://vuejs.org" target="_blank"><img src="https://img.shields.io/badge/Vue-3.5-brightgreen" alt="vue"></a>
|
||||
<a href="https://www.typescriptlang.org" target="_blank"><img src="https://img.shields.io/badge/TypeScript-5.8-blue" alt="typescript"></a>
|
||||
<a href="https://vite.dev" target="_blank"><img src="https://img.shields.io/badge/Vite-6.2-orange" alt="vite"></a>
|
||||
<a href="https://www.naiveui.com" target="_blank"><img src="https://img.shields.io/badge/NaiveUI-2.41-purple" alt="naive-ui"></a>
|
||||
<a href="./LICENSE" target="_blank"><img src="https://img.shields.io/badge/License-MIT-yellow" alt="license"></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
# 📢 重要通知
|
||||
|
||||
<p style="color: red; font-weight: bold; font-size: 24px;">该项目未首发公测版本,请谨慎在生产环境使用!!!</p>
|
||||
<p style="color: red; font-weight: bold; font-size: 24px;">该项目未首发公测版本,请谨慎在生产环境使用!!!</p>
|
||||
<p style="color: red; font-weight: bold; font-size: 24px;">该项目未首发公测版本,请谨慎在生产环境使用!!!</p>
|
||||
1.1.2 版本已经正式发布,工作流版本迎来首个版本(请切换 [flow](https://gitee.com/xlsea/ruoyi-plus-soybean/tree/flow/) 分支查看),但仍然建议:
|
||||
- 在生产环境使用前进行充分测试
|
||||
- 关注项目更新,及时获取最新版本
|
||||
- 积极反馈问题,帮助我们快速迭代
|
||||
|
||||
**后续规划**
|
||||
- 多语言国际化完善
|
||||
- 性能优化和稳定性提升
|
||||
|
||||
> 如果对该项目感兴趣,可以给一个 Star 支持一下,谢谢!
|
||||
> 请大家踊跃提交 PR 和 Issue,一起完善这个项目
|
||||
@ -110,8 +118,8 @@ root
|
||||
## 🚀 环境要求与安装
|
||||
|
||||
### 环境要求
|
||||
- Node.js >= 18.20.0
|
||||
- pnpm >= 8.7.0
|
||||
- Node.js >= 20.19.0
|
||||
- pnpm >= 10.5.0
|
||||
- Git
|
||||
|
||||
### 安装步骤及说明
|
||||
@ -340,6 +348,8 @@ console.log(t('common.confirm'));
|
||||
- [RuoYi-Vue-Plus](https://gitee.com/dromara/RuoYi-Vue-Plus) - 后端基础框架
|
||||
- [Soybean Admin](https://github.com/soybeanjs/soybean-admin) - 前端基础框架
|
||||
- [RuoYi-Plus-Soybean](https://ruoyi.xlsea.cn) - 官方演示站点
|
||||
- [RuoYi-Plus-Soybean-Docs](https://docs.ruoyi.xlsea.cn) - 项目文档
|
||||
- [Open Hives](https://openhives.com/questions) - OpenHives 问答社区
|
||||
|
||||
## 📮 联系方式
|
||||
|
||||
@ -354,7 +364,15 @@ console.log(t('common.confirm'));
|
||||
|
||||
## 💬 交流群
|
||||
|
||||
<img src="https://foruda.gitee.com/images/1748404753216665472/3d8b1a0b_5601833.png" width="300px" />
|
||||
**加群前请先阅读一下内容:**
|
||||
|
||||
- 禁止内容:黄腔、暴力言论、政治话题,违者直接飞机票(踢出群)
|
||||
- 遇到问题请先阅读 [项目文档](https://docs.ruoyi.xlsea.cn) 和 [Soybean 文档](https://docs.soybeanjs.cn/),某些简单问题不予理睬
|
||||
- 蜡笔小新头像为机器人助手,私聊不保证回复,问题请在群内讨论
|
||||
|
||||
<img src="https://foruda.gitee.com/images/1749174520085305975/ad1b54fe_5601833.png" width="300px" />
|
||||
|
||||
添加作者微信备注:加群
|
||||
|
||||
## 🧧 捐献作者
|
||||
|
||||
@ -364,56 +382,4 @@ console.log(t('common.confirm'));
|
||||
|
||||
## 🫡 捐赠列表
|
||||
|
||||
感谢下方各位老板的捐赠 🫡
|
||||
|
||||
> 如果不想出现在下方捐赠列表,请在备注中说明,我会匿名处理
|
||||
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<img style="border-radius: 50%;" src="https://foruda.gitee.com/images/1747030503230216352/6879cbe5_5601833.jpeg" width="24px" >
|
||||
<span>酷酷冬天 20元</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<img style="border-radius: 50%;" src="https://foruda.gitee.com/images/1747105640574377313/6d259bba_5601833.jpeg" width="24px" >
|
||||
<span>Selfish Altruism(JackSue) 200元</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<img style="border-radius: 50%;" src="https://foruda.gitee.com/images/1747190127964232140/5ffa5ac4_5601833.jpeg" width="24px" >
|
||||
<span>匿名用户 50元</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<img style="border-radius: 50%;" src="https://foruda.gitee.com/images/1747190127964232140/5ffa5ac4_5601833.jpeg" width="24px" >
|
||||
<span>匿名用户 10元</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<img style="border-radius: 50%;" src="https://foruda.gitee.com/images/1747280244120267391/6d719481_5601833.jpeg" width="24px" >
|
||||
<span>DAS 20元</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<img style="border-radius: 50%;" src="https://foruda.gitee.com/images/1747739790990961771/57f4b208_5601833.jpeg" width="24px" >
|
||||
<span>大山 100元</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<img style="border-radius: 50%;" src="https://foruda.gitee.com/images/1747742188121789563/646dff1c_5601833.jpeg" width="24px" >
|
||||
<span>依依 20元</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<img style="border-radius: 50%;" src="https://foruda.gitee.com/images/1747742593880284419/5b56043d_5601833.jpeg" width="24px" >
|
||||
<span>沙海 20元</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<img style="border-radius: 50%;" src="https://foruda.gitee.com/images/1747789196227712891/00c37bdf_5601833.jpeg" width="24px" >
|
||||
<span>xxl 50元</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<img style="border-radius: 50%;" src="https://foruda.gitee.com/images/1747796468040874363/1faa75ce_5601833.jpeg" width="24px" >
|
||||
<span>莫离支🤴 10元</span>
|
||||
</div>
|
||||
**捐赠列表已移至 [捐赠列表](https://docs.ruoyi.xlsea.cn/other/donate.html)**
|
||||
|
@ -73,8 +73,9 @@ public class VelocityUtils {
|
||||
velocityContext.put("permissionPrefix", getPermissionPrefix(moduleName, businessName));
|
||||
velocityContext.put("dicts", getDicts(genTable));
|
||||
velocityContext.put("dictList", getDictList(genTable));
|
||||
velocityContext.put("columns", getColumns(genTable));
|
||||
velocityContext.put("columns", genTable.getColumns());
|
||||
velocityContext.put("table", genTable);
|
||||
velocityContext.put("StrUtil", new StrUtil());
|
||||
setMenuVelocityContext(velocityContext, genTable);
|
||||
if (GenConstants.TPL_TREE.equals(tplCategory)) {
|
||||
setTreeVelocityContext(velocityContext, genTable);
|
||||
@ -189,9 +190,9 @@ public class VelocityUtils {
|
||||
} else if (template.contains("index-tree.vue.vm")) {
|
||||
fileName = StringUtils.format("{}/views/{}/{}/index.vue", soybeanPath, moduleName, StrUtil.toSymbolCase(businessName, '-'));
|
||||
} else if (template.contains("api.d.ts.vm")) {
|
||||
fileName = StringUtils.format("{}/typings/api/{}.api.d.ts", soybeanPath, moduleName);
|
||||
fileName = StringUtils.format("{}/typings/api/{}.{}.api.d.ts", soybeanPath, moduleName, StrUtil.toSymbolCase(businessName, '-'));
|
||||
} else if (template.contains("api.ts.vm")) {
|
||||
fileName = StringUtils.format("{}/api/{}/{}.ts", soybeanPath, moduleName, StrUtil.toSymbolCase(businessName, '-'));
|
||||
fileName = StringUtils.format("{}/service/api/{}/{}.ts", soybeanPath, moduleName, StrUtil.toSymbolCase(businessName, '-'));
|
||||
} else if (template.contains("search.vue.vm")) {
|
||||
fileName = StringUtils.format("{}/views/{}/{}/modules/{}-search.vue", soybeanPath, moduleName, StrUtil.toSymbolCase(businessName, '-'), StrUtil.toSymbolCase(businessName, '-'));
|
||||
} else if (template.contains("operate-drawer.vue.vm")) {
|
||||
@ -296,23 +297,6 @@ public class VelocityUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据列类型获取字典组
|
||||
*
|
||||
* @param genTable 业务表对象
|
||||
* @return 返回字典组
|
||||
*/
|
||||
public static List<GenTableColumn> getColumns(GenTable genTable) {
|
||||
List<GenTableColumn> columns = genTable.getColumns();
|
||||
for (GenTableColumn column : columns) {
|
||||
if (StringUtils.isNotBlank(column.getDictType())) {
|
||||
column.setDictType(StringUtils.toCamelCase(column.getDictType()));
|
||||
}
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取权限前缀
|
||||
*
|
||||
|
59
docs/sql/sys_dict_data.sql
Normal file
59
docs/sql/sys_dict_data.sql
Normal file
@ -0,0 +1,59 @@
|
||||
-- 修改字典数据表的 list_class 字段,将 danger 改为 error
|
||||
UPDATE `sys_dict_data` SET `list_class` = 'error' WHERE `list_class` = 'danger';
|
||||
|
||||
-- 字典适配多语言
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_user_sex.male', `dict_type` = 'sys_user_sex' WHERE `dict_code` = 1;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_user_sex.female', `dict_type` = 'sys_user_sex' WHERE `dict_code` = 2;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_user_sex.unknown', `dict_type` = 'sys_user_sex' WHERE `dict_code` = 3;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_show_hide.show', `dict_type` = 'sys_show_hide' WHERE `dict_code` = 4;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_show_hide.hide', `dict_type` = 'sys_show_hide' WHERE `dict_code` = 5;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_normal_disable.normal', `dict_type` = 'sys_normal_disable' WHERE `dict_code` = 6;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_normal_disable.disable', `dict_type` = 'sys_normal_disable' WHERE `dict_code` = 7;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_yes_no.yes', `dict_type` = 'sys_yes_no' WHERE `dict_code` = 12;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_yes_no.no', `dict_type` = 'sys_yes_no' WHERE `dict_code` = 13;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_notice_type.notice', `dict_type` = 'sys_notice_type' WHERE `dict_code` = 14;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_notice_type.announcement', `dict_type` = 'sys_notice_type' WHERE `dict_code` = 15;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_notice_status.normal', `dict_type` = 'sys_notice_status' WHERE `dict_code` = 16;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_notice_status.close', `dict_type` = 'sys_notice_status' WHERE `dict_code` = 17;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_oper_type.insert', `dict_type` = 'sys_oper_type' WHERE `dict_code` = 18;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_oper_type.update', `dict_type` = 'sys_oper_type' WHERE `dict_code` = 19;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_oper_type.delete', `dict_type` = 'sys_oper_type' WHERE `dict_code` = 20;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_oper_type.grant', `dict_type` = 'sys_oper_type' WHERE `dict_code` = 21;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_oper_type.export', `dict_type` = 'sys_oper_type' WHERE `dict_code` = 22;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_oper_type.import', `dict_type` = 'sys_oper_type' WHERE `dict_code` = 23;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_oper_type.force', `dict_type` = 'sys_oper_type' WHERE `dict_code` = 24;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_oper_type.gencode', `dict_type` = 'sys_oper_type' WHERE `dict_code` = 25;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_oper_type.clean', `dict_type` = 'sys_oper_type' WHERE `dict_code` = 26;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_common_status.success', `dict_type` = 'sys_common_status' WHERE `dict_code` = 27;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_common_status.fail', `dict_type` = 'sys_common_status' WHERE `dict_code` = 28;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_oper_type.other', `dict_type` = 'sys_oper_type' WHERE `dict_code` = 29;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_grant_type.password', `dict_type` = 'sys_grant_type' WHERE `dict_code` = 30;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_grant_type.sms', `dict_type` = 'sys_grant_type' WHERE `dict_code` = 31;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_grant_type.email', `dict_type` = 'sys_grant_type' WHERE `dict_code` = 32;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_grant_type.miniapp', `dict_type` = 'sys_grant_type' WHERE `dict_code` = 33;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_grant_type.social', `dict_type` = 'sys_grant_type' WHERE `dict_code` = 34;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_device_type.pc', `dict_type` = 'sys_device_type' WHERE `dict_code` = 35;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_device_type.android', `dict_type` = 'sys_device_type' WHERE `dict_code` = 36;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_device_type.ios', `dict_type` = 'sys_device_type' WHERE `dict_code` = 37;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.sys_device_type.miniapp', `dict_type` = 'sys_device_type' WHERE `dict_code` = 38;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_business_status.revoked', `dict_type` = 'wf_business_status' WHERE `dict_code` = 39;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_business_status.draft', `dict_type` = 'wf_business_status' WHERE `dict_code` = 40;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_business_status.pending', `dict_type` = 'wf_business_status' WHERE `dict_code` = 41;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_business_status.completed', `dict_type` = 'wf_business_status' WHERE `dict_code` = 42;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_business_status.cancelled', `dict_type` = 'wf_business_status' WHERE `dict_code` = 43;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_business_status.returned', `dict_type` = 'wf_business_status' WHERE `dict_code` = 44;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_business_status.terminated', `dict_type` = 'wf_business_status' WHERE `dict_code` = 45;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_form_type.custom_form', `dict_type` = 'wf_form_type' WHERE `dict_code` = 46;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_form_type.dynamic_form', `dict_type` = 'wf_form_type' WHERE `dict_code` = 47;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.revoke', `dict_type` = 'wf_task_status' WHERE `dict_code` = 48;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.pass', `dict_type` = 'wf_task_status' WHERE `dict_code` = 49;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.pending_review', `dict_type` = 'wf_task_status' WHERE `dict_code` = 50;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.cancel', `dict_type` = 'wf_task_status' WHERE `dict_code` = 51;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.return', `dict_type` = 'wf_task_status' WHERE `dict_code` = 52;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.terminate', `dict_type` = 'wf_task_status' WHERE `dict_code` = 53;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.transfer', `dict_type` = 'wf_task_status' WHERE `dict_code` = 54;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.delegate', `dict_type` = 'wf_task_status' WHERE `dict_code` = 55;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.copy', `dict_type` = 'wf_task_status' WHERE `dict_code` = 56;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.add_sign', `dict_type` = 'wf_task_status' WHERE `dict_code` = 57;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.minus_sign', `dict_type` = 'wf_task_status' WHERE `dict_code` = 58;
|
||||
UPDATE `sys_dict_data` SET `dict_label` = 'dict.wf_task_status.timeout', `dict_type` = 'wf_task_status' WHERE `dict_code` = 59;
|
@ -1,38 +1,38 @@
|
||||
-- 目录类型菜单
|
||||
UPDATE `sys_menu` SET `component` = 'Layout', `icon` = 'carbon:cloud-service-management' WHERE `menu_id` = 1;
|
||||
UPDATE `sys_menu` SET `component` = 'Layout', `icon` = 'stash:dashboard' WHERE `menu_id` = 2;
|
||||
UPDATE `sys_menu` SET `component` = 'Layout', `icon` = 'tabler:tools' WHERE `menu_id` = 3;
|
||||
UPDATE `sys_menu` SET `component` = 'Layout', `icon` = 'material-symbols:kid-star-outline' WHERE `menu_id` = 5;
|
||||
UPDATE `sys_menu` SET `component` = 'Layout', `icon` = 'tabler:building-cog' WHERE `menu_id` = 6;
|
||||
UPDATE `sys_menu` SET `component` = 'Layout', `icon` = 'tabler:logs' WHERE `menu_id` = 108;
|
||||
UPDATE `sys_menu` SET `component` = 'Layout', `icon` = 'carbon:cloud-service-management', `menu_name` = 'route.system' WHERE `menu_id` = 1;
|
||||
UPDATE `sys_menu` SET `component` = 'Layout', `icon` = 'stash:dashboard', `menu_name` = 'route.monitor' WHERE `menu_id` = 2;
|
||||
UPDATE `sys_menu` SET `component` = 'Layout', `icon` = 'tabler:tools', `menu_name` = 'route.tool' WHERE `menu_id` = 3;
|
||||
UPDATE `sys_menu` SET `component` = 'Layout', `icon` = 'material-symbols:kid-star-outline', `menu_name` = 'route.demo' WHERE `menu_id` = 5;
|
||||
UPDATE `sys_menu` SET `component` = 'Layout', `icon` = 'tabler:building-cog', `menu_name` = 'menu.system_tenant' WHERE `menu_id` = 6;
|
||||
UPDATE `sys_menu` SET `component` = 'Layout', `icon` = 'tabler:logs', `menu_name` = 'menu.system_log' WHERE `menu_id` = 108;
|
||||
|
||||
-- 页面类型
|
||||
UPDATE `sys_menu` SET `icon` = 'ic:round-manage-accounts' WHERE `menu_id` = 100;
|
||||
UPDATE `sys_menu` SET `icon` = 'carbon:user-role' WHERE `menu_id` = 101;
|
||||
UPDATE `sys_menu` SET `icon` = 'material-symbols:route' WHERE `menu_id` = 102;
|
||||
UPDATE `sys_menu` SET `icon` = 'mingcute:department-line' WHERE `menu_id` = 103;
|
||||
UPDATE `sys_menu` SET `icon` = 'hugeicons:permanent-job' WHERE `menu_id` = 104;
|
||||
UPDATE `sys_menu` SET `icon` = 'qlementine-icons:dictionary-16' WHERE `menu_id` = 105;
|
||||
UPDATE `sys_menu` SET `icon` = 'carbon:parameter' WHERE `menu_id` = 106;
|
||||
UPDATE `sys_menu` SET `icon` = 'solar:chat-line-outline' WHERE `menu_id` = 107;
|
||||
UPDATE `sys_menu` SET `icon` = 'majesticons:status-online-line' WHERE `menu_id` = 109;
|
||||
UPDATE `sys_menu` SET `icon` = 'simple-icons:redis' WHERE `menu_id` = 113;
|
||||
UPDATE `sys_menu` SET `icon` = 'material-symbols:code-blocks-outline' WHERE `menu_id` = 115;
|
||||
UPDATE `sys_menu` SET `icon` = 'material-symbols:attach-file' WHERE `menu_id` = 118;
|
||||
UPDATE `sys_menu` SET `icon` = 'tabler:building-skyscraper' WHERE `menu_id` = 121;
|
||||
UPDATE `sys_menu` SET `icon` = 'lets-icons:package-box-alt' WHERE `menu_id` = 122;
|
||||
UPDATE `sys_menu` SET `icon` = 'tabler:device-imac-cog' WHERE `menu_id` = 123;
|
||||
UPDATE `sys_menu` SET `icon` = 'carbon:operations-record' WHERE `menu_id` = 500;
|
||||
UPDATE `sys_menu` SET `icon` = 'tabler:login-2' WHERE `menu_id` = 501;
|
||||
UPDATE `sys_menu` SET `icon` = 'gg:debug' WHERE `menu_id` = 1500;
|
||||
UPDATE `sys_menu` SET `icon` = 'gg:debug' WHERE `menu_id` = 1506;
|
||||
UPDATE `sys_menu` SET `path` = 'oss/config', `component` = 'system/oss-config/index', `icon` = 'hugeicons:configuration-01' WHERE `menu_id` = 133;
|
||||
UPDATE `sys_menu` SET `icon` = 'ic:round-manage-accounts', `menu_name` = 'route.system_user' WHERE `menu_id` = 100;
|
||||
UPDATE `sys_menu` SET `icon` = 'carbon:user-role', `menu_name` = 'route.system_role' WHERE `menu_id` = 101;
|
||||
UPDATE `sys_menu` SET `icon` = 'material-symbols:route', `menu_name` = 'route.system_menu' WHERE `menu_id` = 102;
|
||||
UPDATE `sys_menu` SET `icon` = 'mingcute:department-line', `menu_name` = 'route.system_dept' WHERE `menu_id` = 103;
|
||||
UPDATE `sys_menu` SET `icon` = 'hugeicons:permanent-job', `menu_name` = 'route.system_post' WHERE `menu_id` = 104;
|
||||
UPDATE `sys_menu` SET `icon` = 'qlementine-icons:dictionary-16', `menu_name` = 'route.system_dict' WHERE `menu_id` = 105;
|
||||
UPDATE `sys_menu` SET `icon` = 'carbon:parameter', `menu_name` = 'route.system_config' WHERE `menu_id` = 106;
|
||||
UPDATE `sys_menu` SET `icon` = 'solar:chat-line-outline', `menu_name` = 'route.system_notice' WHERE `menu_id` = 107;
|
||||
UPDATE `sys_menu` SET `icon` = 'majesticons:status-online-line', `menu_name` = 'route.monitor_online' WHERE `menu_id` = 109;
|
||||
UPDATE `sys_menu` SET `icon` = 'simple-icons:redis', `menu_name` = 'route.monitor_cache' WHERE `menu_id` = 113;
|
||||
UPDATE `sys_menu` SET `icon` = 'material-symbols:code-blocks-outline', `menu_name` = 'route.tool_gen' WHERE `menu_id` = 115;
|
||||
UPDATE `sys_menu` SET `icon` = 'material-symbols:attach-file', `menu_name` = 'route.system_oss' WHERE `menu_id` = 118;
|
||||
UPDATE `sys_menu` SET `icon` = 'tabler:building-skyscraper', `menu_name` = 'route.system_tenant' WHERE `menu_id` = 121;
|
||||
UPDATE `sys_menu` SET `icon` = 'lets-icons:package-box-alt', `menu_name` = 'route.system_tenant-package' WHERE `menu_id` = 122;
|
||||
UPDATE `sys_menu` SET `icon` = 'tabler:device-imac-cog', `menu_name` = 'route.system_client' WHERE `menu_id` = 123;
|
||||
UPDATE `sys_menu` SET `icon` = 'carbon:operations-record', `menu_name` = 'route.monitor_operlog' WHERE `menu_id` = 500;
|
||||
UPDATE `sys_menu` SET `icon` = 'tabler:login-2', `menu_name` = 'route.monitor_logininfor' WHERE `menu_id` = 501;
|
||||
UPDATE `sys_menu` SET `icon` = 'gg:debug', `menu_name` = 'route.demo_demo' WHERE `menu_id` = 1500;
|
||||
UPDATE `sys_menu` SET `icon` = 'gg:debug', `menu_name` = 'route.demo_tree' WHERE `menu_id` = 1506;
|
||||
UPDATE `sys_menu` SET `path` = 'oss/config', `component` = 'system/oss-config/index', `icon` = 'hugeicons:configuration-01', `menu_name` = 'route.system_oss-config' WHERE `menu_id` = 133;
|
||||
|
||||
-- IFrame 类型
|
||||
UPDATE `sys_menu` SET `component` = 'FrameView', `query_param` = 'https://ruoyi.xlsea.cn/admin/', `is_frame` = 2, `icon` = 'bx:bxl-spring-boot' WHERE `menu_id` = 117;
|
||||
UPDATE `sys_menu` SET `component` = 'FrameView', `query_param` = 'https://preview.snailjob.opensnail.com/', `is_frame` = 2, `icon` = 'gridicons:scheduled' WHERE `menu_id` = 120;
|
||||
UPDATE `sys_menu` SET `component` = 'FrameView', `query_param` = 'https://ruoyi.xlsea.cn/admin/', `is_frame` = 2, `icon` = 'bx:bxl-spring-boot', `menu_name` = 'menu.monitor_admin' WHERE `menu_id` = 117;
|
||||
UPDATE `sys_menu` SET `component` = 'FrameView', `query_param` = 'https://preview.snailjob.opensnail.com/', `is_frame` = 2, `icon` = 'gridicons:scheduled', `menu_name` = 'menu.monitor_snail-job' WHERE `menu_id` = 120;
|
||||
-- 外链类型
|
||||
UPDATE `sys_menu` SET `path` = 'https://gitee.com/xlsea/ruoyi-plus-soybean', `component` = 'FrameView', `icon` = 'local-icon-gitee' WHERE `menu_id` = 4;
|
||||
UPDATE `sys_menu` SET `path` = 'https://gitee.com/xlsea/ruoyi-plus-soybean', `component` = 'FrameView', `icon` = 'local-icon-gitee', `menu_name` = 'RuoYi-Plus-Soybean' WHERE `menu_id` = 4;
|
||||
|
||||
-- plus-ui 需要禁用的页面
|
||||
UPDATE `sys_menu` SET `status` = '1' WHERE `menu_id` IN ( '116', '130', '131', '132', '11700', '11701' );
|
||||
|
2
docs/template/index-tree.vue.vm
vendored
2
docs/template/index-tree.vue.vm
vendored
@ -2,7 +2,7 @@
|
||||
import { NDivider } from 'naive-ui';
|
||||
import { jsonClone } from '@sa/utils';
|
||||
import { type TableDataWithIndex } from '@sa/hooks';
|
||||
import { fetchBatchDelete${BusinessName}, fetchGet${BusinessName}List } from '@/service/api/${moduleName}/${businessName}';
|
||||
import { fetchBatchDelete${BusinessName}, fetchGet${BusinessName}List } from '@/service/api/${moduleName}/${business__name}';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
import { useTreeTable, useTreeTableOperate } from '@/hooks/common/tree-table';
|
||||
|
2
docs/template/index.vue.vm
vendored
2
docs/template/index.vue.vm
vendored
@ -1,6 +1,6 @@
|
||||
<script setup lang="tsx">
|
||||
import { NDivider } from 'naive-ui';
|
||||
import { fetchBatchDelete${BusinessName}, fetchGet${BusinessName}List } from '@/service/api/${moduleName}/${businessName}';
|
||||
import { fetchBatchDelete${BusinessName}, fetchGet${BusinessName}List } from '@/service/api/${moduleName}/${business__name}';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
import { useDownload } from '@/hooks/business/download';
|
||||
|
49
docs/template/modules/operate-drawer.vue.vm
vendored
49
docs/template/modules/operate-drawer.vue.vm
vendored
@ -52,7 +52,7 @@ function createDefaultModel(): Model {
|
||||
return {
|
||||
#foreach($column in $columns)
|
||||
#if($column.insert)
|
||||
${column.javaField}:#if($column.javaType == 'String') ''#else null#end#if($foreach.hasNext),#end
|
||||
${column.javaField}:#if($column.javaType == 'String' || ($!column.dictType && $column.dictType != '')) ''#else undefined#end#if($foreach.hasNext),#end
|
||||
#end
|
||||
#end
|
||||
};
|
||||
@ -91,16 +91,22 @@ function closeDrawer() {
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
#set($operateColumns = [])
|
||||
#foreach($column in $columns)#if($column.insert || $column.edit)#set($dummy = $operateColumns.add($column))#end#end
|
||||
const { #foreach($column in $operateColumns)$column.javaField#if($foreach.hasNext), #end#end } = model;
|
||||
|
||||
// request
|
||||
if (props.operateType === 'add') {
|
||||
const { #foreach($column in $columns)#if($column.insert)$column.javaField#if($foreach.hasNext), #end#end#end } = model;
|
||||
const { error } = await fetchCreate${BusinessName}({ #foreach($column in $columns)#if($column.insert)$column.javaField#if($foreach.hasNext), #end#end#end });
|
||||
#set($addFields = [])
|
||||
#foreach($column in $columns)#if($column.insert)#set($dummy = $addFields.add($column.javaField))#end#end
|
||||
const { error } = await fetchCreate${BusinessName}({ #foreach($field in $addFields)$field#if($foreach.hasNext), #end#end });
|
||||
if (error) return;
|
||||
}
|
||||
|
||||
if (props.operateType === 'edit') {
|
||||
const { #foreach($column in $columns)#if($column.edit)$column.javaField#if($foreach.hasNext), #end#end#end } = model;
|
||||
const { error } = await fetchUpdate${BusinessName}({ #foreach($column in $columns)#if($column.edit)$column.javaField#if($foreach.hasNext), #end#end#end });
|
||||
#set($editFields = [])
|
||||
#foreach($column in $columns)#if($column.edit)#set($dummy = $editFields.add($column.javaField))#end#end
|
||||
const { error } = await fetchUpdate${BusinessName}({ #foreach($field in $editFields)$field#if($foreach.hasNext), #end#end });
|
||||
if (error) return;
|
||||
}
|
||||
|
||||
@ -130,7 +136,7 @@ watch(visible, () => {
|
||||
#else
|
||||
#set($comment=$column.columnComment)
|
||||
#end
|
||||
#set($dictType=$column.dictType)
|
||||
#set($dictType=$!StrUtil.toCamelCase($column.dictType))
|
||||
<NFormItem label="$column.columnComment" path="$column.javaField">
|
||||
#if($column.htmlType == "textarea" || $column.htmlType == "editor")
|
||||
<NInput
|
||||
@ -143,7 +149,7 @@ watch(visible, () => {
|
||||
<NSelect
|
||||
v-model:value="model.$column.javaField"
|
||||
placeholder="请选择$column.columnComment"
|
||||
:options="${column.dictType}Options"
|
||||
:options="${dictType}Options"
|
||||
clearable
|
||||
/>
|
||||
#elseif($column.htmlType == "select" && $dictType)
|
||||
@ -157,7 +163,7 @@ watch(visible, () => {
|
||||
<NRadioGroup v-model:value="model.$column.javaField">
|
||||
<NSpace>
|
||||
<NRadio
|
||||
v-for="option in ${column.dictType}Options"
|
||||
v-for="option in ${dictType}Options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
@ -170,13 +176,36 @@ watch(visible, () => {
|
||||
<NRadio value="0" label="请选择字典生成" />
|
||||
</NSpace>
|
||||
</NRadioGroup>
|
||||
#elseif($column.htmlType == "datetime")
|
||||
#elseif($column.htmlType == "checkbox" && "" != $dictType)
|
||||
<NCheckboxGroup v-model:value="model.$column.javaField">
|
||||
<NSpace>
|
||||
<NCheckbox
|
||||
v-for="option in ${dictType}Options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
:label="option.label"
|
||||
/>
|
||||
</NSpace>
|
||||
</NCheckboxGroup>
|
||||
#elseif($column.htmlType == "checkbox" && $dictType)
|
||||
<NCheckboxGroup v-model:value="model.$column.javaField">
|
||||
<NSpace>
|
||||
<NCheckbox value="0" label="请选择字典生成" />
|
||||
</NSpace>
|
||||
</NCheckboxGroup>
|
||||
#elseif($column.htmlType == 'datetime')
|
||||
<NDatePicker
|
||||
v-model:formatted-value="model.$column.javaField"
|
||||
type="date"
|
||||
type="datetime"
|
||||
value-format="yyyy-MM-dd HH:mm:ss"
|
||||
clearable
|
||||
/>
|
||||
#elseif($column.htmlType == "imageUpload")
|
||||
<OssUpload v-model:value="model.$column.javaField" upload-type="image" />
|
||||
#elseif($column.htmlType == "fileUpload")
|
||||
<OssUpload v-model:value="model.$column.javaField" upload-type="file" />
|
||||
#elseif($column.htmlType == "editor")
|
||||
<TinymceEditor v-model:value="model.$column.javaField" />
|
||||
#else <NInput v-model:value="model.$column.javaField" placeholder="请输入$column.columnComment" />
|
||||
#end
|
||||
</NFormItem>
|
||||
|
25
docs/template/modules/search.vue.vm
vendored
25
docs/template/modules/search.vue.vm
vendored
@ -50,7 +50,7 @@ async function search() {
|
||||
#set($AttrName=$column.javaField.substring(0,1).toUpperCase() + ${column.javaField.substring(1)})
|
||||
if (dateRange${AttrName}.value?.length) {
|
||||
model.value.params!.begin${AttrName} = dateRange${AttrName}.value[0];
|
||||
model.value.params!.end${AttrName} = dateRange${AttrName}.value[0];
|
||||
model.value.params!.end${AttrName} = dateRange${AttrName}.value[1];
|
||||
}
|
||||
#end
|
||||
#end
|
||||
@ -66,7 +66,7 @@ async function search() {
|
||||
<NGrid responsive="screen" item-responsive>
|
||||
#foreach($column in $columns)
|
||||
#if($column.query)
|
||||
#set($dictType=$column.dictType)
|
||||
#set($dictType=$!StrUtil.toCamelCase($column.dictType))
|
||||
#set($AttrName=$column.javaField.substring(0,1).toUpperCase() + ${column.javaField.substring(1)})
|
||||
#set($parentheseIndex=$column.columnComment.indexOf("("))
|
||||
#if($parentheseIndex != -1)
|
||||
@ -75,35 +75,28 @@ async function search() {
|
||||
#set($comment=$column.columnComment)
|
||||
#end
|
||||
<NFormItemGi span="24 s:12 m:6" label="$column.columnComment" path="$column.javaField" class="pr-24px">
|
||||
#if(($column.htmlType == "select" || $column.htmlType == "radio") && "" != $dictType)
|
||||
#if($!StrUtil.contains("select, radio, checkbox", $column.htmlType) && $dictType && "" != $dictType)
|
||||
<NSelect
|
||||
v-model:value="model.$column.javaField"
|
||||
placeholder="请选择$column.columnComment"
|
||||
:options="${column.dictType}Options"
|
||||
:options="${dictType}Options"
|
||||
clearable
|
||||
/>
|
||||
#elseif(($column.htmlType == "select" || $column.htmlType == "radio") && $dictType)
|
||||
#elseif($!StrUtil.contains("select, radio, checkbox", $column.htmlType))
|
||||
<NSelect
|
||||
v-model:value="model.$column.javaField"
|
||||
placeholder="请选择$column.columnComment"
|
||||
:options="[]"
|
||||
clearable
|
||||
/>
|
||||
#elseif($column.htmlType.equals('select') || $column.htmlType.equals('radio'))
|
||||
<NSelect
|
||||
v-model:value="model.$column.javaField"
|
||||
placeholder="请选择$column.columnComment"
|
||||
:options="[]"
|
||||
clearable
|
||||
/>
|
||||
#elseif($column.htmlType == "datetime" && $column.queryType != "BETWEEN")
|
||||
#elseif($column.htmlType == 'datetime' && $column.queryType != "BETWEEN")
|
||||
<NDatePicker
|
||||
v-model:formatted-value="model.$column.javaField"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
type="datetime"
|
||||
value-format="yyyy-MM-dd HH:mm:ss"
|
||||
clearable
|
||||
/>
|
||||
#elseif($column.htmlType == "datetime" && $column.queryType == "BETWEEN")
|
||||
#elseif($column.htmlType == 'datetime' && $column.queryType == "BETWEEN")
|
||||
<NDatePicker
|
||||
v-model:formatted-value="dateRange${AttrName}"
|
||||
type="datetimerange"
|
||||
|
77
docs/template/typings/api.d.ts.vm
vendored
77
docs/template/typings/api.d.ts.vm
vendored
@ -1,44 +1,51 @@
|
||||
#set($BaseEntity = ['createDept', 'createBy', 'createTime', 'updateBy', 'updateTime'])
|
||||
#set($ModuleName = $moduleName.substring(0, 1).toUpperCase() + $moduleName.substring(1))
|
||||
/**
|
||||
* namespace ${ModuleName}
|
||||
* Namespace Api
|
||||
*
|
||||
* backend api module: "${ModuleName}"
|
||||
* All backend api type
|
||||
*/
|
||||
namespace ${ModuleName} {
|
||||
/** ${businessname} */
|
||||
type ${BusinessName} = Common.CommonRecord<{
|
||||
#foreach($column in $columns)#if(!$BaseEntity.contains($column.javaField))
|
||||
/** $column.columnComment */
|
||||
$column.javaField:#if($column.javaField.indexOf("id") != -1 || $column.javaField.indexOf("Id") != -1) CommonType.IdType; #elseif($column.javaType == 'Long' || $column.javaType == 'Integer' || $column.javaType == 'Double' || $column.javaType == 'Float' || $column.javaType == 'BigDecimal') number; #elseif($column.javaType == 'Boolean') boolean; #else string; #end
|
||||
#end#end
|
||||
}>;
|
||||
declare namespace Api {
|
||||
/**
|
||||
* namespace ${ModuleName}
|
||||
*
|
||||
* backend api module: "${ModuleName}"
|
||||
*/
|
||||
namespace ${ModuleName} {
|
||||
/** ${businessname} */
|
||||
type ${BusinessName} = Common.CommonRecord<{
|
||||
#foreach($column in $columns)#if(!$BaseEntity.contains($column.javaField))
|
||||
/** $column.columnComment */
|
||||
$column.javaField:#if($column.javaField.indexOf("id") != -1 || $column.javaField.indexOf("Id") != -1) CommonType.IdType; #elseif($column.javaType == 'Long' || $column.javaType == 'Integer' || $column.javaType == 'Double' || $column.javaType == 'Float' || $column.javaType == 'BigDecimal') number; #elseif($column.javaType == 'Boolean') boolean; #else string; #end
|
||||
#end#end
|
||||
}>;
|
||||
|
||||
/** ${businessname} search params */
|
||||
type ${BusinessName}SearchParams = CommonType.RecordNullable<
|
||||
Pick<
|
||||
Api.${ModuleName}.${BusinessName},
|
||||
#foreach($column in $columns)
|
||||
#if($column.query && $column.queryType != 'BETWEEN')
|
||||
| '${column.javaField}'
|
||||
#end
|
||||
#end
|
||||
> &
|
||||
Api.Common.CommonSearchParams
|
||||
>;
|
||||
/** ${businessname} search params */
|
||||
type ${BusinessName}SearchParams = CommonType.RecordNullable<
|
||||
Pick<
|
||||
Api.${ModuleName}.${BusinessName},
|
||||
#foreach($column in $columns)
|
||||
#if($column.query && $column.queryType != 'BETWEEN')
|
||||
| '${column.javaField}'
|
||||
#end
|
||||
#end
|
||||
> &
|
||||
Api.Common.CommonSearchParams
|
||||
>;
|
||||
|
||||
/** ${businessname} operate params */
|
||||
type ${BusinessName}OperateParams = CommonType.RecordNullable<
|
||||
Pick<
|
||||
Api.${ModuleName}.${BusinessName},
|
||||
#foreach($column in $columns)
|
||||
#if($column.insert || $column.edit)
|
||||
| '${column.javaField}'
|
||||
#end
|
||||
#end
|
||||
>
|
||||
>;
|
||||
/** ${businessname} operate params */
|
||||
type ${BusinessName}OperateParams = CommonType.RecordNullable<
|
||||
Pick<
|
||||
Api.${ModuleName}.${BusinessName},
|
||||
#foreach($column in $columns)
|
||||
#if($column.insert || $column.edit)
|
||||
| '${column.javaField}'
|
||||
#end
|
||||
#end
|
||||
>
|
||||
>;
|
||||
|
||||
/** ${businessname} list */
|
||||
type ${BusinessName}List = Api.Common.PaginatingQueryRecord<${BusinessName}>;
|
||||
/** ${businessname} list */
|
||||
type ${BusinessName}List = Api.Common.PaginatingQueryRecord<${BusinessName}>;
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,7 @@
|
||||
<html lang="zh-cmn-Hans">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>%VITE_APP_TITLE%</title>
|
||||
|
82
package.json
82
package.json
@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "ruoyi-vue-plus",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"description": "RuoYi-Vue-Plus多租户管理系统",
|
||||
"version": "1.1.2",
|
||||
"description": "结合了 RuoYi-Vue-Plus 的强大后端功能和 Soybean Admin 的现代化前端特性,为开发者提供了完整的企业管理解决方案。",
|
||||
"author": {
|
||||
"name": "xlsea",
|
||||
"email": "xlsea@linux.do",
|
||||
"url": "https://gitee.com/xlsea/ruoyi-plus-soybean"
|
||||
"email": "m@xlsea.cn",
|
||||
"url": "https://gitee.com/xlsea"
|
||||
},
|
||||
"license": "MIT",
|
||||
"homepage": "https://ruoyi.xlsea.cn",
|
||||
"homepage": "https://docs.ruoyi.xlsea.cn",
|
||||
"repository": {
|
||||
"url": "https://gitee.com/xlsea/ruoyi-plus-soybean.git"
|
||||
},
|
||||
@ -18,17 +18,25 @@
|
||||
},
|
||||
"keywords": [
|
||||
"RuoYi-Vue-Plus",
|
||||
"Soybean Admin",
|
||||
"Vue3 admin ",
|
||||
"vue-admin-template",
|
||||
"Vite6",
|
||||
"Vite7",
|
||||
"TypeScript",
|
||||
"naive-ui",
|
||||
"naive-ui-admin",
|
||||
"UnoCSS"
|
||||
],
|
||||
"contributors": [
|
||||
{
|
||||
"name": "Elio",
|
||||
"email": "1983933789@qq.com",
|
||||
"url": "https://gitee.com/elio-an"
|
||||
}
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18.20.0",
|
||||
"pnpm": ">=8.7.0"
|
||||
"node": ">=20.19.0",
|
||||
"pnpm": ">=10.5.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build --mode prod",
|
||||
@ -57,7 +65,8 @@
|
||||
"@sa/materials": "workspace:*",
|
||||
"@sa/tinymce": "workspace:*",
|
||||
"@sa/utils": "workspace:*",
|
||||
"@vueuse/core": "13.1.0",
|
||||
"@types/streamsaver": "^2.0.5",
|
||||
"@vueuse/core": "13.5.0",
|
||||
"clipboard": "2.0.11",
|
||||
"dayjs": "1.11.13",
|
||||
"defu": "6.1.4",
|
||||
@ -65,51 +74,52 @@
|
||||
"highlight.js": "^11.11.1",
|
||||
"jsencrypt": "^3.3.2",
|
||||
"json5": "2.2.3",
|
||||
"monaco-editor": "^0.52.0",
|
||||
"naive-ui": "2.41.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"naive-ui": "2.42.0",
|
||||
"nprogress": "0.2.0",
|
||||
"pinia": "3.0.2",
|
||||
"tailwind-merge": "3.2.0",
|
||||
"vue": "3.5.13",
|
||||
"pinia": "3.0.3",
|
||||
"streamsaver": "^2.0.6",
|
||||
"tailwind-merge": "3.3.1",
|
||||
"vue": "3.5.17",
|
||||
"vue-advanced-cropper": "^2.8.9",
|
||||
"vue-draggable-plus": "0.6.0",
|
||||
"vue-i18n": "11.1.3",
|
||||
"vue-i18n": "11.1.10",
|
||||
"vue-router": "4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@elegant-router/vue": "0.3.8",
|
||||
"@iconify/json": "2.2.337",
|
||||
"@iconify/json": "2.2.359",
|
||||
"@sa/scripts": "workspace:*",
|
||||
"@sa/uno-preset": "workspace:*",
|
||||
"@soybeanjs/eslint-config": "1.6.0",
|
||||
"@types/node": "22.15.17",
|
||||
"@soybeanjs/eslint-config": "1.7.1",
|
||||
"@types/node": "24.0.15",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@unocss/eslint-config": "66.1.1",
|
||||
"@unocss/preset-icons": "66.1.1",
|
||||
"@unocss/preset-uno": "66.1.1",
|
||||
"@unocss/transformer-directives": "66.1.1",
|
||||
"@unocss/transformer-variant-group": "66.1.1",
|
||||
"@unocss/vite": "66.1.1",
|
||||
"@vitejs/plugin-vue": "5.2.4",
|
||||
"@vitejs/plugin-vue-jsx": "4.1.2",
|
||||
"@unocss/eslint-config": "66.3.3",
|
||||
"@unocss/preset-icons": "66.3.3",
|
||||
"@unocss/preset-uno": "66.3.3",
|
||||
"@unocss/transformer-directives": "66.3.3",
|
||||
"@unocss/transformer-variant-group": "66.3.3",
|
||||
"@unocss/vite": "66.3.3",
|
||||
"@vitejs/plugin-vue": "6.0.0",
|
||||
"@vitejs/plugin-vue-jsx": "5.0.1",
|
||||
"consola": "3.4.2",
|
||||
"eslint": "9.26.0",
|
||||
"eslint-plugin-vue": "10.1.0",
|
||||
"eslint": "9.31.0",
|
||||
"eslint-plugin-vue": "10.3.0",
|
||||
"kolorist": "1.8.0",
|
||||
"sass": "1.88.0",
|
||||
"sass": "1.89.2",
|
||||
"simple-git-hooks": "2.13.0",
|
||||
"tsx": "4.19.4",
|
||||
"tsx": "4.20.3",
|
||||
"typescript": "5.8.3",
|
||||
"unplugin-icons": "22.1.0",
|
||||
"unplugin-vue-components": "28.5.0",
|
||||
"vite": "6.3.5",
|
||||
"unplugin-vue-components": "28.8.0",
|
||||
"vite": "7.0.5",
|
||||
"vite-plugin-monaco-editor": "^1.1.0",
|
||||
"vite-plugin-progress": "0.0.7",
|
||||
"vite-plugin-static-copy": "^3.0.0",
|
||||
"vite-plugin-static-copy": "^3.1.0",
|
||||
"vite-plugin-svg-icons": "2.0.1",
|
||||
"vite-plugin-vue-devtools": "7.7.6",
|
||||
"vue-eslint-parser": "10.1.3",
|
||||
"vue-tsc": "2.2.10"
|
||||
"vite-plugin-vue-devtools": "7.7.7",
|
||||
"vue-eslint-parser": "10.2.0",
|
||||
"vue-tsc": "3.0.3"
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
"commit-msg": "pnpm sa git-commit-verify",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sa/alova",
|
||||
"version": "1.3.13",
|
||||
"version": "1.3.15",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./fetch": "./src/fetch.ts",
|
||||
@ -13,8 +13,8 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@alova/mock": "2.0.14",
|
||||
"@alova/mock": "2.0.17",
|
||||
"@sa/utils": "workspace:*",
|
||||
"alova": "3.2.10"
|
||||
"alova": "3.3.4"
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sa/axios",
|
||||
"version": "1.3.13",
|
||||
"version": "1.3.15",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
@ -11,11 +11,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@sa/utils": "workspace:*",
|
||||
"axios": "1.9.0",
|
||||
"axios": "1.10.0",
|
||||
"axios-retry": "4.5.0",
|
||||
"qs": "6.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qs": "6.9.18"
|
||||
"@types/qs": "6.14.0"
|
||||
}
|
||||
}
|
||||
|
@ -13,11 +13,12 @@ import type {
|
||||
ResponseType
|
||||
} from './type';
|
||||
|
||||
function createCommonRequest<ResponseData = any>(
|
||||
axiosConfig?: CreateAxiosDefaults,
|
||||
options?: Partial<RequestOption<ResponseData>>
|
||||
) {
|
||||
const opts = createDefaultOptions<ResponseData>(options);
|
||||
function createCommonRequest<
|
||||
ResponseData,
|
||||
ApiData = ResponseData,
|
||||
State extends Record<string, unknown> = Record<string, unknown>
|
||||
>(axiosConfig?: CreateAxiosDefaults, options?: Partial<RequestOption<ResponseData, ApiData, State>>) {
|
||||
const opts = createDefaultOptions<ResponseData, ApiData, State>(options);
|
||||
|
||||
const axiosConf = createAxiosConfig(axiosConfig);
|
||||
const instance = axios.create(axiosConf);
|
||||
@ -80,14 +81,6 @@ function createCommonRequest<ResponseData = any>(
|
||||
}
|
||||
);
|
||||
|
||||
function cancelRequest(requestId: string) {
|
||||
const abortController = abortControllerMap.get(requestId);
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
abortControllerMap.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelAllRequest() {
|
||||
abortControllerMap.forEach(abortController => {
|
||||
abortController.abort();
|
||||
@ -98,7 +91,6 @@ function createCommonRequest<ResponseData = any>(
|
||||
return {
|
||||
instance,
|
||||
opts,
|
||||
cancelRequest,
|
||||
cancelAllRequest
|
||||
};
|
||||
}
|
||||
@ -109,27 +101,27 @@ function createCommonRequest<ResponseData = any>(
|
||||
* @param axiosConfig axios config
|
||||
* @param options request options
|
||||
*/
|
||||
export function createRequest<ResponseData = any, State = Record<string, unknown>>(
|
||||
export function createRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
|
||||
axiosConfig?: CreateAxiosDefaults,
|
||||
options?: Partial<RequestOption<ResponseData>>
|
||||
options?: Partial<RequestOption<ResponseData, ApiData, State>>
|
||||
) {
|
||||
const { instance, opts, cancelRequest, cancelAllRequest } = createCommonRequest<ResponseData>(axiosConfig, options);
|
||||
const { instance, opts, cancelAllRequest } = createCommonRequest<ResponseData, ApiData, State>(axiosConfig, options);
|
||||
|
||||
const request: RequestInstance<State> = async function request<T = any, R extends ResponseType = 'json'>(
|
||||
config: CustomAxiosRequestConfig
|
||||
) {
|
||||
const request: RequestInstance<ApiData, State> = async function request<
|
||||
T extends ApiData = ApiData,
|
||||
R extends ResponseType = 'json'
|
||||
>(config: CustomAxiosRequestConfig) {
|
||||
const response: AxiosResponse<ResponseData> = await instance(config);
|
||||
|
||||
const responseType = response.config?.responseType || 'json';
|
||||
|
||||
if (responseType === 'json') {
|
||||
return opts.transformBackendResponse(response);
|
||||
return opts.transform(response);
|
||||
}
|
||||
|
||||
return response.data as MappedType<R, T>;
|
||||
} as RequestInstance<State>;
|
||||
} as RequestInstance<ApiData, State>;
|
||||
|
||||
request.cancelRequest = cancelRequest;
|
||||
request.cancelAllRequest = cancelAllRequest;
|
||||
request.state = {} as State;
|
||||
|
||||
@ -144,14 +136,14 @@ export function createRequest<ResponseData = any, State = Record<string, unknown
|
||||
* @param axiosConfig axios config
|
||||
* @param options request options
|
||||
*/
|
||||
export function createFlatRequest<ResponseData = any, State = Record<string, unknown>>(
|
||||
export function createFlatRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
|
||||
axiosConfig?: CreateAxiosDefaults,
|
||||
options?: Partial<RequestOption<ResponseData>>
|
||||
options?: Partial<RequestOption<ResponseData, ApiData, State>>
|
||||
) {
|
||||
const { instance, opts, cancelRequest, cancelAllRequest } = createCommonRequest<ResponseData>(axiosConfig, options);
|
||||
const { instance, opts, cancelAllRequest } = createCommonRequest<ResponseData, ApiData, State>(axiosConfig, options);
|
||||
|
||||
const flatRequest: FlatRequestInstance<State, ResponseData> = async function flatRequest<
|
||||
T = any,
|
||||
const flatRequest: FlatRequestInstance<ResponseData, ApiData, State> = async function flatRequest<
|
||||
T extends ApiData = ApiData,
|
||||
R extends ResponseType = 'json'
|
||||
>(config: CustomAxiosRequestConfig) {
|
||||
try {
|
||||
@ -160,20 +152,21 @@ export function createFlatRequest<ResponseData = any, State = Record<string, unk
|
||||
const responseType = response.config?.responseType || 'json';
|
||||
|
||||
if (responseType === 'json') {
|
||||
const data = opts.transformBackendResponse(response);
|
||||
const data = await opts.transform(response);
|
||||
|
||||
return { data, error: null, response };
|
||||
}
|
||||
|
||||
return { data: response.data as MappedType<R, T>, error: null };
|
||||
return { data: response.data as MappedType<R, T>, error: null, response };
|
||||
} catch (error) {
|
||||
return { data: null, error, response: (error as AxiosError<ResponseData>).response };
|
||||
}
|
||||
} as FlatRequestInstance<State, ResponseData>;
|
||||
} as FlatRequestInstance<ResponseData, ApiData, State>;
|
||||
|
||||
flatRequest.cancelRequest = cancelRequest;
|
||||
flatRequest.cancelAllRequest = cancelAllRequest;
|
||||
flatRequest.state = {} as State;
|
||||
flatRequest.state = {
|
||||
...opts.defaultState
|
||||
} as State;
|
||||
|
||||
return flatRequest;
|
||||
}
|
||||
|
@ -4,15 +4,27 @@ import { stringify } from 'qs';
|
||||
import { isHttpSuccess } from './shared';
|
||||
import type { RequestOption } from './type';
|
||||
|
||||
export function createDefaultOptions<ResponseData = any>(options?: Partial<RequestOption<ResponseData>>) {
|
||||
const opts: RequestOption<ResponseData> = {
|
||||
export function createDefaultOptions<
|
||||
ResponseData,
|
||||
ApiData = ResponseData,
|
||||
State extends Record<string, unknown> = Record<string, unknown>
|
||||
>(options?: Partial<RequestOption<ResponseData, ApiData, State>>) {
|
||||
const opts: RequestOption<ResponseData, ApiData, State> = {
|
||||
defaultState: {} as State,
|
||||
transform: async response => response.data as unknown as ApiData,
|
||||
transformBackendResponse: async response => response.data as unknown as ApiData,
|
||||
onRequest: async config => config,
|
||||
isBackendSuccess: _response => true,
|
||||
onBackendFail: async () => {},
|
||||
transformBackendResponse: async response => response.data,
|
||||
onError: async () => {}
|
||||
};
|
||||
|
||||
if (options?.transform) {
|
||||
opts.transform = options.transform;
|
||||
} else {
|
||||
opts.transform = options?.transformBackendResponse || opts.transform;
|
||||
}
|
||||
|
||||
Object.assign(opts, options);
|
||||
|
||||
return opts;
|
||||
|
@ -8,7 +8,30 @@ export type ContentType =
|
||||
| 'application/x-www-form-urlencoded'
|
||||
| 'application/octet-stream';
|
||||
|
||||
export interface RequestOption<ResponseData = any> {
|
||||
export type ResponseTransform<Input = any, Output = any> = (input: Input) => Output | Promise<Output>;
|
||||
|
||||
export interface RequestOption<
|
||||
ResponseData,
|
||||
ApiData = ResponseData,
|
||||
State extends Record<string, unknown> = Record<string, unknown>
|
||||
> {
|
||||
/**
|
||||
* The default state
|
||||
*/
|
||||
defaultState?: State;
|
||||
/**
|
||||
* transform the response data to the api data
|
||||
*
|
||||
* @param response Axios response
|
||||
*/
|
||||
transform: ResponseTransform<AxiosResponse<ResponseData>, ApiData>;
|
||||
/**
|
||||
* transform the response data to the api data
|
||||
*
|
||||
* @deprecated use `transform` instead, will be removed in the next major version v3
|
||||
* @param response Axios response
|
||||
*/
|
||||
transformBackendResponse: ResponseTransform<AxiosResponse<ResponseData>, ApiData>;
|
||||
/**
|
||||
* The hook before request
|
||||
*
|
||||
@ -35,12 +58,6 @@ export interface RequestOption<ResponseData = any> {
|
||||
response: AxiosResponse<ResponseData>,
|
||||
instance: AxiosInstance
|
||||
) => Promise<AxiosResponse | null> | Promise<void>;
|
||||
/**
|
||||
* transform backend response when the responseType is json
|
||||
*
|
||||
* @param response Axios response
|
||||
*/
|
||||
transformBackendResponse(response: AxiosResponse<ResponseData>): any | Promise<any>;
|
||||
/**
|
||||
* The hook to handle error
|
||||
*
|
||||
@ -68,15 +85,7 @@ export type CustomAxiosRequestConfig<R extends ResponseType = 'json'> = Omit<Axi
|
||||
responseType?: R;
|
||||
};
|
||||
|
||||
export interface RequestInstanceCommon<T> {
|
||||
/**
|
||||
* cancel the request by request id
|
||||
*
|
||||
* if the request provide abort controller sign from config, it will not collect in the abort controller map
|
||||
*
|
||||
* @param requestId
|
||||
*/
|
||||
cancelRequest: (requestId: string) => void;
|
||||
export interface RequestInstanceCommon<State extends Record<string, unknown>> {
|
||||
/**
|
||||
* cancel all request
|
||||
*
|
||||
@ -84,32 +93,35 @@ export interface RequestInstanceCommon<T> {
|
||||
*/
|
||||
cancelAllRequest: () => void;
|
||||
/** you can set custom state in the request instance */
|
||||
state: T;
|
||||
state: State;
|
||||
}
|
||||
|
||||
/** The request instance */
|
||||
export interface RequestInstance<S = Record<string, unknown>> extends RequestInstanceCommon<S> {
|
||||
<T = any, R extends ResponseType = 'json'>(config: CustomAxiosRequestConfig<R>): Promise<MappedType<R, T>>;
|
||||
export interface RequestInstance<ApiData, State extends Record<string, unknown>> extends RequestInstanceCommon<State> {
|
||||
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
|
||||
config: CustomAxiosRequestConfig<R>
|
||||
): Promise<MappedType<R, T>>;
|
||||
}
|
||||
|
||||
export type FlatResponseSuccessData<T = any, ResponseData = any> = {
|
||||
data: T;
|
||||
export type FlatResponseSuccessData<ResponseData, ApiData> = {
|
||||
data: ApiData;
|
||||
error: null;
|
||||
response: AxiosResponse<ResponseData>;
|
||||
};
|
||||
|
||||
export type FlatResponseFailData<ResponseData = any> = {
|
||||
export type FlatResponseFailData<ResponseData> = {
|
||||
data: null;
|
||||
error: AxiosError<ResponseData>;
|
||||
response: AxiosResponse<ResponseData>;
|
||||
};
|
||||
|
||||
export type FlatResponseData<T = any, ResponseData = any> =
|
||||
| FlatResponseSuccessData<T, ResponseData>
|
||||
export type FlatResponseData<ResponseData, ApiData> =
|
||||
| FlatResponseSuccessData<ResponseData, ApiData>
|
||||
| FlatResponseFailData<ResponseData>;
|
||||
|
||||
export interface FlatRequestInstance<S = Record<string, unknown>, ResponseData = any> extends RequestInstanceCommon<S> {
|
||||
<T = any, R extends ResponseType = 'json'>(
|
||||
export interface FlatRequestInstance<ResponseData, ApiData, State extends Record<string, unknown>>
|
||||
extends RequestInstanceCommon<State> {
|
||||
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
|
||||
config: CustomAxiosRequestConfig<R>
|
||||
): Promise<FlatResponseData<MappedType<R, T>, ResponseData>>;
|
||||
): Promise<FlatResponseData<ResponseData, MappedType<R, T>>>;
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sa/color",
|
||||
"version": "1.3.13",
|
||||
"version": "1.3.15",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sa/hooks",
|
||||
"version": "1.3.13",
|
||||
"version": "1.3.15",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
|
@ -3,9 +3,7 @@ import useLoading from './use-loading';
|
||||
import useCountDown from './use-count-down';
|
||||
import useContext from './use-context';
|
||||
import useSvgIconRender from './use-svg-icon-render';
|
||||
import useHookTable from './use-table';
|
||||
import useTable from './use-table';
|
||||
|
||||
export { useBoolean, useLoading, useCountDown, useContext, useSvgIconRender, useHookTable };
|
||||
|
||||
export * from './use-signal';
|
||||
export * from './use-table';
|
||||
export { useBoolean, useLoading, useCountDown, useContext, useSvgIconRender, useTable };
|
||||
export type * from './use-table';
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { inject, provide } from 'vue';
|
||||
import type { InjectionKey } from 'vue';
|
||||
|
||||
/**
|
||||
* Use context
|
||||
@ -12,7 +11,7 @@ import type { InjectionKey } from 'vue';
|
||||
* import { ref } from 'vue';
|
||||
* import { useContext } from '@sa/hooks';
|
||||
*
|
||||
* export const { setupStore, useStore } = useContext('demo', () => {
|
||||
* export const [provideDemoContext, useDemoContext] = useContext('demo', () => {
|
||||
* const count = ref(0);
|
||||
*
|
||||
* function increment() {
|
||||
@ -35,10 +34,10 @@ import type { InjectionKey } from 'vue';
|
||||
* <div>A</div>
|
||||
* </template>
|
||||
* <script setup lang="ts">
|
||||
* import { setupStore } from './context';
|
||||
* import { provideDemoContext } from './context';
|
||||
*
|
||||
* setupStore();
|
||||
* // const { increment } = setupStore(); // also can control the store in the parent component
|
||||
* provideDemoContext();
|
||||
* // const { increment } = provideDemoContext(); // also can control the store in the parent component
|
||||
* </script>
|
||||
* ``` // B.vue
|
||||
* ```vue
|
||||
@ -46,9 +45,9 @@ import type { InjectionKey } from 'vue';
|
||||
* <div>B</div>
|
||||
* </template>
|
||||
* <script setup lang="ts">
|
||||
* import { useStore } from './context';
|
||||
* import { useDemoContext } from './context';
|
||||
*
|
||||
* const { count, increment } = useStore();
|
||||
* const { count, increment } = useDemoContext();
|
||||
* </script>
|
||||
* ```;
|
||||
*
|
||||
@ -57,40 +56,41 @@ import type { InjectionKey } from 'vue';
|
||||
* @param contextName Context name
|
||||
* @param fn Context function
|
||||
*/
|
||||
export default function useContext<T extends (...args: any[]) => any>(contextName: string, fn: T) {
|
||||
type Context = ReturnType<T>;
|
||||
export default function useContext<Arguments extends Array<any>, T>(
|
||||
contextName: string,
|
||||
composable: (...args: Arguments) => T
|
||||
) {
|
||||
const key = Symbol(contextName);
|
||||
|
||||
const { useProvide, useInject: useStore } = createContext<Context>(contextName);
|
||||
/**
|
||||
* Injects the context value.
|
||||
*
|
||||
* @param consumerName - The name of the component that is consuming the context. If provided, the component must be
|
||||
* used within the context provider.
|
||||
* @param defaultValue - The default value to return if the context is not provided.
|
||||
* @returns The context value.
|
||||
*/
|
||||
const useInject = <N extends string | null | undefined = undefined>(
|
||||
consumerName?: N,
|
||||
defaultValue?: T
|
||||
): N extends null | undefined ? T | null : T => {
|
||||
const value = inject(key, defaultValue);
|
||||
|
||||
function setupStore(...args: Parameters<T>) {
|
||||
const context: Context = fn(...args);
|
||||
return useProvide(context);
|
||||
}
|
||||
if (consumerName && !value) {
|
||||
throw new Error(`\`${consumerName}\` must be used within \`${contextName}\``);
|
||||
}
|
||||
|
||||
return {
|
||||
/** Setup store in the parent component */
|
||||
setupStore,
|
||||
/** Use store in the child component */
|
||||
useStore
|
||||
// @ts-expect-error - we want to return null if the value is undefined or null
|
||||
return value || null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Create context */
|
||||
function createContext<T>(contextName: string) {
|
||||
const injectKey: InjectionKey<T> = Symbol(contextName);
|
||||
const useProvide = (...args: Arguments) => {
|
||||
const value = composable(...args);
|
||||
|
||||
function useProvide(context: T) {
|
||||
provide(injectKey, context);
|
||||
provide(key, value);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function useInject() {
|
||||
return inject(injectKey) as T;
|
||||
}
|
||||
|
||||
return {
|
||||
useProvide,
|
||||
useInject
|
||||
return value;
|
||||
};
|
||||
|
||||
return [useProvide, useInject] as const;
|
||||
}
|
||||
|
@ -6,31 +6,31 @@ import type {
|
||||
CreateAxiosDefaults,
|
||||
CustomAxiosRequestConfig,
|
||||
MappedType,
|
||||
RequestInstanceCommon,
|
||||
RequestOption,
|
||||
ResponseType
|
||||
} from '@sa/axios';
|
||||
import useLoading from './use-loading';
|
||||
|
||||
export type HookRequestInstanceResponseSuccessData<T = any> = {
|
||||
data: Ref<T>;
|
||||
export type HookRequestInstanceResponseSuccessData<ApiData> = {
|
||||
data: Ref<ApiData>;
|
||||
error: Ref<null>;
|
||||
};
|
||||
|
||||
export type HookRequestInstanceResponseFailData<ResponseData = any> = {
|
||||
export type HookRequestInstanceResponseFailData<ResponseData> = {
|
||||
data: Ref<null>;
|
||||
error: Ref<AxiosError<ResponseData>>;
|
||||
};
|
||||
|
||||
export type HookRequestInstanceResponseData<T = any, ResponseData = any> = {
|
||||
export type HookRequestInstanceResponseData<ResponseData, ApiData> = {
|
||||
loading: Ref<boolean>;
|
||||
} & (HookRequestInstanceResponseSuccessData<T> | HookRequestInstanceResponseFailData<ResponseData>);
|
||||
} & (HookRequestInstanceResponseSuccessData<ApiData> | HookRequestInstanceResponseFailData<ResponseData>);
|
||||
|
||||
export interface HookRequestInstance<ResponseData = any> {
|
||||
<T = any, R extends ResponseType = 'json'>(
|
||||
export interface HookRequestInstance<ResponseData, ApiData, State extends Record<string, unknown>>
|
||||
extends RequestInstanceCommon<State> {
|
||||
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
|
||||
config: CustomAxiosRequestConfig
|
||||
): HookRequestInstanceResponseData<MappedType<R, T>, ResponseData>;
|
||||
cancelRequest: (requestId: string) => void;
|
||||
cancelAllRequest: () => void;
|
||||
): HookRequestInstanceResponseData<ResponseData, MappedType<R, T>>;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -39,25 +39,26 @@ export interface HookRequestInstance<ResponseData = any> {
|
||||
* @param axiosConfig
|
||||
* @param options
|
||||
*/
|
||||
export default function createHookRequest<ResponseData = any>(
|
||||
export default function createHookRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
|
||||
axiosConfig?: CreateAxiosDefaults,
|
||||
options?: Partial<RequestOption<ResponseData>>
|
||||
options?: Partial<RequestOption<ResponseData, ApiData, State>>
|
||||
) {
|
||||
const request = createFlatRequest<ResponseData>(axiosConfig, options);
|
||||
const request = createFlatRequest<ResponseData, ApiData, State>(axiosConfig, options);
|
||||
|
||||
const hookRequest: HookRequestInstance<ResponseData> = function hookRequest<T = any, R extends ResponseType = 'json'>(
|
||||
config: CustomAxiosRequestConfig
|
||||
) {
|
||||
const hookRequest: HookRequestInstance<ResponseData, ApiData, State> = function hookRequest<
|
||||
T extends ApiData = ApiData,
|
||||
R extends ResponseType = 'json'
|
||||
>(config: CustomAxiosRequestConfig) {
|
||||
const { loading, startLoading, endLoading } = useLoading();
|
||||
|
||||
const data = ref<MappedType<R, T> | null>(null) as Ref<MappedType<R, T>>;
|
||||
const error = ref<AxiosError<ResponseData> | null>(null) as Ref<AxiosError<ResponseData> | null>;
|
||||
const data = ref(null) as Ref<MappedType<R, T>>;
|
||||
const error = ref(null) as Ref<AxiosError<ResponseData> | null>;
|
||||
|
||||
startLoading();
|
||||
|
||||
request(config).then(res => {
|
||||
if (res.data) {
|
||||
data.value = res.data;
|
||||
data.value = res.data as MappedType<R, T>;
|
||||
} else {
|
||||
error.value = res.error;
|
||||
}
|
||||
@ -70,9 +71,8 @@ export default function createHookRequest<ResponseData = any>(
|
||||
data,
|
||||
error
|
||||
};
|
||||
} as HookRequestInstance<ResponseData>;
|
||||
} as HookRequestInstance<ResponseData, ApiData, State>;
|
||||
|
||||
hookRequest.cancelRequest = request.cancelRequest;
|
||||
hookRequest.cancelAllRequest = request.cancelAllRequest;
|
||||
|
||||
return hookRequest;
|
||||
|
@ -1,144 +0,0 @@
|
||||
import { computed, ref, shallowRef, triggerRef } from 'vue';
|
||||
import type {
|
||||
ComputedGetter,
|
||||
DebuggerOptions,
|
||||
Ref,
|
||||
ShallowRef,
|
||||
WritableComputedOptions,
|
||||
WritableComputedRef
|
||||
} from 'vue';
|
||||
|
||||
type Updater<T> = (value: T) => T;
|
||||
type Mutator<T> = (value: T) => void;
|
||||
|
||||
/**
|
||||
* Signal is a reactive value that can be set, updated or mutated
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const count = useSignal(0);
|
||||
*
|
||||
* // `watchEffect`
|
||||
* watchEffect(() => {
|
||||
* console.log(count());
|
||||
* });
|
||||
*
|
||||
* // watch
|
||||
* watch(count, value => {
|
||||
* console.log(value);
|
||||
* });
|
||||
*
|
||||
* // useComputed
|
||||
* const double = useComputed(() => count() * 2);
|
||||
* const writeableDouble = useComputed({
|
||||
* get: () => count() * 2,
|
||||
* set: value => count.set(value / 2)
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export interface Signal<T> {
|
||||
(): Readonly<T>;
|
||||
/**
|
||||
* Set the value of the signal
|
||||
*
|
||||
* It recommend use `set` for primitive values
|
||||
*
|
||||
* @param value
|
||||
*/
|
||||
set(value: T): void;
|
||||
/**
|
||||
* Update the value of the signal using an updater function
|
||||
*
|
||||
* It recommend use `update` for non-primitive values, only the first level of the object will be reactive.
|
||||
*
|
||||
* @param updater
|
||||
*/
|
||||
update(updater: Updater<T>): void;
|
||||
/**
|
||||
* Mutate the value of the signal using a mutator function
|
||||
*
|
||||
* this action will call `triggerRef`, so the value will be tracked on `watchEffect`.
|
||||
*
|
||||
* It recommend use `mutate` for non-primitive values, all levels of the object will be reactive.
|
||||
*
|
||||
* @param mutator
|
||||
*/
|
||||
mutate(mutator: Mutator<T>): void;
|
||||
/**
|
||||
* Get the reference of the signal
|
||||
*
|
||||
* Sometimes it can be useful to make `v-model` work with the signal
|
||||
*
|
||||
* ```vue
|
||||
* <template>
|
||||
* <input v-model="model.count" />
|
||||
* </template>;
|
||||
*
|
||||
* <script setup lang="ts">
|
||||
* const state = useSignal({ count: 0 }, { useRef: true });
|
||||
*
|
||||
* const model = state.getRef();
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
getRef(): Readonly<ShallowRef<Readonly<T>>>;
|
||||
}
|
||||
|
||||
export interface ReadonlySignal<T> {
|
||||
(): Readonly<T>;
|
||||
}
|
||||
|
||||
export interface SignalOptions {
|
||||
/**
|
||||
* Whether to use `ref` to store the value
|
||||
*
|
||||
* @default false use `sharedRef` to store the value
|
||||
*/
|
||||
useRef?: boolean;
|
||||
}
|
||||
|
||||
export function useSignal<T>(initialValue: T, options?: SignalOptions): Signal<T> {
|
||||
const { useRef } = options || {};
|
||||
|
||||
const state = useRef ? (ref(initialValue) as Ref<T>) : shallowRef(initialValue);
|
||||
|
||||
return createSignal(state);
|
||||
}
|
||||
|
||||
export function useComputed<T>(getter: ComputedGetter<T>, debugOptions?: DebuggerOptions): ReadonlySignal<T>;
|
||||
export function useComputed<T>(options: WritableComputedOptions<T>, debugOptions?: DebuggerOptions): Signal<T>;
|
||||
export function useComputed<T>(
|
||||
getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>,
|
||||
debugOptions?: DebuggerOptions
|
||||
) {
|
||||
const isGetter = typeof getterOrOptions === 'function';
|
||||
|
||||
const computedValue = computed(getterOrOptions as any, debugOptions);
|
||||
|
||||
if (isGetter) {
|
||||
return () => computedValue.value as ReadonlySignal<T>;
|
||||
}
|
||||
|
||||
return createSignal(computedValue);
|
||||
}
|
||||
|
||||
function createSignal<T>(state: ShallowRef<T> | WritableComputedRef<T>): Signal<T> {
|
||||
const signal = () => state.value;
|
||||
|
||||
signal.set = (value: T) => {
|
||||
state.value = value;
|
||||
};
|
||||
|
||||
signal.update = (updater: Updater<T>) => {
|
||||
state.value = updater(state.value);
|
||||
};
|
||||
|
||||
signal.mutate = (mutator: Mutator<T>) => {
|
||||
mutator(state.value);
|
||||
triggerRef(state);
|
||||
};
|
||||
|
||||
signal.getRef = () => state as Readonly<ShallowRef<Readonly<T>>>;
|
||||
|
||||
return signal;
|
||||
}
|
@ -1,12 +1,20 @@
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import type { Ref, VNodeChild } from 'vue';
|
||||
import { jsonClone } from '@sa/utils';
|
||||
import useBoolean from './use-boolean';
|
||||
import useLoading from './use-loading';
|
||||
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
export interface PaginationData<T> {
|
||||
data: T[];
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export type ApiFn = (args: any) => Promise<unknown>;
|
||||
type GetApiData<ApiData, Pagination extends boolean> = Pagination extends true ? PaginationData<ApiData> : ApiData[];
|
||||
|
||||
type Transform<ResponseData, ApiData, Pagination extends boolean> = (
|
||||
response: ResponseData
|
||||
) => GetApiData<ApiData, Pagination>;
|
||||
|
||||
export type TableColumnCheckTitle = string | ((...args: any) => VNodeChild);
|
||||
|
||||
@ -14,74 +22,64 @@ export type TableColumnCheck = {
|
||||
key: string;
|
||||
title: TableColumnCheckTitle;
|
||||
checked: boolean;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export type TableDataWithIndex<T> = T & { index: number };
|
||||
|
||||
export type TransformedData<T> = {
|
||||
data: TableDataWithIndex<T>[];
|
||||
total?: number;
|
||||
};
|
||||
|
||||
export type Transformer<T, Response> = (response: Response) => TransformedData<T>;
|
||||
|
||||
export type TableConfig<A extends ApiFn, T, C> = {
|
||||
/** api function to get table data */
|
||||
apiFn: A;
|
||||
/** api params */
|
||||
apiParams?: Parameters<A>[0];
|
||||
/** transform api response to table data */
|
||||
transformer: Transformer<T, Awaited<ReturnType<A>>>;
|
||||
/** columns factory */
|
||||
columns: () => C[];
|
||||
export interface UseTableOptions<ResponseData, ApiData, Column, Pagination extends boolean> {
|
||||
/**
|
||||
* api function to get table data
|
||||
*/
|
||||
api: () => Promise<ResponseData>;
|
||||
/**
|
||||
* whether to enable pagination
|
||||
*/
|
||||
pagination?: Pagination;
|
||||
/**
|
||||
* transform api response to table data
|
||||
*/
|
||||
transform: Transform<ResponseData, ApiData, Pagination>;
|
||||
/**
|
||||
* columns factory
|
||||
*/
|
||||
columns: () => Column[];
|
||||
/**
|
||||
* get column checks
|
||||
*
|
||||
* @param columns
|
||||
*/
|
||||
getColumnChecks: (columns: C[]) => TableColumnCheck[];
|
||||
getColumnChecks: (columns: Column[]) => TableColumnCheck[];
|
||||
/**
|
||||
* get columns
|
||||
*
|
||||
* @param columns
|
||||
*/
|
||||
getColumns: (columns: C[], checks: TableColumnCheck[]) => C[];
|
||||
getColumns: (columns: Column[], checks: TableColumnCheck[]) => Column[];
|
||||
/**
|
||||
* callback when response fetched
|
||||
*
|
||||
* @param transformed transformed data
|
||||
*/
|
||||
onFetched?: (transformed: TransformedData<T>) => MaybePromise<void>;
|
||||
onFetched?: (data: GetApiData<ApiData, Pagination>) => void | Promise<void>;
|
||||
/**
|
||||
* whether to get data immediately
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
immediate?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export default function useHookTable<A extends ApiFn, T, C>(config: TableConfig<A, T, C>) {
|
||||
export default function useTable<ResponseData, ApiData, Column, Pagination extends boolean>(
|
||||
options: UseTableOptions<ResponseData, ApiData, Column, Pagination>
|
||||
) {
|
||||
const { loading, startLoading, endLoading } = useLoading();
|
||||
const { bool: empty, setBool: setEmpty } = useBoolean();
|
||||
|
||||
const { apiFn, apiParams, transformer, immediate = true, getColumnChecks, getColumns } = config;
|
||||
const { api, pagination, transform, columns, getColumnChecks, getColumns, onFetched, immediate = true } = options;
|
||||
|
||||
const searchParams: NonNullable<Parameters<A>[0]> = reactive(jsonClone({ ...apiParams }));
|
||||
const data = ref([]) as Ref<ApiData[]>;
|
||||
|
||||
const allColumns = ref(config.columns()) as Ref<C[]>;
|
||||
const columnChecks = ref(getColumnChecks(columns())) as Ref<TableColumnCheck[]>;
|
||||
|
||||
const data: Ref<TableDataWithIndex<T>[]> = ref([]);
|
||||
|
||||
const columnChecks: Ref<TableColumnCheck[]> = ref(getColumnChecks(config.columns()));
|
||||
|
||||
const columns = computed(() => getColumns(allColumns.value, columnChecks.value));
|
||||
const $columns = computed(() => getColumns(columns(), columnChecks.value));
|
||||
|
||||
function reloadColumns() {
|
||||
allColumns.value = config.columns();
|
||||
|
||||
const checkMap = new Map(columnChecks.value.map(col => [col.key, col.checked]));
|
||||
|
||||
const defaultChecks = getColumnChecks(allColumns.value);
|
||||
const defaultChecks = getColumnChecks(columns());
|
||||
|
||||
columnChecks.value = defaultChecks.map(col => ({
|
||||
...col,
|
||||
@ -90,48 +88,21 @@ export default function useHookTable<A extends ApiFn, T, C>(config: TableConfig<
|
||||
}
|
||||
|
||||
async function getData() {
|
||||
startLoading();
|
||||
try {
|
||||
startLoading();
|
||||
|
||||
const formattedParams = formatSearchParams(searchParams);
|
||||
const response = await api();
|
||||
|
||||
const response = await apiFn(formattedParams);
|
||||
const transformed = transform(response);
|
||||
|
||||
const transformed = transformer(response as Awaited<ReturnType<A>>);
|
||||
data.value = getTableData(transformed, pagination);
|
||||
|
||||
data.value = transformed.data;
|
||||
setEmpty(data.value.length === 0);
|
||||
|
||||
setEmpty(transformed.data.length === 0);
|
||||
|
||||
await config.onFetched?.(transformed);
|
||||
|
||||
endLoading();
|
||||
}
|
||||
|
||||
function formatSearchParams(params: Record<string, unknown>) {
|
||||
const formattedParams: Record<string, unknown> = {};
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
formattedParams[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return formattedParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* update search params
|
||||
*
|
||||
* @param params
|
||||
*/
|
||||
function updateSearchParams(params: Partial<Parameters<A>[0]>) {
|
||||
Object.assign(searchParams, params);
|
||||
}
|
||||
|
||||
/** reset search params */
|
||||
function resetSearchParams() {
|
||||
Object.assign(searchParams, jsonClone(apiParams));
|
||||
getData();
|
||||
await onFetched?.(transformed);
|
||||
} finally {
|
||||
endLoading();
|
||||
}
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
@ -142,12 +113,20 @@ export default function useHookTable<A extends ApiFn, T, C>(config: TableConfig<
|
||||
loading,
|
||||
empty,
|
||||
data,
|
||||
columns,
|
||||
columns: $columns,
|
||||
columnChecks,
|
||||
reloadColumns,
|
||||
getData,
|
||||
searchParams,
|
||||
updateSearchParams,
|
||||
resetSearchParams
|
||||
getData
|
||||
};
|
||||
}
|
||||
|
||||
function getTableData<ApiData, Pagination extends boolean>(
|
||||
data: GetApiData<ApiData, Pagination>,
|
||||
pagination?: Pagination
|
||||
) {
|
||||
if (pagination) {
|
||||
return (data as PaginationData<ApiData>).data;
|
||||
}
|
||||
|
||||
return data as ApiData[];
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sa/materials",
|
||||
"version": "1.3.13",
|
||||
"version": "1.3.15",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
@ -11,7 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@sa/utils": "workspace:*",
|
||||
"simplebar-vue": "2.4.1"
|
||||
"simplebar-vue": "2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typed-css-modules": "0.9.1"
|
||||
|
@ -127,7 +127,6 @@ function handleClickMask() {
|
||||
:class="[
|
||||
style['layout-header'],
|
||||
commonClass,
|
||||
headerClass,
|
||||
headerLeftGapClass,
|
||||
{ 'absolute top-0 left-0 w-full': fixedHeaderAndTab }
|
||||
]"
|
||||
|
@ -6,12 +6,6 @@ interface AdminLayoutHeaderConfig {
|
||||
* @default true
|
||||
*/
|
||||
headerVisible?: boolean;
|
||||
/**
|
||||
* Header class
|
||||
*
|
||||
* @default ''
|
||||
*/
|
||||
headerClass?: string;
|
||||
/**
|
||||
* Header height
|
||||
*
|
||||
|
@ -1,15 +0,0 @@
|
||||
{
|
||||
"name": "@sa/fetch",
|
||||
"version": "1.3.13",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"ofetch": "1.4.1"
|
||||
}
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
import { ofetch } from 'ofetch';
|
||||
import type { FetchOptions } from 'ofetch';
|
||||
|
||||
export function createRequest(options: FetchOptions) {
|
||||
const request = ofetch.create(options);
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
export default createRequest;
|
@ -1,20 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"jsx": "preserve",
|
||||
"lib": ["DOM", "ESNext"],
|
||||
"baseUrl": ".",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sa/scripts",
|
||||
"version": "1.3.13",
|
||||
"version": "1.3.15",
|
||||
"bin": {
|
||||
"sa": "./bin.ts"
|
||||
},
|
||||
@ -14,12 +14,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@soybeanjs/changelog": "0.3.24",
|
||||
"bumpp": "10.1.0",
|
||||
"c12": "3.0.3",
|
||||
"bumpp": "10.2.0",
|
||||
"c12": "3.1.0",
|
||||
"cac": "6.7.14",
|
||||
"consola": "3.4.2",
|
||||
"enquirer": "2.4.1",
|
||||
"execa": "9.5.3",
|
||||
"execa": "9.6.0",
|
||||
"kolorist": "1.8.0",
|
||||
"npm-check-updates": "18.0.1",
|
||||
"rimraf": "6.0.1"
|
||||
|
@ -5,7 +5,7 @@ import type { CliOption } from '../types';
|
||||
const defaultOptions: CliOption = {
|
||||
cwd: process.cwd(),
|
||||
cleanupDirs: [
|
||||
'**/dist',
|
||||
'dist',
|
||||
'**/package-lock.json',
|
||||
'**/yarn.lock',
|
||||
'**/pnpm-lock.yaml',
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sa/tinymce",
|
||||
"version": "1.3.13",
|
||||
"version": "1.3.15",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
@ -10,9 +10,9 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"tinymce": "7.8.0"
|
||||
"tinymce": "7.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tinymce/tinymce-vue": "6.1.0"
|
||||
"@tinymce/tinymce-vue": "6.2.0"
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sa/uno-preset",
|
||||
"version": "1.3.13",
|
||||
"version": "1.3.15",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sa/utils",
|
||||
"version": "1.3.13",
|
||||
"version": "1.3.15",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
|
@ -32,7 +32,8 @@ export function createStorage<T extends object>(type: StorageType, storagePrefix
|
||||
storageData = JSON.parse(json);
|
||||
} catch {}
|
||||
|
||||
if (storageData) {
|
||||
// storageData may be `false` if it is boolean type
|
||||
if (storageData !== null) {
|
||||
return storageData as T[K];
|
||||
}
|
||||
}
|
||||
|
4201
pnpm-lock.yaml
generated
4201
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Before Width: | Height: | Size: 124 KiB |
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 476.22 476.22">
|
||||
<path fill="#0e42d2" d="M389.98,400.6c-64.58,76.59-176.66,93.98-261.1,38.54-57.75-37.91-92.29-103.74-87.69-173.54,1.28-19.35,9.54-58.77,33.33-61.44,20.49-2.29,43.2,14.41,62.99,21.14,64.93,22.1,150.88,21.39,214.51-4.81,17.24-7.1,39.23-23.9,58.05-13.31,25.61,14.41,27.13,67.07,24.38,92.75-3.94,36.78-20.9,72.7-44.47,100.65ZM150.91,269c-17.4-4.01-34.19-9.87-50.5-17.04-2.56-1.12-15.17-8.19-16.64-6.83-23.51,92.25,48.81,179.87,140.64,188.01,3.68.33,9,.72,12.64.79,3.76.07,7.18-.41,10.86-.78-.24-3.7-1.05-7.05-.77-10.85,1.3-17.71,28.61-26.36,26.88-43.25-.89-8.76-14.85-11.03-22.02-11.55-8.23-.6-17.94,1.77-25.24-3.11-5.39-3.61-5.08-10.32-7.37-15.76-4.74-11.26-15.86-17.54-27.79-18.47-22.55-1.77-68.52,7.5-81.87-16.63-11.77-21.27,12.24-35.1,29.25-40.42,3.38-1.06,7.52-1.31,10.88-2.55,1.03-.38,1.28.27,1.04-1.56ZM391.83,245.21c-1.03-.58-12.51,5.5-14.65,6.42-20.82,8.92-42.01,16.02-64.2,20.87-10.31,2.25-21.7,3.15-31.66,5.65-19.17,4.82-17.59,22.78-.42,29.11,10.27,3.78,25.34,3.94,36.56,5.97,23.56,4.26,56.74,11.28,53.56,41.93-.31,2.98-2.13,5.86-2.26,8.2-.08,1.48.18,1.21,1.5,1.13,5.18-9.87,11.46-19.09,15.7-29.46,7.55-18.44,12.19-42.75,11.17-62.68-.19-3.67-2.95-25.81-5.31-27.15Z"/>
|
||||
<path fill="#0e42d2" d="M278.22,21.54c4.79,5.69,9.27,14.39,11.84,21.37.67,1.8,1.49,7.86,2.59,8.61,1.49,1.01,17.78-3.63,21.39-4.05,71.76-8.37,101.88,65.14,44.46,110.3-41.92,32.98-84.43,32.67-135.62,31.41-47.74-1.18-111.25-13.52-129.97-64.78-19.66-53.81,31.01-83.19,77.15-58.5,2,1.07,11.58,7.79,12.54,7.59,1.57-.32,1.05-4.62,1.34-6.14,3.75-19.76,11.37-39.47,27.8-52.04,20.82-15.93,49.31-14.16,66.47,6.23ZM256.71,71.1c1.07-24.31-18.03-42.71-30.8-12.52-5.88,13.9-7.77,32.56-3.36,47,1.73.46,1.24-.41,1.7-.95,10.15-11.8,19.02-25.11,32.46-33.52ZM340.18,123.96c10.12-10.37,17.92-27.48.94-35.85-29-14.31-83.74,17.89-87.91,49.24-1.6,11.97,2.2,14.36,13.71,14.53,21.84.32,57.86-12.13,73.26-27.92ZM173.64,117.92c-4.77-6.36-9.81-11.55-16.53-15.93-7.07-4.62-24.6-13.3-29.47-2.16-3.94,9.02,6.8,23.05,13.43,28.86,12.36,10.83,29.63,16.76,45.66,19.42-.28-11.02-6.62-21.58-13.08-30.19Z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.1 KiB |
179
public/streamsaver/mitm.html
Normal file
179
public/streamsaver/mitm.html
Normal file
@ -0,0 +1,179 @@
|
||||
<!--
|
||||
mitm.html is the lite "man in the middle"
|
||||
|
||||
This is only meant to signal the opener's messageChannel to
|
||||
the service worker - when that is done this mitm can be closed
|
||||
but it's better to keep it alive since this also stops the sw
|
||||
from restarting
|
||||
|
||||
The service worker is capable of intercepting all request and fork their
|
||||
own "fake" response - wish we are going to craft
|
||||
when the worker then receives a stream then the worker will tell the opener
|
||||
to open up a link that will start the download
|
||||
-->
|
||||
<script>
|
||||
// This will prevent the sw from restarting
|
||||
let keepAlive = () => {
|
||||
keepAlive = () => {};
|
||||
var ping = location.href.substr(0, location.href.lastIndexOf('/')) + '/ping';
|
||||
var interval = setInterval(() => {
|
||||
if (sw) {
|
||||
sw.postMessage('ping');
|
||||
} else {
|
||||
fetch(ping).then(res => res.text(!res.ok && clearInterval(interval)));
|
||||
}
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
// message event is the first thing we need to setup a listner for
|
||||
// don't want the opener to do a random timeout - instead they can listen for
|
||||
// the ready event
|
||||
// but since we need to wait for the Service Worker registration, we store the
|
||||
// message for later
|
||||
let messages = [];
|
||||
window.onmessage = evt => messages.push(evt);
|
||||
|
||||
let sw = null;
|
||||
let scope = '';
|
||||
|
||||
function registerWorker() {
|
||||
return navigator.serviceWorker
|
||||
.getRegistration('./')
|
||||
.then(swReg => {
|
||||
return swReg || navigator.serviceWorker.register('sw.js', { scope: './' });
|
||||
})
|
||||
.then(swReg => {
|
||||
const swRegTmp = swReg.installing || swReg.waiting;
|
||||
|
||||
scope = swReg.scope;
|
||||
|
||||
return (
|
||||
(sw = swReg.active) ||
|
||||
new Promise(resolve => {
|
||||
swRegTmp.addEventListener(
|
||||
'statechange',
|
||||
(fn = () => {
|
||||
if (swRegTmp.state === 'activated') {
|
||||
swRegTmp.removeEventListener('statechange', fn);
|
||||
sw = swReg.active;
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Now that we have the Service Worker registered we can process messages
|
||||
function onMessage(event) {
|
||||
let { data, ports, origin } = event;
|
||||
|
||||
// It's important to have a messageChannel, don't want to interfere
|
||||
// with other simultaneous downloads
|
||||
if (!ports || !ports.length) {
|
||||
throw new TypeError("[StreamSaver] You didn't send a messageChannel");
|
||||
}
|
||||
|
||||
if (typeof data !== 'object') {
|
||||
throw new TypeError("[StreamSaver] You didn't send a object");
|
||||
}
|
||||
|
||||
// the default public service worker for StreamSaver is shared among others.
|
||||
// so all download links needs to be prefixed to avoid any other conflict
|
||||
data.origin = origin;
|
||||
|
||||
// if we ever (in some feature versoin of streamsaver) would like to
|
||||
// redirect back to the page of who initiated a http request
|
||||
data.referrer = data.referrer || document.referrer || origin;
|
||||
|
||||
// pass along version for possible backwards compatibility in sw.js
|
||||
data.streamSaverVersion = new URLSearchParams(location.search).get('version');
|
||||
|
||||
if (data.streamSaverVersion === '1.2.0') {
|
||||
console.warn('[StreamSaver] please update streamsaver');
|
||||
}
|
||||
|
||||
/** @since v2.0.0 */
|
||||
if (!data.headers) {
|
||||
console.warn(
|
||||
"[StreamSaver] pass `data.headers` that you would like to pass along to the service worker\nit should be a 2D array or a key/val object that fetch's Headers api accepts"
|
||||
);
|
||||
} else {
|
||||
// test if it's correct
|
||||
// should thorw a typeError if not
|
||||
new Headers(data.headers);
|
||||
}
|
||||
|
||||
/** @since v2.0.0 */
|
||||
if (typeof data.filename === 'string') {
|
||||
console.warn(
|
||||
"[StreamSaver] You shouldn't send `data.filename` anymore. It should be included in the Content-Disposition header option"
|
||||
);
|
||||
// Do what File constructor do with fileNames
|
||||
data.filename = data.filename.replace(/\//g, ':');
|
||||
}
|
||||
|
||||
/** @since v2.0.0 */
|
||||
if (data.size) {
|
||||
console.warn(
|
||||
"[StreamSaver] You shouldn't send `data.size` anymore. It should be included in the content-length header option"
|
||||
);
|
||||
}
|
||||
|
||||
/** @since v2.0.0 */
|
||||
if (data.readableStream) {
|
||||
console.warn('[StreamSaver] You should send the readableStream in the messageChannel, not throught mitm');
|
||||
}
|
||||
|
||||
/** @since v2.0.0 */
|
||||
if (!data.pathname) {
|
||||
console.warn('[StreamSaver] Please send `data.pathname` (eg: /pictures/summer.jpg)');
|
||||
data.pathname = Math.random().toString().slice(-6) + '/' + data.filename;
|
||||
}
|
||||
|
||||
// remove all leading slashes
|
||||
data.pathname = data.pathname.replace(/^\/+/g, '');
|
||||
|
||||
// remove protocol
|
||||
let org = origin.replace(/(^\w+:|^)\/\//, '');
|
||||
|
||||
// set the absolute pathname to the download url.
|
||||
data.url = new URL(`${scope + org}/${data.pathname}`).toString();
|
||||
|
||||
if (!data.url.startsWith(`${scope + org}/`)) {
|
||||
throw new TypeError('[StreamSaver] bad `data.pathname`');
|
||||
}
|
||||
|
||||
// This sends the message data as well as transferring
|
||||
// messageChannel.port2 to the service worker. The service worker can
|
||||
// then use the transferred port to reply via postMessage(), which
|
||||
// will in turn trigger the onmessage handler on messageChannel.port1.
|
||||
|
||||
const transferable = data.readableStream ? [ports[0], data.readableStream] : [ports[0]];
|
||||
|
||||
if (!(data.readableStream || data.transferringReadable)) {
|
||||
keepAlive();
|
||||
}
|
||||
|
||||
return sw.postMessage(data, transferable);
|
||||
}
|
||||
|
||||
if (window.opener) {
|
||||
// The opener can't listen to onload event, so we need to help em out!
|
||||
// (telling them that we are ready to accept postMessage's)
|
||||
window.opener.postMessage('StreamSaver::loadedPopup', '*');
|
||||
}
|
||||
|
||||
if (navigator.serviceWorker) {
|
||||
registerWorker().then(() => {
|
||||
window.onmessage = onMessage;
|
||||
messages.forEach(window.onmessage);
|
||||
});
|
||||
}
|
||||
|
||||
// FF v102 just started to supports transferable streams, but still needs to ping sw.js
|
||||
// even tough the service worker dose not have to do any kind of work and listen to any
|
||||
// messages... #305
|
||||
keepAlive();
|
||||
</script>
|
132
public/streamsaver/sw.js
Normal file
132
public/streamsaver/sw.js
Normal file
@ -0,0 +1,132 @@
|
||||
/* eslint-disable */
|
||||
/* global self ReadableStream Response */
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
const map = new Map();
|
||||
|
||||
// This should be called once per download
|
||||
// Each event has a dataChannel that the data will be piped through
|
||||
self.onmessage = event => {
|
||||
// We send a heartbeat every x second to keep the
|
||||
// service worker alive if a transferable stream is not sent
|
||||
if (event.data === 'ping') {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.data;
|
||||
const downloadUrl =
|
||||
data.url || `${self.registration.scope + Math.random()}/${typeof data === 'string' ? data : data.filename}`;
|
||||
const port = event.ports[0];
|
||||
const metadata = Array.from({ length: 3 }); // [stream, data, port]
|
||||
|
||||
metadata[1] = data;
|
||||
metadata[2] = port;
|
||||
|
||||
// Note to self:
|
||||
// old streamsaver v1.2.0 might still use `readableStream`...
|
||||
// but v2.0.0 will always transfer the stream through MessageChannel #94
|
||||
if (event.data.readableStream) {
|
||||
metadata[0] = event.data.readableStream;
|
||||
} else if (event.data.transferringReadable) {
|
||||
port.onmessage = evt => {
|
||||
port.onmessage = null;
|
||||
metadata[0] = evt.data.readableStream;
|
||||
};
|
||||
} else {
|
||||
metadata[0] = createStream(port);
|
||||
}
|
||||
|
||||
map.set(downloadUrl, metadata);
|
||||
port.postMessage({ download: downloadUrl });
|
||||
};
|
||||
|
||||
function createStream(port) {
|
||||
// ReadableStream is only supported by chrome 52
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
// When we receive data on the messageChannel, we write
|
||||
port.onmessage = ({ data }) => {
|
||||
if (data === 'end') {
|
||||
return controller.close();
|
||||
}
|
||||
|
||||
if (data === 'abort') {
|
||||
controller.error('Aborted the download');
|
||||
return;
|
||||
}
|
||||
|
||||
controller.enqueue(data);
|
||||
};
|
||||
},
|
||||
cancel(reason) {
|
||||
console.log('user aborted', reason);
|
||||
port.postMessage({ abort: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
self.onfetch = event => {
|
||||
const url = event.request.url;
|
||||
|
||||
// this only works for Firefox
|
||||
if (url.endsWith('/ping')) {
|
||||
return event.respondWith(new Response('pong'));
|
||||
}
|
||||
|
||||
const hijacke = map.get(url);
|
||||
|
||||
if (!hijacke) return null;
|
||||
|
||||
const [stream, data, port] = hijacke;
|
||||
|
||||
map.delete(url);
|
||||
|
||||
// Not comfortable letting any user control all headers
|
||||
// so we only copy over the length & disposition
|
||||
const responseHeaders = new Headers({
|
||||
'Content-Type': 'application/octet-stream; charset=utf-8',
|
||||
|
||||
// To be on the safe side, The link can be opened in a iframe.
|
||||
// but octet-stream should stop it.
|
||||
'Content-Security-Policy': "default-src 'none'",
|
||||
'X-Content-Security-Policy': "default-src 'none'",
|
||||
'X-WebKit-CSP': "default-src 'none'",
|
||||
'X-XSS-Protection': '1; mode=block',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp'
|
||||
});
|
||||
|
||||
const headers = new Headers(data.headers || {});
|
||||
|
||||
if (headers.has('Content-Length')) {
|
||||
responseHeaders.set('Content-Length', headers.get('Content-Length'));
|
||||
}
|
||||
|
||||
if (headers.has('Content-Disposition')) {
|
||||
responseHeaders.set('Content-Disposition', headers.get('Content-Disposition'));
|
||||
}
|
||||
|
||||
// data, data.filename and size should not be used anymore
|
||||
if (data.size) {
|
||||
console.warn('Depricated');
|
||||
responseHeaders.set('Content-Length', data.size);
|
||||
}
|
||||
|
||||
let fileName = typeof data === 'string' ? data : data.filename;
|
||||
if (fileName) {
|
||||
console.warn('Depricated');
|
||||
// Make filename RFC5987 compatible
|
||||
fileName = encodeURIComponent(fileName).replace(/['()]/g, escape).replace(/\*/g, '%2A');
|
||||
responseHeaders.set('Content-Disposition', `attachment; filename*=UTF-8''${fileName}`);
|
||||
}
|
||||
|
||||
event.respondWith(new Response(stream, { headers: responseHeaders }));
|
||||
|
||||
port.postMessage({ debug: 'Download started' });
|
||||
};
|
15
src/App.vue
15
src/App.vue
@ -5,7 +5,6 @@ import type { WatermarkProps } from 'naive-ui';
|
||||
import { useAppStore } from './store/modules/app';
|
||||
import { useThemeStore } from './store/modules/theme';
|
||||
import { naiveDateLocales, naiveLocales } from './locales/naive';
|
||||
import { useAuthStore } from './store/modules/auth';
|
||||
|
||||
defineOptions({
|
||||
name: 'App'
|
||||
@ -13,7 +12,6 @@ defineOptions({
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { userInfo } = useAuthStore();
|
||||
|
||||
const naiveDarkTheme = computed(() => (themeStore.darkMode ? darkTheme : undefined));
|
||||
|
||||
@ -26,20 +24,19 @@ const naiveDateLocale = computed(() => {
|
||||
});
|
||||
|
||||
const watermarkProps = computed<WatermarkProps>(() => {
|
||||
const appTitle = import.meta.env.VITE_APP_TITLE || 'RuoYi-Vue-Plus';
|
||||
return {
|
||||
content: userInfo.user?.userName ? `${userInfo.user?.nickName}@${appTitle} ${userInfo.user?.userName}` : appTitle,
|
||||
content: themeStore.watermarkContent,
|
||||
cross: true,
|
||||
fullscreen: true,
|
||||
fontSize: 14,
|
||||
fontColor: themeStore.darkMode ? 'rgba(200, 200, 200, 0.03)' : 'rgba(200, 200, 200, 0.2)',
|
||||
lineHeight: 14,
|
||||
width: 200,
|
||||
height: 300,
|
||||
width: 384,
|
||||
height: 384,
|
||||
xOffset: 12,
|
||||
yOffset: 60,
|
||||
rotate: -18,
|
||||
zIndex: 9999
|
||||
rotate: -13,
|
||||
zIndex: 9999,
|
||||
fontColor: themeStore.darkMode ? 'rgba(200, 200, 200, 0.03)' : 'rgba(200, 200, 200, 0.2)'
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 124 KiB |
1
src/assets/svg-icon/login-background.svg
Normal file
1
src/assets/svg-icon/login-background.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 192 KiB |
4
src/assets/svg-icon/logo.svg
Normal file
4
src/assets/svg-icon/logo.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 476.22 476.22">
|
||||
<path fill="#0e42d2" d="M389.98,400.6c-64.58,76.59-176.66,93.98-261.1,38.54-57.75-37.91-92.29-103.74-87.69-173.54,1.28-19.35,9.54-58.77,33.33-61.44,20.49-2.29,43.2,14.41,62.99,21.14,64.93,22.1,150.88,21.39,214.51-4.81,17.24-7.1,39.23-23.9,58.05-13.31,25.61,14.41,27.13,67.07,24.38,92.75-3.94,36.78-20.9,72.7-44.47,100.65ZM150.91,269c-17.4-4.01-34.19-9.87-50.5-17.04-2.56-1.12-15.17-8.19-16.64-6.83-23.51,92.25,48.81,179.87,140.64,188.01,3.68.33,9,.72,12.64.79,3.76.07,7.18-.41,10.86-.78-.24-3.7-1.05-7.05-.77-10.85,1.3-17.71,28.61-26.36,26.88-43.25-.89-8.76-14.85-11.03-22.02-11.55-8.23-.6-17.94,1.77-25.24-3.11-5.39-3.61-5.08-10.32-7.37-15.76-4.74-11.26-15.86-17.54-27.79-18.47-22.55-1.77-68.52,7.5-81.87-16.63-11.77-21.27,12.24-35.1,29.25-40.42,3.38-1.06,7.52-1.31,10.88-2.55,1.03-.38,1.28.27,1.04-1.56ZM391.83,245.21c-1.03-.58-12.51,5.5-14.65,6.42-20.82,8.92-42.01,16.02-64.2,20.87-10.31,2.25-21.7,3.15-31.66,5.65-19.17,4.82-17.59,22.78-.42,29.11,10.27,3.78,25.34,3.94,36.56,5.97,23.56,4.26,56.74,11.28,53.56,41.93-.31,2.98-2.13,5.86-2.26,8.2-.08,1.48.18,1.21,1.5,1.13,5.18-9.87,11.46-19.09,15.7-29.46,7.55-18.44,12.19-42.75,11.17-62.68-.19-3.67-2.95-25.81-5.31-27.15Z"/>
|
||||
<path fill="#0e42d2" d="M278.22,21.54c4.79,5.69,9.27,14.39,11.84,21.37.67,1.8,1.49,7.86,2.59,8.61,1.49,1.01,17.78-3.63,21.39-4.05,71.76-8.37,101.88,65.14,44.46,110.3-41.92,32.98-84.43,32.67-135.62,31.41-47.74-1.18-111.25-13.52-129.97-64.78-19.66-53.81,31.01-83.19,77.15-58.5,2,1.07,11.58,7.79,12.54,7.59,1.57-.32,1.05-4.62,1.34-6.14,3.75-19.76,11.37-39.47,27.8-52.04,20.82-15.93,49.31-14.16,66.47,6.23ZM256.71,71.1c1.07-24.31-18.03-42.71-30.8-12.52-5.88,13.9-7.77,32.56-3.36,47,1.73.46,1.24-.41,1.7-.95,10.15-11.8,19.02-25.11,32.46-33.52ZM340.18,123.96c10.12-10.37,17.92-27.48.94-35.85-29-14.31-83.74,17.89-87.91,49.24-1.6,11.97,2.2,14.36,13.71,14.53,21.84.32,57.86-12.13,73.26-27.92ZM173.64,117.92c-4.77-6.36-9.81-11.55-16.53-15.93-7.07-4.62-24.6-13.3-29.47-2.16-3.94,9.02,6.8,23.05,13.43,28.86,12.36,10.83,29.63,16.76,45.66,19.42-.28-11.02-6.62-21.58-13.08-30.19Z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.1 KiB |
@ -22,7 +22,12 @@ const columns = defineModel<NaiveUI.TableColumnCheck[]>('columns', {
|
||||
</NButton>
|
||||
</template>
|
||||
<VueDraggable v-model="columns" :animation="150" filter=".none_draggable">
|
||||
<div v-for="item in columns" :key="item.key" class="h-36px flex-y-center rd-4px hover:(bg-primary bg-opacity-20)">
|
||||
<div
|
||||
v-for="item in columns"
|
||||
:key="item.key"
|
||||
class="h-36px flex-y-center rd-4px hover:(bg-primary bg-opacity-20)"
|
||||
:class="{ hidden: !item.visible }"
|
||||
>
|
||||
<icon-mdi-drag class="mr-8px h-full cursor-move text-icon" />
|
||||
<NCheckbox v-model:checked="item.checked" class="none_draggable flex-1">
|
||||
<template v-if="typeof item.title === 'function'">
|
||||
|
42
src/components/common/icon-tooltip.vue
Normal file
42
src/components/common/icon-tooltip.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, useSlots } from 'vue';
|
||||
import type { PopoverPlacement } from 'naive-ui';
|
||||
|
||||
defineOptions({ name: 'IconTooltip' });
|
||||
|
||||
interface Props {
|
||||
icon?: string;
|
||||
localIcon?: string;
|
||||
desc?: string;
|
||||
placement?: PopoverPlacement;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
icon: 'mdi-help-circle',
|
||||
localIcon: '',
|
||||
desc: '',
|
||||
placement: 'top'
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
const hasCustomTrigger = computed(() => Boolean(slots.trigger));
|
||||
|
||||
if (!hasCustomTrigger.value && !props.icon && !props.localIcon) {
|
||||
throw new Error('icon or localIcon is required when no custom trigger slot is provided');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NTooltip :placement="placement">
|
||||
<template #trigger>
|
||||
<slot name="trigger">
|
||||
<div class="cursor-pointer">
|
||||
<SvgIcon :icon="icon" :local-icon="localIcon" />
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
<slot>
|
||||
<span>{{ desc }}</span>
|
||||
</slot>
|
||||
</NTooltip>
|
||||
</template>
|
@ -31,13 +31,25 @@ const tooltipContent = computed(() => {
|
||||
return $t('icon.lang');
|
||||
});
|
||||
|
||||
/** Add bottom margin to all options except the last one for proper visual separation */
|
||||
const dropdownOptions = computed(() => {
|
||||
const lastIndex = props.langOptions.length - 1;
|
||||
|
||||
return props.langOptions.map((option, index) => ({
|
||||
...option,
|
||||
props: {
|
||||
class: index < lastIndex ? 'mb-1' : undefined
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
function changeLang(lang: App.I18n.LangType) {
|
||||
emit('changeLang', lang);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDropdown :value="lang" :options="langOptions" trigger="hover" @select="changeLang">
|
||||
<NDropdown :value="lang" :options="dropdownOptions" trigger="hover" @select="changeLang">
|
||||
<div>
|
||||
<ButtonIcon :tooltip-content="tooltipContent" tooltip-placement="left">
|
||||
<SvgIcon icon="heroicons:language" />
|
||||
|
@ -3,7 +3,7 @@ defineOptions({ name: 'SystemLogo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img src="@/assets/imgs/logo.png" />
|
||||
<icon-local-logo />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
@ -2,7 +2,7 @@
|
||||
import { useAttrs } from 'vue';
|
||||
import type { TreeSelectProps } from 'naive-ui';
|
||||
import { useLoading } from '@sa/hooks';
|
||||
import { fetchGetDeptSelect } from '@/service/api/system';
|
||||
import { fetchGetDeptTree } from '@/service/api/system';
|
||||
|
||||
defineOptions({ name: 'DeptTreeSelect' });
|
||||
|
||||
@ -13,16 +13,21 @@ interface Props {
|
||||
defineProps<Props>();
|
||||
|
||||
const value = defineModel<CommonType.IdType | null>('value', { required: false });
|
||||
const options = defineModel<Api.System.Dept[]>('options', { required: false, default: [] });
|
||||
const options = defineModel<Api.Common.CommonTreeRecord>('options', { required: false, default: [] });
|
||||
const expandedKeys = defineModel<CommonType.IdType[]>('expandedKeys', { required: false, default: [] });
|
||||
|
||||
const attrs: TreeSelectProps = useAttrs();
|
||||
const { loading, startLoading, endLoading } = useLoading();
|
||||
|
||||
async function getDeptList() {
|
||||
startLoading();
|
||||
const { error, data } = await fetchGetDeptSelect();
|
||||
const { error, data } = await fetchGetDeptTree();
|
||||
if (error) return;
|
||||
options.value = data;
|
||||
// 设置默认展开的节点
|
||||
if (data?.length && !expandedKeys.value.length) {
|
||||
expandedKeys.value = [data[0].id];
|
||||
}
|
||||
endLoading();
|
||||
}
|
||||
|
||||
@ -32,13 +37,13 @@ getDeptList();
|
||||
<template>
|
||||
<NTreeSelect
|
||||
v-model:value="value"
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
filterable
|
||||
class="h-full"
|
||||
:loading="loading"
|
||||
key-field="deptId"
|
||||
label-field="deptName"
|
||||
:options="options"
|
||||
:default-expanded-keys="[0]"
|
||||
key-field="id"
|
||||
label-field="label"
|
||||
:options="options as []"
|
||||
v-bind="attrs"
|
||||
/>
|
||||
</template>
|
||||
|
@ -3,6 +3,7 @@ import { computed, useAttrs } from 'vue';
|
||||
import type { TagProps } from 'naive-ui';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import { isNotNull } from '@/utils/common';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'DictTag' });
|
||||
|
||||
@ -23,13 +24,18 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const attrs = useAttrs() as TagProps;
|
||||
|
||||
const { transformDictData } = useDict(props.dictCode, props.immediate);
|
||||
|
||||
const dictTagData = computed<Api.System.DictData[]>(() => {
|
||||
if (props.dictData) {
|
||||
return [props.dictData];
|
||||
const dictData = props.dictData;
|
||||
if (dictData.dictLabel?.startsWith(`dict.${dictData.dictType}.`)) {
|
||||
dictData.dictLabel = $t(dictData.dictLabel as App.I18n.I18nKey);
|
||||
}
|
||||
return [dictData];
|
||||
}
|
||||
// 避免 props.value 为 0 时,无法触发
|
||||
if (props.dictCode && isNotNull(props.value)) {
|
||||
const { transformDictData } = useDict(props.dictCode, props.immediate);
|
||||
return transformDictData(props.value) || [];
|
||||
}
|
||||
|
||||
|
@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useAttrs, watch } from 'vue';
|
||||
import { computed, useAttrs } from 'vue';
|
||||
import type { UploadFileInfo, UploadProps } from 'naive-ui';
|
||||
import { fetchBatchDeleteOss } from '@/service/api/system/oss';
|
||||
import { getToken } from '@/store/modules/auth/shared';
|
||||
import { getServiceBaseURL } from '@/utils/service';
|
||||
import { AcceptType } from '@/enum/business';
|
||||
|
||||
defineOptions({
|
||||
name: 'FileUpload'
|
||||
@ -26,27 +27,24 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
defaultUpload: true,
|
||||
showTip: true,
|
||||
max: 5,
|
||||
accept: '.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.pdf',
|
||||
accept: undefined,
|
||||
fileSize: 5,
|
||||
uploadType: 'file'
|
||||
});
|
||||
|
||||
const accept = computed(() => {
|
||||
if (props.accept) {
|
||||
return props.accept;
|
||||
}
|
||||
return props.uploadType === 'file' ? AcceptType.File : AcceptType.Image;
|
||||
});
|
||||
|
||||
const attrs: UploadProps = useAttrs();
|
||||
|
||||
let fileNum = 0;
|
||||
const fileList = ref<UploadFileInfo[]>([]);
|
||||
|
||||
const needRelaodData = ref(false);
|
||||
|
||||
defineExpose({
|
||||
needRelaodData
|
||||
const fileList = defineModel<UploadFileInfo[]>('fileList', {
|
||||
default: () => []
|
||||
});
|
||||
watch(
|
||||
() => fileList.value,
|
||||
newValue => {
|
||||
needRelaodData.value = newValue.length > 0;
|
||||
}
|
||||
);
|
||||
|
||||
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
|
||||
const { baseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
|
||||
@ -61,12 +59,12 @@ function beforeUpload(options: { file: UploadFileInfo; fileList: UploadFileInfo[
|
||||
const { file } = options;
|
||||
|
||||
// 校检文件类型
|
||||
if (props.accept) {
|
||||
if (accept.value) {
|
||||
const fileName = file.name.split('.');
|
||||
const fileExt = `.${fileName[fileName.length - 1]}`;
|
||||
const isTypeOk = props.accept.split(',')?.includes(fileExt);
|
||||
const isTypeOk = accept.value.split(',')?.includes(fileExt);
|
||||
if (!isTypeOk) {
|
||||
window.$message?.error(`文件格式不正确, 请上传 ${props.accept} 格式文件!`);
|
||||
window.$message?.error(`文件格式不正确, 请上传 ${accept.value} 格式文件!`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -119,64 +117,67 @@ function handleError(options: { file: UploadFileInfo; event?: ProgressEvent }) {
|
||||
|
||||
async function handleRemove(file: UploadFileInfo) {
|
||||
if (file.status !== 'finished') {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const { error } = await fetchBatchDeleteOss([file.id]);
|
||||
if (error) return;
|
||||
if (error) return false;
|
||||
window.$message?.success('删除成功');
|
||||
return true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NUpload
|
||||
v-bind="attrs"
|
||||
v-model:file-list="fileList"
|
||||
:action="`${baseURL}${action}`"
|
||||
:data="data"
|
||||
:headers="headers"
|
||||
:max="max"
|
||||
:accept="accept"
|
||||
:multiple="max > 1"
|
||||
directory-dnd
|
||||
:default-upload="defaultUpload"
|
||||
:list-type="uploadType === 'image' ? 'image-card' : 'text'"
|
||||
:is-error-state="isErrorState"
|
||||
@finish="handleFinish"
|
||||
@error="handleError"
|
||||
@before-upload="beforeUpload"
|
||||
@remove="({ file }) => handleRemove(file)"
|
||||
>
|
||||
<NUploadDragger v-if="uploadType === 'file'">
|
||||
<div class="mb-12px flex-center">
|
||||
<SvgIcon icon="material-symbols:unarchive-outline" class="text-58px color-#d8d8db dark:color-#a1a1a2" />
|
||||
</div>
|
||||
<NText class="text-16px">点击或者拖动文件到该区域来上传</NText>
|
||||
<NP v-if="showTip" depth="3" class="mt-8px text-center">
|
||||
请上传
|
||||
<template v-if="fileSize">
|
||||
大小不超过
|
||||
<b class="text-red-500">{{ fileSize }}MB</b>
|
||||
</template>
|
||||
<template v-if="accept">
|
||||
,且格式为
|
||||
<b class="text-red-500">{{ accept.replaceAll(',', '/') }}</b>
|
||||
</template>
|
||||
的文件
|
||||
</NP>
|
||||
</NUploadDragger>
|
||||
</NUpload>
|
||||
<NP v-if="showTip && uploadType === 'image'" depth="3" class="mt-12px">
|
||||
请上传
|
||||
<template v-if="fileSize">
|
||||
大小不超过
|
||||
<b class="text-red-500">{{ fileSize }}MB</b>
|
||||
</template>
|
||||
<template v-if="accept">
|
||||
,且格式为
|
||||
<b class="text-red-500">{{ accept.replaceAll(',', '/') }}</b>
|
||||
</template>
|
||||
的文件
|
||||
</NP>
|
||||
<div class="w-full flex-col">
|
||||
<NUpload
|
||||
v-bind="attrs"
|
||||
v-model:file-list="fileList"
|
||||
:action="`${baseURL}${action}`"
|
||||
:data="data"
|
||||
:headers="headers"
|
||||
:max="max"
|
||||
:accept="accept"
|
||||
:multiple="max > 1"
|
||||
directory-dnd
|
||||
:default-upload="defaultUpload"
|
||||
:list-type="uploadType === 'image' ? 'image-card' : 'text'"
|
||||
:is-error-state="isErrorState"
|
||||
@finish="handleFinish"
|
||||
@error="handleError"
|
||||
@before-upload="beforeUpload"
|
||||
@remove="({ file }) => handleRemove(file)"
|
||||
>
|
||||
<NUploadDragger v-if="uploadType === 'file'">
|
||||
<div class="mb-12px flex-center">
|
||||
<SvgIcon icon="material-symbols:unarchive-outline" class="text-58px color-#d8d8db dark:color-#a1a1a2" />
|
||||
</div>
|
||||
<NText class="text-16px">点击或者拖动文件到该区域来上传</NText>
|
||||
<NP v-if="showTip" depth="3" class="mt-8px text-center">
|
||||
请上传
|
||||
<template v-if="fileSize">
|
||||
大小不超过
|
||||
<b class="text-red-500">{{ fileSize }}MB</b>
|
||||
</template>
|
||||
<template v-if="accept">
|
||||
,且格式为
|
||||
<b class="text-red-500">{{ accept.replaceAll(',', '/') }}</b>
|
||||
</template>
|
||||
的文件
|
||||
</NP>
|
||||
</NUploadDragger>
|
||||
</NUpload>
|
||||
<NP v-if="showTip && uploadType === 'image'" depth="3" class="mt-12px">
|
||||
请上传
|
||||
<template v-if="fileSize">
|
||||
大小不超过
|
||||
<b class="text-red-500">{{ fileSize }}MB</b>
|
||||
</template>
|
||||
<template v-if="accept">
|
||||
,且格式为
|
||||
<b class="text-red-500">{{ accept.replaceAll(',', '/') }}</b>
|
||||
</template>
|
||||
的文件
|
||||
</NP>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
@ -1,18 +1,22 @@
|
||||
<script setup lang="tsx">
|
||||
import { useAttrs } from 'vue';
|
||||
import { onMounted, useAttrs } from 'vue';
|
||||
import type { TreeOption, TreeSelectProps } from 'naive-ui';
|
||||
import { useLoading } from '@sa/hooks';
|
||||
import { fetchGetMenuList } from '@/service/api/system';
|
||||
import { handleTree } from '@/utils/common';
|
||||
import SvgIcon from '@/components/custom/svg-icon.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'MenuTreeSelect' });
|
||||
|
||||
interface Props {
|
||||
immediate?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
immediate: true
|
||||
});
|
||||
|
||||
const value = defineModel<CommonType.IdType | null>('value', { required: false });
|
||||
const options = defineModel<Api.System.MenuList>('options', { required: false, default: [] });
|
||||
@ -35,7 +39,19 @@ async function getMenuList() {
|
||||
endLoading();
|
||||
}
|
||||
|
||||
getMenuList();
|
||||
onMounted(() => {
|
||||
if (props.immediate) {
|
||||
getMenuList();
|
||||
}
|
||||
});
|
||||
|
||||
function renderLabel({ option }: { option: TreeOption }) {
|
||||
let label = String(option.menuName);
|
||||
if (label?.startsWith('route.') || label?.startsWith('menu.')) {
|
||||
label = $t(label as App.I18n.I18nKey);
|
||||
}
|
||||
return <div>{label}</div>;
|
||||
}
|
||||
|
||||
function renderPrefix({ option }: { option: TreeOption }) {
|
||||
const renderLocalIcon = String(option.icon).startsWith('local-icon-');
|
||||
@ -55,6 +71,8 @@ function renderPrefix({ option }: { option: TreeOption }) {
|
||||
label-field="menuName"
|
||||
:options="options"
|
||||
:default-expanded-keys="[0]"
|
||||
:render-tag="renderLabel"
|
||||
:render-label="renderLabel"
|
||||
:render-prefix="renderPrefix"
|
||||
v-bind="attrs"
|
||||
/>
|
||||
|
@ -4,6 +4,7 @@ import type { TreeOption, TreeSelectInst, TreeSelectProps } from 'naive-ui';
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import { fetchGetMenuTreeSelect } from '@/service/api/system';
|
||||
import SvgIcon from '@/components/custom/svg-icon.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'MenuTree' });
|
||||
|
||||
@ -51,7 +52,6 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// 添加 watch 监听 expandAll 的变化,options有值后,计算expandedKeys
|
||||
watch([expandAll, options], ([newVal]) => {
|
||||
if (newVal) {
|
||||
// 展开所有节点
|
||||
@ -61,6 +61,14 @@ watch([expandAll, options], ([newVal]) => {
|
||||
}
|
||||
});
|
||||
|
||||
function renderLabel({ option }: { option: TreeOption }) {
|
||||
let label = option.label;
|
||||
if (label?.startsWith('route.') || label?.startsWith('menu.')) {
|
||||
label = $t(label as App.I18n.I18nKey);
|
||||
}
|
||||
return <div>{label}</div>;
|
||||
}
|
||||
|
||||
function renderPrefix({ option }: { option: TreeOption }) {
|
||||
const renderLocalIcon = String(option.icon).startsWith('local-icon-');
|
||||
let icon = renderLocalIcon ? undefined : String(option.icon ?? 'material-symbols:buttons-alt-outline-rounded');
|
||||
@ -82,6 +90,21 @@ function getAllMenuIds(menu: Api.System.MenuList) {
|
||||
return menuIds;
|
||||
}
|
||||
|
||||
/** 获取所有叶子节点的 ID(没有子节点的节点) */
|
||||
function getLeafMenuIds(menu: Api.System.MenuList): CommonType.IdType[] {
|
||||
const leafIds: CommonType.IdType[] = [];
|
||||
menu.forEach(item => {
|
||||
if (!item.children || item.children.length === 0) {
|
||||
// 是叶子节点
|
||||
leafIds.push(item.id!);
|
||||
} else {
|
||||
// 有子节点,递归获取子节点中的叶子节点
|
||||
leafIds.push(...getLeafMenuIds(item.children));
|
||||
}
|
||||
});
|
||||
return leafIds;
|
||||
}
|
||||
|
||||
function handleCheckedTreeNodeAll(checked: boolean) {
|
||||
if (checked) {
|
||||
checkedKeys.value = getAllMenuIds(options.value);
|
||||
@ -90,16 +113,30 @@ function handleCheckedTreeNodeAll(checked: boolean) {
|
||||
checkedKeys.value = [];
|
||||
}
|
||||
|
||||
function getCheckedMenuIds() {
|
||||
function getCheckedMenuIds(isCascade: boolean = false) {
|
||||
const menuIds = menuTreeRef.value?.getCheckedData()?.keys as string[];
|
||||
const indeterminateData = menuTreeRef.value?.getIndeterminateData();
|
||||
if (cascade.value) {
|
||||
if (cascade.value || isCascade) {
|
||||
const parentIds: string[] = indeterminateData?.keys.filter(item => !menuIds?.includes(String(item))) as string[];
|
||||
menuIds?.push(...parentIds);
|
||||
}
|
||||
return menuIds;
|
||||
}
|
||||
|
||||
watch(cascade, () => {
|
||||
if (cascade.value) {
|
||||
// 获取当前菜单树中的所有叶子节点ID
|
||||
const allLeafIds = getLeafMenuIds(options.value);
|
||||
// 筛选出当前选中项中的叶子节点
|
||||
const selectedLeafIds = checkedKeys.value.filter(id => allLeafIds.includes(id));
|
||||
// 重新设置选中状态为只包含叶子节点,让组件基于父子联动规则重新计算父节点状态
|
||||
checkedKeys.value = selectedLeafIds;
|
||||
return;
|
||||
}
|
||||
// 禁用父子联动时,将半选中的父节点也加入到选中列表
|
||||
checkedKeys.value = getCheckedMenuIds(true);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
getCheckedMenuIds,
|
||||
refresh: getMenuList
|
||||
@ -135,6 +172,7 @@ defineExpose({
|
||||
:loading="loading"
|
||||
virtual-scroll
|
||||
check-strategy="all"
|
||||
:render-label="renderLabel"
|
||||
:render-prefix="renderPrefix"
|
||||
v-bind="attrs"
|
||||
/>
|
||||
|
69
src/components/custom/oss-upload.vue
Normal file
69
src/components/custom/oss-upload.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useAttrs, watch } from 'vue';
|
||||
import type { UploadFileInfo } from 'naive-ui';
|
||||
import { useLoading } from '@sa/hooks';
|
||||
import { fetchGetOssListByIds } from '@/service/api/system/oss';
|
||||
import { isNotNull } from '@/utils/common';
|
||||
import FileUpload from '@/components/custom/file-upload.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'OssUpload'
|
||||
});
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
const value = defineModel<string>('value', { default: '' });
|
||||
|
||||
const { loading, startLoading, endLoading } = useLoading();
|
||||
|
||||
const fileList = ref<UploadFileInfo[]>([]);
|
||||
|
||||
async function handleFetchOssList(ossIds: string[]) {
|
||||
startLoading();
|
||||
try {
|
||||
const { error, data } = await fetchGetOssListByIds(ossIds);
|
||||
if (error) return;
|
||||
fileList.value = data.map(item => ({
|
||||
id: String(item.ossId),
|
||||
url: item.url,
|
||||
name: item.originalName,
|
||||
status: 'finished'
|
||||
}));
|
||||
} catch (error) {
|
||||
window.$message?.error(`获取文件列表失败: ${error}`);
|
||||
} finally {
|
||||
endLoading();
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
value,
|
||||
async val => {
|
||||
const ossIds = val?.split(',')?.filter(item => isNotNull(item)) || [];
|
||||
if (ossIds.length === 0) {
|
||||
fileList.value = [];
|
||||
return;
|
||||
}
|
||||
const fileIds = new Set(fileList.value.filter(item => item.status === 'finished').map(item => item.id));
|
||||
if (ossIds.every(item => fileIds.has(item))) {
|
||||
return;
|
||||
}
|
||||
await handleFetchOssList(ossIds);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(fileList, val => {
|
||||
value.value = val
|
||||
.filter(item => item.status === 'finished')
|
||||
.map(item => item.id)
|
||||
.join(',');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpin v-if="loading" />
|
||||
<FileUpload v-else v-bind="attrs" v-model:file-list="fileList" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
@ -21,27 +21,27 @@ const attrs: SelectProps = useAttrs();
|
||||
|
||||
const { loading: postLoading, startLoading: startPostLoading, endLoading: endPostLoading } = useLoading();
|
||||
|
||||
/** the enabled role options */
|
||||
const roleOptions = ref<CommonType.Option<CommonType.IdType>[]>([]);
|
||||
/** the enabled post options */
|
||||
const postOptions = ref<CommonType.Option<CommonType.IdType>[]>([]);
|
||||
|
||||
watch(
|
||||
() => props.deptId,
|
||||
() => {
|
||||
if (!props.deptId) {
|
||||
roleOptions.value = [];
|
||||
postOptions.value = [];
|
||||
return;
|
||||
}
|
||||
getRoleOptions();
|
||||
getPostOptions();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function getRoleOptions() {
|
||||
async function getPostOptions() {
|
||||
startPostLoading();
|
||||
const { error, data } = await fetchGetPostSelect(props.deptId!);
|
||||
|
||||
if (!error) {
|
||||
roleOptions.value = data.map(item => ({
|
||||
postOptions.value = data.map(item => ({
|
||||
label: item.postName,
|
||||
value: item.postId
|
||||
}));
|
||||
@ -54,7 +54,7 @@ async function getRoleOptions() {
|
||||
<NSelect
|
||||
v-model:value="value"
|
||||
:loading="postLoading"
|
||||
:options="roleOptions"
|
||||
:options="postOptions"
|
||||
v-bind="attrs"
|
||||
placeholder="请选择岗位"
|
||||
/>
|
||||
|
36
src/components/custom/tinymce-editor.vue
Normal file
36
src/components/custom/tinymce-editor.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { Tinymce } from '@sa/tinymce';
|
||||
import { getToken } from '@/store/modules/auth/shared';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { getServiceBaseURL } from '@/utils/service';
|
||||
|
||||
defineOptions({
|
||||
name: 'TinymceEditor'
|
||||
});
|
||||
|
||||
const value = defineModel<string | null>('value', { required: false, default: '' });
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
|
||||
const { baseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${getToken()}`,
|
||||
clientid: import.meta.env.VITE_APP_CLIENT_ID!
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tinymce
|
||||
v-model="value"
|
||||
:lang="appStore.locale"
|
||||
:is-dark="themeStore.darkMode"
|
||||
:upload-url="`${baseURL}/resource/oss/upload`"
|
||||
:upload-headers="headers"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
@ -5,9 +5,9 @@ export const GLOBAL_HEADER_MENU_ID = '__GLOBAL_HEADER_MENU__';
|
||||
export const GLOBAL_SIDER_MENU_ID = '__GLOBAL_SIDER_MENU__';
|
||||
|
||||
export const themeSchemaRecord: Record<UnionKey.ThemeScheme, App.I18n.I18nKey> = {
|
||||
light: 'theme.themeSchema.light',
|
||||
dark: 'theme.themeSchema.dark',
|
||||
auto: 'theme.themeSchema.auto'
|
||||
light: 'theme.appearance.themeSchema.light',
|
||||
dark: 'theme.appearance.themeSchema.dark',
|
||||
auto: 'theme.appearance.themeSchema.auto'
|
||||
};
|
||||
|
||||
export const themeSchemaOptions = transformRecordToOption(themeSchemaRecord);
|
||||
@ -21,49 +21,61 @@ export const loginModuleRecord: Record<UnionKey.LoginModule, App.I18n.I18nKey> =
|
||||
};
|
||||
|
||||
export const themeLayoutModeRecord: Record<UnionKey.ThemeLayoutMode, App.I18n.I18nKey> = {
|
||||
vertical: 'theme.layoutMode.vertical',
|
||||
'vertical-mix': 'theme.layoutMode.vertical-mix',
|
||||
horizontal: 'theme.layoutMode.horizontal',
|
||||
'horizontal-mix': 'theme.layoutMode.horizontal-mix'
|
||||
vertical: 'theme.layout.layoutMode.vertical',
|
||||
'vertical-mix': 'theme.layout.layoutMode.vertical-mix',
|
||||
'vertical-hybrid-header-first': 'theme.layout.layoutMode.vertical-hybrid-header-first',
|
||||
horizontal: 'theme.layout.layoutMode.horizontal',
|
||||
'top-hybrid-sidebar-first': 'theme.layout.layoutMode.top-hybrid-sidebar-first',
|
||||
'top-hybrid-header-first': 'theme.layout.layoutMode.top-hybrid-header-first'
|
||||
};
|
||||
|
||||
export const themeLayoutModeOptions = transformRecordToOption(themeLayoutModeRecord);
|
||||
|
||||
export const themeScrollModeRecord: Record<UnionKey.ThemeScrollMode, App.I18n.I18nKey> = {
|
||||
wrapper: 'theme.scrollMode.wrapper',
|
||||
content: 'theme.scrollMode.content'
|
||||
wrapper: 'theme.layout.content.scrollMode.wrapper',
|
||||
content: 'theme.layout.content.scrollMode.content'
|
||||
};
|
||||
|
||||
export const themeScrollModeOptions = transformRecordToOption(themeScrollModeRecord);
|
||||
|
||||
export const themeTabModeRecord: Record<UnionKey.ThemeTabMode, App.I18n.I18nKey> = {
|
||||
chrome: 'theme.tab.mode.chrome',
|
||||
button: 'theme.tab.mode.button'
|
||||
chrome: 'theme.layout.tab.mode.chrome',
|
||||
button: 'theme.layout.tab.mode.button'
|
||||
};
|
||||
|
||||
export const themeTabModeOptions = transformRecordToOption(themeTabModeRecord);
|
||||
|
||||
export const themePageAnimationModeRecord: Record<UnionKey.ThemePageAnimateMode, App.I18n.I18nKey> = {
|
||||
'fade-slide': 'theme.page.mode.fade-slide',
|
||||
fade: 'theme.page.mode.fade',
|
||||
'fade-bottom': 'theme.page.mode.fade-bottom',
|
||||
'fade-scale': 'theme.page.mode.fade-scale',
|
||||
'zoom-fade': 'theme.page.mode.zoom-fade',
|
||||
'zoom-out': 'theme.page.mode.zoom-out',
|
||||
none: 'theme.page.mode.none'
|
||||
'fade-slide': 'theme.layout.content.page.mode.fade-slide',
|
||||
fade: 'theme.layout.content.page.mode.fade',
|
||||
'fade-bottom': 'theme.layout.content.page.mode.fade-bottom',
|
||||
'fade-scale': 'theme.layout.content.page.mode.fade-scale',
|
||||
'zoom-fade': 'theme.layout.content.page.mode.zoom-fade',
|
||||
'zoom-out': 'theme.layout.content.page.mode.zoom-out',
|
||||
none: 'theme.layout.content.page.mode.none'
|
||||
};
|
||||
|
||||
export const themePageAnimationModeOptions = transformRecordToOption(themePageAnimationModeRecord);
|
||||
|
||||
export const resetCacheStrategyRecord: Record<UnionKey.ResetCacheStrategy, App.I18n.I18nKey> = {
|
||||
close: 'theme.resetCacheStrategy.close',
|
||||
refresh: 'theme.resetCacheStrategy.refresh'
|
||||
close: 'theme.layout.resetCacheStrategy.close',
|
||||
refresh: 'theme.layout.resetCacheStrategy.refresh'
|
||||
};
|
||||
|
||||
export const resetCacheStrategyOptions = transformRecordToOption(resetCacheStrategyRecord);
|
||||
|
||||
export const DARK_CLASS = 'dark';
|
||||
|
||||
export const watermarkTimeFormatOptions = [
|
||||
{ label: 'YYYY-MM-DD HH:mm', value: 'YYYY-MM-DD HH:mm' },
|
||||
{ label: 'YYYY-MM-DD HH:mm:ss', value: 'YYYY-MM-DD HH:mm:ss' },
|
||||
{ label: 'YYYY/MM/DD HH:mm', value: 'YYYY/MM/DD HH:mm' },
|
||||
{ label: 'YYYY/MM/DD HH:mm:ss', value: 'YYYY/MM/DD HH:mm:ss' },
|
||||
{ label: 'HH:mm', value: 'HH:mm' },
|
||||
{ label: 'HH:mm:ss', value: 'HH:mm:ss' },
|
||||
{ label: 'MM-DD HH:mm', value: 'MM-DD HH:mm' }
|
||||
];
|
||||
|
||||
export const themeTableSizeRecord: Record<UnionKey.ThemeTableSize, App.I18n.I18nKey> = {
|
||||
small: 'theme.table.size.small',
|
||||
medium: 'theme.table.size.medium',
|
||||
|
@ -76,7 +76,7 @@ export const genHtmlTypeRecord: Record<Api.Tool.HtmlType, string> = {
|
||||
select: '下拉框',
|
||||
radio: '单选框',
|
||||
checkbox: '复选框',
|
||||
datetime: '日期控件',
|
||||
datetime: '日期时间控件',
|
||||
imageUpload: '图片上传',
|
||||
fileUpload: '文件上传',
|
||||
editor: '富文本控件'
|
||||
|
4
src/enum/business.ts
Normal file
4
src/enum/business.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum AcceptType {
|
||||
Image = '.jpg,.jpeg,.png,.gif,.bmp,.webp',
|
||||
File = '.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.pdf,.zip,.rar,.7z'
|
||||
}
|
@ -3,6 +3,8 @@ import { storeToRefs } from 'pinia';
|
||||
import { fetchGetDictDataByType } from '@/service/api/system';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { isNull } from '@/utils/common';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
export function useDict(dictType: string, immediate: boolean = true) {
|
||||
const dictStore = useDictStore();
|
||||
const { dictData: dictList } = storeToRefs(dictStore);
|
||||
@ -19,6 +21,11 @@ export function useDict(dictType: string, immediate: boolean = true) {
|
||||
}
|
||||
const { data: dictData, error } = await fetchGetDictDataByType(dictType);
|
||||
if (error) return;
|
||||
dictData.forEach(dict => {
|
||||
if (dict.dictLabel?.startsWith(`dict.${dictType}.`)) {
|
||||
dict.dictLabel = $t(dict.dictLabel as App.I18n.I18nKey);
|
||||
}
|
||||
});
|
||||
dictStore.setDict(dictType, dictData);
|
||||
data.value = dictData;
|
||||
}
|
||||
|
@ -1,114 +1,159 @@
|
||||
import StreamSaver from 'streamsaver';
|
||||
import { errorCodeRecord } from '@/constants/common';
|
||||
import { localStg } from '@/utils/storage';
|
||||
import { getServiceBaseURL } from '@/utils/service';
|
||||
import { transformToURLSearchParams } from '@/utils/common';
|
||||
|
||||
interface RequestConfig {
|
||||
method: 'GET' | 'POST';
|
||||
url: string;
|
||||
params?: Record<string, any>;
|
||||
filename?: string;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
export function useDownload() {
|
||||
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
|
||||
const { baseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
|
||||
|
||||
function downloadByData(data: BlobPart, filename: string, type: string = 'application/octet-stream') {
|
||||
const blobData = [data];
|
||||
const blob = new Blob(blobData, { type });
|
||||
const isHttps = () => {
|
||||
const protocol = document.location.protocol;
|
||||
const hostname = document.location.hostname;
|
||||
return protocol === 'https' || hostname === 'localhost' || hostname === '127.0.0.1';
|
||||
};
|
||||
|
||||
/** 获取通用请求头 */
|
||||
const getCommonHeaders = (contentType = 'application/octet-stream') => ({
|
||||
Authorization: `Bearer ${localStg.get('token')}`,
|
||||
Clientid: import.meta.env.VITE_APP_CLIENT_ID!,
|
||||
'Content-Type': contentType
|
||||
});
|
||||
|
||||
/** 通用下载方法 */
|
||||
function downloadByData(data: BlobPart, filename: string, type = 'application/octet-stream') {
|
||||
const blob = new Blob([data], { type });
|
||||
const blobURL = window.URL.createObjectURL(blob);
|
||||
const tempLink = document.createElement('a');
|
||||
tempLink.style.display = 'none';
|
||||
tempLink.href = blobURL;
|
||||
tempLink.setAttribute('download', filename);
|
||||
|
||||
const tempLink = Object.assign(document.createElement('a'), {
|
||||
style: { display: 'none' },
|
||||
href: blobURL,
|
||||
download: filename
|
||||
});
|
||||
|
||||
if (typeof tempLink.download === 'undefined') {
|
||||
tempLink.setAttribute('target', '_blank');
|
||||
}
|
||||
|
||||
document.body.appendChild(tempLink);
|
||||
tempLink.click();
|
||||
document.body.removeChild(tempLink);
|
||||
window.URL.revokeObjectURL(blobURL);
|
||||
}
|
||||
|
||||
function download(url: string, params: any, fileName: string) {
|
||||
window.$loading?.startLoading('正在下载数据,请稍候...');
|
||||
const token = localStg.get('token');
|
||||
const clientId = import.meta.env.VITE_APP_CLIENT_ID;
|
||||
const now = Date.now();
|
||||
const searchParams = new FormData();
|
||||
if (params) {
|
||||
Object.keys(params).forEach(key => {
|
||||
if (params[key] !== null && params[key] !== undefined) {
|
||||
searchParams.append(key, params[key]);
|
||||
}
|
||||
});
|
||||
/** 流式下载 */
|
||||
async function downloadByStream(
|
||||
readableStream: ReadableStream<Uint8Array>,
|
||||
filename: string,
|
||||
contentLength?: number
|
||||
): Promise<void> {
|
||||
window.$loading?.endLoading();
|
||||
StreamSaver.mitm = '/streamsaver/mitm.html?version=2.0.0';
|
||||
const fileStream = StreamSaver.createWriteStream(filename, { size: contentLength });
|
||||
|
||||
if (window.WritableStream && readableStream?.pipeTo) {
|
||||
await readableStream.pipeTo(fileStream);
|
||||
window.$message?.success('下载完成');
|
||||
return;
|
||||
}
|
||||
fetch(`${baseURL}${url}?t=${now}`, {
|
||||
method: 'post',
|
||||
body: searchParams,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Clientid: clientId!,
|
||||
'Content-Type': 'application/octet-stream'
|
||||
}
|
||||
})
|
||||
.then(async response => {
|
||||
if (response.headers.get('Content-Type')?.includes('application/json')) {
|
||||
const res = await response.json();
|
||||
const code = res.code as CommonType.ErrorCode;
|
||||
throw new Error(errorCodeRecord[code] || res.msg || errorCodeRecord.default);
|
||||
}
|
||||
return response.blob();
|
||||
})
|
||||
.then(data => downloadByData(data, fileName, 'application/zip'))
|
||||
.catch(err => window.$message?.error(err.message))
|
||||
.finally(() => window.$loading?.endLoading());
|
||||
|
||||
// 降级处理
|
||||
const writer = fileStream.getWriter();
|
||||
const reader = readableStream.getReader();
|
||||
|
||||
const pump = async (): Promise<void> => {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return writer.close();
|
||||
await writer.write(value);
|
||||
return pump();
|
||||
};
|
||||
|
||||
await pump();
|
||||
}
|
||||
|
||||
function oss(ossId: CommonType.IdType) {
|
||||
window.$loading?.startLoading('正在下载数据,请稍候...');
|
||||
const token = localStg.get('token');
|
||||
const clientId = import.meta.env.VITE_APP_CLIENT_ID;
|
||||
const url = `/resource/oss/download/${ossId}`;
|
||||
const now = Date.now();
|
||||
let fileName = String(`${ossId}-${now}`);
|
||||
fetch(`${baseURL}${url}?t=${now}`, {
|
||||
method: 'get',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Clientid: clientId!,
|
||||
'Content-Type': 'application/octet-stream'
|
||||
}
|
||||
})
|
||||
.then(async response => {
|
||||
fileName = String(response.headers.get('Download-Filename'));
|
||||
return response.blob();
|
||||
})
|
||||
.then(data => downloadByData(data, fileName))
|
||||
.catch(err => window.$message?.error(err.message))
|
||||
.finally(() => window.$loading?.endLoading());
|
||||
/** 处理响应 */
|
||||
async function handleResponse(response: Response) {
|
||||
if (response.headers.get('Content-Type')?.includes('application/json')) {
|
||||
const res = await response.json();
|
||||
const code = res.code as CommonType.ErrorCode;
|
||||
throw new Error(errorCodeRecord[code] || res.msg || errorCodeRecord.default);
|
||||
}
|
||||
}
|
||||
|
||||
function zip(url: string, fileName: string) {
|
||||
/** 核心下载逻辑 */
|
||||
async function executeDownload(config: RequestConfig): Promise<void> {
|
||||
const { method, url, params, filename, contentType } = config;
|
||||
const timestamp = Date.now();
|
||||
const fullUrl = `${baseURL}${url}${url.includes('?') ? '&' : '?'}t=${timestamp}`;
|
||||
|
||||
window.$loading?.startLoading('正在下载数据,请稍候...');
|
||||
const token = localStg.get('token');
|
||||
const clientId = import.meta.env.VITE_APP_CLIENT_ID;
|
||||
const now = Date.now();
|
||||
fetch(`${baseURL}${url}${url.includes('?') ? '&' : '?'}t=${now}`, {
|
||||
method: 'get',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Clientid: clientId!,
|
||||
'Content-Type': 'application/octet-stream'
|
||||
|
||||
try {
|
||||
const requestOptions: RequestInit = {
|
||||
method,
|
||||
headers: getCommonHeaders(contentType)
|
||||
};
|
||||
|
||||
if (method === 'POST' && params) {
|
||||
requestOptions.body = transformToURLSearchParams(params);
|
||||
requestOptions.headers = {
|
||||
...requestOptions.headers,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
};
|
||||
}
|
||||
})
|
||||
.then(async response => {
|
||||
if (response.headers.get('Content-Type')?.includes('application/json')) {
|
||||
const res = await response.json();
|
||||
const code = res.code as CommonType.ErrorCode;
|
||||
throw new Error(errorCodeRecord[code] || res.msg || errorCodeRecord.default);
|
||||
}
|
||||
return response.blob();
|
||||
})
|
||||
.then(data => downloadByData(data, fileName, 'application/zip'))
|
||||
.catch(err => window.$message?.error(err.message))
|
||||
.finally(() => window.$loading?.endLoading());
|
||||
|
||||
const response = await fetch(fullUrl, requestOptions);
|
||||
|
||||
await handleResponse(response);
|
||||
|
||||
const rawHeader = response.headers.get('Download-Filename');
|
||||
const finalFilename = filename || (rawHeader ? decodeURIComponent(rawHeader) : null) || `download-${timestamp}`;
|
||||
|
||||
if (response.body && isHttps()) {
|
||||
const contentLength = Number(response.headers.get('Content-Length'));
|
||||
await downloadByStream(response.body, finalFilename, contentLength);
|
||||
return;
|
||||
}
|
||||
|
||||
const responseContentType = response.headers.get('Content-Type');
|
||||
const mainType = responseContentType?.split(';')[0]?.trim() || 'application/octet-stream';
|
||||
downloadByData(await response.blob(), finalFilename, mainType);
|
||||
} catch (error: any) {
|
||||
window.$message?.error(error.message);
|
||||
} finally {
|
||||
window.$loading?.endLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/** 公共下载接口 */
|
||||
const download = (url: string, params: Record<string, any>, filename: string) =>
|
||||
executeDownload({ method: 'POST', url, params, filename });
|
||||
|
||||
/** OSS文件下载 */
|
||||
const oss = (ossId: CommonType.IdType) =>
|
||||
executeDownload({
|
||||
method: 'GET',
|
||||
url: `/resource/oss/download/${ossId}`
|
||||
});
|
||||
|
||||
/** ZIP文件下载 */
|
||||
const zip = (url: string, filename: string) =>
|
||||
executeDownload({
|
||||
method: 'GET',
|
||||
url,
|
||||
filename,
|
||||
contentType: 'application/octet-stream'
|
||||
});
|
||||
|
||||
return {
|
||||
oss,
|
||||
zip,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { computed, effectScope, nextTick, onScopeDispose, ref, watch } from 'vue';
|
||||
import { computed, effectScope, nextTick, onScopeDispose, shallowRef, watch } from 'vue';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { BarChart, GaugeChart, LineChart, PictorialBarChart, PieChart, RadarChart, ScatterChart } from 'echarts/charts';
|
||||
@ -86,11 +86,11 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
|
||||
const themeStore = useThemeStore();
|
||||
const darkMode = computed(() => themeStore.darkMode);
|
||||
|
||||
const domRef = ref<HTMLElement | null>(null);
|
||||
const domRef = shallowRef<HTMLElement | null>(null);
|
||||
const initialSize = { width: 0, height: 0 };
|
||||
const { width, height } = useElementSize(domRef, initialSize);
|
||||
|
||||
let chart: echarts.ECharts | null = null;
|
||||
const chart = shallowRef<echarts.ECharts | null>(null);
|
||||
const chartOptions: T = optionsFactory();
|
||||
|
||||
const {
|
||||
@ -111,18 +111,9 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
|
||||
onDestroy
|
||||
} = hooks;
|
||||
|
||||
/**
|
||||
* whether can render chart
|
||||
*
|
||||
* when domRef is ready and initialSize is valid
|
||||
*/
|
||||
function canRender() {
|
||||
return domRef.value && initialSize.width > 0 && initialSize.height > 0;
|
||||
}
|
||||
|
||||
/** is chart rendered */
|
||||
function isRendered() {
|
||||
return Boolean(domRef.value && chart);
|
||||
return Boolean(domRef.value && chart.value);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -131,59 +122,59 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
|
||||
* @param callback callback function
|
||||
*/
|
||||
async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) {
|
||||
if (!isRendered()) return;
|
||||
|
||||
const updatedOpts = callback(chartOptions, optionsFactory);
|
||||
|
||||
Object.assign(chartOptions, updatedOpts);
|
||||
|
||||
await nextTick();
|
||||
|
||||
if (!isRendered()) return;
|
||||
|
||||
if (isRendered()) {
|
||||
chart?.clear();
|
||||
chart.value?.clear();
|
||||
}
|
||||
|
||||
chart?.setOption({ ...updatedOpts, backgroundColor: 'transparent' });
|
||||
chart.value?.setOption({ ...updatedOpts, backgroundColor: 'transparent' });
|
||||
|
||||
await onUpdated?.(chart!);
|
||||
await onUpdated?.(chart.value!);
|
||||
}
|
||||
|
||||
function setOptions(options: T) {
|
||||
chart?.setOption(options);
|
||||
chart.value?.setOption(options);
|
||||
}
|
||||
|
||||
/** render chart */
|
||||
async function render() {
|
||||
if (!isRendered()) {
|
||||
const chartTheme = darkMode.value ? 'dark' : 'light';
|
||||
if (isRendered()) return;
|
||||
|
||||
await nextTick();
|
||||
const chartTheme = darkMode.value ? 'dark' : 'light';
|
||||
|
||||
chart = echarts.init(domRef.value, chartTheme);
|
||||
chart.value = echarts.init(domRef.value, chartTheme);
|
||||
|
||||
chart.setOption({ ...chartOptions, backgroundColor: 'transparent' });
|
||||
chart.value?.setOption({ ...chartOptions, backgroundColor: 'transparent' });
|
||||
|
||||
await onRender?.(chart);
|
||||
}
|
||||
await onRender?.(chart.value!);
|
||||
}
|
||||
|
||||
/** resize chart */
|
||||
function resize() {
|
||||
chart?.resize();
|
||||
chart.value?.resize();
|
||||
}
|
||||
|
||||
/** destroy chart */
|
||||
async function destroy() {
|
||||
if (!chart) return;
|
||||
if (!chart.value) return;
|
||||
|
||||
await onDestroy?.(chart);
|
||||
chart?.dispose();
|
||||
chart = null;
|
||||
await onDestroy?.(chart.value);
|
||||
chart.value?.dispose();
|
||||
chart.value = null;
|
||||
}
|
||||
|
||||
/** change chart theme */
|
||||
async function changeTheme() {
|
||||
await destroy();
|
||||
await render();
|
||||
await onUpdated?.(chart!);
|
||||
await onUpdated?.(chart.value!);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -196,30 +187,29 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
|
||||
initialSize.width = w;
|
||||
initialSize.height = h;
|
||||
|
||||
// size is abnormal, destroy chart
|
||||
if (!canRender()) {
|
||||
await destroy();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// resize chart
|
||||
if (isRendered()) {
|
||||
resize();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// render chart
|
||||
await render();
|
||||
|
||||
if (chart) {
|
||||
await onUpdated?.(chart);
|
||||
if (chart.value) {
|
||||
await onUpdated?.(chart.value);
|
||||
}
|
||||
}
|
||||
|
||||
scope.run(() => {
|
||||
watch([width, height], ([newWidth, newHeight]) => {
|
||||
renderChartBySize(newWidth, newHeight);
|
||||
});
|
||||
watch(
|
||||
[width, height],
|
||||
([newWidth, newHeight]) => {
|
||||
renderChartBySize(newWidth, newHeight);
|
||||
},
|
||||
{ flush: 'post' }
|
||||
);
|
||||
|
||||
watch(darkMode, () => {
|
||||
changeTheme();
|
||||
@ -233,6 +223,7 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
|
||||
|
||||
return {
|
||||
domRef,
|
||||
chart,
|
||||
updateOptions,
|
||||
setOptions
|
||||
};
|
||||
|
@ -2,6 +2,7 @@ import { ref, toValue } from 'vue';
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import type { FormInst } from 'naive-ui';
|
||||
import { REG_CODE_SIX, REG_EMAIL, REG_PHONE, REG_PWD, REG_USER_NAME } from '@/constants/reg';
|
||||
import { isNull } from '@/utils/common';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
export function useFormRules() {
|
||||
@ -52,7 +53,7 @@ export function useFormRules() {
|
||||
required: true,
|
||||
trigger: ['input', 'blur'],
|
||||
validator: (_rule: any, value: any) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
if (isNull(value) || (Array.isArray(value) && value.length === 0)) {
|
||||
return new Error(message);
|
||||
}
|
||||
return true;
|
||||
|
@ -1,195 +1,55 @@
|
||||
import { computed, effectScope, onScopeDispose, reactive, ref, watch } from 'vue';
|
||||
import { computed, effectScope, onScopeDispose, reactive, ref, shallowRef, watch } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import type { PaginationProps } from 'naive-ui';
|
||||
import { useBoolean, useTable } from '@sa/hooks';
|
||||
import type { PaginationData, TableColumnCheck, UseTableOptions } from '@sa/hooks';
|
||||
import type { FlatResponseData } from '@sa/axios';
|
||||
import { jsonClone } from '@sa/utils';
|
||||
import { useBoolean, useHookTable } from '@sa/hooks';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
type TableData = NaiveUI.TableData;
|
||||
type GetTableData<A extends NaiveUI.TableApiFn> = NaiveUI.GetTableData<A>;
|
||||
type TableColumn<T> = NaiveUI.TableColumn<T>;
|
||||
export type UseNaiveTableOptions<ResponseData, ApiData, Pagination extends boolean> = Omit<
|
||||
UseTableOptions<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, Pagination>,
|
||||
'pagination' | 'getColumnChecks' | 'getColumns'
|
||||
> & {
|
||||
/**
|
||||
* get column visible
|
||||
*
|
||||
* @param column
|
||||
*
|
||||
* @default true
|
||||
*
|
||||
* @returns true if the column is visible, false otherwise
|
||||
*/
|
||||
getColumnVisible?: (column: NaiveUI.TableColumn<ApiData>) => boolean;
|
||||
};
|
||||
|
||||
export function useTable<A extends NaiveUI.TableApiFn>(config: NaiveUI.NaiveTableConfig<A>) {
|
||||
const SELECTION_KEY = '__selection__';
|
||||
|
||||
const EXPAND_KEY = '__expand__';
|
||||
|
||||
export function useNaiveTable<ResponseData, ApiData>(options: UseNaiveTableOptions<ResponseData, ApiData, false>) {
|
||||
const scope = effectScope();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const isMobile = computed(() => appStore.isMobile);
|
||||
|
||||
const { apiFn, apiParams, immediate, showTotal = true } = config;
|
||||
|
||||
const SELECTION_KEY = '__selection__';
|
||||
|
||||
const EXPAND_KEY = '__expand__';
|
||||
|
||||
const {
|
||||
loading,
|
||||
empty,
|
||||
data,
|
||||
columns,
|
||||
columnChecks,
|
||||
reloadColumns,
|
||||
getData,
|
||||
searchParams,
|
||||
updateSearchParams,
|
||||
resetSearchParams
|
||||
} = useHookTable<A, GetTableData<A>, TableColumn<NaiveUI.TableDataWithIndex<GetTableData<A>>>>({
|
||||
apiFn,
|
||||
apiParams,
|
||||
columns: config.columns,
|
||||
transformer: res => {
|
||||
const { rows: records = [], total = 0 } = res.data || {};
|
||||
|
||||
const current = searchParams.pageNum as number;
|
||||
const size = (searchParams.pageSize || 0) as number;
|
||||
|
||||
// Ensure that the size is greater than 0, If it is less than 0, it will cause paging calculation errors.
|
||||
const pageSize = size <= 0 ? 10 : size;
|
||||
|
||||
const recordsWithIndex = records.map((item, index) => {
|
||||
return {
|
||||
...item,
|
||||
index: (current - 1) * pageSize + index + 1
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
data: recordsWithIndex,
|
||||
pageNum: current,
|
||||
pageSize,
|
||||
total
|
||||
};
|
||||
},
|
||||
getColumnChecks: cols => {
|
||||
const checks: NaiveUI.TableColumnCheck[] = [];
|
||||
|
||||
cols.forEach(column => {
|
||||
if (isTableColumnHasKey(column)) {
|
||||
checks.push({
|
||||
key: column.key as string,
|
||||
title: column.title!,
|
||||
checked: true
|
||||
});
|
||||
} else if (column.type === 'selection') {
|
||||
checks.push({
|
||||
key: SELECTION_KEY,
|
||||
title: $t('common.check'),
|
||||
checked: true
|
||||
});
|
||||
} else if (column.type === 'expand') {
|
||||
checks.push({
|
||||
key: EXPAND_KEY,
|
||||
title: $t('common.expandColumn'),
|
||||
checked: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return checks;
|
||||
},
|
||||
getColumns: (cols, checks) => {
|
||||
const columnMap = new Map<string, TableColumn<GetTableData<A>>>();
|
||||
|
||||
cols.forEach(column => {
|
||||
if (isTableColumnHasKey(column)) {
|
||||
columnMap.set(column.key as string, column);
|
||||
} else if (column.type === 'selection') {
|
||||
columnMap.set(SELECTION_KEY, column);
|
||||
} else if (column.type === 'expand') {
|
||||
columnMap.set(EXPAND_KEY, column);
|
||||
}
|
||||
});
|
||||
|
||||
const filteredColumns = checks
|
||||
.filter(item => item.checked)
|
||||
.map(check => columnMap.get(check.key) as TableColumn<GetTableData<A>>);
|
||||
|
||||
return filteredColumns;
|
||||
},
|
||||
onFetched: async transformed => {
|
||||
const { total } = transformed;
|
||||
|
||||
updatePagination({
|
||||
page: searchParams.pageNum,
|
||||
pageSize: searchParams.pageSize,
|
||||
itemCount: total
|
||||
});
|
||||
},
|
||||
immediate
|
||||
const result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, false>({
|
||||
...options,
|
||||
getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
|
||||
getColumns
|
||||
});
|
||||
|
||||
const pagination: PaginationProps = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
showSizePicker: true,
|
||||
itemCount: 0,
|
||||
pageSizes: [10, 15, 20, 25, 30],
|
||||
onUpdatePage: async (page: number) => {
|
||||
pagination.page = page;
|
||||
|
||||
updateSearchParams({
|
||||
pageNum: page,
|
||||
pageSize: pagination.pageSize!
|
||||
});
|
||||
|
||||
getData();
|
||||
},
|
||||
onUpdatePageSize: async (pageSize: number) => {
|
||||
pagination.pageSize = pageSize;
|
||||
pagination.page = 1;
|
||||
|
||||
updateSearchParams({
|
||||
pageNum: pagination.page,
|
||||
pageSize
|
||||
});
|
||||
|
||||
getData();
|
||||
},
|
||||
...(showTotal
|
||||
? {
|
||||
prefix: page => $t('datatable.itemCount', { total: page.itemCount })
|
||||
}
|
||||
: {})
|
||||
// calculate the total width of the table this is used for horizontal scrolling
|
||||
const scrollX = computed(() => {
|
||||
return result.columns.value.reduce((acc, column) => {
|
||||
return acc + Number(column.width ?? column.minWidth ?? 120);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// this is for mobile, if the system does not support mobile, you can use `pagination` directly
|
||||
const mobilePagination = computed(() => {
|
||||
const p: PaginationProps = {
|
||||
...pagination,
|
||||
pageSlot: isMobile.value ? 3 : 9,
|
||||
prefix: !isMobile.value && showTotal ? pagination.prefix : undefined
|
||||
};
|
||||
|
||||
return p;
|
||||
});
|
||||
|
||||
function updatePagination(update: Partial<PaginationProps>) {
|
||||
Object.assign(pagination, update);
|
||||
}
|
||||
|
||||
/**
|
||||
* get data by page number
|
||||
*
|
||||
* @param pageNum the page number. default is 1
|
||||
*/
|
||||
async function getDataByPage(pageNum: number = 1) {
|
||||
updatePagination({
|
||||
page: pageNum
|
||||
});
|
||||
|
||||
updateSearchParams({
|
||||
pageNum,
|
||||
pageSize: pagination.pageSize!
|
||||
});
|
||||
|
||||
await getData();
|
||||
}
|
||||
|
||||
scope.run(() => {
|
||||
watch(
|
||||
() => appStore.locale,
|
||||
() => {
|
||||
reloadColumns();
|
||||
result.reloadColumns();
|
||||
}
|
||||
);
|
||||
});
|
||||
@ -199,27 +59,126 @@ export function useTable<A extends NaiveUI.TableApiFn>(config: NaiveUI.NaiveTabl
|
||||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
empty,
|
||||
data,
|
||||
columns,
|
||||
columnChecks,
|
||||
reloadColumns,
|
||||
pagination,
|
||||
mobilePagination,
|
||||
updatePagination,
|
||||
getData,
|
||||
getDataByPage,
|
||||
searchParams,
|
||||
updateSearchParams,
|
||||
resetSearchParams
|
||||
...result,
|
||||
scrollX
|
||||
};
|
||||
}
|
||||
|
||||
export function useTableOperate<T extends TableData = TableData>(data: Ref<T[]>, getData: () => Promise<void>) {
|
||||
type PaginationParams = Pick<PaginationProps, 'page' | 'pageSize'>;
|
||||
|
||||
type UseNaivePaginatedTableOptions<ResponseData, ApiData> = UseNaiveTableOptions<ResponseData, ApiData, true> & {
|
||||
paginationProps?: Omit<PaginationProps, 'page' | 'pageSize' | 'itemCount'>;
|
||||
/**
|
||||
* whether to show the total count of the table
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
showTotal?: boolean;
|
||||
onPaginationParamsChange?: (params: PaginationParams) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function useNaivePaginatedTable<ResponseData, ApiData>(
|
||||
options: UseNaivePaginatedTableOptions<ResponseData, ApiData>
|
||||
) {
|
||||
const scope = effectScope();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const isMobile = computed(() => appStore.isMobile);
|
||||
|
||||
const showTotal = computed(() => options.showTotal ?? true);
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
itemCount: 0,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 15, 20, 25, 30],
|
||||
prefix: showTotal.value ? page => $t('datatable.itemCount', { total: page.itemCount }) : undefined,
|
||||
onUpdatePage(page) {
|
||||
pagination.page = page;
|
||||
},
|
||||
onUpdatePageSize(pageSize) {
|
||||
pagination.pageSize = pageSize;
|
||||
pagination.page = 1;
|
||||
},
|
||||
...options.paginationProps
|
||||
}) as PaginationProps;
|
||||
|
||||
// this is for mobile, if the system does not support mobile, you can use `pagination` directly
|
||||
const mobilePagination = computed(() => {
|
||||
const p: PaginationProps = {
|
||||
...pagination,
|
||||
pageSlot: isMobile.value ? 3 : 9,
|
||||
prefix: !isMobile.value && showTotal.value ? pagination.prefix : undefined
|
||||
};
|
||||
|
||||
return p;
|
||||
});
|
||||
|
||||
const paginationParams = computed(() => {
|
||||
const { page, pageSize } = pagination;
|
||||
|
||||
return {
|
||||
page,
|
||||
pageSize
|
||||
};
|
||||
});
|
||||
|
||||
const result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, true>({
|
||||
...options,
|
||||
pagination: true,
|
||||
getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
|
||||
getColumns,
|
||||
onFetched: data => {
|
||||
pagination.itemCount = data.total;
|
||||
}
|
||||
});
|
||||
|
||||
async function getDataByPage(page: number = 1) {
|
||||
if (page !== pagination.page) {
|
||||
pagination.page = page;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await result.getData();
|
||||
}
|
||||
|
||||
scope.run(() => {
|
||||
watch(
|
||||
() => appStore.locale,
|
||||
() => {
|
||||
result.reloadColumns();
|
||||
}
|
||||
);
|
||||
|
||||
watch(paginationParams, async newVal => {
|
||||
await options.onPaginationParamsChange?.(newVal);
|
||||
|
||||
await result.getData();
|
||||
});
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
getDataByPage,
|
||||
pagination,
|
||||
mobilePagination
|
||||
};
|
||||
}
|
||||
|
||||
export function useTableOperate<TableData>(
|
||||
data: Ref<TableData[]>,
|
||||
idKey: keyof TableData,
|
||||
getData: () => Promise<void>
|
||||
) {
|
||||
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean();
|
||||
|
||||
const operateType = ref<NaiveUI.TableOperateType>('add');
|
||||
const operateType = shallowRef<NaiveUI.TableOperateType>('add');
|
||||
|
||||
function handleAdd() {
|
||||
operateType.value = 'add';
|
||||
@ -227,18 +186,18 @@ export function useTableOperate<T extends TableData = TableData>(data: Ref<T[]>,
|
||||
}
|
||||
|
||||
/** the editing row data */
|
||||
const editingData: Ref<T | null> = ref(null);
|
||||
const editingData = shallowRef<TableData | null>(null);
|
||||
|
||||
function handleEdit(field: keyof T, id: CommonType.IdType) {
|
||||
function handleEdit(id: TableData[keyof TableData]) {
|
||||
operateType.value = 'edit';
|
||||
const findItem = data.value.find(item => item[field] === id) || null;
|
||||
const findItem = data.value.find(item => item[idKey] === id) || null;
|
||||
editingData.value = jsonClone(findItem);
|
||||
|
||||
openDrawer();
|
||||
}
|
||||
|
||||
/** the checked row keys of table */
|
||||
const checkedRowKeys = ref<CommonType.IdType[]>([]);
|
||||
const checkedRowKeys = shallowRef<string[]>([]);
|
||||
|
||||
/** the hook after the batch delete operation is completed */
|
||||
async function onBatchDeleted() {
|
||||
@ -270,6 +229,285 @@ export function useTableOperate<T extends TableData = TableData>(data: Ref<T[]>,
|
||||
};
|
||||
}
|
||||
|
||||
function isTableColumnHasKey<T>(column: TableColumn<T>): column is NaiveUI.TableColumnWithKey<T> {
|
||||
export function defaultTransform<ApiData>(
|
||||
response: FlatResponseData<any, Api.Common.PaginatingQueryRecord<ApiData>>
|
||||
): PaginationData<ApiData> {
|
||||
const { data, error } = response;
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
data: [],
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
const { rows: records, pageSize: current, pageNum: size, total } = data;
|
||||
|
||||
return {
|
||||
data: records,
|
||||
pageNum: current,
|
||||
pageSize: size,
|
||||
total
|
||||
};
|
||||
}
|
||||
|
||||
type UseNaiveTreeTableOptions<ResponseData, ApiData> = UseNaiveTableOptions<ResponseData, ApiData, false> & {
|
||||
keyField: keyof ApiData;
|
||||
defaultExpandAll?: boolean;
|
||||
};
|
||||
|
||||
export function useNaiveTreeTable<ResponseData, ApiData>(options: UseNaiveTreeTableOptions<ResponseData, ApiData>) {
|
||||
const scope = effectScope();
|
||||
const appStore = useAppStore();
|
||||
const rows: Ref<ApiData[]> = ref([]);
|
||||
|
||||
const result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, false>({
|
||||
...options,
|
||||
pagination: false,
|
||||
getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
|
||||
getColumns,
|
||||
onFetched: transformData => {
|
||||
const data: ApiData[] = [];
|
||||
|
||||
const collect = (nodes: any[]) => {
|
||||
nodes.forEach(node => {
|
||||
data.push(node);
|
||||
if (node?.children?.length) {
|
||||
collect(node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
collect(transformData);
|
||||
rows.value = data;
|
||||
}
|
||||
});
|
||||
|
||||
const { keyField = 'id', defaultExpandAll = false } = options;
|
||||
|
||||
const expandedRowKeys = ref<ApiData[keyof ApiData][]>([]);
|
||||
const { bool: isCollapse, toggle: toggleCollapse } = useBoolean(defaultExpandAll);
|
||||
|
||||
/** expand all nodes */
|
||||
function expandAll() {
|
||||
toggleCollapse();
|
||||
expandedRowKeys.value = rows.value.map(item => item[keyField as keyof ApiData]);
|
||||
}
|
||||
|
||||
/** collapse all nodes */
|
||||
function collapseAll() {
|
||||
toggleCollapse();
|
||||
expandedRowKeys.value = [];
|
||||
}
|
||||
|
||||
scope.run(() => {
|
||||
watch(
|
||||
() => appStore.locale,
|
||||
() => {
|
||||
result.reloadColumns();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
rows,
|
||||
isCollapse,
|
||||
expandedRowKeys,
|
||||
expandAll,
|
||||
collapseAll
|
||||
};
|
||||
}
|
||||
|
||||
export function useTreeTableOperate<ApiData>(data: Ref<ApiData[]>, idKey: keyof ApiData, getData: () => Promise<void>) {
|
||||
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean();
|
||||
|
||||
const operateType = shallowRef<NaiveUI.TableOperateType>('add');
|
||||
|
||||
function handleAdd() {
|
||||
operateType.value = 'add';
|
||||
openDrawer();
|
||||
}
|
||||
|
||||
/** the editing row data */
|
||||
const editingData = shallowRef<ApiData | null>(null);
|
||||
|
||||
function handleEdit(id: ApiData[keyof ApiData]) {
|
||||
operateType.value = 'edit';
|
||||
const findItem = data.value.find(item => item[idKey] === id) || null;
|
||||
editingData.value = jsonClone(findItem);
|
||||
|
||||
openDrawer();
|
||||
}
|
||||
|
||||
/** the checked row keys of table */
|
||||
const checkedRowKeys = shallowRef<string[]>([]);
|
||||
|
||||
/** the hook after the batch delete operation is completed */
|
||||
async function onBatchDeleted() {
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
|
||||
checkedRowKeys.value = [];
|
||||
|
||||
await getData();
|
||||
}
|
||||
|
||||
/** the hook after the delete operation is completed */
|
||||
async function onDeleted() {
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
|
||||
await getData();
|
||||
}
|
||||
|
||||
return {
|
||||
drawerVisible,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
operateType,
|
||||
handleAdd,
|
||||
editingData,
|
||||
handleEdit,
|
||||
checkedRowKeys,
|
||||
onBatchDeleted,
|
||||
onDeleted
|
||||
};
|
||||
}
|
||||
|
||||
type TreeTableOptions<ApiData> = {
|
||||
/** id field name */
|
||||
idField?: keyof ApiData;
|
||||
/** parent id field name */
|
||||
parentIdField?: keyof ApiData;
|
||||
/** children field name */
|
||||
childrenField?: keyof ApiData;
|
||||
/** filter function */
|
||||
filterFn?: (node: ApiData) => boolean;
|
||||
};
|
||||
|
||||
export function treeTransform<ApiData>(
|
||||
response: FlatResponseData<any, ApiData[]>,
|
||||
options: TreeTableOptions<ApiData>
|
||||
): ApiData[] {
|
||||
const { data, error } = response;
|
||||
|
||||
if (error || !data.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { idField = 'id', parentIdField = 'parentId', childrenField = 'children', filterFn = () => true } = options;
|
||||
|
||||
// 使用 Map 替代普通对象,提高性能
|
||||
const childrenMap = new Map<ApiData[keyof ApiData], ApiData[]>();
|
||||
const nodeMap = new Map<ApiData[keyof ApiData], ApiData>();
|
||||
const tree: ApiData[] = [];
|
||||
|
||||
// 第一遍遍历:构建节点映射
|
||||
for (const item of data) {
|
||||
const id = item[idField as keyof ApiData];
|
||||
const parentId = item[parentIdField as keyof ApiData];
|
||||
|
||||
nodeMap.set(id, item);
|
||||
|
||||
if (!childrenMap.has(parentId)) {
|
||||
childrenMap.set(parentId, []);
|
||||
}
|
||||
// 应用过滤函数
|
||||
if (filterFn(item)) {
|
||||
childrenMap.get(parentId)!.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// 第二遍遍历:找出根节点
|
||||
for (const item of data) {
|
||||
const parentId = item[parentIdField as keyof ApiData];
|
||||
if (!nodeMap.has(parentId) && filterFn(item)) {
|
||||
tree.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// 递归构建树形结构
|
||||
const buildTree = (node: ApiData) => {
|
||||
const id = node[idField as keyof ApiData];
|
||||
const children = childrenMap.get(id);
|
||||
|
||||
if (children?.length) {
|
||||
// 使用类型断言确保类型安全
|
||||
(node as any)[childrenField] = children;
|
||||
for (const child of children) {
|
||||
buildTree(child);
|
||||
}
|
||||
} else {
|
||||
// 如果没有子节点,设置为 undefined
|
||||
(node as any)[childrenField] = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// 从根节点开始构建树
|
||||
for (const root of tree) {
|
||||
buildTree(root);
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
function getColumnChecks<Column extends NaiveUI.TableColumn<any>>(
|
||||
cols: Column[],
|
||||
getColumnVisible?: (column: Column) => boolean
|
||||
) {
|
||||
const checks: TableColumnCheck[] = [];
|
||||
|
||||
cols.forEach(column => {
|
||||
if (isTableColumnHasKey(column)) {
|
||||
checks.push({
|
||||
key: column.key as string,
|
||||
title: column.title!,
|
||||
checked: true,
|
||||
visible: getColumnVisible?.(column) ?? true
|
||||
});
|
||||
} else if (column.type === 'selection') {
|
||||
checks.push({
|
||||
key: SELECTION_KEY,
|
||||
title: $t('common.check'),
|
||||
checked: true,
|
||||
visible: getColumnVisible?.(column) ?? false
|
||||
});
|
||||
} else if (column.type === 'expand') {
|
||||
checks.push({
|
||||
key: EXPAND_KEY,
|
||||
title: $t('common.expandColumn'),
|
||||
checked: true,
|
||||
visible: getColumnVisible?.(column) ?? false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
function getColumns<Column extends NaiveUI.TableColumn<any>>(cols: Column[], checks: TableColumnCheck[]) {
|
||||
const columnMap = new Map<string, Column>();
|
||||
|
||||
cols.forEach(column => {
|
||||
if (isTableColumnHasKey(column)) {
|
||||
columnMap.set(column.key as string, column);
|
||||
} else if (column.type === 'selection') {
|
||||
columnMap.set(SELECTION_KEY, column);
|
||||
} else if (column.type === 'expand') {
|
||||
columnMap.set(EXPAND_KEY, column);
|
||||
}
|
||||
});
|
||||
|
||||
const filteredColumns = checks.filter(item => item.checked).map(check => columnMap.get(check.key) as Column);
|
||||
|
||||
return filteredColumns;
|
||||
}
|
||||
|
||||
export function isTableColumnHasKey<T>(column: NaiveUI.TableColumn<T>): column is NaiveUI.TableColumnWithKey<T> {
|
||||
return Boolean((column as NaiveUI.TableColumnWithKey<T>).key);
|
||||
}
|
||||
|
@ -1,237 +0,0 @@
|
||||
import { effectScope, onScopeDispose, ref, watch } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import { jsonClone } from '@sa/utils';
|
||||
import { useBoolean, useHookTable } from '@sa/hooks';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { handleTree } from '@/utils/common';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
type TableData = NaiveUI.TableData;
|
||||
type GetTableData<A extends NaiveUI.TreeTableApiFn> = NaiveUI.GetTreeTableData<A>;
|
||||
type TableColumn<T> = NaiveUI.TableColumn<T>;
|
||||
|
||||
export function useTreeTable<A extends NaiveUI.TreeTableApiFn>(
|
||||
config: NaiveUI.NaiveTreeTableConfig<A> & CommonType.TreeConfig & { defaultExpandAll?: boolean }
|
||||
) {
|
||||
const scope = effectScope();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const {
|
||||
apiFn,
|
||||
apiParams,
|
||||
immediate,
|
||||
idField,
|
||||
parentIdField = 'parentId',
|
||||
childrenField = 'children',
|
||||
defaultExpandAll = false
|
||||
} = config;
|
||||
|
||||
const SELECTION_KEY = '__selection__';
|
||||
const EXPAND_KEY = '__expand__';
|
||||
|
||||
const expandedRowKeys = ref<CommonType.IdType[]>([]);
|
||||
|
||||
const {
|
||||
loading,
|
||||
empty,
|
||||
data,
|
||||
columns,
|
||||
columnChecks,
|
||||
reloadColumns,
|
||||
getData,
|
||||
searchParams,
|
||||
updateSearchParams,
|
||||
resetSearchParams
|
||||
} = useHookTable<A, GetTableData<A>, TableColumn<NaiveUI.TableDataWithIndex<GetTableData<A>>>>({
|
||||
apiFn,
|
||||
apiParams,
|
||||
columns: config.columns,
|
||||
transformer: res => {
|
||||
const records = res.data || [];
|
||||
if (!records.length) return { data: [] };
|
||||
|
||||
const treeData = handleTree(records, {
|
||||
idField,
|
||||
parentIdField,
|
||||
childrenField
|
||||
});
|
||||
|
||||
// if defaultExpandAll is true, expand all nodes
|
||||
expandedRowKeys.value = defaultExpandAll
|
||||
? records.map(item => item[idField])
|
||||
: records.filter(item => item[parentIdField] === 0).map(item => item[idField]) || [];
|
||||
|
||||
return { data: treeData };
|
||||
},
|
||||
getColumnChecks: cols => {
|
||||
const checks: NaiveUI.TableColumnCheck[] = [];
|
||||
|
||||
cols.forEach(column => {
|
||||
if (isTableColumnHasKey(column)) {
|
||||
checks.push({
|
||||
key: column.key as string,
|
||||
title: column.title as string,
|
||||
checked: true
|
||||
});
|
||||
} else if (column.type === 'selection') {
|
||||
checks.push({
|
||||
key: SELECTION_KEY,
|
||||
title: $t('common.check'),
|
||||
checked: true
|
||||
});
|
||||
} else if (column.type === 'expand') {
|
||||
checks.push({
|
||||
key: EXPAND_KEY,
|
||||
title: $t('common.expandColumn'),
|
||||
checked: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return checks;
|
||||
},
|
||||
getColumns: (cols, checks) => {
|
||||
const columnMap = new Map<string, TableColumn<GetTableData<A>>>();
|
||||
|
||||
cols.forEach(column => {
|
||||
if (isTableColumnHasKey(column)) {
|
||||
columnMap.set(column.key as string, column);
|
||||
} else if (column.type === 'selection') {
|
||||
columnMap.set(SELECTION_KEY, column);
|
||||
} else if (column.type === 'expand') {
|
||||
columnMap.set(EXPAND_KEY, column);
|
||||
}
|
||||
});
|
||||
|
||||
const filteredColumns = checks
|
||||
.filter(item => item.checked)
|
||||
.map(check => columnMap.get(check.key) as TableColumn<GetTableData<A>>);
|
||||
|
||||
return filteredColumns;
|
||||
},
|
||||
immediate
|
||||
});
|
||||
|
||||
/** 收集所有节点的key */
|
||||
function collectAllNodeKeys(treeNodes: any[]): CommonType.IdType[] {
|
||||
const keys: CommonType.IdType[] = [];
|
||||
|
||||
const collect = (nodes: any[]) => {
|
||||
nodes.forEach(node => {
|
||||
keys.push(node[idField]);
|
||||
if (node[childrenField]?.length) {
|
||||
collect(node[childrenField]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
collect(treeNodes);
|
||||
return keys;
|
||||
}
|
||||
|
||||
const { bool: isCollapse, toggle: toggleCollapse } = useBoolean(defaultExpandAll);
|
||||
|
||||
/** expand all nodes */
|
||||
function expandAll() {
|
||||
toggleCollapse();
|
||||
expandedRowKeys.value = collectAllNodeKeys(data.value);
|
||||
}
|
||||
|
||||
/** collapse all nodes */
|
||||
function collapseAll() {
|
||||
toggleCollapse();
|
||||
expandedRowKeys.value = [];
|
||||
}
|
||||
|
||||
scope.run(() => {
|
||||
watch(
|
||||
() => appStore.locale,
|
||||
() => {
|
||||
reloadColumns();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
empty,
|
||||
data,
|
||||
columns,
|
||||
columnChecks,
|
||||
reloadColumns,
|
||||
getData,
|
||||
searchParams,
|
||||
updateSearchParams,
|
||||
resetSearchParams,
|
||||
expandedRowKeys,
|
||||
isCollapse,
|
||||
expandAll,
|
||||
collapseAll
|
||||
};
|
||||
}
|
||||
|
||||
export function useTreeTableOperate<T extends TableData = TableData>(_: Ref<T[]>, getData: () => Promise<void>) {
|
||||
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean();
|
||||
|
||||
const operateType = ref<NaiveUI.TableOperateType>('add');
|
||||
|
||||
function handleAdd() {
|
||||
operateType.value = 'add';
|
||||
openDrawer();
|
||||
}
|
||||
|
||||
/** the editing row data */
|
||||
const editingData: Ref<T | null> = ref(null);
|
||||
|
||||
function handleEdit(row: T) {
|
||||
operateType.value = 'edit';
|
||||
editingData.value = jsonClone(row);
|
||||
|
||||
openDrawer();
|
||||
}
|
||||
|
||||
/** the checked row keys of table */
|
||||
const checkedRowKeys = ref<CommonType.IdType[]>([]);
|
||||
|
||||
function clearCheckedRowKeys() {
|
||||
checkedRowKeys.value = [];
|
||||
}
|
||||
|
||||
/** the hook after the batch delete operation is completed */
|
||||
async function onBatchDeleted() {
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
|
||||
checkedRowKeys.value = [];
|
||||
|
||||
await getData();
|
||||
}
|
||||
|
||||
/** the hook after the delete operation is completed */
|
||||
async function onDeleted() {
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
|
||||
await getData();
|
||||
}
|
||||
|
||||
return {
|
||||
drawerVisible,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
operateType,
|
||||
handleAdd,
|
||||
editingData,
|
||||
handleEdit,
|
||||
checkedRowKeys,
|
||||
onBatchDeleted,
|
||||
onDeleted,
|
||||
clearCheckedRowKeys
|
||||
};
|
||||
}
|
||||
|
||||
function isTableColumnHasKey<T>(column: TableColumn<T>): column is NaiveUI.TableColumnWithKey<T> {
|
||||
return Boolean((column as NaiveUI.TableColumnWithKey<T>).key);
|
||||
}
|
@ -12,7 +12,7 @@ import GlobalTab from '../modules/global-tab/index.vue';
|
||||
import GlobalContent from '../modules/global-content/index.vue';
|
||||
import GlobalFooter from '../modules/global-footer/index.vue';
|
||||
import ThemeDrawer from '../modules/theme-drawer/index.vue';
|
||||
import { setupMixMenuContext } from '../context';
|
||||
import { provideMixMenuContext } from '../modules/global-menu/context';
|
||||
|
||||
defineOptions({
|
||||
name: 'BaseLayout'
|
||||
@ -20,7 +20,7 @@ defineOptions({
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { childLevelMenus, isActiveFirstLevelMenuHasChildren } = setupMixMenuContext();
|
||||
const { childLevelMenus, isActiveFirstLevelMenuHasChildren } = provideMixMenuContext();
|
||||
|
||||
const GlobalMenu = defineAsyncComponent(() => import('../modules/global-menu/index.vue'));
|
||||
|
||||
@ -31,7 +31,7 @@ const layoutMode = computed(() => {
|
||||
});
|
||||
|
||||
const headerProps = computed(() => {
|
||||
const { mode, reverseHorizontalMix } = themeStore.layout;
|
||||
const { mode } = themeStore.layout;
|
||||
|
||||
const headerPropsConfig: Record<UnionKey.ThemeLayoutMode, App.Global.HeaderProps> = {
|
||||
vertical: {
|
||||
@ -44,15 +44,25 @@ const headerProps = computed(() => {
|
||||
showMenu: false,
|
||||
showMenuToggler: false
|
||||
},
|
||||
'vertical-hybrid-header-first': {
|
||||
showLogo: !isActiveFirstLevelMenuHasChildren.value,
|
||||
showMenu: true,
|
||||
showMenuToggler: false
|
||||
},
|
||||
horizontal: {
|
||||
showLogo: true,
|
||||
showMenu: true,
|
||||
showMenuToggler: false
|
||||
},
|
||||
'horizontal-mix': {
|
||||
'top-hybrid-sidebar-first': {
|
||||
showLogo: true,
|
||||
showMenu: true,
|
||||
showMenuToggler: reverseHorizontalMix && isActiveFirstLevelMenuHasChildren.value
|
||||
showMenuToggler: false
|
||||
},
|
||||
'top-hybrid-header-first': {
|
||||
showLogo: true,
|
||||
showMenu: true,
|
||||
showMenuToggler: isActiveFirstLevelMenuHasChildren.value
|
||||
}
|
||||
};
|
||||
|
||||
@ -63,44 +73,56 @@ const siderVisible = computed(() => themeStore.layout.mode !== 'horizontal');
|
||||
|
||||
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix');
|
||||
|
||||
const isHorizontalMix = computed(() => themeStore.layout.mode === 'horizontal-mix');
|
||||
const isVerticalHybridHeaderFirst = computed(() => themeStore.layout.mode === 'vertical-hybrid-header-first');
|
||||
|
||||
const isTopHybridSidebarFirst = computed(() => themeStore.layout.mode === 'top-hybrid-sidebar-first');
|
||||
|
||||
const isTopHybridHeaderFirst = computed(() => themeStore.layout.mode === 'top-hybrid-header-first');
|
||||
|
||||
const siderWidth = computed(() => getSiderWidth());
|
||||
|
||||
const siderCollapsedWidth = computed(() => getSiderCollapsedWidth());
|
||||
|
||||
function getSiderWidth() {
|
||||
const { reverseHorizontalMix } = themeStore.layout;
|
||||
const { width, mixWidth, mixChildMenuWidth } = themeStore.sider;
|
||||
function getSiderAndCollapsedWidth(isCollapsed: boolean) {
|
||||
const {
|
||||
mixChildMenuWidth,
|
||||
collapsedWidth,
|
||||
width: themeWidth,
|
||||
mixCollapsedWidth,
|
||||
mixWidth: themeMixWidth
|
||||
} = themeStore.sider;
|
||||
|
||||
if (isHorizontalMix.value && reverseHorizontalMix) {
|
||||
const width = isCollapsed ? collapsedWidth : themeWidth;
|
||||
const mixWidth = isCollapsed ? mixCollapsedWidth : themeMixWidth;
|
||||
|
||||
if (isTopHybridHeaderFirst.value) {
|
||||
return isActiveFirstLevelMenuHasChildren.value ? width : 0;
|
||||
}
|
||||
|
||||
let w = isVerticalMix.value || isHorizontalMix.value ? mixWidth : width;
|
||||
|
||||
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
|
||||
w += mixChildMenuWidth;
|
||||
if (isVerticalHybridHeaderFirst.value && !isActiveFirstLevelMenuHasChildren.value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return w;
|
||||
const isMixMode = isVerticalMix.value || isTopHybridSidebarFirst.value || isVerticalHybridHeaderFirst.value;
|
||||
let finalWidth = isMixMode ? mixWidth : width;
|
||||
|
||||
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
|
||||
finalWidth += mixChildMenuWidth;
|
||||
}
|
||||
|
||||
if (isVerticalHybridHeaderFirst.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
|
||||
finalWidth += mixChildMenuWidth;
|
||||
}
|
||||
|
||||
return finalWidth;
|
||||
}
|
||||
|
||||
function getSiderWidth() {
|
||||
return getSiderAndCollapsedWidth(false);
|
||||
}
|
||||
|
||||
function getSiderCollapsedWidth() {
|
||||
const { reverseHorizontalMix } = themeStore.layout;
|
||||
const { collapsedWidth, mixCollapsedWidth, mixChildMenuWidth } = themeStore.sider;
|
||||
|
||||
if (isHorizontalMix.value && reverseHorizontalMix) {
|
||||
return isActiveFirstLevelMenuHasChildren.value ? collapsedWidth : 0;
|
||||
}
|
||||
|
||||
let w = isVerticalMix.value || isHorizontalMix.value ? mixCollapsedWidth : collapsedWidth;
|
||||
|
||||
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
|
||||
w += mixChildMenuWidth;
|
||||
}
|
||||
|
||||
return w;
|
||||
return getSiderAndCollapsedWidth(true);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
@ -1,83 +0,0 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useContext } from '@sa/hooks';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
|
||||
export const { setupStore: setupMixMenuContext, useStore: useMixMenuContext } = useContext('mix-menu', useMixMenu);
|
||||
|
||||
function useMixMenu() {
|
||||
const route = useRoute();
|
||||
const routeStore = useRouteStore();
|
||||
const { selectedKey } = useMenu();
|
||||
|
||||
const activeFirstLevelMenuKey = ref('');
|
||||
|
||||
function setActiveFirstLevelMenuKey(key: string) {
|
||||
activeFirstLevelMenuKey.value = key;
|
||||
}
|
||||
|
||||
function getActiveFirstLevelMenuKey() {
|
||||
const [firstLevelRouteName] = selectedKey.value.split('_');
|
||||
|
||||
setActiveFirstLevelMenuKey(firstLevelRouteName);
|
||||
}
|
||||
|
||||
const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus);
|
||||
|
||||
const firstLevelMenus = computed<App.Global.Menu[]>(() =>
|
||||
routeStore.menus.map(menu => {
|
||||
const { children: _, ...rest } = menu;
|
||||
|
||||
return rest;
|
||||
})
|
||||
);
|
||||
|
||||
const childLevelMenus = computed<App.Global.Menu[]>(
|
||||
() => routeStore.menus.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []
|
||||
);
|
||||
|
||||
const isActiveFirstLevelMenuHasChildren = computed(() => {
|
||||
if (!activeFirstLevelMenuKey.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const findItem = allMenus.value.find(item => item.key === activeFirstLevelMenuKey.value);
|
||||
|
||||
return Boolean(findItem?.children?.length);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
() => {
|
||||
getActiveFirstLevelMenuKey();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return {
|
||||
allMenus,
|
||||
firstLevelMenus,
|
||||
childLevelMenus,
|
||||
isActiveFirstLevelMenuHasChildren,
|
||||
activeFirstLevelMenuKey,
|
||||
setActiveFirstLevelMenuKey,
|
||||
getActiveFirstLevelMenuKey
|
||||
};
|
||||
}
|
||||
|
||||
export function useMenu() {
|
||||
const route = useRoute();
|
||||
|
||||
const selectedKey = computed(() => {
|
||||
const { hideInMenu, activeMenu } = route.meta;
|
||||
const name = route.name as string;
|
||||
|
||||
const routeName = (hideInMenu ? activeMenu : name) || name;
|
||||
|
||||
return routeName;
|
||||
});
|
||||
|
||||
return {
|
||||
selectedKey
|
||||
};
|
||||
}
|
@ -39,7 +39,7 @@ const toGitee = () => {
|
||||
<NCard
|
||||
size="small"
|
||||
:bordered="false"
|
||||
class="w-340px"
|
||||
class="w-345px"
|
||||
header-class="p-0"
|
||||
:segmented="{ content: true, footer: 'soft' }"
|
||||
>
|
||||
@ -63,7 +63,7 @@ const toGitee = () => {
|
||||
一键已读
|
||||
</NTooltip>
|
||||
</template>
|
||||
<div>
|
||||
<NScrollbar class="h-260px">
|
||||
<template v-if="state?.notices?.length">
|
||||
<template v-for="(message, index) in state?.notices" :key="index">
|
||||
<NDivider v-show="index !== 0" />
|
||||
@ -81,7 +81,7 @@ const toGitee = () => {
|
||||
</template>
|
||||
</template>
|
||||
<NEmpty v-else class="h-180px flex-center" />
|
||||
</div>
|
||||
</NScrollbar>
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end">
|
||||
<NButton type="primary" size="small" @click="toGitee">前往 Gitee</NButton>
|
||||
|
@ -45,7 +45,7 @@ const tenantId = ref<CommonType.IdType>(authStore.userInfo?.user?.tenantId || '0
|
||||
</div>
|
||||
<div class="h-full flex-y-center justify-end">
|
||||
<TenantSelect v-if="!appStore.isMobile" v-model:value="tenantId" class="mr-12px w-150px" />
|
||||
<GlobalSearch v-if="themeStore.header.globalSearch.visible" />
|
||||
<GlobalSearch v-if="themeStore.header.globalSearch.visible && !appStore.isMobile" />
|
||||
<MessageButton />
|
||||
<FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" />
|
||||
<LangSwitch
|
||||
@ -59,7 +59,7 @@ const tenantId = ref<CommonType.IdType>(authStore.userInfo?.user?.tenantId || '0
|
||||
:is-dark="themeStore.darkMode"
|
||||
@switch="themeStore.toggleThemeScheme"
|
||||
/>
|
||||
<ThemeButton />
|
||||
<ThemeButton v-if="!appStore.isMobile" />
|
||||
<UserAvatar />
|
||||
</div>
|
||||
</DarkModeContainer>
|
||||
|
@ -17,8 +17,8 @@ withDefaults(defineProps<Props>(), {
|
||||
|
||||
<template>
|
||||
<RouterLink to="/" class="w-full flex-center nowrap-hidden">
|
||||
<SystemLogo class="w-32px text-primary" />
|
||||
<h2 v-show="showTitle" class="pl-8px text-16px text-primary font-bold transition duration-300 ease-in-out">
|
||||
<SystemLogo class="text-30px text-primary" />
|
||||
<h2 v-show="showTitle" class="pl-12px text-16px text-primary font-bold transition duration-300 ease-in-out">
|
||||
{{ $t('system.title') }}
|
||||
</h2>
|
||||
</RouterLink>
|
||||
|
@ -3,6 +3,7 @@ import { computed } from 'vue';
|
||||
import { createReusableTemplate } from '@vueuse/core';
|
||||
import { SimpleScrollbar } from '@sa/materials';
|
||||
import { transformColorWithOpacity } from '@sa/color';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
|
||||
defineOptions({
|
||||
name: 'FirstLevelMenu'
|
||||
@ -20,7 +21,7 @@ interface Props {
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', menu: App.Global.Menu): boolean;
|
||||
(e: 'select', menuKey: RouteKey): boolean;
|
||||
(e: 'toggleSiderCollapse'): void;
|
||||
}
|
||||
|
||||
@ -47,8 +48,8 @@ const selectedBgColor = computed(() => {
|
||||
return darkMode ? dark : light;
|
||||
});
|
||||
|
||||
function handleClickMixMenu(menu: App.Global.Menu) {
|
||||
emit('select', menu);
|
||||
function handleClickMixMenu(menuKey: RouteKey) {
|
||||
emit('select', menuKey);
|
||||
}
|
||||
|
||||
function toggleSiderCollapse() {
|
||||
@ -88,7 +89,7 @@ function toggleSiderCollapse() {
|
||||
:icon="menu.icon"
|
||||
:active="menu.key === activeMenuKey"
|
||||
:is-mini="siderCollapse"
|
||||
@click="handleClickMixMenu(menu)"
|
||||
@click="handleClickMixMenu(menu.routeKey)"
|
||||
/>
|
||||
</SimpleScrollbar>
|
||||
<MenuToggler
|
||||
|
143
src/layouts/modules/global-menu/context/index.ts
Normal file
143
src/layouts/modules/global-menu/context/index.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useContext } from '@sa/hooks';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
|
||||
export const [provideMixMenuContext, useMixMenuContext] = useContext('MixMenu', useMixMenu);
|
||||
|
||||
function useMixMenu() {
|
||||
const route = useRoute();
|
||||
const routeStore = useRouteStore();
|
||||
const { selectedKey } = useMenu();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
|
||||
const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus);
|
||||
|
||||
const firstLevelMenus = computed<App.Global.Menu[]>(() =>
|
||||
routeStore.menus.map(menu => {
|
||||
const { children: _, ...rest } = menu;
|
||||
|
||||
return rest;
|
||||
})
|
||||
);
|
||||
|
||||
const activeFirstLevelMenuKey = ref('');
|
||||
|
||||
function setActiveFirstLevelMenuKey(key: string) {
|
||||
activeFirstLevelMenuKey.value = key;
|
||||
}
|
||||
|
||||
function getActiveFirstLevelMenuKey() {
|
||||
const [firstLevelRouteName] = selectedKey.value.split('_');
|
||||
|
||||
setActiveFirstLevelMenuKey(firstLevelRouteName);
|
||||
}
|
||||
|
||||
const isActiveFirstLevelMenuHasChildren = computed(() => {
|
||||
if (!activeFirstLevelMenuKey.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const findItem = allMenus.value.find(item => item.key === activeFirstLevelMenuKey.value);
|
||||
|
||||
return Boolean(findItem?.children?.length);
|
||||
});
|
||||
|
||||
function handleSelectFirstLevelMenu(key: RouteKey) {
|
||||
setActiveFirstLevelMenuKey(key);
|
||||
|
||||
if (!isActiveFirstLevelMenuHasChildren.value) {
|
||||
routerPushByKeyWithMetaQuery(key);
|
||||
}
|
||||
}
|
||||
|
||||
const secondLevelMenus = computed<App.Global.Menu[]>(
|
||||
() => allMenus.value.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []
|
||||
);
|
||||
|
||||
const activeSecondLevelMenuKey = ref('');
|
||||
|
||||
function setActiveSecondLevelMenuKey(key: string) {
|
||||
activeSecondLevelMenuKey.value = key;
|
||||
}
|
||||
|
||||
function getActiveSecondLevelMenuKey() {
|
||||
const keys = selectedKey.value.split('_');
|
||||
|
||||
if (keys.length < 2) {
|
||||
setActiveSecondLevelMenuKey('');
|
||||
return;
|
||||
}
|
||||
|
||||
const [firstLevelRouteName, level2SuffixName] = keys;
|
||||
|
||||
const secondLevelRouteName = `${firstLevelRouteName}_${level2SuffixName}`;
|
||||
|
||||
setActiveSecondLevelMenuKey(secondLevelRouteName);
|
||||
}
|
||||
|
||||
const isActiveSecondLevelMenuHasChildren = computed(() => {
|
||||
if (!activeSecondLevelMenuKey.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const findItem = secondLevelMenus.value.find(item => item.key === activeSecondLevelMenuKey.value);
|
||||
|
||||
return Boolean(findItem?.children?.length);
|
||||
});
|
||||
|
||||
function handleSelectSecondLevelMenu(key: RouteKey) {
|
||||
setActiveSecondLevelMenuKey(key);
|
||||
|
||||
if (!isActiveSecondLevelMenuHasChildren.value) {
|
||||
routerPushByKeyWithMetaQuery(key);
|
||||
}
|
||||
}
|
||||
|
||||
const childLevelMenus = computed<App.Global.Menu[]>(
|
||||
() => secondLevelMenus.value.find(menu => menu.key === activeSecondLevelMenuKey.value)?.children || []
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
() => {
|
||||
getActiveFirstLevelMenuKey();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return {
|
||||
firstLevelMenus,
|
||||
activeFirstLevelMenuKey,
|
||||
setActiveFirstLevelMenuKey,
|
||||
isActiveFirstLevelMenuHasChildren,
|
||||
handleSelectFirstLevelMenu,
|
||||
getActiveFirstLevelMenuKey,
|
||||
secondLevelMenus,
|
||||
activeSecondLevelMenuKey,
|
||||
setActiveSecondLevelMenuKey,
|
||||
isActiveSecondLevelMenuHasChildren,
|
||||
handleSelectSecondLevelMenu,
|
||||
getActiveSecondLevelMenuKey,
|
||||
childLevelMenus
|
||||
};
|
||||
}
|
||||
|
||||
export function useMenu() {
|
||||
const route = useRoute();
|
||||
|
||||
const selectedKey = computed(() => {
|
||||
const { hideInMenu, activeMenu } = route.meta;
|
||||
const name = route.name as string;
|
||||
|
||||
const routeName = (hideInMenu ? activeMenu : name) || name;
|
||||
|
||||
return routeName;
|
||||
});
|
||||
|
||||
return {
|
||||
selectedKey
|
||||
};
|
||||
}
|
@ -5,9 +5,10 @@ import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import VerticalMenu from './modules/vertical-menu.vue';
|
||||
import VerticalMixMenu from './modules/vertical-mix-menu.vue';
|
||||
import VerticalHybridHeaderFirst from './modules/vertical-hybrid-header-first.vue';
|
||||
import HorizontalMenu from './modules/horizontal-menu.vue';
|
||||
import HorizontalMixMenu from './modules/horizontal-mix-menu.vue';
|
||||
import ReversedHorizontalMixMenu from './modules/reversed-horizontal-mix-menu.vue';
|
||||
import TopHybridSidebarFirst from './modules/top-hybrid-sidebar-first.vue';
|
||||
import TopHybridHeaderFirst from './modules/top-hybrid-header-first.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'GlobalMenu'
|
||||
@ -20,8 +21,10 @@ const activeMenu = computed(() => {
|
||||
const menuMap: Record<UnionKey.ThemeLayoutMode, Component> = {
|
||||
vertical: VerticalMenu,
|
||||
'vertical-mix': VerticalMixMenu,
|
||||
'vertical-hybrid-header-first': VerticalHybridHeaderFirst,
|
||||
horizontal: HorizontalMenu,
|
||||
'horizontal-mix': themeStore.layout.reverseHorizontalMix ? ReversedHorizontalMixMenu : HorizontalMixMenu
|
||||
'top-hybrid-sidebar-first': TopHybridSidebarFirst,
|
||||
'top-hybrid-header-first': TopHybridHeaderFirst
|
||||
};
|
||||
|
||||
return menuMap[themeStore.layout.mode];
|
||||
|
@ -2,7 +2,7 @@
|
||||
import { GLOBAL_HEADER_MENU_ID } from '@/constants/app';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useMenu } from '../../../context';
|
||||
import { useMenu } from '../context';
|
||||
|
||||
defineOptions({
|
||||
name: 'HorizontalMenu'
|
||||
|
@ -1,17 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { SimpleScrollbar } from '@sa/materials';
|
||||
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useMenu, useMixMenuContext } from '../../../context';
|
||||
import { useMenu, useMixMenuContext } from '../context';
|
||||
|
||||
defineOptions({
|
||||
name: 'ReversedHorizontalMixMenu'
|
||||
name: 'TopHybridHeaderFirst'
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
@ -19,23 +18,10 @@ const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const routeStore = useRouteStore();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const {
|
||||
firstLevelMenus,
|
||||
childLevelMenus,
|
||||
activeFirstLevelMenuKey,
|
||||
setActiveFirstLevelMenuKey,
|
||||
isActiveFirstLevelMenuHasChildren
|
||||
} = useMixMenuContext();
|
||||
const { firstLevelMenus, secondLevelMenus, activeFirstLevelMenuKey, handleSelectFirstLevelMenu } =
|
||||
useMixMenuContext('TopHybridHeaderFirst');
|
||||
const { selectedKey } = useMenu();
|
||||
|
||||
function handleSelectMixMenu(key: RouteKey) {
|
||||
setActiveFirstLevelMenuKey(key);
|
||||
|
||||
if (!isActiveFirstLevelMenuHasChildren.value) {
|
||||
routerPushByKeyWithMetaQuery(key);
|
||||
}
|
||||
}
|
||||
|
||||
const expandedKeys = ref<string[]>([]);
|
||||
|
||||
function updateExpandedKeys() {
|
||||
@ -63,7 +49,7 @@ watch(
|
||||
:options="firstLevelMenus"
|
||||
:indent="18"
|
||||
responsive
|
||||
@update:value="handleSelectMixMenu"
|
||||
@update:value="handleSelectFirstLevelMenu"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
@ -75,7 +61,7 @@ watch(
|
||||
:collapsed="appStore.siderCollapse"
|
||||
:collapsed-width="themeStore.sider.collapsedWidth"
|
||||
:collapsed-icon-size="22"
|
||||
:options="childLevelMenus"
|
||||
:options="secondLevelMenus"
|
||||
:indent="18"
|
||||
@update:value="routerPushByKeyWithMetaQuery"
|
||||
/>
|
@ -4,25 +4,18 @@ import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import FirstLevelMenu from '../components/first-level-menu.vue';
|
||||
import { useMenu, useMixMenuContext } from '../../../context';
|
||||
import { useMenu, useMixMenuContext } from '../context';
|
||||
|
||||
defineOptions({
|
||||
name: 'HorizontalMixMenu'
|
||||
name: 'TopHybridSidebarFirst'
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const { allMenus, childLevelMenus, activeFirstLevelMenuKey, setActiveFirstLevelMenuKey } = useMixMenuContext();
|
||||
const { firstLevelMenus, secondLevelMenus, activeFirstLevelMenuKey, handleSelectFirstLevelMenu } =
|
||||
useMixMenuContext('TopHybridSidebarFirst');
|
||||
const { selectedKey } = useMenu();
|
||||
|
||||
function handleSelectMixMenu(menu: App.Global.Menu) {
|
||||
setActiveFirstLevelMenuKey(menu.key);
|
||||
|
||||
if (!menu.children?.length) {
|
||||
routerPushByKeyWithMetaQuery(menu.routeKey);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -30,22 +23,24 @@ function handleSelectMixMenu(menu: App.Global.Menu) {
|
||||
<NMenu
|
||||
mode="horizontal"
|
||||
:value="selectedKey"
|
||||
:options="childLevelMenus"
|
||||
:options="secondLevelMenus"
|
||||
:indent="18"
|
||||
responsive
|
||||
@update:value="routerPushByKeyWithMetaQuery"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<FirstLevelMenu
|
||||
:menus="allMenus"
|
||||
:active-menu-key="activeFirstLevelMenuKey"
|
||||
:sider-collapse="appStore.siderCollapse"
|
||||
:dark-mode="themeStore.darkMode"
|
||||
:theme-color="themeStore.themeColor"
|
||||
@select="handleSelectMixMenu"
|
||||
@toggle-sider-collapse="appStore.toggleSiderCollapse"
|
||||
/>
|
||||
<div class="h-full pt-2">
|
||||
<FirstLevelMenu
|
||||
:menus="firstLevelMenus"
|
||||
:active-menu-key="activeFirstLevelMenuKey"
|
||||
:sider-collapse="appStore.siderCollapse"
|
||||
:dark-mode="themeStore.darkMode"
|
||||
:theme-color="themeStore.themeColor"
|
||||
@select="handleSelectFirstLevelMenu"
|
||||
@toggle-sider-collapse="appStore.toggleSiderCollapse"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { SimpleScrollbar } from '@sa/materials';
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useMenu, useMixMenuContext } from '../context';
|
||||
import FirstLevelMenu from '../components/first-level-menu.vue';
|
||||
import GlobalLogo from '../../global-logo/index.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'VerticalHybridHeaderFirst'
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const routeStore = useRouteStore();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean();
|
||||
const {
|
||||
firstLevelMenus,
|
||||
activeFirstLevelMenuKey,
|
||||
handleSelectFirstLevelMenu,
|
||||
getActiveFirstLevelMenuKey,
|
||||
secondLevelMenus,
|
||||
activeSecondLevelMenuKey,
|
||||
isActiveSecondLevelMenuHasChildren,
|
||||
handleSelectSecondLevelMenu,
|
||||
getActiveSecondLevelMenuKey,
|
||||
childLevelMenus
|
||||
} = useMixMenuContext('VerticalHybridHeaderFirst');
|
||||
const { selectedKey } = useMenu();
|
||||
|
||||
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
|
||||
|
||||
const hasChildMenus = computed(() => childLevelMenus.value.length > 0);
|
||||
|
||||
const showDrawer = computed(() => hasChildMenus.value && (drawerVisible.value || appStore.mixSiderFixed));
|
||||
|
||||
function handleSelectMixMenu(key: RouteKey) {
|
||||
handleSelectSecondLevelMenu(key);
|
||||
|
||||
if (isActiveSecondLevelMenuHasChildren.value) {
|
||||
setDrawerVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectMenu(key: RouteKey) {
|
||||
handleSelectFirstLevelMenu(key);
|
||||
|
||||
if (secondLevelMenus.value.length > 0) {
|
||||
handleSelectMixMenu(secondLevelMenus.value[0].routeKey);
|
||||
}
|
||||
}
|
||||
|
||||
function handleResetActiveMenu() {
|
||||
setDrawerVisible(false);
|
||||
|
||||
if (!appStore.mixSiderFixed) {
|
||||
getActiveFirstLevelMenuKey();
|
||||
getActiveSecondLevelMenuKey();
|
||||
}
|
||||
}
|
||||
|
||||
const expandedKeys = ref<string[]>([]);
|
||||
|
||||
function updateExpandedKeys() {
|
||||
if (appStore.siderCollapse || !selectedKey.value) {
|
||||
expandedKeys.value = [];
|
||||
return;
|
||||
}
|
||||
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
() => {
|
||||
updateExpandedKeys();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
||||
<NMenu
|
||||
mode="horizontal"
|
||||
:value="activeFirstLevelMenuKey"
|
||||
:options="firstLevelMenus"
|
||||
:indent="18"
|
||||
responsive
|
||||
@update:value="handleSelectMenu"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
|
||||
<FirstLevelMenu
|
||||
:menus="secondLevelMenus"
|
||||
:active-menu-key="activeSecondLevelMenuKey"
|
||||
:inverted="inverted"
|
||||
:sider-collapse="appStore.siderCollapse"
|
||||
:dark-mode="themeStore.darkMode"
|
||||
:theme-color="themeStore.themeColor"
|
||||
@select="handleSelectMixMenu"
|
||||
@toggle-sider-collapse="appStore.toggleSiderCollapse"
|
||||
>
|
||||
<GlobalLogo :show-title="false" :style="{ height: themeStore.header.height + 'px' }" />
|
||||
</FirstLevelMenu>
|
||||
<div
|
||||
class="relative h-full transition-width-300"
|
||||
:style="{ width: appStore.mixSiderFixed && hasChildMenus ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
|
||||
>
|
||||
<DarkModeContainer
|
||||
class="absolute-lt h-full flex-col-stretch nowrap-hidden shadow-sm transition-all-300"
|
||||
:inverted="inverted"
|
||||
:style="{ width: showDrawer ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
|
||||
>
|
||||
<header class="flex-y-center justify-between px-12px" :style="{ height: themeStore.header.height + 'px' }">
|
||||
<h2 class="text-16px text-primary font-bold">{{ $t('system.title') }}</h2>
|
||||
<PinToggler
|
||||
:pin="appStore.mixSiderFixed"
|
||||
:class="{ 'text-white:88 !hover:text-white': inverted }"
|
||||
@click="appStore.toggleMixSiderFixed"
|
||||
/>
|
||||
</header>
|
||||
<SimpleScrollbar>
|
||||
<NMenu
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
mode="vertical"
|
||||
:value="selectedKey"
|
||||
:options="childLevelMenus"
|
||||
:inverted="inverted"
|
||||
:indent="18"
|
||||
@update:value="routerPushByKeyWithMetaQuery"
|
||||
/>
|
||||
</SimpleScrollbar>
|
||||
</DarkModeContainer>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
@ -7,7 +7,7 @@ import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useMenu } from '../../../context';
|
||||
import { useMenu } from '../context';
|
||||
|
||||
defineOptions({
|
||||
name: 'VerticalMenu'
|
||||
|
@ -3,13 +3,14 @@ import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { SimpleScrollbar } from '@sa/materials';
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { $t } from '@/locales';
|
||||
import { useMenu, useMixMenuContext } from '../../../context';
|
||||
import { useMenu, useMixMenuContext } from '../context';
|
||||
import FirstLevelMenu from '../components/first-level-menu.vue';
|
||||
import GlobalLogo from '../../global-logo/index.vue';
|
||||
|
||||
@ -24,28 +25,26 @@ const routeStore = useRouteStore();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean();
|
||||
const {
|
||||
allMenus,
|
||||
childLevelMenus,
|
||||
firstLevelMenus,
|
||||
secondLevelMenus,
|
||||
activeFirstLevelMenuKey,
|
||||
setActiveFirstLevelMenuKey,
|
||||
getActiveFirstLevelMenuKey
|
||||
//
|
||||
} = useMixMenuContext();
|
||||
isActiveFirstLevelMenuHasChildren,
|
||||
getActiveFirstLevelMenuKey,
|
||||
handleSelectFirstLevelMenu
|
||||
} = useMixMenuContext('VerticalMixMenu');
|
||||
const { selectedKey } = useMenu();
|
||||
|
||||
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
|
||||
|
||||
const hasChildMenus = computed(() => childLevelMenus.value.length > 0);
|
||||
const hasChildMenus = computed(() => secondLevelMenus.value.length > 0);
|
||||
|
||||
const showDrawer = computed(() => hasChildMenus.value && (drawerVisible.value || appStore.mixSiderFixed));
|
||||
|
||||
function handleSelectMixMenu(menu: App.Global.Menu) {
|
||||
setActiveFirstLevelMenuKey(menu.key);
|
||||
function handleSelectMenu(key: RouteKey) {
|
||||
handleSelectFirstLevelMenu(key);
|
||||
|
||||
if (menu.children?.length) {
|
||||
if (isActiveFirstLevelMenuHasChildren.value) {
|
||||
setDrawerVisible(true);
|
||||
} else {
|
||||
routerPushByKeyWithMetaQuery(menu.routeKey);
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,13 +79,13 @@ watch(
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
|
||||
<FirstLevelMenu
|
||||
:menus="allMenus"
|
||||
:menus="firstLevelMenus"
|
||||
:active-menu-key="activeFirstLevelMenuKey"
|
||||
:inverted="inverted"
|
||||
:sider-collapse="appStore.siderCollapse"
|
||||
:dark-mode="themeStore.darkMode"
|
||||
:theme-color="themeStore.themeColor"
|
||||
@select="handleSelectMixMenu"
|
||||
@select="handleSelectMenu"
|
||||
@toggle-sider-collapse="appStore.toggleSiderCollapse"
|
||||
>
|
||||
<GlobalLogo :show-title="false" :style="{ height: themeStore.header.height + 'px' }" />
|
||||
@ -113,7 +112,7 @@ watch(
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
mode="vertical"
|
||||
:value="selectedKey"
|
||||
:options="childLevelMenus"
|
||||
:options="secondLevelMenus"
|
||||
:inverted="inverted"
|
||||
:indent="18"
|
||||
@update:value="routerPushByKeyWithMetaQuery"
|
||||
|
@ -12,10 +12,13 @@ defineOptions({
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix');
|
||||
const isHorizontalMix = computed(() => themeStore.layout.mode === 'horizontal-mix');
|
||||
const darkMenu = computed(() => !themeStore.darkMode && !isHorizontalMix.value && themeStore.sider.inverted);
|
||||
const showLogo = computed(() => !isVerticalMix.value && !isHorizontalMix.value);
|
||||
const isTopHybridSidebarFirst = computed(() => themeStore.layout.mode === 'top-hybrid-sidebar-first');
|
||||
const isTopHybridHeaderFirst = computed(() => themeStore.layout.mode === 'top-hybrid-header-first');
|
||||
const darkMenu = computed(
|
||||
() =>
|
||||
!themeStore.darkMode && !isTopHybridSidebarFirst.value && !isTopHybridHeaderFirst.value && themeStore.sider.inverted
|
||||
);
|
||||
const showLogo = computed(() => themeStore.layout.mode === 'vertical');
|
||||
const menuWrapperClass = computed(() => (showLogo.value ? 'flex-1-hidden' : 'h-full'));
|
||||
</script>
|
||||
|
||||
|
@ -27,7 +27,6 @@ type LayoutConfig = Record<
|
||||
UnionKey.ThemeLayoutMode,
|
||||
{
|
||||
placement: PopoverPlacement;
|
||||
headerClass: string;
|
||||
menuClass: string;
|
||||
mainClass: string;
|
||||
}
|
||||
@ -36,25 +35,31 @@ type LayoutConfig = Record<
|
||||
const layoutConfig: LayoutConfig = {
|
||||
vertical: {
|
||||
placement: 'bottom',
|
||||
headerClass: '',
|
||||
menuClass: 'w-1/3 h-full',
|
||||
mainClass: 'w-2/3 h-3/4'
|
||||
},
|
||||
'vertical-mix': {
|
||||
placement: 'bottom',
|
||||
headerClass: '',
|
||||
menuClass: 'w-1/4 h-full',
|
||||
mainClass: 'w-2/3 h-3/4'
|
||||
},
|
||||
'vertical-hybrid-header-first': {
|
||||
placement: 'bottom',
|
||||
menuClass: 'w-1/4 h-full',
|
||||
mainClass: 'w-2/3 h-3/4'
|
||||
},
|
||||
horizontal: {
|
||||
placement: 'bottom',
|
||||
headerClass: '',
|
||||
menuClass: 'w-full h-1/4',
|
||||
mainClass: 'w-full h-3/4'
|
||||
},
|
||||
'horizontal-mix': {
|
||||
'top-hybrid-sidebar-first': {
|
||||
placement: 'bottom',
|
||||
menuClass: 'w-full h-1/4',
|
||||
mainClass: 'w-2/3 h-3/4'
|
||||
},
|
||||
'top-hybrid-header-first': {
|
||||
placement: 'bottom',
|
||||
headerClass: '',
|
||||
menuClass: 'w-full h-1/4',
|
||||
mainClass: 'w-2/3 h-3/4'
|
||||
}
|
||||
@ -68,25 +73,27 @@ function handleChangeMode(mode: UnionKey.ThemeLayoutMode) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-center flex-wrap gap-x-32px gap-y-16px">
|
||||
<div class="grid grid-cols-2 gap-x-16px gap-y-12px md:grid-cols-3">
|
||||
<div
|
||||
v-for="(item, key) in layoutConfig"
|
||||
:key="key"
|
||||
class="flex cursor-pointer border-2px rounded-6px hover:border-primary"
|
||||
:class="[mode === key ? 'border-primary' : 'border-transparent']"
|
||||
class="flex-col-center cursor-pointer"
|
||||
@click="handleChangeMode(key)"
|
||||
>
|
||||
<NTooltip :placement="item.placement">
|
||||
<IconTooltip :placement="item.placement">
|
||||
<template #trigger>
|
||||
<div
|
||||
class="h-64px w-96px gap-6px rd-4px p-6px shadow dark:shadow-coolGray-5"
|
||||
:class="[key.includes('vertical') ? 'flex' : 'flex-col']"
|
||||
class="h-64px w-96px gap-6px rd-4px p-6px shadow ring-2 ring-transparent transition-all hover:ring-primary"
|
||||
:class="{ '!ring-primary': mode === key }"
|
||||
>
|
||||
<slot :name="key"></slot>
|
||||
<div class="h-full w-full gap-1" :class="[key.includes('vertical') ? 'flex' : 'flex-col']">
|
||||
<slot :name="key"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
{{ $t(themeLayoutModeRecord[key]) }}
|
||||
</NTooltip>
|
||||
{{ $t(`theme.layout.layoutMode.${key}_detail`) }}
|
||||
</IconTooltip>
|
||||
<p class="mt-8px text-12px">{{ $t(themeLayoutModeRecord[key]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -13,7 +13,7 @@ defineProps<Props>();
|
||||
|
||||
<template>
|
||||
<div class="w-full flex-y-center justify-between">
|
||||
<div>
|
||||
<div class="flex-y-center">
|
||||
<span class="pr-8px text-base-text">{{ label }}</span>
|
||||
<slot name="suffix"></slot>
|
||||
</div>
|
||||
|
@ -1,28 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { $t } from '@/locales';
|
||||
import DarkMode from './modules/dark-mode.vue';
|
||||
import LayoutMode from './modules/layout-mode.vue';
|
||||
import ThemeColor from './modules/theme-color.vue';
|
||||
import PageFun from './modules/page-fun.vue';
|
||||
import AppearanceSettings from './modules/appearance/index.vue';
|
||||
import LayoutSettings from './modules/layout/index.vue';
|
||||
import GeneralSettings from './modules/general/index.vue';
|
||||
import ConfigOperation from './modules/config-operation.vue';
|
||||
import TableProps from './modules/table-props.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'ThemeDrawer'
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
const activeTab = ref('appearance');
|
||||
|
||||
const drawerWidth = computed(() => {
|
||||
const width = 400;
|
||||
|
||||
// On mobile devices, use 90% of viewport width with a maximum of 400px
|
||||
if (appStore.isMobile) {
|
||||
return `min(90vw, ${width}px)`;
|
||||
}
|
||||
|
||||
return width;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDrawer v-model:show="appStore.themeDrawerVisible" display-directive="show" :width="360">
|
||||
<NDrawer v-model:show="appStore.themeDrawerVisible" display-directive="show" :width="drawerWidth">
|
||||
<NDrawerContent :title="$t('theme.themeDrawerTitle')" :native-scrollbar="false" closable>
|
||||
<DarkMode />
|
||||
<LayoutMode />
|
||||
<ThemeColor />
|
||||
<PageFun />
|
||||
<TableProps />
|
||||
<NTabs v-model:value="activeTab" type="segment" size="medium" class="mb-16px">
|
||||
<NTab name="appearance" :tab="$t('theme.tabs.appearance')"></NTab>
|
||||
<NTab name="layout" :tab="$t('theme.tabs.layout')"></NTab>
|
||||
<NTab name="general" :tab="$t('theme.tabs.general')"></NTab>
|
||||
</NTabs>
|
||||
|
||||
<div class="min-h-400px">
|
||||
<KeepAlive>
|
||||
<AppearanceSettings v-if="activeTab === 'appearance'" />
|
||||
<LayoutSettings v-else-if="activeTab === 'layout'" />
|
||||
<GeneralSettings v-else-if="activeTab === 'general'" />
|
||||
</KeepAlive>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<ConfigOperation />
|
||||
</template>
|
||||
@ -30,4 +50,14 @@ const appStore = useAppStore();
|
||||
</NDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
:deep(.n-tab) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:deep(.n-tab-pane) {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import ThemeSchema from './modules/theme-schema.vue';
|
||||
import ThemeColor from './modules/theme-color.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'AppearanceSettings'
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-col-stretch gap-16px">
|
||||
<ThemeSchema />
|
||||
<ThemeColor />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import SettingItem from '../components/setting-item.vue';
|
||||
import SettingItem from '../../../components/setting-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'ThemeColor'
|
||||
@ -34,33 +34,38 @@ const swatches: string[] = [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDivider>{{ $t('theme.themeColor.title') }}</NDivider>
|
||||
<NDivider>{{ $t('theme.appearance.themeColor.title') }}</NDivider>
|
||||
<div class="flex-col-stretch gap-12px">
|
||||
<NTooltip placement="top-start">
|
||||
<template #trigger>
|
||||
<SettingItem key="recommend-color" :label="$t('theme.recommendColor')">
|
||||
<NSwitch v-model:value="themeStore.recommendColor" />
|
||||
</SettingItem>
|
||||
<SettingItem key="recommend-color" :label="$t('theme.appearance.recommendColor')">
|
||||
<template #suffix>
|
||||
<IconTooltip>
|
||||
<p>
|
||||
<span class="pr-12px">{{ $t('theme.appearance.recommendColorDesc') }}</span>
|
||||
<br />
|
||||
<NButton
|
||||
text
|
||||
tag="a"
|
||||
href="https://uicolors.app/create"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-gray"
|
||||
>
|
||||
https://uicolors.app/create
|
||||
</NButton>
|
||||
</p>
|
||||
</IconTooltip>
|
||||
</template>
|
||||
<p>
|
||||
<span class="pr-12px">{{ $t('theme.recommendColorDesc') }}</span>
|
||||
<br />
|
||||
<NButton
|
||||
text
|
||||
tag="a"
|
||||
href="https://uicolors.app/create"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-gray"
|
||||
>
|
||||
https://uicolors.app/create
|
||||
</NButton>
|
||||
</p>
|
||||
</NTooltip>
|
||||
<SettingItem v-for="(_, key) in themeStore.themeColors" :key="key" :label="$t(`theme.themeColor.${key}`)">
|
||||
<NSwitch v-model:value="themeStore.recommendColor" />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
v-for="(_, key) in themeStore.themeColors"
|
||||
:key="key"
|
||||
:label="$t(`theme.appearance.themeColor.${key}`)"
|
||||
>
|
||||
<template v-if="key === 'info'" #suffix>
|
||||
<NCheckbox v-model:checked="themeStore.isInfoFollowPrimary">
|
||||
{{ $t('theme.themeColor.followPrimary') }}
|
||||
{{ $t('theme.appearance.themeColor.followPrimary') }}
|
||||
</NCheckbox>
|
||||
</template>
|
||||
<NColorPicker
|
@ -3,10 +3,10 @@ import { computed } from 'vue';
|
||||
import { themeSchemaRecord } from '@/constants/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import SettingItem from '../components/setting-item.vue';
|
||||
import SettingItem from '../../../components/setting-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'DarkMode'
|
||||
name: 'ThemeSchema'
|
||||
});
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
@ -33,7 +33,7 @@ const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layo
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDivider>{{ $t('theme.themeSchema.title') }}</NDivider>
|
||||
<NDivider>{{ $t('theme.appearance.themeSchema.title') }}</NDivider>
|
||||
<div class="flex-col-stretch gap-16px">
|
||||
<div class="i-flex-center">
|
||||
<NTabs
|
||||
@ -50,14 +50,14 @@ const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layo
|
||||
</NTabs>
|
||||
</div>
|
||||
<Transition name="sider-inverted">
|
||||
<SettingItem v-if="showSiderInverted" :label="$t('theme.sider.inverted')">
|
||||
<SettingItem v-if="showSiderInverted" :label="$t('theme.layout.sider.inverted')">
|
||||
<NSwitch v-model:value="themeStore.sider.inverted" />
|
||||
</SettingItem>
|
||||
</Transition>
|
||||
<SettingItem :label="$t('theme.grayscale')">
|
||||
<SettingItem :label="$t('theme.appearance.grayscale')">
|
||||
<NSwitch :value="themeStore.grayscale" @update:value="handleGrayscaleChange" />
|
||||
</SettingItem>
|
||||
<SettingItem :label="$t('theme.colourWeakness')">
|
||||
<SettingItem :label="$t('theme.appearance.colourWeakness')">
|
||||
<NSwitch :value="themeStore.colourWeakness" @update:value="handleColourWeaknessChange" />
|
||||
</SettingItem>
|
||||
</div>
|
17
src/layouts/modules/theme-drawer/modules/general/index.vue
Normal file
17
src/layouts/modules/theme-drawer/modules/general/index.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import GlobalSettings from './modules/global-settings.vue';
|
||||
import WatermarkSettings from './modules/watermark-settings.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'GeneralSettings'
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-col-stretch gap-16px">
|
||||
<GlobalSettings />
|
||||
<WatermarkSettings />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import SettingItem from '../../../components/setting-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'GlobalSettings'
|
||||
});
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDivider>{{ $t('theme.general.title') }}</NDivider>
|
||||
<SettingItem :label="$t('theme.general.multilingual.visible')">
|
||||
<NSwitch v-model:value="themeStore.header.multilingual.visible" />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem :label="$t('theme.general.globalSearch.visible')">
|
||||
<NSwitch v-model:value="themeStore.header.globalSearch.visible" />
|
||||
</SettingItem>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-list-move,
|
||||
.setting-list-enter-active,
|
||||
.setting-list-leave-active {
|
||||
--uno: transition-all-300;
|
||||
}
|
||||
|
||||
.setting-list-enter-from,
|
||||
.setting-list-leave-to {
|
||||
--uno: opacity-0 -translate-x-30px;
|
||||
}
|
||||
|
||||
.setting-list-leave-active {
|
||||
--uno: absolute;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { watermarkTimeFormatOptions } from '@/constants/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import SettingItem from '../../../components/setting-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'WatermarkSettings'
|
||||
});
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDivider>{{ $t('theme.general.watermark.title') }}</NDivider>
|
||||
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
|
||||
<SettingItem key="1" :label="$t('theme.general.watermark.visible')">
|
||||
<NSwitch v-model:value="themeStore.watermark.visible" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.watermark.visible" key="2" :label="$t('theme.general.watermark.enableUserName')">
|
||||
<NSwitch :value="themeStore.watermark.enableUserName" @update:value="themeStore.setWatermarkEnableUserName" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.watermark.visible" key="3" :label="$t('theme.general.watermark.enableTime')">
|
||||
<NSwitch :value="themeStore.watermark.enableTime" @update:value="themeStore.setWatermarkEnableTime" />
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
v-if="themeStore.watermark.visible && themeStore.watermark.enableTime"
|
||||
key="4"
|
||||
:label="$t('theme.general.watermark.timeFormat')"
|
||||
>
|
||||
<NSelect
|
||||
v-model:value="themeStore.watermark.timeFormat"
|
||||
:options="watermarkTimeFormatOptions"
|
||||
size="small"
|
||||
class="w-210px"
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem key="5" :label="$t('theme.general.watermark.text')">
|
||||
<NInput
|
||||
v-model:value="themeStore.watermark.text"
|
||||
autosize
|
||||
type="text"
|
||||
size="small"
|
||||
class="w-120px"
|
||||
placeholder="SoybeanAdmin"
|
||||
/>
|
||||
</SettingItem>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-list-move,
|
||||
.setting-list-enter-active,
|
||||
.setting-list-leave-active {
|
||||
--uno: transition-all-300;
|
||||
}
|
||||
|
||||
.setting-list-enter-from,
|
||||
.setting-list-leave-to {
|
||||
--uno: opacity-0 -translate-x-30px;
|
||||
}
|
||||
|
||||
.setting-list-leave-active {
|
||||
--uno: absolute;
|
||||
}
|
||||
</style>
|
31
src/layouts/modules/theme-drawer/modules/layout/index.vue
Normal file
31
src/layouts/modules/theme-drawer/modules/layout/index.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import LayoutMode from './modules/layout-mode.vue';
|
||||
import TabSettings from './modules/tab-settings.vue';
|
||||
import HeaderSettings from './modules/header-settings.vue';
|
||||
import SiderSettings from './modules/sider-settings.vue';
|
||||
import FooterSettings from './modules/footer-settings.vue';
|
||||
import ContentSettings from './modules/content-settings.vue';
|
||||
import TableSettings from './modules/table-settings.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'LayoutSettings'
|
||||
});
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-col-stretch gap-16px">
|
||||
<LayoutMode />
|
||||
<TabSettings />
|
||||
<HeaderSettings />
|
||||
<!-- The top menu mode does not have a sidebar -->
|
||||
<SiderSettings v-if="themeStore.layout.mode !== 'horizontal'" />
|
||||
<FooterSettings />
|
||||
<ContentSettings />
|
||||
<TableSettings />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { themePageAnimationModeOptions, themeScrollModeOptions } from '@/constants/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { translateOptions } from '@/utils/common';
|
||||
import { $t } from '@/locales';
|
||||
import SettingItem from '../../../components/setting-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'ContentSettings'
|
||||
});
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const isWrapperScrollMode = computed(() => themeStore.layout.scrollMode === 'wrapper');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDivider>{{ $t('theme.layout.content.title') }}</NDivider>
|
||||
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
|
||||
<SettingItem key="1" :label="$t('theme.layout.content.scrollMode.title')">
|
||||
<template #suffix>
|
||||
<IconTooltip :desc="$t('theme.layout.content.scrollMode.tip')" />
|
||||
</template>
|
||||
<NSelect
|
||||
v-model:value="themeStore.layout.scrollMode"
|
||||
:options="translateOptions(themeScrollModeOptions)"
|
||||
size="small"
|
||||
class="w-120px"
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem key="2" :label="$t('theme.layout.content.page.animate')">
|
||||
<NSwitch v-model:value="themeStore.page.animate" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.page.animate" key="3" :label="$t('theme.layout.content.page.mode.title')">
|
||||
<NSelect
|
||||
v-model:value="themeStore.page.animateMode"
|
||||
:options="translateOptions(themePageAnimationModeOptions)"
|
||||
size="small"
|
||||
class="w-120px"
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem v-if="isWrapperScrollMode" key="4" :label="$t('theme.layout.content.fixedHeaderAndTab')">
|
||||
<NSwitch v-model:value="themeStore.fixedHeaderAndTab" />
|
||||
</SettingItem>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.setting-list-move,
|
||||
.setting-list-enter-active,
|
||||
.setting-list-leave-active {
|
||||
--uno: transition-all-300;
|
||||
}
|
||||
|
||||
.setting-list-enter-from,
|
||||
.setting-list-leave-to {
|
||||
--uno: opacity-0 -translate-x-30px;
|
||||
}
|
||||
|
||||
.setting-list-leave-active {
|
||||
--uno: absolute;
|
||||
}
|
||||
</style>
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user