Improve media detail UX and preserve search context.

Render structured media details instead of raw JSON, keep search state when navigating back from detail, and surface HDHive link validation and fallback resource-page links for clearer troubleshooting.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
renjue
2026-05-09 17:37:48 +08:00
parent 15a9ae0798
commit e6407094bd
4 changed files with 245 additions and 21 deletions

View File

@@ -40,6 +40,15 @@ def normalize_resource(search_data, unlock_data):
resolution = (search_data or {}).get("video_resolution") resolution = (search_data or {}).get("video_resolution")
source = (search_data or {}).get("source") source = (search_data or {}).get("source")
subtitle_language = (search_data or {}).get("subtitle_language") subtitle_language = (search_data or {}).get("subtitle_language")
unlock_url = (unlock_data or {}).get("full_url") or (unlock_data or {}).get("url") or ""
media_url = (search_data or {}).get("media_url") or ""
detail_url = media_url
if not detail_url and (search_data or {}).get("media_slug"):
detail_url = f"{Config.HDHIVE_BASE_URL}/movie/{(search_data or {}).get('media_slug')}"
validate_status = (search_data or {}).get("validate_status")
validate_message = (search_data or {}).get("validate_message")
return { return {
"resourceTitle": (search_data or {}).get("title", ""), "resourceTitle": (search_data or {}).get("title", ""),
"quality": ", ".join(resolution) if isinstance(resolution, list) else "", "quality": ", ".join(resolution) if isinstance(resolution, list) else "",
@@ -50,11 +59,10 @@ def normalize_resource(search_data, unlock_data):
if isinstance(subtitle_language, list) if isinstance(subtitle_language, list)
else "", else "",
"slug": (search_data or {}).get("slug", ""), "slug": (search_data or {}).get("slug", ""),
"unlockUrl": (unlock_data or {}).get("full_url") "unlockUrl": unlock_url,
or (unlock_data or {}).get("url") "detailUrl": detail_url,
or "", "availability": "available" if unlock_url else ("index_only" if detail_url else "unknown"),
"availability": "available" "validateStatus": validate_status,
if ((unlock_data or {}).get("full_url") or (unlock_data or {}).get("url")) "validateMessage": validate_message,
else "unknown",
"raw": {"searchData": search_data, "unlockData": unlock_data}, "raw": {"searchData": search_data, "unlockData": unlock_data},
} }

View File

