summaryrefslogtreecommitdiff
path: root/frontend/src/pages/ProjectsPage.svelte
blob: c3d7e5e4f5ac1781a86006f28c0b0d060097b461 (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
<script lang="ts">
  import Page from '../components/Page.svelte';
  import Status from '../components/Status.svelte';
  import ProjectMetricRow from '../components/ProjectMetricRow.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 } from '../lib/format';
  import { Button } from '$lib/components/ui/button';
  import * as Card from '$lib/components/ui/card';
  import * as Table from '$lib/components/ui/table';
  import { Input } from '$lib/components/ui/input';
  import { Label } from '$lib/components/ui/label';
  import { Alert } from '$lib/components/ui/alert';

  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);

  // ---- round2 指标卡行输入 ----
  // projectsStore 持有项目列表;其余三项在本页拉取一次。
  // TODO: AI 用量若 /api/ai/usage 不给 totals.requests,占位 "-"(见 ProjectMetricRow)。
  let datasets: { id: string }[] | null = $state(null);
  let runs: { id: string; status: 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 }>());

  async function load() {
    busy = true;
    error = null;
    try {
      await projectsStore.load();
      // 指标卡的三项远程输入并行拉取;任何一项失败不影响项目列表本身。
      // TODO: 用量字段若有专门计数端点(总数/时长),替换 getAIUsage。
      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;
    }
  }

  // 159399 QA regression (2026-09-17): load() existed but was wired nowhere,
  // so the projects page kept rendering the store's stale/empty state and
  // users saw 还没有项目 despite existing projects on the server.
  $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;
    }
  }
</script>

<Page title="我的项目" subtitle="每个项目持有独立的策略代码版本、数据集与回测记录">
  {#snippet actions()}
    <Button variant={showCreate ? 'outline' : 'default'} onclick={() => (showCreate = !showCreate)} aria-expanded={showCreate}>
      {showCreate ? '取消新建' : '新建项目'}
    </Button>
  {/snippet}

  <div class="stack">
    <ProjectMetricRow projects={projectsStore.all} {datasets} {runs} {usage} />
    <Status {busy} {error} busyText="加载项目列表…" empty={null} />
    {#if showCreate}
      <form class="card stack" onsubmit={create}>
        <h2>新建项目</h2>
        <div>
          <Label for="pn">项目名称</Label>
          <Input id="pn" bind:value={newName} required placeholder="例如:双均线ETF轮动" class="mt-1" />
        </div>
        <div>
          <Label for="pd">简介(可选)</Label>
          <Input id="pd" bind:value={newDesc} placeholder="一句话说明研究思路" class="mt-1" />
        </div>
        {#if createErr}<Alert variant="destructive" class="banner error" role="alert">{createErr}</Alert>{/if}
        <div class="row">
          <Button type="submit" disabled={creating || !newName.trim()}>
            {creating ? '创建中…' : '创建项目'}
          </Button>
        </div>
      </form>
    {/if}

    {#if !busy && !error && projectsStore.all.length === 0}
      <Status busy={false} empty="还没有项目" emptyHint="点击右上角「新建项目」开始你的第一个策略研究" />
    {/if}

    {#if projectsStore.all.length > 0}
      <Card.Root size="sm">
        <Table.Root>
          <Table.Header>
            <Table.Row>
              <Table.Head>名称</Table.Head>
              <Table.Head>最近回测</Table.Head>
              <Table.Head>简介</Table.Head>
              <Table.Head>版本代</Table.Head>
              <Table.Head>最近更新</Table.Head>
            </Table.Row>
          </Table.Header>
          <Table.Body>
            {#each projectsStore.all as p (p.id)}
              {@const sp = sparks.get(p.id)}
              <Table.Row>
                <Table.Cell>
                  <a href={`#/projects/${p.id}/data`} class="p-name">{p.name}</a>
                </Table.Cell>
                <Table.Cell>
                  {#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>
                  {/if}
                </Table.Cell>
                <Table.Cell class="hint">{p.description ?? '—'}</Table.Cell>
                <Table.Cell class="num">{p.draft_generation ?? '—'}</Table.Cell>
                <Table.Cell>{fmtDateTime(p.updated_at)}</Table.Cell>
              </Table.Row>
            {/each}
          </Table.Body>
        </Table.Root>
      </Card.Root>
    {/if}
  </div>
</Page>

<style>
  .p-name { font-weight: 600; color: var(--teal-strong); }
  .p-name:hover { text-decoration: underline; }
  form.card h2 { margin-bottom: 0.2rem; }
</style>