# 用户名修改 + 观察者标记 — 实施计划

> Plan agent 基于代码现状产出（不靠猜）。dev agent 可照着实施。

## 用户拍板的决策

| # | 决策 |
|---|------|
| 1 | 改用户名权限：超管+团管都能改、各管各的范围 |
| 2 | 观察者标记只能给团管打（普通成员先升团管） |
| 3 | 观察者视野：全部团队（含自己团队） |
| 4 | 观察者能下载（能看就能下） |

---

## Phase 0 — 现状事实表（plan agent 读完代码后确认）

### Backend

| Item | Truth |
|---|---|
| accounts 最高 migration | `0014_set_existing_admins_as_owners.py` → 下一个 **0015** |
| `User.role` property | `'super_admin'` / `'team_admin'` / `'member'` (`accounts/models.py:74-80`) |
| `AdminAuditLog.ACTION_CHOICES` | 13 actions 末尾 `('user_password_reset', '重置用户密码')` (`accounts/models.py:84-99`) |
| `UserSerializer` fields | `id, username, email, is_staff, is_team_admin, is_team_owner, role, team_name, must_change_password` (`accounts/serializers.py:14`) |
| `admin_user_detail_view` | GET only (`views.py:1535`) |
| `team_member_detail_view` | GET only (`views.py:2494`) |
| **照抄 5 步权限矩阵** | `team_reset_member_password_view` at `views.py:2683-2743` |
| **admin 账号保护** | `admin_user_status_view:1675` (`if user.username == 'admin': return 403`) |
| Username validator | Django `UnicodeUsernameValidator` 允许 `[\w.@+-]` Unicode → 中文 OK |
| 三个资产 endpoint | `views.py:2912 / 2961 / 3001` 全用 `[IsSuperAdmin]` |
| `log_admin_action` 签名 | `(request, action, target_type, target_id=None, target_name='', before=None, after=None)` (`accounts/models.py:243`) |

### Frontend

| Item | Truth |
|---|---|
| 内联编辑模板 | `TeamsPage.tsx:445-491` — `<span style={{display:'inline-flex', gap:6, alignItems:'center', flexWrap:'nowrap'}}>` + 保存/取消按 `whiteSpace:'nowrap'`（**没有可复用 InlineEdit 组件，就地写**） |
| `User` type (`types/index.ts:89`) | 已有 `is_team_owner?: boolean`，需加 `is_observer` |
| `ProtectedRoute` props | 已有 `requireAdmin / requireTeamAdmin / requireTeamMember`，需加 `requireAdminOrObserver` |
| `AdminLayout` nav | hardcoded `navItems` at top |
| `TeamAdminLayout` | 4 项 dashboard/members/records/assets（团队的） |
| UsersPage row | L228-271；username at L237 (`<button styles.usernameLink>`) |
| TeamMembersPage row | L174-231；username at L182（无 button） |

### AdminAssetsPage 的 ¥ 出现位置（精确）

1. `AdminAssetsPage.tsx:8` — `formatCost` helper
2. **L158** — 总费用统计卡 value
3. **L174** — team accordion badge
4. **L195** — member accordion badge
5. **L238** — no-team badge
6. **VideoDetailModal.tsx:548-554** — token + ¥ block（已确认 `assetVideoToTask` 不填 `tokensConsumed/costAmount`，**已经不渲染**，无需改）

---

## Phase A — 修改用户名（commit 1）

### 关键决策

| 决策 | 选择 | 理由 |
|---|---|---|
| `admin_user_detail` 加 PUT 还是新 endpoint | **新建 `admin_user_username_update_view`** | 1) detail GET 是聚合查询不能污染 2) 项目惯例：`/quota`、`/status`、`/reset-password` 都是专项写 endpoint 3) 审计/限流粒度好控 |
| 复用什么 inline edit | **就地按 TeamsPage:445-491 模板写**（不抽组件） | 项目无 `<InlineEdit>` 组件、本次不抽避免范围蔓延 |
| 5 步矩阵 | **照抄 `team_reset_member_password_view`** | 1) 同团队 2) 不能改自己 3) 不能改主管 4) 副管不能改副管 5) 走到合法 |

### Backend 改动

**文件 1：`backend/apps/accounts/models.py`** — `ACTION_CHOICES` 末尾加

