177 Commits
Author SHA1 Message Date
2910410219andClaude Code 3f9b5e59a8 新增:首页工作流执行必选计费方式(重跑回显上次选择);修复:退出登录整页刷新解决重新登录后动态菜单404
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 15:09:24 +08:00
2910410219andClaude Code fbe2bcfee6 新增:模型计价编辑器适配新计费结构(mediaPrices媒体价格模板、thinking/outputAudio/outputResolution中文展示与下拉)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 15:09:15 +08:00
2910410219andClaude Code bdbf989b5e 新增:价格管理(计价配置管理)页面与 API
- 左侧计价对象枚举列表(工作流 / 模型 / 业务模块,模型支持类型筛选与配置状态圆点)
- 右侧按对象类型切换工作流 / 模型 / 业务编辑器,费率规则编辑与后端配置回显
- 新增 src/api/trade/pricing 接口(subjects / config/get / config/save)
- rules 兼容对象与历史字符串两种形态;配置 id 用字符串承载避免雪花 ID 精度丢失

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 11:47:37 +08:00
2910410219andClaude c2244f50b1 新增:用户管理增加充值功能与钱包流水查询
- 操作列新增"充值""流水信息"按钮,权限与修改一致(isOperation 控制)
- 新增充值弹窗 rechargeUser.vue:展示账户名称(只读),金额单位元(校验大于0、最多两位小数),提交 userId/userName/amount/orderNo/description
- 新增流水弹窗 walletLog.vue:展示充值与扣费明细(income/expense),金额元,后端分页(pageNum/pageSize)
- 新增 recharge、getWalletLogs 接口,路径 shop-user-trade/account/controller/*
- other.scss 补充 op-btn-recharge 固定绿色样式(含 dark 主题)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 15:28:03 +08:00
2910410219 43d4392873 Merge branch 'feature/home-workflow' of http://116.204.74.41:3000/red-future/admin-ui into feature/home-workflow 2026-08-27 11:55:00 +08:00
2910410219andClaude 0ec961e210 修复:贴片模板统一走CDN参考(默认模板与下载、移除本地模板文件)、首页新建会话首次执行后贴片模板回显空白
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 11:54:50 +08:00
admin 3b0369850f admin-ui ng转发域名增加 2026-08-27 09:23:22 +08:00
admin 42b6e75b77 Merge remote-tracking branch 'origin/feature/home-workflow' into feature/home-workflow 2026-08-25 17:07:27 +08:00
admin d62f79a407 admin-ui ng转发域名增加 2026-08-25 17:07:12 +08:00
2910410219 ef4976ee03 Merge branch 'feature/home-workflow' of http://116.204.74.41:3000/red-future/admin-ui into feature/home-workflow 2026-08-24 18:25:46 +08:00
2910410219andClaude cf6a17387d 修复:子例程引入便签名称兼容模板字段、模板创建成功后刷新首页占位工作流列表
- 工作流管理引入子例程:存名/提示补 flowTemplateName,管理员引入模板时便签不再显示数字 id
- 首页模板补全创建成功:同步刷新占位页工作流列表(MainContent 暴露 loadPlaceWorkflows),点叉取消后可见新建工作流

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 18:25:32 +08:00
2910410219andClaude dd4e719f55 工作流功能完善:子例程弹窗角色分支与模板名称修复、模板子工作流递归补全、首页工作流名称省略+tooltip
- 引入子例程弹窗:按 isAdmin 分支读取 listFlowTemplateRes/listFlowUserRes,名称兼容 flowTemplateName
- 模板补全弹窗:递归展开子工作流模型节点,一次 PUT 更新整棵树(updateWorkflowFromTemplate)
- 首页工作流胶囊/占位卡片:名称单行省略 + el-tooltip 悬停显示全名

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 18:09:34 +08:00
admin 95dc89e097 admin-ui ng转发域名增加 2026-08-24 13:22:22 +08:00
2910410219andClaude dbd41cc045 首页工作流产出:resultFileUrl 逗号拼接多 URL 统一拆分(兼容字符串/数组入参),修复回显与实时产出渲染异常
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 09:26:45 +08:00
2910410219andClaude d48875bd7a 首页工作空间下载:对象名传参适配后端相对路径、下载感知(toast+按钮级)、5分钟超时
- downloadAsset 重写:content 相对路径直接作下载参数,兼容历史完整 URL 去域名;
  blob 校验拒绝 JSON 错误体,避免生成坏文件;文件名带扩展名
- 下载感知:统一加下载中轻量 toast(旋转图标、不锁页面),工作空间按钮级
  「下载中…」禁用;成功/失败替换为结果提示
- 新增 downloadWorkspaceFile(workflow 域独立 API):下载超时放宽到 5 分钟,
  不动 creation 共享 downloadToFile

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 20:09:20 +08:00
2910410219andClaude 490cf4bb93 首页「更多」入口改为常驻:点击跳转工作流管理页,不再依赖工作流数量超出 9 个
- InputBar 快捷胶囊区、MainContent 占位卡片区:去掉「超过 9 个才显示」条件,「更多」常驻在列表末尾
- 清理 MainContent 不再使用的 hasMorePlaceWorkflows 状态

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 19:28:01 +08:00
2910410219andClaude b140bff5f1 首页工作流与历史会话:修复执行后表单清空/默认展开,工作流最多展示9个+更多入口,历史会话分页加载更多
- 修复:执行时把用户填的表单值写回消息对象,会话认领重建后能恢复,不再清空;执行完成/历史回显卡片默认收起,可手动展开改参
- 功能:首页工作流最多展示 9 个(用户优先、模板补足),超出显示「更多」入口跳转工作流管理页
- 功能:历史会话分页「加载更多」,逐页追加去重
- 重构:工作流执行信号统一走提交(移除 retry/re-edit/cancel),失败卡片重跑/重编辑并入提交路径
- 其他:删除消息时终止进行中的轮;Markdown 渲染缓存设上限

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 19:17:12 +08:00
2910410219andClaude 0232dc6e14 删除首页孤儿组件(CommandList/DiaryList/FileList/ReplyList)
早期首页版本遗留,全项目无任何引用(import/动态导入/barrel 导出均无命中),
已核验不影响编译与功能。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 18:33:37 +08:00
2910410219andClaude c51d0dc2ca 首页:删除气泡重新生成功能
- 移除失败气泡与 AI/用户消息操作栏的重新生成入口
- 清理 retry/regenerate 事件链、handleRetry 及 retryQuestion 字段
- 保留失败/空回复提示文本、复制与删除操作

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 18:24:03 +08:00
2910410219andClaude 918ae96f71 首页工作流:统一执行开关至发送按钮,最后一张已执行卡片参数可编辑
- 工作流表单卡片移除执行/重跑按钮,执行统一由 InputBar 发送按钮触发
- 会话执行过工作流即为工作流对话,下方输入框永久禁止文本输入
- 执行中发送按钮变停止,复用 teardownRound 按 runTypeMap 区分协议
- 最后一张已执行(done/failed)卡片参数保持可编辑,改参后经发送按钮重新执行(含必填校验)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 18:13:47 +08:00
2910410219andClaude cb6792cb29 首页工作流执行:表单默认收起可展开、节点执行过程可视化(仿豆包)、失败支持重新执行/重新编辑、错误信息去重
- 提交后表单卡片默认收起(仅显示工作流标题),头部加明确展开/收起按钮
- node_start/node_complete 事件驱动节点步骤流,执行过程区仿豆包竖向展示
- 失败卡片拆分「重新执行」(复用已填值直接重跑)与「重新编辑」两按钮
- 失败消息只显示「 执行失败」,错误详情由卡片 footer 展示,避免双处重复

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 17:07:40 +08:00
2910410219andClaude a95fe2d73f nginx 反向代理透传 WebSocket 升级头,修复首页会话长连接握手失败
客户端 wsExecute 握手缺少 'upgrade' token in 'Connection' header,在 @proxy 增加 Upgrade/Connection 头透传。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 14:58:02 +08:00
2910410219andClaude 73036038fd 首页工作流卡片名称完整展示(换行),不再省略号截断
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 14:57:54 +08:00
2910410219andClaude e596c9b5ae 模型管理v2:系统模型填 API Key 转用户模型时透传 priceConfig 等4个业务字段
后端在返回的模型信息中携带 requestBusinessFieldMapping/responseBusinessFieldMapping/minDuration/priceConfig,
首页两处提交链路(SessionModelSetter、TemplateCompleteDialog)此前遗漏未传,现从模型对象原样透传。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 14:57:44 +08:00
2910410219andClaude c441ccec94 修复首页会话切换时正在执行的工作流被历史详情覆盖的问题
切回正在执行的会话时保留内存中的实时消息流(running 表单卡片 + 实时节点进度),
不再用 session/get 历史详情重建覆盖;普通对话生成中同样受益

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-21 10:25:49 +08:00
2910410219andClaude c432eb431a 模型配置响应主体映射保存时直接填充字段中文 label
responseBodyMapping 提交时 value 由空串占位改为对应响应映射字段的中文
label(无 label 时兜底为路径本身),使后端存储即携带展示名,无需前端
workflow 侧再 enrich 补充。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 18:20:08 +08:00
2910410219andClaude 4d74dd2d63 修复首页对话回显中贴片模板未恢复的问题
贴片模板提交时写入 patchLayout 节点的内部 templates 字段并删除顶层,
回显恢复逻辑仅从顶层读取导致模板丢失;现优先从 nodeInputParams 中
patchLayout 节点的 templates 恢复,顶层旧位置作为兼容兜底。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 17:54:33 +08:00
2910410219andClaude daf9f5147b 模型管理v2:schema 保存时统一将标量字段 value 置空,数据值不在模板中预设
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 17:31:38 +08:00
2910410219andClaude de2e1d8902 模型管理v2:异步映射配置分区排版优化,请求体映射与响应主体映射合并一行
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 16:51:21 +08:00
2910410219andClaude 28cc58bca0 模型管理v2:映射配置新增错误消息映射字段,重排映射配置分区为四列均分
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 16:48:16 +08:00
2910410219andClaude 9f94f4a9ab 模型管理v2:异步映射配置新增请求方法下拉与请求体映射,回调文案改异步
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 16:42:12 +08:00
2910410219andClaude 03697b9747 首页删除以真实接口为准:会话/工作空间删除成功才移除并刷新,删除接口改 DELETE
- deleteSessionV2 请求方式 post → delete(/ai-agent/session/delete)
- 工作空间删除:缺少删除信息提示不可删;接口失败保留数据;成功才移除本地并刷新工作空间树
- 会话记录删除:已认领后端号的会话接口失败保留数据且不打断连接;成功才 teardown/关闭并刷新列表;
  未认领的本地虚拟会话直接本地删除

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 16:28:25 +08:00
2910410219andClaude ee5120c6ed 首页执行工作流:贴片模板写入开启贴片布局的节点内部(与 modelConfig 平级)
- templates 不再放 flowContent 最外层,改为写入 patchLayout===true 的节点内部(数组),后端执行从节点读取
- 删除 detail.flowContent 可能遗留的顶层 templates,统一走节点

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 14:48:28 +08:00
2910410219andClaude ba5ff9b87d 工作流贴片布局节点输出可被下级引用(field=templates)
- getNodeOutputFields 重构为累积输出,开启贴片布局的节点额外提供「贴片布局」输出(field=templates、label=贴片布局),与模型返回参数等并列供下级引用
- upstreamNodes 的 hasOutput 放行开启贴片布局的节点(不依赖具体 nodeCode),保证可被前驱引用

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 14:48:20 +08:00
2910410219andClaude e2a5480bf9 工作流节点库 patchLayout 驱动贴片布局开关显示(对齐内容创作)
- NodeLibraryItem 增加 patchLayout 字段(节点库返回,仅 model 节点 true)
- nodeConfigMap 聚合透传节点库 patchLayout,开关显示条件由 modelType 范围改为 nodeConfig.patchLayout
- 移除不再使用的 isVideoModelSelected 判断

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 14:01:38 +08:00
2910410219andClaude abc5b47f72 首页工作空间适配新结构并支持加载失败重试、切换独立删除接口
- 工作空间结果树适配 createDate→flows→items 两级结构(flows 下文件平铺,兼容旧平铺结构)
- 删除接口切换:工作空间树删除改走 session 域独立接口 resultDelete(deleteWorkspaceResult),不依赖内容创作域
- 会话记录/工作空间列表加载失败:区分失败态与空态,提供重试按钮;工作空间懒加载失败后切回 tab 自动重试

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 14:01:30 +08:00
2910410219andClaude 63ac39cd4a 模型参数约束透传:collectExposedFields 从 constraint 归一化 fieldConstraint 供首页表单校验
模板字段约束实际挂在 constraint 键(min/max/uploadRules 等),原读取 fieldConstraint 键导致模型勾选字段的约束丢失。新增 toFieldConstraint 归一化(min→minValue、max→maxValue、uploadRules/accept→fileTypes/maxFileSize/maxFileCount),并将格式分隔符统一为英文逗号,修复 wan 模型音频上传被误拦。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 11:46:50 +08:00
2910410219andClaude 20bab0997e 多会话并发执行:每会话独立连接与 handler,切换/新建会话不再取消执行中工作流
重构首页 WebSocket 连接模型:wsConnMap/roundHandlers/runTypeMap 按会话独立管理;执行中保留连接、空闲切换释放;消息按 sid 定向写入;修复后台会话完成时误清当前会话输入状态(resetAll 仅限活跃会话)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 11:46:40 +08:00
2910410219andClaude 3cddff6df2 工作流绘制链路加固:校验增强、悬垂引用清理与保存连续性修复
- validateFlowConstraints 增加自环/model 必选模型/孤立节点校验,onConnect 拦截自环连线
- 删除节点后主动清理其余节点指向被删节点的 valueSource,避免保存脏 DSL
- 保存成功后保留工作流 id 支持连续保存,新建回填 id,避免重复副本
- attachFormFieldValueSources 不再覆盖已有上游引用,保持引用/表单展示互斥
- form 自定义字段引入不可变 field,重命名 label 不影响下游引用

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 01:04:58 +08:00
2910410219andClaude c1658380a7 工作流节点库 outputField 引用 + totalDuration 字段级引用;首页选项卡拆分/懒加载、工作空间结果接口 resultList;引用标签与 KeyValue prop 校验修复
- 节点库 outputField 作为可引用输出项:NodeLibraryItem 补类型、getNodeOutputFields 优先返回、upstreamNodes 来源扩展
- script_transcribe 视频总时长支持引用上级输出/手动输入:NodeConfigPanel 引用按钮 + valueSource 存 formConfig 条目,buildOutputConfig 序列化、buildNodeFormConfigFromDsl 回显
- 首页工作空间结果接口切 /ai-agent/session/resultList,会话列表/结果树选项卡拆分独立容错与按需懒加载
- ChatList 滚底感知;引用标签 ElTag type 空值、KeyValueEditor modelValue 空值兜底修复

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 00:38:19 +08:00
2910410219andClaude 998e13e6be 工作流 valueSource label 契约统一:去节点名前缀 + outputConfig/subConfig 补全
- resolveRefLabel 去掉「节点名.」前缀,valueSource label 只保留字段中文名
- ModelField 引用 tag 展示同步去前缀
- buildOutputConfig(http/form/其它节点)与 serializeSubFlowConfig(subConfig)的
  valueSource 补 label,subConfig 引用上游分支经 outputIndex 查上游字段中文名
- 取消引用改用 delete(避免写空数组),enumValues 按 __originIndex 统一索引

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 23:18:03 +08:00
2910410219 68850c40fe socket失败处理 2026-08-19 20:27:37 +08:00
2910410219andClaude d2143809de 首页选中工作流时禁用底部输入与发送按钮,占位提示引导填写表单
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 20:17:53 +08:00
2910410219andClaude 68bdc63231 首页工作流节点引用多选与保存回显完善:isMultiParameter 全链路透传 + valueSource 数组化/label + 旧 DSL 兼容
- isMultiParameter 节点级开关从节点库透传至引用下拉,支持多选勾选/去重/逐条删除/清空
- valueSource 契约统一为数组并补 label(节点名.字段中文名),覆盖用户引用、表单展示标记与旧 DSL 单对象
- ModelField 递归透传 multi,object/array 嵌套子字段同样支持多选
- 保存/回显兼容旧 DSL 单对象 valueSource,重开自动升级为数组
- modelRequestParamsPath 每项新增 label 字段,后端按 path 重组请求参数

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 19:53:06 +08:00
2910410219andClaude b6208b37e4 首页工作流引用功能收口:Schema 移除对象/数组引用,模型参数引用与表单展示同级
- FieldConfigDialog(json-schema-editor):删除对象/数组字段的「值来源」引用入口,
  标量字段的默认值引用保留
- ModelField:leaf 分支引用按钮对所有叶子参数生效(含数组元素内字段),
  与「表单展示」勾选同级;对象/数组字段本身不提供引用
- modelParamUtils:buildModelRequestParamsPath 恢复所有叶子保留 valueSource,
  保存链路与编辑器语义一致

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 18:21:06 +08:00
2910410219andClaude f7b3531392 首页工作流:输出字段中文展示 + 引用面板滚动修复
- valueSource 契约统一为 { nodeId, field },替换 modelParamUtils 中 fieldName 旧用法
- 数组模板压缩时记录 __originIndex,叶子字段扁平清单按后端原始索引产出 enumValues
- 模型节点输出项保存时用 responseMapping 中文填充 value,引用展示显示中文
- http 节点输出字段识别 JsonEditor 包裹结构,修正真实数据路径并提取中文 label
- 引用上级节点输出面板超高时内部滚动,避免超出视口被截掉

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 17:07:21 +08:00
2910410219andClaude c8f457dada 首页历史工作流会话解除工作流锁定,回显预选上次执行工作流可自由切换
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 13:38:04 +08:00
2910410219andClaude 0421adb6ac 首页 round_start 记录 id 实时绑定 + 用户消息重生成修复
- wsMessage 新增 RoundStart 事件与 getRecordId 解析(兼容数字/字符串)
- 普通对话:round_start 首帧把 recordId 绑定到本轮用户消息与 AI 气泡,实时即可删除/重生成
- 工作流:round_start 把 recordId 绑定到表单卡片与结果消息(共享同一记录)
- 修复用户消息重新生成时气泡消失(runChat 不补用户消息,重生成时先重新追加)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:04:21 +08:00
2910410219 c962cdd830 首页工作流产出链路去 execution/get 依赖,空结果兜底与删除接口契约对齐
- 实时执行产出只信 flow_complete 事件 resultFileUrls,删除 getExecutionDetail 兜底
- 历史回显产出改用 session/get 记录自带 resultFileUrl,不再调 execution/get
- 普通对话空回复:提示「未收到回复内容,请重试」并支持重新生成
- 工作流实时/历史无产出文件:明确提示「本次未生成产出文件」
- deleteSessionV2 对齐后端契约:/ai-agent/session/delete 传 sessionId
2026-08-18 17:04:41 +08:00
2910410219 13a2aeee9f 首页工作流会话重构与产出内嵌:后端 UUID 认领 + 产出对话内直接预览
会话机制:
- 建连不传本地会话号,由后端生成 UUID 并经首帧 ack 认领(claimSessionId 就地迁移全部状态)
- 删除执行完成后轮询会话列表替换本地条目的双路径逻辑,统一认领后刷新
- 清理 virtual_ 特判,收敛为 hasBackendSessionId 单一语义判断

产出展示:
- flow_complete 事件自带 resultFileUrls 直接构建产出卡片(buildOutputsFromUrls)
- 完成文案改为「 执行完成」,产出以消息形式展示在对话里
- 产出媒体(图片/视频/音频)常驻内嵌预览,图片点击放大,text/file 保持文件行
- ChatList 自动续载增加次数上限,防止 hasMore 恒真时无限加载循环
2026-08-17 17:59:24 +08:00
2910410219andClaude bb15c053e6 首页工作流表单卡片化闭环:执行进度、输入禁用与取消入口
- socket 节点事件推进表单卡片执行进度,失败详情展示详细原因
- 工作流执行时禁用输入框,取消入口移到卡片 footer,卡片定格可重试
- 工作流 ModelRequestParams 按详情原样透传后端
- 会话内产出文件卡片展示(预览/下载/删除),移除顶部结果卡片区

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 21:36:33 +08:00
2910410219andClaude dc20a6a829 首页工作流表单对话卡片化,历史回显走消息流
选中工作流即以表单卡片推送进入对话消息流,卡片内填写并提交执行,
移除整页表单区。历史 workflow 会话回显为带值表单卡片 + 结果消息,
不再跳整页表单页;回显数据源改用 session/get 记录(requestParams
即完整 flowContent,含 __start__ 值快照),失败判断以 errorMsg 为准。

- 新增 WorkflowFormCard.vue 表单卡片组件(迁移原整页表单渲染/校验逻辑)
- ChatList 渲染 form 类型消息,透传 form-submit
- MainContent 精简为两态(对话页/占位页)
- SessionResults workflow 结果精简摘要,状态以 errorMsg 为准

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 20:03:39 +08:00
2910410219 b8d29e31df 非子流程节点表单展示补 valueSource,修复子流程生成次数回显
- http/form/其它 presetOption 节点勾选表单展示的字段保存时补 valueSource={nodeId:开始节点id, fieldName},与子流程契约统一
- model 节点勾选字段按 path 补 valueSource,回显时清除指向开始节点的引用防误删
- 修复 sub_flow 节点 outputConfig 为空时参数面板(含生成次数)无法回显:回显逻辑改为节点库有 presetOption 定义即构建参数,值从开始节点运行表单反查
2026-08-15 19:23:11 +08:00
2910410219 41655fa825 sub_flow 节点引入工作流修复与首页执行工作流重构
- 引入工作流列表字段兼容 flowName,选择弹窗卡片正常展示数据
- 生成次数 maxConcurrency 同步保存到 sub_flow 节点,回显兜底恢复
- valueSource 按后端契约统一 {nodeId, fieldName},表单展示/引用正确保存与回显
- 首页执行工作流 DSL 字段路径改为 .enumValues[i],支持引用上游输出与上传文件名
2026-08-15 18:49:56 +08:00
2910410219 3b71f144c4 修复模型管理v2对话模型回显与异步弹窗崩溃问题
- 修复 chatModel 开关回显:字段大小写与后端对齐(chatModel)
- 修复异步模型 asyncTaskMapping 内字段为 null 时编辑弹窗崩溃
- lastFrame 提交仅限视频模型,避免占位符误提交
- 切换返回类型时清空 responseBodyMapping 残留
- 列表页访问类型列改为供应商列(后端不再返回 PrivateModel)
- ModelManageItem 类型与后端返回字段对齐(modelSupplier/chatModel/minDuration 等)
2026-08-15 10:43:40 +08:00
2910410219 c4c2625714 重构socket单会话单链接,先握手 2026-08-14 22:22:29 +08:00
2910410219 7e7a3cbf2d 会话删除修复 2026-08-14 21:57:00 +08:00
2910410219 a65b898680 修改会话的bug和回显以及滚动加载 2026-08-14 21:41:32 +08:00
2910410219 315d05c3ea 会话相关 2026-08-14 19:57:46 +08:00
2910410219 362cb4332f 主题和样式以及动画过渡的首页修改 2026-08-14 15:35:03 +08:00
2910410219 4ec05b507a 首页对话相关 2026-08-14 11:25:13 +08:00
2910410219 c088894546 websocket相关 2026-08-13 20:03:14 +08:00
2910410219 8e7948cff5 首页调试工作流管理 2026-08-13 18:02:35 +08:00
2910410219 2ae59c9252 工作流管理相关 2026-08-13 09:36:46 +08:00
2910410219 0e6381d2b3 1. 上游节点输出引用(核心功能)
- 新增 upstreamNodes computed:遍历当前选中节点的前驱链路,仅 model / http / form 三类节点被识别为有输出的节点
  - 新增 getNodeOutputFields,按节点类型提取可引用字段:
    - form → formConfig 的字段
    - http → response schema 的叶子字段
    - model → modelResponseBodyMapping(模型返回参数)的 key
    - 开始节点 → runFormFields(运行表单字段),从而让下游节点能引用"用户填的运行表单"作为输出

  2. 开始节点运行表单字段聚合
  - syncRunFormFields + 深监听:开始节点 formConfig 变化时自动聚合字段,存为 runFormFields,作为开始节点自身可被下游引用的输出

  3. 模型返回参数
  - handleModelConfirm 用 stripReadonlyFields 清理 requestBodyMapping 冗余字段,同时保存 modelResponseBodyMapping 供下游引用
  - 保存时 collectExposedFields 提取对外暴露字段存 modelFormFields

  4. 提示词 / 反向提示词
  - 保存 prompt、negativePrompt、runFormFields 到节点 DSL

  5. 其他
  - nodeTypes 用 markRaw 包裹 FlowNode,修复 Vue 警告
  - buildNodeFormConfigFromDsl 的 responseType 匹配改为兼容 key/value 两种取值
2026-08-12 17:26:58 +08:00
2910410219 4e1cd284e9 修改节点连接点左右样式和色彩 2026-08-10 15:02:10 +08:00
2910410219 5ac546a466 Merge branch 'feature/workflow' of http://116.204.74.41:3000/red-future/admin-ui into feature/workflow 2026-08-10 14:05:49 +08:00
2910410219 4796750305 工作流管理相关调整模型列表接口调整 2026-08-10 14:05:46 +08:00
admin 20990db7d6 gitea,gitea-runner写入docker-compose 2026-08-06 09:40:33 +08:00
2910410219 cb8effc769 工作流管理相关 2026-08-06 09:31:34 +08:00
2910410219 a951f864f4 Merge branch 'feature/workflow' of http://116.204.74.41:3000/red-future/admin-ui into feature/workflow 2026-08-04 14:29:00 +08:00
2910410219 448620ed8a 针对工作流管理进行组件抽离和与内容创作进行安全隔离 2026-08-04 14:28:57 +08:00
admin 9fc80a6c98 gitea,gitea-runner写入docker-compose 2026-08-03 17:24:08 +08:00
2910410219andClaude e1d369b205 feat(login): 隐藏手机号登录与扫码登录
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 11:40:28 +08:00
2910410219andClaude a8106f6c4c feat(workflow): 节点配置支持 keyValue 与 schemaJson 字段类型
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 11:40:28 +08:00
2910410219andClaude 7de6f00cff feat(json-schema-editor): 数组字段支持枚举值配置,移除循环生成与容器联动
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 11:40:28 +08:00
2910410219 4dc2c38820 修复schema的遗留bug 2026-07-27 15:33:28 +08:00
2910410219 ace80efd63 模型类型修复 2026-07-23 17:57:28 +08:00
2910410219 be51ac40e0 修复模型管理v2前后端类型定义不一致 2026-07-23 17:17:30 +08:00
2910410219 adea3a90ec 取消工作空间查看更多功能,默认查所有 2026-07-22 15:15:08 +08:00
2910410219 cac2dd019c 优化模型管理的添加表单,完善数据源 2026-07-18 16:50:16 +08:00
2910410219andClaude 3099f65109 fix: tokenMapping 初始加载时被 responseType 的 watch 误清
openDialog 中 responseType 赋值后触发了 watcher 异步清除
tokenMapping,导致已加载的 tokenMapping 值被清空。
通过 _skipTokenClear 标志跳过初始加载时的清除逻辑。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 15:11:36 +08:00
2910410219andClaude bf1e9fa89a feat: Token映射配置增强 + 模型详情API + 编辑器布局优化
1. Token 映射下拉:
   - 新增 flattenWrappedFields 函数递归提取 { type, attrs } 结构中的叶子字段
   - 下拉选项显示 路径 — 标签 格式,选中值存完整路径
   - 同步/流式从响应映射取,异步从回调响应映射取

2. 模型详情 API:
   - 新增 getModelManage 详情接口 (/model/manage/getModelManage)
   - openDialog 改为 async,编辑时调详情接口获取完整数据
   - 修复 res.data.modelManage 取值路径

3. JSON 编辑器布局:
   - 弹窗宽度 1000px → 1200px
   - 配置侧栏 320px → 380px
   - config-summary 改用 flex:1 防止滚动条截断文本

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 14:45:53 +08:00
2910410219 51b5008951 chore: update dev env 2026-07-17 11:09:08 +08:00
2910410219andClaude fd287a0d92 fix(json-schema-editor): getNodeJsonPath 路径修复 - 包装对象父节点增加 .attrs. 前缀
根节点直子之外的 { type, attrs } 包装对象,其子字段路径中缺少 .attrs. 段。
通过给包装节点添加 _isWrapped 标记,让路径生成逻辑正确插入 .attrs. 前缀。

例如 messages.attrs[0].content 改为 messages.attrs[0].attrs.content

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 11:08:57 +08:00
2910410219 fa9ada2334 fix(json-schema-editor): 根节点输出时去除 { type, attrs } 包装
最外层根 object 不应该套 { type, attrs } 包装,
应直接输出子字段组成的原始对象。

- nodeToValue 中增加根节点判断:node === rootNode.value 时直接返回 rawValue
- 非根节点保持原有 { type, attrs/value, ...config } 格式不变
2026-07-16 17:32:20 +08:00
2910410219 76be2dc85c feat(json-schema-editor): JSON编辑器新增 isContainer 容器字段联动隐藏配置
将旧的分级 upLevel/remove 联动规则改为基于完整路径的平铺结构。

新增特性:
- 标量字段(string/number/boolean)配置面板中增加"容器字段"开关
- 开关打开后展示"联动隐藏"下拉菜单,列出当前字段同级及以上所有可选字段
- 下拉展示格式:"字段key — 完整路径"(如 type — messages.attrs[0].content.attrs[0].type)
- 选中后以 { key: path } 格式输出,渲染器按路径直接定位隐藏字段

核心改动:
- index.vue: 新增 getAvailableFields / getNodeJsonPath 替代旧的分级遍历逻辑
  - getAvailableFields: 从当前字段向上遍历,跳过数组层级,根级只含容器自身
  - getNodeJsonPath: inAttrs 状态机生成完整路径(messages.attrs[0].content.attrs[0].type)
  - linkRules 类型从 Array<{ upLevel, remove[] }> 改为 Array<Record<string, string>>
  - formToConfig / hasAnyConfig 同步更新
- FieldConfigDialog.vue: 重构联动隐藏UI,单 el-select multiple 替代分级 checkbox
- JsonNode.vue: 类型同步 + 摘要显示容器触发器及规则数

数据格式示例:
  "isContainer": true,
  "linkRules": [
    { "type": "messages.attrs[0].content.attrs[0].type" }
  ]
2026-07-16 17:26:30 +08:00
2910410219 5742608b97 feat: JSON编辑器增强 - Token映射下拉、字段选项key:value、序列化始终包装type
1. 模型配置弹窗: Token映射三个字段由输入框改为下拉菜单
   - 同步/流式从响应映射取key, 异步从回调响应映射取key
   - 切换返回类型时清空Token映射, 无映射配置时禁用并提示
2. JSON编辑器序列化: 所有节点始终输出{type, value/attrs}格式
   - 容器(object/array)始终{type, attrs}, 标量始终{type, value}
3. 字段配置增强:
   - 对象类型新增"循环生成"开关
   - 下拉控件选项列表改为key:value双输入框
4. 类型注释更新
2026-07-15 16:33:58 +08:00
2910410219 a6eaf57898 v2模型管理相关 2026-07-14 16:30:26 +08:00
2910410219 f179a5b755 新版模型v2相关 2026-07-14 11:03:46 +08:00
2910410219 ea3945d70f 工作空间默认一次拿10个结果 2026-07-08 16:45:40 +08:00
2910410219 0a4c281997 修复工作空间结果日期重复问题 2026-07-08 16:42:40 +08:00
2910410219 57c0e4281a Merge branch 'feature/workflow' of http://116.204.74.41:3000/red-future/admin-ui into feature/workflow 2026-07-08 14:08:41 +08:00
2910410219 5b6b52a18b 文件预览调整 2026-07-08 14:08:39 +08:00
admin 2412e9abea ng代理minio支持websocket 2026-07-08 13:06:19 +08:00
admin 1df43a33a1 Merge remote-tracking branch 'origin/feature/workflow' into feature/workflow 2026-07-08 11:56:22 +08:00
admin a7c5ebbab0 ng代理minio支持websocket 2026-07-08 11:56:06 +08:00
2910410219 3512981408 Merge branch 'feature/workflow' of http://116.204.74.41:3000/red-future/admin-ui into feature/workflow 2026-07-08 11:52:15 +08:00
2910410219 72cb10c4f5 修改外层上传文件收集逻辑 2026-07-08 11:52:12 +08:00
admin 1bdef0efd6 ng代理minio支持websocket 2026-07-08 10:57:22 +08:00
admin eaec78354b ng代理minio支持websocket 2026-07-08 10:15:03 +08:00
admin 36e62a5360 ng代理minio支持websocket 2026-07-08 09:55:39 +08:00
2910410219 7c01f69a24 贴片模版组件更新 2026-07-07 18:11:57 +08:00
2910410219 67ec83832f ai助手1.0 2026-07-07 17:53:32 +08:00
2910410219 9f9f7eb376 首页plus 2026-07-07 17:28:58 +08:00
2910410219 ab7bb9fc42 更新临时对话状态显示问题 2026-07-07 16:44:42 +08:00
2910410219 5f0d860d27 对话记录和工作空间链路确认 2026-07-07 16:00:08 +08:00
2910410219 4679280e45 首页更新 2026-07-07 14:36:59 +08:00
2910410219 6a588015d0 feat: 主页对话功能完善 — 会话列表、工作流执行、样式优化
- 新增会话列表 API(getSessionList)及类型定义
- 修复对话记录为空:兼容 API 返回 data.list 结构
- 删除接口改为 DELETE 方法,参数统一使用 id
- 实现工作流执行逻辑,与内容创作对齐(含表单参数、文件上传、flowContent)
- 动态表单样式优化:三段式布局防溢出、上传区支持拖拽、节点分组显示
- 对话记录时间格式化:当天显示 HH:mm,非当天显示日期
- 对话记录默认展示全部,移除分页展开
- 发送按钮支持选中工作流时空文本可点击
- 默认占位展示引导提示,替代空状态
- 添加对话记录删除确认弹窗
- 下载改用后端代理,避免跨域问题
2026-07-07 11:31:40 +08:00
2910410219 6be970dcaf fix: 贴片模板显示逻辑改为按节点实例patchLayout开关判断
- currentWorkflowHasPatchLayout 改为检查节点实例的 patchLayout 字段
- InputBar 工作流对接真实接口,禁用技能选择
- home/index.vue 移除 mock 数据,工作空间对接真实接口
- 工作空间增加预览/下载/删除功能
2026-07-06 17:44:01 +08:00
2910410219 cce0bc6448 恢复贴片相关 2026-07-06 16:49:06 +08:00
admin 4391189e0c ng代理minio支持websocket 2026-07-06 16:33:56 +08:00
2910410219 0dd9637725 更新 2026-07-06 16:19:48 +08:00
2910410219 d7f7d68f39 更新claude规范 2026-07-06 16:18:59 +08:00
2910410219 c2b6cd274d 贴片模版限制html文件 2026-07-06 16:17:35 +08:00
2910410219 422c85a068 视频贴片相关 2026-07-06 16:08:02 +08:00
2910410219 595858ae5e Merge branch 'feature/workflow' of http://116.204.74.41:3000/red-future/admin-ui into feature/workflow 2026-07-04 17:57:31 +08:00
2910410219 175d2e47ce json编辑器组件相关 2026-07-04 17:57:28 +08:00
admin 5113808991 运维脚本调整 2026-07-03 12:56:30 +08:00
2910410219 50f430def2 修改租户余额展示 2026-07-02 11:43:42 +08:00
2910410219 fceb8eb3c7 Merge branch 'feature/workflow' of http://116.204.74.41:3000/red-future/admin-ui into feature/workflow 2026-07-02 11:33:07 +08:00
2910410219 ab9c3cf863 增加租户和余额查看功能 2026-07-02 11:27:43 +08:00
admin a4d0028473 运维脚本调整 2026-06-26 16:54:22 +08:00
admin 5de47b5701 运维脚本调整 2026-06-26 15:58:00 +08:00
admin 0f01b301e9 common版本更新 2026-06-26 15:16:50 +08:00
admin 2b5ac801a2 Merge remote-tracking branch 'origin/feature/workflow' into feature/workflow 2026-06-25 17:05:54 +08:00
admin c52f3d5a8a common版本更新 2026-06-25 17:05:14 +08:00
2910410219 e8833b458d 数据查询新增功能 2026-06-25 15:13:52 +08:00
2910410219 4b8ff9af65 Merge branch 'feature/workflow' of http://116.204.74.41:3000/red-future/admin-ui into feature/workflow 2026-06-25 13:32:56 +08:00
2910410219 a6fdba912d 增加报表配置管理 2026-06-25 13:32:52 +08:00
admin e3c63b9377 cicd 2026-06-22 16:32:47 +08:00
admin 9653b5c276 cicd 2026-06-22 16:24:49 +08:00
admin e4f1e3af36 项目初始化 2026-06-22 10:42:21 +08:00
2910410219 9fbe2c6d64 接口管理改动 2026-06-18 11:05:56 +08:00
2910410219 ffab3be854 取消创作模式下文件上传超时时间 2026-06-17 10:05:32 +08:00
2910410219 f85107be6d Merge branch 'feature/workflow' of http://116.204.74.41:3000/red-future/admin-ui into feature/workflow 2026-06-17 09:57:26 +08:00
2910410219 27f35a5679 更新首页布局样式和逻辑 2026-06-17 09:55:30 +08:00
admin 2ede6f4ba0 1 2026-06-16 16:13:10 +08:00
2910410219 195d219f91 输入框工作流调整 2026-06-16 11:08:10 +08:00
2910410219 536ad8386c 首页布局更新 2026-06-15 16:52:51 +08:00
2910410219 10519e7500 更新地址 2026-06-12 13:15:48 +08:00
2910410219 79d0a2a9fe 测试git 2026-06-12 13:15:21 +08:00
2910410219 20bf8138a8 Merge branch 'feature/workflow' of http://116.204.74.41:3000/red-future/admin-ui into feature/workflow 2026-06-12 13:12:30 +08:00
2910410219 1c0e966b7a 更新表单项标签,将“Token / access_token”修改为“Token/密钥”以提升可读性 2026-06-12 13:12:27 +08:00
admin 57ca523b5a cicd 2026-06-12 11:07:32 +08:00
2910410219 f72508aa83 Merge branch 'feature/workflow' of http://116.204.74.41:3000/red-future/admin-ui into feature/workflow 2026-06-11 17:27:28 +08:00
2910410219 61577be41f 更新 API 接口和数据源管理逻辑,调整参数类型,新增表格定义字段,优化请求路径,更新登录组件密码 2026-06-11 17:26:39 +08:00
admin 1baef251ab Merge remote-tracking branch 'origin/feature/workflow' into feature/workflow 2026-06-10 15:37:35 +08:00
admin b25a1457af ci/cd调整 2026-06-10 15:37:10 +08:00
2910410219 85845b0d38 执行传递参数更改 2026-06-10 13:07:56 +08:00
2910410219 075bf22add 优化技能选择和模型确认逻辑,更新节点属性处理,调整配置字段初始化逻辑,改善文件名生成方式 2026-06-10 10:16:28 +08:00
2910410219 52340fcd94 更新提示词管理逻辑,重命名相关变量,优化提示词选择器界面,支持直接编辑提示词内容 2026-06-09 17:51:32 +08:00
2910410219 10d6ce2b0d 更新提示词管理功能,新增提示词创建和删除功能,调整来源类型标识,优化提示词选择界面和表单逻辑 2026-06-09 17:17:32 +08:00
2910410219 58d5713ec1 nginx配置更改无法找到的请求(API路径)代理到后端方案 2026-06-09 15:27:30 +08:00
2910410219 cb6c881365 新增参数结构配置功能,支持动态添加和删除参数字段,优化表单逻辑和样式 2026-06-09 14:37:58 +08:00
2910410219 ffaf04982a 更新 Nginx 配置,使用自定义 404 页面并优化未命中路径处理逻辑 2026-06-09 13:20:30 +08:00
2910410219 28d81d86bd Merge branch 'feature/workflow' of http://116.204.74.41:3000/red-future/admin-ui into feature/workflow 2026-06-09 09:30:47 +08:00
2910410219 0b958a0ebb 更新 Nginx 配置,优化路径处理逻辑,统一未命中路径返回网页入口,移除不必要的代理设置 2026-06-09 09:30:43 +08:00
admin c13e01c902 Merge remote-tracking branch 'origin/feature/workflow' into feature/workflow 2026-06-08 13:54:52 +08:00
admin c8ad78ab44 ci/cd调整 2026-06-08 13:54:26 +08:00
2910410219 04a8912307 更新模型 2026-06-06 17:23:50 +08:00
2910410219 495fde42ca 路由兜底 2026-06-06 15:30:09 +08:00
2910410219 5363613a7d 更新模型配置,调整部分字段为可选,移除界面中不必要的输入项,优化表单验证逻辑 2026-06-06 13:57:57 +08:00
2910410219 fc54bcd3d6 重构流式配置弹窗,新增流式参数表单和验证逻辑,移除无用的模型类型组件 2026-06-06 13:46:31 +08:00
2910410219 4a8bc3cb7a 更新上级参数选择逻辑,修正可选参数列表,排除 HTTP/HTTPS 接口节点 2026-06-06 10:46:47 +08:00
2910410219 367cd98018 修复回显问题 2026-06-06 10:10:12 +08:00
2910410219 ee8ba0a5d9 优化模型配置和节回显 2026-06-05 17:33:18 +08:00
2910410219 56e1517743 更新模型配置,新增字段支持和请求映射功能,优化界面元素,移除冗余代码 2026-06-05 15:56:44 +08:00
2910410219 eea5874dbf 配置模型相关 2026-06-05 13:07:00 +08:00
2910410219 6ef063ac09 创作模式oss文件上传临时数据清理 2026-06-05 09:41:33 +08:00
2910410219 c37710af3d 节点传参数据结构调整 2026-06-05 09:27:26 +08:00
2910410219 c52d31c16e 节点上级模型参数的引用和调整 2026-06-05 09:15:40 +08:00
2910410219 48a4636a81 移除不必要的节点输出引用相关代码,简化参数处理逻辑。 2026-06-04 18:04:09 +08:00
2910410219 9c03013c44 更新 2026-06-04 16:04:16 +08:00
2910410219 05a0e35891 节点新增字段和提示词弹窗 2026-06-04 15:04:01 +08:00
2910410219 937d1e9373 Merge branch 'feature/workflow' of http://116.204.74.41:3000/red-future/admin-ui into feature/workflow 2026-06-04 10:16:23 +08:00
2910410219 af3f0678b8 模型配置相关 2026-06-04 10:16:20 +08:00
115 changed files with 24310 additions and 3386 deletions
+7
View File
@@ -0,0 +1,7 @@
---
name: grill-me
description: A relentless interview to sharpen a plan or design.
disable-model-invocation: true
---
Run a `/grilling` session.
+7
View File
@@ -0,0 +1,7 @@
---
name: grill-me
description: A relentless interview to sharpen a plan or design.
disable-model-invocation: true
---
Run a `/grilling` session.
+6
View File
@@ -0,0 +1,6 @@
node_modules
.git
.gitignore
*.md
dist
.cache
+2 -1
View File
@@ -4,4 +4,5 @@ ENV = 'development'
# 统一后端服务地址前缀(网关服务名:admin-go)
# 开发环境走本地代理,避免 CORS
VITE_API_URL = 'http://116.204.74.41:8000'
# VITE_API_URL = 'http://192.168.3.30:8000'
# VITE_API_URL = 'http://192.168.3.30:8000'
# VITE_API_URL = 'http://192.168.3.135:8000'
-18
View File
@@ -1,18 +0,0 @@
name: 部署 admin-ui 到 K3s
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: 拉取代码
uses: actions/checkout@v4
- name: 部署到 K3s
run: |
kubectl apply -f k8s/
kubectl rollout restart deployment admin-ui
+3
View File
@@ -1,6 +1,8 @@
.DS_Store
node_modules
/dist
# Vue language service / accidental transpile artifacts
src/**/*.vue.js
@@ -23,3 +25,4 @@ pnpm-debug.log*
*.sln
*.sw?
/admin-ui.iml
.gstack/
+196
View File
@@ -19,6 +19,7 @@ This is the **GFast UI** project (`gfast-ui`), a Vue 3 admin management system b
## Architecture Overview
**Tech Stack:**
- Vue 3 with Composition API (script setup)
- TypeScript
- Vite
@@ -30,6 +31,7 @@ This is the **GFast UI** project (`gfast-ui`), a Vue 3 admin management system b
- mitt (event bus)
**Directory Structure:**
- `src/api/` - API client methods organized by business domain (ads, assets, cid, customerService, digitalHuman, knowledge, login, menu, settings, system, etc.)
- `src/components/` - Reusable Vue components
- `src/directives/` - Custom Vue directives
@@ -92,6 +94,139 @@ This is the **GFast UI** project (`gfast-ui`), a Vue 3 admin management system b
5. **Keep-alive**: Route components can be cached via keep-alive, managed by the `useKeepALiveNames` store.
## Repository Collaboration Rules
These rules capture long-term repository preferences confirmed by the user and should be applied in future sessions unless the user explicitly overrides them.
### 最高优先级规则(覆盖以下所有规则)
以下规则优先级高于本文件任何其他规则:
1. **修改前必须确认**:任何代码修改前必须先向用户确认,用户明确同意后才能执行
2. **反常问题必须上报**:遇到 Edit 反复失败、渲染异常、非预期行为等反常问题,必须立即向用户报告问题原因、可行方案和推荐方向,由用户选择后再继续
3. **不确定必须询问**:不确定、不了解、不清楚的事情一律先问用户,不自行假设或绕路
4. **改后影响检查**:代码修改完成后,必须逐一检查对其他代码的影响(渲染条件、接口字段、样式覆盖、其他引用处)
5. **改动评分与总结**:每次修改完成后,对本次改动按 1-10 分打分,并附上总结和改进建议
6. **中文沟通**:所有与用户的沟通、说明、思考展示必须使用中文
7. **操作后清理**:每次文件操作(拆分、替换、脚本执行等)完成后,必须检查并清理产生的临时文件,不得遗留在项目目录中
8. **内容创作与工作流管理必须隔离**`src/views/settings/creation`(内容创作)是线上运行的旧版本,所有改动只服务于工作流管理(`src/views/settings/workflow`),绝对不修改内容创作的代码、API、组件或数据结构。工作流如需共享能力,应为自身新建独立版本(如独立的 `src/api/settings/workflow/`、本地组件),不得改动内容创作在用的东西。后端为工作流管理做的数据变化也属于工作流管理,不必适配内容创作。
### Pagination Rules
- Prefer the project global `pagination` component by default.
- Pagination should be fixed and visible as part of the page or dialog layout, not conditionally hidden only because the dataset is small.
- In single-page scenarios, the UI should still show total count together with the pagination area.
- Avoid designs where pagination only appears after data exceeds one page.
### Dialog Layout Rules
- For dialogs that contain searchable tabular data, prefer this structure:
1. search area
2. table/content area
3. pagination area placed under the table inside the dialog body
4. footer action buttons
- The pagination area should remain visually stable and should not be omitted just because the current result set is small.
### Theme and Style Rules
- When functionality and existing theme style conflict, prioritize making the feature clearly visible and usable first.
- After ensuring usability, align the implementation with the repository's existing theme and style system as much as possible.
- Be cautious about global theme styles that may affect local pagination, dialog, or table layouts.
### Problem-solving Workflow
- For repeated UI issues, analyze in this order:
1. interface/request data and returned structure
2. component logic and render conditions
3. theme and style coverage/conflicts
4. then decide whether a rewrite is necessary
- Do not jump straight to a rewrite before checking interface behavior and style interference.
### Execution Workflow Rules
- When modifying a feature, locate it in this order: usage location, component definition, API definition, then at least one similar implementation in the project.
- For common UI features such as pagination, dialogs, and tables, align with an existing similar implementation before making changes.
- Prefer componentization. If a page or Vue SFC is already large, or a block has a clear independent responsibility, split it into components instead of continuing to extend the same file.
- For very long files such as complex page-level `index.vue` files, treat further component splitting as the default direction rather than the exception.
- Before editing large files or complex Vue SFCs, first read the target context; if a replacement fails once, do not repeat the same replacement blindly, and instead expand the context or switch to smaller targeted edits.
- After UI or interaction changes, verify not only lint status for edited files but also render conditions, interface fields, and possible style impact.
- If the same issue is not resolved after one attempt, pause and ask the user before continuing with more speculative changes.
- Do not proactively rewrite a component unless the user explicitly asks for a rewrite.
- At the start of a new conversation, read `CLAUDE.md` before making changes.
- When code changes appear not to take effect, check in this order: compile/HMR status, render conditions, API data, then style overrides.
- Turn repeated low-level mistakes into explicit workflow checks and follow those checks in future edits.
### Environment and Tool Selection Rules
- Before running environment-dependent commands, first follow the known runtime context already provided by the IDE, such as OS and default shell.
- On Windows projects with PowerShell as the active shell, prefer PowerShell-native commands and scripts by default.
- Do not assume Python, bash, sed, awk, or other non-default tooling is installed locally unless the user or repository explicitly indicates that they are available.
- For reading and editing repository files, prefer dedicated file tools first; use shell-based text replacement only when file tools are not suitable for the change.
- When a replacement fails once, do not keep retrying the same method blindly. Re-read the exact target content and switch to a smaller or more reliable edit strategy that matches the current environment.
- Avoid trial-and-error probing for basic tooling when the available environment is already known from context.
### Componentization and Structure Rules
- If a view block has an independent responsibility, prefer extracting it into a component instead of keeping it inside a large page file.
- When a file is already long, or a new feature would further expand responsibilities, proactively split it into components.
- Child components may keep their own local logic when that logic belongs clearly to the childs responsibility; only shared state and necessary events should stay in the parent.
- For complex pages, small-scale components can stay in `component/`, while larger feature areas may be grouped by responsibility in subdirectories.
- For very long page files, prefer keeping the parent focused on composition, state orchestration, and data flow, while moving feature UI and local behavior into child components.
- When extracting new child components from a large file, move their related local styles with them to avoid continued growth of the parent file.
### API and Data Handling Rules
- Follow the projects common response structure by default, but add moderate component-side compatibility handling when equivalent APIs return slightly inconsistent fields.
- For page-level error handling, use global handling by default, but use page-level or silent handling when the interaction requires local control.
- Prefer a unified parameter-building entry for request params instead of assembling the same parameters across multiple functions.
### Form and Interaction Rules
- Follow the projects existing form behavior style: required fields should be clearly validated on submit, while avoiding unnecessarily disruptive validation during input.
- For shared create/edit forms, initialize, patch, and reset form state through a unified initialization path to avoid stale state.
- Search interactions should behave consistently across button click, Enter key, and clear actions, and should reset to the first page by default.
### Dialog and Selector Rules
- Dialogs should clear temporary internal state on close by default; any state that needs to persist should be explicitly provided back by the parent.
- Selector components should follow a consistent pattern: `v-model` for visibility, a default value prop, and a `confirm` event for returning the selected result.
- For temporary selectors and lightweight dialogs, prefer `destroy-on-close`; for more complex stateful forms, decide deliberately rather than applying it blindly.
### Styling Rules
- Prefer local scoped style changes first; only modify global theme styles after confirming the issue is truly shared across multiple places.
- When overriding Element Plus internals, prefer local `:deep()` overrides before considering global style changes.
- Use semantic, area-based class names instead of short or overly generic names.
### Debugging and Validation Rules
- Minimal temporary debugging is allowed when necessary, but all temporary debugging code must be removed before final delivery.
- Temporary `console` output is acceptable only when debugging is genuinely needed and must be removed before final delivery.
- For UI and interaction changes, verify lint, key render conditions, interface fields, and style impact before considering the task complete.
### Change Scope and Consistency Rules
- Prefer the smallest necessary change set, but do not pretend a local-only fix is sufficient when the real root cause is elsewhere in the chain.
- Only fix nearby issues when they are strongly related to the current task; otherwise ask the user before expanding the scope.
- Within the current modification scope, move code toward repository rules when practical, but avoid broad unrelated refactors.
### Communication and Risk Rules
- 默认使用中文进行回复、说明、提问与思考表达;如果用户明确要求使用其他语言,再按用户要求切换。
- Before changing code, confirm with the user when a change involves a rewrite, global side effects, unclear requirements, or a second attempt that still has not solved the issue.
- After changes, explain what changed, why it was changed that way, and any remaining risks or points worth confirming.
- When a requirement still depends on a meaningful assumption, ask the user first instead of silently choosing a direction.
### Repository Memory Rules
- When collaboration reveals a repeated problem pattern, proactively suggest turning it into an explicit repository rule in `CLAUDE.md`.
- Project-specific experience or pitfall notes may be added when they become necessary, but do not expand them prematurely without clear recurring value.
### Rule Documentation Style
- Keep long-term repository rules concise and practical.
- Prefer short rule lists with a small amount of explanation over overly long documentation.
## Environment Variables
- `.env.development` - Development environment settings
@@ -101,6 +236,67 @@ This is the **GFast UI** project (`gfast-ui`), a Vue 3 admin management system b
- `VITE_PUBLIC_PATH` - Public base path for production build
- `VITE_OPEN_CDN` - Enable CDN for external dependencies
## AI Skills
本项目已全局安装 **gstack****Superpowers** 两组技能(skill),通过斜杠命令调用。
### gstack — 浏览器验证与发布
负责 QA 测试、浏览器自动化、安全审计、代码审查和发布部署。
| 阶段 | 命令 | 功能 |
| ----------- | ------------------------- | ------------------------------------------------ |
| **审查** | `/gstack-review` | 代码审查,自动修复明显问题,标记完整性缺口 |
| | `/gstack-cso` | 安全审计(OWASP Top 10 + STRIDE),含漏洞验证 |
| **QA 测试** | `/gstack-qa <url>` | 打开浏览器进行功能测试,发现 bug 并自动修复 |
| | `/gstack-qa-only <url>` | 仅检测并报告 bug,不修改代码 |
| | `/gstack-browse` | AI 浏览网页内容(通过 Playwright + 系统 Chrome |
| | `/gstack-scrape` | 抓取网页内容 |
| **性能** | `/gstack-benchmark` | 页面加载性能基准测试 |
| | `/gstack-canary` | 部署后监控:控制台错误、性能回归、页面故障 |
| **发布** | `/gstack-ship` | 同步主分支、运行测试、推送、创建 PR |
| | `/gstack-land-and-deploy` | 合并 PR、等待 CI、部署、验证生产环境 |
| **诊断** | `/gstack-investigate` | 系统性根因调试 |
| | `/gstack-retro` | 迭代回顾分析 |
浏览器功能依赖 Playwright,已配置使用系统 Chrome`C:\Program Files\Google\Chrome\Application\chrome.exe`),无需额外下载浏览器。
### Superpowers — 思考与计划
负责头脑风暴、方案设计、计划编写与执行、系统性调试。
| 阶段 | 命令 | 功能 |
| -------- | ----------------------- | ----------------------------------- |
| **构思** | `/sp-brainstorm` | 交互式头脑风暴,梳理需求和方案 |
| | `/sp-using-superpowers` | Superpowers 使用指南 |
| **计划** | `/sp-write-plan` | 编写实现计划 |
| | `/sp-execute-plan` | 按批次执行计划 |
| **开发** | `/sp-subagent-dev` | 子代理驱动开发:规范→审查→实现→验证 |
| | `/sp-tdd` | 测试驱动开发工作流 |
| | `/sp-dispatch-agents` | 并行分发子代理任务 |
| **调试** | `/sp-debug` | 系统性调试方法论 |
| **审查** | `/sp-request-review` | 请求代码审查 |
| | `/sp-receive-review` | 接收并处理审查反馈 |
| **完成** | `/sp-verify` | 完成前验证检查 |
| | `/sp-finish-branch` | 完成开发分支 |
| **工具** | `/sp-write-skills` | 编写自定义 skill |
| | `/sp-git-worktrees` | Git 工作树使用指南 |
### 工作流建议
推荐开发流程按此顺序使用两组 skill:
```
/sp-brainstorm → 梳理需求
/sp-write-plan → 编写实现计划
(编码实现)
/gstack-review → 代码审查
/gstack-qa <url> → QA 测试
/gstack-cso → 安全检查
/gstack-ship → 发布
```
## Development Notes
- The project uses `/@` alias for `src/`
+14 -2
View File
@@ -1,5 +1,5 @@
# ==================== 第一阶段:构建前端 ====================
FROM node:18-alpine AS builder
FROM docker.m.daocloud.io/node:18-alpine AS builder
WORKDIR /app
# 配置Alpine国内镜像源
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
@@ -13,7 +13,7 @@ COPY . .
RUN npm run build
# ==================== 第二阶段:部署到Nginx ====================
FROM nginx:alpine
FROM docker.m.daocloud.io/nginx:alpine
# 复制构建产物
COPY --from=builder /app/dist/ /usr/share/nginx/html/
@@ -24,6 +24,18 @@ COPY ngnix.conf /etc/nginx/conf.d/default.conf
# 复制SSL证书
COPY ssl/* /etc/nginx/ssl/
# 启动时自动获取宿主机内网 IP,替换 __API_HOST__ 占位符
RUN printf '%s\n' \
'#!/bin/sh' \
'set -e' \
'if [ -n "$API_HOST" ]; then' \
' HOST_IP="$API_HOST"' \
'else' \
' HOST_IP=$(route -n 2>/dev/null | grep "^0.0.0.0" | tr -s " " | cut -d " " -f2)' \
'fi' \
'sed -i "s/__API_HOST__/$HOST_IP/g" /etc/nginx/conf.d/default.conf' \
> /docker-entrypoint.d/01-set-api-host.sh && chmod +x /docker-entrypoint.d/01-set-api-host.sh
EXPOSE 80 443
CMD ["nginx", "-g", "daemon off;"]
-16
View File
@@ -1,16 +0,0 @@
FROM node:18-alpine
# 配置Alpine国内镜像源(加速apk)
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
WORKDIR /app
COPY package*.json ./
RUN npm install --registry=https://registry.npmmirror.com
COPY . .
EXPOSE 8080
CMD ["npm", "run", "dev"]
-26
View File
@@ -1,26 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: admin-ui
namespace: default
spec:
replicas: 1
selector:
matchLabels:
app: admin-ui
template:
metadata:
labels:
app: admin-ui
spec:
containers:
- name: admin-ui
image: 116.204.74.41:3000/red-future/admin-ui:latest
imagePullPolicy: Always
ports:
- containerPort: 80
- containerPort: 443
resources:
limits:
memory: "512Mi"
cpu: "500m"
-20
View File
@@ -1,20 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: admin-ui
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
spec:
tls:
- secretName: admin-ui-tls
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: admin-ui
port:
number: 443
-15
View File
@@ -1,15 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: admin-ui
spec:
selector:
app: admin-ui
ports:
- name: http
port: 80
targetPort: 80
- name: https
port: 443
targetPort: 443
type: ClusterIP
+238 -25
View File
@@ -1,62 +1,275 @@
# Nginx 静态文件服务 + 智能代理
# Nginx 静态文件服务 + 智能代理 + 自定义 404 页面
# HTTP 重定向到 HTTPS
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
# AI 爬虫 User-Agent 黑名单
map $http_user_agent $badbot {
default 0;
~*(GPTBot|ChatGPT-User|Claude-Web|ClaudeBot|Bytespider|CCBot|Google-Extended) 1;
}
# WebSocket 升级头透传:客户端带 Upgrade 头(WS 握手)时 Connection 置 upgrade,普通 REST 请求保持 close
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# 主站 HTTPSSL 已在外层 nginx 终结,此处只需 serve 内容)
server {
# 静态资源根目录(dist)
root /usr/share/nginx/html;
index index.html;
# SSL 配置
listen 443 ssl;
ssl_certificate /etc/nginx/ssl/scs1779764972146_redpowerfuture.com_server.crt;
ssl_certificate_key /etc/nginx/ssl/scs1779764972146_redpowerfuture.com_server.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 500M;
listen 80;
server_name redpowerfuture.com www.redpowerfuture.com;
error_page 404 /404.html;
location = /404.html {
internal;
}
# 根路径默认进入网页端
location = / {
return 302 /web/index.html;
}
# 网页端(public/web/index.html -> dist/web/index.html
location = /web {
return 302 /web/;
}
location = /sys {
return 302 /sys/;
}
location /web/ {
alias /usr/share/nginx/html/web/;
try_files $uri $uri/ /web/index.html;
}
# 后台管理端(dist/index.html,前缀 /sys/
location /sys/ {
alias /usr/share/nginx/html/;
try_files $uri $uri/ /sys/index.html;
try_files $uri $uri/ /index.html;
}
# 1. 先尝试作为静态文件查找
location / {
try_files $uri $uri/ @proxy;
}
# 2. 无法找到的请求(API路径)代理到后端
location @proxy {
# 判断 URI 最后一段是否有扩展名
# 有扩展名返回 404,无扩展名则代理
if ($uri ~ \.[^./]+$) {
return 404;
}
proxy_pass http://116.204.74.41:8000;
proxy_pass http://__API_HOST__:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
# WebSocket 升级头透传(首页会话长连接 /ai-agent/session/wsExecute 依赖握手成功)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_connect_timeout 75s;
proxy_send_timeout 86400s;
proxy_read_timeout 86400s;
}
}
# Gitea HTTP → HTTPS 重定向
server {
listen 80;
server_name gitea.redpowerfuture.com;
client_max_body_size 500M;
if ($badbot) {
return 444;
}
return 301 https://$host$request_uri;
}
# MinIO HTTP/HTTPS(增加 WebSocket 支持)
server {
listen 80;
server_name minio.redpowerfuture.com;
client_max_body_size 500M;
location / {
proxy_pass http://__API_HOST__:9001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 支持
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
}
}
# Consul HTTP(仅 HTTP
server {
listen 80;
server_name consul.redpowerfuture.com;
client_max_body_size 500M;
location / {
proxy_pass http://__API_HOST__:8500;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# MeiliSearch HTTP(仅 HTTP
server {
listen 80;
server_name meilisearch.redpowerfuture.com;
client_max_body_size 500M;
location / {
proxy_pass http://__API_HOST__:7700;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# 项目管理 HTTP(仅 HTTP
server {
listen 80;
server_name pm.redpowerfuture.com;
client_max_body_size 500M;
location / {
proxy_pass http://__API_HOST__:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# jaeger HTTP(仅 HTTP
server {
listen 80;
server_name jaeger.redpowerfuture.com;
client_max_body_size 500M;
location / {
proxy_pass http://__API_HOST__:16686;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# observer HTTP(转发到 18080
server {
listen 80;
server_name observer.redpowerfuture.com;
client_max_body_size 500M;
location / {
proxy_pass http://__API_HOST__:18080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 升级头透传
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400s;
}
}
# ==================== 主站 HTTPS(静态文件 + API 反向代理) ====================
server {
root /usr/share/nginx/html;
index index.html;
client_max_body_size 500M;
listen 443 ssl;
server_name redpowerfuture.com www.redpowerfuture.com;
ssl_certificate /etc/nginx/ssl/scs1779764972146_redpowerfuture.com_server.crt;
ssl_certificate_key /etc/nginx/ssl/scs1779764972146_redpowerfuture.com_server.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
error_page 404 /404.html;
location = /404.html {
internal;
}
location = / {
return 302 /web/index.html;
}
location = /web {
return 302 /web/;
}
location = /sys {
return 302 /sys/;
}
location /web/ {
alias /usr/share/nginx/html/web/;
try_files $uri $uri/ /web/index.html;
}
location /sys/ {
alias /usr/share/nginx/html/;
try_files $uri $uri/ /index.html;
}
location / {
try_files $uri $uri/ @proxy;
}
location @proxy {
if ($uri ~ \.[^./]+$) {
return 404;
}
proxy_pass http://__API_HOST__:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 升级头透传(首页会话长连接 /ai-agent/session/wsExecute 依赖握手成功)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_connect_timeout 75s;
proxy_send_timeout 86400s;
proxy_read_timeout 86400s;
}
}
# ==================== Gitea HTTPS ====================
server {
listen 443 ssl;
server_name gitea.redpowerfuture.com;
client_max_body_size 500M;
ssl_certificate /etc/nginx/ssl/gitea.redpowerfuture.com.pem;
ssl_certificate_key /etc/nginx/ssl/gitea.redpowerfuture.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
if ($badbot) {
return 403;
}
proxy_pass http://__API_HOST__:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 75s;
proxy_send_timeout 86400s;
proxy_read_timeout 86400s;
}
}
+82 -1
View File
@@ -23,16 +23,21 @@
"codemirror": "^6.0.1",
"countup.js": "^2.8.0",
"cropperjs": "^1.6.0",
"dompurify": "^3.4.13",
"echarts": "^5.5.0",
"echarts-gl": "^2.0.9",
"echarts-wordcloud": "^2.1.0",
"element-china-area-data": "^6.1.0",
"element-plus": "^2.10.7",
"file-saver": "^2.0.5",
"highlight.js": "^11.12.0",
"js-cookie": "^3.0.5",
"json-schema-editor": "github:wusij/json-schema#8827d0d6a360c37bc32a8945fb979260bd4a4c85",
"jsplumb": "^2.15.6",
"jszip": "^3.10.1",
"lodash": "^4.17.21",
"marked": "^18.0.9",
"marked-highlight": "^2.2.4",
"mitt": "^3.0.1",
"nprogress": "^0.2.0",
"pinia": "^2.1.7",
@@ -51,7 +56,8 @@
"vue-i18n": "11.1.2",
"vue-router": "^4.3.0",
"vue-simple-uploader": "^1.0.0-beta.5",
"vue-ueditor-wrap": "^3.0.8"
"vue-ueditor-wrap": "^3.0.8",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@types/node": "^20.11.28",
@@ -1943,6 +1949,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.16",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
@@ -3246,6 +3259,15 @@
"ssr-window": "^3.0.0-alpha.1"
}
},
"node_modules/dompurify": {
"version": "3.4.13",
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.13.tgz",
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -4168,6 +4190,15 @@
"node": ">= 0.4"
}
},
"node_modules/highlight.js": {
"version": "11.12.0",
"resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.12.0.tgz",
"integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/hoist-non-react-statics": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz",
@@ -4387,6 +4418,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-schema-editor": {
"version": "1.0.0",
"resolved": "git+ssh://git@github.com/wusij/json-schema.git#8827d0d6a360c37bc32a8945fb979260bd4a4c85",
"integrity": "sha512-XtNGXmlbycpLzfOkzY9p9fimcvVfLXjbxLcUGjRZpgl0g47LJCQ52fhNs4DIEwlTRMJTBYeq0x8POBXMIXARfg==",
"license": "MIT",
"peerDependencies": {
"@element-plus/icons-vue": "^2.3.0",
"element-plus": "^2.5.0",
"vue": "^3.4.0"
}
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -4563,6 +4605,27 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "18.0.9",
"resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.9.tgz",
"integrity": "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/marked-highlight": {
"version": "2.2.4",
"resolved": "https://registry.npmmirror.com/marked-highlight/-/marked-highlight-2.2.4.tgz",
"integrity": "sha512-PZxisNMJDduSjc0q6uvjsnqqHCXc9s0eyzxDO9sB1eNGJnd/H1/Fu+z6g/liC1dfJdFW4SftMwMlLvsBhUPrqQ==",
"license": "MIT",
"peerDependencies": {
"marked": ">=4 <19"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -6207,6 +6270,24 @@
"vue": "^3.0.0"
}
},
"node_modules/vuedraggable": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/vuedraggable/-/vuedraggable-4.1.0.tgz",
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
"license": "MIT",
"dependencies": {
"sortablejs": "1.14.0"
},
"peerDependencies": {
"vue": "^3.0.1"
}
},
"node_modules/vuedraggable/node_modules/sortablejs": {
"version": "1.14.0",
"resolved": "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.14.0.tgz",
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+7 -1
View File
@@ -28,16 +28,21 @@
"codemirror": "^6.0.1",
"countup.js": "^2.8.0",
"cropperjs": "^1.6.0",
"dompurify": "^3.4.13",
"echarts": "^5.5.0",
"echarts-gl": "^2.0.9",
"echarts-wordcloud": "^2.1.0",
"element-china-area-data": "^6.1.0",
"element-plus": "^2.10.7",
"file-saver": "^2.0.5",
"highlight.js": "^11.12.0",
"js-cookie": "^3.0.5",
"json-schema-editor": "github:wusij/json-schema#8827d0d6a360c37bc32a8945fb979260bd4a4c85",
"jsplumb": "^2.15.6",
"jszip": "^3.10.1",
"lodash": "^4.17.21",
"marked": "^18.0.9",
"marked-highlight": "^2.2.4",
"mitt": "^3.0.1",
"nprogress": "^0.2.0",
"pinia": "^2.1.7",
@@ -56,7 +61,8 @@
"vue-i18n": "11.1.2",
"vue-router": "^4.3.0",
"vue-simple-uploader": "^1.0.0-beta.5",
"vue-ueditor-wrap": "^3.0.8"
"vue-ueditor-wrap": "^3.0.8",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@types/node": "^20.11.28",
+212
View File
@@ -0,0 +1,212 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>404 - 页面不存在</title>
<link rel="icon" href="/favicon.ico" />
<style>
:root {
color-scheme: light;
--bg-start: #f5f7fa;
--bg-end: #e8ecf3;
--card-bg: rgba(255, 255, 255, 0.92);
--text-primary: #1f2d3d;
--text-secondary: #6b7280;
--accent: #409eff;
--accent-hover: #66b1ff;
--border: rgba(64, 158, 255, 0.12);
--shadow: 0 20px 50px rgba(31, 45, 61, 0.12);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background: linear-gradient(135deg, var(--bg-start), var(--bg-end));
color: var(--text-primary);
}
.error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
}
.error-card {
width: min(960px, 100%);
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 24px;
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
display: flex;
gap: 48px;
align-items: center;
padding: 56px;
}
.error-content {
flex: 1;
}
.error-code {
margin: 0;
font-size: clamp(64px, 12vw, 110px);
line-height: 1;
color: var(--accent);
letter-spacing: 4px;
}
.error-title {
margin: 20px 0 12px;
font-size: 30px;
line-height: 1.3;
}
.error-message {
margin: 0;
color: var(--text-secondary);
font-size: 16px;
line-height: 1.8;
}
.error-actions {
margin-top: 32px;
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.action-button {
appearance: none;
border: none;
border-radius: 999px;
padding: 12px 22px;
font-size: 15px;
cursor: pointer;
text-decoration: none;
transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.action-button:hover {
transform: translateY(-1px);
}
.action-button.primary {
background: var(--accent);
color: #fff;
box-shadow: 0 12px 24px rgba(64, 158, 255, 0.24);
}
.action-button.primary:hover {
background: var(--accent-hover);
}
.action-button.secondary {
background: #fff;
color: var(--text-primary);
border: 1px solid rgba(31, 45, 61, 0.12);
}
.error-illustration {
flex: 0 0 320px;
display: flex;
align-items: center;
justify-content: center;
}
.illustration-shell {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 32px;
background: linear-gradient(160deg, rgba(64, 158, 255, 0.12), rgba(64, 158, 255, 0.02));
border: 1px solid rgba(64, 158, 255, 0.14);
position: relative;
overflow: hidden;
}
.illustration-shell::before,
.illustration-shell::after {
content: '';
position: absolute;
border-radius: 50%;
background: rgba(64, 158, 255, 0.12);
}
.illustration-shell::before {
width: 180px;
height: 180px;
top: -30px;
right: -30px;
}
.illustration-shell::after {
width: 120px;
height: 120px;
left: -20px;
bottom: -20px;
}
.illustration-center {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(72px, 10vw, 120px);
font-weight: 800;
color: rgba(64, 158, 255, 0.85);
letter-spacing: 6px;
}
@media (max-width: 900px) {
.error-card {
flex-direction: column;
padding: 36px 24px;
gap: 28px;
}
.error-illustration {
flex-basis: auto;
width: min(320px, 100%);
}
.error-title {
font-size: 24px;
}
}
</style>
</head>
<body>
<main class="error-page">
<section class="error-card">
<div class="error-content">
<h1 class="error-code">404</h1>
<h2 class="error-title">抱歉,你访问的页面不存在</h2>
<p class="error-message">
当前地址可能已失效、输入有误,或者对应内容已被移动。你可以返回首页,或回到后台入口继续操作。
</p>
<div class="error-actions">
<a class="action-button primary" href="/">返回首页</a>
<a class="action-button secondary" href="/sys/">进入后台</a>
</div>
</div>
<div class="error-illustration" aria-hidden="true">
<div class="illustration-shell">
<div class="illustration-center">404</div>
</div>
</div>
</section>
</main>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

+11
View File
@@ -0,0 +1,11 @@
{
"version": 1,
"skills": {
"grill-me": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/productivity/grill-me/SKILL.md",
"computedHash": "67e31b63e6e9e454cc2f51cd85b9cf1396ee46379a87c07a1450f33c47c1276a"
}
}
}
+9 -7
View File
@@ -12,7 +12,7 @@ export interface ApiInterfaceQueryParams {
// 创建接口参数
export interface CreateApiInterfaceParams {
platformId: string | number;
platformId: number;
name: string;
code: string;
url: string;
@@ -22,6 +22,7 @@ export interface CreateApiInterfaceParams {
requestConfig?: Record<string, any>;
responseConfig?: Record<string, any>;
limitConfig?: Record<string, any>;
tableDefinition?: Record<string, any>;
}
// 更新接口参数
@@ -50,6 +51,7 @@ export interface ApiInterfaceInfo {
requestConfig?: Record<string, any>;
responseConfig?: Record<string, any>;
limitConfig?: Record<string, any>;
tableDefinition?: Record<string, any>;
createdBy?: string;
createdAt?: number;
updatedBy?: string;
@@ -59,7 +61,7 @@ export interface ApiInterfaceInfo {
// 获取接口列表
export function listApiInterfaces(params: ApiInterfaceQueryParams) {
return request({
url: '/api/interface/controller/listApiInterfaces',
url: '/data-engine/api/interface/controller/listApiInterfaces',
method: 'get',
params,
});
@@ -68,7 +70,7 @@ export function listApiInterfaces(params: ApiInterfaceQueryParams) {
// 获取接口详情
export function getApiInterface(id: string) {
return request({
url: '/api/interface/controller/getApiInterface',
url: '/data-engine/api/interface/controller/getApiInterface',
method: 'get',
params: { id },
});
@@ -77,7 +79,7 @@ export function getApiInterface(id: string) {
// 创建接口
export function createApiInterface(data: CreateApiInterfaceParams) {
return request({
url: '/api/interface/controller/createApiInterface',
url: '/data-engine/api/interface/controller/createApiInterface',
method: 'post',
data,
});
@@ -86,7 +88,7 @@ export function createApiInterface(data: CreateApiInterfaceParams) {
// 修改接口
export function updateApiInterface(data: UpdateApiInterfaceParams) {
return request({
url: '/api/interface/controller/updateApiInterface',
url: '/data-engine/api/interface/controller/updateApiInterface',
method: 'put',
data,
});
@@ -95,7 +97,7 @@ export function updateApiInterface(data: UpdateApiInterfaceParams) {
// 更新接口状态
export function updateApiInterfaceStatus(data: UpdateApiInterfaceStatusParams) {
return request({
url: '/api/interface/controller/updateApiInterfaceStatus',
url: '/data-engine/api/interface/controller/updateApiInterfaceStatus',
method: 'put',
data,
});
@@ -104,7 +106,7 @@ export function updateApiInterfaceStatus(data: UpdateApiInterfaceStatusParams) {
// 删除接口
export function deleteApiInterface(id: string) {
return request({
url: '/api/interface/controller/deleteApiInterface',
url: '/data-engine/api/interface/controller/deleteApiInterface',
method: 'delete',
params: { id },
});
+13 -12
View File
@@ -26,8 +26,7 @@ export interface CreateDatasourcePlatformParams {
requestTimeoutMs?: number;
maxRetries?: number;
retryDelayMs?: number;
createdBy?: string;
updatedBy?: string;
authConfig?: Record<string, any>;
}
// 更新平台参数
@@ -40,7 +39,6 @@ export interface UpdateDatasourcePlatformParams extends Partial<CreateDatasource
export interface UpdateDatasourcePlatformStatusParams {
id: string;
Status: string;
updatedBy?: string;
}
// 平台信息
@@ -54,22 +52,25 @@ export interface DatasourcePlatformInfo {
apiBaseUrl: string;
authType: string;
authTypeName: string;
token?: string;
apiKey?: string;
clientId?: string;
clientSecret?: string;
authConfig?: Record<string, any>;
rateLimitPerMinute?: number;
rateLimitPerHour?: number;
concurrencyLimit?: number;
requestTimeoutMs?: number;
maxRetries?: number;
retryDelayMs?: number;
createdBy?: string;
createdAt?: number;
updatedBy?: string;
updatedAt?: number;
}
// 获取平台列表
export function listDatasourcePlatforms(params: DatasourcePlatformQueryParams) {
return request({
url: '/datasource/platform/controller/listDatasourcePlatforms',
url: '/data-engine/datasource/platform/controller/listDatasourcePlatforms',
method: 'get',
params,
});
@@ -78,7 +79,7 @@ export function listDatasourcePlatforms(params: DatasourcePlatformQueryParams) {
// 创建平台
export function createDatasourcePlatform(data: CreateDatasourcePlatformParams) {
return request({
url: '/datasource/platform/controller/createDatasourcePlatform',
url: '/data-engine/datasource/platform/controller/createDatasourcePlatform',
method: 'post',
data,
});
@@ -87,7 +88,7 @@ export function createDatasourcePlatform(data: CreateDatasourcePlatformParams) {
// 更新平台
export function updateDatasourcePlatform(data: UpdateDatasourcePlatformParams) {
return request({
url: '/datasource/platform/controller/updateDatasourcePlatform',
url: '/data-engine/datasource/platform/controller/updateDatasourcePlatform',
method: 'put',
data,
});
@@ -96,7 +97,7 @@ export function updateDatasourcePlatform(data: UpdateDatasourcePlatformParams) {
// 删除平台
export function deleteDatasourcePlatform(id: string) {
return request({
url: '/datasource/platform/controller/deleteDatasourcePlatform',
url: '/data-engine/datasource/platform/controller/deleteDatasourcePlatform',
method: 'delete',
params: { id },
});
@@ -105,7 +106,7 @@ export function deleteDatasourcePlatform(id: string) {
// 获取平台详情
export function getDatasourcePlatform(id: string) {
return request({
url: '/datasource/platform/controller/getDatasourcePlatform',
url: '/data-engine/datasource/platform/controller/getDatasourcePlatform',
method: 'get',
params: { id },
});
@@ -114,7 +115,7 @@ export function getDatasourcePlatform(id: string) {
// 根据编码获取平台信息
export function getPlatformByCode(platformCode: string) {
return request({
url: '/datasource/platform/controller/getPlatformByCode',
url: '/data-engine/datasource/platform/controller/getPlatformByCode',
method: 'get',
params: { platformCode },
});
@@ -123,7 +124,7 @@ export function getPlatformByCode(platformCode: string) {
// 更新平台状态
export function updateDatasourcePlatformStatus(data: UpdateDatasourcePlatformStatusParams) {
return request({
url: '/datasource/platform/controller/updateDatasourcePlatformStatus',
url: '/data-engine/datasource/platform/controller/updateDatasourcePlatformStatus',
method: 'put',
data,
});
+234
View File
@@ -0,0 +1,234 @@
import request from '/@/utils/request';
/** 字段定义 */
export interface FieldDefine {
id?: number | string;
fieldCode: string;
fieldName: string;
fieldType: 'STRING' | 'INT' | 'FLOAT' | 'BIGINT' | 'DECIMAL' | 'DATE' | 'DATETIME' | 'BOOLEAN';
dataType?: string;
fieldRole: 'DIMENSION' | 'INDICATOR';
isAggregatable?: boolean;
isFilterable?: boolean;
isQueryable?: boolean;
isSortable?: boolean;
defaultAggregate?: string;
groupName?: string;
sortOrder: number;
unit?: string;
expression?: string;
status?: string;
validAggregates?: string[];
}
/** 报表+字段保存请求参数 */
export interface ReportWithFieldsData {
businessCode: string;
reportCode: string;
reportName: string;
description?: string;
status?: string;
statTableName: string;
statTableComment?: string;
dateField?: string;
primaryKeys?: string[];
conflictKeys?: string[];
fields: FieldDefine[];
}
export interface ExtractConfigSaveData {
id?: number;
businessCode: string;
reportCode: string;
extractCode: string;
extractName: string;
sourceTableName: string;
sourceTableAlias?: string;
targetTableName: string;
isEnabled: boolean;
extractType: string;
extractMode: string;
extractKeyField?: string;
batchSize?: number;
status?: string;
groupByFields?: string[];
filterExpression?: string;
operator?: string;
}
// --- 业务 Business ---
export function listBusinesses() {
return request({
url: '/data-engine/report/businesses',
method: 'get',
});
}
export function getBusiness(id: number) {
return request({
url: '/data-engine/report/business',
method: 'get',
params: { id },
});
}
export function saveBusiness(data: { id?: number; businessCode: string; businessName: string }) {
return request({
url: '/data-engine/report/business/save',
method: 'post',
data,
});
}
export function deleteBusiness(id: number) {
return request({
url: '/data-engine/report/business',
method: 'delete',
params: { id },
});
}
// --- 报表 Report ---
export function listReports(businessCode: string, pageNum: number, pageSize: number, reportName?: string) {
const params: any = { businessCode, pageNum, pageSize };
if (reportName) params.reportName = reportName;
return request({
url: '/data-engine/report/reports',
method: 'get',
params,
});
}
export function getReport(id: number) {
return request({
url: '/data-engine/report/report',
method: 'get',
params: { id },
});
}
export function saveReport(data: {
businessCode: string;
reportCode: string;
reportName: string;
description?: string;
status?: string;
statTableName: string;
statTableComment?: string;
dateField?: string;
primaryKeys?: string[];
conflictKeys?: string[];
}) {
return request({
url: '/data-engine/report/report/save',
method: 'post',
data,
});
}
/** 保存报表及字段(新建 / 编辑统一入口,全量替换字段) */
export function saveReportWithFields(data: ReportWithFieldsData) {
return request({
url: '/data-engine/report/report/saveWithFields',
method: 'post',
data,
});
}
export function deleteReport(id: number) {
return request({
url: '/data-engine/report/report',
method: 'delete',
params: { id },
});
}
/** 获取某业务下所有报表(不分页,供下拉选择) */
export function listAllReports(businessCode: string) {
return request({
url: '/data-engine/report/reports',
method: 'get',
params: { businessCode, pageNum: 1, pageSize: 9999 },
});
}
// --- 查询相关类型 ---
export interface ReportFilter {
fieldCode: string;
operator: string;
value: string;
}
export interface ReportIndicator {
fieldCode: string;
aggregate: string;
alias: string;
}
export interface ReportOrderBy {
fieldCode: string;
direction: 'ASC' | 'DESC';
}
export interface ReportQueryParams {
businessCode: string;
reportCode: string;
dimensions: string[];
indicators: ReportIndicator[];
filters: ReportFilter[];
timeRange: {
startDate: string;
endDate: string;
};
timeGroup: string;
orderBy: ReportOrderBy[];
page: number;
pageSize: number;
}
/** 数据查询 */
export function reportQuery(data: ReportQueryParams) {
return request({
url: '/data-engine/report/query',
method: 'post',
data,
});
}
/** 按天回刷(支持单天或日期范围) */
export function execDailyBackfill(data: { businessCode: string; reportCode: string; startDate: string; endDate: string }) {
return request({
url: '/data-engine/report/backfill',
method: 'post',
data,
});
}
// --- 抽取配置 ExtractConfig ---
export function getExtractConfig(id: number) {
return request({
url: '/data-engine/report/extractConfig',
method: 'get',
params: { id },
});
}
export function saveExtractConfig(data: Record<string, any>) {
return request({
url: '/data-engine/report/extractConfig/save',
method: 'post',
data,
});
}
export function deleteExtractConfig(id: number) {
return request({
url: '/data-engine/report/extractConfig',
method: 'delete',
params: { id },
});
}
+53 -9
View File
@@ -37,6 +37,9 @@ export interface NodeLibraryItem {
nodeName: string;
modelType: number;
skillOption: boolean;
promptOption: boolean;
isSaveFile: boolean;
patchLayout: boolean;
formConfig: NodeLibraryFormItem[];
modelConfig: NodeLibraryModelConfig[];
}
@@ -83,21 +86,16 @@ export interface CreationTreeItem {
// 新的执行列表数据结构
export interface ExecutionItem {
id: string;
timestamp: string;
content: string;
label: string;
}
export interface ExecutionFlowItem {
flowName: string;
Id?: number | string;
sessionId?: string;
items: ExecutionItem[];
type?: string;
}
export interface ExecutionTreeItem {
createDate: string;
flows: ExecutionFlowItem[];
items: ExecutionItem[];
}
export interface CreationListData {
@@ -149,10 +147,11 @@ export function getCreationList(params: CreationListParams, requestOptions?: Req
}) as Promise<CreationListResponse>;
}
export function getExecutionList(requestOptions?: RequestOptions) {
export function getExecutionList(params?: Record<string, any>, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/flow/execution/list',
method: 'get',
params,
requestOptions,
}) as Promise<ExecutionListResponse>;
}
@@ -332,6 +331,7 @@ export interface ExecuteFlowParams {
sessionId?: string;
skillName?: string;
resultUrl?: string;
templates?: any[];
}
export function executeFlow(data: ExecuteFlowParams | FormData, requestOptions?: RequestOptions) {
@@ -344,3 +344,47 @@ export function executeFlow(data: ExecuteFlowParams | FormData, requestOptions?:
requestOptions,
});
}
/** 删除工作空间结果(单个文件) */
export function deleteExecutionResult(data: { id: string; content: string }, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/flow/execution/deleteResult',
method: 'delete',
data,
requestOptions,
});
}
/** 删除会话记录 */
export function deleteExecutionSession(data: { id: string }, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/flow/execution/deleteSession',
method: 'delete',
data,
requestOptions,
});
}
export interface SessionListResponse {
code: number;
message: string;
data: SessionListItem[];
}
export interface SessionListItem {
id: string;
flowName: string;
sessionId: string;
createDate: string;
status?: number; // 1=执行中, 2=成功, 3=失败
}
/** 获取会话记录列表 */
export function getSessionList(params?: Record<string, any>, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/flow/execution/sessionList',
method: 'get',
params,
requestOptions,
}) as Promise<SessionListResponse>;
}
@@ -119,18 +119,22 @@ export interface ModelModuleItem {
/** 会话开关状态(列表接口返回,0 关 1 开;会话开关接口就绪后生效) */
chatSessionEnabled?: number;
enabled: number;
/** 调用模式:0-同步 1-异步 2-流式 */
callMode?: number;
maxConcurrency: number;
queueLimit: number;
queueLimit?: number;
timeoutMs?: number;
timeoutSeconds?: number;
expectedSeconds?: number;
retryTimes: number;
retryQueueMaxSeconds: number;
retryQueueMaxSeconds?: number;
autoCleanSeconds: number;
remark?: string;
headMsg?: string;
headMsg?: string | Record<string, string>;
form?: ModelFormEntry[] | Record<string, { value: string }>;
requestMapping?: Record<string, unknown>;
requiredFields?: string[];
firstFrame?: string;
lastFrame?: string;
responseMapping?: Record<string, unknown>;
}
@@ -150,27 +154,32 @@ export interface CreateModelParams {
operatorName?: string;
baseUrl: string;
httpMethod?: string;
headMsg?: string;
headMsg?: Record<string, string>;
isPrivate: number;
enabled: number;
isChatModel: number;
/** 调用模式:0-同步 1-异步 2-流式,默认0 */
callMode: number;
apiKey?: string;
form: ModelFormEntry[];
requestMapping?: Record<string, unknown>;
requiredFields?: string[];
firstFrame?: string;
lastFrame?: string;
responseMapping?: Record<string, unknown>;
responseBody?: Record<string, unknown>;
responseBody?: string;
extendMapping?: Record<string, unknown>;
responseTokenField?: string;
tokenConfig?: Record<string, unknown>;
queryConfig?: Record<string, unknown>;
streamConfig?: Record<string, unknown>;
maxConcurrency?: number;
queueLimit?: number;
timeoutSeconds: number;
expectedSeconds: number;
expectedSeconds?: number;
retryTimes?: number;
retryQueueMaxSeconds: number;
retryQueueMaxSeconds?: number;
autoCleanSeconds: number;
remark?: string;
}
export interface ModelConfigTypeItem {
+243
View File
@@ -0,0 +1,243 @@
import request from '/@/utils/request';
/** Token 映射(三个字段存的是对应响应映射中的 key,不是 JSON Path */
export interface TokenMapping {
promptTokens: string;
completionTokens: string;
totalTokens: string;
}
/** 异步任务映射 */
export interface AsyncTaskMapping {
url: string;
httpMethod: string;
requestHeadMapping: Record<string, unknown>;
requestBodyMapping?: Record<string, unknown>;
responseMapping: Record<string, unknown>;
taskId: string;
taskStatus: string;
taskStatusPending: string;
taskStatusRunning: string;
taskStatusSuccess: string;
taskStatusFailed: string;
taskStatusCancel: string;
taskStatusUnknown: string;
}
/** 列表查询参数 */
export interface ListModelManageParams {
pageNum: number;
pageSize: number;
modelName?: string;
modelType?: number;
}
/** 列表项 */
export interface ModelManageItem {
id: string;
tenantId: number;
creator: string;
createdAt: string;
updater: string;
updatedAt: string;
deletedAt: null | string;
modelSupplier: number;
modelName: string;
modelType: number;
baseUrl: string;
responseType: number;
apiKey: string;
enabled: boolean;
chatModel?: boolean;
maxConcurrency: number;
maxTokens: number;
tokenPredictPrice: number;
requestHeadMapping: Record<string, unknown>;
requestBodyMapping: Record<string, unknown>;
requestBusinessFieldMapping?: Record<string, string>;
error_message_mapping?: Record<string, unknown>;
responseMapping: Record<string, unknown>;
responseBodyMapping: Record<string, string>;
responseBusinessFieldMapping?: Record<string, string>;
minDuration?: number;
priceConfig?: unknown;
tokenMapping?: TokenMapping;
asyncTaskMapping?: AsyncTaskMapping;
lastFrame?: string;
maxDuration: number;
tokenPredictPriceUnit: string;
// 以下旧字段兼容
httpMethod?: string;
systemModel?: boolean;
PrivateModel?: boolean;
invokeType?: number;
requestMapping?: Record<string, unknown>;
}
/** 列表响应 */
export interface ListModelManageResponse {
code: number;
message: string;
data: {
list: ModelManageItem[];
total: number;
};
}
/** 模型类型树节点(支持二级) */
export interface ModelTypeTreeNode {
value: number;
label: string;
children?: ModelTypeTreeNode[];
}
/** 模型类型列表响应 */
export interface ModelManageTypeResponse {
code: number;
message: string;
data: {
list: ModelTypeTreeNode[];
};
}
/** 供应商列表项 */
export interface ModelSupplierItem {
value: number;
label: string;
}
/** 供应商列表响应 */
export interface ModelSupplierListResponse {
code: number;
message: string;
data: {
list: ModelSupplierItem[];
};
}
/**
* 获取模型配置列表(v2
*/
export function listModelManage(params: ListModelManageParams) {
return request({
url: '/model-gateway/model/manage/listModelManage',
method: 'get',
params,
});
}
/**
* 获取模型类型列表(v2,树形结构,支持一级/二级)
*/
export function getModelManageTypes() {
return request({
url: '/model-gateway/model/manage/modelType',
method: 'get',
});
}
/**
* 删除模型配置
*/
export function deleteModelManage(id: string) {
return request({
url: '/model-gateway/model/manage/deleteModelManage',
method: 'delete',
data: { id },
});
}
/**
* 获取模型供应商列表
*/
export function getModelSupplierList() {
return request({
url: '/model-gateway/model/manage/modelSupplier',
method: 'get',
});
}
/** 创建模型配置参数 */
export interface CreateModelManageParams {
modelSupplier: number;
modelName: string;
modelType: number;
baseUrl: string;
responseType: number;
apiKey?: string;
enabled?: boolean;
chatModel?: boolean;
maxConcurrency?: number;
maxTokens?: number;
tokenPredictPrice?: number;
requestHeadMapping?: Record<string, unknown>;
requestBodyMapping?: Record<string, unknown>;
requestBusinessFieldMapping?: Record<string, string>;
error_message_mapping?: Record<string, unknown>;
responseMapping?: Record<string, unknown>;
responseBodyMapping?: Record<string, string>;
responseBusinessFieldMapping?: Record<string, string>;
minDuration?: number;
priceConfig?: unknown;
tokenMapping?: TokenMapping;
asyncTaskMapping?: AsyncTaskMapping;
lastFrame?: string;
maxDuration?: number;
tokenPredictPriceUnit?: string;
}
/** 更新模型配置参数 */
export interface UpdateModelManageParams extends Partial<CreateModelManageParams> {
id: string;
}
/** 详情响应 */
export interface ModelManageDetailResponse {
code: number;
message: string;
data: ModelManageItem;
}
/**
* 获取模型配置详情
*/
export function getModelManage(id: string) {
return request({
url: '/model-gateway/model/manage/getModelManage',
method: 'get',
params: { id },
});
}
/**
* 创建模型配置
*/
export function createModelManage(data: CreateModelManageParams) {
return request({
url: '/model-gateway/model/manage/createModelManage',
method: 'post',
data,
});
}
/**
* 更新模型配置
*/
export function updateModelManage(data: UpdateModelManageParams) {
return request({
url: '/model-gateway/model/manage/updateModelManage',
method: 'put',
data,
});
}
/**
* 获取当前会话模型(普通对话执行时使用的模型 id)
* 返回兼容 v2 惯例:data.modelManage.id(亦兼容 data.id / data.modelId
*/
export function getChatModel() {
return request({
url: '/model-gateway/model/manage/getChatModel',
method: 'get',
});
}
+41 -2
View File
@@ -11,7 +11,7 @@ export interface PromptItem {
deletedAt?: string | null;
nodeType: string;
prompt: string;
sourceType: number; // 0-自定义 1-公共
sourceType: number; // 1-管理员 2-用户
}
export interface PromptListResponse {
@@ -23,6 +23,7 @@ export interface PromptListParams {
pageNum?: number;
pageSize?: number;
keyword?: string;
nodeType?: string;
}
export interface CreatePromptParams {
@@ -31,6 +32,23 @@ export interface CreatePromptParams {
sourceType: number;
}
export interface CheckIsSuperAdminResponse {
isSuperAdmin: boolean;
}
export interface CreatePromptResponse {
id?: number | string;
tenantId?: number;
creator?: string;
createdAt?: string;
updater?: string;
updatedAt?: string;
deletedAt?: string | null;
nodeType?: string;
prompt?: string;
sourceType?: number;
}
export interface UpdatePromptParams extends CreatePromptParams {
id: number | string;
}
@@ -65,6 +83,17 @@ export function getNodeLibraryList() {
});
}
/**
* 获取提示词列表(根据节点类型)
*/
export function getPromptList(params: PromptListParams) {
return request<PromptListResponse>({
url: '/ai-agent/node/prompt/list',
method: 'get',
params,
});
}
/**
* 获取当前用户提示词列表
*/
@@ -80,7 +109,7 @@ export function getMyPromptList(params: PromptListParams) {
* 创建提示词
*/
export function createPrompt(data: CreatePromptParams) {
return request({
return request<CreatePromptResponse>({
url: '/ai-agent/node/prompt/create',
method: 'post',
data,
@@ -108,3 +137,13 @@ export function deletePrompt(id: number | string) {
data: { id },
});
}
/**
* 判断当前用户是否为超级管理员
*/
export function checkIsSuperAdmin() {
return request<CheckIsSuperAdminResponse>({
url: '/admin-go/api/v1/system/user/checkIsSuperAdmin',
method: 'get',
});
}
+169
View File
@@ -0,0 +1,169 @@
import request from '/@/utils/request';
import type { RequestOptions } from '/@/utils/request';
// ===== 节点库(V2 结构,workflow 专用;creation 继续使用 creation/index.ts 的旧结构)=====
export interface NodeLibraryPresetOption {
value?: unknown;
field: string;
label: string;
type: string;
required: boolean;
options?: Array<{
key: string;
value: string;
config?: NodeLibraryPresetOption[] | null;
}> | null;
// 运行时已在用的扩展字段(补齐声明消除 TS 报错)
isFormField?: boolean;
constraint?: any;
defaultValue?: any;
}
export interface NodeLibraryItem {
key: string;
name: string;
group: string;
sort: number;
desc?: string;
batchExecOption: boolean;
// 是否允许该节点内「引用上级节点输出」多选(true 时所有引用下拉可多选;节点库返回,仅 model 节点 true
isMultiParameter: boolean;
// 前置/后置方法配置定义(结构同 presetOption,值为数组;仅 model 节点提供)
preToolOption: NodeLibraryPresetOption[] | null;
postToolOption: NodeLibraryPresetOption[] | null;
skillOption: boolean;
promptOption: boolean;
negativePromptOption: boolean;
isSaveFileOption: boolean;
formConfigOption: boolean;
modelConfigOption: boolean;
// 节点自带输出项([{ field, label }]):空/null 表示无输出;非空时该节点可作为下游节点的引用来源
outputField?: { field: string; label: string }[] | null;
presetOption: NodeLibraryPresetOption[] | null;
// 是否支持贴片布局(节点库返回,仅 model 节点 true):控制节点配置面板「贴片布局」开关显示
patchLayout?: boolean;
}
export interface NodeLibraryGroup {
group: { key: string; name: string };
nodes: NodeLibraryItem[];
}
export function getNodeLibraryList() {
return request({
url: '/ai-agent/node/library/list',
method: 'get',
}) as Promise<{ code: number; message: string; data: { groups: NodeLibraryGroup[] } }>;
}
// ===== 模型列表(不按类型过滤,显示全部)=====
export interface WorkflowModelItem {
id: string;
modelName: string;
modelType: number | string;
baseUrl?: string;
enabled?: number | boolean;
isOwner?: number;
// true = 系统内置模型(选它需填 API Key 转用户模型);false/缺省 = 用户模型
systemModel?: boolean;
// 模型请求参数模板(选择模型后渲染为递归表单,与 DSL modelRequestParams 结构一致)
requestBodyMapping?: Record<string, any>;
[key: string]: any;
}
export function getWorkflowModelList(params?: {
pageNum: number;
pageSize: number;
modelName?: string;
isSameType?: boolean;
modelType?: string | number;
}) {
return request({
url: '/model-gateway/model/manage/listModelManage',
method: 'get',
params,
}) as Promise<{ code: number; message: string; data: { list: WorkflowModelItem[]; total: number } }>;
}
// ===== 技能列表 =====
export interface WorkflowSkillItem {
id: number;
name: string;
description: string;
category: string;
[key: string]: any;
}
export function getWorkflowSkillList(params?: { pageNum: number; pageSize: number; keyword?: string }) {
return request({
url: '/ai-agent/skill/user/listUser',
method: 'get',
params,
}) as Promise<{ code: number; message: string; data: { list: WorkflowSkillItem[]; total: number } }>;
}
// ===== 子流程:引入已有工作流(独立实现,不依赖 creation=====
export interface SubFlowWorkflowItem {
id: string;
// 后端列表接口返回 flowName(name 仅为前端兜底)
name?: string;
flowName?: string;
description?: string;
[key: string]: any;
}
// 获取"我的工作流"列表(IsOwn=true 只取本人创建,供 sub_flow 节点选择引入)
// 响应结构多形态容错在调用方处理(扁平 list 优先,否则按 isAdmin 分支读取 listFlowTemplateRes / listFlowUserRes
export function getSubFlowWorkflowList(params?: {
pageNum: number;
pageSize: number;
keyword?: string;
IsOwn?: boolean;
}) {
return request({
url: '/ai-agent/flow/user/list',
method: 'get',
params,
}) as Promise<{ code: number; message: string; data: any }>;
}
// 获取工作流详情(取开始节点 outputConfig 构建 sub_flow 引入参数)
export function getSubFlowWorkflowDetail(id: string) {
return request({
url: '/ai-agent/flow/user/get',
method: 'get',
params: { id },
}) as Promise<{ code: number; message: string; data: any }>;
}
// 工作空间文件下载(首页工作流/工作空间专用):与 creation 的 downloadToFile 同一后端接口,
// 但工作空间文件可能较大(视频/音频),下载超时放宽到 5 分钟;不改动 creation 共享 API 的超时
export function downloadWorkspaceFile(data: { fileURL: string }, requestOptions?: RequestOptions) {
return request({
url: '/oss/file/downloadToBrowser',
method: 'post',
data,
responseType: 'blob',
timeout: 5 * 60 * 1000,
requestOptions,
});
}
// 与后端 UpdateFlowUserReq 对齐的更新树:id/flowName/description/flowContent/subFlows
// subFlows 递归嵌套,一次 PUT 更新整棵「主流程 + 嵌套子流程」树);独立实现,不动内容创作的 API
export interface UpdateFlowUserTree {
id: string;
flowName: string;
description: string;
flowContent: any;
subFlows?: UpdateFlowUserTree[];
}
export function updateWorkflowFromTemplate(data: UpdateFlowUserTree, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/flow/user/update',
method: 'put',
data,
requestOptions,
});
}
+123
View File
@@ -0,0 +1,123 @@
import request, { type RequestOptions } from '/@/utils/request';
// ===== 会话管理(/ai-agent/session/*=====
// 后端结构:
// ListSessionReq { page } → ListSessionRes { list: VOSession[], total }
// GetSessionInfoReq { page, sessionId } → GetSessionInfoRes { list: VOSessionInfoResult[], total }
// DeleteSessionReq { sessionId }POST,级联删除普通对话结果,工作流结果保留)
// 说明:会话为「会话 → 会话内结果(工作流/普通对话混排,按时间倒序)」模型。
export interface VOSession {
id: string;
sessionName: string;
createdAt: string;
}
export interface VOSessionInfoResult {
id: string;
type: 'workflow' | 'chat';
status: number; // 1-运行中, 2-成功, 3-失败
flowId: string;
requestParams: Record<string, any>;
resultFileUrl: string; // 产出文件地址(单文件;工作流执行成功时后端填写,空串表示无产出)
resultContent: string; // 回复文本内容(非文件地址)
totalTokens: number;
totalFee: number;
errorMsg: string;
createdAt: string;
}
export function getSessionListV2(params?: Record<string, any>, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/session/list',
method: 'get',
params,
requestOptions,
}) as Promise<{ code: number; message: string; data: { list: VOSession[]; total: number } }>;
}
export function getSessionResults(sessionId: string, params?: Record<string, any>, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/session/get',
method: 'get',
params: { sessionId, ...params },
requestOptions,
}) as Promise<{ code: number; message: string; data: { list: VOSessionInfoResult[]; total: number } }>;
}
export function deleteSessionV2(sessionId: string, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/session/delete',
method: 'delete',
data: { sessionId },
requestOptions,
});
}
// 删除会话内的对话记录(AI 回复 / 用户问题单条或多条),传 Ids 数组,每项为 { id, type }
// 注意:记录 id 为 19 位雪花大整数,超出 JS Number 安全范围,必须用 string 形式传递,否则精度丢失
export function deleteSessionRecord(ids: { id: string; type: string }[], requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/session/deleteRecord',
method: 'post',
data: { ids },
requestOptions,
});
}
// ===== 工作空间结果列表(/ai-agent/session/resultList=====
// 与内容创作 execution/list 同结构:data.tree(按 createDate 分组)+ data.imgAddressPrefix
export interface ExecutionItem {
// 兼容旧平铺结构;resultList 新结构下 items 无独立 id,删除按流程记录 flow.Id
id?: string;
timestamp?: string;
content: string;
label: string;
type?: string;
}
// resultList 新两级结构:createDate → flows(流程分组)→ items(文件)
export interface ExecutionFlow {
flowName: string;
Id: string; // 流程结果记录 id(雪花)
sessionId: string;
items: ExecutionItem[];
}
export interface ExecutionTreeItem {
createDate: string;
// 新结构:日期下按流程分组;兼容旧平铺结构(无 flows 时回落 items
flows?: ExecutionFlow[];
items?: ExecutionItem[];
}
export interface ExecutionListData {
tree: ExecutionTreeItem[];
imgAddressPrefix: string;
}
export interface ExecutionListResponse {
code: number;
message: string;
data: ExecutionListData;
}
/** 获取工作空间结果列表(结果树) */
export function getWorkspaceResultList(params?: Record<string, any>, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/session/resultList',
method: 'get',
params,
requestOptions,
}) as Promise<ExecutionListResponse>;
}
/** 删除工作空间结果(单个文件)——工作流域独立接口,不依赖内容创作域 */
export function deleteWorkspaceResult(data: { id: string; content: string }, requestOptions?: RequestOptions) {
return request({
url: '/ai-agent/session/resultDelete',
method: 'delete',
data,
requestOptions,
});
}
+9
View File
@@ -50,3 +50,12 @@ export function deleteTenant(ids: number[]) {
data: { ids },
});
}
// 获取租户详情(含名称和余额)
export function getTenantDetails(tenantId: number) {
return request({
url: '/admin-go/api/v1/system/tenant/getTenantDetails',
method: 'get',
params: { tenantId },
});
}
+16
View File
@@ -70,6 +70,22 @@ export function deleteUser(ids: number[]) {
});
}
export function recharge(data: object) {
return request({
url: '/shop-user-trade/account/controller/recharge',
method: 'post',
data: data,
});
}
export function getWalletLogs(userId: number, pageNum: number, pageSize: number) {
return request({
url: '/shop-user-trade/account/controller/logs',
method: 'get',
params: { userId, pageNum, pageSize },
});
}
export function checkIsSuperAdmin() {
return request({
url: '/admin-go/api/v1/system/user/checkIsSuperAdmin',
+44
View File
@@ -0,0 +1,44 @@
import request from '/@/utils/request';
/** 计价对象枚举 */
export function getSubjects() {
return request({
url: '/shop-user-trade/pricing/controller/subjects',
method: 'get',
});
}
/** 计价配置详情 */
export function getPricingConfig(params: { subjectType: string; subjectId: string }) {
return request({
url: '/shop-user-trade/pricing/controller/config/get',
method: 'get',
params,
});
}
/** 计价对象可选计费方式(workflow/business 使用;model 计价方式由配置规则决定,不调用) */
export function getChargeModes(params: { subjectType: string; subjectId: string }) {
return request({
url: '/shop-user-trade/pricing/controller/subjects/charge-modes',
method: 'get',
params,
});
}
/** 新增/更新计价配置(id > 0 更新,0 新增;rules 传费率对象;id 兼容雪花字符串避免精度丢失) */
export function savePricingConfig(data: {
id: string | number;
subjectType: string;
subjectId: string;
rules: object;
minBalance?: number;
currency?: string;
enabled?: number;
}) {
return request({
url: '/shop-user-trade/pricing/controller/config/save',
method: 'post',
data,
});
}
@@ -0,0 +1,418 @@
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="560px"
:close-on-click-modal="false"
destroy-on-close
@update:model-value="$emit('update:visible', $event)"
@closed="handleClosed"
>
<el-form ref="formRef" :model="form" label-width="110px" size="small">
<!-- ===== 基础信息 ===== -->
<el-divider content-position="left">基础信息</el-divider>
<el-form-item v-if="showKeyField" label="字段英文名">
<el-input v-model="form.nodeKey" placeholder="字段名称(key" clearable />
</el-form-item>
<el-form-item v-if="!isArrayElement" label="字段中文名">
<el-input v-model="form.label" placeholder="表单中显示的字段名称" clearable />
</el-form-item>
<el-form-item v-if="!isArrayElement" label="字段描述">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="此字段的用途说明" clearable />
</el-form-item>
<el-form-item label="字段类型">
<el-select v-model="form.jsonType" @change="onJsonTypeChange">
<el-option label="字符串" value="string" />
<el-option label="数字" value="number" />
<el-option label="布尔" value="boolean" />
<el-option label="空值" value="null" />
<el-option label="对象" value="object" />
<el-option label="数组" value="array" />
</el-select>
<div class="form-hint">JSON 数据类型</div>
</el-form-item>
<!-- ===== 表单属性 & 约束规则仅标量类型 ===== -->
<template v-if="jsonTypeIsScalar">
<el-divider content-position="left">表单属性</el-divider>
<el-form-item label="表单显示">
<el-switch v-model="form.isForm" />
<span class="form-hint">{{ form.isForm ? '在表单中展示' : '隐藏字段' }}</span>
</el-form-item>
<template v-if="form.isForm">
<el-form-item label="表单控件">
<el-select v-model="form.fieldType" @change="onTypeChange">
<el-option v-for="opt in fieldTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<div class="form-hint">此字段在表单中以什么组件展示</div>
</el-form-item>
<el-form-item label="必填项">
<el-switch v-model="form.required" />
</el-form-item>
</template>
<el-form-item label="默认值">
<el-input v-model="form.defaultValue" placeholder="留空则无默认值" clearable />
</el-form-item>
<el-divider content-position="left">约束规则</el-divider>
<el-form-item v-if="form.fieldType === 'select'" label="选项列表">
<div class="options-editor">
<div v-for="(opt, idx) in form.options" :key="idx" class="option-row">
<el-input v-model="form.options[idx]" size="small" placeholder="选项值" />
<el-button size="small" text type="danger" @click="form.options.splice(idx, 1)">×</el-button>
</div>
<el-button size="small" class="add-option-btn" @click="form.options.push('')"
><el-icon><Plus /></el-icon> 添加选项</el-button
>
</div>
</el-form-item>
<template v-if="form.fieldType === 'string' || form.fieldType === 'textarea'">
<el-form-item label="最小长度"
><el-input-number
v-model="form.constraint.minLength"
:min="0"
:step="100"
controls-position="right"
placeholder="不限"
style="width: 100%"
/></el-form-item>
<el-form-item label="最大长度"
><el-input-number
v-model="form.constraint.maxLength"
:min="0"
:step="1000"
controls-position="right"
placeholder="不限"
style="width: 100%"
/></el-form-item>
<el-form-item label="正则验证"><el-input v-model="form.constraint.pattern" placeholder="如 ^https?://" clearable /></el-form-item>
</template>
<template v-if="form.fieldType === 'upload'">
<el-form-item label="总数量">
<el-input-number v-model="form.constraint.uploadTotalMaxCount" :min="1" controls-position="right" placeholder="不限" style="width:100%" />
</el-form-item>
<el-form-item label="总容量(MB)">
<el-input-number v-model="form.constraint.uploadTotalMaxSize" :min="1" controls-position="right" placeholder="不限" style="width:100%" />
</el-form-item>
<el-divider content-position="left">按格式限制可选</el-divider>
<div class="upload-rules">
<div v-for="(rule, idx) in uploadRules" :key="idx" class="upload-rule-row">
<el-input v-model="rule.format" size="small" placeholder="格式(如 jpg" style="width: 90px" />
<el-input-number v-model="rule.maxSize" :min="1" controls-position="right" size="small" placeholder="大小(MB)" style="width: 140px" />
<el-input-number v-model="rule.maxCount" :min="1" controls-position="right" size="small" placeholder="数量" style="width: 110px" />
<el-button size="small" text type="danger" @click="removeUploadRule(idx)">×</el-button>
</div>
<el-button size="small" class="add-rule-btn" @click="addUploadRule"
><el-icon><Plus /></el-icon> 添加规则</el-button
>
</div>
</template>
<template v-if="form.fieldType === 'number'">
<el-form-item label="最小值"
><el-input-number
v-model="form.constraint.min"
:min="Number.MIN_SAFE_INTEGER"
:max="Number.MAX_SAFE_INTEGER"
controls-position="right"
style="width: 100%"
/></el-form-item>
<el-form-item label="最大值"
><el-input-number
v-model="form.constraint.max"
:min="Number.MIN_SAFE_INTEGER"
:max="Number.MAX_SAFE_INTEGER"
controls-position="right"
style="width: 100%"
/></el-form-item>
<el-form-item label="数值类型"
><el-select v-model="form.constraint.numberType" clearable placeholder="不限"
><el-option label="整数" value="integer" /><el-option label="浮点数" value="float" /></el-select
></el-form-item>
</template>
</template>
</el-form>
<template #footer>
<el-button :disabled="!hasConfig" @click="handleRemove">清除配置</el-button>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue';
import { ElMessageBox } from 'element-plus';
import { Plus } from '@element-plus/icons-vue';
interface UploadRule {
format: string;
maxSize?: number;
maxCount?: number;
}
interface FieldFormData {
nodeKey: string;
nodeId: string;
jsonType: string;
fieldType: string;
label: string;
description: string;
role: string;
isForm: boolean;
required: boolean;
defaultValue: string;
options: string[];
constraint: {
minLength?: number;
maxLength?: number;
pattern?: string;
min?: number;
max?: number;
numberType?: string;
accept?: string;
maxSize?: number;
maxCount?: number;
uploadRules?: UploadRule[];
uploadTotalMaxCount?: number;
uploadTotalMaxSize?: number;
};
_createParentId?: string;
_isArrayParent?: boolean;
_childrenCount?: number;
}
const props = defineProps<{
visible: boolean;
formData: FieldFormData | null;
fieldTypeOptions: { label: string; value: string }[];
hasConfig: boolean;
}>();
const emit = defineEmits<{
(e: 'update:visible', v: boolean): void;
(e: 'save', form: FieldFormData): void;
(e: 'remove'): void;
}>();
const form = reactive<FieldFormData>({
nodeKey: '',
nodeId: '',
jsonType: 'string',
fieldType: 'string',
label: '',
description: '',
role: '',
isForm: true,
required: false,
defaultValue: '',
options: [],
constraint: {},
});
const uploadRules = ref<UploadRule[]>([]);
const prevJsonType = ref('');
const isCreateMode = computed(() => !!form._createParentId);
const jsonTypeIsScalar = computed(() => form.jsonType !== 'object' && form.jsonType !== 'array');
const isArrayElement = computed(() => isCreateMode.value && form._isArrayParent);
const showKeyField = computed(() => !isArrayElement.value);
const dialogTitle = computed(() => {
return isCreateMode.value ? (form._isArrayParent ? '添加元素' : '添加字段 —— ' + (form.nodeKey || '新字段')) : '字段配置 —— ' + form.nodeKey;
});
function resetForm() {
form.nodeKey = '';
form.nodeId = '';
form.jsonType = 'string';
form.fieldType = 'string';
form.label = '';
form.description = '';
form.role = '';
form.isForm = true;
form.required = false;
form.defaultValue = '';
form.options = [];
form.constraint = {};
// @ts-ignore
form._createParentId = undefined;
// @ts-ignore
form._isArrayParent = false;
uploadRules.value = [];
}
function loadForm(data: FieldFormData | null) {
resetForm();
if (!data) return;
form.nodeId = data.nodeId;
form.nodeKey = data.nodeKey || '';
prevJsonType.value = data.jsonType || 'string';
form.jsonType = data.jsonType || 'string';
form.fieldType = data.fieldType;
form.label = data.label || '';
form.description = data.description || '';
form.role = data.role || '';
form.isForm = data.isForm ?? true;
form.required = data.required || false;
form.defaultValue = data.defaultValue || '';
form.options = data.options ? [...data.options] : [];
form.constraint = data.constraint ? { ...data.constraint } : {};
// @ts-ignore
form._createParentId = data._createParentId;
// @ts-ignore
form._isArrayParent = data._isArrayParent ?? false;
// @ts-ignore
form._childrenCount = data._childrenCount ?? 0;
// Load upload rules — support both old (accept string) and new format
if (data.constraint?.uploadRules && data.constraint.uploadRules.length > 0) {
uploadRules.value = data.constraint.uploadRules.map((r) => ({ ...r }));
} else if (data.constraint?.accept) {
const formats = data.constraint.accept
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (formats.length > 0) {
uploadRules.value = formats.map((f) => ({
format: f,
maxSize: data.constraint?.maxSize,
maxCount: data.constraint?.maxCount,
}));
}
}
}
function syncUploadRulesToConstraint() {
if (uploadRules.value.length > 0 && uploadRules.value.some((r) => r.format)) {
form.constraint.uploadRules = uploadRules.value
.filter((r) => r.format)
.map((r) => ({ format: r.format, maxSize: r.maxSize, maxCount: r.maxCount }));
} else {
delete form.constraint.uploadRules;
}
}
function addUploadRule() {
uploadRules.value.push({ format: '', maxSize: undefined, maxCount: undefined });
}
function removeUploadRule(idx: number) {
uploadRules.value.splice(idx, 1);
}
function onJsonTypeChange(val: string) {
const childrenCount = (form as any)._childrenCount || 0;
const prevType = prevJsonType.value;
const m: Record<string, string> = { string: 'string', number: 'number', boolean: 'switch', null: 'string', object: 'string', array: 'string' };
form.fieldType = m[val] || 'string';
if (!form._createParentId && childrenCount > 0 && prevType && prevType !== val) {
ElMessageBox.confirm('更改字段类型将删除该节点下所有子节点数据,是否继续?', '确认操作', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
draggable: true,
})
.then(() => {
prevJsonType.value = val;
})
.catch(() => {
form.jsonType = prevType;
});
} else {
prevJsonType.value = val;
}
}
function onTypeChange() {
if (form.fieldType !== 'upload') {
delete form.constraint.accept;
delete form.constraint.maxSize;
delete form.constraint.maxCount;
delete form.constraint.uploadRules;
uploadRules.value = [];
}
if (form.fieldType !== 'string' && form.fieldType !== 'textarea') {
delete form.constraint.minLength;
delete form.constraint.maxLength;
delete form.constraint.pattern;
}
if (form.fieldType !== 'number') {
delete form.constraint.min;
delete form.constraint.max;
delete form.constraint.numberType;
}
}
watch(
() => props.visible,
(val) => {
if (val) loadForm(props.formData);
}
);
function handleSave() {
syncUploadRulesToConstraint();
emit('save', { ...form, constraint: { ...form.constraint } });
}
function handleRemove() {
emit('remove');
}
function handleCancel() {
emit('update:visible', false);
}
function handleClosed() {
resetForm();
}
</script>
<style scoped lang="scss">
.form-hint {
font-size: 11px;
color: #94a3b8;
margin-left: 8px;
}
.options-editor {
width: 100%;
.option-row {
display: flex;
gap: 4px;
margin-bottom: 4px;
}
.add-option-btn {
font-size: 12px;
margin-top: 4px;
}
}
.upload-rules {
width: 100%;
.upload-rule-row {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 6px;
}
.add-rule-btn {
font-size: 12px;
margin-top: 2px;
}
}
:deep(.el-form-item) {
margin-bottom: 12px;
}
</style>
+220
View File
@@ -0,0 +1,220 @@
<template>
<div class="json-node">
<template v-if="node.type === 'object'">
<div class="node-row node-row-object" :class="{ 'is-collapsed': collapsed }" :style="{ paddingLeft: depth * 20 + 'px' }">
<span class="collapse-btn" @click="toggleCollapse">
<el-icon><CaretRight v-if="collapsed" /><CaretBottom v-else /></el-icon>
</span>
<span class="key-label">{{ node.key }}</span>
<span class="colon">:</span>
<span class="type-label">object</span>
<span class="node-badge badge-object">&#123;...&#125;</span>
<span class="node-count">{{ node.children.length }} </span>
<span v-if="node.config?.label" class="node-label-tag" :title="node.config.label">{{ node.config.label }}</span>
<el-button size="small" text class="node-action-btn always-visible" @click.stop="emit('add-requested', node._id)">
<el-icon><Plus /></el-icon>
</el-button>
<el-button size="small" text class="node-action-btn field-btn" @click.stop="onOpenFieldDialog?.(node._id)" title="字段配置"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><rect x="2" y="4" width="20" height="16" rx="2"/><circle cx="7" cy="9" r="1.5" fill="#fff"/><circle cx="12" cy="9" r="1.5" fill="#fff"/><circle cx="17" cy="9" r="1.5" fill="#fff"/><path d="M4 14h16v1H4zm0 3h12v1H4z" fill="#fff"/></svg></el-button>
<el-button size="small" text class="node-action-btn copy-btn" @click.stop="copyNodeJson" title="复制 JSON">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
</el-button>
<el-button v-if="!isRoot && parentCanDelete" size="small" text class="node-action-btn node-action-delete" @click.stop="deleteSelf">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<div v-show="!collapsed" class="node-children" :style="{ marginLeft: depth * 20 + 18 + 'px' }">
<JsonNode v-for="child in node.children" :key="child._id" :node="child" :depth="depth + 1" :is-root="false" :parent-type="'object'" :parent-can-delete="true" @delete-node="removeChild(child._id)" @change="emitChange" @add-requested="(id: string) => emit('add-requested', id)" />
</div>
</template>
<template v-else-if="node.type === 'array'">
<div class="node-row node-row-array" :class="{ 'is-collapsed': collapsed }" :style="{ paddingLeft: depth * 20 + 'px' }">
<span class="collapse-btn" @click="toggleCollapse">
<el-icon><CaretRight v-if="collapsed" /><CaretBottom v-else /></el-icon>
</span>
<span class="key-label">{{ node.key }}</span>
<span class="colon">:</span>
<span class="type-label">array</span>
<span class="node-badge badge-array">[...]</span>
<span class="node-count">{{ node.children.length }} </span>
<span v-if="node.config?.label" class="node-label-tag" :title="node.config.label">{{ node.config.label }}</span>
<el-button size="small" text class="node-action-btn always-visible" @click.stop="emit('add-requested', node._id)">
<el-icon><Plus /></el-icon>
</el-button>
<el-button size="small" text class="node-action-btn field-btn" @click.stop="onOpenFieldDialog?.(node._id)" title="字段配置"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><rect x="2" y="4" width="20" height="16" rx="2"/><circle cx="7" cy="9" r="1.5" fill="#fff"/><circle cx="12" cy="9" r="1.5" fill="#fff"/><circle cx="17" cy="9" r="1.5" fill="#fff"/><path d="M4 14h16v1H4zm0 3h12v1H4z" fill="#fff"/></svg></el-button>
<el-button size="small" text class="node-action-btn copy-btn" @click.stop="copyNodeJson" title="复制 JSON">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
</el-button>
<el-button v-if="!isRoot && parentCanDelete" size="small" text class="node-action-btn node-action-delete" @click.stop="deleteSelf">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<div v-show="!collapsed" class="node-children" :style="{ marginLeft: depth * 20 + 18 + 'px' }">
<JsonNode v-for="child in node.children" :key="child._id" :node="child" :depth="depth + 1" :is-root="false" :parent-type="'array'" :parent-can-delete="true" @delete-node="removeChild(child._id)" @change="emitChange" @add-requested="(id: string) => emit('add-requested', id)" />
</div>
</template>
<template v-else>
<div class="node-row node-row-primitive" :style="{ paddingLeft: depth * 20 + 'px' }">
<span class="indent-placeholder" v-if="depth > 0"></span>
<span class="key-label">{{ node.key }}</span>
<span class="colon">:</span>
<span class="type-label">{{ node.type }}</span>
<template v-if="node.type === 'string'">
<span class="value-display">{{ node.primitiveValue || '(空)' }}</span>
</template>
<template v-else-if="node.type === 'number'">
<span class="value-display">{{ node.primitiveValue }}</span>
</template>
<template v-else-if="node.type === 'boolean'">
<el-tag :type="node.primitiveValue ? 'success' : 'info'" size="small" effect="plain">{{ node.primitiveValue ? 'true' : 'false' }}</el-tag>
</template>
<span v-else-if="node.type === 'null'" class="null-label">null</span>
<span v-if="node.config?.label" class="node-label-tag" :title="node.config.label">{{ node.config.label }}</span>
<el-button size="small" text class="node-action-btn field-btn" @click.stop="onOpenFieldDialog?.(node._id)" title="字段配置"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><rect x="2" y="4" width="20" height="16" rx="2"/><circle cx="7" cy="9" r="1.5" fill="#fff"/><circle cx="12" cy="9" r="1.5" fill="#fff"/><circle cx="17" cy="9" r="1.5" fill="#fff"/><path d="M4 14h16v1H4zm0 3h12v1H4z" fill="#fff"/></svg></el-button>
<el-button size="small" text class="node-action-btn copy-btn" @click.stop="copyNodeJson" title="复制 JSON">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
</el-button>
<el-button v-if="!isRoot && parentCanDelete" size="small" text class="node-action-btn node-action-delete" @click="deleteSelf">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, inject } from 'vue';
import { ElMessage } from 'element-plus';
import { Plus, Delete, CaretRight, CaretBottom } from '@element-plus/icons-vue';
defineOptions({ name: 'JsonNode' });
interface JsonNodeData {
_id: string; key: string; keyEditable: boolean;
type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array';
primitiveValue: string | number | boolean | null;
children: JsonNodeData[];
config?: {
fieldType?: string;
label?: string;
role?: string;
isForm?: boolean;
required?: boolean;
defaultValue?: string | number;
options?: string[];
constraint?: Record<string, any>;
};
}
const props = defineProps<{
node: JsonNodeData; depth: number; isRoot?: boolean;
parentType?: 'object' | 'array' | 'root'; parentCanDelete?: boolean;
}>();
const emit = defineEmits<{
(e: 'delete-node'): void;
(e: 'change'): void;
(e: 'add-requested', parentNodeId: string): void;
}>();
const onOpenFieldDialog = inject<((nodeId: string) => void) | undefined>("onOpenFieldDialog");
const collapsed = ref(false);
function toggleCollapse() { collapsed.value = !collapsed.value; }
function nodeToValue(node: JsonNodeData): any {
if (node.type === 'object') { const obj: Record<string, any> = {}; for (const c of node.children) obj[c.key] = nodeToValue(c); return obj; }
if (node.type === 'array') return node.children.map(c => nodeToValue(c));
if (node.type === 'null') return null;
if (node.type === 'number') return Number(node.primitiveValue);
if (node.type === 'boolean') return node.primitiveValue === true || node.primitiveValue === 'true';
return String(node.primitiveValue);
}
function copyNodeJson() {
const val = nodeToValue(props.node);
const json = JSON.stringify(val, null, 2);
navigator.clipboard.writeText(json).then(() => {
ElMessage.success('JSON 已复制到剪贴板');
}).catch(() => {
// Fallback
const ta = document.createElement('textarea');
ta.value = json;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
ElMessage.success('JSON 已复制到剪贴板');
});
}
function removeChild(childId: string) {
const n = props.node;
const idx = n.children.findIndex((c) => c._id === childId);
if (idx === -1) return;
n.children.splice(idx, 1);
if (n.type === 'array') {
n.children.forEach((child, i) => { child.key = String(i); });
}
emitChange();
}
function deleteSelf() { emit('delete-node'); }
function emitChange() { emit('change'); }
</script>
<style scoped lang="scss">
.json-node {
.node-row {
display: flex; align-items: center; gap: 4px;
padding: 3px 4px; border-radius: 3px; transition: background 0.15s;
min-height: 30px; flex-wrap: nowrap;
&:hover { background: #f5f7fa;
.node-action-btn:not(.always-visible) { opacity: 1; }
}
.collapse-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px; cursor: pointer; color: #94a3b8;
flex-shrink: 0; font-size: 12px;
&:hover { color: #3b82f6; }
}
.indent-placeholder { display: inline-block; width: 18px; flex-shrink: 0; }
.key-label { font-family: 'Consolas', monospace; font-size: 13px; color: #881391; font-weight: 500; white-space: nowrap; min-width: 20px; max-width: 180px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; cursor: default; }
.colon { color: #94a3b8; font-weight: 600; flex-shrink: 0; margin-right: 2px; }
.type-label {
font-family: 'Consolas', monospace; font-size: 11px; color: #64748b;
background: #f1f5f9; padding: 1px 5px; border-radius: 3px;
flex-shrink: 0; line-height: 18px;
}
.node-badge {
font-size: 11px; padding: 1px 6px; border-radius: 3px; font-weight: 500; flex-shrink: 0;
&.badge-object { color: #2563eb; background: #eff6ff; }
&.badge-array { color: #7c3aed; background: #f5f3ff; }
}
.node-count { font-size: 11px; color: #94a3b8; flex-shrink: 0; }
.node-label-tag {
font-size: 11px; color: #0891b2; background: #ecfeff;
padding: 0 6px; border-radius: 3px; white-space: nowrap;
max-width: 120px; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0;
}
.value-display {
font-family: 'Consolas', monospace; font-size: 13px; color: #475569;
padding: 0 6px; min-width: 60px; flex: 1;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.null-label { font-size: 13px; color: #94a3b8; font-style: italic; flex: 1; }
.node-action-btn { opacity: 0; transition: opacity 0.15s; margin-left: 2px; flex-shrink: 0; font-size: 14px; padding: 2px; }
.node-action-btn.always-visible { opacity: 1; }
&:hover .node-action-btn { opacity: 1; }
.node-action-delete { color: #ef4444; }
}
.node-row-object, .node-row-array {
background: #fafbfc; border: 1px solid transparent;
&:hover { border-color: #e2e8f0; }
&.is-collapsed { border-color: transparent; background: transparent; }
}
.node-children {
border-left: 1px solid #e2e8f0;
}
}
</style>
+417
View File
@@ -0,0 +1,417 @@
<template>
<div class="json-editor-tree">
<div class="tree-root">
<JsonNode
:node="rootNode" :depth="0" :is-root="true"
@change="emitChange"
@add-requested="openAddChildDialog"
/>
</div>
<FieldConfigDialog
v-model:visible="fieldDialog.visible"
:form-data="fieldDialog.formData"
:field-type-options="fieldDialog.typeOptions"
:has-config="fieldDialog.hasConfig"
@save="handleFieldSave"
@remove="handleFieldRemove"
/>
<el-dialog v-model="showPasteDialog" title="粘贴 JSON" width="560px" :close-on-click-modal="false" destroy-on-close>
<div class="paste-tip">
<el-alert type="info" :closable="false" show-icon>
<template #default>
JSON 字符串粘贴到下方文本框系统将解析并<strong>替换</strong>当前节点的内容
将替换整个根节点
</template>
</el-alert>
</div>
<el-input
v-model="pasteJsonText"
type="textarea"
:rows="12"
placeholder="例如:&#10;{&#10; &quot;name&quot;: &quot;test&quot;,&#10; &quot;age&quot;: 18&#10;}"
:autosize="{ minRows: 8, maxRows: 24 }"
/>
<template #footer>
<el-button @click="showPasteDialog = false; pasteJsonText = ''">取消</el-button>
<el-button type="primary" @click="handlePasteJson">解析并替换</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, watch, provide } from 'vue';
import { ElMessage } from 'element-plus';
import JsonNode from './JsonNode.vue';
import FieldConfigDialog from './FieldConfigDialog.vue';
defineOptions({ name: 'JsonEditor' });
const props = defineProps<{ modelValue: any }>();
const emit = defineEmits<{
(e: 'update:modelValue', value: any): void;
(e: 'change', value: any): void;
}>();
// --- types ---
interface FieldConfig {
fieldType?: string;
label?: string;
description?: string;
role?: string;
isForm?: boolean;
required?: boolean;
defaultValue?: string | number;
options?: string[];
constraint?: {
minLength?: number; maxLength?: number; pattern?: string;
min?: number; max?: number; numberType?: string;
accept?: string; maxSize?: number; maxCount?: number;
uploadRules?: Array<{ format: string; maxSize?: number; maxCount?: number }>;
uploadTotalMaxCount?: number;
uploadTotalMaxSize?: number;
};
}
interface JsonNodeData {
_id: string; key: string; keyEditable: boolean;
type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array';
primitiveValue: string | number | boolean | null;
children: JsonNodeData[];
config?: FieldConfig;
}
interface FieldFormData {
nodeKey: string;
nodeId: string;
jsonType: string;
fieldType: string; label: string; description: string; role: string;
isForm: boolean; required: boolean; defaultValue: string; options: string[];
constraint: {
minLength?: number; maxLength?: number; pattern?: string;
min?: number; max?: number; numberType?: string;
accept?: string; maxSize?: number; maxCount?: number;
uploadRules?: Array<{ format: string; maxSize?: number; maxCount?: number }>;
uploadTotalMaxCount?: number;
uploadTotalMaxSize?: number;
};
_createParentId?: string;
_isArrayParent?: boolean;
_childrenCount?: number;
}
// --- helpers ---
let idCounter = 0;
function genId(): string {
return `jn_${Date.now()}_${++idCounter}_${Math.random().toString(36).slice(2, 6)}`;
}
function getDefaultPrimitive(type: string): string | number | boolean | null {
switch (type) {
case 'number': return 0;
case 'boolean': return false;
case 'null': return null;
default: return '';
}
}
function valueToNode(value: any, key: string, keyEditable: boolean): JsonNodeData {
const id = genId();
if (value === null || value === undefined)
return { _id: id, key, keyEditable, type: 'null', primitiveValue: null, children: [] };
if (Array.isArray(value))
return { _id: id, key, keyEditable, type: 'array', primitiveValue: null, children: value.map((item, idx) => valueToNode(item, String(idx), false)) };
if (typeof value === 'object')
return { _id: id, key, keyEditable, type: 'object', primitiveValue: null, children: Object.entries(value).map(([k, v]) => valueToNode(v, k, true)) };
return { _id: id, key, keyEditable, type: typeof value as 'string' | 'number' | 'boolean', primitiveValue: value, children: [] };
}
function nodeToValue(node: JsonNodeData): any {
if (node.type === 'object') { const obj: Record<string, any> = {}; for (const c of node.children) obj[c.key] = nodeToValue(c); return obj; }
if (node.type === 'array') return node.children.map(c => nodeToValue(c));
if (node.type === 'null') return null;
if (node.type === 'number') return Number(node.primitiveValue);
if (node.type === 'boolean') return node.primitiveValue === true || node.primitiveValue === 'true';
return String(node.primitiveValue);
}
function defaultFormType(jsonType: string): string {
const m: Record<string, string> = { string: 'string', number: 'number', boolean: 'switch' };
return m[jsonType] || 'string';
}
// --- paste JSON ---
const showPasteDialog = ref(false);
const pasteJsonText = ref('');
const pastingNodeId = ref<string | null>(null);
function openPasteDialog(nodeId: string | null) {
pastingNodeId.value = nodeId;
pasteJsonText.value = '';
showPasteDialog.value = true;
}
function handlePasteJson() {
const text = pasteJsonText.value.trim();
if (!text) { ElMessage.warning('请粘贴 JSON 内容'); return; }
try {
const parsed = JSON.parse(text);
if (pastingNodeId.value) {
const node = findNodeById(pastingNodeId.value);
if (!node || (node.type !== 'object' && node.type !== 'array')) {
ElMessage.warning('只能在对象或数组节点上粘贴 JSON');
return;
}
if (Array.isArray(parsed)) {
node.type = 'array';
node.children = parsed.map((item, idx) => valueToNode(item, String(idx), false));
} else if (typeof parsed === 'object' && parsed !== null) {
node.type = 'object';
node.children = Object.entries(parsed).map(([k, v]) => valueToNode(v, k, true));
} else {
ElMessage.warning('粘贴的内容必须是对象或数组');
return;
}
} else {
rootNode.value = valueToNode(parsed, '', false);
}
showPasteDialog.value = false;
pasteJsonText.value = '';
emitChange();
} catch (e: any) {
ElMessage.error('JSON 格式错误: ' + (e.message || ''));
}
}
// --- field config dialog ---
const typeOptionList: { label: string; value: string }[] = [
{ label: '文本框', value: 'string' },
{ label: '多行文本', value: 'textarea' },
{ label: '数字输入', value: 'number' },
{ label: '开关', value: 'switch' },
{ label: '下拉选择', value: 'select' },
{ label: '文件上传', value: 'upload' },
];
const fieldDialog = ref({
visible: false,
formData: null as FieldFormData | null,
typeOptions: [] as { label: string; value: string }[],
hasConfig: false,
});
/** Extract FieldConfig from form data */
function formToConfig(form: FieldFormData): FieldConfig {
const cfg: FieldConfig = {};
if (form.label) cfg.label = form.label;
if (form.description) cfg.description = form.description;
if (form.fieldType && form.fieldType !== defaultFormType(form.jsonType)) cfg.fieldType = form.fieldType;
if (form.role) cfg.role = form.role;
if (!form.isForm) cfg.isForm = false;
if (form.required) cfg.required = true;
if (form.defaultValue) cfg.defaultValue = form.jsonType === 'number' ? Number(form.defaultValue) : form.defaultValue;
if (form.options?.length > 0) cfg.options = [...form.options];
const hasC = Object.values(form.constraint).some((v: any) => {
if (Array.isArray(v)) return v.length > 0;
return v !== undefined && v !== null && v !== '';
});
if (hasC) {
cfg.constraint = { ...form.constraint };
// Deep copy uploadRules array
if (cfg.constraint.uploadRules) {
cfg.constraint.uploadRules = cfg.constraint.uploadRules.map(r => ({ ...r }));
}
}
return cfg;
}
/** Check if a node has any meaningful config */
function hasAnyConfig(node: JsonNodeData): boolean {
const c = node.config;
if (!c) return false;
return !!(c.label || c.description || c.fieldType || c.role || c.isForm === false || c.required || c.defaultValue || c.options?.length || (c.constraint && Object.keys(c.constraint).length > 0));
}
/** Open dialog in edit mode for an existing node */
function openFieldDialog(nodeId: string) {
const node = findNodeById(nodeId);
if (!node) return;
const cfg = node.config || {};
fieldDialog.value = {
visible: true,
typeOptions: typeOptionList,
hasConfig: hasAnyConfig(node),
formData: {
nodeId: node._id,
nodeKey: node.key,
jsonType: node.type,
fieldType: cfg.fieldType || defaultFormType(node.type),
label: cfg.label || '',
description: cfg.description || "",
role: cfg.role || '',
isForm: cfg.isForm ?? true,
required: cfg.required ?? false,
defaultValue: String(cfg.defaultValue ?? ''),
options: cfg.options || [],
constraint: cfg.constraint ? { ...cfg.constraint } : {},
_childrenCount: node.children.length,
},
};
}
provide('onOpenFieldDialog', openFieldDialog);
/** Open dialog in create mode for adding a new child to a parent node */
function openAddChildDialog(parentNodeId: string) {
const parent = findNodeById(parentNodeId);
if (!parent || (parent.type !== 'object' && parent.type !== 'array')) return;
const isArray = parent.type === 'array';
fieldDialog.value = {
visible: true,
typeOptions: typeOptionList,
hasConfig: false,
formData: {
nodeId: '',
nodeKey: '',
jsonType: 'string',
fieldType: 'string',
label: '',
description: "",
role: '',
isForm: true,
required: false,
defaultValue: '',
options: [],
constraint: {},
_createParentId: parentNodeId,
_isArrayParent: isArray,
_childrenCount: 0,
},
};
}
/** Handle save from the config dialog — supports both create and edit modes */
function handleFieldSave(form: FieldFormData) {
if (form._createParentId) {
// --- create mode ---
const parent = findNodeById(form._createParentId);
if (!parent || (parent.type !== 'object' && parent.type !== 'array')) return;
const isArray = parent.type === 'array';
const newNode: JsonNodeData = {
_id: genId(),
key: isArray ? (form.nodeKey || String(parent.children.length)) : (form.nodeKey || 'newKey'),
keyEditable: !isArray,
type: form.jsonType as JsonNodeData['type'],
primitiveValue: getDefaultPrimitive(form.jsonType),
children: (form.jsonType === 'object' || form.jsonType === 'array') ? [] : [],
config: formToConfig(form),
};
parent.children.push(newNode);
fieldDialog.value.visible = false;
emitChange();
} else {
// --- edit mode ---
const node = findNodeById(form.nodeId);
if (!node) return;
node.key = form.nodeKey;
node.type = form.jsonType as JsonNodeData['type'];
// Store config on node
node.config = formToConfig(form);
// Reset value/children when type changes
if (node.type === 'object' || node.type === 'array') {
if (!node.children) node.children = [];
// Preserve existing children when switching between object/array
} else {
// Switching to primitive type — clear children, set default value
node.children = [];
if (node.primitiveValue === undefined || node.primitiveValue === null || node.type === 'null') {
node.primitiveValue = getDefaultPrimitive(node.type);
}
}
fieldDialog.value.visible = false;
emitChange();
}
}
function handleFieldRemove() {
const form = fieldDialog.value.formData;
if (!form) return;
if (form._createParentId) {
// Create mode — nothing to remove, just close
fieldDialog.value.visible = false;
return;
}
const node = findNodeById(form.nodeId);
if (node) {
delete node.config;
fieldDialog.value.visible = false;
emitChange();
}
}
// --- root node management ---
function createDefaultNode(type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array', key: string, keyEditable: boolean): JsonNodeData {
const id = genId();
const base = { _id: id, key, keyEditable, children: [] as JsonNodeData[] };
switch (type) {
case 'object': return { ...base, type: 'object', primitiveValue: null };
case 'array': return { ...base, type: 'array', primitiveValue: null };
case 'null': return { ...base, type: 'null', primitiveValue: null };
case 'number': return { ...base, type: 'number', primitiveValue: 0 };
case 'boolean': return { ...base, type: 'boolean', primitiveValue: false };
default: return { ...base, type: 'string', primitiveValue: '' };
}
}
const rootNode = ref<JsonNodeData>(createDefaultNode('object', '', false));
let skipNextWatch = false;
watch(() => props.modelValue, (val) => {
if (skipNextWatch) { skipNextWatch = false; return; }
rootNode.value = val === undefined || val === null ? createDefaultNode('object', '', false) : valueToNode(val, '', false);
}, { deep: true, immediate: true });
function emitChange() {
skipNextWatch = true;
const json = nodeToValue(rootNode.value);
emit('update:modelValue', json);
emit('change', json);
}
function findNodeById(nodeId: string, from?: JsonNodeData): JsonNodeData | null {
const s = (n: JsonNodeData): JsonNodeData | null => {
if (n._id === nodeId) return n;
for (const c of n.children) { const f = s(c); if (f) return f; }
return null;
};
return s(from || rootNode.value);
}
defineExpose({
getData: () => nodeToValue(rootNode.value),
setData: (d: any) => { rootNode.value = valueToNode(d, '', false); },
openPasteDialog: () => openPasteDialog(null),
});
</script>
<style scoped lang="scss">
.json-editor-tree {
.paste-tip { margin-bottom: 12px;
:deep(.el-alert__description) { font-size: 13px; }
}
}
</style>
@@ -0,0 +1,625 @@
<template>
<!-- panel mode: embedded sidebar -->
<template v-if="panel">
<div class="config-panel">
<div class="config-panel-title">{{ dialogTitle }}</div>
<div class="config-panel-body">
<el-form ref="formRef" :model="form" label-width="110px" size="small">
<el-divider content-position="left">基础信息</el-divider>
<el-form-item v-if="showKeyField" label="字段英文名">
<el-input v-model="form.nodeKey" placeholder="字段名称(key" clearable />
</el-form-item>
<el-form-item v-if="!isArrayElement" label="字段中文名">
<el-input v-model="form.label" placeholder="表单中显示的字段名称" clearable />
</el-form-item>
<el-form-item v-if="!isArrayElement" label="字段描述">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="此字段的用途说明" clearable />
</el-form-item>
<el-form-item label="字段类型">
<el-select v-model="form.jsonType" @change="onJsonTypeChange">
<el-option label="字符串" value="string" />
<el-option label="数字" value="number" />
<el-option label="布尔" value="boolean" />
<el-option label="对象" value="object" />
<el-option label="数组" value="array" />
</el-select>
<div class="form-hint">JSON 数据类型</div>
</el-form-item>
<el-form-item v-if="form.jsonType === 'array'" label="枚举配置">
<div class="enum-config-inline">
<el-button size="small" @click="enumDialogVisible = true">
{{ form.enumValues.length > 0 ? '编辑枚举值' : '配置枚举值' }}
</el-button>
<span v-if="form.enumValues.length > 0" class="form-hint">已配置 {{ form.enumValues.length }} 个枚举值</span>
<span v-else class="form-hint">设置数组允许的固定值列表</span>
</div>
</el-form-item>
<el-form-item v-if="jsonTypeIsScalar" label="默认值">
<template v-if="hasValueSource">
<div class="fcd-ref-bound" :class="{ 'is-invalid': valueSourceInvalid }">
<span class="fcd-ref-bound-text">已引用{{ valueSourceLabel }}</span>
<el-button size="small" text type="danger" @click="clearValueSource">清除</el-button>
</div>
</template>
<template v-else>
<div class="fcd-default-row">
<el-select v-if="form.jsonType === 'boolean'" v-model="form.defaultValue" placeholder="选择默认值" clearable>
<el-option label="true" value="true" />
<el-option label="false" value="false" />
</el-select>
<el-input v-else v-model="form.defaultValue" placeholder="留空则无默认值" clearable />
<el-popover v-if="hasUpstream" placement="bottom" :width="260" trigger="click" popper-class="fcd-ref-popover">
<template #reference>
<el-button size="small" text type="primary" class="fcd-ref-btn" title="引用上级节点输出">
<el-icon><Link /></el-icon>
</el-button>
</template>
<div class="fcd-ref-panel">
<div v-if="!hasUpstream" class="fcd-ref-empty">无可引用的上级节点</div>
<template v-else>
<div v-for="node in upstreamNodes" :key="node.id" class="fcd-ref-node">
<div class="fcd-ref-node-head">
<span class="fcd-ref-node-name">{{ node.label }}</span>
<el-tag size="small" type="info">{{ node.nodeCode }}</el-tag>
</div>
<div v-if="node.outputFields.length === 0" class="fcd-ref-node-empty">该节点无输出字段</div>
<div v-for="of in node.outputFields" :key="of.field" class="fcd-ref-field" @click="setValueSource(node, of)">
<span v-if="of.fieldType === 'object'" class="fcd-ref-type" title="引用对象整体">{} 对象</span>
<span v-else-if="of.fieldType === 'array'" class="fcd-ref-type" title="引用数组整体">[] 数组</span>
<span class="fcd-ref-field-text">{{ of.label || of.field }}</span>
</div>
</div>
</template>
</div>
</el-popover>
</div>
</template>
</el-form-item>
<template v-if="jsonTypeIsScalar && !hideFormConfig">
<el-divider content-position="left">表单属性</el-divider>
<el-form-item label="表单显示">
<el-switch v-model="form.isForm" />
<span class="form-hint">{{ form.isForm ? '在表单中展示' : '隐藏字段' }}</span>
</el-form-item>
<el-form-item label="表单控件">
<el-select v-model="form.fieldType" @change="onTypeChange">
<el-option v-for="opt in fieldTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<div class="form-hint">此字段在表单中以什么组件展示</div>
</el-form-item>
<el-form-item label="必填项">
<el-switch v-model="form.required" />
</el-form-item>
<el-divider content-position="left">约束规则</el-divider>
<el-form-item v-if="form.fieldType === 'select'" label="选项列表">
<div class="options-editor">
<div v-for="(opt, idx) in form.options" :key="idx" class="option-row">
<el-input v-model="opt.label" size="small" placeholder="显示名" style="width:120px" />
<el-input v-model="opt.value" size="small" placeholder="选项值" style="width:120px" />
<el-button size="small" text type="danger" @click="form.options.splice(idx, 1)">×</el-button>
</div>
<el-button size="small" class="add-option-btn" @click="form.options.push({ label: '', value: '' })"><el-icon><Plus /></el-icon> 添加选项</el-button>
</div>
</el-form-item>
<template v-if="form.fieldType === 'string' || form.fieldType === 'textarea'">
<el-form-item label="最小长度"><el-input-number v-model="form.constraint.minLength" :min="0" :step="100" controls-position="right" placeholder="不限" style="width:100%" /></el-form-item>
<el-form-item label="最大长度"><el-input-number v-model="form.constraint.maxLength" :min="0" :step="1000" controls-position="right" placeholder="不限" style="width:100%" /></el-form-item>
<el-form-item label="正则验证"><el-input v-model="form.constraint.pattern" placeholder="如 ^https?://" clearable /></el-form-item>
</template>
<template v-if="form.fieldType === 'upload'">
<el-form-item label="总数量"><el-input-number v-model="form.constraint.uploadTotalMaxCount" :min="1" controls-position="right" placeholder="不限" style="width:100%" /></el-form-item>
<el-form-item label="总容量(MB)"><el-input-number v-model="form.constraint.uploadTotalMaxSize" :min="1" controls-position="right" placeholder="不限" style="width:100%" /></el-form-item>
<el-divider content-position="left">按格式限制可选</el-divider>
<div class="upload-rules">
<div v-for="(rule, idx) in uploadRules" :key="idx" class="upload-rule-row">
<el-input v-model="rule.format" size="small" placeholder="格式(如 jpg" style="width:90px" />
<el-input-number v-model="rule.maxSize" :min="1" controls-position="right" size="small" placeholder="大小(MB)" style="width:140px" />
<el-input-number v-model="rule.maxCount" :min="1" controls-position="right" size="small" placeholder="数量" style="width:110px" />
<el-button size="small" text type="danger" @click="removeUploadRule(idx)">×</el-button>
</div>
<el-button size="small" class="add-rule-btn" @click="addUploadRule"><el-icon><Plus /></el-icon> 添加规则</el-button>
</div>
</template>
<template v-if="form.fieldType === 'number'">
<el-form-item label="最小值"><el-input-number v-model="form.constraint.min" :min="Number.MIN_SAFE_INTEGER" :max="Number.MAX_SAFE_INTEGER" controls-position="right" style="width:100%" /></el-form-item>
<el-form-item label="最大值"><el-input-number v-model="form.constraint.max" :min="Number.MIN_SAFE_INTEGER" :max="Number.MAX_SAFE_INTEGER" controls-position="right" style="width:100%" /></el-form-item>
<el-form-item label="数值类型"><el-select v-model="form.constraint.numberType" clearable placeholder="不限"><el-option label="整数" value="integer" /><el-option label="浮点数" value="float" /></el-select></el-form-item>
</template>
</template>
</el-form>
</div>
<div class="config-panel-actions">
<el-button :disabled="!hasConfig" @click="handleRemove">清除配置</el-button>
<el-button @click="handleCancel">关闭</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</div>
</div>
</template>
<!-- dialog mode: popup overlay -->
<template v-else>
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="560px"
:close-on-click-modal="false"
destroy-on-close
@update:model-value="$emit('update:visible', $event)"
@closed="handleClosed"
>
<el-form ref="formRef" :model="form" label-width="110px" size="small">
<el-divider content-position="left">基础信息</el-divider>
<el-form-item v-if="showKeyField" label="字段英文名">
<el-input v-model="form.nodeKey" placeholder="字段名称(key" clearable />
</el-form-item>
<el-form-item v-if="!isArrayElement" label="字段中文名">
<el-input v-model="form.label" placeholder="表单中显示的字段名称" clearable />
</el-form-item>
<el-form-item v-if="!isArrayElement" label="字段描述">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="此字段的用途说明" clearable />
</el-form-item>
<el-form-item label="字段类型">
<el-select v-model="form.jsonType" @change="onJsonTypeChange">
<el-option label="字符串" value="string" />
<el-option label="数字" value="number" />
<el-option label="布尔" value="boolean" />
<el-option label="对象" value="object" />
<el-option label="数组" value="array" />
</el-select>
<div class="form-hint">JSON 数据类型</div>
</el-form-item>
<el-form-item v-if="form.jsonType === 'array'" label="枚举配置">
<div class="enum-config-inline">
<el-button size="small" @click="enumDialogVisible = true">
{{ form.enumValues.length > 0 ? '编辑枚举值' : '配置枚举值' }}
</el-button>
<span v-if="form.enumValues.length > 0" class="form-hint">已配置 {{ form.enumValues.length }} 个枚举值</span>
<span v-else class="form-hint">设置数组允许的固定值列表</span>
</div>
</el-form-item>
<el-form-item v-if="jsonTypeIsScalar" label="默认值">
<template v-if="hasValueSource">
<div class="fcd-ref-bound" :class="{ 'is-invalid': valueSourceInvalid }">
<span class="fcd-ref-bound-text">已引用{{ valueSourceLabel }}</span>
<el-button size="small" text type="danger" @click="clearValueSource">清除</el-button>
</div>
</template>
<template v-else>
<div class="fcd-default-row">
<el-select v-if="form.jsonType === 'boolean'" v-model="form.defaultValue" placeholder="选择默认值" clearable>
<el-option label="true" value="true" />
<el-option label="false" value="false" />
</el-select>
<el-input v-else v-model="form.defaultValue" placeholder="留空则无默认值" clearable />
<el-popover v-if="hasUpstream" placement="bottom" :width="260" trigger="click" popper-class="fcd-ref-popover">
<template #reference>
<el-button size="small" text type="primary" class="fcd-ref-btn" title="引用上级节点输出">
<el-icon><Link /></el-icon>
</el-button>
</template>
<div class="fcd-ref-panel">
<div v-if="!hasUpstream" class="fcd-ref-empty">无可引用的上级节点</div>
<template v-else>
<div v-for="node in upstreamNodes" :key="node.id" class="fcd-ref-node">
<div class="fcd-ref-node-head">
<span class="fcd-ref-node-name">{{ node.label }}</span>
<el-tag size="small" type="info">{{ node.nodeCode }}</el-tag>
</div>
<div v-if="node.outputFields.length === 0" class="fcd-ref-node-empty">该节点无输出字段</div>
<div v-for="of in node.outputFields" :key="of.field" class="fcd-ref-field" @click="setValueSource(node, of)">
<span v-if="of.fieldType === 'object'" class="fcd-ref-type" title="引用对象整体">{} 对象</span>
<span v-else-if="of.fieldType === 'array'" class="fcd-ref-type" title="引用数组整体">[] 数组</span>
<span class="fcd-ref-field-text">{{ of.label || of.field }}</span>
</div>
</div>
</template>
</div>
</el-popover>
</div>
</template>
</el-form-item>
<template v-if="jsonTypeIsScalar && !hideFormConfig">
<el-divider content-position="left">表单属性</el-divider>
<el-form-item label="是否可编辑">
<el-switch v-model="form.isForm" />
<span class="form-hint">{{ form.isForm ? '可编辑' : '只读' }}</span>
</el-form-item>
<el-form-item label="表单控件">
<el-select v-model="form.fieldType" @change="onTypeChange">
<el-option v-for="opt in fieldTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<div class="form-hint">此字段在表单中以什么组件展示</div>
</el-form-item>
<el-form-item label="必填项">
<el-switch v-model="form.required" />
</el-form-item>
<el-divider content-position="left">约束规则</el-divider>
<el-form-item v-if="form.fieldType === 'select'" label="选项列表">
<div class="options-editor">
<div v-for="(opt, idx) in form.options" :key="idx" class="option-row">
<el-input v-model="opt.label" size="small" placeholder="显示名" style="width:120px" />
<el-input v-model="opt.value" size="small" placeholder="选项值" style="width:120px" />
<el-button size="small" text type="danger" @click="form.options.splice(idx, 1)">×</el-button>
</div>
<el-button size="small" class="add-option-btn" @click="form.options.push({ label: '', value: '' })"><el-icon><Plus /></el-icon> 添加选项</el-button>
</div>
</el-form-item>
<template v-if="form.fieldType === 'string' || form.fieldType === 'textarea'">
<el-form-item label="最小长度"><el-input-number v-model="form.constraint.minLength" :min="0" :step="100" controls-position="right" placeholder="不限" style="width:100%" /></el-form-item>
<el-form-item label="最大长度"><el-input-number v-model="form.constraint.maxLength" :min="0" :step="1000" controls-position="right" placeholder="不限" style="width:100%" /></el-form-item>
<el-form-item label="正则验证"><el-input v-model="form.constraint.pattern" placeholder="如 ^https?://" clearable /></el-form-item>
</template>
<template v-if="form.fieldType === 'upload'">
<el-form-item label="总数量"><el-input-number v-model="form.constraint.uploadTotalMaxCount" :min="1" controls-position="right" placeholder="不限" style="width:100%" /></el-form-item>
<el-form-item label="总容量(MB)"><el-input-number v-model="form.constraint.uploadTotalMaxSize" :min="1" controls-position="right" placeholder="不限" style="width:100%" /></el-form-item>
<el-divider content-position="left">按格式限制可选</el-divider>
<div class="upload-rules">
<div v-for="(rule, idx) in uploadRules" :key="idx" class="upload-rule-row">
<el-input v-model="rule.format" size="small" placeholder="格式(如 jpg" style="width:90px" />
<el-input-number v-model="rule.maxSize" :min="1" controls-position="right" size="small" placeholder="大小(MB)" style="width:140px" />
<el-input-number v-model="rule.maxCount" :min="1" controls-position="right" size="small" placeholder="数量" style="width:110px" />
<el-button size="small" text type="danger" @click="removeUploadRule(idx)">×</el-button>
</div>
<el-button size="small" class="add-rule-btn" @click="addUploadRule"><el-icon><Plus /></el-icon> 添加规则</el-button>
</div>
</template>
<template v-if="form.fieldType === 'number'">
<el-form-item label="最小值"><el-input-number v-model="form.constraint.min" :min="Number.MIN_SAFE_INTEGER" :max="Number.MAX_SAFE_INTEGER" controls-position="right" style="width:100%" /></el-form-item>
<el-form-item label="最大值"><el-input-number v-model="form.constraint.max" :min="Number.MIN_SAFE_INTEGER" :max="Number.MAX_SAFE_INTEGER" controls-position="right" style="width:100%" /></el-form-item>
<el-form-item label="数值类型"><el-select v-model="form.constraint.numberType" clearable placeholder="不限"><el-option label="整数" value="integer" /><el-option label="浮点数" value="float" /></el-select></el-form-item>
</template>
</template>
</el-form>
<template #footer>
<el-button :disabled="!hasConfig" @click="handleRemove">清除配置</el-button>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</template>
<!-- Enum editor dialog -->
<el-dialog v-model="enumDialogVisible" title="枚举值配置" width="900px" :close-on-click-modal="false" destroy-on-close>
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 12px;">
<template #default>
编辑数组字段允许的固定值列表每个值为一个允许的枚举项
</template>
</el-alert>
<div class="enum-editor-container">
<JsonEditor v-model="form.enumValues" />
</div>
<template #footer>
<el-button @click="enumDialogVisible = false">关闭</el-button>
<el-button type="primary" @click="enumDialogVisible = false">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, defineAsyncComponent } from 'vue';
import { ElMessageBox } from 'element-plus';
import { Plus, Link } from '@element-plus/icons-vue';
const JsonEditor = defineAsyncComponent(() => import('./index.vue'));
interface UploadRule { format: string; maxSize?: number; maxCount?: number; }
// 前驱节点的可引用输出字段(供「引用上级节点输出」)
interface UpstreamNodeInfo {
id: string;
label: string;
nodeCode: string;
outputFields: { field: string; label: string; fieldType?: 'scalar' | 'object' | 'array' }[];
}
interface FieldFormData {
nodeKey: string; nodeId: string; jsonType: string; fieldType: string;
label: string; description: string; role: string;
isForm: boolean; required: boolean; defaultValue: string; options: Array<{ label: string; value: string }>;
enumValues: any[];
constraint: {
minLength?: number; maxLength?: number; pattern?: string;
min?: number; max?: number; numberType?: string;
accept?: string; maxSize?: number; maxCount?: number;
uploadRules?: UploadRule[]; uploadTotalMaxCount?: number; uploadTotalMaxSize?: number;
};
valueSource?: { nodeId: string; field: string };
_createParentId?: string; _isArrayParent?: boolean; _childrenCount?: number;
}
const props = defineProps<{
visible: boolean;
formData: FieldFormData | null;
fieldTypeOptions: { label: string; value: string }[];
hasConfig: boolean;
panel?: boolean;
upstreamNodes?: UpstreamNodeInfo[];
hideFormConfig?: boolean;
}>();
const emit = defineEmits<{
(e: 'update:visible', v: boolean): void;
(e: 'save', form: FieldFormData): void;
(e: 'remove'): void;
}>();
const form = reactive<FieldFormData>({
nodeKey: '', nodeId: '', jsonType: 'string', fieldType: 'string',
label: '', description: '', role: '',
isForm: true, required: false, defaultValue: '', options: [], constraint: {},
enumValues: [],
valueSource: undefined,
});
const uploadRules = ref<UploadRule[]>([]);
const enumDialogVisible = ref(false);
const prevJsonType = ref('');
const isCreateMode = computed(() => !!form._createParentId);
const jsonTypeIsScalar = computed(() => form.jsonType !== 'object' && form.jsonType !== 'array');
const isArrayElement = computed(() => isCreateMode.value && form._isArrayParent);
const showKeyField = computed(() => !isArrayElement.value);
const dialogTitle = computed(() => {
return isCreateMode.value
? (form._isArrayParent ? '添加元素' : '添加字段 —— ' + (form.nodeKey || '新字段'))
: '字段配置 —— ' + form.nodeKey;
});
function resetForm() {
form.nodeKey = ''; form.nodeId = ''; form.jsonType = 'string'; form.fieldType = 'string';
form.label = ''; form.description = ''; form.role = '';
form.isForm = true; form.required = false; form.defaultValue = '';
form.options = []; form.constraint = {};
form.enumValues = [];
form.valueSource = undefined;
// @ts-ignore
form._createParentId = undefined; form._isArrayParent = false;
uploadRules.value = [];
enumDialogVisible.value = false;
}
function loadForm(data: FieldFormData | null) {
resetForm();
if (!data) return;
form.nodeId = data.nodeId; form.nodeKey = data.nodeKey || '';
prevJsonType.value = data.jsonType || 'string';
form.jsonType = data.jsonType || 'string'; form.fieldType = data.fieldType;
form.label = data.label || ''; form.description = data.description || ''; form.role = data.role || '';
form.isForm = data.isForm ?? true; form.required = data.required || false;
form.defaultValue = data.defaultValue || '';
form.options = data.options ? [...data.options] : [];
if (form.options.length > 0 && typeof (form.options as any)[0] === 'string') {
form.options = (data.options as unknown as string[]).map(s => ({ label: s, value: s }));
}
form.constraint = data.constraint ? { ...data.constraint } : {};
form.enumValues = data.enumValues ? JSON.parse(JSON.stringify(data.enumValues)) : [];
form.valueSource = data.valueSource ? { ...data.valueSource } : undefined;
// @ts-ignore
form._createParentId = data._createParentId; form._isArrayParent = data._isArrayParent ?? false;
// @ts-ignore
form._childrenCount = data._childrenCount ?? 0;
if (data.constraint?.uploadRules?.length) {
uploadRules.value = data.constraint.uploadRules.map((r) => ({ ...r }));
} else if (data.constraint?.accept) {
const formats = data.constraint.accept.split(',').map((s) => s.trim()).filter(Boolean);
if (formats.length) {
uploadRules.value = formats.map((f) => ({ format: f, maxSize: data.constraint?.maxSize, maxCount: data.constraint?.maxCount }));
}
}
}
function syncUploadRulesToConstraint() {
if (uploadRules.value.some((r) => r.format)) {
form.constraint.uploadRules = uploadRules.value.filter((r) => r.format).map((r) => ({ ...r }));
} else { delete form.constraint.uploadRules; }
}
function addUploadRule() { uploadRules.value.push({ format: '', maxSize: undefined, maxCount: undefined }); }
function removeUploadRule(idx: number) { uploadRules.value.splice(idx, 1); }
async function onJsonTypeChange(val: string) {
const childrenCount = (form as any)._childrenCount || 0;
const prevType = prevJsonType.value;
const m: Record<string, string> = { string: 'string', number: 'number', boolean: 'switch', null: 'string', object: 'string', array: 'string' };
form.fieldType = m[val] || 'string';
if (!form._createParentId && childrenCount > 0 && prevType && prevType !== val) {
try {
await ElMessageBox.confirm('更改字段类型将删除该节点下所有子节点数据,是否继续?', '确认操作', {
confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning', draggable: true,
});
prevJsonType.value = val;
} catch {
form.jsonType = prevType;
}
} else { prevJsonType.value = val; }
}
function onTypeChange() {
if (form.fieldType !== 'upload') {
delete form.constraint.accept; delete form.constraint.maxSize; delete form.constraint.maxCount;
delete form.constraint.uploadRules; uploadRules.value = [];
}
if (form.fieldType !== 'string' && form.fieldType !== 'textarea') {
delete form.constraint.minLength; delete form.constraint.maxLength; delete form.constraint.pattern;
}
if (form.fieldType !== 'number') {
delete form.constraint.min; delete form.constraint.max; delete form.constraint.numberType;
}
}
watch(() => props.visible, (val) => { if (val) loadForm(props.formData); }, { immediate: true });
// ===== 默认值引用上级节点输出 =====
const hasUpstream = computed(() => (props.upstreamNodes?.length ?? 0) > 0);
const hasValueSource = computed(() => !!form.valueSource);
const valueSourceLabel = computed(() => {
const ref = form.valueSource;
if (!ref) return '';
const node = props.upstreamNodes?.find((n) => n.id === ref.nodeId);
const nodeName = node?.label || ref.nodeId;
const fieldLabel = node?.outputFields.find((f) => f.field === ref.field)?.label || ref.field;
return `${nodeName}.${fieldLabel}`;
});
// 引用是否已失效:上游节点被删除,或其输出字段已不存在
const valueSourceInvalid = computed(() => {
const ref = form.valueSource;
if (!ref) return false;
const node = props.upstreamNodes?.find((n) => n.id === ref.nodeId);
if (!node) return true;
return !node.outputFields.some((f) => f.field === ref.field);
});
watch(valueSourceInvalid, (invalid) => {
if (invalid && form.valueSource) { form.valueSource = undefined; }
}, { immediate: true });
const setValueSource = (node: UpstreamNodeInfo, of: { field: string; label: string }) => {
form.valueSource = { nodeId: node.id, field: of.field };
// 有引用时默认值无意义,清空避免两者并存导致歧义
form.defaultValue = '';
};
const clearValueSource = () => {
form.valueSource = undefined;
};
function handleSave() {
syncUploadRulesToConstraint();
emit('save', { ...form, constraint: { ...form.constraint } });
}
function handleRemove() { emit('remove'); }
function handleCancel() { emit('update:visible', false); }
function handleClosed() { resetForm(); }
</script>
<style scoped lang="scss">
.config-panel {
height: 100%; display: flex; flex-direction: column;
.config-panel-title {
font-size: 14px; font-weight: 600; color: #333;
padding: 12px 16px; border-bottom: 1px solid #e2e8f0; flex-shrink: 0;
}
.config-panel-body {
flex: 1; overflow-y: auto; padding: 12px 16px;
:deep(.el-divider__text) { font-size: 12px; }
}
.config-panel-actions {
display: flex; gap: 8px; justify-content: flex-end;
padding: 12px 16px; border-top: 1px solid #e2e8f0; flex-shrink: 0;
}
}
.form-hint { font-size: 11px; color: #94a3b8; margin-left: 8px; }
.enum-config-inline { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.enum-editor-container { height: 500px; border: 1px solid #e2e8f0; border-radius: 4px; overflow: hidden; }
.options-editor { width: 100%;
.option-row { display: flex; gap: 4px; margin-bottom: 4px; }
.add-option-btn { font-size: 12px; margin-top: 4px; }
}
.upload-rules { width: 100%;
.upload-rule-row { display: flex; gap: 6px; align-items: center; margin-bottom: 6px; }
.add-rule-btn { font-size: 12px; margin-top: 2px; }
}
:deep(.el-form-item) { margin-bottom: 12px; }
.fcd-default-row {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
.el-select, .el-input { flex: 1; min-width: 0; }
}
.fcd-ref-bound {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #3b82f6;
background: #eff6ff;
border-radius: 4px;
padding: 4px 8px;
.fcd-ref-bound-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.is-invalid {
color: #dc2626;
background: #fef2f2;
}
}
</style>
<!-- popover teleport body面板样式需脱离 scoped -->
<style lang="scss">
.fcd-ref-popover {
.fcd-ref-panel {
// 面板超高时内部滚动,避免超出浏览器视口被截掉显示不全
max-height: 40vh;
overflow-y: auto;
.fcd-ref-empty {
color: #94a3b8;
font-size: 12px;
padding: 6px 0;
text-align: center;
}
.fcd-ref-node {
& + .fcd-ref-node {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #e2e8f0;
}
.fcd-ref-node-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
.fcd-ref-node-name {
flex: 1;
font-size: 12px;
font-weight: 600;
color: #334155;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.fcd-ref-node-empty {
font-size: 12px;
color: #cbd5e1;
padding: 2px 0 2px 8px;
}
.fcd-ref-field {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #475569;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
&:hover {
background: #eff6ff;
color: #3b82f6;
}
.fcd-ref-type {
flex-shrink: 0;
font-size: 11px;
line-height: 1;
color: #64748b;
background: #f1f5f9;
border-radius: 3px;
padding: 2px 4px;
}
.fcd-ref-field-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
}
</style>
@@ -0,0 +1,265 @@
<template>
<div class="json-node">
<template v-if="node.type === 'object'">
<div class="node-row node-row-object" :class="{ 'is-collapsed': collapsed }" :style="{ paddingLeft: depth * 20 + 'px' }">
<span class="collapse-btn" @click="toggleCollapse">
<el-icon><CaretRight v-if="collapsed" /><CaretBottom v-else /></el-icon>
</span>
<span class="key-label">{{ node.key }}</span>
<span class="colon">:</span>
<span class="type-label">object</span>
<span class="node-badge badge-object">&#123;...&#125;</span>
<span class="node-count">{{ node.children.length }} </span>
<el-button size="small" text class="node-action-btn always-visible" @click.stop="emit('add-requested', node._id)">
<el-icon><Plus /></el-icon>
</el-button>
<el-button size="small" text class="node-action-btn field-btn" :class="{ 'field-btn-configured': hasConfig(node) }" @click.stop="onOpenFieldDialog?.(node._id)" title="字段配置"><el-icon><Setting /></el-icon></el-button>
<el-button size="small" text class="node-action-btn copy-btn" @click.stop="copyNodeJson" title="复制 JSON">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
</el-button>
<el-button v-if="!isRoot && parentCanDelete" size="small" text class="node-action-btn node-action-delete" @click.stop="deleteSelf">
<el-icon><Delete /></el-icon>
</el-button>
<span v-if="configSummary(node)" class="config-summary">{{ configSummary(node) }}</span>
</div>
<div v-show="!collapsed" class="node-children" :style="{ marginLeft: depth * 20 + 18 + 'px' }">
<JsonNode v-for="child in node.children" :key="child._id" :node="child" :depth="depth + 1" :is-root="false" :parent-type="'object'" :parent-can-delete="true" @delete-node="removeChild(child._id)" @change="emitChange" @add-requested="(id: string) => emit('add-requested', id)" />
</div>
</template>
<template v-else-if="node.type === 'array'">
<div class="node-row node-row-array" :class="{ 'is-collapsed': collapsed }" :style="{ paddingLeft: depth * 20 + 'px' }">
<span class="collapse-btn" @click="toggleCollapse">
<el-icon><CaretRight v-if="collapsed" /><CaretBottom v-else /></el-icon>
</span>
<span class="key-label">{{ node.key }}</span>
<span class="colon">:</span>
<span class="type-label">array</span>
<span class="node-badge badge-array">[...]</span>
<span class="node-count">{{ node.children.length }} </span>
<el-button size="small" text class="node-action-btn always-visible" @click.stop="emit('add-requested', node._id)">
<el-icon><Plus /></el-icon>
</el-button>
<el-button size="small" text class="node-action-btn field-btn" :class="{ 'field-btn-configured': hasConfig(node) }" @click.stop="onOpenFieldDialog?.(node._id)" title="字段配置"><el-icon><Setting /></el-icon></el-button>
<el-button size="small" text class="node-action-btn copy-btn" @click.stop="copyNodeJson" title="复制 JSON">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
</el-button>
<el-button v-if="!isRoot && parentCanDelete" size="small" text class="node-action-btn node-action-delete" @click.stop="deleteSelf">
<el-icon><Delete /></el-icon>
</el-button>
<span v-if="configSummary(node)" class="config-summary">{{ configSummary(node) }}</span>
</div>
<div v-show="!collapsed" class="node-children" :style="{ marginLeft: depth * 20 + 18 + 'px' }">
<JsonNode v-for="child in node.children" :key="child._id" :node="child" :depth="depth + 1" :is-root="false" :parent-type="'array'" :parent-can-delete="true" @delete-node="removeChild(child._id)" @change="emitChange" @add-requested="(id: string) => emit('add-requested', id)" />
</div>
</template>
<template v-else>
<div class="node-row node-row-primitive" :style="{ paddingLeft: depth * 20 + 'px' }">
<span class="indent-placeholder" v-if="depth > 0"></span>
<span class="key-label">{{ node.key }}</span>
<span class="colon">:</span>
<span class="type-label">{{ node.type }}</span>
<el-button size="small" text class="node-action-btn field-btn" :class="{ 'field-btn-configured': hasConfig(node) }" @click.stop="onOpenFieldDialog?.(node._id)" title="字段配置"><el-icon><Setting /></el-icon></el-button>
<el-button size="small" text class="node-action-btn copy-btn" @click.stop="copyNodeJson" title="复制 JSON">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
</el-button>
<el-button v-if="!isRoot && parentCanDelete" size="small" text class="node-action-btn node-action-delete" @click="deleteSelf">
<el-icon><Delete /></el-icon>
</el-button>
<span v-if="configSummary(node)" class="config-summary">{{ configSummary(node) }}</span>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, inject } from 'vue';
import { ElMessage } from 'element-plus';
import { Plus, Delete, CaretRight, CaretBottom, Setting } from '@element-plus/icons-vue';
defineOptions({ name: 'JsonNode' });
interface JsonNodeData {
_id: string; key: string; keyEditable: boolean;
type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array';
primitiveValue: string | number | boolean | null;
children: JsonNodeData[];
config?: {
fieldType?: string;
label?: string;
description?: string;
role?: string;
isForm?: boolean;
required?: boolean;
isContainer?: boolean;
linkRules?: Array<Record<string, string>>;
defaultValue?: string | number;
options?: Array<{ label: string; value: string }>;
constraint?: Record<string, any>;
};
}
function hasConfig(node: JsonNodeData): boolean {
const c = node.config;
if (!c) return false;
if (c.label || c.description || c.role || c.defaultValue) return true;
if (c.options?.length) return true;
if (c.required) return true;
if (c.isForm === false) return true;
if (c.isContainer) return true;
if (c.linkRules?.length) return true;
if (c.constraint && Object.keys(c.constraint).length > 0) return true;
const defaultTypes: Record<string, string> = { string: 'string', number: 'number', boolean: 'switch' };
if (c.fieldType && c.fieldType !== defaultTypes[node.type]) return true;
return false;
}
function configSummary(node: JsonNodeData): string {
const c = node.config;
if (!c) return '';
const parts: string[] = [];
if (c.label) parts.push(c.label);
if (c.required) parts.push('必填');
if (c.fieldType) {
const m: Record<string, string> = {
string: '文本框', textarea: '多行文本', number: '数字',
switch: '开关', select: '下拉选择', upload: '文件上传'
};
parts.push(m[c.fieldType] || c.fieldType);
}
if (c.options?.length) parts.push(`${c.options.length}个选项`);
if (c.defaultValue !== undefined && c.defaultValue !== '' && c.defaultValue !== null) {
parts.push(`默认=${c.defaultValue}`);
}
if (c.isContainer) {
parts.push("容器触发器");
if (c.linkRules?.length) parts.push(`联动 ${c.linkRules.length} 条规则`);
}
const con = c.constraint;
if (con) {
if (con.minLength || con.maxLength) {
if (con.minLength && con.maxLength) parts.push(`${con.minLength}-${con.maxLength}`);
else if (con.minLength) parts.push(`${con.minLength}`);
else if (con.maxLength) parts.push(`${con.maxLength}`);
}
if (con.min !== undefined || con.max !== undefined) {
if (con.min !== undefined && con.max !== undefined) parts.push(`${con.min}-${con.max}`);
else if (con.min !== undefined) parts.push(`${con.min}`);
else if (con.max !== undefined) parts.push(`${con.max}`);
}
if (con.pattern) parts.push('正则');
if (con.numberType === 'integer') parts.push('整数');
if (con.numberType === 'float') parts.push('浮点');
}
return parts.join(' · ');
}
const props = defineProps<{
node: JsonNodeData; depth: number; isRoot?: boolean;
parentType?: 'object' | 'array' | 'root'; parentCanDelete?: boolean;
}>();
const emit = defineEmits<{
(e: 'delete-node'): void;
(e: 'change'): void;
(e: 'add-requested', parentNodeId: string): void;
}>();
const onOpenFieldDialog = inject<((nodeId: string) => void) | undefined>("onOpenFieldDialog");
const collapsed = ref(false);
function toggleCollapse() { collapsed.value = !collapsed.value; }
function nodeToValue(node: JsonNodeData): any {
if (node.type === 'object') { const obj: Record<string, any> = {}; for (const c of node.children) obj[c.key] = nodeToValue(c); return obj; }
if (node.type === 'array') return node.children.map(c => nodeToValue(c));
if (node.type === 'null') return null;
if (node.type === 'number') return Number(node.primitiveValue);
if (node.type === 'boolean') return node.primitiveValue === true || node.primitiveValue === 'true';
return String(node.primitiveValue);
}
function copyNodeJson() {
const val = nodeToValue(props.node);
const json = JSON.stringify(val, null, 2);
navigator.clipboard.writeText(json).then(() => {
ElMessage.success('JSON 已复制到剪贴板');
}).catch(() => {
// Fallback
const ta = document.createElement('textarea');
ta.value = json;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
ElMessage.success('JSON 已复制到剪贴板');
});
}
function removeChild(childId: string) {
const n = props.node;
const idx = n.children.findIndex((c) => c._id === childId);
if (idx === -1) return;
n.children.splice(idx, 1);
if (n.type === 'array') {
n.children.forEach((child, i) => { child.key = String(i); });
}
emitChange();
}
function deleteSelf() { emit('delete-node'); }
function emitChange() { emit('change'); }
</script>
<style scoped lang="scss">
.json-node {
.node-row {
display: flex; align-items: center; gap: 4px;
padding: 3px 4px; border-radius: 3px; transition: background 0.15s;
min-height: 30px; flex-wrap: nowrap;
&:hover { background: #f5f7fa;
.node-action-btn:not(.always-visible) { opacity: 1; }
}
.collapse-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px; cursor: pointer; color: #94a3b8;
flex-shrink: 0; font-size: 12px;
&:hover { color: #3b82f6; }
}
.indent-placeholder { display: inline-block; width: 18px; flex-shrink: 0; }
.key-label { font-family: 'Consolas', monospace; font-size: 13px; color: #881391; font-weight: 500; white-space: nowrap; min-width: 20px; max-width: 180px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; cursor: default; }
.colon { color: #94a3b8; font-weight: 600; flex-shrink: 0; margin-right: 2px; }
.type-label {
font-family: 'Consolas', monospace; font-size: 11px; color: #64748b;
background: #f1f5f9; padding: 1px 5px; border-radius: 3px;
flex-shrink: 0; line-height: 18px;
}
.node-badge {
font-size: 11px; padding: 1px 6px; border-radius: 3px; font-weight: 500; flex-shrink: 0;
&.badge-object { color: #2563eb; background: #eff6ff; }
&.badge-array { color: #7c3aed; background: #f5f3ff; }
}
.node-count { font-size: 11px; color: #94a3b8; flex-shrink: 0; }
.config-summary {
font-size: 11px; color: #64748b; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.field-btn-configured {
color: #2563eb !important;
}
.node-action-btn:not(.always-visible) { opacity: 0; transition: opacity 0.15s; margin-left: 2px; flex-shrink: 0; font-size: 14px; padding: 2px; }
.node-action-btn.field-btn { opacity: 0.4; }
.node-row:hover .node-action-btn.field-btn { opacity: 1; }
.node-action-btn.always-visible { opacity: 1; }
&:hover .node-action-btn { opacity: 1; }
.node-action-delete { color: #ef4444; }
}
.node-row-object, .node-row-array {
background: #fafbfc; border: 1px solid transparent;
&:hover { border-color: #e2e8f0; }
&.is-collapsed { border-color: transparent; background: transparent; }
}
.node-children {
border-left: 1px solid #e2e8f0;
}
}
</style>
@@ -0,0 +1,6 @@
import JsonEditor from './index.vue'
import JsonNode from './JsonNode.vue'
import FieldConfigDialog from './FieldConfigDialog.vue'
export { JsonEditor, JsonNode, FieldConfigDialog }
export default JsonEditor
+554
View File
@@ -0,0 +1,554 @@
<template>
<div class="json-editor-split">
<div class="tree-panel">
<div class="tree-root">
<JsonNode
:node="rootNode" :depth="0" :is-root="true"
@change="emitChange"
@add-requested="openAddChildDialog"
/>
</div>
</div>
<div v-if="selectNodeId" class="config-sidebar">
<FieldConfigDialog
:key="selectNodeId + '-' + (fieldDialog.formData?._createParentId ? 'add' : 'edit')"
:panel="true"
:visible="true"
:form-data="fieldDialog.formData"
:field-type-options="fieldDialog.typeOptions"
:has-config="fieldDialog.hasConfig"
:upstream-nodes="props.upstreamNodes"
:hide-form-config="props.hideFormConfig"
@update:visible="clearSelection"
@save="handleFieldSave"
@remove="handleFieldRemove"
/>
</div>
<div v-else class="config-sidebar config-sidebar-empty">
<div class="config-placeholder">
<p>选择一个字段</p>
<p class="config-placeholder-hint">点击字段右侧的齿轮按钮进行配置</p>
</div>
</div>
<el-dialog v-model="showPasteDialog" title="粘贴 JSON" width="560px" :close-on-click-modal="false" destroy-on-close>
<div class="paste-tip">
<el-alert type="info" :closable="false" show-icon>
<template #default>
JSON 字符串粘贴到下方文本框系统将解析并<strong>替换</strong>当前节点的内容
将替换整个根节点
</template>
</el-alert>
</div>
<el-input
v-model="pasteJsonText"
type="textarea"
:rows="12"
placeholder="例如:&#10;{&#10; &quot;name&quot;: &quot;test&quot;,&#10; &quot;age&quot;: 18&#10;}"
:autosize="{ minRows: 8, maxRows: 24 }"
/>
<template #footer>
<el-button @click="showPasteDialog = false; pasteJsonText = ''">取消</el-button>
<el-button type="primary" @click="handlePasteJson">解析并替换</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, watch, provide } from 'vue';
import { ElMessage } from 'element-plus';
import JsonNode from './JsonNode.vue';
import FieldConfigDialog from './FieldConfigDialog.vue';
defineOptions({ name: 'JsonEditor' });
const props = defineProps<{
modelValue: any;
upstreamNodes?: UpstreamNodeInfo[];
hideFormConfig?: boolean;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: any): void;
(e: 'change', value: any): void;
}>();
// --- types ---
interface FieldConfig {
fieldType?: string;
label?: string;
description?: string;
role?: string;
isForm?: boolean;
required?: boolean;
defaultValue?: string | number;
options?: Array<{ label: string; value: string }>;
enumValues?: any[];
valueSource?: { nodeId: string; field: string };
constraint?: {
minLength?: number; maxLength?: number; pattern?: string;
min?: number; max?: number; numberType?: string;
accept?: string; maxSize?: number; maxCount?: number;
uploadRules?: Array<{ format: string; maxSize?: number; maxCount?: number }>;
uploadTotalMaxCount?: number;
uploadTotalMaxSize?: number;
};
}
interface JsonNodeData {
_id: string; key: string; keyEditable: boolean;
type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array';
primitiveValue: string | number | boolean | null;
children: JsonNodeData[];
config?: FieldConfig;
_isWrapped?: boolean;
}
// 前驱节点的可引用输出字段(供「引用上级节点输出」)
interface UpstreamNodeInfo {
id: string;
label: string;
nodeCode: string;
outputFields: { field: string; label: string }[];
}
interface FieldFormData {
nodeKey: string;
nodeId: string;
jsonType: string;
fieldType: string; label: string; description: string; role: string;
isForm: boolean; required: boolean; defaultValue: string; options: Array<{ label: string; value: string }>;
enumValues: any[];
constraint: {
minLength?: number; maxLength?: number; pattern?: string;
min?: number; max?: number; numberType?: string;
accept?: string; maxSize?: number; maxCount?: number;
uploadRules?: Array<{ format: string; maxSize?: number; maxCount?: number }>;
uploadTotalMaxCount?: number;
uploadTotalMaxSize?: number;
};
_createParentId?: string;
_isArrayParent?: boolean;
_childrenCount?: number;
valueSource?: { nodeId: string; field: string };
}
// --- helpers ---
let idCounter = 0;
function genId(): string {
return `jn_${Date.now()}_${++idCounter}_${Math.random().toString(36).slice(2, 6)}`;
}
function getDefaultPrimitive(type: string): string | number | boolean | null {
switch (type) {
case 'number': return 0;
case 'boolean': return false;
case 'null': return null;
default: return '';
}
}
function valueToNode(value: any, key: string, keyEditable: boolean): JsonNodeData {
const id = genId();
if (value === null || value === undefined)
return { _id: id, key, keyEditable, type: 'null', primitiveValue: null, children: [] };
// Detect embedded config: { type: '<jsonType>', value/attrs: <actualValue>, ...config }
if (typeof value === 'object' && !Array.isArray(value) && 'type' in value) {
const jtype = value.type;
if (typeof jtype === 'string' && ['string','number','boolean','null','object','array'].includes(jtype)) {
const dataKey = (jtype === 'object' || jtype === 'array') ? 'attrs' : 'value';
if (dataKey in value) {
const rawVal = value[dataKey];
const cfg: Record<string, any> = {};
for (const k of Object.keys(value)) {
if (k !== 'type' && k !== dataKey) cfg[k] = (value as Record<string, any>)[k];
}
const hasCfg = Object.keys(cfg).length > 0;
if (jtype === 'object') {
const children = rawVal ? Object.entries(rawVal).map(([k, v]) => valueToNode(v, k, true)) : [];
return { _id: id, key, keyEditable, type: 'object', primitiveValue: null, children, config: hasCfg ? cfg : undefined, _isWrapped: true };
}
if (jtype === 'array') {
const children = Array.isArray(rawVal) ? rawVal.map((item, idx) => valueToNode(item, String(idx), false)) : [];
return { _id: id, key, keyEditable, type: 'array', primitiveValue: null, children, config: hasCfg ? cfg : undefined, _isWrapped: true };
}
return {
_id: id, key, keyEditable,
type: jtype as 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array',
primitiveValue: rawVal ?? getDefaultPrimitive(jtype),
children: [],
config: hasCfg ? cfg : undefined,
_isWrapped: true,
};
}
}
}
if (Array.isArray(value))
return { _id: id, key, keyEditable, type: 'array', primitiveValue: null, children: value.map((item, idx) => valueToNode(item, String(idx), false)) };
if (typeof value === 'object')
return { _id: id, key, keyEditable, type: 'object', primitiveValue: null, children: Object.entries(value).map(([k, v]) => valueToNode(v, k, true)) };
return { _id: id, key, keyEditable, type: typeof value as 'string' | 'number' | 'boolean', primitiveValue: value, children: [] };
}
function nodeToValue(node: JsonNodeData): any {
let rawValue: any;
if (node.type === 'object') { const obj: Record<string, any> = {}; for (const c of node.children) obj[c.key] = nodeToValue(c); rawValue = obj; }
else if (node.type === 'array') rawValue = node.children.map(c => nodeToValue(c));
else if (node.type === 'null') rawValue = null;
else if (node.type === 'number') rawValue = Number(node.primitiveValue);
else if (node.type === 'boolean') rawValue = node.primitiveValue === true || node.primitiveValue === 'true';
else rawValue = String(node.primitiveValue);
// 最外层根 object 不套 { type, attrs },直接返回原始对象
if (node === rootNode.value) return rawValue;
// 容器类型(object/array)始终输出 { type, attrs, ...config }
if (node.type === 'object' || node.type === 'array') {
const result: Record<string, any> = { type: node.type, attrs: rawValue };
if (node.config && hasAnyConfig(node)) {
Object.assign(result, node.config);
}
return result;
}
// 标量类型也始终输出 { type, value, ...config }
const result: Record<string, any> = { type: node.type, value: rawValue };
if (node.config && hasAnyConfig(node)) {
Object.assign(result, node.config);
}
return result;
}
function defaultFormType(jsonType: string): string {
const m: Record<string, string> = { string: 'string', number: 'number', boolean: 'switch' };
return m[jsonType] || 'string';
}
/** 收集当前字段同级及以上的所有可选字段(平铺,含完整路径) */
// --- paste JSON ---
const showPasteDialog = ref(false);
const pasteJsonText = ref('');
const pastingNodeId = ref<string | null>(null);
function openPasteDialog(nodeId: string | null) {
pastingNodeId.value = nodeId;
pasteJsonText.value = '';
showPasteDialog.value = true;
}
function handlePasteJson() {
const text = pasteJsonText.value.trim();
if (!text) { ElMessage.warning('请粘贴 JSON 内容'); return; }
try {
const parsed = JSON.parse(text);
if (pastingNodeId.value) {
const node = findNodeById(pastingNodeId.value);
if (!node || (node.type !== 'object' && node.type !== 'array')) {
ElMessage.warning('只能在对象或数组节点上粘贴 JSON');
return;
}
if (Array.isArray(parsed)) {
node.type = 'array';
node.children = parsed.map((item, idx) => valueToNode(item, String(idx), false));
} else if (typeof parsed === 'object' && parsed !== null) {
node.type = 'object';
node.children = Object.entries(parsed).map(([k, v]) => valueToNode(v, k, true));
} else {
ElMessage.warning('粘贴的内容必须是对象或数组');
return;
}
} else {
rootNode.value = valueToNode(parsed, '', false);
}
showPasteDialog.value = false;
pasteJsonText.value = '';
emitChange();
} catch (e: any) {
ElMessage.error('JSON 格式错误: ' + (e.message || ''));
}
}
// --- field config dialog ---
const typeOptionList: { label: string; value: string }[] = [
{ label: '文本框', value: 'string' },
{ label: '多行文本', value: 'textarea' },
{ label: '数字输入', value: 'number' },
{ label: '开关', value: 'switch' },
{ label: '下拉选择', value: 'select' },
{ label: '文件上传', value: 'upload' },
];
const selectNodeId = ref<string | null>(null);
const fieldDialog = ref({
visible: false,
formData: null as FieldFormData | null,
typeOptions: [] as { label: string; value: string }[],
hasConfig: false,
});
function clearSelection() {
selectNodeId.value = null;
fieldDialog.value.visible = false;
fieldDialog.value.formData = null;
}
/** Extract FieldConfig from form data */
function formToConfig(form: FieldFormData): FieldConfig {
const cfg: FieldConfig = {};
// 骨架字段:始终发送,保证后端收到完整结构
cfg.label = form.label || '';
cfg.fieldType = form.fieldType || defaultFormType(form.jsonType);
cfg.isForm = form.isForm ?? true;
cfg.required = form.required ?? false;
// 非骨架字段:有值才发
if (form.description) cfg.description = form.description;
if (form.role) cfg.role = form.role;
if (form.defaultValue) cfg.defaultValue = form.jsonType === 'number' ? Number(form.defaultValue) : form.defaultValue;
if (form.options?.length > 0) cfg.options = [...form.options];
if (form.enumValues?.length > 0) cfg.enumValues = JSON.parse(JSON.stringify(form.enumValues));
if (form.valueSource) cfg.valueSource = { ...form.valueSource };
const hasC = Object.values(form.constraint).some((v: any) => {
if (Array.isArray(v)) return v.length > 0;
return v !== undefined && v !== null && v !== '';
});
if (hasC) {
cfg.constraint = { ...form.constraint };
if (cfg.constraint.uploadRules) {
cfg.constraint.uploadRules = cfg.constraint.uploadRules.map(r => ({ ...r }));
}
}
return cfg;
}
/** Check if a node has any meaningful config beyond default skeleton values */
function hasAnyConfig(node: JsonNodeData): boolean {
const c = node.config;
if (!c) return false;
if (c.label || c.description || c.role || c.defaultValue) return true;
if (c.options?.length) return true;
if (c.enumValues?.length) return true;
if (c.valueSource) return true;
if (c.required) return true;
if (c.isForm === false) return true;
if (c.constraint && Object.keys(c.constraint).length > 0) return true;
if (c.fieldType && c.fieldType !== defaultFormType(node.type)) return true;
return false;
}
/** Open dialog in edit mode for an existing node */
function openFieldDialog(nodeId: string) {
const node = findNodeById(nodeId);
if (!node) return;
const cfg = node.config || {};
fieldDialog.value = {
visible: true,
typeOptions: typeOptionList,
hasConfig: hasAnyConfig(node),
formData: {
nodeId: node._id,
nodeKey: node.key,
jsonType: node.type,
fieldType: cfg.fieldType || defaultFormType(node.type),
label: cfg.label || '',
description: cfg.description || '',
role: cfg.role || '',
isForm: cfg.isForm ?? true,
required: cfg.required ?? false,
defaultValue: String(cfg.defaultValue ?? ''),
options: cfg.options || [],
constraint: cfg.constraint ? { ...cfg.constraint } : {},
enumValues: cfg.enumValues ? JSON.parse(JSON.stringify(cfg.enumValues)) : [],
valueSource: cfg.valueSource ? { ...cfg.valueSource } : undefined,
_childrenCount: node.children.length,
},
};
selectNodeId.value = nodeId;
}
provide('onOpenFieldDialog', openFieldDialog);
/** Open dialog in create mode for adding a new child to a parent node */
function openAddChildDialog(parentNodeId: string) {
const parent = findNodeById(parentNodeId);
if (!parent || (parent.type !== 'object' && parent.type !== 'array')) return;
const isArray = parent.type === 'array';
fieldDialog.value = {
visible: true,
typeOptions: typeOptionList,
hasConfig: false,
formData: {
nodeId: '',
nodeKey: '',
jsonType: 'string',
fieldType: 'string',
label: '',
description: '',
role: '',
isForm: true,
required: false,
defaultValue: '',
options: [],
constraint: {},
enumValues: [],
valueSource: undefined,
_createParentId: parentNodeId,
_isArrayParent: isArray,
_childrenCount: 0,
},
};
selectNodeId.value = parentNodeId;
}
/** Handle save from the config dialog — supports both create and edit modes */
function handleFieldSave(form: FieldFormData) {
if (form._createParentId) {
// --- create mode ---
const parent = findNodeById(form._createParentId);
if (!parent || (parent.type !== 'object' && parent.type !== 'array')) return;
const isArray = parent.type === 'array';
const newNode: JsonNodeData = {
_id: genId(),
key: isArray ? (form.nodeKey || String(parent.children.length)) : (form.nodeKey || 'newKey'),
keyEditable: !isArray,
type: form.jsonType as JsonNodeData['type'],
primitiveValue: getDefaultPrimitive(form.jsonType),
children: (form.jsonType === 'object' || form.jsonType === 'array') ? [] : [],
config: formToConfig(form),
};
parent.children.push(newNode);
fieldDialog.value.visible = false;
emitChange();
clearSelection();
} else {
// --- edit mode ---
const node = findNodeById(form.nodeId);
if (!node) return;
node.key = form.nodeKey;
node.type = form.jsonType as JsonNodeData['type'];
node.config = formToConfig(form);
if (node.type === 'object' || node.type === 'array') {
if (!node.children) node.children = [];
} else {
node.children = [];
if (node.primitiveValue === undefined || node.primitiveValue === null || node.type === 'null') {
node.primitiveValue = getDefaultPrimitive(node.type);
}
}
fieldDialog.value.visible = false;
emitChange();
clearSelection();
}
}
function handleFieldRemove() {
const form = fieldDialog.value.formData;
if (!form) return;
if (form._createParentId) {
fieldDialog.value.visible = false;
clearSelection();
return;
}
const node = findNodeById(form.nodeId);
if (node) {
delete node.config;
fieldDialog.value.visible = false;
emitChange();
clearSelection();
}
}
// --- root node management ---
function createDefaultNode(type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array', key: string, keyEditable: boolean): JsonNodeData {
const id = genId();
const base = { _id: id, key, keyEditable, children: [] as JsonNodeData[] };
switch (type) {
case 'object': return { ...base, type: 'object', primitiveValue: null };
case 'array': return { ...base, type: 'array', primitiveValue: null };
case 'null': return { ...base, type: 'null', primitiveValue: null };
case 'number': return { ...base, type: 'number', primitiveValue: 0 };
case 'boolean': return { ...base, type: 'boolean', primitiveValue: false };
default: return { ...base, type: 'string', primitiveValue: '' };
}
}
const rootNode = ref<JsonNodeData>(createDefaultNode('object', '', false));
let skipNextWatch = false;
watch(() => props.modelValue, (val) => {
if (skipNextWatch) { skipNextWatch = false; return; }
rootNode.value = val === undefined || val === null ? createDefaultNode('object', '', false) : valueToNode(val, '', false);
}, { deep: true, immediate: true });
function emitChange() {
skipNextWatch = true;
const json = nodeToValue(rootNode.value);
emit('update:modelValue', json);
emit('change', json);
}
function findNodeById(nodeId: string, from?: JsonNodeData): JsonNodeData | null {
const s = (n: JsonNodeData): JsonNodeData | null => {
if (n._id === nodeId) return n;
for (const c of n.children) { const f = s(c); if (f) return f; }
return null;
};
return s(from || rootNode.value);
}
defineExpose({
getData: () => nodeToValue(rootNode.value),
setData: (d: any) => { rootNode.value = valueToNode(d, '', false); },
openPasteDialog: () => openPasteDialog(null),
getSelectedNodeId: () => selectNodeId.value,
});
</script>
<style scoped lang="scss">
.json-editor-split {
display: flex;
height: 100%;
.tree-panel {
flex: 1;
min-width: 0;
overflow: auto;
.tree-root {
padding: 4px 0;
}
}
.config-sidebar {
width: 380px;
flex-shrink: 0;
border-left: 1px solid #e2e8f0;
overflow: hidden;
}
.config-sidebar-empty {
display: flex;
align-items: center;
justify-content: center;
.config-placeholder {
text-align: center;
color: #94a3b8;
font-size: 13px;
p { margin: 4px 0; }
.config-placeholder-hint { font-size: 11px; color: #c0c8d4; }
}
}
.paste-tip { margin-bottom: 12px;
:deep(.el-alert__description) { font-size: 13px; }
}
}
</style>
+87 -82
View File
@@ -118,15 +118,22 @@ interface ModelItem {
apiKey?: string;
isPrivate?: number;
isChatModel?: number;
headMsg?: string;
headMsg?: string | Record<string, string>;
operatorName?: string;
responseBody?: Record<string, unknown>;
responseTokenField?: string;
tokenConfig?: Record<string, unknown> | string;
extendMapping?: Record<string, unknown> | string;
queryConfig?: Record<string, unknown>;
streamConfig?: Record<string, unknown>;
form?: ModelFormEntry[] | Record<string, unknown>;
requestMapping?: Record<string, unknown>;
requiredFields?: string[];
firstFrame?: string;
lastFrame?: string;
responseMapping?: Record<string, unknown>;
callMode?: number;
isAsync?: number;
maxConcurrency?: number;
queueLimit?: number;
timeoutSeconds?: number;
@@ -271,74 +278,71 @@ const fieldsToUnknownObject = (fields: Array<{ key: string; value: string }>) =>
return obj;
};
const flattenNestedObject = (obj: Record<string, unknown>, prefix = ''): Array<{ key: string; value: string }> => {
const rows: Array<{ key: string; value: string }> = [];
Object.entries(obj || {}).forEach(([k, v]) => {
const fk = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === 'object' && !Array.isArray(v)) {
rows.push(...flattenNestedObject(v as Record<string, unknown>, fk));
return;
}
rows.push({ key: fk, value: String(v ?? '') });
});
return rows;
};
const nestFieldsToObject = (fields: Array<{ key: string; value: string }>) => {
const root: Record<string, unknown> = {};
fields.forEach((f) => {
const path = String(f.key || '').trim();
if (!path) return;
const parts = path
.split('.')
.map((p) => p.trim())
.filter(Boolean);
if (!parts.length) return;
let cur: Record<string, unknown> = root;
parts.forEach((part, idx) => {
if (idx === parts.length - 1) {
cur[part] = String(f.value ?? '');
return;
}
if (!cur[part] || typeof cur[part] !== 'object' || Array.isArray(cur[part])) {
cur[part] = {};
}
cur = cur[part] as Record<string, unknown>;
});
});
return root;
};
const buildQueryConfigFromRaw = (rawQc: Record<string, unknown> | null): Record<string, unknown> => {
if (!rawQc) return { responseType: 'sync', callbackUrl: '' };
const rt = String(rawQc.responseType || 'sync');
if (rt === 'callback') return { responseType: 'callback', callbackUrl: String(rawQc.callbackUrl || '') };
if (rt === 'pull') {
const hFields = Object.entries((rawQc.headers as Record<string, unknown>) || {}).map(([k, v]) => ({ key: k, value: String(v ?? '') }));
const bFields = flattenNestedObject((rawQc.body as Record<string, unknown>) || {});
return {
responseType: 'pull',
callbackUrl: '',
method: String(rawQc.method || 'GET'),
url: String(rawQc.url || ''),
headers: fieldsToUnknownObject(hFields),
body: nestFieldsToObject(bFields),
response: ((rawQc.response as unknown[]) || [])
.map((item) => {
if (typeof item === 'string') {
return { value: item, isTokenField: false, isMainBody: false };
}
const row = item as Record<string, unknown>;
return {
value: String(row.value ?? ''),
isTokenField: Boolean(row.isTokenField),
isMainBody: Boolean(row.isMainBody),
};
})
.filter((row) => row.value !== ''),
};
const parseFormEntries = (raw: unknown): ModelFormEntry[] => {
if (Array.isArray(raw)) {
return (raw as ModelFormEntry[])
.filter((item) => item && item.key !== undefined)
.map((item) => ({
key: String(item.key ?? '').trim(),
value: String(item.value ?? ''),
}))
.filter((item) => item.key !== '');
}
return { responseType: 'sync', callbackUrl: '' };
if (raw && typeof raw === 'object') {
return Object.entries(raw as Record<string, unknown>).map(([key, value]) => {
let nextValue = value;
if (nextValue && typeof nextValue === 'object' && !Array.isArray(nextValue) && 'value' in nextValue) {
nextValue = (nextValue as { value: unknown }).value;
}
return {
key: String(key).trim(),
value: String(nextValue ?? ''),
};
});
}
return [];
};
const normalizeQueryConfig = (raw: unknown): Record<string, unknown> => {
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
return raw as Record<string, unknown>;
}
return {};
};
const parseHeadMsgRecord = (raw: ModelItem['headMsg']) => {
const headMsgRecord: Record<string, string> = {};
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
Object.entries(parsed).forEach(([k, v]) => {
headMsgRecord[k] = String(v);
});
return headMsgRecord;
}
} catch {
const pairs = raw.split(',');
pairs.forEach((pair) => {
const idx = pair.indexOf(':');
if (idx === -1) return;
const key = pair.slice(0, idx).trim();
const value = pair.slice(idx + 1).trim();
if (key) {
headMsgRecord[key] = value;
}
});
return headMsgRecord;
}
}
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
Object.entries(raw).forEach(([k, v]) => {
headMsgRecord[k] = String(v);
});
}
return headMsgRecord;
};
const fetchModelList = async () => {
@@ -396,25 +400,25 @@ const handleCreatePrivateModel = async () => {
creatingModel.value = true;
const builtInModel = builtInModelToClone.value;
const formList: ModelFormEntry[] = Array.isArray(builtInModel.form)
? (builtInModel.form as ModelFormEntry[])
: Object.entries((builtInModel.form as Record<string, unknown>) || {}).map(([key, value]) => ({
key: String(key),
value: String(value ?? ''),
}));
const formList = parseFormEntries(builtInModel.form);
const createParams: CreateModelParams = {
modelName: apiKeyForm.modelName,
modelType: builtInModel.modelType,
operatorName: builtInModel.operatorName || '',
baseUrl: builtInModel.baseUrl,
httpMethod: builtInModel.httpMethod || 'POST',
headMsg: builtInModel.headMsg || '',
headMsg: parseHeadMsgRecord(builtInModel.headMsg),
isPrivate: builtInModel.isPrivate ?? 1,
enabled: builtInModel.enabled ?? 1,
isChatModel: builtInModel.isChatModel || 0,
callMode: builtInModel.callMode ?? builtInModel.isAsync ?? 0,
apiKey: apiKeyForm.apiKey,
form: formList,
requestMapping: (builtInModel.requestMapping as Record<string, unknown>) || {},
requiredFields: Array.isArray(builtInModel.requiredFields) ? builtInModel.requiredFields : [],
firstFrame: String(builtInModel.firstFrame || ''),
lastFrame: String(builtInModel.lastFrame || ''),
responseMapping: (builtInModel.responseMapping as Record<string, unknown>) || {},
responseBody: builtInModel.responseBody || {},
maxConcurrency: builtInModel.maxConcurrency || 10,
@@ -424,19 +428,20 @@ const handleCreatePrivateModel = async () => {
retryTimes: builtInModel.retryTimes || 3,
retryQueueMaxSeconds: builtInModel.retryQueueMaxSeconds || 60,
autoCleanSeconds: builtInModel.autoCleanSeconds || 300,
remark: builtInModel.remark || '',
extendMapping: fieldsToUnknownObject(
Object.entries(parseJsonObjectField(builtInModel.extendMapping)).map(([k, v]) => ({ key: k, value: String(v ?? '') }))
),
tokenConfig: fieldsToUnknownObject(
Object.entries(parseJsonObjectField(builtInModel.tokenConfig)).map(([k, v]) => ({ key: k, value: String(v ?? '') }))
),
queryConfig: buildQueryConfigFromRaw(
builtInModel.queryConfig && typeof builtInModel.queryConfig === 'object' && !Array.isArray(builtInModel.queryConfig)
? (builtInModel.queryConfig as Record<string, unknown>)
: null
),
queryConfig: normalizeQueryConfig(builtInModel.queryConfig),
responseTokenField: String(builtInModel.responseTokenField || ''),
streamConfig:
Number(builtInModel.callMode ?? builtInModel.isAsync ?? 0) === 2
? builtInModel.streamConfig && typeof builtInModel.streamConfig === 'object' && !Array.isArray(builtInModel.streamConfig)
? (builtInModel.streamConfig as Record<string, unknown>)
: {}
: undefined,
};
const res: any = await addModelModule(createParams);
@@ -0,0 +1,456 @@
<template>
<div class="patch-template-editor">
<el-divider content-position="left">贴片模板</el-divider>
<div v-for="(template, index) in modelValue" :key="index" class="template-card">
<div class="template-card-header">
<span class="template-card-title">模板 #{{ index + 1 }}</span>
<el-button type="danger" :icon="Delete" circle size="small" @click="removeTemplate(index)" />
</div>
<div class="template-card-body">
<!-- 模板文件上传 -->
<div class="template-field-row">
<span class="field-label">模板文件</span>
<div class="field-control">
<el-upload
:key="`upload-${index}`"
:auto-upload="false"
:show-file-list="false"
:accept="'.html,.htm'"
:on-change="(file: any) => handleTemplateUpload(index, file)"
>
<el-button size="small" type="primary" :loading="uploadingIndex === index" :disabled="uploadingIndex !== null">
<el-icon><Upload /></el-icon> {{ uploadingIndex === index ? '上传中...' : (template.url ? '替换模板' : '上传模板') }}
</el-button>
</el-upload>
<el-button size="small" @click="handleDownload">
<el-icon><Download /></el-icon> 下载参考模板
</el-button>
<el-tag v-if="template.url" :type="template.url === DEFAULT_TEMPLATE_URL ? 'info' : 'success'" size="small" class="uploaded-tag">{{ template.url === DEFAULT_TEMPLATE_URL ? '已有默认模板' : '已上传' }}</el-tag>
</div>
</div>
<!-- 起始/结束位置 -->
<div class="template-field-row">
<span class="field-label">开始结束时间</span>
<div class="field-control field-control-inline">
<el-input-number v-model="template.start" :min="0" :max="99999" size="small" controls-position="right" class="inline-number" />
<span class="inline-separator">~</span>
<el-input-number v-model="template.end" :min="0" :max="99999" size="small" controls-position="right" class="inline-number" />
</div>
</div>
<!-- 标题 -->
<div class="template-field-row">
<span class="field-label">标题</span>
<div class="field-control">
<el-input v-model="template.data.title" placeholder="可选填,如果没有内容将会没有贴片信息" size="small" />
</div>
</div>
<!-- 产品图片动态追加 -->
<div class="template-field-row template-field-row-vertical">
<span class="field-label">图片</span>
<div class="field-control field-control-images">
<div v-for="(img, imgIdx) in getProductImages(template)" :key="imgIdx" class="image-item">
<span class="image-index">#{{ imgIdx + 1 }}</span>
<div v-if="img.url" class="image-preview-wrapper">
<img :src="img.url" class="image-preview" @click="previewImage(img.url)" />
<el-button class="image-remove-btn" type="danger" :icon="Close" circle size="small" @click="removeProductImage(template, imgIdx)" />
</div>
<el-upload
v-else
:key="`img-${index}-${imgIdx}`"
:auto-upload="false"
:show-file-list="false"
:accept="'image/*'"
:on-change="(file: any) => handleImageUpload(index, imgIdx, file)"
>
<el-button size="small" type="primary" class="image-upload-btn">
<el-icon><Plus /></el-icon> 上传图片
</el-button>
</el-upload>
</div>
<el-button size="small" class="add-image-btn" @click="addProductImage(template)">
<el-icon><Plus /></el-icon> 添加图片
</el-button>
</div>
</div>
<!-- 产品描述 -->
<div class="template-field-row template-field-row-vertical">
<span class="field-label">产品描述</span>
<div class="field-control">
<el-input
v-model="template.data.product_desc"
type="textarea"
:rows="2"
placeholder="可选填,如果没有内容将会没有贴片信息"
size="small"
/>
</div>
</div>
<!-- 产品信息 -->
<div class="template-field-row template-field-row-vertical">
<span class="field-label">产品信息</span>
<div class="field-control">
<el-input
v-model="template.data.product_info"
type="textarea"
:rows="2"
placeholder="可选填,如果没有内容将会没有贴片信息"
size="small"
/>
</div>
</div>
<!-- 免责声明 -->
<div class="template-field-row template-field-row-vertical">
<span class="field-label">免责声明</span>
<div class="field-control">
<el-input v-model="template.data.disclaimer" type="textarea" :rows="2" placeholder="可选填,如果没有内容将会没有贴片信息" size="small" />
</div>
</div>
</div>
</div>
<!-- 添加模板按钮 -->
<el-button type="primary" :icon="Plus" class="add-template-btn" @click="addTemplate"> 添加模板 </el-button>
<!-- 图片预览弹窗 -->
<el-image-viewer v-if="previewVisible" :url-list="[previewUrl]" @close="previewVisible = false" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Plus, Delete, Close, Upload, Download } from '@element-plus/icons-vue';
import { uploadFile } from '/@/api/common/upload';
import { ElMessage } from 'element-plus';
export interface PatchTemplate {
url: string;
start: number;
end: number;
data: {
title: string;
product_desc: string;
product_info: string;
disclaimer: string;
[key: string]: string;
};
}
/** 获取模板中所有产品图片(按 product_image_N 顺序,含已添加的空槽位) */
function getProductImages(template: PatchTemplate): { key: string; url: string }[] {
const images: { key: string; url: string }[] = [];
for (let i = 1; i <= 20; i++) {
const key = `product_image_${i}`;
if (key in template.data) {
images.push({ key, url: template.data[key] || '' });
}
}
return images;
}
/** 获取下一个可用的 product_image_N key */
function nextImageKey(template: PatchTemplate): string | null {
for (let i = 1; i <= 20; i++) {
const key = `product_image_${i}`;
if (!template.data[key]) {
return key;
}
}
return null;
}
const DEFAULT_TEMPLATE_URL = 'https://cdn.redpowerfuture.com/tenantid-1/vertical_template_example.html';
const props = defineProps<{
modelValue: PatchTemplate[];
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: PatchTemplate[]): void;
}>();
//
const previewVisible = ref(false);
const previewUrl = ref('');
//
const uploadingIndex = ref<number | null>(null);
const previewImage = (url: string) => {
previewUrl.value = url;
previewVisible.value = true;
};
const updateTemplates = (newTemplates: PatchTemplate[]) => {
emit('update:modelValue', [...newTemplates]);
};
const addTemplate = () => {
const newTemplate: PatchTemplate = {
url: DEFAULT_TEMPLATE_URL,
start: 0,
end: 0,
data: {
title: '',
product_image_1: '',
product_desc: '',
product_info: '',
disclaimer: '',
},
};
updateTemplates([...props.modelValue, newTemplate]);
};
const removeTemplate = (index: number) => {
const newList = props.modelValue.filter((_, i) => i !== index);
updateTemplates(newList);
};
const addProductImage = (template: PatchTemplate) => {
const key = nextImageKey(template);
if (!key) {
ElMessage.warning('产品图片最多 20 张');
return;
}
template.data[key] = '';
updateTemplates([...props.modelValue]);
};
const removeProductImage = (template: PatchTemplate, imgIdx: number) => {
// key
const images = getProductImages(template);
if (imgIdx >= images.length) return;
const removedKey = images[imgIdx].key;
delete template.data[removedKey];
//
for (let i = imgIdx + 1; i < images.length; i++) {
const oldKey = images[i].key;
const newKey = `product_image_${i}`;
if (oldKey in template.data) {
template.data[newKey] = template.data[oldKey];
if (newKey !== oldKey) {
delete template.data[oldKey];
}
}
}
updateTemplates([...props.modelValue]);
};
const handleTemplateUpload = async (index: number, file: any) => {
const raw = file.raw;
if (!raw) return;
uploadingIndex.value = index;
try {
const uploadRes = await uploadFile(raw, { timeout: 0 });
if (!uploadRes?.data?.fileURL) throw new Error('上传失败:未返回文件URL');
const fileUrl = uploadRes.data.fileAddressPrefix ? `${uploadRes.data.fileAddressPrefix}${uploadRes.data.fileURL}` : uploadRes.data.fileURL;
const newList = [...props.modelValue];
newList[index].url = fileUrl;
updateTemplates(newList);
} catch (error: any) {
ElMessage.error(error?.message || "模板上传失败");
} finally {
uploadingIndex.value = null;
}
};
const handleImageUpload = async (templateIndex: number, imgIdx: number, file: any) => {
const raw = file.raw;
if (!raw) return;
try {
const uploadRes = await uploadFile(raw, { timeout: 0 });
if (!uploadRes?.data?.fileURL) throw new Error('上传失败:未返回文件URL');
const fileUrl = uploadRes.data.fileAddressPrefix ? `${uploadRes.data.fileAddressPrefix}${uploadRes.data.fileURL}` : uploadRes.data.fileURL;
const newList = [...props.modelValue];
const images = getProductImages(newList[templateIndex]);
if (imgIdx < images.length) {
newList[templateIndex].data[images[imgIdx].key] = fileUrl;
}
updateTemplates(newList);
ElMessage.success('图片上传成功');
} catch (error: any) {
ElMessage.error(error?.message || '图片上传失败');
}
};
// CDN blob <a download>
const handleDownload = async (_e?: Event) => {
try {
const res = await fetch(DEFAULT_TEMPLATE_URL);
if (!res.ok) throw new Error('参考模板获取失败');
const blob = await res.blob();
const fileName = decodeURIComponent(DEFAULT_TEMPLATE_URL.split('/').pop() || 'vertical_template_example.html');
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch (error: any) {
ElMessage.error(error?.message || '参考模板下载失败');
}
};
</script>
<style scoped lang="scss">
.patch-template-editor {
width: 100%;
.template-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
.template-card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #fafbfc;
border-bottom: 1px solid #f1f5f9;
.template-card-title {
font-weight: 600;
font-size: 13px;
color: #1e293b;
}
}
.template-card-body {
padding: 12px;
}
}
.template-field-row {
display: flex;
align-items: center;
margin-bottom: 10px;
gap: 8px;
&.template-field-row-vertical {
align-items: flex-start;
}
.field-label {
min-width: 70px;
font-size: 12px;
color: #475569;
font-weight: 500;
flex-shrink: 0;
}
.field-control {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
&.field-control-inline {
flex-wrap: nowrap;
}
.inline-number {
width: 120px;
}
.inline-separator {
color: #94a3b8;
font-size: 13px;
}
.uploaded-tag {
margin-left: 4px;
}
}
.field-control-images {
display: flex;
flex-direction: column;
gap: 8px;
.image-item {
display: flex;
align-items: center;
gap: 8px;
.image-index {
font-size: 12px;
color: #64748b;
font-weight: 600;
min-width: 20px;
}
.image-preview-wrapper {
position: relative;
display: inline-block;
.image-preview {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 4px;
border: 1px solid #e2e8f0;
cursor: pointer;
transition: transform 0.15s;
&:hover {
transform: scale(1.05);
}
}
.image-remove-btn {
position: absolute;
top: -8px;
right: -8px;
width: 18px;
height: 18px;
padding: 0;
}
}
.image-upload-btn {
display: inline-flex;
align-items: center;
gap: 2px;
}
}
.add-image-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
}
}
.add-template-btn {
width: 100%;
margin-top: 4px;
}
}
</style>
+52 -3
View File
@@ -24,6 +24,12 @@
<!-- </el-dropdown-menu>-->
<!-- </template>-->
<!-- </el-dropdown>-->
<!-- 租户名称和余额 -->
<div class="layout-navbars-breadcrumb-tenant" v-if="tenantInfo">
<span class="tenant-name">租户: {{ tenantInfo.tenantName }}</span>
<span class="tenant-divider">|</span>
<span class="tenant-balance">余额: {{ tenantInfo.surplus.toFixed(2) }}</span>
</div>
<div class="layout-navbars-breadcrumb-user-icon" @click="onSearchClick">
<el-icon :title="$t('message.user.title2')">
<ele-Search />
@@ -94,6 +100,7 @@ import UserNews from '/@/layout/navBars/breadcrumb/userNews.vue';
import Search from '/@/layout/navBars/breadcrumb/search.vue';
import {logout} from "/@/api/login";
import {removeCache} from "/@/api/system/cache";
import { getTenantDetails } from "/@/api/system/tenant";
export default defineComponent({
name: 'layoutBreadcrumbUser',
@@ -112,6 +119,25 @@ export default defineComponent({
disabledI18n: 'zh-cn',
disabledSize: 'large',
});
//
const tenantInfo = ref<{ tenantName: string; surplus: number } | null>(null);
//
const initTenantInfo = async () => {
const userInfo = Session.get('userInfo') as any;
const tenantId = userInfo?.tenantId;
if (!tenantId) return;
try {
const res: any = await getTenantDetails(tenantId);
if (res.code === 0 || res.code === 200) {
tenantInfo.value = {
tenantName: res.data.tenant.tenantName,
surplus: res.data.tenant.surplus,
};
}
} catch (e) {
console.error('获取租户信息失败', e);
}
};
//
const layoutUserFlexNum = computed(() => {
let num: string | number = '';
@@ -177,11 +203,12 @@ export default defineComponent({
}
},
})
.then(async () => {
.then(() => {
// 退
Session.clearAuth();
//
await router.replace('/login');
// SPA router.replace
// setAddRoute hasRoute " 404"
window.location.href = import.meta.env.BASE_URL + '#/login';
})
.catch(() => {});
} else if (path === 'wareHouse') {
@@ -252,6 +279,7 @@ export default defineComponent({
initI18n();
initComponentSize();
}
initTenantInfo();
});
return {
userInfos,
@@ -264,6 +292,7 @@ export default defineComponent({
removeCacheClick,
searchRef,
layoutUserFlexNum,
tenantInfo,
...toRefs(state),
};
},
@@ -315,4 +344,24 @@ export default defineComponent({
top: 12px;
}
}
//
.layout-navbars-breadcrumb-tenant {
display: flex;
align-items: center;
gap: 6px;
padding: 0 12px;
height: 50px;
font-size: 13px;
color: var(--next-bg-topBarColor);
white-space: nowrap;
.tenant-name {
font-weight: 600;
}
.tenant-divider {
opacity: 0.4;
}
.tenant-balance {
opacity: 0.85;
}
}
</style>
+24 -7
View File
@@ -34,6 +34,23 @@ export const router = createRouter({
history: createWebHashHistory(),
routes: staticRoutes,
});
const hasValidResolvedRoute = (to: any) => {
const resolved = router.resolve(to.fullPath);
const matched = resolved.matched || [];
const isStaticFallbackRoute = matched.length === 1 && matched[0]?.path === '/:pathMatch(.*)*';
const isRedirectTo404Route = matched.length > 0 && matched.every((item) => item.redirect === '/404');
return matched.length > 0 && !isStaticFallbackRoute && !isRedirectTo404Route;
};
const goResolvedOr404AfterInit = (to: any, next: any) => {
if (hasValidResolvedRoute(to)) {
next({ ...to, replace: true });
} else {
next('/404');
}
};
/**
*
@@ -106,18 +123,18 @@ router.beforeEach(async (to, from, next) => {
const { routesList } = storeToRefs(storesRoutesList);
if (routesList.value.length === 0) {
if (isRequestRoutes) {
// 后端控制路由:路由数据初始化,防止刷新时丢失
await initBackEndControlRoutes();
// 动态添加路由:防止非首页刷新时跳转回首页的问题
// 确保 addRoute() 时动态添加的路由已经被完全加载上去
next({ ...to, replace: true });
goResolvedOr404AfterInit(to, next);
} else {
// https://gitee.com/lyt-top/vue-next-admin/issues/I5F1HP
await initFrontEndControlRoutes();
next({ ...to, replace: true });
goResolvedOr404AfterInit(to, next);
}
} else {
next();
if (hasValidResolvedRoute(to)) {
next();
} else {
next('/404');
}
}
}
}
+14 -5
View File
@@ -1,4 +1,4 @@
import { RouteRecordRaw } from 'vue-router';
import { RouteRecordRaw } from 'vue-router';
/**
* meta对象参数说明
@@ -69,9 +69,9 @@ export const dynamicRoutes: Array<RouteRecordRaw> = [
/**
*
*
*
* 父级菜单: 库存作业 (/assets/operation)
*
*
* :
* - : /assets/operation/count
* - 组件路径: assets/operation/count/index
@@ -1077,8 +1077,8 @@ export const demoRoutes: Array<RouteRecordRaw> = [
*/
export const notFoundAndNoPower = [
{
path: '/:path(.*)*',
name: 'notFound',
path: '/404',
name: 'notFoundPage',
component: () => import('/@/views/error/404.vue'),
meta: {
title: 'message.staticRoutes.notFound',
@@ -1094,6 +1094,15 @@ export const notFoundAndNoPower = [
isHide: true,
},
},
{
path: '/:path(.*)*',
name: 'notFound',
redirect: '/404',
meta: {
title: 'message.staticRoutes.notFound',
isHide: true,
},
},
];
/**
+8
View File
@@ -31,6 +31,10 @@
color: #f56c6c !important;
}
.op-btn-recharge {
color: #67c23a !important; // 充值 绿色
}
[data-theme='dark'] {
// textarea - css vars
--w-e-textarea-bg-color: var(--el-color-white) !important;
@@ -49,6 +53,10 @@
color: #f56c6c !important; // 删除 红色
}
.op-btn-recharge {
color: #67c23a !important; // 充值 绿色
}
// toolbar - css vars
--w-e-toolbar-color: var(--el-text-color-primary) !important;
--w-e-toolbar-bg-color: var(--el-color-white) !important;
+21
View File
@@ -0,0 +1,21 @@
declare module 'json-schema-editor' {
import { DefineComponent } from 'vue';
export const JsonEditor: DefineComponent<{
modelValue?: Record<string, unknown>;
}>;
export const FieldConfigDialog: DefineComponent;
export const JsonNode: DefineComponent;
}
declare module '/@/components/json-schema-editor' {
import { DefineComponent } from 'vue';
export const JsonEditor: DefineComponent<{
modelValue?: Record<string, unknown>;
}>;
export const FieldConfigDialog: DefineComponent;
export const JsonNode: DefineComponent;
}
+698 -57
View File
@@ -1,10 +1,82 @@
<template>
<div class="chat-list">
<div class="chat-divider">今天 15:14</div>
<div v-for="msg in messages" :key="msg.id" class="message-row" :class="{ 'is-user': msg.isUser }">
<div ref="chatListRef" class="chat-list" @scroll.passive="handleScroll">
<!-- 顶部有更多历史时上滑加载更早记录 -->
<div v-if="hasMore || loadingMore" class="load-more-bar">
<span v-if="loadingMore" class="load-more-spinner"></span>
{{ loadingMore ? '加载中…' : '继续上滑,加载更早的记录' }}
</div>
<div
v-for="msg in messages"
:key="msg.id"
class="message-row"
:class="{ 'is-user': msg.isUser, 'is-form': msg.type === 'form', 'is-error': !msg.isUser && isErrorMsg(msg) }"
>
<div class="bubble-wrap">
<div class="bubble">{{ msg.content }}</div>
<!-- 工作流表单卡片不套气泡独立卡片渲染 -->
<WorkflowFormCard
v-if="msg.type === 'form'"
:execute-signal="executeRequest?.msgId === msg.id ? executeRequest : null"
:detail="msg.form"
:readonly="msg.formStatus === 'done' || msg.formStatus === 'failed'"
:editable="lastFormMsg ? msg.id === lastFormMsg.id : false"
:submitting="msg.formStatus === 'running'"
:form-error="msg.formError"
:progress="msg.formProgress"
@submit="emit('form-submit', msg, $event)"
/>
<template v-else>
<div class="bubble">
<template v-if="msg.isUser">{{ msg.content }}</template>
<template v-else>
<!-- 初始加载动画 -->
<template v-if="msg.loading && !msg.thinking && !msg.content">
<span class="loading-text">思考中</span><span class="loading-dots"><i></i><i></i><i></i></span>
</template>
<!-- 思考区DeepSeek 风格折叠块 -->
<div v-if="msg.thinking" class="thinking-block">
<button class="thinking-toggle" type="button" @click="toggleThinking(msg)">
<span class="thinking-icon">🧠</span>
<span class="thinking-title">{{ thinkingTitle(msg) }}</span>
<span class="thinking-arrow" :class="{ collapsed: !isThinkingExpanded(msg) }"></span>
</button>
<div v-if="isThinkingExpanded(msg)" class="thinking-content">
<div class="thinking-text">{{ msg.thinking }}</div>
</div>
</div>
<!-- 回答区Markdown 渲染 -->
<div
v-if="msg.content"
class="answer-block"
v-html="renderAnswer(msg.content)"
@click="handleAnswerClick"
></div>
</template>
</div>
<!-- 工作流执行产出文件卡片挂在结果消息上预览/下载/删除 -->
<WorkflowOutputCard
v-if="msg.outputs && msg.outputs.length"
:outputs="msg.outputs"
:resolve-url="props.resolveUrl"
@preview="emit('output-preview', msg, $event)"
@download="emit('output-download', msg, $event)"
@delete="emit('output-delete', msg, $event)"
/>
<!-- AI 消息操作栏hover 显示 -->
<div v-if="!msg.isUser && msg.content" class="msg-actions">
<button class="msg-action" title="复制回答" @click="copyText(msg.content)">
<el-icon><DocumentCopy /></el-icon>
</button>
<button v-if="msg.recordId" class="msg-action is-danger" title="删除对话" @click="emit('delete', msg)">
<el-icon><Delete /></el-icon>
</button>
</div>
<!-- 用户消息操作栏hover 显示重新生成 + 删除 -->
<div v-if="msg.isUser" class="msg-actions">
<button v-if="msg.recordId" class="msg-action is-danger" title="删除对话" @click="emit('delete', msg)">
<el-icon><Delete /></el-icon>
</button>
</div>
</template>
<div class="time">{{ msg.time }}</div>
</div>
</div>
@@ -12,58 +84,266 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { Delete, DocumentCopy } from '@element-plus/icons-vue';
import 'highlight.js/styles/github-dark.css';
import { renderMarkdown } from '../utils/markdown';
import type { WorkflowOutput } from '../utils/flowDsl';
import type { WorkflowNodeProgress } from '../utils/wsMessage';
import WorkflowFormCard from './WorkflowFormCard.vue';
import WorkflowOutputCard from './WorkflowOutputCard.vue';
interface Message {
id: number;
interface ChatMessage {
id: string;
content: string;
time: string;
isUser: boolean;
loading?: boolean;
thinking?: string;
thinkingSeconds?: number;
// id / session/get
recordId?: string;
recordType?: string;
// type==='form' WorkflowFormCard
type?: 'form';
form?: any;
formStatus?: 'editing' | 'running' | 'done' | 'failed';
formError?: string;
// node_start/node_complete
formProgress?: WorkflowNodeProgress;
// workflow
outputs?: WorkflowOutput[];
}
const messages = ref<Message[]>([
{
id: 1,
content: '你好!我叫知子,很高兴为您服务。您今天想聊些什么呢?',
time: '09:30',
isUser: false,
interface Emits {
(e: 'delete', msg: ChatMessage): void;
(e: 'form-submit', msg: ChatMessage, payload: any): void;
(e: 'output-preview', msg: ChatMessage, output: WorkflowOutput): void;
(e: 'output-download', msg: ChatMessage, output: WorkflowOutput): void;
(e: 'output-delete', msg: ChatMessage, output: WorkflowOutput): void;
(e: 'load-more'): void;
}
interface Props {
messages: ChatMessage[];
hasMore?: boolean;
loadingMore?: boolean;
// URL buildAssetUrl
resolveUrl?: (url: string) => string;
// InputBar
//
executeRequest?: { msgId: string; action: 'submit'; nonce: number } | null;
}
const props = withDefaults(defineProps<Props>(), {
hasMore: false,
loadingMore: false,
resolveUrl: undefined,
executeRequest: null,
});
const emit = defineEmits<Emits>();
const chatListRef = ref<HTMLElement | null>(null);
// done/failed便
const lastFormMsg = computed(() =>
[...props.messages].reverse().find((m) => m.type === 'form' && !!m.formStatus && m.formStatus !== 'editing')
);
const isErrorMsg = (msg: ChatMessage): boolean => !msg.isUser && String(msg.content || '').startsWith('❌');
// DeepSeek
const thinkingExpanded = reactive<Record<string, boolean>>({});
const isThinkingExpanded = (msg: ChatMessage): boolean =>
msg.id in thinkingExpanded ? thinkingExpanded[msg.id] : !msg.content;
const toggleThinking = (msg: ChatMessage) => {
thinkingExpanded[msg.id] = !isThinkingExpanded(msg);
};
// Markdown content
//
const mdCache = new Map<string, string>();
const MD_CACHE_MAX = 300;
const renderAnswer = (content: string): string => {
if (mdCache.size >= MD_CACHE_MAX) mdCache.clear();
if (!mdCache.has(content)) mdCache.set(content, renderMarkdown(content));
return mdCache.get(content)!;
};
// API + 退
const copyText = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
ElMessage.success('已复制');
};
// .code-copy-btn
const handleAnswerClick = (e: MouseEvent) => {
const el = e.target as HTMLElement;
const btn = el.closest('.code-copy-btn');
if (!btn) return;
const code = btn.closest('.code-wrapper')?.querySelector('code');
if (code) copyText(code.textContent || '');
};
// " X """
const thinkingTitle = (msg: ChatMessage): string => {
if (!msg.content) return '思考中…';
return msg.thinkingSeconds != null ? `已深度思考(用时 ${msg.thinkingSeconds} 秒)` : '已深度思考';
};
// px""
const NEAR_BOTTOM_THRESHOLD = 120;
// ""isTrusted
let isAtBottom = true;
// autoLoading load-more ""
let autoLoading = false;
// hasMore true
// load-more 线 handleScroll
const MAX_AUTO_LOAD = 5;
let autoLoadCount = 0;
const canAutoLoad = (): boolean => autoLoadCount < MAX_AUTO_LOAD;
//
// immediate ChatList
// ""
watch(
() => props.messages.map((m) => `${m.id}|${m.content}|${m.thinking || ''}|${m.loading ? 1 : 0}`).join('\n'),
() => {
if (props.loadingMore) return;
nextTick(() => {
const el = chatListRef.value;
if (!el) return;
// isAtBottom
if (isAtBottom) el.scrollTop = el.scrollHeight;
if (props.messages.length > 0 && props.hasMore && el.scrollHeight <= el.clientHeight) {
if (canAutoLoad()) {
autoLoading = true;
autoLoadCount++;
emit('load-more');
} else {
//
autoLoading = false;
}
}
});
},
{
id: 2,
content: '你好!我想了解下这个系统都能做什么。',
time: '09:31',
isUser: true,
},
{
id: 3,
content: '当然可以,这个系统提供了多种功能:日记、文件、快捷指令、快捷回复,以及技能管理和模型管理入口。你想先看哪一块?',
time: '09:31',
isUser: false,
},
]);
{ immediate: true }
);
//
// watch messages watch nextTick ""
const prevScrollHeight = ref(0);
watch(
() => props.loadingMore,
(loading, prev) => {
if (loading && !prev) {
const el = chatListRef.value;
prevScrollHeight.value = autoLoading ? 0 : el && el.scrollHeight > el.clientHeight ? el.scrollHeight : 0;
} else if (!loading && prev) {
nextTick(() => {
const el = chatListRef.value;
if (!el) return;
if (prevScrollHeight.value) {
//
el.scrollTop = el.scrollHeight - prevScrollHeight.value;
prevScrollHeight.value = 0;
} else if (autoLoading) {
// watch loadingMore loadingMore true flush
// scrollTop
el.scrollTop = el.scrollHeight;
if (props.messages.length > 0 && props.hasMore && el.scrollHeight <= el.clientHeight) {
if (canAutoLoad()) {
autoLoadCount++;
emit('load-more');
} else {
//
autoLoading = false;
}
} else {
autoLoading = false;
}
}
});
}
}
);
// sessionLoadingMore
const handleScroll = (e: Event) => {
// scrollTop scroll isTrusted=false
// / scrollTop 0"" autoLoading
if (!e.isTrusted) return;
const el = chatListRef.value;
if (!el) return;
// ""
isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_THRESHOLD;
if (!props.hasMore || props.loadingMore) return;
if (el.scrollTop <= 4) {
// 使
autoLoading = false;
autoLoadCount = 0;
emit('load-more');
}
};
onMounted(() => {
const el = chatListRef.value;
// immediate watch nextTick Transition DOM el null
// DOM sh requestAnimationFrame
requestAnimationFrame(() => {
if (el) el.scrollTop = el.scrollHeight;
});
});
</script>
<style scoped lang="scss">
.chat-list {
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 16px;
padding: 28px 0 10px;
padding: 8px 0 16px;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.chat-divider {
align-self: center;
font-size: 12px;
color: #64748b;
line-height: 1;
padding: 4px 16px;
background: rgba(255, 255, 255, 0.7);
border-radius: 999px;
border: 1px solid rgba(226, 233, 244, 0.8);
letter-spacing: 0.2px;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.05);
/* 顶部:加载更早记录提示条 */
.load-more-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
flex-shrink: 0;
font-size: 11px;
color: #cbd5e1;
padding: 4px 0 10px;
}
.load-more-spinner {
width: 12px;
height: 12px;
border: 2px solid #e2e8f0;
border-top-color: #94a3b8;
border-radius: 50%;
animation: load-more-rotate 0.7s linear infinite;
}
@keyframes load-more-rotate {
to { transform: rotate(360deg); }
}
.message-row {
@@ -77,38 +357,399 @@ const messages = ref<Message[]>([
}
}
/* 节奏化消息间距:AI↔AI 24,用户↔AI 16 */
.message-row + .message-row {
margin-top: 16px;
}
.message-row:not(.is-user) + .message-row:not(.is-user) {
margin-top: 24px;
}
.bubble-wrap {
display: flex;
flex-direction: column;
gap: 5px;
max-width: min(84%, 1120px);
gap: 4px;
max-width: 100%;
align-items: flex-start;
.message-row.is-user & {
align-items: flex-end;
/* 明确宽度基准:打断 fit-content 与百分比 max-width 的循环,避免气泡被压得过窄导致文字竖排 */
width: 100%;
}
}
.bubble {
font-size: 14px;
line-height: 1.75;
padding: 14px 16px;
border-radius: 16px;
font-size: 15px;
line-height: 1.7;
padding: 0;
border-radius: 0;
word-break: break-word;
color: #1f2937;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96) 0%, rgba(248, 250, 255, 0.92) 100%);
border: 1px solid rgba(226, 233, 244, 0.95);
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.05);
transition: all 0.2s ease;
color: #1f2328;
background: transparent;
border: none;
box-shadow: none;
/* AI 消息:无背景卡片,纯文本左对齐(DeepSeek 式) */
.message-row:not(.is-user) & {
width: 100%;
max-width: 880px;
}
.message-row.is-user & {
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
border-color: rgba(59, 130, 246, 0.4);
color: #fff;
box-shadow: 0 8px 24px rgba(37, 99, 235, 0.35);
width: fit-content;
max-width: 72%;
padding: 10px 16px;
border-radius: 18px 18px 6px 18px;
background: #e8ecff;
color: #1f2328;
box-shadow: none;
}
.message-row.is-error & {
color: #dc2626;
}
}
/* 工作流表单卡片消息:左对齐,卡片自带背景不套气泡 */
.message-row.is-form {
justify-content: flex-start;
.bubble-wrap {
align-items: flex-start;
width: 100%;
.time { padding-left: 2px; }
}
}
/* AI 消息操作栏(hover 显示) */
.msg-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.15s;
.message-row:hover & {
opacity: 1;
}
/* 用户消息在右侧,操作栏右对齐 */
.message-row.is-user & {
align-self: flex-end;
}
}
.msg-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: none;
border-radius: 8px;
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: background 0.15s, color 0.15s;
&:hover {
background: #eef0f3;
color: #4b5563;
}
&.is-danger:hover {
background: #fef2f2;
color: #dc2626;
}
.el-icon {
font-size: 14px;
}
}
/* 思考区:DeepSeek 风格折叠块 */
.thinking-block {
margin: 0 0 12px;
background: #f5f6f7;
border: 1px solid #eef0f3;
border-radius: 12px;
overflow: hidden;
max-width: 880px;
}
.thinking-toggle {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 9px 14px;
border: none;
background: transparent;
font-size: 13px;
font-weight: 500;
color: #8b8b98;
cursor: pointer;
text-align: left;
user-select: none;
transition: color 0.15s;
&:hover {
color: #6b7280;
}
}
.thinking-icon {
font-size: 14px;
line-height: 1;
}
.thinking-title {
font-size: 13px;
}
.thinking-arrow {
margin-left: auto;
display: inline-flex;
font-size: 11px;
line-height: 1;
opacity: 0.7;
transition: transform 0.2s;
&.collapsed {
transform: rotate(-90deg);
}
}
.thinking-content {
padding: 0 14px 14px;
animation: thinking-fade-in 0.2s ease;
}
.thinking-text {
font-size: 13px;
line-height: 1.7;
color: #9a9aab;
font-style: italic;
white-space: pre-wrap;
word-break: break-word;
border-top: 1px solid #eef0f3;
padding-top: 10px;
}
@keyframes thinking-fade-in {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 回答区:Markdown 渲染(DeepSeek 风格排版) */
.answer-block {
white-space: normal;
line-height: 1.7;
font-size: 15px;
word-break: break-word;
max-width: 880px;
& > :first-child {
margin-top: 0;
}
& > :last-child {
margin-bottom: 0;
}
:deep(p) {
margin: 0 0 12px;
}
:deep(h1),
:deep(h2),
:deep(h3),
:deep(h4),
:deep(h5),
:deep(h6) {
font-weight: 600;
line-height: 1.4;
margin: 20px 0 10px;
color: #1f2328;
}
:deep(h1) {
font-size: 20px;
border-bottom: 1px solid #eaecef;
padding-bottom: 8px;
}
:deep(h2) {
font-size: 18px;
}
:deep(h3) {
font-size: 16px;
}
:deep(h4),
:deep(h5),
:deep(h6) {
font-size: 15px;
}
:deep(ul),
:deep(ol) {
margin: 0 0 12px;
padding-left: 22px;
}
:deep(li) {
margin: 3px 0;
}
:deep(li > ul),
:deep(li > ol) {
margin-bottom: 0;
}
:deep(blockquote) {
margin: 0 0 12px;
padding: 6px 14px;
border-left: 4px solid #e0e0e0;
color: #6a737d;
background: #fafafa;
border-radius: 0 8px 8px 0;
}
:deep(code) {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px;
padding: 2px 6px;
border-radius: 4px;
background: #f2f3f5;
color: #c7254e;
}
:deep(.code-wrapper) {
position: relative;
margin: 0 0 14px;
&:hover .code-copy-btn {
opacity: 1;
}
}
:deep(.code-copy-btn) {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
padding: 2px 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
background: rgba(255, 255, 255, 0.08);
color: #8b949e;
font-size: 12px;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.16);
color: #c9d1d9;
}
}
:deep(pre) {
margin: 0;
padding: 12px 14px;
border-radius: 10px;
background: #0d1117;
overflow-x: auto;
line-height: 1.6;
code {
display: block;
padding: 0;
background: transparent;
color: inherit;
font-size: 13px;
}
}
:deep(table) {
width: 100%;
margin: 0 0 14px;
border-collapse: collapse;
font-size: 14px;
th,
td {
border: 1px solid #e2e8f0;
padding: 6px 12px;
text-align: left;
}
th {
background: #f5f7fa;
font-weight: 600;
}
}
:deep(a) {
color: #2563eb;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:deep(hr) {
border: none;
border-top: 1px solid #eaecef;
margin: 16px 0;
}
:deep(img) {
max-width: 100%;
border-radius: 6px;
}
}
/* 执行中占位 */
.loading-text {
color: #94a3b8;
font-size: 13px;
}
.loading-dots {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 6px;
i {
width: 4px;
height: 4px;
border-radius: 50%;
background: #94a3b8;
animation: dot-blink 1.2s infinite ease-in-out both;
&:nth-child(1) { animation-delay: 0s; }
&:nth-child(2) { animation-delay: 0.15s; }
&:nth-child(3) { animation-delay: 0.3s; }
}
}
@keyframes dot-blink {
0%, 80%, 100% { opacity: 0.2; }
40% { opacity: 1; }
}
.time {
font-size: 11px;
color: #8f9aae;
padding: 4px 6px;
padding: 4px 2px;
.message-row.is-user & {
text-align: right;
-164
View File
@@ -1,164 +0,0 @@
<template>
<div class="command-list">
<div v-for="command in commandList" :key="command.id" class="command-card" @click="handleCommandClick(command)">
<div class="command-icon">
<el-icon :size="32" color="#3b82f6">
<Lightning />
</el-icon>
</div>
<div class="command-content">
<h3 class="command-name">{{ command.name }}</h3>
<p class="command-desc">{{ command.description }}</p>
<div class="command-trigger">
<el-tag size="small" type="info">{{ command.trigger }}</el-tag>
</div>
</div>
<div class="command-actions">
<el-button text size="small" @click.stop="handleEdit(command)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button text size="small" type="danger" @click.stop="handleDelete(command)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Lightning, Edit, Delete } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
interface Command {
id: number;
name: string;
description: string;
trigger: string;
action: string;
}
const commandList = ref<Command[]>([
{
id: 1,
name: '快速创建组件',
description: '快速创建一个 Vue 组件模板,包含基础结构和样式',
trigger: '/component',
action: 'create_component',
},
{
id: 2,
name: '生成 API 接口',
description: '根据接口文档快速生成 API 请求函数',
trigger: '/api',
action: 'generate_api',
},
{
id: 3,
name: '代码格式化',
description: '格式化当前文件的代码,统一代码风格',
trigger: '/format',
action: 'format_code',
},
{
id: 4,
name: '添加类型定义',
description: '为 JavaScript 代码添加 TypeScript 类型定义',
trigger: '/types',
action: 'add_types',
},
{
id: 5,
name: '生成测试用例',
description: '为当前组件或函数生成单元测试代码',
trigger: '/test',
action: 'generate_test',
},
{
id: 6,
name: '优化性能',
description: '分析代码并提供性能优化建议',
trigger: '/optimize',
action: 'optimize_code',
},
]);
const handleCommandClick = (command: Command) => {
ElMessage.success(`执行指令: ${command.name}`);
};
const handleEdit = (command: Command) => {
ElMessage.info(`编辑指令: ${command.name}`);
};
const handleDelete = (command: Command) => {
ElMessage.warning(`删除指令: ${command.name}`);
};
</script>
<style scoped lang="scss">
.command-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 16px;
}
.command-card {
background: #ffffff;
border-radius: 12px;
padding: 20px;
border: 1px solid #e5e7eb;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
gap: 16px;
align-items: flex-start;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
border-color: #3b82f6;
background: linear-gradient(135deg, #ffffff 0%, #f0f9ff 100%);
}
}
.command-icon {
flex-shrink: 0;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
border-radius: 12px;
}
.command-content {
flex: 1;
min-width: 0;
}
.command-name {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px 0;
}
.command-desc {
font-size: 13px;
color: #6b7280;
line-height: 1.5;
margin: 0 0 12px 0;
}
.command-trigger {
display: inline-block;
}
.command-actions {
display: flex;
flex-direction: column;
gap: 4px;
}
</style>
-171
View File
@@ -1,171 +0,0 @@
<template>
<div class="diary-list">
<div v-for="diary in diaryList" :key="diary.id" class="diary-card" @click="handleDiaryClick(diary)">
<div class="diary-header">
<div class="diary-date">
<el-icon><Calendar /></el-icon>
{{ diary.date }}
</div>
<el-tag :type="diary.mood === 'happy' ? 'success' : diary.mood === 'sad' ? 'danger' : 'warning'" size="small">
{{ getMoodText(diary.mood) }}
</el-tag>
</div>
<h3 class="diary-title">{{ diary.title }}</h3>
<p class="diary-content">{{ diary.content }}</p>
<div class="diary-footer">
<div class="diary-tags">
<el-tag v-for="tag in diary.tags" :key="tag" size="small" effect="plain">{{ tag }}</el-tag>
</div>
<div class="diary-actions">
<el-button text size="small" @click.stop="handleEdit(diary)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button text size="small" type="danger" @click.stop="handleDelete(diary)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Calendar, Edit, Delete } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
interface Diary {
id: number;
title: string;
content: string;
date: string;
mood: 'happy' | 'sad' | 'normal';
tags: string[];
}
const diaryList = ref<Diary[]>([
{
id: 1,
title: '今天学习了 Vue 3 新特性',
content: '今天深入学习了 Vue 3 的 Composition API,感觉比 Options API 更加灵活和强大。特别是 setup 函数和响应式系统的改进,让代码组织更加清晰...',
date: '2026-05-26',
mood: 'happy',
tags: ['学习', 'Vue3', '前端'],
},
{
id: 2,
title: '项目进度顺利',
content: '今天完成了首页重构的基础布局,整体效果不错。明天继续完善功能模块和样式细节...',
date: '2026-05-25',
mood: 'happy',
tags: ['工作', '项目'],
},
{
id: 3,
title: '遇到了一些技术难题',
content: '在处理复杂组件通信时遇到了一些问题,花了不少时间调试。最后通过重新设计数据流解决了...',
date: '2026-05-24',
mood: 'normal',
tags: ['技术', '调试'],
},
]);
const getMoodText = (mood: string) => {
const moodMap: Record<string, string> = {
happy: '😊 开心',
sad: '😢 难过',
normal: '😐 平静',
};
return moodMap[mood] || '平静';
};
const handleDiaryClick = (diary: Diary) => {
ElMessage.info(`查看日记: ${diary.title}`);
};
const handleEdit = (diary: Diary) => {
ElMessage.info(`编辑日记: ${diary.title}`);
};
const handleDelete = (diary: Diary) => {
ElMessage.warning(`删除日记: ${diary.title}`);
};
</script>
<style scoped lang="scss">
.diary-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.diary-card {
background: #ffffff;
border-radius: 12px;
padding: 20px;
border: 1px solid #e5e7eb;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
border-color: #3b82f6;
}
}
.diary-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.diary-date {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #6b7280;
}
.diary-title {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin: 0 0 12px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.diary-content {
font-size: 14px;
color: #6b7280;
line-height: 1.6;
margin: 0 0 16px 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.diary-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 12px;
border-top: 1px solid #f3f4f6;
}
.diary-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.diary-actions {
display: flex;
gap: 4px;
}
</style>
-199
View File
@@ -1,199 +0,0 @@
<template>
<div class="file-list">
<div v-for="file in fileList" :key="file.id" class="file-card" @click="handleFileClick(file)">
<div class="file-icon">
<el-icon :size="40" :color="getFileColor(file.type)">
<component :is="getFileIcon(file.type)" />
</el-icon>
</div>
<div class="file-info">
<h3 class="file-name">{{ file.name }}</h3>
<div class="file-meta">
<span class="file-size">{{ file.size }}</span>
<span class="file-date">{{ file.date }}</span>
</div>
<div class="file-tags">
<el-tag v-for="tag in file.tags" :key="tag" size="small" effect="plain">{{ tag }}</el-tag>
</div>
</div>
<div class="file-actions">
<el-button text size="small" @click.stop="handleDownload(file)">
<el-icon><Download /></el-icon>
</el-button>
<el-button text size="small" type="danger" @click.stop="handleDelete(file)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Document, Picture, VideoPlay, Folder, Download, Delete } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
interface FileItem {
id: number;
name: string;
type: 'document' | 'image' | 'video' | 'folder';
size: string;
date: string;
tags: string[];
}
const fileList = ref<FileItem[]>([
{
id: 1,
name: 'Vue3 学习笔记.pdf',
type: 'document',
size: '2.5 MB',
date: '2026-05-26',
tags: ['学习', '文档'],
},
{
id: 2,
name: '项目截图.png',
type: 'image',
size: '856 KB',
date: '2026-05-25',
tags: ['截图', '项目'],
},
{
id: 3,
name: '演示视频.mp4',
type: 'video',
size: '15.3 MB',
date: '2026-05-24',
tags: ['视频', '演示'],
},
{
id: 4,
name: '项目文档',
type: 'folder',
size: '12 个文件',
date: '2026-05-23',
tags: ['文件夹'],
},
{
id: 5,
name: 'API 接口文档.docx',
type: 'document',
size: '1.2 MB',
date: '2026-05-22',
tags: ['文档', 'API'],
},
{
id: 6,
name: 'UI 设计稿.sketch',
type: 'image',
size: '4.8 MB',
date: '2026-05-21',
tags: ['设计', 'UI'],
},
]);
const getFileIcon = (type: string) => {
const iconMap: Record<string, any> = {
document: Document,
image: Picture,
video: VideoPlay,
folder: Folder,
};
return iconMap[type] || Document;
};
const getFileColor = (type: string) => {
const colorMap: Record<string, string> = {
document: '#3b82f6',
image: '#10b981',
video: '#f59e0b',
folder: '#8b5cf6',
};
return colorMap[type] || '#6b7280';
};
const handleFileClick = (file: FileItem) => {
ElMessage.info(`打开文件: ${file.name}`);
};
const handleDownload = (file: FileItem) => {
ElMessage.success(`下载文件: ${file.name}`);
};
const handleDelete = (file: FileItem) => {
ElMessage.warning(`删除文件: ${file.name}`);
};
</script>
<style scoped lang="scss">
.file-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.file-card {
background: #ffffff;
border-radius: 12px;
padding: 20px;
border: 1px solid #e5e7eb;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
gap: 16px;
align-items: flex-start;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
border-color: #3b82f6;
}
}
.file-icon {
flex-shrink: 0;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: #f3f4f6;
border-radius: 8px;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-size: 15px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-meta {
display: flex;
gap: 12px;
font-size: 13px;
color: #6b7280;
margin-bottom: 8px;
}
.file-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.file-actions {
display: flex;
flex-direction: column;
gap: 4px;
}
</style>
+434 -203
View File
@@ -1,332 +1,563 @@
<template>
<div class="input-shell">
<div class="input-card">
<div class="input-card" :class="{ 'is-focused': isFocused }">
<!-- 已选中状态标签栏 -->
<Transition name="capsule-slide" @enter="onCapsuleEnter" @after-enter="onCapsuleAfterEnter" @leave="onCapsuleLeave" @after-leave="onCapsuleAfterLeave">
<div v-if="selectedWorkflowId !== null" class="selected-tags">
<div class="selected-tag workflow-tag">
<el-icon><Promotion /></el-icon>
<span>{{ commonWorkflows.find((w) => w.id === selectedWorkflowId)?.name }}</span>
<el-icon v-if="!lockAll" class="tag-close" @click="clearWorkflow"><Close /></el-icon>
</div>
</div>
</Transition>
<!-- 文本输入区 -->
<el-input
v-model="message"
type="textarea"
:rows="1"
:autosize="{ minRows: 2, maxRows: 5 }"
placeholder="多说说你的偏好和要求,我会越用越懂你"
:autosize="{ minRows: 1, maxRows: 6 }"
:disabled="generating || isWorkflowRunning || selectedWorkflowId !== null || isWorkflowSession"
:placeholder="inputPlaceholder"
class="message-input"
@keydown.enter.exact="handleSend"
@focus="isFocused = true"
@blur="isFocused = false"
@keydown.enter.exact.prevent="handleSend"
@keydown.enter.shift.exact="() => {}"
/>
<!-- 底部工具栏 -->
<div class="input-toolbar">
<div class="toolbar-left">
<el-button circle class="tool-btn" @click="handleAttachment">
<el-icon><Plus /></el-icon>
</el-button>
<el-dropdown trigger="click" @command="handleSkillSelect">
<el-button class="pill-btn">
{{ currentSkillDisplay }}
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="default">默认对话</el-dropdown-item>
<el-dropdown-item command="code-review">代码审查</el-dropdown-item>
<el-dropdown-item command="refactor">代码重构</el-dropdown-item>
<el-dropdown-item command="writing">内容写作</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="toolbar-right">
<el-button circle class="send-btn" :disabled="!message.trim()" @click="handleSend">
<el-icon><Top /></el-icon>
</el-button>
</div>
</div>
<!-- 常用工作流胶囊 -->
<div class="workflow-pills" v-if="commonWorkflows.length > 0">
<div
v-for="wf in commonWorkflows"
:key="wf.id"
class="workflow-pill"
:class="{ active: selectedWorkflowId === wf.id }"
@click="selectWorkflow(wf)"
>
<el-icon class="pill-icon">
<component :is="wf.icon" />
</el-icon>
<span class="pill-text">{{ wf.name }}</span>
<!-- 切换会话模型入口 -->
<button class="tool-icon-btn" :class="{ active: !!currentModelName }" title="切换会话模型" @click="emit('select-model')">
<el-icon><Cpu /></el-icon>
<span class="tool-label">{{ currentModelName || '设置模型' }}</span>
</button>
</div>
<div class="toolbar-right">
<span class="hint-text">Shift+Enter 换行</span>
<button
class="send-btn"
:class="{ 'is-stop': isStopping }"
:disabled="sendBtnDisabled"
@click="handleSend"
>
<el-icon v-if="isStopping"><VideoPause /></el-icon>
<el-icon v-else><Top /></el-icon>
</button>
</div>
</div>
</div>
<!-- 快捷工作流胶囊输入框下方 -->
<Transition name="capsule-slide" @enter="onCapsuleEnter" @after-enter="onCapsuleAfterEnter" @leave="onCapsuleLeave" @after-leave="onCapsuleAfterLeave">
<div v-if="visibleWorkflows.length > 0 && !hideShortcuts" class="workflow-shortcuts">
<el-tooltip
v-for="wf in visibleWorkflows"
:key="wf.id"
:content="wf.name"
placement="top"
:show-after="150"
>
<button
class="shortcut-pill"
:class="{ active: selectedWorkflowId === wf.id }"
@click="toggleWorkflow(wf.id)"
>
<el-icon><Promotion /></el-icon>
<span class="pill-name">{{ wf.name }}</span>
<span v-if="wf.isTemplate" class="pill-tag">模板</span>
</button>
</el-tooltip>
<!-- 更多常驻入口点击跳转工作流管理页查看/选择全部不依赖工作流数量超出上限 -->
<button
class="shortcut-pill shortcut-more"
title="查看全部工作流"
@click="handleOpenWorkflowManage"
>
<el-icon><More /></el-icon>
更多
</button>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { Top, Plus, MagicStick, Document, CircleCheck } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { Top, Promotion, Close, VideoPause, Cpu, More } from '@element-plus/icons-vue';
import { getWorkflowList } from '/@/api/settings/creation';
interface Props {
sendDisabled?: boolean;
hideShortcuts?: boolean;
generating?: boolean;
//
currentModelName?: string;
//
isWorkflowRunning?: boolean;
//
isWorkflowSession?: boolean;
}
interface Emits {
(e: 'send', message: string): void;
// /
(e: 'execute-workflow'): void;
(e: 'workflow-select', workflowId: string | null, isTemplate?: boolean): void;
(e: 'stop'): void;
(e: 'select-model'): void;
}
interface Workflow {
id: number;
id: string;
name: string;
icon: any;
prefix: string;
editPath: string;
isTemplate: boolean;
raw?: any;
}
const props = withDefaults(defineProps<Props>(), {
sendDisabled: false,
hideShortcuts: false,
generating: false,
currentModelName: '',
isWorkflowRunning: false,
isWorkflowSession: false,
});
const emit = defineEmits<Emits>();
const router = useRouter();
const message = ref('');
const selectedSkill = ref('default');
const selectedWorkflowId = ref<number | null>(null);
// 9
const MAX_WORKFLOWS = 9;
const skillLabels: Record<string, string> = {
default: '选择技能',
'code-review': '代码审查',
refactor: '代码重构',
writing: '内容写作',
// /
const handleOpenWorkflowManage = () => {
router.push('/settings/workflow');
};
const message = ref('');
const isFocused = ref(false);
const selectedWorkflowId = ref<string | null>(null);
const commonWorkflows = ref<Workflow[]>([]);
// InputBar 稿
const previousSelectedWorkflowId = ref<string | null>(null);
const currentSkillDisplay = computed(() => {
return selectedSkill.value === 'default' ? '选择技能' : skillLabels[selectedSkill.value];
// MAX_WORKFLOWS commonWorkflows
// MAX_WORKFLOWS
const visibleWorkflows = computed(() => commonWorkflows.value.slice(0, MAX_WORKFLOWS));
// /
const lockAll = computed(() => props.isWorkflowRunning || props.generating);
// / /
const inputPlaceholder = computed(() =>
props.isWorkflowRunning
? '工作流执行中,请稍候...'
: selectedWorkflowId.value !== null
? '请填写上方工作流表单,提交后开始执行'
: props.isWorkflowSession
? '本会话为工作流对话,仅可执行工作流'
: '有什么想聊的?按 Enter 发送,Shift+Enter 换行',
);
// /
const isStopping = computed(() => props.generating || props.isWorkflowRunning);
//
const sendBtnDisabled = computed(() => {
if (props.generating || props.isWorkflowRunning) return false;
if (selectedWorkflowId.value !== null || props.isWorkflowSession) return false;
return props.sendDisabled || !message.value.trim();
});
const skills: Record<string, string> = {
default: '',
'code-review': '[技能:代码审查] 请帮我审查下面这段代码,找出潜在问题并给出改进建议:\n',
refactor: '[技能:代码重构] 请帮我重构下面这段代码,提升可读性和可维护性:\n',
writing: '[技能:内容写作] 请根据下面的需求帮我创作内容:\n',
const fetchWorkflows = async () => {
try {
const res = await getWorkflowList();
const userList = res.data?.listFlowUserRes?.list || [];
const tplList = res.data?.listFlowTemplateRes?.list || [];
// commonWorkflows id /
const workflows = [
...userList.map((w) => ({ ...w, isTemplate: false })),
...tplList.map((w) => ({ ...w, isTemplate: true })),
];
commonWorkflows.value = workflows.map((w) => ({
id: String(w.id),
name: w.flowName || w.flowTemplateName || '未命名',
prefix: '[工作流] ' + (w.flowName || w.flowTemplateName || '') + ':\n',
isTemplate: !!w.isTemplate,
raw: w,
}));
} catch {
commonWorkflows.value = [];
}
};
//
const commonWorkflows: Workflow[] = [
{
id: 1,
name: '审查+测试',
icon: Document,
prefix: '[工作流:审查+测试] 完成后请自动生成单元测试:\n',
editPath: '/settings/workflow',
},
{
id: 2,
name: '文档再编码',
icon: Document,
prefix: '[工作流:文档再编码] 请先编写接口文档,再实现代码:\n',
editPath: '/settings/workflow',
},
{
id: 3,
name: '代码重构',
icon: Document,
prefix: '[工作流:代码重构] 请帮我重构这段代码:\n',
editPath: '/settings/workflow',
},
{
id: 4,
name: '需求分析',
icon: Document,
prefix: '[工作流:需求分析] 请帮我分析这个需求并拆解任务:\n',
editPath: '/settings/workflow',
},
];
const handleSend = (event?: KeyboardEvent) => {
if (event) event.preventDefault();
const handleSend = () => {
// /
if (props.generating || props.isWorkflowRunning) {
emit('stop');
return;
}
// /
if (selectedWorkflowId.value !== null || props.isWorkflowSession) {
emit('execute-workflow');
return;
}
//
const msg = message.value.trim();
if (!msg) return;
const skillPrefix = skills[selectedSkill.value] || '';
const workflowPrefix = selectedWorkflowId.value !== null ? commonWorkflows.find((w) => w.id === selectedWorkflowId.value)?.prefix || '' : '';
const finalMessage = (skillPrefix + workflowPrefix + msg).trim();
emit('send', finalMessage);
if (!msg || props.sendDisabled) return;
emit('send', msg);
message.value = '';
//
selectedWorkflowId.value = null;
//
};
const handleAttachment = () => {
ElMessage.info('附件上传功能开发中...');
const toggleWorkflow = (id: string) => {
if (lockAll.value) return;
const item = commonWorkflows.value.find((w) => w.id === id);
const newId = selectedWorkflowId.value === id ? null : id;
// newId null
if (newId === null) previousSelectedWorkflowId.value = selectedWorkflowId.value;
selectedWorkflowId.value = newId;
emit('workflow-select', newId, item?.isTemplate || false);
};
const handleSkillSelect = (key: string | number | object) => {
selectedSkill.value = String(key);
if (selectedSkill.value !== 'default') {
ElMessage.success(`已选择技能:${skillLabels[selectedSkill.value]}`);
}
};
const selectWorkflow = (wf: Workflow) => {
if (selectedWorkflowId.value === wf.id) {
const resetAll = () => {
message.value = '';
selectedWorkflowId.value = null;
ElMessage.info('已取消工作流');
} else {
selectedWorkflowId.value = wf.id;
ElMessage.success(`已选择工作流:${wf.name}`);
emit('workflow-select', null);
};
const clearWorkflow = () => {
if (lockAll.value) return;
//
previousSelectedWorkflowId.value = selectedWorkflowId.value;
selectedWorkflowId.value = null;
emit('workflow-select', null);
};
const selectWorkflow = (id: string | null) => {
if (lockAll.value) return;
selectedWorkflowId.value = id;
emit('workflow-select', id, false);
};
//
// emit稿 selectedWorkflowDetail
const restoreWorkflowSelection = () => {
if (previousSelectedWorkflowId.value !== null) {
selectedWorkflowId.value = previousSelectedWorkflowId.value;
}
};
onMounted(() => {
fetchWorkflows();
});
// JS max-height
// 0 0 CSS transition
const onCapsuleEnter = (el: Element) => {
const e = el as HTMLElement;
e.style.maxHeight = '0px';
requestAnimationFrame(() => {
e.style.maxHeight = e.scrollHeight + 'px';
});
};
const onCapsuleAfterEnter = (el: Element) => {
(el as HTMLElement).style.maxHeight = '';
};
const onCapsuleLeave = (el: Element) => {
const e = el as HTMLElement;
e.style.maxHeight = e.scrollHeight + 'px';
requestAnimationFrame(() => {
e.style.maxHeight = '0px';
});
};
const onCapsuleAfterLeave = (el: Element) => {
(el as HTMLElement).style.maxHeight = '';
};
defineExpose({ resetAll, clearWorkflow, selectWorkflow, restoreWorkflowSelection, fetchWorkflows, selectedWorkflowId, commonWorkflows });
</script>
<style scoped lang="scss">
/* ===== 胶囊显示/隐藏过渡:淡入淡出 + 轻位移 + 高度展开/收缩 ===== */
.capsule-slide-enter-active,
.capsule-slide-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease, max-height 0.25s ease;
overflow: hidden;
}
.capsule-slide-enter-from,
.capsule-slide-leave-to {
opacity: 0;
transform: translateY(-6px);
}
.input-shell {
position: relative;
z-index: 12;
margin-top: -20px;
padding: 0 0 50px;
padding: 0 20px 20px;
background: transparent;
}
.input-card {
width: min(1060px, 82%);
max-width: 880px;
margin: 0 auto;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(255, 255, 255, 0.94) 100%),
radial-gradient(120% 180% at 0% 0%, rgba(59, 130, 246, 0.15) 0%, rgba(59, 130, 246, 0) 38%),
radial-gradient(80% 120% at 100% 100%, rgba(168, 85, 247, 0.08) 0%, rgba(168, 85, 247, 0) 38%);
border: 1px solid rgba(59, 130, 246, 0.28);
background: #fff;
border: 1.5px solid #e5e8ee;
border-radius: 20px;
box-shadow:
0 24px 48px rgba(15, 23, 42, 0.12),
0 0 0 1px rgba(59, 130, 246, 0.08) inset,
0 8px 16px rgba(37, 99, 235, 0.1);
padding: 16px 18px 18px;
backdrop-filter: blur(16px);
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.07);
transition:
border-color 0.2s,
box-shadow 0.2s;
overflow: hidden;
&.is-focused {
border-color: #93c5fd;
box-shadow:
0 0 0 3px rgba(59, 130, 246, 0.12),
0 2px 12px rgba(15, 23, 42, 0.07);
}
}
/* 已选标签栏 */
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 10px 14px 0;
}
.selected-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px 3px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
&.workflow-tag {
background: #eff6ff;
color: #2563eb;
border: 1px solid #bfdbfe;
}
.tag-close {
margin-left: 2px;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.15s;
&:hover {
opacity: 1;
}
}
}
/* 文本域 */
.message-input {
:deep(.el-textarea__inner) {
border: none;
box-shadow: none;
resize: none;
padding: 8px 6px 14px;
padding: 16px 18px 10px;
font-size: 15px;
line-height: 1.65;
line-height: 1.6;
color: #0f172a;
background: transparent;
min-height: 50px;
font-weight: 500;
min-height: 28px !important;
&::placeholder {
color: #90a1b7;
color: #94a3b8;
font-weight: 400;
}
}
}
/* 底部工具栏 */
.input-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding-top: 2px;
padding: 8px 12px 12px 14px;
}
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: 10px;
gap: 4px;
}
.tool-btn {
width: 32px;
height: 32px;
border: 1px solid #d9e4f5;
color: #5b6b84;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.06);
transition: all 0.2s ease;
.tool-icon-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 34px;
padding: 0 10px;
border: none;
border-radius: 10px;
background: transparent;
color: #64748b;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
outline: none;
.el-icon {
font-size: 15px;
}
&:hover {
color: #2b4d8f;
background: linear-gradient(135deg, #f0f7ff 0%, #e0edff 100%);
border-color: #bfdbfe;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
background: #f1f5f9;
color: #334155;
}
&.active {
background: #eff6ff;
color: #2563eb;
}
}
.pill-btn {
height: 32px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid #dbe7f7;
color: #30435f;
background: linear-gradient(180deg, #ffffff 0%, #f6f9ff 100%);
.tool-label {
font-size: 12px;
font-weight: 600;
transition: all 0.2s ease;
}
&:hover {
background: linear-gradient(135deg, #f0f7ff 0%, #e0edff 100%);
border-color: #bfdbfe;
color: #1d4ed8;
}
.hint-text {
font-size: 11px;
color: #cbd5e1;
margin-right: 6px;
user-select: none;
}
.send-btn {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
border-radius: 10px;
background: #2563eb;
color: #fff;
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4);
transition: all 0.2s ease;
cursor: pointer;
transition:
background 0.15s,
transform 0.1s,
opacity 0.15s;
outline: none;
.el-icon {
font-size: 16px;
}
&:hover:not(:disabled) {
filter: brightness(1.08);
background: #1d4ed8;
transform: translateY(-1px);
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.5);
}
&:disabled {
opacity: 0.5;
box-shadow: none;
background: linear-gradient(135deg, #cbd5e1 0%, #94a3b8 100%);
background: #e2e8f0;
color: #94a3b8;
cursor: not-allowed;
transform: none;
}
/* 生成中:变停止按钮 */
&.is-stop {
background: #f43f5e;
color: #fff;
&:hover {
background: #e11d48;
}
}
}
/* 工作流胶囊样式 */
.workflow-pills {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(59, 130, 246, 0.15);
/* 快捷工作流胶囊 */
.workflow-shortcuts {
max-width: 880px;
margin: 8px auto 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.workflow-pill {
.shortcut-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
gap: 5px;
height: 32px;
padding: 0 12px;
border: 1px solid #e5e8ee;
border-radius: 999px;
border: 1px solid rgba(59, 130, 246, 0.3);
background: rgba(255, 255, 255, 0.6);
cursor: pointer;
transition: all 0.2s ease;
background: rgba(255, 255, 255, 0.7);
color: #475569;
font-size: 13px;
font-weight: 500;
cursor: pointer;
backdrop-filter: blur(4px);
transition: all 0.15s;
outline: none;
.el-icon {
font-size: 13px;
}
&:hover {
border-color: #3b82f6;
background: rgba(59, 130, 246, 0.08);
transform: translateY(-1px);
border-color: #93c5fd;
color: #2563eb;
background: rgba(239, 246, 255, 0.9);
}
&.active {
border-color: #3b82f6;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: #ffffff;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
background: #2563eb;
color: #fff;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
}
}
.pill-icon {
font-size: 14px;
/* 胶囊内工作流名称:单行省略显示(不撑宽胶囊),悬停经 el-tooltip 展示完整名称 */
.pill-name {
max-width: 140px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 「更多」入口:与普通胶囊区分,虚线边框 + 更轻的视觉重量,提示可进入管理页查看全部 */
.shortcut-more {
border-style: dashed;
color: #94a3b8;
&:hover {
border-style: dashed;
border-color: #93c5fd;
color: #2563eb;
background: rgba(239, 246, 255, 0.9);
}
}
.pill-text {
font-size: 12px;
font-weight: 500;
line-height: 1.2;
.pill-tag {
font-size: 10px;
line-height: 1;
padding: 2px 4px;
border-radius: 4px;
background: #fff7e6;
color: #d97706;
border: 1px solid #fde68a;
white-space: nowrap;
.shortcut-pill.active & {
background: rgba(255, 247, 230, 0.2);
color: #fff;
border-color: rgba(255, 255, 255, 0.4);
}
}
</style>
+244 -48
View File
@@ -1,88 +1,284 @@
<template>
<div class="main-content">
<div v-if="activeMenu !== 'chat'" class="content-header">
<h2 class="content-title">{{ currentTitle }}</h2>
<div class="content-actions">
</div>
<Transition name="content-fade" mode="out-in">
<!-- 对话页 有消息或会话结果时展示消息流工作流表单卡片/结果/产出统一在此
key 绑定会话 id切换会话时重建内容区触发 Transition 过渡动画固定 key 会复用 DOM 不触发 -->
<div v-if="hasMessages || hasResults" :key="activeHistoryId || 'chat'" class="content-body chat-body">
<ChatList
:messages="messages"
:has-more="hasMore"
:loading-more="loadingMore"
:execute-request="executeRequest"
class="chat-list-scroll"
@delete="emit('delete', $event)"
@form-submit="(msg: any, payload: any) => emit('form-submit', msg, payload)"
@output-preview="(msg: any, output: any) => emit('output-preview', msg, output)"
@output-download="(msg: any, output: any) => emit('output-download', msg, output)"
@output-delete="(msg: any, output: any) => emit('output-delete', msg, output)"
@load-more="emit('load-more', activeHistoryId)"
/>
</div>
<div class="content-body">
<div class="chat-container" v-if="activeMenu === 'chat'">
<ChatList />
<!-- 默认占位 无工作流时显示引导 -->
<div v-else :key="'placeholder'" class="content-body placeholder-body">
<div class="placeholder-content">
<h2 class="placeholder-title">你好我能帮你解决什么问题</h2>
<p class="placeholder-desc">选择一个工作流填写参数后一键生成</p>
<div v-if="placeWorkflows.length" class="workflow-cards">
<el-tooltip
v-for="wf in placeWorkflows"
:key="wf.id"
:content="wf.name"
placement="top"
:show-after="150"
>
<button
type="button"
class="workflow-card"
@click="handlePlaceSelect(wf)"
>
<el-icon class="wf-card-icon"><Promotion /></el-icon>
<span class="wf-card-name">{{ wf.name }}</span>
<span v-if="wf.isTemplate" class="wf-card-tag">模板</span>
</button>
</el-tooltip>
<!-- 更多常驻入口点击跳转工作流管理页查看/选择全部不依赖工作流数量超出上限 -->
<button
type="button"
class="workflow-card wf-card-more"
title="查看全部工作流"
@click="goWorkflowManage"
>
<el-icon class="wf-card-icon"><More /></el-icon>
<span class="wf-card-name">更多工作流</span>
</button>
</div>
<div v-else class="placeholder-empty">暂无工作流可在工作流管理中创建</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { getWorkflowList } from '/@/api/settings/creation';
import { Promotion, More } from '@element-plus/icons-vue';
import ChatList from './ChatList.vue';
import type { VOSessionInfoResult } from '/@/api/settings/workflow/session';
interface Props {
activeMenu: string;
activeHistoryId?: string | null;
messages?: any[];
// session/get vs
results?: VOSessionInfoResult[];
hasMore?: boolean;
loadingMore?: boolean;
// InputBar ChatList
executeRequest?: { msgId: string; action: 'submit'; nonce: number } | null;
}
const props = defineProps<Props>();
interface Emits {
(e: 'workflow-select', id: string, isTemplate?: boolean): void;
(e: 'delete', msg: any): void;
(e: 'form-submit', msg: any, payload: any): void;
(e: 'output-preview', msg: any, output: any): void;
(e: 'output-download', msg: any, output: any): void;
(e: 'output-delete', msg: any, output: any): void;
(e: 'load-more', sid: string | null | undefined): void;
}
const menuTitles: Record<string, string> = {
chat: '对话',
models: '模型管理',
creation: '内容创作',
const props = withDefaults(defineProps<Props>(), {
activeHistoryId: null,
messages: () => [],
results: () => [],
hasMore: false,
loadingMore: false,
executeRequest: null,
});
const emit = defineEmits<Emits>();
const router = useRouter();
const hasMessages = computed(() => Array.isArray(props.messages) && props.messages.length > 0);
const hasResults = computed(() => Array.isArray(props.results) && props.results.length > 0);
// DeepSeek +
// InputBar 9
const MAX_PLACE_WORKFLOWS = 9;
const placeWorkflows = ref<any[]>([]);
const loadPlaceWorkflows = async () => {
try {
const res: any = await getWorkflowList();
const userList = res.data?.listFlowUserRes?.list || [];
const tplList = res.data?.listFlowTemplateRes?.list || [];
placeWorkflows.value = [
...userList.map((w: any) => ({ id: String(w.id), name: w.flowName || '未命名', isTemplate: false })),
...tplList.map((w: any) => ({ id: String(w.id), name: w.flowTemplateName || '未命名', isTemplate: true })),
].slice(0, MAX_PLACE_WORKFLOWS);
} catch {
placeWorkflows.value = [];
}
};
const handlePlaceSelect = (wf: any) => {
emit('workflow-select', wf.id, wf.isTemplate);
};
// /
const goWorkflowManage = () => {
router.push('/settings/workflow');
};
onMounted(() => {
loadPlaceWorkflows();
});
const currentTitle = computed(() => menuTitles[props.activeMenu] || '首页');
defineExpose({ loadPlaceWorkflows });
</script>
<style scoped lang="scss">
/* ===== 页面切换过渡:淡入淡出 + 轻微上移 ===== */
.content-fade-enter-active,
.content-fade-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.content-fade-enter-from {
opacity: 0;
transform: translateY(8px);
}
.content-fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background: #ffffff;
overflow: hidden;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
background: #ffffff;
}
.content-title {
font-size: 20px;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.content-actions {
display: flex;
gap: 12px;
}
.content-body {
flex: 1;
overflow-y: auto;
background:
radial-gradient(1200px 420px at 58% -120px, rgba(59, 130, 246, 0.1) 0%, rgba(59, 130, 246, 0) 65%),
radial-gradient(900px 320px at 20% 120%, rgba(99, 102, 241, 0.08) 0%, rgba(99, 102, 241, 0) 68%),
linear-gradient(180deg, #f8fbff 0%, #f5f7fb 100%);
/* 隐藏滚动条但保持滚动功能 */
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar { display: none; }
}
&::-webkit-scrollbar {
display: none;
/* ===== 对话页 ===== */
.chat-body {
width: min(880px, calc(100% - 40px));
margin: 0 auto;
height: 100%;
display: flex;
flex-direction: column;
padding: 0;
box-sizing: border-box;
overflow: hidden; /* 结果列表固定高度、消息流内部滚动,避免整体滚动与内部冲突 */
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
/* 消息流填充剩余高度并在内部滚动 */
.chat-body :deep(.chat-list-scroll) {
flex: 1;
min-height: 0;
height: auto;
}
.placeholder-body { display: flex; align-items: center; justify-content: center; }
.placeholder-content { text-align: center; padding: 24px 20px; width: 100%; max-width: 740px; }
/* ===== 标题与描述 ===== */
.placeholder-title { font-size: 26px; font-weight: 700; color: #0f172a; margin: 0 0 8px; letter-spacing: -0.2px; line-height: 1.4; }
.placeholder-desc { font-size: 15px; color: #94a3b8; margin: 0 0 24px; line-height: 1.5; }
/* ===== 工作流功能卡片(DeepSeek 式引导) ===== */
.workflow-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
text-align: left;
}
.workflow-card {
display: flex;
align-items: center;
gap: 10px;
padding: 18px 20px;
border: 1px solid #e5e8ee;
border-radius: 14px;
background: #fff;
cursor: pointer;
transition: all 0.15s;
outline: none;
&:hover {
border-color: #93c5fd;
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.12);
transform: translateY(-1px);
}
}
.wf-card-icon {
width: 40px;
height: 40px;
border-radius: 12px;
background: #eaf1ff;
color: #2563eb;
font-size: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.wf-card-name {
flex: 1;
min-width: 0;
font-size: 15px;
font-weight: 600;
color: #1e293b;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wf-card-tag {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
background: #fff7e6;
color: #d97706;
border: 1px solid #fde68a;
white-space: nowrap;
}
/* 「更多」入口:与普通工作流卡片区分,虚线边框 + 更轻的视觉重量,提示可进入管理页查看全部 */
.wf-card-more {
border-style: dashed;
justify-content: center;
.wf-card-icon {
background: #f8fafc;
color: #94a3b8;
}
.wf-card-name {
color: #94a3b8;
font-weight: 500;
}
&:hover {
border-style: dashed;
border-color: #93c5fd;
.wf-card-icon {
background: #eaf1ff;
color: #2563eb;
}
.wf-card-name {
color: #2563eb;
}
}
}
.chat-container {
width: min(1060px, 82%);
margin: 0 auto;
padding: 20px 0 130px;
.placeholder-empty {
font-size: 13px;
color: #cbd5e1;
padding: 16px 0;
}
</style>
-207
View File
@@ -1,207 +0,0 @@
<template>
<div class="reply-list">
<div v-for="reply in replyList" :key="reply.id" class="reply-card" @click="handleReplyClick(reply)">
<div class="reply-header">
<div class="reply-category">
<el-tag :type="getCategoryType(reply.category)" size="small">{{ reply.category }}</el-tag>
</div>
<div class="reply-usage">
<el-icon><ChatDotRound /></el-icon>
<span>{{ reply.usageCount }} 次使用</span>
</div>
</div>
<h3 class="reply-title">{{ reply.title }}</h3>
<p class="reply-content">{{ reply.content }}</p>
<div class="reply-footer">
<div class="reply-tags">
<el-tag v-for="tag in reply.tags" :key="tag" size="small" effect="plain">{{ tag }}</el-tag>
</div>
<div class="reply-actions">
<el-button text size="small" @click.stop="handleCopy(reply)">
<el-icon><CopyDocument /></el-icon>
</el-button>
<el-button text size="small" @click.stop="handleEdit(reply)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button text size="small" type="danger" @click.stop="handleDelete(reply)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ChatDotRound, CopyDocument, Edit, Delete } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
interface Reply {
id: number;
title: string;
content: string;
category: string;
tags: string[];
usageCount: number;
}
const replyList = ref<Reply[]>([
{
id: 1,
title: '欢迎语',
content: '您好!很高兴为您服务。请问有什么可以帮助您的吗?',
category: '问候',
tags: ['欢迎', '问候'],
usageCount: 156,
},
{
id: 2,
title: '感谢回复',
content: '非常感谢您的反馈!我们会继续努力为您提供更好的服务。',
category: '感谢',
tags: ['感谢', '反馈'],
usageCount: 89,
},
{
id: 3,
title: '技术支持',
content: '我已经收到您的问题,正在为您查询相关信息。请稍等片刻,我会尽快回复您。',
category: '支持',
tags: ['技术', '支持'],
usageCount: 234,
},
{
id: 4,
title: '道歉回复',
content: '非常抱歉给您带来不便。我们会立即处理这个问题,并尽快给您一个满意的答复。',
category: '道歉',
tags: ['道歉', '问题'],
usageCount: 45,
},
{
id: 5,
title: '结束语',
content: '如果您还有其他问题,随时欢迎联系我们。祝您生活愉快!',
category: '结束',
tags: ['结束', '祝福'],
usageCount: 178,
},
{
id: 6,
title: '产品介绍',
content: '我们的产品采用最新技术,为您提供高效、稳定、安全的服务体验。',
category: '介绍',
tags: ['产品', '介绍'],
usageCount: 67,
},
]);
const getCategoryType = (category: string) => {
const typeMap: Record<string, any> = {
问候: 'success',
感谢: 'primary',
支持: 'warning',
道歉: 'danger',
结束: 'info',
介绍: '',
};
return typeMap[category] || '';
};
const handleReplyClick = (reply: Reply) => {
ElMessage.success(`使用快捷回复: ${reply.title}`);
};
const handleCopy = (reply: Reply) => {
navigator.clipboard.writeText(reply.content);
ElMessage.success('已复制到剪贴板');
};
const handleEdit = (reply: Reply) => {
ElMessage.info(`编辑回复: ${reply.title}`);
};
const handleDelete = (reply: Reply) => {
ElMessage.warning(`删除回复: ${reply.title}`);
};
</script>
<style scoped lang="scss">
.reply-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.reply-card {
background: #ffffff;
border-radius: 12px;
padding: 20px;
border: 1px solid #e5e7eb;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
border-color: #3b82f6;
}
}
.reply-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.reply-category {
flex-shrink: 0;
}
.reply-usage {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #6b7280;
}
.reply-title {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin: 0 0 12px 0;
}
.reply-content {
font-size: 14px;
color: #6b7280;
line-height: 1.6;
margin: 0 0 16px 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.reply-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 12px;
border-top: 1px solid #f3f4f6;
}
.reply-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.reply-actions {
display: flex;
gap: 4px;
}
</style>
@@ -0,0 +1,482 @@
<template>
<el-dialog
v-model="visible"
title="切换会话模型"
width="860px"
:close-on-click-modal="false"
@close="handleClose"
>
<!-- 当前会话模型提示 -->
<el-alert
v-if="props.currentChatModelName"
type="success"
:closable="false"
show-icon
class="current-model-alert"
>
<template #title>当前会话模型{{ props.currentChatModelName }}</template>
</el-alert>
<div class="setter-header">
<div class="search-bar">
<el-input v-model="searchKeyword" placeholder="搜索模型名称" clearable @clear="handleSearch">
<template #prefix
><el-icon> <Search /> </el-icon
></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
</div>
<div class="model-list" v-loading="loading">
<el-empty v-if="!loading && pagedModels.length === 0" description="暂无推理模型" :image-size="100" />
<div v-else class="model-grid">
<div
v-for="model in pagedModels"
:key="model.id"
class="model-card"
:class="{ selected: selectedModel?.id === model.id, active: String(model.id) === String(props.currentChatModelId) }"
@click="handleSelectModel(model)"
>
<div class="model-card-header">
<div class="model-name-row">
<span class="model-name">{{ model.modelName }}</span>
<el-tag v-if="model.systemModel" size="small" type="warning">内置</el-tag>
<el-tag v-else size="small" type="success">我的</el-tag>
</div>
<div class="model-icons">
<el-icon v-if="String(model.id) === String(props.currentChatModelId)" class="current-icon" color="#2563eb">
<CircleCheck />
</el-icon>
<el-icon v-if="selectedModel?.id === model.id" class="check-icon" color="#67c23a">
<CircleCheck />
</el-icon>
</div>
</div>
<div class="model-card-body">
<p class="model-url">{{ model.baseUrl }}</p>
<div class="model-status">
<el-tag :type="isModelEnabled(model) ? 'success' : 'info'" size="small">
{{ isModelEnabled(model) ? '已启用' : '已禁用' }}
</el-tag>
</div>
</div>
</div>
</div>
</div>
<div class="pagination-wrap">
<el-pagination
:current-page="pagination.pageNum"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 30, 50]"
layout="total, sizes, prev, pager, next"
background
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :disabled="!selectedModel" :loading="saving" @click="handleConfirm">设为会话模型</el-button>
</template>
</el-dialog>
<!-- 系统内置模型填写 API Key经修改接口转换为用户模型并设为会话模型 -->
<el-dialog
v-model="apiKeyDialogVisible"
title="填写 API Key"
width="480px"
append-to-body
:close-on-click-modal="false"
@close="handleApiKeyClose"
>
<el-alert
type="info"
:closable="false"
show-icon
class="api-key-alert"
title="该模型为系统内置模型,填写你的 API Key 后将自动转换为你的用户模型,并设为会话模型。"
/>
<el-form label-position="top" class="api-key-form">
<el-form-item label="API Key" required>
<el-input
v-model="apiKey"
type="password"
show-password
placeholder="请输入你的 API Key"
@keyup.enter="handleApiKeyConfirm"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleApiKeyClose">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleApiKeyConfirm">确认并设置</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { Search, CircleCheck } from '@element-plus/icons-vue';
import { listModelManage, getModelManageTypes, updateModelManage, type ModelManageItem, type ModelTypeTreeNode, type UpdateModelManageParams } from '/@/api/settings/modelConfigV2';
interface Props {
modelValue: boolean;
// id
currentChatModelId?: string | number | null;
//
currentChatModelName?: string;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'saved', model: { id: string; modelName: string }): void;
}
const props = withDefaults(defineProps<Props>(), {
currentChatModelId: null,
currentChatModelName: '',
});
const emit = defineEmits<Emits>();
const visible = ref(false);
const searchKeyword = ref('');
const loading = ref(false);
const saving = ref(false);
const modelList = ref<ModelManageItem[]>([]);
const reasoningTypes = ref<Set<number>>(new Set());
const selectedModel = ref<ModelManageItem | null>(null);
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
// API Key updateModelManage
const apiKeyDialogVisible = ref(false);
const pendingSystemModel = ref<ModelManageItem | null>(null);
const apiKey = ref('');
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
selectedModel.value = null;
searchKeyword.value = '';
pagination.pageNum = 1;
fetchData();
}
}
);
watch(visible, (val) => {
if (!val) emit('update:modelValue', false);
});
// enabled (1/0) (true/false)
const isModelEnabled = (model: ModelManageItem) => model.enabled === true || (model as any).enabled === 1;
// value
const addSubtreeValues = (node: ModelTypeTreeNode, set: Set<number>) => {
set.add(Number(node.value));
for (const child of node.children || []) {
addSubtreeValues(child, set);
}
};
// "" value
// (100) 101 /102 103
// editModule ""
const collectReasoningTypes = (nodes: ModelTypeTreeNode[], set: Set<number>) => {
for (const n of nodes || []) {
const label = String(n.label || '');
if (label.includes('推理') || label.toLowerCase().includes('reasoning')) {
addSubtreeValues(n, set);
continue;
}
if (n.children) collectReasoningTypes(n.children, set);
}
};
//
const fetchAllModels = async () => {
const all: ModelManageItem[] = [];
const pageSize = 100;
const first: any = await listModelManage({ pageNum: 1, pageSize });
all.push(...(first?.data?.list || []));
const total = first?.data?.total || 0;
const pages = Math.ceil(total / pageSize);
for (let p = 2; p <= pages; p++) {
const r: any = await listModelManage({ pageNum: p, pageSize });
all.push(...(r?.data?.list || []));
}
return all;
};
const fetchData = async () => {
loading.value = true;
try {
//
const typeRes: any = await getModelManageTypes();
const set = new Set<number>();
collectReasoningTypes(typeRes?.data?.list || [], set);
reasoningTypes.value = set;
//
const all = await fetchAllModels();
modelList.value = all.filter((m) => {
// 退
if (reasoningTypes.value.size > 0 && !reasoningTypes.value.has(Number(m.modelType))) return false;
return isModelEnabled(m);
});
} catch {
modelList.value = [];
} finally {
loading.value = false;
}
};
const filteredModels = computed(() => {
const kw = searchKeyword.value.trim();
if (!kw) return modelList.value;
return modelList.value.filter((m) => (m.modelName || '').includes(kw));
});
//
const pagedModels = computed(() => {
const start = (pagination.pageNum - 1) * pagination.pageSize;
return filteredModels.value.slice(start, start + pagination.pageSize);
});
watch(
filteredModels,
() => {
pagination.total = filteredModels.value.length;
},
{ immediate: true }
);
const handleSearch = () => {
pagination.pageNum = 1;
};
const handlePageChange = (page: number) => {
pagination.pageNum = page;
};
const handleSizeChange = (size: number) => {
pagination.pageSize = size;
pagination.pageNum = 1;
};
const handleSelectModel = (model: ModelManageItem) => {
// API Key
if (model.systemModel) {
pendingSystemModel.value = model;
apiKey.value = '';
apiKeyDialogVisible.value = true;
return;
}
selectedModel.value = model;
};
const handleConfirm = async () => {
if (!selectedModel.value) return;
await doUpdate(selectedModel.value);
};
const handleApiKeyConfirm = async () => {
if (!apiKey.value.trim()) {
ElMessage.warning('请输入 API Key');
return;
}
if (!pendingSystemModel.value) return;
await doUpdate(pendingSystemModel.value, apiKey.value.trim());
};
//
const buildFullPayload = (model: ModelManageItem, apiKeyVal: string): UpdateModelManageParams => {
const safeObj = (v: unknown): Record<string, unknown> | undefined =>
v && typeof v === 'object' ? (v as Record<string, unknown>) : undefined;
return {
id: model.id,
modelSupplier: Number(model.modelSupplier),
modelName: model.modelName,
modelType: Number(model.modelType),
baseUrl: model.baseUrl,
responseType: Number(model.responseType),
apiKey: apiKeyVal,
enabled: model.enabled,
chatModel: true,
maxConcurrency: model.maxConcurrency,
maxTokens: model.maxTokens,
tokenPredictPrice: model.tokenPredictPrice,
requestHeadMapping: safeObj(model.requestHeadMapping),
requestBodyMapping: safeObj(model.requestBodyMapping),
requestBusinessFieldMapping: model.requestBusinessFieldMapping,
responseMapping: safeObj(model.responseMapping),
responseBodyMapping: (safeObj(model.responseBodyMapping) as Record<string, string>) || undefined,
responseBusinessFieldMapping: model.responseBusinessFieldMapping,
tokenMapping: model.tokenMapping,
asyncTaskMapping: model.asyncTaskMapping,
lastFrame: model.lastFrame || '',
maxDuration: model.maxDuration,
minDuration: model.minDuration,
tokenPredictPriceUnit: model.tokenPredictPriceUnit || '',
priceConfig: model.priceConfig,
};
};
const doUpdate = async (model: ModelManageItem, apiKeyVal?: string) => {
saving.value = true;
try {
// API Key id + chatModel
const payload: UpdateModelManageParams = apiKeyVal ? buildFullPayload(model, apiKeyVal) : { id: model.id, chatModel: true };
const res: any = await updateModelManage(payload);
// modelManage id
const newId = res?.data?.modelManage?.id || res?.data?.id || model.id;
emit('saved', { id: String(newId), modelName: model.modelName });
apiKeyDialogVisible.value = false;
pendingSystemModel.value = null;
selectedModel.value = null;
visible.value = false;
} catch (e: any) {
ElMessage.error(e?.message || '设置会话模型失败');
} finally {
saving.value = false;
}
};
const handleApiKeyClose = () => {
apiKeyDialogVisible.value = false;
pendingSystemModel.value = null;
};
const handleClose = () => {
visible.value = false;
selectedModel.value = null;
};
</script>
<style scoped lang="scss">
.current-model-alert {
margin-bottom: 16px;
}
.setter-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 12px;
}
.search-bar {
display: flex;
gap: 12px;
flex: 1;
}
.model-list {
min-height: 300px;
max-height: 420px;
overflow-y: auto;
}
.model-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.model-card {
background: #f8fafc;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
&.selected {
border-color: #67c23a;
background: #f0f9ff;
}
&.active {
border-color: #2563eb;
background: #eff6ff;
}
}
.model-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.model-name-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.model-icons {
display: inline-flex;
align-items: center;
gap: 4px;
}
.current-icon,
.check-icon {
font-size: 20px;
}
.model-card-body {
flex: 1;
}
.model-name {
font-size: 16px;
font-weight: 600;
color: #1f2937;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-url {
font-size: 12px;
color: #94a3b8;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-status {
display: flex;
align-items: center;
}
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 20px;
}
.api-key-alert {
margin-bottom: 16px;
}
.api-key-form {
margin-bottom: 4px;
}
</style>
+478 -108
View File
@@ -1,17 +1,33 @@
<template>
<div class="sidebar">
<div class="sidebar-header">
<el-button class="new-chat-btn" @click="handleNewChat">
<el-icon>
<Plus />
</el-icon>
新增对话
</el-button>
<!-- 顶部品牌区 -->
<div class="sidebar-brand">
<div class="brand-logo">
<el-icon class="brand-icon"><ChatDotRound /></el-icon>
<span class="brand-name">AI 助手</span>
</div>
<el-tooltip content="新建会话" placement="right">
<button class="new-chat-icon-btn" @click="handleNewChat">
<el-icon><EditPen /></el-icon>
</button>
</el-tooltip>
</div>
<div v-if="historyList.length" class="history-section">
<div class="history-title">历史对话</div>
<div class="history-list">
<!-- Tab 切换 -->
<div class="sidebar-tabs">
<button class="tab-btn" :class="{ active: activeTab === 'history' }" @click="handleTabChange('history')">
<el-icon><ChatLineRound /></el-icon>
会话记录
</button>
<button class="tab-btn" :class="{ active: activeTab === 'workspace' }" @click="handleTabChange('workspace')">
<el-icon><FolderOpened /></el-icon>
工作空间
</button>
</div>
<!-- 对话记录面板 -->
<div v-show="activeTab === 'history'" class="panel">
<div v-if="historyList.length" class="panel-list">
<div
v-for="item in historyList"
:key="item.id"
@@ -19,15 +35,58 @@
:class="{ active: activeHistoryId === item.id }"
@click="handleSelectHistory(item.id)"
>
<div class="history-main">
<div class="history-name">{{ item.title }}</div>
<div class="history-time">{{ item.time }}</div>
<el-icon class="item-icon"><ChatLineRound /></el-icon>
<div class="item-body">
<div class="item-title">{{ item.title }}</div>
<div v-if="item.status === 'executing'" class="status-badge status-running">执行中</div>
<div class="item-time">{{ item.time }}</div>
</div>
<el-button text class="delete-btn" @click.stop="handleDeleteHistory(item.id)">
<el-icon>
<Delete />
<button class="item-del-btn" @click.stop="handleDeleteHistory(item.id)">
<el-icon><Delete /></el-icon>
</button>
</div>
<!-- 历史会话分页加载更多还有更多会话时展示点击加载下一页 -->
<div v-if="historyHasMore" class="history-load-more">
<button class="load-more-btn" :disabled="historyLoadingMore" @click="emit('load-more-history')">
{{ historyLoadingMore ? '加载中...' : '加载更多' }}
</button>
</div>
</div>
<div v-else-if="historyFailed" class="panel-error">
<span class="panel-error-text">加载失败</span>
<button class="panel-retry-btn" @click="emit('retry-history')">重试</button>
</div>
<div v-else class="panel-empty">暂无会话记录</div>
</div>
<!-- 工作空间面板 -->
<div v-show="activeTab === 'workspace'" class="panel" v-loading="treeLoading">
<div v-if="!treeLoading && treeNodes.length === 0 && workspaceFailed" class="panel-error">
<span class="panel-error-text">加载失败</span>
<button class="panel-retry-btn" @click="emit('retry-workspace')">重试</button>
</div>
<el-empty v-else-if="!treeLoading && treeNodes.length === 0" :image-size="48" description="暂无内容" />
<div v-else class="panel-list">
<div v-for="dateNode in treeNodes" :key="dateNode.id" class="tree-date-group">
<div class="tree-date-label">{{ dateNode.label }}</div>
<div v-for="fileNode in dateNode.children" :key="fileNode.id" class="tree-file-item">
<el-icon class="file-type-icon">
<component :is="getFileIcon(fileNode.fileType)" />
</el-icon>
</el-button>
<span class="file-name">{{ fileNode.label }}</span>
<div v-if="fileNode.fileUrl" class="file-actions">
<button class="file-action-btn" @click.stop="handlePreviewNode(fileNode)">预览</button>
<button
class="file-action-btn"
:disabled="downloadingFileId === fileNode.id"
@click.stop="handleDownloadNode(fileNode)"
>
{{ downloadingFileId === fileNode.id ? '下载中…' : '下载' }}
</button>
<button class="file-action-btn file-action-btn--del" @click.stop="handleDeleteNode(fileNode)">删除</button>
</div>
</div>
</div>
</div>
</div>
@@ -35,173 +94,484 @@
</template>
<script setup lang="ts">
import { Plus, Delete } from '@element-plus/icons-vue';
import { ref } from 'vue';
import { Delete, ChatDotRound, ChatLineRound, EditPen, Document, VideoPlay, Headset, Picture, FolderOpened } from '@element-plus/icons-vue';
interface TreeNode {
id: string;
label: string;
nodeType: string;
children?: TreeNode[];
fileUrl?: string;
fileType?: string;
}
interface HistoryItem {
id: number;
id: string;
sessionId: string;
title: string;
time: string;
status?: 'idle' | 'executing' | 'completed' | 'failed';
}
interface Props {
activeMenu: string;
activeHistoryId: number;
activeHistoryId: string | null;
historyList: HistoryItem[];
// / +
historyFailed?: boolean;
//
historyHasMore?: boolean;
historyLoadingMore?: boolean;
workspaceFailed?: boolean;
// false tab
workspaceLoaded?: boolean;
// id
downloadingFileId?: string | null;
treeNodes: TreeNode[];
treeLoading: boolean;
}
interface Emits {
(e: 'menu-change', key: string): void;
(e: 'new-chat'): void;
(e: 'select-history', id: number): void;
(e: 'delete-history', id: number): void;
(e: 'select-history', id: string): void;
(e: 'delete-history', id: string): void;
(e: 'preview-node', data: TreeNode): void;
(e: 'download-node', data: TreeNode): void;
(e: 'delete-node', data: TreeNode): void;
(e: 'load-workspace'): void;
(e: 'retry-history'): void;
(e: 'load-more-history'): void;
(e: 'retry-workspace'): void;
}
defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
historyFailed: false,
historyHasMore: false,
historyLoadingMore: false,
workspaceFailed: false,
workspaceLoaded: false,
downloadingFileId: null,
});
const emit = defineEmits<Emits>();
const handleNewChat = () => {
emit('new-chat');
const activeTab = ref<'history' | 'workspace'>('history');
const handleTabChange = (tab: 'history' | 'workspace') => {
activeTab.value = tab;
// tab
// workspaceLoaded tab
if (tab === 'workspace' && !props.workspaceLoaded) {
emit('load-workspace');
}
};
const handleSelectHistory = (id: number) => {
emit('select-history', id);
const getFileIcon = (fileType?: string) => {
if (!fileType) return Document;
const t = fileType.toLowerCase();
if (t.includes('image') || t.includes('png') || t.includes('jpg') || t.includes('gif')) return Picture;
if (t.includes('video') || t.includes('mp4') || t.includes('webm')) return VideoPlay;
if (t.includes('audio') || t.includes('mp3') || t.includes('wav')) return Headset;
return Document;
};
const handleDeleteHistory = (id: number) => {
emit('delete-history', id);
};
const handleNewChat = () => emit('new-chat');
const handleSelectHistory = (id: string) => emit('select-history', id);
const handleDeleteHistory = (id: string) => emit('delete-history', id);
const handlePreviewNode = (data: TreeNode) => emit('preview-node', data);
const handleDownloadNode = (data: TreeNode) => emit('download-node', data);
const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
</script>
<style scoped lang="scss">
.sidebar {
width: 252px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.95) 0%, rgba(249, 251, 255, 0.92) 100%);
border-right: 1px solid #e7edf7;
width: 240px;
flex-shrink: 0;
background: #f7f8fa;
border-right: 1px solid #e5e8ee;
display: flex;
flex-direction: column;
height: 100%;
box-shadow: 8px 0 30px rgba(15, 23, 42, 0.06);
backdrop-filter: blur(10px);
overflow: hidden;
}
.sidebar-header {
padding: 18px 16px 14px;
border-bottom: 1px solid #e9eef7;
position: relative;
/* 品牌区 */
.sidebar-brand {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 14px 14px;
flex-shrink: 0;
}
.new-chat-btn {
margin-top: 0;
width: 100%;
height: 40px;
border-radius: 12px;
border: 1px solid rgba(59, 130, 246, 0.28);
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: #ffffff;
font-weight: 600;
letter-spacing: 0.2px;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.26);
transition: all 0.2s ease;
.brand-logo {
display: flex;
align-items: center;
gap: 9px;
}
.brand-icon {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9px;
background: linear-gradient(135deg, #2563eb, #4f8bff);
color: #fff;
font-size: 16px;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.25);
}
.brand-name {
font-size: 15px;
font-weight: 700;
color: #0f172a;
letter-spacing: -0.2px;
}
.new-chat-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: none;
border-radius: 8px;
background: transparent;
color: #64748b;
cursor: pointer;
transition: background 0.15s, color 0.15s;
outline: none;
.el-icon {
font-size: 16px;
}
&:hover {
transform: translateY(-1px);
color: #ffffff;
border-color: rgba(59, 130, 246, 0.4);
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.32);
background: #e8ebf0;
color: #0f172a;
}
}
.history-section {
flex: 1;
/* Tab 切换(圆角胶囊) */
.sidebar-tabs {
display: flex;
flex-direction: column;
margin-top: 8px;
min-height: 0;
background: linear-gradient(180deg, rgba(249, 251, 255, 0.5) 0%, rgba(249, 251, 255, 0.9) 100%);
}
.history-title {
padding: 12px 12px 8px;
font-size: 11px;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.8px;
gap: 4px;
flex-shrink: 0;
}
.history-list {
.tab-btn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 36px;
border: none;
border-radius: 10px;
background: transparent;
color: #64748b;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, color 0.15s;
outline: none;
.el-icon {
font-size: 15px;
}
&:hover:not(.active) {
background: #eef0f3;
color: #334155;
}
&.active {
color: #2563eb;
font-weight: 600;
background: #fff;
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.06);
}
}
/* 面板通用 */
.panel {
flex: 1;
padding: 0 8px 10px;
overflow-y: auto;
padding: 4px 8px 12px;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
.panel-list {
display: flex;
flex-direction: column;
}
.panel-empty {
font-size: 12px;
color: #cbd5e1;
text-align: center;
padding: 32px 0;
}
/* 加载失败错误态:可区分「失败」与「空数据」,并提供重试入口 */
.panel-error {
padding: 32px 0;
text-align: center;
}
.panel-error-text {
font-size: 12px;
color: #94a3b8;
margin-right: 8px;
}
.panel-retry-btn {
border: 1px solid #dbeafe;
background: #eff6ff;
color: #2563eb;
font-size: 12px;
padding: 4px 14px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
outline: none;
&:hover {
background: #dbeafe;
}
}
/* 历史记录条目 */
.history-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 10px;
margin-bottom: 6px;
padding: 10px;
border-radius: 10px;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
background: rgba(255, 255, 255, 0.6);
transition: background 0.15s;
margin-bottom: 2px;
&:hover {
border-color: #bfdbfe;
background: linear-gradient(135deg, rgba(239, 246, 255, 0.9) 0%, rgba(219, 234, 254, 0.7) 100%);
transform: translateX(2px);
}
background: #eef0f3;
&.active {
border-color: #3b82f6;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(59, 130, 246, 0.04) 100%);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.15);
}
.delete-btn {
opacity: 0;
color: #94a3b8;
transition: all 0.2s ease;
padding: 4px;
&:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
border-radius: 4px;
.item-del-btn {
opacity: 1;
}
}
&:hover .delete-btn {
opacity: 1;
&.active {
background: #e6f0ff;
.item-icon {
color: #2563eb;
}
.item-title {
color: #1d4ed8;
font-weight: 600;
}
}
}
.history-main {
min-width: 0;
flex: 1;
.item-icon {
font-size: 14px;
color: #94a3b8;
flex-shrink: 0;
}
.history-name {
font-size: 12px;
.item-body {
flex: 1;
min-width: 0;
}
.item-title {
font-size: 13px;
font-weight: 500;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
line-height: 1.4;
}
.history-time {
.item-time {
font-size: 11px;
color: #94a3b8;
margin-top: 2px;
}
.item-del-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 6px;
background: transparent;
color: #94a3b8;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s, color 0.15s;
flex-shrink: 0;
outline: none;
.el-icon {
font-size: 13px;
}
&:hover {
background: #fee2e2;
color: #ef4444;
}
}
/* 工作空间树节点 */
.tree-date-group {
display: flex;
flex-direction: column;
margin-bottom: 4px;
}
.tree-date-label {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
padding: 8px 6px 4px;
letter-spacing: 0.4px;
text-transform: uppercase;
}
.tree-file-item {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 10px 7px 24px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #eef0f3;
.file-actions {
opacity: 1;
}
}
}
.file-type-icon {
font-size: 13px;
color: #64748b;
flex-shrink: 0;
}
.file-name {
flex: 1;
font-size: 12px;
color: #334155;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-actions {
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s;
flex-shrink: 0;
}
.file-action-btn {
border: none;
background: transparent;
color: #3b82f6;
font-size: 11px;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
outline: none;
transition: background 0.15s;
&:hover {
background: #dbeafe;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
&:hover {
background: transparent;
}
}
&--del {
color: #ef4444;
&:hover {
background: #fee2e2;
color: #dc2626;
}
}
}
.status-badge {
font-size: 11px;
padding: 1px 8px;
border-radius: 999px;
line-height: 1.5;
flex-shrink: 0;
margin-left: auto;
}
.status-running {
background: #dbeafe;
color: #2563eb;
animation: pulse 1.5s ease-in-out infinite;
}
/* 历史会话分页「加载更多」按钮 */
.history-load-more {
padding: 10px 0 4px;
text-align: center;
}
.load-more-btn {
border: 1px solid #e5e8ee;
background: #fff;
color: #64748b;
font-size: 12px;
padding: 5px 20px;
border-radius: 999px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
outline: none;
&:hover:not(:disabled) {
border-color: #93c5fd;
color: #2563eb;
background: #eff6ff;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
</style>
@@ -0,0 +1,500 @@
<template>
<div class="template-complete-dialog">
<el-dialog
:model-value="modelValue"
title="完善系统工作流"
width="760px"
top="6vh"
:close-on-click-modal="false"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
>
<div v-loading="loading" class="tc-body">
<el-alert
type="info"
:closable="false"
show-icon
title="系统模板缺少运行所需配置,补全后将保存为你的个人工作流,之后可直接复用。"
class="tc-alert"
/>
<template v-if="!loading">
<el-form label-position="top" class="tc-form">
<el-form-item label="工作流名称">
<el-input v-model="flowName" placeholder="请输入工作流名称" maxlength="50" />
</el-form-item>
</el-form>
<el-empty v-if="flowWorkflowGroups.length === 0" description="该模板无需补全模型参数" :image-size="60" />
<!-- 主模板 / 子工作流 分组递归展开整棵工作流树每层模型节点单独补全 -->
<div v-for="group in flowWorkflowGroups" :key="group.wf.key" class="tc-group">
<div v-if="group.wf.key !== 'main'" class="tc-group-title">{{ group.wf.pathLabel }}</div>
<div v-for="m in group.models" :key="m.nodeKey" class="tc-node">
<div class="tc-node-title">
<span class="tc-node-name">{{ m.node.name || m.node.nodeCode }}</span>
<el-tag v-if="!m.node.modelConfig?.modelId" type="warning" size="small">未选择模型</el-tag>
<el-tag v-else-if="m.node.modelConfig?.modelName" type="success" size="small">{{ m.node.modelConfig.modelName }}</el-tag>
</div>
<div class="tc-node-model">
<template v-if="m.node.modelConfig?.modelId">
<!-- 系统模型直接内联显示 API Key 输入框保存时统一转换 -->
<div v-if="systemModelNodeIds[m.nodeKey]" class="tc-api-key-inline">
<el-input
v-model="apiKeyInputs[m.nodeKey]"
type="password"
show-password
placeholder="该模型为系统内置模型,请输入你的 API Key"
size="small"
clearable
/>
</div>
<el-button type="primary" plain size="small" @click="openModelSelector(m)">重新选择模型</el-button>
</template>
<el-button v-else type="primary" plain size="small" @click="openModelSelector(m)">选择模型</el-button>
</div>
</div>
</div>
</template>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="saving" :disabled="!canSave" @click="handleSave">保存并使用</el-button>
</template>
<!-- 内嵌模型选择器自身 el-dialog teleport body层级高于本弹窗 -->
<ModelSelector
v-model="modelSelectorVisible"
:default-model="selectorDefaultModel"
:same-type-model-type="selectorTargetRef?.node?.modelConfig?.modelType"
@confirm="handleModelConfirm"
/>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { ElMessage } from 'element-plus';
import ModelSelector from '/@/views/settings/workflow/component/ModelSelector.vue';
import { stripReadonlyFields, deepClone, buildModelRequestParamsPath, enrichResponseBodyMapping } from '/@/views/settings/workflow/component/modelParamUtils';
import { getWorkflowDetail } from '/@/api/settings/creation';
import { updateWorkflowFromTemplate, getSubFlowWorkflowDetail } from '/@/api/settings/workflow';
import { getModelManage, updateModelManage } from '/@/api/settings/modelConfigV2';
interface Props {
modelValue: boolean;
template?: any; // { id, flowName, raw }
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'saved', newId: string, flowName: string): void;
}
// sub_flow
interface FlowWorkflowNode {
key: string; // key 'main' 'sub:{workflowId}'
id: string; // id id
flowName: string; // 沿
flowContent: any; // flowContent subFlows
nodes: any[]; // flowContent.nodes
startNodeId: string; // idbuildModelRequestParamsPath
pathLabel: string; // '' / 'xxx'
hasModel: boolean; // model subFlows
children: FlowWorkflowNode[];
}
// nodeKey id key
interface ModelNodeRef {
wfKey: string;
nodeKey: string;
node: any;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const loading = ref(false);
const saving = ref(false);
const flowName = ref('');
const flowTree = ref<FlowWorkflowNode | null>(null);
const modelRefs = ref<ModelNodeRef[]>([]);
// id /
const visitedFlowIds = new Set<string>();
const modelSelectorVisible = ref(false);
const selectorTargetRef = ref<ModelNodeRef | null>(null);
const selectorDefaultModel = ref<any>(null);
// API Key Key
const apiKeyInputs = ref<Record<string, string>>({});
const systemModelNodeIds = ref<Record<string, boolean>>({});
//
const flowWorkflowGroups = computed(() => {
const groups: { wf: FlowWorkflowNode; models: ModelNodeRef[] }[] = [];
const walk = (wf: FlowWorkflowNode) => {
groups.push({ wf, models: modelRefs.value.filter((m) => m.wfKey === wf.key) });
for (const c of wf.children) walk(c);
};
if (flowTree.value) walk(flowTree.value);
return groups;
});
const canSave = computed(() => {
if (!flowName.value.trim()) return false;
//
if (modelRefs.value.some((m) => !m.node.modelConfig?.modelId)) return false;
// API Key
if (modelRefs.value.some((m) => systemModelNodeIds.value[m.nodeKey] && !(apiKeyInputs.value[m.nodeKey] || '').trim())) return false;
return true;
});
// nodes model sub_flow
const expandSubFlows = async (wf: FlowWorkflowNode) => {
for (const n of wf.nodes) {
const code = String(n?.nodeCode || '').toLowerCase();
if (code === 'model') {
wf.hasModel = true;
modelRefs.value.push({ wfKey: wf.key, nodeKey: `${wf.key}|${n.id}`, node: n });
} else if (code === 'sub_flow') {
const subWfId = String(n?.subConfig?.workflowId || '');
if (!subWfId || visitedFlowIds.has(subWfId)) continue;
visitedFlowIds.add(subWfId);
try {
const detail = (await getSubFlowWorkflowDetail(subWfId))?.data;
const fc = detail?.flowContent || {};
const childNodes = Array.isArray(fc.nodes) ? fc.nodes : [];
const child: FlowWorkflowNode = {
key: 'sub:' + subWfId,
id: subWfId,
flowName: detail?.flowName || '',
flowContent: fc,
nodes: childNodes,
startNodeId: childNodes.find((x: any) => String(x?.nodeCode || '').toLowerCase() === '__start__')?.id || '',
pathLabel: '子工作流『' + (detail?.flowName || subWfId) + '』',
hasModel: false,
children: [],
};
wf.children.push(child);
await expandSubFlows(child);
} catch {
//
}
}
}
};
// DSLget 退 flowContent
const buildMainTree = async () => {
let fc: any = null;
let mainNodes: any[] = [];
let detailFlowName = '';
try {
const detail = (await getWorkflowDetail(props.template?.id))?.data;
fc = detail?.flowContent || null;
mainNodes = JSON.parse(JSON.stringify(fc?.nodes || detail?.nodeInputParams || []));
detailFlowName = detail?.flowName || '';
} catch {
fc = props.template?.raw?.flowContent || props.template?.flowContent;
mainNodes = JSON.parse(JSON.stringify(fc?.nodes || []));
}
flowTree.value = {
key: 'main',
id: String(props.template?.id || ''),
flowName: detailFlowName || props.template?.flowName || '',
flowContent: fc,
nodes: mainNodes,
startNodeId: mainNodes.find((n: any) => String(n?.nodeCode || '').toLowerCase() === '__start__')?.id || '',
pathLabel: '主模板',
hasModel: false,
children: [],
};
visitedFlowIds.add(flowTree.value.id);
if (detailFlowName) flowName.value = '我的-' + detailFlowName;
};
watch(
() => props.modelValue,
async (val) => {
if (!val) return;
loading.value = true;
saving.value = false;
flowName.value = '我的-' + (props.template?.flowName || '系统模板');
flowTree.value = null;
modelRefs.value = [];
visitedFlowIds.clear();
apiKeyInputs.value = {};
systemModelNodeIds.value = {};
try {
await buildMainTree();
if (flowTree.value) await expandSubFlows(flowTree.value);
} finally {
// API Key loading
await checkModelApiKey();
loading.value = false;
}
}
);
const openModelSelector = (m: ModelNodeRef) => {
selectorTargetRef.value = m;
selectorDefaultModel.value = null;
modelSelectorVisible.value = true;
};
// key id
const getWfStartNodeId = (wfKey: string): string => {
const walk = (wf: FlowWorkflowNode | null): string => {
if (!wf) return '';
if (wf.key === wfKey) return wf.startNodeId;
for (const c of wf.children) {
const r = walk(c);
if (r) return r;
}
return '';
};
return walk(flowTree.value);
};
// handleModelConfirm
const handleModelConfirm = (model: any) => {
const m = selectorTargetRef.value;
if (m) {
const node = m.node;
node.modelConfig = node.modelConfig || {};
node.modelConfig.modelId = model.id || '';
node.modelConfig.modelName = model.modelName;
node.modelConfig.modelType = model.modelType;
// requestBodyMapping strip __originIndex
const params = stripReadonlyFields(deepClone(model.requestBodyMapping ?? null));
node.modelConfig.modelRequestParams = params;
// __originIndex
node.modelConfig.modelRequestParamsPath = buildModelRequestParamsPath(params, getWfStartNodeId(m.wfKey));
// responseMapping value
node.modelConfig.modelResponseBodyMapping = enrichResponseBodyMapping(model.responseBodyMapping, model.responseMapping);
// API Key
systemModelNodeIds.value[m.nodeKey] = false;
apiKeyInputs.value[m.nodeKey] = '';
}
modelSelectorVisible.value = false;
selectorTargetRef.value = null;
};
// API Key
const checkModelApiKey = async () => {
const targets = modelRefs.value.filter((m) => m.node.modelConfig?.modelId);
await Promise.all(
targets.map(async (m) => {
try {
const res = await getModelManage(m.node.modelConfig.modelId);
const model = res?.data?.modelManage || res?.data;
systemModelNodeIds.value[m.nodeKey] = model?.systemModel === true;
if (!systemModelNodeIds.value[m.nodeKey]) apiKeyInputs.value[m.nodeKey] = '';
} catch {
// Key
systemModelNodeIds.value[m.nodeKey] = false;
}
})
);
};
//
const convertSystemModel = async (m: ModelNodeRef) => {
const node = m.node;
const res = await getModelManage(node.modelConfig.modelId);
const src = res?.data?.modelManage || res?.data;
if (!src?.id) throw new Error('模型详情获取失败');
const upRes = await updateModelManage({
id: src.id,
//
modelName: src.modelName,
modelType: src.modelType,
modelSupplier: src.modelSupplier,
baseUrl: src.baseUrl,
responseType: src.responseType ?? src.invokeType,
apiKey: (apiKeyInputs.value[m.nodeKey] || '').trim(),
enabled: src.enabled,
chatModel: src.ChatModel ?? src.chatModel,
maxConcurrency: src.maxConcurrency,
maxTokens: src.maxTokens,
tokenPredictPrice: src.tokenPredictPrice,
requestHeadMapping: src.requestHeadMapping ?? src.requestMapping,
requestBodyMapping: src.requestBodyMapping,
requestBusinessFieldMapping: src.requestBusinessFieldMapping,
responseMapping: src.responseMapping,
responseBodyMapping: src.responseBodyMapping,
responseBusinessFieldMapping: src.responseBusinessFieldMapping,
minDuration: src.minDuration,
priceConfig: src.priceConfig,
...(src.tokenMapping ? { tokenMapping: src.tokenMapping } : {}),
...(src.asyncTaskMapping ? { asyncTaskMapping: src.asyncTaskMapping } : {}),
...(src.lastFrame ? { lastFrame: src.lastFrame } : {}),
...(src.maxDuration ? { maxDuration: src.maxDuration } : {}),
...(src.tokenPredictPriceUnit ? { tokenPredictPriceUnit: src.tokenPredictPriceUnit } : {}),
} as any);
const newModelManage = upRes?.data?.modelManage || upRes?.data;
if (!newModelManage?.id) throw new Error('接口未返回新的用户模型');
// Key
node.modelConfig.modelId = newModelManage.id;
systemModelNodeIds.value[m.nodeKey] = false;
apiKeyInputs.value[m.nodeKey] = '';
};
// DSL modelRequestParamsPath
const patchRequestParamsPath = (wf: FlowWorkflowNode) => {
for (const n of wf.nodes) {
if (String(n?.nodeCode || '').toLowerCase() !== 'model') continue;
const mc = n?.modelConfig;
if (mc?.modelRequestParams && !mc.modelRequestParamsPath) {
mc.modelRequestParamsPath = buildModelRequestParamsPath(mc.modelRequestParams, wf.startNodeId);
}
}
for (const c of wf.children) patchRequestParamsPath(c);
};
// model
const nodeHasModelInSubtree = (wf: FlowWorkflowNode): boolean => wf.hasModel || wf.children.some(nodeHasModelInSubtree);
// subFlows
const buildSubFlows = (wf: FlowWorkflowNode): any[] =>
wf.children
.filter((c) => nodeHasModelInSubtree(c))
.map((c) => ({
id: c.id,
flowName: c.flowName,
description: '',
flowContent: c.flowContent,
subFlows: buildSubFlows(c),
}));
const handleSave = async () => {
const name = flowName.value.trim();
if (!name) {
ElMessage.warning('请输入工作流名称');
return;
}
const missing = modelRefs.value.find((m) => !m.node.modelConfig?.modelId);
if (missing) {
ElMessage.warning(`节点「${missing.node.name || missing.node.nodeCode}」未选择模型,请先选择`);
return;
}
saving.value = true;
try {
//
for (const m of modelRefs.value.filter((mm) => systemModelNodeIds.value[mm.nodeKey])) {
await convertSystemModel(m);
}
// modelRequestParamsPath +
if (flowTree.value) patchRequestParamsPath(flowTree.value);
const flowContent = {
...(flowTree.value?.flowContent || {}),
nodes: flowTree.value?.nodes || [],
};
const res = await updateWorkflowFromTemplate({
id: flowTree.value?.id || String(props.template?.id || ''),
flowName: name,
description: '',
flowContent,
subFlows: flowTree.value ? buildSubFlows(flowTree.value) : [],
});
const data = res?.data;
const newId = data?.id ?? (typeof data === 'string' || typeof data === 'number' ? data : null);
if (!newId) throw new Error('保存失败:未返回工作流ID');
ElMessage.success('已保存为用户工作流');
emit('saved', String(newId), name);
emit('update:modelValue', false);
} catch (e: any) {
ElMessage.error(e?.message || '保存失败,请重试');
} finally {
saving.value = false;
}
};
const handleClose = () => {
emit('update:modelValue', false);
};
</script>
<style scoped lang="scss">
.tc-body {
min-height: 120px;
max-height: 62vh;
overflow-y: auto;
padding: 2px 4px 4px 0;
}
.tc-alert {
margin-bottom: 16px;
}
.tc-form {
:deep(.el-form-item) {
margin-bottom: 14px;
}
}
/* 主模板 / 子工作流 分组:子工作流组带标题条,与主模板节点区分 */
.tc-group {
margin-bottom: 16px;
}
.tc-group-title {
font-size: 12px;
font-weight: 600;
color: #2563eb;
background: #eff6ff;
border-radius: 6px;
padding: 6px 10px;
margin-bottom: 10px;
}
.tc-node {
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 12px 14px;
margin-bottom: 12px;
background: #fbfcfe;
&:last-child {
margin-bottom: 0;
}
}
.tc-node-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
.tc-node-name {
font-size: 13px;
font-weight: 600;
color: #334155;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.tc-node-model {
margin-bottom: 10px;
}
.tc-api-key-inline {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
.el-input {
max-width: 340px;
}
}
</style>
@@ -0,0 +1,850 @@
<template>
<div class="workflow-form-card" :class="{ readonly, submitting, failed: isFailed }">
<div class="workflow-form-header" @click="toggleCollapse">
<div class="header-left">
<h3>{{ detail?.flowName || '工作流表单' }}</h3>
<div class="workflow-form-sub">{{ detail?.description || '请填写以下参数' }}</div>
</div>
<div class="header-right">
<el-tag v-if="submitting" size="small" type="primary">执行中</el-tag>
<el-tag v-else-if="isFailed" size="small" type="danger">执行失败</el-tag>
<el-tag v-else-if="formStatus === 'done'" size="small" type="success">执行完成</el-tag>
<!-- 折叠切换明确按钮提示用户可展开/收起已填参数点击标题行同样可切换 -->
<button type="button" class="collapse-toggle" @click.stop="toggleCollapse">
<el-icon class="collapse-arrow" :class="{ collapsed }"><ArrowDown /></el-icon>
<span>{{ collapsed ? '展开' : '收起' }}</span>
</button>
</div>
</div>
<!-- 节点执行过程区仿豆包步骤流有节点事件才显示始终可见不受参数收起影响 -->
<div v-if="progress?.nodes?.length" class="node-progress-list">
<div v-for="node in progress.nodes" :key="node.nodeId" class="node-step" :class="'is-' + node.status">
<span class="node-step-icon">
<span v-if="node.status === 'running'" class="node-spinner" />
<el-icon v-else-if="node.status === 'done'" class="node-icon-done"><CircleCheckFilled /></el-icon>
<el-icon v-else-if="node.status === 'failed'" class="node-icon-failed"><CircleCloseFilled /></el-icon>
<span v-else class="node-dot" />
</span>
<span class="node-step-name">{{ node.nodeName }}</span>
<span class="node-step-status">{{ nodeStatusText(node) }}</span>
</div>
</div>
<div v-show="!collapsed" class="workflow-form-scroll">
<el-form label-position="top" class="workflow-form">
<!-- 计费方式必选不设置默认值可选项来自计价对象 charge-modes 接口workflow -->
<el-form-item label="计费方式" required>
<el-select v-model="chargeMode" :disabled="isDisabled" placeholder="请选择计费方式" class="w100">
<el-option v-for="opt in chargeModes" :key="opt.mode" :label="opt.name" :value="opt.mode" />
</el-select>
</el-form-item>
<template v-if="detail?.nodeInputParams">
<template v-for="node in detail.nodeInputParams" :key="node.id || node.nodeCode">
<template v-if="hasFormConfig(node)">
<el-form-item
v-for="field in getVisibleFields(node)"
:key="getFieldKey(node, field)"
:label="field.label"
:required="field.required"
>
<!-- 引用其他节点输出只读展示值由上游节点运行时提供 -->
<div v-if="field.valueSource" class="field-source-readonly">
<el-tag type="info" size="small" class="source-tag">引用</el-tag>
<span class="source-text">{{ getSourceDisplay(field) }}自动提供</span>
</div>
<template v-else>
<!-- 文本输入 -->
<el-input
v-if="field.type === 'input' || field.type === 'string'"
v-model="formValues[getFieldKey(node, field)]"
:disabled="isDisabled"
:placeholder="field.required ? '必填' : '选填'"
clearable
/>
<!-- 数字输入 -->
<el-input-number
v-else-if="field.type === 'number' || field.type === 'inputNumber'"
v-model="formValues[getFieldKey(node, field)]"
class="w100"
:controls="true"
:disabled="isDisabled"
:min="field.fieldConstraint?.minValue ?? undefined"
:max="field.fieldConstraint?.maxValue ?? undefined"
/>
<!-- 多行文本 -->
<el-input
v-else-if="field.type === 'textarea'"
v-model="formValues[getFieldKey(node, field)]"
type="textarea"
:rows="3"
:disabled="isDisabled"
:placeholder="field.required ? '必填' : '选填'"
show-word-limit
:maxlength="500"
/>
<!-- 开关 -->
<el-switch
v-else-if="field.type === 'switch'"
v-model="formValues[getFieldKey(node, field)]"
:disabled="isDisabled"
active-text="开启"
inactive-text="关闭"
/>
<!-- 下拉选择 -->
<el-select
v-else-if="field.type === 'select' && field.options"
v-model="formValues[getFieldKey(node, field)]"
:disabled="isDisabled"
:placeholder="field.required ? '必填' : '选填'"
class="w100"
>
<el-option v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<!-- 文件上传 -->
<div v-else-if="isFileField(field)" class="field-upload-wrapper">
<el-upload
:key="`upload-${getFieldKey(node, field)}`"
:auto-upload="false"
:multiple="field.type === 'uploadMultiple'"
:show-file-list="false"
:accept="getFileAccept(field)"
:disabled="isDisabled"
:on-change="(file: any) => handleFileUpload(node, field, file)"
>
<el-button
size="small"
type="primary"
:loading="uploadingFields[getFieldKey(node, field)]"
:disabled="isDisabled || uploadingFields[getFieldKey(node, field)]"
>
{{ uploadingFields[getFieldKey(node, field)] ? '上传中...' : '选择文件' }}
</el-button>
</el-upload>
<div class="upload-rules">
<span>{{ getFileRuleText(field) }}</span>
<span v-if="getFieldFileList(node, field).length > 0" class="upload-count">
已上传 {{ getFieldFileCountText(node, field) }}
</span>
</div>
<div v-if="getFieldFileList(node, field).length > 0" class="uploaded-files-list">
<div
v-for="(f, fileIdx) in getFieldFileList(node, field)"
:key="fileIdx"
class="uploaded-file-item"
>
<span class="file-name">{{ f.name }}</span>
<el-button v-if="!isDisabled" type="danger" link size="small" @click="removeFieldFile(node, field, fileIdx)">
删除
</el-button>
</div>
</div>
</div>
<!-- 兜底文本输入 -->
<el-input
v-else
v-model="formValues[getFieldKey(node, field)]"
:disabled="isDisabled"
:placeholder="field.required ? '必填' : '选填'"
/>
</template>
</el-form-item>
</template>
</template>
</template>
</el-form>
<!-- 贴片模板编辑器 -->
<div v-if="currentWorkflowHasPatchLayout" class="patch-template-section">
<PatchTemplateEditor v-model="templates" />
</div>
<el-empty v-if="!hasFormFields && !currentWorkflowHasPatchLayout" description="该工作流无需填写参数" :image-size="60" />
</div>
<!-- 底部操作区editing 提示 / running 执行中 / done·failed 状态标签执行统一由 InputBar 发送按钮触发 -->
<div class="workflow-form-footer">
<div v-if="isFailed" class="form-error-text">{{ formError }}</div>
<!-- 执行中节点文本进度推进后端 node_start/node_complete 事件无进度时兜底执行中...
停止统一由 InputBar 发送按钮变停止触发卡片内不再提供取消入口 -->
<div v-if="submitting" class="executing-progress">
<span class="exec-spinner" />
<span class="exec-text">{{ progressText }}</span>
</div>
<!-- 已执行卡片done/failed含可编辑的最后一张显示状态标签参数是否可编辑由 editable 决定 -->
<el-tag v-else-if="formStatus === 'done' || formStatus === 'failed'" :type="isFailed ? 'danger' : 'success'" size="small" effect="light">
{{ isFailed ? '执行失败' : '执行完成' }}
</el-tag>
<!-- 编辑态无执行按钮提示通过下方发送按钮执行 -->
<span v-else class="form-edit-tip">填写参数后点击下方发送按钮执行工作流</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { ArrowDown, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
import { uploadFile } from '/@/api/common/upload';
import { getChargeModes } from '/@/api/trade/pricing';
import { collectHomeFormFields } from '../utils/flowDsl';
import type { WorkflowNodeStep } from '../utils/wsMessage';
interface Props {
// nodeInputParams / flowContent
detail: any;
// done/failed
readonly?: boolean;
//
submitting?: boolean;
// failed
formError?: string;
// node_start/node_complete
progress?: { nodes: WorkflowNodeStep[] };
// InputBar null
executeSignal?: { action: 'submit'; nonce: number } | null;
// done/failed
editable?: boolean;
}
interface Emits {
(e: 'submit', payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates: any[]; chargeMode: string }): void;
}
const props = withDefaults(defineProps<Props>(), {
readonly: false,
submitting: false,
formError: '',
progress: () => ({ nodes: [] }),
executeSignal: null,
editable: false,
});
const emit = defineEmits<Emits>();
const formValues = reactive<Record<string, any>>({});
const fieldFiles = reactive<Record<string, { name: string; url: string }[]>>({});
// formValues value
const formFileNames = reactive<Record<string, string | string[]>>({});
const uploadingFields = reactive<Record<string, boolean>>({});
const templates = ref<any[]>([]);
// ===== =====
interface ChargeModeOption {
mode: string;
name: string;
}
const chargeMode = ref('');
const chargeModes = ref<ChargeModeOption[]>([]);
/** 计费方式可选项缓存:workflow 计价对象固定(subjectType/subjectId 均 workflow),模块级共享避免每张卡片重复请求 */
let workflowChargeModesCache: ChargeModeOption[] | null = null;
const loadChargeModes = async () => {
if (workflowChargeModesCache) {
chargeModes.value = workflowChargeModesCache;
return;
}
try {
const res: any = await getChargeModes({ subjectType: 'workflow', subjectId: 'workflow' });
const list: ChargeModeOption[] = res?.data?.chargeModes || [];
chargeModes.value = list;
workflowChargeModesCache = list;
} catch {
//
chargeModes.value = [];
}
};
// = formError = done/failed
// editablesubmitting
const isFailed = computed(() => !!props.formError);
const isDisabled = computed(() => (props.readonly && !props.editable) || props.submitting);
// /editing/running
// done/failed
const collapsed = ref(false);
const formStatus = computed(() => {
if (props.submitting) return 'running';
if (props.formError) return 'failed';
if (props.readonly) return 'done';
return 'editing';
});
watch(
formStatus,
(st, prev) => {
if (st === 'editing') {
collapsed.value = false; // /
} else if (prev === undefined) {
// //
// /
collapsed.value = true;
} else if (st === 'running') {
collapsed.value = true; //
}
// done/failed
},
{ immediate: true }
);
const toggleCollapse = () => {
collapsed.value = !collapsed.value;
};
// ...
const progressText = computed(() => {
const running = (props.progress?.nodes || []).find((n) => n.status === 'running');
return running ? `正在执行:${running.nodeName}` : '执行中...';
});
//
const nodeStatusText = (node: WorkflowNodeStep): string => {
switch (node.status) {
case 'running':
return '执行中';
case 'done':
return '完成';
case 'failed':
return '失败';
default:
return '待执行';
}
};
const getFieldKey = (node: any, field: any): string => {
const id = node.id || node.nodeCode;
return `${id}|${field.path || field.field || field.label}`;
};
const getVisibleFields = (node: any): any[] => {
return collectHomeFormFields(node);
};
// valueSource [{ nodeId, field }] DSL +
const getSourceDisplay = (field: any): string => {
const vs = field?.valueSource;
if (!vs || typeof vs !== 'object') return '';
const arr = Array.isArray(vs) ? vs : [vs];
const nodes = props.detail?.nodeInputParams || [];
const parts = arr
.filter((r: any) => r && typeof r === 'object')
.map((r: any) => {
const srcNode = nodes.find((n: any) => String(n?.id) === String(r.nodeId));
const nodeName = srcNode?.name || srcNode?.nodeName || (r.nodeId ? `节点 ${r.nodeId}` : '上游节点');
return r.field ? `${nodeName} · ${r.field}` : nodeName;
});
return parts.join('');
};
const isFileField = (field: any): boolean => {
return field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload';
};
const getFileAccept = (field: any): string => {
return field.fieldConstraint?.fileTypes ? '.' + field.fieldConstraint.fileTypes.replace(/,/g, ',.') : '*';
};
const getFileRuleText = (field: any): string => {
const rules: string[] = [];
if (field.fieldConstraint?.fileTypes) rules.push('格式: ' + field.fieldConstraint.fileTypes);
if (field.fieldConstraint?.maxFileSize) rules.push('最大: ' + field.fieldConstraint.maxFileSize + 'MB');
if (field.fieldConstraint?.maxFileCount) rules.push('最多: ' + field.fieldConstraint.maxFileCount + '个');
return rules.join(' | ');
};
const getFieldFileList = (node: any, field: any): { name: string; url: string }[] => {
return fieldFiles[getFieldKey(node, field)] || [];
};
const getFieldConstraint = (field: any): any => {
return field?.fieldConstraint && typeof field.fieldConstraint === 'object' ? field.fieldConstraint : {};
};
const getFieldFileCountText = (node: any, field: any): string => {
const current = getFieldFileList(node, field).length;
const max = Number(getFieldConstraint(field).maxFileCount);
if (!Number.isNaN(max) && max > 0) {
return `${current} / ${max}`;
}
return `${current}`;
};
const handleFileUpload = async (node: any, field: any, file: any) => {
const raw = file.raw;
if (!raw) return;
const key = getFieldKey(node, field);
const fc = getFieldConstraint(field);
const typeRules = String(fc.fileTypes || '')
.split(',')
.map((t: string) => t.trim().toLowerCase().replace(/^\./, ''))
.filter(Boolean);
if (typeRules.length > 0) {
const ext = (raw.name?.split('.').pop() || '').toLowerCase();
if (!typeRules.includes(ext)) {
ElMessage.warning(`文件格式不符合要求,仅支持:${typeRules.join(', ')}`);
return;
}
}
const maxFileSize = Number(fc.maxFileSize);
if (!Number.isNaN(maxFileSize) && maxFileSize > 0) {
const sizeMB = raw.size / 1024 / 1024;
if (sizeMB > maxFileSize) {
ElMessage.warning(`文件大小超限,最大 ${maxFileSize}MB`);
return;
}
}
const maxFileCount = Number(fc.maxFileCount);
if (!Number.isNaN(maxFileCount) && maxFileCount > 0) {
const currentCount = fieldFiles[key]?.length || 0;
if (currentCount >= maxFileCount) {
ElMessage.warning(`文件数量超限,最多 ${maxFileCount}`);
return;
}
}
uploadingFields[key] = true;
try {
const uploadRes = await uploadFile(raw, { timeout: 0 });
if (!uploadRes?.data?.fileURL) throw new Error('上传失败:未返回文件URL');
const fileUrl = uploadRes.data.fileAddressPrefix
? `${uploadRes.data.fileAddressPrefix}${uploadRes.data.fileURL}`
: uploadRes.data.fileURL;
if (!fieldFiles[key]) fieldFiles[key] = [];
// fileNameOSS value
fieldFiles[key].push({ name: uploadRes.data.fileName || raw.name, url: fileUrl });
if (field.type === 'upload') {
formValues[key] = fileUrl;
formFileNames[key] = fieldFiles[key][0]?.name;
} else {
formValues[key] = fieldFiles[key].map((f: any) => f.url);
formFileNames[key] = fieldFiles[key].map((f: any) => f.name);
}
} catch (error: any) {
ElMessage.error(error?.message || '文件上传失败');
} finally {
uploadingFields[key] = false;
}
};
const removeFieldFile = (node: any, field: any, fileIdx: number) => {
const key = getFieldKey(node, field);
if (!fieldFiles[key]) return;
fieldFiles[key].splice(fileIdx, 1);
if (field.type === 'upload') {
formValues[key] = '';
formFileNames[key] = '';
} else {
formValues[key] = fieldFiles[key].map((f) => f.url);
formFileNames[key] = fieldFiles[key].map((f) => f.name);
}
};
const currentWorkflowHasPatchLayout = computed(() => {
const nodes = props.detail?.nodeInputParams || [];
return nodes.some((node: any) => node.patchLayout === true);
});
const hasFormConfig = (node: any): boolean => {
// runFormFields model + form
return String(node?.nodeCode || '').toLowerCase() === '__start__' && collectHomeFormFields(node).length > 0;
};
const hasFormFields = computed(() => {
if (!props.detail?.nodeInputParams) return false;
return props.detail.nodeInputParams.some((node: any) => hasFormConfig(node));
});
const validateFormFields = (): boolean => {
//
if (!chargeMode.value) {
ElMessage.warning('请选择计费方式');
return false;
}
if (!props.detail?.nodeInputParams) return true;
for (const node of props.detail.nodeInputParams as any[]) {
if (String(node?.nodeCode || '').toLowerCase() !== '__start__') continue;
const fields = getVisibleFields(node);
for (const field of fields) {
if (!field.required) continue;
//
if (field.valueSource) continue;
const key = getFieldKey(node, field);
const value = formValues[key];
if (value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)) {
ElMessage.warning(`请填写必填项:${field.label}`);
return false;
}
}
}
return true;
};
// + +
watch(
() => props.detail,
(detail) => {
Object.keys(formValues).forEach((key) => delete formValues[key]);
Object.keys(fieldFiles).forEach((key) => delete fieldFiles[key]);
Object.keys(formFileNames).forEach((key) => delete formFileNames[key]);
Object.keys(uploadingFields).forEach((key) => delete uploadingFields[key]);
// handleWorkflowSelect chargeMode
// flowContent.chargeMode
chargeMode.value = detail?.flowContent?.chargeMode || '';
loadChargeModes();
//
// templates patchLayout === true node.templates
// detail.nodeInputParams nodesextension / detail / flowContent
const patchNodes = Array.isArray(detail?.nodeInputParams)
? (detail.nodeInputParams as any[]).filter((n: any) => n.patchLayout === true && Array.isArray(n.templates))
: [];
const nodeTemplates = patchNodes[0]?.templates;
const ext = (detail as any)?.extension;
const restoredTemplates =
nodeTemplates || ext?.templates || (detail as any)?.templates || detail?.flowContent?.templates || [];
templates.value = Array.isArray(restoredTemplates) ? restoredTemplates : [];
if (!detail?.nodeInputParams) return;
const nodes = detail.nodeInputParams as any[];
// 1) model modelRequestParams runtimeShowform outputConfig formConfig
nodes.forEach((node) => {
collectHomeFormFields(node).forEach((field) => {
const key = getFieldKey(node, field);
const hasValue = field.value !== undefined && field.value !== null && field.value !== '';
if (field.type === 'number' || field.type === 'inputNumber') {
formValues[key] = hasValue ? Number(field.value) : (field.default ?? null);
} else if (field.type === 'switch') {
formValues[key] = hasValue ? Boolean(field.value) : (field.default ?? false);
} else if (field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload') {
if (field.type === 'fileUpload') {
formValues[key] = hasValue
? Array.isArray(field.value)
? field.value
: field.value
? [field.value]
: []
: Array.isArray(field.default)
? field.default
: field.default
? [field.default]
: [];
} else {
formValues[key] = hasValue ? field.value : (field.default ?? (field.type === 'upload' ? '' : []));
}
} else {
formValues[key] = hasValue ? field.value : (field.default ?? '');
}
});
});
// 2)
nodes.forEach((node) => {
collectHomeFormFields(node).forEach((field) => {
if (!isFileField(field)) return;
const key = getFieldKey(node, field);
const rawValue = formValues[key];
const urls = Array.isArray(rawValue) ? rawValue : rawValue ? [rawValue] : [];
if (urls.length === 0) return;
// url
const rawFn = (field as any).fileName;
fieldFiles[key] = urls.map((url: string, i: number) => ({
name:
(Array.isArray(rawFn) ? rawFn[i] : i === 0 ? rawFn : undefined) ||
String(url || '').split('/').pop() ||
'file-' + Math.random().toString(36).slice(2, 8),
url,
}));
formFileNames[key] =
field.type === 'upload' && fieldFiles[key].length === 1
? fieldFiles[key][0].name
: fieldFiles[key].map((f) => f.name);
});
});
},
{ immediate: true }
);
const handleSubmit = () => {
// editable
if ((props.readonly && !props.editable) || props.submitting) return;
if (!validateFormFields()) return;
emit('submit', {
formValues: JSON.parse(JSON.stringify(formValues)),
formFileNames: JSON.parse(JSON.stringify(formFileNames)),
templates: JSON.parse(JSON.stringify(templates.value || [])),
chargeMode: chargeMode.value,
});
};
// InputBar executeSignal null
//
watch(
() => props.executeSignal,
(sig) => {
if (!sig) return;
handleSubmit();
}
);
</script>
<style scoped lang="scss">
/* ===== 表单卡片(对话消息流内) ===== */
.workflow-form-card {
background: #fff;
border: 1px solid #e5e8ee;
border-radius: 14px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
width: min(640px, calc(100% - 4px));
max-width: 100%;
transition: opacity 0.2s;
&.readonly {
box-shadow: none;
}
&.submitting {
opacity: 0.9;
}
}
.workflow-form-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid #f1f5f9;
flex-shrink: 0;
cursor: pointer;
user-select: none;
transition: background 0.15s;
&:hover { background: #f8fafc; }
h3 { margin: 0; font-size: 14px; font-weight: 600; color: #0f172a; }
.workflow-form-sub { font-size: 12px; color: #94a3b8; margin-top: 2px; }
}
.header-left { min-width: 0; }
.header-right {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
/* 折叠切换按钮:文字 + 箭头,明确提示可展开/收起参数区 */
.collapse-toggle {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 3px 9px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
color: #64748b;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: all 0.15s;
&:hover {
border-color: #93c5fd;
color: #2563eb;
background: #eff6ff;
}
.collapse-arrow {
font-size: 12px;
line-height: 1;
transition: transform 0.2s;
&.collapsed { transform: rotate(-180deg); }
}
}
/* 节点执行过程区(豆包式步骤流) */
.node-progress-list {
background: #f8fafc;
border-bottom: 1px solid #f1f5f9;
padding: 10px 20px;
display: flex;
flex-direction: column;
gap: 8px;
}
.node-step {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
.node-step-icon {
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.node-step-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #1e293b;
}
.node-step-status {
flex-shrink: 0;
font-size: 12px;
color: #64748b;
}
&.is-running .node-step-status { color: #2563eb; }
&.is-done .node-step-status { color: #22c55e; }
&.is-failed .node-step-status { color: #ef4444; }
}
.node-spinner {
width: 14px;
height: 14px;
border: 2px solid #dbeafe;
border-top-color: #2563eb;
border-radius: 50%;
animation: node-spin 0.8s linear infinite;
}
.node-icon-done { color: #22c55e; font-size: 15px; }
.node-icon-failed { color: #ef4444; font-size: 15px; }
.node-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #cbd5e1;
}
@keyframes node-spin {
to { transform: rotate(360deg); }
}
.workflow-form-scroll {
max-height: 55vh;
overflow-y: auto;
padding: 20px;
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
&::-webkit-scrollbar-track { background: transparent; }
}
:deep(.el-form-item) {
margin-bottom: 14px;
&:last-child { margin-bottom: 0; }
.el-form-item__label { font-size: 13px; font-weight: 500; color: #334155; padding-bottom: 4px; line-height: 1.5; }
.el-form-item__label::before { color: #ef4444; margin-right: 4px; }
.el-input-number { width: 100%; }
.el-select { width: 100%; }
.el-textarea__inner { font-size: 13px; }
}
/* 引用字段只读展示 */
.field-source-readonly {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 12px;
color: #64748b;
.source-tag { flex-shrink: 0; }
.source-text { line-height: 1.5; }
}
.uploaded-files-list { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; }
.uploaded-file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
.file-name {
flex: 1;
font-size: 12px;
color: #475569;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.patch-template-section {
margin-top: 16px;
:deep(.patch-template-editor) {
> .el-divider { margin-top: 4px; margin-bottom: 12px; border-top-color: #e2e8f0;
.el-divider__text { font-weight: 600; font-size: 13px; color: #475569; background: transparent; padding-left: 0; }
}
.template-card { border: 1px solid #e2e8f0; border-radius: 10px; margin-bottom: 10px; }
}
}
/* 底部操作区 */
.workflow-form-footer {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
border-top: 1px solid #f1f5f9;
background: #fafbfc;
flex-shrink: 0;
.form-error-text {
flex: 1;
font-size: 12px;
color: #ef4444;
line-height: 1.5;
white-space: pre-wrap; /* 失败详情可能含换行(error 字段多行原因),允许换行展示 */
word-break: break-word;
}
.form-edit-tip {
flex: 1;
font-size: 12px;
color: #94a3b8;
}
}
/* 执行进度:spinner + 文本,节点事件推进 */
.executing-progress {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
.exec-spinner {
width: 14px;
height: 14px;
flex-shrink: 0;
border: 2px solid #bfdbfe;
border-top-color: #2563eb;
border-radius: 50%;
animation: exec-spin 0.8s linear infinite;
}
.exec-text {
font-size: 13px;
color: #1e293b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@keyframes exec-spin {
to { transform: rotate(360deg); }
}
.w100 { width: 100%; }
</style>
@@ -0,0 +1,263 @@
<template>
<div class="workflow-output-card">
<template v-for="(out, i) in outputs" :key="i">
<!-- 图片常驻内嵌缩略图点击放大看大图走父级预览弹窗 -->
<div v-if="out.type === 'image'" class="output-media is-image" @click="emit('preview', out)">
<img :src="resolveUrl ? resolveUrl(out.url) : out.url" :alt="out.name" loading="lazy" />
<span class="media-name">{{ out.name }}</span>
<span class="media-actions" @click.stop>
<button type="button" class="output-action" title="下载" @click="emit('download', out)">
<el-icon><Download /></el-icon>
</button>
<button type="button" class="output-action is-danger" title="删除" @click="emit('delete', out)">
<el-icon><Delete /></el-icon>
</button>
</span>
</div>
<!-- 视频内嵌播放器底部不叠加文件名避免遮挡原生控制条 -->
<div v-else-if="out.type === 'video'" class="output-media is-video">
<video :src="resolveUrl ? resolveUrl(out.url) : out.url" controls preload="metadata" :title="out.name"></video>
<span class="media-actions" @click.stop>
<button type="button" class="output-action" title="下载" @click="emit('download', out)">
<el-icon><Download /></el-icon>
</button>
<button type="button" class="output-action is-danger" title="删除" @click="emit('delete', out)">
<el-icon><Delete /></el-icon>
</button>
</span>
</div>
<!-- 音频内嵌播放器底部不叠加文件名避免遮挡原生控制条 -->
<div v-else-if="out.type === 'audio'" class="output-media is-audio">
<audio :src="resolveUrl ? resolveUrl(out.url) : out.url" controls preload="metadata" :title="out.name"></audio>
<span class="media-actions" @click.stop>
<button type="button" class="output-action" title="下载" @click="emit('download', out)">
<el-icon><Download /></el-icon>
</button>
<button type="button" class="output-action is-danger" title="删除" @click="emit('delete', out)">
<el-icon><Delete /></el-icon>
</button>
</span>
</div>
<!-- 文本 / 其他类型文件行 + 预览/下载/删除 -->
<div v-else class="output-item">
<el-icon class="output-icon" :class="`is-${out.type || 'file'}`">
<component :is="getIcon(out.type)" />
</el-icon>
<span class="output-name" :title="out.name">{{ out.name }}</span>
<span class="output-actions">
<button v-if="out.type !== 'text'" type="button" class="output-action" title="预览" @click="emit('preview', out)">
<el-icon><View /></el-icon>
</button>
<button type="button" class="output-action" title="下载" @click="emit('download', out)">
<el-icon><Download /></el-icon>
</button>
<button type="button" class="output-action is-danger" title="删除" @click="emit('delete', out)">
<el-icon><Delete /></el-icon>
</button>
</span>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { Picture, VideoPlay, Headset, Document, View, Download, Delete } from '@element-plus/icons-vue';
// url /name type image/video/audio/text/filebackendId id
interface WorkflowOutput {
url: string;
name: string;
type: string;
backendId: string;
}
interface Props {
outputs: WorkflowOutput[];
// URL buildAssetUrl url
resolveUrl?: (url: string) => string;
}
interface Emits {
(e: 'preview', output: WorkflowOutput): void;
(e: 'download', output: WorkflowOutput): void;
(e: 'delete', output: WorkflowOutput): void;
}
defineProps<Props>();
const emit = defineEmits<Emits>();
//
const getIcon = (type: string) => {
const t = String(type || '').toLowerCase();
if (t === 'image') return Picture;
if (t === 'video') return VideoPlay;
if (t === 'audio') return Headset;
return Document;
};
</script>
<style scoped lang="scss">
.workflow-output-card {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 10px;
}
// //hover /
.output-media {
position: relative;
overflow: hidden;
border-radius: 10px;
background: #0f172a;
img,
video {
display: block;
width: 100%;
max-height: 320px;
object-fit: contain;
background: #0f172a;
}
audio {
display: block;
width: 100%;
background: #0f172a;
padding: 10px;
}
.media-name {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 4px 8px;
font-size: 12px;
color: #e2e8f0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.6));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.media-actions {
position: absolute;
top: 6px;
right: 6px;
display: flex;
gap: 2px;
padding: 2px;
border-radius: 8px;
background: rgba(15, 23, 42, 0.75);
opacity: 0;
transition: opacity 0.15s;
}
&:hover {
.media-actions {
opacity: 1;
}
}
}
// +
.output-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: #f8fafc;
border: 1px solid #f1f5f9;
border-radius: 10px;
transition: background 0.15s;
&:hover {
background: #eef2f7;
.output-actions {
opacity: 1;
}
}
}
.output-icon {
font-size: 16px;
flex-shrink: 0;
&.is-image {
color: #2563eb;
}
&.is-video {
color: #7c3aed;
}
&.is-audio {
color: #d97706;
}
&.is-file,
&.is-text {
color: #64748b;
}
}
.output-name {
flex: 1;
font-size: 13px;
color: #334155;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.output-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
}
.output-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: 6px;
background: transparent;
color: #94a3b8;
cursor: pointer;
transition: background 0.15s, color 0.15s;
&:hover {
background: #e2e8f0;
color: #475569;
}
&.is-danger:hover {
background: #fef2f2;
color: #dc2626;
}
.el-icon {
font-size: 13px;
}
}
// 使
.output-media {
.output-action {
color: #cbd5e1;
&:hover {
background: rgba(255, 255, 255, 0.18);
color: #f8fafc;
}
&.is-danger:hover {
background: rgba(220, 38, 38, 0.35);
color: #fecaca;
}
}
}
</style>
+1751 -311
View File
File diff suppressed because it is too large Load Diff
+226
View File
@@ -0,0 +1,226 @@
// ===== 首页执行工作流管理 DSL 的解析与写回工具 =====
// 独立实现,不依赖 settings/workflow 或 settings/creation 的旧结构。
// 字段路径文法与工作流管理 modelParamUtils 一致:key / .attrs. / .enumValues[i]
export interface HomeFormField {
path: string;
label: string;
type: string;
fieldType: string;
required: boolean;
value?: any;
default?: any;
options?: any[];
fieldConstraint?: any;
// 引用上游节点输出({ nodeId, field });有则首页只读展示引用,值由上游节点提供
valueSource?: any;
// 来源节点 id(未配置 valueSource 时默认引用开始节点,用于引用展示)
nodeId?: string;
// 上传字段的文件名(单文件为字符串,多文件为数组),执行时随 value 一并写回传给后端
fileName?: string | string[];
// 旧兼容:creation 时代 http body 中 showInForm 的子字段
__isHttpBodyChild?: boolean;
bodyKey?: string;
}
export function deepClone<T>(val: T): T {
return JSON.parse(JSON.stringify(val ?? null)) as T;
}
// 开始节点运行字段(runFormFields)→ MainContent 控件 type
function mapRunFieldType(f: any): string {
const ft = String(f?.fieldType || '');
const t = String(f?.type || 'string');
if (ft === 'number' || t === 'number') return 'number';
if (ft === 'boolean' || t === 'boolean' || ft === 'switch') return 'switch';
if (ft === 'select' && Array.isArray(f?.options)) return 'select';
if (ft === 'textarea') return 'textarea';
if (ft === 'uploadMultiple' || t === 'uploadMultiple' || f?.multiple) return 'uploadMultiple';
if (ft === 'upload' || ft === 'file' || ft === 'fileUpload' || t === 'upload' || t === 'file') return 'upload';
return 'input';
}
// 选项归一化:兼容 {label,value} / {key,value} / value.options 嵌套三种来源
function normalizeOptions(def: any): any[] | undefined {
let raw: any[] | undefined;
if (Array.isArray(def.options) && def.options.length > 0) raw = def.options;
else if (def.value && typeof def.value === 'object' && !Array.isArray(def.value) && Array.isArray(def.value.options))
raw = def.value.options;
else if (Array.isArray(def.enumValues) && def.enumValues.length > 0) raw = def.enumValues;
if (!raw) return undefined;
return raw.map((o: any) => {
if (o && typeof o === 'object' && !Array.isArray(o)) {
const val = o.value !== undefined ? o.value : o.key;
const label = o.label !== undefined ? o.label : o.key;
return { label: label === undefined ? String(val) : label, value: val };
}
return { label: String(o), value: o };
});
}
// 开始节点(__start__):读取 outputConfig 中保存的 runFormFields(工作流管理汇总的
// model 勾选字段 + form 自定义字段),转首页扁平表单字段。条目已有
// field/label/type/fieldType/required/value/defaultValue/fieldConstraint/valueSource/multiple
// 直接归一化映射,无需再走 model/form 各自结构。
function collectStartNodeFields(node: any): HomeFormField[] {
const out = Array.isArray(node?.outputConfig) ? node.outputConfig : [];
return out
.filter((o: any) => o && typeof o === 'object' && (o.field !== undefined || o.path !== undefined))
.map((o: any) => {
const field = o.field !== undefined ? o.field : o.path;
return {
path: field,
label: o.label || String(field),
type: mapRunFieldType(o),
fieldType: o.fieldType || o.type || 'string',
required: Boolean(o.required),
value: o.value !== undefined ? o.value : o.defaultValue,
default: o.defaultValue,
options: normalizeOptions(o),
fieldConstraint: o.fieldConstraint && typeof o.fieldConstraint === 'object' ? o.fieldConstraint : undefined,
valueSource: o.valueSource && typeof o.valueSource === 'object' ? o.valueSource : undefined,
nodeId: o.nodeId,
fileName: o.fileName,
};
});
}
// 主入口:DSL 节点 → 首页扁平表单字段。开始节点为唯一表单源
// runFormFields 汇总了各 model 节点勾选字段 + form 节点自定义字段),其余节点无运行时字段。
export function collectHomeFormFields(node: any): HomeFormField[] {
if (String(node?.nodeCode || '').toLowerCase() !== '__start__') return [];
return collectStartNodeFields(node);
}
// 上传类型空值:未选文件(空字符串 / 空数组 / null)
const isEmptyUploadValue = (val: any): boolean => val === '' || val === null || (Array.isArray(val) && val.length === 0);
// 首页表单值写回开始节点(唯一表单源)outputConfig 对应条目 value
// 1. 引用其他节点输出(valueSource)的字段只读展示,值由上游节点运行时提供,
// 清空保存时的值快照(避免后端误用过期值)
// 2. 上传类型字段未选文件 → 从开始节点运行字段移除,不向后端提交空字段
export function applyHomeFormValues(nodes: any[], formValues: Record<string, any>, formFileNames?: Record<string, any>): void {
if (!Array.isArray(nodes)) return;
const startNode = nodes.find((n: any) => String(n?.nodeCode || '').toLowerCase() === '__start__');
if (!startNode || !Array.isArray(startNode.outputConfig)) return;
for (let i = startNode.outputConfig.length - 1; i >= 0; i--) {
const f = startNode.outputConfig[i];
if (!f || typeof f !== 'object') continue;
if (f.valueSource && typeof f.valueSource === 'object') {
// 引用其他节点输出的字段:值由上游节点运行时提供,清空保存时的快照,避免后端误用过期值
if ('value' in f) delete f.value;
continue;
}
const field = f.field !== undefined ? f.field : f.path;
if (field === undefined || field === '') continue;
const key = `${startNode.id || startNode.nodeCode}|${field}`;
const val = formValues[key];
// 仅跳过未初始化的 key;null(如数字清空)也要写回
if (val === undefined) continue;
// 上传类型字段未选文件 → 不提交,从开始节点运行字段移除
const ft = String(f.fieldType || f.type || '');
const isUpload = ft === 'upload' || ft === 'uploadMultiple' || ft === 'fileUpload' || ft === 'file';
if (isUpload && isEmptyUploadValue(val)) {
startNode.outputConfig.splice(i, 1);
continue;
}
f.value = val;
// 上传字段附带文件名:与 value 类型对齐(单文件字符串/多文件数组),随开始节点一并传给后端
if (formFileNames && formFileNames[key] !== undefined) {
f.fileName = formFileNames[key];
}
}
}
// 模板完整性校验:返回缺模型等缺失项,供补全弹窗提示
export function checkTemplateMissing(flowContent: any): { nodeId: string; nodeName: string; reason: string }[] {
const nodes = Array.isArray(flowContent?.nodes) ? flowContent.nodes : [];
const missing: { nodeId: string; nodeName: string; reason: string }[] = [];
for (const n of nodes) {
if (String(n?.nodeCode || '').toLowerCase() === 'model' && !n?.modelConfig?.modelId) {
missing.push({ nodeId: n?.id || '', nodeName: n?.name || n?.nodeCode || '模型节点', reason: '未选择模型' });
}
}
return missing;
}
// ===== 执行产出提取 =====
// 会话内展示工作流执行产出(文件卡片):来自 getExecutionDetail 返回的 WorkflowItem
// 合并 fileUrls / outputParams 各 value / resultUrl,去重后归一为 { url, name, type, backendId }。
// type 用于预览模式判定(image/video/audio/text/file),backendId 为执行记录 id(删除产出用)。
export interface WorkflowOutput {
url: string;
name: string;
type: string;
backendId: string;
}
// 按文件扩展名推断预览类型
function guessFileType(url: string): string {
const clean = String(url).split('?')[0] || '';
const ext = (clean.split('.').pop() || '').toLowerCase();
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'ico'].includes(ext)) return 'image';
if (['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(ext)) return 'video';
if (['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac'].includes(ext)) return 'audio';
if (['txt', 'md', 'json', 'log', 'csv'].includes(ext)) return 'text';
return 'file';
}
export function extractWorkflowOutputs(data: any, backendId?: string): WorkflowOutput[] {
if (!data) return [];
const seen = new Set<string>();
const outputs: WorkflowOutput[] = [];
const push = (rawUrl: any) => {
if (typeof rawUrl !== 'string') return;
const url = rawUrl.trim();
if (!url || seen.has(url)) return;
seen.add(url);
const name = String(url).split('?')[0].split('/').pop() || '产出文件';
outputs.push({
url,
name,
type: guessFileType(url),
backendId: backendId || data.id || '',
});
};
if (Array.isArray(data.fileUrls)) data.fileUrls.forEach(push);
if (Array.isArray(data.outputParams)) {
data.outputParams.forEach((o: any) => {
if (!o || typeof o !== 'object') return;
Object.values(o).forEach((v) => push(v));
});
}
if (data.resultUrl) push(data.resultUrl);
return outputs;
}
// 从事件携带的文件 URL 数组(flow_complete 的 data.resultFileUrls)直接构建产出列表:
// 不依赖执行详情的异步查询,type 同样按扩展名推断,去重后归一为 { url, name, type, backendId }。
// 用于完成事件即时渲染产出卡片,删除时 backendId 为空则跳过删除接口(安全降级)。
export function buildOutputsFromUrls(urls: any, backendId?: string): WorkflowOutput[] {
// 兼容两种入参:字符串(逗号拼接多 URL)或数组(元素本身可能是逗号拼接串);
// 合法 URL 不含裸逗号,统一按逗号拆分后逐条归一化,避免把整串误当单个产出
const arr: string[] = typeof urls === 'string' ? [urls] : Array.isArray(urls) ? urls : [];
const seen = new Set<string>();
const outputs: WorkflowOutput[] = [];
for (const u of arr) {
if (typeof u !== 'string') continue;
const parts = u
.split(',')
.map((s) => s.trim())
.filter(Boolean);
for (const url of parts) {
if (seen.has(url)) continue;
seen.add(url);
const name = String(url).split('?')[0].split('/').pop() || '产出文件';
outputs.push({
url,
name,
type: guessFileType(url),
backendId: backendId || '',
});
}
}
return outputs;
}
+22
View File
@@ -0,0 +1,22 @@
// ===== 首页回答区 Markdown 渲染(DeepSeek 风格)=====
// marked 负责 Markdown 语法,自定义 renderer.code 用 highlight.js 做代码语法高亮,
// 并为代码块包一层带"复制"按钮的结构;DOMPurify 消毒避免 v-html 注入 XSS。
import { marked } from 'marked';
import hljs from 'highlight.js/lib/common';
import DOMPurify from 'dompurify';
const renderer = new marked.Renderer();
renderer.code = ({ text, lang }) => {
const language = lang && hljs.getLanguage(lang) ? lang : 'plaintext';
const highlighted = hljs.highlight(text, { language }).value;
return `<div class="code-wrapper"><button type="button" class="code-copy-btn" data-copy>复制</button><pre><code class="hljs language-${language}">${highlighted}</code></pre></div>`;
};
marked.use({ renderer });
// gfm:GitHub 风格(表格、任务列表等);breaks:单换行转 <br>,贴近对话输出
marked.setOptions({ gfm: true, breaks: true });
/** 将 Markdown 渲染为安全 HTML(含代码高亮与复制按钮,已消毒) */
export function renderMarkdown(content: string): string {
return DOMPurify.sanitize(marked.parse(content) as string);
}
+158
View File
@@ -0,0 +1,158 @@
// ===== 首页 WebSocket 会话级长连接(/ai-agent/session/wsExecute=====
// 约定:握手 URL query 只带 sessionId/token+可选 modelId/flowId),
// 建连后不发任何帧;每轮提问/工作流执行通过 send* 系列函数发送启动帧,
// 一轮推完由服务端推送完成事件(answer/error)判定结束,连接保持不关闭。
// 服务端推送消息格式见 wsMessage.ts,本工具只做连接/发送,消息解析交给调用方。
import { Session } from '/@/utils/storage';
// VITE_API_URLhttp(s)://host)→ ws(s)://host
const getWsBase = (): string => {
const raw = import.meta.env.VITE_API_URL || '';
return raw.replace(/^http/, 'ws');
};
export interface SessionSocket {
ws: WebSocket;
// onopen 时 resolve(ws);打开前 close/error 时 reject,调用方据此判定建连失败
ready: Promise<WebSocket>;
}
export interface ConnectSessionSocketOptions {
// 可选:已认领会话(后端正式号)传回复用;未传(空串)则后端自动创建,从首帧 ack 认领
sessionId?: string;
modelId?: string | number;
flowId?: string | number;
onMessage: (raw: any) => void;
// 未传 sessionId 时,后端创建会话后首帧 ack 返回的正式会话号(UUID),调用方据此认领本地会话
onClaim?: (sessionId: string) => void;
onClose: (ev: CloseEvent) => void;
onError: (ev: Event) => void;
}
/**
* WebSocket
* query sessionId/token+ modelId/flowId
* sendAgentStart / sendWorkflowStart
* SessionSocketURL null
*/
export function connectSessionSocket(opts: ConnectSessionSocketOptions): SessionSocket | null {
const base = getWsBase();
const params: Record<string, string> = {};
// 已认领会话传号复用;未传号则由后端自动创建会话,首帧 ack 返回正式号
const sidForConnect = opts.sessionId;
if (sidForConnect) params.sessionId = sidForConnect;
if (opts.flowId != null) params.flowId = String(opts.flowId);
if (opts.modelId != null) params.modelId = String(opts.modelId);
const token = Session.get('token');
if (token) params.token = String(token);
const query = Object.keys(params)
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
.join('&');
const url = `${base}/ai-agent/session/wsExecute?${query}`;
let ws: WebSocket;
try {
ws = new WebSocket(url);
} catch (e) {
console.error('[wsExecute] WebSocket 创建失败', e);
return null;
}
let readyResolve!: (w: WebSocket) => void;
let readyReject!: (e: unknown) => void;
const ready = new Promise<WebSocket>((resolve, reject) => {
readyResolve = resolve;
readyReject = reject;
});
// 未传会话号:等后端首帧 ackdata.sessionId)认领后才算就绪,避免业务帧先于会话号发出
const wantsClaim = !opts.sessionId;
let claimed = false;
// 打开前是否已就绪:用于区分「未连上就关闭」与「正常关闭」
let opened = false;
// 兜底:ack 迟迟未到(异常)时超时放行,降级用原本地 id 继续,避免永久卡在建连
let claimTimer: number | null = null;
ws.onopen = () => {
// 长连接:握手成功仅标记就绪,不发送任何启动帧
opened = true;
if (!wantsClaim) {
readyResolve(ws);
} else {
claimTimer = window.setTimeout(() => {
claimed = true;
readyResolve(ws);
}, 5000);
}
};
ws.onmessage = (ev) => {
// 首次建连:首帧 ack 携带后端正式会话号 → 认领后放行业务帧
if (wantsClaim && !claimed) {
const ackId = parseAckSessionId(ev.data);
if (ackId) {
claimed = true;
if (claimTimer != null) window.clearTimeout(claimTimer);
opts.onClaim?.(ackId);
readyResolve(ws);
return; // ack 帧仅用于认领,不交给业务 handler
}
}
opts.onMessage(ev.data);
};
ws.onclose = (ev) => {
if (claimTimer != null) window.clearTimeout(claimTimer);
if (!opened) readyReject(ev);
opts.onClose(ev);
};
ws.onerror = (ev) => opts.onError(ev);
return { ws, ready };
}
// 解析建连 ack 帧:{ type:'ack', data:{ sessionId } } → 返回 sessionId;非 ack / 无号返回空串
function parseAckSessionId(raw: any): string {
let data: any = raw;
if (typeof raw === 'string') {
try {
data = JSON.parse(raw);
} catch {
return '';
}
}
if (!data || typeof data !== 'object') return '';
if (data.type !== 'ack') return '';
const sid = data?.data?.sessionId;
return typeof sid === 'string' && sid ? sid : '';
}
// ===== 启动帧 =====
/** 普通对话:发送提问(type=agent),后端据此开始推流 */
export function sendAgentStart(ws: WebSocket, p: { modelId?: string | number; question: string }): void {
ws.send(
JSON.stringify({
type: 'agent',
payload: { modelId: p.modelId != null ? String(p.modelId) : '', question: p.question || '' },
})
);
}
/** 工作流执行:发送 { type, payload:{ flowId, flowContent } },对齐后端 socket 传参结构 */
export function sendWorkflowStart(ws: WebSocket, p: { flowId?: string | number; flowContent: any }): void {
ws.send(
JSON.stringify({
type: 'workflow',
payload: {
flowId: p.flowId != null ? String(p.flowId) : '',
flowContent: p.flowContent,
},
})
);
}
/** 取消当前轮(不关闭连接,长连接保持复用) */
export function sendCancel(ws: WebSocket, isWorkflow: boolean): void {
ws.send(JSON.stringify({ type: isWorkflow ? 'workflow_cancel' : 'agent_cancel' }));
}
+171
View File
@@ -0,0 +1,171 @@
// ===== 首页 WebSocket 流式消息解析工具 =====
// 服务端推送统一为 JSON 文本帧(后端 ReAct 事件协议):
// {"type":"model_call","message":"模型思考","data":{"step":1,"maxStep":15}} 模型思考(step 开始)
// {"type":"tool_call","message":"调用工具","data":{"tool":"web_search","arguments":{}}} 模型请求调用工具
// {"type":"tool_result","message":"工具返回","data":{"result":"..."}} 工具返回结果
// {"type":"reasoning_chunk","message":"思考中","data":{"delta":"你"}} 思考内容增量(逐 chunk)
// {"type":"answer_chunk","message":"回答中","data":{"delta":"你"}} 回答内容增量(逐 chunk)
// {"type":"answer","message":"作答完成","data":{"answer":"..."}} 最终回答
// {"type":"node_start","message":"开始执行(2/4): xxx","data":{"nodeCount":4,"nodeId":"..","nodeIndex":2,"nodeName":".."}} 工作流节点开始(进度推进)
// {"type":"node_complete","message":"执行完成(2/4): xxx","data":同上} 工作流节点完成(进度推进)
// {"type":"error","message":"...","error":"节点失败详情"} 执行失败(error 字段含详细原因)
// 完成/失败判定均以 type 为准,不再用正则猜测文本。
export interface WsStreamMessage {
type: string;
message?: string;
data?: any;
payload?: any;
// 失败详情:error 事件携带的详细原因(含节点名与具体失败信息),比 message 更具体
error?: string;
}
/** 后端 ReAct WebSocket 事件类型常量 */
export const WsEventType = {
ModelCall: 'model_call', // 模型思考(step 开始)
ToolCall: 'tool_call', // 模型请求调用工具
ToolResult: 'tool_result', // 工具返回结果
Answer: 'answer', // 最终回答
AnswerChunk: 'answer_chunk', // 回答内容增量(逐 chunk
RoundStart: 'round_start', // 本轮问答开始(后端收到提问后第一帧,data.recordId 为本次问答记录 id
ReasoningChunk: 'reasoning_chunk', // 思考内容增量(逐 chunk
NodeStart: 'node_start', // 工作流节点开始(进度推进)
NodeComplete: 'node_complete', // 工作流节点完成(进度推进)
FlowComplete: 'flow_complete', // 工作流执行完成(后端完成信号,日志末尾事件)
Error: 'error', // 出错
} as const;
/**
*
* JSON type null
*/
export function parseWsMessage(raw: any): WsStreamMessage | null {
let data: any = raw;
if (typeof raw === 'string') {
try {
data = JSON.parse(raw);
} catch {
return null;
}
}
if (!data || typeof data !== 'object' || typeof data.type !== 'string') return null;
return data;
}
/** answer_chunk / reasoning_chunk → data.delta 流式增量 */
export function getDelta(msg: WsStreamMessage): string {
return typeof msg?.data?.delta === 'string' ? msg.data.delta : '';
}
/** answer → data.answer 最终答案 */
export function getAnswer(msg: WsStreamMessage): string {
return typeof msg?.data?.answer === 'string' ? msg.data.answer : '';
}
/** round_start → data.recordId 本次问答记录 id(19 位雪花大整数,契约要求字符串形式,兼容数字类型防御性转 string) */
export function getRecordId(msg: WsStreamMessage): string {
const rid = msg?.data?.recordId;
if (typeof rid === 'string' || typeof rid === 'number') return String(rid);
return '';
}
/** model_call → { step, maxStep },缺失时 maxStep 为 0 */
export function getStepInfo(msg: WsStreamMessage): { step: number; maxStep: number } | null {
const step = msg?.data?.step;
if (typeof step !== 'number') return null;
const maxStep = msg?.data?.maxStep;
return { step, maxStep: typeof maxStep === 'number' ? maxStep : 0 };
}
/** 工作流节点进度:node_start / node_complete → { current, total, nodeName },非节点事件返回 null */
export function getNodeProgress(msg: WsStreamMessage): { current: number; total: number; nodeName: string } | null {
const t = msg?.type;
if (t !== WsEventType.NodeStart && t !== WsEventType.NodeComplete) return null;
const d = msg?.data;
const nodeIndex = d?.nodeIndex;
const nodeCount = d?.nodeCount;
if (typeof nodeIndex !== 'number' || typeof nodeCount !== 'number') return null;
return {
current: nodeIndex,
total: nodeCount,
nodeName: typeof d?.nodeName === 'string' ? d.nodeName : '',
};
}
/** 工作流执行节点步骤(node_start/node_complete/error 事件驱动,渲染表单卡片执行过程区) */
export interface WorkflowNodeStep {
nodeId: string;
nodeName: string;
nodeIndex: number;
status: 'pending' | 'running' | 'done' | 'failed';
}
/** 工作流执行节点进度(挂在表单卡片消息 formProgress 上) */
export interface WorkflowNodeProgress {
nodes: WorkflowNodeStep[];
}
/** 工作流节点事件解析结果:node_start / node_complete → { nodeId, nodeName, nodeIndex, phase } */
export interface WorkflowNodeEvent {
nodeId: string;
nodeName: string;
nodeIndex: number;
phase: 'start' | 'complete';
}
/**
* node_start / node_complete
* nodeId/nodeName/nodeIndex/phase null
*/
export function getNodeEvent(msg: WsStreamMessage): WorkflowNodeEvent | null {
const t = msg?.type;
if (t !== WsEventType.NodeStart && t !== WsEventType.NodeComplete) return null;
const d = msg?.data;
const nodeIndex = d?.nodeIndex;
if (typeof nodeIndex !== 'number') return null;
return {
nodeId: typeof d?.nodeId === 'string' ? d.nodeId : '',
nodeName: typeof d?.nodeName === 'string' ? d.nodeName : '',
nodeIndex,
phase: t === WsEventType.NodeStart ? 'start' : 'complete',
};
}
/** tool_call → 工具名称(tool / toolName / name 依次尝试,缺失返回空串) */
export function getToolCallName(msg: WsStreamMessage): string {
const d = msg?.data;
if (!d || typeof d !== 'object') return '';
const name = d.tool ?? d.toolName ?? d.name;
return typeof name === 'string' ? name : '';
}
/** tool_result → 工具返回文本(result / output / content / message 依次尝试,对象则序列化) */
export function getToolResultText(msg: WsStreamMessage): string {
const d = msg?.data;
if (typeof d === 'string') return d;
if (!d || typeof d !== 'object') return '';
const raw = d.result ?? d.output ?? d.content ?? d.message;
if (typeof raw === 'string') return raw;
if (raw != null) {
try {
return JSON.stringify(raw);
} catch {
return '';
}
}
return '';
}
/** error → 错误文案(message 优先展示简洁业务提示;error 详细原因仅在 message 缺失时兜底) */
export function getErrorText(msg: WsStreamMessage): string {
// 只显示 message 的消息(用户确认),error 详细原因不再作为界面展示内容
if (typeof msg?.message === 'string' && msg.message) return msg.message;
if (typeof msg?.error === 'string' && msg.error) return msg.error;
const d = msg?.data;
if (typeof d === 'string') return d;
if (d && typeof d === 'object') {
const nested = d.message ?? d.error ?? d.msg;
if (typeof nested === 'string' && nested) return nested;
}
return '执行失败,请重试';
}
+1 -1
View File
@@ -87,7 +87,7 @@ export default defineComponent({
isShowPassword: false,
ruleForm: {
username: 'admin',
password: '123456',
password: 'Tongli686^*^',
verifyCode: '',
verifyKey: '',
},
+14 -6
View File
@@ -16,8 +16,8 @@
<!-- 右侧表单区域 -->
<div class="login-form-section">
<div class="form-wrapper">
<!-- 扫码/账号切换按钮 -->
<div class="scan-toggle" @click="state.isScan = !state.isScan">
<!-- 扫码/账号切换按钮暂时隐藏恢复时删除 v-if="false"-->
<div class="scan-toggle" v-if="false" @click="state.isScan = !state.isScan">
<!-- 电脑图标 (切换回账号登录) -->
<svg v-if="state.isScan" viewBox="0 0 1024 1024" width="24" height="24" fill="currentColor">
<path d="M128 192h768v448H128V192z m0-64c-35.3 0-64 28.7-64 64v448c0 35.3 28.7 64 64 64h768c35.3 0 64-28.7 64-64V192c0-35.3-28.7-64-64-64H128z m256 640h256v64H384v-64z"></path>
@@ -40,17 +40,20 @@
<el-tab-pane label="账号登录" name="account">
<Account />
</el-tab-pane>
<!-- 手机号登录 tab暂时隐藏恢复时取消注释下方三行
<el-tab-pane label="手机号登录" name="mobile">
<Mobile />
</el-tab-pane>
-->
</el-tabs>
</div>
</div>
<!-- 扫码登录 -->
<!-- 扫码登录暂时隐藏恢复时取消注释下方四行
<div v-else class="scan-container">
<Scan />
</div>
-->
</div>
</div>
</div>
@@ -70,8 +73,9 @@ import logoMini from '/@/assets/logo-mini.svg';
import loginIconTwo from '/@/assets/login-icon-two.svg';
import { NextLoading } from '/@/utils/loading';
import Account from './component/account.vue';
import Mobile from './component/mobile.vue';
import Scan from './component/scan.vue';
// /
// import Mobile from './component/mobile.vue';
// import Scan from './component/scan.vue';
//
interface LoginState {
@@ -81,7 +85,11 @@ interface LoginState {
export default defineComponent({
name: 'loginIndex',
components: { Account, Mobile, Scan },
components: {
Account,
// Mobile, //
// Scan, //
},
setup() {
const storesThemeConfig = useThemeConfig();
const { themeConfig } = storeToRefs(storesThemeConfig);
+500 -202
View File
@@ -1,221 +1,519 @@
<template>
<template>
<div class="cid-apis-container">
<el-card shadow="hover">
<div class="system-user-search mb15">
<el-form :model="tableData.param" ref="queryRef" :inline="true" label-width="68px">
<el-form-item label="关键字" prop="keyword">
<el-input
v-model="tableData.param.keyword"
placeholder="请输入接口名称"
clearable
size="default"
style="width: 240px"
@keyup.enter.native="getList"
/>
</el-form-item>
<el-form-item>
<el-button size="default" type="primary" @click="getList">
<el-icon><ele-Search /></el-icon>
查询
</el-button>
<el-button size="default" @click="resetQuery(queryRef)">
<el-icon><ele-Refresh /></el-icon>
重置
</el-button>
<el-button size="default" type="success" @click="onOpenAdd">
<el-icon><ele-FolderAdd /></el-icon>
新增接口
</el-button>
<el-button size="default" type="danger" @click="onRowDel(null)">
<el-icon><ele-Delete /></el-icon>
删除
</el-button>
</el-form-item>
</el-form>
</div>
<el-table :data="tableData.data" style="width: 100%" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<template #header>
<span style="font-weight: 600">平台管理</span>
</template>
<el-form ref="pqf" :model="pq" :inline="true" label-width="68px" class="mb15">
<el-form-item label="关键字" prop="keyword">
<el-input v-model="pq.keyword" placeholder="请输入平台名称/编码" clearable style="width: 240px" @keyup.enter.native="searchPlatform" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="pq.status" placeholder="平台状态" clearable style="width: 160px">
<el-option label="启用" value="ACTIVE" />
<el-option label="禁用" value="INACTIVE" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="searchPlatform"><el-icon><ele-Search /></el-icon>查询</el-button>
<el-button @click="resetPlatformQuery"><el-icon><ele-Refresh /></el-icon>重置</el-button>
<el-button type="success" @click="openPlatform()"><el-icon><ele-FolderAdd /></el-icon>新增平台</el-button>
</el-form-item>
</el-form>
<el-table :data="platformRows" row-key="id" v-loading="platformLoading">
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="name" label="接口名称" show-overflow-tooltip />
<el-table-column prop="path" label="接口路径" show-overflow-tooltip />
<el-table-column prop="method" label="请求方式" width="100" align="center" />
<el-table-column prop="datasourceName" label="所属平台" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'" size="small">
{{ scope.row.status === 1 ? '启用' : '禁用' }}
</el-tag>
<el-table-column prop="platformCode" label="平台编码" min-width="140" show-overflow-tooltip />
<el-table-column prop="platformName" label="平台名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="apiBaseUrl" label="API地址" min-width="220" show-overflow-tooltip />
<el-table-column prop="authType" label="认证" width="100" align="center" />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="s">
<el-tag size="small" :type="s.row.status === 'ACTIVE' ? 'success' : 'danger'">{{ s.row.status === 'ACTIVE' ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" show-overflow-tooltip />
<el-table-column label="操作" width="180">
<template #default="scope">
<el-button size="small" text type="primary" @click="onOpenEdit(scope.row)">修改</el-button>
<el-button size="small" text type="danger" @click="onRowDel(scope.row)">删除</el-button>
<el-table-column prop="rateLimitPerMinute" label="限流/分钟" width="110" align="center" />
<el-table-column prop="requestTimeoutMs" label="超时(ms)" width="110" align="center" />
<el-table-column label="创建时间" width="170">
<template #default="s">{{ ft(s.row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="240" fixed="right">
<template #default="s">
<el-button size="small" text type="primary" @click="manageApis(s.row)">接口管理</el-button>
<el-button size="small" text type="primary" @click="openPlatform(s.row)">修改</el-button>
<el-button size="small" text type="danger" @click="delPlatform(s.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="tableData.total > 0"
:total="tableData.total"
v-model:page="tableData.param.pageNum"
v-model:limit="tableData.param.pageSize"
@pagination="getList"
/>
<pagination :total="platformTotal" v-model:page="pq.pageNum" v-model:limit="pq.pageSize" @pagination="getPlatforms" />
</el-card>
<!-- 新增/编辑弹窗 -->
<el-dialog :title="dialog.title" v-model="dialog.visible" width="500px" :close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="接口名称" prop="name">
<el-input v-model="form.name" placeholder="请输入接口名称" />
</el-form-item>
<el-form-item label="接口路径" prop="path">
<el-input v-model="form.path" placeholder="请输入接口路径" />
</el-form-item>
<el-form-item label="请求方式" prop="method">
<el-select v-model="form.method" style="width: 100%">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
</el-select>
</el-form-item>
<el-form-item label="所属平台" prop="datasourceId">
<el-input v-model="form.datasourceId" placeholder="请输入所属平台ID" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入描述" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="form.status" style="width: 100%">
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialog.visible = false">取消</el-button>
<el-button type="primary" @click="onSubmit" :loading="dialog.saving">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="apiListDialog.visible" :title="apiListDialog.title" width="1100px" :close-on-click-modal="false" destroy-on-close @closed="handleApiListClose"
><el-form ref="aqf" :model="aq" :inline="true" label-width="68px" class="mb15"
><el-form-item label="关键字" prop="keyword"
><el-input v-model="aq.keyword" placeholder="请输入接口名称/编码" clearable style="width: 220px" @keyup.enter.native="searchApi" /></el-form-item
><el-form-item label="状态" prop="status"
><el-select v-model="aq.status" placeholder="状态" clearable style="width: 130px"
><el-option label="启用" value="active" /><el-option label="禁用" value="inactive" /></el-select></el-form-item
><el-form-item label="方法" prop="method"
><el-select v-model="aq.method" placeholder="请求方式" clearable style="width: 130px"
><el-option v-for="m in methods" :key="m" :label="m" :value="m" /></el-select></el-form-item
><el-form-item
><el-button type="primary" @click="searchApi"><el-icon><ele-Search /></el-icon>查询</el-button
><el-button @click="resetApiQuery"><el-icon><ele-Refresh /></el-icon>重置</el-button
><el-button type="success" @click="openApiForCurrentPlatform()"><el-icon><ele-FolderAdd /></el-icon>新增接口</el-button></el-form-item></el-form
><el-table :data="apiRows" v-loading="apiLoading" max-height="420"
><el-table-column type="index" label="序号" width="60" /><el-table-column prop="code" label="接口编码" min-width="140" show-overflow-tooltip
/><el-table-column prop="name" label="接口名称" min-width="150" show-overflow-tooltip /><el-table-column prop="url" label="URL" min-width="220"
show-overflow-tooltip /><el-table-column prop="method" label="请求方式" width="100" align="center"
><template #default="s"
><el-tag size="small" :type="s.row.method === 'GET' ? 'success' : 'primary'">{{ s.row.method }}</el-tag></template></el-table-column
><el-table-column prop="status" label="状态" width="90" align="center"
><template #default="s"
><el-tag size="small" :type="ns(s.row.status) === 'active' ? 'success' : 'danger'">{{ ns(s.row.status) === 'active' ? '启用' : '禁用' }}</el-tag></template></el-table-column
><el-table-column prop="platformName" label="所属平台" min-width="140" show-overflow-tooltip /><el-table-column label="创建时间" width="170"
><template #default="s">{{ ft(s.row.createdAt) }}</template></el-table-column
><el-table-column label="操作" width="160" fixed="right"
><template #default="s"
><el-button size="small" text type="primary" @click="openApi(String(s.row.id))">修改</el-button
><el-button size="small" text type="danger" @click="delApi(s.row)">删除</el-button></template></el-table-column></el-table
><template #footer
><div style="display: flex; justify-content: space-between; align-items: center; width: 100%;"
><pagination :total="apiTotal" v-model:page="aq.pageNum" v-model:limit="aq.pageSize" @pagination="getApis" />
<el-button type="primary" @click="apiListDialog.visible = false">关闭</el-button></div></template
></el-dialog>
<el-dialog v-model="platformDialog.visible" :title="platformDialog.title" width="720px" :close-on-click-modal="false" destroy-on-close
><el-form ref="platformFormRef" :model="platformForm" :rules="platformRules" label-width="120px" v-loading="platformDialog.loading"
><el-row :gutter="16"
><el-col :span="12"
><el-form-item label="平台编码" prop="platformCode"><el-input v-model="platformForm.platformCode" /></el-form-item></el-col
><el-col :span="12"
><el-form-item label="平台名称" prop="platformName"><el-input v-model="platformForm.platformName" /></el-form-item></el-col></el-row
><el-form-item label="平台描述"><el-input v-model="platformForm.description" type="textarea" :rows="3" /></el-form-item
><el-form-item label="API地址"><el-input v-model="platformForm.apiBaseUrl" /></el-form-item
><el-row :gutter="16"
><el-col :span="12"
><el-form-item label="认证类型" prop="authType"
><el-select v-model="platformForm.authType" style="width: 100%"
><el-option label="Token" value="TOKEN" /><el-option label="API Key" value="API_KEY" /><el-option
label="OAuth2"
value="OAUTH2" /><el-option label="Basic" value="BASIC" /></el-select></el-form-item></el-col
><el-col :span="12"
><el-form-item label="状态" prop="status"
><el-select v-model="platformForm.status" style="width: 100%"
><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="INACTIVE" /></el-select></el-form-item></el-col></el-row
><el-form-item label="Token/密钥"><el-input v-model="platformForm.token" show-password /></el-form-item
><el-form-item label="API Key"><el-input v-model="platformForm.apiKey" show-password /></el-form-item
><el-row :gutter="16"
><el-col :span="12"
><el-form-item label="Client ID"><el-input v-model="platformForm.clientId" /></el-form-item></el-col
><el-col :span="12"
><el-form-item label="Client Secret"><el-input v-model="platformForm.clientSecret" show-password /></el-form-item></el-col></el-row
><el-form-item label="认证配置 JSON" prop="authConfigText"
><el-input v-model="platformForm.authConfigText" type="textarea" :rows="6" /></el-form-item
><el-row :gutter="16"
><el-col :span="12"
><el-form-item label="限流/分钟"
><el-input-number v-model="platformForm.rateLimitPerMinute" :min="0" style="width: 100%" /></el-form-item></el-col
><el-col :span="12"
><el-form-item label="限流/小时"
><el-input-number v-model="platformForm.rateLimitPerHour" :min="0" style="width: 100%" /></el-form-item></el-col></el-row
><el-row :gutter="16"
><el-col :span="12"
><el-form-item label="并发限制"
><el-input-number v-model="platformForm.concurrencyLimit" :min="0" style="width: 100%" /></el-form-item></el-col
><el-col :span="12"
><el-form-item label="超时(ms)"
><el-input-number v-model="platformForm.requestTimeoutMs" :min="0" style="width: 100%" /></el-form-item></el-col></el-row
><el-row :gutter="16"
><el-col :span="12"
><el-form-item label="最大重试"><el-input-number v-model="platformForm.maxRetries" :min="0" style="width: 100%" /></el-form-item></el-col
><el-col :span="12"
><el-form-item label="重试延迟(ms)"
><el-input-number v-model="platformForm.retryDelayMs" :min="0" style="width: 100%" /></el-form-item></el-col></el-row></el-form
><template #footer
><el-button @click="platformDialog.visible = false">取消</el-button
><el-button type="primary" :loading="platformDialog.saving" @click="savePlatform">确定</el-button></template
></el-dialog
><el-dialog v-model="apiDialog.visible" :title="apiDialog.title" width="780px" :close-on-click-modal="false" destroy-on-close
><el-form ref="apiFormRef" :model="apiForm" :rules="apiRules" label-width="110px" v-loading="apiDialog.loading"
><el-form-item label="所属平台" prop="platformId"
><el-select v-model="apiForm.platformId" style="width: 100%" filterable
><el-option v-for="i in platforms" :key="i.id" :label="i.platformName" :value="String(i.id)" /></el-select></el-form-item
><el-row :gutter="16"
><el-col :span="12"
><el-form-item label="接口编码" prop="code"><el-input v-model="apiForm.code" /></el-form-item></el-col
><el-col :span="12"
><el-form-item label="接口名称" prop="name"><el-input v-model="apiForm.name" /></el-form-item></el-col></el-row
><el-row :gutter="16"
><el-col :span="12"
><el-form-item label="URL" prop="url"><el-input v-model="apiForm.url" /></el-form-item></el-col
><el-col :span="12"
><el-form-item label="请求方式" prop="method"
><el-select v-model="apiForm.method" style="width: 100%"
><el-option v-for="m in methods" :key="m" :label="m" :value="m" /></el-select></el-form-item></el-col></el-row
><el-row :gutter="16"
><el-col :span="12"
><el-form-item label="状态" prop="status"
><el-select v-model="apiForm.status" style="width: 100%"
><el-option label="启用" value="active" /><el-option label="禁用" value="inactive" /></el-select></el-form-item></el-col
><el-col :span="12"
><el-form-item label="认证类型"
><el-select v-model="apiForm.authType" style="width: 100%" clearable
><el-option label="Token" value="TOKEN" /><el-option label="API Key" value="API_KEY" /><el-option
label="OAuth2"
value="OAUTH2" /><el-option label="APP_SIGNATURE" value="APP_SIGNATURE" /></el-select></el-form-item></el-col></el-row
><el-form-item label="请求配置 JSON" prop="requestConfigText"
><el-input v-model="apiForm.requestConfigText" type="textarea" :rows="6" /></el-form-item
><el-form-item label="响应配置 JSON" prop="responseConfigText"
><el-input v-model="apiForm.responseConfigText" type="textarea" :rows="5" /></el-form-item
><el-form-item label="限流配置 JSON" prop="limitConfigText"
><el-input v-model="apiForm.limitConfigText" type="textarea" :rows="4" /></el-form-item
><el-form-item label="表结构定义 JSON" prop="tableDefinitionText"
><el-input v-model="apiForm.tableDefinitionText" type="textarea" :rows="6" /></el-form-item></el-form
><template #footer
><el-button @click="apiDialog.visible = false">取消</el-button
><el-button type="primary" :loading="apiDialog.saving" @click="saveApi">确定</el-button></template
></el-dialog
>
</div>
</template>
<script lang="ts">
export default { name: 'cidApis' };
</script>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { ElMessage, ElMessageBox, FormInstance } from 'element-plus';
const queryRef = ref<FormInstance>();
const formRef = ref<FormInstance>();
const ids = ref<string[]>([]);
const tableData = reactive({
data: [] as any[],
total: 0,
param: {
pageNum: 1,
pageSize: 10,
keyword: '',
},
});
const dialog = reactive({
visible: false,
title: '',
saving: false,
});
const form = reactive({
import { onMounted, reactive, ref } from 'vue';
import type { FormInstance, FormRules } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import { getApiErrorMessage } from '/@/utils/request';
import {
createDatasourcePlatform,
deleteDatasourcePlatform,
getDatasourcePlatform,
listDatasourcePlatforms,
updateDatasourcePlatform,
} from '/@/api/cid/datasource';
import {
createApiInterface,
deleteApiInterface,
getApiInterface,
listApiInterfaces,
updateApiInterface,
type ApiInterfaceInfo,
} from '/@/api/cid/apis';
defineOptions({ name: 'cidApis' });
const currentPlatform = ref<any>(null),
methods = ['GET', 'POST', 'PUT', 'DELETE'],
platforms = ref<any[]>([]),
platformRows = ref<any[]>([]),
apiRows = ref<ApiInterfaceInfo[]>([]),
platformTotal = ref(0),
apiTotal = ref(0),
platformLoading = ref(false),
apiLoading = ref(false);
const pqf = ref<FormInstance>(),
aqf = ref<FormInstance>(),
platformFormRef = ref<FormInstance>(),
apiFormRef = ref<FormInstance>();
const pq = reactive({ pageNum: 1, pageSize: 10, keyword: '', status: '' }),
aq = reactive({ pageNum: 1, pageSize: 10, keyword: '', platformId: '', status: '', method: '' });
const platformDialog = reactive({ visible: false, title: '', loading: false, saving: false }),
apiDialog = reactive({ visible: false, title: '', loading: false, saving: false }),
apiListDialog = reactive({ visible: false, title: '' });
const dpf = () => ({
id: '',
name: '',
path: '',
method: 'GET',
datasourceId: '',
platformCode: '',
platformName: '',
description: '',
status: 1,
apiBaseUrl: '',
authType: 'TOKEN',
status: 'ACTIVE',
token: '',
apiKey: '',
clientId: '',
clientSecret: '',
authConfigText: '{}',
rateLimitPerMinute: 60,
rateLimitPerHour: 1000,
concurrencyLimit: 10,
requestTimeoutMs: 30000,
maxRetries: 3,
retryDelayMs: 1000,
});
const rules = {
name: [{ required: true, message: '请输入接口名称', trigger: 'blur' }],
path: [{ required: true, message: '请输入接口路径', trigger: 'blur' }],
method: [{ required: true, message: '请选择请求方式', trigger: 'change' }],
};
const getList = () => {
// TODO:
};
const resetQuery = (formEl: FormInstance | undefined) => {
if (!formEl) return;
formEl.resetFields();
getList();
};
const handleSelectionChange = (selection: any[]) => {
ids.value = selection.map((item) => item.id);
};
const onOpenAdd = () => {
dialog.title = '新增接口';
dialog.visible = true;
form.id = '';
form.name = '';
form.path = '';
form.method = 'GET';
form.datasourceId = '';
form.description = '';
form.status = 1;
};
const onOpenEdit = (row: any) => {
dialog.title = '修改接口';
dialog.visible = true;
form.id = row.id;
form.name = row.name;
form.path = row.path;
form.method = row.method;
form.datasourceId = row.datasourceId;
form.description = row.description;
form.status = row.status;
};
const onSubmit = () => {
if (!formRef.value) return;
formRef.value.validate((valid) => {
if (!valid) return;
dialog.saving = true;
// TODO: /
dialog.saving = false;
dialog.visible = false;
getList();
});
};
const onRowDel = (row: any) => {
const delIds = row ? [row.id] : ids.value;
if (delIds.length === 0) {
ElMessage.error('请选择要删除的数据');
return;
const daf = () => ({
id: '',
platformId: '',
code: '',
name: '',
url: '',
method: 'GET',
status: 'active',
authType: '',
requestConfigText: '{}',
responseConfigText: '{}',
limitConfigText: '{}',
tableDefinitionText: '{}',
});
const platformForm = reactive(dpf()),
apiForm = reactive(daf());
const jv = (v: string, cb: (e?: Error) => void, l: string) => {
if (!v) return cb();
try {
JSON.parse(v);
cb();
} catch {
cb(new Error(`${l} 格式错误`));
}
ElMessageBox.confirm(`确定要删除选中的接口吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
// TODO:
ElMessage.success('删除成功');
getList();
})
.catch(() => {});
};
onMounted(() => {
getList();
const platformRules: FormRules = {
platformCode: [{ required: true, message: '请输入平台编码', trigger: 'blur' }],
platformName: [{ required: true, message: '请输入平台名称', trigger: 'blur' }],
authType: [{ required: true, message: '请选择认证类型', trigger: 'change' }],
authConfigText: [{ validator: (_r, v, cb) => jv(v, cb, '认证配置 JSON'), trigger: 'blur' }],
};
const apiRules: FormRules = {
platformId: [{ required: true, message: '请选择所属平台', trigger: 'change' }],
code: [{ required: true, message: '请输入接口编码', trigger: 'blur' }],
name: [{ required: true, message: '请输入接口名称', trigger: 'blur' }],
url: [{ required: true, message: '请输入接口 URL', trigger: 'blur' }],
method: [{ required: true, message: '请选择请求方式', trigger: 'change' }],
requestConfigText: [{ validator: (_r, v, cb) => jv(v, cb, '请求配置 JSON'), trigger: 'blur' }],
responseConfigText: [{ validator: (_r, v, cb) => jv(v, cb, '响应配置 JSON'), trigger: 'blur' }],
limitConfigText: [{ validator: (_r, v, cb) => jv(v, cb, '接口独立限流配置 JSON'), trigger: 'blur' }],
tableDefinitionText: [{ validator: (_r, v, cb) => jv(v, cb, '表结构定义 JSON'), trigger: 'blur' }],
};
const ft = (t?: number) => (!t ? '' : new Date(String(t).length === 10 ? t * 1000 : t).toLocaleString('zh-CN', { hour12: false })),
ns = (s?: string) => (String(s || '').toLowerCase() === 'active' ? 'active' : 'inactive');
const loadPlatforms = async () => {
const r = await listDatasourcePlatforms({ pageNum: 1, pageSize: 100 });
platforms.value = r.data?.list || [];
};
const getPlatforms = async () => {
platformLoading.value = true;
try {
const r = await listDatasourcePlatforms({
pageNum: pq.pageNum,
pageSize: pq.pageSize,
...(pq.keyword ? { keyword: pq.keyword } : {}),
...(pq.status ? { status: pq.status } : {}),
});
platformRows.value = r.data?.list || [];
platformTotal.value = r.data?.total || 0;
} catch (e) {
ElMessage.error(getApiErrorMessage(e) || '获取平台列表失败');
} finally {
platformLoading.value = false;
}
};
const getApis = async () => {
apiLoading.value = true;
try {
const r = await listApiInterfaces({
pageNum: aq.pageNum,
pageSize: aq.pageSize,
...(aq.keyword ? { keyword: aq.keyword } : {}),
...(aq.platformId ? { platformId: aq.platformId } : {}),
...(aq.status ? { status: aq.status } : {}),
...(aq.method ? { method: aq.method } : {}),
});
apiRows.value = r.data?.list || [];
apiTotal.value = r.data?.total || 0;
} catch (e) {
ElMessage.error(getApiErrorMessage(e) || '获取接口列表失败');
} finally {
apiLoading.value = false;
}
};
const searchPlatform = () => {
pq.pageNum = 1;
getPlatforms();
};
const searchApi = () => {
aq.pageNum = 1;
getApis();
};
const resetPlatformQuery = () => {
pqf.value?.resetFields();
searchPlatform();
};
const resetApiQuery: any = () => {
aqf.value?.resetFields();
searchApi();
};
const manageApis = (row: any) => {
currentPlatform.value = row;
aq.platformId = String(row.id);
aq.pageNum = 1;
aq.keyword = '';
aq.status = '';
aq.method = '';
apiListDialog.title = `${row.platformName} - 接口管理`;
apiListDialog.visible = true;
getApis();
};
const handleApiListClose = () => {
currentPlatform.value = null;
};
const openApiForCurrentPlatform = () => {
openApi('', currentPlatform.value?.id || '');
};
const openPlatform = async (row?: any) => {
Object.assign(platformForm, dpf());
platformFormRef.value?.clearValidate();
platformDialog.title = row ? '编辑平台' : '新建平台';
platformDialog.visible = true;
if (!row) return;
platformDialog.loading = true;
try {
const r = await getDatasourcePlatform(row.id);
const d = r.data || {};
Object.assign(platformForm, {
id: String(d.id || row.id),
platformCode: d.platformCode || '',
platformName: d.platformName || '',
description: d.description || '',
apiBaseUrl: d.apiBaseUrl || '',
authType: d.authType || 'TOKEN',
status: d.status || 'ACTIVE',
token: d.token || '',
apiKey: d.apiKey || '',
clientId: d.clientId || '',
clientSecret: d.clientSecret || '',
authConfigText: JSON.stringify(d.authConfig ?? {}, null, 2),
rateLimitPerMinute: d.rateLimitPerMinute ?? 60,
rateLimitPerHour: d.rateLimitPerHour ?? 1000,
concurrencyLimit: d.concurrencyLimit ?? 10,
requestTimeoutMs: d.requestTimeoutMs ?? 30000,
maxRetries: d.maxRetries ?? 3,
retryDelayMs: d.retryDelayMs ?? 1000,
});
} catch (e) {
platformDialog.visible = false;
ElMessage.error(getApiErrorMessage(e) || '获取平台详情失败');
} finally {
platformDialog.loading = false;
}
};
const savePlatform = async () => {
if (!platformFormRef.value) return;
const ok = await platformFormRef.value.validate().catch(() => false);
if (!ok) return;
platformDialog.saving = true;
try {
const p = {
platformCode: platformForm.platformCode,
platformName: platformForm.platformName,
description: platformForm.description,
apiBaseUrl: platformForm.apiBaseUrl,
authType: platformForm.authType,
status: platformForm.status,
token: platformForm.token,
apiKey: platformForm.apiKey,
clientId: platformForm.clientId,
clientSecret: platformForm.clientSecret,
authConfig: JSON.parse(platformForm.authConfigText || '{}'),
rateLimitPerMinute: platformForm.rateLimitPerMinute || 60,
rateLimitPerHour: platformForm.rateLimitPerHour || 1000,
concurrencyLimit: platformForm.concurrencyLimit || 10,
requestTimeoutMs: platformForm.requestTimeoutMs || 30000,
maxRetries: platformForm.maxRetries || 3,
retryDelayMs: platformForm.retryDelayMs || 1000,
};
platformForm.id ? await updateDatasourcePlatform({ id: platformForm.id, ...p }) : await createDatasourcePlatform(p);
ElMessage.success(platformForm.id ? '更新成功' : '创建成功');
platformDialog.visible = false;
await Promise.all([getPlatforms(), loadPlatforms()]);
} catch (e) {
ElMessage.error(getApiErrorMessage(e) || (platformForm.id ? '更新失败' : '创建失败'));
} finally {
platformDialog.saving = false;
}
};
const delPlatform = (row: any) => {
const ids = [row.id];
ElMessageBox.confirm(`确定删除平台「${row.platformName}」吗?`, '提示', { type: 'warning' })
.then(async () => {
try {
for (const id of ids) await deleteDatasourcePlatform(id);
ElMessage.success('删除成功');
await Promise.all([getPlatforms(), loadPlatforms()]);
} catch (e) {
ElMessage.error(getApiErrorMessage(e) || '删除失败');
}
})
.catch(() => undefined);
};
const openApi = async (id = '', platformId = '') => {
Object.assign(apiForm, daf(), platformId ? { platformId } : {});
apiFormRef.value?.clearValidate();
if (!platforms.value.length) await loadPlatforms();
apiDialog.title = id ? '编辑接口' : '新建接口';
apiDialog.visible = true;
if (!id) return;
apiDialog.loading = true;
try {
const r = await getApiInterface(id);
const d = r.data || {};
Object.assign(apiForm, {
id: String(d.id || id),
platformId: String(d.platformId || platformId || ''),
code: d.code || '',
name: d.name || '',
url: d.url || '',
method: d.method || 'GET',
status: ns(d.status),
authType: d.authType || '',
requestConfigText: JSON.stringify(d.requestConfig ?? {}, null, 2),
responseConfigText: JSON.stringify(d.responseConfig ?? {}, null, 2),
limitConfigText: JSON.stringify(d.limitConfig ?? {}, null, 2),
tableDefinitionText: JSON.stringify(d.tableDefinition ?? {}, null, 2),
});
} catch (e) {
apiDialog.visible = false;
ElMessage.error(getApiErrorMessage(e) || '获取接口详情失败');
} finally {
apiDialog.loading = false;
}
};
const saveApi = async () => {
if (!apiFormRef.value) return;
const ok = await apiFormRef.value.validate().catch(() => false);
if (!ok) return;
apiDialog.saving = true;
try {
const p = {
platformId: Number(apiForm.platformId),
code: apiForm.code,
name: apiForm.name,
url: apiForm.url,
method: apiForm.method,
status: apiForm.status,
authType: apiForm.authType || undefined,
requestConfig: JSON.parse(apiForm.requestConfigText || '{}'),
responseConfig: JSON.parse(apiForm.responseConfigText || '{}'),
limitConfig: JSON.parse(apiForm.limitConfigText || '{}'),
tableDefinition: JSON.parse(apiForm.tableDefinitionText || '{}'),
};
apiForm.id ? await updateApiInterface({ id: apiForm.id, ...p }) : await createApiInterface(p);
ElMessage.success(apiForm.id ? '更新成功' : '创建成功');
apiDialog.visible = false;
await getApis();
} catch (e) {
ElMessage.error(getApiErrorMessage(e) || (apiForm.id ? '更新失败' : '创建失败'));
} finally {
apiDialog.saving = false;
}
};
const delApi = (row: any) => {
ElMessageBox.confirm(`确定删除接口「${row.name}」吗?`, '提示', { type: 'warning' })
.then(async () => {
try {
await deleteApiInterface(String(row.id));
ElMessage.success('删除成功');
await getApis();
} catch (e) {
ElMessage.error(getApiErrorMessage(e) || '删除失败');
}
})
.catch(() => undefined);
};
onMounted(async () => {
await loadPlatforms();
await getPlatforms();
});
</script>
+627
View File
@@ -0,0 +1,627 @@
<template>
<div class="report-query-container">
<!-- 选择业务和报表 -->
<el-card shadow="hover" class="mb15">
<el-form :inline="true">
<el-form-item label="业务">
<el-select v-model="queryForm.businessCode" placeholder="请选择业务" style="width: 200px" @change="onBusinessChange">
<el-option v-for="biz in businesses" :key="biz.businessCode" :label="biz.businessName" :value="biz.businessCode" />
</el-select>
</el-form-item>
<el-form-item label="报表">
<el-select v-model="queryForm.reportCode" placeholder="请选择报表" style="width: 260px" @change="onReportChange" :loading="reportLoading">
<el-option v-for="r in reports" :key="r.reportCode" :label="r.reportName" :value="r.reportCode" />
</el-select>
</el-form-item>
</el-form>
</el-card>
<!-- 查询配置区 -->
<template v-if="reportLoaded">
<el-card shadow="hover" class="mb15">
<template #header><span>查询配置</span></template>
<el-form :model="queryForm" label-width="100px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="时间范围" required>
<el-date-picker
v-model="timeRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="时间粒度">
<el-select v-model="queryForm.timeGroup" style="width: 100%">
<el-option label="按天" value="day" />
<el-option label="按周" value="week" />
<el-option label="按月" value="month" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- 维度 -->
<el-form-item label="统计维度">
<el-checkbox-group v-model="queryForm.dimensions" class="field-checkbox-group">
<el-checkbox
v-for="f in dimensionFields"
:key="f.fieldCode"
:label="f.fieldCode"
>
{{ f.fieldName || f.fieldCode }}
</el-checkbox>
</el-checkbox-group>
<div v-if="dimensionFields.length === 0" class="field-empty-hint">该报表无可用的维度字段</div>
</el-form-item>
<!-- 指标 -->
<el-form-item label="统计指标">
<div class="indicator-list" v-if="indicatorFields.length > 0">
<div v-for="(f, idx) in indicatorFields" :key="f.fieldCode" class="indicator-row">
<el-checkbox
v-model="indicatorChecked[f.fieldCode]"
:label="f.fieldName || f.fieldCode"
@change="(val: boolean) => onIndicatorCheck(f.fieldCode, val)"
style="min-width: 120px"
/>
<template v-if="indicatorChecked[f.fieldCode]">
<el-select
v-model="indicatorAggs[f.fieldCode].aggregate"
style="width: 130px; margin-left: 8px"
placeholder="聚合方式"
>
<el-option v-for="agg in aggregateOptions" :key="agg.value" :label="agg.label" :value="agg.value" />
</el-select>
<el-input
v-model="indicatorAggs[f.fieldCode].alias"
style="width: 130px; margin-left: 8px"
placeholder="显示别名"
/>
</template>
</div>
</div>
<div v-else class="field-empty-hint">该报表无可用的指标字段</div>
</el-form-item>
<!-- 筛选条件 -->
<el-form-item label="筛选条件">
<div class="filter-list">
<div v-for="(f, idx) in filters" :key="idx" class="filter-row">
<el-select v-model="f.fieldCode" style="width: 150px" placeholder="字段" @change="onFilterFieldChange(idx)">
<el-option v-for="qf in queryableFields" :key="qf.fieldCode" :label="qf.fieldName || qf.fieldCode" :value="qf.fieldCode" />
</el-select>
<el-select v-model="f.operator" style="width: 100px; margin-left: 6px" placeholder="运算符">
<el-option v-for="op in filterOperators" :key="op.value" :label="op.label" :value="op.value" />
</el-select>
<el-input v-model="f.value" style="width: 160px; margin-left: 6px" placeholder="值" />
<el-button type="danger" :icon="Delete" circle size="small" style="margin-left: 6px" @click="removeFilter(idx)" />
</div>
<el-button size="small" @click="addFilter" :disabled="queryableFields.length === 0">+ 添加筛选</el-button>
</div>
</el-form-item>
<!-- 排序 -->
<el-form-item label="排序">
<div class="order-list">
<div v-for="(o, idx) in queryForm.orderBy" :key="idx" class="order-row">
<el-select v-model="o.fieldCode" style="width: 150px" placeholder="排序字段">
<el-option v-for="qf in sortableFields" :key="qf.fieldCode" :label="qf.fieldName || qf.fieldCode" :value="qf.fieldCode" />
</el-select>
<el-select v-model="o.direction" style="width: 100px; margin-left: 6px">
<el-option label="降序" value="DESC" />
<el-option label="升序" value="ASC" />
</el-select>
<el-button type="danger" :icon="Delete" circle size="small" style="margin-left: 6px" @click="removeOrder(idx)" />
</div>
<el-button size="small" @click="addOrder" :disabled="sortableFields.length === 0">+ 添加排序</el-button>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" size="default" @click="handleQuery" :loading="queryLoading">
<el-icon><ele-Search /></el-icon> 查询
</el-button>
<el-button size="default" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 查询结果 -->
<el-card shadow="hover" v-if="queryDone">
<template #header>
<div class="result-header">
<span>查询结果 {{ queryResult.total }} </span>
<el-radio-group v-model="viewMode" size="small">
<el-radio-button value="table">数据列表</el-radio-button>
<el-radio-button value="chart">图表展示</el-radio-button>
</el-radio-group>
</div>
</template>
<!-- 数据列表 -->
<template v-if="viewMode === 'table'">
<el-table :data="queryResult.list" border stripe v-loading="queryLoading" style="width: 100%" empty-text="暂无数据">
<el-table-column v-for="col in queryResult.columns" :key="col" :prop="col" :label="col" show-overflow-tooltip min-width="120" />
</el-table>
<pagination
v-show="queryResult.total > 0"
:total="queryResult.total"
v-model:page="queryForm.page"
v-model:limit="queryForm.pageSize"
@pagination="handleQuery"
/>
</template>
<!-- 图表展示 -->
<template v-if="viewMode === 'chart'">
<div class="chart-tip" v-if="!chartReady">
<el-alert title="图表展示需要至少选择一个维度和一个指标" type="info" show-icon :closable="false" />
</div>
<div ref="chartRef" class="chart-container" v-loading="queryLoading"></div>
</template>
</el-card>
</template>
</div>
</template>
<script lang="ts">
export default { name: 'reportEngineQuery' };
</script>
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Delete } from '@element-plus/icons-vue';
import * as echarts from 'echarts';
import {
listBusinesses,
listAllReports,
getReport,
reportQuery,
} from '/@/api/report-engine/report';
import type {
FieldDefine,
ReportFilter,
ReportOrderBy,
} from '/@/api/report-engine/report';
// ========== ==========
const AGGREGATE_OPTIONS = [
{ value: 'SUM', label: 'SUM (求和)' },
{ value: 'COUNT', label: 'COUNT (计数)' },
{ value: 'AVG', label: 'AVG (平均)' },
{ value: 'MAX', label: 'MAX (最大)' },
{ value: 'MIN', label: 'MIN (最小)' },
];
const FILTER_OPERATORS = [
{ value: '=', label: '=' },
{ value: '!=', label: '!=' },
{ value: '>', label: '>' },
{ value: '>=', label: '>=' },
{ value: '<', label: '<' },
{ value: '<=', label: '<=' },
{ value: 'LIKE', label: 'LIKE' },
{ value: 'IN', label: 'IN' },
];
// ========== ==========
const businesses = ref<any[]>([]);
const reports = ref<any[]>([]);
const reportLoading = ref(false);
const reportLoaded = ref(false);
const queryLoading = ref(false);
const queryDone = ref(false);
const viewMode = ref<'table' | 'chart'>('table');
const chartRef = ref<HTMLDivElement>();
let chartInstance: echarts.ECharts | null = null;
/** 当前选中报表的所有字段 */
const reportFields = ref<FieldDefine[]>([]);
// role === DIMENSION
const dimensionFields = computed(() =>
reportFields.value.filter((f) => f.fieldRole === 'DIMENSION')
);
// role === INDICATOR
const indicatorFields = computed(() =>
reportFields.value.filter((f) => f.fieldRole === 'INDICATOR')
);
//
const queryableFields = computed(() =>
reportFields.value.filter((f) => f.isFilterable !== false || f.isQueryable)
);
//
const sortableFields = computed(() =>
reportFields.value.filter((f) => f.isSortable !== false || f.fieldRole === 'DIMENSION')
);
const aggregateOptions = AGGREGATE_OPTIONS;
const filterOperators = FILTER_OPERATORS;
/** 指标选中状态 */
const indicatorChecked = reactive<Record<string, boolean>>({});
/** 指标聚合配置 */
const indicatorAggs = reactive<Record<string, { aggregate: string; alias: string }>>({});
/** 筛选条件列表 */
const filters = ref<ReportFilter[]>([]);
/** 时间范围绑定(内部用数组) */
const timeRange = ref<string[]>([]);
/** 查询表单 */
const queryForm = reactive({
businessCode: '',
reportCode: '',
dimensions: [] as string[],
timeGroup: 'day',
page: 1,
pageSize: 10,
orderBy: [] as ReportOrderBy[],
});
/** 查询结果 */
const queryResult = reactive({
list: [] as Record<string, any>[],
columns: [] as string[],
total: 0,
});
/** 图表数据是否可用 */
const chartReady = computed(() => {
return queryForm.dimensions.length > 0 && Object.values(indicatorChecked).some(Boolean);
});
// ========== ==========
const initBusinesses = async () => {
try {
const res = await listBusinesses();
businesses.value = res.data?.list || res.data || [];
} catch (_e) {
ElMessage.error('获取业务列表失败');
}
};
initBusinesses();
// ========== / ==========
const onBusinessChange = async (code: string) => {
queryForm.reportCode = '';
reportLoaded.value = false;
queryDone.value = false;
reports.value = [];
if (!code) return;
reportLoading.value = true;
try {
const res = await listAllReports(code);
const raw = res.data;
reports.value = Array.isArray(raw) ? raw : raw?.list || raw?.records || [];
} catch (_e) {
reports.value = [];
ElMessage.error('获取报表列表失败');
} finally {
reportLoading.value = false;
}
};
const onReportChange = async (code: string) => {
reportLoaded.value = false;
queryDone.value = false;
if (!code) return;
// id
const row = reports.value.find((r) => r.reportCode === code);
if (!row?.id) {
ElMessage.warning('未找到报表信息');
return;
}
try {
const res = await getReport(row.id);
const raw = res.data || {};
const detail = raw.report || raw.data || raw;
reportFields.value = detail.fields || [];
//
resetQueryState();
//
queryForm.dimensions = dimensionFields.value.map((f) => f.fieldCode);
//
indicatorFields.value.forEach((f) => {
indicatorChecked[f.fieldCode] = true;
indicatorAggs[f.fieldCode] = {
aggregate: f.defaultAggregate || 'SUM',
alias: f.fieldName || f.fieldCode,
};
});
reportLoaded.value = true;
} catch (_e) {
ElMessage.error('获取报表字段信息失败');
}
};
// ========== ==========
const onIndicatorCheck = (fieldCode: string, checked: boolean) => {
if (checked) {
const f = reportFields.value.find((r) => r.fieldCode === fieldCode);
indicatorAggs[fieldCode] = {
aggregate: f?.defaultAggregate || 'SUM',
alias: f?.fieldName || fieldCode,
};
}
};
// ========== ==========
const addFilter = () => {
filters.value.push({ fieldCode: '', operator: '=', value: '' });
};
const removeFilter = (idx: number) => {
filters.value.splice(idx, 1);
};
const onFilterFieldChange = (idx: number) => {
// 便
filters.value[idx].value = '';
};
// ========== ==========
const addOrder = () => {
queryForm.orderBy.push({ fieldCode: '', direction: 'DESC' });
};
const removeOrder = (idx: number) => {
queryForm.orderBy.splice(idx, 1);
};
// ========== ==========
const buildQueryParams = () => {
const dimensions = [...queryForm.dimensions];
//
const indicators: { fieldCode: string; aggregate: string; alias: string }[] = [];
indicatorFields.value.forEach((f) => {
if (indicatorChecked[f.fieldCode]) {
indicators.push({
fieldCode: f.fieldCode,
aggregate: indicatorAggs[f.fieldCode]?.aggregate || 'SUM',
alias: indicatorAggs[f.fieldCode]?.alias || f.fieldName || f.fieldCode,
});
}
});
const validFilters = filters.value.filter((f) => f.fieldCode && f.value);
const validOrderBy = queryForm.orderBy.filter((o) => o.fieldCode);
return {
businessCode: queryForm.businessCode,
reportCode: queryForm.reportCode,
dimensions,
indicators,
filters: validFilters,
timeRange: {
startDate: timeRange.value?.[0] || '',
endDate: timeRange.value?.[1] || '',
},
timeGroup: queryForm.timeGroup,
orderBy: validOrderBy,
page: queryForm.page,
pageSize: queryForm.pageSize,
};
};
const handleQuery = async () => {
if (!queryForm.businessCode || !queryForm.reportCode) {
ElMessage.warning('请选择业务和报表');
return;
}
if (!timeRange.value || timeRange.value.length !== 2) {
ElMessage.warning('请选择时间范围');
return;
}
queryLoading.value = true;
queryDone.value = true;
try {
const params = buildQueryParams();
const res = await reportQuery(params);
const raw = res.data || {};
//
const list = raw.list || raw.records || raw.rows || [];
const total = raw.total ?? list.length;
//
const columns = raw.columns || raw.header || [];
if (columns.length === 0 && list.length > 0) {
queryResult.columns = Object.keys(list[0]);
} else {
queryResult.columns = columns;
}
queryResult.list = list;
queryResult.total = total;
//
if (viewMode.value === 'chart') {
nextTick(() => renderChart());
}
} catch (_e) {
queryResult.list = [];
queryResult.columns = [];
queryResult.total = 0;
ElMessage.error('查询失败');
} finally {
queryLoading.value = false;
}
};
const resetQuery = () => {
resetQueryState();
timeRange.value = [];
queryForm.page = 1;
queryDone.value = false;
};
const resetQueryState = () => {
queryForm.dimensions = [];
queryForm.orderBy = [];
queryForm.timeGroup = 'day';
queryForm.pageSize = 20;
queryForm.page = 1;
filters.value = [];
//
Object.keys(indicatorChecked).forEach((k) => delete indicatorChecked[k]);
Object.keys(indicatorAggs).forEach((k) => delete indicatorAggs[k]);
};
// ========== ==========
watch(viewMode, (mode) => {
if (mode === 'chart' && queryDone.value && queryResult.list.length > 0) {
nextTick(() => renderChart());
}
if (mode === 'table') {
disposeChart();
}
});
const renderChart = () => {
if (!chartRef.value || queryResult.list.length === 0) return;
if (!chartReady.value) {
disposeChart();
return;
}
if (!chartInstance) {
chartInstance = echarts.init(chartRef.value);
}
const dimensions = queryForm.dimensions;
const dimField = dimensions[0] || queryResult.columns[0];
// x
const xData = queryResult.list.map((row) => row[dimField] ?? '');
//
// API key 使 alias fieldCode
const series: echarts.SeriesOption[] = [];
indicatorFields.value.forEach((f) => {
if (indicatorChecked[f.fieldCode]) {
const alias = indicatorAggs[f.fieldCode]?.alias || f.fieldName || f.fieldCode;
series.push({
name: alias,
type: 'bar',
data: queryResult.list.map((row) => {
const val = Number(row[alias]);
return isNaN(val) ? row[alias] : val;
}),
});
}
});
chartInstance.setOption({
tooltip: { trigger: 'axis' },
legend: { type: 'scroll', bottom: 0 },
grid: { left: '3%', right: '4%', bottom: '20%', containLabel: true },
xAxis: {
type: 'category',
data: xData,
axisLabel: { rotate: xData.length > 10 ? 45 : 0 },
},
yAxis: { type: 'value' },
series,
});
chartInstance.resize();
};
const disposeChart = () => {
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
};
// ========== resize ==========
window.addEventListener('resize', () => {
if (chartInstance) chartInstance.resize();
});
</script>
<style scoped lang="scss">
.report-query-container {
.field-checkbox-group {
.el-checkbox {
margin-right: 16px;
margin-bottom: 6px;
}
}
.field-empty-hint {
color: var(--el-text-color-placeholder);
font-size: 13px;
}
.indicator-list {
width: 100%;
.indicator-row {
display: flex;
align-items: center;
margin-bottom: 8px;
flex-wrap: wrap;
}
}
.filter-list,
.order-list {
width: 100%;
.filter-row,
.order-row {
display: flex;
align-items: center;
margin-bottom: 8px;
}
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.chart-container {
width: 100%;
height: 450px;
}
.chart-tip {
margin-bottom: 12px;
}
}
</style>
@@ -0,0 +1,95 @@
<template>
<el-dialog :title="dialogTitle" :model-value="visible" width="480px" :close-on-click-modal="false" destroy-on-close @update:model-value="emit('update:visible', $event)">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="业务编码" prop="businessCode">
<el-input
v-model="form.businessCode"
placeholder="请输入业务编码,如 KUAISHOU"
/>
</el-form-item>
<el-form-item label="业务名称" prop="businessName">
<el-input v-model="form.businessName" placeholder="请输入业务名称,如 快手电商" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="saving">确定</el-button>
</template>
</el-dialog>
</template>
<script lang="ts">
export default { name: 'reportEngineBusinessManager' };
</script>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import { FormInstance } from 'element-plus';
const props = defineProps<{
visible: boolean;
editData?: { id?: number; businessCode: string; businessName: string } | null;
}>();
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void;
(e: 'confirm', data: { id?: number; businessCode: string; businessName: string }): void;
}>();
const formRef = ref<FormInstance>();
const saving = ref(false);
const form = reactive({
id: undefined as number | undefined,
businessCode: '',
businessName: '',
});
const rules = {
businessCode: [{ required: true, message: '请输入业务编码', trigger: 'blur' }],
businessName: [{ required: true, message: '请输入业务名称', trigger: 'blur' }],
};
const dialogTitle = ref('新增业务');
watch(
() => props.visible,
(val) => {
if (val) {
if (props.editData) {
form.id = props.editData.id;
form.businessCode = props.editData.businessCode;
form.businessName = props.editData.businessName;
dialogTitle.value = '编辑业务';
} else {
form.id = undefined;
form.businessCode = '';
form.businessName = '';
dialogTitle.value = '新增业务';
}
}
}
);
const handleCancel = () => {
emit('update:visible', false);
};
const handleSubmit = () => {
if (!formRef.value) return;
formRef.value.validate(async (valid) => {
if (!valid) return;
saving.value = true;
try {
const payload: { id?: number; businessCode: string; businessName: string } = {
businessCode: form.businessCode,
businessName: form.businessName,
};
if (form.id) payload.id = form.id;
emit('confirm', payload);
} finally {
saving.value = false;
}
});
};
</script>
@@ -0,0 +1,250 @@
<template>
<el-dialog
:title="dialogTitle"
:model-value="visible"
width="750px"
:close-on-click-modal="false"
destroy-on-close
@update:model-value="emit('update:visible', $event)"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" v-loading="loading">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="抽取编码" prop="extractCode">
<el-input v-model="form.extractCode" placeholder="如 shop_daily_direct" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="抽取名称" prop="extractName">
<el-input v-model="form.extractName" placeholder="如 店铺日订单直接抽取" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="源表名" prop="sourceTableName">
<el-input v-model="form.sourceTableName" placeholder="如 ods_kuaishou_orders" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="源表别名" prop="sourceTableAlias">
<el-input v-model="form.sourceTableAlias" placeholder="如 o" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="目标表名" prop="targetTableName">
<el-input v-model="form.targetTableName" placeholder="如 stat_kuaishou_shop_daily" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="抽取类型" prop="extractType">
<el-select v-model="form.extractType" style="width: 100%">
<el-option label="增量抽取" value="INCREMENTAL" />
<el-option label="全量抽取" value="FULL" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="抽取模式" prop="extractMode">
<el-select v-model="form.extractMode" style="width: 100%">
<el-option label="直接抽取" value="DIRECT" />
<el-option label="SQL转换" value="SQL" />
<el-option label="API抽取" value="API" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="抽取键字段" prop="extractKeyField">
<el-input v-model="form.extractKeyField" placeholder="如 created_at" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="每批数量" prop="batchSize">
<el-input-number v-model="form.batchSize" :min="1" :max="100000" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="是否启用" prop="isEnabled">
<el-switch v-model="form.isEnabled" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-select v-model="form.status" style="width: 100%">
<el-option label="启用" value="ACTIVE" />
<el-option label="停用" value="INACTIVE" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="负责人" prop="operator">
<el-input v-model="form.operator" placeholder="如 system" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="分组字段" prop="groupByFields">
<el-select v-model="form.groupByFields" multiple filterable allow-create default-first-option style="width: 100%" placeholder="输入字段名后回车添加">
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="过滤表达式" prop="filterExpression">
<el-input v-model="form.filterExpression" type="textarea" :rows="2" placeholder="如 o.order_status = '已完成'" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="saving">确定</el-button>
</template>
</el-dialog>
</template>
<script lang="ts">
export default { name: 'reportEngineExtractConfigForm' };
</script>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import { ElMessage, FormInstance } from 'element-plus';
import { getExtractConfig, saveExtractConfig, type ExtractConfigSaveData } from '/@/api/report-engine/report';
const props = defineProps<{
visible: boolean;
editId?: number | null;
businessCode: string;
reportCode: string;
reportName: string;
}>();
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void;
(e: 'confirm'): void;
}>();
const formRef = ref<FormInstance>();
const saving = ref(false);
const loading = ref(false);
const dialogTitle = ref('新增抽取配置');
const form = reactive<ExtractConfigSaveData>({
businessCode: '',
reportCode: '',
extractCode: '',
extractName: '',
sourceTableName: '',
sourceTableAlias: '',
targetTableName: '',
isEnabled: true,
extractType: 'INCREMENTAL',
extractMode: 'DIRECT',
extractKeyField: '',
batchSize: 1000,
status: 'ACTIVE',
groupByFields: [],
filterExpression: '',
operator: '',
});
const rules = {
extractCode: [{ required: true, message: '请输入抽取编码', trigger: 'blur' }],
extractName: [{ required: true, message: '请输入抽取名称', trigger: 'blur' }],
sourceTableName: [{ required: true, message: '请输入源表名', trigger: 'blur' }],
targetTableName: [{ required: true, message: '请输入目标表名', trigger: 'blur' }],
extractType: [{ required: true, message: '请选择抽取类型', trigger: 'change' }],
extractMode: [{ required: true, message: '请选择抽取模式', trigger: 'change' }],
};
const resetForm = () => {
form.id = undefined;
form.businessCode = props.businessCode;
form.reportCode = props.reportCode;
form.extractCode = '';
form.extractName = '';
form.sourceTableName = '';
form.sourceTableAlias = '';
form.targetTableName = '';
form.isEnabled = true;
form.extractType = 'INCREMENTAL';
form.extractMode = 'DIRECT';
form.extractKeyField = '';
form.batchSize = 1000;
form.status = 'ACTIVE';
form.groupByFields = [];
form.filterExpression = '';
form.operator = '';
};
watch(
() => props.visible,
async (val) => {
if (val) {
form.businessCode = props.businessCode;
form.reportCode = props.reportCode;
if (props.editId) {
dialogTitle.value = '编辑抽取配置';
loading.value = true;
try {
const res = await getExtractConfig(props.editId);
const raw = res.data || {};
const detail = raw.extractConfig || raw.data || raw;
form.id = detail.id;
form.businessCode = detail.businessCode || props.businessCode;
form.reportCode = detail.reportCode || props.reportCode;
form.extractCode = detail.extractCode || '';
form.extractName = detail.extractName || '';
form.sourceTableName = detail.sourceTableName || '';
form.sourceTableAlias = detail.sourceTableAlias || '';
form.targetTableName = detail.targetTableName || '';
form.isEnabled = detail.isEnabled !== undefined ? detail.isEnabled : true;
form.extractType = detail.extractType || 'INCREMENTAL';
form.extractMode = detail.extractMode || 'DIRECT';
form.extractKeyField = detail.extractKeyField || '';
form.batchSize = detail.batchSize || 1000;
form.status = detail.status || 'ACTIVE';
form.groupByFields = detail.groupByFields || [];
form.filterExpression = detail.filterExpression || '';
form.operator = detail.operator || '';
} catch (_e) {
ElMessage.error('获取抽取配置详情失败');
} finally {
loading.value = false;
}
} else {
dialogTitle.value = '新增抽取配置';
resetForm();
}
}
}
);
const handleCancel = () => {
emit('update:visible', false);
};
const handleSubmit = () => {
if (!formRef.value) return;
formRef.value.validate(async (valid) => {
if (!valid) return;
saving.value = true;
try {
const payload: any = { ...form };
if (!payload.id) delete payload.id;
await saveExtractConfig(payload);
ElMessage.success(props.editId ? '修改成功' : '新增成功');
emit('update:visible', false);
emit('confirm');
} catch (_e) {
ElMessage.error(props.editId ? '修改失败' : '新增失败');
} finally {
saving.value = false;
}
});
};
</script>
@@ -0,0 +1,398 @@
<template>
<div class="field-editor">
<div class="field-editor-header">
<span class="field-editor-title">字段配置</span>
<el-button size="small" type="primary" @click="onAddField">
<el-icon><ele-Plus /></el-icon> 新增字段
</el-button>
</div>
<!-- 字段列表 -->
<el-table
ref="tableRef"
:data="localFields"
row-key="fieldCode"
border
size="small"
class="field-table"
empty-text="暂无字段,请点击「新增字段」添加"
>
<el-table-column label="排序" width="56" align="center">
<template #default="scope">
<span
class="drag-handle"
draggable="true"
@dragstart="onDragStart($event, scope.$index)"
@dragover.prevent="onDragOver($event, scope.$index)"
@drop.prevent="onDrop($event, scope.$index)"
>
<el-icon><ele-Rank /></el-icon>
</span>
</template>
</el-table-column>
<el-table-column prop="sortOrder" label="序号" width="60" align="center" />
<el-table-column prop="fieldCode" label="字段编码" min-width="110" show-overflow-tooltip />
<el-table-column prop="fieldName" label="字段名称" min-width="110" show-overflow-tooltip />
<el-table-column prop="fieldType" label="类型" width="85" />
<el-table-column prop="fieldRole" label="角色" width="70" align="center">
<template #default="scope">
<el-tag :type="scope.row.fieldRole === 'DIMENSION' ? 'primary' : 'warning'" size="small" effect="plain">
{{ scope.row.fieldRole === 'DIMENSION' ? '维度' : '指标' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="defaultAggregate" label="默认聚合" width="100" show-overflow-tooltip />
<el-table-column prop="groupName" label="分组" width="90" show-overflow-tooltip />
<el-table-column prop="unit" label="单位" width="70" show-overflow-tooltip />
<el-table-column label="操作" width="120" fixed="right">
<template #default="scope">
<el-button size="small" text type="primary" @click="onEditField(scope.$index)">编辑</el-button>
<el-button size="small" text type="danger" @click="onDeleteField(scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 字段编辑弹窗 -->
<el-dialog
:title="fieldDialog.title"
v-model="fieldDialog.visible"
width="700px"
:close-on-click-modal="false"
destroy-on-close
append-to-body
>
<el-form ref="fieldFormRef" :model="fieldForm" :rules="fieldRules" label-width="100px" label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="字段编码" prop="fieldCode">
<el-input v-model="fieldForm.fieldCode" placeholder="如 shop_id" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="字段名称" prop="fieldName">
<el-input v-model="fieldForm.fieldName" placeholder="如 店铺ID" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="字段类型" prop="fieldType">
<el-select v-model="fieldForm.fieldType" style="width: 100%">
<el-option v-for="opt in FIELD_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="数据类型">
<el-select v-model="fieldForm.dataType" style="width: 100%" clearable>
<el-option v-for="opt in FIELD_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="字段角色" prop="fieldRole">
<el-select v-model="fieldForm.fieldRole" style="width: 100%">
<el-option label="维度 (DIMENSION)" value="DIMENSION" />
<el-option label="指标 (INDICATOR)" value="INDICATOR" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="默认聚合">
<el-select v-model="fieldForm.defaultAggregate" style="width: 100%" clearable placeholder="无">
<el-option label="无" value="" />
<el-option v-for="opt in AGGREGATE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="分组名称">
<el-input v-model="fieldForm.groupName" placeholder="如 基础信息" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="单位">
<el-input v-model="fieldForm.unit" placeholder="如 元、%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="状态">
<el-select v-model="fieldForm.status" style="width: 100%">
<el-option label="启用" value="ACTIVE" />
<el-option label="停用" value="INACTIVE" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序号">
<el-input-number v-model="fieldForm.sortOrder" :min="1" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="表达式">
<el-input v-model="fieldForm.expression" placeholder="衍生字段表达式,如 {refund}/{order}*100" />
</el-form-item>
<el-form-item label="属性">
<el-checkbox v-model="fieldForm.isAggregatable">可聚合</el-checkbox>
<el-checkbox v-model="fieldForm.isFilterable">可筛选</el-checkbox>
<el-checkbox v-model="fieldForm.isQueryable">可查询</el-checkbox>
<el-checkbox v-model="fieldForm.isSortable">可排序</el-checkbox>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="fieldDialog.visible = false">取消</el-button>
<el-button type="primary" @click="onFieldConfirm" :loading="fieldDialog.saving">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
export default { name: 'reportEngineFieldEditor' };
</script>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import type { FormInstance } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { FieldDefine } from '/@/api/report-engine/report';
const FIELD_TYPE_OPTIONS = [
{ value: 'STRING', label: 'STRING (字符串)' },
{ value: 'INT', label: 'INT (整数)' },
{ value: 'FLOAT', label: 'FLOAT (浮点)' },
{ value: 'BIGINT', label: 'BIGINT (长整)' },
{ value: 'DECIMAL', label: 'DECIMAL (小数)' },
{ value: 'DATE', label: 'DATE (日期)' },
{ value: 'DATETIME', label: 'DATETIME (时间)' },
{ value: 'BOOLEAN', label: 'BOOLEAN (布尔)' },
];
const AGGREGATE_OPTIONS = [
{ value: 'SUM', label: 'SUM (求和)' },
{ value: 'COUNT', label: 'COUNT (计数)' },
{ value: 'AVG', label: 'AVG (平均)' },
{ value: 'MAX', label: 'MAX (最大)' },
{ value: 'MIN', label: 'MIN (最小)' },
];
const props = defineProps<{
modelValue: FieldDefine[];
}>();
const emit = defineEmits<{
(e: 'update:modelValue', val: FieldDefine[]): void;
}>();
const tableRef = ref();
const fieldFormRef = ref<FormInstance>();
const localFields = ref<FieldDefine[]>([]);
let dragSourceIndex = -1;
watch(
() => props.modelValue,
(val) => {
localFields.value = JSON.parse(JSON.stringify(val || []));
},
{ immediate: true }
);
const emitUpdate = () => {
emit('update:modelValue', JSON.parse(JSON.stringify(localFields.value)));
};
// ========== ==========
const onDragStart = (event: DragEvent, index: number) => {
dragSourceIndex = index;
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
};
const onDragOver = (event: DragEvent, index: number) => {
if (dragSourceIndex === -1 || dragSourceIndex === index) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
};
const onDrop = (event: DragEvent, targetIndex: number) => {
if (dragSourceIndex === -1 || dragSourceIndex === targetIndex) return;
const list = localFields.value;
const [moved] = list.splice(dragSourceIndex, 1);
list.splice(targetIndex, 0, moved);
dragSourceIndex = -1;
reindexSortOrder();
emitUpdate();
};
const reindexSortOrder = () => {
localFields.value.forEach((f, i) => {
f.sortOrder = i + 1;
});
};
// ========== ==========
const fieldDialog = reactive({
visible: false,
title: '新增字段',
saving: false,
editingIndex: -1 as number,
});
const defaultFieldForm = () => ({
id: undefined,
fieldCode: '',
fieldName: '',
fieldType: 'STRING' as FieldDefine['fieldType'],
fieldRole: 'DIMENSION' as FieldDefine['fieldRole'],
dataType: '',
defaultAggregate: '',
groupName: '',
sortOrder: 1,
unit: '',
expression: '',
status: 'ACTIVE',
isAggregatable: false,
isFilterable: false,
isQueryable: false,
isSortable: false,
});
const fieldForm = reactive(defaultFieldForm());
const fieldRules = {
fieldCode: [
{ required: true, message: '请输入字段编码', trigger: 'blur' },
],
fieldName: [{ required: true, message: '请输入字段名称', trigger: 'blur' }],
fieldType: [{ required: true, message: '请选择字段类型', trigger: 'change' }],
fieldRole: [{ required: true, message: '请选择字段角色', trigger: 'change' }],
};
const onAddField = () => {
fieldDialog.title = '新增字段';
fieldDialog.editingIndex = -1;
resetFieldForm();
fieldDialog.visible = true;
};
const onEditField = (index: number) => {
fieldDialog.title = '编辑字段';
fieldDialog.editingIndex = index;
const item = localFields.value[index];
Object.assign(fieldForm, JSON.parse(JSON.stringify(item)));
fieldDialog.visible = true;
};
const onDeleteField = (index: number) => {
const item = localFields.value[index];
ElMessageBox.confirm(`确定要删除字段「${item.fieldName || item.fieldCode}」吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
localFields.value.splice(index, 1);
reindexSortOrder();
emitUpdate();
ElMessage.success('已删除');
})
.catch(() => {});
};
const resetFieldForm = () => {
const defaults = defaultFieldForm();
Object.assign(fieldForm, defaults);
if (localFields.value.length > 0) {
fieldForm.sortOrder = Math.max(...localFields.value.map((f) => f.sortOrder)) + 1;
} else {
fieldForm.sortOrder = 1;
}
};
const onFieldConfirm = () => {
if (!fieldFormRef.value) return;
fieldFormRef.value.validate(async (valid) => {
if (!valid) return;
fieldDialog.saving = true;
try {
const data: FieldDefine = {
id: fieldForm.id || undefined,
fieldCode: fieldForm.fieldCode,
fieldName: fieldForm.fieldName,
fieldType: fieldForm.fieldType,
fieldRole: fieldForm.fieldRole,
dataType: fieldForm.dataType || undefined,
defaultAggregate: fieldForm.defaultAggregate || undefined,
groupName: fieldForm.groupName || undefined,
sortOrder: fieldForm.sortOrder,
unit: fieldForm.unit || undefined,
expression: fieldForm.expression || undefined,
status: fieldForm.status || 'ACTIVE',
isAggregatable: fieldForm.isAggregatable,
isFilterable: fieldForm.isFilterable,
isQueryable: fieldForm.isQueryable,
isSortable: fieldForm.isSortable,
};
if (fieldDialog.editingIndex >= 0) {
localFields.value[fieldDialog.editingIndex] = data;
} else {
data.sortOrder = localFields.value.length + 1;
localFields.value.push(data);
reindexSortOrder();
}
emitUpdate();
fieldDialog.visible = false;
} finally {
fieldDialog.saving = false;
}
});
};
</script>
<style scoped lang="scss">
.field-editor {
margin-top: 16px;
border-top: 1px solid var(--el-border-color-light);
padding-top: 16px;
}
.field-editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.field-editor-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.field-table {
.drag-handle {
cursor: grab;
color: var(--el-text-color-placeholder);
font-size: 16px;
display: inline-block;
padding: 4px;
&:active {
cursor: grabbing;
}
&:hover {
color: var(--el-color-primary);
}
}
}
</style>
@@ -0,0 +1,196 @@
<template>
<el-dialog :title="dialogTitle" :model-value="visible" width="900px" :close-on-click-modal="false" destroy-on-close @update:model-value="emit('update:visible', $event)">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" v-loading="loading">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="报表编码" prop="reportCode">
<el-input v-model="form.reportCode" placeholder="如 shop_daily" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="报表名称" prop="reportName">
<el-input v-model="form.reportName" placeholder="如 店铺日报" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="报表描述" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="form.status" style="width: 100%">
<el-option label="启用" value="ACTIVE" />
<el-option label="停用" value="INACTIVE" />
</el-select>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="统计表名" prop="statTableName">
<el-input v-model="form.statTableName" placeholder="如 stat_kuaishou_shop_daily" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="表注释" prop="statTableComment">
<el-input v-model="form.statTableComment" placeholder="如 快手电商-店铺日报" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="日期字段" prop="dateField">
<el-input v-model="form.dateField" placeholder="如 stat_date" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="主键" prop="primaryKeys">
<el-input v-model="primaryKeysStr" placeholder="多个主键用逗号分隔,如 shop_id,stat_date" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="冲突键" prop="conflictKeys">
<el-input v-model="conflictKeysStr" placeholder="多个冲突键用逗号分隔,如 shop_id,stat_date" />
</el-form-item>
<!-- 字段编辑器 -->
<FieldEditor ref="fieldEditorRef" v-model="fields" />
</el-form>
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="saving">确定</el-button>
</template>
</el-dialog>
</template>
<script lang="ts">
export default { name: 'reportEngineReportForm' };
</script>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import { ElMessage, FormInstance } from 'element-plus';
import { getReport } from '/@/api/report-engine/report';
import type { FieldDefine } from '/@/api/report-engine/report';
import FieldEditor from './FieldEditor.vue';
const props = defineProps<{
visible: boolean;
editId?: number | null;
businessCode: string;
}>();
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void;
(e: 'confirm', data: any): void;
}>();
const formRef = ref<FormInstance>();
const fieldEditorRef = ref<InstanceType<typeof FieldEditor>>();
const saving = ref(false);
const loading = ref(false);
const dialogTitle = ref('新增报表');
const fields = ref<FieldDefine[]>([]);
const primaryKeysStr = ref('');
const conflictKeysStr = ref('');
const form = reactive({
reportCode: '',
reportName: '',
description: '',
status: 'ACTIVE',
statTableName: '',
statTableComment: '',
dateField: '',
primaryKeys: [] as string[],
conflictKeys: [] as string[],
});
const rules = {
reportCode: [{ required: true, message: '请输入报表编码', trigger: 'blur' }],
reportName: [{ required: true, message: '请输入报表名称', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
statTableName: [{ required: true, message: '请输入统计表名', trigger: 'blur' }],
dateField: [{ required: true, message: '请输入日期字段', trigger: 'blur' }],
};
watch(
() => props.visible,
async (val) => {
if (val) {
if (props.editId) {
dialogTitle.value = '编辑报表';
loading.value = true;
try {
const res = await getReport(props.editId);
const raw = res.data || {};
const detail = raw.report || raw.data || raw;
form.reportCode = detail.reportCode || '';
form.reportName = detail.reportName || '';
form.description = detail.description || '';
form.status = detail.status || 'ACTIVE';
form.statTableName = detail.statTableName || '';
form.statTableComment = detail.statTableComment || '';
form.dateField = detail.dateField || '';
form.primaryKeys = detail.primaryKeys || [];
form.conflictKeys = detail.conflictKeys || [];
primaryKeysStr.value = (detail.primaryKeys || []).join(',');
conflictKeysStr.value = (detail.conflictKeys || []).join(',');
fields.value = detail.fields || [];
} catch (_e) {
ElMessage.error('获取报表详情失败');
} finally {
loading.value = false;
}
} else {
dialogTitle.value = '新增报表';
resetForm();
}
}
}
);
const resetForm = () => {
form.reportCode = '';
form.reportName = '';
form.description = '';
form.status = 'ACTIVE';
form.statTableName = '';
form.statTableComment = '';
form.dateField = '';
form.primaryKeys = [];
form.conflictKeys = [];
primaryKeysStr.value = '';
conflictKeysStr.value = '';
fields.value = [];
};
const handleCancel = () => {
emit('update:visible', false);
};
const handleSubmit = () => {
if (!formRef.value) return;
formRef.value.validate(async (valid) => {
if (!valid) return;
saving.value = true;
try {
//
const toArray = (str: string) =>
str
.split(/[,]/)
.map((s) => s.trim())
.filter(Boolean);
emit('confirm', {
businessCode: props.businessCode,
...form,
id: props.editId || undefined,
primaryKeys: toArray(primaryKeysStr.value),
conflictKeys: toArray(conflictKeysStr.value),
fields: JSON.parse(JSON.stringify(fields.value)),
});
} finally {
saving.value = false;
}
});
};
</script>
+520
View File
@@ -0,0 +1,520 @@
<template>
<div class="report-engine-report-container">
<el-card shadow="hover">
<!-- 业务 Tab -->
<div class="biz-tabs-header mb15">
<el-tabs v-model="activeBusinessCode" type="card" @tab-click="handleTabClick" @tab-remove="handleTabRemove" class="biz-tabs">
<el-tab-pane
v-for="biz in businesses"
:key="biz.businessCode"
:name="biz.businessCode"
closable
>
<template #label>
<span class="custom-tab-label">
<span class="tab-name">{{ biz.businessName }}</span>
<el-icon class="tab-edit-btn" @click.stop="onOpenBusinessEdit(biz)">
<ele-EditPen />
</el-icon>
</span>
</template>
</el-tab-pane>
</el-tabs>
<el-button
size="default"
type="primary"
@click="onOpenBusinessAdd"
class="biz-add-btn"
>
<el-icon><ele-Plus /></el-icon>
<span>新增业务</span>
</el-button>
</div>
<!-- 搜索栏 -->
<div class="system-user-search mb15">
<el-form :model="tableData.param" ref="queryRef" :inline="true" label-width="80px">
<el-form-item label="报表名称" prop="reportName">
<el-input
v-model="tableData.param.reportName"
placeholder="请输入报表名称"
clearable
size="default"
style="width: 220px"
@keyup.enter.native="getList"
/>
</el-form-item>
<el-form-item>
<el-button size="default" type="primary" @click="getList">
<el-icon><ele-Search /></el-icon>
查询
</el-button>
<el-button size="default" @click="resetQuery">
<el-icon><ele-Refresh /></el-icon>
重置
</el-button>
<el-button size="default" type="success" @click="onOpenReportAdd">
<el-icon><ele-Plus /></el-icon>
新增报表
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 报表表格 -->
<el-table :data="tableData.data" style="width: 100%" v-loading="tableData.loading">
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="reportCode" label="报表编码" show-overflow-tooltip />
<el-table-column prop="reportName" label="报表名称" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 'ACTIVE' ? 'success' : 'danger'" size="small">
{{ scope.row.status === 'ACTIVE' ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" show-overflow-tooltip />
<el-table-column prop="statTableName" label="统计表名" show-overflow-tooltip />
<el-table-column label="操作" width="320" fixed="right">
<template #default="scope">
<el-button size="small" text type="primary" @click="onOpenReportEdit(scope.row)">编辑</el-button>
<el-button size="small" text type="primary" @click="onOpenExtractConfig(scope.row)">抽取配置</el-button>
<el-button size="small" text type="success" @click="onDailyExtract(scope.row)">按天回刷</el-button>
<el-button size="small" text type="danger" @click="onExtractConfigDel(scope.row)" v-if="scope.row.extractConfigIds">删除抽取配置</el-button>
<el-button size="small" text type="danger" @click="onReportDel(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<pagination
v-show="tableData.total > 0"
:total="tableData.total"
v-model:page="tableData.param.pageNum"
v-model:limit="tableData.param.pageSize"
@pagination="getList"
/>
</el-card>
<!-- 业务管理弹窗 -->
<BusinessManager
v-model:visible="businessDialog.visible"
:edit-data="businessDialog.editData"
@confirm="onBusinessConfirm"
/>
<!-- 报表表单弹窗 -->
<ReportForm
v-model:visible="reportDialog.visible"
:edit-id="reportDialog.editId"
:business-code="activeBusinessCode"
@confirm="onReportConfirm"
/>
<!-- 抽取配置弹窗 -->
<ExtractConfigForm
v-model:visible="extractDialog.visible"
:edit-id="extractDialog.editId"
:business-code="extractDialog.businessCode"
:report-code="extractDialog.reportCode"
:report-name="extractDialog.reportName"
@confirm="onExtractConfigConfirm"
/>
<!-- 按天回刷弹窗 -->
<el-dialog
v-model="dailyExtractDialog.visible"
title="按天回刷"
width="420px"
destroy-on-close
>
<el-form label-width="100px">
<el-form-item label="报表编码">
<el-input :model-value="dailyExtractDialog.reportCode" disabled />
</el-form-item>
<el-form-item label="日期范围" required>
<el-date-picker
v-model="dailyExtractDialog.dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
:disabled-date="(t: Date) => t > new Date()"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dailyExtractDialog.visible = false">取消</el-button>
<el-button type="primary" :loading="dailyExtractDialog.loading" @click="onDailyExtractConfirm">确认回刷</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
export default { name: 'reportEngineReport' };
</script>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { ElMessage, ElMessageBox, FormInstance } from 'element-plus';
import { listBusinesses, saveBusiness, deleteBusiness, saveReportWithFields, deleteReport, listReports, deleteExtractConfig, execDailyBackfill } from '/@/api/report-engine/report';
import BusinessManager from './components/BusinessManager.vue';
import ReportForm from './components/ReportForm.vue';
import ExtractConfigForm from './components/ExtractConfigForm.vue';
const queryRef = ref<FormInstance>();
// --- ---
const businesses = ref<any[]>([]);
const activeBusinessCode = ref('');
// --- ---
const tableData = reactive({
data: [] as any[],
total: 0,
loading: false,
param: {
pageNum: 1,
pageSize: 10,
reportName: '',
},
});
// --- ---
const businessDialog = reactive({
visible: false,
editData: null as { id?: number; businessCode: string; businessName: string } | null,
});
// --- ---
const reportDialog = reactive({
visible: false,
editId: null as number | null,
});
// --- ---
const extractDialog = reactive({
visible: false,
editId: null as number | null,
businessCode: '',
reportCode: '',
reportName: '',
});
// --- ---
const dailyExtractDialog = reactive({
visible: false,
businessCode: '',
reportCode: '',
dateRange: [] as string[],
loading: false,
});
// ========== ==========
const loadBusinesses = async () => {
try {
const res = await listBusinesses();
businesses.value = res.data?.list || res.data || [];
if (businesses.value.length > 0 && !activeBusinessCode.value) {
activeBusinessCode.value = businesses.value[0].businessCode;
getList();
}
} catch (_e) {
ElMessage.error('获取业务列表失败');
}
};
const handleTabClick = (pane?: any) => {
const code = pane?.props?.name;
if (code) activeBusinessCode.value = code;
tableData.param.pageNum = 1;
getList();
};
const handleTabRemove = (name: string) => {
const biz = businesses.value.find((b) => b.businessCode === name);
if (!biz || !biz.id) return;
const label = biz.businessName;
ElMessageBox.confirm(`确定要删除业务「${label}」吗?该操作同时会删除该业务下所有报表配置。`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
try {
await deleteBusiness(biz.id);
ElMessage.success('删除成功');
const idx = businesses.value.findIndex((b) => b.businessCode === name);
await loadBusinesses();
if (businesses.value.length > 0) {
const newIdx = Math.min(idx, businesses.value.length - 1);
activeBusinessCode.value = businesses.value[newIdx].businessCode;
} else {
activeBusinessCode.value = '';
tableData.data = [];
tableData.total = 0;
}
getList();
} catch (_e) {
ElMessage.error('删除失败');
}
})
.catch(() => {});
};
const onOpenBusinessAdd = () => {
businessDialog.editData = null;
businessDialog.visible = true;
};
const onOpenBusinessEdit = (biz: { id?: number; businessCode: string; businessName: string }) => {
businessDialog.editData = { ...biz };
businessDialog.visible = true;
};
const onBusinessConfirm = async (data: { id?: number; businessCode: string; businessName: string }) => {
try {
const isEdit = !!data.id;
await saveBusiness(data);
ElMessage.success(isEdit ? '修改成功' : '新增成功');
businessDialog.visible = false;
await loadBusinesses();
activeBusinessCode.value = data.businessCode;
getList();
} catch (_e) {
ElMessage.error(data.id ? '修改失败' : '新增失败');
}
};
// ========== ==========
const getList = async () => {
if (!activeBusinessCode.value) return;
tableData.loading = true;
try {
const res = await listReports(
activeBusinessCode.value,
tableData.param.pageNum,
tableData.param.pageSize,
tableData.param.reportName || undefined
);
const raw = res.data;
if (Array.isArray(raw)) {
tableData.data = raw;
tableData.total = raw.length;
} else if (raw) {
tableData.data = raw.list || raw.records || raw.rows || [];
tableData.total = raw.total || tableData.data.length;
}
} catch (_e) {
tableData.data = [];
tableData.total = 0;
} finally {
tableData.loading = false;
}
};
const resetQuery = () => {
if (!queryRef.value) return;
queryRef.value.resetFields();
tableData.param.pageNum = 1;
getList();
};
const onOpenReportAdd = () => {
reportDialog.editId = null;
reportDialog.visible = true;
};
const onOpenReportEdit = async (row: any) => {
reportDialog.editId = row.id;
reportDialog.visible = true;
};
const onReportConfirm = async (data: any) => {
try {
const isEdit = reportDialog.editId !== null;
await saveReportWithFields(data);
ElMessage.success(isEdit ? '修改成功' : '新增成功');
reportDialog.visible = false;
getList();
} catch (_e) {
ElMessage.error(reportDialog.editId ? '修改失败' : '新增失败');
}
};
const onReportDel = (row: any) => {
ElMessageBox.confirm(`确定要删除报表「${row.reportName}」吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
try {
const res = await deleteReport(row.id);
if (res.data?.success === false) {
//
ElMessage.warning(res.data?.message || '无法删除');
return;
}
ElMessage.success('删除成功');
getList();
} catch (_e) {
ElMessage.error('删除失败');
}
})
.catch(() => {});
};
// ========== ==========
const onOpenExtractConfig = (row: any) => {
extractDialog.businessCode = row.businessCode;
extractDialog.reportCode = row.reportCode;
extractDialog.reportName = row.reportName;
extractDialog.editId = row.extractConfigIds ? Number(row.extractConfigIds) : null;
extractDialog.visible = true;
};
const onExtractConfigConfirm = () => {
extractDialog.visible = false;
getList();
};
const onExtractConfigDel = (row: any) => {
ElMessageBox.confirm(`确定要删除「${row.reportName}」的抽取配置吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
try {
await deleteExtractConfig(Number(row.extractConfigIds));
ElMessage.success('删除成功');
getList();
} catch (_e) {
ElMessage.error('删除失败');
}
})
.catch(() => {});
};
// ========== ==========
const onDailyExtract = (row: any) => {
dailyExtractDialog.businessCode = row.businessCode;
dailyExtractDialog.reportCode = row.reportCode;
dailyExtractDialog.dateRange = [];
dailyExtractDialog.loading = false;
dailyExtractDialog.visible = true;
};
const onDailyExtractConfirm = async () => {
if (!dailyExtractDialog.dateRange || dailyExtractDialog.dateRange.length !== 2) {
ElMessage.warning('请选择日期范围');
return;
}
dailyExtractDialog.loading = true;
try {
const res = await execDailyBackfill({
businessCode: dailyExtractDialog.businessCode,
reportCode: dailyExtractDialog.reportCode,
startDate: dailyExtractDialog.dateRange[0],
endDate: dailyExtractDialog.dateRange[1],
});
if (res.data?.success === false) {
ElMessage.warning(res.data?.message || '回刷失败');
return;
}
ElMessage.success('回刷任务已提交');
dailyExtractDialog.visible = false;
} catch (_e) {
ElMessage.error('执行回刷失败');
} finally {
dailyExtractDialog.loading = false;
}
};
// ========== ==========
onMounted(() => {
loadBusinesses();
});
</script>
<style scoped lang="scss">
.biz-tabs-header {
display: flex;
align-items: stretch;
.biz-tabs {
flex: 1;
min-width: 0;
:deep(.el-tabs__header) {
margin-bottom: 0;
}
:deep(.el-tabs__content) {
display: none;
}
:deep(.el-tabs__item) {
position: relative;
height: 34px;
line-height: 34px;
padding: 0 10px;
//
.is-icon-close {
width: 14px !important;
overflow: visible !important;
opacity: 0.45;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}
}
}
.biz-add-btn {
flex-shrink: 0;
margin-left: 12px;
height: 34px;
align-self: flex-end;
}
}
.custom-tab-label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
.tab-name {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-edit-btn {
font-size: 13px;
cursor: pointer;
margin-left: 3px;
color: var(--el-text-color-placeholder);
transition: color 0.2s;
&:hover {
color: var(--el-color-primary);
}
}
}
</style>
@@ -0,0 +1,68 @@
<template>
<el-dialog v-model="visible" title="编辑提示词" width="720px" :close-on-click-modal="false" destroy-on-close @close="handleClose">
<div class="prompt-input-dialog">
<el-input v-model="promptContent" type="textarea" :rows="12" maxlength="5000" show-word-limit placeholder="请输入提示词内容" />
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleConfirm">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
interface Props {
modelValue: boolean;
defaultPrompt?: string;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'confirm', promptContent: string): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
defaultPrompt: '',
});
const emit = defineEmits<Emits>();
const visible = ref(false);
const promptContent = ref('');
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
promptContent.value = props.defaultPrompt || '';
}
}
);
watch(visible, (val) => {
if (!val) {
emit('update:modelValue', false);
}
});
const handleConfirm = () => {
emit('confirm', promptContent.value.trim());
handleClose();
};
const handleClose = () => {
visible.value = false;
};
</script>
<style scoped lang="scss">
.prompt-input-dialog {
display: flex;
flex-direction: column;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -240,18 +240,54 @@ const handleCreatePrivateModelAndSetChat = async () => {
// apiKey
const builtInModel = builtInModelToClone.value;
// Parse headMsg to Record<string, string> - it might be stored as string or already as object
let headMsgRecord: Record<string, string> = {};
if (builtInModel.headMsg && typeof builtInModel.headMsg === 'string') {
// Try to parse as JSON first (new format stored as string)
try {
const parsed = JSON.parse(builtInModel.headMsg);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
Object.entries(parsed).forEach(([k, v]) => {
headMsgRecord[k] = String(v);
});
}
} catch {
// If JSON parse fails, parse as old format "key1:value1,key2:value2"
const pairs = builtInModel.headMsg.split(',');
pairs.forEach((pair: string) => {
const idx = pair.indexOf(':');
if (idx === -1) return;
const key = pair.slice(0, idx).trim();
const value = pair.slice(idx + 1).trim();
if (key) {
headMsgRecord[key] = value;
}
});
}
} else if (builtInModel.headMsg && typeof builtInModel.headMsg === 'object' && !Array.isArray(builtInModel.headMsg)) {
// Already an object
Object.entries(builtInModel.headMsg).forEach(([k, v]) => {
headMsgRecord[k] = String(v);
});
}
const createParams = {
modelName: apiKeyForm.modelName,
modelType: builtInModel.modelType,
baseUrl: builtInModel.baseUrl,
httpMethod: builtInModel.httpMethod || 'POST',
headMsg: builtInModel.headMsg || '',
headMsg: headMsgRecord,
isPrivate: builtInModel.isPrivate ?? 1,
enabled: builtInModel.enabled ?? 1,
isChatModel: 1, //
callMode: builtInModel.callMode ?? builtInModel.isAsync ?? 0,
apiKey: apiKeyForm.apiKey,
form: builtInModel.form || {},
requestMapping: builtInModel.requestMapping || {},
requiredFields: Array.isArray(builtInModel.requiredFields) ? builtInModel.requiredFields : [],
firstFrame: String(builtInModel.firstFrame || ''),
lastFrame: String(builtInModel.lastFrame || ''),
responseMapping: builtInModel.responseMapping || {},
responseBody: builtInModel.responseBody || {},
tokenMapping: builtInModel.tokenMapping || '',
@@ -1,127 +0,0 @@
<template>
<div class="system-model-type-container layout-padding">
<el-card shadow="hover" class="layout-padding-auto">
<div class="system-model-type-search mb15">
<el-input v-model="state.tableData.param.keyword" size="default" placeholder="请输入模型类型名称" style="max-width: 180px" clearable> </el-input>
<el-button size="default" type="primary" class="ml10" @click="getTableData">
<el-icon>
<ele-Search />
</el-icon>
查询
</el-button>
<el-button size="default" type="success" class="ml10" @click="onOpenAddType('add')">
<el-icon>
<ele-FolderAdd />
</el-icon>
新增模型类型
</el-button>
</div>
<el-table :data="state.tableData.data" v-loading="state.tableData.loading" style="width: 100%">
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="typeName" label="类型名称" show-overflow-tooltip></el-table-column>
<el-table-column prop="typeCode" label="类型编码" show-overflow-tooltip></el-table-column>
<el-table-column prop="description" label="描述" show-overflow-tooltip></el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">{{ scope.row.status === 1 ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" show-overflow-tooltip></el-table-column>
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button size="small" text type="primary" @click="onOpenEditType('edit', scope.row)">修改</el-button>
<el-button size="small" text type="primary" @click="onRowDel(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="onHandleSizeChange"
@current-change="onHandleCurrentChange"
class="mt15"
:pager-count="5"
:page-sizes="[10, 20, 30]"
v-model:current-page="state.tableData.param.pageNum"
background
v-model:page-size="state.tableData.param.pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="state.tableData.total"
>
</el-pagination>
</el-card>
</div>
</template>
<script setup lang="ts" name="digitalHumanModelType">
import { reactive, onMounted } from 'vue';
import { ElMessageBox, ElMessage } from 'element-plus';
const state = reactive({
tableData: {
data: [],
total: 0,
loading: false,
param: {
pageNum: 1,
pageSize: 10,
keyword: '',
},
},
});
//
const getTableData = () => {
state.tableData.loading = true;
// TODO: API
setTimeout(() => {
state.tableData.data = [];
state.tableData.total = 0;
state.tableData.loading = false;
}, 500);
};
//
const onOpenAddType = (type: string) => {
ElMessage.info('功能开发中...');
};
//
const onOpenEditType = (type: string, row: any) => {
ElMessage.info('功能开发中...');
};
//
const onRowDel = (row: any) => {
ElMessage.info('功能开发中...');
};
//
const onHandleSizeChange = (val: number) => {
state.tableData.param.pageSize = val;
getTableData();
};
//
const onHandleCurrentChange = (val: number) => {
state.tableData.param.pageNum = val;
getTableData();
};
//
onMounted(() => {
getTableData();
});
</script>
<style scoped lang="scss">
.system-model-type-container {
:deep(.el-card__body) {
display: flex;
flex-direction: column;
flex: 1;
overflow: auto;
.el-table {
flex: 1;
}
}
}
</style>
@@ -0,0 +1,885 @@
<template>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="900px" :close-on-click-modal="false" destroy-on-close>
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px" v-loading="detailLoading">
<!-- ========== 基础信息 ========== -->
<el-divider content-position="left">基础信息</el-divider>
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="模型名称" prop="modelName">
<el-input v-model="formData.modelName" placeholder="请输入模型名称(唯一标识)" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="模型类型" prop="modelType">
<el-cascader
v-model="formData.modelType"
:options="modelTypes"
:props="{ expandTrigger: 'hover', value: 'value', label: 'label', children: 'children' }"
placeholder="请选择模型类型"
clearable
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="模型供应商" prop="modelSupplier">
<el-select
v-model="formData.modelSupplier"
placeholder="请选择模型供应商"
clearable
filterable
style="width: 100%"
:loading="supplierLoading"
>
<el-option
v-for="item in supplierList"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="服务地址" prop="baseUrl">
<el-input v-model="formData.baseUrl" placeholder="请输入模型服务地址" clearable />
</el-form-item>
</el-col>
</el-row>
<!-- ========== 调用配置 ========== -->
<el-divider content-position="left">调用配置</el-divider>
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="返回类型" prop="responseType">
<el-select v-model="formData.responseType" placeholder="请选择返回类型" clearable style="width: 100%" @change="handleResponseTypeChange">
<el-option label="同步" :value="1" />
<el-option label="异步" :value="2" />
<el-option label="流式" :value="3" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="API Key" prop="apiKey">
<el-input v-model="formData.apiKey" type="password" show-password placeholder="请输入调用凭证/密钥" clearable />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="对话模型" prop="chatModel">
<el-switch v-model="formData.chatModel" :disabled="!isReasoningModel" />
<span v-if="!isReasoningModel" class="form-hint">仅推理模型支持此设置</span>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="启用" prop="enabled">
<el-switch v-model="formData.enabled" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<!-- 占位对齐 -->
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="最大并发" prop="maxConcurrency">
<el-input-number v-model="formData.maxConcurrency" :min="1" :max="1000" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="最大 Token 数" prop="maxTokens">
<el-input-number v-model="formData.maxTokens" :min="0" :max="999999" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="Token 预估价格" prop="tokenPredictPrice">
<el-input-number v-model="formData.tokenPredictPrice" :min="0" :precision="4" :step="0.0001" style="width: calc(100% - 148px); margin-right: 8px" />
<el-select v-model="formData.tokenPredictPriceUnit" placeholder="单位" clearable style="width: 140px">
<el-option label="秒" value="second" />
<el-option label="百万 Token" value="per_million_tokens" />
<el-option label="千 Token" value="per_kilo_tokens" />
<el-option label="字数" value="per_word" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- ========== 映射配置 ========== -->
<el-divider content-position="left">映射配置</el-divider>
<el-row :gutter="20">
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
<el-form-item label="请求头映射">
<KeyValueEditor v-model="formData.requestHeadMapping" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6" class="mb20">
<el-form-item label="请求体映射">
<el-button @click="openMappingEditor('requestBodyMapping')" style="width: 100%">
配置 ({{ Object.keys(formData.requestBodyMapping).length }})
</el-button>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6" class="mb20">
<el-form-item label="响应映射">
<el-button @click="openMappingEditor('responseMapping')" style="width: 100%">
配置 ({{ Object.keys(formData.responseMapping).length }})
</el-button>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6" class="mb20">
<el-form-item label="错误消息映射">
<el-button @click="openMappingEditor('error_message_mapping')" style="width: 100%">
配置 ({{ Object.keys(formData.error_message_mapping || {}).length }})
</el-button>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6" class="mb20" v-if="formData.responseType !== 2">
<el-form-item label="响应主体映射">
<el-select
v-model="formData.responseBodyMapping"
multiple
:disabled="!hasMappingConfig"
:placeholder="mappingPlaceholder"
clearable
style="width: 100%"
>
<el-option
v-for="item in tokenMappingOptions"
:key="item.path"
:label="`${item.path} — ${item.label}`"
:value="item.path"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- ========== 异步专属 ========== -->
<template v-if="formData.responseType === 2">
<el-divider content-position="left">异步映射配置</el-divider>
<el-row :gutter="20">
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="异步响应映射">
<el-button @click="openMappingEditor('asyncResponseMapping')" style="width: 100%">
配置 ({{ Object.keys((formData.asyncTaskMapping.responseMapping || {})).length }})
</el-button>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="请求方法">
<el-select v-model="formData.asyncTaskMapping.httpMethod" style="width: 100%">
<el-option label="POST" value="POST" />
<el-option label="GET" value="GET" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="回调 URL">
<el-input v-model="formData.asyncTaskMapping.url" placeholder="请输入异步回调地址" clearable />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="请求体映射">
<el-button @click="openMappingEditor('asyncRequestBodyMapping')" style="width: 100%">
配置 ({{ Object.keys((formData.asyncTaskMapping.requestBodyMapping || {})).length }})
</el-button>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="响应主体映射">
<el-select
v-model="formData.responseBodyMapping"
multiple
:disabled="!hasMappingConfig"
:placeholder="mappingPlaceholder"
clearable
style="width: 100%"
>
<el-option
v-for="item in tokenMappingOptions"
:key="item.path"
:label="`${item.path} — ${item.label}`"
:value="item.path"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
<el-form-item label="异步请求头映射">
<KeyValueEditor v-model="formData.asyncTaskMapping.requestHeadMapping" />
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left" class="sub-divider">任务状态字段路径</el-divider>
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="Task ID 路径">
<el-select v-model="formData.asyncTaskMapping.taskId" :disabled="!hasTaskIdConfig" :placeholder="hasTaskIdConfig ? '请选择 Task ID 路径' : '请先配置响应映射'" clearable style="width: 100%">
<el-option v-for="item in taskIdOptions" :key="item.path" :label="`${item.path} — ${item.label}`" :value="item.path" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="任务状态路径">
<el-select v-model="formData.asyncTaskMapping.taskStatus" :disabled="!hasTaskStatusConfig" :placeholder="hasTaskStatusConfig ? '请选择任务状态路径' : '请先配置回调响应映射'" clearable style="width: 100%">
<el-option v-for="item in taskStatusOptions" :key="item.path" :label="`${item.path} — ${item.label}`" :value="item.path" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="待处理值">
<el-input v-model="formData.asyncTaskMapping.taskStatusPending" placeholder="pending" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="运行中值">
<el-input v-model="formData.asyncTaskMapping.taskStatusRunning" placeholder="running" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="成功值">
<el-input v-model="formData.asyncTaskMapping.taskStatusSuccess" placeholder="succeeded" clearable />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="失败值">
<el-input v-model="formData.asyncTaskMapping.taskStatusFailed" placeholder="failed" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="取消值">
<el-input v-model="formData.asyncTaskMapping.taskStatusCancel" placeholder="canceled" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="未知值">
<el-input v-model="formData.asyncTaskMapping.taskStatusUnknown" placeholder="unknown" clearable />
</el-form-item>
</el-col>
</el-row>
</template>
<el-divider content-position="left">Token 映射配置</el-divider>
<el-row :gutter="20">
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="输入 Token 路径">
<el-select v-model="formData.tokenMapping.promptTokens" :disabled="!hasMappingConfig" :placeholder="mappingPlaceholder" clearable style="width: 100%">
<el-option v-for="item in tokenMappingOptions" :key="item.path" :label="`${item.path} — ${item.label}`" :value="item.path" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="输出 Token 路径">
<el-select v-model="formData.tokenMapping.completionTokens" :disabled="!hasMappingConfig" :placeholder="mappingPlaceholder" clearable style="width: 100%">
<el-option v-for="item in tokenMappingOptions" :key="item.path" :label="`${item.path} — ${item.label}`" :value="item.path" />
</el-select>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<el-form-item label="总 Token 路径">
<el-select v-model="formData.tokenMapping.totalTokens" :disabled="!hasMappingConfig" :placeholder="mappingPlaceholder" clearable style="width: 100%">
<el-option v-for="item in tokenMappingOptions" :key="item.path" :label="`${item.path} — ${item.label}`" :value="item.path" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- ========== 视频配置 ========== -->
<template v-if="isVideoModel">
<el-divider content-position="left">视频配置</el-divider>
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="尾帧图像">
<el-input v-model="formData.lastFrame" placeholder="视频的尾帧图像 URL" clearable />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<el-form-item label="最大视频时长(秒)">
<el-input-number v-model="formData.maxDuration" :min="0" :max="999999" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
</template>
</el-form>
<!-- JSON 编辑器弹窗 -->
<el-dialog v-model="jsonEditorVisible" :title="jsonEditorTitle" width="1200px" top="5vh" :close-on-click-modal="false" destroy-on-close append-to-body>
<template #header>
<span>{{ jsonEditorTitle }}</span>
<div class="json-editor-toolbar">
<el-button size="small" @click="handlePasteJson">粘贴 JSON</el-button>
<el-button size="small" @click="handleViewJson">查看 JSON</el-button>
</div>
</template>
<div style="height: 520px; overflow-y: auto">
<JsonEditor ref="jsonEditorRef" v-model="jsonEditorData" />
</div>
<template #footer>
<el-button @click="jsonEditorVisible = false">取消</el-button>
<el-button type="primary" @click="confirmJsonEditor"> </el-button>
</template>
</el-dialog>
<!-- 查看 JSON 弹窗 -->
<el-dialog v-model="viewJsonVisible" title="查看 JSON" width="700px" :close-on-click-modal="false" destroy-on-close append-to-body>
<el-input type="textarea" :rows="18" v-model="viewJsonText" readonly />
<template #footer>
<el-button type="primary" @click="viewJsonVisible = false"> </el-button>
</template>
</el-dialog>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">{{ submitText }}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="modelConfigV2EditModule">
import { ref, reactive, computed, watch } from 'vue';
import { ElMessage } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
import { JsonEditor } from '/@/components/json-schema-editor';
import {
createModelManage,
updateModelManage,
getModelManage,
getModelSupplierList,
type ModelTypeTreeNode,
type ModelSupplierItem,
type TokenMapping,
type AsyncTaskMapping,
} from '/@/api/settings/modelConfigV2';
import KeyValueEditor from './keyValueEditor.vue';
const props = withDefaults(
defineProps<{
modelTypes?: ModelTypeTreeNode[];
}>(),
{
modelTypes: () => [] as ModelTypeTreeNode[],
}
);
const emit = defineEmits<{
refresh: [];
}>();
const formRef = ref<FormInstance>();
const dialogVisible = ref(false);
const submitting = ref(false);
const detailLoading = ref(false);
const isEdit = ref(false);
const editId = ref('');
const supplierList = ref<ModelSupplierItem[]>([]);
const supplierLoading = ref(false);
const dialogTitle = computed(() => (isEdit.value ? '修改模型配置' : '新增模型配置'));
const submitText = computed(() => (isEdit.value ? '保存修改' : '确 定'));
const defaultTokenMapping: TokenMapping = {
promptTokens: '',
completionTokens: '',
totalTokens: '',
};
const defaultAsyncTaskMapping: AsyncTaskMapping = {
url: '',
httpMethod: 'POST',
requestHeadMapping: {},
requestBodyMapping: {},
responseMapping: {},
taskId: '',
taskStatus: '',
taskStatusPending: '',
taskStatusRunning: '',
taskStatusSuccess: '',
taskStatusFailed: '',
taskStatusCancel: '',
taskStatusUnknown: '',
};
const formData = reactive({
modelName: '',
modelType: [] as number[],
modelSupplier: undefined as number | undefined,
baseUrl: '',
apiKey: '',
responseType: undefined as number | undefined,
enabled: true,
chatModel: false,
maxConcurrency: 10,
maxTokens: 4096,
tokenPredictPrice: 0,
//
requestHeadMapping: {} as Record<string, unknown>,
requestBodyMapping: {} as Record<string, unknown>,
error_message_mapping: {} as Record<string, unknown>,
responseMapping: {} as Record<string, unknown>,
responseBodyMapping: [] as string[],
// Token
tokenMapping: { ...defaultTokenMapping },
//
asyncTaskMapping: { ...defaultAsyncTaskMapping },
//
lastFrame: '',
maxDuration: 0,
tokenPredictPriceUnit: '',
});
/** 获取选中模型类型路径上的所有标签 */
function getSelectedModelLabels(): string[] {
const path = formData.modelType;
if (!path || path.length === 0) return [];
const labels: string[] = [];
let currentLevel = props.modelTypes;
for (let i = 0; i < path.length; i++) {
const found = currentLevel.find((n) => n.value === path[i]);
if (!found) break;
labels.push(found.label);
currentLevel = found.children || [];
}
return labels;
}
/** 从模型类型树中查找叶子值对应的完整路径(el-cascader 需要路径而非叶子值) */
function findModelTypePath(value: number, tree: ModelTypeTreeNode[]): number[] {
for (const node of tree) {
if (node.value === value) return [value];
if (node.children) {
const found = node.children.find((c) => c.value === value);
if (found) return [node.value, value];
}
}
return [value];
}
/** 判断是否是视频模型类型 */
const isVideoModel = computed(() => {
return getSelectedModelLabels().some((l) => l.includes('视频') || l.toLowerCase().includes('video'));
});
/** 判断是否是推理模型类型 */
const isReasoningModel = computed(() => {
return getSelectedModelLabels().some((l) => l.includes('推理') || l.toLowerCase().includes('reasoning'));
});
/** 非推理模型时自动关闭对话模型开关 */
watch(isReasoningModel, (val) => {
if (!val) formData.chatModel = false;
});
/** 递归提取 { type, attrs/value } 包裹结构中的所有叶子字段 */
function flattenWrappedFields(obj: any, basePath: string = ''): { path: string; label: string }[] {
const results: { path: string; label: string }[] = [];
if (!obj || typeof obj !== 'object') return results;
// { type, attrs/value }
if (typeof obj.type === 'string' && ['string','number','boolean','null','object','array'].includes(obj.type)) {
const jtype = obj.type;
const dataKey = (jtype === 'object' || jtype === 'array') ? 'attrs' : 'value';
if (jtype === 'object' && dataKey in obj && obj[dataKey] && typeof obj[dataKey] === 'object' && !Array.isArray(obj[dataKey])) {
for (const [key, val] of Object.entries(obj[dataKey])) {
const childPath = basePath ? basePath + '.attrs.' + key : key;
results.push(...flattenWrappedFields(val, childPath));
}
} else if (jtype === 'array' && dataKey in obj && Array.isArray(obj[dataKey])) {
obj[dataKey].forEach((item: any, idx: number) => {
results.push(...flattenWrappedFields(item, basePath + '.attrs[' + idx + ']'));
});
} else if (['string', 'number', 'boolean', 'null'].includes(jtype)) {
results.push({ path: basePath, label: obj.label || '' });
}
return results;
}
//
if (!Array.isArray(obj)) {
for (const [key, val] of Object.entries(obj)) {
const childPath = basePath ? basePath + '.' + key : key;
results.push(...flattenWrappedFields(val, childPath));
}
}
return results;
}
/** 根据返回类型获取 Token 映射下拉选项(响应映射的 key — 路径) */
const tokenMappingOptions = computed(() => {
const source = formData.responseType === 2
? formData.asyncTaskMapping.responseMapping
: formData.responseMapping;
return source ? flattenWrappedFields(source) : [];
});
/** 是否有可用的映射配置 */
const hasMappingConfig = computed(() => tokenMappingOptions.value.length > 0);
/** 下拉占位文本 */
const mappingPlaceholder = computed(() => {
if (hasMappingConfig.value) return '请选择';
return formData.responseType === 2 ? '请先配置回调响应映射' : '请先配置响应映射';
});
/** Task ID 路径下拉选项(来源响应映射) */
const taskIdOptions = computed(() => {
const source = formData.responseMapping;
return source ? flattenWrappedFields(source) : [];
});
/** 是否有 Task ID 映射配置 */
const hasTaskIdConfig = computed(() => taskIdOptions.value.length > 0);
/** 任务状态路径下拉选项(来源回调响应映射) */
const taskStatusOptions = computed(() => {
const source = formData.asyncTaskMapping.responseMapping;
return source ? flattenWrappedFields(source) : [];
});
/** 是否有任务状态映射配置 */
const hasTaskStatusConfig = computed(() => taskStatusOptions.value.length > 0);
/** 用户手动切换返回类型时清空 token 映射(编程赋值不触发) */
function handleResponseTypeChange() {
formData.tokenMapping = { ...defaultTokenMapping };
formData.responseBodyMapping = [];
}
/** JSON 编辑器弹窗状态 */
const jsonEditorVisible = ref(false);
const jsonEditorData = ref<Record<string, unknown>>({});
const editingMappingKey = ref<string>('');
const jsonEditorRef = ref();
const mappingFieldLabels: Record<string, string> = {
requestHeadMapping: '请求头映射',
requestBodyMapping: '请求体映射',
responseMapping: '响应映射',
error_message_mapping: '错误消息映射',
asyncResponseMapping: '异步-响应映射',
asyncRequestBodyMapping: '异步-请求体映射',
};
const jsonEditorTitle = computed(() => {
const label = mappingFieldLabels[editingMappingKey.value] || editingMappingKey.value;
return `编辑 ${label}`;
});
/** 获取映射字段的引用 */
function getMappingValue(key: string): Record<string, unknown> {
if (key === 'asyncResponseMapping') return formData.asyncTaskMapping.responseMapping;
if (key === 'asyncRequestBodyMapping') return formData.asyncTaskMapping.requestBodyMapping || {};
return (formData as Record<string, unknown>)[key] as Record<string, unknown>;
}
/** 设置映射字段的值 */
function setMappingValue(key: string, value: Record<string, unknown>) {
if (key === 'asyncResponseMapping') {
formData.asyncTaskMapping.responseMapping = value;
} else if (key === 'asyncRequestBodyMapping') {
formData.asyncTaskMapping.requestBodyMapping = value;
} else {
(formData as Record<string, unknown>)[key] = value;
}
}
const openMappingEditor = (key: string) => {
editingMappingKey.value = key;
jsonEditorData.value = { ...getMappingValue(key) };
jsonEditorVisible.value = true;
};
/** schema value null
* 模型配置保存的是结构模板保留 value 键与结构defaultValue 等配置但数据值不预设
* defaultValue 已有专门字段承载默认值value 不再携带任何值统一置 null
* 根节点经 nodeToValue 输出时不带 type/attrs 包装普通对象故需对无 type 节点也递归子键
*/
function resetSchemaValues(schema: any): any {
if (schema === null || schema === undefined) return schema;
if (Array.isArray(schema)) {
schema.forEach((item, i) => {
schema[i] = resetSchemaValues(item);
});
return schema;
}
if (typeof schema !== 'object') return schema;
if (typeof schema.type === 'string') {
switch (schema.type) {
case 'object':
if (schema.attrs && typeof schema.attrs === 'object') {
Object.keys(schema.attrs).forEach((k) => {
schema.attrs[k] = resetSchemaValues(schema.attrs[k]);
});
}
break;
case 'array':
if (Array.isArray(schema.attrs)) {
schema.attrs = schema.attrs.map((item: any) => resetSchemaValues(item));
}
break;
case 'string':
case 'number':
case 'boolean':
case 'null':
schema.value = null;
break;
}
return schema;
}
// object
Object.keys(schema).forEach((k) => {
schema[k] = resetSchemaValues(schema[k]);
});
return schema;
}
const confirmJsonEditor = () => {
const cleaned = resetSchemaValues(JSON.parse(JSON.stringify(jsonEditorData.value)));
setMappingValue(editingMappingKey.value, cleaned);
jsonEditorVisible.value = false;
};
const handlePasteJson = () => {
jsonEditorRef.value?.openPasteDialog();
};
const handleViewJson = () => {
viewJsonText.value = JSON.stringify(jsonEditorData.value, null, 2);
viewJsonVisible.value = true;
};
/** 查看 JSON 弹窗 */
const viewJsonVisible = ref(false);
const viewJsonText = ref('');
const formRules: FormRules = {
modelName: [{ required: true, message: '请输入模型名称', trigger: 'blur' }],
modelType: [
{
validator: (_rule: unknown, value: unknown, callback: (e?: Error) => void) => {
if (!value || (Array.isArray(value) && value.length === 0)) {
callback(new Error('请选择模型类型'));
} else {
callback();
}
},
trigger: 'change',
},
],
modelSupplier: [{ required: true, message: '请选择模型供应商', trigger: 'change' }],
baseUrl: [{ required: true, message: '请输入模型服务地址', trigger: 'blur' }],
responseType: [{ required: true, message: '请选择返回类型', trigger: 'change' }],
};
const loadSupplierList = async () => {
supplierLoading.value = true;
try {
const res = await getModelSupplierList();
supplierList.value = res.data?.list || [];
} catch {
supplierList.value = [];
} finally {
supplierLoading.value = false;
}
};
const resetForm = () => {
formData.modelName = '';
formData.modelType = [];
formData.modelSupplier = undefined;
formData.baseUrl = '';
formData.apiKey = '';
formData.responseType = undefined;
formData.enabled = true;
formData.chatModel = false;
formData.maxConcurrency = 10;
formData.maxTokens = 4096;
formData.tokenPredictPrice = 0;
formData.requestHeadMapping = {};
formData.requestBodyMapping = {};
formData.error_message_mapping = {};
formData.responseMapping = {};
formData.responseBodyMapping = [];
formData.tokenMapping = { ...defaultTokenMapping };
formData.asyncTaskMapping = { ...defaultAsyncTaskMapping };
formData.lastFrame = '';
formData.maxDuration = 0;
formData.tokenPredictPriceUnit = '';
};
const openDialog = async (type: 'add' | 'edit', id?: string) => {
resetForm();
isEdit.value = type === 'edit';
detailLoading.value = type === 'edit';
loadSupplierList();
if (type === 'edit' && id) {
editId.value = id;
try {
const res = await getModelManage(id);
const row = res.data.modelManage;
formData.modelName = row.modelName;
formData.modelType = findModelTypePath(row.modelType, props.modelTypes);
formData.modelSupplier = Number(row.modelSupplier) || undefined;
formData.baseUrl = row.baseUrl;
formData.apiKey = row.apiKey || '';
formData.responseType = row.responseType ?? row.invokeType;
formData.enabled = row.enabled ?? true;
formData.chatModel = row.chatModel ?? row.ChatModel ?? false;
formData.maxConcurrency = row.maxConcurrency || 10;
formData.maxTokens = row.maxTokens || 4096;
formData.tokenPredictPrice = row.tokenPredictPrice ?? 0;
formData.requestHeadMapping = row.requestHeadMapping || row.requestMapping || {};
formData.requestBodyMapping = row.requestBodyMapping || {};
formData.error_message_mapping = row.error_message_mapping || {};
formData.responseMapping = row.responseMapping || {};
formData.responseBodyMapping = row.responseBodyMapping
? (Array.isArray(row.responseBodyMapping) ? row.responseBodyMapping : Object.keys(row.responseBodyMapping))
: [];
formData.tokenMapping = row.tokenMapping ? { ...defaultTokenMapping, ...row.tokenMapping } : { ...defaultTokenMapping };
formData.asyncTaskMapping = row.asyncTaskMapping
? {
...defaultAsyncTaskMapping,
...row.asyncTaskMapping,
requestHeadMapping: row.asyncTaskMapping.requestHeadMapping || {},
requestBodyMapping: row.asyncTaskMapping.requestBodyMapping || {},
responseMapping: row.asyncTaskMapping.responseMapping || {},
}
: { ...defaultAsyncTaskMapping };
formData.lastFrame = row.lastFrame || '';
formData.maxDuration = row.maxDuration ?? 0;
formData.tokenPredictPriceUnit = row.tokenPredictPriceUnit || '';
} catch {
//
dialogVisible.value = false;
return;
} finally {
detailLoading.value = false;
}
}
dialogVisible.value = true;
};
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
submitting.value = true;
try {
// value label label
const responseBodyLabelMap = new Map<string, string>(
tokenMappingOptions.value.map((o) => [o.path, o.label || o.path])
);
const baseParams = {
modelName: formData.modelName,
modelType: Array.isArray(formData.modelType) ? formData.modelType[formData.modelType.length - 1] : formData.modelType,
modelSupplier: formData.modelSupplier as number,
baseUrl: formData.baseUrl,
responseType: formData.responseType as number,
apiKey: formData.apiKey || undefined,
enabled: formData.enabled,
chatModel: formData.chatModel,
maxConcurrency: formData.maxConcurrency,
maxTokens: formData.maxTokens,
tokenPredictPrice: formData.tokenPredictPrice,
requestHeadMapping: Object.keys(formData.requestHeadMapping).length > 0 ? formData.requestHeadMapping : undefined,
requestBodyMapping: Object.keys(formData.requestBodyMapping).length > 0 ? formData.requestBodyMapping : undefined,
error_message_mapping: Object.keys(formData.error_message_mapping).length > 0 ? formData.error_message_mapping : undefined,
responseMapping: Object.keys(formData.responseMapping).length > 0 ? formData.responseMapping : undefined,
responseBodyMapping: formData.responseBodyMapping.length > 0
? Object.fromEntries(formData.responseBodyMapping.map((k) => [k, responseBodyLabelMap.get(k) || k]))
: undefined,
};
//
let tokenMapping: TokenMapping | undefined;
let asyncTaskMapping: AsyncTaskMapping | undefined;
let lastFrame: string | undefined;
// Token key
const hasTokenConfig = Object.values(formData.tokenMapping).some((v) => v);
tokenMapping = hasTokenConfig ? { ...formData.tokenMapping } : undefined;
if (formData.responseType === 2) {
const atm = formData.asyncTaskMapping;
const hasAsyncConfig =
atm.url ||
Object.keys(atm.requestHeadMapping).length > 0 ||
Object.keys(atm.requestBodyMapping || {}).length > 0 ||
atm.taskId;
asyncTaskMapping = hasAsyncConfig ? ({ ...atm } as AsyncTaskMapping) : undefined;
}
if (isVideoModel.value && formData.lastFrame) {
lastFrame = formData.lastFrame;
}
let maxDuration: number | undefined;
let tokenPredictPriceUnit: string | undefined;
if (isVideoModel.value) {
maxDuration = formData.maxDuration || undefined;
}
if (formData.tokenPredictPriceUnit) {
tokenPredictPriceUnit = formData.tokenPredictPriceUnit;
}
const params: Record<string, unknown> = {
...baseParams,
...(tokenMapping ? { tokenMapping } : {}),
...(asyncTaskMapping ? { asyncTaskMapping } : {}),
...(lastFrame ? { lastFrame } : {}),
...(maxDuration ? { maxDuration } : {}),
...(tokenPredictPriceUnit ? { tokenPredictPriceUnit } : {}),
};
if (isEdit.value) {
await updateModelManage({ id: editId.value, ...params } as any);
ElMessage.success('修改成功');
} else {
await createModelManage(params as any);
ElMessage.success('新增成功');
}
dialogVisible.value = false;
emit('refresh');
} catch {
// request
} finally {
submitting.value = false;
}
});
};
defineExpose({ openDialog });
</script>
<style scoped lang="scss">
.mb20 {
margin-bottom: 18px;
}
::v-deep(.sub-divider .el-divider__text) {
font-size: 13px;
color: #909399;
}
.json-editor-toolbar {
display: inline-flex;
gap: 8px;
margin-left: 16px;
}
</style>
@@ -0,0 +1,93 @@
<template>
<div class="kv-editor">
<div v-for="(item, index) in kvList" :key="index" class="kv-row">
<el-input v-model="item.key" placeholder="key" size="small" class="kv-key" @input="emitUpdate" />
<el-input v-model="item.value" placeholder="value" size="small" class="kv-value" @input="emitUpdate" />
<el-button size="small" text type="danger" class="kv-del" @click="removeRow(index)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<el-button size="small" class="kv-add" @click="addRow">
<el-icon><Plus /></el-icon> 添加
</el-button>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Plus, Delete } from '@element-plus/icons-vue';
const props = defineProps<{
modelValue: Record<string, unknown>;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', val: Record<string, unknown>): void;
}>();
const kvList = ref<{ key: string; value: string }[]>([]);
function toKvList(obj: Record<string, unknown>): { key: string; value: string }[] {
return Object.entries(obj || {}).map(([k, v]) => ({
key: k,
value: typeof v === 'string' ? v : JSON.stringify(v),
}));
}
function toRecord(list: { key: string; value: string }[]): Record<string, unknown> {
const obj: Record<string, unknown> = {};
for (const item of list) {
if (item.key.trim()) {
obj[item.key.trim()] = item.value;
}
}
return obj;
}
function emitUpdate() {
emit('update:modelValue', toRecord(kvList.value));
}
function addRow() {
kvList.value.push({ key: '', value: '' });
}
function removeRow(index: number) {
kvList.value.splice(index, 1);
emitUpdate();
}
watch(
() => props.modelValue,
(val) => {
kvList.value = toKvList(val);
},
{ immediate: true, deep: true }
);
</script>
<style scoped lang="scss">
.kv-editor {
width: 100%;
.kv-row {
display: flex;
gap: 6px;
margin-bottom: 6px;
align-items: center;
.kv-key {
flex: 1;
min-width: 0;
}
.kv-value {
flex: 2;
min-width: 0;
}
.kv-del {
flex-shrink: 0;
}
}
.kv-add {
font-size: 12px;
}
}
</style>
+242
View File
@@ -0,0 +1,242 @@
<template>
<div class="system-user-container layout-padding">
<el-card shadow="hover" class="layout-padding-auto">
<div class="system-user-search mb15">
<el-input
v-model="searchParams.modelName"
size="default"
placeholder="请输入模型名称"
style="max-width: 180px"
clearable
@clear="handleSearch"
/>
<el-cascader
v-model="selectedCascaderPath"
:options="modelTypes"
:props="{ expandTrigger: 'hover' }"
clearable
placeholder="请选择模型类型"
style="max-width: 180px"
class="ml10"
@change="onCascaderChange"
/>
<el-button size="default" type="primary" class="ml10" @click="handleSearch">
<el-icon><Search /></el-icon>
查询
</el-button>
<el-button size="default" type="success" class="ml10" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增模型配置
</el-button>
</div>
<el-table :data="modelList" v-loading="loading" stripe style="width: 100%" class="fixed-table">
<el-table-column type="index" label="序号" width="55" />
<el-table-column prop="modelName" label="模型名称" show-overflow-tooltip />
<el-table-column label="模型类型" show-overflow-tooltip>
<template #default="{ row }">
{{ resolveModelTypeLabel(row.modelType) }}
</template>
</el-table-column>
<el-table-column label="模型归属">
<template #default="scope">
<el-tag :type="scope.row.systemModel ? 'warning' : 'success'">{{ scope.row.systemModel ? '系统' : '用户' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="供应商" show-overflow-tooltip>
<template #default="scope">
{{ resolveSupplierLabel(scope.row.modelSupplier) }}
</template>
</el-table-column>
<el-table-column label="状态">
<template #default="scope">
<el-tag :type="scope.row.enabled ? 'success' : 'danger'">{{ scope.row.enabled ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" show-overflow-tooltip />
<el-table-column prop="updatedAt" label="更新时间" show-overflow-tooltip />
<el-table-column label="操作" fixed="right">
<template #default="scope">
<el-button size="small" text type="primary" @click="handleEdit(scope.row)">修改</el-button>
<el-button size="small" text type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="handleSizeChange"
@current-change="handlePageChange"
class="mt15"
:pager-count="5"
:page-sizes="[10, 20, 30, 50]"
v-model:current-page="pagination.pageNum"
background
v-model:page-size="pagination.pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
/>
</el-card>
<EditModule ref="editModuleRef" :model-types="modelTypes" @refresh="fetchModelList" />
</div>
</template>
<script setup lang="ts" name="modelConfigV2">
import { ref, reactive, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Search, Plus } from '@element-plus/icons-vue';
import EditModule from './component/editModule.vue';
import { listModelManage, getModelManageTypes, deleteModelManage, getModelSupplierList, type ModelManageItem, type ModelTypeTreeNode, type ModelSupplierItem } from '/@/api/settings/modelConfigV2';
const searchParams = reactive({
modelName: '',
modelType: undefined as number | undefined,
});
const pagination = reactive({
pageNum: 1,
pageSize: 10,
total: 0,
});
const modelList = ref<ModelManageItem[]>([]);
const loading = ref(false);
const modelTypes = ref<ModelTypeTreeNode[]>([]);
const supplierList = ref<ModelSupplierItem[]>([]);
const editModuleRef = ref();
/** 级联选择器的选中路径(例:[200, 201]) */
const selectedCascaderPath = ref<number[]>([]);
/** 级联选择器值变化时,取叶子节点的值作为筛选参数 */
const onCascaderChange = (val: number[]) => {
searchParams.modelType = val.length > 0 ? val[val.length - 1] : undefined;
};
/** 从树形结构中查找标签(含一级/二级路径) */
const resolveModelTypeLabel = (modelType: number | string | undefined | null): string => {
if (modelType === undefined || modelType === null || modelType === '') {
return '—';
}
const key = String(modelType).trim();
for (const group of modelTypes.value) {
if (group.children) {
const child = group.children.find((c) => String(c.value).trim() === key);
if (child) return `${group.label} / ${child.label}`;
}
if (String(group.value).trim() === key) {
return group.label;
}
}
return String(modelType);
};
/** 加载模型类型选项(树形结构,供 el-cascader 使用) */
const loadModelTypes = async () => {
try {
const res = await getModelManageTypes();
modelTypes.value = res.data?.list || [];
} catch {
modelTypes.value = [];
}
};
/** 加载模型供应商列表 */
const loadSupplierList = async () => {
try {
const res = await getModelSupplierList();
supplierList.value = res.data?.list || [];
} catch {
supplierList.value = [];
}
};
/** 根据供应商值显示名称 */
const resolveSupplierLabel = (supplier: number | undefined | null): string => {
if (supplier === undefined || supplier === null) return '—';
return supplierList.value.find((s) => s.value === supplier)?.label ?? String(supplier);
};
/** 获取模型列表 */
const fetchModelList = async () => {
loading.value = true;
try {
const params = {
pageNum: pagination.pageNum,
pageSize: pagination.pageSize,
modelName: searchParams.modelName || undefined,
modelType: searchParams.modelType,
};
const res = await listModelManage(params);
modelList.value = res.data?.list || [];
pagination.total = res.data?.total || 0;
} catch {
modelList.value = [];
pagination.total = 0;
} finally {
loading.value = false;
}
};
const handleAdd = () => {
editModuleRef.value?.openDialog('add');
};
const handleSearch = () => {
pagination.pageNum = 1;
fetchModelList();
};
const handlePageChange = (page: number) => {
pagination.pageNum = page;
fetchModelList();
};
const handleSizeChange = (size: number) => {
pagination.pageSize = size;
pagination.pageNum = 1;
fetchModelList();
};
const handleEdit = (row: ModelManageItem) => {
editModuleRef.value?.openDialog('edit', row.id)
};
const handleDelete = (row: ModelManageItem) => {
ElMessageBox.confirm(`确定要删除模型"${row.modelName}"吗?`, '删除确认', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
try {
await deleteModelManage(row.id);
ElMessage.success('删除成功');
fetchModelList();
} catch {
// request
}
})
.catch(() => {});
};
onMounted(async () => {
await loadModelTypes();
loadSupplierList();
fetchModelList();
});
</script>
<style scoped lang="scss">
.system-user-container {
:deep(.el-card__body) {
display: flex;
flex-direction: column;
flex: 1;
overflow: auto;
.el-table.fixed-table {
flex: 1;
table-layout: fixed;
}
}
}
</style>
+4 -4
View File
@@ -26,8 +26,8 @@
</el-table-column>
<el-table-column prop="sourceType" label="来源" width="100">
<template #default="{ row }">
<el-tag v-if="row.sourceType === 0">自定义</el-tag>
<el-tag v-else-if="row.sourceType === 1" type="success">公共</el-tag>
<el-tag v-if="row.sourceType === 1" type="success">管理员</el-tag>
<el-tag v-else-if="row.sourceType === 2">用户</el-tag>
<el-tag v-else type="info">{{ row.sourceType }}</el-tag>
</template>
</el-table-column>
@@ -128,7 +128,7 @@ const nodeOptions = computed(() => {
const createForm = reactive({
nodeType: '',
prompt: '',
sourceType: 0 as number,
sourceType: 2 as number,
});
//
@@ -269,7 +269,7 @@ const cancelCreate = () => {
editId.value = null;
createForm.nodeType = '';
createForm.prompt = '';
createForm.sourceType = 0;
createForm.sourceType = 2;
};
//
@@ -0,0 +1,114 @@
<template>
<div class="flow-node" :class="`flow-node--${variant}`">
<!-- target / 开始节点不可作为 target按约束禁止被连接 -->
<template v-if="!isStartNode">
<Handle type="target" :position="Position.Top" id="target-top" />
<Handle type="target" :position="Position.Left" id="target-left" />
</template>
<!-- source / -->
<Handle type="source" :position="Position.Right" id="source-right" />
<Handle type="source" :position="Position.Bottom" id="source-bottom" />
<span class="flow-node-label">{{ props.data?.label }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Handle, Position, type NodeProps } from '@vue-flow/core';
const props = defineProps<NodeProps<{ label?: string; nodeCode?: string }>>();
const variantByCode: Record<string, string> = {
__start__: 'start',
model: 'model',
http: 'http',
form: 'form',
judge: 'judge',
data_merge: 'merge',
sub_flow: 'subflow',
script_transcribe: 'transcribe',
};
const isStartNode = computed(() => props.data?.nodeCode === '__start__');
const variant = computed(() => variantByCode[props.data?.nodeCode || ''] || 'default');
</script>
<style scoped lang="scss">
// position:relative Handle .vue-flow__node
.flow-node {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 100px;
max-width: 220px;
padding: 10px 16px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
line-height: 1.4;
word-break: break-word;
color: #fff;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
transition: box-shadow 0.2s ease, transform 0.15s ease;
box-sizing: border-box;
text-align: center;
.flow-node-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.flow-node--start {
background: linear-gradient(135deg, #34d399, #10b981);
border: 2px solid #059669;
}
.flow-node--model {
background: linear-gradient(135deg, #a78bfa, #8b5cf6);
border: 2px solid #7c3aed;
}
.flow-node--http {
background: linear-gradient(135deg, #60a5fa, #3b82f6);
border: 2px solid #2563eb;
}
.flow-node--form {
background: linear-gradient(135deg, #22d3ee, #06b6d4);
border: 2px solid #0891b2;
}
.flow-node--judge {
background: linear-gradient(135deg, #fbbf24, #f59e0b);
border: 2px solid #d97706;
}
.flow-node--merge {
background: linear-gradient(135deg, #818cf8, #6366f1);
border: 2px solid #4f46e5;
}
.flow-node--subflow {
background: linear-gradient(135deg, #f472b6, #ec4899);
border: 2px solid #db2777;
}
.flow-node--transcribe {
background: linear-gradient(135deg, #94a3b8, #64748b);
border: 2px solid #475569;
}
.flow-node--default {
background: linear-gradient(135deg, #94a3b8, #64748b);
border: 2px solid #475569;
}
.flow-node:hover {
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.18);
transform: translateY(-1px);
}
</style>
@@ -0,0 +1,140 @@
<template>
<div class="form-fields-editor">
<div v-for="(field, index) in fields" :key="index" class="custom-field-config">
<el-input v-model="field.label" placeholder="字段名" class="custom-field-input" @input="sync" />
<div class="custom-field-row">
<el-select v-model="field.type" placeholder="类型" class="custom-field-type" @change="sync">
<el-option label="文本" value="input" />
<el-option label="数字" value="number" />
<el-option label="多行文本" value="textarea" />
<el-option label="开关" value="switch" />
<el-option label="文件上传" value="uploadMultiple" />
</el-select>
<el-checkbox v-model="field.required" class="custom-field-required" @change="sync">必填</el-checkbox>
<el-button type="danger" link @click="removeField(index)">删除</el-button>
</div>
<!-- 文件上传类型只配置规则不在此处上传 -->
<div v-if="field.type === 'uploadMultiple'" class="upload-rules">
<el-input v-model="field.fileTypes" placeholder="文件格式(如 .mp4,jpg,png" class="rule-file-types" @input="sync" />
<el-input-number v-model="field.maxFileSize" :min="1" placeholder="最大大小(MB)" class="rule-file-size" @change="sync" />
<el-input-number v-model="field.maxFileCount" :min="1" placeholder="最大数量" class="rule-file-size" @change="sync" />
</div>
<!-- 非文件上传类型显示默认值输入框 -->
<el-input v-else-if="field.type === 'textarea'" v-model="field.value" placeholder="默认值" type="textarea" :rows="2" class="custom-field-value-full" @input="sync" />
<el-input-number v-else-if="field.type === 'number'" v-model="field.value" placeholder="默认值" class="custom-field-value-full" @change="sync" />
<el-switch v-else-if="field.type === 'switch'" v-model="field.value" class="custom-field-value-full" @change="sync" />
<el-input v-else v-model="field.value" placeholder="默认值" class="custom-field-value-full" @input="sync" />
</div>
<el-button type="primary" link class="w100" @click="addField">+ 添加自定义字段</el-button>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue';
export interface FormField {
// / keylabel
// 退 label field label
field?: string;
label: string;
type: string; // input | number | textarea | switch | uploadMultiple
value: any; // uploadMultiple URL
required: boolean;
fileTypes?: string;
maxFileSize?: number;
maxFileCount?: number;
}
const props = defineProps<{ modelValue: FormField[] }>();
const emit = defineEmits<{ 'update:modelValue': [FormField[]] }>();
const fields = ref<FormField[]>([]);
let internalSync = false; // props emit
const clone = (arr: FormField[]): FormField[] => JSON.parse(JSON.stringify(arr || [])) as FormField[];
watch(
() => props.modelValue,
(val) => {
internalSync = true;
fields.value = clone(val);
nextTick(() => {
internalSync = false;
});
},
{ immediate: true, deep: true }
);
const sync = () => {
if (internalSync) return;
emit('update:modelValue', clone(fields.value));
};
// + field
let fieldSeq = 0;
const genFieldKey = () => `f_${Date.now().toString(36)}_${++fieldSeq}`;
const addField = () => {
fields.value.push({ field: genFieldKey(), label: '', type: 'input', value: '', required: false });
sync();
};
const removeField = (index: number) => {
fields.value.splice(index, 1);
sync();
};
</script>
<style scoped lang="scss">
.form-fields-editor {
width: 100%;
.custom-field-config {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px;
margin-bottom: 10px;
background: #f8fafc;
.custom-field-input {
margin-bottom: 8px;
}
.custom-field-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
.custom-field-type {
flex: 1;
min-width: 0;
}
.custom-field-required {
flex-shrink: 0;
}
}
.upload-rules {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
.rule-file-types,
.rule-file-size {
width: 100%;
}
}
.custom-field-value-full {
width: 100%;
}
}
.w100 {
width: 100%;
}
}
</style>
@@ -1,279 +0,0 @@
<template>
<div class="input-source-manager">
<el-divider content-position="left">上级参数引用</el-divider>
<!-- 已引用的参数列表 -->
<div v-if="currentInputSource && currentInputSource.length > 0" class="input-source-list">
<div v-for="(sourceNode, index) in currentInputSource" :key="index" class="input-source-item">
<div class="input-source-header">
<span class="input-source-node-name">{{ getNodeName(sourceNode.nodeId) }}</span>
</div>
<div v-if="sourceNode.field && sourceNode.field.length > 0" class="input-source-fields">
<div v-for="fieldName in sourceNode.field" :key="fieldName" class="field-tag">
<el-tag size="small">{{ fieldName }}</el-tag>
<el-button type="danger" link size="small" @click="emit('removeField', sourceNode.nodeId, fieldName)">删除</el-button>
</div>
</div>
<div class="input-source-output">
<el-switch
:model-value="sourceNode.quoteOutput === true"
@change="(val: boolean) => emit('toggleOutput', sourceNode.nodeId, val)"
size="small"
active-text="引入输出"
inactive-text=""
/>
</div>
</div>
</div>
<!-- 显示所有上级节点的输出引用选项 -->
<div v-if="availableParentNodes.length > 0" class="parent-nodes-output">
<div class="parent-nodes-title">上级节点输出</div>
<div v-for="parentNode in availableParentNodes" :key="parentNode.id" class="parent-node-output-item">
<span class="parent-node-name">{{ parentNode.name }}</span>
<el-switch
:model-value="isNodeOutputQuoted(parentNode.id)"
@change="(val: boolean) => emit('toggleOutput', parentNode.id, val)"
size="small"
active-text="引入输出"
inactive-text=""
/>
</div>
</div>
<!-- 选择参数下拉框 -->
<el-form-item label="选择参数">
<el-select :model-value="selectedParam" @update:model-value="handleParamSelect" placeholder="选择上级节点的参数" class="w100">
<el-option v-for="param in availableParams" :key="param.value" :label="param.label" :value="param.value" />
</el-select>
</el-form-item>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import type { Node } from '@vue-flow/core';
interface NodeData {
label?: string;
nodeCode?: string;
inputSource?: Array<{ nodeId: string; field: string[]; quoteOutput?: boolean }> | null;
formConfig?: any[];
modelConfig?: any;
skillName?: string;
}
interface ParentNode {
id: string;
name: string;
}
interface ParamOption {
label: string;
value: string;
}
const props = defineProps<{
selectedNode: Node<NodeData, any, string> | null;
nodes: Node<NodeData, any, string>[];
edges: any[];
}>();
const emit = defineEmits<{
(e: 'removeField', nodeId: string, fieldName: string): void;
(e: 'toggleOutput', nodeId: string, enabled: boolean): void;
(e: 'addParam', paramValue: string): void;
}>();
const selectedParam = ref('');
// inputSource
const currentInputSource = computed(() => {
if (!props.selectedNode?.data?.inputSource) return [];
return props.selectedNode.data.inputSource.filter((item) => item.field && item.field.length > 0);
});
//
const getNodeName = (nodeId: string) => {
const node = props.nodes.find((n) => n.id === nodeId);
return node?.data?.label || nodeId;
};
//
const availableParentNodes = computed(() => {
if (!props.selectedNode?.data) return [];
// ID
const inputSource = props.selectedNode.data.inputSource;
const nodesWithFields = new Set<string>();
if (Array.isArray(inputSource)) {
inputSource.forEach((item) => {
if (item.field && item.field.length > 0) {
nodesWithFields.add(item.nodeId);
}
});
}
//
const findAllParentNodes = (nodeId: string, visited = new Set<string>()): string[] => {
if (visited.has(nodeId)) return [];
visited.add(nodeId);
const incomingEdges = props.edges.filter((e) => e.target === nodeId);
const parentIds: string[] = [];
incomingEdges.forEach((edge) => {
parentIds.push(edge.source);
parentIds.push(...findAllParentNodes(edge.source, visited));
});
return parentIds;
};
const allParentIds = findAllParentNodes(props.selectedNode.id);
const parentNodes = allParentIds
.map((parentId) => {
const parentNode = props.nodes.find((n) => n.id === parentId);
if (!parentNode?.data) return null;
const nodeCode = String(parentNode.data.nodeCode || '').toLowerCase();
const isJudge = ['判断', 'judge', 'condition', 'if', 'branch', 'gateway'].some((k) => nodeCode.includes(k));
const isStart = nodeCode === '__start__';
if (isJudge || isStart || nodesWithFields.has(parentId)) return null;
return {
id: parentId,
name: parentNode.data.label || parentId,
};
})
.filter(Boolean);
return parentNodes as ParentNode[];
});
//
const isNodeOutputQuoted = (nodeId: string): boolean => {
if (!props.selectedNode?.data) return false;
const inputSource = props.selectedNode.data.inputSource;
if (!Array.isArray(inputSource)) return false;
const node = inputSource.find((item) => item.nodeId === nodeId);
return node?.quoteOutput === true;
};
//
const availableParams = computed(() => {
if (!props.selectedNode) return [];
const params: ParamOption[] = [];
const visited = new Set<string>();
const findParents = (nodeId: string) => {
if (visited.has(nodeId)) return;
visited.add(nodeId);
props.edges
.filter((e) => e.target === nodeId)
.forEach((edge) => {
const parent = props.nodes.find((n) => n.id === edge.source);
if (parent?.data && parent.data.nodeCode !== '__start__' && parent.data.nodeCode !== 'judge') {
params.push({
label: `${parent.data.label}.output`,
value: `\${${parent.id}.output}`,
});
}
findParents(edge.source);
});
};
findParents(props.selectedNode.id);
return params;
});
const handleParamSelect = (value: string) => {
if (!value) return;
emit('addParam', value);
selectedParam.value = '';
};
</script>
<style scoped lang="scss">
.input-source-manager {
margin-top: 16px;
}
.input-source-list {
margin-bottom: 16px;
}
.input-source-item {
padding: 12px;
margin-bottom: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.input-source-header {
margin-bottom: 8px;
}
.input-source-node-name {
font-size: 14px;
font-weight: 600;
color: #334155;
}
.input-source-fields {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
}
.field-tag {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
background: #fff;
border-radius: 4px;
}
.input-source-output {
padding-top: 8px;
border-top: 1px solid #e2e8f0;
}
.parent-nodes-output {
margin-bottom: 16px;
}
.parent-nodes-title {
font-size: 13px;
font-weight: 600;
color: #64748b;
margin-bottom: 8px;
}
.parent-node-output-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
margin-bottom: 6px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
}
.parent-node-name {
font-size: 13px;
color: #475569;
}
.w100 {
width: 100%;
}
</style>
@@ -0,0 +1,86 @@
<template>
<div class="kv-editor">
<div v-for="(item, idx) in list" :key="idx" class="kv-row">
<el-input v-model="item.key" placeholder="key" size="small" class="kv-key" @input="sync" />
<el-input v-model="item.value" placeholder="value" size="small" class="kv-value" @input="sync" />
<el-button size="small" text type="danger" class="kv-del" @click="remove(idx)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<el-button size="small" class="kv-add" @click="add">
<el-icon><Plus /></el-icon> 添加
</el-button>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Plus, Delete } from '@element-plus/icons-vue';
const props = defineProps<{ modelValue: Record<string, unknown> }>();
const emit = defineEmits<{ 'update:modelValue': [Record<string, unknown>] }>();
const list = ref<{ key: string; value: string }[]>([]);
function toList(obj: Record<string, unknown>) {
return Object.entries(obj || {}).map(([k, v]) => ({
key: k,
value: typeof v === 'string' ? v : JSON.stringify(v),
}));
}
function toRecord() {
const obj: Record<string, unknown> = {};
for (const item of list.value) {
if (item.key.trim()) obj[item.key.trim()] = item.value;
}
return obj;
}
function sync() {
emit('update:modelValue', toRecord());
}
function add() {
list.value.push({ key: '', value: '' });
}
function remove(idx: number) {
list.value.splice(idx, 1);
sync();
}
watch(
() => props.modelValue,
(val) => {
list.value = toList(val);
},
{ immediate: true, deep: true }
);
</script>
<style scoped>
.kv-editor {
width: 100%;
}
.kv-row {
display: flex;
gap: 6px;
margin-bottom: 6px;
align-items: center;
}
.kv-key {
flex: 1;
min-width: 0;
}
.kv-value {
flex: 2;
min-width: 0;
}
.kv-del {
flex-shrink: 0;
}
.kv-add {
font-size: 12px;
}
</style>
@@ -0,0 +1,215 @@
<template>
<!-- 引用上级节点输出按钮 + 候选面板叶子字段 + 对象/数组整体multi 时支持多选 -->
<el-popover
v-if="canValueSource"
placement="bottom"
:width="260"
trigger="click"
popper-class="mf-ref-popover"
>
<template #reference>
<el-button
size="small"
text
type="primary"
class="mf-ref-btn"
title="引用上级节点输出"
:disabled="!!fieldDef?.runtimeShow || !hasUpstream"
>
<el-icon><Link /></el-icon>
</el-button>
</template>
<div class="mf-ref-panel">
<div v-if="!hasUpstream" class="mf-ref-empty">无可引用的上级节点</div>
<template v-else>
<div v-for="node in upstreamNodes" :key="node.id" class="mf-ref-node">
<div class="mf-ref-node-head">
<span class="mf-ref-node-name">{{ node.label }}</span>
<el-tag size="small" type="info">{{ node.nodeCode }}</el-tag>
</div>
<div v-if="node.outputFields.length === 0" class="mf-ref-node-empty">该节点无输出字段</div>
<div
v-for="of in node.outputFields"
:key="`${of.field}-${of.fieldType || 'scalar'}`"
class="mf-ref-field"
:class="{ 'is-selected': isSelected(node, of) }"
@click="toggleValueSource(node, of)"
>
<span class="mf-ref-check">
<el-icon v-if="isSelected(node, of)"><Check /></el-icon>
</span>
<span v-if="of.fieldType === 'object'" class="mf-ref-type" title="引用对象整体">{} 对象</span>
<span v-else-if="of.fieldType === 'array'" class="mf-ref-type" title="引用数组整体">[] 数组</span>
<span class="mf-ref-field-text">{{ of.label || of.field }}</span>
</div>
</div>
</template>
</div>
</el-popover>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Check, Link } from '@element-plus/icons-vue';
defineOptions({ name: 'MfReferenceButton' });
// fieldTypescalar / objectarray
interface UpstreamField {
field: string;
label: string;
fieldType?: 'scalar' | 'object' | 'array';
}
interface UpstreamNodeInfo {
id: string;
label: string;
nodeCode: string;
outputFields: UpstreamField[];
}
const props = defineProps<{ fieldDef: any; upstreamNodes?: UpstreamNodeInfo[]; multi?: boolean }>();
// //
const canValueSource = computed(() => {
const ft = props.fieldDef?.fieldType || 'string';
const dt = props.fieldDef?.type || 'string';
return ft !== 'switch' && ft !== 'select' && dt !== 'switch';
});
const hasUpstream = computed(() => (props.upstreamNodes?.length ?? 0) > 0);
// [{ nodeId, field }] DSL
const valueSource = computed<any[]>(() => {
const vs = props.fieldDef?.valueSource;
if (Array.isArray(vs)) return vs;
return vs && typeof vs === 'object' && !Array.isArray(vs) ? [vs] : [];
});
const isSelected = (node: UpstreamNodeInfo, of: UpstreamField): boolean =>
valueSource.value.some((r) => r && r.nodeId === node.id && r.field === of.field);
// fieldDef.valueSource watch
const toggleValueSource = (node: UpstreamNodeInfo, of: UpstreamField) => {
if (!props.fieldDef) return;
const cur = valueSource.value;
if (props.multi) {
// /
if (isSelected(node, of)) {
const keep = cur.filter((r) => !(r && r.nodeId === node.id && r.field === of.field));
if (keep.length === 0) delete props.fieldDef.valueSource;
else props.fieldDef.valueSource = keep;
} else {
props.fieldDef.valueSource = [...cur, { nodeId: node.id, field: of.field }];
}
} else {
//
if (cur.length === 1 && isSelected(node, of)) {
delete props.fieldDef.valueSource;
} else {
props.fieldDef.valueSource = [{ nodeId: node.id, field: of.field }];
}
}
};
</script>
<style scoped lang="scss">
.mf-ref-btn {
flex-shrink: 0;
margin-left: 2px;
}
</style>
<!-- popover teleport body面板样式需脱离 scoped -->
<style lang="scss">
.mf-ref-popover {
.mf-ref-panel {
//
max-height: 40vh;
overflow-y: auto;
.mf-ref-empty {
color: #94a3b8;
font-size: 12px;
padding: 6px 0;
text-align: center;
}
.mf-ref-node {
& + .mf-ref-node {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #e2e8f0;
}
.mf-ref-node-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
.mf-ref-node-name {
flex: 1;
font-size: 12px;
font-weight: 600;
color: #334155;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.mf-ref-node-empty {
font-size: 12px;
color: #cbd5e1;
padding: 2px 0 2px 8px;
}
.mf-ref-field {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #475569;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
.mf-ref-check {
flex-shrink: 0;
width: 14px;
font-size: 12px;
color: #3b82f6;
display: inline-flex;
align-items: center;
}
&:hover {
background: #eff6ff;
color: #3b82f6;
}
&.is-selected {
background: #eff6ff;
color: #3b82f6;
}
.mf-ref-type {
flex-shrink: 0;
font-size: 11px;
line-height: 1;
color: #64748b;
background: #f1f5f9;
border-radius: 3px;
padding: 2px 4px;
}
.mf-ref-field-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
}
</style>
@@ -0,0 +1,575 @@
<template>
<div class="model-field">
<!-- object递归渲染 attrs 子字段 -->
<template v-if="isObject">
<div class="mf-block">
<div class="mf-block-title">
<el-icon><FolderOpened /></el-icon>
<span class="mf-block-name">{{ label }}</span> <el-tooltip v-if="description" :content="description">
<el-icon class="mf-info"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
<div class="mf-block-body">
<ModelField
v-for="(childDef, childKey) in objectChildren"
:key="childKey"
:field-def="childDef"
:path="path ? `${path}.attrs.${childKey}` : childKey"
:upstream-nodes="upstreamNodes"
:multi="multi"
/>
</div>
</div>
</template>
<!-- array直接平铺展示元素子字段用户直接填写不提供添加/删除 -->
<template v-else-if="isArray">
<div class="mf-block">
<div class="mf-block-title">
<el-icon><List /></el-icon>
<span class="mf-block-name">{{ label }}</span>
<span v-if="required" class="mf-required">*</span> <el-tooltip v-if="description" :content="description">
<el-icon class="mf-info"><QuestionFilled /></el-icon>
</el-tooltip>
</div>
<div class="mf-array-flat">
<!-- 数组元素直接遍历模板enumValues/attrs值与勾选均落在模板上
保存只留 enumValues 一份即可完整回显不依赖 value 实例 -->
<template v-for="(item, idx) in renderedArrayItems()" :key="idx">
<template v-if="isObjectDef(item)">
<div v-if="isVariantArray" class="mf-variant-head">
<span class="mf-variant-name">{{ variantLabel(idx) }}</span>
</div>
<div v-else-if="renderedArrayItems().length > 1" class="mf-array-flat-index">#{{ idx + 1 }}</div>
<ModelField
v-for="(subDef, subKey) in item.attrs"
:key="subKey"
:field-def="subDef"
:path="path ? `${path}.enumValues[${idx}].attrs.${subKey}` : String(subKey)"
:upstream-nodes="upstreamNodes"
:multi="multi"
/>
</template>
<el-input v-else :model-value="getTemplatePrimitive(item)" @input="(v: any) => setTemplatePrimitive(item, v)" size="small" />
</template>
</div>
</div>
</template>
<!-- 叶子 fieldType 渲染控件 -->
<template v-else>
<div class="mf-leaf">
<div class="mf-leaf-label">
<span class="mf-label-text">{{ label }}</span>
<span v-if="required" class="mf-required">*</span> <el-tooltip v-if="description" :content="description">
<el-icon class="mf-info"><QuestionFilled /></el-icon>
</el-tooltip>
<el-tooltip :content="path || label" placement="top">
<el-checkbox
size="small"
:model-value="def.runtimeShow ?? false"
:disabled="hasValueSource"
@change="toggleExpose"
class="mf-leaf-expose"
>表单展示</el-checkbox>
</el-tooltip>
<!-- 引用上级输出与表单展示勾选同级所有叶子参数含数组元素内字段都可引用multi 时支持多选 -->
<MfReferenceButton :field-def="def" :upstream-nodes="upstreamNodes" :multi="multi" />
</div>
<div v-if="hasValueSource" class="mf-ref-bound" :class="{ 'is-invalid': valueSourceInvalid }">
<div class="mf-ref-tags">
<el-tag
v-for="(ref, i) in valueSourceList"
:key="`${ref.nodeId}-${ref.field}-${i}`"
size="small"
class="mf-ref-tag"
:type="ref.invalid ? 'danger' : 'primary'"
closable
@close="removeValueSource(ref)"
>{{ ref.label }}</el-tag>
</div>
<el-button size="small" text type="danger" @click="clearValueSource">清空</el-button>
</div>
<div v-if="!hasValueSource && !isUpload" class="mf-leaf-ctrl">
<el-input-number
v-if="isNumber"
:model-value="numberValue"
@change="setValue"
class="mf-ctrl"
:controls="false"
:placeholder="required ? '必填' : ''"
/>
<el-switch v-else-if="isSwitch" :model-value="switchValue" @change="setValue" class="mf-ctrl" />
<el-select
v-else-if="isSelect"
:model-value="def.value ?? ''"
@change="setSelectValue"
class="mf-ctrl"
clearable
:placeholder="required ? '必填' : ''"
>
<el-option v-for="opt in def.options" :key="opt.value" :label="opt.label ?? opt.value" :value="opt.value" />
</el-select>
<el-input
v-else-if="isTextarea"
:model-value="def.value ?? ''"
@input="setValue"
type="textarea"
:rows="2"
class="mf-ctrl"
:placeholder="required ? '必填' : ''"
/>
<el-input
v-else
:model-value="def.value ?? ''"
@input="setValue"
class="mf-ctrl"
:placeholder="required ? '必填' : ''"
/>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue';
import { FolderOpened, List, QuestionFilled } from '@element-plus/icons-vue';
import MfReferenceButton from './MfReferenceButton.vue';
defineOptions({ name: 'ModelField' });
//
interface UpstreamNodeInfo {
id: string;
label: string;
nodeCode: string;
outputFields: { field: string; label: string; fieldType?: 'scalar' | 'object' | 'array' }[];
}
const props = defineProps<{ fieldDef: any; path?: string; upstreamNodes?: UpstreamNodeInfo[]; multi?: boolean }>();
// computed props internalSync
const def = computed(() => props.fieldDef);
// ===== =====
const label = computed(() => def.value?.label || def.value?.field || '');
const description = computed(() => def.value?.description || '');
const required = computed(() => !!def.value?.required);
const defType = computed(() => def.value?.type || 'string');
const fieldType = computed(() => def.value?.fieldType || 'string');
// ===== =====
const isObject = computed(() => defType.value === 'object');
const isArray = computed(() => defType.value === 'array');
const isLeaf = computed(() => !isObject.value && !isArray.value);
const objectChildren = computed<Record<string, any>>(() => {
if (!isObject.value) return {};
const attrs = def.value?.attrs;
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) return attrs;
return {};
});
const isNumber = computed(() => isLeaf.value && (fieldType.value === 'number' || defType.value === 'number'));
const isSwitch = computed(() => isLeaf.value && (fieldType.value === 'switch' || defType.value === 'boolean'));
const isSelect = computed(() => isLeaf.value && fieldType.value === 'select' && Array.isArray(def.value?.options));
const isTextarea = computed(() => isLeaf.value && fieldType.value === 'textarea');
const isUpload = computed(() => isLeaf.value && fieldType.value === 'upload');
// ===== =====
// MfReferenceButton /
// valueSource [{ nodeId, field }]multi isMultiParameter
const valueSourceList = computed(() => {
const vs = def.value?.valueSource;
// DSL { nodeId, field }
if (!vs || typeof vs !== 'object') return [];
const arr = Array.isArray(vs) ? vs : [vs];
return arr.map((r: any) => {
const node = props.upstreamNodes?.find((n) => n.id === r?.nodeId);
// .
const fieldLabel = node?.outputFields.find((f) => f.field === r?.field)?.label || r?.field || '';
const invalid = !node || !node.outputFields.some((f) => f.field === r?.field);
return { nodeId: r?.nodeId, field: r?.field, label: fieldLabel, invalid };
});
});
const hasValueSource = computed(() => valueSourceList.value.length > 0);
// watch
const valueSourceInvalid = computed(() => valueSourceList.value.some((r) => r.invalid));
// valueSource watch
watch(
() => valueSourceList.value,
(list) => {
if (!def.value) return;
const vs = def.value.valueSource;
if (!vs || typeof vs !== 'object') return;
// DSL
const arr = Array.isArray(vs) ? vs : [vs];
if (!list.some((r) => r.invalid)) return;
const keep = arr.filter((r: any) => {
const node = props.upstreamNodes?.find((n) => n.id === r?.nodeId);
return node && node.outputFields.some((f) => f.field === r?.field);
});
if (keep.length === 0) delete def.value.valueSource;
else def.value.valueSource = keep;
},
{ immediate: true }
);
// closable
const removeValueSource = (ref: any) => {
if (!def.value) return;
const vs = def.value.valueSource;
if (!vs || typeof vs !== 'object') return;
const arr = Array.isArray(vs) ? vs : [vs];
const keep = arr.filter((r: any) => !(r?.nodeId === ref.nodeId && r?.field === ref.field));
if (keep.length === 0) delete def.value.valueSource;
else def.value.valueSource = keep;
};
//
const clearValueSource = () => {
if (!def.value) return;
delete def.value.valueSource;
};
// ===== // =====
const numberValue = computed(() => {
const v = def.value?.value !== undefined ? def.value.value : def.value?.defaultValue;
const n = Number(v);
return Number.isNaN(n) ? 0 : n;
});
const switchValue = computed(() => {
const v = def.value?.value !== undefined ? def.value.value : def.value?.defaultValue;
return v === true || v === 'true';
});
const setValue = (val: any) => {
if (!def.value) return;
if (isSwitch.value) {
def.value.value = val === true || val === 'true';
} else if (isNumber.value) {
def.value.value = val === null || val === undefined || val === '' ? 0 : Number(val);
} else {
def.value.value = val ?? '';
}
};
const setSelectValue = (val: any) => {
if (!def.value) return;
def.value.value = val ?? '';
};
// def.runtimeShow watch
const toggleExpose = (checked: any) => {
if (!def.value) return;
def.value.runtimeShow = !!checked;
};
// ===== array/ =====
// enumValues attrs attrs
const arrayTemplates = computed<any[]>(() => {
if (!isArray.value || !def.value) return [];
if (Array.isArray(def.value.enumValues) && def.value.enumValues.length) return def.value.enumValues;
if (Array.isArray(def.value.attrs) && def.value.attrs.length) return def.value.attrs;
if (def.value.attrs && typeof def.value.attrs === 'object' && !Array.isArray(def.value.attrs)) {
return [{ type: 'object', attrs: def.value.attrs }];
}
return [];
});
const isObjectDef = (item: any) => !!item && typeof item === 'object' && item.type === 'object' && item.attrs && typeof item.attrs === 'object';
// enumValues/attrs
// props localParams enumValues
const renderedArrayItems = (): any[] => {
const templates = arrayTemplates.value;
// variant type/role
if (templates.length > 1) templates.forEach(autoFillVariant);
return templates;
};
// value
const getTemplatePrimitive = (item: any): any => {
if (!item || typeof item !== 'object') return '';
return item.value !== undefined ? item.value : item.defaultValue ?? '';
};
const setTemplatePrimitive = (item: any, v: any): void => {
if (!item || typeof item !== 'object') return;
item.value = v ?? '';
};
// ===== arrayenumValues =====
const isVariantArray = computed(
() => isArray.value && Array.isArray(def.value?.enumValues) && (def.value?.enumValues?.length ?? 0) > 1
);
// type text/image_url/video_url role " N"
const templateLabel = (tpl: any, index: number): string => {
if (tpl && typeof tpl === 'object' && tpl.attrs && typeof tpl.attrs === 'object' && !Array.isArray(tpl.attrs)) {
const typeField = tpl.attrs.type;
if (typeField) {
const t = typeField.value ?? typeField.defaultValue ?? '';
if (t) return String(t);
}
const roleField = tpl.attrs.role;
if (roleField && roleField.description) {
const m = String(roleField.description).match(/(此处为|固定为|为)\s*([a-zA-Z0-9_-]+)/);
if (m) return m[1];
}
}
return `变体 ${index + 1}`;
};
// type defaultValuerole description
const autoFillVariant = (tpl: any): void => {
if (!tpl || typeof tpl !== 'object' || !tpl.attrs || typeof tpl.attrs !== 'object' || Array.isArray(tpl.attrs)) return;
const typeField = tpl.attrs.type;
if (typeField && (typeField.value === undefined || typeField.value === null || typeField.value === '')) {
if (typeField.defaultValue !== undefined && typeField.defaultValue !== null) typeField.value = typeField.defaultValue;
}
const roleField = tpl.attrs.role;
if (roleField && (roleField.value === undefined || roleField.value === null || roleField.value === '')) {
const m = String(roleField.description || '').match(/(此处为|固定为|为)\s*([a-zA-Z0-9_-]+)/);
if (m) roleField.value = m[1];
}
};
const variantLabel = (idx: number): string => templateLabel(def.value?.enumValues?.[idx], idx);
</script>
<style scoped lang="scss">
.model-field {
width: 100%;
//
.model-field {
.mf-block,
.mf-leaf {
border: none;
background: transparent;
padding: 0 0 0 12px;
margin-bottom: 6px;
}
}
}
.mf-block {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 10px;
background: #fbfcfe;
.mf-block-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #475569;
margin-bottom: 8px;
.mf-block-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.mf-block-body {
padding-left: 4px;
}
}
.mf-info {
cursor: help;
color: #94a3b8;
font-size: 13px;
flex-shrink: 0;
}
.mf-required {
color: #f56c6c;
margin-left: 2px;
}
.mf-array-flat {
.mf-array-flat-index {
display: inline-block;
font-size: 11px;
color: #94a3b8;
background: #eef2f7;
border-radius: 999px;
padding: 0 6px;
margin: 2px 0 6px;
}
:deep(.model-field) {
margin-bottom: 0;
}
}
.mf-variant {
margin-bottom: 8px;
padding-left: 10px;
border-left: 2px solid #e2e8f0;
.mf-variant-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
.mf-variant-name {
font-size: 12px;
font-weight: 600;
color: #475569;
}
}
}
.mf-leaf {
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px 10px;
margin-bottom: 10px;
background: #ffffff;
.mf-leaf-label {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
font-size: 12px;
font-weight: 600;
color: #475569;
.mf-label-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mf-leaf-expose {
flex-shrink: 0;
margin-left: 8px;
:deep(.el-checkbox__label) {
font-size: 12px;
color: #475569;
font-weight: 400;
}
}
.mf-ref-btn {
flex-shrink: 0;
margin-left: 2px;
}
}
.mf-ref-bound {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 12px;
color: #3b82f6;
background: #eff6ff;
border-radius: 4px;
padding: 6px 8px;
.mf-ref-tags {
flex: 1;
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: 4px;
.mf-ref-tag {
:deep(.el-tag__content) {
font-size: 12px;
}
}
}
&.is-invalid {
color: #dc2626;
background: #fef2f2;
}
}
.mf-leaf-ctrl {
.mf-ctrl {
width: 100%;
}
}
}
</style>
<!-- popover teleport body面板样式需脱离 scoped -->
<style lang="scss">
.mf-ref-popover {
.mf-ref-panel {
//
max-height: 40vh;
overflow-y: auto;
.mf-ref-empty {
color: #94a3b8;
font-size: 12px;
padding: 6px 0;
text-align: center;
}
.mf-ref-node {
& + .mf-ref-node {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #e2e8f0;
}
.mf-ref-node-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
.mf-ref-node-name {
flex: 1;
font-size: 12px;
font-weight: 600;
color: #334155;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.mf-ref-node-empty {
font-size: 12px;
color: #cbd5e1;
padding: 2px 0 2px 8px;
}
.mf-ref-field {
font-size: 12px;
color: #475569;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
&:hover {
background: #eff6ff;
color: #3b82f6;
}
}
}
}
}
</style>
@@ -0,0 +1,92 @@
<template>
<div class="model-params-form">
<div v-if="!hasParams" class="mpf-empty">
<el-empty description="该模型无请求参数配置" :image-size="80" />
</div>
<div v-else>
<div class="mpf-count">
{{ fieldCount }} 个参数<el-divider direction="vertical" />点击字段名旁图标查看说明
</div>
<div class="mpf-fields">
<ModelField
v-for="(def, key) in localParams"
:key="key"
:field-def="def"
:path="key"
:upstream-nodes="upstreamNodes"
:multi="multi"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import ModelField from './ModelField.vue';
import { deepClone, normalizeModelParams, stripReadonlyFields } from './modelParamUtils';
//
interface UpstreamNodeInfo {
id: string;
label: string;
nodeCode: string;
outputFields: { field: string; label: string }[];
}
const props = defineProps<{ modelRequestParams: Record<string, any> | null; upstreamNodes?: UpstreamNodeInfo[]; multi?: boolean }>();
const emit = defineEmits<{ 'update:modelRequestParams': [Record<string, any> | null] }>();
const localParams = ref<Record<string, any>>({});
let internalSync = false; // props emit
// props + defaultValue
watch(
() => props.modelRequestParams,
(val) => {
internalSync = true;
// isForm=false
localParams.value = stripReadonlyFields(normalizeModelParams(deepClone(val || {})));
nextTick(() => {
internalSync = false;
});
},
{ immediate: true, deep: true }
);
// value watch
watch(
localParams,
(val) => {
if (internalSync) return;
emit('update:modelRequestParams', deepClone(val));
},
{ deep: true }
);
const hasParams = computed(() => Object.keys(localParams.value).length > 0);
const fieldCount = computed(() => Object.keys(localParams.value).length);
</script>
<style scoped lang="scss">
.model-params-form {
width: 100%;
.mpf-empty {
padding: 8px 0;
}
.mpf-count {
font-size: 12px;
color: #94a3b8;
margin-bottom: 10px;
line-height: 1.4;
}
.mpf-fields {
max-height: 420px;
overflow-y: auto;
padding-right: 4px;
}
}
</style>
@@ -0,0 +1,413 @@
<template>
<el-dialog v-model="visible" title="选择模型" width="1000px" :close-on-click-modal="false" @close="handleClose">
<div class="model-selector-header">
<div class="search-bar">
<el-input v-model="searchParams.modelName" placeholder="搜索模型名称" clearable @clear="handleSearch">
<template #prefix
><el-icon> <Search /> </el-icon
></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
</div>
<div class="model-list" v-loading="loading">
<el-empty v-if="!loading && modelList.length === 0" description="暂无模型数据" :image-size="100" />
<div v-else class="model-grid">
<div
v-for="model in modelList"
:key="model.id"
class="model-card"
:class="{ selected: selectedModel?.id === model.id }"
@click="handleSelectModel(model)"
>
<div class="model-card-header">
<div class="model-type">
{{ getModelTypeName(model.modelType) }}
<el-tag v-if="model.systemModel" size="small" type="warning" class="model-owner-tag">内置</el-tag>
<el-tag v-else size="small" type="success" class="model-owner-tag">我的</el-tag>
</div>
<el-icon v-if="selectedModel?.id === model.id" class="check-icon" color="#67c23a">
<CircleCheck />
</el-icon>
</div>
<div class="model-card-body">
<h3 class="model-name">{{ model.modelName }}</h3>
<p class="model-url">{{ model.baseUrl }}</p>
<div class="model-status">
<el-tag :type="isModelEnabled(model) ? 'success' : 'info'" size="small">
{{ isModelEnabled(model) ? '已启用' : '已禁用' }}
</el-tag>
</div>
</div>
</div>
</div>
</div>
<div v-if="pagination.total > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.pageNum"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, prev, pager, next"
small
@current-change="handlePageChange"
/>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleConfirm" :disabled="!selectedModel">确定</el-button>
</template>
</el-dialog>
<!-- 系统内置模型填写 API Key经修改接口转换为用户模型后自动绑定 -->
<el-dialog
v-model="apiKeyDialogVisible"
title="填写 API Key"
width="480px"
append-to-body
:close-on-click-modal="false"
@close="handleApiKeyClose"
>
<el-alert
type="info"
:closable="false"
show-icon
title="该模型为系统内置模型,填写你的 API Key 后将创建一条用户模型并自动绑定到当前节点。"
class="api-key-alert"
/>
<el-form label-position="top" class="api-key-form">
<el-form-item label="API Key" required>
<el-input
v-model="apiKeyForm.apiKey"
type="password"
show-password
placeholder="请输入你的 API Key"
@keyup.enter="handleApiKeyConfirm"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleApiKeyClose">取消</el-button>
<el-button type="primary" :loading="converting" @click="handleApiKeyConfirm">确认并转换</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { Search, CircleCheck } from '@element-plus/icons-vue';
import { getWorkflowModelList, type WorkflowModelItem } from '/@/api/settings/workflow';
import { updateModelManage } from '/@/api/settings/modelConfigV2';
interface Props {
modelValue: boolean;
defaultModel?: WorkflowModelItem | null;
//
sameTypeModelType?: string | number | null;
// API Key true
// / false API Key
systemModelRequireKey?: boolean;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'confirm', model: WorkflowModelItem): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
defaultModel: null,
sameTypeModelType: null,
systemModelRequireKey: true,
});
const emit = defineEmits<Emits>();
const visible = ref(false);
const searchParams = reactive({ modelName: '' });
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
const modelList = ref<WorkflowModelItem[]>([]);
const loading = ref(false);
const selectedModel = ref<WorkflowModelItem | null>(null);
// systemModel === true API Key
const apiKeyDialogVisible = ref(false);
const converting = ref(false);
const pendingCloneModel = ref<WorkflowModelItem | null>(null);
const apiKeyForm = reactive({ apiKey: '' });
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
selectedModel.value = props.defaultModel || null;
pagination.pageNum = 1;
fetchModelList();
}
}
);
watch(visible, (val) => {
if (!val) {
emit('update:modelValue', false);
}
});
const getModelTypeName = (type: number | string) => {
const typeMap: Record<number, string> = {
100: '推理模型',
200: '图片模型',
201: '图片模型-文生图',
202: '图片模型-图生图',
203: '图片模型-图片编辑',
204: '图片模型-图片变体',
300: '音频模型',
301: '音频模型-文生音',
302: '音频模型-音生文',
303: '音频模型-音生音',
400: '向量化模型',
401: '向量化模型-文本嵌入',
402: '向量化模型-重排序',
500: '全模态模型',
501: '全模态模型-文图音',
502: '全模态模型-视觉理解',
600: '视频模型',
601: '视频模型-文生视频',
602: '视频模型-图生视频',
603: '视频模型-图文生视频',
604: '视频模型-视频生视频',
605: '视频模型-视频编辑',
};
return typeMap[Number(type)] || '未知类型';
};
// enabled (1/0) (true/false)
const isModelEnabled = (model: WorkflowModelItem) => model.enabled === 1 || model.enabled === true;
const fetchModelList = async () => {
loading.value = true;
try {
const params = {
pageNum: pagination.pageNum,
pageSize: pagination.pageSize,
modelName: searchParams.modelName || undefined,
...(props.sameTypeModelType !== undefined && props.sameTypeModelType !== null && props.sameTypeModelType !== ''
? { isSameType: true, modelType: props.sameTypeModelType }
: {}),
};
const res = await getWorkflowModelList(params);
modelList.value = res.data?.list || [];
pagination.total = res.data?.total || 0;
// id
if (selectedModel.value && !selectedModel.value.id && selectedModel.value.modelName) {
const matched = modelList.value.find((m) => m.modelName === selectedModel.value!.modelName);
if (matched) selectedModel.value = matched;
}
} catch {
modelList.value = [];
pagination.total = 0;
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.pageNum = 1;
fetchModelList();
};
const handlePageChange = () => {
fetchModelList();
};
const handleSelectModel = (model: WorkflowModelItem) => {
// API Key
// systemModelRequireKey=false使 Key
if (model.systemModel && props.systemModelRequireKey) {
pendingCloneModel.value = model;
apiKeyForm.apiKey = '';
apiKeyDialogVisible.value = true;
return;
}
selectedModel.value = model;
};
const handleConfirm = () => {
if (selectedModel.value) {
emit('confirm', selectedModel.value);
handleClose();
}
};
// API Key
const handleApiKeyConfirm = async () => {
if (!apiKeyForm.apiKey.trim()) {
ElMessage.warning('请输入 API Key');
return;
}
if (!pendingCloneModel.value?.id) return;
converting.value = true;
try {
const src = pendingCloneModel.value;
const res = await updateModelManage({
id: src.id,
//
modelName: src.modelName,
modelType: src.modelType,
modelSupplier: src.modelSupplier,
baseUrl: src.baseUrl,
responseType: src.responseType ?? src.invokeType,
apiKey: apiKeyForm.apiKey.trim(),
enabled: src.enabled,
chatModel: src.ChatModel ?? src.chatModel,
maxConcurrency: src.maxConcurrency,
maxTokens: src.maxTokens,
tokenPredictPrice: src.tokenPredictPrice,
requestHeadMapping: src.requestHeadMapping ?? src.requestMapping,
requestBodyMapping: src.requestBodyMapping,
responseMapping: src.responseMapping,
responseBodyMapping: src.responseBodyMapping,
...(src.tokenMapping ? { tokenMapping: src.tokenMapping } : {}),
...(src.asyncTaskMapping ? { asyncTaskMapping: src.asyncTaskMapping } : {}),
...(src.lastFrame ? { lastFrame: src.lastFrame } : {}),
...(src.maxDuration ? { maxDuration: src.maxDuration } : {}),
...(src.tokenPredictPriceUnit ? { tokenPredictPriceUnit: src.tokenPredictPriceUnit } : {}),
} as any);
// { data: { modelManage: {...} } } id modelManage
const newModelManage = res?.data?.modelManage || res?.data;
if (!newModelManage?.id) throw new Error('接口未返回新的用户模型');
// modelManage id/apiKeymodelName/requestBodyMapping
// + id isSameType
const newModel: WorkflowModelItem = {
...(pendingCloneModel.value || {}),
id: newModelManage.id,
systemModel: false,
apiKey: apiKeyForm.apiKey.trim(),
};
apiKeyDialogVisible.value = false;
pendingCloneModel.value = null;
emit('confirm', newModel);
handleClose();
ElMessage.success('已创建用户模型并绑定');
} catch (e: any) {
ElMessage.error(e?.message || '模型转换失败,请重试');
} finally {
converting.value = false;
}
};
const handleApiKeyClose = () => {
apiKeyDialogVisible.value = false;
pendingCloneModel.value = null;
};
const handleClose = () => {
visible.value = false;
selectedModel.value = null;
};
</script>
<style scoped lang="scss">
.model-selector-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 12px;
}
.search-bar {
display: flex;
gap: 12px;
flex: 1;
}
.model-list {
min-height: 300px;
max-height: 400px;
overflow-y: auto;
}
.model-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.model-card {
background: #f8fafc;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.model-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.model-card.selected {
border-color: #67c23a;
background: #f0f9ff;
}
.model-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.model-type {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
background: #eff6ff;
color: #3b82f6;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
.model-owner-tag {
margin-left: 2px;
}
}
.check-icon {
font-size: 20px;
}
.model-card-body {
flex: 1;
}
.model-name {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-url {
font-size: 12px;
color: #94a3b8;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-status {
display: flex;
align-items: center;
}
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 20px;
}
.api-key-alert {
margin-bottom: 16px;
}
.api-key-form {
margin-bottom: 4px;
}
</style>
@@ -6,19 +6,65 @@
<el-form-item label="节点名称">
<el-input :model-value="selectedNode.data?.label" @update:model-value="updateNodeLabel" />
</el-form-item>
<el-form-item label="节点类型">
<el-tag>{{ selectedNode.data?.nodeCode }}</el-tag>
<el-form-item v-if="!isStartNode" label="节点描述">
<el-input
:model-value="selectedNode.data?.desc || ''"
placeholder="请输入节点描述"
@update:model-value="updateNodeDesc"
/>
</el-form-item>
<!-- 模型选择 -->
<el-form-item v-if="nodeConfig?.modelConfig && nodeConfig.modelConfig.length > 0" label="选择模型">
<el-button type="primary" @click="emit('openModelSelector')" style="width: 100%">选择模型</el-button>
<div v-if="selectedNode.data?.modelConfig?.modelName" class="selected-tag">
<el-tag type="success" size="large" closable @close="emit('removeModel')">
{{ selectedNode.data.modelConfig.modelName }}
</el-tag>
</div>
<!-- 前置方法 / 后置方法节点库 preToolOption / postToolOption 配置定义值保存到节点顶层 preTool / postTool outputConfig 平级 -->
<el-form-item v-if="preToolDef" :label="preToolDef.label || '前置方法'">
<el-select
:model-value="selectedNode.data?.preTool ?? ''"
@update:model-value="updatePreTool"
clearable
class="w100"
:placeholder="preToolDef.required ? '必选' : '选填'"
>
<el-option
v-for="opt in preToolDef.options || []"
:key="opt.key || opt.value"
:label="opt.value ?? opt.key"
:value="opt.key ?? opt.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="postToolDef" :label="postToolDef.label || '后置方法'">
<el-select
:model-value="selectedNode.data?.postTool ?? ''"
@update:model-value="updatePostTool"
clearable
class="w100"
:placeholder="postToolDef.required ? '必选' : '选填'"
>
<el-option
v-for="opt in postToolDef.options || []"
:key="opt.key || opt.value"
:label="opt.value ?? opt.key"
:value="opt.key ?? opt.value"
/>
</el-select>
</el-form-item>
<!-- 开始节点运行表单字段摘要来自各模型节点勾选的表单展示字段 -->
<template v-if="isStartNode">
<el-divider content-position="left">运行表单字段</el-divider>
<div v-if="runFormFields.length === 0" class="run-form-empty">
暂无展示字段请在模型节点参数中勾选表单展示
</div>
<div v-else class="run-form-list">
<div v-for="(f, i) in runFormFields" :key="i" class="run-form-item">
<el-tooltip :content="f.path" placement="top">
<span class="run-form-label">{{ f.label }}</span>
</el-tooltip>
<span class="run-form-node">{{ f.nodeLabel }}</span>
<el-tag size="small" type="info" class="run-form-type">{{ fieldTypeLabel(f.fieldType) }}</el-tag>
<el-tag v-if="f.required" size="small" type="danger">必填</el-tag>
</div>
</div>
</template>
<!-- 技能选择 -->
<el-form-item v-if="nodeConfig?.skillOption" label="选择技能">
@@ -30,113 +76,376 @@
</div>
</el-form-item>
<!-- 贴片布局开关 -->
<el-form-item v-if="nodeConfig?.patchLayout" label="贴片布局">
<el-switch
:model-value="selectedNode.data?.patchLayout ?? false"
@update:model-value="emit('update:patchLayout', $event)"
/>
</el-form-item>
<!-- 是否保存文件开关 -->
<el-form-item v-if="nodeConfig?.isSaveFileOption" label="是否保存文件">
<el-switch
:model-value="selectedNode.data?.isSaveFile ?? false"
active-text="开启"
inactive-text="关闭"
@update:model-value="updateIsSaveFile"
/>
</el-form-item>
<!-- 提示词promptOption -->
<el-form-item v-if="nodeConfig?.promptOption" label="提示词">
<PromptEditor
:model-value="selectedNode.data?.prompt ?? ''"
@update:model-value="updatePrompt('prompt', $event)"
/>
</el-form-item>
<!-- 反向提示词negativePromptOption -->
<el-form-item v-if="nodeConfig?.negativePromptOption" label="反向提示词">
<PromptEditor
:model-value="selectedNode.data?.negativePrompt ?? ''"
button-text="编辑反向提示词"
dialog-title="编辑反向提示词"
@update:model-value="updatePrompt('negativePrompt', $event)"
/>
</el-form-item>
<!-- 动态表单字段 -->
<template v-if="nodeConfig?.formConfig && nodeConfig.formConfig.length > 0">
<el-divider content-position="left">节点参数</el-divider>
<el-form-item v-for="field in nodeConfig.formConfig" :key="field.field" :label="field.label" :required="field.required">
<el-input
v-if="field.type === 'input' || field.type === 'string'"
:model-value="getFieldValue(field.field)"
@update:model-value="updateFieldValue(field.field, $event)"
:placeholder="field.required ? '必填' : '选填'"
/>
<el-input-number
v-else-if="field.type === 'number' || field.type === 'inputNumber'"
:model-value="getFieldValue(field.field)"
@update:model-value="updateFieldValue(field.field, $event)"
class="w100"
/>
<el-input
v-else-if="field.type === 'textarea'"
:model-value="getFieldValue(field.field)"
@update:model-value="updateFieldValue(field.field, $event)"
type="textarea"
:rows="3"
:placeholder="field.required ? '必填' : '选填'"
/>
<el-switch
v-else-if="field.type === 'switch'"
:model-value="getFieldValue(field.field)"
@update:model-value="updateFieldValue(field.field, $event)"
/>
<el-select
v-else-if="field.type === 'select' && field.options"
:model-value="getFieldValue(field.field)"
@update:model-value="updateFieldValue(field.field, $event)"
:placeholder="field.required ? '必填' : '选填'"
class="w100"
>
<el-option v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-input
v-else
:model-value="getFieldValue(field.field)"
@update:model-value="updateFieldValue(field.field, $event)"
:placeholder="field.required ? '必填' : '选填'"
/>
</el-form-item>
</template>
</el-form>
<template v-for="field in nodeConfig.formConfig" :key="field.field">
<el-form-item :required="field.required">
<template #label>
<span class="dyn-field-label">{{ field.label }}</span>
<!-- 脚本转写节点的视频总时长支持引用上级节点输出引用后隐藏输入显示引用来源 -->
<MfReferenceButton v-if="isTotalDurationField(field)" :field-def="fieldRefDef(field)" :upstream-nodes="upstreamNodes" :multi="false" />
</template>
<!-- 脚本转写节点的视频总时长引用上级输出后隐藏输入控件显示引用来源标签 -->
<template v-if="isTotalDurationField(field) && hasFieldRef(field.field)">
<div class="mf-ref-bound">
<div class="mf-ref-tags">
<el-tag
v-for="(ref, i) in fieldRefList(field.field)"
:key="`${ref.nodeId}-${ref.field}-${i}`"
size="small"
class="mf-ref-tag"
:type="ref.invalid ? 'danger' : 'primary'"
closable
@close="removeFieldRef(field, ref)"
>{{ ref.label }}</el-tag>
</div>
<el-button size="small" text type="danger" @click="clearFieldRef(field)">清空</el-button>
</div>
</template>
<template v-else>
<el-input
v-if="field.type === 'input' || field.type === 'string'"
:model-value="getFieldValue(field.field)"
@update:model-value="updateFieldValue(field.field, $event)"
:placeholder="field.required ? '必填' : '选填'"
/>
<el-input-number
v-else-if="field.type === 'number' || field.type === 'inputNumber'"
:model-value="getFieldValue(field.field)"
@update:model-value="updateFieldValue(field.field, $event)"
class="w100"
/>
<el-input
v-else-if="field.type === 'textarea'"
:model-value="getFieldValue(field.field)"
@update:model-value="updateFieldValue(field.field, $event)"
type="textarea"
:rows="3"
:placeholder="field.required ? '必填' : '选填'"
/>
<el-switch
v-else-if="field.type === 'switch'"
:model-value="getFieldValue(field.field)"
@update:model-value="updateFieldValue(field.field, $event)"
/>
<el-select
v-else-if="field.type === 'select' && field.options"
:model-value="getFieldValue(field.field)"
@update:model-value="updateSelectFieldValue(field, $event)"
:placeholder="field.required ? '必填' : '选填'"
class="w100"
>
<el-option
v-for="opt in field.options"
:key="opt.key || opt.value"
:label="opt.value ?? opt.key"
:value="opt.key ?? opt.value"
/>
</el-select>
<KeyValueEditor
v-else-if="field.type === 'keyValue'"
:model-value="getKeyValueValue(field.field)"
@update:model-value="(v: any) => updateFieldValue(field.field, v)"
/>
<el-button
v-else-if="field.type === 'schemaJson'"
@click="openSchemaEditor(field.field)"
style="width: 100%"
>配置</el-button>
<el-input
v-else
:model-value="getFieldValue(field.field)"
@update:model-value="updateFieldValue(field.field, $event)"
:placeholder="field.required ? '必填' : '选填'"
/>
</template>
</el-form-item>
<!-- 上级参数管理 -->
<InputSourceManager
v-if="selectedNode.data?.nodeCode !== '__start__'"
:selected-node="selectedNode"
:nodes="allNodes"
:edges="allEdges"
@remove-field="(nodeId: string, fieldName: string) => emit('removeField', nodeId, fieldName)"
@toggle-output="(nodeId: string, enabled: boolean) => emit('toggleOutput', nodeId, enabled)"
@add-param="(paramValue: string) => emit('addParamByValue', paramValue)"
/>
<!-- 下拉选中选项的嵌套配置responseType 级联 -->
<template v-if="getSelectedOptionConfig(field).length > 0">
<el-form-item
v-for="sub in getSelectedOptionConfig(field)"
:key="`${field.field}-${sub.field}`"
:label="sub.label"
:required="sub.required"
>
<el-input
v-if="sub.type === 'input' || sub.type === 'string'"
:model-value="getExpandValue(field.field, sub.field)"
@update:model-value="(v: any) => updateExpandValue(field.field, sub.field, v)"
:placeholder="sub.required ? '必填' : '选填'"
/>
<el-input-number
v-else-if="sub.type === 'number' || sub.type === 'inputNumber'"
:model-value="getExpandValue(field.field, sub.field)"
@update:model-value="(v: any) => updateExpandValue(field.field, sub.field, v)"
class="w100"
/>
<el-switch
v-else-if="sub.type === 'switch'"
:model-value="getExpandValue(field.field, sub.field)"
@update:model-value="(v: any) => updateExpandValue(field.field, sub.field, v)"
/>
<el-select
v-else-if="sub.type === 'select' && sub.options"
:model-value="getExpandValue(field.field, sub.field)"
@update:model-value="(v: any) => updateExpandValue(field.field, sub.field, v)"
:placeholder="sub.required ? '必填' : '选填'"
class="w100"
>
<el-option
v-for="opt in sub.options"
:key="opt.key || opt.value"
:label="opt.value ?? opt.key"
:value="opt.key ?? opt.value"
/>
</el-select>
<KeyValueEditor
v-else-if="sub.type === 'keyValue'"
:model-value="getExpandKeyValueValue(field.field, sub.field)"
@update:model-value="(v: any) => updateExpandValue(field.field, sub.field, v)"
/>
<el-button
v-else-if="sub.type === 'schemaJson'"
@click="openSchemaEditor(field.field, sub.field)"
style="width: 100%"
>配置</el-button>
<el-input
v-else
:model-value="getExpandValue(field.field, sub.field)"
@update:model-value="(v: any) => updateExpandValue(field.field, sub.field, v)"
:placeholder="sub.required ? '必填' : '选填'"
/>
</el-form-item>
</template>
</template>
</template>
<!-- 子流程配置 sub_flow 节点选择已有工作流引入其开始参数 -->
<template v-if="isSubFlowNode">
<el-divider content-position="left">子流程配置</el-divider>
<el-form-item label="引入工作流">
<el-button type="primary" plain @click="emit('openWorkflowSelector')" style="width: 100%">选择工作流</el-button>
<div v-if="subFlowConfig" class="selected-tag">
<el-tag type="success" size="large" closable @close="emit('removeWorkflow')">
{{ subFlowConfig.workflowName || subFlowConfig.workflowId }}
</el-tag>
</div>
</el-form-item>
<template v-if="subFlowConfig">
<el-divider content-position="left">引入参数</el-divider>
<SubFlowParams
:fields="subFlowConfig.fields"
:upstream-nodes="upstreamNodes"
:multi="!!nodeConfig?.isMultiParameter"
@update:fields="updateSubFlowFields"
/>
</template>
</template>
<!-- 自定义表单字段formConfigOption form 节点 -->
<template v-if="nodeConfig?.formConfigOption">
<el-divider content-position="left">自定义字段</el-divider>
<FormFieldsEditor :model-value="customFormFields" @update:model-value="updateCustomFields" />
</template>
<!-- 模型选择 -->
<el-form-item v-if="nodeConfig?.modelConfigOption" label="选择模型">
<el-button type="primary" @click="emit('openModelSelector')" style="width: 100%">选择模型</el-button>
<div v-if="selectedNode.data?.modelConfig?.modelName" class="selected-tag">
<el-tag type="success" size="large" closable @close="emit('removeModel')">
{{ selectedNode.data.modelConfig.modelName }}
</el-tag>
</div>
</el-form-item>
<!-- 模型参数选中模型后渲染 requestBodyMapping 递归表单 -->
<template v-if="nodeConfig?.modelConfigOption && selectedNode.data?.modelConfig?.modelId">
<el-divider content-position="left">模型参数</el-divider>
<ModelParamsForm
:model-request-params="selectedNode.data.modelConfig.modelRequestParams"
:upstream-nodes="upstreamNodes"
:multi="!!nodeConfig?.isMultiParameter"
@update:model-request-params="updateModelRequestParams"
/>
</template>
</el-form>
</div>
<el-empty v-else description="请选择一个节点" :image-size="100" />
<!-- Schema 编辑器弹窗 -->
<el-dialog v-model="schemaEditorVisible" title="编辑 Schema" width="960px" append-to-body destroy-on-close>
<div style="height: 560px; overflow-y: auto">
<JsonEditor
v-model="schemaEditorData"
:upstream-nodes="upstreamNodes"
hide-form-config
/>
</div>
<template #footer>
<el-button @click="schemaEditorVisible = false">取消</el-button>
<el-button type="primary" @click="confirmSchemaEditor"> </el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import type { Node } from '@vue-flow/core';
import InputSourceManager from './InputSourceManager.vue';
import KeyValueEditor from './KeyValueEditor.vue';
import MfReferenceButton from './MfReferenceButton.vue';
import FormFieldsEditor, { type FormField } from './FormFieldsEditor.vue';
import ModelParamsForm from './ModelParamsForm.vue';
import PromptEditor from './PromptEditor.vue';
import SubFlowParams from './SubFlowParams.vue';
import type { SubFlowField } from './subFlowTypes';
import { JsonEditor } from '/@/components/json-schema-editor';
interface NodeData {
label?: string;
nodeCode?: string;
inputSource?: Array<{ nodeId: string; field: string[]; quoteOutput?: boolean }> | null;
desc?: string;
formConfig?: any[];
modelConfig?: any;
skillName?: string;
}
interface ParamRef {
id: string;
label: string;
prompt?: string;
negativePrompt?: string;
patchLayout?: boolean;
isSaveFile?: boolean;
preTool?: string | null;
postTool?: string | null;
runFormFields?: any[];
// sub_flowworkflowId +
subFlowConfig?: any;
}
interface NodeConfig {
formConfig: any[];
modelConfig: any[];
preToolOption: any[];
postToolOption: any[];
isMultiParameter: boolean;
modelConfigOption: boolean;
formConfigOption: boolean;
skillOption: boolean;
promptOption: boolean;
negativePromptOption: boolean;
isSaveFileOption: boolean;
patchLayout: boolean;
}
//
interface UpstreamNodeInfo {
id: string;
label: string;
nodeCode: string;
outputFields: { field: string; label: string }[];
}
const props = defineProps<{
selectedNode: Node<NodeData, any, string> | null;
availableParams: ParamRef[];
nodeConfig: NodeConfig | null;
allNodes: Node<NodeData, any, string>[];
allEdges: any[];
upstreamNodes?: UpstreamNodeInfo[];
}>();
//
const isStartNode = computed(() => props.selectedNode?.data?.nodeCode === '__start__');
// index.vue
const runFormFields = computed<any[]>(() => props.selectedNode?.data?.runFormFields || []);
// sub_flow
const isSubFlowNode = computed(() => props.selectedNode?.data?.nodeCode === 'sub_flow');
// subFlowConfig null
const subFlowConfig = computed<any>(() => props.selectedNode?.data?.subFlowConfig || null);
// fieldType
const fieldTypeLabel = (ft: string) => {
const map: Record<string, string> = {
string: '文本',
number: '数字',
switch: '开关',
boolean: '布尔',
select: '下拉',
textarea: '多行文本',
upload: '上传',
};
return map[ft] || ft || '文本';
};
// form formConfigOption
const customFormFields = computed<FormField[]>(() => (props.selectedNode?.data?.formConfig as FormField[]) || []);
const emit = defineEmits<{
(e: 'update:selectedNode', node: Node<NodeData, any, string>): void;
(e: 'addParam', param: ParamRef): void;
(e: 'openModelSelector'): void;
(e: 'removeModel'): void;
(e: 'openSkillSelector'): void;
(e: 'removeSkill'): void;
(e: 'removeField', nodeId: string, fieldName: string): void;
(e: 'toggleOutput', nodeId: string, enabled: boolean): void;
(e: 'addParamByValue', paramValue: string): void;
(e: 'update:patchLayout', value: boolean): void;
(e: 'openWorkflowSelector'): void;
(e: 'removeWorkflow'): void;
}>();
// Schema
const schemaEditorVisible = ref(false);
const schemaEditorField = ref('');
const schemaEditorExpandField = ref(''); // responseType expand
const schemaEditorData = ref<any>({});
function openSchemaEditor(fieldName: string, expandField = '') {
schemaEditorField.value = fieldName;
schemaEditorExpandField.value = expandField;
const val = expandField ? getExpandValue(fieldName, expandField) : getFieldValue(fieldName);
schemaEditorData.value = val && typeof val === 'object' ? { ...val } : {};
schemaEditorVisible.value = true;
}
function confirmSchemaEditor() {
if (schemaEditorExpandField.value) {
updateExpandValue(schemaEditorField.value, schemaEditorExpandField.value, schemaEditorData.value);
} else {
updateFieldValue(schemaEditorField.value, schemaEditorData.value);
}
schemaEditorVisible.value = false;
}
const updateNodeLabel = (newLabel: string) => {
if (!props.selectedNode?.data) return;
const updatedNode = {
@@ -149,12 +458,137 @@ const updateNodeLabel = (newLabel: string) => {
emit('update:selectedNode', updatedNode);
};
const updateNodeDesc = (newDesc: string) => {
if (!props.selectedNode?.data) return;
const updatedNode = {
...props.selectedNode,
data: {
...props.selectedNode.data,
desc: newDesc,
},
};
emit('update:selectedNode', updatedNode);
};
const updateIsSaveFile = (value: boolean) => {
if (!props.selectedNode?.data) return;
const updatedNode = {
...props.selectedNode,
data: {
...props.selectedNode.data,
isSaveFile: value,
},
};
emit('update:selectedNode', updatedNode);
};
// / update:selectedNode VueFlow nodes
const updatePrompt = (field: 'prompt' | 'negativePrompt', value: string) => {
if (!props.selectedNode?.data) return;
const updatedNode = {
...props.selectedNode,
data: {
...props.selectedNode.data,
[field]: value,
},
};
emit('update:selectedNode', updatedNode);
};
// formConfigOption node.data.formConfig
const updateCustomFields = (fields: FormField[]) => {
if (!props.selectedNode?.data) return;
const updatedNode = {
...props.selectedNode,
data: {
...props.selectedNode.data,
formConfig: fields,
},
};
emit('update:selectedNode', updatedNode);
};
// node.data.modelConfig.modelRequestParams
const updateModelRequestParams = (params: Record<string, any> | null) => {
if (!props.selectedNode?.data) return;
const updatedNode = {
...props.selectedNode,
data: {
...props.selectedNode.data,
modelConfig: {
...props.selectedNode.data.modelConfig,
modelRequestParams: params,
},
},
};
emit('update:selectedNode', updatedNode);
};
// / preToolOption / postToolOption model
const preToolDef = computed(() => {
const defs = props.nodeConfig?.preToolOption;
return Array.isArray(defs) && defs.length > 0 ? defs[0] : null;
});
const postToolDef = computed(() => {
const defs = props.nodeConfig?.postToolOption;
return Array.isArray(defs) && defs.length > 0 ? defs[0] : null;
});
// preTool outputConfig
const updatePreTool = (value: string) => {
if (!props.selectedNode?.data) return;
const updatedNode = {
...props.selectedNode,
data: {
...props.selectedNode.data,
preTool: value,
},
};
emit('update:selectedNode', updatedNode);
};
// postTool outputConfig
const updatePostTool = (value: string) => {
if (!props.selectedNode?.data) return;
const updatedNode = {
...props.selectedNode,
data: {
...props.selectedNode.data,
postTool: value,
},
};
emit('update:selectedNode', updatedNode);
};
// ModelField
// VueFlow / fields SubFlowParams watch
const updateSubFlowFields = (fields: SubFlowField[]) => {
if (!props.selectedNode?.data || !props.selectedNode.data.subFlowConfig) return;
const updatedNode = {
...props.selectedNode,
data: {
...props.selectedNode.data,
subFlowConfig: {
...props.selectedNode.data.subFlowConfig,
fields,
},
},
};
emit('update:selectedNode', updatedNode);
};
const getFieldValue = (fieldName: string) => {
if (!props.selectedNode?.data?.formConfig) return '';
const field = props.selectedNode.data.formConfig.find((f: any) => f.field === fieldName);
return field?.value ?? '';
};
// keyValue prop Record<string, unknown>
const getKeyValueValue = (fieldName: string) => {
const v = getFieldValue(fieldName);
return v && typeof v === 'object' ? v : {};
};
const updateFieldValue = (fieldName: string, value: any) => {
if (!props.selectedNode?.data) return;
@@ -183,9 +617,225 @@ const updateFieldValue = (fieldName: string, value: any) => {
};
emit('update:selectedNode', updatedNode);
};
// formConfig
const getFormConfigEntry = (fieldName: string) => {
if (!props.selectedNode?.data?.formConfig) return undefined;
return props.selectedNode.data.formConfig.find((f: any) => f.field === fieldName);
};
// ===== / =====
// valueSource [{ nodeId, field }] node.data.formConfig
// totalDuration
const isTotalDurationField = (field: any) => !!field && field.field === 'totalDuration' && field.isFormField !== true;
// DSL
const getFieldValueSource = (fieldName: string) => {
const entry = getFormConfigEntry(fieldName);
const vs = entry?.valueSource;
if (Array.isArray(vs)) return vs;
if (vs && typeof vs === 'object') return [vs];
return [];
};
// valueSource
const setFieldValueSource = (field: any, valueSource: any) => {
if (!props.selectedNode?.data) return;
const formConfig = props.selectedNode.data.formConfig || [];
const idx = formConfig.findIndex((f: any) => f && f.field === field.field);
const arr = Array.isArray(valueSource) ? valueSource : [];
let updatedFormConfig;
if (idx >= 0) {
updatedFormConfig = [...formConfig];
const { valueSource: _vs, ...rest } = updatedFormConfig[idx];
updatedFormConfig[idx] = arr.length > 0 ? { ...rest, valueSource: arr } : rest;
} else {
updatedFormConfig = [...formConfig, { ...field, value: '', ...(arr.length > 0 ? { valueSource: arr } : {}) }];
}
const updatedNode = {
...props.selectedNode,
data: { ...props.selectedNode.data, formConfig: updatedFormConfig },
};
emit('update:selectedNode', updatedNode);
};
const hasFieldRef = (fieldName: string) => getFieldValueSource(fieldName).length > 0;
// label .
const fieldRefList = (fieldName: string) =>
getFieldValueSource(fieldName).map((r: any) => {
const node = props.upstreamNodes?.find((n) => n.id === r?.nodeId);
const fieldLabel = node?.outputFields.find((f: any) => f.field === r?.field)?.label || r?.field || '';
const invalid = !node || !node.outputFields.some((f: any) => f.field === r?.field);
return { nodeId: r?.nodeId, field: r?.field, label: fieldLabel, invalid };
});
const removeFieldRef = (field: any, ref: any) => {
const keep = getFieldValueSource(field.field).filter((r: any) => !(r?.nodeId === ref.nodeId && r?.field === ref.field));
setFieldValueSource(field, keep);
};
const clearFieldRef = (field: any) => setFieldValueSource(field, []);
// MfReferenceButton valueSourceset/deleteProperty formConfig
const fieldRefDef = (field: any) =>
new Proxy(
{ ...field },
{
get(t, key) {
if (key === 'valueSource') return getFieldValueSource(field.field);
return Reflect.get(t, key);
},
set(t, key, value) {
if (key === 'valueSource') {
setFieldValueSource(field, value);
return true;
}
return Reflect.set(t, key, value);
},
deleteProperty() {
setFieldValueSource(field, undefined);
return true;
},
}
);
// /
watch(
() => {
if (!props.selectedNode?.data?.nodeCode) return null;
const entry = getFormConfigEntry('totalDuration');
// upstreamNodes /
return { vs: entry?.valueSource, upstream: props.upstreamNodes };
},
(state: any) => {
const vs = state?.vs;
if (!vs || typeof vs !== 'object') return;
const arr = Array.isArray(vs) ? vs : [vs];
const anyInvalid = arr.some((r: any) => {
const node = props.upstreamNodes?.find((n) => n.id === r?.nodeId);
return !node || !node.outputFields.some((f: any) => f.field === r?.field);
});
if (!anyInvalid) return;
const keep = arr.filter((r: any) => {
const node = props.upstreamNodes?.find((n) => n.id === r?.nodeId);
return node && node.outputFields.some((f: any) => f.field === r?.field);
});
setFieldValueSource({ field: 'totalDuration' }, keep);
},
{ immediate: true }
);
// config presetOption
const getSelectedOptionConfig = (fieldDef: any) => {
if (fieldDef?.type !== 'select' || !Array.isArray(fieldDef.options)) return [];
const value = getFieldValue(fieldDef.field);
const opt = fieldDef.options.find((o: any) => o.key === value || o.value === value);
return opt?.config || [];
};
// expand
const getExpandValue = (hostField: string, subField: string) => {
const entry = getFormConfigEntry(hostField);
const sub = entry?.expand?.find((e: any) => e.field === subField);
return sub?.value ?? '';
};
// keyValue getKeyValueValue使
const getExpandKeyValueValue = (hostField: string, subField: string) => {
const v = getExpandValue(hostField, subField);
return v && typeof v === 'object' ? v : {};
};
// expand emit
const updateExpandValue = (hostField: string, subField: string, value: any) => {
if (!props.selectedNode?.data) return;
const formConfig = props.selectedNode.data.formConfig || [];
const entryIndex = formConfig.findIndex((f: any) => f.field === hostField);
if (entryIndex < 0) return;
const entry = formConfig[entryIndex];
const expand = entry.expand || [];
const subIndex = expand.findIndex((e: any) => e.field === subField);
const newExpand = [...expand];
if (subIndex >= 0) {
newExpand[subIndex] = { ...newExpand[subIndex], value };
} else {
// expand label config
const opt = (entry.options || []).find((o: any) => o.key === entry.value || o.value === entry.value);
const cd = (opt?.config || []).find((c: any) => c.field === subField);
newExpand.push({ field: subField, label: cd?.label || subField, value });
}
const updatedFormConfig = [...formConfig];
updatedFormConfig[entryIndex] = { ...entry, expand: newExpand };
const updatedNode = {
...props.selectedNode,
data: { ...props.selectedNode.data, formConfig: updatedFormConfig },
};
emit('update:selectedNode', updatedNode);
};
// select value config expand
const updateSelectFieldValue = (fieldDef: any, value: any) => {
if (!props.selectedNode?.data) return;
const formConfig = props.selectedNode.data.formConfig || [];
const entryIndex = formConfig.findIndex((f: any) => f.field === fieldDef.field);
const opt = (fieldDef.options || []).find((o: any) => o.key === value || o.value === value);
const configDefs = opt?.config || [];
const oldExpand = entryIndex >= 0 ? formConfig[entryIndex]?.expand || [] : [];
const newExpand = configDefs.map((cd: any) => {
const old = oldExpand.find((e: any) => e.field === cd.field);
return { ...cd, value: old?.value !== undefined ? old.value : (cd.default ?? '') };
});
let updatedFormConfig;
if (entryIndex >= 0) {
updatedFormConfig = [...formConfig];
updatedFormConfig[entryIndex] = { ...updatedFormConfig[entryIndex], value, expand: newExpand };
} else {
updatedFormConfig = [...formConfig, { ...fieldDef, value, expand: newExpand }];
}
const updatedNode = {
...props.selectedNode,
data: { ...props.selectedNode.data, formConfig: updatedFormConfig },
};
emit('update:selectedNode', updatedNode);
};
</script>
<style scoped lang="scss">
/* 动态字段 label 行:字段名与引用按钮并排 */
.dyn-field-label {
margin-right: 2px;
}
/* 字段引用来源标签区(脚本转写节点「视频总时长」引用上级输出后展示) */
.mf-ref-bound {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
width: 100%;
.mf-ref-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 1;
min-width: 0;
}
.mf-ref-tag {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.config-panel {
background: #ffffff;
border-radius: 10px;
@@ -256,6 +906,51 @@ const updateFieldValue = (fieldName: string, value: any) => {
width: 100%;
}
.run-form-empty {
font-size: 12px;
color: #94a3b8;
padding: 8px 0;
line-height: 1.6;
}
.run-form-list {
.run-form-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border: 1px solid #e2e8f0;
border-radius: 6px;
margin-bottom: 6px;
background: #f8fafc;
.run-form-label {
flex: 1;
min-width: 0;
font-size: 12px;
font-weight: 600;
color: #334155;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.run-form-node {
flex-shrink: 0;
font-size: 11px;
color: #64748b;
background: #eef2f7;
border-radius: 999px;
padding: 0 6px;
line-height: 1.6;
}
.run-form-type {
flex-shrink: 0;
}
}
}
:deep(.el-empty) {
padding: 32px 12px;
}
@@ -12,17 +12,17 @@
<div v-if="!collapsed" class="node-library-content">
<el-empty v-if="nodeLibraryGroups.length === 0" description="暂无节点" :image-size="40" />
<div v-else class="node-library-groups">
<div v-for="group in nodeLibraryGroups" :key="group.group" class="node-group">
<div class="node-group-title">{{ group.label }}</div>
<div v-for="group in nodeLibraryGroups" :key="group.group.key" class="node-group">
<div class="node-group-title">{{ group.group.name }}</div>
<div class="node-group-items">
<el-button
v-for="item in group.items"
:key="item.nodeCode"
v-for="item in group.nodes"
:key="item.key"
text
class="node-item"
@click="emit('addNode', item.nodeCode, item.nodeName)"
@click="emit('addNode', item.key, item.name)"
>
{{ item.nodeName }}
{{ item.name }}
</el-button>
</div>
</div>
@@ -33,7 +33,7 @@
<script setup lang="ts">
import { ArrowLeftBold, ArrowRightBold } from '@element-plus/icons-vue';
import type { NodeLibraryGroup } from '/@/api/settings/creation';
import type { NodeLibraryGroup } from '/@/api/settings/workflow';
defineProps<{
nodeLibraryGroups: NodeLibraryGroup[];
@@ -0,0 +1,93 @@
<template>
<div class="prompt-editor">
<el-button type="primary" plain style="width: 100%" @click="visible = true">
<el-icon><EditPen /></el-icon>
{{ buttonText }}
</el-button>
<div v-if="modelValue" class="pe-filled">
<span class="pe-filled-text" :title="modelValue">{{ modelValue }}</span>
<el-button size="small" text type="danger" @click="clear">清除</el-button>
</div>
<el-dialog
v-model="visible"
:title="dialogTitle"
width="720px"
:close-on-click-modal="false"
destroy-on-close
append-to-body
>
<el-input v-model="draft" type="textarea" :rows="12" maxlength="5000" show-word-limit placeholder="请输入提示词内容" />
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="handleConfirm">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { EditPen } from '@element-plus/icons-vue';
defineOptions({ name: 'PromptEditor' });
const props = withDefaults(
defineProps<{
modelValue?: string;
buttonText?: string;
dialogTitle?: string;
}>(),
{
modelValue: '',
buttonText: '编辑提示词',
dialogTitle: '编辑提示词',
}
);
const emit = defineEmits<{ (e: 'update:modelValue', value: string): void }>();
const visible = ref(false);
const draft = ref('');
// 稿
watch(visible, (val) => {
if (val) draft.value = props.modelValue || '';
});
const handleConfirm = () => {
emit('update:modelValue', draft.value.trim());
visible.value = false;
};
const clear = () => {
emit('update:modelValue', '');
};
</script>
<style scoped lang="scss">
.prompt-editor {
width: 100%;
.pe-filled {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 4px;
padding: 4px 8px;
.pe-filled-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: #0369a1;
}
}
}
</style>
@@ -0,0 +1,220 @@
<template>
<el-dialog v-model="visible" title="选择技能" width="900px" :close-on-click-modal="false" @close="handleClose">
<div class="search-bar">
<el-input v-model="searchParams.keyword" placeholder="搜索技能名称或描述" clearable @clear="handleSearch">
<template #prefix
><el-icon><Search /></el-icon
></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<div class="skill-list" v-loading="loading">
<el-empty v-if="!loading && skillList.length === 0" description="暂无技能数据" :image-size="100" />
<div v-else class="skill-grid">
<div
v-for="skill in skillList"
:key="skill.id"
class="skill-card"
:class="{ selected: selectedSkill?.id === skill.id }"
@click="handleSelectSkill(skill)"
>
<div class="skill-card-header">
<div class="skill-category">{{ skill.category }}</div>
<el-icon v-if="selectedSkill?.id === skill.id" class="check-icon" color="#67c23a"><CircleCheck /></el-icon>
</div>
<div class="skill-card-body">
<h3 class="skill-name">{{ skill.name }}</h3>
<p class="skill-desc">{{ skill.description || '暂无描述' }}</p>
</div>
</div>
</div>
</div>
<div v-if="pagination.total > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.pageNum"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, prev, pager, next"
small
@current-change="handlePageChange"
/>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleConfirm" :disabled="!selectedSkill">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import { Search, CircleCheck } from '@element-plus/icons-vue';
import { getWorkflowSkillList, type WorkflowSkillItem } from '/@/api/settings/workflow';
interface Props {
modelValue: boolean;
defaultSkill?: WorkflowSkillItem | null;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'confirm', skill: WorkflowSkillItem): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
defaultSkill: null,
});
const emit = defineEmits<Emits>();
const visible = ref(false);
const searchParams = reactive({ keyword: '' });
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
const skillList = ref<WorkflowSkillItem[]>([]);
const loading = ref(false);
const selectedSkill = ref<WorkflowSkillItem | null>(null);
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
selectedSkill.value = props.defaultSkill || null;
pagination.pageNum = 1;
fetchSkillList();
}
}
);
watch(visible, (val) => {
if (!val) {
emit('update:modelValue', false);
}
});
const fetchSkillList = async () => {
loading.value = true;
try {
const params = { pageNum: pagination.pageNum, pageSize: pagination.pageSize, keyword: searchParams.keyword || undefined };
const res = await getWorkflowSkillList(params);
skillList.value = res.data?.list || [];
pagination.total = res.data?.total || 0;
// id
if (selectedSkill.value && !selectedSkill.value.id && selectedSkill.value.name) {
const matched = skillList.value.find((s) => s.name === selectedSkill.value!.name);
if (matched) selectedSkill.value = matched;
}
} catch {
skillList.value = [];
pagination.total = 0;
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.pageNum = 1;
fetchSkillList();
};
const handlePageChange = () => {
fetchSkillList();
};
const handleSelectSkill = (skill: WorkflowSkillItem) => {
selectedSkill.value = skill;
};
const handleConfirm = () => {
if (selectedSkill.value) {
emit('confirm', selectedSkill.value);
handleClose();
}
};
const handleClose = () => {
visible.value = false;
selectedSkill.value = null;
};
</script>
<style scoped lang="scss">
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.skill-list {
min-height: 300px;
max-height: 400px;
overflow-y: auto;
}
.skill-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
}
.skill-card {
background: #f8fafc;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.skill-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.skill-card.selected {
border-color: #67c23a;
background: #f0f9ff;
}
.skill-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.skill-category {
display: inline-block;
padding: 2px 8px;
background: #eff6ff;
color: #3b82f6;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.check-icon {
font-size: 20px;
}
.skill-card-body {
flex: 1;
}
.skill-name {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skill-desc {
font-size: 13px;
color: #64748b;
line-height: 1.5;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 40px;
}
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>
@@ -0,0 +1,55 @@
<template>
<div class="sub-flow-params">
<template v-if="fields?.length">
<!-- 薄封装直接复用 ModelField 渲染引入参数
ModelField 就地写 def.value / def.valueSource / def.runtimeShow
fields 元素为同一引用父组件读取同步无需额外同步 -->
<ModelField
v-for="f in fields"
:key="f.field"
:field-def="f"
:path="f.field"
:upstream-nodes="upstreamNodes"
:multi="multi"
/>
</template>
<el-empty v-else description="该工作流无可引入的开始参数" :image-size="60" />
</div>
</template>
<script setup lang="ts">
import { watch } from 'vue';
import ModelField from './ModelField.vue';
import type { SubFlowField } from './subFlowTypes';
defineOptions({ name: 'SubFlowParams' });
interface Props {
fields: SubFlowField[];
upstreamNodes?: any[];
multi?: boolean;
}
interface Emits {
(e: 'update:fields', fields: SubFlowField[]): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// ModelField defvalue/valueSource/runtimeShowdeep watch
// fields emit update:selectedNode
watch(
() => props.fields,
(fields) => {
emit('update:fields', fields);
},
{ deep: true }
);
</script>
<style scoped lang="scss">
.sub-flow-params {
width: 100%;
}
</style>
@@ -0,0 +1,235 @@
<template>
<el-dialog v-model="visible" title="选择要引入的工作流" width="900px" :close-on-click-modal="false" @close="handleClose">
<div class="search-bar">
<el-input v-model="searchParams.keyword" placeholder="搜索工作流名称或描述" clearable @clear="handleSearch">
<template #prefix
><el-icon><Search /></el-icon
></template>
</el-input>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<div class="workflow-list" v-loading="loading">
<el-empty v-if="!loading && workflowList.length === 0" description="暂无工作流数据" :image-size="100" />
<div v-else class="workflow-grid">
<div
v-for="workflow in workflowList"
:key="workflow.id"
class="workflow-card"
:class="{ selected: selectedWorkflow?.id === workflow.id }"
@click="handleSelectWorkflow(workflow)"
>
<div class="workflow-card-header">
<span class="workflow-badge">我的工作流</span>
<el-icon v-if="selectedWorkflow?.id === workflow.id" class="check-icon" color="#67c23a"><CircleCheck /></el-icon>
</div>
<div class="workflow-card-body">
<h3 class="workflow-name">{{ workflow.flowName || workflow.flowTemplateName || workflow.name || '未命名工作流' }}</h3>
<p class="workflow-desc">{{ workflow.description || '暂无描述' }}</p>
</div>
</div>
</div>
</div>
<div v-if="pagination.total > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.pageNum"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, prev, pager, next"
small
@current-change="handlePageChange"
/>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleConfirm" :disabled="!selectedWorkflow">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import { Search, CircleCheck } from '@element-plus/icons-vue';
import { getSubFlowWorkflowList, type SubFlowWorkflowItem } from '/@/api/settings/workflow';
interface Props {
modelValue: boolean;
defaultWorkflow?: SubFlowWorkflowItem | null;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'confirm', workflow: SubFlowWorkflowItem): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
defaultWorkflow: null,
});
const emit = defineEmits<Emits>();
const visible = ref(false);
const searchParams = reactive({ keyword: '' });
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
const workflowList = ref<SubFlowWorkflowItem[]>([]);
const loading = ref(false);
const selectedWorkflow = ref<SubFlowWorkflowItem | null>(null);
watch(
() => props.modelValue,
(val) => {
visible.value = val;
if (val) {
selectedWorkflow.value = props.defaultWorkflow || null;
pagination.pageNum = 1;
fetchWorkflowList();
}
}
);
watch(visible, (val) => {
if (!val) {
emit('update:modelValue', false);
}
});
const fetchWorkflowList = async () => {
loading.value = true;
try {
const params = {
pageNum: pagination.pageNum,
pageSize: pagination.pageSize,
keyword: searchParams.keyword || undefined,
IsOwn: true,
};
const res = await getSubFlowWorkflowList(params);
const data = res.data || {};
// isAdmin=true listFlowTemplateRes listFlowUserRes
// list
const isAdmin = !!data.isAdmin;
const source = isAdmin ? data.listFlowTemplateRes : data.listFlowUserRes;
const list = data.list ?? source?.list ?? [];
workflowList.value = list || [];
pagination.total = data.total ?? source?.total ?? workflowList.value.length;
// id
// flowName / flowTemplateName / name
if (selectedWorkflow.value && !selectedWorkflow.value.id) {
const selName = selectedWorkflow.value.flowName || selectedWorkflow.value.flowTemplateName || selectedWorkflow.value.name;
if (selName) {
const matched = workflowList.value.find((w) => (w.flowName || w.flowTemplateName || w.name) === selName);
if (matched) selectedWorkflow.value = matched;
}
}
} catch {
workflowList.value = [];
pagination.total = 0;
} finally {
loading.value = false;
}
};
const handleSearch = () => {
pagination.pageNum = 1;
fetchWorkflowList();
};
const handlePageChange = () => {
fetchWorkflowList();
};
const handleSelectWorkflow = (workflow: SubFlowWorkflowItem) => {
selectedWorkflow.value = workflow;
};
const handleConfirm = () => {
if (selectedWorkflow.value) {
emit('confirm', selectedWorkflow.value);
handleClose();
}
};
const handleClose = () => {
visible.value = false;
selectedWorkflow.value = null;
};
</script>
<style scoped lang="scss">
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.workflow-list {
min-height: 300px;
max-height: 400px;
overflow-y: auto;
}
.workflow-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
}
.workflow-card {
background: #f8fafc;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.workflow-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.workflow-card.selected {
border-color: #67c23a;
background: #f0f9ff;
}
.workflow-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.workflow-badge {
display: inline-block;
padding: 2px 8px;
background: #eff6ff;
color: #3b82f6;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.check-icon {
font-size: 20px;
}
.workflow-card-body {
flex: 1;
}
.workflow-name {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-desc {
font-size: 13px;
color: #64748b;
line-height: 1.5;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 40px;
}
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>
@@ -0,0 +1,509 @@
// 模型参数表单共用工具(workflow 独立实现,不依赖内容创作)
// 与项目一致的深拷贝方式
export function deepClone<T>(val: T): T {
return JSON.parse(JSON.stringify(val ?? null)) as T;
}
// 按类型生成叶子字段空值
export function defaultLeafValue(def: any): any {
const t = def?.type || 'string';
const ft = def?.fieldType || '';
if (t === 'boolean' || ft === 'switch') return false;
if (t === 'number' || ft === 'number') return 0;
return '';
}
// 归一化数组元素:元素为 { type:'object', attrs } 包装时,递归预填 attrs 子字段
export function normalizeArrayItem(item: any): any {
if (item && typeof item === 'object') {
if (item.type === 'object' && item.attrs && typeof item.attrs === 'object' && !Array.isArray(item.attrs)) {
Object.values(item.attrs).forEach(normalizeFieldDef);
}
}
return item;
}
// 归一化字段定义:用 defaultValue 预填 value;递归处理 object/array 子树
export function normalizeFieldDef(def: any): any {
if (!def || typeof def !== 'object') return def;
const t = def.type;
if (t === 'object') {
const attrs = def.attrs;
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
Object.values(attrs).forEach(normalizeFieldDef);
}
if (def.value === undefined || def.value === null) {
def.value = def.defaultValue !== undefined && def.defaultValue !== null ? def.defaultValue : {};
}
} else if (t === 'array') {
if (Array.isArray(def.value)) {
def.value.forEach(normalizeArrayItem);
} else if (Array.isArray(def.defaultValue)) {
def.value = def.defaultValue.map((it: any) => normalizeArrayItem(deepClone(it)));
} else {
def.value = [];
}
} else {
if (def.value === undefined || def.value === null) {
def.value = def.defaultValue !== undefined && def.defaultValue !== null ? def.defaultValue : defaultLeafValue(def);
}
}
return def;
}
// 归一化整个 requestBodyMapping 参数模板对象
export function normalizeModelParams(params: any): Record<string, any> {
if (!params || typeof params !== 'object') return {};
Object.values(params).forEach(normalizeFieldDef);
return params;
}
// 剔除 isForm === false(只读)字段:不在节点表单中显示,且不随工作流保存。
// 级联规则:容器若内部无可编辑子级则无意义,一并剔除
// 1. 字段自身 isForm === false → 整体剔除
// 2. object:剔除子字段后 attrs 为空(无可编辑子字段)→ object 一并剔除
// 3. array:过滤「子字段全部只读」的元素模板;无可编辑模板 → array 一并剔除
// 4. array 已添加元素内部被清空 → 一并移除
export function stripReadonlyFields(params: any): any {
if (!params || typeof params !== 'object' || Array.isArray(params)) return params;
for (const key of Object.keys(params)) {
const def = params[key];
if (!def || typeof def !== 'object') continue;
// 自身标记为只读 → 整体删除
if (def.isForm === false) {
delete params[key];
continue;
}
if (def.type === 'object') {
const attrs = def.attrs;
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
stripReadonlyFields(attrs);
// 子字段全部只读 → object 无意义,一并剔除
if (Object.keys(attrs).length === 0) {
delete params[key];
}
}
} else if (def.type === 'array') {
let hasEditable = false;
// 单一元素模板(attrs 为 object
if (def.attrs && typeof def.attrs === 'object' && !Array.isArray(def.attrs)) {
stripReadonlyFields(def.attrs);
if (Object.keys(def.attrs).length > 0) hasEditable = true;
}
// 多元素模板:优先 enumValues(与 ModelField.arrayTemplates 一致),其次 attrs 数组;过滤子字段全部只读的空模板
const isEnumValues = Array.isArray(def.enumValues) && def.enumValues.length > 0;
const isAttrsArray = Array.isArray(def.attrs) && def.attrs.length > 0;
const templates = isEnumValues ? def.enumValues : isAttrsArray ? def.attrs : [];
const keptTemplates = templates.filter((t: any) => {
if (t && typeof t === 'object' && t.attrs && typeof t.attrs === 'object' && !Array.isArray(t.attrs)) {
stripReadonlyFields(t.attrs);
return Object.keys(t.attrs).length > 0;
}
return true;
});
// 有模板被剔除(子字段全只读)→ 数组索引被压缩;给保留的模板补记原始位置 __originIndex
// 供 buildModelRequestParamsPath 生成后端契约的 enumValues 原始索引(幂等:已带则不覆盖)
if (keptTemplates.length !== templates.length) {
templates.forEach((t: any, originIdx: number) => {
if (t && typeof t === 'object' && keptTemplates.includes(t) && t.__originIndex === undefined) {
t.__originIndex = originIdx;
}
});
}
if (isEnumValues) def.enumValues = keptTemplates;
if (isAttrsArray) def.attrs = keptTemplates;
if (keptTemplates.length > 0) hasEditable = true;
// 已添加元素内部清理后为空的 → 一并移除
if (Array.isArray(def.value)) {
def.value.forEach((it: any) => {
if (it && typeof it === 'object' && it.attrs && typeof it.attrs === 'object' && !Array.isArray(it.attrs)) {
stripReadonlyFields(it.attrs);
}
});
def.value = def.value.filter((it: any) => {
if (it && typeof it === 'object' && it.attrs && typeof it.attrs === 'object' && !Array.isArray(it.attrs)) {
return Object.keys(it.attrs).length > 0;
}
return true;
});
}
// 无可编辑内容 → array 无意义,一并剔除
if (!hasEditable) {
delete params[key];
}
}
}
return params;
}
// 剔除 array 类型字段的实例值(value):后端模型定义中 array 仅保存结构/模板
// enumValues / attrs),前端渲染时从模板复制并填充的 value 实例是冗余数据,
// 保存工作流时不提交。递归处理嵌套结构(array 字段可嵌套于 enumValues 模板中,
// 如 messages.enumValues[0].attrs.content 仍是 array,同样需删除 value)。
export function removeArrayValueInstances(params: any): any {
if (!params || typeof params !== 'object' || Array.isArray(params)) return params;
// 单个字段定义(带 type):array 删实例值并递归模板;object 递归 attrs
if (typeof params.type === 'string') {
if (params.type === 'array') {
delete params.value;
if (params.attrs && typeof params.attrs === 'object') {
if (Array.isArray(params.attrs)) params.attrs.forEach((it: any) => removeArrayValueInstances(it));
else removeArrayValueInstances(params.attrs);
}
if (Array.isArray(params.enumValues)) params.enumValues.forEach((tpl: any) => removeArrayValueInstances(tpl));
} else if (params.type === 'object' && params.attrs && typeof params.attrs === 'object' && !Array.isArray(params.attrs)) {
removeArrayValueInstances(params.attrs);
}
return params;
}
// 字段定义容器 { key: fieldDef }:逐个字段递归
for (const key of Object.keys(params)) {
removeArrayValueInstances(params[key]);
}
return params;
}
// ===== 暴露清单:在工作流表单中展示的勾选字段 =====
// array 的模板元素列表(与 ModelField.arrayTemplates 优先级一致):
// enumValues 数组 → attrs 数组 → 单个 attrs 对象。勾选/值都跟随模板,故 path 用
// enumValues[i] 定位模板,collect 与 resolve 共用本函数保持文法一致。
function arrayTemplatesOf(def: any): any[] {
if (!def || typeof def !== 'object') return [];
if (Array.isArray(def.enumValues) && def.enumValues.length) return def.enumValues;
if (Array.isArray(def.attrs) && def.attrs.length) return def.attrs;
if (def.attrs && typeof def.attrs === 'object' && !Array.isArray(def.attrs)) return [{ type: 'object', attrs: def.attrs }];
return [];
}
// 单个暴露叶子字段(路径带模板索引,如 "messages.enumValues[0].attrs.content"
export interface ExposedField {
path: string;
label: string;
fieldType: string;
type: string;
required: boolean;
options?: any[];
value?: any;
defaultValue?: any; // 默认值:首页初始化/回显用
fieldConstraint?: any; // 字段约束:上传格式/大小/数量、数字 min/max 等
valueSource?: any; // 引用上游节点输出([{ nodeId, field }] 数组,支持多选);有则首页只读展示
multiple?: boolean; // 多文件上传标记
refNodeId?: string; // 预留:引用其他节点功能(后续启用)
}
// 将模型模板字段级约束(constraintJsonEditor 风格:min/max/uploadRules/accept/maxSize/maxCount 等)
// 归一化为首页运行表单消费的 fieldConstraintminValue/maxValue/fileTypes/maxFileSize/maxFileCount)。
// 模板字段约束实际挂在 constraint 键下(如 video_url.url 的 uploadRules、duration 的 min/numberType),
// 与 collectExposedFields 原读取的 fieldConstraint 键不一致,需在此转换后随 modelFormFields 透传首页。
function toFieldConstraint(constraint: any): any {
if (!constraint || typeof constraint !== 'object') return undefined;
const out: Record<string, any> = {};
// 数字 min/max → 首页 el-input-number 的 minValue/maxValue
if (constraint.min !== undefined && constraint.min !== null) out.minValue = constraint.min;
if (constraint.max !== undefined && constraint.max !== null) out.maxValue = constraint.max;
// 上传约束:uploadRules 数组取第一条;兼容顶层 accept/maxSize/maxCount 单值形式
const rules = Array.isArray(constraint.uploadRules) ? constraint.uploadRules.filter((r: any) => r && typeof r === 'object') : [];
const rule = rules[0] || null;
const fileTypes = rule?.format ?? constraint.accept;
if (fileTypes !== undefined && fileTypes !== null && fileTypes !== '') {
// 模板 format 可能用中文顿号/空白分隔(如 wan 模型 "wav、mp3"),
// 统一转英文逗号,保证首页 split(',') 与扩展名匹配正确
out.fileTypes = String(fileTypes)
.replace(/[、\s]+/g, ',')
.replace(/,+/g, ',')
.replace(/^,|,$/g, '');
}
const maxSize = rule?.maxSize ?? constraint.maxSize;
if (maxSize !== undefined && maxSize !== null) out.maxFileSize = maxSize;
const maxCount = rule?.maxCount ?? constraint.maxCount;
if (maxCount !== undefined && maxCount !== null) out.maxFileCount = maxCount;
return Object.keys(out).length > 0 ? out : undefined;
}
// 收集 runtimeShow === true 的叶子字段,生成暴露清单
// 路径文法(与 restoreRuntimeShow 共用):
// object 子字段 → 父路径 + ".attrs." + 子key
// array 模板元素 → 父路径 + ".enumValues[i]"
// 原始值数组元素(非 object 包装)不可勾选,跳过
export function collectExposedFields(params: any, prefix = ''): ExposedField[] {
if (!params || typeof params !== 'object') return [];
const result: ExposedField[] = [];
for (const key of Object.keys(params)) {
const def = params[key];
if (!def || typeof def !== 'object') continue;
const path = prefix ? `${prefix}.${key}` : key;
const t = def.type;
if (t === 'object') {
const attrs = def.attrs;
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
result.push(...collectExposedFields(attrs, `${path}.attrs`));
}
} else if (t === 'array') {
// 勾选跟随模板:渲染时 value 元素与模板共享引用,runtimeShow 落在模板上,
// 故遍历模板(enumValues 优先)收集,path 用 enumValues[i] 定位模板
const templates = arrayTemplatesOf(def);
templates.forEach((tpl: any, i: number) => {
if (tpl && typeof tpl === 'object' && tpl.attrs && typeof tpl.attrs === 'object' && !Array.isArray(tpl.attrs)) {
// 索引与 collectAllLeafFields 一致:优先用 strip 时记录的原始模板索引 __originIndex
// 保证 modelFormFields 与 modelRequestParamsPath 的 path 索引对齐后端模型完整定义
const originIdx = typeof tpl.__originIndex === 'number' ? tpl.__originIndex : i;
result.push(...collectExposedFields(tpl.attrs, `${path}.enumValues[${originIdx}].attrs`));
}
});
} else if (def.runtimeShow === true) {
// 叶子且已勾选
result.push({
path,
label: def.label || key,
fieldType: def.fieldType || 'string',
type: t || 'string',
required: !!def.required,
options: Array.isArray(def.options) ? def.options : undefined,
value: def.value,
defaultValue: def.defaultValue,
// 优先透传已有的 fieldConstraint(首页格式);模板字段约束在 constraint 键时归一化补全
fieldConstraint:
(def.fieldConstraint && typeof def.fieldConstraint === 'object' ? def.fieldConstraint : undefined) ??
toFieldConstraint(def.constraint),
valueSource: (() => {
const vs = def.valueSource;
// 契约统一数组;兼容旧 DSL 单对象 { nodeId, field }
return Array.isArray(vs) ? vs : vs && typeof vs === 'object' ? [vs] : undefined;
})(),
multiple: def.multiple || def.fieldType === 'uploadMultiple' || undefined,
});
}
}
return result;
}
// ===== 模型请求参数扁平清单(modelRequestParamsPath=====
// 后端契约:保存时与 modelRequestParams 平级新增 modelRequestParamsPath
// 为嵌套模型参数树的全部叶子字段扁平清单(path 文法与 collectExposedFields 一致),
// 后端按 path 重组请求参数。value 保留当前值;valueSource 规则(统一数组 [{ nodeId, field }]):
// - 已有引用配置(用户在设计器配置「引用上游」)→ 原样保留数组
// - 无引用但勾选「表单展示」(runtimeShow=true)→ 补 [{ nodeId: 开始节点id, field: path }]
// - 其余 → 不带 valueSource
export interface ModelRequestParamsPathItem {
path: string;
// 叶子字段中文名(回显/展示用;无 label 时兜底 path 最后一段字段名)
label: string;
type: string;
required: boolean;
value: any;
valueSource?: any;
}
// 收集嵌套树的全部叶子字段(含数组模板元素,不过滤 runtimeShow),返回 { path, def }
function collectAllLeafFields(params: any, prefix = ''): { path: string; def: any }[] {
if (!params || typeof params !== 'object' || Array.isArray(params)) return [];
const result: { path: string; def: any }[] = [];
for (const key of Object.keys(params)) {
const def = params[key];
if (!def || typeof def !== 'object') continue;
const path = prefix ? `${prefix}.${key}` : key;
const t = def.type;
if (t === 'object') {
const attrs = def.attrs;
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
result.push(...collectAllLeafFields(attrs, `${path}.attrs`));
}
} else if (t === 'array') {
// 数组字段遍历模板(enumValues 优先,其次 attrs 数组),path 用 enumValues[i] 定位模板,
// 与 ModelField 渲染 / collectExposedFields 文法一致(值/勾选均落在模板上,不依赖 value 实例)。
// 模板带 __originIndexstrip 时记录的原始位置)时用原始索引,保证与后端模型完整定义的 enumValues 编号一致
const templates = arrayTemplatesOf(def);
templates.forEach((tpl: any, i: number) => {
if (tpl && typeof tpl === 'object' && tpl.attrs && typeof tpl.attrs === 'object' && !Array.isArray(tpl.attrs)) {
const originIdx = typeof tpl.__originIndex === 'number' ? tpl.__originIndex : i;
result.push(...collectAllLeafFields(tpl.attrs, `${path}.enumValues[${originIdx}].attrs`));
}
});
} else {
result.push({ path, def });
}
}
return result;
}
// 生成 modelRequestParamsPath 扁平清单
// 所有叶子字段(含对象/数组内部)都支持「引用上游」,valueSource 原样保留;
// 与编辑器「引用上级输出与表单展示同级」语义一致
export function buildModelRequestParamsPath(
params: any,
startNodeId = '',
// 节点输出索引(id → 节点名 + 字段 label),用于补全 valueSource 的引用来源 label
outputIndex?: Map<string, { name: string; fields: Map<string, string> }>
): ModelRequestParamsPathItem[] {
const fields = collectAllLeafFields(params);
// 引用来源 label:字段中文名(不带「节点名.」前缀;无索引/未命中时兜底 field)
const resolveRefLabel = (nodeId: string, field: string): string => {
const info = outputIndex?.get(nodeId);
if (!info) return field;
return info.fields.get(field) || field;
};
return fields.map(({ path, def }) => {
let valueSource: any;
if (Array.isArray(def.valueSource)) {
// 引用上游:前端编辑态 [{ nodeId, field }] 数组,逐项补 label
valueSource = def.valueSource.map((r: any) =>
r && r.nodeId && r.field
? { nodeId: r.nodeId, field: r.field, label: resolveRefLabel(r.nodeId, r.field) }
: r
);
} else if (def.valueSource && typeof def.valueSource === 'object') {
// 兼容旧 DSL 单对象 { nodeId, field }
const r = def.valueSource as any;
valueSource = [
r && r.nodeId && r.field
? { nodeId: r.nodeId, field: r.field, label: resolveRefLabel(r.nodeId, r.field) }
: r,
];
} else if (def.runtimeShow === true && startNodeId) {
// 勾选「表单展示」:补后端契约数组(指向开始节点,label 取开始节点字段名)
valueSource = [{ nodeId: startNodeId, field: path, label: resolveRefLabel(startNodeId, path) }];
}
return {
path,
label: def.label || path.split('.').pop() || '',
type: def.type || 'string',
required: Boolean(def.required),
value: def.value ?? '',
...(valueSource ? { valueSource } : {}),
};
});
}
// 按暴露清单的 path 反解,把对应叶子 def 的 runtimeShow 置 true(加载时还原勾选)
// startNodeId:勾选「表单展示」的字段保存时带 valueSource 指向开始节点(后端契约);
// 回显时该 valueSource 无编辑器引用语义,清除避免 ModelField 误判引用/误删
export function restoreRuntimeShow(params: any, fields: ExposedField[] | null | undefined, startNodeId = ''): void {
if (!params || typeof params !== 'object' || !Array.isArray(fields) || fields.length === 0) return;
for (const f of fields) {
if (!f || typeof f.path !== 'string') continue;
const cur = resolvePath(params, f.path);
if (cur && typeof cur === 'object' && !Array.isArray(cur)) {
cur.runtimeShow = true;
if (startNodeId && cur.valueSource && typeof cur.valueSource === 'object') {
// 移除指向开始节点(表单展示标记)的引用;兼容旧 DSL 单对象(统一数组化),全部移除后删除整个 valueSource
const arr = Array.isArray(cur.valueSource) ? cur.valueSource : [cur.valueSource];
const keep = arr.filter((r: any) => !(r && r.nodeId === startNodeId));
if (keep.length === 0) delete cur.valueSource;
else cur.valueSource = keep;
}
}
}
}
// 勾选「表单展示」的字段:保存前按后端契约补 valueSource={nodeId:开始节点id, field:path}
// 标记字段值来自主工作流开始节点(首页表单)。startNodeId 为空(无开始节点)时跳过。
export function attachFormFieldValueSources(params: any, fields: ExposedField[] | null | undefined, startNodeId = ''): void {
if (!params || typeof params !== 'object' || !Array.isArray(fields) || fields.length === 0 || !startNodeId) return;
for (const f of fields) {
if (!f || typeof f.path !== 'string') continue;
const cur = resolvePath(params, f.path);
if (cur && typeof cur === 'object' && !Array.isArray(cur)) {
// 已有指向上游节点的真实引用时保持互斥:不覆盖为开始节点标记,避免引用丢失
const vs = cur.valueSource;
const hasRealRef = Array.isArray(vs)
? vs.some((r: any) => r && r.nodeId && r.nodeId !== startNodeId)
: !!vs && typeof vs === 'object' && vs.nodeId && vs.nodeId !== startNodeId;
if (!hasRealRef) {
cur.valueSource = [{ nodeId: startNodeId, field: f.path }];
}
}
}
}
// 按路径走回 params 树;解析失败(schema 变更导致字段缺失)返回 undefined
function resolvePath(params: any, path: string): any {
if (!path) return undefined;
const segments = path.split('.');
let cur = params;
for (const seg of segments) {
if (cur === undefined || cur === null || typeof cur !== 'object' || Array.isArray(cur)) return undefined;
if (seg === 'attrs') {
cur = cur.attrs;
if (!cur || typeof cur !== 'object') return undefined;
} else {
const mv = seg.match(/^value\[(\d+)\]$/);
if (mv) {
const idx = Number(mv[1]);
if (!Array.isArray(cur.value) || idx >= cur.value.length) return undefined;
cur = cur.value[idx];
continue;
}
const me = seg.match(/^enumValues\[(\d+)\]$/);
if (me) {
const idx = Number(me[1]);
const templates = arrayTemplatesOf(cur);
// 路径索引可能为 strip 压缩前记录的原始索引(__originIndex),优先按原始索引匹配;
// 未带 __originIndex 时退化为按数组位置匹配(未压缩场景)
const byOrigin = templates.find((t: any) => t && typeof t.__originIndex === 'number' && t.__originIndex === idx);
if (byOrigin !== undefined) cur = byOrigin;
else if (idx < templates.length) cur = templates[idx];
else return undefined;
continue;
}
cur = cur[seg];
}
}
return cur;
}
// 深度比较(JSON 序列化方式),供 index.vue 的"仅变更才写"守卫使用
export function isEqual(a: any, b: any): boolean {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
}
// ===== 模型返回参数中文 label(引用展示用)=====
// 模型 responseMapping(响应映射:路径→中文)支持两种结构:普通对象 {路径: 中文} 与
// { type, attrs/value, label } 包裹格式。独立实现平铺(不依赖 settings/modelConfigV2),
// 供保存 modelResponseBodyMapping 时把中文描述填入 value,引用上级节点输出展示中文而非英文 key。
function flattenResponseLabelMap(mapping: any, basePath = '', out: Record<string, string>): void {
if (!mapping || typeof mapping !== 'object') return;
// { type, attrs/value } 包裹格式:叶子节点带 label
if (typeof mapping.type === 'string' && ['string', 'number', 'boolean', 'null', 'object', 'array'].includes(mapping.type)) {
const jtype = mapping.type;
const dataKey = jtype === 'object' || jtype === 'array' ? 'attrs' : 'value';
if (jtype === 'object' && dataKey in mapping && mapping[dataKey] && typeof mapping[dataKey] === 'object' && !Array.isArray(mapping[dataKey])) {
for (const [key, val] of Object.entries(mapping[dataKey])) {
flattenResponseLabelMap(val, basePath ? `${basePath}.attrs.${key}` : key, out);
}
} else if (jtype === 'array' && dataKey in mapping && Array.isArray(mapping[dataKey])) {
mapping[dataKey].forEach((item: any, idx: number) => {
flattenResponseLabelMap(item, `${basePath}.attrs[${idx}]`, out);
});
} else if (['string', 'number', 'boolean', 'null'].includes(jtype)) {
if (mapping.label) out[basePath] = String(mapping.label);
}
return;
}
// 普通对象(非包裹格式)
if (!Array.isArray(mapping)) {
for (const [key, val] of Object.entries(mapping)) {
const childPath = basePath ? `${basePath}.${key}` : key;
if (val && typeof val === 'object' && !Array.isArray(val)) {
flattenResponseLabelMap(val, childPath, out);
} else if (typeof val === 'string' && val) {
out[childPath] = val;
}
}
}
}
// 用模型 responseMapping 的中文描述填充 modelResponseBodyMapping 的 value
// { key: '' } → { key: '中文' }。结构不变(对象 key→string),无对应中文时保留原 value(兜底 key)。
export function enrichResponseBodyMapping(body: any, mapping: any): any {
if (!body || typeof body !== 'object' || Array.isArray(body)) return body ?? null;
const labelMap: Record<string, string> = {};
flattenResponseLabelMap(mapping, '', labelMap);
const out: Record<string, string> = {};
for (const k of Object.keys(body)) {
const raw = typeof body[k] === 'string' ? body[k] : '';
out[k] = labelMap[k] || raw || k;
}
return out;
}
@@ -0,0 +1,58 @@
// 子流程节点(sub_flow)引入工作流的类型定义(workflow 独立实现,不依赖内容创作)
// ===== 编辑器态(node.data.subFlowConfig=====
export interface SubFlowField {
// = 目标工作流开始节点 runFormFields 的 field(后端匹配键,不可改)
field: string;
label: string;
// 控件类型:input/number/textarea/switch/select/upload/uploadMultiple
type: string;
fieldType: string;
required: boolean;
// 未引用且未勾选表单展示时的静态值(编辑器可填)
value?: any;
defaultValue?: any;
// upload {fileTypes,maxFileSize,maxFileCount} / number {minValue,maxValue}
fieldConstraint?: any;
// select 选项
options?: any[];
// 多文件上传标记
multiple?: boolean;
// 引用上级节点输出(主工作流上游);有值则编辑器内只读引用展示
valueSource?: { nodeId: string; field: string }[] | null;
// 编辑器内「表单展示」勾选(ModelField 用 runtimeShow
runtimeShow?: boolean;
}
export interface SubFlowConfig {
// 目标工作流 id(后端执行 sub_flow 时引入)
workflowId: string;
workflowName: string;
// 生成次数(sub_flow 执行并发数;未填为 0)
maxConcurrency?: number;
fields: SubFlowField[];
}
// ===== DSL 保存态(node.subConfigruntimeShow → isFormField=====
export interface SubFlowDslField {
field: string;
label: string;
type: string;
fieldType: string;
required: boolean;
value?: any;
defaultValue?: any;
fieldConstraint?: any;
options?: any[] | null;
multiple?: boolean;
valueSource?: { nodeId: string; field: string }[] | null;
// 勾选表单展示 → 聚合进主工作流开始节点(首页表单可填)
isFormField?: boolean;
}
export interface SubFlowConfigDsl {
workflowId: string;
workflowName: string;
maxConcurrency?: number;
fields: SubFlowDslField[];
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,192 @@
<template>
<div class="editor-body">
<div class="editor-col">
<!-- 周期订阅 -->
<div class="card">
<div class="card-head">
<span class="card-title">周期订阅</span>
<span class="head-hint">按周期一次性收取与用量无关</span>
</div>
<div class="card-body">
<el-form label-position="top" size="default">
<el-form-item label="周期 period">
<el-select v-model="period" class="ctrl-select">
<el-option label="year(按年)" value="year" />
</el-select>
</el-form-item>
<el-form-item label="每周期价格(元)">
<el-input-number
v-model="price"
:min="0"
:precision="2"
:step="0.01"
:controls="false"
class="ctrl-input"
/>
</el-form-item>
<div class="form-hint">本期支持按年订阅period=year订阅购买 / 续费流程接入另期</div>
</el-form>
</div>
</div>
</div>
<div class="editor-col">
<!-- 通用配置 -->
<div class="card">
<div class="card-head">
<span class="card-title">通用配置</span>
</div>
<div class="card-body">
<el-form label-position="top" size="default">
<el-form-item label="门禁 min_balance(元)">
<el-input-number
v-model="minBalance"
:min="0"
:precision="2"
:step="0.01"
:controls="false"
class="ctrl-input"
/>
</el-form-item>
<el-form-item label="币种">
<el-select v-model="currency" class="ctrl-select">
<el-option label="人民币 (CNY)" value="CNY" />
<el-option label="美元 (USD)" value="USD" />
</el-select>
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="enabled" />
</el-form-item>
</el-form>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import {
type BusinessRules,
type Currency,
type EditorInitConfig,
type Subject,
} from '../mock';
/**
* 业务模块计价编辑面板
* 周期订阅 per_periodperiod 固定 year价格 + 公共配置
* initConfig 传入后端已保存配置parse 时初始化表单无配置时用默认值
*/
const props = defineProps<{
subject: Subject;
initConfig: EditorInitConfig | null;
}>();
const period = ref<'year'>('year');
const price = ref(1999);
const minBalance = ref(0);
const currency = ref<Currency>('CNY');
const enabled = ref(true);
/** 用后端已保存配置初始化表单 */
function applyConfig(cfg: EditorInitConfig) {
const r = cfg.rules as BusinessRules;
period.value = r.period;
price.value = r.price;
minBalance.value = cfg.minBalance;
currency.value = cfg.currency;
enabled.value = cfg.enabled;
}
/** 重置为默认(无配置) */
function resetDefault() {
period.value = 'year';
price.value = 1999;
minBalance.value = 0;
currency.value = 'CNY';
enabled.value = true;
}
function save(): { rules: BusinessRules; minBalance: number; currency: Currency; enabled: boolean } {
return {
rules: { period: period.value, price: price.value },
minBalance: minBalance.value,
currency: currency.value,
enabled: enabled.value,
};
}
watch(
() => props.initConfig,
(cfg) => {
if (cfg && cfg.rules && (cfg.rules as BusinessRules).period) {
applyConfig(cfg);
} else {
resetDefault();
}
},
{ immediate: true },
);
defineExpose({ save });
</script>
<style scoped lang="scss">
.editor-body {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.editor-col {
flex: 1;
min-width: 380px;
}
.card {
border: 1px solid #e4e7ed;
border-radius: 8px;
margin-bottom: 16px;
overflow: hidden;
.card-head {
background: #fafafa;
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
.card-title {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.head-hint {
font-size: 12px;
color: #909399;
font-weight: 400;
}
}
.card-body {
padding: 14px;
}
}
.form-hint {
font-size: 12px;
color: #909399;
line-height: 1.7;
}
.ctrl-input {
width: 220px;
}
.ctrl-select {
width: 220px;
}
</style>
@@ -0,0 +1,578 @@
<template>
<div class="editor-body">
<div class="editor-col">
<!-- 基础计费 -->
<div class="card">
<div class="card-head">
<span class="card-title">基础计费</span>
</div>
<div class="card-body">
<el-form label-position="top" size="default">
<el-form-item label="计费单位 unit">
<el-select v-model="unit" class="ctrl-select" @change="onUnitChange">
<el-option v-for="o in UNIT_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="阶梯分档 tiered">
<el-switch v-model="tiered" :disabled="!isTokenUnit" />
</el-form-item>
<div class="form-hint">阶梯=按输入 token 分档<b>命中哪档整单按哪档价不累加</b>档位区间相邻不重叠下档 max+1 = 上档 min token 单位per_1K / per_1M可用</div>
<div class="money-note">{{ priceNote }}</div>
</el-form>
</div>
</div>
<!-- 计费规则 -->
<div class="card">
<div class="card-head">
<span class="card-title">计费规则</span>
<span class="head-select">
<el-select :model-value="exampleIndex" size="small" placeholder="选择示例填充" @change="loadExample">
<el-option v-for="(e, i) in examples" :key="i" :label="e.label" :value="i" />
</el-select>
</span>
</div>
<div class="card-body">
<div v-for="(r, i) in modelRules" :key="i" class="rule-card">
<div class="rule-head">
<span class="rule-index">#{{ i + 1 }}</span>
<el-input v-model="r.name" placeholder="规则名(如:有声-1080p-输入含视频)" class="rule-name" />
<el-button size="small" type="danger" text @click="removeRule(i)">删除</el-button>
</div>
<div class="rc-sec match">命中条件 match</div>
<el-form label-position="top" size="small">
<div class="rc-grid">
<template v-if="subject.modelType === 'reason'">
<el-form-item label="思考模式">
<el-select v-model="r.thinking" class="rc-control">
<el-option v-for="o in THINKING_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="输入长度下界">
<el-input-number v-model="r.inLenMin" :min="0" :precision="0" :controls="false" class="rc-control" />
</el-form-item>
<el-form-item label="输入长度上界">
<el-input-number v-model="r.inLenMax" :min="0" :precision="0" :controls="false" class="rc-control" />
</el-form-item>
</template>
<template v-if="subject.modelType === 'video'">
<el-form-item label="输出音频">
<el-select v-model="r.outputAudio" class="rc-control">
<el-option v-for="o in OUTPUT_AUDIO_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
<el-form-item label="输出分辨率">
<el-select v-model="r.outputResolution" class="rc-control">
<el-option v-for="o in RESOLUTION_OPTIONS.video" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
</template>
<template v-if="subject.modelType === 'image'">
<el-form-item label="输出分辨率">
<el-select v-model="r.outputResolution" class="rc-control">
<el-option v-for="o in RESOLUTION_OPTIONS.image" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
</el-form-item>
</template>
</div>
</el-form>
<div class="rc-sec price">计价项 price</div>
<el-form label-position="top" size="small">
<div class="rc-grid">
<template v-if="isTokenUnit">
<el-form-item label="输入单价 input">
<el-input-number v-model="r.input" :min="0" :precision="6" :step="0.0001" :controls="false" class="rc-control" />
</el-form-item>
<el-form-item label="输出单价 output">
<el-input-number v-model="r.output" :min="0" :precision="6" :step="0.0001" :controls="false" class="rc-control" />
</el-form-item>
<el-form-item label="缓存命中 cacheHit">
<el-input-number v-model="r.cacheHit" :min="0" :precision="6" :step="0.0001" :controls="false" class="rc-control" />
</el-form-item>
</template>
<template v-else>
<el-form-item label="每单位单价 unitPrice">
<el-input-number v-model="r.unitPrice" :min="0" :precision="6" :step="0.0001" :controls="false" class="rc-control" />
</el-form-item>
</template>
</div>
</el-form>
<!-- 媒体价格模板 token 单位媒体命中条件已移除改为对特定媒体类型设独立覆盖价 mediaPrices -->
<div v-if="isTokenUnit" class="media-price-section">
<div class="rc-sec price">媒体价格模板 mediaPrices可选</div>
<div v-for="(mp, mIdx) in r.mediaPrices" :key="mIdx" class="media-price-row">
<el-select v-model="mp.mediaType" class="mp-media" placeholder="媒体类型">
<el-option v-for="o in getAvailableMediaOptions(r, mIdx)" :key="o.value" :label="o.label" :value="o.value" />
</el-select>
<el-input-number v-model="mp.input" :min="0" :precision="6" :step="0.0001" :controls="false" class="mp-num" placeholder="input" />
<el-input-number v-model="mp.output" :min="0" :precision="6" :step="0.0001" :controls="false" class="mp-num" placeholder="output" />
<el-input-number v-model="mp.cacheHit" :min="0" :precision="6" :step="0.0001" :controls="false" class="mp-num" placeholder="cacheHit" />
<el-button type="danger" text size="small" @click="removeMediaPrice(r, mIdx)">删除</el-button>
</div>
<el-button size="small" class="media-add-btn" @click="addMediaPrice(r)">+ 添加媒体价格</el-button>
<div class="mp-tip">基础价input/output/cacheHit为默认价媒体模板仅覆盖指定媒体类型的价未选媒体类型的行保存时丢弃</div>
</div>
</div>
<el-button size="small" class="add-btn" @click="addRule">+ 添加规则</el-button>
<div class="note mt10">
结算入参ChargeUsage 扩展本期备用<code>{promptTokens, completionTokens, cachedTokens, charCount, imageCount, mediaType, thinking, outputAudio, outputResolution}</code>匹配 = 全部条件满足的<b>首条规则</b>命中无命中报错
</div>
</div>
</div>
</div>
<div class="editor-col">
<!-- 通用配置 -->
<div class="card">
<div class="card-head">
<span class="card-title">通用配置</span>
</div>
<div class="card-body">
<el-form label-position="top" size="default">
<el-form-item label="门禁 min_balance(元)">
<el-input-number
v-model="minBalance"
:min="0"
:precision="2"
:step="0.01"
:controls="false"
class="ctrl-input"
/>
</el-form-item>
<el-form-item label="币种">
<el-select v-model="currency" class="ctrl-select">
<el-option label="人民币 (CNY)" value="CNY" />
<el-option label="美元 (USD)" value="USD" />
</el-select>
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="enabled" />
</el-form-item>
</el-form>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
EXAMPLES,
MEDIA_PRICE_OPTIONS,
OUTPUT_AUDIO_OPTIONS,
RESOLUTION_OPTIONS,
THINKING_OPTIONS,
UNIT_OPTIONS,
type BuiltModelRule,
type BuiltModelRuleMatch,
type Currency,
type EditorInitConfig,
type MediaPriceRow,
type ModelRule,
type ModelRules,
type Subject,
type Unit,
} from '../mock';
/**
* 模型计价编辑面板按模型类型自适应
* 计费单位决定计价项token 单位 input/output/cacheHit token 单位 unitPrice
* 命中条件 match 字段随模型类型变化reason/video/audio/image
* initConfig 传入后端已保存配置parse 时初始化表单无配置时自动加载本地示例
*/
const props = defineProps<{
subject: Subject;
initConfig: EditorInitConfig | null;
}>();
const examples = computed(() => EXAMPLES[props.subject.modelType!] ?? []);
const unit = ref<Unit>('per_second');
const tiered = ref(false);
const currency = ref<'CNY' | 'USD'>('CNY');
const enabled = ref(true);
const minBalance = ref(0);
const modelRules = ref<ModelRule[]>([]);
const exampleIndex = ref(0);
const isTokenUnit = computed(() => unit.value === 'per_1K' || unit.value === 'per_1M');
const priceNote = computed(() =>
isTokenUnit.value
? 'token 单位价格按 元/基准(per_1M=每百万 token)计:input=输入单价、output=输出单价、cacheHit=缓存命中输入单价。'
: '非 token 单位价格按 元/单位 计:unitPrice=每单位单价(秒/分钟/小时/字/张)。',
);
function emptyRule(): ModelRule {
return {
name: '',
thinking: '',
outputAudio: '',
outputResolution: '',
inLenMin: undefined,
inLenMax: undefined,
input: 0,
output: 0,
cacheHit: 0,
unitPrice: 0,
mediaPrices: [],
};
}
function loadExample(index: number) {
const ex = examples.value[index];
if (!ex) return;
exampleIndex.value = index;
unit.value = ex.unit;
tiered.value = !!ex.tiered;
if (ex.currency) currency.value = ex.currency;
if (ex.minBalance != null) minBalance.value = ex.minBalance;
modelRules.value = ex.rules.map((r) => ({
name: r.name,
thinking: (r.thinking == null ? '' : String(r.thinking)) as ModelRule['thinking'],
outputAudio: (r.outputAudio == null ? '' : String(r.outputAudio)) as ModelRule['outputAudio'],
outputResolution: r.outputResolution || '',
inLenMin: r.inLenMin == null ? undefined : r.inLenMin,
inLenMax: r.inLenMax == null ? undefined : r.inLenMax,
input: r.input ?? 0,
output: r.output ?? 0,
cacheHit: r.cacheHit ?? 0,
unitPrice: r.unitPrice ?? 0,
mediaPrices: mediaPricesToRows(r.mediaPrices),
}));
onUnitChange();
}
function onUnitChange() {
// token
if (!isTokenUnit.value) tiered.value = false;
}
function addRule() {
modelRules.value.push(emptyRule());
}
function removeRule(index: number) {
modelRules.value.splice(index, 1);
}
/** 构建态规则 → 编辑器行态(parse 反向映射) */
function fromBuiltRule(r: BuiltModelRule): ModelRule {
const m = r.match;
return {
name: r.name,
thinking: (m?.thinking == null ? '' : String(m.thinking)) as ModelRule['thinking'],
outputAudio: (m?.outputAudio == null ? '' : String(m.outputAudio)) as ModelRule['outputAudio'],
outputResolution: m?.outputResolution || '',
inLenMin: m?.inputLengthMin,
inLenMax: m?.inputLengthMax,
input: r.price.input ?? 0,
output: r.price.output ?? 0,
cacheHit: r.price.cacheHit ?? 0,
unitPrice: r.price.unitPrice ?? 0,
mediaPrices: mediaPricesToRows(r.mediaPrices),
};
}
/** 用后端已保存配置初始化表单 */
function applyInit(cfg: EditorInitConfig) {
const r = cfg.rules as ModelRules;
unit.value = r.unit;
tiered.value = !!r.tiered;
currency.value = r.currency || cfg.currency;
enabled.value = cfg.enabled;
minBalance.value = cfg.minBalance;
modelRules.value = (r.rules || []).map(fromBuiltRule);
exampleIndex.value = -1;
if (!isTokenUnit.value) tiered.value = false;
}
/** 过滤空白规则:规则名或任一命中/价格字段非空才保留 */
function keepRule(r: ModelRule): boolean {
if (r.name.trim()) return true;
if (r.thinking || r.outputAudio || r.outputResolution) return true;
if (r.inLenMin != null || r.inLenMax != null) return true;
if (r.input > 0 || r.output > 0 || r.cacheHit > 0 || r.unitPrice > 0) return true;
//
return !!buildMediaPrices(r.mediaPrices);
}
/** 编辑器行态 → 构建态规则(match 只收录非空字段) */
function buildBuiltRule(r: ModelRule): BuiltModelRule {
const match: BuiltModelRuleMatch = {};
if (r.thinking === 'true' || r.thinking === 'false') match.thinking = r.thinking === 'true';
if (r.outputAudio === 'true' || r.outputAudio === 'false') match.outputAudio = r.outputAudio === 'true';
if (r.outputResolution) match.outputResolution = r.outputResolution;
if (r.inLenMin != null) match.inputLengthMin = r.inLenMin;
if (r.inLenMax != null) match.inputLengthMax = r.inLenMax;
const price: Record<string, number> = isTokenUnit.value
? { input: r.input ?? 0, output: r.output ?? 0, cacheHit: r.cacheHit ?? 0 }
: { unitPrice: r.unitPrice ?? 0 };
return {
name: r.name,
match: Object.keys(match).length ? match : undefined,
price,
mediaPrices: buildMediaPrices(r.mediaPrices),
};
}
/** 媒体价格行 → mediaPrices 对象(仅保留填写了价格的媒体;无覆盖返回 undefined 不写入) */
function buildMediaPrices(rows: MediaPriceRow[]): Record<string, Record<string, number>> | undefined {
if (!rows?.length) return undefined;
const map: Record<string, Record<string, number>> = {};
rows.forEach((row) => {
if (!row.mediaType) return;
const prices: Record<string, number> = {};
if (row.input > 0) prices.input = row.input;
if (row.output > 0) prices.output = row.output;
if (row.cacheHit > 0) prices.cacheHit = row.cacheHit;
if (Object.keys(prices).length) map[row.mediaType] = prices;
});
return Object.keys(map).length ? map : undefined;
}
/** mediaPrices 对象 → 编辑态媒体价格行(兼容后端两种结构:按媒体分组的内层对象,或空/缺失) */
function mediaPricesToRows(mediaPrices?: unknown): MediaPriceRow[] {
if (!mediaPrices || typeof mediaPrices !== 'object') return [];
return Object.entries(mediaPrices as Record<string, Record<string, number | undefined>>)
.filter(([, prices]) => prices && typeof prices === 'object')
.map(([mediaType, prices]) => ({
mediaType,
input: prices.input ?? 0,
output: prices.output ?? 0,
cacheHit: prices.cacheHit ?? 0,
}));
}
/** 添加一条媒体价格行 */
function addMediaPrice(r: ModelRule) {
r.mediaPrices.push({ mediaType: '', input: 0, output: 0, cacheHit: 0 });
}
/** 删除一条媒体价格行 */
function removeMediaPrice(r: ModelRule, index: number) {
r.mediaPrices.splice(index, 1);
}
/** 媒体价格行的可选媒体:排除本规则其他行已选中的媒体,避免同一条规则重复选择同一媒体 */
function getAvailableMediaOptions(r: ModelRule, curIndex: number) {
const selected = new Set(r.mediaPrices.filter((_, i) => i !== curIndex).map((row) => row.mediaType));
return MEDIA_PRICE_OPTIONS.filter((opt) => !selected.has(opt.value) || r.mediaPrices[curIndex]?.mediaType === opt.value);
}
/** 构建模型费率 JSON(对齐后端结构,由父组件 stringify 提交) */
function buildRules(): ModelRules {
return {
unit: unit.value,
tiered: tiered.value,
currency: currency.value,
rules: modelRules.value.filter(keepRule).map(buildBuiltRule),
};
}
function save(): { rules: ModelRules; minBalance: number; currency: Currency; enabled: boolean } {
return {
rules: buildRules(),
minBalance: minBalance.value,
currency: currency.value,
enabled: enabled.value,
};
}
watch(
() => props.initConfig,
(cfg) => {
if (cfg && (cfg.rules as ModelRules).unit) {
//
applyInit(cfg);
} else if (examples.value.length) {
//
loadExample(0);
} else {
modelRules.value = [emptyRule()];
}
},
{ immediate: true },
);
defineExpose({ save });
</script>
<style scoped lang="scss">
.editor-body {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.editor-col {
flex: 1;
min-width: 380px;
}
.card {
border: 1px solid #e4e7ed;
border-radius: 8px;
margin-bottom: 16px;
overflow: hidden;
.card-head {
background: #fafafa;
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
.card-title {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.head-select {
flex-shrink: 0;
}
}
.card-body {
padding: 14px;
}
}
.form-hint {
font-size: 12px;
color: #909399;
line-height: 1.7;
}
.money-note {
font-size: 12px;
color: #909399;
line-height: 1.7;
}
.mt10 {
margin-top: 10px;
}
.ctrl-input {
width: 220px;
}
.ctrl-select {
width: 220px;
}
.rule-card {
border: 1px solid #e4e7ed;
border-radius: 8px;
margin-bottom: 12px;
padding: 12px;
.rule-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.rule-index {
color: #909399;
font-size: 12px;
flex-shrink: 0;
}
.rule-name {
flex: 1;
}
}
}
.rc-sec {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #909399;
margin: 10px 0 6px;
&::before {
content: '';
width: 3px;
height: 12px;
border-radius: 2px;
}
&.match::before {
background: #e6a23c;
}
&.price::before {
background: #409eff;
}
}
.rc-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0 12px;
.rc-control {
width: 100%;
}
}
.add-btn {
width: 100%;
}
/* 媒体价格模板区(token 单位):基础价下方的媒体覆盖价动态行 */
.media-price-section {
margin-top: 12px;
border-top: 1px dashed #e4e7ed;
padding-top: 10px;
}
.media-price-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.mp-media {
width: 120px;
flex-shrink: 0;
}
.mp-num {
flex: 1;
min-width: 0;
}
}
.media-add-btn {
width: 100%;
}
.mp-tip {
font-size: 12px;
color: #909399;
line-height: 1.7;
margin-top: 8px;
}
.note {
border: 1px solid #e6a23c;
background: #fdf6ec;
color: #b88230;
border-radius: 6px;
padding: 10px 14px;
font-size: 12.5px;
line-height: 1.7;
}
</style>
@@ -0,0 +1,281 @@
<template>
<div class="subject-list">
<div v-if="loading" class="subject-loading">加载计价对象</div>
<template v-else>
<!-- 模型类型筛选 -->
<div v-if="hasModel" class="filter-bar">
<button
v-for="f in FILTER_OPTIONS"
:key="f.value"
class="filter-chip"
:class="{ active: modelFilter === f.value }"
@click="modelFilter = f.value"
>
{{ f.label }}
</button>
</div>
<!-- 工作流 -->
<div class="grp-head">
<span class="grp-name">工作流</span>
<span class="grp-count">{{ workflowSubjects.length }}</span>
</div>
<div
v-for="s in workflowSubjects"
:key="`${s.type}:${s.id}`"
class="subj-item"
:class="{ active: `${s.type}:${s.id}` === currentKey }"
@click="emit('select', s)"
>
<span class="type-badge t-workflow">工作流</span>
<span class="subj-name" :title="s.name">{{ s.name }}</span>
<span v-for="m in s.modes" :key="m" class="mode-chip">{{ m }}</span>
<span
class="status-dot"
:class="s.hasConfig ? 'on' : 'off'"
:title="s.hasConfig ? '已配置' : '未配置'"
/>
</div>
<!-- 模型支持类型筛选 -->
<div class="grp-head">
<span class="grp-name">模型</span>
<span class="grp-count">{{ filteredModels.length }}</span>
</div>
<div
v-for="s in filteredModels"
:key="`${s.type}:${s.id}`"
class="subj-item"
:class="{ active: `${s.type}:${s.id}` === currentKey }"
@click="emit('select', s)"
>
<span class="type-badge" :class="`t-${s.modelType}`">{{ s.typeLabel }}</span>
<span class="subj-name" :title="s.name">{{ s.name }}</span>
<span
class="status-dot"
:class="s.hasConfig ? 'on' : 'off'"
:title="s.hasConfig ? '已配置' : '未配置'"
/>
</div>
<div v-if="!filteredModels.length" class="empty-tip">该类型暂无模型</div>
<!-- 业务模块 -->
<div class="grp-head">
<span class="grp-name">业务模块</span>
<span class="grp-count">{{ businessSubjects.length }}</span>
</div>
<div
v-for="s in businessSubjects"
:key="`${s.type}:${s.id}`"
class="subj-item"
:class="{ active: `${s.type}:${s.id}` === currentKey }"
@click="emit('select', s)"
>
<span class="type-badge t-business">业务</span>
<span class="subj-name" :title="s.name">{{ s.name }}</span>
<span v-for="m in s.modes" :key="m" class="mode-chip">{{ m }}</span>
<span
class="status-dot"
:class="s.hasConfig ? 'on' : 'off'"
:title="s.hasConfig ? '已配置' : '未配置'"
/>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { ModelType, Subject } from '../mock';
/**
* 左侧计价对象枚举列表
* 分三组渲染工作流 / 模型 / 业务模块模型组支持类型筛选
* 配置状态以实心已配置/ 空心未配置圆点呈现
*/
const props = defineProps<{
subjects: Subject[];
currentKey: string;
loading?: boolean;
}>();
const emit = defineEmits<{ select: [subject: Subject] }>();
const workflowSubjects = computed(() => props.subjects.filter((s) => s.type === 'workflow'));
const modelSubjects = computed(() => props.subjects.filter((s) => s.type === 'model'));
const businessSubjects = computed(() => props.subjects.filter((s) => s.type === 'business'));
const hasModel = computed(() => modelSubjects.value.length > 0);
/** 模型类型筛选 */
const FILTER_OPTIONS: { value: ModelType | 'all'; label: string }[] = [
{ value: 'all', label: '全部' },
{ value: 'reason', label: '推理' },
{ value: 'video', label: '视频' },
{ value: 'audio', label: '音频' },
{ value: 'image', label: '图片' },
];
const modelFilter = ref<ModelType | 'all'>('all');
const filteredModels = computed(() =>
modelFilter.value === 'all'
? modelSubjects.value
: modelSubjects.value.filter((s) => s.modelType === modelFilter.value),
);
</script>
<style scoped lang="scss">
.subject-list {
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 10px 16px 2px;
.filter-chip {
border: 1px solid #dcdfe6;
background: #fff;
color: #606266;
border-radius: 14px;
padding: 3px 12px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
line-height: 1.4;
&:hover {
color: #409eff;
border-color: #409eff;
}
&.active {
background: #ecf5ff;
border-color: #409eff;
color: #409eff;
font-weight: 500;
}
}
}
.grp-head {
display: flex;
align-items: center;
gap: 6px;
padding: 14px 16px 6px;
.grp-name {
font-size: 12px;
font-weight: 600;
color: #909399;
letter-spacing: 0.5px;
}
.grp-count {
font-size: 11px;
background: #f4f4f5;
color: #909399;
border-radius: 8px;
padding: 0 6px;
line-height: 16px;
}
}
.subject-loading {
padding: 20px 16px;
font-size: 12px;
color: #909399;
text-align: center;
}
.empty-tip {
padding: 10px 16px;
font-size: 12px;
color: #c0c4cc;
text-align: center;
}
.subj-item {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 16px;
cursor: pointer;
border-left: 3px solid transparent;
transition: all 0.15s;
&:hover {
background: #f5f7fa;
}
&.active {
background: #ecf5ff;
border-left-color: #409eff;
}
.subj-name {
flex: 1;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.type-badge {
font-size: 11px;
border-radius: 3px;
padding: 1px 6px;
color: #fff;
flex-shrink: 0;
&.t-workflow {
background: #8e44ad;
}
&.t-reason {
background: #e6a23c;
}
&.t-video {
background: #909399;
}
&.t-audio {
background: #67c23a;
}
&.t-image {
background: #00bcd4;
}
&.t-business {
background: #e74c3c;
}
}
.mode-chip {
font-size: 11px;
background: #f4f4f5;
color: #606266;
border-radius: 3px;
padding: 1px 5px;
flex-shrink: 0;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&.on {
background: #67c23a;
box-shadow: 0 0 0 2px rgba(103, 194, 58, 0.2);
}
&.off {
background: #fff;
border: 1px solid #c0c4cc;
}
}
}
}
</style>
@@ -0,0 +1,334 @@
<template>
<div class="editor-body">
<div class="editor-col">
<!-- 计费模式 -->
<div class="card">
<div class="card-head">
<span class="card-title">计费模式</span>
<span class="head-hint">可同时开启多套计费</span>
</div>
<div class="card-body">
<!-- 按条计费 per_item -->
<div class="mode-item">
<div class="mode-item-head">
<span class="mode-name">按条计费 <span class="mode-code">per_item</span></span>
<el-switch v-model="modes.per_item" />
</div>
<div v-if="modes.per_item" class="mode-item-body">
<el-form label-position="top" size="default">
<el-form-item label="档位列表(上不封顶,命中首个 maxSec ≥ 时长 的档)">
<div class="tier-list">
<div v-for="(tier, i) in tiers" :key="i" class="tier-row">
<el-input-number
v-model="tier.maxSec"
:min="0"
:precision="0"
:controls="false"
placeholder="maxSec"
class="tier-input"
/>
<el-input-number
v-model="tier.price"
:min="0"
:precision="2"
:controls="false"
placeholder="价格(元)"
class="tier-input"
/>
<el-button size="small" type="danger" text @click="removeTier(i)">删除</el-button>
</div>
<el-button size="small" class="add-btn" @click="addTier">+ 添加档位</el-button>
</div>
</el-form-item>
<el-form-item label="超出最大档单价(元/秒)">
<el-input-number
v-model="overflowUnitPrice"
:min="0"
:precision="2"
:step="0.01"
:controls="false"
class="ctrl-input"
/>
</el-form-item>
</el-form>
</div>
</div>
<!-- 按秒计费 per_second -->
<div class="mode-item">
<div class="mode-item-head">
<span class="mode-name">按秒计费 <span class="mode-code">per_second</span></span>
<el-switch v-model="modes.per_second" />
</div>
<div v-if="modes.per_second" class="mode-item-body">
<el-form label-position="top" size="default">
<el-form-item label="单价(元/秒,不满 1 秒向上取整)">
<el-input-number
v-model="perSecondUnitPrice"
:min="0"
:precision="2"
:step="0.01"
:controls="false"
class="ctrl-input"
/>
</el-form-item>
</el-form>
</div>
</div>
<!-- token 计费 per_token -->
<div class="mode-item">
<div class="mode-item-head">
<span class="mode-name"> Token 计费 <span class="mode-code">per_token</span></span>
<el-switch v-model="modes.per_token" />
</div>
<div v-if="modes.per_token" class="mode-item-body">
<div class="form-hint">
价格<b>取自各模型自己的计价配置</b>模型列表里配的那份不在此重复配置未配价的模型按 0
per_token 费率不冻结在快照结算时按模型实时价
</div>
</div>
</div>
</div>
</div>
</div>
<div class="editor-col">
<!-- 通用配置 -->
<div class="card">
<div class="card-head">
<span class="card-title">通用配置</span>
</div>
<div class="card-body">
<el-form label-position="top" size="default">
<el-form-item label="门禁 min_balance(元)">
<el-input-number
v-model="minBalance"
:min="0"
:precision="2"
:step="0.01"
:controls="false"
class="ctrl-input"
/>
</el-form-item>
<el-form-item label="币种">
<el-select v-model="currency" class="ctrl-select">
<el-option label="人民币 (CNY)" value="CNY" />
<el-option label="美元 (USD)" value="USD" />
</el-select>
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="enabled" />
</el-form-item>
</el-form>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue';
import {
WORKFLOW_DEFAULT_TIERS,
type Currency,
type EditorInitConfig,
type Subject,
type WorkflowRules,
type WorkflowTier,
} from '../mock';
/**
* 工作流计价编辑面板
* 三套计费模式开关per_item 档位表 / per_second 单价 / per_token 占位
* initConfig 传入后端已保存配置parse 时初始化表单无配置时用默认档位
*/
const props = defineProps<{
subject: Subject;
initConfig: EditorInitConfig | null;
}>();
const modes = reactive({ per_item: true, per_second: false, per_token: false });
const tiers = ref<WorkflowTier[]>(WORKFLOW_DEFAULT_TIERS.map((t) => ({ ...t })));
const overflowUnitPrice = ref(0.9);
const perSecondUnitPrice = ref(0.9);
const minBalance = ref(0);
const currency = ref<Currency>('CNY');
const enabled = ref(true);
function addTier() {
tiers.value.push({ maxSec: 60, price: 89 });
}
function removeTier(index: number) {
tiers.value.splice(index, 1);
}
/** 用后端已保存配置初始化表单 */
function applyConfig(cfg: EditorInitConfig) {
const r = cfg.rules as WorkflowRules;
modes.per_item = !!r.per_item;
modes.per_second = !!r.per_second;
modes.per_token = !!r.per_token;
tiers.value = r.per_item?.tiers?.map((t) => ({ ...t })) ?? WORKFLOW_DEFAULT_TIERS.map((t) => ({ ...t }));
overflowUnitPrice.value = r.per_item?.overflowUnitPrice ?? 0.9;
perSecondUnitPrice.value = r.per_second?.unitPrice ?? 0.9;
minBalance.value = cfg.minBalance;
currency.value = cfg.currency;
enabled.value = cfg.enabled;
}
/** 重置为默认(无配置) */
function resetDefault() {
modes.per_item = true;
modes.per_second = false;
modes.per_token = false;
tiers.value = WORKFLOW_DEFAULT_TIERS.map((t) => ({ ...t }));
overflowUnitPrice.value = 0.9;
perSecondUnitPrice.value = 0.9;
minBalance.value = 0;
currency.value = 'CNY';
enabled.value = true;
}
function save(): { rules: WorkflowRules; minBalance: number; currency: Currency; enabled: boolean } {
const rules: WorkflowRules = {};
// JSON
if (modes.per_item) {
rules.per_item = { tiers: tiers.value.filter((t) => t.maxSec > 0), overflowUnitPrice: overflowUnitPrice.value };
}
if (modes.per_second) rules.per_second = { unitPrice: perSecondUnitPrice.value };
if (modes.per_token) rules.per_token = {};
return {
rules,
minBalance: minBalance.value,
currency: currency.value,
enabled: enabled.value,
};
}
watch(
() => props.initConfig,
(cfg) => {
if (cfg && cfg.rules && !('unit' in (cfg.rules as Record<string, unknown>))) {
applyConfig(cfg);
} else {
resetDefault();
}
},
{ immediate: true },
);
defineExpose({ save });
</script>
<style scoped lang="scss">
.editor-body {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.editor-col {
flex: 1;
min-width: 380px;
}
.card {
border: 1px solid #e4e7ed;
border-radius: 8px;
margin-bottom: 16px;
overflow: hidden;
.card-head {
background: #fafafa;
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
.card-title {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.head-hint {
font-size: 12px;
color: #909399;
font-weight: 400;
}
}
.card-body {
padding: 14px;
}
}
.mode-item {
border: 1px solid #ebeef5;
border-radius: 6px;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
.mode-item-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: #f5f7fa;
border-radius: 6px 6px 0 0;
.mode-name {
font-size: 13px;
font-weight: 600;
.mode-code {
font-size: 12px;
font-weight: 400;
color: #909399;
margin-left: 6px;
}
}
}
.mode-item-body {
padding: 12px;
}
}
.tier-list {
.tier-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.tier-input {
width: 130px;
}
}
}
.add-btn {
width: 100%;
}
.form-hint {
font-size: 12px;
color: #909399;
line-height: 1.7;
}
.ctrl-input {
width: 220px;
}
.ctrl-select {
width: 220px;
}
</style>

Some files were not shown because too many files have changed in this diff Show More