TeamCard.vue 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. <script setup lang="ts">
  2. // 工作组消息上的卡片(docs/design/13-bot-teams.md §5、§12.2):任务、子任务的状态标签,限额、替身、逐步执行的操作。
  3. // 状态取 jobs / tasks / steps 的最新值,历史消息上的卡片也会随之更新
  4. import { computed, ref } from 'vue'
  5. import type { JobStatus, TaskStatus, TeamCard } from '@agent-server/shared'
  6. import type { TeamSession } from '../team-store'
  7. const props = defineProps<{ card: TeamCard; session: TeamSession; readonly?: boolean }>()
  8. const state = computed(() => props.session.state)
  9. const job = computed(() => ('jobId' in props.card ? state.value.jobs[props.card.jobId] : undefined))
  10. const task = computed(() => ('taskId' in props.card ? state.value.tasks[props.card.taskId] : undefined))
  11. const botName = (id: string) => state.value.team?.bots.find(b => b.id === id)?.name ?? '组员'
  12. const JOB_STATUS: Record<JobStatus, string> = { running: '进行中', blocked: '已暂停(超出限额)', completed: '已完成', failed: '失败', cancelled: '已取消' }
  13. const TASK_STATUS: Record<TaskStatus, string> = { waiting: '等待依赖', queued: '排队中', running: '执行中', stub: '等待替身结果', completed: '已完成', failed: '失败', cancelled: '已取消' }
  14. const tone = (s: string) => (s === 'completed' ? 'ok' : s === 'failed' ? 'bad' : s === 'cancelled' ? 'muted' : s === 'blocked' || s === 'stub' ? 'warn' : 'busy')
  15. const busy = ref(false)
  16. const error = ref('')
  17. async function act(fn: () => Promise<unknown>) {
  18. busy.value = true
  19. error.value = ''
  20. try {
  21. await fn()
  22. } catch (err) {
  23. error.value = (err as Error).message
  24. } finally {
  25. busy.value = false
  26. }
  27. }
  28. const jobId = () => ('jobId' in props.card ? props.card.jobId : '')
  29. const taskId = () => ('taskId' in props.card ? props.card.taskId : '')
  30. const continueJob = () => act(() => props.session.continueJob(jobId()))
  31. const cancelJob = () => act(() => props.session.cancelJob(jobId()))
  32. const stubDone = (outcome: 'completed' | 'failed') => act(() => props.session.stubResult(taskId(), { outcome, result: stubText.value.trim() }))
  33. const continueStep = () => act(() => props.session.continueStep(props.card.kind === 'step' ? props.card.stepId : ''))
  34. // 替身结果
  35. const stubText = ref('')
  36. const stubPending = computed(() => props.card.kind === 'stub' && task.value?.status === 'stub')
  37. const stepPending = computed(() => props.card.kind === 'step' && state.value.steps.some(s => s.id === (props.card as { stepId: string }).stepId))
  38. const limitPending = computed(() => props.card.kind === 'limit' && job.value?.status === 'blocked')
  39. </script>
  40. <template>
  41. <!-- 任务:开始 / 结束 -->
  42. <div v-if="card.kind === 'job'" class="chip-line">
  43. <span class="chip" :class="tone(job?.status ?? card.event)">
  44. <span class="codicon" :class="card.event === 'started' ? 'codicon-tasklist' : card.event === 'completed' ? 'codicon-pass' : 'codicon-circle-slash'" />
  45. {{ card.event === 'started' ? '开始任务' : '任务结束' }}{{ job ? ` #${job.no}` : '' }}
  46. </span>
  47. <span v-if="job && card.event === 'started'" class="state">{{ JOB_STATUS[job.status] }}<template v-if="job.taskCount"> · {{ job.taskCount }} 个子任务</template><template v-if="job.costUsd"> · ${{ job.costUsd.toFixed(3) }}</template></span>
  48. </div>
  49. <!-- 子任务:分派 / 结果 -->
  50. <div v-else-if="card.kind === 'task'" class="chip-line">
  51. <span class="chip" :class="tone(card.event === 'assigned' ? task?.status ?? 'queued' : card.event)">
  52. <span class="codicon" :class="card.event === 'assigned' ? 'codicon-arrow-right' : card.event === 'completed' ? 'codicon-check' : 'codicon-close'" />
  53. 子任务 #{{ card.no }}<template v-if="card.event !== 'assigned'"> {{ TASK_STATUS[card.event] }}</template>
  54. </span>
  55. <span v-if="card.event === 'assigned' && task" class="state">{{ botName(task.botId) }} · {{ TASK_STATUS[task.status] }}</span>
  56. <span v-if="card.stub" class="chip warn">替身</span>
  57. </div>
  58. <!-- 超出限额 -->
  59. <div v-else-if="card.kind === 'limit'" class="action-card">
  60. <slot />
  61. <div v-if="limitPending && !readonly" class="actions">
  62. <button type="button" class="btn primary" :disabled="busy" @click="continueJob">继续(放宽一倍)</button>
  63. <button type="button" class="btn" :disabled="busy" @click="cancelJob">取消任务</button>
  64. </div>
  65. <div v-else-if="job" class="state">{{ JOB_STATUS[job.status] }}</div>
  66. </div>
  67. <!-- 替身组员:管理员填写结果 -->
  68. <div v-else-if="card.kind === 'stub'" class="action-card">
  69. <slot />
  70. <template v-if="stubPending && !readonly">
  71. <div v-if="task" class="instructions">{{ task.instructions }}</div>
  72. <textarea v-model="stubText" class="stub-input" rows="3" placeholder="填写这个子任务的结果,组长会收到它" />
  73. <div class="actions">
  74. <button type="button" class="btn primary" :disabled="busy || !stubText.trim()" @click="stubDone('completed')">完成</button>
  75. <button type="button" class="btn" :disabled="busy || !stubText.trim()" @click="stubDone('failed')">标记失败</button>
  76. </div>
  77. </template>
  78. <div v-else-if="task" class="state">{{ TASK_STATUS[task.status] }}</div>
  79. </div>
  80. <!-- 逐步执行 -->
  81. <div v-else-if="card.kind === 'step'" class="action-card step">
  82. <slot />
  83. <button v-if="stepPending && !readonly" type="button" class="btn primary" :disabled="busy" @click="continueStep">
  84. <span class="codicon codicon-debug-continue" />继续
  85. </button>
  86. <span v-else class="state">已执行</span>
  87. </div>
  88. <div v-if="error" class="err">{{ error }}</div>
  89. </template>
  90. <style scoped>
  91. .chip-line { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 2px 0 4px; font-size: 12px; }
  92. .chip {
  93. display: inline-flex;
  94. align-items: center;
  95. gap: 4px;
  96. padding: 0 8px;
  97. border-radius: 999px;
  98. line-height: 20px;
  99. background: color-mix(in srgb, var(--app-primary-foreground) 10%, transparent);
  100. font-weight: 600;
  101. }
  102. .chip.ok { background: color-mix(in srgb, var(--app-success, #3fb950) 20%, transparent); }
  103. .chip.bad { background: color-mix(in srgb, var(--app-error-foreground) 18%, transparent); }
  104. .chip.warn { background: color-mix(in srgb, var(--app-warning) 22%, transparent); }
  105. .chip.busy { background: color-mix(in srgb, var(--app-link-foreground) 18%, transparent); }
  106. .chip.muted { opacity: 0.7; }
  107. .state { color: var(--app-secondary-foreground); font-size: 12px; }
  108. .action-card {
  109. display: flex;
  110. flex-direction: column;
  111. align-items: flex-start;
  112. gap: 6px;
  113. max-width: 560px;
  114. margin: 0 auto;
  115. padding: 8px 12px;
  116. border: 1px solid var(--app-input-border);
  117. border-radius: var(--corner-radius-medium, 6px);
  118. background: var(--app-input-secondary-background);
  119. text-align: left;
  120. font-size: 12.5px;
  121. color: var(--app-primary-foreground);
  122. }
  123. .action-card.step { flex-direction: row; align-items: center; justify-content: space-between; }
  124. .instructions { width: 100%; max-height: 120px; overflow-y: auto; white-space: pre-wrap; color: var(--app-secondary-foreground); }
  125. .stub-input {
  126. width: 100%;
  127. box-sizing: border-box;
  128. padding: 4px 6px;
  129. border: 1px solid var(--app-input-border);
  130. border-radius: 4px;
  131. background: var(--app-input-background);
  132. color: inherit;
  133. font: inherit;
  134. resize: vertical;
  135. }
  136. .actions { display: flex; gap: 6px; }
  137. .btn {
  138. display: inline-flex;
  139. align-items: center;
  140. gap: 4px;
  141. padding: 2px 10px;
  142. border: 1px solid var(--app-input-border);
  143. border-radius: 4px;
  144. background: transparent;
  145. color: inherit;
  146. cursor: pointer;
  147. font-size: 12px;
  148. white-space: nowrap;
  149. }
  150. .btn.primary { border-color: transparent; background: var(--app-button-background, #0e639c); color: var(--app-button-foreground, #fff); }
  151. .btn:disabled { opacity: 0.5; cursor: default; }
  152. .err { margin-top: 4px; font-size: 12px; color: var(--app-error-foreground); text-align: center; }
  153. </style>