From 83ee119d7d69af6ea24e857b94c0747abe1e2e91 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:48:34 +0000 Subject: [PATCH 01/20] Initial plan From b376da35a0bdf60ac7766c57bce3a93fe108435d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:57:04 +0000 Subject: [PATCH 02/20] =?UTF-8?q?fix:=20=E6=9A=82=E6=97=B6=E9=9A=90?= =?UTF-8?q?=E8=97=8F=E6=9C=AA=E5=AE=8C=E6=88=90=E5=8A=9F=E8=83=BDUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: kira1928 <2352900+kira1928@users.noreply.github.com> --- src/webapp/src/component/config-info/index.tsx | 5 +++-- src/webapp/src/component/layout/index.tsx | 5 +++-- tests/e2e/config.spec.ts | 6 +++++- tests/e2e/navigation.spec.ts | 17 +++-------------- 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/webapp/src/component/config-info/index.tsx b/src/webapp/src/component/config-info/index.tsx index ed13a22..7c2221f 100644 --- a/src/webapp/src/component/config-info/index.tsx +++ b/src/webapp/src/component/config-info/index.tsx @@ -31,6 +31,7 @@ const { TextArea } = Input; // 功能开关:代理配置(开发中,设为 false 隐藏 UI) // 与后端 configs.EnableProxyConfig 对应 const ENABLE_PROXY_CONFIG = false; +const ENABLE_CLOUD_UPLOAD_SETTINGS = false; const { Panel } = Collapse; // 配置项类型定义 @@ -681,8 +682,8 @@ const GlobalSettings: React.FC<{ - {/* 云盘上传设置 */} - + {/* 云盘上传设置(开发中) */} + {ENABLE_CLOUD_UPLOAD_SETTINGS && } {/* 高级设置 */} diff --git a/src/webapp/src/component/layout/index.tsx b/src/webapp/src/component/layout/index.tsx index 0fc96cb..39ba70a 100644 --- a/src/webapp/src/component/layout/index.tsx +++ b/src/webapp/src/component/layout/index.tsx @@ -18,6 +18,7 @@ import { import './layout.css'; const { Header, Content, Sider } = Layout; +const ENABLE_IO_STATS_UI = false; interface Props { children?: React.ReactNode; @@ -142,11 +143,11 @@ class RootLayout extends React.Component { icon: , label: 调度器, }, - { + ...(ENABLE_IO_STATS_UI ? [{ key: 'iostats', icon: , label: IO 统计, - }, + }] : []), { key: 'update', icon: , diff --git a/tests/e2e/config.spec.ts b/tests/e2e/config.spec.ts index 34413b6..4cadd82 100644 --- a/tests/e2e/config.spec.ts +++ b/tests/e2e/config.spec.ts @@ -29,6 +29,11 @@ test.describe('设置页面测试', () => { expect(count).toBeGreaterThan(0); }); + test('云盘上传设置已隐藏', async ({ page }) => { + await page.waitForTimeout(1000); + await expect(page.getByText('云盘上传')).toHaveCount(0); + }); + test('输入框可以编辑', async ({ page }) => { await page.waitForTimeout(1000); @@ -161,4 +166,3 @@ test.describe('设置页面 - 标签页切换', () => { } }); }); - diff --git a/tests/e2e/navigation.spec.ts b/tests/e2e/navigation.spec.ts index d230eac..68788ae 100644 --- a/tests/e2e/navigation.spec.ts +++ b/tests/e2e/navigation.spec.ts @@ -11,14 +11,14 @@ test.describe('导航功能测试', () => { await page.waitForLoadState('domcontentloaded'); }); - test('侧边栏包含所有菜单项', async ({ page }) => { + test('侧边栏包含已开放的菜单项', async ({ page }) => { // 验证侧边栏存在 const sider = page.getByRole('complementary'); await expect(sider).toBeVisible(); // 验证菜单项(使用 menuitem 角色) const menuItems = page.getByRole('menuitem'); - await expect(menuItems).toHaveCount(10); // 10个菜单项 + await expect(menuItems).toHaveCount(9); // IO 统计已暂时隐藏 // 验证各个菜单项文本 await expect(page.getByRole('menuitem', { name: /监控列表/ })).toBeVisible(); @@ -29,8 +29,8 @@ test.describe('导航功能测试', () => { await expect(page.getByRole('menuitem', { name: /工具/ })).toBeVisible(); await expect(page.getByRole('menuitem', { name: /任务队列/ })).toBeVisible(); await expect(page.getByRole('menuitem', { name: /调度器/ })).toBeVisible(); - await expect(page.getByRole('menuitem', { name: /IO 统计/ })).toBeVisible(); await expect(page.getByRole('menuitem', { name: /更新/ })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: /IO 统计/ })).toHaveCount(0); }); test('点击导航到系统状态页面', async ({ page }) => { @@ -77,17 +77,6 @@ test.describe('导航功能测试', () => { await expect(page.getByRole('main')).toBeVisible(); }); - test('点击导航到IO统计页面', async ({ page }) => { - await page.getByRole('menuitem', { name: /IO 统计/ }).click(); - await page.waitForTimeout(500); - - // 验证 URL 包含 iostats - expect(page.url()).toContain('iostats'); - - // 验证页面内容加载 - await expect(page.getByRole('main')).toBeVisible(); - }); - test('点击导航到更新页面', async ({ page }) => { await page.getByRole('menuitem', { name: /更新/ }).click(); await page.waitForTimeout(500); From 7d294ab87962ab5fc128c5dfe79dfa5573b51284 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:58:21 +0000 Subject: [PATCH 03/20] =?UTF-8?q?test:=20=E7=B2=BE=E7=AE=80=E9=9A=90?= =?UTF-8?q?=E8=97=8FUI=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: kira1928 <2352900+kira1928@users.noreply.github.com> --- src/webapp/src/component/layout/index.tsx | 1 + tests/e2e/config.spec.ts | 2 +- tests/e2e/navigation.spec.ts | 3 +++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/webapp/src/component/layout/index.tsx b/src/webapp/src/component/layout/index.tsx index 39ba70a..585eca2 100644 --- a/src/webapp/src/component/layout/index.tsx +++ b/src/webapp/src/component/layout/index.tsx @@ -18,6 +18,7 @@ import { import './layout.css'; const { Header, Content, Sider } = Layout; +// 功能开关:IO 统计(开发中,设为 false 隐藏 UI) const ENABLE_IO_STATS_UI = false; interface Props { diff --git a/tests/e2e/config.spec.ts b/tests/e2e/config.spec.ts index 4cadd82..f49aefa 100644 --- a/tests/e2e/config.spec.ts +++ b/tests/e2e/config.spec.ts @@ -30,7 +30,7 @@ test.describe('设置页面测试', () => { }); test('云盘上传设置已隐藏', async ({ page }) => { - await page.waitForTimeout(1000); + await expect(page.getByRole('main')).toBeVisible(); await expect(page.getByText('云盘上传')).toHaveCount(0); }); diff --git a/tests/e2e/navigation.spec.ts b/tests/e2e/navigation.spec.ts index 68788ae..2566936 100644 --- a/tests/e2e/navigation.spec.ts +++ b/tests/e2e/navigation.spec.ts @@ -30,6 +30,9 @@ test.describe('导航功能测试', () => { await expect(page.getByRole('menuitem', { name: /任务队列/ })).toBeVisible(); await expect(page.getByRole('menuitem', { name: /调度器/ })).toBeVisible(); await expect(page.getByRole('menuitem', { name: /更新/ })).toBeVisible(); + }); + + test('侧边栏不显示 IO 统计入口', async ({ page }) => { await expect(page.getByRole('menuitem', { name: /IO 统计/ })).toHaveCount(0); }); From 153113dc9dc48d6f2264725d5a622d50633a4d4f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:00:21 +0000 Subject: [PATCH 04/20] =?UTF-8?q?chore:=20=E5=BF=BD=E7=95=A5E2E=E4=B8=B4?= =?UTF-8?q?=E6=97=B6=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: kira1928 <2352900+kira1928@users.noreply.github.com> --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3fe54a8..a4f81c8 100644 --- a/.gitignore +++ b/.gitignore @@ -189,6 +189,7 @@ test/launcher-config-local.json # E2E 测试输出(录制文件等) /test-output/ +test-output\\.appdata/ # npm 锁文件(项目统一使用 yarn) package-lock.json From c8457e018c441a9bc0caefef5b5339f433a5a7ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:01:27 +0000 Subject: [PATCH 05/20] =?UTF-8?q?chore:=20=E4=BF=AE=E6=AD=A3E2E=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E5=BF=BD=E7=95=A5=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: kira1928 <2352900+kira1928@users.noreply.github.com> --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index a4f81c8..1217148 100644 --- a/.gitignore +++ b/.gitignore @@ -189,7 +189,7 @@ test/launcher-config-local.json # E2E 测试输出(录制文件等) /test-output/ -test-output\\.appdata/ +test-output?appdata/ # npm 锁文件(项目统一使用 yarn) package-lock.json From c8be6de537dffe7b40c374809d48699527834008 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:02:43 +0000 Subject: [PATCH 06/20] =?UTF-8?q?test:=20=E6=8F=90=E5=8D=87=E9=9A=90?= =?UTF-8?q?=E8=97=8FUI=E6=B5=8B=E8=AF=95=E7=A8=B3=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: kira1928 <2352900+kira1928@users.noreply.github.com> --- .gitignore | 2 +- tests/e2e/navigation.spec.ts | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 1217148..733749e 100644 --- a/.gitignore +++ b/.gitignore @@ -189,7 +189,7 @@ test/launcher-config-local.json # E2E 测试输出(录制文件等) /test-output/ -test-output?appdata/ +test-output*appdata/ # npm 锁文件(项目统一使用 yarn) package-lock.json diff --git a/tests/e2e/navigation.spec.ts b/tests/e2e/navigation.spec.ts index 2566936..1b244f3 100644 --- a/tests/e2e/navigation.spec.ts +++ b/tests/e2e/navigation.spec.ts @@ -16,10 +16,6 @@ test.describe('导航功能测试', () => { const sider = page.getByRole('complementary'); await expect(sider).toBeVisible(); - // 验证菜单项(使用 menuitem 角色) - const menuItems = page.getByRole('menuitem'); - await expect(menuItems).toHaveCount(9); // IO 统计已暂时隐藏 - // 验证各个菜单项文本 await expect(page.getByRole('menuitem', { name: /监控列表/ })).toBeVisible(); await expect(page.getByRole('menuitem', { name: /系统状态/ })).toBeVisible(); From 8d8f20a85fc614e74671c72b3494903943b21f6a Mon Sep 17 00:00:00 2001 From: Joftal <42807405+Joftal@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:43:17 +0800 Subject: [PATCH 07/20] =?UTF-8?q?feat=EF=BC=9A=E6=96=B0=E5=A2=9E=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E6=B7=BB=E5=8A=A0=E7=9B=B4=E6=92=AD=E9=97=B4=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81txt=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/configs/config.go | 142 +++--- src/servers/handler.go | 117 ++++- src/servers/models.go | 33 ++ src/servers/osrp_handler.go | 2 +- src/servers/server.go | 1 + src/servers/sse.go | 22 + .../src/component/add-room-dialog/index.tsx | 86 ---- .../component/batch-add-room-dialog/index.tsx | 403 ++++++++++++++++++ src/webapp/src/component/live-list/index.tsx | 28 +- src/webapp/src/utils/api.ts | 10 + src/webapp/src/utils/sse.ts | 14 +- 11 files changed, 687 insertions(+), 171 deletions(-) delete mode 100644 src/webapp/src/component/add-room-dialog/index.tsx create mode 100644 src/webapp/src/component/batch-add-room-dialog/index.tsx diff --git a/src/configs/config.go b/src/configs/config.go index ddce413..adfaaa5 100644 --- a/src/configs/config.go +++ b/src/configs/config.go @@ -649,38 +649,47 @@ func UpdateTransient(mutator func(c *Config) error) (*Config, error) { } func updateImpl(mutator func(c *Config) error, persist bool) (*Config, error) { - updateMu.Lock() - defer updateMu.Unlock() - old := GetCurrentConfig() - // 若当前尚未设置配置,则以默认配置为基础 - var base *Config - if old == nil { - base = NewConfig() - } else { - base = CloneConfigShallow(old) - } - if err := mutator(base); err != nil { - return nil, err - } - // 维护派生字段 - base.RefreshLiveRoomIndexCache() - // 版本号自增 - if old == nil { - base.Version = 1 - } else { - base.Version = old.Version + 1 - } - newCfg := base + var newCfg *Config + var file string - if persist && newCfg.File != "" { + func() { + updateMu.Lock() + defer updateMu.Unlock() + old := GetCurrentConfig() + // 若当前尚未设置配置,则以默认配置为基础 + var base *Config + if old == nil { + base = NewConfig() + } else { + base = CloneConfigShallow(old) + } + if err := mutator(base); err != nil { + return + } + // 维护派生字段 + base.RefreshLiveRoomIndexCache() + // 版本号自增 + if old == nil { + base.Version = 1 + } else { + base.Version = old.Version + 1 + } + newCfg = base + file = base.File + SetCurrentConfig(newCfg) + }() + + if newCfg == nil { + return nil, errors.New("config update failed") + } + + // 持久化在锁外执行,避免阻塞其他配置更新 + if persist && file != "" { if err := newCfg.Marshal(); err != nil { - // 如果持久化失败,我们选择记录错误但不阻止内存更新 - // 或者返回错误?这里选择返回错误,因为用户期望保存成功。 - return nil, fmt.Errorf("failed to save config: %w", err) + return newCfg, fmt.Errorf("failed to save config (in-memory updated): %w", err) } } - SetCurrentConfig(newCfg) return newCfg, nil } @@ -691,38 +700,53 @@ func UpdateCAS(expectedVersion int64, mutator func(c *Config) error) (*Config, e } func updateCASImpl(expectedVersion int64, mutator func(c *Config) error, persist bool) (*Config, error) { - updateMu.Lock() - defer updateMu.Unlock() - cur := GetCurrentConfig() - // 校验版本 - var curVersion int64 - if cur != nil { - curVersion = cur.Version - } - if curVersion != expectedVersion { - return nil, ErrConfigVersionConflict - } - // 克隆并修改 - var base *Config - if cur == nil { - base = NewConfig() - } else { - base = CloneConfigShallow(cur) - } - if err := mutator(base); err != nil { - return nil, err - } - base.RefreshLiveRoomIndexCache() - base.Version = expectedVersion + 1 + var newCfg *Config + var file string + var updateErr error - if persist && base.File != "" { - if err := base.Marshal(); err != nil { - return nil, fmt.Errorf("failed to save config: %w", err) + func() { + updateMu.Lock() + defer updateMu.Unlock() + cur := GetCurrentConfig() + // 校验版本 + var curVersion int64 + if cur != nil { + curVersion = cur.Version + } + if curVersion != expectedVersion { + updateErr = ErrConfigVersionConflict + return + } + // 克隆并修改 + var base *Config + if cur == nil { + base = NewConfig() + } else { + base = CloneConfigShallow(cur) + } + if err := mutator(base); err != nil { + updateErr = err + return + } + base.RefreshLiveRoomIndexCache() + base.Version = expectedVersion + 1 + newCfg = base + file = base.File + SetCurrentConfig(newCfg) + }() + + if updateErr != nil { + return nil, updateErr + } + + // 持久化在锁外执行,避免阻塞其他配置更新 + if persist && file != "" { + if err := newCfg.Marshal(); err != nil { + return newCfg, fmt.Errorf("failed to save config (in-memory updated): %w", err) } } - SetCurrentConfig(base) - return base, nil + return newCfg, nil } // UpdateWithRetry 在读取-修改-提交之间做乐观锁重试,避免调用方自行实现重试逻辑 @@ -811,6 +835,14 @@ func AppendLiveRoom(room LiveRoom) (*Config, error) { }, 3, 10*time.Millisecond) } +// AppendLiveRoomTransient 追加一个 LiveRoom(仅更新内存,不持久化)。 +func AppendLiveRoomTransient(room LiveRoom) (*Config, error) { + return UpdateWithRetryTransient(func(c *Config) error { + c.LiveRooms = append(c.LiveRooms, room) + return nil + }, 3, 10*time.Millisecond) +} + // RemoveLiveRoomByUrl 从配置中移除指定 URL 的房间 func RemoveLiveRoomByUrl(url string) (*Config, error) { return UpdateWithRetry(func(c *Config) error { diff --git a/src/servers/handler.go b/src/servers/handler.go index 6ace7eb..f0ad5cb 100644 --- a/src/servers/handler.go +++ b/src/servers/handler.go @@ -19,6 +19,7 @@ import ( "text/template" "time" + "github.com/google/uuid" "github.com/gorilla/mux" "github.com/hr3lxphr6j/requests" "github.com/tidwall/gjson" @@ -678,8 +679,9 @@ func addLives(writer http.ResponseWriter, r *http.Request) { inst := instance.GetInstance(r.Context()) b, err := io.ReadAll(r.Body) if err != nil { - writeJSON(writer, map[string]any{ - "error": err.Error(), + writeJsonWithStatusCode(writer, http.StatusBadRequest, commonResp{ + ErrNo: http.StatusBadRequest, + ErrMsg: err.Error(), }) return } @@ -688,7 +690,7 @@ func addLives(writer http.ResponseWriter, r *http.Request) { gjson.ParseBytes(b).ForEach(func(key, value gjson.Result) bool { isListen := value.Get("listen").Bool() urlStr := strings.Trim(value.Get("url").String(), " ") - if retInfo, err := addLiveImpl(inst.Ctx, urlStr, isListen); err != nil { + if retInfo, err := addLiveImpl(inst.Ctx, urlStr, isListen, true); err != nil { msg := urlStr + ": " + err.Error() applog.GetLogger().Error(msg) errorMessages = append(errorMessages, msg) @@ -699,11 +701,10 @@ func addLives(writer http.ResponseWriter, r *http.Request) { return true }) sort.Sort(info) - // TODO return error messages too writeJSON(writer, info) } -func addLiveImpl(ctx context.Context, urlStr string, isListen bool) (info *live.Info, err error) { +func addLiveImpl(ctx context.Context, urlStr string, isListen bool, persist bool) (info *live.Info, err error) { if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") { urlStr = "https://" + urlStr } @@ -737,9 +738,14 @@ func addLiveImpl(ctx context.Context, urlStr string, isListen bool) (info *live. if liveRoom == nil { return nil, errors.New("liveRoom is nil, cannot append to LiveRooms") } - // 使用统一的 Update 接口做 COW 并原子替换 - if _, err := configs.AppendLiveRoom(*liveRoom); err != nil { - return nil, err + if persist { + if _, err := configs.AppendLiveRoom(*liveRoom); err != nil { + return nil, err + } + } else { + if _, err := configs.AppendLiveRoomTransient(*liveRoom); err != nil { + return nil, err + } } } // 广播直播间列表变更事件 @@ -748,10 +754,103 @@ func addLiveImpl(ctx context.Context, urlStr string, isListen bool) (info *live. "url": urlStr, "listening": isListen, }) + } else { + return nil, errors.New("直播间已存在") } return info, nil } +func batchAddLives(writer http.ResponseWriter, r *http.Request) { + inst := instance.GetInstance(r.Context()) + b, err := io.ReadAll(r.Body) + if err != nil { + writeJsonWithStatusCode(writer, http.StatusBadRequest, commonResp{ + ErrNo: http.StatusBadRequest, + ErrMsg: "failed to read request body", + }) + return + } + + var req batchAddRequest + if err := json.Unmarshal(b, &req); err != nil { + writeJsonWithStatusCode(writer, http.StatusBadRequest, commonResp{ + ErrNo: http.StatusBadRequest, + ErrMsg: "invalid request body", + }) + return + } + + // 过滤空 URL,只保留有效条目 + validURLs := make([]string, 0, len(req.URLs)) + for _, u := range req.URLs { + if s := strings.TrimSpace(u); s != "" { + validURLs = append(validURLs, s) + } + } + + if len(validURLs) == 0 { + writeJsonWithStatusCode(writer, http.StatusBadRequest, commonResp{ + ErrNo: http.StatusBadRequest, + ErrMsg: "urls is empty", + }) + return + } + + // 使用客户端提供的 batch_id,或生成新的 + batchID := req.BatchID + if batchID == "" { + batchID = "batch_" + uuid.New().String() + } + + // 立即返回 batch_id 和有效总数 + writeJSON(writer, batchAddResponse{ + BatchID: batchID, + Total: len(validURLs), + }) + + // 异步处理批量添加 + go func() { + hub := GetSSEHub() + successCount := 0 + failCount := 0 + + for i, urlStr := range validURLs { + retInfo, err := addLiveImpl(inst.Ctx, urlStr, req.Listen, false) + event := batchProgressEvent{ + Index: i, + Total: len(validURLs), + URL: urlStr, + Success: err == nil, + } + if err != nil { + event.Error = err.Error() + failCount++ + } else { + event.Info = retInfo + successCount++ + } + + hub.BroadcastBatchProgress(batchID, event) + } + + // 批量完成后统一持久化一次配置(通过 CAS 确保不覆盖并发更新) + if successCount > 0 { + if _, err := configs.UpdateWithRetry(func(c *configs.Config) error { + return nil // 无操作,仅触发持久化 + }, 3, 10*time.Millisecond); err != nil { + applog.GetLogger().Errorf("batch add: failed to persist config: %v", err) + } + } + + hub.BroadcastBatchComplete(batchID, batchCompleteEvent{ + Total: len(validURLs), + SuccessCount: successCount, + FailCount: failCount, + Timestamp: time.Now(), + }) + }() +} + func removeLive(writer http.ResponseWriter, r *http.Request) { inst := instance.GetInstance(r.Context()) vars := mux.Vars(r) @@ -888,7 +987,7 @@ func applyLiveRoomsByConfig(ctx context.Context, oldConfig *configs.Config, newC newUrlMap[newRoom.Url] = newRoom if room, err := oldConfig.GetLiveRoomByUrl(newRoom.Url); err != nil { // add live - if _, err := addLiveImpl(ctx, newRoom.Url, newRoom.IsListening); err != nil { + if _, err := addLiveImpl(ctx, newRoom.Url, newRoom.IsListening, true); err != nil { return err } } else { diff --git a/src/servers/models.go b/src/servers/models.go index b8a5d3a..8065764 100644 --- a/src/servers/models.go +++ b/src/servers/models.go @@ -1,6 +1,8 @@ package servers import ( + "time" + "github.com/bililive-go/bililive-go/src/live" ) @@ -21,3 +23,34 @@ func (c liveSlice) Swap(i, j int) { func (c liveSlice) Less(i, j int) bool { return c[i].Live.GetLiveId() < c[j].Live.GetLiveId() } + +// batchAddRequest 批量添加直播间请求 +type batchAddRequest struct { + URLs []string `json:"urls"` + Listen bool `json:"listen"` + BatchID string `json:"batch_id,omitempty"` +} + +// batchAddResponse 批量添加直播间响应(立即返回) +type batchAddResponse struct { + BatchID string `json:"batch_id"` + Total int `json:"total"` +} + +// batchProgressEvent SSE 进度事件数据 +type batchProgressEvent struct { + Index int `json:"index"` + Total int `json:"total"` + URL string `json:"url"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + Info *live.Info `json:"live_info,omitempty"` +} + +// batchCompleteEvent SSE 完成事件数据 +type batchCompleteEvent struct { + Total int `json:"total"` + SuccessCount int `json:"success_count"` + FailCount int `json:"fail_count"` + Timestamp time.Time `json:"timestamp"` +} diff --git a/src/servers/osrp_handler.go b/src/servers/osrp_handler.go index 0ac063f..5a8916d 100644 --- a/src/servers/osrp_handler.go +++ b/src/servers/osrp_handler.go @@ -260,7 +260,7 @@ func osrpAddTask(w http.ResponseWriter, r *http.Request) { } // 添加直播间 - info, err := addLiveImpl(ctx, req.URL, req.AutoStart) + info, err := addLiveImpl(ctx, req.URL, req.AutoStart, true) if err != nil { osrpWriteError(w, http.StatusBadRequest, "ADD_FAILED", err.Error()) return diff --git a/src/servers/server.go b/src/servers/server.go index 0c68b57..9bb67d7 100644 --- a/src/servers/server.go +++ b/src/servers/server.go @@ -84,6 +84,7 @@ func initMux(ctx context.Context) *mux.Router { apiRoute.HandleFunc("/raw-config", putRawConfig).Methods("PUT") apiRoute.HandleFunc("/lives", getAllLives).Methods("GET") apiRoute.HandleFunc("/lives", addLives).Methods("POST") + apiRoute.HandleFunc("/lives/batch", batchAddLives).Methods("POST") apiRoute.HandleFunc("/lives/{id}", getLive).Methods("GET") apiRoute.HandleFunc("/lives/{id}", removeLive).Methods("DELETE") apiRoute.HandleFunc("/lives/{id}/logs", getLiveLogs).Methods("GET") diff --git a/src/servers/sse.go b/src/servers/sse.go index 09b2eb7..25d8876 100644 --- a/src/servers/sse.go +++ b/src/servers/sse.go @@ -44,6 +44,10 @@ const ( SSEEventMemoryWarning SSEEventType = "memory_warning" // SSEEventDanmaku 弹幕实时推送 SSEEventDanmaku SSEEventType = "danmaku" + // SSEEventBatchProgress 批量添加进度 + SSEEventBatchProgress SSEEventType = "batch_progress" + // SSEEventBatchComplete 批量添加完成 + SSEEventBatchComplete SSEEventType = "batch_complete" ) // SSEMessage SSE 消息结构 @@ -229,6 +233,24 @@ func (h *SSEHub) BroadcastDanmaku(roomID types.LiveID, data interface{}) { }) } +// BroadcastBatchProgress 广播批量添加进度 +func (h *SSEHub) BroadcastBatchProgress(batchID string, data interface{}) { + h.Broadcast(SSEMessage{ + Type: SSEEventBatchProgress, + RoomID: batchID, + Data: data, + }) +} + +// BroadcastBatchComplete 广播批量添加完成 +func (h *SSEHub) BroadcastBatchComplete(batchID string, data interface{}) { + h.Broadcast(SSEMessage{ + Type: SSEEventBatchComplete, + RoomID: batchID, + Data: data, + }) +} + // ClientCount 获取当前连接的客户端数量 func (h *SSEHub) ClientCount() int { h.mu.RLock() diff --git a/src/webapp/src/component/add-room-dialog/index.tsx b/src/webapp/src/component/add-room-dialog/index.tsx deleted file mode 100644 index 346eb7f..0000000 --- a/src/webapp/src/component/add-room-dialog/index.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Modal, Input } from 'antd'; -import React from 'react'; -import API from '../../utils/api'; - -const api = new API(); - -interface Props { - refresh?: any; - children?: React.ReactNode; -} - -class AddRoomDialog extends React.Component { - state = { - ModalText: '请输入直播间的URL地址', - visible: false, - confirmLoading: false, - textView: '' - }; - - showModal = () => { - this.setState({ - ModalText: '请输入直播间的URL地址', - visible: true, - confirmLoading: false, - }); - }; - - handleOk = () => { - this.setState({ - ModalText: '正在添加直播间......', - confirmLoading: true, - }); - - api.addNewRoom(this.state.textView) - .then((rsp) => { - // 保存设置 - api.saveSettingsInBackground(); - this.setState({ - visible: false, - confirmLoading: false, - textView: '' - }); - this.props.refresh(); - }) - .catch(err => { - alert(`添加直播间失败:\n${err}`); - this.setState({ - visible: false, - confirmLoading: false, - textView: '' - }); - }) - }; - - handleCancel = () => { - this.setState({ - visible: false, - textView: '' - }); - }; - - textChange = (e: any) => { - this.setState({ - textView: e.target.value - }) - } - - render() { - const { visible, confirmLoading, ModalText, textView } = this.state; - return ( -
- -

