diff --git a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java index 95ca4ec3..521dd171 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java +++ b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java @@ -26,7 +26,6 @@ import java.security.Principal; import java.util.List; import java.util.Map; import java.util.function.Supplier; -import java.util.stream.Collectors; /** * Admin REST surface for agent teams: team/membership CRUD, the shared task @@ -131,8 +130,11 @@ public class TeamController { @Operation(summary = "任务板列表") @GetMapping("/{id}/tasks") public R> listTasks(@PathVariable Long id, - @RequestParam(required = false) List status) { - return R.ok(taskService.listTasks(id, status).stream().map(this::toTaskVO).toList()); + @RequestParam(required = false) List status, + @RequestParam(required = false) Integer limit, + @RequestParam(required = false) Integer offset) { + return R.ok(taskService.listTasks(id, status, limit, offset).stream() + .map(this::toTaskVO).toList()); } @Operation(summary = "任务详情(含评论)") @@ -283,8 +285,7 @@ public class TeamController { @Operation(summary = "任务状态统计(看板列头)") @GetMapping("/{id}/tasks/stats") public R> taskStats(@PathVariable Long id) { - return R.ok(taskService.listTasks(id, null).stream() - .collect(Collectors.groupingBy(TeamTaskEntity::getStatus, Collectors.counting()))); + return R.ok(taskService.countByStatus(id)); } // ==================== helpers / DTOs ==================== diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java index cb186560..d82f35ba 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamTaskService.java @@ -22,7 +22,9 @@ import java.net.URI; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; /** @@ -577,11 +579,38 @@ public class TeamTaskService { } public List listTasks(Long teamId, List statuses) { + return listTasks(teamId, statuses, null, null); + } + + /** + * Board query with optional windowing. Terminal columns grow without + * bound on long-lived teams, so the UI pages them (newest first) while + * active columns stay unwindowed. LIMIT/OFFSET is valid across all three + * supported dialects. + */ + public List listTasks(Long teamId, List statuses, + Integer limit, Integer offset) { return taskMapper.selectList(Wrappers.lambdaQuery() .eq(TeamTaskEntity::getTeamId, teamId) .in(statuses != null && !statuses.isEmpty(), TeamTaskEntity::getStatus, statuses) .orderByDesc(TeamTaskEntity::getPriority) - .orderByDesc(TeamTaskEntity::getCreateTime)); + .orderByDesc(TeamTaskEntity::getCreateTime) + .last(limit != null, + "LIMIT " + (limit == null ? 0 : Math.max(1, limit)) + + " OFFSET " + (offset == null ? 0 : Math.max(0, offset)))); + } + + /** Per-status task counts for the board header, computed in the database. */ + public Map countByStatus(Long teamId) { + Map counts = new HashMap<>(); + taskMapper.selectMaps(Wrappers.query() + .select("status", "count(*) as cnt") + .eq("team_id", teamId) + .eq("deleted", 0) + .groupBy("status")) + .forEach(row -> counts.put(String.valueOf(row.get("status")), + ((Number) row.get("cnt")).longValue())); + return counts; } // ==================== dependency release ==================== diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index fd41c690..e2fcbb3c 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -906,8 +906,15 @@ export const teamApi = { addMember: (id: string, agentId: string, role: string) => http.post(`/teams/${id}/members`, { agentId, role }), removeMember: (id: string, agentId: string) => http.delete(`/teams/${id}/members/${agentId}`), - listTasks: (id: string, status?: string[]) => - http.get(`/teams/${id}/tasks`, { params: status?.length ? { status: status.join(',') } : {} }), + listTasks: (id: string, status?: string[], opts?: { limit?: number; offset?: number }) => + http.get(`/teams/${id}/tasks`, { + params: { + ...(status?.length ? { status: status.join(',') } : {}), + ...(opts?.limit != null ? { limit: opts.limit } : {}), + ...(opts?.offset != null ? { offset: opts.offset } : {}), + }, + }), + taskStats: (id: string) => http.get(`/teams/${id}/tasks/stats`), getTask: (id: string, taskId: string) => http.get(`/teams/${id}/tasks/${taskId}`), createTask: ( id: string, diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 88d426b2..002b7e1f 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -52,6 +52,7 @@ export default { deliverables: 'Deliverables', viewRun: 'View execution', timeline: 'Timeline', + loadMore: 'Load more ({loaded}/{total})', eventType: { created: 'Created', dispatched: 'Dispatched', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 76ce7dab..642c218c 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -52,6 +52,7 @@ export default { deliverables: '交付物', viewRun: '查看执行过程', timeline: '时间线', + loadMore: '加载更多({loaded}/{total})', eventType: { created: '创建', dispatched: '派发', diff --git a/mateclaw-ui/src/stores/useTeamStore.ts b/mateclaw-ui/src/stores/useTeamStore.ts index 580c903c..aea8cc22 100644 --- a/mateclaw-ui/src/stores/useTeamStore.ts +++ b/mateclaw-ui/src/stores/useTeamStore.ts @@ -7,6 +7,11 @@ import type { TeamMemberVO, TeamTaskComment, TeamTaskVO, TeamVO } from '@/api/in * Agent-team domain state: team list, the currently opened team (members + * task board), and board polling. Ids stay strings for their entire lifecycle * (Snowflake precision convention). + * + * Board loading strategy: active statuses are always fetched in full (they + * are bounded by the team's working set), while the ever-growing terminal + * columns (completed / closed) are windowed newest-first and extended with + * load-more. Column headers show true totals from the stats endpoint. */ export const useTeamStore = defineStore('team', () => { const teams = ref([]) @@ -14,15 +19,38 @@ export const useTeamStore = defineStore('team', () => { const currentTeam = ref(null) const members = ref([]) - const tasks = ref([]) const boardLoading = ref(false) /** Statuses that mean the board is still moving and worth polling. */ const ACTIVE_STATUSES = ['pending', 'in_progress', 'in_review', 'blocked'] + const COMPLETED_STATUSES = ['completed'] + const CLOSED_STATUSES = ['failed', 'cancelled', 'stale'] + /** Terminal-column page size. */ + const TERMINAL_PAGE = 20 - const hasActiveTasks = computed(() => - tasks.value.some((t) => ACTIVE_STATUSES.includes(t.task.status)), - ) + const activeTasks = ref([]) + const completedTasks = ref([]) + const closedTasks = ref([]) + /** True per-status totals from the stats endpoint. */ + const taskStats = ref>({}) + + /** Merged view consumed by the board's status-filtered columns. */ + const tasks = computed(() => [ + ...activeTasks.value, + ...completedTasks.value, + ...closedTasks.value, + ]) + + const hasActiveTasks = computed(() => activeTasks.value.length > 0) + + const completedTotal = computed(() => sumStats(COMPLETED_STATUSES)) + const closedTotal = computed(() => sumStats(CLOSED_STATUSES)) + const completedHasMore = computed(() => completedTasks.value.length < completedTotal.value) + const closedHasMore = computed(() => closedTasks.value.length < closedTotal.value) + + function sumStats(statuses: string[]): number { + return statuses.reduce((sum, s) => sum + (Number(taskStats.value[s]) || 0), 0) + } async function fetchTeams() { loading.value = true @@ -40,20 +68,40 @@ export const useTeamStore = defineStore('team', () => { const res: any = await teamApi.get(teamId) currentTeam.value = res.data?.team || null members.value = res.data?.members || [] + completedTasks.value = [] + closedTasks.value = [] await fetchTasks(teamId) } function closeTeam() { currentTeam.value = null members.value = [] - tasks.value = [] + activeTasks.value = [] + completedTasks.value = [] + closedTasks.value = [] + taskStats.value = {} } + /** + * Refresh the board. Terminal windows keep (at least) their currently + * loaded size, so a poll/event refresh never collapses a column the user + * has extended with load-more. + */ async function fetchTasks(teamId: string) { boardLoading.value = true try { - const res: any = await teamApi.listTasks(teamId) - tasks.value = res.data || [] + const completedLimit = Math.max(TERMINAL_PAGE, completedTasks.value.length) + const closedLimit = Math.max(TERMINAL_PAGE, closedTasks.value.length) + const [active, completed, closed, stats] = (await Promise.all([ + teamApi.listTasks(teamId, ACTIVE_STATUSES), + teamApi.listTasks(teamId, COMPLETED_STATUSES, { limit: completedLimit, offset: 0 }), + teamApi.listTasks(teamId, CLOSED_STATUSES, { limit: closedLimit, offset: 0 }), + teamApi.taskStats(teamId), + ])) as any[] + activeTasks.value = active.data || [] + completedTasks.value = completed.data || [] + closedTasks.value = closed.data || [] + taskStats.value = stats.data || {} } catch (e) { console.error('Failed to fetch team tasks', e) } finally { @@ -61,6 +109,22 @@ export const useTeamStore = defineStore('team', () => { } } + async function loadMoreCompleted(teamId: string) { + const res: any = await teamApi.listTasks(teamId, COMPLETED_STATUSES, { + limit: TERMINAL_PAGE, + offset: completedTasks.value.length, + }) + completedTasks.value = [...completedTasks.value, ...(res.data || [])] + } + + async function loadMoreClosed(teamId: string) { + const res: any = await teamApi.listTasks(teamId, CLOSED_STATUSES, { + limit: TERMINAL_PAGE, + offset: closedTasks.value.length, + }) + closedTasks.value = [...closedTasks.value, ...(res.data || [])] + } + async function createTeam(data: { name: string description?: string @@ -85,12 +149,19 @@ export const useTeamStore = defineStore('team', () => { currentTeam, members, tasks, + taskStats, boardLoading, hasActiveTasks, + completedTotal, + closedTotal, + completedHasMore, + closedHasMore, fetchTeams, openTeam, closeTeam, fetchTasks, + loadMoreCompleted, + loadMoreClosed, createTeam, deleteTeam, } diff --git a/mateclaw-ui/src/views/Teams.vue b/mateclaw-ui/src/views/Teams.vue index 6854222e..490e4f6a 100644 --- a/mateclaw-ui/src/views/Teams.vue +++ b/mateclaw-ui/src/views/Teams.vue @@ -120,7 +120,7 @@
{{ col.label }} - {{ col.tasks.length }} + {{ col.total }}
+ @@ -514,21 +519,43 @@ const activeTab = ref('board') // ==================== board columns ==================== const COLUMN_DEFS = [ - { key: 'todo', statuses: ['pending', 'blocked'] }, - { key: 'in_progress', statuses: ['in_progress'] }, - { key: 'in_review', statuses: ['in_review'] }, - { key: 'completed', statuses: ['completed'] }, - { key: 'closed', statuses: ['failed', 'cancelled', 'stale'] }, + { key: 'todo', statuses: ['pending', 'blocked'], terminal: false }, + { key: 'in_progress', statuses: ['in_progress'], terminal: false }, + { key: 'in_review', statuses: ['in_review'], terminal: false }, + { key: 'completed', statuses: ['completed'], terminal: true }, + { key: 'closed', statuses: ['failed', 'cancelled', 'stale'], terminal: true }, ] as const const boardColumns = computed(() => - COLUMN_DEFS.map((col) => ({ - key: col.key, - label: t(`teams.column.${col.key}`), - tasks: store.tasks.filter((vo) => (col.statuses as readonly string[]).includes(vo.task.status)), - })), + COLUMN_DEFS.map((col) => { + const colTasks = store.tasks.filter((vo) => + (col.statuses as readonly string[]).includes(vo.task.status), + ) + // Terminal columns are windowed: the header shows the true database + // total and the column body ends with a load-more control. + const total = col.key === 'completed' ? store.completedTotal + : col.key === 'closed' ? store.closedTotal + : colTasks.length + const hasMore = col.key === 'completed' ? store.completedHasMore + : col.key === 'closed' ? store.closedHasMore + : false + return { + key: col.key, + label: t(`teams.column.${col.key}`), + tasks: colTasks, + total, + hasMore, + } + }), ) +async function loadMoreColumn(key: string) { + if (!store.currentTeam) return + const teamId = store.currentTeam.team.id + if (key === 'completed') await store.loadMoreCompleted(teamId) + else if (key === 'closed') await store.loadMoreClosed(teamId) +} + function statusLabel(status?: string) { return status ? t(`teams.status.${status}`, status) : '' } @@ -1670,6 +1697,22 @@ async function cancelTask() { text-overflow: ellipsis; white-space: nowrap; } +.board-col__more { + width: 100%; + padding: 8px 0; + margin-top: 2px; + border: 1px dashed var(--mc-border); + border-radius: 10px; + background: transparent; + color: var(--mc-text-secondary); + font-size: 12px; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; +} +.board-col__more:hover { + border-color: var(--mc-primary); + color: var(--mc-primary); +} .deliverable-list { display: flex; flex-direction: column;