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:
107
apps/web/src/components/AppShell.vue
Normal file
107
apps/web/src/components/AppShell.vue
Normal 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>
|
||||
Reference in New Issue
Block a user