@@ -97,6 +97,12 @@ button:disabled {
cursor: not-allowed; cursor: not-allowed;
} }
.secondary-btn {
border: 1px solid #d0d7de;
background: #fff;
color: #1f2328;
}
.grid { .grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -143,6 +149,16 @@ pre {
color: #cf222e; color: #cf222e;
} }
.warning {
color: #9a6700;
background: #fff8c5;
border: 1px solid #d4a72c;
border-radius: 6px;
padding: 6px 8px;
margin: 8px 0 0;
font-size: 12px;
}
.log-time { .log-time {
color: #57606a; color: #57606a;
margin-left: 8px; margin-left: 8px;
@@ -178,6 +194,11 @@ pre {
font-size: 13px; font-size: 13px;
} }
.poster-subtitle {
color: #57606a;
font-size: 12px;
}
.resource-list { .resource-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
@@ -193,8 +214,88 @@ pre {
padding: 10px; padding: 10px;
} }
.resource-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-top: 8px;
}
.link-btn {
border: none;
background: none;
color: #1f6feb;
padding: 0;
text-decoration: underline;
}
.link-btn:disabled {
text-decoration: none;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.detail-layout {
display: grid;
grid-template-columns: 240px 1fr;
gap: 16px;
margin-top: 12px;
}
.detail-poster,
.detail-no-poster {
width: 100%;
height: 340px;
border-radius: 10px;
border: 1px solid #d0d7de;
}
.detail-poster {
object-fit: cover;
}
.detail-no-poster {
display: flex;
align-items: center;
justify-content: center;
background: #f6f8fa;
color: #57606a;
}
.detail-content h3 {
margin-top: 2px;
margin-bottom: 8px;
}
.detail-subtitle {
margin-top: 0;
color: #57606a;
}
.detail-overview {
margin-top: 12px;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.grid { .grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.detail-layout {
grid-template-columns: 1fr;
}
.detail-poster,
.detail-no-poster {
max-width: 280px;
margin: 0 auto;
}
} }

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { fetchMediaDetail, ingestMediaResource } from "../api/backendApi"; import { fetchMediaDetail, ingestMediaResource } from "../api/backendApi";
@@ -12,6 +12,45 @@ const successMessage = ref("");
const media = ref(null); const media = ref(null);
const resources = ref([]); const resources = ref([]);
const searchQuery = computed(() => String(route.query.query || "").trim());
const searchType = computed(() => {
const type = String(route.query.type || "").trim();
return type === "tv" ? "tv" : "movie";
});
const displayTitle = computed(
() => media.value?.title || media.value?.originalTitle || "未命名影视",
);
const releaseYear = computed(() => media.value?.year || "未知年份");
const score = computed(() => {
const value = media.value?.rating;
return Number.isFinite(value) ? Number(value).toFixed(1) : "-";
});
const genresText = computed(() => {
const genres = media.value?.genres || [];
if (!Array.isArray(genres) || genres.length === 0) return "未知类型";
return genres.join(" / ");
});
function validationHint(res) {
if (res.validateStatus === "valid") {
return "";
}
if (res.validateStatus === "invalid") {
return `链接校验状态:无效${res.validateMessage ? `${res.validateMessage}` : ""}`;
}
if (res.validateStatus === "error") {
return `链接校验异常${res.validateMessage ? `${res.validateMessage}` : ""}`;
}
if (res.validateMessage) {
return `链接提示:${res.validateMessage}`;
}
return "";
}
function posterUrl(path) {
return path ? `https://image.tmdb.org/t/p/w500${path}` : "";
}
async function loadDetail() { async function loadDetail() {
loading.value = true; loading.value = true;
errorMessage.value = ""; errorMessage.value = "";
@@ -56,16 +95,50 @@ async function handleIngest(resource) {
} }
} }
function backToSearch() {
router.push({
name: "search",
query: {
query: searchQuery.value,
type: searchType.value,
},
});
}
onMounted(loadDetail); onMounted(loadDetail);
</script> </script>
<template> <template>
<section class="card"> <section class="card">
<h2>影视详情</h2> <div class="detail-header">
<h2>影视详情</h2>
<button class="secondary-btn" @click="backToSearch">返回搜索结果</button>
</div>
<p v-if="loading">加载中...</p> <p v-if="loading">加载中...</p>
<p v-if="errorMessage" class="error">{{ errorMessage }}</p> <p v-if="errorMessage" class="error">{{ errorMessage }}</p>
<p v-if="successMessage">{{ successMessage }}</p> <p v-if="successMessage">{{ successMessage }}</p>
<pre v-if="media">{{ JSON.stringify(media, null, 2) }}</pre> <div v-if="media" class="detail-layout">
<img
v-if="posterUrl(media.posterPath)"
class="detail-poster"
:src="posterUrl(media.posterPath)"
:alt="displayTitle"
/>
<div v-else class="detail-no-poster">暂无海报</div>
<div class="detail-content">
<h3>{{ displayTitle }}</h3>
<p v-if="media.originalTitle && media.originalTitle !== media.title" class="detail-subtitle">
原始标题{{ media.originalTitle }}
</p>
<div class="meta">
<span>{{ media.type === "tv" ? "剧集" : "电影" }}</span>
<span>{{ releaseYear }}</span>
<span>评分 {{ score }}</span>
<span>{{ genresText }}</span>
</div>
<p class="detail-overview">{{ media.overview || "暂无简介" }}</p>
</div>
</div>
</section> </section>
<section class="card"> <section class="card">
@@ -77,13 +150,28 @@ onMounted(loadDetail);
<span>{{ res.quality || "未知画质" }}</span> <span>{{ res.quality || "未知画质" }}</span>
<span>{{ res.diskType || "未知网盘" }}</span> <span>{{ res.diskType || "未知网盘" }}</span>
</div> </div>
<a href="#" @click.prevent="handleIngest(res)"> <p v-if="validationHint(res)" class="warning">{{ validationHint(res) }}</p>
{{ <div class="resource-actions">
ingestingSlug === res.slug <a v-if="res.detailUrl" :href="res.detailUrl" target="_blank" rel="noopener noreferrer">
? "入库中..." 打开 HDHive 资源页
: res.unlockUrl || "点击该资源链接执行入库" </a>
}} <a
</a> v-if="res.unlockUrl"
:href="res.unlockUrl"
target="_blank"
rel="noopener noreferrer"
>
打开解锁直链
</a>
<button
class="link-btn"
:disabled="ingestingSlug === res.slug"
@click="handleIngest(res)"
>
{{ ingestingSlug === res.slug ? "入库中..." : "使用该资源入库" }}
</button>
</div>
<p v-if="res.unlockError" class="error">解锁失败{{ res.unlockError }}</p>
</li> </li>
</ul> </ul>
</section> </section>

