# aisupport Jira Issue Plugin 前端样式与交互分析

## 1. 文档范围

本文分析 `apps/plugin/ticket-manager-workspace-plugin` 在当前 `main` 分支（`0b3341e7`）上的前端呈现和交互逻辑。分析范围包括：

- Forge 模块如何进入 Jira Issue 页面；
- `src/frontend/index.jsx` 中的视觉层次、组件和样式；
- 页面状态、弹窗状态和异步操作之间的转换；
- 前端实际调用的 resolver 及刷新策略；
- 当前实现中值得注意的一致性、可用性和维护性问题。

本文描述的是代码所表达的当前行为，没有对已部署环境进行视觉回归或浏览器实测。

## 2. 页面定位与技术约束

插件通过 `manifest.yml` 注册为 Jira `issueContext` 模块：

- Jira 中展示的标题为 `Ticket Workspace (Beta)`；
- 描述为 `View and manage the workspace for this Jira issue.`；
- 折叠入口标签为 `Open the workspace for this ticket.`；
- 使用 `render: native`，前端资源直接指向 `src/frontend/index.jsx`；
- UI 由 Forge UI Kit 渲染，不使用自定义 HTML、CSS 文件或 Custom UI iframe 页面。

因此，卡片外层的 Jira 容器、标题栏、展开和折叠行为由 Jira/Forge 控制。当前代码负责的只是 Issue Context 展开后的内容区域。

前端依赖关系很小：

- `@forge/react`：所有可见组件、布局组件和 `xcss`；
- `@forge/bridge`：通过 `invoke` 调用后端 resolver；
- React hooks：维护页面和弹窗状态；
- `shared/context.js`：从 Forge product context 中提取 Issue Key；
- `shared/validation.js`：对 Lark Group ID 做必填和去空格校验。

## 3. 当前界面结构

界面可以概括为以下层级：

```text
Jira Issue Context: Ticket Workspace (Beta)
└── 可滚动内容容器
    └── 垂直内容栈
        ├── 加载提示（条件显示）
        ├── 页面级错误（条件显示）
        ├── 页面级成功提示（条件显示）
        ├── 未初始化 Workspace 的引导（条件显示）
        ├── Lark Group 资源卡片（条件显示）
        │   ├── 标题
        │   ├── 群列表或空提示
        │   └── 创建/关联群操作（仅无群时显示）
        ├── Initialize Workspace 弹窗
        └── Link Existing Group 弹窗
```

Workspace 对象本身虽然会从后端加载并保存在前端状态中，但界面不展示 Workspace ID、名称、Owner、状态或其他元数据。当前 Workspace 的可见价值主要是作为 Lark Group 资源的容器和操作前提。

## 4. 样式分析

### 4.1 总体风格

整体视觉风格是克制的 Atlassian 原生管理界面：

- 没有品牌插图、图标、渐变、阴影或自定义色值；
- 主要依赖 `Stack` 和 `Inline` 组织垂直、水平间距；
- 主要动作使用 `appearance="primary"`，次要动作使用默认按钮；
- 错误、成功和信息提示使用 UI Kit `SectionMessage`；
- 字体、按钮高度、颜色、焦点态、禁用态和 Modal 细节由 Atlassian Design System 统一提供。

这种实现能自然跟随 Jira 的主题和 UI Kit 升级，也避免了 Forge 原生渲染环境下自定义样式能力有限的问题。

### 4.2 自定义样式

代码仅定义了三组 `xcss`：

| 样式 | 用途 | 视觉效果 |
| --- | --- | --- |
| `panelContainerStyles` | 整个内容区 | `maxHeight: 100%`，内容超出时自动滚动 |
| `resourceContainerStyles` | Lark Group 卡片 | 标准边框、圆角和 `space.200` 内边距 |
| `dividerStyles` | 群之间及操作区上方 | 使用标准边框色和边框宽度的横向分隔线 |

全部颜色、边框、圆角和间距都使用 design token，没有写死像素或颜色。外层内容区没有额外 padding、背景色或固定宽度，实际宽度由 Jira Issue Context 容器决定。

### 4.3 间距和信息层次

- 页面主内容使用 `Stack space="space.200"`；
- Lark Group 卡片内部也使用 `space.200`；
- 群条目中的 Join 按钮与群名使用 `Inline space="space.100"`；
- Modal 表单内容使用 `space.150`；
- Modal footer 的按钮间距使用 `space.100`；
- Lark Group 标题和群名通过 `Strong` 提升权重，没有使用额外字号层级。

