feat: 新增开发规划

This commit is contained in:
duanshuwen
2026-04-14 07:36:40 +08:00
parent 66db6c462e
commit b3f07c4cfe
8 changed files with 1050 additions and 34 deletions

View File

@@ -0,0 +1,223 @@
# ChatHistory 会话列表重构迁移计划
## 参考来源
- **源文件**: `ClawX/src/components/layout/Sidebar.tsx:280-335`
- **目标文件**: `zn-ai/src/pages/home/ChatHistory.vue`
## 源实现思路分析
`Sidebar.tsx:280-335` 的历史会话列表核心设计:
1. **时间分桶Time Buckets**
- 将会话按最后活动时间划分为:`today``yesterday``withinWeek``withinTwoWeeks``withinMonth``older`
- 通过 `getSessionBucket(activityMs, nowMs)` 函数计算每个会话所属的分组。
- 会话先按时间降序排序,再归入对应 bucket。
2. **分组标签渲染**
- 每个 bucket 顶部显示一个 11px 的灰色小标签(如 "Today" / "Yesterday" / "Older")。
- 空 bucket 直接跳过不渲染。
3. **会话项结构**
- 外层 `group relative flex items-center` 包裹。
- 左侧显示 **agent 名称标签**(圆角小 pill右侧显示 **会话标题**`truncate` 截断)。
- 当前选中的会话高亮:背景色 + 加粗文字。
- hover 时背景变深。
4. **删除交互(悬停显隐)**
- 删除按钮默认 `opacity-0`hover 整个会话项时通过 `group-hover:opacity-100` 显现。
- 按钮绝对定位在右侧,避免挤压标题空间。
5. **点击行为**
- 点击整个会话行:切换会话 + 导航到聊天页。
- 删除按钮单独拦截 `stopPropagation`
---
## 迁移目标
将上述时间分桶、分组标签、悬停删除、紧凑列表布局迁移到 `ChatHistory.vue`,替换当前简单平铺的 `groups` 列表,同时保留 zn-ai 项目现有风格与功能。
---
## 实现步骤
### 1. 引入时间分桶工具函数
`<script setup>` 中新增 `SessionBucketKey` 类型与 `getSessionBucket` 函数:
```ts
type SessionBucketKey =
| 'today'
| 'yesterday'
| 'withinWeek'
| 'withinTwoWeeks'
| 'withinMonth'
| 'older';
function getSessionBucket(activityMs: number, nowMs: number): SessionBucketKey {
if (!activityMs || activityMs <= 0) return 'older';
const now = new Date(nowMs);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
if (activityMs >= startOfToday) return 'today';
if (activityMs >= startOfYesterday) return 'yesterday';
const daysAgo = (startOfToday - activityMs) / (24 * 60 * 60 * 1000);
if (daysAgo <= 7) return 'withinWeek';
if (daysAgo <= 14) return 'withinTwoWeeks';
if (daysAgo <= 30) return 'withinMonth';
return 'older';
}
```
> **说明**ChatHistory.vue 当前接口可能不返回 `lastActivity` 时间。若接口无该字段,可先以会话创建时间/更新时间或本地 `Date.now()` 作为兜底,后续再补充真实数据。
### 2. 扩展数据模型
`HistoryMessage` 接口增加可选时间字段,用于分桶:
```ts
interface HistoryMessage {
conversationId: string;
conversationTitle: string;
updatedAt?: number; // 毫秒时间戳,用于分桶
}
```
映射接口数据时注入时间戳(若后端返回字符串时间,先 `new Date().getTime()` 转换):
```ts
groups.value = list.sessions.map((item: any) => ({
conversationId: item.session_id,
conversationTitle: item.title,
updatedAt: item.updated_at ? new Date(item.updated_at).getTime() : Date.now(),
}));
```
### 3. 计算时间分桶(响应式)
使用 `computed` 将会话列表转换为 bucket 数组:
```ts
import { computed } from 'vue';
const nowMs = ref(Date.now());
const sessionBuckets = computed(() => {
const buckets: Array<{ key: SessionBucketKey; label: string; sessions: HistoryMessage[] }> = [
{ key: 'today', label: '今天', sessions: [] },
{ key: 'yesterday', label: '昨天', sessions: [] },
{ key: 'withinWeek', label: '近7天', sessions: [] },
{ key: 'withinTwoWeeks', label: '近14天', sessions: [] },
{ key: 'withinMonth', label: '近30天', sessions: [] },
{ key: 'older', label: '更早', sessions: [] },
];
const map = Object.fromEntries(buckets.map((b) => [b.key, b])) as Record<SessionBucketKey, typeof buckets[number]>;
for (const session of [...groups.value].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))) {
const bucketKey = getSessionBucket(session.updatedAt || 0, nowMs.value);
map[bucketKey].sessions.push(session);
}
return buckets;
});
```
### 4. 模板结构改造
替换当前平铺的 `<ul>` 为按 bucket 分组的结构。外层保持 `v-if="!sidebarCollapsed"` 控制折叠显隐:
```html
<div v-if="!sidebarCollapsed" class="overflow-y-auto p-2 flex-1">
<div v-for="bucket in sessionBuckets" :key="bucket.key" class="mb-2">
<div
v-if="bucket.sessions.length > 0"
class="px-2.5 pb-1 text-[11px] font-medium text-gray-400 tracking-tight"
>
{{ bucket.label }}
</div>
<div
v-for="item in bucket.sessions"
:key="item.conversationId"
class="group relative flex items-center"
>
<div
@click="selectedHistoryMessage(item.conversationId)"
:class="[
'w-full text-left rounded-lg px-2.5 py-1.5 text-[13px] transition-colors cursor-pointer pr-7',
item.conversationId === selectedConversationId
? 'bg-white shadow-sm border border-[#E5E8EE] text-gray-800 font-medium'
: 'text-gray-600 hover:bg-gray-200'
]"
>
<div class="flex min-w-0 items-center gap-2">
<!-- 如需展示 agent/来源,可在此加 pill 标签 -->
<span class="truncate">{{ item.conversationTitle }}</span>
</div>
</div>
<!-- 悬停删除按钮 -->
<button
aria-label="删除会话"
@click.stop="deleteHistoryMessage(item.conversationId)"
class="absolute right-1 flex items-center justify-center rounded p-0.5 transition-opacity opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 hover:bg-red-50"
>
<RiDeleteBinLine class="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
```
> **说明**
> - 移除了旧版左侧的蓝色圆点,改为更紧凑的行式布局。
> - 删除按钮改为 hover 显隐,并使用 `@click.stop` 避免触发选择。
> - 保留原 `selectedHistoryMessage` 选择逻辑。
> - 重命名功能可继续通过双击或扩展菜单实现;若需保留,可额外加一个 hover 出现的 "更多" 下拉按钮(本计划暂不展开)。
### 5. 图标引入
`<script setup>` 中引入删除图标:
```ts
import { RiDeleteBinLine } from '@remixicon/vue';
```
### 6. 样式与行为保持一致
- **选中态**:继续使用 `selectedConversationId` 判断,样式调整为带阴影和边框的白色背景卡片(与源实现高亮思路一致)。
- **空列表**:若 `groups` 为空,整个列表区域自然无内容,可后续补充 "暂无历史会话" 占位。
- **定时刷新时间基准**:如需让 "今天/昨天" 标签随时间自动更新,可补充 `setInterval` 每分钟刷新 `nowMs`
```ts
let timer: number | undefined;
onMounted(() => {
getHistoryConversationList();
timer = window.setInterval(() => {
nowMs.value = Date.now();
}, 60 * 1000);
});
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
});
```
---
## 涉及文件
| 文件 | 修改内容 |
|---|---|
| `zn-ai/src/pages/home/ChatHistory.vue` | 新增时间分桶逻辑、改造历史会话列表模板、引入悬停删除按钮、扩展 `HistoryMessage` 类型 |
---
## 验收标准
- [ ] 历史会话按时间自动分组成今天、昨天、近7天、近14天、近30天、更早。
- [ ] 每个分组顶部显示对应中文标签。
- [ ] 会话项采用紧凑行式布局,标题超长截断。
- [ ] 鼠标悬停在某会话项上时,右侧出现删除按钮;移开后隐藏。
- [ ] 点击会话项仍正常触发 `select-chat` 事件。
- [ ] 删除、重命名(如保留)等原有功能不受影响。
- [ ] 侧边栏折叠时,历史会话列表跟随隐藏。

