feat(m6): 额度流水、套餐开通、数据删除、运营后台(M6 剩下的 P0)
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

决策 M6-1–M6-10(docs/architecture/m6-plan.md、docs/decisions.md)。

用户端:
- 额度与套餐页:本期额度、按功能分布、扣点规则、流水(逐条余额、关联项目和任务、导出 CSV)
- 开通 / 续费申请(对公转账,运营确认到账后开通),80% / 100% 提醒
- 专业版到期:整个账号只读、额度清零,到期前 30 / 7 / 0 天提醒(worker 每小时 billing_tick)
- 删除项目(彻底删除、销毁数据密钥),注销账号(企业名称 + 短信验证码)
- 通知铃铛、账号页

运营后台:
- 独立进程 bid-doctor ops(/ops/v1)和 apps/admin;用户名 + 密码 + TOTP + IP 白名单
- 开通申请(到账 / 开通 / 退回 / 发票)、企业查询和调额度、误报 / 漏报、失败任务、审计;不看业务内容
- 左侧菜单(标准 logo、可展开收起的分组、图标、待办数、环境标签)
- 运营人员角色只留结构:ops_admin.role、权限点、每个接口的检查点、菜单按权限过滤

数据库:迁移 0010(排队的订阅、申请单号、通知类型、billing_due)、0011(bd_ops 权限)、0012(运营角色)。
另修:取消解析 / 检查接口报 500;uuid7 进程内单调;data_key 销毁后不再新建;审计 None 存 SQL NULL。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-05 15:06:06 +08:00
parent 54cbb556d0
commit dd8c04001d
78 changed files with 5064 additions and 104 deletions

13
apps/admin/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>标博士运营后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

25
apps/admin/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "@bid-doctor/admin",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/barlow-semi-condensed": "5.3.0",
"@lucide/vue": "1.50.0",
"vue": "3.5.43",
"vue-router": "4.6.4"
},
"devDependencies": {
"@types/node": "24.19.2",
"@vitejs/plugin-vue": "6.0.9",
"typescript": "5.8.3",
"vite": "7.3.7",
"vue-tsc": "3.3.12"
}
}

53
apps/admin/src/App.vue Normal file
View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
/** 运营后台的框架:左侧菜单(OpsSidebar)+ 内容。登录页不带框架。菜单上的待办数随切换页面刷新,另外每分钟刷新一次。 */
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import { RouterView, useRoute, useRouter } from "vue-router";
import { CHANGED, ops, type Operator, type Summary } from "./api";
import OpsSidebar from "./OpsSidebar.vue";
const route = useRoute();
const router = useRouter();
const me = ref<Operator | null>(null);
const summary = ref<Summary | null>(null);
async function loadMe() {
if (route.meta.bare) return;
me.value = await ops<Operator>("/me").catch(() => null);
}
async function loadSummary() {
if (route.meta.bare || !me.value) return;
summary.value = await ops<Summary>("/summary").catch(() => summary.value);
}
let timer: number | undefined;
onMounted(async () => {
await loadMe();
await loadSummary();
timer = window.setInterval(loadSummary, 60_000);
window.addEventListener(CHANGED, loadSummary);
});
onBeforeUnmount(() => {
window.clearInterval(timer);
window.removeEventListener(CHANGED, loadSummary);
});
watch(
() => route.fullPath,
async (_, old) => {
if (route.meta.bare) return;
if (!me.value || old?.startsWith("/login")) await loadMe();
await loadSummary();
},
);
async function logout() {
await ops("/auth/logout", { method: "POST" }).catch(() => undefined);
me.value = null;
summary.value = null;
await router.replace({ name: "login" });
}
</script>
<template>
<RouterView v-if="route.meta.bare" />
<div v-else class="ops-frame">
<OpsSidebar :me="me" :summary="summary" @logout="logout" />
<main class="ops-main"><div class="content"><RouterView /></div></main>
</div>
</template>

View File

