TaskTable.vue 9.28 KB
<!--
  任务看板的表格视图 —— TaskBoard.vue 专用,不是 GoalTaskPane 的替代品(那个还在
  GraphConsole 左栏服役,两边分开维护)。数据摊平自 goals[].tasks[],不额外请求。
-->
<template>
  <div class="tt">
    <div class="toolbar">
      <input v-model="search" class="in search" placeholder="搜索任务标题 / 描述…" />
      <select v-model="statusFilter" class="in sel">
        <option value="">全部状态</option>
        <option v-for="s in STATUS_OPTIONS" :key="s" :value="s">{{ taskStyle(s).label }}</option>
      </select>
      <select v-model="goalFilter" class="in sel">
        <option value="">全部目标</option>
        <option v-for="g in goals" :key="g.goalId" :value="g.goalId">{{ g.title || g.goalId }}</option>
      </select>
      <select v-model="assigneeFilter" class="in sel">
        <option value="">全部负责人</option>
        <option v-for="o in assigneeOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
      </select>
      <span class="count">{{ sorted.length }} / {{ flatTasks.length }} 条</span>
      <button class="btn primary" @click="$emit('create')">+ 新建任务</button>
    </div>

    <div class="scroll">
      <table>
        <thead>
          <tr>
            <th class="c-status" @click="toggleSort('status')">状态{{ sortArrow('status') }}</th>
            <th class="c-desc">任务描述</th>
            <th class="c-goal">所属目标</th>
            <th class="c-assignee">负责 Agent</th>
            <th class="c-priority" @click="toggleSort('priority')">优先级{{ sortArrow('priority') }}</th>
            <th class="c-time" @click="toggleSort('createdAt')">创建时间{{ sortArrow('createdAt') }}</th>
            <th class="c-time" @click="toggleSort('startedAt')">开始时间{{ sortArrow('startedAt') }}</th>
            <th class="c-time" @click="toggleSort('finishedAt')">结束时间{{ sortArrow('finishedAt') }}</th>
          </tr>
        </thead>
        <tbody>
          <tr v-if="loading && !flatTasks.length"><td colspan="8" class="empty">加载中…</td></tr>
          <tr v-else-if="!sorted.length"><td colspan="8" class="empty">没有匹配的任务</td></tr>
          <tr
            v-for="t in sorted" :key="t.taskId"
            class="row" :class="{ blocked: t.status === 'blocked' }"
            @click="$emit('select', t)"
          >
            <td class="c-status">
              <span class="dot" :class="{ pulse: taskStyle(t.status).pulse }" :style="dotStyle(taskStyle(t.status))"></span>
              {{ taskStyle(t.status).label }}
              <span v-if="queueBadge(t)" class="qb" :title="queueBadge(t).title">{{ queueBadge(t).text }}</span>
            </td>
            <td class="c-desc">
              <div class="title">{{ t.title || '(无标题)' }}</div>
              <div v-if="t.description" class="desc" :title="t.description">{{ t.description }}</div>
              <div v-if="t.status === 'blocked'" class="blocked-line">{{ blockedLabel(t, siblingsOf(t)) || '挂起' }}</div>
            </td>
            <td class="c-goal">{{ t.goalTitle }}</td>
            <td class="c-assignee">{{ assigneeLabel(t) }}</td>
            <td class="c-priority">{{ t.priority || '—' }}</td>
            <td class="c-time">{{ fmt(t.createdAt) }}</td>
            <td class="c-time">{{ fmt(t.startedAt) }}</td>
            <td class="c-time">{{ fmt(t.finishedAt) }}</td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
</template>

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

const props = defineProps({
  companyId: { type: String, required: true },
  goals: { type: Array, default: () => [] },
  agents: { type: Array, default: () => [] },
  loading: { type: Boolean, default: false },
});
defineEmits(['select', 'create']);

const STATUS_OPTIONS = ['draft', 'queued', 'running', 'completed', 'failed', 'blocked', 'cancelled'];

const flatTasks = computed(() => {
  const out = [];
  for (const g of props.goals || []) {
    for (const t of g.tasks || []) out.push({ ...t, goalTitle: g.title || g.goalId });
  }
  return out;
});

/** 同 goal 下的兄弟任务,blockedLabel 解析「上游 #N 未完成」要用 */
function siblingsOf(t) {
  const g = (props.goals || []).find((x) => x.goalId === t.goalId);
  return (g && g.tasks) || [];
}

function dotStyle(s) {
  return s.hollow ? { background: 'transparent', borderColor: s.color } : { background: s.color, borderColor: s.color };
}

const agentNameById = computed(() => {
  const m = new Map();
  for (const a of props.agents || []) m.set(a.id, a.name || a.id);
  return m;
});

