Browse Source

docs: 增加 CommandProgressFloat 设计与实现计划

地图指令进度浮层抽离,轮询状态机仍留页面。
main
xiaosi 3 weeks ago
parent
commit
d8a4af3973
  1. 429
      docs/superpowers/plans/2026-08-28-command-progress-float.md
  2. 125
      docs/superpowers/specs/2026-08-28-command-progress-float-design.md

429
docs/superpowers/plans/2026-08-28-command-progress-float.md

@ -1,425 +1,42 @@
# Command Progress Float Implementation Plan
# CommandProgressFloat Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 监控页下发指令后弹出当前指令进度浮窗,轮询 `/v1/commands/:id` 逐步展示 `sent/acked/timeout/terminal`,并同步按钮旁状态文案
**Goal:** 抽出地图指令进度浮层为展示组件;轮询/状态机留页面
**Architecture:** 在 `MonitorView` 本地维护 `commandProgress` 状态;`runCommand` 使用 `sendCommand` 返回的 `cmd.id` 启动轮询;右下角非模态浮窗展示三步进度;同 title 按钮 `small` 文案跟随状态。不改后端协议。
**Tech Stack:** Vue 3、现有 `request`/`urls`、`devicesStore.sendCommand`、`prototype.css`
**Architecture:** `CommandProgressFloat.vue` 接收 open/title/deviceName/step/status/result/error/terminalLabel,emit `close`
**Spec:** `docs/superpowers/specs/2026-08-28-command-progress-float-design.md` **Spec:** `docs/superpowers/specs/2026-08-28-command-progress-float-design.md`
--- ---
### Task 1: 增加指令详情 URL
**Files:**
- Modify: `src/config/urls.js`
- [ ] **Step 1: 增加 `COMMAND(id)`**
`// Commands` 段:
```js
// Commands
export const COMMANDS = '/v1/commands'
export const COMMAND = (id) => `/v1/commands/${id}`
```
- [ ] **Step 2: Commit**
```bash
git add src/config/urls.js
git commit -m "feat: 增加单条指令查询 URL"
```
---
### Task 2: Monitor 指令进度状态机与轮询
**Files:**
- Modify: `src/views/MonitorView/MonitorView.vue`
- [ ] **Step 1: 增加 import**
确保已有:
```js
import request from '@/utils/http'
import * as urls from '@/config/urls'
```
若当前是别的 http 路径,保持项目现有 import 风格(与文件顶部一致),只补 `urls.COMMAND`
- [ ] **Step 2: 增加状态常量与 progress 状态**
放在 `script setup` 控制相关区域(`runCommand` 附近):
```js
const COMMAND_STATUS = {
sent: { name: '已下发', step: 2, result: '等待应答', terminal: false },
acked: { name: '已确认', step: 3, result: '执行成功', terminal: true, ok: true },
timeout: { name: '已超时', step: 3, result: '已超时', terminal: true, ok: false },
terminal: { name: '执行失败', step: 3, result: '执行失败', terminal: true, ok: false },
}
const commandProgress = reactive({
open: false,
title: '',
deviceName: '',
commandId: '',
status: '', // sent/acked/timeout/terminal
result: '',
step: 1, // 1 确认下发 2 已下发 3 终态
error: '',
buttonTitle: '',
})
let commandPollTimer = null
let commandAutoCloseTimer = null
let commandPollFails = 0
```
- [ ] **Step 3: 增加 helper**
```js
function buttonStatusText(title) {
if (commandProgress.buttonTitle !== title || !commandProgress.status) return '状态:待执行'
const meta = COMMAND_STATUS[commandProgress.status]
return meta ? `状态:${meta.name}` : '状态:待执行'
}
function clearCommandTimers() {
if (commandPollTimer) {
clearInterval(commandPollTimer)
commandPollTimer = null
}
if (commandAutoCloseTimer) {
clearTimeout(commandAutoCloseTimer)
commandAutoCloseTimer = null
}
}
function closeCommandProgress() {
clearCommandTimers()
commandProgress.open = false
}
function applyCommandStatus(cmd) {
const status = cmd?.status || 'sent'
const meta = COMMAND_STATUS[status] || COMMAND_STATUS.sent
commandProgress.status = status
commandProgress.step = meta.step
commandProgress.result = cmd?.ackResultCode || meta.result
commandProgress.error = ''
return meta
}
function scheduleAutoClose() {
if (commandAutoCloseTimer) clearTimeout(commandAutoCloseTimer)
commandAutoCloseTimer = setTimeout(() => {
closeCommandProgress()
}, 5000)
}
async function pollCommandOnce() {
if (!commandProgress.commandId) return
try {
const cmd = await request.get(urls.COMMAND(commandProgress.commandId))
commandPollFails = 0
const meta = applyCommandStatus(cmd)
if (meta.terminal) {
clearInterval(commandPollTimer)
commandPollTimer = null
scheduleAutoClose()
}
} catch (e) {
commandPollFails += 1
const status = e?.response?.status
if (status === 403 || status === 404 || commandPollFails >= 3) {
clearInterval(commandPollTimer)
commandPollTimer = null
commandProgress.error =
status === 403
? '无权限查看进度,请稍后在操作记录确认'
: status === 404
? '未找到指令记录'
: (e.message || '进度查询失败')
scheduleAutoClose()
}
}
}
function startCommandProgress(cmd, title, deviceName) {
clearCommandTimers()
commandPollFails = 0
commandProgress.open = true
commandProgress.title = title
commandProgress.deviceName = deviceName || '--'
commandProgress.commandId = String(cmd?.id || '')
commandProgress.buttonTitle = title
commandProgress.step = 1
commandProgress.error = ''
applyCommandStatus(cmd || { status: 'sent' })
if (!commandProgress.commandId) {
commandProgress.error = '未返回指令 ID,无法跟踪进度'
scheduleAutoClose()
return
}
const ttl = Number(cmd?.ttlMs) > 0 ? Number(cmd.ttlMs) : 30000
const deadline = Date.now() + ttl + 5000
commandPollTimer = setInterval(async () => {
if (Date.now() > deadline) {
clearInterval(commandPollTimer)
commandPollTimer = null
if (!COMMAND_STATUS[commandProgress.status]?.terminal) {
commandProgress.error = '等待超时,请稍后在操作记录查看'
commandProgress.step = 3
}
scheduleAutoClose()
return
}
await pollCommandOnce()
}, 1500)
// 立即查一次,避免干等 1.5s
pollCommandOnce()
}
```
- [ ] **Step 4: 改写 `runCommand`**
```js
async function runCommand(title, desc) {
const rec = record.value
if (!rec) return
if (!rec.online) {
ui.toast('设备当前离线,无法下发指令')
return
}
if (rec.kind === 'drone' && rec.bindingState !== 'bound') {
ui.toast('无人机未绑定机巢,无法下发指令')
return
}
const ok = await ui.confirm(title, desc)
if (!ok) return
try {
const cmd = await devices.sendCommand(rec, title)
ui.toast(`${title}指令已下发`)
startCommandProgress(cmd, title, rec.name)
await reload()
} catch (e) {
ui.toast(e.message || '指令下发失败')
}
}
```
- [ ] **Step 5: 卸载清理**
在现有 `onUnmounted` 中追加:
```js
closeCommandProgress()
```
并在 `onGlobalKeydown` 增加:
```js
if (event.key === 'Escape' && commandProgress.open) {
closeCommandProgress()
return
}
```
- [ ] **Step 6: Commit**
```bash
git add src/views/MonitorView/MonitorView.vue
git commit -m "feat: 监控指令下发后轮询命令状态"
```
---
### Task 3: 浮窗 UI + 按钮状态文案
**Files:**
- Modify: `src/views/MonitorView/MonitorView.vue`
- Modify: `src/styles/prototype.css`
### Task 1
- [ ] **Step 1: 按钮 `small` 改为动态**
- Create `src/components/CommandProgressFloat.vue`
- Modify `src/views/MonitorView/MonitorView.vue`
机巢主按钮(有「状态:待执行」的)
替换 map-panel 内 float 为:
```vue ```vue
<small>{{ buttonStatusText('一键起飞') }}</small>
<small>{{ buttonStatusText('一键降落') }}</small>
<!-- stage / reset 同理,title 与 runCommand 第一个参数一致 -->
<CommandProgressFloat
:open="commandProgress.open"
:title="commandProgress.title"
:device-name="commandProgress.deviceName"
:step="commandProgress.step"
:status="commandProgress.status"
:result="commandProgress.result"
:error="commandProgress.error"
:terminal-label="commandProgressTerminalLabel"
@close="closeCommandProgress"
/>
``` ```
更多控制里 `dockExtraCommands`
```vue
<small>{{ commandProgress.buttonTitle === cmd.title && commandProgress.status ? buttonStatusText(cmd.title) : cmd.hint }}</small>
```
无人机侧:对已有 `<small>` 的主按钮(一键返航/起飞/紧急停止)同样用 `buttonStatusText(title)`;纯文字 secondary 按钮可不加。
- [ ] **Step 2: 增加浮窗模板**
放在 `MonitorView` 根模板末尾(与 dock status modal 同级):
```vue
<div
v-if="commandProgress.open"
class="command-progress-float"
role="status"
aria-live="polite"
>
<header>
<div>
<strong>{{ commandProgress.title }}</strong>
<small>{{ commandProgress.deviceName }}</small>
</div>
<button type="button" class="icon-command" title="关闭" @click="closeCommandProgress">
<svg><use href="#i-x" /></svg>
</button>
</header>
<ol>
<li :class="{ done: commandProgress.step > 1, active: commandProgress.step === 1 }">
<i></i><span>确认下发</span><em>已确认</em>
</li>
<li :class="{ done: commandProgress.step > 2, active: commandProgress.step === 2 }">
<i></i><span>已下发</span><em>{{ commandProgress.step >= 2 ? '等待应答' : '…' }}</em>
</li>
<li
:class="{
done: commandProgress.step >= 3 && !commandProgress.error && commandProgress.status === 'acked',
active: commandProgress.step === 3,
fail: commandProgress.step >= 3 && (commandProgress.status === 'timeout' || commandProgress.status === 'terminal' || !!commandProgress.error),
}"
>
<i></i>
<span>{{ commandProgress.status === 'acked' ? '已确认' : (COMMAND_STATUS[commandProgress.status]?.name || '执行结果') }}</span>
<em>{{ commandProgress.error || commandProgress.result || '…' }}</em>
</li>
</ol>
</div>
```
注意:模板里若不能直接用 `COMMAND_STATUS`,改成 computed `commandProgressTerminalLabel`
- [ ] **Step 3: 增加样式**
`prototype.css` 追加:
```css
.command-progress-float {
position: absolute;
right: 18px;
bottom: 56px;
z-index: 36;
width: 280px;
padding: 12px 12px 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255,255,255,.96);
box-shadow: 0 10px 28px rgba(20, 34, 48, .18);
color: #334553;
}
.command-progress-float > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.command-progress-float > header strong { display: block; font-size: 13px; }
.command-progress-float > header small { color: var(--muted); font-size: 11px; }
.command-progress-float ol { margin: 0; padding: 0; list-style: none; display: grid; gap: 8px; }
.command-progress-float li {
display: grid;
grid-template-columns: 14px 1fr auto;
align-items: center;
gap: 8px;
min-height: 28px;
color: #7b8792;
font-size: 12px;
}
.command-progress-float li i {
width: 10px; height: 10px; border-radius: 50%;
border: 1.5px solid #c0CAD3; background: #fff;
}
.command-progress-float li.active { color: #1f5f9f; font-weight: 600; }
.command-progress-float li.active i { border-color: #2d82da; background: #2d82da; }
.command-progress-float li.done { color: #2f6b4f; }
.command-progress-float li.done i { border-color: #2f9d6a; background: #2f9d6a; }
.command-progress-float li.fail { color: #a53e45; }
.command-progress-float li.fail i { border-color: #c8454b; background: #c8454b; }
.command-progress-float li em { font-style: normal; color: var(--muted); font-size: 11px; }
```
- [ ] **Step 4: Commit**
```bash
git add src/views/MonitorView/MonitorView.vue src/styles/prototype.css
git commit -m "feat: 增加指令进度浮窗与按钮状态文案"
```
---
### Task 4: 浏览器冒烟
**Steps:**
- [ ] **Step 1: 启动前端,mock 指令接口**
对监控页:
1. 登录进 `/monitor`,选中在线机巢。
2. 拦截:
- `POST /api/v1/docks/:id/command``{ code:200, data:{ id: 1001, status:'sent', ttlMs:30000, commandType:'dock.open' } }`
- 前 2 次 `GET /api/v1/commands/1001``status:'sent'`
- 之后 → `status:'acked', ackResultCode:'OK'`
3. 点击「打开舱门」或「一键起飞」并确认。
- [ ] **Step 2: 验收清单**
1. 浮窗出现,步骤 1 完成 → 步骤 2「已下发/等待应答」→ 步骤 3「已确认/执行成功」。
2. 对应按钮 `small` 变为「状态:已下发」再变「状态:已确认」。
3. 终态约 5s 后浮窗自动关闭;期间可手动关。
4. Escape 可关闭浮窗。
5. 连续点另一条指令:浮窗切换到新指令。
6. 离线设备:仅 toast,不出现成功浮窗。
- [ ] **Step 3: Commit 验收说明(若有小修)**
若冒烟中修了样式/边界,单独 commit:
```bash
git add -A
git commit -m "fix: 指令进度浮窗冒烟问题"
```
---
Commit: `refactor: 抽取 CommandProgressFloat 指令进度浮层`
## 覆盖对照(spec)
### Task 2 冒烟
| Spec 要求 | Task |
|-----------|------|
| 下发后浮窗 + 三步状态 | Task 2/3 |
| 轮询 `/v1/commands/:id` 1.5s,TTL+5s | Task 2 |
| 按钮 small 同步 | Task 3 |
| 终态 5s 自动关 / 手动关 / Escape | Task 2/3 |
| 同设备新指令替换跟踪 | Task 2 `startCommandProgress` |
| 403/404/连续失败 | Task 2 `pollCommandOnce` |
| 不改后端协议 | 全程 |
浮层 DOM/class 在 open=true 时出现;点关闭触发 close;无 console error。
## 执行注意
## 约束
- 不要改 `devicesStore.sendCommand` 的返回值语义(已返回 cmd)。
- 不要引入 WebSocket。
- 工作流子步骤(开门/归中)不在本计划范围。
不改后台;精确 git add;中文 commit

