feat(m6): 额度流水、套餐开通、数据删除、运营后台(M6 剩下的 P0)
决策 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:
287
apps/web/src/pages/QuotaPage.vue
Normal file
287
apps/web/src/pages/QuotaPage.vue
Normal 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>
|
||||
Reference in New Issue
Block a user