Refine mobile subscribe cards and grouped list styling

This commit is contained in:
jxxghp
2026-07-02 20:28:03 +08:00
parent 693b081ecf
commit f7d354fca4
4 changed files with 342 additions and 42 deletions

View File

@@ -92,6 +92,17 @@ const rightBottomStateDisplay = computed(() => {
return null return null
}) })
// 移动端紧凑卡片的状态展示,颜色统一映射到 Vuetify 全局主题 token。
const compactStateDisplay = computed(() => {
if (subscribeState.value === 'S') {
return { color: 'secondary', icon: 'mdi-pause-circle-outline', label: t('subscribe.cardStatePaused') }
}
if (subscribeState.value === 'P') {
return { color: 'info', icon: 'mdi-timer-sand', label: t('subscribe.cardStatePending') }
}
return { color: 'primary', icon: 'mdi-rss', label: t('subscribe.subscribing') }
})
// 洗版徽标:共用 mdi-shimmer 图标,分集 / 全集 由 full 标记区分背景 // 洗版徽标:共用 mdi-shimmer 图标,分集 / 全集 由 full 标记区分背景
const bestVersionBadge = computed(() => { const bestVersionBadge = computed(() => {
if (!isEnabledFlag(props.media?.best_version)) return null if (!isEnabledFlag(props.media?.best_version)) return null
@@ -423,45 +434,143 @@ function handleCardClick() {
}" }"
min-height="150" min-height="150"
@click="handleCardClick" @click="handleCardClick"
:ripple="!props.batchMode && !props.sortable" :ripple="display.smAndUp.value && !props.batchMode && !props.sortable"
> >
<div <div
v-if="bestVersionBadge && imageLoaded" v-if="bestVersionBadge && imageLoaded"
class="best-version-badge" class="best-version-badge"
:class="{ 'best-version-badge-full': bestVersionBadge.full }" :class="{ 'best-version-badge-full': bestVersionBadge.full }"
> >
<VIcon :icon="bestVersionBadge.icon" color="white" size="16" /> <VIcon :icon="bestVersionBadge.icon" color="white" size="16" />
</div> </div>
<div v-if="!props.sortable" class="me-n3 absolute top-1 right-4"> <div v-if="!props.sortable && display.smAndUp.value" class="me-n3 absolute top-1 right-4">
<IconBtn @click.stop> <IconBtn @click.stop>
<VIcon icon="mdi-dots-vertical" color="white" /> <VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click> <VMenu activator="parent" close-on-content-click>
<VList> <VList>
<template v-for="(item, i) in dropdownItems" :key="i"> <template v-for="(item, i) in dropdownItems" :key="i">
<VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click"> <VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click">
<template #prepend> <template #prepend>
<VIcon :icon="item.props.prependIcon" /> <VIcon :icon="item.props.prependIcon" />
</template> </template>
<VListItemTitle v-text="item.title" /> <VListItemTitle v-text="item.title" />
</VListItem> </VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
</div>
<template #image v-if="display.smAndUp.value">
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
</div>
</template>
<template #default>
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
</template>
</VImg>
</template>
<template v-if="display.xs.value">
<div class="subscribe-card-mobile-media">
<VImg
:src="backdropUrl || posterUrl"
:aspect-ratio="2"
cover
position="top"
@load="imageLoadHandler"
>
<template #placeholder>
<VSkeletonLoader class="h-full w-full" />
</template> </template>
</VList> </VImg>
</VMenu>
</IconBtn> <div
</div> v-if="props.media?.username || lastUpdateText"
<template #image> class="subscribe-card-mobile-image-meta"
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top"> :class="{ 'subscribe-card-mobile-image-meta--with-badge': bestVersionBadge }"
<template #placeholder> >
<div class="w-full h-full"> <div
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" /> v-if="props.media?.username"
class="subscribe-card-mobile-image-meta__item subscribe-card-mobile-image-meta__user"
:title="props.media?.username"
>
<VIcon icon="mdi-account" size="14" />
<span>{{ props.media?.username }}</span>
</div>
<div
v-if="lastUpdateText"
class="subscribe-card-mobile-image-meta__item subscribe-card-mobile-image-meta__updated"
>
<VIcon icon="mdi-download" size="14" />
<span>{{ lastUpdateText }}</span>
</div>
</div> </div>
</template> </div>
<template #default>
<div class="absolute inset-0 outline-none subscribe-card-background"></div> <div class="subscribe-card-mobile-body">
</template> <div class="subscribe-card-mobile-title">
</VImg> {{ props.media?.name }}
</template> {{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
<div> </div>
<div class="subscribe-card-mobile-footer">
<div class="subscribe-card-mobile-meta">
<div
class="subscribe-card-mobile-state"
:style="{ color: `rgb(var(--v-theme-${compactStateDisplay.color}))` }"
:title="compactStateDisplay.label"
:aria-label="compactStateDisplay.label"
>
<VIcon :icon="compactStateDisplay.icon" size="18" />
<span v-if="subscribeProgressText" class="subscribe-card-mobile-progress-text">
{{ subscribeProgressText }}
</span>
</div>
<IconBtn
v-if="!props.sortable"
class="subscribe-card-mobile-menu"
size="small"
@click.stop
>
<VIcon icon="mdi-dots-horizontal" size="20" />
<VMenu activator="parent" close-on-content-click>
<VList>
<template v-for="(item, i) in dropdownItems" :key="i">
<VListItem
v-if="item.show !== false"
:base-color="item.props.color"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</template>
</VList>
</VMenu>
</IconBtn>
</div>
<div v-if="props.media?.total_episode" class="subscribe-card-mobile-progress">
<VProgressLinear
:model-value="getPercentage()"
:bg-color="compactStateDisplay.color"
:color="compactStateDisplay.color"
bg-opacity="0.18"
height="4"
rounded
/>
</div>
</div>
</div>
</template>
<div v-else>
<VCardText class="flex items-center pt-3 pb-2"> <VCardText class="flex items-center pt-3 pb-2">
<div <div
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative" class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative"
@@ -568,7 +677,7 @@ function handleCardClick() {
color="success" color="success"
/> />
</div> </div>
</div> </div>
</VCard> </VCard>
</div> </div>
</div> </div>
@@ -592,6 +701,151 @@ function handleCardClick() {
border: var(--app-card-light-border); border: var(--app-card-light-border);
} }
.subscribe-card-mobile-media {
position: relative;
overflow: hidden;
aspect-ratio: 2 / 1;
flex-shrink: 0;
inline-size: 100%;
}
.subscribe-card-mobile-media .v-img {
block-size: 100%;
}
.subscribe-card-mobile-image-meta {
position: absolute;
z-index: 2;
font-size: 0.6875rem;
font-weight: 500;
inset: 0;
pointer-events: none;
}
.subscribe-card-mobile-image-meta__item {
position: absolute;
display: flex;
min-inline-size: 0;
align-items: center;
gap: 0.25rem;
color: rgba(255, 255, 255, 0.88);
isolation: isolate;
line-height: 1.2;
padding-block: 0.0625rem;
padding-inline: 0.125rem;
}
.subscribe-card-mobile-image-meta__item::before {
position: absolute;
z-index: -1;
border-radius: 0.4rem;
background: rgba(0, 0, 0, 0.36);
content: '';
filter: blur(3px);
inset: -0.25rem -0.55rem;
}
.subscribe-card-mobile-image-meta__user {
max-inline-size: calc(100% - 1rem);
inset-block-start: 0.5rem;
inset-inline-start: 0.5rem;
transition: inset-block-start 0.2s ease;
}
.subscribe-card-mobile-image-meta--with-badge .subscribe-card-mobile-image-meta__user {
max-inline-size: calc(100% - 4.25rem);
inset-block-start: 0.5rem;
}
.subscribe-card-mobile-image-meta__user span {
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subscribe-card-mobile-image-meta__updated {
flex-shrink: 0;
color: rgba(255, 255, 255, 0.76);
inset-block-end: 0.5rem;
inset-inline-end: 0.5rem;
}
.subscribe-card-mobile-body {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
.subscribe-card-mobile-title {
display: -webkit-box;
overflow: hidden;
font-size: 0.9375rem;
font-weight: 600;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.subscribe-card-mobile-footer {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-block-start: auto;
}
.subscribe-card-mobile-meta {
display: flex;
min-inline-size: 0;
min-block-size: 2rem;
align-items: center;
gap: 0.25rem;
justify-content: space-between;
}
.subscribe-card-mobile-state {
display: flex;
min-inline-size: 0;
align-items: center;
flex: 1 1 auto;
gap: 0.35rem;
font-size: 0.8125rem;
font-weight: 500;
line-height: 1.25;
white-space: nowrap;
}
.subscribe-card-mobile-state span {
overflow: hidden;
text-overflow: ellipsis;
}
.subscribe-card-mobile-progress-text {
flex-shrink: 0;
}
.subscribe-card-mobile-menu {
block-size: 2rem;
min-block-size: 2rem;
inline-size: 2rem;
min-inline-size: 2rem;
flex: 0 0 2rem;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
.subscribe-card-mobile-progress {
display: flex;
block-size: 4px;
inline-size: 100%;
}
.subscribe-card-mobile-progress .v-progress-linear {
flex: 1 1 auto;
}
.subscribe-card-shell--selected::after { .subscribe-card-shell--selected::after {
position: absolute; position: absolute;
z-index: 5; z-index: 5;
@@ -625,7 +879,7 @@ function handleCardClick() {
position: absolute; position: absolute;
z-index: 3; z-index: 3;
border-radius: inherit; border-radius: inherit;
box-shadow: inset 0 0 48px rgba(56, 189, 248, 40%); // sky-400 box-shadow: inset 0 0 48px rgba(var(--v-theme-info), 0.28);
content: ''; content: '';
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
@@ -657,4 +911,30 @@ function handleCardClick() {
background: rgba(255, 255, 255, 22%); background: rgba(255, 255, 255, 22%);
box-shadow: 0 2px 8px rgba(255, 255, 255, 15%); box-shadow: 0 2px 8px rgba(255, 255, 255, 15%);
} }
@media (width <= 599px) {
.subscribe-card {
min-block-size: 0 !important;
}
.subscribe-card-paused {
opacity: 1;
}
.subscribe-card-paused .subscribe-card-mobile-media {
filter: saturate(0.65);
opacity: 0.58;
}
.best-version-badge {
inset-inline-start: auto;
inset-inline-end: 0.5rem;
}
.subscribe-card-pending-tint::after {
box-shadow:
inset 0 0 0 1px rgba(var(--v-theme-info), 0.28),
inset 0 -4rem 5rem rgba(var(--v-theme-info), 0.08);
}
}
</style> </style>

View File

@@ -209,7 +209,7 @@ onMounted(() => {
background-color: var(--app-grouped-list-separator-color); background-color: var(--app-grouped-list-separator-color);
block-size: 1px; block-size: 1px;
inset-block-end: 0; inset-block-end: 0;
inset-inline: 58px 0; inset-inline: var(--app-grouped-list-content-offset) 0;
pointer-events: none; pointer-events: none;
z-index: 2; z-index: 2;
} }
@@ -221,6 +221,8 @@ onMounted(() => {
.settings-list-icon { .settings-list-icon {
--app-grouped-list-icon-rgb: var(--app-grouped-list-icon-primary-rgb); --app-grouped-list-icon-rgb: var(--app-grouped-list-icon-primary-rgb);
block-size: var(--app-grouped-list-icon-size) !important;
inline-size: var(--app-grouped-list-icon-size) !important;
border-radius: max(var(--app-control-radius), 8px) !important; border-radius: max(var(--app-control-radius), 8px) !important;
background: rgba(var(--app-grouped-list-icon-rgb), var(--app-grouped-list-icon-opacity)) !important; background: rgba(var(--app-grouped-list-icon-rgb), var(--app-grouped-list-icon-opacity)) !important;
color: var(--app-grouped-list-icon-foreground) !important; color: var(--app-grouped-list-icon-foreground) !important;
@@ -248,7 +250,7 @@ onMounted(() => {
} }
.settings-list-icon :deep(.v-icon) { .settings-list-icon :deep(.v-icon) {
font-size: 1.375rem !important; font-size: var(--app-grouped-list-icon-glyph-size) !important;
} }
.settings-list-title { .settings-list-title {

View File

@@ -108,6 +108,9 @@ html {
--app-grouped-list-active-background: rgba(var(--v-theme-primary), 0.1); --app-grouped-list-active-background: rgba(var(--v-theme-primary), 0.1);
--app-grouped-list-icon-foreground: rgb(var(--v-theme-on-primary)); --app-grouped-list-icon-foreground: rgb(var(--v-theme-on-primary));
--app-grouped-list-icon-opacity: 0.92; --app-grouped-list-icon-opacity: 0.92;
--app-grouped-list-icon-size: 36px;
--app-grouped-list-icon-glyph-size: 1.25rem;
--app-grouped-list-content-offset: calc(24px + var(--app-grouped-list-icon-size));
--app-grouped-list-icon-primary-rgb: var(--v-theme-primary); --app-grouped-list-icon-primary-rgb: var(--v-theme-primary);
--app-grouped-list-icon-info-rgb: var(--v-theme-info); --app-grouped-list-icon-info-rgb: var(--v-theme-info);
--app-grouped-list-icon-success-rgb: var(--v-theme-success); --app-grouped-list-icon-success-rgb: var(--v-theme-success);
@@ -1037,6 +1040,13 @@ html[data-theme="transparent"].transparent-glass-realtime .v-theme--transparent
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
} }
@media (width <= 599px) {
.grid-subscribe-card {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
}
.grid-user-card { .grid-user-card {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
} }

View File

@@ -11,12 +11,16 @@ import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh' import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
import { openSharedDialog } from '@/composables/useSharedDialog' import { openSharedDialog } from '@/composables/useSharedDialog'
import { useDisplay } from 'vuetify'
const SubscribeHistoryDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeHistoryDialog.vue')) const SubscribeHistoryDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeHistoryDialog.vue'))
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
// 响应式断点用于切换订阅卡片网格密度。
const display = useDisplay()
// 用户 Store // 用户 Store
const userStore = useUserStore() const userStore = useUserStore()
@@ -117,6 +121,9 @@ const sortMode = computed({
}) })
const canDragSort = computed(() => sortMode.value && canSortContext.value) const canDragSort = computed(() => sortMode.value && canSortContext.value)
const shouldVirtualizeList = computed(() => !sortMode.value) const shouldVirtualizeList = computed(() => !sortMode.value)
const subscribeGridMinItemWidth = computed(() => (display.xs.value ? 144 : 240))
const subscribeGridEstimatedItemHeight = computed(() => (display.xs.value ? 190 : 300))
const subscribeGridGap = computed(() => (display.xs.value ? 12 : 16))
const scrollToIndex = computed(() => { const scrollToIndex = computed(() => {
if (!props.subid || sortMode.value) { if (!props.subid || sortMode.value) {
return undefined return undefined
@@ -581,8 +588,9 @@ defineExpose({
v-else-if="displayList.length > 0 && shouldVirtualizeList" v-else-if="displayList.length > 0 && shouldVirtualizeList"
:items="displayList" :items="displayList"
:get-item-key="item => item.id" :get-item-key="item => item.id"
:min-item-width="240" :min-item-width="subscribeGridMinItemWidth"
:estimated-item-height="300" :estimated-item-height="subscribeGridEstimatedItemHeight"
:gap="subscribeGridGap"
:scroll-to-index="scrollToIndex" :scroll-to-index="scrollToIndex"
class="px-2" class="px-2"
> >