```python
('user_username_update', '修改用户名'),
```

**文件 2：迁移**

```bash
cd backend && python manage.py makemigrations accounts -n add_username_update_audit_action
```

**文件 3：`backend/apps/generation/views.py`** — 新增两个 view（位置：靠近 `admin_reset_password_view` 之后；team 那个靠近 `team_reset_member_password_view` 之后）

```python
@api_view(['PATCH'])
@permission_classes([IsSuperAdmin])
def admin_user_username_update_view(request, user_id):
    """PATCH /api/v1/admin/users/<id>/username — 超管修改任意用户的用户名。"""
    try:
        user = User.objects.get(id=user_id)
    except User.DoesNotExist:
        return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND)

    if user.username == 'admin':
        return Response({'error': '不能修改超级管理员的用户名'}, status=status.HTTP_403_FORBIDDEN)

    new_username = (request.data.get('username') or '').strip()
    if not (3 <= len(new_username) <= 20):
        return Response({'error': '用户名长度需 3-20 个字符'}, status=status.HTTP_400_BAD_REQUEST)
    if new_username == user.username:
        return Response({'error': '新用户名与原用户名相同'}, status=status.HTTP_400_BAD_REQUEST)
    if User.objects.filter(username=new_username).exclude(id=user.id).exists():
        return Response({'error': '该用户名已被占用'}, status=status.HTTP_400_BAD_REQUEST)

    from django.core.exceptions import ValidationError as DjangoValidationError
    old_username = user.username
    user.username = new_username
    try:
        user.full_clean(exclude=['password'])
    except DjangoValidationError:
        return Response({'error': '用户名包含非法字符'}, status=status.HTTP_400_BAD_REQUEST)
    user.save(update_fields=['username'])

    log_admin_action(request, 'user_username_update', 'user',
                     target_id=user.id, target_name=new_username,
                     before={'username': old_username},
                     after={'username': new_username})
    return Response({'user_id': user.id, 'username': user.username})


@api_view(['PATCH'])
@permission_classes([IsTeamAdmin])
def team_member_username_update_view(request, member_id):
    """PATCH /api/v1/team/members/<id>/username — 团管修改本团队成员用户名。5 步矩阵。"""
    team = request.user.team
    if team is None:
        return Response({'error': '当前用户没有团队'}, status=status.HTTP_400_BAD_REQUEST)
    try:
        target = team.members.get(id=member_id)
    except User.DoesNotExist:
        return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND)

    operator = request.user
    if target.team_id != operator.team_id:
        return Response({'error': '不在同一团队'}, status=status.HTTP_403_FORBIDDEN)
    if target.id == operator.id:
        return Response({'error': '不能修改自己的用户名'}, status=status.HTTP_400_BAD_REQUEST)
    if target.username == 'admin':  # 兜底（admin 不在团队里所以理论不会进来，但保险加）
        return Response({'error': '不能修改超级管理员的用户名'}, status=status.HTTP_403_FORBIDDEN)
    if target.is_team_owner:
        return Response({'error': '不能修改主管理员的用户名'}, status=status.HTTP_403_FORBIDDEN)
    if target.is_team_admin and not operator.is_team_owner:
        return Response({'error': '只有主管理员能修改副管理员的用户名'}, status=status.HTTP_403_FORBIDDEN)

    new_username = (request.data.get('username') or '').strip()
    if not (3 <= len(new_username) <= 20):
        return Response({'error': '用户名长度需 3-20 个字符'}, status=status.HTTP_400_BAD_REQUEST)
    if new_username == target.username:
        return Response({'error': '新用户名与原用户名相同'}, status=status.HTTP_400_BAD_REQUEST)
    if User.objects.filter(username=new_username).exclude(id=target.id).exists():
        return Response({'error': '该用户名已被占用'}, status=status.HTTP_400_BAD_REQUEST)

    from django.core.exceptions import ValidationError as DjangoValidationError
    old_username = target.username
    target.username = new_username
    try:
        target.full_clean(exclude=['password'])
    except DjangoValidationError:
        return Response({'error': '用户名包含非法字符'}, status=status.HTTP_400_BAD_REQUEST)
    target.save(update_fields=['username'])

    log_admin_action(request, 'user_username_update', 'user',
                     target_id=target.id, target_name=new_username,
                     before={'username': old_username},
                     after={'username': new_username})
    return Response({'user_id': target.id, 'username': target.username})
```

