mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-03-19 19:37:14 +08:00
feat(calendar): add drag-and-drop to assign unknown bangumi to weekdays
Allow users to drag bangumi cards from the "Unknown" section into weekday
columns in the calendar view. Manual assignments are locked so calendar
refresh from Bangumi.tv doesn't overwrite them. A reset button lets users
unlock and send cards back to Unknown.
Backend:
- Add weekday_locked field to Bangumi model (migration v9)
- Add PATCH /api/v1/bangumi/{id}/weekday endpoint
- Skip locked items in refresh_calendar()
Frontend:
- Add vuedraggable for smooth drag-and-drop
- Pin indicator and unpin button on manually-assigned cards
- Drop zone highlighting during drag
- i18n strings for drag/pin/unpin
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,10 @@ class OffsetSuggestionDetail(BaseModel):
|
||||
confidence: Literal["high", "medium", "low"]
|
||||
|
||||
|
||||
class SetWeekdayRequest(BaseModel):
|
||||
weekday: Optional[int] = None # 0-6 for Mon-Sun, None to reset
|
||||
|
||||
|
||||
class DetectOffsetRequest(BaseModel):
|
||||
"""Request body for detect-offset endpoint."""
|
||||
title: str
|
||||
@@ -339,3 +343,41 @@ async def get_needs_review():
|
||||
"""Get all bangumi that need review for offset mismatch."""
|
||||
with Database() as db:
|
||||
return db.bangumi.get_needs_review()
|
||||
|
||||
|
||||
@router.patch(
|
||||
path="/{bangumi_id}/weekday",
|
||||
response_model=APIResponse,
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def set_weekday(bangumi_id: int, request: SetWeekdayRequest):
|
||||
"""Manually set the broadcast weekday for a bangumi."""
|
||||
if request.weekday is not None and not (0 <= request.weekday <= 6):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"status": False,
|
||||
"msg_en": "Weekday must be 0-6 (Mon-Sun) or null.",
|
||||
"msg_zh": "星期必须是 0-6(周一至周日)或空。",
|
||||
},
|
||||
)
|
||||
with Database() as db:
|
||||
success = db.bangumi.set_weekday(bangumi_id, request.weekday)
|
||||
if success:
|
||||
action = f"weekday {request.weekday}" if request.weekday is not None else "unknown"
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"status": True,
|
||||
"msg_en": f"Set bangumi to {action}.",
|
||||
"msg_zh": f"已设置放送日为 {action}。",
|
||||
},
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"status": False,
|
||||
"msg_en": f"Bangumi {bangumi_id} not found.",
|
||||
"msg_zh": f"未找到番剧 {bangumi_id}。",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -657,3 +657,25 @@ class BangumiDatabase:
|
||||
_invalidate_bangumi_cache()
|
||||
logger.debug("[Database] Cleared needs_review for bangumi id %s", _id)
|
||||
return True
|
||||
|
||||
def set_weekday(self, _id: int, weekday: int | None) -> bool:
|
||||
"""Set air_weekday and weekday_locked for manual calendar assignment."""
|
||||
bangumi = self.session.get(Bangumi, _id)
|
||||
if not bangumi:
|
||||
return False
|
||||
if weekday is not None:
|
||||
bangumi.air_weekday = weekday
|
||||
bangumi.weekday_locked = True
|
||||
else:
|
||||
bangumi.air_weekday = None
|
||||
bangumi.weekday_locked = False
|
||||
self.session.add(bangumi)
|
||||
self.session.commit()
|
||||
_invalidate_bangumi_cache()
|
||||
logger.debug(
|
||||
"[Database] Set weekday=%s, locked=%s for bangumi id %s",
|
||||
weekday,
|
||||
bangumi.weekday_locked,
|
||||
_id,
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
|
||||
TABLE_MODELS: list[type[SQLModel]] = [Bangumi, RSSItem, Torrent, User, Passkey]
|
||||
|
||||
# Increment this when adding new migrations to MIGRATIONS list.
|
||||
CURRENT_SCHEMA_VERSION = 8
|
||||
CURRENT_SCHEMA_VERSION = 9
|
||||
|
||||
# Each migration is a tuple of (version, description, list of SQL statements).
|
||||
# Migrations are applied in order. A migration at index i brings the schema
|
||||
@@ -103,6 +103,13 @@ MIGRATIONS = [
|
||||
"ALTER TABLE bangumi ADD COLUMN title_aliases TEXT DEFAULT NULL",
|
||||
],
|
||||
),
|
||||
(
|
||||
9,
|
||||
"add weekday_locked column to bangumi",
|
||||
[
|
||||
"ALTER TABLE bangumi ADD COLUMN weekday_locked BOOLEAN DEFAULT 0",
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -198,6 +205,10 @@ class Database(Session):
|
||||
columns = [col["name"] for col in inspector.get_columns("bangumi")]
|
||||
if "title_aliases" in columns:
|
||||
needs_run = False
|
||||
if "bangumi" in tables and version == 9:
|
||||
columns = [col["name"] for col in inspector.get_columns("bangumi")]
|
||||
if "weekday_locked" in columns:
|
||||
needs_run = False
|
||||
if needs_run:
|
||||
try:
|
||||
with self.engine.connect() as conn:
|
||||
|
||||
@@ -210,7 +210,7 @@ class TorrentManager(Database):
|
||||
bangumis = self.bangumi.search_all()
|
||||
updated = 0
|
||||
for bangumi in bangumis:
|
||||
if bangumi.deleted:
|
||||
if bangumi.deleted or bangumi.weekday_locked:
|
||||
continue
|
||||
weekday = match_weekday(
|
||||
bangumi.official_title, bangumi.title_raw, calendar_items
|
||||
|
||||
@@ -36,6 +36,9 @@ class Bangumi(SQLModel, table=True):
|
||||
air_weekday: Optional[int] = Field(
|
||||
default=None, alias="air_weekday", title="放送星期"
|
||||
)
|
||||
weekday_locked: bool = Field(
|
||||
default=False, alias="weekday_locked", title="放送星期锁定"
|
||||
)
|
||||
needs_review: bool = Field(default=False, alias="needs_review", title="需要检查")
|
||||
needs_review_reason: Optional[str] = Field(
|
||||
default=None, alias="needs_review_reason", title="检查原因"
|
||||
@@ -77,6 +80,9 @@ class BangumiUpdate(SQLModel):
|
||||
air_weekday: Optional[int] = Field(
|
||||
default=None, alias="air_weekday", title="放送星期"
|
||||
)
|
||||
weekday_locked: bool = Field(
|
||||
default=False, alias="weekday_locked", title="放送星期锁定"
|
||||
)
|
||||
needs_review: bool = Field(default=False, alias="needs_review", title="需要检查")
|
||||
needs_review_reason: Optional[str] = Field(
|
||||
default=None, alias="needs_review_reason", title="检查原因"
|
||||
|
||||
406
docs/plans/2026-02-23-calendar-drag-organize-design.md
Normal file
406
docs/plans/2026-02-23-calendar-drag-organize-design.md
Normal file
@@ -0,0 +1,406 @@
|
||||
# Calendar Drag-to-Organize Unknown Bangumi — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Allow users to drag bangumi cards from the "Unknown" section into weekday columns in the calendar view, with a locked flag to prevent calendar refresh from overwriting manual assignments.
|
||||
|
||||
**Architecture:** Add a `weekday_locked` boolean field to the Bangumi model. A new API endpoint sets weekday + locks it. The `refresh_calendar()` method skips locked items. Frontend uses vuedraggable for smooth drag-and-drop from Unknown to weekday columns, with a reset/unlock button on manually-pinned cards.
|
||||
|
||||
**Tech Stack:** Python/FastAPI (backend), SQLModel/SQLite (data), Vue 3 + TypeScript + vuedraggable (frontend)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `weekday_locked` field to data model + migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/module/models/bangumi.py:36-38`
|
||||
- Modify: `backend/src/module/database/combine.py:26,99-105`
|
||||
|
||||
**Step 1: Add `weekday_locked` field to `Bangumi` model**
|
||||
|
||||
In `backend/src/module/models/bangumi.py`, after the `air_weekday` field (line 38), add:
|
||||
|
||||
```python
|
||||
weekday_locked: bool = Field(
|
||||
default=False, alias="weekday_locked", title="放送星期锁定"
|
||||
)
|
||||
```
|
||||
|
||||
**Step 2: Add `weekday_locked` to `BangumiUpdate` model**
|
||||
|
||||
In the same file, after `air_weekday` in `BangumiUpdate` (line 79), add:
|
||||
|
||||
```python
|
||||
weekday_locked: bool = Field(
|
||||
default=False, alias="weekday_locked", title="放送星期锁定"
|
||||
)
|
||||
```
|
||||
|
||||
**Step 3: Add database migration**
|
||||
|
||||
In `backend/src/module/database/combine.py`:
|
||||
|
||||
1. Increment `CURRENT_SCHEMA_VERSION` from `8` to `9`
|
||||
2. Add migration entry after the existing migration 8:
|
||||
|
||||
```python
|
||||
(
|
||||
9,
|
||||
"add weekday_locked column to bangumi",
|
||||
[
|
||||
"ALTER TABLE bangumi ADD COLUMN weekday_locked BOOLEAN DEFAULT 0",
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
3. Add skip-check in `run_migrations()` after the version 8 check:
|
||||
|
||||
```python
|
||||
if "bangumi" in tables and version == 9:
|
||||
columns = [col["name"] for col in inspector.get_columns("bangumi")]
|
||||
if "weekday_locked" in columns:
|
||||
needs_run = False
|
||||
```
|
||||
|
||||
**Step 4: Run backend tests to verify migration**
|
||||
|
||||
Run: `cd backend && uv run pytest src/test/ -v -k "not test_mcp"`
|
||||
Expected: All pass (new column has default, so no breaking changes)
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/src/module/models/bangumi.py backend/src/module/database/combine.py
|
||||
git commit -m "feat(model): add weekday_locked field to bangumi for manual calendar assignment"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add backend API endpoint for setting weekday
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/src/module/api/bangumi.py`
|
||||
- Modify: `backend/src/module/database/bangumi.py`
|
||||
|
||||
**Step 1: Add `set_weekday` database method**
|
||||
|
||||
In `backend/src/module/database/bangumi.py`, add method to `BangumiDatabase`:
|
||||
|
||||
```python
|
||||
def set_weekday(self, _id: int, weekday: int | None) -> bool:
|
||||
"""Set air_weekday and weekday_locked for manual calendar assignment."""
|
||||
bangumi = self.session.get(Bangumi, _id)
|
||||
if not bangumi:
|
||||
return False
|
||||
if weekday is not None:
|
||||
bangumi.air_weekday = weekday
|
||||
bangumi.weekday_locked = True
|
||||
else:
|
||||
bangumi.air_weekday = None
|
||||
bangumi.weekday_locked = False
|
||||
self.session.add(bangumi)
|
||||
self.session.commit()
|
||||
_invalidate_bangumi_cache()
|
||||
logger.debug(
|
||||
"[Database] Set weekday=%s, locked=%s for bangumi id %s",
|
||||
weekday,
|
||||
bangumi.weekday_locked,
|
||||
_id,
|
||||
)
|
||||
return True
|
||||
```
|
||||
|
||||
**Step 2: Add API endpoint**
|
||||
|
||||
In `backend/src/module/api/bangumi.py`, add request model and endpoint:
|
||||
|
||||
```python
|
||||
class SetWeekdayRequest(BaseModel):
|
||||
weekday: Optional[int] = None # 0-6 for Mon-Sun, None to reset
|
||||
|
||||
@router.patch(
|
||||
path="/{bangumi_id}/weekday",
|
||||
response_model=APIResponse,
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def set_weekday(bangumi_id: int, request: SetWeekdayRequest):
|
||||
"""Manually set the broadcast weekday for a bangumi."""
|
||||
if request.weekday is not None and not (0 <= request.weekday <= 6):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"status": False,
|
||||
"msg_en": "Weekday must be 0-6 (Mon-Sun) or null.",
|
||||
"msg_zh": "星期必须是 0-6(周一至周日)或空。",
|
||||
},
|
||||
)
|
||||
with Database() as db:
|
||||
success = db.bangumi.set_weekday(bangumi_id, request.weekday)
|
||||
if success:
|
||||
action = f"weekday {request.weekday}" if request.weekday is not None else "unknown"
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"status": True,
|
||||
"msg_en": f"Set bangumi to {action}.",
|
||||
"msg_zh": f"已设置放送日为 {action}。",
|
||||
},
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"status": False,
|
||||
"msg_en": f"Bangumi {bangumi_id} not found.",
|
||||
"msg_zh": f"未找到番剧 {bangumi_id}。",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
**Step 3: Modify `refresh_calendar()` to skip locked items**
|
||||
|
||||
In `backend/src/module/manager/torrent.py`, in `refresh_calendar()` method (line 212-213), change:
|
||||
|
||||
```python
|
||||
# Before:
|
||||
if bangumi.deleted:
|
||||
continue
|
||||
|
||||
# After:
|
||||
if bangumi.deleted or bangumi.weekday_locked:
|
||||
continue
|
||||
```
|
||||
|
||||
**Step 4: Run tests**
|
||||
|
||||
Run: `cd backend && uv run pytest src/test/ -v -k "not test_mcp"`
|
||||
Expected: All pass
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/src/module/api/bangumi.py backend/src/module/database/bangumi.py backend/src/module/manager/torrent.py
|
||||
git commit -m "feat(api): add PATCH /bangumi/{id}/weekday endpoint and skip locked items in calendar refresh"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add frontend TypeScript types and API client
|
||||
|
||||
**Files:**
|
||||
- Modify: `webui/types/bangumi.ts`
|
||||
- Modify: `webui/src/api/bangumi.ts`
|
||||
|
||||
**Step 1: Add `weekday_locked` to TypeScript types**
|
||||
|
||||
In `webui/types/bangumi.ts`, add to `BangumiRule` interface (after `air_weekday` line 26):
|
||||
|
||||
```typescript
|
||||
weekday_locked: boolean;
|
||||
```
|
||||
|
||||
Add to `ruleTemplate` (after `air_weekday: null` line 65):
|
||||
|
||||
```typescript
|
||||
weekday_locked: false,
|
||||
```
|
||||
|
||||
**Step 2: Add API method for setting weekday**
|
||||
|
||||
In `webui/src/api/bangumi.ts`, add method to `apiBangumi`:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 手动设置番剧的放送星期
|
||||
* @param bangumiId - bangumi 的 id
|
||||
* @param weekday - 0-6 for Mon-Sun, null to reset
|
||||
*/
|
||||
async setWeekday(bangumiId: number, weekday: number | null) {
|
||||
const { data } = await axios.patch<ApiSuccess>(
|
||||
`api/v1/bangumi/${bangumiId}/weekday`,
|
||||
{ weekday }
|
||||
);
|
||||
return data;
|
||||
},
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add webui/types/bangumi.ts webui/src/api/bangumi.ts
|
||||
git commit -m "feat(webui): add weekday_locked type and setWeekday API client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Install vuedraggable
|
||||
|
||||
**Files:**
|
||||
- Modify: `webui/package.json`
|
||||
|
||||
**Step 1: Install vuedraggable**
|
||||
|
||||
```bash
|
||||
cd webui && pnpm add vuedraggable@next
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add webui/package.json webui/pnpm-lock.yaml
|
||||
git commit -m "chore(webui): add vuedraggable dependency for calendar drag-and-drop"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add i18n strings for drag-and-drop
|
||||
|
||||
**Files:**
|
||||
- Modify: `webui/src/i18n/en.json`
|
||||
- Modify: `webui/src/i18n/zh-CN.json`
|
||||
|
||||
**Step 1: Add English i18n strings**
|
||||
|
||||
In `webui/src/i18n/en.json`, inside the `"calendar"` object (before the closing `}`), add:
|
||||
|
||||
```json
|
||||
"drag_hint": "Drag to assign weekday",
|
||||
"pinned": "Manually assigned",
|
||||
"unpin": "Reset to unknown",
|
||||
"drop_here": "Drop here"
|
||||
```
|
||||
|
||||
**Step 2: Add Chinese i18n strings**
|
||||
|
||||
In `webui/src/i18n/zh-CN.json`, inside the `"calendar"` object, add:
|
||||
|
||||
```json
|
||||
"drag_hint": "拖拽以设置放送日",
|
||||
"pinned": "手动设置",
|
||||
"unpin": "重置为未知",
|
||||
"drop_here": "拖放到此处"
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add webui/src/i18n/en.json webui/src/i18n/zh-CN.json
|
||||
git commit -m "feat(i18n): add calendar drag-and-drop strings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Implement drag-and-drop in calendar.vue (Desktop)
|
||||
|
||||
**Files:**
|
||||
- Modify: `webui/src/pages/index/calendar.vue`
|
||||
|
||||
This is the main implementation task. The calendar.vue file needs:
|
||||
|
||||
1. **Import vuedraggable** and add drag-and-drop functionality
|
||||
2. **Wrap Unknown section cards** in a `<draggable>` component as the drag source
|
||||
3. **Wrap each weekday column** in a `<draggable>` component as drop targets
|
||||
4. **Handle the `onChange` event** to call the API when a card is dropped
|
||||
5. **Add reset/unpin button** on cards with `weekday_locked === true`
|
||||
6. **Add visual pin indicator** on locked cards
|
||||
7. **Add drop-zone highlighting** CSS for when dragging over a weekday column
|
||||
|
||||
Key implementation details:
|
||||
|
||||
- vuedraggable uses `group` option to allow cross-list dragging
|
||||
- Unknown list: `group: { name: 'calendar', pull: true, put: false }` (can pull from, cannot put into)
|
||||
- Weekday lists: `group: { name: 'calendar', pull: false, put: true }` (can put into, cannot pull from)
|
||||
- On `change` event with `added` property, extract the bangumi group's primary ID and target day index, then call `apiBangumi.setWeekday(id, dayIndex)`
|
||||
- After API success, update the local bangumi store to reflect the change
|
||||
- The reset button calls `apiBangumi.setWeekday(id, null)` and refreshes store
|
||||
|
||||
CSS additions:
|
||||
- `.calendar-column--drop-active`: highlight border when dragging over
|
||||
- `.calendar-card--pinned`: pin icon overlay
|
||||
- `.calendar-unpin-btn`: reset button style
|
||||
- `.sortable-ghost`: semi-transparent placeholder during drag
|
||||
- `.sortable-drag`: shadow on the card being dragged
|
||||
|
||||
**Step 1: Implement the full calendar.vue changes**
|
||||
|
||||
(See implementation — this is a substantial template + script change)
|
||||
|
||||
**Step 2: Test manually in dev server**
|
||||
|
||||
```bash
|
||||
cd webui && pnpm dev
|
||||
```
|
||||
|
||||
Verify:
|
||||
- Unknown cards can be dragged to weekday columns
|
||||
- Weekday column highlights on dragover
|
||||
- Dropped cards show pin icon
|
||||
- Pin icon has working reset button
|
||||
- Cards with weekday_locked show pin in weekday columns
|
||||
- Mobile view still works (no drag on mobile — touch has different UX)
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add webui/src/pages/index/calendar.vue
|
||||
git commit -m "feat(calendar): add drag-and-drop from Unknown to weekday columns with pin/reset"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Update bangumi store to handle weekday_locked
|
||||
|
||||
**Files:**
|
||||
- Modify: `webui/src/store/bangumi.ts` (if needed for reactive updates after setWeekday)
|
||||
|
||||
**Step 1: Add `setWeekday` action to store**
|
||||
|
||||
Add a store action that calls the API and updates the local bangumi array reactively:
|
||||
|
||||
```typescript
|
||||
async function setWeekday(bangumiId: number, weekday: number | null) {
|
||||
await apiBangumi.setWeekday(bangumiId, weekday);
|
||||
// Update local state
|
||||
const item = bangumi.value?.find((b) => b.id === bangumiId);
|
||||
if (item) {
|
||||
item.air_weekday = weekday;
|
||||
item.weekday_locked = weekday !== null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add webui/src/store/bangumi.ts
|
||||
git commit -m "feat(store): add setWeekday action for calendar drag-and-drop"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Final integration test and type check
|
||||
|
||||
**Step 1: Run type check**
|
||||
|
||||
```bash
|
||||
cd webui && pnpm test:build
|
||||
```
|
||||
|
||||
**Step 2: Run backend tests**
|
||||
|
||||
```bash
|
||||
cd backend && uv run pytest src/test/ -v -k "not test_mcp"
|
||||
```
|
||||
|
||||
**Step 3: Run lint**
|
||||
|
||||
```bash
|
||||
cd webui && pnpm lint
|
||||
cd backend && uv run ruff check src
|
||||
```
|
||||
|
||||
**Step 4: Fix any issues and commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: resolve type and lint issues from calendar drag-and-drop feature"
|
||||
```
|
||||
@@ -29,11 +29,11 @@
|
||||
"vue": "^3.5.8",
|
||||
"vue-i18n": "^9.14.0",
|
||||
"vue-inline-svg": "^3.1.4",
|
||||
"vue-router": "^4.4.5"
|
||||
"vue-router": "^4.4.5",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^0.38.6",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@icon-park/vue-next": "^1.4.2",
|
||||
"@intlify/unplugin-vue-i18n": "^0.11.0",
|
||||
"@storybook/addon-essentials": "^7.6.20",
|
||||
@@ -50,9 +50,11 @@
|
||||
"@vitejs/plugin-vue": "^4.6.2",
|
||||
"@vitejs/plugin-vue-jsx": "^3.1.0",
|
||||
"@vue/runtime-dom": "^3.5.8",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"eslint-plugin-storybook": "^0.6.15",
|
||||
"happy-dom": "^12.10.3",
|
||||
"husky": "^8.0.3",
|
||||
"prettier": "^2.8.8",
|
||||
"radash": "^12.1.0",
|
||||
@@ -66,7 +68,6 @@
|
||||
"vite": "^4.5.5",
|
||||
"vite-plugin-pwa": "^0.16.7",
|
||||
"vitest": "^0.30.1",
|
||||
"happy-dom": "^12.10.3",
|
||||
"vue-tsc": "^1.8.27"
|
||||
}
|
||||
}
|
||||
|
||||
26
webui/pnpm-lock.yaml
generated
26
webui/pnpm-lock.yaml
generated
@@ -38,6 +38,9 @@ importers:
|
||||
vue-router:
|
||||
specifier: ^4.4.5
|
||||
version: 4.4.5(vue@3.5.8(typescript@4.9.5))
|
||||
vuedraggable:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0(vue@3.5.8(typescript@4.9.5))
|
||||
devDependencies:
|
||||
'@antfu/eslint-config':
|
||||
specifier: ^0.38.6
|
||||
@@ -5170,6 +5173,9 @@ packages:
|
||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
sortablejs@1.14.0:
|
||||
resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -5791,8 +5797,8 @@ packages:
|
||||
vue-component-type-helpers@2.1.6:
|
||||
resolution: {integrity: sha512-ng11B8B/ZADUMMOsRbqv0arc442q7lifSubD0v8oDXIFoMg/mXwAPUunrroIDkY+mcD0dHKccdaznSVp8EoX3w==}
|
||||
|
||||
vue-component-type-helpers@3.2.4:
|
||||
resolution: {integrity: sha512-05lR16HeZDcDpB23ku5b5f1fBOoHqFnMiKRr2CiEvbG5Ux4Yi0McmQBOET0dR0nxDXosxyVqv67q6CzS3AK8rw==}
|
||||
vue-component-type-helpers@3.2.5:
|
||||
resolution: {integrity: sha512-tkvNr+bU8+xD/onAThIe7CHFvOJ/BO6XCOrxMzeytJq40nTfpGDJuVjyCM8ccGZKfAbGk2YfuZyDMXM56qheZQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -5854,6 +5860,11 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
vuedraggable@4.1.0:
|
||||
resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==}
|
||||
peerDependencies:
|
||||
vue: ^3.0.1
|
||||
|
||||
vueuc@0.4.63:
|
||||
resolution: {integrity: sha512-QJT0z9yYWXdKpUq6f6IrAgJ83e34iTYMCVHjcAP8lCjldG0JzHnDfJYMpPWkNuLB5SdBZCbYGmYTKnTR+ff7CQ==}
|
||||
peerDependencies:
|
||||
@@ -8314,7 +8325,7 @@ snapshots:
|
||||
ts-dedent: 2.2.0
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.8(typescript@4.9.5)
|
||||
vue-component-type-helpers: 3.2.4
|
||||
vue-component-type-helpers: 3.2.5
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
@@ -12133,6 +12144,8 @@ snapshots:
|
||||
|
||||
slash@3.0.0: {}
|
||||
|
||||
sortablejs@1.14.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
@@ -12814,7 +12827,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@2.1.6: {}
|
||||
|
||||
vue-component-type-helpers@3.2.4: {}
|
||||
vue-component-type-helpers@3.2.5: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.8(typescript@4.9.5)):
|
||||
dependencies:
|
||||
@@ -12891,6 +12904,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 4.9.5
|
||||
|
||||
vuedraggable@4.1.0(vue@3.5.8(typescript@4.9.5)):
|
||||
dependencies:
|
||||
sortablejs: 1.14.0
|
||||
vue: 3.5.8(typescript@4.9.5)
|
||||
|
||||
vueuc@0.4.63(vue@3.5.8(typescript@4.9.5)):
|
||||
dependencies:
|
||||
'@css-render/vue3-ssr': 0.15.14(vue@3.5.8(typescript@4.9.5))
|
||||
|
||||
@@ -219,6 +219,19 @@ export const apiBangumi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 手动设置番剧的放送星期
|
||||
* @param bangumiId - bangumi 的 id
|
||||
* @param weekday - 0-6 for Mon-Sun, null to reset
|
||||
*/
|
||||
async setWeekday(bangumiId: number, weekday: number | null) {
|
||||
const { data } = await axios.patch<ApiSuccess>(
|
||||
`api/v1/bangumi/${bangumiId}/weekday`,
|
||||
{ weekday }
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取所有需要检查偏移量的 bangumi
|
||||
*/
|
||||
|
||||
@@ -79,6 +79,14 @@
|
||||
"type": "Proxy Type",
|
||||
"username": "Username"
|
||||
},
|
||||
"security_set": {
|
||||
"title": "Security",
|
||||
"hint": "Login whitelist: empty = allow all IPs. MCP whitelist: empty = deny all access.",
|
||||
"login_whitelist": "Login IP Whitelist",
|
||||
"login_tokens": "Login API Tokens",
|
||||
"mcp_whitelist": "MCP IP Whitelist",
|
||||
"mcp_tokens": "MCP API Tokens"
|
||||
},
|
||||
"search_provider_set": {
|
||||
"title": "Search Provider",
|
||||
"add_new": "Add Provider",
|
||||
@@ -287,7 +295,11 @@
|
||||
"empty_state": {
|
||||
"title": "No Schedule Yet",
|
||||
"subtitle": "Click refresh to fetch this season's broadcast data"
|
||||
}
|
||||
},
|
||||
"drag_hint": "Drag to assign weekday",
|
||||
"pinned": "Manually assigned",
|
||||
"unpin": "Reset to unknown",
|
||||
"drop_here": "Drop here"
|
||||
},
|
||||
"setup": {
|
||||
"welcome": {
|
||||
|
||||
@@ -79,6 +79,14 @@
|
||||
"type": "类型",
|
||||
"username": "用户名"
|
||||
},
|
||||
"security_set": {
|
||||
"title": "安全设置",
|
||||
"hint": "登录白名单:为空则允许所有 IP。MCP 白名单:为空则拒绝所有访问。",
|
||||
"login_whitelist": "登录 IP 白名单",
|
||||
"login_tokens": "登录 API 令牌",
|
||||
"mcp_whitelist": "MCP IP 白名单",
|
||||
"mcp_tokens": "MCP API 令牌"
|
||||
},
|
||||
"search_provider_set": {
|
||||
"title": "搜索源设置",
|
||||
"add_new": "添加搜索源",
|
||||
@@ -287,7 +295,11 @@
|
||||
"empty_state": {
|
||||
"title": "暂无放送表",
|
||||
"subtitle": "点击刷新按钮获取本季度放送数据"
|
||||
}
|
||||
},
|
||||
"drag_hint": "拖拽以设置放送日",
|
||||
"pinned": "手动设置",
|
||||
"unpin": "重置为未知",
|
||||
"drop_here": "拖放到此处"
|
||||
},
|
||||
"setup": {
|
||||
"welcome": {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { ErrorPicture, Refresh } from '@icon-park/vue-next';
|
||||
import { ErrorPicture, Pin, Refresh } from '@icon-park/vue-next';
|
||||
import draggable from 'vuedraggable';
|
||||
import type { BangumiRule } from '#/bangumi';
|
||||
|
||||
definePage({
|
||||
@@ -9,7 +10,7 @@ definePage({
|
||||
const { t } = useMyI18n();
|
||||
const posterSrc = (link: string | null | undefined) => resolvePosterUrl(link);
|
||||
const { bangumi } = storeToRefs(useBangumiStore());
|
||||
const { getAll, openEditPopup } = useBangumiStore();
|
||||
const { getAll, openEditPopup, setWeekday } = useBangumiStore();
|
||||
const { isMobile } = useBreakpointQuery();
|
||||
|
||||
const refreshing = ref(false);
|
||||
@@ -127,6 +128,36 @@ 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>
|
||||
@@ -165,6 +196,7 @@ function onRuleSelect(rule: BangumiRule) {
|
||||
class="calendar-column anim-slide-up"
|
||||
:class="{
|
||||
'calendar-column--today': isToday(index),
|
||||
'calendar-column--drop-active': isDragging,
|
||||
}"
|
||||
:style="{ '--delay': `${index * 0.05}s` }"
|
||||
>
|
||||
@@ -182,13 +214,103 @@ function onRuleSelect(rule: BangumiRule) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Anime cards (grouped) -->
|
||||
<div class="calendar-column-items">
|
||||
<div
|
||||
v-for="group in groupedBangumiByDay[key]"
|
||||
:key="group.key"
|
||||
class="calendar-card-wrapper"
|
||||
>
|
||||
<!-- Anime cards (grouped) - drop target -->
|
||||
<draggable
|
||||
:model-value="getDayGroups(key)"
|
||||
group="calendar"
|
||||
item-key="key"
|
||||
:sort="false"
|
||||
ghost-class="sortable-ghost"
|
||||
drag-class="sortable-drag"
|
||||
class="calendar-column-items"
|
||||
@change="onDropToDay(index, $event)"
|
||||
>
|
||||
<template #item="{ element: group }">
|
||||
<div class="calendar-card-wrapper">
|
||||
<div
|
||||
class="calendar-card"
|
||||
:class="{ 'calendar-card--pinned': group.primary.weekday_locked }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="`Edit ${group.primary.official_title}`"
|
||||
@click="onCardClick(group)"
|
||||
@keydown.enter="onCardClick(group)"
|
||||
>
|
||||
<div class="calendar-card-poster">
|
||||
<img
|
||||
v-if="group.primary.poster_link"
|
||||
:src="posterSrc(group.primary.poster_link)"
|
||||
:alt="group.primary.official_title"
|
||||
class="calendar-card-img"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-else class="calendar-card-placeholder">
|
||||
<ErrorPicture theme="outline" size="20" />
|
||||
</div>
|
||||
<div class="calendar-card-overlay">
|
||||
<div class="calendar-card-overlay-tags">
|
||||
<ab-tag :title="`S${group.primary.season}`" type="primary" />
|
||||
<ab-tag
|
||||
v-if="group.primary.group_name"
|
||||
:title="group.primary.group_name"
|
||||
type="primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="calendar-card-overlay-title">{{ group.primary.official_title }}</div>
|
||||
</div>
|
||||
<!-- Pin indicator for manually assigned -->
|
||||
<div v-if="group.primary.weekday_locked" class="calendar-card-pin">
|
||||
<Pin theme="filled" size="12" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Unpin button -->
|
||||
<button
|
||||
v-if="group.primary.weekday_locked"
|
||||
class="calendar-unpin-btn"
|
||||
:title="$t('calendar.unpin')"
|
||||
@click="onUnpin(group, $event)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div v-if="group.rules.length > 1" class="group-badge">
|
||||
{{ group.rules.length }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div v-if="getDayGroups(key).length === 0" class="calendar-empty-day">
|
||||
{{ isDragging ? $t('calendar.drop_here') : $t('calendar.empty') }}
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unknown air day section (draggable source) -->
|
||||
<div
|
||||
v-if="unknownGroups.length > 0"
|
||||
class="calendar-unknown-section anim-slide-up"
|
||||
:style="{ '--delay': '0.4s' }"
|
||||
>
|
||||
<div class="calendar-unknown-header">
|
||||
<span class="calendar-day-label">{{ getDayLabel('unknown') }}</span>
|
||||
<span class="calendar-drag-hint">{{ $t('calendar.drag_hint') }}</span>
|
||||
</div>
|
||||
<draggable
|
||||
v-model="unknownGroups"
|
||||
:group="{ name: 'calendar', pull: 'clone', put: false }"
|
||||
item-key="key"
|
||||
:sort="false"
|
||||
ghost-class="sortable-ghost"
|
||||
drag-class="sortable-drag"
|
||||
class="calendar-unknown-items"
|
||||
@start="isDragging = true"
|
||||
@end="isDragging = false"
|
||||
>
|
||||
<template #item="{ element: group }">
|
||||
<div class="calendar-card-wrapper">
|
||||
<div
|
||||
class="calendar-card"
|
||||
role="button"
|
||||
@@ -225,67 +347,8 @@ function onRuleSelect(rule: BangumiRule) {
|
||||
{{ group.rules.length }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty day -->
|
||||
<div v-if="groupedBangumiByDay[key].length === 0" class="calendar-empty-day">
|
||||
{{ $t('calendar.empty') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unknown air day section (separate from main grid) -->
|
||||
<div
|
||||
v-if="groupedBangumiByDay.unknown.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>
|
||||
</div>
|
||||
<div class="calendar-unknown-items">
|
||||
<div
|
||||
v-for="group in groupedBangumiByDay.unknown"
|
||||
:key="group.key"
|
||||
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>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -683,6 +746,80 @@ function onRuleSelect(rule: BangumiRule) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -82,6 +82,15 @@ export const useBangumiStore = defineStore('bangumi', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function setWeekday(bangumiId: number, weekday: number | null) {
|
||||
await apiBangumi.setWeekday(bangumiId, weekday);
|
||||
const item = bangumi.value.find((b) => b.id === bangumiId);
|
||||
if (item) {
|
||||
item.air_weekday = weekday;
|
||||
item.weekday_locked = weekday !== null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bangumi,
|
||||
showArchived,
|
||||
@@ -102,5 +111,6 @@ export const useBangumiStore = defineStore('bangumi', () => {
|
||||
refreshMetadata,
|
||||
openEditPopup,
|
||||
ruleManage,
|
||||
setWeekday,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -49,6 +49,7 @@ export const mockBangumiAPI: BangumiAPI = {
|
||||
deleted: false,
|
||||
archived: false,
|
||||
air_weekday: 3,
|
||||
weekday_locked: false,
|
||||
needs_review: false,
|
||||
needs_review_reason: null,
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface BangumiRule {
|
||||
title_raw: string;
|
||||
year: string | null;
|
||||
air_weekday: number | null; // 0=Mon, 1=Tue, ..., 6=Sun, null=Unknown
|
||||
weekday_locked: boolean;
|
||||
needs_review: boolean;
|
||||
needs_review_reason: string | null;
|
||||
}
|
||||
@@ -63,6 +64,7 @@ export const ruleTemplate: BangumiRule = {
|
||||
title_raw: '',
|
||||
year: null,
|
||||
air_weekday: null,
|
||||
weekday_locked: false,
|
||||
needs_review: false,
|
||||
needs_review_reason: null,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user