mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-02 21:55:22 +08:00
refactor(webui): split oversized components, adopt naive-ui primitives, add SSE
Extract useBangumiRuleForm + shared preview/tag/rss-link/filter/offset components out of ab-add-rss.vue and ab-edit-rule.vue, and split calendar.vue into calendar-board/calendar-day-column/calendar-card/ calendar-mobile-list/calendar-rule-list-popup. Replace the bespoke ab-button/ab-select/ab-switch/ab-checkbox primitives with themed NButton/NSelect/NSwitch (naive-ui) across the app and delete the now- unused wrappers. Add a backend SSE endpoint (GET /api/v1/events/stream) streaming status/downloader/log ticks, and a shared useEventStream composable that the status/downloader/log polling loops prefer over their interval fetch, falling back to polling when SSE is unavailable or the tab is hidden. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
This commit is contained in:
@@ -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)
|
||||
|
||||
87
backend/src/module/api/events.py
Normal file
87
backend/src/module/api/events.py
Normal file
@@ -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())
|
||||
129
backend/src/test/test_api_events.py
Normal file
129
backend/src/test/test_api_events.py
Normal file
@@ -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"
|
||||
@@ -1,6 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { type GlobalThemeOverrides, NConfigProvider, NMessageProvider, darkTheme } from 'naive-ui';
|
||||
import {
|
||||
type GlobalThemeOverrides,
|
||||
NConfigProvider,
|
||||
NMessageProvider,
|
||||
darkTheme,
|
||||
} from 'naive-ui';
|
||||
|
||||
const { isDark } = useDarkMode();
|
||||
const { refresh, isLoggedIn } = useAuth();
|
||||
@@ -9,8 +14,31 @@ if (isLoggedIn.value) {
|
||||
refresh();
|
||||
}
|
||||
|
||||
const lightOverrides: GlobalThemeOverrides = {
|
||||
// Button/Select/Switch tokens reference the app's own CSS custom properties
|
||||
// directly (they resolve correctly under both `:root` and `.dark`), so they
|
||||
// don't need to be duplicated between the light/dark override objects below.
|
||||
const primitiveOverrides: GlobalThemeOverrides = {
|
||||
common: {
|
||||
errorColor: 'var(--color-danger)',
|
||||
errorColorHover: 'var(--color-danger)',
|
||||
errorColorPressed: 'var(--color-danger)',
|
||||
errorColorSuppl: 'var(--color-danger)',
|
||||
},
|
||||
Button: {
|
||||
borderRadiusTiny: 'var(--radius-sm)',
|
||||
borderRadiusSmall: 'var(--radius-sm)',
|
||||
borderRadiusMedium: 'var(--radius-sm)',
|
||||
borderRadiusLarge: 'var(--radius-md)',
|
||||
},
|
||||
Switch: {
|
||||
railColorActive: 'var(--color-primary)',
|
||||
},
|
||||
};
|
||||
|
||||
const lightOverrides: GlobalThemeOverrides = {
|
||||
...primitiveOverrides,
|
||||
common: {
|
||||
...primitiveOverrides.common,
|
||||
primaryColor: '#6C4AB6',
|
||||
primaryColorHover: '#563A92',
|
||||
primaryColorPressed: '#4A3291',
|
||||
@@ -40,7 +68,9 @@ const lightOverrides: GlobalThemeOverrides = {
|
||||
};
|
||||
|
||||
const darkOverrides: GlobalThemeOverrides = {
|
||||
...primitiveOverrides,
|
||||
common: {
|
||||
...primitiveOverrides.common,
|
||||
primaryColor: '#8B6CC7',
|
||||
primaryColorHover: '#A78BDB',
|
||||
primaryColorPressed: '#7B5CB7',
|
||||
@@ -69,8 +99,10 @@ const darkOverrides: GlobalThemeOverrides = {
|
||||
},
|
||||
};
|
||||
|
||||
const themeOverrides = computed(() => isDark.value ? darkOverrides : lightOverrides);
|
||||
const naiveTheme = computed(() => isDark.value ? darkTheme : null);
|
||||
const themeOverrides = computed(() =>
|
||||
isDark.value ? darkOverrides : lightOverrides
|
||||
);
|
||||
const naiveTheme = computed(() => (isDark.value ? darkTheme : null));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { CheckOne, Close, Copy, Down, ErrorPicture, Link, Right } from '@icon-park/vue-next';
|
||||
import { NDynamicTags, NSpin } from 'naive-ui';
|
||||
import { Close, Link } from '@icon-park/vue-next';
|
||||
import { NButton, NSpin } from 'naive-ui';
|
||||
import type { BangumiRule } from '#/bangumi';
|
||||
import type { RSS } from '#/rss';
|
||||
import { rssTemplate } from '#/rss';
|
||||
@@ -14,16 +14,19 @@ const { getAll } = useBangumiStore();
|
||||
const { t } = useMyI18n();
|
||||
|
||||
const rss = ref<RSS>({ ...rssTemplate });
|
||||
const rule = defineModel<BangumiRule>('rule', { default: () => ({ ...ruleTemplate }) });
|
||||
const rule = defineModel<BangumiRule>('rule', {
|
||||
default: () => ({ ...ruleTemplate }),
|
||||
});
|
||||
const parserTypes = ['tmdb', 'mikan', 'parser'] as const;
|
||||
|
||||
// UI state
|
||||
const step = ref<'input' | 'confirm'>('input');
|
||||
const showAdvanced = ref(true);
|
||||
const copied = ref(false);
|
||||
const offsetLoading = ref(false);
|
||||
const offsetReason = ref('');
|
||||
|
||||
const { posterSrc, infoTags, showAdvanced, copied, copyRssLink } =
|
||||
useBangumiRuleForm(rule);
|
||||
|
||||
const loading = reactive({
|
||||
analyze: false,
|
||||
collect: false,
|
||||
@@ -85,33 +88,6 @@ const { execute: executeSubscribe } = useApi(apiDownload.subscribe, {
|
||||
},
|
||||
});
|
||||
|
||||
// Computed
|
||||
const posterSrc = computed(() => resolvePosterUrl(rule.value.poster_link));
|
||||
|
||||
const infoTags = computed(() => {
|
||||
const tags: { value: string; type: string }[] = [];
|
||||
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;
|
||||
});
|
||||
|
||||
// Watchers
|
||||
watch(show, (val) => {
|
||||
if (!val) {
|
||||
@@ -147,23 +123,7 @@ function goBack() {
|
||||
step.value = 'input';
|
||||
}
|
||||
|
||||
let copyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function copyRssLink() {
|
||||
const rssLink = rule.value.rss_link?.[0] || rss.value.url || '';
|
||||
if (rssLink) {
|
||||
await navigator.clipboard.writeText(rssLink);
|
||||
copied.value = true;
|
||||
clearTimeout(copyTimer);
|
||||
copyTimer = setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(copyTimer);
|
||||
});
|
||||
const rssLink = computed(() => rule.value.rss_link?.[0] || rss.value.url || '');
|
||||
|
||||
async function autoDetectOffset() {
|
||||
if (!rule.value.id) return;
|
||||
@@ -210,7 +170,9 @@ function subscribe() {
|
||||
<div class="form-section">
|
||||
<!-- RSS Link -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ $t('topbar.add.rss_link') }}</label>
|
||||
<label class="form-label">{{
|
||||
$t('topbar.add.rss_link')
|
||||
}}</label>
|
||||
<div class="input-wrapper">
|
||||
<Link theme="outline" size="16" class="input-icon" />
|
||||
<input
|
||||
@@ -237,7 +199,9 @@ function subscribe() {
|
||||
<div class="options-row">
|
||||
<!-- Aggregate Switch -->
|
||||
<div class="option-item">
|
||||
<label class="option-label">{{ $t('topbar.add.aggregate') }}</label>
|
||||
<label class="option-label">{{
|
||||
$t('topbar.add.aggregate')
|
||||
}}</label>
|
||||
<label class="switch">
|
||||
<input v-model="rss.aggregate" type="checkbox" />
|
||||
<span class="switch-slider"></span>
|
||||
@@ -246,9 +210,15 @@ function subscribe() {
|
||||
|
||||
<!-- Parser Select -->
|
||||
<div class="option-item">
|
||||
<label class="option-label">{{ $t('topbar.add.parser') }}</label>
|
||||
<label class="option-label">{{
|
||||
$t('topbar.add.parser')
|
||||
}}</label>
|
||||
<select v-model="rss.parser" class="form-select">
|
||||
<option v-for="type in parserTypes" :key="type" :value="type">
|
||||
<option
|
||||
v-for="type in parserTypes"
|
||||
:key="type"
|
||||
:value="type"
|
||||
>
|
||||
{{ type }}
|
||||
</option>
|
||||
</select>
|
||||
@@ -258,139 +228,79 @@ function subscribe() {
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="add-footer">
|
||||
<ab-button size="small" :loading="loading.analyze" @click="addRss">
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="loading.analyze"
|
||||
@click="addRss"
|
||||
>
|
||||
{{ $t('topbar.add.button') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Confirm -->
|
||||
<template v-else>
|
||||
<div class="add-content">
|
||||
<!-- Bangumi Info -->
|
||||
<div class="bangumi-info">
|
||||
<div class="bangumi-poster">
|
||||
<template v-if="rule.poster_link">
|
||||
<img :src="posterSrc" :alt="rule.official_title" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="poster-placeholder">
|
||||
<ErrorPicture theme="outline" size="32" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="bangumi-meta">
|
||||
<input
|
||||
v-model="rule.official_title"
|
||||
type="text"
|
||||
class="title-input"
|
||||
:placeholder="$t('homepage.rule.official_title')"
|
||||
/>
|
||||
<p v-if="rule.title_raw" class="bangumi-subtitle">{{ rule.title_raw }}</p>
|
||||
<div class="meta-row">
|
||||
<input
|
||||
:value="rule.year ?? ''"
|
||||
type="text"
|
||||
class="year-input"
|
||||
:class="{ 'year-input--empty': !rule.year }"
|
||||
:placeholder="$t('homepage.rule.year')"
|
||||
@input="(e) => rule.year = (e.target as HTMLInputElement).value || null"
|
||||
/>
|
||||
<span class="meta-separator">·</span>
|
||||
<label class="season-label">S</label>
|
||||
<input
|
||||
v-model.number="rule.season"
|
||||
type="number"
|
||||
class="season-input"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<bangumi-preview v-model:rule="rule" :poster-src="posterSrc" />
|
||||
|
||||
<!-- Info Tags -->
|
||||
<div v-if="infoTags.length > 0" class="info-tags">
|
||||
<div
|
||||
v-for="tag in infoTags"
|
||||
:key="tag.type"
|
||||
class="info-tag"
|
||||
:class="`info-tag--${tag.type}`"
|
||||
>
|
||||
{{ tag.value }}
|
||||
</div>
|
||||
</div>
|
||||
<bangumi-info-tags :tags="infoTags" />
|
||||
|
||||
<!-- RSS Link Display -->
|
||||
<div v-if="rule.rss_link?.[0] || rss.url" class="rss-section">
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ $t('search.confirm.rss') }}:</span>
|
||||
<span class="info-value info-value--link">
|
||||
{{ rule.rss_link?.[0] || rss.url || '-' }}
|
||||
</span>
|
||||
<button class="copy-btn" :class="{ copied }" @click="copyRssLink">
|
||||
<CheckOne v-if="copied" theme="outline" size="14" />
|
||||
<Copy v-else theme="outline" size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<bangumi-rss-link-row
|
||||
:link="rssLink"
|
||||
:copied="copied"
|
||||
@copy="copyRssLink(rssLink)"
|
||||
/>
|
||||
|
||||
<!-- Advanced settings -->
|
||||
<div class="advanced-section">
|
||||
<button class="advanced-toggle" @click="showAdvanced = !showAdvanced">
|
||||
<component :is="showAdvanced ? Down : Right" theme="outline" size="14" />
|
||||
{{ $t('search.confirm.advanced') }}
|
||||
</button>
|
||||
<advanced-section v-model:open="showAdvanced">
|
||||
<bangumi-filter-field v-model="rule.filter" />
|
||||
|
||||
<Transition name="expand">
|
||||
<div v-show="showAdvanced" class="advanced-content">
|
||||
<!-- Filter rules row -->
|
||||
<div class="advanced-row advanced-row--tags">
|
||||
<label class="advanced-label">{{ $t('homepage.rule.filter') }}</label>
|
||||
<div class="advanced-control filter-tags">
|
||||
<NDynamicTags v-model:value="rule.filter" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Offset row -->
|
||||
<div class="advanced-row">
|
||||
<label class="advanced-label">{{ $t('homepage.rule.offset') }}</label>
|
||||
<div class="advanced-control offset-controls">
|
||||
<input
|
||||
v-model.number="rule.episode_offset"
|
||||
type="number"
|
||||
ab-input
|
||||
class="offset-input"
|
||||
/>
|
||||
<button
|
||||
class="detect-btn"
|
||||
:disabled="offsetLoading || !rule.id"
|
||||
@click="autoDetectOffset"
|
||||
>
|
||||
<NSpin v-if="offsetLoading" :size="14" />
|
||||
<span v-else>{{ $t('homepage.rule.auto_detect') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="offsetReason" class="offset-reason">{{ offsetReason }}</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<bangumi-offset-field
|
||||
v-model="rule.episode_offset"
|
||||
:label="$t('homepage.rule.offset')"
|
||||
>
|
||||
<template #action>
|
||||
<button
|
||||
class="detect-btn"
|
||||
:disabled="offsetLoading || !rule.id"
|
||||
@click="autoDetectOffset"
|
||||
>
|
||||
<NSpin v-if="offsetLoading" :size="14" />
|
||||
<span v-else>{{ $t('homepage.rule.auto_detect') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</bangumi-offset-field>
|
||||
<div v-if="offsetReason" class="offset-reason">
|
||||
{{ offsetReason }}
|
||||
</div>
|
||||
</advanced-section>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="add-footer add-footer--confirm">
|
||||
<div class="footer-left">
|
||||
<ab-button size="small" type="secondary" @click="goBack">
|
||||
<NButton size="small" type="primary" secondary @click="goBack">
|
||||
{{ $t('setup.nav.previous') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
<ab-button size="small" :loading="loading.collect" @click="collect">
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="loading.collect"
|
||||
@click="collect"
|
||||
>
|
||||
{{ $t('topbar.add.collect') }}
|
||||
</ab-button>
|
||||
<ab-button size="small" :loading="loading.subscribe" @click="subscribe">
|
||||
</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="loading.subscribe"
|
||||
@click="subscribe"
|
||||
>
|
||||
{{ $t('topbar.add.subscribe') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -512,11 +422,13 @@ function subscribe() {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
transition: border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
@@ -641,355 +553,9 @@ function subscribe() {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
// Bangumi Info (Step 2 - same as ab-edit-rule)
|
||||
.bangumi-info {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.bangumi-poster {
|
||||
width: 80px;
|
||||
height: 112px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--color-surface-hover);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.poster-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.bangumi-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
padding: 4px 0;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-bottom-color: var(--color-border);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.bangumi-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.year-input {
|
||||
width: 60px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
padding: 2px 0;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast), background-color var(--transition-fast);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-bottom-color: var(--color-border);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
&--empty {
|
||||
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
||||
border-bottom-color: var(--color-warning);
|
||||
border-radius: var(--radius-xs) var(--radius-xs) 0 0;
|
||||
padding: 2px 4px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.meta-separator {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.season-label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.season-input {
|
||||
width: 40px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
padding: 2px 0;
|
||||
outline: none;
|
||||
text-align: center;
|
||||
transition: border-color var(--transition-fast);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-bottom-color: var(--color-border);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
// Info Tags
|
||||
.info-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
&--season {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
&--resolution {
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
&--subtitle {
|
||||
background: color-mix(in srgb, var(--color-success) 12%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
&--group {
|
||||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
// RSS section
|
||||
.rss-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--color-surface-hover);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
flex-shrink: 0;
|
||||
width: 70px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
word-break: break-all;
|
||||
|
||||
&--link {
|
||||
color: var(--color-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
&.copied {
|
||||
background: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// Advanced section
|
||||
.advanced-section {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.advanced-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--color-surface-hover);
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.advanced-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 32px;
|
||||
|
||||
&--tags {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.advanced-control {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
:deep(.n-dynamic-tags) {
|
||||
justify-content: flex-end;
|
||||
min-height: 32px;
|
||||
|
||||
.n-tag {
|
||||
height: 28px;
|
||||
margin: 2px 0 2px 6px !important;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.offset-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.offset-input {
|
||||
width: 70px;
|
||||
height: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Offset detect button + reason (specific to the add-rss flow;
|
||||
// shared preview/tags/rss-link/filter/offset-row styles live in the
|
||||
// bangumi-* / advanced-section sub-components)
|
||||
.detect-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1024,22 +590,6 @@ function subscribe() {
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
// Expand transition
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: all var(--transition-normal);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
// Modal transition
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from 'naive-ui';
|
||||
|
||||
const show = defineModel('show', {
|
||||
default: false,
|
||||
});
|
||||
@@ -34,9 +36,9 @@ const { user, update } = useAuth();
|
||||
<div line></div>
|
||||
|
||||
<div flex="~ justify-end">
|
||||
<ab-button size="small" @click="update">{{
|
||||
<NButton type="primary" size="small" @click="update">{{
|
||||
$t('topbar.profile.update_btn')
|
||||
}}</ab-button>
|
||||
}}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</ab-popup>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { CheckOne, Close, Copy, Down, ErrorPicture, Right } from '@icon-park/vue-next';
|
||||
import { NDynamicTags, NSpin, useMessage } from 'naive-ui';
|
||||
import { Close } from '@icon-park/vue-next';
|
||||
import { NButton, NSpin, useMessage } from 'naive-ui';
|
||||
import type { BangumiRule, DetectOffsetResponse } from '#/bangumi';
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -28,13 +28,16 @@ const message = useMessage();
|
||||
const localRule = ref<BangumiRule>(JSON.parse(JSON.stringify(rule.value)));
|
||||
|
||||
// Sync when rule changes (e.g., opening different item)
|
||||
watch(rule, (newVal) => {
|
||||
localRule.value = JSON.parse(JSON.stringify(newVal));
|
||||
}, { deep: true });
|
||||
watch(
|
||||
rule,
|
||||
(newVal) => {
|
||||
localRule.value = JSON.parse(JSON.stringify(newVal));
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const posterSrc = computed(() => resolvePosterUrl(localRule.value.poster_link));
|
||||
const showAdvanced = ref(true);
|
||||
const copied = ref(false);
|
||||
const { posterSrc, infoTags, showAdvanced, copied, copyRssLink } =
|
||||
useBangumiRuleForm(localRule);
|
||||
const offsetLoading = ref(false);
|
||||
const offsetReason = ref('');
|
||||
const dismissingReview = ref(false);
|
||||
@@ -56,49 +59,7 @@ watch(show, (val) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Info tags for display
|
||||
const infoTags = computed(() => {
|
||||
const tags: { value: string; type: string }[] = [];
|
||||
const { season, season_raw, dpi, subtitle, group_name } = localRule.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;
|
||||
});
|
||||
|
||||
// Copy RSS link
|
||||
let copyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function copyRssLink() {
|
||||
const rssLink = localRule.value.rss_link?.[0] || '';
|
||||
if (rssLink) {
|
||||
await navigator.clipboard.writeText(rssLink);
|
||||
copied.value = true;
|
||||
clearTimeout(copyTimer);
|
||||
copyTimer = setTimeout(() => {
|
||||
copied.value = false;
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(copyTimer);
|
||||
});
|
||||
const rssLink = computed(() => localRule.value.rss_link?.[0] || '');
|
||||
|
||||
// Auto detect offset using the new detectOffset API
|
||||
async function autoDetectOffset() {
|
||||
@@ -196,12 +157,12 @@ function emitUnarchive() {
|
||||
<div>{{ $t('homepage.rule.enable_hit') }}</div>
|
||||
<div line my-8></div>
|
||||
<div f-cer gap-x-10>
|
||||
<ab-button size="small" type="warn" @click="emitEnable">
|
||||
<NButton size="small" type="error" @click="emitEnable">
|
||||
{{ $t('homepage.rule.yes_btn') }}
|
||||
</ab-button>
|
||||
<ab-button size="small" @click="close">
|
||||
</NButton>
|
||||
<NButton type="primary" size="small" @click="close">
|
||||
{{ $t('homepage.rule.no_btn') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</ab-popup>
|
||||
|
||||
@@ -223,8 +184,13 @@ function emitUnarchive() {
|
||||
<div class="review-warning-main">
|
||||
<span class="review-warning-emoji">⚠️</span>
|
||||
<div class="review-warning-content">
|
||||
<div class="review-warning-title">{{ $t('offset.needs_review') }}</div>
|
||||
<div v-if="localRule.needs_review_reason" class="review-warning-reason">
|
||||
<div class="review-warning-title">
|
||||
{{ $t('offset.needs_review') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="localRule.needs_review_reason"
|
||||
class="review-warning-reason"
|
||||
>
|
||||
{{ localRule.needs_review_reason }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -251,166 +217,84 @@ function emitUnarchive() {
|
||||
|
||||
<!-- Content -->
|
||||
<div class="edit-content">
|
||||
<!-- Bangumi Info -->
|
||||
<div class="bangumi-info">
|
||||
<div class="bangumi-poster">
|
||||
<template v-if="localRule.poster_link">
|
||||
<img :src="posterSrc" :alt="localRule.official_title" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="poster-placeholder">
|
||||
<ErrorPicture theme="outline" size="32" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="bangumi-meta">
|
||||
<input
|
||||
v-model="localRule.official_title"
|
||||
type="text"
|
||||
class="title-input"
|
||||
:placeholder="$t('homepage.rule.official_title')"
|
||||
/>
|
||||
<p v-if="localRule.title_raw" class="bangumi-subtitle">{{ localRule.title_raw }}</p>
|
||||
<div class="meta-row">
|
||||
<input
|
||||
:value="localRule.year ?? ''"
|
||||
type="text"
|
||||
class="year-input"
|
||||
:class="{ 'year-input--empty': !localRule.year }"
|
||||
:placeholder="$t('homepage.rule.year')"
|
||||
@input="(e) => localRule.year = (e.target as HTMLInputElement).value || null"
|
||||
/>
|
||||
<span class="meta-separator">·</span>
|
||||
<label class="season-label">S</label>
|
||||
<input
|
||||
v-model.number="localRule.season"
|
||||
type="number"
|
||||
class="season-input"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<bangumi-preview v-model:rule="localRule" :poster-src="posterSrc" />
|
||||
|
||||
<!-- Info Tags -->
|
||||
<div v-if="infoTags.length > 0" class="info-tags">
|
||||
<div
|
||||
v-for="tag in infoTags"
|
||||
:key="tag.type"
|
||||
class="info-tag"
|
||||
:class="`info-tag--${tag.type}`"
|
||||
>
|
||||
{{ tag.value }}
|
||||
</div>
|
||||
</div>
|
||||
<bangumi-info-tags :tags="infoTags" />
|
||||
|
||||
<!-- RSS Link -->
|
||||
<div v-if="localRule.rss_link?.[0]" class="rss-section">
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ $t('search.confirm.rss') }}:</span>
|
||||
<span class="info-value info-value--link">
|
||||
{{ localRule.rss_link?.[0] || '-' }}
|
||||
</span>
|
||||
<button class="copy-btn" :class="{ copied }" @click="copyRssLink">
|
||||
<CheckOne v-if="copied" theme="outline" size="14" />
|
||||
<Copy v-else theme="outline" size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<bangumi-rss-link-row
|
||||
:link="rssLink"
|
||||
:copied="copied"
|
||||
@copy="copyRssLink(rssLink)"
|
||||
/>
|
||||
|
||||
<!-- Advanced settings -->
|
||||
<div class="advanced-section">
|
||||
<button class="advanced-toggle" @click="showAdvanced = !showAdvanced">
|
||||
<component :is="showAdvanced ? Down : Right" theme="outline" size="14" />
|
||||
{{ $t('search.confirm.advanced') }}
|
||||
</button>
|
||||
<advanced-section v-model:open="showAdvanced">
|
||||
<bangumi-filter-field v-model="localRule.filter" />
|
||||
|
||||
<Transition name="expand">
|
||||
<div v-show="showAdvanced" class="advanced-content">
|
||||
<!-- Filter rules row -->
|
||||
<div class="advanced-row advanced-row--tags">
|
||||
<label class="advanced-label">{{ $t('homepage.rule.filter') }}</label>
|
||||
<div class="advanced-control filter-tags">
|
||||
<NDynamicTags v-model:value="localRule.filter" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<bangumi-offset-field
|
||||
v-model="localRule.season_offset"
|
||||
:label="$t('homepage.rule.season_offset')"
|
||||
/>
|
||||
|
||||
<!-- Season Offset row -->
|
||||
<div class="advanced-row">
|
||||
<label class="advanced-label">{{ $t('homepage.rule.season_offset') }}</label>
|
||||
<div class="advanced-control offset-controls">
|
||||
<input
|
||||
v-model.number="localRule.season_offset"
|
||||
type="number"
|
||||
ab-input
|
||||
class="offset-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Episode Offset row -->
|
||||
<div class="advanced-row">
|
||||
<label class="advanced-label">{{ $t('homepage.rule.episode_offset') }}</label>
|
||||
<div class="advanced-control offset-controls">
|
||||
<input
|
||||
v-model.number="localRule.episode_offset"
|
||||
type="number"
|
||||
ab-input
|
||||
class="offset-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<bangumi-offset-field
|
||||
v-model="localRule.episode_offset"
|
||||
:label="$t('homepage.rule.episode_offset')"
|
||||
/>
|
||||
</advanced-section>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="edit-footer">
|
||||
<div class="footer-left">
|
||||
<ab-button
|
||||
<NButton
|
||||
v-if="localRule.archived"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="emitUnarchive"
|
||||
>
|
||||
{{ $t('homepage.rule.unarchive') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
v-else
|
||||
size="small"
|
||||
@click="emitArchive"
|
||||
>
|
||||
</NButton>
|
||||
<NButton v-else type="primary" size="small" @click="emitArchive">
|
||||
{{ $t('homepage.rule.archive') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
size="small"
|
||||
type="warn"
|
||||
@click="showDeleteFileDialog"
|
||||
>
|
||||
</NButton>
|
||||
<NButton size="small" type="error" @click="showDeleteFileDialog">
|
||||
{{ $t('homepage.rule.delete') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
<ab-button size="small" @click="emitApply">
|
||||
<NButton type="primary" size="small" @click="emitApply">
|
||||
{{ $t('homepage.rule.apply') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Delete confirmation dialog -->
|
||||
<Transition name="modal">
|
||||
<div v-if="deleteFileDialog.show" class="delete-dialog-backdrop" @click.self="deleteFileDialog.show = false">
|
||||
<div
|
||||
v-if="deleteFileDialog.show"
|
||||
class="delete-dialog-backdrop"
|
||||
@click.self="deleteFileDialog.show = false"
|
||||
>
|
||||
<div class="delete-dialog">
|
||||
<h3 class="delete-title">{{ $t('homepage.rule.delete') }}</h3>
|
||||
<p class="delete-message">{{ $t('homepage.rule.delete_hit') }}</p>
|
||||
<div class="delete-actions">
|
||||
<ab-button size="small" type="secondary" @click="emitDeleteFile(false)">
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
secondary
|
||||
@click="emitDeleteFile(false)"
|
||||
>
|
||||
{{ $t('homepage.rule.no_btn') }}
|
||||
</ab-button>
|
||||
<ab-button size="small" type="warn" @click="emitDeleteFile(true)">
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
type="error"
|
||||
@click="emitDeleteFile(true)"
|
||||
>
|
||||
{{ $t('homepage.rule.yes_btn') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -578,399 +462,6 @@ function emitUnarchive() {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
// Bangumi info section
|
||||
.bangumi-info {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.bangumi-poster {
|
||||
width: 80px;
|
||||
height: 112px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--color-surface-hover);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.poster-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.bangumi-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
padding: 4px 0;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-bottom-color: var(--color-border);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.bangumi-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.year-input {
|
||||
width: 60px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
padding: 2px 0;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast), background-color var(--transition-fast);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-bottom-color: var(--color-border);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
&--empty {
|
||||
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
||||
border-bottom-color: var(--color-warning);
|
||||
border-radius: var(--radius-xs) var(--radius-xs) 0 0;
|
||||
padding: 2px 4px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.meta-separator {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.season-label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.season-input {
|
||||
width: 40px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
padding: 2px 0;
|
||||
outline: none;
|
||||
text-align: center;
|
||||
transition: border-color var(--transition-fast);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-bottom-color: var(--color-border);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
// Hide spinner buttons
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
// Info Tags
|
||||
.info-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
&--season {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
&--resolution {
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
&--subtitle {
|
||||
background: color-mix(in srgb, var(--color-success) 12%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
&--group {
|
||||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
// RSS section
|
||||
.rss-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--color-surface-hover);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
flex-shrink: 0;
|
||||
width: 70px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
word-break: break-all;
|
||||
|
||||
&--link {
|
||||
color: var(--color-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
&.copied {
|
||||
background: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// Advanced section
|
||||
.advanced-section {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.advanced-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--color-surface-hover);
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.advanced-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 32px;
|
||||
|
||||
&--tags {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.advanced-control {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
:deep(.n-dynamic-tags) {
|
||||
justify-content: flex-end;
|
||||
min-height: 32px;
|
||||
|
||||
.n-tag {
|
||||
height: 28px;
|
||||
margin: 2px 0 2px 6px !important;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.offset-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.offset-input {
|
||||
width: 70px;
|
||||
height: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detect-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 80px;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
}
|
||||
|
||||
// Expand transition
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: all var(--transition-normal);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
// Footer
|
||||
.edit-footer {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from 'naive-ui';
|
||||
import type { BangumiRule } from '#/bangumi';
|
||||
import type { SettingItem } from '#/components';
|
||||
|
||||
@@ -79,13 +80,14 @@ const items: SettingItem<BangumiRule>[] = [
|
||||
ab-input
|
||||
class="offset-input"
|
||||
/>
|
||||
<ab-button
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="offsetLoading"
|
||||
@click="autoDetectOffset"
|
||||
>
|
||||
{{ $t('homepage.rule.auto_detect') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</ab-label>
|
||||
|
||||
|
||||
72
webui/src/components/advanced-section.vue
Normal file
72
webui/src/components/advanced-section.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script lang="ts" setup>
|
||||
import { Down, Right } from '@icon-park/vue-next';
|
||||
|
||||
/** v-model open state */
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="advanced-section">
|
||||
<button class="advanced-toggle" @click="open = !open">
|
||||
<component :is="open ? Down : Right" theme="outline" size="14" />
|
||||
{{ $t('search.confirm.advanced') }}
|
||||
</button>
|
||||
|
||||
<Transition name="expand">
|
||||
<div v-show="open" class="advanced-content">
|
||||
<slot />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.advanced-section {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.advanced-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--color-surface-hover);
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
// Expand transition
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: all var(--transition-normal);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
65
webui/src/components/bangumi-filter-field.vue
Normal file
65
webui/src/components/bangumi-filter-field.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts" setup>
|
||||
import { NDynamicTags } from 'naive-ui';
|
||||
|
||||
defineProps<{
|
||||
modelValue: string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string[]): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="advanced-row advanced-row--tags">
|
||||
<label class="advanced-label">{{ $t('homepage.rule.filter') }}</label>
|
||||
<div class="advanced-control filter-tags">
|
||||
<NDynamicTags
|
||||
:value="modelValue"
|
||||
size="small"
|
||||
@update:value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.advanced-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 32px;
|
||||
|
||||
&--tags {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.advanced-control {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
:deep(.n-dynamic-tags) {
|
||||
justify-content: flex-end;
|
||||
min-height: 32px;
|
||||
|
||||
.n-tag {
|
||||
height: 28px;
|
||||
margin: 2px 0 2px 6px !important;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
58
webui/src/components/bangumi-info-tags.vue
Normal file
58
webui/src/components/bangumi-info-tags.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts" setup>
|
||||
import type { InfoTag } from '@/hooks/useBangumiRuleForm';
|
||||
|
||||
defineProps<{
|
||||
tags: InfoTag[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="tags.length > 0" class="info-tags">
|
||||
<div
|
||||
v-for="tag in tags"
|
||||
:key="tag.type"
|
||||
class="info-tag"
|
||||
:class="`info-tag--${tag.type}`"
|
||||
>
|
||||
{{ tag.value }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.info-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
&--season {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
&--resolution {
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
&--subtitle {
|
||||
background: color-mix(in srgb, var(--color-success) 12%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
&--group {
|
||||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
73
webui/src/components/bangumi-offset-field.vue
Normal file
73
webui/src/components/bangumi-offset-field.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 单个数字 offset 行:标签 + 数字输入 + 可选的操作插槽(如“自动检测”按钮)。
|
||||
* ab-add-rss 用它承载 episode_offset + 检测按钮,ab-edit-rule 用它承载
|
||||
* season_offset / episode_offset 两个纯输入行。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
label: string;
|
||||
modelValue: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: number): void;
|
||||
}>();
|
||||
|
||||
// v-model.number 代理:保持与原生 input[type=number] + .number 修饰符完全一致的解析行为
|
||||
const proxyValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: number) => emit('update:modelValue', value),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="advanced-row">
|
||||
<label class="advanced-label">{{ label }}</label>
|
||||
<div class="advanced-control offset-controls">
|
||||
<input
|
||||
v-model.number="proxyValue"
|
||||
type="number"
|
||||
ab-input
|
||||
class="offset-input"
|
||||
/>
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.advanced-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.advanced-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.advanced-control {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.offset-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.offset-input {
|
||||
width: 70px;
|
||||
height: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
217
webui/src/components/bangumi-preview.vue
Normal file
217
webui/src/components/bangumi-preview.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<script lang="ts" setup>
|
||||
import { ErrorPicture } from '@icon-park/vue-next';
|
||||
import type { BangumiRule } from '#/bangumi';
|
||||
|
||||
/**
|
||||
* ab-add-rss / ab-edit-rule 共用的番剧预览区块:海报 + 可编辑的标题/年份/季度。
|
||||
* 通过 v-model:rule 双向绑定,避免直接修改 props。
|
||||
*/
|
||||
defineProps<{
|
||||
posterSrc: string;
|
||||
}>();
|
||||
|
||||
const rule = defineModel<BangumiRule>('rule', { required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bangumi-info">
|
||||
<div class="bangumi-poster">
|
||||
<template v-if="rule.poster_link">
|
||||
<img :src="posterSrc" :alt="rule.official_title" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="poster-placeholder">
|
||||
<ErrorPicture theme="outline" size="32" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="bangumi-meta">
|
||||
<input
|
||||
v-model="rule.official_title"
|
||||
type="text"
|
||||
class="title-input"
|
||||
:placeholder="$t('homepage.rule.official_title')"
|
||||
/>
|
||||
<p v-if="rule.title_raw" class="bangumi-subtitle">{{ rule.title_raw }}</p>
|
||||
<div class="meta-row">
|
||||
<input
|
||||
:value="rule.year ?? ''"
|
||||
type="text"
|
||||
class="year-input"
|
||||
:class="{ 'year-input--empty': !rule.year }"
|
||||
:placeholder="$t('homepage.rule.year')"
|
||||
@input="(e) => (rule.year = (e.target as HTMLInputElement).value || null)"
|
||||
/>
|
||||
<span class="meta-separator">·</span>
|
||||
<label class="season-label">S</label>
|
||||
<input
|
||||
v-model.number="rule.season"
|
||||
type="number"
|
||||
class="season-input"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bangumi-info {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.bangumi-poster {
|
||||
width: 80px;
|
||||
height: 112px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--color-surface-hover);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.poster-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.bangumi-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
padding: 4px 0;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-bottom-color: var(--color-border);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.bangumi-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.year-input {
|
||||
width: 60px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
padding: 2px 0;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast),
|
||||
background-color var(--transition-fast);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-bottom-color: var(--color-border);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
&--empty {
|
||||
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
||||
border-bottom-color: var(--color-warning);
|
||||
border-radius: var(--radius-xs) var(--radius-xs) 0 0;
|
||||
padding: 2px 4px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.meta-separator {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.season-label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.season-input {
|
||||
width: 40px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
padding: 2px 0;
|
||||
outline: none;
|
||||
text-align: center;
|
||||
transition: border-color var(--transition-fast);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-bottom-color: var(--color-border);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
96
webui/src/components/bangumi-rss-link-row.vue
Normal file
96
webui/src/components/bangumi-rss-link-row.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script lang="ts" setup>
|
||||
import { CheckOne, Copy } from '@icon-park/vue-next';
|
||||
|
||||
const props = defineProps<{
|
||||
link: string;
|
||||
copied: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'copy'): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="props.link" class="rss-section">
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ $t('search.confirm.rss') }}:</span>
|
||||
<span class="info-value info-value--link">{{ props.link }}</span>
|
||||
<button
|
||||
class="copy-btn"
|
||||
:class="{ copied: props.copied }"
|
||||
@click="emit('copy')"
|
||||
>
|
||||
<CheckOne v-if="props.copied" theme="outline" size="14" />
|
||||
<Copy v-else theme="outline" size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rss-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--color-surface-hover);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
flex-shrink: 0;
|
||||
width: 70px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
word-break: break-all;
|
||||
|
||||
&--link {
|
||||
color: var(--color-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
&.copied {
|
||||
background: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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: '<span>Custom Content</span>',
|
||||
},
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
|
||||
import AbButton from './ab-button.vue';
|
||||
|
||||
const meta: Meta<typeof AbButton> = {
|
||||
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<typeof AbButton>;
|
||||
|
||||
export const Template: Story = {
|
||||
render: (args) => ({
|
||||
components: { AbButton },
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
template: '<ab-button v-bind="args">button</ab-button>',
|
||||
}),
|
||||
};
|
||||
@@ -1,163 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { NSpin } from 'naive-ui';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
type?: 'primary' | 'secondary' | 'warn';
|
||||
size?: 'big' | 'normal' | 'small';
|
||||
link?: string | null;
|
||||
loading?: boolean;
|
||||
/** Native `type` attribute when rendered as a <button>. Defaults to
|
||||
* "button" so ab-button never triggers an implicit form submit. */
|
||||
nativeType?: 'button' | 'submit' | 'reset';
|
||||
}>(),
|
||||
{
|
||||
type: 'primary',
|
||||
size: 'normal',
|
||||
link: null,
|
||||
loading: false,
|
||||
nativeType: 'button',
|
||||
}
|
||||
);
|
||||
|
||||
defineEmits<{ click: [] }>();
|
||||
|
||||
const buttonSize = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'big':
|
||||
return 'btn--big';
|
||||
case 'normal':
|
||||
return 'btn--normal';
|
||||
case 'small':
|
||||
return 'btn--small';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Component
|
||||
:is="link !== null ? 'a' : 'button'"
|
||||
:href="link"
|
||||
:type="link !== null ? undefined : nativeType"
|
||||
class="btn"
|
||||
:class="[`btn--${type}`, buttonSize]"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<NSpin :show="loading" :size="size === 'big' ? 'large' : 'small'">
|
||||
<span class="btn-content">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</NSpin>
|
||||
</Component>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
color: var(--color-white);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
text-decoration: none;
|
||||
transition: background-color var(--transition-fast),
|
||||
transform var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
|
||||
// Focus ring for keyboard navigation
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
// Sizes
|
||||
&--big {
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
max-width: 276px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
&--normal {
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
max-width: 170px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
&--small {
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
min-width: 80px;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Types
|
||||
&--primary {
|
||||
background: var(--color-primary);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
box-shadow: 0 2px 8px color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline-color: var(--color-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: var(--color-danger);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
filter: brightness(0.9);
|
||||
box-shadow: 0 2px 8px color-mix(in srgb, var(--color-danger) 30%, transparent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline-color: var(--color-danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
|
||||
import AbCheckbox from './ab-checkbox.vue';
|
||||
|
||||
const meta: Meta<typeof AbCheckbox> = {
|
||||
title: 'basic/ab-checkbox',
|
||||
component: AbCheckbox,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbCheckbox>;
|
||||
|
||||
export const Template: Story = {
|
||||
render: (args) => ({
|
||||
components: { AbCheckbox },
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
template: '<ab-checkbox v-bind="args" />',
|
||||
}),
|
||||
};
|
||||
@@ -1,105 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { Switch } from '@headlessui/vue';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
small?: boolean;
|
||||
}>(),
|
||||
{
|
||||
small: false,
|
||||
}
|
||||
);
|
||||
|
||||
const checked = defineModel<boolean>({ default: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Switch v-model="checked" as="template">
|
||||
<div class="checkbox-wrapper">
|
||||
<slot name="before"></slot>
|
||||
|
||||
<div
|
||||
class="checkbox"
|
||||
:class="[
|
||||
small ? 'checkbox--small' : 'checkbox--normal',
|
||||
checked && 'checkbox--checked',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="checkbox-inner"
|
||||
:class="[
|
||||
small ? 'checkbox-inner--small' : 'checkbox-inner--normal',
|
||||
checked && 'checkbox-inner--checked',
|
||||
]"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<slot name="after"></slot>
|
||||
</div>
|
||||
</Switch>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.checkbox-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-primary);
|
||||
transition: border-color var(--transition-fast),
|
||||
background-color var(--transition-fast);
|
||||
|
||||
&--normal {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
&--small {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
&--checked {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:hover:not(.checkbox--checked) {
|
||||
.checkbox-inner {
|
||||
background: var(--color-border-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-inner {
|
||||
border-radius: 2px;
|
||||
transition: background-color var(--transition-fast);
|
||||
background: transparent;
|
||||
|
||||
&--normal {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
&--small {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
&--checked {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from 'naive-ui';
|
||||
import { Caution, Close } from '@icon-park/vue-next';
|
||||
import type { OffsetSuggestionDetail, TMDBSummary } from '#/bangumi';
|
||||
|
||||
@@ -48,7 +49,9 @@ const preview = computed(() => {
|
||||
const formatNum = (n: number) => (n < 10 ? `0${n}` : `${n}`);
|
||||
return {
|
||||
from: `S${formatNum(props.parsedSeason)}E${formatNum(props.parsedEpisode)}`,
|
||||
to: `S${formatNum(Math.max(1, newSeason))}E${formatNum(Math.max(1, newEpisode))}`,
|
||||
to: `S${formatNum(Math.max(1, newSeason))}E${formatNum(
|
||||
Math.max(1, newEpisode)
|
||||
)}`,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -108,7 +111,9 @@ function handleCancel() {
|
||||
<div class="comparison">
|
||||
<!-- RSS parsed result -->
|
||||
<div class="comparison-box">
|
||||
<div class="comparison-label">{{ t('offset.parsed_result') }}</div>
|
||||
<div class="comparison-label">
|
||||
{{ t('offset.parsed_result') }}
|
||||
</div>
|
||||
<div class="comparison-value">
|
||||
<span class="value-label">{{ t('offset.season') }}:</span>
|
||||
<span class="value-num">{{ parsedSeason }}</span>
|
||||
@@ -125,19 +130,39 @@ function handleCancel() {
|
||||
<div class="comparison-box">
|
||||
<div class="comparison-label">{{ t('offset.tmdb_data') }}</div>
|
||||
<div v-if="tmdbInfo" class="comparison-value">
|
||||
<span class="value-label">{{ t('offset.total_seasons') }}:</span>
|
||||
<span class="value-label"
|
||||
>{{ t('offset.total_seasons') }}:</span
|
||||
>
|
||||
<span class="value-num">{{ tmdbInfo.total_seasons }}</span>
|
||||
</div>
|
||||
<div v-if="tmdbInfo && tmdbInfo.season_episode_counts[parsedSeason + (suggestion?.season_offset ?? 0)]" class="comparison-value">
|
||||
<span class="value-label">S{{ parsedSeason + (suggestion?.season_offset ?? 0) }} {{ t('offset.episode') }}:</span>
|
||||
<span class="value-num">{{ tmdbInfo.season_episode_counts[parsedSeason + (suggestion?.season_offset ?? 0)] }}</span>
|
||||
<div
|
||||
v-if="
|
||||
tmdbInfo &&
|
||||
tmdbInfo.season_episode_counts[
|
||||
parsedSeason + (suggestion?.season_offset ?? 0)
|
||||
]
|
||||
"
|
||||
class="comparison-value"
|
||||
>
|
||||
<span class="value-label"
|
||||
>S{{ parsedSeason + (suggestion?.season_offset ?? 0) }}
|
||||
{{ t('offset.episode') }}:</span
|
||||
>
|
||||
<span class="value-num">{{
|
||||
tmdbInfo.season_episode_counts[
|
||||
parsedSeason + (suggestion?.season_offset ?? 0)
|
||||
]
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reason -->
|
||||
<div v-if="suggestion?.reason" class="reason-section">
|
||||
<span class="reason-badge" :style="{ backgroundColor: confidenceColor }">
|
||||
<span
|
||||
class="reason-badge"
|
||||
:style="{ backgroundColor: confidenceColor }"
|
||||
>
|
||||
{{ suggestion.confidence }}
|
||||
</span>
|
||||
<span class="reason-text">{{ suggestion.reason }}</span>
|
||||
@@ -147,22 +172,36 @@ function handleCancel() {
|
||||
<div class="offset-section">
|
||||
<div class="offset-title">{{ t('offset.suggested_offset') }}</div>
|
||||
<div class="offset-row">
|
||||
<label class="offset-label">{{ t('offset.season_offset') }}:</label>
|
||||
<label class="offset-label"
|
||||
>{{ t('offset.season_offset') }}:</label
|
||||
>
|
||||
<input
|
||||
v-model.number="seasonOffset"
|
||||
type="number"
|
||||
class="offset-input"
|
||||
/>
|
||||
<span class="offset-hint">→ S{{ parsedSeason }} {{ t('offset.season') === '季度' ? '变为' : 'becomes' }} S{{ Math.max(1, parsedSeason + seasonOffset) }}</span>
|
||||
<span class="offset-hint"
|
||||
>→ S{{ parsedSeason }}
|
||||
{{ t('offset.season') === '季度' ? '变为' : 'becomes' }} S{{
|
||||
Math.max(1, parsedSeason + seasonOffset)
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
<div class="offset-row">
|
||||
<label class="offset-label">{{ t('offset.episode_offset') }}:</label>
|
||||
<label class="offset-label"
|
||||
>{{ t('offset.episode_offset') }}:</label
|
||||
>
|
||||
<input
|
||||
v-model.number="episodeOffset"
|
||||
type="number"
|
||||
class="offset-input"
|
||||
/>
|
||||
<span class="offset-hint">→ E{{ parsedEpisode }} {{ t('offset.season') === '季度' ? '保持' : 'stays' }} E{{ Math.max(1, parsedEpisode + episodeOffset) }}</span>
|
||||
<span class="offset-hint"
|
||||
>→ E{{ parsedEpisode }}
|
||||
{{ t('offset.season') === '季度' ? '保持' : 'stays' }} E{{
|
||||
Math.max(1, parsedEpisode + episodeOffset)
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -177,15 +216,20 @@ function handleCancel() {
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="dialog-footer">
|
||||
<ab-button size="small" type="secondary" @click="handleCancel">
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
secondary
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ t('offset.cancel') }}
|
||||
</ab-button>
|
||||
<ab-button size="small" @click="handleKeep">
|
||||
</NButton>
|
||||
<NButton type="primary" size="small" @click="handleKeep">
|
||||
{{ t('offset.keep') }}
|
||||
</ab-button>
|
||||
<ab-button size="small" type="primary" @click="handleApply">
|
||||
</NButton>
|
||||
<NButton size="small" type="primary" @click="handleApply">
|
||||
{{ t('offset.apply') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
|
||||
import AbSelect from './ab-select.vue';
|
||||
|
||||
const meta: Meta<typeof AbSelect> = {
|
||||
title: 'basic/ab-select',
|
||||
component: AbSelect,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbSelect>;
|
||||
|
||||
export const Template: Story = {
|
||||
render: (args) => ({
|
||||
components: { AbSelect },
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
template: '<ab-select v-bind="args" />',
|
||||
}),
|
||||
};
|
||||
@@ -1,187 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from '@headlessui/vue';
|
||||
import { Down, Up } from '@icon-park/vue-next';
|
||||
import { isObject, isString } from 'radash';
|
||||
import type { SelectItem } from '#/components';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: SelectItem | string;
|
||||
items: Array<SelectItem | string>;
|
||||
}>(),
|
||||
{}
|
||||
);
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
// Find an object item whose `.value` matches a plain string modelValue.
|
||||
// Lets callers bind a string v-model against object items ({id,label,value})
|
||||
// without having to round-trip the whole object.
|
||||
function findByValue(items: Array<SelectItem | string>, value: string) {
|
||||
return items.find((item) => isObject(item) && item.value === value);
|
||||
}
|
||||
|
||||
// Writable computed (instead of a ref seeded once from props.modelValue) so
|
||||
// external updates to modelValue (e.g. after async getConfig()) stay in sync.
|
||||
const selected = computed<SelectItem | string>({
|
||||
get() {
|
||||
if (isString(props.modelValue)) {
|
||||
return findByValue(props.items, props.modelValue) ?? props.modelValue;
|
||||
}
|
||||
return props.modelValue ?? props.items?.[0] ?? '';
|
||||
},
|
||||
set(item) {
|
||||
// Object item selected while the caller's v-model is a plain string:
|
||||
// emit item.value so the binding stays string <-> string.
|
||||
if (isObject(item) && isString(props.modelValue)) {
|
||||
emit('update:modelValue', item.value);
|
||||
} else {
|
||||
emit('update:modelValue', item);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const otherItems = computed(() => {
|
||||
return (
|
||||
props.items.filter((e) => {
|
||||
if (isString(e) && isString(selected.value)) {
|
||||
return e !== selected.value;
|
||||
} else if (isObject(e) && isObject(selected.value)) {
|
||||
return e.id !== selected.value.id;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}) ?? []
|
||||
);
|
||||
});
|
||||
|
||||
const label = computed(() => {
|
||||
if (isString(selected.value)) {
|
||||
return selected.value;
|
||||
} else {
|
||||
return selected.value.label ?? selected.value.value;
|
||||
}
|
||||
});
|
||||
|
||||
function getLabel(item: SelectItem | string) {
|
||||
if (isString(item)) {
|
||||
return item;
|
||||
} else {
|
||||
return item.label ?? item.value;
|
||||
}
|
||||
}
|
||||
|
||||
function getDisabled(item: SelectItem | string) {
|
||||
return isString(item) ? false : item.disabled;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Listbox v-slot="{ open }" v-model="selected">
|
||||
<div class="select-wrapper">
|
||||
<ListboxButton class="select-button">
|
||||
<div class="select-value">{{ label }}</div>
|
||||
<div :class="[{ hidden: open }]">
|
||||
<Down :size="14" />
|
||||
</div>
|
||||
</ListboxButton>
|
||||
|
||||
<ListboxOptions class="select-options">
|
||||
<div class="select-options-inner">
|
||||
<div class="select-options-list">
|
||||
<ListboxOption
|
||||
v-for="item in otherItems"
|
||||
v-slot="{ active }"
|
||||
:key="isString(item) ? item : item.id"
|
||||
:value="item"
|
||||
:disabled="getDisabled(item)"
|
||||
>
|
||||
<div
|
||||
class="select-option"
|
||||
:class="[
|
||||
active && 'select-option--active',
|
||||
getDisabled(item) && 'select-option--disabled',
|
||||
]"
|
||||
>
|
||||
{{ getLabel(item) }}
|
||||
</div>
|
||||
</ListboxOption>
|
||||
</div>
|
||||
|
||||
<div :class="[{ hidden: !open }]"><Up :size="14" /></div>
|
||||
</div>
|
||||
</ListboxOptions>
|
||||
</div>
|
||||
</Listbox>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.select-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
transition: border-color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.select-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.select-value {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.select-options {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.select-options-inner {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.select-options-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.select-option {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--color-text-secondary);
|
||||
transition: color var(--transition-fast);
|
||||
|
||||
&--active {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3';
|
||||
|
||||
import AbSwitch from './ab-switch.vue';
|
||||
|
||||
const meta: Meta<typeof AbSwitch> = {
|
||||
title: 'basic/ab-switch',
|
||||
component: AbSwitch,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AbSwitch>;
|
||||
|
||||
export const Template: Story = {
|
||||
render: (args) => ({
|
||||
components: { AbSwitch },
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
template: '<ab-switch v-bind="args" />',
|
||||
}),
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { Switch } from '@headlessui/vue';
|
||||
|
||||
const checked = defineModel<boolean>('checked', {
|
||||
default: false,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Switch v-model="checked" as="template">
|
||||
<div class="switch-track" :class="{ 'switch-track--checked': checked }">
|
||||
<div
|
||||
class="switch-thumb"
|
||||
:class="{ 'switch-thumb--checked': checked }"
|
||||
></div>
|
||||
</div>
|
||||
</Switch>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.switch-track {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-full);
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: var(--color-border-hover);
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&--checked {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.switch-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||
transition: transform var(--transition-fast);
|
||||
|
||||
&--checked {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
173
webui/src/components/calendar/calendar-board.vue
Normal file
173
webui/src/components/calendar/calendar-board.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<script lang="ts" setup>
|
||||
import draggable from 'vuedraggable';
|
||||
import type { BangumiGroup } from './types';
|
||||
|
||||
const props = defineProps<{
|
||||
dayKeys: readonly string[];
|
||||
groupedByDay: Record<string, BangumiGroup[]>;
|
||||
todayIndex: number;
|
||||
getDayLabel: (key: string) => string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'card-click', group: BangumiGroup): void;
|
||||
}>();
|
||||
|
||||
const { setWeekday } = useBangumiStore();
|
||||
|
||||
const isDragging = ref(false);
|
||||
|
||||
function isToday(index: number): boolean {
|
||||
return index === props.todayIndex;
|
||||
}
|
||||
|
||||
async function onDropToDay(dayIndex: number, evt: any) {
|
||||
if (evt.added) {
|
||||
const group: BangumiGroup = evt.added.element;
|
||||
for (const rule of group.rules) {
|
||||
await setWeekday(rule.id, dayIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onUnpin(group: BangumiGroup, event: Event) {
|
||||
event.stopPropagation();
|
||||
for (const rule of group.rules) {
|
||||
await setWeekday(rule.id, null);
|
||||
}
|
||||
}
|
||||
|
||||
const unknownGroups = computed({
|
||||
get: () => props.groupedByDay.unknown || [],
|
||||
set: () => {
|
||||
// No-op: actual update happens via API in onDropToDay
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calendar-desktop">
|
||||
<div class="calendar-grid">
|
||||
<calendar-day-column
|
||||
v-for="(key, index) in dayKeys"
|
||||
:key="key"
|
||||
:day-label="getDayLabel(key)"
|
||||
:is-today="isToday(index)"
|
||||
:is-dragging="isDragging"
|
||||
:groups="groupedByDay[key] || []"
|
||||
:delay="`${index * 0.05}s`"
|
||||
@change="onDropToDay(index, $event)"
|
||||
@card-click="emit('card-click', $event)"
|
||||
@unpin="(group, event) => onUnpin(group, event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Unknown air day section (draggable source) -->
|
||||
<div
|
||||
v-if="unknownGroups.length > 0"
|
||||
class="calendar-unknown-section anim-slide-up"
|
||||
style="--delay: 0.4s"
|
||||
>
|
||||
<div class="calendar-unknown-header">
|
||||
<span class="calendar-day-label">{{ getDayLabel('unknown') }}</span>
|
||||
<span class="calendar-drag-hint">{{ $t('calendar.drag_hint') }}</span>
|
||||
</div>
|
||||
<draggable
|
||||
v-model="unknownGroups"
|
||||
:group="{ name: 'calendar', pull: 'clone', put: false }"
|
||||
item-key="key"
|
||||
:sort="false"
|
||||
ghost-class="sortable-ghost"
|
||||
drag-class="sortable-drag"
|
||||
class="calendar-unknown-items"
|
||||
@start="isDragging = true"
|
||||
@end="isDragging = false"
|
||||
>
|
||||
<template #item="{ element: group }">
|
||||
<calendar-card :group="group" @click="emit('card-click', group)" />
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.calendar-desktop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.calendar-unknown-section {
|
||||
background: var(--color-surface-hover);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.calendar-unknown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.calendar-unknown-items {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.calendar-day-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.calendar-drag-hint {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sortable-drag {
|
||||
opacity: 0.9;
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: rotate(2deg);
|
||||
}
|
||||
|
||||
.anim-slide-up {
|
||||
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.anim-slide-up {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
244
webui/src/components/calendar/calendar-card.vue
Normal file
244
webui/src/components/calendar/calendar-card.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<script lang="ts" setup>
|
||||
import { ErrorPicture, Pin } from '@icon-park/vue-next';
|
||||
import type { BangumiGroup } from './types';
|
||||
|
||||
const props = defineProps<{
|
||||
group: BangumiGroup;
|
||||
/** 是否展示已手动锁定的“取消置顶”按钮(未知播出日分区的卡片不展示) */
|
||||
showUnpin?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void;
|
||||
(e: 'unpin', event: Event): void;
|
||||
}>();
|
||||
|
||||
const posterSrc = computed(() =>
|
||||
resolvePosterUrl(props.group.primary.poster_link)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calendar-card-wrapper">
|
||||
<div
|
||||
class="calendar-card"
|
||||
:class="{
|
||||
'calendar-card--pinned': showUnpin && group.primary.weekday_locked,
|
||||
}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="`Edit ${group.primary.official_title}`"
|
||||
@click="emit('click')"
|
||||
@keydown.enter="emit('click')"
|
||||
>
|
||||
<div class="calendar-card-poster">
|
||||
<img
|
||||
v-if="group.primary.poster_link"
|
||||
:src="posterSrc"
|
||||
:alt="group.primary.official_title"
|
||||
class="calendar-card-img"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-else class="calendar-card-placeholder">
|
||||
<ErrorPicture theme="outline" size="20" />
|
||||
</div>
|
||||
<div class="calendar-card-overlay">
|
||||
<div class="calendar-card-overlay-tags">
|
||||
<ab-tag :title="`S${group.primary.season}`" type="primary" />
|
||||
<ab-tag
|
||||
v-if="group.primary.group_name"
|
||||
:title="group.primary.group_name"
|
||||
type="primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="calendar-card-overlay-title">
|
||||
{{ group.primary.official_title }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Pin indicator for manually assigned -->
|
||||
<div
|
||||
v-if="showUnpin && group.primary.weekday_locked"
|
||||
class="calendar-card-pin"
|
||||
>
|
||||
<Pin theme="filled" size="12" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Unpin button -->
|
||||
<button
|
||||
v-if="showUnpin && group.primary.weekday_locked"
|
||||
class="calendar-unpin-btn"
|
||||
:title="$t('calendar.unpin')"
|
||||
@click="emit('unpin', $event)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div v-if="group.rules.length > 1" class="group-badge">
|
||||
{{ group.rules.length }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// Card wrapper for badge positioning
|
||||
.calendar-card-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.group-badge {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
border-radius: 10px;
|
||||
background: var(--color-badge-bg);
|
||||
color: var(--color-white);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-dropdown);
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2px 4px var(--color-badge-shadow);
|
||||
}
|
||||
|
||||
.calendar-card {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: var(--radius-md);
|
||||
transition: transform var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-card-poster {
|
||||
position: relative;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
aspect-ratio: 2 / 3;
|
||||
}
|
||||
|
||||
.calendar-card-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.calendar-card-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-text-muted);
|
||||
transition: background-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.calendar-card-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(2px);
|
||||
transition: opacity var(--transition-normal);
|
||||
|
||||
.calendar-card:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-card-overlay-title {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.calendar-card-overlay-tags {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
:deep(.tag) {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
}
|
||||
|
||||
// Pin indicator
|
||||
.calendar-card-pin {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
// Unpin button
|
||||
.calendar-unpin-btn {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: -6px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-danger, #e74c3c);
|
||||
color: #fff;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-dropdown);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
|
||||
.calendar-card-wrapper:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Pinned card subtle styling
|
||||
.calendar-card--pinned {
|
||||
box-shadow: 0 0 0 1.5px var(--color-primary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
</style>
|
||||
173
webui/src/components/calendar/calendar-day-column.vue
Normal file
173
webui/src/components/calendar/calendar-day-column.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<script lang="ts" setup>
|
||||
import draggable from 'vuedraggable';
|
||||
import type { BangumiGroup } from './types';
|
||||
|
||||
const props = defineProps<{
|
||||
dayLabel: string;
|
||||
isToday: boolean;
|
||||
isDragging: boolean;
|
||||
groups: BangumiGroup[];
|
||||
delay: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'change', evt: any): void;
|
||||
(e: 'card-click', group: BangumiGroup): void;
|
||||
(e: 'unpin', group: BangumiGroup, event: Event): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="calendar-column anim-slide-up"
|
||||
:class="{
|
||||
'calendar-column--today': isToday,
|
||||
'calendar-column--drop-active': isDragging,
|
||||
}"
|
||||
:style="{ '--delay': delay }"
|
||||
>
|
||||
<!-- Day header -->
|
||||
<div
|
||||
class="calendar-day-header"
|
||||
:class="{ 'calendar-day-header--today': isToday }"
|
||||
>
|
||||
<span class="calendar-day-label">{{ dayLabel }}</span>
|
||||
<span v-if="isToday" class="calendar-today-badge">{{
|
||||
$t('calendar.today')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Anime cards (grouped) - drop target -->
|
||||
<draggable
|
||||
:model-value="props.groups"
|
||||
group="calendar"
|
||||
item-key="key"
|
||||
:sort="false"
|
||||
ghost-class="sortable-ghost"
|
||||
drag-class="sortable-drag"
|
||||
class="calendar-column-items"
|
||||
@change="emit('change', $event)"
|
||||
>
|
||||
<template #item="{ element: group }">
|
||||
<calendar-card
|
||||
:group="group"
|
||||
show-unpin
|
||||
@click="emit('card-click', group)"
|
||||
@unpin="(event) => emit('unpin', group, event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div v-if="props.groups.length === 0" class="calendar-empty-day">
|
||||
{{ isDragging ? $t('calendar.drop_here') : $t('calendar.empty') }}
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.calendar-column {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
transition: background-color var(--transition-normal),
|
||||
border-color var(--transition-normal);
|
||||
|
||||
&--today {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 1px var(--color-primary-light);
|
||||
}
|
||||
|
||||
&--drop-active {
|
||||
border-color: var(--color-primary);
|
||||
border-style: dashed;
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-day-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&--today {
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-day-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
transition: color var(--transition-normal);
|
||||
|
||||
.calendar-day-header--today & {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-today-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.calendar-column-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// vuedraggable ghost and drag classes
|
||||
.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sortable-drag {
|
||||
opacity: 0.9;
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: rotate(2deg);
|
||||
}
|
||||
|
||||
.calendar-empty-day {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
padding: 12px 4px;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.anim-slide-up {
|
||||
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.anim-slide-up {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
281
webui/src/components/calendar/calendar-mobile-list.vue
Normal file
281
webui/src/components/calendar/calendar-mobile-list.vue
Normal file
@@ -0,0 +1,281 @@
|
||||
<script lang="ts" setup>
|
||||
import { ErrorPicture } from '@icon-park/vue-next';
|
||||
import type { BangumiGroup } from './types';
|
||||
|
||||
const props = defineProps<{
|
||||
dayKeys: readonly string[];
|
||||
groupedByDay: Record<string, BangumiGroup[]>;
|
||||
todayIndex: number;
|
||||
getDayLabel: (key: string) => string;
|
||||
hasBangumi: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'card-click', group: BangumiGroup): void;
|
||||
}>();
|
||||
|
||||
function isToday(index: number): boolean {
|
||||
return index === props.todayIndex;
|
||||
}
|
||||
|
||||
function posterSrc(link: string | null | undefined): string {
|
||||
return resolvePosterUrl(link);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calendar-list">
|
||||
<template v-for="(key, index) in dayKeys" :key="key">
|
||||
<div
|
||||
v-if="groupedByDay[key].length > 0"
|
||||
class="calendar-section anim-slide-up"
|
||||
:style="{ '--delay': `${index * 0.05}s` }"
|
||||
>
|
||||
<!-- Day divider -->
|
||||
<div
|
||||
class="calendar-section-header"
|
||||
:class="{
|
||||
'calendar-section-header--today':
|
||||
key !== 'unknown' && isToday(index),
|
||||
}"
|
||||
>
|
||||
<span class="calendar-section-label">{{ getDayLabel(key) }}</span>
|
||||
<span
|
||||
v-if="key !== 'unknown' && isToday(index)"
|
||||
class="calendar-today-badge calendar-today-badge--small"
|
||||
>
|
||||
{{ $t('calendar.today') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Anime rows (grouped) -->
|
||||
<div class="calendar-section-items">
|
||||
<div
|
||||
v-for="group in groupedByDay[key]"
|
||||
:key="group.key"
|
||||
class="calendar-row"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="`Edit ${group.primary.official_title}`"
|
||||
@click="emit('card-click', group)"
|
||||
@keydown.enter="emit('card-click', group)"
|
||||
>
|
||||
<div class="calendar-row-poster">
|
||||
<img
|
||||
v-if="group.primary.poster_link"
|
||||
:src="posterSrc(group.primary.poster_link)"
|
||||
:alt="group.primary.official_title"
|
||||
class="calendar-row-img"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-else class="calendar-row-placeholder">
|
||||
<ErrorPicture theme="outline" size="16" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="calendar-row-info">
|
||||
<div class="calendar-row-title">
|
||||
{{ group.primary.official_title }}
|
||||
<span v-if="group.rules.length > 1" class="calendar-row-badge">
|
||||
{{ group.rules.length }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="calendar-row-meta">
|
||||
<ab-tag :title="`S${group.primary.season}`" type="primary" />
|
||||
<ab-tag
|
||||
v-if="group.primary.group_name"
|
||||
:title="group.primary.group_name"
|
||||
type="primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- All days empty on mobile -->
|
||||
<div
|
||||
v-if="!hasBangumi"
|
||||
class="calendar-empty-day calendar-empty-day--mobile"
|
||||
>
|
||||
{{ $t('calendar.no_data') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.calendar-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.calendar-section {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.calendar-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0 6px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 6px;
|
||||
transition: border-color var(--transition-normal);
|
||||
|
||||
&--today {
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-section-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
letter-spacing: 0.3px;
|
||||
transition: color var(--transition-normal);
|
||||
|
||||
.calendar-section-header--today & {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-today-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
|
||||
&--small {
|
||||
font-size: 10px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-section-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.calendar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-row-poster {
|
||||
width: 44px;
|
||||
height: 62px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.calendar-row-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.calendar-row-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-text-muted);
|
||||
transition: background-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.calendar-row-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.calendar-row-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 4px;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.calendar-row-meta {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.calendar-row-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
margin-left: 6px;
|
||||
border-radius: 9px;
|
||||
background: var(--color-badge-bg);
|
||||
color: var(--color-white);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.calendar-empty-day {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
padding: 12px 4px;
|
||||
transition: color var(--transition-normal);
|
||||
|
||||
&--mobile {
|
||||
padding: 32px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.anim-slide-up {
|
||||
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.anim-slide-up {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
160
webui/src/components/calendar/calendar-rule-list-popup.vue
Normal file
160
webui/src/components/calendar/calendar-rule-list-popup.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<script lang="ts" setup>
|
||||
import type { BangumiGroup } from './types';
|
||||
import type { BangumiRule } from '#/bangumi';
|
||||
|
||||
defineProps<{
|
||||
group: BangumiGroup | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', rule: BangumiRule): void;
|
||||
}>();
|
||||
|
||||
const show = defineModel<boolean>('show', { default: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ab-popup v-model:show="show" :title="group?.primary.official_title || ''">
|
||||
<div v-if="group" class="rule-list">
|
||||
<div class="rule-list-hint">{{ $t('homepage.rule.select_hint') }}</div>
|
||||
<div
|
||||
v-for="rule in group.rules"
|
||||
:key="rule.id"
|
||||
class="rule-list-item"
|
||||
:class="[rule.deleted && 'rule-list-item--disabled']"
|
||||
@click="emit('select', rule)"
|
||||
>
|
||||
<div class="rule-list-item-info">
|
||||
<div class="rule-list-item-title">
|
||||
{{
|
||||
rule.group_name || rule.rule_name || $t('homepage.rule.unnamed')
|
||||
}}
|
||||
</div>
|
||||
<div class="rule-list-item-tags">
|
||||
<ab-tag v-if="rule.dpi" :title="rule.dpi" type="primary" />
|
||||
<ab-tag
|
||||
v-if="rule.subtitle"
|
||||
:title="rule.subtitle"
|
||||
type="primary"
|
||||
/>
|
||||
<ab-tag v-if="rule.source" :title="rule.source" type="primary" />
|
||||
</div>
|
||||
<div
|
||||
v-if="rule.filter && rule.filter.length > 0"
|
||||
class="rule-list-item-filter"
|
||||
>
|
||||
<span class="rule-list-item-filter-label"
|
||||
>{{ $t('homepage.rule.filter') }}:</span
|
||||
>
|
||||
<span class="rule-list-item-filter-value">{{
|
||||
rule.filter.join(', ')
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="rule.title_raw" class="rule-list-item-raw">
|
||||
{{ rule.title_raw }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rule-list-item-arrow">›</div>
|
||||
</div>
|
||||
</div>
|
||||
</ab-popup>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rule-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.rule-list-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
padding: 4px 12px 8px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rule-list-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: var(--touch-target);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.rule-list-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.rule-list-item-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rule-list-item-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rule-list-item-filter {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rule-list-item-filter-label {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.rule-list-item-filter-value {
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.rule-list-item-raw {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rule-list-item-arrow {
|
||||
font-size: 18px;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
8
webui/src/components/calendar/types.ts
Normal file
8
webui/src/components/calendar/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { BangumiRule } from '#/bangumi';
|
||||
|
||||
/** 按 official_title + season 分组后的番剧组,日历页面/看板/卡片共用 */
|
||||
export interface BangumiGroup {
|
||||
key: string;
|
||||
primary: BangumiRule;
|
||||
rules: BangumiRule[];
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from 'naive-ui';
|
||||
import type { NotificationProviderConfig, NotificationType } from '#/config';
|
||||
import type { TupleToUnion } from '#/utils';
|
||||
import { apiNotification } from '@/api/notification';
|
||||
@@ -9,7 +10,10 @@ const { getSettingGroup } = useConfigStore();
|
||||
const notificationRef = getSettingGroup('notification');
|
||||
|
||||
// Provider types with display names
|
||||
const providerTypes: { value: TupleToUnion<NotificationType>; label: string }[] = [
|
||||
const providerTypes: {
|
||||
value: TupleToUnion<NotificationType>;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ value: 'telegram', label: 'Telegram' },
|
||||
{ value: 'discord', label: 'Discord' },
|
||||
{ value: 'bark', label: 'Bark' },
|
||||
@@ -23,7 +27,11 @@ const providerTypes: { value: TupleToUnion<NotificationType>; label: string }[]
|
||||
// Provider field configurations
|
||||
const providerFields: Record<
|
||||
string,
|
||||
{ key: keyof NotificationProviderConfig; label: string; placeholder: string }[]
|
||||
{
|
||||
key: keyof NotificationProviderConfig;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
}[]
|
||||
> = {
|
||||
telegram: [
|
||||
{ key: 'token', label: 'Bot Token', placeholder: 'bot token' },
|
||||
@@ -176,7 +184,9 @@ async function testProvider(index: number) {
|
||||
testingIndex.value = index;
|
||||
testResult.value = null;
|
||||
try {
|
||||
const response = await apiNotification.testProvider({ provider_index: index });
|
||||
const response = await apiNotification.testProvider({
|
||||
provider_index: index,
|
||||
});
|
||||
testResult.value = {
|
||||
success: response.data.success,
|
||||
message: returnUserLangText({
|
||||
@@ -228,10 +238,10 @@ function getFieldsForType(type: string) {
|
||||
<div space-y-8>
|
||||
<!-- Global enable switch -->
|
||||
<ab-setting
|
||||
v-model:data="notificationEnabled"
|
||||
config-key="enable"
|
||||
:label="() => t('config.notification_set.enable')"
|
||||
type="switch"
|
||||
v-model:data="notificationEnabled"
|
||||
bottom-line
|
||||
/>
|
||||
|
||||
@@ -253,9 +263,10 @@ function getFieldsForType(type: string) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-actions">
|
||||
<ab-button
|
||||
<NButton
|
||||
size="small"
|
||||
type="secondary"
|
||||
type="primary"
|
||||
secondary
|
||||
:disabled="testingIndex === index"
|
||||
:title="$t('config.notification_set.test')"
|
||||
@click="testProvider(index)"
|
||||
@@ -266,18 +277,20 @@ function getFieldsForType(type: string) {
|
||||
animate-spin
|
||||
/>
|
||||
<div v-else i-carbon-play />
|
||||
</ab-button>
|
||||
<ab-button
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
type="secondary"
|
||||
type="primary"
|
||||
secondary
|
||||
:title="$t('config.notification_set.edit')"
|
||||
@click="openEditDialog(index)"
|
||||
>
|
||||
<div i-carbon-edit />
|
||||
</ab-button>
|
||||
<ab-button
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
type="secondary"
|
||||
type="primary"
|
||||
secondary
|
||||
:title="
|
||||
provider.enabled
|
||||
? $t('config.notification_set.disable')
|
||||
@@ -286,17 +299,19 @@ function getFieldsForType(type: string) {
|
||||
@click="toggleProvider(index)"
|
||||
>
|
||||
<div
|
||||
:class="provider.enabled ? 'i-carbon-view' : 'i-carbon-view-off'"
|
||||
:class="
|
||||
provider.enabled ? 'i-carbon-view' : 'i-carbon-view-off'
|
||||
"
|
||||
/>
|
||||
</ab-button>
|
||||
<ab-button
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
type="warn"
|
||||
type="error"
|
||||
:title="$t('config.notification_set.remove')"
|
||||
@click="removeProvider(index)"
|
||||
>
|
||||
<div i-carbon-trash-can />
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -313,10 +328,10 @@ function getFieldsForType(type: string) {
|
||||
|
||||
<!-- Add provider button -->
|
||||
<div flex="~ justify-end">
|
||||
<ab-button size="small" type="primary" @click="openAddDialog">
|
||||
<NButton size="small" type="primary" @click="openAddDialog">
|
||||
<div i-carbon-add />
|
||||
{{ $t('config.notification_set.add_provider') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,7 +345,11 @@ function getFieldsForType(type: string) {
|
||||
<div space-y-16>
|
||||
<ab-label :label="$t('config.notification_set.type')">
|
||||
<select v-model="newProvider.type" ab-input>
|
||||
<option v-for="pt in providerTypes" :key="pt.value" :value="pt.value">
|
||||
<option
|
||||
v-for="pt in providerTypes"
|
||||
:key="pt.value"
|
||||
:value="pt.value"
|
||||
>
|
||||
{{ pt.label }}
|
||||
</option>
|
||||
</select>
|
||||
@@ -368,9 +387,10 @@ function getFieldsForType(type: string) {
|
||||
<div line></div>
|
||||
|
||||
<div flex="~ justify-between items-center">
|
||||
<ab-button
|
||||
<NButton
|
||||
size="small"
|
||||
type="secondary"
|
||||
type="primary"
|
||||
secondary
|
||||
:disabled="testingIndex === -999"
|
||||
@click="testNewProvider"
|
||||
>
|
||||
@@ -381,14 +401,14 @@ function getFieldsForType(type: string) {
|
||||
/>
|
||||
<div v-else i-carbon-play />
|
||||
{{ $t('config.notification_set.test') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
<div flex="~ gap-8">
|
||||
<ab-button size="small" type="warn" @click="showAddDialog = false">
|
||||
<NButton size="small" type="error" @click="showAddDialog = false">
|
||||
{{ $t('config.cancel') }}
|
||||
</ab-button>
|
||||
<ab-button size="small" type="primary" @click="addProvider">
|
||||
</NButton>
|
||||
<NButton size="small" type="primary" @click="addProvider">
|
||||
{{ $t('config.apply') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -403,7 +423,11 @@ function getFieldsForType(type: string) {
|
||||
<div space-y-16>
|
||||
<ab-label :label="$t('config.notification_set.type')">
|
||||
<select v-model="newProvider.type" ab-input disabled>
|
||||
<option v-for="pt in providerTypes" :key="pt.value" :value="pt.value">
|
||||
<option
|
||||
v-for="pt in providerTypes"
|
||||
:key="pt.value"
|
||||
:value="pt.value"
|
||||
>
|
||||
{{ pt.label }}
|
||||
</option>
|
||||
</select>
|
||||
@@ -441,9 +465,10 @@ function getFieldsForType(type: string) {
|
||||
<div line></div>
|
||||
|
||||
<div flex="~ justify-between items-center">
|
||||
<ab-button
|
||||
<NButton
|
||||
size="small"
|
||||
type="secondary"
|
||||
type="primary"
|
||||
secondary
|
||||
:disabled="testingIndex === -999"
|
||||
@click="testNewProvider"
|
||||
>
|
||||
@@ -454,14 +479,14 @@ function getFieldsForType(type: string) {
|
||||
/>
|
||||
<div v-else i-carbon-play />
|
||||
{{ $t('config.notification_set.test') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
<div flex="~ gap-8">
|
||||
<ab-button size="small" type="warn" @click="showEditDialog = false">
|
||||
<NButton size="small" type="error" @click="showEditDialog = false">
|
||||
{{ $t('config.cancel') }}
|
||||
</ab-button>
|
||||
<ab-button size="small" type="primary" @click="saveProvider">
|
||||
</NButton>
|
||||
<NButton size="small" type="primary" @click="saveProvider">
|
||||
{{ $t('config.apply') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -478,7 +503,8 @@ function getFieldsForType(type: string) {
|
||||
padding: 12px;
|
||||
background: var(--color-surface-elevated, #f9fafb);
|
||||
border-radius: 8px;
|
||||
transition: background-color var(--transition-normal), opacity var(--transition-normal);
|
||||
transition: background-color var(--transition-normal),
|
||||
opacity var(--transition-normal);
|
||||
|
||||
:root.dark & {
|
||||
background: var(--color-surface-elevated, #1f2937);
|
||||
@@ -519,7 +545,7 @@ function getFieldsForType(type: string) {
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
:deep(.btn--small) {
|
||||
:deep(.n-button) {
|
||||
min-width: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -538,7 +564,8 @@ function getFieldsForType(type: string) {
|
||||
|
||||
.field-textarea {
|
||||
resize: none;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
|
||||
monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -550,7 +577,11 @@ function getFieldsForType(type: string) {
|
||||
|
||||
.test-success {
|
||||
color: var(--color-success, #22c55e);
|
||||
background: color-mix(in srgb, var(--color-success, #22c55e) 10%, transparent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-success, #22c55e) 10%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.test-error {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from 'naive-ui';
|
||||
import { Delete } from '@icon-park/vue-next';
|
||||
import type { PasskeyItem } from '#/passkey';
|
||||
|
||||
const { t } = useMyI18n();
|
||||
const { passkeys, loading, isSupported, loadPasskeys, addPasskey, deletePasskey } =
|
||||
usePasskey();
|
||||
const {
|
||||
passkeys,
|
||||
loading,
|
||||
isSupported,
|
||||
loadPasskeys,
|
||||
addPasskey,
|
||||
deletePasskey,
|
||||
} = usePasskey();
|
||||
|
||||
const showAddDialog = ref(false);
|
||||
const deviceName = ref('');
|
||||
@@ -93,22 +100,20 @@ function formatDate(dateString: string | null): string {
|
||||
<div>
|
||||
<div font-medium>{{ passkey.name }}</div>
|
||||
<div text-12 text-gray-500>
|
||||
{{ $t('passkey.created_at') }}: {{ formatDate(passkey.created_at) }}
|
||||
{{ $t('passkey.created_at') }}:
|
||||
{{ formatDate(passkey.created_at) }}
|
||||
</div>
|
||||
<div v-if="passkey.last_used_at" text-12 text-gray-500>
|
||||
{{ $t('passkey.last_used') }}: {{ formatDate(passkey.last_used_at) }}
|
||||
{{ $t('passkey.last_used') }}:
|
||||
{{ formatDate(passkey.last_used_at) }}
|
||||
</div>
|
||||
<div v-if="passkey.backup_eligible" text-12 text-green-600>
|
||||
{{ $t('passkey.synced') }}
|
||||
</div>
|
||||
</div>
|
||||
<ab-button
|
||||
size="small"
|
||||
type="warn"
|
||||
@click="handleDelete(passkey)"
|
||||
>
|
||||
<NButton size="small" type="error" @click="handleDelete(passkey)">
|
||||
<Delete size="16" />
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,14 +121,14 @@ function formatDate(dateString: string | null): string {
|
||||
|
||||
<!-- 添加按钮 -->
|
||||
<div flex="~ justify-end">
|
||||
<ab-button
|
||||
<NButton
|
||||
v-if="isSupported"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="openAddDialog"
|
||||
>
|
||||
{{ $t('passkey.add_new') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,21 +157,17 @@ function formatDate(dateString: string | null): string {
|
||||
<div line></div>
|
||||
|
||||
<div flex="~ justify-end gap-8">
|
||||
<ab-button
|
||||
size="small"
|
||||
type="warn"
|
||||
@click="showAddDialog = false"
|
||||
>
|
||||
<NButton size="small" type="error" @click="showAddDialog = false">
|
||||
{{ $t('config.cancel') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!deviceName.trim() || isRegistering"
|
||||
@click="handleAdd"
|
||||
>
|
||||
{{ $t('config.apply') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</ab-popup>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from 'naive-ui';
|
||||
import { Delete, EditTwo, Plus } from '@icon-park/vue-next';
|
||||
|
||||
interface SearchProvider {
|
||||
@@ -160,7 +161,10 @@ function validateUrl(url: string): boolean {
|
||||
<div class="provider-info">
|
||||
<div class="provider-name">
|
||||
{{ provider.name }}
|
||||
<span v-if="isDefaultProvider(provider.name)" class="default-badge">
|
||||
<span
|
||||
v-if="isDefaultProvider(provider.name)"
|
||||
class="default-badge"
|
||||
>
|
||||
{{ $t('config.search_provider_set.default') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -169,21 +173,22 @@ function validateUrl(url: string): boolean {
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-actions">
|
||||
<ab-button
|
||||
<NButton
|
||||
size="small"
|
||||
type="secondary"
|
||||
type="primary"
|
||||
secondary
|
||||
@click="openEditDialog(provider, index)"
|
||||
>
|
||||
<EditTwo size="16" />
|
||||
</ab-button>
|
||||
<ab-button
|
||||
</NButton>
|
||||
<NButton
|
||||
v-if="!isDefaultProvider(provider.name)"
|
||||
size="small"
|
||||
type="warn"
|
||||
type="error"
|
||||
@click="handleDelete(index)"
|
||||
>
|
||||
<Delete size="16" />
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,10 +202,10 @@ function validateUrl(url: string): boolean {
|
||||
|
||||
<!-- Add button -->
|
||||
<div flex="~ justify-end">
|
||||
<ab-button size="small" type="primary" @click="openAddDialog">
|
||||
<NButton size="small" type="primary" @click="openAddDialog">
|
||||
<Plus size="16" />
|
||||
{{ $t('config.search_provider_set.add_new') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -231,27 +236,26 @@ function validateUrl(url: string): boolean {
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<div
|
||||
v-if="formUrl && !validateUrl(formUrl)"
|
||||
class="validation-warning"
|
||||
>
|
||||
<div v-if="formUrl && !validateUrl(formUrl)" class="validation-warning">
|
||||
{{ $t('config.search_provider_set.url_missing_placeholder') }}
|
||||
</div>
|
||||
|
||||
<div line></div>
|
||||
|
||||
<div flex="~ justify-end gap-8">
|
||||
<ab-button size="small" type="warn" @click="showAddDialog = false">
|
||||
<NButton size="small" type="error" @click="showAddDialog = false">
|
||||
{{ $t('config.cancel') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!formName.trim() || !formUrl.trim() || !validateUrl(formUrl)"
|
||||
:disabled="
|
||||
!formName.trim() || !formUrl.trim() || !validateUrl(formUrl)
|
||||
"
|
||||
@click="handleAdd"
|
||||
>
|
||||
{{ $t('config.apply') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</ab-popup>
|
||||
@@ -270,7 +274,10 @@ function validateUrl(url: string): boolean {
|
||||
:placeholder="$t('config.search_provider_set.name_placeholder')"
|
||||
ab-input
|
||||
maxlength="32"
|
||||
:disabled="editingProvider !== null && isDefaultProvider(editingProvider.name)"
|
||||
:disabled="
|
||||
editingProvider !== null &&
|
||||
isDefaultProvider(editingProvider.name)
|
||||
"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
@@ -284,27 +291,26 @@ function validateUrl(url: string): boolean {
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
<div
|
||||
v-if="formUrl && !validateUrl(formUrl)"
|
||||
class="validation-warning"
|
||||
>
|
||||
<div v-if="formUrl && !validateUrl(formUrl)" class="validation-warning">
|
||||
{{ $t('config.search_provider_set.url_missing_placeholder') }}
|
||||
</div>
|
||||
|
||||
<div line></div>
|
||||
|
||||
<div flex="~ justify-end gap-8">
|
||||
<ab-button size="small" type="warn" @click="showEditDialog = false">
|
||||
<NButton size="small" type="error" @click="showEditDialog = false">
|
||||
{{ $t('config.cancel') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!formName.trim() || !formUrl.trim() || !validateUrl(formUrl)"
|
||||
:disabled="
|
||||
!formName.trim() || !formUrl.trim() || !validateUrl(formUrl)
|
||||
"
|
||||
@click="handleEdit"
|
||||
>
|
||||
{{ $t('config.apply') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</ab-popup>
|
||||
@@ -358,7 +364,8 @@ function validateUrl(url: string): boolean {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
|
||||
monospace;
|
||||
}
|
||||
|
||||
.provider-actions {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from 'naive-ui';
|
||||
|
||||
const { t } = useMyI18n();
|
||||
const setupStore = useSetupStore();
|
||||
const { accountData } = storeToRefs(setupStore);
|
||||
@@ -60,12 +62,22 @@ const passwordError = computed(() => {
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<ab-button size="small" type="secondary" @click="setupStore.prevStep()">
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
secondary
|
||||
@click="setupStore.prevStep()"
|
||||
>
|
||||
{{ t('setup.nav.previous') }}
|
||||
</ab-button>
|
||||
<ab-button size="small" :disabled="!isValid" @click="setupStore.nextStep()">
|
||||
</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!isValid"
|
||||
@click="setupStore.nextStep()"
|
||||
>
|
||||
{{ t('setup.nav.next') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</ab-container>
|
||||
@@ -106,7 +118,8 @@ const passwordError = 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);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton, NSwitch } from 'naive-ui';
|
||||
|
||||
const { t } = useMyI18n();
|
||||
const setupStore = useSetupStore();
|
||||
const { downloaderData, validation } = storeToRefs(setupStore);
|
||||
@@ -38,7 +40,11 @@ function handleNext() {
|
||||
}
|
||||
|
||||
const canTest = computed(() => {
|
||||
return downloaderData.value.host && downloaderData.value.username && downloaderData.value.password;
|
||||
return (
|
||||
downloaderData.value.host &&
|
||||
downloaderData.value.username &&
|
||||
downloaderData.value.password
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -84,35 +90,50 @@ const canTest = computed(() => {
|
||||
</ab-label>
|
||||
|
||||
<ab-label :label="t('config.downloader_set.ssl')">
|
||||
<ab-switch v-model="downloaderData.ssl" />
|
||||
<NSwitch v-model:value="downloaderData.ssl" />
|
||||
</ab-label>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<ab-button
|
||||
<NButton
|
||||
size="small"
|
||||
type="secondary"
|
||||
type="primary"
|
||||
secondary
|
||||
:disabled="!canTest || isTesting"
|
||||
@click="testConnection"
|
||||
>
|
||||
{{ isTesting ? t('setup.downloader.testing') : t('setup.downloader.test') }}
|
||||
</ab-button>
|
||||
<p v-if="testMessage" class="test-message" :class="{ success: testSuccess }">
|
||||
{{
|
||||
isTesting
|
||||
? t('setup.downloader.testing')
|
||||
: t('setup.downloader.test')
|
||||
}}
|
||||
</NButton>
|
||||
<p
|
||||
v-if="testMessage"
|
||||
class="test-message"
|
||||
:class="{ success: testSuccess }"
|
||||
>
|
||||
{{ testMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<ab-button size="small" type="secondary" @click="setupStore.prevStep()">
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
secondary
|
||||
@click="setupStore.prevStep()"
|
||||
>
|
||||
{{ t('setup.nav.previous') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!validation.downloaderTested"
|
||||
@click="handleNext"
|
||||
>
|
||||
{{ t('setup.nav.next') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</ab-container>
|
||||
@@ -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);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton, NSelect } from 'naive-ui';
|
||||
|
||||
const { t } = useMyI18n();
|
||||
const setupStore = useSetupStore();
|
||||
const { notificationData, validation } = storeToRefs(setupStore);
|
||||
@@ -61,9 +63,10 @@ const canTest = computed(() => {
|
||||
|
||||
<div class="form-fields">
|
||||
<ab-label :label="t('config.notification_set.type')">
|
||||
<ab-select
|
||||
v-model="notificationData.type"
|
||||
:items="notificationTypes"
|
||||
<NSelect
|
||||
v-model:value="notificationData.type"
|
||||
:options="notificationTypes"
|
||||
size="small"
|
||||
/>
|
||||
</ab-label>
|
||||
|
||||
@@ -85,34 +88,49 @@ const canTest = computed(() => {
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<ab-button
|
||||
<NButton
|
||||
size="small"
|
||||
type="secondary"
|
||||
type="primary"
|
||||
secondary
|
||||
:disabled="!canTest || isTesting"
|
||||
@click="testNotification"
|
||||
>
|
||||
{{ isTesting ? t('setup.downloader.testing') : t('setup.notification.test') }}
|
||||
</ab-button>
|
||||
<p v-if="testMessage" class="test-message" :class="{ success: testSuccess }">
|
||||
{{
|
||||
isTesting
|
||||
? t('setup.downloader.testing')
|
||||
: t('setup.notification.test')
|
||||
}}
|
||||
</NButton>
|
||||
<p
|
||||
v-if="testMessage"
|
||||
class="test-message"
|
||||
:class="{ success: testSuccess }"
|
||||
>
|
||||
{{ testMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<ab-button size="small" type="secondary" @click="setupStore.prevStep()">
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
secondary
|
||||
@click="setupStore.prevStep()"
|
||||
>
|
||||
{{ t('setup.nav.previous') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
<div class="action-group">
|
||||
<ab-button size="small" type="secondary" @click="skipStep">
|
||||
<NButton size="small" type="primary" secondary @click="skipStep">
|
||||
{{ t('setup.nav.skip') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!validation.notificationTested"
|
||||
@click="handleNext"
|
||||
>
|
||||
{{ t('setup.nav.next') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,7 +172,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);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from 'naive-ui';
|
||||
import { markSetupComplete } from '@/router';
|
||||
|
||||
const { t } = useMyI18n();
|
||||
@@ -44,18 +45,24 @@ function maskPassword(pwd: string): string {
|
||||
</div>
|
||||
<div class="review-item">
|
||||
<span class="review-label">{{ t('setup.account.password') }}</span>
|
||||
<span class="review-value">{{ maskPassword(accountData.password) }}</span>
|
||||
<span class="review-value">{{
|
||||
maskPassword(accountData.password)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="review-section">
|
||||
<h4>{{ t('setup.downloader.title') }}</h4>
|
||||
<div class="review-item">
|
||||
<span class="review-label">{{ t('config.downloader_set.host') }}</span>
|
||||
<span class="review-label">{{
|
||||
t('config.downloader_set.host')
|
||||
}}</span>
|
||||
<span class="review-value">{{ downloaderData.host }}</span>
|
||||
</div>
|
||||
<div class="review-item">
|
||||
<span class="review-label">{{ t('config.downloader_set.username') }}</span>
|
||||
<span class="review-label">{{
|
||||
t('config.downloader_set.username')
|
||||
}}</span>
|
||||
<span class="review-value">{{ downloaderData.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,22 +75,41 @@ function maskPassword(pwd: string): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!notificationData.skipped && notificationData.token" class="review-section">
|
||||
<div
|
||||
v-if="!notificationData.skipped && notificationData.token"
|
||||
class="review-section"
|
||||
>
|
||||
<h4>{{ t('setup.notification.title') }}</h4>
|
||||
<div class="review-item">
|
||||
<span class="review-label">{{ t('config.notification_set.type') }}</span>
|
||||
<span class="review-label">{{
|
||||
t('config.notification_set.type')
|
||||
}}</span>
|
||||
<span class="review-value">{{ notificationData.type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<ab-button size="small" type="secondary" @click="setupStore.prevStep()">
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
secondary
|
||||
@click="setupStore.prevStep()"
|
||||
>
|
||||
{{ t('setup.nav.previous') }}
|
||||
</ab-button>
|
||||
<ab-button size="small" :disabled="isLoading" @click="completeSetup">
|
||||
{{ isLoading ? t('setup.review.completing') : t('setup.review.complete') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="isLoading"
|
||||
@click="completeSetup"
|
||||
>
|
||||
{{
|
||||
isLoading
|
||||
? t('setup.review.completing')
|
||||
: t('setup.review.complete')
|
||||
}}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</ab-container>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from 'naive-ui';
|
||||
|
||||
const { t } = useMyI18n();
|
||||
const setupStore = useSetupStore();
|
||||
const { rssData, validation } = storeToRefs(setupStore);
|
||||
@@ -65,48 +67,59 @@ function handleNext() {
|
||||
</ab-label>
|
||||
|
||||
<ab-label v-if="rssData.name" :label="t('setup.rss.feed_name')">
|
||||
<input
|
||||
v-model="rssData.name"
|
||||
type="text"
|
||||
class="setup-input"
|
||||
/>
|
||||
<input v-model="rssData.name" type="text" class="setup-input" />
|
||||
</ab-label>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<ab-button
|
||||
<NButton
|
||||
size="small"
|
||||
type="secondary"
|
||||
type="primary"
|
||||
secondary
|
||||
:disabled="!rssData.url || isTesting"
|
||||
@click="testFeed"
|
||||
>
|
||||
{{ isTesting ? t('setup.downloader.testing') : t('setup.rss.test') }}
|
||||
</ab-button>
|
||||
<p v-if="testMessage" class="test-message" :class="{ success: testSuccess }">
|
||||
</NButton>
|
||||
<p
|
||||
v-if="testMessage"
|
||||
class="test-message"
|
||||
:class="{ success: testSuccess }"
|
||||
>
|
||||
{{ testMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="feedTitle" class="feed-info">
|
||||
<p><strong>{{ t('setup.rss.feed_title') }}:</strong> {{ feedTitle }}</p>
|
||||
<p><strong>{{ t('setup.rss.item_count') }}:</strong> {{ itemCount }}</p>
|
||||
<p>
|
||||
<strong>{{ t('setup.rss.feed_title') }}:</strong> {{ feedTitle }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{{ t('setup.rss.item_count') }}:</strong> {{ itemCount }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="wizard-actions">
|
||||
<ab-button size="small" type="secondary" @click="setupStore.prevStep()">
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
secondary
|
||||
@click="setupStore.prevStep()"
|
||||
>
|
||||
{{ t('setup.nav.previous') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
<div class="action-group">
|
||||
<ab-button size="small" type="secondary" @click="skipStep">
|
||||
<NButton size="small" type="primary" secondary @click="skipStep">
|
||||
{{ t('setup.nav.skip') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!validation.rssTested"
|
||||
@click="handleNext"
|
||||
>
|
||||
{{ t('setup.nav.next') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,7 +161,8 @@ function handleNext() {
|
||||
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);
|
||||
@@ -189,7 +203,9 @@ function handleNext() {
|
||||
|
||||
p {
|
||||
margin: 0 0 4px;
|
||||
&:last-child { margin: 0; }
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from 'naive-ui';
|
||||
|
||||
const { t } = useMyI18n();
|
||||
const setupStore = useSetupStore();
|
||||
</script>
|
||||
@@ -11,9 +13,9 @@ const setupStore = useSetupStore();
|
||||
|
||||
<div class="wizard-actions">
|
||||
<div></div>
|
||||
<ab-button size="small" @click="setupStore.nextStep()">
|
||||
<NButton type="primary" size="small" @click="setupStore.nextStep()">
|
||||
{{ t('setup.welcome.start') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</ab-container>
|
||||
|
||||
107
webui/src/hooks/__tests__/useBangumiRuleForm.test.ts
Normal file
107
webui/src/hooks/__tests__/useBangumiRuleForm.test.ts
Normal file
@@ -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<BangumiRule>) {
|
||||
let result!: ReturnType<typeof useBangumiRuleForm>;
|
||||
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<BangumiRule>({ ...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<BangumiRule>({
|
||||
...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<BangumiRule>({
|
||||
...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<BangumiRule>({ ...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<BangumiRule>({ ...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<BangumiRule>({ ...ruleTemplate });
|
||||
const { result } = withSetup(rule);
|
||||
|
||||
await result.copyRssLink('');
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
expect(result.copied.value).toBe(false);
|
||||
});
|
||||
});
|
||||
152
webui/src/hooks/__tests__/useEventStream.test.ts
Normal file
152
webui/src/hooks/__tests__/useEventStream.test.ts
Normal file
@@ -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<string, Array<(e: { data: string }) => 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<T>(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);
|
||||
});
|
||||
});
|
||||
@@ -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<boolean>(false);
|
||||
const version = ref<string>('');
|
||||
|
||||
// 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()
|
||||
|
||||
66
webui/src/hooks/useBangumiRuleForm.ts
Normal file
66
webui/src/hooks/useBangumiRuleForm.ts
Normal file
@@ -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<BangumiRule>) {
|
||||
const posterSrc = computed(() => resolvePosterUrl(rule.value.poster_link));
|
||||
|
||||
const infoTags = computed<InfoTag[]>(() => {
|
||||
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<typeof setTimeout> | 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,
|
||||
};
|
||||
}
|
||||
118
webui/src/hooks/useEventStream.ts
Normal file
118
webui/src/hooks/useEventStream.ts
Normal file
@@ -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<StatusPayload | null>(null);
|
||||
const downloaderData = ref<QbTorrentInfo[] | null>(null);
|
||||
const logData = ref<string | null>(null);
|
||||
|
||||
let source: EventSource | null = null;
|
||||
let retryCount = 0;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | 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,
|
||||
};
|
||||
});
|
||||
@@ -1,11 +1,19 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from 'naive-ui';
|
||||
import type { BangumiRule } from '#/bangumi';
|
||||
|
||||
definePage({
|
||||
name: 'Bangumi List',
|
||||
});
|
||||
|
||||
const { bangumi, showArchived, isLoading, hasLoaded, activeBangumi, archivedBangumi } = storeToRefs(useBangumiStore());
|
||||
const {
|
||||
bangumi,
|
||||
showArchived,
|
||||
isLoading,
|
||||
hasLoaded,
|
||||
activeBangumi,
|
||||
archivedBangumi,
|
||||
} = storeToRefs(useBangumiStore());
|
||||
const { getAll, openEditPopup } = useBangumiStore();
|
||||
const { openAddRss } = useAddRss();
|
||||
|
||||
@@ -52,8 +60,12 @@ function groupBangumi(items: BangumiRule[]): BangumiGroup[] {
|
||||
return groups;
|
||||
}
|
||||
|
||||
const groupedBangumi = computed<BangumiGroup[]>(() => groupBangumi(activeBangumi.value));
|
||||
const groupedArchivedBangumi = computed<BangumiGroup[]>(() => groupBangumi(archivedBangumi.value));
|
||||
const groupedBangumi = computed<BangumiGroup[]>(() =>
|
||||
groupBangumi(activeBangumi.value)
|
||||
);
|
||||
const groupedArchivedBangumi = computed<BangumiGroup[]>(() =>
|
||||
groupBangumi(archivedBangumi.value)
|
||||
);
|
||||
|
||||
// Rule list popup state
|
||||
const ruleListPopup = reactive<{
|
||||
@@ -80,127 +92,87 @@ function onRuleSelect(rule: BangumiRule) {
|
||||
|
||||
// Check if any rule in group needs review
|
||||
function groupNeedsReview(group: BangumiGroup): boolean {
|
||||
return group.rules.some(r => r.needs_review);
|
||||
return group.rules.some((r) => r.needs_review);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ab-pull-refresh :loading="refreshing" @refresh="onRefresh">
|
||||
<div class="page-bangumi">
|
||||
<!-- Skeleton loading state -->
|
||||
<div v-if="showSkeleton" class="bangumi-grid">
|
||||
<div
|
||||
v-for="i in skeletonCount"
|
||||
:key="`skeleton-${i}`"
|
||||
class="skeleton-card"
|
||||
>
|
||||
<div class="skeleton-poster"></div>
|
||||
<div class="skeleton-title"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state guide -->
|
||||
<div v-else-if="!bangumi || bangumi.length === 0" class="empty-guide">
|
||||
<div class="empty-guide-header anim-fade-in">
|
||||
<div class="empty-guide-title">{{ $t('homepage.empty.title') }}</div>
|
||||
<div class="empty-guide-subtitle">{{ $t('homepage.empty.subtitle') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-guide-steps">
|
||||
<div class="empty-guide-step anim-slide-up" style="--delay: 0.15s">
|
||||
<div class="empty-guide-step-number">1</div>
|
||||
<div class="empty-guide-step-content">
|
||||
<div class="empty-guide-step-title">{{ $t('homepage.empty.step1_title') }}</div>
|
||||
<div class="empty-guide-step-desc">{{ $t('homepage.empty.step1_desc') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-guide-step anim-slide-up" style="--delay: 0.3s">
|
||||
<div class="empty-guide-step-number">2</div>
|
||||
<div class="empty-guide-step-content">
|
||||
<div class="empty-guide-step-title">{{ $t('homepage.empty.step2_title') }}</div>
|
||||
<div class="empty-guide-step-desc">{{ $t('homepage.empty.step2_desc') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-guide-step anim-slide-up" style="--delay: 0.45s">
|
||||
<div class="empty-guide-step-number">3</div>
|
||||
<div class="empty-guide-step-content">
|
||||
<div class="empty-guide-step-title">{{ $t('homepage.empty.step3_title') }}</div>
|
||||
<div class="empty-guide-step-desc">{{ $t('homepage.empty.step3_desc') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-guide-action anim-slide-up" style="--delay: 0.6s">
|
||||
<ab-button type="primary" size="big" @click="openAddRss">
|
||||
{{ $t('homepage.empty.add_rss_btn') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bangumi grid -->
|
||||
<template v-else>
|
||||
<transition-group
|
||||
name="bangumi"
|
||||
tag="div"
|
||||
class="bangumi-grid"
|
||||
>
|
||||
<div class="page-bangumi">
|
||||
<!-- Skeleton loading state -->
|
||||
<div v-if="showSkeleton" class="bangumi-grid">
|
||||
<div
|
||||
v-for="group in groupedBangumi"
|
||||
:key="group.key"
|
||||
class="bangumi-group-wrapper"
|
||||
:class="[group.rules.every(r => r.deleted) && 'grayscale']"
|
||||
v-for="i in skeletonCount"
|
||||
:key="`skeleton-${i}`"
|
||||
class="skeleton-card"
|
||||
>
|
||||
<ab-bangumi-card
|
||||
:bangumi="group.primary"
|
||||
type="primary"
|
||||
@click="() => onCardClick(group)"
|
||||
/>
|
||||
<!-- Combined notification badge -->
|
||||
<div
|
||||
v-if="groupNeedsReview(group) || group.rules.length > 1"
|
||||
class="group-badge"
|
||||
:class="{ 'group-badge--warning': groupNeedsReview(group) }"
|
||||
>
|
||||
<template v-if="groupNeedsReview(group)">
|
||||
<span class="badge-icon">!</span>
|
||||
<template v-if="group.rules.length > 1">
|
||||
<span class="badge-divider"></span>
|
||||
<span class="badge-count">{{ group.rules.length }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ group.rules.length }}
|
||||
</template>
|
||||
<div class="skeleton-poster"></div>
|
||||
<div class="skeleton-title"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state guide -->
|
||||
<div v-else-if="!bangumi || bangumi.length === 0" class="empty-guide">
|
||||
<div class="empty-guide-header anim-fade-in">
|
||||
<div class="empty-guide-title">{{ $t('homepage.empty.title') }}</div>
|
||||
<div class="empty-guide-subtitle">
|
||||
{{ $t('homepage.empty.subtitle') }}
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
|
||||
<!-- Archived section -->
|
||||
<div v-if="groupedArchivedBangumi.length > 0" class="archived-section">
|
||||
<button
|
||||
type="button"
|
||||
class="archived-header"
|
||||
:aria-expanded="showArchived"
|
||||
@click="showArchived = !showArchived"
|
||||
>
|
||||
<span class="archived-title">
|
||||
{{ $t('homepage.rule.archived_section', { count: archivedBangumi.length }) }}
|
||||
</span>
|
||||
<span class="archived-toggle" aria-hidden="true">{{ showArchived ? '−' : '+' }}</span>
|
||||
</button>
|
||||
<div class="empty-guide-steps">
|
||||
<div class="empty-guide-step anim-slide-up" style="--delay: 0.15s">
|
||||
<div class="empty-guide-step-number">1</div>
|
||||
<div class="empty-guide-step-content">
|
||||
<div class="empty-guide-step-title">
|
||||
{{ $t('homepage.empty.step1_title') }}
|
||||
</div>
|
||||
<div class="empty-guide-step-desc">
|
||||
{{ $t('homepage.empty.step1_desc') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition-group
|
||||
v-show="showArchived"
|
||||
name="bangumi"
|
||||
tag="div"
|
||||
class="bangumi-grid archived-grid"
|
||||
>
|
||||
<div class="empty-guide-step anim-slide-up" style="--delay: 0.3s">
|
||||
<div class="empty-guide-step-number">2</div>
|
||||
<div class="empty-guide-step-content">
|
||||
<div class="empty-guide-step-title">
|
||||
{{ $t('homepage.empty.step2_title') }}
|
||||
</div>
|
||||
<div class="empty-guide-step-desc">
|
||||
{{ $t('homepage.empty.step2_desc') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-guide-step anim-slide-up" style="--delay: 0.45s">
|
||||
<div class="empty-guide-step-number">3</div>
|
||||
<div class="empty-guide-step-content">
|
||||
<div class="empty-guide-step-title">
|
||||
{{ $t('homepage.empty.step3_title') }}
|
||||
</div>
|
||||
<div class="empty-guide-step-desc">
|
||||
{{ $t('homepage.empty.step3_desc') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-guide-action anim-slide-up" style="--delay: 0.6s">
|
||||
<NButton type="primary" size="large" @click="openAddRss">
|
||||
{{ $t('homepage.empty.add_rss_btn') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bangumi grid -->
|
||||
<template v-else>
|
||||
<transition-group name="bangumi" tag="div" class="bangumi-grid">
|
||||
<div
|
||||
v-for="group in groupedArchivedBangumi"
|
||||
v-for="group in groupedBangumi"
|
||||
:key="group.key"
|
||||
class="bangumi-group-wrapper archived-item"
|
||||
class="bangumi-group-wrapper"
|
||||
:class="[group.rules.every((r) => r.deleted) && 'grayscale']"
|
||||
>
|
||||
<ab-bangumi-card
|
||||
:bangumi="group.primary"
|
||||
@@ -224,52 +196,131 @@ function groupNeedsReview(group: BangumiGroup): boolean {
|
||||
{{ group.rules.length }}
|
||||
</template>
|
||||
</div>
|
||||
<div class="archived-badge">{{ $t('homepage.rule.archived') }}</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Rule list popup for grouped items -->
|
||||
<ab-popup
|
||||
v-model:show="ruleListPopup.show"
|
||||
:title="ruleListPopup.group?.primary.official_title || ''"
|
||||
>
|
||||
<div v-if="ruleListPopup.group" class="rule-list">
|
||||
<div class="rule-list-hint">{{ $t('homepage.rule.select_hint') }}</div>
|
||||
<div
|
||||
v-for="rule in ruleListPopup.group.rules"
|
||||
:key="rule.id"
|
||||
class="rule-list-item"
|
||||
:class="[
|
||||
rule.deleted && 'rule-list-item--disabled',
|
||||
rule.needs_review && 'rule-list-item--warning'
|
||||
]"
|
||||
@click="onRuleSelect(rule)"
|
||||
>
|
||||
<div class="rule-list-item-info">
|
||||
<div class="rule-list-item-title">
|
||||
<span v-if="rule.needs_review" class="warning-text">! </span>{{ rule.group_name || rule.rule_name || $t('homepage.rule.unnamed') }}
|
||||
<!-- Archived section -->
|
||||
<div v-if="groupedArchivedBangumi.length > 0" class="archived-section">
|
||||
<button
|
||||
type="button"
|
||||
class="archived-header"
|
||||
:aria-expanded="showArchived"
|
||||
@click="showArchived = !showArchived"
|
||||
>
|
||||
<span class="archived-title">
|
||||
{{
|
||||
$t('homepage.rule.archived_section', {
|
||||
count: archivedBangumi.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span class="archived-toggle" aria-hidden="true">{{
|
||||
showArchived ? '−' : '+'
|
||||
}}</span>
|
||||
</button>
|
||||
|
||||
<transition-group
|
||||
v-show="showArchived"
|
||||
name="bangumi"
|
||||
tag="div"
|
||||
class="bangumi-grid archived-grid"
|
||||
>
|
||||
<div
|
||||
v-for="group in groupedArchivedBangumi"
|
||||
:key="group.key"
|
||||
class="bangumi-group-wrapper archived-item"
|
||||
>
|
||||
<ab-bangumi-card
|
||||
:bangumi="group.primary"
|
||||
type="primary"
|
||||
@click="() => onCardClick(group)"
|
||||
/>
|
||||
<!-- Combined notification badge -->
|
||||
<div
|
||||
v-if="groupNeedsReview(group) || group.rules.length > 1"
|
||||
class="group-badge"
|
||||
:class="{ 'group-badge--warning': groupNeedsReview(group) }"
|
||||
>
|
||||
<template v-if="groupNeedsReview(group)">
|
||||
<span class="badge-icon">!</span>
|
||||
<template v-if="group.rules.length > 1">
|
||||
<span class="badge-divider"></span>
|
||||
<span class="badge-count">{{ group.rules.length }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ group.rules.length }}
|
||||
</template>
|
||||
</div>
|
||||
<div class="archived-badge">
|
||||
{{ $t('homepage.rule.archived') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rule-list-item-tags">
|
||||
<ab-tag v-if="rule.dpi" :title="rule.dpi" type="primary" />
|
||||
<ab-tag v-if="rule.subtitle" :title="rule.subtitle" type="primary" />
|
||||
<ab-tag v-if="rule.source" :title="rule.source" type="primary" />
|
||||
</div>
|
||||
<div v-if="rule.filter && rule.filter.length > 0" class="rule-list-item-filter">
|
||||
<span class="rule-list-item-filter-label">{{ $t('homepage.rule.filter') }}:</span>
|
||||
<span class="rule-list-item-filter-value">{{ rule.filter.join(', ') }}</span>
|
||||
</div>
|
||||
<div v-if="rule.title_raw" class="rule-list-item-raw">
|
||||
{{ rule.title_raw }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rule-list-item-arrow">›</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</div>
|
||||
</ab-popup>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
<!-- Rule list popup for grouped items -->
|
||||
<ab-popup
|
||||
v-model:show="ruleListPopup.show"
|
||||
:title="ruleListPopup.group?.primary.official_title || ''"
|
||||
>
|
||||
<div v-if="ruleListPopup.group" class="rule-list">
|
||||
<div class="rule-list-hint">
|
||||
{{ $t('homepage.rule.select_hint') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="rule in ruleListPopup.group.rules"
|
||||
:key="rule.id"
|
||||
class="rule-list-item"
|
||||
:class="[
|
||||
rule.deleted && 'rule-list-item--disabled',
|
||||
rule.needs_review && 'rule-list-item--warning',
|
||||
]"
|
||||
@click="onRuleSelect(rule)"
|
||||
>
|
||||
<div class="rule-list-item-info">
|
||||
<div class="rule-list-item-title">
|
||||
<span v-if="rule.needs_review" class="warning-text">! </span
|
||||
>{{
|
||||
rule.group_name ||
|
||||
rule.rule_name ||
|
||||
$t('homepage.rule.unnamed')
|
||||
}}
|
||||
</div>
|
||||
<div class="rule-list-item-tags">
|
||||
<ab-tag v-if="rule.dpi" :title="rule.dpi" type="primary" />
|
||||
<ab-tag
|
||||
v-if="rule.subtitle"
|
||||
:title="rule.subtitle"
|
||||
type="primary"
|
||||
/>
|
||||
<ab-tag
|
||||
v-if="rule.source"
|
||||
:title="rule.source"
|
||||
type="primary"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="rule.filter && rule.filter.length > 0"
|
||||
class="rule-list-item-filter"
|
||||
>
|
||||
<span class="rule-list-item-filter-label"
|
||||
>{{ $t('homepage.rule.filter') }}:</span
|
||||
>
|
||||
<span class="rule-list-item-filter-value">{{
|
||||
rule.filter.join(', ')
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="rule.title_raw" class="rule-list-item-raw">
|
||||
{{ rule.title_raw }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rule-list-item-arrow">›</div>
|
||||
</div>
|
||||
</div>
|
||||
</ab-popup>
|
||||
</div>
|
||||
</ab-pull-refresh>
|
||||
</template>
|
||||
|
||||
@@ -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 {
|
||||
<style>
|
||||
.bangumi-enter-active,
|
||||
.bangumi-leave-active {
|
||||
transition: opacity var(--transition-normal), transform var(--transition-normal);
|
||||
transition: opacity var(--transition-normal),
|
||||
transform var(--transition-normal);
|
||||
}
|
||||
.bangumi-enter-from,
|
||||
.bangumi-leave-to {
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
import { ErrorPicture, Pin, Refresh } from '@icon-park/vue-next';
|
||||
import draggable from 'vuedraggable';
|
||||
import { Refresh } from '@icon-park/vue-next';
|
||||
import type { BangumiRule } from '#/bangumi';
|
||||
import type { BangumiGroup } from '@/components/calendar/types';
|
||||
|
||||
definePage({
|
||||
name: 'Calendar',
|
||||
});
|
||||
|
||||
const { t } = useMyI18n();
|
||||
const posterSrc = (link: string | null | undefined) => resolvePosterUrl(link);
|
||||
const { bangumi } = storeToRefs(useBangumiStore());
|
||||
const { getAll, openEditPopup, setWeekday } = useBangumiStore();
|
||||
const { getAll, openEditPopup } = useBangumiStore();
|
||||
const { isMobile } = useBreakpointQuery();
|
||||
const { t } = useMyI18n();
|
||||
|
||||
const refreshing = ref(false);
|
||||
|
||||
@@ -41,12 +40,6 @@ const todayIndex = computed(() => {
|
||||
});
|
||||
|
||||
// Group bangumi by official_title + season (same logic as main page)
|
||||
interface BangumiGroup {
|
||||
key: string;
|
||||
primary: BangumiRule;
|
||||
rules: BangumiRule[];
|
||||
}
|
||||
|
||||
function groupBangumiList(items: BangumiRule[]): BangumiGroup[] {
|
||||
if (!items) return [];
|
||||
const map = new Map<string, BangumiRule[]>();
|
||||
@@ -103,10 +96,6 @@ function getDayLabel(key: string): string {
|
||||
: t(`calendar.days_short.${key}`);
|
||||
}
|
||||
|
||||
function isToday(index: number): boolean {
|
||||
return index === todayIndex.value;
|
||||
}
|
||||
|
||||
// Rule list popup state (same as main page)
|
||||
const ruleListPopup = reactive<{
|
||||
show: boolean;
|
||||
@@ -129,36 +118,6 @@ function onRuleSelect(rule: BangumiRule) {
|
||||
ruleListPopup.show = false;
|
||||
openEditPopup(rule);
|
||||
}
|
||||
|
||||
// Drag-and-drop state (desktop only)
|
||||
const isDragging = ref(false);
|
||||
|
||||
async function onDropToDay(dayIndex: number, evt: any) {
|
||||
if (evt.added) {
|
||||
const group: BangumiGroup = evt.added.element;
|
||||
for (const rule of group.rules) {
|
||||
await setWeekday(rule.id, dayIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onUnpin(group: BangumiGroup, event: Event) {
|
||||
event.stopPropagation();
|
||||
for (const rule of group.rules) {
|
||||
await setWeekday(rule.id, null);
|
||||
}
|
||||
}
|
||||
|
||||
const unknownGroups = computed({
|
||||
get: () => groupedBangumiByDay.value.unknown || [],
|
||||
set: () => {
|
||||
// No-op: actual update happens via API in onDropToDay
|
||||
},
|
||||
});
|
||||
|
||||
function getDayGroups(key: string) {
|
||||
return groupedBangumiByDay.value[key] || [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -183,285 +142,42 @@ function getDayGroups(key: string) {
|
||||
<!-- Empty state -->
|
||||
<div v-if="!hasBangumi" class="empty-guide">
|
||||
<div class="empty-guide-header anim-fade-in">
|
||||
<div class="empty-guide-title">{{ $t('calendar.empty_state.title') }}</div>
|
||||
<div class="empty-guide-subtitle">{{ $t('calendar.empty_state.subtitle') }}</div>
|
||||
<div class="empty-guide-title">
|
||||
{{ $t('calendar.empty_state.title') }}
|
||||
</div>
|
||||
<div class="empty-guide-subtitle">
|
||||
{{ $t('calendar.empty_state.subtitle') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: Grid columns -->
|
||||
<div v-else-if="!isMobile" class="calendar-desktop">
|
||||
<div class="calendar-grid">
|
||||
<div
|
||||
v-for="(key, index) in DAY_KEYS"
|
||||
:key="key"
|
||||
class="calendar-column anim-slide-up"
|
||||
:class="{
|
||||
'calendar-column--today': isToday(index),
|
||||
'calendar-column--drop-active': isDragging,
|
||||
}"
|
||||
:style="{ '--delay': `${index * 0.05}s` }"
|
||||
>
|
||||
<!-- Day header -->
|
||||
<div
|
||||
class="calendar-day-header"
|
||||
:class="{ 'calendar-day-header--today': isToday(index) }"
|
||||
>
|
||||
<span class="calendar-day-label">{{ getDayLabel(key) }}</span>
|
||||
<span
|
||||
v-if="isToday(index)"
|
||||
class="calendar-today-badge"
|
||||
>
|
||||
{{ $t('calendar.today') }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Desktop: board of day columns -->
|
||||
<calendar-board
|
||||
v-else-if="!isMobile"
|
||||
:day-keys="DAY_KEYS"
|
||||
:grouped-by-day="groupedBangumiByDay"
|
||||
:today-index="todayIndex"
|
||||
:get-day-label="getDayLabel"
|
||||
@card-click="onCardClick"
|
||||
/>
|
||||
|
||||
<!-- Anime cards (grouped) - drop target -->
|
||||
<draggable
|
||||
:model-value="getDayGroups(key)"
|
||||
group="calendar"
|
||||
item-key="key"
|
||||
:sort="false"
|
||||
ghost-class="sortable-ghost"
|
||||
drag-class="sortable-drag"
|
||||
class="calendar-column-items"
|
||||
@change="onDropToDay(index, $event)"
|
||||
>
|
||||
<template #item="{ element: group }">
|
||||
<div class="calendar-card-wrapper">
|
||||
<div
|
||||
class="calendar-card"
|
||||
:class="{ 'calendar-card--pinned': group.primary.weekday_locked }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="`Edit ${group.primary.official_title}`"
|
||||
@click="onCardClick(group)"
|
||||
@keydown.enter="onCardClick(group)"
|
||||
>
|
||||
<div class="calendar-card-poster">
|
||||
<img
|
||||
v-if="group.primary.poster_link"
|
||||
:src="posterSrc(group.primary.poster_link)"
|
||||
:alt="group.primary.official_title"
|
||||
class="calendar-card-img"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-else class="calendar-card-placeholder">
|
||||
<ErrorPicture theme="outline" size="20" />
|
||||
</div>
|
||||
<div class="calendar-card-overlay">
|
||||
<div class="calendar-card-overlay-tags">
|
||||
<ab-tag :title="`S${group.primary.season}`" type="primary" />
|
||||
<ab-tag
|
||||
v-if="group.primary.group_name"
|
||||
:title="group.primary.group_name"
|
||||
type="primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="calendar-card-overlay-title">{{ group.primary.official_title }}</div>
|
||||
</div>
|
||||
<!-- Pin indicator for manually assigned -->
|
||||
<div v-if="group.primary.weekday_locked" class="calendar-card-pin">
|
||||
<Pin theme="filled" size="12" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Unpin button -->
|
||||
<button
|
||||
v-if="group.primary.weekday_locked"
|
||||
class="calendar-unpin-btn"
|
||||
:title="$t('calendar.unpin')"
|
||||
@click="onUnpin(group, $event)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div v-if="group.rules.length > 1" class="group-badge">
|
||||
{{ group.rules.length }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div v-if="getDayGroups(key).length === 0" class="calendar-empty-day">
|
||||
{{ isDragging ? $t('calendar.drop_here') : $t('calendar.empty') }}
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unknown air day section (draggable source) -->
|
||||
<div
|
||||
v-if="unknownGroups.length > 0"
|
||||
class="calendar-unknown-section anim-slide-up"
|
||||
:style="{ '--delay': '0.4s' }"
|
||||
>
|
||||
<div class="calendar-unknown-header">
|
||||
<span class="calendar-day-label">{{ getDayLabel('unknown') }}</span>
|
||||
<span class="calendar-drag-hint">{{ $t('calendar.drag_hint') }}</span>
|
||||
</div>
|
||||
<draggable
|
||||
v-model="unknownGroups"
|
||||
:group="{ name: 'calendar', pull: 'clone', put: false }"
|
||||
item-key="key"
|
||||
:sort="false"
|
||||
ghost-class="sortable-ghost"
|
||||
drag-class="sortable-drag"
|
||||
class="calendar-unknown-items"
|
||||
@start="isDragging = true"
|
||||
@end="isDragging = false"
|
||||
>
|
||||
<template #item="{ element: group }">
|
||||
<div class="calendar-card-wrapper">
|
||||
<div
|
||||
class="calendar-card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="`Edit ${group.primary.official_title}`"
|
||||
@click="onCardClick(group)"
|
||||
@keydown.enter="onCardClick(group)"
|
||||
>
|
||||
<div class="calendar-card-poster">
|
||||
<img
|
||||
v-if="group.primary.poster_link"
|
||||
:src="posterSrc(group.primary.poster_link)"
|
||||
:alt="group.primary.official_title"
|
||||
class="calendar-card-img"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-else class="calendar-card-placeholder">
|
||||
<ErrorPicture theme="outline" size="20" />
|
||||
</div>
|
||||
<div class="calendar-card-overlay">
|
||||
<div class="calendar-card-overlay-tags">
|
||||
<ab-tag :title="`S${group.primary.season}`" type="primary" />
|
||||
<ab-tag
|
||||
v-if="group.primary.group_name"
|
||||
:title="group.primary.group_name"
|
||||
type="primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="calendar-card-overlay-title">{{ group.primary.official_title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="group.rules.length > 1" class="group-badge">
|
||||
{{ group.rules.length }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Vertical list -->
|
||||
<div v-else class="calendar-list">
|
||||
<template v-for="(key, index) in [...DAY_KEYS, 'unknown']" :key="key">
|
||||
<div
|
||||
v-if="groupedBangumiByDay[key].length > 0"
|
||||
class="calendar-section anim-slide-up"
|
||||
:style="{ '--delay': `${index * 0.05}s` }"
|
||||
>
|
||||
<!-- Day divider -->
|
||||
<div
|
||||
class="calendar-section-header"
|
||||
:class="{ 'calendar-section-header--today': key !== 'unknown' && isToday(index) }"
|
||||
>
|
||||
<span class="calendar-section-label">{{ getDayLabel(key) }}</span>
|
||||
<span
|
||||
v-if="key !== 'unknown' && isToday(index)"
|
||||
class="calendar-today-badge calendar-today-badge--small"
|
||||
>
|
||||
{{ $t('calendar.today') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Anime rows (grouped) -->
|
||||
<div class="calendar-section-items">
|
||||
<div
|
||||
v-for="group in groupedBangumiByDay[key]"
|
||||
:key="group.key"
|
||||
class="calendar-row"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="`Edit ${group.primary.official_title}`"
|
||||
@click="onCardClick(group)"
|
||||
@keydown.enter="onCardClick(group)"
|
||||
>
|
||||
<div class="calendar-row-poster">
|
||||
<img
|
||||
v-if="group.primary.poster_link"
|
||||
:src="posterSrc(group.primary.poster_link)"
|
||||
:alt="group.primary.official_title"
|
||||
class="calendar-row-img"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-else class="calendar-row-placeholder">
|
||||
<ErrorPicture theme="outline" size="16" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="calendar-row-info">
|
||||
<div class="calendar-row-title">
|
||||
{{ group.primary.official_title }}
|
||||
<span v-if="group.rules.length > 1" class="calendar-row-badge">
|
||||
{{ group.rules.length }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="calendar-row-meta">
|
||||
<ab-tag :title="`S${group.primary.season}`" type="primary" />
|
||||
<ab-tag
|
||||
v-if="group.primary.group_name"
|
||||
:title="group.primary.group_name"
|
||||
type="primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- All days empty on mobile -->
|
||||
<div v-if="!hasBangumi" class="calendar-empty-day calendar-empty-day--mobile">
|
||||
{{ $t('calendar.no_data') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile: vertical list -->
|
||||
<calendar-mobile-list
|
||||
v-else
|
||||
:day-keys="[...DAY_KEYS, 'unknown']"
|
||||
:grouped-by-day="groupedBangumiByDay"
|
||||
:today-index="todayIndex"
|
||||
:get-day-label="getDayLabel"
|
||||
:has-bangumi="hasBangumi"
|
||||
@card-click="onCardClick"
|
||||
/>
|
||||
|
||||
<!-- Rule list popup for grouped items -->
|
||||
<ab-popup
|
||||
<calendar-rule-list-popup
|
||||
v-model:show="ruleListPopup.show"
|
||||
:title="ruleListPopup.group?.primary.official_title || ''"
|
||||
>
|
||||
<div v-if="ruleListPopup.group" class="rule-list">
|
||||
<div class="rule-list-hint">{{ $t('homepage.rule.select_hint') }}</div>
|
||||
<div
|
||||
v-for="rule in ruleListPopup.group.rules"
|
||||
:key="rule.id"
|
||||
class="rule-list-item"
|
||||
:class="[rule.deleted && 'rule-list-item--disabled']"
|
||||
@click="onRuleSelect(rule)"
|
||||
>
|
||||
<div class="rule-list-item-info">
|
||||
<div class="rule-list-item-title">
|
||||
{{ rule.group_name || rule.rule_name || $t('homepage.rule.unnamed') }}
|
||||
</div>
|
||||
<div class="rule-list-item-tags">
|
||||
<ab-tag v-if="rule.dpi" :title="rule.dpi" type="primary" />
|
||||
<ab-tag v-if="rule.subtitle" :title="rule.subtitle" type="primary" />
|
||||
<ab-tag v-if="rule.source" :title="rule.source" type="primary" />
|
||||
</div>
|
||||
<div v-if="rule.filter && rule.filter.length > 0" class="rule-list-item-filter">
|
||||
<span class="rule-list-item-filter-label">{{ $t('homepage.rule.filter') }}:</span>
|
||||
<span class="rule-list-item-filter-value">{{ rule.filter.join(', ') }}</span>
|
||||
</div>
|
||||
<div v-if="rule.title_raw" class="rule-list-item-raw">
|
||||
{{ rule.title_raw }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rule-list-item-arrow">›</div>
|
||||
</div>
|
||||
</div>
|
||||
</ab-popup>
|
||||
|
||||
:group="ruleListPopup.group"
|
||||
@select="onRuleSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -507,9 +223,8 @@ function getDayGroups(key: string) {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
background-color var(--transition-fast);
|
||||
transition: color var(--transition-fast), border-color var(--transition-fast),
|
||||
background-color var(--transition-fast);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--color-primary);
|
||||
@@ -530,536 +245,14 @@ function getDayGroups(key: string) {
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
// Desktop layout
|
||||
.calendar-desktop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// Desktop grid
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.calendar-column {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
transition: background-color var(--transition-normal),
|
||||
border-color var(--transition-normal);
|
||||
|
||||
&--today {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 1px var(--color-primary-light);
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown air day section
|
||||
.calendar-unknown-section {
|
||||
background: var(--color-surface-hover);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.calendar-unknown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.calendar-unknown-items {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.calendar-day-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&--today {
|
||||
background: var(--color-primary-light);
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-day-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
transition: color var(--transition-normal);
|
||||
|
||||
.calendar-day-header--today & {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-today-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
|
||||
&--small {
|
||||
font-size: 10px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-column-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// Card wrapper for badge positioning
|
||||
.calendar-card-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.group-badge {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
border-radius: 10px;
|
||||
background: var(--color-badge-bg);
|
||||
color: var(--color-white);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-dropdown);
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2px 4px var(--color-badge-shadow);
|
||||
}
|
||||
|
||||
// Desktop card
|
||||
.calendar-card {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: var(--radius-md);
|
||||
transition: transform var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-card-poster {
|
||||
position: relative;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
aspect-ratio: 2 / 3;
|
||||
}
|
||||
|
||||
.calendar-card-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.calendar-card-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-text-muted);
|
||||
transition: background-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.calendar-card-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(2px);
|
||||
transition: opacity var(--transition-normal);
|
||||
|
||||
.calendar-card:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-card-overlay-title {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.calendar-card-overlay-tags {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
:deep(.tag) {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
}
|
||||
|
||||
// Drag hint text
|
||||
.calendar-drag-hint {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// Drop active state
|
||||
.calendar-column--drop-active {
|
||||
border-color: var(--color-primary);
|
||||
border-style: dashed;
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
|
||||
// Pin indicator
|
||||
.calendar-card-pin {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
// Unpin button
|
||||
.calendar-unpin-btn {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: -6px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-danger, #e74c3c);
|
||||
color: #fff;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-dropdown);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
|
||||
.calendar-card-wrapper:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Pinned card subtle styling
|
||||
.calendar-card--pinned {
|
||||
box-shadow: 0 0 0 1.5px var(--color-primary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
// vuedraggable ghost and drag classes
|
||||
.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sortable-drag {
|
||||
opacity: 0.9;
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: rotate(2deg);
|
||||
}
|
||||
|
||||
// Empty day
|
||||
.calendar-empty-day {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
padding: 12px 4px;
|
||||
transition: color var(--transition-normal);
|
||||
|
||||
&--mobile {
|
||||
padding: 32px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile list
|
||||
.calendar-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.calendar-section {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.calendar-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0 6px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 6px;
|
||||
transition: border-color var(--transition-normal);
|
||||
|
||||
&--today {
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-section-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
letter-spacing: 0.3px;
|
||||
transition: color var(--transition-normal);
|
||||
|
||||
.calendar-section-header--today & {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-section-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.calendar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-row-poster {
|
||||
width: 44px;
|
||||
height: 62px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.calendar-row-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.calendar-row-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-text-muted);
|
||||
transition: background-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.calendar-row-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.calendar-row-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 4px;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.calendar-row-meta {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.calendar-row-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
margin-left: 6px;
|
||||
border-radius: 9px;
|
||||
background: var(--color-badge-bg);
|
||||
color: var(--color-white);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
// Rule list popup (same as main page)
|
||||
.rule-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.rule-list-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
padding: 4px 12px 8px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rule-list-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: var(--touch-target);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.rule-list-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.rule-list-item-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rule-list-item-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rule-list-item-filter {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rule-list-item-filter-label {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.rule-list-item-filter-value {
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.rule-list-item-raw {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rule-list-item-arrow {
|
||||
font-size: 18px;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
// Empty state (reuse pattern from bangumi page)
|
||||
.empty-guide {
|
||||
display: flex;
|
||||
@@ -1093,11 +286,6 @@ function getDayGroups(key: string) {
|
||||
animation: fadeIn 0.5s ease both;
|
||||
}
|
||||
|
||||
.anim-slide-up {
|
||||
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -1109,27 +297,9 @@ function getDayGroups(key: string) {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.anim-fade-in,
|
||||
.anim-slide-up {
|
||||
.anim-fade-in {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.calendar-card {
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from 'naive-ui';
|
||||
|
||||
definePage({
|
||||
name: 'Config',
|
||||
});
|
||||
@@ -54,18 +56,19 @@ onActivated(() => {
|
||||
</div>
|
||||
|
||||
<div class="config-actions">
|
||||
<ab-button
|
||||
:size="isMobileOrTablet ? 'big' : 'normal'"
|
||||
<NButton
|
||||
:size="isMobileOrTablet ? 'large' : 'medium'"
|
||||
:class="[{ 'flex-1': isMobileOrTablet }]"
|
||||
type="secondary"
|
||||
type="primary"
|
||||
secondary
|
||||
:loading="isResetting"
|
||||
:disabled="isResetting || isSaving"
|
||||
@click="handleReset"
|
||||
>
|
||||
{{ $t('config.cancel') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
:size="isMobileOrTablet ? 'big' : 'normal'"
|
||||
</NButton>
|
||||
<NButton
|
||||
:size="isMobileOrTablet ? 'large' : 'medium'"
|
||||
:class="[{ 'flex-1': isMobileOrTablet }]"
|
||||
type="primary"
|
||||
:loading="isSaving"
|
||||
@@ -73,7 +76,7 @@ onActivated(() => {
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ $t('config.apply') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -122,26 +125,12 @@ onActivated(() => {
|
||||
background: color-mix(in srgb, var(--color-surface) 90%, transparent);
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
// Override button max-width on mobile to allow flex grow
|
||||
:deep(.btn) {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
@include forTablet {
|
||||
justify-content: flex-end;
|
||||
padding: 12px 0;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
background: color-mix(in srgb, var(--color-bg) 80%, transparent);
|
||||
|
||||
// Restore button max-width on tablet+
|
||||
:deep(.btn) {
|
||||
max-width: 170px;
|
||||
|
||||
&.btn--big {
|
||||
max-width: 276px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<script lang="tsx" setup>
|
||||
import { type DataTableColumns, NDataTable, NProgress } from 'naive-ui';
|
||||
import {
|
||||
type DataTableColumns,
|
||||
NButton,
|
||||
NDataTable,
|
||||
NProgress,
|
||||
} from 'naive-ui';
|
||||
import type { QbTorrentInfo, TorrentGroup } from '#/downloader';
|
||||
|
||||
definePage({
|
||||
@@ -24,8 +29,18 @@ const isNull = computed(() => {
|
||||
return config.value.downloader.host === '';
|
||||
});
|
||||
|
||||
const { connected: sseConnected } = useEventStream();
|
||||
|
||||
const isActive = ref(false);
|
||||
const { pause, resume } = useIntervalFn(getAll, 5000, { immediate: false });
|
||||
const { pause, resume } = useIntervalFn(
|
||||
() => {
|
||||
// SSE 已接管种子列表推送,或页面不可见时,跳过本次轮询请求。
|
||||
if (sseConnected.value || document.hidden) return;
|
||||
getAll();
|
||||
},
|
||||
5000,
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
onActivated(async () => {
|
||||
isActive.value = true;
|
||||
@@ -46,12 +61,12 @@ function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / 1024**i).toFixed(1) } ${ units[i]}`;
|
||||
return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function formatSpeed(bytesPerSec: number): string {
|
||||
if (bytesPerSec === 0) return '-';
|
||||
return `${formatSize(bytesPerSec) }/s`;
|
||||
return `${formatSize(bytesPerSec)}/s`;
|
||||
}
|
||||
|
||||
function formatEta(seconds: number): string {
|
||||
@@ -124,7 +139,9 @@ const tableColumnsValue = computed<DataTableColumns<QbTorrentInfo>>(() => [
|
||||
key: 'state',
|
||||
width: 100,
|
||||
render(row: QbTorrentInfo) {
|
||||
return <ab-tag type={stateType(row.state)} title={stateLabel(row.state)} />;
|
||||
return (
|
||||
<ab-tag type={stateType(row.state)} title={stateLabel(row.state)} />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -193,36 +210,54 @@ function groupCheckedKeys(group: TorrentGroup): string[] {
|
||||
<div v-if="isNull" class="empty-guide">
|
||||
<div class="empty-guide-header anim-fade-in">
|
||||
<div class="empty-guide-title">{{ $t('downloader.empty.title') }}</div>
|
||||
<div class="empty-guide-subtitle">{{ $t('downloader.empty.subtitle') }}</div>
|
||||
<div class="empty-guide-subtitle">
|
||||
{{ $t('downloader.empty.subtitle') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-guide-steps">
|
||||
<div class="empty-guide-step anim-slide-up" style="--delay: 0.15s">
|
||||
<div class="empty-guide-step-number">1</div>
|
||||
<div class="empty-guide-step-content">
|
||||
<div class="empty-guide-step-title">{{ $t('downloader.empty.step1_title') }}</div>
|
||||
<div class="empty-guide-step-desc">{{ $t('downloader.empty.step1_desc') }}</div>
|
||||
<div class="empty-guide-step-title">
|
||||
{{ $t('downloader.empty.step1_title') }}
|
||||
</div>
|
||||
<div class="empty-guide-step-desc">
|
||||
{{ $t('downloader.empty.step1_desc') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-guide-step anim-slide-up" style="--delay: 0.3s">
|
||||
<div class="empty-guide-step-number">2</div>
|
||||
<div class="empty-guide-step-content">
|
||||
<div class="empty-guide-step-title">{{ $t('downloader.empty.step2_title') }}</div>
|
||||
<div class="empty-guide-step-desc">{{ $t('downloader.empty.step2_desc') }}</div>
|
||||
<div class="empty-guide-step-title">
|
||||
{{ $t('downloader.empty.step2_title') }}
|
||||
</div>
|
||||
<div class="empty-guide-step-desc">
|
||||
{{ $t('downloader.empty.step2_desc') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-guide-step anim-slide-up" style="--delay: 0.45s">
|
||||
<div class="empty-guide-step-number">3</div>
|
||||
<div class="empty-guide-step-content">
|
||||
<div class="empty-guide-step-title">{{ $t('downloader.empty.step3_title') }}</div>
|
||||
<div class="empty-guide-step-desc">{{ $t('downloader.empty.step3_desc') }}</div>
|
||||
<div class="empty-guide-step-title">
|
||||
{{ $t('downloader.empty.step3_title') }}
|
||||
</div>
|
||||
<div class="empty-guide-step-desc">
|
||||
{{ $t('downloader.empty.step3_desc') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RouterLink to="/config" class="empty-guide-action anim-slide-up" style="--delay: 0.6s">
|
||||
<RouterLink
|
||||
to="/config"
|
||||
class="empty-guide-action anim-slide-up"
|
||||
style="--delay: 0.6s"
|
||||
>
|
||||
{{ $t('sidebar.config') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
@@ -258,9 +293,15 @@ function groupCheckedKeys(group: TorrentGroup): string[] {
|
||||
{{ selectedHashes.length }} {{ $t('downloader.selected') }}
|
||||
</span>
|
||||
<div class="action-bar-buttons">
|
||||
<ab-button size="small" @click="resumeSelected">{{ $t('downloader.action.resume') }}</ab-button>
|
||||
<ab-button size="small" @click="pauseSelected">{{ $t('downloader.action.pause') }}</ab-button>
|
||||
<ab-button size="small" type="warn" @click="deleteSelected(false)">{{ $t('downloader.action.delete') }}</ab-button>
|
||||
<NButton type="primary" size="small" @click="resumeSelected">{{
|
||||
$t('downloader.action.resume')
|
||||
}}</NButton>
|
||||
<NButton type="primary" size="small" @click="pauseSelected">{{
|
||||
$t('downloader.action.pause')
|
||||
}}</NButton>
|
||||
<NButton size="small" type="error" @click="deleteSelected(false)">{{
|
||||
$t('downloader.action.delete')
|
||||
}}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -339,7 +380,7 @@ function groupCheckedKeys(group: TorrentGroup): string[] {
|
||||
@include forMobile {
|
||||
width: 100%;
|
||||
|
||||
:deep(.btn) {
|
||||
:deep(.n-button) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
@@ -399,7 +440,7 @@ function groupCheckedKeys(group: TorrentGroup): string[] {
|
||||
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 {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from 'naive-ui';
|
||||
import { watchOnce } from '@vueuse/core';
|
||||
|
||||
definePage({
|
||||
@@ -137,10 +138,7 @@ onDeactivated(() => {
|
||||
<div ref="logContainer" class="log-viewer">
|
||||
<div class="log-content">
|
||||
<template v-for="i in filteredLog" :key="i.index">
|
||||
<div
|
||||
class="log-entry"
|
||||
:style="{ color: typeColor(i.type) }"
|
||||
>
|
||||
<div class="log-entry" :style="{ color: typeColor(i.type) }">
|
||||
<div class="log-meta">
|
||||
<div class="log-type">{{ i.type }}</div>
|
||||
<div class="log-date">{{ i.date }}</div>
|
||||
@@ -152,17 +150,17 @@ onDeactivated(() => {
|
||||
</div>
|
||||
|
||||
<div class="log-actions">
|
||||
<ab-button size="small" @click="getLog">
|
||||
<NButton type="primary" size="small" @click="getLog">
|
||||
{{ $t('log.update_now') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
|
||||
<ab-button type="warn" size="small" @click="reset">
|
||||
<NButton type="error" size="small" @click="reset">
|
||||
{{ $t('log.reset') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
|
||||
<ab-button size="small" @click="copy">
|
||||
<NButton type="primary" size="small" @click="copy">
|
||||
{{ $t('log.copy') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</ab-container>
|
||||
|
||||
@@ -170,57 +168,67 @@ onDeactivated(() => {
|
||||
<ab-container :title="$t('log.contact_info')">
|
||||
<div class="contact-list">
|
||||
<ab-label label="Github">
|
||||
<ab-button
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
link="https://github.com/EstrellaXD/Auto_Bangumi"
|
||||
tag="a"
|
||||
href="https://github.com/EstrellaXD/Auto_Bangumi"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('log.go') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</ab-label>
|
||||
|
||||
<ab-label label="Official Website">
|
||||
<ab-button
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
link="https://autobangumi.org"
|
||||
tag="a"
|
||||
href="https://autobangumi.org"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('log.go') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</ab-label>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<ab-label label="X">
|
||||
<ab-button
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
link="https://twitter.com/Estrella_Pan"
|
||||
tag="a"
|
||||
href="https://twitter.com/Estrella_Pan"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('log.go') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</ab-label>
|
||||
|
||||
<ab-label label="Telegram Group">
|
||||
<ab-button
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
link="https://t.me/autobangumi"
|
||||
tag="a"
|
||||
href="https://t.me/autobangumi"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('log.join') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</ab-label>
|
||||
</div>
|
||||
</ab-container>
|
||||
|
||||
<ab-container :title="$t('log.bug_repo')">
|
||||
<div class="bug-section">
|
||||
<ab-button
|
||||
<NButton
|
||||
type="primary"
|
||||
class="issues-btn"
|
||||
link="https://github.com/EstrellaXD/Auto_Bangumi/issues"
|
||||
tag="a"
|
||||
href="https://github.com/EstrellaXD/Auto_Bangumi/issues"
|
||||
>
|
||||
Github Issues
|
||||
</ab-button>
|
||||
</NButton>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="tsx" setup>
|
||||
import { type DataTableColumns, NDataTable, NTooltip } from 'naive-ui';
|
||||
import { type DataTableColumns, NButton, NDataTable, NTooltip } from 'naive-ui';
|
||||
import type { RSS } from '#/rss';
|
||||
|
||||
definePage({
|
||||
@@ -96,15 +96,20 @@ const rssColumns = computed<DataTableColumns<RSS>>(() => [
|
||||
render(rss: RSS) {
|
||||
const isRowRefreshing = refreshingId.value === rss.id;
|
||||
return (
|
||||
<ab-button
|
||||
<NButton
|
||||
size="small"
|
||||
type="secondary"
|
||||
title={t('rss.refresh')}
|
||||
type="primary"
|
||||
secondary
|
||||
{...{ title: t('rss.refresh') }}
|
||||
disabled={isRowRefreshing}
|
||||
onClick={() => onRefreshOne(rss.id)}
|
||||
>
|
||||
<div class={isRowRefreshing ? 'i-carbon-renew animate-spin' : 'i-carbon-renew'} />
|
||||
</ab-button>
|
||||
<div
|
||||
class={
|
||||
isRowRefreshing ? 'i-carbon-renew animate-spin' : 'i-carbon-renew'
|
||||
}
|
||||
/>
|
||||
</NButton>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -117,16 +122,21 @@ const rssRowKey = (row: RSS) => row.id;
|
||||
<div class="page-rss">
|
||||
<ab-container :title="$t('rss.title')">
|
||||
<template #title-right>
|
||||
<ab-button
|
||||
<NButton
|
||||
size="small"
|
||||
type="secondary"
|
||||
type="primary"
|
||||
secondary
|
||||
:title="$t('rss.refresh_all')"
|
||||
:disabled="isRefreshingAll"
|
||||
@click="refreshAllRSS"
|
||||
>
|
||||
<div :class="isRefreshingAll ? 'i-carbon-renew animate-spin' : 'i-carbon-renew'"></div>
|
||||
<div
|
||||
:class="
|
||||
isRefreshingAll ? 'i-carbon-renew animate-spin' : 'i-carbon-renew'
|
||||
"
|
||||
></div>
|
||||
{{ $t('rss.refresh_all') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</template>
|
||||
|
||||
<!-- Mobile: Card-based list -->
|
||||
@@ -165,9 +175,10 @@ const rssRowKey = (row: RSS) => row.id;
|
||||
/>
|
||||
</div>
|
||||
<div class="rss-card-actions" @click.stop>
|
||||
<ab-button
|
||||
<NButton
|
||||
size="small"
|
||||
type="secondary"
|
||||
type="primary"
|
||||
secondary
|
||||
:title="$t('rss.refresh')"
|
||||
:disabled="refreshingId === item.id"
|
||||
@click="onRefreshOne(item.id)"
|
||||
@@ -180,7 +191,7 @@ const rssRowKey = (row: RSS) => row.id;
|
||||
"
|
||||
></div>
|
||||
{{ $t('rss.refresh') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -201,13 +212,15 @@ const rssRowKey = (row: RSS) => row.id;
|
||||
<div v-if="selectedRSS.length > 0">
|
||||
<div class="divider"></div>
|
||||
<div class="rss-actions">
|
||||
<ab-button @click="enableSelected">{{ $t('rss.enable') }}</ab-button>
|
||||
<ab-button @click="disableSelected">{{
|
||||
<NButton type="primary" @click="enableSelected">{{
|
||||
$t('rss.enable')
|
||||
}}</NButton>
|
||||
<NButton type="primary" @click="disableSelected">{{
|
||||
$t('rss.disable')
|
||||
}}</ab-button>
|
||||
<ab-button type="warn" @click="deleteSelected">{{
|
||||
}}</NButton>
|
||||
<NButton type="error" @click="deleteSelected">{{
|
||||
$t('rss.delete')
|
||||
}}</ab-button>
|
||||
}}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</ab-container>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from 'naive-ui';
|
||||
import { Fingerprint, Lock, User } from '@icon-park/vue-next';
|
||||
|
||||
definePage({
|
||||
@@ -46,7 +47,11 @@ async function handleLogin() {
|
||||
<div class="login-logo">
|
||||
<!-- Light mode: colored logo, Dark mode: light logo -->
|
||||
<img src="/images/logo.svg" alt="AutoBangumi" class="logo-dark" />
|
||||
<img src="/images/logo-light.svg" alt="AutoBangumi" class="logo-light" />
|
||||
<img
|
||||
src="/images/logo-light.svg"
|
||||
alt="AutoBangumi"
|
||||
class="logo-light"
|
||||
/>
|
||||
</div>
|
||||
<h1 class="login-title">AutoBangumi</h1>
|
||||
<p class="login-subtitle">{{ $t('login.title') }}</p>
|
||||
@@ -90,15 +95,15 @@ async function handleLogin() {
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="login-actions">
|
||||
<ab-button
|
||||
size="big"
|
||||
<NButton
|
||||
size="large"
|
||||
type="primary"
|
||||
:loading="isLoginLoading"
|
||||
:disabled="isLoginLoading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ $t('login.login_btn') }}
|
||||
</ab-button>
|
||||
</NButton>
|
||||
|
||||
<button
|
||||
v-if="isSupported"
|
||||
@@ -179,7 +184,8 @@ async function handleLogin() {
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
33% {
|
||||
@@ -211,7 +217,7 @@ async function handleLogin() {
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-lg),
|
||||
0 0 0 1px color-mix(in srgb, var(--color-white) 5%, transparent) inset;
|
||||
0 0 0 1px color-mix(in srgb, var(--color-white) 5%, transparent) inset;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
padding: 32px 24px;
|
||||
@@ -317,8 +323,7 @@ async function handleLogin() {
|
||||
border-radius: var(--radius-md);
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
background-color var(--transition-fast);
|
||||
box-shadow var(--transition-fast), background-color var(--transition-fast);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
@@ -330,7 +335,8 @@ async function handleLogin() {
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
background: var(--color-surface);
|
||||
|
||||
~ .input-icon,
|
||||
@@ -358,9 +364,9 @@ async function handleLogin() {
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
|
||||
// Override ab-button max-width to be full width
|
||||
:deep(.btn) {
|
||||
max-width: 100%;
|
||||
// Override NButton width to be full width
|
||||
:deep(.n-button) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,13 @@ export const useDownloaderStore = defineStore('downloader', () => {
|
||||
const selectedHashes = ref<string[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const { downloaderData } = useEventStream();
|
||||
// SSE 已连接时使用推送数据,省去 downloader.vue 页面自己的 5s 轮询请求。
|
||||
watch(downloaderData, (data) => {
|
||||
if (data === null) return;
|
||||
torrents.value = data;
|
||||
});
|
||||
|
||||
const groups = computed<TorrentGroup[]>(() => {
|
||||
const map = new Map<string, QbTorrentInfo[]>();
|
||||
for (const t of torrents.value) {
|
||||
|
||||
@@ -4,10 +4,19 @@ export const useLogStore = defineStore('log', () => {
|
||||
const message = useMessage();
|
||||
const { isLoggedIn } = useAuth();
|
||||
const { t } = useMyI18n();
|
||||
const { connected: sseConnected, logData } = useEventStream();
|
||||
|
||||
const log = ref('');
|
||||
|
||||
// SSE 已连接时使用推送数据;否则回退到轮询。
|
||||
watch(logData, (data) => {
|
||||
if (data === null) return;
|
||||
log.value = data;
|
||||
});
|
||||
|
||||
function getLog() {
|
||||
// SSE 已接管日志推送,或页面不可见时,跳过本次轮询请求。
|
||||
if (sseConnected.value || document.hidden) return;
|
||||
if (isLoggedIn.value) {
|
||||
apiLog.getLog().then((res) => {
|
||||
log.value = res;
|
||||
|
||||
2
webui/types/dts/auto-imports.d.ts
vendored
2
webui/types/dts/auto-imports.d.ts
vendored
@@ -93,6 +93,7 @@ declare global {
|
||||
const useAppInfo: typeof import('../../src/hooks/useAppInfo')['useAppInfo']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useAuth: typeof import('../../src/hooks/useAuth')['useAuth']
|
||||
const useBangumiRuleForm: typeof import('../../src/hooks/useBangumiRuleForm')['useBangumiRuleForm']
|
||||
const useBangumiStore: typeof import('../../src/store/bangumi')['useBangumiStore']
|
||||
const useBreakpointQuery: typeof import('../../src/hooks/useBreakpointQuery')['useBreakpointQuery']
|
||||
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
|
||||
@@ -102,6 +103,7 @@ declare global {
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useDarkMode: typeof import('../../src/hooks/useDarkMode')['useDarkMode']
|
||||
const useDownloaderStore: typeof import('../../src/store/downloader')['useDownloaderStore']
|
||||
const useEventStream: typeof import('../../src/hooks/useEventStream')['useEventStream']
|
||||
const useI18n: typeof import('vue-i18n')['useI18n']
|
||||
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
|
||||
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
|
||||
|
||||
15
webui/types/dts/components.d.ts
vendored
15
webui/types/dts/components.d.ts
vendored
@@ -14,10 +14,8 @@ declare module '@vue/runtime-core' {
|
||||
AbAddRss: typeof import('./../../src/components/ab-add-rss.vue')['default']
|
||||
AbBangumiCard: typeof import('./../../src/components/ab-bangumi-card.vue')['default']
|
||||
AbBottomSheet: typeof import('./../../src/components/basic/ab-bottom-sheet.vue')['default']
|
||||
AbButton: typeof import('./../../src/components/basic/ab-button.vue')['default']
|
||||
AbButtonMulti: typeof import('./../../src/components/basic/ab-button-multi.vue')['default']
|
||||
AbChangeAccount: typeof import('./../../src/components/ab-change-account.vue')['default']
|
||||
AbCheckbox: typeof import('./../../src/components/basic/ab-checkbox.vue')['default']
|
||||
AbContainer: typeof import('./../../src/components/ab-container.vue')['default']
|
||||
AbDataList: typeof import('./../../src/components/basic/ab-data-list.vue')['default']
|
||||
AbEditRule: typeof import('./../../src/components/ab-edit-rule.vue')['default']
|
||||
@@ -36,15 +34,24 @@ declare module '@vue/runtime-core' {
|
||||
AbSearchConfirm: typeof import('./../../src/components/search/ab-search-confirm.vue')['default']
|
||||
AbSearchFilters: typeof import('./../../src/components/search/ab-search-filters.vue')['default']
|
||||
AbSearchModal: typeof import('./../../src/components/search/ab-search-modal.vue')['default']
|
||||
AbSelect: typeof import('./../../src/components/basic/ab-select.vue')['default']
|
||||
AbSetting: typeof import('./../../src/components/ab-setting.vue')['default']
|
||||
AbSidebar: typeof import('./../../src/components/layout/ab-sidebar.vue')['default']
|
||||
AbStatus: typeof import('./../../src/components/basic/ab-status.vue')['default']
|
||||
AbStatusBar: typeof import('./../../src/components/ab-status-bar.vue')['default']
|
||||
AbSwipeContainer: typeof import('./../../src/components/basic/ab-swipe-container.vue')['default']
|
||||
AbSwitch: typeof import('./../../src/components/basic/ab-switch.vue')['default']
|
||||
AbTag: typeof import('./../../src/components/basic/ab-tag.vue')['default']
|
||||
AbTopbar: typeof import('./../../src/components/layout/ab-topbar.vue')['default']
|
||||
AdvancedSection: typeof import('./../../src/components/advanced-section.vue')['default']
|
||||
BangumiFilterField: typeof import('./../../src/components/bangumi-filter-field.vue')['default']
|
||||
BangumiInfoTags: typeof import('./../../src/components/bangumi-info-tags.vue')['default']
|
||||
BangumiOffsetField: typeof import('./../../src/components/bangumi-offset-field.vue')['default']
|
||||
BangumiPreview: typeof import('./../../src/components/bangumi-preview.vue')['default']
|
||||
BangumiRssLinkRow: typeof import('./../../src/components/bangumi-rss-link-row.vue')['default']
|
||||
CalendarBoard: typeof import('./../../src/components/calendar/calendar-board.vue')['default']
|
||||
CalendarCard: typeof import('./../../src/components/calendar/calendar-card.vue')['default']
|
||||
CalendarDayColumn: typeof import('./../../src/components/calendar/calendar-day-column.vue')['default']
|
||||
CalendarMobileList: typeof import('./../../src/components/calendar/calendar-mobile-list.vue')['default']
|
||||
CalendarRuleListPopup: typeof import('./../../src/components/calendar/calendar-rule-list-popup.vue')['default']
|
||||
ConfigDownload: typeof import('./../../src/components/setting/config-download.vue')['default']
|
||||
ConfigManage: typeof import('./../../src/components/setting/config-manage.vue')['default']
|
||||
ConfigNormal: typeof import('./../../src/components/setting/config-normal.vue')['default']
|
||||
|
||||
Reference in New Issue
Block a user