summaryrefslogtreecommitdiff
path: root/frontend/src/pages/ProjectsPage.svelte
blob: 65137d2454cc10b51fac9777705ba66edf8fee4a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
<script lang="ts">
  import Page from '../components/Page.svelte';
  import Status from '../components/Status.svelte';
  import Sparkline from '../components/Sparkline.svelte';
  import { projectsStore } from '../lib/listsStore.svelte';
  import { createProject, listDatasets, listRuns, getAIUsage } from '../lib/client';
  import { sparkFirstLast } from '../lib/dashboardMetrics';
  import { extractStatusMessage } from '../lib/state';
  import { go } from '../lib/router';
  import { fmtDateTime, fmtPct } from '../lib/format';
  import { Button } from '$lib/components/ui/button';
  import * as Card from '$lib/components/ui/card';
  import { Input } from '$lib/components/ui/input';
  import { Label } from '$lib/components/ui/label';
  import { Alert } from '$lib/components/ui/alert';
  import { Badge } from '$lib/components/ui/badge';

  let busy = $state(false);
  let error = $state<string | null>(null);
  let creating = $state(false);
  let showCreate = $state(false);
  let newName = $state('');
  let newDesc = $state('');
  let createErr = $state<string | null>(null);

  // ---- 问候行 + 指标摘要(一行小字,不做 4 张大卡)----
  let datasets: { id: string }[] | null = $state(null);
  let runs: { id: string; status: string; created_at?: string; result?: { equity?: { equity: number }[] } | null; project_id?: string }[] | null = $state(null);
  let usage: { totals?: { input_tokens?: number; output_tokens?: number; requests?: number } } | null = $state(null);
  let sparks = $state(new Map<string, { first: number; last: number }>());

  const hours = new Date().getHours();
  const greeting = hours < 12 ? '早上好' : hours < 18 ? '下午好' : '晚上好';

  async function load() {
    busy = true;
    error = null;
    try {
      await projectsStore.load();
      const [ds, runList, usageRes] = await Promise.allSettled([
        listDatasets(),
        listRuns(),
        getAIUsage()
      ]);
      if (ds.status === 'fulfilled') datasets = Array.isArray(ds.value.items) ? ds.value.items : null;
      if (runList.status === 'fulfilled') runs = runList.value.items;
      if (usageRes.status === 'fulfilled') usage = usageRes.value;
      if (runList.status === 'fulfilled') sparks = sparkFirstLast(runList.value.items ?? []);
    } catch (e) {
      error = extractStatusMessage(e);
    } finally {
      busy = false;
    }
  }

  $effect(() => {
    void load();
  });

  async function create(e: SubmitEvent) {
    e.preventDefault();
    if (!newName.trim()) return;
    creating = true;
    createErr = null;
    try {
      const p = await createProject(newName.trim(), newDesc.trim() || undefined);
      projectsStore.bump(p);
      showCreate = false;
      newName = '';
      newDesc = '';
      go(`/projects/${p.id}/data`);
    } catch (err) {
      createErr = extractStatusMessage(err);
    } finally {
      creating = false;
    }
  }

  // 项目卡一句话状态
  const runStatusOf = $derived.by(() => {
    const m = new Map<string, { running: number; done: number; lastReturn?: string }>();
    for (const r of runs ?? []) {
      const e = m.get(r.project_id ?? '') ?? { running: 0, done: 0 };
      if (r.status === 'running' || r.status === 'queued') e.running++;
      if (r.status === 'succeeded') {
        e.done++;
        const tr = (r.result as { metrics?: { total_return?: number } } | undefined)?.metrics?.total_return;
        if (tr !== undefined) e.lastReturn = undefined;
        const tr2 = tr;
        if (tr2 !== undefined) (e as { lastReturn?: string }).lastReturn = fmtPct(tr2);
      }
      m.set(r.project_id ?? '', e);
    }
    return m;
  });
</script>

