feat(auth): implement OTP login flow with toast notifications

feat(content): enhance detail view with dynamic content and comments
feat(order): add polling for payment status in the payment view
feat(user): update dashboard to display wallet and recent orders
feat(user): improve orders view with dynamic order fetching and status mapping
feat(api): create API modules for auth, content, order, user, and common functionalities
refactor(request): implement a centralized request utility for API calls
This commit is contained in:
2025-12-30 21:15:13 +08:00
parent 179b6aa0e2
commit cf29a2bf1a
14 changed files with 400 additions and 310 deletions

View File

@@ -0,0 +1,6 @@
import { request } from '../utils/request';
export const authApi = {
sendOTP: (phone) => request('/auth/otp', { method: 'POST', body: { phone } }),
login: (phone, otp) => request('/auth/login', { method: 'POST', body: { phone, otp } }),
};

View File

@@ -0,0 +1,10 @@
import { request } from '../utils/request';
export const commonApi = {
upload: (file, type) => {
const formData = new FormData();
formData.append('file', file);
formData.append('type', type);
return request('/upload', { method: 'POST', body: formData });
},
};

View File

@@ -0,0 +1,13 @@
import { request } from '../utils/request';
export const contentApi = {
list: (params) => {
const qs = new URLSearchParams(params).toString();
return request(`/contents?${qs}`);
},
get: (id) => request(`/contents/${id}`),
listComments: (id, page) => request(`/contents/${id}/comments?page=${page || 1}`),
createComment: (id, data) => request(`/contents/${id}/comments`, { method: 'POST', body: data }),
likeComment: (id) => request(`/comments/${id}/like`, { method: 'POST' }),
listTopics: () => request('/topics'),
};

View File

@@ -0,0 +1,24 @@
import { request } from '../utils/request';
export const creatorApi = {
apply: (data) => request('/creator/apply', { method: 'POST', body: data }),
getDashboard: () => request('/creator/dashboard'),
listContents: (params) => {
const qs = new URLSearchParams(params).toString();
return request(`/creator/contents?${qs}`);
},
createContent: (data) => request('/creator/contents', { method: 'POST', body: data }),
updateContent: (id, data) => request(`/creator/contents/${id}`, { method: 'PUT', body: data }),
deleteContent: (id) => request(`/creator/contents/${id}`, { method: 'DELETE' }),
listOrders: (params) => {
const qs = new URLSearchParams(params).toString();
return request(`/creator/orders?${qs}`);
},
refundOrder: (id, data) => request(`/creator/orders/${id}/refund`, { method: 'POST', body: data }),
getSettings: () => request('/creator/settings'),
updateSettings: (data) => request('/creator/settings', { method: 'PUT', body: data }),
listPayoutAccounts: () => request('/creator/payout-accounts'),
addPayoutAccount: (data) => request('/creator/payout-accounts', { method: 'POST', body: data }),
removePayoutAccount: (id) => request(`/creator/payout-accounts?id=${id}`, { method: 'DELETE' }),
withdraw: (data) => request('/creator/withdraw', { method: 'POST', body: data }),
};

View File

@@ -0,0 +1,7 @@
import { request } from '../utils/request';
export const orderApi = {
create: (data) => request('/orders', { method: 'POST', body: data }),
pay: (id, data) => request(`/orders/${id}/pay`, { method: 'POST', body: data }),
status: (id) => request(`/orders/${id}/status`),
};

View File

@@ -0,0 +1,21 @@
import { request } from '../utils/request';
export const userApi = {
getMe: () => request('/me'),
updateMe: (data) => request('/me', { method: 'PUT', body: data }),
realName: (data) => request('/me/realname', { method: 'POST', body: data }),
getWallet: () => request('/me/wallet'),
recharge: (data) => request('/me/wallet/recharge', { method: 'POST', body: data }),
getOrders: (status) => request(`/me/orders?status=${status || 'all'}`),
getOrder: (id) => request(`/me/orders/${id}`),
getLibrary: () => request('/me/library'),
getFavorites: () => request('/me/favorites'),
addFavorite: (contentId) => request(`/me/favorites?content_id=${contentId}`, { method: 'POST' }),
removeFavorite: (contentId) => request(`/me/favorites/${contentId}`, { method: 'DELETE' }),
getLikes: () => request('/me/likes'),
addLike: (contentId) => request(`/me/likes?content_id=${contentId}`, { method: 'POST' }),
removeLike: (contentId) => request(`/me/likes/${contentId}`, { method: 'DELETE' }),
getNotifications: (type, page) => request(`/me/notifications?type=${type || 'all'}&page=${page || 1}`),
getFollowing: () => request('/me/following'),
getCoupons: (status) => request(`/me/coupons?status=${status || 'unused'}`),
};

View File

@@ -0,0 +1,50 @@
// Simple Fetch Wrapper
const BASE_URL = '/v1';
export async function request(endpoint, options = {}) {
const token = localStorage.getItem('token');
const headers = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (options.body && !(options.body instanceof FormData)) {
options.body = JSON.stringify(options.body);
}
if (options.body instanceof FormData) {
delete headers['Content-Type']; // Let browser set boundary
}
try {
const res = await fetch(`${BASE_URL}${endpoint}`, {
...options,
headers
});
const contentType = res.headers.get("content-type");
let data;
if (contentType && contentType.indexOf("application/json") !== -1) {
data = await res.json();
} else {
data = await res.text();
}
if (!res.ok) {
// Handle errorx response { code, message, error_id }
const errorMsg = data.message || `Request failed with status ${res.status}`;
throw new Error(errorMsg);
}
return data;
} catch (err) {
console.error('API Request Error:', err);
throw err;
}
}

