mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-10-28 19:25:55 +08:00
fix(webui): 全界面 UI 审查修复:按钮与文字对齐、动画、布局错乱
用 Playwright 在桌面/平板/手机、浅色/深色、中英文下截取全部页面与弹窗, 由干净上下文的审查者逐张检查,共修复 122 个问题(高严重度 12 个)。 - 布局:手机搜索结果行、矮屏登录页与向导、下载器表格响应式、日历长标题撑破网格、 深色模式下 Passkey 列表与种子复选框不可见、设置页新下载器行对齐 - 对齐:向导、登录页、添加 RSS 改用 ab-input / ab-button,控件高度与圆角统一 - 动画:向导步骤、插件页离场、归档区折叠、插件选项展开等过渡不再跳动或闪烁 - 日历:触屏上看不见的取消置顶按钮不再可点 - 私有弹窗改用 ab-modal;更新说明改用 renderMarkdown(链接只接受 http(s)) - 空状态步骤样式合并到 global.scss
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { Link } from '@lucide/vue';
|
||||
import { NSpin } from 'naive-ui';
|
||||
import AbField from './basic/ab-field.vue';
|
||||
import AbSelect from './basic/ab-select.vue';
|
||||
import AbSwitch from './basic/ab-switch.vue';
|
||||
@@ -173,26 +172,27 @@ function subscribe() {
|
||||
<!-- RSS Link -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ $t('topbar.add.rss_link') }}</label>
|
||||
<div class="input-wrapper">
|
||||
<Link :size="16" class="input-icon" />
|
||||
<input
|
||||
v-model="rss.url"
|
||||
type="text"
|
||||
class="form-input form-input--with-icon"
|
||||
:placeholder="$t('topbar.add.placeholder_link')"
|
||||
>
|
||||
</div>
|
||||
<ab-input
|
||||
v-model="rss.url"
|
||||
class="form-input"
|
||||
:aria-label="$t('topbar.add.rss_link')"
|
||||
:placeholder="$t('topbar.add.placeholder_link')"
|
||||
>
|
||||
<template #prefix>
|
||||
<Link :size="14" />
|
||||
</template>
|
||||
</ab-input>
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ $t('topbar.add.name') }}</label>
|
||||
<input
|
||||
<ab-input
|
||||
v-model="rss.name"
|
||||
type="text"
|
||||
class="form-input"
|
||||
:aria-label="$t('topbar.add.name')"
|
||||
:placeholder="$t('topbar.add.placeholder_name')"
|
||||
>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Options row -->
|
||||
@@ -244,15 +244,17 @@ function subscribe() {
|
||||
v-model="rule.episode_offset"
|
||||
:label="$t('homepage.rule.episode_offset')"
|
||||
>
|
||||
<template #action>
|
||||
<button
|
||||
<!-- 检测依赖已存在的规则 id:新规则没有 id,不显示按钮 -->
|
||||
<template v-if="rule.id" #action>
|
||||
<!-- md:与 ab-input 同高(34px / 触屏 44px) -->
|
||||
<ab-button
|
||||
variant="secondary"
|
||||
class="detect-btn"
|
||||
:disabled="offsetLoading || !rule.id"
|
||||
:loading="offsetLoading"
|
||||
@click="autoDetectOffset"
|
||||
>
|
||||
<NSpin v-if="offsetLoading" :size="14" />
|
||||
<span v-else>{{ $t('homepage.rule.auto_detect') }}</span>
|
||||
</button>
|
||||
{{ $t('homepage.rule.auto_detect') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</bangumi-offset-field>
|
||||
<div v-if="offsetReason" class="offset-reason">
|
||||
@@ -282,7 +284,7 @@ function subscribe() {
|
||||
{{ $t('setup.nav.previous') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
variant="primary"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:loading="loading.collect"
|
||||
@click="collect"
|
||||
@@ -334,46 +336,9 @@ function subscribe() {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: var(--color-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
// ab-input 在平板以上默认 200px;这里上下堆叠,占满整行
|
||||
.form-group .form-input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface-hover);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
&--with-icon {
|
||||
padding-left: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.options-row {
|
||||
@@ -381,7 +346,7 @@ function subscribe() {
|
||||
flex-direction: column;
|
||||
gap: var(--layout-gap);
|
||||
padding: var(--layout-padding);
|
||||
background: var(--color-surface-hover);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
@@ -402,31 +367,7 @@ function subscribe() {
|
||||
// shared preview/tags/rss-link/filter/offset-row styles live in the
|
||||
// bangumi-* / advanced-section sub-components)
|
||||
.detect-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 80px;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.offset-reason {
|
||||
|
||||
@@ -29,10 +29,7 @@ const posterSrc = computed(() => resolvePosterUrl(bangumi.poster_link));
|
||||
@keydown.enter="() => $emit('click')"
|
||||
@keydown.space.prevent="() => $emit('click')"
|
||||
>
|
||||
<div
|
||||
class="card-poster"
|
||||
:class="{ 'card-poster--needs-review': bangumi.needs_review }"
|
||||
>
|
||||
<div class="card-poster">
|
||||
<template v-if="bangumi.poster_link">
|
||||
<img
|
||||
:src="posterSrc"
|
||||
@@ -146,7 +143,7 @@ const posterSrc = computed(() => resolvePosterUrl(bangumi.poster_link));
|
||||
<style lang="scss" scoped>
|
||||
// Grid poster card
|
||||
.card-wrap {
|
||||
width: 150px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
@@ -200,47 +197,9 @@ const posterSrc = computed(() => resolvePosterUrl(bangumi.poster_link));
|
||||
display: block;
|
||||
}
|
||||
|
||||
// 待审核的黄色光晕:静态 box-shadow 放在 ::after 上,只动画 opacity(合成器线程,
|
||||
// 不触发每帧重绘)。光晕在海报外侧,所以这里取消 overflow 裁剪,由子元素继承圆角
|
||||
.card-poster--needs-review {
|
||||
overflow: visible;
|
||||
|
||||
> * {
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
box-shadow: 0 0 16px 4px rgba(251, 191, 36, 0.6);
|
||||
pointer-events: none;
|
||||
animation: card-glow 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
&::after {
|
||||
animation: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes card-glow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.card-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 210px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -30,6 +30,12 @@ const { title = 'title', flush = false } = defineProps<{
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: border-color var(--transition-normal);
|
||||
|
||||
// 正文/页脚的方角底色会盖住圆角描边:最后一块跟着卡片圆角。
|
||||
// 不用 overflow: hidden,否则卡片边缘的焦点环和定位元素会被裁掉
|
||||
> :last-child {
|
||||
border-radius: 0 0 calc(var(--radius-md) - 1px) calc(var(--radius-md) - 1px);
|
||||
}
|
||||
}
|
||||
|
||||
.container-header {
|
||||
@@ -44,6 +50,11 @@ const { title = 'title', flush = false } = defineProps<{
|
||||
user-select: none;
|
||||
transition: color var(--transition-normal),
|
||||
border-color var(--transition-normal);
|
||||
|
||||
// 触屏按钮 44px 高,上下留白,不贴住头部边框
|
||||
@include forTouch {
|
||||
padding-block: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.container-title {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { NCheckbox, NSpin, useMessage } from 'naive-ui';
|
||||
import { NCheckbox, useMessage } from 'naive-ui';
|
||||
import { onKeyStroke } from '@vueuse/core';
|
||||
import type { BangumiRule, DetectOffsetResponse } from '#/bangumi';
|
||||
import { useDownloaderInstances } from '@/hooks/useDownloaderInstances';
|
||||
@@ -249,46 +249,40 @@ function emitUnarchive() {
|
||||
:avoid-keyboard="false"
|
||||
@close="close"
|
||||
>
|
||||
<!-- Needs Review Warning Banner -->
|
||||
<div
|
||||
v-if="localRule.needs_review"
|
||||
class="review-warning"
|
||||
role="status"
|
||||
:aria-label="$t('offset.needs_review')"
|
||||
>
|
||||
<div class="review-warning-main">
|
||||
<span class="review-warning-emoji">⚠️</span>
|
||||
<div class="review-warning-content">
|
||||
<div class="review-warning-title">
|
||||
{{ $t('offset.needs_review') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="localRule.needs_review_reason"
|
||||
class="review-warning-reason"
|
||||
<!-- Needs Review Warning Banner:忽略/检测后按行高收起,下方内容不跳 -->
|
||||
<Transition name="collapse">
|
||||
<div v-if="localRule.needs_review" class="review-collapse">
|
||||
<div class="review-collapse-inner">
|
||||
<ab-alert
|
||||
type="warning"
|
||||
class="review-warning"
|
||||
:title="$t('offset.needs_review')"
|
||||
>
|
||||
{{ localRule.needs_review_reason }}
|
||||
</div>
|
||||
<template #action>
|
||||
<span class="review-warning-actions">
|
||||
<ab-button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
:loading="offsetLoading"
|
||||
@click="autoDetectOffset"
|
||||
>
|
||||
{{ $t('homepage.rule.auto_detect') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
:loading="dismissingReview"
|
||||
@click="dismissReview"
|
||||
>
|
||||
{{ $t('offset.dismiss') }}
|
||||
</ab-button>
|
||||
</span>
|
||||
</template>
|
||||
</ab-alert>
|
||||
</div>
|
||||
</div>
|
||||
<div class="review-warning-actions">
|
||||
<button
|
||||
class="detect-btn"
|
||||
:disabled="offsetLoading"
|
||||
@click="autoDetectOffset"
|
||||
>
|
||||
<NSpin v-if="offsetLoading" :size="12" />
|
||||
<span v-else>{{ $t('homepage.rule.auto_detect') }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="dismiss-btn"
|
||||
:disabled="dismissingReview"
|
||||
@click="dismissReview"
|
||||
>
|
||||
<NSpin v-if="dismissingReview" :size="12" />
|
||||
<span v-else>{{ $t('offset.dismiss') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div v-if="pluginTabs.length" class="edit-tabs">
|
||||
<ab-segmented
|
||||
@@ -337,7 +331,6 @@ function emitUnarchive() {
|
||||
:model-value="localRule.air_weekday ?? null"
|
||||
:options="weekdayOptions"
|
||||
clearable
|
||||
size="small"
|
||||
:placeholder="$t('calendar.unknown')"
|
||||
@update:model-value="onWeekdayChange($event as number | null)"
|
||||
/>
|
||||
@@ -347,7 +340,6 @@ function emitUnarchive() {
|
||||
<AbSelect
|
||||
:model-value="localRule.episode_type"
|
||||
:options="episodeTypeOptions"
|
||||
size="small"
|
||||
@update:model-value="
|
||||
localRule.episode_type = $event as BangumiRule['episode_type']
|
||||
"
|
||||
@@ -362,7 +354,6 @@ function emitUnarchive() {
|
||||
v-model="localRule.downloader_id"
|
||||
:options="downloaders.options.value"
|
||||
clearable
|
||||
size="small"
|
||||
:placeholder="
|
||||
$t('homepage.rule.downloader_default', {
|
||||
id: downloaders.data.value.default,
|
||||
@@ -387,7 +378,7 @@ function emitUnarchive() {
|
||||
clearable
|
||||
filterable
|
||||
tag
|
||||
size="small"
|
||||
class="resolution-select"
|
||||
:placeholder="$t('homepage.rule.auto_detect')"
|
||||
/>
|
||||
</AbField>
|
||||
@@ -409,7 +400,7 @@ function emitUnarchive() {
|
||||
<p class="delete-message">
|
||||
{{ $t('homepage.rule.delete_confirm') }}
|
||||
</p>
|
||||
<NCheckbox v-model:checked="deleteLocalFiles" class="delete-files-option">
|
||||
<NCheckbox v-model:checked="deleteLocalFiles">
|
||||
{{ $t('homepage.rule.delete_files_label') }}
|
||||
</NCheckbox>
|
||||
|
||||
@@ -454,99 +445,45 @@ function emitUnarchive() {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// Review warning banner
|
||||
.review-collapse {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.review-collapse-inner {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.collapse-enter-active,
|
||||
.collapse-leave-active {
|
||||
transition: grid-template-rows var(--transition-normal),
|
||||
opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
.collapse-enter-from,
|
||||
.collapse-leave-to {
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// 间距放在收起容器内部,随横幅一起收起
|
||||
.review-warning {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
margin: 12px 20px;
|
||||
background: var(--color-warning-bg);
|
||||
border: 1px solid var(--color-warning-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
margin-bottom: 14px;
|
||||
|
||||
.review-warning-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1 1 200px;
|
||||
min-width: 0;
|
||||
}
|
||||
:deep(.ab-alert-text) {
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
|
||||
.review-warning-emoji {
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.review-warning-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.review-warning-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
|
||||
.review-warning-reason {
|
||||
font-size: 12px;
|
||||
color: var(--color-warning-text-secondary);
|
||||
line-height: 1.3;
|
||||
margin-top: 2px;
|
||||
:deep(.ab-alert-action) {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.review-warning-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.review-warning-actions .detect-btn,
|
||||
.review-warning-actions .dismiss-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
}
|
||||
|
||||
.review-warning-actions .detect-btn {
|
||||
min-width: 90px;
|
||||
color: #fff;
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.review-warning-actions .dismiss-btn {
|
||||
min-width: 70px;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: var(--color-text-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-tabs {
|
||||
@@ -579,8 +516,11 @@ function emitUnarchive() {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.delete-files-option {
|
||||
margin-bottom: 20px;
|
||||
// filterable 下拉按占位文字撑宽(204px);钉回与其它控件同宽
|
||||
.edit-content .resolution-select {
|
||||
@include forTablet {
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.preferred-hint {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { NDynamicTags } from 'naive-ui';
|
||||
import AbField from './basic/ab-field.vue';
|
||||
import AbInput from './basic/ab-input.vue';
|
||||
import AbSelect from './basic/ab-select.vue';
|
||||
import AbSwitch from './basic/ab-switch.vue';
|
||||
import AbTagsInput from './basic/ab-tags-input.vue';
|
||||
import type { AbSettingProps, SelectItem } from '#/components';
|
||||
|
||||
// 设置行:ab-field(标签/说明/错误/aria)+ 对应控件的组合。
|
||||
@@ -72,10 +72,10 @@ const selectValue = computed<string | number | null>({
|
||||
/>
|
||||
|
||||
<div v-else-if="type === 'dynamic-tags'" class="dynamic-tags-wrapper">
|
||||
<NDynamicTags
|
||||
v-model:value="data"
|
||||
size="small"
|
||||
<AbTagsInput
|
||||
v-model="data"
|
||||
:aria-label="labelText"
|
||||
:placeholder="prop?.placeholder"
|
||||
/>
|
||||
</div>
|
||||
</AbField>
|
||||
@@ -104,13 +104,12 @@ const selectValue = computed<string | number | null>({
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
// 与 ab-input 同宽(200px),各行控件左缘对齐;标签在框内换行
|
||||
.dynamic-tags-wrapper {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
|
||||
@include forTablet {
|
||||
max-width: 220px;
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -78,7 +78,8 @@ const menuItems = computed<AbMenuItem[]>(() =>
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--color-text-secondary);
|
||||
transition: color var(--transition-fast), transform var(--transition-fast);
|
||||
transition: color var(--transition-fast),
|
||||
background-color var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -97,13 +98,10 @@ const menuItems = computed<AbMenuItem[]>(() =>
|
||||
min-height: var(--touch-target);
|
||||
}
|
||||
|
||||
// 与 ab-icon-button ghost 一致:状态只靠底色与墨色表达
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
|
||||
@@ -214,19 +214,25 @@ onActivated(load);
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-hover);
|
||||
background: var(--color-surface-2);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-surface-hover) 80%,
|
||||
var(--color-surface-2) 85%,
|
||||
var(--color-primary)
|
||||
);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
// 行底色(含悬停混色)接近 --color-border,行内标签描边提一级
|
||||
:deep(.ab-tag) {
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
&--selected,
|
||||
&--selected:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
outline: 1px solid var(--color-primary);
|
||||
}
|
||||
@@ -237,7 +243,7 @@ onActivated(load);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid var(--color-border);
|
||||
border: 2px solid var(--color-border-hover);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -21,19 +21,19 @@ const open = defineModel<boolean>('open', { default: false });
|
||||
{{ $t('search.confirm.advanced') }}
|
||||
</button>
|
||||
|
||||
<Transition name="expand">
|
||||
<div v-show="open" class="advanced-content">
|
||||
<slot />
|
||||
<!-- 0fr↔1fr 行高过渡:高度可插值,展开/收起不再一帧跳到终值;
|
||||
收起时 inert,隐藏的控件不可聚焦 -->
|
||||
<div class="advanced-collapse" :class="{ 'is-open': open }">
|
||||
<div class="advanced-inner" :inert="!open">
|
||||
<div class="advanced-content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.advanced-section {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.advanced-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -72,29 +72,35 @@ const open = defineModel<boolean>('open', { default: false });
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-collapse {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
transition: grid-template-rows var(--transition-normal),
|
||||
opacity var(--transition-normal);
|
||||
|
||||
&.is-open {
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-inner {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.advanced-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--color-surface-hover);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
// Expand transition
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: all var(--transition-normal);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { NDynamicTags } from 'naive-ui';
|
||||
import AbField from './basic/ab-field.vue';
|
||||
import AbTagsInput from './basic/ab-tags-input.vue';
|
||||
|
||||
defineProps<{
|
||||
modelValue: string[];
|
||||
@@ -11,63 +12,24 @@ const emit = defineEmits<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="advanced-row advanced-row--tags">
|
||||
<label class="advanced-label">{{ $t('homepage.rule.filter') }}</label>
|
||||
<div class="advanced-control filter-tags">
|
||||
<NDynamicTags
|
||||
:value="modelValue"
|
||||
size="small"
|
||||
@update:value="emit('update:modelValue', $event)"
|
||||
<AbField :label="$t('homepage.rule.filter')">
|
||||
<div class="filter-tags">
|
||||
<AbTagsInput
|
||||
:model-value="modelValue"
|
||||
:aria-label="$t('homepage.rule.filter')"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AbField>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.advanced-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 32px;
|
||||
// 与 AbField 里 ab-input / 下拉框同宽,右列左缘对齐
|
||||
.filter-tags {
|
||||
width: 100%;
|
||||
|
||||
&--tags {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.advanced-control {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
|
||||
:deep(.n-dynamic-tags) {
|
||||
justify-content: flex-end;
|
||||
min-height: 32px;
|
||||
min-width: 0;
|
||||
|
||||
.n-tag {
|
||||
height: 28px;
|
||||
max-width: calc(100% - 6px);
|
||||
margin: 2px 0 2px 6px !important;
|
||||
}
|
||||
|
||||
.n-tag__content {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
height: 28px;
|
||||
}
|
||||
@include forTablet {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,14 +8,14 @@ defineProps<{
|
||||
|
||||
<template>
|
||||
<div v-if="tags.length > 0" class="info-tags">
|
||||
<div
|
||||
<!-- 剧场版/特别篇用 info 标记与常规元数据区分,其余为中性标签 -->
|
||||
<ab-tag
|
||||
v-for="tag in tags"
|
||||
:key="tag.type"
|
||||
class="info-tag"
|
||||
:class="`info-tag--${tag.type}`"
|
||||
:type="tag.type === 'etype' ? 'info' : 'neutral'"
|
||||
>
|
||||
{{ tag.i18n ? $t(tag.value) : tag.value }}
|
||||
</div>
|
||||
</ab-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -23,42 +23,6 @@ defineProps<{
|
||||
.info-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
&--season {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
&--resolution {
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
&--subtitle {
|
||||
background: color-mix(in srgb, var(--color-success) 12%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
&--group {
|
||||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
// 剧场版/特别篇:反白强调,与常规元数据标签区分
|
||||
&--etype {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-white);
|
||||
}
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import AbField from './basic/ab-field.vue';
|
||||
|
||||
/**
|
||||
* 单个数字 offset 行:标签 + 数字输入 + 可选的操作插槽(如“自动检测”按钮)。
|
||||
* ab-add-rss 用它承载 episode_offset + 检测按钮,ab-edit-rule 用它承载
|
||||
@@ -21,57 +23,33 @@ const proxyValue = computed({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="advanced-row">
|
||||
<label class="advanced-label">{{ label }}</label>
|
||||
<div class="advanced-control offset-controls">
|
||||
<ab-input
|
||||
v-model="proxyValue"
|
||||
type="number"
|
||||
class="offset-input"
|
||||
:aria-label="label"
|
||||
/>
|
||||
<AbField :label="label">
|
||||
<div class="offset-controls">
|
||||
<ab-input v-model="proxyValue" type="number" class="offset-input" />
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
</AbField>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.advanced-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.advanced-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.advanced-control {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
// 控件列与其它 AbField 行同宽(200px);有操作按钮时输入框收窄让位
|
||||
.offset-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
width: 100%;
|
||||
|
||||
@include forTablet {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.offset-input {
|
||||
width: 70px;
|
||||
height: 32px;
|
||||
text-align: center;
|
||||
// 压过 AbField 给 .ab-input 的 200px 宽度下限,让输入框给按钮让位
|
||||
.offset-controls .ab-input.offset-input {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
|
||||
@include forTouch {
|
||||
width: 84px;
|
||||
height: var(--touch-target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ const rule = defineModel<BangumiRule>('rule', { required: true });
|
||||
.bangumi-info {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.bangumi-poster {
|
||||
@@ -169,15 +168,9 @@ const rule = defineModel<BangumiRule>('rule', { required: true });
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
// 缺年份:只用警示色下划线标记,不着底色、不改内边距(与标题左缘对齐)
|
||||
&--empty {
|
||||
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
||||
border-bottom-color: var(--color-warning);
|
||||
border-radius: var(--radius-xs) var(--radius-xs) 0 0;
|
||||
padding: 2px 4px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +185,7 @@ const rule = defineModel<BangumiRule>('rule', { required: true });
|
||||
}
|
||||
|
||||
.season-input {
|
||||
width: 40px;
|
||||
width: 4ch;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
background: transparent;
|
||||
@@ -200,7 +193,6 @@ const rule = defineModel<BangumiRule>('rule', { required: true });
|
||||
border-bottom: 1px solid transparent;
|
||||
padding: 2px 0;
|
||||
outline: none;
|
||||
text-align: center;
|
||||
transition: border-color var(--transition-fast);
|
||||
|
||||
&:hover,
|
||||
|
||||
@@ -17,7 +17,6 @@ const emit = defineEmits<{
|
||||
<span class="info-label">{{ $t('search.confirm.rss') }}:</span>
|
||||
<span class="info-value info-value--link">{{ props.link }}</span>
|
||||
<ab-icon-button
|
||||
size="sm"
|
||||
class="copy-btn"
|
||||
:class="{ copied: props.copied }"
|
||||
:label="$t('common.copy')"
|
||||
@@ -35,15 +34,14 @@ const emit = defineEmits<{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--color-surface-hover);
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -70,29 +68,10 @@ const emit = defineEmits<{
|
||||
}
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
&.copied {
|
||||
background: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
color: #fff;
|
||||
}
|
||||
// 复制成功只换图标并着成功色(标记,不填充)
|
||||
// 点击时指针仍悬停在按钮上(触屏 hover 会粘住),悬停态也保留成功色
|
||||
.copy-btn.copied,
|
||||
.copy-btn.copied:hover:not(:disabled) {
|
||||
color: var(--color-success);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,20 +28,29 @@ function onClick(event: MouseEvent) {
|
||||
<template>
|
||||
<button
|
||||
class="ab-btn"
|
||||
:class="[`ab-btn--${variant}`, `ab-btn--${size}`, block && 'ab-btn--block']"
|
||||
:class="[
|
||||
`ab-btn--${variant}`,
|
||||
`ab-btn--${size}`,
|
||||
block && 'ab-btn--block',
|
||||
loading && 'ab-btn--loading',
|
||||
]"
|
||||
:type="type"
|
||||
:disabled="disabled || loading"
|
||||
:aria-busy="loading || undefined"
|
||||
@click="onClick"
|
||||
>
|
||||
<!-- loading 时文字只隐藏不移除,转圈叠在原位,按钮尺寸不变 -->
|
||||
<span class="ab-btn-label">
|
||||
<slot name="icon" />
|
||||
<slot />
|
||||
</span>
|
||||
<span v-if="loading" class="ab-btn-spin" aria-hidden="true" />
|
||||
<slot v-else name="icon" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ab-btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -132,7 +141,25 @@ function onClick(event: MouseEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// 调用方写在按钮上的 gap / justify-content 透传给插槽内容;min-width:0 让内容可收缩、省略
|
||||
.ab-btn-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: inherit;
|
||||
justify-content: inherit;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
|
||||
// 用 opacity 而非 visibility:保留按钮的可访问名称
|
||||
.ab-btn--loading & {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ab-btn-spin {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -153,7 +153,8 @@ function clear() {
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
// 与 ab-select(naive 默认 34px)、ab-button md 同高;触屏统一 44px 命中区
|
||||
height: 34px;
|
||||
padding: 0 11px;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid transparent;
|
||||
@@ -163,7 +164,10 @@ function clear() {
|
||||
|
||||
@include forTablet {
|
||||
width: 200px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
@include forTouch {
|
||||
height: var(--touch-target);
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
|
||||
@@ -160,8 +160,11 @@ function toggleAll() {
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-hover);
|
||||
// 触屏点按后不保留悬停态
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts" setup>
|
||||
import { TriangleAlert, X } from '@lucide/vue';
|
||||
import type { OffsetSuggestionDetail, TMDBSummary } from '#/bangumi';
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -54,17 +53,12 @@ const preview = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
// Confidence badge color
|
||||
const confidenceColor = computed(() => {
|
||||
switch (props.suggestion?.confidence) {
|
||||
case 'high':
|
||||
return 'var(--color-error)';
|
||||
case 'medium':
|
||||
return 'var(--color-warning)';
|
||||
default:
|
||||
return 'var(--color-text-muted)';
|
||||
}
|
||||
});
|
||||
// 置信度:方块语义标记 + 墨色文字(ab-tag),颜色不承载文字
|
||||
const CONFIDENCE_TAG = {
|
||||
high: 'danger',
|
||||
medium: 'warning',
|
||||
low: 'neutral',
|
||||
} as const;
|
||||
|
||||
function handleApply() {
|
||||
emit('apply', {
|
||||
@@ -86,244 +80,155 @@ function handleCancel() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="show" class="dialog-backdrop" @click.self="handleCancel">
|
||||
<div class="dialog-modal" role="dialog" aria-modal="true">
|
||||
<!-- Header -->
|
||||
<header class="dialog-header">
|
||||
<div class="header-icon">
|
||||
<TriangleAlert :size="20" />
|
||||
</div>
|
||||
<h2 class="dialog-title">
|
||||
{{ t('offset.dialog_title') }}
|
||||
</h2>
|
||||
<button class="close-btn" aria-label="Close" @click="handleCancel">
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</header>
|
||||
<ab-modal
|
||||
v-model:show="show"
|
||||
size="md"
|
||||
:title="t('offset.dialog_title')"
|
||||
@close="emit('cancel')"
|
||||
>
|
||||
<!-- Bangumi title -->
|
||||
<div class="bangumi-title">
|
||||
{{ bangumiTitle }}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="dialog-content">
|
||||
<!-- Bangumi title -->
|
||||
<div class="bangumi-title">
|
||||
{{ bangumiTitle }}
|
||||
</div>
|
||||
|
||||
<!-- Comparison section -->
|
||||
<div class="comparison">
|
||||
<!-- RSS parsed result -->
|
||||
<div class="comparison-box">
|
||||
<div class="comparison-label">
|
||||
{{ t('offset.parsed_result') }}
|
||||
</div>
|
||||
<div class="comparison-value">
|
||||
<span class="value-label">{{ t('offset.season') }}:</span>
|
||||
<span class="value-num">{{ parsedSeason }}</span>
|
||||
</div>
|
||||
<div class="comparison-value">
|
||||
<span class="value-label">{{ t('offset.episode') }}:</span>
|
||||
<span class="value-num">{{ parsedEpisode }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comparison-vs">
|
||||
≠
|
||||
</div>
|
||||
|
||||
<!-- TMDB data -->
|
||||
<div class="comparison-box">
|
||||
<div class="comparison-label">
|
||||
{{ t('offset.tmdb_data') }}
|
||||
</div>
|
||||
<div v-if="tmdbInfo" class="comparison-value">
|
||||
<span class="value-label">{{ t('offset.total_seasons') }}:</span>
|
||||
<span class="value-num">{{ tmdbInfo.total_seasons }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
tmdbInfo
|
||||
&& tmdbInfo.season_episode_counts[
|
||||
parsedSeason + (suggestion?.season_offset ?? 0)
|
||||
]
|
||||
"
|
||||
class="comparison-value"
|
||||
>
|
||||
<span class="value-label">S{{ parsedSeason + (suggestion?.season_offset ?? 0) }}
|
||||
{{ t('offset.episode') }}:</span>
|
||||
<span class="value-num">{{
|
||||
tmdbInfo.season_episode_counts[
|
||||
parsedSeason + (suggestion?.season_offset ?? 0)
|
||||
]
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reason -->
|
||||
<div v-if="suggestion?.reason" class="reason-section">
|
||||
<span
|
||||
class="reason-badge"
|
||||
:style="{ backgroundColor: confidenceColor }"
|
||||
>
|
||||
{{ suggestion.confidence }}
|
||||
</span>
|
||||
<span class="reason-text">{{ suggestion.reason }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Offset inputs -->
|
||||
<div class="offset-section">
|
||||
<div class="offset-title">
|
||||
{{ t('offset.suggested_offset') }}
|
||||
</div>
|
||||
<div class="offset-row">
|
||||
<label class="offset-label">{{ t('offset.season_offset') }}:</label>
|
||||
<input
|
||||
v-model.number="seasonOffset"
|
||||
type="number"
|
||||
class="offset-input"
|
||||
>
|
||||
<span class="offset-hint">→ S{{ parsedSeason }}
|
||||
{{ t('offset.season') === '季度' ? '变为' : 'becomes' }} S{{
|
||||
Math.max(1, parsedSeason + seasonOffset)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="offset-row">
|
||||
<label class="offset-label">{{ t('offset.episode_offset') }}:</label>
|
||||
<input
|
||||
v-model.number="episodeOffset"
|
||||
type="number"
|
||||
class="offset-input"
|
||||
>
|
||||
<span class="offset-hint">→ E{{ parsedEpisode }}
|
||||
{{ t('offset.season') === '季度' ? '保持' : 'stays' }} E{{
|
||||
Math.max(1, parsedEpisode + episodeOffset)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="preview-section">
|
||||
<span class="preview-label">{{ t('offset.preview') }}:</span>
|
||||
<span class="preview-from">{{ preview.from }}</span>
|
||||
<span class="preview-arrow">→</span>
|
||||
<span class="preview-to">{{ preview.to }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="dialog-footer">
|
||||
<ab-button size="sm" variant="secondary" @click="handleCancel">
|
||||
{{ t('offset.cancel') }}
|
||||
</ab-button>
|
||||
<ab-button variant="primary" size="sm" @click="handleKeep">
|
||||
{{ t('offset.keep') }}
|
||||
</ab-button>
|
||||
<ab-button size="sm" variant="primary" @click="handleApply">
|
||||
{{ t('offset.apply') }}
|
||||
</ab-button>
|
||||
</footer>
|
||||
<!-- Comparison section:两列共用行轨道(subgrid),标题换行时数值行仍对齐 -->
|
||||
<div class="comparison">
|
||||
<!-- RSS parsed result -->
|
||||
<div class="comparison-box">
|
||||
<div class="comparison-label">
|
||||
{{ t('offset.parsed_result') }}
|
||||
</div>
|
||||
<div class="comparison-value">
|
||||
<span class="value-label">{{ t('offset.season') }}:</span>
|
||||
<span class="value-num">{{ parsedSeason }}</span>
|
||||
</div>
|
||||
<div class="comparison-value">
|
||||
<span class="value-label">{{ t('offset.episode') }}:</span>
|
||||
<span class="value-num">{{ parsedEpisode }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<div class="comparison-vs">
|
||||
≠
|
||||
</div>
|
||||
|
||||
<!-- TMDB data -->
|
||||
<div class="comparison-box">
|
||||
<div class="comparison-label">
|
||||
{{ t('offset.tmdb_data') }}
|
||||
</div>
|
||||
<div v-if="tmdbInfo" class="comparison-value">
|
||||
<span class="value-label">{{ t('offset.total_seasons') }}:</span>
|
||||
<span class="value-num">{{ tmdbInfo.total_seasons }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
tmdbInfo
|
||||
&& tmdbInfo.season_episode_counts[
|
||||
parsedSeason + (suggestion?.season_offset ?? 0)
|
||||
]
|
||||
"
|
||||
class="comparison-value"
|
||||
>
|
||||
<span class="value-label">S{{ parsedSeason + (suggestion?.season_offset ?? 0) }}
|
||||
{{ t('offset.episode') }}:</span>
|
||||
<span class="value-num">{{
|
||||
tmdbInfo.season_episode_counts[
|
||||
parsedSeason + (suggestion?.season_offset ?? 0)
|
||||
]
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reason -->
|
||||
<div v-if="suggestion?.reason" class="reason-section">
|
||||
<ab-tag
|
||||
class="reason-badge"
|
||||
:type="CONFIDENCE_TAG[suggestion.confidence]"
|
||||
:title="suggestion.confidence"
|
||||
/>
|
||||
<span class="reason-text">{{ suggestion.reason }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Offset inputs -->
|
||||
<div class="offset-section">
|
||||
<div class="offset-title">
|
||||
{{ t('offset.suggested_offset') }}
|
||||
</div>
|
||||
<div class="offset-row">
|
||||
<span class="offset-label">{{ t('offset.season_offset') }}:</span>
|
||||
<ab-input
|
||||
v-model="seasonOffset"
|
||||
type="number"
|
||||
class="offset-input"
|
||||
:aria-label="t('offset.season_offset')"
|
||||
/>
|
||||
<span class="offset-hint">→ S{{ parsedSeason }}
|
||||
{{ t('offset.season') === '季度' ? '变为' : 'becomes' }} S{{
|
||||
Math.max(1, parsedSeason + seasonOffset)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="offset-row">
|
||||
<span class="offset-label">{{ t('offset.episode_offset') }}:</span>
|
||||
<ab-input
|
||||
v-model="episodeOffset"
|
||||
type="number"
|
||||
class="offset-input"
|
||||
:aria-label="t('offset.episode_offset')"
|
||||
/>
|
||||
<span class="offset-hint">→ E{{ parsedEpisode }}
|
||||
{{ t('offset.season') === '季度' ? '保持' : 'stays' }} E{{
|
||||
Math.max(1, parsedEpisode + episodeOffset)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="preview-section">
|
||||
<span class="preview-label">{{ t('offset.preview') }}:</span>
|
||||
<span class="preview-from">{{ preview.from }}</span>
|
||||
<span class="preview-arrow">→</span>
|
||||
<span class="preview-to">{{ preview.to }}</span>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<ab-button size="sm" variant="ghost" @click="handleCancel">
|
||||
{{ t('offset.cancel') }}
|
||||
</ab-button>
|
||||
<ab-button size="sm" variant="secondary" @click="handleKeep">
|
||||
{{ t('offset.keep') }}
|
||||
</ab-button>
|
||||
<ab-button size="sm" variant="primary" @click="handleApply">
|
||||
{{ t('offset.apply') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-overlay);
|
||||
z-index: var(--z-modal);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.dialog-modal {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-warning);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.bangumi-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
color: var(--color-text);
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.comparison {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
grid-template-rows: repeat(3, auto);
|
||||
column-gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.comparison-box {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-row: span 3;
|
||||
grid-template-rows: subgrid;
|
||||
row-gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
background: var(--color-surface-hover);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
@@ -332,32 +237,33 @@ function handleCancel() {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.comparison-value {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.value-label {
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.value-num {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.comparison-vs {
|
||||
grid-row: span 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 24px;
|
||||
color: var(--color-warning);
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.reason-section {
|
||||
@@ -365,7 +271,6 @@ function handleCancel() {
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
@@ -373,11 +278,6 @@ function handleCancel() {
|
||||
|
||||
.reason-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -389,7 +289,7 @@ function handleCancel() {
|
||||
|
||||
.offset-section {
|
||||
padding: 16px;
|
||||
background: var(--color-surface-hover);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -420,26 +320,14 @@ function handleCancel() {
|
||||
}
|
||||
|
||||
.offset-input {
|
||||
width: 70px;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
flex-shrink: 0;
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.offset-hint {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
@@ -475,33 +363,4 @@ function handleCancel() {
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
// Modal transition
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 200ms ease;
|
||||
|
||||
.dialog-modal {
|
||||
transition: transform 200ms ease, opacity 200ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
|
||||
.dialog-modal {
|
||||
transform: scale(0.95) translateY(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
// 细进度条(下载页):轨道用 surface-2,进行中为主色,错误为 danger。
|
||||
// 细进度条(下载页):轨道用半透明 muted 色(任何行底色上都可见),进行中为主色,错误为 danger。
|
||||
const { value = 0, label = '', state = 'active' } = defineProps<{
|
||||
/** 0–100 */
|
||||
value: number;
|
||||
@@ -42,7 +42,7 @@ const clamped = computed(() => Math.min(100, Math.max(0, value)));
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface-2);
|
||||
background: color-mix(in srgb, var(--color-text-muted) 30%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -64,6 +64,9 @@ const clamped = computed(() => Math.min(100, Math.max(0, value)));
|
||||
|
||||
.ab-progress-label {
|
||||
flex-shrink: 0;
|
||||
// 固定宽度放下 '100%',各行轨道等长、数字右对齐
|
||||
min-width: 4ch;
|
||||
text-align: right;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
@@ -67,6 +67,13 @@ function optionNodeProps() {
|
||||
.ab-select {
|
||||
max-width: 100%;
|
||||
|
||||
// 触屏与 ab-input、ab-button 同为 44px 命中区(naive 把 --n-height 写在行内样式上)
|
||||
@include forTouch {
|
||||
:deep(.n-base-selection) {
|
||||
--n-height: var(--touch-target) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&--error {
|
||||
:deep(.n-base-selection) {
|
||||
--n-border: 1px solid var(--color-danger) !important;
|
||||
|
||||
@@ -3,7 +3,7 @@ import AbInput from './ab-input.vue';
|
||||
import AbTag from './ab-tag.vue';
|
||||
|
||||
// 字符串列表输入:已有项显示为可删除的 ab-tag,回车添加新项(去重、去空白)
|
||||
defineProps<{ ariaLabel?: string }>();
|
||||
defineProps<{ ariaLabel?: string; placeholder?: string }>();
|
||||
const model = defineModel<string[]>({ default: () => [] });
|
||||
const draft = ref('');
|
||||
|
||||
@@ -36,6 +36,7 @@ function onEnter(event: KeyboardEvent) {
|
||||
<AbInput
|
||||
v-model="draft"
|
||||
:aria-label="ariaLabel"
|
||||
:placeholder="placeholder"
|
||||
@keydown.enter.prevent="onEnter"
|
||||
@focusout="add"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
import { NTooltip } from 'naive-ui';
|
||||
|
||||
// NTooltip 的统一封装:延迟、触屏回退(点击触发)与样式只在这里定一次。
|
||||
@@ -15,10 +16,18 @@ const { content = '', placement = 'top', delay = 400 } = defineProps<{
|
||||
'bottom-end';
|
||||
delay?: number;
|
||||
}>();
|
||||
|
||||
// 触屏无法悬停:改为点击触发
|
||||
const isTouch = useMediaQuery('(hover: none) and (pointer: coarse)');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NTooltip :placement="placement" :delay="delay" trigger="hover">
|
||||
<NTooltip
|
||||
:placement="placement"
|
||||
:delay="delay"
|
||||
:trigger="isTouch ? 'click' : 'hover'"
|
||||
:style="{ maxWidth: '360px', overflowWrap: 'anywhere' }"
|
||||
>
|
||||
<template #trigger>
|
||||
<slot />
|
||||
</template>
|
||||
|
||||
@@ -51,11 +51,12 @@ const unknownGroups = computed({
|
||||
<calendar-day-column
|
||||
v-for="(key, index) in dayKeys"
|
||||
:key="key"
|
||||
class="calendar-enter"
|
||||
:style="{ '--delay': `${index * 0.03}s` }"
|
||||
:day-label="getDayLabel(key)"
|
||||
:is-today="isToday(index)"
|
||||
:is-dragging="isDragging"
|
||||
:groups="groupedByDay[key] || []"
|
||||
:delay="`${index * 0.05}s`"
|
||||
@change="onDropToDay(index, $event)"
|
||||
@card-click="emit('card-click', $event)"
|
||||
@unpin="onUnpin"
|
||||
@@ -65,8 +66,8 @@ const unknownGroups = computed({
|
||||
<!-- Unknown air day section (draggable source) -->
|
||||
<div
|
||||
v-if="unknownGroups.length > 0"
|
||||
class="calendar-unknown-section anim-slide-up"
|
||||
style="--delay: 0.4s"
|
||||
class="calendar-unknown-section calendar-enter"
|
||||
style="--delay: 0.2s"
|
||||
>
|
||||
<div class="calendar-unknown-header">
|
||||
<span class="calendar-day-label">{{ getDayLabel('unknown') }}</span>
|
||||
@@ -101,12 +102,12 @@ const unknownGroups = computed({
|
||||
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.calendar-unknown-section {
|
||||
background: var(--color-surface-hover);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 12px;
|
||||
@@ -148,26 +149,4 @@ const unknownGroups = computed({
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: rotate(2deg);
|
||||
}
|
||||
|
||||
.anim-slide-up {
|
||||
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.anim-slide-up {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { ImageOff, Pin } from '@lucide/vue';
|
||||
import { ImageOff, Pin, X } from '@lucide/vue';
|
||||
import type { BangumiGroup } from './types';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -47,6 +47,9 @@ const posterSrc = computed(() =>
|
||||
</span>
|
||||
</div>
|
||||
<div class="calendar-card-overlay">
|
||||
<div class="calendar-card-overlay-title">
|
||||
{{ group.primary.official_title }}
|
||||
</div>
|
||||
<div class="calendar-card-overlay-tags">
|
||||
<ab-tag :title="`S${group.primary.season}`" type="info" />
|
||||
<ab-tag
|
||||
@@ -55,9 +58,6 @@ const posterSrc = computed(() =>
|
||||
type="info"
|
||||
/>
|
||||
</div>
|
||||
<div class="calendar-card-overlay-title">
|
||||
{{ group.primary.official_title }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Pin indicator for manually assigned -->
|
||||
<div
|
||||
@@ -68,18 +68,22 @@ const posterSrc = computed(() =>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Unpin button -->
|
||||
<button
|
||||
<!-- Unpin button:盖在置顶标记上,悬停 / 键盘聚焦时出现 -->
|
||||
<ab-icon-button
|
||||
v-if="showUnpin && group.primary.weekday_locked"
|
||||
class="calendar-unpin-btn"
|
||||
:title="$t('calendar.unpin')"
|
||||
size="sm"
|
||||
round
|
||||
:label="$t('calendar.unpin')"
|
||||
@click="emit('unpin', $event)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div v-if="group.rules.length > 1" class="group-badge">
|
||||
{{ group.rules.length }}
|
||||
</div>
|
||||
<X :size="12" />
|
||||
</ab-icon-button>
|
||||
<ab-badge
|
||||
v-if="group.rules.length > 1"
|
||||
class="group-badge"
|
||||
:count="group.rules.length"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -91,34 +95,24 @@ const posterSrc = computed(() =>
|
||||
|
||||
.group-badge {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
border-radius: 10px;
|
||||
background: var(--color-badge-bg);
|
||||
color: var(--color-white);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
z-index: var(--z-dropdown);
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2px 4px var(--color-badge-shadow);
|
||||
}
|
||||
|
||||
.calendar-card {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: transform var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@@ -150,7 +144,9 @@ const posterSrc = computed(() =>
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
background: var(--color-surface-hover);
|
||||
border-radius: inherit;
|
||||
background: var(--color-surface-2);
|
||||
box-shadow: inset 0 0 0 1px var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
transition: background-color var(--transition-normal);
|
||||
}
|
||||
@@ -163,29 +159,35 @@ const posterSrc = computed(() =>
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
}
|
||||
|
||||
// 标题与标签都放在底部,顶部两角留给置顶标记与组数徽标
|
||||
.calendar-card-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding: 5px;
|
||||
opacity: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(2px);
|
||||
transition: opacity var(--transition-normal);
|
||||
|
||||
.calendar-card:hover & {
|
||||
opacity: 1;
|
||||
@media (hover: hover) {
|
||||
.calendar-card:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-card-overlay-title {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
padding: 0 1px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
@@ -196,10 +198,6 @@ const posterSrc = computed(() =>
|
||||
}
|
||||
|
||||
.calendar-card-overlay-tags {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
@@ -222,7 +220,7 @@ const posterSrc = computed(() =>
|
||||
.calendar-card-pin {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
left: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
@@ -235,35 +233,37 @@ const posterSrc = computed(() =>
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
// Unpin button
|
||||
// Unpin button:与置顶标记同心,悬停 / 聚焦时盖住它
|
||||
.calendar-unpin-btn {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: -6px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-danger, #e74c3c);
|
||||
color: #fff;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
z-index: var(--z-dropdown);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-sm);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
// 不可见时不可点:触屏没有 hover,点卡片左上角不能静默取消置顶
|
||||
pointer-events: none;
|
||||
transition: opacity var(--transition-fast),
|
||||
background-color var(--transition-fast);
|
||||
|
||||
.calendar-card-wrapper:hover & {
|
||||
// 只认键盘焦点:触摸点开卡片后焦点留在卡片上,不应让按钮常显
|
||||
.calendar-card-wrapper:has(:focus-visible) & {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.calendar-card-wrapper:hover & {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pinned card subtle styling
|
||||
// Pinned card subtle styling:用 outline,悬停阴影不会盖掉它
|
||||
.calendar-card--pinned {
|
||||
box-shadow: 0 0 0 1.5px var(--color-primary);
|
||||
border-radius: var(--radius-md);
|
||||
outline: 1.5px solid var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,6 @@ const props = defineProps<{
|
||||
isToday: boolean;
|
||||
isDragging: boolean;
|
||||
groups: BangumiGroup[];
|
||||
delay: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -19,20 +18,16 @@ const emit = defineEmits<{
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="calendar-column anim-slide-up"
|
||||
class="calendar-column"
|
||||
:class="{
|
||||
'calendar-column--today': isToday,
|
||||
'calendar-column--drop-active': isDragging,
|
||||
}"
|
||||
:style="{ '--delay': delay }"
|
||||
>
|
||||
<!-- Day header -->
|
||||
<div
|
||||
class="calendar-day-header"
|
||||
:class="{ 'calendar-day-header--today': isToday }"
|
||||
>
|
||||
<div class="calendar-day-header">
|
||||
<span class="calendar-day-label">{{ dayLabel }}</span>
|
||||
<span v-if="isToday" class="calendar-today-badge">{{
|
||||
<span v-if="isToday" class="calendar-today-mark">{{
|
||||
$t('calendar.today')
|
||||
}}</span>
|
||||
</div>
|
||||
@@ -79,8 +74,7 @@ const emit = defineEmits<{
|
||||
border-color var(--transition-normal);
|
||||
|
||||
&--today {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 1px var(--color-primary-light);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
&--drop-active {
|
||||
@@ -95,32 +89,44 @@ const emit = defineEmits<{
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&--today {
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.calendar-day-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
transition: color var(--transition-normal);
|
||||
|
||||
.calendar-day-header--today & {
|
||||
color: var(--color-primary);
|
||||
.calendar-column--today & {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-today-badge {
|
||||
// 今天:accent 方块标记 + 墨色文字(DESIGN:accent 只用于日历的今天)
|
||||
.calendar-today-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
|
||||
// 窄列(平板看板)只留方块;文字仍在 DOM 中供读屏
|
||||
@container (max-width: 80px) {
|
||||
font-size: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-column-items {
|
||||
@@ -148,26 +154,4 @@ const emit = defineEmits<{
|
||||
padding: 12px 4px;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.anim-slide-up {
|
||||
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.anim-slide-up {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,6 @@ const props = defineProps<{
|
||||
groupedByDay: Record<string, BangumiGroup[]>;
|
||||
todayIndex: number;
|
||||
getDayLabel: (key: string) => string;
|
||||
hasBangumi: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -28,8 +27,8 @@ function posterSrc(link: string | null | undefined): string {
|
||||
<template v-for="(key, index) in dayKeys" :key="key">
|
||||
<div
|
||||
v-if="groupedByDay[key].length > 0"
|
||||
class="calendar-section anim-slide-up"
|
||||
:style="{ '--delay': `${index * 0.05}s` }"
|
||||
class="calendar-section calendar-enter"
|
||||
:style="{ '--delay': `${index * 0.03}s` }"
|
||||
>
|
||||
<!-- Day divider -->
|
||||
<div
|
||||
@@ -42,7 +41,7 @@ function posterSrc(link: string | null | undefined): string {
|
||||
<span class="calendar-section-label">{{ getDayLabel(key) }}</span>
|
||||
<span
|
||||
v-if="key !== 'unknown' && isToday(index)"
|
||||
class="calendar-today-badge calendar-today-badge--small"
|
||||
class="calendar-today-mark"
|
||||
>
|
||||
{{ $t('calendar.today') }}
|
||||
</span>
|
||||
@@ -76,9 +75,11 @@ function posterSrc(link: string | null | undefined): string {
|
||||
<div class="calendar-row-info">
|
||||
<div class="calendar-row-title">
|
||||
<span class="calendar-row-title-text">{{ group.primary.official_title }}</span>
|
||||
<span v-if="group.rules.length > 1" class="calendar-row-badge">
|
||||
{{ group.rules.length }}
|
||||
</span>
|
||||
<ab-badge
|
||||
v-if="group.rules.length > 1"
|
||||
class="calendar-row-badge"
|
||||
:count="group.rules.length"
|
||||
/>
|
||||
</div>
|
||||
<div class="calendar-row-meta">
|
||||
<ab-tag :title="`S${group.primary.season}`" type="info" />
|
||||
@@ -93,14 +94,6 @@ function posterSrc(link: string | null | undefined): string {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- All days empty on mobile -->
|
||||
<div
|
||||
v-if="!hasBangumi"
|
||||
class="calendar-empty-day calendar-empty-day--mobile"
|
||||
>
|
||||
{{ $t('calendar.no_data') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -126,33 +119,37 @@ function posterSrc(link: string | null | undefined): string {
|
||||
transition: border-color var(--transition-normal);
|
||||
|
||||
&--today {
|
||||
border-bottom-color: var(--color-primary);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-section-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-text-secondary);
|
||||
letter-spacing: 0.3px;
|
||||
transition: color var(--transition-normal);
|
||||
|
||||
.calendar-section-header--today & {
|
||||
color: var(--color-primary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-today-badge {
|
||||
// 今天:accent 方块标记 + 墨色文字(DESIGN:accent 只用于日历的今天)
|
||||
.calendar-today-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-text);
|
||||
|
||||
&--small {
|
||||
font-size: 10px;
|
||||
padding: 0 5px;
|
||||
&::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,13 +168,19 @@ function posterSrc(link: string | null | undefined): string {
|
||||
user-select: none;
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-hover);
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,53 +240,6 @@ function posterSrc(link: string | null | undefined): string {
|
||||
|
||||
.calendar-row-badge {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
margin-left: 6px;
|
||||
border-radius: 9px;
|
||||
background: var(--color-badge-bg);
|
||||
color: var(--color-white);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.calendar-empty-day {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
padding: 12px 4px;
|
||||
transition: color var(--transition-normal);
|
||||
|
||||
&--mobile {
|
||||
padding: 32px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.anim-slide-up {
|
||||
animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.anim-slide-up {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { ChevronRight } from '@lucide/vue';
|
||||
import type { BangumiGroup } from './types';
|
||||
import type { BangumiRule } from '#/bangumi';
|
||||
|
||||
@@ -45,7 +46,7 @@ const show = defineModel<boolean>('show', { default: false });
|
||||
v-if="rule.filter && rule.filter.length > 0"
|
||||
class="rule-list-item-filter"
|
||||
>
|
||||
<span class="rule-list-item-filter-label">{{ $t('homepage.rule.filter') }}:</span>
|
||||
<span class="rule-list-item-filter-label">{{ $t('homepage.rule.filter') }}: </span>
|
||||
<span class="rule-list-item-filter-value">{{
|
||||
rule.filter.join(', ')
|
||||
}}</span>
|
||||
@@ -54,9 +55,7 @@ const show = defineModel<boolean>('show', { default: false });
|
||||
{{ rule.title_raw }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rule-list-item-arrow">
|
||||
›
|
||||
</div>
|
||||
<ChevronRight :size="16" class="rule-list-item-arrow" />
|
||||
</div>
|
||||
</div>
|
||||
</ab-modal>
|
||||
@@ -67,31 +66,38 @@ const show = defineModel<boolean>('show', { default: false });
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.rule-list-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
padding: 4px 12px 8px;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0 0 8px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rule-list-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: var(--touch-target);
|
||||
padding: 12px;
|
||||
// 行文字与弹窗标题对齐,悬停底色向两侧延伸到 body 内边距里
|
||||
margin-inline: -12px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-hover);
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@@ -117,13 +123,13 @@ const show = defineModel<boolean>('show', { default: false });
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rule-list-item-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rule-list-item-filter {
|
||||
@@ -154,9 +160,7 @@ const show = defineModel<boolean>('show', { default: false });
|
||||
}
|
||||
|
||||
.rule-list-item-arrow {
|
||||
font-size: 18px;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { NPopover } from 'naive-ui';
|
||||
import { createReusableTemplate } from '@vueuse/core';
|
||||
import { Bell, CircleAlert, CircleX, Info, Trash } from '@lucide/vue';
|
||||
import { Bell, Trash } from '@lucide/vue';
|
||||
import type { InboxMessage } from '@/api/notification';
|
||||
import { relativeTime } from '@/utils/relative-time';
|
||||
|
||||
@@ -26,12 +26,6 @@ const KIND_ROUTES: Record<string, string> = {
|
||||
rename_skipped: '/downloader',
|
||||
};
|
||||
|
||||
const SEVERITY_ICONS = {
|
||||
error: CircleX,
|
||||
warning: CircleAlert,
|
||||
info: Info,
|
||||
} as const;
|
||||
|
||||
const confirmClear = ref(false);
|
||||
const bell = useTemplateRef<HTMLButtonElement>('bell');
|
||||
|
||||
@@ -56,10 +50,6 @@ function closeByEsc() {
|
||||
bell.value?.focus();
|
||||
}
|
||||
|
||||
function severityIcon(msg: InboxMessage) {
|
||||
return SEVERITY_ICONS[msg.severity] ?? Info;
|
||||
}
|
||||
|
||||
function onItemClick(msg: InboxMessage) {
|
||||
store.markRead(msg.id);
|
||||
const route = KIND_ROUTES[msg.kind];
|
||||
@@ -85,9 +75,11 @@ function onClearClick() {
|
||||
<div class="notification-head">
|
||||
<span class="notification-head-title">
|
||||
{{ $t('notifications.title') }}
|
||||
<template v-if="unreadCount > 0">
|
||||
· {{ $t('notifications.unread', { n: unreadCount }) }}
|
||||
</template>
|
||||
<span
|
||||
v-if="unreadCount > 0"
|
||||
class="notification-head-unread"
|
||||
:title="$t('notifications.unread', { n: unreadCount })"
|
||||
>{{ unreadCount }}</span>
|
||||
</span>
|
||||
<div class="notification-head-actions">
|
||||
<ab-button
|
||||
@@ -128,9 +120,7 @@ function onClearClick() {
|
||||
@click="onItemClick(msg)"
|
||||
@keydown.enter="onItemClick(msg)"
|
||||
>
|
||||
<div class="notification-sev" :class="`is-${msg.severity}`">
|
||||
<Component :is="severityIcon(msg)" :size="14" />
|
||||
</div>
|
||||
<span class="notification-sev" :class="`is-${msg.severity}`" />
|
||||
<div class="notification-content">
|
||||
<div class="notification-title">
|
||||
{{ store.titleOf(msg) }}
|
||||
@@ -211,7 +201,8 @@ function onClearClick() {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--color-text-secondary);
|
||||
transition: color var(--transition-fast), transform var(--transition-fast);
|
||||
transition: color var(--transition-fast),
|
||||
background-color var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -229,13 +220,10 @@ function onClearClick() {
|
||||
min-height: var(--touch-target);
|
||||
}
|
||||
|
||||
// 与 ab-icon-button ghost 一致:状态只靠底色与墨色表达
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@@ -295,13 +283,26 @@ function onClearClick() {
|
||||
}
|
||||
|
||||
.notification-head-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.notification-head-unread {
|
||||
margin-left: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.notification-head-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@@ -351,29 +352,21 @@ function onClearClick() {
|
||||
}
|
||||
}
|
||||
|
||||
// 严重度 = 语义色方块标记(与 ab-tag/ab-status 一致),对齐标题首行中线
|
||||
.notification-sev {
|
||||
flex-shrink: 0;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
margin-top: 2px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 6px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-primary);
|
||||
|
||||
&.is-error {
|
||||
color: var(--color-danger, #ef4444);
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
&.is-warning {
|
||||
color: var(--color-warning, #f59e0b);
|
||||
background: color-mix(in srgb, var(--color-warning) 16%, transparent);
|
||||
}
|
||||
|
||||
&.is-info {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
background: var(--color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,12 +385,11 @@ function onClearClick() {
|
||||
}
|
||||
|
||||
.notification-count {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
border-radius: 8px;
|
||||
padding: 0 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.notification-body {
|
||||
|
||||
@@ -119,7 +119,6 @@ function Exit() {
|
||||
[
|
||||
h(LogOut, { size: 20 }),
|
||||
!isMobileOrTablet.value &&
|
||||
show.value &&
|
||||
h('div', { class: 'sidebar-item-label' }, t('sidebar.logout')),
|
||||
]
|
||||
);
|
||||
@@ -140,7 +139,7 @@ function Exit() {
|
||||
:aria-expanded="show"
|
||||
@click="toggle"
|
||||
>
|
||||
<div v-show="show" class="sidebar-title">
|
||||
<div class="sidebar-title">
|
||||
{{ $t('sidebar.title') }}
|
||||
</div>
|
||||
<PanelLeftOpen
|
||||
@@ -165,7 +164,7 @@ function Exit() {
|
||||
]"
|
||||
>
|
||||
<Component :is="i.icon" :size="20" />
|
||||
<div v-show="show" class="sidebar-item-label">
|
||||
<div class="sidebar-item-label">
|
||||
{{ i.label() }}
|
||||
</div>
|
||||
</RouterLink>
|
||||
@@ -175,16 +174,13 @@ function Exit() {
|
||||
<div class="sidebar-footer">
|
||||
<button
|
||||
class="sidebar-item sidebar-item--action sidebar-item--theme"
|
||||
:title="isDark ? 'Light mode' : 'Dark mode'"
|
||||
:aria-label="
|
||||
isDark ? 'Switch to light mode' : 'Switch to dark mode'
|
||||
"
|
||||
:title="isDark ? $t('theme.light') : $t('theme.dark')"
|
||||
@click="toggleDark"
|
||||
>
|
||||
<Moon v-if="!isDark" :size="20" />
|
||||
<Sun v-else :size="20" />
|
||||
<div v-show="show" class="sidebar-item-label">
|
||||
{{ isDark ? 'Light' : 'Dark' }}
|
||||
<div class="sidebar-item-label">
|
||||
{{ isDark ? $t('theme.light') : $t('theme.dark') }}
|
||||
</div>
|
||||
</button>
|
||||
<Exit />
|
||||
@@ -216,7 +212,8 @@ function Exit() {
|
||||
<div class="sidebar-footer">
|
||||
<button
|
||||
class="sidebar-item sidebar-item--action sidebar-item--theme"
|
||||
:title="isDark ? 'Light mode' : 'Dark mode'"
|
||||
:title="isDark ? $t('theme.light') : $t('theme.dark')"
|
||||
:aria-label="isDark ? $t('theme.light') : $t('theme.dark')"
|
||||
@click="toggleDark"
|
||||
>
|
||||
<Moon v-if="!isDark" :size="20" />
|
||||
@@ -288,7 +285,24 @@ function Exit() {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
// 标签始终渲染:收起时先快速淡出,展开时等宽度过半再淡入,避免过渡中露出半截文字。
|
||||
// 用 :deep 是因为 Exit 是渲染函数,其子元素不带本组件的 scope 属性。
|
||||
.sidebar {
|
||||
:deep(.sidebar-title),
|
||||
:deep(.sidebar-item-label) {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
transition: opacity var(--transition-fast) 100ms;
|
||||
}
|
||||
|
||||
&--collapsed :deep(.sidebar-title),
|
||||
&--collapsed :deep(.sidebar-item-label) {
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-toggle-icon {
|
||||
@@ -329,9 +343,10 @@ function Exit() {
|
||||
background-color var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
|
||||
// 悬停用中性色,紫色只留给当前页
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@@ -339,7 +354,8 @@ function Exit() {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&--active {
|
||||
&--active,
|
||||
&--active:hover {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
font-weight: 500;
|
||||
@@ -363,13 +379,20 @@ function Exit() {
|
||||
}
|
||||
|
||||
&--theme:hover {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-item-label {
|
||||
font-size: 14px;
|
||||
.sidebar-item {
|
||||
// 标签收窄时图标不被压缩
|
||||
:deep(svg) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.sidebar-item-label) {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
|
||||
@@ -146,9 +146,11 @@ function permissionLabel(id: string) {
|
||||
}
|
||||
|
||||
.confirm__title {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 650;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.confirm__version {
|
||||
@@ -161,10 +163,15 @@ function permissionLabel(id: string) {
|
||||
.confirm__kv {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(64px, max-content) 1fr;
|
||||
align-items: baseline;
|
||||
gap: 8px 14px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
|
||||
@include forMobile {
|
||||
grid-template-columns: minmax(64px, 7.5em) 1fr;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
@@ -244,5 +251,12 @@ function permissionLabel(id: string) {
|
||||
margin-top: auto;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
// 手机:与详情页的整宽主按钮衔接,两个按钮平分整行
|
||||
@media screen and (max-width: 639px) {
|
||||
> * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -98,8 +98,8 @@ watch(
|
||||
<ab-skeleton v-if="loading" preset="lines" :count="2" />
|
||||
<ab-alert v-if="failed" type="danger" :title="$t('plugin.load_failed')">
|
||||
<span class="plugin-slot__id">{{ ui.plugin_id }}</span>
|
||||
<span v-if="reason" class="plugin-slot__id">{{ reason }}</span>
|
||||
{{ $t('plugin.load_failed_hint') }}
|
||||
<span v-if="reason" class="plugin-slot__reason">{{ reason }}</span>
|
||||
<template #action>
|
||||
<ab-button size="sm" @click="reload">
|
||||
{{
|
||||
@@ -120,4 +120,14 @@ watch(
|
||||
.plugin-slot__id {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
// 原因(多为 URL 或异常信息)单独一行,长串可在任意位置换行
|
||||
.plugin-slot__reason {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,9 +5,8 @@ import {
|
||||
CircleCheck,
|
||||
Copy,
|
||||
ImageOff,
|
||||
X,
|
||||
} from '@lucide/vue';
|
||||
import { NDynamicTags, NSpin, useMessage } from 'naive-ui';
|
||||
import { NCollapseTransition, NDynamicTags, useMessage } from 'naive-ui';
|
||||
import type {
|
||||
BangumiRule,
|
||||
DetectOffsetResponse,
|
||||
@@ -17,6 +16,8 @@ import type {
|
||||
|
||||
const props = defineProps<{
|
||||
bangumi: BangumiRule;
|
||||
/** 由父组件控制显隐,关闭时保持挂载,让 ab-modal 的离场过渡跑完 */
|
||||
show: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -32,17 +33,6 @@ const localBangumi = ref<BangumiRule>(
|
||||
JSON.parse(JSON.stringify(props.bangumi))
|
||||
);
|
||||
|
||||
// Sync when props change
|
||||
watch(
|
||||
() => props.bangumi,
|
||||
(newVal) => {
|
||||
localBangumi.value = JSON.parse(JSON.stringify(newVal));
|
||||
// Re-detect offset when bangumi changes
|
||||
detectOffsetMismatch();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const posterSrc = computed(() =>
|
||||
resolvePosterUrl(localBangumi.value.poster_link)
|
||||
);
|
||||
@@ -54,16 +44,32 @@ const showOffsetDialog = ref(false);
|
||||
const offsetSuggestion = ref<OffsetSuggestionDetail | null>(null);
|
||||
const tmdbInfo = ref<TMDBSummary | null>(null);
|
||||
|
||||
// 每次打开时从 prop 重新拷贝并检测偏移(组件在关闭后保持挂载)
|
||||
watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (!open) {
|
||||
showOffsetDialog.value = false;
|
||||
return;
|
||||
}
|
||||
localBangumi.value = JSON.parse(JSON.stringify(props.bangumi));
|
||||
detectOffsetMismatch();
|
||||
}
|
||||
);
|
||||
|
||||
// Detect offset mismatch on mount
|
||||
async function detectOffsetMismatch() {
|
||||
if (!localBangumi.value.official_title || !localBangumi.value.season) return;
|
||||
|
||||
// 组件关闭后保持挂载:晚到的结果只在同一次打开里有效
|
||||
const target = localBangumi.value;
|
||||
try {
|
||||
const result: DetectOffsetResponse = await apiBangumi.detectOffset({
|
||||
title: localBangumi.value.official_title,
|
||||
parsed_season: localBangumi.value.season,
|
||||
title: target.official_title,
|
||||
parsed_season: target.season,
|
||||
parsed_episode: 1, // Use episode 1 as baseline for detection
|
||||
});
|
||||
if (!props.show || localBangumi.value !== target) return;
|
||||
|
||||
if (result.has_mismatch && result.suggestion) {
|
||||
offsetSuggestion.value = result.suggestion;
|
||||
@@ -97,29 +103,34 @@ function handleOffsetCancel() {
|
||||
|
||||
// Run detection on mount
|
||||
onMounted(() => {
|
||||
detectOffsetMismatch();
|
||||
if (props.show) detectOffsetMismatch();
|
||||
});
|
||||
|
||||
// Info tags for display (just values, no labels)
|
||||
// 标记色与搜索结果一致:组=info、分辨率=success、字幕=warning、季=neutral
|
||||
const infoTags = computed(() => {
|
||||
const tags: { value: string; type: string }[] = [];
|
||||
const tags: {
|
||||
value: string;
|
||||
type: string;
|
||||
tagType: 'info' | 'success' | 'warning' | 'neutral';
|
||||
}[] = [];
|
||||
const { season, season_raw, dpi, subtitle, group_name } = localBangumi.value;
|
||||
|
||||
if (season || season_raw) {
|
||||
const seasonDisplay = season_raw || (season ? `第${season}季` : '');
|
||||
tags.push({ value: seasonDisplay, type: 'season' });
|
||||
tags.push({ value: seasonDisplay, type: 'season', tagType: 'neutral' });
|
||||
}
|
||||
|
||||
if (dpi) {
|
||||
tags.push({ value: dpi, type: 'resolution' });
|
||||
tags.push({ value: dpi, type: 'resolution', tagType: 'success' });
|
||||
}
|
||||
|
||||
if (subtitle) {
|
||||
tags.push({ value: subtitle, type: 'subtitle' });
|
||||
tags.push({ value: subtitle, type: 'subtitle', tagType: 'warning' });
|
||||
}
|
||||
|
||||
if (group_name) {
|
||||
tags.push({ value: group_name, type: 'group' });
|
||||
tags.push({ value: group_name, type: 'group', tagType: 'info' });
|
||||
}
|
||||
|
||||
return tags;
|
||||
@@ -156,247 +167,143 @@ function handleConfirm() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="confirm-backdrop" @click.self="emit('cancel')">
|
||||
<div class="confirm-modal" role="dialog" aria-modal="true">
|
||||
<!-- Header -->
|
||||
<header class="confirm-header">
|
||||
<h2 class="confirm-title">
|
||||
{{ $t('search.confirm.title') }}
|
||||
</h2>
|
||||
<ab-icon-button
|
||||
class="close-btn"
|
||||
:label="$t('common.close')"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
<X :size="18" />
|
||||
</ab-icon-button>
|
||||
</header>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="confirm-content">
|
||||
<!-- Bangumi Info -->
|
||||
<div class="bangumi-info">
|
||||
<div class="bangumi-poster">
|
||||
<template v-if="localBangumi.poster_link">
|
||||
<img :src="posterSrc" :alt="localBangumi.official_title">
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="poster-placeholder">
|
||||
<ImageOff :size="32" />
|
||||
</div>
|
||||
</template>
|
||||
<ab-modal
|
||||
:show="show"
|
||||
size="md"
|
||||
:title="$t('search.confirm.title')"
|
||||
@update:show="(value: boolean) => value || emit('cancel')"
|
||||
>
|
||||
<!-- Bangumi Info -->
|
||||
<div class="bangumi-info">
|
||||
<div class="bangumi-poster">
|
||||
<template v-if="localBangumi.poster_link">
|
||||
<img :src="posterSrc" :alt="localBangumi.official_title">
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="poster-placeholder">
|
||||
<ImageOff :size="32" />
|
||||
</div>
|
||||
<div class="bangumi-meta">
|
||||
<h3 class="bangumi-title">
|
||||
{{ localBangumi.official_title }}
|
||||
</h3>
|
||||
<p v-if="localBangumi.title_raw" class="bangumi-subtitle">
|
||||
{{ localBangumi.title_raw }}
|
||||
</p>
|
||||
<p v-if="localBangumi.year" class="bangumi-year">
|
||||
{{ localBangumi.year }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Tags -->
|
||||
<div v-if="infoTags.length > 0" class="info-tags">
|
||||
<div
|
||||
v-for="tag in infoTags"
|
||||
:key="tag.type"
|
||||
class="info-tag"
|
||||
:class="`info-tag--${tag.type}`"
|
||||
>
|
||||
{{ tag.value }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RSS Link -->
|
||||
<div class="rss-section">
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ $t('search.confirm.rss') }}:</span>
|
||||
<span class="info-value info-value--link">
|
||||
{{ localBangumi.rss_link?.[0] || '-' }}
|
||||
</span>
|
||||
<button
|
||||
class="copy-btn"
|
||||
:class="{ copied }"
|
||||
:aria-label="$t('search.confirm.copy_rss')"
|
||||
@click="copyRssLink"
|
||||
>
|
||||
<CircleCheck v-if="copied" :size="14" />
|
||||
<Copy v-else :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced settings -->
|
||||
<div class="advanced-section">
|
||||
<button class="advanced-toggle" @click="showAdvanced = !showAdvanced">
|
||||
<component
|
||||
:is="showAdvanced ? ChevronDown : ChevronRight"
|
||||
:size="14"
|
||||
/>
|
||||
{{ $t('search.confirm.advanced') }}
|
||||
</button>
|
||||
|
||||
<transition name="expand">
|
||||
<div v-show="showAdvanced" class="advanced-content">
|
||||
<!-- Filter rules row -->
|
||||
<div class="advanced-row advanced-row--tags">
|
||||
<label class="advanced-label">{{
|
||||
$t('search.confirm.filter')
|
||||
}}</label>
|
||||
<div class="advanced-control filter-tags">
|
||||
<NDynamicTags
|
||||
v-model:value="localBangumi.filter"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Season Offset row -->
|
||||
<div class="advanced-row">
|
||||
<label class="advanced-label">{{
|
||||
$t('homepage.rule.season_offset')
|
||||
}}</label>
|
||||
<div class="advanced-control offset-controls">
|
||||
<ab-input
|
||||
v-model="localBangumi.season_offset"
|
||||
type="number"
|
||||
class="offset-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Episode Offset row -->
|
||||
<div class="advanced-row">
|
||||
<label class="advanced-label">{{
|
||||
$t('homepage.rule.episode_offset')
|
||||
}}</label>
|
||||
<div class="advanced-control offset-controls">
|
||||
<ab-input
|
||||
v-model="localBangumi.episode_offset"
|
||||
type="number"
|
||||
class="offset-input"
|
||||
/>
|
||||
<button
|
||||
class="detect-btn"
|
||||
:disabled="offsetLoading"
|
||||
@click="autoDetectOffset"
|
||||
>
|
||||
<NSpin v-if="offsetLoading" :size="14" />
|
||||
<span v-else>{{ $t('homepage.rule.auto_detect') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="bangumi-meta">
|
||||
<h3 class="bangumi-title">
|
||||
{{ localBangumi.official_title }}
|
||||
</h3>
|
||||
<p v-if="localBangumi.title_raw" class="bangumi-subtitle">
|
||||
{{ localBangumi.title_raw }}
|
||||
</p>
|
||||
<p v-if="localBangumi.year" class="bangumi-year">
|
||||
{{ localBangumi.year }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="confirm-footer">
|
||||
<ab-button @click="emit('cancel')">
|
||||
{{ $t('common.cancel') }}
|
||||
</ab-button>
|
||||
<ab-button variant="primary" @click="handleConfirm">
|
||||
{{ $t('search.confirm.subscribe') }}
|
||||
</ab-button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Offset Mismatch Dialog -->
|
||||
<ab-offset-mismatch-dialog
|
||||
v-model:show="showOffsetDialog"
|
||||
:bangumi-title="localBangumi.official_title"
|
||||
:parsed-season="localBangumi.season"
|
||||
:parsed-episode="1"
|
||||
:tmdb-info="tmdbInfo"
|
||||
:suggestion="offsetSuggestion"
|
||||
@apply="handleOffsetApply"
|
||||
@keep="handleOffsetKeep"
|
||||
@cancel="handleOffsetCancel"
|
||||
/>
|
||||
</div>
|
||||
<!-- Info Tags -->
|
||||
<div v-if="infoTags.length > 0" class="info-tags">
|
||||
<ab-tag v-for="tag in infoTags" :key="tag.type" :type="tag.tagType">
|
||||
{{ tag.value }}
|
||||
</ab-tag>
|
||||
</div>
|
||||
|
||||
<!-- RSS Link -->
|
||||
<div class="rss-section">
|
||||
<div class="info-row">
|
||||
<span class="info-label">{{ $t('search.confirm.rss') }}:</span>
|
||||
<span class="info-value info-value--link">
|
||||
{{ localBangumi.rss_link?.[0] || '-' }}
|
||||
</span>
|
||||
<button
|
||||
class="copy-btn"
|
||||
:class="{ copied }"
|
||||
:aria-label="$t('search.confirm.copy_rss')"
|
||||
@click="copyRssLink"
|
||||
>
|
||||
<CircleCheck v-if="copied" :size="14" />
|
||||
<Copy v-else :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced settings -->
|
||||
<div class="advanced-section">
|
||||
<button
|
||||
class="advanced-toggle"
|
||||
:aria-expanded="showAdvanced"
|
||||
@click="showAdvanced = !showAdvanced"
|
||||
>
|
||||
<component
|
||||
:is="showAdvanced ? ChevronDown : ChevronRight"
|
||||
:size="14"
|
||||
/>
|
||||
{{ $t('search.confirm.advanced') }}
|
||||
</button>
|
||||
|
||||
<!-- 与 ab-fold-panel 同一高度过渡 -->
|
||||
<NCollapseTransition :show="showAdvanced">
|
||||
<!-- 两列网格:标签一列,控件统一左对齐在第二列 -->
|
||||
<div class="advanced-content">
|
||||
<label class="advanced-label">{{ $t('search.confirm.filter') }}</label>
|
||||
<div class="advanced-control filter-tags">
|
||||
<NDynamicTags v-model:value="localBangumi.filter" size="small" />
|
||||
</div>
|
||||
|
||||
<label class="advanced-label">{{
|
||||
$t('homepage.rule.season_offset')
|
||||
}}</label>
|
||||
<div class="advanced-control">
|
||||
<ab-input
|
||||
v-model="localBangumi.season_offset"
|
||||
type="number"
|
||||
class="offset-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="advanced-label">{{
|
||||
$t('homepage.rule.episode_offset')
|
||||
}}</label>
|
||||
<div class="advanced-control">
|
||||
<ab-input
|
||||
v-model="localBangumi.episode_offset"
|
||||
type="number"
|
||||
class="offset-input"
|
||||
/>
|
||||
<ab-button
|
||||
size="sm"
|
||||
:loading="offsetLoading"
|
||||
@click="autoDetectOffset"
|
||||
>
|
||||
{{ $t('homepage.rule.auto_detect') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</NCollapseTransition>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<ab-button @click="emit('cancel')">
|
||||
{{ $t('common.cancel') }}
|
||||
</ab-button>
|
||||
<ab-button variant="primary" @click="handleConfirm">
|
||||
{{ $t('search.confirm.subscribe') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-modal>
|
||||
|
||||
<!-- Offset Mismatch Dialog -->
|
||||
<ab-offset-mismatch-dialog
|
||||
v-model:show="showOffsetDialog"
|
||||
:bangumi-title="localBangumi.official_title"
|
||||
:parsed-season="localBangumi.season"
|
||||
:parsed-episode="1"
|
||||
:tmdb-info="tmdbInfo"
|
||||
:suggestion="offsetSuggestion"
|
||||
@apply="handleOffsetApply"
|
||||
@keep="handleOffsetKeep"
|
||||
@cancel="handleOffsetCancel"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.confirm-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-overlay);
|
||||
z-index: calc(var(--z-modal) + 10);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.confirm-modal {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
animation: modal-in 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes modal-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.confirm-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
// Bangumi info section
|
||||
.bangumi-info {
|
||||
display: flex;
|
||||
@@ -466,35 +373,6 @@ function handleConfirm() {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
&--season {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
&--resolution {
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
&--subtitle {
|
||||
background: color-mix(in srgb, var(--color-success) 12%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
&--group {
|
||||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
// RSS section
|
||||
.rss-section {
|
||||
display: flex;
|
||||
@@ -586,46 +464,37 @@ function handleConfirm() {
|
||||
}
|
||||
|
||||
.advanced-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 12px 16px;
|
||||
padding: 16px;
|
||||
background: var(--color-surface-hover);
|
||||
// surface 底加发丝边框:内部 surface-2 填充的输入框和按钮才有可见边界
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.advanced-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 32px;
|
||||
|
||||
&--tags {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.advanced-control {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
|
||||
:deep(.n-dynamic-tags) {
|
||||
justify-content: flex-end;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
|
||||
.n-tag {
|
||||
height: 28px;
|
||||
margin: 2px 0 2px 6px !important;
|
||||
margin: 2px 6px 2px 0 !important;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
@@ -634,102 +503,7 @@ function handleConfirm() {
|
||||
}
|
||||
}
|
||||
|
||||
.offset-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.offset-input {
|
||||
width: 70px;
|
||||
height: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detect-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 80px;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
}
|
||||
|
||||
// Expand transition
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: all var(--transition-normal);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
// Footer
|
||||
.confirm-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 40px;
|
||||
padding: 0 20px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&-secondary {
|
||||
background: var(--color-surface-hover);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-surface);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
&-primary {
|
||||
background: var(--color-primary);
|
||||
border: 1px solid var(--color-primary);
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-hover);
|
||||
border-color: var(--color-primary-hover);
|
||||
}
|
||||
}
|
||||
width: 80px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -44,6 +44,15 @@ const {
|
||||
|
||||
const subscribing = ref(false);
|
||||
|
||||
// 确认框关闭后保留最后一次选择,ab-modal 的离场过渡期间内容不消失
|
||||
const confirmTarget = shallowRef<BangumiRule | null>(null);
|
||||
watch(selectedResult, (value) => {
|
||||
if (value) confirmTarget.value = value;
|
||||
});
|
||||
|
||||
// 海报加载失败(404 等)时回落到占位,不显示破图图标
|
||||
const brokenPosters = ref(new Set<string>());
|
||||
|
||||
const showProvider = ref(false);
|
||||
const searchInputRef = useTemplateRef<HTMLInputElement>('searchInputRef');
|
||||
|
||||
@@ -82,8 +91,11 @@ onBeforeUnmount(() => {
|
||||
// Close on Escape (this component is always mounted, so the listener must
|
||||
// no-op while the modal itself is closed — otherwise Escape anywhere in the
|
||||
// app would toggle the modal open via the `close` -> toggleModal() handler).
|
||||
onKeyStroke('Escape', () => {
|
||||
onKeyStroke('Escape', (e) => {
|
||||
if (!showModal.value) return;
|
||||
// 确认弹窗是 ab-modal(NModal / NDrawer),它自己处理 Esc。焦点在弹窗里时
|
||||
// 这里不再处理,否则两个监听器的先后顺序会让一次 Esc 关掉整个搜索窗。
|
||||
if ((e.target as Element | null)?.closest?.('.n-modal-container, .n-drawer-container')) return;
|
||||
if (selectedResult.value) {
|
||||
clearSelectedResult();
|
||||
} else {
|
||||
@@ -856,10 +868,10 @@ function handleFilterClick(
|
||||
>
|
||||
{{ tag.value }} ×
|
||||
</button>
|
||||
<button class="clear-all-btn" @click="clearFilters">
|
||||
{{ $t('search.filter.clear') }}
|
||||
</button>
|
||||
</div>
|
||||
<button class="clear-all-btn" @click="clearFilters">
|
||||
{{ $t('search.filter.clear') }}
|
||||
</button>
|
||||
</div>
|
||||
<span class="results-count">
|
||||
<template v-if="hasActiveFilters">
|
||||
@@ -875,8 +887,16 @@ function handleFilterClick(
|
||||
|
||||
<!-- Results List -->
|
||||
<div class="results-container">
|
||||
<!-- 加载中且尚无结果:骨架屏,避免结果区塌成窄条 -->
|
||||
<div
|
||||
v-if="loading && groupedResults.length === 0"
|
||||
class="bangumi-list"
|
||||
>
|
||||
<ab-skeleton preset="card" :count="3" />
|
||||
</div>
|
||||
|
||||
<!-- Failure state (connection/server error, distinct from zero matches) -->
|
||||
<div v-if="!loading && searchFailed" class="empty-state">
|
||||
<div v-else-if="!loading && searchFailed" class="empty-state">
|
||||
<p>{{ $t('search.failed_hint') }}</p>
|
||||
</div>
|
||||
|
||||
@@ -906,9 +926,10 @@ function handleFilterClick(
|
||||
<!-- Left: Poster -->
|
||||
<div class="bangumi-poster">
|
||||
<img
|
||||
v-if="group.poster_link"
|
||||
v-if="group.poster_link && !brokenPosters.has(group.poster_link)"
|
||||
:src="getPosterUrl(group.poster_link)"
|
||||
:alt="group.official_title"
|
||||
@error="brokenPosters.add(group.poster_link)"
|
||||
>
|
||||
<div v-else class="bangumi-poster-placeholder">
|
||||
<span class="placeholder-title">{{
|
||||
@@ -926,18 +947,18 @@ function handleFilterClick(
|
||||
type="button"
|
||||
@click="handleVariantSelect(variant)"
|
||||
>
|
||||
<span class="tag tag-group">{{
|
||||
variant.group_name || 'Unknown'
|
||||
}}</span>
|
||||
<span v-if="getResolution(variant)" class="tag tag-res">
|
||||
<ab-tag type="info">
|
||||
{{ variant.group_name || 'Unknown' }}
|
||||
</ab-tag>
|
||||
<ab-tag v-if="getResolution(variant)" type="success">
|
||||
{{ getResolution(variant) }}
|
||||
</span>
|
||||
<span v-if="getSubtitle(variant)" class="tag tag-sub">
|
||||
</ab-tag>
|
||||
<ab-tag v-if="getSubtitle(variant)" type="warning">
|
||||
{{ getSubtitle(variant) }}
|
||||
</span>
|
||||
<span v-if="getSeason(variant)" class="tag tag-season">
|
||||
</ab-tag>
|
||||
<ab-tag v-if="getSeason(variant)" type="neutral">
|
||||
{{ getSeason(variant) }}
|
||||
</span>
|
||||
</ab-tag>
|
||||
</button>
|
||||
<button
|
||||
v-if="hasVariantOverflow(group)"
|
||||
@@ -959,8 +980,9 @@ function handleFilterClick(
|
||||
|
||||
<!-- Confirmation Modal -->
|
||||
<AbSearchConfirm
|
||||
v-if="selectedResult"
|
||||
:bangumi="selectedResult"
|
||||
v-if="confirmTarget"
|
||||
:show="!!selectedResult"
|
||||
:bangumi="confirmTarget"
|
||||
@confirm="handleConfirm"
|
||||
@cancel="clearSelectedResult"
|
||||
/>
|
||||
@@ -1095,6 +1117,14 @@ function handleFilterClick(
|
||||
color: var(--color-text-muted);
|
||||
transition: color var(--transition-fast);
|
||||
|
||||
// 触屏:命中区撑满输入框高度并向两侧外扩,图标位置不变
|
||||
@include forTouch {
|
||||
align-self: stretch;
|
||||
justify-content: center;
|
||||
min-width: var(--touch-target);
|
||||
margin: 0 -12px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
@@ -1128,7 +1158,7 @@ function handleFilterClick(
|
||||
padding: 0 10px;
|
||||
min-width: 70px;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
color: var(--color-white);
|
||||
border: none;
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
cursor: pointer;
|
||||
@@ -1182,7 +1212,7 @@ function handleFilterClick(
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
&.active {
|
||||
@@ -1229,97 +1259,73 @@ function handleFilterClick(
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// 分类标记色,与 ab-tag 语义一致:组=info、分辨率=success、字幕=warning、季=neutral
|
||||
.chip-group {
|
||||
--mark: var(--color-primary);
|
||||
}
|
||||
|
||||
.chip-resolution {
|
||||
--mark: var(--color-success);
|
||||
}
|
||||
|
||||
.chip-subtitle {
|
||||
--mark: var(--color-warning);
|
||||
}
|
||||
|
||||
.chip-season {
|
||||
--mark: var(--color-text-muted);
|
||||
}
|
||||
|
||||
// Soft Ink 筛选胶囊:发丝边框 + 方形分类标记 + 墨色文字;选中态用主色
|
||||
.filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid transparent;
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all var(--transition-fast);
|
||||
transition: border-color var(--transition-fast),
|
||||
background-color var(--transition-fast);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--mark);
|
||||
}
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
&.active,
|
||||
&.active:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// Group chips - Blue/Primary
|
||||
.chip-group {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
|
||||
&:hover:not(.disabled),
|
||||
&.active {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolution chips - Green
|
||||
.chip-resolution {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--color-success);
|
||||
|
||||
&:hover:not(.disabled),
|
||||
&.active {
|
||||
background: var(--color-success);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// Subtitle chips - Orange
|
||||
.chip-subtitle {
|
||||
background: rgba(249, 115, 22, 0.15);
|
||||
color: var(--color-accent);
|
||||
|
||||
&:hover:not(.disabled),
|
||||
&.active {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// Season chips - Purple
|
||||
.chip-season {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
color: rgb(139, 92, 246);
|
||||
|
||||
&:hover:not(.disabled),
|
||||
&.active {
|
||||
background: rgb(139, 92, 246);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
@include forTouch {
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1332,7 +1338,12 @@ function handleFilterClick(
|
||||
color: var(--color-text-secondary);
|
||||
background: transparent;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
@include forTouch {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
@@ -1345,23 +1356,34 @@ function handleFilterClick(
|
||||
|
||||
// Filter Summary
|
||||
.filter-summary {
|
||||
display: flex;
|
||||
// 窄屏:首行放标签和结果数,已选胶囊另起一行占满整宽
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
@include forTablet {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: contents;
|
||||
|
||||
@include forTablet {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-label {
|
||||
@@ -1371,58 +1393,56 @@ function handleFilterClick(
|
||||
}
|
||||
|
||||
.selected-chips {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.selected-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
transition: opacity var(--transition-fast);
|
||||
transition: border-color var(--transition-fast);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--mark);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&.chip-group {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
&.chip-resolution {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
&.chip-subtitle {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
&.chip-season {
|
||||
background: rgb(139, 92, 246);
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.clear-all-btn {
|
||||
padding: 4px 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
color: var(--color-text-muted);
|
||||
color: var(--color-text-secondary);
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
transition: border-color var(--transition-fast), color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-danger);
|
||||
@@ -1431,7 +1451,10 @@ function handleFilterClick(
|
||||
}
|
||||
|
||||
.results-count {
|
||||
grid-area: 1 / 2;
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -1490,11 +1513,17 @@ function handleFilterClick(
|
||||
}
|
||||
|
||||
.bangumi-poster {
|
||||
// 手机上缩小海报,给变体区留出宽度
|
||||
width: 72px;
|
||||
height: 100px;
|
||||
flex-shrink: 0;
|
||||
|
||||
// Height = 4 rows: 4 * 36px + 3 * 8px = 168px
|
||||
// Width = 168px * 5/7 = 120px
|
||||
width: 120px;
|
||||
height: 168px;
|
||||
flex-shrink: 0;
|
||||
@include forTablet {
|
||||
width: 120px;
|
||||
height: 168px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
@@ -1542,16 +1571,19 @@ function handleFilterClick(
|
||||
|
||||
.variant-chip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 6px;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 5px 6px;
|
||||
font-family: inherit;
|
||||
background: var(--color-surface-hover);
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
transition: border-color var(--transition-fast),
|
||||
background-color var(--transition-fast);
|
||||
|
||||
@include forTouch {
|
||||
min-height: 44px;
|
||||
@@ -1560,50 +1592,18 @@ function handleFilterClick(
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary);
|
||||
|
||||
.tag {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
}
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
// Display-only tags (non-clickable) - unified with filter chips
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-full);
|
||||
pointer-events: none;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.tag-group {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tag-res {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.tag-sub {
|
||||
background: rgba(249, 115, 22, 0.15);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.tag-season {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
color: rgb(139, 92, 246);
|
||||
// 胶囊整体换行,组名等标签内部不折行
|
||||
.ab-tag {
|
||||
background: var(--color-surface);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.variant-expand-btn {
|
||||
@@ -1615,7 +1615,7 @@ function handleFilterClick(
|
||||
color: var(--color-text-secondary);
|
||||
background: transparent;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('plugin-schema-form validation', () => {
|
||||
[{ url: 'x', n: 0 }, 'config.plugins_set.field_min'],
|
||||
[{ url: 'x', n: 11 }, 'config.plugins_set.field_max'],
|
||||
[{ url: 'x', n: 5 }, null],
|
||||
])('should show the error for %j', (model, error) => {
|
||||
])('should show the error for %j', async (model, error) => {
|
||||
const wrapper = mountForm(
|
||||
{
|
||||
properties: {
|
||||
@@ -123,6 +123,9 @@ describe('plugin-schema-form validation', () => {
|
||||
},
|
||||
model
|
||||
);
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false);
|
||||
for (const field of wrapper.findAll('.ab-field'))
|
||||
await field.trigger('focusout');
|
||||
const alert = wrapper.find('[role="alert"]');
|
||||
if (error) expect(alert.text()).toBe(error);
|
||||
else expect(alert.exists()).toBe(false);
|
||||
|
||||
@@ -169,6 +169,12 @@ function tokenOwner(userId: number): string {
|
||||
return ownerByUserId.value.get(userId) ?? t('access.unknown_user');
|
||||
}
|
||||
|
||||
const TOKEN_STATUS_TAG = {
|
||||
active: 'success',
|
||||
expired: 'warning',
|
||||
revoked: 'neutral',
|
||||
} as const;
|
||||
|
||||
function tokenStatus(token: ApiTokenPublic): ApiTokenStatus {
|
||||
if (token.revoked_at !== null) return 'revoked';
|
||||
if (token.expires_at !== null) {
|
||||
@@ -205,9 +211,10 @@ function tokenStatus(token: ApiTokenPublic): ApiTokenStatus {
|
||||
>
|
||||
<div class="row-main">
|
||||
<strong>{{ user.username }}</strong>
|
||||
<span class="status-dot" :class="[{ disabled: !user.enabled }]">
|
||||
{{ user.enabled ? $t('access.enabled') : $t('access.disabled') }}
|
||||
</span>
|
||||
<ab-tag
|
||||
:type="user.enabled ? 'success' : 'neutral'"
|
||||
:title="user.enabled ? $t('access.enabled') : $t('access.disabled')"
|
||||
/>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<ab-switch
|
||||
@@ -255,13 +262,13 @@ function tokenStatus(token: ApiTokenPublic): ApiTokenStatus {
|
||||
<div class="token-info">
|
||||
<div class="row-main">
|
||||
<strong>{{ token.name }}</strong>
|
||||
<span class="scope-badge">{{ token.scope.toUpperCase() }}</span>
|
||||
<span
|
||||
class="token-status"
|
||||
<ab-tag type="neutral" :title="token.scope.toUpperCase()" />
|
||||
<ab-tag
|
||||
:class="`token-status--${tokenStatus(token)}`"
|
||||
:type="TOKEN_STATUS_TAG[tokenStatus(token)]"
|
||||
>
|
||||
{{ $t(`access.token_status_${tokenStatus(token)}`) }}
|
||||
</span>
|
||||
</ab-tag>
|
||||
</div>
|
||||
<code>{{ token.prefix }}…</code>
|
||||
<small>
|
||||
@@ -427,15 +434,7 @@ function tokenStatus(token: ApiTokenPublic): ApiTokenStatus {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-dot,
|
||||
.scope-badge,
|
||||
.token-status {
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
.row-main .ab-tag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -444,21 +443,6 @@ function tokenStatus(token: ApiTokenPublic): ApiTokenStatus {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.status-dot.disabled {
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.token-status--expired {
|
||||
color: var(--color-warning);
|
||||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||||
}
|
||||
|
||||
.token-status--revoked {
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.token-info {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
|
||||
@@ -204,17 +204,24 @@ const items = computed(() =>
|
||||
</div>
|
||||
<div class="instance-add" data-new-instance>
|
||||
<ab-field
|
||||
class="instance-add-field"
|
||||
:label="$t('config.downloader_set.new_id')"
|
||||
:description="$t('config.downloader_set.new_id_hint')"
|
||||
:error="idError"
|
||||
>
|
||||
<ab-input v-model="newId" @keyup.enter="addInstance" />
|
||||
<!-- 按钮与输入框组成同一控件:始终同一行、垂直居中 -->
|
||||
<div class="instance-add-control">
|
||||
<ab-input v-model="newId" @keyup.enter="addInstance" />
|
||||
<ab-button
|
||||
size="sm"
|
||||
data-action="add"
|
||||
:disabled="!canAdd"
|
||||
@click="addInstance"
|
||||
>
|
||||
<Plus :size="16" />
|
||||
{{ $t('config.downloader_set.add') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</ab-field>
|
||||
<ab-button data-action="add" :disabled="!canAdd" @click="addInstance">
|
||||
<Plus :size="16" />
|
||||
{{ $t('config.downloader_set.add') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -284,13 +291,17 @@ const items = computed(() =>
|
||||
}
|
||||
|
||||
.instance-add {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.instance-add-field {
|
||||
flex: 1;
|
||||
.instance-add-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
:deep(.ab-input) {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts" setup>
|
||||
import { Info } from '@lucide/vue';
|
||||
import { NSelect } from 'naive-ui';
|
||||
import type { SelectGroupOption, SelectOption } from 'naive-ui';
|
||||
import type { LLMProviderView } from '@/api/llm';
|
||||
@@ -262,10 +261,7 @@ const tuningItems = computed(() => [
|
||||
<template>
|
||||
<ab-fold-panel :title="$t('config.llm_set.title')">
|
||||
<div class="llm-section">
|
||||
<div class="llm-hint">
|
||||
<Info :size="16" />
|
||||
<span>{{ $t('config.llm_set.hint') }}</span>
|
||||
</div>
|
||||
<ab-alert>{{ $t('config.llm_set.hint') }}</ab-alert>
|
||||
|
||||
<ab-setting
|
||||
v-model:data="llm.enable"
|
||||
@@ -273,150 +269,150 @@ const tuningItems = computed(() => [
|
||||
type="switch"
|
||||
/>
|
||||
|
||||
<transition name="slide-fade">
|
||||
<div v-if="llm.enable" class="llm-config">
|
||||
<ab-field :label="() => t('config.llm_set.provider')">
|
||||
<div v-if="llm.enable" class="llm-config">
|
||||
<ab-field :label="() => t('config.llm_set.provider')">
|
||||
<NSelect
|
||||
v-model:value="llm.provider"
|
||||
class="model-select"
|
||||
:options="providerOptions"
|
||||
:aria-label="t('config.llm_set.provider')"
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<ab-setting
|
||||
v-model:data="llm.mode"
|
||||
:label="() => t('config.llm_set.mode')"
|
||||
type="select"
|
||||
:prop="{ items: modeItems }"
|
||||
/>
|
||||
|
||||
<!-- 订阅类提供商:连接状态 chip 或 Connect 按钮 -->
|
||||
<div v-if="isSubscription" class="llm-subscription">
|
||||
<div v-if="selected?.connected" class="llm-connected">
|
||||
<ab-status
|
||||
state="running"
|
||||
:label="
|
||||
t('config.llm_set.connected_as', {
|
||||
account: selected.account_label || selected.display_name,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<ab-button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="onDisconnect(selected.id)"
|
||||
>
|
||||
{{ t('config.llm_set.disconnect') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
<ab-button
|
||||
v-else
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@click="showAuthDialog = true"
|
||||
>
|
||||
{{ t('config.llm_set.connect') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
|
||||
<!-- api_key 类(内置 + 预设):密钥 + 模型 + base_url -->
|
||||
<template v-if="isBuiltinLike">
|
||||
<ab-setting
|
||||
v-model:data="apiKey"
|
||||
:label="() => t('config.llm_set.api_key')"
|
||||
type="input"
|
||||
:prop="{ type: 'password', placeholder: 'sk-...' }"
|
||||
/>
|
||||
|
||||
<ab-field :label="() => t('config.llm_set.model')">
|
||||
<NSelect
|
||||
v-model:value="llm.provider"
|
||||
v-model:value="model"
|
||||
class="model-select"
|
||||
:options="providerOptions"
|
||||
:aria-label="t('config.llm_set.provider')"
|
||||
filterable
|
||||
tag
|
||||
:options="modelOptions"
|
||||
:loading="modelsLoading"
|
||||
:placeholder="modelPlaceholder"
|
||||
:aria-label="t('config.llm_set.model')"
|
||||
@update:show="onModelDropdownShow"
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<ab-setting
|
||||
v-model:data="llm.mode"
|
||||
:label="() => t('config.llm_set.mode')"
|
||||
type="select"
|
||||
:prop="{ items: modeItems }"
|
||||
v-if="selected?.needs_base_url"
|
||||
v-model:data="baseUrl"
|
||||
:label="() => t('config.llm_set.base_url')"
|
||||
type="input"
|
||||
:prop="{ type: 'url', placeholder: baseUrlPlaceholder }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 订阅类提供商:连接状态 chip 或 Connect 按钮 -->
|
||||
<div v-if="isSubscription" class="llm-subscription">
|
||||
<div v-if="selected?.connected" class="llm-connected">
|
||||
<span class="llm-connected-dot" />
|
||||
<span class="llm-connected-label">{{
|
||||
t('config.llm_set.connected_as', {
|
||||
account: selected.account_label || selected.display_name,
|
||||
})
|
||||
}}</span>
|
||||
<!-- 可下载插件:安装按钮(灰色插件先弹风险确认) -->
|
||||
<div v-if="isDownloadable" class="llm-install">
|
||||
<p class="llm-install-hint">
|
||||
{{ t('config.llm_set.not_installed') }}
|
||||
</p>
|
||||
<ab-button
|
||||
v-if="!confirmRisk"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
:loading="busyProvider === llm.provider"
|
||||
@click="onInstall(llm.provider)"
|
||||
>
|
||||
{{ t('config.llm_set.install') }}
|
||||
</ab-button>
|
||||
<div v-else class="llm-risk">
|
||||
<p class="llm-risk-text">
|
||||
{{ t('config.llm_set.risk_notice') }}
|
||||
</p>
|
||||
<div class="llm-risk-actions">
|
||||
<ab-button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="onDisconnect(selected.id)"
|
||||
variant="secondary"
|
||||
@click="confirmRisk = false"
|
||||
>
|
||||
{{ t('config.llm_set.disconnect') }}
|
||||
{{ t('config.llm_set.risk_cancel') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
:loading="busyProvider === llm.provider"
|
||||
@click="onInstall(llm.provider)"
|
||||
>
|
||||
{{ t('config.llm_set.risk_confirm') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
<ab-button
|
||||
v-else
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@click="showAuthDialog = true"
|
||||
>
|
||||
{{ t('config.llm_set.connect') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
|
||||
<!-- api_key 类(内置 + 预设):密钥 + 模型 + base_url -->
|
||||
<template v-if="isBuiltinLike">
|
||||
<ab-setting
|
||||
v-model:data="apiKey"
|
||||
:label="() => t('config.llm_set.api_key')"
|
||||
type="input"
|
||||
:prop="{ type: 'password', placeholder: 'sk-...' }"
|
||||
/>
|
||||
|
||||
<ab-field :label="() => t('config.llm_set.model')">
|
||||
<NSelect
|
||||
v-model:value="model"
|
||||
class="model-select"
|
||||
filterable
|
||||
tag
|
||||
:options="modelOptions"
|
||||
:loading="modelsLoading"
|
||||
:placeholder="modelPlaceholder"
|
||||
:aria-label="t('config.llm_set.model')"
|
||||
@update:show="onModelDropdownShow"
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<ab-setting
|
||||
v-if="selected?.needs_base_url"
|
||||
v-model:data="baseUrl"
|
||||
:label="() => t('config.llm_set.base_url')"
|
||||
type="input"
|
||||
:prop="{ type: 'url', placeholder: baseUrlPlaceholder }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 可下载插件:安装按钮(灰色插件先弹风险确认) -->
|
||||
<div v-if="isDownloadable" class="llm-install">
|
||||
<p class="llm-install-hint">
|
||||
{{ t('config.llm_set.not_installed') }}
|
||||
</p>
|
||||
<ab-button
|
||||
v-if="!confirmRisk"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
:loading="busyProvider === llm.provider"
|
||||
@click="onInstall(llm.provider)"
|
||||
>
|
||||
{{ t('config.llm_set.install') }}
|
||||
</ab-button>
|
||||
<div v-else class="llm-risk">
|
||||
<p class="llm-risk-text">
|
||||
{{ t('config.llm_set.risk_notice') }}
|
||||
</p>
|
||||
<div class="llm-risk-actions">
|
||||
<ab-button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@click="confirmRisk = false"
|
||||
>
|
||||
{{ t('config.llm_set.risk_cancel') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
:loading="busyProvider === llm.provider"
|
||||
@click="onInstall(llm.provider)"
|
||||
>
|
||||
{{ t('config.llm_set.risk_confirm') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已安装的非内置提供商:卸载入口 -->
|
||||
<div
|
||||
v-if="selected && !selected.builtin && selected.plugin_version"
|
||||
class="llm-uninstall"
|
||||
>
|
||||
<ab-button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
:loading="busyProvider === selected.id"
|
||||
@click="onUninstall(selected.id)"
|
||||
>
|
||||
{{ t('config.llm_set.uninstall') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
|
||||
<!-- 超时/缓存/并发/熔断调优项 -->
|
||||
<advanced-section v-model:open="showTuning">
|
||||
<ab-setting
|
||||
v-for="item in tuningItems"
|
||||
:key="item.key"
|
||||
v-model:data="llm[item.key]"
|
||||
:label="() => item.label"
|
||||
type="input"
|
||||
css="w-72"
|
||||
:prop="{ type: 'number', min: 0 }"
|
||||
/>
|
||||
</advanced-section>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- 已安装的非内置提供商:卸载入口 -->
|
||||
<div
|
||||
v-if="selected && !selected.builtin && selected.plugin_version"
|
||||
class="llm-uninstall"
|
||||
>
|
||||
<ab-button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
:loading="busyProvider === selected.id"
|
||||
@click="onUninstall(selected.id)"
|
||||
>
|
||||
{{ t('config.llm_set.uninstall') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
|
||||
<!-- 超时/缓存/并发/熔断调优项 -->
|
||||
<advanced-section v-model:open="showTuning">
|
||||
<ab-setting
|
||||
v-for="item in tuningItems"
|
||||
:key="item.key"
|
||||
v-model:data="llm[item.key]"
|
||||
:label="() => item.label"
|
||||
type="input"
|
||||
css="w-72"
|
||||
:prop="{ type: 'number', min: 0 }"
|
||||
/>
|
||||
</advanced-section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<llm-auth-dialog
|
||||
@@ -436,24 +432,6 @@ const tuningItems = computed(() => [
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.llm-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
transition: background-color var(--transition-normal),
|
||||
border-color var(--transition-normal);
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.llm-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -461,9 +439,10 @@ const tuningItems = computed(() => [
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.model-select {
|
||||
// 与 ab-field 控件列同宽(.n-select 提高优先级,压过 ab-field 的 width: auto)
|
||||
.llm-config .model-select.n-select {
|
||||
@include forTablet {
|
||||
max-width: 260px;
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,24 +454,22 @@ const tuningItems = computed(() => [
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// 按钮落在控件列的右边缘,不拉成整行
|
||||
.llm-subscription,
|
||||
.llm-uninstall {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.llm-install > .ab-btn {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.llm-connected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.llm-connected-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-success, #22c55e);
|
||||
}
|
||||
|
||||
.llm-connected-label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.llm-install-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
@@ -517,18 +494,4 @@ const tuningItems = computed(() => [
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.15s ease-in;
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -74,10 +74,14 @@ const providerFields: Record<
|
||||
},
|
||||
],
|
||||
bark: [
|
||||
{ key: 'device_key', label: 'Device Key', placeholder: 'device key' },
|
||||
{
|
||||
key: 'device_key',
|
||||
label: () => t('config.notification_set.device_key'),
|
||||
placeholder: 'device key',
|
||||
},
|
||||
{
|
||||
key: 'server_url',
|
||||
label: 'Server URL (optional)',
|
||||
label: () => t('config.notification_set.server_url_optional'),
|
||||
placeholder: 'https://api.day.app',
|
||||
},
|
||||
{
|
||||
@@ -115,7 +119,7 @@ const providerFields: Record<
|
||||
gotify: [
|
||||
{
|
||||
key: 'server_url',
|
||||
label: 'Server URL',
|
||||
label: () => t('config.notification_set.server_url'),
|
||||
placeholder: 'https://gotify.example.com',
|
||||
},
|
||||
{ key: 'token', label: 'App Token', placeholder: 'app token' },
|
||||
@@ -132,7 +136,7 @@ const providerFields: Record<
|
||||
},
|
||||
{
|
||||
key: 'template',
|
||||
label: 'Template (JSON)',
|
||||
label: () => t('config.notification_set.template'),
|
||||
placeholder: '{"title": "{{title}}", "episode": {{episode}}}',
|
||||
},
|
||||
],
|
||||
@@ -342,9 +346,11 @@ function getFieldsForType(type: string) {
|
||||
<div v-if="brandIcons[provider.type]" :class="brandIcons[provider.type]" />
|
||||
<component :is="lucideIcons[provider.type] ?? Bell" v-else :size="16" />
|
||||
{{ getProviderLabel(provider.type) }}
|
||||
<span v-if="!provider.enabled" class="disabled-badge">
|
||||
{{ $t('config.notification_set.disabled') }}
|
||||
</span>
|
||||
<ab-tag
|
||||
v-if="!provider.enabled"
|
||||
type="danger"
|
||||
:title="$t('config.notification_set.disabled')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-actions">
|
||||
@@ -387,13 +393,13 @@ function getFieldsForType(type: string) {
|
||||
</div>
|
||||
|
||||
<!-- Test result message -->
|
||||
<div
|
||||
<ab-alert
|
||||
v-if="listResult"
|
||||
class="test-result"
|
||||
:class="listResult.success ? 'test-success' : 'test-error'"
|
||||
:type="listResult.success ? 'info' : 'danger'"
|
||||
>
|
||||
{{ listResult.message }}
|
||||
</div>
|
||||
</ab-alert>
|
||||
|
||||
<div class="line" />
|
||||
|
||||
@@ -453,13 +459,13 @@ function getFieldsForType(type: string) {
|
||||
{{ $t('config.notification_set.plugin_hint') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
<ab-alert
|
||||
v-if="dialogResult"
|
||||
class="test-result"
|
||||
:class="dialogResult.success ? 'test-success' : 'test-error'"
|
||||
:type="dialogResult.success ? 'info' : 'danger'"
|
||||
>
|
||||
{{ dialogResult.message }}
|
||||
</div>
|
||||
</ab-alert>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@@ -529,13 +535,13 @@ function getFieldsForType(type: string) {
|
||||
{{ $t('config.notification_set.plugin_hint') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
<ab-alert
|
||||
v-if="dialogResult"
|
||||
class="test-result"
|
||||
:class="dialogResult.success ? 'test-success' : 'test-error'"
|
||||
:type="dialogResult.success ? 'info' : 'danger'"
|
||||
>
|
||||
{{ dialogResult.message }}
|
||||
</div>
|
||||
</ab-alert>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@@ -573,23 +579,20 @@ function getFieldsForType(type: string) {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--color-surface-elevated, #f9fafb);
|
||||
border-radius: 8px;
|
||||
transition: background-color var(--transition-normal),
|
||||
opacity var(--transition-normal);
|
||||
|
||||
:root.dark & {
|
||||
background: var(--color-surface-elevated, #1f2937);
|
||||
}
|
||||
}
|
||||
|
||||
.provider-disabled {
|
||||
opacity: 0.5;
|
||||
background: var(--color-surface-2);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.provider-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
transition: opacity var(--transition-normal);
|
||||
|
||||
// 停用只淡化信息区,“重新启用”等操作按钮保持可点的样子
|
||||
.provider-disabled & {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-name {
|
||||
@@ -606,17 +609,6 @@ function getFieldsForType(type: string) {
|
||||
}
|
||||
}
|
||||
|
||||
.disabled-badge {
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.provider-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -667,24 +659,4 @@ function getFieldsForType(type: string) {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.test-result {
|
||||
font-size: 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.test-success {
|
||||
color: var(--color-success, #22c55e);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-success, #22c55e) 10%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.test-error {
|
||||
color: var(--color-danger, #ef4444);
|
||||
background: color-mix(in srgb, var(--color-danger, #ef4444) 10%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -88,12 +88,12 @@ function formatDate(dateString: string | null): string {
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-else-if="loading" class="text-gray-500 text-14">
|
||||
<div v-else-if="loading" class="passkey-muted text-14">
|
||||
{{ $t('passkey.loading') }}
|
||||
</div>
|
||||
|
||||
<!-- 无 Passkey -->
|
||||
<div v-else-if="passkeys.length === 0" class="text-gray-500 text-14">
|
||||
<div v-else-if="passkeys.length === 0" class="passkey-muted text-14">
|
||||
{{ $t('passkey.no_passkeys') }}
|
||||
</div>
|
||||
|
||||
@@ -102,23 +102,26 @@ function formatDate(dateString: string | null): string {
|
||||
<div
|
||||
v-for="passkey in passkeys"
|
||||
:key="passkey.id"
|
||||
class="flex justify-between items-center p-12 bg-gray-50 rounded-8"
|
||||
class="passkey-row"
|
||||
>
|
||||
<div>
|
||||
<div class="font-medium">
|
||||
{{ passkey.name }}
|
||||
</div>
|
||||
<div class="text-12 text-gray-500">
|
||||
<div class="passkey-muted text-12">
|
||||
{{ $t('passkey.created_at') }}:
|
||||
{{ formatDate(passkey.created_at) }}
|
||||
</div>
|
||||
<div v-if="passkey.last_used_at" class="text-12 text-gray-500">
|
||||
<div v-if="passkey.last_used_at" class="passkey-muted text-12">
|
||||
{{ $t('passkey.last_used') }}:
|
||||
{{ formatDate(passkey.last_used_at) }}
|
||||
</div>
|
||||
<div v-if="passkey.backup_eligible" class="text-12 text-green-600">
|
||||
{{ $t('passkey.synced') }}
|
||||
</div>
|
||||
<ab-tag
|
||||
v-if="passkey.backup_eligible"
|
||||
type="success"
|
||||
class="passkey-synced"
|
||||
:title="$t('passkey.synced')"
|
||||
/>
|
||||
</div>
|
||||
<ab-icon-button
|
||||
size="sm"
|
||||
@@ -162,7 +165,7 @@ function formatDate(dateString: string | null): string {
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<div class="text-14 text-gray-500">
|
||||
<div class="passkey-muted text-14">
|
||||
{{ $t('passkey.register_hint') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,3 +186,23 @@ function formatDate(dateString: string | null): string {
|
||||
</ab-modal>
|
||||
</ab-fold-panel>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 主题色令牌:深色模式下行底与说明文字随 .dark 切换
|
||||
.passkey-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: var(--color-surface-2);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.passkey-muted {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.passkey-synced {
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -146,7 +146,11 @@ async function setEnabled(plugin: PluginInfo, enabled: boolean) {
|
||||
run(plugin.id, () => apiPlugins.update(plugin.id, { enabled }));
|
||||
}
|
||||
|
||||
// 选项表单只在失焦后显示错误;保存时让未碰过的字段也显示
|
||||
const forms: Record<string, InstanceType<typeof PluginSchemaForm> | null> = {};
|
||||
|
||||
async function saveOptions(plugin: PluginInfo) {
|
||||
forms[plugin.id]?.showErrors();
|
||||
const ok = await run(plugin.id, () =>
|
||||
apiPlugins.update(plugin.id, { options: drafts.value[plugin.id] }));
|
||||
if (ok) message.success(t('config.plugins_set.save_success'));
|
||||
@@ -283,6 +287,7 @@ onActivated(async () => {
|
||||
</summary>
|
||||
<template v-if="plugin.config_schema">
|
||||
<PluginSchemaForm
|
||||
:ref="(el) => (forms[plugin.id] = el as typeof forms[string])"
|
||||
v-model="drafts[plugin.id]"
|
||||
:fields="
|
||||
localizeFields(t, plugin, schemaFields(plugin.config_schema))
|
||||
@@ -377,6 +382,22 @@ onActivated(async () => {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
// 选项区高度过渡(与 ab-fold-panel 一致);不支持 ::details-content 的浏览器退化为即时切换
|
||||
.plugin__options {
|
||||
interpolate-size: allow-keywords;
|
||||
|
||||
&::details-content {
|
||||
height: 0;
|
||||
overflow: clip;
|
||||
transition: height var(--transition-normal),
|
||||
content-visibility var(--transition-normal) allow-discrete;
|
||||
}
|
||||
|
||||
&[open]::details-content {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin__options summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -385,6 +406,7 @@ onActivated(async () => {
|
||||
min-height: var(--touch-target);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: margin-bottom var(--transition-normal);
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { Plus, SquarePen, Trash } from '@lucide/vue';
|
||||
import { Pencil, Plus, Trash } from '@lucide/vue';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import {
|
||||
buildNexusPhpSearchUrl,
|
||||
@@ -229,30 +229,31 @@ function validateUrl(url: string): boolean {
|
||||
<div class="provider-info">
|
||||
<div class="provider-name">
|
||||
{{ provider.name }}
|
||||
<span
|
||||
<ab-tag
|
||||
v-if="isDefaultProvider(provider.name)"
|
||||
class="default-badge"
|
||||
>
|
||||
{{ $t('config.search_provider_set.default') }}
|
||||
</span>
|
||||
type="neutral"
|
||||
:title="$t('config.search_provider_set.default')"
|
||||
/>
|
||||
</div>
|
||||
<div class="provider-url" :title="provider.url">
|
||||
{{ provider.url }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-actions">
|
||||
<ab-button
|
||||
<ab-icon-button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
:label="$t('config.search_provider_set.edit_title')"
|
||||
@click="openEditDialog(provider, index)"
|
||||
>
|
||||
<SquarePen :size="16" />
|
||||
</ab-button>
|
||||
<Pencil :size="16" />
|
||||
</ab-icon-button>
|
||||
<!-- 默认源不能删除:按钮隐藏但占位,各行编辑按钮保持同一列 -->
|
||||
<ab-icon-button
|
||||
v-if="!isDefaultProvider(provider.name)"
|
||||
size="sm"
|
||||
class="provider-delete"
|
||||
:class="{ 'provider-delete--hidden': isDefaultProvider(provider.name) }"
|
||||
:label="$t('config.search_provider_set.remove')"
|
||||
:disabled="isDefaultProvider(provider.name)"
|
||||
@click="onDeleteClick(index)"
|
||||
>
|
||||
<Trash :size="16" />
|
||||
@@ -304,7 +305,14 @@ function validateUrl(url: string): boolean {
|
||||
</ab-field>
|
||||
|
||||
<template v-if="formMode === 'custom'">
|
||||
<ab-field :label="$t('config.search_provider_set.url')">
|
||||
<ab-field
|
||||
:label="$t('config.search_provider_set.url')"
|
||||
:error="
|
||||
formUrl && !validateUrl(formUrl)
|
||||
? $t('config.search_provider_set.url_missing_placeholder')
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<ab-input
|
||||
v-model="formUrl"
|
||||
type="text"
|
||||
@@ -312,13 +320,6 @@ function validateUrl(url: string): boolean {
|
||||
@keyup.enter="handleAdd"
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<div
|
||||
v-if="formUrl && !validateUrl(formUrl)"
|
||||
class="validation-warning"
|
||||
>
|
||||
{{ $t('config.search_provider_set.url_missing_placeholder') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
@@ -342,7 +343,14 @@ function validateUrl(url: string): boolean {
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<ab-field :label="$t('config.search_provider_set.category_id')">
|
||||
<ab-field
|
||||
:label="$t('config.search_provider_set.category_id')"
|
||||
:error="
|
||||
formCategoryId && !categoryIdsValid
|
||||
? $t('config.search_provider_set.category_id_invalid')
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<ab-input
|
||||
v-model="formCategoryId"
|
||||
type="text"
|
||||
@@ -353,13 +361,6 @@ function validateUrl(url: string): boolean {
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<div
|
||||
v-if="formCategoryId && !categoryIdsValid"
|
||||
class="validation-warning"
|
||||
>
|
||||
{{ $t('config.search_provider_set.category_id_invalid') }}
|
||||
</div>
|
||||
|
||||
<div class="hint-text">
|
||||
{{ $t('config.search_provider_set.nexusphp_hint') }}
|
||||
</div>
|
||||
@@ -404,7 +405,14 @@ function validateUrl(url: string): boolean {
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<ab-field :label="$t('config.search_provider_set.url')">
|
||||
<ab-field
|
||||
:label="$t('config.search_provider_set.url')"
|
||||
:error="
|
||||
formUrl && !validateUrl(formUrl)
|
||||
? $t('config.search_provider_set.url_missing_placeholder')
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<ab-input
|
||||
v-model="formUrl"
|
||||
type="text"
|
||||
@@ -412,10 +420,6 @@ function validateUrl(url: string): boolean {
|
||||
@keyup.enter="handleEdit"
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<div v-if="formUrl && !validateUrl(formUrl)" class="validation-warning">
|
||||
{{ $t('config.search_provider_set.url_missing_placeholder') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@@ -444,13 +448,9 @@ function validateUrl(url: string): boolean {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--color-surface-elevated, #f9fafb);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface-2);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color var(--transition-normal);
|
||||
|
||||
:root.dark & {
|
||||
background: var(--color-surface-elevated, #1f2937);
|
||||
}
|
||||
}
|
||||
|
||||
.provider-info {
|
||||
@@ -467,16 +467,6 @@ function validateUrl(url: string): boolean {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.default-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.provider-url {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
@@ -507,22 +497,11 @@ function validateUrl(url: string): boolean {
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
|
||||
monospace;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-surface-elevated, #f9fafb);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface-2);
|
||||
border-radius: var(--radius-md);
|
||||
word-break: break-all;
|
||||
|
||||
:root.dark & {
|
||||
background: var(--color-surface-elevated, #1f2937);
|
||||
}
|
||||
}
|
||||
|
||||
.validation-warning {
|
||||
font-size: 12px;
|
||||
color: var(--color-danger, #ef4444);
|
||||
padding: 8px 12px;
|
||||
background: color-mix(in srgb, var(--color-danger, #ef4444) 10%, transparent);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.provider-delete {
|
||||
color: var(--color-danger);
|
||||
|
||||
@@ -530,5 +509,9 @@ function validateUrl(url: string): boolean {
|
||||
color: var(--color-danger);
|
||||
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||
}
|
||||
|
||||
&--hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts" setup>
|
||||
import { NInput, NModal } from 'naive-ui';
|
||||
import type { LLMAuthChallenge } from '@/api/llm';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -28,18 +27,22 @@ function stopPolling() {
|
||||
}
|
||||
}
|
||||
|
||||
// 内容留到下次打开再清空,弹窗淡出时不切换成等待文字
|
||||
function close() {
|
||||
stopPolling();
|
||||
challenge.value = null;
|
||||
pastedCode.value = '';
|
||||
show.value = false;
|
||||
}
|
||||
|
||||
async function begin() {
|
||||
challenge.value = null;
|
||||
pastedCode.value = '';
|
||||
loading.value = true;
|
||||
try {
|
||||
challenge.value = await apiLLM.beginAuth(props.providerId);
|
||||
if (challenge.value.method === 'device_code') startPolling();
|
||||
const result = await apiLLM.beginAuth(props.providerId);
|
||||
// 请求返回前弹窗已关闭:丢弃结果,不在隐藏的弹窗里轮询
|
||||
if (!show.value) return;
|
||||
challenge.value = result;
|
||||
if (result.method === 'device_code') startPolling();
|
||||
} catch {
|
||||
message.error(t('config.llm_set.auth_failed'));
|
||||
close();
|
||||
@@ -88,21 +91,20 @@ async function submitCode() {
|
||||
}
|
||||
}
|
||||
|
||||
// ab-modal 的 X/遮罩/Esc/抽屉下滑只改 show,关闭时在这里统一清理
|
||||
watch(show, (visible) => {
|
||||
if (visible) begin();
|
||||
else stopPolling();
|
||||
else close();
|
||||
});
|
||||
|
||||
onUnmounted(stopPolling);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
:show="show"
|
||||
preset="card"
|
||||
<ab-modal
|
||||
v-model:show="show"
|
||||
size="sm"
|
||||
:title="`${t('config.llm_set.connect')} · ${displayName}`"
|
||||
style="max-width: 460px"
|
||||
@update:show="(v: boolean) => !v && close()"
|
||||
>
|
||||
<div v-if="challenge" class="auth-body">
|
||||
<template v-if="challenge.method === 'device_code'">
|
||||
@@ -135,26 +137,35 @@ onUnmounted(stopPolling);
|
||||
rel="noopener"
|
||||
class="auth-link"
|
||||
>{{ challenge.authorize_url }}</a>
|
||||
<NInput
|
||||
v-model:value="pastedCode"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
<textarea
|
||||
v-model="pastedCode"
|
||||
class="auth-textarea"
|
||||
rows="2"
|
||||
:aria-label="t('config.llm_set.auth_paste_code')"
|
||||
:placeholder="t('config.llm_set.auth_paste_code')"
|
||||
/>
|
||||
<ab-button
|
||||
variant="primary"
|
||||
:loading="loading"
|
||||
:disabled="!pastedCode.trim()"
|
||||
@click="submitCode"
|
||||
>
|
||||
{{ t('config.llm_set.connect') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="auth-body auth-loading">
|
||||
{{ t('config.llm_set.auth_pending') }}
|
||||
</div>
|
||||
</NModal>
|
||||
|
||||
<template #footer>
|
||||
<ab-button size="sm" @click="close">
|
||||
{{ t('config.cancel') }}
|
||||
</ab-button>
|
||||
<ab-button
|
||||
v-if="challenge && challenge.method !== 'device_code'"
|
||||
size="sm"
|
||||
variant="primary"
|
||||
:loading="loading"
|
||||
:disabled="!pastedCode.trim()"
|
||||
@click="submitCode"
|
||||
>
|
||||
{{ t('config.llm_set.connect') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -187,6 +198,28 @@ onUnmounted(stopPolling);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.auth-textarea {
|
||||
// Soft Ink 填充式多行输入(与 config-notification 的 .field-textarea 一致)
|
||||
width: 100%;
|
||||
padding: 8px 11px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
transition: border-color var(--transition-fast),
|
||||
background-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
|
||||
&:focus {
|
||||
background: var(--color-surface);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-alpha);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-pending {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
@@ -10,7 +10,8 @@ import { fillSchemaDefaults } from '@/utils/plugin-schema';
|
||||
import type { SchemaField } from '@/utils/plugin-schema';
|
||||
|
||||
// 由插件 config_model 的 JSON Schema 渲染的表单;字段描述由 schemaFields() 生成
|
||||
defineProps<{ fields: SchemaField[] }>();
|
||||
// showAll:外层表单保存时传给列表行里的子表单,让嵌套字段也显示错误
|
||||
const props = defineProps<{ fields: SchemaField[]; showAll?: boolean }>();
|
||||
const model = defineModel<Record<string, unknown>>({ required: true });
|
||||
const { t } = useMyI18n();
|
||||
const { confirm } = useConfirm();
|
||||
@@ -40,6 +41,19 @@ function errorOf(field: SchemaField): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 字段离开焦点后才显示错误,避免一展开就满屏红框;保存时由父组件调用
|
||||
// showErrors() 显示全部
|
||||
const touched = ref(new Set<string>());
|
||||
const showAll = ref(false);
|
||||
function shownError(field: SchemaField): string {
|
||||
return showAll.value || props.showAll || touched.value.has(field.key) ? errorOf(field) : '';
|
||||
}
|
||||
defineExpose({
|
||||
showErrors() {
|
||||
showAll.value = true;
|
||||
},
|
||||
});
|
||||
|
||||
function rowsOf(field: SchemaField): Record<string, unknown>[] {
|
||||
return (
|
||||
(model.value[field.key] as Record<string, unknown>[] | undefined) ?? []
|
||||
@@ -94,7 +108,8 @@ function setNumber(field: SchemaField, value: string | number) {
|
||||
:label="field.label"
|
||||
:description="field.description"
|
||||
:required="field.required"
|
||||
:error="errorOf(field)"
|
||||
:error="shownError(field)"
|
||||
@focusout="touched.add(field.key)"
|
||||
>
|
||||
<AbSwitch
|
||||
v-if="field.kind === 'switch'"
|
||||
@@ -130,7 +145,11 @@ function setNumber(field: SchemaField, value: string | number) {
|
||||
:key="rowKey(row)"
|
||||
class="schema-form__row"
|
||||
>
|
||||
<PluginSchemaForm :model-value="row" :fields="field.itemFields" />
|
||||
<PluginSchemaForm
|
||||
:model-value="row"
|
||||
:fields="field.itemFields"
|
||||
:show-all="showAll || props.showAll"
|
||||
/>
|
||||
<AbButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { NProgress, NSpin, NSwitch, NTag } from 'naive-ui';
|
||||
import { NProgress, NSpin } from 'naive-ui';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import { renderMarkdown } from '@/utils/markdown';
|
||||
import type { UpdateChannel, UpdateInfo } from '#/update';
|
||||
|
||||
const { t } = useMyI18n();
|
||||
@@ -90,30 +91,8 @@ const phaseLabel = computed(() => {
|
||||
return phase ? map[phase] ?? '' : '';
|
||||
});
|
||||
|
||||
/**
|
||||
* 极简且防 XSS 的 Release 说明渲染:先转义所有 HTML,再对少量 markdown 语法
|
||||
* 做替换(标题/加粗/行内代码/链接/无序列表/换行)。输入来自项目固定的 GitHub
|
||||
* Release,但仍先转义以确保安全。
|
||||
*/
|
||||
function renderNotes(md: string): string {
|
||||
if (!md) return '';
|
||||
const escaped = md
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return escaped
|
||||
.replace(/^###?\s?#*\s*(.*)$/gm, '<strong>$1</strong>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(
|
||||
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
||||
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'
|
||||
)
|
||||
.replace(/^[-*]\s+(.*)$/gm, '• $1')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
const notesHtml = computed(() => renderNotes(info.value?.notes ?? ''));
|
||||
// 与插件 README 同一个安全渲染器:先转义全部 HTML,链接只接受 http(s)
|
||||
const notesHtml = computed(() => renderMarkdown(info.value?.notes ?? ''));
|
||||
|
||||
// 上次检查完成的时刻;用户主动点检查时每次刷新,给出"确实检查过了"的反馈。
|
||||
const lastCheckedAt = ref<Date | null>(null);
|
||||
@@ -229,7 +208,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ab-container :title="$t('update.title')">
|
||||
<ab-fold-panel :title="$t('update.title')">
|
||||
<div class="update-card">
|
||||
<!-- 版本信息 -->
|
||||
<div class="update-row">
|
||||
@@ -240,9 +219,11 @@ onBeforeUnmount(() => {
|
||||
<span class="update-key">{{ $t('update.latest_version') }}</span>
|
||||
<span class="update-val">
|
||||
{{ info.latest }}
|
||||
<NTag v-if="hasUpdate" type="success" size="small" round>
|
||||
{{ $t('update.update_available') }}
|
||||
</NTag>
|
||||
<ab-tag
|
||||
v-if="hasUpdate"
|
||||
type="success"
|
||||
:title="$t('update.update_available')"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -257,11 +238,11 @@ onBeforeUnmount(() => {
|
||||
<span :class="{ dim: channel === 'beta' }">
|
||||
{{ $t('update.channel_stable') }}
|
||||
</span>
|
||||
<NSwitch
|
||||
:value="channel === 'beta'"
|
||||
<ab-switch
|
||||
:model-value="channel === 'beta'"
|
||||
:disabled="applying || restarting"
|
||||
:aria-label="$t('update.channel')"
|
||||
@update:value="onChannelToggle"
|
||||
@update:model-value="onChannelToggle"
|
||||
/>
|
||||
<span :class="{ dim: channel === 'stable' }">
|
||||
{{ $t('update.channel_beta') }}
|
||||
@@ -272,11 +253,11 @@ onBeforeUnmount(() => {
|
||||
<!-- 自动检查 -->
|
||||
<div class="update-row">
|
||||
<span class="update-key">{{ $t('update.auto_check') }}</span>
|
||||
<NSwitch
|
||||
:value="autoCheck"
|
||||
<ab-switch
|
||||
:model-value="autoCheck"
|
||||
:disabled="applying || restarting"
|
||||
:aria-label="$t('update.auto_check')"
|
||||
@update:value="onAutoCheckToggle"
|
||||
@update:model-value="onAutoCheckToggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -358,7 +339,7 @@ onBeforeUnmount(() => {
|
||||
</ab-button>
|
||||
</div>
|
||||
</div>
|
||||
</ab-container>
|
||||
</ab-fold-panel>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -376,8 +357,8 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.update-key {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.update-val {
|
||||
@@ -385,7 +366,14 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text);
|
||||
|
||||
// 标签是文字,不用等宽
|
||||
.ab-tag {
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
}
|
||||
|
||||
.update-channel {
|
||||
@@ -445,12 +433,46 @@ onBeforeUnmount(() => {
|
||||
color: var(--color-text-secondary);
|
||||
word-break: break-word;
|
||||
|
||||
:deep(:is(p, ul, ol, h3, h4, h5, h6)) {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
:deep(:is(h3, h4, h5, h6)) {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
:deep(ul) {
|
||||
padding-left: 18px;
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
:deep(ol) {
|
||||
padding-left: 18px;
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
:deep(code) {
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
background: var(--color-border);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
:deep(pre) {
|
||||
margin: 4px 0;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
overflow-x: auto;
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.update-progress {
|
||||
|
||||
@@ -92,12 +92,12 @@ const { t } = useMyI18n();
|
||||
// below the 44px touch-target minimum; widen and heighten the shared
|
||||
// wizard controls here instead of per step component.
|
||||
@include forTouch {
|
||||
:deep(.setup-input),
|
||||
:deep(.setup-input-wide) {
|
||||
width: 100%;
|
||||
:deep(.ab-input) {
|
||||
height: var(--touch-target);
|
||||
}
|
||||
|
||||
:deep(.ab-input input) {
|
||||
font-size: 16px; // ≥16px stops iOS Safari from zooming the input
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
:deep(.wizard-actions .ab-btn),
|
||||
|
||||
@@ -37,34 +37,30 @@ const passwordError = computed(() => {
|
||||
|
||||
<div class="form-fields">
|
||||
<ab-field :label="t('setup.account.username')">
|
||||
<input
|
||||
<ab-input
|
||||
v-model="accountData.username"
|
||||
type="text"
|
||||
:aria-label="t('setup.account.username')"
|
||||
autocomplete="username"
|
||||
placeholder="admin"
|
||||
class="setup-input"
|
||||
>
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<ab-field :label="t('setup.account.password')">
|
||||
<input
|
||||
<ab-input
|
||||
v-model="accountData.password"
|
||||
type="password"
|
||||
:aria-label="t('setup.account.password')"
|
||||
autocomplete="new-password"
|
||||
class="setup-input"
|
||||
>
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<ab-field :label="t('setup.account.confirm_password')">
|
||||
<input
|
||||
<ab-input
|
||||
v-model="accountData.confirmPassword"
|
||||
type="password"
|
||||
:aria-label="t('setup.account.confirm_password')"
|
||||
autocomplete="new-password"
|
||||
class="setup-input"
|
||||
>
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<p v-if="passwordError" class="error-text">
|
||||
@@ -112,34 +108,9 @@ const passwordError = computed(() => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setup-input {
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
width: 200px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(108, 74, 182, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 11px;
|
||||
color: var(--color-error, #e53935);
|
||||
color: var(--color-danger);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { NSelect, NSwitch } from 'naive-ui';
|
||||
import { NSwitch } from 'naive-ui';
|
||||
|
||||
const { t } = useMyI18n();
|
||||
const setupStore = useSetupStore();
|
||||
@@ -85,11 +85,10 @@ const canProceed = computed(() => Boolean(downloaderData.value.host));
|
||||
|
||||
<div class="form-fields">
|
||||
<ab-field :label="t('config.downloader_set.type')">
|
||||
<NSelect
|
||||
v-model:value="downloaderData.type"
|
||||
<ab-select
|
||||
v-model="downloaderData.type"
|
||||
:options="downloaderTypeOptions"
|
||||
:aria-label="t('config.downloader_set.type')"
|
||||
class="type-select"
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
@@ -98,44 +97,37 @@ const canProceed = computed(() => Boolean(downloaderData.value.host));
|
||||
</p>
|
||||
|
||||
<ab-field :label="t('config.downloader_set.host')">
|
||||
<input
|
||||
<ab-input
|
||||
v-model="downloaderData.host"
|
||||
type="text"
|
||||
:aria-label="t('config.downloader_set.host')"
|
||||
placeholder="172.17.0.1:8080"
|
||||
class="setup-input"
|
||||
>
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<ab-field :label="t('config.downloader_set.username')">
|
||||
<input
|
||||
<ab-input
|
||||
v-model="downloaderData.username"
|
||||
type="text"
|
||||
:aria-label="t('config.downloader_set.username')"
|
||||
autocomplete="username"
|
||||
placeholder="admin"
|
||||
class="setup-input"
|
||||
>
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<ab-field :label="t('config.downloader_set.password')">
|
||||
<input
|
||||
<ab-input
|
||||
v-model="downloaderData.password"
|
||||
type="password"
|
||||
:aria-label="t('config.downloader_set.password')"
|
||||
autocomplete="current-password"
|
||||
class="setup-input"
|
||||
>
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<ab-field :label="t('config.downloader_set.path')">
|
||||
<input
|
||||
<ab-input
|
||||
v-model="downloaderData.path"
|
||||
type="text"
|
||||
:aria-label="t('config.downloader_set.path')"
|
||||
placeholder="/downloads/Bangumi"
|
||||
class="setup-input"
|
||||
>
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<ab-field :label="t('config.downloader_set.ssl')">
|
||||
@@ -212,31 +204,6 @@ const canProceed = computed(() => Boolean(downloaderData.value.host));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setup-input {
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
width: 200px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(108, 74, 182, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.test-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -245,11 +212,11 @@ const canProceed = computed(() => Boolean(downloaderData.value.host));
|
||||
|
||||
.test-message {
|
||||
font-size: 11px;
|
||||
color: var(--color-error, #e53935);
|
||||
color: var(--color-danger);
|
||||
margin: 0;
|
||||
|
||||
&.success {
|
||||
color: var(--color-success, #43a047);
|
||||
color: var(--color-success);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,10 +226,6 @@ const canProceed = computed(() => Boolean(downloaderData.value.host));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.type-select {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.aria2-hint {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script lang="ts" setup>
|
||||
import { NSelect } from 'naive-ui';
|
||||
|
||||
const { t } = useMyI18n();
|
||||
const setupStore = useSetupStore();
|
||||
const { notificationData, validation } = storeToRefs(setupStore);
|
||||
@@ -65,31 +63,26 @@ const canTest = computed(() => {
|
||||
|
||||
<div class="form-fields">
|
||||
<ab-field :label="t('config.notification_set.type')">
|
||||
<NSelect
|
||||
v-model:value="notificationData.type"
|
||||
<ab-select
|
||||
v-model="notificationData.type"
|
||||
:options="notificationTypes"
|
||||
:aria-label="t('config.notification_set.type')"
|
||||
size="small"
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<ab-field :label="t('config.notification_set.token')">
|
||||
<input
|
||||
<ab-input
|
||||
v-model="notificationData.token"
|
||||
type="text"
|
||||
:aria-label="t('config.notification_set.token')"
|
||||
autocomplete="off"
|
||||
class="setup-input setup-input-wide"
|
||||
>
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<ab-field :label="t('config.notification_set.chat_id')">
|
||||
<input
|
||||
<ab-input
|
||||
v-model="notificationData.chat_id"
|
||||
type="text"
|
||||
:aria-label="t('config.notification_set.chat_id')"
|
||||
class="setup-input"
|
||||
>
|
||||
/>
|
||||
</ab-field>
|
||||
</div>
|
||||
|
||||
@@ -160,35 +153,6 @@ const canTest = computed(() => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setup-input {
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
width: 200px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(108, 74, 182, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.setup-input-wide {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -197,11 +161,11 @@ const canTest = computed(() => {
|
||||
|
||||
.test-message {
|
||||
font-size: 11px;
|
||||
color: var(--color-error, #e53935);
|
||||
color: var(--color-danger);
|
||||
margin: 0;
|
||||
|
||||
&.success {
|
||||
color: var(--color-success, #43a047);
|
||||
color: var(--color-success);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +163,7 @@ function maskPassword(pwd: string): string {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -172,16 +173,19 @@ function maskPassword(pwd: string): string {
|
||||
}
|
||||
|
||||
.review-label {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.review-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--color-text);
|
||||
font-family: monospace;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
// 提交前要能看到完整地址,长值换行而不截断
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
|
||||
@@ -58,22 +58,18 @@ function handleNext() {
|
||||
|
||||
<div class="form-fields">
|
||||
<ab-field :label="t('setup.rss.url')">
|
||||
<input
|
||||
<ab-input
|
||||
v-model="rssData.url"
|
||||
type="text"
|
||||
:aria-label="t('setup.rss.url')"
|
||||
placeholder="https://mikanani.me/RSS/..."
|
||||
class="setup-input setup-input-wide"
|
||||
>
|
||||
/>
|
||||
</ab-field>
|
||||
|
||||
<ab-field v-if="rssData.name" :label="t('setup.rss.feed_name')">
|
||||
<input
|
||||
<ab-input
|
||||
v-model="rssData.name"
|
||||
type="text"
|
||||
:aria-label="t('setup.rss.feed_name')"
|
||||
class="setup-input"
|
||||
>
|
||||
/>
|
||||
</ab-field>
|
||||
</div>
|
||||
|
||||
@@ -149,35 +145,6 @@ function handleNext() {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.setup-input {
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
width: 200px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(108, 74, 182, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.setup-input-wide {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -186,11 +153,11 @@ function handleNext() {
|
||||
|
||||
.test-message {
|
||||
font-size: 11px;
|
||||
color: var(--color-error, #e53935);
|
||||
color: var(--color-danger);
|
||||
margin: 0;
|
||||
|
||||
&.success {
|
||||
color: var(--color-success, #43a047);
|
||||
color: var(--color-success);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -124,7 +124,11 @@
|
||||
"bark_group": "Group (optional)",
|
||||
"bark_level": "Interruption level (optional)",
|
||||
"bark_icon": "Icon URL (optional, defaults to the poster)",
|
||||
"bark_url": "URL to open on tap (optional)"
|
||||
"bark_url": "URL to open on tap (optional)",
|
||||
"device_key": "Device Key",
|
||||
"server_url": "Server URL",
|
||||
"server_url_optional": "Server URL (optional)",
|
||||
"template": "Template (JSON)"
|
||||
},
|
||||
"parser_set": {
|
||||
"enable": "Enable",
|
||||
@@ -543,7 +547,6 @@
|
||||
"today": "Today",
|
||||
"empty": "No anime",
|
||||
"refresh": "Refresh schedule",
|
||||
"no_data": "No schedule data available",
|
||||
"loading": "Loading...",
|
||||
"tracked": "Tracked",
|
||||
"tracked_count": "{count} tracked",
|
||||
@@ -551,7 +554,7 @@
|
||||
"search_title": "Search and Add Anime",
|
||||
"no_search_results": "No results found. Try different keywords.",
|
||||
"empty_state": {
|
||||
"title": "No Schedule Yet",
|
||||
"title": "No schedule yet",
|
||||
"subtitle": "Click refresh to fetch this season's broadcast data"
|
||||
},
|
||||
"drag_hint": "Drag to assign weekday",
|
||||
@@ -787,7 +790,7 @@
|
||||
"restart": "Restart",
|
||||
"search": {
|
||||
"placeholder": "Type to search",
|
||||
"click_to_search": "Click to search"
|
||||
"click_to_search": "Search"
|
||||
},
|
||||
"shutdown": "Shutdown",
|
||||
"start": "Start"
|
||||
@@ -867,7 +870,7 @@
|
||||
"state_all": "All states",
|
||||
"state": {
|
||||
"installed": "Installed",
|
||||
"update": "Update available",
|
||||
"update": "Updates",
|
||||
"available": "Not installed"
|
||||
},
|
||||
"update_from": "Update available · {version} installed",
|
||||
@@ -899,6 +902,7 @@
|
||||
"update": "Update",
|
||||
"install_enable": "Install and enable",
|
||||
"back": "Back",
|
||||
"not_found": "This plugin is not in the catalog",
|
||||
"confirm_install": "Install {name}",
|
||||
"confirm_update": "Update {name}",
|
||||
"warning_lead": "Note",
|
||||
|
||||
@@ -124,7 +124,11 @@
|
||||
"bark_group": "分组(可选)",
|
||||
"bark_level": "中断级别(可选)",
|
||||
"bark_icon": "图标地址(可选,默认用海报)",
|
||||
"bark_url": "点击打开的地址(可选)"
|
||||
"bark_url": "点击打开的地址(可选)",
|
||||
"device_key": "设备密钥",
|
||||
"server_url": "服务器地址",
|
||||
"server_url_optional": "服务器地址(可选)",
|
||||
"template": "模板(JSON)"
|
||||
},
|
||||
"parser_set": {
|
||||
"enable": "启用",
|
||||
@@ -530,20 +534,19 @@
|
||||
"sun": "周日"
|
||||
},
|
||||
"days_short": {
|
||||
"mon": "一",
|
||||
"tue": "二",
|
||||
"wed": "三",
|
||||
"thu": "四",
|
||||
"fri": "五",
|
||||
"sat": "六",
|
||||
"sun": "日"
|
||||
"mon": "周一",
|
||||
"tue": "周二",
|
||||
"wed": "周三",
|
||||
"thu": "周四",
|
||||
"fri": "周五",
|
||||
"sat": "周六",
|
||||
"sun": "周日"
|
||||
},
|
||||
"unknown": "未知",
|
||||
"unknown_title": "未知标题",
|
||||
"today": "今天",
|
||||
"empty": "今日无番",
|
||||
"empty": "暂无番剧",
|
||||
"refresh": "刷新放送表",
|
||||
"no_data": "暂无放送数据",
|
||||
"loading": "加载中...",
|
||||
"tracked": "已追踪",
|
||||
"tracked_count": "已追踪 {count} 部",
|
||||
@@ -899,6 +902,7 @@
|
||||
"update": "更新",
|
||||
"install_enable": "安装并启用",
|
||||
"back": "返回",
|
||||
"not_found": "目录中没有这个插件",
|
||||
"confirm_install": "安装 {name}",
|
||||
"confirm_update": "更新 {name}",
|
||||
"warning_lead": "注意",
|
||||
|
||||
@@ -18,13 +18,25 @@ const { comp: EditRule, shown: editRuleShown } = useLazyModal(
|
||||
);
|
||||
|
||||
// 插件页面的标题取自清单(清单到达前留空,避免闪出路由名 Plugin;清单已到达
|
||||
// 或加载失败仍无匹配页面时回退为插件 id);插件市场用翻译后的标题,其它页面沿用路由名
|
||||
// 或加载失败仍无匹配页面时回退为插件 id);其它页面用与侧栏相同的翻译,未登记的路由回退为路由名
|
||||
const route = useRoute();
|
||||
const { t, lang } = useMyI18n();
|
||||
const { slots, loaded, loadFailed } = usePluginUi();
|
||||
const titleKeys: Record<string, string> = {
|
||||
Market: 'market.title',
|
||||
'Bangumi List': 'sidebar.homepage',
|
||||
'Bangumi Torrents': 'homepage.torrents.title',
|
||||
'Orphan Torrents': 'homepage.others.title',
|
||||
Calendar: 'sidebar.calendar',
|
||||
RSS: 'sidebar.rss',
|
||||
Player: 'sidebar.player',
|
||||
Downloader: 'sidebar.downloader',
|
||||
Log: 'sidebar.log',
|
||||
Config: 'sidebar.config',
|
||||
};
|
||||
const pageTitle = computed(() => {
|
||||
const name = String(route.name ?? '');
|
||||
if (name === 'Market') return t('market.title');
|
||||
if (titleKeys[name]) return t(titleKeys[name]);
|
||||
if (route.name !== 'Plugin') return name;
|
||||
const id = route.params.id;
|
||||
const ui = slots.value.find((s) => s.slot === 'page' && s.plugin_id === id);
|
||||
@@ -68,7 +80,10 @@ function onApplyRule(rule: BangumiRule) {
|
||||
<ab-sidebar />
|
||||
|
||||
<div id="main-content" class="layout-content">
|
||||
<ab-page-title :title="pageTitle" />
|
||||
<!-- 标题与内容用同一个 page 过渡,切换路由时一起淡出淡入 -->
|
||||
<transition name="page" mode="out-in">
|
||||
<ab-page-title :key="pageTitle" :title="pageTitle" />
|
||||
</transition>
|
||||
|
||||
<RouterView v-slot="{ Component }">
|
||||
<transition name="page" mode="out-in">
|
||||
|
||||
@@ -28,12 +28,22 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 标题用番剧名;番剧列表尚未加载(直接打开本页)时回退为 #id。
|
||||
// 标题单独计算,列表加载后只更新标题,不重建 loadFn 等回调
|
||||
const { bangumi } = storeToRefs(useBangumiStore());
|
||||
const title = computed(() => {
|
||||
const id = bangumiId.value;
|
||||
const name = bangumi.value.find((b) => b.id === id)?.official_title;
|
||||
return name
|
||||
? `${name} · ${t('homepage.torrents.title')}`
|
||||
: `${t('homepage.torrents.title')} #${id}`;
|
||||
});
|
||||
|
||||
// id 收窄后一次性构造 props,切换参数时 :key 触发重新挂载并加载
|
||||
const pageProps = computed(() => {
|
||||
const id = bangumiId.value;
|
||||
if (id === null) return null;
|
||||
return {
|
||||
title: `${t('homepage.torrents.title')} #${id}`,
|
||||
loadFn: () => apiBangumi.getTorrents(id),
|
||||
deleteOne: (torrentId: number) => apiBangumi.deleteTorrent(id, torrentId),
|
||||
deleteAll: () => apiBangumi.deleteAllTorrents(id),
|
||||
@@ -42,5 +52,5 @@ const pageProps = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AbTorrentListPage v-if="pageProps" :key="bangumiId ?? -1" v-bind="pageProps" />
|
||||
<AbTorrentListPage v-if="pageProps" :key="bangumiId ?? -1" v-bind="pageProps" :title="title" />
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { ChevronDown } from '@lucide/vue';
|
||||
import type { BangumiRule } from '#/bangumi';
|
||||
import PluginSlot from '@/components/plugin-slot.vue';
|
||||
import { useUiSlots } from '@/hooks/usePluginUi';
|
||||
@@ -117,6 +118,21 @@ function onRuleSelect(rule: BangumiRule) {
|
||||
function groupNeedsReview(group: BangumiGroup): boolean {
|
||||
return group.rules.some((r) => r.needs_review);
|
||||
}
|
||||
|
||||
// 展开后归档内容在视口外:高度过渡结束(可滚动范围已够)后把折叠栏滚到顶部
|
||||
const archivedHeader = useTemplateRef<HTMLButtonElement>('archivedHeader');
|
||||
function onArchivedExpanded(e: TransitionEvent) {
|
||||
if (e.target !== e.currentTarget || !showArchived.value) return;
|
||||
archivedHeader.value?.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// 离场卡片绝对定位脱离文档流(其余卡片才能 FLIP 移位),先钉住原来的盒子
|
||||
function pinLeaving(el: Element) {
|
||||
const node = el as HTMLElement;
|
||||
node.style.width = `${node.offsetWidth}px`;
|
||||
node.style.left = `${node.offsetLeft}px`;
|
||||
node.style.top = `${node.offsetTop}px`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -143,85 +159,49 @@ function groupNeedsReview(group: BangumiGroup): boolean {
|
||||
</div>
|
||||
|
||||
<!-- Failed first load — distinct from an empty library -->
|
||||
<div v-else-if="loadFailed && !hasLoaded" class="empty-guide load-failed">
|
||||
<div class="empty-guide-header">
|
||||
<div class="empty-guide-title">
|
||||
{{ $t('homepage.load_failed.title') }}
|
||||
</div>
|
||||
<div class="empty-guide-subtitle">
|
||||
{{ $t('homepage.load_failed.subtitle') }}
|
||||
</div>
|
||||
</div>
|
||||
<ab-button variant="primary" :loading="isLoading" @click="getAll">
|
||||
{{ $t('homepage.load_failed.retry') }}
|
||||
</ab-button>
|
||||
<div v-else-if="loadFailed && !hasLoaded" class="page-empty">
|
||||
<ab-empty
|
||||
:title="$t('homepage.load_failed.title')"
|
||||
:description="$t('homepage.load_failed.subtitle')"
|
||||
>
|
||||
<template #action>
|
||||
<ab-button variant="primary" :loading="isLoading" @click="getAll">
|
||||
{{ $t('homepage.load_failed.retry') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-empty>
|
||||
</div>
|
||||
|
||||
<!-- Empty state guide -->
|
||||
<div v-else-if="!bangumi || bangumi.length === 0" class="empty-guide">
|
||||
<div class="empty-guide-header anim-fade-in">
|
||||
<div class="empty-guide-title">
|
||||
{{ $t('homepage.empty.title') }}
|
||||
</div>
|
||||
<div class="empty-guide-subtitle">
|
||||
{{ $t('homepage.empty.subtitle') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-guide-steps">
|
||||
<div class="empty-guide-step anim-slide-up" style="--delay: 0.15s">
|
||||
<div class="empty-guide-step-number">
|
||||
1
|
||||
</div>
|
||||
<div class="empty-guide-step-content">
|
||||
<div class="empty-guide-step-title">
|
||||
{{ $t('homepage.empty.step1_title') }}
|
||||
</div>
|
||||
<div class="empty-guide-step-desc">
|
||||
{{ $t('homepage.empty.step1_desc') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-guide-step anim-slide-up" style="--delay: 0.3s">
|
||||
<div class="empty-guide-step-number">
|
||||
2
|
||||
</div>
|
||||
<div class="empty-guide-step-content">
|
||||
<div class="empty-guide-step-title">
|
||||
{{ $t('homepage.empty.step2_title') }}
|
||||
</div>
|
||||
<div class="empty-guide-step-desc">
|
||||
{{ $t('homepage.empty.step2_desc') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-guide-step anim-slide-up" style="--delay: 0.45s">
|
||||
<div class="empty-guide-step-number">
|
||||
3
|
||||
</div>
|
||||
<div class="empty-guide-step-content">
|
||||
<div class="empty-guide-step-title">
|
||||
{{ $t('homepage.empty.step3_title') }}
|
||||
</div>
|
||||
<div class="empty-guide-step-desc">
|
||||
{{ $t('homepage.empty.step3_desc') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-guide-action anim-slide-up" style="--delay: 0.6s">
|
||||
<ab-button variant="primary" @click="openAddRss">
|
||||
{{ $t('homepage.empty.add_rss_btn') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
<div v-else-if="!bangumi || bangumi.length === 0" class="page-empty">
|
||||
<ab-empty
|
||||
:title="$t('homepage.empty.title')"
|
||||
:description="$t('homepage.empty.subtitle')"
|
||||
>
|
||||
<template #action>
|
||||
<ol class="empty-steps">
|
||||
<li v-for="n in 3" :key="n">
|
||||
<span class="empty-steps-title">{{
|
||||
$t(`homepage.empty.step${n}_title`)
|
||||
}}</span>
|
||||
{{ $t(`homepage.empty.step${n}_desc`) }}
|
||||
</li>
|
||||
</ol>
|
||||
<ab-button variant="primary" @click="openAddRss">
|
||||
{{ $t('homepage.empty.add_rss_btn') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-empty>
|
||||
</div>
|
||||
|
||||
<!-- Bangumi grid -->
|
||||
<template v-else>
|
||||
<transition-group name="bangumi" tag="div" class="bangumi-grid">
|
||||
<transition-group
|
||||
name="bangumi"
|
||||
tag="div"
|
||||
class="bangumi-grid"
|
||||
@before-leave="pinLeaving"
|
||||
>
|
||||
<div
|
||||
v-for="group in groupedBangumi"
|
||||
:key="group.key"
|
||||
@@ -233,20 +213,13 @@ function groupNeedsReview(group: BangumiGroup): boolean {
|
||||
type="primary"
|
||||
@click="() => onCardClick(group)"
|
||||
/>
|
||||
<!-- Combined notification badge -->
|
||||
<!-- Combined notification badge:待审核 = 方块标记,多规则 = 墨色计数 -->
|
||||
<div
|
||||
v-if="groupNeedsReview(group) || group.rules.length > 1"
|
||||
class="group-badge"
|
||||
:class="{ 'group-badge--warning': groupNeedsReview(group) }"
|
||||
>
|
||||
<template v-if="groupNeedsReview(group)">
|
||||
<span class="badge-icon">!</span>
|
||||
<template v-if="group.rules.length > 1">
|
||||
<span class="badge-divider" />
|
||||
<span class="badge-count">{{ group.rules.length }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="groupNeedsReview(group)" class="badge-mark" />
|
||||
<template v-if="group.rules.length > 1">
|
||||
{{ group.rules.length }}
|
||||
</template>
|
||||
</div>
|
||||
@@ -277,6 +250,7 @@ function groupNeedsReview(group: BangumiGroup): boolean {
|
||||
<!-- Archived section -->
|
||||
<div v-if="groupedArchivedBangumi.length > 0" class="archived-section">
|
||||
<button
|
||||
ref="archivedHeader"
|
||||
type="button"
|
||||
class="archived-header"
|
||||
:aria-expanded="showArchived"
|
||||
@@ -289,49 +263,55 @@ function groupNeedsReview(group: BangumiGroup): boolean {
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span class="archived-toggle" aria-hidden="true">{{
|
||||
showArchived ? '−' : '+'
|
||||
}}</span>
|
||||
<ChevronDown
|
||||
class="archived-toggle"
|
||||
:class="{ 'archived-toggle--open': showArchived }"
|
||||
:size="16"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<transition-group
|
||||
v-show="showArchived"
|
||||
name="bangumi"
|
||||
tag="div"
|
||||
class="bangumi-grid archived-grid"
|
||||
<!-- grid-template-rows 0fr↔1fr 做高度过渡;收起时 inert,卡片不可聚焦 -->
|
||||
<div
|
||||
class="archived-body"
|
||||
:class="{ 'archived-body--open': showArchived }"
|
||||
:inert="!showArchived"
|
||||
@transitionend="onArchivedExpanded"
|
||||
>
|
||||
<div
|
||||
v-for="group in groupedArchivedBangumi"
|
||||
:key="group.key"
|
||||
class="bangumi-group-wrapper archived-item"
|
||||
>
|
||||
<ab-bangumi-card
|
||||
:bangumi="group.primary"
|
||||
type="primary"
|
||||
@click="() => onCardClick(group)"
|
||||
/>
|
||||
<!-- Combined notification badge -->
|
||||
<div
|
||||
v-if="groupNeedsReview(group) || group.rules.length > 1"
|
||||
class="group-badge"
|
||||
:class="{ 'group-badge--warning': groupNeedsReview(group) }"
|
||||
<!-- 裁剪层不带 padding:网格的 12px padding 不会在收起时撑出 24px 高度 -->
|
||||
<div class="archived-clip">
|
||||
<transition-group
|
||||
name="bangumi"
|
||||
tag="div"
|
||||
class="bangumi-grid archived-grid"
|
||||
@before-leave="pinLeaving"
|
||||
>
|
||||
<template v-if="groupNeedsReview(group)">
|
||||
<span class="badge-icon">!</span>
|
||||
<template v-if="group.rules.length > 1">
|
||||
<span class="badge-divider" />
|
||||
<span class="badge-count">{{ group.rules.length }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ group.rules.length }}
|
||||
</template>
|
||||
</div>
|
||||
<div class="archived-badge">
|
||||
{{ $t('homepage.rule.archived') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="group in groupedArchivedBangumi"
|
||||
:key="group.key"
|
||||
class="bangumi-group-wrapper archived-item"
|
||||
>
|
||||
<ab-bangumi-card
|
||||
:bangumi="group.primary"
|
||||
type="primary"
|
||||
@click="() => onCardClick(group)"
|
||||
/>
|
||||
<div
|
||||
v-if="groupNeedsReview(group) || group.rules.length > 1"
|
||||
class="group-badge"
|
||||
>
|
||||
<span v-if="groupNeedsReview(group)" class="badge-mark" />
|
||||
<template v-if="group.rules.length > 1">
|
||||
{{ group.rules.length }}
|
||||
</template>
|
||||
</div>
|
||||
<div class="archived-badge">
|
||||
{{ $t('homepage.rule.archived') }}
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -355,8 +335,11 @@ function groupNeedsReview(group: BangumiGroup): boolean {
|
||||
@click="onRuleSelect(rule)"
|
||||
>
|
||||
<div class="rule-list-item-info">
|
||||
<div class="rule-list-item-title">
|
||||
<span v-if="rule.needs_review" class="warning-text">! </span>{{
|
||||
<div
|
||||
class="rule-list-item-title"
|
||||
:title="rule.needs_review ? $t('homepage.rule.needs_review') : undefined"
|
||||
>
|
||||
{{
|
||||
rule.group_name
|
||||
|| rule.rule_name
|
||||
|| $t('homepage.rule.unnamed')
|
||||
@@ -412,7 +395,8 @@ function groupNeedsReview(group: BangumiGroup): boolean {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
justify-items: center;
|
||||
// 离场卡片绝对定位时以网格为参照(见 pinLeaving)
|
||||
position: relative;
|
||||
|
||||
@include forTablet {
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
@@ -427,7 +411,7 @@ function groupNeedsReview(group: BangumiGroup): boolean {
|
||||
|
||||
// Skeleton loading cards
|
||||
.skeleton-card {
|
||||
width: 150px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skeleton-poster {
|
||||
@@ -470,7 +454,7 @@ function groupNeedsReview(group: BangumiGroup): boolean {
|
||||
|
||||
// Others card for orphan torrents
|
||||
.others-card {
|
||||
width: 150px;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
@@ -538,9 +522,15 @@ function groupNeedsReview(group: BangumiGroup): boolean {
|
||||
.bangumi-group-wrapper {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
width: fit-content;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// 离场卡片脱离文档流,其余卡片才会触发 .bangumi-move 位移(盒子由 pinLeaving 钉住)
|
||||
.bangumi-grid > .bangumi-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
// Soft Ink:surface 底 + 墨色计数,待审核用语义色方块标记,不用彩色填充/光晕
|
||||
.group-badge {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
@@ -548,55 +538,41 @@ function groupNeedsReview(group: BangumiGroup): boolean {
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 10px;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
gap: 5px;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2px 6px rgba(124, 77, 255, 0.4);
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
// Warning variant - yellow with purple border
|
||||
&--warning {
|
||||
.badge-mark {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-warning);
|
||||
border: 2px solid var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
box-shadow: 0 2px 8px rgba(251, 191, 36, 0.5);
|
||||
}
|
||||
|
||||
.badge-icon {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.badge-divider {
|
||||
width: 1px;
|
||||
height: 10px;
|
||||
background: currentColor;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.badge-count {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.archived-section {
|
||||
margin-top: 24px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
// 左右 padding 与 .bangumi-grid 的 12px 一致,标题文字与海报共用左边线
|
||||
.archived-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 12px 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
@@ -621,9 +597,28 @@ function groupNeedsReview(group: BangumiGroup): boolean {
|
||||
}
|
||||
|
||||
.archived-toggle {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-secondary);
|
||||
transition: transform var(--transition-normal);
|
||||
|
||||
&--open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.archived-body {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows var(--transition-normal);
|
||||
|
||||
> .archived-clip {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&--open {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.archived-grid {
|
||||
@@ -688,22 +683,9 @@ function groupNeedsReview(group: BangumiGroup): boolean {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
// Warning variant - needs review
|
||||
// 待审核:内阴影画左侧标记,不改变盒宽(与通知中心未读项同一写法)
|
||||
&--warning {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(251, 191, 36, 0.15) 0%,
|
||||
rgba(251, 191, 36, 0.08) 100%
|
||||
);
|
||||
border-left: 3px solid var(--color-warning);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(251, 191, 36, 0.22) 0%,
|
||||
rgba(251, 191, 36, 0.12) 100%
|
||||
);
|
||||
}
|
||||
box-shadow: inset 3px 0 0 var(--color-warning);
|
||||
}
|
||||
|
||||
// Add spacing between items
|
||||
@@ -712,11 +694,6 @@ function groupNeedsReview(group: BangumiGroup): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: #d97706;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rule-list-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -773,117 +750,8 @@ function groupNeedsReview(group: BangumiGroup): boolean {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.empty-guide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.empty-guide-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.empty-guide-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.empty-guide-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty-guide-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty-guide-action {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.empty-guide-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
transition: background-color var(--transition-normal),
|
||||
border-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.empty-guide-step-number {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-white);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-guide-step-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.empty-guide-step-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.empty-guide-step-desc {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.anim-fade-in {
|
||||
animation: fadeIn 0.5s ease both;
|
||||
}
|
||||
|
||||
.anim-slide-up {
|
||||
animation: slideUp 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.page-empty {
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -898,4 +766,7 @@ function groupNeedsReview(group: BangumiGroup): boolean {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
.bangumi-move {
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -126,7 +126,7 @@ function onRuleSelect(rule: BangumiRule) {
|
||||
<template>
|
||||
<div class="page-calendar">
|
||||
<!-- Header -->
|
||||
<div class="calendar-header anim-fade-in">
|
||||
<div class="calendar-header calendar-enter">
|
||||
<!-- 页面本身已有 h1 “Calendar”,这里不再重复标题,只留说明文字 -->
|
||||
<div class="calendar-header-text">
|
||||
<p class="calendar-subtitle">
|
||||
@@ -134,7 +134,6 @@ function onRuleSelect(rule: BangumiRule) {
|
||||
</p>
|
||||
</div>
|
||||
<ab-icon-button
|
||||
class="calendar-refresh-btn"
|
||||
:class="{ 'calendar-refresh-btn--spinning': refreshing }"
|
||||
:disabled="refreshing"
|
||||
:label="$t('calendar.refresh')"
|
||||
@@ -145,16 +144,18 @@ function onRuleSelect(rule: BangumiRule) {
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="!hasBangumi" class="empty-guide">
|
||||
<div class="empty-guide-header anim-fade-in">
|
||||
<div class="empty-guide-title">
|
||||
{{ $t('calendar.empty_state.title') }}
|
||||
</div>
|
||||
<div class="empty-guide-subtitle">
|
||||
{{ $t('calendar.empty_state.subtitle') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ab-empty
|
||||
v-if="!hasBangumi"
|
||||
class="empty-guide calendar-enter"
|
||||
:title="$t('calendar.empty_state.title')"
|
||||
:description="$t('calendar.empty_state.subtitle')"
|
||||
>
|
||||
<template #action>
|
||||
<ab-button size="sm" :loading="refreshing" @click="refreshCalendar">
|
||||
{{ $t('calendar.refresh') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-empty>
|
||||
|
||||
<!-- Desktop: board of day columns -->
|
||||
<calendar-board
|
||||
@@ -173,7 +174,6 @@ function onRuleSelect(rule: BangumiRule) {
|
||||
:grouped-by-day="groupedBangumiByDay"
|
||||
:today-index="todayIndex"
|
||||
:get-day-label="getDayLabel"
|
||||
:has-bangumi="hasBangumi"
|
||||
@card-click="onCardClick"
|
||||
/>
|
||||
|
||||
@@ -209,41 +209,8 @@ function onRuleSelect(rule: BangumiRule) {
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.calendar-refresh-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
@include forTouch {
|
||||
width: var(--touch-target);
|
||||
height: var(--touch-target);
|
||||
}
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), border-color var(--transition-fast),
|
||||
background-color var(--transition-fast);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--spinning {
|
||||
:deep(svg) {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
.calendar-refresh-btn--spinning :deep(svg) {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
@@ -254,53 +221,24 @@ function onRuleSelect(rule: BangumiRule) {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
// Empty state (reuse pattern from bangumi page)
|
||||
.empty-guide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 40vh;
|
||||
padding: 24px;
|
||||
<style lang="scss">
|
||||
// 日历页统一进场:标题、星期列、移动端分区共用(子组件根元素加 calendar-enter + --delay)
|
||||
.calendar-enter {
|
||||
animation: calendar-enter var(--transition-slow) both;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
.empty-guide-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-guide-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 6px;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.empty-guide-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
// Animations
|
||||
.anim-fade-in {
|
||||
animation: fadeIn 0.5s ease both;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
@keyframes calendar-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.anim-fade-in {
|
||||
.calendar-enter {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,9 +282,19 @@ function endProgrammaticScroll() {
|
||||
scrollTimer = null;
|
||||
}
|
||||
|
||||
// 程序化滚动静止 150ms 后才恢复 scroll-spy(平滑滚动时长不固定,长距离可超过 600ms)
|
||||
function armScrollTimer() {
|
||||
if (scrollTimer) clearTimeout(scrollTimer);
|
||||
scrollTimer = setTimeout(endProgrammaticScroll, 150);
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
const container = scrollEl.value;
|
||||
if (!container || isProgrammaticScroll) return;
|
||||
if (!container) return;
|
||||
if (isProgrammaticScroll) {
|
||||
armScrollTimer();
|
||||
return;
|
||||
}
|
||||
const containerTop = container.getBoundingClientRect().top;
|
||||
const tops = visibleSections.value.flatMap(({ id }) => {
|
||||
const el = sectionEls.get(id);
|
||||
@@ -301,9 +311,8 @@ function jumpTo(id: string) {
|
||||
if (!el) return;
|
||||
activeSection.value = id;
|
||||
isProgrammaticScroll = true;
|
||||
// scrollend 不可用(Safari)或未触发时,由定时器兜底解除
|
||||
if (scrollTimer) clearTimeout(scrollTimer);
|
||||
scrollTimer = setTimeout(endProgrammaticScroll, 600);
|
||||
// scrollend 不可用(Safari)或根本没有滚动时,由静止定时器兜底解除
|
||||
armScrollTimer();
|
||||
scrollEl.value?.addEventListener('scrollend', endProgrammaticScroll, {
|
||||
once: true,
|
||||
});
|
||||
@@ -456,6 +465,11 @@ onBeforeRouteLeave(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
// 保存栏与内容列同宽:rail 190 + gap 16 + 内容 860
|
||||
@include forDesktop {
|
||||
max-width: calc(190px + 16px + 860px);
|
||||
}
|
||||
}
|
||||
|
||||
.config-layout {
|
||||
@@ -517,7 +531,7 @@ onBeforeRouteLeave(() => {
|
||||
.dirty-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: var(--radius-full);
|
||||
border-radius: 2px;
|
||||
background: var(--color-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -546,9 +560,9 @@ onBeforeRouteLeave(() => {
|
||||
.config-no-match {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 32px 16px;
|
||||
padding: 16px 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -182,15 +182,45 @@ function onCheckedChange(keys: DataTableRowKey[]) {
|
||||
// 多个下载器实例时标出每个种子所在的实例
|
||||
const downloaders = useDownloaderInstances();
|
||||
|
||||
const tableColumnsValue = computed<DataTableColumns<Row>>(() => [
|
||||
{
|
||||
type: 'selection',
|
||||
},
|
||||
{
|
||||
const { isMobile, isTablet } = useBreakpointQuery();
|
||||
|
||||
function progressCell(row: QbTorrentInfo) {
|
||||
return h(AbProgress, {
|
||||
value: row.progress * 100,
|
||||
label: `${Math.round(row.progress * 100)}%`,
|
||||
state:
|
||||
row.state === 'error' || row.state === 'missingFiles' ? 'error' : 'active',
|
||||
});
|
||||
}
|
||||
|
||||
function stateCell(row: QbTorrentInfo) {
|
||||
return h(AbTag, { type: stateType(row.state), title: stateLabel(row.state) });
|
||||
}
|
||||
|
||||
// 数据列:等宽数字右对齐,小数点与单位上下对齐
|
||||
const numCol = { align: 'right', className: 'num-col' } as const;
|
||||
|
||||
// 手机只留勾选与名称列,进度/状态/大小收进名称下方一行
|
||||
function mobileNameCell(row: QbTorrentInfo) {
|
||||
return h('div', { class: 'torrent-cell' }, [
|
||||
h('div', { class: 'torrent-name' }, row.name),
|
||||
h('div', { class: 'torrent-meta' }, [
|
||||
progressCell(row),
|
||||
stateCell(row),
|
||||
h('span', { class: 'num-col' }, formatSize(row.size)),
|
||||
downloaders.multiple.value
|
||||
? h('span', { class: 'num-col torrent-instance' }, row.downloader_id)
|
||||
: null,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
const tableColumnsValue = computed<DataTableColumns<Row>>(() => {
|
||||
const nameCol: DataTableColumns<Row>[number] = {
|
||||
title: t('downloader.torrent.name'),
|
||||
key: 'name',
|
||||
// CSS 省略号 + title:不用逐行测量宽度的 tooltip
|
||||
ellipsis: true,
|
||||
ellipsis: !isMobile.value,
|
||||
className: 'name-col',
|
||||
minWidth: 200,
|
||||
colSpan: (row: Row): number =>
|
||||
@@ -198,158 +228,124 @@ const tableColumnsValue = computed<DataTableColumns<Row>>(() => [
|
||||
cellProps: (row) => ({
|
||||
title: isGroupRow(row) ? row.group.savePath : row.name,
|
||||
}),
|
||||
render: (row) =>
|
||||
isGroupRow(row) ? `${row.group.name} (${row.group.count})` : row.name,
|
||||
},
|
||||
{
|
||||
title: t('downloader.torrent.progress'),
|
||||
key: 'progress',
|
||||
width: 160,
|
||||
render: torrentCell((row) => {
|
||||
return h(AbProgress, {
|
||||
value: row.progress * 100,
|
||||
label: `${Math.round(row.progress * 100)}%`,
|
||||
state:
|
||||
row.state === 'error' || row.state === 'missingFiles'
|
||||
? 'error'
|
||||
: 'active',
|
||||
});
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t('downloader.torrent.status'),
|
||||
key: 'state',
|
||||
width: 130,
|
||||
render: torrentCell((row) => {
|
||||
return h(AbTag, {
|
||||
type: stateType(row.state),
|
||||
title: stateLabel(row.state),
|
||||
});
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t('downloader.torrent.size'),
|
||||
key: 'size',
|
||||
width: 100,
|
||||
render: torrentCell((row) => {
|
||||
return formatSize(row.size);
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t('downloader.torrent.dlspeed'),
|
||||
key: 'dlspeed',
|
||||
width: 110,
|
||||
render: torrentCell((row) => {
|
||||
return formatSpeed(row.dlspeed);
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t('downloader.torrent.upspeed'),
|
||||
key: 'upspeed',
|
||||
width: 110,
|
||||
render: torrentCell((row) => {
|
||||
return formatSpeed(row.upspeed);
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: 'ETA',
|
||||
key: 'eta',
|
||||
width: 80,
|
||||
render: torrentCell((row) => {
|
||||
return formatEta(row.eta);
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: t('downloader.torrent.peers'),
|
||||
key: 'peers',
|
||||
width: 110,
|
||||
render: torrentCell((row) => {
|
||||
return `${row.num_seeds} / ${row.num_leechs}`;
|
||||
}),
|
||||
},
|
||||
// 放在最后并完整显示 id(触屏没有悬停提示)
|
||||
...(downloaders.multiple.value
|
||||
? [
|
||||
{
|
||||
title: t('downloader.torrent.instance'),
|
||||
key: 'downloader_id',
|
||||
minWidth: 110,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
render: (row) => {
|
||||
if (!isGroupRow(row)) return isMobile.value ? mobileNameCell(row) : row.name;
|
||||
const label = `${row.group.name} (${row.group.count})`;
|
||||
// 手机关掉了列省略号,分组名自己截断
|
||||
return isMobile.value
|
||||
? h('span', { class: 'torrent-cell' }, h('span', { class: 'torrent-name' }, label))
|
||||
: label;
|
||||
},
|
||||
};
|
||||
if (isMobile.value) return [{ type: 'selection' }, nameCol];
|
||||
|
||||
return [
|
||||
{ type: 'selection' },
|
||||
nameCol,
|
||||
{
|
||||
title: t('downloader.torrent.progress'),
|
||||
key: 'progress',
|
||||
width: 140,
|
||||
render: torrentCell(progressCell),
|
||||
},
|
||||
{
|
||||
title: t('downloader.torrent.status'),
|
||||
key: 'state',
|
||||
width: 130,
|
||||
render: torrentCell(stateCell),
|
||||
},
|
||||
{
|
||||
title: t('downloader.torrent.size'),
|
||||
key: 'size',
|
||||
width: 100,
|
||||
...numCol,
|
||||
render: torrentCell((row) => formatSize(row.size)),
|
||||
},
|
||||
{
|
||||
title: t('downloader.torrent.dlspeed'),
|
||||
key: 'dlspeed',
|
||||
width: 110,
|
||||
...numCol,
|
||||
render: torrentCell((row) => formatSpeed(row.dlspeed)),
|
||||
},
|
||||
// 平板宽度不够:去掉上传速度、ETA、做种/下载
|
||||
...(isTablet.value
|
||||
? []
|
||||
: [
|
||||
{
|
||||
title: t('downloader.torrent.upspeed'),
|
||||
key: 'upspeed',
|
||||
width: 110,
|
||||
...numCol,
|
||||
render: torrentCell((row) => formatSpeed(row.upspeed)),
|
||||
},
|
||||
{
|
||||
title: 'ETA',
|
||||
key: 'eta',
|
||||
width: 72,
|
||||
...numCol,
|
||||
render: torrentCell((row) => formatEta(row.eta)),
|
||||
},
|
||||
{
|
||||
title: t('downloader.torrent.peers'),
|
||||
key: 'peers',
|
||||
width: 110,
|
||||
...numCol,
|
||||
render: torrentCell((row) => `${row.num_seeds} / ${row.num_leechs}`),
|
||||
},
|
||||
]),
|
||||
// 多个下载器实例时放在最后;定宽,名称列是唯一的弹性列
|
||||
...(downloaders.multiple.value
|
||||
? [
|
||||
{
|
||||
title: t('downloader.torrent.instance'),
|
||||
key: 'downloader_id',
|
||||
width: 130,
|
||||
ellipsis: true,
|
||||
cellProps: (row: Row) => ({
|
||||
title: isGroupRow(row) ? undefined : row.downloader_id,
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
});
|
||||
|
||||
// 表格最小宽度 = 各列定宽 + 名称列下限:WebKit 在 table-layout: fixed 下忽略
|
||||
// <col> 的 min-width,不设 scroll-x 时名称列会被算成 0
|
||||
const tableMinWidth = computed(() =>
|
||||
tableColumnsValue.value.reduce((sum, col) => {
|
||||
if (col.type === 'selection') return sum + 40;
|
||||
const c = col as { width?: number; minWidth?: number };
|
||||
return sum + (c.width ?? c.minWidth ?? 0);
|
||||
}, 0)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-downloader">
|
||||
<div v-if="isNull" class="empty-guide">
|
||||
<div class="empty-guide-header anim-fade-in">
|
||||
<div class="empty-guide-title">
|
||||
{{ $t('downloader.empty.title') }}
|
||||
</div>
|
||||
<div class="empty-guide-subtitle">
|
||||
{{ $t('downloader.empty.subtitle') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-guide-steps">
|
||||
<div class="empty-guide-step anim-slide-up" style="--delay: 0.15s">
|
||||
<div class="empty-guide-step-number">
|
||||
1
|
||||
</div>
|
||||
<div class="empty-guide-step-content">
|
||||
<div class="empty-guide-step-title">
|
||||
{{ $t('downloader.empty.step1_title') }}
|
||||
</div>
|
||||
<div class="empty-guide-step-desc">
|
||||
{{ $t('downloader.empty.step1_desc') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-guide-step anim-slide-up" style="--delay: 0.3s">
|
||||
<div class="empty-guide-step-number">
|
||||
2
|
||||
</div>
|
||||
<div class="empty-guide-step-content">
|
||||
<div class="empty-guide-step-title">
|
||||
{{ $t('downloader.empty.step2_title') }}
|
||||
</div>
|
||||
<div class="empty-guide-step-desc">
|
||||
{{ $t('downloader.empty.step2_desc') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-guide-step anim-slide-up" style="--delay: 0.45s">
|
||||
<div class="empty-guide-step-number">
|
||||
3
|
||||
</div>
|
||||
<div class="empty-guide-step-content">
|
||||
<div class="empty-guide-step-title">
|
||||
{{ $t('downloader.empty.step3_title') }}
|
||||
</div>
|
||||
<div class="empty-guide-step-desc">
|
||||
{{ $t('downloader.empty.step3_desc') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RouterLink
|
||||
to="/config"
|
||||
class="empty-guide-action anim-slide-up"
|
||||
style="--delay: 0.6s"
|
||||
<div v-if="isNull" class="page-empty">
|
||||
<ab-empty
|
||||
:title="$t('downloader.empty.title')"
|
||||
:description="$t('downloader.empty.subtitle')"
|
||||
>
|
||||
{{ $t('sidebar.config') }}
|
||||
</RouterLink>
|
||||
<template #action>
|
||||
<ol class="empty-steps">
|
||||
<li v-for="n in 3" :key="n">
|
||||
<span class="empty-steps-title">{{
|
||||
$t(`downloader.empty.step${n}_title`)
|
||||
}}</span>
|
||||
{{ $t(`downloader.empty.step${n}_desc`) }}
|
||||
</li>
|
||||
</ol>
|
||||
<ab-button variant="primary" @click="$router.push('/config')">
|
||||
{{ $t('sidebar.config') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-empty>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="downloader-content"
|
||||
:class="{ 'has-selection': selectedKeys.size > 0 }"
|
||||
>
|
||||
<div v-else class="downloader-content">
|
||||
<div v-if="groups.length === 0 && !loading" class="downloader-empty">
|
||||
{{ $t('downloader.empty_torrents') }}
|
||||
</div>
|
||||
@@ -366,6 +362,7 @@ const tableColumnsValue = computed<DataTableColumns<Row>>(() => [
|
||||
:bordered="false"
|
||||
:checked-row-keys="checkedKeys"
|
||||
:expanded-row-keys="expandedKeys"
|
||||
:scroll-x="tableMinWidth"
|
||||
virtual-scroll
|
||||
flex-height
|
||||
size="small"
|
||||
@@ -373,7 +370,8 @@ const tableColumnsValue = computed<DataTableColumns<Row>>(() => [
|
||||
@update:expanded-row-keys="onExpandedChange"
|
||||
/>
|
||||
|
||||
<Transition name="fade">
|
||||
<!-- 操作栏在内容列底部占位(不 fixed):不遮表格、不压底栏,无需预留 padding -->
|
||||
<Transition name="action-bar">
|
||||
<div v-if="selectedKeys.size > 0" class="action-bar">
|
||||
<span class="action-bar-count">
|
||||
{{ selectedKeys.size }} {{ $t('downloader.selected') }}
|
||||
@@ -416,14 +414,6 @@ const tableColumnsValue = computed<DataTableColumns<Row>>(() => [
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 12px;
|
||||
padding-bottom: 60px;
|
||||
|
||||
// 手机端操作栏为两行布局,留出其高度,避免遮住最后几行
|
||||
&.has-selection {
|
||||
@media (width < 640px) {
|
||||
padding-bottom: 104px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flex-height:表格填满页面剩余高度,虚拟滚动在表体内进行
|
||||
@@ -443,6 +433,59 @@ const tableColumnsValue = computed<DataTableColumns<Row>>(() => [
|
||||
:deep(.name-col .n-data-table-td__ellipsis) {
|
||||
max-width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
:deep(.num-col) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 列 className 同时加在 th 上:只给单元格用等宽字体,表头与其他列一致
|
||||
:deep(td.num-col),
|
||||
:deep(.torrent-meta .num-col) {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// 手机名称格:名称一行省略,下方一行进度/状态/大小
|
||||
:deep(.torrent-cell) {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: calc(100% - 40px);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
:deep(.torrent-name) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// 进度条定宽:各行轨道等长、状态标签起点对齐;大小靠右,格子太窄时换到下一行
|
||||
:deep(.torrent-meta) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
.ab-progress {
|
||||
flex: 0 0 80px;
|
||||
}
|
||||
|
||||
> .num-col {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
// 多实例时实例 id 独占一行,过长省略
|
||||
> .torrent-instance {
|
||||
flex-basis: 100%;
|
||||
min-width: 0;
|
||||
margin-left: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.downloader-empty {
|
||||
@@ -455,26 +498,20 @@ const tableColumnsValue = computed<DataTableColumns<Row>>(() => [
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
position: fixed;
|
||||
bottom: calc(24px + env(safe-area-inset-bottom, 0px));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
max-width: 100%;
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
z-index: 100;
|
||||
max-width: calc(100vw - 32px);
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
@media (width < 640px) {
|
||||
bottom: calc(72px + env(safe-area-inset-bottom, 0px));
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
transform: none;
|
||||
align-self: stretch;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
@@ -500,143 +537,15 @@ const tableColumnsValue = computed<DataTableColumns<Row>>(() => [
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
.action-bar-enter-active,
|
||||
.action-bar-leave-active {
|
||||
transition: opacity var(--transition-normal),
|
||||
transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
.action-bar-enter-from,
|
||||
.action-bar-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(8px);
|
||||
|
||||
@media (width < 640px) {
|
||||
transform: translateY(8px);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-guide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.empty-guide-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.empty-guide-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.empty-guide-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty-guide-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty-guide-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
transition: background-color var(--transition-normal),
|
||||
border-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.empty-guide-step-number {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-guide-step-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.empty-guide-step-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.empty-guide-step-desc {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.empty-guide-action {
|
||||
margin-top: 24px;
|
||||
padding: 8px 24px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.anim-fade-in {
|
||||
animation: fadeIn 0.5s ease both;
|
||||
}
|
||||
|
||||
.anim-slide-up {
|
||||
animation: slideUp 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
transform: translateY(4px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -133,22 +133,22 @@ onDeactivated(() => {
|
||||
<div class="page-log">
|
||||
<div class="log-layout">
|
||||
<div class="log-main">
|
||||
<div
|
||||
<ab-alert
|
||||
v-if="hasProblems"
|
||||
class="log-health"
|
||||
:class="{ 'has-errors': errorCount > 0 }"
|
||||
role="status"
|
||||
>
|
||||
<b>{{
|
||||
:type="errorCount > 0 ? 'danger' : 'warning'"
|
||||
:title="
|
||||
$t('log.health_summary', {
|
||||
errors: errorCount,
|
||||
warnings: warningCount,
|
||||
})
|
||||
}}</b>
|
||||
<ab-button size="sm" @click="jumpToFirstProblem">
|
||||
{{ $t('log.jump_first') }} ↓
|
||||
</ab-button>
|
||||
</div>
|
||||
"
|
||||
>
|
||||
<template #action>
|
||||
<ab-button size="sm" @click="jumpToFirstProblem">
|
||||
{{ $t('log.jump_first') }} ↓
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-alert>
|
||||
|
||||
<div class="log-viewer-card">
|
||||
<div class="log-toolbar">
|
||||
@@ -297,27 +297,9 @@ onDeactivated(() => {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.log-health {
|
||||
display: flex;
|
||||
// 健康摘要只有一行引导词:与右侧按钮垂直居中
|
||||
.log-main > :deep(.ab-alert) {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-warning-border);
|
||||
background: var(--color-warning-bg);
|
||||
color: var(--color-warning-text);
|
||||
font-size: 13px;
|
||||
|
||||
&.has-errors {
|
||||
border-color: color-mix(in srgb, var(--color-danger) 35%, transparent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-danger) 8%,
|
||||
var(--color-surface)
|
||||
);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.log-viewer-card {
|
||||
@@ -336,10 +318,13 @@ onDeactivated(() => {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
// 筛选项不换行:放不下时整组单独占一行并横向滚动,
|
||||
// 搜索框与操作按钮落到下一行,不出现 3+1 的零散换行
|
||||
.filter-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
@@ -355,10 +340,19 @@ onDeactivated(() => {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.log-search {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
max-width: 260px;
|
||||
// 与同行 sm 按钮等高(ab-input 默认 34px);窄屏时与操作按钮共用一行并占满剩余宽度
|
||||
.log-toolbar .log-search {
|
||||
flex: 1 1 100px;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
|
||||
@include forDesktop {
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
@include forTouch {
|
||||
height: var(--touch-target);
|
||||
}
|
||||
}
|
||||
|
||||
.log-actions {
|
||||
@@ -413,42 +407,36 @@ onDeactivated(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// 级别:方块语义标记 + 墨色文字(与 ab-tag 一致),不用着色填充
|
||||
.log-level {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 58px;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
gap: 6px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
&.level-info {
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-text-secondary);
|
||||
&::before {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
&.level-debug {
|
||||
&.level-debug::before {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--color-border-hover);
|
||||
color: var(--color-text-secondary);
|
||||
box-shadow: inset 0 0 0 1px var(--color-text-muted);
|
||||
}
|
||||
|
||||
&.level-warning {
|
||||
background: var(--color-warning-bg);
|
||||
border: 1px solid var(--color-warning-border);
|
||||
color: var(--color-warning-text);
|
||||
&.level-warning::before {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
&.level-error {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-danger) 12%,
|
||||
var(--color-surface)
|
||||
);
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger) 40%, transparent);
|
||||
color: var(--color-danger);
|
||||
&.level-error::before {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -136,8 +136,12 @@ function resetFilters() {
|
||||
}
|
||||
|
||||
// 选中项:/market/:id;桌面上没有 id 时默认显示列表第一项
|
||||
const routeId = computed(
|
||||
() => (route.params as Record<string, string | undefined>).id
|
||||
// 离场淡出时 useRoute() 已是下一个路由(如 /plugins/:id 也叫 id):
|
||||
// 只在本路由激活时取参数,否则保留上一个值
|
||||
const routeId = computed<string | undefined>((prev) =>
|
||||
route.name === 'Market'
|
||||
? (route.params as Record<string, string | undefined>).id
|
||||
: prev
|
||||
);
|
||||
const selected = computed(() => {
|
||||
const list = catalog.value ?? [];
|
||||
@@ -238,7 +242,10 @@ function permissionLabel(id: string) {
|
||||
<ab-icon-button :label="$t('market.back')" @click="back">
|
||||
<ChevronLeft :size="20" />
|
||||
</ab-icon-button>
|
||||
<h1 class="market__bar-title">
|
||||
<h1
|
||||
class="market__bar-title"
|
||||
:title="selected ? selected.name || selected.id : undefined"
|
||||
>
|
||||
{{ selected ? selected.name || selected.id : $t('market.title') }}
|
||||
</h1>
|
||||
</header>
|
||||
@@ -281,18 +288,24 @@ function permissionLabel(id: string) {
|
||||
v-model="state"
|
||||
class="market__state"
|
||||
:options="stateOptions"
|
||||
:consistent-menu-width="false"
|
||||
:aria-label="$t('market.state_filter')"
|
||||
/>
|
||||
<ab-segmented
|
||||
v-if="catalog?.length"
|
||||
v-model:value="category"
|
||||
size="sm"
|
||||
class="market__tabs"
|
||||
:options="categoryOptions"
|
||||
:aria-label="$t('market.categories')"
|
||||
/>
|
||||
<!-- 首次加载:给分类 Tab 留同高的位置,内容出现时不再下移 -->
|
||||
<div
|
||||
v-else-if="!catalog && loading"
|
||||
class="market__tabs market__tabs--placeholder"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<ab-segmented
|
||||
v-if="catalog?.length"
|
||||
v-model:value="category"
|
||||
size="sm"
|
||||
class="market__tabs"
|
||||
:options="categoryOptions"
|
||||
:aria-label="$t('market.categories')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ab-alert v-if="stale" type="warning" :title="$t('market.stale_lead')">
|
||||
@@ -305,7 +318,15 @@ function permissionLabel(id: string) {
|
||||
</template>
|
||||
</ab-alert>
|
||||
|
||||
<ab-skeleton v-if="!catalog && loading" preset="row" :count="5" />
|
||||
<!-- 骨架与最终的列表 + 详情双栏同形 -->
|
||||
<div v-if="!catalog && loading" class="market__panes">
|
||||
<div class="market__list">
|
||||
<ab-skeleton preset="row" :count="6" />
|
||||
</div>
|
||||
<div v-if="!isMobile" class="market__detail">
|
||||
<ab-skeleton preset="lines" :count="4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ab-empty
|
||||
v-else-if="!catalog && error !== null"
|
||||
@@ -322,6 +343,29 @@ function permissionLabel(id: string) {
|
||||
</template>
|
||||
</ab-empty>
|
||||
|
||||
<!-- 列表为空且没有选中的插件:单独的左对齐空状态,不嵌进双栏。
|
||||
路由带 id 时保留双栏,详情与进行中的安装确认不能消失 -->
|
||||
<template v-else-if="catalog && !visible.length && !routeId">
|
||||
<ab-empty
|
||||
v-if="query.trim()"
|
||||
:title="$t('market.no_match', { query: query.trim() })"
|
||||
>
|
||||
<template #action>
|
||||
<ab-button size="sm" variant="ghost" @click="query = ''">
|
||||
{{ $t('market.clear_search') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-empty>
|
||||
<ab-empty v-else-if="catalog.length" :title="$t('market.no_filter_match')">
|
||||
<template #action>
|
||||
<ab-button size="sm" variant="ghost" @click="resetFilters">
|
||||
{{ $t('market.show_all') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-empty>
|
||||
<ab-empty v-else :title="$t('market.empty')" />
|
||||
</template>
|
||||
|
||||
<div v-else-if="catalog" class="market__panes">
|
||||
<nav
|
||||
v-if="!showDetail"
|
||||
@@ -340,7 +384,7 @@ function permissionLabel(id: string) {
|
||||
"
|
||||
>
|
||||
<span class="row__name">{{ entry.name || entry.id }}</span>
|
||||
<span class="row__version">{{ entry.version }}</span>
|
||||
<span class="row__version" :title="entry.version">{{ entry.version }}</span>
|
||||
<span class="row__state">
|
||||
<span
|
||||
class="mark"
|
||||
@@ -351,32 +395,19 @@ function permissionLabel(id: string) {
|
||||
</span>
|
||||
<span class="row__author">{{ entry.authors.join(', ') }}</span>
|
||||
</RouterLink>
|
||||
|
||||
<ab-empty
|
||||
v-if="!visible.length && query.trim()"
|
||||
:title="$t('market.no_match', { query: query.trim() })"
|
||||
>
|
||||
<template #action>
|
||||
<ab-button size="sm" variant="ghost" @click="query = ''">
|
||||
{{ $t('market.clear_search') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-empty>
|
||||
<ab-empty
|
||||
v-else-if="!visible.length && catalog.length"
|
||||
:title="$t('market.no_filter_match')"
|
||||
>
|
||||
<template #action>
|
||||
<ab-button size="sm" variant="ghost" @click="resetFilters">
|
||||
{{ $t('market.show_all') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-empty>
|
||||
<ab-empty v-else-if="!catalog.length" :title="$t('market.empty')" />
|
||||
v-if="!visible.length"
|
||||
:title="
|
||||
query.trim()
|
||||
? $t('market.no_match', { query: query.trim() })
|
||||
: $t('market.no_filter_match')
|
||||
"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<section
|
||||
v-if="selected && (!isMobile || showDetail)"
|
||||
:key="confirming ? 'confirm' : selected.id"
|
||||
class="market__detail"
|
||||
>
|
||||
<MarketConfirm
|
||||
@@ -488,6 +519,19 @@ function permissionLabel(id: string) {
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="routeId && (!isMobile || showDetail)"
|
||||
class="market__detail"
|
||||
>
|
||||
<ab-empty :title="$t('market.not_found')" :description="routeId">
|
||||
<template #action>
|
||||
<ab-button size="sm" variant="ghost" @click="back">
|
||||
{{ $t('market.back') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-empty>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -503,6 +547,10 @@ function permissionLabel(id: string) {
|
||||
}
|
||||
|
||||
.market__tabs {
|
||||
grid-area: tabs;
|
||||
// 与原先在 header 外时一样,离搜索行 12px(header 行距 8px)
|
||||
margin-top: 4px;
|
||||
|
||||
@include forMobile {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
@@ -511,7 +559,7 @@ function permissionLabel(id: string) {
|
||||
// 选中下划线(margin-bottom:-1px),焦点框也被裁。上下留 4px 并用背景线画边框,
|
||||
// 负 margin 抵消留白,布局与边线位置不变。
|
||||
padding-block: 4px;
|
||||
margin-block: -4px -3px;
|
||||
margin-block: 0 -3px;
|
||||
border-bottom: none;
|
||||
background: linear-gradient(var(--color-border), var(--color-border))
|
||||
left bottom 3px / 100% 1px no-repeat;
|
||||
@@ -527,22 +575,42 @@ function permissionLabel(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 手机:标题 | 目录状态 / 搜索 | 状态 / 分类;
|
||||
// 平板以上:搜索 | 状态 / 分类 | 目录状态 —— 目录状态宽度变化不再推动搜索框与下拉
|
||||
.market__top {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'title meta'
|
||||
'search state';
|
||||
'search state'
|
||||
'tabs tabs';
|
||||
grid-template-columns: minmax(0, 1fr) 128px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
@include forTablet {
|
||||
grid-template-areas: 'search state meta';
|
||||
grid-template-columns: minmax(0, 1fr) 140px auto;
|
||||
grid-template-areas:
|
||||
'search search state'
|
||||
'tabs meta meta';
|
||||
grid-template-columns: minmax(0, 1fr) auto 140px;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// 与 ab-segmented 同高:标签行 27px + 底线(手机/平板由 .market__tabs 加上下 4px 留白)
|
||||
.market__tabs--placeholder {
|
||||
box-sizing: content-box;
|
||||
height: 27px;
|
||||
|
||||
// 桌面画 ab-segmented 的底线;手机/平板已由 .market__tabs 的背景线画出
|
||||
@include forDesktop {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@include forTouch {
|
||||
height: 35px;
|
||||
}
|
||||
}
|
||||
|
||||
.market__title,
|
||||
.market__bar-title {
|
||||
margin: 0;
|
||||
@@ -584,7 +652,6 @@ function permissionLabel(id: string) {
|
||||
}
|
||||
|
||||
.market__time {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -615,10 +682,13 @@ function permissionLabel(id: string) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// 长名称最多两行,完整名称在 title 提示里
|
||||
.market__bar-title {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
// 桌面:列表 + 详情双栏,各自滚动;手机:只显示其中一栏
|
||||
@@ -647,11 +717,17 @@ function permissionLabel(id: string) {
|
||||
@include forTablet {
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
// 手机上从详情返回时列表按 v-if 重新挂载:与进入详情同样的淡入上移
|
||||
@include forMobile {
|
||||
animation: detail-enter var(--transition-normal) both;
|
||||
}
|
||||
}
|
||||
|
||||
// 右列最多占 45%,名称至少保留一半以上
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-columns: minmax(0, 1fr) fit-content(45%);
|
||||
align-items: baseline;
|
||||
gap: 2px 10px;
|
||||
flex-shrink: 0;
|
||||
@@ -696,9 +772,13 @@ function permissionLabel(id: string) {
|
||||
}
|
||||
|
||||
.row__version {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row__state {
|
||||
@@ -747,6 +827,8 @@ function permissionLabel(id: string) {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
// 换插件 / 进入确认时按 key 重建:与路由切换同样的淡入上移
|
||||
animation: detail-enter var(--transition-normal) both;
|
||||
|
||||
@include forTablet {
|
||||
padding: 16px 20px 0;
|
||||
@@ -768,21 +850,28 @@ function permissionLabel(id: string) {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 650;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.detail__desc {
|
||||
margin: 2px 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.detail__kv {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(64px, max-content) 1fr;
|
||||
align-items: baseline;
|
||||
gap: 8px 14px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
|
||||
@include forMobile {
|
||||
grid-template-columns: minmax(64px, 7.5em) 1fr;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
@@ -888,5 +977,33 @@ function permissionLabel(id: string) {
|
||||
:deep(a) {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
:deep(table) {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
border-collapse: collapse;
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
:deep(th),
|
||||
:deep(td) {
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
:deep(th) {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes detail-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,67 +8,25 @@ const { url } = storeToRefs(usePlayerStore());
|
||||
|
||||
<template>
|
||||
<div class="page-embed">
|
||||
<div v-if="url === ''" class="empty-guide">
|
||||
<div class="empty-guide-header anim-fade-in">
|
||||
<div class="empty-guide-title">
|
||||
{{ $t('player.empty.title') }}
|
||||
</div>
|
||||
<div class="empty-guide-subtitle">
|
||||
{{ $t('player.empty.subtitle') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-guide-steps">
|
||||
<div class="empty-guide-step anim-slide-up" style="--delay: 0.15s">
|
||||
<div class="empty-guide-step-number">
|
||||
1
|
||||
</div>
|
||||
<div class="empty-guide-step-content">
|
||||
<div class="empty-guide-step-title">
|
||||
{{ $t('player.empty.step1_title') }}
|
||||
</div>
|
||||
<div class="empty-guide-step-desc">
|
||||
{{ $t('player.empty.step1_desc') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-guide-step anim-slide-up" style="--delay: 0.3s">
|
||||
<div class="empty-guide-step-number">
|
||||
2
|
||||
</div>
|
||||
<div class="empty-guide-step-content">
|
||||
<div class="empty-guide-step-title">
|
||||
{{ $t('player.empty.step2_title') }}
|
||||
</div>
|
||||
<div class="empty-guide-step-desc">
|
||||
{{ $t('player.empty.step2_desc') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-guide-step anim-slide-up" style="--delay: 0.45s">
|
||||
<div class="empty-guide-step-number">
|
||||
3
|
||||
</div>
|
||||
<div class="empty-guide-step-content">
|
||||
<div class="empty-guide-step-title">
|
||||
{{ $t('player.empty.step3_title') }}
|
||||
</div>
|
||||
<div class="empty-guide-step-desc">
|
||||
{{ $t('player.empty.step3_desc') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RouterLink
|
||||
to="/config"
|
||||
class="empty-guide-action anim-slide-up"
|
||||
style="--delay: 0.6s"
|
||||
<div v-if="url === ''" class="page-empty">
|
||||
<ab-empty
|
||||
:title="$t('player.empty.title')"
|
||||
:description="$t('player.empty.subtitle')"
|
||||
>
|
||||
{{ $t('sidebar.config') }}
|
||||
</RouterLink>
|
||||
<template #action>
|
||||
<ol class="empty-steps">
|
||||
<li v-for="n in 3" :key="n">
|
||||
<span class="empty-steps-title">{{
|
||||
$t(`player.empty.step${n}_title`)
|
||||
}}</span>
|
||||
{{ $t(`player.empty.step${n}_desc`) }}
|
||||
</li>
|
||||
</ol>
|
||||
<ab-button variant="primary" @click="$router.push('/config')">
|
||||
{{ $t('sidebar.config') }}
|
||||
</ab-button>
|
||||
</template>
|
||||
</ab-empty>
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
@@ -97,129 +55,4 @@ const { url } = storeToRefs(usePlayerStore());
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.empty-guide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.empty-guide-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.empty-guide-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.empty-guide-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty-guide-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty-guide-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
transition: background-color var(--transition-normal),
|
||||
border-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.empty-guide-step-number {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-guide-step-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.empty-guide-step-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.empty-guide-step-desc {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.empty-guide-action {
|
||||
margin-top: 24px;
|
||||
padding: 8px 24px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.anim-fade-in {
|
||||
animation: fadeIn 0.5s ease both;
|
||||
}
|
||||
|
||||
.anim-slide-up {
|
||||
animation: slideUp 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PluginUiSlot } from '@autobangumi/plugin-ui';
|
||||
import PluginSlot from '@/components/plugin-slot.vue';
|
||||
import { refreshPluginUi, usePluginUi } from '@/hooks/usePluginUi';
|
||||
import { refreshPluginUi, slotTitle, usePluginUi } from '@/hooks/usePluginUi';
|
||||
|
||||
definePage({
|
||||
name: 'Plugin',
|
||||
@@ -9,17 +10,24 @@ definePage({
|
||||
// 插件经 page 挂载点提供的独立页面;同一插件声明多个时取第一个
|
||||
const route = useRoute('Plugin');
|
||||
const { slots, loaded, loadFailed } = usePluginUi();
|
||||
const ui = computed(() =>
|
||||
slots.value.find(
|
||||
(s) =>
|
||||
s.slot === 'page' &&
|
||||
s.plugin_id === route.params.id
|
||||
)
|
||||
const { lang } = useMyI18n();
|
||||
const { isMobile } = useBreakpointQuery();
|
||||
// 离场淡出时 useRoute() 已是下一个路由:只在本路由激活时取参数,否则保留上一个值
|
||||
const ui = computed<PluginUiSlot | undefined>((prev) =>
|
||||
route.name !== 'Plugin'
|
||||
? prev
|
||||
: slots.value.find(
|
||||
(s) => s.slot === 'page' && s.plugin_id === route.params.id
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-plugin">
|
||||
<!-- 手机上 ab-page-title 隐藏,页面自己给标题 -->
|
||||
<h1 v-if="isMobile && ui" class="page-plugin__title">
|
||||
{{ slotTitle(ui, lang) }}
|
||||
</h1>
|
||||
<PluginSlot v-if="ui" :key="`${ui.plugin_id}:${ui.element}`" :ui="ui" />
|
||||
<ab-empty
|
||||
v-else-if="loaded"
|
||||
@@ -43,4 +51,10 @@ const ui = computed(() =>
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.page-plugin__title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 650;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,9 @@ vi.mock('@/hooks/usePluginUi', () => ({
|
||||
usePluginUi: () => state,
|
||||
refreshPluginUi: () => refreshPluginUi(),
|
||||
}));
|
||||
vi.mock('@/hooks/useMyI18n', () => ({
|
||||
useMyI18n: () => ({ lang: ref('zh-CN') }),
|
||||
}));
|
||||
vi.mock('@/components/plugin-slot.vue', () => ({
|
||||
default: { template: '<div class="slot-stub" />' },
|
||||
}));
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import { RefreshCw } from '@lucide/vue';
|
||||
import { type DataTableColumns, NDataTable, NTooltip } from 'naive-ui';
|
||||
import { type DataTableColumns, NDataTable } from 'naive-ui';
|
||||
import AbButton from '@/components/basic/ab-button.vue';
|
||||
import AbEmpty from '@/components/basic/ab-empty.vue';
|
||||
import AbTag from '@/components/basic/ab-tag.vue';
|
||||
import AbTooltip from '@/components/basic/ab-tooltip.vue';
|
||||
import { useConfirm } from '@/hooks/useConfirm';
|
||||
import type { RSS } from '#/rss';
|
||||
|
||||
@@ -63,7 +65,6 @@ const rssColumns = computed<DataTableColumns<RSS>>(() => [
|
||||
// 名称列同时承载来源元信息(解析器 / 聚合),Status 列只保留运行状态
|
||||
title: t('rss.name'),
|
||||
key: 'name',
|
||||
className: 'text-h3',
|
||||
render(rss: RSS) {
|
||||
return h('div', { class: 'rss-name-cell' }, [
|
||||
h('span', { class: 'rss-name', title: rss.name }, rss.name),
|
||||
@@ -75,7 +76,6 @@ const rssColumns = computed<DataTableColumns<RSS>>(() => [
|
||||
{
|
||||
title: t('rss.url'),
|
||||
key: 'url',
|
||||
className: 'text-h3',
|
||||
minWidth: 240,
|
||||
ellipsis: {
|
||||
tooltip: true,
|
||||
@@ -84,7 +84,6 @@ const rssColumns = computed<DataTableColumns<RSS>>(() => [
|
||||
{
|
||||
title: t('rss.status'),
|
||||
key: 'status',
|
||||
className: 'text-h3',
|
||||
align: 'right',
|
||||
minWidth: 140,
|
||||
render(rss: RSS) {
|
||||
@@ -92,10 +91,11 @@ const rssColumns = computed<DataTableColumns<RSS>>(() => [
|
||||
rss.connection_status === 'healthy' &&
|
||||
h(AbTag, { type: 'success', title: t('rss.connected') }),
|
||||
rss.connection_status === 'error' &&
|
||||
h(NTooltip, null, {
|
||||
trigger: () => h(AbTag, { type: 'danger', title: t('rss.error') }),
|
||||
default: () => rss.last_error || 'Unknown error',
|
||||
}),
|
||||
h(
|
||||
AbTooltip,
|
||||
{ content: rss.last_error || t('rss.error') },
|
||||
() => h(AbTag, { type: 'danger', title: t('rss.error') })
|
||||
),
|
||||
rss.enabled
|
||||
? h(AbTag, { type: 'success', title: t('rss.active') })
|
||||
: h(AbTag, { type: 'neutral', title: t('rss.inactive') }),
|
||||
@@ -165,15 +165,27 @@ function onSelectedRSSUpdate(keys: unknown[]) {
|
||||
>
|
||||
<template #row="{ item }">
|
||||
<div class="rss-card-content">
|
||||
<div class="rss-card-name">
|
||||
{{ item.name }}
|
||||
<div class="rss-card-header">
|
||||
<div class="rss-card-name">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<AbButton
|
||||
size="sm"
|
||||
:variant="refreshFailedId === item.id ? 'danger' : 'secondary'"
|
||||
:title="$t('rss.refresh')"
|
||||
:aria-label="$t('rss.refresh')"
|
||||
:disabled="refreshingId === item.id"
|
||||
@click.stop="onRefreshOne(item.id)"
|
||||
>
|
||||
<RefreshCw :size="14" :class="{ 'animate-spin': refreshingId === item.id }" />
|
||||
</AbButton>
|
||||
</div>
|
||||
<div class="rss-card-url">
|
||||
{{ item.url }}
|
||||
</div>
|
||||
<div class="rss-card-tags">
|
||||
<AbTag v-if="item.parser" type="info" :title="item.parser" />
|
||||
<AbTag v-if="item.aggregate" type="info" title="aggregate" />
|
||||
<AbTag v-if="item.aggregate" type="info" :title="$t('rss.aggregate')" />
|
||||
<AbTag
|
||||
v-if="item.connection_status === 'healthy'"
|
||||
type="success"
|
||||
@@ -186,11 +198,7 @@ function onSelectedRSSUpdate(keys: unknown[]) {
|
||||
/>
|
||||
<AbTag
|
||||
:type="item.enabled ? 'success' : 'neutral'"
|
||||
:title="
|
||||
item.enabled
|
||||
? $t('config.notification_set.enabled')
|
||||
: $t('config.notification_set.disabled')
|
||||
"
|
||||
:title="item.enabled ? $t('rss.active') : $t('rss.inactive')"
|
||||
/>
|
||||
</div>
|
||||
<!-- Inline on touch — a tooltip can't be hovered on a phone -->
|
||||
@@ -200,18 +208,6 @@ function onSelectedRSSUpdate(keys: unknown[]) {
|
||||
>
|
||||
{{ item.last_error }}
|
||||
</div>
|
||||
<div class="rss-card-actions" @click.stop>
|
||||
<AbButton
|
||||
size="sm"
|
||||
:variant="refreshFailedId === item.id ? 'danger' : 'secondary'"
|
||||
:title="$t('rss.refresh')"
|
||||
:disabled="refreshingId === item.id"
|
||||
@click="onRefreshOne(item.id)"
|
||||
>
|
||||
<RefreshCw :size="14" :class="{ 'animate-spin': refreshingId === item.id }" />
|
||||
{{ $t('rss.refresh') }}
|
||||
</AbButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ab-list>
|
||||
@@ -227,29 +223,32 @@ function onSelectedRSSUpdate(keys: unknown[]) {
|
||||
:bordered="false"
|
||||
:max-height="500"
|
||||
@update:checked-row-keys="onSelectedRSSUpdate"
|
||||
/>
|
||||
>
|
||||
<template #empty>
|
||||
<AbEmpty :title="$t('common.empty')" />
|
||||
</template>
|
||||
</NDataTable>
|
||||
</ab-container>
|
||||
|
||||
<div v-if="selectedRSS.length > 0">
|
||||
<div class="divider" />
|
||||
<div class="rss-actions">
|
||||
<AbButton variant="primary" @click="enableSelected">
|
||||
{{
|
||||
$t('rss.enable')
|
||||
}}
|
||||
<!-- 操作栏在卡片下方占位,长列表时 sticky 贴在滚动区底部;样式与下载器页一致 -->
|
||||
<Transition name="action-bar">
|
||||
<div v-if="selectedRSS.length > 0" class="action-bar">
|
||||
<span class="action-bar-count">
|
||||
{{ selectedRSS.length }} {{ $t('downloader.selected') }}
|
||||
</span>
|
||||
<div class="action-bar-buttons">
|
||||
<AbButton variant="primary" size="sm" @click="enableSelected">
|
||||
{{ $t('rss.enable') }}
|
||||
</AbButton>
|
||||
<AbButton variant="secondary" @click="disableSelected">
|
||||
{{
|
||||
$t('rss.disable')
|
||||
}}
|
||||
<AbButton variant="secondary" size="sm" @click="disableSelected">
|
||||
{{ $t('rss.disable') }}
|
||||
</AbButton>
|
||||
<AbButton variant="danger" @click="onDeleteSelected">
|
||||
{{
|
||||
$t('rss.delete')
|
||||
}}
|
||||
<AbButton variant="danger" size="sm" @click="onDeleteSelected">
|
||||
{{ $t('rss.delete') }}
|
||||
</AbButton>
|
||||
</div>
|
||||
</div>
|
||||
</ab-container>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -268,31 +267,90 @@ function onSelectedRSSUpdate(keys: unknown[]) {
|
||||
min-width: 0;
|
||||
|
||||
.rss-name {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
// naive 的空状态容器居中内容;ab-empty 是左对齐说明,铺满表格宽度
|
||||
:deep(.n-data-table-empty .ab-empty) {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.rss-actions {
|
||||
// 与 downloader.vue 的 .action-bar 同一套样式;在 .page-rss 滚动区内 sticky,
|
||||
// 按内容区居中,手机上与卡片同宽,不压底栏
|
||||
.action-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: var(--z-fixed);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin: 12px auto 0;
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
@include forTablet {
|
||||
gap: 10px;
|
||||
@media (width < 640px) {
|
||||
width: auto;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-bar-count {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-bar-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@media (width < 640px) {
|
||||
width: 100%;
|
||||
|
||||
:deep(.ab-btn) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-bar-enter-active,
|
||||
.action-bar-leave-active {
|
||||
transition: opacity var(--transition-normal), transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.action-bar-enter-from,
|
||||
.action-bar-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
// Mobile RSS card styles
|
||||
// 复选框与名称行顶部对齐,不随卡片高度垂直居中
|
||||
:deep(.ab-list-row) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
:deep(.ab-list-check) {
|
||||
// 名称行高度 = 刷新按钮高度(桌面 28px,触屏 44px),复选框 16px,居中到该行;
|
||||
// ab-list 用 6px 内边距 + -6px 外边距扩大命中区,这里的值已扣掉那 6px
|
||||
margin-top: 0;
|
||||
|
||||
@include forTouch {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.rss-card-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -301,7 +359,21 @@ function onSelectedRSSUpdate(keys: unknown[]) {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rss-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
// 按钮与卡片同为 surface-2,换成 surface 底色才能看出按钮边界
|
||||
:deep(.ab-btn--secondary) {
|
||||
flex-shrink: 0;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
}
|
||||
|
||||
.rss-card-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
@@ -312,7 +384,8 @@ function onSelectedRSSUpdate(keys: unknown[]) {
|
||||
|
||||
.rss-card-url {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -320,7 +393,7 @@ function onSelectedRSSUpdate(keys: unknown[]) {
|
||||
|
||||
.rss-card-error {
|
||||
font-size: 12px;
|
||||
color: var(--color-warning-text-secondary);
|
||||
color: var(--color-danger);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -330,10 +403,4 @@ function onSelectedRSSUpdate(keys: unknown[]) {
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.rss-card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -125,16 +125,18 @@ async function handleLogin() {
|
||||
{{ $t('login.login_btn') }}
|
||||
</ab-button>
|
||||
|
||||
<button
|
||||
<ab-button
|
||||
v-if="isSupported"
|
||||
type="button"
|
||||
class="passkey-btn"
|
||||
variant="secondary"
|
||||
block
|
||||
:disabled="isPasskeyLoading"
|
||||
@click="handlePasskeyLogin()"
|
||||
>
|
||||
<FingerprintPattern :size="20" />
|
||||
<span>{{ $t('login.passkey_btn') }}</span>
|
||||
</button>
|
||||
<template #icon>
|
||||
<FingerprintPattern :size="16" />
|
||||
</template>
|
||||
{{ $t('login.passkey_btn') }}
|
||||
</ab-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -144,19 +146,21 @@ async function handleLogin() {
|
||||
<style lang="scss" scoped>
|
||||
.page-login {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
// 内容高于视口(横屏手机)时可滚动,safe center 避免上溢被裁
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: safe center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
padding: 24px 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
// Animated background
|
||||
.login-bg {
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-bg-gradient {
|
||||
@@ -334,14 +338,19 @@ async function handleLogin() {
|
||||
|
||||
.login-input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
height: 34px;
|
||||
padding: 0 14px 0 44px;
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
|
||||
// 与 ab-button 同高:桌面 34px,触屏 44px
|
||||
@include forTouch {
|
||||
height: var(--touch-target);
|
||||
}
|
||||
transition: border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast), background-color var(--transition-fast);
|
||||
|
||||
@@ -384,39 +393,4 @@ async function handleLogin() {
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.passkey-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
background: transparent;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
border-style: solid;
|
||||
background: color-mix(in srgb, var(--color-primary) 5%, transparent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,23 +14,32 @@ const { steps } = setupStore;
|
||||
:current-step="currentStepIndex"
|
||||
:total-steps="steps.length"
|
||||
>
|
||||
<wizard-step-welcome v-if="currentStep === 'welcome'" />
|
||||
<wizard-step-account v-else-if="currentStep === 'account'" />
|
||||
<wizard-step-downloader v-else-if="currentStep === 'downloader'" />
|
||||
<wizard-step-rss v-else-if="currentStep === 'rss'" />
|
||||
<wizard-step-notification v-else-if="currentStep === 'notification'" />
|
||||
<wizard-step-review v-else-if="currentStep === 'review'" />
|
||||
<!-- 过渡挂在不带 transition 的包裹层上:步骤根节点 .container-card 的
|
||||
scoped transition 会覆盖全局 .page-* 规则 -->
|
||||
<Transition name="page" mode="out-in">
|
||||
<div :key="currentStep">
|
||||
<wizard-step-welcome v-if="currentStep === 'welcome'" />
|
||||
<wizard-step-account v-else-if="currentStep === 'account'" />
|
||||
<wizard-step-downloader v-else-if="currentStep === 'downloader'" />
|
||||
<wizard-step-rss v-else-if="currentStep === 'rss'" />
|
||||
<wizard-step-notification
|
||||
v-else-if="currentStep === 'notification'"
|
||||
/>
|
||||
<wizard-step-review v-else-if="currentStep === 'review'" />
|
||||
</div>
|
||||
</Transition>
|
||||
</wizard-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-setup {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
// 顶部对齐:步骤高度不同,居中会让进度条随步骤跳动;内容高于视口时可滚动
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: clamp(24px, 12vh, 120px) 0 24px;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -90,3 +90,29 @@ input::-webkit-inner-spin-button {
|
||||
.n-drawer-mask {
|
||||
background-color: var(--color-overlay);
|
||||
}
|
||||
|
||||
// 空状态里的编号步骤(主页、下载器、播放器共用)
|
||||
.empty-steps {
|
||||
max-width: 52ch;
|
||||
margin: 0 0 14px;
|
||||
padding-left: 1.5em;
|
||||
list-style: decimal;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
li + li {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
li::marker {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-steps-title {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@@ -113,7 +113,8 @@
|
||||
|
||||
--color-bg: #0F172A;
|
||||
--color-surface: #1E293B;
|
||||
--color-surface-hover: #334155;
|
||||
// 介于 surface-2 与 border 之间:悬停行上的发丝边框仍可见
|
||||
--color-surface-hover: #2B3850;
|
||||
--color-surface-2: #26334A;
|
||||
|
||||
--color-text: #F1F5F9;
|
||||
|
||||
@@ -46,6 +46,14 @@ function inline(raw: string): string {
|
||||
|
||||
const LIST_ITEM = /^\s*(?:([-*+])|(\d+)[.)])\s+(.*)$/;
|
||||
const RULE = /^\s*([-*_])(?:\s*\1){2,}\s*$/;
|
||||
const TABLE_ROW = /^\s*\|/;
|
||||
const TABLE_SEP = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)*\|?\s*$/;
|
||||
|
||||
// GFM 表格的一行:去掉首尾竖线后按 | 切分(不处理转义的 \|)
|
||||
function tableCells(row: string, tag: 'th' | 'td'): string {
|
||||
const cells = row.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
|
||||
return `<tr>${cells.map((c) => `<${tag}>${inline(c.trim())}</${tag}>`).join('')}</tr>`;
|
||||
}
|
||||
|
||||
export function renderMarkdown(source: string): string {
|
||||
const lines = source
|
||||
@@ -68,7 +76,8 @@ export function renderMarkdown(source: string): string {
|
||||
list = null;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (code) {
|
||||
if (/^\s*```/.test(line)) {
|
||||
out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
|
||||
@@ -81,6 +90,18 @@ export function renderMarkdown(source: string): string {
|
||||
code = [];
|
||||
continue;
|
||||
}
|
||||
// 表格:| 开头的行,下一行是 --- 分隔行
|
||||
if (TABLE_ROW.test(line) && TABLE_SEP.test(lines[i + 1] ?? '')) {
|
||||
flush();
|
||||
const rows: string[] = [];
|
||||
for (i += 2; i < lines.length && TABLE_ROW.test(lines[i]); i++)
|
||||
rows.push(tableCells(lines[i], 'td'));
|
||||
i--;
|
||||
out.push(
|
||||
`<table><thead>${tableCells(line, 'th')}</thead><tbody>${rows.join('')}</tbody></table>`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (RULE.test(line)) {
|
||||
flush();
|
||||
out.push('<hr>');
|
||||
|
||||
Reference in New Issue
Block a user