feat: add creator coupons and portal lint

This commit is contained in:
2026-01-14 09:51:23 +08:00
parent 4f315cc2db
commit fb0a1c2f84
14 changed files with 5103 additions and 1973 deletions

View File

@@ -1,183 +1,15 @@
<template>
<div>
<!-- Cover & Header Info Merged -->
<div class="relative h-[400px] bg-slate-900 overflow-hidden group">
<!-- Background Image -->
<img :src="tenant.cover || 'https://images.unsplash.com/photo-1611454453122-7b02c6b58188?ixlib=rb-1.2.1&auto=format&fit=crop&w=1950&q=80'"
class="w-full h-full object-cover opacity-90 transition-transform duration-700 group-hover:scale-105">
<!-- Gradient Overlay -->
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent"></div>
<!-- Content Overlay -->
<div class="absolute bottom-0 w-full z-10">
<div class="mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8 pb-8 flex flex-col md:flex-row items-end gap-8">
<!-- Avatar -->
<div
class="w-32 h-32 rounded-full border-4 border-white/20 shadow-2xl overflow-hidden flex-shrink-0 backdrop-blur-sm">
<img :src="tenant.avatar || 'https://api.dicebear.com/7.x/avataaars/svg?seed=' + (tenant.name || 'User')" class="w-full h-full object-cover">
</div>
<!-- Info -->
<div class="flex-1 min-w-0 pb-2 text-white">
<div class="flex items-center gap-3 mb-2">
<h1 class="text-4xl font-bold text-white truncate max-w-[600px] drop-shadow-md"
:title="tenant.name">{{ tenant.name }}</h1>
<!-- Cert Badge -->
<i v-if="tenant.certType === 'personal'"
class="pi pi-check-circle text-yellow-400 text-2xl drop-shadow-sm" title="个人认证"></i>
<i v-else-if="tenant.certType === 'enterprise'"
class="pi pi-shield text-blue-400 text-2xl drop-shadow-sm" title="企业认证"></i>
</div>
<p class="text-lg text-slate-200 line-clamp-1 font-medium drop-shadow-sm">{{ tenant.bio }}</p>
</div>
<!-- Actions & Stats -->
<div class="flex flex-col items-end gap-5 pb-2">
<div class="flex gap-3">
<button @click="toggleFollow" :disabled="followLoading"
class="h-11 w-32 rounded-full font-bold text-base transition-all flex items-center justify-center gap-2 backdrop-blur-md"
:class="[
isFollowing ? 'bg-white/10 text-white border border-white/20 hover:bg-white/20' : 'bg-primary-600 text-white hover:bg-primary-700 border border-transparent shadow-lg shadow-primary-900/30',
followLoading ? 'cursor-wait' : ''
]">
<i class="pi" :class="{
'pi-spin pi-spinner': followLoading,
'pi-check': !followLoading && isFollowing,
'pi-plus': !followLoading && !isFollowing
}"></i>
{{ isFollowing ? '已关注' : '关注' }}
</button>
<button @click="toast.add({ severity: 'info', summary: '开发中', detail: '私信功能即将上线', life: 2000 })"
class="h-11 px-6 border border-white/20 text-white rounded-full font-bold hover:bg-white/10 backdrop-blur-md transition-colors">私信</button>
<button @click="toast.add({ severity: 'info', summary: '开发中', detail: '更多功能敬请期待', life: 2000 })"
class="h-11 w-11 border border-white/20 text-white rounded-full flex items-center justify-center hover:bg-white/10 backdrop-blur-md transition-colors"><i
class="pi pi-ellipsis-h"></i></button>
</div>
<div class="flex gap-6 text-sm text-slate-300 font-medium" v-if="tenant.stats">
<div><span class="font-bold text-white text-xl">{{ tenant.stats.followers }}</span> 关注</div>
<div><span class="font-bold text-white text-xl">{{ tenant.stats.contents }}</span> 内容</div>
<div><span class="font-bold text-white text-xl">{{ tenant.stats.likes }}</span> 获赞</div>
</div>
</div>
</div>
</div>
</div>
<!-- Sticky Nav -->
<div class="sticky top-16 z-20 bg-white border-b border-slate-200 shadow-sm">
<div class="mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8 flex items-center justify-between h-14">
<div class="flex gap-8 h-full">
<button v-for="tab in tabs" :key="tab.value" @click="currentTab = tab.value"
class="h-full border-b-2 font-bold text-sm px-1 transition-colors relative top-[1px]"
:class="currentTab === tab.value ? 'border-primary-600 text-primary-600' : 'border-transparent text-slate-500 hover:text-slate-700'">
{{ tab.label }}
</button>
</div>
<!-- In-Tenant Search -->
<div class="relative group">
<i
class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-hover:text-primary-500 transition-colors"></i>
<input type="text" placeholder="搜素频道内容" v-model="searchKeyword" @keyup.enter="handleSearch"
class="h-9 pl-9 pr-4 rounded-full bg-slate-100 border-none text-sm focus:bg-white focus:ring-2 focus:ring-primary-100 transition-all w-48 focus:w-64">
</div>
</div>
</div>
<!-- Content Area -->
<div class="mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8 py-8 min-h-[600px]">
<!-- 1. Home Tab -->
<div v-if="currentTab === 'home'" class="space-y-10">
<!-- Featured (Pinned) -->
<div class="relative h-[400px] rounded-2xl overflow-hidden group cursor-pointer"
v-if="featuredContent"
@click="$router.push(tenantRoute(`/contents/${featuredContent.id}`))">
<img :src="featuredContent.cover" class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105">
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
<div class="absolute top-4 left-4 px-2 py-1 bg-red-600 text-white text-xs font-bold rounded">置顶</div>
<div class="absolute bottom-0 left-0 p-8 text-white w-full">
<div class="flex items-center justify-between">
<div>
<span class="bg-white/10 text-white text-xs px-2 py-1 rounded-full font-bold mb-2 inline-block">{{ featuredContent.genre }}</span>
<h2 class="text-3xl font-bold mb-2">{{ featuredContent.title }}</h2>
<div class="text-sm opacity-80">{{ featuredContent.created_at }}</div>
</div>
<div class="text-2xl font-bold text-amber-400" v-if="featuredContent.price > 0">¥ {{ featuredContent.price.toFixed(2) }}</div>
<div class="text-2xl font-bold text-green-400" v-else>免费</div>
</div>
</div>
</div>
<!-- Latest -->
<div>
<h3 class="text-xl font-bold text-slate-900 mb-6 pl-4 border-l-4 border-primary-600">最新动态</h3>
<div class="grid grid-cols-1 gap-6">
<div v-for="item in contents" :key="item.id"
v-show="!featuredContent || item.id !== featuredContent.id"
class="bg-white rounded-xl border border-slate-100 p-5 flex gap-6 hover:shadow-md transition-shadow group cursor-pointer"
@click="$router.push(tenantRoute(`/contents/${item.id}`))">
<div class="w-64 h-36 bg-slate-100 rounded-lg flex-shrink-0 overflow-hidden relative">
<img :src="item.cover || 'https://via.placeholder.com/300x168?text=No+Cover'" class="w-full h-full object-cover">
</div>
<div class="flex-1 flex flex-col">
<div>
<h3 class="text-lg font-bold text-slate-900 mb-1 group-hover:text-primary-600 transition-colors line-clamp-1">
{{ item.title }}</h3>
<p class="text-xs text-slate-400 mb-2">{{ item.created_at }}</p>
</div>
<div class="mt-auto pt-4 flex items-center justify-between">
<div class="text-xs text-slate-400">
<span><i class="pi pi-eye mr-1"></i> {{ item.views }}</span>
<span class="ml-4"><i class="pi pi-thumbs-up mr-1"></i> {{ item.likes }}</span>
</div>
<div class="text-lg font-bold text-red-600" v-if="item.price > 0">¥ {{ item.price.toFixed(2) }}</div>
<div class="text-lg font-bold text-green-600" v-else>免费</div>
</div>
</div>
</div>
</div>
<div class="text-center mt-10" v-if="hasMore">
<button @click="loadMore" :disabled="loading" class="h-11 px-8 bg-slate-100 text-slate-600 rounded-full font-bold hover:bg-slate-200 transition-colors disabled:cursor-wait disabled:opacity-70">
<span v-if="loading">加载中...</span>
<span v-else>加载更多</span>
</button>
</div>
</div>
</div>
<!-- 4. About Tab -->
<div v-if="currentTab === 'about'" class="max-w-3xl mx-auto">
<div class="prose prose-slate prose-lg text-slate-700 whitespace-pre-wrap">
<h2 class="font-bold text-2xl mb-6 pb-4 border-b border-slate-100">关于我们</h2>
<p>{{ tenant.description || '暂无详细介绍' }}</p>
</div>
</div>
</div>
<!-- Floating Share FAB -->
<div class="fixed bottom-8 right-8 z-50">
<button @click="toast.add({ severity: 'info', summary: '开发中', detail: '分享功能即将上线', life: 2000 })"
class="w-14 h-14 bg-slate-900 text-white rounded-full shadow-xl flex items-center justify-center hover:scale-110 transition-transform"
title="分享频道">
<i class="pi pi-share-alt text-xl"></i>
</button>
</div>
</div>
</template>
<script setup>
import { useToast } from 'primevue/usetoast';
import { reactive, ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { tenantApi } from '../../api/tenant';
import { contentApi } from '../../api/content';
import { tenantPath } from '../../utils/tenant';
import { useToast } from "primevue/usetoast";
import { ref, onMounted } from "vue";
import { useRoute } from "vue-router";
import { tenantApi } from "../../api/tenant";
import { contentApi } from "../../api/content";
import { tenantPath } from "../../utils/tenant";
const route = useRoute();
const tenantRoute = (path) => tenantPath(path, route);
const toast = useToast();
const currentTab = ref('home');
const currentTab = ref("home");
const isFollowing = ref(false);
const tenant = ref({});
const contents = ref([]);
@@ -186,100 +18,446 @@ const featuredContent = ref(null);
// New States
const loading = ref(true);
const followLoading = ref(false);
const searchKeyword = ref('');
const searchKeyword = ref("");
const page = ref(1);
const hasMore = ref(false);
const limit = 10;
const fetchData = async (isLoadMore = false) => {
if (!isLoadMore) loading.value = true;
try {
const id = route.params.id;
const query = {
tenant_id: id,
sort: 'latest',
page: page.value,
limit: limit,
keyword: searchKeyword.value
};
if (!isLoadMore) loading.value = true;
try {
const id = route.params.id;
const query = {
tenant_id: id,
sort: "latest",
page: page.value,
limit: limit,
keyword: searchKeyword.value,
};
const reqs = [
contentApi.list(query)
];
// Only fetch tenant info & featured on first load
if (!isLoadMore && page.value === 1) {
reqs.push(tenantApi.get(id));
reqs.push(contentApi.list({ tenant_id: id, is_pinned: true }));
}
const reqs = [contentApi.list(query)];
const results = await Promise.all(reqs);
const c = results[0]; // Content List
if (!isLoadMore && page.value === 1) {
const t = results[1];
const f = results[2];
tenant.value = t || {};
isFollowing.value = t?.is_following || false;
if (f && f.items && f.items.length > 0) {
featuredContent.value = f.items[0];
} else {
featuredContent.value = null;
}
contents.value = c?.items || [];
} else {
// Append mode
if (c?.items) {
contents.value.push(...c.items);
}
}
// Check if more
hasMore.value = (c?.total > contents.value.length);
} catch (e) {
console.error(e);
toast.add({ severity: 'error', summary: '加载失败', detail: '请稍后重试', life: 3000 });
} finally {
loading.value = false;
// Only fetch tenant info & featured on first load
if (!isLoadMore && page.value === 1) {
reqs.push(tenantApi.get(id));
reqs.push(contentApi.list({ tenant_id: id, is_pinned: true }));
}
const results = await Promise.all(reqs);
const c = results[0]; // Content List
if (!isLoadMore && page.value === 1) {
const t = results[1];
const f = results[2];
tenant.value = t || {};
isFollowing.value = t?.is_following || false;
if (f && f.items && f.items.length > 0) {
featuredContent.value = f.items[0];
} else {
featuredContent.value = null;
}
contents.value = c?.items || [];
} else {
// Append mode
if (c?.items) {
contents.value.push(...c.items);
}
}
// Check if more
hasMore.value = c?.total > contents.value.length;
} catch (e) {
console.error(e);
toast.add({
severity: "error",
summary: "加载失败",
detail: "请稍后重试",
life: 3000,
});
} finally {
loading.value = false;
}
};
onMounted(() => fetchData());
const handleSearch = () => {
page.value = 1;
fetchData();
page.value = 1;
fetchData();
};
const loadMore = () => {
page.value++;
fetchData(true);
page.value++;
fetchData(true);
};
const toggleFollow = async () => {
if (followLoading.value) return;
followLoading.value = true;
try {
if (isFollowing.value) {
await tenantApi.unfollow(route.params.id);
isFollowing.value = false;
} else {
await tenantApi.follow(route.params.id);
isFollowing.value = true;
toast.add({ severity: 'success', summary: '关注成功', detail: '已开启更新提醒', life: 3000 });
}
} catch(e) {
console.error(e);
toast.add({ severity: 'error', summary: '操作失败', detail: e.message, life: 3000 });
} finally {
followLoading.value = false;
}
if (followLoading.value) return;
followLoading.value = true;
try {
if (isFollowing.value) {
await tenantApi.unfollow(route.params.id);
isFollowing.value = false;
} else {
await tenantApi.follow(route.params.id);
isFollowing.value = true;
toast.add({
severity: "success",
summary: "关注成功",
detail: "已开启更新提醒",
life: 3000,
});
}
} catch (e) {
console.error(e);
toast.add({
severity: "error",
summary: "操作失败",
detail: e.message,
life: 3000,
});
} finally {
followLoading.value = false;
}
};
const tabs = [
{ label: '主页', value: 'home' },
{ label: '关于', value: 'about' }
{ label: "主页", value: "home" },
{ label: "关于", value: "about" },
];
</script>
<template>
<div>
<!-- Cover & Header Info Merged -->
<div class="relative h-[400px] bg-slate-900 overflow-hidden group">
<!-- Background Image -->
<img
:src="
tenant.cover ||
'https://images.unsplash.com/photo-1611454453122-7b02c6b58188?ixlib=rb-1.2.1&auto=format&fit=crop&w=1950&q=80'
"
class="w-full h-full object-cover opacity-90 transition-transform duration-700 group-hover:scale-105"
/>
<!-- Gradient Overlay -->
<div
class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent"
></div>
<!-- Content Overlay -->
<div class="absolute bottom-0 w-full z-10">
<div
class="mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8 pb-8 flex flex-col md:flex-row items-end gap-8"
>
<!-- Avatar -->
<div
class="w-32 h-32 rounded-full border-4 border-white/20 shadow-2xl overflow-hidden flex-shrink-0 backdrop-blur-sm"
>
<img
:src="
tenant.avatar ||
'https://api.dicebear.com/7.x/avataaars/svg?seed=' +
(tenant.name || 'User')
"
class="w-full h-full object-cover"
/>
</div>
<!-- Info -->
<div class="flex-1 min-w-0 pb-2 text-white">
<div class="flex items-center gap-3 mb-2">
<h1
class="text-4xl font-bold text-white truncate max-w-[600px] drop-shadow-md"
:title="tenant.name"
>
{{ tenant.name }}
</h1>
<!-- Cert Badge -->
<i
v-if="tenant.certType === 'personal'"
class="pi pi-check-circle text-yellow-400 text-2xl drop-shadow-sm"
title="个人认证"
></i>
<i
v-else-if="tenant.certType === 'enterprise'"
class="pi pi-shield text-blue-400 text-2xl drop-shadow-sm"
title="企业认证"
></i>
</div>
<p
class="text-lg text-slate-200 line-clamp-1 font-medium drop-shadow-sm"
>
{{ tenant.bio }}
</p>
</div>
<!-- Actions & Stats -->
<div class="flex flex-col items-end gap-5 pb-2">
<div class="flex gap-3">
<button
@click="toggleFollow"
:disabled="followLoading"
class="h-11 w-32 rounded-full font-bold text-base transition-all flex items-center justify-center gap-2 backdrop-blur-md"
:class="[
isFollowing
? 'bg-white/10 text-white border border-white/20 hover:bg-white/20'
: 'bg-primary-600 text-white hover:bg-primary-700 border border-transparent shadow-lg shadow-primary-900/30',
followLoading ? 'cursor-wait' : '',
]"
>
<i
class="pi"
:class="{
'pi-spin pi-spinner': followLoading,
'pi-check': !followLoading && isFollowing,
'pi-plus': !followLoading && !isFollowing,
}"
></i>
{{ isFollowing ? "已关注" : "关注" }}
</button>
<button
@click="
toast.add({
severity: 'info',
summary: '开发中',
detail: '私信功能即将上线',
life: 2000,
})
"
class="h-11 px-6 border border-white/20 text-white rounded-full font-bold hover:bg-white/10 backdrop-blur-md transition-colors"
>
私信
</button>
<button
@click="
toast.add({
severity: 'info',
summary: '开发中',
detail: '更多功能敬请期待',
life: 2000,
})
"
class="h-11 w-11 border border-white/20 text-white rounded-full flex items-center justify-center hover:bg-white/10 backdrop-blur-md transition-colors"
>
<i class="pi pi-ellipsis-h"></i>
</button>
</div>
<div
class="flex gap-6 text-sm text-slate-300 font-medium"
v-if="tenant.stats"
>
<div>
<span class="font-bold text-white text-xl">{{
tenant.stats.followers
}}</span>
关注
</div>
<div>
<span class="font-bold text-white text-xl">{{
tenant.stats.contents
}}</span>
内容
</div>
<div>
<span class="font-bold text-white text-xl">{{
tenant.stats.likes
}}</span>
获赞
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Sticky Nav -->
<div
class="sticky top-16 z-20 bg-white border-b border-slate-200 shadow-sm"
>
<div
class="mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8 flex items-center justify-between h-14"
>
<div class="flex gap-8 h-full">
<button
v-for="tab in tabs"
:key="tab.value"
@click="currentTab = tab.value"
class="h-full border-b-2 font-bold text-sm px-1 transition-colors relative top-[1px]"
:class="
currentTab === tab.value
? 'border-primary-600 text-primary-600'
: 'border-transparent text-slate-500 hover:text-slate-700'
"
>
{{ tab.label }}
</button>
</div>
<!-- In-Tenant Search -->
<div class="relative group">
<i
class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-hover:text-primary-500 transition-colors"
></i>
<input
type="text"
placeholder="搜素频道内容"
v-model="searchKeyword"
@keyup.enter="handleSearch"
class="h-9 pl-9 pr-4 rounded-full bg-slate-100 border-none text-sm focus:bg-white focus:ring-2 focus:ring-primary-100 transition-all w-48 focus:w-64"
/>
</div>
</div>
</div>
<!-- Content Area -->
<div
class="mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8 py-8 min-h-[600px]"
>
<!-- 1. Home Tab -->
<div v-if="currentTab === 'home'" class="space-y-10">
<!-- Featured (Pinned) -->
<div
class="relative h-[400px] rounded-2xl overflow-hidden group cursor-pointer"
v-if="featuredContent"
@click="$router.push(tenantRoute(`/contents/${featuredContent.id}`))"
>
<img
:src="featuredContent.cover"
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
<div
class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"
></div>
<div
class="absolute top-4 left-4 px-2 py-1 bg-red-600 text-white text-xs font-bold rounded"
>
置顶
</div>
<div class="absolute bottom-0 left-0 p-8 text-white w-full">
<div class="flex items-center justify-between">
<div>
<span
class="bg-white/10 text-white text-xs px-2 py-1 rounded-full font-bold mb-2 inline-block"
>{{ featuredContent.genre }}</span
>
<h2 class="text-3xl font-bold mb-2">
{{ featuredContent.title }}
</h2>
<div class="text-sm opacity-80">
{{ featuredContent.created_at }}
</div>
</div>
<div
class="text-2xl font-bold text-amber-400"
v-if="featuredContent.price > 0"
>
¥ {{ featuredContent.price.toFixed(2) }}
</div>
<div class="text-2xl font-bold text-green-400" v-else>免费</div>
</div>
</div>
</div>
<!-- Latest -->
<div>
<h3
class="text-xl font-bold text-slate-900 mb-6 pl-4 border-l-4 border-primary-600"
>
最新动态
</h3>
<div class="grid grid-cols-1 gap-6">
<div
v-for="item in contents"
:key="item.id"
v-show="!featuredContent || item.id !== featuredContent.id"
class="bg-white rounded-xl border border-slate-100 p-5 flex gap-6 hover:shadow-md transition-shadow group cursor-pointer"
@click="$router.push(tenantRoute(`/contents/${item.id}`))"
>
<div
class="w-64 h-36 bg-slate-100 rounded-lg flex-shrink-0 overflow-hidden relative"
>
<img
:src="
item.cover ||
'https://via.placeholder.com/300x168?text=No+Cover'
"
class="w-full h-full object-cover"
/>
</div>
<div class="flex-1 flex flex-col">
<div>
<h3
class="text-lg font-bold text-slate-900 mb-1 group-hover:text-primary-600 transition-colors line-clamp-1"
>
{{ item.title }}
</h3>
<p class="text-xs text-slate-400 mb-2">
{{ item.created_at }}
</p>
</div>
<div class="mt-auto pt-4 flex items-center justify-between">
<div class="text-xs text-slate-400">
<span><i class="pi pi-eye mr-1"></i> {{ item.views }}</span>
<span class="ml-4"
><i class="pi pi-thumbs-up mr-1"></i>
{{ item.likes }}</span
>
</div>
<div
class="text-lg font-bold text-red-600"
v-if="item.price > 0"
>
¥ {{ item.price.toFixed(2) }}
</div>
<div class="text-lg font-bold text-green-600" v-else>
免费
</div>
</div>
</div>
</div>
</div>
<div class="text-center mt-10" v-if="hasMore">
<button
@click="loadMore"
:disabled="loading"
class="h-11 px-8 bg-slate-100 text-slate-600 rounded-full font-bold hover:bg-slate-200 transition-colors disabled:cursor-wait disabled:opacity-70"
>
<span v-if="loading">加载中...</span>
<span v-else>加载更多</span>
</button>
</div>
</div>
</div>
<!-- 4. About Tab -->
<div v-if="currentTab === 'about'" class="max-w-3xl mx-auto">
<div
class="prose prose-slate prose-lg text-slate-700 whitespace-pre-wrap"
>
<h2 class="font-bold text-2xl mb-6 pb-4 border-b border-slate-100">
关于我们
</h2>
<p>{{ tenant.description || "暂无详细介绍" }}</p>
</div>
</div>
</div>
<!-- Floating Share FAB -->
<div class="fixed bottom-8 right-8 z-50">
<button
@click="
toast.add({
severity: 'info',
summary: '开发中',
detail: '分享功能即将上线',
life: 2000,
})
"
class="w-14 h-14 bg-slate-900 text-white rounded-full shadow-xl flex items-center justify-center hover:scale-110 transition-transform"
title="分享频道"
>
<i class="pi pi-share-alt text-xl"></i>
</button>
</div>
</div>
</template>