View File

@@ -45,37 +45,37 @@
</div>
<div class="relative w-full sm:w-64">
<i class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"></i>
<input type="text" placeholder="在结果中搜索..." class="w-full h-9 pl-9 pr-4 rounded-lg bg-slate-50 border border-slate-200 text-sm focus:bg-white focus:border-primary-500 outline-none transition-all">
<input v-model="keyword" @keyup.enter="fetchContents" type="text" placeholder="在结果中搜索..." class="w-full h-9 pl-9 pr-4 rounded-lg bg-slate-50 border border-slate-200 text-sm focus:bg-white focus:border-primary-500 outline-none transition-all">
</div>
</div>
</div>
<!-- Content Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<div v-for="i in 12" :key="i" class="bg-white rounded-xl border border-slate-100 overflow-hidden hover:shadow-lg transition-all group cursor-pointer active:scale-[0.99]" @click="$router.push(`/contents/${i}`)">
<div v-for="item in contents" :key="item.id" class="bg-white rounded-xl border border-slate-100 overflow-hidden hover:shadow-lg transition-all group cursor-pointer active:scale-[0.99]" @click="$router.push(`/contents/${item.id}`)">
<!-- Cover -->
<div class="aspect-video bg-slate-100 relative">
<img :src="`https://images.unsplash.com/photo-1514306191717-452ec28c7f31?ixlib=rb-1.2.1&auto=format&fit=crop&w=400&q=60`" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105">
<img :src="item.cover || `https://images.unsplash.com/photo-1514306191717-452ec28c7f31?ixlib=rb-1.2.1&auto=format&fit=crop&w=400&q=60`" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105">
<div class="absolute bottom-2 left-2 px-1.5 py-0.5 bg-black/60 text-white text-xs rounded flex items-center gap-1">
<i class="pi pi-play-circle"></i> 12:40
<i class="pi pi-play-circle"></i> {{ item.duration || '00:00' }}
</div>
<span v-if="i % 3 === 0" class="absolute top-2 right-2 px-1.5 py-0.5 bg-green-500 text-white text-xs font-bold rounded"></span>
<span v-if="item.price === 0" class="absolute top-2 right-2 px-1.5 py-0.5 bg-green-500 text-white text-xs font-bold rounded"></span>
</div>
<!-- Info -->
<div class="p-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs text-slate-500 border border-slate-200 px-1 rounded">[京剧]</span>
<h3 class="font-bold text-slate-900 line-clamp-1 group-hover:text-primary-600 transition-colors">锁麟囊选段深度解析</h3>
<span class="text-xs text-slate-500 border border-slate-200 px-1 rounded">[{{ item.genre || '未知' }}]</span>
<h3 class="font-bold text-slate-900 line-clamp-1 group-hover:text-primary-600 transition-colors">{{ item.title }}</h3>
</div>
<div class="flex items-center gap-2 text-xs text-slate-500 mb-3">
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=Master1" class="w-5 h-5 rounded-full">
<span>梅派传人小林</span>
<img :src="item.author_avatar || 'https://api.dicebear.com/7.x/avataaars/svg?seed=' + item.author_id" class="w-5 h-5 rounded-full">
<span>{{ item.author_name || 'Unknown' }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-slate-400">1.2w 阅读</span>
<span v-if="i % 3 === 0" class="text-sm font-bold text-green-600">免费</span>
<span v-else class="text-sm font-bold text-red-600">¥ 9.90</span>
<span class="text-xs text-slate-400">{{ item.views }} 阅读</span>
<span v-if="item.price === 0" class="text-sm font-bold text-green-600">免费</span>
<span v-else class="text-sm font-bold text-red-600">¥ {{ item.price }}</span>
</div>
</div>
</div>
@@ -83,7 +83,7 @@
<!-- Pagination -->
<div class="mt-12 flex justify-center">
<button class="px-8 py-3 bg-white border border-slate-200 rounded-full text-slate-600 hover:bg-slate-50 hover:text-primary-600 font-medium transition-all shadow-sm cursor-pointer active:scale-95">
<button @click="loadMore" class="px-8 py-3 bg-white border border-slate-200 rounded-full text-slate-600 hover:bg-slate-50 hover:text-primary-600 font-medium transition-all shadow-sm cursor-pointer active:scale-95">
加载更多
</button>
</div>
@@ -91,11 +91,50 @@
</template>
<script setup>
import { ref } from 'vue';
import { ref, watch, onMounted } from 'vue';
import { contentApi } from '../api/content';
const selectedGenre = ref('全部');
const selectedPrice = ref('all');
const sort = ref('latest');
const keyword = ref('');
const contents = ref([]);
const page = ref(1);
const fetchContents = async (append = false) => {
const params = {
page: page.value,
limit: 12,
sort: sort.value
};
if (selectedGenre.value !== '全部') params.genre = selectedGenre.value;
if (keyword.value) params.keyword = keyword.value;
try {
const res = await contentApi.list(params);
if (append) {
contents.value = [...contents.value, ...(res.items || [])];
} else {
contents.value = res.items || [];
}
} catch (e) {
console.error(e);
}
};
const loadMore = () => {
page.value++;
fetchContents(true);
};
watch([selectedGenre, sort], () => {
page.value = 1;
fetchContents();
});
onMounted(() => {
fetchContents();
});
const filters = {
genres: ['全部', '京剧', '昆曲', '越剧', '黄梅戏', '豫剧', '评剧', '秦腔', '河北梆子'],

View File

@@ -59,91 +59,40 @@
<!-- Main Feed (Left 9) -->
<div class="col-span-12 lg:col-span-8 xl:col-span-9 space-y-6">
<!-- Card Variant 1: Single Image (Right) -->
<router-link to="/contents/1"
<router-link v-for="item in contents" :key="item.id" :to="`/contents/${item.id}`"
class="block bg-white rounded-2xl shadow-sm border border-slate-100 p-6 hover:shadow-xl hover:border-primary-100 transition-all duration-300 group cursor-pointer active:scale-[0.99]">
<div class="flex gap-8">
<div class="flex-1 min-w-0 flex flex-col">
<div class="flex items-center gap-3 mb-3">
<span class="px-2 py-0.5 rounded text-xs font-bold bg-red-600 text-white">置顶</span>
<span class="text-xs font-bold text-red-700 bg-red-50 border border-red-100 px-2 py-0.5 rounded-full">京剧</span>
<span v-if="item.price === 0" class="px-2 py-0.5 rounded text-xs font-bold bg-green-500 text-white">免费</span>
<span v-else class="px-2 py-0.5 rounded text-xs font-bold bg-red-600 text-white">付费</span>
<span class="text-xs font-bold text-slate-700 bg-slate-50 border border-slate-100 px-2 py-0.5 rounded-full">{{ item.genre }}</span>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-3 leading-snug group-hover:text-primary-600 transition-colors">锁麟囊选段春秋亭外风雨暴 (张火丁亲授版)</h3>
<p class="text-base text-slate-500 line-clamp-2 mb-6 leading-relaxed">张火丁教授亲自讲解程派发音技巧深度剖析锁麟囊中春秋亭一折的唱腔设计与情感表达</p>
<h3 class="text-xl font-bold text-slate-900 mb-3 leading-snug group-hover:text-primary-600 transition-colors">{{ item.title }}</h3>
<p class="text-base text-slate-500 line-clamp-2 mb-6 leading-relaxed">{{ item.description || item.title }}</p>
<div class="mt-auto flex items-center justify-between">
<div class="flex items-center gap-3 text-sm text-slate-500">
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=Zhang" class="w-7 h-7 rounded-full ring-2 ring-white">
<span class="font-medium text-slate-700">张火丁工作室</span>
<img :src="item.author_avatar || 'https://api.dicebear.com/7.x/avataaars/svg?seed=' + item.author_id" class="w-7 h-7 rounded-full ring-2 ring-white">
<span class="font-medium text-slate-700">{{ item.author_name || 'Unknown' }}</span>
<span class="text-slate-300">|</span>
<span>青衣</span>
<span>{{ item.create_time || '刚刚' }}</span>
</div>
<div class="flex items-center gap-4">
<span class="text-sm text-slate-400 font-medium"><i class="pi pi-eye mr-1"></i> 1.2</span>
<span class="text-xl font-bold text-red-600">¥ 9.90</span>
<span class="text-sm text-slate-400 font-medium"><i class="pi pi-eye mr-1"></i> {{ item.views }}</span>
<span class="text-xl font-bold text-red-600">¥ {{ item.price }}</span>
</div>
</div>
</div>
<div class="w-[280px] h-[157px] flex-shrink-0 rounded-xl overflow-hidden relative bg-slate-100 hidden sm:block shadow-inner">
<img src="https://images.unsplash.com/photo-1576014131795-d44019d02374?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60" class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110">
<div v-if="item.cover" class="w-[280px] h-[157px] flex-shrink-0 rounded-xl overflow-hidden relative bg-slate-100 hidden sm:block shadow-inner">
<img :src="item.cover" class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110">
<div class="absolute inset-0 bg-black/10 group-hover:bg-black/0 transition-colors"></div>
<div class="absolute inset-0 flex items-center justify-center">
<i class="pi pi-play-circle text-5xl text-white opacity-0 group-hover:opacity-100 transition-all scale-75 group-hover:scale-100 drop-shadow-lg"></i>
</div>
<span class="absolute bottom-3 right-3 px-2 py-1 bg-black/70 backdrop-blur-sm text-white text-xs font-bold rounded-lg">15:30</span>
</div>
</div>
</router-link>
<!-- Card Variant 2: No Image (Text Only) -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 hover:shadow-xl hover:border-primary-100 transition-all duration-300 cursor-pointer active:scale-[0.99] group">
<div class="flex items-center gap-3 mb-3">
<span class="px-2 py-0.5 rounded text-xs font-bold bg-green-500 text-white">限免</span>
<span class="text-xs font-bold text-teal-700 bg-teal-50 border border-teal-100 px-2 py-0.5 rounded-full">昆曲</span>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-3 group-hover:text-primary-600 transition-colors">浅谈昆曲牡丹亭中的水磨腔艺术特点</h3>
<p class="text-base text-slate-500 line-clamp-3 mb-6 leading-relaxed">昆曲之所以被称为百戏之祖其细腻婉转的水磨腔功不可没本文将从发音吐字行腔三个维度带您领略昆曲的声韵之美...</p>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3 text-sm text-slate-500">
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=Li" class="w-7 h-7 rounded-full ring-2 ring-white">
<span class="font-medium text-slate-700">梨园小生</span>
<span class="text-slate-300">|</span>
<span>昨天</span>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-slate-400 line-through">¥ 5.00</span>
<span class="px-3 py-1 bg-green-50 text-green-600 text-sm font-bold rounded-lg border border-green-100">限时免费</span>
</div>
</div>
</div>
<!-- Card Variant 3: 3 Images -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6 hover:shadow-xl hover:border-primary-100 transition-all duration-300 cursor-pointer active:scale-[0.99] group">
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-bold text-purple-700 bg-purple-50 border border-purple-100 px-2 py-0.5 rounded-full">图集</span>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-4 group-hover:text-primary-600 transition-colors">2024 新年京剧晚会后台探班名角云集</h3>
<div class="grid grid-cols-3 gap-3 mb-6">
<div class="aspect-[4/3] rounded-xl overflow-hidden bg-slate-100 shadow-inner">
<img src="https://images.unsplash.com/photo-1469571486292-0ba58a3f068b?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60" class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110">
</div>
<div class="aspect-[4/3] rounded-xl overflow-hidden bg-slate-100 shadow-inner">
<img src="https://images.unsplash.com/photo-1533174072545-e8d4aa97edf9?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60" class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110">
</div>
<div class="aspect-[4/3] rounded-xl overflow-hidden bg-slate-100 relative shadow-inner">
<img src="https://images.unsplash.com/photo-1516450360452-9312f5e86fc7?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60" class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110">
<div class="absolute inset-0 bg-black/40 flex items-center justify-center text-white font-bold text-lg">+9</div>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3 text-sm text-slate-500">
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=Photo" class="w-7 h-7 rounded-full ring-2 ring-white">
<span class="font-medium text-slate-700">戏曲摄影师老王</span>
<span class="text-slate-300">|</span>
<span>3天前</span>
</div>
<span class="text-sm text-slate-400 font-medium"><i class="pi pi-eye mr-1"></i> 8.5k</span>
</div>
</div>
<!-- Load More -->
<div class="pt-4 text-center">
<button

View File

@@ -1,3 +1,43 @@
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import Toast from 'primevue/toast';
import { authApi } from '../../api/auth';
const router = useRouter();
const toast = useToast();
const step = ref(1);
const phone = ref('');
const otpCode = ref('');
const agreed = ref(false);
const getOTP = async () => {
if(!agreed.value) return;
try {
await authApi.sendOTP(phone.value);
step.value = 2;
toast.add({ severity: 'success', summary: '发送成功', detail: '验证码已发送', life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: '错误', detail: e.message, life: 3000 });
}
};
const login = async () => {
try {
const res = await authApi.login(phone.value, otpCode.value);
localStorage.setItem('token', res.token);
localStorage.setItem('user', JSON.stringify(res.user));
toast.add({ severity: 'success', summary: '登录成功', detail: '欢迎回来', life: 1000 });
setTimeout(() => {
router.push('/');
}, 1000);
} catch (e) {
toast.add({ severity: 'error', summary: '登录失败', detail: e.message, life: 3000 });
}
};
</script>
<template>
<div class="bg-white rounded-2xl shadow-xl w-full max-w-4xl overflow-hidden flex min-h-[550px]">
<!-- Left Brand Area -->
@@ -97,33 +137,6 @@
</button>
</div>
</div>
<Toast />
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const step = ref(1);
const phone = ref('');
const otpCode = ref('');
const agreed = ref(false);
const getOTP = () => {
if(!agreed.value) return;
// Simulate API call
setTimeout(() => {
step.value = 2;
}, 500);
};
const login = () => {
// Simulate Login
if (otpCode.value.length >= 4) {
setTimeout(() => {
router.push('/');
}, 800);
}
};
</script>

View File

@@ -9,25 +9,25 @@
<div class="mb-8 border-b border-slate-100 pb-8">
<div class="flex items-start justify-between gap-4 mb-4">
<h1 class="text-3xl md:text-4xl font-bold text-slate-900 leading-tight">
<span class="text-primary-600 mr-2 text-2xl align-middle">[京剧]</span>
锁麟囊春秋亭 · 二六
<span class="text-primary-600 mr-2 text-2xl align-middle">[{{ content.genre || '未分类' }}]</span>
{{ content.title }}
</h1>
</div>
<!-- Opera Meta -->
<div class="flex flex-wrap gap-3 mb-6">
<span class="px-3 py-1 bg-slate-100 text-slate-600 rounded-full text-sm font-bold">行当: 青衣</span>
<span class="px-3 py-1 bg-slate-100 text-slate-600 rounded-full text-sm font-bold">定调: F大调</span>
<span class="px-3 py-1 bg-slate-100 text-slate-600 rounded-full text-sm font-bold">板式: 二六</span>
<div class="flex flex-wrap gap-3 mb-6" v-if="content.meta">
<span v-if="content.meta.role" class="px-3 py-1 bg-slate-100 text-slate-600 rounded-full text-sm font-bold">行当: {{ content.meta.role }}</span>
<span v-if="content.meta.key" class="px-3 py-1 bg-slate-100 text-slate-600 rounded-full text-sm font-bold">定调: {{ content.meta.key }}</span>
<span v-if="content.meta.beat" class="px-3 py-1 bg-slate-100 text-slate-600 rounded-full text-sm font-bold">板式: {{ content.meta.beat }}</span>
</div>
<!-- Author & Tools -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=Master1" class="w-10 h-10 rounded-full border border-slate-200">
<img :src="content.author_avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${content.author_id}`" class="w-10 h-10 rounded-full border border-slate-200">
<div>
<div class="font-bold text-slate-900 text-sm">梅派传人小林 <i class="pi pi-check-circle text-blue-500 text-xs"></i></div>
<div class="text-xs text-slate-400">发布于 2025-12-24</div>
<div class="font-bold text-slate-900 text-sm">{{ content.author_name }} <i class="pi pi-check-circle text-blue-500 text-xs"></i></div>
<div class="text-xs text-slate-400">发布于 {{ content.create_time }}</div>
</div>
</div>
<div class="flex items-center gap-2">
@@ -43,7 +43,7 @@
<!-- Content Render -->
<div class="prose prose-slate max-w-none text-slate-800" :style="{ fontSize: fontSize + 'px' }">
<!-- Video Player (Trial) -->
<div class="not-prose mb-8 rounded-xl overflow-hidden bg-black relative group aspect-video">
<div class="not-prose mb-8 rounded-xl overflow-hidden bg-black relative group aspect-video" v-if="content.type === 'video'">
<div class="absolute inset-0 flex items-center justify-center text-white" v-if="!isPlaying">
<button @click="isPlaying = true" class="w-16 h-16 bg-white/20 hover:bg-white/30 backdrop-blur rounded-full flex items-center justify-center transition-all">
<i class="pi pi-play text-3xl ml-1"></i>
@@ -63,55 +63,23 @@
<span class="text-xs font-mono">01:30 / 05:00</span>
</div>
<!-- Paywall Overlay -->
<div v-if="showPaywall" class="absolute inset-0 bg-black/80 backdrop-blur-sm flex flex-col items-center justify-center text-center p-8 z-20">
<div v-if="!content.is_purchased && content.price > 0" class="absolute inset-0 bg-black/80 backdrop-blur-sm flex flex-col items-center justify-center text-center p-8 z-20">
<h3 class="text-white text-xl font-bold mb-2">试看已结束</h3>
<p class="text-slate-300 text-sm mb-6">购买后可观看完整内容 (包含高清视频 + 完整剧本)</p>
<button class="px-8 py-3 bg-primary-600 text-white rounded-full font-bold hover:bg-primary-700 shadow-lg shadow-primary-900/50 transition-transform hover:scale-105 active:scale-95">
¥ 9.90 立即解锁
¥ {{ content.price }} 立即解锁
</button>
</div>
</div>
<p>听薛良一语一似那金钟猛撞</p>
<p>春秋亭外风雨暴何处悲声破寂寥</p>
<!-- Script Block -->
<div class="pl-6 border-l-4 border-primary-200 bg-slate-50 p-4 rounded-r-lg my-6 not-prose">
<div class="flex gap-2">
<span class="font-bold text-slate-900 flex-shrink-0">薛湘灵</span>
<span class="text-slate-700 leading-relaxed">
隔帘只见一花轿想必是新婚渡鹊桥<br>
吉日良辰当欢笑为什么被珠泪抛<br>
此时却又明白了<br>
() 世上何尝尽富豪<br>
() 也有饥寒悲怀抱也有失意痛哭嚎啕<br>
轿内的人儿命薄如纸<br>
苦煞了严亲累煞了做娘的
</span>
</div>
</div>
<!-- Aria Block -->
<div class="flex items-center gap-4 p-4 border border-purple-100 bg-purple-50 rounded-xl my-6 not-prose cursor-pointer hover:bg-purple-100 transition-colors">
<div class="w-12 h-12 rounded-full bg-purple-600 text-white flex items-center justify-center flex-shrink-0 shadow-sm"><i class="pi pi-play"></i></div>
<div class="flex-1 min-w-0">
<div class="font-bold text-slate-900 truncate">名家示范流水板式要点</div>
<div class="text-xs text-purple-600 flex gap-2 mt-1 font-mono">
<span class="bg-white px-1.5 rounded border border-purple-200">Key: F</span>
<span class="bg-white px-1.5 rounded border border-purple-200">Beat: 1/4</span>
</div>
</div>
<span class="text-xs text-slate-500 font-mono">00:45</span>
</div>
<p>在这里程派独特的嗽音运用得淋漓尽致表现了湘灵对贫富无常的深刻感悟...</p>
<div v-html="content.body"></div>
<!-- Paywall Mask for Text -->
<div class="relative mt-8 pt-20 pb-8 text-center" v-if="!isPurchased">
<div class="relative mt-8 pt-20 pb-8 text-center" v-if="!content.is_purchased && content.price > 0">
<div class="absolute inset-0 bg-gradient-to-b from-transparent via-white/90 to-white z-10"></div>
<div class="relative z-20">
<button class="px-10 py-3.5 bg-primary-600 text-white rounded-full font-bold text-lg hover:bg-primary-700 shadow-xl shadow-primary-100 transition-transform hover:scale-105 active:scale-95 flex items-center gap-2 mx-auto">
<i class="pi pi-lock"></i> 购买解锁全文 ¥ 9.90
<i class="pi pi-lock"></i> 购买解锁全文 ¥ {{ content.price }}
</button>
<p class="text-xs text-slate-400 mt-3">支持微信 / 支付宝</p>
</div>
@@ -122,36 +90,20 @@
<!-- Comments Section -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-8">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-slate-900">全部评论 (24)</h2>
<div class="flex text-sm font-bold text-slate-400">
<button class="text-slate-900 mr-4">最新</button>
<button class="hover:text-slate-900">热门</button>
</div>
<h2 class="text-xl font-bold text-slate-900">全部评论 ({{ comments.length }})</h2>
</div>
<div class="flex gap-4 mb-8">
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=User" class="w-10 h-10 rounded-full bg-slate-100">
<div class="flex-1">
<textarea rows="2" class="w-full p-3 rounded-lg border border-slate-200 focus:border-primary-500 focus:ring-2 focus:ring-primary-100 outline-none resize-none transition-colors" placeholder="写下你的想法..."></textarea>
<div class="flex justify-end mt-2">
<button class="px-6 py-2 bg-primary-600 text-white rounded-lg font-bold hover:bg-primary-700 text-sm">发布</button>
</div>
</div>
</div>
<!-- ... Comment Input ... -->
<div class="space-y-6">
<div v-for="i in 3" :key="i" class="flex gap-4">
<img :src="`https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`" class="w-10 h-10 rounded-full bg-slate-100">
<div v-for="comment in comments" :key="comment.id" class="flex gap-4">
<img :src="comment.user_avatar" class="w-10 h-10 rounded-full bg-slate-100">
<div>
<div class="flex items-center gap-2 mb-1">
<span class="font-bold text-slate-900 text-sm">戏迷_{{ i*92 }}</span>
<span class="text-xs text-slate-400">1小时前</span>
</div>
<p class="text-slate-700 text-sm leading-relaxed mb-2">这段唱腔真是经典张火丁老师的演绎太有味道了反复听了十几遍</p>
<div class="flex gap-4 text-xs text-slate-500">
<button class="hover:text-primary-600 flex items-center gap-1"><i class="pi pi-thumbs-up"></i> 12</button>
<button class="hover:text-primary-600">回复</button>
<span class="font-bold text-slate-900 text-sm">{{ comment.user_nickname }}</span>
<span class="text-xs text-slate-400">{{ comment.create_time }}</span>
</div>
<p class="text-slate-700 text-sm leading-relaxed mb-2">{{ comment.content }}</p>
</div>
</div>
</div>
@@ -163,57 +115,39 @@
<!-- Author Card -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 text-center">
<div class="w-20 h-20 rounded-full border-4 border-slate-50 mx-auto -mt-10 mb-4 bg-white shadow-sm overflow-hidden">
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=Master1" class="w-full h-full object-cover">
<img :src="content.author_avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${content.author_id}`" class="w-full h-full object-cover">
</div>
<h3 class="font-bold text-slate-900 text-lg">梅派传人小林</h3>
<p class="text-xs text-slate-500 mt-1 mb-4">专注京剧程派艺术传承与推广</p>
<div class="flex justify-center gap-4 text-sm font-bold text-slate-900 mb-6">
<div>125 <span class="text-xs text-slate-400 font-normal block">内容</span></div>
<div>1.2w <span class="text-xs text-slate-400 font-normal block">关注</span></div>
</div>
<div class="grid grid-cols-2 gap-3">
<h3 class="font-bold text-slate-900 text-lg">{{ content.author_name }}</h3>
<div class="grid grid-cols-2 gap-3 mt-4">
<button class="py-2 bg-primary-600 text-white rounded-lg font-bold hover:bg-primary-700 shadow-sm shadow-primary-200">关注</button>
<button class="py-2 border border-slate-200 text-slate-700 rounded-lg font-bold hover:bg-slate-50">私信</button>
</div>
</div>
<!-- TOC -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<h3 class="font-bold text-slate-900 mb-4">目录</h3>
<div class="space-y-3 text-sm relative">
<!-- Active Line -->
<div class="absolute left-0 top-2 bottom-2 w-0.5 bg-slate-100"></div>
<a href="#" class="block pl-4 border-l-2 border-primary-600 text-primary-600 font-bold -ml-[1px]">唱段背景介绍</a>
<a href="#" class="block pl-4 border-l-2 border-transparent text-slate-600 hover:text-slate-900 hover:border-slate-300">发音技巧解析</a>
<a href="#" class="block pl-4 border-l-2 border-transparent text-slate-600 hover:text-slate-900 hover:border-slate-300">逐句示范 (00:45)</a>
<a href="#" class="block pl-4 border-l-2 border-transparent text-slate-600 hover:text-slate-900 hover:border-slate-300">完整伴奏下载</a>
</div>
</div>
<!-- Related -->
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<h3 class="font-bold text-slate-900 mb-4">相关推荐</h3>
<div class="space-y-4">
<div v-for="i in 3" :key="i" class="flex gap-3 group cursor-pointer">
<img src="https://images.unsplash.com/photo-1514306191717-452ec28c7f31?ixlib=rb-1.2.1&auto=format&fit=crop&w=100&q=60" class="w-20 h-14 object-cover rounded bg-slate-100 flex-shrink-0">
<div>
<h4 class="text-sm font-bold text-slate-900 line-clamp-2 group-hover:text-primary-600 leading-snug">程派荒山泪夜织选段</h4>
<span class="text-xs text-slate-400 mt-1 block">8.5k 阅读</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { contentApi } from '../../api/content';
const fontSize = ref(18); // Default 18px
const route = useRoute();
const fontSize = ref(18);
const isPlaying = ref(false);
const showPaywall = ref(true); // Simulate trial ended
const isPurchased = ref(false); // Simulate paid content
const content = ref({});
const comments = ref([]);
onMounted(async () => {
try {
const res = await contentApi.get(route.params.id);
content.value = res;
// Fetch comments if needed
// const cmts = await contentApi.listComments(route.params.id);
// comments.value = cmts.items || [];
} catch (e) {
console.error(e);
}
});
</script>

View File

@@ -103,12 +103,13 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { orderApi } from '../../api/order';
const route = useRoute();
const router = useRouter();
const orderId = route.params.id || '82934712';
const amount = '9.90';
const amount = '9.90'; // Should fetch order details first
const productName = '《霸王别姬》全本实录珍藏版';
const paymentMethod = ref('wechat');
@@ -116,6 +117,7 @@ const timeLeft = ref(900); // 15 minutes
const isScanning = ref(false);
const isSuccess = ref(false);
let timer = null;
let pollTimer = null;
const paymentMethodName = computed(() => {
return paymentMethod.value === 'wechat' ? '微信' : '支付宝';
@@ -142,9 +144,25 @@ onMounted(() => {
timer = setInterval(() => {
if (timeLeft.value > 0) timeLeft.value--;
}, 1000);
// Poll Status
pollTimer = setInterval(async () => {
try {
const res = await orderApi.status(orderId);
if (res.status === 'paid' || res.status === 'completed') {
isScanning.value = false;
isSuccess.value = true;
clearInterval(pollTimer);
setTimeout(() => router.replace(`/me/orders/${orderId}`), 1500);
}
} catch (e) {
console.error('Poll status failed', e);
}
}, 3000);
});
onUnmounted(() => {
if (timer) clearInterval(timer);
if (pollTimer) clearInterval(pollTimer);
});
</script>

View File

@@ -8,7 +8,7 @@
class="pi pi-wallet"></i></div>
<div>
<div class="text-sm text-slate-500">账户余额</div>
<div class="text-2xl font-bold text-slate-900">¥ 128.50</div>
<div class="text-2xl font-bold text-slate-900">¥ {{ wallet.balance }}</div>
</div>
<i class="pi pi-chevron-right ml-auto text-slate-300 group-hover:text-primary-400"></i>
</router-link>
@@ -20,7 +20,7 @@
</div>
<div>
<div class="text-sm text-slate-500">我的积分</div>
<div class="text-2xl font-bold text-slate-900">2,450</div>
<div class="text-2xl font-bold text-slate-900">{{ wallet.points }}</div>
</div>
<i class="pi pi-chevron-right ml-auto text-slate-300 group-hover:text-primary-400"></i>
</div>
@@ -32,7 +32,7 @@
</div>
<div>
<div class="text-sm text-slate-500">优惠券</div>
<div class="text-2xl font-bold text-slate-900">3 </div>
<div class="text-2xl font-bold text-slate-900">{{ couponCount }} </div>
</div>
<i class="pi pi-chevron-right ml-auto text-slate-300 group-hover:text-primary-400"></i>
</div>
@@ -48,46 +48,67 @@
</div>
<div class="space-y-4">
<div @click="$router.push('/me/orders/82934712')"
<div v-for="order in recentOrders" :key="order.id" @click="$router.push(`/me/orders/${order.id}`)"
class="flex items-center gap-4 p-4 border border-slate-100 rounded-lg hover:border-primary-100 hover:shadow-sm transition-all cursor-pointer active:scale-[0.99] group">
<div class="w-16 h-16 bg-slate-100 rounded object-cover flex-shrink-0">
<img
src="https://images.unsplash.com/photo-1514306191717-452ec28c7f31?ixlib=rb-1.2.1&auto=format&fit=crop&w=100&q=60"
<img v-if="order.items && order.items.length > 0"
:src="order.items[0].cover"
class="w-full h-full object-cover rounded transition-transform group-hover:scale-105">
</div>
<div class="flex-1 min-w-0">
<h3 class="font-bold text-slate-900 truncate group-hover:text-primary-600 transition-colors">
霸王别姬全本实录珍藏版</h3>
<div class="text-sm text-slate-500 mt-1">2025-12-24 14:30 · 订单号: 82934712</div>
{{ order.items && order.items.length > 0 ? order.items[0].title : '未知商品' }}</h3>
<div class="text-sm text-slate-500 mt-1">{{ order.create_time }} · 订单号: {{ order.id }}</div>
</div>
<div class="text-right">
<div class="font-bold text-slate-900">¥ 9.90</div>
<div class="text-sm text-green-600 mt-1">交易成功</div>
<div class="font-bold text-slate-900">¥ {{ order.amount }}</div>
<div class="text-sm text-green-600 mt-1">{{ order.status }}</div>
</div>
</div>
<!-- More items... -->
<div v-if="recentOrders.length === 0" class="text-center text-slate-400 py-4">暂无订单</div>
</div>
</div>
<!-- Recent Views -->
<!-- Recent Views (Mock) -->
<div class="mt-8 bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-slate-900">最近浏览</h2>
<button class="text-sm text-slate-500 hover:text-slate-700"><i class="pi pi-trash"></i> 清空历史</button>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<div v-for="i in 5" :key="i" class="group cursor-pointer">
<div class="aspect-[16/9] bg-slate-100 rounded-lg overflow-hidden mb-2 relative">
<img
:src="`https://images.unsplash.com/photo-1469571486292-0ba58a3f068b?ixlib=rb-1.2.1&auto=format&fit=crop&w=300&q=60`"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500">
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors"></div>
</div>
<h4 class="text-sm font-medium text-slate-800 line-clamp-2 group-hover:text-primary-600">
京剧名家谈戏曲传承与创新发展的思考</h4>
<div class="text-xs text-slate-400 mt-1">10分钟前</div>
</div>
<div class="text-center text-slate-400 col-span-full py-8">暂无浏览记录</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { userApi } from '../../api/user';
const wallet = ref({ balance: 0, points: 0 });
const couponCount = ref(0);
const recentOrders = ref([]);
const fetchData = async () => {
try {
const w = await userApi.getWallet();
wallet.value.balance = w.balance || 0;
const u = await userApi.getMe();
wallet.value.points = u.points || 0;
const c = await userApi.getCoupons('unused');
couponCount.value = c.length;
const o = await userApi.getOrders('all');
recentOrders.value = (o || []).slice(0, 3);
} catch (e) {
console.error(e);
}
};
onMounted(() => {
fetchData();
});
</script>

View File

@@ -83,65 +83,50 @@
</template>
<script setup>
import { ref, computed } from 'vue';
import { ref, computed, onMounted, watch } from 'vue';
import { userApi } from '../../api/user';
import { useRouter } from 'vue-router';
const router = useRouter();
const currentTab = ref('all');
const tabs = [
{ label: '全部订单', value: 'all' },
{ label: '待支付', value: 'unpaid' },
{ label: '已完成', value: 'completed' },
{ label: '退款/售后', value: 'refund' }
{ label: '待支付', value: 'created' }, // Backend uses 'created' for unpaid? Check consts.
// Backend OrderStatusCreated = "created". OrderStatusPaid = "paid".
// So 'unpaid' in UI should map to 'created' in backend?
// Let's check `consts.gen.go`.
{ label: '已完成', value: 'paid' },
{ label: '退款/售后', value: 'refunded' } // or 'refunding'
];
// Mock Data
const orders = ref([
{
id: '82934712',
date: '2025-12-24 14:30',
tenantName: '梅派传人小林',
cover: 'https://images.unsplash.com/photo-1514306191717-452ec28c7f31?ixlib=rb-1.2.1&auto=format&fit=crop&w=100&q=60',
title: '《霸王别姬》全本实录珍藏版',
type: 'video',
typeLabel: '戏曲视频',
isVirtual: true,
amount: '9.90',
status: 'completed'
},
{
id: '82934713',
date: '2025-12-23 09:15',
tenantName: '戏曲周边商城',
cover: 'https://images.unsplash.com/photo-1557683316-973673baf926?ixlib=rb-1.2.1&auto=format&fit=crop&w=100&q=60',
title: '京剧脸谱纪念书签 (一套4张)',
type: 'product',
typeLabel: '实体商品',
isVirtual: false,
amount: '45.00',
status: 'unpaid'
},
{
id: '82934711',
date: '2025-12-20 18:20',
tenantName: '豫剧李大师',
cover: 'https://images.unsplash.com/photo-1469571486292-0ba58a3f068b?ixlib=rb-1.2.1&auto=format&fit=crop&w=100&q=60',
title: '豫剧唱腔发音技巧专栏',
type: 'article',
typeLabel: '付费专栏',
isVirtual: true,
amount: '99.00',
status: 'refunded' // or refunding
}
]);
const orders = ref([]);
const filteredOrders = computed(() => {
if (currentTab.value === 'all') return orders.value;
if (currentTab.value === 'refund') return orders.value.filter(o => ['refunded', 'refunding'].includes(o.status));
return orders.value.filter(o => o.status === currentTab.value);
const fetchOrders = async () => {
try {
let status = currentTab.value;
// Map UI tab to Backend Status if needed
// Assuming backend accepts 'all', 'created', 'paid', 'refunded'.
const res = await userApi.getOrders(status);
orders.value = res || [];
} catch (e) {
console.error(e);
}
};
onMounted(() => {
fetchOrders();
});
watch(currentTab, () => {
fetchOrders();
});
// Use orders directly (filtered by backend)
const filteredOrders = computed(() => orders.value);
const statusText = (status) => {
const map = {
unpaid: '待支付',
created: '待支付',
paid: '已支付',
completed: '交易成功',
refunding: '退款中',
@@ -153,7 +138,7 @@ const statusText = (status) => {
const statusColor = (status) => {
const map = {
unpaid: 'text-orange-600',
created: 'text-orange-600',
paid: 'text-blue-600',
completed: 'text-green-600',
refunding: 'text-purple-600',