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:
13
apps/admin/index.html
Normal file
13
apps/admin/index.html
Normal 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
25
apps/admin/package.json
Normal 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
53
apps/admin/src/App.vue
Normal 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>
|
||||
289
apps/admin/src/OpsSidebar.vue
Normal file
289
apps/admin/src/OpsSidebar.vue
Normal 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
15
apps/admin/src/admin.css
Normal 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
155
apps/admin/src/api.ts
Normal 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
11
apps/admin/src/main.ts
Normal 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
49
apps/admin/src/nav.ts
Normal 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);
|
||||
}
|
||||
50
apps/admin/src/pages/AuditPage.vue
Normal file
50
apps/admin/src/pages/AuditPage.vue
Normal 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>
|
||||
48
apps/admin/src/pages/FailedJobsPage.vue
Normal file
48
apps/admin/src/pages/FailedJobsPage.vue
Normal 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>
|
||||
63
apps/admin/src/pages/FeedbackPage.vue
Normal file
63
apps/admin/src/pages/FeedbackPage.vue
Normal 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>
|
||||
82
apps/admin/src/pages/LoginPage.vue
Normal file
82
apps/admin/src/pages/LoginPage.vue
Normal 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>
|
||||
126
apps/admin/src/pages/RequestsPage.vue
Normal file
126
apps/admin/src/pages/RequestsPage.vue
Normal 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>
|
||||
143
apps/admin/src/pages/TenantPage.vue
Normal file
143
apps/admin/src/pages/TenantPage.vue
Normal 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>
|
||||
60
apps/admin/src/pages/TenantsPage.vue
Normal file
60
apps/admin/src/pages/TenantsPage.vue
Normal 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
20
apps/admin/src/router.ts
Normal 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
20
apps/admin/tsconfig.json
Normal 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
25
apps/admin/vite.config.ts
Normal 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 } },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user