feat: update desktop workflows and app center
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
143
src/pages/ProductCenter/index.tsx
Normal file
143
src/pages/ProductCenter/index.tsx
Normal 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;
|
||||
55
src/pages/ProductCenter/url.ts
Normal file
55
src/pages/ProductCenter/url.ts
Normal 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()}`;
|
||||
}
|
||||
}
|
||||
@@ -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
1479
src/pages/Tasks/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
86
src/pages/Tasks/task-templates.ts
Normal file
86
src/pages/Tasks/task-templates.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -84,7 +84,7 @@ export function YinianLogin() {
|
||||
} else {
|
||||
void window.yinian.auth.clearSavedCredentials().catch(() => undefined);
|
||||
}
|
||||
navigate('/today', { replace: true });
|
||||
navigate('/', { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user