TaskDetailModal.vue 12.3 KB
<!--
  任务详情/新建弹窗 —— TaskTable.vue 专用。`task` 为 null 时是新建模式。

  增删改查落地方式:
  - 读:`task` prop 本身就是 taskView() 的完整形状,摊平自 TaskBoard 已经拉好的 goals 树,
    不再单独请求(后端目前也没有单任务 GET 端点)。
  - 改:PATCH /:cid/tasks/:taskId(title/priority 随时可改;description/mode 在 queuedAt
    非空后会被服务端 409 拒绝,这里提前禁用输入框并给出同样的理由)。
  - 增:POST /:cid/tasks,固定带 draft:true —— 先建草稿,不自动派发。
  - 删:DELETE /:cid/tasks/:taskId(归档,运行中/排队中会 409,错误原样显示)。
  - 状态流转(下达/开始/取消/重跑/强制放行/停止):`taskActions()` 给可执行操作,
    这里的 runAction() 是 GoalTaskPane.vue 的 runTaskAction() 的等价物,端点表照抄不重新设计。
-->
<template>
  <div class="overlay" @click.self="$emit('close')">
    <div class="modal">
      <header class="head">
        <b>{{ isCreate ? '新建任务' : '任务详情' }}</b>
        <button class="x" @click="$emit('close')">×</button>
      </header>

      <div class="body">
        <template v-if="!isCreate">
          <div class="row">
            <span class="dot" :style="dotStyle(taskStyle(task.status))"></span>
            {{ taskStyle(task.status).label }}
            <span v-if="queueBadge(task)" class="qb" :title="queueBadge(task).title">{{ queueBadge(task).text }}</span>
            <span class="tid">{{ task.taskId }}</span>
          </div>
          <p v-if="task.status === 'blocked'" class="blocked-hint">{{ blockedLabel(task) || '挂起,原因未知' }}</p>
        </template>

        <label class="field">
          <span class="lbl">标题</span>
          <input v-model="form.title" class="in" :placeholder="isCreate ? '(留空则用描述自动生成)' : ''" />
        </label>

        <label class="field">
          <span class="lbl">任务描述{{ frozen ? '(已下达,不可改)' : '' }}</span>
          <textarea v-model="form.description" class="in ta" rows="4" :disabled="frozen"
            placeholder="要交给 agent 执行的完整描述"></textarea>
        </label>

        <div class="grid2">
          <label class="field">
            <span class="lbl">优先级</span>
            <input v-model="form.priority" class="in" placeholder="P1" />
          </label>
          <label class="field">
            <span class="lbl">执行模式{{ frozen ? '(已下达,不可改)' : '' }}</span>
            <select v-model="form.mode" class="in" :disabled="frozen">
              <option value="manual">manual(需人点「立即开始」)</option>
              <option value="auto">auto(有空位自动开跑)</option>
            </select>
          </label>
        </div>

        <label v-if="isCreate" class="field">
          <span class="lbl">所属目标</span>
          <select v-model="form.goalId" class="in">
            <option value="">(自动新建一个同名目标)</option>
            <option v-for="g in goals" :key="g.goalId" :value="g.goalId">{{ g.title || g.goalId }}</option>
          </select>
        </label>

        <template v-if="!isCreate">
          <div class="grid2">
            <div class="field ro"><span class="lbl">所属目标</span><span>{{ goalTitle }}</span></div>
            <div class="field ro"><span class="lbl">负责 Agent</span><span>{{ assigneeLabel }}</span></div>
          </div>
          <div class="grid2">
            <div class="field ro"><span class="lbl">创建时间</span><span>{{ fmt(task.createdAt) }}</span></div>
            <div class="field ro"><span class="lbl">下达时间</span><span>{{ fmt(task.queuedAt) }}</span></div>
          </div>
          <div class="grid2">
            <div class="field ro"><span class="lbl">开始时间</span><span>{{ fmt(task.startedAt) }}</span></div>
            <div class="field ro"><span class="lbl">结束时间</span><span>{{ fmt(task.finishedAt) }}</span></div>
          </div>
          <div v-if="task.dependsOn && task.dependsOn.length" class="field ro">
            <span class="lbl">依赖任务</span>
            <span>{{ task.dependsOn.join('、') }}</span>
          </div>
          <div v-if="task.budget" class="field ro">
            <span class="lbl">预算</span>
            <span class="mono">{{ JSON.stringify(task.budget) }}</span>
          </div>
          <router-link class="graph-link" :to="`/${companyId}/graph-console?goal=${task.goalId}&task=${task.taskId}`">
            🕸️ 查看执行图
          </router-link>
        </template>

        <p v-if="error" class="err">{{ error }}</p>
      </div>

      <footer class="foot">
        <div class="actions" v-if="!isCreate">
          <button v-if="task.status === 'draft'" class="btn" :disabled="busy" @click="enqueue">下达</button>
          <button
            v-for="act in actions" :key="act.key"
            class="btn" :class="{ danger: act.danger }"
            :disabled="act.disabled || busy" :title="act.title"
            @click="runAction(act)"
          >{{ act.label }}</button>
        </div>
        <div class="spacer"></div>
        <button class="btn ghost" @click="$emit('close')">取消</button>
        <button class="btn primary" :disabled="busy || !form.description.trim()" @click="save">
          {{ isCreate ? '创建' : '保存' }}
        </button>
      </footer>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive, computed } from 'vue';
