Files
BYW-VoiceHub/app/components/Songs/RequestForm.vue
T

7267 lines
204 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="request-form">
<div class="rules-section desktop-only-rules">
<h2 class="rules-title">
<Icon :size="16" class="rules-icon" name="bell" />
{{ locale.guidelinesTitle }}
</h2>
<div class="rules-content-desktop">
<div
v-if="submissionGuidelines"
class="guidelines-content markdown-body"
v-html="renderedGuidelines"
/>
<div v-else class="default-guidelines">
<p v-for="(rule, index) in locale.defaultGuidelines" :key="`desktop-rule-${index}`">
{{ index + 1 }}. {{ rule }}
</p>
</div>
</div>
</div>
<!-- 移动端投稿须知 -->
<div class="rules-section mobile-only-rules">
<h3 class="rules-title">
<Icon :size="16" class="rules-icon" name="bell" />
{{ locale.guidelinesTitle }}
</h3>
<div class="rules-content">
<div
v-if="submissionGuidelines"
class="guidelines-content markdown-body"
v-html="renderedGuidelines"
/>
<div v-else class="default-guidelines">
<div
v-for="(rule, index) in locale.mobileDefaultGuidelines"
:key="`mobile-rule-${index}`"
class="rule-item"
>
<span>{{ index + 1 }}.</span> {{ rule }}
</div>
</div>
</div>
</div>
<div class="form-container">
<form class="song-request-form" @submit.prevent="handleSearch">
<!-- 顶部行:搜索和联合投稿 -->
<div class="form-header-row">
<!-- 歌曲搜索区域 -->
<div class="search-section">
<div class="search-label">{{ locale.searchLabel }}</div>
<div class="search-input-group">
<input
id="title"
v-model="title"
class="search-input"
:placeholder="locale.searchPlaceholder"
enterkeyhint="search"
required
type="text"
@keydown.enter.prevent="handleSearch"
>
<button
:disabled="loading || searching || !title.trim()"
class="search-button"
type="submit"
>
{{ loading || searching ? locale.processing : locale.search }}
</button>
<button
:disabled="loading || searching"
:aria-label="locale.audioMatch"
class="audio-match-btn"
:title="locale.audioMatch"
type="button"
@click="openAudioMatchModal"
>
<Icon :size="16" name="mic" />
<span class="btn-text">{{ locale.audioMatchShort }}</span>
</button>
</div>
<button
v-if="showImportSemesterBtn"
class="import-semester-btn"
type="button"
:title="locale.importFromPast"
@click="showImportSongsModal = true"
>
<Icon :size="16" name="history" />
<span class="btn-text">{{ locale.importFromPast }}</span>
</button>
</div>
<!-- 联合投稿人区域 -->
<div v-if="user && enableCollaborativeSubmission" class="collaborators-section">
<div class="section-label">{{ locale.collaborators }}</div>
<div class="collaborators-list">
<div v-for="user in collaborators" :key="user.id" class="collaborator-tag">
<span class="collaborator-name">{{ user.name }}</span>
<button
class="remove-collaborator"
type="button"
@click="removeCollaborator(user.id)"
>
<Icon :size="12" name="close" />
</button>
</div>
<button
class="add-collaborator-btn"
type="button"
@click="showUserSearchModal = true"
>
<Icon :size="14" name="plus" />
{{ locale.add }}
</button>
</div>
</div>
</div>
<!-- 搜索结果容器 -->
<div class="search-results-container">
<div v-if="!user" class="submission-status-horizontal login-required-notice">
<Lock class="notice-icon" :size="14" />
<span class="notice-text">{{ locale.loginRequiredNotice }}</span>
<button class="login-link-btn" type="button" @click="handleLoginRedirect">
{{ locale.loginNow }}
</button>
</div>
<!-- 投稿状态显示 - 横向布局,只在设置了限额时显示 -->
<div
v-if="
user &&
submissionStatus &&
(submissionStatus.limitEnabled ||
submissionStatus.timeLimitationEnabled ||
submissionStatus.submissionClosed)
"
class="submission-status-horizontal"
>
<!-- 超级管理员提示 -->
<div
v-if="user && (user.role === 'SUPER_ADMIN' || user.role === 'ADMIN')"
class="admin-notice-horizontal"
>
<span class="admin-icon">👑</span>
<span class="admin-text">{{ locale.adminUnlimited }}</span>
</div>
<!-- 投稿关闭提示 -->
<div v-else-if="submissionStatus.submissionClosed" class="submission-closed-notice">
<span class="closed-icon">🚫</span>
<span class="closed-text">
{{
submissionStatus.timeLimitationEnabled && !submissionStatus.currentTimePeriod
? locale.outsideRequestTime
: locale.submissionClosed
}}
</span>
</div>
<!-- 投稿状态内容 -->
<div v-else class="status-content-horizontal">
<!-- 当前时段信息 -->
<div
v-if="submissionStatus.timeLimitationEnabled && submissionStatus.currentTimePeriod"
class="status-item-horizontal"
>
<span class="status-label">{{ locale.currentPeriod }}</span>
<span class="status-value">{{ submissionStatus.currentTimePeriod.name }}</span>
<span
v-if="submissionStatus.currentTimePeriod.expected > 0"
class="status-remaining"
>
({{ locale.accepted }} {{ submissionStatus.currentTimePeriod.accepted }} /
{{ submissionStatus.currentTimePeriod.expected }})
</span>
</div>
<div v-if="submissionStatus.dailyLimit" class="status-item-horizontal">
<span class="status-label">{{ locale.todayRequests }}</span>
<span class="status-value"
>{{ submissionStatus.dailyUsed }} / {{ submissionStatus.dailyLimit }}</span
>
<span class="status-remaining"
>{{ locale.remaining }}
{{ Math.max(0, submissionStatus.dailyLimit - submissionStatus.dailyUsed) }}</span
>
</div>
<div v-if="submissionStatus.weeklyLimit" class="status-item-horizontal">
<span class="status-label">{{ locale.weeklyRequests }}</span>
<span class="status-value"
>{{ submissionStatus.weeklyUsed }} / {{ submissionStatus.weeklyLimit }}</span
>
<span class="status-remaining"
>{{ locale.remaining }}
{{
Math.max(0, submissionStatus.weeklyLimit - submissionStatus.weeklyUsed)
}}</span
>
</div>
<div v-if="submissionStatus.monthlyLimit" class="status-item-horizontal">
<span class="status-label">{{ locale.monthlyRequests }}</span>
<span class="status-value"
>{{ submissionStatus.monthlyUsed }} / {{ submissionStatus.monthlyLimit }}</span
>
<span class="status-remaining"
>{{ locale.remaining }}
{{
Math.max(0, submissionStatus.monthlyLimit - submissionStatus.monthlyUsed)
}}</span
>
</div>
<div v-if="cardCodeLimitBypassActive" class="status-item-horizontal">
<span class="status-label">{{ locale.cardCodeLabel }}</span>
<span class="status-value">{{ locale.cardCodeBypassesLimit }}</span>
</div>
</div>
</div>
<!-- 音乐平台选择按钮 -->
<div v-if="platformConfigLoaded" class="platform-selection-container">
<div class="platform-selection">
<button
v-for="pKey in availablePlatforms"
:key="pKey"
:class="['platform-btn', { active: platform === pKey }]"
type="button"
@click="switchPlatform(pKey)"
>
{{ locale.platforms[pKey] || pKey }}
</button>
</div>
<!-- 网易云音乐登录状态和选项 -->
<div v-if="platform === 'netease'" class="netease-options">
<!-- 加载中状态 -->
<div v-if="checkingNeteaseLogin" class="netease-loading-state">
<div class="loading-content">
<div class="loading-spinner" />
<span class="loading-text">{{ locale.refreshing }}</span>
</div>
</div>
<!-- 未登录状态 -->
<div v-else-if="!isNeteaseLoggedIn" class="login-entry">
<div class="login-desc">
<p class="login-title">{{ locale.neteaseLoginTitle }}</p>
</div>
<div class="login-actions">
<button class="login-btn" type="button" @click="showLoginModal = true">
{{ locale.loginNow }}
</button>
<button class="import-btn" type="button" @click="handleImportClick">
<Icon :size="14" name="upload" />
{{ locale.importData }}
</button>
</div>
</div>
<!-- 已登录状态 -->
<div v-else class="user-status">
<div class="user-compact-row">
<div class="user-profile">
<img
v-if="neteaseUser?.avatarUrl"
:src="convertToHttps(neteaseUser.avatarUrl)"
alt="avatar"
class="user-avatar"
>
<span class="user-name">{{ neteaseUser?.nickname || locale.loggedIn }}</span>
</div>
<div class="search-type-switch">
<label :class="['radio-label', { active: searchType === 1 }]">
<input v-model="searchType" :value="1" type="radio" > {{ locale.single }}
</label>
<label :class="['radio-label', { active: searchType === 1009 }]">
<input v-model="searchType" :value="1009" type="radio" > {{ locale.podcast }}
</label>
</div>
<div class="user-actions-row">
<button
class="action-btn-compact"
:title="locale.recent"
type="button"
@click="showRecentSongsModal = true"
>
<Icon :size="14" name="history" />
<span>{{ locale.recent }}</span>
</button>
<button
class="action-btn-compact"
:title="locale.playlist"
type="button"
@click="openPlaylistModal('netease')"
>
<Icon :size="14" name="playlist" />
<span>{{ locale.playlist }}</span>
</button>
<button
class="action-btn-compact"
:aria-label="locale.exportCookie"
:title="locale.exportCookie"
type="button"
@click="handleExportData"
>
<Icon :size="14" name="download" />
</button>
<button
class="action-btn-compact text-error hover:text-error hover:bg-error-10"
:aria-label="locale.logout"
:title="locale.logout"
type="button"
@click="handleLogoutNetease"
>
<Icon :size="14" name="logout" />
</button>
</div>
</div>
</div>
</div>
<!-- QQ音乐登录状态和选项 -->
<div v-if="platform === 'tencent'" class="netease-options">
<!-- 检查中状态 -->
<div v-if="checkingQQLogin" class="netease-loading-state">
<div class="loading-content">
<div class="loading-spinner" />
<span class="loading-text">{{ locale.refreshing }}</span>
</div>
</div>
<!-- 未登录状态 -->
<div v-else-if="!isQQMusicLoggedIn" class="login-entry">
<div class="login-desc">
<p class="login-title">{{ locale.qqLoginTitle }}</p>
</div>
<div class="login-actions">
<button
class="login-btn qq-login-btn"
type="button"
@click="showQQLoginModal = true"
>
{{ locale.loginNow }}
</button>
<button class="import-btn" type="button" @click="handleImportClickQQ">
<Icon :size="14" name="upload" />
{{ locale.importData }}
</button>
</div>
</div>
<!-- 已登录状态 -->
<div v-else class="user-status">
<div class="user-compact-row">
<div class="user-profile">
<img
v-if="qqMusicUser?.avatarUrl"
:src="convertToHttps(qqMusicUser.avatarUrl)"
alt="avatar"
class="user-avatar"
>
<div v-else class="qq-user-avatar">
<Icon :size="14" name="music" />
</div>
<span class="user-name">{{ qqMusicUser?.nickname || locale.qqLoggedIn }}</span>
</div>
<div class="user-actions-row">
<button
class="action-btn-compact"
:title="locale.playlist"
type="button"
@click="openPlaylistModal('tencent')"
>
<Icon :size="14" name="playlist" />
<span>{{ locale.playlist }}</span>
</button>
<button
class="action-btn-compact"
:aria-label="locale.exportCookie"
:title="locale.exportCookie"
type="button"
@click="handleExportDataQQ"
>
<Icon :size="14" name="download" />
</button>
<button
class="action-btn-compact text-error hover:bg-error-10 hover:text-error"
:aria-label="locale.logoutQQ"
:title="locale.logoutQQ"
type="button"
@click="handleLogoutQQMusic"
>
<Icon :size="14" name="logout" />
</button>
</div>
</div>
</div>
</div>
</div>
<div class="results-content">
<!-- 播出时段、备注和点歌券 -->
<div v-if="playTimeSelectionEnabled" class="form-row mb-4">
<!-- 播出时段选择 -->
<div class="form-group">
<div class="input-wrapper">
<CustomSelect
v-model="preferredPlayTimeId"
:options="formattedPlayTimes"
:label="locale.preferredPlayTime"
label-key="displayName"
value-key="id"
:placeholder="locale.choosePlayTime"
/>
</div>
</div>
</div>
<div
v-if="enableSubmissionRemarks || cardCodeEnabled"
class="form-row submission-meta-row"
>
<div v-if="enableSubmissionRemarks" class="form-group submission-note-group">
<div class="input-wrapper">
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
<label
for="submission-note"
class="text-[12px] font-bold text-text-secondary"
>{{ locale.submissionNote }}</label
>
<div class="flex items-center gap-2">
<button
v-if="cardCodeEnabled"
:class="[
'mobile-card-code-chip',
trimmedCardCode
? cardCodeValidation.valid
? 'is-valid'
: cardCodeValidation.valid === false
? 'is-invalid'
: 'has-code'
: cardCodeFieldMeta.required
? 'is-required'
: ''
]"
type="button"
@click="openCardCodeModal"
>
<Icon
:size="12"
:name="
cardCodeValidation.checking
? 'loader'
: trimmedCardCode
? cardCodeValidation.valid === false
? 'close'
: 'check'
: 'plus'
"
/>
<span>{{ mobileCardCodeLabel }}</span>
</button>
<label class="custom-checkbox-wrapper">
<input
v-model="submissionNotePublic"
type="checkbox"
class="custom-checkbox-input"
:disabled="submissionNoteRequiresApproval || !siteConfigLoaded"
/>
<span class="custom-checkbox-box">
<svg
class="custom-checkbox-icon"
viewBox="0 0 12 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1 5L4.5 8.5L11 1.5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<span class="custom-checkbox-text">{{ locale.publicToUsers }}</span>
</label>
</div>
</div>
<textarea
id="submission-note"
v-model="submissionNote"
maxlength="300"
class="w-full min-h-[60px] rounded-xl border border-border-secondary bg-bg-secondary-60 px-4 py-2 text-sm text-text-primary focus:outline-none focus:border-primary-50 focus:ring-1 focus:ring-primary-10 resize-y transition-all"
/>
<div class="mt-1 flex items-center justify-between gap-2 text-[11px] text-text-tertiary">
<p v-if="submissionNoteRequiresApproval" class="flex min-w-0 items-center gap-1">
<Icon name="info" size="12" class="shrink-0 text-warning" />
<span class="truncate">{{ locale.submissionNoteApprovalHint }}</span>
</p>
<span class="shrink-0">{{ submissionNote.length }}/300</span>
</div>
</div>
</div>
<div
v-if="cardCodeEnabled"
:class="[
'form-group card-code-form-group',
{ 'mobile-hidden-card-code-group': enableSubmissionRemarks }
]"
>
<div class="desktop-card-code-panel">
<div class="flex min-w-0 items-center gap-2">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-black text-text-primary">{{
locale.cardCode
}}</span>
<span
:class="[
'rounded-full border px-1.5 py-0.5 text-[9px] font-black',
cardCodeFieldMeta.required
? 'border-warning-30 bg-warning-10 text-warning-300'
: 'border-border-tertiary bg-bg-tertiary-70 text-text-tertiary'
]"
>
{{ cardCodeFieldMeta.required ? locale.required : locale.optional }}
</span>
</div>
<p
:class="[
'mt-1 truncate text-[11px]',
cardCodeValidation.valid
? 'text-success-300'
: cardCodeValidation.valid === false
? 'text-error-80'
: cardCodeFieldMeta.required
? 'text-warning-300'
: 'text-text-tertiary'
]"
>
{{ cardCodeStatusText }}
</p>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<button
class="inline-flex h-9 items-center justify-center gap-1.5 rounded-lg border border-warning-25 bg-warning-10 px-3 text-xs font-black text-warning-200 transition-all hover:border-warning-40 hover:bg-warning-15"
type="button"
@click="openCardCodeModal"
>
<Icon :size="13" :name="trimmedCardCode ? 'edit' : 'plus'" />
{{ trimmedCardCode ? locale.editCardCode : locale.addCardCode }}
</button>
<button
v-if="trimmedCardCode"
class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-border-secondary bg-bg-secondary-80 text-text-tertiary transition-all hover:border-error-30 hover:text-error"
:title="locale.clearCardCode"
type="button"
@click="clearCardCode"
>
<Icon :size="13" name="close" />
</button>
</div>
</div>
<button
v-if="!enableSubmissionRemarks"
:class="[
'mobile-card-code-button',
trimmedCardCode
? cardCodeValidation.valid
? 'is-valid'
: cardCodeValidation.valid === false
? 'is-invalid'
: 'has-code'
: cardCodeFieldMeta.required
? 'is-required'
: ''
]"
type="button"
@click="openCardCodeModal"
>
<span class="flex min-w-0 items-center gap-2">
<Icon
:size="14"
:name="
cardCodeValidation.checking
? 'loader'
: trimmedCardCode
? cardCodeValidation.valid === false
? 'close'
: 'check'
: 'plus'
"
/>
<span class="truncate">{{ cardCodeStatusText }}</span>
</span>
<Icon :size="13" name="chevron-right" />
</button>
</div>
</div>
<!-- 加载状态 -->
<div v-if="searching" class="loading-state">
<AppSpinner :size="40" />
<p class="loading-text">{{ locale.processing }}</p>
</div>
<!-- 搜索结果列表 -->
<Transition mode="out-in" name="results-fade">
<div v-if="searchResults.length > 0 && !searching" key="results" class="results-list">
<TransitionGroup class="results-grid" name="result-item" tag="div">
<div
v-for="(result, index) in searchResults"
:key="`${platform}-${result.id || index}`"
class="result-item group"
>
<div
class="result-cover"
@click.stop="isBilibiliMultiP(result) ? submitSong(result) : playSong(result)"
>
<img
:src="convertToHttps(result.cover)"
:alt="locale.coverAlt"
class="cover-img"
referrerpolicy="no-referrer"
>
<div v-if="!isBilibiliMultiP(result)" class="play-overlay-container">
<div class="play-button-wrapper">
<Icon name="play" :size="20" class="play-icon" />
</div>
</div>
</div>
<div class="result-info">
<h4 class="result-title">{{ result.song || result.title }}</h4>
<p class="result-artist">{{ result.singer || result.artist }}</p>
<p
v-if="result.album"
:class="['result-album', { 'clickable-album': isNeteaseAlbum(result) }]"
:title="isNeteaseAlbum(result) ? locale.albumDetailsTitle : result.album"
@click.stop="isNeteaseAlbum(result) ? openAlbumDetails(result) : null"
>
<span class="album-label">{{ locale.album }}</span>
<span class="album-name">{{ result.album }}</span>
<Icon
v-if="isNeteaseAlbum(result)"
name="external-link"
:size="12"
class="album-link-icon"
/>
</p>
</div>
<div class="result-actions">
<!-- QQ音乐上传到网易云按钮(仅当结果确实来自QQ音乐时显示) -->
<button
v-if="isTencentSource(result)"
class="cloud-disk-btn"
:title="locale.uploadToNeteaseCloud"
@click.stop.prevent="openUploadDialog(result)"
>
<Icon name="cloud-upload" :size="18" />
</button>
<!-- 多P视频的特殊处理 -->
<div
v-if="
isBilibiliMultiP(result) && getBilibiliEpisodeStatus(result)?.allSubmitted
"
class="similar-song-info"
>
<span class="similar-text">{{ locale.allEpisodesSubmitted }}</span>
<button
v-if="canResubmitBilibiliEpisodes(result)"
:disabled="
!canSubmitFromSearch || isSongBlockedByRestriction(result) || submitting
"
class="select-btn"
@click.stop.prevent="submitSong(result, { replayRequest: true })"
>
{{ locale.chooseEpisodes }}
</button>
</div>
<div
v-else-if="
isBilibiliMultiP(result) &&
getBilibiliEpisodeStatus(result)?.partialSubmitted
"
class="similar-song-info"
>
<span class="similar-text">{{ locale.partialEpisodesSubmitted }}</span>
<button
:disabled="
!canSubmitFromSearch || isSongBlockedByRestriction(result) || submitting
"
class="select-btn"
@click.stop.prevent="submitSong(result)"
>
{{ locale.chooseEpisodes }}
</button>
</div>
<div v-else-if="getSimilarSong(result)" class="similar-song-info">
<!-- 根据歌曲状态显示不同的文本 -->
<span
v-if="getSimilarSong(result)?.played"
class="similar-text status-played"
>
{{ locale.songPlayed }}
</span>
<span
v-else-if="getSimilarSong(result)?.scheduled"
class="similar-text status-scheduled"
>{{ locale.songScheduled }}</span
>
<span v-else class="similar-text">{{ locale.songExists }}</span>
<!-- 管理员或未开启重复投稿限制时可直接重复投稿;否则普通用户仅可申请重播已播放歌曲。 -->
<button
v-if="auth.isAdmin.value || (siteConfigLoaded && !enableSubmissionRestriction)"
:disabled="
!canSubmitFromSearch || isSongBlockedByRestriction(result) || submitting
"
class="select-btn"
@click.stop.prevent="submitSong(result)"
>
{{ locale.chooseSubmit }}
</button>
<button
v-else-if="getSimilarSong(result)?.played && enableReplayRequests"
:disabled="
!canSubmitFromSearch || isSongBlockedByRestriction(result) || submitting
"
class="select-btn"
@click.stop.prevent="submitSong(result, { replayRequest: true })"
>
{{ locale.requestReplay }}
</button>
<!-- 其他用户:显示点赞按钮,根据状态设置不同样式 -->
<button
v-else
:class="{
disabled:
getSimilarSong(result)?.played ||
getSimilarSong(result)?.scheduled ||
getSimilarSong(result)?.voted ||
submitting
}"
:disabled="
getSimilarSong(result)?.played ||
getSimilarSong(result)?.scheduled ||
getSimilarSong(result)?.voted ||
submitting
"
:title="
getSimilarSong(result)?.played
? locale.playedCannotLike
: getSimilarSong(result)?.scheduled
? locale.scheduledCannotLike
: getSimilarSong(result)?.voted
? locale.liked
: locale.like
"
class="like-btn"
@click.stop.prevent="
getSimilarSong(result)?.played || getSimilarSong(result)?.scheduled
? null
: handleLikeFromSearch(getSimilarSong(result), result)
"
>
<svg
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
/>
</svg>
{{
getSimilarSong(result)?.played
? locale.played
: getSimilarSong(result)?.scheduled
? locale.scheduled
: getSimilarSong(result)?.voted
? locale.liked
: locale.like
}}
</button>
</div>
<button
v-else-if="!user"
class="select-btn login-btn"
@click.stop.prevent="handleLoginRedirect"
>
{{ locale.loginRequiredToSubmit }}
</button>
<button
v-else
:disabled="
!canSubmitFromSearch || isSongBlockedByRestriction(result) || submitting
"
:title="
isSongBlockedByRestriction(result)
? getRestrictionMessage(getRestrictionReason(result))
: ''
"
class="select-btn"
@click.stop.prevent="submitSong(result)"
>
{{
submitting
? locale.processing
: platform === 'netease' && searchType === 1009
? locale.chooseProgram
: isBilibiliMultiP(result)
? locale.chooseEpisodes
: locale.chooseSubmit
}}
</button>
</div>
</div>
</TransitionGroup>
<!-- 手动输入按钮 -->
<div class="no-results-action">
<button
v-if="!user"
class="manual-submit-btn"
type="button"
@click="handleLoginRedirect"
>
{{ locale.loginRequiredToSubmit }}
</button>
<button
v-else
:disabled="!canSubmitFromSearch"
class="manual-submit-btn"
type="button"
@click="showManualModal = true"
>
{{ locale.manualSubmitLong }}
</button>
</div>
</div>
<!-- 空状态 -->
<div v-else-if="!searching && hasSearched" key="empty" class="empty-state">
<div class="empty-icon">🔍</div>
<p class="empty-text">{{ locale.noResults }}</p>
<p class="empty-hint">{{ locale.noResultsHint }}</p>
<button
v-if="!user"
class="manual-submit-btn"
type="button"
@click="handleLoginRedirect"
>
{{ locale.loginRequiredToSubmit }}
</button>
<button
v-else
:disabled="!canSubmitFromSearch"
class="manual-submit-btn"
type="button"
@click="showManualModal = true"
>
{{ locale.manualSubmit }}
</button>
</div>
<!-- 初始状态 -->
<div v-else-if="!searching" key="initial" class="initial-state">
<div class="search-illustration">
<img :alt="locale.searchSongsAlt" class="search-svg" :src="getSearchIcon()" >
</div>
</div>
</Transition>
</div>
</div>
</form>
</div>
<!-- 历史学期导入弹窗 -->
<ImportSongsModal
:show="showImportSongsModal"
@close="showImportSongsModal = false"
@import-success="handleImportSuccess"
/>
<!-- 网易云音乐登录弹窗 -->
<NeteaseLoginModal
:show="showLoginModal"
@close="showLoginModal = false"
@login-success="handleLoginSuccess"
/>
<!-- QQ音乐登录弹窗 -->
<QQMusicLoginModal
:show="showQQLoginModal"
@close="showQQLoginModal = false"
@login-success="handleQQLoginSuccess"
/>
<!-- 播客节目列表弹窗 -->
<PodcastEpisodesModal
ref="podcastModalRef"
:cookie="podcastCookie"
:radio-id="selectedPodcastId"
:radio-name="selectedPodcastName"
:show="showPodcastModal"
@close="showPodcastModal = false"
@play="handlePodcastPlay"
@submit="handlePodcastSubmit"
/>
<!-- Bilibili 剧集选择弹窗 -->
<BilibiliEpisodesModal
ref="bilibiliModalRef"
:show="showBilibiliEpisodesModal"
:video="selectedBilibiliVideo"
:episodes="bilibiliEpisodes"
:submitted-episodes="getBilibiliEpisodeStatus(selectedBilibiliVideo)?.submittedEpisodes || []"
:current-user-id="user?.id"
:allow-played-resubmit="enableReplayRequests"
@close="showBilibiliEpisodesModal = false"
@play="handleBilibiliEpisodePlay"
@submit="handleBilibiliEpisodeSelect"
@vote="handleEpisodeVote"
/>
<!-- 专辑详情弹窗 -->
<AlbumDetailsModal
ref="albumModalRef"
:show="showAlbumDetailsModal"
:album-id="selectedAlbumId"
:album-name="selectedAlbumName"
:platform="selectedAlbumPlatform"
:submitted-songs="songService.songs.value || []"
:current-user-id="user?.id"
@close="showAlbumDetailsModal = false"
@play="handleAlbumSongPlay"
@submit="handleAlbumSongSubmit"
@vote="handleAlbumSongVote"
/>
<!-- 上传到网易云音乐弹窗 -->
<NeteaseUploadDialog
:show="showUploadDialog"
:song="selectedUploadSong"
@close="showUploadDialog = false"
@show-login="handleShowLogin"
/>
<!-- 最近播放歌曲弹窗 -->
<RecentSongsModal
ref="recentSongsModalRef"
:cookie="neteaseCookie"
:show="showRecentSongsModal"
@close="showRecentSongsModal = false"
@play="handleRecentSongPlay"
@submit="handleRecentSongSubmit"
/>
<!-- 歌单选择弹窗 -->
<PlaylistSelectionModal
ref="playlistModalRef"
:cookie="playlistModalPlatform === 'tencent' ? qqMusicCookie : neteaseCookie"
:platform="playlistModalPlatform"
:show="showPlaylistModal"
:uid="neteaseUser?.userId || neteaseUser?.id"
@close="showPlaylistModal = false"
@play="handlePlaylistPlay"
@submit="handlePlaylistSubmit"
/>
<!-- 用户搜索弹窗 -->
<UserSearchModal
v-if="enableCollaborativeSubmission"
v-model:show="showUserSearchModal"
:exclude-ids="[user?.id, ...collaborators.map((u) => u.id)]"
:multiple="true"
:title="locale.addCollaboratorTitle"
@select="handleUserSelect"
/>
<!-- 点歌券输入弹窗 -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="showCardCodeModal"
class="fixed inset-0 z-[105] flex items-end justify-center bg-bg-primary-80 p-3 backdrop-blur-sm sm:items-center sm:p-6"
@click.self="closeCardCodeModal"
>
<div
class="w-full max-w-md overflow-hidden rounded-2xl border border-border-secondary bg-bg-secondary shadow-2xl"
@click.stop
>
<div
class="flex items-center justify-between border-b border-border-secondary-70 px-5 py-4"
>
<div>
<div class="flex items-center gap-2">
<h3 class="text-base font-black text-text-primary">{{ locale.cardCode }}</h3>
<span
:class="[
'rounded-full border px-1.5 py-0.5 text-[9px] font-black',
cardCodeFieldMeta.required
? 'border-warning-30 bg-warning-10 text-warning-300'
: 'border-border-tertiary bg-bg-tertiary-70 text-text-tertiary'
]"
>
{{ cardCodeFieldMeta.required ? locale.required : locale.optional }}
</span>
</div>
<p class="mt-1 text-[11px] text-text-tertiary">{{ cardCodeFieldMeta.helper }}</p>
</div>
<button
class="flex h-9 w-9 items-center justify-center rounded-xl bg-bg-tertiary-60 text-text-tertiary transition-all hover:bg-bg-tertiary hover:text-text-primary"
type="button"
@click="closeCardCodeModal"
>
<Icon :size="15" name="close" />
</button>
</div>
<div class="px-5 py-5">
<label
for="card-code-modal"
class="px-1 text-[10px] font-black uppercase tracking-widest text-text-disabled"
>
{{ locale.cardCodeLabel }}
</label>
<input
id="card-code-modal"
ref="cardCodeInputRef"
v-model="cardCodeDraft"
:placeholder="cardCodeFieldMeta.placeholder"
class="mt-2 w-full rounded-xl border border-border-secondary bg-bg-primary px-4 py-3 text-sm font-bold text-text-primary placeholder-text-disabled transition-all focus:border-warning-50 focus:outline-none focus:ring-1 focus:ring-warning-10"
type="text"
@keydown.enter.prevent="saveCardCode"
>
<p
:class="[
'mt-2 px-1 text-[11px]',
cardCodeValidation.valid
? 'text-success-300'
: cardCodeValidation.valid === false
? 'text-error-80'
: 'text-text-tertiary'
]"
>
{{ cardCodeModalHint }}
</p>
</div>
<div
class="flex flex-col-reverse gap-2 border-t border-border-secondary-70 bg-bg-secondary-70 px-5 py-4 sm:flex-row sm:justify-end"
>
<button
class="rounded-lg px-4 py-2.5 text-xs font-bold text-text-tertiary transition-all hover:text-text-secondary"
type="button"
@click="closeCardCodeModal"
>
{{ locale.cancel }}
</button>
<button
v-if="trimmedCardCode"
class="rounded-lg border border-border-secondary bg-bg-primary px-4 py-2.5 text-xs font-bold text-text-tertiary transition-all hover:border-error-30 hover:text-error"
type="button"
@click="clearCardCode"
>
{{ locale.clear }}
</button>
<button
:disabled="cardCodeValidation.checking"
class="rounded-lg bg-warning px-5 py-2.5 text-xs font-black text-text-primary transition-all hover:bg-warning disabled:cursor-not-allowed disabled:opacity-60"
type="button"
@click="saveCardCode"
>
{{ cardCodeValidation.checking ? locale.validatingCardCode : locale.save }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
<Teleport to="body">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="showAudioMatchModal"
class="fixed inset-0 z-[110] flex items-center justify-center p-4 sm:p-6 bg-bg-primary-85 backdrop-blur-sm"
>
<div
class="relative w-full max-w-md bg-bg-secondary border border-border-secondary rounded-3xl shadow-2xl flex flex-col max-h-[85vh] overflow-hidden"
@click.stop
>
<div class="px-8 py-7 flex flex-col items-center text-center">
<div class="relative">
<div
class="w-20 h-20 rounded-full flex items-center justify-center transition-all duration-500"
:class="
audioMatchRecording
? 'bg-error-20 text-error scale-110'
: audioMatchError
? 'bg-bg-tertiary-50 text-text-tertiary'
: 'bg-primary-10 text-primary'
"
>
<Icon :size="32" name="mic" />
</div>
<div
v-if="audioMatchRecording"
class="absolute inset-0 rounded-full border-2 border-error animate-ping opacity-30"
/>
</div>
<h3 class="mt-6 text-xl font-bold text-text-primary tracking-tight">
{{ locale.audioMatch }}
</h3>
<p class="mt-2 text-sm text-text-tertiary max-w-[260px]">
{{
audioMatchStatus || (audioMatchError ? audioMatchError : locale.audioMatchHint)
}}
</p>
<div class="mt-8 flex items-center gap-4">
<button
v-if="!audioMatchRecording"
:disabled="audioMatchPreparing || audioMatchProcessing"
class="audio-match-primary-btn"
type="button"
@click="startAudioMatchRecording"
>
{{
audioMatchPreparing
? locale.preparing
: audioMatchProcessing
? locale.recognizing
: locale.startAudioMatch
}}
</button>
<button
v-else
:disabled="!audioMatchRecording"
class="audio-match-record-btn"
type="button"
@click="stopAudioMatchRecording"
>
<span class="recording-dot" />
{{ locale.recording }}
</button>
<button class="audio-match-cancel-btn" type="button" @click="closeAudioMatchModal">
{{ locale.cancel }}
</button>
</div>
</div>
<div
v-if="audioMatchResults.length"
class="flex-1 overflow-y-auto px-6 pb-6 custom-scrollbar"
>
<div class="border-t border-border-secondary-60 pt-5">
<h4 class="text-sm font-semibold text-text-secondary mb-4">
{{ locale.audioMatchResults }}
</h4>
<div class="space-y-2">
<button
v-for="match in audioMatchResults"
:key="match.key"
class="audio-match-result-item group/item w-full"
type="button"
@click="useAudioMatchResult(match)"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<div
class="relative shrink-0 w-11 h-11 rounded-xl overflow-hidden group/cover bg-bg-tertiary-50 flex items-center justify-center"
@click.stop="playAudioMatchResult(match)"
>
<img
v-if="match.cover"
:src="match.cover"
class="w-full h-full object-cover"
>
<Music v-else class="w-5 h-5 text-text-tertiary" />
<div
class="absolute inset-0 bg-bg-primary-50 opacity-0 group-hover/cover:opacity-100 flex items-center justify-center transition-all"
>
<Play class="w-4 h-4 text-text-primary fill-white" />
</div>
</div>
<div class="min-w-0 text-left flex-1">
<p class="truncate text-sm font-medium text-text-primary">
{{ match.name }}
</p>
<p class="truncate text-xs text-text-tertiary mt-0.5">{{ match.artist }}</p>
</div>
<div class="shrink-0 text-right">
<span class="text-[11px] font-mono text-primary">
{{ (match.startTime / 1000).toFixed(1) }}s
</span>
</div>
</div>
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- 手动输入弹窗 -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="showManualModal"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6 bg-bg-primary-80 backdrop-blur-sm"
@click.self="showManualModal = false"
>
<div
class="relative w-full max-w-2xl bg-bg-secondary border border-border-secondary rounded-2xl shadow-2xl flex flex-col max-h-[90vh] overflow-hidden"
@click.stop
>
<!-- Header -->
<div
class="px-8 py-6 border-b border-border-secondary-50 flex items-center justify-between shrink-0"
>
<div class="flex items-center gap-4">
<div
class="w-12 h-12 rounded-2xl bg-primary-hover-10 flex items-center justify-center text-primary"
>
<Edit3 :size="24" />
</div>
<h3 class="text-xl font-black text-text-primary tracking-tight">
{{ locale.manualTitle }}
</h3>
</div>
<button
class="w-10 h-10 flex items-center justify-center rounded-xl bg-bg-tertiary-50 text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary transition-all"
@click="showManualModal = false"
>
<X class="w-5 h-5" />
</button>
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto p-8 custom-scrollbar">
<div class="grid grid-cols-1 gap-6">
<!-- 歌曲名称 -->
<div class="space-y-2">
<label
class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.songName }}</label
>
<div class="relative group">
<input
:value="title"
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-3 text-sm text-text-tertiary font-bold focus:outline-none cursor-not-allowed transition-all"
readonly
type="text"
>
<div class="absolute inset-y-0 right-4 flex items-center pointer-events-none">
<Lock class="w-4 h-4 text-text-disabled" />
</div>
</div>
</div>
<!-- 歌手名称 -->
<div class="space-y-2">
<label
for="modal-artist"
class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.artistName }}</label
>
<input
id="modal-artist"
v-model="manualArtist"
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-3 text-sm text-text-primary placeholder-text-disabled focus:outline-none focus:border-primary-30 transition-all"
:placeholder="locale.artistPlaceholder"
required
type="text"
>
</div>
<!-- 歌曲封面地址 -->
<div class="space-y-2">
<label
for="modal-cover"
class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.coverUrl }}</label
>
<div class="relative group">
<input
id="modal-cover"
v-model="manualCover"
:class="[
'w-full bg-bg-primary border rounded-xl px-4 py-3 text-sm text-text-primary placeholder-text-disabled focus:outline-none transition-all',
manualCover && !coverValidation.valid
? 'border-error-50 focus:border-error-50'
: 'border-border-secondary focus:border-primary-30'
]"
:placeholder="locale.coverPlaceholder"
type="url"
>
<div
v-if="coverValidation.validating"
class="absolute inset-y-0 right-4 flex items-center"
>
<Loader2 class="w-4 h-4 text-text-tertiary animate-spin" />
</div>
</div>
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-1"
enter-to-class="opacity-100 translate-y-0"
>
<div v-if="manualCover && !coverValidation.validating" class="px-1 pt-1">
<p
v-if="!coverValidation.valid"
class="text-[10px] font-bold text-error-80 flex items-center gap-1"
>
<X class="w-3 h-3" /> {{ coverValidation.error }}
</p>
<p
v-else
class="text-[10px] font-bold text-success-80 flex items-center gap-1"
>
<Check class="w-3 h-3" /> {{ locale.validUrl }}
</p>
</div>
</Transition>
</div>
<!-- 播放地址 -->
<div class="space-y-2">
<label
for="modal-play-url"
class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.playUrl }}</label
>
<div class="relative group">
<input
id="modal-play-url"
v-model="manualPlayUrl"
:class="[
'w-full bg-bg-primary border rounded-xl px-4 py-3 text-sm text-text-primary placeholder-text-disabled focus:outline-none transition-all',
manualPlayUrl && !playUrlValidation.valid
? 'border-error-50 focus:border-error-50'
: 'border-border-secondary focus:border-primary-30'
]"
:placeholder="locale.playUrlPlaceholder"
type="url"
>
<div
v-if="playUrlValidation.validating"
class="absolute inset-y-0 right-4 flex items-center"
>
<Loader2 class="w-4 h-4 text-text-tertiary animate-spin" />
</div>
</div>
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-1"
enter-to-class="opacity-100 translate-y-0"
>
<div v-if="manualPlayUrl && !playUrlValidation.validating" class="px-1 pt-1">
<p
v-if="!playUrlValidation.valid"
class="text-[10px] font-bold text-error-80 flex items-center gap-1"
>
<X class="w-3 h-3" /> {{ playUrlValidation.error }}
</p>
<p
v-else
class="text-[10px] font-bold text-success-80 flex items-center gap-1"
>
<Check class="w-3 h-3" /> {{ locale.validUrl }}
</p>
</div>
</Transition>
</div>
</div>
</div>
<!-- Footer -->
<div
class="px-8 py-6 bg-bg-secondary-50 border-t border-border-secondary-50 flex gap-3 justify-end shrink-0"
>
<button
class="px-6 py-2.5 text-xs font-bold text-text-tertiary hover:text-text-secondary transition-all"
type="button"
@click="showManualModal = false"
>
{{ locale.cancel }}
</button>
<button
:disabled="!canSubmitFromSearch || !canSubmitManualForm || submitting"
class="px-8 py-2.5 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-lg transition-all disabled:opacity-50"
type="button"
@click="handleManualSubmit"
>
{{ submitting ? locale.submitting : locale.confirmSubmit }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- 隐藏的文件输入框 -->
<input
ref="fileInput"
accept=".json"
style="display: none"
type="file"
@change="handleImportData"
>
<input
ref="qqFileInput"
accept=".json"
style="display: none"
type="file"
@change="handleImportDataQQ"
>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { X, Lock, Loader2, Check, Edit3, Music, Play } from '@lucide/vue'
import { useSongs } from '~/composables/useSongs'
import { useThemeImage } from '~/composables/useThemeImage'
import { useAudioPlayer } from '~/composables/useAudioPlayer'
import { useAudioPlayerControl } from '~/composables/useAudioPlayerControl'
import { useSiteConfig } from '~/composables/useSiteConfig'
import { useAuth } from '~/composables/useAuth'
import { useSemesters } from '~/composables/useSemesters'
import { useMusicSources } from '~/composables/useMusicSources'
import { useAudioQuality } from '~/composables/useAudioQuality'
import { usePlatformConfig } from '~/composables/usePlatformConfig'
import { useLocale } from '~/utils/locale'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
import Icon from '../UI/Icon.vue'
import { convertToHttps, validateUrl } from '~/utils/url'
import { isBilibiliSong } from '~/utils/bilibiliSource'
import { getLoginStatus } from '~/utils/neteaseApi'
import { getMusicUrl as resolveMusicUrl } from '~/utils/musicUrl'
import { renderMarkdown } from '~/utils/markdown'
import { normalizeForMatch as normalizeString } from '~/utils/song-name-normalize'
import ImportSongsModal from './ImportSongsModal.vue'
import NeteaseLoginModal from './NeteaseLoginModal.vue'
import QQMusicLoginModal from './QQMusicLoginModal.vue'
import PodcastEpisodesModal from './PodcastEpisodesModal.vue'
import BilibiliEpisodesModal from './BilibiliEpisodesModal.vue'
import AlbumDetailsModal from './AlbumDetailsModal.vue'
import RecentSongsModal from './RecentSongsModal.vue'
import PlaylistSelectionModal from './PlaylistSelectionModal.vue'
import UserSearchModal from '../Common/UserSearchModal.vue'
import NeteaseUploadDialog from './NeteaseUploadDialog.vue'
const props = defineProps({
loading: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['request', 'vote'])
const { songs: songsLocale } = useLocale()
const locale = computed(() => useSafeLocale(songsLocale.value?.requestForm || {}))
const { t: callLocale } = useLocaleText(locale)
const { localize: localizeServerError } = useServerErrors()
const { getSearchIcon } = useThemeImage()
// 站点配置
const {
guidelines: submissionGuidelines,
initSiteConfig,
enableReplayRequests,
enableSubmissionRestriction,
enableCollaborativeSubmission,
enableSubmissionRemarks,
enableSubmissionLimit,
enableCardCodeRequests,
requireCardCodeForRequests,
enableCardCodeLimitBypass,
submissionNoteRequiresApproval,
isLoaded: siteConfigLoaded
} = useSiteConfig()
// 将投稿须知 Markdown 渲染为安全 HTML
const renderedGuidelines = computed(() => renderMarkdown(submissionGuidelines.value))
// 用户认证
const auth = useAuth()
const user = computed(() => auth.user.value)
// 学期管理
const { fetchCurrentSemester, currentSemester, fetchSemesterOptions, semesters } = useSemesters()
// 是否显示“从往期导入”按钮:只有在有多个学期的情况下才显示
const showImportSemesterBtn = computed(() => semesters.value && semesters.value.length > 1)
const AUDIO_MATCH_DURATION = 3
const title = ref('')
const artist = ref('')
const platform = ref('netease') // 占位值,平台配置加载后跟随后台排序的首选平台
const platformTouched = ref(false) // 用户是否手动切换过平台,切换后不再覆盖其选择
const preferredPlayTimeId = ref('')
const submissionNote = ref('')
const submissionNotePublic = ref(true)
const cardCode = ref('')
const cardCodeDraft = ref('')
const showCardCodeModal = ref(false)
const cardCodeInputRef = ref(null)
const cardCodeValidation = ref({
checking: false,
valid: null,
message: ''
})
const error = ref('')
const success = ref('')
const submitting = ref(false)
const voting = ref(false)
const {
getAvailablePlatforms,
loadPlatformConfig,
loaded: platformConfigLoaded
} = usePlatformConfig()
const availablePlatforms = computed(() => getAvailablePlatforms())
// 监听平台可用性与排序变化:
// - 当前平台被禁用时,强制切换到第一个可用平台并提示
// - 用户未手动选择过平台时,默认选中跟随后台排序的第一位
watch(availablePlatforms, (available) => {
if (available.length === 0) return
if (!available.includes(platform.value)) {
platform.value = available[0]
if (window.$showNotification) {
const switchedName = locale.value.platforms[platform.value] || ''
const msg = callLocale('notifications.platformAutoSwitched', '', switchedName)
window.$showNotification(msg, 'info')
}
return
}
if (!platformTouched.value && platform.value !== available[0]) {
platform.value = available[0]
}
})
const showImportSongsModal = ref(false)
const showLoginModal = ref(false)
const showQQLoginModal = ref(false)
const isNeteaseLoggedIn = ref(false)
const neteaseUser = ref(null)
const neteaseCookie = ref('')
const isQQMusicLoggedIn = ref(false)
const qqMusicUser = ref(null)
const qqMusicCookie = ref('')
const checkingQQLogin = ref(false)
const searchType = ref(1) // 1: 单曲, 1009: 播客/电台
// 播客弹窗相关
const showPodcastModal = ref(false)
const selectedPodcastId = ref('')
const selectedPodcastName = ref('')
const podcastCookie = ref('')
const showRecentSongsModal = ref(false)
const showPlaylistModal = ref(false)
// 当前打开歌单弹窗对应的音源平台,决定弹窗内数据获取分支
const playlistModalPlatform = ref('netease')
const openPlaylistModal = (platform) => {
playlistModalPlatform.value = platform
showPlaylistModal.value = true
}
const showUserSearchModal = ref(false)
const collaborators = ref([])
const songService = useSongs()
const playTimes = ref([])
const playTimeSelectionEnabled = ref(false)
const loadingPlayTimes = ref(false)
const cardCodeEnabled = computed(
() => enableCardCodeRequests.value || requireCardCodeForRequests.value
)
const cardCodeLimitBypassActive = computed(
() => enableSubmissionLimit.value && cardCodeEnabled.value && enableCardCodeLimitBypass.value
)
const cardCodeFieldMeta = computed(() => ({
required: requireCardCodeForRequests.value,
helper: requireCardCodeForRequests.value
? cardCodeLimitBypassActive.value
? locale.value.cardCodeRequiredBypassHelper
: locale.value.cardCodeRequiredHelper
: cardCodeLimitBypassActive.value
? locale.value.cardCodeOptionalBypassHelper
: locale.value.cardCodeOptionalHelper,
placeholder: locale.value.cardCodePlaceholder
}))
const trimmedCardCode = computed(() => cardCode.value.trim())
const cardCodeStatusText = computed(() => {
if (cardCodeValidation.value.checking) return locale.value.validatingCardCode
if (trimmedCardCode.value) {
if (cardCodeValidation.value.valid && cardCodeLimitBypassActive.value) {
return locale.value.cardCodeAvailableBypass
}
return cardCodeValidation.value.message || locale.value.cardCodeWillValidate
}
return cardCodeFieldMeta.value.required
? locale.value.cardCodeRequiredStatus
: locale.value.cardCodeOptionalStatus
})
const mobileCardCodeLabel = computed(() => {
if (cardCodeValidation.value.checking) return locale.value.validatingShort
if (trimmedCardCode.value) {
if (cardCodeValidation.value.valid === false) return locale.value.cardCodeInvalid
if (cardCodeValidation.value.valid) return locale.value.cardCodeAvailable
return locale.value.cardCodeFilled
}
return cardCodeFieldMeta.value.required
? locale.value.cardCodeRequiredShort
: locale.value.cardCodeOptionalShort
})
const cardCodeModalHint = computed(() => {
if (cardCodeValidation.value.checking) return locale.value.validatingCardCode
if (cardCodeValidation.value.message) return cardCodeValidation.value.message
return locale.value.cardCodeSaveHint
})
const resetCardCodeValidation = () => {
cardCodeValidation.value = {
checking: false,
valid: null,
message: ''
}
}
const openCardCodeModal = async () => {
cardCodeDraft.value = cardCode.value
showCardCodeModal.value = true
await nextTick()
cardCodeInputRef.value?.focus()
}
const closeCardCodeModal = () => {
const draftChanged = cardCodeDraft.value.trim() !== cardCode.value.trim()
showCardCodeModal.value = false
cardCodeDraft.value = cardCode.value
if (draftChanged) {
resetCardCodeValidation()
}
}
const validateCardCode = async (code) => {
const normalizedCode = typeof code === 'string' ? code.trim().toUpperCase() : ''
if (!normalizedCode) {
resetCardCodeValidation()
return !requireCardCodeForRequests.value
}
cardCodeValidation.value = {
checking: true,
valid: null,
message: locale.value.validatingCardCode
}
try {
const response = await $fetch('/api/card-codes/validate', {
method: 'POST',
body: { cardCode: normalizedCode },
...auth.getAuthConfig()
})
cardCodeValidation.value = {
checking: false,
valid: true,
message: locale.value.cardCodeAvailable
}
return true
} catch (err) {
const message = localizeServerError(err, locale.value.cardCodeValidateFailed)
cardCodeValidation.value = {
checking: false,
valid: false,
message
}
if (window.$showNotification) {
window.$showNotification(message, 'error')
}
return false
}
}
const saveCardCode = async () => {
const draft = cardCodeDraft.value.trim().toUpperCase()
if (requireCardCodeForRequests.value && !draft) {
if (window.$showNotification) {
window.$showNotification(locale.value.cardCodeRequiredWarning, 'warning')
}
return
}
if (!draft) {
clearCardCode()
return
}
if (!(await validateCardCode(draft))) {
return
}
cardCode.value = draft
showCardCodeModal.value = false
}
const clearCardCode = () => {
cardCode.value = ''
cardCodeDraft.value = ''
showCardCodeModal.value = false
resetCardCodeValidation()
}
const ensureCardCodeForSubmit = async () => {
if (!cardCodeEnabled.value) {
return true
}
if (!trimmedCardCode.value) {
if (!requireCardCodeForRequests.value) {
return true
}
await openCardCodeModal()
if (window.$showNotification) {
window.$showNotification(locale.value.cardCodeRequiredWarning, 'warning')
}
return false
}
// 已验证过且未变动则跳过重复验证
if (cardCodeValidation.value.valid === true) {
return true
}
return await validateCardCode(trimmedCardCode.value)
}
// 投稿状态
const submissionStatus = ref(null)
const loadingSubmissionStatus = ref(false)
// 搜索相关
const searching = ref(false)
const searchResults = ref([])
const restrictionCheckMap = ref(new Map())
const restrictionChecking = ref(false)
let restrictionCheckRequestId = 0
const restrictionAbortController = ref(null)
const selectedCover = ref('')
const selectedUrl = ref('')
const audioPlayer = useAudioPlayer() // 使用全局音频播放器
// 搜索请求控制器
const searchAbortController = ref(null)
const showAudioMatchModal = ref(false)
const audioMatchPreparing = ref(false)
const audioMatchRecording = ref(false)
const audioMatchProcessing = ref(false)
const audioMatchStatus = ref('')
const audioMatchError = ref('')
const audioMatchResults = ref([])
let audioMatchScriptsPromise = null
let audioMatchContext = null
let audioMatchRecorderNode = null
let audioMatchMicStream = null
let audioMatchMicSourceNode = null
let audioMatchSilentNode = null
// 音源管理器
const musicSources = useMusicSources()
const { currentSource, sourceStatus, sourceStatusSummary, currentSourceInfo } = musicSources
const { checkNeteaseLoginStatus: updateGlobalNeteaseStatus, persistNeteaseVipFlag, clearNeteaseVipFlag } = useAudioQuality()
const searchError = ref('')
// 手动输入相关
const showManualModal = ref(false)
const showBilibiliEpisodesModal = ref(false)
const selectedBilibiliVideo = ref(null)
const bilibiliEpisodes = ref([])
const bilibiliEpisodeSubmitOptions = ref({})
// 专辑详情相关
const showAlbumDetailsModal = ref(false)
const selectedAlbumId = ref(null)
const selectedAlbumName = ref(null)
const selectedAlbumPlatform = ref('netease')
const manualArtist = ref('')
const manualCover = ref('')
const manualPlayUrl = ref('')
const hasSearched = ref(false)
// 上传到网易云相关
const showUploadDialog = ref(false)
const selectedUploadSong = ref(null)
// URL验证相关
const coverValidation = ref({ valid: true, error: '', validating: false })
const playUrlValidation = ref({ valid: true, error: '', validating: false })
// 网易云音乐登录检查状态
const checkingNeteaseLogin = ref(false)
const resetAudioMatchState = () => {
audioMatchPreparing.value = false
audioMatchRecording.value = false
audioMatchProcessing.value = false
audioMatchStatus.value = ''
audioMatchError.value = ''
audioMatchResults.value = []
}
const stopAudioMatchSession = async () => {
audioMatchRecording.value = false
audioMatchProcessing.value = false
if (audioMatchMicStream) {
audioMatchMicStream.getTracks().forEach((track) => track.stop())
audioMatchMicStream = null
}
audioMatchMicSourceNode?.disconnect()
audioMatchRecorderNode?.disconnect()
audioMatchSilentNode?.disconnect()
audioMatchMicSourceNode = null
audioMatchRecorderNode = null
audioMatchSilentNode = null
if (audioMatchContext) {
try {
await audioMatchContext.close()
} catch {
// ignore close failures
}
audioMatchContext = null
}
}
const loadAudioMatchScript = (src) =>
new Promise((resolve, reject) => {
if (document.querySelector(`script[data-audio-match="${src}"]`)) {
resolve()
return
}
const script = document.createElement('script')
script.src = src
script.async = true
script.dataset.audioMatch = src
script.onload = () => resolve()
script.onerror = () => reject(new Error(callLocale('audioMatchScriptLoadFailed', '', src)))
document.head.appendChild(script)
})
const ensureAudioMatchScripts = async () => {
if (!import.meta.client) return
if (typeof window.GenerateFP === 'function') {
return
}
if (!audioMatchScriptsPromise) {
audioMatchScriptsPromise = loadAudioMatchScript('/audio-match/afp.wasm.js')
.then(() => loadAudioMatchScript('/audio-match/afp.js'))
.then(() => {
if (typeof window.GenerateFP !== 'function') {
throw new Error(locale.value.audioMatchEngineFailed)
}
})
.catch((err) => {
audioMatchScriptsPromise = null
throw err
})
}
await audioMatchScriptsPromise
}
const parseAudioMatchResults = (response) => {
const resultList =
response?.data?.result ||
response?.result ||
response?.body?.data?.result ||
response?.body?.result ||
[]
return resultList.map((item, index) => {
const song = item?.song || {}
const artists = Array.isArray(song.artists)
? song.artists.map((artistItem) => artistItem?.name).filter(Boolean)
: []
return {
key: `${song.id || 'unknown'}-${index}`,
id: song.id,
name: song.name || locale.value.unknownSong,
artist: artists.join(' / ') || locale.value.unknownArtist,
album: song.album?.name || '',
cover: song.album?.picUrl || song.al?.picUrl || '',
startTime: typeof item?.startTime === 'number' ? item.startTime : 0
}
})
}
const handleAudioMatchFingerprint = async (recording) => {
try {
audioMatchProcessing.value = true
audioMatchStatus.value = locale.value.audioMatchGenerating
const fingerprint = await window.GenerateFP(recording)
const response = await $fetch('/api/api-enhanced/netease/audio/match', {
method: 'POST',
body: {
duration: AUDIO_MATCH_DURATION,
audioFP: fingerprint
}
})
const matches = parseAudioMatchResults(response)
if (!matches.length) {
throw new Error(locale.value.audioMatchNoMatch)
}
audioMatchResults.value = matches
audioMatchStatus.value = callLocale('audioMatchDone', '', matches.length)
} catch (err) {
console.error('听歌识曲失败:', err)
audioMatchError.value = err?.message || locale.value.audioMatchFailed
audioMatchStatus.value = ''
audioMatchResults.value = []
} finally {
audioMatchProcessing.value = false
}
}
const initializeAudioMatch = async () => {
await ensureAudioMatchScripts()
await stopAudioMatchSession()
audioMatchPreparing.value = true
audioMatchStatus.value = locale.value.microphoneRequesting
try {
const AudioContextClass = window.AudioContext || window.webkitAudioContext
audioMatchContext = new AudioContextClass({ sampleRate: 8000 })
if (audioMatchContext.state === 'suspended') {
await audioMatchContext.resume()
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error(locale.value.microphoneUnsupported)
}
if (audioMatchContext.audioWorklet) {
await audioMatchContext.audioWorklet.addModule('/audio-match/rec.js')
audioMatchRecorderNode = new AudioWorkletNode(audioMatchContext, 'timed-recorder')
audioMatchRecorderNode.port.onmessage = async (event) => {
switch (event.data.message) {
case 'bufferhealth': {
const progress = Math.min(1, Number(event.data.health) || 0)
const currentSeconds = (AUDIO_MATCH_DURATION * progress).toFixed(1)
audioMatchStatus.value = callLocale(
'audioMatchRecordingProgress',
'',
currentSeconds,
AUDIO_MATCH_DURATION
)
break
}
case 'finished':
audioMatchRecording.value = false
await handleAudioMatchFingerprint(event.data.recording)
break
default:
break
}
}
} else {
// 兼容不支持 AudioWorklet 的浏览器 (如旧版 Safari)
const bufferSize = 4096
const scriptNode = audioMatchContext.createScriptProcessor(bufferSize, 1, 1)
let maxLength = 0
let recbuffer = new Float32Array()
let recording = false
let bufIndex = 0
scriptNode.onaudioprocess = (e) => {
if (!recording) return
const channelL = e.inputBuffer.getChannelData(0)
const progress = bufIndex / maxLength
const currentSeconds = (AUDIO_MATCH_DURATION * progress).toFixed(1)
// 提高更新频率以改善用户体验
if (bufIndex % bufferSize === 0) {
audioMatchStatus.value = callLocale(
'audioMatchRecordingProgress',
'',
currentSeconds,
AUDIO_MATCH_DURATION
)
}
if (bufIndex + channelL.length > maxLength) {
recording = false
audioMatchRecording.value = false
// 确保包含最后一块数据
const remaining = maxLength - bufIndex
if (remaining > 0) {
recbuffer.set(channelL.subarray(0, remaining), bufIndex)
}
bufIndex = 0
handleAudioMatchFingerprint(recbuffer)
} else {
recbuffer.set(channelL, bufIndex)
bufIndex += channelL.length
}
}
// 模拟 AudioWorkletNode 接口
audioMatchRecorderNode = scriptNode
audioMatchRecorderNode.port = {
postMessage: (msg) => {
if (msg.message === 'start') {
maxLength = msg.duration * 8000
recbuffer = new Float32Array(maxLength)
bufIndex = 0
recording = true
}
}
}
}
audioMatchSilentNode = audioMatchContext.createGain()
audioMatchSilentNode.gain.value = 0
audioMatchRecorderNode.connect(audioMatchSilentNode)
audioMatchSilentNode.connect(audioMatchContext.destination)
const GET_USER_MEDIA_TIMEOUT_MS = 15000
audioMatchMicStream = await Promise.race([
navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: false,
autoGainControl: false,
noiseSuppression: false,
latency: 0
}
}),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error(locale.value.microphoneTimeout)),
GET_USER_MEDIA_TIMEOUT_MS
)
)
])
audioMatchMicSourceNode = audioMatchContext.createMediaStreamSource(audioMatchMicStream)
audioMatchMicSourceNode.connect(audioMatchRecorderNode)
audioMatchStatus.value = locale.value.microphoneReady
} catch (err) {
await stopAudioMatchSession()
throw err
} finally {
audioMatchPreparing.value = false
}
}
const openAudioMatchModal = async () => {
showAudioMatchModal.value = true
resetAudioMatchState()
try {
await initializeAudioMatch()
} catch (err) {
console.error('初始化听歌识曲失败:', err)
audioMatchError.value = err?.message || locale.value.audioMatchInitFailed
}
}
const closeAudioMatchModal = async () => {
showAudioMatchModal.value = false
resetAudioMatchState()
await stopAudioMatchSession()
}
const playAudioMatchResult = async (match) => {
try {
const result = {
id: match.id,
musicId: match.id,
song: match.name,
singer: match.artist,
cover: match.cover || null,
musicPlatform: 'netease',
hasUrl: false
}
await playSong(result)
if (match.startTime > 0) {
const audioPlayerControl = useAudioPlayerControl()
audioPlayerControl.seekAndPlay(match.startTime / 1000)
}
} catch (err) {
console.error('播放听歌识曲片段失败:', err)
}
}
const startAudioMatchRecording = async () => {
if (audioMatchPreparing.value || audioMatchRecording.value || audioMatchProcessing.value) return
audioMatchError.value = ''
audioMatchResults.value = []
if (!audioMatchRecorderNode) {
try {
await initializeAudioMatch()
} catch (err) {
console.error('重新初始化听歌识曲失败:', err)
audioMatchError.value = err?.message || locale.value.microphoneAccessFailed
return
}
}
audioMatchStatus.value = callLocale(
'audioMatchRecordingProgress',
'',
'0.0',
AUDIO_MATCH_DURATION
)
audioMatchRecording.value = true
audioMatchRecorderNode.port.postMessage({
message: 'start',
duration: AUDIO_MATCH_DURATION
})
}
const stopAudioMatchRecording = () => {
if (audioMatchRecorderNode?.port) {
audioMatchRecorderNode.port.postMessage({ message: 'stop' })
}
}
const useAudioMatchResult = async (match) => {
platform.value = 'netease'
searchType.value = 1
title.value = `${match.name} ${match.artist}`.trim()
error.value = ''
success.value = ''
await closeAudioMatchModal()
await nextTick()
await handleSearch()
}
onBeforeUnmount(() => {
stopAudioMatchSession()
})
const handleImportSuccess = async () => {
// 不自动关闭弹窗,等待用户在结果页点击完成
// showImportSongsModal.value = false
// 刷新歌曲列表以便检查相似歌曲
try {
await songService.fetchSongs(true, currentSemester.value?.name, true)
} catch (error) {
console.error('刷新歌曲列表失败:', error)
}
}
const handleUserSelect = (users) => {
if (Array.isArray(users)) {
// 过滤掉已存在的
const newUsers = users.filter((u) => !collaborators.value.some((c) => c.id === u.id))
collaborators.value.push(...newUsers)
} else if (users) {
if (!collaborators.value.some((c) => c.id === users.id)) {
collaborators.value.push(users)
}
}
}
const removeCollaborator = (userId) => {
collaborators.value = collaborators.value.filter((c) => c.id !== userId)
}
// 获取播出时段
const fetchPlayTimes = async () => {
loadingPlayTimes.value = true
try {
const response = await fetch('/api/play-times')
if (response.ok) {
const data = await response.json()
playTimes.value = data.playTimes || []
playTimeSelectionEnabled.value = data.enabled || false
}
} catch (err) {
console.error('获取播出时段失败:', err)
} finally {
loadingPlayTimes.value = false
}
}
const fileInput = ref(null)
const checkNeteaseLoginStatus = async () => {
if (import.meta.client) {
const cookie = localStorage.getItem('netease_cookie')
// const userStr = localStorage.getItem('netease_user')
if (cookie) {
checkingNeteaseLogin.value = true
try {
const res = await getLoginStatus(cookie)
const dataObj = res.body?.data || res.body
if (dataObj && dataObj.account) {
neteaseCookie.value = cookie
isNeteaseLoggedIn.value = true
neteaseUser.value = dataObj.profile || dataObj.account
localStorage.setItem('netease_user', JSON.stringify(neteaseUser.value))
// 同步 VIP 标志(vipType 非 0 即 VIP)
persistNeteaseVipFlag(dataObj.profile)
// 同步全局网易云登录状态
updateGlobalNeteaseStatus()
} else {
// 登录失效
handleLogoutNetease()
}
} catch (e) {
console.error('检查登录状态失败', e)
// 如果网络请求失败,暂时保留本地状态,或者根据需求清除
// 这里选择保守策略,只有明确失效才清除,除非是401等错误
// 但鉴于用户要求“失效了就清除”,如果API通了但返回无效则清除。
// 如果API不通,可能不清除。这里假设getLoginStatus返回正常结构。
} finally {
checkingNeteaseLogin.value = false
}
} else {
// 同步全局网易云登录状态
updateGlobalNeteaseStatus()
}
}
}
const handleExportData = () => {
if (!neteaseCookie.value) return
const data = {
cookie: neteaseCookie.value,
timestamp: Date.now()
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `netease_cookie_${Date.now()}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.exportSuccess, 'success')
}
}
const handleImportClick = () => {
fileInput.value.click()
}
const handleImportData = async (event) => {
const file = event.target.files[0]
if (!file) return
try {
const text = await file.text()
const data = JSON.parse(text)
if (data.cookie) {
checkingNeteaseLogin.value = true
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.validatingCookie, 'info')
}
const res = await getLoginStatus(data.cookie)
const dataObj = res.body?.data || res.body
if (dataObj && dataObj.account) {
handleLoginSuccess({
cookie: data.cookie,
user: dataObj.profile || dataObj.account
})
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.importSuccess, 'success')
}
} else {
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.cookieInvalid, 'error')
}
}
} else {
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.fileFormatError, 'error')
}
}
} catch (e) {
console.error('导入失败', e)
if (window.$showNotification) {
window.$showNotification(
callLocale('notifications.importFailed', '', getErrorMessage(e)),
'error'
)
}
} finally {
checkingNeteaseLogin.value = false
}
// 重置input
event.target.value = ''
}
const handleLoginSuccess = (data) => {
neteaseCookie.value = data.cookie
neteaseUser.value = data.user
isNeteaseLoggedIn.value = true
if (import.meta.client) {
localStorage.setItem('netease_cookie', data.cookie)
localStorage.setItem('netease_user', JSON.stringify(data.user))
persistNeteaseVipFlag(data.user)
updateGlobalNeteaseStatus()
}
}
const handleLogoutNetease = () => {
neteaseCookie.value = ''
neteaseUser.value = null
isNeteaseLoggedIn.value = false
searchType.value = 1
if (import.meta.client) {
localStorage.removeItem('netease_cookie')
localStorage.removeItem('netease_user')
clearNeteaseVipFlag()
updateGlobalNeteaseStatus()
}
}
const applyQqMusicUser = (user) => {
qqMusicUser.value = user || { nickname: locale.value.qqLoggedIn }
}
const validateQqCookie = async (cookie) => {
const res = await $fetch('/api/native-api/qq/check-cookie', {
method: 'POST',
body: { cookie }
})
return res?.data || {}
}
// 持久化服务端返回的 VIP 状态,供取链优先级判断(仅 VIP 时优先官方链路)
const persistQqVipFlag = (data) => {
if (typeof data?.isVip !== 'boolean' || !import.meta.client) return
localStorage.setItem('qq_music_vip', data.isVip ? '1' : '0')
}
// 用服务端校验结果补全真实昵称与头像,仅在有增量时更新
const refreshQqProfileFromServer = async (cookie) => {
if (!cookie) return
try {
const data = await validateQqCookie(cookie)
if (!data.valid) return
persistQqVipFlag(data)
if (!(data.user?.nickname || data.user?.avatarUrl)) return
const mergedUser = { ...(qqMusicUser.value || {}), ...data.user }
applyQqMusicUser(mergedUser)
localStorage.setItem('qq_music_user', JSON.stringify(mergedUser))
} catch (e) {
console.warn('刷新 QQ 用户资料失败:', e)
}
}
// 启动时静默校验本地保存的 QQ 登录态,失效则清理并提示
const checkQQMusicLoginStatus = async () => {
if (!import.meta.client) return
const cookie = localStorage.getItem('qq_music_cookie')
if (!cookie) {
handleLogoutQQMusic()
return
}
// 先按本地缓存呈现,再异步校验,避免界面闪烁
qqMusicCookie.value = cookie
isQQMusicLoggedIn.value = true
try {
const userStr = localStorage.getItem('qq_music_user')
qqMusicUser.value = userStr ? JSON.parse(userStr) : { nickname: locale.value.qqLoggedIn }
} catch {
applyQqMusicUser(null)
}
checkingQQLogin.value = true
try {
const data = await validateQqCookie(cookie)
if (data.valid) {
persistQqVipFlag(data)
await refreshQqProfileFromServer(cookie)
} else {
if (window.$showNotification) {
window.$showNotification(locale.value.qqLoginExpired, 'warning')
}
handleLogoutQQMusic()
}
} catch (e) {
console.warn('校验 QQ 登录状态失败,保留本地状态:', e)
} finally {
checkingQQLogin.value = false
}
}
const handleQQLoginSuccess = (data) => {
qqMusicCookie.value = data.cookie
applyQqMusicUser(data.user)
isQQMusicLoggedIn.value = true
if (import.meta.client) {
localStorage.setItem('qq_music_cookie', data.cookie)
localStorage.setItem('qq_music_user', JSON.stringify(qqMusicUser.value))
}
// 立即拉取真实昵称与头像,替换扫码占位信息
refreshQqProfileFromServer(data.cookie)
}
const handleLogoutQQMusic = () => {
qqMusicCookie.value = ''
qqMusicUser.value = null
isQQMusicLoggedIn.value = false
if (import.meta.client) {
localStorage.removeItem('qq_music_cookie')
localStorage.removeItem('qq_music_user')
localStorage.removeItem('qq_music_vip')
}
}
const handleExportDataQQ = () => {
if (!qqMusicCookie.value) return
const data = {
cookie: qqMusicCookie.value,
user: qqMusicUser.value,
timestamp: Date.now()
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `qq_music_cookie_${Date.now()}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.exportSuccess, 'success')
}
}
const qqFileInput = ref(null)
const handleImportClickQQ = () => {
qqFileInput.value.click()
}
const handleImportDataQQ = async (event) => {
const file = event.target.files[0]
if (!file) return
try {
const text = await file.text()
const data = JSON.parse(text)
if (data.cookie) {
checkingQQLogin.value = true
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.validatingCookie, 'info')
}
const result = await validateQqCookie(data.cookie)
if (result.valid) {
const user =
result.user?.nickname || result.user?.avatarUrl
? result.user
: { nickname: locale.value.qqLoggedIn }
handleQQLoginSuccess({ cookie: data.cookie, user })
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.importSuccess, 'success')
}
} else {
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.cookieInvalid, 'error')
}
}
} else {
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.fileFormatError, 'error')
}
}
} catch (e) {
console.error('导入失败', e)
if (window.$showNotification) {
window.$showNotification(
callLocale('notifications.importFailed', '', getErrorMessage(e)),
'error'
)
}
} finally {
checkingQQLogin.value = false
}
event.target.value = ''
}
watch(
() => searchType.value,
() => {
if (platform.value !== 'netease') return
// 记录当前状态,判断是否需要重新搜索
// 如果已经有搜索结果(hasSearched)或正在搜索中(searching),且有关键词,则应该重新搜索
const shouldResearch = title.value.trim() && (hasSearched.value || searching.value)
// 如果有正在进行的搜索请求,立即取消
if (searchAbortController.value) {
searchAbortController.value.abort()
searchAbortController.value = null
searching.value = false
}
// 清空之前的搜索结果,避免显示错误的类型结果
searchResults.value = []
// 自动重新搜索
if (shouldResearch) {
handleSearch()
}
}
)
onMounted(async () => {
// 后台加载平台配置(不阻塞其他初始化);平台可用性变化由 watch 自动处理
loadPlatformConfig()
checkNeteaseLoginStatus()
checkQQMusicLoginStatus()
fetchPlayTimes()
initSiteConfig()
fetchSubmissionStatus()
// 获取当前学期和所有学期选项
await Promise.all([fetchCurrentSemester(), fetchSemesterOptions()])
// 只有在用户已登录时才加载歌曲列表以便检查相似歌曲
if (auth.isAuthenticated.value) {
try {
const currentSemesterName = currentSemester.value?.name
await songService.fetchSongs(false, currentSemesterName)
} catch (error) {
console.error('加载歌曲列表失败:', error)
}
try {
const pendingSearch = sessionStorage.getItem('pending_search')
if (pendingSearch) {
const saved = JSON.parse(pendingSearch)
sessionStorage.removeItem('pending_search')
if (saved.title) {
title.value = saved.title
if (saved.platform) platform.value = saved.platform
await checkNeteaseLoginStatus()
await handleSearch()
}
}
} catch {
// 浏览器禁用会话存储时不影响页面初始化。
}
}
// 音源健康检查功能已移除
})
// 过滤出启用的播出时段
const enabledPlayTimes = computed(() => {
return playTimes.value.filter((pt) => pt.enabled)
})
const formattedPlayTimes = computed(() => {
const options = enabledPlayTimes.value.map((pt) => ({
...pt,
displayName: pt.startTime || pt.endTime ? `${pt.name} (${formatPlayTimeRange(pt)})` : pt.name
}))
return [{ id: '', displayName: locale.value.choosePlayTime }, ...options]
})
// 格式化播出时段时间范围
const formatPlayTimeRange = (playTime) => {
if (!playTime) return ''
if (playTime.startTime && playTime.endTime) {
return `${playTime.startTime} - ${playTime.endTime}`
} else if (playTime.startTime) {
return `${playTime.startTime}`
} else if (playTime.endTime) {
return `${playTime.endTime}`
}
return locale.value.choosePlayTime
}
// 监听用户状态变化,当用户登录后重新获取投稿状态
watch(
() => user.value,
(newUser) => {
if (newUser) {
fetchSubmissionStatus()
} else {
submissionStatus.value = null
}
}
)
watch(enableCollaborativeSubmission, (enabled) => {
if (!enabled) {
showUserSearchModal.value = false
collaborators.value = []
}
})
watch(enableSubmissionRemarks, (enabled) => {
if (!enabled) {
submissionNote.value = ''
submissionNotePublic.value = true
}
})
watch([enableCardCodeRequests, requireCardCodeForRequests], ([enabled, required]) => {
if (!enabled && !required) {
clearCardCode()
}
})
watch(cardCodeDraft, (value) => {
if (value.trim() !== cardCode.value.trim()) {
resetCardCodeValidation()
}
})
// 处理剧集投票
const handleEpisodeVote = async (episode) => {
if (!episode.songId) {
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.missingSongId, 'error')
}
return
}
if (episode.voted) {
return
}
voting.value = true
try {
await songService.voteSong(episode.songId)
// 只有在后端成功返回后才更新前端状态
episode.voted = true
episode.voteCount = (episode.voteCount || 0) + 1
// 刷新歌曲列表
await songService.refreshSongsSilent().catch((err) => {
console.error('刷新歌曲列表失败', err)
})
} catch (err) {
if (window.$showNotification) {
window.$showNotification(
getErrorMessage(err) || locale.value.notifications.likeFailed,
'error'
)
}
} finally {
voting.value = false
}
}
// 检查搜索结果是否已存在完全匹配的歌曲
const getSimilarSong = (result) => {
// 多P视频不在这里检查相似性,使用专门的 getBilibiliEpisodeStatus
if (isBilibiliMultiP(result)) {
return null
}
const title = result.song || result.title
const artist = result.singer || result.artist
if (!title || !artist) return null
const normalizedTitle = normalizeString(title)
const normalizedArtist = normalizeString(artist)
// 获取当前学期名称
const currentSemesterName = currentSemester.value?.name
// 检查完全匹配的歌曲(标准化后),只检查当前学期的歌曲
return songService.songs.value.find((song) => {
const songTitle = normalizeString(song.title)
const songArtist = normalizeString(song.artist)
const titleMatch = songTitle === normalizedTitle && songArtist === normalizedArtist
// 如果有当前学期信息,只检查当前学期的歌曲
if (currentSemesterName) {
return titleMatch && song.semester === currentSemesterName
}
// 如果没有学期信息,检查所有歌曲(向后兼容)
return titleMatch
})
}
// 从搜索结果中点赞已存在的歌曲
const handleLikeFromSearch = async (song, originalResult = null) => {
if (!song || song.voted) {
return
}
if (song.played || song.scheduled) {
if (window.$showNotification) {
const message = song.played ? locale.value.playedCannotLike : locale.value.scheduledCannotLike
window.$showNotification(message, 'warning')
}
return
}
// 如果原始结果是多P视频,打开剧集选择弹窗
if (originalResult && isBilibiliMultiP(originalResult)) {
selectedBilibiliVideo.value = originalResult
bilibiliEpisodes.value = originalResult.pages
bilibiliEpisodeSubmitOptions.value = {}
showBilibiliEpisodesModal.value = true
return
}
try {
await songService.voteSong(song.id)
} catch (error) {
console.error('点赞失败:', error)
}
}
// 平台切换函数
const switchPlatform = (newPlatform) => {
if (platform.value === newPlatform) return
platformTouched.value = true
// 如果有正在进行的搜索请求,立即取消
if (searchAbortController.value) {
searchAbortController.value.abort()
searchAbortController.value = null
searching.value = false
}
platform.value = newPlatform
// 清空之前的搜索结果,避免显示错误的平台来源
searchResults.value = []
// 如果已经有搜索结果,自动重新搜索
if (title.value.trim() && hasSearched.value) {
handleSearch()
}
}
// 搜索歌曲
const handleSearch = async () => {
error.value = ''
success.value = ''
searchError.value = ''
if (!title.value.trim()) {
error.value = locale.value.notifications.songNameRequired
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.songNameRequired, 'error')
}
return
}
// 如果有正在进行的搜索请求,先取消
if (searchAbortController.value) {
searchAbortController.value.abort()
searchAbortController.value = null
}
// 创建新的AbortController
searchAbortController.value = new AbortController()
const signal = searchAbortController.value.signal
// 记录请求时的上下文,用于结果校验
const requestPlatform = platform.value
const requestSearchType = searchType.value
searching.value = true
try {
// 使用多音源搜索
const searchParams = {
keywords: title.value.trim(),
platform: requestPlatform,
limit: 20,
signal: signal, // 传递AbortSignal
type: requestPlatform === 'netease' ? requestSearchType : 1,
cookie:
requestPlatform === 'netease'
? neteaseCookie.value
: requestPlatform === 'tencent'
? qqMusicCookie.value
: undefined
}
console.log('开始多音源搜索:', searchParams)
// 平台可用性检查已由 searchSongs 内部统一处理
const results = await musicSources.searchSongs(searchParams)
// 再次检查是否被中断,防止竞态条件
if (signal.aborted) return
// 额外的安全检查:如果用户已经切换了平台或类型,丢弃结果
if (platform.value !== requestPlatform) {
console.log('平台已切换,丢弃旧结果', requestPlatform, '->', platform.value)
return
}
if (requestPlatform === 'netease' && searchType.value !== requestSearchType) {
console.log('搜索类型已切换,丢弃旧结果', requestSearchType, '->', searchType.value)
return
}
if (results && results.success && results.data && results.data.length > 0) {
// 转换搜索结果格式以兼容现有UI
searchResults.value = results.data.map((item) => ({
...item,
musicId: item.id,
hasUrl: false,
// 统一字段名称
song: item.title || item.song,
singer: item.artist || item.singer,
// 保存实际的平台来源信息
actualSource: results.source,
musicPlatform:
item.musicPlatform || (results.source === 'netease-backup' ? 'netease' : results.source),
actualMusicPlatform:
item.musicPlatform || (results.source === 'netease-backup' ? 'netease' : results.source)
}))
// 如果QQ音乐搜索降级到网易云,自动切换选项卡
if (platform.value === 'tencent' && searchResults.value.length > 0) {
const firstPlatform =
searchResults.value[0].actualMusicPlatform || searchResults.value[0].musicPlatform || ''
if (firstPlatform.includes('netease')) {
platform.value = 'netease'
}
}
console.log('搜索成功,找到', results.data.length, '首歌曲')
// 异步预检服务端投稿限制(same-song / same-artist)
fetchRestrictionChecks()
} else {
searchResults.value = []
const errorMsg = results && results.error ? results.error : locale.value.noMatchingSongs
error.value = errorMsg
if (window.$showNotification) {
window.$showNotification(errorMsg, 'info')
}
}
} catch (err) {
// 如果请求被取消,不显示错误信息
if (err.name === 'AbortError' || signal.aborted) {
console.log('搜索请求已被取消')
return
}
console.error('搜索错误:', err)
searchError.value = getErrorMessage(err) || locale.value.searchRequestFailed
error.value = searchError.value
if (window.$showNotification) {
window.$showNotification(error.value, 'error')
}
} finally {
// 只有在请求没有被取消的情况下才更新状态
if (!signal.aborted) {
searching.value = false
hasSearched.value = true
// 清理AbortController
searchAbortController.value = null
}
}
}
// 获取音乐播放URL
const getAudioUrl = async (result) => {
if (result.hasUrl || result.url) return result
try {
// 根据搜索结果的sourceInfo.source字段判断音源类型
const sourceType = result.sourceInfo?.source || result.actualSource || ''
// 哔哩哔哩
if (sourceType === 'bilibili' || isBilibiliSong(result)) {
try {
const songId = result.musicId || result.id
if (!songId) throw new Error(locale.value.notifications.missingSongId)
const options = result.bilibiliCid ? { bilibiliCid: String(result.bilibiliCid) } : undefined
const urlResult = await musicSources.getSongUrl(songId, 0, 'bilibili', undefined, options)
if (urlResult && urlResult.success && urlResult.url) {
result.url = urlResult.url
result.hasUrl = true
return result
}
} catch (error) {
console.error('Bilibili获取播放链接失败:', error)
}
}
if (sourceType === 'vkeys-v3') {
try {
const songId = result.musicId || result.id
if (!songId) throw new Error(locale.value.notifications.missingSongId)
const { getQuality } = useAudioQuality()
const urlResult = await musicSources.getSongUrl(songId, getQuality('tencent'), 'tencent')
if (urlResult?.success && urlResult.url) {
result.url = urlResult.url
result.hasUrl = true
return result
}
} catch (error) {
console.warn('vkeys v3 获取播放链接失败:', error)
}
}
// 对于vkeys音源的处理
if (sourceType === 'vkeys') {
if (result.url) {
result.hasUrl = true
return result
} else {
// 根据平台直接尝试对应的备用源
if (platform.value === 'tencent') {
try {
const songId = result.musicId || result.id
if (!songId) throw new Error(locale.value.notifications.missingSongId)
const { getQuality } = useAudioQuality()
const quality = getQuality(platform.value) || 8
const urlResult = await musicSources.getSongUrl(songId, quality, platform.value)
if (urlResult && urlResult.success && urlResult.url) {
result.url = urlResult.url
result.hasUrl = true
return result
} else {
// QQ音乐 vkeys 系无法获取URL,继续回退
}
} catch (qqError) {
// QQ音乐播放链接获取失败,继续回退
}
} else if (platform.value === 'netease') {
try {
const { getQuality } = useAudioQuality()
const quality = getQuality(platform.value)
const songDetail = await musicSources.getSongDetail({
ids: [result.musicId || result.id],
quality: quality
})
if (songDetail?.data?.songs?.[0]?.url) {
const song = songDetail.data.songs[0]
result.url = song.url
result.hasUrl = true
if (song.cover) result.cover = song.cover
if (song.duration) result.duration = song.duration
return result
} else {
console.warn('getSongDetail 未返回 url,尝试备用音源')
throw new Error('song detail has no url')
}
} catch (error) {
// vkeys getSongDetail 失败,继续回退
}
// 如果getSongDetail失败,尝试网易云备用源
try {
const { getQuality } = useAudioQuality()
const quality = getQuality(platform.value)
const songId = result.musicId || result.id
const urlResult = await musicSources.getSongUrl(songId, quality, platform.value)
if (urlResult && urlResult.success && urlResult.url) {
result.url = urlResult.url
result.hasUrl = true
return result
} else {
// 备用源也无法获取URL
}
} catch (backupError) {
// 备用源获取失败
}
}
}
}
// 对于网易云备用源,直接调用getSongUrl获取播放链接
if (sourceType === 'netease-backup') {
const targetPlatform = 'netease'
const { getQuality } = useAudioQuality()
const quality = getQuality(targetPlatform)
const songId = result.musicId || result.id
// 检查是否为播客内容
const isPodcast = result.sourceInfo?.type === 'voice' || searchType.value === 1009
try {
const urlResult = await musicSources.getSongUrl(
songId,
quality,
targetPlatform,
neteaseCookie.value,
{ unblock: isPodcast ? false : undefined } // 播客内容 unblock=false,普通歌曲使用默认逻辑(登录则false,未登录则true)
)
if (urlResult && urlResult.success && urlResult.url) {
// 更新结果中的URL和其他信息
result.url = urlResult.url
result.hasUrl = true
// 更新搜索结果中的对应项
const index = searchResults.value.findIndex(
(item) => (item.musicId || item.id) === (result.musicId || result.id)
)
if (index !== -1) {
searchResults.value[index] = { ...result }
}
} else {
// 未能获取到有效的歌曲URL
}
} catch (urlError) {
// 调用 getSongUrl 失败
}
}
// 如果是网易云音乐且没有被前面特殊的 sourceType 处理(比如听歌识曲的普通结果)
if (result.musicPlatform === 'netease' && !result.url) {
try {
const { getQuality } = useAudioQuality()
const quality = getQuality('netease')
const songDetail = await musicSources.getSongDetail({
ids: [result.musicId || result.id],
quality: quality
})
if (songDetail?.data?.songs?.[0]?.url) {
const song = songDetail.data.songs[0]
result.url = song.url
result.hasUrl = true
if (song.cover) result.cover = song.cover
if (song.duration) result.duration = song.duration
return result
} else {
console.warn('getSongDetail 未返回 url,尝试备用音源')
throw new Error('song detail has no url')
}
} catch (error) {
console.error('获取网易云音乐详情失败:', error)
}
// 如果getSongDetail失败,尝试网易云备用源
try {
const { getQuality } = useAudioQuality()
const quality = getQuality('netease')
const songId = result.musicId || result.id
const urlResult = await musicSources.getSongUrl(songId, quality, 'netease')
if (urlResult && urlResult.success && urlResult.url) {
result.url = urlResult.url
result.hasUrl = true
return result
}
} catch (backupError) {
console.error('备用源获取失败:', backupError)
}
}
// 最终回退:使用统一的 resolveMusicUrl(包含 vkeys API 回退)
// 对于未登录用户或音源获取失败时使用第三方 API 获取播放链接
if (!result.url && result.musicPlatform && result.musicId) {
try {
const fallbackUrl = await resolveMusicUrl(
result.musicPlatform,
result.musicId,
result.playUrl,
{
mediaId:
result.sourceInfo?.strMediaMid ||
result.sourceInfo?.mediaId ||
result.sourceInfo?.mediaMid,
musicInfo: {
name: result.title,
artist: result.artist,
album: result.album || undefined
}
}
)
if (fallbackUrl) {
result.url = fallbackUrl
result.hasUrl = true
}
} catch (fallbackError) {
console.error('resolveMusicUrl 回退获取失败:', fallbackError)
}
}
return result
} catch (err) {
error.value = locale.value.notifications.musicUrlFailedRetry
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.musicUrlFailedRetry, 'error')
}
return result
}
}
// 播放歌曲
const playSong = async (result, playlist, playlistIndex) => {
// 如果还没有获取URL,先获取
if (!result.hasUrl && !result.url) {
result = await getAudioUrl(result)
}
// 对于非哔哩哔哩平台,如果没有URL则提示错误
if (!result.url && !isBilibiliSong(result)) {
error.value = locale.value.notifications.songUnavailable
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.songUnavailable, 'error')
}
return
}
let finalMusicId = result.musicId ? String(result.musicId) : null
if (isBilibiliSong(result) && result.bilibiliCid) {
finalMusicId = `${result.musicId}:${result.bilibiliCid}`
// 如果有分P信息,也添加到 musicId
if (result.bilibiliPage || result.page) {
const page = result.bilibiliPage || result.page
if (Number(page) > 1) {
finalMusicId += `:${page}`
}
}
}
// 准备播放所需的数据
const song = {
id: finalMusicId || result.musicId || Date.now(),
title: result.song || result.title,
artist: result.singer || result.artist,
cover: result.cover || null,
musicUrl: result.url || null, // 哔哩哔哩可能没有音频URL
musicPlatform: result.musicPlatform || platform.value,
musicId: finalMusicId,
albumId: result.albumId,
sourceInfo: result.sourceInfo,
bilibiliCid: result.bilibiliCid // 确保传递 cid
}
console.log('[RequestForm] 准备播放歌曲:', song)
// 处理播放列表:如果提供了playlist,将其转换为audioPlayer需要的格式
let finalPlaylist
let finalIndex
if (playlist && playlist.length > 0) {
finalPlaylist = playlist.map((s) => ({
...s,
id: String(s.id),
musicId: String(s.musicId || s.id),
musicUrl: s.musicUrl || s.url || null
}))
// 如果提供了playlistIndex则使用,否则自动查找
finalIndex =
typeof playlistIndex === 'number'
? playlistIndex
: finalPlaylist.findIndex((s) => s.id === String(song.id))
if (finalIndex < 0) finalIndex = 0
}
// 使用全局播放器播放歌曲
const playResult = audioPlayer.playSong(song, finalPlaylist, finalIndex)
if (!playResult) {
console.error('[RequestForm] 播放器返回 false,播放失败')
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.playFailed, 'error')
}
return
}
console.log('[RequestForm] 播放器已启动')
// 如果有音乐平台信息,请求歌词
if (song.musicPlatform && song.musicId) {
try {
const { useLyrics } = await import('~/composables/useLyrics')
const lyrics = useLyrics()
// 请求歌词(对于bilibili,传递原始的bvid,不包含cid)
const lyricMusicId = result.bilibiliCid ? result.musicId : song.musicId
await lyrics.fetchLyrics(song.musicPlatform, lyricMusicId, {
title: song.title,
artist: song.artist,
album: result.album || ''
})
} catch (error) {
console.error('获取歌词失败:', error)
// 歌词获取失败不影响播放
}
}
}
// 选择搜索结果
const selectResult = async (result) => {
// 防止重复点击和事件冒泡
event?.stopPropagation()
// 先获取完整信息
if (!result.hasUrl) {
result = await getAudioUrl(result)
}
// 标准化属性名称(处理不同API返回格式的差异)
const songTitle = result.song || result.title
const singerName = result.singer || result.artist
title.value = songTitle
artist.value = singerName
selectedCover.value = result.cover || ''
selectedUrl.value = result.url || ''
// 如果没有URL,给出提示
if (!result.url) {
success.value = locale.value.notifications.songSelectedMaybeLimited
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.songSelectedMaybeLimited, 'info')
}
}
console.log('已选择歌曲:', songTitle, '- 填充表单但不自动提交')
}
// 打开上传到网易云对话框
const openUploadDialog = (result) => {
console.log('QQ音乐搜索结果 - 完整对象:', result)
console.log('QQ音乐搜索结果 - 所有键:', Object.keys(result))
// 转换QQ音乐搜索结果为上传对话框需要的格式
// 直接传递整个 result 对象,让上传对话框自己提取需要的字段
selectedUploadSong.value = result
console.log('准备上传的歌曲数据:', selectedUploadSong.value)
showUploadDialog.value = true
}
// 显示登录弹窗
const handleShowLogin = () => {
showUploadDialog.value = false
showLoginModal.value = true
}
const handleLoginRedirect = async () => {
if (title.value.trim()) {
try {
sessionStorage.setItem(
'pending_search',
JSON.stringify({
title: title.value.trim(),
platform: platform.value
})
)
} catch {
// 浏览器禁用会话存储时仍可继续登录。
}
}
await navigateTo(`/login?redirect=${encodeURIComponent('/?tab=request')}`)
}
// 归一化时长到秒(网易云返回毫秒,其余平台返回秒)
const normalizeDurationToSeconds = (duration, actualMusicPlatform) => {
if (!duration) return null
const d = Number(duration)
if (!isFinite(d) || d <= 0) return null
// 网易云详情接口返回毫秒,其余平台返回秒
return actualMusicPlatform === 'netease' || actualMusicPlatform === 'netease-podcast'
? Math.floor(d / 1000)
: Math.floor(d)
}
const SUBMISSION_AUDIO_DURATION_TIMEOUT_MS = 8000
// 播放地址未提供时长时,仅加载媒体元数据,不启动播放。
const readAudioDuration = (url) => {
if (!url || typeof document === 'undefined') return Promise.resolve(null)
return new Promise((resolve) => {
const audio = document.createElement('audio')
let settled = false
let timeoutId = null
const cleanup = () => {
audio.onloadedmetadata = null
audio.ondurationchange = null
audio.onerror = null
audio.onabort = null
if (timeoutId !== null) window.clearTimeout(timeoutId)
audio.pause()
audio.removeAttribute('src')
audio.load()
}
const finish = (duration) => {
if (settled) return
settled = true
cleanup()
resolve(duration)
}
const handleMetadata = () => {
const duration = Number(audio.duration)
if (Number.isFinite(duration) && duration > 0) {
finish(Math.floor(duration))
}
}
audio.preload = 'metadata'
audio.onloadedmetadata = handleMetadata
audio.ondurationchange = handleMetadata
audio.onerror = () => finish(null)
audio.onabort = () => finish(null)
timeoutId = window.setTimeout(() => finish(null), SUBMISSION_AUDIO_DURATION_TIMEOUT_MS)
try {
audio.src = url
audio.load()
} catch {
finish(null)
}
})
}
const resolveSubmissionDuration = async (result, actualMusicPlatform, fallbackUrl = '') => {
const apiDuration = normalizeDurationToSeconds(result.duration, actualMusicPlatform)
if (apiDuration !== null) return apiDuration
const audioDuration = await readAudioDuration(result.url || result.file || fallbackUrl)
if (audioDuration === null) return null
// 保持 result.duration 与各平台搜索结果的单位一致,后续统一转换为秒。
result.duration =
actualMusicPlatform === 'netease' || actualMusicPlatform === 'netease-podcast'
? audioDuration * 1000
: audioDuration
return audioDuration
}
// 提交选中的歌曲
const submitSong = async (result, options = {}) => {
// 防止重复点击和重复提交
if (submitting.value) return
// 如果是播客/电台模式,且是在网易云平台下,且不是具体的单集提交
if (
platform.value === 'netease' &&
searchType.value === 1009 &&
!options.isPodcastEpisode &&
!options.isDirectSubmit
) {
console.log('打开播客节目列表:', result)
// 打开播客节目列表弹窗
selectedPodcastId.value = result.id || result.musicId
selectedPodcastName.value = result.title || result.song || result.name
podcastCookie.value = neteaseCookie.value
showPodcastModal.value = true
return
}
// 如果是 Bilibili 平台,且有多个剧集,且不是具体的剧集提交
if (
platform.value === 'bilibili' &&
result.pages &&
result.pages.length > 1 &&
!options.isBilibiliEpisode
) {
console.log('打开 Bilibili 剧集列表:', result)
selectedBilibiliVideo.value = result
bilibiliEpisodes.value = result.pages
bilibiliEpisodeSubmitOptions.value =
options.replayRequest === true ? { replayRequest: true } : {}
showBilibiliEpisodesModal.value = true
return
}
console.log('执行submitSong,提交歌曲:', result.title || result.song)
// 使用搜索结果中的数据
const songTitle = result.song || result.title
const songArtist = result.singer || result.artist
let replayTargetSong = null
// 重播申请需要定位到本学期内已播放的原歌曲;多P 视频按完整 musicId 精确匹配,其余平台由 getSimilarSong 归一化回退
if (
options.replayRequest === true &&
!auth.isAdmin.value &&
auth.isAuthenticated.value &&
songService.songs.value &&
songService.songs.value.length > 0 &&
platform.value === 'bilibili' &&
result.musicId
) {
let fullMusicId = String(result.musicId)
if (options.isBilibiliEpisode && options.episode) {
const bvId = fullMusicId.split(':')[0]
const musicIdParts = [bvId, options.episode.cid]
if (options.episode.page && Number(options.episode.page) > 1) {
musicIdParts.push(String(options.episode.page))
}
fullMusicId = musicIdParts.join(':')
}
replayTargetSong =
songService.songs.value.find(
(song) => isBilibiliSong(song) && song.musicId === fullMusicId && song.played
) || null
}
// 能否投稿由服务端预检结论决定(区分本学期查重与排期冷却窗口两种模式,且已随开关关闭放行)
if (!auth.isAdmin.value && isSongBlockedByRestriction(result)) {
if (window.$showNotification) {
window.$showNotification(getRestrictionMessage(getRestrictionReason(result)), 'warning')
}
return
}
submitting.value = true
error.value = ''
title.value = songTitle
artist.value = songArtist
selectedCover.value = result.cover || ''
selectedUrl.value = result.url || result.file || ''
if (options.replayRequest === true) {
try {
// 严格匹配未命中时回退到归一化模糊匹配,与相似歌曲检测保持一致
if (!replayTargetSong) {
const similarSong = getSimilarSong(result)
if (similarSong?.played) {
replayTargetSong = similarSong
}
}
if (!replayTargetSong) {
throw new Error(
locale.value.notifications?.replayOriginalNotFound || '未找到可申请重播的原歌曲'
)
}
const replayResult = await songService.requestReplay(replayTargetSong.id, {
preferredPlayTimeId: preferredPlayTimeId.value ? parseInt(preferredPlayTimeId.value) : null,
submissionNote: submissionNote.value.trim() || null,
submissionNotePublic: submissionNotePublic.value
})
if (!replayResult) return false
resetForm()
return true
} catch (err) {
// 服务端错误按错误码本地化;replayRequestFailed 是带参数的词典函数,需用 formatLocaleValue 求值
const replayErrorMessage = localizeServerError(err)
error.value =
formatLocaleValue(
locale.value.notifications?.replayRequestFailed,
replayErrorMessage || ''
) || replayErrorMessage
if (window.$showNotification) {
window.$showNotification(error.value, 'error')
}
return false
} finally {
submitting.value = false
}
}
if (!(await ensureCardCodeForSubmit())) {
submitting.value = false
return false
}
// 检查投稿限额
const limitCheck = checkSubmissionLimit()
if (!limitCheck.canSubmit) {
error.value = limitCheck.message
if (window.$showNotification) {
window.$showNotification(limitCheck.message, 'error')
}
submitting.value = false
return
}
// 管理员不受黑名单限制
if (!auth.isAdmin.value) {
try {
// 检查黑名单
const blacklistCheck = await $fetch('/api/blacklist/check', {
method: 'POST',
body: {
title: title.value,
artist: artist.value,
musicPlatform: result.actualMusicPlatform || result.musicPlatform || platform.value,
musicId: result.musicId ? String(result.musicId) : null
}
})
if (blacklistCheck.isBlocked) {
const reasons = blacklistCheck.reasons.map((r) => r.reason).join('; ')
error.value = callLocale('notifications.blacklistedSong', '', reasons)
if (window.$showNotification) {
window.$showNotification(error.value, 'error')
}
submitting.value = false
return
}
} catch (err) {
console.error('黑名单检查失败:', err)
// 黑名单检查失败不阻止提交,只记录错误
}
}
const actualMusicPlatform = result.actualMusicPlatform || result.musicPlatform || platform.value
let submissionDurationSeconds = normalizeDurationToSeconds(result.duration, actualMusicPlatform)
// 只有平台接口没有有效时长时,才获取播放地址并读取音频元数据。
if (submissionDurationSeconds === null) {
if (!result.url && !result.file && !selectedUrl.value && result.musicId) {
const fullResult = await getAudioUrl(result)
result = fullResult
selectedUrl.value = fullResult.url || ''
}
submissionDurationSeconds = await resolveSubmissionDuration(
result,
actualMusicPlatform,
selectedUrl.value
)
}
// 处理 Bilibili 分 P 信息
let bilibiliCid = result.bilibiliCid
let bilibiliPage = result.bilibiliPage || null
if (options.isBilibiliEpisode && options.episode) {
bilibiliCid = options.episode.cid
bilibiliPage = options.episode.page
// 追加分P标题
if (options.episode.part && !title.value.includes(options.episode.part)) {
title.value += ` - ${options.episode.part}`
}
}
try {
// 构建歌曲数据对象
const songData = {
title: title.value,
artist: artist.value,
preferredPlayTimeId: preferredPlayTimeId.value ? parseInt(preferredPlayTimeId.value) : null,
cover: selectedCover.value,
musicPlatform: actualMusicPlatform, // 优先使用搜索结果的实际平台来源
musicId: result.musicId ? String(result.musicId) : null,
durationSeconds: submissionDurationSeconds,
submissionNote: submissionNote.value.trim() || null,
submissionNotePublic: submissionNotePublic.value,
collaborators: collaborators.value.map((u) => u.id),
bilibiliCid: bilibiliCid || null,
bilibiliPage: bilibiliPage
}
// 如果用户填写了点歌券,传递给后端
if (cardCode.value && cardCode.value.trim()) {
songData.cardCode = cardCode.value.trim()
}
// 只emit事件,让父组件处理实际的API调用
emit('request', songData)
// 成功提示由父组件处理,这里只重置表单
resetForm()
return true
} catch (err) {
error.value = getErrorMessage(err) || locale.value.notifications.submitFailed
if (window.$showNotification) {
window.$showNotification(error.value, 'error')
}
return false
} finally {
submitting.value = false
}
}
// 直接提交表单
const handleSubmit = async () => {
if (submitting.value) return
if (!(await ensureCardCodeForSubmit())) {
return
}
// 检查投稿限额
const limitCheck = checkSubmissionLimit()
if (!limitCheck.canSubmit) {
error.value = limitCheck.message
if (window.$showNotification) {
window.$showNotification(limitCheck.message, 'error')
}
return
}
submitting.value = true
error.value = ''
try {
// 构建歌曲数据对象
const songData = {
title: title.value,
artist: artist.value,
preferredPlayTimeId: preferredPlayTimeId.value ? parseInt(preferredPlayTimeId.value) : null,
cover: selectedCover.value,
musicPlatform: platform.value,
musicId: null, // 手动输入时没有musicId
submissionNote: submissionNote.value.trim() || null,
submissionNotePublic: submissionNotePublic.value,
collaborators: collaborators.value.map((u) => u.id)
}
if (cardCode.value && cardCode.value.trim()) {
songData.cardCode = cardCode.value.trim()
}
// 只emit事件,让父组件处理实际的API调用
emit('request', songData)
// 成功提示由父组件处理,这里只重置表单
resetForm()
} catch (err) {
error.value = getErrorMessage(err) || locale.value.notifications.submitFailed
if (window.$showNotification) {
window.$showNotification(error.value, 'error')
}
} finally {
submitting.value = false
}
}
const isBilibiliMultiP = (result) => {
return result && platform.value === 'bilibili' && result.pages && result.pages.length > 1
}
const getBilibiliEpisodeStatus = (result) => {
if (!result || !isBilibiliMultiP(result)) return null
const currentSemesterName = currentSemester.value?.name
const bvid = result.id
const submittedEpisodes = songService.songs.value.filter((song) => {
if (song.musicPlatform !== 'bilibili') return false
if (!song.musicId) return false
const songBvid = song.musicId.includes(':') ? song.musicId.split(':')[0] : song.musicId
const isSameBvid = songBvid === bvid
if (currentSemesterName) {
return isSameBvid && song.semester === currentSemesterName
}
return isSameBvid
})
const totalEpisodes = result.pages.length
const submittedCount = submittedEpisodes.length
return {
submittedEpisodes,
submittedCount,
totalEpisodes,
allSubmitted: submittedCount === totalEpisodes,
partialSubmitted: submittedCount > 0 && submittedCount < totalEpisodes,
noneSubmitted: submittedCount === 0
}
}
const canResubmitBilibiliEpisodes = (result) => {
if (!enableReplayRequests.value) return false
const episodeStatus = getBilibiliEpisodeStatus(result)
if (!episodeStatus || episodeStatus.submittedEpisodes.length === 0) return false
return episodeStatus.submittedEpisodes.every((song) => song.played)
}
const handleBilibiliEpisodeSelect = async (payload) => {
if (!selectedBilibiliVideo.value) return
const episode = payload?.episode || payload
const episodeStatus = payload?.status || null
const isReplayEpisode = episodeStatus?.played === true
const episodeResult = {
...selectedBilibiliVideo.value,
title: `${selectedBilibiliVideo.value.title} - ${episode.part}`,
bilibiliCid: episode.cid,
duration: episode.duration
}
const success = await submitSong(episodeResult, {
...(isReplayEpisode ? { replayRequest: true } : bilibiliEpisodeSubmitOptions.value),
isBilibiliEpisode: true,
episode: episode
})
if (success) {
showBilibiliEpisodesModal.value = false
bilibiliEpisodeSubmitOptions.value = {}
if (bilibiliModalRef.value && bilibiliModalRef.value.resetSubmissionState) {
bilibiliModalRef.value.resetSubmissionState()
}
} else {
if (bilibiliModalRef.value && bilibiliModalRef.value.resetSubmissionState) {
bilibiliModalRef.value.resetSubmissionState()
}
}
}
const handleBilibiliEpisodePlay = async ({ song: episodeData, playlist, playlistIndex }) => {
// 与 biliPlaylist 使用相同的 ID 构建逻辑,确保匹配
const bvid = episodeData.bvid || episodeData.id
let finalId = bvid
if (episodeData.cid) {
finalId = bvid + ':' + episodeData.cid
const page = episodeData.bilibiliPage || episodeData.page
if (page && Number(page) > 1) {
finalId += ':' + page
}
}
const episodeResult = {
id: finalId,
title: `${episodeData.title} - ${episodeData.part}`,
artist: episodeData.artist,
cover: episodeData.cover || '',
musicId: finalId,
musicPlatform: 'bilibili',
bilibiliCid: episodeData.cid,
duration: episodeData.duration,
sourceInfo: { source: 'bilibili' }
}
// 转换播放列表为统一格式
let biliPlaylist
let biliIndex
if (playlist && playlist.length > 0) {
biliPlaylist = playlist.map((e) => {
const bvid = e.bvid || e.id
let finalId = bvid
if (e.cid) {
finalId = bvid + ':' + e.cid
const page = e.bilibiliPage || e.page
if (page && Number(page) > 1) {
finalId += ':' + page
}
}
return {
id: finalId,
title: `${e.title} - ${e.part}`,
artist: e.artist,
cover: e.cover || '',
musicId: finalId,
musicPlatform: 'bilibili',
bilibiliCid: e.cid,
duration: e.duration,
sourceInfo: { source: 'bilibili' }
}
})
biliIndex =
typeof playlistIndex === 'number'
? playlistIndex
: playlist.findIndex((e) => e.cid === episodeData.cid)
if (biliIndex < 0) biliIndex = 0
}
await playSong(episodeResult, biliPlaylist, biliIndex)
}
// 引用模态框组件
const bilibiliModalRef = ref(null)
const podcastModalRef = ref(null)
const recentSongsModalRef = ref(null)
const playlistModalRef = ref(null)
const albumModalRef = ref(null)
// 判断搜索结果是否为网易云专辑(仅网易云支持专辑详情弹窗)
const isNeteaseAlbum = (result) => {
const p = result.actualMusicPlatform || result.musicPlatform || platform.value
return !!(result.albumId && p && p.includes('netease'))
}
// 判断结果是否来自QQ音乐(用于显示上传按钮)
const isTencentSource = (result) => {
const p = result.actualMusicPlatform || result.musicPlatform || ''
return p.includes('vkeys') || p === 'tencent'
}
// 打开专辑详情(标准化平台名称,AlbumDetailsModal 只识别 netease/tencent)
const openAlbumDetails = (result) => {
if (!result.albumId) return
selectedAlbumId.value = result.albumId
selectedAlbumName.value = result.album || result.albumName
const rawPlatform = result.actualMusicPlatform || result.musicPlatform || platform.value
selectedAlbumPlatform.value = rawPlatform.includes('netease') ? 'netease' : 'tencent'
showAlbumDetailsModal.value = true
}
// 处理专辑歌曲播放
const handleAlbumSongPlay = async ({ song, playlist, playlistIndex }) => {
await playSong(song, playlist, playlistIndex)
}
// 处理专辑歌曲投稿
const handleAlbumSongSubmit = async (songData) => {
const success = await submitSong(songData, { isDirectSubmit: true })
if (success) {
showAlbumDetailsModal.value = false
}
if (albumModalRef.value && albumModalRef.value.resetSubmissionState) {
albumModalRef.value.resetSubmissionState()
}
}
// 处理专辑歌曲点赞
const handleAlbumSongVote = async (song) => {
if (voting.value) return
if (!song.songId) {
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.missingSongId, 'error')
}
return
}
if (song.voted) {
return
}
voting.value = true
try {
await songService.voteSong(song.songId)
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.likeSuccess, 'success')
}
// 静默刷新歌曲列表
songService.refreshSongsSilent().catch((err) => {
console.error('刷新歌曲列表失败', err)
})
} catch (error) {
console.error('点赞失败:', error)
if (window.$showNotification) {
window.$showNotification(
getErrorMessage(error) || locale.value.notifications.likeFailedRetry,
'error'
)
}
} finally {
voting.value = false
}
}
// 处理播客单集提交
const handlePodcastSubmit = async (song) => {
const success = await submitSong(song, { isPodcastEpisode: true })
if (success) {
showPodcastModal.value = false
} else {
// 如果失败,重置模态框内的提交状态
if (podcastModalRef.value && podcastModalRef.value.resetSubmissionState) {
podcastModalRef.value.resetSubmissionState()
}
}
}
// 处理播客单集播放
const handlePodcastPlay = async (song) => {
console.log('播放播客单集:', song.title)
await playSong(song)
}
// 处理最近播放歌曲提交
const handleRecentSongSubmit = async (song) => {
const success = await submitSong(song, { isPodcastEpisode: false, isDirectSubmit: true })
if (success) {
showRecentSongsModal.value = false
} else {
// 如果失败,重置模态框内的提交状态
if (recentSongsModalRef.value && recentSongsModalRef.value.resetSubmissionState) {
recentSongsModalRef.value.resetSubmissionState()
}
}
}
// 处理最近播放歌曲播放
const handleRecentSongPlay = async (song) => {
await playSong(song)
}
// 处理歌单歌曲提交
const handlePlaylistSubmit = async (song) => {
const success = await submitSong(song, { isPodcastEpisode: false, isDirectSubmit: true })
if (success) {
showPlaylistModal.value = false
} else {
// 如果失败,重置模态框内的提交状态
if (playlistModalRef.value && playlistModalRef.value.resetSubmissionState) {
playlistModalRef.value.resetSubmissionState()
}
}
}
// 处理歌单歌曲播放
const handlePlaylistPlay = async (song) => {
await playSong(song)
}
// 手动输入相关方法
const handleManualSubmit = async () => {
if (!title.value.trim() || !manualArtist.value.trim()) {
error.value = locale.value.notifications.completeSongInfoRequired
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.completeSongInfoRequired, 'error')
}
return
}
// 验证URL
if (manualCover.value && !coverValidation.value.valid) {
error.value = locale.value.notifications.fixCoverUrl
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.fixCoverUrl, 'error')
}
return
}
if (manualPlayUrl.value && !playUrlValidation.value.valid) {
error.value = locale.value.notifications.fixPlayUrl
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.fixPlayUrl, 'error')
}
return
}
if (!(await ensureCardCodeForSubmit())) {
return
}
// 检查投稿限额
const limitCheck = checkSubmissionLimit()
if (!limitCheck.canSubmit) {
error.value = limitCheck.message
if (window.$showNotification) {
window.$showNotification(limitCheck.message, 'error')
}
return
}
submitting.value = true
error.value = ''
// 管理员不受黑名单限制
if (!auth.isAdmin.value) {
try {
// 检查黑名单
const blacklistCheck = await $fetch('/api/blacklist/check', {
method: 'POST',
body: {
title: title.value,
artist: manualArtist.value,
musicPlatform: platform.value,
musicId: null // 手动输入没有 musicId,类型黑名单不生效
}
})
if (blacklistCheck.isBlocked) {
const reasons = blacklistCheck.reasons.map((r) => r.reason).join('; ')
error.value = callLocale('notifications.blacklistedSong', '', reasons)
if (window.$showNotification) {
window.$showNotification(error.value, 'error')
}
submitting.value = false
return
}
} catch (err) {
console.error('黑名单检查失败:', err)
// 黑名单检查失败不阻止提交,只记录错误
}
}
try {
// 构建歌曲数据对象
// 如果手动填入了 playUrl,则不保留平台标识符
const trimmedPlayUrl = manualPlayUrl.value?.trim() || ''
const hasManualPlayUrl = !!trimmedPlayUrl
const songData = {
title: title.value,
artist: manualArtist.value,
preferredPlayTimeId: preferredPlayTimeId.value ? parseInt(preferredPlayTimeId.value) : null,
cover: manualCover.value?.trim() || '',
playUrl: trimmedPlayUrl,
musicPlatform: hasManualPlayUrl ? null : platform.value,
musicId: null, // 手动输入时没有musicId
submissionNote: submissionNote.value.trim() || null,
submissionNotePublic: submissionNotePublic.value
}
if (cardCode.value && cardCode.value.trim()) {
songData.cardCode = cardCode.value.trim()
}
// 只emit事件,让父组件处理实际的API调用
emit('request', songData)
// 成功提示由父组件处理,这里只重置表单和关闭弹窗
resetForm()
showManualModal.value = false
} catch (err) {
error.value = getErrorMessage(err) || locale.value.notifications.submitFailed
if (window.$showNotification) {
window.$showNotification(error.value, 'error')
}
} finally {
submitting.value = false
}
}
// 重置表单
const resetForm = () => {
title.value = ''
artist.value = ''
preferredPlayTimeId.value = ''
searchResults.value = []
restrictionCheckMap.value = new Map()
selectedCover.value = ''
selectedUrl.value = ''
showManualModal.value = false
manualArtist.value = ''
manualCover.value = ''
manualPlayUrl.value = ''
hasSearched.value = false
collaborators.value = []
submissionNote.value = ''
submissionNotePublic.value = true
cardCode.value = ''
cardCodeDraft.value = ''
showCardCodeModal.value = false
resetCardCodeValidation()
// 重置URL验证状态
coverValidation.value = { valid: true, error: '', validating: false }
playUrlValidation.value = { valid: true, error: '', validating: false }
}
// 停止播放
const stopPlaying = () => {
audioPlayer.stopSong()
}
// 获取投稿状态
const fetchSubmissionStatus = async () => {
if (!user.value) return
loadingSubmissionStatus.value = true
try {
const authConfig = auth.getAuthConfig()
const response = await $fetch('/api/songs/submission-status', authConfig)
submissionStatus.value = response
} catch (err) {
console.error('获取投稿状态失败:', err)
} finally {
loadingSubmissionStatus.value = false
}
}
// 检查投稿限额
const checkSubmissionLimit = () => {
// 超级管理员不受投稿限制
if (user.value && (user.value.role === 'SUPER_ADMIN' || user.value.role === 'ADMIN')) {
return { canSubmit: true, message: '' }
}
if (!submissionStatus.value) {
return { canSubmit: true, message: '' }
}
// 检查投稿是否已关闭
if (submissionStatus.value.submissionClosed) {
let message = locale.value.submissionClosed
if (submissionStatus.value.timeLimitationEnabled && !submissionStatus.value.currentTimePeriod) {
message = locale.value.outsideRequestTime
}
return {
canSubmit: false,
message: message
}
}
// 检查投稿时段名额限制
if (submissionStatus.value.timeLimitationEnabled && submissionStatus.value.currentTimePeriod) {
const { expected, accepted } = submissionStatus.value.currentTimePeriod
if (expected > 0 && accepted >= expected) {
return {
canSubmit: false,
message: callLocale('notifications.periodQuotaFull', '', accepted, expected)
}
}
}
if (!submissionStatus.value.limitEnabled) {
return { canSubmit: true, message: '' }
}
if (
cardCodeLimitBypassActive.value &&
trimmedCardCode.value &&
cardCodeValidation.value.valid === true
) {
return { canSubmit: true, message: '' }
}
const { dailyLimit, weeklyLimit, monthlyLimit, dailyUsed, weeklyUsed, monthlyUsed } =
submissionStatus.value
// 检查日限额
if (dailyLimit && dailyUsed >= dailyLimit) {
return {
canSubmit: false,
message: callLocale('notifications.dailyLimitReached', '', dailyUsed, dailyLimit)
}
}
// 检查周限额
if (weeklyLimit && weeklyUsed >= weeklyLimit) {
return {
canSubmit: false,
message: callLocale('notifications.weeklyLimitReached', '', weeklyUsed, weeklyLimit)
}
}
// 检查月限额
if (monthlyLimit && monthlyUsed >= monthlyLimit) {
return {
canSubmit: false,
message: callLocale('notifications.monthlyLimitReached', '', monthlyUsed, monthlyLimit)
}
}
return { canSubmit: true, message: '' }
}
// URL验证函数
const validateCoverUrl = async (url) => {
if (!url) {
coverValidation.value = { valid: true, error: '', validating: false }
return
}
coverValidation.value.validating = true
const result = validateUrl(url)
coverValidation.value = {
valid: result.valid,
error: result.error || '',
validating: false
}
}
const validatePlayUrl = async (url) => {
if (!url) {
playUrlValidation.value = { valid: true, error: '', validating: false }
return
}
playUrlValidation.value.validating = true
const result = validateUrl(url)
playUrlValidation.value = {
valid: result.valid,
error: result.error || '',
validating: false
}
}
// 监听URL变化并验证
watch(manualCover, (newUrl) => {
if (newUrl) {
// 防抖处理,避免频繁验证
clearTimeout(coverValidation.value.debounceTimer)
coverValidation.value.debounceTimer = setTimeout(() => {
validateCoverUrl(newUrl)
}, 1000)
} else {
coverValidation.value = { valid: true, error: '', validating: false }
}
})
watch(manualPlayUrl, (newUrl) => {
if (newUrl) {
// 防抖处理,避免频繁验证
clearTimeout(playUrlValidation.value.debounceTimer)
playUrlValidation.value.debounceTimer = setTimeout(() => {
validatePlayUrl(newUrl)
}, 1000)
} else {
playUrlValidation.value = { valid: true, error: '', validating: false }
}
})
// 计算属性:检查手动表单是否可以提交
const canSubmitFromSearch = computed(() => {
// 复用 checkSubmissionLimit:包含管理员豁免、投稿关闭、时段名额、日/周/月限额及卡码绕过
return checkSubmissionLimit().canSubmit
})
const isSongBlockedByRestriction = (result) => {
if (auth.isAdmin.value) return false
if (restrictionChecking.value) return false
const songTitle = result.song || result.title
const songArtist = result.singer || result.artist
const key = `${songTitle}||${songArtist}`
const check = restrictionCheckMap.value.get(key)
if (!check) return false
return check.blocked
}
const getRestrictionReason = (result) => {
const songTitle = result.song || result.title
const songArtist = result.singer || result.artist
const key = `${songTitle}||${songArtist}`
const check = restrictionCheckMap.value.get(key)
return check?.reason || null
}
const getRestrictionMessage = (reason) => {
if (reason === 'duplicateSong') return locale.value.notifications?.duplicateSong
if (reason === 'sameSong') return locale.value.notifications?.restrictionSameSong
if (reason === 'sameArtist') return locale.value.notifications?.restrictionSameArtist
return locale.value.notifications?.restrictionGeneric
}
const fetchRestrictionChecks = async () => {
// 每次搜索后清空旧预检结果,避免上一轮结果污染
const requestId = ++restrictionCheckRequestId
restrictionAbortController.value?.abort()
restrictionAbortController.value = new AbortController()
restrictionCheckMap.value = new Map()
if (!auth.isAuthenticated.value || searchResults.value.length === 0) {
restrictionChecking.value = false
return
}
restrictionChecking.value = true
const uniqueResults = []
const resultKeys = new Set()
for (const result of searchResults.value) {
const title = result.song || result.title
const artist = result.singer || result.artist
const key = `${title}||${artist}`
if (!title || !artist || resultKeys.has(key)) continue
resultKeys.add(key)
uniqueResults.push({
key,
title,
artist,
musicPlatform: result.actualMusicPlatform || result.musicPlatform || '',
musicId: result.musicId || ''
})
}
if (uniqueResults.length === 0) {
if (requestId === restrictionCheckRequestId) {
restrictionChecking.value = false
}
return
}
try {
const authConfig = auth.getAuthConfig()
const response = await $fetch('/api/songs/check-restriction', {
method: 'POST',
body: { songs: uniqueResults },
signal: restrictionAbortController.value.signal,
...authConfig
})
const checks = Array.isArray(response?.checks) ? response.checks : []
if (requestId === restrictionCheckRequestId) {
uniqueResults.forEach((result, index) => {
restrictionCheckMap.value.set(result.key, checks[index] || { blocked: false, reason: null })
})
}
} catch (err) {
const status = err && (err.statusCode || (err.data && err.data.statusCode))
if (status === 401 && requestId === restrictionCheckRequestId) {
console.warn('投稿限制预检鉴权失败,未应用限制', err)
}
} finally {
if (requestId === restrictionCheckRequestId) {
restrictionChecking.value = false
}
}
}
const canSubmitManualForm = computed(() => {
// 必填字段检查
if (!manualArtist.value.trim()) {
return false
}
// 可选字段验证检查
// 如果输入了封面URL,必须验证通过且不在验证中
if (manualCover.value && (!coverValidation.value.valid || coverValidation.value.validating)) {
return false
}
// 如果输入了播放URL,必须验证通过且不在验证中
if (
manualPlayUrl.value &&
(!playUrlValidation.value.valid || playUrlValidation.value.validating)
) {
return false
}
return true
})
// 暴露方法给父组件
defineExpose({
refreshSubmissionStatus: fetchSubmissionStatus
})
</script>
<style scoped>
.request-form {
width: 100%;
color: var(--text-primary);
display: flex;
gap: 2rem;
height: calc(100vh - 160px);
min-height: 650px;
}
/* 桌面端样式 */
.desktop-only-rules {
display: block;
}
.mobile-only-rules {
display: none;
}
.rules-section {
background: var(--surface-card-bg);
border-radius: 13px;
padding: 1.25rem;
flex: 0 0 35%; /* 稍微缩小规则区域占比 */
min-width: 300px;
height: 100%;
overflow-y: auto;
}
.rules-content-desktop {
font-family: 'MiSans', sans-serif;
font-weight: 400;
font-size: 15px;
line-height: 1.7;
letter-spacing: 0.04em;
color: var(--overlay-40);
}
.rules-content-desktop p {
margin-bottom: 0.6rem;
}
.guidelines-content {
overflow-wrap: anywhere;
}
/* 移动端样式 */
.rules-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 15px;
font-weight: 700;
color: var(--overlay-90);
margin-bottom: 1.25rem;
}
.rules-icon {
color: var(--color-warning);
}
.rules-content {
font-family: 'MiSans', sans-serif;
font-size: 13px;
line-height: 1.8;
color: var(--overlay-50);
}
.rule-item {
display: flex;
margin-bottom: 0.75rem;
}
.rule-item span {
margin-right: 0.5rem;
color: var(--overlay-30);
font-weight: 600;
}
.form-container {
flex: 1;
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.song-request-form {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
gap: 1rem;
position: relative;
}
/* 搜索区域样式 */
.form-header-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.75rem;
padding-top: 4px; /* 为按钮悬停上浮留出空间 */
flex-wrap: wrap; /* 允许在窄屏下换行 */
}
.search-section {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1.5;
min-width: 460px; /* 增加最小宽度,确保搜索框、标签和按钮有足够空间 */
flex-wrap: wrap; /* 允许在窄屏下换行 */
}
.search-label {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 15px;
color: var(--text-primary);
white-space: nowrap;
flex-shrink: 0; /* 防止标签被压缩 */
}
.search-input-group {
display: flex;
gap: 0.5rem;
flex: 1;
min-width: 220px; /* 保证按钮与输入框同行不重叠 */
}
.search-input {
background: var(--panel-bg-quaternary);
border: 1px solid var(--panel-border-active);
border-radius: 8px;
padding: 0.6rem 0.85rem;
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 15px;
color: var(--overlay-60);
flex: 1;
min-width: 80px; /* 确保输入框不会缩到太小 */
}
.search-input:focus {
outline: none;
border-color: var(--color-accent);
}
.search-button {
background: linear-gradient(180deg, var(--color-accent) 0%, var(--color-accent) 100%);
border: 1px solid var(--overlay-16);
border-radius: 8px;
padding: 0.75rem 1.2rem;
color: var(--text-primary);
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
transition: all 0.3s ease;
flex-shrink: 0; /* 确保搜索按钮始终完整显示,不被重叠 */
}
.search-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px var(--primary-30);
}
.search-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.audio-match-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
background: var(--overlay-8);
border: 1px solid var(--overlay-12);
border-radius: 8px;
padding: 0.75rem 0.75rem;
color: var(--overlay-85);
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 13px;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s ease;
flex-shrink: 0;
}
.audio-match-btn .btn-text {
display: none;
}
.audio-match-btn:hover:not(:disabled) {
background: var(--primary-18);
border-color: var(--requestform-audio-match-hover-border);
color: var(--text-primary);
}
.audio-match-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 联合投稿人区域 */
.collaborators-section {
display: flex;
align-items: center;
gap: 1rem;
flex: 1;
min-width: 200px; /* 增加最小宽度,防止在窄屏下与搜索框重叠 */
}
.section-label {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 16px;
color: var(--text-primary);
white-space: nowrap;
flex-shrink: 0; /* 防止标签被压缩 */
}
.collaborators-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.collaborator-tag {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--color-accent-alpha-10);
border: 1px solid var(--color-accent-alpha-20);
border-radius: 6px;
padding: 0.25rem 0.5rem;
font-size: 14px;
color: var(--text-primary);
}
.remove-collaborator {
background: none;
border: none;
color: var(--overlay-60);
cursor: pointer;
display: flex;
align-items: center;
padding: 0;
}
.remove-collaborator:hover {
color: var(--text-primary);
}
.add-collaborator-btn {
display: flex;
align-items: center;
gap: 0.25rem;
background: var(--overlay-5);
border: 1px solid var(--overlay-10);
border-radius: 6px;
padding: 0.25rem 0.75rem;
font-size: 13px;
color: var(--overlay-80);
cursor: pointer;
transition: all 0.2s;
}
.add-collaborator-btn:hover {
background: var(--overlay-10);
color: var(--text-primary);
}
/* 自定义复选框样式 */
.custom-checkbox-wrapper {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
}
.custom-checkbox-input {
display: none;
}
.custom-checkbox-box {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 4px;
border: 1px solid var(--panel-bg-hover);
background: var(--panel-bg-overlay);
transition: all 0.2s ease;
}
.custom-checkbox-icon {
width: 8px;
height: 8px;
opacity: 0;
transform: scale(0.5);
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
color: white;
}
.custom-checkbox-input:checked + .custom-checkbox-box {
background: var(--color-accent-light);
border-color: var(--color-accent-light);
}
.custom-checkbox-input:checked + .custom-checkbox-box .custom-checkbox-icon {
opacity: 1;
transform: scale(1);
}
.custom-checkbox-text {
font-size: 11px;
color: var(--text-muted);
transition: color 0.2s ease;
}
.custom-checkbox-input:checked ~ .custom-checkbox-text {
color: var(--text-primary-lighter);
}
.custom-checkbox-wrapper:hover .custom-checkbox-box {
border-color: var(--color-accent-light);
}
/* 横向投稿状态样式 */
.login-required-notice {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: var(--primary-10);
border-color: var(--primary-30);
}
.login-required-notice .notice-icon {
flex-shrink: 0;
color: var(--color-accent-light);
}
.login-required-notice .notice-text {
color: var(--text-link-hover);
font-size: 13px;
font-weight: 500;
}
.login-required-notice .login-link-btn {
padding: 0.2rem 0.6rem;
border: 1px solid var(--primary-40);
border-radius: 4px;
background: var(--primary-20);
color: var(--color-accent-light);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition:
background 0.2s ease,
color 0.2s ease;
}
.login-required-notice .login-link-btn:hover {
background: var(--primary-35);
color: var(--text-link-hover);
}
.submission-status-horizontal {
background: var(--surface-card-bg-medium);
border-radius: 8px;
padding: 0.4rem 0.75rem;
margin-bottom: 0.5rem;
border: 1px solid var(--overlay-10);
}
.admin-notice-horizontal {
display: flex;
align-items: center;
gap: 0.4rem;
justify-content: center;
}
.admin-notice-horizontal .admin-icon {
font-size: 14px;
}
.admin-notice-horizontal .admin-text {
font-family: 'MiSans', sans-serif;
font-weight: 500;
font-size: 13px;
color: var(--text-highlight);
}
.submission-closed-notice {
display: flex;
align-items: center;
gap: 0.4rem;
justify-content: center;
}
.submission-closed-notice .closed-icon {
font-size: 14px;
}
.submission-closed-notice .closed-text {
font-family: 'MiSans', sans-serif;
font-weight: 500;
font-size: 13px;
color: var(--color-error-light);
}
.status-content-horizontal {
display: flex;
align-items: center;
gap: 1.5rem;
justify-content: center;
flex-wrap: wrap;
}
.status-item-horizontal {
display: flex;
align-items: center;
gap: 0.4rem;
}
.status-item-horizontal .status-label {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 13px;
color: var(--text-primary);
}
.status-item-horizontal .status-value {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 13px;
color: var(--color-accent);
}
.status-item-horizontal .status-remaining {
font-family: 'MiSans', sans-serif;
font-weight: 500;
font-size: 12px;
color: var(--overlay-70);
background: var(--color-accent-alpha-10);
border: 1px solid var(--color-accent-alpha-30);
border-radius: 4px;
padding: 0.15rem 0.4rem;
}
.form-row {
display: flex;
gap: 1rem;
width: 100%;
}
.form-row .form-group {
flex: 1;
min-width: 0;
}
.submission-meta-row {
align-items: stretch;
}
.submission-note-group,
.card-code-form-group {
flex-basis: 0;
}
.desktop-card-code-panel {
min-height: 94px;
height: 100%;
border: 1px solid var(--panel-bg-deep);
border-radius: 12px;
background: var(--panel-bg-overlay);
padding: 0.75rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.mobile-card-code-chip,
.mobile-card-code-button {
display: none;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex-shrink: 0; /* 防止被压缩 */
position: relative;
z-index: 10;
}
.form-group label {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 15px;
letter-spacing: 0.02em;
color: var(--overlay-90);
margin-bottom: 0.25rem;
}
.input-wrapper {
width: 100%;
}
.form-input,
.form-select {
background: var(--panel-bg-quaternary);
border: 1px solid var(--panel-border-active);
border-radius: 8px;
padding: 0.75rem 1rem;
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 16px;
color: var(--overlay-60);
width: 100%;
}
.form-input:focus,
.form-select:focus {
outline: none;
border-color: var(--color-accent);
}
/* 平台选择按钮样式 */
.platform-selection-container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
flex-shrink: 0;
flex-wrap: wrap;
}
.platform-selection {
display: flex;
gap: 0.5rem;
align-items: center;
}
/* 网易云音乐登录选项 - 内联风格 */
.netease-options {
position: relative;
background: transparent;
border-radius: 0;
padding: 0;
border: none;
display: flex;
flex-direction: column;
gap: 0;
flex: 1;
min-width: 300px;
justify-content: center;
}
.netease-options:hover {
background: transparent;
border-color: transparent;
}
.netease-options::before {
display: none;
}
.login-entry {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
background: transparent;
padding: 0;
border: none;
}
.login-desc {
display: flex;
align-items: center;
gap: 0.5rem;
}
.login-title {
font-size: 13px;
font-weight: 500;
color: var(--text-muted-light);
margin: 0;
}
.login-hint {
font-size: 11px;
color: var(--overlay-45);
margin: 2px 0 0 0;
line-height: 1.3;
}
.login-btn {
background: linear-gradient(135deg, var(--color-accent-light) 0%, var(--color-accent-hover) 100%);
color: white;
border: none;
padding: 0.45rem 0.85rem;
border-radius: 7px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
font-family: 'MiSans', sans-serif;
font-weight: 600;
white-space: nowrap;
box-shadow: 0 4px 10px var(--primary-15);
}
.login-btn:hover {
transform: translateY(-1px);
box-shadow: 0 5px 14px var(--primary-25);
filter: brightness(1.1);
}
.qq-login-btn {
background: linear-gradient(135deg, var(--color-teal) 0%, var(--color-teal) 100%);
box-shadow: 0 4px 10px var(--requestform-qq-login-shadow);
}
.qq-login-btn:hover {
box-shadow: 0 5px 14px var(--requestform-qq-login-shadow-hover);
}
.header-actions {
display: flex;
align-items: center;
gap: 0.4rem;
}
.header-btn {
display: flex;
align-items: center;
gap: 0.25rem;
background: transparent;
border: none;
color: var(--overlay-50);
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s ease;
}
.header-btn:hover {
color: var(--text-primary);
background: var(--overlay-8);
}
.login-actions {
display: flex;
align-items: center;
gap: 0.6rem;
}
.import-btn {
background: var(--overlay-4);
color: var(--overlay-70);
border: 1px solid var(--overlay-8);
padding: 0.45rem 0.75rem;
border-radius: 7px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
font-family: 'MiSans', sans-serif;
font-weight: 500;
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.3rem;
}
.import-btn:hover {
background: var(--overlay-8);
border-color: var(--overlay-15);
color: var(--text-primary);
}
.user-status {
display: flex;
flex-direction: column;
}
.user-compact-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.user-profile {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.user-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
border: 1.5px solid var(--overlay-10);
}
.qq-user-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
flex-shrink: 0;
border-radius: 50%;
color: var(--color-cyan);
background: var(--requestform-user-icon-bg);
border: 1.5px solid var(--requestform-user-icon-border);
}
.user-name {
font-size: 12px;
color: var(--overlay-90);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-actions-row {
display: flex;
gap: 0.4rem;
}
.action-btn-compact {
display: flex;
align-items: center;
gap: 0.3rem;
background: var(--overlay-4);
border: 1px solid var(--overlay-6);
color: var(--overlay-70);
padding: 0.35rem 0.6rem;
border-radius: 6px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
white-space: nowrap;
}
.action-btn-compact:hover {
background: var(--overlay-8);
color: var(--text-primary);
border-color: var(--overlay-12);
}
.audio-waveform {
display: flex;
align-items: center;
gap: 3px;
height: 24px;
}
.audio-waveform .wave-bar {
display: block;
width: 3px;
height: 100%;
background: linear-gradient(
180deg,
var(--color-accent-light) 0%,
var(--color-accent-light-hover) 100%
);
border-radius: 2px;
animation: wave 1.2s ease-in-out infinite;
}
.audio-waveform .wave-bar:nth-child(1) {
animation-delay: 0s;
height: 45%;
}
.audio-waveform .wave-bar:nth-child(2) {
animation-delay: 0.1s;
height: 70%;
}
.audio-waveform .wave-bar:nth-child(3) {
animation-delay: 0.2s;
height: 100%;
}
.audio-waveform .wave-bar:nth-child(4) {
animation-delay: 0.3s;
height: 60%;
}
.audio-waveform .wave-bar:nth-child(5) {
animation-delay: 0.4s;
height: 40%;
}
@keyframes wave {
0%,
100% {
transform: scaleY(0.3);
opacity: 0.5;
}
50% {
transform: scaleY(1);
opacity: 1;
}
}
.audio-match-primary-btn,
.audio-match-cancel-btn,
.audio-match-record-btn,
.audio-match-result-item {
transition: all 0.2s ease;
}
.audio-match-primary-btn {
background: linear-gradient(
135deg,
var(--color-accent-hover) 0%,
var(--color-accent-light-hover) 100%
);
border: none;
border-radius: 12px;
padding: 0.875rem 2rem;
color: var(--text-primary);
font-weight: 600;
font-size: 14px;
cursor: pointer;
box-shadow: 0 4px 14px var(--requestform-login-btn-hover-shadow);
}
.audio-match-primary-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 20px var(--requestform-login-btn-hover-shadow-lg);
}
.audio-match-primary-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.audio-match-record-btn {
background: linear-gradient(135deg, var(--color-error-hover) 0%, var(--color-error-hover) 100%);
border: none;
border-radius: 12px;
padding: 0.875rem 2rem;
color: var(--text-primary);
font-weight: 600;
font-size: 14px;
cursor: pointer;
box-shadow: 0 4px 14px var(--requestform-like-btn-hover-shadow);
display: flex;
align-items: center;
gap: 8px;
}
.audio-match-record-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 20px var(--requestform-like-btn-hover-shadow-lg);
}
.recording-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-primary);
animation: pulse-dot 1s ease-in-out infinite;
}
@keyframes pulse-dot {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.8);
}
}
.audio-match-cancel-btn {
background: transparent;
border: 1px solid var(--overlay-10);
border-radius: 12px;
padding: 0.875rem 1.5rem;
color: var(--text-muted-light);
font-weight: 500;
font-size: 14px;
cursor: pointer;
}
.audio-match-cancel-btn:hover {
background: var(--overlay-5);
color: var(--text-primary-lighter);
border-color: var(--overlay-15);
}
.audio-match-result-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
border-radius: 14px;
border: 1px solid var(--overlay-8);
background: var(--overlay-3);
padding: 0.95rem 1rem;
cursor: pointer;
}
.audio-match-result-item:hover {
background: var(--primary-10);
border-color: var(--requestform-audio-match-hover-border-light);
transform: translateY(-1px);
}
.search-type-switch {
display: flex;
background: var(--surface-card-bg-soft);
border-radius: 6px;
padding: 2px;
border: 1px solid var(--overlay-5);
}
.radio-label {
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: var(--overlay-40);
cursor: pointer;
padding: 0.15rem 0.5rem;
border-radius: 4px;
transition: all 0.2s ease;
}
.radio-label.active {
color: var(--text-primary);
font-weight: 600;
background: var(--overlay-8);
box-shadow: 0 1px 3px var(--mask-10);
}
.radio-label input {
display: none;
}
.logout-btn {
display: flex;
align-items: center;
gap: 0.2rem;
background: transparent;
border: none;
color: var(--overlay-40);
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s ease;
}
.logout-btn:hover {
color: var(--overlay-70);
background: var(--overlay-5);
}
.platform-btn {
background: var(--overlay-8);
border: 1px solid var(--overlay-12);
border-radius: 8px;
padding: 0.45rem 0.85rem;
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 13px;
color: var(--overlay-50);
cursor: pointer;
transition: all 0.3s ease;
white-space: nowrap;
}
.platform-btn.active {
background: linear-gradient(180deg, var(--color-accent) 0%, var(--color-accent) 100%);
border-color: var(--overlay-16);
color: var(--text-primary);
}
.platform-btn:hover:not(.active) {
background: var(--overlay-20);
color: var(--overlay-80);
}
/* 音源状态显示 */
.source-status-display {
background: var(--overlay-5);
border: 1px solid var(--overlay-10);
border-radius: 12px;
padding: 1rem;
margin-bottom: 1rem;
backdrop-filter: blur(10px);
}
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.status-title {
font-size: 14px;
font-weight: 600;
color: var(--overlay-90);
font-family: 'MiSans', sans-serif;
}
.status-summary {
font-size: 12px;
color: var(--overlay-60);
background: var(--overlay-10);
padding: 0.25rem 0.5rem;
border-radius: 6px;
font-family: 'MiSans', sans-serif;
font-weight: 500;
}
.status-sources {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.source-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 8px;
font-size: 12px;
transition: all 0.2s ease;
border: 1px solid transparent;
font-family: 'MiSans', sans-serif;
font-weight: 500;
}
.source-item.healthy {
background: var(--requestform-source-healthy-bg);
border-color: var(--requestform-source-healthy-border);
color: var(--color-success-light);
}
.source-item.unhealthy {
background: var(--error-15);
border-color: var(--error-30);
color: var(--color-error-light);
}
.source-item.checking {
background: var(--requestform-source-checking-bg);
border-color: var(--requestform-source-checking-border);
color: var(--color-warning-light);
}
.source-item.current {
box-shadow: 0 0 0 2px var(--color-accent-alpha-40);
transform: scale(1.02);
}
.source-name {
font-weight: 600;
}
.source-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.source-item.healthy .source-indicator {
background: var(--color-success-hover);
box-shadow: 0 0 6px var(--requestform-source-healthy-glow);
}
.source-item.unhealthy .source-indicator {
background: var(--color-error);
box-shadow: 0 0 6px var(--error-60);
}
.source-item.checking .source-indicator {
background: var(--color-warning-light);
box-shadow: 0 0 6px var(--requestform-source-checking-glow);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.error-message {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding: 0.5rem 0.75rem;
background: var(--error-15);
border: 1px solid var(--error-30);
border-radius: 6px;
color: var(--color-error-light);
font-size: 12px;
font-family: 'MiSans', sans-serif;
font-weight: 500;
}
/* 搜索结果容器样式 */
.search-results-container {
flex: 1;
background: var(--surface-card-bg);
border-radius: 13px;
display: flex;
flex-direction: column;
min-height: 0;
padding: 0.5rem 1rem 1rem 1rem;
position: relative;
z-index: 1;
}
.results-content {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* 加载状态 */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
gap: 1rem;
min-height: 200px; /* 添加最小高度 */
}
.loading-text {
color: var(--overlay-60);
font-family: 'MiSans', sans-serif;
font-weight: 500;
margin: 0;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* 搜索结果列表 */
.results-list {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.results-grid {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
padding-right: 0.5rem;
min-height: 0;
}
/* 滚动条样式 */
.results-grid::-webkit-scrollbar {
width: 6px;
}
.results-grid::-webkit-scrollbar-track {
background: var(--overlay-10);
border-radius: 3px;
}
.results-grid::-webkit-scrollbar-thumb {
background: var(--overlay-30);
border-radius: 3px;
}
.results-grid::-webkit-scrollbar-thumb:hover {
background: var(--overlay-50);
}
/* 空状态和初始状态 */
.empty-state,
.initial-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
text-align: center;
gap: 0.75rem;
padding: 1.5rem;
min-height: 200px; /* 增加最小高度 */
}
.empty-icon,
.initial-icon {
font-size: 2.5rem;
margin-bottom: 0.25rem;
}
.empty-text,
.initial-text {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 15px;
color: var(--text-primary);
margin: 0;
}
.empty-hint,
.initial-hint {
color: var(--overlay-60);
font-size: 13px;
margin: 0;
}
/* 搜索插图样式 */
.search-illustration {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 120px;
}
.search-svg {
width: 25%;
max-width: 300px;
min-width: 150px;
height: auto;
object-fit: contain;
opacity: 0.8;
}
/* 手动输入触发按钮 */
.manual-input-trigger {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--overlay-10);
text-align: center;
}
.manual-submit-btn {
background: var(--overlay-10);
border: 1px solid var(--overlay-16);
border-radius: 8px;
padding: 0.5rem 1.5rem;
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.3s ease;
}
.manual-submit-btn:hover {
background: var(--overlay-20);
transform: translateY(-2px);
}
.form-notice {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 14px;
letter-spacing: 0.04em;
color: var(--overlay-40);
margin-top: 0.5rem;
}
.form-actions {
display: flex;
justify-content: flex-end;
margin-top: 1rem;
}
.submit-button {
background: linear-gradient(180deg, var(--color-accent) 0%, var(--color-accent) 100%);
border: 1px solid var(--overlay-16);
border-radius: 8px;
padding: 0.5rem 1.5rem;
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.3s ease;
}
.submit-button:hover {
transform: translateY(-2px);
}
.submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.alert-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.alert-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.alert-icon {
color: var(--color-warning);
flex-shrink: 0;
}
.alert-title {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 16px;
color: var(--text-primary);
}
.alert-content {
margin-bottom: 1rem;
}
.similar-songs-list {
margin-bottom: 0.5rem;
max-height: 120px;
overflow-y: auto;
}
.similar-song-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4rem 0;
border-bottom: 1px solid var(--overlay-5);
}
.similar-song-item:last-child {
border-bottom: none;
}
.song-info {
flex: 1;
min-width: 0;
}
.song-title {
margin: 0 0 0.25rem 0;
font-size: 14px;
color: var(--text-primary);
font-weight: 500;
}
.song-actions {
margin-left: 1rem;
flex-shrink: 0;
}
.vote-btn.small,
.replay-btn.small {
padding: 0.3rem 0.6rem;
font-size: 12px;
}
/* 歌曲状态样式 */
.song-status {
display: inline-block;
font-size: 12px;
font-weight: 600;
margin-left: 0.5rem;
}
.status-played {
color: var(--color-error);
}
.status-scheduled {
color: var(--color-warning);
}
.alert-hint {
color: var(--overlay-60);
font-size: 14px;
margin-top: 0.5rem;
}
.voted-status {
color: var(--color-success);
font-size: 14px;
font-weight: 600;
margin-top: 0.5rem;
display: flex;
align-items: center;
gap: 0.3rem;
}
.alert-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.vote-btn {
background: linear-gradient(180deg, var(--color-accent) 0%, var(--color-accent) 100%);
border: 1px solid var(--overlay-16);
border-radius: 8px;
padding: 0.5rem 1rem;
color: var(--text-primary);
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.vote-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 2px 8px var(--primary-30);
}
.vote-btn:disabled {
background: var(--overlay-20);
cursor: not-allowed;
transform: none;
}
.ignore-btn {
background: var(--overlay-10);
border: 1px solid var(--overlay-16);
border-radius: 8px;
padding: 0.5rem 1rem;
color: var(--text-primary);
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 14px;
cursor: pointer;
}
/* 桌面端继续投稿按钮 */
.desktop-continue-btn {
display: none;
}
/* 移动端继续投稿按钮 */
.mobile-continue-actions {
display: block;
}
.mobile-continue-btn {
display: block;
}
/* 宽屏时的样式 */
@media (min-width: 768px) {
.desktop-continue-btn {
display: inline-flex;
padding: 6px 12px;
font-size: 12px;
border-radius: 6px;
}
.mobile-continue-actions {
display: none;
}
.similar-songs-list {
max-height: 80px;
}
}
/* 移动端时增加相似歌曲列表高度 */
@media (max-width: 767px) {
.similar-songs-list {
max-height: 150px;
}
}
/* 动画样式 */
.results-fade-enter-active,
.results-fade-leave-active {
transition: all 0.3s ease;
}
.results-fade-enter-from,
.results-fade-leave-to {
opacity: 0;
transform: translateY(20px);
}
.result-item-enter-active {
transition: all 0.4s ease;
}
.result-item-leave-active {
transition: all 0.3s ease;
}
.result-item-enter-from {
opacity: 0;
transform: translateX(-20px);
}
.result-item-leave-to {
opacity: 0;
transform: translateX(20px);
}
.result-item-move {
transition: transform 0.3s ease;
}
/* 弹窗样式 */
.requestform-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--mask-60);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
transition: all 0.3s ease;
}
.modal-content {
background: var(--requestform-modal-overlay-bg);
border-radius: 16px;
width: 90%;
max-width: 500px;
box-shadow: 0 25px 50px -12px var(--mask-50);
overflow: hidden;
border: 1px solid var(--overlay-10);
transform-origin: center;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--overlay-5);
background: var(--overlay-2);
}
.modal-header h3 {
margin: 0;
color: var(--text-primary);
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 18px;
letter-spacing: 0.02em;
}
.close-btn {
background: transparent;
border: none;
color: var(--overlay-40);
font-size: 24px;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s ease;
}
.close-btn:hover {
background: var(--overlay-10);
color: var(--text-primary);
transform: rotate(90deg);
}
.modal-body {
padding: 1.5rem;
}
.modal-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 2rem;
}
.btn {
padding: 0.6rem 1.2rem;
border-radius: 8px;
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn:active {
transform: scale(0.96);
}
.btn-secondary {
background: var(--overlay-5);
border-color: var(--overlay-10);
color: var(--overlay-80);
}
.btn-secondary:hover {
background: var(--overlay-10);
color: var(--text-primary);
border-color: var(--overlay-20);
}
.btn-primary {
background: linear-gradient(135deg, var(--color-accent) 0%, var(--color-accent) 100%);
border-color: var(--overlay-10);
color: var(--text-primary);
box-shadow: 0 4px 12px var(--primary-30);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 16px var(--requestform-submit-btn-hover-shadow);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
}
.readonly {
background: var(--surface-card-bg-soft) !important;
color: var(--overlay-50) !important;
cursor: not-allowed;
border-color: transparent !important;
}
/* 弹窗动画 */
.modal-animation-enter-active,
.modal-animation-leave-active {
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.modal-animation-enter-from,
.modal-animation-leave-to {
opacity: 0;
}
.modal-animation-enter-from .modal-content,
.modal-animation-leave-to .modal-content {
transform: scale(0.95) translateY(10px);
opacity: 0;
}
.result-item {
display: flex;
padding: 0.6rem 1rem; /* 缩小内边距以提高信息密度 */
gap: 1rem;
transition: all 0.2s ease;
cursor: pointer;
border-bottom: 1px solid var(--overlay-10);
}
.result-item:last-child {
border-bottom: none;
}
.result-item:hover {
background: var(--overlay-5);
}
.result-cover {
width: 70px;
height: 70px;
position: relative;
flex-shrink: 0;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px var(--mask-20);
background: var(--panel-bg-dialog);
}
.cover-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s ease;
}
.result-item:hover .cover-img {
transform: scale(1.1);
}
.play-overlay-container {
position: absolute;
inset: 0;
background: var(--surface-card-bg);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
z-index: 2;
}
.result-item:hover .play-overlay-container {
opacity: 1;
}
.play-button-wrapper {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--overlay-20);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
color: white;
border: 1px solid var(--overlay-30);
box-shadow: 0 8px 16px var(--mask-30);
}
.play-icon {
fill: currentColor;
margin-left: 2px;
}
.result-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.result-title {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 15px;
color: var(--text-primary);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.4;
}
.result-artist {
color: var(--overlay-60);
font-size: 13px;
margin: 0.25rem 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-album,
.result-quality,
.result-pay {
color: var(--overlay-40);
font-size: 11px;
margin: 0.15rem 0;
}
.result-actions {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
gap: 0.8rem;
margin-right: 0.25rem;
flex-shrink: 0;
}
.cloud-disk-btn {
background: linear-gradient(180deg, var(--color-error) 0%, var(--color-error-hover) 100%);
border: 1px solid var(--overlay-20);
border-radius: 50%;
width: 32px;
height: 32px;
padding: 0;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
box-shadow: 0 2px 6px var(--mask-20);
-webkit-appearance: none;
appearance: none;
}
.cloud-disk-btn:hover {
transform: translateY(-2px) scale(1.05);
box-shadow: 0 4px 12px var(--requestform-delete-btn-hover-shadow);
background: linear-gradient(180deg, var(--color-error-hover) 0%, var(--color-error-hover) 100%);
border-color: var(--overlay-40);
}
.cloud-disk-btn:active {
transform: translateY(0) scale(0.95);
box-shadow: 0 2px 4px var(--requestform-delete-btn-hover-shadow-sm);
}
.similar-song-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
text-align: center;
}
.similar-text {
font-size: 12px;
color: var(--overlay-60);
font-family: 'MiSans', sans-serif;
font-weight: 500;
}
.similar-text.status-played {
color: var(--color-error);
font-weight: 600;
}
.similar-text.status-scheduled {
color: var(--color-warning);
font-weight: 600;
}
.like-btn {
background: linear-gradient(180deg, var(--color-error) 0%, var(--color-error-hover) 100%);
border: 1px solid var(--overlay-16);
border-radius: 6px;
padding: 0.4rem 0.8rem;
color: var(--text-primary);
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.3rem;
transition: all 0.2s ease;
}
.like-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 2px 8px var(--error-30);
}
.like-btn:disabled {
background: var(--overlay-20);
cursor: not-allowed;
transform: none;
}
.like-btn.disabled {
background: var(--overlay-10);
border-color: var(--overlay-20);
cursor: not-allowed;
opacity: 0.5;
}
.like-btn svg {
width: 14px;
height: 14px;
fill: currentColor;
}
.select-btn {
background: linear-gradient(180deg, var(--color-accent) 0%, var(--color-accent) 100%);
border: 1px solid var(--overlay-16);
border-radius: 8px;
padding: 0.5rem 1rem;
color: var(--text-primary);
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
}
.replay-btn {
background: var(--requestform-replay-btn-bg);
border: 1px solid var(--requestform-replay-btn-border);
border-radius: 6px;
padding: 0.4rem 0.8rem;
color: var(--color-accent-light);
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.replay-btn:hover:not(:disabled) {
background: var(--requestform-replay-btn-bg-hover);
border-color: var(--requestform-replay-btn-border-hover);
color: var(--color-accent-light);
transform: translateY(-1px);
}
.replay-btn:disabled {
background: var(--overlay-5);
border-color: var(--overlay-10);
color: var(--overlay-30);
cursor: not-allowed;
transform: none;
}
/* 音频播放器现在使用全局组件 */
/* 手动提交按钮样式 */
.no-results-action {
margin-top: 1rem;
text-align: center;
padding: 1rem 0;
border-top: 1px solid var(--overlay-10);
}
.manual-submit-btn {
background: var(--overlay-10);
border: 1px solid var(--overlay-16);
border-radius: 8px;
padding: 0.5rem 1.5rem;
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.3s ease;
}
.manual-submit-btn:hover {
background: var(--overlay-20);
transform: translateY(-2px);
}
/* 手动输入区域样式 */
.manual-input-section {
margin-top: 2rem;
background: var(--surface-card-bg);
border-radius: 13px;
padding: 1.5rem;
}
.manual-title {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 18px;
margin-bottom: 1rem;
color: var(--text-primary);
}
.manual-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.manual-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
}
.manual-cancel-btn {
background: var(--overlay-10);
border: 1px solid var(--overlay-16);
border-radius: 8px;
padding: 0.5rem 1rem;
color: var(--text-primary);
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
}
.manual-cancel-btn:hover {
background: var(--overlay-20);
}
.manual-confirm-btn {
background: linear-gradient(180deg, var(--color-accent) 0%, var(--color-accent) 100%);
border: 1px solid var(--overlay-16);
border-radius: 8px;
padding: 0.5rem 1rem;
color: var(--text-primary);
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
}
.manual-confirm-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px var(--primary-30);
}
.manual-confirm-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 响应式调整 */
@media (max-width: 768px) {
/* Netease Options Mobile Optimization */
.netease-options {
padding: 0.75rem;
min-width: unset; /* 移除桌面端的最小宽度限制 */
}
.user-compact-row {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
.user-profile {
width: 100%;
justify-content: flex-start;
}
.search-type-switch {
width: 100%;
display: flex;
min-width: 0;
}
.radio-label {
flex: 1;
min-width: 0;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-actions-row {
display: flex;
flex-direction: row;
gap: 0.5rem;
width: 100%;
}
/* 移动端下让按钮平分宽度 */
.user-actions-row .action-btn-compact {
flex: 1;
min-width: 0;
width: auto;
justify-content: center;
padding: 0.6rem 0.4rem;
}
/* 按钮文字过窄时省略号截断 */
.user-actions-row .action-btn-compact span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 移动端显示/隐藏控制 */
.desktop-only-rules {
display: none;
}
.mobile-only-rules {
display: block;
}
.request-form {
flex-direction: column;
height: auto;
max-height: none;
overflow: visible;
padding: 0; /* 移除可能的内边距 */
}
.rules-section {
width: 100%;
height: auto;
margin-bottom: 1.5rem;
padding: 1.25rem;
background: var(--overlay-4);
backdrop-filter: blur(20px);
border: 1px solid var(--overlay-8);
border-radius: 18px;
}
.rules-title {
font-size: 15px;
font-weight: 700;
color: var(--overlay-90);
margin-bottom: 1.25rem;
letter-spacing: normal;
}
.rules-icon {
display: block;
color: var(--color-warning);
}
.form-container {
width: 100%;
flex: none; /* 改为不占用flex空间,让内容自然撑开 */
display: flex;
flex-direction: column;
height: auto;
overflow-x: hidden; /* 防止横向溢出 */
max-width: 100%; /* 确保不超过父容器宽度 */
}
.song-request-form {
flex: none; /* 改为不占用flex空间,让内容自然撑开 */
display: flex;
flex-direction: column;
height: auto;
gap: 1.25rem;
max-width: 100%; /* 确保不超过父容器宽度 */
}
.form-header-row {
flex-direction: column;
gap: 1rem;
width: 100%; /* 确保占满宽度 */
max-width: 100%; /* 防止超出 */
}
.search-results-container {
flex: none; /* 改为不占用flex空间,让内容自然撑开 */
height: auto;
max-height: none; /* 移除最大高度限制 */
padding: 0.75rem;
overflow: visible;
display: flex;
flex-direction: column;
margin-bottom: 2rem;
background: var(--overlay-2);
backdrop-filter: blur(20px);
border: 1px solid var(--overlay-8);
border-radius: 20px;
/* 允许容器内容触发页面滚动 */
touch-action: pan-y;
}
.results-content {
height: auto;
min-height: 200px; /* 减小最小高度 */
max-height: none; /* 移除最大高度限制 */
overflow: visible;
flex: none; /* 改为不占用flex空间 */
}
/* 移动端搜索区域 */
.form-header-row {
flex-direction: column;
align-items: flex-start;
gap: 0;
margin-bottom: 0;
}
.login-entry {
flex-direction: column;
align-items: stretch;
padding: 0.85rem;
gap: 0.6rem;
}
.login-desc {
width: 100%;
}
.login-title {
/* 不缩小字号 */
}
.login-hint {
white-space: normal;
word-break: break-word;
line-height: 1.3;
}
.login-actions {
width: 100%;
justify-content: flex-start;
gap: 0.5rem;
}
.login-btn,
.import-btn {
flex: 1;
justify-content: center;
padding: 0.5rem 0.4rem;
}
.search-section {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
margin-bottom: 1.25rem;
flex: none;
width: 100%;
min-width: unset; /* 移除桌面端的最小宽度限制 */
}
.collaborators-section {
flex-direction: row;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
flex: none;
width: 100%;
flex-wrap: wrap;
}
.section-label {
font-size: 14px;
}
.search-label {
font-size: 14px;
}
.search-input-group {
flex-direction: row;
gap: 0.5rem;
width: 100%; /* 确保占满父容器宽度 */
max-width: 100%; /* 防止超出 */
}
.search-input {
background: var(--overlay-5);
border: 1px solid var(--overlay-10);
border-radius: 12px;
padding: 0.75rem 1rem;
font-size: 15px;
flex: 1;
min-width: 0; /* 允许输入框在必要时缩小 */
max-width: 100%; /* 防止超出 */
}
.search-button {
padding: 0.75rem 1rem; /* 减小按钮的左右内边距 */
border-radius: 12px;
flex-shrink: 0; /* 防止按钮被压缩 */
white-space: nowrap; /* 防止按钮文字换行 */
font-size: 14px; /* 稍微减小字体 */
}
.audio-match-btn {
padding: 0.75rem;
min-width: 48px;
}
.audio-match-btn .btn-text {
display: none;
}
/* 移动端平台选择按钮 */
.platform-selection-container {
flex-direction: column;
align-items: stretch;
min-width: 0;
}
.platform-selection {
background: var(--surface-card-bg-soft);
padding: 4px;
border-radius: 12px;
margin-bottom: 0.5rem;
display: flex;
min-width: 0;
}
.platform-btn {
flex: 1;
min-width: 0;
padding: 0.5rem 0.25rem;
font-size: 12px;
line-height: 1.3;
border-radius: 10px;
background: transparent;
border: none;
color: var(--overlay-50);
white-space: normal;
word-break: break-word;
}
.platform-btn.active {
background: var(--color-accent);
color: var(--text-primary);
box-shadow: 0 2px 8px var(--color-accent-alpha-30);
}
/* 移动端音源状态显示 */
.source-status-display {
padding: 0.75rem;
margin-bottom: 0.75rem;
}
.status-header {
margin-bottom: 0.5rem;
}
.status-title {
font-size: 13px;
}
.status-summary {
font-size: 11px;
padding: 0.2rem 0.4rem;
}
.status-sources {
gap: 0.4rem;
}
.source-item {
padding: 0.4rem 0.6rem;
font-size: 11px;
}
.source-indicator {
width: 6px;
height: 6px;
}
.error-message {
margin-top: 0.5rem;
padding: 0.4rem 0.6rem;
font-size: 11px;
}
.form-row {
flex-direction: column;
gap: 1rem;
}
.submission-meta-row {
gap: 0.75rem;
}
.card-code-form-group {
margin-bottom: 0;
}
.mobile-hidden-card-code-group {
display: none;
}
.desktop-card-code-panel {
display: none;
}
.mobile-card-code-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-height: 28px;
border-radius: 999px;
border: 1px solid var(--requestform-mobile-chip-border);
background: var(--requestform-mobile-chip-bg);
color: var(--requestform-mobile-chip-text);
padding: 0.25rem 0.55rem;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.mobile-card-code-button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
width: 100%;
min-height: 42px;
border-radius: 12px;
border: 1px solid var(--requestform-mobile-chip-border-sm);
background: var(--panel-bg-overlay);
color: var(--requestform-mobile-button-text);
padding: 0.65rem 0.85rem;
font-size: 13px;
font-weight: 800;
}
.mobile-card-code-chip.is-required,
.mobile-card-code-button.is-required {
border-color: var(--requestform-chip-required-border);
background: var(--requestform-chip-required-bg);
color: var(--color-warning-light);
}
.mobile-card-code-chip.has-code,
.mobile-card-code-button.has-code {
border-color: var(--requestform-chip-required-border-sm);
color: var(--color-warning-light);
}
.mobile-card-code-chip.is-valid,
.mobile-card-code-button.is-valid {
border-color: var(--requestform-chip-valid-border);
background: var(--success-10);
color: var(--color-success-light);
}
.mobile-card-code-chip.is-invalid,
.mobile-card-code-button.is-invalid {
border-color: var(--requestform-chip-invalid-border);
background: var(--requestform-chip-invalid-bg);
color: var(--color-error-light);
}
.form-group label {
font-size: 18px;
}
.form-actions {
justify-content: center;
}
.submit-button {
width: 100%;
padding: 0.75rem;
}
.alert-actions {
flex-direction: column;
gap: 0.5rem;
}
.vote-btn,
.ignore-btn {
width: 100%;
}
.audio-player {
flex-direction: column;
padding: 0.75rem;
}
.player-info {
width: 100%;
}
.audio-player audio {
width: 100%;
}
.close-player {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
/* 移动端平台选择Tab */
.tab-header {
gap: 2px;
}
.tab-btn {
padding: 0.6rem 0.8rem;
font-size: 13px;
}
/* 移动端搜索结果优化 */
.result-item {
background: var(--overlay-3);
border-radius: 16px;
border: 1px solid var(--overlay-6);
margin-bottom: 0.5rem;
padding: 10px;
flex-direction: row;
gap: 10px;
align-items: center;
}
.result-cover {
width: 64px;
height: 64px;
align-self: flex-start;
}
.result-info {
text-align: left;
flex: 1;
}
.result-title {
font-size: 15px;
font-weight: 600;
}
.result-artist {
font-size: 12px;
opacity: 0.6;
margin: 4px 0;
}
.result-actions {
flex-direction: row;
width: auto;
}
.select-btn {
padding: 0.5rem 0.75rem;
font-size: 12px;
border-radius: 10px;
width: auto;
}
/* 移动端弹窗优化 */
.modal-content {
width: 95%;
max-width: none;
}
.modal-actions {
flex-direction: column;
gap: 0.75rem;
}
.btn {
width: 100%;
padding: 0.75rem;
}
/* 移动端搜索插图 */
.search-svg {
width: 50%;
max-width: 250px;
min-width: 120px;
}
.search-illustration {
padding: 0.5rem;
min-height: 120px;
}
/* 移动端搜索结果列表 */
.results-list {
flex: none; /* 改为不占用flex空间 */
height: auto;
max-height: none; /* 移除最大高度限制 */
overflow: visible;
}
.results-grid {
max-height: 60vh; /* 保持合理的最大高度,防止结果过长 */
overflow-y: auto; /* 当结果超过最大高度时允许滚动 */
-webkit-overflow-scrolling: touch;
padding-bottom: 2rem; /* 减小底部内边距 */
padding-right: 0;
/* 优化触摸滚动体验 */
overscroll-behavior: contain; /* 防止滚动穿透 */
}
/* 当没有搜索结果时,移除滚动容器的高度限制 */
.initial-state,
.empty-state,
.loading-state {
min-height: 300px;
max-height: none;
overflow: visible;
}
/* 移动端期望排期选择 */
.form-group {
margin-bottom: 1rem;
z-index: 10;
position: relative;
}
.form-select {
position: relative;
z-index: 10;
}
}
/* 网易云音乐账号加载状态样式 */
.netease-loading-state {
padding: 0;
background-color: transparent;
border-radius: 0;
display: flex;
align-items: center;
justify-content: flex-end;
margin: 0;
border: none;
height: 32px; /* 匹配旁边按钮的高度 */
}
.loading-content {
display: flex;
align-items: center;
gap: 8px;
}
.netease-loading-state .loading-spinner {
width: 16px;
height: 16px;
border: 2px solid var(--error-20);
border-top-color: var(--color-error);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.netease-loading-state .loading-text {
color: var(--text-muted-light);
font-size: 13px;
font-weight: 500;
margin: 0;
}
/* URL验证状态样式 */
.validation-loading {
color: var(--color-warning-light);
font-size: 0.875rem;
margin-top: 0.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.validation-error {
color: var(--color-error);
font-size: 0.875rem;
margin-top: 0.25rem;
}
.validation-success {
color: var(--color-success);
font-size: 0.875rem;
margin-top: 0.25rem;
}
.form-input.error {
border-color: var(--color-error);
box-shadow: 0 0 0 1px var(--color-error);
}
.import-semester-btn {
display: flex;
align-items: center;
gap: 0.4rem;
background: var(--overlay-8);
border: 1px solid var(--overlay-12);
border-radius: 8px;
padding: 0.6rem 0.8rem;
color: var(--overlay-80);
font-family: 'MiSans', sans-serif;
font-weight: 500;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
height: 42px; /* 使匹配搜索输入框的大致高度 */
flex-shrink: 0;
}
.import-semester-btn:hover {
background: var(--overlay-15);
color: var(--text-primary);
border-color: var(--overlay-20);
}
@media (max-width: 768px) {
.import-semester-btn {
width: 100%;
justify-content: center;
}
}
.bilibili-episodes-container {
max-height: 60vh;
overflow-y: auto;
}
.video-info {
padding: 1rem;
border-bottom: 1px solid var(--overlay-10);
margin-bottom: 1rem;
}
.video-info h3 {
font-family: 'MiSans', sans-serif;
font-weight: 500;
font-size: 16px;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.video-author {
font-family: 'MiSans', sans-serif;
font-size: 14px;
color: var(--overlay-60);
}
.episodes-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0 1rem 1rem;
}
.episode-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--overlay-5);
border: 1px solid var(--overlay-10);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.episode-item:hover {
background: var(--overlay-10);
border-color: var(--overlay-20);
transform: translateX(4px);
}
.episode-number {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 14px;
color: var(--overlay-80);
background: var(--overlay-10);
padding: 0.4rem 0.8rem;
border-radius: 6px;
min-width: 40px;
text-align: center;
}
.episode-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.episode-title {
font-family: 'MiSans', sans-serif;
font-weight: 500;
font-size: 14px;
color: var(--text-primary);
}
.episode-duration {
font-family: 'MiSans', sans-serif;
font-size: 12px;
color: var(--overlay-50);
}
/* 专辑详情样式 */
.clickable-album {
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
transition: all 0.2s ease;
position: relative;
}
.clickable-album:hover .album-name {
color: var(--color-accent-light);
text-decoration: underline;
}
.album-label {
color: var(--overlay-40);
}
.album-link-icon {
opacity: 0.7;
transition: opacity 0.2s ease;
}
.clickable-album:hover .album-link-icon {
opacity: 1;
}
</style>