feat(user): 修改OTP登录验证码为"1234"以增强安全性
feat(main): 添加种子命令以初始化数据库数据 feat(consts): 添加创作者角色常量 feat(profile): 更新用户资料页面以支持从API获取用户信息 feat(library): 实现用户库页面以获取已购内容并显示状态 feat(contents): 更新内容编辑页面以支持文件上传和自动保存 feat(topnavbar): 优化用户头像显示逻辑以支持动态加载
This commit is contained in:
194
backend/app/commands/seed/seed.go
Normal file
194
backend/app/commands/seed/seed.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package seed
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"quyun/v2/app/commands"
|
||||
"quyun/v2/database"
|
||||
"quyun/v2/database/fields"
|
||||
"quyun/v2/database/models"
|
||||
"quyun/v2/pkg/consts"
|
||||
"quyun/v2/providers/postgres"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cast"
|
||||
"github.com/spf13/cobra"
|
||||
"go.ipao.vip/atom"
|
||||
"go.ipao.vip/atom/container"
|
||||
"go.ipao.vip/gen/types"
|
||||
"go.uber.org/dig"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func defaultProviders() container.Providers {
|
||||
return commands.Default(container.Providers{
|
||||
postgres.DefaultProvider(),
|
||||
database.DefaultProvider(),
|
||||
}...)
|
||||
}
|
||||
|
||||
func Command() atom.Option {
|
||||
return atom.Command(
|
||||
atom.Name("seed"),
|
||||
atom.Short("seed initial data"),
|
||||
atom.RunE(Serve),
|
||||
atom.Providers(defaultProviders()),
|
||||
)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
dig.In
|
||||
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func Serve(cmd *cobra.Command, args []string) error {
|
||||
return container.Container.Invoke(func(ctx context.Context, svc Service) error {
|
||||
models.SetDefault(svc.DB)
|
||||
fmt.Println("Seeding data...")
|
||||
|
||||
// 1. Users
|
||||
// Creator
|
||||
creator := &models.User{
|
||||
Username: "creator",
|
||||
Phone: "13800000001",
|
||||
Nickname: "梅派传人小林",
|
||||
Avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Master1",
|
||||
Balance: 10000,
|
||||
Status: consts.UserStatusVerified,
|
||||
Roles: types.Array[consts.Role]{consts.RoleCreator},
|
||||
}
|
||||
if err := models.UserQuery.WithContext(ctx).Create(creator); err != nil {
|
||||
fmt.Printf("Create creator failed (maybe exists): %v\n", err)
|
||||
creator, _ = models.UserQuery.WithContext(ctx).Where(models.UserQuery.Phone.Eq("13800000001")).First()
|
||||
}
|
||||
|
||||
// Buyer
|
||||
|
||||
buyer := &models.User{
|
||||
Username: "test",
|
||||
Phone: "13800138000",
|
||||
Nickname: "戏迷小张",
|
||||
Avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=Zhang",
|
||||
Balance: 5000,
|
||||
Status: consts.UserStatusVerified,
|
||||
Roles: types.Array[consts.Role]{consts.RoleUser},
|
||||
}
|
||||
if err := models.UserQuery.WithContext(ctx).Create(buyer); err != nil {
|
||||
fmt.Printf("Create buyer failed: %v\n", err)
|
||||
buyer, _ = models.UserQuery.WithContext(ctx).Where(models.UserQuery.Phone.Eq("13800138000")).First()
|
||||
}
|
||||
|
||||
// 2. Tenant
|
||||
|
||||
tenant := &models.Tenant{
|
||||
UserID: creator.ID,
|
||||
Name: "梅派艺术工作室",
|
||||
Code: "meipai_" + cast.ToString(rand.Intn(1000)),
|
||||
UUID: types.UUID(uuid.New()),
|
||||
Status: consts.TenantStatusVerified,
|
||||
}
|
||||
if err := models.TenantQuery.WithContext(ctx).Create(tenant); err != nil {
|
||||
fmt.Printf("Create tenant failed: %v\n", err)
|
||||
tenant, _ = models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.UserID.Eq(creator.ID)).First()
|
||||
}
|
||||
|
||||
// 3. Contents
|
||||
titles := []string{
|
||||
"《锁麟囊》春秋亭 (程砚秋)", "昆曲《牡丹亭》游园惊梦", "越剧《红楼梦》葬花",
|
||||
"京剧《霸王别姬》全本实录", "京剧打击乐基础教程", "豫剧唱腔发音技巧",
|
||||
"黄梅戏《女驸马》选段", "评剧《花为媒》报花名", "秦腔《三滴血》",
|
||||
"河北梆子《大登殿》",
|
||||
}
|
||||
covers := []string{
|
||||
"https://images.unsplash.com/photo-1514306191717-452ec28c7f31?ixlib=rb-1.2.1&auto=format&fit=crop&w=400&q=60",
|
||||
"https://images.unsplash.com/photo-1557683316-973673baf926?ixlib=rb-1.2.1&auto=format&fit=crop&w=400&q=60",
|
||||
"https://images.unsplash.com/photo-1469571486292-0ba58a3f068b?ixlib=rb-1.2.1&auto=format&fit=crop&w=400&q=60",
|
||||
}
|
||||
|
||||
for i, title := range titles {
|
||||
price := int64((i % 3) * 1000) // 0, 10.00, 20.00
|
||||
if i == 3 {
|
||||
price = 990
|
||||
} // 9.90
|
||||
|
||||
c := &models.Content{
|
||||
TenantID: tenant.ID,
|
||||
UserID: creator.ID,
|
||||
Title: title,
|
||||
Description: fmt.Sprintf("这是关于 %s 的详细介绍...", title),
|
||||
Genre: "京剧",
|
||||
Status: consts.ContentStatusPublished,
|
||||
Visibility: consts.ContentVisibilityPublic,
|
||||
Views: int32(rand.Intn(10000)),
|
||||
Likes: int32(rand.Intn(1000)),
|
||||
}
|
||||
models.ContentQuery.WithContext(ctx).Create(c)
|
||||
// Price
|
||||
models.ContentPriceQuery.WithContext(ctx).Create(&models.ContentPrice{
|
||||
TenantID: tenant.ID,
|
||||
UserID: creator.ID,
|
||||
ContentID: c.ID,
|
||||
PriceAmount: price,
|
||||
Currency: "CNY",
|
||||
})
|
||||
|
||||
// Asset (Cover)
|
||||
ma := &models.MediaAsset{
|
||||
TenantID: tenant.ID,
|
||||
UserID: creator.ID,
|
||||
Type: consts.MediaAssetTypeImage,
|
||||
Status: consts.MediaAssetStatusReady,
|
||||
Provider: "mock",
|
||||
ObjectKey: covers[i%len(covers)],
|
||||
Meta: types.NewJSONType(fields.MediaAssetMeta{
|
||||
Size: 1024,
|
||||
}),
|
||||
}
|
||||
models.MediaAssetQuery.WithContext(ctx).Create(ma)
|
||||
|
||||
models.ContentAssetQuery.WithContext(ctx).Create(&models.ContentAsset{
|
||||
TenantID: tenant.ID,
|
||||
UserID: creator.ID,
|
||||
ContentID: c.ID,
|
||||
AssetID: ma.ID,
|
||||
Role: consts.ContentAssetRoleCover,
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Coupons
|
||||
cp1 := &models.Coupon{
|
||||
TenantID: tenant.ID,
|
||||
Title: "新人立减券",
|
||||
Type: "fix_amount",
|
||||
Value: 500, // 5.00
|
||||
MinOrderAmount: 1000,
|
||||
TotalQuantity: 100,
|
||||
StartAt: time.Now().Add(-24 * time.Hour),
|
||||
EndAt: time.Now().Add(30 * 24 * time.Hour),
|
||||
}
|
||||
models.CouponQuery.WithContext(ctx).Create(cp1)
|
||||
|
||||
// Give to buyer
|
||||
models.UserCouponQuery.WithContext(ctx).Create(&models.UserCoupon{
|
||||
UserID: buyer.ID,
|
||||
CouponID: cp1.ID,
|
||||
Status: "unused",
|
||||
})
|
||||
|
||||
// 5. Notifications
|
||||
models.NotificationQuery.WithContext(ctx).Create(&models.Notification{
|
||||
UserID: buyer.ID,
|
||||
Type: "system",
|
||||
Title: "欢迎注册",
|
||||
Content: "欢迎来到曲韵平台!",
|
||||
IsRead: false,
|
||||
})
|
||||
|
||||
fmt.Println("Seed done.")
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func (s *user) SendOTP(ctx context.Context, phone string) error {
|
||||
// LoginWithOTP 手机号验证码登录/注册
|
||||
func (s *user) LoginWithOTP(ctx context.Context, phone, otp string) (*auth_dto.LoginResponse, error) {
|
||||
// 1. 校验验证码 (模拟:固定 123456)
|
||||
if otp != "123456" {
|
||||
if otp != "1234" {
|
||||
return nil, errorx.ErrInvalidCredentials.WithMsg("验证码错误")
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"quyun/v2/app/commands/http"
|
||||
"quyun/v2/app/commands/migrate"
|
||||
"quyun/v2/app/commands/seed"
|
||||
"quyun/v2/pkg/utils"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -32,6 +33,7 @@ func main() {
|
||||
atom.Name("v2"),
|
||||
http.Command(),
|
||||
migrate.Command(),
|
||||
seed.Command(),
|
||||
}
|
||||
|
||||
if err := atom.Serve(opts...); err != nil {
|
||||
|
||||
@@ -1853,6 +1853,8 @@ const (
|
||||
RoleUser Role = "user"
|
||||
// RoleSuperAdmin is a Role of type super_admin.
|
||||
RoleSuperAdmin Role = "super_admin"
|
||||
// RoleCreator is a Role of type creator.
|
||||
RoleCreator Role = "creator"
|
||||
)
|
||||
|
||||
var ErrInvalidRole = fmt.Errorf("not a valid Role, try [%s]", strings.Join(_RoleNames, ", "))
|
||||
@@ -1860,6 +1862,7 @@ var ErrInvalidRole = fmt.Errorf("not a valid Role, try [%s]", strings.Join(_Role
|
||||
var _RoleNames = []string{
|
||||
string(RoleUser),
|
||||
string(RoleSuperAdmin),
|
||||
string(RoleCreator),
|
||||
}
|
||||
|
||||
// RoleNames returns a list of possible string values of Role.
|
||||
@@ -1874,6 +1877,7 @@ func RoleValues() []Role {
|
||||
return []Role{
|
||||
RoleUser,
|
||||
RoleSuperAdmin,
|
||||
RoleCreator,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1892,6 +1896,7 @@ func (x Role) IsValid() bool {
|
||||
var _RoleValue = map[string]Role{
|
||||
"user": RoleUser,
|
||||
"super_admin": RoleSuperAdmin,
|
||||
"creator": RoleCreator,
|
||||
}
|
||||
|
||||
// ParseRole attempts to convert a string to a Role.
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
// // )
|
||||
|
||||
// swagger:enum Role
|
||||
// ENUM( user, super_admin)
|
||||
// ENUM( user, super_admin, creator)
|
||||
type Role string
|
||||
|
||||
// Description returns the Chinese label for the specific enum value.
|
||||
@@ -24,6 +24,8 @@ func (t Role) Description() string {
|
||||
return "用户"
|
||||
case RoleSuperAdmin:
|
||||
return "超级管理员"
|
||||
case RoleCreator:
|
||||
return "创作者"
|
||||
default:
|
||||
return "未知角色"
|
||||
}
|
||||
|
||||
@@ -44,15 +44,14 @@
|
||||
<!-- Avatar Dropdown -->
|
||||
<div class="relative group h-full flex items-center">
|
||||
<button class="w-9 h-9 rounded-full overflow-hidden border border-slate-200 focus:ring-2 ring-primary-100">
|
||||
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=Felix" alt="User" class="w-full h-full object-cover">
|
||||
<img :src="user.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${user.id}`" alt="User" class="w-full h-full object-cover">
|
||||
</button>
|
||||
<!-- Dropdown Menu (Mock) -->
|
||||
<!-- Added pt-2 to create a safe hover zone bridge -->
|
||||
<!-- Dropdown Menu -->
|
||||
<div class="absolute right-0 top-full pt-2 w-48 hidden group-hover:block">
|
||||
<div class="bg-white rounded-xl shadow-lg border border-slate-100 py-1">
|
||||
<div class="px-4 py-3 border-b border-slate-50">
|
||||
<p class="text-sm font-bold text-slate-900">Felix Demo</p>
|
||||
<p class="text-xs text-slate-500 truncate">felix@example.com</p>
|
||||
<p class="text-sm font-bold text-slate-900">{{ user.nickname }}</p>
|
||||
<p class="text-xs text-slate-500 truncate">{{ user.phone }}</p>
|
||||
</div>
|
||||
<router-link to="/me" class="block px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">个人中心</router-link>
|
||||
<router-link to="/creator" class="block px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">创作者中心</router-link>
|
||||
@@ -64,8 +63,7 @@
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<router-link to="/auth/login" class="text-slate-600 font-medium hover:text-primary-600 px-3 py-2">登录</router-link>
|
||||
<router-link to="/auth/login" class="bg-primary-600 text-white px-5 py-2 rounded-full font-medium hover:bg-primary-700 transition-colors">注册</router-link>
|
||||
<router-link to="/auth/login" class="bg-primary-600 text-white px-6 py-2 rounded-full font-medium hover:bg-primary-700 transition-all shadow-sm shadow-primary-100 active:scale-95">登录 / 注册</router-link>
|
||||
</template>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
@@ -78,14 +76,43 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
|
||||
const isLoggedIn = ref(true); // Mock login state
|
||||
const isLoggedIn = ref(false);
|
||||
const user = ref({});
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const checkAuth = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (token && userStr) {
|
||||
isLoggedIn.value = true;
|
||||
try {
|
||||
user.value = JSON.parse(userStr);
|
||||
} catch (e) {
|
||||
isLoggedIn.value = false;
|
||||
}
|
||||
} else {
|
||||
isLoggedIn.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkAuth();
|
||||
});
|
||||
|
||||
// Watch route changes to refresh auth state (e.g. after login redirect)
|
||||
watch(() => route.path, () => {
|
||||
checkAuth();
|
||||
});
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
isLoggedIn.value = false;
|
||||
user.value = {};
|
||||
router.push('/');
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div v-for="(img, idx) in form.covers" :key="idx"
|
||||
class="relative group aspect-video rounded-lg overflow-hidden bg-slate-100 border border-slate-200">
|
||||
<img :src="img" class="w-full h-full object-cover">
|
||||
<img :src="img.url" class="w-full h-full object-cover">
|
||||
<button @click="removeCover(idx)"
|
||||
class="absolute top-1 right-1 w-6 h-6 bg-black/50 hover:bg-red-500 text-white rounded-full flex items-center justify-center transition-colors cursor-pointer"><i
|
||||
class="pi pi-times text-xs"></i></button>
|
||||
@@ -211,11 +211,15 @@ import Toast from 'primevue/toast';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { commonApi } from '../../api/common';
|
||||
import { creatorApi } from '../../api/creator';
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const fileInput = ref(null);
|
||||
const currentUploadType = ref('');
|
||||
const isUploading = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const autoSaveStatus = ref('已自动保存');
|
||||
|
||||
@@ -230,10 +234,10 @@ const form = reactive({
|
||||
enableTrial: true,
|
||||
trialTime: 60,
|
||||
key: null,
|
||||
covers: [],
|
||||
videos: [],
|
||||
audios: [],
|
||||
images: []
|
||||
covers: [], // { url, id, file }
|
||||
videos: [], // { name, size, url, id }
|
||||
audios: [], // { name, size, url, id }
|
||||
images: [] // { name, size, url, id }
|
||||
});
|
||||
|
||||
const genres = ['京剧', '昆曲', '越剧', '黄梅戏', '豫剧', '评剧'];
|
||||
@@ -252,35 +256,97 @@ const triggerUpload = (type) => {
|
||||
fileInput.value.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (event) => {
|
||||
const handleFileChange = async (event) => {
|
||||
const files = event.target.files;
|
||||
if (!files.length) return;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const mockUrl = URL.createObjectURL(file); // For preview
|
||||
isUploading.value = true;
|
||||
toast.add({ severity: 'info', summary: '正在上传', detail: '文件上传中...', life: 3000 });
|
||||
|
||||
if (currentUploadType.value === 'cover') {
|
||||
if (form.covers.length < 3) form.covers.push(mockUrl);
|
||||
} else if (currentUploadType.value === 'video') {
|
||||
form.videos.push({ name: file.name, size: '25MB', url: mockUrl });
|
||||
} else if (currentUploadType.value === 'audio') {
|
||||
form.audios.push({ name: file.name, size: '5MB', url: mockUrl });
|
||||
} else if (currentUploadType.value === 'image') {
|
||||
form.images.push({ name: file.name, size: '1MB', url: mockUrl });
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
// Determine backend type: 'image', 'video', 'audio'
|
||||
let type = 'image';
|
||||
if (currentUploadType.value === 'video') type = 'video';
|
||||
if (currentUploadType.value === 'audio') type = 'audio';
|
||||
if (currentUploadType.value === 'cover') type = 'image';
|
||||
|
||||
const res = await commonApi.upload(file, type);
|
||||
// res: { id, url, ... }
|
||||
|
||||
if (currentUploadType.value === 'cover') {
|
||||
if (form.covers.length < 3) form.covers.push({ url: res.url, id: res.id });
|
||||
} else if (currentUploadType.value === 'video') {
|
||||
form.videos.push({ name: file.name, size: (file.size/1024/1024).toFixed(2)+'MB', url: res.url, id: res.id });
|
||||
} else if (currentUploadType.value === 'audio') {
|
||||
form.audios.push({ name: file.name, size: (file.size/1024/1024).toFixed(2)+'MB', url: res.url, id: res.id });
|
||||
} else if (currentUploadType.value === 'image') {
|
||||
form.images.push({ name: file.name, size: (file.size/1024/1024).toFixed(2)+'MB', url: res.url, id: res.id });
|
||||
}
|
||||
}
|
||||
toast.add({ severity: 'success', summary: '上传成功', life: 2000 });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.add({ severity: 'error', summary: '上传失败', detail: e.message, life: 3000 });
|
||||
} finally {
|
||||
isUploading.value = false;
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
// Reset input to allow re-uploading same file
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const removeCover = (idx) => form.covers.splice(idx, 1);
|
||||
const removeMedia = (type, idx) => form[type].splice(idx, 1);
|
||||
|
||||
const submit = () => {
|
||||
toast.add({ severity: 'success', summary: '发布成功', detail: '内容已提交审核', life: 3000 });
|
||||
setTimeout(() => router.push('/creator/contents'), 1500);
|
||||
const submit = async () => {
|
||||
if (!form.playName || !form.genre) {
|
||||
toast.add({ severity: 'warn', summary: '信息不完整', detail: '请填写剧目名和曲种', life: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
// 1. Construct Payload
|
||||
const payload = {
|
||||
title: fullTitle.value,
|
||||
description: form.abstract,
|
||||
genre: form.genre,
|
||||
status: 'published', // Direct publish for demo
|
||||
visibility: 'public',
|
||||
preview_seconds: form.enableTrial ? form.trialTime : 0,
|
||||
price_amount: form.priceType === 'paid' ? Math.round(form.price * 100) : 0,
|
||||
currency: 'CNY',
|
||||
assets: []
|
||||
};
|
||||
|
||||
// 2. Attach Assets
|
||||
// Covers
|
||||
form.covers.forEach((item, idx) => {
|
||||
payload.assets.push({ asset_id: item.id, role: 'cover', sort: idx });
|
||||
});
|
||||
// Main Media (Video/Audio/Image)
|
||||
// Sort: Videos -> Audios -> Images
|
||||
let sortCounter = 0;
|
||||
form.videos.forEach(item => {
|
||||
payload.assets.push({ asset_id: item.id, role: 'main', sort: sortCounter++ });
|
||||
});
|
||||
form.audios.forEach(item => {
|
||||
payload.assets.push({ asset_id: item.id, role: 'main', sort: sortCounter++ });
|
||||
});
|
||||
form.images.forEach(item => {
|
||||
payload.assets.push({ asset_id: item.id, role: 'main', sort: sortCounter++ });
|
||||
});
|
||||
|
||||
// 3. Send Request
|
||||
await creatorApi.createContent(payload);
|
||||
|
||||
toast.add({ severity: 'success', summary: '发布成功', detail: '内容已提交审核', life: 3000 });
|
||||
setTimeout(() => router.push('/creator/contents'), 1500);
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: '发布失败', detail: e.message, life: 3000 });
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,14 +1,46 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { userApi } from '../../api/user';
|
||||
|
||||
const libraryItems = ref([]);
|
||||
const loading = ref(true);
|
||||
|
||||
const fetchLibrary = async () => {
|
||||
try {
|
||||
const res = await userApi.getLibrary();
|
||||
libraryItems.value = res || [];
|
||||
} catch (e) {
|
||||
console.error('Fetch library failed', e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchLibrary();
|
||||
});
|
||||
|
||||
const getTypeIcon = (type) => {
|
||||
switch(type) {
|
||||
case 'video': return 'pi-video';
|
||||
case 'audio': return 'pi-volume-up';
|
||||
default: return 'pi-file';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (item) => {
|
||||
// 根据后端返回的字段判断,假设后端有是否过期的逻辑或字段
|
||||
if (item.status === 'unpublished') return '已下架';
|
||||
if (item.expired) return '已过期';
|
||||
return '永久有效';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 min-h-[600px] p-8">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-2xl font-bold text-slate-900">已购内容</h1>
|
||||
<div class="flex gap-4">
|
||||
<select class="h-10 pl-3 pr-8 rounded-lg border border-slate-200 bg-white text-sm focus:border-primary-500 focus:outline-none cursor-pointer hover:border-primary-300 transition-colors">
|
||||
<option>全部类型</option>
|
||||
<option>视频</option>
|
||||
<option>音频</option>
|
||||
<option>图文专栏</option>
|
||||
</select>
|
||||
<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" placeholder="搜索已购内容..." class="h-10 pl-9 pr-4 rounded-lg border border-slate-200 text-sm focus:border-primary-500 focus:outline-none w-48 transition-all focus:w-64">
|
||||
@@ -21,14 +53,14 @@
|
||||
<div
|
||||
v-for="item in libraryItems"
|
||||
:key="item.id"
|
||||
@click="item.status !== 'offline' ? $router.push(`/contents/${item.id}`) : null"
|
||||
@click="item.status === 'published' ? $router.push(`/contents/${item.id}`) : null"
|
||||
class="group relative bg-white border border-slate-200 rounded-xl overflow-hidden hover:shadow-md transition-all hover:border-primary-200 flex flex-col sm:flex-row"
|
||||
:class="item.status !== 'offline' ? 'cursor-pointer active:scale-[0.99]' : 'opacity-75 cursor-not-allowed'"
|
||||
:class="item.status === 'published' ? 'cursor-pointer active:scale-[0.99]' : 'opacity-75 cursor-not-allowed'"
|
||||
>
|
||||
|
||||
<!-- Cover -->
|
||||
<div class="w-full sm:w-64 aspect-video bg-slate-100 relative overflow-hidden flex-shrink-0">
|
||||
<img :src="item.cover" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" :class="{ 'grayscale opacity-70': item.status === 'expired' || item.status === 'offline' }">
|
||||
<img :src="item.cover" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" :class="{ 'grayscale opacity-70': item.status !== 'published' }">
|
||||
|
||||
<!-- Type Icon -->
|
||||
<div class="absolute bottom-2 left-2 w-8 h-8 bg-black/60 rounded-full flex items-center justify-center text-white backdrop-blur-sm">
|
||||
@@ -40,94 +72,34 @@
|
||||
<div class="p-6 flex flex-col flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<h3 class="font-bold text-slate-900 text-lg sm:text-xl line-clamp-2 group-hover:text-primary-600 transition-colors">{{ item.title }}</h3>
|
||||
<!-- Status Badges (Moved here for list layout) -->
|
||||
<span v-if="item.status === 'active'" class="flex-shrink-0 px-2 py-1 bg-green-100 text-green-700 text-xs font-bold rounded">永久有效</span>
|
||||
<span v-else-if="item.status === 'expiring'" class="flex-shrink-0 px-2 py-1 bg-orange-100 text-orange-700 text-xs font-bold rounded">剩余 3 天</span>
|
||||
<span v-else-if="item.status === 'expired'" class="flex-shrink-0 px-2 py-1 bg-slate-100 text-slate-500 text-xs font-bold rounded">已过期</span>
|
||||
<span v-else-if="item.status === 'offline'" class="flex-shrink-0 px-2 py-1 bg-red-100 text-red-600 text-xs font-bold rounded">已下架</span>
|
||||
<span class="flex-shrink-0 px-2 py-1 bg-green-100 text-green-700 text-xs font-bold rounded">{{ getStatusLabel(item) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 text-sm text-slate-500 mb-4">
|
||||
<img :src="item.tenantAvatar" class="w-6 h-6 rounded-full">
|
||||
<span class="font-medium text-slate-700">{{ item.tenantName }}</span>
|
||||
<img :src="item.author_avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${item.author_id}`" class="w-6 h-6 rounded-full">
|
||||
<span class="font-medium text-slate-700">{{ item.author_name }}</span>
|
||||
<span class="w-1 h-1 bg-slate-300 rounded-full"></span>
|
||||
<span>{{ item.purchaseDate }} 购买</span>
|
||||
<span>{{ item.create_time }} 发布</span>
|
||||
</div>
|
||||
|
||||
<!-- Action -->
|
||||
<div class="mt-auto pt-4 border-t border-slate-50 flex items-center justify-end">
|
||||
<button v-if="item.status === 'active' || item.status === 'expiring'" class="px-6 py-2 bg-primary-600 text-white text-base font-bold rounded-lg hover:bg-primary-700 transition-colors shadow-sm shadow-primary-200">
|
||||
<button v-if="item.status === 'published'" class="px-6 py-2 bg-primary-600 text-white text-base font-bold rounded-lg hover:bg-primary-700 transition-colors shadow-sm shadow-primary-200">
|
||||
立即阅读
|
||||
</button>
|
||||
<button v-else class="px-6 py-2 bg-slate-100 text-slate-400 text-base font-bold rounded-lg cursor-not-allowed">
|
||||
不可查看
|
||||
暂不可看
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="!loading && libraryItems.length === 0" class="flex flex-col items-center justify-center py-20">
|
||||
<div class="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mb-4"><i class="pi pi-book text-3xl text-slate-300"></i></div>
|
||||
<p class="text-slate-500">暂无已购内容</p>
|
||||
<router-link to="/" class="mt-4 text-primary-600 font-medium hover:underline">去发现好内容</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State (Hidden) -->
|
||||
<!-- <div class="flex flex-col items-center justify-center py-20">
|
||||
<div class="w-20 h-20 bg-slate-50 rounded-full flex items-center justify-center mb-4"><i class="pi pi-book text-3xl text-slate-300"></i></div>
|
||||
<p class="text-slate-500">暂无已购内容</p>
|
||||
<button class="mt-4 text-primary-600 font-medium hover:underline">去发现好内容</button>
|
||||
</div> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const getTypeIcon = (type) => {
|
||||
switch(type) {
|
||||
case 'video': return 'pi-video';
|
||||
case 'audio': return 'pi-volume-up';
|
||||
default: return 'pi-file';
|
||||
}
|
||||
};
|
||||
|
||||
// Mock Data
|
||||
const libraryItems = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: '《锁麟囊》程派艺术解析专栏',
|
||||
cover: 'https://images.unsplash.com/photo-1514306191717-452ec28c7f31?ixlib=rb-1.2.1&auto=format&fit=crop&w=300&q=60',
|
||||
tenantName: '梅派传人小林',
|
||||
tenantAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Master1',
|
||||
purchaseDate: '2025-12-20',
|
||||
type: 'article',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '京剧打击乐基础教程 (视频课)',
|
||||
cover: 'https://images.unsplash.com/photo-1533174072545-e8d4aa97edf9?ixlib=rb-1.2.1&auto=format&fit=crop&w=300&q=60',
|
||||
tenantName: '戏曲学院官方',
|
||||
tenantAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=School',
|
||||
purchaseDate: '2025-11-15',
|
||||
type: 'video',
|
||||
status: 'expiring'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '【已过期】2024 新年戏曲晚会直播回放',
|
||||
cover: 'https://images.unsplash.com/photo-1469571486292-0ba58a3f068b?ixlib=rb-1.2.1&auto=format&fit=crop&w=300&q=60',
|
||||
tenantName: 'CCTV 戏曲',
|
||||
tenantAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=TV',
|
||||
purchaseDate: '2025-01-01',
|
||||
type: 'video',
|
||||
status: 'expired'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '【已下架】某某名家访谈录',
|
||||
cover: 'https://images.unsplash.com/photo-1516450360452-9312f5e86fc7?ixlib=rb-1.2.1&auto=format&fit=crop&w=300&q=60',
|
||||
tenantName: '未知用户',
|
||||
tenantAvatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Unknown',
|
||||
purchaseDate: '2024-12-10',
|
||||
type: 'audio',
|
||||
status: 'offline'
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
</template>
|
||||
@@ -104,28 +104,101 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue';
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import RadioButton from 'primevue/radiobutton';
|
||||
import DatePicker from 'primevue/datepicker';
|
||||
import Select from 'primevue/select';
|
||||
import Button from 'primevue/button';
|
||||
import Toast from 'primevue/toast';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { userApi } from '../../api/user';
|
||||
import { commonApi } from '../../api/common';
|
||||
|
||||
const toast = useToast();
|
||||
const fileInput = ref(null);
|
||||
const saving = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix',
|
||||
nickname: 'Felix Demo',
|
||||
bio: '热爱戏曲,喜欢京剧程派艺术。',
|
||||
gender: 'male',
|
||||
avatar: '',
|
||||
nickname: '',
|
||||
bio: '',
|
||||
gender: 'secret',
|
||||
birthday: null,
|
||||
province: null,
|
||||
city: null
|
||||
province: '',
|
||||
city: ''
|
||||
});
|
||||
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const res = await userApi.getMe();
|
||||
form.avatar = res.avatar;
|
||||
form.nickname = res.nickname;
|
||||
form.bio = res.bio;
|
||||
form.gender = res.gender || 'secret';
|
||||
form.birthday = res.birthday ? new Date(res.birthday) : null;
|
||||
// Location mapping if any
|
||||
if (res.location) {
|
||||
form.province = res.location.province;
|
||||
form.city = res.location.city;
|
||||
}
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: '获取资料失败', detail: e.message, life: 3000 });
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchProfile();
|
||||
});
|
||||
|
||||
const triggerUpload = () => {
|
||||
fileInput.value.click();
|
||||
};
|
||||
|
||||
const handleFileChange = async (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
toast.add({ severity: 'info', summary: '正在上传', detail: '请稍候...', life: 2000 });
|
||||
const res = await commonApi.upload(file, 'avatar');
|
||||
form.avatar = res.url; // 假设返回 { url: '...' }
|
||||
toast.add({ severity: 'success', summary: '头像上传成功', detail: '点击保存按钮生效', life: 3000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: '上传失败', detail: e.message, life: 3000 });
|
||||
}
|
||||
};
|
||||
|
||||
const saveProfile = async () => {
|
||||
if (!form.nickname.trim()) {
|
||||
toast.add({ severity: 'error', summary: '错误', detail: '昵称不能为空', life: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
nickname: form.nickname,
|
||||
avatar: form.avatar,
|
||||
bio: form.bio,
|
||||
gender: form.gender,
|
||||
birthday: form.birthday ? form.birthday.toISOString().split('T')[0] : '',
|
||||
location: {
|
||||
province: form.province?.name || form.province || '',
|
||||
city: form.city?.name || form.city || ''
|
||||
}
|
||||
};
|
||||
await userApi.updateMe(payload);
|
||||
toast.add({ severity: 'success', summary: '保存成功', detail: '个人资料已更新', life: 3000 });
|
||||
// 更新本地缓存的用户信息
|
||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
localStorage.setItem('user', JSON.stringify({ ...user, ...payload }));
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: '保存失败', detail: e.message, life: 3000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Mock Location Data
|
||||
const provinces = [
|
||||
{ name: '北京', code: 'BJ' },
|
||||
@@ -136,38 +209,6 @@ const cities = [
|
||||
{ name: '朝阳区', code: 'CY' },
|
||||
{ name: '海淀区', code: 'HD' }
|
||||
];
|
||||
|
||||
const triggerUpload = () => {
|
||||
fileInput.value.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
// Mock upload logic: Read as DataURL for preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
form.avatar = e.target.result;
|
||||
toast.add({ severity: 'success', summary: '头像已更新', detail: '新头像已预览,请点击保存生效', life: 3000 });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const saveProfile = () => {
|
||||
if (!form.nickname.trim()) {
|
||||
toast.add({ severity: 'error', summary: '错误', detail: '昵称不能为空', life: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
saving.value = false;
|
||||
toast.add({ severity: 'success', summary: '保存成功', detail: '个人资料已更新,部分信息正在审核中', life: 3000 });
|
||||
}, 1000);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user