mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-27 03:01:04 +08:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff871e7049 | ||
|
|
20f80d0f1f | ||
|
|
36bb99cf06 | ||
|
|
24c2e3b49c | ||
|
|
486a471c0c | ||
|
|
ce32da9176 | ||
|
|
39dd1b9e00 | ||
|
|
807711cae8 | ||
|
|
00feeb86ca | ||
|
|
4378c4843b | ||
|
|
5676e0b9b1 | ||
|
|
38a841dc9c | ||
|
|
6e92673438 | ||
|
|
5e2585b210 | ||
|
|
c3c323b018 | ||
|
|
5390af50ac | ||
|
|
c0f58c7df4 | ||
|
|
9bdf102f7a | ||
|
|
52df6bef38 | ||
|
|
c5da08e388 | ||
|
|
8a069db55e | ||
|
|
9900042bc3 | ||
|
|
e4f02a326f | ||
|
|
199693abf8 | ||
|
|
32b0c1c00c | ||
|
|
e4d0afc380 | ||
|
|
8e89078055 | ||
|
|
2e0a582d22 | ||
|
|
9c2c4d9fc4 | ||
|
|
4e0191ba5c | ||
|
|
9247850411 | ||
|
|
29b4854dae | ||
|
|
338af516be | ||
|
|
ea892a31c0 | ||
|
|
c0f3814560 | ||
|
|
15f984ba44 | ||
|
|
362c601c7e | ||
|
|
4f0204d465 | ||
|
|
d92e79169c | ||
|
|
563849aab3 | ||
|
|
b0a27c6405 | ||
|
|
b664e0b447 | ||
|
|
d625d1ad46 | ||
|
|
cec9563963 | ||
|
|
2df3ce1b86 |
557
.github/workflows/pr-agent.yml
vendored
557
.github/workflows/pr-agent.yml
vendored
@@ -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 }}
|
||||
|
||||
# 仓库设置中添加的 Secret:Settings -> 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
37
.github/workflows/test.yml
vendored
Normal 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
1
.gitignore
vendored
@@ -12,6 +12,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
dev-dist
|
||||
coverage
|
||||
*.local
|
||||
package-lock.json
|
||||
|
||||
|
||||
@@ -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
94
docs/testing.md
Normal 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 和无渲染模块直接使用 Vitest;Vue 组件使用标准渲染入口。
|
||||
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 错误。
|
||||
210
index.html
210
index.html
@@ -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,
|
||||
|
||||
25
package.json
25
package.json
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 订阅集详情
|
||||
|
||||
@@ -1539,8 +1539,8 @@ defineExpose({
|
||||
.agent-assistant-fab {
|
||||
position: fixed;
|
||||
|
||||
/* 保持高于菜单浮层,但低于 agent 会话面板(2101)。 */
|
||||
z-index: 2100;
|
||||
/* 保持机器人和提示气泡高于 Vuetify 弹窗(2400)及全局 Toast(2500)。 */
|
||||
z-index: 2600;
|
||||
|
||||
--agent-assistant-robot-outline: #5b00c5;
|
||||
--agent-assistant-robot-outline-soft: #7432df;
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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 === ''">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
438
src/components/cards/__tests__/SubscribeCard.spec.ts
Normal file
438
src/components/cards/__tests__/SubscribeCard.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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)]
|
||||
})
|
||||
|
||||
// 仅电视剧订阅支持全集洗版,电影保持原有洗版逻辑
|
||||
|
||||
@@ -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>
|
||||
|
||||
451
src/components/dialog/__tests__/SubscribeEditDialog.spec.ts
Normal file
451
src/components/dialog/__tests__/SubscribeEditDialog.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -30,6 +30,7 @@ const propsWithoutModelValue = computed(() => {
|
||||
return { ...rest, ...attrs }
|
||||
})
|
||||
|
||||
/** 同步路径输入值并向父组件派发更新。 */
|
||||
function updateModelValue(value: string) {
|
||||
innerValue.value = value
|
||||
emit('update:modelValue', value)
|
||||
|
||||
3543
src/components/misc/OpticalLogoLab.vue
Normal file
3543
src/components/misc/OpticalLogoLab.vue
Normal file
File diff suppressed because it is too large
Load Diff
251
src/components/misc/PrismaticLogo.vue
Normal file
251
src/components/misc/PrismaticLogo.vue
Normal 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>
|
||||
50
src/components/misc/ThemeLogoMark.vue
Normal file
50
src/components/misc/ThemeLogoMark.vue
Normal 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>
|
||||
598
src/composables/__tests__/useMediaSubscribe.spec.ts
Normal file
598
src/composables/__tests__/useMediaSubscribe.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>()
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 可以在不刷新页面的情况下即时响应。 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '订阅文件',
|
||||
|
||||
@@ -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: '訂閱文件',
|
||||
|
||||
261
src/pages/__tests__/recommend.spec.ts
Normal file
261
src/pages/__tests__/recommend.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
467
src/pages/__tests__/subscribe.spec.ts
Normal file
467
src/pages/__tests__/subscribe.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
199
src/plugins/vuetify/AppInput.ts
Normal file
199
src/plugins/vuetify/AppInput.ts
Normal 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),
|
||||
]),
|
||||
])
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
21
src/plugins/vuetify/responsiveInputNames.ts
Normal file
21
src/plugins/vuetify/responsiveInputNames.ts
Normal 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]
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
67
src/stores/__tests__/auth.spec.ts
Normal file
67
src/stores/__tests__/auth.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
106
src/utils/__tests__/permission.spec.ts
Normal file
106
src/utils/__tests__/permission.spec.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
61
src/utils/__tests__/recommendSources.spec.ts
Normal file
61
src/utils/__tests__/recommendSources.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
42
src/utils/__tests__/themeLogo.spec.ts
Normal file
42
src/utils/__tests__/themeLogo.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
25
src/utils/__tests__/themePalette.spec.ts
Normal file
25
src/utils/__tests__/themePalette.spec.ts
Normal 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
138
src/utils/themeLogo.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
392
src/views/dashboard/__tests__/MediaRecommend.spec.ts
Normal file
392
src/views/dashboard/__tests__/MediaRecommend.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
446
src/views/subscribe/__tests__/FullCalendarView.spec.ts
Normal file
446
src/views/subscribe/__tests__/FullCalendarView.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
657
src/views/subscribe/__tests__/SubscribeListView.spec.ts
Normal file
657
src/views/subscribe/__tests__/SubscribeListView.spec.ts
Normal 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 }))
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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
70
tests/setup.ts
Normal 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()
|
||||
})
|
||||
34
tests/support/factories/media.ts
Normal file
34
tests/support/factories/media.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
101
tests/support/factories/subscribe.ts
Normal file
101
tests/support/factories/subscribe.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
29
tests/support/msw/handlers/media.ts
Normal file
29
tests/support/msw/handlers/media.ts
Normal 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 })
|
||||
})
|
||||
}
|
||||
55
tests/support/msw/handlers/recommend.ts
Normal file
55
tests/support/msw/handlers/recommend.ts
Normal 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 })
|
||||
})
|
||||
}
|
||||
259
tests/support/msw/handlers/subscribe.ts
Normal file
259
tests/support/msw/handlers/subscribe.ts
Normal 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)
|
||||
}),
|
||||
]
|
||||
}
|
||||
3
tests/support/msw/server.ts
Normal file
3
tests/support/msw/server.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { setupServer } from 'msw/node'
|
||||
|
||||
export const server = setupServer()
|
||||
59
tests/support/render.ts
Normal file
59
tests/support/render.ts
Normal 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 }
|
||||
}
|
||||
@@ -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/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
417
vite.config.ts
417
vite.config.ts
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user