feat: add creator coupons and portal lint

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

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View 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>

View File

@@ -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>