feat(team): windowed task board — paged terminal columns with true totals and database-side status counts

This commit is contained in:
mateaix 2026-07-26 10:17:47 +08:00
parent 2cbe00a1e7
commit b6326daaf8
7 changed files with 179 additions and 26 deletions

View File

@ -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<List<TaskVO>> listTasks(@PathVariable Long id,
@RequestParam(required = false) List<String> status) {
return R.ok(taskService.listTasks(id, status).stream().map(this::toTaskVO).toList());
@RequestParam(required = false) List<String> 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<Map<String, Long>> 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 ====================

View File

@ -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<TeamTaskEntity> listTasks(Long teamId, List<String> 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<TeamTaskEntity> listTasks(Long teamId, List<String> statuses,
Integer limit, Integer offset) {
return taskMapper.selectList(Wrappers.<TeamTaskEntity>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<String, Long> countByStatus(Long teamId) {
Map<String, Long> counts = new HashMap<>();
taskMapper.selectMaps(Wrappers.<TeamTaskEntity>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 ====================

View File

@ -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,

View File

@ -52,6 +52,7 @@ export default {
deliverables: 'Deliverables',
viewRun: 'View execution',
timeline: 'Timeline',
loadMore: 'Load more ({loaded}/{total})',
eventType: {
created: 'Created',
dispatched: 'Dispatched',

View File

@ -52,6 +52,7 @@ export default {
deliverables: '交付物',
viewRun: '查看执行过程',
timeline: '时间线',
loadMore: '加载更多({loaded}/{total}',
eventType: {
created: '创建',
dispatched: '派发',

View File

@ -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<TeamVO[]>([])
@ -14,15 +19,38 @@ export const useTeamStore = defineStore('team', () => {
const currentTeam = ref<TeamVO | null>(null)
const members = ref<TeamMemberVO[]>([])
const tasks = ref<TeamTaskVO[]>([])
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<TeamTaskVO[]>([])
const completedTasks = ref<TeamTaskVO[]>([])
const closedTasks = ref<TeamTaskVO[]>([])
/** True per-status totals from the stats endpoint. */
const taskStats = ref<Record<string, number>>({})
/** Merged view consumed by the board's status-filtered columns. */
const tasks = computed<TeamTaskVO[]>(() => [
...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,
}

View File

@ -120,7 +120,7 @@
<div class="board-col__head">
<span class="board-col__dot" :class="`dot--${col.key}`"></span>
<span class="board-col__label">{{ col.label }}</span>
<span class="board-col__count">{{ col.tasks.length }}</span>
<span class="board-col__count">{{ col.total }}</span>
</div>
<div class="board-col__body">
<div
@ -142,6 +142,11 @@
<div class="task-card__progress-bar" :style="{ width: vo.task.progressPercent + '%' }"></div>
</div>
</div>
<button
v-if="col.hasMore"
class="board-col__more"
@click="loadMoreColumn(col.key)"
>{{ t('teams.loadMore', { loaded: col.tasks.length, total: col.total }) }}</button>
</div>
</div>
</div>
@ -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;