View File

@@ -1,8 +1,9 @@
<script setup> <script setup>
import { ref } from "vue"; import { onMounted, ref } from "vue";
import { useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { searchMedia } from "../api/backendApi"; import { searchMedia } from "../api/backendApi";
const route = useRoute();
const router = useRouter(); const router = useRouter();
const keyword = ref(""); const keyword = ref("");
const mediaType = ref("movie"); const mediaType = ref("movie");
@@ -15,15 +16,20 @@ function posterUrl(path) {
} }
async function handleSearch() { async function handleSearch() {
if (!keyword.value.trim()) { const query = keyword.value.trim();
if (!query) {
errorMessage.value = "请输入关键词"; errorMessage.value = "请输入关键词";
return; return;
} }
loading.value = true; loading.value = true;
errorMessage.value = ""; errorMessage.value = "";
try { try {
const data = await searchMedia(keyword.value.trim(), mediaType.value); const data = await searchMedia(query, mediaType.value);
results.value = data.items || []; results.value = data.items || [];
router.replace({
path: "/",
query: { query, type: mediaType.value },
});
} catch (error) { } catch (error) {
errorMessage.value = error.message || "搜索失败"; errorMessage.value = error.message || "搜索失败";
results.value = []; results.value = [];
@@ -33,8 +39,25 @@ async function handleSearch() {
} }
function goDetail(item) { function goDetail(item) {
router.push(`/media/${mediaType.value}/${item.id}`); const query = keyword.value.trim();
router.push({
name: "media-detail",
params: { type: mediaType.value, tmdbId: item.id },
query: { query, type: mediaType.value },
});
} }
onMounted(() => {
const query = String(route.query.query || "").trim();
const type = String(route.query.type || "").trim();
if (type === "movie" || type === "tv") {
mediaType.value = type;
}
if (query) {
keyword.value = query;
handleSearch();
}
});
</script> </script>
<template> <template>
@@ -71,6 +94,10 @@ function goDetail(item) {
/> />
<div v-else class="no-poster" @click="goDetail(item)">无海报</div> <div v-else class="no-poster" @click="goDetail(item)">无海报</div>
<div class="poster-title">{{ item.title }}</div> <div class="poster-title">{{ item.title }}</div>
<div class="poster-subtitle">
{{ item.releaseDate?.slice(0, 4) || "未知年份" }}
<span v-if="item.voteAverage"> · 评分 {{ Number(item.voteAverage).toFixed(1) }}</span>
</div>
</article> </article>
</div> </div>
</section> </section>