248
ChatPageMigrationPlan.md Normal file
View File

@@ -0,0 +1,248 @@
# Chat 对话功能迁移重构计划
## 参考来源
- **源文件**: `ClawX/src/pages/Chat/index.tsx` 及其子组件
- **目标文件**: `zn-ai/src/pages/home/index.vue``zn-ai/src/pages/home/ChatBox.vue`
## 源实现思路分析
`ClawX/src/pages/Chat/index.tsx` 的核心架构:
1. **状态层集中化**所有聊天状态messages、sending、loading、error、streamingMessage 等)托管在 `useChatStore`Zustand页面组件只负责订阅与渲染。
2. **组件原子化**
- `ChatMessage`单条消息渲染markdown、thinking、tool_use、附件图片
- `ChatInput`:输入框 + 发送/停止按钮。
- `ChatToolbar`会话选择器、thinking 显隐切换、刷新按钮。
- `ExecutionGraphCard`:用户消息后的任务执行可视化卡片。
3. **流式消息处理**:通过 `streamingMessage``streamingTools` 实现未落库前的增量渲染;`pendingFinal` 标识等待最终响应的状态。
4. **生命周期管理**
- 切走页面时调用 `cleanupEmptySession()` 清理空会话。
- 加载历史消息时保留乐观用户消息,避免闪烁。
- 轮询 + 安全超时机制防止消息卡死。
5. **错误与加载态**
- 底部 `error` 条显示全局错误并可一键清除。
- `minLoading` 在 history 加载时显示透明遮罩 + LoadingSpinner。
- 发送中显示 `TypingIndicator` / `ActivityIndicator`
6. **WelcomeScreen**:当 `messages.length === 0 && !sending` 时展示欢迎页 + 快捷操作按钮。
---
## 迁移目标
`ChatBox.vue` 中沉淀的 880+ 行“上帝组件”逻辑解耦,引入 **Pinia Store + 原子组件** 的 ClawX 式架构,同时保留 zn-ai 现有的视觉风格(蓝色主题、头像布局、输入框样式)。
---
## 实现步骤
### 1. 提取 Pinia Chat Store状态层迁移
新建 `zn-ai/src/store/chat.ts`,将 `ChatBox.vue` 中的以下逻辑迁入 Store
| ChatBox.vue 现有逻辑 | Store 对应设计 |
|---|---|
| `chatMsgList` | `state.messages` |
| `isSendingMessage` | `state.sending` |
| `isSessionActive` | `state.pendingFinal` / `state.loading` |
| WebSocket 管理(`initWebSocket``sendWebSocketMessage` | Store Actions`initConnection``sendMessage``stopRun` |
| `pendingMap` / `pendingTimeouts` 超时回退 | Store 内部 Map + 定时器(不暴露给 UI |
| `handleWebSocketMessage` | Store Action`handleStreamEvent` |
| `loadConversationMessages` | Store Action`loadHistory(sessionId)` |
| `createConversationRequest` | Store Action`createSession()` |
| `resetConversation` / `cleanup` | Store Action`resetSession()` |
**Store 核心 State 设计**
```ts
interface ChatState {
messages: ChatMessage[]; // 当前会话消息列表
sending: boolean; // 是否正在发送/等待回复
loading: boolean; // 是否正在加载历史
error: string | null; // 全局错误提示
streamingMessage: Partial<ChatMessage> | null; // 流式增量消息
currentSessionId: string; // 当前会话 ID
}
```
> **说明**zn-ai 当前使用 WebSocket 直联后端ClawX 使用 Gateway RPC本次迁移**只搬架构、不搬协议**Store 内部仍继续使用现有的 `WebSocketManager`。
### 2. 组件拆分UI 层迁移)
`ChatBox.vue` 按职责拆成以下子组件(放置于 `zn-ai/src/pages/home/components/chat/`
#### 2.1 `ChatMessage.vue`
职责:渲染单条消息。
- Props`msg: ChatMessage``isStreaming?: boolean`
- 复用现有 `ChatRoleMe.vue``ChatRoleAI.vue``ChatAvatar.vue``ChatNameTime.vue``ChatAttach.vue``ChatAIMark.vue``ChatOperation.vue` 的能力。
- **新增**:支持 `streamingMessage` 的渲染(内容可能未完成)。
#### 2.2 `ChatInput.vue`(由现有 `ChatInputArea.vue` 升级)
职责:输入框 + 发送/停止按钮。
- Props`modelValue: string``sending: boolean``disabled?: boolean`
- Events`send``stop``update:modelValue``attach`
- **新增**:当 `sending = true` 时按钮显示停止图标并触发 `stop`
#### 2.3 `ChatEmpty.vue`WelcomeScreen 等价物)
职责:空会话欢迎页。
- 迁移现有 `ChatBox.vue` 中的引导页 DOM大标题“你好我今天能帮你什么
- 可保留现有的 `TaskCenter` 插槽或快捷操作按钮区域。
#### 2.4 `ChatErrorBar.vue`
职责:底部错误条。
- Props`error: string | null`
- Events`dismiss`
- 样式参考 ClawX 的红色背景条:`bg-red-50` + 左侧 `AlertCircle` 图标 + 右侧“Dismiss”按钮。
#### 2.5 `ChatLoadingOverlay.vue`(可选)
职责history 加载时透明遮罩 + LoadingSpinner。
- 若 zn-ai 现有 `ChatLoading.vue` 已满足,可直接复用。
### 3. ChatBox.vue 瘦身改造
改造后 `ChatBox.vue` 只承担:**Store 订阅 + 组件拼装 + 少量 prop 透传**。
```html
<template>
<div class="flex flex-col h-full py-6 px-6 overflow-hidden">
<!-- 空状态 -->
<ChatEmpty v-if="isEmpty" />
<!-- 消息列表 -->
<div v-else ref="listRef" class="flex-1 overflow-y-auto py-6 space-y-6">
<div
v-for="msg in chatStore.messages"
:key="msg.messageId"
class="flex items-start gap-3"
:class="msg.messageRole === MessageRole.ME ? 'justify-end' : 'justify-start'"
>
<ChatAvatar v-if="msg.messageRole === MessageRole.AI" :src="aiAvatar" />
<ChatMessage :msg="msg" />
<ChatAvatar v-if="msg.messageRole === MessageRole.ME" :src="userAvatar" />
</div>
<!-- 流式消息占位(未落库前的 AI 回复) -->
<div v-if="chatStore.streamingMessage" class="flex items-start gap-3 justify-start">
<ChatAvatar :src="aiAvatar" />
<ChatMessage :msg="chatStore.streamingMessage" is-streaming />
</div>
<!-- 发送中 Indicator -->
<ChatTypingIndicator v-if="showTypingIndicator" />
</div>
<!-- 错误条 -->
<ChatErrorBar :error="chatStore.error" @dismiss="chatStore.clearError()" />
<!-- 输入区 -->
<div class="flex flex-col gap-3 mt-4">
<ChatInput
v-model="inputMessage"
:sending="chatStore.sending"
@send="onSend"
@stop="chatStore.stopRun()"
@attach="onAttach"
/>
</div>
</div>
</template>
```
```ts
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useChatStore } from '@store/chat';
import { MessageRole } from './model/ChatModel';
// ... 引入各子组件
const chatStore = useChatStore();
const inputMessage = ref('');
const isEmpty = computed(() =>
chatStore.messages.length === 0 && !chatStore.sending && !chatStore.streamingMessage
);
const showTypingIndicator = computed(() =>
chatStore.sending && !chatStore.streamingMessage
);
const onSend = () => {
const text = inputMessage.value.trim();
if (!text) return;
chatStore.sendMessage(text);
inputMessage.value = '';
};
</script>
```
### 4. 流式消息与 Indicator 支持
在 Store 的 WebSocket `onMessage` 回调中:
1. **首次收到内容**:创建 `streamingMessage`AI 角色、初始内容)。
2. **后续收到增量**`streamingMessage.messageContent += data.content`
3. **收到 finish**:将 `streamingMessage` 推入 `messages[]`,然后清空 `streamingMessage`,结束 `sending`
4. **异常/超时**:清空 `streamingMessage` 并设置 `error`
**新增 `ChatTypingIndicator.vue`**(参考 ClawX `TypingIndicator`
- 左侧 AI 头像,右侧三个跳动的圆点,用于 `sending && !streamingMessage` 时占位。
### 5. 生命周期与页面级整合(`home/index.vue`
`home/index.vue` 中补充 Store 生命周期绑定:
```ts
import { useChatStore } from '@store/chat';
const chatStore = useChatStore();
onMounted(() => {
chatStore.initConnection();
});
onBeforeUnmount(() => {
chatStore.resetSession();
});
```
> 当用户从首页切到其他页面时,调用 `resetSession()` 关闭 WebSocket、清理定时器避免后台继续接收消息。
### 6. 历史消息加载与空会话清理
- **选择历史会话**`ChatHistory.vue``@select-chat` 事件触发 `chatStore.loadHistory(conversationId)`
- **新建对话**`@new-chat` 触发 `chatStore.createSession()`,然后清空 `messages`
- **切页清理**Store 中实现 `cleanupEmptySession()`,如果当前会话没有任何消息且不是历史来源,则自动重置 `conversationId`,避免产生幽灵会话。
### 7. 样式与兼容性保持
- **颜色主题**:继续使用 zn-ai 现有的 `#2B7FFF` 蓝色高亮、`#E5E8EE` 边框色。
- **头像布局**左右头像顺序不变AI 左、用户右)。
- **输入框样式**`ChatInput` 保留现有的圆角、阴影、发送按钮样式。
- **引导页**:保留现有的 `TaskCenter` 和欢迎文案。
---
## 涉及文件
| 新建/修改 | 文件路径 | 说明 |
|---|---|---|
| 新建 | `zn-ai/src/store/chat.ts` | Pinia Chat Store承载状态与 WebSocket 逻辑 |
| 新建 | `zn-ai/src/pages/home/components/chat/ChatMessage.vue` | 单条消息渲染(聚合现有原子组件) |
| 新建 | `zn-ai/src/pages/home/components/chat/ChatEmpty.vue` | 空会话欢迎页 |
| 新建 | `zn-ai/src/pages/home/components/chat/ChatErrorBar.vue` | 底部全局错误提示条 |
| 新建 | `zn-ai/src/pages/home/components/chat/ChatTypingIndicator.vue` | 发送中跳动圆点 |
| 升级 | `zn-ai/src/pages/home/components/ChatInputArea.vue` | 增加 `sending` 态与 `stop` 事件 |
| 重构 | `zn-ai/src/pages/home/ChatBox.vue` | 大幅瘦身,仅负责拼装子组件 |
| 调整 | `zn-ai/src/pages/home/index.vue` | 引入 Store 生命周期、事件透传 |
---
## 验收标准
- [ ] `ChatBox.vue` 代码量从 800+ 行降至 150 行以内,不再包含 WebSocket 细节。
- [ ] 新建 `chat.ts` Pinia Store 能正常初始化 WebSocket、发送消息、接收回复。
- [ ] 用户发送消息后,输入框清空,列表底部出现 `ChatTypingIndicator`;收到首包内容后切换为 `ChatMessage` 流式渲染。
- [ ] 收到完成标识后,流式消息固化到消息列表,状态恢复正常。
- [ ] 发生超时或错误时,底部出现 `ChatErrorBar`,可点击清除。
- [ ] 切换历史会话时,通过 Store 加载历史消息并正确渲染。
- [ ] 新建对话时,列表回到空状态(`ChatEmpty`)。
- [ ] 离开首页时 WebSocket 被正确关闭,无后台消息泄漏。

View File

@@ -0,0 +1,126 @@
# ChatHistory 侧边栏折叠功能迁移计划
## 参考来源
- **源文件**: `ClawX/src/components/layout/Sidebar.tsx:234-247`
- **目标文件**: `zn-ai/src/pages/home/ChatHistory.vue:6`
## 源实现思路分析
`Sidebar.tsx:234-247` 的核心实现逻辑:
1. **状态驱动**: 通过 `useSettingsStore` 获取 `sidebarCollapsed` 布尔值与 `setSidebarCollapsed` setter。
2. **图标切换**: 点击按钮时,根据当前状态切换图标:
- 展开状态 → 显示 `PanelLeftClose`(提示可收起)
- 收起状态 → 显示 `PanelLeft`(提示可展开)
3. **样式过渡**: 外层 `aside` 通过 `transition-all duration-300` 配合条件类名 `w-16` / `w-64` 实现宽度动画。
4. **内容显隐**: 内部文字/列表在收起时隐藏(`!sidebarCollapsed && ...`),只保留图标按钮可点。
## 迁移目标
将上述折叠/展开交互迁移到 `ChatHistory.vue`,使其第 6 行的 `RiSideBarLine` 图标具备切换侧边栏宽度的能力,并保持与现有 `zn-ai` 项目风格一致。
## 实现步骤
### 1. 状态定义ChatHistory.vue 本地状态)
`<script setup>` 中新增响应式状态,无需引入全局 store当前页面无对应 store本地 ref 足够):
```ts
const sidebarCollapsed = ref(false)
```
### 2. 图标点击交互(对应目标文件第 6 行)
将现有静态图标改造为可点击切换状态:
```html
<!-- 修改前 -->
<RiSideBarLine class="ml-auto cursor-pointer" />
<!-- 修改后 -->
<RiSideBarLine
v-if="!sidebarCollapsed"
class="ml-auto cursor-pointer hover:text-[#2B7FFF]"
@click="sidebarCollapsed = true"
/>
<RiArrowRightSLine
v-else
class="ml-auto cursor-pointer hover:text-[#2B7FFF]"
@click="sidebarCollapsed = false"
/>
```
> 说明:
> - 展开时显示 `RiSideBarLine`(或 `RiSideBarFoldLine` 若项目已有),点击后收起。
> - 收起时显示 `RiArrowRightSLine`(或 `RiSideBarLine`),点击后展开。
> - 与源实现保持一致:图标根据状态互斥显示。
### 3. 容器宽度过渡动画
`<aside>` 根元素添加动态类名与过渡:
```html
<aside
:class="[
'h-full box-border flex flex-col transition-all duration-300',
sidebarCollapsed ? 'w-16' : 'w-50'
]"
>
```
- 展开宽度保持原设计 `w-50``200px`)。
- 收起宽度收缩为 `w-16``64px`),仅保留 Logo 与切换按钮空间。
- `transition-all duration-300` 保证宽度变化平滑。
### 4. 内部内容显隐控制
`sidebarCollapsed = true` 时,以下区域应隐藏:
- **YINIAN Logo 文字**(保留图片,隐藏文字,避免换行)
- **"新对话" 按钮文字**(可保留图标 `RiAddLine`,或整体隐藏按钮)
- **历史会话列表**`groups` 循环区域整体隐藏,避免窄边栏内文字截断)
具体修改点:
```html
<!-- Logo 区域 -->
<div class="flex items-center m-2">
<img class="w-10 h-10 rounded-md" src="@assets/images/login/white_logo.png" />
<div v-if="!sidebarCollapsed" class="font-bold text-gray-80">YINIAN</div>
<!-- 图标切换按钮 -->
...
</div>
<!-- 新对话按钮 -->
<div
v-if="!sidebarCollapsed"
class="flex justify-center ..."
@click="addNewChat"
>
<RiAddLine /> 新对话
</div>
<!-- 历史会话列表 -->
<div v-if="!sidebarCollapsed" class="overflow-y-auto p-2">
...
</div>
```
### 5. 保持功能可用性
- **历史记录加载**:收起状态下仍可在后台通过 `onMounted` 正常加载列表数据,只是不渲染 DOM。
- **选中态同步**:展开后 `selectedConversationId``groups` 数据不丢失,用户可继续操作。
- **响应式恢复**:若窗口尺寸变化导致侧边栏过窄,可后续补充监听自动展开逻辑(本期非必须)。
## 涉及文件
| 文件 | 修改内容 |
|---|---|
| `zn-ai/src/pages/home/ChatHistory.vue` | 新增 `sidebarCollapsed` ref、改造第 6 行图标为状态切换按钮、为 `aside` 添加动态宽度与过渡、控制内部元素显隐 |
## 验收标准
- [ ] 点击 `RiSideBarLine` 后,侧边栏从 `w-50` 平滑过渡到 `w-16`
- [ ] 收起状态下只显示 Logo 图片与展开图标按钮,其余文字/列表隐藏。
- [ ] 再次点击展开图标,侧边栏平滑恢复 `w-50`,历史列表与选中态保持。
- [ ] 不影响现有的新建对话、选择会话、重命名、删除功能。

