Files
geo/apps/admin-web/src/views/KolPackageDetailView.vue
T
root 4dd8a1bb0e feat(tenant/kol): let KOLs self-subscribe to their own published packages
Add /kol/manage/packages/:id/self-subscription POST/DELETE on the tenant
side so a KOL can grant themselves access to their own published package
without going through the marketplace approval flow. Reject self-subscribe
through the marketplace with a clear error, expose owned_by_current_tenant
on package responses, and surface a self_subscription block on KolPackage
Response. The admin-web KOL workspace gets 订阅自己 / 取消订阅 entries with
matching messaging in the marketplace detail view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:06:57 +08:00

548 lines
14 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.
<script setup lang="ts">
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message } from "ant-design-vue";
import {
LeftOutlined,
UserOutlined,
TagOutlined,
ThunderboltOutlined,
TeamOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
ExclamationCircleOutlined,
} from "@ant-design/icons-vue";
import { kolMarketplaceApi, resolveApiURL } from "@/lib/api";
import { formatError } from "@/lib/errors";
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const queryClient = useQueryClient();
const packageId = computed(() => Number(route.params.id));
const packageQuery = useQuery({
queryKey: computed(() => ["kol", "package", packageId.value]),
queryFn: () => kolMarketplaceApi.detail(packageId.value),
enabled: computed(() => !isNaN(packageId.value)),
});
const mySubscriptionPromptsQuery = useQuery({
queryKey: ["kol", "my-subscription-prompts"],
queryFn: () => kolMarketplaceApi.mySubscriptionPrompts(),
});
const subscribeMutation = useMutation({
mutationFn: (id: number) => kolMarketplaceApi.subscribe(id),
onSuccess: () => {
message.success(t("common.submit"));
void queryClient.invalidateQueries({ queryKey: ["kol", "package", packageId.value] });
},
onError: (error) => {
message.error(formatError(error) || t("common.noData"));
},
});
const pkg = computed(() => packageQuery.data.value);
const resolvedCoverUrl = computed(() => resolveApiURL(pkg.value?.cover_url));
const subscription = computed(() => pkg.value?.subscription);
const isOwnPackage = computed(() => Boolean(pkg.value?.owned_by_current_tenant));
const subscriptionStatusMeta = computed(() => {
if (!subscription.value) return null;
switch (subscription.value.status) {
case "pending":
return { label: t("kol.package.pending"), color: "orange", icon: ClockCircleOutlined };
case "active":
return { label: t("kol.package.subscribed"), color: "success", icon: CheckCircleOutlined };
case "expired":
return { label: t("kol.package.expired"), color: "error", icon: ExclamationCircleOutlined };
case "revoked":
return { label: t("kol.package.revoked"), color: "default", icon: ExclamationCircleOutlined };
default:
return null;
}
});
function handleSubscribe() {
if (pkg.value && !isOwnPackage.value) {
subscribeMutation.mutate(pkg.value.id);
}
}
function handleGenerate(promptId: number) {
const subPrompt = mySubscriptionPromptsQuery.data.value?.find(
(sp) => sp.prompt_id === promptId && sp.package_id === packageId.value
);
if (subPrompt) {
void router.push({
name: "kol-generate",
params: { subscriptionPromptId: String(subPrompt.id) },
query: { source: "templates" },
});
}
}
function goBack() {
void router.push({ name: "kol-marketplace" });
}
</script>
<template>
<div class="kol-package-detail">
<div class="detail-header">
<button type="button" class="back-btn" @click="goBack" :aria-label="t('common.back')">
<span class="back-btn__icon">
<LeftOutlined />
</span>
<span class="back-btn__text">{{ t("common.back") }}</span>
</button>
</div>
<div v-if="packageQuery.isPending.value" class="loading-state">
<a-spin size="large" />
</div>
<div v-else-if="pkg" class="detail-content">
<a-row :gutter="24">
<a-col :xs="24" :lg="16">
<section class="main-card">
<div
class="package-cover"
v-if="resolvedCoverUrl"
:style="{ backgroundImage: `url(${resolvedCoverUrl})` }"
>
<img :src="resolvedCoverUrl" alt="cover" />
</div>
<div class="package-info">
<div class="package-title-row">
<h1 class="package-name">{{ pkg.name }}</h1>
<a-tag color="blue" v-if="pkg.industry">
<template #icon><TagOutlined /></template>
{{ pkg.industry }}
</a-tag>
</div>
<div class="kol-profile">
<a-avatar :src="pkg.kol_avatar_url" :size="48">
<template #icon><UserOutlined /></template>
</a-avatar>
<div class="kol-meta">
<span class="kol-display-name">{{ pkg.kol_display_name }}</span>
<p class="kol-bio" v-if="pkg.kol_bio">{{ pkg.kol_bio }}</p>
</div>
</div>
<div class="tags-row" v-if="pkg.tags?.length">
<a-tag v-for="tag in pkg.tags" :key="tag">{{ tag }}</a-tag>
</div>
<a-divider />
<div class="description-section">
<h3>{{ t("common.description") }}</h3>
<p class="description-text">{{ pkg.description || t("common.noData") }}</p>
</div>
<div class="price-section" v-if="pkg.price_note">
<h3>费用说明</h3>
<p class="price-text">{{ pkg.price_note }}</p>
</div>
</div>
</section>
<section class="prompts-section">
<h3 class="section-title">
<ThunderboltOutlined />
{{ t("kol.marketplace.prompts", { count: pkg.prompts?.length || 0 }) }}
</h3>
<div class="prompts-list">
<div v-for="prompt in pkg.prompts" :key="prompt.id" class="prompt-item">
<div class="prompt-info">
<span class="prompt-name">{{ prompt.name }}</span>
<a-tag v-if="prompt.platform_hint" color="cyan" size="small">{{ prompt.platform_hint }}</a-tag>
</div>
<div class="prompt-action">
<a-tooltip :title="subscription?.status !== 'active' ? '订阅后可用' : ''">
<a-button
type="primary"
size="small"
:disabled="subscription?.status !== 'active'"
@click="handleGenerate(prompt.id)"
>
{{ t("kol.generate.title") }}
</a-button>
</a-tooltip>
</div>
</div>
<div v-if="!pkg.prompts?.length" class="empty-prompts">
<a-empty :description="t('common.noData')" />
</div>
</div>
</section>
</a-col>
<a-col :xs="24" :lg="8">
<a-card class="subscription-card">
<h3 class="card-title">订阅状态</h3>
<div v-if="!subscription && isOwnPackage" class="not-subscribed">
<p class="status-text">这是你自己发布的订阅包不能在模版市场申请订阅</p>
<p class="owner-hint">如需自己使用请回到 KOL 工作台在订阅包菜单中点击订阅自己</p>
</div>
<div v-else-if="!subscription" class="not-subscribed">
<p class="status-text">您尚未订阅该 KOL 提示词包</p>
<a-button
type="primary"
block
size="large"
:loading="subscribeMutation.isPending.value"
@click="handleSubscribe"
>
{{ t("kol.package.subscribe") }}
</a-button>
</div>
<div v-else class="subscription-info">
<div class="status-badge">
<a-tag :color="subscriptionStatusMeta?.color" class="status-tag">
<template #icon><component :is="subscriptionStatusMeta?.icon" /></template>
{{ subscriptionStatusMeta?.label }}
</a-tag>
</div>
<div class="subscription-dates" v-if="subscription.status === 'active'">
<div class="date-item">
<span class="label">到期时间</span>
<span class="value">{{ subscription.end_at || '永不过期' }}</span>
</div>
</div>
<a-button
v-if="!isOwnPackage && (subscription.status === 'expired' || subscription.status === 'revoked')"
type="primary"
block
size="large"
:loading="subscribeMutation.isPending.value"
@click="handleSubscribe"
>
重新订阅
</a-button>
</div>
<a-divider />
<div class="package-stats">
<div class="stat-row">
<span class="stat-label"><TeamOutlined /> 已有订阅</span>
<span class="stat-value">{{ pkg.subscriber_count }}</span>
</div>
<div class="stat-row">
<span class="stat-label"><ThunderboltOutlined /> 包含 Prompt</span>
<span class="stat-value">{{ pkg.prompt_count }}</span>
</div>
</div>
</a-card>
</a-col>
</a-row>
</div>
</div>
</template>
<style scoped>
.kol-package-detail {
display: flex;
flex-direction: column;
gap: 16px;
}
.detail-content {
width: 100%;
max-width: 1360px;
margin: 0 auto;
}
.detail-header {
width: 100%;
max-width: 1360px;
display: flex;
align-items: center;
margin-bottom: 8px;
}
.back-btn {
appearance: none;
border: 0;
background: transparent;
padding: 0;
display: inline-flex;
align-items: center;
gap: 10px;
color: #111827;
cursor: pointer;
}
.back-btn__icon {
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(144, 157, 181, 0.24);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
line-height: 1;
}
.back-btn__text {
font-size: 16px;
line-height: 1;
font-weight: 700;
letter-spacing: 0;
}
.back-btn:hover .back-btn__icon {
border-color: rgba(144, 157, 181, 0.36);
}
.loading-state {
display: flex;
justify-content: center;
padding: 100px;
}
.main-card {
background: #fff;
border: 1px solid #e6edf5;
border-radius: 12px;
overflow: hidden;
}
.package-cover {
width: 100%;
height: clamp(220px, 28vw, 340px);
background-color: #eef2f6;
background-position: center;
background-size: cover;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
isolation: isolate;
}
.package-cover::before {
content: "";
position: absolute;
inset: -24px;
z-index: 0;
background: inherit;
filter: blur(24px);
opacity: 0.58;
transform: scale(1.08);
}
.package-cover::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
background: rgba(248, 250, 252, 0.28);
}
.package-cover img {
position: relative;
z-index: 2;
width: 100%;
height: 100%;
object-fit: contain;
}
.package-info {
padding: 24px;
}
.package-title-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.package-name {
font-size: 24px;
font-weight: 700;
color: #1a1a1a;
margin: 0;
}
.kol-profile {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
.kol-meta {
display: flex;
flex-direction: column;
}
.kol-display-name {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
}
.kol-bio {
font-size: 14px;
color: #8c8c8c;
margin: 4px 0 0;
}
.tags-row {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.description-section h3,
.price-section h3 {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 12px;
}
.description-text,
.price-text {
font-size: 14px;
color: #595959;
line-height: 1.6;
white-space: pre-wrap;
}
.prompts-section {
margin-top: 24px;
background: #fff;
border: 1px solid #e6edf5;
border-radius: 12px;
padding: 24px;
}
.section-title {
font-size: 18px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
}
.prompts-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.prompt-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #f7f9fc;
border-radius: 8px;
border: 1px solid #f0f3fa;
}
.prompt-name {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
margin-right: 12px;
}
.subscription-card {
border-radius: 12px;
border: 1px solid #e6edf5;
position: sticky;
top: 24px;
}
.card-title {
font-size: 16px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 20px;
}
.status-text {
color: #8c8c8c;
margin-bottom: 24px;
}
.owner-hint {
color: #64748b;
font-size: 13px;
line-height: 1.7;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px;
margin: -8px 0 4px;
}
.status-badge {
margin-bottom: 20px;
}
.status-tag {
font-size: 14px;
padding: 6px 12px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.subscription-dates {
margin-bottom: 24px;
}
.date-item {
display: flex;
justify-content: space-between;
font-size: 14px;
}
.date-item .label {
color: #8c8c8c;
}
.date-item .value {
color: #1a1a1a;
font-weight: 500;
}
.package-stats {
display: flex;
flex-direction: column;
gap: 12px;
}
.stat-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.stat-label {
color: #595959;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.stat-value {
font-size: 16px;
font-weight: 700;
color: #1a1a1a;
}
</style>