信息密度较低，适合 Issue Context 的窄栏区域。与此同时，界面没有独立页面标题、Workspace 摘要或分区说明，用户主要依靠 Jira 外层标题和 `Lark Group` 小标题理解上下文。

### 4.4 操作的视觉优先级

| 场景 | 主要动作 | 次要动作 |
| --- | --- | --- |
| 未初始化 Workspace | `Initialize Workspace` | 无 |
| 初始化弹窗 | `Initialize` | `Cancel` |
| Workspace 已存在但没有群 | `Create a new Lark group` | `Link an existing Lark group` |
| 关联已有群弹窗 | `Link` | `Cancel` |
| 已有关联群 | `Join` | 无 |

创建新群被持续设为主动作，关联已有群是次要动作。这一层级既出现在初始化弹窗的默认选项中，也出现在已有 Workspace 的操作区中。

## 5. 页面状态模型

页面的核心数据状态是：

- `workspace`：`null` 表示没有 Workspace；
- `groups`：当前 Workspace 关联的 Lark 群列表；
- `loadingWorkspace` 和 `loadError`：控制加载状态；
- 三组独立的操作状态：初始化、创建群、关联群；
- `operationError` 和 `operationSuccess`：页面级操作反馈。

派生状态如下：

```text
hasWorkspace = workspace !== null
hasLinkedGroups = groups.length > 0
canCreateOrLinkGroup =
  hasWorkspace && !loadingWorkspace && !loadError && !hasLinkedGroups
```

主要渲染状态为：

| 条件 | 可见内容 | 可执行动作 |
| --- | --- | --- |
| `loadingWorkspace` | 小号 Spinner + `Loading...` | 无主内容动作 |
| `loadError` | 错误 `SectionMessage` | 无重试按钮 |
| 无 Workspace | `No workspace initialized.` | 打开初始化弹窗 |
| 有 Workspace、无群 | Lark Group 卡片 + 空提示 | 创建新群或关联已有群 |
| 有 Workspace、有群 | 群列表 | 通过外部链接 Join |

状态转换可以表示为：

```mermaid
stateDiagram-v2
    [*] --> ResolvingContext
    ResolvingContext --> Loading: 取得 Issue Key
    ResolvingContext --> LoadError: 缺少 Issue Key
    Loading --> NoWorkspace: workspace = null
    Loading --> WorkspaceWithoutGroup: workspace != null 且 groups 为空
    Loading --> WorkspaceWithGroup: groups 非空
    Loading --> LoadError: invoke 失败

    NoWorkspace --> InitializeModal: 点击 Initialize Workspace
    InitializeModal --> Loading: 初始化成功后重新加载
    InitializeModal --> InitializeModal: 校验或请求失败

    WorkspaceWithoutGroup --> Loading: 创建新群成功后重新加载
    WorkspaceWithoutGroup --> LinkModal: 点击 Link an existing Lark group
    LinkModal --> Loading: 关联成功后重新加载
    LinkModal --> LinkModal: 校验或请求失败

    WorkspaceWithGroup --> ExternalLark: 点击 Join
```

所有写操作成功后都会重新调用 `loadWorkspaceState`，以服务端结果覆盖本地数据；没有乐观更新。

## 6. 首次加载与上下文解析

`useProductContext()` 提供 Jira 上下文，`extractIssueKey()` 兼容多个可能的上下文字段路径。上下文准备好后，前端调用：

```text
invoke('loadWorkspaceState', { issue_key })
```

加载成功后同时更新 `workspace` 和 `groups`；失败时会：

1. 清空 Workspace 和群列表；
2. 在浏览器 console 记录完整 invoke 错误；
3. 在页面顶部显示错误 `SectionMessage`；
4. 隐藏 Workspace 空态和资源卡片。

当前没有显式的 Retry/Refresh 按钮。一次首次加载失败后，用户只能依赖 Issue Context 重新挂载、Issue 上下文变化或 Jira 页面刷新来重新触发加载。

还有一个短暂的初始态问题：`productContext` 尚为 `undefined` 时，`loadingWorkspace` 初始值为 `false`，`workspace` 初始值为 `null`，因此第一次渲染可能先显示 `No workspace initialized.`，随后才进入 Loading。这可能产生空态闪烁。

