feat: 添加获取全局选项接口,支持动态状态和曲种选择
This commit is contained in:
@@ -39,3 +39,16 @@ func (c *Common) Upload(
|
|||||||
}
|
}
|
||||||
return services.Common.Upload(ctx.Context(), user.ID, file, val)
|
return services.Common.Upload(ctx.Context(), user.ID, file, val)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get options (enums)
|
||||||
|
//
|
||||||
|
// @Router /v1/common/options [get]
|
||||||
|
// @Summary Get options
|
||||||
|
// @Description Get global options (enums)
|
||||||
|
// @Tags Common
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} dto.OptionsResponse
|
||||||
|
func (c *Common) GetOptions(ctx fiber.Ctx) (*dto.OptionsResponse, error) {
|
||||||
|
return services.Common.Options(ctx.Context())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
|
import "quyun/v2/app/requests"
|
||||||
|
|
||||||
type UploadResult struct {
|
type UploadResult struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
@@ -7,3 +9,8 @@ type UploadResult struct {
|
|||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
MimeType string `json:"mime_type"`
|
MimeType string `json:"mime_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OptionsResponse struct {
|
||||||
|
ContentStatus []requests.KV `json:"content_status"`
|
||||||
|
ContentGenre []requests.KV `json:"content_genre"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ func (r *Routes) Name() string {
|
|||||||
// Each route is registered with its corresponding controller action and parameter bindings.
|
// Each route is registered with its corresponding controller action and parameter bindings.
|
||||||
func (r *Routes) Register(router fiber.Router) {
|
func (r *Routes) Register(router fiber.Router) {
|
||||||
// Register routes for controller: Common
|
// Register routes for controller: Common
|
||||||
|
r.log.Debugf("Registering route: Get /v1/common/options -> common.GetOptions")
|
||||||
|
router.Get("/v1/common/options"[len(r.Path()):], DataFunc0(
|
||||||
|
r.common.GetOptions,
|
||||||
|
))
|
||||||
r.log.Debugf("Registering route: Post /v1/upload -> common.Upload")
|
r.log.Debugf("Registering route: Post /v1/upload -> common.Upload")
|
||||||
router.Post("/v1/upload"[len(r.Path()):], DataFunc3(
|
router.Post("/v1/upload"[len(r.Path()):], DataFunc3(
|
||||||
r.common.Upload,
|
r.common.Upload,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"quyun/v2/app/errorx"
|
"quyun/v2/app/errorx"
|
||||||
common_dto "quyun/v2/app/http/v1/dto"
|
common_dto "quyun/v2/app/http/v1/dto"
|
||||||
|
"quyun/v2/app/requests"
|
||||||
"quyun/v2/database/fields"
|
"quyun/v2/database/fields"
|
||||||
"quyun/v2/database/models"
|
"quyun/v2/database/models"
|
||||||
"quyun/v2/pkg/consts"
|
"quyun/v2/pkg/consts"
|
||||||
@@ -22,6 +23,21 @@ type common struct {
|
|||||||
storage *storage.Storage
|
storage *storage.Storage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *common) Options(ctx context.Context) (*common_dto.OptionsResponse, error) {
|
||||||
|
return &common_dto.OptionsResponse{
|
||||||
|
ContentStatus: consts.ContentStatusItems(),
|
||||||
|
ContentGenre: []requests.KV{
|
||||||
|
requests.NewKV("Jingju", "京剧"),
|
||||||
|
requests.NewKV("Kunqu", "昆曲"),
|
||||||
|
requests.NewKV("Yueju", "越剧"),
|
||||||
|
requests.NewKV("Yuju", "豫剧"),
|
||||||
|
requests.NewKV("Huangmeixi", "黄梅戏"),
|
||||||
|
requests.NewKV("Pingju", "评剧"),
|
||||||
|
requests.NewKV("Qinqiang", "秦腔"),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *common) Upload(ctx context.Context, userID int64, file *multipart.FileHeader, typeArg string) (*common_dto.UploadResult, error) {
|
func (s *common) Upload(ctx context.Context, userID int64, file *multipart.FileHeader, typeArg string) (*common_dto.UploadResult, error) {
|
||||||
// Mock Upload to S3/MinIO (Here we just generate key, actual upload handling via direct upload or stream is better)
|
// Mock Upload to S3/MinIO (Here we just generate key, actual upload handling via direct upload or stream is better)
|
||||||
// But this Upload endpoint accepts file. So we save it.
|
// But this Upload endpoint accepts file. So we save it.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { request } from '../utils/request';
|
import { request } from '../utils/request';
|
||||||
|
|
||||||
export const commonApi = {
|
export const commonApi = {
|
||||||
|
getOptions: () => request('/common/options'),
|
||||||
upload: (file, type) => {
|
upload: (file, type) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|||||||
@@ -13,42 +13,37 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm font-bold text-slate-500">状态:</span>
|
<span class="text-sm font-bold text-slate-500">状态:</span>
|
||||||
<select v-model="filterStatus"
|
<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">
|
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 value="all">全部</option>
|
||||||
<option value="published">已发布</option>
|
<option v-for="opt in statusOptions" :key="opt.key" :value="opt.key">{{ opt.value }}</option>
|
||||||
<option value="audit">审核中</option>
|
|
||||||
<option value="rejected">已驳回</option>
|
|
||||||
<option value="draft">草稿箱</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm font-bold text-slate-500">曲种:</span>
|
<span class="text-sm font-bold text-slate-500">曲种:</span>
|
||||||
<select v-model="filterGenre"
|
<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">
|
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 value="all">全部</option>
|
||||||
<option value="京剧">京剧</option>
|
<option v-for="opt in genreOptions" :key="opt.key" :value="opt.key">{{ opt.value }}</option>
|
||||||
<option value="昆曲">昆曲</option>
|
|
||||||
<option value="越剧">越剧</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-auto relative">
|
<div class="ml-auto relative">
|
||||||
<i class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"></i>
|
<i class="pi pi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"></i>
|
||||||
<input type="text" placeholder="搜索标题..."
|
<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">
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content List -->
|
<!-- Content List -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div v-for="item in filteredList" :key="item.id"
|
<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">
|
class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 flex gap-6 hover:shadow-md transition-shadow group relative">
|
||||||
|
|
||||||
<!-- Cover -->
|
<!-- Cover -->
|
||||||
<div class="w-40 h-[90px] bg-slate-100 rounded-lg flex-shrink-0 overflow-hidden relative">
|
<div class="w-40 h-[90px] bg-slate-100 rounded-lg flex-shrink-0 overflow-hidden relative">
|
||||||
<img :src="item.cover" class="w-full h-full object-cover">
|
<img :src="item.cover || 'https://via.placeholder.com/300x168?text=No+Cover'" class="w-full h-full object-cover">
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<router-link :to="`/creator/contents/new`"
|
<router-link :to="`/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>
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,16 +53,17 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-xs px-1.5 py-0.5 border rounded text-slate-500">[{{ item.genre }}]</span>
|
<span class="text-xs px-1.5 py-0.5 border rounded text-slate-500" v-if="item.genre">[{{ item.genre }}]</span>
|
||||||
<h3
|
<h3
|
||||||
class="font-bold text-slate-900 text-lg truncate hover:text-primary-600 cursor-pointer transition-colors">
|
class="font-bold text-slate-900 text-lg truncate hover:text-primary-600 cursor-pointer transition-colors"
|
||||||
|
@click="$router.push(`/creator/contents/${item.id}`)">
|
||||||
{{ item.title }}</h3>
|
{{ item.title }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<!-- Status Badge -->
|
<!-- Status Badge -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span v-if="item.status === 'rejected'"
|
<span v-if="item.status === 'blocked'"
|
||||||
class="text-red-500 text-xs flex items-center gap-1 cursor-help" title="点击查看原因">
|
class="text-red-500 text-xs flex items-center gap-1 cursor-help" title="已被封禁">
|
||||||
<i class="pi pi-info-circle"></i> {{ item.rejectReason }}
|
<i class="pi pi-info-circle"></i> 封禁
|
||||||
</span>
|
</span>
|
||||||
<span class="px-2.5 py-1 rounded text-xs font-bold"
|
<span class="px-2.5 py-1 rounded text-xs font-bold"
|
||||||
:class="statusStyle(item.status).bg + ' ' + statusStyle(item.status).text">
|
:class="statusStyle(item.status).bg + ' ' + statusStyle(item.status).text">
|
||||||
@@ -81,21 +77,21 @@
|
|||||||
<span v-else class="text-green-600 font-bold">免费</span>
|
<span v-else class="text-green-600 font-bold">免费</span>
|
||||||
<span><i class="pi pi-eye mr-1"></i> {{ item.views }}</span>
|
<span><i class="pi pi-eye mr-1"></i> {{ item.views }}</span>
|
||||||
<span><i class="pi pi-thumbs-up mr-1"></i> {{ item.likes }}</span>
|
<span><i class="pi pi-thumbs-up mr-1"></i> {{ item.likes }}</span>
|
||||||
<span>{{ item.date }}</span>
|
<!-- Date field missing in DTO, using hardcoded or omitting -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex items-center gap-4 pt-3 border-t border-slate-50 mt-2">
|
<div class="flex items-center gap-4 pt-3 border-t border-slate-50 mt-2">
|
||||||
<button class="text-sm text-slate-500 hover:text-primary-600 font-medium cursor-pointer"><i
|
<button class="text-sm text-slate-500 hover:text-primary-600 font-medium cursor-pointer" @click="$router.push(`/creator/contents/${item.id}`)"><i
|
||||||
class="pi pi-file-edit mr-1"></i> 编辑</button>
|
class="pi pi-file-edit mr-1"></i> 编辑</button>
|
||||||
<button v-if="item.status === 'published'"
|
<button v-if="item.status === 'published'"
|
||||||
class="text-sm text-slate-500 hover:text-orange-600 font-medium cursor-pointer"><i
|
class="text-sm text-slate-500 hover:text-orange-600 font-medium cursor-pointer"><i
|
||||||
class="pi pi-arrow-down mr-1"></i> 下架</button>
|
class="pi pi-arrow-down mr-1"></i> 下架</button>
|
||||||
<button v-if="item.status === 'offline'"
|
<button v-if="item.status === 'unpublished'"
|
||||||
class="text-sm text-slate-500 hover:text-green-600 font-medium cursor-pointer"><i
|
class="text-sm text-slate-500 hover:text-green-600 font-medium cursor-pointer"><i
|
||||||
class="pi pi-arrow-up mr-1"></i> 上架</button>
|
class="pi pi-arrow-up mr-1"></i> 上架</button>
|
||||||
<button class="text-sm text-slate-500 hover:text-red-600 font-medium ml-auto cursor-pointer"><i
|
<button class="text-sm text-slate-500 hover:text-red-600 font-medium ml-auto cursor-pointer" @click="handleDelete(item.id)"><i
|
||||||
class="pi pi-trash mr-1"></i> 删除</button>
|
class="pi pi-trash mr-1"></i> 删除</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,74 +101,83 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref, onMounted, watch } from 'vue';
|
||||||
|
import { creatorApi } from '../../api/creator';
|
||||||
|
import { commonApi } from '../../api/common';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const contents = ref([]);
|
||||||
const filterStatus = ref('all');
|
const filterStatus = ref('all');
|
||||||
const filterGenre = ref('all');
|
const filterGenre = ref('all');
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
const statusOptions = ref([]);
|
||||||
|
const genreOptions = ref([]);
|
||||||
|
|
||||||
const list = ref([
|
const fetchOptions = async () => {
|
||||||
{
|
try {
|
||||||
id: 1,
|
const res = await commonApi.getOptions();
|
||||||
title: '《锁麟囊》春秋亭 (程砚秋)',
|
if (res) {
|
||||||
genre: '京剧',
|
statusOptions.value = res.content_status || [];
|
||||||
cover: 'https://images.unsplash.com/photo-1514306191717-452ec28c7f31?ixlib=rb-1.2.1&auto=format&fit=crop&w=300&q=60',
|
genreOptions.value = res.content_genre || [];
|
||||||
status: 'published',
|
|
||||||
price: 9.9,
|
|
||||||
views: '12.5k',
|
|
||||||
likes: 850,
|
|
||||||
date: '2025-12-24'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: '昆曲《牡丹亭》游园惊梦',
|
|
||||||
genre: '昆曲',
|
|
||||||
cover: 'https://images.unsplash.com/photo-1557683316-973673baf926?ixlib=rb-1.2.1&auto=format&fit=crop&w=300&q=60',
|
|
||||||
status: 'audit',
|
|
||||||
price: 0,
|
|
||||||
views: '-',
|
|
||||||
likes: '-',
|
|
||||||
date: '2025-12-25'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: '越剧《红楼梦》葬花',
|
|
||||||
genre: '越剧',
|
|
||||||
cover: 'https://images.unsplash.com/photo-1469571486292-0ba58a3f068b?ixlib=rb-1.2.1&auto=format&fit=crop&w=300&q=60',
|
|
||||||
status: 'rejected',
|
|
||||||
rejectReason: '封面图清晰度不足',
|
|
||||||
price: 19.9,
|
|
||||||
views: '-',
|
|
||||||
likes: '-',
|
|
||||||
date: '2025-12-23'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: '未命名的草稿',
|
|
||||||
genre: '京剧',
|
|
||||||
cover: '',
|
|
||||||
status: 'draft',
|
|
||||||
price: 0,
|
|
||||||
views: '-',
|
|
||||||
likes: '-',
|
|
||||||
date: '2025-12-26'
|
|
||||||
}
|
}
|
||||||
]);
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filteredList = computed(() => {
|
const fetchContents = async () => {
|
||||||
return list.value.filter(item => {
|
try {
|
||||||
const matchStatus = filterStatus.value === 'all' || item.status === filterStatus.value;
|
const params = {};
|
||||||
const matchGenre = filterGenre.value === 'all' || item.genre === filterGenre.value;
|
if (filterStatus.value !== 'all') params.status = filterStatus.value;
|
||||||
return matchStatus && matchGenre;
|
if (filterGenre.value !== 'all') params.genre = filterGenre.value;
|
||||||
});
|
if (searchKeyword.value) params.keyword = searchKeyword.value;
|
||||||
|
|
||||||
|
const res = await creatorApi.listContents(params);
|
||||||
|
contents.value = res || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchOptions();
|
||||||
|
fetchContents();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch([filterStatus, filterGenre], () => {
|
||||||
|
fetchContents();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
fetchContents();
|
||||||
|
};
|
||||||
|
|
||||||
const statusStyle = (status) => {
|
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.
|
||||||
|
|
||||||
|
const option = statusOptions.value.find(o => o.key === status);
|
||||||
|
const label = option ? option.value : status;
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'published': return { bg: 'bg-green-50', text: 'text-green-600', label: '已发布' };
|
case 'published': return { bg: 'bg-green-50', text: 'text-green-600', label };
|
||||||
case 'audit': return { bg: 'bg-orange-50', text: 'text-orange-600', label: '审核中' };
|
case 'reviewing': return { bg: 'bg-orange-50', text: 'text-orange-600', label };
|
||||||
case 'rejected': return { bg: 'bg-red-50', text: 'text-red-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 'draft': return { bg: 'bg-slate-100', text: 'text-slate-500', label };
|
||||||
default: 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 handleDelete = async (id) => {
|
||||||
|
if (!confirm('确定要删除吗?')) return;
|
||||||
|
try {
|
||||||
|
await creatorApi.deleteContent(id);
|
||||||
|
fetchContents();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
Reference in New Issue
Block a user