+
+
+
+
+
+
+
+
+
+
成员管理
+
+ {team ? teamStatusLabel(team.status) : ''}
+
+
+
+ {(() => {
+ const members = team?.members || [];
+ const leader = members.find((member) => member.role === 'leader');
+ const others = members.filter((member) => member.role !== 'leader');
+
+ function memberNode(member: TeamMemberRead, isLeader: boolean) {
+ return (
+
+
+
+ {member.agent_name || member.agent_id}
+
+
+ {isLeader ? 'TL' : '成员'}
+
+
+ {!isLeader && (
+
+ )}
+
+
+
+ );
+ }
+
+ return (
+
+ {leader && memberNode(leader, true)}
+ {leader && others.length > 0 &&
}
+ {others.length > 0 && (
+
+ {others.map((member, index) => (
+
+ {leader && (
+ <>
+
+
0 && 'bg-[#dbe1ec]',
+ )}
+ />
+
+
+
+ >
+ )}
+ {memberNode(member, false)}
+
+ ))}
+
+ )}
+ {team && members.length === 0 && (
+
暂无成员
+ )}
+
+ );
+ })()}
+
+
+
+
+
+
+
+
+ TL 对话
+
+
+ 向 TL 描述目标,TL 会拆解并派发任务。对话在团队专属聊天室中进行,可查看完整的执行过程与产出。
+
+
+
+
+
+
+
+
+
+ 团队黑板
+
+ {sortedBoardEntries.map((entry) => {
+ const taskTitle = textField(entry.citation, 'task_title');
+ const promoted = Boolean(textField(entry.citation, 'knowledge_base_id'));
+ return (
+
+
+
+ {entry.content}
+
+ {entry.pinned && (
+
+ 置顶
+
+ )}
+
+ {entry.tags.length > 0 && (
+
+ {entry.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+
+ {boardSourceLabel(entry)}
+ {taskTitle ? ` · 关联任务:${taskTitle}` : ''}
+ {` · ${formatClientDateTime(entry.updated_at)}`}
+
+
+
+
+
+
+
+
+
+ );
+ })}
+ {sortedBoardEntries.length === 0 && (
+
暂无黑板条目
+ )}
+
+
+ setBoardContent(event.target.value)}
+ placeholder="输入黑板内容"
+ aria-label="输入黑板内容"
+ disabled={postingEntry}
+ className="h-[36px] flex-1 rounded-[10px] border-[#e3e7f1] text-[14px]"
+ />
+ setBoardTags(event.target.value)}
+ placeholder="标签(逗号分隔,可选)"
+ aria-label="标签(逗号分隔,可选)"
+ disabled={postingEntry}
+ className="h-[36px] w-[200px] shrink-0 rounded-[10px] border-[#e3e7f1] text-[14px]"
+ />
+
+
+
+
+
+
+
任务看板
+
+
+
+ {TASK_STATUS_COLUMNS.map((column) => {
+ const columnTasks = tasksByStatus.get(column.status) || [];
+ return (
+
+
+ {column.label}
+ {columnTasks.length}
+
+ {columnTasks.map((task) => (
+
+ ))}
+ {columnTasks.length === 0 && (
+
暂无任务
+ )}
+
+ );
+ })}
+
+
+
+
+ 团队动态
+ {teamEvents.length === 0 ? (
+ 暂无团队动态
+ ) : (
+
+ {eventGroups.map((group) => (
+
+ {group.task ? (
+
+ ) : (
+
{group.title}
+ )}
+
+ {group.events.map((event) => (
+ -
+ {teamEventTypeLabel(event.event_type)}
+ {eventActorLabel(event)}
+
+ {relativeTimeLabel(event.created_at)}
+
+
+ ))}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend-enterprise/src/pages/TeamsPage.test.tsx b/frontend-enterprise/src/pages/TeamsPage.test.tsx
new file mode 100644
index 00000000..95716405
--- /dev/null
+++ b/frontend-enterprise/src/pages/TeamsPage.test.tsx
@@ -0,0 +1,257 @@
+// @vitest-environment jsdom
+
+import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { I18nProvider } from '@/i18n';
+import type { TeamRead, TeamThreadRead } from '@/types';
+
+import TeamsPage from './TeamsPage';
+
+const team: TeamRead = {
+ id: 'team-1',
+ tenant_id: 'tenant_demo',
+ name: '增长团队',
+ description: '负责增长实验',
+ owner_user_id: 'user-1',
+ config: {},
+ status: 'active',
+ members: [
+ {
+ id: 'member-1',
+ team_id: 'team-1',
+ agent_id: 'agent-1',
+ role: 'leader',
+ agent_name: '小艾',
+ created_at: '2026-08-01T00:00:00Z',
+ },
+ {
+ id: 'member-2',
+ team_id: 'team-1',
+ agent_id: 'agent-2',
+ role: 'member',
+ agent_name: '小北',
+ created_at: '2026-08-01T00:00:00Z',
+ },
+ ],
+ created_at: '2026-08-01T00:00:00Z',
+ updated_at: '2026-08-01T00:00:00Z',
+};
+
+function jsonResponse(body: unknown): Response {
+ return {
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ text: async () => JSON.stringify(body ?? {}),
+ } as Response;
+}
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+describe('TeamsPage', () => {
+ it('renders the team list with member count and TL name', async () => {
+ const fetchMock = vi.fn(async () => jsonResponse([team]));
+ vi.stubGlobal('fetch', fetchMock);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(await screen.findByText('增长团队')).toBeTruthy();
+ expect(screen.getByText('负责增长实验')).toBeTruthy();
+ expect(screen.getAllByText('2 名成员').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getByText('TL:小艾')).toBeTruthy();
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining('/api/enterprise/teams?tenant_id='),
+ expect.anything(),
+ );
+ });
+
+ it('creates a team through the dialog', async () => {
+ const user = userEvent.setup();
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (init?.method === 'POST') return jsonResponse({ ...team, id: 'team-2', name: '新团队' });
+ return jsonResponse(url.includes('/teams') ? [team] : []);
+ });
+ vi.stubGlobal('fetch', fetchMock);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ await screen.findByText('增长团队');
+ await user.click(screen.getByRole('button', { name: /创建新团队/ }));
+ await user.type(screen.getByLabelText('团队名称'), '新团队');
+ await user.click(screen.getByRole('button', { name: '创建' }));
+
+ await waitFor(() => {
+ const createCall = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
+ expect(createCall).toBeTruthy();
+ expect(String(createCall?.[0])).toContain('/api/enterprise/teams');
+ const body = JSON.parse(String(createCall?.[1]?.body)) as Record
;
+ expect(body.name).toBe('新团队');
+ expect(body.tenant_id).toBeTruthy();
+ });
+ });
+
+ it('deletes a team after confirmation', async () => {
+ const user = userEvent.setup();
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ if (init?.method === 'DELETE') return jsonResponse({ ok: true });
+ return jsonResponse([team]);
+ });
+ vi.stubGlobal('fetch', fetchMock);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ await screen.findByText('增长团队');
+ await user.click(screen.getByRole('button', { name: '删除团队 增长团队' }));
+ await user.click(await screen.findByRole('button', { name: '删除' }));
+
+ await waitFor(() => {
+ const deleteCall = fetchMock.mock.calls.find(([, init]) => init?.method === 'DELETE');
+ expect(deleteCall).toBeTruthy();
+ expect(String(deleteCall?.[0])).toContain('/api/enterprise/teams/team-1');
+ });
+ });
+});
+
+const threads: TeamThreadRead[] = [
+ {
+ team_id: 'team-1',
+ team_name: '增长团队',
+ kind: 'tl_chat',
+ session_id: 'session-1',
+ task_id: null,
+ title: 'planning chat',
+ task_status: null,
+ updated_at: new Date().toISOString(),
+ },
+ {
+ team_id: 'team-1',
+ team_name: '增长团队',
+ kind: 'task',
+ session_id: 'session-2',
+ task_id: 'task-9',
+ title: '写周报',
+ task_status: 'in_progress',
+ updated_at: new Date().toISOString(),
+ },
+];
+
+function LocationEcho() {
+ const location = useLocation();
+ return {`${location.pathname}${location.search}`}
;
+}
+
+function renderTeamsWithRoutes() {
+ return render(
+
+
+
+ } />
+ } />
+
+
+ ,
+ );
+}
+
+function stubThreadsFetch() {
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes('/team-threads')) return jsonResponse(threads);
+ return jsonResponse([team]);
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ return fetchMock;
+}
+
+describe('TeamsPage team activity', () => {
+ it('renders the activity tree grouped by team and task', async () => {
+ stubThreadsFetch();
+ renderTeamsWithRoutes();
+
+ const activity = await screen.findByLabelText('团队动态');
+ // 团队节点只出现一次,线程按任务分组收拢在节点下(默认展开最新团队)
+ expect(within(activity).getAllByText('增长团队').length).toBe(1);
+ expect(within(activity).getByText('TL 对话')).toBeTruthy();
+ expect(within(activity).getByText('planning chat')).toBeTruthy();
+ expect(within(activity).getAllByText('写周报').length).toBeGreaterThanOrEqual(1);
+ expect(within(activity).getByText('进行中')).toBeTruthy();
+ expect(within(activity).getByText(/1 任务 · 2 线程/)).toBeTruthy();
+ });
+
+ it('collapses and expands a team node', async () => {
+ const user = userEvent.setup();
+ stubThreadsFetch();
+ renderTeamsWithRoutes();
+
+ const activity = await screen.findByLabelText('团队动态');
+ await user.click(within(activity).getByLabelText('收起团队 增长团队'));
+ expect(within(activity).queryByText('planning chat')).toBeNull();
+
+ await user.click(within(activity).getByLabelText('展开团队 增长团队'));
+ expect(within(activity).getByText('planning chat')).toBeTruthy();
+ });
+
+ it('navigates to the task detail when the thread has a task_id', async () => {
+ const user = userEvent.setup();
+ stubThreadsFetch();
+ renderTeamsWithRoutes();
+
+ const activity = await screen.findByLabelText('团队动态');
+ await user.click(within(activity).getAllByRole('button', { name: /写周报/ })[0]);
+
+ expect((await screen.findByTestId('location')).textContent).toBe(
+ '/enterprise/teams/team-1?task=task-9',
+ );
+ });
+
+ it('navigates to the team detail without a task param for TL chats', async () => {
+ const user = userEvent.setup();
+ stubThreadsFetch();
+ renderTeamsWithRoutes();
+
+ const activity = await screen.findByLabelText('团队动态');
+ await user.click(within(activity).getByRole('button', { name: /planning chat/ }));
+
+ expect((await screen.findByTestId('location')).textContent).toBe('/enterprise/teams/team-1');
+ });
+});
+
+describe('relativeTimeLabel', () => {
+ it('treats naive backend timestamps as UTC when computing relative time', async () => {
+ const { relativeTimeLabel } = await import('./TeamsPage');
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-08-11T12:00:00Z'));
+ try {
+ expect(relativeTimeLabel('2026-08-11T11:50:00')).toBe('10 分钟前');
+ expect(relativeTimeLabel('2026-08-11T11:00:00Z')).toBe('1 小时前');
+ expect(relativeTimeLabel('')).toBe('');
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/frontend-enterprise/src/pages/TeamsPage.tsx b/frontend-enterprise/src/pages/TeamsPage.tsx
new file mode 100644
index 00000000..3fd15c9b
--- /dev/null
+++ b/frontend-enterprise/src/pages/TeamsPage.tsx
@@ -0,0 +1,582 @@
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { ChevronDown, ChevronRight } from 'lucide-react';
+
+import {
+ Badge,
+ Button,
+ Dialog,
+ DialogContent,
+ DialogTitle,
+ Input,
+ Textarea,
+} from '@/components/ui';
+import { notify } from '@/components/ui/app-toast';
+
+import IconPlus from '../assets/icons/plus.svg?react';
+import IconTrash from '../assets/icons/trash.svg?react';
+
+import { api, TENANT_ID } from '../api/client';
+import type { EnterpriseAuthUser } from '../auth';
+import AppHeader from '../components/AppHeader';
+import { ConfirmDialog } from '../components/ConfirmDialog';
+import EmployeeAvatar from '../components/EmployeeAvatar';
+import { EnterpriseRoute } from '../enums/routes';
+import { parseBackendDateTime } from '../lib/timezone';
+import type { AgentProfileRead, TeamRead, TeamThreadRead } from '../types';
+
+export function teamStatusLabel(status: string): string {
+ if (status === 'active') return '正常';
+ if (status === 'archived') return '已归档';
+ return status;
+}
+
+export function taskStatusLabel(status: string): string {
+ if (status === 'bidding') return '竞标中';
+ if (status === 'pending') return '待认领';
+ if (status === 'in_progress') return '进行中';
+ if (status === 'review') return '待验收';
+ if (status === 'done') return '已完成';
+ if (status === 'rework') return '已退回';
+ if (status === 'escalated') return '已升级';
+ return status;
+}
+
+export function relativeTimeLabel(iso: string): string {
+ const time = parseBackendDateTime(iso).getTime();
+ if (Number.isNaN(time)) return '';
+ const minutes = Math.floor((Date.now() - time) / 60000);
+ if (minutes < 1) return '刚刚';
+ if (minutes < 60) return `${minutes} 分钟前`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours} 小时前`;
+ const days = Math.floor(hours / 24);
+ if (days < 7) return `${days} 天前`;
+ return parseBackendDateTime(iso).toLocaleDateString();
+}
+
+export function teamLeaderName(team: TeamRead): string {
+ const leader = (team.members || []).find((member) => member.role === 'leader');
+ return leader?.agent_name || '未设置';
+}
+
+export type TeamThreadTaskGroup = {
+ taskId: string;
+ title: string;
+ status: string | null;
+ latestAt: string;
+ threads: TeamThreadRead[];
+};
+
+export type TeamThreadTree = {
+ teamId: string;
+ teamName: string;
+ latestAt: string;
+ tlThreads: TeamThreadRead[];
+ tasks: TeamThreadTaskGroup[];
+};
+
+const THREAD_TASK_PREFIXES = ['团队任务验收:', '团队任务验收:', '团队任务:', '团队任务:', '团队竞标:', '团队竞标:'];
+
+function stripThreadPrefix(title: string): string {
+ for (const prefix of THREAD_TASK_PREFIXES) {
+ if (title.startsWith(prefix)) return title.slice(prefix.length);
+ }
+ return title;
+}
+
+function latestOf(items: TeamThreadRead[]): string {
+ return items.reduce((latest, item) => {
+ const time = parseBackendDateTime(item.updated_at).getTime();
+ return time > parseBackendDateTime(latest).getTime() ? item.updated_at : latest;
+ }, items[0]?.updated_at ?? '');
+}
+
+/** 把平铺的团队线程组装成 团队 → 任务 → 线程 的树,供动态区树状展示。 */
+export function buildThreadTree(threads: TeamThreadRead[]): TeamThreadTree[] {
+ const byTeam = new Map();
+ for (const thread of threads) {
+ const list = byTeam.get(thread.team_id) || [];
+ list.push(thread);
+ byTeam.set(thread.team_id, list);
+ }
+ const tree: TeamThreadTree[] = [];
+ for (const [teamId, teamThreads] of byTeam) {
+ const tlThreads = teamThreads
+ .filter((thread) => !thread.task_id)
+ .sort((a, b) => parseBackendDateTime(b.updated_at).getTime() - parseBackendDateTime(a.updated_at).getTime());
+ const byTask = new Map();
+ for (const thread of teamThreads) {
+ if (!thread.task_id) continue;
+ const list = byTask.get(thread.task_id) || [];
+ list.push(thread);
+ byTask.set(thread.task_id, list);
+ }
+ const tasks: TeamThreadTaskGroup[] = [];
+ for (const [taskId, taskThreads] of byTask) {
+ taskThreads.sort(
+ (a, b) => parseBackendDateTime(b.updated_at).getTime() - parseBackendDateTime(a.updated_at).getTime(),
+ );
+ const titled = taskThreads.find((thread) => thread.title.startsWith('团队任务')) || taskThreads[0];
+ tasks.push({
+ taskId,
+ title: stripThreadPrefix(titled.title),
+ status: taskThreads.find((thread) => thread.task_status)?.task_status ?? null,
+ latestAt: latestOf(taskThreads),
+ threads: taskThreads,
+ });
+ }
+ tasks.sort((a, b) => parseBackendDateTime(b.latestAt).getTime() - parseBackendDateTime(a.latestAt).getTime());
+ tree.push({
+ teamId,
+ teamName: teamThreads[0]?.team_name || teamId,
+ latestAt: latestOf(teamThreads),
+ tlThreads,
+ tasks,
+ });
+ }
+ tree.sort((a, b) => parseBackendDateTime(b.latestAt).getTime() - parseBackendDateTime(a.latestAt).getTime());
+ return tree;
+}
+
+export default function TeamsPage({
+ currentUser,
+ onLogout,
+}: {
+ currentUser?: EnterpriseAuthUser;
+ isAdmin?: boolean;
+ onLogout?: () => void;
+}) {
+ const [teams, setTeams] = useState([]);
+ const [threads, setThreads] = useState([]);
+ const [agents, setAgents] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [createOpen, setCreateOpen] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [deleting, setDeleting] = useState(false);
+ const [expandedTeams, setExpandedTeams] = useState | null>(null);
+ const navigate = useNavigate();
+
+ const threadTree = buildThreadTree(threads);
+ // 每个团队的实时任务概况(threads 按 updated_at 倒序,同任务首次出现即最新状态)
+ const teamTaskCounts = new Map();
+ {
+ const seen = new Set();
+ for (const thread of threads) {
+ if (!thread.task_id || !thread.task_status) continue;
+ const key = `${thread.team_id}:${thread.task_id}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ const counts = teamTaskCounts.get(thread.team_id) || { active: 0, attention: 0 };
+ if (['pending', 'bidding', 'in_progress'].includes(thread.task_status)) counts.active += 1;
+ else if (thread.task_status === 'review' || thread.task_status === 'escalated') counts.attention += 1;
+ teamTaskCounts.set(thread.team_id, counts);
+ }
+ }
+ // 默认只展开最新动态的团队,避免动态刷屏
+ const expanded = expandedTeams ?? new Set(threadTree.slice(0, 1).map((node) => node.teamId));
+
+ function toggleTeamExpand(teamId: string) {
+ const next = new Set(expanded);
+ if (next.has(teamId)) next.delete(teamId);
+ else next.add(teamId);
+ setExpandedTeams(next);
+ }
+
+ function renderThreadRow(thread: TeamThreadRead) {
+ return (
+
+ );
+ }
+
+ async function load() {
+ setLoading(true);
+ try {
+ const rows = await api.get(`/api/enterprise/teams?tenant_id=${TENANT_ID}`);
+ setTeams(rows);
+ } catch (error) {
+ notify.error(error instanceof Error ? error.message : '加载团队失败');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ useEffect(() => {
+ void load();
+ void loadThreads();
+ // 员工列表仅用于团队卡片的成员头像映射,失败不影响主流程
+ api
+ .get(`/api/enterprise/agents?tenant_id=${TENANT_ID}`)
+ .then(setAgents)
+ .catch(() => setAgents([]));
+ }, []);
+
+ async function loadThreads() {
+ try {
+ const rows = await api.get(`/api/enterprise/team-threads?tenant_id=${TENANT_ID}`);
+ setThreads(rows);
+ } catch {
+ setThreads([]);
+ }
+ }
+
+ function openThread(thread: TeamThreadRead) {
+ const base = `${EnterpriseRoute.Teams}/${thread.team_id}`;
+ navigate(thread.task_id ? `${base}?task=${thread.task_id}` : base);
+ }
+
+ async function createTeam() {
+ const trimmed = name.trim();
+ if (!trimmed) {
+ notify.error('请输入团队名称');
+ return;
+ }
+ setCreating(true);
+ try {
+ await api.post('/api/enterprise/teams', {
+ tenant_id: TENANT_ID,
+ name: trimmed,
+ description: description.trim() || undefined,
+ });
+ notify.success('团队已创建');
+ setCreateOpen(false);
+ setName('');
+ setDescription('');
+ await load();
+ } catch (error) {
+ notify.error(error instanceof Error ? error.message : '创建团队失败');
+ } finally {
+ setCreating(false);
+ }
+ }
+
+ async function confirmDelete() {
+ const target = deleteTarget;
+ if (!target) return;
+ setDeleting(true);
+ try {
+ await api.delete(`/api/enterprise/teams/${target.id}?tenant_id=${TENANT_ID}`);
+ notify.success('团队已删除');
+ setDeleteTarget(null);
+ await load();
+ } catch (error) {
+ notify.error(error instanceof Error ? error.message : '删除团队失败');
+ } finally {
+ setDeleting(false);
+ }
+ }
+
+ return (
+
+
+
+ {(() => {
+ const totalMembers = teams.reduce((sum, team) => sum + (team.members || []).length, 0);
+ // threads 按 updated_at 倒序,task 首次出现即最新状态
+ const latestTaskStatus = new Map
();
+ for (const thread of threads) {
+ if (thread.task_id && thread.task_status && !latestTaskStatus.has(thread.task_id)) {
+ latestTaskStatus.set(thread.task_id, thread.task_status);
+ }
+ }
+ const statuses = [...latestTaskStatus.values()];
+ const activeTasks = statuses.filter((status) => ['pending', 'bidding', 'in_progress'].includes(status)).length;
+ const attentionTasks = statuses.filter((status) => status === 'review' || status === 'escalated').length;
+ const summaryCardClass =
+ 'flex h-[100px] flex-1 basis-[220px] items-center gap-[16px] rounded-[20px] bg-[#f6f6f6] px-[32px] py-[20px] text-left transition-shadow';
+ const summaryStats = [
+ { key: 'all', value: teams.length, label: '团队总数', sub: `${totalMembers} 名成员` },
+ { key: 'active', value: activeTasks, label: '进行中任务', sub: '正在推进' },
+ { key: 'attention', value: attentionTasks, label: '待处理', sub: '需要人工介入' },
+ ];
+ return (
+
+ {summaryStats.map((stat) => (
+
+ {stat.value}
+
+ {stat.label}
+ {stat.sub}
+
+
+ ))}
+
+
+ );
+ })()}
+
+
+ {teams.map((team) => {
+ const members = team.members || [];
+ const leader = members.find((member) => member.role === 'leader') || null;
+ const ordered = leader ? [leader, ...members.filter((member) => member.id !== leader.id)] : members;
+ const stacked = ordered.slice(0, 4);
+ const extraCount = members.length - stacked.length;
+ const counts = teamTaskCounts.get(team.id) || { active: 0, attention: 0 };
+ return (
+
navigate(`${EnterpriseRoute.Teams}/${team.id}`)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ navigate(`${EnterpriseRoute.Teams}/${team.id}`);
+ }
+ }}
+ className="group cursor-pointer rounded-[20px] bg-white p-[20px] shadow-[0_0_6px_rgba(0,0,0,0.05)] transition-all duration-200 hover:-translate-y-[2px] hover:shadow-[0_18px_36px_-12px_rgba(70,76,94,0.28)] active:translate-y-0 active:scale-[0.99]"
+ >
+ {/* 成员合影:TL 居大带标记,悬浮时成员扇形散开 */}
+
+
+ {stacked.map((member, index) => {
+ const isLeader = leader?.id === member.id;
+ const memberAgent = agents.find((agent) => agent.id === member.agent_id) || null;
+ return (
+ 0 ? '-ml-[16px] transition-all duration-200 group-hover:-ml-[8px]' : ''}
+ >
+
+
+ {isLeader && (
+
+ TL
+
+ )}
+
+
+ );
+ })}
+ {extraCount > 0 && (
+
+ {`+${extraCount}`}
+
+ )}
+
+
+ {teamStatusLabel(team.status)}
+
+
+
+
+
+ {team.name}
+
+
+ {team.description || '暂无描述'}
+
+
+
+ {`${members.length} 名成员`}
+
+ {counts.active > 0 && (
+
+ {`${counts.active} 进行中`}
+
+ )}
+ {counts.attention > 0 && (
+
+ {`${counts.attention} 待处理`}
+
+ )}
+
+
+
+ {`TL:${leader?.agent_name || '未设置'}`}
+
+
+
+
+
+ );
+ })}
+ {!loading && teams.length === 0 && (
+
+ 暂无团队,点击上方「创建新团队」开始
+
+ )}
+
+
+
+ 团队动态
+
+ {threadTree.map((node) => {
+ const isExpanded = expanded.has(node.teamId);
+ const threadCount = node.tlThreads.length + node.tasks.reduce((sum, task) => sum + task.threads.length, 0);
+ return (
+
+
+
+
+
+ {node.tasks.length} 任务 · {threadCount} 线程
+
+ {relativeTimeLabel(node.latestAt)}
+
+ {isExpanded && (
+
+ {node.tlThreads.map((thread) => renderThreadRow(thread))}
+ {node.tasks.map((task) => (
+
+
+
+ {task.threads.map((thread) => renderThreadRow(thread))}
+
+
+ ))}
+
+ )}
+
+ );
+ })}
+ {threadTree.length === 0 && (
+
暂无团队动态
+ )}
+
+
+
+
+
+ {
+ if (!open) setDeleteTarget(null);
+ }}
+ loading={deleting}
+ title={`删除团队「${deleteTarget?.name || ''}」?`}
+ description="删除后团队及其任务将一并移除,操作不可撤销。"
+ onConfirm={() => void confirmDelete()}
+ />
+
+ );
+}
diff --git a/frontend-enterprise/src/pages/ToolsPage.tsx b/frontend-enterprise/src/pages/ToolsPage.tsx
index 50779a8f..9a19219f 100644
--- a/frontend-enterprise/src/pages/ToolsPage.tsx
+++ b/frontend-enterprise/src/pages/ToolsPage.tsx
@@ -65,6 +65,7 @@ import {
visibleEmployeeAgents,
} from '../employee';
import { useClientPagination } from '../hooks/useClientPagination';
+import { isTeamScope, readEmployeeScope } from '../lib/agent-scope-storage';
import { StatusBadge } from './scheduled-tasks/StatusBadge';
import type {
AgentProfileRead,
@@ -117,7 +118,7 @@ const TRANSPORT_OPTIONS: { value: MCPTransport; label: string; hint: string }[]
export default function ToolsPage({ currentUser, onLogout }: ToolPageProps = {}) {
const [rows, setRows] = useState([]);
- const [agentId, setAgentId] = useState(() => window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) || '');
+ const [agentId, setAgentId] = useState(readEmployeeScope);
const [isOverallAgent, setIsOverallAgent] = useState(true);
const [agentScopeLoaded, setAgentScopeLoaded] = useState(false);
const [bucketFilter, setBucketFilter] = useState('__all__');
@@ -196,8 +197,8 @@ export default function ToolsPage({ currentUser, onLogout }: ToolPageProps = {})
useEffect(() => {
const onScopeChange = (event: Event) => {
- const nextAgentId = (event as CustomEvent<{ agentId?: string }>).detail?.agentId || window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) || '';
- setAgentId(nextAgentId);
+ const next = (event as CustomEvent<{ agentId?: string }>).detail?.agentId || '';
+ setAgentId(next && !isTeamScope(next) ? next : readEmployeeScope());
};
window.addEventListener('ultrarag-enterprise-agent-scope-change', onScopeChange);
return () => window.removeEventListener('ultrarag-enterprise-agent-scope-change', onScopeChange);
@@ -2206,7 +2207,7 @@ async function loadBucketOptions() {
}
function currentAgentQuery() {
- const agentId = window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) || '';
+ const agentId = readEmployeeScope();
return agentId ? `&agent_id=${encodeURIComponent(agentId)}` : '';
}
diff --git a/frontend-enterprise/src/pages/chat/ChatPage.tsx b/frontend-enterprise/src/pages/chat/ChatPage.tsx
index 7f6c6b0a..222f6440 100644
--- a/frontend-enterprise/src/pages/chat/ChatPage.tsx
+++ b/frontend-enterprise/src/pages/chat/ChatPage.tsx
@@ -34,6 +34,7 @@ export default function ChatPage() {
sessions={chat.visibleSidebarSessions}
sessionsLoading={chat.sessionsLoading}
agents={chat.agents}
+ scopeTeams={chat.teams}
activeSessionId={chat.sessionId}
sessionFilter={chat.sessionAgentFilter}
onSessionFilterChange={chat.setSessionAgentFilter}
diff --git a/frontend-enterprise/src/pages/chat/components/ChatEmptyState.test.tsx b/frontend-enterprise/src/pages/chat/components/ChatEmptyState.test.tsx
new file mode 100644
index 00000000..c01122bf
--- /dev/null
+++ b/frontend-enterprise/src/pages/chat/components/ChatEmptyState.test.tsx
@@ -0,0 +1,123 @@
+// @vitest-environment jsdom
+
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { employeeProfile } from '@/employee';
+import { I18nProvider } from '@/i18n';
+import type { AgentProfileRead, ChatSession, TeamRead } from '@/types';
+
+import type { UseChatSession } from '../useChatSession';
+import ChatEmptyState from './ChatEmptyState';
+
+const agent: AgentProfileRead = {
+ id: 'agent-1',
+ tenant_id: 'tenant_demo',
+ name: '小艾',
+ is_overall: false,
+ status: 'active',
+ metadata: {},
+ resources: [],
+ created_at: '2026-08-01T00:00:00Z',
+ updated_at: '2026-08-01T00:00:00Z',
+};
+
+const team: TeamRead = {
+ id: 'team-1',
+ tenant_id: 'tenant_demo',
+ name: '增长团队',
+ description: '负责增长实验与内容投放',
+ owner_user_id: 'user-1',
+ config: {},
+ status: 'active',
+ members: [
+ { id: 'm-1', team_id: 'team-1', agent_id: 'agent-1', role: 'leader', agent_name: '小艾', created_at: '2026-08-01T00:00:00Z' },
+ { id: 'm-2', team_id: 'team-1', agent_id: 'agent-2', role: 'member', agent_name: '小北', created_at: '2026-08-01T00:00:00Z' },
+ { id: 'm-3', team_id: 'team-1', agent_id: 'agent-3', role: 'member', agent_name: '小南', created_at: '2026-08-01T00:00:00Z' },
+ { id: 'm-4', team_id: 'team-1', agent_id: 'agent-4', role: 'member', agent_name: '小西', created_at: '2026-08-01T00:00:00Z' },
+ ],
+ created_at: '2026-08-01T00:00:00Z',
+ updated_at: '2026-08-01T00:00:00Z',
+};
+
+function buildChat(session: Partial, extra: Record = {}): UseChatSession {
+ return {
+ currentSession: {
+ id: 'session-1',
+ tenant_id: 'tenant_demo',
+ status: 'active',
+ updated_at: '2026-08-01T00:00:00Z',
+ ...session,
+ } as ChatSession,
+ ...extra,
+ } as unknown as UseChatSession;
+}
+
+function renderEmptyState(chat: UseChatSession) {
+ return render(
+
+
+ ,
+ );
+}
+
+afterEach(() => {
+ cleanup();
+});
+
+describe('ChatEmptyState team card', () => {
+ it('renders the team card for team sessions', () => {
+ renderEmptyState(buildChat(
+ { team_id: 'team-1', team_name: '增长团队' },
+ { displayedTeam: team, agents: [agent], teamEmptyStats: { tasks: 2, blackboard: 3 } },
+ ));
+
+ expect(screen.getByText(/Hello 我们是/).textContent).toContain('增长团队');
+ expect(screen.getByText('我们来做什么?')).toBeTruthy();
+ expect(screen.getByText('负责增长实验与内容投放')).toBeTruthy();
+ // 成员名标签,TL 带后缀标识
+ expect(screen.getByText(/小艾 · TL/)).toBeTruthy();
+ expect(screen.getByText(/小北/)).toBeTruthy();
+ // 统计格:成员数 / 任务数 / 黑板条目数
+ expect(screen.getByText('成员数')).toBeTruthy();
+ expect(screen.getByText('4')).toBeTruthy();
+ expect(screen.getByText('任务数')).toBeTruthy();
+ expect(screen.getByText('2')).toBeTruthy();
+ expect(screen.getByText('黑板条目数')).toBeTruthy();
+ expect(screen.getByText('3')).toBeTruthy();
+ });
+
+ it('falls back to team_name and a member-count summary when the team is not loaded', () => {
+ renderEmptyState(buildChat(
+ { team_id: 'team-1', team_name: '增长团队' },
+ { displayedTeam: null, agents: [], teamEmptyStats: { tasks: 0, blackboard: 0 } },
+ ));
+
+ expect(screen.getByText(/Hello 我们是/).textContent).toContain('增长团队');
+ expect(screen.getByText(/团队由 0 名成员组成/)).toBeTruthy();
+ });
+
+ it('still renders the employee card for regular sessions', () => {
+ renderEmptyState(buildChat(
+ { agent_id: 'agent-1' },
+ {
+ displayedAgent: agent,
+ displayedProfile: employeeProfile(agent),
+ emptyRoleSummary: 'role summary',
+ emptyProfileTags: ['结构化整理'],
+ emptyStats: [
+ { label: '资料', value: 1 },
+ { label: '技能', value: 2 },
+ { label: 'SOP', value: 3 },
+ ],
+ displayedTeam: null,
+ agents: [agent],
+ teamEmptyStats: { tasks: 0, blackboard: 0 },
+ },
+ ));
+
+ expect(screen.getByText(/Hello 我是/).textContent).toContain('小艾');
+ expect(screen.queryByText(/Hello 我们是/)).toBeNull();
+ expect(screen.getByText('资料')).toBeTruthy();
+ });
+});
diff --git a/frontend-enterprise/src/pages/chat/components/ChatEmptyState.tsx b/frontend-enterprise/src/pages/chat/components/ChatEmptyState.tsx
index b4838ad3..05fa1a16 100644
--- a/frontend-enterprise/src/pages/chat/components/ChatEmptyState.tsx
+++ b/frontend-enterprise/src/pages/chat/components/ChatEmptyState.tsx
@@ -1,4 +1,5 @@
import EmployeeAvatar from '@/components/EmployeeAvatar';
+import { teamLeader } from '@/components/TeamCard';
import { employeeDisplayName } from '@/employee';
import {
@@ -13,11 +14,21 @@ import {
} from '../chatPageStyles';
import type { UseChatSession } from '../useChatSession';
+function greetingFontSize(displayName: string): number {
+ const length = Array.from(displayName).length;
+ return length > 20 ? 20 : length > 12 ? 24 : length > 6 ? 30 : 36;
+}
+
export default function ChatEmptyState({ chat }: { chat: UseChatSession }) {
+ if (chat.currentSession?.team_id) {
+ return ;
+ }
+ return ;
+}
+
+function EmployeeEmptyCard({ chat }: { chat: UseChatSession }) {
const { displayedAgent, displayedProfile, emptyRoleSummary, emptyProfileTags, emptyStats } = chat;
const displayName = displayedAgent ? employeeDisplayName(displayedAgent) : '';
- const displayNameLength = Array.from(displayName).length;
- const greetingFontSize = displayNameLength > 20 ? 20 : displayNameLength > 12 ? 24 : displayNameLength > 6 ? 30 : 36;
return (
@@ -40,7 +51,7 @@ export default function ChatEmptyState({ chat }: { chat: UseChatSession }) {
Hello 我是{displayName}!
@@ -71,3 +82,73 @@ export default function ChatEmptyState({ chat }: { chat: UseChatSession }) {
);
}
+
+function TeamEmptyCard({ chat }: { chat: UseChatSession }) {
+ const { displayedTeam, currentSession, agents, teamEmptyStats } = chat;
+ const members = displayedTeam?.members || [];
+ const leader = displayedTeam ? teamLeader(displayedTeam) : null;
+ const teamName = displayedTeam?.name || currentSession?.team_name || '';
+ const agentById = (agentId: string) => agents.find((agent) => agent.id === agentId) || null;
+ const summary = displayedTeam?.description?.trim()
+ || `团队由 ${members.length} 名成员组成,TL 是 ${leader?.agent_name || '未设置'}`;
+ const memberTags = members.slice(0, 5).map((member) => (
+ member.role === 'leader'
+ ? `${member.agent_name || '未设置'} · TL`
+ : member.agent_name || '未设置'
+ ));
+ const stats = [
+ { label: '成员数', value: members.length },
+ { label: '任务数', value: teamEmptyStats.tasks },
+ { label: '黑板条目数', value: teamEmptyStats.blackboard },
+ ];
+
+ return (
+
+
+
+
+
+ {members.slice(0, 3).map((member) => (
+
+ ))}
+
+
+
+
+ Hello 我们是{teamName}!
+
+ 我们来做什么?
+
+
+
+
+
+
+
{summary}
+
+ {memberTags.map((tag, index) => (
+ {tag}
+ ))}
+
+
+
+ {stats.map((item) => (
+
+ {item.value}
+ {item.label}
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend-enterprise/src/pages/chat/components/ChatHeader.test.tsx b/frontend-enterprise/src/pages/chat/components/ChatHeader.test.tsx
new file mode 100644
index 00000000..c303cc54
--- /dev/null
+++ b/frontend-enterprise/src/pages/chat/components/ChatHeader.test.tsx
@@ -0,0 +1,88 @@
+// @vitest-environment jsdom
+
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { I18nProvider } from '@/i18n';
+import type { ChatSession, TeamRead } from '@/types';
+
+import type { UseChatSession } from '../useChatSession';
+import ChatHeader from './ChatHeader';
+
+const team: TeamRead = {
+ id: 'team-1',
+ tenant_id: 'tenant_demo',
+ name: '增长团队',
+ description: '',
+ owner_user_id: 'user-1',
+ config: {},
+ status: 'active',
+ members: [],
+ created_at: '2026-08-01T00:00:00Z',
+ updated_at: '2026-08-01T00:00:00Z',
+};
+
+function jsonResponse(body: unknown): Response {
+ return {
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ text: async () => JSON.stringify(body ?? {}),
+ } as Response;
+}
+
+function buildChat(session: Partial
): UseChatSession {
+ return {
+ auth: null,
+ currentSession: {
+ id: 'session-1',
+ tenant_id: 'tenant_demo',
+ status: 'active',
+ updated_at: '2026-08-01T00:00:00Z',
+ ...session,
+ } as ChatSession,
+ openRename: vi.fn(),
+ logout: vi.fn(),
+ } as unknown as UseChatSession;
+}
+
+function renderHeader(chat: UseChatSession) {
+ return render(
+
+
+ ,
+ );
+}
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+describe('ChatHeader team badge', () => {
+ it('shows the team badge from team_name when present', () => {
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse([team])));
+ renderHeader(buildChat({ title: '计划讨论', team_id: 'team-1', team_name: '增长团队' }));
+
+ expect(screen.getByText('团队 · 增长团队')).toBeTruthy();
+ });
+
+ it('resolves the team name from the teams list when team_name is missing', async () => {
+ const fetchMock = vi.fn(async () => jsonResponse([team]));
+ vi.stubGlobal('fetch', fetchMock);
+ renderHeader(buildChat({ title: '计划讨论', team_id: 'team-1' }));
+
+ expect((await screen.findByText('团队 · 增长团队')).textContent).toBeTruthy();
+ expect(fetchMock).toHaveBeenCalledWith(
+ expect.stringContaining('/api/enterprise/teams?tenant_id='),
+ expect.anything(),
+ );
+ });
+
+ it('renders no badge for regular employee sessions', () => {
+ vi.stubGlobal('fetch', vi.fn(async () => jsonResponse([team])));
+ renderHeader(buildChat({ title: '计划讨论' }));
+
+ expect(screen.queryByText(/团队/)).toBeNull();
+ });
+});
diff --git a/frontend-enterprise/src/pages/chat/components/ChatHeader.tsx b/frontend-enterprise/src/pages/chat/components/ChatHeader.tsx
index 728aa6b3..248a8622 100644
--- a/frontend-enterprise/src/pages/chat/components/ChatHeader.tsx
+++ b/frontend-enterprise/src/pages/chat/components/ChatHeader.tsx
@@ -1,10 +1,15 @@
+import { useEffect, useState } from 'react';
+
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
+import { Badge } from '@/components/ui';
+import { api, TENANT_ID } from '@/api/client';
import { staffdeckDisplayText } from '@/employee';
+import type { TeamRead } from '@/types';
import IconEdit from '@/assets/icons/edit.svg?react';
import IconChevronDown from '@/assets/icons/chevron-down.svg?react';
import IconLogout from '@/assets/icons/logout.svg?react';
@@ -23,11 +28,38 @@ export default function ChatHeader({ chat }: { chat: UseChatSession }) {
const username = auth?.user?.username || '';
const initial = username ? username.slice(0, 1).toUpperCase() : '--';
+ // 团队会话徽标:read 带 team_name 直接用;缺省时用团队列表做 id→name 映射。
+ const teamId = currentSession?.team_id || null;
+ const sessionTeamName = currentSession?.team_name || null;
+ const [teamName, setTeamName] = useState(sessionTeamName);
+
+ useEffect(() => {
+ setTeamName(sessionTeamName);
+ if (!teamId || sessionTeamName) return;
+ let cancelled = false;
+ api.get(`/api/enterprise/teams?tenant_id=${TENANT_ID}`)
+ .then((rows) => {
+ if (!cancelled) setTeamName(rows.find((team) => team.id === teamId)?.name || null);
+ })
+ .catch(() => {});
+ return () => {
+ cancelled = true;
+ };
+ }, [teamId, sessionTeamName]);
+
return (
{name}
+ {teamId && (
+
+ {teamName ? `团队 · ${teamName}` : '团队'}
+
+ )}
{currentSession && (