Przeglądaj źródła

默认折叠交付进展卡片

Samien 1 miesiąc temu
rodzic
commit
fe09a09a1b

+ 66 - 49
src/client/components/workspace/TaskPlan.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { computed, nextTick, ref } from 'vue'
+import { computed, nextTick, ref, useId, watch } from 'vue'
 import type { AgentAssignment, AgentGoal } from '@shared/agentTasks'
 import type { WorkspaceAgent } from '@shared/workspace'
 import AppIcon from '@/components/common/AppIcon.vue'
@@ -14,9 +14,16 @@ const labels: Record<string, string> = { pending: '待执行', running: '进行
 const completed = computed(() => props.assignments.filter(a => a.status === 'complete').length)
 const progress = computed(() => props.assignments.length ? Math.round(completed.value / props.assignments.length * 100) : props.goal?.status === 'complete' ? 100 : 0)
 const editable = computed(() => !!props.saveCriteria && !!props.goal && ['running', 'review', 'waiting'].includes(props.goal.status))
+const expanded = ref(false)
+const contentId = useId()
 const editing = ref(false), saving = ref(false), draft = ref(''), error = ref('')
 const editor = ref<HTMLTextAreaElement>(), editButton = ref<HTMLButtonElement>()
 let revision = 1
+watch(() => props.goal?.id ?? props.assignments[0]?.goalId ?? '', () => {
+  expanded.value = false
+  editing.value = false
+  error.value = ''
+})
 async function edit() {
   if (!props.goal) return
   draft.value = props.goal.acceptanceCriteria.join('\n')
@@ -48,61 +55,66 @@ async function save() {
 }
 </script>
 <template>
-  <section v-if="goal || assignments.length" class="task-plan" aria-label="目标进度">
+  <section v-if="goal || assignments.length" class="task-plan" :class="{ 'task-plan--expanded': expanded }" aria-label="目标进度">
     <header class="task-plan__header">
-      <span class="task-plan__icon"><AppIcon name="files" :size="17" /></span>
-      <div class="task-plan__heading">
-        <strong>交付进展</strong>
-        <span v-if="goal" class="task-plan__status"><i />{{ labels[goal.status] }}</span>
-      </div>
-      <strong v-if="assignments.length" class="task-plan__count">{{ completed }}/{{ assignments.length }} <small>项完成</small></strong>
+      <button class="task-plan__disclosure" type="button" :aria-expanded="expanded" :aria-controls="contentId" @click="expanded = !expanded">
+        <span class="task-plan__icon"><AppIcon name="files" :size="17" /></span>
+        <span class="task-plan__heading">
+          <strong>交付进展</strong>
+          <span v-if="goal" class="task-plan__status"><i />{{ labels[goal.status] }}</span>
+        </span>
+        <strong v-if="assignments.length" class="task-plan__count">{{ completed }}/{{ assignments.length }} <small>项完成</small></strong>
+        <AppIcon class="task-plan__header-chevron" name="chevron-down" :size="15" />
+      </button>
       <div class="task-plan__actions">
         <button v-if="goal && ['running','review','waiting'].includes(goal.status)" type="button" @click="$emit('stop')">停止任务</button>
         <button v-if="goal && ['blocked','waiting','cancelled'].includes(goal.status)" type="button" @click="$emit('resume')">继续任务</button>
       </div>
     </header>
 
-    <div v-if="assignments.length" class="task-plan__progress" role="progressbar" aria-label="交付完成进度" aria-valuemin="0" aria-valuemax="100" :aria-valuenow="progress">
-      <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) }" />
-    </div>
-    <div v-if="assignments.length" class="task-plan__steps" aria-label="任务步骤">
-      <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) }">
-        <span><AppIcon v-if="item.status === 'complete'" name="check" :size="11" /><template v-else>{{ index + 1 }}</template></span>
-        <strong>{{ item.title }}</strong>
+    <div v-if="expanded" :id="contentId" class="task-plan__content">
+      <div v-if="assignments.length" class="task-plan__progress" role="progressbar" aria-label="交付完成进度" aria-valuemin="0" aria-valuemax="100" :aria-valuenow="progress">
+        <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) }" />
+      </div>
+      <div v-if="assignments.length" class="task-plan__steps" aria-label="任务步骤">
+        <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) }">
+          <span><AppIcon v-if="item.status === 'complete'" name="check" :size="11" /><template v-else>{{ index + 1 }}</template></span>
+          <strong>{{ item.title }}</strong>
+        </div>
       </div>
-    </div>
 
