Author SHA1 Message Date
admin 7c286f639e admin-ui ng转发域名增加 2026-09-11 00:13:41 +08:00
admin 56131a4662 Merge branch 'feature/home-workflow' of https://gitea.redpowerfuture.com/red-future/admin-ui into feature/home-workflow 2026-09-07 10:45:37 +08:00
admin 7209cd3ba2 admin-ui ng转发域名增加 2026-09-07 10:42:49 +08:00
2910410219andClaude Code a5262941ea 价格管理:媒体价格模板三个输入框增加上方 label
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 17:20:16 +08:00
2910410219andClaude Code 490abd60e9 价格管理:label 中文化、金额保留5位小数、通用配置置顶重排、取消模型类型筛选、去除列表计费方式标签
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 17:15:55 +08:00
2910410219andClaude Code aa7c8856fb 新增:首页工作流失败卡片提供「查看失败原因」按钮(默认收起、点击展开,绑定 error 字段);实时与历史回显均展示详细失败原因
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 15:39:21 +08:00
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
58 changed files with 10996 additions and 2489 deletions
+1 -1
View File
@@ -5,4 +5,4 @@ ENV = 'development'
# 开发环境走本地代理,避免 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.135:8000'
# VITE_API_URL = 'http://192.168.3.135:8000'
+2 -2
View File
@@ -1,5 +1,5 @@
# ==================== 第一阶段:构建前端 ====================
FROM docker.m.daocloud.io/node:18-alpine AS builder
FROM 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 docker.m.daocloud.io/nginx:alpine
FROM nginx:alpine
# 复制构建产物
COPY --from=builder /app/dist/ /usr/share/nginx/html/
+37
View File
@@ -6,6 +6,12 @@ map $http_user_agent $badbot {
~*(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 {
root /usr/share/nginx/html;
@@ -59,6 +65,9 @@ server {
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;
@@ -69,6 +78,7 @@ server {
server {
listen 80;
server_name gitea.redpowerfuture.com;
client_max_body_size 500M;
if ($badbot) {
return 444;
}
@@ -79,6 +89,7 @@ server {
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;
@@ -97,6 +108,7 @@ server {
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;
@@ -111,6 +123,7 @@ server {
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;
@@ -124,6 +137,7 @@ server {
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;
@@ -137,6 +151,7 @@ server {
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;
@@ -146,6 +161,24 @@ server {
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;
@@ -203,6 +236,9 @@ server {
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;
@@ -213,6 +249,7 @@ server {
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;
+50
View File
@@ -23,17 +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",
@@ -1945,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",
@@ -3248,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",
@@ -4170,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",
@@ -4576,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",
+4
View File
@@ -28,17 +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",
-213
View File
@@ -1,213 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
/* ========== 全局重置 ========== */
/* 去掉所有元素的自带边距和内边距,让布局从零开始 */
* { margin: 0; padding: 0; box-sizing: border-box; }
/* ========== 画布(视频输出尺寸) ========== */
/* 这是最终视频的分辨率:宽1080px × 高1920px(竖屏 9:16,手机全屏) */
/* 如果你想改输出尺寸,改这里的 width 和 height 就行 */
body {
width: 1080px; /* 画布宽度 ← 改成 720 就是 720×1280 */
height: 1920px; /* 画布高度 ← 改成 1280 就是 720p */
overflow: hidden; /* 超出画布的部分裁掉,不显示滚动条 */
background: #000; /* 画布背景色(黑色),视频没铺满时露出的颜色 */
}
/* ========== 所有元素的定位基础 ========== */
/* 给所有带 clip 类的元素开启绝对定位,可以用 left/top 精确控制位置 */
.clip {
position: absolute; /* 绝对定位,相对于父容器(body)定位 */
}
/* ========== 文字元素的通用样式 ========== */
.text-element {
font-family: Arial, Helvetica, sans-serif; /* 字体:首选Arial,不行就Helvetica,再不行用系统无衬线体 */
text-align: center; /* 文字水平居中 */
display: flex; /* 启用弹性盒子布局 */
align-items: center; /* 垂直居中(配合flex使用) */
justify-content: center; /* 水平居中(配合flex使用) */
white-space: pre-wrap; /* 保留换行,文字超出宽度自动换行 */
word-break: break-word; /* 长单词或URL撑破容器时强制换行 */
}
/* ========== 入场动画定义 ========== */
/* 想改动画时长,改后面的秒数(如 0.5s → 1s 就是慢一倍) */
/* 淡入:从完全透明 → 完全可见 */
@keyframes fadeIn {
from { opacity: 0; } /* 开始:透明(完全看不见) */
to { opacity: 1; } /* 结束:不透明(完全可见) */
}
/* 上滑:从下方40px处边向上移动边显现 */
@keyframes slideUp {
from { opacity: 0; transform: translateY(40px); } /* 开始:透明 + 在下方40px */
to { opacity: 1; transform: translateY(0); } /* 结束:可见 + 回到原位 */
}
/* 左滑:从右侧40px处边向左移动边显现(视觉上像从右边滑入) */
@keyframes slideLeft {
from { opacity: 0; transform: translateX(40px); } /* 开始:透明 + 在右边40px */
to { opacity: 1; transform: translateX(0); } /* 结束:可见 + 回到原位 */
}
/* 放大:从缩小到80%边放大边显现 */
@keyframes scaleIn {
from { opacity: 0; transform: scale(0.8); } /* 开始:透明 + 缩到80%大小 */
to { opacity: 1; transform: scale(1); } /* 结束:可见 + 100%正常大小 */
}
/* 脉冲:一直循环缩放,像呼吸一样忽大忽小(一般用于强调/吸引注意) */
@keyframes pulse {
0% { transform: scale(1); } /* 开始:正常大小 */
50% { transform: scale(1.05); } /* 中间:放大到1.05倍(105%*/
100% { transform: scale(1); } /* 结束:回到正常大小 */
}
/* ========== 动画类名 ========== */
/* 给元素加上 class="anim-xxx" 就能用上面的动画,用法看下面的HTML元素 */
/* 淡入 0.5秒,先快后慢 */
.anim-fadeIn { animation: fadeIn 0.5s ease-out; }
/* 上滑 0.6秒,先快后慢 */
.anim-slideUp { animation: slideUp 0.6s ease-out; }
/* 左滑 0.6秒,先快后慢 */
.anim-slideLeft { animation: slideLeft 0.6s ease-out; }
/* 放大入场 0.5秒,先快后慢 */
.anim-scaleIn { animation: scaleIn 0.5s ease-out; }
/* 脉冲 1.5秒一个循环,一直重复(infinite) */
.anim-pulse { animation: pulse 1.5s ease-in-out infinite; }
/* ── 动画参数怎么改? ── */
/* • 0.5s → 改成 1s 动画就慢一倍,改 0.2s 就快一倍 */
/* • ease-out → 开始快结尾慢,换成 linear 就是匀速,换成 ease-in 就是开始慢结尾快 */
/* • infinite → 一直重复,去掉就不重复只播一次 */
</style>
<!-- GSAP:专业动画引擎,用于精确控制时间轴(当前脚本只用了初始化,没做复杂动画) -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
</head>
<body>
<!-- ====================================================================== -->
<!-- 舞台容器:所有元素都放在这里面 -->
<!-- data-composition-id 是内部标识,不要改 -->
<!-- data-width / data-height 需要和上面的 body 宽高一致 -->
<!-- ====================================================================== -->
<div id="stage" data-composition-id="main" data-start="0" data-width="1080" data-height="1920">
<!-- ================================================================== -->
<!-- data-key 属性:这个元素的内容会被程序自动替换 -->
<!-- 比如 data-key="title",程序就会用"标题"对应的文字替换掉这个元素的内容 -->
<!-- 所以里面的"标题文字"只是占位,实际显示什么由数据决定 -->
<!-- ================================================================== -->
<!-- ======================================================== -->
<!-- 标题:红色大标题,靠上居中 -->
<!-- ======================================================== -->
<!--
data-key="title" → 程序会用标题数据替换此元素内容
data-start="0" → 第0秒开始显示(单位:秒)
data-duration="58.000" → 持续显示58秒
class="clip" → 绝对定位,可以用 left/top 控制位置
class="text-element" → 文字居中、自动换行
class="anim-slideUp" → 入场动画:从下方滑入,0.6秒
-->
<div data-key="title" data-start="0" data-duration="58.000" data-track-index="1"
class="clip text-element anim-slideUp"
style="
left: 50%; /* 水平居中定位(配合 translateX 使用) */
top: 70px; /* 距离顶部70px ← 改这个数字上下移动 */
transform: translateX(-50%); /* 把元素向左拉回自身宽度的一半,实现真居中 */
width: 972px; /* 文字区域宽度 ← 改窄一点文字就缩短 */
max-width: 972px; /* 最大宽度,防止被撑破 */
font-size: 48px; /* 字号 ← 改大/改小 */
color: #D82828; /* 文字颜色(红色)← 改成 #FFFFFF 就是白色 */
">标题文字</div>
<!-- ======================================================== -->
<!-- 产品图片:左上角位置,图片链接由程序自动替换 -->
<!-- ======================================================== -->
<!--
这是 <img> 标签,src 属性会被程序替换成真实的图片 URL
data-key="product_image" → 程序用产品图片的URL替换 src
-->
<img data-key="product_image" data-start="0" data-duration="58.000" data-track-index="2"
class="clip anim-fadeIn"
style="
width: 200px; /* 图片宽度 ← 改这个调大小 */
height: 200px; /* 图片高度 ← 改这个调大小 */
left: 0px; /* 距离左边0px(贴左边缘)← 改大往右移 */
top: 200px; /* 距离顶部200px ← 改大往下移 */
"
src="占位图片.jpg" />
<!-- ======================================================== -->
<!-- 产品卖点描述:居中偏下位置 -->
<!-- ======================================================== -->
<div data-key="product_desc" data-start="0" data-duration="58.000" data-track-index="3"
class="clip text-element"
style="
left: 50%; /* 水平居中 */
top: 830px; /* 距离顶部830px ← 改大就往下移 */
transform: translateX(-50%); /* 水平居中补偿 */
width: 972px; /* 文本宽度 */
max-width: 972px; /* 最大宽度 */
font-size: 34px; /* 字号 ← 改大/改小 */
color: #333333; /* 文字颜色(深灰色)← 改成 #000 就是黑色 */
">产品卖点描述</div>
<!-- ======================================================== -->
<!-- 底部免责声明:背景半透明黑底白字,圆角,贴底部 -->
<!-- ======================================================== -->
<div data-key="disclaimer" data-start="0" data-duration="58.000" data-track-index="5"
class="clip text-element"
style="
left: 50%; /* 水平居中 */
top: calc(100% - 45px); /* 距离底部45px(100%是父容器高度)← 改45调距离 */
transform: translateX(-50%); /* 水平居中补偿 */
width: 972px; /* 文本宽度 */
max-width: 972px;
font-size: 20px; /* 字号 */
color: #FFFFFF; /* 文字颜色(白色) */
">
<!--
内部 span:负责背景和间距
background: rgba(0,0,0,0.70) → 黑色半透明背景,70%不透明度
← 改最后一个数字(0~1)调透明度:0=全透,1=全黑
padding: 12px 24px → 内边距:上下12px,左右24px
← 改这个让背景范围变大/变小
border-radius: 8px → 圆角大小
← 改大更圆(如 20px),改 0 就是直角
-->
<span style="background:rgba(0,0,0,0.70); padding:12px 24px; border-radius:8px;">
免责声明文字
</span>
</div>
</div>
<!-- ================================================================ -->
<!-- GSAP 时间轴初始化脚本 -->
<!-- 当前只初始化了一个空时间轴,给程序框架用,一般不需要改 -->
<!-- ================================================================ -->
<script>
(function() {
var tl = gsap.timeline({ paused: true }); // 建一个暂停的时间轴
tl.to('#stage', { duration: 0, opacity: 1 }, 0); // 舞台立刻显示(第0秒)
window.__timelines = window.__timelines || {}; // 存到全局,供程序调用
window.__timelines['main'] = tl; // 时间轴名字叫 'main'
})();
</script>
</body>
</html>
+24 -2
View File
@@ -12,6 +12,7 @@ export interface AsyncTaskMapping {
url: string;
httpMethod: string;
requestHeadMapping: Record<string, unknown>;
requestBodyMapping?: Record<string, unknown>;
responseMapping: Record<string, unknown>;
taskId: string;
taskStatus: string;
@@ -40,21 +41,26 @@ export interface ModelManageItem {
updater: string;
updatedAt: string;
deletedAt: null | string;
modelSupplier: string;
modelSupplier: number;
modelName: string;
modelType: number;
baseUrl: string;
responseType: number;
apiKey: string;
enabled: boolean;
ChatModel: 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;
@@ -166,8 +172,13 @@ export interface CreateModelManageParams {
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;
@@ -219,3 +230,14 @@ export function updateModelManage(data: UpdateModelManageParams) {
data,
});
}
/**
* 获取当前会话模型(普通对话执行时使用的模型 id)
* 返回兼容 v2 惯例:data.modelManage.id(亦兼容 data.id / data.modelId
*/
export function getChatModel() {
return request({
url: '/model-gateway/model/manage/getChatModel',
method: 'get',
});
}
+89 -3
View File
@@ -1,4 +1,5 @@
import request from '/@/utils/request';
import type { RequestOptions } from '/@/utils/request';
// ===== 节点库(V2 结构,workflow 专用;creation 继续使用 creation/index.ts 的旧结构)=====
export interface NodeLibraryPresetOption {
@@ -12,6 +13,10 @@ export interface NodeLibraryPresetOption {
value: string;
config?: NodeLibraryPresetOption[] | null;
}> | null;
// 运行时已在用的扩展字段(补齐声明消除 TS 报错)
isFormField?: boolean;
constraint?: any;
defaultValue?: any;
}
export interface NodeLibraryItem {
@@ -21,15 +26,22 @@ export interface NodeLibraryItem {
sort: number;
desc?: string;
batchExecOption: boolean;
preToolOption: boolean;
postToolOption: 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 {
@@ -52,12 +64,20 @@ export interface WorkflowModelItem {
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 }) {
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',
@@ -81,3 +101,69 @@ export function getWorkflowSkillList(params?: { pageNum: number; pageSize: numbe
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,
});
}
+125
View File
@@ -0,0 +1,125 @@
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;
// 失败详细原因(工作流节点失败详情,如「执行工作流失败: ...节点:生成视频, 失败原因:...」),比 errorMsg 更具体
error?: 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,
});
}
+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,
});
}
@@ -65,7 +65,9 @@
</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)">
{{ of.label || of.field }}
<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>
@@ -206,7 +208,9 @@
</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)">
{{ of.label || of.field }}
<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>
@@ -305,7 +309,7 @@ interface UpstreamNodeInfo {
id: string;
label: string;
nodeCode: string;
outputFields: { field: string; label: string }[];
outputFields: { field: string; label: string; fieldType?: 'scalar' | 'object' | 'array' }[];
}
interface FieldFormData {
@@ -548,6 +552,9 @@ function handleClosed() { resetForm(); }
<style lang="scss">
.fcd-ref-popover {
.fcd-ref-panel {
// 面板超高时内部滚动,避免超出浏览器视口被截掉显示不全
max-height: 40vh;
overflow-y: auto;
.fcd-ref-empty {
color: #94a3b8;
font-size: 12px;
@@ -581,6 +588,9 @@ function handleClosed() { resetForm(); }
padding: 2px 0 2px 8px;
}
.fcd-ref-field {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #475569;
padding: 4px 8px;
@@ -590,6 +600,24 @@ function handleClosed() { resetForm(); }
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;
}
}
}
}
@@ -166,7 +166,7 @@ function nextImageKey(template: PatchTemplate): string | null {
return null;
}
const DEFAULT_TEMPLATE_URL = 'https://cdn.redpowerfuture.com/tenantid-1/template_example1.html';
const DEFAULT_TEMPLATE_URL = 'https://cdn.redpowerfuture.com/tenantid-1/vertical_template_example.html';
const props = defineProps<{
modelValue: PatchTemplate[];
@@ -290,14 +290,24 @@ const handleImageUpload = async (templateIndex: number, imgIdx: number, file: an
}
};
// 下载参考模板文件
const handleDownload = (_e?: Event) => {
const a = document.createElement('a');
a.href = '/template_example.html';
a.download = 'template_example.html';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// 下载参考模板文件:统一走 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>
+4 -3
View File
@@ -203,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') {
+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;
+696 -22
View File
@@ -1,8 +1,82 @@
<template>
<div class="chat-list">
<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>
@@ -10,28 +84,266 @@
</template>
<script setup lang="ts">
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 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[];
}
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;
}
defineProps<Props>();
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;
}
}
});
},
{ 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; }
}
/* 顶部:加载更早记录提示条 */
.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 {
@@ -45,37 +357,399 @@ defineProps<Props>();
}
}
/* 节奏化消息间距: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);
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>
+258 -69
View File
@@ -2,20 +2,23 @@
<div class="input-shell">
<div class="input-card" :class="{ 'is-focused': isFocused }">
<!-- 已选中状态标签栏 -->
<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="!workflowLocked" class="tag-close" @click="clearWorkflow"><Close /></el-icon>
<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>
</div>
</Transition>
<!-- 文本输入区 -->
<el-input
v-model="message"
type="textarea"
:autosize="{ minRows: 1, maxRows: 6 }"
placeholder="有什么想聊的?按 Enter 发送,Shift+Enter 换行"
:disabled="generating || isWorkflowRunning || selectedWorkflowId !== null || isWorkflowSession"
:placeholder="inputPlaceholder"
class="message-input"
@focus="isFocused = true"
@blur="isFocused = false"
@@ -26,89 +29,165 @@
<!-- 底部工具栏 -->
<div class="input-toolbar">
<div class="toolbar-left">
<button class="tool-icon-btn" title="上传文件" @click="handleAttachment">
<el-icon><Paperclip /></el-icon>
</button>
<!-- 技能暂已禁用 -->
<button class="tool-icon-btn" title="技能暂不可用" disabled>
<el-icon><MagicStick /></el-icon>
<span class="tool-label">技能</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>
<div class="toolbar-right">
<span class="hint-text">Shift+Enter 换行</span>
<button class="send-btn" :disabled="selectedWorkflowId === null || sendDisabled" @click="handleSend">
<el-icon><Top /></el-icon>
<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>
<!-- 快捷工作流胶囊输入框下方 -->
<div v-if="visibleWorkflows.length > 0" class="workflow-shortcuts">
<button
v-for="wf in visibleWorkflows"
:key="wf.id"
class="shortcut-pill"
:class="{ active: selectedWorkflowId === wf.id }"
@click="toggleWorkflow(wf.id)"
>
<el-icon><Promotion /></el-icon>
{{ wf.name }}
</button>
</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, onMounted } from 'vue';
import { Top, MagicStick, Promotion, Close, Paperclip } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { useRouter } from 'vue-router';
import { Top, Promotion, Close, VideoPause, Cpu, More } from '@element-plus/icons-vue';
import { getWorkflowList } from '/@/api/settings/creation';
interface Props {
sendDisabled?: boolean;
workflowLocked?: boolean;
hideShortcuts?: boolean;
generating?: boolean;
// 当前会话模型名称,用于输入栏切换入口展示
currentModelName?: string;
// 工作流正在执行中:输入框禁用,发送按钮变停止(与普通对话生成共用同一开关)
isWorkflowRunning?: boolean;
// 本会话为工作流会话(已执行过工作流):文本输入永久禁止,发送按钮仅执行工作流
isWorkflowSession?: boolean;
}
interface Emits {
(e: 'send', message: string): void;
(e: 'workflow-select', workflowId: string | null): void;
// 工作流场景点击发送:父组件定位目标工作流卡片执行(编辑态提交 / 定格态重跑)
(e: 'execute-workflow'): void;
(e: 'workflow-select', workflowId: string | null, isTemplate?: boolean): void;
(e: 'stop'): void;
(e: 'select-model'): void;
}
interface Workflow {
id: string;
name: string;
prefix: string;
isTemplate: boolean;
raw?: any;
}
const props = withDefaults(defineProps<Props>(), {
sendDisabled: false,
workflowLocked: false,
hideShortcuts: false,
generating: false,
currentModelName: '',
isWorkflowRunning: false,
isWorkflowSession: false,
});
const emit = defineEmits<Emits>();
const router = useRouter();
// 首页展示工作流上限:用户工作流优先,不足用系统模板补足,最多 9 个
const MAX_WORKFLOWS = 9;
// 「更多」入口:工作流数量超出展示上限时跳转工作流管理页查看/选择全部
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 visibleWorkflows = computed(() => {
if (props.workflowLocked && selectedWorkflowId.value) {
return commonWorkflows.value.filter((w) => w.id === selectedWorkflowId.value);
}
return commonWorkflows.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 fetchWorkflows = async () => {
try {
const res = await getWorkflowList();
const workflows = res.data?.listFlowUserRes?.list || [];
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 || '未命名',
prefix: '[工作流] ' + (w.flowName || '') + ':\n',
name: w.flowName || w.flowTemplateName || '未命名',
prefix: '[工作流] ' + (w.flowName || w.flowTemplateName || '') + ':\n',
isTemplate: !!w.isTemplate,
raw: w,
}));
} catch {
commonWorkflows.value = [];
@@ -116,22 +195,32 @@ const fetchWorkflows = async () => {
};
const handleSend = () => {
if (selectedWorkflowId.value === null || props.sendDisabled) return;
// 生成中 / 工作流执行中:发送按钮即「停止」(普通对话与工作流共用同一开关)
if (props.generating || props.isWorkflowRunning) {
emit('stop');
return;
}
// 工作流场景(已选工作流 / 本会话为工作流会话):发送按钮即「执行工作流」
if (selectedWorkflowId.value !== null || props.isWorkflowSession) {
emit('execute-workflow');
return;
}
// 普通对话:发送文本
const msg = message.value.trim();
emit('send', msg || '');
if (!msg || props.sendDisabled) return;
emit('send', msg);
message.value = '';
// 不清除工作流选择,保持表单可见
};
const handleAttachment = () => {
ElMessage.info('附件上传功能开发中...');
};
const toggleWorkflow = (id: string) => {
if (props.workflowLocked) return;
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);
emit('workflow-select', newId, item?.isTemplate || false);
};
const resetAll = () => {
@@ -141,30 +230,81 @@ const resetAll = () => {
};
const clearWorkflow = () => {
if (props.workflowLocked) return;
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();
});
defineExpose({ resetAll, clearWorkflow, selectedWorkflowId, commonWorkflows });
// —— 胶囊高度动画: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 {
padding: 0 24px 24px;
padding: 0 20px 20px;
background: transparent;
}
.input-card {
max-width: 860px;
max-width: 880px;
margin: 0 auto;
background: #fff;
border: 1.5px solid #e2e8f0;
border-radius: 16px;
border: 1.5px solid #e5e8ee;
border-radius: 20px;
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.07);
transition:
border-color 0.2s,
@@ -221,7 +361,7 @@ onMounted(() => {
border: none;
box-shadow: none;
resize: none;
padding: 14px 16px 8px;
padding: 16px 18px 10px;
font-size: 15px;
line-height: 1.6;
color: #0f172a;
@@ -240,7 +380,7 @@ onMounted(() => {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 10px 10px 12px;
padding: 8px 12px 12px 14px;
}
.toolbar-left,
@@ -254,10 +394,10 @@ onMounted(() => {
display: inline-flex;
align-items: center;
gap: 4px;
height: 30px;
height: 34px;
padding: 0 10px;
border: none;
border-radius: 8px;
border-radius: 10px;
background: transparent;
color: #64748b;
font-size: 13px;
@@ -298,10 +438,10 @@ onMounted(() => {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
border-radius: 10px;
background: #2563eb;
color: #fff;
cursor: pointer;
@@ -326,12 +466,22 @@ onMounted(() => {
cursor: not-allowed;
transform: none;
}
/* 生成中:变停止按钮 */
&.is-stop {
background: #f43f5e;
color: #fff;
&:hover {
background: #e11d48;
}
}
}
/* 快捷工作流胶囊 */
.workflow-shortcuts {
max-width: 860px;
margin: 10px auto 0;
max-width: 880px;
margin: 8px auto 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
@@ -341,13 +491,13 @@ onMounted(() => {
display: inline-flex;
align-items: center;
gap: 5px;
height: 28px;
height: 32px;
padding: 0 12px;
border: 1px solid #e2e8f0;
border: 1px solid #e5e8ee;
border-radius: 999px;
background: rgba(255, 255, 255, 0.7);
color: #475569;
font-size: 12px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
backdrop-filter: blur(4px);
@@ -355,7 +505,7 @@ onMounted(() => {
outline: none;
.el-icon {
font-size: 12px;
font-size: 13px;
}
&:hover {
@@ -371,4 +521,43 @@ onMounted(() => {
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
}
}
/* 胶囊内工作流名称:单行省略显示(不撑宽胶囊),悬停经 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-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>
+212 -600
View File
@@ -1,456 +1,157 @@
<template>
<div class="main-content">
<!-- 工作流动态表单 -->
<div v-if="workflowDetail" class="content-body workflow-form-body">
<div class="workflow-form-card">
<div class="workflow-form-header">
<div>
<h3>{{ workflowDetail.flowName || '工作流表单' }}</h3>
<div class="workflow-form-sub">{{ workflowDetail.description || '请填写以下参数' }}</div>
</div>
</div>
<div class="workflow-form-scroll">
<el-form label-position="top" class="workflow-form">
<template v-if="workflowDetail.nodeInputParams">
<template v-for="node in workflowDetail.nodeInputParams" :key="node.id || node.nodeCode">
<template v-if="node.nodeCode !== '__start__' && hasFormConfig(node)">
<el-form-item
v-for="field in getVisibleFields(node)"
:key="getFieldKey(node, field)"
:label="field.label"
:required="field.required"
>
<!-- 文本输入 -->
<el-input
v-if="field.type === 'input' || field.type === 'string'"
v-model="formValues[getFieldKey(node, field)]"
: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"
: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"
:placeholder="field.required ? '必填' : '选填'"
show-word-limit
:maxlength="500"
/>
<!-- 开关 -->
<el-switch
v-else-if="field.type === 'switch'"
v-model="formValues[getFieldKey(node, field)]"
active-text="开启"
inactive-text="关闭"
/>
<!-- 下拉选择 -->
<el-select
v-else-if="field.type === 'select' && field.options"
v-model="formValues[getFieldKey(node, field)]"
: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)"
:on-change="(file: any) => handleFileUpload(node, field, file)"
>
<el-button
size="small"
type="primary"
:loading="uploadingFields[getFieldKey(node, field)]"
:disabled="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 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)]"
:placeholder="field.required ? '必填' : '选填'"
/>
</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>
</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 v-else class="content-body placeholder-body">
<div v-else :key="'placeholder'" class="content-body placeholder-body">
<div class="placeholder-content">
<div class="placeholder-icon">
<div class="icon-ring ring-outer">
<div class="icon-ring ring-inner">
<div class="icon-ring ring-core">
<svg class="icon-sparkle" width="28" height="28" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2l1.09 6.26L18 4.5l-3.41 4.74L20 10l-5.41.76L18 15.5l-4.91-2.76L12 19l-1.09-6.26L6 15.5l3.41-4.74L4 10l5.41-.76L6 4.5l4.91 2.74L12 2z" fill="currentColor"/>
</svg>
</div>
</div>
</div>
</div>
<h2 class="placeholder-title">你好,我能帮你解决什么问题?</h2>
<p class="placeholder-desc">选择一个工作流,填写参数后一键生成</p>
<h2 class="placeholder-title">请选择下方工作流进行创作</h2>
<p class="placeholder-desc">点击工作流胶囊快速切换填写参数后一键生成内容</p>
<div class="placeholder-steps">
<div class="step-item">
<div class="step-num">1</div>
<div class="step-text">
<span class="step-label">选择工作流</span>
<span class="step-hint">点击胶囊切换不同创作模式</span>
</div>
</div>
<div class="step-arrow">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
</div>
<div class="step-item">
<div class="step-num">2</div>
<div class="step-text">
<span class="step-label">填写参数</span>
<span class="step-hint">按需配置内容与样式选项</span>
</div>
</div>
<div class="step-arrow">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
</div>
<div class="step-item">
<div class="step-num">3</div>
<div class="step-text">
<span class="step-label">一键生成</span>
<span class="step-hint">AI 自动产出结果同步到工作空间</span>
</div>
</div>
</div>
<div class="placeholder-footer">
<span class="footer-hint">还可从左侧对话记录查看历史或工作空间管理作品</span>
<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, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
import { uploadFile } from '/@/api/common/upload';
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;
workflowDetail: any;
activeHistoryId?: string | null;
messages?: any[];
// 会话内结果(session/get):仅用于判断「对话页 vs 占位页」,结果渲染统一走消息流
results?: VOSessionInfoResult[];
hasMore?: boolean;
loadingMore?: boolean;
// InputBar 发送按钮触发执行信号(透传给 ChatList,定位工作流卡片执行)
executeRequest?: { msgId: string; action: 'submit'; nonce: number } | null;
}
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 props = withDefaults(defineProps<Props>(), {
activeHistoryId: null,
messages: () => [],
results: () => [],
hasMore: false,
loadingMore: false,
executeRequest: null,
});
const formValues = reactive<Record<string, any>>({});
const fieldFiles = reactive<Record<string, { name: string; url: string }[]>>({});
const uploadingFields = reactive<Record<string, boolean>>({});
const templates = ref<any[]>([]);
const getFieldKey = (node: any, field: any): string => {
if (field?.__isHttpBodyChild) {
return `${node.id}_body_${field.bodyKey}`;
}
return (node.id || node.nodeCode) + '_' + (field.field || field.label);
};
const emit = defineEmits<Emits>();
const router = useRouter();
const getVisibleFields = (node: any): any[] => {
const fields = Array.isArray(node?.formConfig) ? node.formConfig : [];
const result: any[] = [];
fields.forEach((field: any) => {
if (!field) return;
if (field.expand && typeof field.expand === 'object' && field.expand.editable === false) return;
if (String(node?.nodeCode || '').toLowerCase() === 'http') {
if (field.field !== 'body') return;
const bodyVal = field.value;
if (!bodyVal || typeof bodyVal !== 'object' || Array.isArray(bodyVal)) return;
Object.entries(bodyVal).forEach(([bodyKey, bodyItem]: [string, any]) => {
if (!bodyItem || bodyItem.showInForm !== true) return;
result.push({
__isHttpBodyChild: true,
bodyKey,
field: `body.${bodyKey}`,
label: bodyItem.key || bodyKey,
required: false,
type: bodyItem.fieldType || 'input',
fieldType: bodyItem.fieldType || 'string',
fieldConstraint: bodyItem.fieldConstraint || {},
});
});
return;
}
result.push(field);
});
return result;
};
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;
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 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] = [];
fieldFiles[key].push({ name: raw.name, url: fileUrl });
if (field.type === 'upload') {
formValues[key] = fileUrl;
} else {
formValues[key] = fieldFiles[key].map((f: any) => f.url);
}
} catch (error: any) {
ElMessage.error(error?.message || '文件上传失败');
} finally {
uploadingFields[key] = false;
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 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] = '';
} else {
formValues[key] = fieldFiles[key].map((f) => f.url);
}
const handlePlaceSelect = (wf: any) => {
emit('workflow-select', wf.id, wf.isTemplate);
};
const currentWorkflowHasPatchLayout = computed(() => {
const nodes = props.workflowDetail?.nodeInputParams || [];
return nodes.some((node: any) => node.patchLayout === true);
// 「更多」入口:工作流数量超出展示上限时跳转工作流管理页查看/选择全部
const goWorkflowManage = () => {
router.push('/settings/workflow');
};
onMounted(() => {
loadPlaceWorkflows();
});
const hasFormConfig = (node: any): boolean => {
return node.nodeCode !== '__start__' && node.formConfig && node.formConfig.length > 0;
};
const hasFormFields = computed(() => {
if (!props.workflowDetail?.nodeInputParams) return false;
return props.workflowDetail.nodeInputParams.some(
(node: any) => node.nodeCode !== '__start__' && node.formConfig?.length > 0
);
});
const validateFormFields = (): boolean => {
if (!props.workflowDetail?.nodeInputParams) return true;
for (const node of props.workflowDetail.nodeInputParams as any[]) {
if (node.nodeCode === '__start__') continue;
const fields = getVisibleFields(node);
for (const field of fields) {
if (!field.required) 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.workflowDetail,
(detail) => {
Object.keys(formValues).forEach((key) => delete formValues[key]);
Object.keys(fieldFiles).forEach((key) => delete fieldFiles[key]);
Object.keys(uploadingFields).forEach((key) => delete uploadingFields[key]);
// 尝试从执行详情恢复贴片模板
const ext = (detail as any)?.extension;
const restoredTemplates = ext?.templates || (detail as any)?.templates || detail?.flowContent?.templates || [];
templates.value = Array.isArray(restoredTemplates) ? restoredTemplates : [];
if (!detail?.nodeInputParams) return;
detail.nodeInputParams.forEach((node: any) => {
if (!node.formConfig) return;
node.formConfig.forEach((field: any) => {
if (String(node.nodeCode || '').toLowerCase() === 'http' && field.field === 'body' && field.value && typeof field.value === 'object') {
Object.entries(field.value).forEach(([bodyKey, bodyItem]: [string, any]) => {
if (!bodyItem || bodyItem.showInForm !== true) return;
const bodyFieldKey = `${node.id}_body_${bodyKey}`;
if (bodyItem.fieldType === 'number') {
formValues[bodyFieldKey] = (bodyItem.value !== undefined && bodyItem.value !== null && bodyItem.value !== '')
? Number(bodyItem.value) : null;
} else if (bodyItem.fieldType === 'fileUpload') {
formValues[bodyFieldKey] = Array.isArray(bodyItem.value) ? bodyItem.value
: bodyItem.value ? [bodyItem.value] : [];
} else {
formValues[bodyFieldKey] = bodyItem.value || '';
}
});
return;
}
const key = getFieldKey(node, field);
const hasValue = field.value !== undefined && field.value !== null;
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') {
formValues[key] = hasValue ? field.value : (field.default ?? (field.type === 'upload' ? '' : []));
} else {
formValues[key] = hasValue ? field.value : (field.default ?? '');
}
});
});
detail.nodeInputParams.forEach((node: any) => {
if (!node.formConfig) return;
node.formConfig.forEach((field: any) => {
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;
fieldFiles[key] = urls.map((url: string) => ({
name: String(url || '').split('/').pop() || 'file-' + Math.random().toString(36).slice(2, 8),
url,
}));
});
});
},
{ immediate: true }
);
defineExpose({ formValues, fieldFiles, templates, validateFormFields });
defineExpose({ loadPlaceWorkflows });
</script>
<style scoped lang="scss">.main-content {
<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;
@@ -465,208 +166,119 @@ defineExpose({ formValues, fieldFiles, templates, validateFormFields });
&::-webkit-scrollbar { display: none; }
}
/* ===== 工作流表单 ===== */
.workflow-form-body {
width: min(820px, 100%);
/* ===== 对话页 ===== */
.chat-body {
width: min(880px, calc(100% - 40px));
margin: 0 auto;
height: 100%;
display: flex;
flex-direction: column;
padding: 16px 20px;
padding: 0;
box-sizing: border-box;
overflow: hidden; /* 结果列表固定高度、消息流内部滚动,避免整体滚动与内部冲突 */
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.workflow-form-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
display: flex;
flex-direction: column;
max-height: 100%;
overflow: hidden;
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
}
.workflow-form-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid #f1f5f9;
flex-shrink: 0;
h3 { margin: 0; font-size: 14px; font-weight: 600; color: #0f172a; }
.workflow-form-sub { font-size: 12px; color: #94a3b8; margin-top: 2px; }
.el-button { flex-shrink: 0; }
}
.workflow-form-scroll {
/* 消息流填充剩余高度并在内部滚动 */
.chat-body :deep(.chat-list-scroll) {
flex: 1;
overflow-y: auto;
padding: 20px;
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
&::-webkit-scrollbar-track { background: transparent; }
min-height: 0;
height: auto;
}
.form-node-group {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 16px 20px;
margin-bottom: 16px;
&:last-child { margin-bottom: 0; }
}
.form-node-group-title {
font-size: 12px;
font-weight: 600;
color: #64748b;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid #e2e8f0;
letter-spacing: 0.3px;
text-transform: uppercase;
}
: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; }
}
:deep(.el-divider) {
border-top-color: #e2e8f0;
margin-top: 6px;
margin-bottom: 12px;
.el-divider__text { font-weight: 600; font-size: 12px; color: #64748b; background: transparent; padding-left: 0; }
}
:deep(.upload-area) {
width: 100%;
.el-upload-dragger {
display: flex; align-items: center; justify-content: center;
width: 100%; height: auto; padding: 20px;
border: 2px dashed #e2e8f0; border-radius: 10px;
background: #fff; cursor: pointer;
transition: all 0.2s;
&:hover { border-color: #93c5fd; background: #f8faff; }
}
.upload-trigger { display: flex; align-items: center; gap: 12px; }
.upload-icon { font-size: 28px; color: #93c5fd; }
.upload-text { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: #475569; text-align: left; }
.upload-rule { font-size: 11px; color: #94a3b8; }
&.is-filled .el-upload-dragger { border-color: #bfdbfe; background: #eff6ff; }
}
.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: #fff;
border: 1px solid #e2e8f0; border-radius: 8px;
transition: background 0.15s;
&:hover { background: #f1f5f9; }
.file-type-icon { font-size: 16px; color: #3b82f6; flex-shrink: 0; }
.file-name { flex: 1; font-size: 12px; color: #475569; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.el-button { flex-shrink: 0; }
}
.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; }
}
}
.w100 { width: 100%; }
.chat-container { width: min(1060px, 82%); margin: 0 auto; padding: 20px 0 130px; }
.placeholder-body { display: flex; align-items: center; justify-content: center; }
.placeholder-content { text-align: center; padding: 20px; max-width: 480px; }
/* ===== 图标:层叠圆环 + 星形 ===== */
.placeholder-icon { margin-bottom: 28px; display: flex; justify-content: center; }
.icon-ring {
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease;
}
.ring-outer {
width: 88px; height: 88px;
background: radial-gradient(circle at 35% 30%, rgba(59, 130, 246, 0.10) 0%, rgba(139, 92, 246, 0.06) 100%);
border: 1.5px solid rgba(59, 130, 246, 0.15);
&:hover { transform: scale(1.05); }
}
.ring-inner {
width: 62px; height: 62px;
background: radial-gradient(circle at 35% 30%, rgba(59, 130, 246, 0.14) 0%, rgba(139, 92, 246, 0.08) 100%);
border: 1.5px solid rgba(59, 130, 246, 0.20);
}
.ring-core {
width: 42px; height: 42px;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.35);
}
.icon-sparkle { color: #fff; }
.placeholder-content { text-align: center; padding: 24px 20px; width: 100%; max-width: 740px; }
/* ===== 标题与描述 ===== */
.placeholder-title { font-size: 18px; font-weight: 700; color: #0f172a; margin: 0 0 6px; letter-spacing: -0.2px; line-height: 1.4; }
.placeholder-desc { font-size: 14px; color: #94a3b8; margin: 0 0 32px; line-height: 1.5; }
.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; }
/* ===== 步骤引导 ===== */
.placeholder-steps {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
margin-bottom: 28px;
/* ===== 工作流功能卡片(DeepSeek 式引导 ===== */
.workflow-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
text-align: left;
}
.step-item {
.workflow-card {
display: flex;
align-items: center;
gap: 10px;
text-align: left;
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);
}
}
.step-num {
width: 28px; height: 28px;
border-radius: 50%;
background: linear-gradient(135deg, #eff6ff 0%, #f0f0ff 100%);
border: 1.5px solid #bfdbfe;
color: #3b82f6;
font-size: 13px;
font-weight: 700;
display: flex;
.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;
transition: all 0.2s;
.step-item:hover & {
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
color: #fff;
border-color: transparent;
}
.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;
}
}
}
.step-text { display: flex; flex-direction: column; gap: 1px; }
.step-label { font-size: 13px; font-weight: 600; color: #334155; line-height: 1.3; white-space: nowrap; }
.step-hint { font-size: 11px; color: #94a3b8; line-height: 1.3; white-space: nowrap; }
.step-arrow {
color: #cbd5e1;
display: flex;
align-items: center;
margin: 0 6px;
flex-shrink: 0;
}
/* ===== 底部辅助提示 ===== */
.placeholder-footer { margin-top: 4px; }
.footer-hint { font-size: 12px; color: #cbd5e1; line-height: 1.5; }
</style>
.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>
+169 -50
View File
@@ -6,7 +6,7 @@
<el-icon class="brand-icon"><ChatDotRound /></el-icon>
<span class="brand-name">AI 助手</span>
</div>
<el-tooltip content="新建话" placement="right">
<el-tooltip content="新建话" placement="right">
<button class="new-chat-icon-btn" @click="handleNewChat">
<el-icon><EditPen /></el-icon>
</button>
@@ -15,11 +15,11 @@
<!-- Tab 切换 -->
<div class="sidebar-tabs">
<button class="tab-btn" :class="{ active: activeTab === 'history' }" @click="activeTab = 'history'">
<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="activeTab = 'workspace'">
<button class="tab-btn" :class="{ active: activeTab === 'workspace' }" @click="handleTabChange('workspace')">
<el-icon><FolderOpened /></el-icon>
工作空间
</button>
@@ -45,14 +45,28 @@
<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 class="panel-empty">暂无对话记录</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">
<el-empty v-if="!treeLoading && treeNodes.length === 0" :image-size="48" description="暂无内容" />
<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>
@@ -63,7 +77,13 @@
<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" @click.stop="handleDownloadNode(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>
@@ -98,6 +118,16 @@ interface Props {
activeMenu: string;
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;
}
@@ -110,12 +140,31 @@ interface Emits {
(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;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
historyFailed: false,
historyHasMore: false,
historyLoadingMore: false,
workspaceFailed: false,
workspaceLoaded: false,
downloadingFileId: null,
});
const emit = defineEmits<Emits>();
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 getFileIcon = (fileType?: string) => {
@@ -139,8 +188,8 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
.sidebar {
width: 240px;
flex-shrink: 0;
background: #f8fafc;
border-right: 1px solid #e2e8f0;
background: #f7f8fa;
border-right: 1px solid #e5e8ee;
display: flex;
flex-direction: column;
height: 100%;
@@ -152,20 +201,27 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 12px 12px;
border-bottom: 1px solid #e2e8f0;
padding: 16px 14px 14px;
flex-shrink: 0;
}
.brand-logo {
display: flex;
align-items: center;
gap: 8px;
gap: 9px;
}
.brand-icon {
font-size: 20px;
color: #2563eb;
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 {
@@ -194,18 +250,17 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
}
&:hover {
background: #e2e8f0;
background: #e8ebf0;
color: #0f172a;
}
}
/* Tab 切换 */
/* Tab 切换(圆角胶囊) */
.sidebar-tabs {
display: flex;
padding: 8px 8px 0;
padding: 12px 12px 8px;
gap: 4px;
flex-shrink: 0;
border-bottom: 1px solid #e2e8f0;
}
.tab-btn {
@@ -213,26 +268,24 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
height: 34px;
gap: 6px;
height: 36px;
border: none;
border-radius: 8px 8px 0 0;
border-radius: 10px;
background: transparent;
color: #64748b;
font-size: 12px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, color 0.15s;
outline: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
.el-icon {
font-size: 14px;
font-size: 15px;
}
&:hover:not(.active) {
background: #f1f5f9;
background: #eef0f3;
color: #334155;
}
@@ -240,7 +293,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
color: #2563eb;
font-weight: 600;
background: #fff;
border-bottom-color: #2563eb;
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.06);
}
}
@@ -248,7 +301,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
.panel {
flex: 1;
overflow-y: auto;
padding: 8px 8px 12px;
padding: 4px 8px 12px;
scrollbar-width: none;
&::-webkit-scrollbar {
@@ -268,19 +321,47 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
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;
gap: 8px;
padding: 8px;
border-radius: 8px;
padding: 10px;
border-radius: 10px;
cursor: pointer;
transition: background 0.15s;
margin-bottom: 1px;
margin-bottom: 2px;
&:hover {
background: #f1f5f9;
background: #eef0f3;
.item-del-btn {
opacity: 1;
@@ -288,7 +369,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
}
&.active {
background: #eff6ff;
background: #e6f0ff;
.item-icon {
color: #2563eb;
@@ -325,17 +406,17 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
.item-time {
font-size: 11px;
color: #94a3b8;
margin-top: 1px;
margin-top: 2px;
}
.item-del-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
width: 24px;
height: 24px;
border: none;
border-radius: 5px;
border-radius: 6px;
background: transparent;
color: #94a3b8;
cursor: pointer;
@@ -365,7 +446,7 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
font-size: 11px;
font-weight: 600;
color: #94a3b8;
padding: 6px 4px 2px;
padding: 8px 6px 4px;
letter-spacing: 0.4px;
text-transform: uppercase;
}
@@ -374,13 +455,13 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px 5px 24px;
border-radius: 6px;
padding: 7px 10px 7px 24px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #f1f5f9;
background: #eef0f3;
.file-actions {
opacity: 1;
@@ -412,14 +493,6 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
}
.file-action-btn {
&--del {
color: #ef4444;
&:hover {
background: #fee2e2;
color: #dc2626;
}
}
border: none;
background: transparent;
color: #3b82f6;
@@ -433,7 +506,26 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
&: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;
@@ -449,7 +541,34 @@ const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
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; }
@@ -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 {
// 子工作流详情拉取失败:跳过该子流程,不阻塞主模板补全
}
}
}
};
// 构建主模板树节点:优先拉取模板完整 DSL(get 接口),失败则回退列表项 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,888 @@
<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">
<!-- 执行中:节点文本进度推进(后端 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,含可编辑的最后一张):状态标签 + 失败原因按钮(默认收起,点击手动展开) -->
<div v-else-if="formStatus === 'done' || formStatus === 'failed'" class="form-status-row">
<el-tag :type="isFailed ? 'danger' : 'success'" size="small" effect="light">
{{ isFailed ? '执行失败' : '执行完成' }}
</el-tag>
<el-button v-if="isFailed && formError" text size="small" class="form-error-toggle" @click="showError = !showError">
{{ showError ? '收起失败原因' : '查看失败原因' }}
<el-icon class="error-arrow" :class="{ 'is-expanded': showError }"><ArrowDown /></el-icon>
</el-button>
</div>
<!-- 编辑态:无执行按钮,提示通过下方发送按钮执行 -->
<span v-else class="form-edit-tip">填写参数后,点击下方发送按钮执行工作流</span>
<!-- 失败原因详情:默认收起,点「查看失败原因」展开(error 字段可能含多行详细原因) -->
<div v-if="isFailed && showError && formError" class="form-error-text">{{ formError }}</div>
</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)卡片默认只读,
// 但最后一张已执行卡片(editable)允许改参;执行中(submitting)一律禁用
const isFailed = computed(() => !!props.formError);
const isDisabled = computed(() => (props.readonly && !props.editable) || props.submitting);
// 失败原因默认收起:新失败时重置为不展示,用户点「查看失败原因」才展开
const showError = ref(false);
watch(
() => props.formError,
() => {
showError.value = false;
}
);
// 表单收起/展开: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] = [];
// 文件名取服务器返回 fileName(OSS 存储名,已确认),随 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 即提交时的 nodes,从该处优先恢复;兼容旧数据(extension / 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;
flex-wrap: wrap; /* 失败原因详情换行到状态行下方 */
align-items: center;
gap: 12px;
padding: 12px 20px;
border-top: 1px solid #f1f5f9;
background: #fafbfc;
flex-shrink: 0;
.form-status-row {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.form-error-toggle {
padding: 0;
height: auto;
font-size: 12px;
color: #ef4444;
.error-arrow {
transition: transform 0.2s;
&.is-expanded { transform: rotate(180deg); }
}
}
.form-error-text {
flex-basis: 100%; /* 占整行,显示在状态行下方 */
font-size: 12px;
color: #ef4444;
line-height: 1.5;
white-space: pre-wrap; /* 失败详情可能含换行(error 字段多行原因),允许换行展示 */
word-break: break-word;
background: #fef2f2;
border: 1px solid #fee2e2;
border-radius: 6px;
padding: 8px 12px;
}
.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/file)、backendId 执行记录 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>
+1546 -261
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' }));
}
+184
View File
@@ -0,0 +1,184 @@
// ===== 首页 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 '执行失败,请重试';
}
/** error 详细原因(error 字段优先展示失败详情;message 仅在 error 缺失时兜底)——供工作流失败卡片「查看失败原因」使用 */
export function getErrorDetail(msg: WsStreamMessage): string {
if (typeof msg?.error === 'string' && msg.error) return msg.error;
if (typeof msg?.message === 'string' && msg.message) return msg.message;
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 '执行失败,请重试';
}
@@ -113,21 +113,28 @@
<KeyValueEditor v-model="formData.requestHeadMapping" />
</el-form-item>
</el-col>
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
<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="8" :md="8" :lg="8" :xl="8" class="mb20">
<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="8" :md="8" :lg="8" :xl="8" class="mb20" v-if="formData.responseType !== 2">
<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"
@@ -152,48 +159,64 @@
<template v-if="formData.responseType === 2">
<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-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 }})
配置 ({{ Object.keys((formData.asyncTaskMapping.responseMapping || {})).length }})
</el-button>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
<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="24" :md="24" :lg="24" :xl="24" class="mb20">
<el-form-item label="回调请求映射">
<KeyValueEditor v-model="formData.asyncTaskMapping.requestHeadMapping" />
<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="8" :md="8" :lg="8" :xl="8" 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-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">
@@ -375,6 +398,7 @@ const defaultAsyncTaskMapping: AsyncTaskMapping = {
url: '',
httpMethod: 'POST',
requestHeadMapping: {},
requestBodyMapping: {},
responseMapping: {},
taskId: '',
taskStatus: '',
@@ -401,6 +425,7 @@ const formData = reactive({
//
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
@@ -529,6 +554,7 @@ const hasTaskStatusConfig = computed(() => taskStatusOptions.value.length > 0);
/** 用户手动切换返回类型时清空 token 映射(编程赋值不触发) */
function handleResponseTypeChange() {
formData.tokenMapping = { ...defaultTokenMapping };
formData.responseBodyMapping = [];
}
@@ -542,7 +568,9 @@ const mappingFieldLabels: Record<string, string> = {
requestHeadMapping: '请求头映射',
requestBodyMapping: '请求体映射',
responseMapping: '响应映射',
error_message_mapping: '错误消息映射',
asyncResponseMapping: '异步-响应映射',
asyncRequestBodyMapping: '异步-请求体映射',
};
const jsonEditorTitle = computed(() => {
@@ -553,6 +581,7 @@ const jsonEditorTitle = computed(() => {
/** 获取映射字段的引用 */
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>;
}
@@ -560,6 +589,8 @@ function getMappingValue(key: string): 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;
}
@@ -571,8 +602,53 @@ const openMappingEditor = (key: string) => {
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 = () => {
setMappingValue(editingMappingKey.value, { ...jsonEditorData.value });
const cleaned = resetSchemaValues(JSON.parse(JSON.stringify(jsonEditorData.value)));
setMappingValue(editingMappingKey.value, cleaned);
jsonEditorVisible.value = false;
};
@@ -634,6 +710,7 @@ const resetForm = () => {
formData.tokenPredictPrice = 0;
formData.requestHeadMapping = {};
formData.requestBodyMapping = {};
formData.error_message_mapping = {};
formData.responseMapping = {};
formData.responseBodyMapping = [];
formData.tokenMapping = { ...defaultTokenMapping };
@@ -661,18 +738,27 @@ const openDialog = async (type: 'add' | 'edit', id?: string) => {
formData.apiKey = row.apiKey || '';
formData.responseType = row.responseType ?? row.invokeType;
formData.enabled = row.enabled ?? true;
formData.chatModel = row.ChatModel ?? false;
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 } : { ...defaultAsyncTaskMapping };
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 || '';
@@ -695,6 +781,10 @@ const handleSubmit = async () => {
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,
@@ -709,9 +799,10 @@ const handleSubmit = async () => {
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, '']))
? Object.fromEntries(formData.responseBodyMapping.map((k) => [k, responseBodyLabelMap.get(k) || k]))
: undefined,
};
@@ -726,11 +817,15 @@ const handleSubmit = async () => {
if (formData.responseType === 2) {
const atm = formData.asyncTaskMapping;
const hasAsyncConfig = atm.url || Object.keys(atm.requestHeadMapping).length > 0 || atm.taskId;
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 (formData.lastFrame) {
if (isVideoModel.value && formData.lastFrame) {
lastFrame = formData.lastFrame;
}
let maxDuration: number | undefined;
+21 -3
View File
@@ -43,9 +43,9 @@
<el-tag :type="scope.row.systemModel ? 'warning' : 'success'">{{ scope.row.systemModel ? '系统' : '用户' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="访问类型">
<el-table-column label="供应商" show-overflow-tooltip>
<template #default="scope">
<el-tag :type="scope.row.PrivateModel ? 'info' : 'primary'">{{ scope.row.PrivateModel ? '服务商模型' : '本地模型' }}</el-tag>
{{ resolveSupplierLabel(scope.row.modelSupplier) }}
</template>
</el-table-column>
<el-table-column label="状态">
@@ -85,7 +85,7 @@ 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, type ModelManageItem, type ModelTypeTreeNode } from '/@/api/settings/modelConfigV2';
import { listModelManage, getModelManageTypes, deleteModelManage, getModelSupplierList, type ModelManageItem, type ModelTypeTreeNode, type ModelSupplierItem } from '/@/api/settings/modelConfigV2';
const searchParams = reactive({
modelName: '',
@@ -101,6 +101,7 @@ const pagination = reactive({
const modelList = ref<ModelManageItem[]>([]);
const loading = ref(false);
const modelTypes = ref<ModelTypeTreeNode[]>([]);
const supplierList = ref<ModelSupplierItem[]>([]);
const editModuleRef = ref();
/** 级联选择器的选中路径(例:[200, 201]) */
@@ -139,6 +140,22 @@ const loadModelTypes = async () => {
}
};
/** 加载模型供应商列表 */
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;
@@ -204,6 +221,7 @@ const handleDelete = (row: ModelManageItem) => {
onMounted(async () => {
await loadModelTypes();
loadSupplierList();
fetchModelList();
});
</script>
@@ -33,6 +33,9 @@
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
@@ -67,8 +70,12 @@ const sync = () => {
emit('update:modelValue', clone(fields.value));
};
// + field
let fieldSeq = 0;
const genFieldKey = () => `f_${Date.now().toString(36)}_${++fieldSeq}`;
const addField = () => {
fields.value.push({ label: '', type: 'input', value: '', required: false });
fields.value.push({ field: genFieldKey(), label: '', type: 'input', value: '', required: false });
sync();
};
@@ -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>
@@ -16,6 +16,7 @@
:field-def="childDef"
:path="path ? `${path}.attrs.${childKey}` : childKey"
:upstream-nodes="upstreamNodes"
:multi="multi"
/>
</div>
</div>
@@ -32,39 +33,24 @@
</el-tooltip>
</div>
<div class="mf-array-flat">
<!-- 联合数组enumValues 多模板全部模板平铺固定条数不可增删 -->
<template v-if="isVariantArray">
<div v-for="(item, idx) in getVariantItems()" :key="idx" class="mf-variant">
<div class="mf-variant-head">
<!-- 数组元素直接遍历模板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>
<template v-if="isObjectDef(item)">
<ModelField
v-for="(subDef, subKey) in item.attrs"
:key="subKey"
:field-def="subDef"
:path="path ? `${path}.value[${idx}].attrs.${subKey}` : String(subKey)"
:upstream-nodes="upstreamNodes"
/>
</template>
<el-input v-else :model-value="item" @input="(v: any) => setArrayPrimitive(idx, v)" size="small" />
</div>
</template>
<!-- 同构数组保持现有平铺逻辑 -->
<template v-else>
<template v-for="(item, idx) in getArrayValue()" :key="idx">
<template v-if="isObjectDef(item)">
<div v-if="getArrayValue().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}.value[${idx}].attrs.${subKey}` : String(subKey)"
:upstream-nodes="upstreamNodes"
/>
</template>
<el-input v-else :model-value="item" @input="(v: any) => setArrayPrimitive(idx, v)" size="small" />
<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>
@@ -87,52 +73,24 @@
class="mf-leaf-expose"
>表单展示</el-checkbox>
</el-tooltip>
<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="!!def.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"
class="mf-ref-field"
@click="setValueSource(node, of)"
>
{{ of.label || of.field }}
</div>
</div>
</template>
</div>
</el-popover>
<!-- 引用上级输出与表单展示勾选同级所有叶子参数含数组元素内字段都可引用multi 时支持多选 -->
<MfReferenceButton :field-def="def" :upstream-nodes="upstreamNodes" :multi="multi" />
</div>
<div v-if="hasValueSource" class="mf-ref-bound" :class="{ 'is-invalid': valueSourceInvalid }">
<span class="mf-ref-bound-text">已引用{{ valueSourceLabel }}</span>
<el-button size="small" text type="danger" @click="clearValueSource">清除</el-button>
<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-else-if="!isUpload" class="mf-leaf-ctrl">
<div v-if="!hasValueSource && !isUpload" class="mf-leaf-ctrl">
<el-input-number
v-if="isNumber"
:model-value="numberValue"
@@ -176,8 +134,8 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { FolderOpened, List, QuestionFilled, Link } from '@element-plus/icons-vue';
import { deepClone, normalizeArrayItem } from './modelParamUtils';
import { FolderOpened, List, QuestionFilled } from '@element-plus/icons-vue';
import MfReferenceButton from './MfReferenceButton.vue';
defineOptions({ name: 'ModelField' });
@@ -186,10 +144,10 @@ interface UpstreamNodeInfo {
id: string;
label: string;
nodeCode: string;
outputFields: { field: string; label: string }[];
outputFields: { field: string; label: string; fieldType?: 'scalar' | 'object' | 'array' }[];
}
const props = defineProps<{ fieldDef: any; path?: string; upstreamNodes?: UpstreamNodeInfo[] }>();
const props = defineProps<{ fieldDef: any; path?: string; upstreamNodes?: UpstreamNodeInfo[]; multi?: boolean }>();
// computed props internalSync
const def = computed(() => props.fieldDef);
@@ -220,46 +178,57 @@ const isTextarea = computed(() => isLeaf.value && fieldType.value === 'textarea'
const isUpload = computed(() => isLeaf.value && fieldType.value === 'upload');
// ===== =====
// + + /
const canValueSource = computed(() => isLeaf.value && !isSwitch.value && !isSelect.value);
const hasUpstream = computed(() => (props.upstreamNodes?.length ?? 0) > 0);
const hasValueSource = computed(() => !!def.value?.valueSource);
// {label}.{label}
const valueSourceLabel = computed(() => {
const ref = def.value?.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}`;
// 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));
// / schema
const valueSourceInvalid = computed(() => {
const ref = def.value?.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);
});
// valueSource watch
// valueSource watch
watch(
valueSourceInvalid,
(invalid) => {
if (invalid && def.value) {
delete def.value.valueSource;
}
() => 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 }
);
// def.valueSource watch
const setValueSource = (node: UpstreamNodeInfo, of: { field: string; label: string }) => {
// closable
const removeValueSource = (ref: any) => {
if (!def.value) return;
def.value.valueSource = { nodeId: node.id, field: of.field };
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;
@@ -312,22 +281,26 @@ const arrayTemplates = computed<any[]>(() => {
return [];
});
const getArrayValue = (): any[] => {
if (!def.value) return [];
if (!Array.isArray(def.value.value)) {
def.value.value = [];
}
// value
if (def.value.value.length === 0 && arrayTemplates.value.length > 0) {
def.value.value.push(normalizeArrayItem(deepClone(arrayTemplates.value[0])));
}
return def.value.value;
};
const isObjectDef = (item: any) => !!item && typeof item === 'object' && item.type === 'object' && item.attrs && typeof item.attrs === 'object';
const setArrayPrimitive = (idx: number, val: any) => {
getArrayValue()[idx] = val ?? '';
// 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 =====
@@ -366,25 +339,6 @@ const autoFillVariant = (tpl: any): void => {
}
};
// value enumValues
const getVariantItems = (): any[] => {
if (!isVariantArray.value || !def.value) return [];
const templates = Array.isArray(def.value.enumValues) ? def.value.enumValues : [];
if (!Array.isArray(def.value.value)) def.value.value = [];
templates.forEach((tpl: any, i: number) => {
const existing = def.value.value[i];
if (!existing || typeof existing !== 'object') {
const item = normalizeArrayItem(deepClone(tpl));
autoFillVariant(item);
def.value.value[i] = item;
}
});
if (def.value.value.length > templates.length) {
def.value.value = def.value.value.slice(0, templates.length);
}
return def.value.value;
};
const variantLabel = (idx: number): string => templateLabel(def.value?.enumValues?.[idx], idx);
</script>
@@ -523,20 +477,26 @@ const variantLabel = (idx: number): string => templateLabel(def.value?.enumValue
.mf-ref-bound {
display: flex;
align-items: center;
gap: 8px;
align-items: flex-start;
gap: 6px;
font-size: 12px;
color: #3b82f6;
background: #eff6ff;
border-radius: 4px;
padding: 4px 8px;
padding: 6px 8px;
.mf-ref-bound-text {
.mf-ref-tags {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
flex-wrap: wrap;
gap: 4px;
.mf-ref-tag {
:deep(.el-tag__content) {
font-size: 12px;
}
}
}
&.is-invalid {
@@ -557,6 +517,9 @@ const variantLabel = (idx: number): string => templateLabel(def.value?.enumValue
<style lang="scss">
.mf-ref-popover {
.mf-ref-panel {
//
max-height: 40vh;
overflow-y: auto;
.mf-ref-empty {
color: #94a3b8;
font-size: 12px;
@@ -14,6 +14,7 @@
:field-def="def"
:path="key"
:upstream-nodes="upstreamNodes"
:multi="multi"
/>
</div>
</div>
@@ -33,7 +34,7 @@ interface UpstreamNodeInfo {
outputFields: { field: string; label: string }[];
}
const props = defineProps<{ modelRequestParams: Record<string, any> | null; upstreamNodes?: UpstreamNodeInfo[] }>();
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>>({});
@@ -22,7 +22,11 @@
@click="handleSelectModel(model)"
>
<div class="model-card-header">
<div class="model-type">{{ getModelTypeName(model.modelType) }}</div>
<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>
@@ -56,16 +60,56 @@
<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 {
@@ -76,6 +120,8 @@ interface Emits {
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
defaultModel: null,
sameTypeModelType: null,
systemModelRequireKey: true,
});
const emit = defineEmits<Emits>();
@@ -86,6 +132,11 @@ 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,
@@ -143,6 +194,9 @@ const fetchModelList = async () => {
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 || [];
@@ -170,6 +224,14 @@ const handlePageChange = () => {
};
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;
};
@@ -180,6 +242,68 @@ const handleConfirm = () => {
}
};
// 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;
@@ -232,13 +356,19 @@ const handleClose = () => {
margin-bottom: 12px;
}
.model-type {
display: inline-block;
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;
@@ -272,4 +402,12 @@ const handleClose = () => {
justify-content: center;
margin-top: 20px;
}
.api-key-alert {
margin-bottom: 16px;
}
.api-key-form {
margin-bottom: 4px;
}
</style>
@@ -13,8 +13,39 @@
@update:model-value="updateNodeDesc"
/>
</el-form-item>
<el-form-item label="节点类型">
<el-tag>{{ selectedNode.data?.nodeCode }}</el-tag>
<!-- 前置方法 / 后置方法节点库 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>
<!-- 开始节点运行表单字段摘要来自各模型节点勾选的表单展示字段 -->
@@ -46,7 +77,7 @@
</el-form-item>
<!-- 贴片布局开关 -->
<el-form-item v-if="isVideoModelSelected" label="贴片布局">
<el-form-item v-if="nodeConfig?.patchLayout" label="贴片布局">
<el-switch
:model-value="selectedNode.data?.patchLayout ?? false"
@update:model-value="emit('update:patchLayout', $event)"
@@ -85,62 +116,86 @@
<template v-if="nodeConfig?.formConfig && nodeConfig.formConfig.length > 0">
<el-divider content-position="left">节点参数</el-divider>
<template v-for="field in nodeConfig.formConfig" :key="field.field">
<el-form-item :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="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-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-select>
<KeyValueEditor
v-else-if="field.type === 'keyValue'"
:model-value="getFieldValue(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 ? '必填' : '选填'"
/>
<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>
<!-- 下拉选中选项的嵌套配置responseType 级联 -->
@@ -184,7 +239,7 @@
</el-select>
<KeyValueEditor
v-else-if="sub.type === 'keyValue'"
:model-value="getExpandValue(field.field, sub.field)"
:model-value="getExpandKeyValueValue(field.field, sub.field)"
@update:model-value="(v: any) => updateExpandValue(field.field, sub.field, v)"
/>
<el-button
@@ -202,6 +257,28 @@
</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>
@@ -224,6 +301,7 @@
<ModelParamsForm
:model-request-params="selectedNode.data.modelConfig.modelRequestParams"
:upstream-nodes="upstreamNodes"
:multi="!!nodeConfig?.isMultiParameter"
@update:model-request-params="updateModelRequestParams"
/>
</template>
@@ -249,12 +327,15 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { ref, computed, watch } from 'vue';
import type { Node } from '@vue-flow/core';
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 {
@@ -268,17 +349,25 @@ interface NodeData {
negativePrompt?: string;
patchLayout?: boolean;
isSaveFile?: boolean;
preTool?: string | null;
postTool?: string | null;
runFormFields?: any[];
// sub_flowworkflowId +
subFlowConfig?: any;
}
interface NodeConfig {
formConfig: any[];
preToolOption: any[];
postToolOption: any[];
isMultiParameter: boolean;
modelConfigOption: boolean;
formConfigOption: boolean;
skillOption: boolean;
promptOption: boolean;
negativePromptOption: boolean;
isSaveFileOption: boolean;
patchLayout: boolean;
}
//
@@ -295,17 +384,17 @@ const props = defineProps<{
upstreamNodes?: UpstreamNodeInfo[];
}>();
const isVideoModelSelected = computed(() => {
const t = Number(props.selectedNode?.data?.modelConfig?.modelType);
return t >= 600 && t <= 605;
});
//
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> = {
@@ -330,6 +419,8 @@ const emit = defineEmits<{
(e: 'openSkillSelector'): void;
(e: 'removeSkill'): void;
(e: 'update:patchLayout', value: boolean): void;
(e: 'openWorkflowSelector'): void;
(e: 'removeWorkflow'): void;
}>();
// Schema
@@ -433,12 +524,71 @@ const updateModelRequestParams = (params: Record<string, any> | null) => {
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;
@@ -474,6 +624,108 @@ const getFormConfigEntry = (fieldName: string) => {
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 [];
@@ -489,6 +741,12 @@ const getExpandValue = (hostField: string, subField: string) => {
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;
@@ -549,6 +807,35 @@ const updateSelectFieldValue = (fieldDef: any, value: any) => {
</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;
@@ -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>
@@ -102,6 +102,15 @@ export function stripReadonlyFields(params: any): any {
}
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;
@@ -128,9 +137,47 @@ export function stripReadonlyFields(params: any): any {
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;
}
// ===== 暴露清单:在工作流表单中展示的勾选字段 =====
// 单个暴露叶子字段(路径带实例索引,如 "messages.value[0].attrs.content"
// 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;
@@ -139,13 +186,46 @@ export interface ExposedField {
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 实例元素 → 父路径 + ".value[i]"
// array 模板元素 → 父路径 + ".enumValues[i]"
// 原始值数组元素(非 object 包装)不可勾选,跳过
export function collectExposedFields(params: any, prefix = ''): ExposedField[] {
if (!params || typeof params !== 'object') return [];
@@ -161,14 +241,17 @@ export function collectExposedFields(params: any, prefix = ''): ExposedField[] {
result.push(...collectExposedFields(attrs, `${path}.attrs`));
}
} else if (t === 'array') {
// 仅遍历实例数组;元素为 { type:'object', attrs } 包装时才递归
if (Array.isArray(def.value)) {
def.value.forEach((item: any, i: number) => {
if (item && typeof item === 'object' && item.attrs && typeof item.attrs === 'object' && !Array.isArray(item.attrs)) {
result.push(...collectExposedFields(item.attrs, `${path}.value[${i}].attrs`));
}
});
}
// 勾选跟随模板:渲染时 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({
@@ -179,20 +262,157 @@ export function collectExposedFields(params: any, prefix = ''): ExposedField[] {
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(加载时还原勾选)
export function restoreRuntimeShow(params: any, fields: ExposedField[] | null | undefined): void {
// 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 }];
}
}
}
}
@@ -208,14 +428,26 @@ function resolvePath(params: any, path: string): any {
cur = cur.attrs;
if (!cur || typeof cur !== 'object') return undefined;
} else {
const m = seg.match(/^value\[(\d+)\]$/);
if (m) {
const idx = Number(m[1]);
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];
} else {
cur = cur[seg];
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;
@@ -225,3 +457,53 @@ function resolvePath(params: any, path: string): any {
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,213 @@
<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">
<div class="cfg-row">
<div class="cfg-item">
<div class="cfg-label">门禁金额</div>
<el-input-number v-model="minBalance" :min="0" :precision="5" :step="0.01" :controls="false" class="cfg-min-balance" />
</div>
<div class="cfg-item">
<div class="cfg-label">币种</div>
<el-select v-model="currency" class="cfg-currency">
<el-option label="人民币" value="CNY" />
<el-option label="美元" value="USD" />
</el-select>
</div>
<div class="cfg-item">
<div class="cfg-label">启用</div>
<el-switch v-model="enabled" />
</div>
</div>
</div>
</div>
</div>
<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="周期">
<el-select v-model="period" class="ctrl-select">
<el-option label="按年" value="year" />
</el-select>
</el-form-item>
<el-form-item label="每周期价格(元)">
<el-input-number
v-model="price"
:min="0"
:precision="5"
:step="0.01"
:controls="false"
class="ctrl-input"
/>
</el-form-item>
<div class="form-hint">本期支持按年订阅订阅购买 / 续费流程接入另期</div>
</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: block;
}
.editor-col {
width: 100%;
min-width: 0;
}
.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;
}
.cfg-row {
display: flex;
align-items: flex-end;
gap: 40px;
flex-wrap: wrap;
}
.cfg-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.cfg-label {
font-size: 13px;
color: #606266;
}
.cfg-min-balance {
width: 200px;
}
.cfg-currency {
width: 200px;
}
.ctrl-input {
width: 220px;
}
.ctrl-select {
width: 220px;
}
</style>
@@ -0,0 +1,616 @@
<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">
<div class="cfg-row">
<div class="cfg-item">
<div class="cfg-label">门禁金额</div>
<el-input-number v-model="minBalance" :min="0" :precision="5" :step="0.01" :controls="false" class="cfg-min-balance" />
</div>
<div class="cfg-item">
<div class="cfg-label">币种</div>
<el-select v-model="currency" class="cfg-currency">
<el-option label="人民币" value="CNY" />
<el-option label="美元" value="USD" />
</el-select>
</div>
<div class="cfg-item">
<div class="cfg-label">启用</div>
<el-switch v-model="enabled" />
</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="计费单位">
<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="阶梯分档">
<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">命中条件</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">计价项</div>
<el-form label-position="top" size="small">
<div class="rc-grid">
<template v-if="isTokenUnit">
<el-form-item label="输入单价">
<el-input-number v-model="r.input" :min="0" :precision="5" :step="0.0001" :controls="false" class="rc-control" />
</el-form-item>
<el-form-item label="输出单价">
<el-input-number v-model="r.output" :min="0" :precision="5" :step="0.0001" :controls="false" class="rc-control" />
</el-form-item>
<el-form-item label="缓存命中">
<el-input-number v-model="r.cacheHit" :min="0" :precision="5" :step="0.0001" :controls="false" class="rc-control" />
</el-form-item>
</template>
<template v-else>
<el-form-item label="每单位单价">
<el-input-number v-model="r.unitPrice" :min="0" :precision="5" :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">媒体价格模板可选</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>
<div class="mp-field">
<span class="mp-label">输入价</span>
<el-input-number v-model="mp.input" :min="0" :precision="5" :step="0.0001" :controls="false" class="mp-num" />
</div>
<div class="mp-field">
<span class="mp-label">输出价</span>
<el-input-number v-model="mp.output" :min="0" :precision="5" :step="0.0001" :controls="false" class="mp-num" />
</div>
<div class="mp-field">
<span class="mp-label">缓存命中价</span>
<el-input-number v-model="mp.cacheHit" :min="0" :precision="5" :step="0.0001" :controls="false" class="mp-num" />
</div>
<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>
</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: block;
}
.editor-col {
width: 100%;
min-width: 0;
}
.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;
}
.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: flex-end;
gap: 8px;
margin-bottom: 8px;
.mp-media {
width: 120px;
flex-shrink: 0;
}
.mp-field {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
.mp-label {
font-size: 12px;
color: #606266;
}
.mp-num {
width: 100%;
}
}
}
.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;
}
.cfg-row {
display: flex;
align-items: flex-end;
gap: 40px;
flex-wrap: wrap;
}
.cfg-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.cfg-label {
font-size: 13px;
color: #606266;
}
.cfg-min-balance {
width: 200px;
}
.cfg-currency {
width: 200px;
}
.ctrl-select {
width: 220px;
}
</style>
@@ -0,0 +1,211 @@
<template>
<div class="subject-list">
<div v-if="loading" class="subject-loading">加载计价对象</div>
<template v-else>
<!-- 工作流 -->
<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
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">{{ modelSubjects.length }}</span>
</div>
<div
v-for="s in modelSubjects"
: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="!modelSubjects.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
class="status-dot"
:class="s.hasConfig ? 'on' : 'off'"
:title="s.hasConfig ? '已配置' : '未配置'"
/>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { 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'));
</script>
<style scoped lang="scss">
.subject-list {
.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;
}
}
.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,343 @@
<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">
<div class="cfg-row">
<div class="cfg-item">
<div class="cfg-label">门禁金额</div>
<el-input-number v-model="minBalance" :min="0" :precision="5" :step="0.01" :controls="false" class="cfg-min-balance" />
</div>
<div class="cfg-item">
<div class="cfg-label">币种</div>
<el-select v-model="currency" class="cfg-currency">
<el-option label="人民币" value="CNY" />
<el-option label="美元" value="USD" />
</el-select>
</div>
<div class="cfg-item">
<div class="cfg-label">启用</div>
<el-switch v-model="enabled" />
</div>
</div>
</div>
</div>
</div>
<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>
<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="档位列表(上不封顶,命中首个档位上限 ≥ 时长的档)">
<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="档位上限(秒)"
class="tier-input"
/>
<el-input-number
v-model="tier.price"
:min="0"
:precision="5"
: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="5"
: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>
<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="5"
: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>
<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>
</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: block;
}
.editor-col {
width: 100%;
min-width: 0;
}
.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-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;
}
.cfg-row {
display: flex;
align-items: flex-end;
gap: 40px;
flex-wrap: wrap;
}
.cfg-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.cfg-label {
font-size: 13px;
color: #606266;
}
.cfg-min-balance {
width: 200px;
}
.cfg-currency {
width: 200px;
}
</style>
+449
View File
@@ -0,0 +1,449 @@
<template>
<div class="system-price-container">
<el-card shadow="hover" class="price-card">
<!-- 顶部操作栏 -->
<div class="page-toolbar">
<div class="page-toolbar-left">
<div class="page-title">计价配置管理</div>
<div class="page-subtitle">统一管理工作流 / 模型 / 业务模块的计费规则与门禁</div>
</div>
<div class="page-toolbar-actions">
<el-button :loading="loading" :disabled="!subjects.length" @click="handleRefresh">刷新</el-button>
<el-button type="primary" :loading="saving" :disabled="!current" @click="handleSave">保存配置</el-button>
</div>
</div>
<div class="price-layout">
<!-- 左侧计价对象枚举 -->
<div class="subject-panel">
<div class="subject-head">
<span class="subject-title">计价对象</span>
<span class="subject-count">{{ subjects.length }} </span>
</div>
<SubjectList :subjects="subjects" :current-key="currentKey" :loading="loading" @select="onSelect" />
</div>
<!-- 右侧配置编辑 -->
<div class="editor-panel">
<div class="editor-overview">
<div class="overview-main">
<div class="overview-title">
{{ current?.name ?? '计价对象' }}
<span v-if="current?.type === 'model'" class="type-tag" :class="`t-${current.modelType}`">
{{ current.typeLabel }}
</span>
</div>
<div class="overview-meta">
<span v-if="current" class="conf-status" :class="current.hasConfig ? 'ok' : 'no'">
{{ current.hasConfig ? '已配置' : '未配置' }}
</span>
<span v-if="current && lastUpdated" class="update-time">更新于 {{ lastUpdated }}</span>
</div>
</div>
<div class="overview-sub">{{ headerSub }}</div>
</div>
<!-- 面板切换v-if 每次切换重建initConfig 驱动回显后端已保存配置 -->
<template v-if="current">
<WorkflowEditor
v-if="current.type === 'workflow'"
ref="workflowRef"
:subject="current"
:init-config="initConfig"
/>
<ModelEditor
v-else-if="current.type === 'model'"
ref="modelRef"
:key="currentKey"
:subject="current"
:init-config="initConfig"
/>
<BusinessEditor
v-else-if="current.type === 'business'"
ref="businessRef"
:subject="current"
:init-config="initConfig"
/>
</template>
<div v-else class="editor-empty">请选择左侧计价对象</div>
</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import SubjectList from './component/subjectList.vue';
import WorkflowEditor from './component/workflowEditor.vue';
import ModelEditor from './component/modelEditor.vue';
import BusinessEditor from './component/businessEditor.vue';
import { getPricingConfig, getSubjects, savePricingConfig } from '/@/api/trade/pricing';
import {
toSubject,
type ApiSubjectItem,
type BusinessRules,
type Currency,
type EditorInitConfig,
type ModelRules,
type Subject,
type WorkflowRules,
} from './mock';
/**
* 价格管理计价配置管理页面
* 左侧计价对象枚举来自后端 subjects 接口右侧按对象加载 config/get 详情并编辑
* 保存时把编辑器构建的费率对象 stringify rules 字符串提交 config/save
*/
/** 后端 config/get 返回的配置记录(rules 为费率对象;兼容历史字符串形态) */
interface PricingDetailRaw {
/** 配置记录 id(雪花 id 超 JS 安全整数,用字符串承载避免精度丢失) */
id: string | number;
subjectType: string;
subjectId: string;
/** rules 为费率对象;旧接口可能返回 JSON 字符串,解析时兼容 */
rules: string | Record<string, unknown>;
minBalance: number;
currency: string;
enabled: number;
updatedAt?: string;
}
/** 编辑器 save() 的返回结构(父组件组装提交) */
type SaveResult = {
rules: WorkflowRules | ModelRules | BusinessRules;
minBalance: number;
currency: Currency;
enabled: boolean;
};
const subjects = ref<Subject[]>([]);
const currentKey = ref('');
const loading = ref(false);
const saving = ref(false);
/** 当前配置记录 id(>0 更新,0 新增;雪花 id 用字符串承载,避免精度丢失) */
const currentConfigId = ref<string | number>(0);
/** 当前对象已保存配置(parse 后),传给编辑器回显;null 表示无配置 */
const initConfig = ref<EditorInitConfig | null>(null);
/** 当前配置最近更新时间(展示用) */
const lastUpdated = ref('');
const workflowRef = ref<InstanceType<typeof WorkflowEditor>>();
const modelRef = ref<InstanceType<typeof ModelEditor>>();
const businessRef = ref<InstanceType<typeof BusinessEditor>>();
const current = computed<Subject | undefined>(() =>
subjects.value.find((s) => `${s.type}:${s.id}` === currentKey.value),
);
const headerSub = computed(() => {
const s = current.value;
if (!s) return '加载计价对象…';
if (s.type === 'workflow') return `工作流对象 · 标识:${s.id}`;
if (s.type === 'model') return `模型对象(${s.typeLabel})· 标识:${s.id}`;
return `业务模块对象 · 标识:${s.id}`;
});
/** 详情请求序号,防止快速切换时旧响应覆盖当前对象 */
let detailSeq = 0;
function onSelect(subject: Subject) {
// initConfig v-if
initConfig.value = null;
currentConfigId.value = 0;
currentKey.value = `${subject.type}:${subject.id}`;
loadDetail(subject);
}
/** 拉取某对象已保存的计价配置,填充编辑器回显并刷新"已配置"状态 */
async function loadDetail(subject: Subject) {
const seq = ++detailSeq;
try {
const res = await getPricingConfig({ subjectType: subject.type, subjectId: subject.id });
const data = res?.data as PricingDetailRaw | undefined;
if (data && data.rules) {
let parsed: EditorInitConfig['rules'];
try {
// config/get rules JSON
parsed =
typeof data.rules === 'string'
? JSON.parse(data.rules)
: (data.rules as unknown as EditorInitConfig['rules']);
} catch {
parsed = undefined as unknown as EditorInitConfig['rules'];
}
if (parsed) {
if (seq !== detailSeq) return;
subject.hasConfig = true;
currentConfigId.value = data.id;
lastUpdated.value = formatTime(data.updatedAt);
initConfig.value = {
id: data.id,
minBalance: data.minBalance ?? 0,
currency: (data.currency as Currency) || 'CNY',
enabled: data.enabled === 1,
rules: parsed,
};
return;
}
}
if (seq !== detailSeq) return;
subject.hasConfig = false;
currentConfigId.value = 0;
lastUpdated.value = '';
initConfig.value = null;
} catch {
//
if (seq !== detailSeq) return;
subject.hasConfig = false;
currentConfigId.value = 0;
lastUpdated.value = '';
initConfig.value = null;
}
}
async function loadSubjects() {
loading.value = true;
try {
const res = await getSubjects();
const list: ApiSubjectItem[] = res?.data?.subjects ?? [];
subjects.value = list.map(toSubject);
//
const first = subjects.value.find((s) => s.type === 'model') ?? subjects.value[0];
if (first) onSelect(first);
} catch {
//
} finally {
loading.value = false;
}
}
/** 刷新枚举:保留当前选中(若对象仍存在),否则回退到第一个模型 */
async function handleRefresh() {
const keepKey = currentKey.value;
loading.value = true;
try {
const res = await getSubjects();
const list: ApiSubjectItem[] = res?.data?.subjects ?? [];
subjects.value = list.map(toSubject);
const kept = subjects.value.find((s) => `${s.type}:${s.id}` === keepKey);
if (kept) {
initConfig.value = null;
currentConfigId.value = 0;
lastUpdated.value = '';
await loadDetail(kept);
} else {
const first = subjects.value.find((s) => s.type === 'model') ?? subjects.value[0];
if (first) onSelect(first);
}
} catch {
//
} finally {
loading.value = false;
}
}
async function handleSave() {
const s = current.value;
if (!s) return;
// v-if ref
let result: SaveResult;
if (s.type === 'workflow') result = workflowRef.value!.save();
else if (s.type === 'model') result = modelRef.value!.save();
else result = businessRef.value!.save();
saving.value = true;
try {
await savePricingConfig({
id: currentConfigId.value,
subjectType: s.type,
subjectId: s.id,
rules: result.rules,
minBalance: result.minBalance,
currency: result.currency,
enabled: result.enabled ? 1 : 0,
});
ElMessage.success('计价配置保存成功');
s.hasConfig = true;
// id id
await loadDetail(s);
} catch {
//
} finally {
saving.value = false;
}
}
/** 时间格式化:YYYY-MM-DD HH:mm */
function formatTime(t?: string): string {
if (!t) return '';
const d = new Date(t);
if (Number.isNaN(d.getTime())) return t;
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
onMounted(loadSubjects);
</script>
<style scoped lang="scss">
.system-price-container {
padding: 12px;
}
.price-card {
:deep(.el-card__body) {
padding: 16px 20px;
}
}
/* 顶部操作栏 */
.page-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
.page-toolbar-left {
.page-title {
font-size: 18px;
font-weight: 600;
color: #303133;
}
.page-subtitle {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
}
.page-toolbar-actions {
flex-shrink: 0;
}
}
.price-layout {
display: flex;
gap: 24px;
align-items: stretch;
}
.subject-panel {
width: 320px;
flex-shrink: 0;
border-right: 1px solid #ebeef5;
.subject-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 16px 12px;
.subject-title {
font-size: 14px;
font-weight: 600;
}
.subject-count {
font-size: 12px;
color: #909399;
}
}
}
.editor-panel {
flex: 1;
min-width: 0;
.editor-overview {
padding-bottom: 12px;
border-bottom: 1px solid #ebeef5;
margin-bottom: 16px;
.overview-main {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
.overview-title {
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
.type-tag {
font-size: 11px;
border-radius: 3px;
padding: 1px 6px;
color: #fff;
font-weight: 400;
&.t-reason {
background: #e6a23c;
}
&.t-video {
background: #909399;
}
&.t-audio {
background: #67c23a;
}
&.t-image {
background: #00bcd4;
}
}
}
.overview-meta {
display: flex;
align-items: center;
gap: 10px;
.conf-status {
font-size: 12px;
padding: 1px 8px;
border-radius: 10px;
&.ok {
background: #f0f9eb;
color: #67c23a;
}
&.no {
background: #f4f4f5;
color: #909399;
}
}
.update-time {
font-size: 12px;
color: #c0c4cc;
}
}
}
.overview-sub {
font-size: 12px;
color: #909399;
margin-top: 6px;
}
}
}
.editor-empty {
padding: 60px 0;
text-align: center;
color: #909399;
font-size: 13px;
}
</style>
+366
View File
@@ -0,0 +1,366 @@
/**
*
*
*
* - GET .../subjects src/api/trade/pricing/index.ts
* Subject modelType/modelTypeCode
* - / rules config/get save
* - reason=100 / image=200 / audio=300 / video=600
* - EXAMPLES "示例填充" UX
*/
/* ================= 枚举类型 ================= */
export type SubjectType = 'workflow' | 'model' | 'business';
export type ModelType = 'reason' | 'video' | 'audio' | 'image';
export type Unit =
| 'per_1M'
| 'per_1K'
| 'per_1'
| 'per_second'
| 'per_minute'
| 'per_hour'
| 'per_char';
export type Currency = 'CNY' | 'USD';
/* ================= 后端枚举接口 ================= */
/** 后端 GET .../subjects 返回的计价对象项 */
export interface ApiSubjectItem {
subjectType: SubjectType;
subjectId: string;
name: string;
/** 仅 model:中文类型(推理/视频/音频/图片) */
modelType?: string;
/** 仅 model:模型类型编码(100推理/200图片/300音频/600视频) */
modelTypeCode?: number;
/** 该对象支持的计费模式(workflow/business 由后端 subjects.chargeModes 下发;model 无此字段) */
chargeModes?: string[];
}
/** modelTypeCode → 英文模型类型 */
export const MODEL_TYPE_CODE_TO_EN: Record<number, ModelType> = {
100: 'reason',
200: 'image',
300: 'audio',
600: 'video',
};
/** 后端枚举项 → 页面 Subject */
export function toSubject(item: ApiSubjectItem): Subject {
if (item.subjectType === 'model') {
const modelType = MODEL_TYPE_CODE_TO_EN[item.modelTypeCode ?? -1] ?? 'reason';
return {
type: 'model',
id: item.subjectId,
name: item.name,
modelType,
modelTypeCode: item.modelTypeCode,
typeLabel: item.modelType || modelType,
hasConfig: false,
};
}
return {
type: item.subjectType,
id: item.subjectId,
name: item.name,
modes: item.chargeModes ?? [],
hasConfig: false,
};
}
/* ================= 计价对象枚举(页面态) ================= */
export interface Subject {
type: SubjectType;
/** subjectId */
id: string;
/** 显示名称 */
name: string;
/** 仅 model:模型类型(英文枚举,由 modelTypeCode 映射) */
modelType?: ModelType;
/** 仅 model:模型类型编码 */
modelTypeCode?: number;
/** 仅 model:模型中文类型(推理/视频/音频/图片) */
typeLabel?: string;
/** workflow/business:支持的计费模式(后端 subjects.chargeModes 下发) */
modes?: string[];
/** 是否已配置计价(选中后由 config/get 判断) */
hasConfig: boolean;
}
/* ================= 工作流费率 JSON ================= */
export interface WorkflowTier {
/** 档位最大秒数 */
maxSec: number;
/** 档位价格(元) */
price: number;
}
export interface WorkflowRules {
/** 按条计费 */
per_item?: {
tiers: WorkflowTier[];
/** 超出最大档单价(元/秒) */
overflowUnitPrice: number;
};
/** 按秒计费 */
per_second?: {
unitPrice: number;
};
/** 按 token 计费(价格取自各模型,仅占位空对象) */
per_token?: Record<string, never>;
}
/* ================= 模型费率 JSON ================= */
/** 媒体价格模板行(token 单位):对特定媒体类型设置独立覆盖价(mediaPrices */
export interface MediaPriceRow {
/** 媒体类型:text / audio / video / image */
mediaType: string;
/** 输入单价 */
input: number;
/** 输出单价 */
output: number;
/** 缓存命中输入单价 */
cacheHit: number;
}
/** 编辑器内部规则行态(thinking/outputAudio 用字符串承载"未设置";媒体命中条件已移除,媒体改走价格模板) */
export interface ModelRule {
name: string;
thinking: '' | 'true' | 'false';
outputAudio: '' | 'true' | 'false';
outputResolution: string;
inLenMin?: number;
inLenMax?: number;
/** token 单位:输入单价 */
input: number;
/** token 单位:输出单价 */
output: number;
/** token 单位:缓存命中输入单价 */
cacheHit: number;
/** 非 token 单位:每单位单价 */
unitPrice: number;
/** 媒体价格模板行(token 单位;空数组=无媒体覆盖价) */
mediaPrices: MediaPriceRow[];
}
/** 示例数据源规则(thinking/outputAudio 为布尔,由 loadExample 转换) */
export interface ExampleRule {
name: string;
thinking?: boolean;
outputAudio?: boolean;
outputResolution?: string;
inLenMin?: number;
inLenMax?: number;
input?: number;
output?: number;
cacheHit?: number;
unitPrice?: number;
/** 媒体价格模板示例(可选):媒体类型 → 覆盖价 */
mediaPrices?: Record<string, { input?: number; output?: number; cacheHit?: number }>;
}
/** 构建态:规则命中条件(媒体类型已改为价格模板,不再作为命中条件) */
export interface BuiltModelRuleMatch {
thinking?: boolean;
outputAudio?: boolean;
outputResolution?: string;
inputLengthMin?: number;
inputLengthMax?: number;
}
/** 构建态:单条规则 */
export interface BuiltModelRule {
name: string;
match?: BuiltModelRuleMatch;
price: Record<string, number>;
/** 媒体价格模板(token 单位):媒体类型 → 该媒体独立覆盖价;无覆盖时省略 */
mediaPrices?: Record<string, Record<string, number>>;
}
/** 模型费率 JSON 结构(对齐后端,含内嵌币种) */
export interface ModelRules {
unit: Unit;
tiered: boolean;
rules: BuiltModelRule[];
currency: Currency;
}
/** 模型示例 */
export interface ModelExample {
label: string;
unit: Unit;
tiered: boolean;
currency?: Currency;
minBalance?: number;
rules: ExampleRule[];
}
export type ModelExampleMap = Record<ModelType, ModelExample[]>;
/* ================= 业务模块费率 JSON ================= */
export interface BusinessRules {
period: 'year';
price: number;
}
/* ================= 编辑器初始化配置 ================= */
/** 后端 config/get 解析后的编辑器初始化数据(公共字段 + 已 parse 的费率 JSON */
export interface EditorInitConfig {
/** 配置记录 id(>0 更新,0 新增;雪花 id 超安全整数,用字符串承载避免精度丢失) */
id: string | number;
minBalance: number;
currency: Currency;
enabled: boolean;
/** 已 parse 的费率 JSON(编辑器按 subjectType 断言具体结构) */
rules: WorkflowRules | ModelRules | BusinessRules;
}
/* ================= 常量 ================= */
export const WORKFLOW_DEFAULT_TIERS: WorkflowTier[] = [
{ maxSec: 15, price: 29 },
{ maxSec: 30, price: 49 },
{ maxSec: 60, price: 89 },
];
export const MODEL_TYPE_CODES: Record<ModelType, number> = {
reason: 100,
image: 200,
audio: 300,
video: 600,
};
export const UNIT_OPTIONS: { value: Unit; label: string }[] = [
{ value: 'per_1M', label: '每百万 Token' },
{ value: 'per_1K', label: '每千 Token' },
{ value: 'per_1', label: '每个 / 每张图' },
{ value: 'per_second', label: '每秒' },
{ value: 'per_minute', label: '每分钟' },
{ value: 'per_hour', label: '每小时' },
{ value: 'per_char', label: '每字' },
];
/** 媒体价格模板可选媒体类型(媒体命中条件已移除,媒体改按模板定价) */
export const MEDIA_PRICE_OPTIONS: { value: 'text' | 'audio' | 'video' | 'image'; label: string }[] = [
{ value: 'text', label: '文本' },
{ value: 'audio', label: '音频' },
{ value: 'video', label: '视频' },
{ value: 'image', label: '图片' },
];
/** outputAudio 命中条件(video):给用户展示中文(有声/无声) */
export const OUTPUT_AUDIO_OPTIONS: { value: '' | 'true' | 'false'; label: string }[] = [
{ value: '', label: '不限制' },
{ value: 'true', label: '有声' },
{ value: 'false', label: '无声' },
];
/** thinking 命中条件(reason):给用户展示中文(是/否) */
export const THINKING_OPTIONS: { value: '' | 'true' | 'false'; label: string }[] = [
{ value: '', label: '不限制' },
{ value: 'true', label: '是' },
{ value: 'false', label: '否' },
];
/** 输出分辨率命中条件下拉选项(按模型类型区分:video 横版 / image 方形) */
export const RESOLUTION_OPTIONS: Record<'video' | 'image', { value: string; label: string }[]> = {
video: [
{ value: '', label: '不限制' },
{ value: '480p', label: '480p' },
{ value: '720p', label: '720p' },
{ value: '1080p', label: '1080p' },
{ value: '2k', label: '2k' },
{ value: '4k', label: '4k' },
],
image: [
{ value: '', label: '不限制' },
{ value: '512x512', label: '512x512' },
{ value: '1024x1024', label: '1024x1024' },
{ value: '2048x2048', label: '2048x2048' },
],
};
/** 模型示例(本地填充 UX,逐字对应原型定价配置) */
export const EXAMPLES: ModelExampleMap = {
video: [
{
label: '视频·按秒(分辨率×有声/无声)',
unit: 'per_second',
tiered: false,
rules: [
{ name: '无声-720p', outputAudio: false, outputResolution: '720p', unitPrice: 0.8 },
{ name: '有声-1080p', outputAudio: true, outputResolution: '1080p', unitPrice: 1.5 },
],
},
{
label: '视频·按token(百万)',
unit: 'per_1M',
tiered: false,
rules: [
{ name: '无声-1080p', outputAudio: false, outputResolution: '1080p', input: 0.6, output: 3.6, cacheHit: 0.12 },
{ name: '有声-1080p', outputAudio: true, outputResolution: '1080p', input: 9, output: 27, cacheHit: 1.8 },
],
},
],
reason: [
{
label: '推理·阶梯(思考×输入长度档×媒体模板)',
unit: 'per_1M',
tiered: true,
rules: [
{ name: '思考-输入长度[0,32k]', thinking: true, inLenMax: 32000, input: 0.6, output: 3.6, cacheHit: 0.12 },
{
name: '思考-输入长度(32k,128k]',
thinking: true,
inLenMin: 32001,
inLenMax: 128000,
input: 0.9,
output: 5.4,
cacheHit: 0.18,
mediaPrices: { audio: { input: 9, output: 3.6, cacheHit: 1.8 } },
},
{ name: '非思考-统一价', thinking: false, input: 0.3, output: 1.2, cacheHit: 0.06 },
],
},
],
audio: [
{
label: '音频·按字数',
unit: 'per_char',
tiered: false,
rules: [{ name: '语音-统一价(万字符5元)', unitPrice: 0.0005 }],
},
{
label: '音频·按分钟',
unit: 'per_minute',
tiered: false,
rules: [{ name: '语音-统一价', unitPrice: 2 }],
},
{
label: '音频·按token(百万)',
unit: 'per_1M',
tiered: false,
rules: [{ name: '统一价', input: 500, output: 500, cacheHit: 0 }],
},
],
image: [
{
label: '图片·按张数×分辨率',
unit: 'per_1',
tiered: false,
rules: [
{ name: '512x512', outputResolution: '512x512', unitPrice: 0.2 },
{ name: '1024x1024', outputResolution: '1024x1024', unitPrice: 0.5 },
],
},
],
};
@@ -0,0 +1,111 @@
<template>
<div class="system-recharge-user-container">
<el-dialog title="用户充值" v-model="isShowDialog" width="520px">
<el-form ref="formRef" :model="ruleForm" :rules="rules" size="default" label-width="110px">
<el-form-item label="账户名称" prop="userName">
<el-input v-model="ruleForm.userName" disabled placeholder="请输入账户名称"></el-input>
</el-form-item>
<el-form-item label="充值金额" prop="amount">
<el-input v-model="ruleForm.amount" placeholder="请输入充值金额(单位:元)" clearable></el-input>
</el-form-item>
<el-form-item label="外部订单号" prop="orderNo">
<el-input v-model="ruleForm.orderNo" placeholder="请输入外部订单号(选填)" clearable></el-input>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="ruleForm.description" type="textarea" placeholder="请输入描述(选填)" maxlength="150"></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="onCancel" size="default"> </el-button>
<el-button type="primary" @click="onSubmit" size="default"> </el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
import { reactive, toRefs, defineComponent, ref, unref } from 'vue';
import { ElMessage } from 'element-plus';
import { recharge } from '/@/api/system/user';
export default defineComponent({
name: 'systemRechargeUser',
setup() {
const formRef = ref<HTMLElement | null>(null);
const state = reactive({
isShowDialog: false,
ruleForm: {
userId: 0,
userName: '',
amount: '',
orderNo: '',
description: '',
},
//
rules: {
amount: [
{ required: true, message: '充值金额不能为空', trigger: 'blur' },
{
validator: (_rule: any, value: any, callback: any) => {
if (!/^\d+(\.\d{1,2})?$/.test(value) || Number(value) <= 0) {
callback(new Error('请输入大于 0 的金额(单位:元,最多两位小数)'));
} else {
callback();
}
},
trigger: 'blur',
},
],
},
});
//
const openDialog = (row: any) => {
state.ruleForm = {
userId: row.id,
userName: row.userName,
amount: '',
orderNo: '',
description: '',
};
state.isShowDialog = true;
};
//
const closeDialog = () => {
state.isShowDialog = false;
};
//
const onCancel = () => {
closeDialog();
};
//
const onSubmit = () => {
const formWrap = unref(formRef) as any;
if (!formWrap) return;
formWrap.validate((valid: boolean) => {
if (valid) {
recharge({
userId: state.ruleForm.userId,
userName: state.ruleForm.userName,
amount: Number(state.ruleForm.amount),
orderNo: state.ruleForm.orderNo,
description: state.ruleForm.description,
}).then(() => {
ElMessage.success('充值成功');
closeDialog();
});
}
});
};
return {
openDialog,
closeDialog,
onCancel,
onSubmit,
formRef,
...toRefs(state),
};
},
});
</script>
@@ -0,0 +1,68 @@
<template>
<div class="system-wallet-log-container">
<el-dialog title="钱包流水信息" v-model="isShowDialog" width="1000px">
<el-table :data="logs" style="width: 100%" :max-height="420">
<el-table-column prop="orderNo" label="业务单据号" show-overflow-tooltip></el-table-column>
<el-table-column prop="transactionNo" label="账务流水号" show-overflow-tooltip></el-table-column>
<el-table-column prop="type" label="类型" width="80" align="center">
<template #default="scope">
<el-tag :type="scope.row.type === 'income' ? 'success' : 'danger'">{{ scope.row.type === 'income' ? '充值' : '扣费' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="amount" label="变动金额(元)" width="120" align="right"></el-table-column>
<el-table-column prop="balanceBefore" label="变动前余额(元)" width="130" align="right"></el-table-column>
<el-table-column prop="balanceAfter" label="变动后余额(元)" width="130" align="right"></el-table-column>
<el-table-column prop="currency" label="货币" width="80" align="center"></el-table-column>
<el-table-column prop="description" label="描述" show-overflow-tooltip></el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="170"></el-table-column>
</el-table>
<div class="system-wallet-log-pagination">
<pagination :total="total" v-model:page="pageNum" v-model:limit="pageSize" @pagination="handlePagination" />
</div>
</el-dialog>
</div>
</template>
<script lang="ts">
import { reactive, toRefs, defineComponent } from 'vue';
import { getWalletLogs } from '/@/api/system/user';
export default defineComponent({
name: 'systemWalletLog',
setup() {
const state = reactive({
isShowDialog: false,
userId: 0,
logs: [] as any[],
total: 0,
pageNum: 1,
pageSize: 10,
});
//
const getLogs = () => {
getWalletLogs(state.userId, state.pageNum, state.pageSize).then((res: any) => {
state.logs = res.data.logs ?? [];
state.total = res.data.total ?? 0;
});
};
//
const openDialog = (userId: number) => {
state.userId = userId;
state.logs = [];
state.total = 0;
state.pageNum = 1;
state.isShowDialog = true;
getLogs();
};
// pageNum/pageSize v-model
const handlePagination = () => {
getLogs();
};
return {
openDialog,
handlePagination,
...toRefs(state),
};
},
});
</script>
+39 -2
View File
@@ -117,7 +117,7 @@
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" show-overflow-tooltip></el-table-column>
<el-table-column label="操作" width="200">
<el-table-column label="操作" width="320">
<template #default="scope">
<el-button
size="small"
@@ -140,6 +140,25 @@
>删除</el-button
>
<el-button size="small" text type="primary" v-debounce @click="handleResetPwd(scope.row)">重置</el-button>
<el-button
size="small"
text
type="primary"
class="op-btn-recharge"
:disabled="!scope.row.isOperation"
v-debounce
@click="onOpenRecharge(scope.row)"
>充值</el-button
>
<el-button
size="small"
text
type="primary"
:disabled="!scope.row.isOperation"
v-debounce
@click="onOpenWalletLog(scope.row)"
>流水信息</el-button
>
</template>
</el-table-column>
</el-table>
@@ -154,6 +173,8 @@
</el-col>
</el-row>
<EditUser ref="editUserRef" :dept-data="deptData" :gender-data="sys_user_sex" @getUserList="userList" />
<RechargeUser ref="rechargeUserRef" />
<WalletLog ref="walletLogRef" />
</div>
</template>
@@ -162,6 +183,8 @@ import { toRefs, reactive, onMounted, ref, defineComponent, watch, getCurrentIns
import { ElMessageBox, ElMessage, ElTree, FormInstance } from 'element-plus';
import { Search } from '@element-plus/icons-vue';
import EditUser from '/@/views/system/user/component/editUser.vue';
import RechargeUser from '/@/views/system/user/component/rechargeUser.vue';
import WalletLog from '/@/views/system/user/component/walletLog.vue';
import { getUserList, getDeptTree, resetUserPwd, changeUserStatus, deleteUser } from '/@/api/system/user/index';
interface TableDataState {
@@ -186,11 +209,13 @@ interface TableDataState {
export default defineComponent({
name: 'systemUser',
components: { EditUser },
components: { EditUser, RechargeUser, WalletLog },
setup() {
const { proxy } = <any>getCurrentInstance();
const { sys_user_sex } = proxy.useDict('sys_user_sex');
const editUserRef = ref();
const rechargeUserRef = ref();
const walletLogRef = ref();
const queryRef = ref();
const filterText = ref('');
const treeRef = ref<InstanceType<typeof ElTree>>();
@@ -259,6 +284,14 @@ export default defineComponent({
const onOpenEditUser = (row: any) => {
editUserRef.value.openDialog(row);
};
//
const onOpenRecharge = (row: any) => {
rechargeUserRef.value.openDialog(row);
};
//
const onOpenWalletLog = (row: any) => {
walletLogRef.value.openDialog(row.id);
};
//
const onRowDel = (row: any) => {
let msg = '你确定要删除所选用户?';
@@ -358,8 +391,12 @@ export default defineComponent({
return {
queryRef,
editUserRef,
rechargeUserRef,
walletLogRef,
onOpenAddUser,
onOpenEditUser,
onOpenRecharge,
onOpenWalletLog,
onRowDel,
onHandleSizeChange,
onHandleCurrentChange,
+25 -25
View File
@@ -1,27 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAqlbH8ExzyW3D87glDgjSz6IoKWmV1iZh33DIYmDFzbaGqdBZ
gWyW4TWbBn9huVDrglShjeIEjAdfK3BcHKv4/xh+XjGjzp/GyvKkggLGB2Itc3Wq
wxlyAhlm7tgU0bmQGPu/+CnIvmss7lKWx9b5L9eYU+ToU3r/GOP4juGqcUYHLyhW
ZDjy2fjO5Ju4/W0U1OcaLho1Sapn9EFXFIjJ5RbiiOF8QVo8Ez3EbV/EsQowaIvp
OoqLT1GGBRd6sz/L1TMliw6UPcGd+TTaADTh4PxSklYsXqro0vstft0kiuAElah3
Hw3XZMNiRfAqJVvhc8/pXdA6kH4FuZKfxAF8CwIDAQABAoIBAEwB8DKKMsppZ9wr
SaGTD1pmHayo+Bq4Qsj83vWDABCUh09j8GR/i23PyS6jjXwvHv+nevVAKs4/Bdjw
yLFkjXVqvQyT8ueixQZPMlfvG+U0ilpwYXOwOX61Tgh6tAOHXUTApT77ih9p3IE1
qiFfDA8skUSyKncBta2qIAHFsZPcNGB+44hv5AT1aaXtZbuFdwAoptIpNDaWVmbo
cjm6AEHOoiTfXx437ORAx+F3e2umzBVkLQZNLPlYsz0pFN8elZAde1e13kAPUaHa
q5g49Yad4UA3iIO6xo3kZz90BGMlcbe0ckY8wOg9biF4zTxVg0unbvk4DZve8iTZ
hDrrAF0CgYEA3oakLouCgo/JWERfXVTYVyTvuh8wkYmsndV4s7hENIjhEiKrgfW1
4RfnVNEjgEbsukgyMj8GpeXp9d8mC5Jz9uhKbbpCbLIuTdZpeNvob4RA1a+rgP6L
Fan3W9yrMqmrz7gLW2PsPmU3vnn8SI/8QlgwmNLutbMPncWJzNukMM0CgYEAw/Zy
dqFjNIgHemnYpK1R0xcLVs6MTOOwqEv0xUor1fZujLittRbb4JAqDlUwQNU6Zn6K
xxuSJ3TrRHqpjLcT812yPeMXd1f7jzCnZXDkWYoUT/ILjIB9cEJuycTwDwMJGkPd
RGV+gyBPHYBM6+hhxh1YSYm0CebDPMRMC7anADcCgYBg/KYKc1vKtAi5o/M8poI0
WTDzGiz9AdhuYmBdVG7FxvLyCJJf7kv7c4OsAAtNGZyMBHN8WuP24qJ6O2xFlutc
LhpigM/6uB5eZplJx+8iy63GdO53JnpQUugw7ZjgV/s+aaTpjD+nYk5NgrVmmvsD
n5Rux2CraQPha/uHgiOp8QKBgQCwqh6ZOnfgZP4w6vmnxKUu4+iSEtXDf8XEg+2g
a3THiUoyzU+1IW2Krw1rLHu7B0CGJHDifcWukqaDbv4p4Oc4kPDfADsomYYtmhRf
zH3hBy22wSSOP0xXB10vw78UGY18Q1BYmBONDXSlyQQP7ckvvUbZzS4lewfqYaGZ
Mcqu0wKBgDh2tcdxuHUizlRAPwfoZ3JhbITJqGytkpZT96WpQ0fpzcSovNJf0shC
5gXCzWWRKQPxV7cUXmB6hBm1Q6WoZ/VKN36+R+7L8Pt7yTmZhBxQKVjVn3k849JC
tcWDXiZd5qINC7dsH/vjLahnr76cFtYfNM5stomJkKNRjC7hZfnw
MIIEowIBAAKCAQEAlL5frs+rVlkwHmvSGJxU9+byc23zxdsLrcFQS5eEFUzQVDyu
z7aZqb3NQMyTRoGcLsUYRHyQWZMdsezu1xEumvNL9Ex1Bqh3/UlKDEBVF/zyVrWh
hfUUL5edxliWNrwwmHsX1FGy+MaVVyB8zV7156yGcvCq7QfANNT6qtltiogqO6rM
KWgT/9hdAZ+FPQaJ2hFHsgz20TWgKkS2VkZ1tmrAxwII6aFBLyqkkmOnDFsIwrW8
zkGinLVpqvI6oyHHgO9VKg+TMYLVsspKwmoHuSECRQVb20b1jpPifssmxoPYxkY8
9aG44VjGDuWDU11tbU73PaEnZbRZ0+cwSQCXFQIDAQABAoIBAAr4zQGN14dnzk0c
KnP+yiS8MCu0E1yHpyGixhvBWjChCSgBjuqK/2Q+OC78uCtmAaGQj9TzdElrOPBI
OCj8nzm2Uya64hIC6NDx8EidkTlTAwRC4x6IwLuu/SglnjT85j/FKrGScjd4HIl/
/7y9cbmsaAF//7e6z+KFyFTEc5hZjKwc245OTcOFcGsP+S2IZkkU3Kc0Wibxv98q
B6IXR0ESWQZc+1/Mu1gIBBlYRbzwVhgXXSvEJ8ZeD7b5QKiWRFXxAoeuCprqH3RG
GxqjFWT64xpiMGEW0mqZ2JI/R8we2NO2e/YFMhlUq0W8dSSB7yAxr4LUTEZ9hgos
hAwLgbkCgYEAw4Ml0p6ImA8dDO3IQe+tg7V48Fexmx4rGXVnOz3/0mPzKQ6HN4j1
OqGVx7W5kQ9JCVSjGDp+DdgYcqg7ETtKY4RbFskp4JRQqBc39nHTIGQfa7z8LRYJ
3Dz829OK0fm714heHU9FVYB+x62Gglrgvw/GmU0Vyrv/1W4j4KX7w6kCgYEAwsMV
HFNX5rU2VOIU6rUR96W7gSUBArEnNhl/v0TDYf8LLKjzQKYNIAYg+dzKVNXkfnlX
YOFGBYJO7j6fgpdVSHcZiLsuvBUJEK+tomXk5GPArGahyVUv/fwKKyngLMwclzwr
UJ7ER9BIOm/XncYOXihSikM3zlEKtr9p4lH7G40CgYB8SmB+H/iYKmt+b5+ygysc
ov5KVIV9JlWh7qf82nJKju3jeO/vFytG5bhxaI6asEvvGZZBbdX6+Ri5s0cxsi6d
S2QY/TOZKNCJu5EBI+VjHyN2e4MZKnyEPWVGXppO2GNZJsEmAuIB9Bkk2aS8NAfS
jrXjeUTUSGXgig9W3k5KWQKBgQCcVjpY7XHO6MF/K6e8q7KLhwbXaNvBzHB8VcWc
xFbOEAtpQwlYaLJ6dVD9IMVtifCLsV15j/tEKtCvH6etDUtRCwF9JzwhkAUh1tNb
W817o7Mi8JOH6JDIoQuMkGW5r/J3V74xTfCi8Mc52VNL+5LG9DklcUWXoKgn/8i/
+izGdQKBgD0tmo612qUBOUVSz0PRXapz26H0CUEFKx6BT8986Dq7q5XElzn/wF6D
GD701Rh0GhdvxUvbp2ie4VdItFtN9ecwcr+9xbq3nIIbXRiDJcJM5zEQ1LUdMCte
UzicKNGtv2i+SuNwWrfAZhDE0Djg/6jXZeJTE/e+hgAgRYOs4AeS
-----END RSA PRIVATE KEY-----
+23 -23
View File
@@ -1,17 +1,17 @@
-----BEGIN CERTIFICATE-----
MIIGJTCCBQ2gAwIBAgIQCXpUlFT9Cue3+TsPpxvnWjANBgkqhkiG9w0BAQsFADBu
MIIGJTCCBQ2gAwIBAgIQA0m9UBf/1I217Y9jcPp0vTANBgkqhkiG9w0BAQsFADBu
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
d3cuZGlnaWNlcnQuY29tMS0wKwYDVQQDEyRFbmNyeXB0aW9uIEV2ZXJ5d2hlcmUg
RFYgVExTIENBIC0gRzIwHhcNMjYwNjEwMDAwMDAwWhcNMjYwOTA3MjM1OTU5WjAj
RFYgVExTIENBIC0gRzIwHhcNMjYwOTA3MDAwMDAwWhcNMjYxMjA1MjM1OTU5WjAj
MSEwHwYDVQQDExhnaXRlYS5yZWRwb3dlcmZ1dHVyZS5jb20wggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQCqVsfwTHPJbcPzuCUOCNLPoigpaZXWJmHfcMhi
YMXNtoap0FmBbJbhNZsGf2G5UOuCVKGN4gSMB18rcFwcq/j/GH5eMaPOn8bK8qSC
AsYHYi1zdarDGXICGWbu2BTRuZAY+7/4Kci+ayzuUpbH1vkv15hT5OhTev8Y4/iO
4apxRgcvKFZkOPLZ+M7km7j9bRTU5xouGjVJqmf0QVcUiMnlFuKI4XxBWjwTPcRt
X8SxCjBoi+k6iotPUYYFF3qzP8vVMyWLDpQ9wZ35NNoANOHg/FKSVixequjS+y1+
3SSK4ASVqHcfDddkw2JF8ColW+Fzz+ld0DqQfgW5kp/EAXwLAgMBAAGjggMIMIID
BDAfBgNVHSMEGDAWgBR435GQX+7erPbFdevVTFVT7yRKtjAdBgNVHQ4EFgQUOiq0
OCRofHA6BrxoM9do+AWnNgowQQYDVR0RBDowOIIYZ2l0ZWEucmVkcG93ZXJmdXR1
DQEBAQUAA4IBDwAwggEKAoIBAQCUvl+uz6tWWTAea9IYnFT35vJzbfPF2wutwVBL
l4QVTNBUPK7Ptpmpvc1AzJNGgZwuxRhEfJBZkx2x7O7XES6a80v0THUGqHf9SUoM
QFUX/PJWtaGF9RQvl53GWJY2vDCYexfUUbL4xpVXIHzNXvXnrIZy8KrtB8A01Pqq
2W2KiCo7qswpaBP/2F0Bn4U9BonaEUeyDPbRNaAqRLZWRnW2asDHAgjpoUEvKqSS
Y6cMWwjCtbzOQaKctWmq8jqjIceA71UqD5MxgtWyykrCage5IQJFBVvbRvWOk+J+
yybGg9jGRjz1objhWMYO5YNTXW1tTvc9oSdltFnT5zBJAJcVAgMBAAGjggMIMIID
BDAfBgNVHSMEGDAWgBR435GQX+7erPbFdevVTFVT7yRKtjAdBgNVHQ4EFgQUYPN9
3He9hDMC/uFm78xCWWEF4/owQQYDVR0RBDowOIIYZ2l0ZWEucmVkcG93ZXJmdXR1
cmUuY29tghx3d3cuZ2l0ZWEucmVkcG93ZXJmdXR1cmUuY29tMD4GA1UdIAQ3MDUw
MwYGZ4EMAQIBMCkwJwYIKwYBBQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQuY29t
L0NQUzAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUF
@@ -19,19 +19,19 @@ BwMCMIGABggrBgEFBQcBAQR0MHIwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRp
Z2ljZXJ0LmNvbTBKBggrBgEFBQcwAoY+aHR0cDovL2NhY2VydHMuZGlnaWNlcnQu
Y29tL0VuY3J5cHRpb25FdmVyeXdoZXJlRFZUTFNDQS1HMi5jcnQwDAYDVR0TAQH/
BAIwADCCAX0GCisGAQQB1nkCBAIEggFtBIIBaQFnAHUAwjF+V0UZo0XufzjespBB
68fCIVoiv3/Vta12mtkOUs0AAAGesEYy8QAABAMARjBEAiBD7255l5PRI6mZXWxU
iC7txvWxkLlBgVFzAkRrMW7RXQIgZyupslAslfGDqDMk9H948AFZGe0JwB6YiuXK
lx1sox0AdgDYCVU7lE96/8gWGW+UT4WrsPj8XodVJg8V0S5yu0VLFAAAAZ6wRjNI
AAAEAwBHMEUCIBqfIfl4sL0ME60NI0KG6JIHImcJjR5qkLGDjCp3sEhrAiEA4cvy
yFaz3BSjjQa+UspsqgfKxeTjCLJq5pLbDcgBgGQAdgCUTkOH+uzB74HzGSQmqBhl
AcfTXzgCAT9yZ31VNy4Z2AAAAZ6wRjL3AAAEAwBHMEUCIQDIIa8mLpO6BmhgCecw
pI+QNznn4lhIxmtJQyHmZo7qRAIgFPq9xlLLqqe5/+WgcNwoawggpi8skhg8t/yT
JJ5TmV0wDQYJKoZIhvcNAQELBQADggEBAOeiZrRwpNh/bNIuKe+1vWBrLHu2Zj70
cKAVUhT08ZAl8scEYusbuqtSAq+oz9sm595YyPgmrB0wSZEMTd/+pUy5GrrJAXal
kOx1a3TBxCvpK9F4T7KmBaBWA5HuljHfw9833jXi7v9jLYShDjyNRzSWeKl41kdt
Hq4CbDLXp9XVzVPBlOongGZV/6cDrvy4hoJcBq8UANU/QWw0PaJQWxPwHcULekBw
AefTX7JwKbHcZ3gGV4EzJU/eoycBOu9VsYgJu9U+p1nNjYHwS8RQjij+cgs8r5Wx
r4+wYkdN2767iK2+NRRdQD/ZqBMTgSZ6AXCM7gS8oP/ylLwnzyq7ikc=
68fCIVoiv3/Vta12mtkOUs0AAAGgebrk6AAABAMARjBEAiAgFMcnwhg3c0h/LxiP
nZ+YWMLYgXC9ePXRZ+F0CFotEAIgR5H6BlW0mUMwOr5n82ukGvdlme/gkKINeQA2
r33cbtQAdgDYCVU7lE96/8gWGW+UT4WrsPj8XodVJg8V0S5yu0VLFAAAAaB5uuUP
AAAEAwBHMEUCIHMpgTZWPL/owVG92G0tYuZEiCPkElXskLLFygZOqbKkAiEA7RGC
gJ+Gd+XWH5n1kvVyofTnRccp9PVwc7gYUzfNQU0AdgCUTkOH+uzB74HzGSQmqBhl
AcfTXzgCAT9yZ31VNy4Z2AAAAaB5uuT1AAAEAwBHMEUCIAzth4PgMSzYLoUFxNAu
ErCqHKZNRACffuKdr1sKodLJAiEAh67qPx8Pfr87kSR2bnMYnpBcXjJ38S4cz2RL
v6dFDaowDQYJKoZIhvcNAQELBQADggEBABPRvwySa7ou11Mlf5265pizxyR6qhG8
ywlCy0IImyFBZ/mu4YzXrLbBVB9c/KNGiLxCAUBg+rEZsbVmPW//cfo31uUtzenb
DhDOXqVlULG1nwDkhcCw90jMGUOl5X6zKZ2KSXd3Uq++EVTjuqXC3fbheocXLNZr
22DWhQ7wfFvogw6bKCkS15y4WGjWa2ut4Avso9pzCA+HDzJCTMLgJStOZNArRkZQ
u3OhuLjnbG5kWQiwVXU/ixxOaL4JCwZWKueMhumux20Ww6bvdj+wx+l4M5Dg+qXp
D3bLSWOxFnv2SqxFf1AA/7hf5rqomO8XhsFGSBV/ESGoKvAQyo5gnRw=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEqjCCA5KgAwIBAgIQDeD/te5iy2EQn2CMnO1e0zANBgkqhkiG9w0BAQsFADBh
+25 -25
View File
@@ -1,27 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAvoMUrxhR2vYC/tyGZlplkANptmFfqcglnd+ETk1ppE/tMyrI
PxBLvjN1BIcbBFkHthuv/Ut/dSgtS0hbI52Z1bMACbJ5SdPLTBZzNpXgHWXUHf/S
OsQB8s7gsCekcVly3Tf1emsXxcyeXQ7u8/ZrNEAKGZwT3xkAvtte01NqJeaIMddY
1o/nrlVhgV6CvDtCAFLRVvw2p5zc/Vejc/MLEXV4l9g3VIODfcIbLsczUmsT98zO
LOwwgVssCQcdnA12T1X3U4sX4U0Pin0MexT5L8/Kx3pHbajpQK+ID0TDpmP4QKf5
QkAHJxM7py4u1EHNBo3nkrxWNPfZTTnhbvowjwIDAQABAoIBAAcLQ14FU615ZXtK
kxZAvGWamAjcHn82vRv9EjVvoPrS0Q3lxI6ptGs9bX5eh84ILq6kj8QZS7FSFs6k
R75lit+/D77MCwutmY7gJkq74ulOBQ2bkl3a4R7lraLFwcubx5FMtYuyXv17cdWL
V6Qh6y0nkFgJuyWyFSLOwKRRVBOU8KTldFLR+iF7oULALpUZeci9RF+EEGKarFmp
07OjPbLjdBl+FT05c2KRy4lVl+BRQB6YfU+Al84VQPdoE8byE3mrzomo6CiMTCQ9
aNN2QfpVZ6x16gOEcgpJZHYvD+gC5H7q//TWBJ/jdR6ToKYcRK7HOJQjZgUFLSUs
wCf1ybkCgYEA7V7XkB10SdfT2axO2jdsRM3kW1U86k//dF26G4OKlRmWpibcJXnk
i0M3be0b1VrqefYEWot3MVhJheQCCa+tNR4hSovN7FqCNVUzXZ8YWrTaycXi46po
urfRLUeupTXJinBfmIPCdbqr0bPOJ0q5NfpG+LeG0LlsAfxjALgjGrcCgYEAzXbG
0TLaDNwB3aXTUPpsL7DrrQTaVi9ScH5sa2IJjes2Pv/MyTB05JyHG9rqAtIShjJi
/N3qMnN0q40GSnzPj99+Sep3p+41LvxfvRma9HMZRnczyPZIT31TMQF++QP3d8gz
giDVNND0GXY11bwcqIuS+ouNwU4P6tvLKznQIOkCgYEAxBZ0JuZeGV5E8O2p2hSs
yQ35FgYNI1dgpUWEJ5R71/3ieHFjrUXLqcumL5YPRyoqxwOXxyCtH0NawVOA53WL
tXSldcqWGykNpXczzqRN3yjGEKb7bq1ohM6y6x/rQylyy31XS0uVSeIibEKIC+dr
pw6QsIgTw7tZYS6YrpBu13MCgYBb5t7zP+2shtQG0l98/yZZBqfEEkGe/ze+va29
MnLXmff/oed1rkj64NDGMtstO82xXORN+u0AeAgdm8zOkJk+31bbtRakdLYxOA2S
xds7sCgEDtmI8DBT7djCOMsUkyOj3la7w/fZ0gT9RpS575RaB2RM0RMs/b+862cr
BIcF0QKBgQCrO6f1zj9vr4h3nWohSjgpAkZHpeUxDf6s39U960JU5Dvz/bhCHr51
uuVO183cFmx9W8IW6BI4dwEKk5MrXD2hdM8uGAIi+ip/SwBqej072HuY1xD2Elp9
mWXLESLAedhWvp9QhNQbHib1RzaPV8TUgCDN/QLGQE+jdVqfk/Smfw==
MIIEowIBAAKCAQEApJoLQEPymb/MK72DooofMaMxgmqE1olucnvx37Z19O0sbdSG
gHxVDB7WqBM7FQS8LkwC2CDVrAk66Jb64Muov1r6bExm2Z+5pC45ik/HW9U6Abnl
v3nj3R14NkNT4Ivwsuf3MJKdvVl78UbG4iDT+I6rJmvuL7qj7l1elqOh31y98XXT
5f2qdEnIwnvmmSgf4VsEdzuEDUpkBS2Oo2+7YB94asESc4zp6pEBwB9k3Hcf4FGG
FBKz6ssqo4RSVubg74dR2uY3G1Nl6YTogYCN8BJQpJnwKreFmRlNM5CJbmJMGQ8X
t5ze4kmZfUm5oME5pTtYNOKwt0+VkUJZNPHVXQIDAQABAoIBAB1ZYDcxxmCWu8ty
y7VgJma93/aOkqgEMj7UjdCjYo3s+jaNJfxj7vAt0X1/jFHXmcwxfnJWDHNT4/6T
6SDK/4g7KxrDVrVoDvxQx667QeNEdkbXhDkrfS8wcX5Pa/XGTgFAbilpgxm/imN/
iLfKPbF8ktTTetFk2JUIbfSzMYo3X9wTVQp6WOlEnIKaZSNq/qgbzhFeOEj+Jmv5
89ee9ijdmyqKrpu41Q6VI4rlqzV8k78/76fgq9uM8lW5/wx9kJ3cpHdqLNYxdsEq
ZLyWwB3w6hBIBZ0CKnVPBU+JrwOHMXFHZKPE8/ukIJDBLQw/5jgwE+VGrroLLbe2
SnwHUJsCgYEA6GgkBKfx6TH/qlQj15YzdKL5vZJ9CzfUqcOueHqEWtG4JZ9ZuTx+
ma5kaRmqMtXei+vbi/0aSQGg3O8IPNIeNWvJem77IqHYb7LCJNeE9HFEygCu+JRD
jzAYersYSZBNaJOQt6O0awdv+SVFKm5CJjR95YMjuJtUepqdy3Q5ZXcCgYEAtU/D
rqBTS9fcyI9Wt4zLdZQ5r6xb2iGcmrIBTwTlmTDf7GgZsbDJRA5tVIcw8LdUFDzw
EcabRdPXtC85ofItKxKVbQma9XIJQIhA4H6gz3ifzg3v8URIZnTjc9nQ1522fXGx
YLLVeaLq+PkhlIW8LTZwDAn81fvBzPE7QEUcoMsCgYEAkD6yvEEQoE/sAueeJ1yO
K0JMByE4gxHv6D4Pt68zHrsztAYnRhUJ7pCSubi4Lj0Xl3iYF1bMTau5D+c8EaPI
JBf2QQtIy8Hv98PXQ86QMrdqnoW2E4rSM7kCOIqPFHl/0fxkwdXVUOiY0uYS+7bb
T6Mc0KqILTgbQs1cSr6kMRkCgYA5hmImcHWdFZLWCyxPNhuDZZDb3MqTraBbMGjb
4kjaTrhs8CZeyyFAN3eWnwH3DaNefGsKqMGw6OKCJ42YhRaxmMbR/n8sqo6m69Px
phttV+8T2DYtjSWPZDyaEByDnGU+7aiCbpruabM1ZQ2zaq7mhWQNXcnbNnIpjMvo
u0S2+wKBgCQT8ImNSoiq4DnA3Dx4ZiTZlOW+hW+nGA74CWnwcmTNZMsX/b6U+brh
HU+6iALAj98YK9DROoBgepUFdI2fv50hOHHY8omEfgXGRhMPgG6tgB8XtqIaLOd8
6SLU7JrqidLHA9YVa5MYZwekgkvj89plvWCJsgwXZbbxD+6JbYP6
-----END RSA PRIVATE KEY-----
+24 -24
View File
@@ -1,37 +1,37 @@
-----BEGIN CERTIFICATE-----
MIIGJTCCBQ2gAwIBAgIQAWng8l3t5rBYcCmLpCDdJTANBgkqhkiG9w0BAQsFADBu
MIIGJDCCBQygAwIBAgIQCG68erA4eBvEiFO3I/+fkDANBgkqhkiG9w0BAQsFADBu
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
d3cuZGlnaWNlcnQuY29tMS0wKwYDVQQDEyRFbmNyeXB0aW9uIEV2ZXJ5d2hlcmUg
RFYgVExTIENBIC0gRzIwHhcNMjYwNjEyMDAwMDAwWhcNMjYwOTA5MjM1OTU5WjAj
RFYgVExTIENBIC0gRzIwHhcNMjYwOTA3MDAwMDAwWhcNMjYxMjA1MjM1OTU5WjAj
MSEwHwYDVQQDExhtaW5pby5yZWRwb3dlcmZ1dHVyZS5jb20wggEiMA0GCSqGSIb3
DQEBAQUAA4IBDwAwggEKAoIBAQC+gxSvGFHa9gL+3IZmWmWQA2m2YV+pyCWd34RO
TWmkT+0zKsg/EEu+M3UEhxsEWQe2G6/9S391KC1LSFsjnZnVswAJsnlJ08tMFnM2
leAdZdQd/9I6xAHyzuCwJ6RxWXLdN/V6axfFzJ5dDu7z9ms0QAoZnBPfGQC+217T
U2ol5ogx11jWj+euVWGBXoK8O0IAUtFW/DannNz9V6Nz8wsRdXiX2DdUg4N9whsu
xzNSaxP3zM4s7DCBWywJBx2cDXZPVfdTixfhTQ+KfQx7FPkvz8rHekdtqOlAr4gP
RMOmY/hAp/lCQAcnEzunLi7UQc0GjeeSvFY099lNOeFu+jCPAgMBAAGjggMIMIID
BDAfBgNVHSMEGDAWgBR435GQX+7erPbFdevVTFVT7yRKtjAdBgNVHQ4EFgQUm+W1
VEi2C0CdxtuncyBFtX9cS5UwQQYDVR0RBDowOIIYbWluaW8ucmVkcG93ZXJmdXR1
DQEBAQUAA4IBDwAwggEKAoIBAQCkmgtAQ/KZv8wrvYOiih8xozGCaoTWiW5ye/Hf
tnX07Sxt1IaAfFUMHtaoEzsVBLwuTALYINWsCTrolvrgy6i/WvpsTGbZn7mkLjmK
T8db1ToBueW/eePdHXg2Q1Pgi/Cy5/cwkp29WXvxRsbiINP4jqsma+4vuqPuXV6W
o6HfXL3xddPl/ap0ScjCe+aZKB/hWwR3O4QNSmQFLY6jb7tgH3hqwRJzjOnqkQHA
H2Tcdx/gUYYUErPqyyqjhFJW5uDvh1Ha5jcbU2XphOiBgI3wElCkmfAqt4WZGU0z
kIluYkwZDxe3nN7iSZl9SbmgwTmlO1g04rC3T5WRQlk08dVdAgMBAAGjggMHMIID
AzAfBgNVHSMEGDAWgBR435GQX+7erPbFdevVTFVT7yRKtjAdBgNVHQ4EFgQUbMUu
74Ronh+u0yO+sQpoSQ4TbFMwQQYDVR0RBDowOIIYbWluaW8ucmVkcG93ZXJmdXR1
cmUuY29tghx3d3cubWluaW8ucmVkcG93ZXJmdXR1cmUuY29tMD4GA1UdIAQ3MDUw
MwYGZ4EMAQIBMCkwJwYIKwYBBQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQuY29t
L0NQUzAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUF
BwMCMIGABggrBgEFBQcBAQR0MHIwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRp
Z2ljZXJ0LmNvbTBKBggrBgEFBQcwAoY+aHR0cDovL2NhY2VydHMuZGlnaWNlcnQu
Y29tL0VuY3J5cHRpb25FdmVyeXdoZXJlRFZUTFNDQS1HMi5jcnQwDAYDVR0TAQH/
BAIwADCCAX0GCisGAQQB1nkCBAIEggFtBIIBaQFnAHUAwjF+V0UZo0XufzjespBB
68fCIVoiv3/Vta12mtkOUs0AAAGeucefAgAABAMARjBEAiBdD793ndAhFw/xk4gS
Pycc/U4IGSbSFNg1wXMMBkma9gIgSK8LNW9lqHn4wEYFRQrBfmYMUkJhUR51JmOb
rUESBu8AdgDXbX0Q0af1d8LH6V/XAL/5gskzWmXh0LMBcxfAyMVpdwAAAZ65x57r
AAAEAwBHMEUCIALrNS+sHrfHVl72YGHkqSb4quYzH6xZ0JNCTLakfjK4AiEA0jbR
ORUL7BLzYbw5geZfMb8NJmD+7CbgXdHNPWmowdYAdgCUTkOH+uzB74HzGSQmqBhl
AcfTXzgCAT9yZ31VNy4Z2AAAAZ65x58NAAAEAwBHMEUCIQCn753LSIK5ODnAw8za
2BUf7PZQhtwhe1I0fEfrFjwcAAIgPZtnV83cNV8UK9zGQhWso4QX/oK+ziXQMuP5
+lApPpcwDQYJKoZIhvcNAQELBQADggEBAKceFp1JWPgaR5TGrMXQNY0qgRPhY0ZC
MSVLqtbgWPeODl3p+BTpLJ/taGR1v1mlL/2cM+EU/RQVDobYUu91Td0Tibp1T8vv
KKgnGTj8fVTzimTR5YsFHKc0XFqHpbneArZFb9tbMnRLheDjAqJ3Xs5H5t/XNDSi
vasqxdvAYZPP+7JZPRrFqq/fPZR79NRz7kl45YiQX199d/8oPRVFRrq1TGtp41Nk
7rP31tq0YZDpBHXXmpYXmOZalWBHoASnZJDq0+H5bSBzNVHR25eOZe0dt5lWMzrf
bzFMatWnPKI/aLhc3GGZAxyy7PCRA5pJGB+qnaJAakODvB02VvJey/Y=
BAIwADCCAXwGCisGAQQB1nkCBAIEggFsBIIBaAFmAHUAwjF+V0UZo0XufzjespBB
68fCIVoiv3/Vta12mtkOUs0AAAGgebrkkgAABAMARjBEAiA3QXzJe/8VFDnVdr/6
BRerjq1qVsc61eFPJfMdmgbeCAIgTTMUJI0RnOIX7O0umzAWJa6T4lljD2+2ybyw
FNTGoaQAdgDYCVU7lE96/8gWGW+UT4WrsPj8XodVJg8V0S5yu0VLFAAAAaB5uuSb
AAAEAwBHMEUCIDb3e4Fb75I9JRnvfM5q8/iPLgs4u/NPdH4brm//kO9KAiEAujgF
VeUmRxadsYgF1pVRytH2vURymXUvcTtZfWLbk/YAdQCUTkOH+uzB74HzGSQmqBhl
AcfTXzgCAT9yZ31VNy4Z2AAAAaB5uuSbAAAEAwBGMEQCIGyiorlBMD7UARZvFGRz
VsPQH+oqh3cEtHq9t/EdDFH7AiAb7dllHHDE1zJrf41OuXOHQZZrA5VBNPpBu1Lj
avU6kzANBgkqhkiG9w0BAQsFAAOCAQEADDNB+zpouXcPD43XUYYE8dOpqFCHXGpe
MOn44OGPpfU/DvsQgsMZl//bdIcSlmuyHIr/Z5CojN/sCiyCmqnM+vSRMjcjoqxU
y7Ly0df9moxXprB20/XiWhCMSy0+bK051Kk4Lh3O0jWKGe2edEUEzwb1vvyhDylV
skd347/BUVGEf+PGT4NqI2ImWoUBc73C8oZ6eHe/bHAiESOI1RnUefOUw3G8El2o
t5DUflbkT1+Gzi/NgxvnSwbuvsf77mA9DOg8F6wwsPk4je+ekGycrzoVj/hnzy+h
4BT6OiBxzBkRa7TLMblQAPiDTXkbP41ZzLNYnCN8XwpXRvrt29Hf6g==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEqjCCA5KgAwIBAgIQDeD/te5iy2EQn2CMnO1e0zANBgkqhkiG9w0BAQsFADBh