mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-09-21 04:46:28 +00:00
feat: 新增插件系统 (#112)
* feat: 初始化插件系统 * refactor: 收口插件系统运行时重构 * perf: 优化插件系统类型提示 * fix&perf: 修复和优化插件系统 * fix: 修复gitignore规则误忽略插件文件的问题 * fix: 修复运行时插件根路径算错的问题 * fix: 加强插件发现和路由注册的防护措施 * revert: 回滚定时任务白名单 * fix: 移除未使用的应用路由注册探测 * revert: 恢复部分代码 * perf: 优化插件系统 * docs: 新增插件开发文档 * perf: 优化插件管理模块 * perf: 提升插件系统核心能力 * refactor: 重构生命周期 step runner * fix: 修复lint错误 * test: 清理测试用例 * test: 调整测试目录名称 * fix: 修复前后端目录硬编码的问题 * fix: 修复插件系统安全性缺口 * refactor: 重新设计插件生命周期 Migration 事务与回滚 * perf: 优化插件系统边界问题 * refactor: 重构当前插件系统的依赖体系设计 * perf: 优化代码 * perf: 优化代码 * fix: 修复代码合并问题 * fix: 修复bug * perf: 优化代码 * perf&fix: 优化代码和修复bug * docs: 优化文档格式 * feat: 适配Vue2版本 * docs: 更新README文档 * fix: 修复ruff lint错误 * chore: 更新后端依赖文件
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from .service import PLUGIN_RUNTIME, CliPluginRuntimeService
|
||||
|
||||
__all__ = ['PLUGIN_RUNTIME', 'CliPluginRuntimeService']
|
||||
@@ -0,0 +1,70 @@
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
|
||||
class PluginRuntimeGateway:
|
||||
"""
|
||||
插件 CLI 运行时网关。
|
||||
|
||||
该对象负责延迟加载插件核心运行时与管理适配器,避免导入
|
||||
`cli.runtime.plugin` 时立即加载 `plugins.core`。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_core_runtime_service_class() -> Any:
|
||||
"""
|
||||
获取插件核心运行时服务类。
|
||||
|
||||
:return: 插件核心运行时服务类
|
||||
"""
|
||||
return import_module('plugins.core.runtime.service').PluginRuntimeService
|
||||
|
||||
@staticmethod
|
||||
def get_core_runtime_gateway_overrides_class() -> Any:
|
||||
"""
|
||||
获取插件核心运行时窄端口覆盖项类。
|
||||
|
||||
:return: 插件核心运行时窄端口覆盖项类
|
||||
"""
|
||||
return import_module('plugins.core.runtime.service.dependency_container').PluginRuntimeGatewayOverrides
|
||||
|
||||
@staticmethod
|
||||
def get_management_runtime_gateway() -> Any:
|
||||
"""
|
||||
获取插件管理运行时适配器。
|
||||
|
||||
:return: 插件管理运行时适配器实例
|
||||
"""
|
||||
gateway_class = import_module('plugins.core.management.service.gateway').PluginManagementRuntimeGateway
|
||||
return gateway_class()
|
||||
|
||||
@staticmethod
|
||||
def get_core_runtime_environment() -> Any:
|
||||
"""
|
||||
获取插件核心运行时环境服务。
|
||||
|
||||
:return: 插件核心运行时环境服务
|
||||
"""
|
||||
return import_module('plugins.core.environment').PLUGIN_RUNTIME_ENVIRONMENT
|
||||
|
||||
@staticmethod
|
||||
def get_core_lifecycle_lock() -> Any:
|
||||
"""
|
||||
获取插件核心生命周期分布式锁。
|
||||
|
||||
:return: 插件核心生命周期分布式锁
|
||||
"""
|
||||
lock_class = import_module('plugins.core.runtime.service.lifecycle_lock').RedisPluginLifecycleLock
|
||||
return lock_class()
|
||||
|
||||
@staticmethod
|
||||
def build_exception_payload(message: str, exc: Exception) -> dict[str, object]:
|
||||
"""
|
||||
构建插件核心异常负载。
|
||||
|
||||
:param message: 异常场景提示
|
||||
:param exc: 异常对象
|
||||
:return: 异常负载
|
||||
"""
|
||||
payload_builder = import_module('plugins.core.runtime.support').PluginRuntimePayloadBuilder
|
||||
return payload_builder.build_exception_payload(message, exc)
|
||||
@@ -0,0 +1,26 @@
|
||||
from .backend import PluginBackendScaffoldTemplateBuilder
|
||||
from .builder import PluginScaffoldBuilder
|
||||
from .frontend import FrontendVersion, PluginFrontendScaffoldTemplateBuilder, PluginFrontendVersionResolver
|
||||
from .naming import PluginScaffoldNaming
|
||||
from .options import PluginScaffoldOptions, PluginScaffoldTemplateResolver
|
||||
from .payload import (
|
||||
PluginScaffoldConflictPayload,
|
||||
PluginScaffoldPayloadBuilder,
|
||||
PluginScaffoldPlanPayload,
|
||||
PluginScaffoldSuccessPayload,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'FrontendVersion',
|
||||
'PluginBackendScaffoldTemplateBuilder',
|
||||
'PluginFrontendScaffoldTemplateBuilder',
|
||||
'PluginFrontendVersionResolver',
|
||||
'PluginScaffoldBuilder',
|
||||
'PluginScaffoldConflictPayload',
|
||||
'PluginScaffoldNaming',
|
||||
'PluginScaffoldOptions',
|
||||
'PluginScaffoldPayloadBuilder',
|
||||
'PluginScaffoldPlanPayload',
|
||||
'PluginScaffoldSuccessPayload',
|
||||
'PluginScaffoldTemplateResolver',
|
||||
]
|
||||
@@ -0,0 +1,536 @@
|
||||
from .naming import PluginScaffoldNaming
|
||||
from .options import PluginScaffoldOptions
|
||||
|
||||
|
||||
class PluginBackendScaffoldTemplateBuilder:
|
||||
"""
|
||||
后端插件模板内容构建器。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_manifest(plugin_id: str, options: PluginScaffoldOptions) -> str:
|
||||
"""
|
||||
构建后端插件清单内容。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:param options: 插件模板生成选项
|
||||
:return: 后端插件清单内容
|
||||
"""
|
||||
migrations = ' []' if not options.migration else '\n - migrations/001_init.sql'
|
||||
seeds = ' []' if not options.seed else '\n - seeds/001_seed.sql'
|
||||
jobs = (
|
||||
' []'
|
||||
if not options.job
|
||||
else f"""
|
||||
- id: heartbeat
|
||||
name: {plugin_id} 心跳
|
||||
callable: plugins.{plugin_id}.jobs.heartbeat
|
||||
trigger: cron
|
||||
cronExpression: '0 0/30 * * * ?'
|
||||
enabled: false
|
||||
description: {plugin_id} 插件定时任务声明示例"""
|
||||
)
|
||||
config = (
|
||||
''
|
||||
if not options.config
|
||||
else """
|
||||
|
||||
config:
|
||||
items:
|
||||
- key: enabled_feature
|
||||
label: 示例开关
|
||||
type: boolean
|
||||
default: true
|
||||
required: false
|
||||
description: 示例插件开关
|
||||
- key: api_key
|
||||
label: 示例密钥
|
||||
type: password
|
||||
default: ''
|
||||
required: false
|
||||
secret: true
|
||||
description: 敏感配置示例,请在安装后填写"""
|
||||
)
|
||||
frontend_menus = (
|
||||
f"""
|
||||
menus:
|
||||
- name: {plugin_id}
|
||||
path: {plugin_id}
|
||||
component: plugin/{plugin_id}/index
|
||||
perms: {plugin_id}:list
|
||||
type: C
|
||||
icon: '#'"""
|
||||
if options.frontend
|
||||
else """
|
||||
menus: []"""
|
||||
)
|
||||
return f"""id: {plugin_id}
|
||||
name: {plugin_id}
|
||||
version: 0.1.0
|
||||
description: {plugin_id} 插件
|
||||
|
||||
backend:
|
||||
module: plugins.{plugin_id}
|
||||
routers:
|
||||
autoScan: true
|
||||
migrations:{migrations}
|
||||
seeds:{seeds}
|
||||
hooks:
|
||||
onInstall: hooks:on_install
|
||||
onUpgrade: hooks:on_upgrade
|
||||
onStartup: hooks:on_startup
|
||||
onShutdown: hooks:on_shutdown
|
||||
onPurge: hooks:on_purge
|
||||
jobs:{jobs}
|
||||
|
||||
frontend:
|
||||
basePath: {plugin_id}
|
||||
pluginId: {plugin_id}
|
||||
viewsPath: views
|
||||
apiPath: api
|
||||
{frontend_menus}
|
||||
|
||||
permissions:
|
||||
- {plugin_id}:list
|
||||
|
||||
dependencies:
|
||||
python: []
|
||||
npm: []
|
||||
npmDev: []
|
||||
plugins: []
|
||||
{config}
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_controller(plugin_id: str) -> str:
|
||||
"""
|
||||
构建后端控制器模板内容。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 后端控制器模板内容
|
||||
"""
|
||||
service_class_name = PluginScaffoldNaming.to_class_name(plugin_id)
|
||||
return f"""from common.router import APIRouterPro
|
||||
|
||||
from plugins.{plugin_id}.service.{plugin_id}_service import {service_class_name}Service
|
||||
|
||||
router = APIRouterPro(prefix='/{plugin_id}', tags=['{plugin_id}'])
|
||||
|
||||
|
||||
@router.get('/ping')
|
||||
async def ping() -> dict[str, str]:
|
||||
\"\"\"
|
||||
插件探活接口。
|
||||
|
||||
:return: 插件探活结果
|
||||
\"\"\"
|
||||
return {service_class_name}Service.ping()
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_crud_controller(plugin_id: str) -> str:
|
||||
"""
|
||||
构建后端 CRUD 控制器模板内容。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 后端 CRUD 控制器模板内容
|
||||
"""
|
||||
service_class_name = PluginScaffoldNaming.to_class_name(plugin_id)
|
||||
return f"""from common.router import APIRouterPro
|
||||
|
||||
from plugins.{plugin_id}.service.{plugin_id}_service import {service_class_name}Service
|
||||
|
||||
router = APIRouterPro(prefix='/{plugin_id}', tags=['{plugin_id}'])
|
||||
|
||||
|
||||
@router.get('/ping')
|
||||
async def ping() -> dict[str, str]:
|
||||
\"\"\"
|
||||
插件探活接口。
|
||||
|
||||
:return: 插件探活结果
|
||||
\"\"\"
|
||||
return {service_class_name}Service.ping()
|
||||
|
||||
|
||||
@router.get('/items')
|
||||
async def list_items(keyword: str = '') -> dict[str, object]:
|
||||
\"\"\"
|
||||
查询示例数据列表。
|
||||
|
||||
:param keyword: 名称关键字
|
||||
:return: 示例数据分页结果
|
||||
\"\"\"
|
||||
return {service_class_name}Service.list_items(keyword)
|
||||
|
||||
|
||||
@router.post('/items')
|
||||
async def create_item(payload: dict[str, object]) -> dict[str, object]:
|
||||
\"\"\"
|
||||
创建示例数据。
|
||||
|
||||
:param payload: 示例数据负载
|
||||
:return: 创建后的示例数据
|
||||
\"\"\"
|
||||
return {service_class_name}Service.create_item(payload)
|
||||
|
||||
|
||||
@router.put('/items/{{item_id}}')
|
||||
async def update_item(item_id: int, payload: dict[str, object]) -> dict[str, object]:
|
||||
\"\"\"
|
||||
更新示例数据。
|
||||
|
||||
:param item_id: 示例数据ID
|
||||
:param payload: 示例数据负载
|
||||
:return: 更新后的示例数据
|
||||
\"\"\"
|
||||
return {service_class_name}Service.update_item(item_id, payload)
|
||||
|
||||
|
||||
@router.delete('/items/{{item_id}}')
|
||||
async def delete_item(item_id: int) -> dict[str, object]:
|
||||
\"\"\"
|
||||
删除示例数据。
|
||||
|
||||
:param item_id: 示例数据ID
|
||||
:return: 删除结果
|
||||
\"\"\"
|
||||
return {service_class_name}Service.delete_item(item_id)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_service(plugin_id: str) -> str:
|
||||
"""
|
||||
构建后端服务模板内容。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 后端服务模板内容
|
||||
"""
|
||||
service_class_name = PluginScaffoldNaming.to_class_name(plugin_id)
|
||||
return f"""class {service_class_name}Service:
|
||||
\"\"\"
|
||||
{plugin_id} 插件服务。
|
||||
\"\"\"
|
||||
|
||||
@classmethod
|
||||
def ping(cls) -> dict[str, str]:
|
||||
\"\"\"
|
||||
返回插件探活结果。
|
||||
|
||||
:return: 插件探活结果
|
||||
\"\"\"
|
||||
return {{'message': '{plugin_id} plugin ok'}}
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_crud_service(plugin_id: str) -> str:
|
||||
"""
|
||||
构建后端 CRUD 服务模板内容。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 后端 CRUD 服务模板内容
|
||||
"""
|
||||
service_class_name = PluginScaffoldNaming.to_class_name(plugin_id)
|
||||
return f"""class {service_class_name}Service:
|
||||
\"\"\"
|
||||
{plugin_id} 插件 CRUD 示例服务。
|
||||
|
||||
第一版模板使用内存数据演示 controller/service 分层,实际业务可替换为 dao/entity 实现。
|
||||
\"\"\"
|
||||
|
||||
_items = [
|
||||
{{'itemId': 1, 'itemName': '{plugin_id} 示例', 'status': '0', 'remark': '插件 CRUD 模板数据'}},
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def ping(cls) -> dict[str, str]:
|
||||
\"\"\"
|
||||
返回插件探活结果。
|
||||
|
||||
:return: 插件探活结果
|
||||
\"\"\"
|
||||
return {{'message': '{plugin_id} plugin ok'}}
|
||||
|
||||
@classmethod
|
||||
def list_items(cls, keyword: str = '') -> dict[str, object]:
|
||||
\"\"\"
|
||||
查询示例数据列表。
|
||||
|
||||
:param keyword: 名称关键字
|
||||
:return: 示例数据分页结果
|
||||
\"\"\"
|
||||
rows = [
|
||||
item
|
||||
for item in cls._items
|
||||
if not keyword or keyword.lower() in str(item.get('itemName', '')).lower()
|
||||
]
|
||||
return {{'rows': rows, 'total': len(rows)}}
|
||||
|
||||
@classmethod
|
||||
def create_item(cls, payload: dict[str, object]) -> dict[str, object]:
|
||||
\"\"\"
|
||||
创建示例数据。
|
||||
|
||||
:param payload: 示例数据负载
|
||||
:return: 创建后的示例数据
|
||||
\"\"\"
|
||||
next_id = max([int(item['itemId']) for item in cls._items], default=0) + 1
|
||||
item = {{
|
||||
'itemId': next_id,
|
||||
'itemName': str(payload.get('itemName') or '未命名'),
|
||||
'status': str(payload.get('status') or '0'),
|
||||
'remark': str(payload.get('remark') or ''),
|
||||
}}
|
||||
cls._items.append(item)
|
||||
return item
|
||||
|
||||
@classmethod
|
||||
def update_item(cls, item_id: int, payload: dict[str, object]) -> dict[str, object]:
|
||||
\"\"\"
|
||||
更新示例数据。
|
||||
|
||||
:param item_id: 示例数据ID
|
||||
:param payload: 示例数据负载
|
||||
:return: 更新后的示例数据
|
||||
\"\"\"
|
||||
for item in cls._items:
|
||||
if item['itemId'] != item_id:
|
||||
continue
|
||||
item.update(
|
||||
{{
|
||||
'itemName': str(payload.get('itemName') or item.get('itemName')),
|
||||
'status': str(payload.get('status') or item.get('status')),
|
||||
'remark': str(payload.get('remark') or ''),
|
||||
}}
|
||||
)
|
||||
return item
|
||||
return {{'itemId': item_id, 'itemName': '', 'status': '1', 'remark': 'not found'}}
|
||||
|
||||
@classmethod
|
||||
def delete_item(cls, item_id: int) -> dict[str, object]:
|
||||
\"\"\"
|
||||
删除示例数据。
|
||||
|
||||
:param item_id: 示例数据ID
|
||||
:return: 删除结果
|
||||
\"\"\"
|
||||
before_count = len(cls._items)
|
||||
cls._items = [item for item in cls._items if item['itemId'] != item_id]
|
||||
return {{'deleted': len(cls._items) < before_count, 'itemId': item_id}}
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_hooks(plugin_id: str) -> str:
|
||||
"""
|
||||
构建后端生命周期钩子模板内容。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 后端生命周期钩子模板内容
|
||||
"""
|
||||
return f"""from plugins.core.runtime.hooks import PluginHookContext
|
||||
from utils.log_util import logger
|
||||
|
||||
|
||||
async def on_install(context: PluginHookContext) -> None:
|
||||
\"\"\"
|
||||
插件安装生命周期钩子。
|
||||
|
||||
:param context: 插件生命周期钩子上下文
|
||||
:return: None
|
||||
\"\"\"
|
||||
logger.info('{plugin_id} plugin install hook executed')
|
||||
|
||||
|
||||
async def on_upgrade(context: PluginHookContext) -> None:
|
||||
\"\"\"
|
||||
插件升级生命周期钩子。
|
||||
|
||||
:param context: 插件生命周期钩子上下文
|
||||
:return: None
|
||||
\"\"\"
|
||||
logger.info('{plugin_id} plugin upgrade hook executed')
|
||||
|
||||
|
||||
async def on_startup(context: PluginHookContext) -> None:
|
||||
\"\"\"
|
||||
插件启动生命周期钩子。
|
||||
|
||||
:param context: 插件生命周期钩子上下文
|
||||
:return: None
|
||||
\"\"\"
|
||||
logger.info('{plugin_id} plugin startup hook executed')
|
||||
|
||||
|
||||
async def on_shutdown(context: PluginHookContext) -> None:
|
||||
\"\"\"
|
||||
插件关闭生命周期钩子。
|
||||
|
||||
:param context: 插件生命周期钩子上下文
|
||||
:return: None
|
||||
\"\"\"
|
||||
logger.info('{plugin_id} plugin shutdown hook executed')
|
||||
|
||||
|
||||
async def on_purge(context: PluginHookContext) -> None:
|
||||
\"\"\"
|
||||
插件物理清理生命周期钩子。
|
||||
|
||||
:param context: 插件生命周期钩子上下文
|
||||
:return: None
|
||||
\"\"\"
|
||||
logger.info('{plugin_id} plugin purge hook executed')
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_jobs(plugin_id: str) -> str:
|
||||
"""
|
||||
构建后端定时任务模板内容。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 后端定时任务模板内容
|
||||
"""
|
||||
return f"""from utils.log_util import logger
|
||||
|
||||
|
||||
def heartbeat() -> None:
|
||||
\"\"\"
|
||||
插件心跳定时任务。
|
||||
|
||||
:return: None
|
||||
\"\"\"
|
||||
logger.info('{plugin_id} plugin heartbeat job executed')
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_migration(plugin_id: str) -> str:
|
||||
"""
|
||||
构建后端 migration 模板内容。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 后端 migration 模板内容
|
||||
"""
|
||||
return f"""-- {plugin_id} plugin initial migration.
|
||||
-- Add plugin tables or schema changes here.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_seed(plugin_id: str) -> str:
|
||||
"""
|
||||
构建后端 seed 模板内容。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 后端 seed 模板内容
|
||||
"""
|
||||
return f"""-- {plugin_id} plugin initial seed.
|
||||
-- Add idempotent initialization data here.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_test(plugin_id: str) -> str:
|
||||
"""
|
||||
构建后端插件 pytest 样例。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 后端插件测试样例内容
|
||||
"""
|
||||
service_class_name = PluginScaffoldNaming.to_class_name(plugin_id)
|
||||
return f"""from plugins.{plugin_id}.service.{plugin_id}_service import {service_class_name}Service
|
||||
|
||||
|
||||
def test_{plugin_id}_service_ping() -> None:
|
||||
\"\"\"
|
||||
校验插件服务探活返回稳定负载。
|
||||
|
||||
:return: None
|
||||
\"\"\"
|
||||
assert {service_class_name}Service.ping() == {{'message': '{plugin_id} plugin ok'}}
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_crud_test(plugin_id: str) -> str:
|
||||
"""
|
||||
构建后端插件 CRUD pytest 样例。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 后端插件 CRUD 测试样例内容
|
||||
"""
|
||||
service_class_name = PluginScaffoldNaming.to_class_name(plugin_id)
|
||||
return f"""from plugins.{plugin_id}.service.{plugin_id}_service import {service_class_name}Service
|
||||
|
||||
|
||||
def test_{plugin_id}_service_ping() -> None:
|
||||
\"\"\"
|
||||
校验插件服务探活返回稳定负载。
|
||||
|
||||
:return: None
|
||||
\"\"\"
|
||||
assert {service_class_name}Service.ping() == {{'message': '{plugin_id} plugin ok'}}
|
||||
|
||||
|
||||
def test_{plugin_id}_service_crud_flow() -> None:
|
||||
\"\"\"
|
||||
校验插件 CRUD 示例服务返回稳定负载。
|
||||
|
||||
:return: None
|
||||
\"\"\"
|
||||
created = {service_class_name}Service.create_item({{'itemName': '测试数据', 'status': '0'}})
|
||||
listed = {service_class_name}Service.list_items('测试')
|
||||
updated = {service_class_name}Service.update_item(created['itemId'], {{'itemName': '测试数据2'}})
|
||||
deleted = {service_class_name}Service.delete_item(created['itemId'])
|
||||
|
||||
assert listed['total'] >= 1
|
||||
assert updated['itemName'] == '测试数据2'
|
||||
assert deleted['deleted'] is True
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_readme(plugin_id: str) -> str:
|
||||
"""
|
||||
构建后端 README 内容。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 后端 README 内容
|
||||
"""
|
||||
return f"""# {plugin_id} backend plugin
|
||||
|
||||
Backend plugin scaffold generated by `ruoyi plugin create`.
|
||||
|
||||
## Structure
|
||||
|
||||
- `plugin.yaml`: backend manifest, menus, permissions and dependencies.
|
||||
- `controller/`: FastAPI routers discovered when the plugin is enabled.
|
||||
- `service/`: plugin service classes.
|
||||
- `dao/`: plugin data access classes.
|
||||
- `entity/do/`: SQLAlchemy models imported before table creation.
|
||||
- `entity/vo/`: Pydantic request and response models.
|
||||
- `hooks.py`: lifecycle hook examples declared in `plugin.yaml`.
|
||||
- `jobs.py`: scheduled job example declared in `plugin.yaml`.
|
||||
- `migrations/`: database migration scripts declared in `plugin.yaml`.
|
||||
- `seeds/`: initialization scripts declared in `plugin.yaml`.
|
||||
- `tests/plugins/{plugin_id}/`: pytest examples for this plugin.
|
||||
- frontend project `tests/plugins/{plugin_id}/`: frontend node tests for this plugin.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
ruoyi plugin check {plugin_id}
|
||||
pytest tests/plugins/{plugin_id}
|
||||
cd <frontend-project> && node tests/plugins/{plugin_id}/pluginView.test.js
|
||||
ruoyi plugin install {plugin_id} --dry-run
|
||||
ruoyi plugin install {plugin_id} --yes
|
||||
ruoyi plugin enable {plugin_id} --yes
|
||||
ruoyi plugin disable {plugin_id} --yes
|
||||
ruoyi plugin upgrade {plugin_id} --dry-run
|
||||
ruoyi plugin uninstall {plugin_id} --yes
|
||||
ruoyi plugin purge {plugin_id} --dry-run
|
||||
ruoyi plugin config get {plugin_id}
|
||||
```
|
||||
|
||||
## Frontend View
|
||||
|
||||
The menu component `plugin/{plugin_id}/index` maps to:
|
||||
|
||||
```text
|
||||
<frontend-project>/plugins/{plugin_id}/views/index.vue
|
||||
```
|
||||
"""
|
||||
@@ -0,0 +1,280 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from plugins.core.utils import validate_plugin_id_value
|
||||
|
||||
from .backend import PluginBackendScaffoldTemplateBuilder
|
||||
from .frontend import FrontendVersion, PluginFrontendScaffoldTemplateBuilder, PluginFrontendVersionResolver
|
||||
from .options import PluginScaffoldOptions, PluginScaffoldTemplateResolver
|
||||
from .payload import PluginScaffoldPayloadBuilder, PluginScaffoldPlanPayload
|
||||
|
||||
|
||||
class PluginScaffoldBuilder:
|
||||
"""
|
||||
插件模板构建器。
|
||||
|
||||
使用 Builder 模式生成后端与前端插件模板文件计划,并在确认无冲突后落地。
|
||||
"""
|
||||
|
||||
def __init__(self, backend_root: Path, frontend_root: Path) -> None:
|
||||
"""
|
||||
初始化插件模板构建器。
|
||||
|
||||
:param backend_root: 后端项目根目录
|
||||
:param frontend_root: 前端项目根目录
|
||||
"""
|
||||
self.backend_root = backend_root
|
||||
self.frontend_root = frontend_root
|
||||
|
||||
def build_plan(
|
||||
self,
|
||||
plugin_id: str,
|
||||
*,
|
||||
template: str = PluginScaffoldTemplateResolver.DEFAULT_TEMPLATE,
|
||||
backend: bool,
|
||||
frontend: bool,
|
||||
migration: bool = True,
|
||||
seed: bool = True,
|
||||
job: bool = True,
|
||||
config: bool = True,
|
||||
test: bool = True,
|
||||
frontend_version: str = PluginFrontendVersionResolver.AUTO,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
构建插件模板写入计划。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:param template: 插件模板名称
|
||||
:param backend: 是否创建后端插件模板
|
||||
:param frontend: 是否创建前端插件模板
|
||||
:param migration: 是否创建 migration 示例
|
||||
:param seed: 是否创建 seed 示例
|
||||
:param job: 是否创建定时任务示例
|
||||
:param config: 是否创建配置项示例
|
||||
:param test: 是否创建测试样例
|
||||
:param frontend_version: 前端 Vue 版本,支持 auto、vue2、vue3
|
||||
:return: 插件模板写入计划
|
||||
"""
|
||||
self._validate_plugin_id(plugin_id)
|
||||
options = self._merge_options(
|
||||
PluginScaffoldTemplateResolver.resolve(template),
|
||||
backend=backend,
|
||||
frontend=frontend,
|
||||
migration=migration,
|
||||
seed=seed,
|
||||
job=job,
|
||||
config=config,
|
||||
test=test,
|
||||
)
|
||||
if not options.backend and not options.frontend:
|
||||
raise ValueError('backend 和 frontend 至少需要创建一个')
|
||||
|
||||
files = []
|
||||
target_dirs = []
|
||||
effective_backend_test = options.test and options.backend
|
||||
effective_frontend_test = options.test and options.frontend
|
||||
resolved_frontend_version = (
|
||||
PluginFrontendVersionResolver.resolve(self.frontend_root, frontend_version) if options.frontend else None
|
||||
)
|
||||
if options.backend:
|
||||
backend_plugin_root = self.backend_root / 'plugins' / plugin_id
|
||||
target_dirs.append(str(backend_plugin_root))
|
||||
if effective_backend_test:
|
||||
target_dirs.append(str(self.backend_root / 'tests' / 'plugins' / plugin_id))
|
||||
files.extend(self._build_backend_files(plugin_id, backend_plugin_root, options))
|
||||
if options.frontend:
|
||||
assert resolved_frontend_version is not None
|
||||
frontend_plugin_root = self.frontend_root / 'plugins' / plugin_id
|
||||
target_dirs.append(str(frontend_plugin_root))
|
||||
if effective_frontend_test:
|
||||
target_dirs.append(str(self.frontend_root / 'tests' / 'plugins' / plugin_id))
|
||||
files.extend(
|
||||
self._build_frontend_files(
|
||||
plugin_id,
|
||||
frontend_plugin_root,
|
||||
options,
|
||||
frontend_version=resolved_frontend_version,
|
||||
)
|
||||
)
|
||||
|
||||
conflicts = [target_dir for target_dir in target_dirs if Path(target_dir).exists()]
|
||||
|
||||
return PluginScaffoldPlanPayload(
|
||||
template=template or PluginScaffoldTemplateResolver.DEFAULT_TEMPLATE,
|
||||
backend=options.backend,
|
||||
frontend=options.frontend,
|
||||
migration=options.migration,
|
||||
seed=options.seed,
|
||||
job=options.job,
|
||||
config=options.config,
|
||||
crud=options.crud,
|
||||
test=effective_backend_test or effective_frontend_test,
|
||||
backend_test=effective_backend_test,
|
||||
frontend_test=effective_frontend_test,
|
||||
frontend_version=resolved_frontend_version,
|
||||
target_dirs=target_dirs,
|
||||
files=files,
|
||||
conflicts=conflicts,
|
||||
).to_payload()
|
||||
|
||||
build_conflict_payload = staticmethod(PluginScaffoldPayloadBuilder.build_conflict_payload)
|
||||
build_success_payload = staticmethod(PluginScaffoldPayloadBuilder.build_success_payload)
|
||||
|
||||
@classmethod
|
||||
def _validate_plugin_id(cls, plugin_id: str) -> None:
|
||||
"""
|
||||
校验插件模板 ID。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: None
|
||||
"""
|
||||
validate_plugin_id_value(plugin_id)
|
||||
|
||||
@staticmethod
|
||||
def _merge_options(
|
||||
base_options: PluginScaffoldOptions,
|
||||
*,
|
||||
backend: bool,
|
||||
frontend: bool,
|
||||
migration: bool,
|
||||
seed: bool,
|
||||
job: bool,
|
||||
config: bool,
|
||||
test: bool,
|
||||
) -> PluginScaffoldOptions:
|
||||
"""
|
||||
合并模板预设和命令行开关。
|
||||
|
||||
:param base_options: 模板预设选项
|
||||
:param backend: 是否创建后端插件模板
|
||||
:param frontend: 是否创建前端插件模板
|
||||
:param migration: 是否创建 migration 示例
|
||||
:param seed: 是否创建 seed 示例
|
||||
:param job: 是否创建定时任务示例
|
||||
:param config: 是否创建配置项示例
|
||||
:param test: 是否创建测试样例
|
||||
:return: 合并后的插件模板生成选项
|
||||
"""
|
||||
return PluginScaffoldOptions(
|
||||
backend=base_options.backend and backend,
|
||||
frontend=base_options.frontend and frontend,
|
||||
migration=base_options.migration and migration,
|
||||
seed=base_options.seed and seed,
|
||||
job=base_options.job and job,
|
||||
config=base_options.config and config,
|
||||
test=base_options.test and test,
|
||||
crud=base_options.crud,
|
||||
)
|
||||
|
||||
def apply_plan(self, scaffold_plan: dict[str, Any]) -> None:
|
||||
"""
|
||||
执行插件模板写入计划。
|
||||
|
||||
:param scaffold_plan: 插件模板写入计划
|
||||
:return: None
|
||||
"""
|
||||
for file_payload in scaffold_plan['files']:
|
||||
file_path = Path(file_payload['path'])
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(file_payload['content'], encoding='utf-8')
|
||||
|
||||
def _build_backend_files(
|
||||
self,
|
||||
plugin_id: str,
|
||||
plugin_root: Path,
|
||||
options: PluginScaffoldOptions,
|
||||
) -> list[tuple[Path, str]]:
|
||||
"""
|
||||
构建后端插件模板文件。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:param plugin_root: 后端插件根目录
|
||||
:param options: 插件模板生成选项
|
||||
:return: 文件路径和内容列表
|
||||
"""
|
||||
files = [
|
||||
(plugin_root / 'plugin.yaml', PluginBackendScaffoldTemplateBuilder.build_manifest(plugin_id, options)),
|
||||
(
|
||||
plugin_root / 'controller' / f'{plugin_id}_controller.py',
|
||||
PluginBackendScaffoldTemplateBuilder.build_crud_controller(plugin_id)
|
||||
if options.crud
|
||||
else PluginBackendScaffoldTemplateBuilder.build_controller(plugin_id),
|
||||
),
|
||||
(
|
||||
plugin_root / 'service' / f'{plugin_id}_service.py',
|
||||
PluginBackendScaffoldTemplateBuilder.build_crud_service(plugin_id)
|
||||
if options.crud
|
||||
else PluginBackendScaffoldTemplateBuilder.build_service(plugin_id),
|
||||
),
|
||||
(plugin_root / 'hooks.py', PluginBackendScaffoldTemplateBuilder.build_hooks(plugin_id)),
|
||||
(plugin_root / 'README.md', PluginBackendScaffoldTemplateBuilder.build_readme(plugin_id)),
|
||||
]
|
||||
if options.job:
|
||||
files.append((plugin_root / 'jobs.py', PluginBackendScaffoldTemplateBuilder.build_jobs(plugin_id)))
|
||||
if options.migration:
|
||||
files.append(
|
||||
(
|
||||
plugin_root / 'migrations' / '001_init.sql',
|
||||
PluginBackendScaffoldTemplateBuilder.build_migration(plugin_id),
|
||||
)
|
||||
)
|
||||
if options.seed:
|
||||
files.append(
|
||||
(plugin_root / 'seeds' / '001_seed.sql', PluginBackendScaffoldTemplateBuilder.build_seed(plugin_id))
|
||||
)
|
||||
if options.test:
|
||||
files.append(
|
||||
(
|
||||
self.backend_root / 'tests' / 'plugins' / plugin_id / 'test_ping.py',
|
||||
PluginBackendScaffoldTemplateBuilder.build_crud_test(plugin_id)
|
||||
if options.crud
|
||||
else PluginBackendScaffoldTemplateBuilder.build_test(plugin_id),
|
||||
)
|
||||
)
|
||||
|
||||
return files
|
||||
|
||||
def _build_frontend_files(
|
||||
self,
|
||||
plugin_id: str,
|
||||
plugin_root: Path,
|
||||
options: PluginScaffoldOptions,
|
||||
*,
|
||||
frontend_version: FrontendVersion,
|
||||
) -> list[tuple[Path, str]]:
|
||||
"""
|
||||
构建前端插件模板文件。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:param plugin_root: 前端插件根目录
|
||||
:param options: 插件模板生成选项
|
||||
:param frontend_version: 已解析的前端 Vue 版本
|
||||
:return: 文件路径和内容列表
|
||||
"""
|
||||
files = [
|
||||
(
|
||||
plugin_root / 'api' / f'{plugin_id}.js',
|
||||
PluginFrontendScaffoldTemplateBuilder.build_crud_api(plugin_id)
|
||||
if options.crud
|
||||
else PluginFrontendScaffoldTemplateBuilder.build_api(plugin_id),
|
||||
),
|
||||
(
|
||||
plugin_root / 'views' / 'index.vue',
|
||||
PluginFrontendScaffoldTemplateBuilder.build_crud_view(plugin_id, frontend_version)
|
||||
if options.crud
|
||||
else PluginFrontendScaffoldTemplateBuilder.build_view(plugin_id, frontend_version),
|
||||
),
|
||||
(
|
||||
plugin_root / 'README.md',
|
||||
PluginFrontendScaffoldTemplateBuilder.build_readme(plugin_id, frontend_version),
|
||||
),
|
||||
]
|
||||
if options.test:
|
||||
files.append(
|
||||
(
|
||||
self.frontend_root / 'tests' / 'plugins' / plugin_id / 'pluginView.test.js',
|
||||
PluginFrontendScaffoldTemplateBuilder.build_test(plugin_id, frontend_version),
|
||||
)
|
||||
)
|
||||
|
||||
return files
|
||||
@@ -0,0 +1,431 @@
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Literal, cast
|
||||
|
||||
from .frontend_vue2 import PluginVue2FrontendScaffoldTemplateBuilder
|
||||
from .naming import PluginScaffoldNaming
|
||||
|
||||
FrontendVersion = Literal['vue2', 'vue3']
|
||||
|
||||
|
||||
class PluginFrontendVersionResolver:
|
||||
"""
|
||||
前端 Vue 版本解析器。
|
||||
"""
|
||||
|
||||
AUTO = 'auto'
|
||||
DEFAULT_VERSION: FrontendVersion = 'vue3'
|
||||
SUPPORTED_VALUES = (AUTO, 'vue2', 'vue3')
|
||||
|
||||
@classmethod
|
||||
def resolve(cls, frontend_root: Path, requested_version: str = AUTO) -> FrontendVersion:
|
||||
"""
|
||||
解析脚手架应使用的 Vue 版本。
|
||||
|
||||
显式版本优先;auto 模式读取目标前端 package.json。临时目录等没有
|
||||
package.json 的场景保持历史行为,默认生成 Vue 3 模板。
|
||||
|
||||
:param frontend_root: 前端项目根目录
|
||||
:param requested_version: auto、vue2 或 vue3
|
||||
:return: 解析后的 Vue 版本
|
||||
"""
|
||||
normalized_version = (requested_version or cls.AUTO).strip().lower()
|
||||
if normalized_version not in cls.SUPPORTED_VALUES:
|
||||
supported = '、'.join(cls.SUPPORTED_VALUES)
|
||||
raise ValueError(f'frontend_version 仅支持 {supported},当前值:{requested_version}')
|
||||
if normalized_version != cls.AUTO:
|
||||
return cast('FrontendVersion', normalized_version)
|
||||
|
||||
package_json_path = frontend_root / 'package.json'
|
||||
if not package_json_path.is_file():
|
||||
return cls.DEFAULT_VERSION
|
||||
|
||||
try:
|
||||
package_payload = json.loads(package_json_path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f'读取前端 package.json 失败:{package_json_path}({exc})') from exc
|
||||
if not isinstance(package_payload, dict):
|
||||
raise ValueError(f'前端 package.json 顶层必须是对象:{package_json_path}')
|
||||
|
||||
dependencies = cls._collect_dependencies(package_payload)
|
||||
vue_version = cls._resolve_vue_dependency(dependencies.get('vue'))
|
||||
if vue_version is not None:
|
||||
return vue_version
|
||||
if 'element-ui' in dependencies:
|
||||
return 'vue2'
|
||||
if 'element-plus' in dependencies:
|
||||
return 'vue3'
|
||||
|
||||
raise ValueError(f'无法从 {package_json_path} 识别 Vue 版本,请使用 --frontend-version vue2 或 vue3 显式指定')
|
||||
|
||||
@staticmethod
|
||||
def _collect_dependencies(package_payload: dict[str, object]) -> dict[str, object]:
|
||||
"""
|
||||
合并 dependencies 和 devDependencies。
|
||||
|
||||
:param package_payload: package.json 负载
|
||||
:return: 依赖映射
|
||||
"""
|
||||
dependencies: dict[str, object] = {}
|
||||
for key in ('devDependencies', 'dependencies'):
|
||||
section = package_payload.get(key)
|
||||
if isinstance(section, dict):
|
||||
dependencies.update(section)
|
||||
return dependencies
|
||||
|
||||
@staticmethod
|
||||
def _resolve_vue_dependency(version_spec: object) -> FrontendVersion | None:
|
||||
"""
|
||||
从 npm Vue 版本约束中提取主版本。
|
||||
|
||||
:param version_spec: Vue npm 版本约束
|
||||
:return: Vue 版本,无法识别时返回 None
|
||||
"""
|
||||
if not isinstance(version_spec, str):
|
||||
return None
|
||||
match = re.search(r'(?<!\d)([23])(?:\.\d+)?', version_spec)
|
||||
if match is None:
|
||||
return None
|
||||
return cast('FrontendVersion', f'vue{match.group(1)}')
|
||||
|
||||
|
||||
class PluginVue3FrontendScaffoldTemplateBuilder:
|
||||
"""
|
||||
前端插件模板内容构建器。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_api(plugin_id: str) -> str:
|
||||
"""
|
||||
构建前端 API 模板内容。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 前端 API 模板内容
|
||||
"""
|
||||
return f"""import request from '@/utils/request'
|
||||
|
||||
export function ping{PluginScaffoldNaming.to_class_name(plugin_id)}() {{
|
||||
return request({{
|
||||
url: '/{plugin_id}/ping',
|
||||
method: 'get'
|
||||
}})
|
||||
}}
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_crud_api(plugin_id: str) -> str:
|
||||
"""
|
||||
构建前端 CRUD API 模板内容。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 前端 CRUD API 模板内容
|
||||
"""
|
||||
class_name = PluginScaffoldNaming.to_class_name(plugin_id)
|
||||
return f"""import request from '@/utils/request'
|
||||
|
||||
export function ping{class_name}() {{
|
||||
return request({{
|
||||
url: '/{plugin_id}/ping',
|
||||
method: 'get'
|
||||
}})
|
||||
}}
|
||||
|
||||
export function list{class_name}Items(query) {{
|
||||
return request({{
|
||||
url: '/{plugin_id}/items',
|
||||
method: 'get',
|
||||
params: query
|
||||
}})
|
||||
}}
|
||||
|
||||
export function add{class_name}Item(data) {{
|
||||
return request({{
|
||||
url: '/{plugin_id}/items',
|
||||
method: 'post',
|
||||
data
|
||||
}})
|
||||
}}
|
||||
|
||||
export function update{class_name}Item(itemId, data) {{
|
||||
return request({{
|
||||
url: '/{plugin_id}/items/' + itemId,
|
||||
method: 'put',
|
||||
data
|
||||
}})
|
||||
}}
|
||||
|
||||
export function del{class_name}Item(itemId) {{
|
||||
return request({{
|
||||
url: '/{plugin_id}/items/' + itemId,
|
||||
method: 'delete'
|
||||
}})
|
||||
}}
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_view(plugin_id: str) -> str:
|
||||
"""
|
||||
构建前端视图模板内容。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 前端视图模板内容
|
||||
"""
|
||||
return f"""<template>
|
||||
<div class=\"app-container\">
|
||||
<el-card shadow=\"never\">
|
||||
<template #header>{plugin_id}</template>
|
||||
<div>{plugin_id} plugin</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_crud_view(plugin_id: str) -> str:
|
||||
"""
|
||||
构建前端 CRUD 视图模板内容。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 前端 CRUD 视图模板内容
|
||||
"""
|
||||
class_name = PluginScaffoldNaming.to_class_name(plugin_id)
|
||||
return f"""<template>
|
||||
<div class=\"app-container\">
|
||||
<el-form :model=\"queryParams\" :inline=\"true\">
|
||||
<el-form-item label=\"名称\">
|
||||
<el-input v-model=\"queryParams.keyword\" placeholder=\"请输入名称\" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type=\"primary\" icon=\"Search\" @click=\"getList\">搜索</el-button>
|
||||
<el-button icon=\"Refresh\" @click=\"resetQuery\">重置</el-button>
|
||||
<el-button type=\"primary\" plain icon=\"Plus\" @click=\"handleAdd\">新增</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading=\"loading\" :data=\"itemList\" border>
|
||||
<el-table-column label=\"ID\" prop=\"itemId\" width=\"90\" align=\"center\" />
|
||||
<el-table-column label=\"名称\" prop=\"itemName\" min-width=\"180\" />
|
||||
<el-table-column label=\"状态\" prop=\"status\" width=\"90\" align=\"center\">
|
||||
<template #default=\"scope\">
|
||||
<el-tag :type=\"scope.row.status === '0' ? 'success' : 'info'\">{{{{ scope.row.status === '0' ? '正常' : '停用' }}}}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label=\"备注\" prop=\"remark\" min-width=\"220\" />
|
||||
<el-table-column label=\"操作\" width=\"150\" align=\"center\">
|
||||
<template #default=\"scope\">
|
||||
<el-button link type=\"primary\" icon=\"Edit\" @click=\"handleUpdate(scope.row)\">修改</el-button>
|
||||
<el-button link type=\"danger\" icon=\"Delete\" @click=\"handleDelete(scope.row)\">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog :title=\"dialogTitle\" v-model=\"open\" width=\"520px\" append-to-body>
|
||||
<el-form ref=\"itemRef\" :model=\"form\" :rules=\"rules\" label-width=\"90px\">
|
||||
<el-form-item label=\"名称\" prop=\"itemName\">
|
||||
<el-input v-model=\"form.itemName\" placeholder=\"请输入名称\" />
|
||||
</el-form-item>
|
||||
<el-form-item label=\"状态\" prop=\"status\">
|
||||
<el-radio-group v-model=\"form.status\">
|
||||
<el-radio label=\"0\">正常</el-radio>
|
||||
<el-radio label=\"1\">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label=\"备注\" prop=\"remark\">
|
||||
<el-input v-model=\"form.remark\" type=\"textarea\" :rows=\"3\" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class=\"dialog-footer\">
|
||||
<el-button type=\"primary\" @click=\"submitForm\">确 定</el-button>
|
||||
<el-button @click=\"open = false\">取 消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name=\"{class_name}Plugin\">
|
||||
import {{
|
||||
add{class_name}Item,
|
||||
del{class_name}Item,
|
||||
list{class_name}Items,
|
||||
update{class_name}Item
|
||||
}} from '../api/{plugin_id}'
|
||||
|
||||
const {{ proxy }} = getCurrentInstance()
|
||||
|
||||
const loading = ref(false)
|
||||
const open = ref(false)
|
||||
const dialogTitle = ref('')
|
||||
const itemList = ref([])
|
||||
const queryParams = reactive({{
|
||||
keyword: ''
|
||||
}})
|
||||
const form = reactive({{
|
||||
itemId: undefined,
|
||||
itemName: '',
|
||||
status: '0',
|
||||
remark: ''
|
||||
}})
|
||||
const rules = {{
|
||||
itemName: [{{ required: true, message: '名称不能为空', trigger: 'blur' }}]
|
||||
}}
|
||||
|
||||
function resetForm() {{
|
||||
form.itemId = undefined
|
||||
form.itemName = ''
|
||||
form.status = '0'
|
||||
form.remark = ''
|
||||
}}
|
||||
|
||||
function getList() {{
|
||||
loading.value = true
|
||||
list{class_name}Items(queryParams).then(response => {{
|
||||
const data = response.data || response
|
||||
itemList.value = data.rows || []
|
||||
}}).finally(() => {{
|
||||
loading.value = false
|
||||
}})
|
||||
}}
|
||||
|
||||
function resetQuery() {{
|
||||
queryParams.keyword = ''
|
||||
getList()
|
||||
}}
|
||||
|
||||
function handleAdd() {{
|
||||
resetForm()
|
||||
dialogTitle.value = '新增{plugin_id}'
|
||||
open.value = true
|
||||
}}
|
||||
|
||||
function handleUpdate(row) {{
|
||||
resetForm()
|
||||
form.itemId = row.itemId
|
||||
form.itemName = row.itemName
|
||||
form.status = row.status
|
||||
form.remark = row.remark
|
||||
dialogTitle.value = '修改{plugin_id}'
|
||||
open.value = true
|
||||
}}
|
||||
|
||||
function submitForm() {{
|
||||
proxy.$refs.itemRef.validate(valid => {{
|
||||
if (!valid) {{
|
||||
return
|
||||
}}
|
||||
const request = form.itemId ? update{class_name}Item(form.itemId, form) : add{class_name}Item(form)
|
||||
request.then(() => {{
|
||||
proxy.$modal.msgSuccess('保存成功')
|
||||
open.value = false
|
||||
getList()
|
||||
}})
|
||||
}})
|
||||
}}
|
||||
|
||||
function handleDelete(row) {{
|
||||
proxy.$modal.confirm('确认删除数据\"' + row.itemName + '\"吗?').then(function () {{
|
||||
return del{class_name}Item(row.itemId)
|
||||
}}).then(() => {{
|
||||
proxy.$modal.msgSuccess('删除成功')
|
||||
getList()
|
||||
}})
|
||||
}}
|
||||
|
||||
getList()
|
||||
</script>
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_readme(plugin_id: str) -> str:
|
||||
"""
|
||||
构建前端 README 内容。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 前端 README 内容
|
||||
"""
|
||||
return f"""# {plugin_id} frontend plugin
|
||||
|
||||
Frontend plugin scaffold generated by `ruoyi plugin create`.
|
||||
|
||||
## Structure
|
||||
|
||||
- `api/`: request wrappers used by plugin pages.
|
||||
- `views/`: Vue pages loaded by backend menu component paths.
|
||||
- `../../tests/plugins/{plugin_id}/`: frontend node tests for plugin view resolving.
|
||||
|
||||
## Route Component
|
||||
|
||||
The backend menu component `plugin/{plugin_id}/index` maps to:
|
||||
|
||||
```text
|
||||
plugins/{plugin_id}/views/index.vue
|
||||
```
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_test(plugin_id: str) -> str:
|
||||
"""
|
||||
构建前端插件 node 测试样例。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 前端插件测试样例内容
|
||||
"""
|
||||
return f"""import assert from 'node:assert/strict'
|
||||
import {{ existsSync }} from 'node:fs'
|
||||
import {{ dirname, resolve }} from 'node:path'
|
||||
import {{ fileURLToPath }} from 'node:url'
|
||||
|
||||
import {{ resolvePluginViewPath }} from '../../../src/utils/pluginViewResolver.js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
const frontendRoot = resolve(__dirname, '../../..')
|
||||
const viewPath = resolve(frontendRoot, 'plugins', '{plugin_id}', 'views', 'index.vue')
|
||||
|
||||
assert.equal(resolvePluginViewPath('plugin/{plugin_id}/index'), '../../../plugins/{plugin_id}/views/index.vue')
|
||||
assert.equal(existsSync(viewPath), true)
|
||||
|
||||
console.log('{plugin_id} plugin frontend tests passed')
|
||||
"""
|
||||
|
||||
|
||||
class PluginFrontendScaffoldTemplateBuilder:
|
||||
"""
|
||||
根据目标 Vue 版本分派前端插件模板。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_api(plugin_id: str) -> str:
|
||||
return PluginVue3FrontendScaffoldTemplateBuilder.build_api(plugin_id)
|
||||
|
||||
@staticmethod
|
||||
def build_crud_api(plugin_id: str) -> str:
|
||||
return PluginVue3FrontendScaffoldTemplateBuilder.build_crud_api(plugin_id)
|
||||
|
||||
@classmethod
|
||||
def build_view(cls, plugin_id: str, frontend_version: FrontendVersion = 'vue3') -> str:
|
||||
return cls._resolve_builder(frontend_version).build_view(plugin_id)
|
||||
|
||||
@classmethod
|
||||
def build_crud_view(cls, plugin_id: str, frontend_version: FrontendVersion = 'vue3') -> str:
|
||||
return cls._resolve_builder(frontend_version).build_crud_view(plugin_id)
|
||||
|
||||
@classmethod
|
||||
def build_readme(cls, plugin_id: str, frontend_version: FrontendVersion = 'vue3') -> str:
|
||||
return cls._resolve_builder(frontend_version).build_readme(plugin_id)
|
||||
|
||||
@classmethod
|
||||
def build_test(cls, plugin_id: str, frontend_version: FrontendVersion = 'vue3') -> str:
|
||||
return cls._resolve_builder(frontend_version).build_test(plugin_id)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_builder(
|
||||
frontend_version: FrontendVersion,
|
||||
) -> type[PluginVue2FrontendScaffoldTemplateBuilder] | type[PluginVue3FrontendScaffoldTemplateBuilder]:
|
||||
if frontend_version == 'vue2':
|
||||
return PluginVue2FrontendScaffoldTemplateBuilder
|
||||
if frontend_version == 'vue3':
|
||||
return PluginVue3FrontendScaffoldTemplateBuilder
|
||||
raise ValueError(f'不支持的 Vue 版本:{frontend_version}')
|
||||
@@ -0,0 +1,235 @@
|
||||
from .naming import PluginScaffoldNaming
|
||||
|
||||
|
||||
class PluginVue2FrontendScaffoldTemplateBuilder:
|
||||
"""
|
||||
Vue 2 前端插件模板内容构建器。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_view(plugin_id: str) -> str:
|
||||
"""
|
||||
构建 Vue 2 前端视图模板内容。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 前端视图模板内容
|
||||
"""
|
||||
return f"""<template>
|
||||
<div class=\"app-container\">
|
||||
<el-card shadow=\"never\">
|
||||
<div slot=\"header\">{plugin_id}</div>
|
||||
<div>{plugin_id} plugin</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_crud_view(plugin_id: str) -> str:
|
||||
"""
|
||||
构建 Vue 2 前端 CRUD 视图模板内容。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 前端 CRUD 视图模板内容
|
||||
"""
|
||||
class_name = PluginScaffoldNaming.to_class_name(plugin_id)
|
||||
return f"""<template>
|
||||
<div class=\"app-container\">
|
||||
<el-form ref=\"queryForm\" :model=\"queryParams\" size=\"small\" :inline=\"true\">
|
||||
<el-form-item label=\"名称\">
|
||||
<el-input v-model=\"queryParams.keyword\" placeholder=\"请输入名称\" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type=\"primary\" icon=\"el-icon-search\" size=\"mini\" @click=\"getList\">搜索</el-button>
|
||||
<el-button icon=\"el-icon-refresh\" size=\"mini\" @click=\"resetQuery\">重置</el-button>
|
||||
<el-button type=\"primary\" plain icon=\"el-icon-plus\" size=\"mini\" @click=\"handleAdd\">新增</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading=\"loading\" :data=\"itemList\" border>
|
||||
<el-table-column label=\"ID\" prop=\"itemId\" width=\"90\" align=\"center\" />
|
||||
<el-table-column label=\"名称\" prop=\"itemName\" min-width=\"180\" />
|
||||
<el-table-column label=\"状态\" prop=\"status\" width=\"90\" align=\"center\">
|
||||
<template slot-scope=\"scope\">
|
||||
<el-tag :type=\"scope.row.status === '0' ? 'success' : 'info'\">{{{{ scope.row.status === '0' ? '正常' : '停用' }}}}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label=\"备注\" prop=\"remark\" min-width=\"220\" />
|
||||
<el-table-column label=\"操作\" width=\"150\" align=\"center\">
|
||||
<template slot-scope=\"scope\">
|
||||
<el-button type=\"text\" icon=\"el-icon-edit\" size=\"mini\" @click=\"handleUpdate(scope.row)\">修改</el-button>
|
||||
<el-button type=\"text\" icon=\"el-icon-delete\" size=\"mini\" @click=\"handleDelete(scope.row)\">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog :title=\"dialogTitle\" :visible.sync=\"open\" width=\"520px\" append-to-body>
|
||||
<el-form ref=\"itemForm\" :model=\"form\" :rules=\"rules\" label-width=\"90px\">
|
||||
<el-form-item label=\"名称\" prop=\"itemName\">
|
||||
<el-input v-model=\"form.itemName\" placeholder=\"请输入名称\" />
|
||||
</el-form-item>
|
||||
<el-form-item label=\"状态\" prop=\"status\">
|
||||
<el-radio-group v-model=\"form.status\">
|
||||
<el-radio label=\"0\">正常</el-radio>
|
||||
<el-radio label=\"1\">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label=\"备注\" prop=\"remark\">
|
||||
<el-input v-model=\"form.remark\" type=\"textarea\" :rows=\"3\" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot=\"footer\" class=\"dialog-footer\">
|
||||
<el-button type=\"primary\" @click=\"submitForm\">确 定</el-button>
|
||||
<el-button @click=\"open = false\">取 消</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {{
|
||||
add{class_name}Item,
|
||||
del{class_name}Item,
|
||||
list{class_name}Items,
|
||||
update{class_name}Item
|
||||
}} from '../api/{plugin_id}'
|
||||
|
||||
export default {{
|
||||
name: '{class_name}Plugin',
|
||||
data() {{
|
||||
return {{
|
||||
loading: false,
|
||||
open: false,
|
||||
dialogTitle: '',
|
||||
itemList: [],
|
||||
queryParams: {{
|
||||
keyword: ''
|
||||
}},
|
||||
form: {{
|
||||
itemId: undefined,
|
||||
itemName: '',
|
||||
status: '0',
|
||||
remark: ''
|
||||
}},
|
||||
rules: {{
|
||||
itemName: [{{ required: true, message: '名称不能为空', trigger: 'blur' }}]
|
||||
}}
|
||||
}}
|
||||
}},
|
||||
created() {{
|
||||
this.getList()
|
||||
}},
|
||||
methods: {{
|
||||
resetFormData() {{
|
||||
this.form = {{
|
||||
itemId: undefined,
|
||||
itemName: '',
|
||||
status: '0',
|
||||
remark: ''
|
||||
}}
|
||||
}},
|
||||
getList() {{
|
||||
this.loading = true
|
||||
list{class_name}Items(this.queryParams).then(response => {{
|
||||
const data = response.data || response
|
||||
this.itemList = data.rows || []
|
||||
}}).finally(() => {{
|
||||
this.loading = false
|
||||
}})
|
||||
}},
|
||||
resetQuery() {{
|
||||
this.queryParams.keyword = ''
|
||||
this.getList()
|
||||
}},
|
||||
handleAdd() {{
|
||||
this.resetFormData()
|
||||
this.dialogTitle = '新增{plugin_id}'
|
||||
this.open = true
|
||||
}},
|
||||
handleUpdate(row) {{
|
||||
this.resetFormData()
|
||||
this.form = {{
|
||||
itemId: row.itemId,
|
||||
itemName: row.itemName,
|
||||
status: row.status,
|
||||
remark: row.remark
|
||||
}}
|
||||
this.dialogTitle = '修改{plugin_id}'
|
||||
this.open = true
|
||||
}},
|
||||
submitForm() {{
|
||||
this.$refs.itemForm.validate(valid => {{
|
||||
if (!valid) {{
|
||||
return
|
||||
}}
|
||||
const request = this.form.itemId
|
||||
? update{class_name}Item(this.form.itemId, this.form)
|
||||
: add{class_name}Item(this.form)
|
||||
request.then(() => {{
|
||||
this.$modal.msgSuccess('保存成功')
|
||||
this.open = false
|
||||
this.getList()
|
||||
}})
|
||||
}})
|
||||
}},
|
||||
handleDelete(row) {{
|
||||
this.$modal.confirm('确认删除数据\"' + row.itemName + '\"吗?').then(function () {{
|
||||
return del{class_name}Item(row.itemId)
|
||||
}}).then(() => {{
|
||||
this.$modal.msgSuccess('删除成功')
|
||||
this.getList()
|
||||
}})
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
</script>
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_readme(plugin_id: str) -> str:
|
||||
"""
|
||||
构建 Vue 2 前端 README 内容。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 前端 README 内容
|
||||
"""
|
||||
return f"""# {plugin_id} frontend plugin
|
||||
|
||||
Vue 2 frontend plugin scaffold generated by `ruoyi plugin create`.
|
||||
|
||||
## Structure
|
||||
|
||||
- `api/`: request wrappers used by plugin pages.
|
||||
- `views/`: Vue pages loaded by backend menu component paths.
|
||||
- `../../tests/plugins/{plugin_id}/`: frontend node tests for plugin view resolving.
|
||||
|
||||
## Route Component
|
||||
|
||||
The backend menu component `plugin/{plugin_id}/index` maps to:
|
||||
|
||||
```text
|
||||
plugins/{plugin_id}/views/index.vue
|
||||
```
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_test(plugin_id: str) -> str:
|
||||
"""
|
||||
构建 Vue 2 前端插件 node 测试样例。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 前端插件测试样例内容
|
||||
"""
|
||||
return f"""const assert = require('assert').strict
|
||||
const {{ existsSync }} = require('fs')
|
||||
const {{ resolve }} = require('path')
|
||||
const {{ resolvePluginViewPath }} = require('../../../src/utils/pluginViewResolver')
|
||||
|
||||
const frontendRoot = resolve(__dirname, '../../..')
|
||||
const viewPath = resolve(frontendRoot, 'plugins', '{plugin_id}', 'views', 'index.vue')
|
||||
|
||||
assert.equal(resolvePluginViewPath('plugin/{plugin_id}/index'), './{plugin_id}/views/index.vue')
|
||||
assert.equal(existsSync(viewPath), true)
|
||||
|
||||
console.log('{plugin_id} plugin frontend tests passed')
|
||||
"""
|
||||
@@ -0,0 +1,14 @@
|
||||
class PluginScaffoldNaming:
|
||||
"""
|
||||
插件模板命名转换工具。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def to_class_name(plugin_id: str) -> str:
|
||||
"""
|
||||
将插件 ID 转换为类名前缀。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 类名前缀
|
||||
"""
|
||||
return ''.join(part.capitalize() for part in plugin_id.replace('-', '_').split('_') if part)
|
||||
@@ -0,0 +1,84 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginScaffoldOptions:
|
||||
"""
|
||||
插件模板生成选项。
|
||||
|
||||
:param backend: 是否生成后端模板
|
||||
:param frontend: 是否生成前端模板
|
||||
:param migration: 是否生成 migration 示例
|
||||
:param seed: 是否生成 seed 示例
|
||||
:param job: 是否生成定时任务示例
|
||||
:param config: 是否生成配置项示例
|
||||
:param test: 是否生成测试样例
|
||||
:param crud: 是否生成 CRUD 页面示例
|
||||
"""
|
||||
|
||||
backend: bool = True
|
||||
frontend: bool = True
|
||||
migration: bool = True
|
||||
seed: bool = True
|
||||
job: bool = True
|
||||
config: bool = True
|
||||
test: bool = True
|
||||
crud: bool = False
|
||||
|
||||
|
||||
class PluginScaffoldTemplateResolver:
|
||||
"""
|
||||
插件模板预设解析器。
|
||||
|
||||
使用 Resolver 模式将模板名称转换为稳定的模板生成选项。
|
||||
"""
|
||||
|
||||
SUPPORTED_TEMPLATES = {'minimal', 'backend-only', 'full-stack', 'scheduled-job', 'crud-page'}
|
||||
DEFAULT_TEMPLATE = 'full-stack'
|
||||
|
||||
@classmethod
|
||||
def resolve(cls, template: str) -> PluginScaffoldOptions:
|
||||
"""
|
||||
解析插件模板预设。
|
||||
|
||||
:param template: 插件模板名称
|
||||
:return: 插件模板生成选项
|
||||
"""
|
||||
normalized_template = (template or cls.DEFAULT_TEMPLATE).strip() or cls.DEFAULT_TEMPLATE
|
||||
if normalized_template == 'minimal':
|
||||
return PluginScaffoldOptions(
|
||||
backend=True,
|
||||
frontend=False,
|
||||
migration=False,
|
||||
seed=False,
|
||||
job=False,
|
||||
config=False,
|
||||
test=True,
|
||||
)
|
||||
if normalized_template == 'backend-only':
|
||||
return PluginScaffoldOptions(backend=True, frontend=False)
|
||||
if normalized_template == 'full-stack':
|
||||
return PluginScaffoldOptions()
|
||||
if normalized_template == 'scheduled-job':
|
||||
return PluginScaffoldOptions(
|
||||
backend=True,
|
||||
frontend=False,
|
||||
migration=False,
|
||||
seed=False,
|
||||
job=True,
|
||||
config=False,
|
||||
test=True,
|
||||
)
|
||||
if normalized_template == 'crud-page':
|
||||
return PluginScaffoldOptions(
|
||||
backend=True,
|
||||
frontend=True,
|
||||
migration=True,
|
||||
seed=True,
|
||||
job=False,
|
||||
config=True,
|
||||
test=True,
|
||||
crud=True,
|
||||
)
|
||||
supported_templates = ', '.join(sorted(cls.SUPPORTED_TEMPLATES))
|
||||
raise ValueError(f'插件模板只支持:{supported_templates}')
|
||||
@@ -0,0 +1,146 @@
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cli.exit_codes import RUNTIME_ERROR
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginScaffoldPlanPayload:
|
||||
"""
|
||||
插件模板写入计划负载。
|
||||
"""
|
||||
|
||||
template: str
|
||||
backend: bool
|
||||
frontend: bool
|
||||
migration: bool
|
||||
seed: bool
|
||||
job: bool
|
||||
config: bool
|
||||
crud: bool
|
||||
test: bool
|
||||
backend_test: bool
|
||||
frontend_test: bool
|
||||
frontend_version: str | None
|
||||
target_dirs: list[str]
|
||||
files: list[tuple[Path, str]]
|
||||
conflicts: list[str]
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
"""
|
||||
序列化为既有 CLI payload 契约。
|
||||
|
||||
:return: 插件模板写入计划负载
|
||||
"""
|
||||
return {
|
||||
'backend': self.backend,
|
||||
'frontend': self.frontend,
|
||||
'template': self.template,
|
||||
'migration': self.migration,
|
||||
'seed': self.seed,
|
||||
'job': self.job,
|
||||
'config': self.config,
|
||||
'crud': self.crud,
|
||||
'test': self.test,
|
||||
'backendTest': self.backend_test,
|
||||
'frontendTest': self.frontend_test,
|
||||
'frontendVersion': self.frontend_version,
|
||||
'targetDirs': self.target_dirs,
|
||||
'files': [{'path': str(path), 'content': content} for path, content in self.files],
|
||||
'conflicts': self.conflicts,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginScaffoldSuccessPayload:
|
||||
"""
|
||||
插件模板创建成功负载。
|
||||
"""
|
||||
|
||||
plugin_id: str
|
||||
scaffold_plan: dict[str, Any]
|
||||
dry_run: bool
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
"""
|
||||
序列化为既有 CLI payload 契约。
|
||||
|
||||
:return: 插件模板创建成功负载
|
||||
"""
|
||||
return {
|
||||
'ok': True,
|
||||
'message': '插件模板预演完成' if self.dry_run else '插件模板创建成功',
|
||||
'pluginId': self.plugin_id,
|
||||
'dryRun': self.dry_run,
|
||||
**self.scaffold_plan,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginScaffoldConflictPayload:
|
||||
"""
|
||||
插件模板目录冲突负载。
|
||||
"""
|
||||
|
||||
plugin_id: str
|
||||
scaffold_plan: dict[str, Any]
|
||||
dry_run: bool
|
||||
failure_code: int = RUNTIME_ERROR
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
"""
|
||||
序列化为既有 CLI payload 契约。
|
||||
|
||||
:return: 插件模板目录冲突负载
|
||||
"""
|
||||
return {
|
||||
'ok': False,
|
||||
'message': '插件目录已存在,拒绝覆盖',
|
||||
'pluginId': self.plugin_id,
|
||||
'dryRun': self.dry_run,
|
||||
**self.scaffold_plan,
|
||||
'exit_code': self.failure_code,
|
||||
}
|
||||
|
||||
|
||||
class PluginScaffoldPayloadBuilder:
|
||||
"""
|
||||
插件模板创建响应负载构建器。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_conflict_payload(
|
||||
plugin_id: str,
|
||||
scaffold_plan: dict[str, Any],
|
||||
*,
|
||||
dry_run: bool,
|
||||
failure_code: int = RUNTIME_ERROR,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
构建插件模板目录冲突负载。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:param scaffold_plan: 插件模板写入计划
|
||||
:param dry_run: 是否预演
|
||||
:param failure_code: 失败退出码
|
||||
:return: 插件模板目录冲突负载
|
||||
"""
|
||||
return PluginScaffoldConflictPayload(
|
||||
plugin_id,
|
||||
scaffold_plan,
|
||||
dry_run=dry_run,
|
||||
failure_code=failure_code,
|
||||
).to_payload()
|
||||
|
||||
@staticmethod
|
||||
def build_success_payload(plugin_id: str, scaffold_plan: dict[str, Any], *, dry_run: bool) -> dict[str, Any]:
|
||||
"""
|
||||
构建插件模板创建成功负载。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:param scaffold_plan: 插件模板写入计划
|
||||
:param dry_run: 是否预演
|
||||
:return: 插件模板创建成功负载
|
||||
"""
|
||||
return PluginScaffoldSuccessPayload(plugin_id, scaffold_plan, dry_run=dry_run).to_payload()
|
||||
@@ -0,0 +1,503 @@
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cli.exit_codes import RUNTIME_ERROR, SUCCESS
|
||||
|
||||
from .gateway import PluginRuntimeGateway
|
||||
from .scaffold import PluginScaffoldBuilder
|
||||
from .support import (
|
||||
PLUGIN_DEPENDENCY_ALLOWLIST_EXAMPLE_YAML,
|
||||
CliPluginRuntimeExceptionPayload,
|
||||
PluginDependencyAllowlistExamplePayloadBuilder,
|
||||
PluginDependencyLockfileTemplateBuilder,
|
||||
PluginDependencyLockPayloadBuilder,
|
||||
PluginTestPayloadBuilder,
|
||||
PluginTestPlanBuilder,
|
||||
)
|
||||
|
||||
PYTEST_COMMAND_TIMEOUT_SECONDS = 120
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CliPluginRuntimeDependencies:
|
||||
"""
|
||||
CLI 插件运行时依赖容器。
|
||||
"""
|
||||
|
||||
runtime_environment: object | None = None
|
||||
dependency_checker: object | None = None
|
||||
management_gateway: object | None = None
|
||||
model_gateway: object | None = None
|
||||
command_gateway: object | None = None
|
||||
lifecycle_lock: object | None = None
|
||||
|
||||
|
||||
class CliPluginRuntimeService:
|
||||
"""
|
||||
插件 CLI 运行时服务。
|
||||
|
||||
该服务负责为 CLI 组装核心插件运行时,并只承载 CLI 专属开发命令能力,
|
||||
例如插件测试执行、插件模板创建和本地辅助文件生成。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
success_code: int = SUCCESS,
|
||||
runtime_error_code: int = RUNTIME_ERROR,
|
||||
runtime_environment: object | None = None,
|
||||
dependency_checker: object | None = None,
|
||||
management_gateway: object | None = None,
|
||||
model_gateway: object | None = None,
|
||||
command_gateway: object | None = None,
|
||||
lifecycle_lock: object | None = None,
|
||||
plugin_gateway: PluginRuntimeGateway | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化插件 CLI 运行时服务。
|
||||
|
||||
:param success_code: CLI 成功退出码
|
||||
:param runtime_error_code: CLI 运行失败退出码
|
||||
:param runtime_environment: 插件运行时环境服务
|
||||
:param dependency_checker: 插件依赖检查器
|
||||
:param management_gateway: 插件管理运行时适配器
|
||||
:param model_gateway: 插件管理模型工厂网关
|
||||
:param command_gateway: 插件命令执行网关
|
||||
:param lifecycle_lock: 插件生命周期操作锁
|
||||
:param plugin_gateway: 插件 CLI 运行时网关
|
||||
:return: None
|
||||
"""
|
||||
self.success_code = success_code
|
||||
self.runtime_error_code = runtime_error_code
|
||||
self.plugin_gateway = plugin_gateway or PluginRuntimeGateway()
|
||||
self.dependencies = CliPluginRuntimeDependencies(
|
||||
runtime_environment=runtime_environment,
|
||||
dependency_checker=dependency_checker,
|
||||
management_gateway=management_gateway,
|
||||
model_gateway=model_gateway,
|
||||
command_gateway=command_gateway,
|
||||
lifecycle_lock=lifecycle_lock,
|
||||
)
|
||||
self._core_runtime: Any | None = None
|
||||
|
||||
@property
|
||||
def core_runtime(self) -> Any:
|
||||
"""
|
||||
延迟获取插件核心运行时服务。
|
||||
|
||||
:return: 插件核心运行时服务
|
||||
"""
|
||||
if self._core_runtime is None:
|
||||
runtime_service_class = self.plugin_gateway.get_core_runtime_service_class()
|
||||
management_gateway = self._resolve_management_gateway()
|
||||
gateway_overrides_class = self.plugin_gateway.get_core_runtime_gateway_overrides_class()
|
||||
self._core_runtime = runtime_service_class(
|
||||
runtime_environment=self._resolve_runtime_environment(),
|
||||
dependency_checker=self.dependencies.dependency_checker,
|
||||
gateways=self._build_gateway_overrides(gateway_overrides_class, management_gateway),
|
||||
model_gateway=self._resolve_model_gateway(),
|
||||
command_gateway=self._resolve_command_gateway(),
|
||||
lifecycle_lock=self._resolve_lifecycle_lock(),
|
||||
)
|
||||
return self._core_runtime
|
||||
|
||||
def _resolve_runtime_environment(self) -> object:
|
||||
"""
|
||||
解析插件核心运行时环境服务。
|
||||
|
||||
:return: 插件核心运行时环境服务
|
||||
"""
|
||||
if self.dependencies.runtime_environment is not None:
|
||||
return self.dependencies.runtime_environment
|
||||
runtime_environment = self.plugin_gateway.get_core_runtime_environment()
|
||||
self.dependencies = replace(self.dependencies, runtime_environment=runtime_environment)
|
||||
return runtime_environment
|
||||
|
||||
def _load_management_gateway(self) -> object:
|
||||
"""
|
||||
解析插件管理运行时适配器。
|
||||
|
||||
:return: 插件管理运行时适配器
|
||||
"""
|
||||
management_gateway = self.plugin_gateway.get_management_runtime_gateway()
|
||||
self.dependencies = replace(
|
||||
self.dependencies,
|
||||
management_gateway=self.dependencies.management_gateway or management_gateway,
|
||||
model_gateway=self.dependencies.model_gateway or management_gateway,
|
||||
command_gateway=self.dependencies.command_gateway or management_gateway,
|
||||
)
|
||||
return management_gateway
|
||||
|
||||
def _resolve_management_gateway(self) -> object:
|
||||
"""
|
||||
解析插件管理运行时适配器。
|
||||
|
||||
:return: 插件管理运行时适配器
|
||||
"""
|
||||
if self.dependencies.management_gateway is None:
|
||||
self._load_management_gateway()
|
||||
return self.dependencies.management_gateway
|
||||
|
||||
@staticmethod
|
||||
def _build_gateway_overrides(gateway_overrides_class: object, management_gateway: object) -> object:
|
||||
"""
|
||||
构建插件核心运行时窄端口覆盖项。
|
||||
|
||||
:param gateway_overrides_class: 插件核心运行时窄端口覆盖项类
|
||||
:param management_gateway: 插件管理运行时适配器
|
||||
:return: 插件核心运行时窄端口覆盖项
|
||||
"""
|
||||
return gateway_overrides_class(
|
||||
config_gateway=management_gateway,
|
||||
audit_gateway=management_gateway,
|
||||
state_query_gateway=management_gateway,
|
||||
migration_history_gateway=management_gateway,
|
||||
purge_plan_gateway=management_gateway,
|
||||
lifecycle_state_gateway=management_gateway,
|
||||
lifecycle_uow_gateway=management_gateway,
|
||||
migration_execution_gateway=management_gateway,
|
||||
)
|
||||
|
||||
def _resolve_model_gateway(self) -> object:
|
||||
"""
|
||||
解析插件核心运行时模型工厂网关。
|
||||
|
||||
:return: 插件核心运行时模型工厂网关
|
||||
"""
|
||||
if self.dependencies.model_gateway is None:
|
||||
self._resolve_management_gateway()
|
||||
return self.dependencies.model_gateway
|
||||
|
||||
def _resolve_command_gateway(self) -> object:
|
||||
"""
|
||||
解析插件核心运行时命令执行网关。
|
||||
|
||||
:return: 插件核心运行时命令执行网关
|
||||
"""
|
||||
if self.dependencies.command_gateway is None:
|
||||
self._resolve_management_gateway()
|
||||
return self.dependencies.command_gateway
|
||||
|
||||
def _resolve_lifecycle_lock(self) -> object:
|
||||
"""
|
||||
解析插件核心生命周期操作锁。
|
||||
|
||||
:return: 插件核心生命周期操作锁
|
||||
"""
|
||||
if self.dependencies.lifecycle_lock is not None:
|
||||
return self.dependencies.lifecycle_lock
|
||||
lifecycle_lock = self.plugin_gateway.get_core_lifecycle_lock()
|
||||
self.dependencies = replace(self.dependencies, lifecycle_lock=lifecycle_lock)
|
||||
return lifecycle_lock
|
||||
|
||||
def _build_exception_payload(self, message: str, exc: Exception) -> dict[str, object]:
|
||||
"""
|
||||
构建 CLI 插件运行时异常负载。
|
||||
|
||||
:param message: 异常场景提示
|
||||
:param exc: 异常对象
|
||||
:return: 异常负载
|
||||
"""
|
||||
return CliPluginRuntimeExceptionPayload(
|
||||
self.plugin_gateway.build_exception_payload(message, exc),
|
||||
failure_code=self.runtime_error_code,
|
||||
).to_payload()
|
||||
|
||||
def lock_plugin_dependencies(
|
||||
self,
|
||||
plugin_id: str,
|
||||
*,
|
||||
output_path: str = '',
|
||||
offline_dir: str = '',
|
||||
dry_run: bool = False,
|
||||
overwrite: bool = False,
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
生成插件依赖锁文件模板。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:param output_path: 输出锁文件路径
|
||||
:param offline_dir: 离线制品根目录
|
||||
:param dry_run: 是否仅预演
|
||||
:param overwrite: 是否覆盖已有文件
|
||||
:return: 插件依赖锁文件模板负载
|
||||
"""
|
||||
try:
|
||||
runtime_environment = self._resolve_runtime_environment()
|
||||
discovered_plugin = self._find_discovered_plugin(plugin_id)
|
||||
if discovered_plugin is None:
|
||||
return PluginDependencyLockPayloadBuilder.build_not_found_payload(plugin_id)
|
||||
|
||||
backend_root = Path(runtime_environment.get_backend_dir())
|
||||
resolved_output_path = self._resolve_lockfile_output_path(
|
||||
backend_root,
|
||||
discovered_plugin.backend_path,
|
||||
output_path,
|
||||
)
|
||||
lockfile_template = PluginDependencyLockfileTemplateBuilder.build(
|
||||
discovered_plugin.manifest,
|
||||
offline_dir=offline_dir or None,
|
||||
)
|
||||
if resolved_output_path.exists() and not overwrite and not dry_run:
|
||||
return PluginDependencyLockPayloadBuilder.build_exists_payload(plugin_id, resolved_output_path)
|
||||
|
||||
written = False
|
||||
overwritten = resolved_output_path.exists()
|
||||
if not dry_run:
|
||||
resolved_output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
resolved_output_path.write_text(lockfile_template.to_yaml(), encoding='utf-8')
|
||||
written = True
|
||||
|
||||
return PluginDependencyLockPayloadBuilder.build_success_payload(
|
||||
plugin_id,
|
||||
lockfile_template,
|
||||
resolved_output_path,
|
||||
dry_run=dry_run,
|
||||
written=written,
|
||||
overwritten=overwritten and written,
|
||||
)
|
||||
except Exception as exc:
|
||||
return self._build_exception_payload('生成插件依赖锁文件模板失败', exc)
|
||||
|
||||
def _find_discovered_plugin(self, plugin_id: str) -> Any | None:
|
||||
"""
|
||||
根据插件ID发现本地插件。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:return: 已发现插件
|
||||
"""
|
||||
from plugins.core.discovery.scanner import PluginScanner # noqa: PLC0415
|
||||
|
||||
runtime_environment = self._resolve_runtime_environment()
|
||||
return next(
|
||||
(
|
||||
discovered_plugin
|
||||
for discovered_plugin in PluginScanner(runtime_environment.get_backend_plugins_dir()).discover()
|
||||
if discovered_plugin.manifest.id == plugin_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_lockfile_output_path(backend_root: Path, plugin_path: Path, output_path: str) -> Path:
|
||||
"""
|
||||
解析锁文件输出路径。
|
||||
|
||||
:param backend_root: 后端项目根目录
|
||||
:param plugin_path: 插件目录
|
||||
:param output_path: 用户指定输出路径
|
||||
:return: 输出路径
|
||||
"""
|
||||
if not output_path:
|
||||
return plugin_path / 'plugin.lock.yaml'
|
||||
return CliPluginRuntimeService._resolve_backend_output_path(backend_root, output_path)
|
||||
|
||||
def generate_plugin_dependency_allowlist_example(
|
||||
self,
|
||||
*,
|
||||
output_path: str = '',
|
||||
dry_run: bool = False,
|
||||
overwrite: bool = False,
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
生成插件依赖允许列表示例。
|
||||
|
||||
:param output_path: 输出允许列表路径
|
||||
:param dry_run: 是否仅预演
|
||||
:param overwrite: 是否覆盖已有文件
|
||||
:return: 允许列表示例负载
|
||||
"""
|
||||
try:
|
||||
runtime_environment = self._resolve_runtime_environment()
|
||||
backend_root = Path(runtime_environment.get_backend_dir())
|
||||
resolved_output_path = self._resolve_allowlist_example_output_path(backend_root, output_path)
|
||||
if resolved_output_path.exists() and not overwrite and not dry_run:
|
||||
return PluginDependencyAllowlistExamplePayloadBuilder.build_exists_payload(resolved_output_path)
|
||||
|
||||
written = False
|
||||
overwritten = resolved_output_path.exists()
|
||||
if not dry_run:
|
||||
resolved_output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
resolved_output_path.write_text(PLUGIN_DEPENDENCY_ALLOWLIST_EXAMPLE_YAML, encoding='utf-8')
|
||||
written = True
|
||||
|
||||
return PluginDependencyAllowlistExamplePayloadBuilder.build_success_payload(
|
||||
resolved_output_path,
|
||||
allowlist_text=PLUGIN_DEPENDENCY_ALLOWLIST_EXAMPLE_YAML,
|
||||
dry_run=dry_run,
|
||||
written=written,
|
||||
overwritten=overwritten and written,
|
||||
)
|
||||
except Exception as exc:
|
||||
return self._build_exception_payload('生成插件依赖允许列表示例失败', exc)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_allowlist_example_output_path(backend_root: Path, output_path: str) -> Path:
|
||||
"""
|
||||
解析允许列表示例输出路径。
|
||||
|
||||
:param backend_root: 后端项目根目录
|
||||
:param output_path: 用户指定输出路径
|
||||
:return: 输出路径
|
||||
"""
|
||||
if not output_path:
|
||||
return backend_root / 'config' / 'plugin_dependency_allowlist.yaml'
|
||||
return CliPluginRuntimeService._resolve_backend_output_path(backend_root, output_path)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_backend_output_path(backend_root: Path, output_path: str) -> Path:
|
||||
"""
|
||||
解析 CLI 输出路径,并限制在后端项目目录内。
|
||||
|
||||
:param backend_root: 后端项目根目录
|
||||
:param output_path: 用户指定输出路径
|
||||
:return: 规范化后的输出路径
|
||||
"""
|
||||
raw_output_path = Path(output_path)
|
||||
resolved_backend_root = backend_root.resolve(strict=False)
|
||||
resolved_output_path = (
|
||||
raw_output_path if raw_output_path.is_absolute() else resolved_backend_root / raw_output_path
|
||||
).resolve(strict=False)
|
||||
try:
|
||||
resolved_output_path.relative_to(resolved_backend_root)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f'输出路径必须位于后端项目目录内:{output_path}') from exc
|
||||
return resolved_output_path
|
||||
|
||||
def test_plugin(
|
||||
self,
|
||||
plugin_id: str,
|
||||
*,
|
||||
keyword: str = '',
|
||||
maxfail: int = 0,
|
||||
quiet: bool = False,
|
||||
frontend_build: bool = False,
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
执行插件测试样例。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:param keyword: pytest `-k` 过滤表达式
|
||||
:param maxfail: 最大失败数,0 表示不限制
|
||||
:param quiet: 是否启用简洁输出
|
||||
:param frontend_build: 是否执行前端构建验收
|
||||
:return: 插件测试执行结果负载
|
||||
"""
|
||||
try:
|
||||
runtime_environment = self._resolve_runtime_environment()
|
||||
command_gateway = self._resolve_command_gateway()
|
||||
backend_root = Path(runtime_environment.get_backend_dir())
|
||||
frontend_root = Path(runtime_environment.get_frontend_dir())
|
||||
test_plan_builder = PluginTestPlanBuilder(
|
||||
backend_root=backend_root,
|
||||
frontend_root=frontend_root,
|
||||
python_executable=runtime_environment.get_python_executable(),
|
||||
timeout=PYTEST_COMMAND_TIMEOUT_SECONDS,
|
||||
)
|
||||
targets = test_plan_builder.build(
|
||||
plugin_id,
|
||||
keyword=keyword,
|
||||
maxfail=maxfail,
|
||||
quiet=quiet,
|
||||
frontend_build=frontend_build,
|
||||
)
|
||||
if not targets:
|
||||
return PluginTestPayloadBuilder.with_exit_code(
|
||||
PluginTestPayloadBuilder.build_missing_payload(
|
||||
plugin_id,
|
||||
test_plan_builder.expected_paths(plugin_id),
|
||||
),
|
||||
success_code=self.success_code,
|
||||
failure_code=self.runtime_error_code,
|
||||
)
|
||||
|
||||
results = []
|
||||
for target in targets:
|
||||
completed = command_gateway.run_command(
|
||||
target.command,
|
||||
str(target.workdir),
|
||||
timeout=target.timeout,
|
||||
)
|
||||
results.append(PluginTestPayloadBuilder.build_result_item(target, completed))
|
||||
|
||||
return PluginTestPayloadBuilder.with_exit_code(
|
||||
PluginTestPayloadBuilder.build_execution_payload(
|
||||
plugin_id,
|
||||
keyword=keyword,
|
||||
maxfail=maxfail,
|
||||
quiet=quiet,
|
||||
frontend_build=frontend_build,
|
||||
results=results,
|
||||
),
|
||||
success_code=self.success_code,
|
||||
failure_code=self.runtime_error_code,
|
||||
)
|
||||
except Exception as exc:
|
||||
return self._build_exception_payload('插件测试执行失败', exc)
|
||||
|
||||
def create_plugin( # noqa: PLR0913
|
||||
self,
|
||||
plugin_id: str,
|
||||
*,
|
||||
template: str = 'full-stack',
|
||||
backend: bool = True,
|
||||
frontend: bool = True,
|
||||
migration: bool = True,
|
||||
seed: bool = True,
|
||||
job: bool = True,
|
||||
config: bool = True,
|
||||
test: bool = True,
|
||||
frontend_version: str = 'auto',
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
创建插件开发模板。
|
||||
|
||||
:param plugin_id: 插件ID
|
||||
:param template: 插件模板名称
|
||||
:param backend: 是否创建后端插件模板
|
||||
:param frontend: 是否创建前端插件模板
|
||||
:param migration: 是否创建 migration 示例
|
||||
:param seed: 是否创建 seed 示例
|
||||
:param job: 是否创建定时任务示例
|
||||
:param config: 是否创建配置项示例
|
||||
:param test: 是否创建测试样例
|
||||
:param frontend_version: 前端 Vue 版本,支持 auto、vue2、vue3
|
||||
:param dry_run: 是否仅预演
|
||||
:return: 插件创建结果负载
|
||||
"""
|
||||
try:
|
||||
runtime_environment = self._resolve_runtime_environment()
|
||||
scaffold = PluginScaffoldBuilder(
|
||||
Path(runtime_environment.get_backend_dir()),
|
||||
frontend_root=Path(runtime_environment.get_frontend_dir()),
|
||||
)
|
||||
scaffold_plan = scaffold.build_plan(
|
||||
plugin_id,
|
||||
template=template,
|
||||
backend=backend,
|
||||
frontend=frontend,
|
||||
migration=migration,
|
||||
seed=seed,
|
||||
job=job,
|
||||
config=config,
|
||||
test=test,
|
||||
frontend_version=frontend_version,
|
||||
)
|
||||
if scaffold_plan['conflicts']:
|
||||
return PluginScaffoldBuilder.build_conflict_payload(
|
||||
plugin_id,
|
||||
scaffold_plan,
|
||||
dry_run=dry_run,
|
||||
failure_code=self.runtime_error_code,
|
||||
)
|
||||
if not dry_run:
|
||||
scaffold.apply_plan(scaffold_plan)
|
||||
|
||||
return PluginScaffoldBuilder.build_success_payload(plugin_id, scaffold_plan, dry_run=dry_run)
|
||||
except Exception as exc:
|
||||
return self._build_exception_payload('创建插件模板失败', exc)
|
||||
|
||||
|
||||
PLUGIN_RUNTIME = CliPluginRuntimeService()
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user