feat: implement task management store with IPC integration
- Added a new task store in `src-react/stores/task.ts` to manage tasks and their statuses. - Implemented functions for creating, executing, and retrying tasks, along with handling task progress and completion. - Introduced persistence for tasks using IPC. - Created utility functions for normalizing room types and building subtasks. - Added a new CSS file for global styles in `src-react/styles.css`. - Created runtime types in `src-react/types/runtime.ts` and exported them. - Updated the main entry points for Vue and React applications to support dynamic framework loading. - Refactored chat model interfaces and utility functions into `src/shared/chat-model.ts`. - Updated TypeScript configuration to include paths for React components and types. - Enhanced Vite configuration to support both Vue and React frameworks.
This commit is contained in:
174
src-react/pages/Home/components/AddChannelDialog.tsx
Normal file
174
src-react/pages/Home/components/AddChannelDialog.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ChannelItem } from '../../../stores';
|
||||
import DialogSurface from './DialogSurface';
|
||||
|
||||
type AddChannelDialogProps = {
|
||||
open: boolean;
|
||||
loading: boolean;
|
||||
availableChannels: ChannelItem[];
|
||||
initialSelected: ChannelItem[];
|
||||
onClose: () => void;
|
||||
onConfirm: (items: ChannelItem[]) => Promise<void>;
|
||||
};
|
||||
|
||||
export default function AddChannelDialog({
|
||||
open,
|
||||
loading,
|
||||
availableChannels,
|
||||
initialSelected,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: AddChannelDialogProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [localSelected, setLocalSelected] = useState<ChannelItem[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
setSearchQuery('');
|
||||
setError(null);
|
||||
setSubmitting(false);
|
||||
setLocalSelected(initialSelected.map((item) => ({ ...item })));
|
||||
}, [open, initialSelected]);
|
||||
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
const existingChannelUrls = new Set(localSelected.map((item) => item.channelUrl));
|
||||
const candidateChannels = availableChannels.filter((item) => !existingChannelUrls.has(item.channelUrl));
|
||||
const filteredChannels = (query
|
||||
? candidateChannels.filter((item) => (
|
||||
item.channelName.toLowerCase().includes(query)
|
||||
|| item.channelUrl.toLowerCase().includes(query)
|
||||
))
|
||||
: candidateChannels)
|
||||
.slice(0, 8);
|
||||
|
||||
function addChannel(item: ChannelItem): void {
|
||||
setLocalSelected((current) => {
|
||||
if (current.some((channel) => channel.channelUrl === item.channelUrl)) return current;
|
||||
return [...current, { ...item }];
|
||||
});
|
||||
setSearchQuery('');
|
||||
}
|
||||
|
||||
function removeChannel(id: string): void {
|
||||
setLocalSelected((current) => current.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
async function handleConfirm(): Promise<void> {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onConfirm(localSelected.map((item) => ({ ...item })));
|
||||
} catch (confirmError) {
|
||||
setError(confirmError instanceof Error ? confirmError.message : String(confirmError));
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSurface open={open} title="关联渠道" onClose={onClose}>
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<div className="text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">搜索添加渠道</div>
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="输入渠道名称或链接"
|
||||
className="h-[48px] w-full rounded-[12px] border border-transparent bg-[#EDECE4] px-4 text-[14px] text-[#171717] outline-none transition-colors placeholder:text-[#99A0AE] focus:border-[#3B6DE8] dark:bg-[#222225] dark:text-[#f3f4f6]"
|
||||
/>
|
||||
|
||||
<div className="min-h-[52px] rounded-[12px] border border-black/5 bg-[#E8E6DE]/30 p-2 dark:border-[#2a2a2d] dark:bg-[#222225]/50">
|
||||
{loading ? (
|
||||
<div className="px-2 py-3 text-[13px] text-[#99A0AE] dark:text-gray-500">正在加载可用渠道...</div>
|
||||
) : filteredChannels.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{filteredChannels.map((item) => (
|
||||
<button
|
||||
key={item.channelUrl}
|
||||
type="button"
|
||||
className="flex w-full flex-col rounded-[10px] px-3 py-2 text-left transition-colors hover:bg-[#E8E6DE] dark:hover:bg-[#2a2a2d]"
|
||||
onClick={() => addChannel(item)}
|
||||
>
|
||||
<span className="text-[13px] font-medium text-[#171717] dark:text-[#f3f4f6]">{item.channelName}</span>
|
||||
<span className="truncate text-[12px] text-[#99A0AE] dark:text-gray-500">{item.channelUrl}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 py-3 text-[13px] text-[#99A0AE] dark:text-gray-500">
|
||||
{availableChannels.length === 0 ? '暂无可用渠道,请先检查脚本配置。' : '没有匹配到可添加的渠道。'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-[14px] font-bold text-[#171717]/80 dark:text-[#f3f4f6]/80">已选渠道</div>
|
||||
<div className="max-h-[240px] space-y-2 overflow-y-auto pr-1">
|
||||
{localSelected.length > 0 ? (
|
||||
localSelected.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between gap-3 rounded-[12px] border border-black/5 bg-[#E8E6DE]/50 p-3 dark:border-[#2a2a2d] dark:bg-[#222225]"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium text-[#171717] dark:text-[#f3f4f6]">{item.channelName}</div>
|
||||
<div className="truncate text-[12px] text-[#99A0AE] dark:text-gray-500">{item.channelUrl}</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-full p-1 text-[#99A0AE] transition-colors hover:text-[#ef4444]"
|
||||
onClick={() => removeChannel(item.id)}
|
||||
aria-label={`移除渠道 ${item.channelName}`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-[18px] w-[18px] fill-none stroke-current" strokeWidth="1.8">
|
||||
<path d="M3 6H21M8 6V4H16V6M7 6L8 19H16L17 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-[12px] border border-dashed border-black/5 bg-[#E8E6DE]/30 px-4 py-6 text-center text-[13px] text-[#99A0AE] dark:border-[#2a2a2d] dark:bg-[#222225]/50 dark:text-gray-500">
|
||||
未选择任何渠道
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-[12px] border border-[#f2c7cd] bg-[#fff5f6] px-4 py-3 text-[13px] text-[#c24150] dark:border-[#4b2229] dark:bg-[#2b1c1f] dark:text-[#ffb4bf]">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="h-[40px] rounded-full bg-[#EDECE4] px-6 text-[13px] font-semibold text-[#4B4B4B] transition-colors hover:bg-[#E5E4DC] dark:bg-[#222225] dark:text-gray-200"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h-[40px] rounded-full bg-[#2B7FFF] px-6 text-[13px] font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => {
|
||||
void handleConfirm();
|
||||
}}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? '保存中...' : '确认'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogSurface>
|
||||
);
|
||||
}
|
||||
56
src-react/pages/Home/components/DialogSurface.tsx
Normal file
56
src-react/pages/Home/components/DialogSurface.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type DialogSurfaceProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
widthClassName?: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function DialogSurface({
|
||||
open,
|
||||
title,
|
||||
widthClassName = 'max-w-[560px]',
|
||||
onClose,
|
||||
children,
|
||||
}: DialogSurfaceProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 px-4 py-6 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
'w-full rounded-[20px] bg-[#F4F3EB] shadow-[0_25px_50px_-12px_rgba(0,0,0,0.2)] dark:bg-[#1f1f22]',
|
||||
widthClassName,
|
||||
].join(' ')}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between border-b border-black/6 px-6 py-5 dark:border-white/6">
|
||||
<h2
|
||||
className="text-[20px] font-normal tracking-tight text-[#171717] dark:text-[#f3f4f6]"
|
||||
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 rounded-full p-1 text-[#99A0AE] transition-colors hover:text-[#171717] dark:hover:text-[#f3f4f6]"
|
||||
onClick={onClose}
|
||||
aria-label="关闭弹窗"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5 fill-none stroke-current" strokeWidth="1.8">
|
||||
<path d="M6 6L18 18M18 6L6 18" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-6 pt-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
src-react/pages/Home/components/TaskOperationDialog.tsx
Normal file
203
src-react/pages/Home/components/TaskOperationDialog.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { hotelStaffTypeMappingListUsingPost } from '@api/typeMapping';
|
||||
import type { RoomTypeMapping } from '@api/types';
|
||||
import type { TaskOperationInput } from '../../../stores';
|
||||
import DialogSurface from './DialogSurface';
|
||||
|
||||
type TaskOperationDialogProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (options: TaskOperationInput) => Promise<void>;
|
||||
};
|
||||
|
||||
type RoomTypeMappingLike = RoomTypeMapping & {
|
||||
dyHotSpringName?: string;
|
||||
};
|
||||
|
||||
const OPERATION_OPTIONS: Array<{ label: string; value: 'open' | 'close' }> = [
|
||||
{ label: '开启', value: 'open' },
|
||||
{ label: '关闭', value: 'close' },
|
||||
];
|
||||
|
||||
function normalizeRoomType(item: RoomTypeMappingLike): RoomTypeMappingLike {
|
||||
return {
|
||||
...item,
|
||||
dyHotSpringName: item.dyHotSpringName ?? item.dyHotSrpingName ?? '',
|
||||
dyHotSrpingName: item.dyHotSrpingName ?? item.dyHotSpringName ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export default function TaskOperationDialog({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: TaskOperationDialogProps) {
|
||||
const [roomList, setRoomList] = useState<RoomTypeMappingLike[]>([]);
|
||||
const [roomType, setRoomType] = useState('');
|
||||
const [startTime, setStartTime] = useState('');
|
||||
const [endTime, setEndTime] = useState('');
|
||||
const [operation, setOperation] = useState<'open' | 'close' | ''>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
setRoomType('');
|
||||
setStartTime('');
|
||||
setEndTime('');
|
||||
setOperation('');
|
||||
setError(null);
|
||||
setSubmitting(false);
|
||||
setLoading(true);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await hotelStaffTypeMappingListUsingPost({ body: {} as RoomTypeMapping });
|
||||
setRoomList(Array.isArray(response?.data) ? response.data.map((item: RoomTypeMappingLike) => normalizeRoomType(item)) : []);
|
||||
} catch (requestError) {
|
||||
setRoomList([]);
|
||||
setError(requestError instanceof Error ? requestError.message : String(requestError));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [open]);
|
||||
|
||||
async function handleConfirm(): Promise<void> {
|
||||
if (!roomType) {
|
||||
setError('请选择房型');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!startTime || !endTime) {
|
||||
setError('请选择日期范围');
|
||||
return;
|
||||
}
|
||||
|
||||
if (startTime > endTime) {
|
||||
setError('开始日期不能晚于结束日期');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!operation) {
|
||||
setError('请选择操作');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onConfirm({
|
||||
roomType,
|
||||
startTime,
|
||||
endTime,
|
||||
operation,
|
||||
roomList: roomList.map((item) => ({ ...item })),
|
||||
});
|
||||
} catch (confirmError) {
|
||||
setError(confirmError instanceof Error ? confirmError.message : String(confirmError));
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSurface open={open} title="渠道房型操作" widthClassName="max-w-[480px]" onClose={onClose}>
|
||||
<div className="space-y-4">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-[14px] font-medium text-[#4B4B4B] dark:text-[#9ca3af]">选择房型</span>
|
||||
<select
|
||||
value={roomType}
|
||||
onChange={(event) => setRoomType(event.target.value)}
|
||||
className="h-[48px] w-full rounded-[12px] border border-transparent bg-[#EDECE4] px-4 text-[14px] text-[#171717] outline-none transition-colors focus:border-[#3B6DE8] dark:bg-[#222225] dark:text-[#f3f4f6]"
|
||||
disabled={loading || submitting}
|
||||
>
|
||||
<option value="">{loading ? '正在加载房型...' : '请选择房型'}</option>
|
||||
{roomList.map((item) => (
|
||||
<option key={item.id || item.pmsName} value={item.id}>
|
||||
{item.pmsName || item.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-[14px] font-medium text-[#4B4B4B] dark:text-[#9ca3af]">开始日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={startTime}
|
||||
onChange={(event) => setStartTime(event.target.value)}
|
||||
className="h-[48px] w-full rounded-[12px] border border-transparent bg-[#EDECE4] px-4 text-[14px] text-[#171717] outline-none transition-colors focus:border-[#3B6DE8] dark:bg-[#222225] dark:text-[#f3f4f6]"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-[14px] font-medium text-[#4B4B4B] dark:text-[#9ca3af]">结束日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={endTime}
|
||||
onChange={(event) => setEndTime(event.target.value)}
|
||||
className="h-[48px] w-full rounded-[12px] border border-transparent bg-[#EDECE4] px-4 text-[14px] text-[#171717] outline-none transition-colors focus:border-[#3B6DE8] dark:bg-[#222225] dark:text-[#f3f4f6]"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-[14px] font-medium text-[#4B4B4B] dark:text-[#9ca3af]">选择操作</span>
|
||||
<select
|
||||
value={operation}
|
||||
onChange={(event) => setOperation(event.target.value as 'open' | 'close' | '')}
|
||||
className="h-[48px] w-full rounded-[12px] border border-transparent bg-[#EDECE4] px-4 text-[14px] text-[#171717] outline-none transition-colors focus:border-[#3B6DE8] dark:bg-[#222225] dark:text-[#f3f4f6]"
|
||||
disabled={submitting}
|
||||
>
|
||||
<option value="">请选择操作</option>
|
||||
{OPERATION_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="rounded-[12px] border border-dashed border-black/5 bg-[#E8E6DE]/30 px-4 py-3 text-[12px] leading-6 text-[#6B7280] dark:border-[#2a2a2d] dark:bg-[#222225]/50 dark:text-gray-400">
|
||||
房型列表沿用现有 `typeMapping` 接口,提交时会兼容 `dyHotSrpingName / dyHotSpringName` 两种字段,避免任务执行时遗漏抖音温泉渠道。
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-[12px] border border-[#f2c7cd] bg-[#fff5f6] px-4 py-3 text-[13px] text-[#c24150] dark:border-[#4b2229] dark:bg-[#2b1c1f] dark:text-[#ffb4bf]">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="h-[40px] rounded-full bg-[#EDECE4] px-6 text-[13px] font-semibold text-[#4B4B4B] transition-colors hover:bg-[#E5E4DC] dark:bg-[#222225] dark:text-gray-200"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h-[40px] rounded-full bg-[#2B7FFF] px-6 text-[13px] font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => {
|
||||
void handleConfirm();
|
||||
}}
|
||||
disabled={loading || submitting}
|
||||
>
|
||||
{submitting ? '执行中...' : '确认'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogSurface>
|
||||
);
|
||||
}
|
||||
2
src-react/pages/Home/components/index.ts
Normal file
2
src-react/pages/Home/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as AddChannelDialog } from './AddChannelDialog';
|
||||
export { default as TaskOperationDialog } from './TaskOperationDialog';
|
||||
446
src-react/pages/Home/index.tsx
Normal file
446
src-react/pages/Home/index.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { RawMessage } from '@shared/chat-model';
|
||||
import { extractText, isInternalMessage } from '@shared/chat-model';
|
||||
import { taskCenterList, type taskCenterItem } from '@constant/taskCenterList';
|
||||
import {
|
||||
ChatComposer,
|
||||
ChatHistoryPanel,
|
||||
ChatMessageList,
|
||||
TaskBoard,
|
||||
type ChatHistoryBucket,
|
||||
type ChatMessageItem,
|
||||
type TaskItem,
|
||||
type TaskTabValue,
|
||||
} from '../../components/chat';
|
||||
import { IPC_EVENTS } from '../../lib/constants';
|
||||
import { invokeIpc } from '../../lib/host-api';
|
||||
import {
|
||||
channelStore,
|
||||
chatStore,
|
||||
getCompletedTasks,
|
||||
getPendingTasks,
|
||||
taskStore,
|
||||
useChannelStore,
|
||||
useChatStore,
|
||||
useTaskStore,
|
||||
type StagedAttachment,
|
||||
} from '../../stores';
|
||||
import { AddChannelDialog, TaskOperationDialog } from './components';
|
||||
|
||||
type SessionBucketKey = 'today' | 'yesterday' | 'withinWeek' | 'withinTwoWeeks' | 'withinMonth' | 'older';
|
||||
|
||||
const HISTORY_BUCKET_META: Array<{ key: SessionBucketKey; label: string }> = [
|
||||
{ key: 'today', label: '今天' },
|
||||
{ key: 'yesterday', label: '昨天' },
|
||||
{ key: 'withinWeek', label: '近7天' },
|
||||
{ key: 'withinTwoWeeks', label: '近14天' },
|
||||
{ key: 'withinMonth', label: '近30天' },
|
||||
{ key: 'older', label: '更早' },
|
||||
];
|
||||
|
||||
function getMessageTime(timestamp?: number): string {
|
||||
if (!timestamp) return '--';
|
||||
const date = new Date(timestamp < 1e12 ? timestamp * 1000 : timestamp);
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function getHistoryBucket(activityMs: number, currentMs: number): SessionBucketKey {
|
||||
if (!activityMs || activityMs <= 0) return 'older';
|
||||
|
||||
const now = new Date(currentMs);
|
||||
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';
|
||||
}
|
||||
|
||||
function formatHistoryTime(activityMs: number, currentMs: number): string {
|
||||
if (!activityMs || activityMs <= 0) return '--';
|
||||
|
||||
const date = new Date(activityMs);
|
||||
const current = new Date(currentMs);
|
||||
const sameDay = date.toDateString() === current.toDateString();
|
||||
if (sameDay) {
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
const yesterday = new Date(current);
|
||||
yesterday.setDate(current.getDate() - 1);
|
||||
if (date.toDateString() === yesterday.toDateString()) {
|
||||
return `昨天 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
}
|
||||
|
||||
return `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getTaskDateLabel(createdAt?: string): string {
|
||||
if (!createdAt) return '执行时段';
|
||||
|
||||
const current = new Date(createdAt);
|
||||
const today = new Date();
|
||||
const y = today.getFullYear();
|
||||
const m = today.getMonth();
|
||||
const d = today.getDate();
|
||||
|
||||
const cy = current.getFullYear();
|
||||
const cm = current.getMonth();
|
||||
const cd = current.getDate();
|
||||
|
||||
if (cy === y && cm === m && cd === d) return '今天';
|
||||
|
||||
const yesterday = new Date(y, m, d - 1);
|
||||
if (cy === yesterday.getFullYear() && cm === yesterday.getMonth() && cd === yesterday.getDate()) {
|
||||
return '昨天';
|
||||
}
|
||||
|
||||
return `${String(cm + 1).padStart(2, '0')}/${String(cd).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getTaskRoomTypeLabel(task: { roomType: string; roomList: Array<{ id?: string; pmsName?: string }> }): string {
|
||||
const matchedRoom = Array.isArray(task.roomList)
|
||||
? task.roomList.find((item) => item.id === task.roomType)
|
||||
: null;
|
||||
|
||||
return matchedRoom?.pmsName || task.roomType;
|
||||
}
|
||||
|
||||
function mapMessages(messages: RawMessage[], streamingMessage: RawMessage | null): ChatMessageItem[] {
|
||||
const visibleMessages = messages
|
||||
.filter((message) => !isInternalMessage(message))
|
||||
.filter((message) => {
|
||||
if (message.role === 'user' || message.role === 'assistant') return true;
|
||||
return Boolean(extractText(message).trim());
|
||||
})
|
||||
.map((message): ChatMessageItem => ({
|
||||
id: message.id || `msg-${message.timestamp || Math.random()}`,
|
||||
role: message.role === 'user' ? 'user' : 'assistant',
|
||||
name: message.role === 'user' ? '你' : 'YINIAN',
|
||||
time: getMessageTime(message.timestamp),
|
||||
content: extractText(message),
|
||||
attachments: message._attachedFiles,
|
||||
isError: Boolean(message.isError),
|
||||
}));
|
||||
|
||||
if (streamingMessage && extractText(streamingMessage).trim()) {
|
||||
visibleMessages.push({
|
||||
id: streamingMessage.id || `stream-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
name: 'YINIAN',
|
||||
time: getMessageTime(streamingMessage.timestamp),
|
||||
content: extractText(streamingMessage),
|
||||
attachments: streamingMessage._attachedFiles,
|
||||
isStreaming: true,
|
||||
});
|
||||
}
|
||||
|
||||
return visibleMessages;
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const chat = useChatStore();
|
||||
const taskState = useTaskStore();
|
||||
const channelState = useChannelStore();
|
||||
const [inputMessage, setInputMessage] = useState('');
|
||||
const [attachments, setAttachments] = useState<StagedAttachment[]>([]);
|
||||
const [activeTaskTab, setActiveTaskTab] = useState<TaskTabValue>('pending');
|
||||
const [taskCenterNotice, setTaskCenterNotice] = useState<string | null>(null);
|
||||
const [taskDialogOpen, setTaskDialogOpen] = useState(false);
|
||||
const [addChannelDialogOpen, setAddChannelDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void chatStore.init();
|
||||
void taskStore.init();
|
||||
void channelStore.init();
|
||||
}, []);
|
||||
|
||||
const currentMs = Date.now();
|
||||
const historyBuckets: ChatHistoryBucket[] = HISTORY_BUCKET_META.map((bucket) => ({
|
||||
...bucket,
|
||||
sessions: [],
|
||||
}));
|
||||
const bucketMap = new Map(historyBuckets.map((bucket) => [bucket.key, bucket]));
|
||||
|
||||
for (const session of [...chat.sessions].sort((left, right) => {
|
||||
const leftTime = chat.sessionLastActivity[left.key] || left.updatedAt || 0;
|
||||
const rightTime = chat.sessionLastActivity[right.key] || right.updatedAt || 0;
|
||||
return rightTime - leftTime;
|
||||
})) {
|
||||
const activityMs = chat.sessionLastActivity[session.key] || session.updatedAt || 0;
|
||||
const bucketKey = getHistoryBucket(activityMs, currentMs);
|
||||
const targetBucket = bucketMap.get(bucketKey);
|
||||
if (!targetBucket) continue;
|
||||
|
||||
targetBucket.sessions.push({
|
||||
conversationId: session.key,
|
||||
title: chat.sessionLabels[session.key] || session.displayName || session.key,
|
||||
updatedAt: formatHistoryTime(activityMs, currentMs),
|
||||
});
|
||||
}
|
||||
|
||||
const pendingTasks = getPendingTasks(taskState.tasks);
|
||||
const completedTasks = getCompletedTasks(taskState.tasks);
|
||||
const pendingTaskItems: TaskItem[] = pendingTasks.flatMap((task) => task.subTasks.map((subTask) => ({
|
||||
id: subTask.id,
|
||||
removeTaskId: task.id,
|
||||
title: subTask.name,
|
||||
description: subTask.message || task.title,
|
||||
status: subTask.status,
|
||||
meta: task.title,
|
||||
})));
|
||||
const completedTaskItems: TaskItem[] = completedTasks.map((task) => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: `${task.operation === 'open' ? '开启' : '关闭'} ${getTaskRoomTypeLabel(task)}`,
|
||||
status: task.status,
|
||||
meta: `${task.dateRange[0]} ~ ${task.dateRange[1]}`,
|
||||
}));
|
||||
const currentTaskSource = activeTaskTab === 'pending' ? pendingTasks : completedTasks;
|
||||
const latestTask = currentTaskSource[0];
|
||||
|
||||
const visibleMessages = mapMessages(chat.messages, chat.streamingMessage);
|
||||
|
||||
async function handleSendMessage(): Promise<void> {
|
||||
const sent = await chatStore.sendMessage(inputMessage, attachments);
|
||||
if (sent) {
|
||||
setInputMessage('');
|
||||
setAttachments([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAttach(files: File[]): Promise<void> {
|
||||
const stagedFiles = await chatStore.stageAttachmentFiles(files);
|
||||
setAttachments((currentAttachments) => [...currentAttachments, ...stagedFiles]);
|
||||
}
|
||||
|
||||
async function handleTaskCenterItem(item: taskCenterItem): Promise<void> {
|
||||
setTaskCenterNotice(null);
|
||||
|
||||
if (item.type === 'channel') {
|
||||
if (!channelState.selectedChannels.length) {
|
||||
setTaskCenterNotice('请先在当前项目里配置“已选渠道”,再执行一键打开。');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await invokeIpc(IPC_EVENTS.OPEN_CHANNEL, channelState.selectedChannels);
|
||||
setTaskCenterNotice('已触发“打开渠道”操作。');
|
||||
} catch (error) {
|
||||
setTaskCenterNotice(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setTaskDialogOpen(true);
|
||||
}
|
||||
|
||||
async function handleSaveChannels(items: typeof channelState.selectedChannels): Promise<void> {
|
||||
await channelStore.saveSelectedChannels(items);
|
||||
setTaskCenterNotice('已更新“打开渠道”配置。');
|
||||
setAddChannelDialogOpen(false);
|
||||
}
|
||||
|
||||
async function handleCreateTask(options: Parameters<typeof taskStore.createAndExecuteTask>[0]): Promise<void> {
|
||||
const { task, result } = await taskStore.createAndExecuteTask(options);
|
||||
setTaskDialogOpen(false);
|
||||
|
||||
if (result.success) {
|
||||
setTaskCenterNotice(`已创建任务“${task.title}”。`);
|
||||
return;
|
||||
}
|
||||
|
||||
setTaskCenterNotice(result.error || `任务“${task.title}”创建后执行失败。`);
|
||||
}
|
||||
|
||||
async function handleRetryTask(taskId: string): Promise<void> {
|
||||
const result = await taskStore.retryFailedSubTasks(taskId);
|
||||
if (result.success) {
|
||||
setTaskCenterNotice('已重新触发失败子任务。');
|
||||
return;
|
||||
}
|
||||
|
||||
setTaskCenterNotice(result.error || '重试失败,请稍后再试。');
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="h-full w-full min-h-0">
|
||||
<div className="flex h-full w-full min-h-0 flex-col gap-2 md:flex-row">
|
||||
<ChatHistoryPanel
|
||||
buckets={historyBuckets}
|
||||
loading={!chat.initialized}
|
||||
selectedConversationId={chat.currentSessionKey}
|
||||
onNewChat={() => {
|
||||
void chatStore.newSession();
|
||||
}}
|
||||
onSelectConversation={(conversationId) => {
|
||||
chatStore.switchSession(conversationId);
|
||||
}}
|
||||
onRenameConversation={(conversationId) => {
|
||||
const currentLabel = chat.sessionLabels[conversationId]
|
||||
|| chat.sessions.find((session) => session.key === conversationId)?.displayName
|
||||
|| '';
|
||||
const nextLabel = window.prompt('重命名对话', currentLabel);
|
||||
if (nextLabel) {
|
||||
chatStore.renameSession(conversationId, nextLabel);
|
||||
}
|
||||
}}
|
||||
onDeleteConversation={(conversationId) => {
|
||||
const confirmed = window.confirm('确定删除该会话吗?删除后将无法恢复。');
|
||||
if (confirmed) {
|
||||
void chatStore.deleteSession(conversationId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[20px] bg-white shadow-[0_10px_30px_rgba(15,23,42,0.08)] dark:bg-[#1b1b1d]">
|
||||
<div className="flex items-center justify-between border-b border-[#edf2f7] px-6 py-4 dark:border-[#2a2a2d]">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-[#171717] dark:text-gray-100">智能对话</h2>
|
||||
<div className="mt-1 text-xs text-[#99A0AE] dark:text-gray-500">
|
||||
网关状态:{chat.gatewayStatus === 'connected' ? '已连接' : chat.gatewayStatus === 'reconnecting' ? '重连中' : '未连接'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-[#E5E8EE] px-3 py-1.5 text-xs text-[#525866] transition-colors hover:border-[#2B7FFF] hover:text-[#2B7FFF] dark:border-[#2a2a2d] dark:text-gray-300"
|
||||
onClick={() => {
|
||||
void chatStore.loadSessions();
|
||||
void chatStore.loadHistory();
|
||||
}}
|
||||
>
|
||||
刷新会话
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<ChatMessageList loading={chat.loading} messages={visibleMessages} />
|
||||
<ChatComposer
|
||||
attachments={attachments}
|
||||
error={chat.error}
|
||||
isSending={chat.sending}
|
||||
value={inputMessage}
|
||||
onAttach={handleAttach}
|
||||
onChange={setInputMessage}
|
||||
onDismissError={() => chatStore.clearError()}
|
||||
onRemoveAttachment={(index) => {
|
||||
setAttachments((currentAttachments) => currentAttachments.filter((_, currentIndex) => currentIndex !== index));
|
||||
}}
|
||||
onSend={() => {
|
||||
void handleSendMessage();
|
||||
}}
|
||||
onStop={() => {
|
||||
void chatStore.abortRun();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#edf2f7] dark:border-[#2a2a2d]">
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between pb-3">
|
||||
<h3 className="text-base font-semibold text-[#171717] dark:text-gray-100">任务中心</h3>
|
||||
<div className="text-xs text-[#99A0AE] dark:text-gray-500">沿用当前视觉与入口结构</div>
|
||||
</div>
|
||||
|
||||
{taskCenterNotice ? (
|
||||
<div className="mb-3 rounded-[12px] border border-[#dfeaf6] bg-[#f8fbff] px-4 py-3 text-sm text-[#525866] dark:border-[#2a2a2d] dark:bg-[#232327] dark:text-gray-300">
|
||||
{taskCenterNotice}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 max-[800px]:grid-cols-1">
|
||||
{taskCenterList.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="relative flex items-start gap-3 rounded-[10px] border border-[#dfeaf6] bg-white p-3.5 text-left transition-colors hover:bg-[#F5F7FA] dark:border-[#2a2a2d] dark:bg-[#1f1f22] dark:hover:bg-[#2a2a2d]"
|
||||
onClick={() => {
|
||||
void handleTaskCenterItem(item);
|
||||
}}
|
||||
>
|
||||
{item.type === 'channel' ? (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="absolute right-2 top-2 rounded-full p-1 text-[#99A0AE] transition-colors hover:text-[#525866] dark:hover:text-gray-200"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setTaskCenterNotice(null);
|
||||
void channelStore.refreshAvailableChannels();
|
||||
setAddChannelDialogOpen(true);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setTaskCenterNotice(null);
|
||||
void channelStore.refreshAvailableChannels();
|
||||
setAddChannelDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
aria-label="配置渠道"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-none stroke-current" strokeWidth="1.8">
|
||||
<path
|
||||
d="M12 3V6M12 18V21M4.93 4.93L7.05 7.05M16.95 16.95L19.07 19.07M3 12H6M18 12H21M4.93 19.07L7.05 16.95M16.95 7.05L19.07 4.93M15 12A3 3 0 1 1 9 12A3 3 0 0 1 15 12Z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
) : null}
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-lg border border-dashed border-[#9fc0e8] bg-[#EFF6FF] text-[23px] text-[#3b82f6] dark:border-gray-700 dark:bg-[#222225]">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-[#171717] dark:text-gray-100">{item.title}</div>
|
||||
<div className="mt-1.5 text-[13px] text-[#9aa5b1] dark:text-gray-400">{item.desc}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskBoard
|
||||
activeTab={activeTaskTab}
|
||||
completedItems={completedTaskItems}
|
||||
currentDateLabel={latestTask ? getTaskDateLabel(latestTask.createdAt) : '执行时段'}
|
||||
currentTime={latestTask ? `${latestTask.dateRange[0]} ~ ${latestTask.dateRange[1]}` : '--'}
|
||||
pendingItems={pendingTaskItems}
|
||||
onRemoveTask={(taskId) => {
|
||||
taskStore.removeTask(taskId);
|
||||
}}
|
||||
onRetryTask={(taskId) => {
|
||||
void handleRetryTask(taskId);
|
||||
}}
|
||||
onTabChange={setActiveTaskTab}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TaskOperationDialog
|
||||
open={taskDialogOpen}
|
||||
onClose={() => setTaskDialogOpen(false)}
|
||||
onConfirm={handleCreateTask}
|
||||
/>
|
||||
|
||||
<AddChannelDialog
|
||||
open={addChannelDialogOpen}
|
||||
loading={channelState.loading}
|
||||
availableChannels={channelState.availableChannels}
|
||||
initialSelected={channelState.selectedChannels}
|
||||
onClose={() => setAddChannelDialogOpen(false)}
|
||||
onConfirm={handleSaveChannels}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user