- 资质库(FR-M6-08):增删改、证书扫描件(PDF/JPG/PNG,企业级密钥加密、签名下载)、 late_for 按进行中项目的投标截止日提醒、审计;前端资质库页和对话框,侧栏入口 - 资质匹配(FR-M3-05):证书类别词表(16 类,产品证书、人员证书不进匹配), QUAL.EXPIRED / LEVEL_LOW / NOT_COVERED;投标文字层按清单项定级,只有资质库记录的降一级, 出处显示「依据:资质库记录」(查标页、报告模板 v2);一类证书一个问题;评分项不逐条找响应的也查 - 响应错位规则不再把资质表的证书名称格当成照抄的要求 - 评测:qual_defects.py 派生资质情况表,D12 3/3、D18 2/2、规矩的资质表 0 误报;开发集不退化 - 决策 QL1–QL7;方案和落地记录 docs/architecture/qualification-plan.md,评测 check-eval.md §13 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
113 lines
4.5 KiB
Vue
113 lines
4.5 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 登录后的页面框架(设计规范 v0.2 §5.3):96px 墨色渐变侧栏 + 透明顶栏(滚动后半透明白)+ 内容区。
|
||
* 挂载期间保持一条事件流连接,把服务端的变化推进查询缓存。
|
||
*/
|
||
import { Award, 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 Toasts from "./Toasts.vue";
|
||
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>
|
||
<RouterLink :to="{ name: 'qualifications' }" :aria-current="route.name === 'qualifications' ? 'page' : undefined">
|
||
<Award 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>
|
||
<Toasts />
|
||
</div>
|
||
</template>
|