决策 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>
155 lines
6.4 KiB
Vue
155 lines
6.4 KiB
Vue
<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>
|