Compare commits
177
Commits
master
...
3f9b5e59a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f9b5e59a8 | ||
|
|
fbe2bcfee6 | ||
|
|
bdbf989b5e | ||
|
|
c2244f50b1 | ||
|
|
43d4392873 | ||
|
|
0ec961e210 | ||
|
|
3b0369850f | ||
|
|
42b6e75b77 | ||
|
|
d62f79a407 | ||
|
|
ef4976ee03 | ||
|
|
cf6a17387d | ||
|
|
dd4e719f55 | ||
|
|
95dc89e097 | ||
|
|
dbd41cc045 | ||
|
|
d48875bd7a | ||
|
|
490cf4bb93 | ||
|
|
b140bff5f1 | ||
|
|
0232dc6e14 | ||
|
|
c51d0dc2ca | ||
|
|
918ae96f71 | ||
|
|
cb6792cb29 | ||
|
|
a95fe2d73f | ||
|
|
73036038fd | ||
|
|
e596c9b5ae | ||
|
|
c441ccec94 | ||
|
|
c432eb431a | ||
|
|
4d74dd2d63 | ||
|
|
daf9f5147b | ||
|
|
de2e1d8902 | ||
|
|
28cc58bca0 | ||
|
|
9f94f4a9ab | ||
|
|
03697b9747 | ||
|
|
ee5120c6ed | ||
|
|
ba5ff9b87d | ||
|
|
e2a5480bf9 | ||
|
|
abc5b47f72 | ||
|
|
63ac39cd4a | ||
|
|
20bab0997e | ||
|
|
3cddff6df2 | ||
|
|
c1658380a7 | ||
|
|
998e13e6be | ||
|
|
68850c40fe | ||
|
|
d2143809de | ||
|
|
68bdc63231 | ||
|
|
b6208b37e4 | ||
|
|
f7b3531392 | ||
|
|
c8f457dada | ||
|
|
0421adb6ac | ||
|
|
c962cdd830 | ||
|
|
13a2aeee9f | ||
|
|
bb15c053e6 | ||
|
|
dc20a6a829 | ||
|
|
b8d29e31df | ||
|
|
41655fa825 | ||
|
|
3b71f144c4 | ||
|
|
c4c2625714 | ||
|
|
7e7a3cbf2d | ||
|
|
a65b898680 | ||
|
|
315d05c3ea | ||
|
|
362cb4332f | ||
|
|
4ec05b507a | ||
|
|
c088894546 | ||
|
|
8e7948cff5 | ||
|
|
2ae59c9252 | ||
|
|
0e6381d2b3 | ||
|
|
4e1cd284e9 | ||
|
|
5ac546a466 | ||
|
|
4796750305 | ||
|
|
20990db7d6 | ||
|
|
cb8effc769 | ||
|
|
a951f864f4 | ||
|
|
448620ed8a | ||
|
|
9fc80a6c98 | ||
|
|
e1d369b205 | ||
|
|
a8106f6c4c | ||
|
|
7de6f00cff | ||
|
|
4dc2c38820 | ||
|
|
ace80efd63 | ||
|
|
be51ac40e0 | ||
|
|
adea3a90ec | ||
|
|
cac2dd019c | ||
|
|
3099f65109 | ||
|
|
bf1e9fa89a | ||
|
|
51b5008951 | ||
|
|
fd287a0d92 | ||
|
|
fa9ada2334 | ||
|
|
76be2dc85c | ||
|
|
5742608b97 | ||
|
|
a6eaf57898 | ||
|
|
f179a5b755 | ||
|
|
ea3945d70f | ||
|
|
0a4c281997 | ||
|
|
57c0e4281a | ||
|
|
5b6b52a18b | ||
|
|
2412e9abea | ||
|
|
1df43a33a1 | ||
|
|
a7c5ebbab0 | ||
|
|
3512981408 | ||
|
|
72cb10c4f5 | ||
|
|
1bdef0efd6 | ||
|
|
eaec78354b | ||
|
|
36e62a5360 | ||
|
|
7c01f69a24 | ||
|
|
67ec83832f | ||
|
|
9f9f7eb376 | ||
|
|
ab7bb9fc42 | ||
|
|
5f0d860d27 | ||
|
|
4679280e45 | ||
|
|
6a588015d0 | ||
|
|
6be970dcaf | ||
|
|
cce0bc6448 | ||
|
|
4391189e0c | ||
|
|
0dd9637725 | ||
|
|
d7f7d68f39 | ||
|
|
c2b6cd274d | ||
|
|
422c85a068 | ||
|
|
595858ae5e | ||
|
|
175d2e47ce | ||
|
|
5113808991 | ||
|
|
50f430def2 | ||
|
|
fceb8eb3c7 | ||
|
|
ab9c3cf863 | ||
|
|
a4d0028473 | ||
|
|
5de47b5701 | ||
|
|
0f01b301e9 | ||
|
|
2b5ac801a2 | ||
|
|
c52f3d5a8a | ||
|
|
e8833b458d | ||
|
|
4b8ff9af65 | ||
|
|
a6fdba912d | ||
|
|
e3c63b9377 | ||
|
|
9653b5c276 | ||
|
|
e4f1e3af36 | ||
|
|
9fbe2c6d64 | ||
|
|
ffab3be854 | ||
|
|
f85107be6d | ||
|
|
27f35a5679 | ||
|
|
2ede6f4ba0 | ||
|
|
195d219f91 | ||
|
|
536ad8386c | ||
|
|
10519e7500 | ||
|
|
79d0a2a9fe | ||
|
|
20bf8138a8 | ||
|
|
1c0e966b7a | ||
|
|
57ca523b5a | ||
|
|
f72508aa83 | ||
|
|
61577be41f | ||
|
|
1baef251ab | ||
|
|
b25a1457af | ||
|
|
85845b0d38 | ||
|
|
075bf22add | ||
|
|
52340fcd94 | ||
|
|
10d6ce2b0d | ||
|
|
58d5713ec1 | ||
|
|
cb6c881365 | ||
|
|
ffaf04982a | ||
|
|
28d81d86bd | ||
|
|
0b958a0ebb | ||
|
|
c13e01c902 | ||
|
|
c8ad78ab44 | ||
|
|
04a8912307 | ||
|
|
495fde42ca | ||
|
|
5363613a7d | ||
|
|
fc54bcd3d6 | ||
|
|
4a8bc3cb7a | ||
|
|
367cd98018 | ||
|
|
ee8ba0a5d9 | ||
|
|
56e1517743 | ||
|
|
eea5874dbf | ||
|
|
6ef063ac09 | ||
|
|
c37710af3d | ||
|
|
c52d31c16e | ||
|
|
48a4636a81 | ||
|
|
9c03013c44 | ||
|
|
05a0e35891 | ||
|
|
937d1e9373 | ||
|
|
af3f0678b8 |
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: grill-me
|
||||
description: A relentless interview to sharpen a plan or design.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
Run a `/grilling` session.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: grill-me
|
||||
description: A relentless interview to sharpen a plan or design.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
Run a `/grilling` session.
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
dist
|
||||
.cache
|
||||
+2
-1
@@ -4,4 +4,5 @@ ENV = 'development'
|
||||
# 统一后端服务地址前缀(网关服务名:admin-go)
|
||||
# 开发环境走本地代理,避免 CORS
|
||||
VITE_API_URL = 'http://116.204.74.41:8000'
|
||||
# VITE_API_URL = 'http://192.168.3.30:8000'
|
||||
# VITE_API_URL = 'http://192.168.3.30:8000'
|
||||
# VITE_API_URL = 'http://192.168.3.135:8000'
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
name: 部署 admin-ui 到 K3s
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 拉取代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 部署到 K3s
|
||||
run: |
|
||||
kubectl apply -f k8s/
|
||||
kubectl rollout restart deployment admin-ui
|
||||
@@ -1,6 +1,8 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
# Vue language service / accidental transpile artifacts
|
||||
src/**/*.vue.js
|
||||
|
||||
|
||||
@@ -23,3 +25,4 @@ pnpm-debug.log*
|
||||
*.sln
|
||||
*.sw?
|
||||
/admin-ui.iml
|
||||
.gstack/
|
||||
|
||||
@@ -19,6 +19,7 @@ This is the **GFast UI** project (`gfast-ui`), a Vue 3 admin management system b
|
||||
## Architecture Overview
|
||||
|
||||
**Tech Stack:**
|
||||
|
||||
- Vue 3 with Composition API (script setup)
|
||||
- TypeScript
|
||||
- Vite
|
||||
@@ -30,6 +31,7 @@ This is the **GFast UI** project (`gfast-ui`), a Vue 3 admin management system b
|
||||
- mitt (event bus)
|
||||
|
||||
**Directory Structure:**
|
||||
|
||||
- `src/api/` - API client methods organized by business domain (ads, assets, cid, customerService, digitalHuman, knowledge, login, menu, settings, system, etc.)
|
||||
- `src/components/` - Reusable Vue components
|
||||
- `src/directives/` - Custom Vue directives
|
||||
@@ -92,6 +94,139 @@ This is the **GFast UI** project (`gfast-ui`), a Vue 3 admin management system b
|
||||
|
||||
5. **Keep-alive**: Route components can be cached via keep-alive, managed by the `useKeepALiveNames` store.
|
||||
|
||||
## Repository Collaboration Rules
|
||||
|
||||
These rules capture long-term repository preferences confirmed by the user and should be applied in future sessions unless the user explicitly overrides them.
|
||||
|
||||
### 最高优先级规则(覆盖以下所有规则)
|
||||
|
||||
以下规则优先级高于本文件任何其他规则:
|
||||
|
||||
1. **修改前必须确认**:任何代码修改前必须先向用户确认,用户明确同意后才能执行
|
||||
2. **反常问题必须上报**:遇到 Edit 反复失败、渲染异常、非预期行为等反常问题,必须立即向用户报告问题原因、可行方案和推荐方向,由用户选择后再继续
|
||||
3. **不确定必须询问**:不确定、不了解、不清楚的事情一律先问用户,不自行假设或绕路
|
||||
4. **改后影响检查**:代码修改完成后,必须逐一检查对其他代码的影响(渲染条件、接口字段、样式覆盖、其他引用处)
|
||||
5. **改动评分与总结**:每次修改完成后,对本次改动按 1-10 分打分,并附上总结和改进建议
|
||||
6. **中文沟通**:所有与用户的沟通、说明、思考展示必须使用中文
|
||||
7. **操作后清理**:每次文件操作(拆分、替换、脚本执行等)完成后,必须检查并清理产生的临时文件,不得遗留在项目目录中
|
||||
8. **内容创作与工作流管理必须隔离**:`src/views/settings/creation`(内容创作)是线上运行的旧版本,所有改动只服务于工作流管理(`src/views/settings/workflow`),绝对不修改内容创作的代码、API、组件或数据结构。工作流如需共享能力,应为自身新建独立版本(如独立的 `src/api/settings/workflow/`、本地组件),不得改动内容创作在用的东西。后端为工作流管理做的数据变化也属于工作流管理,不必适配内容创作。
|
||||
|
||||
### Pagination Rules
|
||||
|
||||
- Prefer the project global `pagination` component by default.
|
||||
- Pagination should be fixed and visible as part of the page or dialog layout, not conditionally hidden only because the dataset is small.
|
||||
- In single-page scenarios, the UI should still show total count together with the pagination area.
|
||||
- Avoid designs where pagination only appears after data exceeds one page.
|
||||
|
||||
### Dialog Layout Rules
|
||||
|
||||
- For dialogs that contain searchable tabular data, prefer this structure:
|
||||
1. search area
|
||||
2. table/content area
|
||||
3. pagination area placed under the table inside the dialog body
|
||||
4. footer action buttons
|
||||
- The pagination area should remain visually stable and should not be omitted just because the current result set is small.
|
||||
|
||||
### Theme and Style Rules
|
||||
|
||||
- When functionality and existing theme style conflict, prioritize making the feature clearly visible and usable first.
|
||||
- After ensuring usability, align the implementation with the repository's existing theme and style system as much as possible.
|
||||
- Be cautious about global theme styles that may affect local pagination, dialog, or table layouts.
|
||||
|
||||
### Problem-solving Workflow
|
||||
|
||||
- For repeated UI issues, analyze in this order:
|
||||
1. interface/request data and returned structure
|
||||
2. component logic and render conditions
|
||||
3. theme and style coverage/conflicts
|
||||
4. then decide whether a rewrite is necessary
|
||||
- Do not jump straight to a rewrite before checking interface behavior and style interference.
|
||||
|
||||
### Execution Workflow Rules
|
||||
|
||||
- When modifying a feature, locate it in this order: usage location, component definition, API definition, then at least one similar implementation in the project.
|
||||
- For common UI features such as pagination, dialogs, and tables, align with an existing similar implementation before making changes.
|
||||
- Prefer componentization. If a page or Vue SFC is already large, or a block has a clear independent responsibility, split it into components instead of continuing to extend the same file.
|
||||
- For very long files such as complex page-level `index.vue` files, treat further component splitting as the default direction rather than the exception.
|
||||
- Before editing large files or complex Vue SFCs, first read the target context; if a replacement fails once, do not repeat the same replacement blindly, and instead expand the context or switch to smaller targeted edits.
|
||||
- After UI or interaction changes, verify not only lint status for edited files but also render conditions, interface fields, and possible style impact.
|
||||
- If the same issue is not resolved after one attempt, pause and ask the user before continuing with more speculative changes.
|
||||
- Do not proactively rewrite a component unless the user explicitly asks for a rewrite.
|
||||
- At the start of a new conversation, read `CLAUDE.md` before making changes.
|
||||
- When code changes appear not to take effect, check in this order: compile/HMR status, render conditions, API data, then style overrides.
|
||||
- Turn repeated low-level mistakes into explicit workflow checks and follow those checks in future edits.
|
||||
|
||||
### Environment and Tool Selection Rules
|
||||
|
||||
- Before running environment-dependent commands, first follow the known runtime context already provided by the IDE, such as OS and default shell.
|
||||
- On Windows projects with PowerShell as the active shell, prefer PowerShell-native commands and scripts by default.
|
||||
- Do not assume Python, bash, sed, awk, or other non-default tooling is installed locally unless the user or repository explicitly indicates that they are available.
|
||||
- For reading and editing repository files, prefer dedicated file tools first; use shell-based text replacement only when file tools are not suitable for the change.
|
||||
- When a replacement fails once, do not keep retrying the same method blindly. Re-read the exact target content and switch to a smaller or more reliable edit strategy that matches the current environment.
|
||||
- Avoid trial-and-error probing for basic tooling when the available environment is already known from context.
|
||||
|
||||
### Componentization and Structure Rules
|
||||
|
||||
- If a view block has an independent responsibility, prefer extracting it into a component instead of keeping it inside a large page file.
|
||||
- When a file is already long, or a new feature would further expand responsibilities, proactively split it into components.
|
||||
- Child components may keep their own local logic when that logic belongs clearly to the child’s responsibility; only shared state and necessary events should stay in the parent.
|
||||
- For complex pages, small-scale components can stay in `component/`, while larger feature areas may be grouped by responsibility in subdirectories.
|
||||
- For very long page files, prefer keeping the parent focused on composition, state orchestration, and data flow, while moving feature UI and local behavior into child components.
|
||||
- When extracting new child components from a large file, move their related local styles with them to avoid continued growth of the parent file.
|
||||
|
||||
### API and Data Handling Rules
|
||||
|
||||
- Follow the project’s common response structure by default, but add moderate component-side compatibility handling when equivalent APIs return slightly inconsistent fields.
|
||||
- For page-level error handling, use global handling by default, but use page-level or silent handling when the interaction requires local control.
|
||||
- Prefer a unified parameter-building entry for request params instead of assembling the same parameters across multiple functions.
|
||||
|
||||
### Form and Interaction Rules
|
||||
|
||||
- Follow the project’s existing form behavior style: required fields should be clearly validated on submit, while avoiding unnecessarily disruptive validation during input.
|
||||
- For shared create/edit forms, initialize, patch, and reset form state through a unified initialization path to avoid stale state.
|
||||
- Search interactions should behave consistently across button click, Enter key, and clear actions, and should reset to the first page by default.
|
||||
|
||||
### Dialog and Selector Rules
|
||||
|
||||
- Dialogs should clear temporary internal state on close by default; any state that needs to persist should be explicitly provided back by the parent.
|
||||
- Selector components should follow a consistent pattern: `v-model` for visibility, a default value prop, and a `confirm` event for returning the selected result.
|
||||
- For temporary selectors and lightweight dialogs, prefer `destroy-on-close`; for more complex stateful forms, decide deliberately rather than applying it blindly.
|
||||
|
||||
### Styling Rules
|
||||
|
||||
- Prefer local scoped style changes first; only modify global theme styles after confirming the issue is truly shared across multiple places.
|
||||
- When overriding Element Plus internals, prefer local `:deep()` overrides before considering global style changes.
|
||||
- Use semantic, area-based class names instead of short or overly generic names.
|
||||
|
||||
### Debugging and Validation Rules
|
||||
|
||||
- Minimal temporary debugging is allowed when necessary, but all temporary debugging code must be removed before final delivery.
|
||||
- Temporary `console` output is acceptable only when debugging is genuinely needed and must be removed before final delivery.
|
||||
- For UI and interaction changes, verify lint, key render conditions, interface fields, and style impact before considering the task complete.
|
||||
|
||||
### Change Scope and Consistency Rules
|
||||
|
||||
- Prefer the smallest necessary change set, but do not pretend a local-only fix is sufficient when the real root cause is elsewhere in the chain.
|
||||
- Only fix nearby issues when they are strongly related to the current task; otherwise ask the user before expanding the scope.
|
||||
- Within the current modification scope, move code toward repository rules when practical, but avoid broad unrelated refactors.
|
||||
|
||||
### Communication and Risk Rules
|
||||
|
||||
- 默认使用中文进行回复、说明、提问与思考表达;如果用户明确要求使用其他语言,再按用户要求切换。
|
||||
- Before changing code, confirm with the user when a change involves a rewrite, global side effects, unclear requirements, or a second attempt that still has not solved the issue.
|
||||
- After changes, explain what changed, why it was changed that way, and any remaining risks or points worth confirming.
|
||||
- When a requirement still depends on a meaningful assumption, ask the user first instead of silently choosing a direction.
|
||||
|
||||
### Repository Memory Rules
|
||||
|
||||
- When collaboration reveals a repeated problem pattern, proactively suggest turning it into an explicit repository rule in `CLAUDE.md`.
|
||||
- Project-specific experience or pitfall notes may be added when they become necessary, but do not expand them prematurely without clear recurring value.
|
||||
|
||||
### Rule Documentation Style
|
||||
|
||||
- Keep long-term repository rules concise and practical.
|
||||
- Prefer short rule lists with a small amount of explanation over overly long documentation.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `.env.development` - Development environment settings
|
||||
@@ -101,6 +236,67 @@ This is the **GFast UI** project (`gfast-ui`), a Vue 3 admin management system b
|
||||
- `VITE_PUBLIC_PATH` - Public base path for production build
|
||||
- `VITE_OPEN_CDN` - Enable CDN for external dependencies
|
||||
|
||||
## AI Skills
|
||||
|
||||
本项目已全局安装 **gstack** 和 **Superpowers** 两组技能(skill),通过斜杠命令调用。
|
||||
|
||||
### gstack — 浏览器验证与发布
|
||||
|
||||
负责 QA 测试、浏览器自动化、安全审计、代码审查和发布部署。
|
||||
|
||||
| 阶段 | 命令 | 功能 |
|
||||
| ----------- | ------------------------- | ------------------------------------------------ |
|
||||
| **审查** | `/gstack-review` | 代码审查,自动修复明显问题,标记完整性缺口 |
|
||||
| | `/gstack-cso` | 安全审计(OWASP Top 10 + STRIDE),含漏洞验证 |
|
||||
| **QA 测试** | `/gstack-qa <url>` | 打开浏览器进行功能测试,发现 bug 并自动修复 |
|
||||
| | `/gstack-qa-only <url>` | 仅检测并报告 bug,不修改代码 |
|
||||
| | `/gstack-browse` | AI 浏览网页内容(通过 Playwright + 系统 Chrome) |
|
||||
| | `/gstack-scrape` | 抓取网页内容 |
|
||||
| **性能** | `/gstack-benchmark` | 页面加载性能基准测试 |
|
||||
| | `/gstack-canary` | 部署后监控:控制台错误、性能回归、页面故障 |
|
||||
| **发布** | `/gstack-ship` | 同步主分支、运行测试、推送、创建 PR |
|
||||
| | `/gstack-land-and-deploy` | 合并 PR、等待 CI、部署、验证生产环境 |
|
||||
| **诊断** | `/gstack-investigate` | 系统性根因调试 |
|
||||
| | `/gstack-retro` | 迭代回顾分析 |
|
||||
|
||||
浏览器功能依赖 Playwright,已配置使用系统 Chrome(`C:\Program Files\Google\Chrome\Application\chrome.exe`),无需额外下载浏览器。
|
||||
|
||||
### Superpowers — 思考与计划
|
||||
|
||||
负责头脑风暴、方案设计、计划编写与执行、系统性调试。
|
||||
|
||||
| 阶段 | 命令 | 功能 |
|
||||
| -------- | ----------------------- | ----------------------------------- |
|
||||
| **构思** | `/sp-brainstorm` | 交互式头脑风暴,梳理需求和方案 |
|
||||
| | `/sp-using-superpowers` | Superpowers 使用指南 |
|
||||
| **计划** | `/sp-write-plan` | 编写实现计划 |
|
||||
| | `/sp-execute-plan` | 按批次执行计划 |
|
||||
| **开发** | `/sp-subagent-dev` | 子代理驱动开发:规范→审查→实现→验证 |
|
||||
| | `/sp-tdd` | 测试驱动开发工作流 |
|
||||
| | `/sp-dispatch-agents` | 并行分发子代理任务 |
|
||||
| **调试** | `/sp-debug` | 系统性调试方法论 |
|
||||
| **审查** | `/sp-request-review` | 请求代码审查 |
|
||||
| | `/sp-receive-review` | 接收并处理审查反馈 |
|
||||
| **完成** | `/sp-verify` | 完成前验证检查 |
|
||||
|
||||
| | `/sp-finish-branch` | 完成开发分支 |
|
||||
| **工具** | `/sp-write-skills` | 编写自定义 skill |
|
||||
| | `/sp-git-worktrees` | Git 工作树使用指南 |
|
||||
|
||||
### 工作流建议
|
||||
|
||||
推荐开发流程按此顺序使用两组 skill:
|
||||
|
||||
```
|
||||
/sp-brainstorm → 梳理需求
|
||||
/sp-write-plan → 编写实现计划
|
||||
(编码实现)
|
||||
/gstack-review → 代码审查
|
||||
/gstack-qa <url> → QA 测试
|
||||
/gstack-cso → 安全检查
|
||||
/gstack-ship → 发布
|
||||
```
|
||||
|
||||
## Development Notes
|
||||
|
||||
- The project uses `/@` alias for `src/`
|
||||
|
||||
+14
-2
@@ -1,5 +1,5 @@
|
||||
# ==================== 第一阶段:构建前端 ====================
|
||||
FROM node:18-alpine AS builder
|
||||
FROM docker.m.daocloud.io/node:18-alpine AS builder
|
||||
WORKDIR /app
|
||||
# 配置Alpine国内镜像源
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
@@ -13,7 +13,7 @@ COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ==================== 第二阶段:部署到Nginx ====================
|
||||
FROM nginx:alpine
|
||||
FROM docker.m.daocloud.io/nginx:alpine
|
||||
|
||||
# 复制构建产物
|
||||
COPY --from=builder /app/dist/ /usr/share/nginx/html/
|
||||
@@ -24,6 +24,18 @@ COPY ngnix.conf /etc/nginx/conf.d/default.conf
|
||||
# 复制SSL证书
|
||||
COPY ssl/* /etc/nginx/ssl/
|
||||
|
||||
# 启动时自动获取宿主机内网 IP,替换 __API_HOST__ 占位符
|
||||
RUN printf '%s\n' \
|
||||
'#!/bin/sh' \
|
||||
'set -e' \
|
||||
'if [ -n "$API_HOST" ]; then' \
|
||||
' HOST_IP="$API_HOST"' \
|
||||
'else' \
|
||||
' HOST_IP=$(route -n 2>/dev/null | grep "^0.0.0.0" | tr -s " " | cut -d " " -f2)' \
|
||||
'fi' \
|
||||
'sed -i "s/__API_HOST__/$HOST_IP/g" /etc/nginx/conf.d/default.conf' \
|
||||
> /docker-entrypoint.d/01-set-api-host.sh && chmod +x /docker-entrypoint.d/01-set-api-host.sh
|
||||
|
||||
EXPOSE 80 443
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
# 配置Alpine国内镜像源(加速apk)
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install --registry=https://registry.npmmirror.com
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
@@ -1,26 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: admin-ui
|
||||
namespace: default
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: admin-ui
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: admin-ui
|
||||
spec:
|
||||
containers:
|
||||
- name: admin-ui
|
||||
image: 116.204.74.41:3000/red-future/admin-ui:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 80
|
||||
- containerPort: 443
|
||||
resources:
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
@@ -1,20 +0,0 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: admin-ui
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
spec:
|
||||
tls:
|
||||
- secretName: admin-ui-tls
|
||||
rules:
|
||||
- http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: admin-ui
|
||||
port:
|
||||
number: 443
|
||||
@@ -1,15 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: admin-ui
|
||||
spec:
|
||||
selector:
|
||||
app: admin-ui
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 80
|
||||
- name: https
|
||||
port: 443
|
||||
targetPort: 443
|
||||
type: ClusterIP
|
||||
+238
-25
@@ -1,62 +1,275 @@
|
||||
# Nginx 静态文件服务 + 智能代理
|
||||
# Nginx 静态文件服务 + 智能代理 + 自定义 404 页面
|
||||
|
||||
# HTTP 重定向到 HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
return 301 https://$host$request_uri;
|
||||
# AI 爬虫 User-Agent 黑名单
|
||||
map $http_user_agent $badbot {
|
||||
default 0;
|
||||
~*(GPTBot|ChatGPT-User|Claude-Web|ClaudeBot|Bytespider|CCBot|Google-Extended) 1;
|
||||
}
|
||||
|
||||
# WebSocket 升级头透传:客户端带 Upgrade 头(WS 握手)时 Connection 置 upgrade,普通 REST 请求保持 close
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
# 主站 HTTP(SSL 已在外层 nginx 终结,此处只需 serve 内容)
|
||||
server {
|
||||
# 静态资源根目录(dist)
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SSL 配置
|
||||
listen 443 ssl;
|
||||
ssl_certificate /etc/nginx/ssl/scs1779764972146_redpowerfuture.com_server.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/scs1779764972146_redpowerfuture.com_server.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
client_max_body_size 500M;
|
||||
|
||||
listen 80;
|
||||
server_name redpowerfuture.com www.redpowerfuture.com;
|
||||
|
||||
error_page 404 /404.html;
|
||||
|
||||
location = /404.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
# 根路径默认进入网页端
|
||||
location = / {
|
||||
return 302 /web/index.html;
|
||||
}
|
||||
|
||||
# 网页端(public/web/index.html -> dist/web/index.html)
|
||||
location = /web {
|
||||
return 302 /web/;
|
||||
}
|
||||
|
||||
location = /sys {
|
||||
return 302 /sys/;
|
||||
}
|
||||
|
||||
location /web/ {
|
||||
alias /usr/share/nginx/html/web/;
|
||||
try_files $uri $uri/ /web/index.html;
|
||||
}
|
||||
|
||||
# 后台管理端(dist/index.html,前缀 /sys/)
|
||||
location /sys/ {
|
||||
alias /usr/share/nginx/html/;
|
||||
try_files $uri $uri/ /sys/index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 1. 先尝试作为静态文件查找
|
||||
location / {
|
||||
try_files $uri $uri/ @proxy;
|
||||
}
|
||||
|
||||
# 2. 无法找到的请求(API路径)代理到后端
|
||||
location @proxy {
|
||||
# 判断 URI 最后一段是否有扩展名
|
||||
# 有扩展名返回 404,无扩展名则代理
|
||||
if ($uri ~ \.[^./]+$) {
|
||||
return 404;
|
||||
}
|
||||
|
||||
proxy_pass http://116.204.74.41:8000;
|
||||
proxy_pass http://__API_HOST__:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
# WebSocket 升级头透传(首页会话长连接 /ai-agent/session/wsExecute 依赖握手成功)
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_connect_timeout 75s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
}
|
||||
|
||||
# Gitea HTTP → HTTPS 重定向
|
||||
server {
|
||||
listen 80;
|
||||
server_name gitea.redpowerfuture.com;
|
||||
client_max_body_size 500M;
|
||||
if ($badbot) {
|
||||
return 444;
|
||||
}
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# MinIO HTTP/HTTPS(增加 WebSocket 支持)
|
||||
server {
|
||||
listen 80;
|
||||
server_name minio.redpowerfuture.com;
|
||||
client_max_body_size 500M;
|
||||
location / {
|
||||
proxy_pass http://__API_HOST__:9001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# WebSocket 支持
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
}
|
||||
|
||||
# Consul HTTP(仅 HTTP)
|
||||
server {
|
||||
listen 80;
|
||||
server_name consul.redpowerfuture.com;
|
||||
client_max_body_size 500M;
|
||||
location / {
|
||||
proxy_pass http://__API_HOST__:8500;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# MeiliSearch HTTP(仅 HTTP)
|
||||
server {
|
||||
listen 80;
|
||||
server_name meilisearch.redpowerfuture.com;
|
||||
client_max_body_size 500M;
|
||||
location / {
|
||||
proxy_pass http://__API_HOST__:7700;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
# 项目管理 HTTP(仅 HTTP)
|
||||
server {
|
||||
listen 80;
|
||||
server_name pm.redpowerfuture.com;
|
||||
client_max_body_size 500M;
|
||||
location / {
|
||||
proxy_pass http://__API_HOST__:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
# jaeger HTTP(仅 HTTP)
|
||||
server {
|
||||
listen 80;
|
||||
server_name jaeger.redpowerfuture.com;
|
||||
client_max_body_size 500M;
|
||||
location / {
|
||||
proxy_pass http://__API_HOST__:16686;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
# observer HTTP(转发到 18080)
|
||||
server {
|
||||
listen 80;
|
||||
server_name observer.redpowerfuture.com;
|
||||
client_max_body_size 500M;
|
||||
location / {
|
||||
proxy_pass http://__API_HOST__:18080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# WebSocket 升级头透传
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
}
|
||||
# ==================== 主站 HTTPS(静态文件 + API 反向代理) ====================
|
||||
server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
client_max_body_size 500M;
|
||||
|
||||
listen 443 ssl;
|
||||
server_name redpowerfuture.com www.redpowerfuture.com;
|
||||
ssl_certificate /etc/nginx/ssl/scs1779764972146_redpowerfuture.com_server.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/scs1779764972146_redpowerfuture.com_server.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
error_page 404 /404.html;
|
||||
|
||||
location = /404.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location = / {
|
||||
return 302 /web/index.html;
|
||||
}
|
||||
|
||||
location = /web {
|
||||
return 302 /web/;
|
||||
}
|
||||
|
||||
location = /sys {
|
||||
return 302 /sys/;
|
||||
}
|
||||
|
||||
location /web/ {
|
||||
alias /usr/share/nginx/html/web/;
|
||||
try_files $uri $uri/ /web/index.html;
|
||||
}
|
||||
|
||||
location /sys/ {
|
||||
alias /usr/share/nginx/html/;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ @proxy;
|
||||
}
|
||||
|
||||
location @proxy {
|
||||
if ($uri ~ \.[^./]+$) {
|
||||
return 404;
|
||||
}
|
||||
|
||||
proxy_pass http://__API_HOST__:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# WebSocket 升级头透传(首页会话长连接 /ai-agent/session/wsExecute 依赖握手成功)
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_connect_timeout 75s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
}
|
||||
|
||||
# ==================== Gitea HTTPS ====================
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name gitea.redpowerfuture.com;
|
||||
client_max_body_size 500M;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/gitea.redpowerfuture.com.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/gitea.redpowerfuture.com.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
location / {
|
||||
if ($badbot) {
|
||||
return 403;
|
||||
}
|
||||
|
||||
proxy_pass http://__API_HOST__:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 75s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+82
-1
@@ -23,16 +23,21 @@
|
||||
"codemirror": "^6.0.1",
|
||||
"countup.js": "^2.8.0",
|
||||
"cropperjs": "^1.6.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"echarts": "^5.5.0",
|
||||
"echarts-gl": "^2.0.9",
|
||||
"echarts-wordcloud": "^2.1.0",
|
||||
"element-china-area-data": "^6.1.0",
|
||||
"element-plus": "^2.10.7",
|
||||
"file-saver": "^2.0.5",
|
||||
"highlight.js": "^11.12.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"json-schema-editor": "github:wusij/json-schema#8827d0d6a360c37bc32a8945fb979260bd4a4c85",
|
||||
"jsplumb": "^2.15.6",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^18.0.9",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"mitt": "^3.0.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^2.1.7",
|
||||
@@ -51,7 +56,8 @@
|
||||
"vue-i18n": "11.1.2",
|
||||
"vue-router": "^4.3.0",
|
||||
"vue-simple-uploader": "^1.0.0-beta.5",
|
||||
"vue-ueditor-wrap": "^3.0.8"
|
||||
"vue-ueditor-wrap": "^3.0.8",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.28",
|
||||
@@ -1943,6 +1949,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
|
||||
@@ -3246,6 +3259,15 @@
|
||||
"ssr-window": "^3.0.0-alpha.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
@@ -4168,6 +4190,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.12.0",
|
||||
"resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.12.0.tgz",
|
||||
"integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "2.5.5",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz",
|
||||
@@ -4387,6 +4418,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-editor": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "git+ssh://git@github.com/wusij/json-schema.git#8827d0d6a360c37bc32a8945fb979260bd4a4c85",
|
||||
"integrity": "sha512-XtNGXmlbycpLzfOkzY9p9fimcvVfLXjbxLcUGjRZpgl0g47LJCQ52fhNs4DIEwlTRMJTBYeq0x8POBXMIXARfg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.0",
|
||||
"element-plus": "^2.5.0",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
@@ -4563,6 +4605,27 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.9",
|
||||
"resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.9.tgz",
|
||||
"integrity": "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/marked-highlight": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/marked-highlight/-/marked-highlight-2.2.4.tgz",
|
||||
"integrity": "sha512-PZxisNMJDduSjc0q6uvjsnqqHCXc9s0eyzxDO9sB1eNGJnd/H1/Fu+z6g/liC1dfJdFW4SftMwMlLvsBhUPrqQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"marked": ">=4 <19"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -6207,6 +6270,24 @@
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vuedraggable": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/vuedraggable/-/vuedraggable-4.1.0.tgz",
|
||||
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sortablejs": "1.14.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vuedraggable/node_modules/sortablejs": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.14.0.tgz",
|
||||
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
|
||||
+7
-1
@@ -28,16 +28,21 @@
|
||||
"codemirror": "^6.0.1",
|
||||
"countup.js": "^2.8.0",
|
||||
"cropperjs": "^1.6.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"echarts": "^5.5.0",
|
||||
"echarts-gl": "^2.0.9",
|
||||
"echarts-wordcloud": "^2.1.0",
|
||||
"element-china-area-data": "^6.1.0",
|
||||
"element-plus": "^2.10.7",
|
||||
"file-saver": "^2.0.5",
|
||||
"highlight.js": "^11.12.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"json-schema-editor": "github:wusij/json-schema#8827d0d6a360c37bc32a8945fb979260bd4a4c85",
|
||||
"jsplumb": "^2.15.6",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^18.0.9",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"mitt": "^3.0.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^2.1.7",
|
||||
@@ -56,7 +61,8 @@
|
||||
"vue-i18n": "11.1.2",
|
||||
"vue-router": "^4.3.0",
|
||||
"vue-simple-uploader": "^1.0.0-beta.5",
|
||||
"vue-ueditor-wrap": "^3.0.8"
|
||||
"vue-ueditor-wrap": "^3.0.8",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.28",
|
||||
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>404 - 页面不存在</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg-start: #f5f7fa;
|
||||
--bg-end: #e8ecf3;
|
||||
--card-bg: rgba(255, 255, 255, 0.92);
|
||||
--text-primary: #1f2d3d;
|
||||
--text-secondary: #6b7280;
|
||||
--accent: #409eff;
|
||||
--accent-hover: #66b1ff;
|
||||
--border: rgba(64, 158, 255, 0.12);
|
||||
--shadow: 0 20px 50px rgba(31, 45, 61, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
background: linear-gradient(135deg, var(--bg-start), var(--bg-end));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.error-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
width: min(960px, 100%);
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
gap: 48px;
|
||||
align-items: center;
|
||||
padding: 56px;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
margin: 0;
|
||||
font-size: clamp(64px, 12vw, 110px);
|
||||
line-height: 1;
|
||||
color: var(--accent);
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
margin: 20px 0 12px;
|
||||
font-size: 30px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
margin-top: 32px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 12px 22px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.action-button.primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 12px 24px rgba(64, 158, 255, 0.24);
|
||||
}
|
||||
|
||||
.action-button.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.action-button.secondary {
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid rgba(31, 45, 61, 0.12);
|
||||
}
|
||||
|
||||
.error-illustration {
|
||||
flex: 0 0 320px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.illustration-shell {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 32px;
|
||||
background: linear-gradient(160deg, rgba(64, 158, 255, 0.12), rgba(64, 158, 255, 0.02));
|
||||
border: 1px solid rgba(64, 158, 255, 0.14);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.illustration-shell::before,
|
||||
.illustration-shell::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: rgba(64, 158, 255, 0.12);
|
||||
}
|
||||
|
||||
.illustration-shell::before {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
top: -30px;
|
||||
right: -30px;
|
||||
}
|
||||
|
||||
.illustration-shell::after {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
left: -20px;
|
||||
bottom: -20px;
|
||||
}
|
||||
|
||||
.illustration-center {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: clamp(72px, 10vw, 120px);
|
||||
font-weight: 800;
|
||||
color: rgba(64, 158, 255, 0.85);
|
||||
letter-spacing: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.error-card {
|
||||
flex-direction: column;
|
||||
padding: 36px 24px;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.error-illustration {
|
||||
flex-basis: auto;
|
||||
width: min(320px, 100%);
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="error-page">
|
||||
<section class="error-card">
|
||||
<div class="error-content">
|
||||
<h1 class="error-code">404</h1>
|
||||
<h2 class="error-title">抱歉,你访问的页面不存在</h2>
|
||||
<p class="error-message">
|
||||
当前地址可能已失效、输入有误,或者对应内容已被移动。你可以返回首页,或回到后台入口继续操作。
|
||||
</p>
|
||||
<div class="error-actions">
|
||||
<a class="action-button primary" href="/">返回首页</a>
|
||||
<a class="action-button secondary" href="/sys/">进入后台</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="error-illustration" aria-hidden="true">
|
||||
<div class="illustration-shell">
|
||||
<div class="illustration-center">404</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"grill-me": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/productivity/grill-me/SKILL.md",
|
||||
"computedHash": "67e31b63e6e9e454cc2f51cd85b9cf1396ee46379a87c07a1450f33c47c1276a"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export interface ApiInterfaceQueryParams {
|
||||
|
||||
// 创建接口参数
|
||||
export interface CreateApiInterfaceParams {
|
||||
platformId: string | number;
|
||||
platformId: number;
|
||||
name: string;
|
||||
code: string;
|
||||
url: string;
|
||||
@@ -22,6 +22,7 @@ export interface CreateApiInterfaceParams {
|
||||
requestConfig?: Record<string, any>;
|
||||
responseConfig?: Record<string, any>;
|
||||
limitConfig?: Record<string, any>;
|
||||
tableDefinition?: Record<string, any>;
|
||||
}
|
||||
|
||||
// 更新接口参数
|
||||
@@ -50,6 +51,7 @@ export interface ApiInterfaceInfo {
|
||||
requestConfig?: Record<string, any>;
|
||||
responseConfig?: Record<string, any>;
|
||||
limitConfig?: Record<string, any>;
|
||||
tableDefinition?: Record<string, any>;
|
||||
createdBy?: string;
|
||||
createdAt?: number;
|
||||
updatedBy?: string;
|
||||
@@ -59,7 +61,7 @@ export interface ApiInterfaceInfo {
|
||||
// 获取接口列表
|
||||
export function listApiInterfaces(params: ApiInterfaceQueryParams) {
|
||||
return request({
|
||||
url: '/api/interface/controller/listApiInterfaces',
|
||||
url: '/data-engine/api/interface/controller/listApiInterfaces',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
@@ -68,7 +70,7 @@ export function listApiInterfaces(params: ApiInterfaceQueryParams) {
|
||||
// 获取接口详情
|
||||
export function getApiInterface(id: string) {
|
||||
return request({
|
||||
url: '/api/interface/controller/getApiInterface',
|
||||
url: '/data-engine/api/interface/controller/getApiInterface',
|
||||
method: 'get',
|
||||
params: { id },
|
||||
});
|
||||
@@ -77,7 +79,7 @@ export function getApiInterface(id: string) {
|
||||
// 创建接口
|
||||
export function createApiInterface(data: CreateApiInterfaceParams) {
|
||||
return request({
|
||||
url: '/api/interface/controller/createApiInterface',
|
||||
url: '/data-engine/api/interface/controller/createApiInterface',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
@@ -86,7 +88,7 @@ export function createApiInterface(data: CreateApiInterfaceParams) {
|
||||
// 修改接口
|
||||
export function updateApiInterface(data: UpdateApiInterfaceParams) {
|
||||
return request({
|
||||
url: '/api/interface/controller/updateApiInterface',
|
||||
url: '/data-engine/api/interface/controller/updateApiInterface',
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
@@ -95,7 +97,7 @@ export function updateApiInterface(data: UpdateApiInterfaceParams) {
|
||||
// 更新接口状态
|
||||
export function updateApiInterfaceStatus(data: UpdateApiInterfaceStatusParams) {
|
||||
return request({
|
||||
url: '/api/interface/controller/updateApiInterfaceStatus',
|
||||
url: '/data-engine/api/interface/controller/updateApiInterfaceStatus',
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
@@ -104,7 +106,7 @@ export function updateApiInterfaceStatus(data: UpdateApiInterfaceStatusParams) {
|
||||
// 删除接口
|
||||
export function deleteApiInterface(id: string) {
|
||||
return request({
|
||||
url: '/api/interface/controller/deleteApiInterface',
|
||||
url: '/data-engine/api/interface/controller/deleteApiInterface',
|
||||
method: 'delete',
|
||||
params: { id },
|
||||
});
|
||||
|
||||
@@ -26,8 +26,7 @@ export interface CreateDatasourcePlatformParams {
|
||||
requestTimeoutMs?: number;
|
||||
maxRetries?: number;
|
||||
retryDelayMs?: number;
|
||||
createdBy?: string;
|
||||
updatedBy?: string;
|
||||
authConfig?: Record<string, any>;
|
||||
}
|
||||
|
||||
// 更新平台参数
|
||||
@@ -40,7 +39,6 @@ export interface UpdateDatasourcePlatformParams extends Partial<CreateDatasource
|
||||
export interface UpdateDatasourcePlatformStatusParams {
|
||||
id: string;
|
||||
Status: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
// 平台信息
|
||||
@@ -54,22 +52,25 @@ export interface DatasourcePlatformInfo {
|
||||
apiBaseUrl: string;
|
||||
authType: string;
|
||||
authTypeName: string;
|
||||
token?: string;
|
||||
apiKey?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
authConfig?: Record<string, any>;
|
||||
rateLimitPerMinute?: number;
|
||||
rateLimitPerHour?: number;
|
||||
concurrencyLimit?: number;
|
||||
requestTimeoutMs?: number;
|
||||
maxRetries?: number;
|
||||
retryDelayMs?: number;
|
||||
createdBy?: string;
|
||||
createdAt?: number;
|
||||
updatedBy?: string;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
// 获取平台列表
|
||||
export function listDatasourcePlatforms(params: DatasourcePlatformQueryParams) {
|
||||
return request({
|
||||
url: '/datasource/platform/controller/listDatasourcePlatforms',
|
||||
url: '/data-engine/datasource/platform/controller/listDatasourcePlatforms',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
@@ -78,7 +79,7 @@ export function listDatasourcePlatforms(params: DatasourcePlatformQueryParams) {
|
||||
// 创建平台
|
||||
export function createDatasourcePlatform(data: CreateDatasourcePlatformParams) {
|
||||
return request({
|
||||
url: '/datasource/platform/controller/createDatasourcePlatform',
|
||||
url: '/data-engine/datasource/platform/controller/createDatasourcePlatform',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
@@ -87,7 +88,7 @@ export function createDatasourcePlatform(data: CreateDatasourcePlatformParams) {
|
||||
// 更新平台
|
||||
export function updateDatasourcePlatform(data: UpdateDatasourcePlatformParams) {
|
||||
return request({
|
||||
url: '/datasource/platform/controller/updateDatasourcePlatform',
|
||||
url: '/data-engine/datasource/platform/controller/updateDatasourcePlatform',
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
@@ -96,7 +97,7 @@ export function updateDatasourcePlatform(data: UpdateDatasourcePlatformParams) {
|
||||
// 删除平台
|
||||
export function deleteDatasourcePlatform(id: string) {
|
||||
return request({
|
||||
url: '/datasource/platform/controller/deleteDatasourcePlatform',
|
||||
url: '/data-engine/datasource/platform/controller/deleteDatasourcePlatform',
|
||||
method: 'delete',
|
||||
params: { id },
|
||||
});
|
||||
@@ -105,7 +106,7 @@ export function deleteDatasourcePlatform(id: string) {
|
||||
// 获取平台详情
|
||||
export function getDatasourcePlatform(id: string) {
|
||||
return request({
|
||||
url: '/datasource/platform/controller/getDatasourcePlatform',
|
||||
url: '/data-engine/datasource/platform/controller/getDatasourcePlatform',
|
||||
method: 'get',
|
||||
params: { id },
|
||||
});
|
||||
@@ -114,7 +115,7 @@ export function getDatasourcePlatform(id: string) {
|
||||
// 根据编码获取平台信息
|
||||
export function getPlatformByCode(platformCode: string) {
|
||||
return request({
|
||||
url: '/datasource/platform/controller/getPlatformByCode',
|
||||
url: '/data-engine/datasource/platform/controller/getPlatformByCode',
|
||||
method: 'get',
|
||||
params: { platformCode },
|
||||
});
|
||||
@@ -123,7 +124,7 @@ export function getPlatformByCode(platformCode: string) {
|
||||
// 更新平台状态
|
||||
export function updateDatasourcePlatformStatus(data: UpdateDatasourcePlatformStatusParams) {
|
||||
return request({
|
||||
url: '/datasource/platform/controller/updateDatasourcePlatformStatus',
|
||||
url: '/data-engine/datasource/platform/controller/updateDatasourcePlatformStatus',
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import request from '/@/utils/request';
|
||||
|
||||
/** 字段定义 */
|
||||
export interface FieldDefine {
|
||||
id?: number | string;
|
||||
fieldCode: string;
|
||||
fieldName: string;
|
||||
fieldType: 'STRING' | 'INT' | 'FLOAT' | 'BIGINT' | 'DECIMAL' | 'DATE' | 'DATETIME' | 'BOOLEAN';
|
||||
dataType?: string;
|
||||
fieldRole: 'DIMENSION' | 'INDICATOR';
|
||||
isAggregatable?: boolean;
|
||||
isFilterable?: boolean;
|
||||
isQueryable?: boolean;
|
||||
isSortable?: boolean;
|
||||
defaultAggregate?: string;
|
||||
groupName?: string;
|
||||
sortOrder: number;
|
||||
unit?: string;
|
||||
expression?: string;
|
||||
status?: string;
|
||||
validAggregates?: string[];
|
||||
}
|
||||
|
||||
/** 报表+字段保存请求参数 */
|
||||
export interface ReportWithFieldsData {
|
||||
businessCode: string;
|
||||
reportCode: string;
|
||||
reportName: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
statTableName: string;
|
||||
statTableComment?: string;
|
||||
dateField?: string;
|
||||
primaryKeys?: string[];
|
||||
conflictKeys?: string[];
|
||||
fields: FieldDefine[];
|
||||
}
|
||||
|
||||
export interface ExtractConfigSaveData {
|
||||
id?: number;
|
||||
businessCode: string;
|
||||
reportCode: string;
|
||||
extractCode: string;
|
||||
extractName: string;
|
||||
sourceTableName: string;
|
||||
sourceTableAlias?: string;
|
||||
targetTableName: string;
|
||||
isEnabled: boolean;
|
||||
extractType: string;
|
||||
extractMode: string;
|
||||
extractKeyField?: string;
|
||||
batchSize?: number;
|
||||
status?: string;
|
||||
groupByFields?: string[];
|
||||
filterExpression?: string;
|
||||
operator?: string;
|
||||
}
|
||||
|
||||
// --- 业务 Business ---
|
||||
|
||||
export function listBusinesses() {
|
||||
return request({
|
||||
url: '/data-engine/report/businesses',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
export function getBusiness(id: number) {
|
||||
return request({
|
||||
url: '/data-engine/report/business',
|
||||
method: 'get',
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export function saveBusiness(data: { id?: number; businessCode: string; businessName: string }) {
|
||||
return request({
|
||||
url: '/data-engine/report/business/save',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteBusiness(id: number) {
|
||||
return request({
|
||||
url: '/data-engine/report/business',
|
||||
method: 'delete',
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
// --- 报表 Report ---
|
||||
|
||||
export function listReports(businessCode: string, pageNum: number, pageSize: number, reportName?: string) {
|
||||
const params: any = { businessCode, pageNum, pageSize };
|
||||
if (reportName) params.reportName = reportName;
|
||||
return request({
|
||||
url: '/data-engine/report/reports',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export function getReport(id: number) {
|
||||
return request({
|
||||
url: '/data-engine/report/report',
|
||||
method: 'get',
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export function saveReport(data: {
|
||||
businessCode: string;
|
||||
reportCode: string;
|
||||
reportName: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
statTableName: string;
|
||||
statTableComment?: string;
|
||||
dateField?: string;
|
||||
primaryKeys?: string[];
|
||||
conflictKeys?: string[];
|
||||
}) {
|
||||
return request({
|
||||
url: '/data-engine/report/report/save',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存报表及字段(新建 / 编辑统一入口,全量替换字段) */
|
||||
export function saveReportWithFields(data: ReportWithFieldsData) {
|
||||
return request({
|
||||
url: '/data-engine/report/report/saveWithFields',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteReport(id: number) {
|
||||
return request({
|
||||
url: '/data-engine/report/report',
|
||||
method: 'delete',
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取某业务下所有报表(不分页,供下拉选择) */
|
||||
export function listAllReports(businessCode: string) {
|
||||
return request({
|
||||
url: '/data-engine/report/reports',
|
||||
method: 'get',
|
||||
params: { businessCode, pageNum: 1, pageSize: 9999 },
|
||||
});
|
||||
}
|
||||
|
||||
// --- 查询相关类型 ---
|
||||
|
||||
export interface ReportFilter {
|
||||
fieldCode: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ReportIndicator {
|
||||
fieldCode: string;
|
||||
aggregate: string;
|
||||
alias: string;
|
||||
}
|
||||
|
||||
export interface ReportOrderBy {
|
||||
fieldCode: string;
|
||||
direction: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
export interface ReportQueryParams {
|
||||
businessCode: string;
|
||||
reportCode: string;
|
||||
dimensions: string[];
|
||||
indicators: ReportIndicator[];
|
||||
filters: ReportFilter[];
|
||||
timeRange: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
timeGroup: string;
|
||||
orderBy: ReportOrderBy[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** 数据查询 */
|
||||
export function reportQuery(data: ReportQueryParams) {
|
||||
return request({
|
||||
url: '/data-engine/report/query',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 按天回刷(支持单天或日期范围) */
|
||||
export function execDailyBackfill(data: { businessCode: string; reportCode: string; startDate: string; endDate: string }) {
|
||||
return request({
|
||||
url: '/data-engine/report/backfill',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// --- 抽取配置 ExtractConfig ---
|
||||
|
||||
export function getExtractConfig(id: number) {
|
||||
return request({
|
||||
url: '/data-engine/report/extractConfig',
|
||||
method: 'get',
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export function saveExtractConfig(data: Record<string, any>) {
|
||||
return request({
|
||||
url: '/data-engine/report/extractConfig/save',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteExtractConfig(id: number) {
|
||||
return request({
|
||||
url: '/data-engine/report/extractConfig',
|
||||
method: 'delete',
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
@@ -37,6 +37,9 @@ export interface NodeLibraryItem {
|
||||
nodeName: string;
|
||||
modelType: number;
|
||||
skillOption: boolean;
|
||||
promptOption: boolean;
|
||||
isSaveFile: boolean;
|
||||
patchLayout: boolean;
|
||||
formConfig: NodeLibraryFormItem[];
|
||||
modelConfig: NodeLibraryModelConfig[];
|
||||
}
|
||||
@@ -83,21 +86,16 @@ export interface CreationTreeItem {
|
||||
|
||||
// 新的执行列表数据结构
|
||||
export interface ExecutionItem {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
content: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ExecutionFlowItem {
|
||||
flowName: string;
|
||||
Id?: number | string;
|
||||
sessionId?: string;
|
||||
items: ExecutionItem[];
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface ExecutionTreeItem {
|
||||
createDate: string;
|
||||
flows: ExecutionFlowItem[];
|
||||
items: ExecutionItem[];
|
||||
}
|
||||
|
||||
export interface CreationListData {
|
||||
@@ -149,10 +147,11 @@ export function getCreationList(params: CreationListParams, requestOptions?: Req
|
||||
}) as Promise<CreationListResponse>;
|
||||
}
|
||||
|
||||
export function getExecutionList(requestOptions?: RequestOptions) {
|
||||
export function getExecutionList(params?: Record<string, any>, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/ai-agent/flow/execution/list',
|
||||
method: 'get',
|
||||
params,
|
||||
requestOptions,
|
||||
}) as Promise<ExecutionListResponse>;
|
||||
}
|
||||
@@ -332,6 +331,7 @@ export interface ExecuteFlowParams {
|
||||
sessionId?: string;
|
||||
skillName?: string;
|
||||
resultUrl?: string;
|
||||
templates?: any[];
|
||||
}
|
||||
|
||||
export function executeFlow(data: ExecuteFlowParams | FormData, requestOptions?: RequestOptions) {
|
||||
@@ -344,3 +344,47 @@ export function executeFlow(data: ExecuteFlowParams | FormData, requestOptions?:
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
/** 删除工作空间结果(单个文件) */
|
||||
export function deleteExecutionResult(data: { id: string; content: string }, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/ai-agent/flow/execution/deleteResult',
|
||||
method: 'delete',
|
||||
data,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除会话记录 */
|
||||
export function deleteExecutionSession(data: { id: string }, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/ai-agent/flow/execution/deleteSession',
|
||||
method: 'delete',
|
||||
data,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export interface SessionListResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: SessionListItem[];
|
||||
}
|
||||
|
||||
export interface SessionListItem {
|
||||
id: string;
|
||||
flowName: string;
|
||||
sessionId: string;
|
||||
createDate: string;
|
||||
status?: number; // 1=执行中, 2=成功, 3=失败
|
||||
}
|
||||
|
||||
/** 获取会话记录列表 */
|
||||
export function getSessionList(params?: Record<string, any>, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/ai-agent/flow/execution/sessionList',
|
||||
method: 'get',
|
||||
params,
|
||||
requestOptions,
|
||||
}) as Promise<SessionListResponse>;
|
||||
}
|
||||
|
||||
|
||||
@@ -119,18 +119,22 @@ export interface ModelModuleItem {
|
||||
/** 会话开关状态(列表接口返回,0 关 1 开;会话开关接口就绪后生效) */
|
||||
chatSessionEnabled?: number;
|
||||
enabled: number;
|
||||
/** 调用模式:0-同步 1-异步 2-流式 */
|
||||
callMode?: number;
|
||||
maxConcurrency: number;
|
||||
queueLimit: number;
|
||||
queueLimit?: number;
|
||||
timeoutMs?: number;
|
||||
timeoutSeconds?: number;
|
||||
expectedSeconds?: number;
|
||||
retryTimes: number;
|
||||
retryQueueMaxSeconds: number;
|
||||
retryQueueMaxSeconds?: number;
|
||||
autoCleanSeconds: number;
|
||||
remark?: string;
|
||||
headMsg?: string;
|
||||
headMsg?: string | Record<string, string>;
|
||||
form?: ModelFormEntry[] | Record<string, { value: string }>;
|
||||
requestMapping?: Record<string, unknown>;
|
||||
requiredFields?: string[];
|
||||
firstFrame?: string;
|
||||
lastFrame?: string;
|
||||
responseMapping?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -150,27 +154,32 @@ export interface CreateModelParams {
|
||||
operatorName?: string;
|
||||
baseUrl: string;
|
||||
httpMethod?: string;
|
||||
headMsg?: string;
|
||||
headMsg?: Record<string, string>;
|
||||
isPrivate: number;
|
||||
enabled: number;
|
||||
isChatModel: number;
|
||||
/** 调用模式:0-同步 1-异步 2-流式,默认0 */
|
||||
callMode: number;
|
||||
apiKey?: string;
|
||||
form: ModelFormEntry[];
|
||||
requestMapping?: Record<string, unknown>;
|
||||
requiredFields?: string[];
|
||||
firstFrame?: string;
|
||||
lastFrame?: string;
|
||||
responseMapping?: Record<string, unknown>;
|
||||
responseBody?: Record<string, unknown>;
|
||||
responseBody?: string;
|
||||
extendMapping?: Record<string, unknown>;
|
||||
responseTokenField?: string;
|
||||
tokenConfig?: Record<string, unknown>;
|
||||
queryConfig?: Record<string, unknown>;
|
||||
streamConfig?: Record<string, unknown>;
|
||||
maxConcurrency?: number;
|
||||
queueLimit?: number;
|
||||
timeoutSeconds: number;
|
||||
expectedSeconds: number;
|
||||
expectedSeconds?: number;
|
||||
retryTimes?: number;
|
||||
retryQueueMaxSeconds: number;
|
||||
retryQueueMaxSeconds?: number;
|
||||
autoCleanSeconds: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface ModelConfigTypeItem {
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import request from '/@/utils/request';
|
||||
|
||||
/** Token 映射(三个字段存的是对应响应映射中的 key,不是 JSON Path) */
|
||||
export interface TokenMapping {
|
||||
promptTokens: string;
|
||||
completionTokens: string;
|
||||
totalTokens: string;
|
||||
}
|
||||
|
||||
/** 异步任务映射 */
|
||||
export interface AsyncTaskMapping {
|
||||
url: string;
|
||||
httpMethod: string;
|
||||
requestHeadMapping: Record<string, unknown>;
|
||||
requestBodyMapping?: Record<string, unknown>;
|
||||
responseMapping: Record<string, unknown>;
|
||||
taskId: string;
|
||||
taskStatus: string;
|
||||
taskStatusPending: string;
|
||||
taskStatusRunning: string;
|
||||
taskStatusSuccess: string;
|
||||
taskStatusFailed: string;
|
||||
taskStatusCancel: string;
|
||||
taskStatusUnknown: string;
|
||||
}
|
||||
|
||||
/** 列表查询参数 */
|
||||
export interface ListModelManageParams {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
modelName?: string;
|
||||
modelType?: number;
|
||||
}
|
||||
|
||||
/** 列表项 */
|
||||
export interface ModelManageItem {
|
||||
id: string;
|
||||
tenantId: number;
|
||||
creator: string;
|
||||
createdAt: string;
|
||||
updater: string;
|
||||
updatedAt: string;
|
||||
deletedAt: null | string;
|
||||
modelSupplier: number;
|
||||
modelName: string;
|
||||
modelType: number;
|
||||
baseUrl: string;
|
||||
responseType: number;
|
||||
apiKey: string;
|
||||
enabled: boolean;
|
||||
chatModel?: boolean;
|
||||
maxConcurrency: number;
|
||||
maxTokens: number;
|
||||
tokenPredictPrice: number;
|
||||
requestHeadMapping: Record<string, unknown>;
|
||||
requestBodyMapping: Record<string, unknown>;
|
||||
requestBusinessFieldMapping?: Record<string, string>;
|
||||
error_message_mapping?: Record<string, unknown>;
|
||||
responseMapping: Record<string, unknown>;
|
||||
responseBodyMapping: Record<string, string>;
|
||||
responseBusinessFieldMapping?: Record<string, string>;
|
||||
minDuration?: number;
|
||||
priceConfig?: unknown;
|
||||
tokenMapping?: TokenMapping;
|
||||
asyncTaskMapping?: AsyncTaskMapping;
|
||||
lastFrame?: string;
|
||||
maxDuration: number;
|
||||
tokenPredictPriceUnit: string;
|
||||
// 以下旧字段兼容
|
||||
httpMethod?: string;
|
||||
systemModel?: boolean;
|
||||
PrivateModel?: boolean;
|
||||
invokeType?: number;
|
||||
requestMapping?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 列表响应 */
|
||||
export interface ListModelManageResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
list: ModelManageItem[];
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** 模型类型树节点(支持二级) */
|
||||
export interface ModelTypeTreeNode {
|
||||
value: number;
|
||||
label: string;
|
||||
children?: ModelTypeTreeNode[];
|
||||
}
|
||||
|
||||
/** 模型类型列表响应 */
|
||||
export interface ModelManageTypeResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
list: ModelTypeTreeNode[];
|
||||
};
|
||||
}
|
||||
|
||||
/** 供应商列表项 */
|
||||
export interface ModelSupplierItem {
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 供应商列表响应 */
|
||||
export interface ModelSupplierListResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
list: ModelSupplierItem[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型配置列表(v2)
|
||||
*/
|
||||
export function listModelManage(params: ListModelManageParams) {
|
||||
return request({
|
||||
url: '/model-gateway/model/manage/listModelManage',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型类型列表(v2,树形结构,支持一级/二级)
|
||||
*/
|
||||
export function getModelManageTypes() {
|
||||
return request({
|
||||
url: '/model-gateway/model/manage/modelType',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除模型配置
|
||||
*/
|
||||
export function deleteModelManage(id: string) {
|
||||
return request({
|
||||
url: '/model-gateway/model/manage/deleteModelManage',
|
||||
method: 'delete',
|
||||
data: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型供应商列表
|
||||
*/
|
||||
export function getModelSupplierList() {
|
||||
return request({
|
||||
url: '/model-gateway/model/manage/modelSupplier',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** 创建模型配置参数 */
|
||||
export interface CreateModelManageParams {
|
||||
modelSupplier: number;
|
||||
modelName: string;
|
||||
modelType: number;
|
||||
baseUrl: string;
|
||||
responseType: number;
|
||||
apiKey?: string;
|
||||
enabled?: boolean;
|
||||
chatModel?: boolean;
|
||||
maxConcurrency?: number;
|
||||
maxTokens?: number;
|
||||
tokenPredictPrice?: number;
|
||||
requestHeadMapping?: Record<string, unknown>;
|
||||
requestBodyMapping?: Record<string, unknown>;
|
||||
requestBusinessFieldMapping?: Record<string, string>;
|
||||
error_message_mapping?: Record<string, unknown>;
|
||||
responseMapping?: Record<string, unknown>;
|
||||
responseBodyMapping?: Record<string, string>;
|
||||
responseBusinessFieldMapping?: Record<string, string>;
|
||||
minDuration?: number;
|
||||
priceConfig?: unknown;
|
||||
tokenMapping?: TokenMapping;
|
||||
asyncTaskMapping?: AsyncTaskMapping;
|
||||
lastFrame?: string;
|
||||
maxDuration?: number;
|
||||
tokenPredictPriceUnit?: string;
|
||||
}
|
||||
|
||||
/** 更新模型配置参数 */
|
||||
export interface UpdateModelManageParams extends Partial<CreateModelManageParams> {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** 详情响应 */
|
||||
export interface ModelManageDetailResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: ModelManageItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型配置详情
|
||||
*/
|
||||
export function getModelManage(id: string) {
|
||||
return request({
|
||||
url: '/model-gateway/model/manage/getModelManage',
|
||||
method: 'get',
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建模型配置
|
||||
*/
|
||||
export function createModelManage(data: CreateModelManageParams) {
|
||||
return request({
|
||||
url: '/model-gateway/model/manage/createModelManage',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模型配置
|
||||
*/
|
||||
export function updateModelManage(data: UpdateModelManageParams) {
|
||||
return request({
|
||||
url: '/model-gateway/model/manage/updateModelManage',
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前会话模型(普通对话执行时使用的模型 id)
|
||||
* 返回兼容 v2 惯例:data.modelManage.id(亦兼容 data.id / data.modelId)
|
||||
*/
|
||||
export function getChatModel() {
|
||||
return request({
|
||||
url: '/model-gateway/model/manage/getChatModel',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export interface PromptItem {
|
||||
deletedAt?: string | null;
|
||||
nodeType: string;
|
||||
prompt: string;
|
||||
sourceType: number; // 0-自定义 1-公共
|
||||
sourceType: number; // 1-管理员 2-用户
|
||||
}
|
||||
|
||||
export interface PromptListResponse {
|
||||
@@ -23,6 +23,7 @@ export interface PromptListParams {
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
nodeType?: string;
|
||||
}
|
||||
|
||||
export interface CreatePromptParams {
|
||||
@@ -31,6 +32,23 @@ export interface CreatePromptParams {
|
||||
sourceType: number;
|
||||
}
|
||||
|
||||
export interface CheckIsSuperAdminResponse {
|
||||
isSuperAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface CreatePromptResponse {
|
||||
id?: number | string;
|
||||
tenantId?: number;
|
||||
creator?: string;
|
||||
createdAt?: string;
|
||||
updater?: string;
|
||||
updatedAt?: string;
|
||||
deletedAt?: string | null;
|
||||
nodeType?: string;
|
||||
prompt?: string;
|
||||
sourceType?: number;
|
||||
}
|
||||
|
||||
export interface UpdatePromptParams extends CreatePromptParams {
|
||||
id: number | string;
|
||||
}
|
||||
@@ -65,6 +83,17 @@ export function getNodeLibraryList() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提示词列表(根据节点类型)
|
||||
*/
|
||||
export function getPromptList(params: PromptListParams) {
|
||||
return request<PromptListResponse>({
|
||||
url: '/ai-agent/node/prompt/list',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户提示词列表
|
||||
*/
|
||||
@@ -80,7 +109,7 @@ export function getMyPromptList(params: PromptListParams) {
|
||||
* 创建提示词
|
||||
*/
|
||||
export function createPrompt(data: CreatePromptParams) {
|
||||
return request({
|
||||
return request<CreatePromptResponse>({
|
||||
url: '/ai-agent/node/prompt/create',
|
||||
method: 'post',
|
||||
data,
|
||||
@@ -108,3 +137,13 @@ export function deletePrompt(id: number | string) {
|
||||
data: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前用户是否为超级管理员
|
||||
*/
|
||||
export function checkIsSuperAdmin() {
|
||||
return request<CheckIsSuperAdminResponse>({
|
||||
url: '/admin-go/api/v1/system/user/checkIsSuperAdmin',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import request from '/@/utils/request';
|
||||
import type { RequestOptions } from '/@/utils/request';
|
||||
|
||||
// ===== 节点库(V2 结构,workflow 专用;creation 继续使用 creation/index.ts 的旧结构)=====
|
||||
export interface NodeLibraryPresetOption {
|
||||
value?: unknown;
|
||||
field: string;
|
||||
label: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
options?: Array<{
|
||||
key: string;
|
||||
value: string;
|
||||
config?: NodeLibraryPresetOption[] | null;
|
||||
}> | null;
|
||||
// 运行时已在用的扩展字段(补齐声明消除 TS 报错)
|
||||
isFormField?: boolean;
|
||||
constraint?: any;
|
||||
defaultValue?: any;
|
||||
}
|
||||
|
||||
export interface NodeLibraryItem {
|
||||
key: string;
|
||||
name: string;
|
||||
group: string;
|
||||
sort: number;
|
||||
desc?: string;
|
||||
batchExecOption: boolean;
|
||||
// 是否允许该节点内「引用上级节点输出」多选(true 时所有引用下拉可多选;节点库返回,仅 model 节点 true)
|
||||
isMultiParameter: boolean;
|
||||
// 前置/后置方法配置定义(结构同 presetOption,值为数组;仅 model 节点提供)
|
||||
preToolOption: NodeLibraryPresetOption[] | null;
|
||||
postToolOption: NodeLibraryPresetOption[] | null;
|
||||
skillOption: boolean;
|
||||
promptOption: boolean;
|
||||
negativePromptOption: boolean;
|
||||
isSaveFileOption: boolean;
|
||||
formConfigOption: boolean;
|
||||
modelConfigOption: boolean;
|
||||
// 节点自带输出项([{ field, label }]):空/null 表示无输出;非空时该节点可作为下游节点的引用来源
|
||||
outputField?: { field: string; label: string }[] | null;
|
||||
presetOption: NodeLibraryPresetOption[] | null;
|
||||
// 是否支持贴片布局(节点库返回,仅 model 节点 true):控制节点配置面板「贴片布局」开关显示
|
||||
patchLayout?: boolean;
|
||||
}
|
||||
|
||||
export interface NodeLibraryGroup {
|
||||
group: { key: string; name: string };
|
||||
nodes: NodeLibraryItem[];
|
||||
}
|
||||
|
||||
export function getNodeLibraryList() {
|
||||
return request({
|
||||
url: '/ai-agent/node/library/list',
|
||||
method: 'get',
|
||||
}) as Promise<{ code: number; message: string; data: { groups: NodeLibraryGroup[] } }>;
|
||||
}
|
||||
|
||||
// ===== 模型列表(不按类型过滤,显示全部)=====
|
||||
export interface WorkflowModelItem {
|
||||
id: string;
|
||||
modelName: string;
|
||||
modelType: number | string;
|
||||
baseUrl?: string;
|
||||
enabled?: number | boolean;
|
||||
isOwner?: number;
|
||||
// true = 系统内置模型(选它需填 API Key 转用户模型);false/缺省 = 用户模型
|
||||
systemModel?: boolean;
|
||||
// 模型请求参数模板(选择模型后渲染为递归表单,与 DSL modelRequestParams 结构一致)
|
||||
requestBodyMapping?: Record<string, any>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export function getWorkflowModelList(params?: {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
modelName?: string;
|
||||
isSameType?: boolean;
|
||||
modelType?: string | number;
|
||||
}) {
|
||||
return request({
|
||||
url: '/model-gateway/model/manage/listModelManage',
|
||||
method: 'get',
|
||||
params,
|
||||
}) as Promise<{ code: number; message: string; data: { list: WorkflowModelItem[]; total: number } }>;
|
||||
}
|
||||
|
||||
// ===== 技能列表 =====
|
||||
export interface WorkflowSkillItem {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export function getWorkflowSkillList(params?: { pageNum: number; pageSize: number; keyword?: string }) {
|
||||
return request({
|
||||
url: '/ai-agent/skill/user/listUser',
|
||||
method: 'get',
|
||||
params,
|
||||
}) as Promise<{ code: number; message: string; data: { list: WorkflowSkillItem[]; total: number } }>;
|
||||
}
|
||||
|
||||
// ===== 子流程:引入已有工作流(独立实现,不依赖 creation)=====
|
||||
export interface SubFlowWorkflowItem {
|
||||
id: string;
|
||||
// 后端列表接口返回 flowName(name 仅为前端兜底)
|
||||
name?: string;
|
||||
flowName?: string;
|
||||
description?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 获取"我的工作流"列表(IsOwn=true 只取本人创建,供 sub_flow 节点选择引入)
|
||||
// 响应结构多形态容错在调用方处理(扁平 list 优先,否则按 isAdmin 分支读取 listFlowTemplateRes / listFlowUserRes)
|
||||
export function getSubFlowWorkflowList(params?: {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
keyword?: string;
|
||||
IsOwn?: boolean;
|
||||
}) {
|
||||
return request({
|
||||
url: '/ai-agent/flow/user/list',
|
||||
method: 'get',
|
||||
params,
|
||||
}) as Promise<{ code: number; message: string; data: any }>;
|
||||
}
|
||||
|
||||
// 获取工作流详情(取开始节点 outputConfig 构建 sub_flow 引入参数)
|
||||
export function getSubFlowWorkflowDetail(id: string) {
|
||||
return request({
|
||||
url: '/ai-agent/flow/user/get',
|
||||
method: 'get',
|
||||
params: { id },
|
||||
}) as Promise<{ code: number; message: string; data: any }>;
|
||||
}
|
||||
|
||||
// 工作空间文件下载(首页工作流/工作空间专用):与 creation 的 downloadToFile 同一后端接口,
|
||||
// 但工作空间文件可能较大(视频/音频),下载超时放宽到 5 分钟;不改动 creation 共享 API 的超时
|
||||
export function downloadWorkspaceFile(data: { fileURL: string }, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/oss/file/downloadToBrowser',
|
||||
method: 'post',
|
||||
data,
|
||||
responseType: 'blob',
|
||||
timeout: 5 * 60 * 1000,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
// 与后端 UpdateFlowUserReq 对齐的更新树:id/flowName/description/flowContent/subFlows
|
||||
// (subFlows 递归嵌套,一次 PUT 更新整棵「主流程 + 嵌套子流程」树);独立实现,不动内容创作的 API
|
||||
export interface UpdateFlowUserTree {
|
||||
id: string;
|
||||
flowName: string;
|
||||
description: string;
|
||||
flowContent: any;
|
||||
subFlows?: UpdateFlowUserTree[];
|
||||
}
|
||||
|
||||
export function updateWorkflowFromTemplate(data: UpdateFlowUserTree, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/ai-agent/flow/user/update',
|
||||
method: 'put',
|
||||
data,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import request, { type RequestOptions } from '/@/utils/request';
|
||||
|
||||
// ===== 会话管理(/ai-agent/session/*)=====
|
||||
// 后端结构:
|
||||
// ListSessionReq { page } → ListSessionRes { list: VOSession[], total }
|
||||
// GetSessionInfoReq { page, sessionId } → GetSessionInfoRes { list: VOSessionInfoResult[], total }
|
||||
// DeleteSessionReq { sessionId }(POST,级联删除普通对话结果,工作流结果保留)
|
||||
// 说明:会话为「会话 → 会话内结果(工作流/普通对话混排,按时间倒序)」模型。
|
||||
|
||||
export interface VOSession {
|
||||
id: string;
|
||||
sessionName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface VOSessionInfoResult {
|
||||
id: string;
|
||||
type: 'workflow' | 'chat';
|
||||
status: number; // 1-运行中, 2-成功, 3-失败
|
||||
flowId: string;
|
||||
requestParams: Record<string, any>;
|
||||
resultFileUrl: string; // 产出文件地址(单文件;工作流执行成功时后端填写,空串表示无产出)
|
||||
resultContent: string; // 回复文本内容(非文件地址)
|
||||
totalTokens: number;
|
||||
totalFee: number;
|
||||
errorMsg: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function getSessionListV2(params?: Record<string, any>, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/ai-agent/session/list',
|
||||
method: 'get',
|
||||
params,
|
||||
requestOptions,
|
||||
}) as Promise<{ code: number; message: string; data: { list: VOSession[]; total: number } }>;
|
||||
}
|
||||
|
||||
export function getSessionResults(sessionId: string, params?: Record<string, any>, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/ai-agent/session/get',
|
||||
method: 'get',
|
||||
params: { sessionId, ...params },
|
||||
requestOptions,
|
||||
}) as Promise<{ code: number; message: string; data: { list: VOSessionInfoResult[]; total: number } }>;
|
||||
}
|
||||
|
||||
export function deleteSessionV2(sessionId: string, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/ai-agent/session/delete',
|
||||
method: 'delete',
|
||||
data: { sessionId },
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除会话内的对话记录(AI 回复 / 用户问题单条或多条),传 Ids 数组,每项为 { id, type }
|
||||
// 注意:记录 id 为 19 位雪花大整数,超出 JS Number 安全范围,必须用 string 形式传递,否则精度丢失
|
||||
export function deleteSessionRecord(ids: { id: string; type: string }[], requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/ai-agent/session/deleteRecord',
|
||||
method: 'post',
|
||||
data: { ids },
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 工作空间结果列表(/ai-agent/session/resultList)=====
|
||||
// 与内容创作 execution/list 同结构:data.tree(按 createDate 分组)+ data.imgAddressPrefix
|
||||
export interface ExecutionItem {
|
||||
// 兼容旧平铺结构;resultList 新结构下 items 无独立 id,删除按流程记录 flow.Id
|
||||
id?: string;
|
||||
timestamp?: string;
|
||||
content: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
// resultList 新两级结构:createDate → flows(流程分组)→ items(文件)
|
||||
export interface ExecutionFlow {
|
||||
flowName: string;
|
||||
Id: string; // 流程结果记录 id(雪花)
|
||||
sessionId: string;
|
||||
items: ExecutionItem[];
|
||||
}
|
||||
|
||||
export interface ExecutionTreeItem {
|
||||
createDate: string;
|
||||
// 新结构:日期下按流程分组;兼容旧平铺结构(无 flows 时回落 items)
|
||||
flows?: ExecutionFlow[];
|
||||
items?: ExecutionItem[];
|
||||
}
|
||||
|
||||
export interface ExecutionListData {
|
||||
tree: ExecutionTreeItem[];
|
||||
imgAddressPrefix: string;
|
||||
}
|
||||
|
||||
export interface ExecutionListResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
data: ExecutionListData;
|
||||
}
|
||||
|
||||
/** 获取工作空间结果列表(结果树) */
|
||||
export function getWorkspaceResultList(params?: Record<string, any>, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/ai-agent/session/resultList',
|
||||
method: 'get',
|
||||
params,
|
||||
requestOptions,
|
||||
}) as Promise<ExecutionListResponse>;
|
||||
}
|
||||
|
||||
/** 删除工作空间结果(单个文件)——工作流域独立接口,不依赖内容创作域 */
|
||||
export function deleteWorkspaceResult(data: { id: string; content: string }, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/ai-agent/session/resultDelete',
|
||||
method: 'delete',
|
||||
data,
|
||||
requestOptions,
|
||||
});
|
||||
}
|
||||
@@ -50,3 +50,12 @@ export function deleteTenant(ids: number[]) {
|
||||
data: { ids },
|
||||
});
|
||||
}
|
||||
|
||||
// 获取租户详情(含名称和余额)
|
||||
export function getTenantDetails(tenantId: number) {
|
||||
return request({
|
||||
url: '/admin-go/api/v1/system/tenant/getTenantDetails',
|
||||
method: 'get',
|
||||
params: { tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import request from '/@/utils/request';
|
||||
|
||||
/** 计价对象枚举 */
|
||||
export function getSubjects() {
|
||||
return request({
|
||||
url: '/shop-user-trade/pricing/controller/subjects',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** 计价配置详情 */
|
||||
export function getPricingConfig(params: { subjectType: string; subjectId: string }) {
|
||||
return request({
|
||||
url: '/shop-user-trade/pricing/controller/config/get',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/** 计价对象可选计费方式(workflow/business 使用;model 计价方式由配置规则决定,不调用) */
|
||||
export function getChargeModes(params: { subjectType: string; subjectId: string }) {
|
||||
return request({
|
||||
url: '/shop-user-trade/pricing/controller/subjects/charge-modes',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/** 新增/更新计价配置(id > 0 更新,0 新增;rules 传费率对象;id 兼容雪花字符串避免精度丢失) */
|
||||
export function savePricingConfig(data: {
|
||||
id: string | number;
|
||||
subjectType: string;
|
||||
subjectId: string;
|
||||
rules: object;
|
||||
minBalance?: number;
|
||||
currency?: string;
|
||||
enabled?: number;
|
||||
}) {
|
||||
return request({
|
||||
url: '/shop-user-trade/pricing/controller/config/save',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="dialogTitle"
|
||||
width="560px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@update:model-value="$emit('update:visible', $event)"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" label-width="110px" size="small">
|
||||
<!-- ===== 基础信息 ===== -->
|
||||
<el-divider content-position="left">基础信息</el-divider>
|
||||
|
||||
<el-form-item v-if="showKeyField" label="字段英文名">
|
||||
<el-input v-model="form.nodeKey" placeholder="字段名称(key)" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!isArrayElement" label="字段中文名">
|
||||
<el-input v-model="form.label" placeholder="表单中显示的字段名称" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!isArrayElement" label="字段描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="此字段的用途说明" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="字段类型">
|
||||
<el-select v-model="form.jsonType" @change="onJsonTypeChange">
|
||||
<el-option label="字符串" value="string" />
|
||||
<el-option label="数字" value="number" />
|
||||
<el-option label="布尔" value="boolean" />
|
||||
<el-option label="空值" value="null" />
|
||||
<el-option label="对象" value="object" />
|
||||
<el-option label="数组" value="array" />
|
||||
</el-select>
|
||||
<div class="form-hint">JSON 数据类型</div>
|
||||
</el-form-item>
|
||||
<!-- ===== 表单属性 & 约束规则(仅标量类型) ===== -->
|
||||
<template v-if="jsonTypeIsScalar">
|
||||
<el-divider content-position="left">表单属性</el-divider>
|
||||
|
||||
<el-form-item label="表单显示">
|
||||
<el-switch v-model="form.isForm" />
|
||||
<span class="form-hint">{{ form.isForm ? '在表单中展示' : '隐藏字段' }}</span>
|
||||
</el-form-item>
|
||||
<template v-if="form.isForm">
|
||||
<el-form-item label="表单控件">
|
||||
<el-select v-model="form.fieldType" @change="onTypeChange">
|
||||
<el-option v-for="opt in fieldTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<div class="form-hint">此字段在表单中以什么组件展示</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="必填项">
|
||||
<el-switch v-model="form.required" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item label="默认值">
|
||||
<el-input v-model="form.defaultValue" placeholder="留空则无默认值" clearable />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">约束规则</el-divider>
|
||||
|
||||
<el-form-item v-if="form.fieldType === 'select'" label="选项列表">
|
||||
<div class="options-editor">
|
||||
<div v-for="(opt, idx) in form.options" :key="idx" class="option-row">
|
||||
<el-input v-model="form.options[idx]" size="small" placeholder="选项值" />
|
||||
<el-button size="small" text type="danger" @click="form.options.splice(idx, 1)">×</el-button>
|
||||
</div>
|
||||
<el-button size="small" class="add-option-btn" @click="form.options.push('')"
|
||||
><el-icon><Plus /></el-icon> 添加选项</el-button
|
||||
>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="form.fieldType === 'string' || form.fieldType === 'textarea'">
|
||||
<el-form-item label="最小长度"
|
||||
><el-input-number
|
||||
v-model="form.constraint.minLength"
|
||||
:min="0"
|
||||
:step="100"
|
||||
controls-position="right"
|
||||
placeholder="不限"
|
||||
style="width: 100%"
|
||||
/></el-form-item>
|
||||
<el-form-item label="最大长度"
|
||||
><el-input-number
|
||||
v-model="form.constraint.maxLength"
|
||||
:min="0"
|
||||
:step="1000"
|
||||
controls-position="right"
|
||||
placeholder="不限"
|
||||
style="width: 100%"
|
||||
/></el-form-item>
|
||||
<el-form-item label="正则验证"><el-input v-model="form.constraint.pattern" placeholder="如 ^https?://" clearable /></el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="form.fieldType === 'upload'">
|
||||
<el-form-item label="总数量">
|
||||
<el-input-number v-model="form.constraint.uploadTotalMaxCount" :min="1" controls-position="right" placeholder="不限" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="总容量(MB)">
|
||||
<el-input-number v-model="form.constraint.uploadTotalMaxSize" :min="1" controls-position="right" placeholder="不限" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">按格式限制(可选)</el-divider>
|
||||
<div class="upload-rules">
|
||||
<div v-for="(rule, idx) in uploadRules" :key="idx" class="upload-rule-row">
|
||||
<el-input v-model="rule.format" size="small" placeholder="格式(如 jpg)" style="width: 90px" />
|
||||
<el-input-number v-model="rule.maxSize" :min="1" controls-position="right" size="small" placeholder="大小(MB)" style="width: 140px" />
|
||||
<el-input-number v-model="rule.maxCount" :min="1" controls-position="right" size="small" placeholder="数量" style="width: 110px" />
|
||||
<el-button size="small" text type="danger" @click="removeUploadRule(idx)">×</el-button>
|
||||
</div>
|
||||
<el-button size="small" class="add-rule-btn" @click="addUploadRule"
|
||||
><el-icon><Plus /></el-icon> 添加规则</el-button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="form.fieldType === 'number'">
|
||||
<el-form-item label="最小值"
|
||||
><el-input-number
|
||||
v-model="form.constraint.min"
|
||||
:min="Number.MIN_SAFE_INTEGER"
|
||||
:max="Number.MAX_SAFE_INTEGER"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/></el-form-item>
|
||||
<el-form-item label="最大值"
|
||||
><el-input-number
|
||||
v-model="form.constraint.max"
|
||||
:min="Number.MIN_SAFE_INTEGER"
|
||||
:max="Number.MAX_SAFE_INTEGER"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/></el-form-item>
|
||||
<el-form-item label="数值类型"
|
||||
><el-select v-model="form.constraint.numberType" clearable placeholder="不限"
|
||||
><el-option label="整数" value="integer" /><el-option label="浮点数" value="float" /></el-select
|
||||
></el-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button :disabled="!hasConfig" @click="handleRemove">清除配置</el-button>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue';
|
||||
import { ElMessageBox } from 'element-plus';
|
||||
import { Plus } from '@element-plus/icons-vue';
|
||||
|
||||
interface UploadRule {
|
||||
format: string;
|
||||
maxSize?: number;
|
||||
maxCount?: number;
|
||||
}
|
||||
|
||||
interface FieldFormData {
|
||||
nodeKey: string;
|
||||
nodeId: string;
|
||||
jsonType: string;
|
||||
fieldType: string;
|
||||
label: string;
|
||||
description: string;
|
||||
role: string;
|
||||
isForm: boolean;
|
||||
required: boolean;
|
||||
defaultValue: string;
|
||||
|
||||
options: string[];
|
||||
constraint: {
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
numberType?: string;
|
||||
accept?: string;
|
||||
maxSize?: number;
|
||||
maxCount?: number;
|
||||
uploadRules?: UploadRule[];
|
||||
uploadTotalMaxCount?: number;
|
||||
uploadTotalMaxSize?: number;
|
||||
};
|
||||
_createParentId?: string;
|
||||
_isArrayParent?: boolean;
|
||||
_childrenCount?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
formData: FieldFormData | null;
|
||||
fieldTypeOptions: { label: string; value: string }[];
|
||||
hasConfig: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', v: boolean): void;
|
||||
(e: 'save', form: FieldFormData): void;
|
||||
(e: 'remove'): void;
|
||||
}>();
|
||||
|
||||
const form = reactive<FieldFormData>({
|
||||
nodeKey: '',
|
||||
nodeId: '',
|
||||
jsonType: 'string',
|
||||
fieldType: 'string',
|
||||
label: '',
|
||||
description: '',
|
||||
role: '',
|
||||
isForm: true,
|
||||
required: false,
|
||||
defaultValue: '',
|
||||
|
||||
options: [],
|
||||
constraint: {},
|
||||
});
|
||||
|
||||
const uploadRules = ref<UploadRule[]>([]);
|
||||
const prevJsonType = ref('');
|
||||
|
||||
const isCreateMode = computed(() => !!form._createParentId);
|
||||
const jsonTypeIsScalar = computed(() => form.jsonType !== 'object' && form.jsonType !== 'array');
|
||||
const isArrayElement = computed(() => isCreateMode.value && form._isArrayParent);
|
||||
const showKeyField = computed(() => !isArrayElement.value);
|
||||
const dialogTitle = computed(() => {
|
||||
return isCreateMode.value ? (form._isArrayParent ? '添加元素' : '添加字段 —— ' + (form.nodeKey || '新字段')) : '字段配置 —— ' + form.nodeKey;
|
||||
});
|
||||
|
||||
|
||||
|
||||
function resetForm() {
|
||||
form.nodeKey = '';
|
||||
form.nodeId = '';
|
||||
form.jsonType = 'string';
|
||||
form.fieldType = 'string';
|
||||
form.label = '';
|
||||
form.description = '';
|
||||
form.role = '';
|
||||
form.isForm = true;
|
||||
form.required = false;
|
||||
form.defaultValue = '';
|
||||
form.options = [];
|
||||
form.constraint = {};
|
||||
// @ts-ignore
|
||||
form._createParentId = undefined;
|
||||
// @ts-ignore
|
||||
form._isArrayParent = false;
|
||||
uploadRules.value = [];
|
||||
}
|
||||
|
||||
function loadForm(data: FieldFormData | null) {
|
||||
resetForm();
|
||||
if (!data) return;
|
||||
form.nodeId = data.nodeId;
|
||||
form.nodeKey = data.nodeKey || '';
|
||||
prevJsonType.value = data.jsonType || 'string';
|
||||
form.jsonType = data.jsonType || 'string';
|
||||
form.fieldType = data.fieldType;
|
||||
form.label = data.label || '';
|
||||
form.description = data.description || '';
|
||||
form.role = data.role || '';
|
||||
form.isForm = data.isForm ?? true;
|
||||
form.required = data.required || false;
|
||||
form.defaultValue = data.defaultValue || '';
|
||||
form.options = data.options ? [...data.options] : [];
|
||||
form.constraint = data.constraint ? { ...data.constraint } : {};
|
||||
// @ts-ignore
|
||||
form._createParentId = data._createParentId;
|
||||
// @ts-ignore
|
||||
form._isArrayParent = data._isArrayParent ?? false;
|
||||
// @ts-ignore
|
||||
form._childrenCount = data._childrenCount ?? 0;
|
||||
|
||||
// Load upload rules — support both old (accept string) and new format
|
||||
if (data.constraint?.uploadRules && data.constraint.uploadRules.length > 0) {
|
||||
uploadRules.value = data.constraint.uploadRules.map((r) => ({ ...r }));
|
||||
} else if (data.constraint?.accept) {
|
||||
const formats = data.constraint.accept
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (formats.length > 0) {
|
||||
uploadRules.value = formats.map((f) => ({
|
||||
format: f,
|
||||
maxSize: data.constraint?.maxSize,
|
||||
maxCount: data.constraint?.maxCount,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncUploadRulesToConstraint() {
|
||||
if (uploadRules.value.length > 0 && uploadRules.value.some((r) => r.format)) {
|
||||
form.constraint.uploadRules = uploadRules.value
|
||||
.filter((r) => r.format)
|
||||
.map((r) => ({ format: r.format, maxSize: r.maxSize, maxCount: r.maxCount }));
|
||||
} else {
|
||||
delete form.constraint.uploadRules;
|
||||
}
|
||||
}
|
||||
|
||||
function addUploadRule() {
|
||||
uploadRules.value.push({ format: '', maxSize: undefined, maxCount: undefined });
|
||||
}
|
||||
|
||||
function removeUploadRule(idx: number) {
|
||||
uploadRules.value.splice(idx, 1);
|
||||
}
|
||||
|
||||
function onJsonTypeChange(val: string) {
|
||||
const childrenCount = (form as any)._childrenCount || 0;
|
||||
const prevType = prevJsonType.value;
|
||||
|
||||
const m: Record<string, string> = { string: 'string', number: 'number', boolean: 'switch', null: 'string', object: 'string', array: 'string' };
|
||||
form.fieldType = m[val] || 'string';
|
||||
|
||||
if (!form._createParentId && childrenCount > 0 && prevType && prevType !== val) {
|
||||
ElMessageBox.confirm('更改字段类型将删除该节点下所有子节点数据,是否继续?', '确认操作', {
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
draggable: true,
|
||||
})
|
||||
.then(() => {
|
||||
prevJsonType.value = val;
|
||||
})
|
||||
.catch(() => {
|
||||
form.jsonType = prevType;
|
||||
});
|
||||
} else {
|
||||
prevJsonType.value = val;
|
||||
}
|
||||
}
|
||||
|
||||
function onTypeChange() {
|
||||
if (form.fieldType !== 'upload') {
|
||||
delete form.constraint.accept;
|
||||
delete form.constraint.maxSize;
|
||||
delete form.constraint.maxCount;
|
||||
delete form.constraint.uploadRules;
|
||||
uploadRules.value = [];
|
||||
}
|
||||
if (form.fieldType !== 'string' && form.fieldType !== 'textarea') {
|
||||
delete form.constraint.minLength;
|
||||
delete form.constraint.maxLength;
|
||||
delete form.constraint.pattern;
|
||||
}
|
||||
if (form.fieldType !== 'number') {
|
||||
delete form.constraint.min;
|
||||
delete form.constraint.max;
|
||||
delete form.constraint.numberType;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) loadForm(props.formData);
|
||||
}
|
||||
);
|
||||
|
||||
function handleSave() {
|
||||
syncUploadRulesToConstraint();
|
||||
emit('save', { ...form, constraint: { ...form.constraint } });
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
emit('remove');
|
||||
}
|
||||
function handleCancel() {
|
||||
emit('update:visible', false);
|
||||
}
|
||||
function handleClosed() {
|
||||
resetForm();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.form-hint {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.options-editor {
|
||||
width: 100%;
|
||||
.option-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.add-option-btn {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-rules {
|
||||
width: 100%;
|
||||
.upload-rule-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.add-rule-btn {
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="json-node">
|
||||
<template v-if="node.type === 'object'">
|
||||
<div class="node-row node-row-object" :class="{ 'is-collapsed': collapsed }" :style="{ paddingLeft: depth * 20 + 'px' }">
|
||||
<span class="collapse-btn" @click="toggleCollapse">
|
||||
<el-icon><CaretRight v-if="collapsed" /><CaretBottom v-else /></el-icon>
|
||||
</span>
|
||||
<span class="key-label">{{ node.key }}</span>
|
||||
<span class="colon">:</span>
|
||||
<span class="type-label">object</span>
|
||||
<span class="node-badge badge-object">{...}</span>
|
||||
<span class="node-count">{{ node.children.length }} 项</span>
|
||||
<span v-if="node.config?.label" class="node-label-tag" :title="node.config.label">{{ node.config.label }}</span>
|
||||
<el-button size="small" text class="node-action-btn always-visible" @click.stop="emit('add-requested', node._id)">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-button>
|
||||
<el-button size="small" text class="node-action-btn field-btn" @click.stop="onOpenFieldDialog?.(node._id)" title="字段配置"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><rect x="2" y="4" width="20" height="16" rx="2"/><circle cx="7" cy="9" r="1.5" fill="#fff"/><circle cx="12" cy="9" r="1.5" fill="#fff"/><circle cx="17" cy="9" r="1.5" fill="#fff"/><path d="M4 14h16v1H4zm0 3h12v1H4z" fill="#fff"/></svg></el-button>
|
||||
<el-button size="small" text class="node-action-btn copy-btn" @click.stop="copyNodeJson" title="复制 JSON">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
||||
</el-button>
|
||||
<el-button v-if="!isRoot && parentCanDelete" size="small" text class="node-action-btn node-action-delete" @click.stop="deleteSelf">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-show="!collapsed" class="node-children" :style="{ marginLeft: depth * 20 + 18 + 'px' }">
|
||||
<JsonNode v-for="child in node.children" :key="child._id" :node="child" :depth="depth + 1" :is-root="false" :parent-type="'object'" :parent-can-delete="true" @delete-node="removeChild(child._id)" @change="emitChange" @add-requested="(id: string) => emit('add-requested', id)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="node.type === 'array'">
|
||||
<div class="node-row node-row-array" :class="{ 'is-collapsed': collapsed }" :style="{ paddingLeft: depth * 20 + 'px' }">
|
||||
<span class="collapse-btn" @click="toggleCollapse">
|
||||
<el-icon><CaretRight v-if="collapsed" /><CaretBottom v-else /></el-icon>
|
||||
</span>
|
||||
<span class="key-label">{{ node.key }}</span>
|
||||
<span class="colon">:</span>
|
||||
<span class="type-label">array</span>
|
||||
<span class="node-badge badge-array">[...]</span>
|
||||
<span class="node-count">{{ node.children.length }} 项</span>
|
||||
<span v-if="node.config?.label" class="node-label-tag" :title="node.config.label">{{ node.config.label }}</span>
|
||||
<el-button size="small" text class="node-action-btn always-visible" @click.stop="emit('add-requested', node._id)">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-button>
|
||||
<el-button size="small" text class="node-action-btn field-btn" @click.stop="onOpenFieldDialog?.(node._id)" title="字段配置"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><rect x="2" y="4" width="20" height="16" rx="2"/><circle cx="7" cy="9" r="1.5" fill="#fff"/><circle cx="12" cy="9" r="1.5" fill="#fff"/><circle cx="17" cy="9" r="1.5" fill="#fff"/><path d="M4 14h16v1H4zm0 3h12v1H4z" fill="#fff"/></svg></el-button>
|
||||
<el-button size="small" text class="node-action-btn copy-btn" @click.stop="copyNodeJson" title="复制 JSON">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
||||
</el-button>
|
||||
<el-button v-if="!isRoot && parentCanDelete" size="small" text class="node-action-btn node-action-delete" @click.stop="deleteSelf">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-show="!collapsed" class="node-children" :style="{ marginLeft: depth * 20 + 18 + 'px' }">
|
||||
<JsonNode v-for="child in node.children" :key="child._id" :node="child" :depth="depth + 1" :is-root="false" :parent-type="'array'" :parent-can-delete="true" @delete-node="removeChild(child._id)" @change="emitChange" @add-requested="(id: string) => emit('add-requested', id)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="node-row node-row-primitive" :style="{ paddingLeft: depth * 20 + 'px' }">
|
||||
<span class="indent-placeholder" v-if="depth > 0"></span>
|
||||
<span class="key-label">{{ node.key }}</span>
|
||||
<span class="colon">:</span>
|
||||
<span class="type-label">{{ node.type }}</span>
|
||||
<template v-if="node.type === 'string'">
|
||||
<span class="value-display">{{ node.primitiveValue || '(空)' }}</span>
|
||||
</template>
|
||||
<template v-else-if="node.type === 'number'">
|
||||
<span class="value-display">{{ node.primitiveValue }}</span>
|
||||
</template>
|
||||
<template v-else-if="node.type === 'boolean'">
|
||||
<el-tag :type="node.primitiveValue ? 'success' : 'info'" size="small" effect="plain">{{ node.primitiveValue ? 'true' : 'false' }}</el-tag>
|
||||
</template>
|
||||
<span v-else-if="node.type === 'null'" class="null-label">null</span>
|
||||
<span v-if="node.config?.label" class="node-label-tag" :title="node.config.label">{{ node.config.label }}</span>
|
||||
<el-button size="small" text class="node-action-btn field-btn" @click.stop="onOpenFieldDialog?.(node._id)" title="字段配置"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><rect x="2" y="4" width="20" height="16" rx="2"/><circle cx="7" cy="9" r="1.5" fill="#fff"/><circle cx="12" cy="9" r="1.5" fill="#fff"/><circle cx="17" cy="9" r="1.5" fill="#fff"/><path d="M4 14h16v1H4zm0 3h12v1H4z" fill="#fff"/></svg></el-button>
|
||||
<el-button size="small" text class="node-action-btn copy-btn" @click.stop="copyNodeJson" title="复制 JSON">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
||||
</el-button>
|
||||
<el-button v-if="!isRoot && parentCanDelete" size="small" text class="node-action-btn node-action-delete" @click="deleteSelf">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, inject } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Plus, Delete, CaretRight, CaretBottom } from '@element-plus/icons-vue';
|
||||
|
||||
defineOptions({ name: 'JsonNode' });
|
||||
|
||||
interface JsonNodeData {
|
||||
_id: string; key: string; keyEditable: boolean;
|
||||
type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array';
|
||||
primitiveValue: string | number | boolean | null;
|
||||
children: JsonNodeData[];
|
||||
config?: {
|
||||
fieldType?: string;
|
||||
label?: string;
|
||||
role?: string;
|
||||
isForm?: boolean;
|
||||
required?: boolean;
|
||||
defaultValue?: string | number;
|
||||
options?: string[];
|
||||
constraint?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
node: JsonNodeData; depth: number; isRoot?: boolean;
|
||||
parentType?: 'object' | 'array' | 'root'; parentCanDelete?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete-node'): void;
|
||||
(e: 'change'): void;
|
||||
(e: 'add-requested', parentNodeId: string): void;
|
||||
}>();
|
||||
const onOpenFieldDialog = inject<((nodeId: string) => void) | undefined>("onOpenFieldDialog");
|
||||
|
||||
const collapsed = ref(false);
|
||||
function toggleCollapse() { collapsed.value = !collapsed.value; }
|
||||
|
||||
function nodeToValue(node: JsonNodeData): any {
|
||||
if (node.type === 'object') { const obj: Record<string, any> = {}; for (const c of node.children) obj[c.key] = nodeToValue(c); return obj; }
|
||||
if (node.type === 'array') return node.children.map(c => nodeToValue(c));
|
||||
if (node.type === 'null') return null;
|
||||
if (node.type === 'number') return Number(node.primitiveValue);
|
||||
if (node.type === 'boolean') return node.primitiveValue === true || node.primitiveValue === 'true';
|
||||
return String(node.primitiveValue);
|
||||
}
|
||||
|
||||
function copyNodeJson() {
|
||||
const val = nodeToValue(props.node);
|
||||
const json = JSON.stringify(val, null, 2);
|
||||
navigator.clipboard.writeText(json).then(() => {
|
||||
ElMessage.success('JSON 已复制到剪贴板');
|
||||
}).catch(() => {
|
||||
// Fallback
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = json;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
ElMessage.success('JSON 已复制到剪贴板');
|
||||
});
|
||||
}
|
||||
|
||||
function removeChild(childId: string) {
|
||||
const n = props.node;
|
||||
const idx = n.children.findIndex((c) => c._id === childId);
|
||||
if (idx === -1) return;
|
||||
n.children.splice(idx, 1);
|
||||
if (n.type === 'array') {
|
||||
n.children.forEach((child, i) => { child.key = String(i); });
|
||||
}
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function deleteSelf() { emit('delete-node'); }
|
||||
function emitChange() { emit('change'); }
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.json-node {
|
||||
.node-row {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 3px 4px; border-radius: 3px; transition: background 0.15s;
|
||||
min-height: 30px; flex-wrap: nowrap;
|
||||
&:hover { background: #f5f7fa;
|
||||
.node-action-btn:not(.always-visible) { opacity: 1; }
|
||||
}
|
||||
.collapse-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 18px; height: 18px; cursor: pointer; color: #94a3b8;
|
||||
flex-shrink: 0; font-size: 12px;
|
||||
&:hover { color: #3b82f6; }
|
||||
}
|
||||
.indent-placeholder { display: inline-block; width: 18px; flex-shrink: 0; }
|
||||
.key-label { font-family: 'Consolas', monospace; font-size: 13px; color: #881391; font-weight: 500; white-space: nowrap; min-width: 20px; max-width: 180px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; cursor: default; }
|
||||
.colon { color: #94a3b8; font-weight: 600; flex-shrink: 0; margin-right: 2px; }
|
||||
.type-label {
|
||||
font-family: 'Consolas', monospace; font-size: 11px; color: #64748b;
|
||||
background: #f1f5f9; padding: 1px 5px; border-radius: 3px;
|
||||
flex-shrink: 0; line-height: 18px;
|
||||
}
|
||||
.node-badge {
|
||||
font-size: 11px; padding: 1px 6px; border-radius: 3px; font-weight: 500; flex-shrink: 0;
|
||||
&.badge-object { color: #2563eb; background: #eff6ff; }
|
||||
&.badge-array { color: #7c3aed; background: #f5f3ff; }
|
||||
}
|
||||
.node-count { font-size: 11px; color: #94a3b8; flex-shrink: 0; }
|
||||
.node-label-tag {
|
||||
font-size: 11px; color: #0891b2; background: #ecfeff;
|
||||
padding: 0 6px; border-radius: 3px; white-space: nowrap;
|
||||
max-width: 120px; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0;
|
||||
}
|
||||
.value-display {
|
||||
font-family: 'Consolas', monospace; font-size: 13px; color: #475569;
|
||||
padding: 0 6px; min-width: 60px; flex: 1;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.null-label { font-size: 13px; color: #94a3b8; font-style: italic; flex: 1; }
|
||||
.node-action-btn { opacity: 0; transition: opacity 0.15s; margin-left: 2px; flex-shrink: 0; font-size: 14px; padding: 2px; }
|
||||
.node-action-btn.always-visible { opacity: 1; }
|
||||
&:hover .node-action-btn { opacity: 1; }
|
||||
.node-action-delete { color: #ef4444; }
|
||||
}
|
||||
.node-row-object, .node-row-array {
|
||||
background: #fafbfc; border: 1px solid transparent;
|
||||
&:hover { border-color: #e2e8f0; }
|
||||
&.is-collapsed { border-color: transparent; background: transparent; }
|
||||
}
|
||||
.node-children {
|
||||
border-left: 1px solid #e2e8f0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,417 @@
|
||||
<template>
|
||||
<div class="json-editor-tree">
|
||||
<div class="tree-root">
|
||||
<JsonNode
|
||||
:node="rootNode" :depth="0" :is-root="true"
|
||||
@change="emitChange"
|
||||
@add-requested="openAddChildDialog"
|
||||
/>
|
||||
</div>
|
||||
<FieldConfigDialog
|
||||
v-model:visible="fieldDialog.visible"
|
||||
:form-data="fieldDialog.formData"
|
||||
:field-type-options="fieldDialog.typeOptions"
|
||||
:has-config="fieldDialog.hasConfig"
|
||||
@save="handleFieldSave"
|
||||
@remove="handleFieldRemove"
|
||||
/>
|
||||
<el-dialog v-model="showPasteDialog" title="粘贴 JSON" width="560px" :close-on-click-modal="false" destroy-on-close>
|
||||
<div class="paste-tip">
|
||||
<el-alert type="info" :closable="false" show-icon>
|
||||
<template #default>
|
||||
将 JSON 字符串粘贴到下方文本框,系统将解析并<strong>替换</strong>当前节点的内容。
|
||||
(将替换整个根节点)
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="pasteJsonText"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
placeholder="例如: { "name": "test", "age": 18 }"
|
||||
:autosize="{ minRows: 8, maxRows: 24 }"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="showPasteDialog = false; pasteJsonText = ''">取消</el-button>
|
||||
<el-button type="primary" @click="handlePasteJson">解析并替换</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, provide } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import JsonNode from './JsonNode.vue';
|
||||
import FieldConfigDialog from './FieldConfigDialog.vue';
|
||||
|
||||
defineOptions({ name: 'JsonEditor' });
|
||||
|
||||
const props = defineProps<{ modelValue: any }>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: any): void;
|
||||
(e: 'change', value: any): void;
|
||||
}>();
|
||||
|
||||
// --- types ---
|
||||
|
||||
interface FieldConfig {
|
||||
fieldType?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
role?: string;
|
||||
isForm?: boolean;
|
||||
required?: boolean;
|
||||
defaultValue?: string | number;
|
||||
options?: string[];
|
||||
constraint?: {
|
||||
minLength?: number; maxLength?: number; pattern?: string;
|
||||
min?: number; max?: number; numberType?: string;
|
||||
accept?: string; maxSize?: number; maxCount?: number;
|
||||
uploadRules?: Array<{ format: string; maxSize?: number; maxCount?: number }>;
|
||||
uploadTotalMaxCount?: number;
|
||||
uploadTotalMaxSize?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface JsonNodeData {
|
||||
_id: string; key: string; keyEditable: boolean;
|
||||
type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array';
|
||||
primitiveValue: string | number | boolean | null;
|
||||
children: JsonNodeData[];
|
||||
config?: FieldConfig;
|
||||
}
|
||||
|
||||
interface FieldFormData {
|
||||
nodeKey: string;
|
||||
nodeId: string;
|
||||
jsonType: string;
|
||||
fieldType: string; label: string; description: string; role: string;
|
||||
isForm: boolean; required: boolean; defaultValue: string; options: string[];
|
||||
constraint: {
|
||||
minLength?: number; maxLength?: number; pattern?: string;
|
||||
min?: number; max?: number; numberType?: string;
|
||||
accept?: string; maxSize?: number; maxCount?: number;
|
||||
uploadRules?: Array<{ format: string; maxSize?: number; maxCount?: number }>;
|
||||
uploadTotalMaxCount?: number;
|
||||
uploadTotalMaxSize?: number;
|
||||
};
|
||||
_createParentId?: string;
|
||||
_isArrayParent?: boolean;
|
||||
_childrenCount?: number;
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
let idCounter = 0;
|
||||
function genId(): string {
|
||||
return `jn_${Date.now()}_${++idCounter}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
function getDefaultPrimitive(type: string): string | number | boolean | null {
|
||||
switch (type) {
|
||||
case 'number': return 0;
|
||||
case 'boolean': return false;
|
||||
case 'null': return null;
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
function valueToNode(value: any, key: string, keyEditable: boolean): JsonNodeData {
|
||||
const id = genId();
|
||||
if (value === null || value === undefined)
|
||||
return { _id: id, key, keyEditable, type: 'null', primitiveValue: null, children: [] };
|
||||
if (Array.isArray(value))
|
||||
return { _id: id, key, keyEditable, type: 'array', primitiveValue: null, children: value.map((item, idx) => valueToNode(item, String(idx), false)) };
|
||||
if (typeof value === 'object')
|
||||
return { _id: id, key, keyEditable, type: 'object', primitiveValue: null, children: Object.entries(value).map(([k, v]) => valueToNode(v, k, true)) };
|
||||
return { _id: id, key, keyEditable, type: typeof value as 'string' | 'number' | 'boolean', primitiveValue: value, children: [] };
|
||||
}
|
||||
|
||||
function nodeToValue(node: JsonNodeData): any {
|
||||
if (node.type === 'object') { const obj: Record<string, any> = {}; for (const c of node.children) obj[c.key] = nodeToValue(c); return obj; }
|
||||
if (node.type === 'array') return node.children.map(c => nodeToValue(c));
|
||||
if (node.type === 'null') return null;
|
||||
if (node.type === 'number') return Number(node.primitiveValue);
|
||||
if (node.type === 'boolean') return node.primitiveValue === true || node.primitiveValue === 'true';
|
||||
return String(node.primitiveValue);
|
||||
}
|
||||
|
||||
function defaultFormType(jsonType: string): string {
|
||||
const m: Record<string, string> = { string: 'string', number: 'number', boolean: 'switch' };
|
||||
return m[jsonType] || 'string';
|
||||
}
|
||||
|
||||
// --- paste JSON ---
|
||||
|
||||
const showPasteDialog = ref(false);
|
||||
const pasteJsonText = ref('');
|
||||
const pastingNodeId = ref<string | null>(null);
|
||||
|
||||
function openPasteDialog(nodeId: string | null) {
|
||||
pastingNodeId.value = nodeId;
|
||||
pasteJsonText.value = '';
|
||||
showPasteDialog.value = true;
|
||||
}
|
||||
|
||||
function handlePasteJson() {
|
||||
const text = pasteJsonText.value.trim();
|
||||
if (!text) { ElMessage.warning('请粘贴 JSON 内容'); return; }
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
|
||||
if (pastingNodeId.value) {
|
||||
const node = findNodeById(pastingNodeId.value);
|
||||
if (!node || (node.type !== 'object' && node.type !== 'array')) {
|
||||
ElMessage.warning('只能在对象或数组节点上粘贴 JSON');
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(parsed)) {
|
||||
node.type = 'array';
|
||||
node.children = parsed.map((item, idx) => valueToNode(item, String(idx), false));
|
||||
} else if (typeof parsed === 'object' && parsed !== null) {
|
||||
node.type = 'object';
|
||||
node.children = Object.entries(parsed).map(([k, v]) => valueToNode(v, k, true));
|
||||
} else {
|
||||
ElMessage.warning('粘贴的内容必须是对象或数组');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
rootNode.value = valueToNode(parsed, '', false);
|
||||
}
|
||||
|
||||
showPasteDialog.value = false;
|
||||
pasteJsonText.value = '';
|
||||
emitChange();
|
||||
} catch (e: any) {
|
||||
ElMessage.error('JSON 格式错误: ' + (e.message || ''));
|
||||
}
|
||||
}
|
||||
|
||||
// --- field config dialog ---
|
||||
|
||||
const typeOptionList: { label: string; value: string }[] = [
|
||||
{ label: '文本框', value: 'string' },
|
||||
{ label: '多行文本', value: 'textarea' },
|
||||
{ label: '数字输入', value: 'number' },
|
||||
{ label: '开关', value: 'switch' },
|
||||
{ label: '下拉选择', value: 'select' },
|
||||
{ label: '文件上传', value: 'upload' },
|
||||
];
|
||||
|
||||
const fieldDialog = ref({
|
||||
visible: false,
|
||||
formData: null as FieldFormData | null,
|
||||
typeOptions: [] as { label: string; value: string }[],
|
||||
hasConfig: false,
|
||||
});
|
||||
|
||||
/** Extract FieldConfig from form data */
|
||||
function formToConfig(form: FieldFormData): FieldConfig {
|
||||
const cfg: FieldConfig = {};
|
||||
if (form.label) cfg.label = form.label;
|
||||
if (form.description) cfg.description = form.description;
|
||||
if (form.fieldType && form.fieldType !== defaultFormType(form.jsonType)) cfg.fieldType = form.fieldType;
|
||||
if (form.role) cfg.role = form.role;
|
||||
if (!form.isForm) cfg.isForm = false;
|
||||
if (form.required) cfg.required = true;
|
||||
if (form.defaultValue) cfg.defaultValue = form.jsonType === 'number' ? Number(form.defaultValue) : form.defaultValue;
|
||||
if (form.options?.length > 0) cfg.options = [...form.options];
|
||||
const hasC = Object.values(form.constraint).some((v: any) => {
|
||||
if (Array.isArray(v)) return v.length > 0;
|
||||
return v !== undefined && v !== null && v !== '';
|
||||
});
|
||||
if (hasC) {
|
||||
cfg.constraint = { ...form.constraint };
|
||||
// Deep copy uploadRules array
|
||||
if (cfg.constraint.uploadRules) {
|
||||
cfg.constraint.uploadRules = cfg.constraint.uploadRules.map(r => ({ ...r }));
|
||||
}
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** Check if a node has any meaningful config */
|
||||
function hasAnyConfig(node: JsonNodeData): boolean {
|
||||
const c = node.config;
|
||||
if (!c) return false;
|
||||
return !!(c.label || c.description || c.fieldType || c.role || c.isForm === false || c.required || c.defaultValue || c.options?.length || (c.constraint && Object.keys(c.constraint).length > 0));
|
||||
}
|
||||
|
||||
/** Open dialog in edit mode for an existing node */
|
||||
function openFieldDialog(nodeId: string) {
|
||||
const node = findNodeById(nodeId);
|
||||
if (!node) return;
|
||||
const cfg = node.config || {};
|
||||
fieldDialog.value = {
|
||||
visible: true,
|
||||
typeOptions: typeOptionList,
|
||||
hasConfig: hasAnyConfig(node),
|
||||
formData: {
|
||||
nodeId: node._id,
|
||||
nodeKey: node.key,
|
||||
jsonType: node.type,
|
||||
fieldType: cfg.fieldType || defaultFormType(node.type),
|
||||
label: cfg.label || '',
|
||||
description: cfg.description || "",
|
||||
role: cfg.role || '',
|
||||
isForm: cfg.isForm ?? true,
|
||||
required: cfg.required ?? false,
|
||||
defaultValue: String(cfg.defaultValue ?? ''),
|
||||
options: cfg.options || [],
|
||||
constraint: cfg.constraint ? { ...cfg.constraint } : {},
|
||||
_childrenCount: node.children.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
provide('onOpenFieldDialog', openFieldDialog);
|
||||
|
||||
/** Open dialog in create mode for adding a new child to a parent node */
|
||||
function openAddChildDialog(parentNodeId: string) {
|
||||
const parent = findNodeById(parentNodeId);
|
||||
if (!parent || (parent.type !== 'object' && parent.type !== 'array')) return;
|
||||
|
||||
const isArray = parent.type === 'array';
|
||||
fieldDialog.value = {
|
||||
visible: true,
|
||||
typeOptions: typeOptionList,
|
||||
hasConfig: false,
|
||||
formData: {
|
||||
nodeId: '',
|
||||
nodeKey: '',
|
||||
jsonType: 'string',
|
||||
fieldType: 'string',
|
||||
label: '',
|
||||
description: "",
|
||||
role: '',
|
||||
isForm: true,
|
||||
required: false,
|
||||
defaultValue: '',
|
||||
options: [],
|
||||
constraint: {},
|
||||
_createParentId: parentNodeId,
|
||||
_isArrayParent: isArray,
|
||||
_childrenCount: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Handle save from the config dialog — supports both create and edit modes */
|
||||
function handleFieldSave(form: FieldFormData) {
|
||||
if (form._createParentId) {
|
||||
// --- create mode ---
|
||||
const parent = findNodeById(form._createParentId);
|
||||
if (!parent || (parent.type !== 'object' && parent.type !== 'array')) return;
|
||||
|
||||
const isArray = parent.type === 'array';
|
||||
const newNode: JsonNodeData = {
|
||||
_id: genId(),
|
||||
key: isArray ? (form.nodeKey || String(parent.children.length)) : (form.nodeKey || 'newKey'),
|
||||
keyEditable: !isArray,
|
||||
type: form.jsonType as JsonNodeData['type'],
|
||||
primitiveValue: getDefaultPrimitive(form.jsonType),
|
||||
children: (form.jsonType === 'object' || form.jsonType === 'array') ? [] : [],
|
||||
config: formToConfig(form),
|
||||
};
|
||||
|
||||
parent.children.push(newNode);
|
||||
fieldDialog.value.visible = false;
|
||||
emitChange();
|
||||
} else {
|
||||
// --- edit mode ---
|
||||
const node = findNodeById(form.nodeId);
|
||||
if (!node) return;
|
||||
|
||||
node.key = form.nodeKey;
|
||||
node.type = form.jsonType as JsonNodeData['type'];
|
||||
|
||||
// Store config on node
|
||||
node.config = formToConfig(form);
|
||||
|
||||
// Reset value/children when type changes
|
||||
if (node.type === 'object' || node.type === 'array') {
|
||||
if (!node.children) node.children = [];
|
||||
// Preserve existing children when switching between object/array
|
||||
} else {
|
||||
// Switching to primitive type — clear children, set default value
|
||||
node.children = [];
|
||||
if (node.primitiveValue === undefined || node.primitiveValue === null || node.type === 'null') {
|
||||
node.primitiveValue = getDefaultPrimitive(node.type);
|
||||
}
|
||||
}
|
||||
|
||||
fieldDialog.value.visible = false;
|
||||
emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFieldRemove() {
|
||||
const form = fieldDialog.value.formData;
|
||||
if (!form) return;
|
||||
if (form._createParentId) {
|
||||
// Create mode — nothing to remove, just close
|
||||
fieldDialog.value.visible = false;
|
||||
return;
|
||||
}
|
||||
const node = findNodeById(form.nodeId);
|
||||
if (node) {
|
||||
delete node.config;
|
||||
fieldDialog.value.visible = false;
|
||||
emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
// --- root node management ---
|
||||
|
||||
function createDefaultNode(type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array', key: string, keyEditable: boolean): JsonNodeData {
|
||||
const id = genId();
|
||||
const base = { _id: id, key, keyEditable, children: [] as JsonNodeData[] };
|
||||
switch (type) {
|
||||
case 'object': return { ...base, type: 'object', primitiveValue: null };
|
||||
case 'array': return { ...base, type: 'array', primitiveValue: null };
|
||||
case 'null': return { ...base, type: 'null', primitiveValue: null };
|
||||
case 'number': return { ...base, type: 'number', primitiveValue: 0 };
|
||||
case 'boolean': return { ...base, type: 'boolean', primitiveValue: false };
|
||||
default: return { ...base, type: 'string', primitiveValue: '' };
|
||||
}
|
||||
}
|
||||
|
||||
const rootNode = ref<JsonNodeData>(createDefaultNode('object', '', false));
|
||||
let skipNextWatch = false;
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (skipNextWatch) { skipNextWatch = false; return; }
|
||||
rootNode.value = val === undefined || val === null ? createDefaultNode('object', '', false) : valueToNode(val, '', false);
|
||||
}, { deep: true, immediate: true });
|
||||
|
||||
function emitChange() {
|
||||
skipNextWatch = true;
|
||||
const json = nodeToValue(rootNode.value);
|
||||
emit('update:modelValue', json);
|
||||
emit('change', json);
|
||||
}
|
||||
|
||||
function findNodeById(nodeId: string, from?: JsonNodeData): JsonNodeData | null {
|
||||
const s = (n: JsonNodeData): JsonNodeData | null => {
|
||||
if (n._id === nodeId) return n;
|
||||
for (const c of n.children) { const f = s(c); if (f) return f; }
|
||||
return null;
|
||||
};
|
||||
return s(from || rootNode.value);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getData: () => nodeToValue(rootNode.value),
|
||||
setData: (d: any) => { rootNode.value = valueToNode(d, '', false); },
|
||||
openPasteDialog: () => openPasteDialog(null),
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.json-editor-tree {
|
||||
|
||||
.paste-tip { margin-bottom: 12px;
|
||||
:deep(.el-alert__description) { font-size: 13px; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,625 @@
|
||||
<template>
|
||||
<!-- panel mode: embedded sidebar -->
|
||||
<template v-if="panel">
|
||||
<div class="config-panel">
|
||||
<div class="config-panel-title">{{ dialogTitle }}</div>
|
||||
<div class="config-panel-body">
|
||||
<el-form ref="formRef" :model="form" label-width="110px" size="small">
|
||||
<el-divider content-position="left">基础信息</el-divider>
|
||||
<el-form-item v-if="showKeyField" label="字段英文名">
|
||||
<el-input v-model="form.nodeKey" placeholder="字段名称(key)" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!isArrayElement" label="字段中文名">
|
||||
<el-input v-model="form.label" placeholder="表单中显示的字段名称" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!isArrayElement" label="字段描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="此字段的用途说明" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="字段类型">
|
||||
<el-select v-model="form.jsonType" @change="onJsonTypeChange">
|
||||
<el-option label="字符串" value="string" />
|
||||
<el-option label="数字" value="number" />
|
||||
<el-option label="布尔" value="boolean" />
|
||||
<el-option label="对象" value="object" />
|
||||
<el-option label="数组" value="array" />
|
||||
</el-select>
|
||||
<div class="form-hint">JSON 数据类型</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="form.jsonType === 'array'" label="枚举配置">
|
||||
<div class="enum-config-inline">
|
||||
<el-button size="small" @click="enumDialogVisible = true">
|
||||
{{ form.enumValues.length > 0 ? '编辑枚举值' : '配置枚举值' }}
|
||||
</el-button>
|
||||
<span v-if="form.enumValues.length > 0" class="form-hint">已配置 {{ form.enumValues.length }} 个枚举值</span>
|
||||
<span v-else class="form-hint">设置数组允许的固定值列表</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="jsonTypeIsScalar" label="默认值">
|
||||
<template v-if="hasValueSource">
|
||||
<div class="fcd-ref-bound" :class="{ 'is-invalid': valueSourceInvalid }">
|
||||
<span class="fcd-ref-bound-text">已引用:{{ valueSourceLabel }}</span>
|
||||
<el-button size="small" text type="danger" @click="clearValueSource">清除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="fcd-default-row">
|
||||
<el-select v-if="form.jsonType === 'boolean'" v-model="form.defaultValue" placeholder="选择默认值" clearable>
|
||||
<el-option label="true" value="true" />
|
||||
<el-option label="false" value="false" />
|
||||
</el-select>
|
||||
<el-input v-else v-model="form.defaultValue" placeholder="留空则无默认值" clearable />
|
||||
<el-popover v-if="hasUpstream" placement="bottom" :width="260" trigger="click" popper-class="fcd-ref-popover">
|
||||
<template #reference>
|
||||
<el-button size="small" text type="primary" class="fcd-ref-btn" title="引用上级节点输出">
|
||||
<el-icon><Link /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="fcd-ref-panel">
|
||||
<div v-if="!hasUpstream" class="fcd-ref-empty">无可引用的上级节点</div>
|
||||
<template v-else>
|
||||
<div v-for="node in upstreamNodes" :key="node.id" class="fcd-ref-node">
|
||||
<div class="fcd-ref-node-head">
|
||||
<span class="fcd-ref-node-name">{{ node.label }}</span>
|
||||
<el-tag size="small" type="info">{{ node.nodeCode }}</el-tag>
|
||||
</div>
|
||||
<div v-if="node.outputFields.length === 0" class="fcd-ref-node-empty">该节点无输出字段</div>
|
||||
<div v-for="of in node.outputFields" :key="of.field" class="fcd-ref-field" @click="setValueSource(node, of)">
|
||||
<span v-if="of.fieldType === 'object'" class="fcd-ref-type" title="引用对象整体">{} 对象</span>
|
||||
<span v-else-if="of.fieldType === 'array'" class="fcd-ref-type" title="引用数组整体">[] 数组</span>
|
||||
<span class="fcd-ref-field-text">{{ of.label || of.field }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-popover>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
<template v-if="jsonTypeIsScalar && !hideFormConfig">
|
||||
<el-divider content-position="left">表单属性</el-divider>
|
||||
<el-form-item label="表单显示">
|
||||
<el-switch v-model="form.isForm" />
|
||||
<span class="form-hint">{{ form.isForm ? '在表单中展示' : '隐藏字段' }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="表单控件">
|
||||
<el-select v-model="form.fieldType" @change="onTypeChange">
|
||||
<el-option v-for="opt in fieldTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<div class="form-hint">此字段在表单中以什么组件展示</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="必填项">
|
||||
<el-switch v-model="form.required" />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">约束规则</el-divider>
|
||||
<el-form-item v-if="form.fieldType === 'select'" label="选项列表">
|
||||
<div class="options-editor">
|
||||
<div v-for="(opt, idx) in form.options" :key="idx" class="option-row">
|
||||
<el-input v-model="opt.label" size="small" placeholder="显示名" style="width:120px" />
|
||||
<el-input v-model="opt.value" size="small" placeholder="选项值" style="width:120px" />
|
||||
<el-button size="small" text type="danger" @click="form.options.splice(idx, 1)">×</el-button>
|
||||
</div>
|
||||
<el-button size="small" class="add-option-btn" @click="form.options.push({ label: '', value: '' })"><el-icon><Plus /></el-icon> 添加选项</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<template v-if="form.fieldType === 'string' || form.fieldType === 'textarea'">
|
||||
<el-form-item label="最小长度"><el-input-number v-model="form.constraint.minLength" :min="0" :step="100" controls-position="right" placeholder="不限" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="最大长度"><el-input-number v-model="form.constraint.maxLength" :min="0" :step="1000" controls-position="right" placeholder="不限" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="正则验证"><el-input v-model="form.constraint.pattern" placeholder="如 ^https?://" clearable /></el-form-item>
|
||||
</template>
|
||||
<template v-if="form.fieldType === 'upload'">
|
||||
<el-form-item label="总数量"><el-input-number v-model="form.constraint.uploadTotalMaxCount" :min="1" controls-position="right" placeholder="不限" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="总容量(MB)"><el-input-number v-model="form.constraint.uploadTotalMaxSize" :min="1" controls-position="right" placeholder="不限" style="width:100%" /></el-form-item>
|
||||
<el-divider content-position="left">按格式限制(可选)</el-divider>
|
||||
<div class="upload-rules">
|
||||
<div v-for="(rule, idx) in uploadRules" :key="idx" class="upload-rule-row">
|
||||
<el-input v-model="rule.format" size="small" placeholder="格式(如 jpg)" style="width:90px" />
|
||||
<el-input-number v-model="rule.maxSize" :min="1" controls-position="right" size="small" placeholder="大小(MB)" style="width:140px" />
|
||||
<el-input-number v-model="rule.maxCount" :min="1" controls-position="right" size="small" placeholder="数量" style="width:110px" />
|
||||
<el-button size="small" text type="danger" @click="removeUploadRule(idx)">×</el-button>
|
||||
</div>
|
||||
<el-button size="small" class="add-rule-btn" @click="addUploadRule"><el-icon><Plus /></el-icon> 添加规则</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="form.fieldType === 'number'">
|
||||
<el-form-item label="最小值"><el-input-number v-model="form.constraint.min" :min="Number.MIN_SAFE_INTEGER" :max="Number.MAX_SAFE_INTEGER" controls-position="right" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="最大值"><el-input-number v-model="form.constraint.max" :min="Number.MIN_SAFE_INTEGER" :max="Number.MAX_SAFE_INTEGER" controls-position="right" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="数值类型"><el-select v-model="form.constraint.numberType" clearable placeholder="不限"><el-option label="整数" value="integer" /><el-option label="浮点数" value="float" /></el-select></el-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="config-panel-actions">
|
||||
<el-button :disabled="!hasConfig" @click="handleRemove">清除配置</el-button>
|
||||
<el-button @click="handleCancel">关闭</el-button>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- dialog mode: popup overlay -->
|
||||
<template v-else>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="dialogTitle"
|
||||
width="560px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@update:model-value="$emit('update:visible', $event)"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" label-width="110px" size="small">
|
||||
<el-divider content-position="left">基础信息</el-divider>
|
||||
<el-form-item v-if="showKeyField" label="字段英文名">
|
||||
<el-input v-model="form.nodeKey" placeholder="字段名称(key)" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!isArrayElement" label="字段中文名">
|
||||
<el-input v-model="form.label" placeholder="表单中显示的字段名称" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!isArrayElement" label="字段描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="此字段的用途说明" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="字段类型">
|
||||
<el-select v-model="form.jsonType" @change="onJsonTypeChange">
|
||||
<el-option label="字符串" value="string" />
|
||||
<el-option label="数字" value="number" />
|
||||
<el-option label="布尔" value="boolean" />
|
||||
<el-option label="对象" value="object" />
|
||||
<el-option label="数组" value="array" />
|
||||
</el-select>
|
||||
<div class="form-hint">JSON 数据类型</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="form.jsonType === 'array'" label="枚举配置">
|
||||
<div class="enum-config-inline">
|
||||
<el-button size="small" @click="enumDialogVisible = true">
|
||||
{{ form.enumValues.length > 0 ? '编辑枚举值' : '配置枚举值' }}
|
||||
</el-button>
|
||||
<span v-if="form.enumValues.length > 0" class="form-hint">已配置 {{ form.enumValues.length }} 个枚举值</span>
|
||||
<span v-else class="form-hint">设置数组允许的固定值列表</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="jsonTypeIsScalar" label="默认值">
|
||||
<template v-if="hasValueSource">
|
||||
<div class="fcd-ref-bound" :class="{ 'is-invalid': valueSourceInvalid }">
|
||||
<span class="fcd-ref-bound-text">已引用:{{ valueSourceLabel }}</span>
|
||||
<el-button size="small" text type="danger" @click="clearValueSource">清除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="fcd-default-row">
|
||||
<el-select v-if="form.jsonType === 'boolean'" v-model="form.defaultValue" placeholder="选择默认值" clearable>
|
||||
<el-option label="true" value="true" />
|
||||
<el-option label="false" value="false" />
|
||||
</el-select>
|
||||
<el-input v-else v-model="form.defaultValue" placeholder="留空则无默认值" clearable />
|
||||
<el-popover v-if="hasUpstream" placement="bottom" :width="260" trigger="click" popper-class="fcd-ref-popover">
|
||||
<template #reference>
|
||||
<el-button size="small" text type="primary" class="fcd-ref-btn" title="引用上级节点输出">
|
||||
<el-icon><Link /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="fcd-ref-panel">
|
||||
<div v-if="!hasUpstream" class="fcd-ref-empty">无可引用的上级节点</div>
|
||||
<template v-else>
|
||||
<div v-for="node in upstreamNodes" :key="node.id" class="fcd-ref-node">
|
||||
<div class="fcd-ref-node-head">
|
||||
<span class="fcd-ref-node-name">{{ node.label }}</span>
|
||||
<el-tag size="small" type="info">{{ node.nodeCode }}</el-tag>
|
||||
</div>
|
||||
<div v-if="node.outputFields.length === 0" class="fcd-ref-node-empty">该节点无输出字段</div>
|
||||
<div v-for="of in node.outputFields" :key="of.field" class="fcd-ref-field" @click="setValueSource(node, of)">
|
||||
<span v-if="of.fieldType === 'object'" class="fcd-ref-type" title="引用对象整体">{} 对象</span>
|
||||
<span v-else-if="of.fieldType === 'array'" class="fcd-ref-type" title="引用数组整体">[] 数组</span>
|
||||
<span class="fcd-ref-field-text">{{ of.label || of.field }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-popover>
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
<template v-if="jsonTypeIsScalar && !hideFormConfig">
|
||||
<el-divider content-position="left">表单属性</el-divider>
|
||||
<el-form-item label="是否可编辑">
|
||||
<el-switch v-model="form.isForm" />
|
||||
<span class="form-hint">{{ form.isForm ? '可编辑' : '只读' }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="表单控件">
|
||||
<el-select v-model="form.fieldType" @change="onTypeChange">
|
||||
<el-option v-for="opt in fieldTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<div class="form-hint">此字段在表单中以什么组件展示</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="必填项">
|
||||
<el-switch v-model="form.required" />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">约束规则</el-divider>
|
||||
<el-form-item v-if="form.fieldType === 'select'" label="选项列表">
|
||||
<div class="options-editor">
|
||||
<div v-for="(opt, idx) in form.options" :key="idx" class="option-row">
|
||||
<el-input v-model="opt.label" size="small" placeholder="显示名" style="width:120px" />
|
||||
<el-input v-model="opt.value" size="small" placeholder="选项值" style="width:120px" />
|
||||
<el-button size="small" text type="danger" @click="form.options.splice(idx, 1)">×</el-button>
|
||||
</div>
|
||||
<el-button size="small" class="add-option-btn" @click="form.options.push({ label: '', value: '' })"><el-icon><Plus /></el-icon> 添加选项</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<template v-if="form.fieldType === 'string' || form.fieldType === 'textarea'">
|
||||
<el-form-item label="最小长度"><el-input-number v-model="form.constraint.minLength" :min="0" :step="100" controls-position="right" placeholder="不限" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="最大长度"><el-input-number v-model="form.constraint.maxLength" :min="0" :step="1000" controls-position="right" placeholder="不限" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="正则验证"><el-input v-model="form.constraint.pattern" placeholder="如 ^https?://" clearable /></el-form-item>
|
||||
</template>
|
||||
<template v-if="form.fieldType === 'upload'">
|
||||
<el-form-item label="总数量"><el-input-number v-model="form.constraint.uploadTotalMaxCount" :min="1" controls-position="right" placeholder="不限" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="总容量(MB)"><el-input-number v-model="form.constraint.uploadTotalMaxSize" :min="1" controls-position="right" placeholder="不限" style="width:100%" /></el-form-item>
|
||||
<el-divider content-position="left">按格式限制(可选)</el-divider>
|
||||
<div class="upload-rules">
|
||||
<div v-for="(rule, idx) in uploadRules" :key="idx" class="upload-rule-row">
|
||||
<el-input v-model="rule.format" size="small" placeholder="格式(如 jpg)" style="width:90px" />
|
||||
<el-input-number v-model="rule.maxSize" :min="1" controls-position="right" size="small" placeholder="大小(MB)" style="width:140px" />
|
||||
<el-input-number v-model="rule.maxCount" :min="1" controls-position="right" size="small" placeholder="数量" style="width:110px" />
|
||||
<el-button size="small" text type="danger" @click="removeUploadRule(idx)">×</el-button>
|
||||
</div>
|
||||
<el-button size="small" class="add-rule-btn" @click="addUploadRule"><el-icon><Plus /></el-icon> 添加规则</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="form.fieldType === 'number'">
|
||||
<el-form-item label="最小值"><el-input-number v-model="form.constraint.min" :min="Number.MIN_SAFE_INTEGER" :max="Number.MAX_SAFE_INTEGER" controls-position="right" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="最大值"><el-input-number v-model="form.constraint.max" :min="Number.MIN_SAFE_INTEGER" :max="Number.MAX_SAFE_INTEGER" controls-position="right" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="数值类型"><el-select v-model="form.constraint.numberType" clearable placeholder="不限"><el-option label="整数" value="integer" /><el-option label="浮点数" value="float" /></el-select></el-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="!hasConfig" @click="handleRemove">清除配置</el-button>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<!-- Enum editor dialog -->
|
||||
<el-dialog v-model="enumDialogVisible" title="枚举值配置" width="900px" :close-on-click-modal="false" destroy-on-close>
|
||||
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 12px;">
|
||||
<template #default>
|
||||
编辑数组字段允许的固定值列表。每个值为一个允许的枚举项。
|
||||
</template>
|
||||
</el-alert>
|
||||
<div class="enum-editor-container">
|
||||
<JsonEditor v-model="form.enumValues" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="enumDialogVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="enumDialogVisible = false">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, defineAsyncComponent } from 'vue';
|
||||
import { ElMessageBox } from 'element-plus';
|
||||
import { Plus, Link } from '@element-plus/icons-vue';
|
||||
|
||||
const JsonEditor = defineAsyncComponent(() => import('./index.vue'));
|
||||
|
||||
interface UploadRule { format: string; maxSize?: number; maxCount?: number; }
|
||||
|
||||
// 前驱节点的可引用输出字段(供「引用上级节点输出」)
|
||||
interface UpstreamNodeInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
nodeCode: string;
|
||||
outputFields: { field: string; label: string; fieldType?: 'scalar' | 'object' | 'array' }[];
|
||||
}
|
||||
|
||||
interface FieldFormData {
|
||||
nodeKey: string; nodeId: string; jsonType: string; fieldType: string;
|
||||
label: string; description: string; role: string;
|
||||
isForm: boolean; required: boolean; defaultValue: string; options: Array<{ label: string; value: string }>;
|
||||
enumValues: any[];
|
||||
constraint: {
|
||||
minLength?: number; maxLength?: number; pattern?: string;
|
||||
min?: number; max?: number; numberType?: string;
|
||||
accept?: string; maxSize?: number; maxCount?: number;
|
||||
uploadRules?: UploadRule[]; uploadTotalMaxCount?: number; uploadTotalMaxSize?: number;
|
||||
};
|
||||
valueSource?: { nodeId: string; field: string };
|
||||
_createParentId?: string; _isArrayParent?: boolean; _childrenCount?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
formData: FieldFormData | null;
|
||||
fieldTypeOptions: { label: string; value: string }[];
|
||||
hasConfig: boolean;
|
||||
panel?: boolean;
|
||||
upstreamNodes?: UpstreamNodeInfo[];
|
||||
hideFormConfig?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', v: boolean): void;
|
||||
(e: 'save', form: FieldFormData): void;
|
||||
(e: 'remove'): void;
|
||||
}>();
|
||||
|
||||
const form = reactive<FieldFormData>({
|
||||
nodeKey: '', nodeId: '', jsonType: 'string', fieldType: 'string',
|
||||
label: '', description: '', role: '',
|
||||
isForm: true, required: false, defaultValue: '', options: [], constraint: {},
|
||||
enumValues: [],
|
||||
valueSource: undefined,
|
||||
});
|
||||
|
||||
const uploadRules = ref<UploadRule[]>([]);
|
||||
const enumDialogVisible = ref(false);
|
||||
const prevJsonType = ref('');
|
||||
|
||||
const isCreateMode = computed(() => !!form._createParentId);
|
||||
const jsonTypeIsScalar = computed(() => form.jsonType !== 'object' && form.jsonType !== 'array');
|
||||
const isArrayElement = computed(() => isCreateMode.value && form._isArrayParent);
|
||||
const showKeyField = computed(() => !isArrayElement.value);
|
||||
const dialogTitle = computed(() => {
|
||||
return isCreateMode.value
|
||||
? (form._isArrayParent ? '添加元素' : '添加字段 —— ' + (form.nodeKey || '新字段'))
|
||||
: '字段配置 —— ' + form.nodeKey;
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
form.nodeKey = ''; form.nodeId = ''; form.jsonType = 'string'; form.fieldType = 'string';
|
||||
form.label = ''; form.description = ''; form.role = '';
|
||||
form.isForm = true; form.required = false; form.defaultValue = '';
|
||||
form.options = []; form.constraint = {};
|
||||
form.enumValues = [];
|
||||
form.valueSource = undefined;
|
||||
// @ts-ignore
|
||||
form._createParentId = undefined; form._isArrayParent = false;
|
||||
uploadRules.value = [];
|
||||
enumDialogVisible.value = false;
|
||||
}
|
||||
|
||||
function loadForm(data: FieldFormData | null) {
|
||||
resetForm();
|
||||
if (!data) return;
|
||||
form.nodeId = data.nodeId; form.nodeKey = data.nodeKey || '';
|
||||
prevJsonType.value = data.jsonType || 'string';
|
||||
form.jsonType = data.jsonType || 'string'; form.fieldType = data.fieldType;
|
||||
form.label = data.label || ''; form.description = data.description || ''; form.role = data.role || '';
|
||||
form.isForm = data.isForm ?? true; form.required = data.required || false;
|
||||
form.defaultValue = data.defaultValue || '';
|
||||
form.options = data.options ? [...data.options] : [];
|
||||
if (form.options.length > 0 && typeof (form.options as any)[0] === 'string') {
|
||||
form.options = (data.options as unknown as string[]).map(s => ({ label: s, value: s }));
|
||||
}
|
||||
form.constraint = data.constraint ? { ...data.constraint } : {};
|
||||
form.enumValues = data.enumValues ? JSON.parse(JSON.stringify(data.enumValues)) : [];
|
||||
form.valueSource = data.valueSource ? { ...data.valueSource } : undefined;
|
||||
// @ts-ignore
|
||||
form._createParentId = data._createParentId; form._isArrayParent = data._isArrayParent ?? false;
|
||||
// @ts-ignore
|
||||
form._childrenCount = data._childrenCount ?? 0;
|
||||
if (data.constraint?.uploadRules?.length) {
|
||||
uploadRules.value = data.constraint.uploadRules.map((r) => ({ ...r }));
|
||||
} else if (data.constraint?.accept) {
|
||||
const formats = data.constraint.accept.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (formats.length) {
|
||||
uploadRules.value = formats.map((f) => ({ format: f, maxSize: data.constraint?.maxSize, maxCount: data.constraint?.maxCount }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncUploadRulesToConstraint() {
|
||||
if (uploadRules.value.some((r) => r.format)) {
|
||||
form.constraint.uploadRules = uploadRules.value.filter((r) => r.format).map((r) => ({ ...r }));
|
||||
} else { delete form.constraint.uploadRules; }
|
||||
}
|
||||
|
||||
function addUploadRule() { uploadRules.value.push({ format: '', maxSize: undefined, maxCount: undefined }); }
|
||||
function removeUploadRule(idx: number) { uploadRules.value.splice(idx, 1); }
|
||||
|
||||
async function onJsonTypeChange(val: string) {
|
||||
const childrenCount = (form as any)._childrenCount || 0;
|
||||
const prevType = prevJsonType.value;
|
||||
const m: Record<string, string> = { string: 'string', number: 'number', boolean: 'switch', null: 'string', object: 'string', array: 'string' };
|
||||
form.fieldType = m[val] || 'string';
|
||||
if (!form._createParentId && childrenCount > 0 && prevType && prevType !== val) {
|
||||
try {
|
||||
await ElMessageBox.confirm('更改字段类型将删除该节点下所有子节点数据,是否继续?', '确认操作', {
|
||||
confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning', draggable: true,
|
||||
});
|
||||
prevJsonType.value = val;
|
||||
} catch {
|
||||
form.jsonType = prevType;
|
||||
}
|
||||
} else { prevJsonType.value = val; }
|
||||
}
|
||||
|
||||
function onTypeChange() {
|
||||
if (form.fieldType !== 'upload') {
|
||||
delete form.constraint.accept; delete form.constraint.maxSize; delete form.constraint.maxCount;
|
||||
delete form.constraint.uploadRules; uploadRules.value = [];
|
||||
}
|
||||
if (form.fieldType !== 'string' && form.fieldType !== 'textarea') {
|
||||
delete form.constraint.minLength; delete form.constraint.maxLength; delete form.constraint.pattern;
|
||||
}
|
||||
if (form.fieldType !== 'number') {
|
||||
delete form.constraint.min; delete form.constraint.max; delete form.constraint.numberType;
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.visible, (val) => { if (val) loadForm(props.formData); }, { immediate: true });
|
||||
|
||||
|
||||
// ===== 默认值引用上级节点输出 =====
|
||||
const hasUpstream = computed(() => (props.upstreamNodes?.length ?? 0) > 0);
|
||||
const hasValueSource = computed(() => !!form.valueSource);
|
||||
const valueSourceLabel = computed(() => {
|
||||
const ref = form.valueSource;
|
||||
if (!ref) return '';
|
||||
const node = props.upstreamNodes?.find((n) => n.id === ref.nodeId);
|
||||
const nodeName = node?.label || ref.nodeId;
|
||||
const fieldLabel = node?.outputFields.find((f) => f.field === ref.field)?.label || ref.field;
|
||||
return `${nodeName}.${fieldLabel}`;
|
||||
});
|
||||
// 引用是否已失效:上游节点被删除,或其输出字段已不存在
|
||||
const valueSourceInvalid = computed(() => {
|
||||
const ref = form.valueSource;
|
||||
if (!ref) return false;
|
||||
const node = props.upstreamNodes?.find((n) => n.id === ref.nodeId);
|
||||
if (!node) return true;
|
||||
return !node.outputFields.some((f) => f.field === ref.field);
|
||||
});
|
||||
watch(valueSourceInvalid, (invalid) => {
|
||||
if (invalid && form.valueSource) { form.valueSource = undefined; }
|
||||
}, { immediate: true });
|
||||
const setValueSource = (node: UpstreamNodeInfo, of: { field: string; label: string }) => {
|
||||
form.valueSource = { nodeId: node.id, field: of.field };
|
||||
// 有引用时默认值无意义,清空避免两者并存导致歧义
|
||||
form.defaultValue = '';
|
||||
};
|
||||
const clearValueSource = () => {
|
||||
form.valueSource = undefined;
|
||||
};
|
||||
|
||||
function handleSave() {
|
||||
syncUploadRulesToConstraint();
|
||||
emit('save', { ...form, constraint: { ...form.constraint } });
|
||||
}
|
||||
function handleRemove() { emit('remove'); }
|
||||
function handleCancel() { emit('update:visible', false); }
|
||||
function handleClosed() { resetForm(); }
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.config-panel {
|
||||
height: 100%; display: flex; flex-direction: column;
|
||||
.config-panel-title {
|
||||
font-size: 14px; font-weight: 600; color: #333;
|
||||
padding: 12px 16px; border-bottom: 1px solid #e2e8f0; flex-shrink: 0;
|
||||
}
|
||||
.config-panel-body {
|
||||
flex: 1; overflow-y: auto; padding: 12px 16px;
|
||||
:deep(.el-divider__text) { font-size: 12px; }
|
||||
}
|
||||
.config-panel-actions {
|
||||
display: flex; gap: 8px; justify-content: flex-end;
|
||||
padding: 12px 16px; border-top: 1px solid #e2e8f0; flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
.form-hint { font-size: 11px; color: #94a3b8; margin-left: 8px; }
|
||||
.enum-config-inline { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.enum-editor-container { height: 500px; border: 1px solid #e2e8f0; border-radius: 4px; overflow: hidden; }
|
||||
.options-editor { width: 100%;
|
||||
.option-row { display: flex; gap: 4px; margin-bottom: 4px; }
|
||||
.add-option-btn { font-size: 12px; margin-top: 4px; }
|
||||
}
|
||||
.upload-rules { width: 100%;
|
||||
.upload-rule-row { display: flex; gap: 6px; align-items: center; margin-bottom: 6px; }
|
||||
.add-rule-btn { font-size: 12px; margin-top: 2px; }
|
||||
}
|
||||
:deep(.el-form-item) { margin-bottom: 12px; }
|
||||
.fcd-default-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
.el-select, .el-input { flex: 1; min-width: 0; }
|
||||
}
|
||||
.fcd-ref-bound {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
.fcd-ref-bound-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
&.is-invalid {
|
||||
color: #dc2626;
|
||||
background: #fef2f2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- popover teleport 到 body,面板样式需脱离 scoped -->
|
||||
<style lang="scss">
|
||||
.fcd-ref-popover {
|
||||
.fcd-ref-panel {
|
||||
// 面板超高时内部滚动,避免超出浏览器视口被截掉显示不全
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
.fcd-ref-empty {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
padding: 6px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.fcd-ref-node {
|
||||
& + .fcd-ref-node {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
}
|
||||
.fcd-ref-node-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
.fcd-ref-node-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.fcd-ref-node-empty {
|
||||
font-size: 12px;
|
||||
color: #cbd5e1;
|
||||
padding: 2px 0 2px 8px;
|
||||
}
|
||||
.fcd-ref-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.fcd-ref-type {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: #64748b;
|
||||
background: #f1f5f9;
|
||||
border-radius: 3px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.fcd-ref-field-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div class="json-node">
|
||||
<template v-if="node.type === 'object'">
|
||||
<div class="node-row node-row-object" :class="{ 'is-collapsed': collapsed }" :style="{ paddingLeft: depth * 20 + 'px' }">
|
||||
<span class="collapse-btn" @click="toggleCollapse">
|
||||
<el-icon><CaretRight v-if="collapsed" /><CaretBottom v-else /></el-icon>
|
||||
</span>
|
||||
<span class="key-label">{{ node.key }}</span>
|
||||
<span class="colon">:</span>
|
||||
<span class="type-label">object</span>
|
||||
<span class="node-badge badge-object">{...}</span>
|
||||
<span class="node-count">{{ node.children.length }} 项</span>
|
||||
<el-button size="small" text class="node-action-btn always-visible" @click.stop="emit('add-requested', node._id)">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-button>
|
||||
<el-button size="small" text class="node-action-btn field-btn" :class="{ 'field-btn-configured': hasConfig(node) }" @click.stop="onOpenFieldDialog?.(node._id)" title="字段配置"><el-icon><Setting /></el-icon></el-button>
|
||||
<el-button size="small" text class="node-action-btn copy-btn" @click.stop="copyNodeJson" title="复制 JSON">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
||||
</el-button>
|
||||
<el-button v-if="!isRoot && parentCanDelete" size="small" text class="node-action-btn node-action-delete" @click.stop="deleteSelf">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
<span v-if="configSummary(node)" class="config-summary">{{ configSummary(node) }}</span>
|
||||
</div>
|
||||
<div v-show="!collapsed" class="node-children" :style="{ marginLeft: depth * 20 + 18 + 'px' }">
|
||||
<JsonNode v-for="child in node.children" :key="child._id" :node="child" :depth="depth + 1" :is-root="false" :parent-type="'object'" :parent-can-delete="true" @delete-node="removeChild(child._id)" @change="emitChange" @add-requested="(id: string) => emit('add-requested', id)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="node.type === 'array'">
|
||||
<div class="node-row node-row-array" :class="{ 'is-collapsed': collapsed }" :style="{ paddingLeft: depth * 20 + 'px' }">
|
||||
<span class="collapse-btn" @click="toggleCollapse">
|
||||
<el-icon><CaretRight v-if="collapsed" /><CaretBottom v-else /></el-icon>
|
||||
</span>
|
||||
<span class="key-label">{{ node.key }}</span>
|
||||
<span class="colon">:</span>
|
||||
<span class="type-label">array</span>
|
||||
<span class="node-badge badge-array">[...]</span>
|
||||
<span class="node-count">{{ node.children.length }} 项</span>
|
||||
<el-button size="small" text class="node-action-btn always-visible" @click.stop="emit('add-requested', node._id)">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-button>
|
||||
<el-button size="small" text class="node-action-btn field-btn" :class="{ 'field-btn-configured': hasConfig(node) }" @click.stop="onOpenFieldDialog?.(node._id)" title="字段配置"><el-icon><Setting /></el-icon></el-button>
|
||||
<el-button size="small" text class="node-action-btn copy-btn" @click.stop="copyNodeJson" title="复制 JSON">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
||||
</el-button>
|
||||
<el-button v-if="!isRoot && parentCanDelete" size="small" text class="node-action-btn node-action-delete" @click.stop="deleteSelf">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
<span v-if="configSummary(node)" class="config-summary">{{ configSummary(node) }}</span>
|
||||
</div>
|
||||
<div v-show="!collapsed" class="node-children" :style="{ marginLeft: depth * 20 + 18 + 'px' }">
|
||||
<JsonNode v-for="child in node.children" :key="child._id" :node="child" :depth="depth + 1" :is-root="false" :parent-type="'array'" :parent-can-delete="true" @delete-node="removeChild(child._id)" @change="emitChange" @add-requested="(id: string) => emit('add-requested', id)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="node-row node-row-primitive" :style="{ paddingLeft: depth * 20 + 'px' }">
|
||||
<span class="indent-placeholder" v-if="depth > 0"></span>
|
||||
<span class="key-label">{{ node.key }}</span>
|
||||
<span class="colon">:</span>
|
||||
<span class="type-label">{{ node.type }}</span>
|
||||
<el-button size="small" text class="node-action-btn field-btn" :class="{ 'field-btn-configured': hasConfig(node) }" @click.stop="onOpenFieldDialog?.(node._id)" title="字段配置"><el-icon><Setting /></el-icon></el-button>
|
||||
<el-button size="small" text class="node-action-btn copy-btn" @click.stop="copyNodeJson" title="复制 JSON">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
||||
</el-button>
|
||||
<el-button v-if="!isRoot && parentCanDelete" size="small" text class="node-action-btn node-action-delete" @click="deleteSelf">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
<span v-if="configSummary(node)" class="config-summary">{{ configSummary(node) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, inject } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Plus, Delete, CaretRight, CaretBottom, Setting } from '@element-plus/icons-vue';
|
||||
|
||||
defineOptions({ name: 'JsonNode' });
|
||||
|
||||
interface JsonNodeData {
|
||||
_id: string; key: string; keyEditable: boolean;
|
||||
type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array';
|
||||
primitiveValue: string | number | boolean | null;
|
||||
children: JsonNodeData[];
|
||||
config?: {
|
||||
fieldType?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
role?: string;
|
||||
isForm?: boolean;
|
||||
required?: boolean;
|
||||
isContainer?: boolean;
|
||||
linkRules?: Array<Record<string, string>>;
|
||||
defaultValue?: string | number;
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
constraint?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
function hasConfig(node: JsonNodeData): boolean {
|
||||
const c = node.config;
|
||||
if (!c) return false;
|
||||
if (c.label || c.description || c.role || c.defaultValue) return true;
|
||||
if (c.options?.length) return true;
|
||||
if (c.required) return true;
|
||||
if (c.isForm === false) return true;
|
||||
if (c.isContainer) return true;
|
||||
if (c.linkRules?.length) return true;
|
||||
if (c.constraint && Object.keys(c.constraint).length > 0) return true;
|
||||
const defaultTypes: Record<string, string> = { string: 'string', number: 'number', boolean: 'switch' };
|
||||
if (c.fieldType && c.fieldType !== defaultTypes[node.type]) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function configSummary(node: JsonNodeData): string {
|
||||
const c = node.config;
|
||||
if (!c) return '';
|
||||
const parts: string[] = [];
|
||||
if (c.label) parts.push(c.label);
|
||||
if (c.required) parts.push('必填');
|
||||
if (c.fieldType) {
|
||||
const m: Record<string, string> = {
|
||||
string: '文本框', textarea: '多行文本', number: '数字',
|
||||
switch: '开关', select: '下拉选择', upload: '文件上传'
|
||||
};
|
||||
parts.push(m[c.fieldType] || c.fieldType);
|
||||
}
|
||||
if (c.options?.length) parts.push(`${c.options.length}个选项`);
|
||||
if (c.defaultValue !== undefined && c.defaultValue !== '' && c.defaultValue !== null) {
|
||||
parts.push(`默认=${c.defaultValue}`);
|
||||
}
|
||||
if (c.isContainer) {
|
||||
parts.push("容器触发器");
|
||||
if (c.linkRules?.length) parts.push(`联动 ${c.linkRules.length} 条规则`);
|
||||
}
|
||||
const con = c.constraint;
|
||||
if (con) {
|
||||
if (con.minLength || con.maxLength) {
|
||||
if (con.minLength && con.maxLength) parts.push(`${con.minLength}-${con.maxLength}字`);
|
||||
else if (con.minLength) parts.push(`≥${con.minLength}字`);
|
||||
else if (con.maxLength) parts.push(`≤${con.maxLength}字`);
|
||||
}
|
||||
if (con.min !== undefined || con.max !== undefined) {
|
||||
if (con.min !== undefined && con.max !== undefined) parts.push(`${con.min}-${con.max}`);
|
||||
else if (con.min !== undefined) parts.push(`≥${con.min}`);
|
||||
else if (con.max !== undefined) parts.push(`≤${con.max}`);
|
||||
}
|
||||
if (con.pattern) parts.push('正则');
|
||||
if (con.numberType === 'integer') parts.push('整数');
|
||||
if (con.numberType === 'float') parts.push('浮点');
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
node: JsonNodeData; depth: number; isRoot?: boolean;
|
||||
parentType?: 'object' | 'array' | 'root'; parentCanDelete?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete-node'): void;
|
||||
(e: 'change'): void;
|
||||
(e: 'add-requested', parentNodeId: string): void;
|
||||
}>();
|
||||
const onOpenFieldDialog = inject<((nodeId: string) => void) | undefined>("onOpenFieldDialog");
|
||||
|
||||
const collapsed = ref(false);
|
||||
function toggleCollapse() { collapsed.value = !collapsed.value; }
|
||||
|
||||
function nodeToValue(node: JsonNodeData): any {
|
||||
if (node.type === 'object') { const obj: Record<string, any> = {}; for (const c of node.children) obj[c.key] = nodeToValue(c); return obj; }
|
||||
if (node.type === 'array') return node.children.map(c => nodeToValue(c));
|
||||
if (node.type === 'null') return null;
|
||||
if (node.type === 'number') return Number(node.primitiveValue);
|
||||
if (node.type === 'boolean') return node.primitiveValue === true || node.primitiveValue === 'true';
|
||||
return String(node.primitiveValue);
|
||||
}
|
||||
|
||||
function copyNodeJson() {
|
||||
const val = nodeToValue(props.node);
|
||||
const json = JSON.stringify(val, null, 2);
|
||||
navigator.clipboard.writeText(json).then(() => {
|
||||
ElMessage.success('JSON 已复制到剪贴板');
|
||||
}).catch(() => {
|
||||
// Fallback
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = json;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
ElMessage.success('JSON 已复制到剪贴板');
|
||||
});
|
||||
}
|
||||
|
||||
function removeChild(childId: string) {
|
||||
const n = props.node;
|
||||
const idx = n.children.findIndex((c) => c._id === childId);
|
||||
if (idx === -1) return;
|
||||
n.children.splice(idx, 1);
|
||||
if (n.type === 'array') {
|
||||
n.children.forEach((child, i) => { child.key = String(i); });
|
||||
}
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function deleteSelf() { emit('delete-node'); }
|
||||
function emitChange() { emit('change'); }
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.json-node {
|
||||
.node-row {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 3px 4px; border-radius: 3px; transition: background 0.15s;
|
||||
min-height: 30px; flex-wrap: nowrap;
|
||||
&:hover { background: #f5f7fa;
|
||||
.node-action-btn:not(.always-visible) { opacity: 1; }
|
||||
}
|
||||
.collapse-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 18px; height: 18px; cursor: pointer; color: #94a3b8;
|
||||
flex-shrink: 0; font-size: 12px;
|
||||
&:hover { color: #3b82f6; }
|
||||
}
|
||||
.indent-placeholder { display: inline-block; width: 18px; flex-shrink: 0; }
|
||||
.key-label { font-family: 'Consolas', monospace; font-size: 13px; color: #881391; font-weight: 500; white-space: nowrap; min-width: 20px; max-width: 180px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; cursor: default; }
|
||||
.colon { color: #94a3b8; font-weight: 600; flex-shrink: 0; margin-right: 2px; }
|
||||
.type-label {
|
||||
font-family: 'Consolas', monospace; font-size: 11px; color: #64748b;
|
||||
background: #f1f5f9; padding: 1px 5px; border-radius: 3px;
|
||||
flex-shrink: 0; line-height: 18px;
|
||||
}
|
||||
.node-badge {
|
||||
font-size: 11px; padding: 1px 6px; border-radius: 3px; font-weight: 500; flex-shrink: 0;
|
||||
&.badge-object { color: #2563eb; background: #eff6ff; }
|
||||
&.badge-array { color: #7c3aed; background: #f5f3ff; }
|
||||
}
|
||||
.node-count { font-size: 11px; color: #94a3b8; flex-shrink: 0; }
|
||||
.config-summary {
|
||||
font-size: 11px; color: #64748b; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.field-btn-configured {
|
||||
color: #2563eb !important;
|
||||
}
|
||||
.node-action-btn:not(.always-visible) { opacity: 0; transition: opacity 0.15s; margin-left: 2px; flex-shrink: 0; font-size: 14px; padding: 2px; }
|
||||
.node-action-btn.field-btn { opacity: 0.4; }
|
||||
.node-row:hover .node-action-btn.field-btn { opacity: 1; }
|
||||
.node-action-btn.always-visible { opacity: 1; }
|
||||
&:hover .node-action-btn { opacity: 1; }
|
||||
.node-action-delete { color: #ef4444; }
|
||||
}
|
||||
.node-row-object, .node-row-array {
|
||||
background: #fafbfc; border: 1px solid transparent;
|
||||
&:hover { border-color: #e2e8f0; }
|
||||
&.is-collapsed { border-color: transparent; background: transparent; }
|
||||
}
|
||||
.node-children {
|
||||
border-left: 1px solid #e2e8f0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
import JsonEditor from './index.vue'
|
||||
import JsonNode from './JsonNode.vue'
|
||||
import FieldConfigDialog from './FieldConfigDialog.vue'
|
||||
|
||||
export { JsonEditor, JsonNode, FieldConfigDialog }
|
||||
export default JsonEditor
|
||||
@@ -0,0 +1,554 @@
|
||||
<template>
|
||||
<div class="json-editor-split">
|
||||
<div class="tree-panel">
|
||||
<div class="tree-root">
|
||||
<JsonNode
|
||||
:node="rootNode" :depth="0" :is-root="true"
|
||||
@change="emitChange"
|
||||
@add-requested="openAddChildDialog"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectNodeId" class="config-sidebar">
|
||||
<FieldConfigDialog
|
||||
:key="selectNodeId + '-' + (fieldDialog.formData?._createParentId ? 'add' : 'edit')"
|
||||
:panel="true"
|
||||
:visible="true"
|
||||
:form-data="fieldDialog.formData"
|
||||
:field-type-options="fieldDialog.typeOptions"
|
||||
:has-config="fieldDialog.hasConfig"
|
||||
:upstream-nodes="props.upstreamNodes"
|
||||
:hide-form-config="props.hideFormConfig"
|
||||
@update:visible="clearSelection"
|
||||
@save="handleFieldSave"
|
||||
@remove="handleFieldRemove"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="config-sidebar config-sidebar-empty">
|
||||
<div class="config-placeholder">
|
||||
<p>选择一个字段</p>
|
||||
<p class="config-placeholder-hint">点击字段右侧的齿轮按钮进行配置</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-dialog v-model="showPasteDialog" title="粘贴 JSON" width="560px" :close-on-click-modal="false" destroy-on-close>
|
||||
<div class="paste-tip">
|
||||
<el-alert type="info" :closable="false" show-icon>
|
||||
<template #default>
|
||||
将 JSON 字符串粘贴到下方文本框,系统将解析并<strong>替换</strong>当前节点的内容。
|
||||
(将替换整个根节点)
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="pasteJsonText"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
placeholder="例如: { "name": "test", "age": 18 }"
|
||||
:autosize="{ minRows: 8, maxRows: 24 }"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="showPasteDialog = false; pasteJsonText = ''">取消</el-button>
|
||||
<el-button type="primary" @click="handlePasteJson">解析并替换</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, provide } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import JsonNode from './JsonNode.vue';
|
||||
import FieldConfigDialog from './FieldConfigDialog.vue';
|
||||
|
||||
defineOptions({ name: 'JsonEditor' });
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: any;
|
||||
upstreamNodes?: UpstreamNodeInfo[];
|
||||
hideFormConfig?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: any): void;
|
||||
(e: 'change', value: any): void;
|
||||
}>();
|
||||
|
||||
// --- types ---
|
||||
|
||||
interface FieldConfig {
|
||||
fieldType?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
role?: string;
|
||||
isForm?: boolean;
|
||||
required?: boolean;
|
||||
defaultValue?: string | number;
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
enumValues?: any[];
|
||||
valueSource?: { nodeId: string; field: string };
|
||||
constraint?: {
|
||||
minLength?: number; maxLength?: number; pattern?: string;
|
||||
min?: number; max?: number; numberType?: string;
|
||||
accept?: string; maxSize?: number; maxCount?: number;
|
||||
uploadRules?: Array<{ format: string; maxSize?: number; maxCount?: number }>;
|
||||
uploadTotalMaxCount?: number;
|
||||
uploadTotalMaxSize?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface JsonNodeData {
|
||||
_id: string; key: string; keyEditable: boolean;
|
||||
type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array';
|
||||
primitiveValue: string | number | boolean | null;
|
||||
children: JsonNodeData[];
|
||||
config?: FieldConfig;
|
||||
_isWrapped?: boolean;
|
||||
}
|
||||
|
||||
// 前驱节点的可引用输出字段(供「引用上级节点输出」)
|
||||
interface UpstreamNodeInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
nodeCode: string;
|
||||
outputFields: { field: string; label: string }[];
|
||||
}
|
||||
|
||||
interface FieldFormData {
|
||||
nodeKey: string;
|
||||
nodeId: string;
|
||||
jsonType: string;
|
||||
fieldType: string; label: string; description: string; role: string;
|
||||
isForm: boolean; required: boolean; defaultValue: string; options: Array<{ label: string; value: string }>;
|
||||
enumValues: any[];
|
||||
constraint: {
|
||||
minLength?: number; maxLength?: number; pattern?: string;
|
||||
min?: number; max?: number; numberType?: string;
|
||||
accept?: string; maxSize?: number; maxCount?: number;
|
||||
uploadRules?: Array<{ format: string; maxSize?: number; maxCount?: number }>;
|
||||
uploadTotalMaxCount?: number;
|
||||
uploadTotalMaxSize?: number;
|
||||
};
|
||||
_createParentId?: string;
|
||||
_isArrayParent?: boolean;
|
||||
_childrenCount?: number;
|
||||
valueSource?: { nodeId: string; field: string };
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
let idCounter = 0;
|
||||
function genId(): string {
|
||||
return `jn_${Date.now()}_${++idCounter}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
function getDefaultPrimitive(type: string): string | number | boolean | null {
|
||||
switch (type) {
|
||||
case 'number': return 0;
|
||||
case 'boolean': return false;
|
||||
case 'null': return null;
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
function valueToNode(value: any, key: string, keyEditable: boolean): JsonNodeData {
|
||||
const id = genId();
|
||||
if (value === null || value === undefined)
|
||||
return { _id: id, key, keyEditable, type: 'null', primitiveValue: null, children: [] };
|
||||
|
||||
// Detect embedded config: { type: '<jsonType>', value/attrs: <actualValue>, ...config }
|
||||
if (typeof value === 'object' && !Array.isArray(value) && 'type' in value) {
|
||||
const jtype = value.type;
|
||||
if (typeof jtype === 'string' && ['string','number','boolean','null','object','array'].includes(jtype)) {
|
||||
const dataKey = (jtype === 'object' || jtype === 'array') ? 'attrs' : 'value';
|
||||
if (dataKey in value) {
|
||||
const rawVal = value[dataKey];
|
||||
const cfg: Record<string, any> = {};
|
||||
for (const k of Object.keys(value)) {
|
||||
if (k !== 'type' && k !== dataKey) cfg[k] = (value as Record<string, any>)[k];
|
||||
}
|
||||
const hasCfg = Object.keys(cfg).length > 0;
|
||||
if (jtype === 'object') {
|
||||
const children = rawVal ? Object.entries(rawVal).map(([k, v]) => valueToNode(v, k, true)) : [];
|
||||
return { _id: id, key, keyEditable, type: 'object', primitiveValue: null, children, config: hasCfg ? cfg : undefined, _isWrapped: true };
|
||||
}
|
||||
if (jtype === 'array') {
|
||||
const children = Array.isArray(rawVal) ? rawVal.map((item, idx) => valueToNode(item, String(idx), false)) : [];
|
||||
return { _id: id, key, keyEditable, type: 'array', primitiveValue: null, children, config: hasCfg ? cfg : undefined, _isWrapped: true };
|
||||
}
|
||||
return {
|
||||
_id: id, key, keyEditable,
|
||||
type: jtype as 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array',
|
||||
primitiveValue: rawVal ?? getDefaultPrimitive(jtype),
|
||||
children: [],
|
||||
config: hasCfg ? cfg : undefined,
|
||||
_isWrapped: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(value))
|
||||
return { _id: id, key, keyEditable, type: 'array', primitiveValue: null, children: value.map((item, idx) => valueToNode(item, String(idx), false)) };
|
||||
if (typeof value === 'object')
|
||||
return { _id: id, key, keyEditable, type: 'object', primitiveValue: null, children: Object.entries(value).map(([k, v]) => valueToNode(v, k, true)) };
|
||||
return { _id: id, key, keyEditable, type: typeof value as 'string' | 'number' | 'boolean', primitiveValue: value, children: [] };
|
||||
}
|
||||
|
||||
function nodeToValue(node: JsonNodeData): any {
|
||||
let rawValue: any;
|
||||
if (node.type === 'object') { const obj: Record<string, any> = {}; for (const c of node.children) obj[c.key] = nodeToValue(c); rawValue = obj; }
|
||||
else if (node.type === 'array') rawValue = node.children.map(c => nodeToValue(c));
|
||||
else if (node.type === 'null') rawValue = null;
|
||||
else if (node.type === 'number') rawValue = Number(node.primitiveValue);
|
||||
else if (node.type === 'boolean') rawValue = node.primitiveValue === true || node.primitiveValue === 'true';
|
||||
else rawValue = String(node.primitiveValue);
|
||||
|
||||
// 最外层根 object 不套 { type, attrs },直接返回原始对象
|
||||
if (node === rootNode.value) return rawValue;
|
||||
|
||||
// 容器类型(object/array)始终输出 { type, attrs, ...config }
|
||||
if (node.type === 'object' || node.type === 'array') {
|
||||
const result: Record<string, any> = { type: node.type, attrs: rawValue };
|
||||
if (node.config && hasAnyConfig(node)) {
|
||||
Object.assign(result, node.config);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 标量类型也始终输出 { type, value, ...config }
|
||||
const result: Record<string, any> = { type: node.type, value: rawValue };
|
||||
if (node.config && hasAnyConfig(node)) {
|
||||
Object.assign(result, node.config);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function defaultFormType(jsonType: string): string {
|
||||
const m: Record<string, string> = { string: 'string', number: 'number', boolean: 'switch' };
|
||||
return m[jsonType] || 'string';
|
||||
}
|
||||
|
||||
/** 收集当前字段同级及以上的所有可选字段(平铺,含完整路径) */
|
||||
// --- paste JSON ---
|
||||
|
||||
const showPasteDialog = ref(false);
|
||||
const pasteJsonText = ref('');
|
||||
const pastingNodeId = ref<string | null>(null);
|
||||
|
||||
function openPasteDialog(nodeId: string | null) {
|
||||
pastingNodeId.value = nodeId;
|
||||
pasteJsonText.value = '';
|
||||
showPasteDialog.value = true;
|
||||
}
|
||||
|
||||
function handlePasteJson() {
|
||||
const text = pasteJsonText.value.trim();
|
||||
if (!text) { ElMessage.warning('请粘贴 JSON 内容'); return; }
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
|
||||
if (pastingNodeId.value) {
|
||||
const node = findNodeById(pastingNodeId.value);
|
||||
if (!node || (node.type !== 'object' && node.type !== 'array')) {
|
||||
ElMessage.warning('只能在对象或数组节点上粘贴 JSON');
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(parsed)) {
|
||||
node.type = 'array';
|
||||
node.children = parsed.map((item, idx) => valueToNode(item, String(idx), false));
|
||||
} else if (typeof parsed === 'object' && parsed !== null) {
|
||||
node.type = 'object';
|
||||
node.children = Object.entries(parsed).map(([k, v]) => valueToNode(v, k, true));
|
||||
} else {
|
||||
ElMessage.warning('粘贴的内容必须是对象或数组');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
rootNode.value = valueToNode(parsed, '', false);
|
||||
}
|
||||
|
||||
showPasteDialog.value = false;
|
||||
pasteJsonText.value = '';
|
||||
emitChange();
|
||||
} catch (e: any) {
|
||||
ElMessage.error('JSON 格式错误: ' + (e.message || ''));
|
||||
}
|
||||
}
|
||||
|
||||
// --- field config dialog ---
|
||||
|
||||
const typeOptionList: { label: string; value: string }[] = [
|
||||
{ label: '文本框', value: 'string' },
|
||||
{ label: '多行文本', value: 'textarea' },
|
||||
{ label: '数字输入', value: 'number' },
|
||||
{ label: '开关', value: 'switch' },
|
||||
{ label: '下拉选择', value: 'select' },
|
||||
{ label: '文件上传', value: 'upload' },
|
||||
];
|
||||
|
||||
const selectNodeId = ref<string | null>(null);
|
||||
|
||||
const fieldDialog = ref({
|
||||
visible: false,
|
||||
formData: null as FieldFormData | null,
|
||||
typeOptions: [] as { label: string; value: string }[],
|
||||
hasConfig: false,
|
||||
});
|
||||
|
||||
function clearSelection() {
|
||||
selectNodeId.value = null;
|
||||
fieldDialog.value.visible = false;
|
||||
fieldDialog.value.formData = null;
|
||||
}
|
||||
|
||||
/** Extract FieldConfig from form data */
|
||||
function formToConfig(form: FieldFormData): FieldConfig {
|
||||
const cfg: FieldConfig = {};
|
||||
// 骨架字段:始终发送,保证后端收到完整结构
|
||||
cfg.label = form.label || '';
|
||||
cfg.fieldType = form.fieldType || defaultFormType(form.jsonType);
|
||||
cfg.isForm = form.isForm ?? true;
|
||||
cfg.required = form.required ?? false;
|
||||
// 非骨架字段:有值才发
|
||||
if (form.description) cfg.description = form.description;
|
||||
if (form.role) cfg.role = form.role;
|
||||
if (form.defaultValue) cfg.defaultValue = form.jsonType === 'number' ? Number(form.defaultValue) : form.defaultValue;
|
||||
if (form.options?.length > 0) cfg.options = [...form.options];
|
||||
if (form.enumValues?.length > 0) cfg.enumValues = JSON.parse(JSON.stringify(form.enumValues));
|
||||
if (form.valueSource) cfg.valueSource = { ...form.valueSource };
|
||||
const hasC = Object.values(form.constraint).some((v: any) => {
|
||||
if (Array.isArray(v)) return v.length > 0;
|
||||
return v !== undefined && v !== null && v !== '';
|
||||
});
|
||||
if (hasC) {
|
||||
cfg.constraint = { ...form.constraint };
|
||||
if (cfg.constraint.uploadRules) {
|
||||
cfg.constraint.uploadRules = cfg.constraint.uploadRules.map(r => ({ ...r }));
|
||||
}
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** Check if a node has any meaningful config beyond default skeleton values */
|
||||
function hasAnyConfig(node: JsonNodeData): boolean {
|
||||
const c = node.config;
|
||||
if (!c) return false;
|
||||
if (c.label || c.description || c.role || c.defaultValue) return true;
|
||||
if (c.options?.length) return true;
|
||||
if (c.enumValues?.length) return true;
|
||||
if (c.valueSource) return true;
|
||||
if (c.required) return true;
|
||||
if (c.isForm === false) return true;
|
||||
if (c.constraint && Object.keys(c.constraint).length > 0) return true;
|
||||
if (c.fieldType && c.fieldType !== defaultFormType(node.type)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Open dialog in edit mode for an existing node */
|
||||
function openFieldDialog(nodeId: string) {
|
||||
const node = findNodeById(nodeId);
|
||||
if (!node) return;
|
||||
const cfg = node.config || {};
|
||||
fieldDialog.value = {
|
||||
visible: true,
|
||||
typeOptions: typeOptionList,
|
||||
hasConfig: hasAnyConfig(node),
|
||||
formData: {
|
||||
nodeId: node._id,
|
||||
nodeKey: node.key,
|
||||
jsonType: node.type,
|
||||
fieldType: cfg.fieldType || defaultFormType(node.type),
|
||||
label: cfg.label || '',
|
||||
description: cfg.description || '',
|
||||
role: cfg.role || '',
|
||||
isForm: cfg.isForm ?? true,
|
||||
required: cfg.required ?? false,
|
||||
defaultValue: String(cfg.defaultValue ?? ''),
|
||||
options: cfg.options || [],
|
||||
constraint: cfg.constraint ? { ...cfg.constraint } : {},
|
||||
enumValues: cfg.enumValues ? JSON.parse(JSON.stringify(cfg.enumValues)) : [],
|
||||
valueSource: cfg.valueSource ? { ...cfg.valueSource } : undefined,
|
||||
_childrenCount: node.children.length,
|
||||
},
|
||||
};
|
||||
selectNodeId.value = nodeId;
|
||||
}
|
||||
|
||||
provide('onOpenFieldDialog', openFieldDialog);
|
||||
|
||||
/** Open dialog in create mode for adding a new child to a parent node */
|
||||
function openAddChildDialog(parentNodeId: string) {
|
||||
const parent = findNodeById(parentNodeId);
|
||||
if (!parent || (parent.type !== 'object' && parent.type !== 'array')) return;
|
||||
|
||||
const isArray = parent.type === 'array';
|
||||
fieldDialog.value = {
|
||||
visible: true,
|
||||
typeOptions: typeOptionList,
|
||||
hasConfig: false,
|
||||
formData: {
|
||||
nodeId: '',
|
||||
nodeKey: '',
|
||||
jsonType: 'string',
|
||||
fieldType: 'string',
|
||||
label: '',
|
||||
description: '',
|
||||
role: '',
|
||||
isForm: true,
|
||||
required: false,
|
||||
defaultValue: '',
|
||||
options: [],
|
||||
constraint: {},
|
||||
enumValues: [],
|
||||
valueSource: undefined,
|
||||
_createParentId: parentNodeId,
|
||||
_isArrayParent: isArray,
|
||||
_childrenCount: 0,
|
||||
},
|
||||
};
|
||||
selectNodeId.value = parentNodeId;
|
||||
}
|
||||
|
||||
/** Handle save from the config dialog — supports both create and edit modes */
|
||||
function handleFieldSave(form: FieldFormData) {
|
||||
if (form._createParentId) {
|
||||
// --- create mode ---
|
||||
const parent = findNodeById(form._createParentId);
|
||||
if (!parent || (parent.type !== 'object' && parent.type !== 'array')) return;
|
||||
|
||||
const isArray = parent.type === 'array';
|
||||
const newNode: JsonNodeData = {
|
||||
_id: genId(),
|
||||
key: isArray ? (form.nodeKey || String(parent.children.length)) : (form.nodeKey || 'newKey'),
|
||||
keyEditable: !isArray,
|
||||
type: form.jsonType as JsonNodeData['type'],
|
||||
primitiveValue: getDefaultPrimitive(form.jsonType),
|
||||
children: (form.jsonType === 'object' || form.jsonType === 'array') ? [] : [],
|
||||
config: formToConfig(form),
|
||||
};
|
||||
|
||||
parent.children.push(newNode);
|
||||
fieldDialog.value.visible = false;
|
||||
emitChange();
|
||||
clearSelection();
|
||||
} else {
|
||||
// --- edit mode ---
|
||||
const node = findNodeById(form.nodeId);
|
||||
if (!node) return;
|
||||
|
||||
node.key = form.nodeKey;
|
||||
node.type = form.jsonType as JsonNodeData['type'];
|
||||
node.config = formToConfig(form);
|
||||
|
||||
if (node.type === 'object' || node.type === 'array') {
|
||||
if (!node.children) node.children = [];
|
||||
} else {
|
||||
node.children = [];
|
||||
if (node.primitiveValue === undefined || node.primitiveValue === null || node.type === 'null') {
|
||||
node.primitiveValue = getDefaultPrimitive(node.type);
|
||||
}
|
||||
}
|
||||
|
||||
fieldDialog.value.visible = false;
|
||||
emitChange();
|
||||
clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFieldRemove() {
|
||||
const form = fieldDialog.value.formData;
|
||||
if (!form) return;
|
||||
if (form._createParentId) {
|
||||
fieldDialog.value.visible = false;
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
const node = findNodeById(form.nodeId);
|
||||
if (node) {
|
||||
delete node.config;
|
||||
fieldDialog.value.visible = false;
|
||||
emitChange();
|
||||
clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
// --- root node management ---
|
||||
|
||||
function createDefaultNode(type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array', key: string, keyEditable: boolean): JsonNodeData {
|
||||
const id = genId();
|
||||
const base = { _id: id, key, keyEditable, children: [] as JsonNodeData[] };
|
||||
switch (type) {
|
||||
case 'object': return { ...base, type: 'object', primitiveValue: null };
|
||||
case 'array': return { ...base, type: 'array', primitiveValue: null };
|
||||
case 'null': return { ...base, type: 'null', primitiveValue: null };
|
||||
case 'number': return { ...base, type: 'number', primitiveValue: 0 };
|
||||
case 'boolean': return { ...base, type: 'boolean', primitiveValue: false };
|
||||
default: return { ...base, type: 'string', primitiveValue: '' };
|
||||
}
|
||||
}
|
||||
|
||||
const rootNode = ref<JsonNodeData>(createDefaultNode('object', '', false));
|
||||
let skipNextWatch = false;
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (skipNextWatch) { skipNextWatch = false; return; }
|
||||
rootNode.value = val === undefined || val === null ? createDefaultNode('object', '', false) : valueToNode(val, '', false);
|
||||
}, { deep: true, immediate: true });
|
||||
|
||||
function emitChange() {
|
||||
skipNextWatch = true;
|
||||
const json = nodeToValue(rootNode.value);
|
||||
emit('update:modelValue', json);
|
||||
emit('change', json);
|
||||
}
|
||||
|
||||
function findNodeById(nodeId: string, from?: JsonNodeData): JsonNodeData | null {
|
||||
const s = (n: JsonNodeData): JsonNodeData | null => {
|
||||
if (n._id === nodeId) return n;
|
||||
for (const c of n.children) { const f = s(c); if (f) return f; }
|
||||
return null;
|
||||
};
|
||||
return s(from || rootNode.value);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getData: () => nodeToValue(rootNode.value),
|
||||
setData: (d: any) => { rootNode.value = valueToNode(d, '', false); },
|
||||
openPasteDialog: () => openPasteDialog(null),
|
||||
getSelectedNodeId: () => selectNodeId.value,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.json-editor-split {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
.tree-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
.tree-root {
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
.config-sidebar {
|
||||
width: 380px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.config-sidebar-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.config-placeholder {
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
p { margin: 4px 0; }
|
||||
.config-placeholder-hint { font-size: 11px; color: #c0c8d4; }
|
||||
}
|
||||
}
|
||||
.paste-tip { margin-bottom: 12px;
|
||||
:deep(.el-alert__description) { font-size: 13px; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -118,15 +118,22 @@ interface ModelItem {
|
||||
apiKey?: string;
|
||||
isPrivate?: number;
|
||||
isChatModel?: number;
|
||||
headMsg?: string;
|
||||
headMsg?: string | Record<string, string>;
|
||||
operatorName?: string;
|
||||
responseBody?: Record<string, unknown>;
|
||||
responseTokenField?: string;
|
||||
tokenConfig?: Record<string, unknown> | string;
|
||||
extendMapping?: Record<string, unknown> | string;
|
||||
queryConfig?: Record<string, unknown>;
|
||||
streamConfig?: Record<string, unknown>;
|
||||
form?: ModelFormEntry[] | Record<string, unknown>;
|
||||
requestMapping?: Record<string, unknown>;
|
||||
requiredFields?: string[];
|
||||
firstFrame?: string;
|
||||
lastFrame?: string;
|
||||
responseMapping?: Record<string, unknown>;
|
||||
callMode?: number;
|
||||
isAsync?: number;
|
||||
maxConcurrency?: number;
|
||||
queueLimit?: number;
|
||||
timeoutSeconds?: number;
|
||||
@@ -271,74 +278,71 @@ const fieldsToUnknownObject = (fields: Array<{ key: string; value: string }>) =>
|
||||
return obj;
|
||||
};
|
||||
|
||||
const flattenNestedObject = (obj: Record<string, unknown>, prefix = ''): Array<{ key: string; value: string }> => {
|
||||
const rows: Array<{ key: string; value: string }> = [];
|
||||
Object.entries(obj || {}).forEach(([k, v]) => {
|
||||
const fk = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
||||
rows.push(...flattenNestedObject(v as Record<string, unknown>, fk));
|
||||
return;
|
||||
}
|
||||
rows.push({ key: fk, value: String(v ?? '') });
|
||||
});
|
||||
return rows;
|
||||
};
|
||||
|
||||
const nestFieldsToObject = (fields: Array<{ key: string; value: string }>) => {
|
||||
const root: Record<string, unknown> = {};
|
||||
fields.forEach((f) => {
|
||||
const path = String(f.key || '').trim();
|
||||
if (!path) return;
|
||||
const parts = path
|
||||
.split('.')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
if (!parts.length) return;
|
||||
let cur: Record<string, unknown> = root;
|
||||
parts.forEach((part, idx) => {
|
||||
if (idx === parts.length - 1) {
|
||||
cur[part] = String(f.value ?? '');
|
||||
return;
|
||||
}
|
||||
if (!cur[part] || typeof cur[part] !== 'object' || Array.isArray(cur[part])) {
|
||||
cur[part] = {};
|
||||
}
|
||||
cur = cur[part] as Record<string, unknown>;
|
||||
});
|
||||
});
|
||||
return root;
|
||||
};
|
||||
|
||||
const buildQueryConfigFromRaw = (rawQc: Record<string, unknown> | null): Record<string, unknown> => {
|
||||
if (!rawQc) return { responseType: 'sync', callbackUrl: '' };
|
||||
const rt = String(rawQc.responseType || 'sync');
|
||||
if (rt === 'callback') return { responseType: 'callback', callbackUrl: String(rawQc.callbackUrl || '') };
|
||||
if (rt === 'pull') {
|
||||
const hFields = Object.entries((rawQc.headers as Record<string, unknown>) || {}).map(([k, v]) => ({ key: k, value: String(v ?? '') }));
|
||||
const bFields = flattenNestedObject((rawQc.body as Record<string, unknown>) || {});
|
||||
return {
|
||||
responseType: 'pull',
|
||||
callbackUrl: '',
|
||||
method: String(rawQc.method || 'GET'),
|
||||
url: String(rawQc.url || ''),
|
||||
headers: fieldsToUnknownObject(hFields),
|
||||
body: nestFieldsToObject(bFields),
|
||||
response: ((rawQc.response as unknown[]) || [])
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return { value: item, isTokenField: false, isMainBody: false };
|
||||
}
|
||||
const row = item as Record<string, unknown>;
|
||||
return {
|
||||
value: String(row.value ?? ''),
|
||||
isTokenField: Boolean(row.isTokenField),
|
||||
isMainBody: Boolean(row.isMainBody),
|
||||
};
|
||||
})
|
||||
.filter((row) => row.value !== ''),
|
||||
};
|
||||
const parseFormEntries = (raw: unknown): ModelFormEntry[] => {
|
||||
if (Array.isArray(raw)) {
|
||||
return (raw as ModelFormEntry[])
|
||||
.filter((item) => item && item.key !== undefined)
|
||||
.map((item) => ({
|
||||
key: String(item.key ?? '').trim(),
|
||||
value: String(item.value ?? ''),
|
||||
}))
|
||||
.filter((item) => item.key !== '');
|
||||
}
|
||||
return { responseType: 'sync', callbackUrl: '' };
|
||||
|
||||
if (raw && typeof raw === 'object') {
|
||||
return Object.entries(raw as Record<string, unknown>).map(([key, value]) => {
|
||||
let nextValue = value;
|
||||
if (nextValue && typeof nextValue === 'object' && !Array.isArray(nextValue) && 'value' in nextValue) {
|
||||
nextValue = (nextValue as { value: unknown }).value;
|
||||
}
|
||||
return {
|
||||
key: String(key).trim(),
|
||||
value: String(nextValue ?? ''),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const normalizeQueryConfig = (raw: unknown): Record<string, unknown> => {
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
return raw as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const parseHeadMsgRecord = (raw: ModelItem['headMsg']) => {
|
||||
const headMsgRecord: Record<string, string> = {};
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
Object.entries(parsed).forEach(([k, v]) => {
|
||||
headMsgRecord[k] = String(v);
|
||||
});
|
||||
return headMsgRecord;
|
||||
}
|
||||
} catch {
|
||||
const pairs = raw.split(',');
|
||||
pairs.forEach((pair) => {
|
||||
const idx = pair.indexOf(':');
|
||||
if (idx === -1) return;
|
||||
const key = pair.slice(0, idx).trim();
|
||||
const value = pair.slice(idx + 1).trim();
|
||||
if (key) {
|
||||
headMsgRecord[key] = value;
|
||||
}
|
||||
});
|
||||
return headMsgRecord;
|
||||
}
|
||||
}
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
Object.entries(raw).forEach(([k, v]) => {
|
||||
headMsgRecord[k] = String(v);
|
||||
});
|
||||
}
|
||||
return headMsgRecord;
|
||||
};
|
||||
|
||||
const fetchModelList = async () => {
|
||||
@@ -396,25 +400,25 @@ const handleCreatePrivateModel = async () => {
|
||||
creatingModel.value = true;
|
||||
|
||||
const builtInModel = builtInModelToClone.value;
|
||||
const formList: ModelFormEntry[] = Array.isArray(builtInModel.form)
|
||||
? (builtInModel.form as ModelFormEntry[])
|
||||
: Object.entries((builtInModel.form as Record<string, unknown>) || {}).map(([key, value]) => ({
|
||||
key: String(key),
|
||||
value: String(value ?? ''),
|
||||
}));
|
||||
const formList = parseFormEntries(builtInModel.form);
|
||||
|
||||
const createParams: CreateModelParams = {
|
||||
modelName: apiKeyForm.modelName,
|
||||
modelType: builtInModel.modelType,
|
||||
operatorName: builtInModel.operatorName || '',
|
||||
baseUrl: builtInModel.baseUrl,
|
||||
httpMethod: builtInModel.httpMethod || 'POST',
|
||||
headMsg: builtInModel.headMsg || '',
|
||||
headMsg: parseHeadMsgRecord(builtInModel.headMsg),
|
||||
isPrivate: builtInModel.isPrivate ?? 1,
|
||||
enabled: builtInModel.enabled ?? 1,
|
||||
isChatModel: builtInModel.isChatModel || 0,
|
||||
callMode: builtInModel.callMode ?? builtInModel.isAsync ?? 0,
|
||||
apiKey: apiKeyForm.apiKey,
|
||||
form: formList,
|
||||
requestMapping: (builtInModel.requestMapping as Record<string, unknown>) || {},
|
||||
requiredFields: Array.isArray(builtInModel.requiredFields) ? builtInModel.requiredFields : [],
|
||||
firstFrame: String(builtInModel.firstFrame || ''),
|
||||
lastFrame: String(builtInModel.lastFrame || ''),
|
||||
responseMapping: (builtInModel.responseMapping as Record<string, unknown>) || {},
|
||||
responseBody: builtInModel.responseBody || {},
|
||||
maxConcurrency: builtInModel.maxConcurrency || 10,
|
||||
@@ -424,19 +428,20 @@ const handleCreatePrivateModel = async () => {
|
||||
retryTimes: builtInModel.retryTimes || 3,
|
||||
retryQueueMaxSeconds: builtInModel.retryQueueMaxSeconds || 60,
|
||||
autoCleanSeconds: builtInModel.autoCleanSeconds || 300,
|
||||
remark: builtInModel.remark || '',
|
||||
|
||||
extendMapping: fieldsToUnknownObject(
|
||||
Object.entries(parseJsonObjectField(builtInModel.extendMapping)).map(([k, v]) => ({ key: k, value: String(v ?? '') }))
|
||||
),
|
||||
tokenConfig: fieldsToUnknownObject(
|
||||
Object.entries(parseJsonObjectField(builtInModel.tokenConfig)).map(([k, v]) => ({ key: k, value: String(v ?? '') }))
|
||||
),
|
||||
queryConfig: buildQueryConfigFromRaw(
|
||||
builtInModel.queryConfig && typeof builtInModel.queryConfig === 'object' && !Array.isArray(builtInModel.queryConfig)
|
||||
? (builtInModel.queryConfig as Record<string, unknown>)
|
||||
: null
|
||||
),
|
||||
queryConfig: normalizeQueryConfig(builtInModel.queryConfig),
|
||||
responseTokenField: String(builtInModel.responseTokenField || ''),
|
||||
streamConfig:
|
||||
Number(builtInModel.callMode ?? builtInModel.isAsync ?? 0) === 2
|
||||
? builtInModel.streamConfig && typeof builtInModel.streamConfig === 'object' && !Array.isArray(builtInModel.streamConfig)
|
||||
? (builtInModel.streamConfig as Record<string, unknown>)
|
||||
: {}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const res: any = await addModelModule(createParams);
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
<template>
|
||||
<div class="patch-template-editor">
|
||||
<el-divider content-position="left">贴片模板</el-divider>
|
||||
|
||||
<div v-for="(template, index) in modelValue" :key="index" class="template-card">
|
||||
<div class="template-card-header">
|
||||
<span class="template-card-title">模板 #{{ index + 1 }}</span>
|
||||
<el-button type="danger" :icon="Delete" circle size="small" @click="removeTemplate(index)" />
|
||||
</div>
|
||||
|
||||
<div class="template-card-body">
|
||||
<!-- 模板文件上传 -->
|
||||
<div class="template-field-row">
|
||||
<span class="field-label">模板文件</span>
|
||||
<div class="field-control">
|
||||
<el-upload
|
||||
:key="`upload-${index}`"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:accept="'.html,.htm'"
|
||||
:on-change="(file: any) => handleTemplateUpload(index, file)"
|
||||
>
|
||||
<el-button size="small" type="primary" :loading="uploadingIndex === index" :disabled="uploadingIndex !== null">
|
||||
<el-icon><Upload /></el-icon> {{ uploadingIndex === index ? '上传中...' : (template.url ? '替换模板' : '上传模板') }}
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<el-button size="small" @click="handleDownload">
|
||||
<el-icon><Download /></el-icon> 下载参考模板
|
||||
</el-button>
|
||||
<el-tag v-if="template.url" :type="template.url === DEFAULT_TEMPLATE_URL ? 'info' : 'success'" size="small" class="uploaded-tag">{{ template.url === DEFAULT_TEMPLATE_URL ? '已有默认模板' : '已上传' }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 起始/结束位置 -->
|
||||
<div class="template-field-row">
|
||||
<span class="field-label">开始结束时间</span>
|
||||
<div class="field-control field-control-inline">
|
||||
<el-input-number v-model="template.start" :min="0" :max="99999" size="small" controls-position="right" class="inline-number" />
|
||||
<span class="inline-separator">~</span>
|
||||
<el-input-number v-model="template.end" :min="0" :max="99999" size="small" controls-position="right" class="inline-number" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="template-field-row">
|
||||
<span class="field-label">标题</span>
|
||||
<div class="field-control">
|
||||
<el-input v-model="template.data.title" placeholder="可选填,如果没有内容将会没有贴片信息" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 产品图片(动态追加) -->
|
||||
<div class="template-field-row template-field-row-vertical">
|
||||
<span class="field-label">图片</span>
|
||||
<div class="field-control field-control-images">
|
||||
<div v-for="(img, imgIdx) in getProductImages(template)" :key="imgIdx" class="image-item">
|
||||
<span class="image-index">#{{ imgIdx + 1 }}</span>
|
||||
<div v-if="img.url" class="image-preview-wrapper">
|
||||
<img :src="img.url" class="image-preview" @click="previewImage(img.url)" />
|
||||
<el-button class="image-remove-btn" type="danger" :icon="Close" circle size="small" @click="removeProductImage(template, imgIdx)" />
|
||||
</div>
|
||||
<el-upload
|
||||
v-else
|
||||
:key="`img-${index}-${imgIdx}`"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:accept="'image/*'"
|
||||
:on-change="(file: any) => handleImageUpload(index, imgIdx, file)"
|
||||
>
|
||||
<el-button size="small" type="primary" class="image-upload-btn">
|
||||
<el-icon><Plus /></el-icon> 上传图片
|
||||
</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
<el-button size="small" class="add-image-btn" @click="addProductImage(template)">
|
||||
<el-icon><Plus /></el-icon> 添加图片
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 产品描述 -->
|
||||
<div class="template-field-row template-field-row-vertical">
|
||||
<span class="field-label">产品描述</span>
|
||||
<div class="field-control">
|
||||
<el-input
|
||||
v-model="template.data.product_desc"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="可选填,如果没有内容将会没有贴片信息"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 产品信息 -->
|
||||
<div class="template-field-row template-field-row-vertical">
|
||||
<span class="field-label">产品信息</span>
|
||||
<div class="field-control">
|
||||
<el-input
|
||||
v-model="template.data.product_info"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="可选填,如果没有内容将会没有贴片信息"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 免责声明 -->
|
||||
<div class="template-field-row template-field-row-vertical">
|
||||
<span class="field-label">免责声明</span>
|
||||
<div class="field-control">
|
||||
<el-input v-model="template.data.disclaimer" type="textarea" :rows="2" placeholder="可选填,如果没有内容将会没有贴片信息" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加模板按钮 -->
|
||||
<el-button type="primary" :icon="Plus" class="add-template-btn" @click="addTemplate"> 添加模板 </el-button>
|
||||
|
||||
<!-- 图片预览弹窗 -->
|
||||
<el-image-viewer v-if="previewVisible" :url-list="[previewUrl]" @close="previewVisible = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Plus, Delete, Close, Upload, Download } from '@element-plus/icons-vue';
|
||||
import { uploadFile } from '/@/api/common/upload';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
export interface PatchTemplate {
|
||||
url: string;
|
||||
start: number;
|
||||
end: number;
|
||||
data: {
|
||||
title: string;
|
||||
product_desc: string;
|
||||
product_info: string;
|
||||
disclaimer: string;
|
||||
[key: string]: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取模板中所有产品图片(按 product_image_N 顺序,含已添加的空槽位) */
|
||||
function getProductImages(template: PatchTemplate): { key: string; url: string }[] {
|
||||
const images: { key: string; url: string }[] = [];
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
const key = `product_image_${i}`;
|
||||
if (key in template.data) {
|
||||
images.push({ key, url: template.data[key] || '' });
|
||||
}
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
/** 获取下一个可用的 product_image_N key */
|
||||
function nextImageKey(template: PatchTemplate): string | null {
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
const key = `product_image_${i}`;
|
||||
if (!template.data[key]) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const DEFAULT_TEMPLATE_URL = 'https://cdn.redpowerfuture.com/tenantid-1/vertical_template_example.html';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: PatchTemplate[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: PatchTemplate[]): void;
|
||||
}>();
|
||||
|
||||
// 图片预览
|
||||
const previewVisible = ref(false);
|
||||
const previewUrl = ref('');
|
||||
// 模板文件上传状态(记录正在上传的模板索引)
|
||||
const uploadingIndex = ref<number | null>(null);
|
||||
|
||||
const previewImage = (url: string) => {
|
||||
previewUrl.value = url;
|
||||
previewVisible.value = true;
|
||||
};
|
||||
|
||||
const updateTemplates = (newTemplates: PatchTemplate[]) => {
|
||||
emit('update:modelValue', [...newTemplates]);
|
||||
};
|
||||
|
||||
const addTemplate = () => {
|
||||
const newTemplate: PatchTemplate = {
|
||||
url: DEFAULT_TEMPLATE_URL,
|
||||
start: 0,
|
||||
end: 0,
|
||||
data: {
|
||||
title: '',
|
||||
product_image_1: '',
|
||||
product_desc: '',
|
||||
product_info: '',
|
||||
disclaimer: '',
|
||||
},
|
||||
};
|
||||
updateTemplates([...props.modelValue, newTemplate]);
|
||||
};
|
||||
|
||||
const removeTemplate = (index: number) => {
|
||||
const newList = props.modelValue.filter((_, i) => i !== index);
|
||||
updateTemplates(newList);
|
||||
};
|
||||
|
||||
const addProductImage = (template: PatchTemplate) => {
|
||||
const key = nextImageKey(template);
|
||||
if (!key) {
|
||||
ElMessage.warning('产品图片最多 20 张');
|
||||
return;
|
||||
}
|
||||
template.data[key] = '';
|
||||
updateTemplates([...props.modelValue]);
|
||||
};
|
||||
|
||||
const removeProductImage = (template: PatchTemplate, imgIdx: number) => {
|
||||
// 删除该图片,并把后面的图片 key 前移
|
||||
const images = getProductImages(template);
|
||||
if (imgIdx >= images.length) return;
|
||||
|
||||
const removedKey = images[imgIdx].key;
|
||||
delete template.data[removedKey];
|
||||
|
||||
// 将后面的图片往前挪
|
||||
for (let i = imgIdx + 1; i < images.length; i++) {
|
||||
const oldKey = images[i].key;
|
||||
const newKey = `product_image_${i}`;
|
||||
if (oldKey in template.data) {
|
||||
template.data[newKey] = template.data[oldKey];
|
||||
if (newKey !== oldKey) {
|
||||
delete template.data[oldKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateTemplates([...props.modelValue]);
|
||||
};
|
||||
|
||||
const handleTemplateUpload = async (index: number, file: any) => {
|
||||
const raw = file.raw;
|
||||
if (!raw) return;
|
||||
uploadingIndex.value = index;
|
||||
|
||||
try {
|
||||
const uploadRes = await uploadFile(raw, { timeout: 0 });
|
||||
if (!uploadRes?.data?.fileURL) throw new Error('上传失败:未返回文件URL');
|
||||
|
||||
const fileUrl = uploadRes.data.fileAddressPrefix ? `${uploadRes.data.fileAddressPrefix}${uploadRes.data.fileURL}` : uploadRes.data.fileURL;
|
||||
|
||||
const newList = [...props.modelValue];
|
||||
newList[index].url = fileUrl;
|
||||
updateTemplates(newList);
|
||||
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || "模板上传失败");
|
||||
} finally {
|
||||
uploadingIndex.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = async (templateIndex: number, imgIdx: number, file: any) => {
|
||||
const raw = file.raw;
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const uploadRes = await uploadFile(raw, { timeout: 0 });
|
||||
if (!uploadRes?.data?.fileURL) throw new Error('上传失败:未返回文件URL');
|
||||
|
||||
const fileUrl = uploadRes.data.fileAddressPrefix ? `${uploadRes.data.fileAddressPrefix}${uploadRes.data.fileURL}` : uploadRes.data.fileURL;
|
||||
|
||||
const newList = [...props.modelValue];
|
||||
const images = getProductImages(newList[templateIndex]);
|
||||
if (imgIdx < images.length) {
|
||||
newList[templateIndex].data[images[imgIdx].key] = fileUrl;
|
||||
}
|
||||
updateTemplates(newList);
|
||||
|
||||
ElMessage.success('图片上传成功');
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '图片上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 下载参考模板文件:统一走 CDN,转 blob 触发下载(避免跨域 <a download> 直接打开页面)
|
||||
const handleDownload = async (_e?: Event) => {
|
||||
try {
|
||||
const res = await fetch(DEFAULT_TEMPLATE_URL);
|
||||
if (!res.ok) throw new Error('参考模板获取失败');
|
||||
const blob = await res.blob();
|
||||
const fileName = decodeURIComponent(DEFAULT_TEMPLATE_URL.split('/').pop() || 'vertical_template_example.html');
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '参考模板下载失败');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.patch-template-editor {
|
||||
width: 100%;
|
||||
|
||||
.template-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
|
||||
.template-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #fafbfc;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
.template-card-title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
}
|
||||
}
|
||||
|
||||
.template-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.template-field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
gap: 8px;
|
||||
|
||||
&.template-field-row-vertical {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
min-width: 70px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.field-control {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&.field-control-inline {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.inline-number {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.inline-separator {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.uploaded-tag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.field-control-images {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.image-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.image-index {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.image-preview-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
.image-preview {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.image-remove-btn {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.image-upload-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-image-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-template-btn {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -24,6 +24,12 @@
|
||||
<!-- </el-dropdown-menu>-->
|
||||
<!-- </template>-->
|
||||
<!-- </el-dropdown>-->
|
||||
<!-- 租户名称和余额 -->
|
||||
<div class="layout-navbars-breadcrumb-tenant" v-if="tenantInfo">
|
||||
<span class="tenant-name">租户: {{ tenantInfo.tenantName }}</span>
|
||||
<span class="tenant-divider">|</span>
|
||||
<span class="tenant-balance">余额: {{ tenantInfo.surplus.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="layout-navbars-breadcrumb-user-icon" @click="onSearchClick">
|
||||
<el-icon :title="$t('message.user.title2')">
|
||||
<ele-Search />
|
||||
@@ -94,6 +100,7 @@ import UserNews from '/@/layout/navBars/breadcrumb/userNews.vue';
|
||||
import Search from '/@/layout/navBars/breadcrumb/search.vue';
|
||||
import {logout} from "/@/api/login";
|
||||
import {removeCache} from "/@/api/system/cache";
|
||||
import { getTenantDetails } from "/@/api/system/tenant";
|
||||
|
||||
export default defineComponent({
|
||||
name: 'layoutBreadcrumbUser',
|
||||
@@ -112,6 +119,25 @@ export default defineComponent({
|
||||
disabledI18n: 'zh-cn',
|
||||
disabledSize: 'large',
|
||||
});
|
||||
// 租户信息
|
||||
const tenantInfo = ref<{ tenantName: string; surplus: number } | null>(null);
|
||||
// 获取租户信息
|
||||
const initTenantInfo = async () => {
|
||||
const userInfo = Session.get('userInfo') as any;
|
||||
const tenantId = userInfo?.tenantId;
|
||||
if (!tenantId) return;
|
||||
try {
|
||||
const res: any = await getTenantDetails(tenantId);
|
||||
if (res.code === 0 || res.code === 200) {
|
||||
tenantInfo.value = {
|
||||
tenantName: res.data.tenant.tenantName,
|
||||
surplus: res.data.tenant.surplus,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取租户信息失败', e);
|
||||
}
|
||||
};
|
||||
// 设置分割样式
|
||||
const layoutUserFlexNum = computed(() => {
|
||||
let num: string | number = '';
|
||||
@@ -177,11 +203,12 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
})
|
||||
.then(async () => {
|
||||
.then(() => {
|
||||
// 手动退出登录也只清理登录态缓存,保留主题、语言等本地配置。
|
||||
Session.clearAuth();
|
||||
// 显式回到登录页,避免保留之前受保护页面的重定向参数
|
||||
await router.replace('/login');
|
||||
// 整页跳转登录页(刷新重建路由实例):SPA 内 router.replace 不会重置已注册的动态路由,
|
||||
// 重新登录时 setAddRoute 的 hasRoute 判断会跳过新菜单注册,出现"菜单显示但点击 404"(如价格管理页)
|
||||
window.location.href = import.meta.env.BASE_URL + '#/login';
|
||||
})
|
||||
.catch(() => {});
|
||||
} else if (path === 'wareHouse') {
|
||||
@@ -252,6 +279,7 @@ export default defineComponent({
|
||||
initI18n();
|
||||
initComponentSize();
|
||||
}
|
||||
initTenantInfo();
|
||||
});
|
||||
return {
|
||||
userInfos,
|
||||
@@ -264,6 +292,7 @@ export default defineComponent({
|
||||
removeCacheClick,
|
||||
searchRef,
|
||||
layoutUserFlexNum,
|
||||
tenantInfo,
|
||||
...toRefs(state),
|
||||
};
|
||||
},
|
||||
@@ -315,4 +344,24 @@ export default defineComponent({
|
||||
top: 12px;
|
||||
}
|
||||
}
|
||||
// 租户信息样式
|
||||
.layout-navbars-breadcrumb-tenant {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
height: 50px;
|
||||
font-size: 13px;
|
||||
color: var(--next-bg-topBarColor);
|
||||
white-space: nowrap;
|
||||
.tenant-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.tenant-divider {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.tenant-balance {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+24
-7
@@ -34,6 +34,23 @@ export const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: staticRoutes,
|
||||
});
|
||||
const hasValidResolvedRoute = (to: any) => {
|
||||
const resolved = router.resolve(to.fullPath);
|
||||
const matched = resolved.matched || [];
|
||||
|
||||
const isStaticFallbackRoute = matched.length === 1 && matched[0]?.path === '/:pathMatch(.*)*';
|
||||
|
||||
const isRedirectTo404Route = matched.length > 0 && matched.every((item) => item.redirect === '/404');
|
||||
|
||||
return matched.length > 0 && !isStaticFallbackRoute && !isRedirectTo404Route;
|
||||
};
|
||||
const goResolvedOr404AfterInit = (to: any, next: any) => {
|
||||
if (hasValidResolvedRoute(to)) {
|
||||
next({ ...to, replace: true });
|
||||
} else {
|
||||
next('/404');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 路由多级嵌套数组处理成一维数组
|
||||
@@ -106,18 +123,18 @@ router.beforeEach(async (to, from, next) => {
|
||||
const { routesList } = storeToRefs(storesRoutesList);
|
||||
if (routesList.value.length === 0) {
|
||||
if (isRequestRoutes) {
|
||||
// 后端控制路由:路由数据初始化,防止刷新时丢失
|
||||
await initBackEndControlRoutes();
|
||||
// 动态添加路由:防止非首页刷新时跳转回首页的问题
|
||||
// 确保 addRoute() 时动态添加的路由已经被完全加载上去
|
||||
next({ ...to, replace: true });
|
||||
goResolvedOr404AfterInit(to, next);
|
||||
} else {
|
||||
// https://gitee.com/lyt-top/vue-next-admin/issues/I5F1HP
|
||||
await initFrontEndControlRoutes();
|
||||
next({ ...to, replace: true });
|
||||
goResolvedOr404AfterInit(to, next);
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
if (hasValidResolvedRoute(to)) {
|
||||
next();
|
||||
} else {
|
||||
next('/404');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-5
@@ -1,4 +1,4 @@
|
||||
import { RouteRecordRaw } from 'vue-router';
|
||||
import { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
/**
|
||||
* 路由meta对象参数说明
|
||||
@@ -69,9 +69,9 @@ export const dynamicRoutes: Array<RouteRecordRaw> = [
|
||||
|
||||
/**
|
||||
* 盘点管理路由配置(供后端菜单配置参考)
|
||||
*
|
||||
*
|
||||
* 父级菜单: 库存作业 (/assets/operation)
|
||||
*
|
||||
*
|
||||
* 盘点菜单配置:
|
||||
* - 路由路径: /assets/operation/count
|
||||
* - 组件路径: assets/operation/count/index
|
||||
@@ -1077,8 +1077,8 @@ export const demoRoutes: Array<RouteRecordRaw> = [
|
||||
*/
|
||||
export const notFoundAndNoPower = [
|
||||
{
|
||||
path: '/:path(.*)*',
|
||||
name: 'notFound',
|
||||
path: '/404',
|
||||
name: 'notFoundPage',
|
||||
component: () => import('/@/views/error/404.vue'),
|
||||
meta: {
|
||||
title: 'message.staticRoutes.notFound',
|
||||
@@ -1094,6 +1094,15 @@ export const notFoundAndNoPower = [
|
||||
isHide: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/:path(.*)*',
|
||||
name: 'notFound',
|
||||
redirect: '/404',
|
||||
meta: {
|
||||
title: 'message.staticRoutes.notFound',
|
||||
isHide: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
declare module 'json-schema-editor' {
|
||||
import { DefineComponent } from 'vue';
|
||||
|
||||
export const JsonEditor: DefineComponent<{
|
||||
modelValue?: Record<string, unknown>;
|
||||
}>;
|
||||
|
||||
export const FieldConfigDialog: DefineComponent;
|
||||
export const JsonNode: DefineComponent;
|
||||
}
|
||||
|
||||
declare module '/@/components/json-schema-editor' {
|
||||
import { DefineComponent } from 'vue';
|
||||
|
||||
export const JsonEditor: DefineComponent<{
|
||||
modelValue?: Record<string, unknown>;
|
||||
}>;
|
||||
|
||||
export const FieldConfigDialog: DefineComponent;
|
||||
export const JsonNode: DefineComponent;
|
||||
}
|
||||
@@ -1,10 +1,82 @@
|
||||
<template>
|
||||
<div class="chat-list">
|
||||
<div class="chat-divider">今天 15:14</div>
|
||||
|
||||
<div v-for="msg in messages" :key="msg.id" class="message-row" :class="{ 'is-user': msg.isUser }">
|
||||
<div ref="chatListRef" class="chat-list" @scroll.passive="handleScroll">
|
||||
<!-- 顶部:有更多历史时上滑加载更早记录 -->
|
||||
<div v-if="hasMore || loadingMore" class="load-more-bar">
|
||||
<span v-if="loadingMore" class="load-more-spinner"></span>
|
||||
{{ loadingMore ? '加载中…' : '继续上滑,加载更早的记录' }}
|
||||
</div>
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
class="message-row"
|
||||
:class="{ 'is-user': msg.isUser, 'is-form': msg.type === 'form', 'is-error': !msg.isUser && isErrorMsg(msg) }"
|
||||
>
|
||||
<div class="bubble-wrap">
|
||||
<div class="bubble">{{ msg.content }}</div>
|
||||
<!-- 工作流表单卡片:不套气泡,独立卡片渲染 -->
|
||||
<WorkflowFormCard
|
||||
v-if="msg.type === 'form'"
|
||||
:execute-signal="executeRequest?.msgId === msg.id ? executeRequest : null"
|
||||
:detail="msg.form"
|
||||
:readonly="msg.formStatus === 'done' || msg.formStatus === 'failed'"
|
||||
:editable="lastFormMsg ? msg.id === lastFormMsg.id : false"
|
||||
:submitting="msg.formStatus === 'running'"
|
||||
:form-error="msg.formError"
|
||||
:progress="msg.formProgress"
|
||||
@submit="emit('form-submit', msg, $event)"
|
||||
/>
|
||||
<template v-else>
|
||||
<div class="bubble">
|
||||
<template v-if="msg.isUser">{{ msg.content }}</template>
|
||||
<template v-else>
|
||||
<!-- 初始加载动画 -->
|
||||
<template v-if="msg.loading && !msg.thinking && !msg.content">
|
||||
<span class="loading-text">思考中</span><span class="loading-dots"><i></i><i></i><i></i></span>
|
||||
</template>
|
||||
<!-- 思考区:DeepSeek 风格折叠块 -->
|
||||
<div v-if="msg.thinking" class="thinking-block">
|
||||
<button class="thinking-toggle" type="button" @click="toggleThinking(msg)">
|
||||
<span class="thinking-icon">🧠</span>
|
||||
<span class="thinking-title">{{ thinkingTitle(msg) }}</span>
|
||||
<span class="thinking-arrow" :class="{ collapsed: !isThinkingExpanded(msg) }">▾</span>
|
||||
</button>
|
||||
<div v-if="isThinkingExpanded(msg)" class="thinking-content">
|
||||
<div class="thinking-text">{{ msg.thinking }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 回答区:Markdown 渲染 -->
|
||||
<div
|
||||
v-if="msg.content"
|
||||
class="answer-block"
|
||||
v-html="renderAnswer(msg.content)"
|
||||
@click="handleAnswerClick"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- 工作流执行产出文件卡片(挂在结果消息上,预览/下载/删除) -->
|
||||
<WorkflowOutputCard
|
||||
v-if="msg.outputs && msg.outputs.length"
|
||||
:outputs="msg.outputs"
|
||||
:resolve-url="props.resolveUrl"
|
||||
@preview="emit('output-preview', msg, $event)"
|
||||
@download="emit('output-download', msg, $event)"
|
||||
@delete="emit('output-delete', msg, $event)"
|
||||
/>
|
||||
<!-- AI 消息操作栏(hover 显示) -->
|
||||
<div v-if="!msg.isUser && msg.content" class="msg-actions">
|
||||
<button class="msg-action" title="复制回答" @click="copyText(msg.content)">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
</button>
|
||||
<button v-if="msg.recordId" class="msg-action is-danger" title="删除对话" @click="emit('delete', msg)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 用户消息操作栏(hover 显示:重新生成 + 删除) -->
|
||||
<div v-if="msg.isUser" class="msg-actions">
|
||||
<button v-if="msg.recordId" class="msg-action is-danger" title="删除对话" @click="emit('delete', msg)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="time">{{ msg.time }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -12,58 +84,266 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Delete, DocumentCopy } from '@element-plus/icons-vue';
|
||||
import 'highlight.js/styles/github-dark.css';
|
||||
import { renderMarkdown } from '../utils/markdown';
|
||||
import type { WorkflowOutput } from '../utils/flowDsl';
|
||||
import type { WorkflowNodeProgress } from '../utils/wsMessage';
|
||||
import WorkflowFormCard from './WorkflowFormCard.vue';
|
||||
import WorkflowOutputCard from './WorkflowOutputCard.vue';
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
time: string;
|
||||
isUser: boolean;
|
||||
loading?: boolean;
|
||||
thinking?: string;
|
||||
thinkingSeconds?: number;
|
||||
// 后端记录 id / 类型(来自 session/get 的每条结果),存在时才显示删除按钮
|
||||
recordId?: string;
|
||||
recordType?: string;
|
||||
// 工作流表单卡片消息(type==='form' 时渲染 WorkflowFormCard)
|
||||
type?: 'form';
|
||||
form?: any;
|
||||
formStatus?: 'editing' | 'running' | 'done' | 'failed';
|
||||
formError?: string;
|
||||
// 工作流执行节点进度(node_start/node_complete 事件驱动的节点步骤列表,渲染执行过程区)
|
||||
formProgress?: WorkflowNodeProgress;
|
||||
// 工作流执行产出文件列表(挂在 workflow 结果消息上,渲染产出卡片)
|
||||
outputs?: WorkflowOutput[];
|
||||
}
|
||||
|
||||
const messages = ref<Message[]>([
|
||||
{
|
||||
id: 1,
|
||||
content: '你好!我叫知子,很高兴为您服务。您今天想聊些什么呢?',
|
||||
time: '09:30',
|
||||
isUser: false,
|
||||
interface Emits {
|
||||
(e: 'delete', msg: ChatMessage): void;
|
||||
(e: 'form-submit', msg: ChatMessage, payload: any): void;
|
||||
(e: 'output-preview', msg: ChatMessage, output: WorkflowOutput): void;
|
||||
(e: 'output-download', msg: ChatMessage, output: WorkflowOutput): void;
|
||||
(e: 'output-delete', msg: ChatMessage, output: WorkflowOutput): void;
|
||||
(e: 'load-more'): void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
messages: ChatMessage[];
|
||||
hasMore?: boolean;
|
||||
loadingMore?: boolean;
|
||||
// 产出相对路径 → 完整 URL 解析函数(由首页传入 buildAssetUrl),内嵌预览媒体需完整地址
|
||||
resolveUrl?: (url: string) => string;
|
||||
// InputBar 发送按钮触发执行:父组件定位目标卡片后,经此信号调用卡片内部提交
|
||||
// (卡片参数在子组件内部,父组件无法直接取值,故由卡片暴露方法、这里中转调用)
|
||||
executeRequest?: { msgId: string; action: 'submit'; nonce: number } | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
hasMore: false,
|
||||
loadingMore: false,
|
||||
resolveUrl: undefined,
|
||||
executeRequest: null,
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
const chatListRef = ref<HTMLElement | null>(null);
|
||||
|
||||
// 会话中最后一张已执行的工作流卡片(done/failed):参数保持可编辑,便于改参后经发送按钮重新执行
|
||||
const lastFormMsg = computed(() =>
|
||||
[...props.messages].reverse().find((m) => m.type === 'form' && !!m.formStatus && m.formStatus !== 'editing')
|
||||
);
|
||||
|
||||
const isErrorMsg = (msg: ChatMessage): boolean => !msg.isUser && String(msg.content || '').startsWith('❌');
|
||||
|
||||
// 思考区折叠:未手动操作时,流式中展开、完成(出现答案)后自动收起(DeepSeek 形态)
|
||||
const thinkingExpanded = reactive<Record<string, boolean>>({});
|
||||
|
||||
const isThinkingExpanded = (msg: ChatMessage): boolean =>
|
||||
msg.id in thinkingExpanded ? thinkingExpanded[msg.id] : !msg.content;
|
||||
|
||||
const toggleThinking = (msg: ChatMessage) => {
|
||||
thinkingExpanded[msg.id] = !isThinkingExpanded(msg);
|
||||
};
|
||||
|
||||
// Markdown 渲染缓存(content 在作答完成后固定,避免重复解析)
|
||||
// 设上限:防止长会话渲染结果无限累积(超限清空重建,量级可控)
|
||||
const mdCache = new Map<string, string>();
|
||||
const MD_CACHE_MAX = 300;
|
||||
const renderAnswer = (content: string): string => {
|
||||
if (mdCache.size >= MD_CACHE_MAX) mdCache.clear();
|
||||
if (!mdCache.has(content)) mdCache.set(content, renderMarkdown(content));
|
||||
return mdCache.get(content)!;
|
||||
};
|
||||
|
||||
// 复制文本(剪贴板 API + 兼容回退)
|
||||
const copyText = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
ElMessage.success('已复制');
|
||||
};
|
||||
|
||||
// 代码块复制(事件委托:点击渲染出的 .code-copy-btn)
|
||||
const handleAnswerClick = (e: MouseEvent) => {
|
||||
const el = e.target as HTMLElement;
|
||||
const btn = el.closest('.code-copy-btn');
|
||||
if (!btn) return;
|
||||
const code = btn.closest('.code-wrapper')?.querySelector('code');
|
||||
if (code) copyText(code.textContent || '');
|
||||
};
|
||||
|
||||
// 思考区折叠标题:完成(有答案)显示"已深度思考(用时 X 秒)",流式中显示"思考中…"
|
||||
const thinkingTitle = (msg: ChatMessage): string => {
|
||||
if (!msg.content) return '思考中…';
|
||||
return msg.thinkingSeconds != null ? `已深度思考(用时 ${msg.thinkingSeconds} 秒)` : '已深度思考';
|
||||
};
|
||||
|
||||
// 距底部小于该阈值(px)视为"正在查看最新":新消息到达自动滚底;用户上滑阅读历史(超过阈值)则不强制拉回
|
||||
const NEAR_BOTTOM_THRESHOLD = 120;
|
||||
// 是否处于"查看最新"状态:初始为真(挂载即定位最新),由用户真实滚动(isTrusted)更新
|
||||
let isAtBottom = true;
|
||||
// autoLoading 标记本次 load-more 是否由"自动续载"触发:自动续载不保持位置(落在最新),用户上滑保持视口
|
||||
let autoLoading = false;
|
||||
// 自动续载累计次数上限:连续自动加载超过上限即停,防止异常数据(如 hasMore 恒 true 且视口不满)
|
||||
// 导致 load-more 无限循环占满主线程。用户主动上滑加载不受此上限约束(见 handleScroll)。
|
||||
const MAX_AUTO_LOAD = 5;
|
||||
let autoLoadCount = 0;
|
||||
const canAutoLoad = (): boolean => autoLoadCount < MAX_AUTO_LOAD;
|
||||
|
||||
// 消息新增或内容变化时自动滚动到底部;顶部加载更多期间不滚底。
|
||||
// immediate:切换会话时 ChatList 会重建,挂载即定位到最新消息,避免停留在顶部旧消息。
|
||||
// 首屏不满视口时自动续载更早记录,直到填满视口或没有更多(保证"上滑加载"入口始终可见)
|
||||
watch(
|
||||
() => props.messages.map((m) => `${m.id}|${m.content}|${m.thinking || ''}|${m.loading ? 1 : 0}`).join('\n'),
|
||||
() => {
|
||||
if (props.loadingMore) return;
|
||||
nextTick(() => {
|
||||
const el = chatListRef.value;
|
||||
if (!el) return;
|
||||
// 仅当用户贴近底部(isAtBottom)时才滚底;上滑阅读历史期间新消息不打断阅读位置
|
||||
if (isAtBottom) el.scrollTop = el.scrollHeight;
|
||||
if (props.messages.length > 0 && props.hasMore && el.scrollHeight <= el.clientHeight) {
|
||||
if (canAutoLoad()) {
|
||||
autoLoading = true;
|
||||
autoLoadCount++;
|
||||
emit('load-more');
|
||||
} else {
|
||||
// 达到自动续载上限:停止自动加载,避免无限循环;用户可主动上滑继续加载更早记录
|
||||
autoLoading = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content: '你好!我想了解下这个系统都能做什么。',
|
||||
time: '09:31',
|
||||
isUser: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
content: '当然可以,这个系统提供了多种功能:日记、文件、快捷指令、快捷回复,以及技能管理和模型管理入口。你想先看哪一块?',
|
||||
time: '09:31',
|
||||
isUser: false,
|
||||
},
|
||||
]);
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 顶部加载更多:开始加载时记录容器高度,完成后把滚动位置补回新增高度,保持视口内消息不跳动。
|
||||
// 该 watch 注册在 messages watch 之后,其 nextTick 回调后执行,可覆盖同批的"滚到底"。
|
||||
const prevScrollHeight = ref(0);
|
||||
watch(
|
||||
() => props.loadingMore,
|
||||
(loading, prev) => {
|
||||
if (loading && !prev) {
|
||||
const el = chatListRef.value;
|
||||
prevScrollHeight.value = autoLoading ? 0 : el && el.scrollHeight > el.clientHeight ? el.scrollHeight : 0;
|
||||
} else if (!loading && prev) {
|
||||
nextTick(() => {
|
||||
const el = chatListRef.value;
|
||||
if (!el) return;
|
||||
if (prevScrollHeight.value) {
|
||||
// 用户主动上滑加载更早:滚动位置补回新增高度,保持视口内消息不跳动
|
||||
el.scrollTop = el.scrollHeight - prevScrollHeight.value;
|
||||
prevScrollHeight.value = 0;
|
||||
} else if (autoLoading) {
|
||||
// 自动续载:加载期间滚底 watch 被 loadingMore 拦截(同批 loadingMore 仍为 true 先 flush),
|
||||
// 恢复时须把 scrollTop 设回底部定位到最新消息;若仍不满屏则继续自动续载
|
||||
el.scrollTop = el.scrollHeight;
|
||||
if (props.messages.length > 0 && props.hasMore && el.scrollHeight <= el.clientHeight) {
|
||||
if (canAutoLoad()) {
|
||||
autoLoadCount++;
|
||||
emit('load-more');
|
||||
} else {
|
||||
// 达到自动续载上限:停止自动加载,避免无限循环
|
||||
autoLoading = false;
|
||||
}
|
||||
} else {
|
||||
autoLoading = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 滚动到顶部时触发加载更早记录(防重入由父组件 sessionLoadingMore 保证)
|
||||
const handleScroll = (e: Event) => {
|
||||
// 程序设置 scrollTop 触发的 scroll 事件(isTrusted=false)不参与交互判断:
|
||||
// 自动续载/滚底时 scrollTop 不满屏被钳制为 0,会被误判为"用户上滑"而清空 autoLoading、破坏自动定位到最新
|
||||
if (!e.isTrusted) return;
|
||||
const el = chatListRef.value;
|
||||
if (!el) return;
|
||||
// 感知用户阅读位置:贴近底部视为"正在查看最新"(新消息自动滚底),上滑阅读历史则不再强制拉回
|
||||
isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_THRESHOLD;
|
||||
if (!props.hasMore || props.loadingMore) return;
|
||||
if (el.scrollTop <= 4) {
|
||||
// 用户主动上滑加载更早:重置自动续载计数,使用户操作不受自动续载上限约束
|
||||
autoLoading = false;
|
||||
autoLoadCount = 0;
|
||||
emit('load-more');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const el = chatListRef.value;
|
||||
// 挂载即定位到最新消息。immediate watch 的 nextTick 在 Transition 期间 DOM 未插入时执行(el 为 null)会失效,
|
||||
// 这里在 DOM 已插入、内容已渲染(sh 已确定)后滚底;requestAnimationFrame 确保过渡与布局完成
|
||||
requestAnimationFrame(() => {
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-list {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: 16px;
|
||||
padding: 28px 0 10px;
|
||||
padding: 8px 0 16px;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
.chat-divider {
|
||||
align-self: center;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
line-height: 1;
|
||||
padding: 4px 16px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(226, 233, 244, 0.8);
|
||||
letter-spacing: 0.2px;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.05);
|
||||
/* 顶部:加载更早记录提示条 */
|
||||
.load-more-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: #cbd5e1;
|
||||
padding: 4px 0 10px;
|
||||
}
|
||||
|
||||
.load-more-spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-top-color: #94a3b8;
|
||||
border-radius: 50%;
|
||||
animation: load-more-rotate 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes load-more-rotate {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.message-row {
|
||||
@@ -77,38 +357,399 @@ const messages = ref<Message[]>([
|
||||
}
|
||||
}
|
||||
|
||||
/* 节奏化消息间距:AI↔AI 24,用户↔AI 16 */
|
||||
.message-row + .message-row {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.message-row:not(.is-user) + .message-row:not(.is-user) {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.bubble-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
max-width: min(84%, 1120px);
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
align-items: flex-start;
|
||||
|
||||
.message-row.is-user & {
|
||||
align-items: flex-end;
|
||||
/* 明确宽度基准:打断 fit-content 与百分比 max-width 的循环,避免气泡被压得过窄导致文字竖排 */
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.bubble {
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
word-break: break-word;
|
||||
color: #1f2937;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96) 0%, rgba(248, 250, 255, 0.92) 100%);
|
||||
border: 1px solid rgba(226, 233, 244, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.05);
|
||||
transition: all 0.2s ease;
|
||||
color: #1f2328;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
/* AI 消息:无背景卡片,纯文本左对齐(DeepSeek 式) */
|
||||
.message-row:not(.is-user) & {
|
||||
width: 100%;
|
||||
max-width: 880px;
|
||||
}
|
||||
|
||||
.message-row.is-user & {
|
||||
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 24px rgba(37, 99, 235, 0.35);
|
||||
width: fit-content;
|
||||
max-width: 72%;
|
||||
padding: 10px 16px;
|
||||
border-radius: 18px 18px 6px 18px;
|
||||
background: #e8ecff;
|
||||
color: #1f2328;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.message-row.is-error & {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
/* 工作流表单卡片消息:左对齐,卡片自带背景不套气泡 */
|
||||
.message-row.is-form {
|
||||
justify-content: flex-start;
|
||||
.bubble-wrap {
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
.time { padding-left: 2px; }
|
||||
}
|
||||
}
|
||||
|
||||
/* AI 消息操作栏(hover 显示) */
|
||||
.msg-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
.message-row:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
/* 用户消息在右侧,操作栏右对齐 */
|
||||
.message-row.is-user & {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.msg-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #eef0f3;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
&.is-danger:hover {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 思考区:DeepSeek 风格折叠块 */
|
||||
.thinking-block {
|
||||
margin: 0 0 12px;
|
||||
background: #f5f6f7;
|
||||
border: 1px solid #eef0f3;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
max-width: 880px;
|
||||
}
|
||||
|
||||
.thinking-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 9px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #8b8b98;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
user-select: none;
|
||||
transition: color 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.thinking-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.thinking-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.thinking-arrow {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.thinking-content {
|
||||
padding: 0 14px 14px;
|
||||
animation: thinking-fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
.thinking-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #9a9aab;
|
||||
font-style: italic;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border-top: 1px solid #eef0f3;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
@keyframes thinking-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 回答区:Markdown 渲染(DeepSeek 风格排版) */
|
||||
.answer-block {
|
||||
white-space: normal;
|
||||
line-height: 1.7;
|
||||
font-size: 15px;
|
||||
word-break: break-word;
|
||||
max-width: 880px;
|
||||
|
||||
& > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
& > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(p) {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
:deep(h1),
|
||||
:deep(h2),
|
||||
:deep(h3),
|
||||
:deep(h4),
|
||||
:deep(h5),
|
||||
:deep(h6) {
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin: 20px 0 10px;
|
||||
color: #1f2328;
|
||||
}
|
||||
|
||||
:deep(h1) {
|
||||
font-size: 20px;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(h2) {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
:deep(h3) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:deep(h4),
|
||||
:deep(h5),
|
||||
:deep(h6) {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
:deep(ul),
|
||||
:deep(ol) {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
:deep(li) {
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
:deep(li > ul),
|
||||
:deep(li > ol) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(blockquote) {
|
||||
margin: 0 0 12px;
|
||||
padding: 6px 14px;
|
||||
border-left: 4px solid #e0e0e0;
|
||||
color: #6a737d;
|
||||
background: #fafafa;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
:deep(code) {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 13px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: #f2f3f5;
|
||||
color: #c7254e;
|
||||
}
|
||||
|
||||
:deep(.code-wrapper) {
|
||||
position: relative;
|
||||
margin: 0 0 14px;
|
||||
|
||||
&:hover .code-copy-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.code-copy-btn) {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #8b949e;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: #c9d1d9;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(pre) {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: #0d1117;
|
||||
overflow-x: auto;
|
||||
line-height: 1.6;
|
||||
|
||||
code {
|
||||
display: block;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(table) {
|
||||
width: 100%;
|
||||
margin: 0 0 14px;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 6px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f5f7fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(a) {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid #eaecef;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 执行中占位 */
|
||||
.loading-text {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-left: 6px;
|
||||
|
||||
i {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
animation: dot-blink 1.2s infinite ease-in-out both;
|
||||
|
||||
&:nth-child(1) { animation-delay: 0s; }
|
||||
&:nth-child(2) { animation-delay: 0.15s; }
|
||||
&:nth-child(3) { animation-delay: 0.3s; }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dot-blink {
|
||||
0%, 80%, 100% { opacity: 0.2; }
|
||||
40% { opacity: 1; }
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 11px;
|
||||
color: #8f9aae;
|
||||
padding: 4px 6px;
|
||||
padding: 4px 2px;
|
||||
|
||||
.message-row.is-user & {
|
||||
text-align: right;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,332 +1,563 @@
|
||||
<template>
|
||||
<div class="input-shell">
|
||||
<div class="input-card">
|
||||
<div class="input-card" :class="{ 'is-focused': isFocused }">
|
||||
<!-- 已选中状态标签栏 -->
|
||||
<Transition name="capsule-slide" @enter="onCapsuleEnter" @after-enter="onCapsuleAfterEnter" @leave="onCapsuleLeave" @after-leave="onCapsuleAfterLeave">
|
||||
<div v-if="selectedWorkflowId !== null" class="selected-tags">
|
||||
<div class="selected-tag workflow-tag">
|
||||
<el-icon><Promotion /></el-icon>
|
||||
<span>{{ commonWorkflows.find((w) => w.id === selectedWorkflowId)?.name }}</span>
|
||||
<el-icon v-if="!lockAll" class="tag-close" @click="clearWorkflow"><Close /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 文本输入区 -->
|
||||
<el-input
|
||||
v-model="message"
|
||||
type="textarea"
|
||||
:rows="1"
|
||||
:autosize="{ minRows: 2, maxRows: 5 }"
|
||||
placeholder="多说说你的偏好和要求,我会越用越懂你"
|
||||
:autosize="{ minRows: 1, maxRows: 6 }"
|
||||
:disabled="generating || isWorkflowRunning || selectedWorkflowId !== null || isWorkflowSession"
|
||||
:placeholder="inputPlaceholder"
|
||||
class="message-input"
|
||||
@keydown.enter.exact="handleSend"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
@keydown.enter.exact.prevent="handleSend"
|
||||
@keydown.enter.shift.exact="() => {}"
|
||||
/>
|
||||
|
||||
<!-- 底部工具栏 -->
|
||||
<div class="input-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<el-button circle class="tool-btn" @click="handleAttachment">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-button>
|
||||
<el-dropdown trigger="click" @command="handleSkillSelect">
|
||||
<el-button class="pill-btn">
|
||||
{{ currentSkillDisplay }}
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="default">默认对话</el-dropdown-item>
|
||||
<el-dropdown-item command="code-review">代码审查</el-dropdown-item>
|
||||
<el-dropdown-item command="refactor">代码重构</el-dropdown-item>
|
||||
<el-dropdown-item command="writing">内容写作</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<el-button circle class="send-btn" :disabled="!message.trim()" @click="handleSend">
|
||||
<el-icon><Top /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 常用工作流胶囊 -->
|
||||
<div class="workflow-pills" v-if="commonWorkflows.length > 0">
|
||||
<div
|
||||
v-for="wf in commonWorkflows"
|
||||
:key="wf.id"
|
||||
class="workflow-pill"
|
||||
:class="{ active: selectedWorkflowId === wf.id }"
|
||||
@click="selectWorkflow(wf)"
|
||||
>
|
||||
<el-icon class="pill-icon">
|
||||
<component :is="wf.icon" />
|
||||
</el-icon>
|
||||
<span class="pill-text">{{ wf.name }}</span>
|
||||
<!-- 切换会话模型入口 -->
|
||||
<button class="tool-icon-btn" :class="{ active: !!currentModelName }" title="切换会话模型" @click="emit('select-model')">
|
||||
<el-icon><Cpu /></el-icon>
|
||||
<span class="tool-label">{{ currentModelName || '设置模型' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<span class="hint-text">Shift+Enter 换行</span>
|
||||
<button
|
||||
class="send-btn"
|
||||
:class="{ 'is-stop': isStopping }"
|
||||
:disabled="sendBtnDisabled"
|
||||
@click="handleSend"
|
||||
>
|
||||
<el-icon v-if="isStopping"><VideoPause /></el-icon>
|
||||
<el-icon v-else><Top /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快捷工作流胶囊(输入框下方) -->
|
||||
<Transition name="capsule-slide" @enter="onCapsuleEnter" @after-enter="onCapsuleAfterEnter" @leave="onCapsuleLeave" @after-leave="onCapsuleAfterLeave">
|
||||
<div v-if="visibleWorkflows.length > 0 && !hideShortcuts" class="workflow-shortcuts">
|
||||
<el-tooltip
|
||||
v-for="wf in visibleWorkflows"
|
||||
:key="wf.id"
|
||||
:content="wf.name"
|
||||
placement="top"
|
||||
:show-after="150"
|
||||
>
|
||||
<button
|
||||
class="shortcut-pill"
|
||||
:class="{ active: selectedWorkflowId === wf.id }"
|
||||
@click="toggleWorkflow(wf.id)"
|
||||
>
|
||||
<el-icon><Promotion /></el-icon>
|
||||
<span class="pill-name">{{ wf.name }}</span>
|
||||
<span v-if="wf.isTemplate" class="pill-tag">模板</span>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<!-- 「更多」常驻入口:点击跳转工作流管理页查看/选择全部,不依赖工作流数量超出上限 -->
|
||||
<button
|
||||
class="shortcut-pill shortcut-more"
|
||||
title="查看全部工作流"
|
||||
@click="handleOpenWorkflowManage"
|
||||
>
|
||||
<el-icon><More /></el-icon>
|
||||
更多
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Top, Plus, MagicStick, Document, CircleCheck } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Top, Promotion, Close, VideoPause, Cpu, More } from '@element-plus/icons-vue';
|
||||
import { getWorkflowList } from '/@/api/settings/creation';
|
||||
|
||||
interface Props {
|
||||
sendDisabled?: boolean;
|
||||
hideShortcuts?: boolean;
|
||||
generating?: boolean;
|
||||
// 当前会话模型名称,用于输入栏切换入口展示
|
||||
currentModelName?: string;
|
||||
// 工作流正在执行中:输入框禁用,发送按钮变停止(与普通对话生成共用同一开关)
|
||||
isWorkflowRunning?: boolean;
|
||||
// 本会话为工作流会话(已执行过工作流):文本输入永久禁止,发送按钮仅执行工作流
|
||||
isWorkflowSession?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'send', message: string): void;
|
||||
// 工作流场景点击发送:父组件定位目标工作流卡片执行(编辑态提交 / 定格态重跑)
|
||||
(e: 'execute-workflow'): void;
|
||||
(e: 'workflow-select', workflowId: string | null, isTemplate?: boolean): void;
|
||||
(e: 'stop'): void;
|
||||
(e: 'select-model'): void;
|
||||
}
|
||||
|
||||
interface Workflow {
|
||||
id: number;
|
||||
id: string;
|
||||
name: string;
|
||||
icon: any;
|
||||
prefix: string;
|
||||
editPath: string;
|
||||
isTemplate: boolean;
|
||||
raw?: any;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
sendDisabled: false,
|
||||
hideShortcuts: false,
|
||||
generating: false,
|
||||
currentModelName: '',
|
||||
isWorkflowRunning: false,
|
||||
isWorkflowSession: false,
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
const router = useRouter();
|
||||
const message = ref('');
|
||||
const selectedSkill = ref('default');
|
||||
const selectedWorkflowId = ref<number | null>(null);
|
||||
// 首页展示工作流上限:用户工作流优先,不足用系统模板补足,最多 9 个
|
||||
const MAX_WORKFLOWS = 9;
|
||||
|
||||
const skillLabels: Record<string, string> = {
|
||||
default: '选择技能',
|
||||
'code-review': '代码审查',
|
||||
refactor: '代码重构',
|
||||
writing: '内容写作',
|
||||
// 「更多」入口:工作流数量超出展示上限时跳转工作流管理页查看/选择全部
|
||||
const handleOpenWorkflowManage = () => {
|
||||
router.push('/settings/workflow');
|
||||
};
|
||||
const message = ref('');
|
||||
const isFocused = ref(false);
|
||||
const selectedWorkflowId = ref<string | null>(null);
|
||||
const commonWorkflows = ref<Workflow[]>([]);
|
||||
// 取消工作流前的选择:父组件确认「继续编辑」时回滚,避免 InputBar 先置空、父侧保留草稿造成状态脱钩
|
||||
const previousSelectedWorkflowId = ref<string | null>(null);
|
||||
|
||||
const currentSkillDisplay = computed(() => {
|
||||
return selectedSkill.value === 'default' ? '选择技能' : skillLabels[selectedSkill.value];
|
||||
// 工作流胶囊最多展示 MAX_WORKFLOWS 个:commonWorkflows 用户工作流在前、模板在后,
|
||||
// 截断前 MAX_WORKFLOWS 个即「用户优先、不足模板补足」;完整列表仍保留供父组件匹配
|
||||
const visibleWorkflows = computed(() => commonWorkflows.value.slice(0, MAX_WORKFLOWS));
|
||||
|
||||
// 输入框整体锁定:正处于生成/工作流执行中(执行中禁止切换工作流)
|
||||
const lockAll = computed(() => props.isWorkflowRunning || props.generating);
|
||||
|
||||
// 输入框占位符:工作流执行中 / 选中工作流待填 / 工作流会话永久禁输入时给出明确提示
|
||||
const inputPlaceholder = computed(() =>
|
||||
props.isWorkflowRunning
|
||||
? '工作流执行中,请稍候...'
|
||||
: selectedWorkflowId.value !== null
|
||||
? '请填写上方工作流表单,提交后开始执行'
|
||||
: props.isWorkflowSession
|
||||
? '本会话为工作流对话,仅可执行工作流'
|
||||
: '有什么想聊的?按 Enter 发送,Shift+Enter 换行',
|
||||
);
|
||||
|
||||
// 生成中 / 工作流执行中:发送按钮变「停止」(普通对话与工作流共用同一开关)
|
||||
const isStopping = computed(() => props.generating || props.isWorkflowRunning);
|
||||
// 发送按钮可用性:执行中可点(停止);工作流场景可点(执行);普通对话需有文本且未禁用
|
||||
const sendBtnDisabled = computed(() => {
|
||||
if (props.generating || props.isWorkflowRunning) return false;
|
||||
if (selectedWorkflowId.value !== null || props.isWorkflowSession) return false;
|
||||
return props.sendDisabled || !message.value.trim();
|
||||
});
|
||||
|
||||
const skills: Record<string, string> = {
|
||||
default: '',
|
||||
'code-review': '[技能:代码审查] 请帮我审查下面这段代码,找出潜在问题并给出改进建议:\n',
|
||||
refactor: '[技能:代码重构] 请帮我重构下面这段代码,提升可读性和可维护性:\n',
|
||||
writing: '[技能:内容写作] 请根据下面的需求帮我创作内容:\n',
|
||||
const fetchWorkflows = async () => {
|
||||
try {
|
||||
const res = await getWorkflowList();
|
||||
const userList = res.data?.listFlowUserRes?.list || [];
|
||||
const tplList = res.data?.listFlowTemplateRes?.list || [];
|
||||
// commonWorkflows 保留完整列表:父组件按 id 匹配工作流名称/预选历史会话,不能被展示截断影响
|
||||
const workflows = [
|
||||
...userList.map((w) => ({ ...w, isTemplate: false })),
|
||||
...tplList.map((w) => ({ ...w, isTemplate: true })),
|
||||
];
|
||||
commonWorkflows.value = workflows.map((w) => ({
|
||||
id: String(w.id),
|
||||
name: w.flowName || w.flowTemplateName || '未命名',
|
||||
prefix: '[工作流] ' + (w.flowName || w.flowTemplateName || '') + ':\n',
|
||||
isTemplate: !!w.isTemplate,
|
||||
raw: w,
|
||||
}));
|
||||
} catch {
|
||||
commonWorkflows.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
// 常用工作流列表
|
||||
const commonWorkflows: Workflow[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: '审查+测试',
|
||||
icon: Document,
|
||||
prefix: '[工作流:审查+测试] 完成后请自动生成单元测试:\n',
|
||||
editPath: '/settings/workflow',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '文档再编码',
|
||||
icon: Document,
|
||||
prefix: '[工作流:文档再编码] 请先编写接口文档,再实现代码:\n',
|
||||
editPath: '/settings/workflow',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '代码重构',
|
||||
icon: Document,
|
||||
prefix: '[工作流:代码重构] 请帮我重构这段代码:\n',
|
||||
editPath: '/settings/workflow',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '需求分析',
|
||||
icon: Document,
|
||||
prefix: '[工作流:需求分析] 请帮我分析这个需求并拆解任务:\n',
|
||||
editPath: '/settings/workflow',
|
||||
},
|
||||
];
|
||||
|
||||
const handleSend = (event?: KeyboardEvent) => {
|
||||
if (event) event.preventDefault();
|
||||
const handleSend = () => {
|
||||
// 生成中 / 工作流执行中:发送按钮即「停止」(普通对话与工作流共用同一开关)
|
||||
if (props.generating || props.isWorkflowRunning) {
|
||||
emit('stop');
|
||||
return;
|
||||
}
|
||||
// 工作流场景(已选工作流 / 本会话为工作流会话):发送按钮即「执行工作流」
|
||||
if (selectedWorkflowId.value !== null || props.isWorkflowSession) {
|
||||
emit('execute-workflow');
|
||||
return;
|
||||
}
|
||||
// 普通对话:发送文本
|
||||
const msg = message.value.trim();
|
||||
if (!msg) return;
|
||||
const skillPrefix = skills[selectedSkill.value] || '';
|
||||
const workflowPrefix = selectedWorkflowId.value !== null ? commonWorkflows.find((w) => w.id === selectedWorkflowId.value)?.prefix || '' : '';
|
||||
const finalMessage = (skillPrefix + workflowPrefix + msg).trim();
|
||||
emit('send', finalMessage);
|
||||
if (!msg || props.sendDisabled) return;
|
||||
emit('send', msg);
|
||||
message.value = '';
|
||||
// 发送后清空选择
|
||||
selectedWorkflowId.value = null;
|
||||
// 不清除工作流选择,保持表单可见
|
||||
};
|
||||
|
||||
const handleAttachment = () => {
|
||||
ElMessage.info('附件上传功能开发中...');
|
||||
const toggleWorkflow = (id: string) => {
|
||||
if (lockAll.value) return;
|
||||
const item = commonWorkflows.value.find((w) => w.id === id);
|
||||
const newId = selectedWorkflowId.value === id ? null : id;
|
||||
// 取消(newId 为 null)前记录当前选择,供父组件确认「继续编辑」时回滚
|
||||
if (newId === null) previousSelectedWorkflowId.value = selectedWorkflowId.value;
|
||||
selectedWorkflowId.value = newId;
|
||||
emit('workflow-select', newId, item?.isTemplate || false);
|
||||
};
|
||||
|
||||
const handleSkillSelect = (key: string | number | object) => {
|
||||
selectedSkill.value = String(key);
|
||||
if (selectedSkill.value !== 'default') {
|
||||
ElMessage.success(`已选择技能:${skillLabels[selectedSkill.value]}`);
|
||||
}
|
||||
};
|
||||
|
||||
const selectWorkflow = (wf: Workflow) => {
|
||||
if (selectedWorkflowId.value === wf.id) {
|
||||
const resetAll = () => {
|
||||
message.value = '';
|
||||
selectedWorkflowId.value = null;
|
||||
ElMessage.info('已取消工作流');
|
||||
} else {
|
||||
selectedWorkflowId.value = wf.id;
|
||||
ElMessage.success(`已选择工作流:${wf.name}`);
|
||||
emit('workflow-select', null);
|
||||
};
|
||||
|
||||
const clearWorkflow = () => {
|
||||
if (lockAll.value) return;
|
||||
// 清空(取消)前记录当前选择,供父组件确认「继续编辑」时回滚
|
||||
previousSelectedWorkflowId.value = selectedWorkflowId.value;
|
||||
selectedWorkflowId.value = null;
|
||||
emit('workflow-select', null);
|
||||
};
|
||||
|
||||
const selectWorkflow = (id: string | null) => {
|
||||
if (lockAll.value) return;
|
||||
selectedWorkflowId.value = id;
|
||||
emit('workflow-select', id, false);
|
||||
};
|
||||
|
||||
// 父组件确认「放弃未提交表单」失败时回滚选择:恢复取消前的工作流(标签与禁用态归位),
|
||||
// 不发 emit(父侧草稿与 selectedWorkflowDetail 保留,状态由父组件维持)
|
||||
const restoreWorkflowSelection = () => {
|
||||
if (previousSelectedWorkflowId.value !== null) {
|
||||
selectedWorkflowId.value = previousSelectedWorkflowId.value;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchWorkflows();
|
||||
});
|
||||
|
||||
// —— 胶囊高度动画:JS 测量实际高度,让 max-height 与内容高度精确匹配,
|
||||
// 进入时从 0 展开、离开时收缩到 0,配合 CSS transition 消除底部高度突变 ——
|
||||
const onCapsuleEnter = (el: Element) => {
|
||||
const e = el as HTMLElement;
|
||||
e.style.maxHeight = '0px';
|
||||
requestAnimationFrame(() => {
|
||||
e.style.maxHeight = e.scrollHeight + 'px';
|
||||
});
|
||||
};
|
||||
const onCapsuleAfterEnter = (el: Element) => {
|
||||
(el as HTMLElement).style.maxHeight = '';
|
||||
};
|
||||
const onCapsuleLeave = (el: Element) => {
|
||||
const e = el as HTMLElement;
|
||||
e.style.maxHeight = e.scrollHeight + 'px';
|
||||
requestAnimationFrame(() => {
|
||||
e.style.maxHeight = '0px';
|
||||
});
|
||||
};
|
||||
const onCapsuleAfterLeave = (el: Element) => {
|
||||
(el as HTMLElement).style.maxHeight = '';
|
||||
};
|
||||
|
||||
defineExpose({ resetAll, clearWorkflow, selectWorkflow, restoreWorkflowSelection, fetchWorkflows, selectedWorkflowId, commonWorkflows });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* ===== 胶囊显示/隐藏过渡:淡入淡出 + 轻位移 + 高度展开/收缩 ===== */
|
||||
.capsule-slide-enter-active,
|
||||
.capsule-slide-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease, max-height 0.25s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.capsule-slide-enter-from,
|
||||
.capsule-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
.input-shell {
|
||||
position: relative;
|
||||
z-index: 12;
|
||||
margin-top: -20px;
|
||||
padding: 0 0 50px;
|
||||
padding: 0 20px 20px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.input-card {
|
||||
width: min(1060px, 82%);
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(255, 255, 255, 0.94) 100%),
|
||||
radial-gradient(120% 180% at 0% 0%, rgba(59, 130, 246, 0.15) 0%, rgba(59, 130, 246, 0) 38%),
|
||||
radial-gradient(80% 120% at 100% 100%, rgba(168, 85, 247, 0.08) 0%, rgba(168, 85, 247, 0) 38%);
|
||||
border: 1px solid rgba(59, 130, 246, 0.28);
|
||||
background: #fff;
|
||||
border: 1.5px solid #e5e8ee;
|
||||
border-radius: 20px;
|
||||
box-shadow:
|
||||
0 24px 48px rgba(15, 23, 42, 0.12),
|
||||
0 0 0 1px rgba(59, 130, 246, 0.08) inset,
|
||||
0 8px 16px rgba(37, 99, 235, 0.1);
|
||||
padding: 16px 18px 18px;
|
||||
backdrop-filter: blur(16px);
|
||||
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.07);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
overflow: hidden;
|
||||
|
||||
&.is-focused {
|
||||
border-color: #93c5fd;
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(59, 130, 246, 0.12),
|
||||
0 2px 12px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
}
|
||||
|
||||
/* 已选标签栏 */
|
||||
.selected-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 10px 14px 0;
|
||||
}
|
||||
|
||||
.selected-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px 3px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
|
||||
&.workflow-tag {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.tag-close {
|
||||
margin-left: 2px;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 文本域 */
|
||||
.message-input {
|
||||
:deep(.el-textarea__inner) {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
resize: none;
|
||||
padding: 8px 6px 14px;
|
||||
padding: 16px 18px 10px;
|
||||
font-size: 15px;
|
||||
line-height: 1.65;
|
||||
line-height: 1.6;
|
||||
color: #0f172a;
|
||||
background: transparent;
|
||||
min-height: 50px;
|
||||
font-weight: 500;
|
||||
min-height: 28px !important;
|
||||
|
||||
&::placeholder {
|
||||
color: #90a1b7;
|
||||
color: #94a3b8;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 底部工具栏 */
|
||||
.input-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 2px;
|
||||
padding: 8px 12px 12px 14px;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid #d9e4f5;
|
||||
color: #5b6b84;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.06);
|
||||
transition: all 0.2s ease;
|
||||
.tool-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
outline: none;
|
||||
|
||||
.el-icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #2b4d8f;
|
||||
background: linear-gradient(135deg, #f0f7ff 0%, #e0edff 100%);
|
||||
border-color: #bfdbfe;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
.pill-btn {
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #dbe7f7;
|
||||
color: #30435f;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f6f9ff 100%);
|
||||
.tool-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, #f0f7ff 0%, #e0edff 100%);
|
||||
border-color: #bfdbfe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.hint-text {
|
||||
font-size: 11px;
|
||||
color: #cbd5e1;
|
||||
margin-right: 6px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
|
||||
border-radius: 10px;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
transform 0.1s,
|
||||
opacity 0.15s;
|
||||
outline: none;
|
||||
|
||||
.el-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
background: #1d4ed8;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
box-shadow: none;
|
||||
background: linear-gradient(135deg, #cbd5e1 0%, #94a3b8 100%);
|
||||
background: #e2e8f0;
|
||||
color: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 生成中:变停止按钮 */
|
||||
&.is-stop {
|
||||
background: #f43f5e;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: #e11d48;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 工作流胶囊样式 */
|
||||
.workflow-pills {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(59, 130, 246, 0.15);
|
||||
/* 快捷工作流胶囊 */
|
||||
.workflow-shortcuts {
|
||||
max-width: 880px;
|
||||
margin: 8px auto 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.workflow-pill {
|
||||
.shortcut-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
gap: 5px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e5e8ee;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(4px);
|
||||
transition: all 0.15s;
|
||||
outline: none;
|
||||
|
||||
.el-icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
transform: translateY(-1px);
|
||||
border-color: #93c5fd;
|
||||
color: #2563eb;
|
||||
background: rgba(239, 246, 255, 0.9);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #3b82f6;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.pill-icon {
|
||||
font-size: 14px;
|
||||
/* 胶囊内工作流名称:单行省略显示(不撑宽胶囊),悬停经 el-tooltip 展示完整名称 */
|
||||
.pill-name {
|
||||
max-width: 140px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 「更多」入口:与普通胶囊区分,虚线边框 + 更轻的视觉重量,提示可进入管理页查看全部 */
|
||||
.shortcut-more {
|
||||
border-style: dashed;
|
||||
color: #94a3b8;
|
||||
|
||||
&:hover {
|
||||
border-style: dashed;
|
||||
border-color: #93c5fd;
|
||||
color: #2563eb;
|
||||
background: rgba(239, 246, 255, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.pill-text {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
.pill-tag {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
background: #fff7e6;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
white-space: nowrap;
|
||||
|
||||
.shortcut-pill.active & {
|
||||
background: rgba(255, 247, 230, 0.2);
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,88 +1,284 @@
|
||||
<template>
|
||||
<div class="main-content">
|
||||
<div v-if="activeMenu !== 'chat'" class="content-header">
|
||||
<h2 class="content-title">{{ currentTitle }}</h2>
|
||||
<div class="content-actions">
|
||||
</div>
|
||||
<Transition name="content-fade" mode="out-in">
|
||||
<!-- 对话页 — 有消息或会话结果时展示消息流(工作流表单卡片/结果/产出统一在此)。
|
||||
key 绑定会话 id:切换会话时重建内容区,触发 Transition 过渡动画(固定 key 会复用 DOM 不触发) -->
|
||||
<div v-if="hasMessages || hasResults" :key="activeHistoryId || 'chat'" class="content-body chat-body">
|
||||
<ChatList
|
||||
:messages="messages"
|
||||
:has-more="hasMore"
|
||||
:loading-more="loadingMore"
|
||||
:execute-request="executeRequest"
|
||||
class="chat-list-scroll"
|
||||
@delete="emit('delete', $event)"
|
||||
@form-submit="(msg: any, payload: any) => emit('form-submit', msg, payload)"
|
||||
@output-preview="(msg: any, output: any) => emit('output-preview', msg, output)"
|
||||
@output-download="(msg: any, output: any) => emit('output-download', msg, output)"
|
||||
@output-delete="(msg: any, output: any) => emit('output-delete', msg, output)"
|
||||
@load-more="emit('load-more', activeHistoryId)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="content-body">
|
||||
<div class="chat-container" v-if="activeMenu === 'chat'">
|
||||
<ChatList />
|
||||
<!-- 默认占位 — 无工作流时显示引导 -->
|
||||
<div v-else :key="'placeholder'" class="content-body placeholder-body">
|
||||
<div class="placeholder-content">
|
||||
<h2 class="placeholder-title">你好,我能帮你解决什么问题?</h2>
|
||||
<p class="placeholder-desc">选择一个工作流,填写参数后一键生成</p>
|
||||
|
||||
<div v-if="placeWorkflows.length" class="workflow-cards">
|
||||
<el-tooltip
|
||||
v-for="wf in placeWorkflows"
|
||||
:key="wf.id"
|
||||
:content="wf.name"
|
||||
placement="top"
|
||||
:show-after="150"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="workflow-card"
|
||||
@click="handlePlaceSelect(wf)"
|
||||
>
|
||||
<el-icon class="wf-card-icon"><Promotion /></el-icon>
|
||||
<span class="wf-card-name">{{ wf.name }}</span>
|
||||
<span v-if="wf.isTemplate" class="wf-card-tag">模板</span>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<!-- 「更多」常驻入口:点击跳转工作流管理页查看/选择全部,不依赖工作流数量超出上限 -->
|
||||
<button
|
||||
type="button"
|
||||
class="workflow-card wf-card-more"
|
||||
title="查看全部工作流"
|
||||
@click="goWorkflowManage"
|
||||
>
|
||||
<el-icon class="wf-card-icon"><More /></el-icon>
|
||||
<span class="wf-card-name">更多工作流</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="placeholder-empty">暂无工作流,可在工作流管理中创建</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { getWorkflowList } from '/@/api/settings/creation';
|
||||
import { Promotion, More } from '@element-plus/icons-vue';
|
||||
import ChatList from './ChatList.vue';
|
||||
import type { VOSessionInfoResult } from '/@/api/settings/workflow/session';
|
||||
|
||||
interface Props {
|
||||
activeMenu: string;
|
||||
activeHistoryId?: string | null;
|
||||
messages?: any[];
|
||||
// 会话内结果(session/get):仅用于判断「对话页 vs 占位页」,结果渲染统一走消息流
|
||||
results?: VOSessionInfoResult[];
|
||||
hasMore?: boolean;
|
||||
loadingMore?: boolean;
|
||||
// InputBar 发送按钮触发执行信号(透传给 ChatList,定位工作流卡片执行)
|
||||
executeRequest?: { msgId: string; action: 'submit'; nonce: number } | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
interface Emits {
|
||||
(e: 'workflow-select', id: string, isTemplate?: boolean): void;
|
||||
(e: 'delete', msg: any): void;
|
||||
(e: 'form-submit', msg: any, payload: any): void;
|
||||
(e: 'output-preview', msg: any, output: any): void;
|
||||
(e: 'output-download', msg: any, output: any): void;
|
||||
(e: 'output-delete', msg: any, output: any): void;
|
||||
(e: 'load-more', sid: string | null | undefined): void;
|
||||
}
|
||||
|
||||
const menuTitles: Record<string, string> = {
|
||||
chat: '对话',
|
||||
models: '模型管理',
|
||||
creation: '内容创作',
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
activeHistoryId: null,
|
||||
messages: () => [],
|
||||
results: () => [],
|
||||
hasMore: false,
|
||||
loadingMore: false,
|
||||
executeRequest: null,
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
const router = useRouter();
|
||||
|
||||
const hasMessages = computed(() => Array.isArray(props.messages) && props.messages.length > 0);
|
||||
const hasResults = computed(() => Array.isArray(props.results) && props.results.length > 0);
|
||||
|
||||
// 占位页工作流卡片(DeepSeek 式引导:居中标题 + 工作流卡片)
|
||||
// 展示上限与 InputBar 胶囊保持一致:用户工作流优先,不足用模板补足,最多 9 个
|
||||
const MAX_PLACE_WORKFLOWS = 9;
|
||||
const placeWorkflows = ref<any[]>([]);
|
||||
const loadPlaceWorkflows = async () => {
|
||||
try {
|
||||
const res: any = await getWorkflowList();
|
||||
const userList = res.data?.listFlowUserRes?.list || [];
|
||||
const tplList = res.data?.listFlowTemplateRes?.list || [];
|
||||
placeWorkflows.value = [
|
||||
...userList.map((w: any) => ({ id: String(w.id), name: w.flowName || '未命名', isTemplate: false })),
|
||||
...tplList.map((w: any) => ({ id: String(w.id), name: w.flowTemplateName || '未命名', isTemplate: true })),
|
||||
].slice(0, MAX_PLACE_WORKFLOWS);
|
||||
} catch {
|
||||
placeWorkflows.value = [];
|
||||
}
|
||||
};
|
||||
const handlePlaceSelect = (wf: any) => {
|
||||
emit('workflow-select', wf.id, wf.isTemplate);
|
||||
};
|
||||
// 「更多」入口:工作流数量超出展示上限时跳转工作流管理页查看/选择全部
|
||||
const goWorkflowManage = () => {
|
||||
router.push('/settings/workflow');
|
||||
};
|
||||
onMounted(() => {
|
||||
loadPlaceWorkflows();
|
||||
});
|
||||
|
||||
const currentTitle = computed(() => menuTitles[props.activeMenu] || '首页');
|
||||
defineExpose({ loadPlaceWorkflows });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* ===== 页面切换过渡:淡入淡出 + 轻微上移 ===== */
|
||||
.content-fade-enter-active,
|
||||
.content-fade-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
.content-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
.content-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.content-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.content-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.content-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background:
|
||||
radial-gradient(1200px 420px at 58% -120px, rgba(59, 130, 246, 0.1) 0%, rgba(59, 130, 246, 0) 65%),
|
||||
radial-gradient(900px 320px at 20% 120%, rgba(99, 102, 241, 0.08) 0%, rgba(99, 102, 241, 0) 68%),
|
||||
linear-gradient(180deg, #f8fbff 0%, #f5f7fb 100%);
|
||||
|
||||
/* 隐藏滚动条但保持滚动功能 */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
/* ===== 对话页 ===== */
|
||||
.chat-body {
|
||||
width: min(880px, calc(100% - 40px));
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden; /* 结果列表固定高度、消息流内部滚动,避免整体滚动与内部冲突 */
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
/* 消息流填充剩余高度并在内部滚动 */
|
||||
.chat-body :deep(.chat-list-scroll) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.placeholder-body { display: flex; align-items: center; justify-content: center; }
|
||||
.placeholder-content { text-align: center; padding: 24px 20px; width: 100%; max-width: 740px; }
|
||||
|
||||
/* ===== 标题与描述 ===== */
|
||||
.placeholder-title { font-size: 26px; font-weight: 700; color: #0f172a; margin: 0 0 8px; letter-spacing: -0.2px; line-height: 1.4; }
|
||||
.placeholder-desc { font-size: 15px; color: #94a3b8; margin: 0 0 24px; line-height: 1.5; }
|
||||
|
||||
/* ===== 工作流功能卡片(DeepSeek 式引导) ===== */
|
||||
.workflow-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.workflow-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid #e5e8ee;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
outline: none;
|
||||
&:hover {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
.wf-card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: #eaf1ff;
|
||||
color: #2563eb;
|
||||
font-size: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wf-card-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wf-card-tag {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: #fff7e6;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 「更多」入口:与普通工作流卡片区分,虚线边框 + 更轻的视觉重量,提示可进入管理页查看全部 */
|
||||
.wf-card-more {
|
||||
border-style: dashed;
|
||||
justify-content: center;
|
||||
|
||||
.wf-card-icon {
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.wf-card-name {
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
&:hover {
|
||||
border-style: dashed;
|
||||
border-color: #93c5fd;
|
||||
.wf-card-icon {
|
||||
background: #eaf1ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
.wf-card-name {
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
width: min(1060px, 82%);
|
||||
margin: 0 auto;
|
||||
padding: 20px 0 130px;
|
||||
.placeholder-empty {
|
||||
font-size: 13px;
|
||||
color: #cbd5e1;
|
||||
padding: 16px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,17 +1,33 @@
|
||||
<template>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<el-button class="new-chat-btn" @click="handleNewChat">
|
||||
<el-icon>
|
||||
<Plus />
|
||||
</el-icon>
|
||||
新增对话
|
||||
</el-button>
|
||||
<!-- 顶部品牌区 -->
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-logo">
|
||||
<el-icon class="brand-icon"><ChatDotRound /></el-icon>
|
||||
<span class="brand-name">AI 助手</span>
|
||||
</div>
|
||||
<el-tooltip content="新建会话" placement="right">
|
||||
<button class="new-chat-icon-btn" @click="handleNewChat">
|
||||
<el-icon><EditPen /></el-icon>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="historyList.length" class="history-section">
|
||||
<div class="history-title">历史对话</div>
|
||||
<div class="history-list">
|
||||
<!-- Tab 切换 -->
|
||||
<div class="sidebar-tabs">
|
||||
<button class="tab-btn" :class="{ active: activeTab === 'history' }" @click="handleTabChange('history')">
|
||||
<el-icon><ChatLineRound /></el-icon>
|
||||
会话记录
|
||||
</button>
|
||||
<button class="tab-btn" :class="{ active: activeTab === 'workspace' }" @click="handleTabChange('workspace')">
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
工作空间
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 对话记录面板 -->
|
||||
<div v-show="activeTab === 'history'" class="panel">
|
||||
<div v-if="historyList.length" class="panel-list">
|
||||
<div
|
||||
v-for="item in historyList"
|
||||
:key="item.id"
|
||||
@@ -19,15 +35,58 @@
|
||||
:class="{ active: activeHistoryId === item.id }"
|
||||
@click="handleSelectHistory(item.id)"
|
||||
>
|
||||
<div class="history-main">
|
||||
<div class="history-name">{{ item.title }}</div>
|
||||
<div class="history-time">{{ item.time }}</div>
|
||||
<el-icon class="item-icon"><ChatLineRound /></el-icon>
|
||||
<div class="item-body">
|
||||
<div class="item-title">{{ item.title }}</div>
|
||||
<div v-if="item.status === 'executing'" class="status-badge status-running">执行中</div>
|
||||
<div class="item-time">{{ item.time }}</div>
|
||||
</div>
|
||||
<el-button text class="delete-btn" @click.stop="handleDeleteHistory(item.id)">
|
||||
<el-icon>
|
||||
<Delete />
|
||||
<button class="item-del-btn" @click.stop="handleDeleteHistory(item.id)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 历史会话分页「加载更多」:还有更多会话时展示,点击加载下一页 -->
|
||||
<div v-if="historyHasMore" class="history-load-more">
|
||||
<button class="load-more-btn" :disabled="historyLoadingMore" @click="emit('load-more-history')">
|
||||
{{ historyLoadingMore ? '加载中...' : '加载更多' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div v-else-if="historyFailed" class="panel-error">
|
||||
<span class="panel-error-text">加载失败</span>
|
||||
<button class="panel-retry-btn" @click="emit('retry-history')">重试</button>
|
||||
</div>
|
||||
<div v-else class="panel-empty">暂无会话记录</div>
|
||||
</div>
|
||||
|
||||
<!-- 工作空间面板 -->
|
||||
<div v-show="activeTab === 'workspace'" class="panel" v-loading="treeLoading">
|
||||
<div v-if="!treeLoading && treeNodes.length === 0 && workspaceFailed" class="panel-error">
|
||||
<span class="panel-error-text">加载失败</span>
|
||||
<button class="panel-retry-btn" @click="emit('retry-workspace')">重试</button>
|
||||
</div>
|
||||
<el-empty v-else-if="!treeLoading && treeNodes.length === 0" :image-size="48" description="暂无内容" />
|
||||
<div v-else class="panel-list">
|
||||
<div v-for="dateNode in treeNodes" :key="dateNode.id" class="tree-date-group">
|
||||
<div class="tree-date-label">{{ dateNode.label }}</div>
|
||||
<div v-for="fileNode in dateNode.children" :key="fileNode.id" class="tree-file-item">
|
||||
<el-icon class="file-type-icon">
|
||||
<component :is="getFileIcon(fileNode.fileType)" />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<span class="file-name">{{ fileNode.label }}</span>
|
||||
<div v-if="fileNode.fileUrl" class="file-actions">
|
||||
<button class="file-action-btn" @click.stop="handlePreviewNode(fileNode)">预览</button>
|
||||
<button
|
||||
class="file-action-btn"
|
||||
:disabled="downloadingFileId === fileNode.id"
|
||||
@click.stop="handleDownloadNode(fileNode)"
|
||||
>
|
||||
{{ downloadingFileId === fileNode.id ? '下载中…' : '下载' }}
|
||||
</button>
|
||||
<button class="file-action-btn file-action-btn--del" @click.stop="handleDeleteNode(fileNode)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,173 +94,484 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Plus, Delete } from '@element-plus/icons-vue';
|
||||
import { ref } from 'vue';
|
||||
import { Delete, ChatDotRound, ChatLineRound, EditPen, Document, VideoPlay, Headset, Picture, FolderOpened } from '@element-plus/icons-vue';
|
||||
|
||||
interface TreeNode {
|
||||
id: string;
|
||||
label: string;
|
||||
nodeType: string;
|
||||
children?: TreeNode[];
|
||||
fileUrl?: string;
|
||||
fileType?: string;
|
||||
}
|
||||
|
||||
interface HistoryItem {
|
||||
id: number;
|
||||
id: string;
|
||||
sessionId: string;
|
||||
title: string;
|
||||
time: string;
|
||||
status?: 'idle' | 'executing' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
activeMenu: string;
|
||||
activeHistoryId: number;
|
||||
activeHistoryId: string | null;
|
||||
historyList: HistoryItem[];
|
||||
// 会话记录 / 工作空间加载失败标记:失败时显示「加载失败 + 重试」错误态
|
||||
historyFailed?: boolean;
|
||||
// 历史会话分页:是否还有更多可加载、加载更多请求中
|
||||
historyHasMore?: boolean;
|
||||
historyLoadingMore?: boolean;
|
||||
workspaceFailed?: boolean;
|
||||
// 工作空间是否已成功加载:懒加载成功后才不再触发;失败保持 false → 切回 tab 自动重试
|
||||
workspaceLoaded?: boolean;
|
||||
// 工作空间正在下载的文件 id:对应「下载」按钮显示下载中并禁用(按钮级感知,不影响其他功能)
|
||||
downloadingFileId?: string | null;
|
||||
treeNodes: TreeNode[];
|
||||
treeLoading: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'menu-change', key: string): void;
|
||||
(e: 'new-chat'): void;
|
||||
(e: 'select-history', id: number): void;
|
||||
(e: 'delete-history', id: number): void;
|
||||
(e: 'select-history', id: string): void;
|
||||
(e: 'delete-history', id: string): void;
|
||||
(e: 'preview-node', data: TreeNode): void;
|
||||
(e: 'download-node', data: TreeNode): void;
|
||||
(e: 'delete-node', data: TreeNode): void;
|
||||
(e: 'load-workspace'): void;
|
||||
(e: 'retry-history'): void;
|
||||
(e: 'load-more-history'): void;
|
||||
(e: 'retry-workspace'): void;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
historyFailed: false,
|
||||
historyHasMore: false,
|
||||
historyLoadingMore: false,
|
||||
workspaceFailed: false,
|
||||
workspaceLoaded: false,
|
||||
downloadingFileId: null,
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const handleNewChat = () => {
|
||||
emit('new-chat');
|
||||
const activeTab = ref<'history' | 'workspace'>('history');
|
||||
const handleTabChange = (tab: 'history' | 'workspace') => {
|
||||
activeTab.value = tab;
|
||||
// 懒加载:仅在「工作空间」tab 且尚未成功加载过时通知父组件拉取结果树,避免进入页面即请求;
|
||||
// 失败时父组件不置 workspaceLoaded,切回 tab 自动重试
|
||||
if (tab === 'workspace' && !props.workspaceLoaded) {
|
||||
emit('load-workspace');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectHistory = (id: number) => {
|
||||
emit('select-history', id);
|
||||
|
||||
const getFileIcon = (fileType?: string) => {
|
||||
if (!fileType) return Document;
|
||||
const t = fileType.toLowerCase();
|
||||
if (t.includes('image') || t.includes('png') || t.includes('jpg') || t.includes('gif')) return Picture;
|
||||
if (t.includes('video') || t.includes('mp4') || t.includes('webm')) return VideoPlay;
|
||||
if (t.includes('audio') || t.includes('mp3') || t.includes('wav')) return Headset;
|
||||
return Document;
|
||||
};
|
||||
|
||||
const handleDeleteHistory = (id: number) => {
|
||||
emit('delete-history', id);
|
||||
};
|
||||
const handleNewChat = () => emit('new-chat');
|
||||
const handleSelectHistory = (id: string) => emit('select-history', id);
|
||||
const handleDeleteHistory = (id: string) => emit('delete-history', id);
|
||||
const handlePreviewNode = (data: TreeNode) => emit('preview-node', data);
|
||||
const handleDownloadNode = (data: TreeNode) => emit('download-node', data);
|
||||
const handleDeleteNode = (data: TreeNode) => emit('delete-node', data);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sidebar {
|
||||
width: 252px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.95) 0%, rgba(249, 251, 255, 0.92) 100%);
|
||||
border-right: 1px solid #e7edf7;
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
background: #f7f8fa;
|
||||
border-right: 1px solid #e5e8ee;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
box-shadow: 8px 0 30px rgba(15, 23, 42, 0.06);
|
||||
backdrop-filter: blur(10px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 18px 16px 14px;
|
||||
border-bottom: 1px solid #e9eef7;
|
||||
position: relative;
|
||||
/* 品牌区 */
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 14px 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.new-chat-btn {
|
||||
margin-top: 0;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.28);
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.26);
|
||||
transition: all 0.2s ease;
|
||||
.brand-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #2563eb, #4f8bff);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.25);
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.new-chat-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
outline: none;
|
||||
|
||||
.el-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
color: #ffffff;
|
||||
border-color: rgba(59, 130, 246, 0.4);
|
||||
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
|
||||
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.32);
|
||||
background: #e8ebf0;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
.history-section {
|
||||
flex: 1;
|
||||
/* Tab 切换(圆角胶囊) */
|
||||
.sidebar-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 8px;
|
||||
min-height: 0;
|
||||
background: linear-gradient(180deg, rgba(249, 251, 255, 0.5) 0%, rgba(249, 251, 255, 0.9) 100%);
|
||||
}
|
||||
|
||||
.history-title {
|
||||
padding: 12px 12px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
outline: none;
|
||||
|
||||
.el-icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
background: #eef0f3;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #2563eb;
|
||||
font-weight: 600;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
/* 面板通用 */
|
||||
.panel {
|
||||
flex: 1;
|
||||
padding: 0 8px 10px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px 12px;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-empty {
|
||||
font-size: 12px;
|
||||
color: #cbd5e1;
|
||||
text-align: center;
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
/* 加载失败错误态:可区分「失败」与「空数据」,并提供重试入口 */
|
||||
.panel-error {
|
||||
padding: 32px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.panel-error-text {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.panel-retry-btn {
|
||||
border: 1px solid #dbeafe;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
padding: 4px 14px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
}
|
||||
|
||||
/* 历史记录条目 */
|
||||
.history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 10px;
|
||||
margin-bottom: 6px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
transition: background 0.15s;
|
||||
margin-bottom: 2px;
|
||||
|
||||
&:hover {
|
||||
border-color: #bfdbfe;
|
||||
background: linear-gradient(135deg, rgba(239, 246, 255, 0.9) 0%, rgba(219, 234, 254, 0.7) 100%);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
background: #eef0f3;
|
||||
|
||||
&.active {
|
||||
border-color: #3b82f6;
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(59, 130, 246, 0.04) 100%);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
opacity: 0;
|
||||
color: #94a3b8;
|
||||
transition: all 0.2s ease;
|
||||
padding: 4px;
|
||||
|
||||
&:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-radius: 4px;
|
||||
.item-del-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .delete-btn {
|
||||
opacity: 1;
|
||||
&.active {
|
||||
background: #e6f0ff;
|
||||
|
||||
.item-icon {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
color: #1d4ed8;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.history-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
.item-icon {
|
||||
font-size: 14px;
|
||||
color: #94a3b8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.history-name {
|
||||
font-size: 12px;
|
||||
.item-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 2px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.history-time {
|
||||
.item-time {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.item-del-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s, color 0.15s;
|
||||
flex-shrink: 0;
|
||||
outline: none;
|
||||
|
||||
.el-icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #fee2e2;
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
/* 工作空间树节点 */
|
||||
.tree-date-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tree-date-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
padding: 8px 6px 4px;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tree-file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 10px 7px 24px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #eef0f3;
|
||||
|
||||
.file-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-type-icon {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-action-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #3b82f6;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&--del {
|
||||
color: #ef4444;
|
||||
|
||||
&:hover {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
line-height: 1.5;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.status-running {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 历史会话分页「加载更多」按钮 */
|
||||
.history-load-more {
|
||||
padding: 10px 0 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.load-more-btn {
|
||||
border: 1px solid #e5e8ee;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
padding: 5px 20px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
outline: none;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: #93c5fd;
|
||||
color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
<template>
|
||||
<div class="template-complete-dialog">
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="完善系统工作流"
|
||||
width="760px"
|
||||
top="6vh"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div v-loading="loading" class="tc-body">
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="系统模板缺少运行所需配置,补全后将保存为你的个人工作流,之后可直接复用。"
|
||||
class="tc-alert"
|
||||
/>
|
||||
|
||||
<template v-if="!loading">
|
||||
<el-form label-position="top" class="tc-form">
|
||||
<el-form-item label="工作流名称">
|
||||
<el-input v-model="flowName" placeholder="请输入工作流名称" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-empty v-if="flowWorkflowGroups.length === 0" description="该模板无需补全模型参数" :image-size="60" />
|
||||
|
||||
<!-- 主模板 / 子工作流 分组:递归展开整棵工作流树,每层模型节点单独补全 -->
|
||||
<div v-for="group in flowWorkflowGroups" :key="group.wf.key" class="tc-group">
|
||||
<div v-if="group.wf.key !== 'main'" class="tc-group-title">{{ group.wf.pathLabel }}</div>
|
||||
|
||||
<div v-for="m in group.models" :key="m.nodeKey" class="tc-node">
|
||||
<div class="tc-node-title">
|
||||
<span class="tc-node-name">{{ m.node.name || m.node.nodeCode }}</span>
|
||||
<el-tag v-if="!m.node.modelConfig?.modelId" type="warning" size="small">未选择模型</el-tag>
|
||||
<el-tag v-else-if="m.node.modelConfig?.modelName" type="success" size="small">{{ m.node.modelConfig.modelName }}</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="tc-node-model">
|
||||
<template v-if="m.node.modelConfig?.modelId">
|
||||
<!-- 系统模型:直接内联显示 API Key 输入框,保存时统一转换 -->
|
||||
<div v-if="systemModelNodeIds[m.nodeKey]" class="tc-api-key-inline">
|
||||
<el-input
|
||||
v-model="apiKeyInputs[m.nodeKey]"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="该模型为系统内置模型,请输入你的 API Key"
|
||||
size="small"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<el-button type="primary" plain size="small" @click="openModelSelector(m)">重新选择模型</el-button>
|
||||
</template>
|
||||
<el-button v-else type="primary" plain size="small" @click="openModelSelector(m)">选择模型</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="!canSave" @click="handleSave">保存并使用</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 内嵌模型选择器(自身 el-dialog 会 teleport 到 body,层级高于本弹窗) -->
|
||||
<ModelSelector
|
||||
v-model="modelSelectorVisible"
|
||||
:default-model="selectorDefaultModel"
|
||||
:same-type-model-type="selectorTargetRef?.node?.modelConfig?.modelType"
|
||||
@confirm="handleModelConfirm"
|
||||
/>
|
||||
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import ModelSelector from '/@/views/settings/workflow/component/ModelSelector.vue';
|
||||
import { stripReadonlyFields, deepClone, buildModelRequestParamsPath, enrichResponseBodyMapping } from '/@/views/settings/workflow/component/modelParamUtils';
|
||||
import { getWorkflowDetail } from '/@/api/settings/creation';
|
||||
import { updateWorkflowFromTemplate, getSubFlowWorkflowDetail } from '/@/api/settings/workflow';
|
||||
import { getModelManage, updateModelManage } from '/@/api/settings/modelConfigV2';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
template?: any; // { id, flowName, raw }
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'saved', newId: string, flowName: string): void;
|
||||
}
|
||||
|
||||
// 工作流树节点:主模板与嵌套子工作流统一建模,递归展开 sub_flow 引用
|
||||
interface FlowWorkflowNode {
|
||||
key: string; // 唯一 key:主模板 'main',子工作流 'sub:{workflowId}'
|
||||
id: string; // 工作流 id(保存时定位;主模板即来源模板 id)
|
||||
flowName: string; // 工作流原名(子工作流保存时沿用原名,不可改)
|
||||
flowContent: any; // 该工作流完整 flowContent(保存时随 subFlows 提交)
|
||||
nodes: any[]; // flowContent.nodes 引用
|
||||
startNodeId: string; // 开始节点 id(buildModelRequestParamsPath 契约需要)
|
||||
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,850 @@
|
||||
<template>
|
||||
<div class="workflow-form-card" :class="{ readonly, submitting, failed: isFailed }">
|
||||
<div class="workflow-form-header" @click="toggleCollapse">
|
||||
<div class="header-left">
|
||||
<h3>{{ detail?.flowName || '工作流表单' }}</h3>
|
||||
<div class="workflow-form-sub">{{ detail?.description || '请填写以下参数' }}</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-tag v-if="submitting" size="small" type="primary">执行中</el-tag>
|
||||
<el-tag v-else-if="isFailed" size="small" type="danger">执行失败</el-tag>
|
||||
<el-tag v-else-if="formStatus === 'done'" size="small" type="success">执行完成</el-tag>
|
||||
<!-- 折叠切换:明确按钮提示用户可展开/收起已填参数(点击标题行同样可切换) -->
|
||||
<button type="button" class="collapse-toggle" @click.stop="toggleCollapse">
|
||||
<el-icon class="collapse-arrow" :class="{ collapsed }"><ArrowDown /></el-icon>
|
||||
<span>{{ collapsed ? '展开' : '收起' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 节点执行过程区(仿豆包步骤流):有节点事件才显示,始终可见、不受参数收起影响 -->
|
||||
<div v-if="progress?.nodes?.length" class="node-progress-list">
|
||||
<div v-for="node in progress.nodes" :key="node.nodeId" class="node-step" :class="'is-' + node.status">
|
||||
<span class="node-step-icon">
|
||||
<span v-if="node.status === 'running'" class="node-spinner" />
|
||||
<el-icon v-else-if="node.status === 'done'" class="node-icon-done"><CircleCheckFilled /></el-icon>
|
||||
<el-icon v-else-if="node.status === 'failed'" class="node-icon-failed"><CircleCloseFilled /></el-icon>
|
||||
<span v-else class="node-dot" />
|
||||
</span>
|
||||
<span class="node-step-name">{{ node.nodeName }}</span>
|
||||
<span class="node-step-status">{{ nodeStatusText(node) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="!collapsed" class="workflow-form-scroll">
|
||||
<el-form label-position="top" class="workflow-form">
|
||||
<!-- 计费方式:必选、不设置默认值;可选项来自计价对象 charge-modes 接口(workflow) -->
|
||||
<el-form-item label="计费方式" required>
|
||||
<el-select v-model="chargeMode" :disabled="isDisabled" placeholder="请选择计费方式" class="w100">
|
||||
<el-option v-for="opt in chargeModes" :key="opt.mode" :label="opt.name" :value="opt.mode" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<template v-if="detail?.nodeInputParams">
|
||||
<template v-for="node in detail.nodeInputParams" :key="node.id || node.nodeCode">
|
||||
<template v-if="hasFormConfig(node)">
|
||||
<el-form-item
|
||||
v-for="field in getVisibleFields(node)"
|
||||
:key="getFieldKey(node, field)"
|
||||
:label="field.label"
|
||||
:required="field.required"
|
||||
>
|
||||
<!-- 引用其他节点输出:只读展示,值由上游节点运行时提供 -->
|
||||
<div v-if="field.valueSource" class="field-source-readonly">
|
||||
<el-tag type="info" size="small" class="source-tag">引用</el-tag>
|
||||
<span class="source-text">由「{{ getSourceDisplay(field) }}」自动提供</span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 文本输入 -->
|
||||
<el-input
|
||||
v-if="field.type === 'input' || field.type === 'string'"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
:disabled="isDisabled"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<!-- 数字输入 -->
|
||||
<el-input-number
|
||||
v-else-if="field.type === 'number' || field.type === 'inputNumber'"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
class="w100"
|
||||
:controls="true"
|
||||
:disabled="isDisabled"
|
||||
:min="field.fieldConstraint?.minValue ?? undefined"
|
||||
:max="field.fieldConstraint?.maxValue ?? undefined"
|
||||
/>
|
||||
|
||||
<!-- 多行文本 -->
|
||||
<el-input
|
||||
v-else-if="field.type === 'textarea'"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:disabled="isDisabled"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
show-word-limit
|
||||
:maxlength="500"
|
||||
/>
|
||||
|
||||
<!-- 开关 -->
|
||||
<el-switch
|
||||
v-else-if="field.type === 'switch'"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
:disabled="isDisabled"
|
||||
active-text="开启"
|
||||
inactive-text="关闭"
|
||||
/>
|
||||
|
||||
<!-- 下拉选择 -->
|
||||
<el-select
|
||||
v-else-if="field.type === 'select' && field.options"
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
:disabled="isDisabled"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
class="w100"
|
||||
>
|
||||
<el-option v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
|
||||
<!-- 文件上传 -->
|
||||
<div v-else-if="isFileField(field)" class="field-upload-wrapper">
|
||||
<el-upload
|
||||
:key="`upload-${getFieldKey(node, field)}`"
|
||||
:auto-upload="false"
|
||||
:multiple="field.type === 'uploadMultiple'"
|
||||
:show-file-list="false"
|
||||
:accept="getFileAccept(field)"
|
||||
:disabled="isDisabled"
|
||||
:on-change="(file: any) => handleFileUpload(node, field, file)"
|
||||
>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="uploadingFields[getFieldKey(node, field)]"
|
||||
:disabled="isDisabled || uploadingFields[getFieldKey(node, field)]"
|
||||
>
|
||||
{{ uploadingFields[getFieldKey(node, field)] ? '上传中...' : '选择文件' }}
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<div class="upload-rules">
|
||||
<span>{{ getFileRuleText(field) }}</span>
|
||||
<span v-if="getFieldFileList(node, field).length > 0" class="upload-count">
|
||||
已上传 {{ getFieldFileCountText(node, field) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="getFieldFileList(node, field).length > 0" class="uploaded-files-list">
|
||||
<div
|
||||
v-for="(f, fileIdx) in getFieldFileList(node, field)"
|
||||
:key="fileIdx"
|
||||
class="uploaded-file-item"
|
||||
>
|
||||
<span class="file-name">{{ f.name }}</span>
|
||||
<el-button v-if="!isDisabled" type="danger" link size="small" @click="removeFieldFile(node, field, fileIdx)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 兜底文本输入 -->
|
||||
<el-input
|
||||
v-else
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
:disabled="isDisabled"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
/>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</el-form>
|
||||
|
||||
<!-- 贴片模板编辑器 -->
|
||||
<div v-if="currentWorkflowHasPatchLayout" class="patch-template-section">
|
||||
<PatchTemplateEditor v-model="templates" />
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!hasFormFields && !currentWorkflowHasPatchLayout" description="该工作流无需填写参数" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<!-- 底部操作区:editing 提示 / running 执行中 / done·failed 状态标签(执行统一由 InputBar 发送按钮触发) -->
|
||||
<div class="workflow-form-footer">
|
||||
<div v-if="isFailed" class="form-error-text">{{ formError }}</div>
|
||||
<!-- 执行中:节点文本进度推进(后端 node_start/node_complete 事件),无进度时兜底「执行中...」;
|
||||
停止统一由 InputBar 发送按钮(变停止)触发,卡片内不再提供取消入口 -->
|
||||
<div v-if="submitting" class="executing-progress">
|
||||
<span class="exec-spinner" />
|
||||
<span class="exec-text">{{ progressText }}</span>
|
||||
</div>
|
||||
<!-- 已执行卡片(done/failed,含可编辑的最后一张):显示状态标签,参数是否可编辑由 editable 决定 -->
|
||||
<el-tag v-else-if="formStatus === 'done' || formStatus === 'failed'" :type="isFailed ? 'danger' : 'success'" size="small" effect="light">
|
||||
{{ isFailed ? '执行失败' : '执行完成' }}
|
||||
</el-tag>
|
||||
<!-- 编辑态:无执行按钮,提示通过下方发送按钮执行 -->
|
||||
<span v-else class="form-edit-tip">填写参数后,点击下方发送按钮执行工作流</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { ArrowDown, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
|
||||
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
|
||||
import { uploadFile } from '/@/api/common/upload';
|
||||
import { getChargeModes } from '/@/api/trade/pricing';
|
||||
import { collectHomeFormFields } from '../utils/flowDsl';
|
||||
import type { WorkflowNodeStep } from '../utils/wsMessage';
|
||||
|
||||
interface Props {
|
||||
// 工作流详情(含 nodeInputParams / flowContent)
|
||||
detail: any;
|
||||
// 定格只读(done/failed 态):禁用表单与提交
|
||||
readonly?: boolean;
|
||||
// 执行中:禁用表单,底部显示加载中
|
||||
submitting?: boolean;
|
||||
// 执行失败信息(有值即 failed 态)
|
||||
formError?: string;
|
||||
// 执行节点进度(node_start/node_complete 事件驱动的步骤列表,渲染执行过程区)
|
||||
progress?: { nodes: WorkflowNodeStep[] };
|
||||
// InputBar 发送按钮触发执行信号:仅当本卡片为目标时非 null(统一走提交,含必填校验)
|
||||
executeSignal?: { action: 'submit'; nonce: number } | null;
|
||||
// 已执行卡片(done/failed)参数可编辑(会话中最后一张执行过的工作流):改参后经发送按钮重新执行
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', payload: { formValues: Record<string, any>; formFileNames: Record<string, string | string[]>; templates: any[]; chargeMode: string }): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
readonly: false,
|
||||
submitting: false,
|
||||
formError: '',
|
||||
progress: () => ({ nodes: [] }),
|
||||
executeSignal: null,
|
||||
editable: false,
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const formValues = reactive<Record<string, any>>({});
|
||||
const fieldFiles = reactive<Record<string, { name: string; url: string }[]>>({});
|
||||
// 上传字段的文件名(与 formValues 平行;单文件为字符串、多文件为数组),执行时随 value 一并写回开始节点传给后端
|
||||
const formFileNames = reactive<Record<string, string | string[]>>({});
|
||||
const uploadingFields = reactive<Record<string, boolean>>({});
|
||||
const templates = ref<any[]>([]);
|
||||
|
||||
// ===== 计费方式:必选、不设置默认值(新建为空强制选择;回显重跑恢复上次选择) =====
|
||||
interface ChargeModeOption {
|
||||
mode: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const chargeMode = ref('');
|
||||
const chargeModes = ref<ChargeModeOption[]>([]);
|
||||
/** 计费方式可选项缓存:workflow 计价对象固定(subjectType/subjectId 均 workflow),模块级共享避免每张卡片重复请求 */
|
||||
let workflowChargeModesCache: ChargeModeOption[] | null = null;
|
||||
|
||||
const loadChargeModes = async () => {
|
||||
if (workflowChargeModesCache) {
|
||||
chargeModes.value = workflowChargeModesCache;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res: any = await getChargeModes({ subjectType: 'workflow', subjectId: 'workflow' });
|
||||
const list: ChargeModeOption[] = res?.data?.chargeModes || [];
|
||||
chargeModes.value = list;
|
||||
workflowChargeModesCache = list;
|
||||
} catch {
|
||||
// 计费方式拉取失败:全局拦截器已提示,下拉留空(必填校验会拦截提交)
|
||||
chargeModes.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
// 状态派生:失败 = 携带 formError;禁用编辑 = 定格(done/failed)卡片默认只读,
|
||||
// 但最后一张已执行卡片(editable)允许改参;执行中(submitting)一律禁用
|
||||
const isFailed = computed(() => !!props.formError);
|
||||
const isDisabled = computed(() => (props.readonly && !props.editable) || props.submitting);
|
||||
|
||||
// 表单收起/展开:editing(待提交/回显)默认展开;提交(running)自动收起;
|
||||
// done/failed 保持提交时状态(默认收起,用户可手动展开查看已填值)
|
||||
const collapsed = ref(false);
|
||||
const formStatus = computed(() => {
|
||||
if (props.submitting) return 'running';
|
||||
if (props.formError) return 'failed';
|
||||
if (props.readonly) return 'done';
|
||||
return 'editing';
|
||||
});
|
||||
watch(
|
||||
formStatus,
|
||||
(st, prev) => {
|
||||
if (st === 'editing') {
|
||||
collapsed.value = false; // 回显/重新编辑:默认展开
|
||||
} else if (prev === undefined) {
|
||||
// 初始即非编辑态(执行中/完成/失败):一律收起(执行后默认收起,不因可编辑展开),
|
||||
// 用户可手动展开查看已填值/改参
|
||||
collapsed.value = true;
|
||||
} else if (st === 'running') {
|
||||
collapsed.value = true; // 提交瞬间自动收起
|
||||
}
|
||||
// done/failed 到达时保持当前(提交时已收起,用户手动展开则保留)
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
const toggleCollapse = () => {
|
||||
collapsed.value = !collapsed.value;
|
||||
};
|
||||
|
||||
// 执行进度文本:有进行中节点显示「正在执行:节点名」,否则兜底「执行中...」
|
||||
const progressText = computed(() => {
|
||||
const running = (props.progress?.nodes || []).find((n) => n.status === 'running');
|
||||
return running ? `正在执行:${running.nodeName}` : '执行中...';
|
||||
});
|
||||
|
||||
// 节点步骤状态文案(执行过程区)
|
||||
const nodeStatusText = (node: WorkflowNodeStep): string => {
|
||||
switch (node.status) {
|
||||
case 'running':
|
||||
return '执行中';
|
||||
case 'done':
|
||||
return '完成';
|
||||
case 'failed':
|
||||
return '失败';
|
||||
default:
|
||||
return '待执行';
|
||||
}
|
||||
};
|
||||
|
||||
const getFieldKey = (node: any, field: any): string => {
|
||||
const id = node.id || node.nodeCode;
|
||||
return `${id}|${field.path || field.field || field.label}`;
|
||||
};
|
||||
|
||||
const getVisibleFields = (node: any): any[] => {
|
||||
return collectHomeFormFields(node);
|
||||
};
|
||||
|
||||
// 引用来源展示:valueSource 统一数组 [{ nodeId, field }](兼容旧 DSL 单对象),逐项定位上游节点名 + 字段
|
||||
const getSourceDisplay = (field: any): string => {
|
||||
const vs = field?.valueSource;
|
||||
if (!vs || typeof vs !== 'object') return '';
|
||||
const arr = Array.isArray(vs) ? vs : [vs];
|
||||
const nodes = props.detail?.nodeInputParams || [];
|
||||
const parts = arr
|
||||
.filter((r: any) => r && typeof r === 'object')
|
||||
.map((r: any) => {
|
||||
const srcNode = nodes.find((n: any) => String(n?.id) === String(r.nodeId));
|
||||
const nodeName = srcNode?.name || srcNode?.nodeName || (r.nodeId ? `节点 ${r.nodeId}` : '上游节点');
|
||||
return r.field ? `${nodeName} · ${r.field}` : nodeName;
|
||||
});
|
||||
return parts.join(',');
|
||||
};
|
||||
|
||||
const isFileField = (field: any): boolean => {
|
||||
return field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload';
|
||||
};
|
||||
|
||||
const getFileAccept = (field: any): string => {
|
||||
return field.fieldConstraint?.fileTypes ? '.' + field.fieldConstraint.fileTypes.replace(/,/g, ',.') : '*';
|
||||
};
|
||||
|
||||
const getFileRuleText = (field: any): string => {
|
||||
const rules: string[] = [];
|
||||
if (field.fieldConstraint?.fileTypes) rules.push('格式: ' + field.fieldConstraint.fileTypes);
|
||||
if (field.fieldConstraint?.maxFileSize) rules.push('最大: ' + field.fieldConstraint.maxFileSize + 'MB');
|
||||
if (field.fieldConstraint?.maxFileCount) rules.push('最多: ' + field.fieldConstraint.maxFileCount + '个');
|
||||
return rules.join(' | ');
|
||||
};
|
||||
|
||||
const getFieldFileList = (node: any, field: any): { name: string; url: string }[] => {
|
||||
return fieldFiles[getFieldKey(node, field)] || [];
|
||||
};
|
||||
|
||||
const getFieldConstraint = (field: any): any => {
|
||||
return field?.fieldConstraint && typeof field.fieldConstraint === 'object' ? field.fieldConstraint : {};
|
||||
};
|
||||
|
||||
const getFieldFileCountText = (node: any, field: any): string => {
|
||||
const current = getFieldFileList(node, field).length;
|
||||
const max = Number(getFieldConstraint(field).maxFileCount);
|
||||
if (!Number.isNaN(max) && max > 0) {
|
||||
return `${current} / ${max}`;
|
||||
}
|
||||
return `${current}`;
|
||||
};
|
||||
|
||||
const handleFileUpload = async (node: any, field: any, file: any) => {
|
||||
const raw = file.raw;
|
||||
if (!raw) return;
|
||||
|
||||
const key = getFieldKey(node, field);
|
||||
const fc = getFieldConstraint(field);
|
||||
|
||||
const typeRules = String(fc.fileTypes || '')
|
||||
.split(',')
|
||||
.map((t: string) => t.trim().toLowerCase().replace(/^\./, ''))
|
||||
.filter(Boolean);
|
||||
if (typeRules.length > 0) {
|
||||
const ext = (raw.name?.split('.').pop() || '').toLowerCase();
|
||||
if (!typeRules.includes(ext)) {
|
||||
ElMessage.warning(`文件格式不符合要求,仅支持:${typeRules.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const maxFileSize = Number(fc.maxFileSize);
|
||||
if (!Number.isNaN(maxFileSize) && maxFileSize > 0) {
|
||||
const sizeMB = raw.size / 1024 / 1024;
|
||||
if (sizeMB > maxFileSize) {
|
||||
ElMessage.warning(`文件大小超限,最大 ${maxFileSize}MB`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const maxFileCount = Number(fc.maxFileCount);
|
||||
if (!Number.isNaN(maxFileCount) && maxFileCount > 0) {
|
||||
const currentCount = fieldFiles[key]?.length || 0;
|
||||
if (currentCount >= maxFileCount) {
|
||||
ElMessage.warning(`文件数量超限,最多 ${maxFileCount} 个`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
uploadingFields[key] = true;
|
||||
|
||||
try {
|
||||
const uploadRes = await uploadFile(raw, { timeout: 0 });
|
||||
if (!uploadRes?.data?.fileURL) throw new Error('上传失败:未返回文件URL');
|
||||
|
||||
const fileUrl = uploadRes.data.fileAddressPrefix
|
||||
? `${uploadRes.data.fileAddressPrefix}${uploadRes.data.fileURL}`
|
||||
: uploadRes.data.fileURL;
|
||||
|
||||
if (!fieldFiles[key]) fieldFiles[key] = [];
|
||||
// 文件名取服务器返回 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 runtimeShow;form → outputConfig;旧数据 → formConfig)
|
||||
nodes.forEach((node) => {
|
||||
collectHomeFormFields(node).forEach((field) => {
|
||||
const key = getFieldKey(node, field);
|
||||
const hasValue = field.value !== undefined && field.value !== null && field.value !== '';
|
||||
if (field.type === 'number' || field.type === 'inputNumber') {
|
||||
formValues[key] = hasValue ? Number(field.value) : (field.default ?? null);
|
||||
} else if (field.type === 'switch') {
|
||||
formValues[key] = hasValue ? Boolean(field.value) : (field.default ?? false);
|
||||
} else if (field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload') {
|
||||
if (field.type === 'fileUpload') {
|
||||
formValues[key] = hasValue
|
||||
? Array.isArray(field.value)
|
||||
? field.value
|
||||
: field.value
|
||||
? [field.value]
|
||||
: []
|
||||
: Array.isArray(field.default)
|
||||
? field.default
|
||||
: field.default
|
||||
? [field.default]
|
||||
: [];
|
||||
} else {
|
||||
formValues[key] = hasValue ? field.value : (field.default ?? (field.type === 'upload' ? '' : []));
|
||||
}
|
||||
} else {
|
||||
formValues[key] = hasValue ? field.value : (field.default ?? '');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 2) 文件字段回填已上传文件列表
|
||||
nodes.forEach((node) => {
|
||||
collectHomeFormFields(node).forEach((field) => {
|
||||
if (!isFileField(field)) return;
|
||||
const key = getFieldKey(node, field);
|
||||
const rawValue = formValues[key];
|
||||
const urls = Array.isArray(rawValue) ? rawValue : rawValue ? [rawValue] : [];
|
||||
if (urls.length === 0) return;
|
||||
// 文件名优先取运行字段携带的服务器名(新数据),旧数据从 url 提取兜底
|
||||
const rawFn = (field as any).fileName;
|
||||
fieldFiles[key] = urls.map((url: string, i: number) => ({
|
||||
name:
|
||||
(Array.isArray(rawFn) ? rawFn[i] : i === 0 ? rawFn : undefined) ||
|
||||
String(url || '').split('/').pop() ||
|
||||
'file-' + Math.random().toString(36).slice(2, 8),
|
||||
url,
|
||||
}));
|
||||
formFileNames[key] =
|
||||
field.type === 'upload' && fieldFiles[key].length === 1
|
||||
? fieldFiles[key][0].name
|
||||
: fieldFiles[key].map((f) => f.name);
|
||||
});
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
// 定格卡片默认只读不可提交;最后一张已执行卡片(editable)允许改参后重新提交
|
||||
if ((props.readonly && !props.editable) || props.submitting) return;
|
||||
if (!validateFormFields()) return;
|
||||
emit('submit', {
|
||||
formValues: JSON.parse(JSON.stringify(formValues)),
|
||||
formFileNames: JSON.parse(JSON.stringify(formFileNames)),
|
||||
templates: JSON.parse(JSON.stringify(templates.value || [])),
|
||||
chargeMode: chargeMode.value,
|
||||
});
|
||||
};
|
||||
|
||||
// InputBar 发送按钮触发执行:父组件把执行信号下发给目标卡片(本卡片为目标时 executeSignal 非 null),
|
||||
// 统一调用提交(含必填校验);卡片参数在组件内部,父组件经信号间接触发
|
||||
watch(
|
||||
() => props.executeSignal,
|
||||
(sig) => {
|
||||
if (!sig) return;
|
||||
handleSubmit();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* ===== 表单卡片(对话消息流内) ===== */
|
||||
.workflow-form-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e8ee;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
|
||||
width: min(640px, calc(100% - 4px));
|
||||
max-width: 100%;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&.readonly {
|
||||
box-shadow: none;
|
||||
}
|
||||
&.submitting {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.workflow-form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.15s;
|
||||
&:hover { background: #f8fafc; }
|
||||
h3 { margin: 0; font-size: 14px; font-weight: 600; color: #0f172a; }
|
||||
.workflow-form-sub { font-size: 12px; color: #94a3b8; margin-top: 2px; }
|
||||
}
|
||||
.header-left { min-width: 0; }
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* 折叠切换按钮:文字 + 箭头,明确提示可展开/收起参数区 */
|
||||
.collapse-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
&:hover {
|
||||
border-color: #93c5fd;
|
||||
color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
.collapse-arrow {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transition: transform 0.2s;
|
||||
&.collapsed { transform: rotate(-180deg); }
|
||||
}
|
||||
}
|
||||
|
||||
/* 节点执行过程区(豆包式步骤流) */
|
||||
.node-progress-list {
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.node-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
|
||||
.node-step-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.node-step-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #1e293b;
|
||||
}
|
||||
.node-step-status {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
&.is-running .node-step-status { color: #2563eb; }
|
||||
&.is-done .node-step-status { color: #22c55e; }
|
||||
&.is-failed .node-step-status { color: #ef4444; }
|
||||
}
|
||||
.node-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid #dbeafe;
|
||||
border-top-color: #2563eb;
|
||||
border-radius: 50%;
|
||||
animation: node-spin 0.8s linear infinite;
|
||||
}
|
||||
.node-icon-done { color: #22c55e; font-size: 15px; }
|
||||
.node-icon-failed { color: #ef4444; font-size: 15px; }
|
||||
.node-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #cbd5e1;
|
||||
}
|
||||
@keyframes node-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.workflow-form-scroll {
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
&::-webkit-scrollbar { width: 4px; }
|
||||
&::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
||||
&::-webkit-scrollbar-track { background: transparent; }
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 14px;
|
||||
&:last-child { margin-bottom: 0; }
|
||||
.el-form-item__label { font-size: 13px; font-weight: 500; color: #334155; padding-bottom: 4px; line-height: 1.5; }
|
||||
.el-form-item__label::before { color: #ef4444; margin-right: 4px; }
|
||||
.el-input-number { width: 100%; }
|
||||
.el-select { width: 100%; }
|
||||
.el-textarea__inner { font-size: 13px; }
|
||||
}
|
||||
|
||||
/* 引用字段只读展示 */
|
||||
.field-source-readonly {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
.source-tag { flex-shrink: 0; }
|
||||
.source-text { line-height: 1.5; }
|
||||
}
|
||||
|
||||
.uploaded-files-list { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
.uploaded-file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
.file-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.patch-template-section {
|
||||
margin-top: 16px;
|
||||
:deep(.patch-template-editor) {
|
||||
> .el-divider { margin-top: 4px; margin-bottom: 12px; border-top-color: #e2e8f0;
|
||||
.el-divider__text { font-weight: 600; font-size: 13px; color: #475569; background: transparent; padding-left: 0; }
|
||||
}
|
||||
.template-card { border: 1px solid #e2e8f0; border-radius: 10px; margin-bottom: 10px; }
|
||||
}
|
||||
}
|
||||
|
||||
/* 底部操作区 */
|
||||
.workflow-form-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
background: #fafbfc;
|
||||
flex-shrink: 0;
|
||||
.form-error-text {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: #ef4444;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap; /* 失败详情可能含换行(error 字段多行原因),允许换行展示 */
|
||||
word-break: break-word;
|
||||
}
|
||||
.form-edit-tip {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
/* 执行进度:spinner + 文本,节点事件推进 */
|
||||
.executing-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
.exec-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid #bfdbfe;
|
||||
border-top-color: #2563eb;
|
||||
border-radius: 50%;
|
||||
animation: exec-spin 0.8s linear infinite;
|
||||
}
|
||||
.exec-text {
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@keyframes exec-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.w100 { width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,263 @@
|
||||
<template>
|
||||
<div class="workflow-output-card">
|
||||
<template v-for="(out, i) in outputs" :key="i">
|
||||
<!-- 图片:常驻内嵌缩略图,点击放大看大图(走父级预览弹窗) -->
|
||||
<div v-if="out.type === 'image'" class="output-media is-image" @click="emit('preview', out)">
|
||||
<img :src="resolveUrl ? resolveUrl(out.url) : out.url" :alt="out.name" loading="lazy" />
|
||||
<span class="media-name">{{ out.name }}</span>
|
||||
<span class="media-actions" @click.stop>
|
||||
<button type="button" class="output-action" title="下载" @click="emit('download', out)">
|
||||
<el-icon><Download /></el-icon>
|
||||
</button>
|
||||
<button type="button" class="output-action is-danger" title="删除" @click="emit('delete', out)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 视频:内嵌播放器(底部不叠加文件名,避免遮挡原生控制条) -->
|
||||
<div v-else-if="out.type === 'video'" class="output-media is-video">
|
||||
<video :src="resolveUrl ? resolveUrl(out.url) : out.url" controls preload="metadata" :title="out.name"></video>
|
||||
<span class="media-actions" @click.stop>
|
||||
<button type="button" class="output-action" title="下载" @click="emit('download', out)">
|
||||
<el-icon><Download /></el-icon>
|
||||
</button>
|
||||
<button type="button" class="output-action is-danger" title="删除" @click="emit('delete', out)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 音频:内嵌播放器(底部不叠加文件名,避免遮挡原生控制条) -->
|
||||
<div v-else-if="out.type === 'audio'" class="output-media is-audio">
|
||||
<audio :src="resolveUrl ? resolveUrl(out.url) : out.url" controls preload="metadata" :title="out.name"></audio>
|
||||
<span class="media-actions" @click.stop>
|
||||
<button type="button" class="output-action" title="下载" @click="emit('download', out)">
|
||||
<el-icon><Download /></el-icon>
|
||||
</button>
|
||||
<button type="button" class="output-action is-danger" title="删除" @click="emit('delete', out)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 文本 / 其他类型:文件行 + 预览/下载/删除 -->
|
||||
<div v-else class="output-item">
|
||||
<el-icon class="output-icon" :class="`is-${out.type || 'file'}`">
|
||||
<component :is="getIcon(out.type)" />
|
||||
</el-icon>
|
||||
<span class="output-name" :title="out.name">{{ out.name }}</span>
|
||||
<span class="output-actions">
|
||||
<button v-if="out.type !== 'text'" type="button" class="output-action" title="预览" @click="emit('preview', out)">
|
||||
<el-icon><View /></el-icon>
|
||||
</button>
|
||||
<button type="button" class="output-action" title="下载" @click="emit('download', out)">
|
||||
<el-icon><Download /></el-icon>
|
||||
</button>
|
||||
<button type="button" class="output-action is-danger" title="删除" @click="emit('delete', out)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Picture, VideoPlay, Headset, Document, View, Download, Delete } from '@element-plus/icons-vue';
|
||||
|
||||
// 产出文件:url 文件地址(相对/绝对)、name 展示名、type 预览类型(image/video/audio/text/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>
|
||||
+1751
-311
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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_URL(http(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 发送启动帧。
|
||||
* 返回 SessionSocket;URL 构造失败返回 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;
|
||||
});
|
||||
|
||||
// 未传会话号:等后端首帧 ack(data.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' }));
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// ===== 首页 WebSocket 流式消息解析工具 =====
|
||||
// 服务端推送统一为 JSON 文本帧(后端 ReAct 事件协议):
|
||||
// {"type":"model_call","message":"模型思考","data":{"step":1,"maxStep":15}} 模型思考(step 开始)
|
||||
// {"type":"tool_call","message":"调用工具","data":{"tool":"web_search","arguments":{}}} 模型请求调用工具
|
||||
// {"type":"tool_result","message":"工具返回","data":{"result":"..."}} 工具返回结果
|
||||
// {"type":"reasoning_chunk","message":"思考中","data":{"delta":"你"}} 思考内容增量(逐 chunk)
|
||||
// {"type":"answer_chunk","message":"回答中","data":{"delta":"你"}} 回答内容增量(逐 chunk)
|
||||
// {"type":"answer","message":"作答完成","data":{"answer":"..."}} 最终回答
|
||||
// {"type":"node_start","message":"开始执行(2/4): xxx","data":{"nodeCount":4,"nodeId":"..","nodeIndex":2,"nodeName":".."}} 工作流节点开始(进度推进)
|
||||
// {"type":"node_complete","message":"执行完成(2/4): xxx","data":同上} 工作流节点完成(进度推进)
|
||||
// {"type":"error","message":"...","error":"节点失败详情"} 执行失败(error 字段含详细原因)
|
||||
// 完成/失败判定均以 type 为准,不再用正则猜测文本。
|
||||
|
||||
export interface WsStreamMessage {
|
||||
type: string;
|
||||
message?: string;
|
||||
data?: any;
|
||||
payload?: any;
|
||||
// 失败详情:error 事件携带的详细原因(含节点名与具体失败信息),比 message 更具体
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** 后端 ReAct WebSocket 事件类型常量 */
|
||||
export const WsEventType = {
|
||||
ModelCall: 'model_call', // 模型思考(step 开始)
|
||||
ToolCall: 'tool_call', // 模型请求调用工具
|
||||
ToolResult: 'tool_result', // 工具返回结果
|
||||
Answer: 'answer', // 最终回答
|
||||
AnswerChunk: 'answer_chunk', // 回答内容增量(逐 chunk)
|
||||
RoundStart: 'round_start', // 本轮问答开始(后端收到提问后第一帧,data.recordId 为本次问答记录 id)
|
||||
ReasoningChunk: 'reasoning_chunk', // 思考内容增量(逐 chunk)
|
||||
NodeStart: 'node_start', // 工作流节点开始(进度推进)
|
||||
NodeComplete: 'node_complete', // 工作流节点完成(进度推进)
|
||||
FlowComplete: 'flow_complete', // 工作流执行完成(后端完成信号,日志末尾事件)
|
||||
Error: 'error', // 出错
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 解析服务端推送帧。
|
||||
* 非 JSON、无 type 的帧返回 null(调用方忽略)。
|
||||
*/
|
||||
export function parseWsMessage(raw: any): WsStreamMessage | null {
|
||||
let data: any = raw;
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!data || typeof data !== 'object' || typeof data.type !== 'string') return null;
|
||||
return data;
|
||||
}
|
||||
|
||||
/** answer_chunk / reasoning_chunk → data.delta 流式增量 */
|
||||
export function getDelta(msg: WsStreamMessage): string {
|
||||
return typeof msg?.data?.delta === 'string' ? msg.data.delta : '';
|
||||
}
|
||||
|
||||
/** answer → data.answer 最终答案 */
|
||||
export function getAnswer(msg: WsStreamMessage): string {
|
||||
return typeof msg?.data?.answer === 'string' ? msg.data.answer : '';
|
||||
}
|
||||
|
||||
/** round_start → data.recordId 本次问答记录 id(19 位雪花大整数,契约要求字符串形式,兼容数字类型防御性转 string) */
|
||||
export function getRecordId(msg: WsStreamMessage): string {
|
||||
const rid = msg?.data?.recordId;
|
||||
if (typeof rid === 'string' || typeof rid === 'number') return String(rid);
|
||||
return '';
|
||||
}
|
||||
|
||||
/** model_call → { step, maxStep },缺失时 maxStep 为 0 */
|
||||
export function getStepInfo(msg: WsStreamMessage): { step: number; maxStep: number } | null {
|
||||
const step = msg?.data?.step;
|
||||
if (typeof step !== 'number') return null;
|
||||
const maxStep = msg?.data?.maxStep;
|
||||
return { step, maxStep: typeof maxStep === 'number' ? maxStep : 0 };
|
||||
}
|
||||
|
||||
/** 工作流节点进度:node_start / node_complete → { current, total, nodeName },非节点事件返回 null */
|
||||
export function getNodeProgress(msg: WsStreamMessage): { current: number; total: number; nodeName: string } | null {
|
||||
const t = msg?.type;
|
||||
if (t !== WsEventType.NodeStart && t !== WsEventType.NodeComplete) return null;
|
||||
const d = msg?.data;
|
||||
const nodeIndex = d?.nodeIndex;
|
||||
const nodeCount = d?.nodeCount;
|
||||
if (typeof nodeIndex !== 'number' || typeof nodeCount !== 'number') return null;
|
||||
return {
|
||||
current: nodeIndex,
|
||||
total: nodeCount,
|
||||
nodeName: typeof d?.nodeName === 'string' ? d.nodeName : '',
|
||||
};
|
||||
}
|
||||
|
||||
/** 工作流执行节点步骤(node_start/node_complete/error 事件驱动,渲染表单卡片执行过程区) */
|
||||
export interface WorkflowNodeStep {
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
nodeIndex: number;
|
||||
status: 'pending' | 'running' | 'done' | 'failed';
|
||||
}
|
||||
|
||||
/** 工作流执行节点进度(挂在表单卡片消息 formProgress 上) */
|
||||
export interface WorkflowNodeProgress {
|
||||
nodes: WorkflowNodeStep[];
|
||||
}
|
||||
|
||||
/** 工作流节点事件解析结果:node_start / node_complete → { nodeId, nodeName, nodeIndex, phase } */
|
||||
export interface WorkflowNodeEvent {
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
nodeIndex: number;
|
||||
phase: 'start' | 'complete';
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析工作流节点事件(node_start / node_complete)。
|
||||
* 返回完整节点信息(nodeId/nodeName/nodeIndex/phase),供执行过程区步骤列表驱动;非节点事件返回 null。
|
||||
*/
|
||||
export function getNodeEvent(msg: WsStreamMessage): WorkflowNodeEvent | null {
|
||||
const t = msg?.type;
|
||||
if (t !== WsEventType.NodeStart && t !== WsEventType.NodeComplete) return null;
|
||||
const d = msg?.data;
|
||||
const nodeIndex = d?.nodeIndex;
|
||||
if (typeof nodeIndex !== 'number') return null;
|
||||
return {
|
||||
nodeId: typeof d?.nodeId === 'string' ? d.nodeId : '',
|
||||
nodeName: typeof d?.nodeName === 'string' ? d.nodeName : '',
|
||||
nodeIndex,
|
||||
phase: t === WsEventType.NodeStart ? 'start' : 'complete',
|
||||
};
|
||||
}
|
||||
|
||||
/** tool_call → 工具名称(tool / toolName / name 依次尝试,缺失返回空串) */
|
||||
export function getToolCallName(msg: WsStreamMessage): string {
|
||||
const d = msg?.data;
|
||||
if (!d || typeof d !== 'object') return '';
|
||||
const name = d.tool ?? d.toolName ?? d.name;
|
||||
return typeof name === 'string' ? name : '';
|
||||
}
|
||||
|
||||
/** tool_result → 工具返回文本(result / output / content / message 依次尝试,对象则序列化) */
|
||||
export function getToolResultText(msg: WsStreamMessage): string {
|
||||
const d = msg?.data;
|
||||
if (typeof d === 'string') return d;
|
||||
if (!d || typeof d !== 'object') return '';
|
||||
const raw = d.result ?? d.output ?? d.content ?? d.message;
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (raw != null) {
|
||||
try {
|
||||
return JSON.stringify(raw);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** error → 错误文案(message 优先展示简洁业务提示;error 详细原因仅在 message 缺失时兜底) */
|
||||
export function getErrorText(msg: WsStreamMessage): string {
|
||||
// 只显示 message 的消息(用户确认),error 详细原因不再作为界面展示内容
|
||||
if (typeof msg?.message === 'string' && msg.message) return msg.message;
|
||||
if (typeof msg?.error === 'string' && msg.error) return msg.error;
|
||||
const d = msg?.data;
|
||||
if (typeof d === 'string') return d;
|
||||
if (d && typeof d === 'object') {
|
||||
const nested = d.message ?? d.error ?? d.msg;
|
||||
if (typeof nested === 'string' && nested) return nested;
|
||||
}
|
||||
return '执行失败,请重试';
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export default defineComponent({
|
||||
isShowPassword: false,
|
||||
ruleForm: {
|
||||
username: 'admin',
|
||||
password: '123456',
|
||||
password: 'Tongli686^*^',
|
||||
verifyCode: '',
|
||||
verifyKey: '',
|
||||
},
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
<!-- 右侧表单区域 -->
|
||||
<div class="login-form-section">
|
||||
<div class="form-wrapper">
|
||||
<!-- 扫码/账号切换按钮 -->
|
||||
<div class="scan-toggle" @click="state.isScan = !state.isScan">
|
||||
<!-- 扫码/账号切换按钮(暂时隐藏,恢复时删除 v-if="false")-->
|
||||
<div class="scan-toggle" v-if="false" @click="state.isScan = !state.isScan">
|
||||
<!-- 电脑图标 (切换回账号登录) -->
|
||||
<svg v-if="state.isScan" viewBox="0 0 1024 1024" width="24" height="24" fill="currentColor">
|
||||
<path d="M128 192h768v448H128V192z m0-64c-35.3 0-64 28.7-64 64v448c0 35.3 28.7 64 64 64h768c35.3 0 64-28.7 64-64V192c0-35.3-28.7-64-64-64H128z m256 640h256v64H384v-64z"></path>
|
||||
@@ -40,17 +40,20 @@
|
||||
<el-tab-pane label="账号登录" name="account">
|
||||
<Account />
|
||||
</el-tab-pane>
|
||||
<!-- 手机号登录 tab(暂时隐藏,恢复时取消注释下方三行)
|
||||
<el-tab-pane label="手机号登录" name="mobile">
|
||||
<Mobile />
|
||||
</el-tab-pane>
|
||||
-->
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 扫码登录 -->
|
||||
<!-- 扫码登录(暂时隐藏,恢复时取消注释下方四行)
|
||||
<div v-else class="scan-container">
|
||||
<Scan />
|
||||
</div>
|
||||
-->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,8 +73,9 @@ import logoMini from '/@/assets/logo-mini.svg';
|
||||
import loginIconTwo from '/@/assets/login-icon-two.svg';
|
||||
import { NextLoading } from '/@/utils/loading';
|
||||
import Account from './component/account.vue';
|
||||
import Mobile from './component/mobile.vue';
|
||||
import Scan from './component/scan.vue';
|
||||
// 手机号登录 / 扫码登录暂时隐藏,恢复时取消注释下方两行
|
||||
// import Mobile from './component/mobile.vue';
|
||||
// import Scan from './component/scan.vue';
|
||||
|
||||
// 定义接口来定义对象的类型
|
||||
interface LoginState {
|
||||
@@ -81,7 +85,11 @@ interface LoginState {
|
||||
|
||||
export default defineComponent({
|
||||
name: 'loginIndex',
|
||||
components: { Account, Mobile, Scan },
|
||||
components: {
|
||||
Account,
|
||||
// Mobile, // 暂时隐藏,恢复时取消注释
|
||||
// Scan, // 暂时隐藏,恢复时取消注释
|
||||
},
|
||||
setup() {
|
||||
const storesThemeConfig = useThemeConfig();
|
||||
const { themeConfig } = storeToRefs(storesThemeConfig);
|
||||
|
||||
@@ -1,221 +1,519 @@
|
||||
<template>
|
||||
<template>
|
||||
<div class="cid-apis-container">
|
||||
<el-card shadow="hover">
|
||||
<div class="system-user-search mb15">
|
||||
<el-form :model="tableData.param" ref="queryRef" :inline="true" label-width="68px">
|
||||
<el-form-item label="关键字" prop="keyword">
|
||||
<el-input
|
||||
v-model="tableData.param.keyword"
|
||||
placeholder="请输入接口名称"
|
||||
clearable
|
||||
size="default"
|
||||
style="width: 240px"
|
||||
@keyup.enter.native="getList"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button size="default" type="primary" @click="getList">
|
||||
<el-icon><ele-Search /></el-icon>
|
||||
查询
|
||||
</el-button>
|
||||
<el-button size="default" @click="resetQuery(queryRef)">
|
||||
<el-icon><ele-Refresh /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
<el-button size="default" type="success" @click="onOpenAdd">
|
||||
<el-icon><ele-FolderAdd /></el-icon>
|
||||
新增接口
|
||||
</el-button>
|
||||
<el-button size="default" type="danger" @click="onRowDel(null)">
|
||||
<el-icon><ele-Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-table :data="tableData.data" style="width: 100%" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<template #header>
|
||||
<span style="font-weight: 600">平台管理</span>
|
||||
</template>
|
||||
<el-form ref="pqf" :model="pq" :inline="true" label-width="68px" class="mb15">
|
||||
<el-form-item label="关键字" prop="keyword">
|
||||
<el-input v-model="pq.keyword" placeholder="请输入平台名称/编码" clearable style="width: 240px" @keyup.enter.native="searchPlatform" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="pq.status" placeholder="平台状态" clearable style="width: 160px">
|
||||
<el-option label="启用" value="ACTIVE" />
|
||||
<el-option label="禁用" value="INACTIVE" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="searchPlatform"><el-icon><ele-Search /></el-icon>查询</el-button>
|
||||
<el-button @click="resetPlatformQuery"><el-icon><ele-Refresh /></el-icon>重置</el-button>
|
||||
<el-button type="success" @click="openPlatform()"><el-icon><ele-FolderAdd /></el-icon>新增平台</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-table :data="platformRows" row-key="id" v-loading="platformLoading">
|
||||
<el-table-column type="index" label="序号" width="60" />
|
||||
<el-table-column prop="name" label="接口名称" show-overflow-tooltip />
|
||||
<el-table-column prop="path" label="接口路径" show-overflow-tooltip />
|
||||
<el-table-column prop="method" label="请求方式" width="100" align="center" />
|
||||
<el-table-column prop="datasourceName" label="所属平台" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ scope.row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
<el-table-column prop="platformCode" label="平台编码" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="platformName" label="平台名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="apiBaseUrl" label="API地址" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="authType" label="认证" width="100" align="center" />
|
||||
<el-table-column prop="status" label="状态" width="90" align="center">
|
||||
<template #default="s">
|
||||
<el-tag size="small" :type="s.row.status === 'ACTIVE' ? 'success' : 'danger'">{{ s.row.status === 'ACTIVE' ? '启用' : '禁用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="scope">
|
||||
<el-button size="small" text type="primary" @click="onOpenEdit(scope.row)">修改</el-button>
|
||||
<el-button size="small" text type="danger" @click="onRowDel(scope.row)">删除</el-button>
|
||||
<el-table-column prop="rateLimitPerMinute" label="限流/分钟" width="110" align="center" />
|
||||
<el-table-column prop="requestTimeoutMs" label="超时(ms)" width="110" align="center" />
|
||||
<el-table-column label="创建时间" width="170">
|
||||
<template #default="s">{{ ft(s.row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="s">
|
||||
<el-button size="small" text type="primary" @click="manageApis(s.row)">接口管理</el-button>
|
||||
<el-button size="small" text type="primary" @click="openPlatform(s.row)">修改</el-button>
|
||||
<el-button size="small" text type="danger" @click="delPlatform(s.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<pagination
|
||||
v-show="tableData.total > 0"
|
||||
:total="tableData.total"
|
||||
v-model:page="tableData.param.pageNum"
|
||||
v-model:limit="tableData.param.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
<pagination :total="platformTotal" v-model:page="pq.pageNum" v-model:limit="pq.pageSize" @pagination="getPlatforms" />
|
||||
</el-card>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<el-dialog :title="dialog.title" v-model="dialog.visible" width="500px" :close-on-click-modal="false">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item label="接口名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入接口名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="接口路径" prop="path">
|
||||
<el-input v-model="form.path" placeholder="请输入接口路径" />
|
||||
</el-form-item>
|
||||
<el-form-item label="请求方式" prop="method">
|
||||
<el-select v-model="form.method" style="width: 100%">
|
||||
<el-option label="GET" value="GET" />
|
||||
<el-option label="POST" value="POST" />
|
||||
<el-option label="PUT" value="PUT" />
|
||||
<el-option label="DELETE" value="DELETE" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属平台" prop="datasourceId">
|
||||
<el-input v-model="form.datasourceId" placeholder="请输入所属平台ID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入描述" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="form.status" style="width: 100%">
|
||||
<el-option label="启用" :value="1" />
|
||||
<el-option label="禁用" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialog.visible = false">取消</el-button>
|
||||
<el-button type="primary" @click="onSubmit" :loading="dialog.saving">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="apiListDialog.visible" :title="apiListDialog.title" width="1100px" :close-on-click-modal="false" destroy-on-close @closed="handleApiListClose"
|
||||
><el-form ref="aqf" :model="aq" :inline="true" label-width="68px" class="mb15"
|
||||
><el-form-item label="关键字" prop="keyword"
|
||||
><el-input v-model="aq.keyword" placeholder="请输入接口名称/编码" clearable style="width: 220px" @keyup.enter.native="searchApi" /></el-form-item
|
||||
><el-form-item label="状态" prop="status"
|
||||
><el-select v-model="aq.status" placeholder="状态" clearable style="width: 130px"
|
||||
><el-option label="启用" value="active" /><el-option label="禁用" value="inactive" /></el-select></el-form-item
|
||||
><el-form-item label="方法" prop="method"
|
||||
><el-select v-model="aq.method" placeholder="请求方式" clearable style="width: 130px"
|
||||
><el-option v-for="m in methods" :key="m" :label="m" :value="m" /></el-select></el-form-item
|
||||
><el-form-item
|
||||
><el-button type="primary" @click="searchApi"><el-icon><ele-Search /></el-icon>查询</el-button
|
||||
><el-button @click="resetApiQuery"><el-icon><ele-Refresh /></el-icon>重置</el-button
|
||||
><el-button type="success" @click="openApiForCurrentPlatform()"><el-icon><ele-FolderAdd /></el-icon>新增接口</el-button></el-form-item></el-form
|
||||
><el-table :data="apiRows" v-loading="apiLoading" max-height="420"
|
||||
><el-table-column type="index" label="序号" width="60" /><el-table-column prop="code" label="接口编码" min-width="140" show-overflow-tooltip
|
||||
/><el-table-column prop="name" label="接口名称" min-width="150" show-overflow-tooltip /><el-table-column prop="url" label="URL" min-width="220"
|
||||
show-overflow-tooltip /><el-table-column prop="method" label="请求方式" width="100" align="center"
|
||||
><template #default="s"
|
||||
><el-tag size="small" :type="s.row.method === 'GET' ? 'success' : 'primary'">{{ s.row.method }}</el-tag></template></el-table-column
|
||||
><el-table-column prop="status" label="状态" width="90" align="center"
|
||||
><template #default="s"
|
||||
><el-tag size="small" :type="ns(s.row.status) === 'active' ? 'success' : 'danger'">{{ ns(s.row.status) === 'active' ? '启用' : '禁用' }}</el-tag></template></el-table-column
|
||||
><el-table-column prop="platformName" label="所属平台" min-width="140" show-overflow-tooltip /><el-table-column label="创建时间" width="170"
|
||||
><template #default="s">{{ ft(s.row.createdAt) }}</template></el-table-column
|
||||
><el-table-column label="操作" width="160" fixed="right"
|
||||
><template #default="s"
|
||||
><el-button size="small" text type="primary" @click="openApi(String(s.row.id))">修改</el-button
|
||||
><el-button size="small" text type="danger" @click="delApi(s.row)">删除</el-button></template></el-table-column></el-table
|
||||
><template #footer
|
||||
><div style="display: flex; justify-content: space-between; align-items: center; width: 100%;"
|
||||
><pagination :total="apiTotal" v-model:page="aq.pageNum" v-model:limit="aq.pageSize" @pagination="getApis" />
|
||||
<el-button type="primary" @click="apiListDialog.visible = false">关闭</el-button></div></template
|
||||
></el-dialog>
|
||||
<el-dialog v-model="platformDialog.visible" :title="platformDialog.title" width="720px" :close-on-click-modal="false" destroy-on-close
|
||||
><el-form ref="platformFormRef" :model="platformForm" :rules="platformRules" label-width="120px" v-loading="platformDialog.loading"
|
||||
><el-row :gutter="16"
|
||||
><el-col :span="12"
|
||||
><el-form-item label="平台编码" prop="platformCode"><el-input v-model="platformForm.platformCode" /></el-form-item></el-col
|
||||
><el-col :span="12"
|
||||
><el-form-item label="平台名称" prop="platformName"><el-input v-model="platformForm.platformName" /></el-form-item></el-col></el-row
|
||||
><el-form-item label="平台描述"><el-input v-model="platformForm.description" type="textarea" :rows="3" /></el-form-item
|
||||
><el-form-item label="API地址"><el-input v-model="platformForm.apiBaseUrl" /></el-form-item
|
||||
><el-row :gutter="16"
|
||||
><el-col :span="12"
|
||||
><el-form-item label="认证类型" prop="authType"
|
||||
><el-select v-model="platformForm.authType" style="width: 100%"
|
||||
><el-option label="Token" value="TOKEN" /><el-option label="API Key" value="API_KEY" /><el-option
|
||||
label="OAuth2"
|
||||
value="OAUTH2" /><el-option label="Basic" value="BASIC" /></el-select></el-form-item></el-col
|
||||
><el-col :span="12"
|
||||
><el-form-item label="状态" prop="status"
|
||||
><el-select v-model="platformForm.status" style="width: 100%"
|
||||
><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="INACTIVE" /></el-select></el-form-item></el-col></el-row
|
||||
><el-form-item label="Token/密钥"><el-input v-model="platformForm.token" show-password /></el-form-item
|
||||
><el-form-item label="API Key"><el-input v-model="platformForm.apiKey" show-password /></el-form-item
|
||||
><el-row :gutter="16"
|
||||
><el-col :span="12"
|
||||
><el-form-item label="Client ID"><el-input v-model="platformForm.clientId" /></el-form-item></el-col
|
||||
><el-col :span="12"
|
||||
><el-form-item label="Client Secret"><el-input v-model="platformForm.clientSecret" show-password /></el-form-item></el-col></el-row
|
||||
><el-form-item label="认证配置 JSON" prop="authConfigText"
|
||||
><el-input v-model="platformForm.authConfigText" type="textarea" :rows="6" /></el-form-item
|
||||
><el-row :gutter="16"
|
||||
><el-col :span="12"
|
||||
><el-form-item label="限流/分钟"
|
||||
><el-input-number v-model="platformForm.rateLimitPerMinute" :min="0" style="width: 100%" /></el-form-item></el-col
|
||||
><el-col :span="12"
|
||||
><el-form-item label="限流/小时"
|
||||
><el-input-number v-model="platformForm.rateLimitPerHour" :min="0" style="width: 100%" /></el-form-item></el-col></el-row
|
||||
><el-row :gutter="16"
|
||||
><el-col :span="12"
|
||||
><el-form-item label="并发限制"
|
||||
><el-input-number v-model="platformForm.concurrencyLimit" :min="0" style="width: 100%" /></el-form-item></el-col
|
||||
><el-col :span="12"
|
||||
><el-form-item label="超时(ms)"
|
||||
><el-input-number v-model="platformForm.requestTimeoutMs" :min="0" style="width: 100%" /></el-form-item></el-col></el-row
|
||||
><el-row :gutter="16"
|
||||
><el-col :span="12"
|
||||
><el-form-item label="最大重试"><el-input-number v-model="platformForm.maxRetries" :min="0" style="width: 100%" /></el-form-item></el-col
|
||||
><el-col :span="12"
|
||||
><el-form-item label="重试延迟(ms)"
|
||||
><el-input-number v-model="platformForm.retryDelayMs" :min="0" style="width: 100%" /></el-form-item></el-col></el-row></el-form
|
||||
><template #footer
|
||||
><el-button @click="platformDialog.visible = false">取消</el-button
|
||||
><el-button type="primary" :loading="platformDialog.saving" @click="savePlatform">确定</el-button></template
|
||||
></el-dialog
|
||||
><el-dialog v-model="apiDialog.visible" :title="apiDialog.title" width="780px" :close-on-click-modal="false" destroy-on-close
|
||||
><el-form ref="apiFormRef" :model="apiForm" :rules="apiRules" label-width="110px" v-loading="apiDialog.loading"
|
||||
><el-form-item label="所属平台" prop="platformId"
|
||||
><el-select v-model="apiForm.platformId" style="width: 100%" filterable
|
||||
><el-option v-for="i in platforms" :key="i.id" :label="i.platformName" :value="String(i.id)" /></el-select></el-form-item
|
||||
><el-row :gutter="16"
|
||||
><el-col :span="12"
|
||||
><el-form-item label="接口编码" prop="code"><el-input v-model="apiForm.code" /></el-form-item></el-col
|
||||
><el-col :span="12"
|
||||
><el-form-item label="接口名称" prop="name"><el-input v-model="apiForm.name" /></el-form-item></el-col></el-row
|
||||
><el-row :gutter="16"
|
||||
><el-col :span="12"
|
||||
><el-form-item label="URL" prop="url"><el-input v-model="apiForm.url" /></el-form-item></el-col
|
||||
><el-col :span="12"
|
||||
><el-form-item label="请求方式" prop="method"
|
||||
><el-select v-model="apiForm.method" style="width: 100%"
|
||||
><el-option v-for="m in methods" :key="m" :label="m" :value="m" /></el-select></el-form-item></el-col></el-row
|
||||
><el-row :gutter="16"
|
||||
><el-col :span="12"
|
||||
><el-form-item label="状态" prop="status"
|
||||
><el-select v-model="apiForm.status" style="width: 100%"
|
||||
><el-option label="启用" value="active" /><el-option label="禁用" value="inactive" /></el-select></el-form-item></el-col
|
||||
><el-col :span="12"
|
||||
><el-form-item label="认证类型"
|
||||
><el-select v-model="apiForm.authType" style="width: 100%" clearable
|
||||
><el-option label="Token" value="TOKEN" /><el-option label="API Key" value="API_KEY" /><el-option
|
||||
label="OAuth2"
|
||||
value="OAUTH2" /><el-option label="APP_SIGNATURE" value="APP_SIGNATURE" /></el-select></el-form-item></el-col></el-row
|
||||
><el-form-item label="请求配置 JSON" prop="requestConfigText"
|
||||
><el-input v-model="apiForm.requestConfigText" type="textarea" :rows="6" /></el-form-item
|
||||
><el-form-item label="响应配置 JSON" prop="responseConfigText"
|
||||
><el-input v-model="apiForm.responseConfigText" type="textarea" :rows="5" /></el-form-item
|
||||
><el-form-item label="限流配置 JSON" prop="limitConfigText"
|
||||
><el-input v-model="apiForm.limitConfigText" type="textarea" :rows="4" /></el-form-item
|
||||
><el-form-item label="表结构定义 JSON" prop="tableDefinitionText"
|
||||
><el-input v-model="apiForm.tableDefinitionText" type="textarea" :rows="6" /></el-form-item></el-form
|
||||
><template #footer
|
||||
><el-button @click="apiDialog.visible = false">取消</el-button
|
||||
><el-button type="primary" :loading="apiDialog.saving" @click="saveApi">确定</el-button></template
|
||||
></el-dialog
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'cidApis' };
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox, FormInstance } from 'element-plus';
|
||||
|
||||
const queryRef = ref<FormInstance>();
|
||||
const formRef = ref<FormInstance>();
|
||||
const ids = ref<string[]>([]);
|
||||
|
||||
const tableData = reactive({
|
||||
data: [] as any[],
|
||||
total: 0,
|
||||
param: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
},
|
||||
});
|
||||
|
||||
const dialog = reactive({
|
||||
visible: false,
|
||||
title: '',
|
||||
saving: false,
|
||||
});
|
||||
|
||||
const form = reactive({
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { getApiErrorMessage } from '/@/utils/request';
|
||||
import {
|
||||
createDatasourcePlatform,
|
||||
deleteDatasourcePlatform,
|
||||
getDatasourcePlatform,
|
||||
listDatasourcePlatforms,
|
||||
updateDatasourcePlatform,
|
||||
} from '/@/api/cid/datasource';
|
||||
import {
|
||||
createApiInterface,
|
||||
deleteApiInterface,
|
||||
getApiInterface,
|
||||
listApiInterfaces,
|
||||
updateApiInterface,
|
||||
type ApiInterfaceInfo,
|
||||
} from '/@/api/cid/apis';
|
||||
defineOptions({ name: 'cidApis' });
|
||||
const currentPlatform = ref<any>(null),
|
||||
methods = ['GET', 'POST', 'PUT', 'DELETE'],
|
||||
platforms = ref<any[]>([]),
|
||||
platformRows = ref<any[]>([]),
|
||||
apiRows = ref<ApiInterfaceInfo[]>([]),
|
||||
platformTotal = ref(0),
|
||||
apiTotal = ref(0),
|
||||
platformLoading = ref(false),
|
||||
apiLoading = ref(false);
|
||||
const pqf = ref<FormInstance>(),
|
||||
aqf = ref<FormInstance>(),
|
||||
platformFormRef = ref<FormInstance>(),
|
||||
apiFormRef = ref<FormInstance>();
|
||||
const pq = reactive({ pageNum: 1, pageSize: 10, keyword: '', status: '' }),
|
||||
aq = reactive({ pageNum: 1, pageSize: 10, keyword: '', platformId: '', status: '', method: '' });
|
||||
const platformDialog = reactive({ visible: false, title: '', loading: false, saving: false }),
|
||||
apiDialog = reactive({ visible: false, title: '', loading: false, saving: false }),
|
||||
apiListDialog = reactive({ visible: false, title: '' });
|
||||
const dpf = () => ({
|
||||
id: '',
|
||||
name: '',
|
||||
path: '',
|
||||
method: 'GET',
|
||||
datasourceId: '',
|
||||
platformCode: '',
|
||||
platformName: '',
|
||||
description: '',
|
||||
status: 1,
|
||||
apiBaseUrl: '',
|
||||
authType: 'TOKEN',
|
||||
status: 'ACTIVE',
|
||||
token: '',
|
||||
apiKey: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
authConfigText: '{}',
|
||||
rateLimitPerMinute: 60,
|
||||
rateLimitPerHour: 1000,
|
||||
concurrencyLimit: 10,
|
||||
requestTimeoutMs: 30000,
|
||||
maxRetries: 3,
|
||||
retryDelayMs: 1000,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入接口名称', trigger: 'blur' }],
|
||||
path: [{ required: true, message: '请输入接口路径', trigger: 'blur' }],
|
||||
method: [{ required: true, message: '请选择请求方式', trigger: 'change' }],
|
||||
};
|
||||
|
||||
const getList = () => {
|
||||
// TODO: 调用列表接口
|
||||
};
|
||||
|
||||
const resetQuery = (formEl: FormInstance | undefined) => {
|
||||
if (!formEl) return;
|
||||
formEl.resetFields();
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleSelectionChange = (selection: any[]) => {
|
||||
ids.value = selection.map((item) => item.id);
|
||||
};
|
||||
|
||||
const onOpenAdd = () => {
|
||||
dialog.title = '新增接口';
|
||||
dialog.visible = true;
|
||||
form.id = '';
|
||||
form.name = '';
|
||||
form.path = '';
|
||||
form.method = 'GET';
|
||||
form.datasourceId = '';
|
||||
form.description = '';
|
||||
form.status = 1;
|
||||
};
|
||||
|
||||
const onOpenEdit = (row: any) => {
|
||||
dialog.title = '修改接口';
|
||||
dialog.visible = true;
|
||||
form.id = row.id;
|
||||
form.name = row.name;
|
||||
form.path = row.path;
|
||||
form.method = row.method;
|
||||
form.datasourceId = row.datasourceId;
|
||||
form.description = row.description;
|
||||
form.status = row.status;
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
if (!formRef.value) return;
|
||||
formRef.value.validate((valid) => {
|
||||
if (!valid) return;
|
||||
dialog.saving = true;
|
||||
// TODO: 调用新增/编辑接口
|
||||
dialog.saving = false;
|
||||
dialog.visible = false;
|
||||
getList();
|
||||
});
|
||||
};
|
||||
|
||||
const onRowDel = (row: any) => {
|
||||
const delIds = row ? [row.id] : ids.value;
|
||||
if (delIds.length === 0) {
|
||||
ElMessage.error('请选择要删除的数据');
|
||||
return;
|
||||
const daf = () => ({
|
||||
id: '',
|
||||
platformId: '',
|
||||
code: '',
|
||||
name: '',
|
||||
url: '',
|
||||
method: 'GET',
|
||||
status: 'active',
|
||||
authType: '',
|
||||
requestConfigText: '{}',
|
||||
responseConfigText: '{}',
|
||||
limitConfigText: '{}',
|
||||
tableDefinitionText: '{}',
|
||||
});
|
||||
const platformForm = reactive(dpf()),
|
||||
apiForm = reactive(daf());
|
||||
const jv = (v: string, cb: (e?: Error) => void, l: string) => {
|
||||
if (!v) return cb();
|
||||
try {
|
||||
JSON.parse(v);
|
||||
cb();
|
||||
} catch {
|
||||
cb(new Error(`${l} 格式错误`));
|
||||
}
|
||||
ElMessageBox.confirm(`确定要删除选中的接口吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(() => {
|
||||
// TODO: 调用删除接口
|
||||
ElMessage.success('删除成功');
|
||||
getList();
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
const platformRules: FormRules = {
|
||||
platformCode: [{ required: true, message: '请输入平台编码', trigger: 'blur' }],
|
||||
platformName: [{ required: true, message: '请输入平台名称', trigger: 'blur' }],
|
||||
authType: [{ required: true, message: '请选择认证类型', trigger: 'change' }],
|
||||
authConfigText: [{ validator: (_r, v, cb) => jv(v, cb, '认证配置 JSON'), trigger: 'blur' }],
|
||||
};
|
||||
const apiRules: FormRules = {
|
||||
platformId: [{ required: true, message: '请选择所属平台', trigger: 'change' }],
|
||||
code: [{ required: true, message: '请输入接口编码', trigger: 'blur' }],
|
||||
name: [{ required: true, message: '请输入接口名称', trigger: 'blur' }],
|
||||
url: [{ required: true, message: '请输入接口 URL', trigger: 'blur' }],
|
||||
method: [{ required: true, message: '请选择请求方式', trigger: 'change' }],
|
||||
requestConfigText: [{ validator: (_r, v, cb) => jv(v, cb, '请求配置 JSON'), trigger: 'blur' }],
|
||||
responseConfigText: [{ validator: (_r, v, cb) => jv(v, cb, '响应配置 JSON'), trigger: 'blur' }],
|
||||
limitConfigText: [{ validator: (_r, v, cb) => jv(v, cb, '接口独立限流配置 JSON'), trigger: 'blur' }],
|
||||
tableDefinitionText: [{ validator: (_r, v, cb) => jv(v, cb, '表结构定义 JSON'), trigger: 'blur' }],
|
||||
};
|
||||
const ft = (t?: number) => (!t ? '' : new Date(String(t).length === 10 ? t * 1000 : t).toLocaleString('zh-CN', { hour12: false })),
|
||||
ns = (s?: string) => (String(s || '').toLowerCase() === 'active' ? 'active' : 'inactive');
|
||||
const loadPlatforms = async () => {
|
||||
const r = await listDatasourcePlatforms({ pageNum: 1, pageSize: 100 });
|
||||
platforms.value = r.data?.list || [];
|
||||
};
|
||||
const getPlatforms = async () => {
|
||||
platformLoading.value = true;
|
||||
try {
|
||||
const r = await listDatasourcePlatforms({
|
||||
pageNum: pq.pageNum,
|
||||
pageSize: pq.pageSize,
|
||||
...(pq.keyword ? { keyword: pq.keyword } : {}),
|
||||
...(pq.status ? { status: pq.status } : {}),
|
||||
});
|
||||
platformRows.value = r.data?.list || [];
|
||||
platformTotal.value = r.data?.total || 0;
|
||||
} catch (e) {
|
||||
ElMessage.error(getApiErrorMessage(e) || '获取平台列表失败');
|
||||
} finally {
|
||||
platformLoading.value = false;
|
||||
}
|
||||
};
|
||||
const getApis = async () => {
|
||||
apiLoading.value = true;
|
||||
try {
|
||||
const r = await listApiInterfaces({
|
||||
pageNum: aq.pageNum,
|
||||
pageSize: aq.pageSize,
|
||||
...(aq.keyword ? { keyword: aq.keyword } : {}),
|
||||
...(aq.platformId ? { platformId: aq.platformId } : {}),
|
||||
...(aq.status ? { status: aq.status } : {}),
|
||||
...(aq.method ? { method: aq.method } : {}),
|
||||
});
|
||||
apiRows.value = r.data?.list || [];
|
||||
apiTotal.value = r.data?.total || 0;
|
||||
} catch (e) {
|
||||
ElMessage.error(getApiErrorMessage(e) || '获取接口列表失败');
|
||||
} finally {
|
||||
apiLoading.value = false;
|
||||
}
|
||||
};
|
||||
const searchPlatform = () => {
|
||||
pq.pageNum = 1;
|
||||
getPlatforms();
|
||||
};
|
||||
const searchApi = () => {
|
||||
aq.pageNum = 1;
|
||||
getApis();
|
||||
};
|
||||
const resetPlatformQuery = () => {
|
||||
pqf.value?.resetFields();
|
||||
searchPlatform();
|
||||
};
|
||||
const resetApiQuery: any = () => {
|
||||
aqf.value?.resetFields();
|
||||
searchApi();
|
||||
};
|
||||
const manageApis = (row: any) => {
|
||||
currentPlatform.value = row;
|
||||
aq.platformId = String(row.id);
|
||||
aq.pageNum = 1;
|
||||
aq.keyword = '';
|
||||
aq.status = '';
|
||||
aq.method = '';
|
||||
apiListDialog.title = `${row.platformName} - 接口管理`;
|
||||
apiListDialog.visible = true;
|
||||
getApis();
|
||||
};
|
||||
const handleApiListClose = () => {
|
||||
currentPlatform.value = null;
|
||||
};
|
||||
const openApiForCurrentPlatform = () => {
|
||||
openApi('', currentPlatform.value?.id || '');
|
||||
};
|
||||
const openPlatform = async (row?: any) => {
|
||||
Object.assign(platformForm, dpf());
|
||||
platformFormRef.value?.clearValidate();
|
||||
platformDialog.title = row ? '编辑平台' : '新建平台';
|
||||
platformDialog.visible = true;
|
||||
if (!row) return;
|
||||
platformDialog.loading = true;
|
||||
try {
|
||||
const r = await getDatasourcePlatform(row.id);
|
||||
const d = r.data || {};
|
||||
Object.assign(platformForm, {
|
||||
id: String(d.id || row.id),
|
||||
platformCode: d.platformCode || '',
|
||||
platformName: d.platformName || '',
|
||||
description: d.description || '',
|
||||
apiBaseUrl: d.apiBaseUrl || '',
|
||||
authType: d.authType || 'TOKEN',
|
||||
status: d.status || 'ACTIVE',
|
||||
token: d.token || '',
|
||||
apiKey: d.apiKey || '',
|
||||
clientId: d.clientId || '',
|
||||
clientSecret: d.clientSecret || '',
|
||||
authConfigText: JSON.stringify(d.authConfig ?? {}, null, 2),
|
||||
rateLimitPerMinute: d.rateLimitPerMinute ?? 60,
|
||||
rateLimitPerHour: d.rateLimitPerHour ?? 1000,
|
||||
concurrencyLimit: d.concurrencyLimit ?? 10,
|
||||
requestTimeoutMs: d.requestTimeoutMs ?? 30000,
|
||||
maxRetries: d.maxRetries ?? 3,
|
||||
retryDelayMs: d.retryDelayMs ?? 1000,
|
||||
});
|
||||
} catch (e) {
|
||||
platformDialog.visible = false;
|
||||
ElMessage.error(getApiErrorMessage(e) || '获取平台详情失败');
|
||||
} finally {
|
||||
platformDialog.loading = false;
|
||||
}
|
||||
};
|
||||
const savePlatform = async () => {
|
||||
if (!platformFormRef.value) return;
|
||||
const ok = await platformFormRef.value.validate().catch(() => false);
|
||||
if (!ok) return;
|
||||
platformDialog.saving = true;
|
||||
try {
|
||||
const p = {
|
||||
platformCode: platformForm.platformCode,
|
||||
platformName: platformForm.platformName,
|
||||
description: platformForm.description,
|
||||
apiBaseUrl: platformForm.apiBaseUrl,
|
||||
authType: platformForm.authType,
|
||||
status: platformForm.status,
|
||||
token: platformForm.token,
|
||||
apiKey: platformForm.apiKey,
|
||||
clientId: platformForm.clientId,
|
||||
clientSecret: platformForm.clientSecret,
|
||||
authConfig: JSON.parse(platformForm.authConfigText || '{}'),
|
||||
rateLimitPerMinute: platformForm.rateLimitPerMinute || 60,
|
||||
rateLimitPerHour: platformForm.rateLimitPerHour || 1000,
|
||||
concurrencyLimit: platformForm.concurrencyLimit || 10,
|
||||
requestTimeoutMs: platformForm.requestTimeoutMs || 30000,
|
||||
maxRetries: platformForm.maxRetries || 3,
|
||||
retryDelayMs: platformForm.retryDelayMs || 1000,
|
||||
};
|
||||
platformForm.id ? await updateDatasourcePlatform({ id: platformForm.id, ...p }) : await createDatasourcePlatform(p);
|
||||
ElMessage.success(platformForm.id ? '更新成功' : '创建成功');
|
||||
platformDialog.visible = false;
|
||||
await Promise.all([getPlatforms(), loadPlatforms()]);
|
||||
} catch (e) {
|
||||
ElMessage.error(getApiErrorMessage(e) || (platformForm.id ? '更新失败' : '创建失败'));
|
||||
} finally {
|
||||
platformDialog.saving = false;
|
||||
}
|
||||
};
|
||||
const delPlatform = (row: any) => {
|
||||
const ids = [row.id];
|
||||
ElMessageBox.confirm(`确定删除平台「${row.platformName}」吗?`, '提示', { type: 'warning' })
|
||||
.then(async () => {
|
||||
try {
|
||||
for (const id of ids) await deleteDatasourcePlatform(id);
|
||||
ElMessage.success('删除成功');
|
||||
await Promise.all([getPlatforms(), loadPlatforms()]);
|
||||
} catch (e) {
|
||||
ElMessage.error(getApiErrorMessage(e) || '删除失败');
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
const openApi = async (id = '', platformId = '') => {
|
||||
Object.assign(apiForm, daf(), platformId ? { platformId } : {});
|
||||
apiFormRef.value?.clearValidate();
|
||||
if (!platforms.value.length) await loadPlatforms();
|
||||
apiDialog.title = id ? '编辑接口' : '新建接口';
|
||||
apiDialog.visible = true;
|
||||
if (!id) return;
|
||||
apiDialog.loading = true;
|
||||
try {
|
||||
const r = await getApiInterface(id);
|
||||
const d = r.data || {};
|
||||
Object.assign(apiForm, {
|
||||
id: String(d.id || id),
|
||||
platformId: String(d.platformId || platformId || ''),
|
||||
code: d.code || '',
|
||||
name: d.name || '',
|
||||
url: d.url || '',
|
||||
method: d.method || 'GET',
|
||||
status: ns(d.status),
|
||||
authType: d.authType || '',
|
||||
requestConfigText: JSON.stringify(d.requestConfig ?? {}, null, 2),
|
||||
responseConfigText: JSON.stringify(d.responseConfig ?? {}, null, 2),
|
||||
limitConfigText: JSON.stringify(d.limitConfig ?? {}, null, 2),
|
||||
tableDefinitionText: JSON.stringify(d.tableDefinition ?? {}, null, 2),
|
||||
});
|
||||
} catch (e) {
|
||||
apiDialog.visible = false;
|
||||
ElMessage.error(getApiErrorMessage(e) || '获取接口详情失败');
|
||||
} finally {
|
||||
apiDialog.loading = false;
|
||||
}
|
||||
};
|
||||
const saveApi = async () => {
|
||||
if (!apiFormRef.value) return;
|
||||
const ok = await apiFormRef.value.validate().catch(() => false);
|
||||
if (!ok) return;
|
||||
apiDialog.saving = true;
|
||||
try {
|
||||
const p = {
|
||||
platformId: Number(apiForm.platformId),
|
||||
code: apiForm.code,
|
||||
name: apiForm.name,
|
||||
url: apiForm.url,
|
||||
method: apiForm.method,
|
||||
status: apiForm.status,
|
||||
authType: apiForm.authType || undefined,
|
||||
requestConfig: JSON.parse(apiForm.requestConfigText || '{}'),
|
||||
responseConfig: JSON.parse(apiForm.responseConfigText || '{}'),
|
||||
limitConfig: JSON.parse(apiForm.limitConfigText || '{}'),
|
||||
tableDefinition: JSON.parse(apiForm.tableDefinitionText || '{}'),
|
||||
};
|
||||
apiForm.id ? await updateApiInterface({ id: apiForm.id, ...p }) : await createApiInterface(p);
|
||||
ElMessage.success(apiForm.id ? '更新成功' : '创建成功');
|
||||
apiDialog.visible = false;
|
||||
await getApis();
|
||||
} catch (e) {
|
||||
ElMessage.error(getApiErrorMessage(e) || (apiForm.id ? '更新失败' : '创建失败'));
|
||||
} finally {
|
||||
apiDialog.saving = false;
|
||||
}
|
||||
};
|
||||
const delApi = (row: any) => {
|
||||
ElMessageBox.confirm(`确定删除接口「${row.name}」吗?`, '提示', { type: 'warning' })
|
||||
.then(async () => {
|
||||
try {
|
||||
await deleteApiInterface(String(row.id));
|
||||
ElMessage.success('删除成功');
|
||||
await getApis();
|
||||
} catch (e) {
|
||||
ElMessage.error(getApiErrorMessage(e) || '删除失败');
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
onMounted(async () => {
|
||||
await loadPlatforms();
|
||||
await getPlatforms();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
<template>
|
||||
<div class="report-query-container">
|
||||
<!-- 选择业务和报表 -->
|
||||
<el-card shadow="hover" class="mb15">
|
||||
<el-form :inline="true">
|
||||
<el-form-item label="业务">
|
||||
<el-select v-model="queryForm.businessCode" placeholder="请选择业务" style="width: 200px" @change="onBusinessChange">
|
||||
<el-option v-for="biz in businesses" :key="biz.businessCode" :label="biz.businessName" :value="biz.businessCode" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="报表">
|
||||
<el-select v-model="queryForm.reportCode" placeholder="请选择报表" style="width: 260px" @change="onReportChange" :loading="reportLoading">
|
||||
<el-option v-for="r in reports" :key="r.reportCode" :label="r.reportName" :value="r.reportCode" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 查询配置区 -->
|
||||
<template v-if="reportLoaded">
|
||||
<el-card shadow="hover" class="mb15">
|
||||
<template #header><span>查询配置</span></template>
|
||||
<el-form :model="queryForm" label-width="100px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="时间范围" required>
|
||||
<el-date-picker
|
||||
v-model="timeRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="时间粒度">
|
||||
<el-select v-model="queryForm.timeGroup" style="width: 100%">
|
||||
<el-option label="按天" value="day" />
|
||||
<el-option label="按周" value="week" />
|
||||
<el-option label="按月" value="month" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 维度 -->
|
||||
<el-form-item label="统计维度">
|
||||
<el-checkbox-group v-model="queryForm.dimensions" class="field-checkbox-group">
|
||||
<el-checkbox
|
||||
v-for="f in dimensionFields"
|
||||
:key="f.fieldCode"
|
||||
:label="f.fieldCode"
|
||||
>
|
||||
{{ f.fieldName || f.fieldCode }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<div v-if="dimensionFields.length === 0" class="field-empty-hint">该报表无可用的维度字段</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 指标 -->
|
||||
<el-form-item label="统计指标">
|
||||
<div class="indicator-list" v-if="indicatorFields.length > 0">
|
||||
<div v-for="(f, idx) in indicatorFields" :key="f.fieldCode" class="indicator-row">
|
||||
<el-checkbox
|
||||
v-model="indicatorChecked[f.fieldCode]"
|
||||
:label="f.fieldName || f.fieldCode"
|
||||
@change="(val: boolean) => onIndicatorCheck(f.fieldCode, val)"
|
||||
style="min-width: 120px"
|
||||
/>
|
||||
<template v-if="indicatorChecked[f.fieldCode]">
|
||||
<el-select
|
||||
v-model="indicatorAggs[f.fieldCode].aggregate"
|
||||
style="width: 130px; margin-left: 8px"
|
||||
placeholder="聚合方式"
|
||||
>
|
||||
<el-option v-for="agg in aggregateOptions" :key="agg.value" :label="agg.label" :value="agg.value" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="indicatorAggs[f.fieldCode].alias"
|
||||
style="width: 130px; margin-left: 8px"
|
||||
placeholder="显示别名"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="field-empty-hint">该报表无可用的指标字段</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 筛选条件 -->
|
||||
<el-form-item label="筛选条件">
|
||||
<div class="filter-list">
|
||||
<div v-for="(f, idx) in filters" :key="idx" class="filter-row">
|
||||
<el-select v-model="f.fieldCode" style="width: 150px" placeholder="字段" @change="onFilterFieldChange(idx)">
|
||||
<el-option v-for="qf in queryableFields" :key="qf.fieldCode" :label="qf.fieldName || qf.fieldCode" :value="qf.fieldCode" />
|
||||
</el-select>
|
||||
<el-select v-model="f.operator" style="width: 100px; margin-left: 6px" placeholder="运算符">
|
||||
<el-option v-for="op in filterOperators" :key="op.value" :label="op.label" :value="op.value" />
|
||||
</el-select>
|
||||
<el-input v-model="f.value" style="width: 160px; margin-left: 6px" placeholder="值" />
|
||||
<el-button type="danger" :icon="Delete" circle size="small" style="margin-left: 6px" @click="removeFilter(idx)" />
|
||||
</div>
|
||||
<el-button size="small" @click="addFilter" :disabled="queryableFields.length === 0">+ 添加筛选</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 排序 -->
|
||||
<el-form-item label="排序">
|
||||
<div class="order-list">
|
||||
<div v-for="(o, idx) in queryForm.orderBy" :key="idx" class="order-row">
|
||||
<el-select v-model="o.fieldCode" style="width: 150px" placeholder="排序字段">
|
||||
<el-option v-for="qf in sortableFields" :key="qf.fieldCode" :label="qf.fieldName || qf.fieldCode" :value="qf.fieldCode" />
|
||||
</el-select>
|
||||
<el-select v-model="o.direction" style="width: 100px; margin-left: 6px">
|
||||
<el-option label="降序" value="DESC" />
|
||||
<el-option label="升序" value="ASC" />
|
||||
</el-select>
|
||||
<el-button type="danger" :icon="Delete" circle size="small" style="margin-left: 6px" @click="removeOrder(idx)" />
|
||||
</div>
|
||||
<el-button size="small" @click="addOrder" :disabled="sortableFields.length === 0">+ 添加排序</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" size="default" @click="handleQuery" :loading="queryLoading">
|
||||
<el-icon><ele-Search /></el-icon> 查询
|
||||
</el-button>
|
||||
<el-button size="default" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 查询结果 -->
|
||||
<el-card shadow="hover" v-if="queryDone">
|
||||
<template #header>
|
||||
<div class="result-header">
|
||||
<span>查询结果(共 {{ queryResult.total }} 条)</span>
|
||||
<el-radio-group v-model="viewMode" size="small">
|
||||
<el-radio-button value="table">数据列表</el-radio-button>
|
||||
<el-radio-button value="chart">图表展示</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 数据列表 -->
|
||||
<template v-if="viewMode === 'table'">
|
||||
<el-table :data="queryResult.list" border stripe v-loading="queryLoading" style="width: 100%" empty-text="暂无数据">
|
||||
<el-table-column v-for="col in queryResult.columns" :key="col" :prop="col" :label="col" show-overflow-tooltip min-width="120" />
|
||||
</el-table>
|
||||
<pagination
|
||||
v-show="queryResult.total > 0"
|
||||
:total="queryResult.total"
|
||||
v-model:page="queryForm.page"
|
||||
v-model:limit="queryForm.pageSize"
|
||||
@pagination="handleQuery"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 图表展示 -->
|
||||
<template v-if="viewMode === 'chart'">
|
||||
<div class="chart-tip" v-if="!chartReady">
|
||||
<el-alert title="图表展示需要至少选择一个维度和一个指标" type="info" show-icon :closable="false" />
|
||||
</div>
|
||||
<div ref="chartRef" class="chart-container" v-loading="queryLoading"></div>
|
||||
</template>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'reportEngineQuery' };
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, nextTick } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Delete } from '@element-plus/icons-vue';
|
||||
import * as echarts from 'echarts';
|
||||
import {
|
||||
listBusinesses,
|
||||
listAllReports,
|
||||
getReport,
|
||||
reportQuery,
|
||||
} from '/@/api/report-engine/report';
|
||||
import type {
|
||||
FieldDefine,
|
||||
ReportFilter,
|
||||
ReportOrderBy,
|
||||
} from '/@/api/report-engine/report';
|
||||
|
||||
// ========== 常量 ==========
|
||||
|
||||
const AGGREGATE_OPTIONS = [
|
||||
{ value: 'SUM', label: 'SUM (求和)' },
|
||||
{ value: 'COUNT', label: 'COUNT (计数)' },
|
||||
{ value: 'AVG', label: 'AVG (平均)' },
|
||||
{ value: 'MAX', label: 'MAX (最大)' },
|
||||
{ value: 'MIN', label: 'MIN (最小)' },
|
||||
];
|
||||
|
||||
const FILTER_OPERATORS = [
|
||||
{ value: '=', label: '=' },
|
||||
{ value: '!=', label: '!=' },
|
||||
{ value: '>', label: '>' },
|
||||
{ value: '>=', label: '>=' },
|
||||
{ value: '<', label: '<' },
|
||||
{ value: '<=', label: '<=' },
|
||||
{ value: 'LIKE', label: 'LIKE' },
|
||||
{ value: 'IN', label: 'IN' },
|
||||
];
|
||||
|
||||
// ========== 状态 ==========
|
||||
|
||||
const businesses = ref<any[]>([]);
|
||||
const reports = ref<any[]>([]);
|
||||
const reportLoading = ref(false);
|
||||
const reportLoaded = ref(false);
|
||||
const queryLoading = ref(false);
|
||||
const queryDone = ref(false);
|
||||
const viewMode = ref<'table' | 'chart'>('table');
|
||||
const chartRef = ref<HTMLDivElement>();
|
||||
let chartInstance: echarts.ECharts | null = null;
|
||||
|
||||
/** 当前选中报表的所有字段 */
|
||||
const reportFields = ref<FieldDefine[]>([]);
|
||||
|
||||
// 维度字段(role === DIMENSION)
|
||||
const dimensionFields = computed(() =>
|
||||
reportFields.value.filter((f) => f.fieldRole === 'DIMENSION')
|
||||
);
|
||||
|
||||
// 指标字段(role === INDICATOR)
|
||||
const indicatorFields = computed(() =>
|
||||
reportFields.value.filter((f) => f.fieldRole === 'INDICATOR')
|
||||
);
|
||||
|
||||
// 可筛选字段
|
||||
const queryableFields = computed(() =>
|
||||
reportFields.value.filter((f) => f.isFilterable !== false || f.isQueryable)
|
||||
);
|
||||
|
||||
// 可排序字段
|
||||
const sortableFields = computed(() =>
|
||||
reportFields.value.filter((f) => f.isSortable !== false || f.fieldRole === 'DIMENSION')
|
||||
);
|
||||
|
||||
const aggregateOptions = AGGREGATE_OPTIONS;
|
||||
const filterOperators = FILTER_OPERATORS;
|
||||
|
||||
/** 指标选中状态 */
|
||||
const indicatorChecked = reactive<Record<string, boolean>>({});
|
||||
/** 指标聚合配置 */
|
||||
const indicatorAggs = reactive<Record<string, { aggregate: string; alias: string }>>({});
|
||||
|
||||
/** 筛选条件列表 */
|
||||
const filters = ref<ReportFilter[]>([]);
|
||||
|
||||
/** 时间范围绑定(内部用数组) */
|
||||
const timeRange = ref<string[]>([]);
|
||||
|
||||
/** 查询表单 */
|
||||
const queryForm = reactive({
|
||||
businessCode: '',
|
||||
reportCode: '',
|
||||
dimensions: [] as string[],
|
||||
timeGroup: 'day',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
orderBy: [] as ReportOrderBy[],
|
||||
});
|
||||
|
||||
/** 查询结果 */
|
||||
const queryResult = reactive({
|
||||
list: [] as Record<string, any>[],
|
||||
columns: [] as string[],
|
||||
total: 0,
|
||||
});
|
||||
|
||||
/** 图表数据是否可用 */
|
||||
const chartReady = computed(() => {
|
||||
return queryForm.dimensions.length > 0 && Object.values(indicatorChecked).some(Boolean);
|
||||
});
|
||||
|
||||
// ========== 初始化 ==========
|
||||
|
||||
const initBusinesses = async () => {
|
||||
try {
|
||||
const res = await listBusinesses();
|
||||
businesses.value = res.data?.list || res.data || [];
|
||||
} catch (_e) {
|
||||
ElMessage.error('获取业务列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
initBusinesses();
|
||||
|
||||
// ========== 业务/报表切换 ==========
|
||||
|
||||
const onBusinessChange = async (code: string) => {
|
||||
queryForm.reportCode = '';
|
||||
reportLoaded.value = false;
|
||||
queryDone.value = false;
|
||||
reports.value = [];
|
||||
if (!code) return;
|
||||
|
||||
reportLoading.value = true;
|
||||
try {
|
||||
const res = await listAllReports(code);
|
||||
const raw = res.data;
|
||||
reports.value = Array.isArray(raw) ? raw : raw?.list || raw?.records || [];
|
||||
} catch (_e) {
|
||||
reports.value = [];
|
||||
ElMessage.error('获取报表列表失败');
|
||||
} finally {
|
||||
reportLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onReportChange = async (code: string) => {
|
||||
reportLoaded.value = false;
|
||||
queryDone.value = false;
|
||||
if (!code) return;
|
||||
|
||||
// 从报表列表中找到该报表的 id
|
||||
const row = reports.value.find((r) => r.reportCode === code);
|
||||
if (!row?.id) {
|
||||
ElMessage.warning('未找到报表信息');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getReport(row.id);
|
||||
const raw = res.data || {};
|
||||
const detail = raw.report || raw.data || raw;
|
||||
reportFields.value = detail.fields || [];
|
||||
|
||||
// 重置查询配置
|
||||
resetQueryState();
|
||||
|
||||
// 默认选中所有维度
|
||||
queryForm.dimensions = dimensionFields.value.map((f) => f.fieldCode);
|
||||
|
||||
// 默认选中所有指标
|
||||
indicatorFields.value.forEach((f) => {
|
||||
indicatorChecked[f.fieldCode] = true;
|
||||
indicatorAggs[f.fieldCode] = {
|
||||
aggregate: f.defaultAggregate || 'SUM',
|
||||
alias: f.fieldName || f.fieldCode,
|
||||
};
|
||||
});
|
||||
|
||||
reportLoaded.value = true;
|
||||
} catch (_e) {
|
||||
ElMessage.error('获取报表字段信息失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 指标勾选 ==========
|
||||
|
||||
const onIndicatorCheck = (fieldCode: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
const f = reportFields.value.find((r) => r.fieldCode === fieldCode);
|
||||
indicatorAggs[fieldCode] = {
|
||||
aggregate: f?.defaultAggregate || 'SUM',
|
||||
alias: f?.fieldName || fieldCode,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 筛选条件 ==========
|
||||
|
||||
const addFilter = () => {
|
||||
filters.value.push({ fieldCode: '', operator: '=', value: '' });
|
||||
};
|
||||
|
||||
const removeFilter = (idx: number) => {
|
||||
filters.value.splice(idx, 1);
|
||||
};
|
||||
|
||||
const onFilterFieldChange = (idx: number) => {
|
||||
// 清空值,方便用户输入
|
||||
filters.value[idx].value = '';
|
||||
};
|
||||
|
||||
// ========== 排序 ==========
|
||||
|
||||
const addOrder = () => {
|
||||
queryForm.orderBy.push({ fieldCode: '', direction: 'DESC' });
|
||||
};
|
||||
|
||||
const removeOrder = (idx: number) => {
|
||||
queryForm.orderBy.splice(idx, 1);
|
||||
};
|
||||
|
||||
// ========== 查询 ==========
|
||||
|
||||
const buildQueryParams = () => {
|
||||
const dimensions = [...queryForm.dimensions];
|
||||
|
||||
// 收集已勾选的指标
|
||||
const indicators: { fieldCode: string; aggregate: string; alias: string }[] = [];
|
||||
indicatorFields.value.forEach((f) => {
|
||||
if (indicatorChecked[f.fieldCode]) {
|
||||
indicators.push({
|
||||
fieldCode: f.fieldCode,
|
||||
aggregate: indicatorAggs[f.fieldCode]?.aggregate || 'SUM',
|
||||
alias: indicatorAggs[f.fieldCode]?.alias || f.fieldName || f.fieldCode,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const validFilters = filters.value.filter((f) => f.fieldCode && f.value);
|
||||
const validOrderBy = queryForm.orderBy.filter((o) => o.fieldCode);
|
||||
|
||||
return {
|
||||
businessCode: queryForm.businessCode,
|
||||
reportCode: queryForm.reportCode,
|
||||
dimensions,
|
||||
indicators,
|
||||
filters: validFilters,
|
||||
timeRange: {
|
||||
startDate: timeRange.value?.[0] || '',
|
||||
endDate: timeRange.value?.[1] || '',
|
||||
},
|
||||
timeGroup: queryForm.timeGroup,
|
||||
orderBy: validOrderBy,
|
||||
page: queryForm.page,
|
||||
pageSize: queryForm.pageSize,
|
||||
};
|
||||
};
|
||||
|
||||
const handleQuery = async () => {
|
||||
if (!queryForm.businessCode || !queryForm.reportCode) {
|
||||
ElMessage.warning('请选择业务和报表');
|
||||
return;
|
||||
}
|
||||
if (!timeRange.value || timeRange.value.length !== 2) {
|
||||
ElMessage.warning('请选择时间范围');
|
||||
return;
|
||||
}
|
||||
|
||||
queryLoading.value = true;
|
||||
queryDone.value = true;
|
||||
try {
|
||||
const params = buildQueryParams();
|
||||
const res = await reportQuery(params);
|
||||
const raw = res.data || {};
|
||||
|
||||
// 适配多种返回格式
|
||||
const list = raw.list || raw.records || raw.rows || [];
|
||||
const total = raw.total ?? list.length;
|
||||
|
||||
// 从接口获取列信息,或从数据推断
|
||||
const columns = raw.columns || raw.header || [];
|
||||
if (columns.length === 0 && list.length > 0) {
|
||||
queryResult.columns = Object.keys(list[0]);
|
||||
} else {
|
||||
queryResult.columns = columns;
|
||||
}
|
||||
|
||||
queryResult.list = list;
|
||||
queryResult.total = total;
|
||||
|
||||
// 切换到图表时渲染
|
||||
if (viewMode.value === 'chart') {
|
||||
nextTick(() => renderChart());
|
||||
}
|
||||
} catch (_e) {
|
||||
queryResult.list = [];
|
||||
queryResult.columns = [];
|
||||
queryResult.total = 0;
|
||||
ElMessage.error('查询失败');
|
||||
} finally {
|
||||
queryLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
resetQueryState();
|
||||
timeRange.value = [];
|
||||
queryForm.page = 1;
|
||||
queryDone.value = false;
|
||||
};
|
||||
|
||||
const resetQueryState = () => {
|
||||
queryForm.dimensions = [];
|
||||
queryForm.orderBy = [];
|
||||
queryForm.timeGroup = 'day';
|
||||
queryForm.pageSize = 20;
|
||||
queryForm.page = 1;
|
||||
filters.value = [];
|
||||
// 清空指标状态
|
||||
Object.keys(indicatorChecked).forEach((k) => delete indicatorChecked[k]);
|
||||
Object.keys(indicatorAggs).forEach((k) => delete indicatorAggs[k]);
|
||||
};
|
||||
|
||||
// ========== 图表渲染 ==========
|
||||
|
||||
watch(viewMode, (mode) => {
|
||||
if (mode === 'chart' && queryDone.value && queryResult.list.length > 0) {
|
||||
nextTick(() => renderChart());
|
||||
}
|
||||
if (mode === 'table') {
|
||||
disposeChart();
|
||||
}
|
||||
});
|
||||
|
||||
const renderChart = () => {
|
||||
if (!chartRef.value || queryResult.list.length === 0) return;
|
||||
if (!chartReady.value) {
|
||||
disposeChart();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chartInstance) {
|
||||
chartInstance = echarts.init(chartRef.value);
|
||||
}
|
||||
|
||||
const dimensions = queryForm.dimensions;
|
||||
const dimField = dimensions[0] || queryResult.columns[0];
|
||||
|
||||
// x 轴:第一个维度
|
||||
const xData = queryResult.list.map((row) => row[dimField] ?? '');
|
||||
|
||||
// 系列:已勾选的指标
|
||||
// 注意:API 返回的数据中,指标字段的 key 使用的是 alias,而非 fieldCode
|
||||
const series: echarts.SeriesOption[] = [];
|
||||
indicatorFields.value.forEach((f) => {
|
||||
if (indicatorChecked[f.fieldCode]) {
|
||||
const alias = indicatorAggs[f.fieldCode]?.alias || f.fieldName || f.fieldCode;
|
||||
series.push({
|
||||
name: alias,
|
||||
type: 'bar',
|
||||
data: queryResult.list.map((row) => {
|
||||
const val = Number(row[alias]);
|
||||
return isNaN(val) ? row[alias] : val;
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
chartInstance.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { type: 'scroll', bottom: 0 },
|
||||
grid: { left: '3%', right: '4%', bottom: '20%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: xData,
|
||||
axisLabel: { rotate: xData.length > 10 ? 45 : 0 },
|
||||
},
|
||||
yAxis: { type: 'value' },
|
||||
series,
|
||||
});
|
||||
|
||||
chartInstance.resize();
|
||||
};
|
||||
|
||||
const disposeChart = () => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose();
|
||||
chartInstance = null;
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 窗口 resize ==========
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (chartInstance) chartInstance.resize();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.report-query-container {
|
||||
.field-checkbox-group {
|
||||
.el-checkbox {
|
||||
margin-right: 16px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.field-empty-hint {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.indicator-list {
|
||||
width: 100%;
|
||||
|
||||
.indicator-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-list,
|
||||
.order-list {
|
||||
width: 100%;
|
||||
|
||||
.filter-row,
|
||||
.order-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 450px;
|
||||
}
|
||||
|
||||
.chart-tip {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<el-dialog :title="dialogTitle" :model-value="visible" width="480px" :close-on-click-modal="false" destroy-on-close @update:model-value="emit('update:visible', $event)">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="业务编码" prop="businessCode">
|
||||
<el-input
|
||||
v-model="form.businessCode"
|
||||
placeholder="请输入业务编码,如 KUAISHOU"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="业务名称" prop="businessName">
|
||||
<el-input v-model="form.businessName" placeholder="请输入业务名称,如 快手电商" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="saving">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'reportEngineBusinessManager' };
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import { FormInstance } from 'element-plus';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
editData?: { id?: number; businessCode: string; businessName: string } | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', val: boolean): void;
|
||||
(e: 'confirm', data: { id?: number; businessCode: string; businessName: string }): void;
|
||||
}>();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const saving = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
id: undefined as number | undefined,
|
||||
businessCode: '',
|
||||
businessName: '',
|
||||
});
|
||||
|
||||
const rules = {
|
||||
businessCode: [{ required: true, message: '请输入业务编码', trigger: 'blur' }],
|
||||
businessName: [{ required: true, message: '请输入业务名称', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
const dialogTitle = ref('新增业务');
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
if (props.editData) {
|
||||
form.id = props.editData.id;
|
||||
form.businessCode = props.editData.businessCode;
|
||||
form.businessName = props.editData.businessName;
|
||||
dialogTitle.value = '编辑业务';
|
||||
} else {
|
||||
form.id = undefined;
|
||||
form.businessCode = '';
|
||||
form.businessName = '';
|
||||
dialogTitle.value = '新增业务';
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formRef.value) return;
|
||||
formRef.value.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload: { id?: number; businessCode: string; businessName: string } = {
|
||||
businessCode: form.businessCode,
|
||||
businessName: form.businessName,
|
||||
};
|
||||
if (form.id) payload.id = form.id;
|
||||
emit('confirm', payload);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:title="dialogTitle"
|
||||
:model-value="visible"
|
||||
width="750px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@update:model-value="emit('update:visible', $event)"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" v-loading="loading">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="抽取编码" prop="extractCode">
|
||||
<el-input v-model="form.extractCode" placeholder="如 shop_daily_direct" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="抽取名称" prop="extractName">
|
||||
<el-input v-model="form.extractName" placeholder="如 店铺日订单直接抽取" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="源表名" prop="sourceTableName">
|
||||
<el-input v-model="form.sourceTableName" placeholder="如 ods_kuaishou_orders" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="源表别名" prop="sourceTableAlias">
|
||||
<el-input v-model="form.sourceTableAlias" placeholder="如 o" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="目标表名" prop="targetTableName">
|
||||
<el-input v-model="form.targetTableName" placeholder="如 stat_kuaishou_shop_daily" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="抽取类型" prop="extractType">
|
||||
<el-select v-model="form.extractType" style="width: 100%">
|
||||
<el-option label="增量抽取" value="INCREMENTAL" />
|
||||
<el-option label="全量抽取" value="FULL" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="抽取模式" prop="extractMode">
|
||||
<el-select v-model="form.extractMode" style="width: 100%">
|
||||
<el-option label="直接抽取" value="DIRECT" />
|
||||
<el-option label="SQL转换" value="SQL" />
|
||||
<el-option label="API抽取" value="API" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="抽取键字段" prop="extractKeyField">
|
||||
<el-input v-model="form.extractKeyField" placeholder="如 created_at" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="每批数量" prop="batchSize">
|
||||
<el-input-number v-model="form.batchSize" :min="1" :max="100000" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="是否启用" prop="isEnabled">
|
||||
<el-switch v-model="form.isEnabled" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="form.status" style="width: 100%">
|
||||
<el-option label="启用" value="ACTIVE" />
|
||||
<el-option label="停用" value="INACTIVE" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="负责人" prop="operator">
|
||||
<el-input v-model="form.operator" placeholder="如 system" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="分组字段" prop="groupByFields">
|
||||
<el-select v-model="form.groupByFields" multiple filterable allow-create default-first-option style="width: 100%" placeholder="输入字段名后回车添加">
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="过滤表达式" prop="filterExpression">
|
||||
<el-input v-model="form.filterExpression" type="textarea" :rows="2" placeholder="如 o.order_status = '已完成'" />
|
||||
</el-form-item>
|
||||
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="saving">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'reportEngineExtractConfigForm' };
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import { ElMessage, FormInstance } from 'element-plus';
|
||||
import { getExtractConfig, saveExtractConfig, type ExtractConfigSaveData } from '/@/api/report-engine/report';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
editId?: number | null;
|
||||
businessCode: string;
|
||||
reportCode: string;
|
||||
reportName: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', val: boolean): void;
|
||||
(e: 'confirm'): void;
|
||||
}>();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const saving = ref(false);
|
||||
const loading = ref(false);
|
||||
const dialogTitle = ref('新增抽取配置');
|
||||
|
||||
const form = reactive<ExtractConfigSaveData>({
|
||||
businessCode: '',
|
||||
reportCode: '',
|
||||
extractCode: '',
|
||||
extractName: '',
|
||||
sourceTableName: '',
|
||||
sourceTableAlias: '',
|
||||
targetTableName: '',
|
||||
isEnabled: true,
|
||||
extractType: 'INCREMENTAL',
|
||||
extractMode: 'DIRECT',
|
||||
extractKeyField: '',
|
||||
batchSize: 1000,
|
||||
status: 'ACTIVE',
|
||||
groupByFields: [],
|
||||
filterExpression: '',
|
||||
operator: '',
|
||||
});
|
||||
|
||||
const rules = {
|
||||
extractCode: [{ required: true, message: '请输入抽取编码', trigger: 'blur' }],
|
||||
extractName: [{ required: true, message: '请输入抽取名称', trigger: 'blur' }],
|
||||
sourceTableName: [{ required: true, message: '请输入源表名', trigger: 'blur' }],
|
||||
targetTableName: [{ required: true, message: '请输入目标表名', trigger: 'blur' }],
|
||||
extractType: [{ required: true, message: '请选择抽取类型', trigger: 'change' }],
|
||||
extractMode: [{ required: true, message: '请选择抽取模式', trigger: 'change' }],
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
form.id = undefined;
|
||||
form.businessCode = props.businessCode;
|
||||
form.reportCode = props.reportCode;
|
||||
form.extractCode = '';
|
||||
form.extractName = '';
|
||||
form.sourceTableName = '';
|
||||
form.sourceTableAlias = '';
|
||||
form.targetTableName = '';
|
||||
form.isEnabled = true;
|
||||
form.extractType = 'INCREMENTAL';
|
||||
form.extractMode = 'DIRECT';
|
||||
form.extractKeyField = '';
|
||||
form.batchSize = 1000;
|
||||
form.status = 'ACTIVE';
|
||||
form.groupByFields = [];
|
||||
form.filterExpression = '';
|
||||
form.operator = '';
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (val) => {
|
||||
if (val) {
|
||||
form.businessCode = props.businessCode;
|
||||
form.reportCode = props.reportCode;
|
||||
if (props.editId) {
|
||||
dialogTitle.value = '编辑抽取配置';
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getExtractConfig(props.editId);
|
||||
const raw = res.data || {};
|
||||
const detail = raw.extractConfig || raw.data || raw;
|
||||
form.id = detail.id;
|
||||
form.businessCode = detail.businessCode || props.businessCode;
|
||||
form.reportCode = detail.reportCode || props.reportCode;
|
||||
form.extractCode = detail.extractCode || '';
|
||||
form.extractName = detail.extractName || '';
|
||||
form.sourceTableName = detail.sourceTableName || '';
|
||||
form.sourceTableAlias = detail.sourceTableAlias || '';
|
||||
form.targetTableName = detail.targetTableName || '';
|
||||
form.isEnabled = detail.isEnabled !== undefined ? detail.isEnabled : true;
|
||||
form.extractType = detail.extractType || 'INCREMENTAL';
|
||||
form.extractMode = detail.extractMode || 'DIRECT';
|
||||
form.extractKeyField = detail.extractKeyField || '';
|
||||
form.batchSize = detail.batchSize || 1000;
|
||||
form.status = detail.status || 'ACTIVE';
|
||||
form.groupByFields = detail.groupByFields || [];
|
||||
form.filterExpression = detail.filterExpression || '';
|
||||
form.operator = detail.operator || '';
|
||||
} catch (_e) {
|
||||
ElMessage.error('获取抽取配置详情失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
} else {
|
||||
dialogTitle.value = '新增抽取配置';
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formRef.value) return;
|
||||
formRef.value.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload: any = { ...form };
|
||||
if (!payload.id) delete payload.id;
|
||||
await saveExtractConfig(payload);
|
||||
ElMessage.success(props.editId ? '修改成功' : '新增成功');
|
||||
emit('update:visible', false);
|
||||
emit('confirm');
|
||||
} catch (_e) {
|
||||
ElMessage.error(props.editId ? '修改失败' : '新增失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,398 @@
|
||||
<template>
|
||||
<div class="field-editor">
|
||||
<div class="field-editor-header">
|
||||
<span class="field-editor-title">字段配置</span>
|
||||
<el-button size="small" type="primary" @click="onAddField">
|
||||
<el-icon><ele-Plus /></el-icon> 新增字段
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 字段列表 -->
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
:data="localFields"
|
||||
row-key="fieldCode"
|
||||
border
|
||||
size="small"
|
||||
class="field-table"
|
||||
empty-text="暂无字段,请点击「新增字段」添加"
|
||||
>
|
||||
<el-table-column label="排序" width="56" align="center">
|
||||
<template #default="scope">
|
||||
<span
|
||||
class="drag-handle"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, scope.$index)"
|
||||
@dragover.prevent="onDragOver($event, scope.$index)"
|
||||
@drop.prevent="onDrop($event, scope.$index)"
|
||||
>
|
||||
<el-icon><ele-Rank /></el-icon>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sortOrder" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="fieldCode" label="字段编码" min-width="110" show-overflow-tooltip />
|
||||
<el-table-column prop="fieldName" label="字段名称" min-width="110" show-overflow-tooltip />
|
||||
<el-table-column prop="fieldType" label="类型" width="85" />
|
||||
<el-table-column prop="fieldRole" label="角色" width="70" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.fieldRole === 'DIMENSION' ? 'primary' : 'warning'" size="small" effect="plain">
|
||||
{{ scope.row.fieldRole === 'DIMENSION' ? '维度' : '指标' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="defaultAggregate" label="默认聚合" width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="groupName" label="分组" width="90" show-overflow-tooltip />
|
||||
<el-table-column prop="unit" label="单位" width="70" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" text type="primary" @click="onEditField(scope.$index)">编辑</el-button>
|
||||
<el-button size="small" text type="danger" @click="onDeleteField(scope.$index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 字段编辑弹窗 -->
|
||||
<el-dialog
|
||||
:title="fieldDialog.title"
|
||||
v-model="fieldDialog.visible"
|
||||
width="700px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
>
|
||||
<el-form ref="fieldFormRef" :model="fieldForm" :rules="fieldRules" label-width="100px" label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="字段编码" prop="fieldCode">
|
||||
<el-input v-model="fieldForm.fieldCode" placeholder="如 shop_id" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="字段名称" prop="fieldName">
|
||||
<el-input v-model="fieldForm.fieldName" placeholder="如 店铺ID" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="字段类型" prop="fieldType">
|
||||
<el-select v-model="fieldForm.fieldType" style="width: 100%">
|
||||
<el-option v-for="opt in FIELD_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="数据类型">
|
||||
<el-select v-model="fieldForm.dataType" style="width: 100%" clearable>
|
||||
<el-option v-for="opt in FIELD_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="字段角色" prop="fieldRole">
|
||||
<el-select v-model="fieldForm.fieldRole" style="width: 100%">
|
||||
<el-option label="维度 (DIMENSION)" value="DIMENSION" />
|
||||
<el-option label="指标 (INDICATOR)" value="INDICATOR" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="默认聚合">
|
||||
<el-select v-model="fieldForm.defaultAggregate" style="width: 100%" clearable placeholder="无">
|
||||
<el-option label="无" value="" />
|
||||
<el-option v-for="opt in AGGREGATE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="分组名称">
|
||||
<el-input v-model="fieldForm.groupName" placeholder="如 基础信息" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="单位">
|
||||
<el-input v-model="fieldForm.unit" placeholder="如 元、%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="fieldForm.status" style="width: 100%">
|
||||
<el-option label="启用" value="ACTIVE" />
|
||||
<el-option label="停用" value="INACTIVE" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="排序号">
|
||||
<el-input-number v-model="fieldForm.sortOrder" :min="1" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="表达式">
|
||||
<el-input v-model="fieldForm.expression" placeholder="衍生字段表达式,如 {refund}/{order}*100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="属性">
|
||||
<el-checkbox v-model="fieldForm.isAggregatable">可聚合</el-checkbox>
|
||||
<el-checkbox v-model="fieldForm.isFilterable">可筛选</el-checkbox>
|
||||
<el-checkbox v-model="fieldForm.isQueryable">可查询</el-checkbox>
|
||||
<el-checkbox v-model="fieldForm.isSortable">可排序</el-checkbox>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="fieldDialog.visible = false">取消</el-button>
|
||||
<el-button type="primary" @click="onFieldConfirm" :loading="fieldDialog.saving">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'reportEngineFieldEditor' };
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import type { FormInstance } from 'element-plus';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import type { FieldDefine } from '/@/api/report-engine/report';
|
||||
|
||||
const FIELD_TYPE_OPTIONS = [
|
||||
{ value: 'STRING', label: 'STRING (字符串)' },
|
||||
{ value: 'INT', label: 'INT (整数)' },
|
||||
{ value: 'FLOAT', label: 'FLOAT (浮点)' },
|
||||
{ value: 'BIGINT', label: 'BIGINT (长整)' },
|
||||
{ value: 'DECIMAL', label: 'DECIMAL (小数)' },
|
||||
{ value: 'DATE', label: 'DATE (日期)' },
|
||||
{ value: 'DATETIME', label: 'DATETIME (时间)' },
|
||||
{ value: 'BOOLEAN', label: 'BOOLEAN (布尔)' },
|
||||
];
|
||||
|
||||
const AGGREGATE_OPTIONS = [
|
||||
{ value: 'SUM', label: 'SUM (求和)' },
|
||||
{ value: 'COUNT', label: 'COUNT (计数)' },
|
||||
{ value: 'AVG', label: 'AVG (平均)' },
|
||||
{ value: 'MAX', label: 'MAX (最大)' },
|
||||
{ value: 'MIN', label: 'MIN (最小)' },
|
||||
];
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: FieldDefine[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: FieldDefine[]): void;
|
||||
}>();
|
||||
|
||||
const tableRef = ref();
|
||||
const fieldFormRef = ref<FormInstance>();
|
||||
|
||||
const localFields = ref<FieldDefine[]>([]);
|
||||
let dragSourceIndex = -1;
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
localFields.value = JSON.parse(JSON.stringify(val || []));
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const emitUpdate = () => {
|
||||
emit('update:modelValue', JSON.parse(JSON.stringify(localFields.value)));
|
||||
};
|
||||
|
||||
// ========== 拖拽排序 ==========
|
||||
|
||||
const onDragStart = (event: DragEvent, index: number) => {
|
||||
dragSourceIndex = index;
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const onDragOver = (event: DragEvent, index: number) => {
|
||||
if (dragSourceIndex === -1 || dragSourceIndex === index) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
|
||||
const onDrop = (event: DragEvent, targetIndex: number) => {
|
||||
if (dragSourceIndex === -1 || dragSourceIndex === targetIndex) return;
|
||||
const list = localFields.value;
|
||||
const [moved] = list.splice(dragSourceIndex, 1);
|
||||
list.splice(targetIndex, 0, moved);
|
||||
dragSourceIndex = -1;
|
||||
reindexSortOrder();
|
||||
emitUpdate();
|
||||
};
|
||||
|
||||
const reindexSortOrder = () => {
|
||||
localFields.value.forEach((f, i) => {
|
||||
f.sortOrder = i + 1;
|
||||
});
|
||||
};
|
||||
|
||||
// ========== 字段编辑弹窗 ==========
|
||||
|
||||
const fieldDialog = reactive({
|
||||
visible: false,
|
||||
title: '新增字段',
|
||||
saving: false,
|
||||
editingIndex: -1 as number,
|
||||
});
|
||||
|
||||
const defaultFieldForm = () => ({
|
||||
id: undefined,
|
||||
fieldCode: '',
|
||||
fieldName: '',
|
||||
fieldType: 'STRING' as FieldDefine['fieldType'],
|
||||
fieldRole: 'DIMENSION' as FieldDefine['fieldRole'],
|
||||
dataType: '',
|
||||
defaultAggregate: '',
|
||||
groupName: '',
|
||||
sortOrder: 1,
|
||||
unit: '',
|
||||
expression: '',
|
||||
status: 'ACTIVE',
|
||||
isAggregatable: false,
|
||||
isFilterable: false,
|
||||
isQueryable: false,
|
||||
isSortable: false,
|
||||
});
|
||||
|
||||
const fieldForm = reactive(defaultFieldForm());
|
||||
|
||||
const fieldRules = {
|
||||
fieldCode: [
|
||||
{ required: true, message: '请输入字段编码', trigger: 'blur' },
|
||||
],
|
||||
fieldName: [{ required: true, message: '请输入字段名称', trigger: 'blur' }],
|
||||
fieldType: [{ required: true, message: '请选择字段类型', trigger: 'change' }],
|
||||
fieldRole: [{ required: true, message: '请选择字段角色', trigger: 'change' }],
|
||||
};
|
||||
|
||||
const onAddField = () => {
|
||||
fieldDialog.title = '新增字段';
|
||||
fieldDialog.editingIndex = -1;
|
||||
resetFieldForm();
|
||||
fieldDialog.visible = true;
|
||||
};
|
||||
|
||||
const onEditField = (index: number) => {
|
||||
fieldDialog.title = '编辑字段';
|
||||
fieldDialog.editingIndex = index;
|
||||
const item = localFields.value[index];
|
||||
Object.assign(fieldForm, JSON.parse(JSON.stringify(item)));
|
||||
fieldDialog.visible = true;
|
||||
};
|
||||
|
||||
const onDeleteField = (index: number) => {
|
||||
const item = localFields.value[index];
|
||||
ElMessageBox.confirm(`确定要删除字段「${item.fieldName || item.fieldCode}」吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(() => {
|
||||
localFields.value.splice(index, 1);
|
||||
reindexSortOrder();
|
||||
emitUpdate();
|
||||
ElMessage.success('已删除');
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const resetFieldForm = () => {
|
||||
const defaults = defaultFieldForm();
|
||||
Object.assign(fieldForm, defaults);
|
||||
if (localFields.value.length > 0) {
|
||||
fieldForm.sortOrder = Math.max(...localFields.value.map((f) => f.sortOrder)) + 1;
|
||||
} else {
|
||||
fieldForm.sortOrder = 1;
|
||||
}
|
||||
};
|
||||
|
||||
const onFieldConfirm = () => {
|
||||
if (!fieldFormRef.value) return;
|
||||
fieldFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
fieldDialog.saving = true;
|
||||
try {
|
||||
const data: FieldDefine = {
|
||||
id: fieldForm.id || undefined,
|
||||
fieldCode: fieldForm.fieldCode,
|
||||
fieldName: fieldForm.fieldName,
|
||||
fieldType: fieldForm.fieldType,
|
||||
fieldRole: fieldForm.fieldRole,
|
||||
dataType: fieldForm.dataType || undefined,
|
||||
defaultAggregate: fieldForm.defaultAggregate || undefined,
|
||||
groupName: fieldForm.groupName || undefined,
|
||||
sortOrder: fieldForm.sortOrder,
|
||||
unit: fieldForm.unit || undefined,
|
||||
expression: fieldForm.expression || undefined,
|
||||
status: fieldForm.status || 'ACTIVE',
|
||||
isAggregatable: fieldForm.isAggregatable,
|
||||
isFilterable: fieldForm.isFilterable,
|
||||
isQueryable: fieldForm.isQueryable,
|
||||
isSortable: fieldForm.isSortable,
|
||||
};
|
||||
|
||||
if (fieldDialog.editingIndex >= 0) {
|
||||
localFields.value[fieldDialog.editingIndex] = data;
|
||||
} else {
|
||||
data.sortOrder = localFields.value.length + 1;
|
||||
localFields.value.push(data);
|
||||
reindexSortOrder();
|
||||
}
|
||||
emitUpdate();
|
||||
fieldDialog.visible = false;
|
||||
} finally {
|
||||
fieldDialog.saving = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.field-editor {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid var(--el-border-color-light);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.field-editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.field-editor-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.field-table {
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 16px;
|
||||
display: inline-block;
|
||||
padding: 4px;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<el-dialog :title="dialogTitle" :model-value="visible" width="900px" :close-on-click-modal="false" destroy-on-close @update:model-value="emit('update:visible', $event)">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" v-loading="loading">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="报表编码" prop="reportCode">
|
||||
<el-input v-model="form.reportCode" placeholder="如 shop_daily" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="报表名称" prop="reportName">
|
||||
<el-input v-model="form.reportName" placeholder="如 店铺日报" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="报表描述" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="form.status" style="width: 100%">
|
||||
<el-option label="启用" value="ACTIVE" />
|
||||
<el-option label="停用" value="INACTIVE" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="统计表名" prop="statTableName">
|
||||
<el-input v-model="form.statTableName" placeholder="如 stat_kuaishou_shop_daily" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="表注释" prop="statTableComment">
|
||||
<el-input v-model="form.statTableComment" placeholder="如 快手电商-店铺日报" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="日期字段" prop="dateField">
|
||||
<el-input v-model="form.dateField" placeholder="如 stat_date" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="主键" prop="primaryKeys">
|
||||
<el-input v-model="primaryKeysStr" placeholder="多个主键用逗号分隔,如 shop_id,stat_date" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="冲突键" prop="conflictKeys">
|
||||
<el-input v-model="conflictKeysStr" placeholder="多个冲突键用逗号分隔,如 shop_id,stat_date" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 字段编辑器 -->
|
||||
<FieldEditor ref="fieldEditorRef" v-model="fields" />
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="saving">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'reportEngineReportForm' };
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import { ElMessage, FormInstance } from 'element-plus';
|
||||
import { getReport } from '/@/api/report-engine/report';
|
||||
import type { FieldDefine } from '/@/api/report-engine/report';
|
||||
import FieldEditor from './FieldEditor.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
editId?: number | null;
|
||||
businessCode: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', val: boolean): void;
|
||||
(e: 'confirm', data: any): void;
|
||||
}>();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const fieldEditorRef = ref<InstanceType<typeof FieldEditor>>();
|
||||
const saving = ref(false);
|
||||
const loading = ref(false);
|
||||
const dialogTitle = ref('新增报表');
|
||||
const fields = ref<FieldDefine[]>([]);
|
||||
const primaryKeysStr = ref('');
|
||||
const conflictKeysStr = ref('');
|
||||
|
||||
const form = reactive({
|
||||
reportCode: '',
|
||||
reportName: '',
|
||||
description: '',
|
||||
status: 'ACTIVE',
|
||||
statTableName: '',
|
||||
statTableComment: '',
|
||||
dateField: '',
|
||||
primaryKeys: [] as string[],
|
||||
conflictKeys: [] as string[],
|
||||
});
|
||||
|
||||
const rules = {
|
||||
reportCode: [{ required: true, message: '请输入报表编码', trigger: 'blur' }],
|
||||
reportName: [{ required: true, message: '请输入报表名称', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
statTableName: [{ required: true, message: '请输入统计表名', trigger: 'blur' }],
|
||||
dateField: [{ required: true, message: '请输入日期字段', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (val) => {
|
||||
if (val) {
|
||||
if (props.editId) {
|
||||
dialogTitle.value = '编辑报表';
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getReport(props.editId);
|
||||
const raw = res.data || {};
|
||||
const detail = raw.report || raw.data || raw;
|
||||
form.reportCode = detail.reportCode || '';
|
||||
form.reportName = detail.reportName || '';
|
||||
form.description = detail.description || '';
|
||||
form.status = detail.status || 'ACTIVE';
|
||||
form.statTableName = detail.statTableName || '';
|
||||
form.statTableComment = detail.statTableComment || '';
|
||||
form.dateField = detail.dateField || '';
|
||||
form.primaryKeys = detail.primaryKeys || [];
|
||||
form.conflictKeys = detail.conflictKeys || [];
|
||||
primaryKeysStr.value = (detail.primaryKeys || []).join(',');
|
||||
conflictKeysStr.value = (detail.conflictKeys || []).join(',');
|
||||
fields.value = detail.fields || [];
|
||||
} catch (_e) {
|
||||
ElMessage.error('获取报表详情失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
} else {
|
||||
dialogTitle.value = '新增报表';
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const resetForm = () => {
|
||||
form.reportCode = '';
|
||||
form.reportName = '';
|
||||
form.description = '';
|
||||
form.status = 'ACTIVE';
|
||||
form.statTableName = '';
|
||||
form.statTableComment = '';
|
||||
form.dateField = '';
|
||||
form.primaryKeys = [];
|
||||
form.conflictKeys = [];
|
||||
primaryKeysStr.value = '';
|
||||
conflictKeysStr.value = '';
|
||||
fields.value = [];
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formRef.value) return;
|
||||
formRef.value.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
// 将逗号分隔字符串转为数组
|
||||
const toArray = (str: string) =>
|
||||
str
|
||||
.split(/[,,]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
emit('confirm', {
|
||||
businessCode: props.businessCode,
|
||||
...form,
|
||||
id: props.editId || undefined,
|
||||
primaryKeys: toArray(primaryKeysStr.value),
|
||||
conflictKeys: toArray(conflictKeysStr.value),
|
||||
fields: JSON.parse(JSON.stringify(fields.value)),
|
||||
});
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,520 @@
|
||||
<template>
|
||||
<div class="report-engine-report-container">
|
||||
<el-card shadow="hover">
|
||||
<!-- 业务 Tab -->
|
||||
<div class="biz-tabs-header mb15">
|
||||
<el-tabs v-model="activeBusinessCode" type="card" @tab-click="handleTabClick" @tab-remove="handleTabRemove" class="biz-tabs">
|
||||
<el-tab-pane
|
||||
v-for="biz in businesses"
|
||||
:key="biz.businessCode"
|
||||
:name="biz.businessCode"
|
||||
closable
|
||||
>
|
||||
<template #label>
|
||||
<span class="custom-tab-label">
|
||||
<span class="tab-name">{{ biz.businessName }}</span>
|
||||
<el-icon class="tab-edit-btn" @click.stop="onOpenBusinessEdit(biz)">
|
||||
<ele-EditPen />
|
||||
</el-icon>
|
||||
</span>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<el-button
|
||||
size="default"
|
||||
type="primary"
|
||||
@click="onOpenBusinessAdd"
|
||||
class="biz-add-btn"
|
||||
>
|
||||
<el-icon><ele-Plus /></el-icon>
|
||||
<span>新增业务</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="system-user-search mb15">
|
||||
<el-form :model="tableData.param" ref="queryRef" :inline="true" label-width="80px">
|
||||
<el-form-item label="报表名称" prop="reportName">
|
||||
<el-input
|
||||
v-model="tableData.param.reportName"
|
||||
placeholder="请输入报表名称"
|
||||
clearable
|
||||
size="default"
|
||||
style="width: 220px"
|
||||
@keyup.enter.native="getList"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button size="default" type="primary" @click="getList">
|
||||
<el-icon><ele-Search /></el-icon>
|
||||
查询
|
||||
</el-button>
|
||||
<el-button size="default" @click="resetQuery">
|
||||
<el-icon><ele-Refresh /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
<el-button size="default" type="success" @click="onOpenReportAdd">
|
||||
<el-icon><ele-Plus /></el-icon>
|
||||
新增报表
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 报表表格 -->
|
||||
<el-table :data="tableData.data" style="width: 100%" v-loading="tableData.loading">
|
||||
<el-table-column type="index" label="序号" width="60" />
|
||||
<el-table-column prop="reportCode" label="报表编码" show-overflow-tooltip />
|
||||
<el-table-column prop="reportName" label="报表名称" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="90" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === 'ACTIVE' ? 'success' : 'danger'" size="small">
|
||||
{{ scope.row.status === 'ACTIVE' ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" show-overflow-tooltip />
|
||||
<el-table-column prop="statTableName" label="统计表名" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="320" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" text type="primary" @click="onOpenReportEdit(scope.row)">编辑</el-button>
|
||||
<el-button size="small" text type="primary" @click="onOpenExtractConfig(scope.row)">抽取配置</el-button>
|
||||
<el-button size="small" text type="success" @click="onDailyExtract(scope.row)">按天回刷</el-button>
|
||||
<el-button size="small" text type="danger" @click="onExtractConfigDel(scope.row)" v-if="scope.row.extractConfigIds">删除抽取配置</el-button>
|
||||
<el-button size="small" text type="danger" @click="onReportDel(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<pagination
|
||||
v-show="tableData.total > 0"
|
||||
:total="tableData.total"
|
||||
v-model:page="tableData.param.pageNum"
|
||||
v-model:limit="tableData.param.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 业务管理弹窗 -->
|
||||
<BusinessManager
|
||||
v-model:visible="businessDialog.visible"
|
||||
:edit-data="businessDialog.editData"
|
||||
@confirm="onBusinessConfirm"
|
||||
/>
|
||||
|
||||
<!-- 报表表单弹窗 -->
|
||||
<ReportForm
|
||||
v-model:visible="reportDialog.visible"
|
||||
:edit-id="reportDialog.editId"
|
||||
:business-code="activeBusinessCode"
|
||||
@confirm="onReportConfirm"
|
||||
/>
|
||||
|
||||
<!-- 抽取配置弹窗 -->
|
||||
<ExtractConfigForm
|
||||
v-model:visible="extractDialog.visible"
|
||||
:edit-id="extractDialog.editId"
|
||||
:business-code="extractDialog.businessCode"
|
||||
:report-code="extractDialog.reportCode"
|
||||
:report-name="extractDialog.reportName"
|
||||
@confirm="onExtractConfigConfirm"
|
||||
/>
|
||||
|
||||
<!-- 按天回刷弹窗 -->
|
||||
<el-dialog
|
||||
v-model="dailyExtractDialog.visible"
|
||||
title="按天回刷"
|
||||
width="420px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="报表编码">
|
||||
<el-input :model-value="dailyExtractDialog.reportCode" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="日期范围" required>
|
||||
<el-date-picker
|
||||
v-model="dailyExtractDialog.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%"
|
||||
:disabled-date="(t: Date) => t > new Date()"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dailyExtractDialog.visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dailyExtractDialog.loading" @click="onDailyExtractConfirm">确认回刷</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'reportEngineReport' };
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox, FormInstance } from 'element-plus';
|
||||
import { listBusinesses, saveBusiness, deleteBusiness, saveReportWithFields, deleteReport, listReports, deleteExtractConfig, execDailyBackfill } from '/@/api/report-engine/report';
|
||||
import BusinessManager from './components/BusinessManager.vue';
|
||||
import ReportForm from './components/ReportForm.vue';
|
||||
import ExtractConfigForm from './components/ExtractConfigForm.vue';
|
||||
|
||||
const queryRef = ref<FormInstance>();
|
||||
|
||||
// --- 业务列表 ---
|
||||
const businesses = ref<any[]>([]);
|
||||
const activeBusinessCode = ref('');
|
||||
|
||||
// --- 报表表格数据 ---
|
||||
const tableData = reactive({
|
||||
data: [] as any[],
|
||||
total: 0,
|
||||
loading: false,
|
||||
param: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
reportName: '',
|
||||
},
|
||||
});
|
||||
|
||||
// --- 业务弹窗 ---
|
||||
const businessDialog = reactive({
|
||||
visible: false,
|
||||
editData: null as { id?: number; businessCode: string; businessName: string } | null,
|
||||
});
|
||||
|
||||
// --- 报表弹窗 ---
|
||||
const reportDialog = reactive({
|
||||
visible: false,
|
||||
editId: null as number | null,
|
||||
});
|
||||
|
||||
// --- 抽取配置弹窗 ---
|
||||
const extractDialog = reactive({
|
||||
visible: false,
|
||||
editId: null as number | null,
|
||||
businessCode: '',
|
||||
reportCode: '',
|
||||
reportName: '',
|
||||
});
|
||||
|
||||
// --- 按天回刷弹窗 ---
|
||||
const dailyExtractDialog = reactive({
|
||||
visible: false,
|
||||
businessCode: '',
|
||||
reportCode: '',
|
||||
dateRange: [] as string[],
|
||||
loading: false,
|
||||
});
|
||||
|
||||
// ========== 业务相关 ==========
|
||||
|
||||
const loadBusinesses = async () => {
|
||||
try {
|
||||
const res = await listBusinesses();
|
||||
businesses.value = res.data?.list || res.data || [];
|
||||
if (businesses.value.length > 0 && !activeBusinessCode.value) {
|
||||
activeBusinessCode.value = businesses.value[0].businessCode;
|
||||
getList();
|
||||
}
|
||||
} catch (_e) {
|
||||
ElMessage.error('获取业务列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabClick = (pane?: any) => {
|
||||
const code = pane?.props?.name;
|
||||
if (code) activeBusinessCode.value = code;
|
||||
tableData.param.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const handleTabRemove = (name: string) => {
|
||||
const biz = businesses.value.find((b) => b.businessCode === name);
|
||||
if (!biz || !biz.id) return;
|
||||
const label = biz.businessName;
|
||||
ElMessageBox.confirm(`确定要删除业务「${label}」吗?该操作同时会删除该业务下所有报表配置。`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
await deleteBusiness(biz.id);
|
||||
ElMessage.success('删除成功');
|
||||
const idx = businesses.value.findIndex((b) => b.businessCode === name);
|
||||
await loadBusinesses();
|
||||
if (businesses.value.length > 0) {
|
||||
const newIdx = Math.min(idx, businesses.value.length - 1);
|
||||
activeBusinessCode.value = businesses.value[newIdx].businessCode;
|
||||
} else {
|
||||
activeBusinessCode.value = '';
|
||||
tableData.data = [];
|
||||
tableData.total = 0;
|
||||
}
|
||||
getList();
|
||||
} catch (_e) {
|
||||
ElMessage.error('删除失败');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const onOpenBusinessAdd = () => {
|
||||
businessDialog.editData = null;
|
||||
businessDialog.visible = true;
|
||||
};
|
||||
|
||||
const onOpenBusinessEdit = (biz: { id?: number; businessCode: string; businessName: string }) => {
|
||||
businessDialog.editData = { ...biz };
|
||||
businessDialog.visible = true;
|
||||
};
|
||||
|
||||
const onBusinessConfirm = async (data: { id?: number; businessCode: string; businessName: string }) => {
|
||||
try {
|
||||
const isEdit = !!data.id;
|
||||
await saveBusiness(data);
|
||||
ElMessage.success(isEdit ? '修改成功' : '新增成功');
|
||||
businessDialog.visible = false;
|
||||
await loadBusinesses();
|
||||
activeBusinessCode.value = data.businessCode;
|
||||
getList();
|
||||
} catch (_e) {
|
||||
ElMessage.error(data.id ? '修改失败' : '新增失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 报表相关 ==========
|
||||
|
||||
const getList = async () => {
|
||||
if (!activeBusinessCode.value) return;
|
||||
tableData.loading = true;
|
||||
try {
|
||||
const res = await listReports(
|
||||
activeBusinessCode.value,
|
||||
tableData.param.pageNum,
|
||||
tableData.param.pageSize,
|
||||
tableData.param.reportName || undefined
|
||||
);
|
||||
const raw = res.data;
|
||||
if (Array.isArray(raw)) {
|
||||
tableData.data = raw;
|
||||
tableData.total = raw.length;
|
||||
} else if (raw) {
|
||||
tableData.data = raw.list || raw.records || raw.rows || [];
|
||||
tableData.total = raw.total || tableData.data.length;
|
||||
}
|
||||
} catch (_e) {
|
||||
tableData.data = [];
|
||||
tableData.total = 0;
|
||||
} finally {
|
||||
tableData.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
if (!queryRef.value) return;
|
||||
queryRef.value.resetFields();
|
||||
tableData.param.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const onOpenReportAdd = () => {
|
||||
reportDialog.editId = null;
|
||||
reportDialog.visible = true;
|
||||
};
|
||||
|
||||
const onOpenReportEdit = async (row: any) => {
|
||||
reportDialog.editId = row.id;
|
||||
reportDialog.visible = true;
|
||||
};
|
||||
|
||||
const onReportConfirm = async (data: any) => {
|
||||
try {
|
||||
const isEdit = reportDialog.editId !== null;
|
||||
await saveReportWithFields(data);
|
||||
ElMessage.success(isEdit ? '修改成功' : '新增成功');
|
||||
reportDialog.visible = false;
|
||||
getList();
|
||||
} catch (_e) {
|
||||
ElMessage.error(reportDialog.editId ? '修改失败' : '新增失败');
|
||||
}
|
||||
};
|
||||
|
||||
const onReportDel = (row: any) => {
|
||||
ElMessageBox.confirm(`确定要删除报表「${row.reportName}」吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
const res = await deleteReport(row.id);
|
||||
if (res.data?.success === false) {
|
||||
// 业务层面拒绝删除(如存在关联字段)
|
||||
ElMessage.warning(res.data?.message || '无法删除');
|
||||
return;
|
||||
}
|
||||
ElMessage.success('删除成功');
|
||||
getList();
|
||||
} catch (_e) {
|
||||
ElMessage.error('删除失败');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
// ========== 抽取配置 ==========
|
||||
|
||||
const onOpenExtractConfig = (row: any) => {
|
||||
extractDialog.businessCode = row.businessCode;
|
||||
extractDialog.reportCode = row.reportCode;
|
||||
extractDialog.reportName = row.reportName;
|
||||
extractDialog.editId = row.extractConfigIds ? Number(row.extractConfigIds) : null;
|
||||
extractDialog.visible = true;
|
||||
};
|
||||
|
||||
const onExtractConfigConfirm = () => {
|
||||
extractDialog.visible = false;
|
||||
getList();
|
||||
};
|
||||
|
||||
const onExtractConfigDel = (row: any) => {
|
||||
ElMessageBox.confirm(`确定要删除「${row.reportName}」的抽取配置吗?`, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
await deleteExtractConfig(Number(row.extractConfigIds));
|
||||
ElMessage.success('删除成功');
|
||||
getList();
|
||||
} catch (_e) {
|
||||
ElMessage.error('删除失败');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
// ========== 按天回刷 ==========
|
||||
|
||||
const onDailyExtract = (row: any) => {
|
||||
dailyExtractDialog.businessCode = row.businessCode;
|
||||
dailyExtractDialog.reportCode = row.reportCode;
|
||||
dailyExtractDialog.dateRange = [];
|
||||
dailyExtractDialog.loading = false;
|
||||
dailyExtractDialog.visible = true;
|
||||
};
|
||||
|
||||
const onDailyExtractConfirm = async () => {
|
||||
if (!dailyExtractDialog.dateRange || dailyExtractDialog.dateRange.length !== 2) {
|
||||
ElMessage.warning('请选择日期范围');
|
||||
return;
|
||||
}
|
||||
dailyExtractDialog.loading = true;
|
||||
try {
|
||||
const res = await execDailyBackfill({
|
||||
businessCode: dailyExtractDialog.businessCode,
|
||||
reportCode: dailyExtractDialog.reportCode,
|
||||
startDate: dailyExtractDialog.dateRange[0],
|
||||
endDate: dailyExtractDialog.dateRange[1],
|
||||
});
|
||||
if (res.data?.success === false) {
|
||||
ElMessage.warning(res.data?.message || '回刷失败');
|
||||
return;
|
||||
}
|
||||
ElMessage.success('回刷任务已提交');
|
||||
dailyExtractDialog.visible = false;
|
||||
} catch (_e) {
|
||||
ElMessage.error('执行回刷失败');
|
||||
} finally {
|
||||
dailyExtractDialog.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 初始化 ==========
|
||||
|
||||
onMounted(() => {
|
||||
loadBusinesses();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.biz-tabs-header {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
.biz-tabs {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
:deep(.el-tabs__header) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__content) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item) {
|
||||
position: relative;
|
||||
height: 34px;
|
||||
line-height: 34px;
|
||||
padding: 0 10px;
|
||||
|
||||
// 关闭图标始终可见
|
||||
.is-icon-close {
|
||||
width: 14px !important;
|
||||
overflow: visible !important;
|
||||
opacity: 0.45;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.biz-add-btn {
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
height: 34px;
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
|
||||
.tab-name {
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-edit-btn {
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
margin-left: 3px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="编辑提示词" width="720px" :close-on-click-modal="false" destroy-on-close @close="handleClose">
|
||||
<div class="prompt-input-dialog">
|
||||
<el-input v-model="promptContent" type="textarea" :rows="12" maxlength="5000" show-word-limit placeholder="请输入提示词内容" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleConfirm">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
defaultPrompt?: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'confirm', promptContent: string): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
defaultPrompt: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = ref(false);
|
||||
const promptContent = ref('');
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
visible.value = val;
|
||||
if (val) {
|
||||
promptContent.value = props.defaultPrompt || '';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(visible, (val) => {
|
||||
if (!val) {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleConfirm = () => {
|
||||
emit('confirm', promptContent.value.trim());
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.prompt-input-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -240,18 +240,54 @@ const handleCreatePrivateModelAndSetChat = async () => {
|
||||
|
||||
// 基于内置模型创建新模型(继承原模型的所有配置,只替换 apiKey)
|
||||
const builtInModel = builtInModelToClone.value;
|
||||
|
||||
// Parse headMsg to Record<string, string> - it might be stored as string or already as object
|
||||
let headMsgRecord: Record<string, string> = {};
|
||||
if (builtInModel.headMsg && typeof builtInModel.headMsg === 'string') {
|
||||
// Try to parse as JSON first (new format stored as string)
|
||||
try {
|
||||
const parsed = JSON.parse(builtInModel.headMsg);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
Object.entries(parsed).forEach(([k, v]) => {
|
||||
headMsgRecord[k] = String(v);
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// If JSON parse fails, parse as old format "key1:value1,key2:value2"
|
||||
const pairs = builtInModel.headMsg.split(',');
|
||||
pairs.forEach((pair: string) => {
|
||||
const idx = pair.indexOf(':');
|
||||
if (idx === -1) return;
|
||||
const key = pair.slice(0, idx).trim();
|
||||
const value = pair.slice(idx + 1).trim();
|
||||
if (key) {
|
||||
headMsgRecord[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (builtInModel.headMsg && typeof builtInModel.headMsg === 'object' && !Array.isArray(builtInModel.headMsg)) {
|
||||
// Already an object
|
||||
Object.entries(builtInModel.headMsg).forEach(([k, v]) => {
|
||||
headMsgRecord[k] = String(v);
|
||||
});
|
||||
}
|
||||
|
||||
const createParams = {
|
||||
modelName: apiKeyForm.modelName,
|
||||
modelType: builtInModel.modelType,
|
||||
baseUrl: builtInModel.baseUrl,
|
||||
httpMethod: builtInModel.httpMethod || 'POST',
|
||||
headMsg: builtInModel.headMsg || '',
|
||||
headMsg: headMsgRecord,
|
||||
isPrivate: builtInModel.isPrivate ?? 1,
|
||||
enabled: builtInModel.enabled ?? 1,
|
||||
isChatModel: 1, // 设置为会话模型
|
||||
callMode: builtInModel.callMode ?? builtInModel.isAsync ?? 0,
|
||||
apiKey: apiKeyForm.apiKey,
|
||||
form: builtInModel.form || {},
|
||||
requestMapping: builtInModel.requestMapping || {},
|
||||
requiredFields: Array.isArray(builtInModel.requiredFields) ? builtInModel.requiredFields : [],
|
||||
firstFrame: String(builtInModel.firstFrame || ''),
|
||||
lastFrame: String(builtInModel.lastFrame || ''),
|
||||
responseMapping: builtInModel.responseMapping || {},
|
||||
responseBody: builtInModel.responseBody || {},
|
||||
tokenMapping: builtInModel.tokenMapping || '',
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
<template>
|
||||
<div class="system-model-type-container layout-padding">
|
||||
<el-card shadow="hover" class="layout-padding-auto">
|
||||
<div class="system-model-type-search mb15">
|
||||
<el-input v-model="state.tableData.param.keyword" size="default" placeholder="请输入模型类型名称" style="max-width: 180px" clearable> </el-input>
|
||||
<el-button size="default" type="primary" class="ml10" @click="getTableData">
|
||||
<el-icon>
|
||||
<ele-Search />
|
||||
</el-icon>
|
||||
查询
|
||||
</el-button>
|
||||
<el-button size="default" type="success" class="ml10" @click="onOpenAddType('add')">
|
||||
<el-icon>
|
||||
<ele-FolderAdd />
|
||||
</el-icon>
|
||||
新增模型类型
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="state.tableData.data" v-loading="state.tableData.loading" style="width: 100%">
|
||||
<el-table-column type="index" label="序号" width="60" />
|
||||
<el-table-column prop="typeName" label="类型名称" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="typeCode" label="类型编码" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="description" label="描述" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">{{ scope.row.status === 1 ? '启用' : '禁用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="scope">
|
||||
<el-button size="small" text type="primary" @click="onOpenEditType('edit', scope.row)">修改</el-button>
|
||||
<el-button size="small" text type="primary" @click="onRowDel(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
@size-change="onHandleSizeChange"
|
||||
@current-change="onHandleCurrentChange"
|
||||
class="mt15"
|
||||
:pager-count="5"
|
||||
:page-sizes="[10, 20, 30]"
|
||||
v-model:current-page="state.tableData.param.pageNum"
|
||||
background
|
||||
v-model:page-size="state.tableData.param.pageSize"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="state.tableData.total"
|
||||
>
|
||||
</el-pagination>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="digitalHumanModelType">
|
||||
import { reactive, onMounted } from 'vue';
|
||||
import { ElMessageBox, ElMessage } from 'element-plus';
|
||||
|
||||
const state = reactive({
|
||||
tableData: {
|
||||
data: [],
|
||||
total: 0,
|
||||
loading: false,
|
||||
param: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 初始化表格数据
|
||||
const getTableData = () => {
|
||||
state.tableData.loading = true;
|
||||
// TODO: 调用API获取数据
|
||||
setTimeout(() => {
|
||||
state.tableData.data = [];
|
||||
state.tableData.total = 0;
|
||||
state.tableData.loading = false;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 打开新增模型类型弹窗
|
||||
const onOpenAddType = (type: string) => {
|
||||
ElMessage.info('功能开发中...');
|
||||
};
|
||||
|
||||
// 打开修改模型类型弹窗
|
||||
const onOpenEditType = (type: string, row: any) => {
|
||||
ElMessage.info('功能开发中...');
|
||||
};
|
||||
|
||||
// 删除模型类型
|
||||
const onRowDel = (row: any) => {
|
||||
ElMessage.info('功能开发中...');
|
||||
};
|
||||
|
||||
// 分页改变
|
||||
const onHandleSizeChange = (val: number) => {
|
||||
state.tableData.param.pageSize = val;
|
||||
getTableData();
|
||||
};
|
||||
|
||||
// 分页改变
|
||||
const onHandleCurrentChange = (val: number) => {
|
||||
state.tableData.param.pageNum = val;
|
||||
getTableData();
|
||||
};
|
||||
|
||||
// 页面加载时
|
||||
onMounted(() => {
|
||||
getTableData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.system-model-type-container {
|
||||
:deep(.el-card__body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
.el-table {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,885 @@
|
||||
<template>
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="900px" :close-on-click-modal="false" destroy-on-close>
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px" v-loading="detailLoading">
|
||||
<!-- ========== 基础信息 ========== -->
|
||||
<el-divider content-position="left">基础信息</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="模型名称" prop="modelName">
|
||||
<el-input v-model="formData.modelName" placeholder="请输入模型名称(唯一标识)" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="模型类型" prop="modelType">
|
||||
<el-cascader
|
||||
v-model="formData.modelType"
|
||||
:options="modelTypes"
|
||||
:props="{ expandTrigger: 'hover', value: 'value', label: 'label', children: 'children' }"
|
||||
placeholder="请选择模型类型"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="模型供应商" prop="modelSupplier">
|
||||
<el-select
|
||||
v-model="formData.modelSupplier"
|
||||
placeholder="请选择模型供应商"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 100%"
|
||||
:loading="supplierLoading"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in supplierList"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="服务地址" prop="baseUrl">
|
||||
<el-input v-model="formData.baseUrl" placeholder="请输入模型服务地址" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- ========== 调用配置 ========== -->
|
||||
<el-divider content-position="left">调用配置</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="返回类型" prop="responseType">
|
||||
<el-select v-model="formData.responseType" placeholder="请选择返回类型" clearable style="width: 100%" @change="handleResponseTypeChange">
|
||||
<el-option label="同步" :value="1" />
|
||||
<el-option label="异步" :value="2" />
|
||||
<el-option label="流式" :value="3" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="API Key" prop="apiKey">
|
||||
<el-input v-model="formData.apiKey" type="password" show-password placeholder="请输入调用凭证/密钥" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||
<el-form-item label="对话模型" prop="chatModel">
|
||||
<el-switch v-model="formData.chatModel" :disabled="!isReasoningModel" />
|
||||
<span v-if="!isReasoningModel" class="form-hint">仅推理模型支持此设置</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||
<el-form-item label="启用" prop="enabled">
|
||||
<el-switch v-model="formData.enabled" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||
<!-- 占位对齐 -->
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="最大并发" prop="maxConcurrency">
|
||||
<el-input-number v-model="formData.maxConcurrency" :min="1" :max="1000" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="最大 Token 数" prop="maxTokens">
|
||||
<el-input-number v-model="formData.maxTokens" :min="0" :max="999999" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="Token 预估价格" prop="tokenPredictPrice">
|
||||
<el-input-number v-model="formData.tokenPredictPrice" :min="0" :precision="4" :step="0.0001" style="width: calc(100% - 148px); margin-right: 8px" />
|
||||
<el-select v-model="formData.tokenPredictPriceUnit" placeholder="单位" clearable style="width: 140px">
|
||||
<el-option label="秒" value="second" />
|
||||
<el-option label="百万 Token" value="per_million_tokens" />
|
||||
<el-option label="千 Token" value="per_kilo_tokens" />
|
||||
<el-option label="字数" value="per_word" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- ========== 映射配置 ========== -->
|
||||
<el-divider content-position="left">映射配置</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
|
||||
<el-form-item label="请求头映射">
|
||||
<KeyValueEditor v-model="formData.requestHeadMapping" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6" class="mb20">
|
||||
<el-form-item label="请求体映射">
|
||||
<el-button @click="openMappingEditor('requestBodyMapping')" style="width: 100%">
|
||||
配置 ({{ Object.keys(formData.requestBodyMapping).length }})
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6" class="mb20">
|
||||
<el-form-item label="响应映射">
|
||||
<el-button @click="openMappingEditor('responseMapping')" style="width: 100%">
|
||||
配置 ({{ Object.keys(formData.responseMapping).length }})
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6" class="mb20">
|
||||
<el-form-item label="错误消息映射">
|
||||
<el-button @click="openMappingEditor('error_message_mapping')" style="width: 100%">
|
||||
配置 ({{ Object.keys(formData.error_message_mapping || {}).length }})
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="6" :md="6" :lg="6" :xl="6" class="mb20" v-if="formData.responseType !== 2">
|
||||
<el-form-item label="响应主体映射">
|
||||
<el-select
|
||||
v-model="formData.responseBodyMapping"
|
||||
multiple
|
||||
:disabled="!hasMappingConfig"
|
||||
:placeholder="mappingPlaceholder"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in tokenMappingOptions"
|
||||
:key="item.path"
|
||||
:label="`${item.path} — ${item.label}`"
|
||||
:value="item.path"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
</el-row>
|
||||
<!-- ========== 异步专属 ========== -->
|
||||
<template v-if="formData.responseType === 2">
|
||||
<el-divider content-position="left">异步映射配置</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||
<el-form-item label="异步响应映射">
|
||||
<el-button @click="openMappingEditor('asyncResponseMapping')" style="width: 100%">
|
||||
配置 ({{ Object.keys((formData.asyncTaskMapping.responseMapping || {})).length }})
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||
<el-form-item label="请求方法">
|
||||
<el-select v-model="formData.asyncTaskMapping.httpMethod" style="width: 100%">
|
||||
<el-option label="POST" value="POST" />
|
||||
<el-option label="GET" value="GET" />
|
||||
<el-option label="PUT" value="PUT" />
|
||||
<el-option label="DELETE" value="DELETE" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||
<el-form-item label="回调 URL">
|
||||
<el-input v-model="formData.asyncTaskMapping.url" placeholder="请输入异步回调地址" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="请求体映射">
|
||||
<el-button @click="openMappingEditor('asyncRequestBodyMapping')" style="width: 100%">
|
||||
配置 ({{ Object.keys((formData.asyncTaskMapping.requestBodyMapping || {})).length }})
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="响应主体映射">
|
||||
<el-select
|
||||
v-model="formData.responseBodyMapping"
|
||||
multiple
|
||||
:disabled="!hasMappingConfig"
|
||||
:placeholder="mappingPlaceholder"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in tokenMappingOptions"
|
||||
:key="item.path"
|
||||
:label="`${item.path} — ${item.label}`"
|
||||
:value="item.path"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
|
||||
<el-form-item label="异步请求头映射">
|
||||
<KeyValueEditor v-model="formData.asyncTaskMapping.requestHeadMapping" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-divider content-position="left" class="sub-divider">任务状态字段路径</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="Task ID 路径">
|
||||
<el-select v-model="formData.asyncTaskMapping.taskId" :disabled="!hasTaskIdConfig" :placeholder="hasTaskIdConfig ? '请选择 Task ID 路径' : '请先配置响应映射'" clearable style="width: 100%">
|
||||
<el-option v-for="item in taskIdOptions" :key="item.path" :label="`${item.path} — ${item.label}`" :value="item.path" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="任务状态路径">
|
||||
<el-select v-model="formData.asyncTaskMapping.taskStatus" :disabled="!hasTaskStatusConfig" :placeholder="hasTaskStatusConfig ? '请选择任务状态路径' : '请先配置回调响应映射'" clearable style="width: 100%">
|
||||
<el-option v-for="item in taskStatusOptions" :key="item.path" :label="`${item.path} — ${item.label}`" :value="item.path" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||
<el-form-item label="待处理值">
|
||||
<el-input v-model="formData.asyncTaskMapping.taskStatusPending" placeholder="pending" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||
<el-form-item label="运行中值">
|
||||
<el-input v-model="formData.asyncTaskMapping.taskStatusRunning" placeholder="running" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||
<el-form-item label="成功值">
|
||||
<el-input v-model="formData.asyncTaskMapping.taskStatusSuccess" placeholder="succeeded" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||
<el-form-item label="失败值">
|
||||
<el-input v-model="formData.asyncTaskMapping.taskStatusFailed" placeholder="failed" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||
<el-form-item label="取消值">
|
||||
<el-input v-model="formData.asyncTaskMapping.taskStatusCancel" placeholder="canceled" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||
<el-form-item label="未知值">
|
||||
<el-input v-model="formData.asyncTaskMapping.taskStatusUnknown" placeholder="unknown" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<el-divider content-position="left">Token 映射配置</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||
<el-form-item label="输入 Token 路径">
|
||||
<el-select v-model="formData.tokenMapping.promptTokens" :disabled="!hasMappingConfig" :placeholder="mappingPlaceholder" clearable style="width: 100%">
|
||||
<el-option v-for="item in tokenMappingOptions" :key="item.path" :label="`${item.path} — ${item.label}`" :value="item.path" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||
<el-form-item label="输出 Token 路径">
|
||||
<el-select v-model="formData.tokenMapping.completionTokens" :disabled="!hasMappingConfig" :placeholder="mappingPlaceholder" clearable style="width: 100%">
|
||||
<el-option v-for="item in tokenMappingOptions" :key="item.path" :label="`${item.path} — ${item.label}`" :value="item.path" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8" :md="8" :lg="8" :xl="8" class="mb20">
|
||||
<el-form-item label="总 Token 路径">
|
||||
<el-select v-model="formData.tokenMapping.totalTokens" :disabled="!hasMappingConfig" :placeholder="mappingPlaceholder" clearable style="width: 100%">
|
||||
<el-option v-for="item in tokenMappingOptions" :key="item.path" :label="`${item.path} — ${item.label}`" :value="item.path" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- ========== 视频配置 ========== -->
|
||||
<template v-if="isVideoModel">
|
||||
<el-divider content-position="left">视频配置</el-divider>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="尾帧图像">
|
||||
<el-input v-model="formData.lastFrame" placeholder="视频的尾帧图像 URL" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
|
||||
<el-form-item label="最大视频时长(秒)">
|
||||
<el-input-number v-model="formData.maxDuration" :min="0" :max="999999" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
</el-form>
|
||||
|
||||
<!-- JSON 编辑器弹窗 -->
|
||||
<el-dialog v-model="jsonEditorVisible" :title="jsonEditorTitle" width="1200px" top="5vh" :close-on-click-modal="false" destroy-on-close append-to-body>
|
||||
<template #header>
|
||||
<span>{{ jsonEditorTitle }}</span>
|
||||
<div class="json-editor-toolbar">
|
||||
<el-button size="small" @click="handlePasteJson">粘贴 JSON</el-button>
|
||||
<el-button size="small" @click="handleViewJson">查看 JSON</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div style="height: 520px; overflow-y: auto">
|
||||
<JsonEditor ref="jsonEditorRef" v-model="jsonEditorData" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="jsonEditorVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmJsonEditor">确 定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 查看 JSON 弹窗 -->
|
||||
<el-dialog v-model="viewJsonVisible" title="查看 JSON" width="700px" :close-on-click-modal="false" destroy-on-close append-to-body>
|
||||
<el-input type="textarea" :rows="18" v-model="viewJsonText" readonly />
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="viewJsonVisible = false">关 闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">{{ submitText }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="modelConfigV2EditModule">
|
||||
import { ref, reactive, computed, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
import { JsonEditor } from '/@/components/json-schema-editor';
|
||||
import {
|
||||
createModelManage,
|
||||
updateModelManage,
|
||||
getModelManage,
|
||||
getModelSupplierList,
|
||||
type ModelTypeTreeNode,
|
||||
type ModelSupplierItem,
|
||||
type TokenMapping,
|
||||
type AsyncTaskMapping,
|
||||
} from '/@/api/settings/modelConfigV2';
|
||||
import KeyValueEditor from './keyValueEditor.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelTypes?: ModelTypeTreeNode[];
|
||||
}>(),
|
||||
{
|
||||
modelTypes: () => [] as ModelTypeTreeNode[],
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
}>();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const dialogVisible = ref(false);
|
||||
const submitting = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const editId = ref('');
|
||||
const supplierList = ref<ModelSupplierItem[]>([]);
|
||||
const supplierLoading = ref(false);
|
||||
|
||||
const dialogTitle = computed(() => (isEdit.value ? '修改模型配置' : '新增模型配置'));
|
||||
const submitText = computed(() => (isEdit.value ? '保存修改' : '确 定'));
|
||||
|
||||
const defaultTokenMapping: TokenMapping = {
|
||||
promptTokens: '',
|
||||
completionTokens: '',
|
||||
totalTokens: '',
|
||||
};
|
||||
|
||||
const defaultAsyncTaskMapping: AsyncTaskMapping = {
|
||||
url: '',
|
||||
httpMethod: 'POST',
|
||||
requestHeadMapping: {},
|
||||
requestBodyMapping: {},
|
||||
responseMapping: {},
|
||||
taskId: '',
|
||||
taskStatus: '',
|
||||
taskStatusPending: '',
|
||||
taskStatusRunning: '',
|
||||
taskStatusSuccess: '',
|
||||
taskStatusFailed: '',
|
||||
taskStatusCancel: '',
|
||||
taskStatusUnknown: '',
|
||||
};
|
||||
|
||||
const formData = reactive({
|
||||
modelName: '',
|
||||
modelType: [] as number[],
|
||||
modelSupplier: undefined as number | undefined,
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
responseType: undefined as number | undefined,
|
||||
enabled: true,
|
||||
chatModel: false,
|
||||
maxConcurrency: 10,
|
||||
maxTokens: 4096,
|
||||
tokenPredictPrice: 0,
|
||||
// 映射
|
||||
requestHeadMapping: {} as Record<string, unknown>,
|
||||
requestBodyMapping: {} as Record<string, unknown>,
|
||||
error_message_mapping: {} as Record<string, unknown>,
|
||||
responseMapping: {} as Record<string, unknown>,
|
||||
responseBodyMapping: [] as string[],
|
||||
// Token 映射
|
||||
tokenMapping: { ...defaultTokenMapping },
|
||||
// 异步任务映射
|
||||
asyncTaskMapping: { ...defaultAsyncTaskMapping },
|
||||
// 视频
|
||||
lastFrame: '',
|
||||
maxDuration: 0,
|
||||
tokenPredictPriceUnit: '',
|
||||
});
|
||||
|
||||
/** 获取选中模型类型路径上的所有标签 */
|
||||
function getSelectedModelLabels(): string[] {
|
||||
const path = formData.modelType;
|
||||
if (!path || path.length === 0) return [];
|
||||
const labels: string[] = [];
|
||||
let currentLevel = props.modelTypes;
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const found = currentLevel.find((n) => n.value === path[i]);
|
||||
if (!found) break;
|
||||
labels.push(found.label);
|
||||
currentLevel = found.children || [];
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
/** 从模型类型树中查找叶子值对应的完整路径(el-cascader 需要路径而非叶子值) */
|
||||
function findModelTypePath(value: number, tree: ModelTypeTreeNode[]): number[] {
|
||||
for (const node of tree) {
|
||||
if (node.value === value) return [value];
|
||||
if (node.children) {
|
||||
const found = node.children.find((c) => c.value === value);
|
||||
if (found) return [node.value, value];
|
||||
}
|
||||
}
|
||||
return [value];
|
||||
}
|
||||
|
||||
/** 判断是否是视频模型类型 */
|
||||
const isVideoModel = computed(() => {
|
||||
return getSelectedModelLabels().some((l) => l.includes('视频') || l.toLowerCase().includes('video'));
|
||||
});
|
||||
|
||||
/** 判断是否是推理模型类型 */
|
||||
const isReasoningModel = computed(() => {
|
||||
return getSelectedModelLabels().some((l) => l.includes('推理') || l.toLowerCase().includes('reasoning'));
|
||||
});
|
||||
|
||||
/** 非推理模型时自动关闭对话模型开关 */
|
||||
watch(isReasoningModel, (val) => {
|
||||
if (!val) formData.chatModel = false;
|
||||
});
|
||||
|
||||
|
||||
/** 递归提取 { type, attrs/value } 包裹结构中的所有叶子字段 */
|
||||
function flattenWrappedFields(obj: any, basePath: string = ''): { path: string; label: string }[] {
|
||||
const results: { path: string; label: string }[] = [];
|
||||
if (!obj || typeof obj !== 'object') return results;
|
||||
|
||||
// 检测 { type, attrs/value } 包裹格式
|
||||
if (typeof obj.type === 'string' && ['string','number','boolean','null','object','array'].includes(obj.type)) {
|
||||
const jtype = obj.type;
|
||||
const dataKey = (jtype === 'object' || jtype === 'array') ? 'attrs' : 'value';
|
||||
|
||||
if (jtype === 'object' && dataKey in obj && obj[dataKey] && typeof obj[dataKey] === 'object' && !Array.isArray(obj[dataKey])) {
|
||||
for (const [key, val] of Object.entries(obj[dataKey])) {
|
||||
const childPath = basePath ? basePath + '.attrs.' + key : key;
|
||||
results.push(...flattenWrappedFields(val, childPath));
|
||||
}
|
||||
} else if (jtype === 'array' && dataKey in obj && Array.isArray(obj[dataKey])) {
|
||||
obj[dataKey].forEach((item: any, idx: number) => {
|
||||
results.push(...flattenWrappedFields(item, basePath + '.attrs[' + idx + ']'));
|
||||
});
|
||||
} else if (['string', 'number', 'boolean', 'null'].includes(jtype)) {
|
||||
results.push({ path: basePath, label: obj.label || '' });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// 普通对象(非包裹格式)
|
||||
if (!Array.isArray(obj)) {
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
const childPath = basePath ? basePath + '.' + key : key;
|
||||
results.push(...flattenWrappedFields(val, childPath));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** 根据返回类型获取 Token 映射下拉选项(响应映射的 key — 路径) */
|
||||
const tokenMappingOptions = computed(() => {
|
||||
const source = formData.responseType === 2
|
||||
? formData.asyncTaskMapping.responseMapping
|
||||
: formData.responseMapping;
|
||||
return source ? flattenWrappedFields(source) : [];
|
||||
});
|
||||
|
||||
/** 是否有可用的映射配置 */
|
||||
const hasMappingConfig = computed(() => tokenMappingOptions.value.length > 0);
|
||||
|
||||
/** 下拉占位文本 */
|
||||
const mappingPlaceholder = computed(() => {
|
||||
if (hasMappingConfig.value) return '请选择';
|
||||
return formData.responseType === 2 ? '请先配置回调响应映射' : '请先配置响应映射';
|
||||
});
|
||||
|
||||
/** Task ID 路径下拉选项(来源响应映射) */
|
||||
const taskIdOptions = computed(() => {
|
||||
const source = formData.responseMapping;
|
||||
return source ? flattenWrappedFields(source) : [];
|
||||
});
|
||||
|
||||
/** 是否有 Task ID 映射配置 */
|
||||
const hasTaskIdConfig = computed(() => taskIdOptions.value.length > 0);
|
||||
|
||||
/** 任务状态路径下拉选项(来源回调响应映射) */
|
||||
const taskStatusOptions = computed(() => {
|
||||
const source = formData.asyncTaskMapping.responseMapping;
|
||||
return source ? flattenWrappedFields(source) : [];
|
||||
});
|
||||
|
||||
/** 是否有任务状态映射配置 */
|
||||
const hasTaskStatusConfig = computed(() => taskStatusOptions.value.length > 0);
|
||||
|
||||
/** 用户手动切换返回类型时清空 token 映射(编程赋值不触发) */
|
||||
function handleResponseTypeChange() {
|
||||
formData.tokenMapping = { ...defaultTokenMapping };
|
||||
formData.responseBodyMapping = [];
|
||||
}
|
||||
|
||||
|
||||
/** JSON 编辑器弹窗状态 */
|
||||
const jsonEditorVisible = ref(false);
|
||||
const jsonEditorData = ref<Record<string, unknown>>({});
|
||||
const editingMappingKey = ref<string>('');
|
||||
const jsonEditorRef = ref();
|
||||
|
||||
const mappingFieldLabels: Record<string, string> = {
|
||||
requestHeadMapping: '请求头映射',
|
||||
requestBodyMapping: '请求体映射',
|
||||
responseMapping: '响应映射',
|
||||
error_message_mapping: '错误消息映射',
|
||||
asyncResponseMapping: '异步-响应映射',
|
||||
asyncRequestBodyMapping: '异步-请求体映射',
|
||||
};
|
||||
|
||||
const jsonEditorTitle = computed(() => {
|
||||
const label = mappingFieldLabels[editingMappingKey.value] || editingMappingKey.value;
|
||||
return `编辑 ${label}`;
|
||||
});
|
||||
|
||||
/** 获取映射字段的引用 */
|
||||
function getMappingValue(key: string): Record<string, unknown> {
|
||||
if (key === 'asyncResponseMapping') return formData.asyncTaskMapping.responseMapping;
|
||||
if (key === 'asyncRequestBodyMapping') return formData.asyncTaskMapping.requestBodyMapping || {};
|
||||
return (formData as Record<string, unknown>)[key] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 设置映射字段的值 */
|
||||
function setMappingValue(key: string, value: Record<string, unknown>) {
|
||||
if (key === 'asyncResponseMapping') {
|
||||
formData.asyncTaskMapping.responseMapping = value;
|
||||
} else if (key === 'asyncRequestBodyMapping') {
|
||||
formData.asyncTaskMapping.requestBodyMapping = value;
|
||||
} else {
|
||||
(formData as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const openMappingEditor = (key: string) => {
|
||||
editingMappingKey.value = key;
|
||||
jsonEditorData.value = { ...getMappingValue(key) };
|
||||
jsonEditorVisible.value = true;
|
||||
};
|
||||
|
||||
/** 递归将 schema 中所有标量字段的 value 重置为 null。
|
||||
* 模型配置保存的是「结构模板」:保留 value 键与结构、defaultValue 等配置,但数据值不预设。
|
||||
* defaultValue 已有专门字段承载默认值,value 不再携带任何值,统一置 null。
|
||||
* 根节点经 nodeToValue 输出时不带 type/attrs 包装(普通对象),故需对无 type 节点也递归子键。
|
||||
*/
|
||||
function resetSchemaValues(schema: any): any {
|
||||
if (schema === null || schema === undefined) return schema;
|
||||
if (Array.isArray(schema)) {
|
||||
schema.forEach((item, i) => {
|
||||
schema[i] = resetSchemaValues(item);
|
||||
});
|
||||
return schema;
|
||||
}
|
||||
if (typeof schema !== 'object') return schema;
|
||||
if (typeof schema.type === 'string') {
|
||||
switch (schema.type) {
|
||||
case 'object':
|
||||
if (schema.attrs && typeof schema.attrs === 'object') {
|
||||
Object.keys(schema.attrs).forEach((k) => {
|
||||
schema.attrs[k] = resetSchemaValues(schema.attrs[k]);
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'array':
|
||||
if (Array.isArray(schema.attrs)) {
|
||||
schema.attrs = schema.attrs.map((item: any) => resetSchemaValues(item));
|
||||
}
|
||||
break;
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'boolean':
|
||||
case 'null':
|
||||
schema.value = null;
|
||||
break;
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
// 普通对象(如根 object 或未包装节点):递归子键
|
||||
Object.keys(schema).forEach((k) => {
|
||||
schema[k] = resetSchemaValues(schema[k]);
|
||||
});
|
||||
return schema;
|
||||
}
|
||||
|
||||
const confirmJsonEditor = () => {
|
||||
const cleaned = resetSchemaValues(JSON.parse(JSON.stringify(jsonEditorData.value)));
|
||||
setMappingValue(editingMappingKey.value, cleaned);
|
||||
jsonEditorVisible.value = false;
|
||||
};
|
||||
|
||||
const handlePasteJson = () => {
|
||||
jsonEditorRef.value?.openPasteDialog();
|
||||
};
|
||||
|
||||
const handleViewJson = () => {
|
||||
viewJsonText.value = JSON.stringify(jsonEditorData.value, null, 2);
|
||||
viewJsonVisible.value = true;
|
||||
};
|
||||
|
||||
/** 查看 JSON 弹窗 */
|
||||
const viewJsonVisible = ref(false);
|
||||
const viewJsonText = ref('');
|
||||
|
||||
const formRules: FormRules = {
|
||||
modelName: [{ required: true, message: '请输入模型名称', trigger: 'blur' }],
|
||||
modelType: [
|
||||
{
|
||||
validator: (_rule: unknown, value: unknown, callback: (e?: Error) => void) => {
|
||||
if (!value || (Array.isArray(value) && value.length === 0)) {
|
||||
callback(new Error('请选择模型类型'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
trigger: 'change',
|
||||
},
|
||||
],
|
||||
modelSupplier: [{ required: true, message: '请选择模型供应商', trigger: 'change' }],
|
||||
baseUrl: [{ required: true, message: '请输入模型服务地址', trigger: 'blur' }],
|
||||
responseType: [{ required: true, message: '请选择返回类型', trigger: 'change' }],
|
||||
};
|
||||
|
||||
const loadSupplierList = async () => {
|
||||
supplierLoading.value = true;
|
||||
try {
|
||||
const res = await getModelSupplierList();
|
||||
supplierList.value = res.data?.list || [];
|
||||
} catch {
|
||||
supplierList.value = [];
|
||||
} finally {
|
||||
supplierLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
formData.modelName = '';
|
||||
formData.modelType = [];
|
||||
formData.modelSupplier = undefined;
|
||||
formData.baseUrl = '';
|
||||
formData.apiKey = '';
|
||||
formData.responseType = undefined;
|
||||
formData.enabled = true;
|
||||
formData.chatModel = false;
|
||||
formData.maxConcurrency = 10;
|
||||
formData.maxTokens = 4096;
|
||||
formData.tokenPredictPrice = 0;
|
||||
formData.requestHeadMapping = {};
|
||||
formData.requestBodyMapping = {};
|
||||
formData.error_message_mapping = {};
|
||||
formData.responseMapping = {};
|
||||
formData.responseBodyMapping = [];
|
||||
formData.tokenMapping = { ...defaultTokenMapping };
|
||||
formData.asyncTaskMapping = { ...defaultAsyncTaskMapping };
|
||||
formData.lastFrame = '';
|
||||
formData.maxDuration = 0;
|
||||
formData.tokenPredictPriceUnit = '';
|
||||
};
|
||||
|
||||
const openDialog = async (type: 'add' | 'edit', id?: string) => {
|
||||
resetForm();
|
||||
isEdit.value = type === 'edit';
|
||||
detailLoading.value = type === 'edit';
|
||||
loadSupplierList();
|
||||
|
||||
if (type === 'edit' && id) {
|
||||
editId.value = id;
|
||||
try {
|
||||
const res = await getModelManage(id);
|
||||
const row = res.data.modelManage;
|
||||
formData.modelName = row.modelName;
|
||||
formData.modelType = findModelTypePath(row.modelType, props.modelTypes);
|
||||
formData.modelSupplier = Number(row.modelSupplier) || undefined;
|
||||
formData.baseUrl = row.baseUrl;
|
||||
formData.apiKey = row.apiKey || '';
|
||||
formData.responseType = row.responseType ?? row.invokeType;
|
||||
formData.enabled = row.enabled ?? true;
|
||||
formData.chatModel = row.chatModel ?? row.ChatModel ?? false;
|
||||
formData.maxConcurrency = row.maxConcurrency || 10;
|
||||
formData.maxTokens = row.maxTokens || 4096;
|
||||
formData.tokenPredictPrice = row.tokenPredictPrice ?? 0;
|
||||
formData.requestHeadMapping = row.requestHeadMapping || row.requestMapping || {};
|
||||
formData.requestBodyMapping = row.requestBodyMapping || {};
|
||||
formData.error_message_mapping = row.error_message_mapping || {};
|
||||
formData.responseMapping = row.responseMapping || {};
|
||||
formData.responseBodyMapping = row.responseBodyMapping
|
||||
? (Array.isArray(row.responseBodyMapping) ? row.responseBodyMapping : Object.keys(row.responseBodyMapping))
|
||||
: [];
|
||||
formData.tokenMapping = row.tokenMapping ? { ...defaultTokenMapping, ...row.tokenMapping } : { ...defaultTokenMapping };
|
||||
formData.asyncTaskMapping = row.asyncTaskMapping
|
||||
? {
|
||||
...defaultAsyncTaskMapping,
|
||||
...row.asyncTaskMapping,
|
||||
requestHeadMapping: row.asyncTaskMapping.requestHeadMapping || {},
|
||||
requestBodyMapping: row.asyncTaskMapping.requestBodyMapping || {},
|
||||
responseMapping: row.asyncTaskMapping.responseMapping || {},
|
||||
}
|
||||
: { ...defaultAsyncTaskMapping };
|
||||
formData.lastFrame = row.lastFrame || '';
|
||||
formData.maxDuration = row.maxDuration ?? 0;
|
||||
formData.tokenPredictPriceUnit = row.tokenPredictPriceUnit || '';
|
||||
} catch {
|
||||
// 关闭弹窗,错误由全局处理
|
||||
dialogVisible.value = false;
|
||||
return;
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
// 响应主体映射的 value 直接填对应字段的中文 label(无 label 时兜底为路径本身),不再用空串占位
|
||||
const responseBodyLabelMap = new Map<string, string>(
|
||||
tokenMappingOptions.value.map((o) => [o.path, o.label || o.path])
|
||||
);
|
||||
const baseParams = {
|
||||
modelName: formData.modelName,
|
||||
modelType: Array.isArray(formData.modelType) ? formData.modelType[formData.modelType.length - 1] : formData.modelType,
|
||||
modelSupplier: formData.modelSupplier as number,
|
||||
baseUrl: formData.baseUrl,
|
||||
responseType: formData.responseType as number,
|
||||
apiKey: formData.apiKey || undefined,
|
||||
enabled: formData.enabled,
|
||||
chatModel: formData.chatModel,
|
||||
maxConcurrency: formData.maxConcurrency,
|
||||
maxTokens: formData.maxTokens,
|
||||
tokenPredictPrice: formData.tokenPredictPrice,
|
||||
requestHeadMapping: Object.keys(formData.requestHeadMapping).length > 0 ? formData.requestHeadMapping : undefined,
|
||||
requestBodyMapping: Object.keys(formData.requestBodyMapping).length > 0 ? formData.requestBodyMapping : undefined,
|
||||
error_message_mapping: Object.keys(formData.error_message_mapping).length > 0 ? formData.error_message_mapping : undefined,
|
||||
responseMapping: Object.keys(formData.responseMapping).length > 0 ? formData.responseMapping : undefined,
|
||||
responseBodyMapping: formData.responseBodyMapping.length > 0
|
||||
? Object.fromEntries(formData.responseBodyMapping.map((k) => [k, responseBodyLabelMap.get(k) || k]))
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// 根据返回类型附加对应配置
|
||||
let tokenMapping: TokenMapping | undefined;
|
||||
let asyncTaskMapping: AsyncTaskMapping | undefined;
|
||||
let lastFrame: string | undefined;
|
||||
|
||||
// 所有返回类型共用 Token 映射(存的是响应映射中的 key)
|
||||
const hasTokenConfig = Object.values(formData.tokenMapping).some((v) => v);
|
||||
tokenMapping = hasTokenConfig ? { ...formData.tokenMapping } : undefined;
|
||||
|
||||
if (formData.responseType === 2) {
|
||||
const atm = formData.asyncTaskMapping;
|
||||
const hasAsyncConfig =
|
||||
atm.url ||
|
||||
Object.keys(atm.requestHeadMapping).length > 0 ||
|
||||
Object.keys(atm.requestBodyMapping || {}).length > 0 ||
|
||||
atm.taskId;
|
||||
asyncTaskMapping = hasAsyncConfig ? ({ ...atm } as AsyncTaskMapping) : undefined;
|
||||
}
|
||||
|
||||
if (isVideoModel.value && formData.lastFrame) {
|
||||
lastFrame = formData.lastFrame;
|
||||
}
|
||||
let maxDuration: number | undefined;
|
||||
let tokenPredictPriceUnit: string | undefined;
|
||||
if (isVideoModel.value) {
|
||||
maxDuration = formData.maxDuration || undefined;
|
||||
}
|
||||
if (formData.tokenPredictPriceUnit) {
|
||||
tokenPredictPriceUnit = formData.tokenPredictPriceUnit;
|
||||
}
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
...baseParams,
|
||||
...(tokenMapping ? { tokenMapping } : {}),
|
||||
...(asyncTaskMapping ? { asyncTaskMapping } : {}),
|
||||
...(lastFrame ? { lastFrame } : {}),
|
||||
...(maxDuration ? { maxDuration } : {}),
|
||||
...(tokenPredictPriceUnit ? { tokenPredictPriceUnit } : {}),
|
||||
};
|
||||
|
||||
if (isEdit.value) {
|
||||
await updateModelManage({ id: editId.value, ...params } as any);
|
||||
ElMessage.success('修改成功');
|
||||
} else {
|
||||
await createModelManage(params as any);
|
||||
ElMessage.success('新增成功');
|
||||
}
|
||||
|
||||
dialogVisible.value = false;
|
||||
emit('refresh');
|
||||
} catch {
|
||||
// 接口错误由 request 全局提示
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({ openDialog });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mb20 {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
::v-deep(.sub-divider .el-divider__text) {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.json-editor-toolbar {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="kv-editor">
|
||||
<div v-for="(item, index) in kvList" :key="index" class="kv-row">
|
||||
<el-input v-model="item.key" placeholder="key" size="small" class="kv-key" @input="emitUpdate" />
|
||||
<el-input v-model="item.value" placeholder="value" size="small" class="kv-value" @input="emitUpdate" />
|
||||
<el-button size="small" text type="danger" class="kv-del" @click="removeRow(index)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button size="small" class="kv-add" @click="addRow">
|
||||
<el-icon><Plus /></el-icon> 添加
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { Plus, Delete } from '@element-plus/icons-vue';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Record<string, unknown>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', val: Record<string, unknown>): void;
|
||||
}>();
|
||||
|
||||
const kvList = ref<{ key: string; value: string }[]>([]);
|
||||
|
||||
function toKvList(obj: Record<string, unknown>): { key: string; value: string }[] {
|
||||
return Object.entries(obj || {}).map(([k, v]) => ({
|
||||
key: k,
|
||||
value: typeof v === 'string' ? v : JSON.stringify(v),
|
||||
}));
|
||||
}
|
||||
|
||||
function toRecord(list: { key: string; value: string }[]): Record<string, unknown> {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const item of list) {
|
||||
if (item.key.trim()) {
|
||||
obj[item.key.trim()] = item.value;
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
function emitUpdate() {
|
||||
emit('update:modelValue', toRecord(kvList.value));
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
kvList.value.push({ key: '', value: '' });
|
||||
}
|
||||
|
||||
function removeRow(index: number) {
|
||||
kvList.value.splice(index, 1);
|
||||
emitUpdate();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
kvList.value = toKvList(val);
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.kv-editor {
|
||||
width: 100%;
|
||||
.kv-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
align-items: center;
|
||||
.kv-key {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.kv-value {
|
||||
flex: 2;
|
||||
min-width: 0;
|
||||
}
|
||||
.kv-del {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
.kv-add {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<div class="system-user-container layout-padding">
|
||||
<el-card shadow="hover" class="layout-padding-auto">
|
||||
<div class="system-user-search mb15">
|
||||
<el-input
|
||||
v-model="searchParams.modelName"
|
||||
size="default"
|
||||
placeholder="请输入模型名称"
|
||||
style="max-width: 180px"
|
||||
clearable
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
<el-cascader
|
||||
v-model="selectedCascaderPath"
|
||||
:options="modelTypes"
|
||||
:props="{ expandTrigger: 'hover' }"
|
||||
clearable
|
||||
placeholder="请选择模型类型"
|
||||
style="max-width: 180px"
|
||||
class="ml10"
|
||||
@change="onCascaderChange"
|
||||
/>
|
||||
<el-button size="default" type="primary" class="ml10" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
查询
|
||||
</el-button>
|
||||
<el-button size="default" type="success" class="ml10" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增模型配置
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="modelList" v-loading="loading" stripe style="width: 100%" class="fixed-table">
|
||||
<el-table-column type="index" label="序号" width="55" />
|
||||
<el-table-column prop="modelName" label="模型名称" show-overflow-tooltip />
|
||||
<el-table-column label="模型类型" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ resolveModelTypeLabel(row.modelType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="模型归属">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.systemModel ? 'warning' : 'success'">{{ scope.row.systemModel ? '系统' : '用户' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="供应商" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
{{ resolveSupplierLabel(scope.row.modelSupplier) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.enabled ? 'success' : 'danger'">{{ scope.row.enabled ? '启用' : '禁用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" show-overflow-tooltip />
|
||||
<el-table-column prop="updatedAt" label="更新时间" show-overflow-tooltip />
|
||||
<el-table-column label="操作" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" text type="primary" @click="handleEdit(scope.row)">修改</el-button>
|
||||
<el-button size="small" text type="danger" @click="handleDelete(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
class="mt15"
|
||||
:pager-count="5"
|
||||
:page-sizes="[10, 20, 30, 50]"
|
||||
v-model:current-page="pagination.pageNum"
|
||||
background
|
||||
v-model:page-size="pagination.pageSize"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="pagination.total"
|
||||
/>
|
||||
</el-card>
|
||||
<EditModule ref="editModuleRef" :model-types="modelTypes" @refresh="fetchModelList" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="modelConfigV2">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Search, Plus } from '@element-plus/icons-vue';
|
||||
import EditModule from './component/editModule.vue';
|
||||
import { listModelManage, getModelManageTypes, deleteModelManage, getModelSupplierList, type ModelManageItem, type ModelTypeTreeNode, type ModelSupplierItem } from '/@/api/settings/modelConfigV2';
|
||||
|
||||
const searchParams = reactive({
|
||||
modelName: '',
|
||||
modelType: undefined as number | undefined,
|
||||
});
|
||||
|
||||
const pagination = reactive({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const modelList = ref<ModelManageItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const modelTypes = ref<ModelTypeTreeNode[]>([]);
|
||||
const supplierList = ref<ModelSupplierItem[]>([]);
|
||||
const editModuleRef = ref();
|
||||
|
||||
/** 级联选择器的选中路径(例:[200, 201]) */
|
||||
const selectedCascaderPath = ref<number[]>([]);
|
||||
|
||||
/** 级联选择器值变化时,取叶子节点的值作为筛选参数 */
|
||||
const onCascaderChange = (val: number[]) => {
|
||||
searchParams.modelType = val.length > 0 ? val[val.length - 1] : undefined;
|
||||
};
|
||||
|
||||
/** 从树形结构中查找标签(含一级/二级路径) */
|
||||
const resolveModelTypeLabel = (modelType: number | string | undefined | null): string => {
|
||||
if (modelType === undefined || modelType === null || modelType === '') {
|
||||
return '—';
|
||||
}
|
||||
const key = String(modelType).trim();
|
||||
for (const group of modelTypes.value) {
|
||||
if (group.children) {
|
||||
const child = group.children.find((c) => String(c.value).trim() === key);
|
||||
if (child) return `${group.label} / ${child.label}`;
|
||||
}
|
||||
if (String(group.value).trim() === key) {
|
||||
return group.label;
|
||||
}
|
||||
}
|
||||
return String(modelType);
|
||||
};
|
||||
|
||||
/** 加载模型类型选项(树形结构,供 el-cascader 使用) */
|
||||
const loadModelTypes = async () => {
|
||||
try {
|
||||
const res = await getModelManageTypes();
|
||||
modelTypes.value = res.data?.list || [];
|
||||
} catch {
|
||||
modelTypes.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
/** 加载模型供应商列表 */
|
||||
const loadSupplierList = async () => {
|
||||
try {
|
||||
const res = await getModelSupplierList();
|
||||
supplierList.value = res.data?.list || [];
|
||||
} catch {
|
||||
supplierList.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
/** 根据供应商值显示名称 */
|
||||
const resolveSupplierLabel = (supplier: number | undefined | null): string => {
|
||||
if (supplier === undefined || supplier === null) return '—';
|
||||
return supplierList.value.find((s) => s.value === supplier)?.label ?? String(supplier);
|
||||
};
|
||||
|
||||
/** 获取模型列表 */
|
||||
const fetchModelList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
pageNum: pagination.pageNum,
|
||||
pageSize: pagination.pageSize,
|
||||
modelName: searchParams.modelName || undefined,
|
||||
modelType: searchParams.modelType,
|
||||
};
|
||||
const res = await listModelManage(params);
|
||||
modelList.value = res.data?.list || [];
|
||||
pagination.total = res.data?.total || 0;
|
||||
} catch {
|
||||
modelList.value = [];
|
||||
pagination.total = 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
editModuleRef.value?.openDialog('add');
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.pageNum = 1;
|
||||
fetchModelList();
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.pageNum = page;
|
||||
fetchModelList();
|
||||
};
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
pagination.pageSize = size;
|
||||
pagination.pageNum = 1;
|
||||
fetchModelList();
|
||||
};
|
||||
|
||||
const handleEdit = (row: ModelManageItem) => {
|
||||
editModuleRef.value?.openDialog('edit', row.id)
|
||||
};
|
||||
|
||||
const handleDelete = (row: ModelManageItem) => {
|
||||
ElMessageBox.confirm(`确定要删除模型"${row.modelName}"吗?`, '删除确认', {
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
await deleteModelManage(row.id);
|
||||
ElMessage.success('删除成功');
|
||||
fetchModelList();
|
||||
} catch {
|
||||
// 接口错误由 request 全局提示
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadModelTypes();
|
||||
loadSupplierList();
|
||||
fetchModelList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.system-user-container {
|
||||
:deep(.el-card__body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
.el-table.fixed-table {
|
||||
flex: 1;
|
||||
table-layout: fixed;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -26,8 +26,8 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="sourceType" label="来源" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.sourceType === 0">自定义</el-tag>
|
||||
<el-tag v-else-if="row.sourceType === 1" type="success">公共</el-tag>
|
||||
<el-tag v-if="row.sourceType === 1" type="success">管理员</el-tag>
|
||||
<el-tag v-else-if="row.sourceType === 2">用户</el-tag>
|
||||
<el-tag v-else type="info">{{ row.sourceType }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -128,7 +128,7 @@ const nodeOptions = computed(() => {
|
||||
const createForm = reactive({
|
||||
nodeType: '',
|
||||
prompt: '',
|
||||
sourceType: 0 as number,
|
||||
sourceType: 2 as number,
|
||||
});
|
||||
|
||||
// 加载节点库
|
||||
@@ -269,7 +269,7 @@ const cancelCreate = () => {
|
||||
editId.value = null;
|
||||
createForm.nodeType = '';
|
||||
createForm.prompt = '';
|
||||
createForm.sourceType = 0;
|
||||
createForm.sourceType = 2;
|
||||
};
|
||||
|
||||
// 分页
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="flow-node" :class="`flow-node--${variant}`">
|
||||
<!-- target:上 / 左(开始节点不可作为 target,按约束禁止被连接) -->
|
||||
<template v-if="!isStartNode">
|
||||
<Handle type="target" :position="Position.Top" id="target-top" />
|
||||
<Handle type="target" :position="Position.Left" id="target-left" />
|
||||
</template>
|
||||
<!-- source:右 / 下 -->
|
||||
<Handle type="source" :position="Position.Right" id="source-right" />
|
||||
<Handle type="source" :position="Position.Bottom" id="source-bottom" />
|
||||
|
||||
<span class="flow-node-label">{{ props.data?.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Handle, Position, type NodeProps } from '@vue-flow/core';
|
||||
|
||||
const props = defineProps<NodeProps<{ label?: string; nodeCode?: string }>>();
|
||||
|
||||
const variantByCode: Record<string, string> = {
|
||||
__start__: 'start',
|
||||
model: 'model',
|
||||
http: 'http',
|
||||
form: 'form',
|
||||
judge: 'judge',
|
||||
data_merge: 'merge',
|
||||
sub_flow: 'subflow',
|
||||
script_transcribe: 'transcribe',
|
||||
};
|
||||
|
||||
const isStartNode = computed(() => props.data?.nodeCode === '__start__');
|
||||
const variant = computed(() => variantByCode[props.data?.nodeCode || ''] || 'default');
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 注意:根元素不设 position:relative,让 Handle 以外层 .vue-flow__node 为定位参照,才能贴到节点边缘
|
||||
.flow-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-width: 100px;
|
||||
max-width: 220px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
|
||||
transition: box-shadow 0.2s ease, transform 0.15s ease;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
|
||||
.flow-node-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.flow-node--start {
|
||||
background: linear-gradient(135deg, #34d399, #10b981);
|
||||
border: 2px solid #059669;
|
||||
}
|
||||
|
||||
.flow-node--model {
|
||||
background: linear-gradient(135deg, #a78bfa, #8b5cf6);
|
||||
border: 2px solid #7c3aed;
|
||||
}
|
||||
|
||||
.flow-node--http {
|
||||
background: linear-gradient(135deg, #60a5fa, #3b82f6);
|
||||
border: 2px solid #2563eb;
|
||||
}
|
||||
|
||||
.flow-node--form {
|
||||
background: linear-gradient(135deg, #22d3ee, #06b6d4);
|
||||
border: 2px solid #0891b2;
|
||||
}
|
||||
|
||||
.flow-node--judge {
|
||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||
border: 2px solid #d97706;
|
||||
}
|
||||
|
||||
.flow-node--merge {
|
||||
background: linear-gradient(135deg, #818cf8, #6366f1);
|
||||
border: 2px solid #4f46e5;
|
||||
}
|
||||
|
||||
.flow-node--subflow {
|
||||
background: linear-gradient(135deg, #f472b6, #ec4899);
|
||||
border: 2px solid #db2777;
|
||||
}
|
||||
|
||||
.flow-node--transcribe {
|
||||
background: linear-gradient(135deg, #94a3b8, #64748b);
|
||||
border: 2px solid #475569;
|
||||
}
|
||||
|
||||
.flow-node--default {
|
||||
background: linear-gradient(135deg, #94a3b8, #64748b);
|
||||
border: 2px solid #475569;
|
||||
}
|
||||
|
||||
.flow-node:hover {
|
||||
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.18);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="form-fields-editor">
|
||||
<div v-for="(field, index) in fields" :key="index" class="custom-field-config">
|
||||
<el-input v-model="field.label" placeholder="字段名" class="custom-field-input" @input="sync" />
|
||||
<div class="custom-field-row">
|
||||
<el-select v-model="field.type" placeholder="类型" class="custom-field-type" @change="sync">
|
||||
<el-option label="文本" value="input" />
|
||||
<el-option label="数字" value="number" />
|
||||
<el-option label="多行文本" value="textarea" />
|
||||
<el-option label="开关" value="switch" />
|
||||
<el-option label="文件上传" value="uploadMultiple" />
|
||||
</el-select>
|
||||
<el-checkbox v-model="field.required" class="custom-field-required" @change="sync">必填</el-checkbox>
|
||||
<el-button type="danger" link @click="removeField(index)">删除</el-button>
|
||||
</div>
|
||||
<!-- 文件上传类型:只配置规则,不在此处上传 -->
|
||||
<div v-if="field.type === 'uploadMultiple'" class="upload-rules">
|
||||
<el-input v-model="field.fileTypes" placeholder="文件格式(如 .mp4,jpg,png)" class="rule-file-types" @input="sync" />
|
||||
<el-input-number v-model="field.maxFileSize" :min="1" placeholder="最大大小(MB)" class="rule-file-size" @change="sync" />
|
||||
<el-input-number v-model="field.maxFileCount" :min="1" placeholder="最大数量" class="rule-file-size" @change="sync" />
|
||||
</div>
|
||||
<!-- 非文件上传类型显示默认值输入框 -->
|
||||
<el-input v-else-if="field.type === 'textarea'" v-model="field.value" placeholder="默认值" type="textarea" :rows="2" class="custom-field-value-full" @input="sync" />
|
||||
<el-input-number v-else-if="field.type === 'number'" v-model="field.value" placeholder="默认值" class="custom-field-value-full" @change="sync" />
|
||||
<el-switch v-else-if="field.type === 'switch'" v-model="field.value" class="custom-field-value-full" @change="sync" />
|
||||
<el-input v-else v-model="field.value" placeholder="默认值" class="custom-field-value-full" @input="sync" />
|
||||
</div>
|
||||
<el-button type="primary" link class="w100" @click="addField">+ 添加自定义字段</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue';
|
||||
|
||||
export interface FormField {
|
||||
// 不可变字段标识(保存/下游引用的 key);label 仅为展示名,重命名不影响已建立的引用。
|
||||
// 旧数据缺省时退化用 label,保存一次后 field 固化不再随 label 变化
|
||||
field?: string;
|
||||
label: string;
|
||||
type: string; // input | number | textarea | switch | uploadMultiple
|
||||
value: any; // 默认值(uploadMultiple 时存文件 URL 数组或空)
|
||||
required: boolean;
|
||||
fileTypes?: string;
|
||||
maxFileSize?: number;
|
||||
maxFileCount?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{ modelValue: FormField[] }>();
|
||||
const emit = defineEmits<{ 'update:modelValue': [FormField[]] }>();
|
||||
|
||||
const fields = ref<FormField[]>([]);
|
||||
let internalSync = false; // 由 props 同步触发,避免 emit 循环
|
||||
|
||||
const clone = (arr: FormField[]): FormField[] => JSON.parse(JSON.stringify(arr || [])) as FormField[];
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
internalSync = true;
|
||||
fields.value = clone(val);
|
||||
nextTick(() => {
|
||||
internalSync = false;
|
||||
});
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
const sync = () => {
|
||||
if (internalSync) return;
|
||||
emit('update:modelValue', clone(fields.value));
|
||||
};
|
||||
|
||||
// 字段唯一标识生成:时间戳 + 递增序号,避免同节点内字段 field 冲突
|
||||
let fieldSeq = 0;
|
||||
const genFieldKey = () => `f_${Date.now().toString(36)}_${++fieldSeq}`;
|
||||
|
||||
const addField = () => {
|
||||
fields.value.push({ field: genFieldKey(), label: '', type: 'input', value: '', required: false });
|
||||
sync();
|
||||
};
|
||||
|
||||
const removeField = (index: number) => {
|
||||
fields.value.splice(index, 1);
|
||||
sync();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-fields-editor {
|
||||
width: 100%;
|
||||
|
||||
.custom-field-config {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
background: #f8fafc;
|
||||
|
||||
.custom-field-input {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.custom-field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.custom-field-type {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.custom-field-required {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-rules {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.rule-file-types,
|
||||
.rule-file-size {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-field-value-full {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.w100 {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,279 +0,0 @@
|
||||
<template>
|
||||
<div class="input-source-manager">
|
||||
<el-divider content-position="left">上级参数引用</el-divider>
|
||||
|
||||
<!-- 已引用的参数列表 -->
|
||||
<div v-if="currentInputSource && currentInputSource.length > 0" class="input-source-list">
|
||||
<div v-for="(sourceNode, index) in currentInputSource" :key="index" class="input-source-item">
|
||||
<div class="input-source-header">
|
||||
<span class="input-source-node-name">{{ getNodeName(sourceNode.nodeId) }}</span>
|
||||
</div>
|
||||
<div v-if="sourceNode.field && sourceNode.field.length > 0" class="input-source-fields">
|
||||
<div v-for="fieldName in sourceNode.field" :key="fieldName" class="field-tag">
|
||||
<el-tag size="small">{{ fieldName }}</el-tag>
|
||||
<el-button type="danger" link size="small" @click="emit('removeField', sourceNode.nodeId, fieldName)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-source-output">
|
||||
<el-switch
|
||||
:model-value="sourceNode.quoteOutput === true"
|
||||
@change="(val: boolean) => emit('toggleOutput', sourceNode.nodeId, val)"
|
||||
size="small"
|
||||
active-text="引入输出"
|
||||
inactive-text=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 显示所有上级节点的输出引用选项 -->
|
||||
<div v-if="availableParentNodes.length > 0" class="parent-nodes-output">
|
||||
<div class="parent-nodes-title">上级节点输出</div>
|
||||
<div v-for="parentNode in availableParentNodes" :key="parentNode.id" class="parent-node-output-item">
|
||||
<span class="parent-node-name">{{ parentNode.name }}</span>
|
||||
<el-switch
|
||||
:model-value="isNodeOutputQuoted(parentNode.id)"
|
||||
@change="(val: boolean) => emit('toggleOutput', parentNode.id, val)"
|
||||
size="small"
|
||||
active-text="引入输出"
|
||||
inactive-text=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 选择参数下拉框 -->
|
||||
<el-form-item label="选择参数">
|
||||
<el-select :model-value="selectedParam" @update:model-value="handleParamSelect" placeholder="选择上级节点的参数" class="w100">
|
||||
<el-option v-for="param in availableParams" :key="param.value" :label="param.label" :value="param.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import type { Node } from '@vue-flow/core';
|
||||
|
||||
interface NodeData {
|
||||
label?: string;
|
||||
nodeCode?: string;
|
||||
inputSource?: Array<{ nodeId: string; field: string[]; quoteOutput?: boolean }> | null;
|
||||
formConfig?: any[];
|
||||
modelConfig?: any;
|
||||
skillName?: string;
|
||||
}
|
||||
|
||||
interface ParentNode {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ParamOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
selectedNode: Node<NodeData, any, string> | null;
|
||||
nodes: Node<NodeData, any, string>[];
|
||||
edges: any[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'removeField', nodeId: string, fieldName: string): void;
|
||||
(e: 'toggleOutput', nodeId: string, enabled: boolean): void;
|
||||
(e: 'addParam', paramValue: string): void;
|
||||
}>();
|
||||
|
||||
const selectedParam = ref('');
|
||||
|
||||
// 当前节点的 inputSource
|
||||
const currentInputSource = computed(() => {
|
||||
if (!props.selectedNode?.data?.inputSource) return [];
|
||||
return props.selectedNode.data.inputSource.filter((item) => item.field && item.field.length > 0);
|
||||
});
|
||||
|
||||
// 获取节点名称
|
||||
const getNodeName = (nodeId: string) => {
|
||||
const node = props.nodes.find((n) => n.id === nodeId);
|
||||
return node?.data?.label || nodeId;
|
||||
};
|
||||
|
||||
// 获取所有上级节点(用于显示输出引用选项)
|
||||
const availableParentNodes = computed(() => {
|
||||
if (!props.selectedNode?.data) return [];
|
||||
|
||||
// 获取已经引用了字段的节点ID列表
|
||||
const inputSource = props.selectedNode.data.inputSource;
|
||||
const nodesWithFields = new Set<string>();
|
||||
if (Array.isArray(inputSource)) {
|
||||
inputSource.forEach((item) => {
|
||||
if (item.field && item.field.length > 0) {
|
||||
nodesWithFields.add(item.nodeId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 递归查找所有上级节点
|
||||
const findAllParentNodes = (nodeId: string, visited = new Set<string>()): string[] => {
|
||||
if (visited.has(nodeId)) return [];
|
||||
visited.add(nodeId);
|
||||
|
||||
const incomingEdges = props.edges.filter((e) => e.target === nodeId);
|
||||
const parentIds: string[] = [];
|
||||
|
||||
incomingEdges.forEach((edge) => {
|
||||
parentIds.push(edge.source);
|
||||
parentIds.push(...findAllParentNodes(edge.source, visited));
|
||||
});
|
||||
|
||||
return parentIds;
|
||||
};
|
||||
|
||||
const allParentIds = findAllParentNodes(props.selectedNode.id);
|
||||
const parentNodes = allParentIds
|
||||
.map((parentId) => {
|
||||
const parentNode = props.nodes.find((n) => n.id === parentId);
|
||||
if (!parentNode?.data) return null;
|
||||
|
||||
const nodeCode = String(parentNode.data.nodeCode || '').toLowerCase();
|
||||
const isJudge = ['判断', 'judge', 'condition', 'if', 'branch', 'gateway'].some((k) => nodeCode.includes(k));
|
||||
const isStart = nodeCode === '__start__';
|
||||
|
||||
if (isJudge || isStart || nodesWithFields.has(parentId)) return null;
|
||||
|
||||
return {
|
||||
id: parentId,
|
||||
name: parentNode.data.label || parentId,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return parentNodes as ParentNode[];
|
||||
});
|
||||
|
||||
// 检查节点输出是否被引用
|
||||
const isNodeOutputQuoted = (nodeId: string): boolean => {
|
||||
if (!props.selectedNode?.data) return false;
|
||||
const inputSource = props.selectedNode.data.inputSource;
|
||||
if (!Array.isArray(inputSource)) return false;
|
||||
|
||||
const node = inputSource.find((item) => item.nodeId === nodeId);
|
||||
return node?.quoteOutput === true;
|
||||
};
|
||||
|
||||
// 获取可用的参数选项
|
||||
const availableParams = computed(() => {
|
||||
if (!props.selectedNode) return [];
|
||||
|
||||
const params: ParamOption[] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
const findParents = (nodeId: string) => {
|
||||
if (visited.has(nodeId)) return;
|
||||
visited.add(nodeId);
|
||||
|
||||
props.edges
|
||||
.filter((e) => e.target === nodeId)
|
||||
.forEach((edge) => {
|
||||
const parent = props.nodes.find((n) => n.id === edge.source);
|
||||
if (parent?.data && parent.data.nodeCode !== '__start__' && parent.data.nodeCode !== 'judge') {
|
||||
params.push({
|
||||
label: `${parent.data.label}.output`,
|
||||
value: `\${${parent.id}.output}`,
|
||||
});
|
||||
}
|
||||
findParents(edge.source);
|
||||
});
|
||||
};
|
||||
|
||||
findParents(props.selectedNode.id);
|
||||
return params;
|
||||
});
|
||||
|
||||
const handleParamSelect = (value: string) => {
|
||||
if (!value) return;
|
||||
emit('addParam', value);
|
||||
selectedParam.value = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.input-source-manager {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.input-source-list {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.input-source-item {
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.input-source-header {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-source-node-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.input-source-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.field-tag {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.input-source-output {
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.parent-nodes-output {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.parent-nodes-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.parent-node-output-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 6px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.parent-node-name {
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.w100 {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="kv-editor">
|
||||
<div v-for="(item, idx) in list" :key="idx" class="kv-row">
|
||||
<el-input v-model="item.key" placeholder="key" size="small" class="kv-key" @input="sync" />
|
||||
<el-input v-model="item.value" placeholder="value" size="small" class="kv-value" @input="sync" />
|
||||
<el-button size="small" text type="danger" class="kv-del" @click="remove(idx)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button size="small" class="kv-add" @click="add">
|
||||
<el-icon><Plus /></el-icon> 添加
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { Plus, Delete } from '@element-plus/icons-vue';
|
||||
|
||||
const props = defineProps<{ modelValue: Record<string, unknown> }>();
|
||||
const emit = defineEmits<{ 'update:modelValue': [Record<string, unknown>] }>();
|
||||
|
||||
const list = ref<{ key: string; value: string }[]>([]);
|
||||
|
||||
function toList(obj: Record<string, unknown>) {
|
||||
return Object.entries(obj || {}).map(([k, v]) => ({
|
||||
key: k,
|
||||
value: typeof v === 'string' ? v : JSON.stringify(v),
|
||||
}));
|
||||
}
|
||||
|
||||
function toRecord() {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const item of list.value) {
|
||||
if (item.key.trim()) obj[item.key.trim()] = item.value;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
function sync() {
|
||||
emit('update:modelValue', toRecord());
|
||||
}
|
||||
|
||||
function add() {
|
||||
list.value.push({ key: '', value: '' });
|
||||
}
|
||||
|
||||
function remove(idx: number) {
|
||||
list.value.splice(idx, 1);
|
||||
sync();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
list.value = toList(val);
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kv-editor {
|
||||
width: 100%;
|
||||
}
|
||||
.kv-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.kv-key {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.kv-value {
|
||||
flex: 2;
|
||||
min-width: 0;
|
||||
}
|
||||
.kv-del {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.kv-add {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<!-- 引用上级节点输出:按钮 + 候选面板(叶子字段 + 对象/数组整体);multi 时支持多选 -->
|
||||
<el-popover
|
||||
v-if="canValueSource"
|
||||
placement="bottom"
|
||||
:width="260"
|
||||
trigger="click"
|
||||
popper-class="mf-ref-popover"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
type="primary"
|
||||
class="mf-ref-btn"
|
||||
title="引用上级节点输出"
|
||||
:disabled="!!fieldDef?.runtimeShow || !hasUpstream"
|
||||
>
|
||||
<el-icon><Link /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="mf-ref-panel">
|
||||
<div v-if="!hasUpstream" class="mf-ref-empty">无可引用的上级节点</div>
|
||||
<template v-else>
|
||||
<div v-for="node in upstreamNodes" :key="node.id" class="mf-ref-node">
|
||||
<div class="mf-ref-node-head">
|
||||
<span class="mf-ref-node-name">{{ node.label }}</span>
|
||||
<el-tag size="small" type="info">{{ node.nodeCode }}</el-tag>
|
||||
</div>
|
||||
<div v-if="node.outputFields.length === 0" class="mf-ref-node-empty">该节点无输出字段</div>
|
||||
<div
|
||||
v-for="of in node.outputFields"
|
||||
:key="`${of.field}-${of.fieldType || 'scalar'}`"
|
||||
class="mf-ref-field"
|
||||
:class="{ 'is-selected': isSelected(node, of) }"
|
||||
@click="toggleValueSource(node, of)"
|
||||
>
|
||||
<span class="mf-ref-check">
|
||||
<el-icon v-if="isSelected(node, of)"><Check /></el-icon>
|
||||
</span>
|
||||
<span v-if="of.fieldType === 'object'" class="mf-ref-type" title="引用对象整体">{} 对象</span>
|
||||
<span v-else-if="of.fieldType === 'array'" class="mf-ref-type" title="引用数组整体">[] 数组</span>
|
||||
<span class="mf-ref-field-text">{{ of.label || of.field }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Check, Link } from '@element-plus/icons-vue';
|
||||
|
||||
defineOptions({ name: 'MfReferenceButton' });
|
||||
|
||||
// 前驱节点的可引用输出字段(fieldType:scalar 叶子 / object、array 整体)
|
||||
interface UpstreamField {
|
||||
field: string;
|
||||
label: string;
|
||||
fieldType?: 'scalar' | 'object' | 'array';
|
||||
}
|
||||
interface UpstreamNodeInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
nodeCode: string;
|
||||
outputFields: UpstreamField[];
|
||||
}
|
||||
|
||||
const props = defineProps<{ fieldDef: any; upstreamNodes?: UpstreamNodeInfo[]; multi?: boolean }>();
|
||||
|
||||
// 可引用:非开关、非固定选项下拉(开关/下拉值来自模型);叶子与对象/数组整体均可引用
|
||||
const canValueSource = computed(() => {
|
||||
const ft = props.fieldDef?.fieldType || 'string';
|
||||
const dt = props.fieldDef?.type || 'string';
|
||||
return ft !== 'switch' && ft !== 'select' && dt !== 'switch';
|
||||
});
|
||||
const hasUpstream = computed(() => (props.upstreamNodes?.length ?? 0) > 0);
|
||||
|
||||
// 当前引用集合(契约统一数组 [{ nodeId, field }];兼容旧 DSL 单对象)
|
||||
const valueSource = computed<any[]>(() => {
|
||||
const vs = props.fieldDef?.valueSource;
|
||||
if (Array.isArray(vs)) return vs;
|
||||
return vs && typeof vs === 'object' && !Array.isArray(vs) ? [vs] : [];
|
||||
});
|
||||
|
||||
const isSelected = (node: UpstreamNodeInfo, of: UpstreamField): boolean =>
|
||||
valueSource.value.some((r) => r && r.nodeId === node.id && r.field === of.field);
|
||||
|
||||
// 点选切换:就地写 fieldDef.valueSource 数组,复用父组件深 watch 上传链路
|
||||
const toggleValueSource = (node: UpstreamNodeInfo, of: UpstreamField) => {
|
||||
if (!props.fieldDef) return;
|
||||
const cur = valueSource.value;
|
||||
if (props.multi) {
|
||||
// 多选:勾选/取消切换,同字段去重
|
||||
if (isSelected(node, of)) {
|
||||
const keep = cur.filter((r) => !(r && r.nodeId === node.id && r.field === of.field));
|
||||
if (keep.length === 0) delete props.fieldDef.valueSource;
|
||||
else props.fieldDef.valueSource = keep;
|
||||
} else {
|
||||
props.fieldDef.valueSource = [...cur, { nodeId: node.id, field: of.field }];
|
||||
}
|
||||
} else {
|
||||
// 单选:点已选字段取消,否则替换为单元素数组
|
||||
if (cur.length === 1 && isSelected(node, of)) {
|
||||
delete props.fieldDef.valueSource;
|
||||
} else {
|
||||
props.fieldDef.valueSource = [{ nodeId: node.id, field: of.field }];
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mf-ref-btn {
|
||||
flex-shrink: 0;
|
||||
margin-left: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- popover teleport 到 body,面板样式需脱离 scoped -->
|
||||
<style lang="scss">
|
||||
.mf-ref-popover {
|
||||
.mf-ref-panel {
|
||||
// 面板超高时内部滚动,避免超出浏览器视口被截掉显示不全
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
.mf-ref-empty {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
padding: 6px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mf-ref-node {
|
||||
& + .mf-ref-node {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
.mf-ref-node-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.mf-ref-node-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.mf-ref-node-empty {
|
||||
font-size: 12px;
|
||||
color: #cbd5e1;
|
||||
padding: 2px 0 2px 8px;
|
||||
}
|
||||
|
||||
.mf-ref-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
.mf-ref-check {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
font-size: 12px;
|
||||
color: #3b82f6;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.mf-ref-type {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: #64748b;
|
||||
background: #f1f5f9;
|
||||
border-radius: 3px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.mf-ref-field-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,575 @@
|
||||
<template>
|
||||
<div class="model-field">
|
||||
<!-- object:递归渲染 attrs 子字段 -->
|
||||
<template v-if="isObject">
|
||||
<div class="mf-block">
|
||||
<div class="mf-block-title">
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
<span class="mf-block-name">{{ label }}</span> <el-tooltip v-if="description" :content="description">
|
||||
<el-icon class="mf-info"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="mf-block-body">
|
||||
<ModelField
|
||||
v-for="(childDef, childKey) in objectChildren"
|
||||
:key="childKey"
|
||||
:field-def="childDef"
|
||||
:path="path ? `${path}.attrs.${childKey}` : childKey"
|
||||
:upstream-nodes="upstreamNodes"
|
||||
:multi="multi"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- array:直接平铺展示元素子字段,用户直接填写;不提供添加/删除 -->
|
||||
<template v-else-if="isArray">
|
||||
<div class="mf-block">
|
||||
<div class="mf-block-title">
|
||||
<el-icon><List /></el-icon>
|
||||
<span class="mf-block-name">{{ label }}</span>
|
||||
<span v-if="required" class="mf-required">*</span> <el-tooltip v-if="description" :content="description">
|
||||
<el-icon class="mf-info"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="mf-array-flat">
|
||||
<!-- 数组元素直接遍历模板(enumValues/attrs):值与勾选均落在模板上,
|
||||
保存只留 enumValues 一份即可完整回显,不依赖 value 实例 -->
|
||||
<template v-for="(item, idx) in renderedArrayItems()" :key="idx">
|
||||
<template v-if="isObjectDef(item)">
|
||||
<div v-if="isVariantArray" class="mf-variant-head">
|
||||
<span class="mf-variant-name">{{ variantLabel(idx) }}</span>
|
||||
</div>
|
||||
<div v-else-if="renderedArrayItems().length > 1" class="mf-array-flat-index">#{{ idx + 1 }}</div>
|
||||
<ModelField
|
||||
v-for="(subDef, subKey) in item.attrs"
|
||||
:key="subKey"
|
||||
:field-def="subDef"
|
||||
:path="path ? `${path}.enumValues[${idx}].attrs.${subKey}` : String(subKey)"
|
||||
:upstream-nodes="upstreamNodes"
|
||||
:multi="multi"
|
||||
/>
|
||||
</template>
|
||||
<el-input v-else :model-value="getTemplatePrimitive(item)" @input="(v: any) => setTemplatePrimitive(item, v)" size="small" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 叶子:按 fieldType 渲染控件 -->
|
||||
<template v-else>
|
||||
<div class="mf-leaf">
|
||||
<div class="mf-leaf-label">
|
||||
<span class="mf-label-text">{{ label }}</span>
|
||||
<span v-if="required" class="mf-required">*</span> <el-tooltip v-if="description" :content="description">
|
||||
<el-icon class="mf-info"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="path || label" placement="top">
|
||||
<el-checkbox
|
||||
size="small"
|
||||
:model-value="def.runtimeShow ?? false"
|
||||
:disabled="hasValueSource"
|
||||
@change="toggleExpose"
|
||||
class="mf-leaf-expose"
|
||||
>表单展示</el-checkbox>
|
||||
</el-tooltip>
|
||||
<!-- 引用上级输出与「表单展示」勾选同级:所有叶子参数(含数组元素内字段)都可引用;multi 时支持多选 -->
|
||||
<MfReferenceButton :field-def="def" :upstream-nodes="upstreamNodes" :multi="multi" />
|
||||
</div>
|
||||
<div v-if="hasValueSource" class="mf-ref-bound" :class="{ 'is-invalid': valueSourceInvalid }">
|
||||
<div class="mf-ref-tags">
|
||||
<el-tag
|
||||
v-for="(ref, i) in valueSourceList"
|
||||
:key="`${ref.nodeId}-${ref.field}-${i}`"
|
||||
size="small"
|
||||
class="mf-ref-tag"
|
||||
:type="ref.invalid ? 'danger' : 'primary'"
|
||||
closable
|
||||
@close="removeValueSource(ref)"
|
||||
>{{ ref.label }}</el-tag>
|
||||
</div>
|
||||
<el-button size="small" text type="danger" @click="clearValueSource">清空</el-button>
|
||||
</div>
|
||||
<div v-if="!hasValueSource && !isUpload" class="mf-leaf-ctrl">
|
||||
<el-input-number
|
||||
v-if="isNumber"
|
||||
:model-value="numberValue"
|
||||
@change="setValue"
|
||||
class="mf-ctrl"
|
||||
:controls="false"
|
||||
:placeholder="required ? '必填' : ''"
|
||||
/>
|
||||
<el-switch v-else-if="isSwitch" :model-value="switchValue" @change="setValue" class="mf-ctrl" />
|
||||
<el-select
|
||||
v-else-if="isSelect"
|
||||
:model-value="def.value ?? ''"
|
||||
@change="setSelectValue"
|
||||
class="mf-ctrl"
|
||||
clearable
|
||||
:placeholder="required ? '必填' : ''"
|
||||
>
|
||||
<el-option v-for="opt in def.options" :key="opt.value" :label="opt.label ?? opt.value" :value="opt.value" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-else-if="isTextarea"
|
||||
:model-value="def.value ?? ''"
|
||||
@input="setValue"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
class="mf-ctrl"
|
||||
:placeholder="required ? '必填' : ''"
|
||||
/>
|
||||
<el-input
|
||||
v-else
|
||||
:model-value="def.value ?? ''"
|
||||
@input="setValue"
|
||||
class="mf-ctrl"
|
||||
:placeholder="required ? '必填' : ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
import { FolderOpened, List, QuestionFilled } from '@element-plus/icons-vue';
|
||||
import MfReferenceButton from './MfReferenceButton.vue';
|
||||
|
||||
defineOptions({ name: 'ModelField' });
|
||||
|
||||
// 前驱节点的可引用输出字段(供「引用上级节点输出」)
|
||||
interface UpstreamNodeInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
nodeCode: string;
|
||||
outputFields: { field: string; label: string; fieldType?: 'scalar' | 'object' | 'array' }[];
|
||||
}
|
||||
|
||||
const props = defineProps<{ fieldDef: any; path?: string; upstreamNodes?: UpstreamNodeInfo[]; multi?: boolean }>();
|
||||
|
||||
// 用 computed 实时取当前 props,避免 internalSync 替换对象树后仍持有旧引用
|
||||
const def = computed(() => props.fieldDef);
|
||||
|
||||
// ===== 基础元信息 =====
|
||||
const label = computed(() => def.value?.label || def.value?.field || '');
|
||||
const description = computed(() => def.value?.description || '');
|
||||
const required = computed(() => !!def.value?.required);
|
||||
const defType = computed(() => def.value?.type || 'string');
|
||||
const fieldType = computed(() => def.value?.fieldType || 'string');
|
||||
|
||||
// ===== 类型判定 =====
|
||||
const isObject = computed(() => defType.value === 'object');
|
||||
const isArray = computed(() => defType.value === 'array');
|
||||
const isLeaf = computed(() => !isObject.value && !isArray.value);
|
||||
|
||||
const objectChildren = computed<Record<string, any>>(() => {
|
||||
if (!isObject.value) return {};
|
||||
const attrs = def.value?.attrs;
|
||||
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) return attrs;
|
||||
return {};
|
||||
});
|
||||
|
||||
const isNumber = computed(() => isLeaf.value && (fieldType.value === 'number' || defType.value === 'number'));
|
||||
const isSwitch = computed(() => isLeaf.value && (fieldType.value === 'switch' || defType.value === 'boolean'));
|
||||
const isSelect = computed(() => isLeaf.value && fieldType.value === 'select' && Array.isArray(def.value?.options));
|
||||
const isTextarea = computed(() => isLeaf.value && fieldType.value === 'textarea');
|
||||
const isUpload = computed(() => isLeaf.value && fieldType.value === 'upload');
|
||||
|
||||
// ===== 引用上级节点输出 =====
|
||||
// 引用按钮与候选面板在 MfReferenceButton 中统一渲染(叶子与对象/数组整体均可引用)
|
||||
// valueSource 契约统一数组 [{ nodeId, field }];multi(节点 isMultiParameter)时支持多选
|
||||
const valueSourceList = computed(() => {
|
||||
const vs = def.value?.valueSource;
|
||||
// 兼容旧 DSL 单对象 { nodeId, field }:统一按数组读取
|
||||
if (!vs || typeof vs !== 'object') return [];
|
||||
const arr = Array.isArray(vs) ? vs : [vs];
|
||||
return arr.map((r: any) => {
|
||||
const node = props.upstreamNodes?.find((n) => n.id === r?.nodeId);
|
||||
// 引用标签只展示字段中文名(不带「节点名.」前缀)
|
||||
const fieldLabel = node?.outputFields.find((f) => f.field === r?.field)?.label || r?.field || '';
|
||||
const invalid = !node || !node.outputFields.some((f) => f.field === r?.field);
|
||||
return { nodeId: r?.nodeId, field: r?.field, label: fieldLabel, invalid };
|
||||
});
|
||||
});
|
||||
const hasValueSource = computed(() => valueSourceList.value.length > 0);
|
||||
// 任一引用失效即整体标红(失效项逐条展示,由失效清理 watch 自动移除)
|
||||
const valueSourceInvalid = computed(() => valueSourceList.value.some((r) => r.invalid));
|
||||
|
||||
// 失效引用自动清理,避免保存脏数据(过滤失效项;全部失效则删除整个 valueSource,复用深 watch 上传链路)
|
||||
watch(
|
||||
() => valueSourceList.value,
|
||||
(list) => {
|
||||
if (!def.value) return;
|
||||
const vs = def.value.valueSource;
|
||||
if (!vs || typeof vs !== 'object') return;
|
||||
// 兼容旧 DSL 单对象:按数组处理,清理后统一写回数组
|
||||
const arr = Array.isArray(vs) ? vs : [vs];
|
||||
if (!list.some((r) => r.invalid)) return;
|
||||
const keep = arr.filter((r: any) => {
|
||||
const node = props.upstreamNodes?.find((n) => n.id === r?.nodeId);
|
||||
return node && node.outputFields.some((f) => f.field === r?.field);
|
||||
});
|
||||
if (keep.length === 0) delete def.value.valueSource;
|
||||
else def.value.valueSource = keep;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 删除单个引用(标签 closable)
|
||||
const removeValueSource = (ref: any) => {
|
||||
if (!def.value) return;
|
||||
const vs = def.value.valueSource;
|
||||
if (!vs || typeof vs !== 'object') return;
|
||||
const arr = Array.isArray(vs) ? vs : [vs];
|
||||
const keep = arr.filter((r: any) => !(r?.nodeId === ref.nodeId && r?.field === ref.field));
|
||||
if (keep.length === 0) delete def.value.valueSource;
|
||||
else def.value.valueSource = keep;
|
||||
};
|
||||
|
||||
// 清空全部引用
|
||||
const clearValueSource = () => {
|
||||
if (!def.value) return;
|
||||
delete def.value.valueSource;
|
||||
};
|
||||
|
||||
// ===== 叶子值(兼容字符串/布尔/数字) =====
|
||||
const numberValue = computed(() => {
|
||||
const v = def.value?.value !== undefined ? def.value.value : def.value?.defaultValue;
|
||||
const n = Number(v);
|
||||
return Number.isNaN(n) ? 0 : n;
|
||||
});
|
||||
const switchValue = computed(() => {
|
||||
const v = def.value?.value !== undefined ? def.value.value : def.value?.defaultValue;
|
||||
return v === true || v === 'true';
|
||||
});
|
||||
|
||||
const setValue = (val: any) => {
|
||||
if (!def.value) return;
|
||||
if (isSwitch.value) {
|
||||
def.value.value = val === true || val === 'true';
|
||||
} else if (isNumber.value) {
|
||||
def.value.value = val === null || val === undefined || val === '' ? 0 : Number(val);
|
||||
} else {
|
||||
def.value.value = val ?? '';
|
||||
}
|
||||
};
|
||||
|
||||
const setSelectValue = (val: any) => {
|
||||
if (!def.value) return;
|
||||
def.value.value = val ?? '';
|
||||
};
|
||||
|
||||
|
||||
// 勾选「表单展示」:就地写 def.runtimeShow,复用深 watch 上传链路
|
||||
const toggleExpose = (checked: any) => {
|
||||
if (!def.value) return;
|
||||
def.value.runtimeShow = !!checked;
|
||||
};
|
||||
|
||||
|
||||
// ===== array:平铺展示元素子字段,供用户直接填写;不提供添加/删除 =====
|
||||
// 元素模板:优先 enumValues 首个,其次 attrs 数组,其次单个 attrs 对象
|
||||
const arrayTemplates = computed<any[]>(() => {
|
||||
if (!isArray.value || !def.value) return [];
|
||||
if (Array.isArray(def.value.enumValues) && def.value.enumValues.length) return def.value.enumValues;
|
||||
if (Array.isArray(def.value.attrs) && def.value.attrs.length) return def.value.attrs;
|
||||
if (def.value.attrs && typeof def.value.attrs === 'object' && !Array.isArray(def.value.attrs)) {
|
||||
return [{ type: 'object', attrs: def.value.attrs }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const isObjectDef = (item: any) => !!item && typeof item === 'object' && item.type === 'object' && item.attrs && typeof item.attrs === 'object';
|
||||
|
||||
// 数组渲染统一以模板(enumValues/attrs)为数据源:值与勾选均落在模板上,
|
||||
// 不受 props 同步重建 localParams(深拷贝)影响,保存只留 enumValues 一份即可完整回显
|
||||
const renderedArrayItems = (): any[] => {
|
||||
const templates = arrayTemplates.value;
|
||||
// 多模板(variant):懒填 type/role 标识值到模板(幂等),保证展示与保存有标识值
|
||||
if (templates.length > 1) templates.forEach(autoFillVariant);
|
||||
return templates;
|
||||
};
|
||||
|
||||
// 原始值数组元素:值直接读写模板原始值对象上的 value
|
||||
const getTemplatePrimitive = (item: any): any => {
|
||||
if (!item || typeof item !== 'object') return '';
|
||||
return item.value !== undefined ? item.value : item.defaultValue ?? '';
|
||||
};
|
||||
|
||||
const setTemplatePrimitive = (item: any, v: any): void => {
|
||||
if (!item || typeof item !== 'object') return;
|
||||
item.value = v ?? '';
|
||||
};
|
||||
|
||||
// ===== array:联合数组(enumValues 多模板)→ 全部模板平铺,固定条数,不可增删 =====
|
||||
const isVariantArray = computed(
|
||||
() => isArray.value && Array.isArray(def.value?.enumValues) && (def.value?.enumValues?.length ?? 0) > 1
|
||||
);
|
||||
|
||||
// 变体名推断:优先 type 字段值(如 text/image_url/video_url),其次 role 字段描述里的角色名,兜底"变体 N"
|
||||
const templateLabel = (tpl: any, index: number): string => {
|
||||
if (tpl && typeof tpl === 'object' && tpl.attrs && typeof tpl.attrs === 'object' && !Array.isArray(tpl.attrs)) {
|
||||
const typeField = tpl.attrs.type;
|
||||
if (typeField) {
|
||||
const t = typeField.value ?? typeField.defaultValue ?? '';
|
||||
if (t) return String(t);
|
||||
}
|
||||
const roleField = tpl.attrs.role;
|
||||
if (roleField && roleField.description) {
|
||||
const m = String(roleField.description).match(/(此处为|固定为|为)\s*([a-zA-Z0-9_-]+)/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
}
|
||||
return `变体 ${index + 1}`;
|
||||
};
|
||||
|
||||
// 自动填标识值:type 用 defaultValue,role 从 description 提取;只填空值,不覆盖用户已填
|
||||
const autoFillVariant = (tpl: any): void => {
|
||||
if (!tpl || typeof tpl !== 'object' || !tpl.attrs || typeof tpl.attrs !== 'object' || Array.isArray(tpl.attrs)) return;
|
||||
const typeField = tpl.attrs.type;
|
||||
if (typeField && (typeField.value === undefined || typeField.value === null || typeField.value === '')) {
|
||||
if (typeField.defaultValue !== undefined && typeField.defaultValue !== null) typeField.value = typeField.defaultValue;
|
||||
}
|
||||
const roleField = tpl.attrs.role;
|
||||
if (roleField && (roleField.value === undefined || roleField.value === null || roleField.value === '')) {
|
||||
const m = String(roleField.description || '').match(/(此处为|固定为|为)\s*([a-zA-Z0-9_-]+)/);
|
||||
if (m) roleField.value = m[1];
|
||||
}
|
||||
};
|
||||
|
||||
const variantLabel = (idx: number): string => templateLabel(def.value?.enumValues?.[idx], idx);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.model-field {
|
||||
width: 100%;
|
||||
|
||||
// 嵌套层级不重复套卡片:内层块去边框背景,用左缩进区分层级
|
||||
.model-field {
|
||||
.mf-block,
|
||||
.mf-leaf {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0 0 0 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mf-block {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
background: #fbfcfe;
|
||||
|
||||
.mf-block-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.mf-block-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.mf-block-body {
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.mf-info {
|
||||
cursor: help;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-required {
|
||||
color: #f56c6c;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.mf-array-flat {
|
||||
.mf-array-flat-index {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
background: #eef2f7;
|
||||
border-radius: 999px;
|
||||
padding: 0 6px;
|
||||
margin: 2px 0 6px;
|
||||
}
|
||||
|
||||
:deep(.model-field) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mf-variant {
|
||||
margin-bottom: 8px;
|
||||
padding-left: 10px;
|
||||
border-left: 2px solid #e2e8f0;
|
||||
|
||||
.mf-variant-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.mf-variant-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.mf-leaf {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 10px;
|
||||
background: #ffffff;
|
||||
|
||||
.mf-leaf-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
|
||||
.mf-label-text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mf-leaf-expose {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
|
||||
:deep(.el-checkbox__label) {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.mf-ref-btn {
|
||||
flex-shrink: 0;
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.mf-ref-bound {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
|
||||
.mf-ref-tags {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
|
||||
.mf-ref-tag {
|
||||
:deep(.el-tag__content) {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-invalid {
|
||||
color: #dc2626;
|
||||
background: #fef2f2;
|
||||
}
|
||||
}
|
||||
|
||||
.mf-leaf-ctrl {
|
||||
.mf-ctrl {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- popover teleport 到 body,面板样式需脱离 scoped -->
|
||||
<style lang="scss">
|
||||
.mf-ref-popover {
|
||||
.mf-ref-panel {
|
||||
// 面板超高时内部滚动,避免超出浏览器视口被截掉显示不全
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
.mf-ref-empty {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
padding: 6px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mf-ref-node {
|
||||
& + .mf-ref-node {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
.mf-ref-node-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.mf-ref-node-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.mf-ref-node-empty {
|
||||
font-size: 12px;
|
||||
color: #cbd5e1;
|
||||
padding: 2px 0 2px 8px;
|
||||
}
|
||||
|
||||
.mf-ref-field {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="model-params-form">
|
||||
<div v-if="!hasParams" class="mpf-empty">
|
||||
<el-empty description="该模型无请求参数配置" :image-size="80" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="mpf-count">
|
||||
共 {{ fieldCount }} 个参数<el-divider direction="vertical" />点击字段名旁图标查看说明
|
||||
</div>
|
||||
<div class="mpf-fields">
|
||||
<ModelField
|
||||
v-for="(def, key) in localParams"
|
||||
:key="key"
|
||||
:field-def="def"
|
||||
:path="key"
|
||||
:upstream-nodes="upstreamNodes"
|
||||
:multi="multi"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import ModelField from './ModelField.vue';
|
||||
import { deepClone, normalizeModelParams, stripReadonlyFields } from './modelParamUtils';
|
||||
|
||||
// 前驱节点的可引用输出字段(供「引用上级节点输出」)
|
||||
interface UpstreamNodeInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
nodeCode: string;
|
||||
outputFields: { field: string; label: string }[];
|
||||
}
|
||||
|
||||
const props = defineProps<{ modelRequestParams: Record<string, any> | null; upstreamNodes?: UpstreamNodeInfo[]; multi?: boolean }>();
|
||||
const emit = defineEmits<{ 'update:modelRequestParams': [Record<string, any> | null] }>();
|
||||
|
||||
const localParams = ref<Record<string, any>>({});
|
||||
let internalSync = false; // 由 props 同步触发,避免 emit 循环
|
||||
|
||||
// props → 本地:深拷贝 + defaultValue 预填,避免直接改动父级引用
|
||||
watch(
|
||||
() => props.modelRequestParams,
|
||||
(val) => {
|
||||
internalSync = true;
|
||||
// 剔除 isForm=false 的只读字段:不显示、不参与编辑与保存
|
||||
localParams.value = stripReadonlyFields(normalizeModelParams(deepClone(val || {})));
|
||||
nextTick(() => {
|
||||
internalSync = false;
|
||||
});
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
// 本地 → 父级:就地修改的叶子 value 会触发深度 watch
|
||||
watch(
|
||||
localParams,
|
||||
(val) => {
|
||||
if (internalSync) return;
|
||||
emit('update:modelRequestParams', deepClone(val));
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const hasParams = computed(() => Object.keys(localParams.value).length > 0);
|
||||
const fieldCount = computed(() => Object.keys(localParams.value).length);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.model-params-form {
|
||||
width: 100%;
|
||||
|
||||
.mpf-empty {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.mpf-count {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.mpf-fields {
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,413 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="选择模型" width="1000px" :close-on-click-modal="false" @close="handleClose">
|
||||
<div class="model-selector-header">
|
||||
<div class="search-bar">
|
||||
<el-input v-model="searchParams.modelName" placeholder="搜索模型名称" clearable @clear="handleSearch">
|
||||
<template #prefix
|
||||
><el-icon> <Search /> </el-icon
|
||||
></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="model-list" v-loading="loading">
|
||||
<el-empty v-if="!loading && modelList.length === 0" description="暂无模型数据" :image-size="100" />
|
||||
<div v-else class="model-grid">
|
||||
<div
|
||||
v-for="model in modelList"
|
||||
:key="model.id"
|
||||
class="model-card"
|
||||
:class="{ selected: selectedModel?.id === model.id }"
|
||||
@click="handleSelectModel(model)"
|
||||
>
|
||||
<div class="model-card-header">
|
||||
<div class="model-type">
|
||||
{{ getModelTypeName(model.modelType) }}
|
||||
<el-tag v-if="model.systemModel" size="small" type="warning" class="model-owner-tag">内置</el-tag>
|
||||
<el-tag v-else size="small" type="success" class="model-owner-tag">我的</el-tag>
|
||||
</div>
|
||||
<el-icon v-if="selectedModel?.id === model.id" class="check-icon" color="#67c23a">
|
||||
<CircleCheck />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="model-card-body">
|
||||
<h3 class="model-name">{{ model.modelName }}</h3>
|
||||
<p class="model-url">{{ model.baseUrl }}</p>
|
||||
<div class="model-status">
|
||||
<el-tag :type="isModelEnabled(model) ? 'success' : 'info'" size="small">
|
||||
{{ isModelEnabled(model) ? '已启用' : '已禁用' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pagination.total > 0" class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.pageNum"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
layout="total, prev, pager, next"
|
||||
small
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleConfirm" :disabled="!selectedModel">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 系统内置模型:填写 API Key,经修改接口转换为用户模型后自动绑定 -->
|
||||
<el-dialog
|
||||
v-model="apiKeyDialogVisible"
|
||||
title="填写 API Key"
|
||||
width="480px"
|
||||
append-to-body
|
||||
:close-on-click-modal="false"
|
||||
@close="handleApiKeyClose"
|
||||
>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="该模型为系统内置模型,填写你的 API Key 后将创建一条用户模型并自动绑定到当前节点。"
|
||||
class="api-key-alert"
|
||||
/>
|
||||
<el-form label-position="top" class="api-key-form">
|
||||
<el-form-item label="API Key" required>
|
||||
<el-input
|
||||
v-model="apiKeyForm.apiKey"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入你的 API Key"
|
||||
@keyup.enter="handleApiKeyConfirm"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleApiKeyClose">取消</el-button>
|
||||
<el-button type="primary" :loading="converting" @click="handleApiKeyConfirm">确认并转换</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Search, CircleCheck } from '@element-plus/icons-vue';
|
||||
import { getWorkflowModelList, type WorkflowModelItem } from '/@/api/settings/workflow';
|
||||
import { updateModelManage } from '/@/api/settings/modelConfigV2';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
defaultModel?: WorkflowModelItem | null;
|
||||
// 非空时仅列出同类型模型(首页「重新选择模型」场景);工作流管理不传,行为不变
|
||||
sameTypeModelType?: string | number | null;
|
||||
// 系统模型是否必须填写 API Key(默认 true)。
|
||||
// 超级管理员绘制/编辑模板工作流时传 false:选系统模型直接选中,不再弹 API Key。
|
||||
systemModelRequireKey?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'confirm', model: WorkflowModelItem): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
defaultModel: null,
|
||||
sameTypeModelType: null,
|
||||
systemModelRequireKey: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = ref(false);
|
||||
const searchParams = reactive({ modelName: '' });
|
||||
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
|
||||
const modelList = ref<WorkflowModelItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const selectedModel = ref<WorkflowModelItem | null>(null);
|
||||
// 系统内置模型(systemModel === true)转用户模型:填 API Key → 调修改接口,后端自动克隆出新用户模型
|
||||
const apiKeyDialogVisible = ref(false);
|
||||
const converting = ref(false);
|
||||
const pendingCloneModel = ref<WorkflowModelItem | null>(null);
|
||||
const apiKeyForm = reactive({ apiKey: '' });
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
visible.value = val;
|
||||
if (val) {
|
||||
selectedModel.value = props.defaultModel || null;
|
||||
pagination.pageNum = 1;
|
||||
fetchModelList();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(visible, (val) => {
|
||||
if (!val) {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
});
|
||||
|
||||
const getModelTypeName = (type: number | string) => {
|
||||
const typeMap: Record<number, string> = {
|
||||
100: '推理模型',
|
||||
200: '图片模型',
|
||||
201: '图片模型-文生图',
|
||||
202: '图片模型-图生图',
|
||||
203: '图片模型-图片编辑',
|
||||
204: '图片模型-图片变体',
|
||||
300: '音频模型',
|
||||
301: '音频模型-文生音',
|
||||
302: '音频模型-音生文',
|
||||
303: '音频模型-音生音',
|
||||
400: '向量化模型',
|
||||
401: '向量化模型-文本嵌入',
|
||||
402: '向量化模型-重排序',
|
||||
500: '全模态模型',
|
||||
501: '全模态模型-文图音',
|
||||
502: '全模态模型-视觉理解',
|
||||
600: '视频模型',
|
||||
601: '视频模型-文生视频',
|
||||
602: '视频模型-图生视频',
|
||||
603: '视频模型-图文生视频',
|
||||
604: '视频模型-视频生视频',
|
||||
605: '视频模型-视频编辑',
|
||||
};
|
||||
return typeMap[Number(type)] || '未知类型';
|
||||
};
|
||||
|
||||
// 兼容 enabled 数字(1/0) 与布尔(true/false) 两种返回
|
||||
const isModelEnabled = (model: WorkflowModelItem) => model.enabled === 1 || model.enabled === true;
|
||||
|
||||
const fetchModelList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
pageNum: pagination.pageNum,
|
||||
pageSize: pagination.pageSize,
|
||||
modelName: searchParams.modelName || undefined,
|
||||
...(props.sameTypeModelType !== undefined && props.sameTypeModelType !== null && props.sameTypeModelType !== ''
|
||||
? { isSameType: true, modelType: props.sameTypeModelType }
|
||||
: {}),
|
||||
};
|
||||
const res = await getWorkflowModelList(params);
|
||||
modelList.value = res.data?.list || [];
|
||||
pagination.total = res.data?.total || 0;
|
||||
// 预选项仅为部分信息(切换节点后无 id)时,按名称匹配当前页以高亮
|
||||
if (selectedModel.value && !selectedModel.value.id && selectedModel.value.modelName) {
|
||||
const matched = modelList.value.find((m) => m.modelName === selectedModel.value!.modelName);
|
||||
if (matched) selectedModel.value = matched;
|
||||
}
|
||||
} catch {
|
||||
modelList.value = [];
|
||||
pagination.total = 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.pageNum = 1;
|
||||
fetchModelList();
|
||||
};
|
||||
|
||||
const handlePageChange = () => {
|
||||
fetchModelList();
|
||||
};
|
||||
|
||||
const handleSelectModel = (model: WorkflowModelItem) => {
|
||||
// 系统内置模型:默认需填写 API Key,经修改接口转成用户模型后再绑定;
|
||||
// 超级管理员绘制模板(systemModelRequireKey=false)时直接选中,模板保留系统模型供用户使用时再补 Key
|
||||
if (model.systemModel && props.systemModelRequireKey) {
|
||||
pendingCloneModel.value = model;
|
||||
apiKeyForm.apiKey = '';
|
||||
apiKeyDialogVisible.value = true;
|
||||
return;
|
||||
}
|
||||
selectedModel.value = model;
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedModel.value) {
|
||||
emit('confirm', selectedModel.value);
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
// 确认 API Key:调用修改接口,后端克隆出用户模型并直接返回,前端立即绑定
|
||||
const handleApiKeyConfirm = async () => {
|
||||
if (!apiKeyForm.apiKey.trim()) {
|
||||
ElMessage.warning('请输入 API Key');
|
||||
return;
|
||||
}
|
||||
if (!pendingCloneModel.value?.id) return;
|
||||
converting.value = true;
|
||||
try {
|
||||
const src = pendingCloneModel.value;
|
||||
const res = await updateModelManage({
|
||||
id: src.id,
|
||||
// 传递系统模型全部配置,供后端克隆用户模型时继承
|
||||
modelName: src.modelName,
|
||||
modelType: src.modelType,
|
||||
modelSupplier: src.modelSupplier,
|
||||
baseUrl: src.baseUrl,
|
||||
responseType: src.responseType ?? src.invokeType,
|
||||
apiKey: apiKeyForm.apiKey.trim(),
|
||||
enabled: src.enabled,
|
||||
chatModel: src.ChatModel ?? src.chatModel,
|
||||
maxConcurrency: src.maxConcurrency,
|
||||
maxTokens: src.maxTokens,
|
||||
tokenPredictPrice: src.tokenPredictPrice,
|
||||
requestHeadMapping: src.requestHeadMapping ?? src.requestMapping,
|
||||
requestBodyMapping: src.requestBodyMapping,
|
||||
responseMapping: src.responseMapping,
|
||||
responseBodyMapping: src.responseBodyMapping,
|
||||
...(src.tokenMapping ? { tokenMapping: src.tokenMapping } : {}),
|
||||
...(src.asyncTaskMapping ? { asyncTaskMapping: src.asyncTaskMapping } : {}),
|
||||
...(src.lastFrame ? { lastFrame: src.lastFrame } : {}),
|
||||
...(src.maxDuration ? { maxDuration: src.maxDuration } : {}),
|
||||
...(src.tokenPredictPriceUnit ? { tokenPredictPriceUnit: src.tokenPredictPriceUnit } : {}),
|
||||
} as any);
|
||||
// 返回结构为 { data: { modelManage: {...} } },新用户模型 id 在 modelManage 中
|
||||
const newModelManage = res?.data?.modelManage || res?.data;
|
||||
if (!newModelManage?.id) throw new Error('接口未返回新的用户模型');
|
||||
// 后端返回的 modelManage 仅带新 id/apiKey,modelName/requestBodyMapping 等为空。
|
||||
// 用原系统模型完整配置 + 新 id 合并,保证绑定后模型名、参数表单、下游引用、isSameType 过滤都正常。
|
||||
const newModel: WorkflowModelItem = {
|
||||
...(pendingCloneModel.value || {}),
|
||||
id: newModelManage.id,
|
||||
systemModel: false,
|
||||
apiKey: apiKeyForm.apiKey.trim(),
|
||||
};
|
||||
apiKeyDialogVisible.value = false;
|
||||
pendingCloneModel.value = null;
|
||||
emit('confirm', newModel);
|
||||
handleClose();
|
||||
ElMessage.success('已创建用户模型并绑定');
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '模型转换失败,请重试');
|
||||
} finally {
|
||||
converting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiKeyClose = () => {
|
||||
apiKeyDialogVisible.value = false;
|
||||
pendingCloneModel.value = null;
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
selectedModel.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.model-selector-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
.model-list {
|
||||
min-height: 300px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.model-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.model-card {
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.model-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.model-card.selected {
|
||||
border-color: #67c23a;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
.model-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.model-type {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
.model-owner-tag {
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
.check-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
.model-card-body {
|
||||
flex: 1;
|
||||
}
|
||||
.model-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 8px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.model-url {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin: 0 0 8px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.model-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.api-key-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.api-key-form {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -6,19 +6,65 @@
|
||||
<el-form-item label="节点名称">
|
||||
<el-input :model-value="selectedNode.data?.label" @update:model-value="updateNodeLabel" />
|
||||
</el-form-item>
|
||||
<el-form-item label="节点类型">
|
||||
<el-tag>{{ selectedNode.data?.nodeCode }}</el-tag>
|
||||
<el-form-item v-if="!isStartNode" label="节点描述">
|
||||
<el-input
|
||||
:model-value="selectedNode.data?.desc || ''"
|
||||
placeholder="请输入节点描述"
|
||||
@update:model-value="updateNodeDesc"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 模型选择 -->
|
||||
<el-form-item v-if="nodeConfig?.modelConfig && nodeConfig.modelConfig.length > 0" label="选择模型">
|
||||
<el-button type="primary" @click="emit('openModelSelector')" style="width: 100%">选择模型</el-button>
|
||||
<div v-if="selectedNode.data?.modelConfig?.modelName" class="selected-tag">
|
||||
<el-tag type="success" size="large" closable @close="emit('removeModel')">
|
||||
{{ selectedNode.data.modelConfig.modelName }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<!-- 前置方法 / 后置方法(节点库 preToolOption / postToolOption 配置定义,值保存到节点顶层 preTool / postTool,与 outputConfig 平级) -->
|
||||
<el-form-item v-if="preToolDef" :label="preToolDef.label || '前置方法'">
|
||||
<el-select
|
||||
:model-value="selectedNode.data?.preTool ?? ''"
|
||||
@update:model-value="updatePreTool"
|
||||
clearable
|
||||
class="w100"
|
||||
:placeholder="preToolDef.required ? '必选' : '选填'"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in preToolDef.options || []"
|
||||
:key="opt.key || opt.value"
|
||||
:label="opt.value ?? opt.key"
|
||||
:value="opt.key ?? opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="postToolDef" :label="postToolDef.label || '后置方法'">
|
||||
<el-select
|
||||
:model-value="selectedNode.data?.postTool ?? ''"
|
||||
@update:model-value="updatePostTool"
|
||||
clearable
|
||||
class="w100"
|
||||
:placeholder="postToolDef.required ? '必选' : '选填'"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in postToolDef.options || []"
|
||||
:key="opt.key || opt.value"
|
||||
:label="opt.value ?? opt.key"
|
||||
:value="opt.key ?? opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 开始节点:运行表单字段摘要(来自各模型节点勾选的「表单展示」字段) -->
|
||||
<template v-if="isStartNode">
|
||||
<el-divider content-position="left">运行表单字段</el-divider>
|
||||
<div v-if="runFormFields.length === 0" class="run-form-empty">
|
||||
暂无展示字段:请在模型节点参数中勾选「表单展示」
|
||||
</div>
|
||||
<div v-else class="run-form-list">
|
||||
<div v-for="(f, i) in runFormFields" :key="i" class="run-form-item">
|
||||
<el-tooltip :content="f.path" placement="top">
|
||||
<span class="run-form-label">{{ f.label }}</span>
|
||||
</el-tooltip>
|
||||
<span class="run-form-node">{{ f.nodeLabel }}</span>
|
||||
<el-tag size="small" type="info" class="run-form-type">{{ fieldTypeLabel(f.fieldType) }}</el-tag>
|
||||
<el-tag v-if="f.required" size="small" type="danger">必填</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 技能选择 -->
|
||||
<el-form-item v-if="nodeConfig?.skillOption" label="选择技能">
|
||||
@@ -30,113 +76,376 @@
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 贴片布局开关 -->
|
||||
<el-form-item v-if="nodeConfig?.patchLayout" label="贴片布局">
|
||||
<el-switch
|
||||
:model-value="selectedNode.data?.patchLayout ?? false"
|
||||
@update:model-value="emit('update:patchLayout', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 是否保存文件开关 -->
|
||||
<el-form-item v-if="nodeConfig?.isSaveFileOption" label="是否保存文件">
|
||||
<el-switch
|
||||
:model-value="selectedNode.data?.isSaveFile ?? false"
|
||||
active-text="开启"
|
||||
inactive-text="关闭"
|
||||
@update:model-value="updateIsSaveFile"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 提示词(promptOption) -->
|
||||
<el-form-item v-if="nodeConfig?.promptOption" label="提示词">
|
||||
<PromptEditor
|
||||
:model-value="selectedNode.data?.prompt ?? ''"
|
||||
@update:model-value="updatePrompt('prompt', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 反向提示词(negativePromptOption) -->
|
||||
<el-form-item v-if="nodeConfig?.negativePromptOption" label="反向提示词">
|
||||
<PromptEditor
|
||||
:model-value="selectedNode.data?.negativePrompt ?? ''"
|
||||
button-text="编辑反向提示词"
|
||||
dialog-title="编辑反向提示词"
|
||||
@update:model-value="updatePrompt('negativePrompt', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 动态表单字段 -->
|
||||
<template v-if="nodeConfig?.formConfig && nodeConfig.formConfig.length > 0">
|
||||
<el-divider content-position="left">节点参数</el-divider>
|
||||
<el-form-item v-for="field in nodeConfig.formConfig" :key="field.field" :label="field.label" :required="field.required">
|
||||
<el-input
|
||||
v-if="field.type === 'input' || field.type === 'string'"
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateFieldValue(field.field, $event)"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
/>
|
||||
<el-input-number
|
||||
v-else-if="field.type === 'number' || field.type === 'inputNumber'"
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateFieldValue(field.field, $event)"
|
||||
class="w100"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="field.type === 'textarea'"
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateFieldValue(field.field, $event)"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
/>
|
||||
<el-switch
|
||||
v-else-if="field.type === 'switch'"
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateFieldValue(field.field, $event)"
|
||||
/>
|
||||
<el-select
|
||||
v-else-if="field.type === 'select' && field.options"
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateFieldValue(field.field, $event)"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
class="w100"
|
||||
>
|
||||
<el-option v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-else
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateFieldValue(field.field, $event)"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template v-for="field in nodeConfig.formConfig" :key="field.field">
|
||||
<el-form-item :required="field.required">
|
||||
<template #label>
|
||||
<span class="dyn-field-label">{{ field.label }}</span>
|
||||
<!-- 脚本转写节点的视频总时长:支持引用上级节点输出(引用后隐藏输入、显示引用来源) -->
|
||||
<MfReferenceButton v-if="isTotalDurationField(field)" :field-def="fieldRefDef(field)" :upstream-nodes="upstreamNodes" :multi="false" />
|
||||
</template>
|
||||
<!-- 脚本转写节点的视频总时长:引用上级输出后隐藏输入控件,显示引用来源标签 -->
|
||||
<template v-if="isTotalDurationField(field) && hasFieldRef(field.field)">
|
||||
<div class="mf-ref-bound">
|
||||
<div class="mf-ref-tags">
|
||||
<el-tag
|
||||
v-for="(ref, i) in fieldRefList(field.field)"
|
||||
:key="`${ref.nodeId}-${ref.field}-${i}`"
|
||||
size="small"
|
||||
class="mf-ref-tag"
|
||||
:type="ref.invalid ? 'danger' : 'primary'"
|
||||
closable
|
||||
@close="removeFieldRef(field, ref)"
|
||||
>{{ ref.label }}</el-tag>
|
||||
</div>
|
||||
<el-button size="small" text type="danger" @click="clearFieldRef(field)">清空</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-input
|
||||
v-if="field.type === 'input' || field.type === 'string'"
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateFieldValue(field.field, $event)"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
/>
|
||||
<el-input-number
|
||||
v-else-if="field.type === 'number' || field.type === 'inputNumber'"
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateFieldValue(field.field, $event)"
|
||||
class="w100"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="field.type === 'textarea'"
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateFieldValue(field.field, $event)"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
/>
|
||||
<el-switch
|
||||
v-else-if="field.type === 'switch'"
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateFieldValue(field.field, $event)"
|
||||
/>
|
||||
<el-select
|
||||
v-else-if="field.type === 'select' && field.options"
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateSelectFieldValue(field, $event)"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
class="w100"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in field.options"
|
||||
:key="opt.key || opt.value"
|
||||
:label="opt.value ?? opt.key"
|
||||
:value="opt.key ?? opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<KeyValueEditor
|
||||
v-else-if="field.type === 'keyValue'"
|
||||
:model-value="getKeyValueValue(field.field)"
|
||||
@update:model-value="(v: any) => updateFieldValue(field.field, v)"
|
||||
/>
|
||||
<el-button
|
||||
v-else-if="field.type === 'schemaJson'"
|
||||
@click="openSchemaEditor(field.field)"
|
||||
style="width: 100%"
|
||||
>配置</el-button>
|
||||
<el-input
|
||||
v-else
|
||||
:model-value="getFieldValue(field.field)"
|
||||
@update:model-value="updateFieldValue(field.field, $event)"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
/>
|
||||
</template>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 上级参数管理 -->
|
||||
<InputSourceManager
|
||||
v-if="selectedNode.data?.nodeCode !== '__start__'"
|
||||
:selected-node="selectedNode"
|
||||
:nodes="allNodes"
|
||||
:edges="allEdges"
|
||||
@remove-field="(nodeId: string, fieldName: string) => emit('removeField', nodeId, fieldName)"
|
||||
@toggle-output="(nodeId: string, enabled: boolean) => emit('toggleOutput', nodeId, enabled)"
|
||||
@add-param="(paramValue: string) => emit('addParamByValue', paramValue)"
|
||||
/>
|
||||
<!-- 下拉选中选项的嵌套配置(responseType 级联) -->
|
||||
<template v-if="getSelectedOptionConfig(field).length > 0">
|
||||
<el-form-item
|
||||
v-for="sub in getSelectedOptionConfig(field)"
|
||||
:key="`${field.field}-${sub.field}`"
|
||||
:label="sub.label"
|
||||
:required="sub.required"
|
||||
>
|
||||
<el-input
|
||||
v-if="sub.type === 'input' || sub.type === 'string'"
|
||||
:model-value="getExpandValue(field.field, sub.field)"
|
||||
@update:model-value="(v: any) => updateExpandValue(field.field, sub.field, v)"
|
||||
:placeholder="sub.required ? '必填' : '选填'"
|
||||
/>
|
||||
<el-input-number
|
||||
v-else-if="sub.type === 'number' || sub.type === 'inputNumber'"
|
||||
:model-value="getExpandValue(field.field, sub.field)"
|
||||
@update:model-value="(v: any) => updateExpandValue(field.field, sub.field, v)"
|
||||
class="w100"
|
||||
/>
|
||||
<el-switch
|
||||
v-else-if="sub.type === 'switch'"
|
||||
:model-value="getExpandValue(field.field, sub.field)"
|
||||
@update:model-value="(v: any) => updateExpandValue(field.field, sub.field, v)"
|
||||
/>
|
||||
<el-select
|
||||
v-else-if="sub.type === 'select' && sub.options"
|
||||
:model-value="getExpandValue(field.field, sub.field)"
|
||||
@update:model-value="(v: any) => updateExpandValue(field.field, sub.field, v)"
|
||||
:placeholder="sub.required ? '必填' : '选填'"
|
||||
class="w100"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in sub.options"
|
||||
:key="opt.key || opt.value"
|
||||
:label="opt.value ?? opt.key"
|
||||
:value="opt.key ?? opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<KeyValueEditor
|
||||
v-else-if="sub.type === 'keyValue'"
|
||||
:model-value="getExpandKeyValueValue(field.field, sub.field)"
|
||||
@update:model-value="(v: any) => updateExpandValue(field.field, sub.field, v)"
|
||||
/>
|
||||
<el-button
|
||||
v-else-if="sub.type === 'schemaJson'"
|
||||
@click="openSchemaEditor(field.field, sub.field)"
|
||||
style="width: 100%"
|
||||
>配置</el-button>
|
||||
<el-input
|
||||
v-else
|
||||
:model-value="getExpandValue(field.field, sub.field)"
|
||||
@update:model-value="(v: any) => updateExpandValue(field.field, sub.field, v)"
|
||||
:placeholder="sub.required ? '必填' : '选填'"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 子流程配置(仅 sub_flow 节点):选择已有工作流引入其开始参数 -->
|
||||
<template v-if="isSubFlowNode">
|
||||
<el-divider content-position="left">子流程配置</el-divider>
|
||||
<el-form-item label="引入工作流">
|
||||
<el-button type="primary" plain @click="emit('openWorkflowSelector')" style="width: 100%">选择工作流</el-button>
|
||||
<div v-if="subFlowConfig" class="selected-tag">
|
||||
<el-tag type="success" size="large" closable @close="emit('removeWorkflow')">
|
||||
{{ subFlowConfig.workflowName || subFlowConfig.workflowId }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<template v-if="subFlowConfig">
|
||||
<el-divider content-position="left">引入参数</el-divider>
|
||||
<SubFlowParams
|
||||
:fields="subFlowConfig.fields"
|
||||
:upstream-nodes="upstreamNodes"
|
||||
:multi="!!nodeConfig?.isMultiParameter"
|
||||
@update:fields="updateSubFlowFields"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 自定义表单字段(formConfigOption,如 form 节点) -->
|
||||
<template v-if="nodeConfig?.formConfigOption">
|
||||
<el-divider content-position="left">自定义字段</el-divider>
|
||||
<FormFieldsEditor :model-value="customFormFields" @update:model-value="updateCustomFields" />
|
||||
</template>
|
||||
|
||||
<!-- 模型选择 -->
|
||||
<el-form-item v-if="nodeConfig?.modelConfigOption" label="选择模型">
|
||||
<el-button type="primary" @click="emit('openModelSelector')" style="width: 100%">选择模型</el-button>
|
||||
<div v-if="selectedNode.data?.modelConfig?.modelName" class="selected-tag">
|
||||
<el-tag type="success" size="large" closable @close="emit('removeModel')">
|
||||
{{ selectedNode.data.modelConfig.modelName }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 模型参数(选中模型后渲染 requestBodyMapping 递归表单) -->
|
||||
<template v-if="nodeConfig?.modelConfigOption && selectedNode.data?.modelConfig?.modelId">
|
||||
<el-divider content-position="left">模型参数</el-divider>
|
||||
<ModelParamsForm
|
||||
:model-request-params="selectedNode.data.modelConfig.modelRequestParams"
|
||||
:upstream-nodes="upstreamNodes"
|
||||
:multi="!!nodeConfig?.isMultiParameter"
|
||||
@update:model-request-params="updateModelRequestParams"
|
||||
/>
|
||||
</template>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-empty v-else description="请选择一个节点" :image-size="100" />
|
||||
|
||||
<!-- Schema 编辑器弹窗 -->
|
||||
<el-dialog v-model="schemaEditorVisible" title="编辑 Schema" width="960px" append-to-body destroy-on-close>
|
||||
<div style="height: 560px; overflow-y: auto">
|
||||
<JsonEditor
|
||||
v-model="schemaEditorData"
|
||||
:upstream-nodes="upstreamNodes"
|
||||
hide-form-config
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="schemaEditorVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmSchemaEditor">确 定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import type { Node } from '@vue-flow/core';
|
||||
import InputSourceManager from './InputSourceManager.vue';
|
||||
import KeyValueEditor from './KeyValueEditor.vue';
|
||||
import MfReferenceButton from './MfReferenceButton.vue';
|
||||
import FormFieldsEditor, { type FormField } from './FormFieldsEditor.vue';
|
||||
import ModelParamsForm from './ModelParamsForm.vue';
|
||||
import PromptEditor from './PromptEditor.vue';
|
||||
import SubFlowParams from './SubFlowParams.vue';
|
||||
import type { SubFlowField } from './subFlowTypes';
|
||||
import { JsonEditor } from '/@/components/json-schema-editor';
|
||||
|
||||
interface NodeData {
|
||||
label?: string;
|
||||
nodeCode?: string;
|
||||
inputSource?: Array<{ nodeId: string; field: string[]; quoteOutput?: boolean }> | null;
|
||||
desc?: string;
|
||||
formConfig?: any[];
|
||||
modelConfig?: any;
|
||||
skillName?: string;
|
||||
}
|
||||
|
||||
interface ParamRef {
|
||||
id: string;
|
||||
label: string;
|
||||
prompt?: string;
|
||||
negativePrompt?: string;
|
||||
patchLayout?: boolean;
|
||||
isSaveFile?: boolean;
|
||||
preTool?: string | null;
|
||||
postTool?: string | null;
|
||||
runFormFields?: any[];
|
||||
// 子流程节点(sub_flow):引入的工作流配置(workflowId + 引入参数)
|
||||
subFlowConfig?: any;
|
||||
}
|
||||
|
||||
interface NodeConfig {
|
||||
formConfig: any[];
|
||||
modelConfig: any[];
|
||||
preToolOption: any[];
|
||||
postToolOption: any[];
|
||||
isMultiParameter: boolean;
|
||||
modelConfigOption: boolean;
|
||||
formConfigOption: boolean;
|
||||
skillOption: boolean;
|
||||
promptOption: boolean;
|
||||
negativePromptOption: boolean;
|
||||
isSaveFileOption: boolean;
|
||||
patchLayout: boolean;
|
||||
}
|
||||
|
||||
// 前驱节点的可引用输出字段(供模型参数「引用上级节点输出」)
|
||||
interface UpstreamNodeInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
nodeCode: string;
|
||||
outputFields: { field: string; label: string }[];
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
selectedNode: Node<NodeData, any, string> | null;
|
||||
availableParams: ParamRef[];
|
||||
nodeConfig: NodeConfig | null;
|
||||
allNodes: Node<NodeData, any, string>[];
|
||||
allEdges: any[];
|
||||
upstreamNodes?: UpstreamNodeInfo[];
|
||||
}>();
|
||||
|
||||
// 开始节点不展示节点描述
|
||||
const isStartNode = computed(() => props.selectedNode?.data?.nodeCode === '__start__');
|
||||
|
||||
// 开始节点运行表单字段摘要(数据来自 index.vue 聚合写回)
|
||||
const runFormFields = computed<any[]>(() => props.selectedNode?.data?.runFormFields || []);
|
||||
|
||||
// 子流程节点(sub_flow):是否当前选中节点为子流程
|
||||
const isSubFlowNode = computed(() => props.selectedNode?.data?.nodeCode === 'sub_flow');
|
||||
// 子流程引入配置(编辑器态 subFlowConfig;未引入则为 null)
|
||||
const subFlowConfig = computed<any>(() => props.selectedNode?.data?.subFlowConfig || null);
|
||||
|
||||
// fieldType 可读标签
|
||||
const fieldTypeLabel = (ft: string) => {
|
||||
const map: Record<string, string> = {
|
||||
string: '文本',
|
||||
number: '数字',
|
||||
switch: '开关',
|
||||
boolean: '布尔',
|
||||
select: '下拉',
|
||||
textarea: '多行文本',
|
||||
upload: '上传',
|
||||
};
|
||||
return map[ft] || ft || '文本';
|
||||
};
|
||||
|
||||
// form 节点自定义字段(formConfigOption)
|
||||
const customFormFields = computed<FormField[]>(() => (props.selectedNode?.data?.formConfig as FormField[]) || []);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:selectedNode', node: Node<NodeData, any, string>): void;
|
||||
(e: 'addParam', param: ParamRef): void;
|
||||
(e: 'openModelSelector'): void;
|
||||
(e: 'removeModel'): void;
|
||||
(e: 'openSkillSelector'): void;
|
||||
(e: 'removeSkill'): void;
|
||||
(e: 'removeField', nodeId: string, fieldName: string): void;
|
||||
(e: 'toggleOutput', nodeId: string, enabled: boolean): void;
|
||||
(e: 'addParamByValue', paramValue: string): void;
|
||||
(e: 'update:patchLayout', value: boolean): void;
|
||||
(e: 'openWorkflowSelector'): void;
|
||||
(e: 'removeWorkflow'): void;
|
||||
}>();
|
||||
|
||||
// Schema 编辑器弹窗
|
||||
const schemaEditorVisible = ref(false);
|
||||
const schemaEditorField = ref('');
|
||||
const schemaEditorExpandField = ref(''); // 空表示顶层字段,非空表示 responseType 的嵌套 expand 字段
|
||||
const schemaEditorData = ref<any>({});
|
||||
|
||||
function openSchemaEditor(fieldName: string, expandField = '') {
|
||||
schemaEditorField.value = fieldName;
|
||||
schemaEditorExpandField.value = expandField;
|
||||
const val = expandField ? getExpandValue(fieldName, expandField) : getFieldValue(fieldName);
|
||||
schemaEditorData.value = val && typeof val === 'object' ? { ...val } : {};
|
||||
schemaEditorVisible.value = true;
|
||||
}
|
||||
|
||||
function confirmSchemaEditor() {
|
||||
if (schemaEditorExpandField.value) {
|
||||
updateExpandValue(schemaEditorField.value, schemaEditorExpandField.value, schemaEditorData.value);
|
||||
} else {
|
||||
updateFieldValue(schemaEditorField.value, schemaEditorData.value);
|
||||
}
|
||||
schemaEditorVisible.value = false;
|
||||
}
|
||||
|
||||
const updateNodeLabel = (newLabel: string) => {
|
||||
if (!props.selectedNode?.data) return;
|
||||
const updatedNode = {
|
||||
@@ -149,12 +458,137 @@ const updateNodeLabel = (newLabel: string) => {
|
||||
emit('update:selectedNode', updatedNode);
|
||||
};
|
||||
|
||||
const updateNodeDesc = (newDesc: string) => {
|
||||
if (!props.selectedNode?.data) return;
|
||||
const updatedNode = {
|
||||
...props.selectedNode,
|
||||
data: {
|
||||
...props.selectedNode.data,
|
||||
desc: newDesc,
|
||||
},
|
||||
};
|
||||
emit('update:selectedNode', updatedNode);
|
||||
};
|
||||
|
||||
const updateIsSaveFile = (value: boolean) => {
|
||||
if (!props.selectedNode?.data) return;
|
||||
const updatedNode = {
|
||||
...props.selectedNode,
|
||||
data: {
|
||||
...props.selectedNode.data,
|
||||
isSaveFile: value,
|
||||
},
|
||||
};
|
||||
emit('update:selectedNode', updatedNode);
|
||||
};
|
||||
|
||||
// 更新提示词 / 反向提示词(走 update:selectedNode 链路同步到 VueFlow 与 nodes 数组)
|
||||
const updatePrompt = (field: 'prompt' | 'negativePrompt', value: string) => {
|
||||
if (!props.selectedNode?.data) return;
|
||||
const updatedNode = {
|
||||
...props.selectedNode,
|
||||
data: {
|
||||
...props.selectedNode.data,
|
||||
[field]: value,
|
||||
},
|
||||
};
|
||||
emit('update:selectedNode', updatedNode);
|
||||
};
|
||||
|
||||
// 自定义表单字段(formConfigOption)变更:写回 node.data.formConfig
|
||||
const updateCustomFields = (fields: FormField[]) => {
|
||||
if (!props.selectedNode?.data) return;
|
||||
const updatedNode = {
|
||||
...props.selectedNode,
|
||||
data: {
|
||||
...props.selectedNode.data,
|
||||
formConfig: fields,
|
||||
},
|
||||
};
|
||||
emit('update:selectedNode', updatedNode);
|
||||
};
|
||||
|
||||
// 模型参数变更:写回 node.data.modelConfig.modelRequestParams
|
||||
const updateModelRequestParams = (params: Record<string, any> | null) => {
|
||||
if (!props.selectedNode?.data) return;
|
||||
const updatedNode = {
|
||||
...props.selectedNode,
|
||||
data: {
|
||||
...props.selectedNode.data,
|
||||
modelConfig: {
|
||||
...props.selectedNode.data.modelConfig,
|
||||
modelRequestParams: params,
|
||||
},
|
||||
},
|
||||
};
|
||||
emit('update:selectedNode', updatedNode);
|
||||
};
|
||||
|
||||
// 前置/后置方法配置定义(节点库 preToolOption / postToolOption,仅 model 节点提供;取首个配置项渲染)
|
||||
const preToolDef = computed(() => {
|
||||
const defs = props.nodeConfig?.preToolOption;
|
||||
return Array.isArray(defs) && defs.length > 0 ? defs[0] : null;
|
||||
});
|
||||
const postToolDef = computed(() => {
|
||||
const defs = props.nodeConfig?.postToolOption;
|
||||
return Array.isArray(defs) && defs.length > 0 ? defs[0] : null;
|
||||
});
|
||||
|
||||
// 前置方法值写节点顶层 preTool(与 outputConfig 平级)
|
||||
const updatePreTool = (value: string) => {
|
||||
if (!props.selectedNode?.data) return;
|
||||
const updatedNode = {
|
||||
...props.selectedNode,
|
||||
data: {
|
||||
...props.selectedNode.data,
|
||||
preTool: value,
|
||||
},
|
||||
};
|
||||
emit('update:selectedNode', updatedNode);
|
||||
};
|
||||
|
||||
// 后置方法值写节点顶层 postTool(与 outputConfig 平级)
|
||||
const updatePostTool = (value: string) => {
|
||||
if (!props.selectedNode?.data) return;
|
||||
const updatedNode = {
|
||||
...props.selectedNode,
|
||||
data: {
|
||||
...props.selectedNode.data,
|
||||
postTool: value,
|
||||
},
|
||||
};
|
||||
emit('update:selectedNode', updatedNode);
|
||||
};
|
||||
|
||||
// 子流程引入参数就地变更(ModelField 已就地写共享引用):
|
||||
// 仅刷新节点引用同步 VueFlow / 父组件状态;fields 为同一数组引用,避免与 SubFlowParams 深 watch 形成循环
|
||||
const updateSubFlowFields = (fields: SubFlowField[]) => {
|
||||
if (!props.selectedNode?.data || !props.selectedNode.data.subFlowConfig) return;
|
||||
const updatedNode = {
|
||||
...props.selectedNode,
|
||||
data: {
|
||||
...props.selectedNode.data,
|
||||
subFlowConfig: {
|
||||
...props.selectedNode.data.subFlowConfig,
|
||||
fields,
|
||||
},
|
||||
},
|
||||
};
|
||||
emit('update:selectedNode', updatedNode);
|
||||
};
|
||||
|
||||
const getFieldValue = (fieldName: string) => {
|
||||
if (!props.selectedNode?.data?.formConfig) return '';
|
||||
const field = props.selectedNode.data.formConfig.find((f: any) => f.field === fieldName);
|
||||
return field?.value ?? '';
|
||||
};
|
||||
|
||||
// keyValue 类型字段的值以对象形式传给编辑器:空值兜底空对象,避免 prop 类型告警(组件期望 Record<string, unknown>)
|
||||
const getKeyValueValue = (fieldName: string) => {
|
||||
const v = getFieldValue(fieldName);
|
||||
return v && typeof v === 'object' ? v : {};
|
||||
};
|
||||
|
||||
const updateFieldValue = (fieldName: string, value: any) => {
|
||||
if (!props.selectedNode?.data) return;
|
||||
|
||||
@@ -183,9 +617,225 @@ const updateFieldValue = (fieldName: string, value: any) => {
|
||||
};
|
||||
emit('update:selectedNode', updatedNode);
|
||||
};
|
||||
|
||||
// 取 formConfig 中的某个字段条目
|
||||
const getFormConfigEntry = (fieldName: string) => {
|
||||
if (!props.selectedNode?.data?.formConfig) return undefined;
|
||||
return props.selectedNode.data.formConfig.find((f: any) => f.field === fieldName);
|
||||
};
|
||||
|
||||
// ===== 脚本转写节点「视频总时长」字段级引用:支持引用上级节点输出 / 手动输入 =====
|
||||
// valueSource 契约统一数组 [{ nodeId, field }],存到 node.data.formConfig 对应条目;
|
||||
// 仅脚本转写节点的 totalDuration 字段启用引用能力(值可由用户手动输入或引用上游输出)
|
||||
const isTotalDurationField = (field: any) => !!field && field.field === 'totalDuration' && field.isFormField !== true;
|
||||
|
||||
// 读字段引用(统一数组;兼容旧 DSL 单对象)
|
||||
const getFieldValueSource = (fieldName: string) => {
|
||||
const entry = getFormConfigEntry(fieldName);
|
||||
const vs = entry?.valueSource;
|
||||
if (Array.isArray(vs)) return vs;
|
||||
if (vs && typeof vs === 'object') return [vs];
|
||||
return [];
|
||||
};
|
||||
|
||||
// 写字段引用:无条目先创建(复用字段定义);引用清空时移除 valueSource 键
|
||||
const setFieldValueSource = (field: any, valueSource: any) => {
|
||||
if (!props.selectedNode?.data) return;
|
||||
const formConfig = props.selectedNode.data.formConfig || [];
|
||||
const idx = formConfig.findIndex((f: any) => f && f.field === field.field);
|
||||
const arr = Array.isArray(valueSource) ? valueSource : [];
|
||||
let updatedFormConfig;
|
||||
if (idx >= 0) {
|
||||
updatedFormConfig = [...formConfig];
|
||||
const { valueSource: _vs, ...rest } = updatedFormConfig[idx];
|
||||
updatedFormConfig[idx] = arr.length > 0 ? { ...rest, valueSource: arr } : rest;
|
||||
} else {
|
||||
updatedFormConfig = [...formConfig, { ...field, value: '', ...(arr.length > 0 ? { valueSource: arr } : {}) }];
|
||||
}
|
||||
const updatedNode = {
|
||||
...props.selectedNode,
|
||||
data: { ...props.selectedNode.data, formConfig: updatedFormConfig },
|
||||
};
|
||||
emit('update:selectedNode', updatedNode);
|
||||
};
|
||||
|
||||
const hasFieldRef = (fieldName: string) => getFieldValueSource(fieldName).length > 0;
|
||||
|
||||
// 引用展示列表:label 用字段中文名(不带「节点名.」前缀);引用来源失效标红
|
||||
const fieldRefList = (fieldName: string) =>
|
||||
getFieldValueSource(fieldName).map((r: any) => {
|
||||
const node = props.upstreamNodes?.find((n) => n.id === r?.nodeId);
|
||||
const fieldLabel = node?.outputFields.find((f: any) => f.field === r?.field)?.label || r?.field || '';
|
||||
const invalid = !node || !node.outputFields.some((f: any) => f.field === r?.field);
|
||||
return { nodeId: r?.nodeId, field: r?.field, label: fieldLabel, invalid };
|
||||
});
|
||||
|
||||
const removeFieldRef = (field: any, ref: any) => {
|
||||
const keep = getFieldValueSource(field.field).filter((r: any) => !(r?.nodeId === ref.nodeId && r?.field === ref.field));
|
||||
setFieldValueSource(field, keep);
|
||||
};
|
||||
|
||||
const clearFieldRef = (field: any) => setFieldValueSource(field, []);
|
||||
|
||||
// 字段引用可写代理:供 MfReferenceButton 就地读写 valueSource(set/deleteProperty 拦截到 formConfig 条目)
|
||||
const fieldRefDef = (field: any) =>
|
||||
new Proxy(
|
||||
{ ...field },
|
||||
{
|
||||
get(t, key) {
|
||||
if (key === 'valueSource') return getFieldValueSource(field.field);
|
||||
return Reflect.get(t, key);
|
||||
},
|
||||
set(t, key, value) {
|
||||
if (key === 'valueSource') {
|
||||
setFieldValueSource(field, value);
|
||||
return true;
|
||||
}
|
||||
return Reflect.set(t, key, value);
|
||||
},
|
||||
deleteProperty() {
|
||||
setFieldValueSource(field, undefined);
|
||||
return true;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// 失效引用自动清理:引用来源节点/字段被移除时过滤脏数据,避免保存时写入失效引用
|
||||
watch(
|
||||
() => {
|
||||
if (!props.selectedNode?.data?.nodeCode) return null;
|
||||
const entry = getFormConfigEntry('totalDuration');
|
||||
// 依赖 upstreamNodes 引用,保证删除上游节点/字段时触发清理
|
||||
return { vs: entry?.valueSource, upstream: props.upstreamNodes };
|
||||
},
|
||||
(state: any) => {
|
||||
const vs = state?.vs;
|
||||
if (!vs || typeof vs !== 'object') return;
|
||||
const arr = Array.isArray(vs) ? vs : [vs];
|
||||
const anyInvalid = arr.some((r: any) => {
|
||||
const node = props.upstreamNodes?.find((n) => n.id === r?.nodeId);
|
||||
return !node || !node.outputFields.some((f: any) => f.field === r?.field);
|
||||
});
|
||||
if (!anyInvalid) return;
|
||||
const keep = arr.filter((r: any) => {
|
||||
const node = props.upstreamNodes?.find((n) => n.id === r?.nodeId);
|
||||
return node && node.outputFields.some((f: any) => f.field === r?.field);
|
||||
});
|
||||
setFieldValueSource({ field: 'totalDuration' }, keep);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 当前选中下拉选项的嵌套 config 定义(来自节点库 presetOption)
|
||||
const getSelectedOptionConfig = (fieldDef: any) => {
|
||||
if (fieldDef?.type !== 'select' || !Array.isArray(fieldDef.options)) return [];
|
||||
const value = getFieldValue(fieldDef.field);
|
||||
const opt = fieldDef.options.find((o: any) => o.key === value || o.value === value);
|
||||
return opt?.config || [];
|
||||
};
|
||||
|
||||
// 读嵌套 expand 字段值
|
||||
const getExpandValue = (hostField: string, subField: string) => {
|
||||
const entry = getFormConfigEntry(hostField);
|
||||
const sub = entry?.expand?.find((e: any) => e.field === subField);
|
||||
return sub?.value ?? '';
|
||||
};
|
||||
|
||||
// 嵌套 keyValue 类型字段对象化(同 getKeyValueValue,供下拉选项嵌套配置使用)
|
||||
const getExpandKeyValueValue = (hostField: string, subField: string) => {
|
||||
const v = getExpandValue(hostField, subField);
|
||||
return v && typeof v === 'object' ? v : {};
|
||||
};
|
||||
|
||||
// 写嵌套 expand 字段值(单次 emit)
|
||||
const updateExpandValue = (hostField: string, subField: string, value: any) => {
|
||||
if (!props.selectedNode?.data) return;
|
||||
const formConfig = props.selectedNode.data.formConfig || [];
|
||||
const entryIndex = formConfig.findIndex((f: any) => f.field === hostField);
|
||||
if (entryIndex < 0) return;
|
||||
|
||||
const entry = formConfig[entryIndex];
|
||||
const expand = entry.expand || [];
|
||||
const subIndex = expand.findIndex((e: any) => e.field === subField);
|
||||
const newExpand = [...expand];
|
||||
if (subIndex >= 0) {
|
||||
newExpand[subIndex] = { ...newExpand[subIndex], value };
|
||||
} else {
|
||||
// 新建 expand 项时补 label(来源:当前选中选项的 config 定义),供下游引用面板展示可读名称
|
||||
const opt = (entry.options || []).find((o: any) => o.key === entry.value || o.value === entry.value);
|
||||
const cd = (opt?.config || []).find((c: any) => c.field === subField);
|
||||
newExpand.push({ field: subField, label: cd?.label || subField, value });
|
||||
}
|
||||
|
||||
const updatedFormConfig = [...formConfig];
|
||||
updatedFormConfig[entryIndex] = { ...entry, expand: newExpand };
|
||||
|
||||
const updatedNode = {
|
||||
...props.selectedNode,
|
||||
data: { ...props.selectedNode.data, formConfig: updatedFormConfig },
|
||||
};
|
||||
emit('update:selectedNode', updatedNode);
|
||||
};
|
||||
|
||||
// select 变更统一入口:更新 value 并按选中选项的 config 重建 expand(保留已有字段值)
|
||||
const updateSelectFieldValue = (fieldDef: any, value: any) => {
|
||||
if (!props.selectedNode?.data) return;
|
||||
const formConfig = props.selectedNode.data.formConfig || [];
|
||||
const entryIndex = formConfig.findIndex((f: any) => f.field === fieldDef.field);
|
||||
const opt = (fieldDef.options || []).find((o: any) => o.key === value || o.value === value);
|
||||
const configDefs = opt?.config || [];
|
||||
const oldExpand = entryIndex >= 0 ? formConfig[entryIndex]?.expand || [] : [];
|
||||
const newExpand = configDefs.map((cd: any) => {
|
||||
const old = oldExpand.find((e: any) => e.field === cd.field);
|
||||
return { ...cd, value: old?.value !== undefined ? old.value : (cd.default ?? '') };
|
||||
});
|
||||
|
||||
let updatedFormConfig;
|
||||
if (entryIndex >= 0) {
|
||||
updatedFormConfig = [...formConfig];
|
||||
updatedFormConfig[entryIndex] = { ...updatedFormConfig[entryIndex], value, expand: newExpand };
|
||||
} else {
|
||||
updatedFormConfig = [...formConfig, { ...fieldDef, value, expand: newExpand }];
|
||||
}
|
||||
|
||||
const updatedNode = {
|
||||
...props.selectedNode,
|
||||
data: { ...props.selectedNode.data, formConfig: updatedFormConfig },
|
||||
};
|
||||
emit('update:selectedNode', updatedNode);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 动态字段 label 行:字段名与引用按钮并排 */
|
||||
.dyn-field-label {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
/* 字段引用来源标签区(脚本转写节点「视频总时长」引用上级输出后展示) */
|
||||
.mf-ref-bound {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
|
||||
.mf-ref-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mf-ref-tag {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
@@ -256,6 +906,51 @@ const updateFieldValue = (fieldName: string, value: any) => {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.run-form-empty {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
padding: 8px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.run-form-list {
|
||||
.run-form-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 6px;
|
||||
background: #f8fafc;
|
||||
|
||||
.run-form-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.run-form-node {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
background: #eef2f7;
|
||||
border-radius: 999px;
|
||||
padding: 0 6px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.run-form-type {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-empty) {
|
||||
padding: 32px 12px;
|
||||
}
|
||||
|
||||
@@ -12,17 +12,17 @@
|
||||
<div v-if="!collapsed" class="node-library-content">
|
||||
<el-empty v-if="nodeLibraryGroups.length === 0" description="暂无节点" :image-size="40" />
|
||||
<div v-else class="node-library-groups">
|
||||
<div v-for="group in nodeLibraryGroups" :key="group.group" class="node-group">
|
||||
<div class="node-group-title">{{ group.label }}</div>
|
||||
<div v-for="group in nodeLibraryGroups" :key="group.group.key" class="node-group">
|
||||
<div class="node-group-title">{{ group.group.name }}</div>
|
||||
<div class="node-group-items">
|
||||
<el-button
|
||||
v-for="item in group.items"
|
||||
:key="item.nodeCode"
|
||||
v-for="item in group.nodes"
|
||||
:key="item.key"
|
||||
text
|
||||
class="node-item"
|
||||
@click="emit('addNode', item.nodeCode, item.nodeName)"
|
||||
@click="emit('addNode', item.key, item.name)"
|
||||
>
|
||||
{{ item.nodeName }}
|
||||
{{ item.name }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowLeftBold, ArrowRightBold } from '@element-plus/icons-vue';
|
||||
import type { NodeLibraryGroup } from '/@/api/settings/creation';
|
||||
import type { NodeLibraryGroup } from '/@/api/settings/workflow';
|
||||
|
||||
defineProps<{
|
||||
nodeLibraryGroups: NodeLibraryGroup[];
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="prompt-editor">
|
||||
<el-button type="primary" plain style="width: 100%" @click="visible = true">
|
||||
<el-icon><EditPen /></el-icon>
|
||||
{{ buttonText }}
|
||||
</el-button>
|
||||
<div v-if="modelValue" class="pe-filled">
|
||||
<span class="pe-filled-text" :title="modelValue">{{ modelValue }}</span>
|
||||
<el-button size="small" text type="danger" @click="clear">清除</el-button>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
width="720px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
>
|
||||
<el-input v-model="draft" type="textarea" :rows="12" maxlength="5000" show-word-limit placeholder="请输入提示词内容" />
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleConfirm">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { EditPen } from '@element-plus/icons-vue';
|
||||
|
||||
defineOptions({ name: 'PromptEditor' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string;
|
||||
buttonText?: string;
|
||||
dialogTitle?: string;
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
buttonText: '编辑提示词',
|
||||
dialogTitle: '编辑提示词',
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', value: string): void }>();
|
||||
|
||||
const visible = ref(false);
|
||||
const draft = ref('');
|
||||
|
||||
// 打开弹窗时同步当前值,关闭时丢弃未确认的草稿
|
||||
watch(visible, (val) => {
|
||||
if (val) draft.value = props.modelValue || '';
|
||||
});
|
||||
|
||||
const handleConfirm = () => {
|
||||
emit('update:modelValue', draft.value.trim());
|
||||
visible.value = false;
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
emit('update:modelValue', '');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.prompt-editor {
|
||||
width: 100%;
|
||||
|
||||
.pe-filled {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
|
||||
.pe-filled-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
color: #0369a1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="选择技能" width="900px" :close-on-click-modal="false" @close="handleClose">
|
||||
<div class="search-bar">
|
||||
<el-input v-model="searchParams.keyword" placeholder="搜索技能名称或描述" clearable @clear="handleSearch">
|
||||
<template #prefix
|
||||
><el-icon><Search /></el-icon
|
||||
></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
<div class="skill-list" v-loading="loading">
|
||||
<el-empty v-if="!loading && skillList.length === 0" description="暂无技能数据" :image-size="100" />
|
||||
<div v-else class="skill-grid">
|
||||
<div
|
||||
v-for="skill in skillList"
|
||||
:key="skill.id"
|
||||
class="skill-card"
|
||||
:class="{ selected: selectedSkill?.id === skill.id }"
|
||||
@click="handleSelectSkill(skill)"
|
||||
>
|
||||
<div class="skill-card-header">
|
||||
<div class="skill-category">{{ skill.category }}</div>
|
||||
<el-icon v-if="selectedSkill?.id === skill.id" class="check-icon" color="#67c23a"><CircleCheck /></el-icon>
|
||||
</div>
|
||||
<div class="skill-card-body">
|
||||
<h3 class="skill-name">{{ skill.name }}</h3>
|
||||
<p class="skill-desc">{{ skill.description || '暂无描述' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pagination.total > 0" class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.pageNum"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
layout="total, prev, pager, next"
|
||||
small
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleConfirm" :disabled="!selectedSkill">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import { Search, CircleCheck } from '@element-plus/icons-vue';
|
||||
import { getWorkflowSkillList, type WorkflowSkillItem } from '/@/api/settings/workflow';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
defaultSkill?: WorkflowSkillItem | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'confirm', skill: WorkflowSkillItem): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
defaultSkill: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = ref(false);
|
||||
const searchParams = reactive({ keyword: '' });
|
||||
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
|
||||
const skillList = ref<WorkflowSkillItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const selectedSkill = ref<WorkflowSkillItem | null>(null);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
visible.value = val;
|
||||
if (val) {
|
||||
selectedSkill.value = props.defaultSkill || null;
|
||||
pagination.pageNum = 1;
|
||||
fetchSkillList();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(visible, (val) => {
|
||||
if (!val) {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
});
|
||||
|
||||
const fetchSkillList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = { pageNum: pagination.pageNum, pageSize: pagination.pageSize, keyword: searchParams.keyword || undefined };
|
||||
const res = await getWorkflowSkillList(params);
|
||||
skillList.value = res.data?.list || [];
|
||||
pagination.total = res.data?.total || 0;
|
||||
// 预选项仅为部分信息(切换节点后无 id)时,按名称匹配当前页以高亮
|
||||
if (selectedSkill.value && !selectedSkill.value.id && selectedSkill.value.name) {
|
||||
const matched = skillList.value.find((s) => s.name === selectedSkill.value!.name);
|
||||
if (matched) selectedSkill.value = matched;
|
||||
}
|
||||
} catch {
|
||||
skillList.value = [];
|
||||
pagination.total = 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.pageNum = 1;
|
||||
fetchSkillList();
|
||||
};
|
||||
|
||||
const handlePageChange = () => {
|
||||
fetchSkillList();
|
||||
};
|
||||
|
||||
const handleSelectSkill = (skill: WorkflowSkillItem) => {
|
||||
selectedSkill.value = skill;
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedSkill.value) {
|
||||
emit('confirm', selectedSkill.value);
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
selectedSkill.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.skill-list {
|
||||
min-height: 300px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.skill-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.skill-card {
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.skill-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.skill-card.selected {
|
||||
border-color: #67c23a;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
.skill-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.skill-category {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.check-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
.skill-card-body {
|
||||
flex: 1;
|
||||
}
|
||||
.skill-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 8px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.skill-desc {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 40px;
|
||||
}
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="sub-flow-params">
|
||||
<template v-if="fields?.length">
|
||||
<!-- 薄封装:直接复用 ModelField 渲染引入参数。
|
||||
ModelField 就地写 def.value / def.valueSource / def.runtimeShow,
|
||||
fields 元素为同一引用,父组件读取同步无需额外同步。 -->
|
||||
<ModelField
|
||||
v-for="f in fields"
|
||||
:key="f.field"
|
||||
:field-def="f"
|
||||
:path="f.field"
|
||||
:upstream-nodes="upstreamNodes"
|
||||
:multi="multi"
|
||||
/>
|
||||
</template>
|
||||
<el-empty v-else description="该工作流无可引入的开始参数" :image-size="60" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue';
|
||||
import ModelField from './ModelField.vue';
|
||||
import type { SubFlowField } from './subFlowTypes';
|
||||
|
||||
defineOptions({ name: 'SubFlowParams' });
|
||||
|
||||
interface Props {
|
||||
fields: SubFlowField[];
|
||||
upstreamNodes?: any[];
|
||||
multi?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:fields', fields: SubFlowField[]): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
// ModelField 就地修改 def(value/valueSource/runtimeShow),deep watch 通知父级刷新节点引用
|
||||
// (fields 元素为共享引用,父级无需复制数组,仅重新 emit update:selectedNode 即可)
|
||||
watch(
|
||||
() => props.fields,
|
||||
(fields) => {
|
||||
emit('update:fields', fields);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sub-flow-params {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="选择要引入的工作流" width="900px" :close-on-click-modal="false" @close="handleClose">
|
||||
<div class="search-bar">
|
||||
<el-input v-model="searchParams.keyword" placeholder="搜索工作流名称或描述" clearable @clear="handleSearch">
|
||||
<template #prefix
|
||||
><el-icon><Search /></el-icon
|
||||
></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
<div class="workflow-list" v-loading="loading">
|
||||
<el-empty v-if="!loading && workflowList.length === 0" description="暂无工作流数据" :image-size="100" />
|
||||
<div v-else class="workflow-grid">
|
||||
<div
|
||||
v-for="workflow in workflowList"
|
||||
:key="workflow.id"
|
||||
class="workflow-card"
|
||||
:class="{ selected: selectedWorkflow?.id === workflow.id }"
|
||||
@click="handleSelectWorkflow(workflow)"
|
||||
>
|
||||
<div class="workflow-card-header">
|
||||
<span class="workflow-badge">我的工作流</span>
|
||||
<el-icon v-if="selectedWorkflow?.id === workflow.id" class="check-icon" color="#67c23a"><CircleCheck /></el-icon>
|
||||
</div>
|
||||
<div class="workflow-card-body">
|
||||
<h3 class="workflow-name">{{ workflow.flowName || workflow.flowTemplateName || workflow.name || '未命名工作流' }}</h3>
|
||||
<p class="workflow-desc">{{ workflow.description || '暂无描述' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pagination.total > 0" class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.pageNum"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
layout="total, prev, pager, next"
|
||||
small
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleConfirm" :disabled="!selectedWorkflow">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import { Search, CircleCheck } from '@element-plus/icons-vue';
|
||||
import { getSubFlowWorkflowList, type SubFlowWorkflowItem } from '/@/api/settings/workflow';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
defaultWorkflow?: SubFlowWorkflowItem | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'confirm', workflow: SubFlowWorkflowItem): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
defaultWorkflow: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = ref(false);
|
||||
const searchParams = reactive({ keyword: '' });
|
||||
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
|
||||
const workflowList = ref<SubFlowWorkflowItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const selectedWorkflow = ref<SubFlowWorkflowItem | null>(null);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
visible.value = val;
|
||||
if (val) {
|
||||
selectedWorkflow.value = props.defaultWorkflow || null;
|
||||
pagination.pageNum = 1;
|
||||
fetchWorkflowList();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(visible, (val) => {
|
||||
if (!val) {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
});
|
||||
|
||||
const fetchWorkflowList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
pageNum: pagination.pageNum,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchParams.keyword || undefined,
|
||||
IsOwn: true,
|
||||
};
|
||||
const res = await getSubFlowWorkflowList(params);
|
||||
const data = res.data || {};
|
||||
// 按登录角色分支读取:isAdmin=true(管理员)工作流在 listFlowTemplateRes,普通用户在 listFlowUserRes;
|
||||
// 兼容扁平 list 结构
|
||||
const isAdmin = !!data.isAdmin;
|
||||
const source = isAdmin ? data.listFlowTemplateRes : data.listFlowUserRes;
|
||||
const list = data.list ?? source?.list ?? [];
|
||||
workflowList.value = list || [];
|
||||
pagination.total = data.total ?? source?.total ?? workflowList.value.length;
|
||||
// 预选项仅为部分信息(切换节点后无 id)时,按名称匹配当前页以高亮
|
||||
// 名称字段兼容 flowName / flowTemplateName / name 三形态
|
||||
if (selectedWorkflow.value && !selectedWorkflow.value.id) {
|
||||
const selName = selectedWorkflow.value.flowName || selectedWorkflow.value.flowTemplateName || selectedWorkflow.value.name;
|
||||
if (selName) {
|
||||
const matched = workflowList.value.find((w) => (w.flowName || w.flowTemplateName || w.name) === selName);
|
||||
if (matched) selectedWorkflow.value = matched;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
workflowList.value = [];
|
||||
pagination.total = 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.pageNum = 1;
|
||||
fetchWorkflowList();
|
||||
};
|
||||
|
||||
const handlePageChange = () => {
|
||||
fetchWorkflowList();
|
||||
};
|
||||
|
||||
const handleSelectWorkflow = (workflow: SubFlowWorkflowItem) => {
|
||||
selectedWorkflow.value = workflow;
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedWorkflow.value) {
|
||||
emit('confirm', selectedWorkflow.value);
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
selectedWorkflow.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.workflow-list {
|
||||
min-height: 300px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.workflow-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.workflow-card {
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.workflow-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.workflow-card.selected {
|
||||
border-color: #67c23a;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
.workflow-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.workflow-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.check-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
.workflow-card-body {
|
||||
flex: 1;
|
||||
}
|
||||
.workflow-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 8px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.workflow-desc {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 40px;
|
||||
}
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,509 @@
|
||||
// 模型参数表单共用工具(workflow 独立实现,不依赖内容创作)
|
||||
|
||||
// 与项目一致的深拷贝方式
|
||||
export function deepClone<T>(val: T): T {
|
||||
return JSON.parse(JSON.stringify(val ?? null)) as T;
|
||||
}
|
||||
|
||||
// 按类型生成叶子字段空值
|
||||
export function defaultLeafValue(def: any): any {
|
||||
const t = def?.type || 'string';
|
||||
const ft = def?.fieldType || '';
|
||||
if (t === 'boolean' || ft === 'switch') return false;
|
||||
if (t === 'number' || ft === 'number') return 0;
|
||||
return '';
|
||||
}
|
||||
|
||||
// 归一化数组元素:元素为 { type:'object', attrs } 包装时,递归预填 attrs 子字段
|
||||
export function normalizeArrayItem(item: any): any {
|
||||
if (item && typeof item === 'object') {
|
||||
if (item.type === 'object' && item.attrs && typeof item.attrs === 'object' && !Array.isArray(item.attrs)) {
|
||||
Object.values(item.attrs).forEach(normalizeFieldDef);
|
||||
}
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
// 归一化字段定义:用 defaultValue 预填 value;递归处理 object/array 子树
|
||||
export function normalizeFieldDef(def: any): any {
|
||||
if (!def || typeof def !== 'object') return def;
|
||||
const t = def.type;
|
||||
if (t === 'object') {
|
||||
const attrs = def.attrs;
|
||||
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
|
||||
Object.values(attrs).forEach(normalizeFieldDef);
|
||||
}
|
||||
if (def.value === undefined || def.value === null) {
|
||||
def.value = def.defaultValue !== undefined && def.defaultValue !== null ? def.defaultValue : {};
|
||||
}
|
||||
} else if (t === 'array') {
|
||||
if (Array.isArray(def.value)) {
|
||||
def.value.forEach(normalizeArrayItem);
|
||||
} else if (Array.isArray(def.defaultValue)) {
|
||||
def.value = def.defaultValue.map((it: any) => normalizeArrayItem(deepClone(it)));
|
||||
} else {
|
||||
def.value = [];
|
||||
}
|
||||
} else {
|
||||
if (def.value === undefined || def.value === null) {
|
||||
def.value = def.defaultValue !== undefined && def.defaultValue !== null ? def.defaultValue : defaultLeafValue(def);
|
||||
}
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
// 归一化整个 requestBodyMapping 参数模板对象
|
||||
export function normalizeModelParams(params: any): Record<string, any> {
|
||||
if (!params || typeof params !== 'object') return {};
|
||||
Object.values(params).forEach(normalizeFieldDef);
|
||||
return params;
|
||||
}
|
||||
|
||||
// 剔除 isForm === false(只读)字段:不在节点表单中显示,且不随工作流保存。
|
||||
// 级联规则:容器若内部无可编辑子级则无意义,一并剔除
|
||||
// 1. 字段自身 isForm === false → 整体剔除
|
||||
// 2. object:剔除子字段后 attrs 为空(无可编辑子字段)→ object 一并剔除
|
||||
// 3. array:过滤「子字段全部只读」的元素模板;无可编辑模板 → array 一并剔除
|
||||
// 4. array 已添加元素内部被清空 → 一并移除
|
||||
export function stripReadonlyFields(params: any): any {
|
||||
if (!params || typeof params !== 'object' || Array.isArray(params)) return params;
|
||||
for (const key of Object.keys(params)) {
|
||||
const def = params[key];
|
||||
if (!def || typeof def !== 'object') continue;
|
||||
// 自身标记为只读 → 整体删除
|
||||
if (def.isForm === false) {
|
||||
delete params[key];
|
||||
continue;
|
||||
}
|
||||
if (def.type === 'object') {
|
||||
const attrs = def.attrs;
|
||||
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
|
||||
stripReadonlyFields(attrs);
|
||||
// 子字段全部只读 → object 无意义,一并剔除
|
||||
if (Object.keys(attrs).length === 0) {
|
||||
delete params[key];
|
||||
}
|
||||
}
|
||||
} else if (def.type === 'array') {
|
||||
let hasEditable = false;
|
||||
// 单一元素模板(attrs 为 object)
|
||||
if (def.attrs && typeof def.attrs === 'object' && !Array.isArray(def.attrs)) {
|
||||
stripReadonlyFields(def.attrs);
|
||||
if (Object.keys(def.attrs).length > 0) hasEditable = true;
|
||||
}
|
||||
// 多元素模板:优先 enumValues(与 ModelField.arrayTemplates 一致),其次 attrs 数组;过滤子字段全部只读的空模板
|
||||
const isEnumValues = Array.isArray(def.enumValues) && def.enumValues.length > 0;
|
||||
const isAttrsArray = Array.isArray(def.attrs) && def.attrs.length > 0;
|
||||
const templates = isEnumValues ? def.enumValues : isAttrsArray ? def.attrs : [];
|
||||
const keptTemplates = templates.filter((t: any) => {
|
||||
if (t && typeof t === 'object' && t.attrs && typeof t.attrs === 'object' && !Array.isArray(t.attrs)) {
|
||||
stripReadonlyFields(t.attrs);
|
||||
return Object.keys(t.attrs).length > 0;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// 有模板被剔除(子字段全只读)→ 数组索引被压缩;给保留的模板补记原始位置 __originIndex,
|
||||
// 供 buildModelRequestParamsPath 生成后端契约的 enumValues 原始索引(幂等:已带则不覆盖)
|
||||
if (keptTemplates.length !== templates.length) {
|
||||
templates.forEach((t: any, originIdx: number) => {
|
||||
if (t && typeof t === 'object' && keptTemplates.includes(t) && t.__originIndex === undefined) {
|
||||
t.__originIndex = originIdx;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (isEnumValues) def.enumValues = keptTemplates;
|
||||
if (isAttrsArray) def.attrs = keptTemplates;
|
||||
if (keptTemplates.length > 0) hasEditable = true;
|
||||
// 已添加元素内部清理后为空的 → 一并移除
|
||||
if (Array.isArray(def.value)) {
|
||||
def.value.forEach((it: any) => {
|
||||
if (it && typeof it === 'object' && it.attrs && typeof it.attrs === 'object' && !Array.isArray(it.attrs)) {
|
||||
stripReadonlyFields(it.attrs);
|
||||
}
|
||||
});
|
||||
def.value = def.value.filter((it: any) => {
|
||||
if (it && typeof it === 'object' && it.attrs && typeof it.attrs === 'object' && !Array.isArray(it.attrs)) {
|
||||
return Object.keys(it.attrs).length > 0;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
// 无可编辑内容 → array 无意义,一并剔除
|
||||
if (!hasEditable) {
|
||||
delete params[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
// 剔除 array 类型字段的实例值(value):后端模型定义中 array 仅保存结构/模板
|
||||
// (enumValues / attrs),前端渲染时从模板复制并填充的 value 实例是冗余数据,
|
||||
// 保存工作流时不提交。递归处理嵌套结构(array 字段可嵌套于 enumValues 模板中,
|
||||
// 如 messages.enumValues[0].attrs.content 仍是 array,同样需删除 value)。
|
||||
export function removeArrayValueInstances(params: any): any {
|
||||
if (!params || typeof params !== 'object' || Array.isArray(params)) return params;
|
||||
// 单个字段定义(带 type):array 删实例值并递归模板;object 递归 attrs
|
||||
if (typeof params.type === 'string') {
|
||||
if (params.type === 'array') {
|
||||
delete params.value;
|
||||
if (params.attrs && typeof params.attrs === 'object') {
|
||||
if (Array.isArray(params.attrs)) params.attrs.forEach((it: any) => removeArrayValueInstances(it));
|
||||
else removeArrayValueInstances(params.attrs);
|
||||
}
|
||||
if (Array.isArray(params.enumValues)) params.enumValues.forEach((tpl: any) => removeArrayValueInstances(tpl));
|
||||
} else if (params.type === 'object' && params.attrs && typeof params.attrs === 'object' && !Array.isArray(params.attrs)) {
|
||||
removeArrayValueInstances(params.attrs);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
// 字段定义容器 { key: fieldDef }:逐个字段递归
|
||||
for (const key of Object.keys(params)) {
|
||||
removeArrayValueInstances(params[key]);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
// ===== 暴露清单:在工作流表单中展示的勾选字段 =====
|
||||
|
||||
// array 的模板元素列表(与 ModelField.arrayTemplates 优先级一致):
|
||||
// enumValues 数组 → attrs 数组 → 单个 attrs 对象。勾选/值都跟随模板,故 path 用
|
||||
// enumValues[i] 定位模板,collect 与 resolve 共用本函数保持文法一致。
|
||||
function arrayTemplatesOf(def: any): any[] {
|
||||
if (!def || typeof def !== 'object') return [];
|
||||
if (Array.isArray(def.enumValues) && def.enumValues.length) return def.enumValues;
|
||||
if (Array.isArray(def.attrs) && def.attrs.length) return def.attrs;
|
||||
if (def.attrs && typeof def.attrs === 'object' && !Array.isArray(def.attrs)) return [{ type: 'object', attrs: def.attrs }];
|
||||
return [];
|
||||
}
|
||||
|
||||
// 单个暴露叶子字段(路径带模板索引,如 "messages.enumValues[0].attrs.content")
|
||||
export interface ExposedField {
|
||||
path: string;
|
||||
label: string;
|
||||
fieldType: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
options?: any[];
|
||||
value?: any;
|
||||
defaultValue?: any; // 默认值:首页初始化/回显用
|
||||
fieldConstraint?: any; // 字段约束:上传格式/大小/数量、数字 min/max 等
|
||||
valueSource?: any; // 引用上游节点输出([{ nodeId, field }] 数组,支持多选);有则首页只读展示
|
||||
multiple?: boolean; // 多文件上传标记
|
||||
refNodeId?: string; // 预留:引用其他节点功能(后续启用)
|
||||
}
|
||||
|
||||
// 将模型模板字段级约束(constraint,JsonEditor 风格:min/max/uploadRules/accept/maxSize/maxCount 等)
|
||||
// 归一化为首页运行表单消费的 fieldConstraint(minValue/maxValue/fileTypes/maxFileSize/maxFileCount)。
|
||||
// 模板字段约束实际挂在 constraint 键下(如 video_url.url 的 uploadRules、duration 的 min/numberType),
|
||||
// 与 collectExposedFields 原读取的 fieldConstraint 键不一致,需在此转换后随 modelFormFields 透传首页。
|
||||
function toFieldConstraint(constraint: any): any {
|
||||
if (!constraint || typeof constraint !== 'object') return undefined;
|
||||
const out: Record<string, any> = {};
|
||||
// 数字 min/max → 首页 el-input-number 的 minValue/maxValue
|
||||
if (constraint.min !== undefined && constraint.min !== null) out.minValue = constraint.min;
|
||||
if (constraint.max !== undefined && constraint.max !== null) out.maxValue = constraint.max;
|
||||
// 上传约束:uploadRules 数组取第一条;兼容顶层 accept/maxSize/maxCount 单值形式
|
||||
const rules = Array.isArray(constraint.uploadRules) ? constraint.uploadRules.filter((r: any) => r && typeof r === 'object') : [];
|
||||
const rule = rules[0] || null;
|
||||
const fileTypes = rule?.format ?? constraint.accept;
|
||||
if (fileTypes !== undefined && fileTypes !== null && fileTypes !== '') {
|
||||
// 模板 format 可能用中文顿号/空白分隔(如 wan 模型 "wav、mp3"),
|
||||
// 统一转英文逗号,保证首页 split(',') 与扩展名匹配正确
|
||||
out.fileTypes = String(fileTypes)
|
||||
.replace(/[、\s]+/g, ',')
|
||||
.replace(/,+/g, ',')
|
||||
.replace(/^,|,$/g, '');
|
||||
}
|
||||
const maxSize = rule?.maxSize ?? constraint.maxSize;
|
||||
if (maxSize !== undefined && maxSize !== null) out.maxFileSize = maxSize;
|
||||
const maxCount = rule?.maxCount ?? constraint.maxCount;
|
||||
if (maxCount !== undefined && maxCount !== null) out.maxFileCount = maxCount;
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
// 收集 runtimeShow === true 的叶子字段,生成暴露清单
|
||||
// 路径文法(与 restoreRuntimeShow 共用):
|
||||
// object 子字段 → 父路径 + ".attrs." + 子key
|
||||
// array 模板元素 → 父路径 + ".enumValues[i]"
|
||||
// 原始值数组元素(非 object 包装)不可勾选,跳过
|
||||
export function collectExposedFields(params: any, prefix = ''): ExposedField[] {
|
||||
if (!params || typeof params !== 'object') return [];
|
||||
const result: ExposedField[] = [];
|
||||
for (const key of Object.keys(params)) {
|
||||
const def = params[key];
|
||||
if (!def || typeof def !== 'object') continue;
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
const t = def.type;
|
||||
if (t === 'object') {
|
||||
const attrs = def.attrs;
|
||||
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
|
||||
result.push(...collectExposedFields(attrs, `${path}.attrs`));
|
||||
}
|
||||
} else if (t === 'array') {
|
||||
// 勾选跟随模板:渲染时 value 元素与模板共享引用,runtimeShow 落在模板上,
|
||||
// 故遍历模板(enumValues 优先)收集,path 用 enumValues[i] 定位模板
|
||||
const templates = arrayTemplatesOf(def);
|
||||
templates.forEach((tpl: any, i: number) => {
|
||||
if (tpl && typeof tpl === 'object' && tpl.attrs && typeof tpl.attrs === 'object' && !Array.isArray(tpl.attrs)) {
|
||||
// 索引与 collectAllLeafFields 一致:优先用 strip 时记录的原始模板索引 __originIndex,
|
||||
// 保证 modelFormFields 与 modelRequestParamsPath 的 path 索引对齐后端模型完整定义
|
||||
const originIdx = typeof tpl.__originIndex === 'number' ? tpl.__originIndex : i;
|
||||
result.push(...collectExposedFields(tpl.attrs, `${path}.enumValues[${originIdx}].attrs`));
|
||||
}
|
||||
});
|
||||
} else if (def.runtimeShow === true) {
|
||||
// 叶子且已勾选
|
||||
result.push({
|
||||
path,
|
||||
label: def.label || key,
|
||||
fieldType: def.fieldType || 'string',
|
||||
type: t || 'string',
|
||||
required: !!def.required,
|
||||
options: Array.isArray(def.options) ? def.options : undefined,
|
||||
value: def.value,
|
||||
defaultValue: def.defaultValue,
|
||||
// 优先透传已有的 fieldConstraint(首页格式);模板字段约束在 constraint 键时归一化补全
|
||||
fieldConstraint:
|
||||
(def.fieldConstraint && typeof def.fieldConstraint === 'object' ? def.fieldConstraint : undefined) ??
|
||||
toFieldConstraint(def.constraint),
|
||||
valueSource: (() => {
|
||||
const vs = def.valueSource;
|
||||
// 契约统一数组;兼容旧 DSL 单对象 { nodeId, field }
|
||||
return Array.isArray(vs) ? vs : vs && typeof vs === 'object' ? [vs] : undefined;
|
||||
})(),
|
||||
multiple: def.multiple || def.fieldType === 'uploadMultiple' || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== 模型请求参数扁平清单(modelRequestParamsPath)=====
|
||||
// 后端契约:保存时与 modelRequestParams 平级新增 modelRequestParamsPath,
|
||||
// 为嵌套模型参数树的全部叶子字段扁平清单(path 文法与 collectExposedFields 一致),
|
||||
// 后端按 path 重组请求参数。value 保留当前值;valueSource 规则(统一数组 [{ nodeId, field }]):
|
||||
// - 已有引用配置(用户在设计器配置「引用上游」)→ 原样保留数组
|
||||
// - 无引用但勾选「表单展示」(runtimeShow=true)→ 补 [{ nodeId: 开始节点id, field: path }]
|
||||
// - 其余 → 不带 valueSource
|
||||
export interface ModelRequestParamsPathItem {
|
||||
path: string;
|
||||
// 叶子字段中文名(回显/展示用;无 label 时兜底 path 最后一段字段名)
|
||||
label: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
value: any;
|
||||
valueSource?: any;
|
||||
}
|
||||
|
||||
// 收集嵌套树的全部叶子字段(含数组模板元素,不过滤 runtimeShow),返回 { path, def }
|
||||
function collectAllLeafFields(params: any, prefix = ''): { path: string; def: any }[] {
|
||||
if (!params || typeof params !== 'object' || Array.isArray(params)) return [];
|
||||
const result: { path: string; def: any }[] = [];
|
||||
for (const key of Object.keys(params)) {
|
||||
const def = params[key];
|
||||
if (!def || typeof def !== 'object') continue;
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
const t = def.type;
|
||||
if (t === 'object') {
|
||||
const attrs = def.attrs;
|
||||
if (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) {
|
||||
result.push(...collectAllLeafFields(attrs, `${path}.attrs`));
|
||||
}
|
||||
} else if (t === 'array') {
|
||||
// 数组字段遍历模板(enumValues 优先,其次 attrs 数组),path 用 enumValues[i] 定位模板,
|
||||
// 与 ModelField 渲染 / collectExposedFields 文法一致(值/勾选均落在模板上,不依赖 value 实例)。
|
||||
// 模板带 __originIndex(strip 时记录的原始位置)时用原始索引,保证与后端模型完整定义的 enumValues 编号一致
|
||||
const templates = arrayTemplatesOf(def);
|
||||
templates.forEach((tpl: any, i: number) => {
|
||||
if (tpl && typeof tpl === 'object' && tpl.attrs && typeof tpl.attrs === 'object' && !Array.isArray(tpl.attrs)) {
|
||||
const originIdx = typeof tpl.__originIndex === 'number' ? tpl.__originIndex : i;
|
||||
result.push(...collectAllLeafFields(tpl.attrs, `${path}.enumValues[${originIdx}].attrs`));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
result.push({ path, def });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 生成 modelRequestParamsPath 扁平清单
|
||||
// 所有叶子字段(含对象/数组内部)都支持「引用上游」,valueSource 原样保留;
|
||||
// 与编辑器「引用上级输出与表单展示同级」语义一致
|
||||
export function buildModelRequestParamsPath(
|
||||
params: any,
|
||||
startNodeId = '',
|
||||
// 节点输出索引(id → 节点名 + 字段 label),用于补全 valueSource 的引用来源 label
|
||||
outputIndex?: Map<string, { name: string; fields: Map<string, string> }>
|
||||
): ModelRequestParamsPathItem[] {
|
||||
const fields = collectAllLeafFields(params);
|
||||
// 引用来源 label:字段中文名(不带「节点名.」前缀;无索引/未命中时兜底 field)
|
||||
const resolveRefLabel = (nodeId: string, field: string): string => {
|
||||
const info = outputIndex?.get(nodeId);
|
||||
if (!info) return field;
|
||||
return info.fields.get(field) || field;
|
||||
};
|
||||
return fields.map(({ path, def }) => {
|
||||
let valueSource: any;
|
||||
if (Array.isArray(def.valueSource)) {
|
||||
// 引用上游:前端编辑态 [{ nodeId, field }] 数组,逐项补 label
|
||||
valueSource = def.valueSource.map((r: any) =>
|
||||
r && r.nodeId && r.field
|
||||
? { nodeId: r.nodeId, field: r.field, label: resolveRefLabel(r.nodeId, r.field) }
|
||||
: r
|
||||
);
|
||||
} else if (def.valueSource && typeof def.valueSource === 'object') {
|
||||
// 兼容旧 DSL 单对象 { nodeId, field }
|
||||
const r = def.valueSource as any;
|
||||
valueSource = [
|
||||
r && r.nodeId && r.field
|
||||
? { nodeId: r.nodeId, field: r.field, label: resolveRefLabel(r.nodeId, r.field) }
|
||||
: r,
|
||||
];
|
||||
} else if (def.runtimeShow === true && startNodeId) {
|
||||
// 勾选「表单展示」:补后端契约数组(指向开始节点,label 取开始节点字段名)
|
||||
valueSource = [{ nodeId: startNodeId, field: path, label: resolveRefLabel(startNodeId, path) }];
|
||||
}
|
||||
return {
|
||||
path,
|
||||
label: def.label || path.split('.').pop() || '',
|
||||
type: def.type || 'string',
|
||||
required: Boolean(def.required),
|
||||
value: def.value ?? '',
|
||||
...(valueSource ? { valueSource } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 按暴露清单的 path 反解,把对应叶子 def 的 runtimeShow 置 true(加载时还原勾选)
|
||||
// startNodeId:勾选「表单展示」的字段保存时带 valueSource 指向开始节点(后端契约);
|
||||
// 回显时该 valueSource 无编辑器引用语义,清除避免 ModelField 误判引用/误删
|
||||
export function restoreRuntimeShow(params: any, fields: ExposedField[] | null | undefined, startNodeId = ''): void {
|
||||
if (!params || typeof params !== 'object' || !Array.isArray(fields) || fields.length === 0) return;
|
||||
for (const f of fields) {
|
||||
if (!f || typeof f.path !== 'string') continue;
|
||||
const cur = resolvePath(params, f.path);
|
||||
if (cur && typeof cur === 'object' && !Array.isArray(cur)) {
|
||||
cur.runtimeShow = true;
|
||||
if (startNodeId && cur.valueSource && typeof cur.valueSource === 'object') {
|
||||
// 移除指向开始节点(表单展示标记)的引用;兼容旧 DSL 单对象(统一数组化),全部移除后删除整个 valueSource
|
||||
const arr = Array.isArray(cur.valueSource) ? cur.valueSource : [cur.valueSource];
|
||||
const keep = arr.filter((r: any) => !(r && r.nodeId === startNodeId));
|
||||
if (keep.length === 0) delete cur.valueSource;
|
||||
else cur.valueSource = keep;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 勾选「表单展示」的字段:保存前按后端契约补 valueSource={nodeId:开始节点id, field:path},
|
||||
// 标记字段值来自主工作流开始节点(首页表单)。startNodeId 为空(无开始节点)时跳过。
|
||||
export function attachFormFieldValueSources(params: any, fields: ExposedField[] | null | undefined, startNodeId = ''): void {
|
||||
if (!params || typeof params !== 'object' || !Array.isArray(fields) || fields.length === 0 || !startNodeId) return;
|
||||
for (const f of fields) {
|
||||
if (!f || typeof f.path !== 'string') continue;
|
||||
const cur = resolvePath(params, f.path);
|
||||
if (cur && typeof cur === 'object' && !Array.isArray(cur)) {
|
||||
// 已有指向上游节点的真实引用时保持互斥:不覆盖为开始节点标记,避免引用丢失
|
||||
const vs = cur.valueSource;
|
||||
const hasRealRef = Array.isArray(vs)
|
||||
? vs.some((r: any) => r && r.nodeId && r.nodeId !== startNodeId)
|
||||
: !!vs && typeof vs === 'object' && vs.nodeId && vs.nodeId !== startNodeId;
|
||||
if (!hasRealRef) {
|
||||
cur.valueSource = [{ nodeId: startNodeId, field: f.path }];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按路径走回 params 树;解析失败(schema 变更导致字段缺失)返回 undefined
|
||||
function resolvePath(params: any, path: string): any {
|
||||
if (!path) return undefined;
|
||||
const segments = path.split('.');
|
||||
let cur = params;
|
||||
for (const seg of segments) {
|
||||
if (cur === undefined || cur === null || typeof cur !== 'object' || Array.isArray(cur)) return undefined;
|
||||
if (seg === 'attrs') {
|
||||
cur = cur.attrs;
|
||||
if (!cur || typeof cur !== 'object') return undefined;
|
||||
} else {
|
||||
const mv = seg.match(/^value\[(\d+)\]$/);
|
||||
if (mv) {
|
||||
const idx = Number(mv[1]);
|
||||
if (!Array.isArray(cur.value) || idx >= cur.value.length) return undefined;
|
||||
cur = cur.value[idx];
|
||||
continue;
|
||||
}
|
||||
const me = seg.match(/^enumValues\[(\d+)\]$/);
|
||||
if (me) {
|
||||
const idx = Number(me[1]);
|
||||
const templates = arrayTemplatesOf(cur);
|
||||
// 路径索引可能为 strip 压缩前记录的原始索引(__originIndex),优先按原始索引匹配;
|
||||
// 未带 __originIndex 时退化为按数组位置匹配(未压缩场景)
|
||||
const byOrigin = templates.find((t: any) => t && typeof t.__originIndex === 'number' && t.__originIndex === idx);
|
||||
if (byOrigin !== undefined) cur = byOrigin;
|
||||
else if (idx < templates.length) cur = templates[idx];
|
||||
else return undefined;
|
||||
continue;
|
||||
}
|
||||
cur = cur[seg];
|
||||
}
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
// 深度比较(JSON 序列化方式),供 index.vue 的"仅变更才写"守卫使用
|
||||
export function isEqual(a: any, b: any): boolean {
|
||||
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
}
|
||||
|
||||
// ===== 模型返回参数中文 label(引用展示用)=====
|
||||
// 模型 responseMapping(响应映射:路径→中文)支持两种结构:普通对象 {路径: 中文} 与
|
||||
// { type, attrs/value, label } 包裹格式。独立实现平铺(不依赖 settings/modelConfigV2),
|
||||
// 供保存 modelResponseBodyMapping 时把中文描述填入 value,引用上级节点输出展示中文而非英文 key。
|
||||
function flattenResponseLabelMap(mapping: any, basePath = '', out: Record<string, string>): void {
|
||||
if (!mapping || typeof mapping !== 'object') return;
|
||||
// { type, attrs/value } 包裹格式:叶子节点带 label
|
||||
if (typeof mapping.type === 'string' && ['string', 'number', 'boolean', 'null', 'object', 'array'].includes(mapping.type)) {
|
||||
const jtype = mapping.type;
|
||||
const dataKey = jtype === 'object' || jtype === 'array' ? 'attrs' : 'value';
|
||||
if (jtype === 'object' && dataKey in mapping && mapping[dataKey] && typeof mapping[dataKey] === 'object' && !Array.isArray(mapping[dataKey])) {
|
||||
for (const [key, val] of Object.entries(mapping[dataKey])) {
|
||||
flattenResponseLabelMap(val, basePath ? `${basePath}.attrs.${key}` : key, out);
|
||||
}
|
||||
} else if (jtype === 'array' && dataKey in mapping && Array.isArray(mapping[dataKey])) {
|
||||
mapping[dataKey].forEach((item: any, idx: number) => {
|
||||
flattenResponseLabelMap(item, `${basePath}.attrs[${idx}]`, out);
|
||||
});
|
||||
} else if (['string', 'number', 'boolean', 'null'].includes(jtype)) {
|
||||
if (mapping.label) out[basePath] = String(mapping.label);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 普通对象(非包裹格式)
|
||||
if (!Array.isArray(mapping)) {
|
||||
for (const [key, val] of Object.entries(mapping)) {
|
||||
const childPath = basePath ? `${basePath}.${key}` : key;
|
||||
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
||||
flattenResponseLabelMap(val, childPath, out);
|
||||
} else if (typeof val === 'string' && val) {
|
||||
out[childPath] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 用模型 responseMapping 的中文描述填充 modelResponseBodyMapping 的 value:
|
||||
// { key: '' } → { key: '中文' }。结构不变(对象 key→string),无对应中文时保留原 value(兜底 key)。
|
||||
export function enrichResponseBodyMapping(body: any, mapping: any): any {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) return body ?? null;
|
||||
const labelMap: Record<string, string> = {};
|
||||
flattenResponseLabelMap(mapping, '', labelMap);
|
||||
const out: Record<string, string> = {};
|
||||
for (const k of Object.keys(body)) {
|
||||
const raw = typeof body[k] === 'string' ? body[k] : '';
|
||||
out[k] = labelMap[k] || raw || k;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// 子流程节点(sub_flow)引入工作流的类型定义(workflow 独立实现,不依赖内容创作)
|
||||
|
||||
// ===== 编辑器态(node.data.subFlowConfig)=====
|
||||
export interface SubFlowField {
|
||||
// = 目标工作流开始节点 runFormFields 的 field(后端匹配键,不可改)
|
||||
field: string;
|
||||
label: string;
|
||||
// 控件类型:input/number/textarea/switch/select/upload/uploadMultiple
|
||||
type: string;
|
||||
fieldType: string;
|
||||
required: boolean;
|
||||
// 未引用且未勾选表单展示时的静态值(编辑器可填)
|
||||
value?: any;
|
||||
defaultValue?: any;
|
||||
// upload {fileTypes,maxFileSize,maxFileCount} / number {minValue,maxValue}
|
||||
fieldConstraint?: any;
|
||||
// select 选项
|
||||
options?: any[];
|
||||
// 多文件上传标记
|
||||
multiple?: boolean;
|
||||
// 引用上级节点输出(主工作流上游);有值则编辑器内只读引用展示
|
||||
valueSource?: { nodeId: string; field: string }[] | null;
|
||||
// 编辑器内「表单展示」勾选(ModelField 用 runtimeShow)
|
||||
runtimeShow?: boolean;
|
||||
}
|
||||
|
||||
export interface SubFlowConfig {
|
||||
// 目标工作流 id(后端执行 sub_flow 时引入)
|
||||
workflowId: string;
|
||||
workflowName: string;
|
||||
// 生成次数(sub_flow 执行并发数;未填为 0)
|
||||
maxConcurrency?: number;
|
||||
fields: SubFlowField[];
|
||||
}
|
||||
|
||||
// ===== DSL 保存态(node.subConfig,runtimeShow → 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[];
|
||||
}
|
||||
+1085
-267
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div class="editor-body">
|
||||
<div class="editor-col">
|
||||
<!-- 周期订阅 -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">周期订阅</span>
|
||||
<span class="head-hint">按周期一次性收取,与用量无关</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<el-form label-position="top" size="default">
|
||||
<el-form-item label="周期 period">
|
||||
<el-select v-model="period" class="ctrl-select">
|
||||
<el-option label="year(按年)" value="year" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="每周期价格(元)">
|
||||
<el-input-number
|
||||
v-model="price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
:controls="false"
|
||||
class="ctrl-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="form-hint">本期支持按年订阅(period=year);订阅购买 / 续费流程接入另期。</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-col">
|
||||
<!-- 通用配置 -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">通用配置</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<el-form label-position="top" size="default">
|
||||
<el-form-item label="门禁 min_balance(元)">
|
||||
<el-input-number
|
||||
v-model="minBalance"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
:controls="false"
|
||||
class="ctrl-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="币种">
|
||||
<el-select v-model="currency" class="ctrl-select">
|
||||
<el-option label="人民币 (CNY)" value="CNY" />
|
||||
<el-option label="美元 (USD)" value="USD" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="enabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import {
|
||||
type BusinessRules,
|
||||
type Currency,
|
||||
type EditorInitConfig,
|
||||
type Subject,
|
||||
} from '../mock';
|
||||
|
||||
/**
|
||||
* 业务模块计价编辑面板。
|
||||
* 周期订阅 per_period:period 固定 year,价格 + 公共配置。
|
||||
* initConfig 传入后端已保存配置(parse 后)时初始化表单;无配置时用默认值。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
subject: Subject;
|
||||
initConfig: EditorInitConfig | null;
|
||||
}>();
|
||||
|
||||
const period = ref<'year'>('year');
|
||||
const price = ref(1999);
|
||||
const minBalance = ref(0);
|
||||
const currency = ref<Currency>('CNY');
|
||||
const enabled = ref(true);
|
||||
|
||||
/** 用后端已保存配置初始化表单 */
|
||||
function applyConfig(cfg: EditorInitConfig) {
|
||||
const r = cfg.rules as BusinessRules;
|
||||
period.value = r.period;
|
||||
price.value = r.price;
|
||||
minBalance.value = cfg.minBalance;
|
||||
currency.value = cfg.currency;
|
||||
enabled.value = cfg.enabled;
|
||||
}
|
||||
|
||||
/** 重置为默认(无配置) */
|
||||
function resetDefault() {
|
||||
period.value = 'year';
|
||||
price.value = 1999;
|
||||
minBalance.value = 0;
|
||||
currency.value = 'CNY';
|
||||
enabled.value = true;
|
||||
}
|
||||
|
||||
function save(): { rules: BusinessRules; minBalance: number; currency: Currency; enabled: boolean } {
|
||||
return {
|
||||
rules: { period: period.value, price: price.value },
|
||||
minBalance: minBalance.value,
|
||||
currency: currency.value,
|
||||
enabled: enabled.value,
|
||||
};
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initConfig,
|
||||
(cfg) => {
|
||||
if (cfg && cfg.rules && (cfg.rules as BusinessRules).period) {
|
||||
applyConfig(cfg);
|
||||
} else {
|
||||
resetDefault();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
defineExpose({ save });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.editor-body {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-col {
|
||||
flex: 1;
|
||||
min-width: 380px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
.card-head {
|
||||
background: #fafafa;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.head-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ctrl-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.ctrl-select {
|
||||
width: 220px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,578 @@
|
||||
<template>
|
||||
<div class="editor-body">
|
||||
<div class="editor-col">
|
||||
<!-- 基础计费 -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">基础计费</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<el-form label-position="top" size="default">
|
||||
<el-form-item label="计费单位 unit">
|
||||
<el-select v-model="unit" class="ctrl-select" @change="onUnitChange">
|
||||
<el-option v-for="o in UNIT_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="阶梯分档 tiered">
|
||||
<el-switch v-model="tiered" :disabled="!isTokenUnit" />
|
||||
</el-form-item>
|
||||
<div class="form-hint">阶梯=按输入 token 分档,<b>命中哪档整单按哪档价(不累加)</b>。档位区间相邻不重叠(下档 max+1 = 上档 min)。仅 token 单位(per_1K / per_1M)可用。</div>
|
||||
<div class="money-note">{{ priceNote }}</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 计费规则 -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">计费规则</span>
|
||||
<span class="head-select">
|
||||
<el-select :model-value="exampleIndex" size="small" placeholder="选择示例填充" @change="loadExample">
|
||||
<el-option v-for="(e, i) in examples" :key="i" :label="e.label" :value="i" />
|
||||
</el-select>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-for="(r, i) in modelRules" :key="i" class="rule-card">
|
||||
<div class="rule-head">
|
||||
<span class="rule-index">#{{ i + 1 }}</span>
|
||||
<el-input v-model="r.name" placeholder="规则名(如:有声-1080p-输入含视频)" class="rule-name" />
|
||||
<el-button size="small" type="danger" text @click="removeRule(i)">删除</el-button>
|
||||
</div>
|
||||
|
||||
<div class="rc-sec match">命中条件 match</div>
|
||||
<el-form label-position="top" size="small">
|
||||
<div class="rc-grid">
|
||||
<template v-if="subject.modelType === 'reason'">
|
||||
<el-form-item label="思考模式">
|
||||
<el-select v-model="r.thinking" class="rc-control">
|
||||
<el-option v-for="o in THINKING_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="输入长度下界">
|
||||
<el-input-number v-model="r.inLenMin" :min="0" :precision="0" :controls="false" class="rc-control" />
|
||||
</el-form-item>
|
||||
<el-form-item label="输入长度上界">
|
||||
<el-input-number v-model="r.inLenMax" :min="0" :precision="0" :controls="false" class="rc-control" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-if="subject.modelType === 'video'">
|
||||
<el-form-item label="输出音频">
|
||||
<el-select v-model="r.outputAudio" class="rc-control">
|
||||
<el-option v-for="o in OUTPUT_AUDIO_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="输出分辨率">
|
||||
<el-select v-model="r.outputResolution" class="rc-control">
|
||||
<el-option v-for="o in RESOLUTION_OPTIONS.video" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-if="subject.modelType === 'image'">
|
||||
<el-form-item label="输出分辨率">
|
||||
<el-select v-model="r.outputResolution" class="rc-control">
|
||||
<el-option v-for="o in RESOLUTION_OPTIONS.image" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<div class="rc-sec price">计价项 price</div>
|
||||
<el-form label-position="top" size="small">
|
||||
<div class="rc-grid">
|
||||
<template v-if="isTokenUnit">
|
||||
<el-form-item label="输入单价 input">
|
||||
<el-input-number v-model="r.input" :min="0" :precision="6" :step="0.0001" :controls="false" class="rc-control" />
|
||||
</el-form-item>
|
||||
<el-form-item label="输出单价 output">
|
||||
<el-input-number v-model="r.output" :min="0" :precision="6" :step="0.0001" :controls="false" class="rc-control" />
|
||||
</el-form-item>
|
||||
<el-form-item label="缓存命中 cacheHit">
|
||||
<el-input-number v-model="r.cacheHit" :min="0" :precision="6" :step="0.0001" :controls="false" class="rc-control" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-form-item label="每单位单价 unitPrice">
|
||||
<el-input-number v-model="r.unitPrice" :min="0" :precision="6" :step="0.0001" :controls="false" class="rc-control" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<!-- 媒体价格模板(仅 token 单位):媒体命中条件已移除,改为对特定媒体类型设独立覆盖价 mediaPrices -->
|
||||
<div v-if="isTokenUnit" class="media-price-section">
|
||||
<div class="rc-sec price">媒体价格模板 mediaPrices(可选)</div>
|
||||
<div v-for="(mp, mIdx) in r.mediaPrices" :key="mIdx" class="media-price-row">
|
||||
<el-select v-model="mp.mediaType" class="mp-media" placeholder="媒体类型">
|
||||
<el-option v-for="o in getAvailableMediaOptions(r, mIdx)" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
<el-input-number v-model="mp.input" :min="0" :precision="6" :step="0.0001" :controls="false" class="mp-num" placeholder="input" />
|
||||
<el-input-number v-model="mp.output" :min="0" :precision="6" :step="0.0001" :controls="false" class="mp-num" placeholder="output" />
|
||||
<el-input-number v-model="mp.cacheHit" :min="0" :precision="6" :step="0.0001" :controls="false" class="mp-num" placeholder="cacheHit" />
|
||||
<el-button type="danger" text size="small" @click="removeMediaPrice(r, mIdx)">删除</el-button>
|
||||
</div>
|
||||
<el-button size="small" class="media-add-btn" @click="addMediaPrice(r)">+ 添加媒体价格</el-button>
|
||||
<div class="mp-tip">基础价(input/output/cacheHit)为默认价;媒体模板仅覆盖指定媒体类型的价,未选媒体类型的行保存时丢弃。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button size="small" class="add-btn" @click="addRule">+ 添加规则</el-button>
|
||||
<div class="note mt10">
|
||||
结算入参(ChargeUsage 扩展,本期备用):<code>{promptTokens, completionTokens, cachedTokens, charCount, imageCount, mediaType, thinking, outputAudio, outputResolution}</code>。匹配 = 全部条件满足的<b>首条规则</b>命中,无命中报错。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-col">
|
||||
<!-- 通用配置 -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">通用配置</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<el-form label-position="top" size="default">
|
||||
<el-form-item label="门禁 min_balance(元)">
|
||||
<el-input-number
|
||||
v-model="minBalance"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
:controls="false"
|
||||
class="ctrl-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="币种">
|
||||
<el-select v-model="currency" class="ctrl-select">
|
||||
<el-option label="人民币 (CNY)" value="CNY" />
|
||||
<el-option label="美元 (USD)" value="USD" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="enabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import {
|
||||
EXAMPLES,
|
||||
MEDIA_PRICE_OPTIONS,
|
||||
OUTPUT_AUDIO_OPTIONS,
|
||||
RESOLUTION_OPTIONS,
|
||||
THINKING_OPTIONS,
|
||||
UNIT_OPTIONS,
|
||||
type BuiltModelRule,
|
||||
type BuiltModelRuleMatch,
|
||||
type Currency,
|
||||
type EditorInitConfig,
|
||||
type MediaPriceRow,
|
||||
type ModelRule,
|
||||
type ModelRules,
|
||||
type Subject,
|
||||
type Unit,
|
||||
} from '../mock';
|
||||
|
||||
/**
|
||||
* 模型计价编辑面板(按模型类型自适应)。
|
||||
* 计费单位决定计价项(token 单位 input/output/cacheHit,非 token 单位 unitPrice);
|
||||
* 命中条件 match 字段随模型类型变化(reason/video/audio/image)。
|
||||
* initConfig 传入后端已保存配置(parse 后)时初始化表单;无配置时自动加载本地示例。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
subject: Subject;
|
||||
initConfig: EditorInitConfig | null;
|
||||
}>();
|
||||
|
||||
const examples = computed(() => EXAMPLES[props.subject.modelType!] ?? []);
|
||||
|
||||
const unit = ref<Unit>('per_second');
|
||||
const tiered = ref(false);
|
||||
const currency = ref<'CNY' | 'USD'>('CNY');
|
||||
const enabled = ref(true);
|
||||
const minBalance = ref(0);
|
||||
const modelRules = ref<ModelRule[]>([]);
|
||||
const exampleIndex = ref(0);
|
||||
|
||||
const isTokenUnit = computed(() => unit.value === 'per_1K' || unit.value === 'per_1M');
|
||||
|
||||
const priceNote = computed(() =>
|
||||
isTokenUnit.value
|
||||
? 'token 单位价格按 元/基准(per_1M=每百万 token)计:input=输入单价、output=输出单价、cacheHit=缓存命中输入单价。'
|
||||
: '非 token 单位价格按 元/单位 计:unitPrice=每单位单价(秒/分钟/小时/字/张)。',
|
||||
);
|
||||
|
||||
function emptyRule(): ModelRule {
|
||||
return {
|
||||
name: '',
|
||||
thinking: '',
|
||||
outputAudio: '',
|
||||
outputResolution: '',
|
||||
inLenMin: undefined,
|
||||
inLenMax: undefined,
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheHit: 0,
|
||||
unitPrice: 0,
|
||||
mediaPrices: [],
|
||||
};
|
||||
}
|
||||
|
||||
function loadExample(index: number) {
|
||||
const ex = examples.value[index];
|
||||
if (!ex) return;
|
||||
exampleIndex.value = index;
|
||||
unit.value = ex.unit;
|
||||
tiered.value = !!ex.tiered;
|
||||
if (ex.currency) currency.value = ex.currency;
|
||||
if (ex.minBalance != null) minBalance.value = ex.minBalance;
|
||||
modelRules.value = ex.rules.map((r) => ({
|
||||
name: r.name,
|
||||
thinking: (r.thinking == null ? '' : String(r.thinking)) as ModelRule['thinking'],
|
||||
outputAudio: (r.outputAudio == null ? '' : String(r.outputAudio)) as ModelRule['outputAudio'],
|
||||
outputResolution: r.outputResolution || '',
|
||||
inLenMin: r.inLenMin == null ? undefined : r.inLenMin,
|
||||
inLenMax: r.inLenMax == null ? undefined : r.inLenMax,
|
||||
input: r.input ?? 0,
|
||||
output: r.output ?? 0,
|
||||
cacheHit: r.cacheHit ?? 0,
|
||||
unitPrice: r.unitPrice ?? 0,
|
||||
mediaPrices: mediaPricesToRows(r.mediaPrices),
|
||||
}));
|
||||
onUnitChange();
|
||||
}
|
||||
|
||||
function onUnitChange() {
|
||||
// 非 token 单位强制关闭阶梯
|
||||
if (!isTokenUnit.value) tiered.value = false;
|
||||
}
|
||||
|
||||
function addRule() {
|
||||
modelRules.value.push(emptyRule());
|
||||
}
|
||||
|
||||
function removeRule(index: number) {
|
||||
modelRules.value.splice(index, 1);
|
||||
}
|
||||
|
||||
/** 构建态规则 → 编辑器行态(parse 反向映射) */
|
||||
function fromBuiltRule(r: BuiltModelRule): ModelRule {
|
||||
const m = r.match;
|
||||
return {
|
||||
name: r.name,
|
||||
thinking: (m?.thinking == null ? '' : String(m.thinking)) as ModelRule['thinking'],
|
||||
outputAudio: (m?.outputAudio == null ? '' : String(m.outputAudio)) as ModelRule['outputAudio'],
|
||||
outputResolution: m?.outputResolution || '',
|
||||
inLenMin: m?.inputLengthMin,
|
||||
inLenMax: m?.inputLengthMax,
|
||||
input: r.price.input ?? 0,
|
||||
output: r.price.output ?? 0,
|
||||
cacheHit: r.price.cacheHit ?? 0,
|
||||
unitPrice: r.price.unitPrice ?? 0,
|
||||
mediaPrices: mediaPricesToRows(r.mediaPrices),
|
||||
};
|
||||
}
|
||||
|
||||
/** 用后端已保存配置初始化表单 */
|
||||
function applyInit(cfg: EditorInitConfig) {
|
||||
const r = cfg.rules as ModelRules;
|
||||
unit.value = r.unit;
|
||||
tiered.value = !!r.tiered;
|
||||
currency.value = r.currency || cfg.currency;
|
||||
enabled.value = cfg.enabled;
|
||||
minBalance.value = cfg.minBalance;
|
||||
modelRules.value = (r.rules || []).map(fromBuiltRule);
|
||||
exampleIndex.value = -1;
|
||||
if (!isTokenUnit.value) tiered.value = false;
|
||||
}
|
||||
|
||||
/** 过滤空白规则:规则名或任一命中/价格字段非空才保留 */
|
||||
function keepRule(r: ModelRule): boolean {
|
||||
if (r.name.trim()) return true;
|
||||
if (r.thinking || r.outputAudio || r.outputResolution) return true;
|
||||
if (r.inLenMin != null || r.inLenMax != null) return true;
|
||||
if (r.input > 0 || r.output > 0 || r.cacheHit > 0 || r.unitPrice > 0) return true;
|
||||
// 仅填写了媒体价格模板也算有效规则
|
||||
return !!buildMediaPrices(r.mediaPrices);
|
||||
}
|
||||
|
||||
/** 编辑器行态 → 构建态规则(match 只收录非空字段) */
|
||||
function buildBuiltRule(r: ModelRule): BuiltModelRule {
|
||||
const match: BuiltModelRuleMatch = {};
|
||||
if (r.thinking === 'true' || r.thinking === 'false') match.thinking = r.thinking === 'true';
|
||||
if (r.outputAudio === 'true' || r.outputAudio === 'false') match.outputAudio = r.outputAudio === 'true';
|
||||
if (r.outputResolution) match.outputResolution = r.outputResolution;
|
||||
if (r.inLenMin != null) match.inputLengthMin = r.inLenMin;
|
||||
if (r.inLenMax != null) match.inputLengthMax = r.inLenMax;
|
||||
const price: Record<string, number> = isTokenUnit.value
|
||||
? { input: r.input ?? 0, output: r.output ?? 0, cacheHit: r.cacheHit ?? 0 }
|
||||
: { unitPrice: r.unitPrice ?? 0 };
|
||||
return {
|
||||
name: r.name,
|
||||
match: Object.keys(match).length ? match : undefined,
|
||||
price,
|
||||
mediaPrices: buildMediaPrices(r.mediaPrices),
|
||||
};
|
||||
}
|
||||
|
||||
/** 媒体价格行 → mediaPrices 对象(仅保留填写了价格的媒体;无覆盖返回 undefined 不写入) */
|
||||
function buildMediaPrices(rows: MediaPriceRow[]): Record<string, Record<string, number>> | undefined {
|
||||
if (!rows?.length) return undefined;
|
||||
const map: Record<string, Record<string, number>> = {};
|
||||
rows.forEach((row) => {
|
||||
if (!row.mediaType) return;
|
||||
const prices: Record<string, number> = {};
|
||||
if (row.input > 0) prices.input = row.input;
|
||||
if (row.output > 0) prices.output = row.output;
|
||||
if (row.cacheHit > 0) prices.cacheHit = row.cacheHit;
|
||||
if (Object.keys(prices).length) map[row.mediaType] = prices;
|
||||
});
|
||||
return Object.keys(map).length ? map : undefined;
|
||||
}
|
||||
|
||||
/** mediaPrices 对象 → 编辑态媒体价格行(兼容后端两种结构:按媒体分组的内层对象,或空/缺失) */
|
||||
function mediaPricesToRows(mediaPrices?: unknown): MediaPriceRow[] {
|
||||
if (!mediaPrices || typeof mediaPrices !== 'object') return [];
|
||||
return Object.entries(mediaPrices as Record<string, Record<string, number | undefined>>)
|
||||
.filter(([, prices]) => prices && typeof prices === 'object')
|
||||
.map(([mediaType, prices]) => ({
|
||||
mediaType,
|
||||
input: prices.input ?? 0,
|
||||
output: prices.output ?? 0,
|
||||
cacheHit: prices.cacheHit ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 添加一条媒体价格行 */
|
||||
function addMediaPrice(r: ModelRule) {
|
||||
r.mediaPrices.push({ mediaType: '', input: 0, output: 0, cacheHit: 0 });
|
||||
}
|
||||
|
||||
/** 删除一条媒体价格行 */
|
||||
function removeMediaPrice(r: ModelRule, index: number) {
|
||||
r.mediaPrices.splice(index, 1);
|
||||
}
|
||||
|
||||
/** 媒体价格行的可选媒体:排除本规则其他行已选中的媒体,避免同一条规则重复选择同一媒体 */
|
||||
function getAvailableMediaOptions(r: ModelRule, curIndex: number) {
|
||||
const selected = new Set(r.mediaPrices.filter((_, i) => i !== curIndex).map((row) => row.mediaType));
|
||||
return MEDIA_PRICE_OPTIONS.filter((opt) => !selected.has(opt.value) || r.mediaPrices[curIndex]?.mediaType === opt.value);
|
||||
}
|
||||
|
||||
/** 构建模型费率 JSON(对齐后端结构,由父组件 stringify 提交) */
|
||||
function buildRules(): ModelRules {
|
||||
return {
|
||||
unit: unit.value,
|
||||
tiered: tiered.value,
|
||||
currency: currency.value,
|
||||
rules: modelRules.value.filter(keepRule).map(buildBuiltRule),
|
||||
};
|
||||
}
|
||||
|
||||
function save(): { rules: ModelRules; minBalance: number; currency: Currency; enabled: boolean } {
|
||||
return {
|
||||
rules: buildRules(),
|
||||
minBalance: minBalance.value,
|
||||
currency: currency.value,
|
||||
enabled: enabled.value,
|
||||
};
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initConfig,
|
||||
(cfg) => {
|
||||
if (cfg && (cfg.rules as ModelRules).unit) {
|
||||
// 有后端已保存配置 → 用它初始化
|
||||
applyInit(cfg);
|
||||
} else if (examples.value.length) {
|
||||
// 无配置 → 自动加载该类型第一个示例(对齐原型行为)
|
||||
loadExample(0);
|
||||
} else {
|
||||
modelRules.value = [emptyRule()];
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
defineExpose({ save });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.editor-body {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-col {
|
||||
flex: 1;
|
||||
min-width: 380px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
.card-head {
|
||||
background: #fafafa;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.head-select {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.money-note {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.mt10 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ctrl-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.ctrl-select {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.rule-card {
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px;
|
||||
|
||||
.rule-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.rule-index {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rule-name {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rc-sec {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin: 10px 0 6px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&.match::before {
|
||||
background: #e6a23c;
|
||||
}
|
||||
|
||||
&.price::before {
|
||||
background: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
.rc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 0 12px;
|
||||
|
||||
.rc-control {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 媒体价格模板区(token 单位):基础价下方的媒体覆盖价动态行 */
|
||||
.media-price-section {
|
||||
margin-top: 12px;
|
||||
border-top: 1px dashed #e4e7ed;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.media-price-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.mp-media {
|
||||
width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mp-num {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.media-add-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mp-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.7;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.note {
|
||||
border: 1px solid #e6a23c;
|
||||
background: #fdf6ec;
|
||||
color: #b88230;
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<div class="subject-list">
|
||||
<div v-if="loading" class="subject-loading">加载计价对象…</div>
|
||||
<template v-else>
|
||||
<!-- 模型类型筛选 -->
|
||||
<div v-if="hasModel" class="filter-bar">
|
||||
<button
|
||||
v-for="f in FILTER_OPTIONS"
|
||||
:key="f.value"
|
||||
class="filter-chip"
|
||||
:class="{ active: modelFilter === f.value }"
|
||||
@click="modelFilter = f.value"
|
||||
>
|
||||
{{ f.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 工作流 -->
|
||||
<div class="grp-head">
|
||||
<span class="grp-name">工作流</span>
|
||||
<span class="grp-count">{{ workflowSubjects.length }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="s in workflowSubjects"
|
||||
:key="`${s.type}:${s.id}`"
|
||||
class="subj-item"
|
||||
:class="{ active: `${s.type}:${s.id}` === currentKey }"
|
||||
@click="emit('select', s)"
|
||||
>
|
||||
<span class="type-badge t-workflow">工作流</span>
|
||||
<span class="subj-name" :title="s.name">{{ s.name }}</span>
|
||||
<span v-for="m in s.modes" :key="m" class="mode-chip">{{ m }}</span>
|
||||
<span
|
||||
class="status-dot"
|
||||
:class="s.hasConfig ? 'on' : 'off'"
|
||||
:title="s.hasConfig ? '已配置' : '未配置'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 模型(支持类型筛选) -->
|
||||
<div class="grp-head">
|
||||
<span class="grp-name">模型</span>
|
||||
<span class="grp-count">{{ filteredModels.length }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="s in filteredModels"
|
||||
:key="`${s.type}:${s.id}`"
|
||||
class="subj-item"
|
||||
:class="{ active: `${s.type}:${s.id}` === currentKey }"
|
||||
@click="emit('select', s)"
|
||||
>
|
||||
<span class="type-badge" :class="`t-${s.modelType}`">{{ s.typeLabel }}</span>
|
||||
<span class="subj-name" :title="s.name">{{ s.name }}</span>
|
||||
<span
|
||||
class="status-dot"
|
||||
:class="s.hasConfig ? 'on' : 'off'"
|
||||
:title="s.hasConfig ? '已配置' : '未配置'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!filteredModels.length" class="empty-tip">该类型暂无模型</div>
|
||||
|
||||
<!-- 业务模块 -->
|
||||
<div class="grp-head">
|
||||
<span class="grp-name">业务模块</span>
|
||||
<span class="grp-count">{{ businessSubjects.length }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="s in businessSubjects"
|
||||
:key="`${s.type}:${s.id}`"
|
||||
class="subj-item"
|
||||
:class="{ active: `${s.type}:${s.id}` === currentKey }"
|
||||
@click="emit('select', s)"
|
||||
>
|
||||
<span class="type-badge t-business">业务</span>
|
||||
<span class="subj-name" :title="s.name">{{ s.name }}</span>
|
||||
<span v-for="m in s.modes" :key="m" class="mode-chip">{{ m }}</span>
|
||||
<span
|
||||
class="status-dot"
|
||||
:class="s.hasConfig ? 'on' : 'off'"
|
||||
:title="s.hasConfig ? '已配置' : '未配置'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { ModelType, Subject } from '../mock';
|
||||
|
||||
/**
|
||||
* 左侧计价对象枚举列表。
|
||||
* 分三组渲染(工作流 / 模型 / 业务模块),模型组支持类型筛选;
|
||||
* 配置状态以实心(已配置)/ 空心(未配置)圆点呈现。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
subjects: Subject[];
|
||||
currentKey: string;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ select: [subject: Subject] }>();
|
||||
|
||||
const workflowSubjects = computed(() => props.subjects.filter((s) => s.type === 'workflow'));
|
||||
const modelSubjects = computed(() => props.subjects.filter((s) => s.type === 'model'));
|
||||
const businessSubjects = computed(() => props.subjects.filter((s) => s.type === 'business'));
|
||||
|
||||
const hasModel = computed(() => modelSubjects.value.length > 0);
|
||||
|
||||
/** 模型类型筛选 */
|
||||
const FILTER_OPTIONS: { value: ModelType | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'reason', label: '推理' },
|
||||
{ value: 'video', label: '视频' },
|
||||
{ value: 'audio', label: '音频' },
|
||||
{ value: 'image', label: '图片' },
|
||||
];
|
||||
const modelFilter = ref<ModelType | 'all'>('all');
|
||||
const filteredModels = computed(() =>
|
||||
modelFilter.value === 'all'
|
||||
? modelSubjects.value
|
||||
: modelSubjects.value.filter((s) => s.modelType === modelFilter.value),
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.subject-list {
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 10px 16px 2px;
|
||||
|
||||
.filter-chip {
|
||||
border: 1px solid #dcdfe6;
|
||||
background: #fff;
|
||||
color: #606266;
|
||||
border-radius: 14px;
|
||||
padding: 3px 12px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
line-height: 1.4;
|
||||
|
||||
&:hover {
|
||||
color: #409eff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #ecf5ff;
|
||||
border-color: #409eff;
|
||||
color: #409eff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.grp-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 14px 16px 6px;
|
||||
|
||||
.grp-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #909399;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.grp-count {
|
||||
font-size: 11px;
|
||||
background: #f4f4f5;
|
||||
color: #909399;
|
||||
border-radius: 8px;
|
||||
padding: 0 6px;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.subject-loading {
|
||||
padding: 20px 16px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 10px 16px;
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subj-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 16px;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #ecf5ff;
|
||||
border-left-color: #409eff;
|
||||
}
|
||||
|
||||
.subj-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
font-size: 11px;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.t-workflow {
|
||||
background: #8e44ad;
|
||||
}
|
||||
|
||||
&.t-reason {
|
||||
background: #e6a23c;
|
||||
}
|
||||
|
||||
&.t-video {
|
||||
background: #909399;
|
||||
}
|
||||
|
||||
&.t-audio {
|
||||
background: #67c23a;
|
||||
}
|
||||
|
||||
&.t-image {
|
||||
background: #00bcd4;
|
||||
}
|
||||
|
||||
&.t-business {
|
||||
background: #e74c3c;
|
||||
}
|
||||
}
|
||||
|
||||
.mode-chip {
|
||||
font-size: 11px;
|
||||
background: #f4f4f5;
|
||||
color: #606266;
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.on {
|
||||
background: #67c23a;
|
||||
box-shadow: 0 0 0 2px rgba(103, 194, 58, 0.2);
|
||||
}
|
||||
|
||||
&.off {
|
||||
background: #fff;
|
||||
border: 1px solid #c0c4cc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<div class="editor-body">
|
||||
<div class="editor-col">
|
||||
<!-- 计费模式 -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">计费模式</span>
|
||||
<span class="head-hint">可同时开启多套计费</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- 按条计费 per_item -->
|
||||
<div class="mode-item">
|
||||
<div class="mode-item-head">
|
||||
<span class="mode-name">按条计费 <span class="mode-code">per_item</span></span>
|
||||
<el-switch v-model="modes.per_item" />
|
||||
</div>
|
||||
<div v-if="modes.per_item" class="mode-item-body">
|
||||
<el-form label-position="top" size="default">
|
||||
<el-form-item label="档位列表(上不封顶,命中首个 maxSec ≥ 时长 的档)">
|
||||
<div class="tier-list">
|
||||
<div v-for="(tier, i) in tiers" :key="i" class="tier-row">
|
||||
<el-input-number
|
||||
v-model="tier.maxSec"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
:controls="false"
|
||||
placeholder="maxSec"
|
||||
class="tier-input"
|
||||
/>
|
||||
<el-input-number
|
||||
v-model="tier.price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
placeholder="价格(元)"
|
||||
class="tier-input"
|
||||
/>
|
||||
<el-button size="small" type="danger" text @click="removeTier(i)">删除</el-button>
|
||||
</div>
|
||||
<el-button size="small" class="add-btn" @click="addTier">+ 添加档位</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="超出最大档单价(元/秒)">
|
||||
<el-input-number
|
||||
v-model="overflowUnitPrice"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
:controls="false"
|
||||
class="ctrl-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按秒计费 per_second -->
|
||||
<div class="mode-item">
|
||||
<div class="mode-item-head">
|
||||
<span class="mode-name">按秒计费 <span class="mode-code">per_second</span></span>
|
||||
<el-switch v-model="modes.per_second" />
|
||||
</div>
|
||||
<div v-if="modes.per_second" class="mode-item-body">
|
||||
<el-form label-position="top" size="default">
|
||||
<el-form-item label="单价(元/秒,不满 1 秒向上取整)">
|
||||
<el-input-number
|
||||
v-model="perSecondUnitPrice"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
:controls="false"
|
||||
class="ctrl-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按 token 计费 per_token -->
|
||||
<div class="mode-item">
|
||||
<div class="mode-item-head">
|
||||
<span class="mode-name">按 Token 计费 <span class="mode-code">per_token</span></span>
|
||||
<el-switch v-model="modes.per_token" />
|
||||
</div>
|
||||
<div v-if="modes.per_token" class="mode-item-body">
|
||||
<div class="form-hint">
|
||||
价格<b>取自各模型自己的计价配置</b>(模型列表里配的那份),不在此重复配置。未配价的模型按 0
|
||||
计。⚠️ per_token 费率不冻结在快照,结算时按模型实时价。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-col">
|
||||
<!-- 通用配置 -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<span class="card-title">通用配置</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<el-form label-position="top" size="default">
|
||||
<el-form-item label="门禁 min_balance(元)">
|
||||
<el-input-number
|
||||
v-model="minBalance"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
:controls="false"
|
||||
class="ctrl-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="币种">
|
||||
<el-select v-model="currency" class="ctrl-select">
|
||||
<el-option label="人民币 (CNY)" value="CNY" />
|
||||
<el-option label="美元 (USD)" value="USD" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="enabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import {
|
||||
WORKFLOW_DEFAULT_TIERS,
|
||||
type Currency,
|
||||
type EditorInitConfig,
|
||||
type Subject,
|
||||
type WorkflowRules,
|
||||
type WorkflowTier,
|
||||
} from '../mock';
|
||||
|
||||
/**
|
||||
* 工作流计价编辑面板。
|
||||
* 三套计费模式开关(per_item 档位表 / per_second 单价 / per_token 占位)。
|
||||
* initConfig 传入后端已保存配置(parse 后)时初始化表单;无配置时用默认档位。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
subject: Subject;
|
||||
initConfig: EditorInitConfig | null;
|
||||
}>();
|
||||
|
||||
const modes = reactive({ per_item: true, per_second: false, per_token: false });
|
||||
const tiers = ref<WorkflowTier[]>(WORKFLOW_DEFAULT_TIERS.map((t) => ({ ...t })));
|
||||
const overflowUnitPrice = ref(0.9);
|
||||
const perSecondUnitPrice = ref(0.9);
|
||||
const minBalance = ref(0);
|
||||
const currency = ref<Currency>('CNY');
|
||||
const enabled = ref(true);
|
||||
|
||||
function addTier() {
|
||||
tiers.value.push({ maxSec: 60, price: 89 });
|
||||
}
|
||||
|
||||
function removeTier(index: number) {
|
||||
tiers.value.splice(index, 1);
|
||||
}
|
||||
|
||||
/** 用后端已保存配置初始化表单 */
|
||||
function applyConfig(cfg: EditorInitConfig) {
|
||||
const r = cfg.rules as WorkflowRules;
|
||||
modes.per_item = !!r.per_item;
|
||||
modes.per_second = !!r.per_second;
|
||||
modes.per_token = !!r.per_token;
|
||||
tiers.value = r.per_item?.tiers?.map((t) => ({ ...t })) ?? WORKFLOW_DEFAULT_TIERS.map((t) => ({ ...t }));
|
||||
overflowUnitPrice.value = r.per_item?.overflowUnitPrice ?? 0.9;
|
||||
perSecondUnitPrice.value = r.per_second?.unitPrice ?? 0.9;
|
||||
minBalance.value = cfg.minBalance;
|
||||
currency.value = cfg.currency;
|
||||
enabled.value = cfg.enabled;
|
||||
}
|
||||
|
||||
/** 重置为默认(无配置) */
|
||||
function resetDefault() {
|
||||
modes.per_item = true;
|
||||
modes.per_second = false;
|
||||
modes.per_token = false;
|
||||
tiers.value = WORKFLOW_DEFAULT_TIERS.map((t) => ({ ...t }));
|
||||
overflowUnitPrice.value = 0.9;
|
||||
perSecondUnitPrice.value = 0.9;
|
||||
minBalance.value = 0;
|
||||
currency.value = 'CNY';
|
||||
enabled.value = true;
|
||||
}
|
||||
|
||||
function save(): { rules: WorkflowRules; minBalance: number; currency: Currency; enabled: boolean } {
|
||||
const rules: WorkflowRules = {};
|
||||
// 只把开启的模式写入费率 JSON
|
||||
if (modes.per_item) {
|
||||
rules.per_item = { tiers: tiers.value.filter((t) => t.maxSec > 0), overflowUnitPrice: overflowUnitPrice.value };
|
||||
}
|
||||
if (modes.per_second) rules.per_second = { unitPrice: perSecondUnitPrice.value };
|
||||
if (modes.per_token) rules.per_token = {};
|
||||
return {
|
||||
rules,
|
||||
minBalance: minBalance.value,
|
||||
currency: currency.value,
|
||||
enabled: enabled.value,
|
||||
};
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initConfig,
|
||||
(cfg) => {
|
||||
if (cfg && cfg.rules && !('unit' in (cfg.rules as Record<string, unknown>))) {
|
||||
applyConfig(cfg);
|
||||
} else {
|
||||
resetDefault();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
defineExpose({ save });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.editor-body {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-col {
|
||||
flex: 1;
|
||||
min-width: 380px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
.card-head {
|
||||
background: #fafafa;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.head-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.mode-item {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.mode-item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px 6px 0 0;
|
||||
|
||||
.mode-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
.mode-code {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #909399;
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mode-item-body {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.tier-list {
|
||||
.tier-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.tier-input {
|
||||
width: 130px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ctrl-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.ctrl-select {
|
||||
width: 220px;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user