View File

@@ -0,0 +1,298 @@
# 任务列表TaskList实时显示脚本执行进度 — 实现思路与开发计划
> 基于 `zn-ai/electron/scripts` 中美团、抖音、飞猪等脚本,在渲染层任务列表组件中实时展示脚本执行过程,并支持任务状态流转与操作。
---
## 一、现状梳理
| 模块 | 当前状态 | 关键文件 |
|---|---|---|
| 脚本执行 | 通过 `utilityProcess.fork` 串行执行,**阻塞式返回结果**,无实时推送 | `electron/service/execute-script-service/index.ts` |
| 任务列表 UI | 使用 `@constant/task` **静态假数据** | `src/components/TaskList/List.vue``Card.vue` |
| 脚本触发入口 | `TaskOperationDialog.vue` 中调用 `window.api.executeScript(options)` | `src/pages/home/components/TaskOperationDialog.vue` |
| 状态管理 | 已有 `store/script.ts` 管理脚本元数据,**缺少任务Task生命周期管理** | `src/store/script.ts` |
| IPC 通信 | 只有 Request/Response 模式invoke/handle**无主进程主动推送** | `electron/preload/index.ts` |
---
## 二、核心设计思路
### 2.1 数据模型Task任务+ SubTask子任务
一次"操作房型"的执行产生 **1 个 Task**,该 Task 包含 **N 个 SubTask**(对应美团、飞猪、抖音酒店、抖音温泉等脚本)。
```ts
// 子任务状态
type SubTaskStatus = 'pending' | 'running' | 'success' | 'failed';
interface SubTask {
id: string; // 子任务唯一 ID
taskId: string; // 所属主任务 ID
scriptId: string; // 脚本标识,如 mt_trace.js
name: string; // 渠道名称,如"美团房态追踪"
status: SubTaskStatus;
progress: number; // 0-100
message: string; // 当前执行步骤描述
stdoutTail: string; // 最新输出
stderrTail: string; // 最新错误
error?: string; // 失败原因
startedAt: string;
completedAt?: string;
}
// 主任务状态
type TaskStatus = 'pending' | 'running' | 'success' | 'partial_failed' | 'failed';
interface Task {
id: string;
title: string; // 如"关闭渠道房型 - 大床房"
operation: 'open' | 'close';
roomType: string;
dateRange: [string, string];
status: TaskStatus;
subTasks: SubTask[];
createdAt: string;
updatedAt: string;
}
```
### 2.2 实时进度推送方案
`utilityProcess.fork``stdout` 可以被主进程监听。采用 **"特殊前缀 JSON" + IPC 主动推送"** 的方案:
1. **脚本侧**:在执行关键步骤时输出进度日志
```js
console.log('__ZN_PROGRESS__' + JSON.stringify({ step: '正在登录美团后台', percent: 30 }));
```
2. **主进程侧**`execute-script-service` 解析 stdout匹配 `__ZN_PROGRESS__` 前缀emit 内部事件
3. **IPC 推送**`runTaskOperationService.ts` 订阅内部事件,通过 `BrowserWindow.webContents.send('task:progress', payload)` 推送到渲染进程
> 如果暂时不修改脚本文件,也可以先实现"开始/完成"两个节点的推送,中间进度用 stdout 文本更新。
### 2.3 状态流转与操作权限
| Tab | 包含状态 | 操作按钮 |
|---|---|---|
| **待处理** | `pending`、`running` | 查看(展开进度详情) |
| **已处理** | `success`、`failed`、`partial_failed` | 成功 → **移除**;失败 / 部分失败 → **重试失败项** |
**排队机制**:一个 Task 包含的 N 个 SubTask对应各渠道脚本在主进程中**串行排队执行**,避免并发启动多个浏览器实例导致资源冲突。
**重试粒度**:仅针对该 Task 下 `status === 'failed'` 的 SubTask 进行重试,成功的 SubTask 保持结果不变。
---
## 三、分阶段开发计划
### Phase 1类型定义与 IPC 扩展
**目标**:打通主进程 -> 渲染进程的推送通道。
1. **`src/lib/task-types.ts`**(新建)
- 定义 `Task`、`SubTask`、`TaskProgressPayload` 等接口。
2. **`src/lib/constants.ts`**(修改)
- 新增 IPC 事件:
```ts
TASK_PROGRESS = 'task:progress',
TASK_STARTED = 'task:started',
TASK_COMPLETED = 'task:completed',
```
3. **`electron/preload/index.ts`**(修改)
- 暴露监听接口:
```ts
onTaskProgress: (cb) => ipcRenderer.on(IPC_EVENTS.TASK_PROGRESS, cb),
onTaskStarted: (cb) => ipcRenderer.on(IPC_EVENTS.TASK_STARTED, cb),
onTaskCompleted: (cb) => ipcRenderer.on(IPC_EVENTS.TASK_COMPLETED, cb),
```
4. **`global.d.ts`**(修改)
- 更新 `WindowApi` 类型声明。
---
### Phase 2主进程进度推送改造
**目标**:让脚本执行过程可被渲染层实时感知。
5. **`electron/service/execute-script-service/index.ts`**(修改)
- 继承 `EventEmitter`,新增事件:
- `progress`:解析到 `__ZN_PROGRESS__` 前缀时触发
- `stdout`:有新的标准输出时触发
- `stderr`:有新的错误输出时触发
- `executeScript` 方法签名扩展,增加 `taskId` / `subTaskId` 参数用于上下文绑定。
6. **`electron/process/runTaskOperationService.ts`**(修改)
- `EXECUTE_SCRIPT` handler 改造:
- 接收 `options.taskId`,生成 `subTaskId` 映射表
- 执行每个脚本前,发送 `TASK_STARTED` IPC
- 订阅 `executeScriptServiceInstance` 的 `progress`/`stdout`/`stderr` 事件,组装 payload 后通过 `webContents.send(IPC_EVENTS.TASK_PROGRESS, ...)` 推送
- 脚本退出后发送 `TASK_COMPLETED` IPC
---
### Phase 3渲染层任务状态管理
**目标**:集中管理任务生命周期。
7. **`src/store/task.ts`**(新建)
```ts
export const useTaskStore = defineStore('task', () => {
const tasks = ref<Task[]>([]);
// 创建任务(在 TaskOperationDialog 确认时调用)
const createTask = (options: ExecuteScriptOptions): Task => { ... };
// 更新子任务进度(监听 TASK_PROGRESS 时调用)
const updateSubTaskProgress = (taskId, subTaskId, payload) => { ... };
// 完成子任务
const completeSubTask = (taskId, subTaskId, result) => { ... };
// 重试该任务下所有失败的子任务
const retryFailedSubTasks = async (taskId) => { ... };
// 移除已处理任务
const removeTask = (taskId) => { ... };
// Computed
const pendingTasks = computed(() => ...); // pending + running
const completedTasks = computed(() => ...); // success + failed
return { ... };
});
```
8. **`src/App.vue` 或 `src/pages/home/index.vue`**(修改)
- 应用挂载时注册 IPC 监听:
```ts
onMounted(() => {
window.api.onTaskProgress((_, payload) => taskStore.updateSubTaskProgress(...));
window.api.onTaskStarted((_, payload) => ...);
window.api.onTaskCompleted((_, payload) => taskStore.completeSubTask(...));
});
```
---
### Phase 4UI 组件改造
**目标**:将假数据替换为真实任务数据,支持 tab 切换与操作。
9. **`src/components/TaskList/Card.vue`**(修改)
- `props` 改为接收 `Task` 或 `SubTask` 对象
- 根据 `status` 渲染不同状态标签(`warning` 运行中 / `error` 失败 / `success` 成功)
- 按钮逻辑:
- `running` → "查看"(可展开显示 stdoutTail
- `failed` / `partial_failed` → "重试失败项"emit `retry-failed`
- `success` → "移除"emit `remove`
- 显示进度条(`el-progress` 或自定义 div
10. **`src/components/TaskList/List.vue`**(修改)
- 从 `useTaskStore()` 读取任务列表
- "待处理" tab 显示 `pendingTasks`"已处理" tab 显示 `completedTasks`
- 动态计算 `total` 数量
- 处理 `retry-failed`(调用 `taskStore.retryFailedSubTasks(taskId)`/ `remove` 事件
---
### Phase 5调用点接入
**目标**:让用户在对话框确认后,任务立刻出现在列表中并开始推送进度。
11. **`src/pages/home/components/TaskOperationDialog.vue`**(修改)
- `confirm` 方法中,在调用 `window.api.executeScript(options)` 之前:
```ts
const task = taskStore.createTask(options);
options.taskId = task.id;
window.api.executeScript(options);
```
12. **`src/pages/scripts/index.vue`**(可选)
- 脚本管理页的"测试运行"也可接入任务列表(通过 `SCRIPT_RUN` IPC保持体验一致性。
---
### Phase 6脚本输出执行步骤可选但推荐
**目标**:让子任务进度条真正动起来。
13. **`electron/scripts/mt_trace.js`、`fg_trace.js`、`dy_hotel_trace.js`、`dy_hot_spring_trace.js`**(修改)
- 在关键步骤插入进度输出:
```js
function reportProgress(step, percent) {
console.log('__ZN_PROGRESS__' + JSON.stringify({ step, percent }));
}
// 示例
reportProgress('连接本地浏览器', 10);
reportProgress('定位目标页面', 30);
reportProgress('操作房态数据', 60);
reportProgress('保存并校验', 90);
```
---
## 四、关键数据流
### 首次执行
```
用户点击确认
TaskOperationDialog 调用 taskStore.createTask() → 生成 taskId
window.api.executeScript({ taskId, roomType, startTime, endTime, operation })
主进程 EXECUTE_SCRIPT handler
├─ 发送 IPC task:started
├─ for 循环串行执行每个脚本utilityProcess.fork
│ ├─ 脚本 stdout → 解析 __ZN_PROGRESS__ → 发送 IPC task:progress
│ └─ 脚本 exit → 发送 IPC task:completed
渲染层 Store 接收 IPC → 更新 tasks 数组
List.vue / Card.vue 响应式更新 UI
```
### 重试失败项
```
用户点击"重试失败项"
List.vue 调用 taskStore.retryFailedSubTasks(taskId)
├─ 将该 Task 下所有 failed 的 SubTask 重置为 pending
├─ Task 状态回退为 pending / running
└─ 重新调用 window.api.executeScript({ taskId, ... })
主进程只执行 status === pending 的 SubTask串行排队
执行完成后更新 Task 状态,移回"已处理" Tab
```
---
## 五、文件变更清单汇总
| 新建文件 | 说明 |
|---|---|
| `src/lib/task-types.ts` | Task / SubTask 类型定义 |
| `src/store/task.ts` | 任务状态管理 Pinia Store |
| 修改文件 | 说明 |
|---|---|
| `src/lib/constants.ts` | 新增 `TASK_PROGRESS` 等 IPC 常量 |
| `electron/preload/index.ts` | 暴露 `onTaskProgress` 等监听 API |
| `global.d.ts` | 更新 `WindowApi` 类型 |
| `electron/service/execute-script-service/index.ts` | 解析进度并 emit 事件 |
| `electron/process/runTaskOperationService.ts` | 绑定 taskId 并推送 IPC |
| `src/components/TaskList/List.vue` | 接入真实数据与 tab 过滤 |
| `src/components/TaskList/Card.vue` | 动态状态、进度、操作按钮 |
| `src/pages/home/components/TaskOperationDialog.vue` | 执行前创建 Task |
| `electron/scripts/*.js` | 插入 `__ZN_PROGRESS__` 输出(可选) |
---
## 六、调整说明2026-04-14
针对实现边界补充以下确认:
1. **排队机制**:一个 Task 内的多个 SubTask各渠道脚本在主进程中**串行排队执行**,不会并发启动多个浏览器实例。
2. **重试粒度**"重试"操作仅针对该 Task 下 `status === 'failed'` 的 SubTask成功的 SubTask 保持原结果不变。
3. **Store 方法**`src/store/task.ts` 中增加 `retryFailedSubTasks(taskId)` 方法,用于批量重置并重新触发失败子任务。

View File

@@ -815,7 +815,7 @@ class WindowService {
}
_loadPage(window2, pageName) {
{
return window2.loadURL(`${"http://localhost:5173/"}/${pageName}.html`);
return window2.loadURL(`${"http://localhost:5173"}/${pageName}.html`);
}
}
_loadWindowTemplate(window2, name) {

View File

@@ -54,6 +54,16 @@
"channel": "",
"createdAt": "2026-04-09T19:29:52.000Z",
"updatedAt": "2026-04-09T19:29:52.000Z"
},
{
"id": "script-1776072351629-vw8at",
"name": "test_truoa",
"description": "cs",
"filename": "test-truoa.mjs",
"enabled": true,
"channel": "https://trvoa-usa.inruan.com/",
"createdAt": "2026-04-13T09:25:51.629Z",
"updatedAt": "2026-04-13T09:32:00.457Z"
}
]
}

View File

@@ -0,0 +1,37 @@
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({
channel: 'chrome',
headless: false
});
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://trvoa-usa.inruan.com/login.html');
await page.getByRole('textbox', { name: '用户名:' }).click();
await page.getByRole('textbox', { name: '用户名:' }).fill('testir888');
await page.getByRole('textbox', { name: '密码:' }).click();
await page.getByRole('textbox', { name: '密码:' }).fill('testir888');
await page.getByText('登录').click();
await page.getByText('总计划模块').click();
await page.getByRole('link', { name: '创建总计划' }).click();
await page.locator('iframe').nth(1).contentFrame().locator('.textbox.textbox-invalid > .textbox-addon > .textbox-icon').first().click();
await page.locator('iframe').nth(1).contentFrame().getByText('trvoa1').click();
await page.locator('iframe').nth(1).contentFrame().locator('.tr-of-operator > td:nth-child(2) > .textbox.combo > .textbox-text').click();
await page.locator('iframe').nth(1).contentFrame().locator('.textbox.combo.textbox-focused > .textbox-addon > .textbox-icon').click();
await page.locator('iframe').nth(1).contentFrame().getByText('JOY').click();
await page.locator('iframe').nth(1).contentFrame().locator('.tr-of-manager > td:nth-child(2) > .textbox.combo > .textbox-addon > .textbox-icon').click();
await page.locator('iframe').nth(1).contentFrame().getByText('IT测试').click();
await page.locator('iframe').nth(1).contentFrame().getByRole('link', { name: '保存' }).nth(1).click();
await page.locator('iframe').nth(1).contentFrame().locator('.textbox.textbox-invalid > .textbox-addon > .textbox-icon').click();
await page.locator('iframe').nth(1).contentFrame().getByText('北京竹园2[bjzy]').click();
await page.locator('iframe').nth(1).contentFrame().getByRole('link', { name: '保存' }).nth(1).click();
await page.locator('iframe').nth(1).contentFrame().getByRole('link', { name: '确定' }).click();
// ---------------------
await context.close();
await browser.close();
})();

