Files
geo/apps/admin-web/src/views/KolPackageDetailView.vue
T
root ae63f60def style(admin-web): apply blurred-backdrop cover treatment to KOL cards
Switch KOL package, template, and workspace card covers from object-fit
cover to a blurred backdrop with a contained foreground image so non-16:9
artwork no longer crops awkwardly. Also restyle the package detail back
button, swap the generate-button copy for the canonical key, and rename
"prompts" counts to "refined templates" in i18n.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:17:04 +08:00

532 lines
13 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 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) {
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" 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="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;
}
.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>