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

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>