feat: 完善代理商端业务逻辑与前后端框架

主要更新:
- 更新代理商端文档,明确项目由品牌方分配流程
- 新增Brief配置详情页(已配置)设计稿
- 完善工作台紧急待办中品牌新任务功能
- 整理Pencil设计文件中代理商端页面顺序
- 新增后端FastAPI框架及核心API
- 新增前端Next.js页面和组件库
- 添加.gitignore排除构建和缓存文件

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-05 19:27:31 +08:00
co-authored by Claude Opus 4.5
parent d52509d630
commit e4959d584f
132 changed files with 58539 additions and 21353 deletions
@@ -0,0 +1,61 @@
/**
* BottomNav 组件测试
* 测试覆盖: role 渲染、active 状态、基础样式
*/
import { render, screen } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { usePathname } from 'next/navigation';
import { BottomNav } from './BottomNav';
const mockedUsePathname = vi.mocked(usePathname);
describe('BottomNav', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染导航栏', () => {
const { container } = render(<BottomNav />);
expect(container.firstChild).toBeInTheDocument();
});
it('渲染所有导航项', () => {
render(<BottomNav role="creator" />);
expect(screen.getByText('任务')).toBeInTheDocument();
expect(screen.getByText('消息')).toBeInTheDocument();
expect(screen.getByText('我的')).toBeInTheDocument();
});
it('渲染图标', () => {
const { container } = render(<BottomNav />);
const icons = container.querySelectorAll('svg');
expect(icons.length).toBeGreaterThan(0);
});
});
// ==================== Active 状态测试 ====================
describe('Active 状态', () => {
beforeEach(() => {
mockedUsePathname.mockReturnValue('/creator/messages');
});
it('激活项使用高亮颜色', () => {
render(<BottomNav role="creator" />);
const activeLink = screen.getByText('消息').closest('a');
expect(activeLink).toHaveClass('text-text-primary');
});
it('非激活项使用次要颜色', () => {
render(<BottomNav role="creator" />);
const inactiveLink = screen.getByText('任务').closest('a');
expect(inactiveLink).toHaveClass('text-text-secondary');
});
});
// ==================== 样式测试 ====================
describe('样式', () => {
it('固定定位在底部', () => {
const { container } = render(<BottomNav />);
const root = container.firstChild as HTMLElement;
expect(root).toHaveClass('fixed', 'bottom-0', 'left-0', 'right-0');
});
});
});
+80 -72
View File
@@ -1,81 +1,89 @@
/**
* BottomNav 底部导航组件 (移动端)
* 设计稿参考: UIDesignSpec.md 3.6
*/
import React from 'react';
import { LucideIcon } from 'lucide-react';
'use client'
export interface NavItem {
id: string;
label: string;
icon: LucideIcon;
href?: string;
badge?: number;
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { ClipboardList, Bell, User, Scan, ListTodo, LayoutDashboard, Settings } from 'lucide-react'
import { cn } from '@/lib/utils'
interface NavItem {
icon: React.ElementType
label: string
href: string
}
export interface BottomNavProps {
items: NavItem[];
activeId: string;
onItemClick?: (id: string) => void;
className?: string;
// 达人端导航项
const creatorNavItems: NavItem[] = [
{ icon: ClipboardList, label: '任务', href: '/creator' },
{ icon: Bell, label: '消息', href: '/creator/messages' },
{ icon: User, label: '我的', href: '/creator/profile' },
]
// 代理商端导航项
const agencyNavItems: NavItem[] = [
{ icon: LayoutDashboard, label: '工作台', href: '/agency' },
{ icon: ListTodo, label: '任务', href: '/agency/tasks' },
{ icon: Scan, label: '审核', href: '/agency/review' },
{ icon: Bell, label: '消息', href: '/agency/messages' },
{ icon: User, label: '我的', href: '/agency/profile' },
]
// 品牌方端导航项
const brandNavItems: NavItem[] = [
{ icon: LayoutDashboard, label: '看板', href: '/brand' },
{ icon: Settings, label: '配置', href: '/brand/rules' },
{ icon: Bell, label: '消息', href: '/brand/messages' },
{ icon: User, label: '我的', href: '/brand/profile' },
]
interface BottomNavProps {
role?: 'creator' | 'agency' | 'brand'
}
export const BottomNav: React.FC<BottomNavProps> = ({
items,
activeId,
onItemClick,
className = '',
}) => {
export function BottomNav({ role = 'creator' }: BottomNavProps) {
const pathname = usePathname() || ''
const navItems = role === 'creator'
? creatorNavItems
: role === 'agency'
? agencyNavItems
: brandNavItems
const isActive = (href: string) => {
if (href === `/${role}`) {
return pathname === href || pathname === `/${role}/`
}
return pathname.startsWith(href)
}
return (
<nav
className={`
fixed bottom-0 left-0 right-0 z-bottom-nav
flex justify-around items-center
h-bottom-nav px-[21px] py-3
safe-area-bottom
${className}
`}
style={{
background: 'linear-gradient(180deg, transparent 0%, #0B0B0E 50%)',
}}
>
{items.map((item) => {
const isActive = item.id === activeId;
const Icon = item.icon;
<div className="fixed bottom-0 left-0 right-0 z-bottom-nav bottom-nav-gradient h-[95px] flex flex-col justify-end px-[21px] pb-[21px] pt-3">
<div className="flex items-center justify-around bg-bg-elevated rounded-[31px] h-[62px] p-1 nav-shadow border border-border-subtle">
{navItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
return (
<button
key={item.id}
onClick={() => onItemClick?.(item.id)}
className={`
flex flex-col items-center gap-1
transition-colors duration-200
${isActive ? 'text-accent-indigo' : 'text-text-secondary'}
`}
>
<div className="relative">
<Icon size={24} />
{item.badge !== undefined && item.badge > 0 && (
<span
className="
absolute -top-1 -right-1
min-w-[16px] h-4 px-1
flex items-center justify-center
bg-accent-coral text-white
text-[10px] font-semibold
rounded-full
"
>
{item.badge > 99 ? '99+' : item.badge}
</span>
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex flex-col items-center justify-center gap-1 w-14 h-full',
active ? 'text-text-primary' : 'text-text-secondary'
)}
</div>
<span className="text-nav">{item.label}</span>
</button>
);
})}
</nav>
);
};
>
<Icon className={cn('w-6 h-6', active && 'text-text-primary')} strokeWidth={active ? 2 : 1.5} />
<span className={cn(
'text-[10px]',
active ? 'font-semibold' : 'font-medium'
)}>
{item.label}
</span>
</Link>
)
})}
</div>
</div>
)
}
export default BottomNav;
export default BottomNav
@@ -0,0 +1,83 @@
/**
* Sidebar 组件测试
* 测试覆盖: role 渲染、active 状态、基础样式
*/
import { render, screen } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { usePathname } from 'next/navigation';
import { Sidebar } from './Sidebar';
const mockedUsePathname = vi.mocked(usePathname);
describe('Sidebar', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染侧边栏', () => {
const { container } = render(<Sidebar />);
expect(container.querySelector('aside')).toBeInTheDocument();
});
it('渲染默认 creator 导航项', () => {
render(<Sidebar role="creator" />);
expect(screen.getByText('我的任务')).toBeInTheDocument();
expect(screen.getByText('消息中心')).toBeInTheDocument();
expect(screen.getByText('个人中心')).toBeInTheDocument();
});
});
// ==================== Role 测试 ====================
describe('Role', () => {
it('渲染 agency 导航项', () => {
render(<Sidebar role="agency" />);
expect(screen.getByText('工作台')).toBeInTheDocument();
expect(screen.getByText('审核决策')).toBeInTheDocument();
expect(screen.getByText('Brief 配置')).toBeInTheDocument();
expect(screen.getByText('达人管理')).toBeInTheDocument();
expect(screen.getByText('数据报表')).toBeInTheDocument();
});
it('渲染 brand 导航项', () => {
render(<Sidebar role="brand" />);
expect(screen.getByText('数据看板')).toBeInTheDocument();
expect(screen.getByText('AI 配置')).toBeInTheDocument();
expect(screen.getByText('规则配置')).toBeInTheDocument();
expect(screen.getByText('终审台')).toBeInTheDocument();
expect(screen.getByText('代理商管理')).toBeInTheDocument();
});
});
// ==================== Active 状态测试 ====================
describe('Active 状态', () => {
beforeEach(() => {
mockedUsePathname.mockReturnValue('/creator/messages');
});
it('激活项使用高亮样式', () => {
render(<Sidebar role="creator" />);
const activeLink = screen.getByText('消息中心').closest('a');
expect(activeLink).toHaveClass('bg-bg-elevated', 'text-text-primary', 'font-semibold');
});
it('非激活项使用默认样式', () => {
render(<Sidebar role="creator" />);
const inactiveLink = screen.getByText('我的任务').closest('a');
expect(inactiveLink).toHaveClass('text-text-secondary');
expect(inactiveLink).not.toHaveClass('text-text-primary');
});
});
// ==================== 样式测试 ====================
describe('样式', () => {
it('固定定位在左侧', () => {
const { container } = render(<Sidebar />);
const aside = container.querySelector('aside');
expect(aside).toHaveClass('fixed', 'left-0', 'top-0', 'bottom-0');
});
it('应用正确宽度', () => {
const { container } = render(<Sidebar />);
const aside = container.querySelector('aside');
expect(aside).toHaveClass('w-[260px]');
});
});
});
+85 -123
View File
@@ -1,136 +1,98 @@
/**
* Sidebar 侧边栏导航组件 (桌面端)
* 设计稿参考: UIDesignSpec.md 3.7
*/
import React from 'react';
import { LucideIcon } from 'lucide-react';
'use client'
export interface SidebarItem {
id: string;
label: string;
icon: LucideIcon;
href?: string;
badge?: number;
children?: SidebarItem[];
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { ShieldCheck, ListTodo, User, LayoutDashboard, Scan, BarChart3, Settings, FileText, Users, Bell } from 'lucide-react'
import { cn } from '@/lib/utils'
interface NavItem {
icon: React.ElementType
label: string
href: string
}
export interface SidebarSection {
title?: string;
items: SidebarItem[];
// 达人端导航项
const creatorNavItems: NavItem[] = [
{ icon: ListTodo, label: '我的任务', href: '/creator' },
{ icon: Bell, label: '消息中心', href: '/creator/messages' },
{ icon: User, label: '个人中心', href: '/creator/profile' },
]
// 代理商端导航项
const agencyNavItems: NavItem[] = [
{ icon: LayoutDashboard, label: '工作台', href: '/agency' },
{ icon: Scan, label: '审核决策', href: '/agency/review' },
{ icon: FileText, label: 'Brief 配置', href: '/agency/briefs' },
{ icon: Users, label: '达人管理', href: '/agency/creators' },
{ icon: BarChart3, label: '数据报表', href: '/agency/reports' },
]
// 品牌方端导航项
const brandNavItems: NavItem[] = [
{ icon: LayoutDashboard, label: '数据看板', href: '/brand' },
{ icon: Settings, label: 'AI 配置', href: '/brand/ai-config' },
{ icon: FileText, label: '规则配置', href: '/brand/rules' },
{ icon: FileCheck, label: '终审台', href: '/brand/final-review' },
{ icon: Users, label: '代理商管理', href: '/brand/agencies' },
]
interface SidebarProps {
role?: 'creator' | 'agency' | 'brand'
}
export interface SidebarProps {
logo?: React.ReactNode;
sections: SidebarSection[];
activeId: string;
onItemClick?: (id: string) => void;
footer?: React.ReactNode;
className?: string;
}
export function Sidebar({ role = 'creator' }: SidebarProps) {
const pathname = usePathname() || ''
const navItems = role === 'creator'
? creatorNavItems
: role === 'agency'
? agencyNavItems
: brandNavItems
const isActive = (href: string) => {
if (href === `/${role}`) {
return pathname === href || pathname === `/${role}/`
}
return pathname.startsWith(href)
}
export const Sidebar: React.FC<SidebarProps> = ({
logo,
sections,
activeId,
onItemClick,
footer,
className = '',
}) => {
return (
<aside
className={`
fixed left-0 top-0 bottom-0 z-sidebar
w-sidebar bg-bg-card
flex flex-col
border-r border-border-subtle
${className}
`}
>
{/* Logo */}
{logo && (
<div className="px-4 py-5 border-b border-border-subtle">
{logo}
<aside className="fixed left-0 top-0 bottom-0 z-sidebar w-[260px] bg-bg-card flex flex-col">
{/* Logo 区域 */}
<div className="flex items-center gap-3 px-6 py-6">
<div className="w-9 h-9 rounded-[10px] bg-gradient-to-br from-accent-indigo to-[#4F46E5] flex items-center justify-center">
<ShieldCheck className="w-5 h-5 text-white" />
</div>
)}
<span className="text-xl font-bold text-text-primary"></span>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-4 px-3">
{sections.map((section, sectionIndex) => (
<div key={sectionIndex} className="mb-6">
{section.title && (
<h4 className="px-3 mb-2 text-small text-text-tertiary uppercase tracking-wider">
{section.title}
</h4>
)}
<ul className="space-y-1">
{section.items.map((item) => (
<SidebarNavItem
key={item.id}
item={item}
isActive={item.id === activeId}
onClick={() => onItemClick?.(item.id)}
/>
))}
</ul>
</div>
))}
{/* 导航列表 */}
<nav className="flex-1 px-4 py-2">
<div className="flex flex-col gap-1">
{navItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 px-4 py-3 rounded-[10px] transition-colors',
active
? 'bg-bg-elevated text-text-primary font-semibold'
: 'text-text-secondary hover:bg-bg-elevated/50'
)}
>
<Icon className="w-5 h-5" />
<span className="text-[15px]">{item.label}</span>
</Link>
)
})}
</div>
</nav>
{/* Footer */}
{footer && (
<div className="px-4 py-4 border-t border-border-subtle">
{footer}
</div>
)}
</aside>
);
};
interface SidebarNavItemProps {
item: SidebarItem;
isActive: boolean;
onClick: () => void;
)
}
const SidebarNavItem: React.FC<SidebarNavItemProps> = ({
item,
isActive,
onClick,
}) => {
const Icon = item.icon;
return (
<li>
<button
onClick={onClick}
className={`
w-full flex items-center gap-2.5
px-3 py-2.5 rounded-btn
transition-colors duration-200
${isActive
? 'bg-bg-elevated text-accent-indigo font-semibold'
: 'text-text-secondary hover:bg-bg-elevated'
}
`}
>
<Icon size={20} className="flex-shrink-0" />
<span className="flex-1 text-left text-body">{item.label}</span>
{item.badge !== undefined && item.badge > 0 && (
<span
className="
min-w-[20px] h-5 px-1.5
flex items-center justify-center
bg-accent-coral text-white
text-[11px] font-semibold
rounded-full
"
>
{item.badge > 99 ? '99+' : item.badge}
</span>
)}
</button>
</li>
);
};
export default Sidebar;
export default Sidebar
@@ -0,0 +1,55 @@
/**
* StatusBar 组件测试
* 测试覆盖: 时间显示、状态图标、自定义样式
*/
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { StatusBar } from './StatusBar';
describe('StatusBar', () => {
// ==================== 基础渲染测试 ====================
describe('基础渲染', () => {
it('渲染状态栏', () => {
const { container } = render(<StatusBar />);
expect(container.firstChild).toBeInTheDocument();
});
it('默认显示时间 9:41', () => {
render(<StatusBar />);
expect(screen.getByText('9:41')).toBeInTheDocument();
});
it('渲染状态图标(信号、WiFi、电池)', () => {
const { container } = render(<StatusBar />);
const icons = container.querySelectorAll('svg');
expect(icons).toHaveLength(3);
});
});
// ==================== Time 属性测试 ====================
describe('Time 属性', () => {
it('支持自定义时间', () => {
render(<StatusBar time="10:30" />);
expect(screen.getByText('10:30')).toBeInTheDocument();
});
it('时间使用正确样式', () => {
render(<StatusBar time="12:00" />);
const timeElement = screen.getByText('12:00');
expect(timeElement).toHaveClass('text-text-primary', 'font-semibold', 'text-[17px]');
});
});
// ==================== 样式测试 ====================
describe('样式', () => {
it('应用固定高度', () => {
const { container } = render(<StatusBar />);
expect(container.firstChild).toHaveClass('h-[44px]');
});
it('支持自定义 className', () => {
const { container } = render(<StatusBar className="custom-status" />);
expect(container.firstChild).toHaveClass('custom-status');
});
});
});
+16 -32
View File
@@ -1,41 +1,25 @@
/**
* StatusBar 状态栏组件 (移动端)
* 设计稿参考: UIDesignSpec.md 3.1
*/
import React from 'react';
import { Signal, Wifi, BatteryFull } from 'lucide-react';
'use client'
export interface StatusBarProps {
time?: string;
className?: string;
import { Signal, Wifi, BatteryFull } from 'lucide-react'
interface StatusBarProps {
time?: string
className?: string
}
export const StatusBar: React.FC<StatusBarProps> = ({
time = '9:41',
className = '',
}) => {
export function StatusBar({ time = '9:41', className = '' }: StatusBarProps) {
return (
<div
className={`
flex items-center justify-between
h-status-bar px-6
bg-bg-page safe-area-top
${className}
`}
>
{/* Time */}
<span className="text-body font-semibold text-text-primary">
<div className={`flex items-center justify-between h-[44px] px-6 w-full ${className}`}>
<span className="text-text-primary font-semibold text-[17px]" style={{ fontFamily: 'Inter' }}>
{time}
</span>
{/* Status Icons */}
<div className="flex items-center gap-1">
<Signal size={16} className="text-text-primary" />
<Wifi size={16} className="text-text-primary" />
<BatteryFull size={16} className="text-text-primary" />
<div className="flex items-center gap-1.5">
<Signal className="w-[18px] h-[18px] text-text-primary" />
<Wifi className="w-[18px] h-[18px] text-text-primary" />
<BatteryFull className="w-6 h-[18px] text-text-primary" />
</div>
</div>
);
};
)
}
export default StatusBar;
export default StatusBar