DispatchTaskButton.vue 6.11 KB
<!--
  下达任务入口(27-P3B W14)

  `POST /api/company/:cid/tasks` 此前**全仓库没有任何前端调用方** —— 界面走查时确认过:
  用户在控制台上没有任何办法给公司下达任务,只能 curl。这是「实现在、零调用方」那类问题
  在 P3 里的第 N 次出现(W8 刚给 replan 补上调用方,tasks 还欠着)。

  两处刻意的设计:
  - **手动/自动在下达时就要选**,不是跑起来再改:`execution_mode` 是 run 的属性,
    起 run 时定;界面上后补一个下拉会让人以为能中途换。
  - **自动模式在这里就给成本预警**:真 CLI 一次中等任务是 $3-8 / 15-40 分钟
    (27-P3B §五),点下去才知道就晚了。GraphRunPane 的「启动」也有一道确认,
    但那是针对已有 run 的,下达这一步是更早的闸门。
-->
<template>
  <div class="dispatch" :class="{ open }">
    <button v-if="!open" class="btn" :class="variant" @click="open = true">
      ✦ 下达任务
    </button>

    <div v-else class="form">
      <label class="lbl">任务描述</label>
      <textarea
        ref="ta"
        v-model="task"
        class="ta"
        rows="3"
        placeholder="用一句自然语言说清要做什么,例如:实现一个最小可运行的接口,错误输入要有明确响应"
        :disabled="busy"
        @keydown.meta.enter="submit"
        @keydown.ctrl.enter="submit"
      />

      <label class="lbl">执行模式</label>
      <div class="modes">
        <label class="mode" :class="{ on: mode === 'manual' }">
          <input v-model="mode" type="radio" value="manual" :disabled="busy" />
          <span class="m-t">手动</span>
          <span class="m-d">每点一次「下一步」推进一个节点</span>
        </label>
        <label class="mode" :class="{ on: mode === 'auto' }">
          <input v-model="mode" type="radio" value="auto" :disabled="busy" />
          <span class="m-t">自动</span>
          <span class="m-d">一次授权,从头跑到尾</span>
        </label>
      </div>

      <p v-if="mode === 'auto'" class="warn">
        ⚠️ 自动模式会连续调用真实 agent。一次中等任务约 <b>60-100 次调用 / $10-20 / 1-2 小时</b>;
        跑起来后可以随时「暂停」,但已经发出的那次调用不会被打断。
      </p>

      <p v-if="err" class="err">{{ err }}<span v-if="hint" class="hint"> · {{ hint }}</span></p>

      <div class="acts">
        <button class="btn primary" :disabled="!task.trim() || busy" @click="submit">
          {{ busy ? '下达中…' : '下达' }}
        </button>
        <button class="btn ghost" :disabled="busy" @click="cancel">取消</button>
        <span class="kbd">⌘/Ctrl + Enter</span>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, nextTick, watch } from 'vue';

const props = defineProps({
  companyId: { type: String, required: true },
  variant: { type: String, default: 'primary' }, // primary | inline
});
const emit = defineEmits(['dispatched']);

const open = ref(false);
const task = ref('');
const mode = ref('manual');
const busy = ref(false);
const err = ref('');
const hint = ref('');
const ta = ref(null);

watch(open, async (v) => {
  if (!v) return;
  await nextTick();
  ta.value?.focus();
});

function cancel() {
  open.value = false;
  err.value = '';
  hint.value = '';
}

async function submit() {
  if (!task.value.trim() || busy.value) return;
  busy.value = true;
  err.value = '';
  hint.value = '';
  try {
    const r = await fetch(`/api/company/${props.companyId}/tasks`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ task: task.value.trim(), mode: mode.value }),
    });
    const d = await r.json().catch(() => ({}));
    if (!r.ok || !d.runId) {
      // 409 = v1 并发约束「一家公司同时只有一个 active root run」,服务端会带 hint,
      // 原样透出去比翻译成「出错了」有用得多
      err.value = d.error || `下达失败(HTTP ${r.status})`;
      hint.value = d.hint || '';
      return;
    }
    open.value = false;
    task.value = '';
    emit('dispatched', d);
  } catch (e) {
    err.value = `下达失败:${e.message}`;
  } finally {
    busy.value = false;
  }
}
</script>

<style scoped>
/* 收起时按内容宽,撑满会盖住同一行的「重新规划组织」;展开时才占满整列 */
.dispatch { min-width: 0; }
.dispatch.open { width: 100%; }
.btn {
  border: 1px solid var(--border-primary); background: var(--bg-surface); color: var(--text-primary);
  border-radius: 6px; padding: 6px 12px; font-size: 13px; cursor: pointer;
}
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn.primary { background: var(--accent, #2563eb); border-color: transparent; color: #fff; font-weight: 600; }
.btn.inline { padding: 4px 10px; font-size: 12px; }
.btn.ghost { background: transparent; }
.form { display: flex; flex-direction: column; gap: 6px; text-align: left; }
.lbl { font-size: 11px; color: var(--text-secondary); font-weight: 600; }
.ta {
  width: 100%; box-sizing: border-box; resize: vertical; font: inherit; font-size: 13px;
  padding: 8px; border: 1px solid var(--border-primary); border-radius: 6px;
  background: var(--bg-surface); color: var(--text-primary);
}
.modes { display: flex; gap: 6px; }
.mode {
  flex: 1; display: flex; flex-direction: column; gap: 2px; cursor: pointer;
  border: 1px solid var(--border-primary); border-radius: 6px; padding: 6px 8px;
}
.mode.on { border-color: var(--accent, #2563eb); background: var(--bg-overlay); }
.mode input { display: none; }
.m-t { font-size: 12px; font-weight: 700; }
.m-d { font-size: 11px; color: var(--text-secondary); line-height: 1.3; }
.warn {
  margin: 0; font-size: 11px; line-height: 1.45; color: var(--text-secondary);
  background: var(--bg-overlay); border: 1px dashed var(--border-primary);
  border-radius: 6px; padding: 6px 8px;
}
.err { margin: 0; font-size: 12px; color: #dc2626; line-height: 1.4; }
.err .hint { color: var(--text-secondary); }
.acts { display: flex; align-items: center; gap: 6px; }
.kbd { margin-left: auto; font-size: 10px; color: var(--text-secondary); }
</style>