Files
geo/apps/ops-web/src/views/KolSubscriptionsView.vue
T
root 73fd7db21a 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>
2026-04-29 22:07:41 +08:00

620 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>