## 7. 初始化 Workspace

### 7.1 打开弹窗

当 Workspace 不存在时，页面显示 `Initialize Workspace` 主按钮。点击后会重置：

- 模式为 `Create New Lark Group`；
- Lark Group ID 为空；
- 初始化错误为空；
- 弹窗打开。

### 7.2 模式选择

弹窗提供两个 Radio 选项：

- `Create New Lark Group`：默认选项，不显示额外输入框；
- `Link Existing Lark Group`：显示 `Lark Group ID` 输入框。

从 Link 切换回 Create 时会清空已经输入的 Group ID。每次切换模式都会清空弹窗错误。

### 7.3 提交行为

Link 模式会先调用 `getRequiredGroupId()`：输入只做 trim 和非空校验，不校验 Lark Group ID 的格式或前缀。

提交 payload 为：

```js
{
  issue_key: issueKey,
  create_lark_group: initializeMode === 'create',
  link_group_id: linkGroupId,
}
```

远端 resolver 会使用当前 Jira 用户邮箱作为 Workspace Owner，以 `P4`、`war_room` 创建 Workspace，并根据模式创建新群或关联指定群。

请求期间：

- Radio、输入框、Cancel 和 Initialize 都被禁用；
- Initialize 显示 loading 状态；
- Modal 的 `onClose` 也会因为 `initializingWorkspace` 而拒绝关闭。

成功后关闭并重置弹窗、重新加载完整状态，再显示 `Initialized workspace successfully.`。失败后 Modal 保持打开，错误显示在输入区下方，方便用户修改后重试。

## 8. 为已有 Workspace 创建或关联群

这两个动作仅在 Workspace 已存在且 `groups.length === 0` 时显示。一旦存在至少一个群，整个创建/关联操作区都会消失。虽然群列表和后端都能处理多个群，当前 UI 实际上把资源操作限制为“补齐第一个群”。

### 8.1 创建新群

点击 `Create a new Lark group` 后直接调用 `createGroup`，没有二次确认：

```js
{
  issue_key: issueKey,
  workspace_id: workspace.id,
}
```

按钮在请求期间显示 loading。成功后重新加载完整状态，并显示 `Created Lark group successfully.`；失败则在页面顶部显示错误。

### 8.2 关联已有群

点击 `Link an existing Lark group` 打开独立 Modal。该 Modal：

- 用信息 `SectionMessage` 提示先把 `AiSupportBot` 加入群；
- 提供带 Label 的 `Lark Group ID` 输入框；
- 对输入执行 trim 和非空校验；
- 在请求期间禁用输入、Cancel、Link 和 Modal 关闭；
- 请求失败时保留弹窗和输入，并在弹窗内显示错误。

成功后会关闭 Modal、清空输入并重新加载状态，但不会显示页面级成功消息。

取消关联 Modal 时只关闭弹窗，不清空 Group ID；再次打开时，上次未提交的输入仍然存在。相比之下，初始化 Modal 在取消时会完整重置，两者行为不一致。

## 9. 群列表与 Join 行为

每个群显示为一行：

```text
[Join]  群名称
```

- Join 是 `LinkButton`，使用 primary appearance；
- 链接来自后端标准化后的 `group.link`；
- 点击后通过 `target="_blank"` 打开新标签页；
- 没有 link 时 Join 按钮禁用；
- 群名使用 Strong；
- 多个群之间使用分隔线；
- React key 使用 `group_id`。

虽然 resolver 定义了 `joinGroup`，前端目前没有调用它，而是直接使用加载结果里的 join link。因此 Join 不会产生新的 resolver 请求，也没有 loading、成功或错误反馈。`listGroups` resolver 同样没有被当前前端直接调用，加载统一走 `loadWorkspaceState`。

## 10. 异步反馈与错误处理

错误按作用域拆分：

| 状态 | 展示位置 | 典型来源 |
| --- | --- | --- |
| `loadError` | 页面顶部 | 首次加载或写操作后的重新加载失败 |
| `operationError` | 页面顶部 | 创建新群失败、缺少 Workspace/Issue Key |
| `initializeError` | 初始化 Modal 内 | 初始化校验或请求失败 |
| `linkError` | Link Modal 内 | Group ID 校验或关联请求失败 |

所有 invoke 失败都会把原始 error 对象和消息写入浏览器 console。resolver 也会在 Forge function 日志中记录操作名、经过脱敏摘要的 payload、HTTP 状态和响应正文。页面上显示的通常是 resolver 传回的完整错误消息。

