fix(search): resolve multiple issues in search and subscription flow

- Fix axios interceptor error when response.data is undefined by adding
  optional chaining (?.msg_en, ?.msg_zh)
- Fix XML parsing crash in backend by catching ParseError exceptions
- Remove brotli (br) from Accept-Encoding header to fix mikan RSS fetch
  issues (httpx doesn't auto-decompress brotli)
- Fix search card click event not triggering confirmation modal by
  changing from native 'click' to custom 'select' event with typed payload
- Add NMessageProvider to App.vue to fix useMessage() outside setup error
- Replace structuredClone with JSON.parse/stringify in confirm modal
  to handle Vue reactive Proxy objects

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Estrella Pan
2026-01-25 17:33:46 +01:00
parent da6e578404
commit 9e6a528e57
7 changed files with 266 additions and 79 deletions

View File

@@ -41,7 +41,11 @@ class RequestContent(RequestURL):
async def get_xml(self, _url, retry: int = 3) -> xml.etree.ElementTree.Element:
req = await self.get_url(_url, retry)
if req:
return xml.etree.ElementTree.fromstring(req.text)
try:
return xml.etree.ElementTree.fromstring(req.text)
except xml.etree.ElementTree.ParseError as e:
logger.warning(f"[Network] Failed to parse XML from {_url}: {e}")
return None
# API JSON
async def get_json(self, _url) -> dict:

View File

@@ -62,7 +62,7 @@ class RequestURL:
base_headers = {
"User-Agent": self.DEFAULT_UA,
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Encoding": "gzip, deflate",
"Connection": "keep-alive",
}
# For torrent files, use different Accept header

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { type GlobalThemeOverrides, NConfigProvider, darkTheme } from 'naive-ui';
import { type GlobalThemeOverrides, NConfigProvider, NMessageProvider, darkTheme } from 'naive-ui';
const { isDark } = useDarkMode();
const { refresh, isLoggedIn } = useAuth();
@@ -76,7 +76,9 @@ const naiveTheme = computed(() => isDark.value ? darkTheme : null);
<template>
<Suspense>
<NConfigProvider :theme="naiveTheme" :theme-overrides="themeOverrides">
<RouterView></RouterView>
<NMessageProvider>
<RouterView></RouterView>
</NMessageProvider>
</NConfigProvider>
</Suspense>
</template>

View File

@@ -6,7 +6,9 @@ const props = defineProps<{
bangumi: BangumiRule;
}>();
defineEmits(['click']);
const emit = defineEmits<{
(e: 'select', bangumi: BangumiRule): void;
}>();
const posterSrc = computed(() => resolvePosterUrl(props.bangumi.poster_link));
@@ -25,8 +27,8 @@ const seasonDisplay = computed(() => {
role="button"
tabindex="0"
:aria-label="`Add ${bangumi.official_title}`"
@click="$emit('click')"
@keydown.enter="$emit('click')"
@click="emit('select', bangumi)"
@keydown.enter="emit('select', bangumi)"
>
<!-- Poster -->
<div class="card-poster">

View File

@@ -1,7 +1,10 @@
<script lang="ts" setup>
import { CheckOne, Close, Copy, Down, ErrorPicture, Right } from '@icon-park/vue-next';
import { NDynamicTags, NSpin, useMessage } from 'naive-ui';
import type { BangumiRule } from '#/bangumi';
const message = useMessage();
const props = defineProps<{
bangumi: BangumiRule;
}>();
@@ -11,21 +14,47 @@ const emit = defineEmits<{
(e: 'cancel'): void;
}>();
const posterSrc = computed(() => resolvePosterUrl(props.bangumi.poster_link));
// Local deep copy of bangumi for editing (prevents mutation of original prop)
const localBangumi = ref<BangumiRule>(JSON.parse(JSON.stringify(props.bangumi)));
// Sync when props change
watch(() => props.bangumi, (newVal) => {
localBangumi.value = JSON.parse(JSON.stringify(newVal));
}, { deep: true });
const posterSrc = computed(() => resolvePosterUrl(localBangumi.value.poster_link));
const showAdvanced = ref(false);
const copied = ref(false);
const offsetLoading = ref(false);
// Format season display
const seasonDisplay = computed(() => {
if (props.bangumi.season_raw) {
return props.bangumi.season_raw;
// Info tags for display (just values, no labels)
const infoTags = computed(() => {
const tags: { value: string; type: string }[] = [];
const { season, season_raw, dpi, subtitle, group_name } = localBangumi.value;
if (season || season_raw) {
const seasonDisplay = season_raw || (season ? `${season}` : '');
tags.push({ value: seasonDisplay, type: 'season' });
}
return props.bangumi.season ? `Season ${props.bangumi.season}` : '';
if (dpi) {
tags.push({ value: dpi, type: 'resolution' });
}
if (subtitle) {
tags.push({ value: subtitle, type: 'subtitle' });
}
if (group_name) {
tags.push({ value: group_name, type: 'group' });
}
return tags;
});
// Copy RSS link
async function copyRssLink() {
const rssLink = props.bangumi.rss_link?.[0] || '';
const rssLink = localBangumi.value.rss_link?.[0] || '';
if (rssLink) {
await navigator.clipboard.writeText(rssLink);
copied.value = true;
@@ -35,8 +64,23 @@ async function copyRssLink() {
}
}
// Auto detect offset
async function autoDetectOffset() {
if (!localBangumi.value.id) return;
offsetLoading.value = true;
try {
const result = await apiBangumi.suggestOffset(localBangumi.value.id);
localBangumi.value.offset = result.suggested_offset;
} catch (e) {
console.error('Failed to detect offset:', e);
message.error('Failed to detect offset');
} finally {
offsetLoading.value = false;
}
}
function handleConfirm() {
emit('confirm', props.bangumi);
emit('confirm', localBangumi.value);
}
</script>
@@ -56,8 +100,8 @@ function handleConfirm() {
<!-- Bangumi Info -->
<div class="bangumi-info">
<div class="bangumi-poster">
<template v-if="bangumi.poster_link">
<img :src="posterSrc" :alt="bangumi.official_title" />
<template v-if="localBangumi.poster_link">
<img :src="posterSrc" :alt="localBangumi.official_title" />
</template>
<template v-else>
<div class="poster-placeholder">
@@ -66,42 +110,36 @@ function handleConfirm() {
</template>
</div>
<div class="bangumi-meta">
<h3 class="bangumi-title">{{ bangumi.official_title }}</h3>
<p v-if="bangumi.title_raw" class="bangumi-subtitle">{{ bangumi.title_raw }}</p>
<div class="bangumi-details">
<span v-if="bangumi.year">{{ bangumi.year }}</span>
<span v-if="seasonDisplay">{{ seasonDisplay }}</span>
</div>
<h3 class="bangumi-title">{{ localBangumi.official_title }}</h3>
<p v-if="localBangumi.title_raw" class="bangumi-subtitle">{{ localBangumi.title_raw }}</p>
<p v-if="localBangumi.year" class="bangumi-year">{{ localBangumi.year }}</p>
</div>
</div>
<!-- Info rows -->
<div class="info-section">
<!-- Info Tags -->
<div v-if="infoTags.length > 0" class="info-tags">
<div
v-for="tag in infoTags"
:key="tag.type"
class="info-tag"
:class="`info-tag--${tag.type}`"
>
{{ tag.value }}
</div>
</div>
<!-- RSS Link -->
<div class="rss-section">
<div class="info-row">
<span class="info-label">{{ $t('search.confirm.rss') }}:</span>
<span class="info-value info-value--link">
{{ bangumi.rss_link?.[0] || '-' }}
{{ localBangumi.rss_link?.[0] || '-' }}
</span>
<button class="copy-btn" :class="{ copied }" @click="copyRssLink">
<CheckOne v-if="copied" theme="outline" size="14" />
<Copy v-else theme="outline" size="14" />
</button>
</div>
<div class="info-row">
<span class="info-label">{{ $t('search.confirm.group') }}:</span>
<span class="info-value">{{ bangumi.group_name || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">{{ $t('search.confirm.resolution') }}:</span>
<span class="info-value">{{ bangumi.dpi || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">{{ $t('search.confirm.subtitle') }}:</span>
<span class="info-value">{{ bangumi.subtitle || '-' }}</span>
</div>
</div>
<!-- Advanced settings -->
@@ -113,18 +151,33 @@ function handleConfirm() {
<transition name="expand">
<div v-show="showAdvanced" class="advanced-content">
<div class="info-row">
<span class="info-label">{{ $t('search.confirm.filter') }}:</span>
<span class="info-value info-value--code">
{{ bangumi.filter?.join(', ') || '-' }}
</span>
<!-- Filter rules row -->
<div class="advanced-row advanced-row--tags">
<label class="advanced-label">{{ $t('search.confirm.filter') }}</label>
<div class="advanced-control filter-tags">
<NDynamicTags v-model:value="localBangumi.filter" size="small" />
</div>
</div>
<div class="info-row">
<span class="info-label">{{ $t('search.confirm.save_path') }}:</span>
<span class="info-value info-value--code">
{{ bangumi.save_path || '-' }}
</span>
<!-- Offset row -->
<div class="advanced-row">
<label class="advanced-label">{{ $t('homepage.rule.offset') }}</label>
<div class="advanced-control offset-controls">
<input
v-model.number="localBangumi.offset"
type="number"
ab-input
class="offset-input"
/>
<button
class="detect-btn"
:disabled="offsetLoading"
@click="autoDetectOffset"
>
<NSpin v-if="offsetLoading" :size="14" />
<span v-else>{{ $t('homepage.rule.auto_detect') }}</span>
</button>
</div>
</div>
</div>
</transition>
@@ -275,23 +328,51 @@ function handleConfirm() {
white-space: nowrap;
}
.bangumi-details {
display: flex;
gap: 8px;
.bangumi-year {
font-size: 13px;
color: var(--color-text-secondary);
color: var(--color-text-muted);
margin: 4px 0 0;
}
span {
&:not(:last-child)::after {
content: '·';
margin-left: 8px;
color: var(--color-text-muted);
}
// Info Tags
.info-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.info-tag {
display: inline-flex;
align-items: center;
padding: 6px 14px;
border-radius: var(--radius-full);
font-size: 13px;
font-weight: 600;
&--season {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
}
&--resolution {
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
color: var(--color-accent);
}
&--subtitle {
background: color-mix(in srgb, var(--color-success) 12%, transparent);
color: var(--color-success);
}
&--group {
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
color: var(--color-warning);
}
}
// Info section
.info-section {
// RSS section
.rss-section {
display: flex;
flex-direction: column;
gap: 12px;
@@ -328,14 +409,6 @@ function handleConfirm() {
text-overflow: ellipsis;
white-space: nowrap;
}
&--code {
font-family: 'Fira Code', monospace;
font-size: 12px;
background: var(--color-surface);
padding: 4px 8px;
border-radius: var(--radius-sm);
}
}
.copy-btn {
@@ -398,6 +471,86 @@ function handleConfirm() {
margin-top: 8px;
}
.advanced-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 32px;
&--tags {
align-items: flex-start;
}
}
.advanced-label {
flex-shrink: 0;
font-size: 13px;
font-weight: 500;
color: var(--color-text-secondary);
line-height: 32px;
}
.advanced-control {
display: flex;
justify-content: flex-end;
:deep(.n-dynamic-tags) {
justify-content: flex-end;
min-height: 32px;
.n-tag {
height: 28px;
margin: 2px 0 2px 6px !important;
}
.n-button {
height: 28px;
}
}
}
.offset-controls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
height: 32px;
}
.offset-input {
width: 70px;
height: 32px;
text-align: center;
}
.detect-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 80px;
height: 32px;
padding: 0 14px;
font-size: 13px;
font-family: inherit;
font-weight: 500;
color: #fff;
background: var(--color-primary);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
white-space: nowrap;
transition: background-color var(--transition-fast);
&:hover:not(:disabled) {
background: var(--color-primary-hover);
}
&:disabled {
cursor: wait;
}
}
// Expand transition
.expand-enter-active,
.expand-leave-active {

View File

@@ -9,9 +9,11 @@ import type { BangumiRule } from '#/bangumi';
const emit = defineEmits<{
(e: 'close'): void;
(e: 'add-bangumi', bangumi: BangumiRule): void;
}>();
const message = useMessage();
const { getAll } = useBangumiStore();
const {
providers,
provider,
@@ -35,6 +37,8 @@ const {
clearSelectedResult,
} = useSearchStore();
const subscribing = ref(false);
const showProvider = ref(false);
const searchInputRef = ref<HTMLInputElement | null>(null);
@@ -64,10 +68,32 @@ function handleCardClick(bangumi: BangumiRule) {
selectResult(bangumi);
}
function handleConfirm(bangumi: BangumiRule) {
emit('add-bangumi', bangumi);
clearSelectedResult();
emit('close');
async function handleConfirm(bangumi: BangumiRule) {
subscribing.value = true;
try {
// Create RSS object from bangumi data
const rss = {
id: 0,
name: bangumi.official_title,
url: bangumi.rss_link?.[0] || '',
aggregate: false,
parser: 'mikan',
enabled: true,
connection_status: null,
last_checked_at: null,
last_error: null,
};
await apiDownload.subscribe(bangumi, rss);
message.success('订阅成功');
getAll();
clearSelectedResult();
emit('close');
} catch (e) {
console.error('Subscribe failed:', e);
message.error('订阅失败');
} finally {
subscribing.value = false;
}
}
function handleClose() {
@@ -175,7 +201,7 @@ function handleClose() {
:key="result.order"
:bangumi="result.value"
:style="{ '--stagger-index': index }"
@click="handleCardClick(result.value)"
@select="handleCardClick"
/>
</transition-group>
</div>

View File

@@ -10,8 +10,8 @@ axios.interceptors.response.use(
(res: AxiosResponse) => res,
(err: AxiosError<ApiSuccess>) => {
const status = err.response?.status as StatusCode;
const msg_en = err.response?.data.msg_en ?? '';
const msg_zh = err.response?.data.msg_zh ?? '';
const msg_en = err.response?.data?.msg_en ?? '';
const msg_zh = err.response?.data?.msg_zh ?? '';
const message = useMessage();
const { returnUserLangText } = useMyI18n();