-    <div v-if="goal?.result" class="task-plan__result">
-      <small>最新结果</small>
-      <p class="result">{{ goal.result }}</p>
-    </div>
-    <details>
-      <summary><AppIcon name="file" :size="15" /><span>任务与验收详情</span><AppIcon class="task-plan__chevron" name="chevron-down" :size="14" /></summary>
-      <div class="plan-details">
-        <p v-if="goal" class="objective">{{ goal.objective }}</p>
-        <ol v-if="assignments.length">
-          <li v-for="item in assignments" :key="item.id">
-            <div><strong>{{ item.title }}</strong><span>{{ labels[item.status] }}</span></div>
-            <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>
-            <p v-if="item.review">{{ item.review }}</p>
-          </li>
-        </ol>
-        <form v-if="editing" @submit.prevent="save">
-          <label>验收要求<textarea ref="editor" v-model="draft" :disabled="saving" rows="4" aria-describedby="criteria-help" /></label>
-          <small id="criteria-help">每行一条,描述你需要的交付结果。</small>
-          <p v-if="error" class="criteria-error" role="alert">{{ error }}</p>
-          <div class="criteria-actions"><button type="submit" :disabled="saving || !editable">{{ saving ? '保存中…' : '保存验收要求' }}</button><button type="button" :disabled="saving" @click="closeEditor">取消</button></div>
-        </form>
-        <template v-else>
-          <ul v-if="goal?.acceptanceCriteria.length" class="criteria">
-            <li v-for="(criterion, index) in goal.acceptanceCriteria" :key="index">
-              <span>{{ goal.checks?.some(check => check.criterion === index && check.passed) ? '已验收' : '待验收' }}</span> {{ criterion }}
-            </li>
-          </ul>
-          <button v-if="editable" ref="editButton" type="button" @click="edit">调整验收要求</button>
-        </template>
+      <div v-if="goal?.result" class="task-plan__result">
+        <small>最新结果</small>
+        <p class="result">{{ goal.result }}</p>
       </div>
-    </details>
+      <details>
+        <summary><AppIcon name="file" :size="15" /><span>任务与验收详情</span><AppIcon class="task-plan__chevron" name="chevron-down" :size="14" /></summary>
+        <div class="plan-details">
+          <p v-if="goal" class="objective">{{ goal.objective }}</p>
+          <ol v-if="assignments.length">
+            <li v-for="item in assignments" :key="item.id">
+              <div><strong>{{ item.title }}</strong><span>{{ labels[item.status] }}</span></div>
+              <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>
+              <p v-if="item.review">{{ item.review }}</p>
+            </li>
+          </ol>
+          <form v-if="editing" @submit.prevent="save">
+            <label>验收要求<textarea ref="editor" v-model="draft" :disabled="saving" rows="4" aria-describedby="criteria-help" /></label>
+            <small id="criteria-help">每行一条,描述你需要的交付结果。</small>
+            <p v-if="error" class="criteria-error" role="alert">{{ error }}</p>
+            <div class="criteria-actions"><button type="submit" :disabled="saving || !editable">{{ saving ? '保存中…' : '保存验收要求' }}</button><button type="button" :disabled="saving" @click="closeEditor">取消</button></div>
+          </form>
+          <template v-else>
+            <ul v-if="goal?.acceptanceCriteria.length" class="criteria">
+              <li v-for="(criterion, index) in goal.acceptanceCriteria" :key="index">
+                <span>{{ goal.checks?.some(check => check.criterion === index && check.passed) ? '已验收' : '待验收' }}</span> {{ criterion }}
+              </li>
+            </ul>
+            <button v-if="editable" ref="editButton" type="button" @click="edit">调整验收要求</button>
+          </template>
+        </div>
+      </details>
+    </div>
   </section>
 </template>
 <style scoped>
@@ -118,7 +130,9 @@ async function save() {
   box-sizing: border-box;
   font-size: 13px;
 }
-.task-plan__header { display: flex; min-height: 58px; align-items: center; gap: 11px; padding: 8px 14px; }
+.task-plan__header { display: flex; min-height: 58px; align-items: center; gap: 6px; padding: 6px 8px; }
+.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; }
+.task-plan__disclosure:hover { background: var(--surface-hover); }
 .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); }
 .task-plan__heading { display: flex; min-width: 0; align-items: center; gap: 8px; }
 .task-plan__heading > strong { color: var(--text-primary); font-size: 14px; font-weight: 650; white-space: nowrap; }
@@ -126,6 +140,8 @@ async function save() {
 .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); }
 .task-plan__count { margin-left: auto; color: var(--text-primary); font-size: 13px; font-variant-numeric: tabular-nums; white-space: nowrap; }
 .task-plan__count small { margin-left: 2px; color: var(--text-muted); font-size: 11px; font-weight: 450; }
