# 公司列表三栏布局设计文档

**日期：** 2026-03-25
**状态：** 已批准
**影响页面：** `/companies` → `CompanyListView.vue`

---

## 目标

将现有 `/companies` 页面从网格卡片布局改造为三栏联动布局，用户可在同一页面浏览公司、查看岗位列表、阅读岗位详情，无需跳转。

---

## 布局结构

```
┌─────────────────────────────────────────────────────┐
│  左栏 220px  │  中栏 260px  │  右栏 flex:1           │
│  公司列表    │  岗位列表    │  岗位详情              │
└─────────────────────────────────────────────────────┘
```

**高度设置：** PortalLayout 包含 `el-header`（60px）+ `el-main`（默认 padding 20px）+ `el-footer`（60px）。三栏容器根元素设置 `height: calc(100vh - 200px)`（60 header + 60 footer + 40 el-main padding + 40 buffer），`overflow: hidden`，各栏内部 `overflow-y: auto` 独立滚动。此高度值在主流桌面分辨率下提供良好体验，无需精确到像素。

---

## 各栏设计

### 左栏 — 公司列表

- 顶部标题「全部企业」+ 公司总数 badge
- 每个公司卡片包含：
  - Logo：如有 `org.logo`，显示 `<img>`；否则显示公司名首字 + 蓝色背景 div
  - 公司名称（`org.name`）
  - 简介摘要（`org.description` 截取前 20 字，为空则不显示）
- **不显示在招岗位数**（Organization API 无此字段）
- 选中状态：左边框 `3px solid #409EFF`，背景白色
- 未选中：左边框透明，背景 `#f5f7fa`
- **加载状态**：`orgsLoading` 为 true 时显示 `el-skeleton`（3 行）
- **错误状态**：`orgsError` 为 true 时显示「加载失败，请刷新重试」居中提示
- **空状态**：`orgs` 为空数组时显示「暂无公司数据」居中提示
- 数据来源：`getOrganizations()` → `GET /organizations/public/`，取 `data.results`

### 中栏 — 岗位列表

- 标题：「{org.name} · 职位列表」
- 每条岗位显示：
  - 岗位名称（`job.title`）
  - 标签行：`job.location`（城市）、`job.salary`（薪资）、`job.category`（类别）
- 选中状态：左边框 `3px solid #409EFF`，背景 `#ecf5ff`
- **空状态**：该公司无已发布岗位时，显示「暂无在招职位」居中提示
- **加载状态**：`jobsLoading` 为 true 时显示 `el-skeleton`（3 行）
- **错误状态**：API 失败时显示「加载失败，请刷新重试」
- 数据来源：`getJobs({ organization: org.id })` → 取 `data.results`

### 右栏 — 岗位详情

**右栏渲染优先级（严格按顺序）：**
1. `detailLoading === true` → 显示 `el-skeleton`
2. `detailError === true` → 显示「加载失败，请刷新重试」
3. `selectedJob !== null` → 显示岗位详情
4. 以上均不满足（`selectedJob === null` 且无加载/错误）→ 显示空状态

**各状态内容：**
- **空状态**：居中空状态图标 + 「← 请选择左侧职位查看详情」
- **岗位详情**：
  - 顶部：公司首字 Logo + 岗位名称（`job.title`）+ 公司/城市副标题 + 「立即申请」按钮
  - 标签行：`job.location`、`job.salary`、`job.category`（无经验字段，不显示）
  - 正文：`job.description`（用 `white-space: pre-wrap` 保留换行）
- **加载状态**：`el-skeleton`（5 行，含头部和正文）
- **错误状态**：「加载失败，请刷新重试」居中提示
- **「立即申请」按钮行为**：
  - 已登录（seeker）：`router.push({ name: 'JobDetail', params: { id: job.id } })`（跳转现有详情页完成申请）
  - 未登录：`router.push({ name: 'Login', query: { redirect: '/jobs/' + job.id } })`

---

## 数据结构

`getOrganizations()` 返回的 Organization 对象字段：`id, name, logo (string|null), description, email, is_active`

`getJobs()` 使用 `JobListSerializer`，返回字段：`id, title, category, location, salary, organization (id only), organization_name, status, created_at`。**不含 `description`。**

`getJob(id)` 使用 `JobDetailSerializer`，返回字段：`id, title, category, location, salary, description, organization: {id, name, logo, ...}, status, created_at`。**含完整 `description`，因此点击岗位时必须调用此接口获取详情。**

`/jobs/public/` 后端已在 queryset 层过滤 `status='published'`，无需前端额外传 status 参数。

**Logo URL 处理**：`org.logo` 为相对路径（如 `/media/org_logos/foo.png`），需拼接后端地址。通过 Vite proxy，开发环境直接使用原始路径即可（proxy 会转发到 `http://127.0.0.1:8000`）。渲染时用 `org.logo ? org.logo : null` 判断是否显示图片。

---

## 状态管理

组件内使用以下 ref：

| 变量 | 类型 | 说明 |
|------|------|------|
| `orgs` | `Ref<Organization[]>` | 所有公司列表 |
| `orgsLoading` | `Ref<boolean>` | 左栏加载状态 |
| `orgsError` | `Ref<boolean>` | 左栏错误状态 |
| `selectedOrg` | `Ref<Organization \| null>` | 当前选中公司 |
| `jobs` | `Ref<Job[]>` | 当前公司的岗位列表 |
| `selectedJob` | `Ref<Job \| null>` | 当前选中岗位（getJob 返回的完整 Job 对象） |
| `jobsLoading` | `Ref<boolean>` | 中栏加载状态 |
| `detailLoading` | `Ref<boolean>` | 右栏加载状态 |
| `jobsError` | `Ref<boolean>` | 中栏错误状态 |
| `detailError` | `Ref<boolean>` | 右栏错误状态 |

---

## 交互流程

1. `onMounted` → 设置 `orgsLoading = true`，调用 `getOrganizations()`
   - 成功且列表非空：`orgsLoading = false`，渲染左栏，自动调用 `selectOrg(orgs.value[0])`
   - 成功但列表为空：`orgsLoading = false`，左栏显示「暂无公司数据」
   - 失败：`orgsLoading = false`，`orgsError = true`，左栏显示「加载失败，请刷新重试」

2. `selectOrg(org)` →
   - 重置 `jobsError = false`，清空 `selectedJob`，`jobs = []`
   - 设置 `selectedOrg = org`，`jobsLoading = true`
   - 调用 `getJobs({ organization: org.id })`，取 `data.results` 更新 `jobs`
   - 完成后 `jobsLoading = false`；失败则 `jobsError = true`

3. `selectJob(job)` →
   - 重置 `detailError = false`
   - 设置 `detailLoading = true`，`selectedJob = null`
   - 调用 `getJob(job.id)` 更新 `selectedJob`
   - 完成后 `detailLoading = false`；失败则 `detailError = true`

4. 「立即申请」按钮点击 →
   - 若 `authStore.isSeeker`：`router.push({ name: 'JobDetail', params: { id: selectedJob.id } })`
   - 否则（未登录或非 seeker 角色）：`router.push({ name: 'Login', query: { redirect: '/jobs/' + selectedJob.id } })`

---

## 文件变更范围

| 文件 | 变更类型 |
|------|---------|
| `offer_frontend/src/views/portal/CompanyListView.vue` | 完全重写 |

无需改动后端、无需新增路由、无需新增组件。

---

## 不在范围内

- 搜索/筛选公司或岗位
- 分页（公司数量有限，一次性加载）
- CompanyDetailView（`/companies/:id`）不变
- 在招岗位数统计（Organization API 无此字段）
