1 changed files with 425 additions and 0 deletions
@ -0,0 +1,425 @@ |
|||||
|
# Command Progress Float 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. |
||||
|
|
||||
|
**Goal:** 监控页下发指令后弹出当前指令进度浮窗,轮询 `/v1/commands/:id` 逐步展示 `sent/acked/timeout/terminal`,并同步按钮旁状态文案。 |
||||
|
|
||||
|
**Architecture:** 在 `MonitorView` 本地维护 `commandProgress` 状态;`runCommand` 使用 `sendCommand` 返回的 `cmd.id` 启动轮询;右下角非模态浮窗展示三步进度;同 title 按钮 `small` 文案跟随状态。不改后端协议。 |
||||
|
|
||||
|
**Tech Stack:** Vue 3、现有 `request`/`urls`、`devicesStore.sendCommand`、`prototype.css` |
||||
|
|
||||
|
**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` |
||||
|
|
||||
|
- [ ] **Step 1: 按钮 `small` 改为动态** |
||||
|
|
||||
|
机巢主按钮(有「状态:待执行」的): |
||||
|
|
||||
|
```vue |
||||
|
<small>{{ buttonStatusText('一键起飞') }}</small> |
||||
|
<small>{{ buttonStatusText('一键降落') }}</small> |
||||
|
<!-- stage / reset 同理,title 与 runCommand 第一个参数一致 --> |
||||
|
``` |
||||
|
|
||||
|
更多控制里 `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: 指令进度浮窗冒烟问题" |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 覆盖对照(spec) |
||||
|
|
||||
|
| 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` | |
||||
|
| 不改后端协议 | 全程 | |
||||
|
|
||||
|
## 执行注意 |
||||
|
|
||||
|
- 不要改 `devicesStore.sendCommand` 的返回值语义(已返回 cmd)。 |
||||
|
- 不要引入 WebSocket。 |
||||
|
- 工作流子步骤(开门/归中)不在本计划范围。 |
||||
Loading…
Reference in new issue