From 8696d541211839f3b58e79b0c7b52ac282c73b48 Mon Sep 17 00:00:00 2001 From: cybaka520 Date: Tue, 20 Oct 2026 13:43:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=8A=95=E7=A8=BF?= =?UTF-8?q?=E8=80=85=E6=AD=8C=E8=AF=8D=E7=BC=96=E8=BE=91=E5=99=A8=E5=8F=8A?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E8=A6=81=E6=B1=82=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/revision.go | 24 + backend/internal/handler/submission.go | 33 ++ backend/internal/router/router.go | 4 + backend/internal/service/file_service.go | 18 + backend/internal/service/revision_service.go | 103 +++- .../internal/service/submission_service.go | 317 ++++++++++ .../components/creator/AudioSourcePicker.tsx | 157 +++++ .../components/creator/LyricSubmitForm.tsx | 127 +++- .../src/components/review/EditorWorkbench.tsx | 44 +- .../components/review/LyricEditorHeader.tsx | 41 +- frontend/src/components/ui/dialog.tsx | 8 +- frontend/src/components/ui/switch.tsx | 3 +- .../src/editor/components/ConfirmDialog.tsx | 2 +- .../editor/components/RibbonBar/edit-mode.tsx | 12 + .../ReviewRequirementsPanel.module.css | 189 ++++++ .../Sidebar/ReviewRequirementsPanel.tsx | 246 ++++++++ .../src/editor/components/Sidebar/index.tsx | 8 +- .../modules/project/modals/MetadataEditor.tsx | 13 +- .../review/modals/ReportFormatEditor.tsx | 29 +- .../review/modals/ReviewReportDialog.tsx | 158 +++-- .../src/editor/modules/review/requirements.ts | 549 ++++++++++++++++++ .../services/report-service/format-service.ts | 56 +- .../report-service/format-template.ts | 97 +++- .../services/report-service/render-service.ts | 2 +- frontend/src/editor/modules/review/states.ts | 3 + frontend/src/editor/states/dialogs.ts | 2 + frontend/src/editor/states/sidebar.ts | 2 +- frontend/src/lib/api.ts | 14 + frontend/src/lib/query.ts | 2 + frontend/src/lib/review/editor-save-bundle.ts | 20 +- frontend/src/lib/types.ts | 25 + frontend/src/pages/LyricEditorPage.tsx | 130 ++++- frontend/src/pages/SubmissionDetailPage.tsx | 27 +- frontend/src/router.tsx | 9 + 34 files changed, 2295 insertions(+), 179 deletions(-) create mode 100644 frontend/src/components/creator/AudioSourcePicker.tsx create mode 100644 frontend/src/editor/components/Sidebar/ReviewRequirementsPanel.module.css create mode 100644 frontend/src/editor/components/Sidebar/ReviewRequirementsPanel.tsx create mode 100644 frontend/src/editor/modules/review/requirements.ts diff --git a/backend/internal/handler/revision.go b/backend/internal/handler/revision.go index 61889cd..1aa2cc7 100644 --- a/backend/internal/handler/revision.go +++ b/backend/internal/handler/revision.go @@ -45,6 +45,30 @@ func (h *RevisionHandler) GetEditorContext(c *gin.Context) { pkg.OK(c, data) } +// GetOwnerEditorContext GET /api/v1/submissions/:id/edit-context +// 投稿者进入编辑器时拉取:原投稿 TTML + 可选音频列表 +func (h *RevisionHandler) GetOwnerEditorContext(c *gin.Context) { + user := currentUser(c) + if user == nil { + pkg.Unauthorized(c) + return + } + id, ok := parseIDParam(c) + if !ok { + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), longTimeout) + defer cancel() + + data, err := h.svc.GetOwnerEditorContext(ctx, user, id) + if err != nil { + writeSubmissionErr(c, err) + return + } + pkg.OK(c, data) +} + // Save POST /api/v1/submissions/:id/revision func (h *RevisionHandler) Save(c *gin.Context) { user := currentUser(c) diff --git a/backend/internal/handler/submission.go b/backend/internal/handler/submission.go index fe51fa2..f945037 100644 --- a/backend/internal/handler/submission.go +++ b/backend/internal/handler/submission.go @@ -195,6 +195,37 @@ func (h *SubmissionHandler) UpdateFile(c *gin.Context) { pkg.OKWithMsg(c, nil, "文件已更新") } +// SaveContent PUT /api/v1/submissions/:id/content +// 投稿者在编辑器中修改歌词后直接保存,无需先上传文件 +func (h *SubmissionHandler) SaveContent(c *gin.Context) { + user := currentUser(c) + if user == nil { + pkg.Unauthorized(c) + return + } + + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + pkg.BadRequest(c, "无效的 id") + return + } + + var req service.SaveSubmitterContentInput + if err := c.ShouldBindJSON(&req); err != nil { + pkg.BadRequest(c, "参数错误") + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), longTimeout) + defer cancel() + + if err := h.svc.SaveSubmitterContent(ctx, user, id, &req); err != nil { + writeSubmissionErr(c, err) + return + } + pkg.OKWithMsg(c, nil, "歌词已提交修改") +} + // Close POST /api/v1/submissions/:id/close func (h *SubmissionHandler) Close(c *gin.Context) { user := currentUser(c) @@ -286,6 +317,8 @@ func writeSubmissionErr(c *gin.Context, err error) { pkg.Fail(c, http.StatusForbidden, http.StatusForbidden, "无权操作该投稿") case errors.Is(err, service.ErrMissingUnrearrangedReason): pkg.BadRequest(c, "勾选未重排歌词时必须填写原因") + case errors.Is(err, service.ErrInvalidInput): + pkg.BadRequest(c, "请求参数非法") case errors.Is(err, service.ErrUpstreamUnavailable): writeUpstreamErr(c, err, "上游服务暂不可用") default: diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 6ca9f8f..6b8c6b0 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -217,6 +217,8 @@ func New(deps RouterDeps) *gin.Engine { sub.GET("/submissions/:id", submissionH.GetDetail) sub.GET("/submissions/:id/ttml", submissionH.GetTtml) sub.PUT("/submissions/:id/file", submissionH.UpdateFile) + // 投稿者编辑器保存(直接提交编辑器生成的 TTML) + sub.PUT("/submissions/:id/content", submissionH.SaveContent) sub.POST("/submissions/:id/close", submissionH.Close) // TTML 校验 @@ -248,6 +250,8 @@ func New(deps RouterDeps) *gin.Engine { middleware.RequireReviewer(reviewerCache), revisionH.GetEditorContext, ) + // 投稿者编辑器上下文(仅投稿者本人) + sub.GET("/submissions/:id/edit-context", revisionH.GetOwnerEditorContext) sub.POST("/submissions/:id/revision", middleware.RequireReviewer(reviewerCache), revisionH.Save, diff --git a/backend/internal/service/file_service.go b/backend/internal/service/file_service.go index 44acb6d..62bd8c7 100644 --- a/backend/internal/service/file_service.go +++ b/backend/internal/service/file_service.go @@ -63,6 +63,24 @@ func (s *FileService) UploadTTML(ctx context.Context, fileName string, content [ return err } +// UploadTTMLTemp 上传到临时对象,事务成功后再 Move 到正式 key,避免事务失败覆盖正式文件 +func (s *FileService) UploadTTMLTemp(ctx context.Context, fileName string, content []byte) (string, error) { + if err := validateTTMLFileName(fileName); err != nil { + return "", err + } + if len(content) > int(s.cfg.Submission.MaxTTMLSize) { + return "", fmt.Errorf("TTML 文件大小超过 %d 字节", s.cfg.Submission.MaxTTMLSize) + } + key := fmt.Sprintf("%s.tmp-%d-%s", PendingLyricsPrefix, time.Now().UnixNano(), fileName) + _, err := s.minio.PutObject(ctx, s.bucket, key, bytes.NewReader(content), int64(len(content)), minio.PutObjectOptions{ + ContentType: "application/xml; charset=utf-8", + }) + if err != nil { + return "", err + } + return key, nil +} + // UploadAudio 上传音频附件 func (s *FileService) UploadAudio(ctx context.Context, submissionID int64, fileName string, content []byte) (string, error) { if len(content) > int(s.cfg.Submission.MaxAudioSize) { diff --git a/backend/internal/service/revision_service.go b/backend/internal/service/revision_service.go index 763ed16..a90cdac 100644 --- a/backend/internal/service/revision_service.go +++ b/backend/internal/service/revision_service.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "sort" "strings" "time" @@ -482,6 +483,23 @@ func (s *RevisionService) GetRevisionContent(ctx context.Context, user *Submissi return *sub.RevisionTTML, nil } +// EditorReviewRequirement 最近一轮审核要求(结构化报告或文字意见) +type EditorReviewRequirement struct { + // Source report / comment + Source string `json:"source"` + // Action 审核动作或历史状态 + Action string `json:"action"` + // Comment 文字审核意见 + Comment string `json:"comment,omitempty"` + // ReportMD 审核报告 Markdown + ReportMD string `json:"reportMd,omitempty"` + // Structured 结构化报告,前端从中取按行条目 + Structured map[string]any `json:"structured,omitempty"` + Reviewer string `json:"reviewer,omitempty"` + ReviewerInfo model.UserInfo `json:"reviewerInfo"` + CreatedAt time.Time `json:"createdAt"` +} + // EditorContext 审核员进入编辑器所需的上下文 type EditorContext struct { // TTML 原始投稿文件内容 @@ -496,6 +514,8 @@ type EditorContext struct { Status string `json:"status"` // HasRevision 是否已存在待确认的修订版 HasRevision bool `json:"hasRevision"` + // Review 最近一轮审核要求 + Review *EditorReviewRequirement `json:"review,omitempty"` } // GetEditorContext 审核员打开编辑器时拉取上下文 @@ -507,6 +527,25 @@ func (s *RevisionService) GetEditorContext(ctx context.Context, id int64) (*Edit } return nil, err } + return s.buildEditorContext(ctx, sub) +} + +// GetOwnerEditorContext 投稿者打开编辑器时拉取上下文 +func (s *RevisionService) GetOwnerEditorContext(ctx context.Context, user *SubmissionUser, id int64) (*EditorContext, error) { + sub, err := s.subRepo.GetByID(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSubmissionNotFound + } + return nil, err + } + if user == nil || sub.Submitter != user.Name { + return nil, ErrForbidden + } + return s.buildEditorContext(ctx, sub) +} + +func (s *RevisionService) buildEditorContext(ctx context.Context, sub *model.Submission) (*EditorContext, error) { if sub.FileName == "" { return nil, ErrMissingFile } @@ -520,10 +559,15 @@ func (s *RevisionService) GetEditorContext(ctx context.Context, id int64) (*Edit return nil, fmt.Errorf("read ttml: %w", err) } - audios, err := s.audioRepo.ListBySubmissionID(ctx, id) + audios, err := s.audioRepo.ListBySubmissionID(ctx, sub.ID) if err != nil { return nil, err } + review, err := s.latestReviewRequirement(ctx, sub.ID) + if err != nil { + // 审核要求读取失败不影响编辑器打开 + logrus.WithError(err).Warn("load editor review requirement failed") + } return &EditorContext{ TTML: raw, FileName: sub.FileName, @@ -531,9 +575,66 @@ func (s *RevisionService) GetEditorContext(ctx context.Context, id int64) (*Edit NcmID: firstNonEmpty(sub.NcmID, extractPlatformId(map[string]any(sub.Metadata), "ncm_music_id")), Status: sub.Status, HasRevision: sub.RevisionFileKey != "", + Review: review, }, nil } +// latestReviewRequirement 取最近一轮审核要求: +// 优先结构化审核报告;若之后又有带文字的新审核意见,则用文字意见。 +func (s *RevisionService) latestReviewRequirement(ctx context.Context, submissionID int64) (*EditorReviewRequirement, error) { + report, err := s.reportRepo.LatestBySubmission(ctx, submissionID) + if err != nil { + return nil, err + } + history, err := s.historyRepo.ListBySubmission(ctx, submissionID) + if err != nil { + return nil, err + } + // 不依赖仓储排序实现,Service 层显式按审核时间倒序兜底 + sort.SliceStable(history, func(i, j int) bool { + return history[i].ReviewedAt.After(history[j].ReviewedAt) + }) + var latest *model.ReviewHistory + if len(history) > 0 { + latest = &history[0] + } + + if report != nil { + hasNewerComment := latest != nil && + strings.TrimSpace(latest.Comment) != "" && + latest.ReviewedAt.After(report.CreatedAt) + if !hasNewerComment { + return reviewRequirementFromReport(report), nil + } + } + if latest != nil && strings.TrimSpace(latest.Comment) != "" { + return &EditorReviewRequirement{ + Source: "comment", + Action: latest.Status, + Comment: latest.Comment, + Reviewer: latest.Reviewer, + ReviewerInfo: latest.ReviewerInfo, + CreatedAt: latest.ReviewedAt, + }, nil + } + if report != nil { + return reviewRequirementFromReport(report), nil + } + return nil, nil +} + +func reviewRequirementFromReport(report *model.ReviewReport) *EditorReviewRequirement { + return &EditorReviewRequirement{ + Source: "report", + Action: report.Action, + ReportMD: report.ReportMD, + Structured: map[string]any(report.Structured), + Reviewer: report.Reviewer, + ReviewerInfo: report.ReviewerInfo, + CreatedAt: report.CreatedAt, + } +} + // firstNonEmpty 返回首个非空字符串,用于多来源字段的兜底 func firstNonEmpty(vals ...string) string { for _, v := range vals { diff --git a/backend/internal/service/submission_service.go b/backend/internal/service/submission_service.go index f98d8f2..464305f 100644 --- a/backend/internal/service/submission_service.go +++ b/backend/internal/service/submission_service.go @@ -516,6 +516,323 @@ func (s *SubmissionService) UpdateFile(ctx context.Context, user *SubmissionUser return nil } +// SaveSubmitterContentInput 投稿者通过编辑器保存歌词的入参 +type SaveSubmitterContentInput struct { + // TTML 编辑器导出的完整歌词内容 + TTML string `json:"ttml"` + // Metadata 编辑器中的元数据(key → 多值),由后端映射回投稿元数据结构 + Metadata map[string][]string `json:"metadata"` + // ReportMD 编辑器自动生成并可编辑的修改摘要 + ReportMD string `json:"reportMd"` +} + +// SaveSubmitterContent 投稿者在编辑器中修改歌词后重新提交 +func (s *SubmissionService) SaveSubmitterContent(ctx context.Context, user *SubmissionUser, id int64, in *SaveSubmitterContentInput) error { + if strings.TrimSpace(in.TTML) == "" { + return fmt.Errorf("%w: 歌词内容为空", ErrInvalidInput) + } + if int64(len(in.TTML)) > s.files.MaxTTMLSize() { + return fmt.Errorf("%w: TTML 文件大小超过限制", ErrInvalidInput) + } + + sub, err := s.subRepo.GetByID(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrSubmissionNotFound + } + return err + } + if sub.Submitter != user.Name { + return ErrForbidden + } + if err := ensureSubmitterEditable(sub.Status); err != nil { + return err + } + if sub.FileName == "" { + return ErrMissingFile + } + + // 先写临时对象;事务成功后再替换正式文件,避免事务失败时正式文件已被覆盖 + tempKey, uploadErr := s.files.UploadTTMLTemp(ctx, sub.FileName, []byte(in.TTML)) + if uploadErr != nil { + return fmt.Errorf("保存歌词文件失败: %w", uploadErr) + } + finalKey := PendingLyricKey(sub.FileName) + defer func() { + if tempKey == "" { + return + } + cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := s.files.Delete(cleanupCtx, tempKey); err != nil { + logrus.WithFields(logrus.Fields{ + "submission_id": id, + "temp_key": tempKey, + "error": err, + }).Warn("cleanup temp ttml failed") + } + }() + + metadata := mergeEditorMetadata(map[string]any(sub.Metadata), in.Metadata) + artists := stringValues(metadata["artist"]) + album := stringValues(metadata["album"]) + title := firstStringValue(metadata["title"], sub.Title) + now := time.Now() + + // 编辑器输出是结构化 TTML,不再保留「未重排」标记 + updates := map[string]any{ + "title": sanitize(title, 200), + "artist": strings.Join(artists, ", "), + "album": strings.Join(album, ", "), + "ncm_id": platformID(metadata, "ncm_music_id"), + "qq_id": platformID(metadata, "qq_music_id"), + "am_id": platformID(metadata, "apple_music_id"), + "spotify_id": platformID(metadata, "spotify_id"), + "metadata": model.JSONObject(metadata), + "is_unrearranged": false, + "unrearranged_reason": "", + "file_updated_at": now, + } + // missing_audio 的稿件保存后仍保持缺音频,等待补音频;其余状态重新排队 + if sub.Status != model.StatusMissingAudio { + updates["status"] = model.StatusPending + } + + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + su, err := s.subRepo.GetByIDForUpdate(ctx, tx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrSubmissionNotFound + } + return err + } + if su.Submitter != user.Name { + return ErrForbidden + } + // 事务内二次校验,避免读取后状态被并发改成终态 + if err := ensureSubmitterEditable(su.Status); err != nil { + return err + } + if err := tx.WithContext(ctx).Model(&model.Submission{}). + Where("id = ?", id). + Updates(updates).Error; err != nil { + return err + } + return s.fileHistoryRepo.Insert(ctx, tx, &model.SubmissionFileHistory{ + SubmissionID: id, + Uploader: user.Name, + UploaderInfo: model.UserInfo{Username: user.Name, DisplayName: user.DisplayName, Avatar: user.Avatar}, + FileName: su.FileName, + UploadedAt: now, + }) + }) + if err != nil { + return err + } + + // 事务已提交,再把临时对象替换为正式文件 + if err := s.files.Move(ctx, tempKey, finalKey); err != nil { + return fmt.Errorf("保存歌词文件失败: %w", err) + } + tempKey = "" + + // 修改摘要作为投稿者评论留痕,写失败不影响保存结果 + reportMD := strings.TrimSpace(in.ReportMD) + if reportMD != "" { + if err := s.commentRepo.Insert(ctx, &model.Comment{ + SubmissionID: id, + Author: model.UserInfo{ + Username: user.Name, + DisplayName: user.DisplayName, + Avatar: user.Avatar, + }, + Content: "**投稿者修改摘要**\n\n" + reportMD, + }); err != nil { + logrus.WithFields(logrus.Fields{ + "submission_id": id, + "error": err, + }).Warn("insert submitter edit report failed") + } + } + return nil +} + +// ensureSubmitterEditable 判断投稿者当前状态下能否在编辑器中保存。 +// reviewing 状态不允许保存,避免审核员基于旧版本审核时投稿者改稿。 +func ensureSubmitterEditable(status string) error { + switch status { + case model.StatusPending, model.StatusNeedRevision, model.StatusMissingAudio: + return nil + default: + return ErrInvalidStatus + } +} + +// mergeEditorMetadata 把编辑器元数据合并回投稿元数据结构。 +// 保留 base 中与 TTML 无关的字段(如 language),并维持 platform_ids 的嵌套形状。 +func mergeEditorMetadata(base map[string]any, editor map[string][]string) map[string]any { + out := make(map[string]any, len(base)+4) + for k, v := range base { + out[k] = v + } + + // 同一目标字段可能有多个编辑器 key(如 title / musicName)。 + // 用有序别名表明确优先级,避免 map 遍历顺序导致落库值不确定。 + aliases := []struct { + target string + keys []string + }{ + {target: "title", keys: []string{"title", "musicName"}}, + {target: "artist", keys: []string{"artists"}}, + {target: "album", keys: []string{"album"}}, + {target: "songwriters", keys: []string{"songwriters", "songwriter"}}, + {target: "isrc", keys: []string{"isrc"}}, + {target: "author_ids", keys: []string{"ttmlAuthorGithub"}}, + {target: "author_names", keys: []string{"ttmlAuthorGithubLogin"}}, + } + known := make(map[string]struct{}, len(aliases)*2) + for _, alias := range aliases { + for _, key := range alias.keys { + known[key] = struct{}{} + } + } + for _, alias := range aliases { + found := false + var chosen []string + // 按优先级取第一个非空值;都为空时清空目标字段 + for _, key := range alias.keys { + values, ok := editor[key] + if !ok { + continue + } + found = true + if filtered := filterNonEmpty(values); len(filtered) > 0 { + chosen = filtered + break + } + } + if !found { + continue + } + if len(chosen) > 0 { + out[alias.target] = chosen + } else { + delete(out, alias.target) + } + } + + platformKeys := map[string]string{ + "ncmMusicId": "ncm_music_id", + "qqMusicId": "qq_music_id", + "spotifyId": "spotify_id", + "appleMusicId": "apple_music_id", + } + pids := map[string]any{} + if raw, ok := out["platform_ids"].(map[string]any); ok { + for k, v := range raw { + pids[k] = v + } + } + for editorKey, platformKey := range platformKeys { + values, ok := editor[editorKey] + if !ok { + continue + } + if filtered := filterNonEmpty(values); len(filtered) > 0 { + pids[platformKey] = filtered + } else { + delete(pids, platformKey) + } + } + if len(pids) > 0 { + out["platform_ids"] = pids + } else { + delete(out, "platform_ids") + } + + // 其余自定义键维持 raw_properties 形状 + rawProps := map[string]any{} + if raw, ok := out["raw_properties"].(map[string]any); ok { + for k, v := range raw { + rawProps[k] = v + } + } + for key, values := range editor { + if _, ok := known[key]; ok { + continue + } + if _, ok := platformKeys[key]; ok { + continue + } + if filtered := filterNonEmpty(values); len(filtered) > 0 { + rawProps[key] = filtered + } else { + delete(rawProps, key) + } + } + if len(rawProps) > 0 { + out["raw_properties"] = rawProps + } else { + delete(out, "raw_properties") + } + return out +} + +// filterNonEmpty 过滤空字符串并去除首尾空白 +func filterNonEmpty(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + if s := strings.TrimSpace(value); s != "" { + out = append(out, s) + } + } + return out +} + +// stringValues 把 metadata 中的值转成字符串数组 +func stringValues(value any) []string { + switch val := value.(type) { + case []string: + return val + case []any: + out := make([]string, 0, len(val)) + for _, item := range val { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } + } + return out + case string: + if val != "" { + return []string{val} + } + } + return nil +} + +// firstStringValue 返回第一个非空字符串,用于标题兜底 +func firstStringValue(value any, fallback string) string { + for _, s := range stringValues(value) { + if strings.TrimSpace(s) != "" { + return s + } + } + return fallback +} + +// platformID 从合并后的 metadata 中取平台 ID 的第一个值 +func platformID(metadata map[string]any, key string) string { + raw, ok := metadata["platform_ids"].(map[string]any) + if !ok { + return "" + } + values := stringValues(raw[key]) + if len(values) == 0 { + return "" + } + return values[0] +} + // Close 关闭投稿 func (s *SubmissionService) Close(ctx context.Context, user *SubmissionUser, id int64, isReviewer bool) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { diff --git a/frontend/src/components/creator/AudioSourcePicker.tsx b/frontend/src/components/creator/AudioSourcePicker.tsx new file mode 100644 index 0000000..e80da8b --- /dev/null +++ b/frontend/src/components/creator/AudioSourcePicker.tsx @@ -0,0 +1,157 @@ +import { useRef, useState } from 'react'; +import { Music, Plus, X } from 'lucide-react'; +import { formatBytes } from '@/lib/format'; + +/** 允许的音频扩展名与上限,与后端 isAllowedAudioExt / SUBMISSION_MAX_AUDIO_SIZE 保持一致 */ +const AUDIO_EXTS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.aac']; +export const MAX_AUDIO_COUNT = 5; +const MAX_AUDIO_SIZE = 50 * 1024 * 1024; + +// 投稿表单的音源选择区 +export function AudioSourcePicker({ + files, + onChange, + disabled = false, +}: { + files: File[]; + onChange: (files: File[]) => void; + disabled?: boolean; +}) { + const inputRef = useRef(null); + const [dragOver, setDragOver] = useState(false); + const [error, setError] = useState(''); + + const addFiles = (incoming: FileList | null) => { + if (!incoming) return; + const accepted: File[] = []; + let wrongExt = 0; + let tooLarge = 0; + let overflow = 0; + + for (const f of Array.from(incoming)) { + const ext = f.name.slice(f.name.lastIndexOf('.')).toLowerCase(); + if (!AUDIO_EXTS.includes(ext)) { + wrongExt++; + continue; + } + if (f.size > MAX_AUDIO_SIZE) { + tooLarge++; + continue; + } + if (files.length + accepted.length >= MAX_AUDIO_COUNT) { + overflow++; + continue; + } + // 同名同大小同修改时间视为重复选择,静默跳过 + const dup = [...files, ...accepted].some( + (x) => x.name === f.name && x.size === f.size && x.lastModified === f.lastModified + ); + if (!dup) accepted.push(f); + } + + if (accepted.length > 0) onChange([...files, ...accepted]); + + const reasons: string[] = []; + if (wrongExt) reasons.push(`${wrongExt} 个格式不支持`); + if (tooLarge) reasons.push(`${tooLarge} 个超过 50 MB`); + if (overflow) reasons.push(`${overflow} 个超出 ${MAX_AUDIO_COUNT} 个上限`); + setError(reasons.length > 0 ? `已忽略:${reasons.join('、')}` : ''); + }; + + const remove = (target: File) => { + onChange(files.filter((f) => f !== target)); + setError(''); + }; + + return ( +
+
+ + {files.length > 0 && ( + + {files.length}/{MAX_AUDIO_COUNT} + + )} +
+ + { + addFiles(e.target.files); + // 允许重复选择同一个文件时仍触发 change + e.target.value = ''; + }} + /> + + {files.length === 0 ? ( +
{ + e.preventDefault(); + if (!disabled) setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { + e.preventDefault(); + setDragOver(false); + if (!disabled) addFiles(e.dataTransfer.files); + }} + onClick={() => !disabled && inputRef.current?.click()} + className={`flex flex-col items-center justify-center gap-1.5 rounded-md border-2 border-dashed py-8 transition-colors ${ + disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer' + } ${dragOver ? 'border-primary bg-primary-soft' : 'border-line bg-surface-2 hover:border-primary/50'}`} + > + +

拖拽或点击添加音频文件

+

+ MP3 / FLAC / WAV / M4A / OGG / AAC,单个 ≤ 50 MB,最多 {MAX_AUDIO_COUNT} 个 +

+
+ ) : ( +
+
    + {files.map((f) => ( +
  • + + {f.name} + {formatBytes(f.size)} + +
  • + ))} +
+ + {files.length < MAX_AUDIO_COUNT && ( + + )} +
+ )} + + {error &&

{error}

} +
+ ); +} diff --git a/frontend/src/components/creator/LyricSubmitForm.tsx b/frontend/src/components/creator/LyricSubmitForm.tsx index 2214076..0acfc07 100644 --- a/frontend/src/components/creator/LyricSubmitForm.tsx +++ b/frontend/src/components/creator/LyricSubmitForm.tsx @@ -17,6 +17,8 @@ import { buttonTap, fadeUp, staggerContainer } from '@/lib/motion'; import { useFormDraft } from '@/hooks/useFormDraft'; import { useUnrearrangedMode } from '@/hooks/useUnrearrangedMode'; import type { TtmlValidationResult } from '@/lib/types'; +import { AudioSourcePicker } from '@/components/creator/AudioSourcePicker'; +import { Checkbox } from '@/components/ui/checkbox'; import { Textarea } from '@/components/ui/textarea'; import { UnrearrangedToggle } from '@/components/submission/UnrearrangedToggle'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; @@ -71,6 +73,13 @@ export function LyricSubmitForm({ onSuccess }: { onSuccess?: () => void }) { ); const [dragOver, setDragOver] = useState(false); const [showTtml, setShowTtml] = useState(false); + // 原创/授权声明与投稿规范确认:不随草稿恢复,每次提交都需要重新勾选 + const [agreed, setAgreed] = useState(false); + // 音源附件 + const [audioFiles, setAudioFiles] = useState([]); + const [audioProgress, setAudioProgress] = useState<{ current: number; total: number } | null>( + null + ); const fileInputRef = useRef(null); // 选择文件后立即校验 @@ -97,7 +106,7 @@ export function LyricSubmitForm({ onSuccess }: { onSuccess?: () => void }) { // 元数据两种模式都从 /validate 的结果取;解析不到才退回文件名 const title = validation?.metadata?.title?.[0] ?? params.file.name.replace(/\.[^.]+$/, ''); const allLangs = [...languages, ...otherLangs]; - return api.createSubmission({ + const created = await api.createSubmission({ title, metadata: (validation?.metadata ?? {}) as Record, fileName, @@ -109,23 +118,47 @@ export function LyricSubmitForm({ onSuccess }: { onSuccess?: () => void }) { tags: tags.length > 0 ? tags : undefined, status: params.status, }); + // 音源为可选附件,只能挂在已创建的投稿下:逐个上传,单个失败不影响投稿本身 + const audioFailures: string[] = []; + for (const [i, audioFile] of audioFiles.entries()) { + setAudioProgress({ current: i + 1, total: audioFiles.length }); + try { + await api.uploadAudio(created.id, audioFile); + } catch { + audioFailures.push(audioFile.name); + } + } + setAudioProgress(null); + return { id: created.id, audioFailures }; }, onMutate: () => setSubmitMsg(null), onSuccess: (result, { status }) => { - setSubmitMsg({ - type: 'success', - text: status === 'draft' ? '草稿已保存,正在跳转…' : '投稿成功,正在跳转…', - }); reset(); + // 音源失败不回滚投稿,提示去详情页补传 + if (result.audioFailures.length > 0) { + setSubmitMsg({ + type: 'error', + text: `${status === 'draft' ? '草稿已保存' : '投稿已创建'},但 ${ + result.audioFailures.length + } 个音源上传失败(${result.audioFailures.join('、')}),可在详情页补传`, + }); + } else { + setSubmitMsg({ + type: 'success', + text: status === 'draft' ? '草稿已保存,正在跳转…' : '投稿成功,正在跳转…', + }); + } + const delay = result.audioFailures.length > 0 ? 3000 : 1200; setTimeout(() => { setSubmitMsg(null); onSuccess?.(); if (result?.id) { navigate(`/creator/lyrics/detail?id=${result.id}`, { replace: true }); } - }, 1200); + }, delay); }, onError: (err) => { + setAudioProgress(null); setSubmitMsg({ type: 'error', text: err instanceof Error ? err.message : '提交失败' }); }, }); @@ -144,6 +177,9 @@ export function LyricSubmitForm({ onSuccess }: { onSuccess?: () => void }) { setTags([]); setTagInput(''); setShowTtml(false); + setAgreed(false); + setAudioFiles([]); + setAudioProgress(null); clearDraft(); if (fileInputRef.current) fileInputRef.current.value = ''; }; @@ -236,10 +272,13 @@ export function LyricSubmitForm({ onSuccess }: { onSuccess?: () => void }) { // 表单主体是否展开:校验模式等 valid;未重排模式等元数据解析返回(不论 valid) const doSubmit = (status: 'pending' | 'draft') => { if (!file || submitting || !formReady || reasonMissing) return; + // 正式提交必须勾选原创/授权声明;存草稿不受此限制 + if (status === 'pending' && !agreed) return; submitMutation.mutate({ file, status }); }; const canSubmit = file !== null && formReady && !reasonMissing && !submitting; + const canSubmitPending = canSubmit && agreed; // 元数据辅助 const meta = validation?.metadata; @@ -468,6 +507,13 @@ export function LyricSubmitForm({ onSuccess }: { onSuccess?: () => void }) { )} + {/* 音源(可选,多选) */} + {formReady && ( + + + + )} + {/* 语言选择(多选) */} {formReady && ( @@ -609,31 +655,52 @@ export function LyricSubmitForm({ onSuccess }: { onSuccess?: () => void }) { )} - {/* 操作按钮 */} + {/* 原创/授权声明 + 操作按钮 */} {formReady && ( - - - doSubmit('pending')} - disabled={!canSubmit} - {...buttonTap} - className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-primary px-5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary-hover disabled:opacity-50" - > - {submitting ? ( - - ) : ( - - )} - 提交审核 - + + + + {audioProgress && ( +

