feat: update desktop workflows and app center

This commit is contained in:
inman
2026-05-13 19:14:56 +08:00
parent 20b5aff4ad
commit 7c8781a6e3
160 changed files with 55492 additions and 1423 deletions

View File

@@ -3,6 +3,7 @@ import {
ArrowUpRight,
Clapperboard,
LayoutGrid,
ShoppingBag,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@@ -18,6 +19,7 @@ import type { AppCenterItem } from '@/types/app-center';
const APP_ICONS = {
Clapperboard,
LayoutGrid,
ShoppingBag,
};
function getAppIcon(icon: string) {
@@ -56,7 +58,7 @@ export function AppCenter() {
void window.electron.openExternal(item.url);
return;
}
if (item.type === 'native' && item.route) {
if ((item.type === 'native' || item.type === 'webview') && item.route) {
navigate(item.route);
return;
}

View File

@@ -12,9 +12,10 @@ import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { hostApiFetch } from '@/lib/host-api';
import { invokeIpc } from '@/lib/api-client';
import { buildQuickTaskPrompt } from '@/lib/quick-task-prompt';
import { cn } from '@/lib/utils';
import { useGatewayStore } from '@/stores/gateway';
import { useQuickTasksStore, type QuickTaskConfig } from '@/stores/quick-tasks';
import { useQuickTasksStore } from '@/stores/quick-tasks';
import { useTranslation } from 'react-i18next';
// ── Types ────────────────────────────────────────────────────────
@@ -102,21 +103,6 @@ function readFileAsBase64(file: globalThis.File): Promise<string> {
});
}
function buildQuickTaskPrompt(tasks: QuickTaskConfig[]): string {
return tasks
.map((task) => {
const skillNames = task.skills
.map((skill) => skill.name.trim())
.filter(Boolean);
const triggerNames = skillNames.length > 0 ? skillNames : [task.name.trim()].filter(Boolean);
return triggerNames
.map((skillName) => `使用${skillName} skill`)
.join(' ');
})
.filter(Boolean)
.join(' ');
}
// ── Component ────────────────────────────────────────────────────
export function ChatInput({

View File

@@ -10,6 +10,7 @@ import { hostApiFetch } from '@/lib/host-api';
const DEFAULT_NIANXX_PLAY_URL = 'http://127.0.0.1:3000';
type NianxxPlayRoute = '/' | '/projects' | '/planning';
type EmbeddedLanguage = 'zh' | 'en';
type ServiceState = 'checking' | 'starting' | 'running' | 'error';
type NianxxPlayServiceStatus = {
@@ -34,20 +35,31 @@ const NIANXX_PLAY_NAV: Array<{
{ path: '/planning', labelKey: 'host.nav.planning', icon: Sparkles },
];
function buildEmbeddedSrc(baseUrl: string, route: NianxxPlayRoute, reloadKey: number) {
export function resolveNianxxPlayEmbeddedLanguage(language?: string): EmbeddedLanguage {
return language?.toLowerCase().startsWith('en') ? 'en' : 'zh';
}
export function buildEmbeddedSrc(
baseUrl: string,
route: NianxxPlayRoute,
reloadKey: number,
language: string = 'zh',
) {
const embeddedLanguage = resolveNianxxPlayEmbeddedLanguage(language);
try {
const url = new URL(route, baseUrl);
url.searchParams.set('zhinianEmbed', '1');
url.searchParams.set('zhinianHostReload', String(reloadKey));
url.searchParams.set('zhinianLang', embeddedLanguage);
return url.toString();
} catch {
const normalizedBase = baseUrl.replace(/\/$/, '');
return `${normalizedBase}${route}?zhinianEmbed=1&zhinianHostReload=${reloadKey}`;
return `${normalizedBase}${route}?zhinianEmbed=1&zhinianHostReload=${reloadKey}&zhinianLang=${embeddedLanguage}`;
}
}
export function NianxxPlay() {
const { t } = useTranslation('appCenter');
const { t, i18n } = useTranslation('appCenter');
const navigate = useNavigate();
const [webviewKey, setWebviewKey] = useState(0);
const [currentRoute, setCurrentRoute] = useState<NianxxPlayRoute>('/');
@@ -58,9 +70,10 @@ export function NianxxPlay() {
const [serviceState, setServiceState] = useState<ServiceState>(configuredAppUrl ? 'running' : 'checking');
const [serviceError, setServiceError] = useState<string | null>(null);
const [appUrl, setAppUrl] = useState(configuredAppUrl || DEFAULT_NIANXX_PLAY_URL);
const embeddedLanguage = resolveNianxxPlayEmbeddedLanguage(i18n.resolvedLanguage || i18n.language);
const initialSrc = useMemo(
() => buildEmbeddedSrc(appUrl, currentRoute, webviewKey),
[appUrl, currentRoute, webviewKey],
() => buildEmbeddedSrc(appUrl, currentRoute, webviewKey, embeddedLanguage),
[appUrl, currentRoute, webviewKey, embeddedLanguage],
);
const ensureService = useCallback(async () => {

View File

@@ -0,0 +1,143 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, ExternalLink, Loader2, RefreshCcw, ShoppingBag } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { useYinianStore } from '@/stores/yinian';
import { buildProductCenterSrc, getProductCenterBaseUrl } from './url';
const PRODUCT_CENTER_PARTITION = 'persist:yinian-travel-resource-ordering';
type ProductCenterWebviewElement = HTMLElement & {
reload?: () => void;
};
export function ProductCenter() {
const { t } = useTranslation('appCenter');
const navigate = useNavigate();
const session = useYinianStore((state) => state.session);
const webviewRef = useRef<ProductCenterWebviewElement | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const baseUrl = useMemo(() => getProductCenterBaseUrl(), []);
const embeddedSrc = useMemo(() => buildProductCenterSrc({
baseUrl,
reloadKey,
session,
}), [baseUrl, reloadKey, session]);
useEffect(() => {
const webview = webviewRef.current;
if (!webview) return;
const handleStart = () => setIsLoading(true);
const handleStop = () => setIsLoading(false);
webview.addEventListener('did-start-loading', handleStart);
webview.addEventListener('did-stop-loading', handleStop);
webview.addEventListener('did-fail-load', handleStop);
return () => {
webview.removeEventListener('did-start-loading', handleStart);
webview.removeEventListener('did-stop-loading', handleStop);
webview.removeEventListener('did-fail-load', handleStop);
};
}, [reloadKey]);
const reloadApp = () => {
setIsLoading(true);
if (typeof webviewRef.current?.reload === 'function') {
webviewRef.current.reload();
return;
}
setReloadKey((value) => value + 1);
};
const openExternal = () => {
void window.electron.openExternal(baseUrl);
};
return (
<div data-testid="product-center-page" className="-m-6 flex h-[calc(100vh-2.5rem)] min-h-0 flex-col overflow-hidden bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50">
<header className="grid h-14 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 border-b border-slate-200/80 bg-white/70 px-4 backdrop-blur dark:border-white/10 dark:bg-slate-950/80">
<div className="flex min-w-0 items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => navigate('/app-center')}
data-testid="product-center-back"
className="shrink-0"
>
<ArrowLeft className="h-4 w-4" />
{t('productCenter.back')}
</Button>
<Button
type="button"
variant="outline"
size="icon"
onClick={reloadApp}
aria-label={t('productCenter.reload')}
title={t('productCenter.reload')}
className="h-9 w-9 shrink-0"
>
<RefreshCcw className="h-4 w-4" />
</Button>
<div className="min-w-0">
<h1 className="truncate text-base font-semibold tracking-normal">
{t('productCenter.title')}
</h1>
</div>
</div>
<div className="flex min-w-0 items-center justify-self-center rounded-lg border border-slate-200/70 bg-white/60 px-3 py-1.5 text-xs font-medium text-slate-600 dark:border-white/10 dark:bg-white/5 dark:text-slate-300">
<ShoppingBag className="mr-1.5 h-3.5 w-3.5 text-[#075985] dark:text-blue-200" />
<span className="truncate">{t('productCenter.badge')}</span>
</div>
<div className="flex min-w-0 justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={openExternal}
data-testid="product-center-open-external"
className="shrink-0"
>
<ExternalLink className="h-4 w-4" />
{t('productCenter.openExternal')}
</Button>
</div>
</header>
<section className="relative min-h-0 flex-1 overflow-hidden bg-white dark:bg-slate-950">
<webview
key={reloadKey}
ref={webviewRef}
data-testid="product-center-frame"
src={embeddedSrc}
partition={PRODUCT_CENTER_PARTITION}
title={t('productCenter.frameTitle')}
className="absolute inset-0 h-full w-full border-0 bg-white dark:bg-slate-950"
allowpopups={false}
webpreferences="contextIsolation=yes,nodeIntegration=no"
/>
{isLoading && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-white/92 p-6 text-center dark:bg-slate-950/92">
<div className="max-w-md">
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-lg bg-[#EAF5FA] text-[#075985] dark:bg-[#1E3A8A]/30 dark:text-blue-100">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
<div className="mt-4 text-base font-semibold text-slate-950 dark:text-slate-50">
{t('productCenter.loading')}
</div>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{t('productCenter.loadingDesc')}
</p>
</div>
</div>
)}
</section>
</div>
);
}
export default ProductCenter;

View File

@@ -0,0 +1,55 @@
import type { YinianSessionState } from '../../../shared/yinian';
const DEFAULT_PRODUCT_CENTER_URL = 'https://ticket.nianxx.cn/';
export interface ProductCenterLaunchOptions {
baseUrl?: string;
reloadKey: number;
session: YinianSessionState;
// Future SSO should use a short-lived one-time ticket, never a long-lived desktop credential.
ssoToken?: string;
}
export function getProductCenterBaseUrl() {
return import.meta.env.VITE_PRODUCT_CENTER_URL?.trim() || DEFAULT_PRODUCT_CENTER_URL;
}
export function buildProductCenterSrc(options: ProductCenterLaunchOptions) {
const baseUrl = options.baseUrl?.trim() || DEFAULT_PRODUCT_CENTER_URL;
try {
const url = new URL(baseUrl);
url.searchParams.set('zhinianEmbed', '1');
url.searchParams.set('zhinianApp', 'product-center');
url.searchParams.set('zhinianHostReload', String(options.reloadKey));
if (options.session.authenticated) {
url.searchParams.set('workspaceId', options.session.currentHotelId);
url.searchParams.set('userId', options.session.user.id);
}
if (options.ssoToken) {
url.searchParams.set('ssoToken', options.ssoToken);
}
return url.toString();
} catch {
const normalizedBase = baseUrl.replace(/\/$/, '');
const params = new URLSearchParams({
zhinianEmbed: '1',
zhinianApp: 'product-center',
zhinianHostReload: String(options.reloadKey),
});
if (options.session.authenticated) {
params.set('workspaceId', options.session.currentHotelId);
params.set('userId', options.session.user.id);
}
if (options.ssoToken) {
params.set('ssoToken', options.ssoToken);
}
return `${normalizedBase}/?${params.toString()}`;
}
}

View File

@@ -138,6 +138,15 @@ type OfficeRuntimeDiagnostics = {
type SettingsTab = 'account' | 'channels' | 'skills' | 'preferences' | 'runtime';
const SETTINGS_TABS = new Set<SettingsTab>(['account', 'channels', 'skills', 'preferences', 'runtime']);
const preferencesPanelClass = 'yinian-panel max-w-4xl overflow-hidden';
const preferencesRowClass = 'p-5 sm:p-6';
const preferenceChoiceClass = (active: boolean) => cn(
'h-10 rounded-lg border px-4 shadow-none',
active
? 'border-[#B7D9EA] bg-[#EAF5FA] text-[#075985] hover:bg-[#DDF0F8] dark:border-blue-300/30 dark:bg-blue-400/15 dark:text-blue-50 dark:hover:bg-blue-400/20'
: 'border-slate-200/80 bg-white/70 text-slate-600 hover:bg-white hover:text-slate-950 dark:border-white/10 dark:bg-slate-950/40 dark:text-slate-300 dark:hover:bg-white/[0.08] dark:hover:text-slate-50'
);
export function Settings() {
const navigate = useNavigate();
const location = useLocation();
@@ -701,7 +710,7 @@ export function Settings() {
};
return (
<div data-testid="settings-page" className="flex h-[calc(100vh-2.5rem)] flex-col overflow-hidden -m-6 dark:bg-background">
<div data-testid="settings-page" className="flex h-[calc(100vh-2.5rem)] flex-col overflow-hidden -m-6 bg-[#F7FAFC] text-slate-950 dark:bg-background dark:text-slate-50">
<div className="w-full max-w-6xl mx-auto flex flex-col h-full p-6">
<Tabs value={activeTab} onValueChange={handleTabChange} className="flex min-h-0 flex-1 flex-col">
<TabsList className="mb-5 grid h-auto w-full shrink-0 grid-cols-2 gap-1 md:grid-cols-5">
@@ -883,72 +892,82 @@ export function Settings() {
<YinianSkills />
</TabsContent>
<TabsContent value="preferences" className="mt-0 h-full min-h-0 space-y-8 overflow-y-auto pr-2 pb-8 -mr-2">
{/* Appearance */}
<div>
<h2 className="text-2xl font-semibold text-foreground mb-6 tracking-normal">
{t('appearance.title')}
</h2>
<div className="space-y-6">
<div className="space-y-3">
<Label className="text-[15px] font-medium text-foreground/80">{t('appearance.theme')}</Label>
<div className="flex flex-wrap gap-2">
<Button
variant={theme === 'light' ? 'secondary' : 'outline'}
className={cn("rounded-full px-5 h-10 border-black/10 dark:border-white/10", theme === 'light' ? "bg-black/5 dark:bg-white/10 text-foreground" : "bg-transparent text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5")}
onClick={() => setTheme('light')}
>
<Sun className="h-4 w-4 mr-2" />
{t('appearance.light')}
</Button>
<Button
variant={theme === 'dark' ? 'secondary' : 'outline'}
className={cn("rounded-full px-5 h-10 border-black/10 dark:border-white/10", theme === 'dark' ? "bg-black/5 dark:bg-white/10 text-foreground" : "bg-transparent text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5")}
onClick={() => setTheme('dark')}
>
<Moon className="h-4 w-4 mr-2" />
{t('appearance.dark')}
</Button>
<Button
variant={theme === 'system' ? 'secondary' : 'outline'}
className={cn("rounded-full px-5 h-10 border-black/10 dark:border-white/10", theme === 'system' ? "bg-black/5 dark:bg-white/10 text-foreground" : "bg-transparent text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5")}
onClick={() => setTheme('system')}
>
<Monitor className="h-4 w-4 mr-2" />
{t('appearance.system')}
</Button>
</div>
</div>
<div className="space-y-3">
<Label className="text-[15px] font-medium text-foreground/80">{t('appearance.language')}</Label>
<div className="flex flex-wrap gap-2">
{SUPPORTED_LANGUAGES.map((lang) => (
<Button
key={lang.code}
variant={language === lang.code ? 'secondary' : 'outline'}
className={cn("rounded-full px-5 h-10 border-black/10 dark:border-white/10", language === lang.code ? "bg-black/5 dark:bg-white/10 text-foreground" : "bg-transparent text-muted-foreground hover:bg-black/5 dark:hover:bg-white/5")}
onClick={() => setLanguage(lang.code)}
>
{lang.label}
</Button>
))}
</div>
</div>
<div className="flex items-center justify-between">
<div>
<Label className="text-[15px] font-medium text-foreground/80">{t('appearance.launchAtStartup')}</Label>
<p className="text-[13px] text-muted-foreground mt-1">
{t('appearance.launchAtStartupDesc')}
<TabsContent value="preferences" className="mt-0 h-full min-h-0 overflow-y-auto pr-2 pb-8 -mr-2">
<div className={preferencesPanelClass}>
<div className="border-b border-slate-200/80 p-5 dark:border-white/10 sm:p-6">
<h2 className="text-2xl font-semibold tracking-normal text-foreground">
{t('appearance.title')}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{t('appearance.description')}
</p>
</div>
<Switch
checked={launchAtStartup}
onCheckedChange={setLaunchAtStartup}
/>
</div>
</div>
</div>
<div className="divide-y divide-slate-200/80 dark:divide-white/10">
<section className={preferencesRowClass}>
<Label className="text-[15px] font-medium text-foreground">{t('appearance.theme')}</Label>
<div className="mt-3 grid gap-2 sm:grid-cols-3">
<Button
type="button"
variant="outline"
className={preferenceChoiceClass(theme === 'light')}
onClick={() => setTheme('light')}
>
<Sun className="mr-2 h-4 w-4" />
{t('appearance.light')}
</Button>
<Button
type="button"
variant="outline"
className={preferenceChoiceClass(theme === 'dark')}
onClick={() => setTheme('dark')}
>
<Moon className="mr-2 h-4 w-4" />
{t('appearance.dark')}
</Button>
<Button
type="button"
variant="outline"
className={preferenceChoiceClass(theme === 'system')}
onClick={() => setTheme('system')}
>
<Monitor className="mr-2 h-4 w-4" />
{t('appearance.system')}
</Button>
</div>
</section>
<section className={preferencesRowClass}>
<Label className="text-[15px] font-medium text-foreground">{t('appearance.language')}</Label>
<div className="mt-3 flex flex-wrap gap-2">
{SUPPORTED_LANGUAGES.map((lang) => (
<Button
key={lang.code}
type="button"
variant="outline"
className={preferenceChoiceClass(language === lang.code)}
onClick={() => setLanguage(lang.code)}
>
{lang.label}
</Button>
))}
</div>
</section>
<section className={cn(preferencesRowClass, 'flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between')}>
<div>
<Label className="text-[15px] font-medium text-foreground">{t('appearance.launchAtStartup')}</Label>
<p className="mt-1 text-[13px] text-muted-foreground">
{t('appearance.launchAtStartupDesc')}
</p>
</div>
<Switch
checked={launchAtStartup}
onCheckedChange={setLaunchAtStartup}
/>
</section>
</div>
</div>
</TabsContent>
<TabsContent value="runtime" className="mt-0 h-full min-h-0 space-y-8 overflow-y-auto pr-2 pb-8 -mr-2">

1479
src/pages/Tasks/index.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
import type { ComponentType } from 'react';
import {
BarChart3,
Bell,
FileText,
} from 'lucide-react';
import type { CronJob, ScheduleType } from '@/types/cron';
export const schedulePresets: { key: string; value: string; type: ScheduleType }[] = [
{ key: 'everyMinute', value: '* * * * *', type: 'interval' },
{ key: 'every5Min', value: '*/5 * * * *', type: 'interval' },
{ key: 'every15Min', value: '*/15 * * * *', type: 'interval' },
{ key: 'everyHour', value: '0 * * * *', type: 'interval' },
{ key: 'daily9am', value: '0 9 * * *', type: 'daily' },
{ key: 'daily6pm', value: '0 18 * * *', type: 'daily' },
{ key: 'weeklyMon', value: '0 9 * * 1', type: 'weekly' },
{ key: 'monthly1st', value: '0 9 1 * *', type: 'monthly' },
];
export type ScheduledTaskDraft = {
name: string;
message: string;
schedule: string;
enabled?: boolean;
};
export type TaskTemplate = {
id: string;
nameKey: string;
descriptionKey: string;
messageKey: string;
schedule: string;
icon: ComponentType<{ className?: string }>;
};
export const taskTemplates: TaskTemplate[] = [
{
id: 'daily-brief',
nameKey: 'templates.dailyBrief.name',
descriptionKey: 'templates.dailyBrief.description',
messageKey: 'templates.dailyBrief.message',
schedule: '0 9 * * *',
icon: FileText,
},
{
id: 'weekly-review',
nameKey: 'templates.weeklyReview.name',
descriptionKey: 'templates.weeklyReview.description',
messageKey: 'templates.weeklyReview.message',
schedule: '0 18 * * 5',
icon: BarChart3,
},
{
id: 'risk-check',
nameKey: 'templates.riskCheck.name',
descriptionKey: 'templates.riskCheck.description',
messageKey: 'templates.riskCheck.message',
schedule: '0 10 * * *',
icon: Bell,
},
];
export function parseScheduleExpr(schedule: CronJob['schedule']): string {
if (typeof schedule === 'string') return schedule;
if (schedule && typeof schedule === 'object' && 'expr' in schedule && typeof schedule.expr === 'string') {
return schedule.expr;
}
return '0 9 * * *';
}
export function formatSchedule(schedule: CronJob['schedule'], t: (key: string, options?: Record<string, unknown>) => string): string {
const expr = parseScheduleExpr(schedule);
const preset = schedulePresets.find((item) => item.value === expr);
if (preset) return t(`presets.${preset.key}`);
const parts = expr.split(' ');
if (parts.length !== 5) return expr;
const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
if (minute === '*' && hour === '*') return t('presets.everyMinute');
if (minute.startsWith('*/')) return t('schedule.everyMinutes', { count: Number(minute.slice(2)) });
if (hour === '*' && minute === '0') return t('presets.everyHour');
if (dayOfWeek !== '*' && dayOfMonth === '*') return t('schedule.weeklyAt', { day: dayOfWeek, time: `${hour}:${minute.padStart(2, '0')}` });
if (dayOfMonth !== '*') return t('schedule.monthlyAtDay', { day: dayOfMonth, time: `${hour}:${minute.padStart(2, '0')}` });
if (hour !== '*') return t('schedule.dailyAt', { time: `${hour}:${minute.padStart(2, '0')}` });
return expr;
}

View File

@@ -1,411 +0,0 @@
import { useEffect, useMemo } from 'react';
import type { ComponentType } from 'react';
import {
ArrowRight,
BookOpen,
Clapperboard,
Clock3,
LayoutGrid,
MessageSquarePlus,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { YinianPageShell } from '@/components/yinian/ui';
import { cn } from '@/lib/utils';
import { useAppCenterStore } from '@/stores/app-center';
import { useChatStore, type ChatSession } from '@/stores/chat';
import { useGatewayStore } from '@/stores/gateway';
import { useYinianStore } from '@/stores/yinian';
import type { AppCenterItem } from '@/types/app-center';
const APP_ICONS = {
Clapperboard,
LayoutGrid,
};
type QuickEntry = {
id: string;
titleKey: string;
descriptionKey: string;
path: string;
icon: ComponentType<{ className?: string }>;
};
const QUICK_ENTRIES: QuickEntry[] = [
{
id: 'tasks',
titleKey: 'quickHome.entries.tasks.title',
descriptionKey: 'quickHome.entries.tasks.description',
path: '/cron',
icon: Clock3,
},
{
id: 'knowledge',
titleKey: 'quickHome.entries.knowledge.title',
descriptionKey: 'quickHome.entries.knowledge.description',
path: '/knowledge',
icon: BookOpen,
},
];
function getAppIcon(icon: string) {
return APP_ICONS[icon as keyof typeof APP_ICONS] ?? LayoutGrid;
}
function LauncherIcon({
children,
tone = 'neutral',
}: {
children: React.ReactNode;
tone?: 'primary' | 'neutral';
}) {
return (
<div
className={cn(
'flex h-11 w-11 shrink-0 items-center justify-center rounded-lg',
tone === 'primary'
? 'bg-[#0369A1] text-white shadow-[0_10px_22px_rgba(3,105,161,0.16)]'
: 'border border-[#D5E8F3]/80 bg-white/70 text-[#075985] dark:border-white/10 dark:bg-white/10 dark:text-slate-200',
)}
>
{children}
</div>
);
}
function SectionHeader({
title,
description,
action,
icon: Icon,
}: {
title: string;
description?: string;
action?: React.ReactNode;
icon?: ComponentType<{ className?: string }>;
}) {
return (
<div className="flex min-w-0 flex-col items-start gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
{Icon ? (
<LauncherIcon tone="primary">
<Icon className="h-5 w-5" />
</LauncherIcon>
) : null}
<div className="min-w-0">
<h2 className="text-base font-semibold tracking-normal text-slate-950 dark:text-slate-50">
{title}
</h2>
{description ? (
<p className="mt-1 text-sm leading-5 text-muted-foreground">
{description}
</p>
) : null}
</div>
</div>
{action}
</div>
);
}
function cleanHistorySessionLabel(label: string): string {
return label
.replace(/^\s*System\s*:\s*\[\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?\s+GMT[+-]\d+\]\s+[A-Za-z][\w -]*(?:\[[^\]]+\])?\s+(?:DM|Group|Room|Chat|Message)\s+\|\s+\S+(?:\s+\[msg:[^\]]+\])?\s*/i, '')
.trim();
}
const INTERNAL_OPENCLAW_MAIN_SESSION_KEY = 'agent:main:main';
function normalizeActivityMs(value: number | undefined): number {
if (!value || !Number.isFinite(value)) return 0;
return value < 1e12 ? value * 1000 : value;
}
function getRecentSessionLabel(
session: ChatSession,
sessionLabels: Record<string, string>,
fallback: string,
): { label: string; meaningful: boolean } {
const rawLabel = sessionLabels[session.key] ?? session.label ?? session.displayName ?? '';
const cleaned = cleanHistorySessionLabel(rawLabel);
const meaningful = Boolean(
cleaned
&& cleaned !== session.key
&& !cleaned.startsWith('agent:')
&& !/^session-\d+$/i.test(cleaned),
);
return { label: meaningful ? cleaned : fallback, meaningful };
}
function formatRecentSessionTime(
activityMs: number,
language: string,
t: (key: string, options?: Record<string, unknown>) => string,
): string {
if (!activityMs || activityMs <= 0) return t('quickHome.history.noTime');
const diffMs = Math.max(0, Date.now() - activityMs);
const minuteMs = 60 * 1000;
const hourMs = 60 * minuteMs;
if (diffMs < minuteMs) return t('quickHome.history.justNow');
if (diffMs < hourMs) return t('quickHome.history.minutesAgo', { count: Math.floor(diffMs / minuteMs) });
if (diffMs < 24 * hourMs) return t('quickHome.history.hoursAgo', { count: Math.floor(diffMs / hourMs) });
return new Intl.DateTimeFormat(language === 'zh' ? 'zh-CN' : 'en-US', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(activityMs);
}
export function Today() {
const { t, i18n } = useTranslation('dashboard');
const { t: tAppCenter } = useTranslation('appCenter');
const navigate = useNavigate();
const config = useYinianStore((state) => state.config);
const initApps = useAppCenterStore((state) => state.init);
const appItems = useAppCenterStore((state) => state.items);
const sessions = useChatStore((state) => state.sessions);
const sessionLabels = useChatStore((state) => state.sessionLabels);
const sessionLastActivity = useChatStore((state) => state.sessionLastActivity);
const loadSessions = useChatStore((state) => state.loadSessions);
const switchSession = useChatStore((state) => state.switchSession);
const newSession = useChatStore((state) => state.newSession);
const gatewayReady = useGatewayStore((state) => state.status.state === 'running' && state.status.gatewayReady !== false);
useEffect(() => {
initApps();
}, [initApps]);
useEffect(() => {
if (gatewayReady) void loadSessions();
}, [gatewayReady, loadSessions]);
const pinnedApps = useMemo(
() => appItems.filter((item) => item.pinned).slice(0, 4),
[appItems],
);
const recentSessions = useMemo(() => sessions
.flatMap((session) => {
const activityMs = normalizeActivityMs(sessionLastActivity[session.key] ?? session.updatedAt);
const label = getRecentSessionLabel(session, sessionLabels, t('quickHome.history.untitled'));
if (session.key === INTERNAL_OPENCLAW_MAIN_SESSION_KEY && !label.meaningful) {
return [];
}
return [{
session,
activityMs,
label: label.label,
meaningful: label.meaningful,
}];
})
.sort((a, b) => b.activityMs - a.activityMs)
.slice(0, 6), [sessionLabels, sessionLastActivity, sessions, t]);
const workspaceName = config?.hotel.name?.trim() || t('quickHome.workspaceFallback');
const userName = config?.user.name?.trim();
const openApp = (item: AppCenterItem) => {
if (item.type === 'native' && item.route) {
navigate(item.route);
return;
}
if ((item.type === 'external' || item.type === 'webview') && item.url) {
void window.electron.openExternal(item.url);
}
};
const openSession = (sessionKey: string) => {
switchSession(sessionKey);
navigate('/chat');
};
const handleStartWork = () => {
newSession();
navigate('/chat');
};
return (
<YinianPageShell data-testid="today-page" className="h-full max-w-none overflow-hidden">
<div className="mx-auto flex h-full w-full max-w-6xl flex-col gap-3 overflow-hidden">
<header className="shrink-0">
<div className="truncate text-xs font-medium text-slate-500 dark:text-slate-400">
{workspaceName}
{userName ? <span className="ml-2 text-slate-400 dark:text-slate-500">/ {userName}</span> : null}
</div>
</header>
<main className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid gap-3 pb-1 lg:grid-cols-12">
<section className="p-5 lg:col-span-7">
<div className="flex h-full min-h-[292px] flex-col">
<div className="grid grid-cols-[auto_1fr_auto] items-start gap-4">
<LauncherIcon tone="primary">
<MessageSquarePlus className="h-5 w-5" />
</LauncherIcon>
<div className="min-w-0">
<h1 className="text-2xl font-semibold tracking-normal text-slate-950 dark:text-slate-50 md:text-3xl">
{t('quickHome.primary.title')}
</h1>
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-600 dark:text-slate-300">
{t('quickHome.primary.description')}
</p>
</div>
<button
type="button"
onClick={handleStartWork}
className="inline-flex h-9 shrink-0 items-center justify-center gap-1 rounded-lg bg-[#0369A1] px-3 text-sm font-medium text-white shadow-[0_10px_22px_rgba(3,105,161,0.16)] transition-colors hover:bg-[#075985] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0369A1]/25"
>
{t('quickHome.primary.cta')}
<ArrowRight className="h-4 w-4" />
</button>
</div>
<div className="mt-5 border-t border-[#D5E8F3] pt-4 dark:border-white/10">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-slate-950 dark:text-slate-50">
{t('quickHome.history.title')}
</div>
{recentSessions.length > 0 ? (
<button
type="button"
onClick={() => navigate('/chat')}
className="text-xs font-medium text-[#0369A1] hover:text-[#075985] dark:text-blue-300 dark:hover:text-blue-200"
>
{t('quickHome.history.all')}
</button>
) : null}
</div>
{recentSessions.length > 0 ? (
<div className="mt-3 grid gap-1.5">
{recentSessions.map((item) => (
<button
key={item.session.key}
type="button"
onClick={() => openSession(item.session.key)}
className="grid grid-cols-[1fr_auto] items-center gap-3 rounded-md px-2 py-2.5 text-left transition-colors hover:bg-white/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0369A1]/20 dark:hover:bg-white/10"
>
<span className="min-w-0 truncate text-sm font-medium text-slate-800 dark:text-slate-100">
{item.label}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{formatRecentSessionTime(item.activityMs, i18n.language, t)}
</span>
</button>
))}
</div>
) : (
<div className="mt-3 rounded-md border border-slate-200/70 bg-slate-50 px-3 py-3 text-sm text-muted-foreground dark:border-white/10 dark:bg-white/5">
{t('quickHome.history.empty')}
</div>
)}
</div>
</div>
</section>
<div className="grid gap-3 lg:col-span-5">
<section className="yinian-panel-soft p-5">
<div className="flex min-h-[292px] flex-col">
<SectionHeader
icon={LayoutGrid}
title={t('quickHome.apps.title')}
description={t('quickHome.apps.description')}
action={(
<button
type="button"
onClick={() => navigate('/app-center')}
className="inline-flex h-9 shrink-0 items-center justify-center gap-1 rounded-lg border border-slate-200/80 bg-white px-3 text-sm font-medium text-slate-700 transition-colors hover:border-[#9AC6DC] hover:text-[#075985] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0369A1]/25 dark:border-white/10 dark:bg-white/10 dark:text-slate-200 dark:hover:bg-white/20"
>
{t('quickHome.apps.all')}
<ArrowRight className="h-4 w-4" />
</button>
)}
/>
{pinnedApps.length > 0 ? (
<div className="mt-4 grid gap-2">
{pinnedApps.map((item) => {
const Icon = getAppIcon(item.icon);
return (
<button
key={item.id}
type="button"
onClick={() => openApp(item)}
className={cn(
'group grid min-h-[72px] grid-cols-[auto_1fr_auto] items-center gap-3 rounded-md px-2 py-2 text-left transition-colors',
'hover:bg-white/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0369A1]/25 dark:hover:bg-white/10',
)}
>
<LauncherIcon>
<Icon className="h-5 w-5" />
</LauncherIcon>
<div className="min-w-0">
<div className="truncate font-semibold text-slate-950 dark:text-slate-50">
{tAppCenter(item.nameKey)}
</div>
<p className="mt-1 line-clamp-1 text-sm leading-5 text-muted-foreground">
{tAppCenter(item.descriptionKey)}
</p>
</div>
<ArrowRight className="h-4 w-4 shrink-0 text-slate-400 transition-transform group-hover:translate-x-0.5 group-hover:text-[#0369A1]" />
</button>
);
})}
</div>
) : (
<button
type="button"
onClick={() => navigate('/app-center')}
className="mt-4 grid min-h-[112px] grid-cols-[auto_1fr_auto] items-center gap-4 rounded-lg border border-slate-200/70 bg-white p-4 text-left transition-colors hover:border-[#9AC6DC] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0369A1]/25 dark:border-white/10 dark:bg-slate-900/40 dark:hover:bg-white/10"
>
<LauncherIcon>
<LayoutGrid className="h-5 w-5" />
</LauncherIcon>
<div className="min-w-0">
<div className="font-semibold">{t('quickHome.apps.emptyTitle')}</div>
<div className="mt-1 text-sm text-muted-foreground">{t('quickHome.apps.emptyDescription')}</div>
</div>
<ArrowRight className="h-4 w-4 shrink-0 text-slate-400" />
</button>
)}
</div>
</section>
{QUICK_ENTRIES.map((entry) => {
const Icon = entry.icon;
return (
<button
key={entry.id}
type="button"
onClick={() => navigate(entry.path)}
className={cn(
'group grid min-h-[112px] grid-cols-[auto_1fr_auto] items-center gap-4 rounded-lg border border-slate-200/80 bg-white p-4 text-left shadow-none transition-all',
'hover:-translate-y-0.5 hover:bg-white hover:shadow-[0_14px_32px_rgba(15,23,42,0.06)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0369A1]/25',
'dark:border-white/10 dark:bg-slate-950/70 dark:hover:bg-slate-900/70',
)}
>
<LauncherIcon>
<Icon className="h-5 w-5" />
</LauncherIcon>
<div className="min-w-0">
<div className="truncate font-semibold text-slate-950 dark:text-slate-50">
{t(entry.titleKey)}
</div>
<p className="mt-1 line-clamp-2 text-sm leading-5 text-muted-foreground">
{t(entry.descriptionKey)}
</p>
</div>
<ArrowRight className="h-4 w-4 shrink-0 text-slate-400 transition-transform group-hover:translate-x-0.5 group-hover:text-[#0369A1]" />
</button>
);
})}
</div>
</div>
</main>
</div>
</YinianPageShell>
);
}
export default Today;

View File

@@ -84,7 +84,7 @@ export function YinianLogin() {
} else {
void window.yinian.auth.clearSavedCredentials().catch(() => undefined);
}
navigate('/today', { replace: true });
navigate('/', { replace: true });
};
return (

View File

@@ -4,6 +4,7 @@ import {
ChevronRight,
CheckCircle2,
CloudDownload,
Edit3,
Hash,
HardDrive,
History,
@@ -20,7 +21,7 @@ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '
import { useYinianStore } from '@/stores/yinian';
import { useYinianSkillsStore } from '@/stores/yinian-skills';
import { useSkillsStore } from '@/stores/skills';
import { useQuickTasksStore } from '@/stores/quick-tasks';
import { useQuickTasksStore, type QuickTaskConfig } from '@/stores/quick-tasks';
import { cn } from '@/lib/utils';
import {
YinianEmptyState,
@@ -101,6 +102,7 @@ export function YinianSkills() {
const [activeTab, setActiveTab] = useState<'server' | 'local' | 'quickTasks'>('quickTasks');
const [selectedServerSkillId, setSelectedServerSkillId] = useState<string | null>(null);
const [selectedLocalSkillId, setSelectedLocalSkillId] = useState<string | null>(null);
const [editingQuickTaskId, setEditingQuickTaskId] = useState<string | null>(null);
const [quickTaskDraft, setQuickTaskDraft] = useState({
name: '',
description: '',
@@ -118,6 +120,7 @@ export function YinianSkills() {
const fetchOpenClawSkills = useSkillsStore((state) => state.fetchSkills);
const quickTasks = useQuickTasksStore((state) => state.tasks);
const addQuickTask = useQuickTasksStore((state) => state.addTask);
const updateQuickTask = useQuickTasksStore((state) => state.updateTask);
const deleteQuickTask = useQuickTasksStore((state) => state.deleteTask);
const toggleQuickTask = useQuickTasksStore((state) => state.toggleTask);
const toggleComposerVisibility = useQuickTasksStore((state) => state.toggleComposerVisibility);
@@ -176,7 +179,21 @@ export function YinianSkills() {
}
};
const handleAddQuickTask = () => {
const resetQuickTaskDraft = () => {
setEditingQuickTaskId(null);
setQuickTaskDraft({ name: '', description: '', skillIds: [] });
};
const startEditQuickTask = (task: QuickTaskConfig) => {
setEditingQuickTaskId(task.id);
setQuickTaskDraft({
name: task.name,
description: task.description,
skillIds: task.skills.map((skill) => skill.id),
});
};
const handleSaveQuickTask = () => {
const name = quickTaskDraft.name.trim();
const selectedSkills = quickTaskSkillOptions
.filter((skill) => quickTaskDraft.skillIds.includes(skill.id))
@@ -185,15 +202,25 @@ export function YinianSkills() {
toast.error(t('business.quickTasks.validation'));
return;
}
addQuickTask({
name,
description: quickTaskDraft.description.trim(),
skills: selectedSkills,
enabled: true,
showInComposer: true,
});
setQuickTaskDraft({ name: '', description: '', skillIds: [] });
toast.success(t('business.quickTasks.created'));
if (editingQuickTaskId) {
updateQuickTask(editingQuickTaskId, {
name,
description: quickTaskDraft.description.trim(),
skills: selectedSkills,
});
toast.success(t('business.quickTasks.updated'));
} else {
addQuickTask({
name,
description: quickTaskDraft.description.trim(),
skills: selectedSkills,
enabled: true,
showInComposer: true,
});
toast.success(t('business.quickTasks.created'));
}
resetQuickTaskDraft();
};
return (
@@ -401,7 +428,18 @@ export function YinianSkills() {
</button>
<button
type="button"
onClick={() => deleteQuickTask(task.id)}
onClick={() => startEditQuickTask(task)}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-slate-50 hover:text-foreground dark:hover:bg-white/5"
title={t('business.quickTasks.edit')}
>
<Edit3 className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => {
deleteQuickTask(task.id);
if (editingQuickTaskId === task.id) resetQuickTaskDraft();
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950/30"
title={t('business.quickTasks.delete')}
>
@@ -413,7 +451,16 @@ export function YinianSkills() {
</div>
</div>
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 dark:border-white/10 dark:bg-slate-900/40">
<div className="text-sm font-semibold">{t('business.quickTasks.addTitle')}</div>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold">
{editingQuickTaskId ? t('business.quickTasks.editTitle') : t('business.quickTasks.addTitle')}
</div>
{editingQuickTaskId && (
<Button type="button" variant="ghost" size="sm" onClick={resetQuickTaskDraft} className="h-7 px-2 text-xs">
{t('common:actions.cancel')}
</Button>
)}
</div>
<div className="mt-3 space-y-2">
<input
value={quickTaskDraft.name}
@@ -462,9 +509,9 @@ export function YinianSkills() {
})}
</div>
</div>
<Button type="button" onClick={handleAddQuickTask} className={cn('w-full', yinianPrimaryButton)}>
<Button type="button" onClick={handleSaveQuickTask} className={cn('w-full', yinianPrimaryButton)}>
<Plus className="mr-2 h-4 w-4" />
{t('business.quickTasks.add')}
{editingQuickTaskId ? t('business.quickTasks.save') : t('business.quickTasks.add')}
</Button>
</div>
</div>