125
docs/superpowers/specs/2026-08-28-command-progress-float-design.md

@ -1,116 +1,41 @@
# 监控页指令进度浮窗
## 背景
监控页快捷控制按钮旁写死「状态:待执行」。下发后只 toast「指令已下发」,领导反馈「指令没有状态」。
设备详情 / 操作记录已接入 `/v1/commands`,状态机为:
`sent → acked | timeout | terminal`
后端项目:`/Users/qingyuan/CodeProject/laic-backend`
已核实:
- `POST /v1/docks/:id/command` 返回完整 `DeviceCommandLog`(含 `id`、`status`)
- `GET /v1/commands/:id` 可查单条
- `GET /v1/commands` 可查历史
- 状态枚举:`sent` / `acked` / `timeout` / `terminal`
- 指令单**无**子步骤字段;工作流逐步事件走 MQTT `state/workflow`,本轮不接入
本轮不新增后端协议字段。
# CommandProgressFloat 设计
## 目标 ## 目标
1. 监控页下发指令后弹出**当前指令进度浮窗**,逐步展示状态。
2. 与详情页操作记录同一套状态语义。
3. 同步更新对应控制按钮旁 `small` 文案。
4. 不打断地图与侧栏主流程。
将 Monitor 地图上的 `command-progress-float` 抽为展示组件。轮询 / `startCommandProgress` / `closeCommandProgress` / Escape 关闭仍留在 MonitorView。
## 非目标 ## 非目标
- 工作流 PLC 子步骤时间线(开门/归中等)——需另开需求。
- 批量多选指令。
- ControlView / 设备详情操作记录大改。
- 用 WebSocket 替换轮询(可后续增强)。
## 方案
### 1. 触发与数据流
- 不迁指令下发与轮询逻辑
- 不改 map-toolbar / 图例 / 比例尺
- 不改按钮状态文案计算(`buttonStatusText` 仍读页面 `commandProgress`
1. 用户确认后调用现有 `devices.sendCommand(rec, title)`
2. 取返回体 `cmd.id` / `cmd.status`(axios 已解包 `data`)。
3. 打开浮窗,初始化步骤:
- 步骤 1:确认下发(本地立即完成)
- 步骤 2:已下发(`sent`)
- 步骤 3:终态(`acked` / `timeout` / `terminal`
4. 以 `1.5s` 间隔轮询 `GET /v1/commands/:id`,直到终态或超时上限。
5. 轮询上限:`max(ttlMs, 30000) + 5000`;超时仍未终态则展示「等待超时,请稍后在操作记录查看」。
## 契约
若下发失败(HTTP/业务错误):不进入轮询,仅 toast(与现网一致)。
**Props**
### 2. 浮窗 UI
- 位置:监控页右下角(避开比例尺),非模态,不挡主操作。
- 标题:指令中文名(如「一键起飞」)+ 目标设备名。
- 主体:垂直步骤列表,当前步高亮;完成步打勾;失败/超时用警示色。
- 副文案:
- `sent`:等待应答
- `acked`:`ackResultCode` 或「执行成功」
- `timeout`:已超时
- `terminal`:`ackResultCode` 或「执行失败」
- 操作:手动关闭;终态后 **5s** 自动关闭。
- Escape:关闭浮窗(不取消已下发指令)。
同设备再次下发新指令:替换当前浮窗内容与轮询目标(只跟踪最新一条)。
### 3. 按钮旁状态
监控页已有 `状态:待执行``small`
| 阶段 | 文案 |
| prop | 说明 |
|------|------| |------|------|
| 默认 | 状态:待执行 |
| 下发中 / sent | 状态:已下发 |
| acked | 状态:已确认 |
| timeout | 状态:已超时 |
| terminal | 状态:执行失败 |
仅更新**本次点击的那颗按钮**(或同 `title` 的按钮);其他按钮保持原状。离开监控页或卸载时清理定时器与浮窗状态。
### 4. 实现落点
- 主改:`src/views/MonitorView/MonitorView.vue`
- `runCommand` 接收 `sendCommand` 返回值
- 本地 `commandProgress` 状态 + 轮询
- 浮窗模板与样式(复用现有视觉 token)
- `src/config/urls.js` 增加 `COMMAND = (id) => `/v1/commands/${id}``
- `devicesStore.sendCommand` 保持返回后端 `cmd` 对象(已返回,勿吞掉)
状态映射复用详情页:
```js
sent → 已下发 / 等待应答
acked → 已确认 / 执行成功
timeout → 已超时
terminal → 执行失败
```
| `open` | 是否显示 |
| `title` | 指令名 |
| `deviceName` | 设备名 |
| `step` | 1–3 |
| `status` | sent/acked/timeout/terminal… |
| `result` | 结果文案 |
| `error` | 错误文案 |
| `terminalLabel` | 第三步标题(原 `commandProgressTerminalLabel`) |
### 5. 权限与错误
**Emits:** `close`
- `GET /v1/commands/:id` 若 403:停止轮询,浮窗提示无权限查看进度,保留「已下发」。
- 404:停止轮询并提示。
- 网络抖动:单次失败不关浮窗,连续失败 3 次再提示。
组件内保留三步 `<ol>` class 规则(done/active/fail)。
## 验收
## 实现
1. 在线机巢点「打开舱门」:确认后出现浮窗;步骤从确认下发推进到已下发,再至已确认/失败/超时。
2. 按钮 `small` 同步变化;终态约 5s 后浮窗自动消失,可提前手动关。
3. 连续点两条指令:浮窗跟踪最新一条。
4. 设备离线:仍只 toast,不出现成功态浮窗。
5. 详情页「操作记录」能查到对应指令,状态与浮窗终态一致。
1. `src/components/CommandProgressFloat.vue`
2. MonitorView map-panel 内替换内联 float
3. Commit: `refactor: 抽取 CommandProgressFloat 指令进度浮层`
4. 冒烟:通过页面临时打开态或 evaluate 设置/触发 close;结构 class 存在即可(后端不可用时可不真下发)
## 风险
## 约束
- 后端 ACK 慢或 mock 不回 ACK:浮窗会停在「已下发」直到 timeout —— 符合真实状态机。
- 工作流多步(起飞准备等)目前只有指令级状态;若领导后续要「开门/归中」子步骤,需后端补步骤事件或前端用 realtime 推断(另开需求)。
精确 git add;中文 commit;跳过全量单测

Loading…
Cancel
Save