feat: 添加租户列表接口,优化租户相关功能;更新前端租户列表和收藏功能

This commit is contained in:
2026-01-07 16:10:03 +08:00
parent 1298192157
commit 5b45f7d5c4
10 changed files with 252 additions and 77 deletions

View File

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

View File

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

View File

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

View File

@@ -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())
}

View File

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

View File

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

View File

@@ -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' }),
};

View File

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

View File

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

View File

@@ -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) => {
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>