TaskPlan.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. <script setup lang="ts">
  2. import { computed, nextTick, ref, useId, watch } from 'vue'
  3. import type { AgentAssignment, AgentGoal } from '@shared/agentTasks'
  4. import type { WorkspaceAgent } from '@shared/workspace'
  5. import AppIcon from '@/components/common/AppIcon.vue'
  6. const props = defineProps<{
  7. goal?: AgentGoal
  8. assignments: AgentAssignment[]
  9. agents: WorkspaceAgent[]
  10. saveCriteria?: (goalId: string, expectedRevision: number, acceptanceCriteria: string[]) => Promise<void>
  11. }>()
  12. defineEmits<{ stop: []; resume: [] }>()
  13. const labels: Record<string, string> = { pending: '待执行', running: '进行中', review: '待复核', complete: '已完成', failed: '执行失败', blocked: '遇到阻碍', cancelling: '正在停止', cancelled: '已停止', waiting: '需要补充信息' }
  14. const completed = computed(() => props.assignments.filter(a => a.status === 'complete').length)
  15. const progress = computed(() => props.assignments.length ? Math.round(completed.value / props.assignments.length * 100) : props.goal?.status === 'complete' ? 100 : 0)
  16. const editable = computed(() => !!props.saveCriteria && !!props.goal && ['running', 'review', 'waiting'].includes(props.goal.status))
  17. const expanded = ref(false)
  18. const contentId = useId()
  19. const editing = ref(false), saving = ref(false), draft = ref(''), error = ref('')
  20. const editor = ref<HTMLTextAreaElement>(), editButton = ref<HTMLButtonElement>()
  21. let revision = 1
  22. watch(() => props.goal?.id ?? props.assignments[0]?.goalId ?? '', () => {
  23. expanded.value = false
  24. editing.value = false
  25. error.value = ''
  26. })
  27. async function edit() {
  28. if (!props.goal) return
  29. draft.value = props.goal.acceptanceCriteria.join('\n')
  30. revision = props.goal.acceptanceRevision ?? 1
  31. error.value = ''
  32. editing.value = true
  33. await nextTick()
  34. editor.value?.focus()
  35. }
  36. async function closeEditor() {
  37. editing.value = false
  38. await nextTick()
  39. editButton.value?.focus()
  40. }
  41. async function save() {
  42. if (!props.goal || !props.saveCriteria || saving.value) return
  43. const criteria = draft.value.split('\n').map(line => line.trim()).filter(Boolean)
  44. if (!criteria.length || criteria.length > 12 || criteria.some(line => line.length > 1000)) {
  45. error.value = '请填写 1 至 12 条验收要求,每条不超过 1000 字。'
  46. return
  47. }
  48. saving.value = true
  49. error.value = ''
  50. try {
  51. await props.saveCriteria(props.goal.id, revision, criteria)
  52. await closeEditor()
  53. } catch (cause) { error.value = cause instanceof Error ? cause.message : '保存失败,请重试' }
  54. finally { saving.value = false }
  55. }
  56. </script>
  57. <template>
  58. <section v-if="goal || assignments.length" class="task-plan" :class="{ 'task-plan--expanded': expanded }" aria-label="目标进度">
  59. <header class="task-plan__header">
  60. <button class="task-plan__disclosure" type="button" :aria-expanded="expanded" :aria-controls="contentId" @click="expanded = !expanded">
  61. <span class="task-plan__icon"><AppIcon name="files" :size="17" /></span>
  62. <span class="task-plan__heading">
  63. <strong>交付进展</strong>
  64. <span v-if="goal" class="task-plan__status"><i />{{ labels[goal.status] }}</span>
  65. </span>
  66. <strong v-if="assignments.length" class="task-plan__count">{{ completed }}/{{ assignments.length }} <small>项完成</small></strong>
  67. <AppIcon class="task-plan__header-chevron" name="chevron-down" :size="15" />
  68. </button>
  69. <div class="task-plan__actions">
  70. <button v-if="goal && ['running','review','waiting'].includes(goal.status)" type="button" @click="$emit('stop')">停止任务</button>
  71. <button v-if="goal && ['blocked','waiting','cancelled'].includes(goal.status)" type="button" @click="$emit('resume')">继续任务</button>
  72. </div>
  73. </header>
  74. <div v-if="expanded" :id="contentId" class="task-plan__content">
  75. <div v-if="assignments.length" class="task-plan__progress" role="progressbar" aria-label="交付完成进度" aria-valuemin="0" aria-valuemax="100" :aria-valuenow="progress">
  76. <span v-for="item in assignments" :key="item.id" :class="{ complete: item.status === 'complete', active: ['running','review'].includes(item.status), failed: ['failed','blocked'].includes(item.status) }" />
  77. </div>
  78. <div v-if="assignments.length" class="task-plan__steps" aria-label="任务步骤">
  79. <div v-for="(item, index) in assignments" :key="item.id" :class="{ complete: item.status === 'complete', active: ['running','review'].includes(item.status), failed: ['failed','blocked'].includes(item.status) }">
  80. <span><AppIcon v-if="item.status === 'complete'" name="check" :size="11" /><template v-else>{{ index + 1 }}</template></span>
  81. <strong>{{ item.title }}</strong>
  82. </div>
  83. </div>
  84. <div v-if="goal?.result" class="task-plan__result">
  85. <small>最新结果</small>
  86. <p class="result">{{ goal.result }}</p>
  87. </div>
  88. <details>
  89. <summary><AppIcon name="file" :size="15" /><span>任务与验收详情</span><AppIcon class="task-plan__chevron" name="chevron-down" :size="14" /></summary>
  90. <div class="plan-details">
  91. <p v-if="goal" class="objective">{{ goal.objective }}</p>
  92. <ol v-if="assignments.length">
  93. <li v-for="item in assignments" :key="item.id">
  94. <div><strong>{{ item.title }}</strong><span>{{ labels[item.status] }}</span></div>
  95. <small>{{ agents.find(a => a.id === item.agentId)?.name || '成员不可用' }}<template v-if="agents.find(a => a.id === item.agentId)?.temporaryGoalId"> · 临时助手{{ agents.find(a => a.id === item.agentId)?.archived ? (agents.find(a => a.id === item.agentId)?.cleanupState === 'pending' ? '(已退役,等待清理)' : '(已退役)') : '' }}</template></small>
  96. <p v-if="item.review">{{ item.review }}</p>
  97. </li>
  98. </ol>
  99. <form v-if="editing" @submit.prevent="save">
  100. <label>验收要求<textarea ref="editor" v-model="draft" :disabled="saving" rows="4" aria-describedby="criteria-help" /></label>
  101. <small id="criteria-help">每行一条,描述你需要的交付结果。</small>
  102. <p v-if="error" class="criteria-error" role="alert">{{ error }}</p>
  103. <div class="criteria-actions"><button type="submit" :disabled="saving || !editable">{{ saving ? '保存中…' : '保存验收要求' }}</button><button type="button" :disabled="saving" @click="closeEditor">取消</button></div>
  104. </form>
  105. <template v-else>
  106. <ul v-if="goal?.acceptanceCriteria.length" class="criteria">
  107. <li v-for="(criterion, index) in goal.acceptanceCriteria" :key="index">
  108. <span>{{ goal.checks?.some(check => check.criterion === index && check.passed) ? '已验收' : '待验收' }}</span> {{ criterion }}
  109. </li>
  110. </ul>
  111. <button v-if="editable" ref="editButton" type="button" @click="edit">调整验收要求</button>
  112. </template>
  113. </div>
  114. </details>
  115. </div>
  116. </section>
  117. </template>
  118. <style scoped>
  119. .task-plan {
  120. width: 100%;
  121. max-width: 760px;
  122. margin: 0 auto 10px;
  123. overflow: hidden;
  124. border: 1px solid var(--line);
  125. border-radius: 15px;
  126. background: var(--surface-raised);
  127. box-shadow: 0 6px 22px color-mix(in srgb, var(--text-primary) 5%, transparent);
  128. box-sizing: border-box;
  129. font-size: 13px;
  130. }
  131. .task-plan__header { display: flex; min-height: 58px; align-items: center; gap: 6px; padding: 6px 8px; }
  132. .task-plan__disclosure { display: flex; min-width: 0; min-height: 46px; flex: 1; align-items: center; gap: 11px; padding: 4px 6px; border: 0; border-radius: 10px; background: transparent; text-align: left; touch-action: manipulation; }
  133. .task-plan__disclosure:hover { background: var(--surface-hover); }
  134. .task-plan__icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 10px; background: var(--surface-soft); color: var(--text-secondary); }
  135. .task-plan__heading { display: flex; min-width: 0; align-items: center; gap: 8px; }
  136. .task-plan__heading > strong { color: var(--text-primary); font-size: 14px; font-weight: 650; white-space: nowrap; }
  137. .task-plan__status { display: inline-flex; align-items: center; gap: 5px; color: var(--text-secondary); font-size: 11px; white-space: nowrap; }
  138. .task-plan__status i { width: 6px; height: 6px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 12%, transparent); }
  139. .task-plan__count { margin-left: auto; color: var(--text-primary); font-size: 13px; font-variant-numeric: tabular-nums; white-space: nowrap; }
  140. .task-plan__count small { margin-left: 2px; color: var(--text-muted); font-size: 11px; font-weight: 450; }
  141. .task-plan__header-chevron { flex: 0 0 auto; color: var(--text-muted); transition: transform 160ms ease; }
  142. .task-plan--expanded .task-plan__header-chevron { transform: rotate(180deg); }
  143. .task-plan__actions { display: flex; gap: 6px; }
  144. .task-plan__actions:empty { display: none; }
  145. .task-plan__actions button { min-height: 40px; padding-inline: 10px; border-color: transparent; color: var(--text-secondary); }
  146. .task-plan__progress { display: grid; gap: 5px; grid-template-columns: repeat(auto-fit, minmax(28px, 1fr)); padding: 0 14px; }
  147. .task-plan__progress span { height: 3px; border-radius: 999px; background: var(--line-strong); }
  148. .task-plan__progress span.complete { background: var(--workflow-accent); }
  149. .task-plan__progress span.active { background: color-mix(in srgb, var(--workflow-accent) 52%, var(--line)); }
  150. .task-plan__progress span.failed { background: var(--danger); }
  151. .task-plan__steps { display: grid; gap: 8px; grid-template-columns: repeat(auto-fit, minmax(92px, 1fr)); padding: 10px 14px 13px; }
  152. .task-plan__steps > div { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--text-muted); }
  153. .task-plan__steps > div > span { display: grid; width: 20px; height: 20px; flex: 0 0 20px; place-items: center; border: 1px solid var(--line-strong); border-radius: 50%; background: var(--surface); color: var(--text-muted); font-size: 10px; font-variant-numeric: tabular-nums; }
  154. .task-plan__steps > div.complete > span { border-color: var(--workflow-accent); background: var(--workflow-accent); color: var(--workflow-on-accent); }
  155. .task-plan__steps > div.active > span { border-color: var(--workflow-accent); color: var(--workflow-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--workflow-accent) 14%, transparent); }
  156. .task-plan__steps > div.failed > span { border-color: var(--danger); color: var(--danger); }
  157. .task-plan__steps strong { min-width: 0; overflow: hidden; font-size: 11px; font-weight: 520; text-overflow: ellipsis; white-space: nowrap; }
  158. .task-plan__steps .complete strong,
  159. .task-plan__steps .active strong { color: var(--text-secondary); }
  160. .task-plan__result { margin: 0 14px 12px; padding: 11px 12px; border-radius: 10px; background: var(--surface-soft); }
  161. .task-plan__result > small { display: block; margin-bottom: 3px; color: var(--text-muted); font-size: 10px; font-weight: 620; }
  162. .result { max-height: 12vh; margin: 0; overflow: auto; color: var(--text-secondary); font-size: 12px; line-height: 1.6; }
  163. details { border-top: 1px solid var(--line); }
  164. summary { display: flex; min-height: 46px; align-items: center; gap: 8px; padding: 0 14px; color: var(--text-secondary); cursor: pointer; list-style: none; }
  165. summary::-webkit-details-marker { display: none; }
  166. summary > span { flex: 1; font-size: 12px; font-weight: 520; }
  167. .task-plan__chevron { transition: transform 160ms ease; }
  168. details[open] .task-plan__chevron { transform: rotate(180deg); }
  169. .plan-details { max-height: 34vh; overflow: auto; padding: 2px 14px 14px; }
  170. .objective { color: var(--text-secondary); }
  171. ol,.criteria { padding-left: 20px; margin-top: 0; }
  172. li { padding: 7px 0; }
  173. li div { display: flex; gap: 12px; justify-content: space-between; }
  174. li div span { color: var(--text-secondary); white-space: nowrap; }
  175. p { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6; }
  176. .criteria span { color: var(--text-secondary); font-size: 12px; }
  177. button { min-height: 44px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); color: var(--text-primary); cursor: pointer; font: inherit; }
  178. button:hover:not(:disabled) { background: var(--surface-hover); }
  179. button:disabled { cursor: default; opacity: .55; }
  180. label { display: grid; gap: 8px; }
  181. textarea { width: 100%; padding: 10px; border: 1px solid var(--line); border-radius: 9px; outline: 0; background: var(--surface); box-sizing: border-box; color: var(--text-primary); font: inherit; line-height: 1.5; resize: vertical; }
  182. textarea:focus { border-color: var(--workflow-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--workflow-accent) 14%, transparent); }
  183. .criteria-actions { display: flex; gap: 8px; margin-top: 10px; }
  184. .criteria-error { color: var(--danger); }
  185. summary:focus-visible,button:focus-visible { outline: 2px solid var(--workflow-accent); outline-offset: 2px; }
  186. @media (max-width: 600px) {
  187. .task-plan { border-radius: 14px; }
  188. .task-plan__header { min-height: 56px; padding-inline: 6px; }
  189. .task-plan__disclosure { gap: 8px; padding-inline: 6px; }
  190. .task-plan__icon { display: none; }
  191. .task-plan__heading { align-items: flex-start; flex-direction: column; gap: 1px; }
  192. .task-plan__actions button { min-width: 44px; padding-inline: 8px; font-size: 12px; }
  193. .task-plan__progress { padding-inline: 12px; }
  194. .task-plan__steps { grid-auto-columns: minmax(78px, 1fr); grid-auto-flow: column; grid-template-columns: none; gap: 8px; padding-inline: 12px; overflow-x: auto; scrollbar-width: none; }
  195. .task-plan__steps::-webkit-scrollbar { display: none; }
  196. .task-plan__result { margin-inline: 12px; }
  197. summary { padding-inline: 12px; }
  198. .plan-details { padding-inline: 12px; }
  199. }
  200. @media (prefers-reduced-motion: reduce) { .task-plan__chevron,.task-plan__header-chevron { transition: none; } }
  201. </style>