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:
@@ -48,6 +48,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
AuditOutlined,
|
AuditOutlined,
|
||||||
|
CrownOutlined,
|
||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
SafetyCertificateOutlined,
|
SafetyCertificateOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
@@ -73,6 +74,7 @@ const collapsed = ref(false);
|
|||||||
|
|
||||||
const menuLeaves: MenuLeaf[] = [
|
const menuLeaves: MenuLeaf[] = [
|
||||||
{ key: "/admin-users", label: "用户管理", path: "/admin-users" },
|
{ key: "/admin-users", label: "用户管理", path: "/admin-users" },
|
||||||
|
{ key: "/kol-subscriptions", label: "订阅包审批", path: "/kol-subscriptions" },
|
||||||
{ key: "/site-domain-mappings", label: "站点映射", path: "/site-domain-mappings" },
|
{ key: "/site-domain-mappings", label: "站点映射", path: "/site-domain-mappings" },
|
||||||
{ key: "/accounts", label: "操作员管理", path: "/accounts" },
|
{ key: "/accounts", label: "操作员管理", path: "/accounts" },
|
||||||
{ key: "/audits", label: "审计日志", path: "/audits" },
|
{ key: "/audits", label: "审计日志", path: "/audits" },
|
||||||
@@ -84,6 +86,11 @@ const menuItems = computed<ItemType[]>(() => [
|
|||||||
label: "用户管理",
|
label: "用户管理",
|
||||||
icon: () => h(TeamOutlined),
|
icon: () => h(TeamOutlined),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "/kol-subscriptions",
|
||||||
|
label: "订阅包审批",
|
||||||
|
icon: () => h(CrownOutlined),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "/site-domain-mappings",
|
key: "/site-domain-mappings",
|
||||||
label: "站点映射",
|
label: "站点映射",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
|
import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
App as AntApp,
|
App as AntApp,
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -55,6 +56,7 @@ bindUnauthorizedHandler(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
[
|
[
|
||||||
|
Alert,
|
||||||
AntApp,
|
AntApp,
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ export const router = createRouter({
|
|||||||
component: () => import("@/views/AdminUsersView.vue"),
|
component: () => import("@/views/AdminUsersView.vue"),
|
||||||
meta: { title: "用户管理" },
|
meta: { title: "用户管理" },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "kol-subscriptions",
|
||||||
|
name: "kol-subscriptions",
|
||||||
|
component: () => import("@/views/KolSubscriptionsView.vue"),
|
||||||
|
meta: { title: "订阅包审批" },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "accounts",
|
path: "accounts",
|
||||||
name: "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>
|
||||||
Reference in New Issue
Block a user