feat(ops-web/kol): add KOL subscription review console

New /kol-subscriptions page lists pending and active subscriptions, lets
operators approve / revoke / manually bind packages to tenants, and links
into the AppShell nav under 订阅包审批. Register Alert globally so the
view's status banners render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 22:07:41 +08:00
parent 9bb7144593
commit 73fd7db21a
4 changed files with 634 additions and 0 deletions
+7
View File
@@ -48,6 +48,7 @@
<script setup lang="ts">
import {
AuditOutlined,
CrownOutlined,
GlobalOutlined,
SafetyCertificateOutlined,
SettingOutlined,
@@ -73,6 +74,7 @@ const collapsed = ref(false);
const menuLeaves: MenuLeaf[] = [
{ key: "/admin-users", label: "用户管理", path: "/admin-users" },
{ key: "/kol-subscriptions", label: "订阅包审批", path: "/kol-subscriptions" },
{ key: "/site-domain-mappings", label: "站点映射", path: "/site-domain-mappings" },
{ key: "/accounts", label: "操作员管理", path: "/accounts" },
{ key: "/audits", label: "审计日志", path: "/audits" },
@@ -84,6 +86,11 @@ const menuItems = computed<ItemType[]>(() => [
label: "用户管理",
icon: () => h(TeamOutlined),
},
{
key: "/kol-subscriptions",
label: "订阅包审批",
icon: () => h(CrownOutlined),
},
{
key: "/site-domain-mappings",
label: "站点映射",
+2
View File
@@ -1,5 +1,6 @@
import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
import {
Alert,
App as AntApp,
Avatar,
Badge,
@@ -55,6 +56,7 @@ bindUnauthorizedHandler(() => {
});
[
Alert,
AntApp,
Avatar,
Badge,
+6
View File
@@ -24,6 +24,12 @@ export const router = createRouter({
component: () => import("@/views/AdminUsersView.vue"),
meta: { title: "用户管理" },
},
{
path: "kol-subscriptions",
name: "kol-subscriptions",
component: () => import("@/views/KolSubscriptionsView.vue"),
meta: { title: "订阅包审批" },
},
{
path: "accounts",
name: "accounts",
@@ -0,0 +1,619 @@
<template>
<h2 class="ops-page-title">订阅包审批</h2>
<div class="ops-card">
<div class="ops-toolbar kol-subscriptions-toolbar">
<a-input
v-model:value="filter.keyword"
class="kol-subscriptions-search"
placeholder="搜索租户 / 手机号 / KOL / 模板包 / ID"
allow-clear
@press-enter="reload"
@change="onKeywordChange"
/>
<a-select
v-model:value="filter.status"
class="kol-subscriptions-status"
:options="statusOptions"
placeholder="订阅状态"
allow-clear
@change="reload"
/>
<a-button @click="reload">刷新</a-button>
<span class="kol-subscriptions-toolbar__spacer" />
<a-tag color="orange">待审批 {{ pendingRowsCount }}</a-tag>
<a-tag>当前筛选 {{ total }}</a-tag>
<a-button type="primary" @click="openManualBind">
<template #icon><PlusOutlined /></template>
绑定模板包
</a-button>
</div>
<a-table
:columns="columns"
:data-source="rows"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="onTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'subscriber'">
<div class="primary-cell">
<strong>{{ record.tenant_name }}</strong>
<span>
{{ record.subscriber_phone ? `手机 ${record.subscriber_phone}` : "手机号 —" }}
· 租户 #{{ record.tenant_id }} · 申请 #{{ record.id }}
</span>
</div>
</template>
<template v-else-if="column.key === 'package'">
<div class="primary-cell">
<strong>{{ record.package_name }}</strong>
<span> #{{ record.package_id }}</span>
</div>
</template>
<template v-else-if="column.key === 'creator'">
<div class="creator-cell">
<a-avatar :src="record.kol_avatar_url || undefined" size="small">
{{ record.kol_display_name.charAt(0) || "K" }}
</a-avatar>
<div class="primary-cell">
<strong>{{ record.kol_display_name }}</strong>
<span>{{ record.creator_tenant_name }} · 租户 #{{ record.creator_tenant_id }}</span>
</div>
</div>
</template>
<template v-else-if="column.key === 'status'">
<a-space direction="vertical" :size="4">
<a-tag :color="statusColor(record.status)">
{{ statusLabel(record.status) }}
</a-tag>
<a-tag :color="record.package_status === 'published' ? 'green' : 'default'">
{{ packageStatusLabel(record.package_status) }}
</a-tag>
</a-space>
</template>
<template v-else-if="column.key === 'prompts'">
<div class="prompt-count">
<strong>{{ record.access_prompt_count }}</strong>
<span>/ {{ record.prompt_count }} Prompt</span>
</div>
</template>
<template v-else-if="column.key === 'period'">
<div class="period-cell">
<span>开始{{ formatOptionalDate(record.start_at) }}</span>
<span>到期{{ formatOptionalDate(record.end_at, "永不过期") }}</span>
</div>
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatDate(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<div class="table-actions-row">
<a-tooltip v-if="record.status === 'pending'" title="通过">
<a-button
type="text"
shape="circle"
size="small"
class="action-btn action-approve"
@click="openApprove(record)"
>
<CheckOutlined />
</a-button>
</a-tooltip>
<a-tooltip v-if="record.status === 'pending' || record.status === 'active'" title="撤销订阅">
<a-popconfirm
title="确认撤销该订阅?撤销后订阅方将无法使用包内 Prompt。"
:ok-button-props="{ danger: true, loading: revokeLoadingId === record.id }"
@confirm="revoke(record)"
>
<a-button
type="text"
shape="circle"
size="small"
class="action-btn action-revoke"
>
<StopOutlined />
</a-button>
</a-popconfirm>
</a-tooltip>
<span v-if="record.status !== 'pending' && record.status !== 'active'" class="muted-text">无操作</span>
</div>
</template>
</template>
</a-table>
</div>
<a-modal
v-model:open="approveOpen"
:title="approveTarget ? `通过订阅申请 #${approveTarget.id}` : '通过订阅申请'"
:confirm-loading="approveLoading"
ok-text="通过"
@ok="submitApprove"
@cancel="approveOpen = false"
>
<a-alert
v-if="approveTarget"
class="approve-alert"
type="info"
show-icon
:message="`${approveTarget.tenant_name} 将获得「${approveTarget.package_name}」的使用权限`"
/>
<a-form layout="vertical" :model="approveForm">
<a-form-item label="订阅有效期" help="留空表示不过期;过渡期可先用人工控制。">
<a-date-picker
v-model:value="approveForm.end_date"
class="approve-date-picker"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
allow-clear
placeholder="永不过期"
/>
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="manualBindOpen"
title="手动绑定精调模板包"
:confirm-loading="manualBindLoading"
ok-text="绑定"
@ok="submitManualBind"
@cancel="manualBindOpen = false"
>
<a-alert
class="manual-bind-alert"
type="info"
show-icon
message="输入用户手机号并选择精调模板市场的模板包,保存后会直接发放包内 Prompt 使用权限。"
/>
<a-form layout="vertical" :model="manualBindForm">
<a-form-item label="用户手机号" required>
<a-input
v-model:value="manualBindForm.phone"
placeholder="13800138000"
allow-clear
@press-enter="submitManualBind"
/>
</a-form-item>
<a-form-item label="精调模板包" required>
<a-select
v-model:value="manualBindForm.package_id"
show-search
allow-clear
:filter-option="false"
:loading="packageLoading"
:options="packageOptions"
placeholder="搜索模板包 / KOL / 包 ID"
@focus="loadPackages()"
@search="onPackageSearch"
/>
</a-form-item>
<div v-if="selectedPackage" class="manual-bind-preview">
<div class="creator-cell">
<a-avatar :src="selectedPackage.kol_avatar_url || undefined" size="small">
{{ selectedPackage.kol_display_name.charAt(0) || "K" }}
</a-avatar>
<div class="primary-cell">
<strong>{{ selectedPackage.name }}</strong>
<span>{{ selectedPackage.kol_display_name }} · {{ selectedPackage.creator_tenant_name }}</span>
</div>
</div>
<a-tag color="blue">{{ selectedPackage.prompt_count }} Prompt</a-tag>
</div>
<a-form-item label="授权有效期" help="留空表示不过期;如果该用户已有待审批/已通过记录,会更新为这里选择的有效期。">
<a-date-picker
v-model:value="manualBindForm.end_date"
class="approve-date-picker"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
allow-clear
placeholder="永不过期"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import { PlusOutlined, CheckOutlined, StopOutlined } from "@ant-design/icons-vue";
import type { TableColumnsType, TablePaginationConfig } from "ant-design-vue";
import { message } from "ant-design-vue";
import dayjs from "dayjs";
import { computed, onMounted, reactive, ref } from "vue";
import { OpsApiError, http } from "@/lib/http";
interface KolSubscriptionRow {
id: number;
tenant_id: number;
tenant_name: string;
subscriber_name: string | null;
subscriber_phone: string | null;
package_id: number;
package_name: string;
package_status: string;
creator_tenant_id: number;
creator_tenant_name: string;
kol_profile_id: number;
kol_display_name: string;
kol_avatar_url: string | null;
status: string;
prompt_count: number;
access_prompt_count: number;
start_at: string | null;
end_at: string | null;
created_at: string;
updated_at: string;
}
interface ListResult {
items: KolSubscriptionRow[];
total: number;
page: number;
size: number;
}
interface KolPackageOption {
id: number;
name: string;
status: string;
creator_tenant_id: number;
creator_tenant_name: string;
kol_profile_id: number;
kol_display_name: string;
kol_avatar_url: string | null;
prompt_count: number;
created_at: string;
updated_at: string;
}
const filter = reactive({
keyword: "",
status: "pending" as string | undefined,
});
const statusOptions = [
{ label: "审核中", value: "pending" },
{ label: "已通过", value: "active" },
{ label: "已撤销", value: "revoked" },
{ label: "已过期", value: "expired" },
];
const columns: TableColumnsType<KolSubscriptionRow> = [
{ title: "申请租户", key: "subscriber", width: 220 },
{ title: "订阅包", key: "package", width: 220 },
{ title: "KOL", key: "creator", width: 260 },
{ title: "状态", key: "status", width: 130 },
{ title: "授权 Prompt", key: "prompts", width: 130 },
{ title: "有效期", key: "period", width: 210 },
{ title: "申请时间", key: "created_at", width: 180 },
{ title: "操作", key: "actions", width: 120 },
];
const rows = ref<KolSubscriptionRow[]>([]);
const packageRows = ref<KolPackageOption[]>([]);
const loading = ref(false);
const packageLoading = ref(false);
const page = ref(1);
const size = ref(20);
const total = ref(0);
const pagination = computed<TablePaginationConfig>(() => ({
current: page.value,
pageSize: size.value,
total: total.value,
showSizeChanger: true,
pageSizeOptions: ["10", "20", "50", "100"],
}));
const pendingRowsCount = computed(() => rows.value.filter((row) => row.status === "pending").length);
const packageOptions = computed(() =>
packageRows.value.map((item) => ({
label: `${item.name} · ${item.kol_display_name} · ${item.prompt_count} 个 Prompt`,
value: item.id,
})),
);
let keywordTimer: number | null = null;
let packageSearchTimer: number | null = null;
function onKeywordChange() {
if (keywordTimer) window.clearTimeout(keywordTimer);
keywordTimer = window.setTimeout(() => {
page.value = 1;
void reload();
}, 300);
}
async function reload() {
loading.value = true;
try {
const result = await http.get<ListResult>("/kol/subscriptions", {
keyword: filter.keyword || undefined,
status: filter.status || undefined,
page: page.value,
size: size.value,
});
rows.value = result.items;
total.value = result.total;
} catch (error) {
if (error instanceof OpsApiError) message.error(error.detail || error.message);
} finally {
loading.value = false;
}
}
async function loadPackages(keyword = "") {
packageLoading.value = true;
try {
packageRows.value = await http.get<KolPackageOption[]>("/kol/packages", {
keyword: keyword || undefined,
size: 50,
});
} catch (error) {
if (error instanceof OpsApiError) message.error(error.detail || error.message);
} finally {
packageLoading.value = false;
}
}
function onPackageSearch(value: string) {
if (packageSearchTimer) window.clearTimeout(packageSearchTimer);
packageSearchTimer = window.setTimeout(() => {
void loadPackages(value.trim());
}, 300);
}
function onTableChange(pag: TablePaginationConfig) {
page.value = pag.current ?? 1;
size.value = pag.pageSize ?? 20;
void reload();
}
const manualBindOpen = ref(false);
const manualBindLoading = ref(false);
const manualBindForm = reactive({
phone: "",
package_id: undefined as number | undefined,
end_date: undefined as string | undefined,
});
const selectedPackage = computed(() =>
packageRows.value.find((item) => item.id === manualBindForm.package_id) ?? null,
);
function resetManualBind() {
manualBindForm.phone = "";
manualBindForm.package_id = undefined;
manualBindForm.end_date = undefined;
}
function openManualBind() {
resetManualBind();
manualBindOpen.value = true;
void loadPackages();
}
async function submitManualBind() {
const phone = normalizePhone(manualBindForm.phone);
if (!phone) {
message.warning("请输入有效手机号");
return;
}
if (!manualBindForm.package_id) {
message.warning("请选择精调模板包");
return;
}
if (manualBindForm.end_date && dayjs(manualBindForm.end_date).endOf("day").isBefore(dayjs())) {
message.warning("有效期不能早于今天");
return;
}
manualBindLoading.value = true;
try {
const result = await http.post<KolSubscriptionRow>("/kol/subscriptions/manual-bind", {
phone,
package_id: manualBindForm.package_id,
end_at: manualBindForm.end_date ? dayjs(manualBindForm.end_date).endOf("day").toISOString() : null,
});
message.success(`已为 ${phone} 绑定「${result.package_name}`);
manualBindOpen.value = false;
filter.keyword = phone;
filter.status = "active";
page.value = 1;
void reload();
} catch (error) {
if (error instanceof OpsApiError) message.error(error.detail || error.message);
} finally {
manualBindLoading.value = false;
}
}
const approveOpen = ref(false);
const approveLoading = ref(false);
const approveTarget = ref<KolSubscriptionRow | null>(null);
const approveForm = reactive({
end_date: undefined as string | undefined,
});
function openApprove(row: KolSubscriptionRow) {
approveTarget.value = row;
approveForm.end_date = undefined;
approveOpen.value = true;
}
async function submitApprove() {
if (!approveTarget.value) return;
if (approveForm.end_date && dayjs(approveForm.end_date).endOf("day").isBefore(dayjs())) {
message.warning("有效期不能早于今天");
return;
}
approveLoading.value = true;
try {
await http.post<KolSubscriptionRow>(`/kol/subscriptions/${approveTarget.value.id}/approve`, {
end_at: approveForm.end_date ? dayjs(approveForm.end_date).endOf("day").toISOString() : null,
});
message.success("订阅已通过,Prompt 权限已发放");
approveOpen.value = false;
void reload();
} catch (error) {
if (error instanceof OpsApiError) message.error(error.detail || error.message);
} finally {
approveLoading.value = false;
}
}
const revokeLoadingId = ref<number | null>(null);
async function revoke(row: KolSubscriptionRow) {
revokeLoadingId.value = row.id;
try {
await http.post<KolSubscriptionRow>(`/kol/subscriptions/${row.id}/revoke`);
message.success("订阅已撤销");
void reload();
} catch (error) {
if (error instanceof OpsApiError) message.error(error.detail || error.message);
} finally {
revokeLoadingId.value = null;
}
}
function statusLabel(status: string): string {
if (status === "pending") return "审核中";
if (status === "active") return "已通过";
if (status === "revoked") return "已撤销";
if (status === "expired") return "已过期";
return status;
}
function statusColor(status: string): string {
if (status === "pending") return "orange";
if (status === "active") return "green";
if (status === "revoked") return "red";
if (status === "expired") return "default";
return "default";
}
function packageStatusLabel(status: string): string {
if (status === "published") return "已发布";
if (status === "draft") return "草稿";
if (status === "archived") return "已归档";
return status;
}
function normalizePhone(value: string): string {
const phone = value.trim().replace(/[\s-]/g, "");
return /^\+?\d{6,20}$/.test(phone) ? phone : "";
}
function formatOptionalDate(value: string | null, empty = "—"): string {
if (!value) return empty;
return dayjs(value).format("YYYY-MM-DD");
}
function formatDate(value: string): string {
return dayjs(value).format("YYYY-MM-DD HH:mm:ss");
}
onMounted(() => {
void reload();
});
</script>
<style scoped>
.kol-subscriptions-toolbar {
align-items: center;
}
.kol-subscriptions-search {
width: 320px;
}
.kol-subscriptions-status {
width: 140px;
}
.kol-subscriptions-toolbar__spacer {
flex: 1;
}
.primary-cell {
display: flex;
flex-direction: column;
gap: 3px;
}
.primary-cell strong {
color: #0f172a;
font-weight: 650;
}
.primary-cell span,
.muted-text {
color: #94a3b8;
font-size: 12px;
}
.creator-cell {
display: flex;
gap: 10px;
align-items: center;
}
.prompt-count {
display: flex;
align-items: baseline;
gap: 4px;
}
.prompt-count strong {
color: #0f172a;
font-size: 18px;
}
.prompt-count span {
color: #64748b;
font-size: 12px;
}
.period-cell {
display: flex;
flex-direction: column;
gap: 4px;
color: #64748b;
font-size: 12px;
}
.danger-link {
color: #dc2626;
}
.approve-alert {
margin-bottom: 16px;
}
.manual-bind-alert {
margin-bottom: 16px;
}
.manual-bind-preview {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: -2px 0 16px;
padding: 12px;
border: 1px solid #dbeafe;
border-radius: 8px;
background: #f8fbff;
}
.approve-date-picker {
width: 100%;
}
</style>