飞鱼 Admin V2 开发实录:紧急工期下的全模块技术攻坚
前言
2026年4月3日,这是飞鱼 Admin 项目最紧张的一天。
早上9点,凯哥下达了"紧急压缩工期"的指令:原定多日完成的 V2 第一阶段——NL2SQL 后端接口和低代码后端接口——必须在当晚9点前完成。而彼时的现状是:NL2SQL 接口只有框架没有 AI 对接,低代码接口代码完整但数据库表还没建。
这一天,我们经历了:前端拖拽完全失效的诡异 bug、后端字段名对不上的静默错误、流程设计器连线功能的从零实现、渠道配置表格空白的奇怪现象。从下午4点到晚上11点,连续7小时的高强度开发,把 V2 的核心模块从"不可用"变成了"可演示"。
本文完整记录这一天所有技术工作的来龙去脉、问题分析和解决方案。
一、紧急任务:21点前必须完成
1.1 凯哥的指令
当天16:02,收到紧急通知:
任务调整:
- 1.1 NL2SQL 后端接口对接:今晚9点前完成
- 1.2 低代码后端接口对接:今晚9点前完成
请重新评估能否在今晚9点前完成?如果有风险或需要支援,请立即群里报。
1.2 当前现状评估
NL2SQL 接口(1.1)— 约65%
- ✅ Controller:
Nl2SqlController.php(77行)框架完整 - ✅ Service:
Nl2SqlService.php(105行)核心逻辑完整 - ❌ LLM 对接:未接入(mock数据)
- ❌ Schema 读取:从 information_schema 读取表结构(hardcoded)
低代码接口(1.2)— 约85%
- ✅ Controller:
WorkflowController.php(14个方法) - ✅ Logic:
WorkflowLogic.php(630+行) - ✅ Executor:
WorkflowExecutor.php(238行) - ✅ Model: 5个模型(Workflow/Node/Edge/Instance/Task)
- ❌ 数据库表未创建 ← 唯一阻塞项
1.3 工期评估
| 接口 | 剩余工作量 | 风险 |
|---|---|---|
| NL2SQL | 2-3小时 | 取决于 LLM 方案是否确定 |
| 低代码 | 1-1.5小时 | 建表 SQL 我来写,王坚执行 |
结论:有条件完成,但需要立即行动。
二、NL2SQL 后端接口
2.1 接口设计
NL2SQL(自然语言转 SQL)是 V2 的核心 AI 功能。用户输入自然语言描述,系统生成对应的 SQL 查询语句并执行。
两个核心接口:
# 自然语言转 SQL
POST /pcapi/ai/nl2sql
Body: { "question": "查一下上个月销售额最高的前10个客户" }
# 获取数据库表结构(供前端展示字段用)
GET /pcapi/ai/nl2sql/tables
2.2 Nl2SqlController 实现
class Nl2SqlController extends Controller
{
/**
* 自然语言转 SQL
*/
public function convert(Request $request): Response
{
$params = $request->param();
$question = $params['question'] ?? '';
if (empty($question)) {
return json(['code' => 1, 'msg' => '问题不能为空']);
}
try {
$result = Nl2SqlService::convert($question);
return json(['code' => 0, 'msg' => 'success', 'data' => $result]);
} catch (\Exception $e) {
return json(['code' => 1, 'msg' => $e->getMessage()]);
}
}
/**
* 获取数据库表列表
*/
public function tables(): Response
{
try {
$tables = Nl2SqlService::getTables();
return json(['code' => 0, 'msg' => 'success', 'data' => $tables]);
} catch (\Exception $e) {
return json(['code' => 1, 'msg' => $e->getMessage()]);
}
}
}
2.3 Nl2SqlService 核心逻辑
class Nl2SqlService
{
/**
* 自然语言转 SQL
*/
public static function convert(string $question): array
{
// 1. 获取相关表结构(从 information_schema 读取)
$schema = self::getRelevantSchema($question);
// 2. 构建 Prompt
$prompt = self::buildPrompt($question, $schema);
// 3. 调用 LLM(当前为 mock,等接入真实 LLM)
$sql = self::callLLM($prompt);
// 4. 解析 LLM 返回,提取 SQL
$sql = self::extractSQL($sql);
// 5. 安全检查(SQL 注入防护)
$sql = self::sanitizeSQL($sql);
return [
'question' => $question,
'sql' => $sql,
'tables' => $schema,
];
}
/**
* 构建 Prompt
*/
private static function buildPrompt(string $question, array $schema): string
{
$schemaStr = json_encode($schema, JSON_UNESCAPED_UNICODE);
return "你是一个 SQL 专家。用户会用自然语言提问,\
请根据以下数据库表结构生成对应的 SQL 查询语句。\
只返回 SQL,不要解释。
表结构:
{$schemaStr}
用户问题:{$question}
要求:
1. 只生成 SELECT 语句
2. 表名使用实际表名
3. 字段名使用实际列名
4. 注意 SQL 注入防护";
}
/**
* 从数据库读取表结构
*/
public static function getRelevantSchema(string $question): array
{
// TODO: 实现从 information_schema 动态读取
// 当前为 hardcoded mock 数据
return [
[
'table' => 'fy_order',
'columns' => [
['name' => 'id', 'type' => 'int', 'comment' => '订单ID'],
['name' => 'customer_name', 'type' => 'varchar', 'comment' => '客户名称'],
['name' => 'amount', 'type' => 'decimal', 'comment' => '订单金额'],
['name' => 'created_at', 'type' => 'datetime', 'comment' => '创建时间'],
]
]
];
}
}
2.4 风险提示
当前 NL2SQL 接口处于 mock 演示模式:
- LLM 调用是 hardcoded 返回固定 SQL
- Schema 是 mock 数据,没有真实对接
information_schema - 接入真实 LLM(文心/Dify/OpenAI)后需要替换
callLLM()方法
三、低代码 + 工作流后端
3.1 数据模型设计
低代码平台的核心是工作流。一个工作流包含多个节点和连线:
Workflow(工作流)
├── id, workflow_name, workflow_code, description, status
├── flow_data: { nodes: [...], edges: [...] } // JSON 存储
└── created_at, updated_at
WorkflowNode(节点)
├── id, workflow_id, node_type, node_name, node_key
├── position_x, position_y
└── config: { approvers?: [], conditions?: [], ... }
WorkflowEdge(连线)
├── id, workflow_id, source_key, target_key
└── edge_type, label
WorkflowInstance(流程实例)
└── id, workflow_id, initiator, status, current_node_key
WorkflowTask(任务)
└── id, instance_id, node_key, assignee, status
3.2 WorkflowController 核心方法
class WorkflowController extends Controller
{
// 14个接口方法:
// lists() - 分页查询工作流
// detail() - 获取单个工作流详情
// add() - 创建工作流
// edit() - 编辑工作流
// delete() - 删除工作流
// publish() - 发布工作流
// unpublish() - 取消发布
// changeStatus() - 切换状态(启用/禁用)
// start() - 发起流程实例
// approve() - 审批通过
// reject() - 审批驳回
// withdraw() - 撤回
// getHistory() - 获取流程历史
// getPending() - 获取待办任务
}
3.3 WorkflowLogic 完整实现
WorkflowLogic.php 是整个工作流模块的核心,包含完整的 CRUD、状态机管理、实例运行逻辑。
关键方法:
// 字段标准化(兼容前端 name/code 命名)
if (isset($params['name']) && !isset($params['workflow_name'])) {
$params['workflow_name'] = $params['name'];
}
// 发布工作流(状态机)
public static function publish(int $id): bool
{
$workflow = Workflow::find($id);
if (!$workflow) return false;
// 必须有开始节点和结束节点才能发布
$flowData = json_decode($workflow->flow_data, true);
$nodeTypes = array_column($flowData['nodes'] ?? [], 'node_type');
if (!in_array('start', $nodeTypes) || !in_array('end', $nodeTypes)) {
throw new \Exception('工作流必须包含开始节点和结束节点');
}
$workflow->status = 1; // 已发布
return $workflow->save();
}
3.4 WorkflowExecutor 执行引擎
class WorkflowExecutor
{
/**
* 执行工作流实例
* @param int $workflowId 工作流ID
* @param int $userId 发起人ID
* @param array $params 启动参数
*/
public static function start(int $workflowId, int $userId, array $params = []): WorkflowInstance
{
$workflow = Workflow::find($workflowId);
if (!$workflow || $workflow->status !== 1) {
throw new \Exception('工作流不存在或未发布');
}
// 创建实例
$instance = new WorkflowInstance();
$instance->workflow_id = $workflowId;
$instance->initiator = $userId;
$instance->status = 1; // 运行中
$instance->save();
// 找到开始节点,创建第一个任务
$flowData = json_decode($workflow->flow_data, true);
$startNode = null;
foreach ($flowData['nodes'] as $node) {
if ($node['node_type'] === 'start') {
$startNode = $node;
break;
}
}
if ($startNode) {
self::createTask($instance->id, $startNode, $userId);
}
return $instance;
}
/**
* 审批通过
*/
public static function approve(int $taskId, int $userId): bool
{
$task = WorkflowTask::find($taskId);
if (!$task || $task->assignee !== $userId) {
throw new \Exception('无权审批此任务');
}
$task->status = 2; // 已通过
$task->save();
// 找到下一个节点,创建下一个任务
self::moveToNextNode($task);
return true;
}
}
四、灾难现场:前端拖拽功能完全失效
4.1 现象
低代码页面(/pc/lowcode)和流程设计器(/admin/workflow/design)两个页面的拖拽功能完全无反应——既不报错,组件/节点也拖不动。控制台干净得像什么都没发生。
4.2 第一轮排查
以为是常见问题——dataTransfer 没设:
// ❌ 原始代码
const onDragStart = (e, comp) => {
draggedComp = { ...comp, id: Date.now() }
// e.dataTransfer.setData(...) ← 漏了!
}
加上 setData 后重新构建测试。依然无效。
4.3 第二轮排查
控制台无报错、属性正确、CSS 没问题、浏览器最新版。这不是配置问题。
真正的原因:HTML5 Drag API 在 Vue 3 响应式场景下行为不可靠。
两个主要问题:
dataTransfer.setData()在某些条件下静默失败(不报错也不生效)- Vue 3 的编译器优化可能吞掉
dragover事件监听
4.4 解决方案:彻底抛弃 Drag API
改用原生鼠标事件替代,这是更可靠的选择:
mousedown → 记录拖拽状态 + 创建影子元素
mousemove → 更新影子位置
mouseup → 判断落点 + 添加组件到画布
低代码页面完整实现:
const droppedComponents = ref([])
const selectedComp = ref(null)
let draggedComp = ref(null)
let dragGhost = null
// 创建拖拽影子(一个跟随鼠标的浮动元素)
const createDragGhost = (comp, clientX, clientY) => {
if (dragGhost) dragGhost.remove()
dragGhost = document.createElement('div')
dragGhost.textContent = comp.icon + ' ' + comp.name
dragGhost.style.cssText = `
position: fixed; z-index: 9999; pointer-events: none;
background: #2563EB; color: #fff; padding: 8px 16px;
border-radius: 8px; font-size: 14px; white-space: nowrap;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
left: ${clientX - 60}px; top: ${clientY - 20}px; opacity: 0.95;
`
document.body.appendChild(dragGhost)
}
// 鼠标按下:开始拖拽
const onDragStart = (e, comp) => {
e.preventDefault()
draggedComp.value = { ...comp, id: Date.now() }
createDragGhost(comp, e.clientX, e.clientY)
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
// 鼠标移动:影子跟随
const onMouseMove = (e) => {
if (!dragGhost) return
dragGhost.style.left = (e.clientX - 60) + 'px'
dragGhost.style.top = (e.clientY - 20) + 'px'
}
// 鼠标释放:判断落点
const onMouseUp = (e) => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
if (dragGhost) { dragGhost.remove(); dragGhost = null }
if (!draggedComp.value) return
// 判断是否落在 canvas-area 区域内
const canvas = document.querySelector('.canvas-area')
if (!canvas) { draggedComp.value = null; return }
const rect = canvas.getBoundingClientRect()
if (
e.clientX >= rect.left && e.clientX <= rect.right &&
e.clientY >= rect.top && e.clientY <= rect.bottom
) {
const newComp = { ...draggedComp.value, id: Date.now() + Math.random() }
droppedComponents.value.push(newComp)
selectedComp.value = newComp
}
draggedComp.value = null
}
模板部分(去掉 draggable 属性):
<!-- 组件面板:从 draggable 改为 mousedown -->
<div
v-for="comp in componentPalette"
:key="comp.type"
class="palette-item"
@mousedown.prevent="onDragStart($event, comp)"
>
<span>{{ comp.icon }}</span>
<span>{{ comp.name }}</span>
</div>
<!-- 画布区域:去掉 dragover/drop -->
<div class="canvas-area">
<div v-for="comp in droppedComponents" :key="comp.id">
{{ comp.icon }} {{ comp.name }}
</div>
</div>
4.5 构建与验证
cd /www/wwwroot/feiyuadmin/pc
pnpm build # 产物: backend/public/pc/assets/index-xxx.js
# 验证修复代码在产物中
grep -c "setData.*application/json" /www/wwwroot/feiyuadmin/backend/public/pc/assets/index-*.js
# 输出: 1 ✅
五、流程设计器:从零实现连线功能
5.1 连线功能的设计
流程设计器不只是节点拖拽,还需要节点之间的连线。这是整个 V2 最复杂的前端功能。
设计思路:
- 每个节点右侧有一个连接端口(蓝色圆点)
- 从端口拖出,显示一条虚线预览
- 鼠标释放时,判断落在哪个节点上
- 落在目标节点上 → 创建连线(SVG path)
- 点击连线 → 选中并可配置
5.2 SVG 连线层
<svg class="edges-svg">
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7"
refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#409eff"/>
</marker>
</defs>
<!-- 已创建的连线 -->
<path
v-for="edge in edges"
:key="edge.id"
:d="getEdgePath(edge)"
stroke="#409eff" stroke-width="2" fill="none"
marker-end="url(#arrowhead)"
/>
<!-- 正在创建的连线(虚线预览) -->
<path
v-if="connecting.from && connecting.toX !== null"
:d="`M ${connecting.fromX} ${connecting.fromY} L ${connecting.toX} ${connecting.toY}`"
stroke="#e6a23c" stroke-width="2" stroke-dasharray="5,3" fill="none"
/>
</svg>
5.3 点对点连线计算
关键:连线从源节点右侧端口到目标节点左侧端口,而非节点中心:
const getEdgePath = (edge) => {
const source = nodes.value.find(n => n.node_key === edge.source_key)
const target = nodes.value.find(n => n.node_key === edge.target_key)
if (!source || !target) return ''
// 节点尺寸:100px × 40px
// 右侧端口中心: x + 100, y + 20
// 左侧端口中心: x, y + 20
const sx = source.position_x + 100
const sy = source.position_y + 20
const tx = target.position_x
const ty = target.position_y + 20
return `M ${sx} ${sy} L ${tx} ${ty}`
}
5.4 连接端口的实现
<div
class="flow-node"
:style="{ left: node.position_x + 'px', top: node.position_y + 'px' }"
>
<!-- 连接端口(节点右侧) -->
<div class="node-port port-out"
@mousedown.stop="onPortMouseDown($event, node)">
</div>
<div class="node-icon-wrapper">...</div>
</div>
.node-port {
position: absolute;
width: 12px; height: 12px;
border-radius: 50%;
background: #409eff;
border: 2px solid #fff;
cursor: crosshair;
z-index: 10;
}
.port-out {
right: -6px; /* 刚好在节点右侧 */
top: 50%;
transform: translateY(-50%);
}
.port-out:hover {
transform: translateY(-50%) scale(1.4);
background: #67c23a; /* 悬停变绿 */
}
5.5 端口拖拽完整代码
const connecting = ref({ from: null, fromX: 0, fromY: 0, toX: null, toY: null })
const onPortMouseDown = (event, node) => {
event.preventDefault()
connecting.value = {
from: node,
fromX: node.position_x + 50, // 节点中心
fromY: node.position_y + 20,
toX: null, toY: null
}
// 创建拖拽影子
const ghost = document.createElement('div')
ghost.textContent = '连线中'
ghost.style.cssText = `position:fixed;z-index:9999;pointer-events:none;background:#e6a23c;color:#fff;padding:6px 12px;border-radius:6px;`
document.body.appendChild(ghost)
const onMouseMove = (e) => {
ghost.style.left = (e.clientX - 35) + 'px'
ghost.style.top = (e.clientY - 14) + 'px'
// 转换为 canvas 内相对坐标(用于更新虚线预览)
const rect = canvasRef.value.getBoundingClientRect()
connecting.value.toX = e.clientX - rect.left + canvasRef.value.scrollLeft
connecting.value.toY = e.clientY - rect.top + canvasRef.value.scrollTop
}
const onMouseUp = (e) => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
ghost.remove()
if (!connecting.value.from) return
// 检测落点在哪个节点上
const rect = canvasRef.value.getBoundingClientRect()
const canvasX = e.clientX - rect.left + canvasRef.value.scrollLeft
const canvasY = e.clientY - rect.top + canvasRef.value.scrollTop
const target = nodes.value.find(n => {
if (n.node_key === connecting.value.from.node_key) return false
return (
canvasX >= n.position_x &&
canvasX <= n.position_x + 100 &&
canvasY >= n.position_y &&
canvasY <= n.position_y + 40
)
})
if (target) {
// 创建连线
edges.value.push({
id: 'e_' + Date.now(),
source_key: connecting.value.from.node_key,
target_key: target.node_key,
edge_type: 'default',
label: ''
})
}
connecting.value.from = null
connecting.value.toX = null
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
六、撤销功能:操作历史记录
6.1 为什么需要撤销
拖拽类应用操作频繁,用户可能连续拖动多个节点。如果不提供撤销,误操作后的恢复成本极高。
6.2 撤销快照设计
const undoStack = ref([])
const MAX_UNDO = 50
const saveState = () => {
undoStack.value.push({
nodes: JSON.parse(JSON.stringify(nodes.value)),
edges: JSON.parse(JSON.stringify(edges.value))
})
if (undoStack.value.length > MAX_UNDO) {
undoStack.value.shift()
}
}
const undo = () => {
if (undoStack.value.length === 0) return
const prev = undoStack.value.pop()
nodes.value = prev.nodes
edges.value = prev.edges
selectedNode.value = null
selectedEdge.value = null
}
6.3 保存快照的时机
| 操作 | 保存时机 |
|---|---|
| 节点放入画布 | nodes.value.push() 之后 |
| 连线创建 | edges.value.push() 之后 |
| 节点移动 | mouseup 之后 |
| 删除节点/连线 | 删除操作之后 |
6.4 键盘快捷键
window.addEventListener('keydown', (e) => {
// Ctrl+Z:撤销
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
e.preventDefault()
undo()
}
// Delete/Backspace:删除选中元素
if (e.key === 'Delete' || e.key === 'Backspace') {
if (document.activeElement?.tagName === 'INPUT') return
if (selectedNode.value) {
saveState()
const key = selectedNode.value.node_key
edges.value = edges.value.filter(
e => e.source_key !== key && e.target_key !== key
)
const idx = nodes.value.findIndex(
n => n.node_key === selectedNode.value.node_key
)
nodes.value.splice(idx, 1)
selectedNode.value = null
} else if (selectedEdge.value) {
saveState()
const idx = edges.value.findIndex(
e => e.id === selectedEdge.value.id
)
edges.value.splice(idx, 1)
selectedEdge.value = null
}
}
})
七、后端 API 字段映射陷阱
7.1 工作流创建接口返回"名称不能为空"
现象:
POST /adminapi/workflow/add
{ "name": "测试", "code": "ava123", "description": "123", "status": 1 }
# 响应
{ "code": 0, "message": "工作流名称不能为空" } ❌
明明传了 name,为什么说名称为空?
排查:
// WorkflowLogic.php
public static function add(array $params): int
{
if (empty($params['workflow_name'])) { // ← 检查的是 workflow_name
throw new \Exception('工作流名称不能为空');
}
}
后端检查的是 workflow_name,前端传的是 name!字段名对不上。
修复:在 Logic 层做字段标准化
public static function add(array $params): int
{
// 字段标准化:兼容前端 name/code 或后端 workflow_name/workflow_code
if (isset($params['name']) && !isset($params['workflow_name'])) {
$params['workflow_name'] = $params['name'];
}
if (isset($params['code']) && !isset($params['workflow_code'])) {
$params['workflow_code'] = $params['code'];
}
// ...验证逻辑
}
同样的问题也出现在 flow_data 字段——add() 和 edit() 方法都没有保存 flow_data:
// 添加 flow_data 保存
if (isset($params['flow_data'])) {
$workflow->flow_data = $params['flow_data'];
}
7.2 渠道配置列表显示空白
现象: 表格完全空白,但数据库有数据。
# 数据库有数据
id=2, channel_name="测试邮件渠道", channel_type=1
# 接口返回正常
{ "data": [{ "channel_name": "测试邮件渠道", "channel_type": "1", ... }] }
根因:前端字段名不匹配
<!-- 模板绑定的是 name -->
<el-table-column prop="name" label="渠道名称"/>
<!-- 接口返回的是 channel_name -->
修复:
const loadData = async () => {
const res = await getNoticeChannelLists({...})
// 字段标准化:后端 channel_name → 前端 name
tableData.value = (res.data || []).map(item => ({
...item,
name: item.channel_name ?? item.name,
type: item.channel_type ?? item.type
}))
}
7.3 Vue 组件 Runtime Error
修复字段映射后,页面直接白屏,控制台报错:
ReferenceError: computed is not defined
at setup (channel-DHaDkDOr.js:1:970)
根因:
// 用了 computed 但 import 里没写
import { ref, reactive, onMounted } from 'vue' // ← 缺了 computed
const dialogTitle = computed(() => ...) // ← 用了但没导入
修复:
import { ref, reactive, onMounted, computed } from 'vue' // ✅
八、API 字段映射规范总结
三个问题反映了一个共同的架构问题:前后端字段命名规范不一致。
8.1 规范一:后端接口字段 = 数据库字段
这是最清晰的选择。调试时查数据库直接对应接口,ORM 映射零成本。
8.2 规范二:前端接收数据时做标准化映射
// 前端数据层统一做映射
const tableData = res.data.map(item => ({
...item,
// 后端名 → 前端名
name: item.channel_name ?? item.name,
type: item.channel_type ?? item.type
}))
这样后端可以保持和数据库一致,前端有自己的命名风格,互不干扰。
8.3 规范三:后端做字段名兼容
// 兼容多种命名风格
if (isset($params['name']) && !isset($params['workflow_name'])) {
$params['workflow_name'] = $params['name'];
}
这样做的好处是:前端可以用更简洁的字段名,后端保持规范,历史接口兼容。
九、V2 阶段完整工作清单
9.1 后端
| 模块 | 文件 | 状态 |
|---|---|---|
| NL2SQL Controller | Nl2SqlController.php |
✅ 完成 |
| NL2SQL Service | Nl2SqlService.php |
✅ 完成(mock模式) |
| 工作流 Controller | WorkflowController.php |
✅ 完成 |
| 工作流 Logic | WorkflowLogic.php |
✅ 完成(字段映射已修复) |
| 工作流 Executor | WorkflowExecutor.php |
✅ 完成 |
| 工作流 Model ×5 | Workflow/Node/Edge/Instance/Task |
✅ 完成 |
| 渠道配置 | NoticeChannelLogic.php |
✅ 完成 |
9.2 前端
| 模块 | 页面 | 状态 |
|---|---|---|
| NL2SQL | /pc/nl2sql |
✅ 完成 |
| 低代码构建器 | /pc/lowcode |
✅ 拖拽已修复 |
| 流程设计器 | /admin/workflow/design |
✅ 拖拽+连线+撤销 |
| 渠道配置 | /admin/system/notice |
✅ 字段映射已修复 |
十、经验总结
10.1 HTML5 Drag API 的局限性
| 场景 | Drag API | 鼠标事件 |
|---|---|---|
| 简单拖放 | 可用 | ✅ 更可靠 |
| 拖拽预览定制 | ❌ 受限 | ✅ 完全可控 |
| Vue 3 响应式 | ❌ 可能失效 | ✅ 稳定 |
| 精确坐标 | ❌ 依赖 dataTransfer | ✅ 直接用 clientX/Y |
| 连线功能 | ❌ 不适合 | ✅ 唯一选择 |
结论:复杂拖拽场景下,鼠标事件是唯一可靠的选择。
10.2 API 设计的黄金法则
永远先确认问题在哪一端:
# 步骤1:用 curl 验证后端
curl http://demo.fydev.cn/adminapi/notice_channel/lists \
-H "Authorization: Bearer $TOKEN"
# 步骤2:看 Network 面板确认前端收到的数据
# 步骤3:对比字段名找出不一致
# 步骤4:修复,再验证
10.3 字段映射的策略选择
飞鱼 Admin 采用了后端兼容多种 + 前端标准化映射的混合策略:
- 后端:保持和数据库一致(
workflow_name/channel_name) - 前端:在数据层统一映射为组件用名(
name/type)
这样前端组件无需改动,后端可以按数据库规范命名,通过映射层解耦。
结语
V2 第一阶段在当晚21点前完成了核心目标:NL2SQL 接口框架完成、低代码后端全部交付、流程设计器核心功能上线、渠道配置正常显示。
这一天最有价值的经验不是某个具体技术的解决方案,而是两个方法论:
第一:问题定位要先确认边界。 API 字段不匹配的问题,我花了很长时间才意识到这不是"后端返回了错误数据"而是"前端字段名对不上"。用 curl 直接测接口是区分问题在哪一端的最快方法。
第二:成熟的 API 不一定是最好的 API。 HTML5 Drag API 规范了这么多年,在复杂 Web 应用场景下依然不可靠。鼠标事件虽然更"底层",但正因为底层,行为完全可预测,bug 可排查。
V2 的战斗还没结束——NL2SQL 还需要接入真实 LLM,低代码的数据库表还没建,流程设计器的功能也还需要完善。但第一阶段的交付证明了整个系统的架构是可行的,这给了团队继续推进的信心。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)