**文件 4：`backend/apps/generation/urls.py`** — 加 2 条 route（位置：reset-password 那两行之后）

```python
path('admin/users/<int:user_id>/username', views.admin_user_username_update_view, name='admin_user_username_update'),
path('team/members/<int:member_id>/username', views.team_member_username_update_view, name='team_member_username_update'),
```

### Frontend 改动

**文件 5：`web/src/lib/api.ts`** — 加 2 方法

```typescript
// adminApi 段（resetUserPassword 之后）
updateUserUsername: (userId: number, username: string) =>
  api.patch<{ user_id: number; username: string }>(`/admin/users/${userId}/username`, { username }),

// teamApi 段（resetMemberPassword 之后）
updateMemberUsername: (memberId: number, username: string) =>
  api.patch<{ user_id: number; username: string }>(`/team/members/${memberId}/username`, { username }),
```

**文件 6：`web/src/pages/UsersPage.tsx`** — 用户名 cell 加内联编辑（L237 附近）

state：`const [editingUsernameId, setEditingUsernameId] = useState<number|null>(null); const [editingUsernameValue, setEditingUsernameValue] = useState('');`

显示态：保留现有 `<button>` 用户名链接，旁边加铅笔小图标按钮触发编辑

编辑态：`<input value={editingUsernameValue} />` + 保存按钮 + 取消按钮（容器 `display:'inline-flex', gap:6, alignItems:'center', flexWrap:'nowrap'`，**保存/取消按钮 `whiteSpace:'nowrap'` 强制不换行**）

**前端不显示编辑按钮的情况**：`u.username === 'admin'`（admin 行只读）

**文件 7：`web/src/pages/TeamMembersPage.tsx`** — 用户名 cell 同上（L182 附近）

加 helper：

```typescript
function canEditUsernameFor(m: TeamMember, op: User | null): boolean {
  if (!op || !m) return false;
  if (m.id === op.id) return false;
  if (m.username === 'admin') return false;
  if (m.is_team_owner) return false;
  if (m.is_team_admin && !op.is_team_owner) return false;
  return true;
}
```

前端只在 `canEditUsernameFor` 返回 true 时显示编辑按钮（后端永远是真相）。

Toast：成功 `已更新用户名为「xxx」`，失败显示 `err.response?.data?.error || '操作失败'`

### Phase A 验收 cases

| # | Scenario | 期望 |
|---|---|---|
| A1 | 超管把 `tudou` → `豆豆`（中文） | 200, audit log `user_username_update` |
| A2 | 超管改 `admin` | 403 `不能修改超级管理员的用户名` |
| A3 | 超管设新名 = 已存在 | 400 `该用户名已被占用` |
| A4 | 超管设新名 = `ab`（2 字） | 400 `长度需 3-20` |
| A5 | 团管（主管）改自己 | 400 `不能修改自己的用户名` |
| A6 | 副管改另一个副管 | 403 `只有主管理员能修改副管理员` |
| A7 | 团管 A 提交团队 B 的 member_id | 404 `成员不存在` |
| A8 | 主管改副管 → 成员 | 200，前端列表实时刷新 |

---

## Phase B — 观察者标记（commit 2）

### 关键决策

| 决策 | 选择 | 理由 |
|---|---|---|
| 「设观察者」入口放哪 | **`UsersPage.tsx` 用户行操作列** | 1) 编辑用户角色属性最自然在用户管理 2) TeamsPage 团队详情入口太深 3) 已有 reset密码/编辑/禁用 3 个 row-level 操作，加这个语义一致 |
| 校验目标 | **后端硬拒绝非团管 + 拒绝超管自己** | 避免数据库里有半生效状态 |

### Backend 改动

**文件 1：`backend/apps/accounts/models.py`**

```python
# User 类内（is_team_owner 那行之后）
is_observer = models.BooleanField(default=False, verbose_name='观察者（仅对团管生效，可看全局资产）')

# ACTION_CHOICES 加（在 A 加的 user_username_update 之后）
('user_observer_toggle', '切换观察者标记'),
```

**文件 2：迁移**

```bash
cd backend && python manage.py makemigrations accounts -n user_is_observer
cd backend && python manage.py migrate
```

**MySQL 严格模式提醒** — grep 验证：

