feat: 新增开发规划
This commit is contained in:
223
ChatHistorySessionListMigrationPlan.md
Normal file
223
ChatHistorySessionListMigrationPlan.md
Normal 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
248
ChatPageMigrationPlan.md
Normal 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 被正确关闭,无后台消息泄漏。
|
||||||
126
SidebarToggleMigrationPlan.md
Normal file
126
SidebarToggleMigrationPlan.md
Normal 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`,历史列表与选中态保持。
|
||||||
|
- [ ] 不影响现有的新建对话、选择会话、重命名、删除功能。
|
||||||
298
TaskList-Implementation-Plan.md
Normal file
298
TaskList-Implementation-Plan.md
Normal 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 4:UI 组件改造
|
||||||
|
**目标**:将假数据替换为真实任务数据,支持 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)` 方法,用于批量重置并重新触发失败子任务。
|
||||||
@@ -815,7 +815,7 @@ class WindowService {
|
|||||||
}
|
}
|
||||||
_loadPage(window2, pageName) {
|
_loadPage(window2, pageName) {
|
||||||
{
|
{
|
||||||
return window2.loadURL(`${"http://localhost:5173/"}/${pageName}.html`);
|
return window2.loadURL(`${"http://localhost:5173"}/${pageName}.html`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_loadWindowTemplate(window2, name) {
|
_loadWindowTemplate(window2, name) {
|
||||||
|
|||||||
@@ -54,6 +54,16 @@
|
|||||||
"channel": "",
|
"channel": "",
|
||||||
"createdAt": "2026-04-09T19:29:52.000Z",
|
"createdAt": "2026-04-09T19:29:52.000Z",
|
||||||
"updatedAt": "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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
37
electron/scripts/test-truoa.mjs
Normal file
37
electron/scripts/test-truoa.mjs
Normal 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();
|
||||||
|
})();
|
||||||
@@ -1,20 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside class="w-50 h-full box-border flex flex-col">
|
<aside :class="['h-full box-border flex flex-col transition-all duration-300', sidebarCollapsed ? 'w-16' : 'w-50']">
|
||||||
<div class="flex items-center m-2">
|
<div class="flex items-center justify-center m-2">
|
||||||
<img class="w-10 h-10 rounded-md" src="@assets/images/login/white_logo.png" />
|
<img v-if="!sidebarCollapsed" class="w-10 h-10 rounded-md" src="@assets/images/login/white_logo.png" />
|
||||||
<div class="font-bold text-gray-80">YINIAN</div>
|
<div v-if="!sidebarCollapsed" class="font-bold text-gray-80">YINIAN</div>
|
||||||
<RiSideBarLine class="ml-auto cursor-pointer" />
|
<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>
|
||||||
|
|
||||||
<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">
|
@click="addNewChat">
|
||||||
<RiAddLine /> 新对话
|
<RiAddLine />
|
||||||
|
<span v-if="!sidebarCollapsed" class="whitespace-nowrap">新对话</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-y-auto p-2 ">
|
<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">
|
<ul class="list-none">
|
||||||
<li v-for="item in groups" :key="item.conversationId" @click="selectedHistoryMessage(item.conversationId)"
|
<li v-for="item in bucket.sessions" :key="item.conversationId" @click="selectedHistoryMessage(item.conversationId)"
|
||||||
:class="[
|
:class="[
|
||||||
'flex items-center gap-2 p-2 text-gray-600 rounded-lg cursor-pointer transition-colors',
|
'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'
|
item.conversationId === selectedConversationId ? 'bg-white shadow-sm border-[#E5E8EE] py-1.5 relative z-10' : 'hover:bg-gray-200'
|
||||||
@@ -38,6 +53,8 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- 重命名对话框 -->
|
<!-- 重命名对话框 -->
|
||||||
@@ -69,18 +86,46 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, defineEmits } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||||
import { RiSideBarLine, RiArrowRightSLine, RiArrowDownSLine } from '@remixicon/vue'
|
import { RiSideBarLine, RiSidebarFoldLine, RiArrowDownSLine, RiAddLine } from '@remixicon/vue'
|
||||||
import { getSessionList, deleteSession, updateSession } from '../../api/SessionsApi';
|
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 deleteDialogVisible = ref(false)
|
||||||
const renameDialogFormVisible = ref(false)
|
const renameDialogFormVisible = ref(false)
|
||||||
const newMessageName = ref('')
|
const newMessageName = ref('')
|
||||||
const formLabelWidth = '100px'
|
const formLabelWidth = '100px'
|
||||||
|
const nowMs = ref(Date.now())
|
||||||
|
let timer: number | undefined
|
||||||
|
|
||||||
interface HistoryMessage {
|
interface HistoryMessage {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
conversationTitle: string;
|
conversationTitle: string;
|
||||||
|
updatedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 记录选择的历史消息ID
|
/// 记录选择的历史消息ID
|
||||||
@@ -89,6 +134,27 @@ const selectedConversationId = ref<string>('')
|
|||||||
/// 历史消息分组数据
|
/// 历史消息分组数据
|
||||||
const groups = ref<Array<HistoryMessage>>([])
|
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'])
|
const emit = defineEmits(['new-chat', 'select-chat'])
|
||||||
|
|
||||||
@@ -153,6 +219,13 @@ const submitDelete = async () => {
|
|||||||
/// 页面加载时获取历史会话列表
|
/// 页面加载时获取历史会话列表
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
getHistoryConversationList()
|
getHistoryConversationList()
|
||||||
|
timer = window.setInterval(() => {
|
||||||
|
nowMs.value = Date.now()
|
||||||
|
}, 60 * 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
})
|
})
|
||||||
|
|
||||||
/// 获取历史会话列表
|
/// 获取历史会话列表
|
||||||
@@ -162,7 +235,8 @@ const getHistoryConversationList = async () => {
|
|||||||
// 使用整体赋值替换 push,避免重复累加
|
// 使用整体赋值替换 push,避免重复累加
|
||||||
groups.value = list.sessions.map((item: any) => ({
|
groups.value = list.sessions.map((item: any) => ({
|
||||||
conversationId: item.session_id,
|
conversationId: item.session_id,
|
||||||
conversationTitle: item.title
|
conversationTitle: item.title,
|
||||||
|
updatedAt: item.updated_at ? new Date(item.updated_at).getTime() : Date.now(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user