Merge branch 'feat/4.0-toolchain-webui' into feat/4.0-toolchain

This commit is contained in:
Estrella Pan
2026-10-20 11:49:15 +02:00
190 changed files with 7887 additions and 12216 deletions

View File

@@ -50,12 +50,12 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 9
version: 12.14.0
run_install: false
- name: Get pnpm store directory
@@ -127,12 +127,12 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 9
version: 12.14.0
run_install: false
- name: Get pnpm store directory

View File

@@ -27,14 +27,14 @@ jobs:
with:
fetch-depth: 0 # lastUpdated 需要完整 git 历史
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v6
with:
version: 9
version: 12.14.0
run_install: false
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: pnpm
cache-dependency-path: docs/pnpm-lock.yaml

View File

@@ -14,7 +14,7 @@ concurrency:
env:
E2E_VERSION: 3.3.999-e2e.1
PNPM_VERSION: 9.11.0
PNPM_VERSION: 12.14.0
PLAYWRIGHT_VERSION: 1.61.1
jobs:
@@ -82,7 +82,7 @@ jobs:
python-version: "3.14"
- name: Set up pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
@@ -90,7 +90,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: 24
cache: pnpm
cache-dependency-path: webui/pnpm-lock.yaml

View File

@@ -13,7 +13,7 @@ concurrency:
env:
E2E_VERSION: 3.3.999-e2e.1
PNPM_VERSION: 9.11.0
PNPM_VERSION: 12.14.0
PLAYWRIGHT_VERSION: 1.61.1
jobs:
@@ -57,7 +57,7 @@ jobs:
cache-dependency-glob: backend/uv.lock
- name: Set up pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
@@ -65,7 +65,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: 24
cache: pnpm
cache-dependency-path: webui/pnpm-lock.yaml
@@ -177,7 +177,7 @@ jobs:
python-version: "3.14"
- name: Set up pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
@@ -185,7 +185,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: 24
cache: pnpm
cache-dependency-path: webui/pnpm-lock.yaml
@@ -304,7 +304,7 @@ jobs:
cache-dependency-glob: backend/uv.lock
- name: Set up pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
version: ${{ env.PNPM_VERSION }}
run_install: false
@@ -312,7 +312,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: 24
cache: pnpm
cache-dependency-path: webui/pnpm-lock.yaml

View File

@@ -60,14 +60,14 @@ jobs:
with:
version: "0.13.x"
- &pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v6
with:
version: 9
version: 12.14.0
run_install: false
- &node
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
- &deps
name: Install backend
run: mkdir -p backend/config && cd backend && uv sync --group dev

1
.gitignore vendored
View File