import {
  taskStyle, queueBadge, blockedLabel, taskActions, parseBlockedReason,
} from '../graph-console/goal-task-model.js';

const props = defineProps({
  companyId: { type: String, required: true },
  task: { type: Object, default: null },
  goals: { type: Array, default: () => [] },
  agents: { type: Array, default: () => [] },
});
const emit = defineEmits(['close', 'saved']);

const isCreate = computed(() => !props.task);
/** 已经下达过(queuedAt 非空):description/mode 服务端会 409,界面提前禁用 */
const frozen = computed(() => !isCreate.value && !!props.task.queuedAt);

const form = reactive({
  title: props.task?.title || '',
  description: props.task?.description || '',
  priority: props.task?.priority || 'P1',
  mode: props.task?.mode || 'manual',
  goalId: '',
});

const goalTitle = computed(() => {
  const t = props.task;
  if (!t) return '';
  const g = (props.goals || []).find((x) => x.goalId === t.goalId);
  return (g && g.title) || t.goalId;
});

const assigneeLabel = computed(() => {
  const t = props.task;
  if (!t || !t.assigneeAgentId) return '未分配';
  if (t.assigneeAgentId === 'org-graph') return '组织图执行';
  const a = (props.agents || []).find((x) => x.id === t.assigneeAgentId);
  return (a && a.name) || t.assigneeAgentId;
});

const actions = computed(() => (props.task ? taskActions(props.task) : []));

const busy = ref(false);
const error = ref('');