+.task-plan__header-chevron { flex: 0 0 auto; color: var(--text-muted); transition: transform 160ms ease; }
+.task-plan--expanded .task-plan__header-chevron { transform: rotate(180deg); }
 .task-plan__actions { display: flex; gap: 6px; }
 .task-plan__actions:empty { display: none; }
 .task-plan__actions button { min-height: 40px; padding-inline: 10px; border-color: transparent; color: var(--text-secondary); }
@@ -172,7 +188,8 @@ summary:focus-visible,button:focus-visible { outline: 2px solid var(--workflow-a
 
 @media (max-width: 600px) {
   .task-plan { border-radius: 14px; }
-  .task-plan__header { min-height: 56px; padding-inline: 12px; }
+  .task-plan__header { min-height: 56px; padding-inline: 6px; }
+  .task-plan__disclosure { gap: 8px; padding-inline: 6px; }
   .task-plan__icon { display: none; }
   .task-plan__heading { align-items: flex-start; flex-direction: column; gap: 1px; }
   .task-plan__actions button { min-width: 44px; padding-inline: 8px; font-size: 12px; }
@@ -184,5 +201,5 @@ summary:focus-visible,button:focus-visible { outline: 2px solid var(--workflow-a
   .plan-details { padding-inline: 12px; }
 }
 
-@media (prefers-reduced-motion: reduce) { .task-plan__chevron { transition: none; } }
+@media (prefers-reduced-motion: reduce) { .task-plan__chevron,.task-plan__header-chevron { transition: none; } }
 </style>

+ 11 - 2
tests/client/taskPlan.test.ts

@@ -6,19 +6,28 @@ import type { AgentAssignment, AgentGoal } from '@shared/agentTasks'
 const goal: AgentGoal = { id:'goal',conversationId:'group',coordinatorId:'lead',authorityRevision:1,objective:'交付报告',acceptanceCriteria:['提供来源'],acceptanceRevision:1,status:'running',origin:{conversationId:'group',runId:'run',agentId:'lead'},artifactIds:[],revision:1,automaticWakes:0,createdAt:1,updatedAt:1 }
 const assignment = (id: string, status: AgentAssignment['status']): AgentAssignment => ({ id,goalId:'goal',conversationId:'group',agentId:'writer',title:`步骤 ${id}`,brief:'完成步骤',acceptanceCriteria:[],dependsOn:[],status,attempt:1,artifactIds:[],createdAt:1,updatedAt:1 })
 
-it('keeps progress and results visible while task details start collapsed', () => {
+it('starts as a compact summary and reveals progress and results on request', async () => {
   const wrapper = mount(TaskPlan,{props:{goal:{...goal,result:'初稿已交付'},assignments:[assignment('一','complete'),assignment('二','running')],agents:[]}})
-  expect(wrapper.get('details').attributes('open')).toBeUndefined()
   expect(wrapper.get('header').text()).toContain('进行中')
+  expect(wrapper.get('.task-plan__disclosure').attributes('aria-expanded')).toBe('false')
+  expect(wrapper.find('[role=progressbar]').exists()).toBe(false)
+  expect(wrapper.find('.result').exists()).toBe(false)
+  await wrapper.get('.task-plan__disclosure').trigger('click')
+  expect(wrapper.get('.task-plan__disclosure').attributes('aria-expanded')).toBe('true')
+  expect(wrapper.get('details').attributes('open')).toBeUndefined()
   expect(wrapper.get('[role=progressbar]').attributes('aria-valuenow')).toBe('50')
   expect(wrapper.get('.task-plan__count').text()).toContain('1/2')
   expect(wrapper.get('.result').text()).toBe('初稿已交付')
+  await wrapper.setProps({goal:{...goal,id:'goal-next',result:'下一项任务'}})
+  expect(wrapper.get('.task-plan__disclosure').attributes('aria-expanded')).toBe('false')
+  expect(wrapper.find('[role=progressbar]').exists()).toBe(false)
   wrapper.unmount()
 })
 
 it('preserves user edits across progress refreshes and reports stale-save errors without discarding the draft', async () => {
   const saveCriteria = vi.fn().mockRejectedValueOnce(new Error('验收要求已更新,请重新查看后修改'))
   const wrapper = mount(TaskPlan,{props:{goal,assignments:[],agents:[],saveCriteria}})
+  await wrapper.get('.task-plan__disclosure').trigger('click')
   await wrapper.get('details').trigger('click')
   await wrapper.findAll('button').find(button=>button.text()==='调整验收要求')!.trigger('click')
   await wrapper.get('textarea').setValue('比较三种方案\n给出建议')