mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(team): windowed task board — paged terminal columns with true totals and database-side status counts
This commit is contained in:
parent
2cbe00a1e7
commit
b6326daaf8
@ -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 ====================
|
||||
|
||||
@ -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 ====================
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -52,6 +52,7 @@ export default {
|
||||
deliverables: 'Deliverables',
|
||||
viewRun: 'View execution',
|
||||
timeline: 'Timeline',
|
||||
loadMore: 'Load more ({loaded}/{total})',
|
||||
eventType: {
|
||||
created: 'Created',
|
||||
dispatched: 'Dispatched',
|
||||
|
||||
@ -52,6 +52,7 @@ export default {
|
||||
deliverables: '交付物',
|
||||
viewRun: '查看执行过程',
|
||||
timeline: '时间线',
|
||||
loadMore: '加载更多({loaded}/{total})',
|
||||
eventType: {
|
||||
created: '创建',
|
||||
dispatched: '派发',
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user