前言

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 响应式场景下行为不可靠。

两个主要问题:

  1. dataTransfer.setData() 在某些条件下静默失败(不报错也不生效)
  2. 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 最复杂的前端功能。

设计思路:

  1. 每个节点右侧有一个连接端口(蓝色圆点)
  2. 从端口拖出,显示一条虚线预览
  3. 鼠标释放时,判断落在哪个节点上
  4. 落在目标节点上 → 创建连线(SVG path)
  5. 点击连线 → 选中并可配置

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,低代码的数据库表还没建,流程设计器的功能也还需要完善。但第一阶段的交付证明了整个系统的架构是可行的,这给了团队继续推进的信心。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