/** assignee_agent_id 是 null(还没派)/ 'org-graph'(被组织图接管,没有真实派单对象)/ 真实 role_name 三种 */
function assigneeLabel(t) {
  if (!t.assigneeAgentId) return '未分配';
  if (t.assigneeAgentId === 'org-graph') return '组织图执行';
  return agentNameById.value.get(t.assigneeAgentId) || t.assigneeAgentId;
}

const assigneeOptions = computed(() => {
  const seen = new Map(); // value -> label
  for (const t of flatTasks.value) {
    const v = t.assigneeAgentId || '__none__';
    if (!seen.has(v)) seen.set(v, assigneeLabel(t));
  }
  return [...seen.entries()].map(([value, label]) => ({ value, label }));
});

const search = ref('');
const statusFilter = ref('');
const goalFilter = ref('');
const assigneeFilter = ref('');

const filtered = computed(() => {
  let list = flatTasks.value;
  const q = search.value.trim().toLowerCase();
  if (q) list = list.filter((t) => (t.title || '').toLowerCase().includes(q) || (t.description || '').toLowerCase().includes(q));
  if (statusFilter.value) list = list.filter((t) => t.status === statusFilter.value);
  if (goalFilter.value) list = list.filter((t) => t.goalId === goalFilter.value);
  if (assigneeFilter.value) list = list.filter((t) => (t.assigneeAgentId || '__none__') === assigneeFilter.value);
  return list;
});

const sortKey = ref('createdAt');
const sortDir = ref('desc');

function toggleSort(key) {
  if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
  else { sortKey.value = key; sortDir.value = 'asc'; }
}
function sortArrow(key) {
  if (sortKey.value !== key) return '';
  return sortDir.value === 'asc' ? ' ▲' : ' ▼';
}

/** 状态按中文标签排序(用户比较的是眼睛看到的字,不是内部枚举值),其余字段原值比较 */
function sortValue(t, key) {
  if (key === 'status') return taskStyle(t.status).label;
  return t[key] == null ? '' : t[key];
}

const sorted = computed(() => {
  const list = filtered.value.slice();
  const dir = sortDir.value === 'asc' ? 1 : -1;
  list.sort((a, b) => {
    const av = sortValue(a, sortKey.value);
    const bv = sortValue(b, sortKey.value);
    if (av < bv) return -1 * dir;
    if (av > bv) return 1 * dir;
    return 0;
  });
  return list;
});

function fmt(iso) {
  if (!iso) return '—';
  try { return new Date(iso).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }
  catch { return iso; }
}
</script>

<style scoped>
.tt { display: flex; flex-direction: column; gap: 10px; height: 100%; min-height: 0; }

.toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.in { font: inherit; font-size: 12px; padding: 6px 9px; border: 1px solid #ddd; border-radius: 6px; background: #fff; color: #1a1a1a; }
.search { flex: 1 1 220px; min-width: 160px; }
.sel { flex: 0 0 auto; }
.count { font-size: 11px; color: #999; white-space: nowrap; }
.btn { border: 1px solid #ddd; background: #fff; border-radius: 6px; font-size: 12px; padding: 6px 12px; cursor: pointer; }
.btn.primary { background: #2b6cb0; border-color: transparent; color: #fff; font-weight: 600; }

.scroll { flex: 1 1 auto; overflow: auto; border: 1px solid #eee; border-radius: 8px; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
thead th {
  position: sticky; top: 0; background: #fafafa; text-align: left; padding: 8px 10px;
  border-bottom: 1px solid #eee; font-weight: 600; color: #555; white-space: nowrap; cursor: pointer; user-select: none;
}
thead th:hover { color: #2b6cb0; }
.c-status:not(th), td.c-status { cursor: default; }
tbody td { padding: 8px 10px; border-bottom: 1px solid #f4f4f6; vertical-align: top; }
.row { cursor: pointer; }
.row:hover { background: #f7f9fc; }
.row.blocked { background: #fffaf9; box-shadow: inset 2px 0 0 #d33; }
.empty { text-align: center; color: #999; padding: 24px 0; }

.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid transparent; margin-right: 5px; vertical-align: middle; }
.dot.pulse { animation: pulse 1.6s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
.qb { margin-left: 6px; font-size: 10px; color: #7aa7dd; }

.c-desc { min-width: 220px; max-width: 360px; }
.title { font-weight: 600; color: #1a1a1a; }
.desc { margin-top: 2px; color: #888; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.blocked-line { margin-top: 2px; color: #d33; font-size: 11px; }
.c-goal, .c-assignee { color: #555; white-space: nowrap; }
.c-priority { white-space: nowrap; }
.c-time { white-space: nowrap; color: #666; }

@media (max-width: 900px) {
  .c-goal, .c-priority { display: none; }
}
</style>