| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166 |
- <script setup lang="ts">
- // 工作组消息上的卡片(docs/design/13-bot-teams.md §5、§12.2):任务、子任务的状态标签,限额、替身、逐步执行的操作。
- // 状态取 jobs / tasks / steps 的最新值,历史消息上的卡片也会随之更新
- import { computed, ref } from 'vue'
- import type { JobStatus, TaskStatus, TeamCard } from '@agent-server/shared'
- import type { TeamSession } from '../team-store'
- const props = defineProps<{ card: TeamCard; session: TeamSession; readonly?: boolean }>()
- const state = computed(() => props.session.state)
- const job = computed(() => ('jobId' in props.card ? state.value.jobs[props.card.jobId] : undefined))
- const task = computed(() => ('taskId' in props.card ? state.value.tasks[props.card.taskId] : undefined))
- const botName = (id: string) => state.value.team?.bots.find(b => b.id === id)?.name ?? '组员'
- const JOB_STATUS: Record<JobStatus, string> = { running: '进行中', blocked: '已暂停(超出限额)', completed: '已完成', failed: '失败', cancelled: '已取消' }
- const TASK_STATUS: Record<TaskStatus, string> = { waiting: '等待依赖', queued: '排队中', running: '执行中', stub: '等待替身结果', completed: '已完成', failed: '失败', cancelled: '已取消' }
- const tone = (s: string) => (s === 'completed' ? 'ok' : s === 'failed' ? 'bad' : s === 'cancelled' ? 'muted' : s === 'blocked' || s === 'stub' ? 'warn' : 'busy')
- const busy = ref(false)
- const error = ref('')
- async function act(fn: () => Promise<unknown>) {
- busy.value = true
- error.value = ''
- try {
- await fn()
- } catch (err) {
- error.value = (err as Error).message
- } finally {
- busy.value = false
- }
- }
- const jobId = () => ('jobId' in props.card ? props.card.jobId : '')
- const taskId = () => ('taskId' in props.card ? props.card.taskId : '')
- const continueJob = () => act(() => props.session.continueJob(jobId()))
- const cancelJob = () => act(() => props.session.cancelJob(jobId()))
- const stubDone = (outcome: 'completed' | 'failed') => act(() => props.session.stubResult(taskId(), { outcome, result: stubText.value.trim() }))
- const continueStep = () => act(() => props.session.continueStep(props.card.kind === 'step' ? props.card.stepId : ''))
- // 替身结果
- const stubText = ref('')
- const stubPending = computed(() => props.card.kind === 'stub' && task.value?.status === 'stub')
- const stepPending = computed(() => props.card.kind === 'step' && state.value.steps.some(s => s.id === (props.card as { stepId: string }).stepId))
- const limitPending = computed(() => props.card.kind === 'limit' && job.value?.status === 'blocked')
- </script>
- <template>
- <!-- 任务:开始 / 结束 -->
- <div v-if="card.kind === 'job'" class="chip-line">
- <span class="chip" :class="tone(job?.status ?? card.event)">
- <span class="codicon" :class="card.event === 'started' ? 'codicon-tasklist' : card.event === 'completed' ? 'codicon-pass' : 'codicon-circle-slash'" />
- {{ card.event === 'started' ? '开始任务' : '任务结束' }}{{ job ? ` #${job.no}` : '' }}
- </span>
- <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>
- </div>
- <!-- 子任务:分派 / 结果 -->
- <div v-else-if="card.kind === 'task'" class="chip-line">
- <span class="chip" :class="tone(card.event === 'assigned' ? task?.status ?? 'queued' : card.event)">
- <span class="codicon" :class="card.event === 'assigned' ? 'codicon-arrow-right' : card.event === 'completed' ? 'codicon-check' : 'codicon-close'" />
- 子任务 #{{ card.no }}<template v-if="card.event !== 'assigned'"> {{ TASK_STATUS[card.event] }}</template>
- </span>
- <span v-if="card.event === 'assigned' && task" class="state">{{ botName(task.botId) }} · {{ TASK_STATUS[task.status] }}</span>
- <span v-if="card.stub" class="chip warn">替身</span>
- </div>
- <!-- 超出限额 -->
- <div v-else-if="card.kind === 'limit'" class="action-card">
- <slot />
- <div v-if="limitPending && !readonly" class="actions">
- <button type="button" class="btn primary" :disabled="busy" @click="continueJob">继续(放宽一倍)</button>
- <button type="button" class="btn" :disabled="busy" @click="cancelJob">取消任务</button>
- </div>
- <div v-else-if="job" class="state">{{ JOB_STATUS[job.status] }}</div>
- </div>
- <!-- 替身组员:管理员填写结果 -->
- <div v-else-if="card.kind === 'stub'" class="action-card">
- <slot />
- <template v-if="stubPending && !readonly">
- <div v-if="task" class="instructions">{{ task.instructions }}</div>
- <textarea v-model="stubText" class="stub-input" rows="3" placeholder="填写这个子任务的结果,组长会收到它" />
- <div class="actions">
- <button type="button" class="btn primary" :disabled="busy || !stubText.trim()" @click="stubDone('completed')">完成</button>
- <button type="button" class="btn" :disabled="busy || !stubText.trim()" @click="stubDone('failed')">标记失败</button>
- </div>
- </template>
- <div v-else-if="task" class="state">{{ TASK_STATUS[task.status] }}</div>
- </div>
- <!-- 逐步执行 -->
- <div v-else-if="card.kind === 'step'" class="action-card step">
- <slot />
- <button v-if="stepPending && !readonly" type="button" class="btn primary" :disabled="busy" @click="continueStep">
- <span class="codicon codicon-debug-continue" />继续
- </button>
- <span v-else class="state">已执行</span>
- </div>
- <div v-if="error" class="err">{{ error }}</div>
- </template>
- <style scoped>
- .chip-line { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 2px 0 4px; font-size: 12px; }
- .chip {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 0 8px;
- border-radius: 999px;
- line-height: 20px;
- background: color-mix(in srgb, var(--app-primary-foreground) 10%, transparent);
- font-weight: 600;
- }
- .chip.ok { background: color-mix(in srgb, var(--app-success, #3fb950) 20%, transparent); }
- .chip.bad { background: color-mix(in srgb, var(--app-error-foreground) 18%, transparent); }
- .chip.warn { background: color-mix(in srgb, var(--app-warning) 22%, transparent); }
- .chip.busy { background: color-mix(in srgb, var(--app-link-foreground) 18%, transparent); }
- .chip.muted { opacity: 0.7; }
- .state { color: var(--app-secondary-foreground); font-size: 12px; }
- .action-card {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 6px;
- max-width: 560px;
- margin: 0 auto;
- padding: 8px 12px;
- border: 1px solid var(--app-input-border);
- border-radius: var(--corner-radius-medium, 6px);
- background: var(--app-input-secondary-background);
- text-align: left;
- font-size: 12.5px;
- color: var(--app-primary-foreground);
- }
- .action-card.step { flex-direction: row; align-items: center; justify-content: space-between; }
- .instructions { width: 100%; max-height: 120px; overflow-y: auto; white-space: pre-wrap; color: var(--app-secondary-foreground); }
- .stub-input {
- width: 100%;
- box-sizing: border-box;
- padding: 4px 6px;
- border: 1px solid var(--app-input-border);
- border-radius: 4px;
- background: var(--app-input-background);
- color: inherit;
- font: inherit;
- resize: vertical;
- }
- .actions { display: flex; gap: 6px; }
- .btn {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 2px 10px;
- border: 1px solid var(--app-input-border);
- border-radius: 4px;
- background: transparent;
- color: inherit;
- cursor: pointer;
- font-size: 12px;
- white-space: nowrap;
- }
- .btn.primary { border-color: transparent; background: var(--app-button-background, #0e639c); color: var(--app-button-foreground, #fff); }
- .btn:disabled { opacity: 0.5; cursor: default; }
- .err { margin-top: 4px; font-size: 12px; color: var(--app-error-foreground); text-align: center; }
- </style>
|