>): ComponentType {
+ let Loaded: ComponentType
| null = null
+ let loading: Promise | undefined
+ let failures = 0
+ let failedLinks: HTMLLinkElement[] = []
+ // 失败提示显示过一次即失效:离开后再回来的新页面实例会重新加载,不会再看到旧的失败。
+ let shownTo: string | null = null
+
+ async function fetchPage(): Promise> {
+ if (failedLinks.length > 0) {
+ const requested = await Promise.all(failedLinks.map(requestAgain))
+ failedLinks = requested.filter(link => failedStyles.has(link))
+ // 缺少样式的页面无法正常使用,样式文件仍取不到时本次重试按失败处理。
+ if (failedLinks.length > 0)
+ throw new Error('样式文件仍然取不到')
+ }
+ const known = new Set(stylesheets())
+ const importing = load()
+ // 预加载包装在 load() 里同步地把这个页面的样式文件加到 head,并等待它们。
+ const own = stylesheets().filter(link => !known.has(link))
+ return importing.catch((error: unknown) => {
+ failedLinks = own.filter(link => failedStyles.has(link))
+ throw error
+ })
+ }
+
+ const start = () => {
+ watchStyles()
+ shownTo = null
+ loading = fetchPage().then(
+ (component) => {
+ Loaded = component
+ failures = 0
+ },
+ () => {
+ failures += 1
+ },
+ )
+ return loading
+ }
+
+ return function Page(props: P) {
+ const id = useId()
+ const [, update] = useReducer((count: number) => count + 1, 0)
+ if (!Loaded && shownTo !== null && shownTo !== id)
+ start()
+ use(loading ?? start())
+ useEffect(() => {
+ if (!Loaded)
+ shownTo = id
+ })
+ if (Loaded)
+ return
+ return (
+ 1}
+ onRetry={() => {
+ start()
+ update()
+ }}
+ />
+ )
+ }
+}
diff --git a/web/src/shared/ui/modal.tsx b/web/src/shared/ui/modal.tsx
new file mode 100644
index 0000000..bb7f719
--- /dev/null
+++ b/web/src/shared/ui/modal.tsx
@@ -0,0 +1,49 @@
+import type { ReactNode } from 'react'
+import { X } from 'lucide-react'
+import { useEffect, useEffectEvent, useId, useRef } from 'react'
+import { createPortal } from 'react-dom'
+import { registerModalHistory } from '@/shared/lib/session-history'
+import { IconButton } from './icon-button'
+
+export function Modal({ title, children, onClose }: { title: string, children: ReactNode, onClose: () => void }) {
+ const dialogRef = useRef(null)
+ const titleId = useId()
+ const close = useEffectEvent(onClose)
+ useEffect(() => {
+ const dialog = dialogRef.current!
+ const previousFocus = document.activeElement as HTMLElement | null
+ dialog.showModal()
+ // React 不渲染 autofocus 属性,showModal() 会把焦点放在第一个可聚焦的元素上,也就是关闭按钮。
+ dialog.querySelector('[data-autofocus]')?.focus()
+ const unregister = registerModalHistory(() => close())
+ return () => {
+ unregister()
+ dialog.close()
+ previousFocus?.focus()
+ }
+ }, [])
+ return createPortal(
+ ,
+ document.body,
+ )
+}
diff --git a/web/src/shared/ui/notification-store.ts b/web/src/shared/ui/notification-store.ts
new file mode 100644
index 0000000..19b9a62
--- /dev/null
+++ b/web/src/shared/ui/notification-store.ts
@@ -0,0 +1,41 @@
+import { Store } from '@/shared/lib/store'
+
+export type NotificationTone = 'success' | 'error' | 'info'
+
+export interface Notification {
+ id: number
+ tone: NotificationTone
+ message: string
+}
+
+let nextId = 0
+
+class NotificationStore extends Store<{ notifications: Notification[] }> {
+ constructor() {
+ super({ notifications: [] })
+ }
+
+ success(message: string, durationMs = 3000): void {
+ this.#show('success', message, durationMs)
+ }
+
+ error(message: string, durationMs = 3000): void {
+ this.#show('error', message, durationMs)
+ }
+
+ info(message: string, durationMs = 3000): void {
+ this.#show('info', message, durationMs)
+ }
+
+ dismiss(id: number): void {
+ this.setState({ notifications: this.state.notifications.filter(n => n.id !== id) })
+ }
+
+ #show(tone: NotificationTone, message: string, durationMs: number): void {
+ const id = ++nextId
+ this.setState({ notifications: [...this.state.notifications, { id, tone, message }] })
+ setTimeout(() => this.dismiss(id), durationMs)
+ }
+}
+
+export const notificationStore = new NotificationStore()
diff --git a/web/src/shared/ui/notifications.tsx b/web/src/shared/ui/notifications.tsx
new file mode 100644
index 0000000..f231687
--- /dev/null
+++ b/web/src/shared/ui/notifications.tsx
@@ -0,0 +1,24 @@
+import { CircleAlert, CircleCheck, Info, X } from 'lucide-react'
+import { useStore } from '@/shared/lib/use-store'
+import { IconButton } from './icon-button'
+import { notificationStore } from './notification-store'
+
+const icons = { success: CircleCheck, error: CircleAlert, info: Info }
+
+export function Notifications() {
+ const notifications = useStore(notificationStore, s => s.notifications)
+ return (
+
+ {notifications.map(({ id, tone, message }) => {
+ const Icon = icons[tone]
+ return (
+
+
+
{message}
+
notificationStore.dismiss(id)} />
+
+ )
+ })}
+
+ )
+}
diff --git a/web/src/shared/ui/page-load-failed.tsx b/web/src/shared/ui/page-load-failed.tsx
new file mode 100644
index 0000000..957f074
--- /dev/null
+++ b/web/src/shared/ui/page-load-failed.tsx
@@ -0,0 +1,26 @@
+import { EmptyState } from './empty-state'
+
+// "重新加载"只在原地重新获取本页面的文件,应用的其余部分和尚未发送的输入都会保留,因此始终是主操作。
+// 原地重试仍然失败,很可能是应用更新后旧文件已被删除,只有整页刷新才能取到新文件。
+export function PageLoadFailed({ onRetry, failedAgain }: { onRetry: () => void, failedAgain: boolean }) {
+ return (
+
+
+
+ {failedAgain && (
+ <>
+ 应用可能已经更新。刷新页面会丢掉还没发送的内容。
+
+ >
+ )}
+ >
+ )}
+ >
+ 可能是网络中断,或者应用已经更新。
+
+
+ )
+}
diff --git a/web/src/shared/ui/skeleton.tsx b/web/src/shared/ui/skeleton.tsx
new file mode 100644
index 0000000..1045649
--- /dev/null
+++ b/web/src/shared/ui/skeleton.tsx
@@ -0,0 +1,15 @@
+export function Skeleton({ rows = 4 }: { rows?: number }) {
+ return (
+
+ {Array.from({ length: rows }, (_, i) => (
+
+ ))}
+
+ )
+}
diff --git a/web/src/shared/ui/song-artwork.tsx b/web/src/shared/ui/song-artwork.tsx
new file mode 100644
index 0000000..a990c5d
--- /dev/null
+++ b/web/src/shared/ui/song-artwork.tsx
@@ -0,0 +1,27 @@
+import { ExternalLink } from 'lucide-react'
+import { bookTitle } from '@/shared/lib/book-title'
+import { Artwork } from './artwork'
+
+function safeHref(href: string | undefined): string | undefined {
+ if (!href)
+ return undefined
+ try {
+ const url = new URL(href)
+ return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : undefined
+ }
+ catch {
+ return undefined
+ }
+}
+
+export function SongArtwork({ coverUrl, title, href, sourceName = '音源' }: { coverUrl: string, title: string, href?: string, sourceName?: string }) {
+ const link = safeHref(href)
+ if (!link)
+ return
+ return (
+
+
+
+
+ )
+}
diff --git a/web/src/shared/ui/song-list.tsx b/web/src/shared/ui/song-list.tsx
new file mode 100644
index 0000000..f03e158
--- /dev/null
+++ b/web/src/shared/ui/song-list.tsx
@@ -0,0 +1,53 @@
+import type { LucideIcon } from 'lucide-react'
+import { formatDuration } from '@/shared/lib/time'
+import { AsyncIconButton } from './icon-button'
+import { SongArtwork } from './song-artwork'
+
+export interface SongItem {
+ key: string
+ title: string
+ artist: string
+ album: string
+ coverUrl: string
+ // 0 表示未知。
+ durationMs: number
+ sourceUrl?: string
+ // 显示在歌手后面,封面链接的提示也用它。
+ sourceName?: string
+ note?: string
+ badge?: string
+}
+
+export interface SongAction {
+ icon: LucideIcon
+ label: string
+ disabled?: boolean
+ run: () => Promise
+}
+
+export function SongList({ songs, actions }: { songs: SongItem[], actions: (song: SongItem, index: number) => SongAction[] }) {
+ return (
+
+ {songs.map((song, index) => (
+ -
+ {String(index + 1).padStart(2, '0')}
+
+
+
+ {song.title}
+ {song.badge && {song.badge}}
+
+
+ {[song.artist, song.sourceName, song.note].filter(Boolean).join(' · ')}
+
+
+ {song.album}
+ {song.durationMs > 0 ? formatDuration(song.durationMs) : ''}
+ {actions(song, index).map(action => (
+
+ ))}
+
+ ))}
+
+ )
+}
diff --git a/web/src/shared/ui/theme.css b/web/src/shared/ui/theme.css
new file mode 100644
index 0000000..1ec1a02
--- /dev/null
+++ b/web/src/shared/ui/theme.css
@@ -0,0 +1,135 @@
+@theme {
+ --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
+ --text-ui-xl: calc(var(--ui-font-size) + 14px);
+ --text-ui-lg: calc(var(--ui-font-size) + 4px);
+ --text-ui-base: var(--ui-font-size);
+ --text-ui-caption: calc(var(--ui-font-size) - 1px);
+ --text-ui-sm: calc(var(--ui-font-size) - 2px);
+ --text-ui-xs: calc(var(--ui-font-size) - 4px);
+ /* iOS 在输入框字号小于 16px 时,聚焦会放大页面。 */
+ --text-mobile-input-safe: 16px;
+}
+
+@utility muted {
+ color: var(--color-foreground-subtle);
+}
+
+@utility accent {
+ color: var(--color-brand);
+}
+
+:root {
+ --ui-font-size: 14px;
+ --color-brand: #68d268;
+ --color-primary: #68d268;
+ --color-primary-foreground: #122412;
+ --color-background: #1c1c1e;
+ --color-frame: #000;
+ --color-content: #121212;
+ --color-panel: #121212;
+ --color-card: #181818;
+ --color-card-hover: #232326;
+ --color-surface: #232326;
+ --color-surface-hover: #2c2c30;
+ --color-popover: #242427;
+ --color-input: #29292d;
+ --color-input-focused: #303034;
+ --color-search-box: #1f1f1f;
+ --color-foreground: #f4f4f5;
+ --color-foreground-subtle: #a6a6af;
+ --color-foreground-subtlest: #90909a;
+ --color-border: #333337;
+ --color-border-hover: #585860;
+ --color-input-border: #414148;
+ --color-input-border-focused: #68d268;
+ --color-hover: #303034;
+ --color-frame-hover: #303034;
+ --color-frame-surface: #232326;
+ --color-selected: #3b3b3e;
+ --color-accent: #203320;
+ --color-destructive: #ff929a;
+ --color-destructive-foreground: #34151b;
+ --color-success: #8cc9a5;
+ --color-toast: #303034;
+ --color-overlay: rgb(0 0 0 / 55%);
+ --color-shadow: rgb(0 0 0 / 24%);
+ color-scheme: dark;
+ font-synthesis: none;
+}
+:root[data-theme='light'] {
+ --color-brand: #217521;
+ --color-frame: #e8e8ed;
+ --color-background: #fff;
+ --color-content: #fff;
+ --color-panel: #fff;
+ --color-card: #f3f3f6;
+ --color-card-hover: #e8e8ed;
+ --color-surface: #f3f3f6;
+ --color-surface-hover: #ededf0;
+ --color-popover: #fff;
+ --color-input: #f3f3f6;
+ --color-input-focused: #fff;
+ --color-search-box: #fff;
+ --color-foreground: #202024;
+ --color-foreground-subtle: #64646e;
+ --color-foreground-subtlest: #6e6e78;
+ --color-border: #e6e6eb;
+ --color-border-hover: #b9b9c2;
+ --color-input-border: #d4d4dc;
+ --color-input-border-focused: #217521;
+ --color-hover: #e8e8ed;
+ --color-frame-hover: #d4d4dc;
+ --color-frame-surface: #fff;
+ --color-selected: #e4e4e8;
+ --color-accent: #e7f6e7;
+ --color-destructive: #b22638;
+ --color-destructive-foreground: #fff;
+ --color-success: #287144;
+ --color-toast: #fff;
+ --color-shadow: rgb(30 30 45 / 12%);
+ color-scheme: light;
+}
+@media (prefers-color-scheme: light) {
+ :root[data-theme='system'] {
+ --color-brand: #217521;
+ --color-frame: #e8e8ed;
+ --color-background: #fff;
+ --color-content: #fff;
+ --color-panel: #fff;
+ --color-card: #f3f3f6;
+ --color-card-hover: #e8e8ed;
+ --color-surface: #f3f3f6;
+ --color-surface-hover: #ededf0;
+ --color-popover: #fff;
+ --color-input: #f3f3f6;
+ --color-input-focused: #fff;
+ --color-search-box: #fff;
+ --color-foreground: #202024;
+ --color-foreground-subtle: #64646e;
+ --color-foreground-subtlest: #6e6e78;
+ --color-border: #e6e6eb;
+ --color-border-hover: #b9b9c2;
+ --color-input-border: #d4d4dc;
+ --color-input-border-focused: #217521;
+ --color-hover: #e8e8ed;
+ --color-frame-hover: #d4d4dc;
+ --color-frame-surface: #fff;
+ --color-selected: #e4e4e8;
+ --color-accent: #e7f6e7;
+ --color-destructive: #b22638;
+ --color-destructive-foreground: #fff;
+ --color-success: #287144;
+ --color-toast: #fff;
+ --color-shadow: rgb(30 30 45 / 12%);
+ color-scheme: light;
+ }
+}
+
+@keyframes popover-in {
+ from { opacity: 0; transform: translateY(-8px) scale(.96); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+@keyframes fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
diff --git a/web/src/shared/ui/ui.css b/web/src/shared/ui/ui.css
new file mode 100644
index 0000000..5776b66
--- /dev/null
+++ b/web/src/shared/ui/ui.css
@@ -0,0 +1,92 @@
+.page-content { width: 100%; max-width: 1400px; margin: 0 auto; padding: 32px 40px 48px; }
+.page-title { margin-bottom: 28px; }
+.page-title h1 { margin: 8px 0; }
+.page-title p { margin-top: 8px; }
+.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
+.section-heading h1, .section-heading h2 { min-width: 0; }
+.section-heading h1 > span { margin-left: 6px; }
+.icon-button { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 36px; height: 36px; border: 0; border-radius: 8px; background: transparent; color: inherit; }
+.icon-button:hover:not(:disabled) { background: var(--color-hover); }
+.button-primary, .button-secondary, .button-destructive, .button-ghost { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 36px; padding: 8px 14px; border-radius: 8px; font-size: var(--text-ui-base); font-weight: 500; white-space: nowrap; border: 1px solid transparent; }
+.button-primary { background: var(--color-primary); color: var(--color-primary-foreground); }
+.button-primary:hover:not(:disabled) { background: color-mix(in srgb, var(--color-primary), var(--color-foreground) 8%); }
+.button-secondary { background: var(--color-surface); border-color: var(--color-border); color: var(--color-foreground); }
+.button-secondary:hover:not(:disabled) { background: var(--color-surface-hover); }
+.button-ghost { background: transparent; padding: 6px 8px; }
+.button-ghost:hover:not(:disabled) { background: var(--color-hover); }
+.button-destructive { background: var(--color-destructive); color: var(--color-destructive-foreground); }
+.search-field { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; border: 1px solid var(--color-input-border); border-radius: 8px; padding: 0 12px; background: var(--color-input); color: var(--color-foreground-subtle); }
+.search-field:focus-within { border-color: var(--color-input-border-focused); background: var(--color-input-focused); }
+.search-field input { border: 0 !important; background: transparent !important; padding: 11px 0 !important; }
+.form-stack { display: flex; flex-direction: column; gap: 16px; }
+.form-stack label { display: flex; flex-direction: column; gap: 6px; font-size: var(--text-ui-base); }
+.checkbox-label, .form-stack .checkbox-label { display: flex; flex-direction: row; align-items: center; gap: 8px; }
+.error-text { color: var(--color-destructive); margin: 12px 0; }
+.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; min-height: 230px; padding: 32px 20px; gap: 12px; }
+.empty-state > svg { color: var(--color-foreground-subtlest); margin-bottom: 4px; }
+.empty-state > .muted { max-width: 380px; }
+.empty-state > button { margin-top: 4px; }
+.skeleton-list { padding: 8px 0; }
+.skeleton-row { display: flex; gap: 12px; align-items: center; padding: 12px 0; }
+.skeleton-row > span { width: 48px; height: 48px; border-radius: 8px; background: var(--color-surface-hover); }
+.skeleton-row > div { flex: 1; }
+.skeleton-row > div > span { display: block; width: 55%; height: 12px; border-radius: 4px; background: var(--color-surface-hover); }
+.skeleton-row > div > span + span { width: 35%; height: 10px; margin-top: 10px; background: var(--color-surface); }
+.artwork { flex-shrink: 0; display: flex; align-items: center; justify-content: center; overflow: hidden; background: var(--color-surface); color: var(--color-foreground-subtlest); border-radius: 8px; aspect-ratio: 1; }
+.artwork img { width: 100%; height: 100%; object-fit: cover; }
+.avatar { display: inline-grid; place-items: center; flex-shrink: 0; width: 32px; aspect-ratio: 1; overflow: hidden; border-radius: 50%; background: var(--color-accent); color: var(--color-brand); font-weight: 600; line-height: 1; }
+.avatar img { width: 100%; height: 100%; object-fit: cover; }
+.song-artwork { display: block; position: relative; flex-shrink: 0; border-radius: 8px; }
+.song-artwork-icon { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: var(--color-brand); opacity: 0; pointer-events: none; }
+.song-artwork:hover .artwork, .song-artwork:focus-visible .artwork { opacity: .45; }
+.song-artwork:hover .song-artwork-icon, .song-artwork:focus-visible .song-artwork-icon { opacity: 1; }
+.song-list { list-style: none; padding: 0; margin: 0; }
+.song-row { display: flex; align-items: center; gap: 12px; padding: 10px 8px; border-radius: 8px; min-height: 68px; content-visibility: auto; contain-intrinsic-size: auto 68px; }
+.song-row:hover { background: var(--color-surface-hover); }
+.song-row .artwork { width: 42px; }
+.song-row .artwork > svg { width: 22px; }
+.song-action { color: var(--color-foreground-subtle); }
+.song-action:hover:not(:disabled), .song-action:focus-visible:not(:disabled) { color: var(--color-brand); background: transparent; }
+.song-action:active:not(:disabled) { color: var(--color-brand); background: transparent; transform: none; }
+.song-action[aria-busy='true'] { cursor: progress; opacity: .7; color: var(--color-brand); }
+.song-index { width: 22px; flex-shrink: 0; }
+.song-copy { display: flex; flex-direction: column; min-width: 0; gap: 3px; flex: 1; }
+.song-title { display: flex; align-items: center; gap: 8px; min-width: 0; }
+.song-badge { flex-shrink: 0; padding: 1px 8px; border-radius: 999px; background: var(--color-surface); color: var(--color-foreground-subtle); }
+.song-album { max-width: 22%; width: 150px; }
+.song-duration { flex-shrink: 0; font-variant-numeric: tabular-nums; }
+.modal { width: min(480px, calc(100vw - 32px)); max-width: none; max-height: calc(100dvh - 48px); margin: auto; padding: 0; overflow: auto; border: 1px solid var(--color-border); border-radius: 16px; background: var(--color-popover); color: var(--color-foreground); box-shadow: 0 24px 64px var(--color-shadow); font-size: var(--text-ui-base); }
+.modal::backdrop { background: var(--color-overlay); }
+.modal-header { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 24px; border-bottom: 1px solid var(--color-border); background: var(--color-popover); }
+.modal-body { padding: 24px; }
+.dialog-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; }
+/* 滚动容器会裁掉超出它的阴影,所以四周留出内边距,再把位置和尺寸放大同样的量。 */
+.notifications { --notification-top: calc(88px + env(safe-area-inset-top)); position: fixed; top: calc(var(--notification-top) - 16px); left: 50%; transform: translateX(-50%); width: calc(min(420px, calc(100vw - 32px - env(safe-area-inset-left) - env(safe-area-inset-right))) + 32px); max-height: calc(100dvh - var(--notification-top) + 16px - env(safe-area-inset-bottom)); padding: 16px 16px 24px; overflow-y: auto; scrollbar-width: none; z-index: 20; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
+.notifications::-webkit-scrollbar { display: none; }
+.toast { display: flex; align-items: center; gap: 12px; flex-shrink: 0; padding: 12px 16px; border: 1px solid var(--color-border); border-radius: 16px; background: var(--color-toast); box-shadow: 0 8px 24px var(--color-shadow); pointer-events: auto; }
+.toast p { flex: 1; min-width: 0; overflow-wrap: anywhere; }
+.toast-error > svg { color: var(--color-destructive); }
+.toast-success > svg { color: var(--color-success); }
+.toast-info > svg { color: var(--color-brand); }
+@keyframes sheet-up {
+ from { transform: translateY(100%); }
+ to { transform: translateY(0); }
+}
+:root[data-performance='low'] .modal { animation-duration: 120ms; }
+@media (max-width: 1100px) {
+ .page-content { padding: 24px; }
+ .song-album { display: none; }
+}
+@media (max-width: 767px) {
+ .page-content { padding: 24px 16px 32px; }
+ .page-title { margin-bottom: 24px; }
+ .page-title p { line-height: 1.8; }
+ .song-row { gap: 8px; }
+ .song-row .icon-button { width: 28px; }
+ .song-row .song-index { display: none; }
+ .song-duration { display: none; }
+ .modal { width: 100%; max-width: none; max-height: calc(100dvh - env(safe-area-inset-top) - 16px); margin: auto 0 0; border: 0; border-radius: 16px 16px 0 0; animation: sheet-up 240ms cubic-bezier(.2, .8, .2, 1) both; }
+ .modal-header { padding: 12px 20px; }
+ .modal-body { padding: 20px 20px calc(20px + env(safe-area-inset-bottom)); }
+ .notifications { --notification-top: calc(76px + env(safe-area-inset-top)); }
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..f056725
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "jsx": "react-jsx",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "paths": {
+ "@/*": ["./src/*"]
+ },
+ "types": ["vite/client", "vite-plugin-pwa/client", "node"],
+ "strict": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noEmit": true,
+ "isolatedModules": true,
+ "skipLibCheck": true
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..84408eb
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,87 @@
+import type { Plugin } from 'vite'
+import { readFileSync, writeFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { fileURLToPath, URL } from 'node:url'
+import tailwindcss from '@tailwindcss/vite'
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite'
+import { VitePWA } from 'vite-plugin-pwa'
+
+const backend = 'http://localhost:8080'
+
+const licenseFile = '.vite/license.json'
+
+// 这些包被打包进前端,却不在 Vite 生成的许可证清单中:virtual:pwa-register 不归属任何包,workbox 的运行时由 workbox-build
+// 内联进 workbox-*.js。补入清单后,随前端分发的许可证才完整。
+const unlisted = ['vite-plugin-pwa', 'workbox-core', 'workbox-precaching', 'workbox-routing', 'workbox-strategies']
+
+function listUnlistedLicenses(): Plugin {
+ let licensePath = ''
+ return {
+ name: 'alisten:list-unlisted-licenses',
+ apply: 'build',
+ enforce: 'post',
+ configResolved(config) {
+ licensePath = resolve(config.root, config.build.outDir, licenseFile)
+ },
+ closeBundle() {
+ const licenses = JSON.parse(readFileSync(licensePath, 'utf8')) as unknown[]
+ for (const name of unlisted) {
+ const dir = fileURLToPath(new URL(`./node_modules/${name}/`, import.meta.url))
+ const pkg = JSON.parse(readFileSync(`${dir}package.json`, 'utf8')) as { version: string, license: string }
+ licenses.push({ name, version: pkg.version, identifier: pkg.license, text: readFileSync(`${dir}LICENSE`, 'utf8') })
+ }
+ writeFileSync(licensePath, JSON.stringify(licenses, null, 2))
+ },
+ }
+}
+
+export default defineConfig({
+ plugins: [
+ react(),
+ tailwindcss(),
+ VitePWA({
+ registerType: 'prompt',
+ injectRegister: false,
+ includeAssets: ['icon-64x64.png', 'icon-180x180.png'],
+ manifest: {
+ name: 'Alisten',
+ short_name: 'Alisten',
+ description: 'Alisten:在线音乐同步播放应用',
+ theme_color: '#000000',
+ background_color: '#000000',
+ display: 'standalone',
+ orientation: 'portrait',
+ lang: 'zh-CN',
+ categories: ['music', 'entertainment', 'multimedia'],
+ icons: [
+ { src: 'icon-192x192.png', sizes: '192x192', type: 'image/png', purpose: 'any maskable' },
+ { src: 'icon-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
+ ],
+ },
+ workbox: {
+ // 与 nginx 一致:只有页面路径返回 index.html,/api、/ws 和 /media 照常走网络。
+ // Workbox 拿路径加查询参数来匹配,nginx 只看路径,所以这里的规则末尾允许查询参数。
+ navigateFallbackAllowlist: [/^\/(?:admin|settings|history)?(?:\?.*)?$/, /^\/r\/[^/?]+(?:\/(?:queue|chat|search|playlists\/[^/?]+\/[^/?]+))?(?:\?.*)?$/],
+ importScripts: ['notification-click.js'],
+ },
+ }),
+ listUnlistedLicenses(),
+ ],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ },
+ },
+ server: {
+ proxy: {
+ '/api': backend,
+ '/media': backend,
+ '/ws': { target: backend, ws: true },
+ },
+ },
+ build: {
+ outDir: 'dist',
+ license: { fileName: licenseFile },
+ },
+})