@@ -197,7 +197,6 @@ webui/test-results/
dist.zip
dist-ssr
*.local
dev-dist
# Editor directories and files
.vscode/*

View File

@@ -55,7 +55,6 @@ pnpm test:build
# Linting and formatting
pnpm lint
pnpm lint:fix
pnpm format
```
### Docker
@@ -127,7 +126,7 @@ webui/src/
## Code Style
- Python: ruff format (88 char lines), Ruff linter (E, F, I rules), target Python 3.14
- TypeScript: ESLint + Prettier
- TypeScript: ESLint (@antfu/eslint-config)
- Run formatters before committing
## Git Branching

View File

@@ -1,6 +1,6 @@
# Design
Visual system of the AutoBangumi web UI (Vue 3 + naive-ui + UnoCSS). Source of truth for tokens: `webui/src/style/var.scss` (`:root` light, `.dark` overrides). naive-ui components are themed via `GlobalThemeOverrides` in `webui/src/App.vue`, which reference these same CSS custom properties.
Visual system of the AutoBangumi web UI (Vue 3 + naive-ui + UnoCSS). Source of truth for tokens: `webui/src/style/var.scss` (`:root` light, `.dark` overrides). naive-ui components are themed via the `abNaiveTheme` `GlobalThemeOverrides` in `webui/src/style/naive-theme.ts` (provided by the root `NConfigProvider` in `webui/src/App.vue`), which reference these same CSS custom properties. Icons come from `@lucide/vue`.
## Color
@@ -52,9 +52,9 @@ Semantic scale only: `--z-dropdown` 10 · `--z-sticky` 20 · `--z-fixed` 30 · `
## Components
- **Universal `ab-*` layer** (`src/components/basic/`): pages and composites use only `ab-*` components — never raw `NButton`/`NInput`/bare styled `<button>`. Where naive-ui fits, the `ab-*` component wraps it (`ab-select`, `ab-switch`, `ab-tooltip`); where it doesn't, the component is custom on tokens (`ab-button`, `ab-tag`, `ab-modal`).
- **Universal `ab-*` layer** (`src/components/basic/`): pages and composites use only `ab-*` components — never raw `NButton`/`NInput`/bare styled `<button>`. Where naive-ui fits, the `ab-*` component wraps it (`ab-select`, `ab-switch`, `ab-tooltip`, `ab-modal`, `ab-bottom-sheet`, `ab-menu`, `ab-fold-panel`); where it doesn't, the component is custom on tokens (`ab-button`, `ab-tag`).
- **Composites**: app-specific `ab-*` components (`ab-add-rss`, `ab-edit-rule`, calendar pieces, layout shells) compose the universal layer + tokens.
- **Utility CSS**: UnoCSS with attributify preset; prefer token-backed values over arbitrary literals.
- **Utility CSS**: UnoCSS (`presetWind3`), utilities go in `class` (no attributify); prefer token-backed values over arbitrary literals.
- Every interactive component ships all states: default, hover, focus-visible, active, disabled, loading, error — and ≥44px touch targets on mobile (`--touch-target`).
- Soft Ink component grammar: filled controls on `--color-surface-2` (border only on focus/error); tags are hairline chips with a square semantic mark and ink text; status is a square mark + ink label; tabs underline (no thumb); alerts are hairline outlines led by a bold colored word (no icons, no tinted fills); toasts are inverted ink chips; empty states are left-aligned prose with one action; loading is skeletons, not spinners-in-content.
- Every universal component ships a Storybook story and (where it has behavior) a vitest test in `__tests__/`.
@@ -63,7 +63,7 @@ The universal set (`src/components/basic/` unless noted):
| Group | Components |
|---|---|
| Actions | `ab-button` (primary/secondary/ghost/danger·sm/md·loading), `ab-icon-button` (required label, 44px touch, ghost/solid/round), `ab-menu` (headlessui dropdown), `ab-split-button` |
| Actions | `ab-button` (primary/secondary/ghost/danger·sm/md·loading), `ab-icon-button` (required label, 44px touch, ghost/solid/round), `ab-menu` (naive-ui NDropdown), `ab-split-button` |
| Forms | `ab-field` (label/description/error/required + aria wiring via injection), `ab-input` (affixes, clearable, password reveal, error), `ab-select`, `ab-switch`, `ab-segmented` (underline tabs), `ab-setting` (root — field+control settings row) |
| Overlays | `ab-modal` (adaptive: dialog ≥ tablet, bottom sheet on mobile; footer slot; sizes sm/md/lg), `useConfirm()` + `ab-confirm-host` (promise confirm, danger variant), `ab-tooltip`, `ab-bottom-sheet` (internal to ab-modal) |
| Display | `ab-tag` (semantic marks: success/warning/danger/info/neutral, closable), `ab-status` (running/stopped/paused/degraded + label/detail), `ab-badge` (count/dot, 99+), `ab-container` (root — title/footer/flush card), `ab-fold-panel` (root — accessible, animated), `ab-progress` |

View File

@@ -120,7 +120,7 @@ def test_pr_dependencies_and_generated_outputs_are_cached() -> None:
assert "uv sync --directory backend --locked --group dev" in job
for job in (runtime, browser, downloader):
assert "pnpm/action-setup@v4" in job
assert "pnpm/action-setup@v6" in job
assert "cache: pnpm" in job
assert "cache-dependency-path: webui/pnpm-lock.yaml" in job
assert "pnpm --dir webui install --frozen-lockfile" in job

2
docs/pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true

View File

@@ -1,6 +0,0 @@
/build
/dist
/dev-dist
/e2e/.auth
/playwright-report
/test-results

View File

@@ -1,8 +0,0 @@
{
"extends": ["@antfu", "prettier", "plugin:storybook/recommended"],
"rules": {
"antfu/if-newline": ["off"],
"no-console": ["off"],
"vue/custom-event-name-casing": ["off"]
}
}

View File

@@ -1,4 +1 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
cd ./webui && pnpm run lint:fix && pnpm run format
cd ./webui && pnpm run lint:fix

View File

@@ -1,3 +0,0 @@
public-hoist-pattern[]=@vue/runtime-core
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*

View File

@@ -1,10 +0,0 @@
/build
/dist
/dev-dist
/e2e/.auth
/playwright-report
/test-results
/pnpm-lock.yaml
auto-imports.d.ts
components.d.ts
router-type.d.ts

View File

@@ -1,3 +0,0 @@
{
"singleQuote": true
}

View File

@@ -3,18 +3,11 @@ import Unocss from 'unocss/vite';
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
],
addons: ['@storybook/addon-links', '@storybook/addon-docs'],
framework: {
name: '@storybook/vue3-vite',
options: {},
},
docs: {
autodocs: 'tag',
},
viteFinal(config) {
config.plugins?.push(Unocss());
// Add other configuration here depending on your use case

View File

@@ -1,6 +1,6 @@
import type { Preview } from '@storybook/vue3';
import type { Preview } from '@storybook/vue3-vite';
import '@unocss/reset/tailwind-compat.css';
import 'uno.css';
import 'virtual:uno.css';
const preview: Preview = {
parameters: {

View File

@@ -16,7 +16,7 @@ async function requireStatus(
if (response.status() !== expected) {
throw new Error(
`${response.url()} returned ${response.status()} ` +
`${response.statusText()}, expected ${expected}`
`${response.statusText()}, expected ${expected}`
);
}
return response;

View File

@@ -25,7 +25,7 @@ export function redactArtifact(
return redacted
.replace(/(Bearer\s+)[^\s"',]+/gi, '$1[REDACTED]')
.replace(/ab_(?:api|mcp|session)_[A-Za-z0-9._~-]+/g, '[REDACTED]')
.replace(/ab_(?:api|mcp|session)_[\w.~-]+/g, '[REDACTED]')
.replace(
/("?(?:password|token|authorization|cookie)"?\s*[:=]\s*)"?[^\s,"'}]+/gi,
'$1[REDACTED]'
@@ -97,7 +97,7 @@ export class NetworkGuard {
this.page.on('requestfailed', (request) => {
this.failedRequests.push(
`${request.method()} ${request.url()}: ` +
`${request.failure()?.errorText ?? 'unknown failure'}`
`${request.failure()?.errorText ?? 'unknown failure'}`
);
});
this.page.on('response', (response) => {

View File

@@ -20,7 +20,7 @@ export const test = base.extend<E2EFixtures>({
},
uniqueName: async ({ browserName: _browserName }, use, testInfo) => {
const value = `${testInfo.project.name}-${testInfo.testId}`
.replace(/[^A-Za-z0-9_-]+/g, '-')
.replace(/[^\w-]+/g, '-')
.slice(-48);
await use(value);
},

View File

@@ -14,7 +14,7 @@ test('player settings stay browser-local and load the hermetic iframe', async ({
if (
(request.url().endsWith('/api/v1/config/update') &&
request.method() === 'PATCH') ||
(request.url().endsWith('/api/v1/restart') && request.method() === 'POST')
(request.url().endsWith('/api/v1/restart') && request.method() === 'POST')
)
backendMutations.push(request.url());
});

47
webui/eslint.config.js Normal file
View File

@@ -0,0 +1,47 @@
import antfu from '@antfu/eslint-config';
import storybook from 'eslint-plugin-storybook';
export default antfu(
{
vue: true,
stylistic: { semi: true, quotes: 'single', indent: 2, braceStyle: '1tbs' },
ignores: [
'/build',
'/dist',
'/e2e/.auth',
'/playwright-report',
'/test-results',
],
},
{
rules: {
'antfu/if-newline': 'off',
'style/operator-linebreak': ['error', 'after', { overrides: { '?': 'before', ':': 'before', '|>': 'before' } }],
'style/quote-props': ['error', 'as-needed'],
'style/multiline-comment-style': 'off',
'node/prefer-global/process': 'off',
'regexp/no-super-linear-backtracking': 'off',
'regexp/no-unused-capturing-group': 'off',
'unicorn/prefer-dom-node-text-content': 'off',
'vue/no-required-prop-with-default': 'off',
'ts/no-empty-object-type': 'off',
'unused-imports/no-unused-vars': 'warn',
'antfu/curly': 'off',
curly: 'off',
'test/prefer-lowercase-title': 'off',
'import/consistent-type-specifier-style': 'off',
'style/arrow-parens': ['error', 'always'],
'style/comma-dangle': ['error', { arrays: 'always-multiline', objects: 'always-multiline', imports: 'always-multiline', exports: 'always-multiline', functions: 'never' }],
'perfectionist/sort-imports': 'off',
'perfectionist/sort-named-imports': 'off',
'import/order': 'off',
'no-console': 'off',
'vue/custom-event-name-casing': 'off',
},
},
{
files: ['public/sw.js'],
rules: { 'no-restricted-globals': 'off' },
},
...storybook.configs['flat/recommended']
);

View File

@@ -10,6 +10,7 @@
<meta name="theme-color" content="#FAFAFA" />
<link rel="icon" href="/images/logo.svg" />
<link rel="apple-touch-icon" href="/images/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="description" content="Automated Bangumi Download Tool" />
<!-- Self-hosted Inter (variable, 300–700): no render-blocking Google
Fonts round-trip, and it works where fonts.googleapis.com is slow

View File

@@ -3,14 +3,12 @@
"type": "module",
"version": "0.0.0",
"private": true,
"packageManager": "pnpm@9.11.0+sha512.0a203ffaed5a3f63242cd064c8fb5892366c103e328079318f78062f24ea8c9d50bc6a47aa3567cabefd824d170e78fa2745ed1f16b132e16436146b7688f19b",
"packageManager": "pnpm@12.14.0",
"scripts": {
"prepare": "cd .. && husky install ./webui/.husky",
"prepare": "cd .. && husky webui/.husky",
"test:build": "vue-tsc --noEmit",
"build": "vite build",
"dev": "vite",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"preview": "vite preview",
@@ -20,61 +18,46 @@
"test:e2e:webkit": "playwright test --project=webkit-mobile",
"test:e2e:typecheck": "tsc --noEmit -p tsconfig.e2e.json",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"generate-pwa-assets": "pwa-assets-generator --preset minimal public/images/logo.svg"
"build-storybook": "storybook build"
},
"dependencies": {
"@headlessui/vue": "^1.7.23",
"@vueuse/components": "^10.11.1",
"@vueuse/core": "^10.11.1",
"axios": "^1.7.9",
"naive-ui": "^2.44.1",
"pinia": "^2.2.2",
"vue": "^3.5.8",
"vue-i18n": "^9.14.2",
"vue-inline-svg": "^3.1.4",
"vue-router": "^4.4.5",
"@lucide/vue": "^1.59.0",
"@vue/devtools-api": "^8.2.1",
"@vueuse/core": "^15.0.0",
"axios": "^1.20.0",
"naive-ui": "^2.45.3",
"pinia": "^4.1.0",
"vue": "^3.5.44",
"vue-i18n": "^11.4.16",
"vue-router": "^5.4.0",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@antfu/eslint-config": "^0.38.6",
"@antfu/eslint-config": "^9.5.0",
"@autobangumi/plugin-ui": "workspace:*",
"@icon-park/vue-next": "^1.4.2",
"@iconify-json/simple-icons": "1.2.89",
"@intlify/unplugin-vue-i18n": "^0.11.0",
"@playwright/test": "1.61.1",
"@storybook/addon-essentials": "^7.6.20",
"@storybook/addon-interactions": "^7.6.20",
"@storybook/addon-links": "^7.6.20",
"@storybook/blocks": "^7.6.20",
"@storybook/testing-library": "0.0.14-next.2",
"@storybook/vue3": "^7.6.20",
"@storybook/vue3-vite": "^7.6.20",
"@types/node": "^18.19.50",
"@unocss/preset-attributify": "^0.55.7",
"@unocss/preset-rem-to-px": "^0.51.13",
"@unocss/reset": "^0.51.13",
"@vitejs/plugin-vue": "^4.6.2",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"@vue/runtime-dom": "^3.5.8",
"@vue/test-utils": "^2.4.6",
"eslint": "^8.57.1",
"eslint-config-prettier": "^8.10.0",
"eslint-plugin-storybook": "^0.6.15",
"happy-dom": "^12.10.3",
"husky": "^8.0.3",
"prettier": "^2.8.8",
"radash": "^12.1.0",
"sass-embedded": "^1.79.3",
"storybook": "^7.6.20",
"typescript": "^4.9.5",
"unocss": "^0.51.13",
"unplugin-auto-import": "^0.10.3",
"unplugin-vue-components": "^0.24.1",
"unplugin-vue-router": "^0.6.4",
"vite": "^4.5.5",
"vite-plugin-pwa": "^0.16.7",
"vitest": "^0.30.1",
"vue-tsc": "^1.8.27"
"@intlify/unplugin-vue-i18n": "^11.2.7",
"@playwright/test": "1.64.1",
"@storybook/addon-docs": "^10.6.2",
"@storybook/addon-links": "^10.6.2",
"@storybook/vue3-vite": "^10.6.2",
"@types/node": "^24",
"@unocss/preset-rem-to-px": "^66.11.0",
"@unocss/reset": "^66.11.0",
"@vitejs/plugin-vue": "^6.0.10",
"@vue/test-utils": "^2.5.1",
"eslint": "^10.0.0",
"eslint-plugin-storybook": "^10.6.2",
"happy-dom": "^21.0.3",
"husky": "^9.1.7",
"sass-embedded": "^1.105.2",
"storybook": "^10.6.2",
"typescript": "~6.0.3",
"unocss": "^66.11.0",
"unplugin-auto-import": "^21.1.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.3.4",
"vitest": "^5.0.5",
"vue-tsc": "^3.3.13"
}
}

View File

@@ -10,11 +10,11 @@
/** 前端挂载点。 */
export type UiSlot =
| 'settings.section'
| 'bangumi.detail.tab'
| 'bangumi.card.action'
| 'page'
| 'dashboard.widget';
| 'settings.section' |
'bangumi.detail.tab' |
'bangumi.card.action' |
'page' |
'dashboard.widget';
/** `GET /api/v1/plugins/ui` 的一项:已启用插件声明的挂载点。 */
export interface PluginUiSlot {
@@ -43,10 +43,10 @@ export interface AbSlotContext {
* 返回响应体(JSON 已解析),非 2xx 时 reject。
*/
export interface AbHostApi {
get<T = unknown>(path: string, params?: Record<string, unknown>): Promise<T>;
post<T = unknown>(path: string, body?: unknown): Promise<T>;
put<T = unknown>(path: string, body?: unknown): Promise<T>;
delete<T = unknown>(path: string): Promise<T>;
get: <T = unknown>(path: string, params?: Record<string, unknown>) => Promise<T>;
post: <T = unknown>(path: string, body?: unknown) => Promise<T>;
put: <T = unknown>(path: string, body?: unknown) => Promise<T>;
delete: <T = unknown>(path: string) => Promise<T>;
}
export interface AbHost {
@@ -56,21 +56,21 @@ export interface AbHost {
/** 当前语言,如 `zh-CN`、`en-US` */
locale: string;
/** 宿主文案;找不到时返回 key 本身 */
t(key: string, params?: Record<string, unknown>): string;
t: (key: string, params?: Record<string, unknown>) => string;
};
theme: {
mode: 'light' | 'dark';
/** `--ab-*` 变量的当前值(见 tokens.css) */
tokens: Record<string, string>;
};
toast(message: string, kind?: 'info' | 'error'): void;
toast: (message: string, kind?: 'info' | 'error') => void;
events: {
/**
* 订阅宿主事件总线(SSE 的 `bus` 帧)上的事件 `kind`,回调收到事件的
* 字段(dataclass 展开成对象,不含 `kind`)。返回取消订阅函数;组件
* 卸载时宿主也会取消它的全部订阅。
*/
on(kind: string, callback: (payload: unknown) => void): () => void;
on: (kind: string, callback: (payload: unknown) => void) => () => void;
};
}

View File

@@ -6,8 +6,7 @@ const ELEMENT = 'ab-plugin-example';
class ExampleElement
extends HTMLElement
implements AbPluginElement<'settings.section'>
{
implements AbPluginElement<'settings.section'> {
// 宿主在插入文档前设置;用 declare 不生成类字段,否则构造时会被重置为 undefined
declare host: AbPluginElement['host'];
declare context: AbPluginElement<'settings.section'>['context'];

View File

@@ -1,12 +1,12 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"isolatedModules": true,
"lib": ["ESNext", "DOM"],
"noEmit": true
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"isolatedModules": true
},
"include": ["src"]
}

15166
webui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,12 @@
minimumReleaseAgeExcludePrune: true
trustPolicy: no-downgrade
shellEmulator: true
packages:
- packages/*
allowBuilds:
# sass 的可选依赖,平台预编译包已覆盖,无需跑源码构建
'@parcel/watcher': false
esbuild: true
vue-demi: true

View File

@@ -1,3 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.88235 20C2.07843 20 1.39706 19.7206 0.838235 19.1618C0.279412 18.6029 0 17.9216 0 17.1176C0 16.3137 0.279412 15.6324 0.838235 15.0735C1.39706 14.5147 2.07843 14.2353 2.88235 14.2353C3.68627 14.2353 4.36765 14.5147 4.92647 15.0735C5.48529 15.6324 5.76471 16.3137 5.76471 17.1176C5.76471 17.9216 5.48529 18.6029 4.92647 19.1618C4.36765 19.7206 3.68627 20 2.88235 20ZM18.3529 20C17.8007 20 17.356 19.5519 17.326 19.0004C17.2159 16.9786 16.769 15.0618 15.9853 13.25C15.0735 11.1422 13.8333 9.30392 12.2647 7.73529C10.6961 6.16667 8.85784 4.92647 6.75 4.01471C4.93823 3.23101 3.02143 2.78412 0.999605 2.67404C0.448137 2.64402 0 2.19934 0 1.64706V1C0 0.447716 0.448008 -0.00260278 0.999689 0.0232079C3.39202 0.135134 5.65193 0.651908 7.77941 1.57353C10.201 2.62255 12.3186 4.05392 14.1324 5.86765C15.9461 7.68137 17.3775 9.79902 18.4265 12.2206C19.3481 14.3481 19.8649 16.608 19.9768 19.0003C20.0026 19.552 19.5523 20 19 20H18.3529ZM11.3529 20C10.8007 20 10.3572 19.5513 10.3186 19.0004C10.1328 16.3515 9.19327 14.1455 7.5 12.3824C5.80702 10.6196 3.6402 9.64132 0.999535 9.44763C0.44873 9.40723 0 8.96405 0 8.41176V7.76471C0 7.21242 0.448269 6.76093 0.99942 6.79631C2.51743 6.89375 3.92939 7.21655 5.23529 7.76471C6.82353 8.43137 8.19118 9.35294 9.33824 10.5294C10.4853 11.7059 11.3824 13.1029 12.0294 14.7206C12.5616 16.0511 12.8749 17.4777 12.9694 19.0005C13.0036 19.5518 12.5523 20 12 20H11.3529Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,30 @@
{
"name": "AutoBangumi",
"short_name": "AutoBangumi",
"start_url": "./",
"display": "standalone",
"background_color": "#ffffff",
"lang": "en",
"scope": "./",
"description": "Automated Bangumi Download Tool",
"theme_color": "#ffffff",
"icons": [
{
"src": "/images/logo.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/images/pwa-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/images/pwa-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}

16
webui/public/sw.js Normal file
View File

@@ -0,0 +1,16 @@
// ponytail: 自毁 SW,清理 2023-09 前注册的旧 SW;4.1 删除本文件
// 旧版 vite-plugin-pwa 注册的 SW 仍会按 /sw.js 检查更新,取到本文件后:
// 清空它留下的缓存、注销自己,再让它控制的页面重新加载,回到无 SW 的普通请求
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
await self.registration.unregister();
const clients = await self.clients.matchAll({ type: 'window' });
clients.forEach((client) => client.navigate(client.url));
})()
);
});

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
type GlobalThemeOverrides,
NConfigProvider,
NDialogProvider,
NMessageProvider,
darkTheme,
} from 'naive-ui';
import { abNaiveTheme } from './style/naive-theme';
const { isDark } = useDarkMode();
const { refresh, isLoggedIn } = useAuth();
@@ -15,128 +15,8 @@ if (isLoggedIn.value) {
refresh();
}
// Button/Select/Switch tokens reference the app's own CSS custom properties
// directly (they resolve correctly under both `:root` and `.dark`), so they
// don't need to be duplicated between the light/dark override objects below.
// 注意:common 里的颜色 token 会进入 naive-ui (seemly) 的 JS 颜色计算,
// 写 CSS 变量会抛 "[seemly/rgba]: Invalid color value var(...)" 并让整个
// 页面渲染崩溃(Config 页曾因此白屏)——必须用字面量,按主题各写一份。
const primitiveOverrides: GlobalThemeOverrides = {
Button: {
borderRadiusTiny: 'var(--radius-sm)',
borderRadiusSmall: 'var(--radius-sm)',
borderRadiusMedium: 'var(--radius-sm)',
borderRadiusLarge: 'var(--radius-md)',
},
Switch: {
railColorActive: 'var(--color-primary)',
},
// Soft Ink:输入类控件为填充式(surface-2),无边框;
// 边框与描边只在聚焦时出现(primary)
Input: {
color: 'var(--color-surface-2)',
colorFocus: 'var(--color-surface)',
border: '1px solid transparent',
borderHover: '1px solid transparent',
borderFocus: '1px solid var(--color-primary)',
boxShadowFocus: '0 0 0 2px var(--color-primary-alpha)',
borderRadius: 'var(--radius-sm)',
},
Select: {
peers: {
InternalSelection: {
color: 'var(--color-surface-2)',
colorActive: 'var(--color-surface)',
border: '1px solid transparent',
borderHover: '1px solid transparent',
borderFocus: '1px solid var(--color-primary)',
borderActive: '1px solid var(--color-primary)',
boxShadowFocus: '0 0 0 2px var(--color-primary-alpha)',
boxShadowActive: '0 0 0 2px var(--color-primary-alpha)',
borderRadius: 'var(--radius-sm)',
},
},
},
Popover: {
borderRadius: 'var(--radius-md)',
},
Dialog: {
borderRadius: 'var(--radius-md)',
},
};
const lightOverrides: GlobalThemeOverrides = {
...primitiveOverrides,
common: {
errorColor: '#EF4444',
errorColorHover: '#DC2626',
errorColorPressed: '#B91C1C',
errorColorSuppl: '#EF4444',
primaryColor: '#6C4AB6',
primaryColorHover: '#563A92',
primaryColorPressed: '#4A3291',
bodyColor: '#FAFAFA',
cardColor: '#FFFFFF',
borderColor: '#E2E8F0',
textColorBase: '#1E293B',
textColor1: '#1E293B',
textColor2: '#64748B',
textColor3: '#94A3B8',
},
Spin: {
color: '#6C4AB6',
},
DataTable: {
thColor: 'transparent',
thColorHover: 'transparent',
tdColorHover: 'var(--color-surface-hover)',
borderColor: 'var(--color-border)',
},
Checkbox: {
colorChecked: '#6C4AB6',
borderFocus: '#6C4AB6',
boxShadowFocus: '0 0 0 2px rgba(108, 74, 182, 0.2)',
borderChecked: '1px solid #6C4AB6',
},
};
const darkOverrides: GlobalThemeOverrides = {
...primitiveOverrides,
common: {
errorColor: '#F87171',
errorColorHover: '#EF4444',
errorColorPressed: '#DC2626',
errorColorSuppl: '#F87171',
primaryColor: '#8B6CC7',
primaryColorHover: '#A78BDB',
primaryColorPressed: '#7B5CB7',
bodyColor: '#0F172A',
cardColor: '#1E293B',
borderColor: '#334155',
textColorBase: '#F1F5F9',
textColor1: '#F1F5F9',
textColor2: '#94A3B8',
textColor3: '#64748B',
},
Spin: {
color: '#8B6CC7',
},
DataTable: {
thColor: 'transparent',
thColorHover: 'transparent',
tdColorHover: 'var(--color-surface-hover)',
borderColor: 'var(--color-border)',
},
Checkbox: {
colorChecked: '#8B6CC7',
borderFocus: '#8B6CC7',
boxShadowFocus: '0 0 0 2px rgba(139, 108, 199, 0.2)',
borderChecked: '1px solid #8B6CC7',
},
};
const themeOverrides = computed(() =>
isDark.value ? darkOverrides : lightOverrides
isDark.value ? abNaiveTheme.dark : abNaiveTheme.light
);
const naiveTheme = computed(() => (isDark.value ? darkTheme : null));
</script>
@@ -146,7 +26,7 @@ const naiveTheme = computed(() => (isDark.value ? darkTheme : null));
<NConfigProvider :theme="naiveTheme" :theme-overrides="themeOverrides">
<NMessageProvider>
<NDialogProvider>
<RouterView></RouterView>
<RouterView />
<ab-confirm-host />
</NDialogProvider>
</NMessageProvider>
@@ -155,7 +35,7 @@ const naiveTheme = computed(() => (isDark.value ? darkTheme : null));
</template>
<style lang="scss">
@import './style/var';
@import './style/transition';
@import './style/global';
@use './style/var';
@use './style/transition';
@use './style/global';
</style>

View File

@@ -1,4 +1,3 @@
import { omit } from 'radash';
import type {
BangumiAPI,
BangumiRule,
@@ -37,7 +36,7 @@ export const apiBangumi = {
filter: bangumiRule.filter.join(','),
rss_link: bangumiRule.rss_link.join(','),
};
const post = omit(rule, ['id']);
const { id: _id, ...post } = rule;
const { data } = await axios.patch<ApiSuccess>(
`api/v1/bangumi/update/${bangumiId}`,
post

View File

@@ -3,7 +3,7 @@ export const apiCheck = {
* 检测下载器
*/
async downloader() {
const { data } = await axios.get<Boolean>('api/v1/check/downloader');
const { data } = await axios.get<boolean>('api/v1/check/downloader');
return data;
},
};

View File

@@ -1,4 +1,3 @@
import { omit } from 'radash';
import type { BangumiAPI, BangumiRule } from '#/bangumi';
type EventSourceStatus = 'OPEN' | 'CONNECTING' | 'CLOSED';
@@ -43,10 +42,11 @@ export const apiSearch = {
return;
}
const _data = JSON.parse(e.data) as BangumiAPI;
const { filter, rss_link, ...rest } = _data;
const newData: BangumiRule = {
...omit(_data, ['filter', 'rss_link']),
filter: _data.filter.split(','),
rss_link: _data.rss_link.split(','),
...rest,
filter: filter.split(','),
rss_link: rss_link.split(','),
};
// 兜底去重:服务端每条流内已按订阅链接去重,这里防异常场景的重复项
const id = newData.rss_link?.[0] || newData.title_raw;

View File

@@ -141,8 +141,7 @@ describe('plugin-slot', () => {
const wrapper = mountSlot(ui);
await flushPromises();
// 组件通过注入的 host 订阅事件
const el = wrapper.find('.plugin-slot__body').element.shadowRoot!
.firstElementChild!.nextElementSibling as unknown as {
const el = wrapper.find('.plugin-slot__body').element.shadowRoot!.firstElementChild!.nextElementSibling as unknown as {
host: { events: { on: (kind: string, cb: () => void) => void } };
};
el.host.events.on('demo.event', () => {});

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { Link } from '@icon-park/vue-next';
import { Link } from '@lucide/vue';
import { NSpin } from 'naive-ui';
import AbField from './basic/ab-field.vue';
import AbSelect from './basic/ab-select.vue';
@@ -174,13 +174,13 @@ function subscribe() {
<div class="form-group">
<label class="form-label">{{ $t('topbar.add.rss_link') }}</label>
<div class="input-wrapper">
<Link theme="outline" size="16" class="input-icon" />
<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>
</div>
@@ -192,7 +192,7 @@ function subscribe() {
type="text"
class="form-input"
:placeholder="$t('topbar.add.placeholder_name')"
/>
>
</div>
<!-- Options row -->

View File

@@ -1,25 +1,20 @@
<script lang="ts" setup>
import { ErrorPicture, Write } from '@icon-park/vue-next';
import { ImageOff, PenLine } from '@lucide/vue';
import PluginSlot from './plugin-slot.vue';
import type { BangumiRule } from '#/bangumi';
import { useUiSlots } from '@/hooks/usePluginUi';
const props = withDefaults(
defineProps<{
type?: 'primary' | 'search' | 'mobile';
bangumi: BangumiRule;
}>(),
{
type: 'primary',
}
);
const { type = 'primary', bangumi } = defineProps<{
type?: 'primary' | 'search' | 'mobile';
bangumi: BangumiRule;
}>();
defineEmits(['click']);
// 插件经 bangumi.card.action 挂载点追加的操作
const actionSlots = useUiSlots('bangumi.card.action');
const posterSrc = computed(() => resolvePosterUrl(props.bangumi.poster_link));
const posterSrc = computed(() => resolvePosterUrl(bangumi.poster_link));
</script>
<template>
@@ -44,11 +39,11 @@ const posterSrc = computed(() => resolvePosterUrl(props.bangumi.poster_link));
:alt="bangumi.official_title"
class="card-img"
loading="lazy"
/>
>
</template>
<template v-else>
<div class="card-placeholder">
<ErrorPicture theme="outline" size="24" />
<ImageOff :size="24" />
</div>
</template>
@@ -66,7 +61,7 @@ const posterSrc = computed(() => resolvePosterUrl(props.bangumi.poster_link));
role="img"
:aria-label="$t('homepage.rule.edit')"
>
<Write size="18" />
<PenLine :size="18" />
</div>
</div>
</div>
@@ -98,16 +93,18 @@ const posterSrc = computed(() => resolvePosterUrl(props.bangumi.poster_link));
:src="posterSrc"
:alt="bangumi.official_title"
class="search-card-img"
/>
>
</template>
<template v-else>
<div class="card-placeholder card-placeholder--small">
<ErrorPicture theme="outline" size="20" />
<ImageOff :size="20" />
</div>
</template>
</div>
<div class="search-card-meta">
<div class="search-card-title">{{ bangumi.official_title }}</div>
<div class="search-card-title">
{{ bangumi.official_title }}
</div>
<div class="card-tags">
<ab-tag
v-if="bangumi.season"

View File

@@ -12,7 +12,7 @@ const { user, update } = useAuth();
size="sm"
:title="$t('topbar.profile.pop_title')"
>
<div space-y-16>
<div class="space-y-16">
<ab-field :label="$t('topbar.profile.username')">
<ab-input
v-model="user.username"
@@ -30,9 +30,11 @@ const { user, update } = useAuth();
</div>
<template #footer>
<ab-button variant="primary" size="sm" @click="update">{{
$t('topbar.profile.update_btn')
}}</ab-button>
<ab-button variant="primary" size="sm" @click="update">
{{
$t('topbar.profile.update_btn')
}}
</ab-button>
</template>
</ab-modal>
</template>

View File

@@ -1,30 +1,26 @@
<script lang="ts" setup>
withDefaults(
defineProps<{
title: string;
/** 去掉 body 内边距(内容自带布局时,如列表/表格贴边) */
flush?: boolean;
}>(),
{
title: 'title',
flush: false,
}
);
const { title = 'title', flush = false } = defineProps<{
title: string;
/** 去掉 body 内边距(内容自带布局时,如列表/表格贴边) */
flush?: boolean;
}>();
</script>
<template>
<div class="container-card">
<div class="container-header">
<div class="container-title">{{ title }}</div>
<slot name="title-right"></slot>
<div class="container-title">
{{ title }}
</div>
<slot name="title-right" />
</div>
<div class="container-body" :class="{ 'container-body--flush': flush }">
<slot></slot>
<slot />
</div>
<div v-if="$slots.footer" class="container-footer">
<slot name="footer"></slot>
<slot name="footer" />
</div>
</div>
</template>

View File

@@ -398,8 +398,8 @@ function emitUnarchive() {
</advanced-section>
</div>
<!-- 删除确认:嵌套在主弹窗组件树内,headlessui 才会把外层的
Escape/遮罩点击挂起,只关闭内层 -->
<!-- 删除确认:叠在主弹窗之上,naive-ui 的焦点陷阱栈只让最上层响应
Escape,遮罩点击也只关闭内层 -->
<!-- Delete confirmation dialog -->
<ab-modal
v-model:show="deleteFileDialog.show"

View File

@@ -1,48 +1,50 @@
<script lang="ts" setup>
import { Down } from '@icon-park/vue-next';
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue';
import { ChevronDown } from '@lucide/vue';
import { NCollapse, NCollapseItem } from 'naive-ui';
// 折叠面板 v2:headlessui 提供键盘/aria-expanded;
// 旋转箭头 + grid-rows 高度动画(reduced-motion 下直接切换)。
withDefaults(
defineProps<{
title: string;
defaultOpen?: boolean;
}>(),
{
title: 'title',
defaultOpen: true,
}
);
// 折叠面板 v2:naive-ui NCollapse 负责展开状态与高度动画(reduced-motion 下由全局规则直接切换)。
// NCollapse 的头部是无语义的 div,可聚焦的 button 与 aria-expanded 由插槽提供;
// 点击冒泡到 naive-ui 的头部触发切换。正文始终挂载(display-directive="show")。
const { title = 'title', defaultOpen = true } = defineProps<{
title: string;
defaultOpen?: boolean;
}>();
</script>
<template>
<Disclosure v-slot="{ open }" :default-open="defaultOpen">
<div class="fold-panel">
<DisclosureButton
class="fold-panel-header"
:class="{ 'fold-panel-header--open': open }"
>
<div class="fold-panel-title">{{ title }}</div>
<slot name="extra"></slot>
<Down
class="fold-panel-chevron"
:class="{ 'fold-panel-chevron--open': open }"
:size="14"
/>
</DisclosureButton>
<NCollapse
class="fold-panel"
display-directive="show"
:default-expanded-names="defaultOpen ? ['panel'] : []"
>
<NCollapseItem name="panel">
<template #header="{ collapsed }">
<button
type="button"
class="fold-panel-header"
:class="{ 'fold-panel-header--open': !collapsed }"
:aria-expanded="!collapsed"
>
<div class="fold-panel-title">
{{ title }}
</div>
<slot name="extra" />
<ChevronDown
class="fold-panel-chevron"
:class="{ 'fold-panel-chevron--open': !collapsed }"
:size="14"
/>
</button>
</template>
<template #arrow>
<span />
</template>
<div class="fold-panel-collapse" :class="{ 'is-open': open }">
<div class="fold-panel-clip">
<DisclosurePanel static>
<div class="fold-panel-body">
<slot></slot>
</div>
</DisclosurePanel>
</div>
<div class="fold-panel-body">
<slot />
</div>
</div>
</Disclosure>
</NCollapseItem>
</NCollapse>
</template>
<style lang="scss" scoped>
@@ -106,24 +108,17 @@ withDefaults(
}
}
// grid-rows 高度动画:0fr ↔ 1fr(不动画 height 本身)
.fold-panel-collapse {
display: grid;
grid-template-rows: 0fr;
visibility: hidden;
transition: grid-template-rows var(--transition-normal),
visibility 0s var(--transition-normal);
&.is-open {
grid-template-rows: 1fr;
visibility: visible;
transition: grid-template-rows var(--transition-normal), visibility 0s;
}
// naive-ui 的箭头与正文上边距不用:箭头画在 button 里,间距由 body 自己给
.fold-panel :deep(.n-collapse-item-arrow) {
display: none;
}
.fold-panel-clip {
min-height: 0;
overflow: hidden;
// naive-ui 的选择器带三层祖先类,这里写全才能压过
.fold-panel
:deep(
.n-collapse-item .n-collapse-item__content-wrapper .n-collapse-item__content-inner
) {
padding-top: 0;
}
.fold-panel-body {
@@ -137,7 +132,6 @@ withDefaults(
}
@media (prefers-reduced-motion: reduce) {
.fold-panel-collapse,
.fold-panel-chevron {
transition: none;
}

View File

@@ -1,6 +1,5 @@
<script lang="ts" setup>
import { NDynamicTags } from 'naive-ui';
import { isObject } from 'radash';
import AbField from './basic/ab-field.vue';
import AbInput from './basic/ab-input.vue';
import AbSelect from './basic/ab-select.vue';
@@ -8,25 +7,28 @@ import AbSwitch from './basic/ab-switch.vue';
import type { AbSettingProps, SelectItem } from '#/components';
// 设置行:ab-field(标签/说明/错误/aria)+ 对应控件的组合。
const props = withDefaults(defineProps<AbSettingProps>(), {
css: '',
bottomLine: false,
description: '',
error: '',
required: false,
});
const {
css = '',
bottomLine = false,
description = '',
error = '',
required = false,
label,
} = defineProps<AbSettingProps>();
const data = defineModel<any>('data');
const labelText = computed(() =>
typeof props.label === 'function' ? props.label() : props.label
typeof label === 'function' ? label() : label
);
// 旧组件允许 v-model 绑定对象(SelectItem);读取时取 .value,
// 写回时保持字符串(配置值均为字符串)
const selectValue = computed<string | number | null>({
get() {
return isObject(data.value) ? (data.value as SelectItem).value : data.value;
return typeof data.value === 'object' && data.value !== null
? (data.value as SelectItem).value
: data.value;
},
set(value) {
data.value = value;
@@ -47,7 +49,7 @@ const selectValue = computed<string | number | null>({
v-model="data"
:class="css"
:aria-label="labelText"
></AbSwitch>
/>
<AbSelect
v-else-if="type === 'select'"
@@ -56,7 +58,7 @@ const selectValue = computed<string | number | null>({
:class="css"
class="setting-select"
:aria-label="labelText"
></AbSelect>
/>
<AbInput
v-else-if="type === 'input'"
@@ -67,18 +69,18 @@ const selectValue = computed<string | number | null>({
:min="prop?.min"
:max="prop?.max"
:autocomplete="prop?.autocomplete"
></AbInput>
/>
<div v-else-if="type === 'dynamic-tags'" class="dynamic-tags-wrapper">
<NDynamicTags
v-model:value="data"
size="small"
:aria-label="labelText"
></NDynamicTags>
/>
</div>
</AbField>
<div v-if="bottomLine" class="setting-divider"></div>
<div v-if="bottomLine" class="setting-divider" />
</div>
</template>

View File

@@ -1,79 +1,65 @@
<script lang="ts" setup>
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue';
import { AddOne, System, Translate } from '@icon-park/vue-next';
import { CirclePlus, Cog, Languages } from '@lucide/vue';
import type { Component } from 'vue';
import AbMenu from './basic/ab-menu.vue';
import type { AbMenuItem } from './basic/ab-menu.vue';
withDefaults(
defineProps<{
running: boolean;
items: {
id: number;
icon: any;
label: string | (() => string);
handle?: () => void | Promise<void>;
}[];
}>(),
{
running: false,
}
);
const { running = false, items } = defineProps<{
running: boolean;
items: {
id: number;
icon: Component;
label: string | (() => string);
handle?: () => void | Promise<void>;
}[];
}>();
defineEmits<{
(e: 'changeLang'): void;
(e: 'clickAdd'): void;
}>();
function abLabel(label: string | (() => string)) {
if (typeof label === 'function') {
return label();
} else {
return label;
}
}
// 系统菜单复用 ab-menu(naive-ui NDropdown)
const menuItems = computed<AbMenuItem[]>(() =>
items.map((i) => ({
key: i.id,
label: i.label,
icon: i.icon,
handler: i.handle,
}))
);
</script>
<template>
<Menu>
<div class="status-bar">
<div class="status-bar-actions">
<button
class="status-bar-btn"
aria-label="Switch language"
@click="() => $emit('changeLang')"
>
<Translate theme="outline" size="1em" />
</button>
<div class="status-bar">
<div class="status-bar-actions">
<button
class="status-bar-btn"
aria-label="Switch language"
@click="() => $emit('changeLang')"
>
<Languages :size="18" />
</button>
<button
class="status-bar-btn"
aria-label="Add RSS subscription"
@click="() => $emit('clickAdd')"
>
<AddOne theme="outline" size="1em" />
</button>
<button
class="status-bar-btn"
aria-label="Add RSS subscription"
@click="() => $emit('clickAdd')"
>
<CirclePlus :size="18" />
</button>
<MenuButton class="status-bar-btn" aria-label="System menu">
<System theme="outline" size="1em" />
</MenuButton>
<AbMenu :items="menuItems" align="right">
<template #trigger>
<button class="status-bar-btn" aria-label="System menu">
<Cog :size="18" />
</button>
</template>
</AbMenu>
<ab-status :state="running ? 'running' : 'stopped'" />
</div>
<MenuItems class="status-menu">
<MenuItem v-for="i in items" :key="i.id" v-slot="{ active }">
<div
class="status-menu-item"
:class="[active && 'status-menu-item--active']"
@click="() => i.handle && i.handle()"
>
<div class="status-menu-item-icon">
<Component :is="i.icon" size="16"></Component>
</div>
<div class="status-menu-item-label">{{ abLabel(i.label) }}</div>
</div>
</MenuItem>
</MenuItems>
<ab-status :state="running ? 'running' : 'stopped'" />
</div>
</Menu>
</div>
</template>
<style lang="scss" scoped>
@@ -125,67 +111,4 @@ function abLabel(label: string | (() => string)) {
outline-offset: 2px;
}
}
.status-menu {
position: absolute;
top: 44px;
right: 0;
width: 180px;
padding: 4px;
border-radius: var(--radius-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-lg);
z-index: 50;
overflow: hidden;
animation: dropdown-in 150ms ease-out;
transform-origin: top right;
transition: background-color var(--transition-normal),
border-color var(--transition-normal);
}
@keyframes dropdown-in {
from {
opacity: 0;
transform: scale(0.95) translateY(-4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.status-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: var(--touch-target);
padding: 0 12px;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--color-text);
transition: color var(--transition-fast),
background-color var(--transition-fast);
&:hover,
&--active {
color: var(--color-primary);
background: var(--color-primary-light);
}
&:active {
transform: scale(0.98);
}
}
.status-menu-item-icon {
color: var(--color-primary);
display: flex;
align-items: center;
}
.status-menu-item-label {
font-size: 12px;
}
</style>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { Close, Delete, FullSelection } from '@icon-park/vue-next';
import { ListChecks, Trash, X } from '@lucide/vue';
import { useConfirm } from '@/hooks/useConfirm';
import type { Torrent } from '#/torrent';
import { useTorrentList } from '@/hooks/useTorrentList';
@@ -59,11 +59,13 @@ onActivated(load);
<template>
<div class="page-torrents">
<div class="toolbar">
<h2 class="toolbar-title">{{ title }}</h2>
<h2 class="toolbar-title">
{{ title }}
</h2>
<div class="toolbar-actions">
<ab-button v-if="torrents.length > 0" size="sm" @click="toggleAll">
<template #icon>
<FullSelection :size="14" />
<ListChecks :size="14" />
</template>
{{
allSelected
@@ -78,7 +80,7 @@ onActivated(load);
@click="handleDeleteSelected"
>
<template #icon>
<Delete :size="14" />
<Trash :size="14" />
</template>
{{
$t('homepage.torrents.delete_selected', { count: selectedIds.size })
@@ -91,7 +93,7 @@ onActivated(load);
@click="handleClearAll"
>
<template #icon>
<Delete :size="14" />
<Trash :size="14" />
</template>
{{ $t('homepage.torrents.deleteAll') }}
</ab-button>
@@ -153,7 +155,7 @@ onActivated(load);
:label="$t('homepage.torrents.delete')"
@click.stop="handleDeleteOne(torrent.id)"
>
<Close :size="14" />
<X :size="14" />
</ab-icon-button>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { Down } from '@icon-park/vue-next';
import { ChevronDown } from '@lucide/vue';
/** v-model open state */
const open = defineModel<boolean>('open', { default: false });
@@ -13,11 +13,10 @@ const open = defineModel<boolean>('open', { default: false });
:aria-expanded="open"
@click="open = !open"
>
<Down
<ChevronDown
class="advanced-chevron"
:class="{ 'advanced-chevron--open': open }"
theme="outline"
size="14"
:size="14"
/>
{{ $t('search.confirm.advanced') }}
</button>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { ErrorPicture } from '@icon-park/vue-next';
import { ImageOff } from '@lucide/vue';
import type { BangumiRule } from '#/bangumi';
/**
@@ -17,11 +17,11 @@ const rule = defineModel<BangumiRule>('rule', { required: true });
<div class="bangumi-info">
<div class="bangumi-poster">
<template v-if="rule.poster_link">
<img :src="posterSrc" :alt="rule.official_title" />
<img :src="posterSrc" :alt="rule.official_title">
</template>
<template v-else>
<div class="poster-placeholder">
<ErrorPicture theme="outline" size="32" />
<ImageOff :size="32" />
</div>
</template>
</div>
@@ -32,8 +32,10 @@ const rule = defineModel<BangumiRule>('rule', { required: true });
:aria-label="$t('homepage.rule.official_title')"
class="title-input"
:placeholder="$t('homepage.rule.official_title')"
/>
<p v-if="rule.title_raw" class="bangumi-subtitle">{{ rule.title_raw }}</p>
>
<p v-if="rule.title_raw" class="bangumi-subtitle">
{{ rule.title_raw }}
</p>
<div class="meta-row">
<input
:value="rule.year ?? ''"
@@ -43,7 +45,7 @@ const rule = defineModel<BangumiRule>('rule', { required: true });
:class="{ 'year-input--empty': !rule.year }"
:placeholder="$t('homepage.rule.year')"
@input="(e) => (rule.year = (e.target as HTMLInputElement).value || null)"
/>
>
<span class="meta-separator">·</span>
<label class="season-label">S</label>
<input
@@ -52,7 +54,7 @@ const rule = defineModel<BangumiRule>('rule', { required: true });
:aria-label="$t('homepage.rule.season')"
class="season-input"
min="1"
/>
>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { CheckOne, Copy } from '@icon-park/vue-next';
import { CircleCheck, Copy } from '@lucide/vue';
const props = defineProps<{
link: string;
@@ -23,8 +23,8 @@ const emit = defineEmits<{
:label="$t('common.copy')"
@click="emit('copy')"
>
<CheckOne v-if="props.copied" theme="outline" size="14" />
<Copy v-else theme="outline" size="14" />
<CircleCheck v-if="props.copied" :size="14" />
<Copy v-else :size="14" />
</ab-icon-button>
</div>
</div>

View File

@@ -7,7 +7,7 @@ describe('ab-input', () => {
const wrapper = mount(AbInput, {
props: {
modelValue: 'abc',
'onUpdate:modelValue': (v: string) =>
'onUpdate:modelValue': (v: string | number) =>
wrapper.setProps({ modelValue: v }),
},
});
@@ -27,7 +27,7 @@ describe('ab-input', () => {
props: {
modelValue: 'abc',
clearable: true,
'onUpdate:modelValue': (v: string) =>
'onUpdate:modelValue': (v: string | number) =>
wrapper.setProps({ modelValue: v }),
},
});

View File

@@ -1,7 +1,16 @@
import { describe, expect, it, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { flushPromises, mount } from '@vue/test-utils';
import AbMenu from '../ab-menu.vue';
// NDropdown 把菜单传送到 document.body,断言从 document 查,测试之间清空
afterEach(() => {
document.body.innerHTML = '';
});
function menuItems() {
return document.querySelectorAll<HTMLElement>('[role="menuitem"]');
}
function items(handler = vi.fn()) {
return [
{ key: 'enable', label: 'Enable rule', handler },
@@ -18,8 +27,8 @@ describe('ab-menu', () => {
attachTo: document.body,
});
await wrapper.find('button').trigger('click');
expect(wrapper.text()).toContain('Enable rule');
expect(wrapper.text()).toContain('Archive rule');
expect(document.body.textContent).toContain('Enable rule');
expect(document.body.textContent).toContain('Archive rule');
});
it('should call the item handler and emits select on item click', async () => {
@@ -30,13 +39,27 @@ describe('ab-menu', () => {
attachTo: document.body,
});
await wrapper.find('button').trigger('click');
await wrapper.findAll('[role="menuitem"]')[0].trigger('click');
menuItems()[0].click();
await flushPromises();
expect(handler).toHaveBeenCalledTimes(1);
expect(wrapper.emitted('select')?.[0]?.[0]).toMatchObject({
key: 'enable',
});
});
it('should expose the popup state on the trigger', async () => {
const wrapper = mount(AbMenu, {
props: { items: items() },
slots: { trigger: '<button type="button">Actions</button>' },
attachTo: document.body,
});
const trigger = wrapper.find('button');
expect(trigger.attributes('aria-haspopup')).toBe('menu');
expect(trigger.attributes('aria-expanded')).toBe('false');
await trigger.trigger('click');
expect(trigger.attributes('aria-expanded')).toBe('true');
});
it('should mark danger items with the danger class', async () => {
const wrapper = mount(AbMenu, {
props: { items: items() },
@@ -44,7 +67,6 @@ describe('ab-menu', () => {
attachTo: document.body,
});
await wrapper.find('button').trigger('click');
const entries = wrapper.findAll('[role="menuitem"]');
expect(entries[2].classes()).toContain('ab-menu-item--danger');
expect(menuItems()[2].classList).toContain('ab-menu-item--danger');
});
});

View File

@@ -3,7 +3,7 @@ import { defineComponent, ref } from 'vue';
import { mount } from '@vue/test-utils';
import AbModal from '../ab-modal.vue';
// headlessui teleports dialogs into document.body and its leave transition
// NModal teleports dialogs into document.body and its leave transition
// never completes under happy-dom — wipe the body between tests.
afterEach(() => {
document.body.innerHTML = '';
@@ -16,7 +16,7 @@ async function mountModal(props = {}, slots = {}) {
slots: { default: '<p>body text</p>', ...slots },
attachTo: document.body,
});
// headlessui mounts dialog content on the next tick (transition setup)
// NModal mounts dialog content on the next tick (transition setup)
await new Promise((resolve) => setTimeout(resolve));
return wrapper;
}
@@ -89,7 +89,7 @@ describe('ab-modal', () => {
const innerBody = document.querySelector('#inner-body') as HTMLElement;
expect(innerBody).not.toBeNull();
// headlessui 的 outside-click 走 pointer/mouse 事件序列
// naive-ui 的 clickoutside 走 pointer/mouse 事件序列
for (const type of ['pointerdown', 'mousedown', 'click']) {
innerBody.dispatchEvent(
new MouseEvent(type, { bubbles: true, cancelable: true })

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { flushPromises, mount } from '@vue/test-utils';
import AbSplitButton from '../ab-split-button.vue';
const options = [
@@ -7,6 +7,11 @@ const options = [
{ label: 'Enable + collect season', value: 'collect' },
];
// 选项菜单由 NDropdown 传送到 document.body
afterEach(() => {
document.body.innerHTML = '';
});
describe('ab-split-button', () => {
it('should show the label of the selected value on the main button', () => {
const wrapper = mount(AbSplitButton, {
@@ -31,7 +36,8 @@ describe('ab-split-button', () => {
attachTo: document.body,
});
await wrapper.find('.ab-split-arrow').trigger('click');
await wrapper.findAll('[role="menuitem"]')[1].trigger('click');
document.querySelectorAll<HTMLElement>('[role="menuitem"]')[1].click();
await flushPromises();
expect(wrapper.emitted('update:value')?.[0]).toEqual(['collect']);
});

View File

@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import AbAlert from './ab-alert.vue';

View File

@@ -1,19 +1,12 @@
<script lang="ts" setup>
// Soft Ink 行内提示:透明底 + 细线框,语义由加粗的彩色引导词承载 —
// 不用图标、不用彩色填充(拒绝 AI 模板味的着色警告框)。
withDefaults(
defineProps<{
type?: 'info' | 'warning' | 'danger';
/** 加粗引导词,如 “Feed unreachable.”(语义色只落在这里) */
title?: string;
closable?: boolean;
}>(),
{
type: 'info',
title: '',
closable: false,
}
);
const { type = 'info', title = '', closable = false } = defineProps<{
type?: 'info' | 'warning' | 'danger';
/** 加粗引导词,如 “Feed unreachable.”(语义色只落在这里) */
title?: string;
closable?: boolean;
}>();
defineEmits<{ close: [] }>();
</script>
@@ -26,11 +19,11 @@ defineEmits<{ close: [] }>();
>
<p class="ab-alert-text">
<b v-if="title" class="ab-alert-title">{{ title }}</b>
<slot></slot>
<slot />
</p>
<span v-if="$slots.action" class="ab-alert-action">
<slot name="action"></slot>
<slot name="action" />
</span>
<button

View File

@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import AbBadge from './ab-badge.vue';

View File

@@ -1,22 +1,15 @@
<script lang="ts" setup>
// 计数/圆点徽标(通知中心等):溢出显示 max+,count 为 0 时隐藏。
const props = withDefaults(
defineProps<{
count?: number;
/** 只显示小圆点(不显示数字) */
dot?: boolean;
max?: number;
}>(),
{
count: 0,
dot: false,
max: 99,
}
);
const { count = 0, dot = false, max = 99 } = defineProps<{
count?: number;
/** 只显示小圆点(不显示数字) */
dot?: boolean;
max?: number;
}>();
const visible = computed(() => props.dot || props.count > 0);
const visible = computed(() => dot || count > 0);
const text = computed(() =>
props.count > props.max ? `${props.max}+` : String(props.count)
count > max ? `${max}+` : String(count)
);
</script>

View File

@@ -1,50 +1,49 @@
<script lang="ts" setup>
import { computed, onUnmounted, ref, watch } from 'vue';
import { usePointerSwipe } from '@vueuse/core';
import {
Dialog,
DialogPanel,
DialogTitle,
TransitionChild,
TransitionRoot,
} from '@headlessui/vue';
import { Close } from '@icon-park/vue-next';
computed,
onWatcherCleanup,
ref,
useId,
useTemplateRef,
watch,
} from 'vue';
import { usePointerSwipe } from '@vueuse/core';
import { X } from '@lucide/vue';
import { NDrawer } from 'naive-ui';
import AbIconButton from './ab-icon-button.vue';
const props = withDefaults(
defineProps<{
show: boolean;
title?: string;
closeable?: boolean;
showClose?: boolean;
closeLabel?: string;
maxHeight?: string;
fullscreen?: boolean;
avoidKeyboard?: boolean;
}>(),
{
closeable: true,
showClose: true,
closeLabel: 'Close',
maxHeight: '85dvh',
fullscreen: false,
avoidKeyboard: true,
}
);
// 底部抽屉:naive-ui NDrawer 提供焦点陷阱、Esc/遮罩关闭与 role="dialog";
// NDrawer 本身只当透明外壳,可见面板与拖拽/键盘位移都在内层 div 上。
const {
title,
closeable = true,
showClose = true,
closeLabel = 'Close',
maxHeight = '85dvh',
fullscreen = false,
avoidKeyboard = true,
} = defineProps<{
title?: string;
closeable?: boolean;
showClose?: boolean;
closeLabel?: string;
maxHeight?: string;
fullscreen?: boolean;
avoidKeyboard?: boolean;
}>();
const emit = defineEmits<{
(e: 'update:show', value: boolean): void;
(e: 'close'): void;
}>();
const sheetRef = ref<HTMLElement | null>(null);
const dragHandleRef = ref<HTMLElement | null>(null);
const show = defineModel<boolean>('show', { required: true });
const titleId = useId();
const dragHandleRef = useTemplateRef<HTMLElement>('dragHandle');
const translateY = ref(0);
const isDragging = ref(false);
const keyboardHeight = ref(0);
const panelMaxHeight = computed(() =>
props.fullscreen ? '100dvh' : props.maxHeight
);
const panelMaxHeight = computed(() => (fullscreen ? '100dvh' : maxHeight));
// Handle iOS Safari virtual keyboard using visualViewport API
function handleViewportResize() {
@@ -58,28 +57,23 @@ function handleViewportResize() {
// Set up visualViewport listeners when sheet is shown
watch(
() => [props.show, props.avoidKeyboard] as const,
() => [show.value, avoidKeyboard] as const,
([isVisible, shouldAvoidKeyboard]) => {
if (isVisible && shouldAvoidKeyboard && window.visualViewport) {
window.visualViewport.addEventListener('resize', handleViewportResize);
window.visualViewport.addEventListener('scroll', handleViewportResize);
handleViewportResize();
} else if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', handleViewportResize);
window.visualViewport.removeEventListener('scroll', handleViewportResize);
const viewport = window.visualViewport;
if (!isVisible || !shouldAvoidKeyboard || !viewport) return;
viewport.addEventListener('resize', handleViewportResize);
viewport.addEventListener('scroll', handleViewportResize);
handleViewportResize();
// 下次触发或卸载时撤掉监听
onWatcherCleanup(() => {
viewport.removeEventListener('resize', handleViewportResize);
viewport.removeEventListener('scroll', handleViewportResize);
keyboardHeight.value = 0;
}
});
},
{ immediate: true }
);
onUnmounted(() => {
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', handleViewportResize);
window.visualViewport.removeEventListener('scroll', handleViewportResize);
}
});
const sheetStyle = computed(() => {
const style: Record<string, string> = {};
@@ -117,111 +111,74 @@ const { distanceY } = usePointerSwipe(dragHandleRef, {
});
function close() {
if (props.closeable) {
emit('update:show', false);
if (closeable) {
show.value = false;
emit('close');
}
}
</script>
<template>
<TransitionRoot :show="show" as="template">
<Dialog class="ab-bottom-sheet" @close="close">
<!-- Backdrop -->
<TransitionChild
enter="overlay-enter-active"
enter-from="overlay-enter-from"
leave="overlay-leave-active"
leave-to="overlay-leave-to"
<NDrawer
:show="show"
placement="bottom"
height="auto"
class="ab-bottom-sheet"
:mask-closable="closeable"
:close-on-esc="closeable"
:aria-labelledby="title ? titleId : undefined"
@update:show="(value: boolean) => value || close()"
>
<div
data-testid="bottom-sheet-panel"
class="ab-bottom-sheet__panel"
:class="{ 'ab-bottom-sheet__panel--fullscreen': fullscreen }"
:style="[sheetStyle, { maxHeight: panelMaxHeight }]"
>
<!-- Drag handle -->
<div ref="dragHandle" class="ab-bottom-sheet__handle">
<div class="ab-bottom-sheet__handle-bar" />
</div>
<!-- Title -->
<div
v-if="title || (closeable && showClose)"
class="ab-bottom-sheet__header"
>
<div class="ab-bottom-sheet__backdrop" aria-hidden="true" />
</TransitionChild>
<h3 v-if="title" :id="titleId" class="ab-bottom-sheet__title">
{{ title }}
</h3>
<AbIconButton
v-if="closeable && showClose"
class="ab-bottom-sheet__close"
size="md"
:label="closeLabel"
@click="close"
>
<X :size="16" />
</AbIconButton>
</div>
<!-- Sheet panel -->
<TransitionChild
enter="sheet-enter-active"
enter-from="sheet-enter-from"
leave="sheet-leave-active"
leave-to="sheet-leave-to"
>
<div class="ab-bottom-sheet__container">
<DialogPanel
ref="sheetRef"
data-testid="bottom-sheet-panel"
class="ab-bottom-sheet__panel"
:class="{ 'ab-bottom-sheet__panel--fullscreen': fullscreen }"
:style="[sheetStyle, { maxHeight: panelMaxHeight }]"
>
<!-- Drag handle -->
<div ref="dragHandleRef" class="ab-bottom-sheet__handle">
<div class="ab-bottom-sheet__handle-bar" />
</div>
<!-- Content -->
<div class="ab-bottom-sheet__content">
<slot />
</div>
<!-- Title -->
<div
v-if="title || (closeable && showClose)"
class="ab-bottom-sheet__header"
>
<DialogTitle v-if="title" as="h3" class="ab-bottom-sheet__title">
{{ title }}
</DialogTitle>
<AbIconButton
v-if="closeable && showClose"
class="ab-bottom-sheet__close"
size="md"
:label="closeLabel"
@click="close"
>
<Close theme="outline" size="16" />
</AbIconButton>
</div>
<!-- Content -->
<div class="ab-bottom-sheet__content">
<slot />
</div>
<!-- Footer actions -->
<div v-if="$slots.footer" class="ab-bottom-sheet__footer">
<slot name="footer" />
</div>
</DialogPanel>
</div>
</TransitionChild>
</Dialog>
</TransitionRoot>
<!-- Footer actions -->
<div v-if="$slots.footer" class="ab-bottom-sheet__footer">
<slot name="footer" />
</div>
</div>
</NDrawer>
</template>
<style lang="scss" scoped>
.ab-bottom-sheet {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: flex;
align-items: flex-end;
&__backdrop {
position: fixed;
inset: 0;
z-index: var(--z-modal-backdrop);
background: var(--color-overlay);
backdrop-filter: blur(4px);
}
&__container {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: flex;
align-items: flex-end;
justify-content: center;
pointer-events: none;
}
&__panel {
position: relative;
width: 100%;
max-width: 640px;
margin: 0 auto;
max-height: 85dvh; // Use dynamic viewport height for iOS Safari keyboard support
background: var(--color-surface);
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
@@ -312,3 +269,17 @@ function close() {
}
}
</style>
<!-- 不加 scoped:NDrawer 把 class 合并到传送到 body 的 .n-drawer 上,
scoped 选择器命中不了。外壳透明且不接收点击,两侧点击落到遮罩上。 -->
<style lang="scss">
.ab-bottom-sheet.n-drawer {
background: none;
box-shadow: none;
pointer-events: none;
}
.n-drawer-container:has(> .ab-bottom-sheet) > .n-drawer-mask {
backdrop-filter: blur(4px);
}
</style>

View File

@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import AbButton from './ab-button.vue';

View File

@@ -1,29 +1,26 @@
<script lang="ts" setup>
// Soft Ink 通用按钮:页面中唯一的按钮词汇(禁止裸 <button> / NButton)。
// danger 变体默认为柔和填充,hover 时升级为实色(Primer 式渐进破坏确认)。
const props = withDefaults(
defineProps<{
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md';
type?: 'button' | 'submit';
loading?: boolean;
disabled?: boolean;
block?: boolean;
}>(),
{
variant: 'secondary',
size: 'md',
type: 'button',
loading: false,
disabled: false,
block: false,
}
);
const {
variant = 'secondary',
size = 'md',
type = 'button',
loading = false,
disabled = false,
block = false,
} = defineProps<{
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md';
type?: 'button' | 'submit';
loading?: boolean;
disabled?: boolean;
block?: boolean;
}>();
const emit = defineEmits<{ click: [event: MouseEvent] }>();
function onClick(event: MouseEvent) {
if (props.disabled || props.loading) return;
if (disabled || loading) return;
emit('click', event);
}
</script>
@@ -37,9 +34,9 @@ function onClick(event: MouseEvent) {
:aria-busy="loading || undefined"
@click="onClick"
>
<span v-if="loading" class="ab-btn-spin" aria-hidden="true"></span>
<slot v-else name="icon"></slot>
<slot></slot>
<span v-if="loading" class="ab-btn-spin" aria-hidden="true" />
<slot v-else name="icon" />
<slot />
</button>
</template>

View File

@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import AbEmpty from './ab-empty.vue';

View File

@@ -1,23 +1,22 @@
<script lang="ts" setup>
// Soft Ink 空状态:左对齐的说明文字 + 一个下一步动作 —
// 教用户界面怎么用,而不是居中图标块 + “暂无数据”。
withDefaults(
defineProps<{
title: string;
description?: string;
}>(),
{
description: '',
}
);
const { description = '' } = defineProps<{
title: string;
description?: string;
}>();
</script>
<template>
<div class="ab-empty">
<p class="ab-empty-title">{{ title }}</p>
<p v-if="description" class="ab-empty-description">{{ description }}</p>
<p class="ab-empty-title">
{{ title }}
</p>
<p v-if="description" class="ab-empty-description">
{{ description }}
</p>
<div v-if="$slots.action" class="ab-empty-action">
<slot name="action"></slot>
<slot name="action" />
</div>
</div>
</template>

View File

@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import AbField from './ab-field.vue';
import AbInput from './ab-input.vue';

View File

@@ -22,19 +22,12 @@ export const abFieldInjectionKey: InjectionKey<AbFieldContext> =
<script lang="ts" setup>
// 表单字段容器(取代 ab-label):label + description + error + aria 接线。
// 控件放默认插槽;ab-input 等会自动消费注入的 id/aria 上下文。
const props = withDefaults(
defineProps<{
label: string | (() => string);
description?: string;
error?: string;
required?: boolean;
}>(),
{
description: '',
error: '',
required: false,
}
);
const { description = '', error = '', required = false, label } = defineProps<{
label: string | (() => string);
description?: string;
error?: string;
required?: boolean;
}>();
// Vue 类型将 useId 标为可空(组件实例外);setup 内恒为 string
const controlId = useId() as string;
@@ -43,17 +36,17 @@ const descriptionId = useId() as string;
const errorId = useId() as string;
const labelText = computed(() =>
typeof props.label === 'function' ? props.label() : props.label
typeof label === 'function' ? label() : label
);
const describedBy = computed(() => {
const ids: string[] = [];
if (props.description) ids.push(descriptionId);
if (props.error) ids.push(errorId);
if (description) ids.push(descriptionId);
if (error) ids.push(errorId);
return ids.length > 0 ? ids.join(' ') : undefined;
});
const invalid = computed(() => Boolean(props.error));
const invalid = computed(() => Boolean(error));
// ab-input 等原生控件在挂载时认领 controlId;naive 组件无法接收 id,
// 走 aria-labelledby(此时渲染 span 而非悬空的 label[for])
@@ -79,9 +72,7 @@ provide(abFieldInjectionKey, {
:for="adopted ? controlId : undefined"
>
{{ labelText }}
<span v-if="required" class="ab-field-required" aria-hidden="true"
>*</span
>
<span v-if="required" class="ab-field-required" aria-hidden="true">*</span>
</Component>
<p v-if="description" :id="descriptionId" class="ab-field-description">
{{ description }}
@@ -89,7 +80,7 @@ provide(abFieldInjectionKey, {
</div>
<div class="ab-field-control">
<slot></slot>
<slot />
</div>
</div>

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import { AddOne, Delete, Edit } from '@icon-park/vue-next';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { CirclePlus, Pencil, Trash } from '@lucide/vue';
import AbIconButton from './ab-icon-button.vue';
@@ -15,24 +15,24 @@ type Story = StoryObj<typeof AbIconButton>;
export const Template: Story = {
args: { label: 'Add' },
render: (args) => ({
components: { AbIconButton, AddOne },
components: { AbIconButton, CirclePlus },
setup() {
return { args };
},
template: '<ab-icon-button v-bind="args"><add-one /></ab-icon-button>',
template: '<ab-icon-button v-bind="args"><circle-plus /></ab-icon-button>',
}),
};
export const Row: Story = {
render: () => ({
components: { AbIconButton, AddOne, Edit, Delete },
components: { AbIconButton, CirclePlus, Pencil, Trash },
template: `
<div style="display:flex;gap:4px">
<ab-icon-button label="Add"><add-one /></ab-icon-button>
<ab-icon-button label="Edit"><edit /></ab-icon-button>
<ab-icon-button label="Delete"><delete /></ab-icon-button>
<ab-icon-button label="Disabled" disabled><edit /></ab-icon-button>
<ab-icon-button label="Small" size="sm"><edit /></ab-icon-button>
<ab-icon-button label="Add"><circle-plus /></ab-icon-button>
<ab-icon-button label="Edit"><pencil /></ab-icon-button>
<ab-icon-button label="Delete"><trash /></ab-icon-button>
<ab-icon-button label="Disabled" disabled><pencil /></ab-icon-button>
<ab-icon-button label="Small" size="sm"><pencil /></ab-icon-button>
</div>`,
}),
};

View File

@@ -1,27 +1,24 @@
<script lang="ts" setup>
// 图标按钮:label 必填(即 aria-label);触屏下命中区 ≥ 44px。
const props = withDefaults(
defineProps<{
/** 无文本按钮的可访问名称(必填) */
label: string;
size?: 'sm' | 'md';
/** solid:主色填充(用于卡片上的主要动作);round:圆形 */
variant?: 'ghost' | 'solid';
round?: boolean;
disabled?: boolean;
}>(),
{
size: 'md',
variant: 'ghost',
round: false,
disabled: false,
}
);
const {
size = 'md',
variant = 'ghost',
round = false,
disabled = false,
} = defineProps<{
/** 无文本按钮的可访问名称(必填) */
label: string;
size?: 'sm' | 'md';
/** solid:主色填充(用于卡片上的主要动作);round:圆形 */
variant?: 'ghost' | 'solid';
round?: boolean;
disabled?: boolean;
}>();
const emit = defineEmits<{ click: [event: MouseEvent] }>();
function onClick(event: MouseEvent) {
if (props.disabled) return;
if (disabled) return;
emit('click', event);
}
</script>
@@ -40,7 +37,7 @@ function onClick(event: MouseEvent) {
:disabled="disabled"
@click="onClick"
>
<slot></slot>
<slot />
</button>
</template>

View File

@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { ref } from 'vue';
import AbInput from './ab-input.vue';

View File

@@ -4,35 +4,26 @@ import { abFieldInjectionKey } from './ab-field.vue';
// 文本输入组件(取代 UnoCSS 的 ab-input 快捷类):
// Soft Ink 填充式,左对齐;支持前后缀、清空、密码可见切换、错误态。
const props = withDefaults(
defineProps<{
type?: 'text' | 'password' | 'number' | 'url' | 'search';
placeholder?: string;
disabled?: boolean;
clearable?: boolean;
/** 错误态样式;错误文案由外层 ab-field 显示 */
error?: boolean;
ariaLabel?: string;
min?: number;
max?: number;
step?: number;
maxlength?: number;
autocomplete?: string;
}>(),
{
type: 'text',
placeholder: '',
disabled: false,
clearable: false,
error: false,
ariaLabel: undefined,
min: undefined,
max: undefined,
step: undefined,
maxlength: undefined,
autocomplete: undefined,
}
);
const {
type = 'text',
placeholder = '',
disabled = false,
clearable = false,
error = false,
} = defineProps<{
type?: 'text' | 'password' | 'number' | 'url' | 'search';
placeholder?: string;
disabled?: boolean;
clearable?: boolean;
/** 错误态样式;错误文案由外层 ab-field 显示 */
error?: boolean;
ariaLabel?: string;
min?: number;
max?: number;
step?: number;
maxlength?: number;
autocomplete?: string;
}>();
const model = defineModel<string | number>({ default: '' });
@@ -42,15 +33,15 @@ if (field) field.adopted.value = true;
const revealed = ref(false);
const effectiveType = computed(() => {
if (props.type === 'password') return revealed.value ? 'text' : 'password';
return props.type;
if (type === 'password') return revealed.value ? 'text' : 'password';
return type;
});
const invalid = computed(() => props.error || field?.invalid.value === true);
const invalid = computed(() => error || field?.invalid.value === true);
function onInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
model.value = props.type === 'number' && value !== '' ? Number(value) : value;
model.value = type === 'number' && value !== '' ? Number(value) : value;
}
function clear() {
@@ -67,7 +58,7 @@ function clear() {
}"
>
<span v-if="$slots.prefix" class="ab-input-affix">
<slot name="prefix"></slot>
<slot name="prefix" />
</span>
<input
@@ -85,7 +76,7 @@ function clear() {
:aria-describedby="field?.describedBy.value"
:aria-invalid="invalid || undefined"
@input="onInput"
/>
>
<button
v-if="clearable && String(model).length > 0 && !disabled"
@@ -151,7 +142,7 @@ function clear() {
</button>
<span v-if="$slots.suffix" class="ab-input-affix">
<slot name="suffix"></slot>
<slot name="suffix" />
</span>
</span>
</template>

View File

@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import AbList from './ab-list.vue';

View File

@@ -7,34 +7,31 @@ type ListItem = Record<string, any>;
// Soft Ink 列表(取代 ab-data-list):surface-2 填充行、悬停/聚焦态、
// 受控选择、加载骨架、i18n 空状态;density 供桌面数据页用紧凑行。
const props = withDefaults(
defineProps<{
items: ListItem[];
keyField?: string;
selectable?: boolean;
/** 受控选择:v-model:selected */
selected?: unknown[];
loading?: boolean;
density?: 'comfortable' | 'compact';
}>(),
{
keyField: 'id',
selectable: false,
selected: () => [],
loading: false,
density: 'comfortable',
}
);
const {
items,
keyField = 'id',
selectable = false,
loading = false,
density = 'comfortable',
} = defineProps<{
items: ListItem[];
keyField?: string;
selectable?: boolean;
loading?: boolean;
density?: 'comfortable' | 'compact';
}>();
const emit = defineEmits<{
'update:selected': [keys: unknown[]];
'row-click': [item: ListItem];
}>();
const selectedSet = computed(() => new Set(props.selected));
/** 受控选择:v-model:selected */
const selected = defineModel<unknown[]>('selected', { default: () => [] });
const selectedSet = computed(() => new Set(selected.value));
const allSelected = computed(
() => props.items.length > 0 && selectedSet.value.size === props.items.length
() => items.length > 0 && selectedSet.value.size === items.length
);
const someSelected = computed(
@@ -45,18 +42,13 @@ function toggle(key: unknown) {
const next = new Set(selectedSet.value);
if (next.has(key)) next.delete(key);
else next.add(key);
emit('update:selected', Array.from(next));
selected.value = Array.from(next);
}
function toggleAll() {
if (allSelected.value) {
emit('update:selected', []);
} else {
emit(
'update:selected',
props.items.map((item) => item[props.keyField])
);
}
selected.value = allSelected.value
? []
: items.map((item) => item[keyField]);
}
</script>
@@ -70,12 +62,10 @@ function toggleAll() {
:checked="allSelected"
:indeterminate="someSelected"
@change="toggleAll"
/>
>
<span>{{ $t('common.selectAll') }}</span>
</label>
<span class="ab-list-count"
>{{ items.length }} {{ $t('common.items') }}</span
>
<span class="ab-list-count">{{ items.length }} {{ $t('common.items') }}</span>
</div>
<!-- 加载骨架 -->
@@ -102,7 +92,7 @@ function toggleAll() {
:checked="selectedSet.has(item[keyField])"
:aria-label="$t('common.select')"
@change="toggle(item[keyField])"
/>
>
</label>
<div class="ab-list-row-content">
@@ -115,7 +105,7 @@ function toggleAll() {
<!-- 空状态 -->
<div v-if="items.length === 0" class="ab-list-empty">
<slot name="empty">
<AbEmpty :title="$t('common.empty')"></AbEmpty>
<AbEmpty :title="$t('common.empty')" />
</slot>
</div>
</template>

View File

@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import AbButton from './ab-button.vue';
import AbMenu from './ab-menu.vue';

View File

@@ -1,6 +1,8 @@
<script lang="ts" setup>
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue';
import type { Component } from 'vue';
import { cloneVNode, h } from 'vue';
import type { Component, VNode } from 'vue';
import { NDropdown } from 'naive-ui';
import type { DropdownOption } from 'naive-ui';
export interface AbMenuItem {
key?: string | number;
@@ -11,133 +13,80 @@ export interface AbMenuItem {
handler?: () => void | Promise<void>;
}
// 下拉菜单:headlessui Menu 负责键盘导航与 aria;
// 触发器放在 #trigger 插槽(渲染为 MenuButton)。
withDefaults(
defineProps<{
items: AbMenuItem[];
/** 菜单相对触发器的对齐边 */
align?: 'left' | 'right';
}>(),
{
align: 'left',
}
);
// 下拉菜单:naive-ui NDropdown 负责定位与键盘导航(方向键/回车/Esc);
// 触发器放在 #trigger 插槽。NDropdown 不输出 aria,menuitem 角色与
// 触发器的 aria-haspopup/aria-expanded 在这里补上。
const { items, align = 'left' } = defineProps<{
items: AbMenuItem[];
/** 菜单相对触发器的对齐边 */
align?: 'left' | 'right';
}>();
const emit = defineEmits<{ select: [item: AbMenuItem] }>();
function itemLabel(item: AbMenuItem): string {
return typeof item.label === 'function' ? item.label() : item.label;
const slots = defineSlots<{ trigger: () => VNode[] }>();
const open = ref(false);
const options = computed<DropdownOption[]>(() =>
items.map((item, index) => ({
key: item.key ?? index,
label: item.label,
disabled: item.disabled,
icon: item.icon && (() => h(item.icon!, { size: 15 })),
props: {
role: 'menuitem',
class: item.danger ? 'ab-menu-item--danger' : undefined,
},
}))
);
function Trigger() {
return cloneVNode(slots.trigger()[0], {
'aria-haspopup': 'menu',
'aria-expanded': open.value,
});
}
function onSelect(item: AbMenuItem) {
if (item.disabled) return;
function onSelect(key: string | number) {
const item = items.find((entry, index) => (entry.key ?? index) === key);
if (!item || item.disabled) return;
item.handler?.();
emit('select', item);
}
</script>
<template>
<Menu as="div" class="ab-menu">
<MenuButton as="template">
<slot name="trigger"></slot>
</MenuButton>
<MenuItems class="ab-menu-list" :class="`ab-menu-list--${align}`">
<MenuItem
v-for="(item, index) in items"
:key="item.key ?? index"
v-slot="{ active }"
:disabled="item.disabled"
>
<button
type="button"
class="ab-menu-item"
:class="[
active && 'ab-menu-item--active',
item.danger && 'ab-menu-item--danger',
item.disabled && 'ab-menu-item--disabled',
]"
@click="onSelect(item)"
>
<Component :is="item.icon" v-if="item.icon" :size="15"></Component>
<span class="ab-menu-item-label">{{ itemLabel(item) }}</span>
</button>
</MenuItem>
</MenuItems>
</Menu>
<NDropdown
trigger="click"
:show="open"
:options="options"
:placement="align === 'right' ? 'bottom-end' : 'bottom-start'"
:menu-props="() => ({ class: 'ab-menu-list' })"
@update:show="open = $event"
@select="onSelect"
>
<Trigger />
</NDropdown>
</template>
<style lang="scss" scoped>
.ab-menu {
position: relative;
display: inline-flex;
<!-- 不加 scoped:菜单项由 NDropdown 渲染并传送到 body,scoped 选择器命中不了。
改写 naive-ui 的颜色变量,悬停态也保持危险色。 -->
<style lang="scss">
.ab-menu-item--danger {
--n-option-text-color: var(--color-danger);
--n-option-text-color-hover: var(--color-danger);
--n-prefix-color: var(--color-danger);
}
.ab-menu-list {
position: absolute;
top: 100%;
margin-top: 4px;
z-index: var(--z-popover);
min-width: 180px;
padding: 4px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
&--left {
left: 0;
}
&--right {
right: 0;
}
&:focus-visible {
outline: none;
}
}
.ab-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
font-family: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background-color var(--transition-fast);
// 触屏放大命中区域,与旧菜单一致
@include forTouch {
padding: 11px 10px;
.n-dropdown-option-body {
--n-option-height: var(--touch-target);
}
}
// active 由 headlessui 的键盘/悬停导航驱动
&--active {
background: var(--color-surface-2);
}
&--danger {
color: var(--color-danger);
}
&--disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.ab-menu-item-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { ref } from 'vue';
import AbButton from './ab-button.vue';

View File

@@ -1,51 +1,44 @@
<script lang="ts" setup>
import {
Dialog,
DialogPanel,
DialogTitle,
TransitionChild,
TransitionRoot,
} from '@headlessui/vue';
import { Close } from '@icon-park/vue-next';
import { X } from '@lucide/vue';
import { NModal } from 'naive-ui';
import AbBottomSheet from './ab-bottom-sheet.vue';
import AbIconButton from './ab-icon-button.vue';
import { useBreakpointQuery } from '@/hooks/useBreakpointQuery';
// 唯一的自适应弹窗组件(合并旧 ab-popup / ab-adaptive-modal):
// 桌面居中对话框,移动端底部抽屉。headlessui Dialog 提供焦点陷阱与 aria。
const props = withDefaults(
defineProps<{
title?: string;
size?: 'sm' | 'md' | 'lg';
/** false 时禁用遮罩点击/关闭按钮(用于流程中不可中断的弹窗) */
closable?: boolean;
/** 隐藏右上角 X(confirm 类弹窗:Esc/遮罩仍可关闭) */
showClose?: boolean;
maxHeight?: string;
/** 移动端用整屏抽屉承载长表单 */
mobileFullscreen?: boolean;
/** 移动端软键盘出现时是否把抽屉整体上移 */
avoidKeyboard?: boolean;
}>(),
{
title: '',
size: 'md',
closable: true,
showClose: true,
maxHeight: '85dvh',
mobileFullscreen: false,
avoidKeyboard: true,
}
);
// 桌面居中对话框,移动端底部抽屉。naive-ui NModal 提供焦点陷阱与 Esc/遮罩关闭,
// 无 preset 时不带 aria,dialog 语义写在面板上。
const {
title = '',
size = 'md',
closable = true,
showClose = true,
maxHeight = '85dvh',
mobileFullscreen = false,
avoidKeyboard = true,
} = defineProps<{
title?: string;
size?: 'sm' | 'md' | 'lg';
/** false 时禁用遮罩点击/关闭按钮(用于流程中不可中断的弹窗) */
closable?: boolean;
/** 隐藏右上角 X(confirm 类弹窗:Esc/遮罩仍可关闭) */
showClose?: boolean;
maxHeight?: string;
/** 移动端用整屏抽屉承载长表单 */
mobileFullscreen?: boolean;
/** 移动端软键盘出现时是否把抽屉整体上移 */
avoidKeyboard?: boolean;
}>();
const emit = defineEmits<{ close: [] }>();
const show = defineModel<boolean>('show', { default: false });
const { isMobile } = useBreakpointQuery();
const titleId = useId();
function close() {
if (!props.closable) return;
if (!closable) return;
show.value = false;
emit('close');
}
@@ -73,101 +66,62 @@ function close() {
</AbBottomSheet>
<!-- 桌面/平板:居中对话框 -->
<TransitionRoot v-else appear :show="show" as="template">
<Dialog class="ab-modal" @close="close">
<TransitionChild
as="template"
enter="duration-200 ease-out"
enter-from="opacity-0"
enter-to="opacity-100"
leave="duration-150 ease-in"
leave-from="opacity-100"
leave-to="opacity-0"
<NModal
v-else
:show="show"
:mask-closable="closable"
:close-on-esc="closable"
@update:show="(value: boolean) => value || close()"
>
<div
class="ab-modal-panel"
:class="[
`ab-modal-panel--${size}`,
!$slots.default && 'ab-modal-panel--bare',
]"
role="dialog"
aria-modal="true"
:aria-labelledby="title ? titleId : undefined"
>
<header
v-if="title || (closable && showClose)"
class="ab-modal-header"
>
<div class="ab-modal-backdrop" aria-hidden="true" />
</TransitionChild>
<div class="ab-modal-wrapper">
<TransitionChild
as="template"
enter="duration-200 ease-out"
enter-from="opacity-0 scale-97"
enter-to="opacity-100 scale-100"
leave="duration-150 ease-in"
leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-97"
<h2 :id="titleId" class="ab-modal-title">
{{ title }}
</h2>
<AbIconButton
v-if="closable && showClose"
class="ab-modal-close"
size="sm"
:label="$t('common.close')"
@click="close"
>
<DialogPanel
class="ab-modal-panel"
:class="[
`ab-modal-panel--${size}`,
!$slots.default && 'ab-modal-panel--bare',
]"
>
<header
v-if="title || (closable && showClose)"
class="ab-modal-header"
>
<DialogTitle as="h2" class="ab-modal-title">
{{ title }}
</DialogTitle>
<AbIconButton
v-if="closable && showClose"
class="ab-modal-close"
size="sm"
:label="$t('common.close')"
@click="close"
>
<Close theme="outline" size="16" />
</AbIconButton>
</header>
<X :size="16" />
</AbIconButton>
</header>
<div
v-if="$slots.default"
class="ab-modal-body"
:style="{ maxHeight }"
>
<slot />
</div>
<footer v-if="$slots.footer" class="ab-modal-footer">
<slot name="footer" />
</footer>
</DialogPanel>
</TransitionChild>
<div
v-if="$slots.default"
class="ab-modal-body"
:style="{ maxHeight }"
>
<slot />
</div>
</Dialog>
</TransitionRoot>
<footer v-if="$slots.footer" class="ab-modal-footer">
<slot name="footer" />
</footer>
</div>
</NModal>
</template>
<style lang="scss" scoped>
.ab-modal {
position: relative;
z-index: var(--z-modal);
}
.ab-modal-backdrop {
position: fixed;
inset: 0;
z-index: var(--z-modal-backdrop);
background: var(--color-overlay);
}
.ab-modal-wrapper {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
overflow-y: auto;
}
.ab-modal-panel {
display: flex;
flex-direction: column;
width: 100%;
// 原 wrapper 的 16px 留白;NModal 的滚动容器负责居中
width: calc(100% - 32px);
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { Caution, Close } from '@icon-park/vue-next';
import { TriangleAlert, X } from '@lucide/vue';
import type { OffsetSuggestionDetail, TMDBSummary } from '#/bangumi';
const props = withDefaults(
@@ -93,18 +93,22 @@ function handleCancel() {
<!-- Header -->
<header class="dialog-header">
<div class="header-icon">
<Caution theme="filled" size="20" />
<TriangleAlert :size="20" />
</div>
<h2 class="dialog-title">{{ t('offset.dialog_title') }}</h2>
<h2 class="dialog-title">
{{ t('offset.dialog_title') }}
</h2>
<button class="close-btn" aria-label="Close" @click="handleCancel">
<Close theme="outline" size="18" />
<X :size="18" />
</button>
</header>
<!-- Content -->
<div class="dialog-content">
<!-- Bangumi title -->
<div class="bangumi-title">{{ bangumiTitle }}</div>
<div class="bangumi-title">
{{ bangumiTitle }}
</div>
<!-- Comparison section -->
<div class="comparison">
@@ -123,30 +127,30 @@ function handleCancel() {
</div>
</div>
<div class="comparison-vs">&ne;</div>
<div class="comparison-vs">
&ne;
</div>
<!-- TMDB data -->
<div class="comparison-box">
<div class="comparison-label">{{ t('offset.tmdb_data') }}</div>
<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-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)
]
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-label">S{{ parsedSeason + (suggestion?.season_offset ?? 0) }}
{{ t('offset.episode') }}:</span>
<span class="value-num">{{
tmdbInfo.season_episode_counts[
parsedSeason + (suggestion?.season_offset ?? 0)
@@ -169,38 +173,32 @@ function handleCancel() {
<!-- Offset inputs -->
<div class="offset-section">
<div class="offset-title">{{ t('offset.suggested_offset') }}</div>
<div class="offset-title">
{{ t('offset.suggested_offset') }}
</div>
<div class="offset-row">
<label class="offset-label"
>{{ t('offset.season_offset') }}:</label
>
<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 }}
>
<span class="offset-hint">&rarr; S{{ parsedSeason }}
{{ t('offset.season') === '季度' ? '变为' : 'becomes' }} S{{
Math.max(1, parsedSeason + seasonOffset)
}}</span
>
}}</span>
</div>
<div class="offset-row">
<label class="offset-label"
>{{ t('offset.episode_offset') }}:</label
>
<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 }}
>
<span class="offset-hint">&rarr; E{{ parsedEpisode }}
{{ t('offset.season') === '季度' ? '保持' : 'stays' }} E{{
Math.max(1, parsedEpisode + episodeOffset)
}}</span
>
}}</span>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import AbPageTitle from './ab-page-title.vue';

View File

@@ -1,18 +1,15 @@
<script lang="ts" setup>
withDefaults(
defineProps<{
title: string;
}>(),
{
title: 'title',
}
);
const { title = 'title' } = defineProps<{
title: string;
}>();
</script>
<template>
<div class="page-title">
<h1 class="page-title-text">{{ title }}</h1>
<div class="page-title-accent"></div>
<h1 class="page-title-text">
{{ title }}
</h1>
<div class="page-title-accent" />
</div>
</template>

View File

@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import AbProgress from './ab-progress.vue';

View File

@@ -1,20 +1,13 @@
<script lang="ts" setup>
// 细进度条(下载页):轨道用 surface-2,进行中为主色,错误为 danger。
const props = withDefaults(
defineProps<{
/** 0–100 */
value: number;
label?: string;
state?: 'active' | 'error';
}>(),
{
value: 0,
label: '',
state: 'active',
}
);
const { value = 0, label = '', state = 'active' } = defineProps<{
/** 0–100 */
value: number;
label?: string;
state?: 'active' | 'error';
}>();
const clamped = computed(() => Math.min(100, Math.max(0, props.value)));
const clamped = computed(() => Math.min(100, Math.max(0, value)));
</script>
<template>
@@ -31,7 +24,7 @@ const clamped = computed(() => Math.min(100, Math.max(0, props.value)));
class="ab-progress-fill"
:class="`ab-progress-fill--${state}`"
:style="{ transform: `scaleX(${clamped / 100})` }"
></div>
/>
</div>
<span v-if="label" class="ab-progress-label">{{ label }}</span>
</div>

View File

@@ -1,24 +1,17 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { computed, ref, useTemplateRef } from 'vue';
const props = withDefaults(
defineProps<{
loading?: boolean;
threshold?: number;
disabled?: boolean;
}>(),
{
loading: false,
threshold: 60,
disabled: false,
}
);
const { loading = false, threshold = 60, disabled = false } = defineProps<{
loading?: boolean;
threshold?: number;
disabled?: boolean;
}>();
const emit = defineEmits<{
(e: 'refresh'): void;
}>();
const containerRef = ref<HTMLElement | null>(null);
const containerRef = useTemplateRef<HTMLElement>('containerRef');
const pullDistance = ref(0);
const isPulling = ref(false);
const startY = ref(0);
@@ -28,7 +21,7 @@ const pullStyle = computed(() => {
return {
transform: `translateY(${Math.min(
pullDistance.value,
props.threshold * 1.5
threshold * 1.5
)}px)`,
transition: isPulling.value
? 'none'
@@ -41,17 +34,17 @@ const pullStyle = computed(() => {
});
const indicatorOpacity = computed(() => {
return Math.min(pullDistance.value / props.threshold, 1);
return Math.min(pullDistance.value / threshold, 1);
});
const indicatorRotation = computed(() => {
return Math.min(pullDistance.value / props.threshold, 1) * 180;
return Math.min(pullDistance.value / threshold, 1) * 180;
});
const isTriggered = computed(() => pullDistance.value >= props.threshold);
const isTriggered = computed(() => pullDistance.value >= threshold);
function onTouchStart(e: TouchEvent) {
if (props.disabled || props.loading) return;
if (disabled || loading) return;
const container = containerRef.value;
if (!container || container.scrollTop > 0) return;
@@ -60,7 +53,7 @@ function onTouchStart(e: TouchEvent) {
}
function onTouchMove(e: TouchEvent) {
if (!isPulling.value || props.disabled || props.loading) return;
if (!isPulling.value || disabled || loading) return;
const container = containerRef.value;
if (!container || container.scrollTop > 0) {
isPulling.value = false;
@@ -82,7 +75,7 @@ function onTouchEnd() {
if (!isPulling.value) return;
isPulling.value = false;
if (isTriggered.value && !props.loading) {
if (isTriggered.value && !loading) {
emit('refresh');
}
pullDistance.value = 0;

View File

@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import AbSearch from './ab-search.vue';

View File

@@ -1,17 +1,11 @@
<script lang="ts" setup>
import { Search } from '@icon-park/vue-next';
import { Search } from '@lucide/vue';
import { NSpin } from 'naive-ui';
withDefaults(
defineProps<{
provider: string;
loading: boolean;
}>(),
{
provider: '',
loading: false,
}
);
const { provider = '', loading = false } = defineProps<{
provider: string;
loading: boolean;
}>();
defineEmits<{ click: [] }>();
</script>
@@ -24,7 +18,7 @@ defineEmits<{ click: [] }>();
@click="$emit('click')"
>
<NSpin v-if="loading" :size="16" class="search-spinner" />
<Search v-else theme="outline" size="18" class="search-icon" />
<Search v-else :size="18" class="search-icon" />
<span class="search-placeholder">{{ $t('topbar.search.placeholder') }}</span>

View File

@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { ref } from 'vue';
import AbSegmented from './ab-segmented.vue';

View File

@@ -7,17 +7,11 @@ export interface AbSegmentedOption {
}
// 视图/筛选切换:Soft Ink 下划线标签页(无浮动滑块),方向键可导航。
const props = withDefaults(
defineProps<{
options: AbSegmentedOption[];
size?: 'sm' | 'md';
ariaLabel?: string;
}>(),
{
size: 'md',
ariaLabel: undefined,
}
);
const { size = 'md', options } = defineProps<{
options: AbSegmentedOption[];
size?: 'sm' | 'md';
ariaLabel?: string;
}>();
const value = defineModel<string>('value', { required: true });
@@ -29,12 +23,12 @@ function select(option: AbSegmentedOption) {
function onKeydown(event: KeyboardEvent, index: number) {
let next: number | null = null;
if (event.key === 'ArrowRight') next = (index + 1) % props.options.length;
if (event.key === 'ArrowRight') next = (index + 1) % options.length;
if (event.key === 'ArrowLeft')
next = (index - 1 + props.options.length) % props.options.length;
next = (index - 1 + options.length) % options.length;
if (next === null) return;
event.preventDefault();
value.value = props.options[next].value;
value.value = options[next].value;
tabRefs.value[next]?.focus();
}
</script>

View File

@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { ref } from 'vue';
import AbSelect from './ab-select.vue';

View File

@@ -1,47 +1,42 @@
<script lang="ts" setup>
import { computed, inject } from 'vue';
import { NSelect } from 'naive-ui';
import { isObject } from 'radash';
import type { SelectOption } from 'naive-ui';
import { abFieldInjectionKey } from './ab-field.vue';
import type { SelectItem } from '#/components';
// NSelect 的统一封装:选项归一化 + ab-field aria 接线在这里定一次。
const props = withDefaults(
defineProps<{
/** 允许 string 或 {label?, value} 混合(旧 ab-setting 的格式) */
items?: Array<SelectItem | string>;
/** 已是 naive 格式时直接传 options */
options?: SelectOption[];
placeholder?: string;
disabled?: boolean;
error?: boolean;
ariaLabel?: string;
}>(),
{
items: undefined,
options: undefined,
placeholder: '',
disabled: false,
error: false,
ariaLabel: undefined,
}
);
const {
items,
options,
placeholder = '',
disabled = false,
error = false,
} = defineProps<{
/** 允许 string 或 {label?, value} 混合(旧 ab-setting 的格式) */
items?: Array<SelectItem | string>;
/** 已是 naive 格式时直接传 options */
options?: SelectOption[];
placeholder?: string;
disabled?: boolean;
error?: boolean;
ariaLabel?: string;
}>();
const model = defineModel<string | number | null>({ default: null });
const field = inject(abFieldInjectionKey, null);
const normalizedOptions = computed<SelectOption[]>(() => {
if (props.options) return props.options;
return (props.items ?? []).map((item) =>
isObject(item)
if (options) return options;
return (items ?? []).map((item) =>
typeof item === 'object'
? { label: item.label ?? String(item.value), value: item.value }
: { label: item, value: item }
);
});
const invalid = computed(() => props.error || field?.invalid.value === true);
const invalid = computed(() => error || field?.invalid.value === true);
const menuProps = { role: 'listbox' } as const;
@@ -65,7 +60,7 @@ function optionNodeProps() {
:aria-describedby="field?.describedBy.value"
:menu-props="menuProps"
:node-props="optionNodeProps"
></NSelect>
/>
</template>
<style lang="scss" scoped>

View File

@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import AbSkeleton from './ab-skeleton.vue';

View File

@@ -1,16 +1,10 @@
<script lang="ts" setup>
// 骨架屏(加载态用骨架,不在内容中放 spinner)。
// preset 对应真实布局:lines(文本)/ row(列表行)/ card(卡片)。
withDefaults(
defineProps<{
preset?: 'lines' | 'row' | 'card';
count?: number;
}>(),
{
preset: 'lines',
count: 3,
}
);
const { preset = 'lines', count = 3 } = defineProps<{
preset?: 'lines' | 'row' | 'card';
count?: number;
}>();
// 行宽做点参差,避免整齐得像占位框
const widths = ['62%', '88%', '40%', '75%', '55%'];
@@ -24,21 +18,21 @@ const widths = ['62%', '88%', '40%', '75%', '55%'];
:key="i"
class="ab-skeleton-line"
:style="{ width: widths[(i - 1) % widths.length] }"
></span>
/>
</template>
<template v-else-if="preset === 'row'">
<span v-for="i in count" :key="i" class="ab-skeleton-row">
<span class="ab-skeleton-line" style="width: 46%"></span>
<span class="ab-skeleton-line" style="width: 46%" />
<span
class="ab-skeleton-line ab-skeleton-line--dim"
style="width: 68%"
></span>
/>
</span>
</template>
<template v-else>
<span v-for="i in count" :key="i" class="ab-skeleton-card"></span>
<span v-for="i in count" :key="i" class="ab-skeleton-card" />
</template>
</div>
</template>

View File

@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { ref } from 'vue';
import AbSplitButton from './ab-split-button.vue';

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { Down } from '@icon-park/vue-next';
import { ChevronDown } from '@lucide/vue';
import AbMenu from './ab-menu.vue';
import type { AbMenuItem } from './ab-menu.vue';
@@ -10,19 +10,17 @@ export interface AbSplitOption {
// 分体按钮:主区执行当前选项,箭头打开选项菜单(替代旧 ab-button-multi)。
// 选中项通过 v-model:value 受控,不再内部私藏状态。
const props = withDefaults(
defineProps<{
options: AbSplitOption[];
loading?: boolean;
disabled?: boolean;
size?: 'sm' | 'md';
}>(),
{
loading: false,
disabled: false,
size: 'md',
}
);
const {
loading = false,
disabled = false,
size = 'md',
options,
} = defineProps<{
options: AbSplitOption[];
loading?: boolean;
disabled?: boolean;
size?: 'sm' | 'md';
}>();
const emit = defineEmits<{ click: [value: string] }>();
@@ -30,12 +28,12 @@ const value = defineModel<string>('value', { required: true });
const selected = computed(
() =>
props.options.find((option) => option.value === value.value) ??
props.options[0]
options.find((option) => option.value === value.value) ??
options[0]
);
const menuItems = computed<AbMenuItem[]>(() =>
props.options.map((option) => ({
options.map((option) => ({
key: option.value,
label: option.label,
handler: () => {
@@ -45,7 +43,7 @@ const menuItems = computed<AbMenuItem[]>(() =>
);
function onMainClick() {
if (props.disabled || props.loading) return;
if (disabled || loading) return;
emit('click', selected.value.value);
}
</script>
@@ -59,7 +57,7 @@ function onMainClick() {
:aria-busy="loading || undefined"
@click="onMainClick"
>
<span v-if="loading" class="ab-split-spin" aria-hidden="true"></span>
<span v-if="loading" class="ab-split-spin" aria-hidden="true" />
{{ selected?.label }}
</button>
@@ -71,7 +69,7 @@ function onMainClick() {
:disabled="disabled || loading"
:aria-label="$t('common.moreActions')"
>
<Down size="12" />
<ChevronDown :size="12" />
</button>
</template>
</AbMenu>

View File

@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import AbStatus from './ab-status.vue';

View File

@@ -1,23 +1,16 @@
<script lang="ts" setup>
// Soft Ink 状态标记:方形色块 + 墨色文字。页面上唯一的语义色载体,
// 回答“是否在正常工作”。四种状态取代旧的 running 布尔值。
const props = withDefaults(
defineProps<{
state: 'running' | 'stopped' | 'paused' | 'degraded';
/** 状态文字(省略则只渲染色块,需自带 aria 上下文) */
label?: string;
/** 次要说明,如 “next poll 3 min” */
detail?: string;
size?: 'sm' | 'md';
}>(),
{
label: '',
detail: '',
size: 'md',
}
);
const { label = '', detail = '', size = 'md', state } = defineProps<{
state: 'running' | 'stopped' | 'paused' | 'degraded';
/** 状态文字(省略则只渲染色块,需自带 aria 上下文) */
label?: string;
/** 次要说明,如 “next poll 3 min” */
detail?: string;
size?: 'sm' | 'md';
}>();
const ariaText = computed(() => props.label || props.state);
const ariaText = computed(() => label || state);
</script>
<template>
@@ -29,7 +22,7 @@ const ariaText = computed(() => props.label || props.state);
>
<!-- 状态灯:外圈细线,内里是灯 -->
<span class="ab-status-ring" aria-hidden="true">
<span class="ab-status-mark"></span>
<span class="ab-status-mark" />
</span>
<span v-if="label" class="ab-status-label">{{ label }}</span>
<span v-if="detail" class="ab-status-detail">{{ detail }}</span>

View File

@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import { ref } from 'vue';
import AbSwitch from './ab-switch.vue';

View File

@@ -4,20 +4,12 @@ import { NSwitch } from 'naive-ui';
import { abFieldInjectionKey } from './ab-field.vue';
// NSwitch 的统一封装:尺寸、loading 与 aria 接线在这里定一次。
withDefaults(
defineProps<{
size?: 'small' | 'medium' | 'large';
disabled?: boolean;
loading?: boolean;
ariaLabel?: string;
}>(),
{
size: 'medium',
disabled: false,
loading: false,
ariaLabel: undefined,
}
);
const { size = 'medium', disabled = false, loading = false } = defineProps<{
size?: 'small' | 'medium' | 'large';
disabled?: boolean;
loading?: boolean;
ariaLabel?: string;
}>();
const model = defineModel<boolean>({ default: false });
@@ -32,5 +24,5 @@ const field = inject(abFieldInjectionKey, null);
:loading="loading"
:aria-label="ariaLabel"
:aria-labelledby="field?.labelId"
></NSwitch>
/>
</template>

View File

@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import AbTag from './ab-tag.vue';

View File

@@ -1,27 +1,18 @@
<script lang="ts" setup>
// Soft Ink 标签:细线框 + 语义色方块标记,文字始终为墨色 —
// 颜色不承载文字(拒绝粉彩胶囊)。不截断 CJK 标题。
withDefaults(
defineProps<{
type?: 'success' | 'warning' | 'danger' | 'info' | 'neutral';
title?: string;
closable?: boolean;
}>(),
{
type: 'neutral',
title: '',
closable: false,
}
);
const { type = 'neutral', title = '', closable = false } = defineProps<{
type?: 'success' | 'warning' | 'danger' | 'info' | 'neutral';
title?: string;
closable?: boolean;
}>();
defineEmits<{ close: [] }>();
</script>
<template>
<span class="ab-tag" :class="`ab-tag--${type}`">
<span class="ab-tag-text"
><slot>{{ title }}</slot></span
>
<span class="ab-tag-text"><slot>{{ title }}</slot></span>
<button
v-if="closable"
type="button"

View File

@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import AbToolbar from './ab-toolbar.vue';

View File

@@ -1,26 +1,21 @@
<script lang="ts" setup>
// 列表上方的操作行:搜索 / 筛选 / 动作三个插槽,一处定义响应式换行。
withDefaults(
defineProps<{
/** 吸顶(长列表页) */
sticky?: boolean;
}>(),
{
sticky: false,
}
);
const { sticky = false } = defineProps<{
/** 吸顶(长列表页) */
sticky?: boolean;
}>();
</script>
<template>
<div class="ab-toolbar" :class="{ 'ab-toolbar--sticky': sticky }">
<div v-if="$slots.search" class="ab-toolbar-search">
<slot name="search"></slot>
<slot name="search" />
</div>
<div v-if="$slots.filters" class="ab-toolbar-filters">
<slot name="filters"></slot>
<slot name="filters" />
</div>
<div v-if="$slots.actions" class="ab-toolbar-actions">
<slot name="actions"></slot>
<slot name="actions" />
</div>
</div>
</template>

View File

@@ -1,4 +1,4 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import type { Meta, StoryObj } from '@storybook/vue3-vite';
import AbButton from './ab-button.vue';
import AbTooltip from './ab-tooltip.vue';

Some files were not shown because too many files have changed in this diff Show More