值得注意的行为包括：

- `loadWorkspaceState` 自己捕获错误而不继续抛出；初始化或创建操作即使后续刷新失败，仍可能追加显示“操作成功”，形成成功与加载错误同时出现的状态；
- Link 成功不设置 `operationSuccess`，与初始化和创建群不一致；
- Link 提交开始时不会清除已有的 `operationError`，此前创建群失败留下的页面级错误可能继续存在；
- 创建群请求期间只禁用创建按钮，关联群按钮仍可点击，理论上可以打开另一个操作弹窗；
- 页面没有主动取消请求、超时提示或防止旧请求结果覆盖新 Issue 状态的机制。

## 11. 可访问性与文案

当前实现主要受益于 UI Kit 的默认能力：

- Textfield 使用 `Label` 和对应的 `labelFor`/`id`；
- Button、LinkButton、RadioGroup 和 Modal 都使用语义化 UI Kit 组件；
- loading 和 disabled 状态通过组件属性表达；
- Modal 的焦点管理、键盘行为和遮罩由 UI Kit 提供；
- 错误、成功和信息使用不同 `appearance`，不只依赖自定义颜色。

文案层面存在以下特点：

- 全部为英文，没有国际化层；
- `Lark Group`、`lark group` 的大小写不完全一致；
- 只有独立 Link Modal 提示需要先添加 AiSupportBot，初始化弹窗的 Link 模式没有相同提示；
- 没有解释如何查找 Lark Group ID；
- 没有展示群 ID，只有群名和 Join；
- 空态使用普通 Text + Button，没有采用更强的 `EmptyState` 视觉组件。

## 12. 代码组织与维护性

当前前端全部集中在一个 521 行的 `index.jsx` 文件中，但已经有初步的组件边界：

- `OperationError`：统一错误消息；
- `Divider`：统一分隔线；
- `LarkGroupItem` / `LarkGroupList`：群展示；
- `InitializeWorkspaceModal`：初始化表单；
- `LinkExistingGroupModal`：关联表单；
- `App`：数据加载、状态和所有业务动作。

这种结构对当前功能规模仍可理解，但 `App` 同时承担状态机、远端请求、反馈策略和页面布局。状态变量数量较多，且相互独立，导致成功提示不一致、Modal 重置策略不一致、刷新失败后出现混合反馈等问题较容易发生。

仓库当前没有前端组件或交互测试。现有测试覆盖的是 resolver/backend、Issue Key 提取和 Group ID 校验，因此条件渲染、按钮可用性、Modal 重置、异步反馈和状态转换没有自动化回归保护。

## 13. 当前体验总结

当前实现的优点是：

- 视觉与 Jira 原生界面一致，样式依赖少；
- 主路径短，未初始化、补齐群、进入群三个阶段清晰；
- 写操作后以服务端状态为准，避免前端构造不完整数据；
- 操作中的 Modal 不允许关闭，减少重复提交和上下文丢失；
- 错误既对用户可见，也保留了较完整的诊断日志。

当前体验的主要限制是：

- 首次上下文解析期间可能闪现错误的“未初始化”空态；
- 加载失败后没有页面内重试入口；
- 已有一个群后不能继续通过 UI 创建或关联更多群；
- 初始化、创建和关联三条路径的成功反馈与表单重置策略不一致；
- Workspace 信息没有任何可视化，只能看到其 Lark Group 资源；
- 前端状态和交互缺少自动化测试。

## 14. 主要代码索引

| 内容 | 代码位置 |
| --- | --- |
| Forge Issue Context 注册 | `manifest.yml` |
| xcss 和基础展示组件 | `src/frontend/index.jsx:47-118` |
| 两个 Modal | `src/frontend/index.jsx:120-232` |
| 页面状态和数据加载 | `src/frontend/index.jsx:234-286` |
| 初始化、创建、关联动作 | `src/frontend/index.jsx:288-391` |
| 条件渲染和 Modal 装配 | `src/frontend/index.jsx:393-514` |
| Issue Key 提取 | `src/shared/context.js` |
| Group ID 校验 | `src/shared/validation.js` |
| resolver 注册及 Jira 用户解析 | `src/resolvers/index.js` |
| mock/remote 后端与数据标准化 | `src/resolvers/backend.js` |
