fix: vertically center insight header items

This commit is contained in:
2026-08-22 17:01:22 +08:00
parent aec6687a8a
commit 8e0dc6c012

View File

@@ -1,46 +1,28 @@
<script lang="ts"> <script lang="ts">
import BrainCircuitIcon from '@lucide/svelte/icons/brain-circuit'; import BrainCircuitIcon from '@lucide/svelte/icons/brain-circuit';
import CalendarDaysIcon from '@lucide/svelte/icons/calendar-days';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw'; import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import { import { ApiError, api, type AIInsight, type AIInsightModule } from '$lib/api/client';
ApiError,
api,
type AIInsight,
type AIInsightModule,
type AIInsightSection,
} from '$lib/api/client';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Skeleton } from '$lib/components/ui/skeleton'; import { Skeleton } from '$lib/components/ui/skeleton';
import { useDashboardContext } from '$lib/dashboard-context.svelte'; import { useDashboardContext } from '$lib/dashboard-context.svelte';
import { formatDate } from '$lib/format';
let { module, terminalId, periodId }: { module: AIInsightModule; terminalId?: string; periodId?: string } = let {
$props(); module,
terminalId,
periodId,
periodLabel,
}: { module: AIInsightModule; terminalId?: string; periodId?: string; periodLabel?: string } = $props();
const dashboard = useDashboardContext(); const dashboard = useDashboardContext();
let insight = $state<AIInsight | null>(null); let insight = $state<AIInsight | null>(null);
let loading = $state(true); let loading = $state(true);
let refreshing = $state(false); let refreshing = $state(false);
let refreshingSections = $state<Partial<Record<AIInsightSection, boolean>>>({});
let sectionErrors = $state<Partial<Record<AIInsightSection, string>>>({});
let error = $state(''); let error = $state('');
let sequence = 0; let sequence = 0;
let activeScope = ''; let activeScope = '';
const confidenceLabel = $derived(
(
{
HIGH: 'اطمینان بالا',
MEDIUM: 'اطمینان متوسط',
LOW: 'اطمینان پایین',
INSUFFICIENT_DATA: 'داده ناکافی',
} as const
)[insight?.confidence ?? 'INSUFFICIENT_DATA'],
);
async function load( async function load(
requestedModule: AIInsightModule, requestedModule: AIInsightModule,
requestedTerminalId?: string, requestedTerminalId?: string,
@@ -50,15 +32,12 @@
error = ''; error = '';
try { try {
const next = await api.latestAIInsight(requestedModule, requestedTerminalId, requestedPeriodId); const next = await api.latestAIInsight(requestedModule, requestedTerminalId, requestedPeriodId);
if (current === sequence) { if (current === sequence) insight = next;
insight = next;
loading = false;
}
} catch (cause) { } catch (cause) {
if (current !== sequence) return; if (current !== sequence) return;
error = error =
cause instanceof ApiError && cause.status === 404 cause instanceof ApiError && cause.status === 404
? 'هنوز تحلیلی برای این محدوده آماده نشده است.' ? 'هنوز تحلیلی برای این بازه آماده نشده است.'
: 'بخش تحلیل هوشمند در دسترس نیست؛ سایر اطلاعات صفحه همچنان قابل استفاده‌اند.'; : 'بخش تحلیل هوشمند در دسترس نیست؛ سایر اطلاعات صفحه همچنان قابل استفاده‌اند.';
} finally { } finally {
if (current === sequence) loading = false; if (current === sequence) loading = false;
@@ -80,54 +59,17 @@
} }
if (current.state === 'FAILED') throw new Error('job failed'); if (current.state === 'FAILED') throw new Error('job failed');
} }
error = 'ساخت نسخه تازه ادامه دارد؛ آخرین تحلیل معتبر همچنان نمایش داده می‌شود.'; error = 'ساخت نسخه تازه ادامه دارد؛ خلاصه قبلی همچنان نمایش داده می‌شود.';
} catch (cause) { } catch (cause) {
error = error =
cause instanceof ApiError && cause.status === 429 cause instanceof ApiError && cause.status === 429
? 'سهم به‌روزرسانی امروز استفاده شده است؛ نسخه ذخیره‌شده همچنان نمایش داده می‌شود.' ? 'سهم به‌روزرسانی امروز استفاده شده است؛ خلاصه قبلی همچنان نمایش داده می‌شود.'
: 'نسخه تازه آماده نشد؛ سایر بخش‌های صفحه تحت‌تأثیر قرار نگرفتند.'; : 'خلاصه تازه آماده نشد؛ اطلاعات دیگر صفحه بدون تغییر باقی ماند.';
} finally { } finally {
refreshing = false; refreshing = false;
} }
} }
async function refreshOne(section: AIInsightSection) {
if (!dashboard.session || refreshingSections[section]) return;
refreshingSections[section] = true;
sectionErrors[section] = '';
try {
const job = await api.refreshAIInsightSection(
module,
section,
dashboard.session.csrfToken,
terminalId,
periodId,
);
for (let attempt = 0; attempt < 45; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 2000));
const current = await api.aiInsightJob(job.jobId);
if (current.state === 'COMPLETED') {
await load(module, terminalId, periodId);
return;
}
if (current.state === 'FAILED') throw new Error('job failed');
}
sectionErrors[section] = 'تازه‌سازی ادامه دارد؛ متن معتبر قبلی حفظ شده است.';
} catch (cause) {
sectionErrors[section] =
cause instanceof ApiError && cause.status === 429
? 'سهم این بخش فعلاً تمام شده است؛ متن قبلی حفظ شد.'
: 'این بخش تازه نشد؛ متن قبلی بدون تغییر باقی ماند.';
} finally {
refreshingSections[section] = false;
}
}
function sectionBusy(section: AIInsightSection) {
const state = insight?.sectionRefreshState?.[section]?.state;
return Boolean(refreshingSections[section]) || ['QUEUED', 'REFRESHING', 'RUNNING'].includes(state ?? '');
}
$effect(() => { $effect(() => {
const requestedModule = module; const requestedModule = module;
const requestedTerminalId = terminalId; const requestedTerminalId = terminalId;
@@ -139,174 +81,49 @@
loading = true; loading = true;
void load(requestedModule, requestedTerminalId, requestedPeriodId); void load(requestedModule, requestedTerminalId, requestedPeriodId);
}); });
function eventTone(tone: string) {
return tone === 'MOURNING' || tone === 'MEMORIAL'
? 'border-slate-300 bg-slate-50 text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300'
: 'border-primary/20 bg-primary/5 text-foreground';
}
</script> </script>
<section class="my-4" aria-label="تحلیل هوشمند" aria-busy={loading || refreshing}> <section class="my-4" aria-label="تحلیل هوشمند" aria-busy={loading || refreshing}>
{#if loading && !insight} {#if loading && !insight}
<Card class="rounded-lg" <Card class="rounded-lg">
><CardContent class="space-y-3 p-5" role="status" aria-label="در حال دریافت تحلیل هوشمند" <CardContent class="space-y-3 p-5" role="status" aria-label="در حال دریافت تحلیل هوشمند">
><Skeleton class="h-6 w-44" /><Skeleton class="h-20 w-full" /></CardContent <Skeleton class="h-6 w-44" />
></Card <Skeleton class="h-16 w-full" />
> </CardContent>
</Card>
{:else if !insight} {:else if !insight}
<Card class="rounded-lg border-dashed"> <Card class="rounded-lg border-dashed">
<CardContent class="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between"> <CardContent class="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<p class="font-bold">تحلیل هوشمند</p> <strong>خلاصه هوشمند</strong>
<p class="text-muted-foreground mt-1 text-sm" role="status">{error}</p> <div class="text-muted-foreground mt-1 text-sm" role="status">{error}</div>
</div> </div>
<Button variant="outline" onclick={refresh} disabled={refreshing} <Button variant="outline" onclick={refresh} disabled={refreshing}>
><RefreshCcwIcon class={refreshing ? 'animate-spin' : ''} /> ساخت تحلیل</Button <RefreshCcwIcon class={refreshing ? 'animate-spin' : ''} /> ساخت خلاصه
> </Button>
</CardContent> </CardContent>
</Card> </Card>
{:else} {:else}
<Card class="border-primary/20 overflow-hidden rounded-lg shadow-xs"> <Card class="border-primary/20 overflow-hidden rounded-lg shadow-xs">
<CardHeader class="bg-primary/[.035] border-b"> <CardHeader class="bg-primary/[.035] border-b">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div class="flex min-h-6 flex-wrap items-center justify-between gap-3">
<div> <CardTitle class="flex h-6 items-center gap-2 text-base leading-none">
<CardTitle class="flex items-center gap-2 text-base" <BrainCircuitIcon class="text-primary size-5" /> خلاصه ساده این صفحه
><BrainCircuitIcon class="text-primary size-5" /> تحلیل هوشمند این صفحه</CardTitle </CardTitle>
> {#if periodLabel}<Badge class="h-6 leading-none" variant="secondary">{periodLabel}</Badge>{/if}
<p class="text-muted-foreground mt-2 text-xs leading-6">
مبنای تحلیل تا {formatDate(insight.dataThroughDate)} است؛ تاریخ واقعی تحلیل {formatDate(
insight.asOfDate,
)} و شروع چشم‌انداز {formatDate(insight.forecastStartDate)} است.
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Badge variant={insight.freshness === 'STALE' ? 'destructive' : 'secondary'}
>{insight.freshness === 'FRESH'
? 'داده تازه'
: insight.freshness === 'LAGGING'
? `${insight.dataLagDays} روز فاصله`
: `داده قدیمی؛ ${insight.dataLagDays} روز فاصله`}</Badge
>
<Badge variant="outline">{confidenceLabel}</Badge>
{#if insight.status === 'PARTIAL'}<Badge variant="outline">نسخه قطعی و بدون متن مولد</Badge>{/if}
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent class="space-y-5 p-5"> <CardContent class="space-y-4 p-5">
<div class="grid gap-3 lg:grid-cols-2 xl:grid-cols-4"> <p class="text-sm leading-8">{insight.copy.latestObserved.body}</p>
{#each [{ id: 'past' as const, copy: insight.copy.past }, { id: 'latest-observed' as const, copy: insight.copy.latestObserved }, { id: 'future-7-days' as const, copy: insight.copy.future7Days }, { id: 'future-30-days' as const, copy: insight.copy.future30Days }] as section} <p class="text-muted-foreground text-sm leading-8">{insight.copy.past.body}</p>
<article class="bg-muted/35 rounded-md border p-4"> <div class="flex flex-col gap-2 border-t pt-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-start justify-between gap-2"> <span class="text-muted-foreground text-xs" aria-live="polite">
<h3 class="text-sm font-bold">{section.copy.headline}</h3>
<Button
variant="ghost"
size="icon-sm"
aria-label="تازه‌سازی همین بخش"
title="تازه‌سازی همین بخش"
onclick={() => refreshOne(section.id)}
disabled={sectionBusy(section.id)}
><RefreshCcwIcon
class={`size-4 ${sectionBusy(section.id) ? 'animate-spin' : ''}`}
/></Button
>
</div>
<p class="text-muted-foreground mt-2 text-sm leading-7">{section.copy.body}</p>
{#if sectionErrors[section.id]}<p class="text-destructive mt-2 text-xs" role="status">
{sectionErrors[section.id]}
</p>{/if}
</article>
{/each}
</div>
{#if insight.copy.future90Days}
<article class="border-primary/15 bg-primary/[.025] rounded-md border p-4">
<div class="flex items-start justify-between gap-2">
<h3 class="text-sm font-bold">{insight.copy.future90Days.headline}</h3>
<Button
variant="ghost"
size="icon-sm"
aria-label="تازه‌سازی چشم‌انداز ۹۰ روزه"
onclick={() => refreshOne('future-90-days')}
disabled={sectionBusy('future-90-days')}
><RefreshCcwIcon
class={`size-4 ${sectionBusy('future-90-days') ? 'animate-spin' : ''}`}
/></Button
>
</div>
<p class="text-muted-foreground mt-2 text-sm leading-7">{insight.copy.future90Days.body}</p>
</article>
{/if}
{#if insight.calendarContext?.length}
<div>
<h3 class="flex items-center gap-2 text-sm font-bold">
<CalendarDaysIcon class="size-4" /> رویدادهای مرتبط
</h3>
<div class="mt-3 flex flex-wrap gap-2">
{#each insight.calendarContext ?? [] as event}<span
class={`rounded-md border px-3 py-2 text-xs ${eventTone(event.tone)}`}
>{event.titleFa} · {formatDate(event.startDate)}</span
>{/each}
</div>
</div>
{/if}
<details class="group rounded-md border p-4">
<summary class="flex cursor-pointer list-none items-center justify-between text-sm font-bold"
>شواهد و گزینه‌های بررسی <ChevronDownIcon
class="size-4 transition-transform group-open:rotate-180"
/></summary
>
<div class="mt-4 grid gap-3 md:grid-cols-2">
<section>
<div class="mb-3 flex items-center justify-between">
<h3 class="font-bold">یافته‌ها</h3>
<Button
variant="ghost"
size="icon-sm"
aria-label="تازه‌سازی یافته‌ها"
onclick={() => refreshOne('findings')}
disabled={sectionBusy('findings')}
><RefreshCcwIcon
class={`size-4 ${sectionBusy('findings') ? 'animate-spin' : ''}`}
/></Button
>
</div>
{#each insight.copy.findings as finding}<div class="mb-3">
<p class="text-sm font-bold">{finding.title}</p>
<p class="text-muted-foreground mt-1 text-sm leading-6">{finding.summary}</p>
<code class="text-muted-foreground mt-1 block text-[11px]">{finding.evidenceId}</code>
</div>{/each}
</section>
<section>
<div class="mb-3 flex items-center justify-between">
<h3 class="font-bold">گزینه‌ها</h3>
<Button
variant="ghost"
size="icon-sm"
aria-label="تازه‌سازی گزینه‌ها"
onclick={() => refreshOne('options')}
disabled={sectionBusy('options')}
><RefreshCcwIcon class={`size-4 ${sectionBusy('options') ? 'animate-spin' : ''}`} /></Button
>
</div>
{#each insight.copy.options as option}<div
class="border-primary/15 bg-primary/[.025] rounded-md border p-3"
>
<p class="text-sm font-bold">{option.title}</p>
<p class="text-muted-foreground mt-1 text-sm leading-6">{option.description}</p>
</div>{/each}
</section>
</div>
</details>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p class="text-muted-foreground text-xs" aria-live="polite">
{error || {error ||
(refreshing (refreshing ? 'خلاصه تازه در حال آماده‌شدن است.' : 'این خلاصه برای کمک به تصمیم‌گیری است.')}
? 'نسخه تازه در پس‌زمینه ساخته می‌شود؛ تحلیل فعلی قابل استفاده است.' </span>
: 'این تحلیل پیشنهادی است و تصمیم خودکار ایجاد نمی‌کند.')} <Button variant="outline" size="sm" onclick={refresh} disabled={refreshing}>
</p> <RefreshCcwIcon class={refreshing ? 'animate-spin' : ''} /> به‌روزرسانی خلاصه
<Button variant="outline" size="sm" onclick={refresh} disabled={refreshing} </Button>
><RefreshCcwIcon class={refreshing ? 'animate-spin' : ''} /> به‌روزرسانی تحلیل</Button
>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>