@@ -0,0 +1,289 @@
<script setup lang="ts">
/**
* 运营后台的左侧菜单:标准横版 logo(反白)+「运营后台」+ 环境标签(不是生产环境时醒目提示,免得把测试当生产);
* 分组像文件夹一样可以展开收起(记在本机),当前页所在的分组总是展开;菜单项带图标和待办数;底部是当前运营人员和退出。
* 菜单按权限点过滤(nav.ts)。
*/
import { ChevronDown, LogOut } from "@lucide/vue";
import logoUrl from "@design/logo/svg/logo-horizontal-reverse.svg?url";
import { computed, ref, watch } from "vue";
import { RouterLink, useRoute } from "vue-router";
import type { Operator, Summary } from "./api";
import { visibleNav } from "./nav";
const props = defineProps<{ me: Operator | null; summary: Summary | null }>();
const emit = defineEmits<{ logout: [] }>();
const route = useRoute();
const groups = computed(() => visibleNav(props.me?.permissions ?? []));
const KEY = "ops.nav.collapsed";
function load(): string[] {
try {
return JSON.parse(localStorage.getItem(KEY) ?? "[]") as string[];
} catch {
return [];
}
}
const collapsed = ref(new Set(load()));
function save(s: Set<string>) {
collapsed.value = s;
try {
localStorage.setItem(KEY, JSON.stringify([...s]));
} catch {
/* 隐私模式等存不了:只是这次有效 */
}
}
function toggle(key: string) {
const s = new Set(collapsed.value);
if (s.has(key)) s.delete(key);
else s.add(key);
save(s);
}
const isActive = (name: string, also: string[] = []) => route.name === name || also.includes(String(route.name));
const activeGroup = computed(() => groups.value.find((g) => g.items.some((i) => isActive(i.name, i.also)))?.key);
const isOpen = (key: string) => !collapsed.value.has(key);
// 跳到一个收起的分组里的页面(链接、详情页):把那一组展开
watch(activeGroup, (g) => {
if (g && collapsed.value.has(g)) save(new Set([...collapsed.value].filter((k) => k !== g)));
}, { immediate: true });
const ENV: Record<string, string> = { dev: "开发环境", test: "测试环境" };
</script>
<template>
<aside class="ops-side" aria-label="运营后台菜单">
<RouterLink class="brand" :to="{ name: 'requests' }" aria-label="标博士运营后台">
<img :src="logoUrl" alt="标博士" />
</RouterLink>
<div class="sub">
<span>运营后台</span>
<span v-if="me && me.env !== 'prod'" class="env">{{ ENV[me.env] ?? me.env }}</span>
</div>
<nav class="groups">
<section v-for="g in groups" :key="g.key" class="group">
<button
type="button"
class="gh"
:aria-expanded="isOpen(g.key)"
:aria-controls="`grp-${g.key}`"
@click="toggle(g.key)"
>
<span>{{ g.label }}</span>
<ChevronDown class="chev" :class="{ shut: !isOpen(g.key) }" :stroke-width="1.75" />
</button>
<ul v-show="isOpen(g.key)" :id="`grp-${g.key}`">
<li v-for="i in g.items" :key="i.name">
<RouterLink :to="{ name: i.name }" :aria-current="isActive(i.name, i.also) ? 'page' : undefined">
<component :is="i.icon" class="ic" :stroke-width="1.5" />
<span class="lbl">{{ i.label }}</span>
<span v-if="i.badge && summary && summary[i.badge]" class="badge" :class="{ warn: i.badge === 'failed_jobs_7d' }">
{{ summary[i.badge] }}
</span>
</RouterLink>
</li>
</ul>
</section>
</nav>
<div v-if="me" class="me">
<span class="avatar" aria-hidden="true">{{ me.display_name.slice(0, 1) }}</span>
<span class="who">
<b :title="me.display_name">{{ me.display_name }}</b>
<small>{{ me.role_label }} · {{ me.username }}</small>
</span>
<button type="button" class="out" aria-label="退出登录" title="退出登录" @click="emit('logout')">
<LogOut class="ic" :stroke-width="1.5" />
</button>
</div>
</aside>
</template>
<style scoped>
.ops-side {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
height: 100vh;
padding: var(--sp-5) var(--sp-3) var(--sp-4);
background: linear-gradient(180deg, var(--side-top) 0%, var(--side-bottom) 100%);
color: var(--side-fg);
overflow-y: auto;
}
.brand {
display: block;
padding: 0 var(--sp-2);
border-radius: var(--radius-ctl);
}
.brand img {
display: block;
width: 148px;
height: auto;
}
.brand:focus-visible,
.gh:focus-visible,
.groups a:focus-visible,
.out:focus-visible {
outline: 2px solid var(--side-fg-strong);
outline-offset: -2px;
}
.sub {
display: flex;
gap: var(--sp-2);
align-items: center;
margin: var(--sp-3) var(--sp-2) var(--sp-5);
padding-bottom: var(--sp-4);
border-bottom: var(--line) solid var(--side-line);
font-size: var(--fs-caption);
letter-spacing: 0.12em;
color: var(--side-muted);
}
/* 非生产环境:赭色实底标签(红色只给致命问题,U1) */
.env {
padding: 0 6px;
border-radius: var(--radius-xs);
background: var(--warn);
color: var(--c-ink);
font-weight: var(--fw-bold);
letter-spacing: 0;
line-height: 18px;
}
.groups {
flex: 1;
}
.group + .group {
margin-top: var(--sp-3);
}
.gh {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 28px;
padding: 0 var(--sp-2);
border: 0;
border-radius: var(--radius-ctl);
background: none;
font: inherit;
font-size: var(--fs-caption);
color: var(--side-muted);
cursor: pointer;
}
.gh:hover {
color: var(--side-fg-strong);
}
.chev {
width: 14px;
height: 14px;
transition: transform var(--dur-fast) var(--ease);
}
.chev.shut {
transform: rotate(-90deg);
}
ul {
margin: 2px 0 0;
padding: 0;
list-style: none;
}
.groups a {
display: flex;
gap: var(--sp-3);
align-items: center;
height: 38px;
padding: 0 var(--sp-2) 0 var(--sp-3);
border-radius: var(--radius-ctl);
color: var(--side-fg);
text-decoration: none;
transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.groups a .ic {
flex: none;
width: 18px;
height: 18px;
}
.groups a .lbl {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.groups a:hover {
background: var(--side-hover);
color: var(--side-fg-strong);
}
.groups a[aria-current="page"] {
background: var(--side-active);
color: var(--side-fg-strong);
font-weight: var(--fw-bold);
}
.badge {
min-width: 20px;
padding: 0 6px;
border-radius: 10px;
background: var(--side-avatar-bg);
color: var(--side-fg-strong);
font: var(--fw-medium) var(--fs-caption) / 20px var(--font-figures);
text-align: center;
}
.badge.warn {
background: var(--warn);
color: var(--c-ink);
}
.me {
display: flex;
gap: var(--sp-2);
align-items: center;
margin-top: var(--sp-4);
padding: var(--sp-3) var(--sp-2) 0;
border-top: var(--line) solid var(--side-line);
}
.avatar {
display: inline-grid;
flex: none;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--side-avatar-bg);
box-shadow: 0 0 0 1px var(--side-avatar-ring);
color: var(--side-fg-strong);
font-weight: var(--fw-bold);
}
.who {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.who b {
overflow: hidden;
color: var(--side-fg-strong);
text-overflow: ellipsis;
white-space: nowrap;
}
.who small {
font-size: var(--fs-caption);
color: var(--side-muted);
}
.out {
display: inline-grid;
place-items: center;
width: 32px;
height: 32px;
border: 0;
border-radius: var(--radius-ctl);
background: none;
color: var(--side-fg);
cursor: pointer;
}
.out:hover {
background: var(--side-hover);
color: var(--side-fg-strong);
}
.ic {
width: 18px;
height: 18px;
}
</style>

15
apps/admin/src/admin.css Normal file
View File

@@ -0,0 +1,15 @@
/* 运营后台的框架(决策 M6-3):颜色、字号、间距只用 tokens.css 的变量;按钮、表格、卡片沿用用户端的组件样式 */
body{margin:0;background:var(--bg-page);color:var(--text-primary)}
.ops-frame{display:grid;grid-template-columns:232px minmax(0,1fr);min-height:100vh;min-width:1280px}
.ops-main{min-width:0}
.ops-main .content{max-width:1440px;padding-top:var(--sp-8)}
.toolbar{display:flex;gap:var(--sp-3);align-items:center;margin-bottom:var(--sp-4)}
.toolbar .input{max-width:360px}
.card{margin-bottom:var(--gap-card)}
.card.flush > .card-h{padding:var(--sp-4) var(--card-x) 0}
table.data td{vertical-align:top}
.nowrap{white-space:nowrap}
.r{text-align:right}
.acts{display:flex;flex-wrap:wrap;gap:var(--sp-2)}
.err-text{color:var(--warn-text)}
.inv{margin:0;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}

155
apps/admin/src/api.ts Normal file
View File

@@ -0,0 +1,155 @@
/**
* 运营后台接口(/ops/v1,docs/architecture/ops-api.md)。内部工具,类型在这里手写;cookie 会话 + CSRF 头。
*/
export class OpsError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
/** 页面上做完操作(开通、退回……)发这个事件,菜单上的待办数跟着刷新 */
export const CHANGED = "ops:changed";
export const notifyChanged = () => window.dispatchEvent(new Event(CHANGED));
let onUnauthorized: () => void = () => {};
export function setUnauthorized(fn: () => void) {
onUnauthorized = fn;
}
function csrf(): string {
return document.cookie.split("; ").find((c) => c.startsWith("bd_ops_csrf="))?.split("=")[1] ?? "";
}
export async function ops<T>(path: string, init: { method?: string; body?: unknown; query?: Record<string, string | undefined> } = {}): Promise<T> {
const qs = new URLSearchParams(Object.entries(init.query ?? {}).filter((e): e is [string, string] => !!e[1])).toString();
const res = await fetch(`/ops/v1${path}${qs ? `?${qs}` : ""}`, {
method: init.method ?? "GET",
credentials: "include",
headers: { "Content-Type": "application/json", ...(init.method && init.method !== "GET" ? { "X-CSRF-Token": csrf() } : {}) },
body: init.body === undefined ? undefined : JSON.stringify(init.body),
});
if (res.status === 204) return undefined as T;
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 401 && path !== "/auth/login") onUnauthorized();
throw new OpsError(res.status, data.code ?? "error", data.detail ?? data.title ?? "请求失败");
}
return data as T;
}
/** 时间一律按北京时间显示:「2026-11-05 14:45」 */
const BJ = new Intl.DateTimeFormat("sv-SE", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
export const fmt = (iso: string | null | undefined) => (iso ? BJ.format(new Date(iso)) : "—");
export const day = (iso: string | null | undefined) => (iso ? BJ.format(new Date(iso)).slice(0, 10) : "—");
export type Tenant = {
id: string;
name: string;
status: string;
created_at: string;
plan: { code: string | null; expired: boolean; ends_at: string | null };
balance: number | null;
projects: number;
phone_hint: string | null;
open_requests: number;
};
export type TenantDetail = Tenant & {
members: { display_name: string; role: string; status: string; phone_hint: string | null; last_login_at: string | null }[];
subscriptions: { plan_code: string; status: string; source: string; starts_at: string; ends_at: string | null; request_no: string | null }[];
quota_entries: { kind: string; amount: number; reason: string | null; ref_type: string | null; created_at: string; unit: string; by_ops: boolean }[];
jobs: Record<string, Record<string, number>>;
};
export type ActivationRequest = {
id: string;
request_no: string;
tenant: { id: string; name: string };
plan_code: string;
status: "submitted" | "paid" | "activated" | "rejected" | "cancelled";
invoice_info: Record<string, string>;
ops_note: string | null;
invoice_no: string | null;
applicant_phone_hint: string | null;
created_at: string;
handled_at: string | null;
};
export type Feedback = {
id: string;
kind: string;
source: string;
note: string | null;
object_type: string;
tenant_name: string;
title: string | null;
severity: string | null;
rule_code: string | null;
detector: string | null;
finding_type: string | null;
rule_set_version: string | null;
prompt_versions: Record<string, string> | null;
pages: number[];
created_at: string;
};
export type FailedJob = {
kind: string;
id: string;
tenant: { id: string; name: string };
stage: string | null;
error_class: string | null;
error_code: string | null;
error_detail: string | null;
created_at: string;
duration_s: number | null;
ai_calls: number;
};
export type AuditEvent = {
id: string;
tenant: { id: string; name: string };
actor_kind: string;
actor_name: string | null;
action: string;
object_type: string;
object_id: string | null;
project_id: string | null;
scrubbed: boolean;
created_at: string;
};
export type Operator = {
id: string;
username: string;
display_name: string;
role: string;
role_label: string;
permissions: string[];
env: "dev" | "test" | "prod";
};
export type Summary = { open_requests: number; failed_jobs_7d: number; feedback_7d: number };
export const REQUEST_STATUS: Record<string, string> = {
submitted: "等待转账",
paid: "已到账",
activated: "已开通",
rejected: "已退回",
cancelled: "已撤回",
};
export const QUOTA_KIND: Record<string, string> = {
grant: "发放",
hold: "预扣",
settle: "结算",
release: "退回",
expire: "过期",
adjust: "调整",
waive: "免扣",
};
export const JOB_KIND: Record<string, string> = { parse_job: "解析", check_run: "检查", report_job: "报告" };

11
apps/admin/src/main.ts Normal file
View File

@@ -0,0 +1,11 @@
import { createApp } from "vue";
import "@fontsource/barlow-semi-condensed/500.css";
import "@fontsource/barlow-semi-condensed/600.css";
import "@design/system/tokens.css";
import "@design/system/components.css";
import "@web-styles/app.css";
import "./admin.css";
import App from "./App.vue";
import { router } from "./router";
createApp(App).use(router).mount("#app");

49
apps/admin/src/nav.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* 运营后台的菜单:分组(像文件夹,可以展开收起)→ 菜单项。每一项写清要哪个权限点(ops/auth.py 的 OpsPermission),
* 没有这个权限的人看不到;一个分组下都看不到时整组不显示。以后加页面、加分组、加二级子菜单都只改这里。
*/
import { Building2, FileClock, MessageSquareWarning, ReceiptText, ScrollText, ServerCrash, type LucideIcon } from "@lucide/vue";
import type { Summary } from "./api";
export type NavItem = {
name: string; // 路由名
label: string;
icon: LucideIcon;
perm: string;
/** 菜单上的待办数(来自 /summary) */
badge?: keyof Summary;
/** 这些路由也算在这一项下面(详情页) */
also?: string[];
};
export type NavGroup = { key: string; label: string; icon: LucideIcon; items: NavItem[] };
export const NAV: NavGroup[] = [
{
key: "customers",
label: "客户",
icon: Building2,
items: [
{ name: "requests", label: "开通申请", icon: ReceiptText, perm: "activation.manage", badge: "open_requests" },
{ name: "tenants", label: "企业", icon: Building2, perm: "tenant.view", also: ["tenant"] },
],
},
{
key: "quality",
label: "质量",
icon: MessageSquareWarning,
items: [
{ name: "feedback", label: "误报 / 漏报", icon: MessageSquareWarning, perm: "feedback.view", badge: "feedback_7d" },
{ name: "failed-jobs", label: "失败任务", icon: ServerCrash, perm: "jobs.view", badge: "failed_jobs_7d" },
],
},
{
key: "security",
label: "安全",
icon: ScrollText,
items: [{ name: "audit", label: "审计", icon: FileClock, perm: "audit.view" }],
},
];
export function visibleNav(perms: string[]): NavGroup[] {
return NAV.map((g) => ({ ...g, items: g.items.filter((i) => perms.includes(i.perm)) })).filter((g) => g.items.length);
}

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
/** 审计(FR-M6-07):谁在什么时候对什么做了什么。修改前后的内容是业务内容,这里不显示;删除项目后只剩动作和时间。 */
import { onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { fmt, ops, OpsError, type AuditEvent } from "../api";
const action = ref("");
const items = ref<AuditEvent[]>([]);
const err = ref("");
async function load() {
err.value = "";
try {
items.value = (await ops<{ items: AuditEvent[] }>("/audit-events", { query: { action: action.value.trim() || undefined } })).items;
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
}
onMounted(load);
const ACTOR: Record<string, string> = { user: "用户", ops: "运营", system: "系统" };
</script>
<template>
<div class="page-title">
<div>
<h1>审计</h1>
<p>最近 200 条。按动作前缀筛选,如 ops.(运营操作)、project.delete、tenant.close。</p>
</div>
</div>
<form class="toolbar" @submit.prevent="load">
<input v-model="action" class="input" placeholder="动作前缀,如 ops." aria-label="动作前缀" />
<button type="submit" class="btn">筛选</button>
</form>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<section class="card flush">
<table class="data">
<thead><tr><th>时间</th><th>企业</th><th>操作人</th><th>动作</th><th>对象</th><th></th></tr></thead>
<tbody>
<tr v-for="e in items" :key="e.id">
<td class="num nowrap">{{ fmt(e.created_at) }}</td>
<td><RouterLink class="lnk" :to="{ name: 'tenant', params: { id: e.tenant.id } }">{{ e.tenant.name }}</RouterLink></td>
<td>{{ ACTOR[e.actor_kind] ?? e.actor_kind }}<template v-if="e.actor_name"> · {{ e.actor_name }}</template></td>
<td class="num">{{ e.action }}</td>
<td class="cap muted">{{ e.object_type }} {{ e.object_id?.slice(0, 8) ?? "" }}</td>
<td class="cap muted">{{ e.scrubbed ? "内容已抹掉" : "" }}</td>
</tr>
<tr v-if="!items.length"><td colspan="6" class="muted">没有记录</td></tr>
</tbody>
</table>
</section>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
/** 失败任务:解析、检查、报告。错误类别、错误码、阶段、耗时、模型调用次数;不含文档内容。失败的都已自动退回额度(红线 9)。 */
import { onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { fmt, JOB_KIND, ops, OpsError, type FailedJob } from "../api";
const items = ref<FailedJob[]>([]);
const err = ref("");
onMounted(async () => {
try {
items.value = (await ops<{ items: FailedJob[] }>("/failed-jobs")).items;
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
});
const CLASS: Record<string, string> = { input: "文件问题", infra: "系统异常", cancelled: "取消" };
</script>
<template>
<div class="page-title">
<div>
<h1>失败任务</h1>
<p>失败的任务都已自动退回额度。系统异常(infra)要排查,文件问题(input)一般是用户的文件读不了。</p>
</div>
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<section class="card flush">
<table class="data">
<thead><tr><th>时间</th><th>任务</th><th>企业</th><th>阶段</th><th>错误</th><th class="r">耗时</th><th class="r">模型调用</th><th>编号</th></tr></thead>
<tbody>
<tr v-for="j in items" :key="j.id">
<td class="num nowrap">{{ fmt(j.created_at) }}</td>
<td>{{ JOB_KIND[j.kind] ?? j.kind }}</td>
<td><RouterLink class="lnk" :to="{ name: 'tenant', params: { id: j.tenant.id } }">{{ j.tenant.name }}</RouterLink></td>
<td>{{ j.stage ?? "—" }}</td>
<td>
<span :class="{ 'err-text': j.error_class === 'infra' }">{{ CLASS[j.error_class ?? ""] ?? j.error_class ?? "" }}</span> {{ j.error_code ?? "" }}
<p v-if="j.error_detail" class="inv">{{ j.error_detail }}</p>
</td>
<td class="num r">{{ j.duration_s != null ? `${Math.round(j.duration_s)} 秒` : "—" }}</td>
<td class="num r">{{ j.ai_calls }}</td>
<td class="num cap muted">{{ j.id.slice(0, 8) }}</td>
</tr>
<tr v-if="!items.length"><td colspan="8" class="muted">没有失败的任务</td></tr>
</tbody>
</table>
</section>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
/**
* 误报 / 漏报反馈(J5):逐条复盘,再迭代规则。不显示招标 / 投标原文(决策 M6-7),只给标题、理由、规则编号、
* 提示词版本、页码;要看原文请用户导出报告发过来。致命问题「确认无误」写的说明也会自动进来(U4)。
*/
import { onMounted, ref, watch } from "vue";
import { fmt, ops, OpsError, type Feedback } from "../api";
const kind = ref<"" | "false_positive" | "false_negative">("");
const items = ref<Feedback[]>([]);
const err = ref("");
async function load() {
err.value = "";
try {
items.value = (await ops<{ items: Feedback[] }>("/feedback", { query: { kind: kind.value || undefined } })).items;
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
}
onMounted(load);
watch(kind, load);
const SEV: Record<string, string> = { fatal: "致命", important: "重要", hint: "提示" };
</script>
<template>
<div class="page-title">
<div>
<h1>误报 / 漏报</h1>
<p>只看标题、理由和规则,不看原文。这里的数字只给内部复盘用,界面上不对用户展示统计。</p>
</div>
</div>
<div class="toolbar">
<div class="seg" role="group" aria-label="筛选">
<button type="button" :aria-pressed="kind === ''" @click="kind = ''">全部</button>
<button type="button" :aria-pressed="kind === 'false_positive'" @click="kind = 'false_positive'">误报</button>
<button type="button" :aria-pressed="kind === 'false_negative'" @click="kind = 'false_negative'">漏报</button>
</div>
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<section class="card flush">
<table class="data">
<thead><tr><th>时间</th><th>类型</th><th>企业</th><th>条目</th><th>用户写的</th><th>规则 / 版本</th><th>页码</th></tr></thead>
<tbody>
<tr v-for="f in items" :key="f.id">
<td class="num nowrap">{{ fmt(f.created_at) }}</td>
<td>{{ f.kind === "false_positive" ? "误报" : "漏报" }}<p v-if="f.source !== 'user'" class="inv">{{ f.source }}</p></td>
<td>{{ f.tenant_name }}</td>
<td>
<span v-if="f.severity" class="tag">{{ SEV[f.severity] ?? f.severity }}</span> {{ f.title ?? "(条目已删除)" }}
<p class="inv">{{ f.object_type === "finding" ? "查标问题" : "读标清单项" }}<template v-if="f.finding_type"> · {{ f.finding_type }}</template><template v-if="f.detector"> · {{ f.detector === "ai" ? "模型" : "规则" }}</template></p>
</td>
<td>{{ f.note ?? "—" }}</td>
<td>
<p class="inv">{{ f.rule_code ?? "—" }}</p>
<p class="inv">{{ f.rule_set_version ?? "" }}<template v-if="f.prompt_versions"> · {{ Object.entries(f.prompt_versions).map(([k, v]) => `${k} ${v}`).join("、") }}</template></p>
</td>
<td class="num">{{ f.pages.join("、") || "—" }}</td>
</tr>
<tr v-if="!items.length"><td colspan="7" class="muted">还没有反馈</td></tr>
</tbody>
</table>
</section>
</template>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
/** 运营登录(决策 M6-4):用户名 + 密码 + 身份验证器 App 上的 6 位动态码。账号用命令行建(bid-doctor ops-admin create)。 */
import logoUrl from "@design/logo/svg/logo-horizontal.svg?url";
import { ref } from "vue";
import { useRouter } from "vue-router";
import { ops, OpsError } from "../api";
const router = useRouter();
const username = ref("");
const password = ref("");
const totp = ref("");
const err = ref("");
const busy = ref(false);
async function submit() {
if (busy.value) return;
busy.value = true;
err.value = "";
try {
await ops("/auth/login", { method: "POST", body: { username: username.value.trim(), password: password.value, totp: totp.value.trim() } });
await router.replace({ name: "requests" });
} catch (e) {
err.value = e instanceof OpsError ? e.message : "登录失败";
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="login">
<form class="card" novalidate @submit.prevent="submit">
<img class="logo" :src="logoUrl" alt="标博士" />
<h1>运营后台</h1>
<p class="cap muted">只在内网和白名单地址可用,所有操作都会留痕。</p>
<div class="field fill">
<label for="u">用户名</label>
<input id="u" v-model="username" class="input" autocomplete="username" />
</div>
<div class="field fill">
<label for="p">密码</label>
<input id="p" v-model="password" class="input" type="password" autocomplete="current-password" />
</div>
<div class="field fill">
<label for="t">动态验证码</label>
<input id="t" v-model="totp" class="input" inputmode="numeric" maxlength="6" autocomplete="one-time-code" placeholder="身份验证器 App 上的 6 位数字" />
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<button type="submit" class="btn primary" :disabled="busy">登录</button>
</form>
</div>
</template>
<style scoped>
.login {
display: grid;
place-items: center;
min-height: 100vh;
}
.login form {
width: 380px;
padding: var(--sp-8);
}
.logo {
display: block;
width: 168px;
margin-bottom: var(--sp-6);
}
h1 {
margin: 0;
font: var(--fw-bold) var(--fs-h1) / var(--lh-h1) var(--font-serif);
}
.field {
margin-top: var(--sp-4);
}
.btn {
width: 100%;
margin-top: var(--sp-6);
}
.hint-warn {
margin: var(--sp-4) 0 0;
}
</style>

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
/**
* 开通申请(G8):用户在线提交 → 对公转账 → 这里「标记到账」→「开通」(默认专业版 1 年;当前专业版没到期时是续费,新一期排队)
* → 开发票后登记发票号。不通过就「退回」并写原因,用户在额度页看得到。每一步都写审计。
*/
import { computed, onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { fmt, notifyChanged, ops, OpsError, REQUEST_STATUS, type ActivationRequest } from "../api";
const filter = ref<"open" | "all">("open");
const items = ref<ActivationRequest[]>([]);
const err = ref("");
async function load() {
err.value = "";
try {
items.value = (await ops<{ items: ActivationRequest[] }>("/activation-requests")).items;
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
}
onMounted(load);
const rows = computed(() => (filter.value === "open" ? items.value.filter((r) => r.status === "submitted" || r.status === "paid") : items.value));
const acting = ref<{ id: string; kind: "reject" | "invoice" | "activate" } | null>(null);
const input = ref("");
const months = ref(12);
const busy = ref(false);
function start(r: ActivationRequest, kind: "reject" | "invoice" | "activate") {
acting.value = { id: r.id, kind };
input.value = "";
months.value = 12;
}
async function act(r: ActivationRequest, kind: "paid" | "activate" | "reject" | "invoice") {
busy.value = true;
err.value = "";
try {
const body = kind === "reject" ? { note: input.value.trim() } : kind === "invoice" ? { invoice_no: input.value.trim() } : kind === "activate" ? { months: months.value } : undefined;
await ops(`/activation-requests/${r.id}/${kind}`, { method: "POST", body });
acting.value = null;
notifyChanged();
await load();
} catch (e) {
err.value = e instanceof OpsError ? `${r.request_no}:${e.message}` : "操作失败";
} finally {
busy.value = false;
}
}
const INVOICE_TYPE: Record<string, string> = { general: "普票", special: "专票" };
</script>
<template>
<div class="page-title">
<div>
<h1>开通申请</h1>
<p>核对对公转账到账后再开通。转账备注应当是申请单号。</p>
</div>
</div>
<div class="toolbar">
<div class="seg" role="group" aria-label="筛选">
<button type="button" :aria-pressed="filter === 'open'" @click="filter = 'open'">待处理</button>
<button type="button" :aria-pressed="filter === 'all'" @click="filter = 'all'">全部</button>
</div>
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<section class="card flush">
<table class="data">
<thead>
<tr><th>申请单号</th><th>企业</th><th>发票信息</th><th>状态</th><th>提交时间</th><th>操作</th></tr>
</thead>
<tbody>
<tr v-for="r in rows" :key="r.id">
<td class="num">{{ r.request_no }}</td>
<td>
<RouterLink class="lnk" :to="{ name: 'tenant', params: { id: r.tenant.id } }">{{ r.tenant.name }}</RouterLink>
<p class="inv">申请人 {{ r.applicant_phone_hint ?? "—" }}</p>
</td>
<td>
<p class="inv">
{{ r.invoice_info.title }} · {{ INVOICE_TYPE[r.invoice_info.invoice_type ?? "general"] }}<br />
税号 {{ r.invoice_info.tax_no }}<br />
<template v-if="r.invoice_info.address_phone">{{ r.invoice_info.address_phone }}<br /></template>
<template v-if="r.invoice_info.bank_account">{{ r.invoice_info.bank_account }}<br /></template>
联系人 {{ r.invoice_info.contact_name }} {{ r.invoice_info.contact_phone }}
</p>
</td>
<td>
<span class="tag">{{ REQUEST_STATUS[r.status] }}</span>
<p v-if="r.ops_note" class="inv">原因:{{ r.ops_note }}</p>
<p v-if="r.invoice_no" class="inv">发票 {{ r.invoice_no }}</p>
</td>
<td class="num nowrap">{{ fmt(r.created_at) }}</td>
<td>
<div v-if="acting?.id === r.id" class="acts">
<template v-if="acting.kind === 'activate'">
<label class="cap">周期(月)<input v-model.number="months" class="input sm m" type="number" min="1" max="36" /></label>
<button type="button" class="btn sm primary" :disabled="busy" @click="act(r, 'activate')">确认开通</button>
</template>
<template v-else>
<input v-model="input" class="input sm" :placeholder="acting.kind === 'reject' ? '退回原因(用户看得到)' : '发票号'" />
<button type="button" class="btn sm primary" :disabled="busy || input.trim().length < 2" @click="act(r, acting.kind)">确认</button>
</template>
<button type="button" class="btn sm text" @click="acting = null">取消</button>
</div>
<div v-else class="acts">
<button v-if="r.status === 'submitted'" type="button" class="btn sm" :disabled="busy" @click="act(r, 'paid')">标记到账</button>
<button v-if="r.status === 'submitted' || r.status === 'paid'" type="button" class="btn sm primary" @click="start(r, 'activate')">开通</button>
<button v-if="r.status === 'submitted' || r.status === 'paid'" type="button" class="btn sm text" @click="start(r, 'reject')">退回</button>
<button v-if="r.status === 'activated' && !r.invoice_no" type="button" class="btn sm" @click="start(r, 'invoice')">登记发票</button>
</div>
</td>
</tr>
<tr v-if="!rows.length"><td colspan="6" class="muted">{{ filter === "open" ? "没有待处理的申请" : "还没有申请" }}</td></tr>
</tbody>
</table>
</section>
</template>
<style scoped>
.input.m {
width: 64px;
margin-left: var(--sp-1);
}
.hint-warn {
margin: 0 0 var(--sp-4);
}
</style>

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
/**
* 企业详情:成员(手机号脱敏)、套餐历史、额度流水(不带项目名称)、任务统计;调额度(G6:原因必填,不能扣成负数)。
* 不显示项目名称和任何业务内容(m6-plan §2.2)。
*/
import { onMounted, ref } from "vue";
import { day, fmt, JOB_KIND, ops, OpsError, QUOTA_KIND, type TenantDetail } from "../api";
const props = defineProps<{ id: string }>();
const t = ref<TenantDetail | null>(null);
const err = ref("");
async function load() {
try {
t.value = await ops<TenantDetail>(`/tenants/${props.id}`);
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
}
onMounted(load);
const amount = ref<number | null>(null);
const reason = ref("");
const msg = ref("");
const busy = ref(false);
async function adjust() {
if (!amount.value || reason.value.trim().length < 2) {
msg.value = "填写数量(可以是负数)和原因";
return;
}
busy.value = true;
msg.value = "";
try {
await ops(`/tenants/${props.id}/quota-adjustments`, { method: "POST", body: { unit: "parse", amount: amount.value, reason: reason.value.trim() } });
amount.value = null;
reason.value = "";
msg.value = "已调整";
await load();
} catch (e) {
msg.value = e instanceof OpsError ? e.message : "调整失败";
} finally {
busy.value = false;
}
}
const PLAN: Record<string, string> = { free: "免费版", pro: "专业版", enterprise: "企业版" };
const SUB: Record<string, string> = { scheduled: "排队中", active: "生效中", expired: "已过期", cancelled: "已结束" };
const JOB_STATUS: Record<string, string> = { queued: "排队", running: "进行中", succeeded: "成功", failed: "失败", cancelled: "取消" };
</script>
<template>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<template v-if="t">
<div class="page-title">
<div>
<h1>{{ t.name }}</h1>
<p>
{{ PLAN[t.plan.code ?? ""] ?? "—" }}<template v-if="t.plan.ends_at"> · 至 {{ day(t.plan.ends_at) }}</template><template v-if="t.plan.expired">(已到期)</template>
· 注册 {{ day(t.created_at) }} · 项目 {{ t.projects }} 个<template v-if="t.status === 'closed'"> · 已注销</template>
</p>
</div>
</div>
<div class="grid">
<section class="card">
<div class="card-h"><h2>成员</h2></div>
<table class="data">
<thead><tr><th>姓名</th><th>手机号</th><th>角色</th><th>最近登录</th></tr></thead>
<tbody>
<tr v-for="(m, i) in t.members" :key="i">
<td>{{ m.display_name }}</td><td class="num">{{ m.phone_hint ?? "—" }}</td><td>{{ m.role === "owner" ? "所有者" : m.role }}</td>
<td class="num">{{ fmt(m.last_login_at) }}</td>
</tr>
</tbody>
</table>
</section>
<section class="card">
<div class="card-h"><h2>调额度</h2><span class="cap muted r">解析余额 {{ t.balance ?? 0 }}</span></div>
<form class="adj" @submit.prevent="adjust">
<input v-model.number="amount" class="input" type="number" placeholder="数量,如 20 或 -5" aria-label="数量" />
<input v-model="reason" class="input" maxlength="200" placeholder="原因(必填,写进流水,用户看得到)" aria-label="原因" />
<button type="submit" class="btn primary" :disabled="busy">调整</button>
</form>
<p v-if="msg" class="cap muted">{{ msg }}</p>
<div class="card-h sub"><h2>任务</h2></div>
<p v-for="(st, kind) in t.jobs" :key="kind" class="cap">
{{ JOB_KIND[kind] ?? kind }}:<template v-for="(n, s, i) in st" :key="s">{{ i ? " · " : "" }}{{ JOB_STATUS[s] ?? s }} {{ n }}</template>
</p>
<p v-if="!Object.keys(t.jobs).length" class="cap muted">还没有任务</p>
</section>
</div>
<section class="card flush">
<div class="card-h"><h2>套餐</h2></div>
<table class="data">
<thead><tr><th>套餐</th><th>状态</th><th>来源</th><th>开始</th><th>结束</th><th>申请单</th></tr></thead>
<tbody>
<tr v-for="(s, i) in t.subscriptions" :key="i">
<td>{{ PLAN[s.plan_code] ?? s.plan_code }}</td><td>{{ SUB[s.status] ?? s.status }}</td>
<td>{{ s.source === "signup" ? "注册" : s.source === "manual" ? "运营开通" : s.source }}</td>
<td class="num">{{ day(s.starts_at) }}</td><td class="num">{{ day(s.ends_at) }}</td><td class="num">{{ s.request_no ?? "—" }}</td>
</tr>
</tbody>
</table>
</section>
<section class="card flush">
<div class="card-h"><h2>最近的额度流水</h2></div>
<table class="data">
<thead><tr><th>时间</th><th>类型</th><th class="r">数量</th><th>任务</th><th>说明</th></tr></thead>
<tbody>
<tr v-for="(e, i) in t.quota_entries" :key="i">
<td class="num nowrap">{{ fmt(e.created_at) }}</td>
<td>{{ QUOTA_KIND[e.kind] ?? e.kind }}<span v-if="e.by_ops" class="cap muted">(运营)</span></td>
<td class="num r">{{ e.amount > 0 ? `+${e.amount}` : e.amount }}</td>
<td>{{ e.ref_type ? JOB_KIND[e.ref_type] : "—" }}</td>
<td class="muted">{{ e.reason === "recheck_24h" ? "24 小时内复查" : e.reason ?? "" }}</td>
</tr>
</tbody>
</table>
</section>
</template>
</template>
<style scoped>
.grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: var(--gap-card);
}
.card-h .r {
margin-left: auto;
}
.card-h.sub {
margin-top: var(--sp-6);
}
.adj {
display: grid;
grid-template-columns: 140px minmax(0, 1fr) auto;
gap: var(--sp-2);
}
p.cap {
margin: var(--sp-2) 0 0;
}
</style>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
/** 企业查询:企业名称(模糊)、手机号(精确,按 HMAC 查,库里没有明文)、申请单号。 */
import { onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { day, ops, OpsError, type Tenant } from "../api";
const q = ref("");
const items = ref<Tenant[]>([]);
const err = ref("");
async function search() {
err.value = "";
try {
items.value = (await ops<{ items: Tenant[] }>("/tenants", { query: { q: q.value.trim() || undefined } })).items;
} catch (e) {
err.value = e instanceof OpsError ? e.message : "读取失败";
}
}
onMounted(search);
const PLAN: Record<string, string> = { free: "免费版", pro: "专业版", enterprise: "企业版" };
</script>
<template>
<div class="page-title">
<div>
<h1>企业</h1>
<p>只看账号、套餐和用量,不看项目和文件内容。查看详情会留痕。</p>
</div>
</div>
<form class="toolbar" @submit.prevent="search">
<input v-model="q" class="input" placeholder="企业名称、手机号或申请单号(KT-…)" aria-label="搜索企业" />
<button type="submit" class="btn">搜索</button>
</form>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<section class="card flush">
<table class="data">
<thead>
<tr><th>企业</th><th>手机号</th><th>套餐</th><th class="r">解析余额</th><th class="r">项目</th><th>注册</th><th>状态</th></tr>
</thead>
<tbody>
<tr v-for="t in items" :key="t.id">
<td>
<RouterLink class="lnk" :to="{ name: 'tenant', params: { id: t.id } }">{{ t.name }}</RouterLink>
<span v-if="t.open_requests" class="tag">待处理申请 {{ t.open_requests }}</span>
</td>
<td class="num">{{ t.phone_hint ?? "—" }}</td>
<td>
{{ PLAN[t.plan.code ?? ""] ?? "—" }}
<span v-if="t.plan.expired" class="err-text">已到期</span>
<span v-else-if="t.plan.ends_at" class="cap muted">至 {{ day(t.plan.ends_at) }}</span>
</td>
<td class="num r">{{ t.balance ?? "—" }}</td>
<td class="num r">{{ t.projects }}</td>
<td class="num">{{ day(t.created_at) }}</td>
<td>{{ t.status === "closed" ? "已注销" : t.status === "active" ? "正常" : t.status }}</td>
</tr>
<tr v-if="!items.length"><td colspan="7" class="muted">没有找到</td></tr>
</tbody>
</table>
</section>
</template>

20
apps/admin/src/router.ts Normal file
View File

@@ -0,0 +1,20 @@
import { createRouter, createWebHistory } from "vue-router";
import { setUnauthorized } from "./api";
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/login", name: "login", component: () => import("./pages/LoginPage.vue"), meta: { bare: true } },
{ path: "/", redirect: "/requests" },
{ path: "/requests", name: "requests", component: () => import("./pages/RequestsPage.vue") },
{ path: "/tenants", name: "tenants", component: () => import("./pages/TenantsPage.vue") },
{ path: "/tenants/:id", name: "tenant", component: () => import("./pages/TenantPage.vue"), props: true },
{ path: "/feedback", name: "feedback", component: () => import("./pages/FeedbackPage.vue") },
{ path: "/failed-jobs", name: "failed-jobs", component: () => import("./pages/FailedJobsPage.vue") },
{ path: "/audit", name: "audit", component: () => import("./pages/AuditPage.vue") },
],
});
setUnauthorized(() => {
if (router.currentRoute.value.name !== "login") void router.replace({ name: "login" });
});

20
apps/admin/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client", "node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"jsx": "preserve",
"noEmit": true,
"skipLibCheck": true,
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}

25
apps/admin/vite.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { fileURLToPath, URL } from "node:url";
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
/**
* 运营后台(决策 M6-3):单独构建、单独部署,私有化不交付。开发时跑在 127.0.0.1:5174,
* 把 /ops 代理到 ops 进程 127.0.0.1:8019(bid-doctor ops)。样式只来自 docs/design/system。
*/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
"@design": fileURLToPath(new URL("../../docs/design", import.meta.url)),
// 和用户端共用组件样式(按钮、输入框、表格、卡片……),不复制(决策 M6-3)
"@web-styles": fileURLToPath(new URL("../web/src/styles", import.meta.url)),
},
},
server: {
host: "127.0.0.1",
port: 5174,
strictPort: true,
proxy: { "/ops": { target: "http://127.0.0.1:8019", changeOrigin: false } },
},
});

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
/**
* 专业版开通 / 续费申请(原型 quota.html 的续费对话框,G8,决策 M6-6):
* 提交申请不扣额度、不用先付款;之后对公转账,运营确认到账后开通并开发票。页面不写价格(D2 未定),运营联系确认金额。
* 专票要地址电话和开户行账号。
*/
import { unwrap } from "@bid-doctor/api-client";
import { X } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { ref, watch } from "vue";
import SelectBox from "@/components/SelectBox.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { qk } from "@/lib/keys";
import { useMe, type ActivationRequest } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ renew?: boolean }>();
const open = defineModel<boolean>("open", { required: true });
const emit = defineEmits<{ submitted: [r: ActivationRequest] }>();
const qc = useQueryClient();
const me = useMe();
const title = ref("");
const type = ref<"general" | "special">("general");
const taxNo = ref("");
const addressPhone = ref("");
const bankAccount = ref("");
const contact = ref("");
const phone = ref("");
const errs = ref<Record<string, string>>({});
const err = ref("");
const busy = ref(false);
watch(open, (v) => {
if (!v) return;
title.value = me.data.value?.tenant.name ?? "";
contact.value = me.data.value?.user.display_name ?? "";
errs.value = {};
err.value = "";
});
function validate(): boolean {
const e: Record<string, string> = {};
if (title.value.trim().length < 2) e.title = "请填写发票抬头,一般就是企业名称";
const t = taxNo.value.trim().toUpperCase();
if (!t) e.taxNo = "请填写纳税人识别号,开发票要用";
else if (!/^[0-9A-Z]{15,20}$/.test(t)) e.taxNo = "纳税人识别号是 15 到 20 位数字或大写字母,请核对";
if (type.value === "special" && !addressPhone.value.trim()) e.addressPhone = "增值税专用发票要填地址电话";
if (type.value === "special" && !bankAccount.value.trim()) e.bankAccount = "增值税专用发票要填开户行和账号";
if (!contact.value.trim()) e.contact = "请填写联系人";
if (!/^1[3-9]\d{9}$/.test(phone.value.trim())) e.phone = "手机号应为 11 位数字";
errs.value = e;
return !Object.keys(e).length;
}
async function submit() {
if (busy.value || !validate()) return;
busy.value = true;
err.value = "";
try {
const r = await unwrap(
api.POST("/activation-requests", {
body: {
plan_code: "pro",
invoice_info: {
title: title.value.trim(),
tax_no: taxNo.value.trim().toUpperCase(),
invoice_type: type.value,
address_phone: addressPhone.value.trim() || undefined,
bank_account: bankAccount.value.trim() || undefined,
contact_name: contact.value.trim(),
contact_phone: phone.value.trim(),
},
},
}),
);
void qc.invalidateQueries({ queryKey: qk.quota });
open.value = false;
toast(`已提交${props.renew ? "续费" : "开通"}申请 ${r.request_no}`);
emit("submitted", r);
} catch (e) {
err.value = isApiError(e, "activation_pending") ? "已经有一份待处理的申请,等它处理完再提交" : errorTitle(e);
} finally {
busy.value = false;
}
}
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="scrim">
<DialogContent class="dialog" :aria-describedby="undefined">
<DialogTitle as="h2">{{ renew ? "续费专业版" : "开通专业版" }}</DialogTitle>
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
<form class="body" novalidate @submit.prevent="submit">
<p class="lead">
专业版按年{{ renew ? "续费" : "开通" }}:提交申请 → 对公转账 → 运营确认到账后开通,并开发票。<b>提交申请不扣额度,也不用先付款</b>;金额由运营联系你确认。
</p>
<div class="field fill" :class="{ err: errs.title }">
<label for="ad-title">发票抬头 <small>(必填)</small></label>
<input id="ad-title" v-model="title" class="input" maxlength="100" autocomplete="off" />
<span v-if="errs.title" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.title }}</span>
<span v-else class="msg">额度绑定注册的企业主体,不可转让</span>
</div>
<div class="grid2">
<div class="field">
<label for="ad-type">发票类型 <small>(必填)</small></label>
<SelectBox
id="ad-type"
:model-value="type"
:options="[
{ value: 'general', label: '增值税普通发票' },
{ value: 'special', label: '增值税专用发票' },
]"
@update:model-value="(v) => (type = v === 'special' ? 'special' : 'general')"
/>
</div>
<div class="field" :class="{ err: errs.taxNo }">
<label for="ad-tax">纳税人识别号 <small>(必填)</small></label>
<input id="ad-tax" v-model="taxNo" class="input num" maxlength="20" placeholder="18 位统一社会信用代码" autocomplete="off" />
<span v-if="errs.taxNo" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.taxNo }}</span>
</div>
</div>
<template v-if="type === 'special'">
<div class="field fill" :class="{ err: errs.addressPhone }">
<label for="ad-addr">地址、电话 <small>(必填)</small></label>
<input id="ad-addr" v-model="addressPhone" class="input" maxlength="200" autocomplete="off" />
<span v-if="errs.addressPhone" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.addressPhone }}</span>
</div>
<div class="field fill" :class="{ err: errs.bankAccount }">
<label for="ad-bank">开户行及账号 <small>(必填)</small></label>
<input id="ad-bank" v-model="bankAccount" class="input" maxlength="200" autocomplete="off" />
<span v-if="errs.bankAccount" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.bankAccount }}</span>
</div>
</template>
<div class="grid2">
<div class="field" :class="{ err: errs.contact }">
<label for="ad-contact">联系人 <small>(必填)</small></label>
<input id="ad-contact" v-model="contact" class="input" maxlength="40" autocomplete="off" />
<span v-if="errs.contact" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.contact }}</span>
</div>
<div class="field" :class="{ err: errs.phone }">
<label for="ad-phone">手机号 <small>(必填)</small></label>
<input id="ad-phone" v-model="phone" class="input num" maxlength="11" inputmode="numeric" autocomplete="off" />
<span v-if="errs.phone" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.phone }}</span>
</div>
</div>
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<div class="foot">
<span class="cap muted left">不扣额度</span>
<DialogClose class="btn" type="button" :disabled="busy">取消</DialogClose>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :disabled="busy">
<span class="idle">提交申请</span><span class="busy">正在提交…</span>
</button>
</div>
</form>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</DialogRoot>
</template>
<style scoped>
.lead {
margin: 0 0 var(--sp-4);
color: var(--text-secondary);
}
.lead b {
color: var(--text-primary);
font-weight: var(--fw-medium);
}
.grid2 {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--sp-4);
}
.body > .field + .field,
.body > .field + .grid2,
.body > .grid2 + .field,
.body > .grid2 + .grid2 {
margin-top: var(--sp-4);
}
.input.num {
font-family: var(--font-figures);
font-variant-numeric: tabular-nums;
font-size: var(--fs-figure-sm);
}
.hint-warn {
margin: var(--sp-4) 0 0;
}
</style>

