mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-04-14 10:30:35 +08:00
feat: add calendar view with Bangumi.tv schedule integration
Add weekly broadcast schedule page showing subscribed anime grouped by day-of-week. Backend fetches air_weekday from Bangumi.tv calendar API and matches titles. Frontend displays responsive grid (desktop) and vertical list (mobile). Edit popup moved to parent layout to fix KeepAlive conflicts, and restyled with purple theme. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
47
backend/src/dev_server.py
Normal file
47
backend/src/dev_server.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Minimal dev server that skips downloader check for UI testing."""
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi import APIRouter
|
||||
|
||||
from module.database.combine import Database
|
||||
from module.database.engine import engine
|
||||
|
||||
# Initialize DB + migrations + default user
|
||||
with Database(engine) as db:
|
||||
db.create_table()
|
||||
db.user.add_default_user()
|
||||
|
||||
# Build v1 router without program router (which blocks on downloader check)
|
||||
from module.api.auth import router as auth_router
|
||||
from module.api.bangumi import router as bangumi_router
|
||||
from module.api.config import router as config_router
|
||||
from module.api.log import router as log_router
|
||||
from module.api.rss import router as rss_router
|
||||
from module.api.search import router as search_router
|
||||
|
||||
v1 = APIRouter(prefix="/v1")
|
||||
v1.include_router(auth_router)
|
||||
v1.include_router(bangumi_router)
|
||||
v1.include_router(config_router)
|
||||
v1.include_router(log_router)
|
||||
v1.include_router(rss_router)
|
||||
v1.include_router(search_router)
|
||||
|
||||
# Stub status endpoint (real one lives in program router which blocks on downloader)
|
||||
@v1.get("/status")
|
||||
async def stub_status():
|
||||
return {"status": True, "version": "dev"}
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.include_router(v1, prefix="/api")
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="127.0.0.1", port=7892)
|
||||
@@ -127,6 +127,17 @@ async def refresh_poster(bangumi_id: int):
|
||||
return u_response(resp)
|
||||
|
||||
|
||||
@router.get(
|
||||
path="/refresh/calendar",
|
||||
response_model=APIResponse,
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def refresh_calendar():
|
||||
with TorrentManager() as manager:
|
||||
resp = manager.refresh_calendar()
|
||||
return u_response(resp)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/reset/all", response_model=APIResponse, dependencies=[Depends(get_current_user)]
|
||||
)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import logging
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlmodel import Session, SQLModel
|
||||
|
||||
from module.models import Bangumi, User
|
||||
@@ -9,6 +12,8 @@ from .rss import RSSDatabase
|
||||
from .torrent import TorrentDatabase
|
||||
from .user import UserDatabase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Database(Session):
|
||||
def __init__(self, engine=e):
|
||||
@@ -21,6 +26,20 @@ class Database(Session):
|
||||
|
||||
def create_table(self):
|
||||
SQLModel.metadata.create_all(self.engine)
|
||||
self._migrate_columns()
|
||||
|
||||
def _migrate_columns(self):
|
||||
"""Add new columns to existing tables if they don't exist."""
|
||||
inspector = inspect(self.engine)
|
||||
if "bangumi" in inspector.get_table_names():
|
||||
columns = [col["name"] for col in inspector.get_columns("bangumi")]
|
||||
if "air_weekday" not in columns:
|
||||
with self.engine.connect() as conn:
|
||||
conn.execute(
|
||||
text("ALTER TABLE bangumi ADD COLUMN air_weekday INTEGER")
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("[Database] Migrated: added air_weekday column to bangumi table.")
|
||||
|
||||
def drop_table(self):
|
||||
SQLModel.metadata.drop_all(self.engine)
|
||||
|
||||
@@ -4,6 +4,7 @@ from module.database import Database
|
||||
from module.downloader import DownloadClient
|
||||
from module.models import Bangumi, BangumiUpdate, ResponseModel
|
||||
from module.parser import TitleParser
|
||||
from module.parser.analyser.bgm_calendar import fetch_bgm_calendar, match_weekday
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -154,6 +155,37 @@ class TorrentManager(Database):
|
||||
msg_zh="刷新海报链接成功。",
|
||||
)
|
||||
|
||||
def refresh_calendar(self):
|
||||
"""Fetch Bangumi.tv calendar and update air_weekday for all bangumi."""
|
||||
calendar_items = fetch_bgm_calendar()
|
||||
if not calendar_items:
|
||||
return ResponseModel(
|
||||
status_code=500,
|
||||
status=False,
|
||||
msg_en="Failed to fetch calendar data from Bangumi.tv.",
|
||||
msg_zh="从 Bangumi.tv 获取放送表失败。",
|
||||
)
|
||||
bangumis = self.bangumi.search_all()
|
||||
updated = 0
|
||||
for bangumi in bangumis:
|
||||
if bangumi.deleted:
|
||||
continue
|
||||
weekday = match_weekday(
|
||||
bangumi.official_title, bangumi.title_raw, calendar_items
|
||||
)
|
||||
if weekday is not None and weekday != bangumi.air_weekday:
|
||||
bangumi.air_weekday = weekday
|
||||
updated += 1
|
||||
if updated > 0:
|
||||
self.bangumi.update_all(bangumis)
|
||||
logger.info(f"[Manager] Calendar refresh: updated {updated} bangumi.")
|
||||
return ResponseModel(
|
||||
status_code=200,
|
||||
status=True,
|
||||
msg_en=f"Calendar refreshed. Updated {updated} anime.",
|
||||
msg_zh=f"放送表已刷新,更新了 {updated} 部番剧。",
|
||||
)
|
||||
|
||||
def search_all_bangumi(self):
|
||||
datas = self.bangumi.search_all()
|
||||
if not datas:
|
||||
|
||||
@@ -27,6 +27,7 @@ class Bangumi(SQLModel, table=True):
|
||||
rule_name: Optional[str] = Field(alias="rule_name", title="番剧规则名")
|
||||
save_path: Optional[str] = Field(alias="save_path", title="番剧保存路径")
|
||||
deleted: bool = Field(False, alias="deleted", title="是否已删除")
|
||||
air_weekday: Optional[int] = Field(default=None, alias="air_weekday", title="放送星期")
|
||||
|
||||
|
||||
class BangumiUpdate(SQLModel):
|
||||
@@ -50,6 +51,7 @@ class BangumiUpdate(SQLModel):
|
||||
rule_name: Optional[str] = Field(alias="rule_name", title="番剧规则名")
|
||||
save_path: Optional[str] = Field(alias="save_path", title="番剧保存路径")
|
||||
deleted: bool = Field(False, alias="deleted", title="是否已删除")
|
||||
air_weekday: Optional[int] = Field(default=None, alias="air_weekday", title="放送星期")
|
||||
|
||||
|
||||
class Notification(BaseModel):
|
||||
|
||||
88
backend/src/module/parser/analyser/bgm_calendar.py
Normal file
88
backend/src/module/parser/analyser/bgm_calendar.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import logging
|
||||
|
||||
from module.network import RequestContent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BGM_CALENDAR_URL = "https://api.bgm.tv/calendar"
|
||||
|
||||
|
||||
def fetch_bgm_calendar() -> list[dict]:
|
||||
"""Fetch the current season's broadcast calendar from Bangumi.tv API.
|
||||
|
||||
Returns a flat list of anime items with their air_weekday (0=Mon, ..., 6=Sun).
|
||||
"""
|
||||
with RequestContent() as req:
|
||||
data = req.get_json(BGM_CALENDAR_URL)
|
||||
|
||||
if not data:
|
||||
logger.warning("[BGM Calendar] Failed to fetch calendar data.")
|
||||
return []
|
||||
|
||||
items = []
|
||||
for day_group in data:
|
||||
weekday_info = day_group.get("weekday", {})
|
||||
# Bangumi.tv uses 1=Mon, 2=Tue, ..., 7=Sun
|
||||
# Convert to 0=Mon, 1=Tue, ..., 6=Sun
|
||||
bgm_weekday = weekday_info.get("id")
|
||||
if bgm_weekday is None:
|
||||
continue
|
||||
weekday = bgm_weekday - 1 # 1-7 → 0-6
|
||||
|
||||
for item in day_group.get("items", []):
|
||||
items.append({
|
||||
"name": item.get("name", ""), # Japanese title
|
||||
"name_cn": item.get("name_cn", ""), # Chinese title
|
||||
"air_weekday": weekday,
|
||||
})
|
||||
|
||||
logger.info(f"[BGM Calendar] Fetched {len(items)} airing anime from Bangumi.tv.")
|
||||
return items
|
||||
|
||||
|
||||
def match_weekday(official_title: str, title_raw: str, calendar_items: list[dict]) -> int | None:
|
||||
"""Match a bangumi against calendar items to find its air weekday.
|
||||
|
||||
Matching strategy:
|
||||
1. Exact match on Chinese title (name_cn == official_title)
|
||||
2. Exact match on Japanese title (name == title_raw or official_title)
|
||||
3. Substring match (name_cn in official_title or vice versa)
|
||||
4. Substring match on Japanese title
|
||||
"""
|
||||
official_title_clean = official_title.strip()
|
||||
title_raw_clean = title_raw.strip()
|
||||
|
||||
for item in calendar_items:
|
||||
name_cn = item["name_cn"].strip()
|
||||
name = item["name"].strip()
|
||||
|
||||
if not name_cn and not name:
|
||||
continue
|
||||
|
||||
# Exact match on Chinese title
|
||||
if name_cn and name_cn == official_title_clean:
|
||||
return item["air_weekday"]
|
||||
|
||||
# Exact match on Japanese/original title
|
||||
if name and (name == title_raw_clean or name == official_title_clean):
|
||||
return item["air_weekday"]
|
||||
|
||||
# Second pass: substring matching
|
||||
for item in calendar_items:
|
||||
name_cn = item["name_cn"].strip()
|
||||
name = item["name"].strip()
|
||||
|
||||
if not name_cn and not name:
|
||||
continue
|
||||
|
||||
# Chinese title substring (at least 4 chars to avoid false positives)
|
||||
if name_cn and len(name_cn) >= 4:
|
||||
if name_cn in official_title_clean or official_title_clean in name_cn:
|
||||
return item["air_weekday"]
|
||||
|
||||
# Japanese title substring
|
||||
if name and len(name) >= 4:
|
||||
if name in title_raw_clean or title_raw_clean in name:
|
||||
return item["air_weekday"]
|
||||
|
||||
return None
|
||||
@@ -13,6 +13,7 @@ export const apiBangumi = {
|
||||
...bangumi,
|
||||
filter: bangumi.filter.split(','),
|
||||
rss_link: bangumi.rss_link.split(','),
|
||||
air_weekday: bangumi.air_weekday ?? null,
|
||||
}));
|
||||
return result;
|
||||
},
|
||||
@@ -30,6 +31,7 @@ export const apiBangumi = {
|
||||
...data,
|
||||
filter: data.filter.split(','),
|
||||
rss_link: data.rss_link.split(','),
|
||||
air_weekday: data.air_weekday ?? null,
|
||||
};
|
||||
return result;
|
||||
},
|
||||
@@ -134,4 +136,14 @@ export const apiBangumi = {
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 从 Bangumi.tv 刷新放送日历数据
|
||||
*/
|
||||
async refreshCalendar() {
|
||||
const { data } = await axios.get<ApiSuccess>(
|
||||
'api/v1/bangumi/refresh/calendar'
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -75,8 +75,8 @@ function close() {
|
||||
.popup-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(2px);
|
||||
background: rgba(108, 74, 182, 0.15);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.popup-wrapper {
|
||||
@@ -93,4 +93,22 @@ function close() {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:deep(.container-card) {
|
||||
border: 1px solid var(--color-primary);
|
||||
box-shadow: 0 8px 32px rgba(108, 74, 182, 0.18), 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.container-header) {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-bottom: none;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
:deep(.container-body) {
|
||||
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -49,7 +49,6 @@ const items = [
|
||||
icon: Calendar,
|
||||
label: () => t('sidebar.calendar'),
|
||||
path: '/calendar',
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
|
||||
@@ -70,9 +70,29 @@
|
||||
}
|
||||
},
|
||||
"downloader": {
|
||||
"hit": "Please set up the downloader"
|
||||
"hit": "Please set up the downloader",
|
||||
"empty": {
|
||||
"title": "Downloader not configured",
|
||||
"subtitle": "Connect your download client to manage torrents here",
|
||||
"step1_title": "Open Config",
|
||||
"step1_desc": "Navigate to the Config page and find the Downloader section.",
|
||||
"step2_title": "Enter Connection Details",
|
||||
"step2_desc": "Set your qBittorrent host address, username, and password.",
|
||||
"step3_title": "Access Downloader",
|
||||
"step3_desc": "Once configured, the downloader web UI will be embedded right here."
|
||||
}
|
||||
},
|
||||
"homepage": {
|
||||
"empty": {
|
||||
"title": "No subscriptions yet",
|
||||
"subtitle": "Get started by adding your first RSS feed",
|
||||
"step1_title": "Add RSS Feed",
|
||||
"step1_desc": "Click the \"Add\" button in the top bar and paste an RSS link from your anime source.",
|
||||
"step2_title": "Configure Downloader",
|
||||
"step2_desc": "Go to Config and set up your downloader (e.g. qBittorrent) connection.",
|
||||
"step3_title": "Sit Back & Enjoy",
|
||||
"step3_desc": "AutoBangumi will automatically download and rename new episodes for you."
|
||||
},
|
||||
"rule": {
|
||||
"apply": "Apply",
|
||||
"delete": "Delete",
|
||||
@@ -141,7 +161,17 @@
|
||||
"update_success": "Update Success!"
|
||||
},
|
||||
"player": {
|
||||
"hit": "Please set up the media player"
|
||||
"hit": "Please set up the media player",
|
||||
"empty": {
|
||||
"title": "Media player not configured",
|
||||
"subtitle": "Connect your media server to stream directly from here",
|
||||
"step1_title": "Open Config",
|
||||
"step1_desc": "Navigate to the Config page and find the Media Player section.",
|
||||
"step2_title": "Set Player URL",
|
||||
"step2_desc": "Enter the URL of your media server (Jellyfin, Emby, Plex, etc.).",
|
||||
"step3_title": "Start Watching",
|
||||
"step3_desc": "Your media player will be embedded here for easy access."
|
||||
}
|
||||
},
|
||||
"rss": {
|
||||
"delete": "Delete",
|
||||
@@ -153,6 +183,37 @@
|
||||
"title": "RSS Item",
|
||||
"url": "Url"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Schedule",
|
||||
"subtitle": "This season's broadcast schedule",
|
||||
"days": {
|
||||
"mon": "Monday",
|
||||
"tue": "Tuesday",
|
||||
"wed": "Wednesday",
|
||||
"thu": "Thursday",
|
||||
"fri": "Friday",
|
||||
"sat": "Saturday",
|
||||
"sun": "Sunday"
|
||||
},
|
||||
"days_short": {
|
||||
"mon": "Mon",
|
||||
"tue": "Tue",
|
||||
"wed": "Wed",
|
||||
"thu": "Thu",
|
||||
"fri": "Fri",
|
||||
"sat": "Sat",
|
||||
"sun": "Sun"
|
||||
},
|
||||
"unknown": "Unknown",
|
||||
"today": "Today",
|
||||
"empty": "No anime",
|
||||
"refresh": "Refresh schedule",
|
||||
"no_data": "No schedule data available",
|
||||
"empty_state": {
|
||||
"title": "No Schedule Yet",
|
||||
"subtitle": "Add anime from RSS to see your weekly schedule"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"calendar": "Calendar",
|
||||
"config": "Config",
|
||||
|
||||
@@ -70,9 +70,29 @@
|
||||
}
|
||||
},
|
||||
"downloader": {
|
||||
"hit": "请设置下载器"
|
||||
"hit": "请设置下载器",
|
||||
"empty": {
|
||||
"title": "下载器未配置",
|
||||
"subtitle": "连接下载客户端以在此管理种子",
|
||||
"step1_title": "打开设置",
|
||||
"step1_desc": "前往设置页面,找到下载器设置部分。",
|
||||
"step2_title": "输入连接信息",
|
||||
"step2_desc": "设置 qBittorrent 的地址、用户名和密码。",
|
||||
"step3_title": "访问下载器",
|
||||
"step3_desc": "配置完成后,下载器界面将直接嵌入此处。"
|
||||
}
|
||||
},
|
||||
"homepage": {
|
||||
"empty": {
|
||||
"title": "暂无订阅",
|
||||
"subtitle": "添加你的第一个 RSS 订阅开始使用",
|
||||
"step1_title": "添加 RSS 订阅",
|
||||
"step1_desc": "点击顶部栏的「添加」按钮,粘贴来自番剧源的 RSS 链接。",
|
||||
"step2_title": "配置下载器",
|
||||
"step2_desc": "前往设置页面,配置你的下载器(如 qBittorrent)连接信息。",
|
||||
"step3_title": "坐享其成",
|
||||
"step3_desc": "AutoBangumi 将自动下载并重命名新剧集。"
|
||||
},
|
||||
"rule": {
|
||||
"apply": "应用",
|
||||
"delete": "删除",
|
||||
@@ -141,7 +161,17 @@
|
||||
"update_success": "更新成功!"
|
||||
},
|
||||
"player": {
|
||||
"hit": "请设置媒体播放器地址"
|
||||
"hit": "请设置媒体播放器地址",
|
||||
"empty": {
|
||||
"title": "播放器未配置",
|
||||
"subtitle": "连接媒体服务器以在此直接播放",
|
||||
"step1_title": "打开设置",
|
||||
"step1_desc": "前往设置页面,找到播放器设置部分。",
|
||||
"step2_title": "设置播放器地址",
|
||||
"step2_desc": "输入媒体服务器的 URL(Jellyfin、Emby、Plex 等)。",
|
||||
"step3_title": "开始观看",
|
||||
"step3_desc": "播放器将嵌入此处,方便随时访问。"
|
||||
}
|
||||
},
|
||||
"rss": {
|
||||
"delete": "删除",
|
||||
@@ -153,6 +183,37 @@
|
||||
"title": "RSS 条目",
|
||||
"url": "链接"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "放送表",
|
||||
"subtitle": "本季度放送时间表",
|
||||
"days": {
|
||||
"mon": "周一",
|
||||
"tue": "周二",
|
||||
"wed": "周三",
|
||||
"thu": "周四",
|
||||
"fri": "周五",
|
||||
"sat": "周六",
|
||||
"sun": "周日"
|
||||
},
|
||||
"days_short": {
|
||||
"mon": "一",
|
||||
"tue": "二",
|
||||
"wed": "三",
|
||||
"thu": "四",
|
||||
"fri": "五",
|
||||
"sat": "六",
|
||||
"sun": "日"
|
||||
},
|
||||
"unknown": "未知",
|
||||
"today": "今天",
|
||||
"empty": "今日无番",
|
||||
"refresh": "刷新放送表",
|
||||
"no_data": "暂无放送数据",
|
||||
"empty_state": {
|
||||
"title": "暂无放送表",
|
||||
"subtitle": "从 RSS 添加番剧后即可查看每周放送时间"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"calendar": "番剧日历",
|
||||
"config": "设置",
|
||||
|
||||
@@ -3,6 +3,9 @@ definePage({
|
||||
name: 'Index',
|
||||
redirect: '/bangumi',
|
||||
});
|
||||
|
||||
const { editRule } = storeToRefs(useBangumiStore());
|
||||
const { updateRule, enableRule, ruleManage } = useBangumiStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -26,6 +29,14 @@ definePage({
|
||||
</RouterView>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<ab-edit-rule
|
||||
v-model:show="editRule.show"
|
||||
v-model:rule="editRule.item"
|
||||
@enable="(id) => enableRule(id)"
|
||||
@delete-file="(type, { id, deleteFile }) => ruleManage(type, id, deleteFile)"
|
||||
@apply="(rule) => updateRule(rule.id, rule)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@ definePage({
|
||||
name: 'Bangumi List',
|
||||
});
|
||||
|
||||
const { bangumi, editRule } = storeToRefs(useBangumiStore());
|
||||
const { getAll, updateRule, enableRule, openEditPopup, ruleManage } =
|
||||
useBangumiStore();
|
||||
const { bangumi } = storeToRefs(useBangumiStore());
|
||||
const { getAll, openEditPopup } = useBangumiStore();
|
||||
|
||||
const { isMobile } = useBreakpointQuery();
|
||||
|
||||
@@ -16,7 +15,43 @@ onActivated(() => {
|
||||
|
||||
<template>
|
||||
<div class="page-bangumi">
|
||||
<!-- Empty state guide -->
|
||||
<div v-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>
|
||||
|
||||
<!-- Bangumi grid -->
|
||||
<transition-group
|
||||
v-else
|
||||
name="bangumi"
|
||||
tag="div"
|
||||
class="bangumi-grid"
|
||||
@@ -32,15 +67,6 @@ onActivated(() => {
|
||||
></ab-bangumi-card>
|
||||
</transition-group>
|
||||
|
||||
<ab-edit-rule
|
||||
v-model:show="editRule.show"
|
||||
v-model:rule="editRule.item"
|
||||
@enable="(id) => enableRule(id)"
|
||||
@delete-file="
|
||||
(type, { id, deleteFile }) => ruleManage(type, id, deleteFile)
|
||||
"
|
||||
@apply="(rule) => updateRule(rule.id, rule)"
|
||||
></ab-edit-rule>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -59,6 +85,115 @@ onActivated(() => {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-guide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.empty-guide-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.empty-guide-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.empty-guide-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty-guide-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty-guide-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
transition: background-color var(--transition-normal),
|
||||
border-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.empty-guide-step-number {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-guide-step-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.empty-guide-step-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.empty-guide-step-desc {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.anim-fade-in {
|
||||
animation: fadeIn 0.5s ease both;
|
||||
}
|
||||
|
||||
.anim-slide-up {
|
||||
animation: slideUp 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,9 +1,638 @@
|
||||
<script lang="ts" setup>
|
||||
import { ErrorPicture, Refresh } from '@icon-park/vue-next';
|
||||
import type { BangumiRule } from '#/bangumi';
|
||||
|
||||
definePage({
|
||||
name: 'Calendar',
|
||||
});
|
||||
|
||||
const { t } = useMyI18n();
|
||||
const { bangumi } = storeToRefs(useBangumiStore());
|
||||
const { getAll, openEditPopup } = useBangumiStore();
|
||||
const { isMobile } = useBreakpointQuery();
|
||||
|
||||
const refreshing = ref(false);
|
||||
|
||||
async function refreshCalendar() {
|
||||
refreshing.value = true;
|
||||
try {
|
||||
await apiBangumi.refreshCalendar();
|
||||
await getAll();
|
||||
} finally {
|
||||
refreshing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onActivated(() => {
|
||||
getAll();
|
||||
refreshCalendar();
|
||||
});
|
||||
|
||||
const DAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const;
|
||||
|
||||
const todayIndex = computed(() => {
|
||||
// JS getDay(): 0=Sun, 1=Mon, ..., 6=Sat
|
||||
// We want: 0=Mon, 1=Tue, ..., 6=Sun
|
||||
const jsDay = new Date().getDay();
|
||||
return jsDay === 0 ? 6 : jsDay - 1;
|
||||
});
|
||||
|
||||
const bangumiByDay = computed(() => {
|
||||
const groups: Record<string, BangumiRule[]> = {};
|
||||
DAY_KEYS.forEach((key) => (groups[key] = []));
|
||||
groups['unknown'] = [];
|
||||
|
||||
bangumi.value?.forEach((item) => {
|
||||
if (item.deleted) return;
|
||||
const weekday = item.air_weekday;
|
||||
if (weekday != null && weekday >= 0 && weekday <= 6) {
|
||||
groups[DAY_KEYS[weekday]].push(item);
|
||||
} else {
|
||||
groups['unknown'].push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
const hasBangumi = computed(() => {
|
||||
return bangumi.value && bangumi.value.some((b) => !b.deleted);
|
||||
});
|
||||
|
||||
function getDayLabel(key: string): string {
|
||||
if (key === 'unknown') return t('calendar.unknown');
|
||||
return isMobile.value
|
||||
? t(`calendar.days.${key}`)
|
||||
: t(`calendar.days_short.${key}`);
|
||||
}
|
||||
|
||||
function isToday(index: number): boolean {
|
||||
return index === todayIndex.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>null</div>
|
||||
<div class="page-calendar">
|
||||
<!-- Header -->
|
||||
<div class="calendar-header anim-fade-in">
|
||||
<div class="calendar-header-text">
|
||||
<h2 class="calendar-title">{{ $t('calendar.title') }}</h2>
|
||||
<p class="calendar-subtitle">{{ $t('calendar.subtitle') }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="calendar-refresh-btn"
|
||||
:class="{ 'calendar-refresh-btn--spinning': refreshing }"
|
||||
:disabled="refreshing"
|
||||
:title="$t('calendar.refresh')"
|
||||
@click="refreshCalendar"
|
||||
>
|
||||
<Refresh :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: Grid columns -->
|
||||
<div v-else-if="!isMobile" class="calendar-grid">
|
||||
<div
|
||||
v-for="(key, index) in [...DAY_KEYS, 'unknown']"
|
||||
:key="key"
|
||||
class="calendar-column anim-slide-up"
|
||||
:class="{ 'calendar-column--today': key !== 'unknown' && isToday(index) }"
|
||||
:style="{ '--delay': `${index * 0.05}s` }"
|
||||
>
|
||||
<!-- Day header -->
|
||||
<div
|
||||
class="calendar-day-header"
|
||||
:class="{ 'calendar-day-header--today': key !== 'unknown' && isToday(index) }"
|
||||
>
|
||||
<span class="calendar-day-label">{{ getDayLabel(key) }}</span>
|
||||
<span
|
||||
v-if="key !== 'unknown' && isToday(index)"
|
||||
class="calendar-today-badge"
|
||||
>
|
||||
{{ $t('calendar.today') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Anime cards -->
|
||||
<div class="calendar-column-items">
|
||||
<div
|
||||
v-for="item in bangumiByDay[key]"
|
||||
:key="item.id"
|
||||
class="calendar-card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="`Edit ${item.official_title}`"
|
||||
@click="openEditPopup(item)"
|
||||
@keydown.enter="openEditPopup(item)"
|
||||
>
|
||||
<div class="calendar-card-poster">
|
||||
<img
|
||||
v-if="item.poster_link"
|
||||
:src="item.poster_link"
|
||||
:alt="item.official_title"
|
||||
class="calendar-card-img"
|
||||
/>
|
||||
<div v-else class="calendar-card-placeholder">
|
||||
<ErrorPicture theme="outline" size="20" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="calendar-card-info">
|
||||
<div class="calendar-card-title">{{ item.official_title }}</div>
|
||||
<div class="calendar-card-meta">
|
||||
<ab-tag :title="`S${item.season}`" type="primary" />
|
||||
<ab-tag
|
||||
v-if="item.group_name"
|
||||
:title="item.group_name"
|
||||
type="primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty day -->
|
||||
<div v-if="bangumiByDay[key].length === 0" class="calendar-empty-day">
|
||||
{{ $t('calendar.empty') }}
|
||||
</div>
|
||||
</div>
|
||||
</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="bangumiByDay[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 -->
|
||||
<div class="calendar-section-items">
|
||||
<div
|
||||
v-for="item in bangumiByDay[key]"
|
||||
:key="item.id"
|
||||
class="calendar-row"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="`Edit ${item.official_title}`"
|
||||
@click="openEditPopup(item)"
|
||||
@keydown.enter="openEditPopup(item)"
|
||||
>
|
||||
<div class="calendar-row-poster">
|
||||
<img
|
||||
v-if="item.poster_link"
|
||||
:src="item.poster_link"
|
||||
:alt="item.official_title"
|
||||
class="calendar-row-img"
|
||||
/>
|
||||
<div v-else class="calendar-row-placeholder">
|
||||
<ErrorPicture theme="outline" size="16" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="calendar-row-info">
|
||||
<div class="calendar-row-title">{{ item.official_title }}</div>
|
||||
<div class="calendar-row-meta">
|
||||
<ab-tag :title="`S${item.season}`" type="primary" />
|
||||
<ab-tag
|
||||
v-if="item.group_name"
|
||||
:title="item.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>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-calendar {
|
||||
overflow: auto;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
// Header
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.calendar-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.calendar-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 4px 0 0;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.calendar-refresh-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
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);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--spinning {
|
||||
:deep(svg) {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
// Desktop grid
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
|
||||
&--small {
|
||||
font-size: 10px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-column-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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-info {
|
||||
padding: 6px 2px 2px;
|
||||
}
|
||||
|
||||
.calendar-card-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 4px;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.calendar-card-meta {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Empty state (reuse pattern from bangumi page)
|
||||
.empty-guide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 40vh;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.empty-guide-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-guide-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 6px;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.empty-guide-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
// Animations
|
||||
.anim-fade-in {
|
||||
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;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.calendar-card {
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface BangumiRule {
|
||||
subtitle: string;
|
||||
title_raw: string;
|
||||
year: string | null;
|
||||
air_weekday: number | null; // 0=Mon, 1=Tue, ..., 6=Sun, null=Unknown
|
||||
}
|
||||
|
||||
export interface BangumiAPI extends Omit<BangumiRule, 'filter' | 'rss_link'> {
|
||||
@@ -55,4 +56,5 @@ export const ruleTemplate: BangumiRule = {
|
||||
subtitle: '',
|
||||
title_raw: '',
|
||||
year: null,
|
||||
air_weekday: null,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user