feat: 添加租户列表接口,优化租户相关功能;更新前端租户列表和收藏功能
This commit is contained in:
@@ -4,10 +4,10 @@ import (
|
||||
"quyun/v2/app/http/v1/dto"
|
||||
"quyun/v2/app/requests"
|
||||
"quyun/v2/app/services"
|
||||
"quyun/v2/database/models"
|
||||
"quyun/v2/pkg/consts"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// @provider
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
package dto
|
||||
|
||||
import "quyun/v2/app/requests"
|
||||
|
||||
type TenantListFilter struct {
|
||||
requests.Pagination
|
||||
Keyword *string `query:"keyword"`
|
||||
}
|
||||
|
||||
type TenantProfile struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -241,17 +241,15 @@ func (r *Routes) Register(router fiber.Router) {
|
||||
Body[dto.Settings]("form"),
|
||||
))
|
||||
// Register routes for controller: Storage
|
||||
r.log.Debugf("Registering route: Get /v1/storage/:key -> storage.Download")
|
||||
router.Get("/v1/storage/:key"[len(r.Path()):], Func3(
|
||||
r.log.Debugf("Registering route: Get /v1/storage/* -> storage.Download")
|
||||
router.Get("/v1/storage/*"[len(r.Path()):], Func2(
|
||||
r.storage.Download,
|
||||
PathParam[string]("key"),
|
||||
QueryParam[string]("expires"),
|
||||
QueryParam[string]("sign"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Put /v1/storage/:key -> storage.Upload")
|
||||
router.Put("/v1/storage/:key"[len(r.Path()):], DataFunc3(
|
||||
r.log.Debugf("Registering route: Put /v1/storage/* -> storage.Upload")
|
||||
router.Put("/v1/storage/*"[len(r.Path()):], DataFunc2(
|
||||
r.storage.Upload,
|
||||
PathParam[string]("key"),
|
||||
QueryParam[string]("expires"),
|
||||
QueryParam[string]("sign"),
|
||||
))
|
||||
@@ -268,6 +266,11 @@ func (r *Routes) Register(router fiber.Router) {
|
||||
PathParam[string]("id"),
|
||||
Query[dto.ContentListFilter]("filter"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Get /v1/tenants -> tenant.List")
|
||||
router.Get("/v1/tenants"[len(r.Path()):], DataFunc1(
|
||||
r.tenant.List,
|
||||
Query[dto.TenantListFilter]("filter"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Get /v1/tenants/:id -> tenant.Get")
|
||||
router.Get("/v1/tenants/:id"[len(r.Path()):], DataFunc2(
|
||||
r.tenant.Get,
|
||||
|
||||
@@ -17,19 +17,18 @@ type Storage struct {
|
||||
|
||||
// Upload file
|
||||
//
|
||||
// @Router /v1/storage/:key [put]
|
||||
// @Router /v1/storage/* [put]
|
||||
// @Summary Upload file
|
||||
// @Tags Storage
|
||||
// @Accept octet-stream
|
||||
// @Produce json
|
||||
// @Param key path string true "Object Key"
|
||||
// @Param expires query string true "Expiry"
|
||||
// @Param sign query string true "Signature"
|
||||
// @Success 200 {string} string "success"
|
||||
// @Bind key path key(key)
|
||||
// @Bind expires query
|
||||
// @Bind sign query
|
||||
func (s *Storage) Upload(ctx fiber.Ctx, key, expires, sign string) (string, error) {
|
||||
func (s *Storage) Upload(ctx fiber.Ctx, expires, sign string) (string, error) {
|
||||
key := ctx.Params("*")
|
||||
if err := s.storage.Verify("PUT", key, expires, sign); err != nil {
|
||||
return "", fiber.NewError(fiber.StatusForbidden, err.Error())
|
||||
}
|
||||
@@ -59,19 +58,18 @@ func (s *Storage) Upload(ctx fiber.Ctx, key, expires, sign string) (string, erro
|
||||
|
||||
// Download file
|
||||
//
|
||||
// @Router /v1/storage/:key [get]
|
||||
// @Router /v1/storage/* [get]
|
||||
// @Summary Download file
|
||||
// @Tags Storage
|
||||
// @Accept json
|
||||
// @Produce octet-stream
|
||||
// @Param key path string true "Object Key"
|
||||
// @Param expires query string true "Expiry"
|
||||
// @Param sign query string true "Signature"
|
||||
// @Success 200 {file} file
|
||||
// @Bind key path key(key)
|
||||
// @Bind expires query
|
||||
// @Bind sign query
|
||||
func (s *Storage) Download(ctx fiber.Ctx, key, expires, sign string) error {
|
||||
func (s *Storage) Download(ctx fiber.Ctx, expires, sign string) error {
|
||||
key := ctx.Params("*")
|
||||
if err := s.storage.Verify("GET", key, expires, sign); err != nil {
|
||||
return fiber.NewError(fiber.StatusForbidden, err.Error())
|
||||
}
|
||||
|
||||
@@ -34,6 +34,23 @@ func (t *Tenant) ListContents(ctx fiber.Ctx, id string, filter *dto.ContentListF
|
||||
return services.Content.List(ctx, filter)
|
||||
}
|
||||
|
||||
// List tenants (search)
|
||||
//
|
||||
// @Router /v1/tenants [get]
|
||||
// @Summary List tenants
|
||||
// @Description Search tenants
|
||||
// @Tags TenantPublic
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param keyword query string false "Keyword"
|
||||
// @Param page query int false "Page"
|
||||
// @Param limit query int false "Limit"
|
||||
// @Success 200 {object} requests.Pager
|
||||
// @Bind filter query
|
||||
func (t *Tenant) List(ctx fiber.Ctx, filter *dto.TenantListFilter) (*requests.Pager, error) {
|
||||
return services.Tenant.List(ctx, filter)
|
||||
}
|
||||
|
||||
// Get tenant public profile
|
||||
//
|
||||
// @Router /v1/tenants/:id [get]
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"quyun/v2/app/errorx"
|
||||
"quyun/v2/app/http/v1/dto"
|
||||
"quyun/v2/app/requests"
|
||||
"quyun/v2/database/models"
|
||||
"quyun/v2/pkg/consts"
|
||||
|
||||
@@ -18,6 +19,52 @@ import (
|
||||
// @provider
|
||||
type tenant struct{}
|
||||
|
||||
func (s *tenant) List(ctx context.Context, filter *dto.TenantListFilter) (*requests.Pager, error) {
|
||||
tbl, q := models.TenantQuery.QueryContext(ctx)
|
||||
q = q.Where(tbl.Status.Eq(consts.TenantStatusVerified))
|
||||
|
||||
if filter.Keyword != nil && *filter.Keyword != "" {
|
||||
q = q.Where(tbl.Name.Like("%" + *filter.Keyword + "%"))
|
||||
}
|
||||
|
||||
filter.Pagination.Format()
|
||||
total, err := q.Count()
|
||||
if err != nil {
|
||||
return nil, errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
|
||||
list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find()
|
||||
if err != nil {
|
||||
return nil, errorx.ErrDatabaseError.WithCause(err)
|
||||
}
|
||||
|
||||
var data []dto.TenantProfile
|
||||
for _, t := range list {
|
||||
followers, _ := models.TenantUserQuery.WithContext(ctx).Where(models.TenantUserQuery.TenantID.Eq(t.ID)).Count()
|
||||
contents, _ := models.ContentQuery.WithContext(ctx).
|
||||
Where(models.ContentQuery.TenantID.Eq(t.ID), models.ContentQuery.Status.Eq(consts.ContentStatusPublished)).
|
||||
Count()
|
||||
|
||||
cfg := t.Config.Data()
|
||||
data = append(data, dto.TenantProfile{
|
||||
ID: cast.ToString(t.ID),
|
||||
Name: t.Name,
|
||||
Avatar: cfg.Avatar,
|
||||
Bio: cfg.Bio,
|
||||
Stats: dto.Stats{
|
||||
Followers: int(followers),
|
||||
Contents: int(contents),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return &requests.Pager{
|
||||
Pagination: filter.Pagination,
|
||||
Total: total,
|
||||
Items: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *tenant) GetPublicProfile(ctx context.Context, userID int64, id string) (*dto.TenantProfile, error) {
|
||||
tid := cast.ToInt64(id)
|
||||
t, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.ID.Eq(tid)).First()
|
||||
|
||||
@@ -2,6 +2,10 @@ import { request } from '../utils/request';
|
||||
|
||||
export const tenantApi = {
|
||||
get: (id) => request(`/tenants/${id}`),
|
||||
list: (params) => {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return request(`/tenants?${qs}`);
|
||||
},
|
||||
follow: (id) => request(`/tenants/${id}/follow`, { method: 'POST' }),
|
||||
unfollow: (id) => request(`/tenants/${id}/follow`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
@@ -1,3 +1,49 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { contentApi } from '../../api/content';
|
||||
|
||||
const contents = ref([]);
|
||||
const searchKeyword = ref('');
|
||||
const loading = ref(true);
|
||||
const page = ref(1);
|
||||
const hasMore = ref(false);
|
||||
|
||||
const fetchContents = async (append = false) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
limit: 10,
|
||||
sort: 'latest',
|
||||
keyword: searchKeyword.value
|
||||
};
|
||||
const res = await contentApi.list(params);
|
||||
if (append) {
|
||||
contents.value.push(...(res.items || []));
|
||||
} else {
|
||||
contents.value = res.items || [];
|
||||
}
|
||||
hasMore.value = (res.total > contents.value.length);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
page.value = 1;
|
||||
fetchContents();
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
page.value++;
|
||||
fetchContents(true);
|
||||
};
|
||||
|
||||
onMounted(() => fetchContents());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-screen-xl py-8">
|
||||
<!-- Hero Banner -->
|
||||
@@ -30,13 +76,19 @@
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-8 border-b border-slate-200 pb-4 mb-4">
|
||||
<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 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">
|
||||
@@ -94,10 +146,11 @@
|
||||
</router-link>
|
||||
|
||||
<!-- Load More -->
|
||||
<div class="pt-4 text-center">
|
||||
<button
|
||||
class="px-8 py-3 bg-white border border-slate-200 rounded-full text-slate-600 hover:bg-slate-50 hover:text-primary-600 font-medium transition-all shadow-sm">
|
||||
点击加载更多内容
|
||||
<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>
|
||||
@@ -198,4 +251,34 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { contentApi } from '../api/content';
|
||||
|
||||
const contents = ref([]);
|
||||
const searchKeyword = ref('');
|
||||
const loading = ref(true);
|
||||
|
||||
const fetchContents = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
limit: 20,
|
||||
sort: 'latest',
|
||||
keyword: searchKeyword.value
|
||||
};
|
||||
const res = await contentApi.list(params);
|
||||
contents.value = res.items || [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
fetchContents();
|
||||
};
|
||||
|
||||
onMounted(fetchContents);
|
||||
</script>
|
||||
|
||||
@@ -24,11 +24,13 @@
|
||||
<div class="flex items-start gap-6">
|
||||
<div class="relative group cursor-pointer flex-shrink-0" @click="triggerUpload('avatar')">
|
||||
<div
|
||||
class="w-24 h-24 rounded-full border-4 border-slate-50 shadow-sm overflow-hidden bg-slate-100">
|
||||
class="w-24 h-24 rounded-full border-4 border-slate-50 shadow-sm overflow-hidden bg-slate-100 relative">
|
||||
<img :src="form.avatar" class="w-full h-full object-cover">
|
||||
<div
|
||||
class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<i class="pi pi-camera text-white text-2xl"></i>
|
||||
class="absolute inset-0 bg-black/40 flex items-center justify-center transition-opacity"
|
||||
:class="{'opacity-100': currentUploadType === 'avatar' && isUploading, 'opacity-0 group-hover:opacity-100': !(currentUploadType === 'avatar' && isUploading)}">
|
||||
<i v-if="currentUploadType === 'avatar' && isUploading" class="pi pi-spin pi-spinner text-white text-2xl"></i>
|
||||
<i v-else class="pi pi-camera text-white text-2xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,9 +64,14 @@
|
||||
<span class="text-sm font-medium">点击上传 (建议尺寸 1280x320)</span>
|
||||
</div>
|
||||
<!-- Hover Overlay -->
|
||||
<div v-if="form.cover"
|
||||
class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span class="text-white font-bold"><i class="pi pi-refresh mr-2"></i>更换封面</span>
|
||||
<div v-if="form.cover || (currentUploadType === 'cover' && isUploading)"
|
||||
class="absolute inset-0 bg-black/40 flex flex-col items-center justify-center transition-opacity"
|
||||
:class="{'opacity-100': currentUploadType === 'cover' && isUploading, 'opacity-0 group-hover:opacity-100': !(currentUploadType === 'cover' && isUploading)}">
|
||||
<template v-if="currentUploadType === 'cover' && isUploading">
|
||||
<i class="pi pi-spin pi-spinner text-white text-3xl mb-2"></i>
|
||||
<span class="text-white text-sm font-bold">上传中 {{ Math.round(uploadProgress) }}%</span>
|
||||
</template>
|
||||
<span v-else class="text-white font-bold"><i class="pi pi-refresh mr-2"></i>更换封面</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,9 +86,11 @@
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="pt-6 border-t border-slate-100 flex justify-end">
|
||||
<button @click="saveSettings"
|
||||
class="px-8 py-3 bg-primary-600 text-white rounded-lg font-bold hover:bg-primary-700 shadow-lg shadow-primary-200 cursor-pointer active:scale-95 flex items-center gap-2">
|
||||
<i class="pi pi-check"></i> 保存修改
|
||||
<button @click="saveSettings" :disabled="saveLoading"
|
||||
class="px-8 py-3 bg-primary-600 text-white rounded-lg font-bold hover:bg-primary-700 shadow-lg shadow-primary-200 cursor-pointer active:scale-95 flex items-center gap-2 disabled:opacity-70 disabled:cursor-not-allowed">
|
||||
<i v-if="saveLoading" class="pi pi-spin pi-spinner"></i>
|
||||
<i v-else class="pi pi-check"></i>
|
||||
{{ saveLoading ? '保存中...' : '保存修改' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,6 +202,9 @@ const fileInput = ref(null);
|
||||
const currentUploadType = ref('');
|
||||
const showAddAccount = ref(false);
|
||||
const payoutAccounts = ref([]);
|
||||
const saveLoading = ref(false);
|
||||
const isUploading = ref(false);
|
||||
const uploadProgress = ref(0);
|
||||
|
||||
const newAccount = reactive({
|
||||
type: 'bank',
|
||||
@@ -240,9 +252,13 @@ const handleFileChange = async (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
isUploading.value = true;
|
||||
uploadProgress.value = 0;
|
||||
|
||||
try {
|
||||
toast.add({ severity: 'info', summary: '正在上传...', life: 2000 });
|
||||
const task = commonApi.uploadWithProgress(file, 'image');
|
||||
const task = commonApi.uploadWithProgress(file, 'image', (p) => {
|
||||
uploadProgress.value = p;
|
||||
});
|
||||
const res = await task.promise;
|
||||
console.log('Upload response:', res);
|
||||
|
||||
@@ -260,6 +276,9 @@ const handleFileChange = async (event) => {
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.add({ severity: 'error', summary: '上传失败', detail: e.message, life: 3000 });
|
||||
} finally {
|
||||
isUploading.value = false;
|
||||
uploadProgress.value = 0;
|
||||
}
|
||||
event.target.value = '';
|
||||
};
|
||||
@@ -304,11 +323,15 @@ const saveSettings = async () => {
|
||||
toast.add({ severity: 'error', summary: '错误', detail: '频道名称不能为空', life: 3000 });
|
||||
return;
|
||||
}
|
||||
if (saveLoading.value) return;
|
||||
saveLoading.value = true;
|
||||
try {
|
||||
await creatorApi.updateSettings(form);
|
||||
toast.add({ severity: 'success', summary: '保存成功', detail: '设置已更新', life: 3000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: '保存失败', detail: e.message, life: 3000 });
|
||||
} finally {
|
||||
saveLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -15,8 +15,10 @@
|
||||
<div class="aspect-video bg-slate-100 relative overflow-hidden">
|
||||
<img :src="item.cover" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105">
|
||||
<div class="absolute bottom-2 left-2 px-1.5 py-0.5 bg-black/60 text-white text-xs rounded flex items-center gap-1">
|
||||
<i class="pi" :class="item.type === 'video' ? 'pi-play-circle' : 'pi-book'"></i>
|
||||
<span>{{ item.duration || '专栏' }}</span>
|
||||
<i class="pi" :class="item.type === 'video' ? 'pi-play-circle' : (item.type === 'audio' ? 'pi-volume-up' : 'pi-book')"></i>
|
||||
<span v-if="item.type === 'video'">视频</span>
|
||||
<span v-else-if="item.type === 'audio'">音频</span>
|
||||
<span v-else>文章</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,11 +26,11 @@
|
||||
<div class="p-4">
|
||||
<h3 class="font-bold text-slate-900 mb-2 line-clamp-2 group-hover:text-primary-600 transition-colors">{{ item.title }}</h3>
|
||||
<div class="flex items-center gap-2 text-xs text-slate-500 mb-3">
|
||||
<img :src="item.authorAvatar" class="w-5 h-5 rounded-full">
|
||||
<span>{{ item.author }}</span>
|
||||
<img :src="item.author_avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${item.author_id}`" class="w-5 h-5 rounded-full">
|
||||
<span>{{ item.author_name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-slate-400 border-t border-slate-50 pt-3">
|
||||
<span>{{ item.time }}</span>
|
||||
<span>{{ item.created_at }}</span>
|
||||
<button @click.stop="removeItem(item.id)" class="hover:text-red-500 flex items-center gap-1 transition-colors"><i class="pi pi-heart-fill text-red-500"></i> 取消收藏</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,46 +51,37 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import Toast from 'primevue/toast';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { userApi } from '../../api/user';
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const items = ref([]);
|
||||
const loading = ref(true);
|
||||
|
||||
const items = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: '程派《荒山泪》夜织选段:沉浸式视听体验',
|
||||
cover: 'https://images.unsplash.com/photo-1516450360452-9312f5e86fc7?ixlib=rb-1.2.1&auto=format&fit=crop&w=300&q=60',
|
||||
author: '梅派传人小林',
|
||||
authorAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Master1',
|
||||
type: 'video',
|
||||
duration: '12:40',
|
||||
time: '2天前收藏'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '京剧脸谱颜色的含义深度解析',
|
||||
cover: 'https://images.unsplash.com/photo-1533174072545-e8d4aa97edf9?ixlib=rb-1.2.1&auto=format&fit=crop&w=300&q=60',
|
||||
author: '戏曲学院官方',
|
||||
authorAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=School',
|
||||
type: 'article',
|
||||
time: '1周前收藏'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '经典唱段合集:名家名段欣赏',
|
||||
cover: 'https://images.unsplash.com/photo-1469571486292-0ba58a3f068b?ixlib=rb-1.2.1&auto=format&fit=crop&w=300&q=60',
|
||||
author: 'CCTV 戏曲',
|
||||
authorAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=TV',
|
||||
type: 'video',
|
||||
duration: '45:00',
|
||||
time: '1个月前收藏'
|
||||
const fetchFavorites = async () => {
|
||||
try {
|
||||
const res = await userApi.getFavorites();
|
||||
items.value = res || [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
const removeItem = (id) => {
|
||||
items.value = items.value.filter(i => i.id !== id);
|
||||
toast.add({ severity: 'success', summary: '已取消收藏', life: 2000 });
|
||||
onMounted(fetchFavorites);
|
||||
|
||||
const removeItem = async (id) => {
|
||||
try {
|
||||
await userApi.removeFavorite(id);
|
||||
items.value = items.value.filter(i => i.id !== id);
|
||||
toast.add({ severity: 'success', summary: '已取消收藏', life: 2000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: '操作失败', detail: e.message, life: 3000 });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user