```bash
grep -rn "User(" backend/apps/ | grep "save()" | grep -v migrations
```

如有裸 `User(**data).save()` 调用点需审；BooleanField default=False 无 NULL 风险。

**文件 3：`backend/apps/accounts/serializers.py`**

```python
fields = ('id', 'username', 'email', 'is_staff', 'is_team_admin', 'is_team_owner',
          'is_observer',  # ← 新增
          'role', 'team_name', 'must_change_password')
```

**文件 4：`backend/apps/accounts/permissions.py`** — 新 class

```python
class IsSuperAdminOrObserver(BasePermission):
    """超级管理员 或 团队管理员且 is_observer=True"""
    def has_permission(self, request, view):
        u = request.user
        if not (u and u.is_authenticated):
            return False
        if u.is_staff and u.team is None:
            return True
        if u.is_team_admin and u.team is not None and getattr(u, 'is_observer', False):
            return True
        return False
```

**文件 5：`backend/apps/generation/views.py`**

5a. 3 个资产 endpoint 权限替换（`views.py:2912 / 2961 / 3001`）：

```python
@permission_classes([IsSuperAdminOrObserver])  # 替换 IsSuperAdmin
```

并把 `IsSuperAdminOrObserver` 加入 import 行：

```python
from apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember, IsSuperAdminOrObserver
```

5b. 新 endpoint：

```python
@api_view(['PATCH'])
@permission_classes([IsSuperAdmin])
def admin_user_observer_toggle_view(request, user_id):
    """PATCH /api/v1/admin/users/<id>/observer — 仅超管，把团管标记为观察者。"""
    try:
        target = User.objects.get(id=user_id)
    except User.DoesNotExist:
        return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND)

    if not (target.is_team_admin and target.team_id is not None):
        return Response({'error': '观察者标记只能给团队管理员'}, status=status.HTTP_400_BAD_REQUEST)
    if target.is_staff and target.team_id is None:
        return Response({'error': '超级管理员无需设观察者'}, status=status.HTTP_400_BAD_REQUEST)

    is_observer = request.data.get('is_observer')
    if is_observer is None:
        return Response({'error': '请提供 is_observer 参数'}, status=status.HTTP_400_BAD_REQUEST)
    new_val = bool(is_observer)
    old_val = target.is_observer
    if old_val == new_val:
        return Response({'user_id': target.id, 'username': target.username, 'is_observer': new_val})
    target.is_observer = new_val
    target.save(update_fields=['is_observer'])

    log_admin_action(request, 'user_observer_toggle', 'user',
                     target_id=target.id, target_name=target.username,
                     before={'is_observer': old_val},
                     after={'is_observer': new_val})
    return Response({'user_id': target.id, 'username': target.username, 'is_observer': new_val})
```

**文件 6：`backend/apps/generation/urls.py`**

```python
path('admin/users/<int:user_id>/observer', views.admin_user_observer_toggle_view, name='admin_user_observer_toggle'),
```

### Frontend 改动

**文件 7：`web/src/types/index.ts`** — 3 处加 `is_observer`

```typescript
// L89 User
is_observer?: boolean;

// L164 附近 AdminUser
is_observer?: boolean;

// TeamMember（如有）也加，便于团管页面识别
```

**文件 8：`web/src/lib/api.ts`**

```typescript
toggleUserObserver: (userId: number, isObserver: boolean) =>
  api.patch<{ user_id: number; username: string; is_observer: boolean }>(
    `/admin/users/${userId}/observer`, { is_observer: isObserver }),
```

**文件 9：`web/src/components/ProtectedRoute.tsx`** — 加 prop + 智能 fallback

```typescript
interface Props {
  children: React.ReactNode;
  requireAdmin?: boolean;
  requireAdminOrObserver?: boolean;  // ← 新
  requireTeamAdmin?: boolean;
  requireTeamMember?: boolean;
}

// 在 requireAdmin 判断段加智能 fallback
if (requireAdmin && user?.role !== 'super_admin') {
  if (user?.role === 'team_admin') return <Navigate to="/team/dashboard" replace />;
  return <Navigate to="/app" replace />;
}

if (requireAdminOrObserver) {
  const isAdmin = user?.role === 'super_admin';
  const isObserverTeamAdmin = user?.role === 'team_admin' && user?.is_observer;
  if (!isAdmin && !isObserverTeamAdmin) {
    if (user?.role === 'team_admin') return <Navigate to="/team/dashboard" replace />;
    return <Navigate to="/app" replace />;
  }
}
```

