From c231847f81692e2ef91eab6d308ce4e10e2b9100 Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 5 Jan 2026 12:54:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E7=BD=AE=E9=A1=B6=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E6=95=B0=E6=8D=AE=E6=A8=A1=E5=9E=8B=E5=92=8C?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/http/v1/dto/creator.go | 2 + backend/app/http/v1/dto/upload.go | 6 +- backend/app/services/common.go | 4 +- backend/app/services/creator.go | 40 ++++ ...260105012511_add_is_pinned_to_contents.sql | 6 + backend/database/models/contents.gen.go | 5 +- backend/database/models/contents.query.gen.go | 192 ++++++++--------- .../portal/src/views/creator/ContentsView.vue | 199 +++++++++++------- 8 files changed, 278 insertions(+), 176 deletions(-) create mode 100644 backend/database/migrations/20260105012511_add_is_pinned_to_contents.sql diff --git a/backend/app/http/v1/dto/creator.go b/backend/app/http/v1/dto/creator.go index 336322b..f0e3c3b 100644 --- a/backend/app/http/v1/dto/creator.go +++ b/backend/app/http/v1/dto/creator.go @@ -41,6 +41,7 @@ type ContentUpdateForm struct { Key string `json:"key"` Price *float64 `json:"price"` Status string `json:"status"` + IsPinned *bool `json:"is_pinned"` CoverIDs []string `json:"cover_ids"` MediaIDs []string `json:"media_ids"` } @@ -71,6 +72,7 @@ type CreatorContentItem struct { VideoCount int `json:"video_count"` AudioCount int `json:"audio_count"` Status string `json:"status"` + IsPinned bool `json:"is_pinned"` IsPurchased bool `json:"is_purchased"` } diff --git a/backend/app/http/v1/dto/upload.go b/backend/app/http/v1/dto/upload.go index 95ba0c1..10ac7d3 100644 --- a/backend/app/http/v1/dto/upload.go +++ b/backend/app/http/v1/dto/upload.go @@ -9,9 +9,9 @@ type UploadInitForm struct { } type UploadInitResponse struct { - UploadID string `json:"upload_id"` - Key string `json:"key"` // For S3 direct - ChunkSize int64 `json:"chunk_size"` + UploadID string `json:"upload_id"` + Key string `json:"key"` // For S3 direct + ChunkSize int64 `json:"chunk_size"` } type UploadPartForm struct { diff --git a/backend/app/services/common.go b/backend/app/services/common.go index 67584e0..c0cab27 100644 --- a/backend/app/services/common.go +++ b/backend/app/services/common.go @@ -101,7 +101,7 @@ func (s *common) InitUpload(ctx context.Context, userID int64, form *common_dto. localPath = "./storage" } tempDir := filepath.Join(localPath, "temp", uploadID) - if err := os.MkdirAll(tempDir, 0755); err != nil { + if err := os.MkdirAll(tempDir, 0o755); err != nil { return nil, errorx.ErrInternalError.WithCause(err) } @@ -205,7 +205,7 @@ func (s *common) CompleteUpload(ctx context.Context, userID int64, form *common_ } hash := hex.EncodeToString(hasher.Sum(nil)) - dst.Close(); // Ensure flush before potential removal + dst.Close() // Ensure flush before potential removal os.RemoveAll(tempDir) // Deduplication Logic (Similar to Upload) diff --git a/backend/app/services/creator.go b/backend/app/services/creator.go index 19194ed..00e18f2 100644 --- a/backend/app/services/creator.go +++ b/backend/app/services/creator.go @@ -3,6 +3,7 @@ package services import ( "context" "errors" + "fmt" "time" "quyun/v2/app/errorx" @@ -129,6 +130,7 @@ func (s *creator) ListContents( } } if filter.Key != nil && *filter.Key != "" { + fmt.Printf("DEBUG: Filter Key: '%s'\n", *filter.Key) q = q.Where(tbl.Key.Eq(*filter.Key)) } if filter.Keyword != nil && *filter.Keyword != "" { @@ -205,6 +207,7 @@ func (s *creator) ListContents( VideoCount: videoCount, AudioCount: audioCount, Status: string(item.Status), + IsPinned: item.IsPinned, IsPurchased: false, }) } @@ -311,11 +314,48 @@ func (s *creator) UpdateContent( if form.Status != "" { contentUpdates.Status = consts.ContentStatus(form.Status) } + + // Determine final status + finalStatus := c.Status + if form.Status != "" { + finalStatus = consts.ContentStatus(form.Status) + } + + // Validation: Only published content can be pinned + if form.IsPinned != nil && *form.IsPinned && finalStatus != consts.ContentStatusPublished { + return errorx.ErrBadRequest.WithMsg("只有已发布的内容支持置顶") + } + + // Perform standard updates _, err = tx.Content.WithContext(ctx).Where(tx.Content.ID.Eq(cid)).Updates(contentUpdates) if err != nil { return err } + // Handle IsPinned Logic + if finalStatus != consts.ContentStatusPublished { + // Force Unpin if not published + _, err = tx.Content.WithContext(ctx).Where(tx.Content.ID.Eq(cid)).UpdateSimple(tx.Content.IsPinned.Value(false)) + if err != nil { + return err + } + } else if form.IsPinned != nil { + // Explicit Pin Update requested + _, err = tx.Content.WithContext(ctx).Where(tx.Content.ID.Eq(cid)).UpdateSimple(tx.Content.IsPinned.Value(*form.IsPinned)) + if err != nil { + return err + } + + // If setting to true, unpin others + if *form.IsPinned { + if _, err := tx.Content.WithContext(ctx). + Where(tx.Content.TenantID.Eq(tid), tx.Content.ID.Neq(cid)). + UpdateSimple(tx.Content.IsPinned.Value(false)); err != nil { + return err + } + } + } + // 3. Update Price // Check if price exists if form.Price != nil { diff --git a/backend/database/migrations/20260105012511_add_is_pinned_to_contents.sql b/backend/database/migrations/20260105012511_add_is_pinned_to_contents.sql new file mode 100644 index 0000000..22ab455 --- /dev/null +++ b/backend/database/migrations/20260105012511_add_is_pinned_to_contents.sql @@ -0,0 +1,6 @@ +-- +goose Up +ALTER TABLE contents ADD COLUMN is_pinned BOOLEAN DEFAULT FALSE; +COMMENT ON COLUMN contents.is_pinned IS 'Whether content is pinned/featured'; + +-- +goose Down +ALTER TABLE contents DROP COLUMN is_pinned; \ No newline at end of file diff --git a/backend/database/models/contents.gen.go b/backend/database/models/contents.gen.go index 154c3fb..a3de8e8 100644 --- a/backend/database/models/contents.gen.go +++ b/backend/database/models/contents.gen.go @@ -38,10 +38,11 @@ type Content struct { CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;default:now()" json:"created_at"` UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;default:now()" json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp with time zone" json:"deleted_at"` - Key string `gorm:"column:key;type:character varying(32);comment:Musical key/tone" json:"key"` // Musical key/tone - ContentAssets []*ContentAsset `gorm:"foreignKey:ContentID;references:ID" json:"content_assets,omitempty"` + Key string `gorm:"column:key;type:character varying(32);comment:Musical key/tone" json:"key"` // Musical key/tone + IsPinned bool `gorm:"column:is_pinned;type:boolean;comment:Whether content is pinned/featured" json:"is_pinned"` // Whether content is pinned/featured Comments []*Comment `gorm:"foreignKey:ContentID;references:ID" json:"comments,omitempty"` Author *User `gorm:"foreignKey:UserID;references:ID" json:"author,omitempty"` + ContentAssets []*ContentAsset `gorm:"foreignKey:ContentID;references:ID" json:"content_assets,omitempty"` } // Quick operations without importing query package diff --git a/backend/database/models/contents.query.gen.go b/backend/database/models/contents.query.gen.go index b5a9f18..ddbf1b6 100644 --- a/backend/database/models/contents.query.gen.go +++ b/backend/database/models/contents.query.gen.go @@ -45,12 +45,7 @@ func newContent(db *gorm.DB, opts ...gen.DOOption) contentQuery { _contentQuery.UpdatedAt = field.NewTime(tableName, "updated_at") _contentQuery.DeletedAt = field.NewField(tableName, "deleted_at") _contentQuery.Key = field.NewString(tableName, "key") - _contentQuery.ContentAssets = contentQueryHasManyContentAssets{ - db: db.Session(&gorm.Session{}), - - RelationField: field.NewRelation("ContentAssets", "ContentAsset"), - } - + _contentQuery.IsPinned = field.NewBool(tableName, "is_pinned") _contentQuery.Comments = contentQueryHasManyComments{ db: db.Session(&gorm.Session{}), @@ -63,6 +58,12 @@ func newContent(db *gorm.DB, opts ...gen.DOOption) contentQuery { RelationField: field.NewRelation("Author", "User"), } + _contentQuery.ContentAssets = contentQueryHasManyContentAssets{ + db: db.Session(&gorm.Session{}), + + RelationField: field.NewRelation("ContentAssets", "ContentAsset"), + } + _contentQuery.fillFieldMap() return _contentQuery @@ -92,12 +93,13 @@ type contentQuery struct { UpdatedAt field.Time DeletedAt field.Field Key field.String // Musical key/tone - ContentAssets contentQueryHasManyContentAssets - - Comments contentQueryHasManyComments + IsPinned field.Bool // Whether content is pinned/featured + Comments contentQueryHasManyComments Author contentQueryBelongsToAuthor + ContentAssets contentQueryHasManyContentAssets + fieldMap map[string]field.Expr } @@ -133,6 +135,7 @@ func (c *contentQuery) updateTableName(table string) *contentQuery { c.UpdatedAt = field.NewTime(table, "updated_at") c.DeletedAt = field.NewField(table, "deleted_at") c.Key = field.NewString(table, "key") + c.IsPinned = field.NewBool(table, "is_pinned") c.fillFieldMap() @@ -165,7 +168,7 @@ func (c *contentQuery) GetFieldByName(fieldName string) (field.OrderExpr, bool) } func (c *contentQuery) fillFieldMap() { - c.fieldMap = make(map[string]field.Expr, 23) + c.fieldMap = make(map[string]field.Expr, 24) c.fieldMap["id"] = c.ID c.fieldMap["tenant_id"] = c.TenantID c.fieldMap["user_id"] = c.UserID @@ -186,109 +189,29 @@ func (c *contentQuery) fillFieldMap() { c.fieldMap["updated_at"] = c.UpdatedAt c.fieldMap["deleted_at"] = c.DeletedAt c.fieldMap["key"] = c.Key + c.fieldMap["is_pinned"] = c.IsPinned } func (c contentQuery) clone(db *gorm.DB) contentQuery { c.contentQueryDo.ReplaceConnPool(db.Statement.ConnPool) - c.ContentAssets.db = db.Session(&gorm.Session{Initialized: true}) - c.ContentAssets.db.Statement.ConnPool = db.Statement.ConnPool c.Comments.db = db.Session(&gorm.Session{Initialized: true}) c.Comments.db.Statement.ConnPool = db.Statement.ConnPool c.Author.db = db.Session(&gorm.Session{Initialized: true}) c.Author.db.Statement.ConnPool = db.Statement.ConnPool + c.ContentAssets.db = db.Session(&gorm.Session{Initialized: true}) + c.ContentAssets.db.Statement.ConnPool = db.Statement.ConnPool return c } func (c contentQuery) replaceDB(db *gorm.DB) contentQuery { c.contentQueryDo.ReplaceDB(db) - c.ContentAssets.db = db.Session(&gorm.Session{}) c.Comments.db = db.Session(&gorm.Session{}) c.Author.db = db.Session(&gorm.Session{}) + c.ContentAssets.db = db.Session(&gorm.Session{}) return c } -type contentQueryHasManyContentAssets struct { - db *gorm.DB - - field.RelationField -} - -func (a contentQueryHasManyContentAssets) Where(conds ...field.Expr) *contentQueryHasManyContentAssets { - if len(conds) == 0 { - return &a - } - - exprs := make([]clause.Expression, 0, len(conds)) - for _, cond := range conds { - exprs = append(exprs, cond.BeCond().(clause.Expression)) - } - a.db = a.db.Clauses(clause.Where{Exprs: exprs}) - return &a -} - -func (a contentQueryHasManyContentAssets) WithContext(ctx context.Context) *contentQueryHasManyContentAssets { - a.db = a.db.WithContext(ctx) - return &a -} - -func (a contentQueryHasManyContentAssets) Session(session *gorm.Session) *contentQueryHasManyContentAssets { - a.db = a.db.Session(session) - return &a -} - -func (a contentQueryHasManyContentAssets) Model(m *Content) *contentQueryHasManyContentAssetsTx { - return &contentQueryHasManyContentAssetsTx{a.db.Model(m).Association(a.Name())} -} - -func (a contentQueryHasManyContentAssets) Unscoped() *contentQueryHasManyContentAssets { - a.db = a.db.Unscoped() - return &a -} - -type contentQueryHasManyContentAssetsTx struct{ tx *gorm.Association } - -func (a contentQueryHasManyContentAssetsTx) Find() (result []*ContentAsset, err error) { - return result, a.tx.Find(&result) -} - -func (a contentQueryHasManyContentAssetsTx) Append(values ...*ContentAsset) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Append(targetValues...) -} - -func (a contentQueryHasManyContentAssetsTx) Replace(values ...*ContentAsset) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Replace(targetValues...) -} - -func (a contentQueryHasManyContentAssetsTx) Delete(values ...*ContentAsset) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Delete(targetValues...) -} - -func (a contentQueryHasManyContentAssetsTx) Clear() error { - return a.tx.Clear() -} - -func (a contentQueryHasManyContentAssetsTx) Count() int64 { - return a.tx.Count() -} - -func (a contentQueryHasManyContentAssetsTx) Unscoped() *contentQueryHasManyContentAssetsTx { - a.tx = a.tx.Unscoped() - return &a -} - type contentQueryHasManyComments struct { db *gorm.DB @@ -451,6 +374,87 @@ func (a contentQueryBelongsToAuthorTx) Unscoped() *contentQueryBelongsToAuthorTx return &a } +type contentQueryHasManyContentAssets struct { + db *gorm.DB + + field.RelationField +} + +func (a contentQueryHasManyContentAssets) Where(conds ...field.Expr) *contentQueryHasManyContentAssets { + if len(conds) == 0 { + return &a + } + + exprs := make([]clause.Expression, 0, len(conds)) + for _, cond := range conds { + exprs = append(exprs, cond.BeCond().(clause.Expression)) + } + a.db = a.db.Clauses(clause.Where{Exprs: exprs}) + return &a +} + +func (a contentQueryHasManyContentAssets) WithContext(ctx context.Context) *contentQueryHasManyContentAssets { + a.db = a.db.WithContext(ctx) + return &a +} + +func (a contentQueryHasManyContentAssets) Session(session *gorm.Session) *contentQueryHasManyContentAssets { + a.db = a.db.Session(session) + return &a +} + +func (a contentQueryHasManyContentAssets) Model(m *Content) *contentQueryHasManyContentAssetsTx { + return &contentQueryHasManyContentAssetsTx{a.db.Model(m).Association(a.Name())} +} + +func (a contentQueryHasManyContentAssets) Unscoped() *contentQueryHasManyContentAssets { + a.db = a.db.Unscoped() + return &a +} + +type contentQueryHasManyContentAssetsTx struct{ tx *gorm.Association } + +func (a contentQueryHasManyContentAssetsTx) Find() (result []*ContentAsset, err error) { + return result, a.tx.Find(&result) +} + +func (a contentQueryHasManyContentAssetsTx) Append(values ...*ContentAsset) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Append(targetValues...) +} + +func (a contentQueryHasManyContentAssetsTx) Replace(values ...*ContentAsset) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Replace(targetValues...) +} + +func (a contentQueryHasManyContentAssetsTx) Delete(values ...*ContentAsset) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Delete(targetValues...) +} + +func (a contentQueryHasManyContentAssetsTx) Clear() error { + return a.tx.Clear() +} + +func (a contentQueryHasManyContentAssetsTx) Count() int64 { + return a.tx.Count() +} + +func (a contentQueryHasManyContentAssetsTx) Unscoped() *contentQueryHasManyContentAssetsTx { + a.tx = a.tx.Unscoped() + return &a +} + type contentQueryDo struct{ gen.DO } func (c contentQueryDo) Debug() *contentQueryDo { diff --git a/frontend/portal/src/views/creator/ContentsView.vue b/frontend/portal/src/views/creator/ContentsView.vue index a60a581..585b014 100644 --- a/frontend/portal/src/views/creator/ContentsView.vue +++ b/frontend/portal/src/views/creator/ContentsView.vue @@ -61,64 +61,73 @@
-
-
- [{{ getGenreLabel(item.genre) }}] - [{{ item.key }}] -

- {{ item.title }}

-
- -
- - 封禁 - - - {{ statusStyle(item.status).label }} - -
-
- -
- ¥ {{ item.price.toFixed(2) }} - 免费 - - - {{ item.image_count }} - - - {{ item.video_count }} - - - {{ item.audio_count }} - - - {{ item.views }} - {{ item.likes }} - -
-
- - -
- - - - -
+
+
+ 置顶 + [{{ getGenreLabel(item.genre) }}] + [{{ item.key }}] +

+ {{ item.title }}

+
+ +
+ + 封禁 + + + {{ statusStyle(item.status).label }} + +
+
+ +
+ ¥ {{ item.price.toFixed(2) }} + 免费 + + + {{ item.image_count }} + + + {{ item.video_count }} + + + {{ item.audio_count }} + + + {{ item.views }} + {{ item.likes }} + +
+
+ + +
+ + + + +
@@ -129,11 +138,14 @@ import { onMounted, ref, watch } from 'vue'; import { useRouter } 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'; const router = useRouter(); const toast = useToast(); +const confirm = useConfirm(); const contents = ref([]); const filterStatus = ref('all'); const filterGenre = ref('all'); @@ -206,24 +218,61 @@ const statusStyle = (status) => { } }; -const handleStatusChange = async (id, status) => { - 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 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 handleDelete = async (id) => { - if (!confirm('确定要删除吗?')) return; - try { - await creatorApi.deleteContent(id); - fetchContents(); - } catch (e) { - console.error(e); - } +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 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 }); + } + } + }); }; \ No newline at end of file