fix(webui): 全界面 UI 审查修复:按钮与文字对齐、动画、布局错乱

用 Playwright 在桌面/平板/手机、浅色/深色、中英文下截取全部页面与弹窗,
由干净上下文的审查者逐张检查,共修复 122 个问题(高严重度 12 个)。

- 布局:手机搜索结果行、矮屏登录页与向导、下载器表格响应式、日历长标题撑破网格、
  深色模式下 Passkey 列表与种子复选框不可见、设置页新下载器行对齐
- 对齐:向导、登录页、添加 RSS 改用 ab-input / ab-button,控件高度与圆角统一
- 动画:向导步骤、插件页离场、归档区折叠、插件选项展开等过渡不再跳动或闪烁
- 日历:触屏上看不见的取消置顶按钮不再可点
- 私有弹窗改用 ab-modal;更新说明改用 renderMarkdown(链接只接受 http(s))
- 空状态步骤样式合并到 global.scss
This commit is contained in:
Estrella Pan
2026-10-22 19:34:57 +02:00
parent e466c669a3
commit 0d97ae0098
68 changed files with 2464 additions and 3358 deletions

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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">
&ne;
</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">&rarr; 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">&rarr; 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">&rarr;</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">
&ne;
</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">&rarr; 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">&rarr; 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">&rarr;</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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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"
/>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)"
>
&times;
</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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">
&rsaquo;
</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>

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 }} &times;
</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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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);

View File

@@ -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"

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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 {

View File

@@ -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),

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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 {

View File

@@ -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);
}
}

View File

@@ -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",

View File

@@ -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": "注意",

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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" />' },
}));

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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>');