View File

@@ -3,11 +3,12 @@
* 登录后的页面框架(设计规范 v0.2 §5.3):96px 墨色渐变侧栏 + 透明顶栏(滚动后半透明白)+ 内容区。
* 挂载期间保持一条事件流连接,把服务端的变化推进查询缓存。
*/
import { Award, House, LogOut } from "@lucide/vue";
import { Award, House, LogOut, Wallet } 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 NotificationBell from "./NotificationBell.vue";
import Toasts from "./Toasts.vue";
import { api } from "@/lib/api";
import { provideCrumbs } from "@/lib/crumbs";
@@ -74,12 +75,15 @@ async function logout() {
<RouterLink :to="{ name: 'qualifications' }" :aria-current="route.name === 'qualifications' ? 'page' : undefined">
<Award class="ic" :stroke-width="1.5" /><span>资质库</span>
</RouterLink>
<RouterLink :to="{ name: 'quota' }" :aria-current="route.name === 'quota' ? 'page' : undefined">
<Wallet class="ic" :stroke-width="1.5" /><span>额度</span>
</RouterLink>
</nav>
<div class="me">
<RouterLink class="me" :to="{ name: 'account' }" :aria-current="route.name === 'account' ? 'page' : undefined" aria-label="账号">
<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>
</RouterLink>
</aside>
<main class="main">
@@ -93,18 +97,25 @@ async function logout() {
</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">
<RouterLink v-if="accounts.length" class="quota-chip tip-below tip-left" :to="{ name: 'quota' }" :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>
</RouterLink>
<NotificationBell />
<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>
<!-- 套餐到期后只读(决策 M6-5):每一页顶上都提醒,额度页里有续费入口 -->
<div v-if="plan?.read_only && route.name !== 'quota'" class="expired-bar" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>专业版已到期,数据只读:可以查看、下载已导出的报告,不能新建项目、解析和检查。</p>
<RouterLink class="btn sm" :to="{ name: 'quota' }">续费</RouterLink>
</div>
<RouterView />
</main>
<Toasts />

View File

@@ -0,0 +1,138 @@
<script setup lang="ts">
/**
* 顶栏的通知(原型 app.js 的铃铛,m6-plan §1.6):有未读时带圆点;点开是最近 30 条,点一条跳到相关页面并标为已读。
* 解析 / 检查 / 报告完成、额度用到 80% / 100%、开通结果、到期提醒都在这里。
*/
import { unwrap } from "@bid-doctor/api-client";
import { Bell } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from "reka-ui";
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import { api } from "@/lib/api";
import { qk } from "@/lib/keys";
import { fmtDateTime } from "@/lib/labels";
import { useNotifications, type Notification } from "@/lib/queries";
const q = useNotifications();
const qc = useQueryClient();
const router = useRouter();
const open = ref(false);
const items = computed(() => q.data.value?.items ?? []);
const unread = computed(() => q.data.value?.unread_count ?? 0);
async function markRead(ids?: string[]) {
await unwrap(api.POST("/notifications/read", { body: ids ? { ids } : {} }));
void qc.invalidateQueries({ queryKey: qk.notifications });
}
async function go(n: Notification) {
open.value = false;
if (!n.read_at) void markRead([n.id]);
if (n.link) await router.push(n.link);
}
</script>
<template>
<PopoverRoot v-model:open="open">
<PopoverTrigger as-child>
<button
class="icon-btn tip-below tip-left bell"
type="button"
:aria-label="unread ? `通知(${unread} 条未读)` : '通知'"
:data-tip="open ? undefined : '通知'"
>
<Bell class="ic lg" :stroke-width="1.5" /><span v-if="unread" class="dot" aria-hidden="true"></span>
</button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent class="menu popper notes" align="end" :side-offset="6" aria-label="通知">
<div class="nh">
<b>通知</b>
<button v-if="unread" type="button" class="lnk cap" @click="markRead()">全部标为已读</button>
</div>
<p v-if="!items.length" class="cap muted none">还没有通知</p>
<ul v-else>
<li v-for="n in items" :key="n.id">
<button type="button" class="note" :class="{ unread: !n.read_at }" @click="go(n)">
<span class="t">{{ n.title }}</span>
<span class="cap muted">
<template v-if="n.project_name">{{ n.project_name }} · </template>{{ fmtDateTime(n.created_at) }}
</span>
</button>
</li>
</ul>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>
<style scoped>
.bell {
position: relative;
}
.dot {
position: absolute;
top: 6px;
right: 6px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--c-ink);
box-shadow: 0 0 0 2px var(--bg-page);
}
/* PopoverContent 的根元素上没有 scoped 属性(在 Reka 的定位外层上),从外层选进去 */
[data-reka-popper-content-wrapper] :deep(.notes) {
width: 360px;
max-height: min(480px, 70vh);
padding: 0;
}
.nh {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3) var(--sp-4) var(--sp-2);
border-bottom: var(--line) solid var(--border-subtle);
}
.none {
margin: 0;
padding: var(--sp-4);
}
ul {
margin: 0;
padding: var(--sp-1);
list-style: none;
}
.note {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: var(--sp-2) var(--sp-3);
border: 0;
border-radius: var(--radius-ctl);
background: none;
font: inherit;
color: var(--text-secondary);
text-align: left;
cursor: pointer;
}
.note:hover {
background: var(--c-n100);
}
.note.unread {
color: var(--text-primary);
}
.note.unread .t {
font-weight: var(--fw-medium);
}
.note.unread .t::before {
content: "";
display: inline-block;
width: 6px;
height: 6px;
margin-right: var(--sp-2);
border-radius: 50%;
background: var(--c-ink);
vertical-align: 2px;
}
</style>

