diff --git a/backend/src/module/api/__init__.py b/backend/src/module/api/__init__.py index cd68605a..7ef342da 100644 --- a/backend/src/module/api/__init__.py +++ b/backend/src/module/api/__init__.py @@ -4,6 +4,7 @@ from .auth import router as auth_router from .bangumi import router as bangumi_router from .config import router as config_router from .downloader import router as downloader_router +from .events import router as events_router from .log import router as log_router from .notification import router as notification_router from .passkey import router as passkey_router @@ -23,6 +24,7 @@ v1.include_router(program_router) v1.include_router(bangumi_router) v1.include_router(config_router) v1.include_router(downloader_router) +v1.include_router(events_router) v1.include_router(rss_router) v1.include_router(search_router) v1.include_router(setup_router) diff --git a/backend/src/module/api/events.py b/backend/src/module/api/events.py new file mode 100644 index 00000000..3f8963aa --- /dev/null +++ b/backend/src/module/api/events.py @@ -0,0 +1,87 @@ +"""SSE 端点:合并推送 status/downloader/log 更新,取代前端的三个轮询循环 +(hooks/useAppInfo.ts:24、pages/index/downloader.vue:28、store/log.ts:25)。 + +单个连接按 tick 节拍分别推送三种事件,节拍与原轮询间隔保持一致, +前端订阅失败时回退到原有轮询逻辑。 +""" + +import asyncio +import json +import logging + +from fastapi import APIRouter, Depends, Request +from sse_starlette.sse import EventSourceResponse + +from module.api.deps import get_context +from module.api.log import _read_log_tail +from module.conf import LOG_PATH, VERSION +from module.core import AppContext +from module.downloader import DownloadClient +from module.security.api import get_current_user + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/events", tags=["events"]) + +# tick 节拍(秒)及各事件相对节拍的推送倍数,与原轮询间隔一一对应: +# status 3s、downloader 5s、log 10s。 +_TICK_SECONDS = 1 +_STATUS_EVERY = 3 +_DOWNLOADER_EVERY = 5 +_LOG_EVERY = 10 + + +def _status_payload(ctx: AppContext) -> dict: + """构造与 GET /program/status 相同结构的状态负载。""" + return { + "status": ctx.is_running, + "version": VERSION, + "first_run": ctx.first_run, + } + + +async def _downloader_payload() -> list[dict] | None: + """获取种子列表;下载器未配置或不可达时返回 None(跳过本次推送)。""" + try: + async with DownloadClient() as client: + return await client.get_torrent_info(category="Bangumi", status_filter=None) + except Exception: + logger.debug("SSE: downloader status unavailable", exc_info=True) + return None + + +async def _log_payload() -> str | None: + """读取日志尾部;日志文件不存在时返回 None(跳过本次推送)。""" + if not LOG_PATH.exists(): + return None + data = await asyncio.to_thread(_read_log_tail) + return data.decode("utf-8", errors="replace") + + +@router.get("/stream", dependencies=[Depends(get_current_user)]) +async def event_stream(request: Request, ctx: AppContext = Depends(get_context)): + """单一 SSE 连接,按各自节拍推送 status/downloader/log 事件。""" + + async def generator(): + tick = 0 + while True: + if await request.is_disconnected(): + break + + if tick % _STATUS_EVERY == 0: + yield {"event": "status", "data": json.dumps(_status_payload(ctx))} + + if tick % _DOWNLOADER_EVERY == 0: + torrents = await _downloader_payload() + if torrents is not None: + yield {"event": "downloader", "data": json.dumps(torrents)} + + if tick % _LOG_EVERY == 0: + log_text = await _log_payload() + if log_text is not None: + yield {"event": "log", "data": log_text} + + await asyncio.sleep(_TICK_SECONDS) + tick += _TICK_SECONDS + + return EventSourceResponse(generator()) diff --git a/backend/src/test/test_api_events.py b/backend/src/test/test_api_events.py new file mode 100644 index 00000000..9c9cf0ba --- /dev/null +++ b/backend/src/test/test_api_events.py @@ -0,0 +1,129 @@ +"""Tests for the events SSE endpoint and its payload builders.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from module.api import v1 +from module.api.events import _downloader_payload, _log_payload, _status_payload + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def app(): + """Create a FastAPI app with v1 routes for testing.""" + app = FastAPI() + app.include_router(v1, prefix="/api") + return app + + +@pytest.fixture +def unauthed_client(app): + """TestClient without auth (no override).""" + return TestClient(app) + + +# --------------------------------------------------------------------------- +# Auth requirement — safe to hit synchronously: the dependency rejects the +# request with 401 before the (otherwise-infinite) generator ever starts. +# --------------------------------------------------------------------------- + + +class TestAuthRequired: + @patch("module.security.api.DEV_AUTH_BYPASS", False) + def test_stream_unauthorized(self, unauthed_client): + """GET /events/stream without auth returns 401.""" + response = unauthed_client.get("/api/v1/events/stream") + assert response.status_code == 401 + + +# --------------------------------------------------------------------------- +# Payload builders — exercised directly (not through the infinite generator) +# --------------------------------------------------------------------------- + + +class TestStatusPayload: + def test_status_payload_running(self): + """_status_payload mirrors GET /program/status's running shape.""" + ctx = MagicMock() + ctx.is_running = True + ctx.first_run = False + + payload = _status_payload(ctx) + + assert payload["status"] is True + assert payload["first_run"] is False + assert "version" in payload + + def test_status_payload_stopped(self): + """_status_payload reflects a stopped program.""" + ctx = MagicMock() + ctx.is_running = False + ctx.first_run = True + + payload = _status_payload(ctx) + + assert payload["status"] is False + assert payload["first_run"] is True + + +class TestDownloaderPayload: + async def test_downloader_payload_success(self): + """_downloader_payload returns the torrent list on success.""" + torrents = [{"hash": "abc", "name": "Episode 1"}] + mock_client = AsyncMock() + mock_client.get_torrent_info = AsyncMock(return_value=torrents) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + with patch("module.api.events.DownloadClient", return_value=mock_client): + result = await _downloader_payload() + + assert result == torrents + mock_client.get_torrent_info.assert_called_once_with( + category="Bangumi", status_filter=None + ) + + async def test_downloader_payload_unavailable_returns_none(self): + """_downloader_payload swallows client errors and returns None so the + stream skips that tick instead of crashing the connection.""" + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(side_effect=RuntimeError("no downloader")) + mock_client.__aexit__ = AsyncMock(return_value=False) + + with patch("module.api.events.DownloadClient", return_value=mock_client): + result = await _downloader_payload() + + assert result is None + + +class TestLogPayload: + async def test_log_payload_missing_file_returns_none(self, tmp_path): + """_log_payload returns None when the log file doesn't exist yet.""" + missing = tmp_path / "does-not-exist.log" + with patch("module.api.events.LOG_PATH", missing): + result = await _log_payload() + + assert result is None + + async def test_log_payload_reads_tail(self, tmp_path): + """_log_payload decodes the tail bytes read via _read_log_tail. + + _read_log_tail (imported from module.api.log) resolves LOG_PATH via + its own module binding, so both names must be patched together. + """ + log_file = tmp_path / "app.log" + log_file.write_text("hello world\n") + + with ( + patch("module.api.events.LOG_PATH", log_file), + patch("module.api.log.LOG_PATH", log_file), + ): + result = await _log_payload() + + assert result == "hello world\n" diff --git a/webui/src/App.vue b/webui/src/App.vue index 91dd422e..5fe5fa42 100644 --- a/webui/src/App.vue +++ b/webui/src/App.vue @@ -1,6 +1,11 @@ diff --git a/webui/src/components/ab-add-rss.vue b/webui/src/components/ab-add-rss.vue index 5203a2d4..25791def 100644 --- a/webui/src/components/ab-add-rss.vue +++ b/webui/src/components/ab-add-rss.vue @@ -1,6 +1,6 @@ + + + + + + {{ $t('search.confirm.advanced') }} + + + + + + + + + + + diff --git a/webui/src/components/bangumi-filter-field.vue b/webui/src/components/bangumi-filter-field.vue new file mode 100644 index 00000000..787b699b --- /dev/null +++ b/webui/src/components/bangumi-filter-field.vue @@ -0,0 +1,65 @@ + + + + + {{ $t('homepage.rule.filter') }} + + + + + + + diff --git a/webui/src/components/bangumi-info-tags.vue b/webui/src/components/bangumi-info-tags.vue new file mode 100644 index 00000000..8f57e892 --- /dev/null +++ b/webui/src/components/bangumi-info-tags.vue @@ -0,0 +1,58 @@ + + + + + + {{ tag.value }} + + + + + diff --git a/webui/src/components/bangumi-offset-field.vue b/webui/src/components/bangumi-offset-field.vue new file mode 100644 index 00000000..ad07cbc6 --- /dev/null +++ b/webui/src/components/bangumi-offset-field.vue @@ -0,0 +1,73 @@ + + + + + {{ label }} + + + + + + + + diff --git a/webui/src/components/bangumi-preview.vue b/webui/src/components/bangumi-preview.vue new file mode 100644 index 00000000..b7ac715d --- /dev/null +++ b/webui/src/components/bangumi-preview.vue @@ -0,0 +1,217 @@ + + + + + + + + + + + + + + + + + {{ rule.title_raw }} + + (rule.year = (e.target as HTMLInputElement).value || null)" + /> + · + S + + + + + + + diff --git a/webui/src/components/bangumi-rss-link-row.vue b/webui/src/components/bangumi-rss-link-row.vue new file mode 100644 index 00000000..42e75b2f --- /dev/null +++ b/webui/src/components/bangumi-rss-link-row.vue @@ -0,0 +1,96 @@ + + + + + + {{ $t('search.confirm.rss') }}: + {{ props.link }} + + + + + + + + + diff --git a/webui/src/components/basic/__tests__/ab-button.test.ts b/webui/src/components/basic/__tests__/ab-button.test.ts deleted file mode 100644 index fdfc8eb9..00000000 --- a/webui/src/components/basic/__tests__/ab-button.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * Tests for AbButton component - */ - -import { describe, it, expect, vi } from 'vitest'; -import { mount } from '@vue/test-utils'; -import { h, defineComponent } from 'vue'; -import AbButton from '../ab-button.vue'; - -// Mock naive-ui NSpin component -vi.mock('naive-ui', () => ({ - NSpin: defineComponent({ - props: ['show', 'size'], - setup(props, { slots }) { - return () => h('div', { class: 'n-spin-mock' }, slots.default?.()); - }, - }), -})); - -describe('AbButton', () => { - describe('rendering', () => { - it('should render as button by default', () => { - const wrapper = mount(AbButton, { - slots: { - default: 'Click me', - }, - }); - - expect(wrapper.element.tagName).toBe('BUTTON'); - expect(wrapper.text()).toContain('Click me'); - }); - - it('should render as anchor when link is provided', () => { - const wrapper = mount(AbButton, { - props: { - link: 'https://example.com', - }, - slots: { - default: 'Click me', - }, - }); - - expect(wrapper.element.tagName).toBe('A'); - expect(wrapper.attributes('href')).toBe('https://example.com'); - }); - - it('should render slot content', () => { - const wrapper = mount(AbButton, { - slots: { - default: 'Custom Content', - }, - }); - - expect(wrapper.html()).toContain('Custom Content'); - }); - }); - - describe('props', () => { - describe('type', () => { - it('should have primary type by default', () => { - const wrapper = mount(AbButton); - - expect(wrapper.classes()).toContain('btn--primary'); - }); - - it('should apply secondary type class', () => { - const wrapper = mount(AbButton, { - props: { type: 'secondary' }, - }); - - expect(wrapper.classes()).toContain('btn--secondary'); - }); - - it('should apply warn type class', () => { - const wrapper = mount(AbButton, { - props: { type: 'warn' }, - }); - - expect(wrapper.classes()).toContain('btn--warn'); - }); - }); - - describe('size', () => { - it('should have normal size by default', () => { - const wrapper = mount(AbButton); - - expect(wrapper.classes()).toContain('btn--normal'); - }); - - it('should apply big size class', () => { - const wrapper = mount(AbButton, { - props: { size: 'big' }, - }); - - expect(wrapper.classes()).toContain('btn--big'); - }); - - it('should apply small size class', () => { - const wrapper = mount(AbButton, { - props: { size: 'small' }, - }); - - expect(wrapper.classes()).toContain('btn--small'); - }); - }); - - describe('loading', () => { - it('should be false by default', () => { - const wrapper = mount(AbButton); - - // Verify component renders with default loading=false - expect(wrapper.vm.$props.loading).toBe(false); - }); - }); - }); - - describe('events', () => { - it('should emit click event when clicked', async () => { - const wrapper = mount(AbButton); - - await wrapper.trigger('click'); - - expect(wrapper.emitted('click')).toBeTruthy(); - expect(wrapper.emitted('click')?.length).toBe(1); - }); - - it('should emit click event multiple times', async () => { - const wrapper = mount(AbButton); - - await wrapper.trigger('click'); - await wrapper.trigger('click'); - await wrapper.trigger('click'); - - expect(wrapper.emitted('click')?.length).toBe(3); - }); - }); - - describe('accessibility', () => { - it('should have btn class for styling', () => { - const wrapper = mount(AbButton); - - expect(wrapper.classes()).toContain('btn'); - }); - }); - - describe('combined props', () => { - it('should apply multiple props correctly', () => { - const wrapper = mount(AbButton, { - props: { - type: 'warn', - size: 'big', - }, - slots: { - default: 'Delete', - }, - }); - - expect(wrapper.classes()).toContain('btn--warn'); - expect(wrapper.classes()).toContain('btn--big'); - expect(wrapper.text()).toContain('Delete'); - }); - }); -}); diff --git a/webui/src/components/basic/__tests__/ab-switch.test.ts b/webui/src/components/basic/__tests__/ab-switch.test.ts deleted file mode 100644 index 95ad14db..00000000 --- a/webui/src/components/basic/__tests__/ab-switch.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Tests for AbSwitch component - */ - -import { describe, it, expect, vi } from 'vitest'; -import { mount } from '@vue/test-utils'; -import { h, defineComponent, ref } from 'vue'; -import AbSwitch from '../ab-switch.vue'; - -// Mock @headlessui/vue Switch component -vi.mock('@headlessui/vue', () => ({ - Switch: defineComponent({ - props: ['modelValue', 'as'], - emits: ['update:modelValue'], - setup(props, { emit, slots }) { - const toggle = () => { - emit('update:modelValue', !props.modelValue); - }; - return () => - h( - 'div', - { - class: 'headlessui-switch-mock', - onClick: toggle, - 'data-checked': props.modelValue, - }, - slots.default?.() - ); - }, - }), -})); - -describe('AbSwitch', () => { - describe('rendering', () => { - it('should render switch track', () => { - const wrapper = mount(AbSwitch); - - expect(wrapper.find('.switch-track').exists()).toBe(true); - }); - - it('should render switch thumb', () => { - const wrapper = mount(AbSwitch); - - expect(wrapper.find('.switch-thumb').exists()).toBe(true); - }); - }); - - describe('checked state', () => { - it('should be unchecked by default', () => { - const wrapper = mount(AbSwitch); - const track = wrapper.find('.switch-track'); - const thumb = wrapper.find('.switch-thumb'); - - expect(track.classes()).not.toContain('switch-track--checked'); - expect(thumb.classes()).not.toContain('switch-thumb--checked'); - }); - - it('should apply checked classes when checked is true', async () => { - const wrapper = mount(AbSwitch, { - props: { - checked: true, - 'onUpdate:checked': (e: boolean) => - wrapper.setProps({ checked: e }), - }, - }); - - const track = wrapper.find('.switch-track'); - const thumb = wrapper.find('.switch-thumb'); - - expect(track.classes()).toContain('switch-track--checked'); - expect(thumb.classes()).toContain('switch-thumb--checked'); - }); - - it('should not have checked classes when checked is false', () => { - const wrapper = mount(AbSwitch, { - props: { - checked: false, - 'onUpdate:checked': () => {}, - }, - }); - - const track = wrapper.find('.switch-track'); - const thumb = wrapper.find('.switch-thumb'); - - expect(track.classes()).not.toContain('switch-track--checked'); - expect(thumb.classes()).not.toContain('switch-thumb--checked'); - }); - }); - - describe('v-model', () => { - it('should emit update:checked when toggled', async () => { - const wrapper = mount(AbSwitch, { - props: { - checked: false, - 'onUpdate:checked': (e: boolean) => - wrapper.setProps({ checked: e }), - }, - }); - - // Find the HeadlessUI Switch mock and click it - const switchMock = wrapper.find('.headlessui-switch-mock'); - await switchMock.trigger('click'); - - expect(wrapper.emitted('update:checked')).toBeTruthy(); - }); - - it('should toggle from false to true', async () => { - let checked = false; - const wrapper = mount(AbSwitch, { - props: { - checked, - 'onUpdate:checked': (e: boolean) => { - checked = e; - wrapper.setProps({ checked: e }); - }, - }, - }); - - const switchMock = wrapper.find('.headlessui-switch-mock'); - await switchMock.trigger('click'); - - expect(checked).toBe(true); - }); - - it('should toggle from true to false', async () => { - let checked = true; - const wrapper = mount(AbSwitch, { - props: { - checked, - 'onUpdate:checked': (e: boolean) => { - checked = e; - wrapper.setProps({ checked: e }); - }, - }, - }); - - const switchMock = wrapper.find('.headlessui-switch-mock'); - await switchMock.trigger('click'); - - expect(checked).toBe(false); - }); - }); - - describe('accessibility', () => { - it('should use HeadlessUI Switch component', () => { - const wrapper = mount(AbSwitch); - - // The mock creates a div with this class - expect(wrapper.find('.headlessui-switch-mock').exists()).toBe(true); - }); - }); - - describe('styling', () => { - it('should have correct track dimensions via CSS class', () => { - const wrapper = mount(AbSwitch); - const track = wrapper.find('.switch-track'); - - expect(track.exists()).toBe(true); - expect(track.classes()).toContain('switch-track'); - }); - - it('should have correct thumb styling via CSS class', () => { - const wrapper = mount(AbSwitch); - const thumb = wrapper.find('.switch-thumb'); - - expect(thumb.exists()).toBe(true); - expect(thumb.classes()).toContain('switch-thumb'); - }); - }); -}); diff --git a/webui/src/components/basic/ab-button.stories.ts b/webui/src/components/basic/ab-button.stories.ts deleted file mode 100644 index 87fd4e20..00000000 --- a/webui/src/components/basic/ab-button.stories.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/vue3'; - -import AbButton from './ab-button.vue'; - -const meta: Meta = { - title: 'basic/ab-button', - component: AbButton, - tags: ['autodocs'], - argTypes: { - type: { - control: { type: 'select' }, - options: ['primary', 'warn'], - }, - size: { - control: { type: 'select' }, - options: ['big', 'normal', 'small'], - }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Template: Story = { - render: (args) => ({ - components: { AbButton }, - setup() { - return { args }; - }, - template: 'button', - }), -}; diff --git a/webui/src/components/basic/ab-button.vue b/webui/src/components/basic/ab-button.vue deleted file mode 100644 index 20317047..00000000 --- a/webui/src/components/basic/ab-button.vue +++ /dev/null @@ -1,163 +0,0 @@ - - - - - - - - - - - - - diff --git a/webui/src/components/basic/ab-checkbox.stories.ts b/webui/src/components/basic/ab-checkbox.stories.ts deleted file mode 100644 index ccf2eb4d..00000000 --- a/webui/src/components/basic/ab-checkbox.stories.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/vue3'; - -import AbCheckbox from './ab-checkbox.vue'; - -const meta: Meta = { - title: 'basic/ab-checkbox', - component: AbCheckbox, - tags: ['autodocs'], -}; - -export default meta; -type Story = StoryObj; - -export const Template: Story = { - render: (args) => ({ - components: { AbCheckbox }, - setup() { - return { args }; - }, - template: '', - }), -}; diff --git a/webui/src/components/basic/ab-checkbox.vue b/webui/src/components/basic/ab-checkbox.vue deleted file mode 100644 index 1fcbe3d6..00000000 --- a/webui/src/components/basic/ab-checkbox.vue +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/webui/src/components/basic/ab-offset-mismatch-dialog.vue b/webui/src/components/basic/ab-offset-mismatch-dialog.vue index 71eaa574..d46bac15 100644 --- a/webui/src/components/basic/ab-offset-mismatch-dialog.vue +++ b/webui/src/components/basic/ab-offset-mismatch-dialog.vue @@ -1,4 +1,5 @@ - - - - - - {{ label }} - - - - - - - - - - - {{ getLabel(item) }} - - - - - - - - - - - - diff --git a/webui/src/components/basic/ab-switch.stories.ts b/webui/src/components/basic/ab-switch.stories.ts deleted file mode 100644 index 07e27d9b..00000000 --- a/webui/src/components/basic/ab-switch.stories.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/vue3'; - -import AbSwitch from './ab-switch.vue'; - -const meta: Meta = { - title: 'basic/ab-switch', - component: AbSwitch, - tags: ['autodocs'], -}; - -export default meta; -type Story = StoryObj; - -export const Template: Story = { - render: (args) => ({ - components: { AbSwitch }, - setup() { - return { args }; - }, - template: '', - }), -}; diff --git a/webui/src/components/basic/ab-switch.vue b/webui/src/components/basic/ab-switch.vue deleted file mode 100644 index de365f1d..00000000 --- a/webui/src/components/basic/ab-switch.vue +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - diff --git a/webui/src/components/calendar/calendar-board.vue b/webui/src/components/calendar/calendar-board.vue new file mode 100644 index 00000000..724c7a10 --- /dev/null +++ b/webui/src/components/calendar/calendar-board.vue @@ -0,0 +1,173 @@ + + + + + + onUnpin(group, event)" + /> + + + + + + {{ getDayLabel('unknown') }} + {{ $t('calendar.drag_hint') }} + + + + + + + + + + + diff --git a/webui/src/components/calendar/calendar-card.vue b/webui/src/components/calendar/calendar-card.vue new file mode 100644 index 00000000..c252d25e --- /dev/null +++ b/webui/src/components/calendar/calendar-card.vue @@ -0,0 +1,244 @@ + + + + + + + + + + + + + + + + + {{ group.primary.official_title }} + + + + + + + + + + + × + + + {{ group.rules.length }} + + + + + diff --git a/webui/src/components/calendar/calendar-day-column.vue b/webui/src/components/calendar/calendar-day-column.vue new file mode 100644 index 00000000..7002cac5 --- /dev/null +++ b/webui/src/components/calendar/calendar-day-column.vue @@ -0,0 +1,173 @@ + + + + + + + {{ dayLabel }} + {{ + $t('calendar.today') + }} + + + + + + emit('unpin', group, event)" + /> + + + + + {{ isDragging ? $t('calendar.drop_here') : $t('calendar.empty') }} + + + + + + + diff --git a/webui/src/components/calendar/calendar-mobile-list.vue b/webui/src/components/calendar/calendar-mobile-list.vue new file mode 100644 index 00000000..897c629f --- /dev/null +++ b/webui/src/components/calendar/calendar-mobile-list.vue @@ -0,0 +1,281 @@ + + + + + + + + + {{ getDayLabel(key) }} + + {{ $t('calendar.today') }} + + + + + + + + + + + + + + + {{ group.primary.official_title }} + + {{ group.rules.length }} + + + + + + + + + + + + + + + {{ $t('calendar.no_data') }} + + + + + diff --git a/webui/src/components/calendar/calendar-rule-list-popup.vue b/webui/src/components/calendar/calendar-rule-list-popup.vue new file mode 100644 index 00000000..5d81987b --- /dev/null +++ b/webui/src/components/calendar/calendar-rule-list-popup.vue @@ -0,0 +1,160 @@ + + + + + + {{ $t('homepage.rule.select_hint') }} + + + + {{ + rule.group_name || rule.rule_name || $t('homepage.rule.unnamed') + }} + + + + + + + + {{ $t('homepage.rule.filter') }}: + {{ + rule.filter.join(', ') + }} + + + {{ rule.title_raw }} + + + › + + + + + + diff --git a/webui/src/components/calendar/types.ts b/webui/src/components/calendar/types.ts new file mode 100644 index 00000000..625929ae --- /dev/null +++ b/webui/src/components/calendar/types.ts @@ -0,0 +1,8 @@ +import type { BangumiRule } from '#/bangumi'; + +/** 按 official_title + season 分组后的番剧组,日历页面/看板/卡片共用 */ +export interface BangumiGroup { + key: string; + primary: BangumiRule; + rules: BangumiRule[]; +} diff --git a/webui/src/components/setting/config-notification.vue b/webui/src/components/setting/config-notification.vue index 1ff2f34a..510cfed1 100644 --- a/webui/src/components/setting/config-notification.vue +++ b/webui/src/components/setting/config-notification.vue @@ -1,4 +1,5 @@ @@ -84,35 +90,50 @@ const canTest = computed(() => { - + - - {{ isTesting ? t('setup.downloader.testing') : t('setup.downloader.test') }} - - + {{ + isTesting + ? t('setup.downloader.testing') + : t('setup.downloader.test') + }} + + {{ testMessage }} - + {{ t('setup.nav.previous') }} - - + {{ t('setup.nav.next') }} - + @@ -153,7 +174,8 @@ const canTest = computed(() => { border: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-text); - transition: border-color var(--transition-fast), box-shadow var(--transition-fast); + transition: border-color var(--transition-fast), + box-shadow var(--transition-fast); &:hover { border-color: var(--color-primary); diff --git a/webui/src/components/setup/wizard-step-notification.vue b/webui/src/components/setup/wizard-step-notification.vue index 8d30208d..595c474d 100644 --- a/webui/src/components/setup/wizard-step-notification.vue +++ b/webui/src/components/setup/wizard-step-notification.vue @@ -1,4 +1,6 @@ @@ -11,9 +13,9 @@ const setupStore = useSetupStore(); - + {{ t('setup.welcome.start') }} - + diff --git a/webui/src/hooks/__tests__/useBangumiRuleForm.test.ts b/webui/src/hooks/__tests__/useBangumiRuleForm.test.ts new file mode 100644 index 00000000..032f9cb7 --- /dev/null +++ b/webui/src/hooks/__tests__/useBangumiRuleForm.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for useBangumiRuleForm — the shared composable extracted from + * ab-add-rss.vue / ab-edit-rule.vue. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { defineComponent, ref } from 'vue'; +import type { Ref } from 'vue'; +import { mount } from '@vue/test-utils'; +import type { BangumiRule } from '#/bangumi'; +import { ruleTemplate } from '#/bangumi'; +import { useBangumiRuleForm } from '@/hooks/useBangumiRuleForm'; + +/** 在真实的组件实例内运行 composable,使 onBeforeUnmount 等生命周期钩子可用 */ +function withSetup(rule: Ref) { + let result!: ReturnType; + const wrapper = mount( + defineComponent({ + setup() { + result = useBangumiRuleForm(rule); + return () => null; + }, + }) + ); + return { result, wrapper }; +} + +describe('useBangumiRuleForm', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('should build no info tags when rule has no season/dpi/subtitle/group', () => { + const rule = ref({ ...ruleTemplate, season: 0 }); + const { result } = withSetup(rule); + + expect(result.infoTags.value).toEqual([]); + }); + + it('should build info tags in season/resolution/subtitle/group order when populated', () => { + const rule = ref({ + ...ruleTemplate, + season: 2, + dpi: '1080p', + subtitle: 'CHS', + group_name: 'ANi', + }); + const { result } = withSetup(rule); + + expect(result.infoTags.value).toEqual([ + { value: 'S2', type: 'season' }, + { value: '1080p', type: 'resolution' }, + { value: 'CHS', type: 'subtitle' }, + { value: 'ANi', type: 'group' }, + ]); + }); + + it('should prefer season_raw over the numeric season when both are set', () => { + const rule = ref({ + ...ruleTemplate, + season: 2, + season_raw: 'S2 Part 2', + }); + const { result } = withSetup(rule); + + expect(result.infoTags.value).toEqual([ + { value: 'S2 Part 2', type: 'season' }, + ]); + }); + + it('should default showAdvanced to true', () => { + const rule = ref({ ...ruleTemplate }); + const { result } = withSetup(rule); + + expect(result.showAdvanced.value).toBe(true); + }); + + it('should set copied to true after copyRssLink and clear it after the timeout', async () => { + vi.useFakeTimers(); + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + + const rule = ref({ ...ruleTemplate }); + const { result } = withSetup(rule); + + await result.copyRssLink('https://example.com/rss'); + expect(writeText).toHaveBeenCalledWith('https://example.com/rss'); + expect(result.copied.value).toBe(true); + + vi.advanceTimersByTime(2000); + expect(result.copied.value).toBe(false); + + vi.useRealTimers(); + }); + + it('should not touch the clipboard when copyRssLink is called with an empty link', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + + const rule = ref({ ...ruleTemplate }); + const { result } = withSetup(rule); + + await result.copyRssLink(''); + expect(writeText).not.toHaveBeenCalled(); + expect(result.copied.value).toBe(false); + }); +}); diff --git a/webui/src/hooks/__tests__/useEventStream.test.ts b/webui/src/hooks/__tests__/useEventStream.test.ts new file mode 100644 index 00000000..3154c93a --- /dev/null +++ b/webui/src/hooks/__tests__/useEventStream.test.ts @@ -0,0 +1,152 @@ +/** + * Tests for useEventStream — the shared SSE connection that replaces the + * status/downloader/log polling loops (useAppInfo, downloader store, log + * store) when available. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { defineComponent, ref } from 'vue'; +import { mount } from '@vue/test-utils'; + +// useAuth.ts creates a vue-router instance at module scope, which the test +// setup doesn't mock (see useAuth.test.ts's own note about this). Stub the +// composable itself instead of pulling in the router. +const isLoggedIn = ref(false); +vi.mock('@/hooks/useAuth', () => ({ + useAuth: () => ({ isLoggedIn }), +})); + +/** Minimal EventSource stand-in that lets tests emit named SSE frames. */ +class MockEventSource { + static instances: MockEventSource[] = []; + url: string; + withCredentials: boolean; + listeners: Record void>> = {}; + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + closed = false; + + constructor(url: string, opts?: { withCredentials?: boolean }) { + this.url = url; + this.withCredentials = opts?.withCredentials ?? false; + MockEventSource.instances.push(this); + } + + addEventListener(type: string, cb: (e: { data: string }) => void) { + (this.listeners[type] ??= []).push(cb); + } + + emit(type: string, data: string) { + for (const cb of this.listeners[type] || []) cb({ data }); + } + + close() { + this.closed = true; + } +} + +/** Runs `fn` inside a real component instance and returns its result. */ +function withSetup(fn: () => T) { + let result!: T; + mount( + defineComponent({ + setup() { + result = fn(); + return () => null; + }, + }) + ); + return result; +} + +/** + * useEventStream is a module-level shared singleton (createSharedComposable), + * so each test resets the module registry and re-imports it fresh — this + * avoids state (the open connection, registered watchers) leaking between + * tests, and makes the synchronous `{ immediate: true }` watch see whatever + * `isLoggedIn` value the test set up beforehand. + */ +async function freshEventStream() { + vi.resetModules(); + const mod = await import('@/hooks/useEventStream'); + return mod.useEventStream; +} + +describe('useEventStream', () => { + beforeEach(() => { + isLoggedIn.value = false; + MockEventSource.instances = []; + vi.stubGlobal('EventSource', MockEventSource); + }); + + it('should open a connection to the events stream when logged in', async () => { + isLoggedIn.value = true; + const useEventStream = await freshEventStream(); + + withSetup(() => useEventStream()); + + const latest = MockEventSource.instances.at(-1)!; + expect(latest.url).toBe('api/v1/events/stream'); + expect(latest.withCredentials).toBe(true); + }); + + it('should not open a connection when logged out', async () => { + isLoggedIn.value = false; + const useEventStream = await freshEventStream(); + + withSetup(() => useEventStream()); + + expect(MockEventSource.instances).toHaveLength(0); + }); + + it('should set connected to true on open and parse status frames', async () => { + isLoggedIn.value = true; + const useEventStream = await freshEventStream(); + + const result = withSetup(() => useEventStream()); + const es = MockEventSource.instances.at(-1)!; + + es.onopen?.(); + expect(result.connected.value).toBe(true); + + es.emit( + 'status', + JSON.stringify({ status: true, version: '3.3.0', first_run: false }) + ); + expect(result.statusData.value).toEqual({ + status: true, + version: '3.3.0', + first_run: false, + }); + }); + + it('should parse downloader frames and pass through log frames as-is', async () => { + isLoggedIn.value = true; + const useEventStream = await freshEventStream(); + + const result = withSetup(() => useEventStream()); + const es = MockEventSource.instances.at(-1)!; + + es.emit('downloader', JSON.stringify([{ hash: 'abc', name: 'Episode 1' }])); + expect(result.downloaderData.value).toEqual([ + { hash: 'abc', name: 'Episode 1' }, + ]); + + es.emit('log', 'line one\nline two\n'); + expect(result.logData.value).toBe('line one\nline two\n'); + }); + + it('should close the connection and reset connected on error', async () => { + isLoggedIn.value = true; + const useEventStream = await freshEventStream(); + + const result = withSetup(() => useEventStream()); + const es = MockEventSource.instances.at(-1)!; + es.onopen?.(); + expect(result.connected.value).toBe(true); + + es.onerror?.(); + expect(result.connected.value).toBe(false); + expect(es.closed).toBe(true); + }); +}); diff --git a/webui/src/hooks/useAppInfo.ts b/webui/src/hooks/useAppInfo.ts index 71a61852..cb671595 100644 --- a/webui/src/hooks/useAppInfo.ts +++ b/webui/src/hooks/useAppInfo.ts @@ -2,10 +2,20 @@ import { createSharedComposable, useIntervalFn } from '@vueuse/core'; export const useAppInfo = createSharedComposable(() => { const { isLoggedIn } = useAuth(); + const { connected: sseConnected, statusData } = useEventStream(); const running = ref(false); const version = ref(''); + // SSE 已连接时使用推送数据;否则回退到轮询。 + watch(statusData, (data) => { + if (!data) return; + running.value = data.status; + version.value = data.version; + }); + function getStatus() { + // SSE 已接管状态推送,或页面不可见时,跳过本次轮询请求。 + if (sseConnected.value || document.hidden) return; if (isLoggedIn.value) { apiProgram .status() diff --git a/webui/src/hooks/useBangumiRuleForm.ts b/webui/src/hooks/useBangumiRuleForm.ts new file mode 100644 index 00000000..7ea3ec3b --- /dev/null +++ b/webui/src/hooks/useBangumiRuleForm.ts @@ -0,0 +1,66 @@ +import type { Ref } from 'vue'; +import type { BangumiRule } from '#/bangumi'; + +export interface InfoTag { + value: string; + type: 'season' | 'resolution' | 'subtitle' | 'group'; +} + +/** + * ab-add-rss / ab-edit-rule 共用的表单逻辑:海报地址、信息标签、 + * RSS 链接复制、以及“高级设置”展开状态。两个编辑器各自保留自己 + * 特有的 offset 检测/审核流程,不在此处统一。 + */ +export function useBangumiRuleForm(rule: Ref) { + const posterSrc = computed(() => resolvePosterUrl(rule.value.poster_link)); + + const infoTags = computed(() => { + const tags: InfoTag[] = []; + const { season, season_raw, dpi, subtitle, group_name } = rule.value; + + if (season || season_raw) { + const seasonDisplay = season_raw || (season ? `S${season}` : ''); + tags.push({ value: seasonDisplay, type: 'season' }); + } + + if (dpi) { + tags.push({ value: dpi, type: 'resolution' }); + } + + if (subtitle) { + tags.push({ value: subtitle, type: 'subtitle' }); + } + + if (group_name) { + tags.push({ value: group_name, type: 'group' }); + } + + return tags; + }); + + const showAdvanced = ref(true); + const copied = ref(false); + let copyTimer: ReturnType | undefined; + + async function copyRssLink(link: string) { + if (!link) return; + await navigator.clipboard.writeText(link); + copied.value = true; + clearTimeout(copyTimer); + copyTimer = setTimeout(() => { + copied.value = false; + }, 2000); + } + + onBeforeUnmount(() => { + clearTimeout(copyTimer); + }); + + return { + posterSrc, + infoTags, + showAdvanced, + copied, + copyRssLink, + }; +} diff --git a/webui/src/hooks/useEventStream.ts b/webui/src/hooks/useEventStream.ts new file mode 100644 index 00000000..fb9261bf --- /dev/null +++ b/webui/src/hooks/useEventStream.ts @@ -0,0 +1,118 @@ +import { createSharedComposable } from '@vueuse/core'; +import type { QbTorrentInfo } from '#/downloader'; + +export interface StatusPayload { + status: boolean; + version: string; + first_run: boolean; +} + +const RECONNECT_BASE_DELAY_MS = 1000; +const RECONNECT_MAX_DELAY_MS = 15000; + +/** + * 单一 SSE 连接(api/v1/events/stream),推送 status/downloader/log 更新, + * 取代原本 useAppInfo/downloader.vue/log store 各自的轮询循环。 + * + * `connected` 供三个消费者判断:为 true 时它们跳过自己的轮询请求,改用 + * `statusData`/`downloaderData`/`logData`;连接断开或环境不支持 + * EventSource 时,`connected` 保持 false,消费者据此回退到原有轮询。 + */ +export const useEventStream = createSharedComposable(() => { + const { isLoggedIn } = useAuth(); + + const connected = ref(false); + const statusData = ref(null); + const downloaderData = ref(null); + const logData = ref(null); + + let source: EventSource | null = null; + let retryCount = 0; + let retryTimer: ReturnType | undefined; + + function teardown() { + source?.close(); + source = null; + connected.value = false; + } + + function scheduleReconnect() { + clearTimeout(retryTimer); + const delay = Math.min( + RECONNECT_BASE_DELAY_MS * 2 ** retryCount, + RECONNECT_MAX_DELAY_MS + ); + retryCount += 1; + retryTimer = setTimeout(() => { + if (isLoggedIn.value) connect(); + }, delay); + } + + function connect() { + if (!isLoggedIn.value || typeof EventSource === 'undefined') { + return; + } + teardown(); + + const es = new EventSource('api/v1/events/stream', { withCredentials: true }); + source = es; + + es.onopen = () => { + connected.value = true; + retryCount = 0; + }; + + es.addEventListener('status', (e) => { + try { + statusData.value = JSON.parse((e as MessageEvent).data); + } catch { + // Ignore malformed frames; the next tick will retry. + } + }); + + es.addEventListener('downloader', (e) => { + try { + downloaderData.value = JSON.parse((e as MessageEvent).data); + } catch { + // Ignore malformed frames; the next tick will retry. + } + }); + + es.addEventListener('log', (e) => { + logData.value = (e as MessageEvent).data; + }); + + es.onerror = () => { + teardown(); + scheduleReconnect(); + }; + } + + function stop() { + clearTimeout(retryTimer); + retryCount = 0; + teardown(); + } + + // 生命周期跟随登录状态,而非某个调用方组件的挂载/卸载——这是一个跨组件 + // 共享的单例连接(createSharedComposable),不能绑定到任意一个消费者的 + // onBeforeUnmount 上。 + watch( + isLoggedIn, + (loggedIn) => { + if (loggedIn) { + connect(); + } else { + stop(); + } + }, + { immediate: true } + ); + + return { + connected, + statusData, + downloaderData, + logData, + }; +}); diff --git a/webui/src/pages/index/bangumi.vue b/webui/src/pages/index/bangumi.vue index d454afa6..ea46c2ce 100644 --- a/webui/src/pages/index/bangumi.vue +++ b/webui/src/pages/index/bangumi.vue @@ -1,11 +1,19 @@ - - - - - - - - - - - - - {{ $t('homepage.empty.title') }} - {{ $t('homepage.empty.subtitle') }} - - - - - 1 - - {{ $t('homepage.empty.step1_title') }} - {{ $t('homepage.empty.step1_desc') }} - - - - - 2 - - {{ $t('homepage.empty.step2_title') }} - {{ $t('homepage.empty.step2_desc') }} - - - - - 3 - - {{ $t('homepage.empty.step3_title') }} - {{ $t('homepage.empty.step3_desc') }} - - - - - - - {{ $t('homepage.empty.add_rss_btn') }} - - - - - - - + + + - onCardClick(group)" - /> - - - - ! - - - {{ group.rules.length }} - - - - {{ group.rules.length }} - + + + + + + + + + {{ $t('homepage.empty.title') }} + + {{ $t('homepage.empty.subtitle') }} - - - - - - {{ $t('homepage.rule.archived_section', { count: archivedBangumi.length }) }} - - {{ showArchived ? '−' : '+' }} - + + + 1 + + + {{ $t('homepage.empty.step1_title') }} + + + {{ $t('homepage.empty.step1_desc') }} + + + - + + 2 + + + {{ $t('homepage.empty.step2_title') }} + + + {{ $t('homepage.empty.step2_desc') }} + + + + + + 3 + + + {{ $t('homepage.empty.step3_title') }} + + + {{ $t('homepage.empty.step3_desc') }} + + + + + + + + {{ $t('homepage.empty.add_rss_btn') }} + + + + + + + - {{ $t('homepage.rule.archived') }} - - - - - - {{ $t('homepage.rule.select_hint') }} - - - - ! {{ rule.group_name || rule.rule_name || $t('homepage.rule.unnamed') }} + + + + + {{ + $t('homepage.rule.archived_section', { + count: archivedBangumi.length, + }) + }} + + {{ + showArchived ? '−' : '+' + }} + + + + + onCardClick(group)" + /> + + + + ! + + + {{ group.rules.length }} + + + + {{ group.rules.length }} + + + + {{ $t('homepage.rule.archived') }} + - - - - - - - {{ $t('homepage.rule.filter') }}: - {{ rule.filter.join(', ') }} - - - {{ rule.title_raw }} - - - › + - - + - + + + + + {{ $t('homepage.rule.select_hint') }} + + + + + ! {{ + rule.group_name || + rule.rule_name || + $t('homepage.rule.unnamed') + }} + + + + + + + + {{ $t('homepage.rule.filter') }}: + {{ + rule.filter.join(', ') + }} + + + {{ rule.title_raw }} + + + › + + + + @@ -495,11 +546,19 @@ function groupNeedsReview(group: BangumiGroup): boolean { // Warning variant - needs review &--warning { - background: linear-gradient(135deg, rgba(251, 191, 36, 0.15) 0%, rgba(251, 191, 36, 0.08) 100%); + background: linear-gradient( + 135deg, + rgba(251, 191, 36, 0.15) 0%, + rgba(251, 191, 36, 0.08) 100% + ); border-left: 3px solid #fbbf24; &:hover { - background: linear-gradient(135deg, rgba(251, 191, 36, 0.22) 0%, rgba(251, 191, 36, 0.12) 100%); + background: linear-gradient( + 135deg, + rgba(251, 191, 36, 0.22) 0%, + rgba(251, 191, 36, 0.12) 100% + ); } } @@ -617,7 +676,7 @@ function groupNeedsReview(group: BangumiGroup): boolean { border: 1px solid var(--color-border); background: var(--color-surface); transition: background-color var(--transition-normal), - border-color var(--transition-normal); + border-color var(--transition-normal); } .empty-guide-step-number { @@ -687,7 +746,8 @@ function groupNeedsReview(group: BangumiGroup): boolean { diff --git a/webui/src/pages/index/config.vue b/webui/src/pages/index/config.vue index cb319baa..8e65b316 100644 --- a/webui/src/pages/index/config.vue +++ b/webui/src/pages/index/config.vue @@ -1,4 +1,6 @@
{{ rule.title_raw }}
+ {{ + isTesting + ? t('setup.downloader.testing') + : t('setup.downloader.test') + }} + +
{{ testMessage }}