async function call(method, url, body) {
  const r = await fetch(url, {
    method,
    headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const d = await r.json().catch(() => ({}));
  if (!r.ok) throw new Error([d.error || `HTTP ${r.status}`, d.hint].filter(Boolean).join(' · '));
  return d;
}

async function guarded(fn) {
  if (busy.value) return;
  busy.value = true;
  error.value = '';
  try {
    await fn();
    emit('saved');
  } catch (e) {
    error.value = e.message;
  } finally {
    busy.value = false;
  }
}

function save() {
  const cid = props.companyId;
  if (isCreate.value) {
    return guarded(() => call('POST', `/api/company/${cid}/tasks`, {
      task: form.description.trim(),
      goal_id: form.goalId || undefined,
      priority: form.priority || undefined,
      mode: form.mode,
      draft: true,
    }));
  }
  const patch = { title: form.title, priority: form.priority };
  if (!frozen.value) {
    patch.description = form.description;
    patch.mode = form.mode;
  }
  return guarded(() => call('PATCH', `/api/company/${cid}/tasks/${props.task.taskId}`, patch));
}

function enqueue() {
  return guarded(() => call('POST', `/api/company/${props.companyId}/tasks/${props.task.taskId}/enqueue`));
}

/** taskActions() 给的 key → 具体端点,逐条抄自 GoalTaskPane.vue 的 runTaskAction */
function runAction(act) {
  if (act.disabled || busy.value) return;
  const cid = props.companyId;
  const t = props.task;
  return guarded(async () => {
    if (act.key === 'stop') {
      await call('POST', `/api/graph-runs/${t.runId}/stop`);
      return;
    }
    if (act.key === 'release') {
      const p = parseBlockedReason(t.blockedReason);
      const deps = t.dependsOn || [];
      const next = deps.filter((id) => id !== (p && p.upstreamId));
      if (next.length === deps.length) {
        throw new Error(`放行失败:blocked 原因指向 ${p && p.upstreamId},但这条任务的依赖里没有它(依赖刚被改过?刷新再看)`);
      }
      await call('PATCH', `/api/company/${cid}/tasks/${t.taskId}`, { depends_on: next });
      return;
    }
    if (act.key === 'start') {
      await call('POST', `/api/company/${cid}/tasks/${t.taskId}/start`);
      return;
    }
    if (act.key === 'cancel') {
      await call('POST', `/api/company/${cid}/tasks/${t.taskId}/cancel`);
      return;
    }
    if (act.key === 'rerun') {
      await call('POST', `/api/company/${cid}/tasks/${t.taskId}/rerun`);
      return;
    }
    if (act.key === 'rerun-upstream') {
      if (!act.upstreamId) throw new Error('这条任务的 blocked 原因里没有上游 id,重跑不了');
      await call('POST', `/api/company/${cid}/tasks/${act.upstreamId}/rerun`);
      return;
    }
    // archive —— 归档后这条 task 会从 goals 树里消失,父组件在 @saved 里发现找不到就会关掉弹窗
    await call('DELETE', `/api/company/${cid}/tasks/${t.taskId}`);
  });
}

function dotStyle(s) {
  return s.hollow ? { background: 'transparent', borderColor: s.color } : { background: s.color, borderColor: s.color };
}
function fmt(iso) {
  if (!iso) return '—';
  try { return new Date(iso).toLocaleString('zh-CN'); } catch { return iso; }
}
</script>

<style scoped>
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.35); display: flex; align-items: center; justify-content: center; z-index: 100; }
.modal { width: min(560px, 92vw); max-height: 88vh; display: flex; flex-direction: column; background: #fff; border-radius: 10px; box-shadow: 0 10px 40px rgba(0,0,0,.2); }

.head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid #eee; }
.head b { font-size: 14px; }
.x { border: none; background: none; font-size: 18px; cursor: pointer; color: #999; line-height: 1; }
.x:hover { color: #333; }

.body { padding: 14px 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; font-size: 12px; }
.row { display: flex; align-items: center; gap: 6px; color: #444; }
.tid { margin-left: auto; color: #bbb; font-size: 11px; font-family: monospace; }
.blocked-hint { margin: 0; color: #d33; font-size: 11px; }
.qb { color: #7aa7dd; font-size: 11px; }

.field { display: flex; flex-direction: column; gap: 4px; }
.field.ro span:last-child { color: #333; }
.lbl { font-size: 11px; color: #888; }
.in { font: inherit; font-size: 12px; padding: 6px 9px; border: 1px solid #ddd; border-radius: 6px; background: #fff; color: #1a1a1a; }
.in:disabled { background: #f7f7f9; color: #999; }
.ta { resize: vertical; font-family: inherit; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.mono { font-family: monospace; font-size: 11px; color: #555; }
.graph-link { align-self: flex-start; font-size: 11px; color: #2b6cb0; text-decoration: none; }
.graph-link:hover { text-decoration: underline; }

.err { margin: 0; color: #dc2626; font-size: 11px; }

.foot { display: flex; align-items: center; gap: 6px; padding: 12px 16px; border-top: 1px solid #eee; flex-wrap: wrap; }
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
.spacer { flex: 1; }
.btn { border: 1px solid #ddd; background: #fff; border-radius: 6px; font-size: 12px; padding: 6px 12px; cursor: pointer; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn.danger { color: #b91c1c; }
.btn.primary { background: #2b6cb0; border-color: transparent; color: #fff; font-weight: 600; }
.btn.ghost { background: transparent; }
</style>