feat: add creator coupons and portal lint
This commit is contained in:
27
frontend/portal/.eslintrc.cjs
Normal file
27
frontend/portal/.eslintrc.cjs
Normal file
@@ -0,0 +1,27 @@
|
||||
/* eslint-env node */
|
||||
require("@rushstack/eslint-patch/modern-module-resolution");
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
"plugin:vue/vue3-essential",
|
||||
"eslint:recommended",
|
||||
"@vue/eslint-config-prettier",
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
},
|
||||
rules: {
|
||||
"vue/multi-word-component-names": "off",
|
||||
"vue/no-reserved-component-names": "off",
|
||||
"vue/component-tags-order": [
|
||||
"error",
|
||||
{
|
||||
order: ["script", "template", "style"],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
1476
frontend/portal/package-lock.json
generated
1476
frontend/portal/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -23,6 +24,10 @@
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.8.0",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-vue": "^9.23.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
import { request } from '../utils/request';
|
||||
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}`);
|
||||
},
|
||||
getContent: (id) => request(`/creator/contents/${id}`),
|
||||
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 }),
|
||||
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}`);
|
||||
},
|
||||
getContent: (id) => request(`/creator/contents/${id}`),
|
||||
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 }),
|
||||
listCoupons: (params) => {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return request(`/creator/coupons?${qs}`);
|
||||
},
|
||||
getCoupon: (id) => request(`/creator/coupons/${id}`),
|
||||
createCoupon: (data) =>
|
||||
request("/creator/coupons", { method: "POST", body: data }),
|
||||
updateCoupon: (id, data) =>
|
||||
request(`/creator/coupons/${id}`, { method: "PUT", body: data }),
|
||||
grantCoupon: (id, data) =>
|
||||
request(`/creator/coupons/${id}/grant`, { 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 }),
|
||||
};
|
||||
|
||||
@@ -1,91 +1,143 @@
|
||||
<template>
|
||||
<div class="flex flex-col bg-slate-50" :class="isFullWidth ? 'h-screen overflow-hidden' : 'min-h-screen'">
|
||||
<TopNavbar v-if="!isFullWidth" />
|
||||
<main class="flex-grow flex flex-col min-h-0" :class="!isFullWidth ? 'pt-16' : ''">
|
||||
<div class="mx-auto flex gap-8 w-full min-h-0 flex-grow" :class="isFullWidth ? 'max-w-none px-0' : 'max-w-screen-xl py-8'">
|
||||
<!-- Creator Sidebar (Dark Theme) -->
|
||||
<aside class="w-[280px] flex-shrink-0 hidden lg:block" v-if="!isFullWidth">
|
||||
<div
|
||||
class="bg-slate-900 rounded-2xl shadow-sm overflow-hidden sticky top-24 text-slate-300 min-h-[600px] flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="p-6 border-b border-slate-800">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 bg-gradient-to-br from-primary-500 to-primary-700 rounded-lg flex items-center justify-center text-white font-bold text-lg shadow-lg">
|
||||
<i class="pi pi-palette"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold text-white leading-tight">创作者中心</div>
|
||||
<div class="text-xs text-slate-500 mt-1">Creator Studio</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menus -->
|
||||
<nav class="p-4 space-y-1 flex-1">
|
||||
<router-link :to="tenantRoute('/creator')"
|
||||
exact-active-class="bg-primary-600 text-white shadow-md shadow-primary-900/20"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-800 hover:text-white transition-all group">
|
||||
<i class="pi pi-th-large text-lg group-hover:scale-110 transition-transform"></i>
|
||||
<span class="font-medium">管理概览</span>
|
||||
</router-link>
|
||||
|
||||
<div class="px-4 py-2 text-xs font-bold text-slate-500 uppercase tracking-wider mt-4">内容与交易</div>
|
||||
|
||||
<router-link :to="tenantRoute('/creator/contents')"
|
||||
active-class="bg-primary-600 text-white shadow-md shadow-primary-900/20"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-800 hover:text-white transition-all group">
|
||||
<i class="pi pi-file-edit text-lg group-hover:scale-110 transition-transform"></i>
|
||||
<span class="font-medium">内容管理</span>
|
||||
</router-link>
|
||||
<router-link :to="tenantRoute('/creator/orders')"
|
||||
active-class="bg-primary-600 text-white shadow-md shadow-primary-900/20"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-800 hover:text-white transition-all group">
|
||||
<i class="pi pi-shopping-cart text-lg group-hover:scale-110 transition-transform"></i>
|
||||
<span class="font-medium">订单管理</span>
|
||||
</router-link>
|
||||
|
||||
<div class="px-4 py-2 text-xs font-bold text-slate-500 uppercase tracking-wider mt-4">配置</div>
|
||||
|
||||
<router-link :to="tenantRoute('/creator/settings')"
|
||||
active-class="bg-primary-600 text-white shadow-md shadow-primary-900/20"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-800 hover:text-white transition-all group">
|
||||
<i class="pi pi-cog text-lg group-hover:scale-110 transition-transform"></i>
|
||||
<span class="font-medium">频道设置</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<!-- Footer Link -->
|
||||
<div class="p-4 border-t border-slate-800">
|
||||
<router-link :to="tenantRoute('/')"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm text-slate-400 hover:text-white transition-colors">
|
||||
<i class="pi pi-external-link"></i> 预览我的主页
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-grow min-w-0 h-full">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<AppFooter v-if="!isFullWidth" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import AppFooter from '../components/AppFooter.vue';
|
||||
import TopNavbar from '../components/TopNavbar.vue';
|
||||
import { tenantPath } from '../utils/tenant';
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import AppFooter from "../components/AppFooter.vue";
|
||||
import TopNavbar from "../components/TopNavbar.vue";
|
||||
import { tenantPath } from "../utils/tenant";
|
||||
|
||||
const route = useRoute();
|
||||
const tenantRoute = (path) => tenantPath(path, route);
|
||||
|
||||
const isFullWidth = computed(() => {
|
||||
return ['creator-content-new', 'creator-content-edit'].includes(route.name);
|
||||
return ["creator-content-new", "creator-content-edit"].includes(route.name);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col bg-slate-50"
|
||||
:class="isFullWidth ? 'h-screen overflow-hidden' : 'min-h-screen'"
|
||||
>
|
||||
<TopNavbar v-if="!isFullWidth" />
|
||||
<main
|
||||
class="flex-grow flex flex-col min-h-0"
|
||||
:class="!isFullWidth ? 'pt-16' : ''"
|
||||
>
|
||||
<div
|
||||
class="mx-auto flex gap-8 w-full min-h-0 flex-grow"
|
||||
:class="isFullWidth ? 'max-w-none px-0' : 'max-w-screen-xl py-8'"
|
||||
>
|
||||
<!-- Creator Sidebar (Dark Theme) -->
|
||||
<aside
|
||||
class="w-[280px] flex-shrink-0 hidden lg:block"
|
||||
v-if="!isFullWidth"
|
||||
>
|
||||
<div
|
||||
class="bg-slate-900 rounded-2xl shadow-sm overflow-hidden sticky top-24 text-slate-300 min-h-[600px] flex flex-col"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="p-6 border-b border-slate-800">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 bg-gradient-to-br from-primary-500 to-primary-700 rounded-lg flex items-center justify-center text-white font-bold text-lg shadow-lg"
|
||||
>
|
||||
<i class="pi pi-palette"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold text-white leading-tight">
|
||||
创作者中心
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 mt-1">Creator Studio</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menus -->
|
||||
<nav class="p-4 space-y-1 flex-1">
|
||||
<router-link
|
||||
:to="tenantRoute('/creator')"
|
||||
exact-active-class="bg-primary-600 text-white shadow-md shadow-primary-900/20"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-800 hover:text-white transition-all group"
|
||||
>
|
||||
<i
|
||||
class="pi pi-th-large text-lg group-hover:scale-110 transition-transform"
|
||||
></i>
|
||||
<span class="font-medium">管理概览</span>
|
||||
</router-link>
|
||||
|
||||
<div
|
||||
class="px-4 py-2 text-xs font-bold text-slate-500 uppercase tracking-wider mt-4"
|
||||
>
|
||||
内容与交易
|
||||
</div>
|
||||
|
||||
<router-link
|
||||
:to="tenantRoute('/creator/contents')"
|
||||
active-class="bg-primary-600 text-white shadow-md shadow-primary-900/20"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-800 hover:text-white transition-all group"
|
||||
>
|
||||
<i
|
||||
class="pi pi-file-edit text-lg group-hover:scale-110 transition-transform"
|
||||
></i>
|
||||
<span class="font-medium">内容管理</span>
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="tenantRoute('/creator/orders')"
|
||||
active-class="bg-primary-600 text-white shadow-md shadow-primary-900/20"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-800 hover:text-white transition-all group"
|
||||
>
|
||||
<i
|
||||
class="pi pi-shopping-cart text-lg group-hover:scale-110 transition-transform"
|
||||
></i>
|
||||
<span class="font-medium">订单管理</span>
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="tenantRoute('/creator/coupons')"
|
||||
active-class="bg-primary-600 text-white shadow-md shadow-primary-900/20"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-800 hover:text-white transition-all group"
|
||||
>
|
||||
<i
|
||||
class="pi pi-ticket text-lg group-hover:scale-110 transition-transform"
|
||||
></i>
|
||||
<span class="font-medium">优惠券管理</span>
|
||||
</router-link>
|
||||
|
||||
<div
|
||||
class="px-4 py-2 text-xs font-bold text-slate-500 uppercase tracking-wider mt-4"
|
||||
>
|
||||
配置
|
||||
</div>
|
||||
|
||||
<router-link
|
||||
:to="tenantRoute('/creator/settings')"
|
||||
active-class="bg-primary-600 text-white shadow-md shadow-primary-900/20"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-slate-800 hover:text-white transition-all group"
|
||||
>
|
||||
<i
|
||||
class="pi pi-cog text-lg group-hover:scale-110 transition-transform"
|
||||
></i>
|
||||
<span class="font-medium">频道设置</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<!-- Footer Link -->
|
||||
<div class="p-4 border-t border-slate-800">
|
||||
<router-link
|
||||
:to="tenantRoute('/')"
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
<i class="pi pi-external-link"></i> 预览我的主页
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="flex-grow min-w-0 h-full">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<AppFooter v-if="!isFullWidth" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,197 +1,202 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import LayoutMain from '../layout/LayoutMain.vue'
|
||||
import LayoutAuth from '../layout/LayoutAuth.vue'
|
||||
import LayoutUser from '../layout/LayoutUser.vue'
|
||||
import LayoutCreator from '../layout/LayoutCreator.vue'
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import LayoutMain from "../layout/LayoutMain.vue";
|
||||
import LayoutAuth from "../layout/LayoutAuth.vue";
|
||||
import LayoutUser from "../layout/LayoutUser.vue";
|
||||
import LayoutCreator from "../layout/LayoutCreator.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/t/:tenantCode',
|
||||
path: "/t/:tenantCode",
|
||||
component: LayoutMain,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'home',
|
||||
component: () => import('../views/HomeView.vue')
|
||||
path: "",
|
||||
name: "home",
|
||||
component: () => import("../views/HomeView.vue"),
|
||||
},
|
||||
{
|
||||
path: 'contents/:id',
|
||||
name: 'content-detail',
|
||||
component: () => import('../views/content/DetailView.vue')
|
||||
path: "contents/:id",
|
||||
name: "content-detail",
|
||||
component: () => import("../views/content/DetailView.vue"),
|
||||
},
|
||||
{
|
||||
path: 't/:id',
|
||||
name: 'tenant-home',
|
||||
component: () => import('../views/tenant/HomeView.vue')
|
||||
path: "t/:id",
|
||||
name: "tenant-home",
|
||||
component: () => import("../views/tenant/HomeView.vue"),
|
||||
},
|
||||
{
|
||||
path: 'explore',
|
||||
name: 'explore',
|
||||
component: () => import('../views/ExploreView.vue') // Placeholder
|
||||
path: "explore",
|
||||
name: "explore",
|
||||
component: () => import("../views/ExploreView.vue"), // Placeholder
|
||||
},
|
||||
{
|
||||
path: 'topics',
|
||||
name: 'topics',
|
||||
component: () => import('../views/TopicsView.vue') // Placeholder
|
||||
path: "topics",
|
||||
name: "topics",
|
||||
component: () => import("../views/TopicsView.vue"), // Placeholder
|
||||
},
|
||||
{
|
||||
path: 'creator/apply',
|
||||
name: 'creator-apply',
|
||||
component: () => import('../views/creator/ApplyView.vue')
|
||||
path: "creator/apply",
|
||||
name: "creator-apply",
|
||||
component: () => import("../views/creator/ApplyView.vue"),
|
||||
},
|
||||
{
|
||||
path: 'creator/contents/new',
|
||||
name: 'creator-content-new',
|
||||
component: () => import('../views/creator/ContentsEditView.vue')
|
||||
}
|
||||
]
|
||||
path: "creator/contents/new",
|
||||
name: "creator-content-new",
|
||||
component: () => import("../views/creator/ContentsEditView.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/t/:tenantCode/auth',
|
||||
path: "/t/:tenantCode/auth",
|
||||
component: LayoutAuth,
|
||||
children: [
|
||||
{
|
||||
path: 'login',
|
||||
name: 'login',
|
||||
component: () => import('../views/auth/LoginView.vue')
|
||||
}
|
||||
]
|
||||
path: "login",
|
||||
name: "login",
|
||||
component: () => import("../views/auth/LoginView.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/t/:tenantCode/me',
|
||||
path: "/t/:tenantCode/me",
|
||||
component: LayoutUser,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'user-dashboard',
|
||||
component: () => import('../views/user/DashboardView.vue')
|
||||
path: "",
|
||||
name: "user-dashboard",
|
||||
component: () => import("../views/user/DashboardView.vue"),
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
name: 'user-orders',
|
||||
component: () => import('../views/user/OrdersView.vue')
|
||||
path: "orders",
|
||||
name: "user-orders",
|
||||
component: () => import("../views/user/OrdersView.vue"),
|
||||
},
|
||||
{
|
||||
path: 'orders/:id',
|
||||
name: 'user-order-detail',
|
||||
component: () => import('../views/order/DetailView.vue')
|
||||
path: "orders/:id",
|
||||
name: "user-order-detail",
|
||||
component: () => import("../views/order/DetailView.vue"),
|
||||
},
|
||||
{
|
||||
path: 'wallet',
|
||||
name: 'user-wallet',
|
||||
component: () => import('../views/user/WalletView.vue')
|
||||
path: "wallet",
|
||||
name: "user-wallet",
|
||||
component: () => import("../views/user/WalletView.vue"),
|
||||
},
|
||||
{
|
||||
path: 'coupons',
|
||||
name: 'user-coupons',
|
||||
component: () => import('../views/user/CouponsView.vue')
|
||||
path: "coupons",
|
||||
name: "user-coupons",
|
||||
component: () => import("../views/user/CouponsView.vue"),
|
||||
},
|
||||
{
|
||||
path: 'library',
|
||||
name: 'user-library',
|
||||
component: () => import('../views/user/LibraryView.vue')
|
||||
path: "library",
|
||||
name: "user-library",
|
||||
component: () => import("../views/user/LibraryView.vue"),
|
||||
},
|
||||
{
|
||||
path: 'favorites',
|
||||
name: 'user-favorites',
|
||||
component: () => import('../views/user/FavoritesView.vue')
|
||||
path: "favorites",
|
||||
name: "user-favorites",
|
||||
component: () => import("../views/user/FavoritesView.vue"),
|
||||
},
|
||||
{
|
||||
path: 'likes',
|
||||
name: 'user-likes',
|
||||
component: () => import('../views/user/LikesView.vue')
|
||||
path: "likes",
|
||||
name: "user-likes",
|
||||
component: () => import("../views/user/LikesView.vue"),
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
name: 'user-notifications',
|
||||
component: () => import('../views/user/NotificationsView.vue')
|
||||
path: "notifications",
|
||||
name: "user-notifications",
|
||||
component: () => import("../views/user/NotificationsView.vue"),
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'user-profile',
|
||||
component: () => import('../views/user/ProfileView.vue')
|
||||
path: "profile",
|
||||
name: "user-profile",
|
||||
component: () => import("../views/user/ProfileView.vue"),
|
||||
},
|
||||
{
|
||||
path: 'security',
|
||||
name: 'user-security',
|
||||
component: () => import('../views/user/SecurityView.vue')
|
||||
}
|
||||
]
|
||||
path: "security",
|
||||
name: "user-security",
|
||||
component: () => import("../views/user/SecurityView.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/t/:tenantCode/creator',
|
||||
path: "/t/:tenantCode/creator",
|
||||
component: LayoutCreator,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'creator-dashboard',
|
||||
component: () => import('../views/creator/DashboardView.vue')
|
||||
path: "",
|
||||
name: "creator-dashboard",
|
||||
component: () => import("../views/creator/DashboardView.vue"),
|
||||
},
|
||||
{
|
||||
path: 'contents',
|
||||
name: 'creator-contents',
|
||||
component: () => import('../views/creator/ContentsView.vue')
|
||||
path: "contents",
|
||||
name: "creator-contents",
|
||||
component: () => import("../views/creator/ContentsView.vue"),
|
||||
},
|
||||
{
|
||||
path: 'contents/new',
|
||||
name: 'creator-content-new',
|
||||
component: () => import('../views/creator/ContentsEditView.vue')
|
||||
path: "contents/new",
|
||||
name: "creator-content-new",
|
||||
component: () => import("../views/creator/ContentsEditView.vue"),
|
||||
},
|
||||
{
|
||||
path: 'contents/:id',
|
||||
name: 'creator-content-edit',
|
||||
component: () => import('../views/creator/ContentsEditView.vue')
|
||||
path: "contents/:id",
|
||||
name: "creator-content-edit",
|
||||
component: () => import("../views/creator/ContentsEditView.vue"),
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
name: 'creator-orders',
|
||||
component: () => import('../views/creator/OrdersView.vue')
|
||||
path: "orders",
|
||||
name: "creator-orders",
|
||||
component: () => import("../views/creator/OrdersView.vue"),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'creator-settings',
|
||||
component: () => import('../views/creator/SettingsView.vue')
|
||||
}
|
||||
]
|
||||
path: "coupons",
|
||||
name: "creator-coupons",
|
||||
component: () => import("../views/creator/CouponsView.vue"),
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
name: "creator-settings",
|
||||
component: () => import("../views/creator/SettingsView.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/t/:tenantCode/checkout',
|
||||
path: "/t/:tenantCode/checkout",
|
||||
component: LayoutMain,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'checkout',
|
||||
component: () => import('../views/order/CheckoutView.vue')
|
||||
}
|
||||
]
|
||||
path: "",
|
||||
name: "checkout",
|
||||
component: () => import("../views/order/CheckoutView.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/t/:tenantCode/payment/:id',
|
||||
path: "/t/:tenantCode/payment/:id",
|
||||
component: LayoutMain,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'payment',
|
||||
component: () => import('../views/order/PaymentView.vue')
|
||||
}
|
||||
]
|
||||
path: "",
|
||||
name: "payment",
|
||||
component: () => import("../views/order/PaymentView.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
// Fallback
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
component: () => import('../views/misc/NotFoundView.vue')
|
||||
}
|
||||
path: "/:pathMatch(.*)*",
|
||||
name: "not-found",
|
||||
component: () => import("../views/misc/NotFoundView.vue"),
|
||||
},
|
||||
],
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
return savedPosition;
|
||||
} else {
|
||||
return { top: 0 }
|
||||
return { top: 0 };
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
export default router
|
||||
export default router;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { contentApi } from '../../api/content';
|
||||
import { tenantApi } from '../../api/tenant';
|
||||
import { tenantPath } from '../../utils/tenant';
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { contentApi } from "../api/content";
|
||||
import { tenantApi } from "../api/tenant";
|
||||
import { tenantPath } from "../utils/tenant";
|
||||
|
||||
const route = useRoute();
|
||||
const tenantRoute = (path) => tenantPath(path, route);
|
||||
@@ -12,296 +12,479 @@ const bannerItems = ref([]);
|
||||
const trendingItems = ref([]);
|
||||
const recommendedCreators = ref([]);
|
||||
const matchedCreators = ref([]);
|
||||
const searchKeyword = ref('');
|
||||
const searchKeyword = ref("");
|
||||
const loading = ref(true);
|
||||
const page = ref(1);
|
||||
const hasMore = ref(false);
|
||||
const activeBannerIndex = ref(0);
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [bannerRes, trendingRes, creatorsRes, feedRes] = await Promise.all([
|
||||
contentApi.list({ is_pinned: true, limit: 5 }),
|
||||
contentApi.list({ sort: 'hot', limit: 3 }),
|
||||
tenantApi.list({ limit: 5 }),
|
||||
contentApi.list({ page: 1, limit: 10, sort: 'latest' })
|
||||
]);
|
||||
loading.value = true;
|
||||
try {
|
||||
const [bannerRes, trendingRes, creatorsRes, feedRes] = await Promise.all([
|
||||
contentApi.list({ is_pinned: true, limit: 5 }),
|
||||
contentApi.list({ sort: "hot", limit: 3 }),
|
||||
tenantApi.list({ limit: 5 }),
|
||||
contentApi.list({ page: 1, limit: 10, sort: "latest" }),
|
||||
]);
|
||||
|
||||
if (bannerRes.items && bannerRes.items.length > 0) {
|
||||
bannerItems.value = bannerRes.items;
|
||||
} else if (feedRes.items && feedRes.items.length > 0) {
|
||||
bannerItems.value = feedRes.items.slice(0, 5);
|
||||
}
|
||||
if (bannerRes.items && bannerRes.items.length > 0) {
|
||||
bannerItems.value = bannerRes.items;
|
||||
} else if (feedRes.items && feedRes.items.length > 0) {
|
||||
bannerItems.value = feedRes.items.slice(0, 5);
|
||||
}
|
||||
|
||||
trendingItems.value = trendingRes.items || [];
|
||||
recommendedCreators.value = creatorsRes.items || [];
|
||||
trendingItems.value = trendingRes.items || [];
|
||||
recommendedCreators.value = creatorsRes.items || [];
|
||||
|
||||
contents.value = feedRes.items || [];
|
||||
hasMore.value = (feedRes.total > contents.value.length);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
contents.value = feedRes.items || [];
|
||||
hasMore.value = feedRes.total > contents.value.length;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
page.value = 1;
|
||||
loading.value = true;
|
||||
matchedCreators.value = [];
|
||||
try {
|
||||
const promises = [
|
||||
contentApi.list({ page: 1, limit: 10, keyword: searchKeyword.value })
|
||||
];
|
||||
if (searchKeyword.value) {
|
||||
promises.push(tenantApi.list({ keyword: searchKeyword.value, limit: 5 }));
|
||||
}
|
||||
page.value = 1;
|
||||
loading.value = true;
|
||||
matchedCreators.value = [];
|
||||
try {
|
||||
const promises = [
|
||||
contentApi.list({ page: 1, limit: 10, keyword: searchKeyword.value }),
|
||||
];
|
||||
if (searchKeyword.value) {
|
||||
promises.push(tenantApi.list({ keyword: searchKeyword.value, limit: 5 }));
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const contentRes = results[0];
|
||||
const results = await Promise.all(promises);
|
||||
const contentRes = results[0];
|
||||
|
||||
contents.value = contentRes.items || [];
|
||||
hasMore.value = (contentRes.total > contents.value.length);
|
||||
contents.value = contentRes.items || [];
|
||||
hasMore.value = contentRes.total > contents.value.length;
|
||||
|
||||
if (results[1]) {
|
||||
matchedCreators.value = results[1].items || [];
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
if (results[1]) {
|
||||
matchedCreators.value = results[1].items || [];
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadMore = async () => {
|
||||
page.value++;
|
||||
const res = await contentApi.list({
|
||||
page: page.value,
|
||||
limit: 10,
|
||||
keyword: searchKeyword.value
|
||||
});
|
||||
if (res.items) {
|
||||
contents.value.push(...res.items);
|
||||
hasMore.value = (res.total > contents.value.length);
|
||||
}
|
||||
page.value++;
|
||||
const res = await contentApi.list({
|
||||
page: page.value,
|
||||
limit: 10,
|
||||
keyword: searchKeyword.value,
|
||||
});
|
||||
if (res.items) {
|
||||
contents.value.push(...res.items);
|
||||
hasMore.value = res.total > contents.value.length;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchData);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-screen-xl py-8">
|
||||
<!-- Hero Banner -->
|
||||
<div class="relative w-full h-[400px] rounded-2xl overflow-hidden bg-slate-900 mb-8 group"
|
||||
v-if="bannerItems.length > 0">
|
||||
<div v-for="(item, index) in bannerItems" :key="item.id"
|
||||
class="absolute inset-0 transition-opacity duration-700"
|
||||
:class="{ 'opacity-100 z-10': activeBannerIndex === index, 'opacity-0 z-0': activeBannerIndex !== index }">
|
||||
<img :src="item.cover" class="w-full h-full object-cover opacity-80" alt="Banner">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
|
||||
<div class="absolute bottom-0 left-0 p-10 max-w-2xl text-white">
|
||||
<div class="inline-block px-3 py-1 bg-red-600 text-white text-xs font-bold rounded mb-3">置顶推荐</div>
|
||||
<h2 class="text-4xl font-bold mb-4 leading-tight cursor-pointer hover:underline"
|
||||
@click="$router.push(tenantRoute(`/contents/${item.id}`))">{{ item.title }}</h2>
|
||||
<p class="text-lg text-slate-200 line-clamp-2">{{ item.description || item.title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrows -->
|
||||
<button @click="activeBannerIndex = (activeBannerIndex - 1 + bannerItems.length) % bannerItems.length"
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/30 hover:bg-black/50 text-white rounded-full flex items-center justify-center backdrop-blur-sm transition-all z-20"><i
|
||||
class="pi pi-chevron-left text-xl"></i></button>
|
||||
<button @click="activeBannerIndex = (activeBannerIndex + 1) % bannerItems.length"
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/30 hover:bg-black/50 text-white rounded-full flex items-center justify-center backdrop-blur-sm transition-all z-20"><i
|
||||
class="pi pi-chevron-right text-xl"></i></button>
|
||||
|
||||
<!-- Indicators -->
|
||||
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2 z-20">
|
||||
<span v-for="(item, index) in bannerItems" :key="index"
|
||||
class="w-2 h-2 rounded-full cursor-pointer transition-colors"
|
||||
:class="activeBannerIndex === index ? 'bg-white' : 'bg-white/50'"
|
||||
@click="activeBannerIndex = index"></span>
|
||||
</div>
|
||||
<div class="mx-auto max-w-screen-xl py-8">
|
||||
<!-- Hero Banner -->
|
||||
<div
|
||||
class="relative w-full h-[400px] rounded-2xl overflow-hidden bg-slate-900 mb-8 group"
|
||||
v-if="bannerItems.length > 0"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in bannerItems"
|
||||
:key="item.id"
|
||||
class="absolute inset-0 transition-opacity duration-700"
|
||||
:class="{
|
||||
'opacity-100 z-10': activeBannerIndex === index,
|
||||
'opacity-0 z-0': activeBannerIndex !== index,
|
||||
}"
|
||||
>
|
||||
<img
|
||||
:src="item.cover"
|
||||
class="w-full h-full object-cover opacity-80"
|
||||
alt="Banner"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"
|
||||
></div>
|
||||
<div class="absolute bottom-0 left-0 p-10 max-w-2xl text-white">
|
||||
<div
|
||||
class="inline-block px-3 py-1 bg-red-600 text-white text-xs font-bold rounded mb-3"
|
||||
>
|
||||
置顶推荐
|
||||
</div>
|
||||
<h2
|
||||
class="text-4xl font-bold mb-4 leading-tight cursor-pointer hover:underline"
|
||||
@click="$router.push(tenantRoute(`/contents/${item.id}`))"
|
||||
>
|
||||
{{ item.title }}
|
||||
</h2>
|
||||
<p class="text-lg text-slate-200 line-clamp-2">
|
||||
{{ item.description || item.title }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="mb-8">
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6 border-b border-slate-200 pb-4">
|
||||
<div class="flex items-center gap-8">
|
||||
<button
|
||||
class="text-lg font-bold text-primary-600 border-b-2 border-primary-600 -mb-4.5 pb-4 px-2">推荐</button>
|
||||
<button
|
||||
class="text-lg font-medium text-slate-500 hover:text-slate-800 -mb-4.5 pb-4 px-2 transition-colors">最新</button>
|
||||
<button
|
||||
class="text-lg font-medium text-slate-500 hover:text-slate-800 -mb-4.5 pb-4 px-2 transition-colors">热门</button>
|
||||
</div>
|
||||
<!-- Arrows -->
|
||||
<button
|
||||
@click="
|
||||
activeBannerIndex =
|
||||
(activeBannerIndex - 1 + bannerItems.length) % bannerItems.length
|
||||
"
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/30 hover:bg-black/50 text-white rounded-full flex items-center justify-center backdrop-blur-sm transition-all z-20"
|
||||
>
|
||||
<i class="pi pi-chevron-left text-xl"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="
|
||||
activeBannerIndex = (activeBannerIndex + 1) % bannerItems.length
|
||||
"
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-black/30 hover:bg-black/50 text-white rounded-full flex items-center justify-center backdrop-blur-sm transition-all z-20"
|
||||
>
|
||||
<i class="pi pi-chevron-right text-xl"></i>
|
||||
</button>
|
||||
|
||||
<!-- Global Search -->
|
||||
<div class="relative">
|
||||
<i class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"></i>
|
||||
<input type="text" v-model="searchKeyword" @keyup.enter="handleSearch" placeholder="搜索全站内容..."
|
||||
class="h-10 pl-10 pr-4 rounded-full border border-slate-200 bg-slate-50 text-sm focus:bg-white focus:border-primary-500 focus:outline-none w-full md:w-64 transition-all">
|
||||
<!-- Indicators -->
|
||||
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2 z-20">
|
||||
<span
|
||||
v-for="(item, index) in bannerItems"
|
||||
:key="index"
|
||||
class="w-2 h-2 rounded-full cursor-pointer transition-colors"
|
||||
:class="activeBannerIndex === index ? 'bg-white' : 'bg-white/50'"
|
||||
@click="activeBannerIndex = index"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="mb-8">
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6 border-b border-slate-200 pb-4"
|
||||
>
|
||||
<div class="flex items-center gap-8">
|
||||
<button
|
||||
class="text-lg font-bold text-primary-600 border-b-2 border-primary-600 -mb-4.5 pb-4 px-2"
|
||||
>
|
||||
推荐
|
||||
</button>
|
||||
<button
|
||||
class="text-lg font-medium text-slate-500 hover:text-slate-800 -mb-4.5 pb-4 px-2 transition-colors"
|
||||
>
|
||||
最新
|
||||
</button>
|
||||
<button
|
||||
class="text-lg font-medium text-slate-500 hover:text-slate-800 -mb-4.5 pb-4 px-2 transition-colors"
|
||||
>
|
||||
热门
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Global Search -->
|
||||
<div class="relative">
|
||||
<i
|
||||
class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
|
||||
></i>
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchKeyword"
|
||||
@keyup.enter="handleSearch"
|
||||
placeholder="搜索全站内容..."
|
||||
class="h-10 pl-10 pr-4 rounded-full border border-slate-200 bg-slate-50 text-sm focus:bg-white focus:border-primary-500 focus:outline-none w-full md:w-64 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tags -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-full bg-slate-900 text-white text-sm font-medium"
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 text-sm font-medium transition-colors"
|
||||
>
|
||||
京剧
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 text-sm font-medium transition-colors"
|
||||
>
|
||||
昆曲
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 text-sm font-medium transition-colors"
|
||||
>
|
||||
越剧
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 text-sm font-medium transition-colors"
|
||||
>
|
||||
名家名段
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 text-sm font-medium transition-colors"
|
||||
>
|
||||
戏曲教学
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Layout: Grid 9:3 -->
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<!-- Main Feed (Left 9) -->
|
||||
<div class="col-span-12 lg:col-span-8 xl:col-span-9 space-y-6">
|
||||
<!-- Matched Creators (Search Result) -->
|
||||
<div
|
||||
v-if="searchKeyword && matchedCreators.length > 0"
|
||||
class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6"
|
||||
>
|
||||
<h3 class="font-bold text-slate-900 mb-4">相关频道</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="creator in matchedCreators"
|
||||
:key="creator.id"
|
||||
class="flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors cursor-pointer border border-transparent hover:border-slate-200"
|
||||
@click="$router.push(tenantRoute(`/t/${creator.id}`))"
|
||||
>
|
||||
<img
|
||||
:src="
|
||||
creator.avatar ||
|
||||
`https://api.dicebear.com/7.x/avataaars/svg?seed=${creator.id}`
|
||||
"
|
||||
class="w-12 h-12 rounded-full border border-slate-100"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-slate-900 truncate">
|
||||
{{ creator.name }}
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 truncate">
|
||||
{{ creator.bio || "暂无简介" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tags -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button class="px-4 py-1.5 rounded-full bg-slate-900 text-white text-sm font-medium">全部</button>
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 text-sm font-medium transition-colors">京剧</button>
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 text-sm font-medium transition-colors">昆曲</button>
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 text-sm font-medium transition-colors">越剧</button>
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 text-sm font-medium transition-colors">名家名段</button>
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 text-sm font-medium transition-colors">戏曲教学</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<router-link
|
||||
v-for="item in contents"
|
||||
:key="item.id"
|
||||
:to="tenantRoute(`/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
|
||||
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"
|
||||
>
|
||||
{{ 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="
|
||||
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>{{ 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> {{ item.views }}</span
|
||||
>
|
||||
<span class="text-xl font-bold text-red-600"
|
||||
>¥ {{ item.price }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<!-- Load More -->
|
||||
<div class="pt-4 text-center" v-if="hasMore">
|
||||
<button
|
||||
@click="loadMore"
|
||||
:disabled="loading"
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
<span v-if="loading">加载中...</span>
|
||||
<span v-else>点击加载更多内容</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Layout: Grid 9:3 -->
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<!-- Main Feed (Left 9) -->
|
||||
<div class="col-span-12 lg:col-span-8 xl:col-span-9 space-y-6">
|
||||
<!-- Sidebar (Right 3) -->
|
||||
<div class="hidden lg:block lg:col-span-4 xl:col-span-3 space-y-6">
|
||||
<!-- Announcement -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5">
|
||||
<h3 class="font-bold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<i class="pi pi-megaphone text-orange-500"></i> 公告
|
||||
</h3>
|
||||
<ul class="space-y-3 text-sm text-slate-600">
|
||||
<li class="line-clamp-1 hover:text-primary-600 cursor-pointer">
|
||||
• 关于调整创作者收益结算周期的通知
|
||||
</li>
|
||||
<li class="line-clamp-1 hover:text-primary-600 cursor-pointer">
|
||||
• “国粹传承”戏曲短视频大赛开启!
|
||||
</li>
|
||||
<li class="line-clamp-1 hover:text-primary-600 cursor-pointer">
|
||||
• 平台系统维护升级公告 (12.30)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Matched Creators (Search Result) -->
|
||||
<div v-if="searchKeyword && matchedCreators.length > 0"
|
||||
class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
|
||||
<h3 class="font-bold text-slate-900 mb-4">相关频道</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div v-for="creator in matchedCreators" :key="creator.id"
|
||||
class="flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors cursor-pointer border border-transparent hover:border-slate-200"
|
||||
@click="$router.push(tenantRoute(`/t/${creator.id}`))">
|
||||
<img :src="creator.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${creator.id}`"
|
||||
class="w-12 h-12 rounded-full border border-slate-100">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-slate-900 truncate">{{ creator.name }}</div>
|
||||
<div class="text-xs text-slate-500 truncate">{{ creator.bio || '暂无简介' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Recommended Tenants -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5">
|
||||
<h3 class="font-bold text-slate-900 mb-4">推荐名家</h3>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="creator in recommendedCreators"
|
||||
:key="creator.id"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<img
|
||||
:src="
|
||||
creator.avatar ||
|
||||
`https://api.dicebear.com/7.x/avataaars/svg?seed=${creator.id}`
|
||||
"
|
||||
class="w-10 h-10 rounded-full cursor-pointer"
|
||||
@click="$router.push(tenantRoute(`/t/${creator.id}`))"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="font-bold text-slate-900 text-sm truncate hover:text-primary-600 cursor-pointer"
|
||||
@click="$router.push(tenantRoute(`/t/${creator.id}`))"
|
||||
>
|
||||
{{ creator.name }}
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 truncate">
|
||||
粉丝 {{ creator.stats?.followers || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1 bg-primary-50 text-primary-600 text-xs font-bold rounded-full hover:bg-primary-100"
|
||||
>
|
||||
关注
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<router-link v-for="item in contents" :key="item.id" :to="tenantRoute(`/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 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">
|
||||
{{ 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="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>{{ 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> {{ item.views
|
||||
}}</span>
|
||||
<span class="text-xl font-bold text-red-600">¥ {{ item.price }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<!-- Load More -->
|
||||
<div class="pt-4 text-center" v-if="hasMore">
|
||||
<button @click="loadMore" :disabled="loading"
|
||||
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 disabled:opacity-50">
|
||||
<span v-if="loading">加载中...</span>
|
||||
<span v-else>点击加载更多内容</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="recommendedCreators.length === 0"
|
||||
class="text-center text-slate-400 text-sm"
|
||||
>
|
||||
暂无推荐
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar (Right 3) -->
|
||||
<div class="hidden lg:block lg:col-span-4 xl:col-span-3 space-y-6">
|
||||
<!-- Announcement -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5">
|
||||
<h3 class="font-bold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<i class="pi pi-megaphone text-orange-500"></i> 公告
|
||||
</h3>
|
||||
<ul class="space-y-3 text-sm text-slate-600">
|
||||
<li class="line-clamp-1 hover:text-primary-600 cursor-pointer">• 关于调整创作者收益结算周期的通知</li>
|
||||
<li class="line-clamp-1 hover:text-primary-600 cursor-pointer">• “国粹传承”戏曲短视频大赛开启!</li>
|
||||
<li class="line-clamp-1 hover:text-primary-600 cursor-pointer">• 平台系统维护升级公告 (12.30)</li>
|
||||
</ul>
|
||||
<!-- Trending List -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5">
|
||||
<h3 class="font-bold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<i class="pi pi-chart-line text-red-500"></i> 本周热门
|
||||
</h3>
|
||||
<ul class="space-y-4">
|
||||
<li
|
||||
v-for="(item, index) in trendingItems"
|
||||
:key="item.id"
|
||||
class="flex gap-3 items-start"
|
||||
>
|
||||
<span
|
||||
class="font-bold italic text-lg w-4"
|
||||
:class="
|
||||
index === 0
|
||||
? 'text-red-500'
|
||||
: index === 1
|
||||
? 'text-orange-500'
|
||||
: 'text-yellow-500'
|
||||
"
|
||||
>{{ index + 1 }}</span
|
||||
>
|
||||
<div class="flex-1">
|
||||
<h4
|
||||
@click="$router.push(tenantRoute(`/contents/${item.id}`))"
|
||||
class="text-sm font-medium text-slate-800 line-clamp-2 hover:text-primary-600 cursor-pointer"
|
||||
>
|
||||
{{ item.title }}
|
||||
</h4>
|
||||
<span class="text-xs text-slate-400 mt-1 block"
|
||||
>{{ item.views }} 阅读</span
|
||||
>
|
||||
</div>
|
||||
</li>
|
||||
<div
|
||||
v-if="trendingItems.length === 0"
|
||||
class="text-center text-slate-400 text-sm"
|
||||
>
|
||||
暂无热门
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Recommended Tenants -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5">
|
||||
<h3 class="font-bold text-slate-900 mb-4">推荐名家</h3>
|
||||
<div class="space-y-4">
|
||||
<div v-for="creator in recommendedCreators" :key="creator.id" class="flex items-center gap-3">
|
||||
<img :src="creator.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${creator.id}`"
|
||||
class="w-10 h-10 rounded-full cursor-pointer" @click="$router.push(tenantRoute(`/t/${creator.id}`))">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-slate-900 text-sm truncate hover:text-primary-600 cursor-pointer"
|
||||
@click="$router.push(tenantRoute(`/t/${creator.id}`))">{{ creator.name }}</div>
|
||||
<div class="text-xs text-slate-500 truncate">粉丝 {{ creator.stats?.followers || 0 }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1 bg-primary-50 text-primary-600 text-xs font-bold rounded-full hover:bg-primary-100">关注</button>
|
||||
</div>
|
||||
<div v-if="recommendedCreators.length === 0" class="text-center text-slate-400 text-sm">暂无推荐</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trending List -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5">
|
||||
<h3 class="font-bold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<i class="pi pi-chart-line text-red-500"></i> 本周热门
|
||||
</h3>
|
||||
<ul class="space-y-4">
|
||||
<li v-for="(item, index) in trendingItems" :key="item.id" class="flex gap-3 items-start">
|
||||
<span class="font-bold italic text-lg w-4"
|
||||
:class="index === 0 ? 'text-red-500' : (index === 1 ? 'text-orange-500' : 'text-yellow-500')">{{
|
||||
index + 1 }}</span>
|
||||
<div class="flex-1">
|
||||
<h4 @click="$router.push(tenantRoute(`/contents/${item.id}`))"
|
||||
class="text-sm font-medium text-slate-800 line-clamp-2 hover:text-primary-600 cursor-pointer">
|
||||
{{ item.title }}</h4>
|
||||
<span class="text-xs text-slate-400 mt-1 block">{{ item.views }} 阅读</span>
|
||||
</div>
|
||||
</li>
|
||||
<div v-if="trendingItems.length === 0" class="text-center text-slate-400 text-sm">暂无热门</div>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Ad / Promo -->
|
||||
<div class="rounded-xl overflow-hidden shadow-sm">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1557683316-973673baf926?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60"
|
||||
class="w-full h-40 object-cover">
|
||||
<div class="bg-white p-3 flex justify-between items-center">
|
||||
<span class="text-xs text-slate-400 border border-slate-200 px-1 rounded">广告</span>
|
||||
<span class="text-sm font-medium text-slate-700">戏曲周边商城上线啦</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Ad / Promo -->
|
||||
<div class="rounded-xl overflow-hidden shadow-sm">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1557683316-973673baf926?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60"
|
||||
class="w-full h-40 object-cover"
|
||||
/>
|
||||
<div class="bg-white p-3 flex justify-between items-center">
|
||||
<span
|
||||
class="text-xs text-slate-400 border border-slate-200 px-1 rounded"
|
||||
>广告</span
|
||||
>
|
||||
<span class="text-sm font-medium text-slate-700"
|
||||
>戏曲周边商城上线啦</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,414 +1,571 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-2xl font-bold text-slate-900">内容管理</h1>
|
||||
<router-link :to="tenantRoute('/creator/contents/new')"
|
||||
class="px-6 py-2.5 bg-primary-600 text-white rounded-lg font-bold hover:bg-primary-700 transition-colors shadow-sm shadow-primary-200 cursor-pointer active:scale-95 flex items-center gap-2">
|
||||
<i class="pi pi-plus"></i> 发布新内容
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 mb-6 flex flex-wrap gap-4 items-center">
|
||||
<!-- ... existing filters ... -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-slate-500">状态:</span>
|
||||
<select v-model="filterStatus"
|
||||
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white cursor-pointer min-w-[100px]">
|
||||
<option value="all">全部</option>
|
||||
<option v-for="opt in statusOptions" :key="opt.key" :value="opt.key">{{ opt.value }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-slate-500">可见性:</span>
|
||||
<select v-model="filterVisibility"
|
||||
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white cursor-pointer min-w-[100px]">
|
||||
<option value="all">全部</option>
|
||||
<option v-for="opt in visibilityOptions" :key="opt.key" :value="opt.key">{{ opt.value }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-slate-500">曲种:</span>
|
||||
<select v-model="filterGenre"
|
||||
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white cursor-pointer min-w-[100px]">
|
||||
<option value="all">全部</option>
|
||||
<option v-for="opt in genreOptions" :key="opt.key" :value="opt.key">{{ opt.value }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-slate-500">主定调:</span>
|
||||
<select v-model="filterKey"
|
||||
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white cursor-pointer min-w-[100px]">
|
||||
<option value="all">全部</option>
|
||||
<option v-for="k in keys" :key="k" :value="k">{{ k }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-slate-500">排序:</span>
|
||||
<select v-model="filterSort"
|
||||
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white cursor-pointer min-w-[100px]">
|
||||
<option v-for="opt in sortOptions" :key="opt.key" :value="opt.key">{{ opt.value }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button @click="handleResetFilters" class="h-9 px-3 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded transition-colors text-sm font-bold flex items-center gap-1">
|
||||
<i class="pi pi-refresh"></i> 重置
|
||||
</button>
|
||||
|
||||
<div class="ml-auto relative">
|
||||
<i class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"></i>
|
||||
<input type="text" placeholder="搜索标题..." v-model="searchKeyword" @keyup.enter="handleSearch"
|
||||
@blur="handleSearch"
|
||||
class="h-9 pl-9 pr-4 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none w-48 transition-all focus:w-64">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="space-y-4">
|
||||
<div v-for="i in 3" :key="i" class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 flex gap-6 animate-pulse">
|
||||
<div class="w-40 h-[90px] bg-slate-200 rounded-lg"></div>
|
||||
<div class="flex-1 space-y-3">
|
||||
<div class="h-6 bg-slate-200 rounded w-1/3"></div>
|
||||
<div class="h-4 bg-slate-200 rounded w-1/4"></div>
|
||||
<div class="h-8 bg-slate-200 rounded w-full mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="!loading && contents.length === 0" class="flex flex-col items-center justify-center py-20 bg-white rounded-xl border border-slate-100 border-dashed">
|
||||
<div class="w-16 h-16 bg-slate-50 rounded-full flex items-center justify-center mb-4">
|
||||
<i class="pi pi-folder-open text-3xl text-slate-300"></i>
|
||||
</div>
|
||||
<h3 class="text-slate-900 font-bold mb-1">暂无内容</h3>
|
||||
<p class="text-slate-500 text-sm mb-6">您还没有发布任何内容,快去创作吧!</p>
|
||||
<router-link :to="tenantRoute('/creator/contents/new')" class="px-5 py-2 bg-primary-600 text-white rounded-lg text-sm font-bold hover:bg-primary-700 transition-colors">
|
||||
立即发布
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Content List -->
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="item in contents" :key="item.id"
|
||||
class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 flex gap-6 hover:shadow-md transition-shadow group relative">
|
||||
<!-- ... existing list item ... -->
|
||||
|
||||
<!-- Cover -->
|
||||
<div class="w-40 h-[90px] 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
|
||||
class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<router-link :to="tenantRoute(`/creator/contents/${item.id}`)"
|
||||
class="text-white text-xs font-bold border border-white px-3 py-1 rounded hover:bg-white hover:text-black transition-colors">编辑</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0 flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span v-if="item.is_pinned"
|
||||
class="bg-red-600 text-white text-[10px] px-1.5 py-0.5 rounded font-bold whitespace-nowrap">置顶</span>
|
||||
<span v-if="item.genre"
|
||||
class="bg-slate-100 text-slate-600 text-[11px] px-2 py-0.5 rounded-full font-bold whitespace-nowrap">{{
|
||||
getGenreLabel(item.genre) }}</span>
|
||||
<span v-if="item.key"
|
||||
class="bg-blue-50 text-blue-600 text-[11px] px-2 py-0.5 rounded-full font-bold whitespace-nowrap">{{
|
||||
item.key }}</span>
|
||||
<h3 class="font-bold text-slate-900 text-lg truncate hover:text-primary-600 cursor-pointer transition-colors"
|
||||
@click="$router.push(tenantRoute(`/creator/contents/${item.id}`))">
|
||||
{{ item.title }}</h3>
|
||||
</div>
|
||||
<!-- Status Badge -->
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded border border-slate-200 text-slate-500 bg-slate-50" v-if="item.visibility">
|
||||
{{ getVisibilityLabel(item.visibility) }}
|
||||
</span>
|
||||
<span v-if="item.status === 'blocked'" class="text-red-500 text-xs flex items-center gap-1 cursor-help"
|
||||
title="已被封禁">
|
||||
<i class="pi pi-info-circle"></i> 封禁
|
||||
</span>
|
||||
<span class="px-2.5 py-1 rounded text-xs font-bold whitespace-nowrap"
|
||||
:class="statusStyle(item.status).bg + ' ' + statusStyle(item.status).text">
|
||||
{{ statusStyle(item.status).label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 text-xs text-slate-500 mt-3">
|
||||
<span class="flex items-center gap-1" title="发布时间">
|
||||
<i class="pi pi-calendar text-[10px]"></i> {{ item.created_at }}
|
||||
</span>
|
||||
|
||||
<div class="flex items-center gap-3 border-l border-slate-200 pl-3">
|
||||
<span v-if="item.price > 0" class="text-red-600 font-bold">¥{{ item.price.toFixed(2) }}</span>
|
||||
<span v-else class="text-green-600 font-bold">免费</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 border-l border-slate-200 pl-3">
|
||||
<span class="flex items-center gap-1" title="图片" v-if="item.image_count > 0">
|
||||
<i class="pi pi-image text-[10px]"></i> {{ item.image_count }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1" title="视频" v-if="item.video_count > 0">
|
||||
<i class="pi pi-video text-[10px]"></i> {{ item.video_count }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1" title="音频" v-if="item.audio_count > 0">
|
||||
<i class="pi pi-microphone text-[10px]"></i> {{ item.audio_count }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 border-l border-slate-200 pl-3">
|
||||
<span title="浏览量"><i class="pi pi-eye mr-1 text-[10px]"></i> {{ item.views }}</span>
|
||||
<span title="点赞数"><i class="pi pi-thumbs-up mr-1 text-[10px]"></i> {{ item.likes }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-4 pt-3 border-t border-slate-50 mt-3">
|
||||
<button class="text-sm text-slate-500 hover:text-primary-600 font-medium cursor-pointer flex items-center gap-1"
|
||||
@click="$router.push(tenantRoute(`/creator/contents/${item.id}`))">
|
||||
<i class="pi pi-file-edit"></i> 编辑
|
||||
</button>
|
||||
<button v-if="item.status === 'published'"
|
||||
class="text-sm text-slate-500 hover:text-orange-600 font-medium cursor-pointer flex items-center gap-1"
|
||||
@click="handleStatusChange(item.id, 'unpublished')">
|
||||
<i class="pi pi-arrow-down"></i> 下架
|
||||
</button>
|
||||
<button v-if="item.status === 'unpublished'"
|
||||
class="text-sm text-slate-500 hover:text-green-600 font-medium cursor-pointer flex items-center gap-1"
|
||||
@click="handleStatusChange(item.id, 'published')">
|
||||
<i class="pi pi-arrow-up"></i> 上架
|
||||
</button>
|
||||
<template v-if="item.status === 'published'">
|
||||
<button v-if="!item.is_pinned"
|
||||
class="text-sm text-slate-500 hover:text-blue-600 font-medium cursor-pointer flex items-center gap-1"
|
||||
@click="handlePin(item.id, true)">
|
||||
<i class="pi pi-bookmark"></i> 置顶
|
||||
</button>
|
||||
<button v-else
|
||||
class="text-sm text-blue-600 font-medium cursor-pointer flex items-center gap-1"
|
||||
@click="handlePin(item.id, false)">
|
||||
<i class="pi pi-bookmark-fill"></i> 取消置顶
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
class="text-sm text-slate-400 hover:text-red-600 font-medium ml-auto cursor-pointer flex items-center gap-1 transition-colors"
|
||||
@click="handleDelete(item.id)">
|
||||
<i class="pi pi-trash"></i> 删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import ConfirmDialog from 'primevue/confirmdialog';
|
||||
import Paginator from 'primevue/paginator';
|
||||
import { commonApi } from '../../api/common';
|
||||
import { creatorApi } from '../../api/creator';
|
||||
import { tenantPath } from '../../utils/tenant';
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import { useConfirm } from "primevue/useconfirm";
|
||||
import ConfirmDialog from "primevue/confirmdialog";
|
||||
import { commonApi } from "../../api/common";
|
||||
import { creatorApi } from "../../api/creator";
|
||||
import { tenantPath } from "../../utils/tenant";
|
||||
|
||||
const route = useRoute();
|
||||
const tenantRoute = (path) => tenantPath(path, route);
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const contents = ref([]);
|
||||
const loading = ref(false);
|
||||
const filterStatus = ref('all');
|
||||
const filterVisibility = ref('all');
|
||||
const filterGenre = ref('all');
|
||||
const filterKey = ref('all');
|
||||
const filterSort = ref('latest');
|
||||
const searchKeyword = ref('');
|
||||
const filterStatus = ref("all");
|
||||
const filterVisibility = ref("all");
|
||||
const filterGenre = ref("all");
|
||||
const filterKey = ref("all");
|
||||
const filterSort = ref("latest");
|
||||
const searchKeyword = ref("");
|
||||
const statusOptions = ref([]);
|
||||
const visibilityOptions = [
|
||||
{ key: 'public', value: '公开' },
|
||||
{ key: 'tenant_only', value: '仅会员' },
|
||||
{ key: 'private', value: '私有' }
|
||||
{ key: "public", value: "公开" },
|
||||
{ key: "tenant_only", value: "仅会员" },
|
||||
{ key: "private", value: "私有" },
|
||||
];
|
||||
const sortOptions = [
|
||||
{ key: 'latest', value: '最新发布' },
|
||||
{ key: 'oldest', value: '最早发布' },
|
||||
{ key: 'views', value: '最多浏览' },
|
||||
{ key: 'likes', value: '最多点赞' }
|
||||
{ key: "latest", value: "最新发布" },
|
||||
{ key: "oldest", value: "最早发布" },
|
||||
{ key: "views", value: "最多浏览" },
|
||||
{ key: "likes", value: "最多点赞" },
|
||||
];
|
||||
const genreOptions = ref([]);
|
||||
const totalRecords = ref(0);
|
||||
const rows = ref(10);
|
||||
const first = ref(0);
|
||||
const keys = ['C大调', 'D大调', 'E大调', 'F大调', 'G大调', 'A大调', 'B大调', '降E大调'];
|
||||
const keys = [
|
||||
"C大调",
|
||||
"D大调",
|
||||
"E大调",
|
||||
"F大调",
|
||||
"G大调",
|
||||
"A大调",
|
||||
"B大调",
|
||||
"降E大调",
|
||||
];
|
||||
|
||||
const fetchOptions = async () => {
|
||||
try {
|
||||
const res = await commonApi.getOptions();
|
||||
if (res) {
|
||||
statusOptions.value = res.content_status || [];
|
||||
genreOptions.value = res.content_genre || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
try {
|
||||
const res = await commonApi.getOptions();
|
||||
if (res) {
|
||||
statusOptions.value = res.content_status || [];
|
||||
genreOptions.value = res.content_genre || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchContents = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
page: (first.value / rows.value) + 1,
|
||||
limit: rows.value
|
||||
};
|
||||
if (filterStatus.value !== 'all') params.status = filterStatus.value;
|
||||
if (filterVisibility.value !== 'all') params.visibility = filterVisibility.value;
|
||||
if (filterGenre.value !== 'all') params.genre = filterGenre.value;
|
||||
if (filterKey.value !== 'all') params.key = filterKey.value;
|
||||
if (filterSort.value !== 'latest') params.sort = filterSort.value;
|
||||
if (searchKeyword.value) params.keyword = searchKeyword.value;
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
page: first.value / rows.value + 1,
|
||||
limit: rows.value,
|
||||
};
|
||||
if (filterStatus.value !== "all") params.status = filterStatus.value;
|
||||
if (filterVisibility.value !== "all")
|
||||
params.visibility = filterVisibility.value;
|
||||
if (filterGenre.value !== "all") params.genre = filterGenre.value;
|
||||
if (filterKey.value !== "all") params.key = filterKey.value;
|
||||
if (filterSort.value !== "latest") params.sort = filterSort.value;
|
||||
if (searchKeyword.value) params.keyword = searchKeyword.value;
|
||||
|
||||
const res = await creatorApi.listContents(params);
|
||||
if (res && res.items) {
|
||||
contents.value = res.items;
|
||||
totalRecords.value = res.total;
|
||||
} else {
|
||||
contents.value = [];
|
||||
totalRecords.value = 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onPage = (event) => {
|
||||
first.value = event.first;
|
||||
rows.value = event.rows;
|
||||
fetchContents();
|
||||
const res = await creatorApi.listContents(params);
|
||||
if (res && res.items) {
|
||||
contents.value = res.items;
|
||||
totalRecords.value = res.total;
|
||||
} else {
|
||||
contents.value = [];
|
||||
totalRecords.value = 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchOptions();
|
||||
fetchContents();
|
||||
fetchOptions();
|
||||
fetchContents();
|
||||
});
|
||||
|
||||
watch([filterStatus, filterVisibility, filterGenre, filterKey, filterSort], () => {
|
||||
fetchContents();
|
||||
});
|
||||
watch(
|
||||
[filterStatus, filterVisibility, filterGenre, filterKey, filterSort],
|
||||
() => {
|
||||
fetchContents();
|
||||
},
|
||||
);
|
||||
|
||||
const handleResetFilters = () => {
|
||||
filterStatus.value = 'all';
|
||||
filterVisibility.value = 'all';
|
||||
filterGenre.value = 'all';
|
||||
filterKey.value = 'all';
|
||||
filterSort.value = 'latest';
|
||||
searchKeyword.value = '';
|
||||
fetchContents();
|
||||
filterStatus.value = "all";
|
||||
filterVisibility.value = "all";
|
||||
filterGenre.value = "all";
|
||||
filterKey.value = "all";
|
||||
filterSort.value = "latest";
|
||||
searchKeyword.value = "";
|
||||
fetchContents();
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
fetchContents();
|
||||
fetchContents();
|
||||
};
|
||||
|
||||
const getGenreLabel = (key) => {
|
||||
const opt = genreOptions.value.find(o => o.key === key);
|
||||
return opt ? opt.value : key;
|
||||
const opt = genreOptions.value.find((o) => o.key === key);
|
||||
return opt ? opt.value : key;
|
||||
};
|
||||
|
||||
const getVisibilityLabel = (vis) => {
|
||||
const map = {
|
||||
'public': '公开',
|
||||
'tenant_only': '仅会员',
|
||||
'private': '私有'
|
||||
};
|
||||
return map[vis] || vis;
|
||||
const map = {
|
||||
public: "公开",
|
||||
tenant_only: "仅会员",
|
||||
private: "私有",
|
||||
};
|
||||
return map[vis] || vis;
|
||||
};
|
||||
|
||||
const statusStyle = (status) => {
|
||||
// Map backend status to UI style. Labels should ideally come from backend option value/label map if needed,
|
||||
// but for style/color mapping we can keep it here or use a helper.
|
||||
// Using labels from options if available would be best for text.
|
||||
// Map backend status to UI style. Labels should ideally come from backend option value/label map if needed,
|
||||
// but for style/color mapping we can keep it here or use a helper.
|
||||
// Using labels from options if available would be best for text.
|
||||
|
||||
const option = statusOptions.value.find(o => o.key === status);
|
||||
const label = option ? option.value : status;
|
||||
const option = statusOptions.value.find((o) => o.key === status);
|
||||
const label = option ? option.value : status;
|
||||
|
||||
switch (status) {
|
||||
case 'published': return { bg: 'bg-green-50', text: 'text-green-600', label };
|
||||
case 'reviewing': return { bg: 'bg-orange-50', text: 'text-orange-600', label };
|
||||
case 'blocked': return { bg: 'bg-red-50', text: 'text-red-600', label };
|
||||
case 'draft': return { bg: 'bg-slate-100', text: 'text-slate-500', label };
|
||||
case 'unpublished': return { bg: 'bg-slate-100', text: 'text-slate-500', label };
|
||||
default: return { bg: 'bg-slate-100', text: 'text-slate-500', label };
|
||||
}
|
||||
switch (status) {
|
||||
case "published":
|
||||
return { bg: "bg-green-50", text: "text-green-600", label };
|
||||
case "reviewing":
|
||||
return { bg: "bg-orange-50", text: "text-orange-600", label };
|
||||
case "blocked":
|
||||
return { bg: "bg-red-50", text: "text-red-600", label };
|
||||
case "draft":
|
||||
return { bg: "bg-slate-100", text: "text-slate-500", label };
|
||||
case "unpublished":
|
||||
return { bg: "bg-slate-100", text: "text-slate-500", label };
|
||||
default:
|
||||
return { bg: "bg-slate-100", text: "text-slate-500", label };
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = (id, status) => {
|
||||
const action = status === 'published' ? '上架' : '下架';
|
||||
confirm.require({
|
||||
message: `确定要${action}该内容吗?`,
|
||||
header: '操作确认',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: status === 'unpublished' ? 'p-button-danger' : '',
|
||||
accept: async () => {
|
||||
try {
|
||||
await creatorApi.updateContent(id, { status });
|
||||
toast.add({ severity: 'success', summary: '更新成功', life: 2000 });
|
||||
fetchContents();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.add({ severity: 'error', summary: '更新失败', detail: e.message, life: 3000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
const action = status === "published" ? "上架" : "下架";
|
||||
confirm.require({
|
||||
message: `确定要${action}该内容吗?`,
|
||||
header: "操作确认",
|
||||
icon: "pi pi-exclamation-triangle",
|
||||
acceptClass: status === "unpublished" ? "p-button-danger" : "",
|
||||
accept: async () => {
|
||||
try {
|
||||
await creatorApi.updateContent(id, { status });
|
||||
toast.add({ severity: "success", summary: "更新成功", life: 2000 });
|
||||
fetchContents();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.add({
|
||||
severity: "error",
|
||||
summary: "更新失败",
|
||||
detail: e.message,
|
||||
life: 3000,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handlePin = (id, isPinned) => {
|
||||
const action = isPinned ? '置顶' : '取消置顶';
|
||||
confirm.require({
|
||||
message: `确定要${action}该内容吗?`,
|
||||
header: '操作确认',
|
||||
icon: 'pi pi-info-circle',
|
||||
accept: async () => {
|
||||
try {
|
||||
await creatorApi.updateContent(id, { is_pinned: isPinned });
|
||||
toast.add({ severity: 'success', summary: isPinned ? '已置顶' : '已取消置顶', life: 2000 });
|
||||
fetchContents();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.add({ severity: 'error', summary: '操作失败', detail: e.message, life: 3000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
const action = isPinned ? "置顶" : "取消置顶";
|
||||
confirm.require({
|
||||
message: `确定要${action}该内容吗?`,
|
||||
header: "操作确认",
|
||||
icon: "pi pi-info-circle",
|
||||
accept: async () => {
|
||||
try {
|
||||
await creatorApi.updateContent(id, { is_pinned: isPinned });
|
||||
toast.add({
|
||||
severity: "success",
|
||||
summary: isPinned ? "已置顶" : "已取消置顶",
|
||||
life: 2000,
|
||||
});
|
||||
fetchContents();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.add({
|
||||
severity: "error",
|
||||
summary: "操作失败",
|
||||
detail: e.message,
|
||||
life: 3000,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (id) => {
|
||||
confirm.require({
|
||||
message: '确定要删除该内容吗?此操作不可恢复。',
|
||||
header: '删除确认',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
await creatorApi.deleteContent(id);
|
||||
fetchContents();
|
||||
toast.add({ severity: 'success', summary: '删除成功', life: 2000 });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.add({ severity: 'error', summary: '删除失败', detail: e.message, life: 3000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
confirm.require({
|
||||
message: "确定要删除该内容吗?此操作不可恢复。",
|
||||
header: "删除确认",
|
||||
icon: "pi pi-exclamation-triangle",
|
||||
acceptClass: "p-button-danger",
|
||||
accept: async () => {
|
||||
try {
|
||||
await creatorApi.deleteContent(id);
|
||||
fetchContents();
|
||||
toast.add({ severity: "success", summary: "删除成功", life: 2000 });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.add({
|
||||
severity: "error",
|
||||
summary: "删除失败",
|
||||
detail: e.message,
|
||||
life: 3000,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-2xl font-bold text-slate-900">内容管理</h1>
|
||||
<router-link
|
||||
:to="tenantRoute('/creator/contents/new')"
|
||||
class="px-6 py-2.5 bg-primary-600 text-white rounded-lg font-bold hover:bg-primary-700 transition-colors shadow-sm shadow-primary-200 cursor-pointer active:scale-95 flex items-center gap-2"
|
||||
>
|
||||
<i class="pi pi-plus"></i> 发布新内容
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div
|
||||
class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 mb-6 flex flex-wrap gap-4 items-center"
|
||||
>
|
||||
<!-- ... existing filters ... -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-slate-500">状态:</span>
|
||||
<select
|
||||
v-model="filterStatus"
|
||||
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white cursor-pointer min-w-[100px]"
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
<option v-for="opt in statusOptions" :key="opt.key" :value="opt.key">
|
||||
{{ opt.value }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-slate-500">可见性:</span>
|
||||
<select
|
||||
v-model="filterVisibility"
|
||||
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white cursor-pointer min-w-[100px]"
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
<option
|
||||
v-for="opt in visibilityOptions"
|
||||
:key="opt.key"
|
||||
:value="opt.key"
|
||||
>
|
||||
{{ opt.value }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-slate-500">曲种:</span>
|
||||
<select
|
||||
v-model="filterGenre"
|
||||
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white cursor-pointer min-w-[100px]"
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
<option v-for="opt in genreOptions" :key="opt.key" :value="opt.key">
|
||||
{{ opt.value }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-slate-500">主定调:</span>
|
||||
<select
|
||||
v-model="filterKey"
|
||||
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white cursor-pointer min-w-[100px]"
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
<option v-for="k in keys" :key="k" :value="k">{{ k }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-slate-500">排序:</span>
|
||||
<select
|
||||
v-model="filterSort"
|
||||
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white cursor-pointer min-w-[100px]"
|
||||
>
|
||||
<option v-for="opt in sortOptions" :key="opt.key" :value="opt.key">
|
||||
{{ opt.value }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleResetFilters"
|
||||
class="h-9 px-3 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded transition-colors text-sm font-bold flex items-center gap-1"
|
||||
>
|
||||
<i class="pi pi-refresh"></i> 重置
|
||||
</button>
|
||||
|
||||
<div class="ml-auto relative">
|
||||
<i
|
||||
class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
|
||||
></i>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索标题..."
|
||||
v-model="searchKeyword"
|
||||
@keyup.enter="handleSearch"
|
||||
@blur="handleSearch"
|
||||
class="h-9 pl-9 pr-4 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none w-48 transition-all focus:w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="space-y-4">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 flex gap-6 animate-pulse"
|
||||
>
|
||||
<div class="w-40 h-[90px] bg-slate-200 rounded-lg"></div>
|
||||
<div class="flex-1 space-y-3">
|
||||
<div class="h-6 bg-slate-200 rounded w-1/3"></div>
|
||||
<div class="h-4 bg-slate-200 rounded w-1/4"></div>
|
||||
<div class="h-8 bg-slate-200 rounded w-full mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-else-if="!loading && contents.length === 0"
|
||||
class="flex flex-col items-center justify-center py-20 bg-white rounded-xl border border-slate-100 border-dashed"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 bg-slate-50 rounded-full flex items-center justify-center mb-4"
|
||||
>
|
||||
<i class="pi pi-folder-open text-3xl text-slate-300"></i>
|
||||
</div>
|
||||
<h3 class="text-slate-900 font-bold mb-1">暂无内容</h3>
|
||||
<p class="text-slate-500 text-sm mb-6">
|
||||
您还没有发布任何内容,快去创作吧!
|
||||
</p>
|
||||
<router-link
|
||||
:to="tenantRoute('/creator/contents/new')"
|
||||
class="px-5 py-2 bg-primary-600 text-white rounded-lg text-sm font-bold hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
立即发布
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Content List -->
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="item in contents"
|
||||
:key="item.id"
|
||||
class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 flex gap-6 hover:shadow-md transition-shadow group relative"
|
||||
>
|
||||
<!-- ... existing list item ... -->
|
||||
|
||||
<!-- Cover -->
|
||||
<div
|
||||
class="w-40 h-[90px] 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
|
||||
class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<router-link
|
||||
:to="tenantRoute(`/creator/contents/${item.id}`)"
|
||||
class="text-white text-xs font-bold border border-white px-3 py-1 rounded hover:bg-white hover:text-black transition-colors"
|
||||
>编辑</router-link
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0 flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span
|
||||
v-if="item.is_pinned"
|
||||
class="bg-red-600 text-white text-[10px] px-1.5 py-0.5 rounded font-bold whitespace-nowrap"
|
||||
>置顶</span
|
||||
>
|
||||
<span
|
||||
v-if="item.genre"
|
||||
class="bg-slate-100 text-slate-600 text-[11px] px-2 py-0.5 rounded-full font-bold whitespace-nowrap"
|
||||
>{{ getGenreLabel(item.genre) }}</span
|
||||
>
|
||||
<span
|
||||
v-if="item.key"
|
||||
class="bg-blue-50 text-blue-600 text-[11px] px-2 py-0.5 rounded-full font-bold whitespace-nowrap"
|
||||
>{{ item.key }}</span
|
||||
>
|
||||
<h3
|
||||
class="font-bold text-slate-900 text-lg truncate hover:text-primary-600 cursor-pointer transition-colors"
|
||||
@click="
|
||||
$router.push(tenantRoute(`/creator/contents/${item.id}`))
|
||||
"
|
||||
>
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
</div>
|
||||
<!-- Status Badge -->
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded border border-slate-200 text-slate-500 bg-slate-50"
|
||||
v-if="item.visibility"
|
||||
>
|
||||
{{ getVisibilityLabel(item.visibility) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.status === 'blocked'"
|
||||
class="text-red-500 text-xs flex items-center gap-1 cursor-help"
|
||||
title="已被封禁"
|
||||
>
|
||||
<i class="pi pi-info-circle"></i> 封禁
|
||||
</span>
|
||||
<span
|
||||
class="px-2.5 py-1 rounded text-xs font-bold whitespace-nowrap"
|
||||
:class="
|
||||
statusStyle(item.status).bg +
|
||||
' ' +
|
||||
statusStyle(item.status).text
|
||||
"
|
||||
>
|
||||
{{ statusStyle(item.status).label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 text-xs text-slate-500 mt-3">
|
||||
<span class="flex items-center gap-1" title="发布时间">
|
||||
<i class="pi pi-calendar text-[10px]"></i> {{ item.created_at }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-3 border-l border-slate-200 pl-3"
|
||||
>
|
||||
<span v-if="item.price > 0" class="text-red-600 font-bold"
|
||||
>¥{{ item.price.toFixed(2) }}</span
|
||||
>
|
||||
<span v-else class="text-green-600 font-bold">免费</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-3 border-l border-slate-200 pl-3"
|
||||
>
|
||||
<span
|
||||
class="flex items-center gap-1"
|
||||
title="图片"
|
||||
v-if="item.image_count > 0"
|
||||
>
|
||||
<i class="pi pi-image text-[10px]"></i> {{ item.image_count }}
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center gap-1"
|
||||
title="视频"
|
||||
v-if="item.video_count > 0"
|
||||
>
|
||||
<i class="pi pi-video text-[10px]"></i> {{ item.video_count }}
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center gap-1"
|
||||
title="音频"
|
||||
v-if="item.audio_count > 0"
|
||||
>
|
||||
<i class="pi pi-microphone text-[10px]"></i>
|
||||
{{ item.audio_count }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-3 border-l border-slate-200 pl-3"
|
||||
>
|
||||
<span title="浏览量"
|
||||
><i class="pi pi-eye mr-1 text-[10px]"></i>
|
||||
{{ item.views }}</span
|
||||
>
|
||||
<span title="点赞数"
|
||||
><i class="pi pi-thumbs-up mr-1 text-[10px]"></i>
|
||||
{{ item.likes }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div
|
||||
class="flex items-center gap-4 pt-3 border-t border-slate-50 mt-3"
|
||||
>
|
||||
<button
|
||||
class="text-sm text-slate-500 hover:text-primary-600 font-medium cursor-pointer flex items-center gap-1"
|
||||
@click="$router.push(tenantRoute(`/creator/contents/${item.id}`))"
|
||||
>
|
||||
<i class="pi pi-file-edit"></i> 编辑
|
||||
</button>
|
||||
<button
|
||||
v-if="item.status === 'published'"
|
||||
class="text-sm text-slate-500 hover:text-orange-600 font-medium cursor-pointer flex items-center gap-1"
|
||||
@click="handleStatusChange(item.id, 'unpublished')"
|
||||
>
|
||||
<i class="pi pi-arrow-down"></i> 下架
|
||||
</button>
|
||||
<button
|
||||
v-if="item.status === 'unpublished'"
|
||||
class="text-sm text-slate-500 hover:text-green-600 font-medium cursor-pointer flex items-center gap-1"
|
||||
@click="handleStatusChange(item.id, 'published')"
|
||||
>
|
||||
<i class="pi pi-arrow-up"></i> 上架
|
||||
</button>
|
||||
<template v-if="item.status === 'published'">
|
||||
<button
|
||||
v-if="!item.is_pinned"
|
||||
class="text-sm text-slate-500 hover:text-blue-600 font-medium cursor-pointer flex items-center gap-1"
|
||||
@click="handlePin(item.id, true)"
|
||||
>
|
||||
<i class="pi pi-bookmark"></i> 置顶
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="text-sm text-blue-600 font-medium cursor-pointer flex items-center gap-1"
|
||||
@click="handlePin(item.id, false)"
|
||||
>
|
||||
<i class="pi pi-bookmark-fill"></i> 取消置顶
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
class="text-sm text-slate-400 hover:text-red-600 font-medium ml-auto cursor-pointer flex items-center gap-1 transition-colors"
|
||||
@click="handleDelete(item.id)"
|
||||
>
|
||||
<i class="pi pi-trash"></i> 删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
545
frontend/portal/src/views/creator/CouponsView.vue
Normal file
545
frontend/portal/src/views/creator/CouponsView.vue
Normal file
@@ -0,0 +1,545 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import Dialog from "primevue/dialog";
|
||||
import Paginator from "primevue/paginator";
|
||||
import Toast from "primevue/toast";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import { creatorApi } from "../../api/creator";
|
||||
|
||||
const toast = useToast();
|
||||
const coupons = ref([]);
|
||||
const totalRecords = ref(0);
|
||||
const rows = ref(10);
|
||||
const first = ref(0);
|
||||
const filterStatus = ref("all");
|
||||
const filterType = ref("all");
|
||||
const searchKeyword = ref("");
|
||||
|
||||
const editorVisible = ref(false);
|
||||
const grantVisible = ref(false);
|
||||
const editingId = ref(null);
|
||||
const grantCouponId = ref(null);
|
||||
const grantUsers = ref("");
|
||||
|
||||
const form = ref({
|
||||
title: "",
|
||||
description: "",
|
||||
type: "fix_amount",
|
||||
value: 0,
|
||||
min_order_amount: 0,
|
||||
max_discount: 0,
|
||||
total_quantity: 0,
|
||||
start_at: "",
|
||||
end_at: "",
|
||||
});
|
||||
|
||||
const editorTitle = computed(() =>
|
||||
editingId.value ? "编辑优惠券" : "新建优惠券",
|
||||
);
|
||||
|
||||
const resetForm = () => {
|
||||
form.value = {
|
||||
title: "",
|
||||
description: "",
|
||||
type: "fix_amount",
|
||||
value: 0,
|
||||
min_order_amount: 0,
|
||||
max_discount: 0,
|
||||
total_quantity: 0,
|
||||
start_at: "",
|
||||
end_at: "",
|
||||
};
|
||||
};
|
||||
|
||||
const toLocalInput = (value) => {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
};
|
||||
|
||||
const toRFC3339 = (value) => {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
const fetchCoupons = async () => {
|
||||
const params = {
|
||||
page: first.value / rows.value + 1,
|
||||
limit: rows.value,
|
||||
};
|
||||
if (filterStatus.value !== "all") params.status = filterStatus.value;
|
||||
if (filterType.value !== "all") params.type = filterType.value;
|
||||
if (searchKeyword.value) params.keyword = searchKeyword.value;
|
||||
|
||||
try {
|
||||
const res = await creatorApi.listCoupons(params);
|
||||
if (res && res.items) {
|
||||
coupons.value = res.items;
|
||||
totalRecords.value = res.total || 0;
|
||||
} else {
|
||||
coupons.value = [];
|
||||
totalRecords.value = 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const onPage = (event) => {
|
||||
first.value = event.first;
|
||||
rows.value = event.rows;
|
||||
fetchCoupons();
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
editingId.value = null;
|
||||
resetForm();
|
||||
editorVisible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (coupon) => {
|
||||
editingId.value = coupon.id;
|
||||
form.value = {
|
||||
title: coupon.title || "",
|
||||
description: coupon.description || "",
|
||||
type: coupon.type || "fix_amount",
|
||||
value: coupon.value || 0,
|
||||
min_order_amount: coupon.min_order_amount || 0,
|
||||
max_discount: coupon.max_discount || 0,
|
||||
total_quantity: coupon.total_quantity || 0,
|
||||
start_at: toLocalInput(coupon.start_at),
|
||||
end_at: toLocalInput(coupon.end_at),
|
||||
};
|
||||
editorVisible.value = true;
|
||||
};
|
||||
|
||||
const openGrant = (coupon) => {
|
||||
grantCouponId.value = coupon.id;
|
||||
grantUsers.value = "";
|
||||
grantVisible.value = true;
|
||||
};
|
||||
|
||||
const submitForm = async () => {
|
||||
const payload = {
|
||||
title: form.value.title,
|
||||
description: form.value.description,
|
||||
type: form.value.type,
|
||||
value: Number(form.value.value) || 0,
|
||||
min_order_amount: Number(form.value.min_order_amount) || 0,
|
||||
max_discount: Number(form.value.max_discount) || 0,
|
||||
total_quantity: Number(form.value.total_quantity) || 0,
|
||||
start_at: toRFC3339(form.value.start_at),
|
||||
end_at: toRFC3339(form.value.end_at),
|
||||
};
|
||||
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await creatorApi.updateCoupon(editingId.value, payload);
|
||||
toast.add({
|
||||
severity: "success",
|
||||
summary: "更新成功",
|
||||
detail: "优惠券已更新",
|
||||
life: 2000,
|
||||
});
|
||||
} else {
|
||||
await creatorApi.createCoupon(payload);
|
||||
toast.add({
|
||||
severity: "success",
|
||||
summary: "创建成功",
|
||||
detail: "优惠券已创建",
|
||||
life: 2000,
|
||||
});
|
||||
}
|
||||
editorVisible.value = false;
|
||||
fetchCoupons();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.add({
|
||||
severity: "error",
|
||||
summary: "操作失败",
|
||||
detail: e.message || "请稍后重试",
|
||||
life: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const submitGrant = async () => {
|
||||
const ids = grantUsers.value
|
||||
.split(/[,\s,]+/)
|
||||
.map((val) => parseInt(val, 10))
|
||||
.filter((val) => Number.isFinite(val) && val > 0);
|
||||
|
||||
if (!grantCouponId.value || ids.length === 0) {
|
||||
toast.add({
|
||||
severity: "warn",
|
||||
summary: "请输入用户ID",
|
||||
detail: "至少输入一个有效 ID",
|
||||
life: 2000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await creatorApi.grantCoupon(grantCouponId.value, { user_ids: ids });
|
||||
toast.add({
|
||||
severity: "success",
|
||||
summary: "发放成功",
|
||||
detail: `已发放 ${ids.length} 张`,
|
||||
life: 2000,
|
||||
});
|
||||
grantVisible.value = false;
|
||||
fetchCoupons();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.add({
|
||||
severity: "error",
|
||||
summary: "发放失败",
|
||||
detail: e.message || "请稍后重试",
|
||||
life: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatMoney = (value) => {
|
||||
return (Number(value) / 100).toFixed(2);
|
||||
};
|
||||
|
||||
const typeLabel = (value) => {
|
||||
if (value === "discount") return "折扣";
|
||||
if (value === "fix_amount") return "满减";
|
||||
return value || "-";
|
||||
};
|
||||
|
||||
const formatRange = (start, end) => {
|
||||
const startText = start ? start.replace("T", " ").replace("Z", "") : "-";
|
||||
const endText = end ? end.replace("T", " ").replace("Z", "") : "-";
|
||||
return `${startText} ~ ${endText}`;
|
||||
};
|
||||
|
||||
const statusStyle = (coupon) => {
|
||||
const now = new Date();
|
||||
if (coupon.end_at) {
|
||||
const end = new Date(coupon.end_at);
|
||||
if (!Number.isNaN(end.getTime()) && end < now) {
|
||||
return { label: "已过期", bg: "bg-slate-100", text: "text-slate-500" };
|
||||
}
|
||||
}
|
||||
return { label: "生效中", bg: "bg-emerald-50", text: "text-emerald-600" };
|
||||
};
|
||||
|
||||
onMounted(fetchCoupons);
|
||||
watch([filterStatus, filterType], () => {
|
||||
first.value = 0;
|
||||
fetchCoupons();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-2xl font-bold text-slate-900">优惠券管理</h1>
|
||||
<button
|
||||
class="px-4 py-2 bg-slate-900 text-white rounded-lg text-sm font-bold hover:bg-slate-800 transition-colors"
|
||||
@click="openCreate"
|
||||
>
|
||||
<i class="pi pi-plus mr-1"></i> 新建优惠券
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 mb-6 flex flex-wrap gap-4 items-center"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-slate-500">状态:</span>
|
||||
<select
|
||||
v-model="filterStatus"
|
||||
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white cursor-pointer"
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
<option value="active">生效中</option>
|
||||
<option value="expired">已过期</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-slate-500">类型:</span>
|
||||
<select
|
||||
v-model="filterType"
|
||||
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white cursor-pointer"
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
<option value="fix_amount">满减</option>
|
||||
<option value="discount">折扣</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ml-auto relative w-64">
|
||||
<i
|
||||
class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
|
||||
></i>
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchKeyword"
|
||||
@keyup.enter="fetchCoupons"
|
||||
placeholder="搜索标题或描述..."
|
||||
class="w-full h-9 pl-9 pr-4 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden"
|
||||
>
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead
|
||||
class="bg-slate-50 text-slate-500 font-bold border-b border-slate-200"
|
||||
>
|
||||
<tr>
|
||||
<th class="px-6 py-4">标题</th>
|
||||
<th class="px-6 py-4 whitespace-nowrap">类型</th>
|
||||
<th class="px-6 py-4 whitespace-nowrap">面值</th>
|
||||
<th class="px-6 py-4 whitespace-nowrap">门槛</th>
|
||||
<th class="px-6 py-4 whitespace-nowrap">发放/使用</th>
|
||||
<th class="px-6 py-4 whitespace-nowrap">有效期</th>
|
||||
<th class="px-6 py-4 whitespace-nowrap">状态</th>
|
||||
<th class="px-6 py-4 text-right whitespace-nowrap">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<tr
|
||||
v-for="coupon in coupons"
|
||||
:key="coupon.id"
|
||||
class="hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<td class="px-6 py-4">
|
||||
<div class="font-bold text-slate-900">{{ coupon.title }}</div>
|
||||
<div class="text-xs text-slate-500 mt-1 line-clamp-1">
|
||||
{{ coupon.description || "-" }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{{ typeLabel(coupon.type) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span v-if="coupon.type === 'fix_amount'"
|
||||
>¥ {{ formatMoney(coupon.value) }}</span
|
||||
>
|
||||
<span v-else>{{ coupon.value }}%</span>
|
||||
<span
|
||||
v-if="coupon.type === 'discount' && coupon.max_discount > 0"
|
||||
class="text-xs text-slate-500 ml-1"
|
||||
>
|
||||
封顶 ¥ {{ formatMoney(coupon.max_discount) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
¥ {{ formatMoney(coupon.min_order_amount) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="font-bold">{{ coupon.used_quantity }}</span>
|
||||
<span class="text-slate-400">
|
||||
/
|
||||
{{
|
||||
coupon.total_quantity === 0 ? "不限" : coupon.total_quantity
|
||||
}}</span
|
||||
>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-slate-500 whitespace-nowrap">
|
||||
{{ formatRange(coupon.start_at, coupon.end_at) }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span
|
||||
class="inline-block px-2.5 py-1 rounded text-xs font-bold whitespace-nowrap"
|
||||
:class="statusStyle(coupon).bg + ' ' + statusStyle(coupon).text"
|
||||
>
|
||||
{{ statusStyle(coupon).label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right whitespace-nowrap">
|
||||
<button
|
||||
class="text-primary-600 hover:text-primary-700 font-medium mr-4 cursor-pointer hover:bg-primary-50 px-2 py-1 rounded transition-colors"
|
||||
@click="openEdit(coupon)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="text-slate-700 hover:text-slate-900 font-medium cursor-pointer hover:bg-slate-100 px-2 py-1 rounded transition-colors"
|
||||
@click="openGrant(coupon)"
|
||||
>
|
||||
发放
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="coupons.length === 0" class="text-center py-12 text-slate-400">
|
||||
暂无优惠券
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end" v-if="totalRecords > rows">
|
||||
<Paginator
|
||||
:rows="rows"
|
||||
:first="first"
|
||||
:totalRecords="totalRecords"
|
||||
@page="onPage"
|
||||
template="PrevPageLink PageLinks NextPageLink RowsPerPageDropdown"
|
||||
:rowsPerPageOptions="[10, 20, 50]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="editorVisible"
|
||||
modal
|
||||
:header="editorTitle"
|
||||
:style="{ width: '36rem' }"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-600">标题</label>
|
||||
<input
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
class="w-full mt-1 h-10 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-600">描述</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
rows="2"
|
||||
class="w-full mt-1 p-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-600">类型</label>
|
||||
<select
|
||||
v-model="form.type"
|
||||
class="w-full mt-1 h-10 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white"
|
||||
>
|
||||
<option value="fix_amount">满减</option>
|
||||
<option value="discount">折扣</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-600">面值</label>
|
||||
<input
|
||||
v-model.number="form.value"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full mt-1 h-10 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
|
||||
/>
|
||||
<p class="text-xs text-slate-400 mt-1">
|
||||
满减输入分;折扣输入 1-100
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-600"
|
||||
>使用门槛(分)</label
|
||||
>
|
||||
<input
|
||||
v-model.number="form.min_order_amount"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full mt-1 h-10 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="form.type === 'discount'">
|
||||
<label class="text-sm font-medium text-slate-600"
|
||||
>最高抵扣(分)</label
|
||||
>
|
||||
<input
|
||||
v-model.number="form.max_discount"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full mt-1 h-10 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-600">发行数量</label>
|
||||
<input
|
||||
v-model.number="form.total_quantity"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full mt-1 h-10 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
|
||||
/>
|
||||
<p class="text-xs text-slate-400 mt-1">0 表示不限量</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-600">生效时间</label>
|
||||
<input
|
||||
v-model="form.start_at"
|
||||
type="datetime-local"
|
||||
class="w-full mt-1 h-10 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-600">过期时间</label>
|
||||
<input
|
||||
v-model="form.end_at"
|
||||
type="datetime-local"
|
||||
class="w-full mt-1 h-10 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button
|
||||
@click="editorVisible = false"
|
||||
class="px-4 py-2 text-slate-500 hover:text-slate-700 text-sm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
@click="submitForm"
|
||||
class="px-4 py-2 bg-slate-900 text-white rounded text-sm hover:bg-slate-800"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="grantVisible"
|
||||
modal
|
||||
header="发放优惠券"
|
||||
:style="{ width: '28rem' }"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-slate-600">
|
||||
请输入用户 ID,使用逗号或空格分隔。
|
||||
</p>
|
||||
<textarea
|
||||
v-model="grantUsers"
|
||||
rows="3"
|
||||
class="w-full p-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none"
|
||||
placeholder="例如:1001,1002,1003"
|
||||
></textarea>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button
|
||||
@click="grantVisible = false"
|
||||
class="px-4 py-2 text-slate-500 hover:text-slate-700 text-sm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
@click="submitGrant"
|
||||
class="px-4 py-2 bg-primary-600 text-white rounded text-sm hover:bg-primary-700"
|
||||
>
|
||||
确认发放
|
||||
</button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Toast />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,188 +1,3 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-2xl font-bold text-slate-900">订单管理</h1>
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
class="px-4 py-2 border border-slate-200 rounded-lg text-sm font-bold text-slate-600 hover:bg-slate-50 cursor-pointer">
|
||||
<i class="pi pi-download mr-1"></i> 导出报表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 mb-6 flex flex-wrap gap-4 items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-slate-500">状态:</span>
|
||||
<select v-model="filterStatus"
|
||||
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white cursor-pointer">
|
||||
<option value="all">全部</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="refunding">退款申请中</option>
|
||||
<option value="refunded">已退款</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ml-auto relative w-64">
|
||||
<i class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"></i>
|
||||
<input type="text" v-model="searchKeyword" @keyup.enter="fetchOrders" placeholder="搜索订单号或买家..."
|
||||
class="w-full h-9 pl-9 pr-4 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none transition-all" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Table -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="bg-slate-50 text-slate-500 font-bold border-b border-slate-200">
|
||||
<tr>
|
||||
<th class="px-6 py-4 whitespace-nowrap">订单号</th>
|
||||
<th class="px-6 py-4 w-[30%]">内容信息</th>
|
||||
<th class="px-6 py-4 whitespace-nowrap">买家</th>
|
||||
<th class="px-6 py-4 text-right whitespace-nowrap">实付金额</th>
|
||||
<th class="px-6 py-4 whitespace-nowrap">下单时间</th>
|
||||
<th class="px-6 py-4 whitespace-nowrap">状态</th>
|
||||
<th class="px-6 py-4 text-right whitespace-nowrap">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<tr v-for="order in filteredOrders" :key="order.id" class="hover:bg-slate-50 transition-colors">
|
||||
<td class="px-6 py-4 font-mono text-slate-600 align-middle">
|
||||
{{ order.id }}
|
||||
</td>
|
||||
<td class="px-6 py-4 align-middle">
|
||||
<div class="flex items-center gap-3">
|
||||
<img :src="order.cover"
|
||||
class="w-16 h-10 object-cover rounded bg-slate-100 flex-shrink-0" />
|
||||
<span class="font-bold text-slate-900 truncate max-w-[240px]" :title="order.title">{{
|
||||
order.title }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 align-middle">
|
||||
<div class="flex items-center gap-2">
|
||||
<img :src="order.buyerAvatar" class="w-8 h-8 rounded-full flex-shrink-0" />
|
||||
<span class="text-slate-700 truncate max-w-[100px]">{{
|
||||
order.buyerName
|
||||
}}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right font-bold text-slate-900 align-middle">
|
||||
¥ {{ order.amount }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-slate-500 whitespace-nowrap align-middle">
|
||||
{{ order.date }}
|
||||
</td>
|
||||
<td class="px-6 py-4 align-middle">
|
||||
<span class="inline-block px-2.5 py-1 rounded text-xs font-bold whitespace-nowrap" :class="statusStyle(order.status).bg + ' ' + statusStyle(order.status).text
|
||||
">
|
||||
{{ statusStyle(order.status).label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right align-middle whitespace-nowrap">
|
||||
<button @click="viewDetail(order)"
|
||||
class="text-primary-600 hover:text-primary-700 font-medium mr-4 cursor-pointer hover:bg-primary-50 px-2 py-1 rounded transition-colors">
|
||||
详情
|
||||
</button>
|
||||
<button v-if="order.status === 'refunding'" @click="handleRefund(order)"
|
||||
class="text-red-600 hover:text-red-700 font-medium cursor-pointer hover:bg-red-50 px-2 py-1 rounded transition-colors">
|
||||
处理退款
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="filteredOrders.length === 0" class="text-center py-12 text-slate-400">
|
||||
暂无相关订单
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Dialog -->
|
||||
<Dialog v-model:visible="detailDialog" modal header="订单详情" :style="{ width: '30rem' }">
|
||||
<div v-if="selectedOrder" class="space-y-6">
|
||||
<div class="flex justify-between items-center pb-4 border-b border-slate-100">
|
||||
<span class="text-sm text-slate-500">订单号</span>
|
||||
<span class="font-mono font-bold">{{ selectedOrder.id }}</span>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<img :src="selectedOrder.cover" class="w-20 h-14 object-cover rounded" />
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900">{{ selectedOrder.title }}</h3>
|
||||
<p class="text-sm text-slate-500 mt-1">类型: {{ selectedOrder.type }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-slate-50 p-4 rounded-lg space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-slate-500">买家</span>
|
||||
<span class="font-medium">{{ selectedOrder.buyerName }} (ID: {{ selectedOrder.buyerId }})</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-slate-500">支付方式</span>
|
||||
<span>微信支付</span>
|
||||
</div>
|
||||
<div class="flex justify-between pt-2 border-t border-slate-200">
|
||||
<span class="font-bold text-slate-900">实付</span>
|
||||
<span class="font-bold text-red-600 text-lg">¥ {{ selectedOrder.amount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedOrder.status === 'refunding'" class="bg-red-50 p-4 rounded-lg border border-red-100">
|
||||
<h4 class="text-red-700 font-bold text-sm mb-2">退款申请信息</h4>
|
||||
<p class="text-sm text-red-600">申请原因:内容无法播放/质量问题</p>
|
||||
<p class="text-sm text-red-600 mt-1">申请说明:视频一直加载不出来。</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button @click="detailDialog = false"
|
||||
class="px-4 py-2 border border-slate-200 rounded text-sm hover:bg-slate-50">
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 border border-primary-200 text-primary-600 rounded text-sm hover:bg-primary-50 ml-2">
|
||||
联系买家
|
||||
</button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Refund Dialog -->
|
||||
<Dialog v-model:visible="refundDialog" modal header="处理退款申请" :style="{ width: '25rem' }">
|
||||
<div class="text-sm text-slate-600 mb-6">
|
||||
您正在处理订单
|
||||
<span class="font-mono font-bold">{{ selectedOrder?.id }}</span> 的退款申请。
|
||||
<br />同意后,金额将原路退回给买家。
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center gap-3 p-3 border rounded-lg cursor-pointer hover:bg-slate-50" :class="refundAction === 'accept'
|
||||
? 'border-green-500 bg-green-50'
|
||||
: 'border-slate-200'
|
||||
">
|
||||
<RadioButton v-model="refundAction" value="accept" />
|
||||
<span>同意退款</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 p-3 border rounded-lg cursor-pointer hover:bg-slate-50" :class="refundAction === 'reject' ? 'border-red-500 bg-red-50' : 'border-slate-200'
|
||||
">
|
||||
<RadioButton v-model="refundAction" value="reject" />
|
||||
<span>拒绝退款</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="refundAction === 'reject'" class="mt-4">
|
||||
<textarea v-model="refundReason" class="w-full p-2 border border-slate-200 rounded text-sm focus:border-red-500 outline-none"
|
||||
rows="2" placeholder="请输入拒绝理由..."></textarea>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button @click="refundDialog = false" class="px-4 py-2 text-slate-500 hover:text-slate-700 text-sm">
|
||||
取消
|
||||
</button>
|
||||
<button @click="confirmRefund"
|
||||
class="px-4 py-2 bg-slate-900 text-white rounded text-sm hover:bg-slate-800">
|
||||
确认处理
|
||||
</button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Toast />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Dialog from "primevue/dialog";
|
||||
import RadioButton from "primevue/radiobutton";
|
||||
@@ -203,29 +18,29 @@ const orders = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const fetchOrders = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
status: filterStatus.value === 'all' ? '' : filterStatus.value,
|
||||
keyword: searchKeyword.value
|
||||
};
|
||||
const res = await creatorApi.listOrders(params);
|
||||
orders.value = (res || []).map(o => ({
|
||||
id: o.id,
|
||||
title: o.title || '未知内容',
|
||||
type: '数字内容',
|
||||
cover: o.cover,
|
||||
buyerName: o.buyer_name,
|
||||
buyerAvatar: o.buyer_avatar,
|
||||
amount: o.amount,
|
||||
date: o.create_time,
|
||||
status: o.status
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
status: filterStatus.value === "all" ? "" : filterStatus.value,
|
||||
keyword: searchKeyword.value,
|
||||
};
|
||||
const res = await creatorApi.listOrders(params);
|
||||
orders.value = (res || []).map((o) => ({
|
||||
id: o.id,
|
||||
title: o.title || "未知内容",
|
||||
type: "数字内容",
|
||||
cover: o.cover,
|
||||
buyerName: o.buyer_name,
|
||||
buyerAvatar: o.buyer_avatar,
|
||||
amount: o.amount,
|
||||
date: o.create_time,
|
||||
status: o.status,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchOrders);
|
||||
@@ -234,42 +49,329 @@ watch(filterStatus, fetchOrders);
|
||||
const filteredOrders = computed(() => orders.value);
|
||||
|
||||
const statusStyle = (status) => {
|
||||
switch (status) {
|
||||
case "paid":
|
||||
case "completed":
|
||||
return { bg: "bg-green-50", text: "text-green-600", label: "已完成" };
|
||||
case "refunding":
|
||||
return { bg: "bg-orange-50", text: "text-orange-600", label: "退款申请中" };
|
||||
case "refunded":
|
||||
return { bg: "bg-slate-100", text: "text-slate-500", label: "已退款" };
|
||||
default:
|
||||
return { bg: "bg-slate-100", text: "text-slate-500", label: "未知" };
|
||||
}
|
||||
switch (status) {
|
||||
case "paid":
|
||||
case "completed":
|
||||
return { bg: "bg-green-50", text: "text-green-600", label: "已完成" };
|
||||
case "refunding":
|
||||
return {
|
||||
bg: "bg-orange-50",
|
||||
text: "text-orange-600",
|
||||
label: "退款申请中",
|
||||
};
|
||||
case "refunded":
|
||||
return { bg: "bg-slate-100", text: "text-slate-500", label: "已退款" };
|
||||
default:
|
||||
return { bg: "bg-slate-100", text: "text-slate-500", label: "未知" };
|
||||
}
|
||||
};
|
||||
|
||||
const viewDetail = (order) => {
|
||||
selectedOrder.value = order;
|
||||
detailDialog.value = true;
|
||||
selectedOrder.value = order;
|
||||
detailDialog.value = true;
|
||||
};
|
||||
|
||||
const handleRefund = (order) => {
|
||||
selectedOrder.value = order;
|
||||
refundAction.value = "accept";
|
||||
refundReason.value = "";
|
||||
refundDialog.value = true;
|
||||
selectedOrder.value = order;
|
||||
refundAction.value = "accept";
|
||||
refundReason.value = "";
|
||||
refundDialog.value = true;
|
||||
};
|
||||
|
||||
const confirmRefund = async () => {
|
||||
try {
|
||||
const res = await creatorApi.refundOrder(selectedOrder.value.id, {
|
||||
action: refundAction.value,
|
||||
reason: refundReason.value
|
||||
});
|
||||
refundDialog.value = false;
|
||||
toast.add({ severity: "success", summary: "处理成功", life: 3000 });
|
||||
fetchOrders();
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: "处理失败", detail: e.message, life: 3000 });
|
||||
}
|
||||
try {
|
||||
await creatorApi.refundOrder(selectedOrder.value.id, {
|
||||
action: refundAction.value,
|
||||
reason: refundReason.value,
|
||||
});
|
||||
refundDialog.value = false;
|
||||
toast.add({ severity: "success", summary: "处理成功", life: 3000 });
|
||||
fetchOrders();
|
||||
} catch (e) {
|
||||
toast.add({
|
||||
severity: "error",
|
||||
summary: "处理失败",
|
||||
detail: e.message,
|
||||
life: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-2xl font-bold text-slate-900">订单管理</h1>
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
class="px-4 py-2 border border-slate-200 rounded-lg text-sm font-bold text-slate-600 hover:bg-slate-50 cursor-pointer"
|
||||
>
|
||||
<i class="pi pi-download mr-1"></i> 导出报表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div
|
||||
class="bg-white rounded-xl shadow-sm border border-slate-100 p-4 mb-6 flex flex-wrap gap-4 items-center"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-slate-500">状态:</span>
|
||||
<select
|
||||
v-model="filterStatus"
|
||||
class="h-9 px-3 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none bg-white cursor-pointer"
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="refunding">退款申请中</option>
|
||||
<option value="refunded">已退款</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ml-auto relative w-64">
|
||||
<i
|
||||
class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
|
||||
></i>
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchKeyword"
|
||||
@keyup.enter="fetchOrders"
|
||||
placeholder="搜索订单号或买家..."
|
||||
class="w-full h-9 pl-9 pr-4 rounded border border-slate-200 text-sm focus:border-primary-500 outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Table -->
|
||||
<div
|
||||
class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden"
|
||||
>
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead
|
||||
class="bg-slate-50 text-slate-500 font-bold border-b border-slate-200"
|
||||
>
|
||||
<tr>
|
||||
<th class="px-6 py-4 whitespace-nowrap">订单号</th>
|
||||
<th class="px-6 py-4 w-[30%]">内容信息</th>
|
||||
<th class="px-6 py-4 whitespace-nowrap">买家</th>
|
||||
<th class="px-6 py-4 text-right whitespace-nowrap">实付金额</th>
|
||||
<th class="px-6 py-4 whitespace-nowrap">下单时间</th>
|
||||
<th class="px-6 py-4 whitespace-nowrap">状态</th>
|
||||
<th class="px-6 py-4 text-right whitespace-nowrap">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<tr
|
||||
v-for="order in filteredOrders"
|
||||
:key="order.id"
|
||||
class="hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<td class="px-6 py-4 font-mono text-slate-600 align-middle">
|
||||
{{ order.id }}
|
||||
</td>
|
||||
<td class="px-6 py-4 align-middle">
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
:src="order.cover"
|
||||
class="w-16 h-10 object-cover rounded bg-slate-100 flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
class="font-bold text-slate-900 truncate max-w-[240px]"
|
||||
:title="order.title"
|
||||
>{{ order.title }}</span
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 align-middle">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
:src="order.buyerAvatar"
|
||||
class="w-8 h-8 rounded-full flex-shrink-0"
|
||||
/>
|
||||
<span class="text-slate-700 truncate max-w-[100px]">{{
|
||||
order.buyerName
|
||||
}}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 text-right font-bold text-slate-900 align-middle"
|
||||
>
|
||||
¥ {{ order.amount }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-slate-500 whitespace-nowrap align-middle">
|
||||
{{ order.date }}
|
||||
</td>
|
||||
<td class="px-6 py-4 align-middle">
|
||||
<span
|
||||
class="inline-block px-2.5 py-1 rounded text-xs font-bold whitespace-nowrap"
|
||||
:class="
|
||||
statusStyle(order.status).bg +
|
||||
' ' +
|
||||
statusStyle(order.status).text
|
||||
"
|
||||
>
|
||||
{{ statusStyle(order.status).label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right align-middle whitespace-nowrap">
|
||||
<button
|
||||
@click="viewDetail(order)"
|
||||
class="text-primary-600 hover:text-primary-700 font-medium mr-4 cursor-pointer hover:bg-primary-50 px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
详情
|
||||
</button>
|
||||
<button
|
||||
v-if="order.status === 'refunding'"
|
||||
@click="handleRefund(order)"
|
||||
class="text-red-600 hover:text-red-700 font-medium cursor-pointer hover:bg-red-50 px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
处理退款
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="filteredOrders.length === 0"
|
||||
class="text-center py-12 text-slate-400"
|
||||
>
|
||||
暂无相关订单
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Dialog -->
|
||||
<Dialog
|
||||
v-model:visible="detailDialog"
|
||||
modal
|
||||
header="订单详情"
|
||||
:style="{ width: '30rem' }"
|
||||
>
|
||||
<div v-if="selectedOrder" class="space-y-6">
|
||||
<div
|
||||
class="flex justify-between items-center pb-4 border-b border-slate-100"
|
||||
>
|
||||
<span class="text-sm text-slate-500">订单号</span>
|
||||
<span class="font-mono font-bold">{{ selectedOrder.id }}</span>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<img
|
||||
:src="selectedOrder.cover"
|
||||
class="w-20 h-14 object-cover rounded"
|
||||
/>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900">{{ selectedOrder.title }}</h3>
|
||||
<p class="text-sm text-slate-500 mt-1">
|
||||
类型: {{ selectedOrder.type }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-slate-50 p-4 rounded-lg space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-slate-500">买家</span>
|
||||
<span class="font-medium"
|
||||
>{{ selectedOrder.buyerName }} (ID:
|
||||
{{ selectedOrder.buyerId }})</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-slate-500">支付方式</span>
|
||||
<span>微信支付</span>
|
||||
</div>
|
||||
<div class="flex justify-between pt-2 border-t border-slate-200">
|
||||
<span class="font-bold text-slate-900">实付</span>
|
||||
<span class="font-bold text-red-600 text-lg"
|
||||
>¥ {{ selectedOrder.amount }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedOrder.status === 'refunding'"
|
||||
class="bg-red-50 p-4 rounded-lg border border-red-100"
|
||||
>
|
||||
<h4 class="text-red-700 font-bold text-sm mb-2">退款申请信息</h4>
|
||||
<p class="text-sm text-red-600">申请原因:内容无法播放/质量问题</p>
|
||||
<p class="text-sm text-red-600 mt-1">
|
||||
申请说明:视频一直加载不出来。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button
|
||||
@click="detailDialog = false"
|
||||
class="px-4 py-2 border border-slate-200 rounded text-sm hover:bg-slate-50"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 border border-primary-200 text-primary-600 rounded text-sm hover:bg-primary-50 ml-2"
|
||||
>
|
||||
联系买家
|
||||
</button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Refund Dialog -->
|
||||
<Dialog
|
||||
v-model:visible="refundDialog"
|
||||
modal
|
||||
header="处理退款申请"
|
||||
:style="{ width: '25rem' }"
|
||||
>
|
||||
<div class="text-sm text-slate-600 mb-6">
|
||||
您正在处理订单
|
||||
<span class="font-mono font-bold">{{ selectedOrder?.id }}</span>
|
||||
的退款申请。 <br />同意后,金额将原路退回给买家。
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<label
|
||||
class="flex items-center gap-3 p-3 border rounded-lg cursor-pointer hover:bg-slate-50"
|
||||
:class="
|
||||
refundAction === 'accept'
|
||||
? 'border-green-500 bg-green-50'
|
||||
: 'border-slate-200'
|
||||
"
|
||||
>
|
||||
<RadioButton v-model="refundAction" value="accept" />
|
||||
<span>同意退款</span>
|
||||
</label>
|
||||
<label
|
||||
class="flex items-center gap-3 p-3 border rounded-lg cursor-pointer hover:bg-slate-50"
|
||||
:class="
|
||||
refundAction === 'reject'
|
||||
? 'border-red-500 bg-red-50'
|
||||
: 'border-slate-200'
|
||||
"
|
||||
>
|
||||
<RadioButton v-model="refundAction" value="reject" />
|
||||
<span>拒绝退款</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="refundAction === 'reject'" class="mt-4">
|
||||
<textarea
|
||||
v-model="refundReason"
|
||||
class="w-full p-2 border border-slate-200 rounded text-sm focus:border-red-500 outline-none"
|
||||
rows="2"
|
||||
placeholder="请输入拒绝理由..."
|
||||
></textarea>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button
|
||||
@click="refundDialog = false"
|
||||
class="px-4 py-2 text-slate-500 hover:text-slate-700 text-sm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
@click="confirmRefund"
|
||||
class="px-4 py-2 bg-slate-900 text-white rounded text-sm hover:bg-slate-800"
|
||||
>
|
||||
确认处理
|
||||
</button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Toast />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,151 +1,216 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Button from 'primevue/button';
|
||||
import { userApi } from '../../api/user';
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import Dialog from "primevue/dialog";
|
||||
import Button from "primevue/button";
|
||||
import { userApi } from "../../api/user";
|
||||
|
||||
const router = useRouter();
|
||||
const currentTab = ref('all');
|
||||
const currentTab = ref("all");
|
||||
const dialogVisible = ref(false);
|
||||
const selectedNotification = ref(null);
|
||||
const loading = ref(false);
|
||||
const page = ref(1);
|
||||
|
||||
const tabs = ref([
|
||||
{ label: '全部', value: 'all', count: 0 },
|
||||
{ label: '系统通知', value: 'system', count: 0 },
|
||||
{ label: '订单通知', value: 'order', count: 0 },
|
||||
{ label: '审核通知', value: 'audit', count: 0 },
|
||||
{ label: '互动消息', value: 'interaction', count: 0 }
|
||||
{ label: "全部", value: "all", count: 0 },
|
||||
{ label: "系统通知", value: "system", count: 0 },
|
||||
{ label: "订单通知", value: "order", count: 0 },
|
||||
{ label: "审核通知", value: "audit", count: 0 },
|
||||
{ label: "互动消息", value: "interaction", count: 0 },
|
||||
]);
|
||||
|
||||
const notifications = ref([]);
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await userApi.getNotifications(currentTab.value, page.value);
|
||||
notifications.value = res.items || [];
|
||||
} catch (e) {
|
||||
console.error("Fetch notifications failed:", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await userApi.getNotifications(currentTab.value, page.value);
|
||||
notifications.value = res.items || [];
|
||||
} catch (e) {
|
||||
console.error("Fetch notifications failed:", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchNotifications);
|
||||
|
||||
watch(currentTab, () => {
|
||||
page.value = 1;
|
||||
fetchNotifications();
|
||||
page.value = 1;
|
||||
fetchNotifications();
|
||||
});
|
||||
|
||||
const getIconStyle = (type) => {
|
||||
switch(type) {
|
||||
case 'system': return { bg: 'bg-blue-50', color: 'text-blue-600', icon: 'pi-megaphone' };
|
||||
case 'order': return { bg: 'bg-green-50', color: 'text-green-600', icon: 'pi-shopping-bag' };
|
||||
case 'audit': return { bg: 'bg-orange-50', color: 'text-orange-600', icon: 'pi-file-edit' };
|
||||
case 'interaction': return { bg: 'bg-purple-50', color: 'text-purple-600', icon: 'pi-comments' };
|
||||
default: return { bg: 'bg-slate-100', color: 'text-slate-500', icon: 'pi-bell' };
|
||||
}
|
||||
switch (type) {
|
||||
case "system":
|
||||
return { bg: "bg-blue-50", color: "text-blue-600", icon: "pi-megaphone" };
|
||||
case "order":
|
||||
return {
|
||||
bg: "bg-green-50",
|
||||
color: "text-green-600",
|
||||
icon: "pi-shopping-bag",
|
||||
};
|
||||
case "audit":
|
||||
return {
|
||||
bg: "bg-orange-50",
|
||||
color: "text-orange-600",
|
||||
icon: "pi-file-edit",
|
||||
};
|
||||
case "interaction":
|
||||
return {
|
||||
bg: "bg-purple-50",
|
||||
color: "text-purple-600",
|
||||
icon: "pi-comments",
|
||||
};
|
||||
default:
|
||||
return { bg: "bg-slate-100", color: "text-slate-500", icon: "pi-bell" };
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotificationClick = async (item) => {
|
||||
if (!item.read) {
|
||||
try {
|
||||
await userApi.markNotificationRead(item.id);
|
||||
item.read = true;
|
||||
} catch (e) {
|
||||
console.error("Mark read failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type === 'system') {
|
||||
selectedNotification.value = item;
|
||||
dialogVisible.value = true;
|
||||
if (!item.read) {
|
||||
try {
|
||||
await userApi.markNotificationRead(item.id);
|
||||
item.read = true;
|
||||
} catch (e) {
|
||||
console.error("Mark read failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type === "system") {
|
||||
selectedNotification.value = item;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
await userApi.markAllNotificationsRead();
|
||||
notifications.value.forEach(n => n.read = true);
|
||||
tabs.value.forEach(t => t.count = 0);
|
||||
} catch (e) {
|
||||
console.error("Mark all read failed:", e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await userApi.markAllNotificationsRead();
|
||||
notifications.value.forEach((n) => (n.read = true));
|
||||
tabs.value.forEach((t) => (t.count = 0));
|
||||
} catch (e) {
|
||||
console.error("Mark all read failed:", e);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 min-h-[600px]">
|
||||
<div
|
||||
class="bg-white rounded-xl shadow-sm border border-slate-100 min-h-[600px]"
|
||||
>
|
||||
<!-- Header & Tabs -->
|
||||
<div class="px-6 pt-6 border-b border-slate-100 flex items-center justify-between">
|
||||
<div
|
||||
class="px-6 pt-6 border-b border-slate-100 flex items-center justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-8">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
@click="currentTab = tab.value"
|
||||
class="pb-4 text-base font-bold transition-colors border-b-2 cursor-pointer focus:outline-none relative"
|
||||
:class="currentTab === tab.value ? 'text-primary-600 border-primary-600' : 'text-slate-500 border-transparent hover:text-slate-700'"
|
||||
:class="
|
||||
currentTab === tab.value
|
||||
? 'text-primary-600 border-primary-600'
|
||||
: 'text-slate-500 border-transparent hover:text-slate-700'
|
||||
"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span v-if="tab.count > 0" class="absolute -top-1 -right-4 min-w-[1.25rem] h-5 px-1.5 bg-red-500 text-white text-[10px] rounded-full flex items-center justify-center">{{ tab.count }}</span>
|
||||
<span
|
||||
v-if="tab.count > 0"
|
||||
class="absolute -top-1 -right-4 min-w-[1.25rem] h-5 px-1.5 bg-red-500 text-white text-[10px] rounded-full flex items-center justify-center"
|
||||
>{{ tab.count }}</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<button @click="handleMarkAllRead" class="mb-4 text-base font-medium text-slate-500 hover:text-primary-600 cursor-pointer flex items-center gap-1">
|
||||
<i class="pi pi-check-circle"></i> 全部已读
|
||||
<button
|
||||
@click="handleMarkAllRead"
|
||||
class="mb-4 text-base font-medium text-slate-500 hover:text-primary-600 cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<i class="pi pi-check-circle"></i> 全部已读
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Notification List -->
|
||||
<div class="p-0">
|
||||
<div v-if="notifications.length > 0">
|
||||
<div
|
||||
v-for="item in notifications"
|
||||
:key="item.id"
|
||||
@click="handleNotificationClick(item)"
|
||||
class="flex items-start gap-4 p-5 border-b border-slate-50 hover:bg-slate-50 transition-colors cursor-pointer group"
|
||||
:class="{ 'bg-blue-50/30': !item.read }"
|
||||
<div v-if="notifications.length > 0">
|
||||
<div
|
||||
v-for="item in notifications"
|
||||
:key="item.id"
|
||||
@click="handleNotificationClick(item)"
|
||||
class="flex items-start gap-4 p-5 border-b border-slate-50 hover:bg-slate-50 transition-colors cursor-pointer group"
|
||||
:class="{ 'bg-blue-50/30': !item.read }"
|
||||
>
|
||||
<!-- Icon -->
|
||||
<div
|
||||
class="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
:class="getIconStyle(item.type).bg"
|
||||
>
|
||||
<!-- Icon -->
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0" :class="getIconStyle(item.type).bg">
|
||||
<i class="pi" :class="[getIconStyle(item.type).icon, getIconStyle(item.type).color]"></i>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h3 class="font-bold text-slate-900 text-lg group-hover:text-primary-600 transition-colors flex items-center gap-2">
|
||||
<span v-if="!item.read" class="w-2 h-2 bg-blue-600 rounded-full inline-block"></span>
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
<span class="text-sm text-slate-400 whitespace-nowrap">{{ item.time }}</span>
|
||||
</div>
|
||||
<p class="text-base text-slate-600 line-clamp-2">{{ item.content }}</p>
|
||||
</div>
|
||||
<i
|
||||
class="pi"
|
||||
:class="[
|
||||
getIconStyle(item.type).icon,
|
||||
getIconStyle(item.type).color,
|
||||
]"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-20">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-50 mb-4">
|
||||
<i class="pi pi-bell-slash text-2xl text-slate-300"></i>
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h3
|
||||
class="font-bold text-slate-900 text-lg group-hover:text-primary-600 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
v-if="!item.read"
|
||||
class="w-2 h-2 bg-blue-600 rounded-full inline-block"
|
||||
></span>
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
<span class="text-sm text-slate-400 whitespace-nowrap">{{
|
||||
item.time
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="text-base text-slate-600 line-clamp-2">
|
||||
{{ item.content }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-slate-500 text-lg">暂无消息通知</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-20">
|
||||
<div
|
||||
class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-50 mb-4"
|
||||
>
|
||||
<i class="pi pi-bell-slash text-2xl text-slate-300"></i>
|
||||
</div>
|
||||
<p class="text-slate-500 text-lg">暂无消息通知</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Message Modal -->
|
||||
<Dialog v-model:visible="dialogVisible" modal :header="selectedNotification?.title" :style="{ width: '50rem' }" :breakpoints="{ '960px': '75vw', '641px': '90vw' }">
|
||||
<div class="p-4">
|
||||
<div class="text-slate-500 text-sm mb-4">{{ selectedNotification?.time }}</div>
|
||||
<div class="text-slate-700 leading-relaxed whitespace-pre-wrap">{{ selectedNotification?.content }}</div>
|
||||
<Dialog
|
||||
v-model:visible="dialogVisible"
|
||||
modal
|
||||
:header="selectedNotification?.title"
|
||||
:style="{ width: '50rem' }"
|
||||
:breakpoints="{ '960px': '75vw', '641px': '90vw' }"
|
||||
>
|
||||
<div class="p-4">
|
||||
<div class="text-slate-500 text-sm mb-4">
|
||||
{{ selectedNotification?.time }}
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="关闭" icon="pi pi-check" @click="dialogVisible = false" autofocus />
|
||||
</template>
|
||||
<div class="text-slate-700 leading-relaxed whitespace-pre-wrap">
|
||||
{{ selectedNotification?.content }}
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button
|
||||
label="关闭"
|
||||
icon="pi pi-check"
|
||||
@click="dialogVisible = false"
|
||||
autofocus
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,115 +1,154 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import Dialog from "primevue/dialog";
|
||||
import ConfirmDialog from "primevue/confirmdialog";
|
||||
import Toast from "primevue/toast";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import { tenantPath } from "../../utils/tenant";
|
||||
|
||||
const toast = useToast();
|
||||
const verifyDialog = ref(false);
|
||||
const currentAction = ref("");
|
||||
const route = useRoute();
|
||||
const tenantRoute = (path) => tenantPath(path, route);
|
||||
|
||||
const openVerify = (action) => {
|
||||
currentAction.value = action;
|
||||
verifyDialog.value = true;
|
||||
};
|
||||
|
||||
const handleVerifySuccess = () => {
|
||||
verifyDialog.value = false;
|
||||
toast.add({
|
||||
severity: "success",
|
||||
summary: "验证通过",
|
||||
detail: "即将跳转至操作页面...",
|
||||
life: 2000,
|
||||
});
|
||||
// In real app, redirect to specific edit page
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 min-h-[600px] p-8">
|
||||
<div
|
||||
class="bg-white rounded-xl shadow-sm border border-slate-100 min-h-[600px] p-8"
|
||||
>
|
||||
<h1 class="text-2xl font-bold text-slate-900 mb-8">账号安全</h1>
|
||||
|
||||
<!-- Security Level (Optional visual) -->
|
||||
<div class="mb-10 p-6 bg-slate-50 rounded-xl border border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-lg font-bold text-slate-900 mb-1">安全等级:中</div>
|
||||
<p class="text-sm text-slate-500">建议绑定邮箱并完成实名认证,提升账号安全性。</p>
|
||||
</div>
|
||||
<div class="w-32 h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-orange-500 w-1/2"></div>
|
||||
</div>
|
||||
<div
|
||||
class="mb-10 p-6 bg-slate-50 rounded-xl border border-slate-100 flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<div class="text-lg font-bold text-slate-900 mb-1">安全等级:中</div>
|
||||
<p class="text-sm text-slate-500">
|
||||
建议绑定邮箱并完成实名认证,提升账号安全性。
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-32 h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-orange-500 w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security Items -->
|
||||
<div class="space-y-6">
|
||||
<!-- Phone -->
|
||||
<div class="flex items-center justify-between py-4 border-b border-slate-50">
|
||||
<div class="flex items-center gap-4">
|
||||
<i class="pi pi-mobile text-xl text-slate-400"></i>
|
||||
<div>
|
||||
<div class="font-bold text-slate-900">手机绑定</div>
|
||||
<div class="text-sm text-slate-500">已绑定:138****8888</div>
|
||||
</div>
|
||||
<!-- Phone -->
|
||||
<div
|
||||
class="flex items-center justify-between py-4 border-b border-slate-50"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<i class="pi pi-mobile text-xl text-slate-400"></i>
|
||||
<div>
|
||||
<div class="font-bold text-slate-900">手机绑定</div>
|
||||
<div class="text-sm text-slate-500">已绑定:138****8888</div>
|
||||
</div>
|
||||
<button @click="openVerify('phone')" class="px-4 py-2 border border-slate-200 text-slate-600 rounded-lg hover:bg-slate-50 text-sm font-medium transition-colors">更换</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="openVerify('phone')"
|
||||
class="px-4 py-2 border border-slate-200 text-slate-600 rounded-lg hover:bg-slate-50 text-sm font-medium transition-colors"
|
||||
>
|
||||
更换
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Real-name Auth -->
|
||||
<div class="flex items-center justify-between py-4 border-b border-slate-50">
|
||||
<div class="flex items-center gap-4">
|
||||
<i class="pi pi-id-card text-xl text-slate-400"></i>
|
||||
<div>
|
||||
<div class="font-bold text-slate-900">实名认证</div>
|
||||
<div class="text-sm text-slate-500">未认证,发布内容前需完成认证</div>
|
||||
</div>
|
||||
<!-- Real-name Auth -->
|
||||
<div
|
||||
class="flex items-center justify-between py-4 border-b border-slate-50"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<i class="pi pi-id-card text-xl text-slate-400"></i>
|
||||
<div>
|
||||
<div class="font-bold text-slate-900">实名认证</div>
|
||||
<div class="text-sm text-slate-500">
|
||||
未认证,发布内容前需完成认证
|
||||
</div>
|
||||
</div>
|
||||
<button @click="$router.push(tenantRoute('/creator/apply'))" class="px-4 py-2 text-primary-600 font-medium hover:text-primary-700 text-sm transition-colors">去认证</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="$router.push(tenantRoute('/creator/apply'))"
|
||||
class="px-4 py-2 text-primary-600 font-medium hover:text-primary-700 text-sm transition-colors"
|
||||
>
|
||||
去认证
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verification Dialog -->
|
||||
<Dialog v-model:visible="verifyDialog" modal header="安全验证" :style="{ width: '25rem' }">
|
||||
<div class="text-sm text-slate-600 mb-6">为了您的账号安全,请先进行身份验证。</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-500 mb-1">手机号</label>
|
||||
<div class="text-lg font-bold text-slate-900">138****8888</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-500 mb-2">验证码</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" class="flex-1 h-10 px-3 rounded border border-slate-200 focus:border-primary-500 focus:outline-none" placeholder="6位数字">
|
||||
<button class="px-3 h-10 border border-slate-200 rounded text-sm text-slate-600 hover:bg-slate-50">获取验证码</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
v-model:visible="verifyDialog"
|
||||
modal
|
||||
header="安全验证"
|
||||
:style="{ width: '25rem' }"
|
||||
>
|
||||
<div class="text-sm text-slate-600 mb-6">
|
||||
为了您的账号安全,请先进行身份验证。
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-8">
|
||||
<button @click="verifyDialog = false" class="px-4 py-2 text-slate-500 hover:text-slate-700 text-sm">取消</button>
|
||||
<button @click="handleVerifySuccess" class="px-4 py-2 bg-primary-600 text-white rounded hover:bg-primary-700 text-sm">下一步</button>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-500 mb-1"
|
||||
>手机号</label
|
||||
>
|
||||
<div class="text-lg font-bold text-slate-900">138****8888</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-500 mb-2"
|
||||
>验证码</label
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
class="flex-1 h-10 px-3 rounded border border-slate-200 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="6位数字"
|
||||
/>
|
||||
<button
|
||||
class="px-3 h-10 border border-slate-200 rounded text-sm text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
获取验证码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-8">
|
||||
<button
|
||||
@click="verifyDialog = false"
|
||||
class="px-4 py-2 text-slate-500 hover:text-slate-700 text-sm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
@click="handleVerifySuccess"
|
||||
class="px-4 py-2 bg-primary-600 text-white rounded hover:bg-primary-700 text-sm"
|
||||
>
|
||||
下一步
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog></ConfirmDialog>
|
||||
<Toast />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import ConfirmDialog from 'primevue/confirmdialog';
|
||||
import Toast from 'primevue/toast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { tenantPath } from '../../utils/tenant';
|
||||
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
const verifyDialog = ref(false);
|
||||
const currentAction = ref('');
|
||||
const route = useRoute();
|
||||
const tenantRoute = (path) => tenantPath(path, route);
|
||||
|
||||
const openVerify = (action) => {
|
||||
currentAction.value = action;
|
||||
verifyDialog.value = true;
|
||||
};
|
||||
|
||||
const handleVerifySuccess = () => {
|
||||
verifyDialog.value = false;
|
||||
toast.add({ severity: 'success', summary: '验证通过', detail: '即将跳转至操作页面...', life: 2000 });
|
||||
// In real app, redirect to specific edit page
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
confirm.require({
|
||||
message: '注销后,您的所有数据、资产、购买记录将永久丢失且无法找回。请输入“注销”以确认。',
|
||||
header: '高风险操作确认',
|
||||
icon: 'pi pi-info-circle',
|
||||
rejectLabel: '取消',
|
||||
acceptLabel: '确认注销',
|
||||
rejectClass: 'p-button-secondary p-button-outlined',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: () => {
|
||||
toast.add({ severity: 'error', summary: '已提交注销申请', detail: '账号将在 7 天后正式注销', life: 3000 });
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user