<Page title={`${greeting},${projectsStore.all.length} 个进行中的策略研究`} subtitle="从下面的项目继续你的研究" wide>
  {#snippet actions()}
    <Button variant={showCreate ? 'outline' : 'default'} onclick={() => (showCreate = !showCreate)} aria-expanded={showCreate}>
      {showCreate ? '取消新建' : '新建项目'}
    </Button>
  {/snippet}

  <div class="flex flex-col gap-4 md:gap-6">
    {#if busy}
      <Status {busy} busyText="加载项目列表…" skeletonRows={4} empty={null} />
    {:else if error}
      <Status {error} empty={null} />
    {:else}
      <p class="text-xs text-muted-foreground" data-testid="metrics-summary">
        {projectsStore.all.length} 个项目 · {(datasets ?? []).length} 个数据集 ·
        {[...runStatusOf.values()].reduce((a, b) => a + b.running, 0)} 个回测运行中 ·
        {(runs ?? []).length} 次回测 · AI 请求 {usage?.totals?.requests ?? '—'}
      </p>
    {/if}

    {#if showCreate}
      <Card.Root class="p-4">
        <form class="flex flex-col gap-4" onsubmit={create}>
          <h2 class="text-lg font-semibold m-0">新建项目</h2>
          <div>
            <Label for="pn">项目名称</Label>
            <Input id="pn" bind:value={newName} required placeholder="例如:双均线ETF轮动" />
          </div>
          <div>
            <Label for="pd">简介(可选)</Label>
            <Input id="pd" bind:value={newDesc} placeholder="一句话说明研究思路" />
          </div>
          {#if createErr}<Alert variant="destructive">{createErr}</Alert>{/if}
          <div>
            <Button class="w-fit" type="submit" disabled={creating || !newName.trim()}>
              {creating ? '创建中…' : '创建项目'}
            </Button>
          </div>
        </form>
      </Card.Root>
    {/if}

    {#if !busy && error && projectsStore.all.length === 0}
      <Status busy={false} {error} empty={null}>
        {#snippet errorCta()}
          <Button onclick={load}>重新加载</Button>
        {/snippet}
      </Status>
    {/if}

    {#if !busy && !error && projectsStore.all.length === 0}
      <Status
        busy={false}
        empty="还没有项目"
        emptyHint="从新建项目开始你的第一个策略研究:命名 → 接入数据 → 编写策略 → 回测"
      >
        {#snippet emptyCta()}
          <Button onclick={() => (showCreate = true)}>新建项目</Button>
        {/snippet}
      </Status>
    {/if}

    {#if projectsStore.all.length > 0}
      <div class="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6" role="list" aria-label="项目列表">
        {#each projectsStore.all as p (p.id)}
          {@const sp = sparks.get(p.id)}
          {@const st = runStatusOf.get(p.id)}
          <Card.Root size="sm" role="listitem">
            <div class="flex justify-between items-start gap-4">
              <div class="min-w-0 flex-1">
                <a href={`#/projects/${p.id}/data`} data-testid="project-link" class="text-lg font-semibold hover:underline">
                  {p.name}
                </a>
                <p class="text-xs text-muted-foreground mt-1 truncate">{p.description ?? '—'}</p>
              </div>
              <Badge variant={st?.running ? 'outline' : st?.done ? 'secondary' : 'outline'}>
                {st?.running ? `运行中 ${st.running}` : st?.done ? `最近 ${st.lastReturn ?? '完成'}` : '未回测'}
              </Badge>
            </div>
            <div class="flex items-center gap-4 mt-4">
              {#if sp}
                <a href={`#/projects/${p.id}/results`} aria-label="查看 {p.name} 最近回测曲线" data-testid="sparkline-cell">
                  <Sparkline first={sp.first} last={sp.last} up={sp.last >= sp.first} />
                </a>
                <span class="text-xs text-muted-foreground">最近 {runStatusOf.get(p.id)?.lastReturn ?? '完成'}</span>
              {:else}
                <span class="text-xs text-muted-foreground">还没有回测记录</span>
              {/if}
              <span class="flex-1"></span>
              <span class="text-xs text-muted-foreground">更新 {fmtDateTime(p.updated_at)}</span>
            </div>
          </Card.Root>
        {/each}
      </div>
    {/if}
  </div>
</Page>