feat: add file deduplication and hash checking for uploads
- Implemented SHA-256 hashing for uploaded files to enable deduplication. - Added CheckHash method to verify if a file with the same hash already exists. - Updated Upload method to reuse existing media assets if a duplicate is found. - Introduced a new hash column in the media_assets table to store file hashes. - Enhanced the upload process to include progress tracking and hash calculation. - Modified frontend to check for existing files before uploading and to show upload progress. - Added vuedraggable for drag-and-drop functionality in the content editing view.
This commit is contained in:
@@ -35,7 +35,7 @@ func (c *Common) Upload(
|
|||||||
if form != nil {
|
if form != nil {
|
||||||
val = form.Type
|
val = form.Type
|
||||||
}
|
}
|
||||||
return services.Common.Upload(ctx.Context(), user.ID, file, val)
|
return services.Common.Upload(ctx, user.ID, file, val)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get options (enums)
|
// Get options (enums)
|
||||||
@@ -50,3 +50,19 @@ func (c *Common) Upload(
|
|||||||
func (c *Common) GetOptions(ctx fiber.Ctx) (*dto.OptionsResponse, error) {
|
func (c *Common) GetOptions(ctx fiber.Ctx) (*dto.OptionsResponse, error) {
|
||||||
return services.Common.Options(ctx)
|
return services.Common.Options(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check file hash for deduplication
|
||||||
|
//
|
||||||
|
// @Router /v1/upload/check [get]
|
||||||
|
// @Summary Check hash
|
||||||
|
// @Description Check if file hash exists
|
||||||
|
// @Tags Common
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param hash query string true "File Hash"
|
||||||
|
// @Success 200 {object} dto.UploadResult
|
||||||
|
// @Bind user local key(__ctx_user)
|
||||||
|
// @Bind hash query
|
||||||
|
func (c *Common) CheckHash(ctx fiber.Ctx, user *models.User, hash string) (*dto.UploadResult, error) {
|
||||||
|
return services.Common.CheckHash(ctx, user.ID, hash)
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ func (r *Routes) Register(router fiber.Router) {
|
|||||||
router.Get("/v1/common/options"[len(r.Path()):], DataFunc0(
|
router.Get("/v1/common/options"[len(r.Path()):], DataFunc0(
|
||||||
r.common.GetOptions,
|
r.common.GetOptions,
|
||||||
))
|
))
|
||||||
|
r.log.Debugf("Registering route: Get /v1/upload/check -> common.CheckHash")
|
||||||
|
router.Get("/v1/upload/check"[len(r.Path()):], DataFunc2(
|
||||||
|
r.common.CheckHash,
|
||||||
|
Local[*models.User]("__ctx_user"),
|
||||||
|
QueryParam[string]("hash"),
|
||||||
|
))
|
||||||
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,
|
||||||
@@ -106,18 +112,18 @@ func (r *Routes) Register(router fiber.Router) {
|
|||||||
Local[*models.User]("__ctx_user"),
|
Local[*models.User]("__ctx_user"),
|
||||||
QueryParam[string]("id"),
|
QueryParam[string]("id"),
|
||||||
))
|
))
|
||||||
r.log.Debugf("Registering route: Get /v1/creator/contents/:id -> creator.GetContent")
|
|
||||||
router.Get("/v1/creator/contents/:id"[len(r.Path()):], DataFunc2(
|
|
||||||
r.creator.GetContent,
|
|
||||||
Local[*models.User]("__ctx_user"),
|
|
||||||
PathParam[string]("id"),
|
|
||||||
))
|
|
||||||
r.log.Debugf("Registering route: Get /v1/creator/contents -> creator.ListContents")
|
r.log.Debugf("Registering route: Get /v1/creator/contents -> creator.ListContents")
|
||||||
router.Get("/v1/creator/contents"[len(r.Path()):], DataFunc2(
|
router.Get("/v1/creator/contents"[len(r.Path()):], DataFunc2(
|
||||||
r.creator.ListContents,
|
r.creator.ListContents,
|
||||||
Local[*models.User]("__ctx_user"),
|
Local[*models.User]("__ctx_user"),
|
||||||
Query[dto.CreatorContentListFilter]("filter"),
|
Query[dto.CreatorContentListFilter]("filter"),
|
||||||
))
|
))
|
||||||
|
r.log.Debugf("Registering route: Get /v1/creator/contents/:id -> creator.GetContent")
|
||||||
|
router.Get("/v1/creator/contents/:id"[len(r.Path()):], DataFunc2(
|
||||||
|
r.creator.GetContent,
|
||||||
|
Local[*models.User]("__ctx_user"),
|
||||||
|
PathParam[string]("id"),
|
||||||
|
))
|
||||||
r.log.Debugf("Registering route: Get /v1/creator/dashboard -> creator.Dashboard")
|
r.log.Debugf("Registering route: Get /v1/creator/dashboard -> creator.Dashboard")
|
||||||
router.Get("/v1/creator/dashboard"[len(r.Path()):], DataFunc1(
|
router.Get("/v1/creator/dashboard"[len(r.Path()):], DataFunc1(
|
||||||
r.creator.Dashboard,
|
r.creator.Dashboard,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
@@ -41,8 +43,54 @@ func (s *common) Options(ctx context.Context) (*common_dto.OptionsResponse, erro
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *common) Upload(ctx context.Context, userID int64, file *multipart.FileHeader, typeArg string) (*common_dto.UploadResult, error) {
|
func (s *common) CheckHash(ctx context.Context, userID int64, hash 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)
|
existing, err := models.MediaAssetQuery.WithContext(ctx).Where(models.MediaAssetQuery.Hash.Eq(hash)).First()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil // Not found, proceed to upload
|
||||||
|
}
|
||||||
|
|
||||||
|
// Found existing file (Global deduplication hit)
|
||||||
|
|
||||||
|
// Check if user already has it (Logic deduplication hit)
|
||||||
|
myExisting, err := models.MediaAssetQuery.WithContext(ctx).
|
||||||
|
Where(models.MediaAssetQuery.Hash.Eq(hash), models.MediaAssetQuery.UserID.Eq(userID)).
|
||||||
|
First()
|
||||||
|
if err == nil {
|
||||||
|
return s.composeUploadResult(myExisting), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new record for this user reusing existing ObjectKey
|
||||||
|
t, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.UserID.Eq(userID)).First()
|
||||||
|
var tid int64 = 0
|
||||||
|
if err == nil {
|
||||||
|
tid = t.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
asset := &models.MediaAsset{
|
||||||
|
TenantID: tid,
|
||||||
|
UserID: userID,
|
||||||
|
Type: existing.Type,
|
||||||
|
Status: consts.MediaAssetStatusUploaded,
|
||||||
|
Provider: existing.Provider,
|
||||||
|
Bucket: existing.Bucket,
|
||||||
|
ObjectKey: existing.ObjectKey,
|
||||||
|
Hash: hash,
|
||||||
|
Meta: existing.Meta,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := models.MediaAssetQuery.WithContext(ctx).Create(asset); err != nil {
|
||||||
|
return nil, errorx.ErrDatabaseError.WithCause(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.composeUploadResult(asset), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
// But this Upload endpoint accepts file. So we save it.
|
// But this Upload endpoint accepts file. So we save it.
|
||||||
|
|
||||||
objectKey := uuid.NewString() + "_" + file.Filename
|
objectKey := uuid.NewString() + "_" + file.Filename
|
||||||
@@ -60,7 +108,7 @@ func (s *common) Upload(ctx context.Context, userID int64, file *multipart.FileH
|
|||||||
}
|
}
|
||||||
dstPath := filepath.Join(localPath, objectKey)
|
dstPath := filepath.Join(localPath, objectKey)
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
|
||||||
return nil, errorx.ErrInternalError.WithCause(err).WithMsg("failed to create storage directory")
|
return nil, errorx.ErrInternalError.WithCause(err).WithMsg("failed to create storage directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,45 +116,90 @@ func (s *common) Upload(ctx context.Context, userID int64, file *multipart.FileH
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errorx.ErrInternalError.WithCause(err).WithMsg("failed to create destination file")
|
return nil, errorx.ErrInternalError.WithCause(err).WithMsg("failed to create destination file")
|
||||||
}
|
}
|
||||||
defer dst.Close()
|
|
||||||
|
|
||||||
if _, err = io.Copy(dst, src); err != nil {
|
// Hash calculation while copying
|
||||||
|
hasher := sha256.New()
|
||||||
|
size, err := io.Copy(io.MultiWriter(dst, hasher), src)
|
||||||
|
dst.Close() // Close immediately to allow removal if needed
|
||||||
|
if err != nil {
|
||||||
return nil, errorx.ErrInternalError.WithCause(err).WithMsg("failed to save file content")
|
return nil, errorx.ErrInternalError.WithCause(err).WithMsg("failed to save file content")
|
||||||
}
|
}
|
||||||
|
|
||||||
url := s.GetAssetURL(objectKey)
|
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||||
|
|
||||||
// ... rest ...
|
|
||||||
t, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.UserID.Eq(userID)).First()
|
t, err := models.TenantQuery.WithContext(ctx).Where(models.TenantQuery.UserID.Eq(userID)).First()
|
||||||
var tid int64 = 0
|
var tid int64 = 0
|
||||||
if err == nil {
|
if err == nil {
|
||||||
tid = t.ID
|
tid = t.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
asset := &models.MediaAsset{
|
var asset *models.MediaAsset
|
||||||
TenantID: tid,
|
|
||||||
UserID: userID,
|
// Deduplication Check
|
||||||
Type: consts.MediaAssetType(typeArg),
|
existing, err := models.MediaAssetQuery.WithContext(ctx).Where(models.MediaAssetQuery.Hash.Eq(hash)).First()
|
||||||
Status: consts.MediaAssetStatusUploaded,
|
if err == nil {
|
||||||
Provider: "local",
|
// Found existing file (Storage Deduplication)
|
||||||
Bucket: "default",
|
os.Remove(dstPath) // Delete the duplicate we just wrote
|
||||||
ObjectKey: objectKey,
|
|
||||||
Meta: types.NewJSONType(fields.MediaAssetMeta{
|
// Check if user already has it (Logic Deduplication)
|
||||||
Size: file.Size,
|
myExisting, err := models.MediaAssetQuery.WithContext(ctx).
|
||||||
}),
|
Where(models.MediaAssetQuery.Hash.Eq(hash), models.MediaAssetQuery.UserID.Eq(userID)).
|
||||||
|
First()
|
||||||
|
if err == nil {
|
||||||
|
return s.composeUploadResult(myExisting), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new link for user reusing existing ObjectKey
|
||||||
|
asset = &models.MediaAsset{
|
||||||
|
TenantID: tid,
|
||||||
|
UserID: userID,
|
||||||
|
Type: consts.MediaAssetType(typeArg),
|
||||||
|
Status: consts.MediaAssetStatusUploaded,
|
||||||
|
Provider: existing.Provider,
|
||||||
|
Bucket: existing.Bucket,
|
||||||
|
ObjectKey: existing.ObjectKey, // Reuse key
|
||||||
|
Hash: hash,
|
||||||
|
Meta: existing.Meta,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// New unique file
|
||||||
|
asset = &models.MediaAsset{
|
||||||
|
TenantID: tid,
|
||||||
|
UserID: userID,
|
||||||
|
Type: consts.MediaAssetType(typeArg),
|
||||||
|
Status: consts.MediaAssetStatusUploaded,
|
||||||
|
Provider: "local",
|
||||||
|
Bucket: "default",
|
||||||
|
ObjectKey: objectKey,
|
||||||
|
Hash: hash,
|
||||||
|
Meta: types.NewJSONType(fields.MediaAssetMeta{
|
||||||
|
Size: size,
|
||||||
|
}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := models.MediaAssetQuery.WithContext(ctx).Create(asset); err != nil {
|
if err := models.MediaAssetQuery.WithContext(ctx).Create(asset); err != nil {
|
||||||
return nil, errorx.ErrDatabaseError.WithCause(err)
|
return nil, errorx.ErrDatabaseError.WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return s.composeUploadResult(asset), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *common) composeUploadResult(asset *models.MediaAsset) *common_dto.UploadResult {
|
||||||
|
url := s.GetAssetURL(asset.ObjectKey)
|
||||||
|
filename := filepath.Base(asset.ObjectKey)
|
||||||
|
// Try to get original filename if stored? Currently objectKey has UUID prefix.
|
||||||
|
// We can store original filename in Meta if needed. For now, just return valid result.
|
||||||
|
// Meta is JSONType wrapper.
|
||||||
|
size := asset.Meta.Data().Size
|
||||||
|
|
||||||
return &common_dto.UploadResult{
|
return &common_dto.UploadResult{
|
||||||
ID: cast.ToString(asset.ID),
|
ID: cast.ToString(asset.ID),
|
||||||
URL: url,
|
URL: url,
|
||||||
Filename: file.Filename,
|
Filename: filename,
|
||||||
Size: file.Size,
|
Size: size,
|
||||||
MimeType: file.Header.Get("Content-Type"),
|
MimeType: "application/octet-stream", // TODO: Store mime type in DB
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *common) GetAssetURL(objectKey string) string {
|
func (s *common) GetAssetURL(objectKey string) string {
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE media_assets ADD COLUMN hash VARCHAR(64) DEFAULT '';
|
||||||
|
CREATE INDEX idx_media_assets_hash ON media_assets (hash);
|
||||||
|
COMMENT ON COLUMN media_assets.hash IS 'File SHA-256 hash';
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP INDEX idx_media_assets_hash;
|
||||||
|
ALTER TABLE media_assets DROP COLUMN hash;
|
||||||
@@ -39,9 +39,9 @@ type Content struct {
|
|||||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;default:now()" json:"updated_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"`
|
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
|
Key string `gorm:"column:key;type:character varying(32);comment:Musical key/tone" json:"key"` // Musical key/tone
|
||||||
Author *User `gorm:"foreignKey:UserID;references:ID" json:"author,omitempty"`
|
|
||||||
ContentAssets []*ContentAsset `gorm:"foreignKey:ContentID;references:ID" json:"content_assets,omitempty"`
|
ContentAssets []*ContentAsset `gorm:"foreignKey:ContentID;references:ID" json:"content_assets,omitempty"`
|
||||||
Comments []*Comment `gorm:"foreignKey:ContentID;references:ID" json:"comments,omitempty"`
|
Comments []*Comment `gorm:"foreignKey:ContentID;references:ID" json:"comments,omitempty"`
|
||||||
|
Author *User `gorm:"foreignKey:UserID;references:ID" json:"author,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick operations without importing query package
|
// Quick operations without importing query package
|
||||||
|
|||||||
@@ -45,12 +45,6 @@ func newContent(db *gorm.DB, opts ...gen.DOOption) contentQuery {
|
|||||||
_contentQuery.UpdatedAt = field.NewTime(tableName, "updated_at")
|
_contentQuery.UpdatedAt = field.NewTime(tableName, "updated_at")
|
||||||
_contentQuery.DeletedAt = field.NewField(tableName, "deleted_at")
|
_contentQuery.DeletedAt = field.NewField(tableName, "deleted_at")
|
||||||
_contentQuery.Key = field.NewString(tableName, "key")
|
_contentQuery.Key = field.NewString(tableName, "key")
|
||||||
_contentQuery.Author = contentQueryBelongsToAuthor{
|
|
||||||
db: db.Session(&gorm.Session{}),
|
|
||||||
|
|
||||||
RelationField: field.NewRelation("Author", "User"),
|
|
||||||
}
|
|
||||||
|
|
||||||
_contentQuery.ContentAssets = contentQueryHasManyContentAssets{
|
_contentQuery.ContentAssets = contentQueryHasManyContentAssets{
|
||||||
db: db.Session(&gorm.Session{}),
|
db: db.Session(&gorm.Session{}),
|
||||||
|
|
||||||
@@ -63,6 +57,12 @@ func newContent(db *gorm.DB, opts ...gen.DOOption) contentQuery {
|
|||||||
RelationField: field.NewRelation("Comments", "Comment"),
|
RelationField: field.NewRelation("Comments", "Comment"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_contentQuery.Author = contentQueryBelongsToAuthor{
|
||||||
|
db: db.Session(&gorm.Session{}),
|
||||||
|
|
||||||
|
RelationField: field.NewRelation("Author", "User"),
|
||||||
|
}
|
||||||
|
|
||||||
_contentQuery.fillFieldMap()
|
_contentQuery.fillFieldMap()
|
||||||
|
|
||||||
return _contentQuery
|
return _contentQuery
|
||||||
@@ -92,12 +92,12 @@ type contentQuery struct {
|
|||||||
UpdatedAt field.Time
|
UpdatedAt field.Time
|
||||||
DeletedAt field.Field
|
DeletedAt field.Field
|
||||||
Key field.String // Musical key/tone
|
Key field.String // Musical key/tone
|
||||||
Author contentQueryBelongsToAuthor
|
ContentAssets contentQueryHasManyContentAssets
|
||||||
|
|
||||||
ContentAssets contentQueryHasManyContentAssets
|
|
||||||
|
|
||||||
Comments contentQueryHasManyComments
|
Comments contentQueryHasManyComments
|
||||||
|
|
||||||
|
Author contentQueryBelongsToAuthor
|
||||||
|
|
||||||
fieldMap map[string]field.Expr
|
fieldMap map[string]field.Expr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,104 +191,23 @@ func (c *contentQuery) fillFieldMap() {
|
|||||||
|
|
||||||
func (c contentQuery) clone(db *gorm.DB) contentQuery {
|
func (c contentQuery) clone(db *gorm.DB) contentQuery {
|
||||||
c.contentQueryDo.ReplaceConnPool(db.Statement.ConnPool)
|
c.contentQueryDo.ReplaceConnPool(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 = db.Session(&gorm.Session{Initialized: true})
|
||||||
c.ContentAssets.db.Statement.ConnPool = db.Statement.ConnPool
|
c.ContentAssets.db.Statement.ConnPool = db.Statement.ConnPool
|
||||||
c.Comments.db = db.Session(&gorm.Session{Initialized: true})
|
c.Comments.db = db.Session(&gorm.Session{Initialized: true})
|
||||||
c.Comments.db.Statement.ConnPool = db.Statement.ConnPool
|
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
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c contentQuery) replaceDB(db *gorm.DB) contentQuery {
|
func (c contentQuery) replaceDB(db *gorm.DB) contentQuery {
|
||||||
c.contentQueryDo.ReplaceDB(db)
|
c.contentQueryDo.ReplaceDB(db)
|
||||||
c.Author.db = db.Session(&gorm.Session{})
|
|
||||||
c.ContentAssets.db = db.Session(&gorm.Session{})
|
c.ContentAssets.db = db.Session(&gorm.Session{})
|
||||||
c.Comments.db = db.Session(&gorm.Session{})
|
c.Comments.db = db.Session(&gorm.Session{})
|
||||||
|
c.Author.db = db.Session(&gorm.Session{})
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
type contentQueryBelongsToAuthor struct {
|
|
||||||
db *gorm.DB
|
|
||||||
|
|
||||||
field.RelationField
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a contentQueryBelongsToAuthor) Where(conds ...field.Expr) *contentQueryBelongsToAuthor {
|
|
||||||
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 contentQueryBelongsToAuthor) WithContext(ctx context.Context) *contentQueryBelongsToAuthor {
|
|
||||||
a.db = a.db.WithContext(ctx)
|
|
||||||
return &a
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a contentQueryBelongsToAuthor) Session(session *gorm.Session) *contentQueryBelongsToAuthor {
|
|
||||||
a.db = a.db.Session(session)
|
|
||||||
return &a
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a contentQueryBelongsToAuthor) Model(m *Content) *contentQueryBelongsToAuthorTx {
|
|
||||||
return &contentQueryBelongsToAuthorTx{a.db.Model(m).Association(a.Name())}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a contentQueryBelongsToAuthor) Unscoped() *contentQueryBelongsToAuthor {
|
|
||||||
a.db = a.db.Unscoped()
|
|
||||||
return &a
|
|
||||||
}
|
|
||||||
|
|
||||||
type contentQueryBelongsToAuthorTx struct{ tx *gorm.Association }
|
|
||||||
|
|
||||||
func (a contentQueryBelongsToAuthorTx) Find() (result *User, err error) {
|
|
||||||
return result, a.tx.Find(&result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a contentQueryBelongsToAuthorTx) Append(values ...*User) (err error) {
|
|
||||||
targetValues := make([]interface{}, len(values))
|
|
||||||
for i, v := range values {
|
|
||||||
targetValues[i] = v
|
|
||||||
}
|
|
||||||
return a.tx.Append(targetValues...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a contentQueryBelongsToAuthorTx) Replace(values ...*User) (err error) {
|
|
||||||
targetValues := make([]interface{}, len(values))
|
|
||||||
for i, v := range values {
|
|
||||||
targetValues[i] = v
|
|
||||||
}
|
|
||||||
return a.tx.Replace(targetValues...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a contentQueryBelongsToAuthorTx) Delete(values ...*User) (err error) {
|
|
||||||
targetValues := make([]interface{}, len(values))
|
|
||||||
for i, v := range values {
|
|
||||||
targetValues[i] = v
|
|
||||||
}
|
|
||||||
return a.tx.Delete(targetValues...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a contentQueryBelongsToAuthorTx) Clear() error {
|
|
||||||
return a.tx.Clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a contentQueryBelongsToAuthorTx) Count() int64 {
|
|
||||||
return a.tx.Count()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a contentQueryBelongsToAuthorTx) Unscoped() *contentQueryBelongsToAuthorTx {
|
|
||||||
a.tx = a.tx.Unscoped()
|
|
||||||
return &a
|
|
||||||
}
|
|
||||||
|
|
||||||
type contentQueryHasManyContentAssets struct {
|
type contentQueryHasManyContentAssets struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
|
||||||
@@ -451,6 +370,87 @@ func (a contentQueryHasManyCommentsTx) Unscoped() *contentQueryHasManyCommentsTx
|
|||||||
return &a
|
return &a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type contentQueryBelongsToAuthor struct {
|
||||||
|
db *gorm.DB
|
||||||
|
|
||||||
|
field.RelationField
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a contentQueryBelongsToAuthor) Where(conds ...field.Expr) *contentQueryBelongsToAuthor {
|
||||||
|
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 contentQueryBelongsToAuthor) WithContext(ctx context.Context) *contentQueryBelongsToAuthor {
|
||||||
|
a.db = a.db.WithContext(ctx)
|
||||||
|
return &a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a contentQueryBelongsToAuthor) Session(session *gorm.Session) *contentQueryBelongsToAuthor {
|
||||||
|
a.db = a.db.Session(session)
|
||||||
|
return &a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a contentQueryBelongsToAuthor) Model(m *Content) *contentQueryBelongsToAuthorTx {
|
||||||
|
return &contentQueryBelongsToAuthorTx{a.db.Model(m).Association(a.Name())}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a contentQueryBelongsToAuthor) Unscoped() *contentQueryBelongsToAuthor {
|
||||||
|
a.db = a.db.Unscoped()
|
||||||
|
return &a
|
||||||
|
}
|
||||||
|
|
||||||
|
type contentQueryBelongsToAuthorTx struct{ tx *gorm.Association }
|
||||||
|
|
||||||
|
func (a contentQueryBelongsToAuthorTx) Find() (result *User, err error) {
|
||||||
|
return result, a.tx.Find(&result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a contentQueryBelongsToAuthorTx) Append(values ...*User) (err error) {
|
||||||
|
targetValues := make([]interface{}, len(values))
|
||||||
|
for i, v := range values {
|
||||||
|
targetValues[i] = v
|
||||||
|
}
|
||||||
|
return a.tx.Append(targetValues...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a contentQueryBelongsToAuthorTx) Replace(values ...*User) (err error) {
|
||||||
|
targetValues := make([]interface{}, len(values))
|
||||||
|
for i, v := range values {
|
||||||
|
targetValues[i] = v
|
||||||
|
}
|
||||||
|
return a.tx.Replace(targetValues...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a contentQueryBelongsToAuthorTx) Delete(values ...*User) (err error) {
|
||||||
|
targetValues := make([]interface{}, len(values))
|
||||||
|
for i, v := range values {
|
||||||
|
targetValues[i] = v
|
||||||
|
}
|
||||||
|
return a.tx.Delete(targetValues...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a contentQueryBelongsToAuthorTx) Clear() error {
|
||||||
|
return a.tx.Clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a contentQueryBelongsToAuthorTx) Count() int64 {
|
||||||
|
return a.tx.Count()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a contentQueryBelongsToAuthorTx) Unscoped() *contentQueryBelongsToAuthorTx {
|
||||||
|
a.tx = a.tx.Unscoped()
|
||||||
|
return &a
|
||||||
|
}
|
||||||
|
|
||||||
type contentQueryDo struct{ gen.DO }
|
type contentQueryDo struct{ gen.DO }
|
||||||
|
|
||||||
func (c contentQueryDo) Debug() *contentQueryDo {
|
func (c contentQueryDo) Debug() *contentQueryDo {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type MediaAsset struct {
|
|||||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;default:now()" json:"created_at"`
|
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"`
|
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"`
|
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp with time zone" json:"deleted_at"`
|
||||||
|
Hash string `gorm:"column:hash;type:character varying(64);comment:File SHA-256 hash" json:"hash"` // File SHA-256 hash
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick operations without importing query package
|
// Quick operations without importing query package
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ func newMediaAsset(db *gorm.DB, opts ...gen.DOOption) mediaAssetQuery {
|
|||||||
_mediaAssetQuery.CreatedAt = field.NewTime(tableName, "created_at")
|
_mediaAssetQuery.CreatedAt = field.NewTime(tableName, "created_at")
|
||||||
_mediaAssetQuery.UpdatedAt = field.NewTime(tableName, "updated_at")
|
_mediaAssetQuery.UpdatedAt = field.NewTime(tableName, "updated_at")
|
||||||
_mediaAssetQuery.DeletedAt = field.NewField(tableName, "deleted_at")
|
_mediaAssetQuery.DeletedAt = field.NewField(tableName, "deleted_at")
|
||||||
|
_mediaAssetQuery.Hash = field.NewString(tableName, "hash")
|
||||||
|
|
||||||
_mediaAssetQuery.fillFieldMap()
|
_mediaAssetQuery.fillFieldMap()
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ type mediaAssetQuery struct {
|
|||||||
CreatedAt field.Time
|
CreatedAt field.Time
|
||||||
UpdatedAt field.Time
|
UpdatedAt field.Time
|
||||||
DeletedAt field.Field
|
DeletedAt field.Field
|
||||||
|
Hash field.String // File SHA-256 hash
|
||||||
|
|
||||||
fieldMap map[string]field.Expr
|
fieldMap map[string]field.Expr
|
||||||
}
|
}
|
||||||
@@ -93,6 +95,7 @@ func (m *mediaAssetQuery) updateTableName(table string) *mediaAssetQuery {
|
|||||||
m.CreatedAt = field.NewTime(table, "created_at")
|
m.CreatedAt = field.NewTime(table, "created_at")
|
||||||
m.UpdatedAt = field.NewTime(table, "updated_at")
|
m.UpdatedAt = field.NewTime(table, "updated_at")
|
||||||
m.DeletedAt = field.NewField(table, "deleted_at")
|
m.DeletedAt = field.NewField(table, "deleted_at")
|
||||||
|
m.Hash = field.NewString(table, "hash")
|
||||||
|
|
||||||
m.fillFieldMap()
|
m.fillFieldMap()
|
||||||
|
|
||||||
@@ -125,7 +128,7 @@ func (m *mediaAssetQuery) GetFieldByName(fieldName string) (field.OrderExpr, boo
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *mediaAssetQuery) fillFieldMap() {
|
func (m *mediaAssetQuery) fillFieldMap() {
|
||||||
m.fieldMap = make(map[string]field.Expr, 14)
|
m.fieldMap = make(map[string]field.Expr, 15)
|
||||||
m.fieldMap["id"] = m.ID
|
m.fieldMap["id"] = m.ID
|
||||||
m.fieldMap["tenant_id"] = m.TenantID
|
m.fieldMap["tenant_id"] = m.TenantID
|
||||||
m.fieldMap["user_id"] = m.UserID
|
m.fieldMap["user_id"] = m.UserID
|
||||||
@@ -140,6 +143,7 @@ func (m *mediaAssetQuery) fillFieldMap() {
|
|||||||
m.fieldMap["created_at"] = m.CreatedAt
|
m.fieldMap["created_at"] = m.CreatedAt
|
||||||
m.fieldMap["updated_at"] = m.UpdatedAt
|
m.fieldMap["updated_at"] = m.UpdatedAt
|
||||||
m.fieldMap["deleted_at"] = m.DeletedAt
|
m.fieldMap["deleted_at"] = m.DeletedAt
|
||||||
|
m.fieldMap["hash"] = m.Hash
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m mediaAssetQuery) clone(db *gorm.DB) mediaAssetQuery {
|
func (m mediaAssetQuery) clone(db *gorm.DB) mediaAssetQuery {
|
||||||
|
|||||||
@@ -14,27 +14,27 @@ func TestStorageProvider(t *testing.T) {
|
|||||||
// Mock Config to match what we expect in config.toml
|
// Mock Config to match what we expect in config.toml
|
||||||
// We use a map to simulate how unmarshal might see it, or just use the Config struct directly if we can manual init.
|
// We use a map to simulate how unmarshal might see it, or just use the Config struct directly if we can manual init.
|
||||||
// But provider uses UnmarshalConfig.
|
// But provider uses UnmarshalConfig.
|
||||||
|
|
||||||
// To test properly, we should try to boot the provider or check the logic.
|
// To test properly, we should try to boot the provider or check the logic.
|
||||||
// Let's manually init the Storage struct with the config we expect to be loaded.
|
// Let's manually init the Storage struct with the config we expect to be loaded.
|
||||||
|
|
||||||
cfg := &storage.Config{
|
cfg := &storage.Config{
|
||||||
Type: "local",
|
Type: "local",
|
||||||
LocalPath: "./storage",
|
LocalPath: "./storage",
|
||||||
Secret: "your-storage-secret",
|
Secret: "your-storage-secret",
|
||||||
BaseURL: "http://localhost:8080/v1/storage",
|
BaseURL: "http://localhost:8080/v1/storage",
|
||||||
}
|
}
|
||||||
|
|
||||||
s := &storage.Storage{Config: cfg}
|
s := &storage.Storage{Config: cfg}
|
||||||
|
|
||||||
Convey("SignURL should return absolute URL with BaseURL", func() {
|
Convey("SignURL should return absolute URL with BaseURL", func() {
|
||||||
key := "test.png"
|
key := "test.png"
|
||||||
url, err := s.SignURL("GET", key, 1*time.Hour)
|
url, err := s.SignURL("GET", key, 1*time.Hour)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
// Log for debugging
|
// Log for debugging
|
||||||
t.Logf("Generated URL: %s", url)
|
t.Logf("Generated URL: %s", url)
|
||||||
|
|
||||||
So(url, ShouldStartWith, "http://localhost:8080/v1/storage/test.png")
|
So(url, ShouldStartWith, "http://localhost:8080/v1/storage/test.png")
|
||||||
So(url, ShouldContainSubstring, "sign=")
|
So(url, ShouldContainSubstring, "sign=")
|
||||||
})
|
})
|
||||||
|
|||||||
20
frontend/portal/package-lock.json
generated
20
frontend/portal/package-lock.json
generated
@@ -18,7 +18,8 @@
|
|||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4",
|
||||||
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
@@ -2380,6 +2381,12 @@
|
|||||||
"@parcel/watcher": "^2.4.1"
|
"@parcel/watcher": "^2.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sortablejs": {
|
||||||
|
"version": "1.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
|
||||||
|
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -2582,6 +2589,17 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
|
||||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/vuedraggable": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
|
||||||
|
"dependencies": {
|
||||||
|
"sortablejs": "1.14.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4",
|
||||||
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
|||||||
@@ -2,10 +2,49 @@ import { request } from '../utils/request';
|
|||||||
|
|
||||||
export const commonApi = {
|
export const commonApi = {
|
||||||
getOptions: () => request('/common/options'),
|
getOptions: () => request('/common/options'),
|
||||||
|
checkHash: (hash) => request(`/upload/check?hash=${hash}`),
|
||||||
upload: (file, type) => {
|
upload: (file, type) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('type', type);
|
formData.append('type', type);
|
||||||
return request('/upload', { method: 'POST', body: formData });
|
return request('/upload', { method: 'POST', body: formData });
|
||||||
},
|
},
|
||||||
|
uploadWithProgress: (file, type, onProgress) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('type', type);
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/v1/upload');
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) {
|
||||||
|
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (event.lengthComputable && onProgress) {
|
||||||
|
const percentComplete = (event.loaded / event.total) * 100;
|
||||||
|
onProgress(percentComplete);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(xhr.responseText);
|
||||||
|
resolve(response);
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reject(new Error(xhr.statusText || 'Upload failed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => reject(new Error('Network Error'));
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,7 +28,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Form -->
|
<!-- Main Content Form -->
|
||||||
<div class="flex-1 overflow-y-auto bg-slate-50/50 p-8 md:p-12">
|
<div class="flex-1 overflow-y-auto bg-slate-50/50 p-8 md:p-12 relative">
|
||||||
|
<ProgressBar v-if="isUploading" mode="determinate" :value="uploadProgress" class="absolute top-0 left-0 w-full h-1 z-20" />
|
||||||
|
|
||||||
<div class="max-w-screen-xl mx-auto bg-white p-10 rounded-2xl border border-slate-200 shadow-sm space-y-10">
|
<div class="max-w-screen-xl mx-auto bg-white p-10 rounded-2xl border border-slate-200 shadow-sm space-y-10">
|
||||||
|
|
||||||
<!-- Row 1: Genre, Key & Title -->
|
<!-- Row 1: Genre, Key & Title -->
|
||||||
@@ -98,17 +100,20 @@
|
|||||||
|
|
||||||
<!-- Row 5: Covers (Max 3) -->
|
<!-- Row 5: Covers (Max 3) -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-bold text-slate-700 mb-3">封面 (最多3张)</label>
|
<label class="block text-sm font-bold text-slate-700 mb-3">封面 (最多3张, 拖拽排序)</label>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div class="flex flex-wrap gap-4">
|
||||||
<div v-for="(img, idx) in form.covers" :key="idx"
|
<draggable v-model="form.covers" item-key="id" class="flex flex-wrap gap-4" group="covers">
|
||||||
class="relative group aspect-video rounded-lg overflow-hidden bg-slate-100 border border-slate-200">
|
<template #item="{ element: img, index }">
|
||||||
<img :src="img.url" class="w-full h-full object-cover">
|
<div class="relative group w-48 aspect-video rounded-lg overflow-hidden bg-slate-100 border border-slate-200 cursor-move">
|
||||||
<button @click="removeCover(idx)"
|
<Image :src="img.url" preview imageClass="w-full h-full object-cover" />
|
||||||
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
|
<button @click="removeCover(index)"
|
||||||
class="pi pi-times text-xs"></i></button>
|
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 z-10"><i
|
||||||
</div>
|
class="pi pi-times text-xs"></i></button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
<div v-if="form.covers.length < 3" @click="triggerUpload('cover')"
|
<div v-if="form.covers.length < 3" @click="triggerUpload('cover')"
|
||||||
class="aspect-video rounded-lg border-2 border-dashed border-slate-300 flex flex-col items-center justify-center text-slate-400 hover:border-primary-500 hover:bg-primary-50 hover:text-primary-600 cursor-pointer transition-all">
|
class="w-48 aspect-video rounded-lg border-2 border-dashed border-slate-300 flex flex-col items-center justify-center text-slate-400 hover:border-primary-500 hover:bg-primary-50 hover:text-primary-600 cursor-pointer transition-all">
|
||||||
<i class="pi pi-plus text-2xl mb-1"></i>
|
<i class="pi pi-plus text-2xl mb-1"></i>
|
||||||
<span class="text-xs font-bold">上传封面</span>
|
<span class="text-xs font-bold">上传封面</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,20 +122,23 @@
|
|||||||
|
|
||||||
<!-- Row 6: Video -->
|
<!-- Row 6: Video -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-bold text-slate-700 mb-3">视频列表</label>
|
<label class="block text-sm font-bold text-slate-700 mb-3">视频列表 (拖拽排序)</label>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div v-for="(file, idx) in form.videos" :key="idx"
|
<draggable v-model="form.videos" item-key="id" class="space-y-3" group="videos">
|
||||||
class="flex items-center gap-4 p-3 border border-slate-200 rounded-lg bg-slate-50">
|
<template #item="{ element: file, index }">
|
||||||
<div class="w-10 h-10 bg-blue-100 text-blue-600 rounded flex items-center justify-center"><i
|
<div class="flex items-center gap-4 p-3 border border-slate-200 rounded-lg bg-slate-50 cursor-move hover:border-primary-300 transition-colors">
|
||||||
class="pi pi-video"></i></div>
|
<div class="w-10 h-10 bg-blue-100 text-blue-600 rounded flex items-center justify-center"><i
|
||||||
<div class="flex-1 min-w-0">
|
class="pi pi-video"></i></div>
|
||||||
<div class="font-bold text-sm text-slate-900 truncate">{{ file.name }}</div>
|
<div class="flex-1 min-w-0">
|
||||||
<div class="text-xs text-slate-500">{{ file.size }}</div>
|
<div class="font-bold text-sm text-slate-900 truncate">{{ file.name }}</div>
|
||||||
</div>
|
<div class="text-xs text-slate-500">{{ file.size }}</div>
|
||||||
<button @click="removeMedia('videos', idx)"
|
</div>
|
||||||
class="text-slate-400 hover:text-red-500 px-2 cursor-pointer"><i
|
<button @click="removeMedia('videos', index)"
|
||||||
class="pi pi-trash"></i></button>
|
class="text-slate-400 hover:text-red-500 px-2 cursor-pointer"><i
|
||||||
</div>
|
class="pi pi-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
<div @click="triggerUpload('video')"
|
<div @click="triggerUpload('video')"
|
||||||
class="h-14 border-2 border-dashed border-slate-300 rounded-lg flex items-center justify-center text-slate-500 hover:border-blue-500 hover:bg-blue-50 hover:text-blue-600 cursor-pointer transition-all font-bold gap-2">
|
class="h-14 border-2 border-dashed border-slate-300 rounded-lg flex items-center justify-center text-slate-500 hover:border-blue-500 hover:bg-blue-50 hover:text-blue-600 cursor-pointer transition-all font-bold gap-2">
|
||||||
<i class="pi pi-video"></i> 添加视频
|
<i class="pi pi-video"></i> 添加视频
|
||||||
@@ -140,20 +148,23 @@
|
|||||||
|
|
||||||
<!-- Row 7: Audio -->
|
<!-- Row 7: Audio -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-bold text-slate-700 mb-3">音频列表</label>
|
<label class="block text-sm font-bold text-slate-700 mb-3">音频列表 (拖拽排序)</label>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div v-for="(file, idx) in form.audios" :key="idx"
|
<draggable v-model="form.audios" item-key="id" class="space-y-3" group="audios">
|
||||||
class="flex items-center gap-4 p-3 border border-slate-200 rounded-lg bg-slate-50">
|
<template #item="{ element: file, index }">
|
||||||
<div class="w-10 h-10 bg-purple-100 text-purple-600 rounded flex items-center justify-center"><i
|
<div class="flex items-center gap-4 p-3 border border-slate-200 rounded-lg bg-slate-50 cursor-move hover:border-purple-300 transition-colors">
|
||||||
class="pi pi-microphone"></i></div>
|
<div class="w-10 h-10 bg-purple-100 text-purple-600 rounded flex items-center justify-center"><i
|
||||||
<div class="flex-1 min-w-0">
|
class="pi pi-microphone"></i></div>
|
||||||
<div class="font-bold text-sm text-slate-900 truncate">{{ file.name }}</div>
|
<div class="flex-1 min-w-0">
|
||||||
<div class="text-xs text-slate-500">{{ file.size }}</div>
|
<div class="font-bold text-sm text-slate-900 truncate">{{ file.name }}</div>
|
||||||
</div>
|
<div class="text-xs text-slate-500">{{ file.size }}</div>
|
||||||
<button @click="removeMedia('audios', idx)"
|
</div>
|
||||||
class="text-slate-400 hover:text-red-500 px-2 cursor-pointer"><i
|
<button @click="removeMedia('audios', index)"
|
||||||
class="pi pi-trash"></i></button>
|
class="text-slate-400 hover:text-red-500 px-2 cursor-pointer"><i
|
||||||
</div>
|
class="pi pi-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
<div @click="triggerUpload('audio')"
|
<div @click="triggerUpload('audio')"
|
||||||
class="h-14 border-2 border-dashed border-slate-300 rounded-lg flex items-center justify-center text-slate-500 hover:border-purple-500 hover:bg-purple-50 hover:text-purple-600 cursor-pointer transition-all font-bold gap-2">
|
class="h-14 border-2 border-dashed border-slate-300 rounded-lg flex items-center justify-center text-slate-500 hover:border-purple-500 hover:bg-purple-50 hover:text-purple-600 cursor-pointer transition-all font-bold gap-2">
|
||||||
<i class="pi pi-microphone"></i> 添加音频
|
<i class="pi pi-microphone"></i> 添加音频
|
||||||
@@ -163,20 +174,23 @@
|
|||||||
|
|
||||||
<!-- Row 8: Images -->
|
<!-- Row 8: Images -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-bold text-slate-700 mb-3">图片列表</label>
|
<label class="block text-sm font-bold text-slate-700 mb-3">图片列表 (拖拽排序)</label>
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div class="flex flex-wrap gap-4">
|
||||||
<div v-for="(file, idx) in form.images" :key="idx"
|
<draggable v-model="form.images" item-key="id" class="flex flex-wrap gap-4" group="images">
|
||||||
class="relative group aspect-square rounded-lg overflow-hidden border border-slate-200">
|
<template #item="{ element: file, index }">
|
||||||
<img :src="file.url" class="w-full h-full object-cover">
|
<div class="relative group w-32 aspect-square rounded-lg overflow-hidden border border-slate-200 cursor-move">
|
||||||
<div
|
<Image :src="file.url" preview imageClass="w-full h-full object-cover" />
|
||||||
class="absolute bottom-0 left-0 w-full bg-black/60 text-white text-xs p-1 truncate text-center">
|
<div
|
||||||
{{ file.name }}</div>
|
class="absolute bottom-0 left-0 w-full bg-black/60 text-white text-xs p-1 truncate text-center pointer-events-none">
|
||||||
<button @click="removeMedia('images', idx)"
|
{{ file.name }}</div>
|
||||||
class="absolute top-1 right-1 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"><i
|
<button @click="removeMedia('images', index)"
|
||||||
class="pi pi-times text-xs"></i></button>
|
class="absolute top-1 right-1 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer z-10"><i
|
||||||
</div>
|
class="pi pi-times text-xs"></i></button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
<div @click="triggerUpload('image')"
|
<div @click="triggerUpload('image')"
|
||||||
class="aspect-square border-2 border-dashed border-slate-300 rounded-lg flex flex-col items-center justify-center text-slate-500 hover:border-orange-500 hover:bg-orange-50 hover:text-orange-600 cursor-pointer transition-all">
|
class="w-32 aspect-square border-2 border-dashed border-slate-300 rounded-lg flex flex-col items-center justify-center text-slate-500 hover:border-orange-500 hover:bg-orange-50 hover:text-orange-600 cursor-pointer transition-all">
|
||||||
<i class="pi pi-image text-2xl mb-1"></i>
|
<i class="pi pi-image text-2xl mb-1"></i>
|
||||||
<span class="text-xs font-bold">添加图片</span>
|
<span class="text-xs font-bold">添加图片</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,7 +207,10 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import Select from 'primevue/select';
|
import Select from 'primevue/select';
|
||||||
|
import ProgressBar from 'primevue/progressbar';
|
||||||
|
import Image from 'primevue/image';
|
||||||
import Toast from 'primevue/toast';
|
import Toast from 'primevue/toast';
|
||||||
|
import draggable from 'vuedraggable';
|
||||||
import { useToast } from 'primevue/usetoast';
|
import { useToast } from 'primevue/usetoast';
|
||||||
import { computed, reactive, ref, onMounted } from 'vue';
|
import { computed, reactive, ref, onMounted } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
@@ -206,6 +223,7 @@ const toast = useToast();
|
|||||||
const fileInput = ref(null);
|
const fileInput = ref(null);
|
||||||
const currentUploadType = ref('');
|
const currentUploadType = ref('');
|
||||||
const isUploading = ref(false);
|
const isUploading = ref(false);
|
||||||
|
const uploadProgress = ref(0);
|
||||||
const isSubmitting = ref(false);
|
const isSubmitting = ref(false);
|
||||||
const isEditMode = ref(false);
|
const isEditMode = ref(false);
|
||||||
const contentId = ref('');
|
const contentId = ref('');
|
||||||
@@ -278,12 +296,20 @@ const triggerUpload = (type) => {
|
|||||||
fileInput.value.click();
|
fileInput.value.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const calculateHash = async (file) => {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
};
|
||||||
|
|
||||||
const handleFileChange = async (event) => {
|
const handleFileChange = async (event) => {
|
||||||
const files = event.target.files;
|
const files = event.target.files;
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
|
|
||||||
isUploading.value = true;
|
isUploading.value = true;
|
||||||
toast.add({ severity: 'info', summary: '正在上传', detail: '文件上传中...', life: 3000 });
|
uploadProgress.value = 0;
|
||||||
|
// toast.add({ severity: 'info', summary: '正在上传', detail: '文件上传中...', life: 3000 });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
@@ -294,7 +320,24 @@ const handleFileChange = async (event) => {
|
|||||||
if (currentUploadType.value === 'audio') type = 'audio';
|
if (currentUploadType.value === 'audio') type = 'audio';
|
||||||
if (currentUploadType.value === 'cover') type = 'image';
|
if (currentUploadType.value === 'cover') type = 'image';
|
||||||
|
|
||||||
const res = await commonApi.upload(file, type);
|
// Check Hash first
|
||||||
|
const hash = await calculateHash(file);
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await commonApi.checkHash(hash);
|
||||||
|
} catch (e) {
|
||||||
|
// Not found or error, proceed to upload
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
res = await commonApi.uploadWithProgress(file, type, (progress) => {
|
||||||
|
uploadProgress.value = Math.round(progress);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Instant completion if hash found
|
||||||
|
uploadProgress.value = 100;
|
||||||
|
}
|
||||||
|
|
||||||
// res: { id, url, ... }
|
// res: { id, url, ... }
|
||||||
|
|
||||||
if (currentUploadType.value === 'cover') {
|
if (currentUploadType.value === 'cover') {
|
||||||
@@ -313,6 +356,7 @@ const handleFileChange = async (event) => {
|
|||||||
toast.add({ severity: 'error', summary: '上传失败', detail: e.message, life: 3000 });
|
toast.add({ severity: 'error', summary: '上传失败', detail: e.message, life: 3000 });
|
||||||
} finally {
|
} finally {
|
||||||
isUploading.value = false;
|
isUploading.value = false;
|
||||||
|
uploadProgress.value = 0;
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user