View File

@@ -4,11 +4,14 @@
* 就绪只在「已检查」时显示(G7),只表示致命问题都已处理,不代表投标文件没有问题(READY_TIP)。
* 状态操作(FR-M6-02):递交 / 撤销递交 / 归档 / 撤销归档。致命问题没处理完也可以递交(红线 4),确认框里说明;
* 递交后项目只读,可以撤销,都留痕。
* 删除项目(J4,决策 M6-1):放在「更多」里,彻底删除、不能恢复,确认时要输入项目名称;额度流水和不含内容的审计保留。
*/
import { unwrap } from "@bid-doctor/api-client";
import { MoreHorizontal } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuTrigger } from "reka-ui";
import { computed, ref } from "vue";
import { RouterLink } from "vue-router";
import { RouterLink, useRouter } from "vue-router";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { api, errorTitle } from "@/lib/api";
import { qk } from "@/lib/keys";
@@ -57,6 +60,38 @@ async function run(action: Action) {
}
}
/* ---------- 删除项目 ---------- */
const router = useRouter();
const delOpen = ref(false);
const delName = ref("");
const delErr = ref("");
function askDelete() {
delName.value = "";
delErr.value = "";
delOpen.value = true;
}
async function doDelete() {
if (!p.value) return;
if (delName.value.trim() !== p.value.name) {
delErr.value = "项目名称不一致,请照上面的名称输入";
return;
}
busy.value = true;
try {
await unwrap(api.DELETE("/projects/{project_id}", { params: { path: { project_id: p.value.id } } }));
delOpen.value = false;
qc.removeQueries({ queryKey: qk.project(p.value.id) });
void qc.invalidateQueries({ queryKey: qk.projects });
void qc.invalidateQueries({ queryKey: qk.quota });
toast(`已删除项目「${p.value.name}」`);
await router.replace({ name: "workbench" });
} catch (e) {
delErr.value = errorTitle(e);
} finally {
busy.value = false;
}
}
const checkTo = computed(() =>
p.value?.latest_check_run_id
? { name: "results", params: { projectId: p.value.id, runId: p.value.latest_check_run_id } }
@@ -84,6 +119,14 @@ const checkTo = computed(() =>
<dd>
<span class="tag">{{ projectStatus(p.status) }}</span>
<button v-for="a in actions" :key="a.action" type="button" class="btn sm text" :disabled="busy" @click="run(a.action)">{{ a.label }}</button>
<DropdownMenuRoot :modal="false">
<DropdownMenuTrigger class="icon-btn sm" aria-label="更多操作"><MoreHorizontal class="ic" :stroke-width="1.5" /></DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent class="menu popper" align="end" :side-offset="4">
<DropdownMenuItem @select="askDelete">删除项目</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</dd>
</div>
<div v-if="p.readiness.ready !== null && p.readiness.ready !== undefined">
@@ -106,4 +149,19 @@ const checkTo = computed(() =>
<p>标记后项目改为只读,可以随时撤销。</p>
<p v-if="p && p.readiness.open_fatal_count">还有 <b class="fig">{{ p.readiness.open_fatal_count }}</b> 项致命问题没有处理。是否递交由你决定,系统不会阻止。</p>
</ConfirmDialog>
<ConfirmDialog v-model:open="delOpen" :title="`删除项目「${p?.name ?? ''}」?`" ok-text="删除项目" busy-text="正在删除…" :busy="busy" @ok="doDelete">
<p>项目下的招标文件、投标文件、清单、检查结果和报告都会彻底删除,<b>不能恢复</b>。需要留底的报告请先下载。</p>
<p class="cap muted">额度流水和一条不含业务内容的操作记录会保留。</p>
<div class="field fill del-name" :class="{ err: delErr }">
<label for="del-name">输入项目名称确认</label>
<input id="del-name" v-model="delName" class="input" :placeholder="p?.name" autocomplete="off" @input="delErr = ''" />
<span v-if="delErr" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ delErr }}</span>
</div>
</ConfirmDialog>
</template>
<style scoped>
.del-name {
margin-top: var(--sp-3);
}
</style>

View File

@@ -37,6 +37,7 @@ function onEvent(qc: QueryClient, e: BdEvent) {
if (e.type === "job.finished") {
// 结束后要拿完整结果(error、result),并刷新项目、额度、清单
void qc.invalidateQueries({ queryKey: qk.parseJob(jobId) });
void qc.invalidateQueries({ queryKey: qk.notifications });
void qc.invalidateQueries({ queryKey: qk.projects });
void qc.invalidateQueries({ queryKey: qk.quota });
if (e.project_id) {
@@ -55,6 +56,7 @@ function onEvent(qc: QueryClient, e: BdEvent) {
);
if (e.type === "job.finished") {
void qc.invalidateQueries({ queryKey: qk.checkRun(jobId) });
void qc.invalidateQueries({ queryKey: qk.notifications });
void qc.invalidateQueries({ queryKey: qk.findings(jobId) });
void qc.invalidateQueries({ queryKey: qk.projects });
void qc.invalidateQueries({ queryKey: qk.quota });
@@ -69,6 +71,7 @@ function onEvent(qc: QueryClient, e: BdEvent) {
switch (e.type) {
case "quota.changed":
void qc.invalidateQueries({ queryKey: qk.quota });
void qc.invalidateQueries({ queryKey: qk.notifications });
break;
case "project.changed":
void qc.invalidateQueries({ queryKey: qk.projects });

View File

@@ -2,6 +2,10 @@
export const qk = {
me: ["me"] as const,
quota: ["quota"] as const,
/** 前缀和 quota 一样:额度一变,流水和申请跟着失效 */
quotaEntries: ["quota", "entries"] as const,
activationRequests: ["quota", "activation-requests"] as const,
notifications: ["notifications"] as const,
plans: ["plans"] as const,
projects: ["projects"] as const,
project: (id: string) => ["project", id] as const,

View File

@@ -1,6 +1,6 @@
/** 服务端数据:全部由 TanStack Query 管理,不放进 Pinia(架构 §4.2)。 */
import { unwrap, type Schemas } from "@bid-doctor/api-client";
import { useQuery } from "@tanstack/vue-query";
import { useInfiniteQuery, useQuery } from "@tanstack/vue-query";
import { computed, toValue, type MaybeRefOrGetter } from "vue";
import { api } from "./api";
import { qk } from "./keys";
@@ -46,6 +46,34 @@ export function useQuotaAccount(unit: Schemas["QuotaUnit"]) {
return computed(() => q.data.value?.accounts.find((a) => a.unit === unit));
}
export type QuotaEntry = Schemas["QuotaEntry"];
export type ActivationRequest = Schemas["ActivationRequest"];
export type Notification = Schemas["Notification"];
/** 额度流水:按时间倒序,按游标一页页往后取(「加载更多」) */
export function useQuotaEntries() {
return useInfiniteQuery({
queryKey: qk.quotaEntries,
queryFn: ({ pageParam }) =>
unwrap(api.GET("/quota/entries", { params: { query: { limit: 50, ...(pageParam ? { cursor: pageParam } : {}) } } })),
initialPageParam: "",
getNextPageParam: (last) => last.next_cursor ?? undefined,
});
}
export function useActivationRequests() {
return useQuery({ queryKey: qk.activationRequests, queryFn: () => unwrap(api.GET("/activation-requests")) });
}
/** 站内通知:顶栏铃铛。事件流在任务结束时让它失效;另外每分钟兜底刷新一次(运营开通、到期提醒不走事件流) */
export function useNotifications() {
return useQuery({
queryKey: qk.notifications,
queryFn: () => unwrap(api.GET("/notifications", { params: { query: { limit: 30 } } })),
refetchInterval: 60_000,
});
}
export function usePlans() {
return useQuery({ queryKey: qk.plans, queryFn: () => unwrap(api.GET("/plans")), staleTime: 10 * 60_000 });
}

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
/**
* 账号(m6-plan §1.5):企业名称、手机号(脱敏)、姓名、套餐;最下面注销账号(J4、data-model §6.2,决策 M6-8)。
* 注销:输入企业名称 + 短信验证码(发到自己的手机号)。全部项目、资质库彻底删除,剩余额度作废、不退款;
* 开通申请和发票记录按财务要求保留。注销后手机号可以重新注册。
*/
import { unwrap } from "@bid-doctor/api-client";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, ref } from "vue";
import { RouterLink, useRouter } from "vue-router";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { useMe, useQuota } from "@/lib/queries";
import { toast } from "@/lib/toast";
useCrumbs(() => [{ label: "账号" }]);
const me = useMe();
const quota = useQuota();
const m = computed(() => me.data.value);
const plan = computed(() => quota.data.value?.plan ?? m.value?.plan);
const paidLeft = computed(() => plan.value && plan.value.code !== "free" && !plan.value.read_only);
const open = ref(false);
const name = ref("");
const code = ref("");
const err = ref<{ name?: string; code?: string; other?: string }>({});
const busy = ref(false);
const wait = ref(0);
let timer: number | undefined;
onBeforeUnmount(() => window.clearInterval(timer));
function ask() {
name.value = "";
code.value = "";
err.value = {};
open.value = true;
}
async function sendCode() {
try {
const r = await unwrap(api.POST("/me/sms-codes", { body: { purpose: "close_tenant" } }));
wait.value = r.resend_after_s;
window.clearInterval(timer);
timer = window.setInterval(() => (wait.value = Math.max(0, wait.value - 1)), 1000);
toast(`验证码已发送到 ${m.value?.user.phone_hint ?? "你的手机"}`);
} catch (e) {
err.value = { other: errorTitle(e) };
}
}
const router = useRouter();
const qc = useQueryClient();
async function close() {
const e: typeof err.value = {};
if (name.value.trim() !== m.value?.tenant.name) e.name = "企业名称不一致,请照上面的名称输入";
if (!/^\d{6}$/.test(code.value.trim())) e.code = "请填写 6 位验证码";
err.value = e;
if (Object.keys(e).length) return;
busy.value = true;
try {
await unwrap(api.POST("/tenant/close", { body: { sms_code: code.value.trim(), confirm_tenant_name: name.value.trim() } }));
open.value = false;
qc.clear();
await router.replace({ name: "login" });
toast("账号已注销,业务数据正在后台删除");
} catch (x) {
if (isApiError(x, "sms_code_invalid")) err.value = { code: "验证码不正确或已过期" };
else if (isApiError(x, "tenant_name_mismatch")) err.value = { name: "企业名称不一致,请照上面的名称输入" };
else err.value = { other: errorTitle(x) };
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="content narrow">
<div class="page-title">
<div>
<h1>账号</h1>
<p>一个企业一个账号;额度和数据都归属企业主体。</p>
</div>
</div>
<section class="card" aria-labelledby="h-info">
<div class="card-h"><h2 id="h-info">企业和登录</h2></div>
<dl v-if="m" class="kv">
<dt>企业名称</dt><dd>{{ m.tenant.name }}</dd>
<dt>手机号</dt><dd class="fig">{{ m.user.phone_hint || "—" }}</dd>
<dt>姓名</dt><dd>{{ m.user.display_name }}</dd>
<dt>套餐</dt>
<dd>
{{ plan?.name }}<template v-if="plan?.read_only">(已到期,只读)</template>
<RouterLink class="lnk cap" :to="{ name: 'quota' }">额度与套餐</RouterLink>
</dd>
</dl>
</section>
<section class="card danger" aria-labelledby="h-close">
<div class="card-h"><h2 id="h-close">注销账号</h2></div>
<p>注销后,企业下的全部项目(招标文件、投标文件、清单、检查结果、报告)和资质库都会<b>彻底删除,不能恢复</b>。需要留底的报告请先下载。</p>
<p class="cap muted">开通申请和发票记录按财务要求保留;额度流水和不含业务内容的操作记录保留。注销后这个手机号可以重新注册。</p>
<button type="button" class="btn" @click="ask">注销账号</button>
</section>
</div>
<ConfirmDialog v-model:open="open" title="注销账号?" ok-text="注销账号" busy-text="正在注销…" :busy="busy" @ok="close">
<p>企业「{{ m?.tenant.name }}」下的全部项目和资质库都会彻底删除,<b>不能恢复</b>。</p>
<p v-if="paidLeft" class="warn-text">专业版还没到期(到 <span class="fig">{{ plan?.ends_at?.slice(0, 10) }}</span>),剩余额度作废,不退款。</p>
<div class="field fill" :class="{ err: err.name }">
<label for="cl-name">输入企业名称确认</label>
<input id="cl-name" v-model="name" class="input" :placeholder="m?.tenant.name" autocomplete="off" @input="err.name = undefined" />
<span v-if="err.name" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ err.name }}</span>
</div>
<div class="field fill" :class="{ err: err.code }">
<label for="cl-code">短信验证码</label>
<div class="code-row">
<input id="cl-code" v-model="code" class="input num" maxlength="6" inputmode="numeric" autocomplete="one-time-code" @input="err.code = undefined" />
<button type="button" class="btn" :disabled="wait > 0" @click="sendCode">{{ wait > 0 ? `${wait} 秒后重发` : `发送到 ${m?.user.phone_hint ?? "手机"}` }}</button>
</div>
<span v-if="err.code" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ err.code }}</span>
</div>
<p v-if="err.other" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err.other }}</p>
</ConfirmDialog>
</template>
<style scoped>
.content > .card {
margin-bottom: var(--gap-card);
}
.kv .lnk {
margin-left: var(--sp-3);
}
.danger p {
margin: 0 0 var(--sp-3);
}
.field {
margin-top: var(--sp-3);
}
.code-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-2);
}
.input.num {
font-family: var(--font-figures);
font-variant-numeric: tabular-nums;
font-size: var(--fs-figure-sm);
}
.warn-text {
color: var(--warn-text);
}
.hint-warn {
margin: var(--sp-3) 0 0;
}
</style>

View File

@@ -0,0 +1,287 @@
<script setup lang="ts">
/**
* 额度与套餐(原型 quota.html,FR-M6-03、05,m6-plan §1.1–1.3):
* - 套餐和本期起止;开通 / 续费申请(对公转账,运营确认到账后开通,G8)和收款信息;到期后只读(M6-5);
* - 本期额度(剩余、已用、退回)、按功能分布、扣点规则(红线 9:扣点规则可见、可查);
* - 本期流水:每一笔都能对到项目和任务,余额逐条可核;项目删了写「已删除的项目」(G10);可以导出 CSV。
* 检查也从解析额度扣(data-model §4.3),所以只有一个「解析」账户。
*/
import { Download, Plus } from "@lucide/vue";
import { computed, ref } from "vue";
import ActivationDialog from "@/components/ActivationDialog.vue";
import { useCrumbs } from "@/lib/crumbs";
import { fmtDateTime, quotaUnit } from "@/lib/labels";
import { useActivationRequests, useQuota, useQuotaEntries, type QuotaEntry } from "@/lib/queries";
import { toast } from "@/lib/toast";
useCrumbs(() => [{ label: "额度与套餐" }]);
const quota = useQuota();
const q = computed(() => quota.data.value);
const plan = computed(() => q.value?.plan);
const acct = computed(() => q.value?.accounts.find((a) => a.unit === "parse"));
const requests = useActivationRequests();
const pending = computed(() => q.value?.pending_request ?? null);
const lastRejected = computed(() => {
const latest = requests.data.value?.items[0];
return latest && latest.status === "rejected" ? latest : null;
});
const day = (iso: string | null | undefined) => (iso ? iso.slice(0, 10) : "");
const expired = computed(() => !!plan.value?.read_only);
const daysLeft = computed(() =>
plan.value?.ends_at ? Math.ceil((new Date(plan.value.ends_at).getTime() - Date.now()) / 86_400_000) : null,
);
/** 开通 / 续费按钮:免费版随时可以申请开通;专业版到期前 30 天起、已到期时可以续费;已续过一期或有待处理的申请时不显示 */
const canApply = computed(() => {
if (!plan.value || pending.value || q.value?.renewal) return false;
if (plan.value.code === "free" || expired.value) return true;
return daysLeft.value !== null && daysLeft.value <= 30;
});
const renewing = computed(() => plan.value?.code !== "free");
const dlg = ref(false);
/* ---------- 流水 ---------- */
const entriesQ = useQuotaEntries();
const rows = computed(() => entriesQ.data.value?.pages.flatMap((p) => p.items) ?? []);
const KIND: Record<string, string> = {
grant: "发放",
hold: "预扣",
settle: "结算",
release: "退回",
expire: "过期",
adjust: "调整",
waive: "免扣(24 小时内复查)",
};
const REASON: Record<string, string> = { recheck_24h: "距上次扣额度的检查不到 24 小时" };
function refText(e: QuotaEntry): string {
const project = e.project ? (e.project.deleted ? "已删除的项目" : e.project.name ?? "") : "";
const task = e.ref?.label ?? (e.ref ? (e.ref.type === "check_run" ? "检查" : "解析") : "");
return [project, task].filter(Boolean).join(" · ");
}
function note(e: QuotaEntry): string {
if (e.reason) return REASON[e.reason] ?? e.reason;
if (e.kind === "release") return "失败、超时或取消,自动退回";
if (e.kind === "settle") return "任务完成";
return "";
}
const signed = (n: number) => (n > 0 ? `+${n}` : String(n));
/** 导出 CSV:把还没加载的页都取完,在浏览器里生成文件 */
const exporting = ref(false);
async function exportCsv() {
exporting.value = true;
try {
while (entriesQ.hasNextPage.value) await entriesQ.fetchNextPage();
const head = ["时间", "类型", "额度", "数量", "余额", "关联项目 · 任务", "说明"];
const lines = rows.value.map((e) =>
[fmtDateTime(e.created_at), KIND[e.kind] ?? e.kind, quotaUnit(e.unit), signed(e.amount), e.balance_after, refText(e), note(e)]
.map((v) => `"${String(v).replaceAll('"', '""')}"`)
.join(","),
);
const blob = new Blob(["" + [head.join(","), ...lines].join("\r\n")], { type: "text/csv;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `额度流水-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(a.href);
} catch {
toast("导出失败,请稍后再试", { ok: false });
} finally {
exporting.value = false;
}
}
</script>
<template>
<div class="content">
<div class="page-title">
<div>
<h1>额度与套餐</h1>
<p v-if="plan">
<template v-if="plan.ends_at">{{ plan.name }} · 本期 <span class="fig">{{ day(plan.starts_at) }}</span> 至 <span class="fig">{{ day(plan.ends_at) }}</span>。额度绑定企业主体,不可转让;本期没用完的不结转。</template>
<template v-else>{{ plan.name }} · <span class="fig">{{ day(plan.starts_at) }}</span> 注册起长期有效,额度不再补发。额度绑定企业主体,不可转让。</template>
</p>
</div>
<div class="actions">
<button v-if="canApply" type="button" class="btn" :class="{ primary: expired || plan?.code === 'free' }" @click="dlg = true">
<Plus class="ic" :stroke-width="1.5" />{{ renewing ? "提交续费申请" : "开通专业版" }}
</button>
</div>
</div>
<div v-if="expired" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>专业版已于 <span class="fig">{{ day(plan?.ends_at) }}</span> 到期,数据只读:可以查看、下载已导出的报告、删除项目,不能新建项目、解析和检查。续费到账后恢复。</p>
</div>
<div v-else-if="daysLeft !== null && daysLeft <= 30 && !q?.renewal" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>专业版还有 <span class="fig">{{ Math.max(daysLeft, 0) }}</span> 天到期,到期后数据只读、额度清零。</p>
</div>
<div v-if="q?.renewal" class="alert" role="note">
<span class="ico i" aria-hidden="true">i</span>
<p>已续费:新一期 <span class="fig">{{ day(q.renewal.starts_at) }}</span> 至 <span class="fig">{{ day(q.renewal.ends_at) }}</span>,到时自动生效并发放额度。</p>
</div>
<section v-if="pending" class="card pend" aria-labelledby="h-pend">
<div class="card-h">
<h2 id="h-pend">{{ renewing ? "续费" : "开通" }}申请 <span class="fig">{{ pending.request_no }}</span></h2>
<span class="tag">{{ pending.status === "paid" ? "已到账,等待开通" : "等待对公转账" }}</span>
</div>
<dl class="kv">
<dt>套餐</dt><dd>专业版 · 1 年</dd>
<dt>收款单位</dt><dd>{{ pending.payment.payee_name }}</dd>
<dt>开户行</dt><dd>{{ pending.payment.payee_bank }}</dd>
<dt>账号</dt><dd class="fig">{{ pending.payment.payee_account }}</dd>
<dt>转账备注</dt><dd>请写申请单号 <b class="fig">{{ pending.request_no }}</b></dd>
</dl>
<p class="cap muted foot-note">金额由运营联系你确认。运营确认到账后开通,并开发票;开通后新一期额度会记进下面的流水。</p>
</section>
<div v-else-if="lastRejected" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>申请 <span class="fig">{{ lastRejected.request_no }}</span> 被退回<template v-if="lastRejected.ops_note">:{{ lastRejected.ops_note }}</template>。可以修改后重新提交。</p>
</div>
<div class="cards">
<section class="card" aria-labelledby="h-acct">
<div class="card-h"><h2 id="h-acct">解析额度</h2><span v-if="acct" class="cap muted r">已用 <span class="fig">{{ Math.round((acct.used_ratio ?? 0) * 100) }}</span>%</span></div>
<template v-if="acct">
<small class="muted">剩余</small>
<div><span class="big" :class="{ warn: (acct.used_ratio ?? 0) >= 0.8 }">{{ acct.balance }}</span><span class="unit">/ {{ acct.period_granted }} 次</span></div>
<div class="bar" :class="{ warn: (acct.used_ratio ?? 0) >= 0.8 }" aria-hidden="true">
<i :style="{ width: `${Math.min(100, Math.round((acct.used_ratio ?? 0) * 100))}%` }"></i>
</div>
<p class="cap muted">
已用 <span class="fig">{{ acct.period_used }}</span> 次 · 退回 <span class="fig">{{ acct.period_released ?? 0 }}</span> 次<template v-if="acct.held"> · 正在预扣 <span class="fig">{{ acct.held }}</span> 次</template>
</p>
</template>
<p v-else class="cap muted">读取中…</p>
</section>
<section class="card" aria-labelledby="h-usage">
<div class="card-h"><h2 id="h-usage">按功能分布</h2></div>
<ul v-if="q" class="usage">
<li><span>读标 · 招标文件</span><span><span class="fig">{{ q.usage.tender }}</span> <small>次解析</small></span></li>
<li><span>读标 · 补遗和澄清</span><span><span class="fig">{{ q.usage.addendum }}</span> <small>次解析</small></span></li>
<li><span>查标</span><span><span class="fig">{{ q.usage.check }}</span> <small>次解析</small></span></li>
<li v-if="q.usage.deleted"><span>已删除的项目</span><span><span class="fig">{{ q.usage.deleted }}</span> <small>次解析</small></span></li>
<li><span>24 小时内复查</span><span><span class="fig">{{ q.usage.waived }}</span> <small>次 · 免扣</small></span></li>
</ul>
</section>
</div>
<section class="card flush" aria-labelledby="h-rules">
<div class="card-h pad"><h2 id="h-rules">扣点规则</h2></div>
<table class="data">
<thead><tr><th>动作</th><th>扣点</th><th>不扣点</th></tr></thead>
<tbody>
<tr>
<td><b>解读一份招标文件、补遗或澄清</b></td>
<td>1 次解析:发起时预扣,成功后结算</td>
<td>解析失败、超时、取消:自动退回</td>
</tr>
<tr>
<td><b>对一个项目做一次检查</b></td>
<td>专业版 1 次解析(投标文件的解析含在内);免费版:这个项目内不限次数</td>
<td>检查中断、系统异常:自动退回<br />同一项目 24 小时内复查:不扣,流水记一行「免扣」。24 小时从上一次扣额度的检查算起</td>
</tr>
</tbody>
</table>
</section>
<section class="card flush" aria-labelledby="h-ledger">
<div class="card-h pad">
<h2 id="h-ledger">流水</h2>
<button type="button" class="btn sm r" :disabled="exporting || !rows.length" @click="exportCsv"><Download class="ic" :stroke-width="1.5" />导出 CSV</button>
</div>
<table class="data ledger">
<thead>
<tr><th>时间</th><th>类型</th><th class="r">数量</th><th class="r">余额</th><th>关联项目 · 任务</th><th>说明</th></tr>
</thead>
<tbody>
<tr v-for="e in rows" :key="e.id">
<td class="fig nowrap">{{ fmtDateTime(e.created_at) }}</td>
<td>{{ KIND[e.kind] ?? e.kind }}</td>
<td class="num r">{{ signed(e.amount) }}</td>
<td class="num r">{{ e.balance_after }}</td>
<td :class="{ muted: e.project?.deleted }">{{ refText(e) || "—" }}</td>
<td class="muted">{{ note(e) }}</td>
</tr>
<tr v-if="!rows.length && !entriesQ.isPending.value"><td colspan="6" class="muted">还没有流水</td></tr>
</tbody>
</table>
<div v-if="entriesQ.hasNextPage.value" class="more">
<button type="button" class="btn sm text" :disabled="entriesQ.isFetchingNextPage.value" @click="entriesQ.fetchNextPage()">加载更多</button>
</div>
</section>
</div>
<ActivationDialog v-model:open="dlg" :renew="renewing" />
</template>
<style scoped>
.content > .alert,
.content > .card {
margin-bottom: var(--gap-card);
}
.cards {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--gap-card);
margin-bottom: var(--gap-card);
}
.cards .card {
margin: 0;
}
.card-h .r {
margin-left: auto;
}
.card-h.pad {
padding: var(--sp-4) var(--card-x) 0;
}
.big {
font: var(--fw-bold) var(--fs-figure) / var(--lh-figure) var(--font-figures);
font-variant-numeric: tabular-nums;
}
.big.warn {
color: var(--warn-text);
}
.unit {
margin-left: 4px;
color: var(--text-secondary);
font-family: var(--font-figures);
}
.bar {
margin: var(--sp-3) 0 var(--sp-2);
}
.usage {
margin: 0;
padding: 0;
list-style: none;
}
.usage li {
display: flex;
justify-content: space-between;
padding: var(--sp-2) 0;
border-top: var(--line) solid var(--border-subtle);
}
.usage li:first-child {
border-top: 0;
}
.usage small {
color: var(--text-secondary);
}
.pend .kv {
margin: 0;
}
.foot-note {
margin: var(--sp-3) 0 0;
}
.ledger .r {
text-align: right;
}
.nowrap {
white-space: nowrap;
}
.more {
padding: var(--sp-2) var(--card-x) var(--sp-3);
}
</style>

View File

@@ -23,6 +23,8 @@ const projects = useProjects();
const quota = useQuota();
const parse = useQuotaAccount("parse");
/** 套餐到期后只读(决策 M6-5):不能新建项目,顶上有续费提醒 */
const readOnly = computed(() => !!me.data.value?.plan.read_only);
const today = new Date();
const hello = computed(() => {
const name = me.data.value?.user.display_name ?? "";
@@ -87,7 +89,7 @@ const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ??
</div>
<div class="actions">
<span v-if="parse" class="cost">解析 1 份招标文件扣 <span class="fig">1</span> 次 · 剩 <span class="fig">{{ parse.balance }}</span> 次</span>
<RouterLink class="btn primary" :to="{ name: 'project-new' }"><Plus class="ic" :stroke-width="1.5" />新建项目</RouterLink>
<RouterLink v-if="!readOnly" class="btn primary" :to="{ name: 'project-new' }"><Plus class="ic" :stroke-width="1.5" />新建项目</RouterLink>
</div>
</div>
@@ -131,7 +133,7 @@ const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ??
<div v-else class="empty">
<b>还没有项目</b>
<p>上传招标文件,标博士会逐条提取要求,每一条都带原文出处。</p>
<RouterLink class="btn primary" :to="{ name: 'project-new' }"><Plus class="ic" :stroke-width="1.5" />新建项目</RouterLink>
<RouterLink v-if="!readOnly" class="btn primary" :to="{ name: 'project-new' }"><Plus class="ic" :stroke-width="1.5" />新建项目</RouterLink>
</div>
</section>

View File

@@ -25,6 +25,8 @@ export const router = createRouter({
{ path: "", name: "workbench", component: () => import("@/pages/WorkbenchPage.vue") },
{ path: "projects/new", name: "project-new", component: () => import("@/pages/ProjectNewPage.vue") },
{ path: "qualifications", name: "qualifications", component: () => import("@/pages/QualificationsPage.vue") },
{ path: "quota", name: "quota", component: () => import("@/pages/QuotaPage.vue") },
{ path: "account", name: "account", component: () => import("@/pages/AccountPage.vue") },
{
path: "projects/:projectId/parse/:jobId",
name: "parse",

View File

@@ -48,6 +48,13 @@
.quota-chip .fig{color:var(--text-primary);font-size:var(--fs-figure-sm)}
.quota-chip .q.warn .fig{color:var(--warn-text);font-weight:var(--fw-bold)}
.quota-chip .sep{width:1px;height:12px;margin:0 var(--sp-1);background:var(--border-subtle)}
a.quota-chip:hover{background:var(--c-n100)}
/* 侧栏底部的账号区可以点(进账号页) */
a.me{text-decoration:none;border-radius:var(--radius-ctl);transition:background-color var(--dur-fast) var(--ease)}
a.me:hover{background:var(--side-hover)}a.me[aria-current="page"]{background:var(--side-active)}
/* 套餐到期的提醒条(决策 M6-5):赭色,不用红(U1) */
.expired-bar{display:flex;gap:var(--sp-3);align-items:center;margin:0 var(--pad-page) var(--gap-card);padding:10px var(--card-x);background:var(--bg-surface);border:var(--line) solid var(--warn);border-radius:var(--radius-card)}
.expired-bar p{flex:1;margin:0;color:var(--warn-text)}
.top .vsep{flex:none;width:1px;height:20px;margin:0 var(--sp-1);background:var(--border-subtle)}
.content{width:100%;padding:20px var(--pad-page) var(--sp-12)}
.content.narrow{max-width:800px}