**文件 10：`web/src/App.tsx`** — 路由分级守卫

```tsx
<Route
  path="/admin"
  element={
    <ProtectedRoute requireAdminOrObserver>
      <AdminLayout />
    </ProtectedRoute>
  }
>
  <Route index element={<RoleAwareIndexRedirect />} />  {/* 见下方 */}
  <Route path="dashboard" element={<ProtectedRoute requireAdmin><DashboardPage /></ProtectedRoute>} />
  <Route path="users" element={<ProtectedRoute requireAdmin><UsersPage /></ProtectedRoute>} />
  {/* ... 所有其他 admin 子页面都用 requireAdmin 包 ... */}
  <Route path="assets" element={<AdminAssetsPage />} />  {/* 父路由的 requireAdminOrObserver 已 cover */}
</Route>
```

`RoleAwareIndexRedirect` 是小辅助组件：

```tsx
function RoleAwareIndexRedirect() {
  const user = useAuthStore((s) => s.user);
  if (user?.role === 'team_admin' && user?.is_observer) {
    return <Navigate to="/admin/assets" replace />;
  }
  return <Navigate to="/admin/dashboard" replace />;
}
```

**文件 11：`web/src/pages/AdminLayout.tsx`** — sidebar 按角色过滤

```tsx
const user = useAuthStore((s) => s.user);
const isObserverOnly = user?.role === 'team_admin' && !!user?.is_observer;

const visibleNavItems = isObserverOnly
  ? navItems.filter(i => i.path === '/admin/assets')
  : navItems;

// 渲染 visibleNavItems 替代 navItems

// logo 文案：isObserverOnly 时显示「观察者」替代「管理后台」
// 「返回首页」按钮：isObserverOnly 时改跳 /team/dashboard 并改 label「返回团队管理」
```

**文件 12：`web/src/pages/TeamAdminLayout.tsx`** — sidebar 末尾加「全局资产」入口（仅观察者可见）

```tsx
const user = useAuthStore((s) => s.user);
const isObserver = !!user?.is_observer;

// 在现有 4 个 nav 之后加一个额外按钮（不在 navItems 数组中，因为跳的是不同 layout）
{isObserver && (
  <button className={styles.navItem} onClick={() => navigate('/admin/assets')}>
    <GlobeIcon /> <span>全局资产</span>
  </button>
)}
```

**文件 13：`web/src/pages/AdminAssetsPage.tsx`** — 4 处 ¥ 条件渲染

```tsx
const user = useAuthStore((s) => s.user);
const hideMoney = user?.role !== 'super_admin';  // observer 团管 = 非超管 = 隐藏

// L158 总费用统计卡（整张条件渲染）
{!hideMoney && (
  <div className={styles.statCard}>
    <div className={styles.statLabel}>总费用</div>
    <div className={styles.statValue}>{formatCost(overview.total_seconds)}</div>
  </div>
)}

// L174 team badge
{!hideMoney && <span className={styles.accordionBadge}>{formatCost(team.cost_consumed ?? team.seconds_consumed)}</span>}

// L195 member badge — 同 L174

// L238 no-team badge — 同 L174
```

`VideoDetailModal` 不动（`assetVideoToTask` 已经不填 tokensConsumed/costAmount，¥ 行已经不渲染）。**在 `assetVideoToTask` 上方加注释**：

```tsx
// 不传 tokensConsumed/costAmount — 观察者团管隐藏 ¥ 依赖此默认行为
```

**文件 14：`web/src/pages/UsersPage.tsx`** — 行加切换按钮 + observer badge

```tsx
{u.is_team_admin && u.team_id && (
  <button
    className={styles.editBtn}
    onClick={async () => {
      try {
        await adminApi.toggleUserObserver(u.id, !u.is_observer);
        showToast(
          u.is_observer
            ? `已取消「${u.username}」的观察者标记`
            : `已将「${u.username}」设为观察者（需该用户重新登录后生效）`
        );
        fetchUsers();
      } catch (e: any) {
        showToast(e.response?.data?.error || '操作失败');
      }
    }}
  >
    {u.is_observer ? '取消观察者' : '设为观察者'}
  </button>
)}

// 用户名/团队 cell 后加 badge
{u.is_observer && (
  <span className={styles.statusBadge} style={{ background: 'var(--color-info-bg)', color: 'var(--color-info)', marginLeft: 6 }}>
    观察者
  </span>
)}
```