+ 正在上传音源 {audioProgress.current}/{audioProgress.total}… +

+ )} + +
+ + doSubmit('pending')} + disabled={!canSubmitPending} + {...buttonTap} + className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-primary px-5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary-hover disabled:opacity-50" + > + {submitting ? ( + + ) : ( + + )} + 提交审核 + +
)}
diff --git a/frontend/src/components/review/EditorWorkbench.tsx b/frontend/src/components/review/EditorWorkbench.tsx index 17d8892..cfff36a 100644 --- a/frontend/src/components/review/EditorWorkbench.tsx +++ b/frontend/src/components/review/EditorWorkbench.tsx @@ -22,7 +22,7 @@ import './editor-theme.css'; const LyricLinesView = lazy(() => import('@/editor/modules/lyric-editor/components')); const AudioControls = lazy(() => import('@/editor/modules/audio/components/index.tsx')); -export function EditorWorkbench() { +export function EditorWorkbench({ textOnly = false }: { textOnly?: boolean }) { const toolMode = useAtomValue(toolModeAtom); const showTouchSyncPanel = useAtomValue(showTouchSyncPanelAtom); const setToolMode = useSetAtom(toolModeAtom); @@ -35,7 +35,11 @@ export function EditorWorkbench() { if (toolMode === ToolMode.Preview) { setToolMode(ToolMode.Edit); } - }, [setToolMode, toolMode]); + // 无参考音频时没有打轴基准,强制留在编辑模式 + if (textOnly && toolMode === ToolMode.Sync) { + setToolMode(ToolMode.Edit); + } + }, [setToolMode, textOnly, toolMode]); useEffect(() => { document.body.classList.add('editor-body'); @@ -45,7 +49,7 @@ export function EditorWorkbench() { return ( - {toolMode === ToolMode.Sync && } + {!textOnly && toolMode === ToolMode.Sync && }
- {showTouchSyncPanel && toolMode === ToolMode.Sync && } + {!textOnly && showTouchSyncPanel && toolMode === ToolMode.Sync && } - - - - - - - + {!textOnly && ( + + + + + + + + )} diff --git a/frontend/src/components/review/LyricEditorHeader.tsx b/frontend/src/components/review/LyricEditorHeader.tsx index 88f1ea3..6fb10d3 100644 --- a/frontend/src/components/review/LyricEditorHeader.tsx +++ b/frontend/src/components/review/LyricEditorHeader.tsx @@ -22,6 +22,10 @@ export interface EditorHeaderProps { onBack: () => void; onSave: () => void; saving: boolean; + /** 投稿者模式:文案与保存动作不同 */ + mode?: 'review' | 'creator'; + /** 无参考音频:隐藏打轴与音频相关控件 */ + textOnly?: boolean; } /** 参考音频选择器 */ @@ -126,13 +130,21 @@ export function LyricEditorHeader({ onBack, onSave, saving, + mode = 'review', + textOnly = false, }: EditorHeaderProps) { const [, setMetadataEditorDialog] = useAtom(metadataEditorDialogAtom); + const isCreator = mode === 'creator'; return (
- @@ -143,12 +155,18 @@ export function LyricEditorHeader({
- - + {textOnly ? ( + + 无参考音频 · 文本编辑 + + ) : ( + + )} + {!textOnly && } {/* 分隔:菜单类操作与右侧的「元数据 / 提交」动作按钮区分开 */} diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 312037a..0b2ee35 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -23,12 +23,16 @@ function DialogOverlay({ className, ...props }: ComponentProps) { +}: ComponentProps & { + /** 覆盖默认遮罩层样式,例如在嵌套弹窗中抬高遮罩层级 */ + overlayClassName?: string; +}) { return ( - + )} diff --git a/frontend/src/editor/components/ConfirmDialog.tsx b/frontend/src/editor/components/ConfirmDialog.tsx index 0fea8ab..52a2b4e 100644 --- a/frontend/src/editor/components/ConfirmDialog.tsx +++ b/frontend/src/editor/components/ConfirmDialog.tsx @@ -43,7 +43,7 @@ export function ConfirmDialog() { return ( - + {state.title} {state.description ? ( diff --git a/frontend/src/editor/components/RibbonBar/edit-mode.tsx b/frontend/src/editor/components/RibbonBar/edit-mode.tsx index 84b4ab1..7f1287a 100644 --- a/frontend/src/editor/components/RibbonBar/edit-mode.tsx +++ b/frontend/src/editor/components/RibbonBar/edit-mode.tsx @@ -922,8 +922,10 @@ export const EditModeRibbonBar: FC = forwardRef((_props, ref) => const toggleTab = useSetAtom(toggleTabAtom); const isOutlineOpen = openTabs.includes('outline'); const isBpmOpen = openTabs.includes('bpm'); + const isRequirementsOpen = openTabs.includes('requirements'); const idOutline = useId(); const idBpm = useId(); + const idRequirements = useId(); const editLyricLines = useSetImmerAtom(lyricLinesAtom); @@ -1074,6 +1076,16 @@ export const EditModeRibbonBar: FC = forwardRef((_props, ref) => toggleTab({ tabId: 'outline', open: Boolean(checked) }); }} /> + + { + toggleTab({ tabId: 'requirements', open: Boolean(checked) }); + }} + /> diff --git a/frontend/src/editor/components/Sidebar/ReviewRequirementsPanel.module.css b/frontend/src/editor/components/Sidebar/ReviewRequirementsPanel.module.css new file mode 100644 index 0000000..67f78ca --- /dev/null +++ b/frontend/src/editor/components/Sidebar/ReviewRequirementsPanel.module.css @@ -0,0 +1,189 @@ +.container { + display: flex; + flex-direction: column; + height: 100%; + overflow-y: auto; + padding: var(--space-2); + gap: var(--space-2); +} + +.header { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--gray-a4); + border-radius: var(--radius-2); + background: var(--gray-a2); +} + +.headerTop { + display: flex; + align-items: center; + gap: 6px; +} + +.badge { + padding: 2px 6px; + border-radius: 999px; + background: var(--accent-a3); + color: var(--accent-11); + font-size: 10px; + line-height: 1.4; +} + +.action { + color: var(--gray-9); + font-size: 11px; +} + +.reviewer { + color: var(--gray-12); + font-size: 12px; + font-weight: 600; +} + +.time { + color: var(--gray-8); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.section { + display: flex; + flex-direction: column; + gap: 2px; +} + +.sectionTitle { + padding: 2px var(--space-2); + color: var(--gray-9); + font-size: 11px; + font-weight: 600; +} + +.item { + display: flex; + width: 100%; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + border: 1px solid transparent; + border-radius: var(--radius-2); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + transition: + background-color 0.1s, + border-color 0.1s; +} + +button.item { + cursor: pointer; +} + +button.item:hover { + background: var(--gray-a3); +} + +button.item[data-selected='true'] { + border-color: var(--accent-a5); + background: var(--accent-a3); +} + +.item[data-clickable='false'] { + cursor: default; +} + +.lineNumber { + display: flex; + flex: 0 0 20px; + align-items: baseline; + gap: 2px; + padding-top: 2px; + color: var(--gray-8); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.bgMark { + color: var(--gray-7); + font-size: 9px; +} + +.approxMark { + color: var(--amber-11); + font-size: 10px; +} + +.overallDot { + display: flex; + flex: 0 0 20px; + justify-content: center; + padding-top: 6px; +} + +.overallDot span { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--gray-7); +} + +.text { + flex: 1 1 auto; + min-width: 0; + color: var(--gray-12); + font-size: 13px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} + +.itemBody { + display: flex; + flex: 1 1 auto; + min-width: 0; + flex-direction: column; + gap: 3px; +} + +.statusRow { + display: flex; + align-items: center; + gap: 4px; +} + +.statusBadge { + padding: 1px 6px; + border-radius: 999px; + font-size: 10px; + line-height: 1.5; +} + +.statusBadge[data-status='done'] { + background: var(--green-a3); + color: var(--green-11); +} + +.statusBadge[data-status='pending'] { + background: var(--amber-a3); + color: var(--amber-11); +} + +.statusBadge[data-status='incorrect'] { + background: var(--red-a3); + color: var(--red-11); +} + +.statusBadge[data-status='unknown'] { + background: var(--gray-a3); + color: var(--gray-9); +} + +.empty { + padding: var(--space-4) var(--space-2); + color: var(--gray-8); + font-size: 12px; + text-align: center; +} diff --git a/frontend/src/editor/components/Sidebar/ReviewRequirementsPanel.tsx b/frontend/src/editor/components/Sidebar/ReviewRequirementsPanel.tsx new file mode 100644 index 0000000..7f10a71 --- /dev/null +++ b/frontend/src/editor/components/Sidebar/ReviewRequirementsPanel.tsx @@ -0,0 +1,246 @@ +import { useAtomValue, useSetAtom } from 'jotai'; +import { memo, useCallback, useMemo, useState } from 'react'; +import { formatDateTime } from '@/lib/format'; +import { + lyricLinesAtom, + selectedLinesAtom, + selectedWordsAtom, + ToolMode, + toolModeAtom, +} from '$/states/main.ts'; +import { outlineJumpActionAtom } from '$/states/sidebar.ts'; +import { metadataEditorDialogAtom, metadataEditorFocusKeyAtom } from '$/states/dialogs.ts'; +import { audioEngine } from '$/modules/audio/audio-engine'; +import { reviewRequirementAtom } from '$/modules/review/states'; +import { + findLineIndex, + findNearestLineIndex, + getRequirementStatus, + parseRequirement, + type RequirementItem, +} from '$/modules/review/requirements'; +import type { LyricWord } from '$/types/ttml'; +import styles from './ReviewRequirementsPanel.module.css'; + +const ACTION_LABELS: Record = { + need_revision: '需修改', + revised: '待确认修订版', + missing_audio: '缺音频', + rejected: '未通过', + approved: '已通过', + approve: '已通过', + reject: '未通过', + pending: '待审核', + reviewing: '审核中', +}; + +const STATUS_LABELS = { + done: '已修改', + pending: '未修改', + incorrect: '未正确修改', + unknown: '需人工确认', +} as const; + +export const ReviewRequirementsPanel = memo(() => { + const lyricData = useAtomValue(lyricLinesAtom); + const requirement = useAtomValue(reviewRequirementAtom); + const setSelectedLines = useSetAtom(selectedLinesAtom); + const setSelectedWords = useSetAtom(selectedWordsAtom); + const setToolMode = useSetAtom(toolModeAtom); + const setJumpAction = useSetAtom(outlineJumpActionAtom); + const setMetadataDialog = useSetAtom(metadataEditorDialogAtom); + const setMetadataFocusKey = useSetAtom(metadataEditorFocusKeyAtom); + /** 只高亮用户最后点击的那一条,而不是同一行的所有要求 */ + const [activeItemKey, setActiveItemKey] = useState(null); + + const { lyricLines } = lyricData; + + const resolveLine = useCallback( + (lineNumber: number, isBG?: boolean) => { + const exact = findLineIndex(lyricLines, lineNumber, isBG); + if (exact >= 0) { + return { id: lyricLines[exact]!.id, index: exact, approximate: false }; + } + const nearest = findNearestLineIndex(lyricLines, lineNumber); + return nearest >= 0 + ? { id: lyricLines[nearest]!.id, index: nearest, approximate: true } + : null; + }, + [lyricLines] + ); + + const parsed = useMemo( + () => (requirement ? parseRequirement(requirement) : { overall: [], lines: [] }), + [requirement] + ); + + const sortedLines = useMemo( + () => + [...parsed.lines].sort( + (a, b) => (a.lineNumber ?? 0) - (b.lineNumber ?? 0) || Number(a.isBG) - Number(b.isBG) + ), + [parsed.lines] + ); + + const handleItemClick = useCallback( + (item: RequirementItem) => { + setActiveItemKey(item.key); + if (item.kind === 'metadata') { + setMetadataFocusKey(item.metadataKey ?? null); + setMetadataDialog(true); + return; + } + if (item.lineNumber == null) return; + + const target = resolveLine(item.lineNumber, item.isBG); + if (!target) return; + + const line = lyricLines[target.index]; + let word: LyricWord | undefined; + if (line) { + if (item.wordIndex != null && item.wordIndex >= 0) { + word = line.words[item.wordIndex]; + } + if (!word && item.wordId) { + word = line.words.find((candidate) => candidate.id === item.wordId); + } + } + + setSelectedLines(new Set([target.id])); + setSelectedWords(word ? new Set([word.id]) : new Set()); + setToolMode(item.kind === 'timing' ? ToolMode.Sync : ToolMode.Edit); + setJumpAction({ id: target.id, ts: Date.now() }); + + // 时轴问题直接跳到该词的时间点,方便马上重打轴 + if (item.kind === 'timing' && word && Number.isFinite(word.startTime)) { + if (audioEngine.musicLoaded) { + audioEngine.seekMusic(word.startTime / 1000); + } + } + }, + [ + lyricLines, + resolveLine, + setJumpAction, + setMetadataDialog, + setMetadataFocusKey, + setSelectedLines, + setSelectedWords, + setToolMode, + ] + ); + + const renderItemBody = (item: RequirementItem) => { + const status = getRequirementStatus(item, lyricData); + return ( +
+
{item.text}
+
+ + {STATUS_LABELS[status]} + +
+
+ ); + }; + + if (!requirement) { + return
暂无审核要求
; + } + + const reviewerName = requirement.reviewerInfo?.displayName || requirement.reviewer || '审核员'; + const actionLabel = requirement.action ? ACTION_LABELS[requirement.action] : ''; + const isEmpty = parsed.overall.length === 0 && sortedLines.length === 0; + + return ( +
+
+
+ + {requirement.source === 'report' ? '审核报告' : '文字意见'} + + {actionLabel && {actionLabel}} +
+
由{reviewerName}进行审核
+
{formatDateTime(requirement.createdAt)}
+
+ + {isEmpty &&
本次审核没有留下可展示的要求
} + + {parsed.overall.length > 0 && ( +
+
整体要求
+ {parsed.overall.map((item) => + item.kind === 'metadata' ? ( + + ) : ( +
+
+ +
+ {renderItemBody(item)} +
+ ) + )} +
+ )} + + {sortedLines.length > 0 && ( +
+
按行要求({sortedLines.length})
+ {sortedLines.map((item) => { + const selected = activeItemKey === item.key; + const target = resolveLine(item.lineNumber ?? 0, item.isBG); + const approximate = target?.approximate ?? false; + const hasWord = item.wordIndex != null || item.wordId != null; + const hint = approximate + ? '行号已变化,跳转到最接近的一行,请人工确认' + : item.kind === 'timing' + ? hasWord + ? '跳到打轴并定位到该词' + : '跳到打轴并定位到该行' + : hasWord + ? '跳到编辑并定位到该词' + : '跳到编辑并定位到该行'; + return ( + + ); + })} +
+ )} +
+ ); +}); + +export default ReviewRequirementsPanel; diff --git a/frontend/src/editor/components/Sidebar/index.tsx b/frontend/src/editor/components/Sidebar/index.tsx index d8298ac..cc46002 100644 --- a/frontend/src/editor/components/Sidebar/index.tsx +++ b/frontend/src/editor/components/Sidebar/index.tsx @@ -9,6 +9,7 @@ import { } from '$/states/sidebar.ts'; import { BpmPanel } from './BpmPanel'; import { OutlinePanel } from './OutlinePanel'; +import { ReviewRequirementsPanel } from './ReviewRequirementsPanel'; import { ResizableSidebar } from './ResizableSidebar'; import { SidebarTabBar } from './SidebarTabBar'; @@ -27,6 +28,11 @@ export const SIDEBAR_TABS: SidebarTab[] = [ title: '大纲', component: OutlinePanel, }, + { + id: 'requirements', + title: '审核要求', + component: ReviewRequirementsPanel, + }, { id: 'bpm', title: 'BPM', @@ -65,7 +71,7 @@ export const Sidebar = () => { minWidth={MIN_WIDTH} maxWidth={MAX_WIDTH} maxWidthRatio={0.5} - titles={{ outline: tabBar, bpm: tabBar }} + titles={{ outline: tabBar, requirements: tabBar, bpm: tabBar }} headerSingle={visibleTabs.length === 1} > {SIDEBAR_TABS.map((tab) => { diff --git a/frontend/src/editor/modules/project/modals/MetadataEditor.tsx b/frontend/src/editor/modules/project/modals/MetadataEditor.tsx index ea49b8f..a9ef0aa 100644 --- a/frontend/src/editor/modules/project/modals/MetadataEditor.tsx +++ b/frontend/src/editor/modules/project/modals/MetadataEditor.tsx @@ -12,7 +12,7 @@ import { User, } from 'lucide-react'; import { AnimatePresence, motion } from 'framer-motion'; -import { useAtom, useAtomValue } from 'jotai'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { useImmerAtom } from 'jotai-immer'; import { memo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; @@ -28,7 +28,7 @@ import { getMeatdataSuggestion, type MetaSuggestionResult, } from '$/modules/project/logic/meatdata-suggestion'; -import { metadataEditorDialogAtom } from '$/states/dialogs.ts'; +import { metadataEditorDialogAtom, metadataEditorFocusKeyAtom } from '$/states/dialogs.ts'; import { resolvedThemeAtom } from '@/atoms/theme'; import { lyricLinesAtom } from '$/states/main.ts'; import type { TTMLLyric, TTMLMetadata } from '$/types/ttml'; @@ -742,6 +742,15 @@ export const MetadataEditor = () => { setActiveKey(builtinOptions[0]?.value ?? ''); }, [activeOption, builtinOptions]); + // 从审核要求面板跳转过来时,聚焦对应元数据项 + const focusMetadataKey = useAtomValue(metadataEditorFocusKeyAtom); + const setFocusMetadataKey = useSetAtom(metadataEditorFocusKeyAtom); + useEffect(() => { + if (!metadataEditorDialog || !focusMetadataKey) return; + setActiveKey(focusMetadataKey); + setFocusMetadataKey(null); + }, [metadataEditorDialog, focusMetadataKey, setFocusMetadataKey]); + const addCustomKey = useCallback(() => { const nextKey = customKey.trim(); if (!nextKey) return; diff --git a/frontend/src/editor/modules/review/modals/ReportFormatEditor.tsx b/frontend/src/editor/modules/review/modals/ReportFormatEditor.tsx index f871a0a..e0dcf31 100644 --- a/frontend/src/editor/modules/review/modals/ReportFormatEditor.tsx +++ b/frontend/src/editor/modules/review/modals/ReportFormatEditor.tsx @@ -20,9 +20,16 @@ export type ReportFormatEditorProps = { onChange: (v: ReviewReportFormat) => void; /** 用于实时预览的报告。 */ report: ReviewReport; + /** 恢复默认时使用的基准格式(审核员 / 投稿者视角不同)。 */ + defaultFormat?: ReviewReportFormat; }; -export const ReportFormatEditor = ({ value, onChange, report }: ReportFormatEditorProps) => { +export const ReportFormatEditor = ({ + value, + onChange, + report, + defaultFormat = DEFAULT_REVIEW_REPORT_FORMAT, +}: ReportFormatEditorProps) => { const templateInputRef = useRef(null); const [selectedKind, setSelectedKind] = useState('wordText'); const changedKinds = useMemo( @@ -31,13 +38,12 @@ export const ReportFormatEditor = ({ value, onChange, report }: ReportFormatEdit reviewReportFormatBlockDefinitions .filter( (d) => - value.blocks[d.kind].template !== - DEFAULT_REVIEW_REPORT_FORMAT.blocks[d.kind].template || - value.blocks[d.kind].listItem !== DEFAULT_REVIEW_REPORT_FORMAT.blocks[d.kind].listItem + value.blocks[d.kind].template !== defaultFormat.blocks[d.kind].template || + value.blocks[d.kind].listItem !== defaultFormat.blocks[d.kind].listItem ) .map((d) => d.kind) ), - [value] + [defaultFormat, value] ); // 定义表是代码内固定常量,至少有一项;空数组只可能来自外部数据异常。 @@ -124,12 +130,7 @@ export const ReportFormatEditor = ({ value, onChange, report }: ReportFormatEdit {/* 区块列表 + 恢复默认 */}
选择要编辑的区块,模板里用 {'{{变量}}'} 引用内容 - @@ -184,7 +185,11 @@ export const ReportFormatEditor = ({ value, onChange, report }: ReportFormatEdit type="button" size="sm" variant="secondary" - onClick={() => onChange(resetReviewReportBlockFormat(value, selectedDefinition.kind))} + onClick={() => + onChange( + resetReviewReportBlockFormat(value, selectedDefinition.kind, defaultFormat) + ) + } > 重置此项 diff --git a/frontend/src/editor/modules/review/modals/ReviewReportDialog.tsx b/frontend/src/editor/modules/review/modals/ReviewReportDialog.tsx index 7b5e132..8bde885 100644 --- a/frontend/src/editor/modules/review/modals/ReviewReportDialog.tsx +++ b/frontend/src/editor/modules/review/modals/ReviewReportDialog.tsx @@ -16,8 +16,14 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Switch } from '@/components/ui/switch'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Textarea } from '@/components/ui/textarea'; +import { lyricLinesAtom } from '$/states/main.ts'; +import { confirmDialogAtom } from '$/states/dialogs.ts'; +import { countIncompleteRequirements } from '$/modules/review/requirements'; import { + DEFAULT_REVIEW_REPORT_FORMAT, + DEFAULT_SUBMITTER_REPORT_FORMAT, reviewReportFormatAtom, + submitterReportFormatAtom, type ReviewReportFormat, } from '$/modules/review/services/report-service/format-service'; import { @@ -38,6 +44,7 @@ import { effectiveReviewReportAtom, reviewActionAtom, reviewReportDialogAtom, + reviewRequirementAtom, reviewUploadTtmlAtom, } from '$/modules/review/states'; @@ -73,10 +80,12 @@ function DialogFlexBody({ children }: { children: ReactNode }) { } export interface ReviewReportDialogProps { - /** 提交:走本站的保存修订版链路 */ + /** 提交:审核员走修订版链路,投稿者走内容保存链路 */ onSubmit: (input: { reportMd: string; action: string; uploadTtml: boolean }) => void; /** 是否正在提交 */ submitting: boolean; + /** review = 审核员;creator = 投稿者修改 */ + mode?: 'review' | 'creator'; } /** 单个报告块的一行摘要 */ @@ -395,16 +404,30 @@ const BRAND_ACCENT = { '--accent-track': '#fbdde0', } as CSSProperties; -export const ReviewReportDialog = ({ onSubmit, submitting }: ReviewReportDialogProps) => { +export const ReviewReportDialog = ({ + onSubmit, + submitting, + mode = 'review', +}: ReviewReportDialogProps) => { + const isCreator = mode === 'creator'; const [dialog, setDialog] = useAtom(reviewReportDialogAtom); const autoReport = useAtomValue(effectiveReviewReportAtom); - const reportFormat = useAtomValue(reviewReportFormatAtom); - const setReportFormat = useSetAtom(reviewReportFormatAtom); + const reviewFormat = useAtomValue(reviewReportFormatAtom); + const setReviewFormat = useSetAtom(reviewReportFormatAtom); + const submitterFormat = useAtomValue(submitterReportFormatAtom); + const setSubmitterFormat = useSetAtom(submitterReportFormatAtom); + const reportFormat = isCreator ? submitterFormat : reviewFormat; + const setReportFormat = isCreator ? setSubmitterFormat : setReviewFormat; const [action, setAction] = useAtom(reviewActionAtom); const [uploadTtml, setUploadTtml] = useAtom(reviewUploadTtmlAtom); + const lyricData = useAtomValue(lyricLinesAtom); + const requirement = useAtomValue(reviewRequirementAtom); + const setConfirmDialog = useSetAtom(confirmDialogAtom); const [activeTab, setActiveTab] = useState('blocks'); const [draft, setDraft] = useState(() => createReviewReport()); + /** 投稿者填写的「其他内容」,作为手写块放在报告最前面 */ + const [extraContent, setExtraContent] = useState(''); const initializedRef = useRef(false); useEffect(() => { @@ -416,11 +439,47 @@ export const ReviewReportDialog = ({ onSubmit, submitting }: ReviewReportDialogP } }, [autoReport]); - const renderedReport = useMemo(() => renderReviewReport(draft), [draft]); + /** 投稿者的其他内容作为手写条目插到报告最前面 */ + const effectiveDraft = useMemo(() => { + const content = extraContent.trim(); + if (!isCreator || !content) return draft; + const extraBlock: ReviewReportBlock = { + id: 'creator-extra', + kind: 'manual', + content, + lineBreakBefore: false, + enabled: true, + }; + return createReviewReport([extraBlock, ...draft.blocks]); + }, [draft, extraContent, isCreator]); + + const renderedReport = useMemo( + () => renderReviewReport(effectiveDraft, reportFormat), + [effectiveDraft, reportFormat] + ); const renderedReportHtml = useMemo(() => renderMarkdown(renderedReport), [renderedReport]); const blocks = useMemo(() => normalizeReviewReport(draft).blocks, [draft]); const blockGroups = useMemo(() => createReportBlockSequenceGroups(blocks), [blocks]); const close = () => setDialog({ ...dialog, open: false }); + + const doCreatorSubmit = () => { + onSubmit({ reportMd: renderedReport, action: 'submitter_edit', uploadTtml: false }); + }; + + /** 投稿者提交前检查审核要求完成情况 */ + const handleCreatorSubmit = () => { + const incomplete = countIncompleteRequirements(requirement, lyricData); + if (incomplete > 0) { + setConfirmDialog({ + open: true, + title: '还有未完成的修改', + description: `还有 ${incomplete} 个要求未完成修改,是否继续提交?`, + onConfirm: doCreatorSubmit, + }); + return; + } + doCreatorSubmit(); + }; const updateReportBlocks = (updater: (prev: ReviewReportBlock[]) => ReviewReportBlock[]) => { setDraft(createReviewReport(captureManualReviewReportPlacements(updater(blocks)))); }; @@ -522,11 +581,15 @@ export const ReviewReportDialog = ({ onSubmit, submitting }: ReviewReportDialogP - 对稿件【{dialog.title}】做出的审阅结果如下: + {isCreator + ? `对稿件【${dialog.title}】的修改内容如下:` + : `对稿件【${dialog.title}】做出的审阅结果如下:`} -
- -
+ {!isCreator && ( +
+ +
+ )} + {isCreator && ( +
+
+ 其他内容 + 会放在报告最前面,可留空 +
+