feat(web): 用户端骨架——登录、工作台、新建项目、解析进度、读标清单

- packages/api-client:从 openapi.yaml 生成类型;cookie + CSRF / Bearer 两种鉴权、
  分片上传、fetch 读事件流(API2)
- apps/web:Vue 3 + Vite + TanStack Query,样式只来自 tokens.css / components.css(AR10);
  扣点说明放在「开始解析」旁边(红线 9);接口地址、路由方式运行时可配(架构 §5.2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-03 18:48:10 +08:00
parent ad7fba6021
commit 6aaf8e768b
36 changed files with 8684 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
/**
* 登录后的页面框架(设计规范 v0.2 §5.3):96px 墨色渐变侧栏 + 透明顶栏(滚动后半透明白)+ 内容区。
* 挂载期间保持一条事件流连接,把服务端的变化推进查询缓存。
*/
import { House, LogOut } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
import logoUrl from "@design/logo/svg/logo-vertical-reverse.svg?url";
import { api } from "@/lib/api";
import { provideCrumbs } from "@/lib/crumbs";
import { startEventStream } from "@/lib/events";
import { PLAN_KEY, quotaUnit } from "@/lib/labels";
import { useMe, useQuota } from "@/lib/queries";
const route = useRoute();
const router = useRouter();
const qc = useQueryClient();
const crumbs = provideCrumbs();
const me = useMe();
const quota = useQuota();
const plan = computed(() => me.data.value?.plan);
const planKey = computed(() => (plan.value ? PLAN_KEY[plan.value.code] : "free"));
const name = computed(() => me.data.value?.user.display_name ?? "");
/** 顶栏额度:只列本期有发放的单位;用到 ≥ 80% 变赭色(Q9) */
const accounts = computed(() => quota.data.value?.accounts.filter((a) => a.period_granted > 0) ?? []);
const quotaTip = computed(
() => "本期剩余额度:" + accounts.value.map((a) => `${quotaUnit(a.unit)} ${a.balance} / ${a.period_granted} 次`).join(","),
);
const onWorkbench = computed(() => route.matched.some((r) => r.name === "workbench") || route.path.startsWith("/projects"));
const scrolled = ref(false);
const onScroll = () => (scrolled.value = window.scrollY > 4);
let stopEvents: (() => void) | null = null;
onMounted(() => {
window.addEventListener("scroll", onScroll, { passive: true });
stopEvents = startEventStream(qc);
});
onBeforeUnmount(() => {
window.removeEventListener("scroll", onScroll);
stopEvents?.();
});
const loggingOut = ref(false);
async function logout() {
loggingOut.value = true;
try {
await api.POST("/auth/logout");
} finally {
stopEvents?.();
stopEvents = null;
qc.clear();
await router.replace({ name: "login" });
}
}
</script>
<template>
<div class="app-frame">
<aside class="sidebar">
<RouterLink class="brand" :to="{ name: 'workbench' }" aria-label="标博士 · 工作台">
<img :src="logoUrl" alt="" />
</RouterLink>
<nav class="nav" aria-label="主导航">
<RouterLink :to="{ name: 'workbench' }" :aria-current="onWorkbench ? 'page' : undefined">
<House class="ic" :stroke-width="1.5" /><span>工作台</span>
</RouterLink>
</nav>
<div class="me">
<span class="avatar" aria-hidden="true">{{ name.slice(0, 1) }}</span>
<b class="uname" :title="name">{{ name }}</b>
<span v-if="plan" class="plan" :data-plan="planKey">{{ plan.name }}</span>
</div>
</aside>
<main class="main">
<header class="top" :class="{ scrolled }">
<div class="crumbs">
<span class="org">{{ me.data.value?.tenant.name }}</span>
<template v-for="(c, i) in crumbs" :key="i">
<span aria-hidden="true">/</span>
<RouterLink v-if="c.to && i < crumbs.length - 1" :to="c.to" :title="c.label">{{ c.label }}</RouterLink>
<b v-else :title="c.label">{{ c.label }}</b>
</template>
</div>
<div class="top-right">
<span v-if="accounts.length" class="quota-chip tip-below tip-left" tabindex="0" :data-tip="quotaTip" :aria-label="quotaTip">
<template v-for="(a, i) in accounts" :key="a.unit">
<span v-if="i > 0" class="sep"></span>
<span class="q" :class="{ warn: (a.used_ratio ?? 0) >= 0.8 }">{{ quotaUnit(a.unit) }}<span class="fig">{{ a.balance }}</span></span>
</template>
</span>
<span class="vsep" aria-hidden="true"></span>
<button class="icon-btn tip-below tip-left" type="button" aria-label="退出登录" data-tip="退出登录" :disabled="loggingOut" @click="logout">
<LogOut class="ic lg" :stroke-width="1.5" />
</button>
</div>
</header>
<RouterView />
</main>
</div>
</template>