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:
192
apps/web/src/components/ActivationDialog.vue
Normal file
192
apps/web/src/components/ActivationDialog.vue
Normal 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>
|
||||
@@ -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 />
|
||||
|
||||
138
apps/web/src/components/NotificationBell.vue
Normal file
138
apps/web/src/components/NotificationBell.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
154
apps/web/src/pages/AccountPage.vue
Normal file
154
apps/web/src/pages/AccountPage.vue
Normal 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>
|
||||
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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user