{ModalText}

- -
-
- ); - } -} - -export default AddRoomDialog; diff --git a/src/webapp/src/component/batch-add-room-dialog/index.tsx b/src/webapp/src/component/batch-add-room-dialog/index.tsx new file mode 100644 index 0000000..92d77c0 --- /dev/null +++ b/src/webapp/src/component/batch-add-room-dialog/index.tsx @@ -0,0 +1,403 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { + Modal, + Input, + Button, + Upload, + Progress, + Space, + Typography, + Radio, + Alert, + message as antdMessage, +} from 'antd'; +import { + UploadOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + MinusCircleOutlined, +} from '@ant-design/icons'; +import API from '../../utils/api'; +import { sseManager } from '../../utils/sse'; + +const { TextArea } = Input; +const { Text } = Typography; + +const api = new API(); + +interface BatchResultItem { + url: string; + status: 'pending' | 'success' | 'error'; + error?: string; +} + +interface Props { + visible: boolean; + onClose: () => void; + onSuccess: () => void; +} + +type InputMode = 'single' | 'batch'; +type ImportMode = 'text' | 'file'; + +function generateBatchId(): string { + return 'batch_' + Date.now() + '_' + Math.random().toString(36).substring(2, 10); +} + +const AddRoomDialog: React.FC = ({ visible, onClose, onSuccess }) => { + const [inputMode, setInputMode] = useState('single'); + const [importMode, setImportMode] = useState('text'); + const [singleUrl, setSingleUrl] = useState(''); + const [textInput, setTextInput] = useState(''); + const [parsedUrls, setParsedUrls] = useState([]); + const [processing, setProcessing] = useState(false); + const [current, setCurrent] = useState(0); + const [total, setTotal] = useState(0); + const [results, setResults] = useState([]); + const [completed, setCompleted] = useState(false); + const [successCount, setSuccessCount] = useState(0); + const [failCount, setFailCount] = useState(0); + + const sseSubRef = useRef([]); + + const parseUrls = useCallback((text: string): string[] => { + return text + .split(/[\r\n]+/) + .map(line => line.trim()) + .filter(line => line.length > 0 && !line.startsWith('#') && !line.startsWith('#')); + }, []); + + useEffect(() => { + if (inputMode === 'batch' && importMode === 'text') { + setParsedUrls(parseUrls(textInput)); + } + }, [textInput, inputMode, importMode, parseUrls]); + + const handleFileImport = useCallback((file: File) => { + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result as string; + setTextInput(text); + setImportMode('text'); + }; + reader.onerror = () => { + antdMessage.error('文件读取失败'); + }; + reader.readAsText(file); + return false; + }, []); + + const cleanupSSE = useCallback(() => { + sseSubRef.current.forEach(id => sseManager.unsubscribe(id)); + sseSubRef.current = []; + }, []); + + const subscribeSSE = useCallback((batchId: string) => { + const progressSubId = sseManager.subscribe( + batchId, + 'batch_progress', + (message) => { + const data = message.data; + setCurrent(data.index); + setResults(prev => prev.map((item, i) => + i === data.index + ? { ...item, status: data.success ? 'success' : 'error', error: data.error } + : item + )); + } + ); + + const completeSubId = sseManager.subscribe( + batchId, + 'batch_complete', + (message) => { + const data = message.data; + setProcessing(false); + setCompleted(true); + setSuccessCount(data.success_count); + setFailCount(data.fail_count); + cleanupSSE(); + + if (data.fail_count === 0) { + antdMessage.success(`添加成功`); + } else if (data.success_count === 0) { + antdMessage.error(`添加失败`); + } else { + antdMessage.warning(`添加完成,成功 ${data.success_count} 个,失败 ${data.fail_count} 个`); + } + onSuccess(); + } + ); + + sseSubRef.current = [progressSubId, completeSubId]; + }, [cleanupSSE, onSuccess]); + + useEffect(() => { + return cleanupSSE; + }, [cleanupSSE]); + + const handleStart = async () => { + if (processing) return; + + // 根据模式获取 URL 列表 + let urls: string[]; + if (inputMode === 'single') { + const trimmed = singleUrl.trim(); + if (!trimmed) { + antdMessage.warning('请输入直播间地址'); + return; + } + urls = [trimmed]; + } else { + urls = importMode === 'text' ? parseUrls(textInput) : parsedUrls; + if (urls.length === 0) { + antdMessage.warning('请输入至少一个直播间地址'); + return; + } + } + + const batchId = generateBatchId(); + + setProcessing(true); + setCompleted(false); + setCurrent(-1); + setTotal(urls.length); + setResults(urls.map(url => ({ url, status: 'pending' as const }))); + setSuccessCount(0); + setFailCount(0); + + subscribeSSE(batchId); + + try { + await api.batchAddRooms(urls, true, batchId); + } catch (err: any) { + antdMessage.error('请求失败: ' + (err?.message || err)); + cleanupSSE(); + setProcessing(false); + } + }; + + const handleClose = () => { + if (processing) { + Modal.confirm({ + title: '确认关闭', + content: '关闭后前端不再显示进度,后端会继续完成剩余添加。确认关闭?', + onOk: () => { + cleanupSSE(); + resetState(); + onClose(); + }, + }); + return; + } + resetState(); + onClose(); + }; + + const resetState = () => { + cleanupSSE(); + setInputMode('single'); + setImportMode('text'); + setSingleUrl(''); + setTextInput(''); + setParsedUrls([]); + setProcessing(false); + setCurrent(0); + setTotal(0); + setResults([]); + setCompleted(false); + setSuccessCount(0); + setFailCount(0); + }; + + const progressPercent = total > 0 && current >= 0 ? Math.round(((current + 1) / total) * 100) : 0; + const validUrlCount = inputMode === 'single' + ? (singleUrl.trim() ? 1 : 0) + : parsedUrls.length; + + // 单个模式下,成功/失败后显示简要结果而非进度列表 + const isSingleMode = inputMode === 'single' || total === 1; + + return ( + + + {!completed && ( + + )} + + ) + } + > + {/* 输入区域(仅未处理/未完成时显示) */} + {!processing && !completed && ( + <> + {/* 模式切换 */} +
+ { + setInputMode(e.target.value); + setImportMode('text'); + }} + size="small" + > + 单个添加 + 批量添加 + + {inputMode === 'batch' && ( + setImportMode(e.target.value)} + size="small" + > + 手动输入 + 导入TXT + + )} +
+ + {/* 文件导入按钮 */} + {inputMode === 'batch' && importMode === 'file' && ( + + + + )} + + {/* URL 输入 */} + {inputMode === 'single' ? ( + setSingleUrl(e.target.value)} + placeholder="输入直播间地址,如 https://live.bilibili.com/12345" + onPressEnter={handleStart} + /> + ) : ( + <> + +