Compare commits

...

45 Commits

Author SHA1 Message Date
InfinityPacer
ff871e7049 feat(login): add hidden logo lab access (#542) 2026-07-17 16:47:11 +08:00
InfinityPacer
20f80d0f1f test(subscribe): cover subscription calendar (#541) 2026-07-17 16:14:24 +08:00
jxxghp
36bb99cf06 Improve plugin market filter menu layout 2026-07-17 15:45:41 +08:00
jxxghp
24c2e3b49c Fix login layout on short mobile screens 2026-07-17 14:47:19 +08:00
jxxghp
486a471c0c 更新 package.json 2026-07-17 14:18:55 +08:00
InfinityPacer
ce32da9176 fix(theme): preserve logo detail across theme colors (#540) 2026-07-17 14:18:35 +08:00
InfinityPacer
39dd1b9e00 feat: 让应用 Logo 跟随主题色 (#538)
* fix(login): show form with logo

* feat(theme): sync logos with primary color

* test(theme): cover favicon color sync

* chore(login): remove unused metal logo component

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-07-17 13:01:25 +08:00
InfinityPacer
807711cae8 test(subscribe): cover card actions (#539) 2026-07-17 12:57:28 +08:00
jxxghp
00feeb86ca Fix agent assistant layering and login card visibility 2026-07-17 12:53:01 +08:00
InfinityPacer
4378c4843b feat: 增强登录页交互式 3D Logo (#536)
* feat(login): add interactive optical logo lab

* feat(login): add prismatic logo component

* fix(login): refine optical logo colors and rendering

* feat(login): add matte logo material and sizing

* feat(login): add logo palette toggle

* feat(login): add configurable logo presets

* fix(login): ignore lab route after authentication

* fix(login): keep prism idle motion
2026-07-17 12:33:26 +08:00
jxxghp
5676e0b9b1 feat: 优化识别结果展示,调整图标和标题样式 2026-07-17 09:51:54 +08:00
jxxghp
38a841dc9c feat: 添加自定义识别词功能,支持保存和管理识别规则 2026-07-17 09:47:15 +08:00
jxxghp
6e92673438 feat: 更新金属材质设置,优化视觉效果和层次感 2026-07-17 09:04:21 +08:00
jxxghp
5e2585b210 feat: 添加媒体详情查看功能和相关计算属性 2026-07-17 08:42:22 +08:00
InfinityPacer
c3c323b018 test(subscribe): cover list management workflows (#535) 2026-07-17 06:53:56 +08:00
jxxghp
5390af50ac feat: 重新设计捷径测试结果页面 (#534) 2026-07-16 21:41:10 +08:00
jxxghp
c0f58c7df4 feat: 更新3D金属Logo材质和光照设置,提升视觉效果 2026-07-16 20:27:47 +08:00
kuke2733
9bdf102f7a fix(subscribe): improve mobile files dialog layout (#533) 2026-07-16 19:26:29 +08:00
jxxghp
52df6bef38 fix: adjust logo bevel thickness and depth for improved appearance 2026-07-16 17:40:09 +08:00
kuke2733
c5da08e388 feat(subscribe): “我的订阅”中“文件统计弹窗”支持媒体服务器入库展示 (#531) 2026-07-16 17:39:33 +08:00
InfinityPacer
8a069db55e test(subscribe): cover subscription management flows (#530) 2026-07-16 17:37:43 +08:00
jxxghp
9900042bc3 feat: enhance 3D MetalLogo component with rounded shapes and improved materials 2026-07-16 17:32:00 +08:00
jxxghp
e4f02a326f feat: add 3D MetalLogo component and update dependencies 2026-07-16 15:34:44 +08:00
InfinityPacer
199693abf8 test: introduce frontend unit testing foundation (#529) 2026-07-16 14:53:18 +08:00
jxxghp
32b0c1c00c fix: widen plugin market mobile filters 2026-07-16 12:44:20 +08:00
InfinityPacer
e4d0afc380 ci(pr-agent): use shared review runner (#528) 2026-07-16 06:26:25 +08:00
InfinityPacer
8e89078055 fix(dashboard): refine responsive recommendation controls (#527) 2026-07-16 06:25:50 +08:00
jxxghp
2e0a582d22 fix: scope mobile form layout styles 2026-07-15 13:53:13 +08:00
jxxghp
9c2c4d9fc4 Improve responsive input layouts and mobile control widths 2026-07-15 13:44:12 +08:00
jxxghp
4e0191ba5c fix(calendar): deepen today highlight 2026-07-15 12:41:33 +08:00
jxxghp
9247850411 Update the rule test empty-state icon 2026-07-15 12:38:18 +08:00
jxxghp
29b4854dae fix: allow wrapping mobile multi-select values 2026-07-15 12:06:01 +08:00
jxxghp
338af516be fix: stabilize mobile shortcut validation styles 2026-07-15 10:57:54 +08:00
jxxghp
ea892a31c0 feat: enhance recognition and rule testing features
- Updated maxWidth for NameTestView and RuleTestView dialogs to improve layout.
- Added new localization strings for recognition and rule testing in English, Simplified Chinese, and Traditional Chinese.
- Refactored NameTestView to include detailed analysis steps and summary items for better user feedback.
- Improved error handling and loading states in both NameTestView and RuleTestView.
- Redesigned form layouts for NameTestView and RuleTestView to enhance user experience and accessibility.
- Added computed properties for better data handling and display in the recognition and rule testing processes.
2026-07-15 09:11:27 +08:00
jxxghp
c0f3814560 Add responsive input separators for stacked mobile fields 2026-07-15 08:14:23 +08:00
jxxghp
15f984ba44 fix: ellipsize mobile input values 2026-07-14 22:21:49 +08:00
jxxghp
362c601c7e fix: remove mobile input dividers 2026-07-14 22:06:17 +08:00
jxxghp
4f0204d465 fix: normalize mobile input dividers 2026-07-14 22:03:07 +08:00
jxxghp
d92e79169c Reduce vertical spacing for responsive input rows 2026-07-14 21:53:16 +08:00
jxxghp
563849aab3 fix: hide trailing mobile input divider 2026-07-14 21:36:55 +08:00
jxxghp
b0a27c6405 fix: optimize mobile input layout 2026-07-14 21:25:27 +08:00
jxxghp
b664e0b447 更新 package.json 2026-07-14 13:54:01 +08:00
jxxghp
d625d1ad46 fix: 完善移动端表单响应式布局 2026-07-14 13:51:50 +08:00
jxxghp
cec9563963 feat: 添加响应式输入适配器,优化小屏幕录入控件布局 2026-07-14 12:45:11 +08:00
InfinityPacer
2df3ce1b86 ci(pr-agent): publish native code reviews (#526) 2026-07-13 18:41:32 +08:00
72 changed files with 13309 additions and 1201 deletions

1
.env.test Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=/api/v1/

View File

@@ -1,9 +1,8 @@
name: PR Agent
name: PR-Agent
on:
pull_request_target:
# PR-Agent 通过 base repo 上下文读取 PR diff 并发布 Review,不 checkout 或执行 PR 分支代码。
# pull_request_target 允许 fork PR 使用仓库 secrets因此 workflow 只运行固定 digest 的 PR-Agent 容器。
# Fork 审查需要目标仓库凭据;该 job 仅通过 GitHub API 读取 PR 内容,不 checkout 或执行 PR 分支代码。
types:
- opened
- reopened
@@ -11,23 +10,17 @@ on:
- review_requested
- synchronize
issue_comment:
# 手动命令如 "/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。
# issue_comment 同时覆盖普通 issue因此 job 里还会再判断是否属于 PR。
types:
- created
- edited
permissions:
# 读取仓库内容和 PR diff。
contents: read
# 更新 PR 描述、发布 PR Review 或修改 PR 相关元数据。
pull-requests: write
# PR 评论在 GitHub API 中属于 issue comments手动命令和总结评论需要该权限。
issues: write
jobs:
pr-agent:
name: PR-Agent inline review
if: >-
github.event.sender.type != 'Bot' &&
(
@@ -35,549 +28,25 @@ jobs:
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association) &&
(
github.event.comment.body == '/describe' ||
startsWith(github.event.comment.body, '/describe ') ||
github.event.comment.body == '/improve' ||
startsWith(github.event.comment.body, '/improve ') ||
github.event.comment.body == '/ask' ||
startsWith(github.event.comment.body, '/ask ')
)
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association)
)
)
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Detect PR review language
id: pr_language
- name: Run PR Review
uses: docker://ghcr.io/infinitypacer/pr-review-runner:latest
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
run: |
set -euo pipefail
pr_info="$(mktemp)"
gh api "repos/${REPO}/pulls/${PR_NUMBER}" > "${pr_info}"
python3 - "${pr_info}" >> "${GITHUB_OUTPUT}" <<'PY'
import json
import re
import sys
from pathlib import Path
pr = json.loads(Path(sys.argv[1]).read_text())
title = pr.get("title") or ""
body = pr.get("body") or ""
labels = {item.get("name", "") for item in pr.get("labels") or []}
skip_pr_agent = "true" if "skip pr-agent" in labels or re.search(r"^(?:\[Auto\]|Auto)", title) else "false"
head_sha = pr.get("head", {}).get("sha") or ""
body = re.sub(
r"\n*##\s+(PR-Agent\s+摘要|PR-Agent Summary)\s*\n\s*"
r"<!-- pr-agent-summary:start -->.*?<!-- pr-agent-summary:end -->",
" ",
body,
flags=re.IGNORECASE | re.DOTALL,
)
body = re.sub(
r"<!-- pr-agent-summary:start -->.*?<!-- pr-agent-summary:end -->",
" ",
body,
flags=re.DOTALL,
)
body = re.sub(r"```.*?```", " ", body, flags=re.DOTALL)
text = f"{title}\n{body}"
cjk_count = len(re.findall(r"[\u4e00-\u9fff]", text))
latin_words = len(re.findall(r"\b[A-Za-z][A-Za-z]{2,}\b", text))
if cjk_count >= 4:
response_language = "zh-CN"
summary_heading = "PR-Agent 摘要"
summary_language = "中文"
elif latin_words >= 8:
response_language = "en-US"
summary_heading = "PR-Agent Summary"
summary_language = "English"
else:
response_language = "zh-CN"
summary_heading = "PR-Agent 摘要"
summary_language = "中文"
print(f"response_language={response_language}")
print(f"summary_heading={summary_heading}")
print(f"summary_language={summary_language}")
print(f"skip_pr_agent={skip_pr_agent}")
print(f"head_sha={head_sha}")
PY
- name: Prepare PR-Agent description markers
id: prepare_description
if: >-
steps.pr_language.outputs.skip_pr_agent != 'true' &&
(
github.event_name == 'pull_request_target' ||
(
github.event_name == 'issue_comment' &&
(
github.event.comment.body == '/describe' ||
startsWith(github.event.comment.body, '/describe ')
)
)
)
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
SUMMARY_HEADING: ${{ steps.pr_language.outputs.summary_heading }}
run: |
set -euo pipefail
current_body="$(mktemp)"
next_body="$(mktemp)"
payload="$(mktemp)"
body_backup="${RUNNER_TEMP}/pr-agent-body-before-describe.md"
placeholder_body="${RUNNER_TEMP}/pr-agent-body-with-placeholder.md"
gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.body // ""' > "${current_body}"
cp "${current_body}" "${body_backup}"
python3 - "${current_body}" "${next_body}" <<'PY'
import os
import re
import sys
from pathlib import Path
current_path = Path(sys.argv[1])
next_path = Path(sys.argv[2])
body = current_path.read_text()
start = "<!-- pr-agent-summary:start -->"
end = "<!-- pr-agent-summary:end -->"
placeholder = "pr_agent:summary"
summary_heading = os.environ.get("SUMMARY_HEADING") or "PR-Agent 摘要"
agent_block = f"## {summary_heading}\n\n{start}\n{placeholder}\n{end}\n"
body = re.sub(
r"(?im)^##\s+(PR-Agent\s+摘要|PR-Agent Summary)\s*\n\s*(?=<!-- pr-agent-summary:start -->)",
f"## {summary_heading}\n\n",
body,
)
start_index = body.find(start)
end_index = body.find(end)
if start_index >= 0 and end_index > start_index:
next_body = body[: start_index + len(start)] + f"\n{placeholder}\n" + body[end_index:]
else:
separator = "\n\n" if body.strip() else ""
next_body = body.rstrip() + separator + agent_block
next_path.write_text(next_body)
PY
cp "${next_body}" "${placeholder_body}"
body_changed=false
if ! cmp -s "${current_body}" "${next_body}"; then
python3 - "${next_body}" "${payload}" <<'PY'
import json
import sys
from pathlib import Path
body = Path(sys.argv[1]).read_text()
Path(sys.argv[2]).write_text(json.dumps({"body": body}, ensure_ascii=False))
PY
gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null
body_changed=true
fi
echo "body_changed=${body_changed}" >> "${GITHUB_OUTPUT}"
- name: Snapshot PR-Agent inline comments
id: inline_state_before
if: >-
steps.pr_language.outputs.skip_pr_agent != 'true' &&
(
github.event_name == 'pull_request_target' ||
(
github.event_name == 'issue_comment' &&
(
github.event.comment.body == '/improve' ||
startsWith(github.event.comment.body, '/improve ')
)
)
)
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
run: |
set -euo pipefail
inline_ids="$(gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/comments?per_page=100" --jq '.[] | select(.user.login == "github-actions[bot]") | .id' | jq -sc '.')"
inline_ids_b64="$(printf '%s' "${inline_ids}" | base64 -w0)"
echo "inline_ids_b64=${inline_ids_b64}" >> "${GITHUB_OUTPUT}"
- name: Run PR-Agent
id: pragent
if: steps.pr_language.outputs.skip_pr_agent != 'true'
# 使用版本号加 digest 固定容器构建,避免 tag 被重推后改变运行内容。
uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825
env:
# PR-Agent 使用该 token 读取 PR 元数据并发布评论。
GITHUB_TOKEN: ${{ github.token }}
# 仓库设置中添加的 SecretSettings -> Secrets and variables -> Actions。
# 该 key 只传给 PR-Agent 运行时,不写入仓库。
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
# 仓库设置中添加的 Secret。OpenAI 兼容服务通常需要填写以 "/v1" 结尾的 API 根地址。
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
# 模型、输出语言和大 diff 处理策略。
config.model: "gpt-5.5"
config.fallback_models: '["gpt-5.4"]'
config.reasoning_effort: "xhigh"
config.ai_timeout: "900"
config.response_language: ${{ steps.pr_language.outputs.response_language }}
config.large_patch_policy: "clip"
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
config.ignore_pr_labels: '["skip pr-agent"]'
# PR 初次进入评审或后续 push 时,更新 PR 摘要并发布 GitHub Review 行内建议。
github_action_config.auto_review: "false"
github_action_config.auto_describe: "true"
github_action_config.auto_improve: "true"
# synchronize 由 push_commands 单独处理;每次 push 更新摘要和行内 Review旧行评由 GitHub 标记 outdated。
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested"]'
github_action_config.handle_push_trigger: "true"
github_action_config.push_commands: '["/describe", "/improve"]'
# 保留 action outputs便于后续 workflow 编排或排查。
github_action_config.enable_output: "true"
# /describe 行为控制;只更新 PR body 中的 PR-Agent 摘要占位符。
pr_description.generate_ai_title: "false"
pr_description.publish_labels: "false"
pr_description.publish_description_as_comment: "false"
pr_description.publish_description_as_comment_persistent: "false"
pr_description.enable_pr_diagram: "false"
pr_description.enable_pr_type: "false"
pr_description.enable_help_text: "false"
pr_description.enable_help_comment: "false"
pr_description.enable_semantic_files_types: "false"
pr_description.collapsible_file_list: "adaptive"
pr_description.add_original_user_description: "true"
pr_description.use_description_markers: "true"
pr_description.final_update_message: "false"
pr_description.extra_instructions: |
Match the configured response language.
Generate a moderately detailed PR summary covering the change goal, key implementation details, configuration or compatibility impact, tests, and notable risks.
Use 2-4 bullets for small PRs; use 4-8 bullets for feature or multi-file PRs.
Avoid low-value file lists and local command transcripts.
# /improve 以 GitHub 内联建议呈现,便于像正式 review discussion 一样逐条处理。
pr_code_suggestions.extra_instructions: |
Match the configured response language.
Only provide substantive issues that maintainers should address; avoid style-only, preference-only, or low-value suggestions.
For prioritized issues, start the suggestion body with one of these Markdown prefixes: 🔴 **High Risk**:, 🟡 **Medium Risk**:, or 🔵 **Low Risk**:.
pr_code_suggestions.focus_only_on_problems: "true"
pr_code_suggestions.suggestions_score_threshold: "3"
pr_code_suggestions.num_code_suggestions_per_chunk: "3"
pr_code_suggestions.commitable_code_suggestions: "true"
pr_code_suggestions.publish_output_no_suggestions: "false"
pr_questions.use_conversation_history: "true"
# 可选成本和噪音控制:
# github_action_config.auto_improve: "true"
# config.verbosity_level: "1"
- name: Publish PR-Agent code review summary
if: >-
steps.pr_language.outputs.skip_pr_agent != 'true' &&
(
github.event_name == 'pull_request_target' ||
(
github.event_name == 'issue_comment' &&
(
github.event.comment.body == '/improve' ||
startsWith(github.event.comment.body, '/improve ')
)
)
)
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
BEFORE_INLINE_IDS_B64: ${{ steps.inline_state_before.outputs.inline_ids_b64 }}
RUN_HEAD_SHA: ${{ steps.pr_language.outputs.head_sha }}
SUMMARY_LANGUAGE: ${{ steps.pr_language.outputs.summary_language }}
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }}
SUMMARY_MODEL: gpt-5.5
run: |
set -euo pipefail
before_ids="$(printf '%s' "${BEFORE_INLINE_IDS_B64:-W10=}" | base64 -d)"
pr_info="$(mktemp)"
comments="$(mktemp)"
review_data="$(mktemp)"
payload="$(mktemp)"
gh api "repos/${REPO}/pulls/${PR_NUMBER}" > "${pr_info}"
CURRENT_HEAD_SHA="$(jq -r '.head.sha' "${pr_info}")"
HEAD_SHA="${RUN_HEAD_SHA:-${CURRENT_HEAD_SHA}}"
if [ "${CURRENT_HEAD_SHA}" != "${HEAD_SHA}" ]; then
echo "PR head changed from ${HEAD_SHA} to ${CURRENT_HEAD_SHA}; skip stale code review summary."
exit 0
fi
short_sha="${HEAD_SHA:0:7}"
pr_url="https://github.com/${REPO}/pull/${PR_NUMBER}"
commit_url="https://github.com/${REPO}/commit/${HEAD_SHA}"
gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/comments?per_page=100" --jq '.[] | @json' > "${comments}"
python3 - "${before_ids}" "${comments}" "${review_data}" "${HEAD_SHA}" "${pr_url}" "${commit_url}" "${short_sha}" <<'PY'
import json
import sys
from pathlib import Path
before_ids = set(json.loads(sys.argv[1] or "[]"))
comments_path = Path(sys.argv[2])
output_path = Path(sys.argv[3])
head_sha = sys.argv[4]
pr_url = sys.argv[5]
commit_url = sys.argv[6]
short_sha = sys.argv[7]
comments = []
for line in comments_path.read_text().splitlines():
if line.strip():
comments.append(json.loads(line))
new_comments = [
item for item in comments
if item.get("user", {}).get("login") == "github-actions[bot]"
and item.get("id") not in before_ids
and item.get("commit_id") == head_sha
]
suggestions = []
for item in new_comments:
body = (item.get("body") or "").strip()
first_line = next((line.strip() for line in body.splitlines() if line.strip()), "")
suggestions.append({
"path": item.get("path"),
"line": item.get("line") or item.get("start_line"),
"url": item.get("html_url"),
"summary": first_line[:500],
"body": body[:1500],
})
output_path.write_text(json.dumps({
"pr_url": pr_url,
"commit_url": commit_url,
"short_sha": short_sha,
"suggestions": suggestions,
}, ensure_ascii=False))
PY
python3 - "${review_data}" "${payload}" <<'PY'
import json
import os
import textwrap
import urllib.error
import urllib.request
from pathlib import Path
review_data = json.loads(Path(os.sys.argv[1]).read_text())
payload_path = Path(os.sys.argv[2])
suggestions = review_data["suggestions"]
marker = "<!-- pr-agent-code-review-summary -->"
summary_language = os.environ.get("SUMMARY_LANGUAGE") or "中文"
use_chinese = summary_language != "English"
def fallback_summary() -> str:
if not suggestions:
if use_chinese:
return "已审查本次变更,未发现需要进一步反馈或调整的问题。"
return "Reviewed this change and found no further feedback or required adjustments."
if use_chinese:
lines = [f"本轮代码审查新增 {len(suggestions)} 条行内建议,建议优先查看以下位置:"]
else:
noun = "suggestion" if len(suggestions) == 1 else "suggestions"
lines = [f"This review added {len(suggestions)} inline {noun}. Consider reviewing these locations first:"]
for item in suggestions[:5]:
location = f"{item.get('path')}:{item.get('line')}" if item.get("line") else str(item.get("path"))
summary = item.get("summary") or "查看行内建议"
url = item.get("url")
if use_chinese:
lines.append(f"- [{location}]({url}){summary}" if url else f"- {location}{summary}")
else:
lines.append(f"- [{location}]({url}): {summary}" if url else f"- {location}: {summary}")
if len(suggestions) > 5:
if use_chinese:
lines.append(f"- 其余 {len(suggestions) - 5} 条请在 Files changed 的行内评论中查看。")
else:
lines.append(f"- Review the remaining {len(suggestions) - 5} inline comments in Files changed.")
return "\n".join(lines)
def llm_summary() -> str | None:
if not suggestions:
return None
api_base = (os.environ.get("OPENAI_API_BASE") or "").rstrip("/")
api_key = os.environ.get("OPENAI_KEY") or ""
model = os.environ.get("SUMMARY_MODEL") or "gpt-5.5"
if not api_base or not api_key:
return None
if use_chinese:
task = (
"你是独立的代码审查摘要助手。只基于本轮已经发布的行内审查意见做简短汇总,"
"不新增审查结论,不替维护者判断 PR 是否可以合并。请用中文 Markdown 输出:"
"第一段说明本轮审查已完成,并已在行内留下需要关注的建议;如有建议,"
"概括 1-3 个主要关注点,使用“建议关注”“可能影响”“可优先查看”等中立表述。"
"不要输出表格,不要复述所有文件,不要使用“可以合并”“不建议合并”“阻塞合并”"
"“先处理后再合入”等合并裁决措辞。"
)
system_prompt = "你是严谨、中立的代码审查摘要助手。输出中文 Markdown简洁自然。"
else:
task = (
"You are an independent code review summarizer. Summarize only the inline review comments "
"already posted in this run; do not add new review conclusions or decide whether the PR should merge. "
"Write concise Markdown in English. Start by noting that review completed and inline suggestions "
"were left; then summarize 1-3 main points using neutral phrasing such as \"consider\", "
"\"may affect\", or \"worth reviewing\". Do not output tables, list every file, or use merge-gate "
"wording such as \"ready to merge\", \"do not merge\", \"blocks merging\", or \"must be fixed before merge\"."
)
system_prompt = "You are a rigorous, neutral code review summarizer. Write concise English Markdown."
user_content = {
"task": task,
"suggestions": suggestions[:10],
}
request_body = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": json.dumps(user_content, ensure_ascii=False)},
],
"temperature": 0.2,
}
request = urllib.request.Request(
f"{api_base}/chat/completions",
data=json.dumps(request_body).encode("utf-8"),
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
data = json.loads(response.read().decode("utf-8"))
content = data["choices"][0]["message"]["content"].strip()
return content or None
except (KeyError, TimeoutError, urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError):
return None
summary = llm_summary() or fallback_summary()
commit_label = "审查提交:" if use_chinese else "Reviewed commit:"
body = textwrap.dedent(f"""\
{marker}
## Code Review
{summary}
{commit_label} [{review_data["short_sha"]}]({review_data["commit_url"]})
""")
payload_path.write_text(json.dumps({"body": body}, ensure_ascii=False))
PY
new_comment_id="$(gh api --method POST "repos/${REPO}/issues/${PR_NUMBER}/comments" --input "${payload}" --jq '.id')"
comment_ids="$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments?per_page=100" --jq ".[] | select(.id != ${new_comment_id} and .user.login == \"github-actions[bot]\" and ((.body | startswith(\"<!-- pr-agent-code-review-summary -->\")) or (.body | startswith(\"<!-- pr-agent-update-notification -->\")))) | .id")"
if [ -z "${comment_ids}" ]; then
echo "No previous PR-Agent code review summary to clean."
exit 0
fi
while IFS= read -r comment_id; do
[ -z "${comment_id}" ] && continue
gh api --method DELETE "repos/${REPO}/issues/comments/${comment_id}" >/dev/null
done <<< "${comment_ids}"
- name: Restore PR-Agent description markers on failure
if: >-
failure() &&
steps.prepare_description.outputs.body_changed == 'true' &&
steps.pr_language.outputs.skip_pr_agent != 'true'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
run: |
set -euo pipefail
body_backup="${RUNNER_TEMP}/pr-agent-body-before-describe.md"
placeholder_body="${RUNNER_TEMP}/pr-agent-body-with-placeholder.md"
current_body="$(mktemp)"
payload="$(mktemp)"
if [ ! -s "${body_backup}" ] || [ ! -s "${placeholder_body}" ]; then
echo "No PR body backup found."
exit 0
fi
gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.body // ""' > "${current_body}"
python3 - "${body_backup}" "${placeholder_body}" "${current_body}" "${payload}" <<'PY'
import json
import re
import sys
from pathlib import Path
backup_body = Path(sys.argv[1]).read_text()
placeholder_body = Path(sys.argv[2]).read_text()
current_body = Path(sys.argv[3]).read_text()
payload_path = Path(sys.argv[4])
start = "<!-- pr-agent-summary:start -->"
end = "<!-- pr-agent-summary:end -->"
heading_re = re.compile(r"(?im)^##\s+(PR-Agent\s+摘要|PR-Agent Summary)\s*\n\s*")
def find_block(body: str) -> tuple[int, int] | None:
start_index = body.find(start)
end_index = body.find(end)
if start_index < 0 or end_index <= start_index:
return None
end_index += len(end)
heading_start = start_index
prefix = body[:start_index]
matches = list(heading_re.finditer(prefix))
if matches:
last = matches[-1]
if prefix[last.end():].strip() == "":
heading_start = last.start()
return heading_start, end_index
current_block = find_block(current_body)
placeholder_block = find_block(placeholder_body)
backup_block = find_block(backup_body)
if not current_block or not placeholder_block:
print("No PR-Agent summary block to restore.")
raise SystemExit(0)
current_section = current_body[current_block[0]:current_block[1]]
placeholder_section = placeholder_body[placeholder_block[0]:placeholder_block[1]]
if "pr_agent:summary" not in current_section:
print("No visible PR-Agent summary placeholder to restore.")
raise SystemExit(0)
if current_section != placeholder_section:
print("Current PR-Agent summary block changed; skip restore.")
raise SystemExit(0)
restored_section = backup_body[backup_block[0]:backup_block[1]] if backup_block else ""
next_body = current_body[:current_block[0]] + restored_section + current_body[current_block[1]:]
next_body = re.sub(r"\n{4,}", "\n\n\n", next_body).rstrip() + "\n"
payload_path.write_text(json.dumps({"body": next_body}, ensure_ascii=False))
PY
if [ -s "${payload}" ]; then
gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null
fi
PRR_AUTO_REVIEW_SCOPE: all
PRR_ALLOWED_ASSOCIATIONS: '["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'
PRR_DISABLED_COMMANDS: '["/improve"]'
PRR_SKIP_LABEL: skip pr-agent
PRR_SKIP_TITLE_PATTERN: '^(?:\[Auto\]|Auto)'
config.response_language: zh-CN

37
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Frontend Tests
on:
pull_request:
branches:
- v2
permissions:
contents: read
concurrency:
group: frontend-tests-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
unit-tests:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: '24'
cache: yarn
cache-dependency-path: yarn.lock
- name: Install dependencies
run: yarn --frozen-lockfile
- name: Typecheck
run: yarn typecheck
- name: Unit tests with coverage
run: yarn test:coverage

1
.gitignore vendored
View File

@@ -12,6 +12,7 @@ node_modules
dist
dist-ssr
dev-dist
coverage
*.local
package-lock.json

View File

@@ -40,6 +40,15 @@ yarn dev
yarn build
```
### 单元测试
```sh
yarn test:run
yarn test:coverage
```
测试文件组织、共享测试设施、HTTP mock、覆盖率门禁和新增用例规范见[单元测试架构](docs/testing.md)。
### 静态运行
1. 使用 `nginx` 等Web服务器托管 `dist` 静态文件nginx配置参考 `public/nginx.conf`

94
docs/testing.md Normal file
View File

@@ -0,0 +1,94 @@
# 单元测试架构
MoviePilot-Frontend 使用 Vitest 运行单元测试和组件测试,使用 jsdom 提供 DOM 环境。测试代码参与 TypeScript 类型检查,但不作为生产构建入口。
## 测试类型
- 单元测试覆盖纯函数、store、composable、路由规则和独立模块的输入、输出及副作用。
- 组件测试挂载 Vue 组件或页面,覆盖 props、emits、用户交互、可见 DOM、Router、Pinia、HTTP 请求和生命周期清理。
- PWA、Service Worker、模块联邦远程入口、真实布局、拖拽和浏览器原生能力由真实浏览器验证不由 jsdom 测试单独证明。
## 目录结构
业务 spec 与源码共置在对应责任域的 `__tests__/` 目录中,文件名与被测源码保持一致并使用 `*.spec.ts`
```text
src/
├── pages/
│ ├── recommend.vue
│ └── __tests__/recommend.spec.ts
├── stores/
│ ├── auth.ts
│ └── __tests__/auth.spec.ts
├── utils/
│ ├── permission.ts
│ └── __tests__/permission.spec.ts
└── views/dashboard/
├── MediaRecommend.vue
└── __tests__/MediaRecommend.spec.ts
```
跨业务 spec 复用的测试设施位于 `tests/`
```text
tests/
├── setup.ts
└── support/
├── render.ts
├── factories/
└── msw/
├── server.ts
└── handlers/
```
- `tests/setup.ts` 注册 DOM matcher、MSW 生命周期、浏览器 API stub 和每例清理逻辑。
- `tests/support/render.ts` 提供带 Vuetify、i18n、Router 和 Pinia 的标准渲染入口。
- `tests/support/factories/` 按业务对象提供最小有效测试数据工厂。
- `tests/support/msw/handlers/` 按业务域定义 HTTP handler`server.ts` 只负责 MSW server 实例。
- spec 通过 `@tests/*` 访问共享测试设施,通过 `@/*` 访问生产源码。
## 工具职责
- Vitest 提供 runner、断言、mock、fake timers 和覆盖率执行入口。
- Vue Test Utils 用于 Vue 特有的 props、emits、slots 和局部组件控制。
- Testing Library、jest-dom 和 user-event 用于按角色、可访问名称和用户操作验证可见行为。
- MSW 在 HTTP 边界拦截真实 API 客户端请求。未声明请求会使测试失败,测试不得访问真实后端或外网。
- `@pinia/testing` 用于依赖 store 的组件测试store 自身使用真实 `createPinia()` 测试。
## 编写规范
- 一个 spec 对应一个主要源码文件;测试名称描述可观察行为或业务规则。
- 组件测试断言可见 DOM、emits、路由、请求和持久化结果不读取组件私有状态或私有方法。
- 纯逻辑优先直接调用依赖生命周期、provide 或 inject 的 composable 通过宿主组件挂载。
- HTTP handler 和 factory 按业务域拆分,不建立包含所有接口或所有数据字段的全局万能 mock。
- 只在与当前断言无关或无法由 jsdom 正确执行时 stub 子组件、浏览器能力或第三方重型组件。
- 每个用例保持独立不依赖文件执行顺序timer、mock、storage、DOM 和未完成请求由全局 setup 恢复。
- 不使用大面积快照或覆盖率占位用例。
## 新增测试
1. 在被测源码所在目录的 `__tests__/` 中创建同名 `*.spec.ts`
2. 纯函数、store 和无渲染模块直接使用 VitestVue 组件使用标准渲染入口。
3. 需要 HTTP 请求时,在 `tests/support/msw/handlers/<domain>.ts` 增加对应 handler。
4. 需要结构化业务数据时,在 `tests/support/factories/` 增加最小工厂。
5. 核心覆盖范围发生变化时,同步更新 `vite.config.ts``coverage.include`
6. 提交前运行测试、覆盖率、类型检查、lint 和生产构建。
## 配置边界
Vitest 只收集 `src/**/__tests__/**/*.spec.ts`。测试模式保留 Vue、Vue JSX、Vuetify、自动导入、自动组件和 i18n 插件,并禁用 PWA、模块联邦和 top-level-await 构建插件。
当前核心覆盖范围在 `vite.config.ts``coverage.include` 中显式维护。聚合门槛为 Lines、Statements、Functions 不低于 85%Branches 不低于 80%;每个显式核心文件的 Lines、Statements、Functions 不低于 80%Branches 不低于 75%。覆盖率报告写入 `coverage/`
## 命令与 CI
```sh
yarn test # watch 模式
yarn test:run # 单次运行
yarn test:coverage # 单次运行并检查覆盖率
yarn typecheck
yarn lint
yarn build
```
Pull Request 测试工作流使用 Node 24 LTS 和 frozen lockfile依次执行类型检查和覆盖率门禁。现有 lint 基线问题按仓库当前维护约定单独处理,新增测试代码不得引入新的 lint 错误。

View File

@@ -4,7 +4,7 @@
--safe-area-inset-bottom: env(safe-area-inset-bottom);
--safe-area-inset-top: env(safe-area-inset-top);
--initial-loader-bg: #0E1116;
--initial-loader-color: #9155FD;
--initial-loader-color: #8D51F9;
--initial-loader-height: 100svh;
--initial-loader-width: 100vw;
--initial-color-scheme: dark;
@@ -33,9 +33,9 @@
<meta name="referrer" content="no-referrer" />
<!-- PWA - 基础图标 -->
<link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="icon" type="image/png" href="/logo.png" sizes="any" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="64x64" />
<link rel="icon" type="image/png" href="/logo.png" sizes="192x192" />
<link id="theme-favicon" rel="icon" type="image/svg+xml" href="/logo.svg" sizes="any" />
<!-- iOS Safari PWA 优化 -->
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
@@ -171,6 +171,11 @@
display: block;
block-size: auto;
inline-size: 100%;
opacity: 0;
}
.loading-logo img[data-theme-ready='true'] {
opacity: 1;
}
.loading-footer {
@@ -273,11 +278,11 @@
}
#timeout-btn {
color: var(--initial-loader-color, #9155FD);
color: var(--initial-loader-color, #8D51F9);
text-decoration: none;
font-weight: bold;
margin-inline-start: 8px;
border-bottom: 1px solid var(--initial-loader-color, #9155FD);
border-bottom: 1px solid var(--initial-loader-color, #8D51F9);
}
</style>
@@ -303,7 +308,7 @@
const launchThemePalettes = {
light: {
background: '#F4F5FA',
primary: '#9155FD',
primary: '#8D51F9',
},
dark: {
background: '#0E1116',
@@ -311,7 +316,7 @@
},
purple: {
background: '#28243D',
primary: '#9155FD',
primary: '#8D51F9',
},
transparent: {
background: '#1C1C1C',
@@ -362,6 +367,194 @@
document.head.appendChild(meta)
}
let logoSvgSourcePromise
let pendingFaviconColor = '#8D51F9'
const themeLogoCacheKey = 'moviepilot-themed-logo-cache'
const sourceLogoPalette = {
'rgb(141,81,249)': 'primary',
'rgb(165,118,255)': 'light',
'rgb(211,187,255)': 'highlight',
'rgb(116,50,223)': 'dark',
'rgb(110,38,217)': 'darker',
'rgb(104,0,197)': 'deep',
'rgb(91,0,197)': 'deepest',
}
const sourceLogoRgb = {
primary: [141, 81, 249],
light: [165, 118, 255],
highlight: [211, 187, 255],
dark: [116, 50, 223],
darker: [110, 38, 217],
deep: [104, 0, 197],
deepest: [91, 0, 197],
}
function clampLogoChannel(value, min = 0, max = 1) {
return Math.min(max, Math.max(min, value))
}
function logoRgbToHsl([red, green, blue]) {
const r = red / 255
const g = green / 255
const b = blue / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const delta = max - min
const l = (max + min) / 2
if (delta === 0) return { h: 0, l, s: 0 }
const s = delta / (1 - Math.abs(2 * l - 1))
let h = 0
if (max === r) h = ((g - b) / delta) % 6
else if (max === g) h = (b - r) / delta + 2
else h = (r - g) / delta + 4
return { h: (h * 60 + 360) % 360, l, s }
}
function logoHslToRgb({ h, l, s }) {
const chroma = (1 - Math.abs(2 * l - 1)) * s
const segment = h / 60
const secondary = chroma * (1 - Math.abs((segment % 2) - 1))
let channels
if (segment < 1) channels = [chroma, secondary, 0]
else if (segment < 2) channels = [secondary, chroma, 0]
else if (segment < 3) channels = [0, chroma, secondary]
else if (segment < 4) channels = [0, secondary, chroma]
else if (segment < 5) channels = [secondary, 0, chroma]
else channels = [chroma, 0, secondary]
const offset = l - chroma / 2
const rgb = channels.map(channel => Math.round((channel + offset) * 255))
return `rgb(${rgb.join(',')})`
}
function shiftLogoTone(color, hueOffset, lightnessOffset, saturationScale = 1) {
return logoHslToRgb({
h: (color.h + hueOffset + 360) % 360,
l: clampLogoChannel(color.l + lightnessOffset, 0.08, 0.92),
s: clampLogoChannel(color.s * saturationScale),
})
}
function createLaunchLogoPalette(primaryColor) {
const normalized = primaryColor.slice(1)
const rgb = [0, 2, 4].map(offset => Number.parseInt(normalized.slice(offset, offset + 2), 16))
const hsl = logoRgbToHsl(rgb)
const sourcePrimaryHsl = logoRgbToHsl(sourceLogoRgb.primary)
const lightDirection = hsl.l >= 0.78 ? -1 : 1
const darkDirection = hsl.l <= 0.22 ? 1 : -1
return Object.fromEntries(
Object.entries(sourceLogoRgb).map(([key, sourceRgb]) => {
if (key === 'primary') return [key, `rgb(${rgb.join(',')})`]
const sourceHsl = logoRgbToHsl(sourceRgb)
const hueOffset = sourceHsl.h - sourcePrimaryHsl.h
const sourceLightnessDelta = sourceHsl.l - sourcePrimaryHsl.l
const lightnessDelta =
Math.abs(sourceLightnessDelta) * (sourceLightnessDelta >= 0 ? lightDirection : darkDirection)
const saturationScale = sourcePrimaryHsl.s ? sourceHsl.s / sourcePrimaryHsl.s : 1
return [key, shiftLogoTone(hsl, hueOffset, lightnessDelta, saturationScale)]
}),
)
}
function createThemedLogoDataUrl(svgSource, primaryColor) {
const palette = createLaunchLogoPalette(primaryColor)
const themedSvg = Object.entries(sourceLogoPalette).reduce(
(svg, [sourceColor, paletteKey]) => svg.replaceAll(sourceColor, palette[paletteKey]),
svgSource,
)
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(themedSvg)}`
}
function withLoadingLogo(callback) {
const loadingLogo = document.querySelector('.loading-logo img')
if (loadingLogo) {
callback(loadingLogo)
return
}
if (document.readyState !== 'loading') return
const observer = new MutationObserver(() => {
const nextLoadingLogo = document.querySelector('.loading-logo img')
if (!nextLoadingLogo) return
observer.disconnect()
callback(nextLoadingLogo)
})
observer.observe(document.documentElement, { childList: true, subtree: true })
}
function applyThemedLogoUrl(themedLogoUrl) {
const faviconLink = document.querySelector('#theme-favicon')
faviconLink?.setAttribute('type', 'image/svg+xml')
faviconLink?.setAttribute('href', themedLogoUrl)
withLoadingLogo(loadingLogo => {
const revealLogo = () => loadingLogo.setAttribute('data-theme-ready', 'true')
loadingLogo.addEventListener('load', revealLogo, { once: true })
loadingLogo.setAttribute('src', themedLogoUrl)
if (loadingLogo.complete) revealLogo()
})
}
function revealOriginalLoadingLogo() {
withLoadingLogo(loadingLogo => loadingLogo.setAttribute('data-theme-ready', 'true'))
}
// 启动层和 Tab 图标共享原始矢量结构,主题切换只替换色阶,不破坏分面和透明高光。
function syncThemeFavicon(primaryColor) {
if (!/^#[0-9a-f]{6}$/i.test(primaryColor)) return
pendingFaviconColor = primaryColor
try {
const cachedLogo = JSON.parse(localStorage.getItem(themeLogoCacheKey) || 'null')
if (cachedLogo?.color === primaryColor && typeof cachedLogo.url === 'string') applyThemedLogoUrl(cachedLogo.url)
} catch {
// 缓存异常不影响根据品牌源文件重新生成主题标识。
}
logoSvgSourcePromise ||= fetch('/logo.svg').then(response => {
if (!response.ok) throw new Error(`Logo SVG request failed: ${response.status}`)
return response.text()
})
logoSvgSourcePromise
.then(svgSource => {
if (primaryColor !== pendingFaviconColor) return
const themedLogoUrl = createThemedLogoDataUrl(svgSource, primaryColor)
applyThemedLogoUrl(themedLogoUrl)
try {
localStorage.setItem(themeLogoCacheKey, JSON.stringify({ color: primaryColor, url: themedLogoUrl }))
} catch {
// 存储空间不可用时仍保留当前页面内的主题标识。
}
})
.catch(() => {
// 原始 SVG 始终保留为无网络或解析异常时的可见回退。
revealOriginalLoadingLogo()
})
}
window.addEventListener('moviepilot-theme-primary-color-change', event => {
syncThemeFavicon(event.detail?.color)
})
function applyLaunchThemeChrome() {
const themePreference = getSavedThemePreference()
const resolvedLaunchTheme = resolveLaunchTheme(themePreference)
@@ -391,6 +584,7 @@
setMetaContent('meta[name="color-scheme"]', colorScheme === 'dark' ? 'dark light' : 'light dark')
syncThemeColorMeta(loaderColor)
syncThemeFavicon(primaryColor)
return {
background: loaderColor,

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.14.3",
"version": "2.14.5",
"private": true,
"type": "module",
"bin": "dist/service.js",
@@ -9,6 +9,9 @@
"prebuild": "npm run build:icons",
"build": "vite build",
"preview": "vite preview --port 5050",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . -c .eslintrc.js --fix --ext .ts,.js,.vue,.tsx,.jsx",
"build:icons": "tsc -b src/@iconify && node src/@iconify/build-icons.js",
@@ -20,6 +23,10 @@
"dist/**/*"
]
},
"resolutions": {
"vitest/**/vite": "5.4.18",
"vitest/vite": "5.4.18"
},
"dependencies": {
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
@@ -50,6 +57,7 @@
"express": "^4.21.2",
"express-http-proxy": "^2.1.1",
"gridstack": "^12.6.0",
"gsap": "^3.15.0",
"http-proxy-middleware": "^3.0.0",
"js-cookie": "^3.0.5",
"lodash-es": "^4.17.21",
@@ -57,11 +65,12 @@
"markdown-it-link-attributes": "^4.0.1",
"mousetrap": "^1.6.5",
"nprogress": "^0.2.0",
"pinia": "^3.0.1",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.2.0",
"qrcode": "^1.5.4",
"sass": "^1.83.4",
"tailwindcss": "^ 3.4.17",
"three": "^0.185.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"vue-toastification": "^2.0.0-rc.5",
@@ -82,7 +91,12 @@
"@iconify/vue": "^4.3.0",
"@intlify/unplugin-vue-i18n": "^6.0.3",
"@originjs/vite-plugin-federation": "^1.4.1",
"@pinia/testing": "1.0.3",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@testing-library/dom": "9.3.4",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/user-event": "14.6.1",
"@testing-library/vue": "8.1.0",
"@types/body-scroll-lock": "^3.1.2",
"@types/lodash-es": "^4.17.12",
"@types/markdown-it": "^14.1.2",
@@ -91,11 +105,15 @@
"@types/node": "^20.1.4",
"@types/nprogress": "^0.2.3",
"@types/qrcode": "^1.5.6",
"@types/three": "^0.185.1",
"@types/webfontloader": "^1.6.34",
"@typescript-eslint/eslint-plugin": "^8.20.0",
"@typescript-eslint/parser": "^8.20.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"@vitest/coverage-v8": "3.2.7",
"@vue/compiler-dom": "3.5.13",
"@vue/test-utils": "2.4.11",
"autoprefixer": "^10.4.14",
"eslint": "^9.18.0",
"eslint-import-resolver-typescript": "^3.5.1",
@@ -105,6 +123,8 @@
"eslint-plugin-sonarjs": "^3.0.1",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-vue": "^9.12.0",
"jsdom": "26.1.0",
"msw": "2.15.0",
"postcss": "^8.5.1",
"postcss-html": "^1.5.0",
"stylelint": "^16.13.2",
@@ -123,6 +143,7 @@
"vite-plugin-top-level-await": "^1.5.0",
"vite-plugin-vue-layouts": "^0.11.0",
"vite-plugin-vuetify": "2.0.4",
"vitest": "3.2.7",
"vue-shepherd": "^4.1.0",
"vue-tsc": "^2.0.10",
"workbox-build": "^7.3.0",

View File

@@ -7,7 +7,7 @@
<link rel="icon" href="/favicon.ico">
<style>
:root {
--primary-color: #9155FD;
--primary-color: #8D51F9;
--surface-color: #FFFFFF;
--text-color: #333333;
--border-color: rgba(0, 0, 0, 0.12);
@@ -52,7 +52,7 @@
width: 120px;
height: 120px;
margin: 0 auto 32px;
background: rgba(145, 85, 253, 0.1);
background: rgba(141, 81, 249, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
@@ -100,7 +100,7 @@
gap: 8px;
margin-top: 24px;
padding: 8px 16px;
background: rgba(145, 85, 253, 0.1);
background: rgba(141, 81, 249, 0.1);
border-radius: 20px;
font-size: 0.875rem;
}

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { Component } from 'vue'
import { useDisplay } from 'vuetify'
import logo from '@images/logo.svg?raw'
import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue'
interface Props {
tag?: string | Component
@@ -51,7 +51,7 @@ function handleNavScroll(evt: Event) {
<div class="nav-header">
<slot name="nav-header">
<RouterLink to="/" class="app-logo d-flex align-center app-title-wrapper">
<div class="d-flex" v-html="logo" />
<ThemeLogoMark />
<h1 class="font-weight-bold leading-normal text-xl">
MOVIEPILOT <span class="text-sm text-gray-500">v2</span>

View File

@@ -273,16 +273,7 @@ export default defineComponent({
transform: none !important;
}
.app-logo > div {
display: flex;
overflow: hidden;
align-items: center;
justify-content: center;
block-size: 2.75rem;
inline-size: 2.75rem;
}
.app-logo svg {
.app-logo .theme-logo-mark {
block-size: 2.5rem;
inline-size: 2.5rem;
}

View File

@@ -1425,6 +1425,28 @@ export interface FilterRuleGroup {
category?: string
}
// 规则测试结果
export interface RuleTestData {
// 原始标题
title?: string
// 原始副标题
subtitle?: string
// 规则组名称
rulegroup_name?: string
// 规则组详情
rulegroup?: FilterRuleGroup | null
// 识别元数据
meta_info?: MetaInfo | null
// 媒体信息
media_info?: MediaInfo | null
// 种子信息
torrent_info?: TorrentInfo | null
// 优先级
priority?: number | null
// 是否命中过滤规则
matched?: boolean
}
// 订阅下载文件详情
export interface SubscribeDownloadFileInfo {
// 种子名称
@@ -1445,6 +1467,12 @@ export interface SubscribeLibraryFileInfo {
storage?: string
// 文件路径
file_path?: string
// 媒体服务器名称
server?: string
// 媒体服务器类型emby、jellyfin、plex 等
server_type?: string
// 媒体服务器条目 ID
itemid?: string
}
// 订阅集详情

View File

@@ -1539,8 +1539,8 @@ defineExpose({
.agent-assistant-fab {
position: fixed;
/* 保持高于菜单浮层,但低于 agent 会话面板2101)。 */
z-index: 2100;
/* 保持机器人和提示气泡高于 Vuetify 弹窗2400及全局 Toast2500)。 */
z-index: 2600;
--agent-assistant-robot-outline: #5b00c5;
--agent-assistant-robot-outline-soft: #7432df;

View File

@@ -1959,6 +1959,7 @@ onScopeDispose(() => {
:style="drawerStyle"
role="dialog"
:aria-label="t('agentAssistant.title')"
@focusin.stop
>
<div class="agent-assistant-shell">
<header class="agent-assistant-header">
@@ -1990,7 +1991,7 @@ onScopeDispose(() => {
location="bottom end"
offset="8"
max-width="360"
:z-index="2103"
:z-index="2603"
>
<template #activator="{ props }">
<IconBtn v-bind="props" :title="t('agentAssistant.history')" :aria-label="t('agentAssistant.history')">
@@ -2346,7 +2347,7 @@ onScopeDispose(() => {
<style lang="scss">
.agent-assistant-history-overlay {
z-index: 2103 !important;
z-index: 2603 !important;
}
</style>
@@ -2356,7 +2357,9 @@ onScopeDispose(() => {
.agent-assistant-panel {
position: fixed;
z-index: 2101;
/* Agent 会话层保持高于入口2600和业务弹窗同时低于自身弹出菜单。 */
z-index: 2601;
overflow: hidden;
background: rgb(var(--v-theme-surface));

View File

@@ -5,7 +5,7 @@ import { nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { storageRemoteDict } from '@/api/constants'
const DEFAULT_DIRECTORY_ACCENT_RGB = '145, 85, 253'
const DEFAULT_DIRECTORY_ACCENT_RGB = '141, 81, 249'
const STORAGE_ACCENT_COLOR_MAP = {
local: '#FFB400',
alipan: '#00A7F2',
@@ -75,10 +75,12 @@ const transferSourceItems = computed(() => [
{ title: t('directory.manualTransfer'), value: 'manual' },
])
/** 判断存储类型是否具备预设强调色。 */
function hasKnownStorageType(storageType?: string): storageType is keyof typeof STORAGE_ACCENT_COLOR_MAP {
return !!storageType && Object.prototype.hasOwnProperty.call(STORAGE_ACCENT_COLOR_MAP, storageType)
}
/** 将十六进制颜色转换为 CSS RGB 通道字符串。 */
function hexToRgbString(hexColor: string) {
const normalizedColor = hexColor.replace('#', '')
const colorValue = Number.parseInt(normalizedColor, 16)
@@ -88,6 +90,7 @@ function hexToRgbString(hexColor: string) {
return `${(colorValue >> 16) & 255}, ${(colorValue >> 8) & 255}, ${colorValue & 255}`
}
/** 根据自定义存储序号选取离散的强调色。 */
function getCustomStoragePaletteColor(storageType?: string) {
const customStorageIndex = Math.max(Number(storageType?.match(/\d+$/)?.[0] ?? 1) - 1, 0)
const customStorageColors = ['#F97316', '#8B5CF6', '#06B6D4', '#84CC16', '#EC4899', '#14B8A6']
@@ -95,6 +98,7 @@ function getCustomStoragePaletteColor(storageType?: string) {
return customStorageColors[customStorageIndex % customStorageColors.length]
}
/** 获取指定存储类型在目录卡片中使用的强调色。 */
function getStorageAccentColor(storageType?: string) {
if (hasKnownStorageType(storageType)) return STORAGE_ACCENT_COLOR_MAP[storageType]
@@ -108,6 +112,7 @@ const directoryAccentStyle = computed(() => ({
'--app-card-accent-end-rgb': libraryAccentRgb.value,
}))
/** 根据目录两端的存储类型刷新卡片强调色。 */
function updateDirectoryAccentColors() {
const downloadStorage = props.directory.storage
const libraryStorage = props.directory.library_storage || props.directory.storage
@@ -270,6 +275,7 @@ watch(
v-model="props.directory.name"
variant="underlined"
:label="t('directory.alias')"
mobile-control-width="65%"
class="me-20 text-high-emphasis font-weight-bold"
/>
<span class="app-card-top-action absolute top-3 right-12">
@@ -287,6 +293,7 @@ watch(
variant="underlined"
:items="typeItems"
:label="t('directory.mediaType')"
mobile-control-width="65%"
@update:modelValue="props.directory.media_category = ''"
/>
</VCol>
@@ -296,6 +303,7 @@ watch(
variant="underlined"
:items="getCategories"
:label="t('directory.mediaCategory')"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="4">
@@ -304,6 +312,7 @@ watch(
variant="underlined"
:items="resourceStorageOptions"
:label="t('directory.resourceStorage')"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="8">
@@ -312,6 +321,7 @@ watch(
:storage="props.directory.storage"
variant="underlined"
:label="t('directory.resourceDirectory')"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">
@@ -332,6 +342,7 @@ watch(
variant="underlined"
:items="transferSourceItems"
:label="t('directory.autoTransfer')"
mobile-control-width="65%"
/>
</VCol>
</VRow>
@@ -342,6 +353,7 @@ watch(
variant="underlined"
:items="MonitorModeItems"
:label="t('directory.monitorMode')"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="4">
@@ -350,6 +362,7 @@ watch(
variant="underlined"
:items="libraryStorageOptions"
:label="t('directory.libraryStorage')"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="8">
@@ -358,6 +371,7 @@ watch(
:storage="props.directory.library_storage"
variant="underlined"
:label="t('directory.libraryDirectory')"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="4">
@@ -367,6 +381,7 @@ watch(
:items="transferTypeItems"
:label="t('directory.transferType')"
:no-data-text="computedNoDataText"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="8">
@@ -375,6 +390,7 @@ watch(
variant="underlined"
:items="overwriteModeItems"
:label="t('directory.overwriteMode')"
mobile-control-width="65%"
/>
</VCol>
<VCol cols="6" v-if="!props.directory.media_type || props.directory.media_type === ''">

View File

@@ -17,12 +17,12 @@ const props = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['close', 'changed'])
// 按钮点击
/** 关闭当前优先级规则卡片。 */
function onClose() {
emit('close')
}
// 选项变化
/** 将当前优先级的规则选择结果通知父组件。 */
function filtersChanged(value: string[]) {
emit('changed', props.pri, value)
}
@@ -61,6 +61,7 @@ const selectFilterOptions = computed<{ [key: string]: string }[]>(() => {
:items="selectFilterOptions"
chips
:label="t('filterRule.rules')"
mobile-control-width="80%"
multiple
clearable
@update:modelValue="filtersChanged"

View File

@@ -173,6 +173,7 @@ async function removeSubscribe() {
emit('remove')
}
} catch (e) {
$toast.error(t('subscribe.requestFailed'))
console.log(e)
}
}
@@ -184,7 +185,9 @@ async function searchSubscribe() {
// 提示
if (result.success) $toast.success(`${props.media?.name} 提交搜索请求成功!`)
else $toast.error(t('subscribe.requestFailed'))
} catch (e) {
$toast.error(t('subscribe.requestFailed'))
console.log(e)
}
}
@@ -211,6 +214,7 @@ async function toggleSubscribeStatus(state: 'R' | 'S') {
$toast.error(t('subscribe.toggleFailed', { action, message: result.message }))
}
} catch (e) {
$toast.error(t('subscribe.requestFailed'))
console.log(e)
}
}
@@ -233,6 +237,7 @@ async function resetSubscribe() {
emit('save')
} else $toast.error(t('subscribe.resetFailed', { name: props.media?.name, message: result.message }))
} catch (e) {
$toast.error(t('subscribe.requestFailed'))
console.log(e)
}
}

View File

@@ -0,0 +1,438 @@
import { formatDateDifference } from '@/@core/utils/formatters'
import type { Subscribe } from '@/api/types'
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { createSubscribe } from '@tests/support/factories/subscribe'
import {
deleteSubscribeByIdHandler,
resetSubscribeByIdHandler,
searchSubscribeByIdHandler,
updateSubscribeStatusHandler,
} from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
confirm: vi.fn(),
openSharedDialog: vi.fn(),
routerPush: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => mocks.confirm,
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('@/router', () => ({
default: { push: (...args: unknown[]) => mocks.routerPush(...args) },
}))
function setViewport(width: number) {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width, writable: true })
window.dispatchEvent(new Event('resize'))
}
function observeElementsImmediately() {
class ImmediateIntersectionObserver {
readonly root = null
readonly rootMargin = '0px'
readonly thresholds = [0]
constructor(private readonly callback: IntersectionObserverCallback) {}
disconnect() {}
observe(target: Element) {
this.callback([{ intersectionRatio: 1, isIntersecting: true, target } as IntersectionObserverEntry], this)
}
takeRecords(): IntersectionObserverEntry[] {
return []
}
unobserve() {}
}
vi.stubGlobal('IntersectionObserver', ImmediateIntersectionObserver)
}
async function renderCard(
mediaOverrides: Partial<Subscribe> = {},
props: Partial<{ batchMode: boolean; selected: boolean; sortable: boolean }> = {},
globalImageCache = false,
) {
const media = createSubscribe({
backdrop: 'https://images.example.com/backdrop.jpg',
id: 2501,
last_update: '2026-07-16 12:00:00',
name: '卡片测试媒体',
poster: 'https://images.example.com/poster.jpg',
...mediaOverrides,
})
const result = await renderWithProviders(SubscribeCard, {
initialState: {
globalSettings: {
data: { GLOBAL_IMAGE_CACHE: globalImageCache },
initialized: true,
loading: false,
},
},
props: { media, ...props },
})
return { ...result, media }
}
function getMenuButton(container: Element) {
const selector = window.innerWidth < 600 ? '.subscribe-card-mobile-menu' : '.absolute.top-1.right-4 .v-btn'
const button = container.querySelector<HTMLButtonElement>(selector)
expect(button).not.toBeNull()
return button as HTMLButtonElement
}
async function openMenu(container: Element) {
await fireEvent.click(getMenuButton(container))
}
async function chooseMenuItem(container: Element, label: string) {
await openMenu(container)
await fireEvent.click(await screen.findByText(label))
}
function getDialogCall(index = 0) {
const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [
unknown,
Record<string, unknown>,
Record<string, () => void>,
Record<string, unknown>,
]
return { events, options, props }
}
describe('SubscribeCard display and progress', () => {
beforeEach(() => {
setViewport(1024)
observeElementsImmediately()
mocks.confirm.mockResolvedValue(true)
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it('renders stable movie metadata and omits episode progress without a total', async () => {
const { container, media } = await renderCard({ total_episode: undefined, type: '电影', year: '2025' }, {}, true)
expect(screen.getByText(media.name)).toBeInTheDocument()
expect(screen.getByText('2025')).toBeInTheDocument()
expect(screen.getByText(media.username)).toHaveAttribute('title', media.username)
const image = container.querySelector<HTMLImageElement>('img')
expect(image).not.toBeNull()
expect((image as HTMLImageElement).src).toContain('system/cache/image?url=')
expect((image as HTMLImageElement).src).toContain(encodeURIComponent(media.backdrop || ''))
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(screen.queryByText(/\d+ \/ \d+/)).not.toBeInTheDocument()
})
it.each([
['regular progress', 10, 4, '6 / 10', '60'],
['negative missing episodes', 10, -2, '10 / 10', '100'],
['missing episodes above the total', 10, 12, '0 / 10', null],
['zero total', 0, 0, null, null],
])('normalizes %s', async (_case, totalEpisode, lackEpisode, expectedText, expectedProgress) => {
await renderCard({ lack_episode: lackEpisode, season: 2, total_episode: totalEpisode, type: '电视剧' })
if (expectedText) expect(screen.getByText(expectedText)).toBeInTheDocument()
else expect(screen.queryByText(/\d+ \/ \d+/)).not.toBeInTheDocument()
if (expectedProgress) expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', expectedProgress)
else expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(screen.getByText(/卡片测试媒体 S02/)).toBeInTheDocument()
})
it.each([
['boolean flag with tv type', true, false, 3, 'tv', 30, true, false],
['numeric flags with negative completed episodes', 1, 1, -2, '电视剧', 0, true, true],
['string flags with completed episodes above the total', '1', '1', 12, '电影', 100, true, true],
['disabled flag', false, true, 3, '电视剧', 80, false, false],
])(
'normalizes %s for wash progress and badges',
async (_case, bestVersion, bestVersionFull, completedEpisode, type, expectedProgress, expectedWash, expectedFull) => {
const { container } = await renderCard({
best_version: bestVersion,
best_version_full: bestVersionFull,
completed_episode: completedEpisode,
lack_episode: 2,
total_episode: 10,
type,
})
const image = container.querySelector<HTMLImageElement>('img')
expect(image).not.toBeNull()
await fireEvent.load(image as HTMLImageElement)
const progress = screen.getByRole('progressbar')
expect(progress).toHaveAttribute('aria-valuenow', String(expectedProgress))
expect(progress.querySelector('.v-progress-linear__buffer')).toHaveStyle({ width: expectedWash ? '80%' : '0%' })
expect(Boolean(container.querySelector('.best-version-badge'))).toBe(expectedWash)
expect(Boolean(container.querySelector('.best-version-badge-full'))).toBe(expectedFull)
},
)
it('keeps mobile wash progress compact while preserving P, S, and R metadata', async () => {
setViewport(480)
const { media, rerender } = await renderCard({
best_version: true,
completed_episode: 3,
lack_episode: 2,
state: 'P',
total_episode: 10,
type: '电视剧',
})
const lastUpdateText = formatDateDifference(media.last_update)
expect(screen.getByLabelText('待定中')).toBeInTheDocument()
expect(screen.getByText('8 / 10')).toBeInTheDocument()
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '30')
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
expect(document.querySelector('.subscribe-card-mobile-menu')).toBeInTheDocument()
await rerender({ media: { ...media, state: 'S' } })
expect(screen.getByLabelText('已暂停')).toBeInTheDocument()
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
await rerender({ media: { ...media, state: 'R' } })
expect(screen.getByLabelText('订阅中')).toBeInTheDocument()
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
})
it('synchronizes desktop P, S, and R state from updated media props', async () => {
const { container, media, rerender } = await renderCard({ state: 'P' })
const lastUpdateText = formatDateDifference(media.last_update)
expect(screen.getByText('待定中')).toBeInTheDocument()
expect(screen.queryByText(lastUpdateText)).not.toBeInTheDocument()
await rerender({ media: { ...media, state: 'S' } })
expect(screen.getByText('已暂停')).toBeInTheDocument()
expect(container.querySelector('.subscribe-card')).toHaveClass('subscribe-card-paused')
await rerender({ media: { ...media, state: 'R' } })
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
expect(screen.queryByText('已暂停')).not.toBeInTheDocument()
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
})
})
describe('SubscribeCard interaction boundaries', () => {
beforeEach(() => {
setViewport(1024)
observeElementsImmediately()
mocks.confirm.mockResolvedValue(true)
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it('routes normal, batch, selected, and sortable card clicks without overlap', async () => {
const { container, emitted, media, rerender } = await renderCard()
const card = container.querySelector('.subscribe-card') as HTMLElement
await fireEvent.click(card)
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
await rerender({ batchMode: true, media, selected: true })
await fireEvent.click(card)
expect(emitted('select')).toHaveLength(1)
expect(container.querySelector('.subscribe-card-shell')).toHaveClass('subscribe-card-shell--selected')
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
expect(container.querySelector('.absolute.top-1.right-4 .v-btn')).toBeInTheDocument()
await rerender({ batchMode: true, media, selected: true, sortable: true })
await fireEvent.click(card)
expect(emitted('select')).toHaveLength(1)
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
expect(container.querySelector('.absolute.top-1.right-4 .v-btn')).not.toBeInTheDocument()
})
it('opens page-selected editing and forwards only save and remove events', async () => {
const { emitted, media } = await renderCard({ page_open: true })
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const dialog = getDialogCall()
expect(dialog.props).toEqual({ subid: media.id })
expect(dialog.options).toEqual({ closeOn: ['close', 'save', 'remove'] })
dialog.events.save()
dialog.events.remove()
expect(emitted('save')).toHaveLength(1)
expect(emitted('remove')).toHaveLength(1)
})
it('passes exact file and TV share data while keeping compatibility TV values unshared', async () => {
const { container, media, rerender } = await renderCard({ season: 1, total_episode: 12, type: '电视剧' })
await chooseMenuItem(container, '分享')
expect(getDialogCall().props).toEqual({ sub: media })
expect(getDialogCall().options).toEqual({ closeOn: ['close'] })
await chooseMenuItem(container, '文件统计')
expect(getDialogCall(1).props).toEqual({ subid: media.id })
expect(getDialogCall(1).options).toEqual({ closeOn: ['close'] })
await rerender({ media: { ...media, type: 'tv' } })
await openMenu(container)
expect(screen.queryByText('分享')).not.toBeInTheDocument()
})
it.each([
['TMDB before all fallbacks', { bangumiid: '33', doubanid: '22', mediaid: 'custom:44', tmdbid: 11 }, 'tmdb:11'],
['Douban before Bangumi', { bangumiid: '33', doubanid: '22', mediaid: 'custom:44', tmdbid: 0 }, 'douban:22'],
['Bangumi before custom', { bangumiid: '33', doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'bangumi:33'],
['custom media ID last', { bangumiid: undefined, doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'custom:44'],
])('routes media details with %s', async (_case, identifiers, expectedMediaId) => {
const { container, media } = await renderCard(identifiers)
await chooseMenuItem(container, '媒体详情')
expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/media',
query: {
mediaid: expectedMediaId,
title: media.name,
type: media.type,
year: media.year,
},
})
})
})
describe('SubscribeCard item operations', () => {
beforeEach(() => {
setViewport(1024)
observeElementsImmediately()
mocks.confirm.mockResolvedValue(true)
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it.each([
['success', 200, { success: true }, 'success', '卡片测试媒体 提交搜索请求成功!'],
['business failure', 200, { message: 'rejected', success: false }, 'error', '请求失败,请稍后重试'],
['HTTP failure', 500, { message: 'server down', success: false }, 'error', '请求失败,请稍后重试'],
] as const)('reports search %s through the exact endpoint', async (_case, status, response, toastType, message) => {
const requested = vi.fn()
const { container, media } = await renderCard()
server.use(searchSubscribeByIdHandler(media.id, response, status, requested))
await chooseMenuItem(container, '搜索')
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
const toast = toastType === 'success' ? mocks.toastSuccess : mocks.toastError
await waitFor(() => expect(toast).toHaveBeenCalledWith(message))
})
it('pauses and enables only after confirmed successful status responses', async () => {
const requested: URL[] = []
const { container, emitted, media } = await renderCard({ state: 'R' })
server.use(
updateSubscribeStatusHandler(media.id, { success: true }, 200, url => {
requested.push(url)
}),
)
await chooseMenuItem(container, '暂停')
await waitFor(() => expect(requested).toHaveLength(1))
expect(requested[0].searchParams.get('state')).toBe('S')
expect(container.querySelector('.subscribe-card')).toHaveClass('subscribe-card-paused')
expect(mocks.toastSuccess).toHaveBeenCalledWith(`${media.name} 已暂停!`)
await chooseMenuItem(container, '启用')
await waitFor(() => expect(requested).toHaveLength(2))
expect(requested[1].searchParams.get('state')).toBe('R')
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
expect(mocks.toastSuccess).toHaveBeenCalledWith(`${media.name} 已启用!`)
expect(emitted('save')).toHaveLength(2)
})
it.each([
['confirmation cancellation', false, 200, { success: true }, null],
['business failure', true, 200, { message: 'rejected', success: false }, '暂停失败rejected'],
['HTTP failure', true, 500, { message: 'server down', success: false }, '请求失败,请稍后重试'],
] as const)(
'keeps status unchanged after %s',
async (_case, confirmed, status, response, expectedError) => {
const requested = vi.fn()
mocks.confirm.mockResolvedValue(confirmed)
const { container, emitted, media } = await renderCard({ state: 'R' })
server.use(updateSubscribeStatusHandler(media.id, response, status, requested))
await chooseMenuItem(container, '暂停')
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce())
else expect(requested).not.toHaveBeenCalled()
if (expectedError) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedError))
else expect(mocks.toastError).not.toHaveBeenCalled()
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
expect(emitted('save') ?? []).toHaveLength(0)
},
)
it.each([
['success', true, 200, { success: true }, 'success', '卡片测试媒体 重置成功!'],
['confirmation cancellation', false, 200, { success: true }, null, null],
['business failure', true, 200, { message: 'rejected', success: false }, 'error', '卡片测试媒体 重置失败rejected'],
['HTTP failure', true, 500, { message: 'server down', success: false }, 'error', '请求失败,请稍后重试'],
] as const)(
'handles reset %s without speculative state',
async (_case, confirmed, status, response, toastType, message) => {
const requested = vi.fn()
mocks.confirm.mockResolvedValue(confirmed)
const { container, emitted, media } = await renderCard({ state: 'S' })
server.use(resetSubscribeByIdHandler(media.id, response, status, requested))
await chooseMenuItem(container, '重置')
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce())
else expect(requested).not.toHaveBeenCalled()
if (toastType && message) {
const toast = toastType === 'success' ? mocks.toastSuccess : mocks.toastError
await waitFor(() => expect(toast).toHaveBeenCalledWith(message))
} else {
expect(mocks.toastSuccess).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
}
if (_case === 'success') {
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
expect(emitted('save')).toHaveLength(1)
} else {
expect(container.querySelector('.subscribe-card')).toHaveClass('subscribe-card-paused')
expect(emitted('save') ?? []).toHaveLength(0)
}
},
)
it.each([
['success', 200, { success: true }, true, null],
['HTTP failure', 500, { message: 'server down', success: false }, false, '请求失败,请稍后重试'],
] as const)('handles delete %s without a synthetic business-failure branch', async (_case, status, response, removed, error) => {
const requested = vi.fn()
const { container, emitted, media } = await renderCard()
server.use(deleteSubscribeByIdHandler(media.id, response, status, requested))
await chooseMenuItem(container, '取消订阅')
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
expect(emitted('remove') ?? []).toHaveLength(removed ? 1 : 0)
if (error) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(error))
else expect(mocks.toastError).not.toHaveBeenCalled()
})
})

View File

@@ -10,6 +10,17 @@ import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constant
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { formatSeason } from '@/@core/utils/formatters'
// 从变更请求异常中提取可展示消息,并为非标准错误提供稳定兜底。
function getRequestErrorMessage(error: unknown, fallback: string) {
if (typeof error === 'object' && error !== null) {
const responseMessage = (error as { response?: { data?: { message?: unknown } } }).response?.data?.message
if (typeof responseMessage === 'string' && responseMessage) return responseMessage
}
if (error instanceof Error && error.message) return error.message
return fallback
}
// i18n
const { t } = useI18n()
const userStore = useUserStore()
@@ -104,6 +115,12 @@ function getSubscribeDisplayName() {
return `${name} ${formatSeason(season.toString())}`
}
function getDefaultSubscribeTypeName() {
if (props.type === '电影') return t('mediaType.movie')
if (props.type === '电视剧') return t('mediaType.tv')
return props.type ?? ''
}
// 剧集组选项属性
function episodeGroupItemProps(item: { title: string; subtitle: string }) {
return {
@@ -162,18 +179,30 @@ const filterRuleGroupOptions = computed(() => {
// 调用API修改订阅
async function updateSubscribeInfo() {
const displayName = getSubscribeDisplayName()
try {
const result: { [key: string]: any } = await api.put('subscribe/', subscribeForm.value)
// 提示
if (result.success) {
$toast.success(`${getSubscribeDisplayName()} 更新成功!`)
$toast.success(t('dialog.subscribeEdit.updateSuccess', { name: displayName }))
// 通知父组件刷新
emit('save')
emit('save', subscribeForm.value)
} else {
$toast.error(`${getSubscribeDisplayName()} 更新失败:${result.message}`)
$toast.error(
t('dialog.subscribeEdit.updateFailed', {
name: displayName,
message: result.message ?? t('subscribe.requestFailed'),
}),
)
}
} catch (e) {
console.log(e)
$toast.error(
t('dialog.subscribeEdit.updateFailed', {
name: displayName,
message: getRequestErrorMessage(e, t('subscribe.requestFailed')),
}),
)
}
}
@@ -181,19 +210,33 @@ async function updateSubscribeInfo() {
async function saveDefaultSubscribeConfig() {
if (!canAdmin.value) return
const typeName = getDefaultSubscribeTypeName()
try {
let subscribe_config_url = ''
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
const result: { [key: string]: any } = await api.post(subscribe_config_url, subscribeForm.value)
if (result.success) $toast.success(`${props.type}订阅默认规则保存成功`)
else $toast.error(`${props.type}订阅默认规则保存失败!`)
// 通知父组件刷新
emit('save')
if (result.success) {
$toast.success(t('dialog.subscribeEdit.defaultSaveSuccess', { type: typeName }))
// 通知父组件刷新
emit('save', subscribeForm.value)
} else {
$toast.error(
t('dialog.subscribeEdit.defaultSaveFailed', {
type: typeName,
message: result.message ?? t('subscribe.requestFailed'),
}),
)
}
} catch (error) {
console.log(error)
$toast.error(
t('dialog.subscribeEdit.defaultSaveFailed', {
type: typeName,
message: getRequestErrorMessage(error, t('subscribe.requestFailed')),
}),
)
}
}
@@ -262,16 +305,28 @@ async function removeSubscribe() {
})
if (!isConfirmed) return
const displayName = getSubscribeDisplayName()
try {
const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`)
if (result.success) {
$toast.success(`订阅 ${getSubscribeDisplayName()} 已取消!`)
$toast.success(`${displayName} ${t('subscribe.cancelSuccess')}`)
// 通知父组件刷新
emit('remove')
} else {
$toast.error(
`${displayName} ${t('subscribe.cancelFailed', {
message: result.message ?? t('subscribe.requestFailed'),
})}`,
)
}
} catch (e) {
console.log(e)
$toast.error(
`${displayName} ${t('subscribe.cancelFailed', {
message: getRequestErrorMessage(e, t('subscribe.requestFailed')),
})}`,
)
}
}
@@ -289,8 +344,10 @@ async function loadDownloadDirectories() {
// 保存目录下拉框
const targetDirectories = computed(() => {
// 去重后的下载目录
return downloadDirectories.value.map(item => item.download_path)
const paths = downloadDirectories.value
.map(item => item.download_path?.trim())
.filter((path): path is string => Boolean(path))
return [...new Set(paths)]
})
// 仅电视剧订阅支持全集洗版,电影保持原有洗版逻辑

View File

@@ -110,6 +110,41 @@ function resolveEpisodeStatus(download: SubscribeDownloadFileInfo[], library: Su
return 'missing'
}
/**
* 入库条目的驱动器标签:服务器名或 local。
*/
function resolveLibraryStorageLabel(file: SubscribeLibraryFileInfo) {
if (file.server) return file.server
return file.storage || 'local'
}
/**
* 判断路径是否为可打开的详情链接。
*/
function isDetailUrl(path?: string) {
return !!path && /^https?:\/\//i.test(path)
}
/**
* 入库条目路径展示文案。
*/
function resolveLibraryPathText(file: SubscribeLibraryFileInfo) {
if (file.file_path) return file.file_path
return t('dialog.subscribeFiles.noPath')
}
/**
* 文件列表项稳定 key媒体服务器条目可能无路径
*/
function resolveFileKey(tab: SubscribeFileTab, file: SubscribeDownloadFileInfo | SubscribeLibraryFileInfo, index: number, episodeNumber?: number) {
const prefix = episodeNumber == null ? tab : `${episodeNumber}-${tab}`
if (tab === 'library') {
const libraryFile = file as SubscribeLibraryFileInfo
return `${prefix}-${libraryFile.file_path || libraryFile.itemid || libraryFile.server || libraryFile.server_type || index}`
}
return `${prefix}-${(file as SubscribeDownloadFileInfo).file_path || index}`
}
/**
* 返回集状态对应的本地化展示文案。
*/
@@ -183,9 +218,23 @@ function calcPercent(value: number, total: number) {
/**
* 从种子标题或文件路径中提取分辨率标签。
* 媒体服务器详情 URL 不参与解析。
*/
function resolveResolutionLabel(file?: SubscribeDownloadFileInfo | SubscribeLibraryFileInfo) {
const text = 'torrent_title' in (file || {}) ? `${(file as SubscribeDownloadFileInfo).torrent_title || ''} ${file?.file_path || ''}` : file?.file_path || ''
if (!file) return undefined
const path = file.file_path || ''
if (isDetailUrl(path)) {
if ('torrent_title' in file) {
const matched = ((file as SubscribeDownloadFileInfo).torrent_title || '').match(/(?:2160|1080|720|480)p|4k|8k/i)
return matched?.[0]?.toUpperCase()
}
return undefined
}
const text = 'torrent_title' in file
? `${(file as SubscribeDownloadFileInfo).torrent_title || ''} ${path}`
: path
const matched = text.match(/(?:2160|1080|720|480)p|4k|8k/i)
return matched?.[0]?.toUpperCase()
}
@@ -336,7 +385,6 @@ onBeforeMount(() => {
<template>
<VDialog
scrollable
max-width="74rem"
:fullscreen="!display.mdAndUp.value"
content-class="subscribe-files-overlay"
@@ -495,7 +543,7 @@ onBeforeMount(() => {
<template v-if="selectedFiles.length">
<article
v-for="(file, index) in selectedFiles"
:key="`${activeTab}-${file.file_path || index}`"
:key="resolveFileKey(activeTab, file, index)"
class="subscribe-files-file-card"
>
<div class="subscribe-files-file-card__media">
@@ -519,12 +567,12 @@ onBeforeMount(() => {
{{ (file as SubscribeDownloadFileInfo).site_name }}
</VChip>
<VChip
v-if="activeTab === 'library' && (file as SubscribeLibraryFileInfo).storage"
v-if="activeTab === 'library' && resolveLibraryStorageLabel(file as SubscribeLibraryFileInfo)"
color="success"
variant="tonal"
size="x-small"
>
{{ (file as SubscribeLibraryFileInfo).storage }}
{{ resolveLibraryStorageLabel(file as SubscribeLibraryFileInfo) }}
</VChip>
<VChip
:color="activeTab === 'download' ? 'info' : 'success'"
@@ -547,10 +595,25 @@ onBeforeMount(() => {
</div>
<div class="subscribe-files-path-block">
<div class="subscribe-files-path-block__label">
<VIcon icon="mdi-folder-outline" size="16" />
<VIcon :icon="activeTab === 'library' && isDetailUrl(file.file_path) ? 'mdi-open-in-new' : 'mdi-folder-outline'" size="16" />
{{ t('dialog.subscribeFiles.filePath') }}
</div>
<code>{{ file.file_path || t('dialog.subscribeFiles.noPath') }}</code>
<a
v-if="activeTab === 'library' && isDetailUrl(file.file_path)"
class="subscribe-files-path-link"
:href="file.file_path"
target="_blank"
rel="noopener noreferrer"
>
{{ file.file_path }}
</a>
<code v-else>
{{
activeTab === 'library'
? resolveLibraryPathText(file as SubscribeLibraryFileInfo)
: (file.file_path || t('dialog.subscribeFiles.noPath'))
}}
</code>
<VBtn
icon="mdi-content-copy"
variant="tonal"
@@ -593,7 +656,7 @@ onBeforeMount(() => {
<div v-if="episode.activeFiles.length" class="subscribe-files-mobile-card__files">
<div
v-for="(file, index) in episode.activeFiles"
:key="`${episode.episodeNumber}-${activeTab}-${file.file_path || index}`"
:key="resolveFileKey(activeTab, file, index, episode.episodeNumber)"
class="subscribe-files-mobile-file"
>
<div class="subscribe-files-mobile-file__chips">
@@ -609,19 +672,34 @@ onBeforeMount(() => {
{{ (file as SubscribeDownloadFileInfo).site_name }}
</VChip>
<VChip
v-if="activeTab === 'library' && (file as SubscribeLibraryFileInfo).storage"
v-if="activeTab === 'library' && resolveLibraryStorageLabel(file as SubscribeLibraryFileInfo)"
color="success"
variant="tonal"
size="x-small"
>
{{ (file as SubscribeLibraryFileInfo).storage }}
{{ resolveLibraryStorageLabel(file as SubscribeLibraryFileInfo) }}
</VChip>
</div>
<div v-if="activeTab === 'download'" class="subscribe-files-mobile-file__title">
{{ (file as SubscribeDownloadFileInfo).torrent_title || t('dialog.subscribeFiles.unknownTorrent') }}
</div>
<div class="subscribe-files-path-block subscribe-files-path-block--mobile">
<code>{{ file.file_path || t('dialog.subscribeFiles.noPath') }}</code>
<a
v-if="activeTab === 'library' && isDetailUrl(file.file_path)"
class="subscribe-files-path-link"
:href="file.file_path"
target="_blank"
rel="noopener noreferrer"
>
{{ file.file_path }}
</a>
<code v-else>
{{
activeTab === 'library'
? resolveLibraryPathText(file as SubscribeLibraryFileInfo)
: (file.file_path || t('dialog.subscribeFiles.noPath'))
}}
</code>
<VBtn
icon="mdi-content-copy"
variant="tonal"
@@ -652,9 +730,91 @@ onBeforeMount(() => {
</VDialog>
</template>
<style lang="scss">
.subscribe-files-overlay {
display: flex !important;
flex-direction: column !important;
inline-size: 100% !important;
width: 100% !important;
max-inline-size: 74rem !important;
overflow: hidden !important;
}
.v-dialog:not(.v-dialog--fullscreen) > .subscribe-files-overlay {
block-size: 80vh !important;
height: 80vh !important;
max-block-size: 80vh !important;
max-height: 80vh !important;
}
.v-dialog--fullscreen > .subscribe-files-overlay {
block-size: 100% !important;
height: 100% !important;
max-block-size: 100% !important;
max-height: 100% !important;
min-block-size: 100% !important;
min-height: 100% !important;
max-inline-size: 100% !important;
max-width: 100% !important;
margin: 0 !important;
padding: 0 !important;
border-radius: 0 !important;
}
@media (width <= 960px) {
.v-dialog > .subscribe-files-overlay {
block-size: 100% !important;
height: 100% !important;
max-block-size: 100% !important;
max-height: 100% !important;
min-block-size: 100% !important;
min-height: 100% !important;
max-inline-size: 100% !important;
max-width: 100% !important;
margin: 0 !important;
padding: 0 !important;
border-radius: 0 !important;
}
}
.subscribe-files-overlay > .v-card {
display: flex;
flex: 1 1 auto;
flex-direction: column;
inline-size: 100%;
width: 100%;
block-size: 100%;
height: 100%;
min-block-size: 0;
overflow: hidden !important;
}
.v-dialog--fullscreen > .subscribe-files-overlay > .v-card {
min-block-size: 100% !important;
min-height: 100% !important;
block-size: 100% !important;
height: 100% !important;
}
@media (width <= 960px) {
.subscribe-files-overlay > .v-card {
min-block-size: 100% !important;
min-height: 100% !important;
block-size: 100% !important;
height: 100% !important;
}
}
</style>
<style lang="scss" scoped>
.subscribe-files-dialog {
display: flex;
overflow: hidden;
flex: 1 1 auto;
flex-direction: column;
inline-size: 100%;
block-size: 100%;
min-block-size: 0;
border: 1px solid rgba(var(--v-theme-on-surface), var(--sfd-border-opacity));
backdrop-filter: blur(var(--sfd-blur)) saturate(1.18);
background:
@@ -686,20 +846,26 @@ onBeforeMount(() => {
}
.subscribe-files-dialog__body {
display: flex;
overflow: hidden;
flex: 1 1 0;
flex-direction: column;
min-block-size: 0;
padding: 0 !important;
}
.subscribe-files-shell {
display: flex;
overflow: hidden;
flex: 1 1 0;
flex-direction: column;
min-block-size: min(86vh, 56rem);
min-block-size: 0;
}
.subscribe-files-hero {
position: relative;
overflow: hidden;
min-block-size: 21rem;
flex: 0 0 auto;
background:
linear-gradient(
90deg,
@@ -848,16 +1014,20 @@ onBeforeMount(() => {
.subscribe-files-content {
display: grid;
flex: 1 1 auto;
overflow: hidden;
flex: 1 1 0;
gap: 1rem;
grid-template-columns: 19rem minmax(0, 1fr);
min-block-size: 0;
grid-template-rows: minmax(0, 1fr);
min-block-size: 12rem;
padding: 0 1.25rem 1.25rem;
}
.subscribe-files-episode-rail,
.subscribe-files-main {
overflow: hidden;
block-size: 100%;
min-block-size: 0;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: var(--app-surface-radius);
background: rgba(var(--v-theme-surface), var(--sfd-panel-opacity));
@@ -1108,7 +1278,8 @@ onBeforeMount(() => {
grid-column: 1 / -1;
}
.subscribe-files-path-block code {
.subscribe-files-path-block code,
.subscribe-files-path-link {
overflow: hidden;
color: rgba(var(--v-theme-on-surface), 0.88);
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
@@ -1117,6 +1288,16 @@ onBeforeMount(() => {
overflow-wrap: anywhere;
}
.subscribe-files-path-link {
color: rgb(var(--v-theme-primary));
text-decoration: underline;
text-underline-offset: 0.15em;
}
.subscribe-files-path-link:hover {
opacity: 0.88;
}
.subscribe-files-empty {
display: grid;
align-content: center;
@@ -1135,15 +1316,15 @@ onBeforeMount(() => {
.subscribe-files-mobile-list {
display: flex;
overflow: auto;
overflow: hidden auto;
flex: 1 1 auto;
flex-direction: column;
gap: 0.85rem;
padding: 1rem;
min-block-size: 0;
padding: 0.85rem 1rem 1rem;
}
.subscribe-files-mobile-card {
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: var(--app-surface-radius);
background: rgba(var(--v-theme-surface), var(--sfd-panel-opacity));
@@ -1154,11 +1335,12 @@ onBeforeMount(() => {
align-items: center;
padding: 0.85rem;
gap: 0.65rem;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-columns: 3rem minmax(0, 1fr) auto;
}
.subscribe-files-mobile-card__episode {
display: grid;
flex-shrink: 0;
border-radius: 0.8rem;
background: rgba(var(--v-theme-primary), 0.18);
block-size: 3rem;
@@ -1166,6 +1348,7 @@ onBeforeMount(() => {
font-size: 1.05rem;
font-weight: 800;
inline-size: 3rem;
min-inline-size: 3rem;
place-items: center;
}
@@ -1239,18 +1422,29 @@ onBeforeMount(() => {
.subscribe-files-dialog {
border: 0;
border-radius: 0 !important;
block-size: 100%;
min-block-size: 100%;
}
.subscribe-files-dialog__body {
flex: 1 1 auto;
block-size: 100%;
min-block-size: 0;
}
.subscribe-files-shell {
min-block-size: 100dvh;
flex: 1 1 auto;
block-size: 100%;
min-block-size: 0;
}
.subscribe-files-hero {
min-block-size: auto;
flex: 0 0 auto;
}
.subscribe-files-hero__content {
display: grid;
align-items: start;
gap: 1rem;
grid-template-columns: 8rem minmax(0, 1fr);
padding: 1rem;
@@ -1310,33 +1504,52 @@ onBeforeMount(() => {
}
.subscribe-files-main {
display: flex;
overflow: hidden;
flex: 1 1 auto;
flex-direction: column;
min-block-size: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.subscribe-files-tabs {
padding: 0.9rem 1rem 0;
flex: 0 0 auto;
padding: 0.9rem 1rem 0.25rem;
}
.subscribe-files-tab-group {
inline-size: 100%;
}
.subscribe-files-mobile-card__header {
align-items: start;
gap: 0.5rem 0.75rem;
grid-template-columns: 3rem minmax(0, 1fr);
grid-template-rows: auto auto;
}
.subscribe-files-mobile-card__episode {
grid-row: 1 / span 2;
align-self: start;
}
.subscribe-files-mobile-card__title {
grid-column: 2;
grid-row: 1;
}
.subscribe-files-mobile-card__header .v-chip {
grid-column: 2;
grid-row: 2;
justify-self: start;
}
}
@media (width <= 560px) {
.subscribe-files-hero__content {
grid-template-columns: 6.5rem minmax(0, 1fr);
}
.subscribe-files-mobile-card__header {
grid-template-columns: auto minmax(0, 1fr);
}
.subscribe-files-mobile-card__header .v-chip {
justify-self: start;
grid-column: 2;
}
}
</style>

View File

@@ -0,0 +1,451 @@
import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import {
createSubscribe,
createSubscribeDirectory,
createSubscribeDownloader,
createSubscribeRuleGroup,
createSubscribeSite,
} from '@tests/support/factories/subscribe'
import {
defaultSubscribeConfigHandler,
deleteSubscribeByIdHandler,
saveDefaultSubscribeConfigHandler,
subscribeApiUrls,
subscribeDetailsHandler,
subscribeDialogOptionHandlers,
type SubscribeDialogOptions,
type SubscribeMediaType,
updateSubscribeHandler,
} from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
confirm: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => mocks.confirm,
}))
interface DialogProps {
default?: boolean
subid?: number
type?: SubscribeMediaType
}
async function renderDialog(props: DialogProps, superUser = true) {
const events = {
close: vi.fn(),
remove: vi.fn(),
save: vi.fn(),
}
const result = await renderWithProviders(SubscribeEditDialog, {
initialState: {
user: {
superUser,
userName: superUser ? 'admin' : 'member',
},
},
props: {
modelValue: true,
...props,
onClose: events.close,
onRemove: events.remove,
onSave: events.save,
},
global: {
components: {
VDialogCloseBtn: DialogCloseBtn,
},
},
})
return { ...result, events }
}
function useDialogOptions(options: SubscribeDialogOptions = {}) {
server.use(...subscribeDialogOptionHandlers(options))
}
describe('SubscribeEditDialog', () => {
beforeEach(() => {
mocks.confirm.mockResolvedValue(true)
})
it('loads a TV subscription, normalizes flags, and exposes episode groups', async () => {
const record = createSubscribe({
best_version: 1,
best_version_full: 1,
id: 801,
name: '季度测试剧',
search_imdbid: 0,
season: 2,
tmdbid: 8010,
type: '电视剧',
})
const episodeGroupsRequested = vi.fn()
server.use(subscribeDetailsHandler(801, record))
useDialogOptions({
episodeGroups: [{ episode_count: 24, group_count: 2, id: 99, name: '官方特别排序' }],
onEpisodeGroups: episodeGroupsRequested,
tmdbId: 8010,
})
const user = userEvent.setup()
await renderDialog({ subid: 801 })
expect(await screen.findByText('季度测试剧 S02')).toBeInTheDocument()
expect(screen.getByLabelText('洗版')).toBeChecked()
expect(screen.getByLabelText('全集洗版')).toBeChecked()
expect(screen.getByLabelText('使用 ImdbID 搜索')).not.toBeChecked()
await waitFor(() => expect(episodeGroupsRequested).toHaveBeenCalledOnce())
await user.click(screen.getByRole('tab', { name: '进阶' }))
await user.click(screen.getByLabelText('指定剧集组'))
expect(await screen.findByText('官方特别排序')).toBeInTheDocument()
expect(screen.getByText('2 季 • 24 集')).toBeInTheDocument()
})
it('keeps movie titles free of season suffixes and skips episode groups', async () => {
const record = createSubscribe({ id: 802, name: '电影测试项', season: undefined, tmdbid: 8020, type: '电影' })
const episodeGroupsRequested = vi.fn()
server.use(subscribeDetailsHandler(802, record))
useDialogOptions({ onEpisodeGroups: episodeGroupsRequested, tmdbId: 8020 })
await renderDialog({ subid: 802 })
expect(await screen.findByText('电影测试项')).toBeInTheDocument()
expect(screen.queryByText(/电影测试项 S\d+/)).not.toBeInTheDocument()
expect(episodeGroupsRequested).not.toHaveBeenCalled()
})
it('shows enabled sites and stable downloader, directory, and rule options', async () => {
const activeSite = createSubscribeSite({ id: 1, is_active: true, name: '启用站点' })
const inactiveSite = createSubscribeSite({ id: 2, is_active: false, name: '停用站点' })
const requests = {
directories: vi.fn(),
downloaders: vi.fn(),
rules: vi.fn(),
sites: vi.fn(),
}
server.use(defaultSubscribeConfigHandler('电影', createSubscribe({ id: 0, type: '电影' })))
useDialogOptions({
directories: [
createSubscribeDirectory({ download_path: '/downloads', name: '目录一' }),
createSubscribeDirectory({ download_path: '/downloads', name: '目录二' }),
createSubscribeDirectory({ download_path: undefined, name: '空目录' }),
],
downloaders: [createSubscribeDownloader({ name: '下载器 A' })],
filterRuleGroups: [createSubscribeRuleGroup({ name: '高优先级' })],
onDirectories: requests.directories,
onDownloaders: requests.downloaders,
onFilterRuleGroups: requests.rules,
onSites: requests.sites,
sites: [activeSite, inactiveSite],
})
const user = userEvent.setup()
await renderDialog({ default: true, type: '电影' })
await waitFor(() => expect(requests.sites).toHaveBeenCalledOnce())
await waitFor(() => expect(requests.downloaders).toHaveBeenCalledOnce())
await waitFor(() => expect(requests.directories).toHaveBeenCalledOnce())
await waitFor(() => expect(requests.rules).toHaveBeenCalledOnce())
await user.click(screen.getByLabelText('订阅站点'))
expect(await screen.findByText('启用站点')).toBeInTheDocument()
expect(screen.queryByText('停用站点')).not.toBeInTheDocument()
await user.keyboard('{Escape}')
await user.click(screen.getByLabelText('下载器'))
expect(await screen.findByText('下载器 A')).toBeInTheDocument()
expect(screen.getAllByText('默认').length).toBeGreaterThanOrEqual(1)
await user.keyboard('{Escape}')
await user.click(screen.getByLabelText('保存路径'))
expect(await screen.findAllByText('/downloads')).toHaveLength(1)
expect(screen.queryByText('undefined')).not.toBeInTheDocument()
await user.keyboard('{Escape}')
await user.click(screen.getByRole('tab', { name: '进阶' }))
await user.click(screen.getByLabelText('优先级规则组'))
expect(await screen.findByText('高优先级')).toBeInTheDocument()
})
it('allows non-admin users to read public defaults but not private rules or save them', async () => {
const configRequested = vi.fn()
const rulesRequested = vi.fn()
const saved = vi.fn()
server.use(
defaultSubscribeConfigHandler('电视剧', createSubscribe({ id: 0, type: '电视剧' }), 200, configRequested),
saveDefaultSubscribeConfigHandler('电视剧', { success: true }, 200, saved),
)
useDialogOptions({ onFilterRuleGroups: rulesRequested })
const { events } = await renderDialog({ default: true, type: '电视剧' }, false)
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
expect(rulesRequested).not.toHaveBeenCalled()
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
expect(saved).not.toHaveBeenCalled()
expect(events.save).not.toHaveBeenCalled()
})
it.each(['电影', '电视剧'] as const)('loads and saves %s default configuration as an administrator', async type => {
const configRequested = vi.fn()
const saved = vi.fn()
server.use(
defaultSubscribeConfigHandler(type, createSubscribe({ id: 0, show_edit_dialog: false, type }), 200, configRequested),
saveDefaultSubscribeConfigHandler(type, { success: true }, 200, saved),
)
useDialogOptions()
const user = userEvent.setup()
const { events } = await renderDialog({ default: true, type })
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(screen.getByLabelText('订阅时编辑更多规则')).not.toBeChecked())
await user.click(screen.getByLabelText('订阅时编辑更多规则'))
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(saved).toHaveBeenCalledOnce())
expect(saved.mock.calls[0][0]).toMatchObject({ show_edit_dialog: true, type })
expect(events.save).toHaveBeenCalledOnce()
expect(mocks.toastSuccess).toHaveBeenCalledWith(`${type}订阅默认规则保存成功`)
})
it('submits the complete TV editing form and exposes the close action', async () => {
const record = createSubscribe({
best_version: 1,
best_version_full: 0,
id: 809,
name: '完整表单测试剧',
search_imdbid: 0,
season: 1,
tmdbid: 8090,
type: '电视剧',
})
const updated = vi.fn()
server.use(subscribeDetailsHandler(809, record), updateSubscribeHandler({ success: true }, 200, updated))
useDialogOptions({
directories: [createSubscribeDirectory({ download_path: '/完整目录' })],
downloaders: [createSubscribeDownloader({ name: '完整下载器' })],
episodeGroups: [{ episode_count: 12, group_count: 1, id: 8091, name: '完整剧集组' }],
filterRuleGroups: [createSubscribeRuleGroup({ name: '完整规则组' })],
sites: [createSubscribeSite({ id: 8092, name: '完整站点' })],
tmdbId: 8090,
})
const user = userEvent.setup()
const { events } = await renderDialog({ subid: 809 })
await screen.findByText('完整表单测试剧 S01')
const chooseOption = async (label: string, option: string) => {
await user.click(screen.getByLabelText(label))
await user.click(await screen.findByText(option, {}, { timeout: 2_000 }))
}
await user.type(screen.getByLabelText('总集数'), '24')
await user.type(screen.getByLabelText('开始集数'), '2')
await chooseOption('质量', 'Remux')
await chooseOption('分辨率', '1080p')
await chooseOption('特效', 'HDR')
await chooseOption('订阅站点', '完整站点')
await user.keyboard('{Escape}')
await chooseOption('下载器', '完整下载器')
await chooseOption('保存路径', '/完整目录')
await user.click(screen.getByLabelText('全集洗版'))
await user.click(screen.getByLabelText('使用 ImdbID 搜索'))
await user.click(screen.getByRole('tab', { name: '进阶' }))
await user.type(screen.getByLabelText('包含(关键字、正则式)'), '国语')
await user.type(screen.getByLabelText('排除(关键字、正则式)'), '预告')
await chooseOption('优先级规则组', '完整规则组')
await user.keyboard('{Escape}')
await chooseOption('指定剧集组', '完整剧集组')
await chooseOption('指定季', '第 2 季')
await user.type(screen.getByLabelText('自定义类别'), '纪录片')
await user.type(screen.getByLabelText('自定义识别词'), '测试词 => 正式词')
const closeButton = document.querySelector<HTMLButtonElement>('.v-card-item button')
expect(closeButton).not.toBeNull()
await user.click(closeButton as HTMLButtonElement)
expect(events.close).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
expect(updated.mock.calls[0][0]).toMatchObject({
best_version_full: true,
custom_words: '测试词 => 正式词',
downloader: '完整下载器',
effect: '[\\s.]+HDR[\\s.]+|HDR10|HDR10\\+',
episode_group: 8091,
exclude: '预告',
filter_groups: ['完整规则组'],
include: '国语',
media_category: '纪录片',
quality: 'Remux',
resolution: '1080[pi]|x1080',
save_path: '/完整目录',
search_imdbid: true,
season: 2,
sites: [8092],
start_episode: '2',
total_episode: '24',
})
expect(events.save).toHaveBeenCalledWith(expect.objectContaining({ season: 2 }))
})
it.each([
['business failure', 200, { message: 'rejected', success: false }, '电影订阅默认规则保存失败rejected'],
['HTTP failure', 500, { message: 'server down', success: false }, '电影订阅默认规则保存失败server down'],
])('keeps a default dialog open after a %s', async (_case, status, response, expectedMessage) => {
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
server.use(
defaultSubscribeConfigHandler('电影', createSubscribe({ id: 0, type: '电影' })),
saveDefaultSubscribeConfigHandler('电影', response, status),
)
useDialogOptions()
const { events } = await renderDialog({ default: true, type: '电影' })
await screen.findByText('电影')
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedMessage))
expect(events.save).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '保存' })).toBeInTheDocument()
consoleLog.mockRestore()
})
it('updates an edited subscription and clears full-season mode when versioning is disabled', async () => {
const record = createSubscribe({
best_version: 1,
best_version_full: 1,
id: 803,
keyword: '旧关键词',
name: '编辑测试剧',
season: 1,
tmdbid: 8030,
type: '电视剧',
})
const updated = vi.fn()
server.use(subscribeDetailsHandler(803, record), updateSubscribeHandler({ success: true }, 200, updated))
useDialogOptions({ tmdbId: 8030 })
const user = userEvent.setup()
const { events } = await renderDialog({ subid: 803 })
await screen.findByText('编辑测试剧 S01')
const keyword = screen.getByLabelText('搜索关键词')
await user.clear(keyword)
await user.type(keyword, '新关键词')
await user.click(screen.getByLabelText('洗版'))
await waitFor(() => expect(screen.queryByLabelText('全集洗版')).not.toBeInTheDocument())
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
expect(updated.mock.calls[0][0]).toMatchObject({
best_version: false,
best_version_full: false,
id: 803,
keyword: '新关键词',
})
expect(events.save).toHaveBeenCalledWith(expect.objectContaining({ id: 803, keyword: '新关键词' }))
expect(mocks.toastSuccess).toHaveBeenCalledWith('编辑测试剧 S01 更新成功!')
})
it.each([
['business failure', 200, { message: 'invalid', success: false }, '失败编辑项 更新失败invalid'],
['HTTP failure', 500, { message: 'server down', success: false }, '失败编辑项 更新失败server down'],
])('keeps an edit dialog usable after an update %s', async (_case, status, response, expectedMessage) => {
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
const record = createSubscribe({ id: 804, name: '失败编辑项', tmdbid: 8040 })
server.use(subscribeDetailsHandler(804, record), updateSubscribeHandler(response, status))
useDialogOptions({ tmdbId: 8040 })
const { events } = await renderDialog({ subid: 804 })
await screen.findByText('失败编辑项')
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedMessage))
expect(events.save).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '保存' })).toBeInTheDocument()
consoleLog.mockRestore()
})
it('does not delete when confirmation is cancelled', async () => {
const record = createSubscribe({ id: 805, name: '保留订阅', tmdbid: 8050 })
const deleted = vi.fn()
server.use(subscribeDetailsHandler(805, record), deleteSubscribeByIdHandler(805, { success: true }, 200, deleted))
useDialogOptions({ tmdbId: 8050 })
mocks.confirm.mockResolvedValue(false)
const { events } = await renderDialog({ subid: 805 })
await screen.findByText('保留订阅')
await fireEvent.click(screen.getByRole('button', { name: '取消订阅' }))
expect(deleted).not.toHaveBeenCalled()
expect(events.remove).not.toHaveBeenCalled()
})
it('emits remove only after a successful deletion', async () => {
const record = createSubscribe({ id: 806, name: '删除订阅', tmdbid: 8060 })
const deleted = vi.fn()
server.use(subscribeDetailsHandler(806, record), deleteSubscribeByIdHandler(806, { success: true }, 200, deleted))
useDialogOptions({ tmdbId: 8060 })
const { events } = await renderDialog({ subid: 806 })
await screen.findByText('删除订阅')
await fireEvent.click(screen.getByRole('button', { name: '取消订阅' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
expect(events.remove).toHaveBeenCalledOnce()
expect(mocks.toastSuccess).toHaveBeenCalledWith('删除订阅 已取消订阅!')
})
it.each([
['business failure', 200, { message: 'not allowed', success: false }, '删除失败项 取消订阅失败not allowed'],
['HTTP failure', 500, { message: 'server down', success: false }, '删除失败项 取消订阅失败server down'],
])('keeps the subscription after a delete %s', async (_case, status, response, expectedMessage) => {
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
const record = createSubscribe({ id: 807, name: '删除失败项', tmdbid: 8070 })
server.use(subscribeDetailsHandler(807, record), deleteSubscribeByIdHandler(807, response, status))
useDialogOptions({ tmdbId: 8070 })
const { events } = await renderDialog({ subid: 807 })
await screen.findByText('删除失败项')
await fireEvent.click(screen.getByRole('button', { name: '取消订阅' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedMessage))
expect(events.remove).not.toHaveBeenCalled()
expect(screen.getByText('删除失败项')).toBeInTheDocument()
consoleLog.mockRestore()
})
it('remains editable when an auxiliary options request fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const record = createSubscribe({ id: 808, keyword: '仍可编辑', name: '部分失败项', tmdbid: 8080 })
const updated = vi.fn()
server.use(subscribeDetailsHandler(808, record), updateSubscribeHandler({ success: true }, 200, updated))
useDialogOptions({ tmdbId: 8080 })
server.use(
http.get(subscribeApiUrls.downloaders, () =>
HttpResponse.json({ message: 'unavailable', success: false }, { status: 500 }),
),
)
await renderDialog({ subid: 808 })
expect(await screen.findByDisplayValue('仍可编辑')).toBeInTheDocument()
await waitFor(() => expect(consoleError).toHaveBeenCalled())
await fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
consoleError.mockRestore()
})
})

View File

@@ -30,6 +30,7 @@ const propsWithoutModelValue = computed(() => {
return { ...rest, ...attrs }
})
/** 同步路径输入值并向父组件派发更新。 */
function updateModelValue(value: string) {
innerValue.value = value
emit('update:modelValue', value)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,251 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import logoUrl from '@images/logo.svg'
import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue'
const props = withDefaults(
defineProps<{
animate?: boolean
intensity?: number
}>(),
{
animate: true,
intensity: 45,
},
)
const rootRef = ref<HTMLSpanElement | null>(null)
const logoMaskStyle = computed(() => ({
'--logo-mask': `url("${logoUrl}")`,
'--prism-intensity': Math.min(1, Math.max(0, props.intensity / 100)),
}))
let pointerFrame: number | null = null
let pendingPointerX = 0.5
let pendingPointerY = 0.42
/** 将指针位置映射为同一套棱镜反射和轻微空间倾角。 */
function renderPointerResponse() {
pointerFrame = null
const root = rootRef.value
if (!root) return
root.style.setProperty('--logo-light-x', `${(pendingPointerX * 100).toFixed(2)}%`)
root.style.setProperty('--logo-light-y', `${(pendingPointerY * 100).toFixed(2)}%`)
root.style.setProperty('--logo-tilt-x', `${((0.5 - pendingPointerY) * 7).toFixed(2)}deg`)
root.style.setProperty('--logo-tilt-y', `${((pendingPointerX - 0.5) * 9).toFixed(2)}deg`)
}
function queuePointerResponse() {
if (pointerFrame === null) pointerFrame = window.requestAnimationFrame(renderPointerResponse)
}
function handlePointerMove(event: PointerEvent) {
if (event.pointerType === 'touch') return
const bounds = rootRef.value?.getBoundingClientRect()
if (!bounds?.width || !bounds.height) return
pendingPointerX = Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width))
pendingPointerY = Math.min(1, Math.max(0, (event.clientY - bounds.top) / bounds.height))
queuePointerResponse()
}
function resetPointerResponse() {
pendingPointerX = 0.5
pendingPointerY = 0.42
queuePointerResponse()
}
onBeforeUnmount(() => {
if (pointerFrame !== null) window.cancelAnimationFrame(pointerFrame)
})
</script>
<template>
<span
ref="rootRef"
class="prismatic-logo"
:class="{ 'prismatic-logo--animated': props.animate }"
:style="logoMaskStyle"
role="img"
aria-label="MoviePilot"
@pointermove="handlePointerMove"
@pointerleave="resetPointerResponse"
>
<ThemeLogoMark class="prismatic-logo__base" decorative />
<span class="prismatic-logo__spectrum" aria-hidden="true" />
<span class="prismatic-logo__specular" aria-hidden="true" />
<span class="prismatic-logo__reveal" aria-hidden="true" />
</span>
</template>
<style scoped lang="scss">
.prismatic-logo {
--logo-light-x: 50%;
--logo-light-y: 42%;
--logo-tilt-x: 0deg;
--logo-tilt-y: 0deg;
position: relative;
display: grid;
isolation: isolate;
block-size: 100%;
inline-size: 100%;
place-items: center;
transform: perspective(520px) rotateX(var(--logo-tilt-x)) rotateY(var(--logo-tilt-y));
transform-style: preserve-3d;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.prismatic-logo--animated {
animation: prismatic-logo-enter 620ms cubic-bezier(0.16, 1, 0.3, 1) 80ms backwards;
}
.prismatic-logo::before {
position: absolute;
z-index: -1;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(var(--v-theme-primary), 0.34),
rgba(var(--v-theme-primary), 0.1) 44%,
transparent 72%
);
content: '';
filter: blur(13px);
inset: 17%;
opacity: calc(0.36 + var(--prism-intensity) * 0.56);
transform: translate3d(0, 8px, -16px) scaleX(1.18);
}
.prismatic-logo__spectrum,
.prismatic-logo__specular,
.prismatic-logo__reveal {
position: absolute;
display: block;
block-size: 100%;
inline-size: 100%;
inset: 0;
pointer-events: none;
user-select: none;
}
.prismatic-logo__base {
position: absolute;
display: block;
block-size: calc(100% - 14px);
filter:
drop-shadow(0 8px 12px rgba(24, 8, 52, 0.3))
drop-shadow(0 0 10px rgba(var(--v-theme-primary), 0.22));
inline-size: calc(100% - 14px);
inset: 7px;
pointer-events: none;
transform: translateZ(10px);
user-select: none;
}
.prismatic-logo__spectrum,
.prismatic-logo__specular,
.prismatic-logo__reveal {
-webkit-mask: var(--logo-mask) center / calc(100% - 14px) calc(100% - 14px) no-repeat;
mask: var(--logo-mask) center / calc(100% - 14px) calc(100% - 14px) no-repeat;
}
.prismatic-logo__spectrum {
background:
radial-gradient(
circle at var(--logo-light-x) var(--logo-light-y),
rgba(255, 255, 255, 0.95),
color-mix(in srgb, rgb(var(--v-theme-primary)) 68%, white 32%) 13%,
transparent 36%
),
conic-gradient(
from 218deg at var(--logo-light-x) var(--logo-light-y),
color-mix(in srgb, rgb(var(--v-theme-primary)) 64%, #ff69d2 36%),
color-mix(in srgb, rgb(var(--v-theme-primary)) 64%, #75d4ff 36%),
color-mix(in srgb, rgb(var(--v-theme-primary)) 78%, white 22%),
color-mix(in srgb, rgb(var(--v-theme-primary)) 64%, #ff69d2 36%)
);
mix-blend-mode: screen;
opacity: calc(0.22 + var(--prism-intensity) * 0.58);
transform: translateZ(14px);
}
.prismatic-logo__specular {
background: radial-gradient(
ellipse 28% 20% at var(--logo-light-x) var(--logo-light-y),
rgba(255, 255, 255, 0.96),
color-mix(in srgb, rgb(var(--v-theme-primary)) 54%, white 46%) 28%,
transparent 72%
);
mix-blend-mode: screen;
opacity: calc(0.3 + var(--prism-intensity) * 0.66);
transform: translateZ(18px);
}
.prismatic-logo__reveal {
background: linear-gradient(
112deg,
transparent 30%,
rgba(255, 255, 255, 0.18) 39%,
rgba(255, 255, 255, 0.98) 48%,
color-mix(in srgb, rgb(var(--v-theme-primary)) 62%, #7dd3fc 38%) 54%,
transparent 66%
);
background-position: 100% 50%;
background-size: 300% 100%;
mix-blend-mode: screen;
opacity: 0;
transform: translateZ(20px);
}
.prismatic-logo--animated .prismatic-logo__reveal {
animation: prismatic-logo-reveal 840ms cubic-bezier(0.2, 0.76, 0.18, 1) 160ms both;
}
@keyframes prismatic-logo-enter {
0% {
opacity: 0;
transform: perspective(520px) translateY(10px) scale(0.82) rotateX(-8deg) rotateY(10deg);
}
62% {
opacity: 1;
transform: perspective(520px) translateY(-2px) scale(1.025) rotateX(1deg) rotateY(-1deg);
}
100% {
opacity: 1;
transform: perspective(520px) translateY(0) scale(1) rotateX(0) rotateY(0);
}
}
@keyframes prismatic-logo-reveal {
0% {
background-position: 100% 50%;
opacity: 0;
}
24% {
opacity: 1;
}
100% {
background-position: 0% 50%;
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.prismatic-logo,
.prismatic-logo__reveal {
animation: none;
transform: none;
transition: none;
}
.prismatic-logo__reveal {
display: none;
}
}
</style>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import logoSvg from '@images/logo.svg?raw'
import { applyThemeLogoPalette, createThemeLogoPalette } from '@/utils/themeLogo'
import { computed } from 'vue'
import { useTheme } from 'vuetify'
const props = withDefaults(
defineProps<{
/** 装饰模式不重复暴露图像语义,适用于已有外层可访问名称的复合标识。 */
decorative?: boolean
}>(),
{
decorative: false,
},
)
const theme = useTheme()
/** 保留品牌 SVG 的分面与高光结构,只将原始紫色色阶映射到当前主题色家族。 */
const themedLogoSvg = computed(() =>
applyThemeLogoPalette(logoSvg, createThemeLogoPalette(theme.current.value.colors.primary)),
)
</script>
<template>
<span
class="theme-logo-mark"
:role="props.decorative ? undefined : 'img'"
:aria-label="props.decorative ? undefined : 'MoviePilot'"
:aria-hidden="props.decorative || undefined"
>
<span class="theme-logo-mark__svg" v-html="themedLogoSvg" />
</span>
</template>
<style scoped lang="scss">
.theme-logo-mark {
display: inline-block;
flex: none;
block-size: 3em;
inline-size: 3em;
}
.theme-logo-mark__svg,
.theme-logo-mark__svg :deep(svg) {
display: block;
block-size: 100%;
inline-size: 100%;
}
</style>

View File

@@ -0,0 +1,598 @@
import type { MediaInfo, MediaSeason, Subscribe } from '@/api/types'
import {
getMediaSubscribeId,
getSubscribeMode,
type SeasonSubscribeModes,
type SubscribeMode,
useMediaSubscribe,
} from '@/composables/useMediaSubscribe'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import {
createSubscribe,
createSubscribeMovie,
createSubscribeTv,
} from '@tests/support/factories/subscribe'
import {
createSubscribeHandler,
defaultSubscribeConfigHandler,
deleteSubscribeByMediaHandler,
querySubscribeByMediaHandler,
updateSubscribeHandler,
} from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
cacheStatus: vi.fn(),
confirm: vi.fn(),
doneProgress: vi.fn(),
onEditRemove: vi.fn(),
openSharedDialog: vi.fn(),
startProgress: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => mocks.confirm,
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('@/api/nprogress', () => ({
configureNProgress: vi.fn(),
doneNProgress: mocks.doneProgress,
startNProgress: mocks.startProgress,
}))
vi.mock('@/utils/mediaStatusCache', () => ({
setCachedMediaSubscribeStatus: (...args: unknown[]) => mocks.cacheStatus(...args),
}))
interface MultiSeasonInput {
modes?: SubscribeMode | SeasonSubscribeModes
seasons?: MediaSeason[]
visible?: number[]
}
interface HarnessOptions {
actionSeason?: number | null
canSubscribe?: boolean
isExists?: boolean
isSubscribed?: boolean
media?: MediaInfo
modes?: SeasonSubscribeModes
multi?: MultiSeasonInput
seasonsMap?: Record<number, boolean>
subscribedSeasons?: number[]
useSeasonMap?: boolean
}
async function renderSubscribeHarness(options: HarnessOptions = {}) {
const media = options.media
const actionSeason = options.actionSeason ?? (media?.type === '电视剧' ? media.season ?? 1 : null)
const Harness = defineComponent({
name: 'MediaSubscribeHarness',
setup() {
const isSubscribed = ref(options.isSubscribed ?? false)
const seasonsSubscribed = ref<Record<number, boolean>>({ ...(options.seasonsMap ?? {}) })
const subscribedSeasons = ref([...(options.subscribedSeasons ?? [])])
const subscribedSeasonModes = ref<SeasonSubscribeModes>({ ...(options.modes ?? {}) })
const checkResult = ref('idle')
const actions = useMediaSubscribe({
canSubscribe: () => options.canSubscribe ?? true,
getSubscribeStatusKey: season => `status:${season ?? 'all'}`,
isExists: () => options.isExists ?? false,
isSubscribed,
media: () => media,
onEditRemove: mocks.onEditRemove,
primarySeason: () => media?.season ?? null,
seasonsSubscribed: options.useSeasonMap ? seasonsSubscribed : undefined,
subscribedSeasonModes,
subscribedSeasons,
})
async function check() {
try {
checkResult.value = (await actions.checkSubscribe(actionSeason)) ? 'subscribed' : 'missing'
} catch {
checkResult.value = 'error'
}
}
function alignSeasons() {
actions.subscribeSeasons(
options.multi?.seasons ?? [],
{},
'episode-group-1',
options.multi?.modes ?? 'normal',
options.multi?.visible ?? [],
)
}
return {
addBestFull: () => actions.addSubscribe(actionSeason, { best_version: 1, best_version_full: 1 }),
addNormal: () => actions.addSubscribe(actionSeason),
alignSeasons,
check,
checkResult,
handlePrimary: () => actions.handleSubscribe(),
handleSeason: () => actions.handleSubscribe(actionSeason, 'episode-group-entry'),
isSubscribed,
modes: subscribedSeasonModes,
openSeason: () => actions.openSubscribeSeasonDialog(actionSeason, 'episode-group-entry'),
remove: () => actions.removeSubscribe(actionSeason),
seasons: subscribedSeasons,
seasonsMap: seasonsSubscribed,
}
},
template: `
<button type="button" @click="handlePrimary">primary</button>
<button type="button" @click="handleSeason">season</button>
<button type="button" @click="addNormal">add-normal</button>
<button type="button" @click="addBestFull">add-best-full</button>
<button type="button" @click="remove">remove</button>
<button type="button" @click="check">check</button>
<button type="button" @click="openSeason">open-season</button>
<button type="button" @click="alignSeasons">align-seasons</button>
<output data-testid="subscribed">{{ String(isSubscribed) }}</output>
<output data-testid="seasons">{{ JSON.stringify(seasons) }}</output>
<output data-testid="season-map">{{ JSON.stringify(seasonsMap) }}</output>
<output data-testid="modes">{{ JSON.stringify(modes) }}</output>
<output data-testid="check-result">{{ checkResult }}</output>
`,
})
return renderWithProviders(Harness, {
initialState: {
user: {
superUser: false,
userName: 'tester',
},
},
})
}
function getDialogCall(index = 0) {
const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [
unknown,
Record<string, unknown>,
Record<string, (...args: any[]) => unknown>,
Record<string, unknown>,
]
return { events, options, props }
}
describe('media subscribe identifiers and modes', () => {
it.each([
['TMDB before all fallback identifiers', { bangumi_id: '30', douban_id: '20', tmdb_id: 10 }, 'tmdb:10'],
['Douban before Bangumi and generic identifiers', { bangumi_id: '30', douban_id: '20', tmdb_id: undefined }, 'douban:20'],
['Bangumi before a generic identifier', { bangumi_id: '30', douban_id: undefined, tmdb_id: undefined }, 'bangumi:30'],
[
'generic identifiers when provider ids are absent',
{ bangumi_id: undefined, douban_id: undefined, media_id: 'abc', mediaid_prefix: 'custom', tmdb_id: undefined },
'custom:abc',
],
])('uses %s', (_case, overrides, expected) => {
expect(getMediaSubscribeId(createSubscribeMovie(overrides))).toBe(expected)
})
it.each([
[{ best_version: false, best_version_full: true }, 'normal'],
[{ best_version: 0, best_version_full: 1 }, 'normal'],
[{ best_version: '0', best_version_full: '1' }, 'normal'],
[{ best_version: true, best_version_full: false }, 'best_version'],
[{ best_version: 1, best_version_full: 0 }, 'best_version'],
[{ best_version: '1', best_version_full: '1' }, 'best_version_full'],
] as const)('normalizes compatible mode flags %#', (subscribe, expected) => {
expect(getSubscribeMode(subscribe)).toBe(expected)
})
})
describe('useMediaSubscribe entry flows', () => {
beforeEach(() => {
mocks.confirm.mockResolvedValue(true)
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
})
it('creates a normal movie subscription and synchronizes public state', async () => {
const media = createSubscribeMovie({ title: '普通电影', tmdb_id: 101, year: '2025' })
const created = vi.fn()
server.use(
createSubscribeHandler({ data: { id: 501 }, success: true }, 200, created),
defaultSubscribeConfigHandler('电影', { show_edit_dialog: false }),
)
await renderSubscribeHarness({ media })
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
await waitFor(() => expect(created).toHaveBeenCalledOnce())
await waitFor(() => expect(mocks.doneProgress).toHaveBeenCalledOnce())
expect(created).toHaveBeenCalledWith({
bangumiid: undefined,
doubanid: undefined,
episode_group: '',
mediaid: '',
name: '普通电影',
season: null,
tmdbid: 101,
type: '电影',
year: '2025',
})
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
expect(mocks.cacheStatus).toHaveBeenCalledWith('status:all', true)
expect(mocks.toastSuccess).toHaveBeenCalled()
expect(mocks.startProgress).toHaveBeenCalledOnce()
expect(mocks.doneProgress).toHaveBeenCalledOnce()
})
it('keeps a successful creation successful when default configuration loading fails', async () => {
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
const media = createSubscribeMovie({ title: '辅助查询失败电影', tmdb_id: 111 })
const created = vi.fn()
const configQueried = vi.fn()
server.use(
createSubscribeHandler({ data: { id: 511 }, success: true }, 200, created),
defaultSubscribeConfigHandler('电影', {}, 500, configQueried),
)
await renderSubscribeHarness({ media })
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
await waitFor(() => expect(created).toHaveBeenCalledOnce())
await waitFor(() => expect(configQueried).toHaveBeenCalledOnce())
await waitFor(() => expect(mocks.doneProgress).toHaveBeenCalledOnce())
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
expect(mocks.cacheStatus).toHaveBeenCalledWith('status:all', true)
expect(mocks.toastSuccess).toHaveBeenCalledOnce()
expect(mocks.toastError).not.toHaveBeenCalled()
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
consoleLog.mockRestore()
})
it('opens the mode chooser for an existing movie and creates the selected mode', async () => {
const media = createSubscribeMovie({ title: '已入库电影', tmdb_id: 102 })
const created = vi.fn()
server.use(
createSubscribeHandler({ data: { id: 502 }, success: true }, 200, created),
defaultSubscribeConfigHandler('电影', { show_edit_dialog: false }),
)
await renderSubscribeHarness({ isExists: true, media })
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
const modeDialog = getDialogCall()
expect(modeDialog.props).toMatchObject({ modes: ['normal', 'best_version'], type: '电影' })
modeDialog.events.choose('best_version')
await waitFor(() => expect(created).toHaveBeenCalledOnce())
await waitFor(() => expect(mocks.doneProgress).toHaveBeenCalledOnce())
expect(created.mock.calls[0][0]).toMatchObject({ best_version: 1, best_version_full: 0, season: null })
})
it('opens the TV season chooser with current state and the default mode', async () => {
const media = createSubscribeTv({ season: 2, title: '季选择剧集', tmdb_id: 103 })
server.use(defaultSubscribeConfigHandler('电视剧', { best_version: '1', best_version_full: '1' }))
await renderSubscribeHarness({
media,
modes: { 1: 'normal', 2: 'best_version' },
subscribedSeasons: [1, 2],
})
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const dialog = getDialogCall()
expect(dialog.props).toMatchObject({
defaultSubscribeMode: 'best_version_full',
selectedSeason: undefined,
subscribedSeasonModes: { 1: 'normal', 2: 'best_version' },
subscribedSeasons: [1, 2],
})
expect(dialog.options).toEqual({ closeOn: ['close', 'subscribe'] })
})
it('opens the season chooser from an unsubscribed TV season entry', async () => {
const media = createSubscribeTv({ season: 2, title: '单季入口剧集', tmdb_id: 1031 })
server.use(defaultSubscribeConfigHandler('电视剧', { best_version: 0 }))
await renderSubscribeHarness({ actionSeason: 2, media })
await fireEvent.click(screen.getByRole('button', { name: 'season' }))
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const dialog = getDialogCall()
expect(dialog.props).toMatchObject({
initialEpisodeGroup: 'episode-group-entry',
selectedSeason: 2,
})
})
it('cancels a subscribed movie from the primary entry', async () => {
const deleted = vi.fn()
server.use(deleteSubscribeByMediaHandler('tmdb:1032', { success: true }, 200, url => deleted(url)))
await renderSubscribeHarness({
isSubscribed: true,
media: createSubscribeMovie({ title: '主入口取消电影', tmdb_id: 1032 }),
})
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
expect((deleted.mock.calls[0][0] as URL).searchParams.has('season')).toBe(false)
expect(screen.getByTestId('subscribed')).toHaveTextContent('false')
expect(mocks.cacheStatus).toHaveBeenCalledWith('status:all', false)
})
it('cancels a subscribed TV season only after confirmation', async () => {
const media = createSubscribeTv({ season: 2, title: '取消季剧集', tmdb_id: 104 })
const deleted = vi.fn()
server.use(deleteSubscribeByMediaHandler('tmdb:104', { success: true }, 200, url => deleted(url)))
await renderSubscribeHarness({
isSubscribed: true,
media,
modes: { 1: 'normal', 2: 'best_version' },
seasonsMap: { 1: true, 2: true },
subscribedSeasons: [1, 2],
useSeasonMap: true,
})
mocks.confirm.mockResolvedValueOnce(false)
await fireEvent.click(screen.getByRole('button', { name: 'season' }))
expect(deleted).not.toHaveBeenCalled()
await fireEvent.click(screen.getByRole('button', { name: 'season' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
expect((deleted.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
expect(screen.getByTestId('season-map')).toHaveTextContent('"2":false')
expect(screen.getByTestId('modes')).not.toHaveTextContent('"2"')
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
expect(mocks.cacheStatus).toHaveBeenCalledWith('status:2', false)
})
it.each([
{
label: 'Douban',
media: createSubscribeTv({ douban_id: 'db-1', tmdb_id: undefined }),
mediaId: 'douban:db-1',
record: createSubscribe({ doubanid: 'db-1', season: 2, tmdbid: 0, type: '电视剧' }),
},
{
label: 'Bangumi',
media: createSubscribeTv({ bangumi_id: '42', tmdb_id: undefined }),
mediaId: 'bangumi:42',
record: createSubscribe({ bangumiid: 42 as unknown as string, season: 2, tmdbid: 0, type: '电视剧' }),
},
{
label: 'generic provider',
media: createSubscribeTv({ media_id: 'series-1', mediaid_prefix: 'custom', tmdb_id: undefined }),
mediaId: 'custom:series-1',
record: createSubscribe({ mediaid: 'custom:series-1', season: 2, tmdbid: 0, type: '电视剧' }),
},
])('queries $label subscriptions through the media endpoint', async ({ media, mediaId, record }) => {
const queried = vi.fn()
server.use(querySubscribeByMediaHandler(mediaId, record, 200, url => queried(url)))
await renderSubscribeHarness({ actionSeason: 2, media })
await fireEvent.click(screen.getByRole('button', { name: 'check' }))
await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('subscribed'))
expect(queried).toHaveBeenCalledOnce()
expect((queried.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
})
it('cancels a non-TMDB season through the media endpoint', async () => {
const media = createSubscribeTv({ douban_id: 'db-delete', tmdb_id: undefined })
const deleted = vi.fn()
server.use(deleteSubscribeByMediaHandler('douban:db-delete', { success: true }, 200, url => deleted(url)))
await renderSubscribeHarness({
actionSeason: 2,
isSubscribed: true,
media,
seasonsMap: { 2: true },
useSeasonMap: true,
})
await fireEvent.click(screen.getByRole('button', { name: 'remove' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
expect((deleted.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
expect(screen.getByTestId('season-map')).toHaveTextContent('"2":false')
})
it('aligns visible seasons while preserving hidden subscriptions', async () => {
const media = createSubscribeTv({ title: '多季剧集', tmdb_id: 105 })
const deleted = vi.fn()
const queried = vi.fn()
const updated = vi.fn()
const created = vi.fn()
server.use(
deleteSubscribeByMediaHandler('tmdb:105', { success: true }, 200, url => deleted(url)),
querySubscribeByMediaHandler(
'tmdb:105',
createSubscribe({ id: 605, season: 2, tmdbid: 105, type: '电视剧' }),
200,
url => queried(url),
),
updateSubscribeHandler({ success: true }, 200, updated),
createSubscribeHandler({ data: { id: 606 }, success: true }, 200, created),
)
await renderSubscribeHarness({
isSubscribed: true,
media,
modes: { 1: 'normal', 2: 'normal', 4: 'best_version' },
multi: {
modes: { 2: 'best_version', 3: 'best_version_full' },
seasons: [{ season_number: 2 }, { season_number: 3 }],
visible: [1, 2, 3],
},
subscribedSeasons: [1, 2, 4],
})
await fireEvent.click(screen.getByRole('button', { name: 'align-seasons' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
await waitFor(() => expect(created).toHaveBeenCalledOnce())
expect((deleted.mock.calls[0][0] as URL).searchParams.get('season')).toBe('1')
expect((queried.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
expect(updated.mock.calls[0][0]).toMatchObject({ best_version: 1, best_version_full: 0, id: 605, season: 2 })
expect(created.mock.calls[0][0]).toMatchObject({
best_version: 1,
best_version_full: 1,
episode_group: 'episode-group-1',
season: 3,
})
await waitFor(() => expect(screen.getByTestId('seasons')).toHaveTextContent('[2,3,4]'))
expect(screen.getByTestId('modes')).toHaveTextContent('"2":"best_version"')
expect(screen.getByTestId('modes')).toHaveTextContent('"3":"best_version_full"')
expect(screen.getByTestId('modes')).toHaveTextContent('"4":"best_version"')
})
it('synchronizes the created season after edit save and remove events', async () => {
const media = createSubscribeTv({ season: 2, title: '编辑后同步', tmdb_id: 106 })
server.use(
createSubscribeHandler({ data: { id: 701 }, success: true }),
defaultSubscribeConfigHandler('电视剧', { show_edit_dialog: true }),
)
await renderSubscribeHarness({
isSubscribed: true,
media,
modes: { 1: 'normal' },
subscribedSeasons: [1],
})
await fireEvent.click(screen.getByRole('button', { name: 'add-best-full' }))
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
expect(screen.getByTestId('seasons')).toHaveTextContent('[1,2]')
const editDialog = getDialogCall()
expect(editDialog.props).toEqual({ subid: 701 })
editDialog.events.save(createSubscribe({ best_version: 1, best_version_full: 0, id: 701, season: 2, type: '电视剧' }))
await waitFor(() => expect(screen.getByTestId('modes')).toHaveTextContent('"2":"best_version"'))
editDialog.events.remove()
await waitFor(() => expect(screen.getByTestId('seasons')).toHaveTextContent('[1]'))
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
expect(screen.getByTestId('modes')).not.toHaveTextContent('"2"')
expect(mocks.cacheStatus).toHaveBeenLastCalledWith('status:2', false)
expect(mocks.onEditRemove).toHaveBeenCalledOnce()
})
it.each([
['business failure', 200, { message: 'duplicate', success: false }],
['HTTP failure', 500, { message: 'server down', success: false }],
])('keeps state unchanged when create returns a %s', async (_case, status, response) => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
server.use(createSubscribeHandler(response, status))
await renderSubscribeHarness({ media: createSubscribeMovie({ tmdb_id: 107 }) })
await fireEvent.click(screen.getByRole('button', { name: 'add-normal' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalled())
expect(screen.getByTestId('subscribed')).toHaveTextContent('false')
expect(mocks.cacheStatus).not.toHaveBeenCalled()
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
expect(mocks.doneProgress).toHaveBeenCalledOnce()
consoleError.mockRestore()
})
it.each([
['business failure', 200, { message: 'delete rejected', success: false }],
['HTTP failure', 500, { message: 'server down', success: false }],
])('keeps subscription state when removal returns a %s', async (_case, status, response) => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const deleted = vi.fn()
server.use(deleteSubscribeByMediaHandler('tmdb:108', response, status, url => deleted(url)))
await renderSubscribeHarness({
actionSeason: 2,
isSubscribed: true,
media: createSubscribeTv({ season: 2, tmdb_id: 108 }),
modes: { 1: 'normal', 2: 'best_version' },
seasonsMap: { 1: true, 2: true },
subscribedSeasons: [1, 2],
useSeasonMap: true,
})
await fireEvent.click(screen.getByRole('button', { name: 'remove' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
await waitFor(() => expect(mocks.toastError).toHaveBeenCalled())
expect((deleted.mock.calls[0][0] as URL).searchParams.get('season')).toBe('2')
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
expect(screen.getByTestId('seasons')).toHaveTextContent('[1,2]')
expect(screen.getByTestId('season-map')).toHaveTextContent('"2":true')
expect(screen.getByTestId('modes')).toHaveTextContent('"2":"best_version"')
expect(mocks.cacheStatus).not.toHaveBeenCalled()
expect(mocks.doneProgress).toHaveBeenCalledOnce()
consoleError.mockRestore()
})
it.each([
['business failure', 200, { message: 'update rejected', success: false }],
['HTTP failure', 500, { message: 'server down', success: false }],
])('keeps the subscribed mode when an update returns a %s', async (_case, status, response) => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const media = createSubscribeTv({ title: '模式更新失败剧集', tmdb_id: 110 })
const updated = vi.fn()
server.use(
querySubscribeByMediaHandler(
'tmdb:110',
createSubscribe({ id: 710, season: 2, tmdbid: 110, type: '电视剧' }),
),
updateSubscribeHandler(response, status, updated),
)
await renderSubscribeHarness({
isSubscribed: true,
media,
modes: { 2: 'normal' },
multi: {
modes: { 2: 'best_version' },
seasons: [{ season_number: 2 }],
visible: [2],
},
subscribedSeasons: [2],
})
await fireEvent.click(screen.getByRole('button', { name: 'align-seasons' }))
await waitFor(() => expect(updated).toHaveBeenCalledOnce())
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledOnce())
expect(screen.getByTestId('subscribed')).toHaveTextContent('true')
expect(screen.getByTestId('seasons')).toHaveTextContent('[2]')
expect(screen.getByTestId('modes')).toHaveTextContent('"2":"normal"')
expect(mocks.cacheStatus).not.toHaveBeenCalled()
expect(mocks.doneProgress).toHaveBeenCalledOnce()
consoleError.mockRestore()
})
it('maps a 404 query to missing and propagates other HTTP errors', async () => {
const media = createSubscribeMovie({ tmdb_id: 109 })
server.use(querySubscribeByMediaHandler('tmdb:109', {}, 404))
await renderSubscribeHarness({ media })
await fireEvent.click(screen.getByRole('button', { name: 'check' }))
await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('missing'))
server.use(querySubscribeByMediaHandler('tmdb:109', {}, 500))
await fireEvent.click(screen.getByRole('button', { name: 'check' }))
await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('error'))
})
it('does nothing when the current media is unavailable', async () => {
await renderSubscribeHarness()
await fireEvent.click(screen.getByRole('button', { name: 'primary' }))
await fireEvent.click(screen.getByRole('button', { name: 'add-normal' }))
await fireEvent.click(screen.getByRole('button', { name: 'open-season' }))
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
expect(mocks.startProgress).not.toHaveBeenCalled()
})
})

View File

@@ -1,6 +1,6 @@
import { getDominantColor } from '@/@core/utils/image'
const DEFAULT_ACCENT_RGB = '145, 85, 253'
const DEFAULT_ACCENT_RGB = '141, 81, 249'
/** 将图标主色转换为卡片 CSS 变量可直接使用的 RGB 字符串。 */
function hexToRgbString(hexColor: string) {
@@ -13,14 +13,14 @@ function hexToRgbString(hexColor: string) {
}
/** 从指定图片中提取卡片强调色,返回 CSS 变量可直接使用的 RGB 字符串。 */
export async function getCardAccentRgbFromImage(image: HTMLImageElement | undefined | null, fallback = '#9155FD') {
export async function getCardAccentRgbFromImage(image: HTMLImageElement | undefined | null, fallback = '#8D51F9') {
const dominantColor = await getDominantColor(image, { fallback })
return hexToRgbString(dominantColor)
}
/** 从卡片图标中提取强调色,保证设置页卡片颜色跟随各自图标。 */
export function useCardAccentColor(fallback = '#9155FD') {
export function useCardAccentColor(fallback = '#8D51F9') {
const accentRgb = ref(DEFAULT_ACCENT_RGB)
const imageRef = ref<any>()

View File

@@ -92,6 +92,16 @@ function getModeName(t: ReturnType<typeof useI18n>['t'], mode: SubscribeMode) {
return t('dialog.subscribeMode.bestVersionFull')
}
// 从变更请求异常中提取可展示消息,并为非标准错误提供稳定兜底。
function getRequestErrorMessage(error: unknown, fallback: string) {
if (typeof error === 'object' && error !== null) {
const responseMessage = (error as { response?: { data?: { message?: unknown } } }).response?.data?.message
if (typeof responseMessage === 'string' && responseMessage) return responseMessage
}
if (error instanceof Error && error.message) return error.message
return fallback
}
// 封装媒体卡片与详情页共用的订阅交互。
export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
const { t } = useI18n()
@@ -155,17 +165,19 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
}
// 打开已创建订阅的编辑弹窗。
function openSubscribeEditDialog(subid: number) {
function openSubscribeEditDialog(subid: number, season: number | null, mode: SubscribeMode) {
openSharedDialog(
SubscribeEditDialog,
{ subid },
{
save: (subscribe?: Subscribe) => {
const savedSeason = currentMedia()?.type === '电影' ? null : (subscribe?.season ?? season)
if (savedSeason !== season) updateSubscribeStatus(season, false)
updateSubscribeStatus(savedSeason, true, subscribe ? getSubscribeMode(subscribe) : mode)
},
remove: () => {
if (options.onEditRemove) {
options.onEditRemove()
} else if (options.isSubscribed) {
options.isSubscribed.value = false
}
updateSubscribeStatus(season, false)
options.onEditRemove?.()
},
},
{ closeOn: ['close', 'save', 'remove'] },
@@ -270,16 +282,33 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
episode_group: episodeGroup.value,
})
if (result.success) updateSubscribeStatus(media.type === '电影' ? null : season, true, getSubscribeMode(payload))
const subscribeSeason = media.type === '电影' ? null : season
const subscribeMode = getSubscribeMode(payload)
if (result.success) updateSubscribeStatus(subscribeSeason, true, subscribeMode)
showSubscribeAddToast(result.success, media.title ?? '', season, result.message, payload.best_version ?? 0)
showSubscribeAddToast(
result.success,
media.title ?? '',
season,
result.message ?? t('subscribe.requestFailed'),
payload.best_version ?? 0,
)
if (result.success && (addOptions.openEditDialog ?? true)) {
const subscribeConfig = await queryDefaultSubscribeConfig()
if (subscribeConfig?.show_edit_dialog) openSubscribeEditDialog(result.data.id)
if (subscribeConfig?.show_edit_dialog && result.data?.id) {
openSubscribeEditDialog(result.data.id, subscribeSeason, subscribeMode)
}
}
} catch (error) {
console.error(error)
showSubscribeAddToast(
false,
media.title ?? '',
season,
getRequestErrorMessage(error, t('subscribe.requestFailed')),
payload.best_version ?? 0,
)
} finally {
doneNProgress()
}
@@ -297,6 +326,8 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
const media = currentMedia()
if (!media) return
let title = media.title ?? ''
if (media.type !== '电影' && season !== null) title = `${title} ${formatSeason(season.toString())}`
startNProgress()
try {
@@ -305,17 +336,20 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
season: media.type === '电影' ? null : season,
},
})
let title = media.title ?? ''
if (media.type !== '电影' && season !== null) title = `${title} ${formatSeason(season.toString())}`
if (result.success) {
updateSubscribeStatus(media.type === '电影' ? null : season, false)
$toast.success(`${title} ${t('subscribe.cancelSuccess')}`)
} else {
$toast.error(`${title} ${t('subscribe.cancelFailed', { message: result.message })}`)
$toast.error(`${title} ${t('subscribe.cancelFailed', { message: result.message ?? t('subscribe.requestFailed') })}`)
}
} catch (error) {
console.error(error)
$toast.error(
`${title} ${t('subscribe.cancelFailed', {
message: getRequestErrorMessage(error, t('subscribe.requestFailed')),
})}`,
)
} finally {
doneNProgress()
}
@@ -361,6 +395,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
async function updateSubscribeMode(season: number, mode: SubscribeMode) {
const media = currentMedia()
if (!media) return
const title = `${media.title ?? ''} ${formatSeason(season.toString())}`
startNProgress()
try {
@@ -375,16 +410,26 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
...subscribe,
...payload,
})
const title = `${media.title ?? ''} ${formatSeason(season.toString())}`
if (result.success) {
updateSubscribeStatus(season, true, mode)
$toast.success(`${title} ${t('subscribe.modeUpdateSuccess', { mode: getModeName(t, mode) })}`)
} else {
$toast.error(`${title} ${t('subscribe.addFailed', { name: getModeName(t, mode), message: result.message })}`)
$toast.error(
`${title} ${t('subscribe.addFailed', {
name: getModeName(t, mode),
message: result.message ?? t('subscribe.requestFailed'),
})}`,
)
}
} catch (error) {
console.error(error)
$toast.error(
`${title} ${t('subscribe.addFailed', {
name: getModeName(t, mode),
message: getRequestErrorMessage(error, t('subscribe.requestFailed')),
})}`,
)
} finally {
doneNProgress()
}

View File

@@ -49,7 +49,7 @@ export function useShortcutTools() {
icon: 'mdi-text-recognition',
dialog: 'nameTest',
component: NameTestView,
maxWidth: '45rem',
maxWidth: '65rem',
titleText: t('shortcut.recognition.title'),
},
{
@@ -58,6 +58,7 @@ export function useShortcutTools() {
icon: 'mdi-filter-cog',
dialog: 'ruleTest',
component: RuleTestView,
maxWidth: '65rem',
titleText: t('shortcut.rule.subtitle'),
},
{

View File

@@ -4,13 +4,14 @@ import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { saveLocalTheme } from '@/@core/utils/theme'
import vuetify from '@/plugins/vuetify'
import { themeManager } from '@/utils/themeManager'
import { syncThemeFavicon } from '@/utils/themePalette'
export const THEME_CUSTOMIZER_STORAGE_KEY = 'moviepilot-theme-customizer'
export const THEME_CUSTOMIZER_CHANGE_EVENT = 'moviepilot-theme-customizer-change'
export const THEME_CUSTOMIZER_OPEN_EVENT = 'moviepilot-theme-customizer-open'
export const themeCustomizerPrimaryColors = [
{ name: 'Purple', value: '#9155FD' },
{ name: 'Purple', value: '#8D51F9' },
{ name: 'Indigo', value: '#3F51B5' },
{ name: 'Blue', value: '#1976D2' },
{ name: 'Cyan', value: '#00BCD4' },
@@ -125,12 +126,13 @@ function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSetti
const fallback = getDefaultThemeCustomizerSettings()
const storedRadius = settings.radius as string | undefined
const radius = storedRadius === 'huge' ? 'extra' : storedRadius
const primaryColor = isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor
return {
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
? (settings.layout as ThemeCustomizerLayout)
: fallback.layout,
primaryColor: isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor,
primaryColor,
radius: validRadii.includes(radius as ThemeCustomizerRadius)
? (radius as ThemeCustomizerRadius)
: fallback.radius,
@@ -154,7 +156,6 @@ export function readThemeCustomizerSettings(): ThemeCustomizerSettings {
try {
const stored = localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)
const parsed = stored ? JSON.parse(stored) : {}
return normalizeThemeCustomizerSettings({
...fallback,
...parsed,
@@ -209,6 +210,7 @@ export function applyPrimaryColorToVuetify(color: string, themeApi: VuetifyTheme
document.documentElement.style.setProperty('--initial-loader-color', color)
localStorage.setItem('materio-initial-loader-color', color)
syncThemeFavicon(color)
}
/** 布局、圆角、阴影、皮肤和局部菜单风格只依赖根节点属性CSS 可以在不刷新页面的情况下即时响应。 */

View File

@@ -37,7 +37,7 @@ import {
THEME_CUSTOMIZER_OPEN_EVENT,
type ThemeCustomizerSettings,
} from '@/composables/useThemeCustomizer'
import logo from '@images/logo.svg?raw'
import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue'
const display = useDisplay()
// PWA模式检测
@@ -510,7 +510,7 @@ onMounted(async () => {
:class="{ 'theme-navbar-row--horizontal': showHorizontalThemeNav }"
>
<RouterLink v-if="showHorizontalThemeNav" :to="canAdmin ? '/dashboard' : '/apps'" class="theme-horizontal-logo">
<span class="theme-horizontal-logo__mark" v-html="logo" />
<ThemeLogoMark class="theme-horizontal-logo__mark" />
<span class="theme-horizontal-logo__text">MOVIEPILOT</span>
</RouterLink>
<!-- 👉 Vertical Nav Toggle -->
@@ -770,14 +770,6 @@ onMounted(async () => {
}
.theme-horizontal-logo__mark {
display: inline-flex;
align-items: center;
justify-content: center;
block-size: 2rem;
inline-size: 2rem;
}
.theme-horizontal-logo__mark :deep(svg) {
display: block;
block-size: 1.8rem;
inline-size: 1.8rem;

View File

@@ -1125,6 +1125,7 @@ export default {
cancelSuccess: 'Subscription cancelled!',
cancelFailed: 'Failed to cancel subscription: {message}!',
notFound: 'Subscription not found!',
requestFailed: 'Request failed. Please try again later.',
filterSubscriptions: 'Filter Subscriptions',
name: 'Name',
searchShares: 'Search Subscription Shares',
@@ -1536,6 +1537,47 @@ export default {
recognizeAgain: 'Recognize Again',
title: 'Title',
subtitle: 'Subtitle',
customWords: 'Custom Words',
customWordsPlaceholder: 'Enter one recognition rule per line; applied directly to this recognition test',
customWordsHint:
'Same format as Custom Words settings: block word / old => new / before <> after >> episode offset',
saveWords: 'Save Words',
saveWordsSuccess: 'Custom words appended to the word list',
saveWordsNoChange: 'These words already exist, no need to save again',
saveWordsFailed: 'Failed to save custom words',
requestFailed: 'Recognition request failed',
inputTitle: 'Test Input',
inputSubtitle: 'Enter a torrent or file name to inspect the recognition breakdown',
unrecognized: 'No media recognized',
waitingResult: 'Waiting for recognition result',
analysisTitle: 'Analysis Flow',
analysisSubtitle: 'Title preprocessing, meta info, and media match',
summary: {
year: 'Year',
episode: 'Episode',
type: 'Type',
source: 'Source',
},
steps: {
original: {
title: 'Original Title',
caption: 'The raw string entering recognition',
},
words: {
title: 'Custom Words',
caption: 'Applied custom recognition words',
value: '{count} matched',
none: 'No custom words applied',
},
meta: {
title: 'Meta Info',
caption: 'Parsed name, episodes, and resource terms',
},
media: {
title: 'Media Match',
caption: 'Final matched media source',
},
},
},
netTest: {
notTested: 'Not Tested',
@@ -1549,8 +1591,46 @@ export default {
title: 'Title',
subtitle: 'Subtitle',
ruleGroup: 'Rule Group',
ruleGroupPlaceholder: 'Please select',
priority: 'Priority: {value}',
noPriorityRule: 'No priority rule matched!',
requestFailed: 'Rule test request failed',
inputTitle: 'Rule Test',
inputSubtitle: 'Select a rule group to inspect filter matching and priority',
waitingResult: 'Waiting for rule test result',
matched: 'Filter rule matched',
priorityLabel: 'Priority',
analysisTitle: 'Filter Flow',
analysisSubtitle: 'Rule group, media recognition, filter match, and priority order',
ruleCount: '{count} rules',
summary: {
priority: 'Priority',
ruleGroup: 'Rule Group',
ruleCount: 'Rules',
media: 'Media',
},
steps: {
group: {
title: 'Rule Group',
caption: 'Current group contains {count} priority rules',
empty: 'No rules in this group',
},
media: {
title: 'Media Recognition',
caption: 'Recognize media context before filtering',
none: 'No media recognized',
},
filter: {
title: 'Filter Match',
matched: 'Resource passes the filter rules',
pending: 'No match result returned',
},
priority: {
title: 'Priority Result',
caption: 'Converted priority for the matched resource',
empty: 'No priority generated',
},
},
},
setting: {
about: {
@@ -3097,6 +3177,10 @@ export default {
cancelSubscribe: 'Cancel Subscription',
save: 'Save',
cancelSubscribeConfirm: 'Are you sure you want to cancel the subscription?',
updateSuccess: '{name} updated successfully!',
updateFailed: 'Failed to update {name}: {message}!',
defaultSaveSuccess: 'Default {type} subscription rules saved successfully.',
defaultSaveFailed: 'Failed to save default {type} subscription rules: {message}!',
},
subscribeFiles: {
title: 'Subscription Files',

View File

@@ -1119,6 +1119,7 @@ export default {
cancelSuccess: '已取消订阅!',
cancelFailed: '取消订阅失败:{message}',
notFound: '订阅不存在!',
requestFailed: '请求失败,请稍后重试',
filterSubscriptions: '筛选订阅',
name: '名称',
searchShares: '搜索订阅分享',
@@ -1529,6 +1530,46 @@ export default {
recognizeAgain: '重新识别',
title: '标题',
subtitle: '副标题',
customWords: '识别词',
customWordsPlaceholder: '每行输入一组识别规则,可直接用于本次识别测试',
customWordsHint: '格式与"识别词管理"一致:屏蔽词 / 被替换词 => 替换词 / 前定位词 <> 后定位词 >> 集偏移量',
saveWords: '保存识别词',
saveWordsSuccess: '识别词已保存到识别词表末尾',
saveWordsNoChange: '识别词已存在,无需重复保存',
saveWordsFailed: '识别词保存失败',
requestFailed: '识别请求失败',
inputTitle: '测试输入',
inputSubtitle: '输入种子名或文件名,查看媒体识别拆解结果',
unrecognized: '未识别到媒体',
waitingResult: '等待识别结果',
analysisTitle: '解析链路',
analysisSubtitle: '标题预处理、元信息和媒体匹配结果',
summary: {
year: '年份',
episode: '季集',
type: '类型',
source: '数据源',
},
steps: {
original: {
title: '原始标题',
caption: '进入识别流程的原始字符串',
},
words: {
title: '识别词',
caption: '自定义识别词应用情况',
value: '命中 {count} 条',
none: '未应用识别词',
},
meta: {
title: '元信息',
caption: '解析出的名称、季集和资源信息',
},
media: {
title: '媒体匹配',
caption: '最终匹配到的媒体数据源',
},
},
},
netTest: {
notTested: '未测试',
@@ -1542,8 +1583,46 @@ export default {
title: '标题',
subtitle: '副标题',
ruleGroup: '规则组',
ruleGroupPlaceholder: '请选择',
priority: '优先级:{value}',
noPriorityRule: '未命中任何优先级规则!',
requestFailed: '规则测试请求失败',
inputTitle: '规则测试',
inputSubtitle: '选择规则组后,查看过滤命中和优先级结果',
waitingResult: '等待规则测试结果',
matched: '命中过滤规则',
priorityLabel: '优先级',
analysisTitle: '过滤链路',
analysisSubtitle: '规则组、媒体识别、过滤命中和优先级排序',
ruleCount: '{count} 条',
summary: {
priority: '优先级',
ruleGroup: '规则组',
ruleCount: '规则数',
media: '媒体',
},
steps: {
group: {
title: '规则组',
caption: '当前规则组包含 {count} 条优先级规则',
empty: '规则组暂无规则',
},
media: {
title: '媒体识别',
caption: '过滤前先识别媒体上下文',
none: '未识别到媒体',
},
filter: {
title: '过滤命中',
matched: '资源符合过滤规则',
pending: '未得到命中结果',
},
priority: {
title: '排序结果',
caption: '命中资源换算后的优先级',
empty: '未生成优先级',
},
},
},
setting: {
about: {
@@ -1708,7 +1787,8 @@ export default {
llmTestFailedToast: 'LLM 调用测试失败',
llmTestFailedToastWithMessage: 'LLM 调用测试失败:{message}',
aiAgentGlobal: '全局智能助手',
aiAgentGlobalHint: '启用全局智能助手:默认使用智能体交互,使用 /noai 临时使用传统交互;关闭全局智能助手:默认使用传统交互,使用 /ai 临时使用智能体交互',
aiAgentGlobalHint:
'启用全局智能助手:默认使用智能体交互,使用 /noai 临时使用传统交互;关闭全局智能助手:默认使用传统交互,使用 /ai 临时使用智能体交互',
aiAgentJobInterval: '定时唤醒',
aiAgentJobIntervalHint: '设置定时唤醒的检查间隔,选择"不启用"则不执行定时任务',
aiAgentVerbose: '啰嗦模式',
@@ -2001,8 +2081,7 @@ export default {
e2ePassword: '端对端加密密码',
e2ePasswordHint: 'CookieCloud浏览器插件生成的端对端加密密码',
cookieCloudAuthHeader: '上传认证 Header',
cookieCloudAuthHeaderHint:
'留空表示关闭上传认证,启用后上传端或反向代理需要发送 X-CookieCloud-Auth',
cookieCloudAuthHeaderHint: '留空表示关闭上传认证,启用后上传端或反向代理需要发送 X-CookieCloud-Auth',
autoSyncInterval: '自动同步间隔',
autoSyncIntervalHint: '从CookieCloud服务器自动同步站点Cookie到MoviePilot的时间间隔',
syncBlacklist: '同步域名黑名单',
@@ -3046,6 +3125,10 @@ export default {
cancelSubscribe: '取消订阅',
save: '保存',
cancelSubscribeConfirm: '是否确认取消订阅?',
updateSuccess: '{name} 更新成功!',
updateFailed: '{name} 更新失败:{message}',
defaultSaveSuccess: '{type}订阅默认规则保存成功',
defaultSaveFailed: '{type}订阅默认规则保存失败:{message}',
},
subscribeFiles: {
title: '订阅文件',

View File

@@ -1119,6 +1119,7 @@ export default {
cancelSuccess: '已取消訂閱!',
cancelFailed: '取消訂閱失敗:{message}',
notFound: '訂閱不存在!',
requestFailed: '請求失敗,請稍後重試',
filterSubscriptions: '篩選訂閱',
name: '名稱',
searchShares: '搜索訂閱分享',
@@ -1528,6 +1529,46 @@ export default {
recognizeAgain: '重新識別',
title: '標題',
subtitle: '副標題',
customWords: '識別詞',
customWordsPlaceholder: '每行輸入一組識別規則,可直接用於本次識別測試',
customWordsHint: '格式與「識別詞管理」一致:屏蔽詞 / 被替換詞 => 替換詞 / 前定位詞 <> 後定位詞 >> 集偏移量',
saveWords: '儲存識別詞',
saveWordsSuccess: '識別詞已儲存到識別詞表末尾',
saveWordsNoChange: '識別詞已存在,無需重複儲存',
saveWordsFailed: '識別詞儲存失敗',
requestFailed: '識別請求失敗',
inputTitle: '測試輸入',
inputSubtitle: '輸入種子名或檔案名,查看媒體識別拆解結果',
unrecognized: '未識別到媒體',
waitingResult: '等待識別結果',
analysisTitle: '解析鏈路',
analysisSubtitle: '標題預處理、元資訊和媒體匹配結果',
summary: {
year: '年份',
episode: '季集',
type: '類型',
source: '資料源',
},
steps: {
original: {
title: '原始標題',
caption: '進入識別流程的原始字串',
},
words: {
title: '識別詞',
caption: '自訂識別詞應用情況',
value: '命中 {count} 條',
none: '未套用識別詞',
},
meta: {
title: '元資訊',
caption: '解析出的名稱、季集和資源資訊',
},
media: {
title: '媒體匹配',
caption: '最終匹配到的媒體資料源',
},
},
},
netTest: {
notTested: '未測試',
@@ -1541,8 +1582,46 @@ export default {
title: '標題',
subtitle: '副標題',
ruleGroup: '規則組',
ruleGroupPlaceholder: '請選擇',
priority: '優先級:{value}',
noPriorityRule: '未命中任何優先級規則!',
requestFailed: '規則測試請求失敗',
inputTitle: '規則測試',
inputSubtitle: '選擇規則組後,查看過濾命中和優先級結果',
waitingResult: '等待規則測試結果',
matched: '命中過濾規則',
priorityLabel: '優先級',
analysisTitle: '過濾鏈路',
analysisSubtitle: '規則組、媒體識別、過濾命中和優先級排序',
ruleCount: '{count} 條',
summary: {
priority: '優先級',
ruleGroup: '規則組',
ruleCount: '規則數',
media: '媒體',
},
steps: {
group: {
title: '規則組',
caption: '目前規則組包含 {count} 條優先級規則',
empty: '規則組暫無規則',
},
media: {
title: '媒體識別',
caption: '過濾前先識別媒體上下文',
none: '未識別到媒體',
},
filter: {
title: '過濾命中',
matched: '資源符合過濾規則',
pending: '未取得命中結果',
},
priority: {
title: '排序結果',
caption: '命中資源換算後的優先級',
empty: '未產生優先級',
},
},
},
setting: {
about: {
@@ -1707,7 +1786,8 @@ export default {
llmTestFailedToast: 'LLM 調用測試失敗',
llmTestFailedToastWithMessage: 'LLM 調用測試失敗:{message}',
aiAgentGlobal: '全局智能助手',
aiAgentGlobalHint: '啟用全域智慧助手:預設使用智慧體互動,使用 /noai 暫時切換為傳統互動;停用全域智慧助手:預設使用傳統互動,使用 /ai 暫時切換為智慧體互動',
aiAgentGlobalHint:
'啟用全域智慧助手:預設使用智慧體互動,使用 /noai 暫時切換為傳統互動;停用全域智慧助手:預設使用傳統互動,使用 /ai 暫時切換為智慧體互動',
aiAgentJobInterval: '定時喚醒',
aiAgentJobIntervalHint: '設置定時喚醒的檢查間隔,選擇「不啟用」則不執行定時任務',
aiAgentVerbose: '囉嗦模式',
@@ -2000,8 +2080,7 @@ export default {
e2ePassword: '端對端加密密碼',
e2ePasswordHint: 'CookieCloud瀏覽器插件生成的端對端加密密碼',
cookieCloudAuthHeader: '上傳認證 Header',
cookieCloudAuthHeaderHint:
'留空表示關閉上傳認證,啟用後上傳端或反向代理需要發送 X-CookieCloud-Auth',
cookieCloudAuthHeaderHint: '留空表示關閉上傳認證,啟用後上傳端或反向代理需要發送 X-CookieCloud-Auth',
autoSyncInterval: '自動同步間隔',
autoSyncIntervalHint: '從CookieCloud服務器自動同步站點Cookie到MoviePilot的時間間隔',
syncBlacklist: '同步域名黑名單',
@@ -3045,6 +3124,10 @@ export default {
cancelSubscribe: '取消訂閱',
save: '儲存',
cancelSubscribeConfirm: '是否確認取消訂閱?',
updateSuccess: '{name} 更新成功!',
updateFailed: '{name} 更新失敗:{message}',
defaultSaveSuccess: '{type}訂閱默認規則儲存成功',
defaultSaveFailed: '{type}訂閱默認規則儲存失敗:{message}',
},
subscribeFiles: {
title: '訂閱文件',

View File

@@ -0,0 +1,261 @@
import RecommendPage from '@/pages/recommend.vue'
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import {
recommendConfigHandler,
recommendSourcesHandler,
saveRecommendConfigHandler,
} from '@tests/support/msw/handlers/recommend'
import { server } from '@tests/support/msw/server'
import { defineComponent } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
closeDialog: vi.fn(),
openSharedDialog: vi.fn(),
registerHeaderTab: vi.fn(),
useDynamicButton: vi.fn(),
}))
vi.mock('@/composables/useDynamicHeaderTab', () => ({
useDynamicHeaderTab: () => ({ registerHeaderTab: mocks.registerHeaderTab }),
}))
vi.mock('@/composables/useDynamicButton', () => ({
useDynamicButton: (options: unknown) => mocks.useDynamicButton(options),
}))
vi.mock('@/composables/usePWA', async () => {
const { ref } = await import('vue')
return {
usePWA: () => ({ appMode: ref(false) }),
}
})
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
const MediaCardSlideViewStub = defineComponent({
name: 'MediaCardSlideView',
props: {
apipath: { type: String, required: true },
ready: { type: Boolean, required: true },
title: { type: String, required: true },
},
template: '<section data-testid="recommend-view" :data-api-path="apipath" :data-ready="ready">{{ title }}</section>',
})
interface SharedDialogEvents {
close: () => void
save: (payload?: { enabled?: Record<string, boolean> }) => Promise<void>
'update:modelValue': (value: boolean) => void
}
async function renderRecommend(options: { superUser?: boolean; discovery?: boolean } = {}) {
return renderWithProviders(RecommendPage, {
initialRoute: '/recommend',
initialState: {
user: {
permissions: { ...DEFAULT_PERMISSIONS, discovery: options.discovery ?? true },
superUser: options.superUser ?? false,
},
},
global: {
stubs: {
MediaCardSlideView: MediaCardSlideViewStub,
VScrollToTopBtn: true,
},
},
})
}
describe('recommend page', () => {
beforeEach(() => {
mocks.openSharedDialog.mockReturnValue({
close: mocks.closeDialog,
id: 1,
updateProps: vi.fn(),
})
})
it('uses local configuration and merges extra sources without duplicates', async () => {
let remoteConfigRequests = 0
localStorage.setItem('MP_RECOMMEND', JSON.stringify({ '流行趋势': true, '自定义来源': true }))
server.use(
recommendConfigHandler({}, 200, () => {
remoteConfigRequests += 1
}),
recommendSourcesHandler([
{ api_path: 'recommend/tmdb_trending', name: '重复来源', type: '榜单' },
{ api_path: 'recommend/custom', name: '自定义来源', type: '扩展' },
]),
)
await renderRecommend()
expect(await screen.findByText('自定义来源')).toBeInTheDocument()
expect(screen.getAllByTestId('recommend-view')).toHaveLength(2)
expect(screen.queryByText('重复来源')).not.toBeInTheDocument()
expect(remoteConfigRequests).toBe(0)
})
it('loads remote configuration when local configuration is absent', async () => {
const remoteConfig = { '流行趋势': false, '正在热映': true }
const configRequested = vi.fn()
const sourcesRequested = vi.fn()
server.use(
recommendConfigHandler(remoteConfig, 200, configRequested),
recommendSourcesHandler([], 200, sourcesRequested),
)
await renderRecommend()
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(screen.queryByText('流行趋势')).not.toBeInTheDocument())
expect(screen.getByText('正在热映')).toBeInTheDocument()
expect(JSON.parse(localStorage.getItem('MP_RECOMMEND') || '{}')).toEqual(remoteConfig)
})
it.each([
['damaged JSON', '{damaged'],
['null', 'null'],
['an array', '[]'],
['a non-boolean field', JSON.stringify({ '流行趋势': 'enabled' })],
])('clears %s local configuration and falls back to the server', async (_case, storedConfig) => {
const configRequested = vi.fn()
const sourcesRequested = vi.fn()
localStorage.setItem('MP_RECOMMEND', storedConfig)
server.use(
recommendConfigHandler({ '流行趋势': true }, 200, configRequested),
recommendSourcesHandler([], 200, sourcesRequested),
)
await renderRecommend()
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(localStorage.getItem('MP_RECOMMEND')).toBe(JSON.stringify({ '流行趋势': true })))
expect(screen.getByText('流行趋势')).toBeInTheDocument()
})
it('keeps defaults and does not persist an invalid remote configuration', async () => {
const configRequested = vi.fn()
const sourcesRequested = vi.fn()
server.use(
recommendConfigHandler({ '流行趋势': 'enabled' }, 200, configRequested),
recommendSourcesHandler([], 200, sourcesRequested),
)
await renderRecommend()
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce())
expect(screen.getByText('流行趋势')).toBeInTheDocument()
expect(localStorage.getItem('MP_RECOMMEND')).toBeNull()
})
it('saves settings through the shared dialog boundary', async () => {
const savedConfig = vi.fn()
const sourcesRequested = vi.fn()
const user = userEvent.setup()
localStorage.setItem('MP_RECOMMEND', JSON.stringify({ '流行趋势': true }))
server.use(recommendSourcesHandler([], 200, sourcesRequested), saveRecommendConfigHandler(savedConfig))
await renderRecommend()
await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(document.querySelector('.compact-fab')).not.toBeNull())
const settingsButton = document.querySelector<HTMLButtonElement>('.compact-fab')
expect(settingsButton).not.toBeNull()
await user.click(settingsButton as HTMLButtonElement)
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as SharedDialogEvents
const nextConfig = { '流行趋势': false, '正在热映': true }
await dialogEvents.save({ enabled: nextConfig })
expect(savedConfig).toHaveBeenCalledWith(nextConfig)
expect(localStorage.getItem('MP_RECOMMEND')).toBe(JSON.stringify(nextConfig))
expect(mocks.closeDialog).toHaveBeenCalledOnce()
})
it('releases the shared settings controller through both close contracts', async () => {
const sourcesRequested = vi.fn()
const user = userEvent.setup()
localStorage.setItem('MP_RECOMMEND', JSON.stringify({ '流行趋势': true }))
server.use(recommendSourcesHandler([], 200, sourcesRequested))
await renderRecommend()
await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(document.querySelector('.compact-fab')).not.toBeNull())
const settingsButton = document.querySelector<HTMLButtonElement>('.compact-fab') as HTMLButtonElement
await user.click(settingsButton)
const dialogProps = mocks.openSharedDialog.mock.calls[0][1] as {
valueGetter: (item: { title: string }) => string
}
const firstDialogEvents = mocks.openSharedDialog.mock.calls[0][2] as SharedDialogEvents
expect(dialogProps.valueGetter({ title: '流行趋势' })).toBe('流行趋势')
firstDialogEvents.close()
await user.click(settingsButton)
expect(mocks.closeDialog).not.toHaveBeenCalled()
const secondDialogEvents = mocks.openSharedDialog.mock.calls[1][2] as SharedDialogEvents
secondDialogEvents['update:modelValue'](true)
await user.click(settingsButton)
expect(mocks.closeDialog).toHaveBeenCalledOnce()
const thirdDialogEvents = mocks.openSharedDialog.mock.calls[2][2] as SharedDialogEvents
thirdDialogEvents['update:modelValue'](false)
await user.click(settingsButton)
expect(mocks.closeDialog).toHaveBeenCalledOnce()
})
it.each([
{ discovery: false, superUser: false, visible: false },
{ discovery: false, superUser: true, visible: true },
])('applies discovery permission to the desktop settings entry', async ({ discovery, superUser, visible }) => {
const sourcesRequested = vi.fn()
localStorage.setItem('MP_RECOMMEND', JSON.stringify({ '流行趋势': true }))
server.use(recommendSourcesHandler([], 200, sourcesRequested))
await renderRecommend({ discovery, superUser })
await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce())
expect(Boolean(document.querySelector('.compact-fab'))).toBe(visible)
})
it('keeps built-in content when remote requests fail', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
server.use(recommendConfigHandler({}, 500), recommendSourcesHandler([], 500))
await renderRecommend()
await waitFor(() => expect(consoleError).toHaveBeenCalled())
await waitFor(() => expect(consoleLog).toHaveBeenCalled())
expect(screen.getByText('流行趋势')).toBeInTheDocument()
})
it('clears its delayed-render timer when unmounted', async () => {
const setTimeout = vi.spyOn(window, 'setTimeout')
const clearTimeout = vi.spyOn(window, 'clearTimeout')
const sourcesRequested = vi.fn()
localStorage.setItem('MP_RECOMMEND', JSON.stringify({ '流行趋势': true }))
server.use(recommendSourcesHandler([], 200, sourcesRequested))
const { unmount } = await renderRecommend()
await waitFor(() => expect(sourcesRequested).toHaveBeenCalledOnce())
await fireEvent.click(screen.getByText('流行趋势'))
const componentTimerIndexes = setTimeout.mock.calls
.map(([, delay], index) => ({ delay, index }))
.filter(({ delay }) => delay === 400)
expect(componentTimerIndexes).toHaveLength(1)
const componentTimer = setTimeout.mock.results[componentTimerIndexes[0].index].value
unmount()
expect(clearTimeout).toHaveBeenCalledWith(componentTimer)
})
})

View File

@@ -0,0 +1,467 @@
import SubscribePage from '@/pages/subscribe.vue'
import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import {
computed,
defineComponent,
h,
nextTick,
ref,
unref,
type ComputedRef,
type Ref,
} from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
appMode: false,
openSharedDialog: vi.fn(),
registerHeaderTab: vi.fn(),
useDynamicButton: vi.fn(),
}))
vi.mock('@/composables/useDynamicHeaderTab', () => ({
useDynamicHeaderTab: () => ({ registerHeaderTab: mocks.registerHeaderTab }),
}))
vi.mock('@/composables/useDynamicButton', () => ({
useDynamicButton: (options: unknown) => mocks.useDynamicButton(options),
}))
vi.mock('@/composables/usePWA', async () => {
const { computed } = await import('vue')
return {
usePWA: () => ({ appMode: computed(() => mocks.appMode) }),
}
})
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
interface SubscribeBatchState {
enabled: boolean
selectedCount: number
totalCount: number
allSelected: boolean
}
const SubscribeListViewStub = defineComponent({
name: 'SubscribeListView',
props: {
type: String,
subid: String,
keyword: String,
statusFilter: String,
sortMode: {
type: Boolean,
default: false,
},
sortBy: {
type: String,
default: '',
},
active: {
type: Boolean,
default: true,
},
},
emits: ['update:sortMode', 'update:sortBy', 'batch-state-change'],
setup(props, { emit, expose }) {
const lastCommand = ref('none')
const batchState = ref<SubscribeBatchState>({
enabled: false,
selectedCount: 0,
totalCount: 0,
allSelected: false,
})
const runCommand = (command: string) => {
lastCommand.value = command
}
const publishBatchState = (state: SubscribeBatchState) => {
batchState.value = state
emit('batch-state-change', state)
}
expose({
enterBatchMode: () => runCommand('enter-batch'),
exitBatchMode: () => runCommand('exit-batch'),
toggleSelectAll: () => runCommand('toggle-select-all'),
batchEnableSubscribes: () => runCommand('batch-enable'),
batchPauseSubscribes: () => runCommand('batch-pause'),
batchDeleteSubscribes: () => runCommand('batch-delete'),
openHistoryDialog: () => runCommand('open-history'),
})
return () =>
h('section', { 'aria-label': 'subscription list stub' }, [
h('button', { 'data-menu-activator': 'filter-btn', type: 'button' }, 'filter activator'),
h('output', { 'aria-label': 'list type' }, props.type ?? ''),
h('output', { 'aria-label': 'list subscription id' }, props.subid ?? ''),
h('output', { 'aria-label': 'list keyword' }, props.keyword ?? ''),
h('output', { 'aria-label': 'list status filter' }, props.statusFilter ?? ''),
h('output', { 'aria-label': 'list sort mode' }, String(props.sortMode)),
h('output', { 'aria-label': 'list sort by' }, props.sortBy ?? ''),
h('output', { 'aria-label': 'list active state' }, String(props.active)),
h('output', { 'aria-label': 'list batch state' }, JSON.stringify(batchState.value)),
h('output', { 'aria-label': 'last list command' }, lastCommand.value),
h(
'button',
{ type: 'button', onClick: () => emit('update:sortMode', true) },
'emit sort mode on',
),
h(
'button',
{ type: 'button', onClick: () => emit('update:sortMode', false) },
'emit sort mode off',
),
h('button', { type: 'button', onClick: () => emit('update:sortBy', 'date') }, 'emit date sort'),
h(
'button',
{
type: 'button',
onClick: () =>
publishBatchState({ enabled: true, selectedCount: 2, totalCount: 3, allSelected: false }),
},
'publish batch selection',
),
h(
'button',
{
type: 'button',
onClick: () =>
publishBatchState({ enabled: true, selectedCount: 3, totalCount: 3, allSelected: true }),
},
'publish all selected batch',
),
])
},
})
const SubscribePopularViewStub = defineComponent({
name: 'SubscribePopularView',
props: { type: String },
setup(props) {
return () => h('section', { 'aria-label': 'popular subscription stub' }, props.type ?? '')
},
})
const SubscribeShareViewStub = defineComponent({
name: 'SubscribeShareView',
props: { keyword: String },
setup(props) {
return () =>
h('section', { 'aria-label': 'shared subscription stub' }, [
h('button', { 'data-menu-activator': 'share-filter-btn', type: 'button' }, 'share filter activator'),
h('output', { 'aria-label': 'share keyword' }, props.keyword ?? ''),
])
},
})
type MaybeRef<T> = T | Ref<T> | ComputedRef<T>
interface HeaderButtonConfig {
icon: string
dataAttr?: string
action?: () => void
color?: MaybeRef<string>
show?: MaybeRef<boolean>
}
interface HeaderTabConfig {
items: MaybeRef<Array<{ title: string; tab: string }>>
modelValue: Ref<string>
appendButtons: HeaderButtonConfig[]
}
interface DynamicButtonConfig {
icon: MaybeRef<string>
menuItems?: MaybeRef<DynamicButtonMenuItem[] | undefined>
onClick?: () => void
show?: MaybeRef<boolean>
}
interface RenderSubscribeOptions {
appMode?: boolean
initialRoute?: string
subType?: '电影' | '电视剧'
subscribePermission?: boolean
superUser?: boolean
}
async function renderSubscribe(options: RenderSubscribeOptions = {}) {
const subType = options.subType ?? '电影'
mocks.appMode = options.appMode ?? false
return renderWithProviders(SubscribePage, {
initialRoute: options.initialRoute ?? `/subscribe/${subType === '电影' ? 'movie' : 'tv'}`,
initialRouteMeta: { subType },
initialState: {
user: {
permissions: {
...DEFAULT_PERMISSIONS,
subscribe: options.subscribePermission ?? true,
},
superUser: options.superUser ?? false,
},
},
global: {
stubs: {
SubscribeListView: SubscribeListViewStub,
SubscribePopularView: SubscribePopularViewStub,
SubscribeShareView: SubscribeShareViewStub,
},
},
})
}
function getHeaderConfig() {
return mocks.registerHeaderTab.mock.calls.at(-1)?.[0] as HeaderTabConfig
}
function getDynamicButtonConfig() {
return mocks.useDynamicButton.mock.calls.at(-1)?.[0] as DynamicButtonConfig
}
function getHeaderButton(predicate: (button: HeaderButtonConfig) => boolean) {
const button = getHeaderConfig().appendButtons.find(predicate)
if (!button) throw new Error('Expected dynamic header button was not registered')
return button
}
function getListOutput(label: string) {
return screen.getByLabelText(label)
}
describe('subscribe page', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.appMode = false
})
it('uses movie route meta and query values to register the movie page contract', async () => {
const { router } = await renderSubscribe({ initialRoute: '/subscribe/movie?id=42' })
await waitFor(() => expect(getListOutput('list active state')).toHaveTextContent('true'))
const header = getHeaderConfig()
expect(router.currentRoute.value.meta.subType).toBe('电影')
expect(unref(header.items).map(item => item.tab)).toEqual(['mysub', 'popular'])
expect(header.modelValue.value).toBe('mysub')
expect(getListOutput('list type')).toHaveTextContent('电影')
expect(getListOutput('list subscription id')).toHaveTextContent('42')
})
it('uses TV route meta and tab query to expose the share page contract', async () => {
const { router } = await renderSubscribe({
initialRoute: '/subscribe/tv?tab=share&id=73',
subType: '电视剧',
})
const header = getHeaderConfig()
expect(router.currentRoute.value.meta.subType).toBe('电视剧')
expect(unref(header.items).map(item => item.tab)).toEqual(['mysub', 'popular', 'share'])
expect(header.modelValue.value).toBe('share')
expect(screen.getByLabelText('share keyword')).toHaveTextContent('')
})
it.each([
['movie value', '电影' as const, 'last_update', 'last_update'],
['TV-only value', '电视剧' as const, 'lack_episode', 'lack_episode'],
['invalid value', '电视剧' as const, 'unexpected', ''],
['TV-only value on movies', '电影' as const, 'lack_episode', ''],
])('normalizes stored sorting for %s', async (_case, subType, storedSort, expectedSort) => {
localStorage.setItem(`MPSubscribeSortBy:${subType}`, storedSort)
await renderSubscribe({ subType })
expect(getListOutput('list sort by')).toHaveTextContent(expectedSort)
})
it('keeps page state usable when sort storage reads or writes fail', async () => {
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('storage read failed')
})
await renderSubscribe()
expect(getListOutput('list sort by')).toHaveTextContent('')
expect(consoleWarn).toHaveBeenCalledWith('读取订阅排序方式失败:', expect.any(Error))
getItem.mockRestore()
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('storage write failed')
})
await fireEvent.click(screen.getByRole('button', { name: 'emit date sort' }))
await waitFor(() => expect(getListOutput('list sort by')).toHaveTextContent('date'))
expect(consoleWarn).toHaveBeenCalledWith('保存订阅排序方式失败:', expect.any(Error))
})
it('coordinates filter and sort state through header actions and list emits', async () => {
await renderSubscribe({ subType: '电视剧' })
const filterButton = getHeaderButton(button => button.dataAttr === 'filter-btn')
const sortButton = getHeaderButton(button => button.icon === 'mdi-sort-variant')
filterButton.action?.()
await nextTick()
const nameInput = await screen.findByPlaceholderText('名称')
await fireEvent.update(nameInput, 'Matrix')
expect(getListOutput('list keyword')).toHaveTextContent('Matrix')
await fireEvent.click(screen.getByText('暂停'))
expect(getListOutput('list status filter')).toHaveTextContent('paused')
sortButton.action?.()
await nextTick()
expect(getListOutput('list sort mode')).toHaveTextContent('true')
expect(getListOutput('list sort by')).toHaveTextContent('custom')
await fireEvent.click(screen.getByRole('button', { name: 'emit sort mode off' }))
await fireEvent.click(screen.getByRole('button', { name: 'emit date sort' }))
expect(getListOutput('list sort mode')).toHaveTextContent('false')
expect(getListOutput('list sort by')).toHaveTextContent('date')
})
it('exits batch management before entering drag sorting', async () => {
await renderSubscribe({ appMode: true })
const sortButton = getHeaderButton(button => button.icon === 'mdi-sort-variant')
const batchButton = getHeaderButton(button => button.icon === 'mdi-checkbox-multiple-marked-outline')
await fireEvent.click(screen.getByRole('button', { name: 'publish batch selection' }))
expect(unref(getDynamicButtonConfig().show)).toBe(true)
sortButton.action?.()
await nextTick()
expect(getListOutput('last list command')).toHaveTextContent('exit-batch')
expect(getListOutput('list sort mode')).toHaveTextContent('true')
expect(getListOutput('list sort by')).toHaveTextContent('custom')
expect(unref(batchButton.color)).toBe('gray')
expect(unref(getDynamicButtonConfig().show)).toBe(false)
})
it('delegates PWA batch actions to the list public API', async () => {
await renderSubscribe({ appMode: true })
const batchButton = getHeaderButton(button => button.icon === 'mdi-checkbox-multiple-marked-outline')
batchButton.action?.()
await nextTick()
expect(getListOutput('last list command')).toHaveTextContent('enter-batch')
await fireEvent.click(screen.getByRole('button', { name: 'publish batch selection' }))
const dynamicButton = getDynamicButtonConfig()
const menuItems = unref(dynamicButton.menuItems) ?? []
expect(unref(dynamicButton.show)).toBe(true)
expect(unref(dynamicButton.icon)).toBe('mdi-checkbox-multiple-marked-outline')
expect(menuItems.find(item => item.titleKey === 'subscribe.batchSelectAll')?.disabled).toBe(false)
for (const [titleKey, command] of [
['subscribe.batchSelectAll', 'toggle-select-all'],
['subscribe.batchEnable', 'batch-enable'],
['subscribe.batchPause', 'batch-pause'],
['subscribe.batchDelete', 'batch-delete'],
] as const) {
menuItems.find(item => item.titleKey === titleKey)?.action()
await nextTick()
expect(getListOutput('last list command')).toHaveTextContent(command)
}
dynamicButton.onClick?.()
await nextTick()
expect(getListOutput('last list command')).toHaveTextContent('exit-batch')
})
it('exits batch mode when the header leaves the personal subscription tab', async () => {
await renderSubscribe({ appMode: true, subType: '电视剧' })
await fireEvent.click(screen.getByRole('button', { name: 'publish batch selection' }))
getHeaderConfig().modelValue.value = 'popular'
await nextTick()
expect(getListOutput('last list command')).toHaveTextContent('exit-batch')
expect(getListOutput('list active state')).toHaveTextContent('false')
expect(unref(getDynamicButtonConfig().icon)).toBe('mdi-clipboard-edit-outline')
})
it('exposes administrator history and default-rule actions on desktop and PWA', async () => {
const { unmount } = await renderSubscribe({ superUser: true })
await waitFor(() => expect(document.querySelectorAll('.compact-fab button')).toHaveLength(2))
const [historyButton, defaultRuleButton] = document.querySelectorAll<HTMLButtonElement>('.compact-fab button')
await fireEvent.click(historyButton)
expect(getListOutput('last list command')).toHaveTextContent('open-history')
await fireEvent.click(defaultRuleButton)
expect(mocks.openSharedDialog).toHaveBeenCalledWith(
expect.any(Object),
{ default: true, type: '电影' },
{},
{ closeOn: ['close', 'save'] },
)
unmount()
await renderSubscribe({ appMode: true, superUser: true })
const dynamicButton = getDynamicButtonConfig()
expect(unref(dynamicButton.show)).toBe(true)
expect(unref(dynamicButton.icon)).toBe('mdi-history')
expect(unref(dynamicButton.menuItems)?.map(item => item.titleKey)).toEqual([
'dialog.subscribeHistory.title',
'dialog.subscribeEdit.titleDefault',
])
})
it.each([
[true, true],
[false, false],
])('gates the PWA share statistics action by subscribe permission=%s', async (permission, visible) => {
await renderSubscribe({
appMode: true,
initialRoute: '/subscribe/tv?tab=share',
subType: '电视剧',
subscribePermission: permission,
})
const dynamicButton = getDynamicButtonConfig()
expect(unref(dynamicButton.show)).toBe(visible)
if (visible) {
expect(unref(dynamicButton.icon)).toBe('mdi-chart-line')
dynamicButton.onClick?.()
expect(mocks.openSharedDialog).toHaveBeenCalledWith(
expect.any(Object),
{},
{},
{ closeOn: ['close'] },
)
}
})
it('debounces and trims share search, then cancels pending work on unmount', async () => {
const { unmount } = await renderSubscribe({
initialRoute: '/subscribe/tv?tab=share',
subType: '电视剧',
})
getHeaderButton(button => button.dataAttr === 'share-filter-btn').action?.()
await nextTick()
const keywordInput = await screen.findByPlaceholderText('关键词')
vi.useFakeTimers()
await fireEvent.update(keywordInput, ' science fiction ')
expect(getListOutput('share keyword')).toHaveTextContent('')
vi.advanceTimersByTime(299)
await nextTick()
expect(getListOutput('share keyword')).toHaveTextContent('')
vi.advanceTimersByTime(1)
await nextTick()
expect(getListOutput('share keyword')).toHaveTextContent('science fiction')
await fireEvent.update(keywordInput, 'pending')
expect(vi.getTimerCount()).toBeGreaterThan(0)
unmount()
expect(vi.getTimerCount()).toBe(0)
})
})

View File

@@ -4,7 +4,7 @@ import { useAuthStore, useUserStore } from '@/stores'
import { authState, userState } from '@/stores/types'
import api from '@/api'
import router from '@/router'
import logo from '@images/logo.png'
import OpticalLogoLab from '@/components/misc/OpticalLogoLab.vue'
import { bufferToBase64Url, base64UrlToUint8Array, urlBase64ToUint8Array } from '@/@core/utils/navigator'
import { SUPPORTED_LOCALES, SupportedLocale } from '@/types/i18n'
import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'
@@ -16,6 +16,75 @@ import { loadRemoteComponentFromModule, type RemoteModule } from '@/utils/federa
const LoginMfaDialog = defineAsyncComponent(() => import('@/components/dialog/LoginMfaDialog.vue'))
const loginRootRef = ref<HTMLElement | null>(null)
type LabTapTarget = 'logo' | 'title'
const LAB_TAP_COUNT = 5
const LAB_TAP_WINDOW_MS = 2000
const labTapSequences: Record<LabTapTarget, { count: number; startedAt: number }> = {
logo: { count: 0, startedAt: 0 },
title: { count: 0, startedAt: 0 },
}
let cardLightFrame: number | null = null
let pendingCardLightX = 0.5
let pendingCardLightY = 0
let pendingCardLightEnergy = 0
/** 在指定区域连续点击五次时进入隐藏的 Logo 实验室。 */
function handleLabTap(target: LabTapTarget) {
if (router.currentRoute.value.query.lab === '1') return
const now = performance.now()
const sequence = labTapSequences[target]
if (sequence.count === 0 || now - sequence.startedAt > LAB_TAP_WINDOW_MS) {
sequence.count = 1
sequence.startedAt = now
return
}
sequence.count += 1
if (sequence.count < LAB_TAP_COUNT) return
labTapSequences.logo.count = 0
labTapSequences.title.count = 0
void router.push({
path: '/login',
query: { ...router.currentRoute.value.query, lab: '1' },
})
}
/** 卡片顶部反射与指针共用光源位置,避免通过响应式状态触发页面重渲染。 */
function renderCardLight() {
cardLightFrame = null
const root = loginRootRef.value
root?.style.setProperty('--login-card-light-x', `${(pendingCardLightX * 100).toFixed(2)}%`)
root?.style.setProperty('--login-card-light-y', `${(pendingCardLightY * 100).toFixed(2)}%`)
root?.style.setProperty('--login-card-highlight-alpha', (0.035 + pendingCardLightEnergy * 0.08).toFixed(3))
root?.style.setProperty('--login-card-primary-alpha', (0.018 + pendingCardLightEnergy * 0.04).toFixed(3))
root?.style.setProperty('--login-card-top-prism-alpha', (0.2 + pendingCardLightEnergy * 0.32).toFixed(3))
}
/** 根据指针在登录卡片中的位置更新光照目标。 */
function handlePointerLight(event: PointerEvent) {
if (event.pointerType === 'touch') return
const bounds = loginRootRef.value?.querySelector<HTMLElement>('.login-card')?.getBoundingClientRect()
if (!bounds?.width || !bounds.height) return
pendingCardLightX = Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width))
pendingCardLightY = Math.min(1, Math.max(0, (event.clientY - bounds.top) / bounds.height))
const centerDistance = Math.hypot(pendingCardLightX - 0.5, pendingCardLightY - 0.5)
pendingCardLightEnergy = Math.max(0.16, 1 - centerDistance * 1.2)
if (cardLightFrame === null) cardLightFrame = window.requestAnimationFrame(renderCardLight)
}
/** 指针离开登录页后恢复卡片的默认光照位置。 */
function resetPointerLight() {
pendingCardLightX = 0.5
pendingCardLightY = 0
pendingCardLightEnergy = 0
if (cardLightFrame === null) cardLightFrame = window.requestAnimationFrame(renderCardLight)
}
// 国际化
const { t, te } = useI18n()
@@ -487,16 +556,19 @@ async function subscribeForPushNotifications() {
// 登录后处理
async function afterLogin(superuser: boolean, userPayload: userState, filteredMenus: any[]) {
const originalPath = authStore.originalPath
authStore.setOriginalPath(null)
// 如果需要显示设置向导,跳转到设置向导页面
if (userPayload.wizard) {
router.push('/setup-wizard')
await router.push('/setup-wizard')
} else {
// 如果有原始路径,优先跳转到原始路径
if (authStore.originalPath && authStore.originalPath !== '/') {
router.push(authStore.originalPath)
// 原始目标是一次性状态,持久化的旧登录页目标不得重新进入认证流程。
if (originalPath && originalPath !== '/' && router.resolve(originalPath).path !== '/login') {
await router.push(originalPath)
} else {
// 跳转到第一个有权限的菜单
router.push(filteredMenus[0].to)
await router.push(filteredMenus[0].to)
}
}
@@ -675,6 +747,8 @@ async function initConditionalPasskey() {
// 组件卸载时清理
onUnmounted(() => {
if (cardLightFrame !== null) window.cancelAnimationFrame(cardLightFrame)
if (conditionalAbortController) {
conditionalAbortController.abort()
conditionalAbortController = null
@@ -688,7 +762,12 @@ onUnmounted(() => {
<template>
<!-- 登录页面容器 -->
<div class="login-root">
<div
ref="loginRootRef"
class="login-root"
@pointermove="handlePointerLight"
@pointerleave="resetPointerLight"
>
<!-- 装饰性背景光晕 -->
<div class="login-bg-decor" aria-hidden="true">
<div class="login-orb login-orb--1" />
@@ -726,17 +805,24 @@ onUnmounted(() => {
<!-- 登录表单 -->
<div v-if="!mfaDialog" class="auth-wrapper d-flex align-center justify-center">
<VCard
class="auth-card login-card glass-effect pa-7 pa-sm-9 w-full h-full login-card--enter"
class="auth-card login-card glass-effect no-blur pa-7 pa-sm-9 w-full h-full login-card--enter"
max-width="24rem"
flat
>
<div class="login-card__glass" aria-hidden="true">
<span class="login-card__glass-caustic" />
</div>
<!-- 卡片头部Logo + 标题 + 欢迎语 -->
<div class="login-head">
<div class="login-logo-wrapper">
<VImg :src="logo" width="72" height="72" class="login-logo" />
</div>
<h1 class="login-title">MoviePilot</h1>
<p class="login-subtitle">{{ t('login.welcomeBack') || 'Welcome Back' }}</p>
<OpticalLogoLab
class="login-logo"
:locale="currentLocale"
@logo-click="handleLabTap('logo')"
>
<h1 class="login-title" @click="handleLabTap('title')">MoviePilot</h1>
<p class="login-subtitle">{{ t('login.welcomeBack') || 'Welcome Back' }}</p>
</OpticalLogoLab>
</div>
<VCardText class="login-body">
@@ -797,13 +883,15 @@ onUnmounted(() => {
<VCol cols="12" class="py-0">
<!-- remember me checkbox -->
<div class="d-flex align-center justify-space-between flex-wrap">
<VCheckbox
v-model="form.remember"
:label="t('login.stayLoggedIn')"
hide-details
density="compact"
class="login-checkbox"
/>
<label class="native-login-checkbox login-checkbox">
<input
v-model="form.remember"
class="native-login-checkbox__input"
type="checkbox"
name="remember"
/>
<span class="native-login-checkbox__label">{{ t('login.stayLoggedIn') }}</span>
</label>
</div>
</VCol>
<VCol cols="12">
@@ -895,15 +983,39 @@ onUnmounted(() => {
/* ===================== 布局根容器 ===================== */
.login-root {
--login-card-light-x: 50%;
--login-card-light-y: 0%;
--login-card-highlight-alpha: 0.035;
--login-card-primary-alpha: 0.018;
--login-card-top-prism-alpha: 0.2;
--optical-glass-x: 50%;
--optical-glass-y: 22%;
--optical-glass-blur: 24px;
--optical-glass-saturate: 146%;
--optical-glass-contrast: 104%;
--optical-glass-glow-opacity: 0.36;
--optical-glass-caustic-opacity: 0.28;
--optical-glass-caustic-scale: 0.9;
--optical-glass-pulse-blur: 14px;
--optical-glass-shift-x: 0px;
--optical-glass-shift-y: 0px;
--optical-glass-rotation: -5deg;
--optical-glass-scale: 1;
--optical-glass-caustic-highlight-alpha: 0.16;
--optical-glass-caustic-primary-alpha: 0.12;
position: relative;
display: flex;
overflow: hidden;
box-sizing: border-box;
overflow-x: hidden;
overflow-y: auto;
flex-direction: column;
align-items: center;
justify-content: center;
isolation: isolate;
min-block-size: 100vh;
min-block-size: 100dvh;
padding-block: calc(env(safe-area-inset-top, 0px) + 24px) calc(env(safe-area-inset-bottom, 0px) + 24px);
}
/* ===================== 装饰性背景光晕 ===================== */
@@ -1004,9 +1116,10 @@ onUnmounted(() => {
.auth-wrapper {
position: relative;
z-index: 2;
overflow: hidden;
overflow: visible;
block-size: auto;
inline-size: 100%;
min-block-size: 0;
padding-inline: 16px;
}
@@ -1014,43 +1127,115 @@ onUnmounted(() => {
.login-card {
position: relative;
z-index: 1;
overflow: hidden;
border: none !important;
border-radius: var(--app-surface-radius, 20px) !important;
box-shadow:
0 20px 50px rgba(var(--app-shadow-rgb, 0, 0, 0), 0.12),
0 8px 20px rgba(var(--app-shadow-rgb, 0, 0, 0), 0.06),
0 0 0 1px rgba(var(--v-theme-primary), 0.04) !important;
transition: box-shadow 300ms ease;
box-shadow: 0 20px 54px rgba(var(--app-shadow-rgb, 0, 0, 0), 0.12) !important;
> :not(.login-card__glass) {
position: relative;
z-index: 2;
}
/* 顶部高光线,营造立体感 */
&::before {
position: absolute;
z-index: 1;
border-radius: inherit;
background: linear-gradient(90deg, transparent 10%, rgba(255, 255, 255, 35%) 50%, transparent 90%);
z-index: 4;
border-radius: 999px;
background:
radial-gradient(
ellipse at center,
rgba(255, 255, 255, 0.92),
rgba(232, 219, 255, 0.48) 28%,
transparent 72%
),
linear-gradient(
90deg,
transparent,
rgba(117, 212, 255, 0.16) 30%,
rgba(177, 139, 255, 0.22) 50%,
rgba(255, 105, 210, 0.12) 70%,
transparent
);
block-size: 1px;
content: '';
filter: drop-shadow(0 1px 3px rgba(var(--v-theme-primary), 0.16));
inset-block-start: 0;
inset-inline: 0;
inset-inline-start: clamp(44px, var(--login-card-light-x), calc(100% - 44px));
inline-size: 88px;
mix-blend-mode: screen;
opacity: var(--login-card-top-prism-alpha);
pointer-events: none;
transform: translateX(-50%);
}
}
/* 登录卡片自身承载固定磨砂效果,避免跟随透明主题设置变化。 */
/* 登录卡片拥有独立光学表面,不跟随透明主题的全局模糊开关。 */
.glass-effect {
backdrop-filter: blur(28px) saturate(170%) !important;
background: rgba(var(--v-theme-surface), 0.75) !important;
backdrop-filter: none !important;
background: transparent !important;
}
/* 深色主题上叠一条更亮的描边,区分背景 */
:deep(.v-theme--dark) .login-card,
:deep(.v-theme--purple) .login-card,
:deep(.v-theme--transparent) .login-card {
border: 1px solid rgba(255, 255, 255, 8%) !important;
.login-card__glass {
position: absolute;
z-index: 0;
overflow: hidden;
border-radius: inherit;
backdrop-filter: blur(var(--optical-glass-blur)) saturate(var(--optical-glass-saturate))
contrast(var(--optical-glass-contrast));
background:
radial-gradient(
180px 150px at var(--login-card-light-x) var(--login-card-light-y),
rgba(255, 255, 255, var(--login-card-highlight-alpha)),
rgba(var(--v-theme-primary), var(--login-card-primary-alpha)) 44%,
transparent 76%
),
linear-gradient(145deg, rgba(255, 255, 255, 0.075), transparent 34%),
rgba(var(--v-theme-surface), 0.7);
inset: 0;
pointer-events: none;
transform: translateZ(0);
}
:deep(.v-theme--light) .login-card {
border: 1px solid rgba(255, 255, 255, 65%) !important;
.login-card__glass::before {
position: absolute;
border-radius: 44% 56% 61% 39% / 42% 39% 61% 58%;
background:
radial-gradient(circle at 36% 32%, rgba(255, 255, 255, 0.2), transparent 24%),
conic-gradient(
from 218deg,
transparent,
rgba(var(--v-theme-primary), 0.13),
transparent 38%,
rgba(255, 255, 255, 0.09),
transparent 72%
);
content: '';
filter: blur(var(--optical-glass-pulse-blur));
inset: -28%;
opacity: var(--optical-glass-glow-opacity);
transform: translate(var(--optical-glass-shift-x), var(--optical-glass-shift-y))
rotate(var(--optical-glass-rotation)) scale(var(--optical-glass-scale));
transition: opacity 220ms ease;
}
.login-card__glass-caustic {
position: absolute;
display: block;
border-radius: 50%;
background: radial-gradient(
ellipse,
rgba(255, 255, 255, var(--optical-glass-caustic-highlight-alpha)),
rgba(var(--v-theme-primary), var(--optical-glass-caustic-primary-alpha)) 34%,
transparent 72%
);
filter: blur(var(--optical-glass-pulse-blur));
inset-block-start: calc(var(--optical-glass-y) - 88px);
inset-inline-start: calc(var(--optical-glass-x) - 112px);
block-size: 176px;
inline-size: 224px;
opacity: var(--optical-glass-caustic-opacity);
pointer-events: none;
transform: scale(var(--optical-glass-caustic-scale));
}
/* ===================== 卡片头部 ===================== */
@@ -1058,62 +1243,18 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
inline-size: 100%;
margin-block-end: 12px;
text-align: center;
}
.login-logo-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-block-end: 8px;
/* Logo 背后的柔光环 */
&::before {
position: absolute;
z-index: -1;
border-radius: 50%;
animation: logo-pulse 4s ease-in-out infinite;
background: radial-gradient(circle, rgba(var(--v-theme-primary), 0.2) 0%, transparent 70%);
block-size: 120px;
content: '';
inline-size: 120px;
}
}
.login-logo {
animation: logo-float 6s ease-in-out infinite;
filter: drop-shadow(0 8px 20px rgba(var(--v-theme-primary), 0.3));
}
@keyframes logo-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
@keyframes logo-pulse {
0%,
100% {
opacity: 0.6;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.05);
}
inline-size: 100%;
}
.login-title {
margin: 0;
animation: text-enter 600ms cubic-bezier(0.16, 1, 0.3, 1) 200ms both;
background: linear-gradient(135deg, rgb(var(--v-theme-on-surface)) 30%, rgba(var(--v-theme-primary), 1) 100%);
background-clip: text;
font-size: 1.85rem;
@@ -1122,9 +1263,13 @@ onUnmounted(() => {
line-height: 1.2;
-webkit-text-fill-color: transparent;
text-transform: uppercase;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
}
.login-subtitle {
animation: text-enter 600ms cubic-bezier(0.16, 1, 0.3, 1) 300ms both;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.875rem;
font-weight: 400;
@@ -1143,14 +1288,17 @@ onUnmounted(() => {
.native-login-field {
position: relative;
display: flex;
overflow: hidden;
align-items: center;
border: 1px solid rgba(var(--v-border-color), 0.38);
min-block-size: 56px;
border-radius: 12px;
background: transparent;
background: rgba(var(--v-theme-surface), 0.13);
backdrop-filter: blur(10px) saturate(118%);
transition:
border-color 150ms ease,
box-shadow 150ms ease;
box-shadow 150ms ease,
background 220ms ease;
}
.native-login-field:focus-within {
@@ -1168,6 +1316,8 @@ onUnmounted(() => {
}
.native-login-field__input {
position: relative;
z-index: 1;
display: block;
border: 0;
appearance: none;
@@ -1227,6 +1377,66 @@ onUnmounted(() => {
outline: none;
}
/* 原生保持登录复选框,避免使用全局 VCheckbox 小屏适配布局。 */
.native-login-checkbox {
display: inline-flex;
align-items: center;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
cursor: pointer;
gap: 10px;
min-block-size: 40px;
user-select: none;
}
.native-login-checkbox__input {
position: relative;
display: inline-grid;
flex: 0 0 18px;
border: 2px solid rgba(var(--v-theme-on-surface), 0.54);
border-radius: 4px;
margin: 0;
appearance: none;
background: transparent;
block-size: 18px;
cursor: pointer;
inline-size: 18px;
place-content: center;
transition:
background-color 150ms ease,
border-color 150ms ease,
box-shadow 150ms ease;
}
.native-login-checkbox__input::before {
block-size: 5px;
border-block-end: 2px solid rgb(var(--v-theme-on-primary));
border-inline-start: 2px solid rgb(var(--v-theme-on-primary));
content: '';
inline-size: 9px;
transform: translateY(-1px) rotate(-45deg) scale(0);
transform-origin: center;
transition: transform 120ms ease;
}
.native-login-checkbox__input:checked {
border-color: rgb(var(--v-theme-primary));
background-color: rgb(var(--v-theme-primary));
}
.native-login-checkbox__input:checked::before {
transform: translateY(-1px) rotate(-45deg) scale(1);
}
.native-login-checkbox__input:focus-visible {
box-shadow: 0 0 0 3px rgba(var(--v-theme-primary), 0.18);
outline: none;
}
.native-login-checkbox__label {
font-size: 0.9375rem;
line-height: 1.4;
}
/* Remember me 复选框样式优化 */
.login-checkbox {
opacity: 0.85;
@@ -1357,6 +1567,7 @@ onUnmounted(() => {
letter-spacing: 0.03em;
margin-block-start: 14px;
opacity: 0.75;
animation: text-enter 600ms cubic-bezier(0.16, 1, 0.3, 1) 520ms both;
}
.login-version {
@@ -1371,44 +1582,16 @@ onUnmounted(() => {
@keyframes login-enter {
0% {
filter: blur(4px);
opacity: 0;
transform: translateY(20px) scale(0.97);
transform: translateY(12px) scale(0.985);
}
100% {
filter: blur(0);
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Logo 入场 */
.login-logo-wrapper {
animation: logo-enter 700ms cubic-bezier(0.16, 1, 0.3, 1) 100ms both;
}
@keyframes logo-enter {
0% {
opacity: 0;
transform: scale(0.8) translateY(10px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* 标题入场 */
.login-title {
animation: text-enter 600ms cubic-bezier(0.16, 1, 0.3, 1) 200ms both;
}
.login-subtitle {
animation: text-enter 600ms cubic-bezier(0.16, 1, 0.3, 1) 300ms both;
}
@keyframes text-enter {
0% {
opacity: 0;
@@ -1424,10 +1607,10 @@ onUnmounted(() => {
/* ===================== 无障碍:尊重减少动态偏好 ===================== */
@media (prefers-reduced-motion: reduce) {
.login-card--enter,
.login-logo-wrapper,
.login-foot,
.login-title,
.login-subtitle {
animation-duration: 1ms !important;
animation: none !important;
}
.login-submit {
@@ -1438,16 +1621,39 @@ onUnmounted(() => {
display: none !important;
}
.login-logo {
animation: none !important;
}
.login-orb {
animation: none !important;
}
.login-logo-wrapper::before {
animation: none !important;
.login-card__glass::before,
.login-card__glass-caustic {
transform: none !important;
transition: none !important;
}
}
@media (prefers-reduced-transparency: reduce) {
.login-card__glass,
.native-login-field {
backdrop-filter: none !important;
background: rgb(var(--v-theme-surface)) !important;
}
}
@media (prefers-contrast: more) {
.login-card__glass {
background: rgba(var(--v-theme-surface), 0.94);
}
.native-login-field {
border-color: rgba(var(--v-theme-on-surface), 0.68);
}
}
@supports not ((backdrop-filter: blur(1px))) {
.login-card__glass,
.native-login-field {
background: rgba(var(--v-theme-surface), 0.96) !important;
}
}
@@ -1480,4 +1686,14 @@ onUnmounted(() => {
display: none;
}
}
@media (width <= 480px) and (height <= 600px) {
.lang-switch-btn {
inset-block-start: calc(env(safe-area-inset-top, 0px) + 4px);
}
.login-card {
padding-block: 0.75rem !important;
}
}
</style>

View File

@@ -97,6 +97,16 @@ function initializeColors() {
// 额外的数据源
const extraRecommendSources = ref<RecommendSource[]>([])
/** 只接受以标题为键、布尔值为开关的推荐配置。 */
function normalizeEnableConfig(value: unknown): Record<string, boolean> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const entries = Object.entries(value)
if (entries.some(([, enabled]) => typeof enabled !== 'boolean')) return null
return Object.fromEntries(entries)
}
// 加载额外的发现数据源
async function loadExtraRecommendSources() {
try {
@@ -109,16 +119,29 @@ async function loadExtraRecommendSources() {
// 加载面板配置
async function loadConfig() {
// 显示配置
const local_enable = localStorage.getItem('MP_RECOMMEND')
if (local_enable) {
enableConfig.value = JSON.parse(local_enable)
} else {
const response = await api.get('/user/config/Recommend')
if (response && response.data && response.data.value) {
enableConfig.value = response.data.value
localStorage.setItem('MP_RECOMMEND', JSON.stringify(response.data.value))
const localEnable = localStorage.getItem('MP_RECOMMEND')
if (localEnable) {
try {
const localConfig = normalizeEnableConfig(JSON.parse(localEnable))
if (localConfig) {
enableConfig.value = localConfig
return
}
} catch {
// 损坏的本地值按未配置处理,继续尝试服务端配置。
}
localStorage.removeItem('MP_RECOMMEND')
}
try {
const response = await api.get('/user/config/Recommend')
const remoteConfig = normalizeEnableConfig(response?.data?.value)
if (remoteConfig) {
enableConfig.value = remoteConfig
localStorage.setItem('MP_RECOMMEND', JSON.stringify(remoteConfig))
}
} catch (error) {
console.error(error)
}
}

View File

@@ -282,12 +282,18 @@ function batchDeleteSelectedSubscribes() {
subscribeListViewRef.value?.batchDeleteSubscribes()
}
// 切换订阅拖拽排序模式,进入时固定使用自定义排序。
// 批量选择与拖拽排序互斥,进入排序模式时清空批量选择
function toggleSubscribeSortMode() {
if (!subscribeSortMode.value) {
const nextSortMode = !subscribeSortMode.value
if (nextSortMode) {
if (subscribeBatchState.value.enabled) {
exitSubscribeBatchMode()
}
subscribeSortBy.value = 'custom'
}
subscribeSortMode.value = !subscribeSortMode.value
subscribeSortMode.value = nextSortMode
}
const shareKeywordUpdater = debounce((keyword: string) => {

View File

@@ -0,0 +1,199 @@
import type { Component } from 'vue'
import { defineComponent, getCurrentInstance, h, ref } from 'vue'
import { useDisplay } from 'vuetify'
type ResponsiveInputKind = 'choice' | 'field' | 'group' | 'multiline' | 'range'
const MOBILE_EMPTY_PLACEHOLDER = '-'
interface ResponsiveInputOptions {
kind: ResponsiveInputKind
name: string
}
interface ForwardedInputInstance {
blur?: () => void
focus?: () => void
reset?: () => void
resetValidation?: () => void
validate?: (silent?: boolean) => unknown
}
/**
* 判断标签或提示是否包含可展示的文本。
*/
function hasDisplayText(value: unknown): value is string | number {
return (typeof value === 'string' && value.trim().length > 0) || typeof value === 'number'
}
/**
* 判断录入控件模型中是否已有可展示的值。
*/
function hasInputValue(value: unknown) {
if (Array.isArray(value)) return value.length > 0
return value !== undefined && value !== null && value !== ''
}
/**
* 解析包装组件尚未声明的 Vue 布尔属性值。
*/
function isBooleanAttributeEnabled(value: unknown) {
return value === '' || value === true || value === 'true'
}
/**
* 合并控件已有的描述引用与移动端提示、校验信息引用。
*/
function mergeDescribedBy(...values: unknown[]) {
return [...new Set(
values
.filter((value): value is string => typeof value === 'string')
.flatMap(value => value.split(/\s+/))
.filter(Boolean),
)].join(' ')
}
/**
* 将移动端右栏宽度参数转换为可用的 CSS 长度。
*/
function normalizeMobileControlWidth(value: number | string | undefined) {
if (typeof value === 'number') {
if (!Number.isFinite(value)) return undefined
return `${Math.min(100, Math.max(0, value))}%`
}
if (typeof value !== 'string') return undefined
return value.trim() || undefined
}
/**
* 为原生 Vuetify 录入组件创建小屏两栏适配器,桌面端保持原组件渲染路径。
*/
export function createResponsiveInputAdapter(component: Component, options: ResponsiveInputOptions) {
return defineComponent({
name: `App${options.name}`,
inheritAttrs: false,
props: {
mobileLayout: {
type: Boolean,
default: true,
},
mobileControlWidth: {
type: [Number, String],
default: undefined,
},
},
/**
* 根据 Vuetify 断点切换布局,并保留原生控件常用的公开方法。
*/
setup(props, { attrs, expose, slots }) {
const display = useDisplay()
const instanceId = getCurrentInstance()?.uid ?? 0
const controlRef = ref<ForwardedInputInstance>()
/** 聚焦内部原生控件。 */
const focus = () => controlRef.value?.focus?.()
/** 移除内部原生控件的焦点。 */
const blur = () => controlRef.value?.blur?.()
/** 触发内部原生控件校验。 */
const validate = (silent?: boolean) => controlRef.value?.validate?.(silent)
/** 重置内部原生控件值与校验状态。 */
const reset = () => controlRef.value?.reset?.()
/** 仅重置内部原生控件校验状态。 */
const resetValidation = () => controlRef.value?.resetValidation?.()
expose({ blur, focus, reset, resetValidation, validate })
return () => {
const label = attrs.label
const useMobileLayout = props.mobileLayout && display.smAndDown.value && hasDisplayText(label)
if (!useMobileLayout) {
return h(component, { ...attrs, ref: controlRef }, slots)
}
const hint = attrs.hint
const hideDetails = attrs.hideDetails ?? attrs['hide-details']
const showHint = hasDisplayText(hint) && !isBooleanAttributeEnabled(hideDetails)
const controlId = String(attrs.id ?? `app-responsive-input-${instanceId}`)
const hintId = `${controlId}-hint`
const rootClass = attrs.class
const rootStyle = attrs.style
const mobileControlWidth = normalizeMobileControlWidth(props.mobileControlWidth)
const controlAttrs: Record<string, unknown> = { ...attrs }
for (const key of ['class', 'hint', 'label', 'persistent-hint', 'persistentHint', 'style']) {
delete controlAttrs[key]
}
controlAttrs.id = controlId
controlAttrs.ref = controlRef
controlAttrs.class = 'app-responsive-input__native'
controlAttrs.label = undefined
controlAttrs.hint = undefined
controlAttrs.persistentHint = false
controlAttrs.density ??= 'compact'
controlAttrs['aria-label'] ??= String(label)
if (options.kind === 'field' || options.kind === 'multiline') {
controlAttrs.variant = 'plain'
controlAttrs.singleLine = true
if (!hasDisplayText(controlAttrs.placeholder)) {
controlAttrs.placeholder = MOBILE_EMPTY_PLACEHOLDER
}
}
if (showHint) {
controlAttrs['aria-describedby'] = mergeDescribedBy(
controlAttrs['aria-describedby'],
hintId,
`${controlId}-messages`,
)
}
const { label: labelSlot, ...controlSlots } = slots
const labelContent = labelSlot?.({ label, props: { for: controlId } }) ?? String(label)
const disabled = isBooleanAttributeEnabled(attrs.disabled)
const isField = options.kind === 'field' || options.kind === 'multiline'
const isEmptyWithoutPlaceholder = isField
&& !hasInputValue(attrs.modelValue ?? attrs['model-value'])
&& !hasDisplayText(attrs.placeholder)
return h('div', {
class: [
'app-responsive-input',
`app-responsive-input--${options.kind}`,
{
'app-responsive-input--disabled': disabled,
'app-responsive-input--empty': isEmptyWithoutPlaceholder,
},
rootClass,
],
style: [
rootStyle,
mobileControlWidth
? { '--app-responsive-input-control-width': mobileControlWidth }
: undefined,
],
}, [
h('div', { class: 'app-responsive-input__meta' }, [
h('label', { class: 'app-responsive-input__label', for: controlId }, labelContent),
showHint
? h('div', { id: hintId, class: 'app-responsive-input__hint' }, String(hint))
: null,
]),
h('div', { class: 'app-responsive-input__control' }, [
h(component, controlAttrs, controlSlots),
]),
])
}
},
})
}

View File

@@ -3,10 +3,28 @@ import * as components from 'vuetify/components'
import { VBtn } from 'vuetify/components/VBtn'
import * as labsComponents from 'vuetify/labs/components'
import AppDialog from './AppDialog'
import { createResponsiveInputAdapter } from './AppInput'
import defaults from './defaults'
import { icons } from './icons'
import type { ResponsiveInputComponentName } from './responsiveInputNames'
import theme from './theme'
const responsiveInputComponents = {
VAutocomplete: createResponsiveInputAdapter(components.VAutocomplete, { name: 'Autocomplete', kind: 'field' }),
VCheckbox: createResponsiveInputAdapter(components.VCheckbox, { name: 'Checkbox', kind: 'choice' }),
VCombobox: createResponsiveInputAdapter(components.VCombobox, { name: 'Combobox', kind: 'field' }),
VDateInput: createResponsiveInputAdapter(labsComponents.VDateInput, { name: 'DateInput', kind: 'field' }),
VFileInput: createResponsiveInputAdapter(components.VFileInput, { name: 'FileInput', kind: 'field' }),
VNumberInput: createResponsiveInputAdapter(labsComponents.VNumberInput, { name: 'NumberInput', kind: 'field' }),
VRadioGroup: createResponsiveInputAdapter(components.VRadioGroup, { name: 'RadioGroup', kind: 'group' }),
VRangeSlider: createResponsiveInputAdapter(components.VRangeSlider, { name: 'RangeSlider', kind: 'range' }),
VSelect: createResponsiveInputAdapter(components.VSelect, { name: 'Select', kind: 'field' }),
VSlider: createResponsiveInputAdapter(components.VSlider, { name: 'Slider', kind: 'range' }),
VSwitch: createResponsiveInputAdapter(components.VSwitch, { name: 'Switch', kind: 'choice' }),
VTextarea: createResponsiveInputAdapter(components.VTextarea, { name: 'Textarea', kind: 'multiline' }),
VTextField: createResponsiveInputAdapter(components.VTextField, { name: 'TextField', kind: 'field' }),
} satisfies Record<ResponsiveInputComponentName, ReturnType<typeof createResponsiveInputAdapter>>
export default createVuetify({
aliases: {
IconBtn: VBtn,
@@ -16,7 +34,8 @@ export default createVuetify({
theme,
components: {
...components,
VDialog: AppDialog,
...labsComponents,
VDialog: AppDialog,
...responsiveInputComponents,
},
})

View File

@@ -0,0 +1,21 @@
export const responsiveInputCoreComponentNames = [
'VAutocomplete',
'VCheckbox',
'VCombobox',
'VFileInput',
'VRadioGroup',
'VRangeSlider',
'VSelect',
'VSlider',
'VSwitch',
'VTextarea',
'VTextField',
] as const
export const responsiveInputComponentNames = [
...responsiveInputCoreComponentNames,
'VDateInput',
'VNumberInput',
] as const
export type ResponsiveInputComponentName = typeof responsiveInputComponentNames[number]

View File

@@ -6,7 +6,7 @@ const theme: VuetifyOptions['theme'] = {
light: {
dark: false,
colors: {
'primary': '#9155FD',
'primary': '#8D51F9',
'secondary': '#8A8D93',
'on-secondary': '#FFFFFF',
'success': '#56CA00',
@@ -107,7 +107,7 @@ const theme: VuetifyOptions['theme'] = {
purple: {
dark: true,
colors: {
'primary': '#9155FD',
'primary': '#8D51F9',
'secondary': '#8A8D93',
'on-secondary': '#FFFFFF',
'success': '#56CA00',

View File

@@ -318,8 +318,8 @@ router.beforeEach(async (to: any, from: any, next: any) => {
// 认证 Store
const authStore = useAuthStore()
// 总是记录非login路由
if (to.fullPath != '/login') authStore.originalPath = to.fullPath
// 登录页的实验参数不是登录后可恢复的业务目标。
if (to.path !== '/login') authStore.originalPath = to.fullPath
const isAuthenticated = authStore.token !== null
if (to.meta.requiresAuth && !isAuthenticated) {

View File

@@ -0,0 +1,67 @@
import { useAuthStore } from '@/stores/auth'
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
describe('auth store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('starts with the unauthenticated state and matching getters', () => {
const authStore = useAuthStore()
expect(authStore.$state).toEqual({ token: null, remember: false, originalPath: null })
expect(authStore.getToken).toBeNull()
expect(authStore.getRemember).toBe(false)
expect(authStore.getOriginalPath).toBeNull()
})
it('logs in and updates independent authentication fields', () => {
const authStore = useAuthStore()
authStore.setOriginalPath('/recommend')
authStore.login({ token: 'test-token', remember: true, originalPath: '/ignored' })
expect(authStore.token).toBe('test-token')
expect(authStore.remember).toBe(true)
expect(authStore.originalPath).toBe('/recommend')
authStore.setRemember(false)
authStore.clearToken()
expect(authStore.getRemember).toBe(false)
expect(authStore.getToken).toBeNull()
})
it('logs out and clears plugin navigation state', () => {
const authStore = useAuthStore()
const pluginNavStore = usePluginSidebarNavStore()
const pendingRequest = Promise.resolve()
authStore.login({ token: 'test-token', remember: true })
authStore.setOriginalPath('/plugins')
pluginNavStore.$patch({
inflight: pendingRequest,
items: [
{
icon: 'mdi-test-tube',
nav_key: 'main',
order: 1,
plugin_id: 'demo',
section: 'system',
title: 'Demo',
},
],
loaded: true,
})
authStore.logout()
expect(authStore.token).toBeNull()
expect(authStore.originalPath).toBeNull()
expect(authStore.remember).toBe(true)
expect(pluginNavStore.items).toEqual([])
expect(pluginNavStore.loaded).toBe(false)
expect(pluginNavStore.inflight).toBeNull()
})
})

View File

@@ -500,6 +500,280 @@ html[data-theme-radius='extra'] {
}
}
// 小屏录入控件统一为左侧说明、右侧操作的紧凑行;桌面端由适配器直接渲染 Vuetify 原组件。
.app-responsive-input {
display: grid;
min-block-size: 4.5rem;
min-inline-size: 0;
padding-block: 0.75rem;
align-items: center;
column-gap: 1rem;
grid-template-columns: minmax(0, 1fr) minmax(7rem, var(--app-responsive-input-control-width, 42%));
}
.app-responsive-input__meta,
.app-responsive-input__control {
min-inline-size: 0;
}
.app-responsive-input__label {
display: block;
color: rgb(var(--v-theme-on-surface));
cursor: default;
font-size: 0.9375rem;
font-weight: 500;
line-height: 1.35;
overflow-wrap: anywhere;
}
.app-responsive-input__hint {
margin-block-start: 0.25rem;
color: rgba(var(--v-theme-on-surface), 0.56);
font-size: 0.75rem;
line-height: 1.4;
overflow-wrap: anywhere;
}
.app-responsive-input__control {
display: flex;
align-items: center;
}
.app-responsive-input__native {
inline-size: 100%;
margin: 0;
}
.app-responsive-input__native .v-input__control,
.app-responsive-input__native .v-field,
.app-responsive-input__native .v-field__field,
.app-responsive-input__native .v-field__input {
min-inline-size: 0;
}
// 移动录入行只保留值本身,统一移除字段内部的装饰与操作图标。
.app-responsive-input__native .v-field__prepend-inner,
.app-responsive-input__native .v-field__append-inner,
.app-responsive-input__native .v-field__clearable {
display: none;
}
.app-responsive-input--field .v-field,
.app-responsive-input--multiline .v-field {
border-radius: var(--app-control-radius) !important;
background-color: transparent;
transition: background-color 0.18s ease, box-shadow 0.18s ease;
}
.app-responsive-input--empty:not(:has(.v-field--dirty)) .v-field {
background-color: transparent;
}
.app-responsive-input--empty:not(:has(.v-field--dirty)) .v-field__input::placeholder {
color: rgba(var(--v-theme-on-surface), 0.5);
opacity: 1;
}
.app-responsive-input--field .v-field__outline,
.app-responsive-input--field .v-field__overlay,
.app-responsive-input--multiline .v-field__outline,
.app-responsive-input--multiline .v-field__overlay {
display: none;
}
.app-responsive-input--field .v-field__input,
.app-responsive-input--multiline .v-field__input {
min-block-size: 2.75rem;
padding: 0.5rem;
font-size: 0.9375rem;
line-height: 1.4;
}
// 移动端单行字段内容超出可视宽度时保持一行并显示省略号。
.app-responsive-input--field .v-field__input {
overflow: hidden;
flex-wrap: nowrap;
}
.app-responsive-input--field :is(
input.v-field__input,
.v-field__input > input,
.v-select__selection,
.v-select__selection-text,
.v-autocomplete__selection,
.v-autocomplete__selection-text,
.v-combobox__selection,
.v-combobox__selection-text
) {
overflow: hidden;
min-inline-size: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-responsive-input__native:is(
.v-select--multiple,
.v-autocomplete--multiple,
.v-combobox--multiple
) .v-field__input {
overflow: visible;
flex-wrap: wrap;
row-gap: 0.25rem;
}
.app-responsive-input__native:is(
.v-select--multiple,
.v-autocomplete--multiple,
.v-combobox--multiple
) :is(
.v-select__selection,
.v-select__selection-text,
.v-autocomplete__selection,
.v-autocomplete__selection-text,
.v-combobox__selection,
.v-combobox__selection-text
) {
overflow: visible;
text-overflow: clip;
white-space: normal;
}
.app-responsive-input__native:is(
.v-select--multiple,
.v-autocomplete--multiple,
.v-combobox--multiple
) .v-chip {
max-inline-size: 100%;
}
.app-responsive-input__native:is(
.v-select--multiple,
.v-autocomplete--multiple,
.v-combobox--multiple
) .v-chip__content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-responsive-input--field .v-field--focused,
.app-responsive-input--multiline .v-field--focused {
background-color: rgba(var(--v-theme-primary), 0.08);
box-shadow: inset 0 0 0 1px rgba(var(--v-theme-primary), 0.2);
}
.app-responsive-input--multiline {
row-gap: 0.5rem;
align-items: start;
grid-template-columns: minmax(0, 1fr);
}
.app-responsive-input--multiline .app-responsive-input__control {
inline-size: 100%;
}
.app-responsive-input--multiline .v-field__input {
min-block-size: 5.5rem;
align-items: start;
}
.app-responsive-input--choice .app-responsive-input__control,
.app-responsive-input--choice .v-input__control,
.app-responsive-input--group .v-selection-control-group {
justify-content: end;
}
.app-responsive-input--choice .v-input__control {
display: flex;
}
.app-responsive-input--choice .v-selection-control {
flex: 0 0 auto;
min-block-size: 2.75rem;
}
.app-responsive-input--range .v-input__control {
min-inline-size: 0;
}
.app-responsive-input--range .v-slider {
padding-block: 0.375rem;
}
.app-responsive-input__native .v-input__details {
padding-inline: 0.5rem 0;
}
.app-responsive-input__native .v-messages__message:empty {
display: none;
}
.app-responsive-input__native
.v-input__details:has(> .v-messages > .v-messages__message:empty):not(
:has(> .v-messages > .v-messages__message:not(:empty))
):not(:has(> :not(.v-messages))) {
display: none;
}
.app-responsive-input__native .v-field--error {
background-color: rgba(var(--v-theme-error), 0.08);
box-shadow: inset 0 0 0 1px rgb(var(--v-theme-error));
}
.app-responsive-input--disabled .app-responsive-input__meta {
opacity: var(--v-disabled-opacity);
}
// 进入移动录入模式的控件强制独占一行,避免原 VCol 小于 12 列时两栏布局被再次压缩。
@media (width < 960px) {
.v-row > :is(.v-col, [class*='v-col-']):has(.app-responsive-input) {
flex: 0 0 100%;
max-width: 100%;
}
// 移动录入行自身已经提供纵向留白,移除 VCol 纵向 gutter 避免堆叠字段间距过大。
.v-row > :is(.v-col, [class*='v-col-']):has(> .app-responsive-input) {
padding-block: 0;
}
// 从第二个录入项开始绘制顶部线;表单内允许字段包装,表单外仅匹配浅层结构以避免穿透嵌套卡片。
.app-responsive-input ~ .app-responsive-input,
.v-form .v-row > :is(.v-col, [class*='v-col-']) ~ :is(.v-col, [class*='v-col-']) .app-responsive-input,
.v-form .v-row + .v-row > :is(.v-col, [class*='v-col-']):first-child .app-responsive-input,
.v-row > :is(.v-col, [class*='v-col-']) ~ :is(.v-col, [class*='v-col-']) > .app-responsive-input,
.v-row > :is(.v-col, [class*='v-col-']) ~ :is(.v-col, [class*='v-col-']) > * > .app-responsive-input,
.v-row + .v-row > :is(.v-col, [class*='v-col-']):first-child > .app-responsive-input,
.v-row + .v-row > :is(.v-col, [class*='v-col-']):first-child > * > .app-responsive-input {
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
// 表单顶层在桌面也占满 12 格的字段改为上下两行,避免把嵌套 md=12 字段误判为整行字段。
.v-form > .v-row > :is(
.v-col-12:not([class*='v-col-sm-']):not([class*='v-col-md-']):not([class*='v-col-lg-']):not([class*='v-col-xl-']):not([class*='v-col-xxl-']),
.v-col-sm-12:not([class*='v-col-md-']):not([class*='v-col-lg-']):not([class*='v-col-xl-']):not([class*='v-col-xxl-']),
.v-col-md-12:not([class*='v-col-lg-']):not([class*='v-col-xl-']):not([class*='v-col-xxl-'])
) > :is(.app-responsive-input--field, .app-responsive-input--range) {
row-gap: 0.5rem;
align-items: start;
grid-template-columns: minmax(0, 1fr);
}
.v-form > .v-row > :is(
.v-col-12:not([class*='v-col-sm-']):not([class*='v-col-md-']):not([class*='v-col-lg-']):not([class*='v-col-xl-']):not([class*='v-col-xxl-']),
.v-col-sm-12:not([class*='v-col-md-']):not([class*='v-col-lg-']):not([class*='v-col-xl-']):not([class*='v-col-xxl-']),
.v-col-md-12:not([class*='v-col-lg-']):not([class*='v-col-xl-']):not([class*='v-col-xxl-'])
) > :is(.app-responsive-input--field, .app-responsive-input--range) .app-responsive-input__control {
inline-size: 100%;
}
}
@media (hover: hover) {
.app-responsive-input--field .v-field:hover,
.app-responsive-input--multiline .v-field:hover {
background-color: rgba(var(--v-theme-on-surface), 0.04);
}
}
.v-btn:not(.v-btn--variant-plain, .v-btn--variant-text, .v-btn--flat, .theme-horizontal-nav__item) {
box-shadow: var(--app-surface-shadow) !important;
transition: box-shadow 0.2s ease;

View File

@@ -0,0 +1,106 @@
import {
ADMIN_PERMISSIONS,
buildDefaultFeaturePermissions,
buildPluginPermissionFeatureKey,
buildUserPermissionContext,
DEFAULT_PERMISSIONS,
filterItemsByPermission,
filterMenusByPermission,
hasAllPermissions,
hasAnyPermission,
hasFeaturePermission,
hasItemPermission,
hasPermission,
normalizeUserPermissions,
PERMISSION_FEATURE,
USER_PERMISSION_FEATURES,
type UserPermissionFeatureMap,
} from '@/utils/permission'
import { describe, expect, it } from 'vitest'
describe('permission utilities', () => {
it('normalizes legacy permissions and filters invalid feature values', () => {
const normalized = normalizeUserPermissions({
discovery: false,
features: {
enabled: true,
invalid: 'yes',
} as unknown as UserPermissionFeatureMap,
})
expect(normalized).toEqual({
...DEFAULT_PERMISSIONS,
discovery: false,
features: { enabled: true },
})
expect(normalizeUserPermissions(null)).toEqual(DEFAULT_PERMISSIONS)
})
it('builds default and plugin feature contracts', () => {
const disabledFeatures = buildDefaultFeaturePermissions(false)
expect(Object.keys(disabledFeatures)).toHaveLength(USER_PERMISSION_FEATURES.length)
expect(Object.values(disabledFeatures).every(enabled => enabled === false)).toBe(true)
expect(buildPluginPermissionFeatureKey('demo')).toBe('plugin.demo.main')
expect(buildPluginPermissionFeatureKey('demo', 'settings')).toBe('plugin.demo.settings')
expect(ADMIN_PERMISSIONS.features?.[PERMISSION_FEATURE.MANAGE_SITE]).toBe(true)
})
it('grants every category and admin entry only to superusers', () => {
const superuser = buildUserPermissionContext(true, {})
expect(hasPermission(superuser, 'admin')).toBe(true)
expect(hasPermission(superuser, 'manage')).toBe(true)
expect(hasFeaturePermission(superuser, PERMISSION_FEATURE.MANAGE_SITE, 'manage')).toBe(true)
expect(hasPermission({ admin: true, manage: true }, 'admin')).toBe(false)
})
it('checks category permissions as explicit booleans', () => {
const permissions = { discovery: true, search: false, subscribe: 1, manage: undefined }
expect(hasPermission(permissions, 'discovery')).toBe(true)
expect(hasPermission(permissions, 'search')).toBe(false)
expect(hasPermission(permissions, 'subscribe')).toBe(false)
expect(hasPermission(null, 'manage')).toBe(false)
expect(hasAnyPermission(permissions, ['search', 'discovery'])).toBe(true)
expect(hasAllPermissions(permissions, ['discovery', 'search'])).toBe(false)
})
it('inherits missing feature flags but honors explicit denial and parent categories', () => {
const legacyUser = { discovery: true }
const restrictedUser = {
discovery: true,
features: { [PERMISSION_FEATURE.DISCOVERY_RECOMMEND]: false },
}
expect(hasFeaturePermission(legacyUser)).toBe(true)
expect(hasFeaturePermission(legacyUser, PERMISSION_FEATURE.DISCOVERY_RECOMMEND, 'discovery')).toBe(true)
expect(hasFeaturePermission(restrictedUser, PERMISSION_FEATURE.DISCOVERY_RECOMMEND, 'discovery')).toBe(false)
expect(
hasFeaturePermission(
{ discovery: false, features: { [PERMISSION_FEATURE.DISCOVERY_RECOMMEND]: true } },
PERMISSION_FEATURE.DISCOVERY_RECOMMEND,
'discovery',
),
).toBe(false)
})
it('checks and filters permission-protected items consistently', () => {
const permissions = {
discovery: true,
manage: false,
features: { [PERMISSION_FEATURE.DISCOVERY_EXPLORE]: false },
}
const items = [
{ id: 'open' },
{ id: 'recommend', permission: 'discovery' as const, feature: PERMISSION_FEATURE.DISCOVERY_RECOMMEND },
{ id: 'explore', permission: 'discovery' as const, feature: PERMISSION_FEATURE.DISCOVERY_EXPLORE },
{ id: 'manage', permission: 'manage' as const },
]
expect(hasItemPermission(items[1], permissions)).toBe(true)
expect(hasItemPermission(items[2], permissions)).toBe(false)
expect(filterItemsByPermission(items, permissions).map(item => item.id)).toEqual(['open', 'recommend'])
expect(filterMenusByPermission(items, permissions).map(item => item.id)).toEqual(['open', 'recommend'])
})
})

View File

@@ -0,0 +1,61 @@
import type { RecommendSource } from '@/api/types'
import {
createBuiltInRecommendSources,
mergeExtraRecommendSources,
type RecommendViewSource,
} from '@/utils/recommendSources'
import { describe, expect, it } from 'vitest'
const translate = (key: string) => `translated:${key}`
describe('recommendSources', () => {
it('creates the complete built-in source contract', () => {
const sources = createBuiltInRecommendSources(translate)
expect(sources).toHaveLength(13)
expect(sources[0]).toEqual({
apipath: 'recommend/tmdb_trending',
linkurl: '/browse/recommend/tmdb_trending?title=translated:recommend.trendingNow',
title: 'translated:recommend.trendingNow',
type: 'translated:recommend.categoryRankings',
})
expect(sources).toContainEqual(
expect.objectContaining({
apipath: 'recommend/tmdb_tvs?with_original_language=zh|en|ja|ko',
linkurl:
'/browse/recommend/tmdb_tvs?with_original_language=zh|en|ja|ko&title=translated:recommend.tmdbHotTVShows',
}),
)
})
it('appends extra sources in order and skips duplicate API paths', () => {
const target = createBuiltInRecommendSources(translate).slice(0, 1)
const extras: RecommendSource[] = [
{ api_path: 'recommend/tmdb_trending', name: '重复来源', type: '榜单' },
{ api_path: 'recommend/custom', name: '自定义来源', type: '扩展' },
{ api_path: 'recommend/custom', name: '重复扩展', type: '扩展' },
]
mergeExtraRecommendSources(target, extras)
expect(target).toHaveLength(2)
expect(target[1]).toMatchObject({
apipath: 'recommend/custom',
title: '自定义来源',
type: '扩展',
})
})
it('uses the correct query separator and encodes source names', () => {
const target: RecommendViewSource[] = []
const extras: RecommendSource[] = [
{ api_path: 'recommend/custom', name: '中文 & special', type: '扩展' },
{ api_path: 'recommend/filtered?genre=1', name: '筛选/来源', type: '扩展' },
]
mergeExtraRecommendSources(target, extras)
expect(target[0].linkurl).toBe('/browse/recommend/custom?title=%E4%B8%AD%E6%96%87%20%26%20special')
expect(target[1].linkurl).toBe('/browse/recommend/filtered?genre=1&title=%E7%AD%9B%E9%80%89%2F%E6%9D%A5%E6%BA%90')
})
})

View File

@@ -0,0 +1,42 @@
import logoSvg from '@images/logo.svg?raw'
import { applyThemeLogoPalette, createThemeLogoPalette } from '@/utils/themeLogo'
import { describe, expect, it } from 'vitest'
describe('theme logo palette', () => {
it('reproduces the source artwork when its original primary color is selected', () => {
const result = applyThemeLogoPalette(logoSvg, createThemeLogoPalette('#8D51F9'))
expect(result).toBe(logoSvg)
})
it('replaces every original logo color without flattening its gradient structure', () => {
const palette = createThemeLogoPalette('#00BCD4')
const result = applyThemeLogoPalette(logoSvg, palette)
const originalColors = [
'rgb(141,81,249)',
'rgb(165,118,255)',
'rgb(211,187,255)',
'rgb(116,50,223)',
'rgb(110,38,217)',
'rgb(104,0,197)',
'rgb(91,0,197)',
]
expect(result.match(/<(?:linear|radial)Gradient/g)).toHaveLength(6)
expect(result.match(/<path/g)).toHaveLength(12)
expect(result).toContain('stop-opacity:1')
expect(result).toContain(palette.primary)
expect(result).toContain(palette.highlight)
expect(result).toContain(palette.deepest)
originalColors.forEach(color => expect(result).not.toContain(color))
})
it.each(['#000000', '#808080', '#FFFFFF'])('keeps visible facet contrast for neutral theme color %s', primary => {
const palette = createThemeLogoPalette(primary)
const distinctColors = new Set(Object.values(palette))
expect(distinctColors.size).toBeGreaterThanOrEqual(5)
expect(palette.primary).not.toBe(palette.highlight)
expect(palette.primary).not.toBe(palette.deepest)
})
})

View File

@@ -0,0 +1,25 @@
import { applyDocumentThemeChrome } from '@/utils/themePalette'
import { describe, expect, it, vi } from 'vitest'
describe('theme palette', () => {
it('notifies the favicon renderer with the applied primary color', () => {
const handleFaviconChange = vi.fn()
window.addEventListener('moviepilot-theme-primary-color-change', handleFaviconChange)
try {
const result = applyDocumentThemeChrome('dark', {
background: '#0E1116',
primary: '#00BCD4',
})
const event = handleFaviconChange.mock.calls[0]?.[0] as CustomEvent<{ color: string }>
expect(result.primary).toBe('#00BCD4')
expect(document.documentElement.style.getPropertyValue('--initial-loader-color')).toBe('#00BCD4')
expect(handleFaviconChange).toHaveBeenCalledOnce()
expect(event.detail).toEqual({ color: '#00BCD4' })
} finally {
window.removeEventListener('moviepilot-theme-primary-color-change', handleFaviconChange)
}
})
})

138
src/utils/themeLogo.ts Normal file
View File

@@ -0,0 +1,138 @@
export interface ThemeLogoPalette {
/** 标识主体色,保持与当前主题主色一致。 */
primary: string
/** 标识迎光面色阶。 */
light: string
/** 标识高光渐变色阶。 */
highlight: string
/** 标识第一层背光面色阶。 */
dark: string
/** 标识第二层背光面色阶。 */
darker: string
/** 标识内侧深色面色阶。 */
deep: string
/** 标识最深的内侧面色阶。 */
deepest: string
}
interface HslColor {
h: number
l: number
s: number
}
const sourceLogoPalette: Record<string, keyof ThemeLogoPalette> = {
'rgb(141,81,249)': 'primary',
'rgb(165,118,255)': 'light',
'rgb(211,187,255)': 'highlight',
'rgb(116,50,223)': 'dark',
'rgb(110,38,217)': 'darker',
'rgb(104,0,197)': 'deep',
'rgb(91,0,197)': 'deepest',
}
const sourceLogoRgb: Record<keyof ThemeLogoPalette, [number, number, number]> = {
primary: [141, 81, 249],
light: [165, 118, 255],
highlight: [211, 187, 255],
dark: [116, 50, 223],
darker: [110, 38, 217],
deep: [104, 0, 197],
deepest: [91, 0, 197],
}
function clamp(value: number, min = 0, max = 1) {
return Math.min(max, Math.max(min, value))
}
function parseHexColor(hexColor: string) {
const normalized = hexColor.trim().replace('#', '')
if (!/^[\da-f]{6}$/i.test(normalized)) return null
return [0, 2, 4].map(offset => Number.parseInt(normalized.slice(offset, offset + 2), 16)) as [number, number, number]
}
function rgbToHsl([red, green, blue]: [number, number, number]): HslColor {
const r = red / 255
const g = green / 255
const b = blue / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const delta = max - min
const l = (max + min) / 2
if (delta === 0) return { h: 0, l, s: 0 }
const s = delta / (1 - Math.abs(2 * l - 1))
let h = 0
if (max === r) h = ((g - b) / delta) % 6
else if (max === g) h = (b - r) / delta + 2
else h = (r - g) / delta + 4
return { h: (h * 60 + 360) % 360, l, s }
}
function hslToRgb({ h, l, s }: HslColor) {
const chroma = (1 - Math.abs(2 * l - 1)) * s
const segment = h / 60
const secondary = chroma * (1 - Math.abs((segment % 2) - 1))
let channels: [number, number, number]
if (segment < 1) channels = [chroma, secondary, 0]
else if (segment < 2) channels = [secondary, chroma, 0]
else if (segment < 3) channels = [0, chroma, secondary]
else if (segment < 4) channels = [0, secondary, chroma]
else if (segment < 5) channels = [secondary, 0, chroma]
else channels = [chroma, 0, secondary]
const offset = l - chroma / 2
const rgb = channels.map(channel => Math.round((channel + offset) * 255))
return `rgb(${rgb.join(',')})`
}
function shiftLogoTone(color: HslColor, hueOffset: number, lightnessOffset: number, saturationScale = 1) {
return hslToRgb({
h: (color.h + hueOffset + 360) % 360,
l: clamp(color.l + lightnessOffset, 0.08, 0.92),
s: clamp(color.s * saturationScale),
})
}
/**
* 从主题主色生成完整的标识明暗色阶。
* 接近黑、白的主题色会反向拉开部分色阶,避免分面收敛成同一颜色。
*/
export function createThemeLogoPalette(primaryColor: string): ThemeLogoPalette {
const rgb = parseHexColor(primaryColor) || [141, 81, 249]
const hsl = rgbToHsl(rgb)
const sourcePrimaryHsl = rgbToHsl(sourceLogoRgb.primary)
const lightDirection = hsl.l >= 0.78 ? -1 : 1
const darkDirection = hsl.l <= 0.22 ? 1 : -1
const palette = Object.fromEntries(
Object.entries(sourceLogoRgb).map(([key, sourceRgb]) => {
const paletteKey = key as keyof ThemeLogoPalette
if (paletteKey === 'primary') return [paletteKey, `rgb(${rgb.join(',')})`]
const sourceHsl = rgbToHsl(sourceRgb)
const hueOffset = sourceHsl.h - sourcePrimaryHsl.h
const sourceLightnessDelta = sourceHsl.l - sourcePrimaryHsl.l
const lightnessDelta =
Math.abs(sourceLightnessDelta) * (sourceLightnessDelta >= 0 ? lightDirection : darkDirection)
const saturationScale = sourcePrimaryHsl.s ? sourceHsl.s / sourcePrimaryHsl.s : 1
return [paletteKey, shiftLogoTone(hsl, hueOffset, lightnessDelta, saturationScale)]
}),
) as unknown as ThemeLogoPalette
return palette
}
/** 将原始品牌 SVG 的色阶逐层映射到当前主题色家族,保留路径、渐变和透明高光。 */
export function applyThemeLogoPalette(svgSource: string, palette: ThemeLogoPalette) {
return Object.entries(sourceLogoPalette).reduce(
(svg, [sourceColor, paletteKey]) => svg.replaceAll(sourceColor, palette[paletteKey]),
svgSource,
)
}

View File

@@ -19,7 +19,7 @@ interface ApplyDocumentThemeChromeOptions {
export const themeRootPalettes: Record<ResolvedThemeName, ThemeRootPalette> = {
light: {
background: '#F4F5FA',
primary: '#9155FD',
primary: '#8D51F9',
},
dark: {
background: '#0E1116',
@@ -27,7 +27,7 @@ export const themeRootPalettes: Record<ResolvedThemeName, ThemeRootPalette> = {
},
purple: {
background: '#28243D',
primary: '#9155FD',
primary: '#8D51F9',
},
transparent: {
background: '#1C1C1C',
@@ -80,6 +80,15 @@ function ensureThemeColorMeta(themeColor: string) {
document.head.appendChild(meta)
}
/** 通知启动层刷新浏览器 Tab 图标,图标颜色与当前主题主色保持一致。 */
export function syncThemeFavicon(primaryColor: string) {
window.dispatchEvent(
new CustomEvent('moviepilot-theme-primary-color-change', {
detail: { color: primaryColor },
}),
)
}
/**
* 同步浏览器首帧会使用的根节点底色和系统控件配色。
* iOS PWA 从后台恢复时可能先绘制 WebView 外壳,再等 Vue 响应式主题更新。
@@ -110,6 +119,7 @@ export function applyDocumentThemeChrome(
setMetaContent('meta[name="color-scheme"]', colorScheme === 'dark' ? 'dark light' : 'light dark')
ensureThemeColorMeta(background)
syncThemeFavicon(primary)
if (options.persistLoaderColors) {
localStorage.setItem('materio-initial-loader-bg', background)

View File

@@ -2,14 +2,15 @@
import api from '@/api'
import type { MediaInfo } from '@/api/types'
import { getMediaSubscribeId } from '@/composables/useMediaSubscribe'
import router from '@/router'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
import { createBuiltInRecommendSources, type RecommendViewSource } from '@/utils/recommendSources'
import noImage from '@images/no-image.jpeg'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const globalSettingsStore = useGlobalSettingsStore()
const RECOMMEND_SOURCE_STORAGE_KEY = 'MP_DASHBOARD_RECOMMEND_SOURCE'
const RECOMMEND_SLIDE_COUNT = 5
@@ -29,10 +30,12 @@ const mediaCache = new Map<string, MediaInfo[]>()
const activeIndex = ref(0)
const loading = ref(true)
const loadFailed = ref(false)
const isPaused = ref(false)
const isHovered = ref(false)
const isFocusWithin = ref(false)
const touchStartX = ref<number | null>(null)
let requestId = 0
let autoplayTimer: number | null = null
let isComponentActive = false
const selectedSource = computed(
() => sources.value.find(source => source.apipath === selectedSourcePath.value) ?? sources.value[0],
@@ -40,6 +43,14 @@ const selectedSource = computed(
const activeMedia = computed(() => mediaItems.value[activeIndex.value])
/** 根据推荐来源返回便于快速识别的媒体类别图标。 */
function getSourceIcon(source?: RecommendViewSource) {
if (source?.apipath === 'recommend/tmdb_trending') return 'mdi-trending-up'
if (source?.apipath === 'recommend/tmdb_movies') return 'mdi-movie-outline'
if (source?.apipath.startsWith('recommend/tmdb_tvs')) return 'mdi-television-classic'
return 'mdi-movie-open-star-outline'
}
/** 将不同接口包装格式归一化为媒体数组。 */
function normalizeMediaResponse(response: unknown): MediaInfo[] {
if (Array.isArray(response)) return response
@@ -67,16 +78,17 @@ function getMediaKey(item: MediaInfo) {
/** 加载指定推荐来源,并缓存当前会话已获取的数据。 */
async function loadMedia(sourcePath = selectedSourcePath.value) {
const currentRequestId = ++requestId
const cachedItems = mediaCache.get(sourcePath)
if (cachedItems) {
mediaItems.value = cachedItems
activeIndex.value = 0
loading.value = false
loadFailed.value = false
resumeAutoplayIfReady()
return
}
const currentRequestId = ++requestId
loading.value = true
loadFailed.value = false
try {
@@ -93,7 +105,10 @@ async function loadMedia(sourcePath = selectedSourcePath.value) {
mediaItems.value = []
loadFailed.value = true
} finally {
if (currentRequestId === requestId) loading.value = false
if (currentRequestId === requestId) {
loading.value = false
resumeAutoplayIfReady()
}
}
}
@@ -171,13 +186,21 @@ function handleTouchEnd(event: TouchEvent) {
else showNext()
}
/** 仅在焦点离开整个卡片时恢复自动播放。 */
function handleFocusOut(event: FocusEvent) {
const card = event.currentTarget as HTMLElement | null
const nextTarget = event.relatedTarget as Node | null
if (card && nextTarget && card.contains(nextTarget)) return
isFocusWithin.value = false
}
/** 启动轮播自动播放,系统减少动态效果时保持静态。 */
function startAutoplay() {
stopAutoplay()
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
autoplayTimer = window.setInterval(() => {
if (!isPaused.value) showNext()
if (!isHovered.value && !isFocusWithin.value) showNext()
}, RECOMMEND_AUTOPLAY_INTERVAL)
}
@@ -188,25 +211,46 @@ function stopAutoplay() {
autoplayTimer = null
}
/** 组件可见且媒体加载完成时确保轮播计时器存在。 */
function resumeAutoplayIfReady() {
if (isComponentActive && !loading.value && autoplayTimer === null) startAutoplay()
}
/** 标记组件活跃并按当前加载状态恢复自动播放。 */
function activateAutoplay() {
isComponentActive = true
resumeAutoplayIfReady()
}
/** 停用组件时阻止异步加载续体重新创建定时器。 */
function deactivateAutoplay() {
isComponentActive = false
stopAutoplay()
}
onMounted(async () => {
activateAutoplay()
localStorage.setItem(RECOMMEND_SOURCE_STORAGE_KEY, selectedSourcePath.value)
await loadMedia()
startAutoplay()
resumeAutoplayIfReady()
})
onActivated(startAutoplay)
onDeactivated(stopAutoplay)
onBeforeUnmount(stopAutoplay)
onActivated(activateAutoplay)
onDeactivated(deactivateAutoplay)
onBeforeUnmount(() => {
requestId += 1
deactivateAutoplay()
})
</script>
<template>
<VCard
class="dashboard-recommend dashboard-grid-adaptive-size dashboard-grid-fill dashboard-grid-no-drag"
:class="{ 'is-loading': loading }"
@mouseenter="isPaused = true"
@mouseleave="isPaused = false"
@focusin="isPaused = true"
@focusout="isPaused = false"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
@focusin="isFocusWithin = true"
@focusout="handleFocusOut"
@touchstart.passive="handleTouchStart"
@touchend.passive="handleTouchEnd"
>
@@ -245,9 +289,10 @@ onBeforeUnmount(stopAutoplay)
color="white"
rounded="pill"
append-icon="mdi-chevron-down"
:aria-label="t('dashboard.selectRecommendSource')"
>
<VIcon icon="mdi-movie-open-star-outline" color="primary" start />
<span>{{ selectedSource.title }}</span>
<VIcon :icon="getSourceIcon(selectedSource)" color="primary" size="20" start />
<span class="dashboard-recommend-source-title">{{ selectedSource.title }}</span>
</VBtn>
</template>
<VList density="compact" max-height="360" :aria-label="t('dashboard.selectRecommendSource')">
@@ -255,7 +300,7 @@ onBeforeUnmount(stopAutoplay)
v-for="source in sources"
:key="source.apipath"
:active="source.apipath === selectedSourcePath"
prepend-icon="mdi-movie-open-star-outline"
:prepend-icon="getSourceIcon(source)"
:title="source.title"
@click="selectSource(source)"
/>
@@ -404,7 +449,7 @@ onBeforeUnmount(stopAutoplay)
text-transform: none;
}
.dashboard-recommend-source span {
.dashboard-recommend-source-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -520,6 +565,7 @@ onBeforeUnmount(stopAutoplay)
@media (min-width: 741px) and (hover: hover) {
.dashboard-recommend-topbar,
.dashboard-recommend-detail,
.dashboard-recommend-arrow {
opacity: 0;
pointer-events: none;
@@ -534,12 +580,15 @@ onBeforeUnmount(stopAutoplay)
transform: translateX(-4px);
}
.dashboard-recommend-detail,
.dashboard-recommend-arrow--next {
transform: translateX(4px);
}
.dashboard-recommend:hover .dashboard-recommend-topbar,
.dashboard-recommend:focus-within .dashboard-recommend-topbar,
.dashboard-recommend:hover .dashboard-recommend-detail,
.dashboard-recommend:focus-within .dashboard-recommend-detail,
.dashboard-recommend:hover .dashboard-recommend-arrow,
.dashboard-recommend:focus-within .dashboard-recommend-arrow {
opacity: 1;
@@ -558,18 +607,33 @@ onBeforeUnmount(stopAutoplay)
}
.dashboard-recommend-topbar {
justify-content: flex-end;
inset-block-start: 0.85rem;
inset-inline: 0.85rem;
}
.dashboard-recommend-label {
padding: 0.45rem 0.65rem;
display: none;
}
.dashboard-recommend-source {
max-inline-size: 50vw;
min-inline-size: 0;
padding-inline: 0.7rem;
border: 1px solid rgba(255, 255, 255, 0.12);
max-inline-size: none;
min-inline-size: 40px;
background: rgba(8, 18, 28, 0.24) !important;
backdrop-filter: blur(8px);
block-size: 40px;
inline-size: 40px;
padding: 0;
}
.dashboard-recommend-source-title,
.dashboard-recommend-source :deep(.v-btn__append) {
display: none;
}
.dashboard-recommend-source :deep(.v-icon--start) {
margin-inline-end: 0;
}
.dashboard-recommend-content {
@@ -611,24 +675,6 @@ onBeforeUnmount(stopAutoplay)
}
}
@media (max-width: 420px) {
.dashboard-recommend-label span {
display: none;
}
.dashboard-recommend-label {
block-size: 40px;
inline-size: 40px;
justify-content: center;
padding: 0;
}
.dashboard-recommend-source {
max-inline-size: 68vw;
}
}
@media (prefers-reduced-motion: reduce) {
.dashboard-recommend-topbar,
.dashboard-recommend-arrow,

View File

@@ -0,0 +1,392 @@
import MediaRecommend from '@/views/dashboard/MediaRecommend.vue'
import { getActiveRequestsCount } from '@/utils/requestOptimizer'
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createMediaInfo } from '@tests/support/factories/media'
import { recommendApiUrls, recommendMediaHandler } from '@tests/support/msw/handlers/recommend'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { http, HttpResponse } from 'msw'
import { defineComponent, ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
const DEFAULT_SOURCE = 'recommend/tmdb_trending'
const MOVIE_SOURCE = 'recommend/tmdb_movies'
const SOURCE_MENU_LABEL = '选择推荐媒体来源'
function getSourceMenuButton() {
return screen.getByRole('button', { name: SOURCE_MENU_LABEL })
}
async function renderMediaRecommend(
response: unknown,
options: { sourcePath?: string; status?: number; onRequest?: () => void } = {},
) {
const sourcePath = options.sourcePath ?? DEFAULT_SOURCE
server.use(
recommendMediaHandler(
sourcePath,
response as Record<string, unknown>,
options.status ?? 200,
options.onRequest,
),
)
return renderWithProviders(MediaRecommend, {
initialRoute: '/dashboard',
initialState: {
globalSettings: {
data: { GLOBAL_IMAGE_CACHE: false },
initialized: true,
loading: false,
},
},
})
}
describe('MediaRecommend', () => {
it.each([
['array', (media: ReturnType<typeof createMediaInfo>) => [media]],
['data array', (media: ReturnType<typeof createMediaInfo>) => ({ data: [media] })],
['data list', (media: ReturnType<typeof createMediaInfo>) => ({ data: { list: [media] } })],
])('normalizes the %s response shape', async (_shape, wrapResponse) => {
const media = createMediaInfo({ title: `响应-${_shape}` })
const requested = vi.fn()
await renderMediaRecommend(wrapResponse(media), { onRequest: requested })
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
expect(await screen.findByText(media.title || '')).toBeInTheDocument()
})
it('filters unusable media and limits the carousel to five items', async () => {
const validMedia = Array.from({ length: 6 }, (_, index) => createMediaInfo({ title: `有效媒体 ${index + 1}` }))
const response = {
data: {
list: [
createMediaInfo({ title: undefined }),
createMediaInfo({ backdrop_path: undefined, poster_path: undefined, title: '无图片' }),
createMediaInfo({ collection_id: undefined, title: '无标识', tmdb_id: undefined }),
...validMedia,
],
},
}
const requested = vi.fn()
const { container } = await renderMediaRecommend(response, { onRequest: requested })
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
await screen.findByText('有效媒体 1')
expect(container.querySelectorAll('.dashboard-recommend-slide')).toHaveLength(5)
expect(screen.getAllByRole('button', { name: /查看第 \d+ 项推荐/ })).toHaveLength(5)
expect(screen.queryByText('无图片')).not.toBeInTheDocument()
expect(screen.queryByText('无标识')).not.toBeInTheDocument()
expect(screen.queryByText('有效媒体 6')).not.toBeInTheDocument()
})
it('restores a valid source and replaces an invalid stored source', async () => {
const movieRequested = vi.fn()
localStorage.setItem('MP_DASHBOARD_RECOMMEND_SOURCE', MOVIE_SOURCE)
await renderMediaRecommend([createMediaInfo({ title: '电影来源内容' })], {
onRequest: movieRequested,
sourcePath: MOVIE_SOURCE,
})
await waitFor(() => expect(movieRequested).toHaveBeenCalledOnce())
expect(await screen.findByText('电影来源内容')).toBeInTheDocument()
expect(within(getSourceMenuButton()).getByText('TMDB热门电影')).toBeInTheDocument()
expect(localStorage.getItem('MP_DASHBOARD_RECOMMEND_SOURCE')).toBe(MOVIE_SOURCE)
})
it('falls back to the first source when persisted data is invalid', async () => {
const requested = vi.fn()
localStorage.setItem('MP_DASHBOARD_RECOMMEND_SOURCE', 'recommend/removed')
await renderMediaRecommend([createMediaInfo({ title: '默认来源内容' })], { onRequest: requested })
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
expect(await screen.findByText('默认来源内容')).toBeInTheDocument()
expect(localStorage.getItem('MP_DASHBOARD_RECOMMEND_SOURCE')).toBe(DEFAULT_SOURCE)
})
it('switches sources, persists the choice, and reuses the session cache', async () => {
const user = userEvent.setup()
const trendingRequested = vi.fn()
const moviesRequested = vi.fn()
server.use(
recommendMediaHandler(MOVIE_SOURCE, [createMediaInfo({ title: '热门电影内容' })], 200, moviesRequested),
)
await renderMediaRecommend([createMediaInfo({ title: '趋势内容' })], { onRequest: trendingRequested })
await waitFor(() => expect(trendingRequested).toHaveBeenCalledOnce())
await user.click(getSourceMenuButton())
await user.click(await screen.findByText('TMDB热门电影'))
await waitFor(() => expect(moviesRequested).toHaveBeenCalledOnce())
expect(await screen.findByText('热门电影内容')).toBeInTheDocument()
expect(localStorage.getItem('MP_DASHBOARD_RECOMMEND_SOURCE')).toBe(MOVIE_SOURCE)
await user.click(getSourceMenuButton())
await user.click(await screen.findByText('流行趋势'))
expect(await screen.findByText('趋势内容')).toBeInTheDocument()
expect(trendingRequested).toHaveBeenCalledOnce()
})
it('supports arrows, pagination, touch gestures, and detail routes', async () => {
const first = createMediaInfo({ title: '普通媒体', tmdb_id: 101, type: '电影', year: '2025' })
const second = createMediaInfo({ collection_id: 202, title: '媒体合集', tmdb_id: undefined, type: '合集' })
const third = createMediaInfo({ title: '第三项媒体', tmdb_id: 303 })
const { container, router } = await renderMediaRecommend([first, second, third])
await screen.findByText('普通媒体')
await fireEvent.click(screen.getByRole('button', { name: '下一项推荐' }))
expect(await screen.findByText('媒体合集')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '查看详情' }))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/browse/tmdb/collection/202'))
expect(router.currentRoute.value.query.title).toBe('媒体合集')
await fireEvent.click(screen.getByRole('button', { name: '查看第 3 项推荐' }))
expect(await screen.findByText('第三项媒体')).toBeInTheDocument()
const card = container.querySelector<HTMLElement>('.dashboard-recommend')
expect(card).not.toBeNull()
await fireEvent.touchStart(card as HTMLElement, { changedTouches: [{ clientX: 200 }] })
await fireEvent.touchEnd(card as HTMLElement, { changedTouches: [{ clientX: 170 }] })
expect(screen.getByText('第三项媒体')).toBeInTheDocument()
await fireEvent.touchStart(card as HTMLElement, { changedTouches: [{ clientX: 200 }] })
await fireEvent.touchEnd(card as HTMLElement, { changedTouches: [{ clientX: 280 }] })
expect(await screen.findByText('媒体合集')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '上一项推荐' }))
expect(await screen.findByText('普通媒体')).toBeInTheDocument()
await fireEvent.keyDown(screen.getByRole('link'), { key: 'Enter' })
await waitFor(() => expect(router.currentRoute.value.path).toBe('/media'))
expect(router.currentRoute.value.query).toMatchObject({
mediaid: 'tmdb:101',
title: '普通媒体',
type: '电影',
year: '2025',
})
})
it('pauses autoplay for interaction and clears the interval on unmount', async () => {
let autoplay: (() => void) | undefined
const autoplayTimer = {} as ReturnType<typeof globalThis.setInterval>
const requestOptimizerTimer = {} as ReturnType<typeof globalThis.setInterval>
const setInterval = vi
.spyOn(window, 'setInterval')
.mockImplementation((handler: TimerHandler, timeout?: number) => {
if (timeout === 8000 && typeof handler === 'function') autoplay = handler as () => void
return timeout === 8000 ? autoplayTimer : requestOptimizerTimer
})
const clearInterval = vi.spyOn(window, 'clearInterval')
const { container, unmount } = await renderMediaRecommend([
createMediaInfo({ title: '自动播放一' }),
createMediaInfo({ title: '自动播放二' }),
])
await screen.findByText('自动播放一')
expect(setInterval).toHaveBeenCalledWith(expect.any(Function), 8000)
const card = container.querySelector<HTMLElement>('.dashboard-recommend') as HTMLElement
await fireEvent.focusIn(card)
await fireEvent.mouseEnter(card)
await fireEvent.mouseLeave(card)
autoplay?.()
expect(screen.getByText('自动播放一')).toBeInTheDocument()
await fireEvent.mouseEnter(card)
await fireEvent.focusOut(card)
autoplay?.()
expect(screen.getByText('自动播放一')).toBeInTheDocument()
await fireEvent.mouseLeave(card)
autoplay?.()
expect(await screen.findByText('自动播放二')).toBeInTheDocument()
unmount()
expect(clearInterval).toHaveBeenCalledWith(autoplayTimer)
})
it('does not start autoplay when reduced motion is requested', async () => {
const reducedMotion = { ...window.matchMedia(''), matches: true }
vi.spyOn(window, 'matchMedia').mockReturnValue(reducedMotion)
const setInterval = vi.spyOn(window, 'setInterval')
await renderMediaRecommend([createMediaInfo({ title: '静态推荐' })])
await screen.findByText('静态推荐')
expect(setInterval).not.toHaveBeenCalledWith(expect.any(Function), 8000)
})
it('clears autoplay when a kept-alive instance is deactivated', async () => {
const autoplayTimer = {} as ReturnType<typeof globalThis.setInterval>
const requestOptimizerTimer = {} as ReturnType<typeof globalThis.setInterval>
vi.spyOn(window, 'setInterval').mockImplementation((_handler: TimerHandler, timeout?: number) =>
timeout === 8000 ? autoplayTimer : requestOptimizerTimer,
)
const clearInterval = vi.spyOn(window, 'clearInterval')
const KeepAliveHarness = defineComponent({
components: { MediaRecommend },
setup() {
const active = ref(true)
return { active }
},
template:
'<button type="button" @click="active = false">停用推荐</button><KeepAlive><MediaRecommend v-if="active" /></KeepAlive>',
})
server.use(recommendMediaHandler(DEFAULT_SOURCE, [createMediaInfo({ title: '可停用推荐' })]))
await renderWithProviders(KeepAliveHarness, {
initialRoute: '/dashboard',
initialState: {
globalSettings: {
data: { GLOBAL_IMAGE_CACHE: false },
initialized: true,
loading: false,
},
},
})
await screen.findByText('可停用推荐')
clearInterval.mockClear()
await fireEvent.click(screen.getByRole('button', { name: '停用推荐' }))
expect(clearInterval).toHaveBeenCalledWith(autoplayTimer)
})
it('does not restart autoplay when deactivated before the initial request settles', async () => {
let resolveRequest: ((response: Response) => void) | undefined
server.use(
http.get(recommendApiUrls.media(DEFAULT_SOURCE), () => new Promise<Response>(resolve => {
resolveRequest = resolve
})),
)
const KeepAliveHarness = defineComponent({
components: { MediaRecommend },
setup() {
const active = ref(true)
return { active }
},
template:
'<button type="button" @click="active = false">停用慢请求推荐</button><KeepAlive><MediaRecommend v-if="active" /></KeepAlive>',
})
await renderWithProviders(KeepAliveHarness, {
initialRoute: '/dashboard',
initialState: {
globalSettings: {
data: { GLOBAL_IMAGE_CACHE: false },
initialized: true,
loading: false,
},
},
})
await waitFor(() => expect(resolveRequest).toBeTypeOf('function'))
const setInterval = vi.spyOn(window, 'setInterval')
await fireEvent.click(screen.getByRole('button', { name: '停用慢请求推荐' }))
setInterval.mockClear()
resolveRequest?.(HttpResponse.json([createMediaInfo({ title: '迟到推荐' })]))
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
await new Promise(resolve => window.setTimeout(resolve, 0))
expect(setInterval).not.toHaveBeenCalledWith(expect.any(Function), 8000)
})
it('restarts autoplay when reactivated before a source request settles', async () => {
const autoplayTimer = {} as ReturnType<typeof globalThis.setInterval>
const requestOptimizerTimer = {} as ReturnType<typeof globalThis.setInterval>
const setInterval = vi
.spyOn(window, 'setInterval')
.mockImplementation((_handler: TimerHandler, timeout?: number) =>
timeout === 8000 ? autoplayTimer : requestOptimizerTimer,
)
let resolveMovies: ((response: Response) => void) | undefined
server.use(
recommendMediaHandler(DEFAULT_SOURCE, [createMediaInfo({ title: '初始推荐' })]),
http.get(recommendApiUrls.media(MOVIE_SOURCE), () => new Promise<Response>(resolve => {
resolveMovies = resolve
})),
)
const KeepAliveHarness = defineComponent({
components: { MediaRecommend },
setup() {
const active = ref(true)
return { active }
},
template:
'<button type="button" @click="active = !active">{{ active ? "停用切源推荐" : "恢复切源推荐" }}</button><KeepAlive><MediaRecommend v-if="active" /></KeepAlive>',
})
await renderWithProviders(KeepAliveHarness, {
initialRoute: '/dashboard',
initialState: {
globalSettings: {
data: { GLOBAL_IMAGE_CACHE: false },
initialized: true,
loading: false,
},
},
})
await screen.findByText('初始推荐')
const user = userEvent.setup()
await user.click(getSourceMenuButton())
await user.click(await screen.findByText('TMDB热门电影'))
await waitFor(() => expect(resolveMovies).toBeTypeOf('function'))
await fireEvent.click(screen.getByRole('button', { name: '停用切源推荐' }))
setInterval.mockClear()
await fireEvent.click(screen.getByRole('button', { name: '恢复切源推荐' }))
expect(setInterval).not.toHaveBeenCalledWith(expect.any(Function), 8000)
resolveMovies?.(HttpResponse.json([createMediaInfo({ title: '切源完成' })]))
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
await screen.findByText('切源完成')
expect(setInterval).toHaveBeenCalledWith(expect.any(Function), 8000)
})
it('shows empty data and retries a failed request', async () => {
const user = userEvent.setup()
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
await renderMediaRecommend({}, { status: 500 })
expect(await screen.findByText('推荐媒体加载失败')).toBeInTheDocument()
expect(consoleError).toHaveBeenCalled()
server.use(recommendMediaHandler(DEFAULT_SOURCE, [createMediaInfo({ title: '重试成功' })]))
await user.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByText('重试成功')).toBeInTheDocument()
})
it('renders the non-error empty state for invalid responses', async () => {
const requested = vi.fn()
await renderMediaRecommend(null, { onRequest: requested })
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
expect(await screen.findByText('当前来源暂无推荐媒体')).toBeInTheDocument()
})
it('invalidates a pending request when switching back to a cached source', async () => {
await renderMediaRecommend([createMediaInfo({ title: '初始结果' })])
expect(await screen.findByText('初始结果')).toBeInTheDocument()
let resolveMovies: ((response: Response) => void) | undefined
server.use(
http.get(recommendApiUrls.media(MOVIE_SOURCE), () => new Promise<Response>(resolve => {
resolveMovies = resolve
})),
)
const user = userEvent.setup()
await user.click(getSourceMenuButton())
const sourceList = await screen.findByRole('listbox', { name: SOURCE_MENU_LABEL })
const moviesOption = within(sourceList).getByText('TMDB热门电影')
const trendingOption = within(sourceList).getByText('流行趋势')
moviesOption.click()
trendingOption.click()
expect(await screen.findByText('初始结果')).toBeInTheDocument()
await waitFor(() => expect(resolveMovies).toBeTypeOf('function'))
resolveMovies?.(HttpResponse.json([createMediaInfo({ title: '过期结果' })]))
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
await new Promise(resolve => window.setTimeout(resolve, 0))
expect(screen.queryByText('过期结果')).not.toBeInTheDocument()
expect(screen.getByText('初始结果')).toBeInTheDocument()
})
})

View File

@@ -688,7 +688,10 @@ function normalizeMarketText(value: unknown) {
/** 将插件市场逗号分隔字段转换为去重前的文本数组。 */
function splitMarketValues(value: unknown) {
if (Array.isArray(value)) {
return value.map(normalizeMarketText).map(item => item.trim()).filter(Boolean)
return value
.map(normalizeMarketText)
.map(item => item.trim())
.filter(Boolean)
}
return normalizeMarketText(value)
@@ -933,7 +936,10 @@ watch([marketList, filterForm, activeSort, PluginStatistics], () => {
marketList.value.forEach(value => {
if (value) {
if (
filterText(filterForm.name, `${normalizeMarketText(value.plugin_name)} ${normalizeMarketText(value.plugin_desc)}`) &&
filterText(
filterForm.name,
`${normalizeMarketText(value.plugin_name)} ${normalizeMarketText(value.plugin_desc)}`,
) &&
match(filterForm.author, value.plugin_author) &&
matchMultiple(filterForm.label, value.plugin_label) &&
match(filterForm.repo, handleRepoUrl(value))
@@ -1114,9 +1120,7 @@ const canAdmin = computed(() =>
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
)
const showNewFolderAction = computed(() => activeTab.value === 'installed' && !currentFolder.value && canAdmin.value)
const showMarketSettingAction = computed(
() => activeTab.value === 'market' && canAdmin.value,
)
const showMarketSettingAction = computed(() => activeTab.value === 'market' && canAdmin.value)
const pluginDynamicMenuItems = computed(() => {
if (!appMode.value) return undefined
@@ -1654,47 +1658,57 @@ function onDragStartPlugin(evt: any) {
</VList>
<!-- 下拉多选筛选项 -->
<VDivider />
<div class="px-3 py-2 d-flex flex-column gap-2">
<VSelect
v-if="authorFilterOptions.length > 0"
v-model="filterForm.author"
:items="authorFilterOptions"
:label="t('plugin.author')"
multiple
chips
closable-chips
density="compact"
variant="outlined"
hide-details
clearable
/>
<VSelect
v-if="labelFilterOptions.length > 0"
v-model="filterForm.label"
:items="labelFilterOptions"
:label="t('plugin.label')"
multiple
chips
closable-chips
density="compact"
variant="outlined"
hide-details
clearable
/>
<VSelect
v-if="repoFilterOptions.length > 0"
v-model="filterForm.repo"
:items="repoFilterOptions"
:label="t('plugin.repository')"
multiple
chips
closable-chips
density="compact"
variant="outlined"
hide-details
clearable
/>
</div>
<VList density="compact" class="market-filter-options-list px-2 py-1">
<VListSubheader>{{ t('common.filter') }}</VListSubheader>
<VListItem>
<VSelect
v-if="authorFilterOptions.length > 0"
v-model="filterForm.author"
:items="authorFilterOptions"
:label="t('plugin.author')"
mobile-control-width="75%"
multiple
chips
closable-chips
density="compact"
variant="outlined"
hide-details
clearable
/>
</VListItem>
<VListItem>
<VSelect
v-if="labelFilterOptions.length > 0"
v-model="filterForm.label"
:items="labelFilterOptions"
:label="t('plugin.label')"
mobile-control-width="75%"
multiple
chips
closable-chips
density="compact"
variant="outlined"
hide-details
clearable
/>
</VListItem>
<VListItem>
<VSelect
v-if="repoFilterOptions.length > 0"
v-model="filterForm.repo"
:items="repoFilterOptions"
:label="t('plugin.repository')"
mobile-control-width="75%"
multiple
chips
closable-chips
density="compact"
variant="outlined"
hide-details
clearable
/>
</VListItem>
</VList>
</VCard>
</VMenu>
</Teleport>
@@ -1921,3 +1935,19 @@ function onDragStartPlugin(evt: any) {
</div>
</Teleport>
</template>
<style scoped lang="scss">
/* stylelint-disable selector-pseudo-class-no-unknown */
@media (width < 960px) {
// 弹出菜单使用紧凑录入行,避免叠加全局移动表单高度与列表项纵向留白。
.market-filter-options-list :deep(.v-list-item) {
padding-block: 0;
}
.market-filter-options-list :deep(.app-responsive-input) {
min-block-size: 3rem;
padding-block: 0.125rem;
}
}
</style>

View File

@@ -643,11 +643,10 @@ async function eventsHander(subscribe: Subscribe) {
// 调用API查询所有订阅
async function getSubscribes() {
if (!isLoaded.value && display.mdAndUp.value) openProgressDialog()
loading.value = true
try {
// 订阅
loading.value = true
const subscribes: Subscribe[] = await api.get('subscribe/')
loading.value = false
const subEvents = await Promise.allSettled(subscribes.map(async sub => eventsHander(sub)))
const succEvents = subEvents.filter(result => result.status === 'fulfilled').map(result => result.value)
rawCalendarEvents.value = normalizeCalendarEventOrder(succEvents.flat().filter(event => event.start))
@@ -656,6 +655,7 @@ async function getSubscribes() {
} catch (error) {
console.error(error)
} finally {
loading.value = false
closeProgressDialog()
}
}
@@ -903,7 +903,7 @@ onActivated(() => {
<style lang="scss">
.v-application .fc {
--fc-today-bg-color: rgba(var(--v-theme-primary), 0.06);
--fc-today-bg-color: rgba(var(--v-theme-primary), 0.12);
--fc-border-color: rgba(var(--v-border-color), var(--v-border-opacity));
--fc-neutral-bg-color: rgb(var(--v-theme-background), 0.3);
--fc-list-event-hover-bg-color: rgba(var(--v-theme-on-surface), 0.02);

View File

@@ -76,6 +76,9 @@ let isRefreshed = ref(false)
// 刷新状态
const loading = ref(false)
// 最近一次列表请求是否失败,用于保留旧数据时持续展示错误状态。
const loadError = ref(false)
// 数据列表
const dataList = ref<Subscribe[]>([])
@@ -93,7 +96,7 @@ const normalizedKeyword = computed(() => props.keyword?.trim().toLowerCase() ||
const selectedSubscribesSet = computed(() => new Set(selectedSubscribes.value))
const hasCustomOrder = computed(() => orderConfig.value.length > 0)
const isAllSubscribesSelected = computed(
() => displayList.value.length > 0 && selectedSubscribes.value.length === displayList.value.length,
() => displayList.value.length > 0 && displayList.value.every(item => selectedSubscribesSet.value.has(item.id)),
)
// 归一化订阅排序方式,电影订阅不使用缺失集数排序。
@@ -253,6 +256,8 @@ watch(
sortSubscribeList(nextDisplayList)
displayList.value = nextDisplayList
const visibleIds = new Set(nextDisplayList.map(item => item.id))
selectedSubscribes.value = selectedSubscribes.value.filter(id => visibleIds.has(id))
},
{ immediate: true },
)
@@ -290,31 +295,44 @@ async function loadSubscribeOrderConfig() {
// 保存顺序设置
async function saveSubscribeOrder() {
// 顺序配置
const confirmedOrder = orderConfig.value.map(item => ({ ...item }))
const orderObj = displayList.value.map(item => ({ id: item.id }))
orderConfig.value = orderObj
emit('update:sortBy', 'custom')
// 保存到服务端
try {
await api.post(`/user/config/${orderRequestKey.value}`, orderObj)
} catch (error) {
console.error(error)
orderConfig.value = confirmedOrder
const restoredDisplayList = [...displayList.value]
sortSubscribeList(restoredDisplayList)
displayList.value = restoredDisplayList
$toast.error(t('subscribe.requestFailed'))
}
}
// 获取订阅列表数据
async function fetchData(context: KeepAliveRefreshContext = {}) {
const showLoading = !context.silent || !isRefreshed.value
const isInitialLoad = !isRefreshed.value
try {
if (showLoading) {
loading.value = true
}
dataList.value = await api.get('subscribe/')
loadError.value = false
isRefreshed.value = true
} catch (error) {
console.error(error)
loadError.value = true
if (isInitialLoad) {
isRefreshed.value = true
}
if (!context.silent || isInitialLoad) {
$toast.error(t('subscribe.requestFailed'))
}
} finally {
if (showLoading) {
loading.value = false
@@ -443,10 +461,14 @@ async function batchEnableSubscribes() {
try {
loading.value = true
const promises = selectedSubscribes.value.map(id => api.put(`subscribe/status/${id}?state=R`))
const promises = selectedSubscribes.value.map(
id => api.put(`subscribe/status/${id}?state=R`) as unknown as Promise<{ success: boolean }>,
)
const results = await Promise.allSettled(promises)
const successCount = results.filter(result => result.status === 'fulfilled').length
const successCount = results.filter(
result => result.status === 'fulfilled' && result.value?.success === true,
).length
const failedCount = results.length - successCount
if (successCount > 0) {
@@ -482,10 +504,14 @@ async function batchPauseSubscribes() {
try {
loading.value = true
const promises = selectedSubscribes.value.map(id => api.put(`subscribe/status/${id}?state=S`))
const promises = selectedSubscribes.value.map(
id => api.put(`subscribe/status/${id}?state=S`) as unknown as Promise<{ success: boolean }>,
)
const results = await Promise.allSettled(promises)
const successCount = results.filter(result => result.status === 'fulfilled').length
const successCount = results.filter(
result => result.status === 'fulfilled' && result.value?.success === true,
).length
const failedCount = results.length - successCount
if (successCount > 0) {
@@ -554,6 +580,10 @@ defineExpose({
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<VAlert v-if="loadError" type="error" variant="tonal" class="mb-4 mx-2">
{{ t('subscribe.requestFailed') }}
</VAlert>
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4 mx-2 py-0 app-surface-static">
<div class="d-flex flex-wrap align-center justify-space-between gap-2 py-5">
<span>{{ t('common.sortModeHint') }}</span>
@@ -608,7 +638,7 @@ defineExpose({
</template>
</ProgressiveCardGrid>
<NoDataFound
v-if="displayList.length === 0 && isRefreshed"
v-if="displayList.length === 0 && isRefreshed && !loadError"
error-code="404"
:error-title="errorTitle"
:error-description="errorDescription"

View File

@@ -0,0 +1,446 @@
import type { MediaInfo, Subscribe } from '@/api/types'
import FullCalendarView from '@/views/subscribe/FullCalendarView.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { createMediaInfo, createTmdbEpisode } from '@tests/support/factories/media'
import { createSubscribe } from '@tests/support/factories/subscribe'
import { mediaDetailsHandler, tmdbSeasonEpisodesHandler } from '@tests/support/msw/handlers/media'
import { subscribeApiUrls, subscribeListHandler } from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { defineComponent, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
getEventById: vi.fn(),
openSharedDialog: vi.fn(),
setExtendedProp: vi.fn(),
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
vi.mock('@fullcalendar/vue3', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'FullCalendarTestDouble',
props: {
options: {
required: true,
type: Object,
},
},
setup(props, { expose, slots }) {
const getEventById = (id: string) => {
mocks.getEventById(id)
const options = props.options as { events?: Record<string, unknown>[] }
const event = options.events?.find(item => item.id === id)
if (!event) return undefined
return {
setExtendedProp(key: string, value: unknown) {
mocks.setExtendedProp(id, key, value)
event[key] = value
},
}
}
expose({
getApi: () => ({ getEventById }),
})
return () => {
const options = props.options as { events?: Record<string, unknown>[] }
const events = Array.isArray(options.events) ? options.events : []
return h(
'div',
{ 'data-testid': 'full-calendar' },
events.map(event =>
h(
'section',
{ 'data-calendar-event-id': String(event.id) },
slots.eventContent?.({
event: {
extendedProps: event,
id: event.id,
},
}),
),
),
)
}
},
}),
}
})
function setViewport(width: number) {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width, writable: true })
window.dispatchEvent(new Event('resize'))
}
function queryMobileCalendarEventCard(title: string) {
return (
Array.from(document.querySelectorAll<HTMLElement>('.mobile-calendar-event-card')).find(card =>
card.title.startsWith(title),
) ?? null
)
}
function movieSubscribe(id: number, name: string, overrides: Partial<Subscribe> = {}) {
return createSubscribe({ id, name, tmdbid: id, type: '电影', username: `user-${id}`, ...overrides })
}
function tvSubscribe(id: number, name: string, overrides: Partial<Subscribe> = {}) {
return createSubscribe({
id,
name,
season: 1,
tmdbid: id,
total_episode: 4,
type: '电视剧',
username: `user-${id}`,
...overrides,
})
}
async function renderCalendar(component = FullCalendarView) {
return renderWithProviders(component, { initialRoute: '/calendar' })
}
function keepAliveHarness() {
return defineComponent({
components: { FullCalendarView },
setup() {
const active = ref(true)
return { active }
},
template: `
<button type="button" @click="active = false">停用日历</button>
<button type="button" @click="active = true">启用日历</button>
<KeepAlive><FullCalendarView v-if="active" /></KeepAlive>
`,
})
}
function sequenceSubscribeList(responses: Array<{ body: Subscribe[]; status?: number }>, onRequest = vi.fn()) {
let index = 0
return http.get(subscribeApiUrls.list, () => {
onRequest()
const response = responses[Math.min(index, responses.length - 1)]
index += 1
return HttpResponse.json(response.body, { status: response.status ?? 200 })
})
}
describe('FullCalendarView', () => {
beforeEach(() => {
setViewport(1280)
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('maps movie and TV requests into ordered desktop calendar events', async () => {
const earlyMovie = movieSubscribe(3101, '较早电影')
const tv = tvSubscribe(3102, 'Zulu剧集', {
episode_group: 'group-a',
lack_episode: 2,
note: [1],
})
const sameDayMovie = movieSubscribe(3103, 'Alpha电影')
const movieRequest = vi.fn<(url: URL) => void>()
const tvRequest = vi.fn<(url: URL) => void>()
const progressClose = vi.fn()
mocks.openSharedDialog.mockReturnValue({ close: progressClose, id: 1, updateProps: vi.fn() })
server.use(
subscribeListHandler([tv, sameDayMovie, earlyMovie]),
mediaDetailsHandler(
3101,
createMediaInfo({ release_date: '2026-07-20', runtime: 121, title: earlyMovie.name, tmdb_id: 3101 }),
200,
movieRequest,
),
mediaDetailsHandler(
3103,
createMediaInfo({ release_date: '2026-07-21', runtime: 110, title: sameDayMovie.name, tmdb_id: 3103 }),
),
tmdbSeasonEpisodesHandler(
3102,
1,
[
createTmdbEpisode({ air_date: '2026-07-21', episode_number: 1, name: '第一集', runtime: 45 }),
createTmdbEpisode({ air_date: '2026-07-21', episode_number: 2, name: '第二集', runtime: 48 }),
],
200,
tvRequest,
),
)
await renderCalendar()
expect(await screen.findByText('较早电影')).toBeInTheDocument()
expect(await screen.findByText('Zulu剧集')).toBeInTheDocument()
expect(screen.getByText('Alpha电影')).toBeInTheDocument()
expect(screen.getByText('第1-2集')).toBeInTheDocument()
expect(screen.getByText('部分入库 (2/4)')).toBeInTheDocument()
expect(document.querySelector('.calendar-event-card[title*="第一集 / 第二集"]')).toBeInTheDocument()
expect(
Array.from(document.querySelectorAll('.calendar-event-title')).map(element => element.textContent?.trim()),
).toEqual(['较早电影', 'Alpha电影', 'Zulu剧集'])
expect(movieRequest).toHaveBeenCalledOnce()
expect(movieRequest.mock.calls[0][0].searchParams.get('type_name')).toBe('电影')
expect(tvRequest).toHaveBeenCalledOnce()
expect(tvRequest.mock.calls[0][0].searchParams.get('episode_group')).toBe('group-a')
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
expect(progressClose).toHaveBeenCalledOnce()
})
it('distinguishes none, partial, complete, and best-version library states', async () => {
const subscriptions = [
tvSubscribe(3201, '未入库', { lack_episode: 4, note: [] }),
tvSubscribe(3202, '部分入库', { lack_episode: 2, note: [1] }),
tvSubscribe(3203, '全部入库', { lack_episode: 0, note: [1, 2] }),
tvSubscribe(3204, '洗版部分入库', {
best_version: '1',
episode_priority: { '1': 100, '2': 50 },
lack_episode: 0,
}),
]
const episodes = [
createTmdbEpisode({ air_date: '2026-07-22', episode_number: 1 }),
createTmdbEpisode({ air_date: '2026-07-22', episode_number: 2 }),
]
server.use(
subscribeListHandler(subscriptions),
...subscriptions.map(subscribe => tmdbSeasonEpisodesHandler(subscribe.tmdbid as number, 1, episodes)),
)
await renderCalendar()
const noneCard = (await screen.findByText('未入库')).closest('.calendar-event-card')
const partialCard = (await screen.findByText('部分入库')).closest('.calendar-event-card')
const completeCard = (await screen.findByText('全部入库')).closest('.calendar-event-card')
const washCard = (await screen.findByText('洗版部分入库')).closest('.calendar-event-card')
expect(noneCard).toHaveClass('calendar-event-card--none')
expect(partialCard).toHaveClass('calendar-event-card--partial')
expect(completeCard).toHaveClass('calendar-event-card--complete')
expect(washCard).toHaveClass('calendar-event-card--partial')
})
it('keeps successful events when another detail request fails and drops invalid dates', async () => {
const valid = movieSubscribe(3301, '有效电影')
const failed = movieSubscribe(3302, '失败电影')
const undated = movieSubscribe(3303, '无日期电影')
const undatedTv = tvSubscribe(3304, '无日期剧集')
server.use(
subscribeListHandler([failed, undated, undatedTv, valid]),
mediaDetailsHandler(3301, createMediaInfo({ release_date: '2026-07-23', tmdb_id: 3301 })),
mediaDetailsHandler(3302, createMediaInfo({ tmdb_id: 3302 }), 500),
mediaDetailsHandler(3303, createMediaInfo({ release_date: '', tmdb_id: 3303 })),
tmdbSeasonEpisodesHandler(3304, 1, [
createTmdbEpisode({ air_date: undefined, episode_number: undefined, name: undefined, runtime: undefined }),
]),
)
await renderCalendar()
expect(await screen.findByText('有效电影')).toBeInTheDocument()
expect(screen.queryByText('失败电影')).not.toBeInTheDocument()
expect(screen.queryByText('无日期电影')).not.toBeInTheDocument()
expect(screen.queryByText('无日期剧集')).not.toBeInTheDocument()
})
it('updates only the expanded day through the FullCalendar API and restores scroll', async () => {
const sameDaySubscriptions = Array.from({ length: 6 }, (_, index) =>
tvSubscribe(3400 + index, `同日项目 ${index + 1}`),
)
const nextDaySubscription = tvSubscribe(3499, '次日项目')
const subscriptions = [...sameDaySubscriptions, nextDaySubscription]
const sameDayEpisode = createTmdbEpisode({ air_date: '2026-08-01', episode_number: 1 })
server.use(
subscribeListHandler(subscriptions),
...sameDaySubscriptions.map(subscribe =>
tmdbSeasonEpisodesHandler(subscribe.tmdbid as number, 1, [sameDayEpisode]),
),
tmdbSeasonEpisodesHandler(3499, 1, [
createTmdbEpisode({ air_date: '2026-08-02', episode_number: 1 }),
]),
)
Object.defineProperty(window, 'scrollY', { configurable: true, value: 240 })
Object.defineProperty(window, 'scrollX', { configurable: true, value: 16 })
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
callback(0)
return 1
})
await renderCalendar()
expect(await screen.findByText('同日项目 1')).toBeInTheDocument()
expect(screen.getByText('次日项目')).toBeInTheDocument()
expect(screen.queryByText('同日项目 6')).not.toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '展开当天剩余 1 个条目' }))
const eventId = 'calendar-day-group-2026-08-01'
expect(mocks.getEventById).toHaveBeenCalledWith(eventId)
expect(mocks.setExtendedProp).toHaveBeenNthCalledWith(
1,
eventId,
'visibleEvents',
expect.arrayContaining([expect.objectContaining({ title: '同日项目 6' })]),
)
expect(mocks.setExtendedProp).toHaveBeenNthCalledWith(2, eventId, 'hiddenEventCount', 0)
expect(mocks.setExtendedProp).toHaveBeenCalledTimes(2)
expect(scrollTo).toHaveBeenCalledWith({ left: 16, top: 240 })
})
it('renders mobile date boundaries and filters without restoring events older than 30 days', async () => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-07-17T12:00:00+08:00'))
setViewport(480)
const today = movieSubscribe(3501, '今日电影', { year: '' })
const future = movieSubscribe(3502, '未来电影')
const recent = movieSubscribe(3503, '近期过期电影')
const old = movieSubscribe(3504, '过久电影')
const boundary = movieSubscribe(3505, '边界电影')
const details: Array<[Subscribe, MediaInfo]> = [
[today, createMediaInfo({ release_date: '2026-07-17', tmdb_id: 3501 })],
[future, createMediaInfo({ release_date: '2026-07-18', tmdb_id: 3502 })],
[recent, createMediaInfo({ release_date: '2026-07-12', tmdb_id: 3503 })],
[old, createMediaInfo({ release_date: '2026-06-16', tmdb_id: 3504 })],
[boundary, createMediaInfo({ release_date: '2026-06-17', tmdb_id: 3505 })],
]
server.use(
subscribeListHandler(details.map(([subscribe]) => subscribe)),
...details.map(([subscribe, media]) => mediaDetailsHandler(subscribe.tmdbid as number, media)),
)
await renderCalendar()
await waitFor(() => expect(queryMobileCalendarEventCard('今日电影')).toBeInTheDocument())
expect(
queryMobileCalendarEventCard('今日电影')?.querySelector('.mobile-calendar-event-content > p'),
).toHaveTextContent('电影')
expect(queryMobileCalendarEventCard('未来电影')).toBeInTheDocument()
expect(queryMobileCalendarEventCard('近期过期电影')).not.toBeInTheDocument()
expect(queryMobileCalendarEventCard('边界电影')).not.toBeInTheDocument()
expect(queryMobileCalendarEventCard('过久电影')).not.toBeInTheDocument()
expect(screen.getByText('5 项')).toBeInTheDocument()
expect(screen.getByText('即将播出')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '隐藏过期' }))
await waitFor(() => expect(queryMobileCalendarEventCard('近期过期电影')).toBeInTheDocument())
expect(queryMobileCalendarEventCard('边界电影')).toBeInTheDocument()
expect(queryMobileCalendarEventCard('过久电影')).not.toBeInTheDocument()
expect(screen.getAllByText('已播出')).toHaveLength(2)
await fireEvent.click(screen.getByRole('option', { name: '未来电影' }))
expect(queryMobileCalendarEventCard('未来电影')).toBeInTheDocument()
expect(queryMobileCalendarEventCard('今日电影')).not.toBeInTheDocument()
expect(queryMobileCalendarEventCard('近期过期电影')).not.toBeInTheDocument()
expect(queryMobileCalendarEventCard('边界电影')).not.toBeInTheDocument()
})
it('renders cross-year TV metadata and all mobile library states', async () => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-12-31T12:00:00+08:00'))
setViewport(480)
const aggregate = tvSubscribe(3551, '年度剧集', {
lack_episode: 3,
note: [],
total_episode: 4,
})
const partial = tvSubscribe(3552, '部分剧集', {
lack_episode: 2,
note: [1],
total_episode: 4,
})
server.use(
subscribeListHandler([aggregate, partial]),
tmdbSeasonEpisodesHandler(3551, 1, [
createTmdbEpisode({ air_date: '2027-01-01', episode_number: 1, name: '跨年首集', runtime: 50 }),
createTmdbEpisode({ air_date: '2027-01-02', episode_number: 2, name: undefined, runtime: undefined }),
]),
tmdbSeasonEpisodesHandler(3552, 1, [
createTmdbEpisode({ air_date: '2027-01-03', episode_number: 1, name: '第一集', runtime: undefined }),
createTmdbEpisode({ air_date: '2027-01-03', episode_number: 2, name: '第二集' }),
]),
)
await renderCalendar()
const completeCard = (await screen.findByRole('heading', { name: '跨年首集' })).closest(
'.mobile-calendar-event-card',
)
const noneCard = screen.getByRole('heading', { name: '第 2 集' }).closest('.mobile-calendar-event-card')
const partialCard = screen
.getByRole('heading', { name: '第一集 / 第二集' })
.closest('.mobile-calendar-event-card')
expect(completeCard).toHaveClass('mobile-calendar-event-card--complete')
expect(noneCard).toHaveClass('mobile-calendar-event-card--none')
expect(partialCard).toHaveClass('mobile-calendar-event-card--partial')
expect(screen.getByText('2027/01/01')).toBeInTheDocument()
expect(screen.getByText('50 分钟')).toBeInTheDocument()
expect(screen.getByText('45 分钟')).toBeInTheDocument()
expect(screen.getAllByText('S01E01').length).toBeGreaterThan(0)
expect(screen.getByRole('option', { name: '年度剧集' })).toBeInTheDocument()
})
it('resets a stale mobile title filter after keep-alive refresh replaces the data', async () => {
setViewport(480)
const first = movieSubscribe(3601, '第一轮电影')
const second = movieSubscribe(3602, '第二轮电影')
server.use(
sequenceSubscribeList([{ body: [first] }, { body: [second] }]),
mediaDetailsHandler(3601, createMediaInfo({ release_date: '2026-08-10', tmdb_id: 3601 })),
mediaDetailsHandler(3602, createMediaInfo({ release_date: '2026-08-11', tmdb_id: 3602 })),
)
await renderCalendar(keepAliveHarness())
await waitFor(() => expect(queryMobileCalendarEventCard('第一轮电影')).toBeInTheDocument())
await fireEvent.click(screen.getByRole('option', { name: '第一轮电影' }))
await fireEvent.click(screen.getByRole('button', { name: '停用日历' }))
await fireEvent.click(screen.getByRole('button', { name: '启用日历' }))
await waitFor(() => expect(queryMobileCalendarEventCard('第二轮电影')).toBeInTheDocument())
expect(screen.getByRole('option', { name: '全部' })).toHaveAttribute('aria-selected', 'true')
})
it('recovers from a failed list request when the kept-alive view is activated again', async () => {
setViewport(480)
const recovered = movieSubscribe(3701, '恢复后的电影')
const onListRequest = vi.fn()
server.use(
sequenceSubscribeList(
[
{ body: [], status: 500 },
{ body: [recovered] },
],
onListRequest,
),
mediaDetailsHandler(3701, createMediaInfo({ release_date: '2026-08-12', tmdb_id: 3701 })),
)
await renderCalendar(keepAliveHarness())
await waitFor(() => expect(onListRequest).toHaveBeenCalledOnce())
await fireEvent.click(screen.getByRole('button', { name: '停用日历' }))
await fireEvent.click(screen.getByRole('button', { name: '启用日历' }))
await waitFor(() => expect(queryMobileCalendarEventCard('恢复后的电影')).toBeInTheDocument())
expect(onListRequest).toHaveBeenCalledTimes(2)
})
it('shows the mobile empty state for an empty subscription list', async () => {
setViewport(480)
server.use(subscribeListHandler([]))
await renderCalendar()
expect(await screen.findByText('暂无符合筛选条件的日历内容')).toBeInTheDocument()
expect(screen.queryByText('加载中 ...')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,657 @@
import type { Subscribe } from '@/api/types'
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { createSubscribe } from '@tests/support/factories/subscribe'
import {
deleteSubscribeByIdHandler,
saveSubscribeOrderConfigHandler,
subscribeApiUrls,
subscribeListHandler,
subscribeOrderConfigHandler,
updateSubscribeStatusHandler,
type SubscribeMediaType,
} from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent, h, nextTick, ref, watch, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
confirm: vi.fn(),
openSharedDialog: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
toastWarning: vi.fn(),
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({
error: mocks.toastError,
success: mocks.toastSuccess,
warning: mocks.toastWarning,
}),
}))
vi.mock('@/composables/useConfirm', () => ({
useConfirm: () => mocks.confirm,
}))
vi.mock('@/composables/useSharedDialog', () => ({
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
}))
const SubscribeCardStub = defineComponent({
name: 'SubscribeCard',
props: {
batchMode: Boolean,
media: { type: Object as PropType<Subscribe>, required: true },
selected: Boolean,
sortable: Boolean,
},
emits: ['remove', 'save', 'select'],
setup(props, { emit }) {
return () =>
h(
'article',
{
'data-batch': String(props.batchMode),
'data-page-open': String(Boolean(props.media.page_open)),
'data-selected': String(props.selected),
'data-sortable': String(props.sortable),
'data-testid': `subscribe-card-${props.media.id}`,
},
[
h('span', props.media.name),
h('button', { 'aria-label': `select-${props.media.id}`, onClick: () => emit('select'), type: 'button' }, 'select'),
h('button', { 'aria-label': `save-${props.media.id}`, onClick: () => emit('save'), type: 'button' }, 'save'),
h('button', { 'aria-label': `remove-${props.media.id}`, onClick: () => emit('remove'), type: 'button' }, 'remove'),
],
)
},
})
const ProgressiveCardGridStub = defineComponent({
name: 'ProgressiveCardGrid',
props: {
items: { type: Array as PropType<Subscribe[]>, required: true },
scrollToIndex: Number,
},
setup(props, { slots }) {
return () =>
h(
'section',
{
'data-scroll-to-index': props.scrollToIndex ?? '',
'data-testid': 'progressive-grid',
},
props.items.flatMap(item => slots.default?.({ item }) ?? []),
)
},
})
const DraggableStub = defineComponent({
name: 'Draggable',
props: {
modelValue: { type: Array as PropType<Subscribe[]>, required: true },
},
emits: ['end', 'update:modelValue'],
setup(props, { emit, slots }) {
async function reverseOrder() {
emit('update:modelValue', [...props.modelValue].reverse())
await nextTick()
emit('end')
}
return () =>
h('section', { 'data-testid': 'draggable-list' }, [
...props.modelValue.flatMap(element => slots.item?.({ element }) ?? []),
h('button', { onClick: reverseOrder, type: 'button' }, 'reverse-order'),
])
},
})
const LoadingBannerStub = defineComponent({
name: 'LoadingBanner',
template: '<div role="status" data-testid="loading-banner">loading</div>',
})
const NoDataFoundStub = defineComponent({
name: 'NoDataFound',
props: {
errorDescription: String,
errorTitle: String,
},
template: '<section data-testid="no-data">{{ errorTitle }} {{ errorDescription }}</section>',
})
interface BatchState {
allSelected: boolean
enabled: boolean
selectedCount: number
totalCount: number
}
interface ListActions {
batchDeleteSubscribes: () => Promise<void>
batchEnableSubscribes: () => Promise<void>
batchPauseSubscribes: () => Promise<void>
enterBatchMode: () => void
exitBatchMode: () => void
openHistoryDialog: () => void
toggleBatchMode: () => void
toggleSelectAll: () => void
}
const SubscribeListHost = defineComponent({
name: 'SubscribeListHost',
components: { SubscribeListView },
props: {
active: { type: Boolean, default: true },
keyword: { type: String, default: '' },
sortBy: { type: String, default: '' },
sortMode: { type: Boolean, default: false },
statusFilter: { type: String, default: 'all' },
subid: { type: String, default: '' },
type: { type: String as PropType<SubscribeMediaType>, default: '电影' },
},
setup(props) {
const list = ref<ListActions | null>(null)
const currentSortBy = ref(props.sortBy)
const currentSortMode = ref(props.sortMode)
const batchState = ref<BatchState>({ allSelected: false, enabled: false, selectedCount: 0, totalCount: 0 })
watch(
() => props.sortBy,
value => {
currentSortBy.value = value
},
)
watch(
() => props.sortMode,
value => {
currentSortMode.value = value
},
)
function call(action: keyof ListActions) {
return list.value?.[action]()
}
return { batchState, call, currentSortBy, currentSortMode, list }
},
template: `
<SubscribeListView
ref="list"
:active="active"
:keyword="keyword"
:sort-by="currentSortBy"
:sort-mode="currentSortMode"
:status-filter="statusFilter"
:subid="subid"
:type="type"
@batch-state-change="batchState = $event"
@update:sort-by="currentSortBy = $event"
@update:sort-mode="currentSortMode = $event"
/>
<button type="button" @click="call('enterBatchMode')">host-enter-batch</button>
<button type="button" @click="call('exitBatchMode')">host-exit-batch</button>
<button type="button" @click="call('toggleBatchMode')">host-toggle-batch</button>
<button type="button" @click="call('toggleSelectAll')">host-toggle-select-all</button>
<button type="button" @click="call('batchEnableSubscribes')">host-batch-enable</button>
<button type="button" @click="call('batchPauseSubscribes')">host-batch-pause</button>
<button type="button" @click="call('batchDeleteSubscribes')">host-batch-delete</button>
<button type="button" @click="call('openHistoryDialog')">host-open-history</button>
<output data-testid="batch-state">{{ JSON.stringify(batchState) }}</output>
<output data-testid="sort-by-state">{{ currentSortBy }}</output>
<output data-testid="sort-mode-state">{{ String(currentSortMode) }}</output>
`,
})
interface RenderListOptions {
active?: boolean
keyword?: string
listResponse?: Subscribe[]
listStatus?: number
onListRequest?: (url: URL) => void
onOrderRequest?: (url: URL) => void
orderStatus?: number
orderValue?: Parameters<typeof subscribeOrderConfigHandler>[1]
sortBy?: string
sortMode?: boolean
statusFilter?: string
subid?: string
superUser?: boolean
type?: SubscribeMediaType
userName?: string
}
async function renderList(options: RenderListOptions = {}) {
const type = options.type ?? '电影'
server.use(
subscribeOrderConfigHandler(
type,
options.orderValue,
options.orderStatus ?? 200,
options.onOrderRequest,
),
subscribeListHandler(options.listResponse ?? [], options.listStatus ?? 200, options.onListRequest),
)
return renderWithProviders(SubscribeListHost, {
props: {
active: options.active ?? true,
keyword: options.keyword ?? '',
sortBy: options.sortBy ?? '',
sortMode: options.sortMode ?? false,
statusFilter: options.statusFilter ?? 'all',
subid: options.subid ?? '',
type,
},
initialState: {
user: {
superUser: options.superUser ?? false,
userName: options.userName ?? 'tester',
},
},
global: {
stubs: {
Draggable: DraggableStub,
LoadingBanner: LoadingBannerStub,
NoDataFound: NoDataFoundStub,
ProgressiveCardGrid: ProgressiveCardGridStub,
SubscribeCard: SubscribeCardStub,
draggable: DraggableStub,
},
},
})
}
function movie(id: number, name: string, overrides: Partial<Subscribe> = {}) {
return createSubscribe({ id, name, type: '电影', username: 'tester', ...overrides })
}
function tv(id: number, name: string, overrides: Partial<Subscribe> = {}) {
return createSubscribe({ id, name, type: '电视剧', username: 'tester', ...overrides })
}
function card(id: number) {
return screen.getByTestId(`subscribe-card-${id}`)
}
function displayedNames() {
return screen.queryAllByTestId(/^subscribe-card-/).map(element => element.querySelector('span')?.textContent)
}
function batchState(): BatchState {
return JSON.parse(screen.getByTestId('batch-state').textContent || '{}') as BatchState
}
beforeEach(() => {
Object.values(mocks).forEach(mock => mock.mockReset())
mocks.confirm.mockResolvedValue(true)
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
})
describe('SubscribeListView loading and filtering', () => {
it('loads exact endpoints and restricts a normal user by owner and media type', async () => {
const listRequested = vi.fn()
const orderRequested = vi.fn()
await renderList({
listResponse: [movie(1, 'Own movie'), movie(2, 'Other movie', { username: 'other' }), tv(3, 'Own TV')],
onListRequest: listRequested,
onOrderRequest: orderRequested,
})
expect(await screen.findByText('Own movie')).toBeInTheDocument()
expect(screen.queryByText('Other movie')).not.toBeInTheDocument()
expect(screen.queryByText('Own TV')).not.toBeInTheDocument()
expect(orderRequested.mock.calls[0][0].href).toBe(subscribeApiUrls.orderConfig('电影'))
expect(listRequested.mock.calls[0][0].href).toBe(subscribeApiUrls.list)
expect(screen.getByTestId('sort-by-state')).toHaveTextContent('date')
})
it('lets a superuser see subscriptions from every owner while retaining type defense', async () => {
await renderList({
listResponse: [movie(1, 'Own movie'), movie(2, 'Other movie', { username: 'other' }), tv(3, 'Other TV')],
superUser: true,
})
expect(await screen.findByText('Own movie')).toBeInTheDocument()
expect(screen.getByText('Other movie')).toBeInTheDocument()
expect(screen.queryByText('Other TV')).not.toBeInTheDocument()
})
it('normalizes keyword filtering', async () => {
await renderList({ keyword: ' ALPHA ', listResponse: [movie(1, 'Alpha One'), movie(2, 'Beta Two')] })
expect(await screen.findByText('Alpha One')).toBeInTheDocument()
expect(screen.queryByText('Beta Two')).not.toBeInTheDocument()
})
it.each([
['best_version', 'Best'],
['pending', 'Pending'],
['paused', 'Paused'],
['completed', 'Completed'],
['subscribing', 'Subscribing'],
['not_started', 'Not started'],
])('derives the %s status defensively', async (statusFilter, expectedName) => {
const subscriptions = [
tv(11, 'Best', { best_version: 1 }),
tv(12, 'Pending', { state: 'P' }),
tv(13, 'Paused', { state: 'S' }),
tv(14, 'Completed', { completed_episode: 10, lack_episode: 0, total_episode: 10 }),
tv(15, 'Subscribing', { completed_episode: 6, lack_episode: 4, total_episode: 10 }),
tv(16, 'Not started', { completed_episode: 0, lack_episode: 10, total_episode: 10 }),
]
await renderList({ listResponse: subscriptions, statusFilter, type: '电视剧' })
expect(await screen.findByText(expectedName)).toBeInTheDocument()
expect(displayedNames()).toEqual([expectedName])
})
it('shows the empty state after a successful empty list response', async () => {
await renderList({ listResponse: [] })
expect(await screen.findByTestId('no-data')).toBeInTheDocument()
expect(screen.queryByTestId('loading-banner')).not.toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('finishes the initial loading state and shows a visible error when the list request fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const listRequested = vi.fn()
await renderList({ listResponse: [], listStatus: 500, onListRequest: listRequested })
await waitFor(() => expect(listRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(screen.queryByTestId('loading-banner')).not.toBeInTheDocument())
expect(screen.getByRole('alert')).toHaveTextContent('请求失败,请稍后重试')
expect(screen.queryByTestId('no-data')).not.toBeInTheDocument()
expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试')
})
it('keeps old data through a silent refresh failure and clears the error after recovery', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const failedRequest = vi.fn()
const recoveredRequest = vi.fn()
const { rerender } = await renderList({ listResponse: [movie(1, 'Cached movie')] })
await screen.findByText('Cached movie')
await rerender({ active: false })
server.use(subscribeListHandler([], 500, failedRequest))
await rerender({ active: true })
await waitFor(() => expect(failedRequest).toHaveBeenCalledOnce())
expect(screen.getByText('Cached movie')).toBeInTheDocument()
expect(screen.getByRole('alert')).toHaveTextContent('请求失败,请稍后重试')
expect(screen.queryByTestId('loading-banner')).not.toBeInTheDocument()
await rerender({ active: false })
server.use(subscribeListHandler([movie(2, 'Recovered movie')], 200, recoveredRequest))
await rerender({ active: true })
await waitFor(() => expect(recoveredRequest).toHaveBeenCalledOnce())
expect(await screen.findByText('Recovered movie')).toBeInTheDocument()
expect(screen.queryByText('Cached movie')).not.toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
})
describe('SubscribeListView sorting and refresh boundaries', () => {
it('applies custom order first and appends unconfigured subscriptions by date', async () => {
await renderList({
listResponse: [
movie(1, 'Old unconfigured', { date: '2024-01-01' }),
movie(2, 'Configured', { date: '2023-01-01' }),
movie(3, 'New unconfigured', { date: '2025-01-01' }),
],
orderValue: [{ id: 2 }],
sortBy: 'custom',
})
await screen.findByText('Configured')
expect(displayedNames()).toEqual(['Configured', 'New unconfigured', 'Old unconfigured'])
})
it.each([
[
'date',
[movie(1, 'Invalid date', { date: 'not-a-date' }), movie(2, 'Newest', { date: '2025-02-01' })],
['Newest', 'Invalid date'],
],
[
'last_update',
[movie(1, 'Invalid update', { last_update: 'bad' }), movie(2, 'Latest update', { last_update: '2025-02-01' })],
['Latest update', 'Invalid update'],
],
[
'lack_episode',
[
tv(1, 'Few missing', { date: '2025-03-01', lack_episode: 1 }),
tv(2, 'Many missing', { date: '2024-01-01', lack_episode: 8 }),
tv(3, 'Few newer', { date: '2025-04-01', lack_episode: 1 }),
],
['Many missing', 'Few newer', 'Few missing'],
],
])('sorts by %s and treats invalid dates as zero', async (sortBy, subscriptions, expected) => {
await renderList({
listResponse: subscriptions,
sortBy,
type: sortBy === 'lack_episode' ? '电视剧' : '电影',
})
await screen.findByText(expected[0])
expect(displayedNames()).toEqual(expected)
})
it('marks and scrolls to the initial subscription id', async () => {
await renderList({ listResponse: [movie(1, 'First'), movie(2, 'Target')], subid: '2' })
await screen.findByText('Target')
expect(card(2)).toHaveAttribute('data-page-open', 'true')
expect(screen.getByTestId('progressive-grid')).toHaveAttribute('data-scroll-to-index', '1')
})
it('refreshes from card save/remove and the history save boundary', async () => {
const listRequested = vi.fn()
await renderList({ listResponse: [movie(1, 'Refresh target')], onListRequest: listRequested })
await screen.findByText('Refresh target')
expect(listRequested).toHaveBeenCalledTimes(1)
await fireEvent.click(screen.getByRole('button', { name: 'save-1' }))
await waitFor(() => expect(listRequested).toHaveBeenCalledTimes(2))
await fireEvent.click(screen.getByRole('button', { name: 'remove-1' }))
await waitFor(() => expect(listRequested).toHaveBeenCalledTimes(3))
await fireEvent.click(screen.getByRole('button', { name: 'host-open-history' }))
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
expect(mocks.openSharedDialog.mock.calls[0][1]).toEqual({ type: '电影' })
const events = mocks.openSharedDialog.mock.calls[0][2] as { save: () => void }
events.save()
await waitFor(() => expect(listRequested).toHaveBeenCalledTimes(4))
})
it('commits a custom order only after a successful response', async () => {
const saved = vi.fn()
server.use(saveSubscribeOrderConfigHandler('电影', { success: true }, 200, saved))
await renderList({
listResponse: [movie(1, 'First'), movie(2, 'Second')],
orderValue: [{ id: 1 }, { id: 2 }],
sortBy: 'custom',
sortMode: true,
})
await screen.findByText('First')
await fireEvent.click(screen.getByRole('button', { name: 'reverse-order' }))
await waitFor(() => expect(saved).toHaveBeenCalledOnce())
expect(saved.mock.calls[0][0]).toEqual([{ id: 2 }, { id: 1 }])
expect(saved.mock.calls[0][1].href).toBe(subscribeApiUrls.orderConfig('电影'))
expect(displayedNames()).toEqual(['Second', 'First'])
expect(screen.getByTestId('sort-mode-state')).toHaveTextContent('true')
})
it('rolls back the confirmed order and remains sortable after a request failure', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
server.use(saveSubscribeOrderConfigHandler('电影', { message: 'server error', success: false }, 500))
await renderList({
listResponse: [movie(1, 'First'), movie(2, 'Second')],
orderValue: [{ id: 1 }, { id: 2 }],
sortBy: 'custom',
sortMode: true,
})
await screen.findByText('First')
await fireEvent.click(screen.getByRole('button', { name: 'reverse-order' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('请求失败,请稍后重试'))
expect(displayedNames()).toEqual(['First', 'Second'])
expect(screen.getByTestId('sort-mode-state')).toHaveTextContent('true')
})
})
describe('SubscribeListView batch operations', () => {
it('exits drag sorting when batch mode makes the list unsortable', async () => {
await renderList({
listResponse: [movie(1, 'Alpha'), movie(2, 'Beta')],
orderValue: [{ id: 1 }, { id: 2 }],
sortBy: 'custom',
sortMode: true,
})
await screen.findByText('Alpha')
expect(screen.getByTestId('sort-mode-state')).toHaveTextContent('true')
await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' }))
await waitFor(() => expect(screen.getByTestId('sort-mode-state')).toHaveTextContent('false'))
expect(batchState()).toMatchObject({ enabled: true, selectedCount: 0, totalCount: 2 })
})
it('intersects selection with the visible list and never treats equal lengths as equal ids', async () => {
const statusRequested = vi.fn()
server.use(updateSubscribeStatusHandler(1, { success: true }, 200, statusRequested))
const { rerender } = await renderList({ listResponse: [movie(1, 'Alpha'), movie(2, 'Beta')] })
await screen.findByText('Alpha')
await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' }))
await fireEvent.click(screen.getByRole('button', { name: 'select-1' }))
expect(batchState()).toMatchObject({ allSelected: false, enabled: true, selectedCount: 1, totalCount: 2 })
await rerender({ keyword: 'Beta' })
await waitFor(() => expect(displayedNames()).toEqual(['Beta']))
expect(card(2)).toHaveAttribute('data-selected', 'false')
expect(batchState()).toMatchObject({ allSelected: false, enabled: true, selectedCount: 0, totalCount: 1 })
await fireEvent.click(screen.getByRole('button', { name: 'host-batch-enable' }))
expect(mocks.toastWarning).toHaveBeenCalledWith('请先选择要操作的订阅')
expect(statusRequested).not.toHaveBeenCalled()
})
it('selects and deselects the exact visible id set', async () => {
await renderList({ listResponse: [movie(1, 'Alpha'), movie(2, 'Beta')] })
await screen.findByText('Alpha')
await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' }))
await fireEvent.click(screen.getByRole('button', { name: 'host-toggle-select-all' }))
expect(card(1)).toHaveAttribute('data-selected', 'true')
expect(card(2)).toHaveAttribute('data-selected', 'true')
expect(batchState()).toMatchObject({ allSelected: true, selectedCount: 2, totalCount: 2 })
await fireEvent.click(screen.getByRole('button', { name: 'host-toggle-select-all' }))
expect(card(1)).toHaveAttribute('data-selected', 'false')
expect(card(2)).toHaveAttribute('data-selected', 'false')
expect(batchState()).toMatchObject({ allSelected: false, selectedCount: 0, totalCount: 2 })
})
it('does not request a mutation without selection or after confirmation is cancelled', async () => {
const requested = vi.fn()
server.use(updateSubscribeStatusHandler(1, { success: true }, 200, requested))
await renderList({ listResponse: [movie(1, 'Alpha')] })
await screen.findByText('Alpha')
await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' }))
await fireEvent.click(screen.getByRole('button', { name: 'host-batch-enable' }))
expect(mocks.toastWarning).toHaveBeenCalledWith('请先选择要操作的订阅')
expect(requested).not.toHaveBeenCalled()
mocks.confirm.mockResolvedValueOnce(false)
await fireEvent.click(screen.getByRole('button', { name: 'select-1' }))
await fireEvent.click(screen.getByRole('button', { name: 'host-batch-enable' }))
expect(requested).not.toHaveBeenCalled()
expect(card(1)).toHaveAttribute('data-selected', 'true')
})
it.each([
['enable', 'host-batch-enable', 'R'],
['pause', 'host-batch-pause', 'S'],
])('completes a successful batch %s and sends the expected state query', async (_case, buttonName, state) => {
const requested = vi.fn()
server.use(updateSubscribeStatusHandler(1, { success: true }, 200, requested))
await renderList({ listResponse: [movie(1, 'Alpha')] })
await screen.findByText('Alpha')
await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' }))
await fireEvent.click(screen.getByRole('button', { name: 'select-1' }))
await fireEvent.click(screen.getByRole('button', { name: buttonName }))
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
expect(requested.mock.calls[0][0].pathname).toBe(new URL(subscribeApiUrls.statusById(1)).pathname)
expect(requested.mock.calls[0][0].searchParams.get('state')).toBe(state)
await waitFor(() => expect(batchState()).toMatchObject({ enabled: false, selectedCount: 0 }))
})
it('completes a successful batch delete through the id endpoint', async () => {
const deleted = vi.fn()
server.use(deleteSubscribeByIdHandler(1, { success: true }, 200, deleted))
await renderList({ listResponse: [movie(1, 'Alpha')] })
await screen.findByText('Alpha')
await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' }))
await fireEvent.click(screen.getByRole('button', { name: 'select-1' }))
await fireEvent.click(screen.getByRole('button', { name: 'host-batch-delete' }))
await waitFor(() => expect(deleted).toHaveBeenCalledOnce())
await waitFor(() => expect(batchState()).toMatchObject({ enabled: false, selectedCount: 0 }))
})
it.each([
['enable', 'host-batch-enable', '启用'],
['pause', 'host-batch-pause', '暂停'],
])('classifies a %s success false response as a failure', async (_case, buttonName, actionName) => {
const requested = vi.fn()
server.use(updateSubscribeStatusHandler(1, { message: 'rejected', success: false }, 200, requested))
await renderList({ listResponse: [movie(1, 'Alpha')] })
await screen.findByText('Alpha')
await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' }))
await fireEvent.click(screen.getByRole('button', { name: 'select-1' }))
await fireEvent.click(screen.getByRole('button', { name: buttonName }))
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(`${actionName}失败 1 个订阅`))
expect(mocks.toastSuccess).not.toHaveBeenCalled()
await waitFor(() => expect(batchState()).toMatchObject({ enabled: false, selectedCount: 0 }))
})
it('reports mixed status results without changing the existing completion flow', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const firstRequested = vi.fn()
const secondRequested = vi.fn()
server.use(
updateSubscribeStatusHandler(1, { success: true }, 200, firstRequested),
updateSubscribeStatusHandler(2, { message: 'failed', success: false }, 500, secondRequested),
)
await renderList({ listResponse: [movie(1, 'Alpha'), movie(2, 'Beta')] })
await screen.findByText('Alpha')
await fireEvent.click(screen.getByRole('button', { name: 'host-enter-batch' }))
await fireEvent.click(screen.getByRole('button', { name: 'host-toggle-select-all' }))
await fireEvent.click(screen.getByRole('button', { name: 'host-batch-enable' }))
await waitFor(() => expect(firstRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(secondRequested).toHaveBeenCalledOnce())
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('成功启用 1 个订阅'))
expect(mocks.toastError).toHaveBeenCalledWith('启用失败 1 个订阅')
await waitFor(() => expect(batchState()).toMatchObject({ enabled: false, selectedCount: 0 }))
})
})

View File

@@ -1,14 +1,25 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { computed, reactive, ref } from 'vue'
import { useToast } from 'vue-toastification'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import type { Context } from '@/api/types'
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
import { getMediaSubscribeId } from '@/composables/useMediaSubscribe'
import router from '@/router'
import { useI18n } from 'vue-i18n'
interface PipelineStep {
icon: string
title: string
value: string
}
// 国际化
const { t } = useI18n()
// 提示
const $toast = useToast()
// 识别结果
const nameTestResult = ref<Context>()
@@ -16,6 +27,7 @@ const nameTestResult = ref<Context>()
const nameTestForm = reactive({
title: '',
subtitle: '',
customWords: '',
})
// 识别按钮状态
@@ -27,64 +39,526 @@ const nameTestText = ref(t('nameTest.recognize'))
// 是否显示结果
const showResult = ref(false)
// 调用API识别
// 请求错误提示
const nameTestError = ref('')
// 识别词保存中状态
const savingCustomWords = ref(false)
const metaInfo = computed(() => nameTestResult.value?.meta_info)
const mediaInfo = computed(() => nameTestResult.value?.media_info)
const isRecognized = computed(() => Boolean(metaInfo.value?.name))
const resultTitle = computed(() => mediaInfo.value?.title || metaInfo.value?.name || t('nameTest.unrecognized'))
const resultSubtitle = computed(() => {
const parts = [mediaInfo.value?.year || metaInfo.value?.year, mediaInfo.value?.type || metaInfo.value?.type]
if (metaInfo.value?.season_episode) parts.push(metaInfo.value.season_episode)
return parts.filter(Boolean).join(' · ') || t('nameTest.waitingResult')
})
const resourceChips = computed(() => {
return [
metaInfo.value?.web_source,
metaInfo.value?.edition,
metaInfo.value?.resource_pix,
metaInfo.value?.video_encode,
metaInfo.value?.audio_encode,
metaInfo.value?.resource_team,
].filter(Boolean) as string[]
})
// 是否已匹配到具体媒体,决定是否展示查看详情入口
const canViewMediaDetail = computed(() =>
Boolean(
mediaInfo.value?.tmdb_id || mediaInfo.value?.douban_id || mediaInfo.value?.bangumi_id || mediaInfo.value?.media_id,
),
)
const pipelineSteps = computed<PipelineStep[]>(() => [
{
icon: 'mdi-file-document-outline',
title: t('nameTest.steps.original.title'),
value: metaInfo.value?.org_string || nameTestForm.title || '-',
},
{
icon: 'mdi-puzzle-check-outline',
title: t('nameTest.steps.meta.title'),
value:
[metaInfo.value?.name, metaInfo.value?.resource_term, metaInfo.value?.release_group]
.filter(Boolean)
.join(' · ') || '-',
},
{
icon: 'mdi-movie-search-outline',
title: t('nameTest.steps.media.title'),
value: mediaInfo.value?.tmdb_id
? `TMDB ${mediaInfo.value.tmdb_id}`
: mediaInfo.value?.douban_id
? `Douban ${mediaInfo.value.douban_id}`
: mediaInfo.value?.title || t('nameTest.unrecognized'),
},
])
/** 将 TMDB 原始图片地址转换为弹窗内更轻量的海报缩略图。 */
function getPosterImage(url = '') {
if (!url) return ''
return url.replace('original', 'w500')
}
/** 跳转查看当前识别结果匹配到的媒体详情。 */
function viewMediaDetail() {
if (!canViewMediaDetail.value || !mediaInfo.value) return
router.push({
path: '/media',
query: {
mediaid: getMediaSubscribeId(mediaInfo.value),
title: mediaInfo.value.title,
year: mediaInfo.value.year,
type: mediaInfo.value.type,
},
})
}
/** 调用媒体识别接口并刷新解析工作台,输入的识别词会临时应用于本次识别测试。 */
async function nameTest() {
if (!nameTestForm.title) return
try {
nameTestLoading.value = true
nameTestText.value = t('nameTest.recognizing')
nameTestError.value = ''
showResult.value = false
nameTestResult.value = await api.get('media/recognize', {
nameTestResult.value = await api.get<Context, Context>('media/recognize', {
params: {
title: nameTestForm.title,
subtitle: nameTestForm.subtitle,
custom_words: nameTestForm.customWords || undefined,
},
})
nameTestLoading.value = false
nameTestText.value = t('nameTest.recognizeAgain')
showResult.value = true
} catch (error) {
console.error(error)
nameTestError.value = error instanceof Error ? error.message : t('nameTest.requestFailed')
} finally {
nameTestLoading.value = false
}
}
/** 将识别词文本拆分为按行的规则列表,过滤掉空白行。 */
function parseCustomWordLines(text: string) {
return text.split('\n').filter(line => line.trim().length > 0)
}
/** 将当前输入的识别词追加保存到系统识别词表末尾。 */
async function saveCustomWords() {
if (savingCustomWords.value) return
const newLines = parseCustomWordLines(nameTestForm.customWords)
if (!newLines.length) return
savingCustomWords.value = true
try {
const queryResult: { [key: string]: any } = await api.get('system/setting/CustomIdentifiers')
const existingLines: string[] = Array.isArray(queryResult?.data?.value) ? queryResult.data.value : []
const appendLines = newLines.filter(line => !existingLines.includes(line))
if (!appendLines.length) {
$toast.warning(t('nameTest.saveWordsNoChange'))
return
}
const saveResult: { [key: string]: any } = await api.post('system/setting/CustomIdentifiers', [
...existingLines,
...appendLines,
])
if (saveResult.success) $toast.success(t('nameTest.saveWordsSuccess'))
else $toast.error(saveResult.message || t('nameTest.saveWordsFailed'))
} catch (error) {
console.error(error)
$toast.error(t('nameTest.saveWordsFailed'))
} finally {
savingCustomWords.value = false
}
}
</script>
<template>
<VForm @submit.prevent="() => {}">
<VRow class="pt-2">
<VCol cols="12">
<VTextField
v-model="nameTestForm.title"
:label="t('nameTest.title')"
:rules="[requiredValidator]"
prepend-inner-icon="mdi-movie-open"
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="nameTestForm.subtitle"
:label="t('nameTest.subtitle')"
rows="2"
auto-grow
prepend-inner-icon="mdi-subtitles"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" class="text-center">
<VBtn :disabled="nameTestLoading" @click="nameTest">
<template #prepend>
<VIcon icon="mdi-text-recognition" />
</template>
{{ nameTestText }}
</VBtn>
</VCol>
</VRow>
</VForm>
<VExpandTransition>
<div v-show="showResult">
<MediaInfoCard :context="nameTestResult" />
</div>
</VExpandTransition>
<div class="shortcut-workbench">
<section class="shortcut-panel shortcut-input-panel">
<div class="panel-heading">
<div>
<div class="text-subtitle-1 font-weight-medium">
{{ t('nameTest.inputTitle') }}
</div>
<div class="text-caption text-medium-emphasis">
{{ t('nameTest.inputSubtitle') }}
</div>
</div>
<VIcon icon="mdi-text-recognition" color="primary" />
</div>
<VForm validate-on="submit lazy" @submit.prevent="nameTest">
<VRow class="shortcut-form">
<VCol cols="12" class="shortcut-form-col">
<VTextField
v-model="nameTestForm.title"
:label="t('nameTest.title')"
:rules="[requiredValidator]"
prepend-inner-icon="mdi-movie-open"
/>
</VCol>
<VCol cols="12" class="shortcut-form-col">
<VTextarea
v-model="nameTestForm.subtitle"
:label="t('nameTest.subtitle')"
rows="2"
auto-grow
prepend-inner-icon="mdi-subtitles"
/>
</VCol>
<VCol cols="12" class="shortcut-form-col">
<VTextarea
v-model="nameTestForm.customWords"
:label="t('nameTest.customWords')"
:placeholder="t('nameTest.customWordsPlaceholder')"
rows="3"
auto-grow
prepend-inner-icon="mdi-tag-text-outline"
/>
<div class="custom-words-toolbar">
<VBtn
type="button"
size="small"
variant="tonal"
color="primary"
:disabled="!nameTestForm.customWords.trim()"
:loading="savingCustomWords"
@click="saveCustomWords"
>
<template #prepend>
<VIcon icon="mdi-content-save-outline" />
</template>
{{ t('nameTest.saveWords') }}
</VBtn>
</div>
</VCol>
<VCol cols="12" class="shortcut-form-col">
<VBtn block type="submit" :disabled="nameTestLoading" :loading="nameTestLoading">
<template #prepend>
<VIcon icon="mdi-movie-search-outline" />
</template>
{{ nameTestText }}
</VBtn>
</VCol>
</VRow>
</VForm>
<VAlert
v-if="nameTestError"
class="mt-4"
density="comfortable"
icon="mdi-alert-circle-outline"
type="error"
variant="tonal"
>
{{ nameTestError }}
</VAlert>
</section>
<section class="shortcut-panel shortcut-result-panel">
<div v-if="showResult" class="result-stack">
<div class="result-hero" :class="{ 'result-hero--failed': !isRecognized }">
<div v-if="mediaInfo?.poster_path" class="hero-poster">
<VImg :src="getPosterImage(mediaInfo.poster_path)" aspect-ratio="2/3" cover>
<template #placeholder>
<VSkeletonLoader class="h-100 w-100" />
</template>
</VImg>
</div>
<div v-else class="hero-poster hero-poster--empty">
<VIcon :icon="isRecognized ? 'mdi-movie-open-check' : 'mdi-movie-open-remove'" size="32" />
</div>
<div class="min-w-0 hero-body">
<div class="hero-heading">
<VIcon v-if="!isRecognized" icon="mdi-alert-circle-outline" color="primary" size="20" />
<span class="hero-title-text text-subtitle-1 font-weight-bold text-truncate">{{ resultTitle }}</span>
</div>
<div class="text-body-2 text-medium-emphasis mt-1">
{{ resultSubtitle }}
</div>
<div v-if="resourceChips.length" class="hero-chips mt-3">
<VChip
v-for="chip in resourceChips"
:key="chip"
class="hero-chip"
color="primary"
size="small"
variant="tonal"
>
{{ chip }}
</VChip>
</div>
<p v-if="mediaInfo?.overview" class="hero-overview text-body-2 text-medium-emphasis mt-3">
{{ mediaInfo.overview }}
</p>
<VBtn
v-if="canViewMediaDetail"
class="mt-3"
size="small"
variant="tonal"
color="primary"
append-icon="mdi-chevron-right"
@click="viewMediaDetail"
>
{{ t('common.viewDetails') }}
</VBtn>
</div>
</div>
<div class="pipeline">
<div v-for="(step, idx) in pipelineSteps" :key="step.title" class="pipeline-step">
<div class="pipeline-marker">
<VIcon :icon="step.icon" color="primary" size="18" />
<span v-if="idx < pipelineSteps.length - 1" class="pipeline-connector" />
</div>
<div class="pipeline-body">
<div class="text-caption text-medium-emphasis pipeline-label">
{{ step.title }}
</div>
<div class="text-body-2 font-weight-medium pipeline-value">
{{ step.value }}
</div>
</div>
</div>
</div>
<div v-if="metaInfo?.apply_words?.length" class="applied-words">
<div class="text-caption text-medium-emphasis applied-words-label">
{{ t('nameTest.steps.words.title') }}
</div>
<div class="words-chips">
<VChip v-for="word in metaInfo.apply_words" :key="word" size="small" variant="tonal">
{{ word }}
</VChip>
</div>
</div>
</div>
<div v-else class="empty-state">
<VIcon icon="mdi-movie-search-outline" size="36" />
<div class="text-body-2 text-medium-emphasis">
{{ t('nameTest.waitingResult') }}
</div>
</div>
</section>
</div>
</template>
<style scoped>
.shortcut-workbench {
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
padding-block-start: 0.5rem;
}
.shortcut-panel {
padding: 1rem;
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
backdrop-filter: var(--app-grouped-list-backdrop-filter);
background: var(--app-grouped-list-background);
box-shadow: var(--app-surface-shadow);
}
.panel-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
margin-block-end: 1rem;
}
.shortcut-form {
margin: 0;
}
.shortcut-form-col {
padding-inline: 0;
}
.shortcut-form-col:first-child {
padding-block-start: 0;
}
.shortcut-form-col:last-child {
padding-block-end: 0;
}
.custom-words-toolbar {
display: flex;
justify-content: flex-end;
margin-block-start: 0.5rem;
}
.result-stack {
display: grid;
gap: 1rem;
}
.result-hero {
display: grid;
align-items: center;
padding: 0.75rem;
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
background: rgba(var(--v-theme-primary), 0.08);
gap: 0.85rem;
grid-template-columns: 5rem minmax(0, 1fr);
}
.result-hero--failed {
background: rgba(var(--v-theme-error), 0.08);
}
.hero-poster {
overflow: hidden;
border: var(--app-surface-border);
border-radius: var(--app-control-radius);
aspect-ratio: 2 / 3;
background: rgba(var(--v-theme-surface-variant), 0.35);
}
.hero-poster--empty {
display: grid;
place-items: center;
}
.hero-body {
min-inline-size: 0;
}
.hero-heading {
display: flex;
align-items: center;
gap: 0.5rem;
}
.hero-title-text {
min-inline-size: 0;
}
.hero-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.hero-chip {
max-inline-size: 100%;
}
.hero-overview {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-height: 1.5;
}
.pipeline {
display: flex;
flex-direction: column;
}
.pipeline-step {
display: grid;
gap: 0.75rem;
grid-template-columns: 1.75rem minmax(0, 1fr);
}
.pipeline-marker {
display: flex;
flex-direction: column;
align-items: center;
min-block-size: 100%;
}
.pipeline-connector {
flex: 1;
background: rgba(var(--v-theme-primary), 0.25);
inline-size: 2px;
margin-block-start: 0.3rem;
}
.pipeline-body {
min-inline-size: 0;
padding-block-end: 0.9rem;
}
.pipeline-step:last-child .pipeline-body {
padding-block-end: 0;
}
.pipeline-label {
letter-spacing: 0.02em;
}
.pipeline-value {
margin-block-start: 0.2rem;
overflow-wrap: anywhere;
word-break: break-word;
}
.applied-words {
display: grid;
border-block-start: var(--app-surface-border);
gap: 0.5rem;
padding-block: 0.4rem;
}
.applied-words-label {
letter-spacing: 0.02em;
}
.words-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.empty-state {
display: grid;
align-content: center;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
gap: 0.75rem;
min-block-size: 14rem;
place-items: center;
text-align: center;
}
@media (width <= 760px) {
.shortcut-workbench {
grid-template-columns: minmax(0, 1fr);
}
.hero-heading {
flex-wrap: wrap;
}
.hero-title-text {
overflow: visible;
text-overflow: clip;
white-space: normal;
}
}
@media (width <= 420px) {
.shortcut-panel {
padding: 0.8rem;
}
.result-hero {
grid-template-columns: 4.25rem minmax(0, 1fr);
}
}
</style>

View File

@@ -1,21 +1,39 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import { FilterRuleGroup } from '@/api/types'
import type { ApiResponse, FilterRuleGroup, RuleTestData } from '@/api/types'
import { useI18n } from 'vue-i18n'
interface PipelineStep {
icon: string
title: string
value: string
tone?: 'success' | 'warning' | 'primary'
}
interface FormValidationResult {
valid: boolean
}
interface RuleTestFormRef {
validate: () => Promise<FormValidationResult>
}
// 国际化
const { t } = useI18n()
// 规则测试表单引用
const ruleTestFormRef = ref<RuleTestFormRef>()
// 识别结果
const ruleTestResult = ref('')
const ruleTestResponse = ref<ApiResponse<RuleTestData>>()
// 名称识别表单
const ruleTestForm = reactive({
title: null,
subtitle: null,
rulegroup: null,
title: '',
subtitle: '',
rulegroup: '',
})
// 识别按钮状态
@@ -27,47 +45,130 @@ const ruleTestText = ref(t('ruleTest.test'))
// 是否显示结果
const showResult = ref(false)
// 请求错误提示
const ruleTestError = ref('')
// 所有规则组列表
const filterRuleGroups = ref<FilterRuleGroup[]>([])
// 规则组加载状态
const filterRuleGroupLoading = ref(false)
// 规则组选项
const filterRuleGroupItems = computed(() => {
return filterRuleGroups.value.map(item => ({ title: item.name, value: item.name }))
return [
{ title: t('ruleTest.ruleGroupPlaceholder'), value: '' },
...filterRuleGroups.value.map(item => ({ title: item.name, value: item.name })),
]
})
const selectedRuleGroup = computed(() => filterRuleGroups.value.find(item => item.name === ruleTestForm.rulegroup))
const ruleTestData = computed(() => ruleTestResponse.value?.data)
const metaInfo = computed(() => ruleTestData.value?.meta_info)
const mediaInfo = computed(() => ruleTestData.value?.media_info)
const torrentInfo = computed(() => ruleTestData.value?.torrent_info)
const isMatched = computed(() => Boolean(ruleTestResponse.value?.success && ruleTestData.value?.matched))
const resultIcon = computed(() => (isMatched.value ? 'mdi-filter-check-outline' : 'mdi-filter-remove-outline'))
const resultColor = computed(() => (isMatched.value ? 'success' : 'warning'))
const priorityText = computed(() => {
const priority = ruleTestData.value?.priority
return typeof priority === 'number' ? priority.toString() : '-'
})
const hasPriority = computed(() => typeof ruleTestData.value?.priority === 'number')
const resultTitle = computed(() => {
if (isMatched.value) return t('ruleTest.matched')
return ruleTestResponse.value?.message || t('ruleTest.noPriorityRule')
})
const resultSubtitle = computed(() => {
const parts = [
mediaInfo.value?.title || metaInfo.value?.name || ruleTestForm.title,
mediaInfo.value?.year || metaInfo.value?.year,
metaInfo.value?.season_episode,
]
return parts.filter(Boolean).join(' · ') || t('ruleTest.waitingResult')
})
const ruleCount = computed(() => countRules(ruleTestData.value?.rulegroup?.rule_string || selectedRuleGroup.value?.rule_string))
const ruleCountLabel = computed(() => {
const count = ruleCount.value
if (!count) return t('ruleTest.steps.group.empty')
return t('ruleTest.ruleCount', { count })
})
const resourceChips = computed(() => {
return [
mediaInfo.value?.type || metaInfo.value?.type,
mediaInfo.value?.category,
metaInfo.value?.resource_pix,
metaInfo.value?.edition,
metaInfo.value?.resource_team,
].filter(Boolean) as string[]
})
const pipelineSteps = computed<PipelineStep[]>(() => [
{
icon: 'mdi-filter-settings-outline',
title: t('ruleTest.steps.group.title'),
value: ruleTestData.value?.rulegroup_name || ruleTestForm.rulegroup
? `${ruleTestData.value?.rulegroup_name || ruleTestForm.rulegroup} · ${ruleCountLabel.value}`
: '-',
tone: 'primary',
},
{
icon: 'mdi-movie-search-outline',
title: t('ruleTest.steps.media.title'),
value: mediaInfo.value?.title || metaInfo.value?.name || t('ruleTest.steps.media.none'),
tone: mediaInfo.value?.title ? 'primary' : 'warning',
},
{
icon: resultIcon.value,
title: t('ruleTest.steps.filter.title'),
value: ruleTestResponse.value?.message
|| (isMatched.value
? torrentInfo.value?.title || ruleTestForm.title
: t('ruleTest.steps.filter.pending')),
tone: isMatched.value ? 'success' : 'warning',
},
])
// 加载规则组
/** 统计规则组串中的优先级规则数量。 */
function countRules(ruleString = '') {
return ruleString.split('>').filter(item => item.trim()).length
}
/** 加载用户过滤规则组并填充规则组选择框。 */
async function queryFilterRuleGroups() {
try {
filterRuleGroupLoading.value = true
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
filterRuleGroups.value = result.data?.value ?? []
} catch (error) {
console.log(error)
} finally {
filterRuleGroupLoading.value = false
}
}
// 调用API识别
/** 调用规则测试接口并刷新解析工作台。 */
async function ruleTest() {
if (!ruleTestForm.title || !ruleTestForm.rulegroup) return
const validation = await ruleTestFormRef.value?.validate()
if (!validation?.valid) return
try {
ruleTestLoading.value = true
ruleTestText.value = t('ruleTest.testing')
ruleTestError.value = ''
showResult.value = false
const result: { [key: string]: any } = await api.get('system/ruletest', {
ruleTestResponse.value = await api.get<ApiResponse<RuleTestData>, ApiResponse<RuleTestData>>('system/ruletest', {
params: {
title: ruleTestForm.title,
subtitle: ruleTestForm.subtitle,
rulegroup_name: ruleTestForm.rulegroup,
},
})
if (result.success) ruleTestResult.value = t('ruleTest.priority', { value: result.data.priority })
else ruleTestResult.value = t('ruleTest.noPriorityRule')
ruleTestLoading.value = false
ruleTestText.value = t('ruleTest.testAgain')
showResult.value = true
} catch (error) {
console.error(error)
ruleTestError.value = error instanceof Error ? error.message : t('ruleTest.requestFailed')
} finally {
ruleTestLoading.value = false
}
}
@@ -77,52 +178,323 @@ onMounted(() => {
</script>
<template>
<VForm @submit.prevent="() => {}">
<VRow class="pt-2">
<VCol cols="12" md="8">
<VTextField
v-model="ruleTestForm.title"
:label="t('ruleTest.title')"
:rules="[requiredValidator]"
prepend-inner-icon="mdi-movie-open"
/>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-model="ruleTestForm.rulegroup"
:label="t('ruleTest.ruleGroup')"
:items="filterRuleGroupItems"
prepend-inner-icon="mdi-filter"
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="ruleTestForm.subtitle"
:label="t('ruleTest.subtitle')"
rows="2"
auto-grow
prepend-inner-icon="mdi-subtitles"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" class="text-center">
<VBtn :disabled="ruleTestLoading" @click="ruleTest">
<template #prepend>
<VIcon icon="mdi-filter-check-outline" />
</template>
{{ ruleTestText }}
</VBtn>
</VCol>
</VRow>
</VForm>
<VExpandTransition>
<div v-show="showResult">
<VCol>
<VAlert icon="mdi-alert-circle-outline">
{{ ruleTestResult }}
</VAlert>
</VCol>
</div>
</VExpandTransition>
<div class="shortcut-workbench">
<section class="shortcut-panel shortcut-input-panel">
<div class="panel-heading">
<div>
<div class="text-subtitle-1 font-weight-medium">
{{ t('ruleTest.inputTitle') }}
</div>
<div class="text-caption text-medium-emphasis">
{{ t('ruleTest.inputSubtitle') }}
</div>
</div>
<VIcon icon="mdi-filter-cog" color="primary" />
</div>
<VForm ref="ruleTestFormRef" validate-on="submit lazy" @submit.prevent="ruleTest">
<VRow class="shortcut-form">
<VCol cols="12" class="shortcut-form-col">
<VTextField
v-model="ruleTestForm.title"
:label="t('ruleTest.title')"
:rules="[requiredValidator]"
prepend-inner-icon="mdi-movie-open"
/>
</VCol>
<VCol cols="12" class="shortcut-form-col">
<VSelect
v-model="ruleTestForm.rulegroup"
:items="filterRuleGroupItems"
:label="t('ruleTest.ruleGroup')"
:loading="filterRuleGroupLoading"
:rules="[requiredValidator]"
prepend-inner-icon="mdi-filter"
/>
</VCol>
<VCol cols="12" class="shortcut-form-col">
<VTextarea
v-model="ruleTestForm.subtitle"
:label="t('ruleTest.subtitle')"
rows="2"
auto-grow
prepend-inner-icon="mdi-subtitles"
/>
</VCol>
<VCol cols="12" class="shortcut-form-col">
<VBtn block type="submit" :disabled="ruleTestLoading" :loading="ruleTestLoading">
<template #prepend>
<VIcon icon="mdi-filter-check-outline" />
</template>
{{ ruleTestText }}
</VBtn>
</VCol>
</VRow>
</VForm>
<VAlert
v-if="ruleTestError"
class="mt-4"
density="comfortable"
icon="mdi-alert-circle-outline"
type="error"
variant="tonal"
>
{{ ruleTestError }}
</VAlert>
</section>
<section class="shortcut-panel shortcut-result-panel">
<div v-if="showResult" class="result-stack">
<div class="result-hero" :class="{ 'result-hero--matched': isMatched }">
<div class="priority-badge" :class="{ 'priority-badge--matched': isMatched, 'priority-badge--empty': !hasPriority }">
<span class="text-caption text-medium-emphasis">{{ t('ruleTest.priorityLabel') }}</span>
<span class="priority-value">{{ priorityText }}</span>
</div>
<div class="min-w-0 hero-body">
<div class="hero-heading">
<VIcon :icon="resultIcon" :color="resultColor" size="20" />
<span class="hero-title-text text-subtitle-1 font-weight-medium text-truncate">{{ resultTitle }}</span>
</div>
<div class="text-body-2 text-medium-emphasis mt-1">
{{ resultSubtitle }}
</div>
<div v-if="resourceChips.length" class="hero-chips mt-3">
<VChip
v-for="chip in resourceChips"
:key="chip"
size="small"
variant="tonal"
:color="resultColor"
>
{{ chip }}
</VChip>
</div>
</div>
</div>
<div class="pipeline">
<div v-for="(step, idx) in pipelineSteps" :key="step.title" class="pipeline-step">
<div class="pipeline-marker">
<VIcon :icon="step.icon" :color="step.tone || 'primary'" size="18" />
<span v-if="idx < pipelineSteps.length - 1" class="pipeline-connector" />
</div>
<div class="pipeline-body">
<div class="text-caption text-medium-emphasis pipeline-label">
{{ step.title }}
</div>
<div class="text-body-2 font-weight-medium pipeline-value">
{{ step.value }}
</div>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<VIcon icon="mdi-filter-cog-outline" size="36" />
<div class="text-body-2 text-medium-emphasis">
{{ t('ruleTest.waitingResult') }}
</div>
</div>
</section>
</div>
</template>
<style scoped>
.shortcut-workbench {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
gap: 1rem;
padding-block-start: 0.5rem;
}
.shortcut-panel {
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
backdrop-filter: var(--app-grouped-list-backdrop-filter);
background: var(--app-grouped-list-background);
box-shadow: var(--app-surface-shadow);
padding: 1rem;
}
.panel-heading {
display: flex;
gap: 0.75rem;
align-items: flex-start;
justify-content: space-between;
margin-block-end: 1rem;
}
.shortcut-form {
margin: 0;
}
.shortcut-form-col {
padding-inline: 0;
}
.shortcut-form-col:first-child {
padding-block-start: 0;
}
.shortcut-form-col:last-child {
padding-block-end: 0;
}
.result-stack {
display: grid;
gap: 1rem;
}
.result-hero {
display: grid;
grid-template-columns: 5rem minmax(0, 1fr);
gap: 0.85rem;
align-items: center;
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
background: rgba(var(--v-theme-warning), 0.08);
padding: 0.75rem;
}
.result-hero--matched {
background: rgba(var(--v-theme-success), 0.08);
}
.priority-badge {
display: grid;
min-block-size: 5rem;
place-items: center;
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
background: rgba(var(--v-theme-surface-variant), 0.32);
text-align: center;
}
.priority-badge--matched {
border-color: rgba(var(--v-theme-success), 0.42);
background: rgba(var(--v-theme-success), 0.1);
}
.priority-badge--empty {
opacity: 0.7;
}
.priority-value {
font-size: 1.65rem;
font-weight: 600;
line-height: 1;
}
.hero-body {
min-inline-size: 0;
}
.hero-heading {
display: flex;
gap: 0.5rem;
align-items: center;
}
.hero-title-text {
min-inline-size: 0;
}
.hero-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.pipeline {
display: flex;
flex-direction: column;
}
.pipeline-step {
display: grid;
grid-template-columns: 1.75rem minmax(0, 1fr);
gap: 0.75rem;
}
.pipeline-marker {
display: flex;
flex-direction: column;
align-items: center;
min-block-size: 100%;
}
.pipeline-connector {
flex: 1;
inline-size: 2px;
margin-block-start: 0.3rem;
background: rgba(var(--v-theme-primary), 0.25);
}
.pipeline-body {
padding-block-end: 0.9rem;
min-inline-size: 0;
}
.pipeline-step:last-child .pipeline-body {
padding-block-end: 0;
}
.pipeline-label {
letter-spacing: 0.02em;
}
.pipeline-value {
margin-block-start: 0.2rem;
overflow-wrap: anywhere;
word-break: break-word;
}
.empty-state {
display: grid;
min-block-size: 14rem;
place-items: center;
align-content: center;
gap: 0.75rem;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
text-align: center;
}
@media (max-width: 760px) {
.shortcut-workbench {
grid-template-columns: minmax(0, 1fr);
}
.hero-heading {
flex-wrap: wrap;
}
.hero-title-text {
overflow: visible;
text-overflow: clip;
white-space: normal;
}
}
@media (max-width: 420px) {
.shortcut-panel {
padding: 0.8rem;
}
.result-hero {
grid-template-columns: minmax(0, 1fr);
}
.priority-badge {
grid-auto-flow: column;
justify-content: start;
min-block-size: 0;
padding: 0.5rem 0.75rem;
gap: 0.5rem;
}
.priority-value {
font-size: 1.35rem;
}
}
</style>

70
tests/setup.ts Normal file
View File

@@ -0,0 +1,70 @@
import '@testing-library/jest-dom/vitest'
import { abortAllRequests } from '@/utils/requestOptimizer'
import { cleanup } from '@testing-library/vue'
import { afterAll, afterEach, beforeAll, vi } from 'vitest'
import { server } from './support/msw/server'
class ResizeObserverStub implements ResizeObserver {
disconnect() {}
observe() {}
unobserve() {}
}
class IntersectionObserverStub implements IntersectionObserver {
readonly root = null
readonly rootMargin = '0px'
readonly thresholds = [0]
disconnect() {}
observe() {}
takeRecords(): IntersectionObserverEntry[] {
return []
}
unobserve() {}
}
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
value: ResizeObserverStub,
writable: true,
})
Object.defineProperty(globalThis, 'IntersectionObserver', {
configurable: true,
value: IntersectionObserverStub,
writable: true,
})
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: (query: string): MediaQueryList => ({
addEventListener: vi.fn(),
addListener: vi.fn(),
dispatchEvent: vi.fn(),
matches: false,
media: query,
onchange: null,
removeEventListener: vi.fn(),
removeListener: vi.fn(),
}),
writable: true,
})
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' })
})
afterEach(() => {
cleanup()
abortAllRequests()
server.resetHandlers()
localStorage.clear()
sessionStorage.clear()
vi.useRealTimers()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
afterAll(() => {
server.close()
})

View File

@@ -0,0 +1,34 @@
import type { MediaInfo, TmdbEpisode } from '@/api/types'
let episodeSeed = 0
let mediaSeed = 0
export function createTmdbEpisode(overrides: Partial<TmdbEpisode> = {}): TmdbEpisode {
episodeSeed += 1
return {
air_date: '2026-01-01',
crew: [],
episode_number: episodeSeed,
guest_stars: [],
name: `测试剧集 ${episodeSeed}`,
runtime: 45,
season_number: 1,
...overrides,
}
}
export function createMediaInfo(overrides: Partial<MediaInfo> = {}): MediaInfo {
mediaSeed += 1
return {
backdrop_path: `/images/media-${mediaSeed}.jpg`,
episode_run_time: [],
genres: ['剧情', '冒险'],
origin_country: [],
source: 'themoviedb',
title: `测试媒体 ${mediaSeed}`,
tmdb_id: mediaSeed,
type: '电影',
year: '2026',
...overrides,
}
}

View File

@@ -0,0 +1,101 @@
import type {
DownloaderConf,
FilterRuleGroup,
MediaInfo,
Site,
Subscribe,
TransferDirectoryConf,
} from '@/api/types'
import { createMediaInfo } from './media'
let subscribeSeed = 1000
let siteSeed = 100
/** 构造满足前端订阅契约的最小记录。 */
export function createSubscribe(overrides: Partial<Subscribe> = {}): Subscribe {
subscribeSeed += 1
return {
best_version: 0,
best_version_full: 0,
current_priority: 0,
date: '2026-07-16',
downloader: '',
episode_group: '',
id: subscribeSeed,
last_update: '2026-07-16 12:00:00',
name: `测试订阅 ${subscribeSeed}`,
show_edit_dialog: false,
sites: [],
state: 'R',
tmdbid: subscribeSeed,
type: '电影',
username: 'tester',
year: '2026',
...overrides,
}
}
/** 构造电影媒体信息。 */
export function createSubscribeMovie(overrides: Partial<MediaInfo> = {}): MediaInfo {
return createMediaInfo({ type: '电影', ...overrides })
}
/** 构造电视剧媒体信息。 */
export function createSubscribeTv(overrides: Partial<MediaInfo> = {}): MediaInfo {
return createMediaInfo({
season: 1,
season_info: [
{ episode_count: 12, name: '第 1 季', season_number: 1 },
{ episode_count: 10, name: '第 2 季', season_number: 2 },
],
type: '电视剧',
...overrides,
})
}
/** 构造订阅站点选项。 */
export function createSubscribeSite(overrides: Partial<Site> = {}): Site {
siteSeed += 1
return {
domain: `site-${siteSeed}.example.com`,
downloader: '',
id: siteSeed,
is_active: true,
name: `测试站点 ${siteSeed}`,
url: `https://site-${siteSeed}.example.com`,
...overrides,
}
}
/** 构造下载器选项。 */
export function createSubscribeDownloader(overrides: Partial<DownloaderConf> = {}): DownloaderConf {
return {
config: {},
default: false,
enabled: true,
name: '测试下载器',
type: 'qbittorrent',
...overrides,
}
}
/** 构造下载目录配置。 */
export function createSubscribeDirectory(overrides: Partial<TransferDirectoryConf> = {}): TransferDirectoryConf {
return {
download_path: '/downloads',
name: '测试目录',
priority: 1,
storage: 'local',
transfer_type: 'link',
...overrides,
}
}
/** 构造订阅过滤规则组。 */
export function createSubscribeRuleGroup(overrides: Partial<FilterRuleGroup> = {}): FilterRuleGroup {
return {
name: '默认规则组',
rule_string: 'priority=1',
...overrides,
}
}

View File

@@ -0,0 +1,29 @@
import type { MediaInfo, TmdbEpisode } from '@/api/types'
import { HttpResponse, http, type JsonBodyType } from 'msw'
const API_BASE_URL = 'http://localhost/api/v1/'
export function mediaDetailsHandler(
tmdbId: number,
response: MediaInfo,
status = 200,
onRequest: (url: URL) => void = () => {},
) {
return http.get(new URL(`media/tmdb:${tmdbId}`, API_BASE_URL).href, ({ request }) => {
onRequest(new URL(request.url))
return HttpResponse.json(response as unknown as JsonBodyType, { status })
})
}
export function tmdbSeasonEpisodesHandler(
tmdbId: number,
season: number,
response: TmdbEpisode[],
status = 200,
onRequest: (url: URL) => void = () => {},
) {
return http.get(new URL(`tmdb/${tmdbId}/${season}`, API_BASE_URL).href, ({ request }) => {
onRequest(new URL(request.url))
return HttpResponse.json(response as unknown as JsonBodyType, { status })
})
}

View File

@@ -0,0 +1,55 @@
import type { RecommendSource } from '@/api/types'
import { HttpResponse, http, type JsonBodyType } from 'msw'
const API_BASE_URL = 'http://localhost/api/v1/'
export const recommendApiUrls = {
config: new URL('user/config/Recommend', API_BASE_URL).href,
media: (sourcePath: string) => new URL(sourcePath.replace(/^\//, ''), API_BASE_URL).href,
sources: new URL('recommend/source', API_BASE_URL).href,
}
export function recommendSourcesHandler(
sources: RecommendSource[],
status = 200,
onRequest: () => void = () => {},
) {
return http.get(recommendApiUrls.sources, () => {
onRequest()
return HttpResponse.json(sources, { status })
})
}
export function recommendConfigHandler(
config: JsonBodyType,
status = 200,
onRequest: () => void = () => {},
) {
return http.get(recommendApiUrls.config, () => {
onRequest()
return HttpResponse.json({ data: { value: config } }, { status })
})
}
export function saveRecommendConfigHandler(
onSave: (config: Record<string, boolean>) => void = () => {},
status = 200,
) {
return http.post(recommendApiUrls.config, async ({ request }) => {
const config = (await request.json()) as Record<string, boolean>
onSave(config)
return HttpResponse.json({ success: status < 400 }, { status })
})
}
export function recommendMediaHandler(
sourcePath: string,
response: JsonBodyType | JsonBodyType[],
status = 200,
onRequest: () => void = () => {},
) {
return http.get(recommendApiUrls.media(sourcePath), () => {
onRequest()
return HttpResponse.json(response as JsonBodyType, { status })
})
}

View File

@@ -0,0 +1,259 @@
import type { DownloaderConf, FilterRuleGroup, Site, Subscribe, TransferDirectoryConf } from '@/api/types'
import { HttpResponse, http, type JsonBodyType, type RequestHandler } from 'msw'
const API_BASE_URL = 'http://localhost/api/v1/'
export type SubscribeMediaType = '电影' | '电视剧'
export interface SubscribeMutationResponse {
success: boolean
data?: Record<string, unknown>
message?: string
}
export const subscribeApiUrls = {
create: new URL('subscribe/', API_BASE_URL).href,
defaultConfig: (type: SubscribeMediaType, writable = false) =>
new URL(
`system/setting/${writable ? '' : 'public/'}${type === '电影' ? 'DefaultMovieSubscribeConfig' : 'DefaultTvSubscribeConfig'}`,
API_BASE_URL,
).href,
deleteById: (id: number) => new URL(`subscribe/${id}`, API_BASE_URL).href,
deleteByMedia: (mediaId: string) => new URL(`subscribe/media/${mediaId}`, API_BASE_URL).href,
details: (id: number) => new URL(`subscribe/${id}`, API_BASE_URL).href,
directories: new URL('system/setting/public/Directories', API_BASE_URL).href,
downloaders: new URL('download/clients', API_BASE_URL).href,
episodeGroups: (tmdbId: number) => new URL(`media/groups/${tmdbId}`, API_BASE_URL).href,
filterRuleGroups: new URL('system/setting/UserFilterRuleGroups', API_BASE_URL).href,
queryByMedia: (mediaId: string) => new URL(`subscribe/media/${mediaId}`, API_BASE_URL).href,
list: new URL('subscribe/', API_BASE_URL).href,
orderConfig: (type: SubscribeMediaType) =>
new URL(`user/config/${type === '电影' ? 'SubscribeMovieOrder' : 'SubscribeTvOrder'}`, API_BASE_URL).href,
resetById: (id: number) => new URL(`subscribe/reset/${id}`, API_BASE_URL).href,
searchById: (id: number) => new URL(`subscribe/search/${id}`, API_BASE_URL).href,
sites: new URL('site/rss', API_BASE_URL).href,
statusById: (id: number) => new URL(`subscribe/status/${id}`, API_BASE_URL).href,
update: new URL('subscribe/', API_BASE_URL).href,
}
function jsonResponse(body: JsonBodyType, status: number) {
return HttpResponse.json(body, { status })
}
export function subscribeListHandler(
response: JsonBodyType = [],
status = 200,
onRequest: (url: URL) => void = () => {},
) {
return http.get(subscribeApiUrls.list, ({ request }) => {
onRequest(new URL(request.url))
return jsonResponse(response, status)
})
}
export function subscribeOrderConfigHandler(
type: SubscribeMediaType,
value: JsonBodyType = [],
status = 200,
onRequest: (url: URL) => void = () => {},
) {
return http.get(subscribeApiUrls.orderConfig(type), ({ request }) => {
onRequest(new URL(request.url))
return jsonResponse({ data: { value }, success: status < 400 }, status)
})
}
export function saveSubscribeOrderConfigHandler(
type: SubscribeMediaType,
response: SubscribeMutationResponse = { success: true },
status = 200,
onSave: (payload: { id: number }[], url: URL) => void | Promise<void> = () => {},
) {
return http.post(subscribeApiUrls.orderConfig(type), async ({ request }) => {
const payload = (await request.json()) as { id: number }[]
await onSave(payload, new URL(request.url))
return jsonResponse(response, status)
})
}
export function updateSubscribeStatusHandler(
id: number,
response: SubscribeMutationResponse = { success: true },
status = 200,
onRequest: (url: URL) => void | Promise<void> = () => {},
) {
return http.put(subscribeApiUrls.statusById(id), async ({ request }) => {
await onRequest(new URL(request.url))
return jsonResponse(response, status)
})
}
export function searchSubscribeByIdHandler(
id: number,
response: SubscribeMutationResponse = { success: true },
status = 200,
onRequest: (url: URL) => void = () => {},
) {
return http.get(subscribeApiUrls.searchById(id), ({ request }) => {
onRequest(new URL(request.url))
return jsonResponse(response, status)
})
}
export function resetSubscribeByIdHandler(
id: number,
response: SubscribeMutationResponse = { success: true },
status = 200,
onRequest: (url: URL) => void = () => {},
) {
return http.get(subscribeApiUrls.resetById(id), ({ request }) => {
onRequest(new URL(request.url))
return jsonResponse(response, status)
})
}
export function createSubscribeHandler(
response: SubscribeMutationResponse = { data: { id: 1 }, success: true },
status = 200,
onCreate: (payload: Record<string, unknown>) => void = () => {},
) {
return http.post(subscribeApiUrls.create, async ({ request }) => {
const payload = (await request.json()) as Record<string, unknown>
onCreate(payload)
return jsonResponse(response, status)
})
}
export function updateSubscribeHandler(
response: SubscribeMutationResponse = { success: true },
status = 200,
onUpdate: (payload: Record<string, unknown>) => void = () => {},
) {
return http.put(subscribeApiUrls.update, async ({ request }) => {
const payload = (await request.json()) as Record<string, unknown>
onUpdate(payload)
return jsonResponse(response, status)
})
}
export function querySubscribeByMediaHandler(
mediaId: string,
subscribe: Partial<Subscribe>,
status = 200,
onRequest: (url: URL) => void = () => {},
) {
return http.get(subscribeApiUrls.queryByMedia(mediaId), ({ request }) => {
onRequest(new URL(request.url))
return jsonResponse(subscribe as JsonBodyType, status)
})
}
export function deleteSubscribeByMediaHandler(
mediaId: string,
response: SubscribeMutationResponse = { success: true },
status = 200,
onRequest: (url: URL) => void = () => {},
) {
return http.delete(subscribeApiUrls.deleteByMedia(mediaId), ({ request }) => {
onRequest(new URL(request.url))
return jsonResponse(response, status)
})
}
export function subscribeDetailsHandler(id: number, subscribe: Subscribe, status = 200, onRequest: () => void = () => {}) {
return http.get(subscribeApiUrls.details(id), () => {
onRequest()
return jsonResponse(subscribe as unknown as JsonBodyType, status)
})
}
export function deleteSubscribeByIdHandler(
id: number,
response: SubscribeMutationResponse = { success: true },
status = 200,
onRequest: () => void = () => {},
) {
return http.delete(subscribeApiUrls.deleteById(id), () => {
onRequest()
return jsonResponse(response, status)
})
}
export function defaultSubscribeConfigHandler(
type: SubscribeMediaType,
config: Partial<Subscribe>,
status = 200,
onRequest: () => void = () => {},
) {
return http.get(subscribeApiUrls.defaultConfig(type), () => {
onRequest()
return jsonResponse({ data: { value: config }, success: status < 400 }, status)
})
}
export function saveDefaultSubscribeConfigHandler(
type: SubscribeMediaType,
response: SubscribeMutationResponse = { success: true },
status = 200,
onSave: (payload: Record<string, unknown>) => void = () => {},
) {
return http.post(subscribeApiUrls.defaultConfig(type, true), async ({ request }) => {
const payload = (await request.json()) as Record<string, unknown>
onSave(payload)
return jsonResponse(response, status)
})
}
export interface SubscribeDialogOptions {
directories?: TransferDirectoryConf[]
downloaders?: DownloaderConf[]
episodeGroups?: Record<string, unknown>[]
filterRuleGroups?: FilterRuleGroup[]
onDirectories?: () => void
onDownloaders?: () => void
onEpisodeGroups?: () => void
onFilterRuleGroups?: () => void
onSites?: () => void
sites?: Site[]
tmdbId?: number
}
/** 为编辑弹窗提供彼此独立、可按测试覆盖的选项接口。 */
export function subscribeDialogOptionHandlers(options: SubscribeDialogOptions = {}): RequestHandler[] {
const {
directories = [],
downloaders = [],
episodeGroups = [],
filterRuleGroups = [],
onDirectories = () => {},
onDownloaders = () => {},
onEpisodeGroups = () => {},
onFilterRuleGroups = () => {},
onSites = () => {},
sites = [],
tmdbId = 1,
} = options
return [
http.get(subscribeApiUrls.sites, () => {
onSites()
return jsonResponse(sites as unknown as JsonBodyType, 200)
}),
http.get(subscribeApiUrls.downloaders, () => {
onDownloaders()
return jsonResponse(downloaders as unknown as JsonBodyType, 200)
}),
http.get(subscribeApiUrls.directories, () => {
onDirectories()
return jsonResponse({ data: { value: directories }, success: true }, 200)
}),
http.get(subscribeApiUrls.filterRuleGroups, () => {
onFilterRuleGroups()
return jsonResponse({ data: { value: filterRuleGroups }, success: true }, 200)
}),
http.get(subscribeApiUrls.episodeGroups(tmdbId), () => {
onEpisodeGroups()
return jsonResponse(episodeGroups as unknown as JsonBodyType, 200)
}),
]
}

View File

@@ -0,0 +1,3 @@
import { setupServer } from 'msw/node'
export const server = setupServer()

59
tests/support/render.ts Normal file
View File

@@ -0,0 +1,59 @@
import i18n from '@/plugins/i18n'
import vuetify from '@/plugins/vuetify'
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { defineComponent, h, type Component } from 'vue'
import { createMemoryHistory, createRouter, type RouteLocationRaw, type RouteMeta } from 'vue-router'
import { vi } from 'vitest'
type TestingLibraryRenderOptions = NonNullable<Parameters<typeof render>[1]>
export interface RenderWithProvidersOptions extends Omit<TestingLibraryRenderOptions, 'global'> {
global?: TestingLibraryRenderOptions['global']
initialRoute?: RouteLocationRaw
initialRouteMeta?: RouteMeta
initialState?: Record<string, Record<string, unknown>>
stubActions?: boolean
}
const EmptyRoute = defineComponent({
name: 'EmptyTestRoute',
setup: () => () => h('div'),
})
/** 使用独立 Router、Pinia 和生产 UI 插件渲染业务组件。 */
export async function renderWithProviders(component: Component, options: RenderWithProvidersOptions = {}) {
const {
global: globalOptions,
initialRoute = '/',
initialRouteMeta = {},
initialState = {},
stubActions = true,
...renderOptions
} = options
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/:pathMatch(.*)*', component: EmptyRoute, meta: initialRouteMeta }],
})
await router.push(initialRoute)
i18n.global.locale.value = 'zh-CN'
const pinia = createTestingPinia({
createSpy: vi.fn,
initialState,
stubActions,
})
setActivePinia(pinia)
const result = render(component, {
...renderOptions,
global: {
...globalOptions,
plugins: [vuetify, i18n, pinia, router, ...(globalOptions?.plugins ?? [])],
},
})
await router.isReady()
return { ...result, pinia, router }
}

View File

@@ -41,6 +41,9 @@
"@styles/*": [
"src/styles/*"
],
"@tests/*": [
"tests/*"
],
},
"lib": [
"esnext",
@@ -62,6 +65,7 @@
"shims.d.ts",
"src/**/*",
"src/**/*.vue",
"tests/**/*.ts",
"themeConfig.ts",
"auto-imports.d.ts",
"components.d.ts",
@@ -73,4 +77,4 @@
"node_modules",
"src/@iconify/*"
]
}
}

View File

@@ -1,3 +1,5 @@
/// <reference types="vitest/config" />
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
@@ -11,182 +13,192 @@ import { resolve } from 'node:path'
import federation from '@originjs/vite-plugin-federation'
import topLevelAwait from 'vite-plugin-top-level-await'
import { readFileSync } from 'node:fs'
import { responsiveInputCoreComponentNames } from './src/plugins/vuetify/responsiveInputNames'
// 读取 package.json 获取版本号
const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8'))
const buildTime = new Date().getTime().toString()
const isTestMode = (mode: string) => mode === 'test' || process.env.VITEST === 'true'
// https://vitejs.dev/config/
export default defineConfig({
export default defineConfig(({ mode }) => ({
base: './',
plugins: [
vue(),
vueJsx(),
vuetify({
autoImport: {
// 这些录入控件由 Vuetify 全局适配器接管,避免模板局部导入绕过移动布局。
ignore: [...responsiveInputCoreComponentNames],
},
styles: {
configFile: 'src/styles/variables/_vuetify.scss',
},
}),
Components({
dirs: ['src/@core/components'],
dts: true,
dts: !isTestMode(mode),
}),
AutoImport({
imports: ['vue', 'vue-router', '@vueuse/core', '@vueuse/math', 'pinia', 'vue-i18n'],
vueTemplate: true,
dts: !isTestMode(mode),
}),
VueI18n({
include: [resolve(__dirname, 'src/locales/*.ts')],
}),
federation({
name: 'MoviePilot',
filename: 'remoteEntry.js',
// @ts-ignore
remotes: {
// 动态remotes将在运行时注入
dummy: {
external: '',
format: 'var',
!isTestMode(mode) &&
federation({
name: 'MoviePilot',
filename: 'remoteEntry.js',
// @ts-ignore
remotes: {
// 动态remotes将在运行时注入
dummy: {
external: '',
format: 'var',
},
},
},
shared: ['vue', 'vuetify'],
}),
VitePWA({
injectRegister: 'script',
registerType: 'autoUpdate',
strategies: 'injectManifest',
srcDir: 'src',
filename: 'service-worker.ts',
injectManifest: {
rollupFormat: 'iife',
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,webp,woff,woff2,ttf,otf,eot}'],
},
devOptions: {
enabled: true,
type: 'module',
},
manifest: {
'name': 'MoviePilot',
'short_name': 'MoviePilot',
'description': 'MoviePilot - 智能影视媒体库管理工具',
'start_url': './',
'scope': './',
'display': 'standalone',
'display_override': ['window-controls-overlay', 'standalone'],
'orientation': 'portrait-primary',
'lang': 'zh-CN',
'dir': 'ltr',
'categories': ['entertainment', 'multimedia', 'utilities'],
'icons': [
{
'src': './android-chrome-192x192.png',
'sizes': '192x192',
'type': 'image/png',
'purpose': 'any',
},
{
'src': './android-chrome-192x192_maskable.png',
'sizes': '192x192',
'type': 'image/png',
'purpose': 'maskable',
},
{
'src': './android-chrome-512x512.png',
'sizes': '512x512',
'type': 'image/png',
'purpose': 'any',
},
{
'src': './android-chrome-512x512_maskable.png',
'sizes': '512x512',
'type': 'image/png',
'purpose': 'maskable',
},
],
'theme_color': '#0E1116',
'background_color': '#0E1116',
'edge_side_panel': {
'preferred_width': 320,
shared: ['vue', 'vuetify'],
}),
!isTestMode(mode) &&
VitePWA({
injectRegister: 'script',
registerType: 'autoUpdate',
strategies: 'injectManifest',
srcDir: 'src',
filename: 'service-worker.ts',
injectManifest: {
rollupFormat: 'iife',
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,webp,woff,woff2,ttf,otf,eot}'],
},
'launch_handler': {
'client_mode': 'navigate-existing',
devOptions: {
enabled: true,
type: 'module',
},
'handle_links': 'preferred',
'id': 'moviepilot-app',
'shortcuts': [
{
'name': '推荐',
'short_name': '推荐',
'description': '查看推荐内容',
'url': './recommend',
'icons': [
{
'src': './sparkles-icon-192x192.png',
'sizes': '192x192',
'type': 'image/png',
},
],
manifest: {
'name': 'MoviePilot',
'short_name': 'MoviePilot',
'description': 'MoviePilot - 智能影视媒体库管理工具',
'start_url': './',
'scope': './',
'display': 'standalone',
'display_override': ['window-controls-overlay', 'standalone'],
'orientation': 'portrait-primary',
'lang': 'zh-CN',
'dir': 'ltr',
'categories': ['entertainment', 'multimedia', 'utilities'],
'icons': [
{
'src': './android-chrome-192x192.png',
'sizes': '192x192',
'type': 'image/png',
'purpose': 'any',
},
{
'src': './android-chrome-192x192_maskable.png',
'sizes': '192x192',
'type': 'image/png',
'purpose': 'maskable',
},
{
'src': './android-chrome-512x512.png',
'sizes': '512x512',
'type': 'image/png',
'purpose': 'any',
},
{
'src': './android-chrome-512x512_maskable.png',
'sizes': '512x512',
'type': 'image/png',
'purpose': 'maskable',
},
],
'theme_color': '#0E1116',
'background_color': '#0E1116',
'edge_side_panel': {
'preferred_width': 320,
},
{
'name': '探索',
'short_name': '探索',
'description': '探索新内容',
'url': './discover',
'icons': [
{
'src': './clock-icon-192x192.png',
'sizes': '192x192',
'type': 'image/png',
},
],
'launch_handler': {
'client_mode': 'navigate-existing',
},
{
'name': '更多',
'short_name': '更多',
'description': '更多功能',
'url': './apps',
'icons': [
{
'src': './cog-icon-192x192.png',
'sizes': '192x192',
'type': 'image/png',
},
],
},
],
'screenshots': [
{
'src': './android-chrome-512x512.png',
'sizes': '512x512',
'type': 'image/png',
'form_factor': 'wide',
'label': 'MoviePilot 主界面',
},
{
'src': './android-chrome-192x192.png',
'sizes': '192x192',
'type': 'image/png',
'form_factor': 'narrow',
'label': 'MoviePilot 移动端',
},
],
'protocol_handlers': [
{
'protocol': 'web+moviepilot',
'url': './?handler=%s',
},
],
'prefer_related_applications': false,
'related_applications': [],
},
}),
topLevelAwait({
// The export name of top-level await promise for each chunk module
promiseExportName: '__mp_tla',
// The function to generate import names of top-level await promise in each chunk module
promiseImportName: i => `__mp_tla_${i}`,
}),
'handle_links': 'preferred',
'id': 'moviepilot-app',
'shortcuts': [
{
'name': '推荐',
'short_name': '推荐',
'description': '查看推荐内容',
'url': './recommend',
'icons': [
{
'src': './sparkles-icon-192x192.png',
'sizes': '192x192',
'type': 'image/png',
},
],
},
{
'name': '探索',
'short_name': '探索',
'description': '探索新内容',
'url': './discover',
'icons': [
{
'src': './clock-icon-192x192.png',
'sizes': '192x192',
'type': 'image/png',
},
],
},
{
'name': '更多',
'short_name': '更多',
'description': '更多功能',
'url': './apps',
'icons': [
{
'src': './cog-icon-192x192.png',
'sizes': '192x192',
'type': 'image/png',
},
],
},
],
'screenshots': [
{
'src': './android-chrome-512x512.png',
'sizes': '512x512',
'type': 'image/png',
'form_factor': 'wide',
'label': 'MoviePilot 主界面',
},
{
'src': './android-chrome-192x192.png',
'sizes': '192x192',
'type': 'image/png',
'form_factor': 'narrow',
'label': 'MoviePilot 移动端',
},
],
'protocol_handlers': [
{
'protocol': 'web+moviepilot',
'url': './?handler=%s',
},
],
'prefer_related_applications': false,
'related_applications': [],
},
}),
!isTestMode(mode) &&
topLevelAwait({
// The export name of top-level await promise for each chunk module
promiseExportName: '__mp_tla',
// The function to generate import names of top-level await promise in each chunk module
promiseImportName: i => `__mp_tla_${i}`,
}),
],
define: {
'process.env': {},
@@ -200,6 +212,7 @@ export default defineConfig({
'@layouts': fileURLToPath(new URL('./src/@layouts', import.meta.url)),
'@images': fileURLToPath(new URL('./src/assets/images/', import.meta.url)),
'@styles': fileURLToPath(new URL('./src/styles/', import.meta.url)),
'@tests': fileURLToPath(new URL('./tests', import.meta.url)),
'@configured-variables': fileURLToPath(new URL('./src/styles/variables/_template.scss', import.meta.url)),
'apexcharts': fileURLToPath(new URL('node_modules/apexcharts', import.meta.url)),
},
@@ -238,4 +251,114 @@ export default defineConfig({
},
},
},
})
test: {
clearMocks: true,
environment: 'jsdom',
environmentOptions: {
jsdom: {
pretendToBeVisual: true,
url: 'http://localhost/',
},
},
include: ['src/**/__tests__/**/*.spec.ts'],
restoreMocks: true,
server: {
deps: {
inline: ['vuetify'],
},
},
setupFiles: ['./tests/setup.ts'],
testTimeout: 60_000,
unstubGlobals: true,
coverage: {
include: [
'src/utils/recommendSources.ts',
'src/utils/permission.ts',
'src/stores/auth.ts',
'src/pages/recommend.vue',
'src/pages/subscribe.vue',
'src/views/dashboard/MediaRecommend.vue',
'src/views/subscribe/FullCalendarView.vue',
'src/views/subscribe/SubscribeListView.vue',
'src/composables/useMediaSubscribe.ts',
'src/components/cards/SubscribeCard.vue',
'src/components/dialog/SubscribeEditDialog.vue',
],
provider: 'v8',
reporter: ['text', 'json-summary', 'html'],
reportsDirectory: 'coverage',
thresholds: {
branches: 80,
functions: 85,
lines: 85,
statements: 85,
'src/components/cards/SubscribeCard.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/components/dialog/SubscribeEditDialog.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/composables/useMediaSubscribe.ts': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/pages/recommend.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/pages/subscribe.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/stores/auth.ts': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/utils/permission.ts': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/utils/recommendSources.ts': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/views/dashboard/MediaRecommend.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
'src/views/subscribe/FullCalendarView.vue': {
branches: 85,
functions: 90,
lines: 90,
statements: 90,
},
'src/views/subscribe/SubscribeListView.vue': {
branches: 75,
functions: 80,
lines: 80,
statements: 80,
},
},
},
},
}))

1325
yarn.lock

File diff suppressed because it is too large Load Diff