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:
Estrella Pan
2026-07-02 19:48:46 +02:00
parent 590220abfe
commit 3baf1c72b3
56 changed files with 3268 additions and 3344 deletions

View File

@@ -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)

View 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())

View 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"

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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');
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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>',
}),
};

View File

@@ -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>

View File

@@ -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" />',
}),
};

View File

@@ -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>

View File

@@ -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">&rarr; S{{ parsedSeason }} {{ t('offset.season') === '季度' ? '变为' : 'becomes' }} S{{ Math.max(1, parsedSeason + seasonOffset) }}</span>
<span class="offset-hint"
>&rarr; 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">&rarr; E{{ parsedEpisode }} {{ t('offset.season') === '季度' ? '保持' : 'stays' }} E{{ Math.max(1, parsedEpisode + episodeOffset) }}</span>
<span class="offset-hint"
>&rarr; 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>

View File

@@ -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" />',
}),
};

View File

@@ -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>

View File

@@ -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" />',
}),
};

View File

@@ -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>

View 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>

View 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)"
>
&times;
</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>

View 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>

View 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>

View 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">&rsaquo;</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>

View File

@@ -0,0 +1,8 @@
import type { BangumiRule } from '#/bangumi';
/** 按 official_title + season 分组后的番剧组,日历页面/看板/卡片共用 */
export interface BangumiGroup {
key: string;
primary: BangumiRule;
rules: BangumiRule[];
}

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -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>

View 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);
});
});

View 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);
});
});

View File

@@ -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()

View 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,
};
}

View 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,
};
});

View File

@@ -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 {

View File

@@ -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)"
>
&times;
</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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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%;
}
}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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']

View File

@@ -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']