View File

@@ -1,42 +1,59 @@
<template>
<aside class="w-50 h-full box-border flex flex-col">
<div class="flex items-center m-2">
<img class="w-10 h-10 rounded-md" src="@assets/images/login/white_logo.png" />
<div class="font-bold text-gray-80">YINIAN</div>
<RiSideBarLine class="ml-auto cursor-pointer" />
<aside :class="['h-full box-border flex flex-col transition-all duration-300', sidebarCollapsed ? 'w-16' : 'w-50']">
<div class="flex items-center justify-center m-2">
<img v-if="!sidebarCollapsed" class="w-10 h-10 rounded-md" src="@assets/images/login/white_logo.png" />
<div v-if="!sidebarCollapsed" class="font-bold text-gray-80">YINIAN</div>
<RiSidebarFoldLine
v-if="!sidebarCollapsed"
class="ml-auto cursor-pointer hover:text-[#2B7FFF]"
@click="sidebarCollapsed = true"
/>
<RiSideBarLine
v-else
class="cursor-pointer hover:text-[#2B7FFF]"
@click="sidebarCollapsed = false"
/>
</div>
<div
class="flex justify-center m-2 bg-white rounded-lg p-2.5 border-[#E5E8EE] shadow-sm text-center cursor-pointer hover:bg-[#F5F7FA] hover:text-[#2B7FFF] hover:border-[#2B7FFF]"
class="flex items-center justify-center m-2 bg-white rounded-lg p-2.5 border-[#E5E8EE] shadow-sm text-center cursor-pointer hover:bg-[#F5F7FA] hover:text-[#2B7FFF] hover:border-[#2B7FFF]"
@click="addNewChat">
<RiAddLine /> 新对话
<RiAddLine />
<span v-if="!sidebarCollapsed" class="whitespace-nowrap">新对话</span>
</div>
<div class="overflow-y-auto p-2 ">
<ul class="list-none">
<li v-for="item in groups" :key="item.conversationId" @click="selectedHistoryMessage(item.conversationId)"
:class="[
'flex items-center gap-2 p-2 text-gray-600 rounded-lg cursor-pointer transition-colors',
item.conversationId === selectedConversationId ? 'bg-white shadow-sm border-[#E5E8EE] py-1.5 relative z-10' : 'hover:bg-gray-200'
]">
<span class="w-2 h-2 rounded-full bg-[#BEDBFF] flex-none"></span>
<div class="flex-1 min-w-0">
<div class="truncate text-sm">{{ item.conversationTitle }}</div>
<div v-if="!sidebarCollapsed" class="overflow-y-auto p-2">
<template v-for="bucket in sessionBuckets">
<div v-if="bucket.sessions.length > 0" :key="bucket.key" class="mb-2 last:mb-0">
<div class="px-2 pb-1 text-[11px] font-medium text-gray-400 tracking-tight">
{{ bucket.label }}
</div>
<ul class="list-none">
<li v-for="item in bucket.sessions" :key="item.conversationId" @click="selectedHistoryMessage(item.conversationId)"
:class="[
'flex items-center gap-2 p-2 text-gray-600 rounded-lg cursor-pointer transition-colors',
item.conversationId === selectedConversationId ? 'bg-white shadow-sm border-[#E5E8EE] py-1.5 relative z-10' : 'hover:bg-gray-200'
]">
<span class="w-2 h-2 rounded-full bg-[#BEDBFF] flex-none"></span>
<div class="flex-1 min-w-0">
<div class="truncate text-sm">{{ item.conversationTitle }}</div>
</div>
<el-dropdown v-if="item.conversationId === selectedConversationId" placement="bottom-end">
<el-icon class="el-icon--right">
...
</el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="renameHistoryMessage(item.conversationId)">重命名</el-dropdown-item>
<el-dropdown-item @click="deleteHistoryMessage(item.conversationId)">删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</li>
</ul>
<el-dropdown v-if="item.conversationId === selectedConversationId" placement="bottom-end">
<el-icon class="el-icon--right">
...
</el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="renameHistoryMessage(item.conversationId)">重命名</el-dropdown-item>
<el-dropdown-item @click="deleteHistoryMessage(item.conversationId)">删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</li>
</ul>
</div>
</template>
</div>
</aside>
@@ -69,18 +86,46 @@
</template>
<script setup lang="ts">
import { ref, onMounted, defineEmits } from 'vue'
import { RiSideBarLine, RiArrowRightSLine, RiArrowDownSLine } from '@remixicon/vue'
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { RiSideBarLine, RiSidebarFoldLine, RiArrowDownSLine, RiAddLine } from '@remixicon/vue'
import { getSessionList, deleteSession, updateSession } from '../../api/SessionsApi';
type SessionBucketKey =
| 'today'
| 'yesterday'
| 'withinWeek'
| 'withinTwoWeeks'
| 'withinMonth'
| 'older';
function getSessionBucket(activityMs: number, nowMs: number): SessionBucketKey {
if (!activityMs || activityMs <= 0) return 'older';
const now = new Date(nowMs);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
if (activityMs >= startOfToday) return 'today';
if (activityMs >= startOfYesterday) return 'yesterday';
const daysAgo = (startOfToday - activityMs) / (24 * 60 * 60 * 1000);
if (daysAgo <= 7) return 'withinWeek';
if (daysAgo <= 14) return 'withinTwoWeeks';
if (daysAgo <= 30) return 'withinMonth';
return 'older';
}
const sidebarCollapsed = ref(false)
const deleteDialogVisible = ref(false)
const renameDialogFormVisible = ref(false)
const newMessageName = ref('')
const formLabelWidth = '100px'
const nowMs = ref(Date.now())
let timer: number | undefined
interface HistoryMessage {
conversationId: string;
conversationTitle: string;
updatedAt?: number;
}
/// 记录选择的历史消息ID
@@ -89,6 +134,27 @@ const selectedConversationId = ref<string>('')
/// 历史消息分组数据
const groups = ref<Array<HistoryMessage>>([])
const sessionBuckets = computed(() => {
const buckets: Array<{ key: SessionBucketKey; label: string; sessions: HistoryMessage[] }> = [
{ key: 'today', label: '今天', sessions: [] },
{ key: 'yesterday', label: '昨天', sessions: [] },
{ key: 'withinWeek', label: '近7天', sessions: [] },
{ key: 'withinTwoWeeks', label: '近14天', sessions: [] },
{ key: 'withinMonth', label: '近30天', sessions: [] },
{ key: 'older', label: '更早', sessions: [] },
];
const map = {} as Record<SessionBucketKey, typeof buckets[number]>;
for (const b of buckets) {
map[b.key] = b;
}
for (const session of [...groups.value].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))) {
const bucketKey = getSessionBucket(session.updatedAt || 0, nowMs.value);
map[bucketKey].sessions.push(session);
}
return buckets;
});
/// 定义事件
const emit = defineEmits(['new-chat', 'select-chat'])
@@ -153,6 +219,13 @@ const submitDelete = async () => {
/// 页面加载时获取历史会话列表
onMounted(() => {
getHistoryConversationList()
timer = window.setInterval(() => {
nowMs.value = Date.now()
}, 60 * 1000)
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
/// 获取历史会话列表
@@ -162,7 +235,8 @@ const getHistoryConversationList = async () => {
// 使用整体赋值替换 push避免重复累加
groups.value = list.sessions.map((item: any) => ({
conversationId: item.session_id,
conversationTitle: item.title
conversationTitle: item.title,
updatedAt: item.updated_at ? new Date(item.updated_at).getTime() : Date.now(),
}))
}