### Phase B 验收 cases

| # | Scenario | 期望 |
|---|---|---|
| B1 | 超管 PATCH 把 `tudou`（团管）`is_observer=true` | 200, DB 更新, audit log `user_observer_toggle` |
| B2 | 超管对普通成员设 | 400 `观察者标记只能给团队管理员` |
| B3 | 超管对 `admin` 自己设 | 400 `超级管理员无需设观察者` |
| B4 | `tudou`（is_observer=true）GET `/admin/assets/overview` | 200 全局数据 |
| B5 | `tudou`（is_observer=false）GET `/admin/assets/overview` | 403 |
| B6 | 普通成员 `bob` GET `/admin/assets/overview` | 403 |
| B7 | 观察者 `tudou` 登录后 `/auth/me` 返回 `is_observer: true` → sidebar 出现「全局资产」入口 | 视觉验证 |
| B8 | 观察者打开 `/admin/assets`：3 张统计卡少 1 张，所有 badge 仅剩「N 个视频」（**无 ¥**） | 视觉验证 |
| B9 | 观察者点视频详情：info bar 无 tokens/¥ | 视觉验证 |
| B10 | 观察者尝试访问 `/admin/users` | 被 `requireAdmin` 拒，自动 302 到 `/team/dashboard`（团管 fallback） |

---

## 9 个关键风险点

1. **admin 账号在 team_member 那条线也得兜底拒**（已在 view 内加 `if target.username == 'admin'`）
2. **副管/主管判断顺序**：先 `is_team_owner` 再 `is_team_admin`（照抄 `team_reset_member_password_view:2717-2722`）
3. **MySQL 严格模式**：`is_observer` BooleanField default=False 无风险；但需 grep 验证无裸 `User(**data).save()` 用法
4. **JWT token 不缓存 `is_observer`**，但**前端 `auth.user.is_observer` 是登录时拉的快照** → 观察者标记切换后那个用户必须重新登录或刷新 `/auth/me` 才能在自己客户端看到「全局资产」入口。UsersPage toast 已写「需该用户重新登录后生效」
5. **`/admin/users` 等子路由的 race**：父路由 `requireAdminOrObserver`，子页面分别用 `requireAdmin` 拦截（观察者团管根本进不去子页面渲染）
6. **普通团管被 `requireAdmin` 拒** → 弹回 `/team/dashboard` 不是 `/app`（ProtectedRoute 智能 fallback）
7. **迁移编号**：A=0015, B=0016（已确认 accounts 当前最高 0014）
8. **观察者下载**：用户决策「能看就能下」→ VideoDetailModal 已有下载按钮保留不动
9. **空白/前后空格**：已 `.strip()`；中文/特殊字符按 `UnicodeUsernameValidator` `full_clean()` 自动处理；不另加禁止

---

## 实施顺序（两个 commit，A 不依赖 B）

```
commit 1 (Phase A 修改用户名):
- backend: models.py choices, views.py 2 view, urls.py 2 行, migration 0015
- frontend: api.ts 2 方法, UsersPage.tsx inline edit, TeamMembersPage.tsx inline edit
- 跑测试: cd backend && python manage.py check && python manage.py migrate
        cd web && npm run build && npx vitest run
- 跑 curl A1-A8 8 项验收

commit 2 (Phase B 观察者):
- backend: models.py 字段+choice, migration 0016, serializers.py, permissions.py,
          views.py 3 endpoint 权限改 + 1 新 view, urls.py 1 行
- frontend: types/index.ts 3 处 is_observer, api.ts 1 方法, 
          ProtectedRoute.tsx 新 prop + 智能 fallback, App.tsx 路由分级守卫,
          AdminLayout.tsx 角色过滤, TeamAdminLayout.tsx 加全局资产入口,
          AdminAssetsPage.tsx 4 处 ¥ 条件渲染, UsersPage.tsx 切换按钮 + badge
- 跑测试: 同上
- 跑 curl B1-B10 10 项验收
```
