feat(webui): complete UI redesign with design system, dark mode, and accessibility

Implement a comprehensive UI overhaul using CSS custom properties for theming,
scoped SCSS for all components and pages, dark/light mode toggle with flash
prevention, page transitions, ARIA accessibility attributes, and responsive
layout fixes. Fix VueUse auto-import configuration and dev proxy target.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
EstrellaXD
2026-01-23 14:54:26 +01:00
parent 7913061f45
commit 72679cca59
37 changed files with 2363 additions and 959 deletions

View File

@@ -4,11 +4,24 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#ffffff" />
<meta name="theme-color" content="#FAFAFA" />
<link rel="icon" href="/images/logo.svg" />
<link rel="apple-touch-icon" href="/images/apple-touch-icon.png" />
<meta name="description" content="Automated Bangumi Download Tool" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" />
<title>AutoBangumi</title>
<script>
// Apply dark mode before render to prevent flash
(function() {
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (saved === 'dark' || (!saved && prefersDark)) {
document.documentElement.classList.add('dark');
}
})();
</script>
</head>
<body>
<div id="app"></div>

View File

@@ -1,40 +1,88 @@
<script setup lang="ts">
import { type GlobalThemeOverrides, NConfigProvider } from 'naive-ui';
const theme: GlobalThemeOverrides = {
Spin: {
color: '#fff',
},
DataTable: {
thColor: 'rgba(255, 255, 255, 0)',
thColorHover: 'rgba(255, 255, 255, 0)',
tdColorHover: 'rgba(255, 255, 255, 0)',
},
Checkbox: {
colorChecked: '#4e3c94',
borderFocus: '#4e3c94',
boxShadowFocus: '0 0 0 2px rgba(78, 60, 148, 0.2)',
borderChecked: '1px solid #4e3c94',
},
};
import { computed } from 'vue';
import { type GlobalThemeOverrides, NConfigProvider, darkTheme } from 'naive-ui';
const { isDark } = useDarkMode();
const { refresh, isLoggedIn } = useAuth();
if (isLoggedIn.value) {
refresh();
}
const lightOverrides: GlobalThemeOverrides = {
common: {
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 = {
common: {
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);
const naiveTheme = computed(() => isDark.value ? darkTheme : null);
</script>
<template>
<Suspense>
<NConfigProvider :theme-overrides="theme">
<NConfigProvider :theme="naiveTheme" :theme-overrides="themeOverrides">
<RouterView></RouterView>
</NConfigProvider>
</Suspense>
</template>
<style lang="scss">
@import './style/transition';
@import './style/var';
@import './style/transition';
@import './style/global';
</style>

View File

@@ -16,48 +16,36 @@ defineEmits(['click']);
</script>
<template>
<div v-if="type === 'primary'" w-150 is-btn @click="() => $emit('click')">
<div rounded-4 overflow-hidden poster-shandow rel>
<div w-full h-210>
<template v-if="bangumi.poster_link">
<img :src="bangumi.poster_link" alt="poster" wh-full />
</template>
<!-- Grid poster card -->
<div
v-if="type === 'primary'"
class="card"
role="button"
tabindex="0"
:aria-label="`Edit ${bangumi.official_title}`"
@click="() => $emit('click')"
@keydown.enter="() => $emit('click')"
>
<div class="card-poster">
<template v-if="bangumi.poster_link">
<img :src="bangumi.poster_link" :alt="bangumi.official_title" class="card-img" />
</template>
<template v-else>
<div class="card-placeholder">
<ErrorPicture theme="outline" size="24" />
</div>
</template>
<template v-else>
<div wh-full f-cer border="1 white">
<ErrorPicture theme="outline" size="24" fill="#333" />
</div>
</template>
</div>
<div
abs
f-cer
z-1
inset-0
opacity-0
transition="all duration-300"
hover="backdrop-blur-2 bg-white bg-opacity-30 opacity-100"
active="duration-0 bg-opacity-60"
class="group"
>
<div
text-white
rounded="1/2"
wh-44
f-cer
bg-theme-row
group-active="poster-pen-active"
>
<Write size="20" />
<div class="card-overlay">
<div class="card-edit-btn">
<Write size="18" />
</div>
</div>
</div>
<div py-4>
<div text-h3 truncate>{{ bangumi.official_title }}</div>
<div space-x-5>
<div class="card-info">
<div class="card-title">{{ bangumi.official_title }}</div>
<div class="card-tags">
<ab-tag :title="`Season ${bangumi.season}`" type="primary" />
<ab-tag
v-if="bangumi.group_name"
@@ -67,42 +55,25 @@ defineEmits(['click']);
</div>
</div>
</div>
<div
v-else-if="type === 'search'"
w-480
rounded-12
p-4
shadow
bg="#eee5f4"
transition="opacity ease-in-out duration-300"
>
<div bg-white rounded-8 p-12 fx-cer justify-between gap-x-16>
<div w-400 gap-x-16 fx-cer>
<div h-44 w-72 rounded-6 overflow-hidden>
<template v-if="bangumi.poster_link">
<img
:src="bangumi.poster_link"
alt="poster"
w-full
translate-y="-25%"
/>
</template>
<!-- Search result card -->
<div v-else-if="type === 'search'" class="search-card">
<div class="search-card-inner">
<div class="search-card-content">
<div class="search-card-thumb">
<template v-if="bangumi.poster_link">
<img :src="bangumi.poster_link" :alt="bangumi.official_title" class="search-card-img" />
</template>
<template v-else>
<div wh-full f-cer border="1 white">
<ErrorPicture theme="outline" size="24" fill="#333" />
<div class="card-placeholder card-placeholder--small">
<ErrorPicture theme="outline" size="20" />
</div>
</template>
</div>
<div flex="~ col gap-y-4">
<div w-300 text="h3 primary" truncate>
{{ bangumi.official_title }}
</div>
<div flex="~ gap-x-8">
<template
v-for="i in ['season', 'group_name', 'subtitle']"
:key="i"
>
<div class="search-card-meta">
<div class="search-card-title">{{ bangumi.official_title }}</div>
<div class="card-tags">
<template v-for="i in ['season', 'group_name', 'subtitle']" :key="i">
<ab-tag
v-if="bangumi[i]"
:title="i === 'season' ? `Season ${bangumi[i]}` : bangumi[i]"
@@ -116,3 +87,165 @@ defineEmits(['click']);
</div>
</div>
</template>
<style lang="scss" scoped>
// Grid poster card
.card {
width: 150px;
cursor: pointer;
user-select: none;
}
.card-poster {
position: relative;
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-md);
transition: box-shadow var(--transition-fast), transform var(--transition-fast);
.card:hover & {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
}
.card-img {
width: 100%;
height: 210px;
object-fit: cover;
display: block;
}
.card-placeholder {
width: 100%;
height: 210px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-surface-hover);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
transition: background-color var(--transition-normal);
&--small {
height: 44px;
}
}
.card-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(2px);
transition: opacity var(--transition-normal);
.card:hover & {
opacity: 1;
}
.card:active & {
background: rgba(0, 0, 0, 0.5);
}
}
.card-edit-btn {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
color: #fff;
box-shadow: var(--shadow-md);
transition: transform var(--transition-fast);
.card:active & {
transform: scale(0.9);
}
}
.card-info {
padding: 8px 2px 4px;
}
.card-title {
font-size: 14px;
font-weight: 500;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 4px;
transition: color var(--transition-normal);
}
.card-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
// Search result card
.search-card {
width: 480px;
border-radius: var(--radius-lg);
padding: 4px;
background: var(--color-primary-light);
box-shadow: var(--shadow-sm);
transition: background-color var(--transition-normal);
}
.search-card-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 12px;
transition: background-color var(--transition-normal);
}
.search-card-content {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.search-card-thumb {
width: 72px;
height: 44px;
border-radius: var(--radius-sm);
overflow: hidden;
flex-shrink: 0;
}
.search-card-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.search-card-meta {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.search-card-title {
font-size: 14px;
font-weight: 500;
color: var(--color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -10,24 +10,62 @@ withDefaults(
</script>
<template>
<div rounded-10 overflow-hidden>
<div
bg-theme-row
w-full
text-white
px-20
h="38 pc:45"
fx-cer
justify-between
select-none
>
<div text="h3 pc:h2">{{ title }}</div>
<div class="container-card">
<div class="container-header">
<div class="container-title">{{ title }}</div>
<slot name="title-right"></slot>
</div>
<div p="14 pc:20" bg-white text="14 inherit">
<div class="container-body">
<slot></slot>
</div>
</div>
</template>
<style lang="scss" scoped>
.container-card {
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
transition: border-color var(--transition-normal),
box-shadow var(--transition-normal);
}
.container-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 42px;
background: var(--color-primary);
color: #fff;
user-select: none;
@include forPC {
height: 48px;
}
}
.container-title {
font-size: 16px;
font-weight: 500;
@include forPC {
font-size: 18px;
}
}
.container-body {
padding: 14px;
background: var(--color-surface);
color: var(--color-text);
font-size: 14px;
transition: background-color var(--transition-normal),
color var(--transition-normal);
@include forPC {
padding: 20px;
}
}
</style>

View File

@@ -14,28 +14,14 @@ withDefaults(
<template>
<Disclosure v-slot="{ open }">
<div rounded-10 overflow-hidden h-max>
<DisclosureButton
bg-theme-row
w-full
text-white
fx-cer
px-20
h="38 pc:45"
justify-between
>
<div text="h3 pc:h2">{{ title }}</div>
<Component :is="open ? Up : Down" size="24" />
<div class="fold-panel">
<DisclosureButton class="fold-panel-header">
<div class="fold-panel-title">{{ title }}</div>
<Component :is="open ? Up : Down" :size="18" />
</DisclosureButton>
<div
bg-white
py="10 pc:20"
text="14 inherit"
:class="[open ? 'px-20' : 'px-8']"
>
<div v-show="!open" line my-12></div>
<div class="fold-panel-body" :class="[open && 'fold-panel-body--open']">
<div v-show="!open" class="fold-panel-divider"></div>
<DisclosurePanel>
<slot></slot>
@@ -44,3 +30,64 @@ withDefaults(
</div>
</Disclosure>
</template>
<style lang="scss" scoped>
.fold-panel {
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--color-border);
transition: border-color var(--transition-normal);
}
.fold-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0 20px;
height: 42px;
background: var(--color-primary);
color: #fff;
border: none;
cursor: pointer;
@include forPC {
height: 48px;
}
}
.fold-panel-title {
font-size: 16px;
font-weight: 500;
@include forPC {
font-size: 18px;
}
}
.fold-panel-body {
background: var(--color-surface);
padding: 10px 8px;
font-size: 14px;
color: var(--color-text);
transition: background-color var(--transition-normal),
color var(--transition-normal);
&--open {
padding: 16px 20px;
}
@include forPC {
&--open {
padding: 20px;
}
}
}
.fold-panel-divider {
width: 100%;
height: 1px;
background: var(--color-border);
margin: 12px 0;
}
</style>

View File

@@ -15,9 +15,23 @@ const abLabel = computed(() => {
</script>
<template>
<div flex="~ items-start justify-between">
<div>{{ abLabel }}</div>
<div class="label-row">
<div class="label-text">{{ abLabel }}</div>
<slot></slot>
</div>
</template>
<style lang="scss" scoped>
.label-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.label-text {
font-size: 14px;
color: var(--color-text);
padding-top: 4px;
transition: color var(--transition-normal);
}
</style>

View File

@@ -30,7 +30,7 @@ function close() {
<template>
<TransitionRoot appear :show="show" as="template">
<Dialog as="div" class="relative z-10" @close="close">
<Dialog as="div" class="popup-dialog" @close="close">
<TransitionChild
as="template"
enter="duration-300 ease-out"
@@ -40,11 +40,11 @@ function close() {
leave-from="opacity-100"
leave-to="opacity-0"
>
<div fixed inset-0 bg="black opacity-25" />
<div class="popup-backdrop" />
</TransitionChild>
<div fixed inset-0 overflow-y-auto>
<div flex="~ items-center justify-center" min-h-full p-4 text-center>
<div class="popup-wrapper">
<div class="popup-center">
<TransitionChild
as="template"
enter="duration-300 ease-out"
@@ -65,3 +65,32 @@ function close() {
</Dialog>
</TransitionRoot>
</template>
<style lang="scss" scoped>
.popup-dialog {
position: relative;
z-index: 40;
}
.popup-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(2px);
}
.popup-wrapper {
position: fixed;
inset: 0;
overflow-y: auto;
}
.popup-center {
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
padding: 16px;
text-align: center;
}
</style>

View File

@@ -31,34 +31,25 @@ function onSelect(site: string) {
@select="() => (showProvider = !showProvider)"
/>
<div
v-show="showProvider"
v-on-click-outside="() => (showProvider = false)"
abs
top-84
left-540
w-100
rounded-12
shadow
bg-white
z-99
overflow-hidden
>
<transition name="dropdown">
<div
v-for="site in providers"
:key="site"
hover:bg-theme-row
is-btn
@click="() => onSelect(site)"
v-show="showProvider"
v-on-click-outside="() => (showProvider = false)"
class="provider-dropdown"
>
<div text="h3 primary" hover="text-white" p-12 truncate>
<div
v-for="site in providers"
:key="site"
class="provider-item"
@click="() => onSelect(site)"
>
{{ site }}
</div>
</div>
</div>
</transition>
<div v-on-click-outside="clearSearch" abs top-84 left-192 z-8>
<transition-group name="fade-list" tag="ul" space-y-12>
<div v-on-click-outside="clearSearch" class="search-results">
<transition-group name="fade-list" tag="ul" class="search-results-list">
<li v-for="bangumi in bangumiList" :key="bangumi.order">
<ab-bangumi-card
:bangumi="bangumi.value"
@@ -70,4 +61,52 @@ function onSelect(site: string) {
</div>
</template>
<style lang="scss" scoped></style>
<style lang="scss" scoped>
.provider-dropdown {
position: absolute;
top: 84px;
left: 540px;
width: 120px;
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;
transition: background-color var(--transition-normal),
border-color var(--transition-normal);
}
.provider-item {
padding: 10px 12px;
font-size: 14px;
color: var(--color-primary);
cursor: pointer;
user-select: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: background-color var(--transition-fast), color var(--transition-fast);
&:hover {
background: var(--color-primary);
color: #fff;
}
}
.search-results {
position: absolute;
top: 84px;
left: 192px;
z-index: 30;
}
.search-results-list {
display: flex;
flex-direction: column;
gap: 12px;
list-style: none;
padding: 0;
margin: 0;
}
</style>

View File

@@ -33,67 +33,152 @@ function abLabel(label: string | (() => string)) {
<template>
<Menu>
<div rel>
<div fx-cer space-x="pc:16 10" text="pc:24 20">
<International
theme="outline"
size="1em"
fill="#fff"
is-btn
btn-click
<div class="status-bar">
<div class="status-bar-actions">
<button
class="status-bar-btn"
aria-label="Switch language"
@click="() => $emit('changeLang')"
/>
>
<International theme="outline" size="1em" />
</button>
<AddOne
theme="outline"
size="1em"
fill="#fff"
is-btn
btn-click
<button
class="status-bar-btn"
aria-label="Add RSS subscription"
@click="() => $emit('clickAdd')"
/>
>
<AddOne theme="outline" size="1em" />
</button>
<MenuButton bg-transparent is-btn btn-click>
<System theme="outline" size="1em" fill="#fff" />
<MenuButton class="status-bar-btn" aria-label="System menu">
<System theme="outline" size="1em" />
</MenuButton>
<ab-status :running="running" />
</div>
<MenuItems
abs
top="pc:50 40"
left="pc:32 0"
w-120
rounded-8
bg-white
overflow-hidden
shadow
z-99
>
<MenuItems class="status-menu">
<MenuItem v-for="i in items" :key="i.id" v-slot="{ active }">
<div
w-full
h-32
px-12
fx-cer
gap-x-8
is-btn
hover="text-white bg-primary"
class="group"
:class="[active ? 'text-white bg-theme-row' : 'text-black']"
class="status-menu-item"
:class="[active && 'status-menu-item--active']"
@click="() => i.handle && i.handle()"
>
<div
group-hover="text-white"
:class="[active ? 'text-white' : 'text-primary']"
>
<div class="status-menu-item-icon">
<Component :is="i.icon" size="16"></Component>
</div>
<div text-main>{{ abLabel(i.label) }}</div>
<div class="status-menu-item-label">{{ abLabel(i.label) }}</div>
</div>
</MenuItem>
</MenuItems>
</div>
</Menu>
</template>
<style lang="scss" scoped>
.status-bar {
position: relative;
}
.status-bar-actions {
display: flex;
align-items: center;
gap: 12px;
font-size: 20px;
@include forMobile {
gap: 8px;
font-size: 18px;
}
}
.status-bar-btn {
cursor: pointer;
user-select: none;
color: var(--color-text-secondary);
transition: color var(--transition-fast), transform var(--transition-fast);
display: flex;
align-items: center;
background: transparent;
border: none;
padding: 4px;
border-radius: var(--radius-sm);
&:hover {
color: var(--color-primary);
transform: scale(1.1);
}
&:active {
transform: scale(1);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
.status-menu {
position: absolute;
top: 40px;
right: 0;
width: 160px;
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);
@include forPC {
top: 44px;
}
}
@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%;
height: 32px;
padding: 0 10px;
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);
}
}
.status-menu-item-icon {
color: var(--color-primary);
display: flex;
align-items: center;
}
.status-menu-item-label {
font-size: 12px;
}
</style>

View File

@@ -15,60 +15,95 @@ defineEmits(['click']);
const buttonSize = computed(() => {
switch (props.type) {
case 'large':
return 'wh-36';
return 'add-btn--large';
case 'medium':
return 'wh-24';
return 'add-btn--medium';
case 'small':
return 'wh-12';
}
});
const lineSize = computed(() => {
switch (props.type) {
case 'large':
return 'w-18 h-4';
case 'medium':
return 'w-3 h-12';
case 'small':
return 'w-2 h-6';
return 'add-btn--small';
}
});
</script>
<template>
<button
:rounded="round ? '1/2' : '8'"
f-cer
rel
transition-colors
class="box"
:class="[`type-${type}`, buttonSize]"
class="add-btn"
:class="[buttonSize, round && 'add-btn--round']"
aria-label="Add"
@click="$emit('click')"
>
<div :class="[`type-${type}`, lineSize]" class="line" abs />
<div :class="[`type-${type}`, lineSize]" class="line" abs rotate-90></div>
<svg viewBox="0 0 24 24" fill="none" class="add-btn-icon">
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
</svg>
</button>
</template>
<style lang="scss" scoped>
$normal: #4e3c94;
$hover: #281e52;
$active: #8e8a9c;
.box {
background: $normal;
.add-btn {
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
color: #fff;
border: none;
cursor: pointer;
transition: background-color var(--transition-fast),
transform var(--transition-fast);
&:hover {
background: $hover;
background: var(--color-primary-hover);
}
&:active {
background: $normal;
transform: scale(0.92);
}
&--round {
border-radius: 50%;
}
&--large {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
.add-btn-icon {
width: 18px;
height: 18px;
}
}
&--medium {
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
.add-btn-icon {
width: 14px;
height: 14px;
}
}
&--small {
width: 20px;
height: 20px;
border-radius: 4px;
.add-btn-icon {
width: 10px;
height: 10px;
}
}
&--round.add-btn--large {
border-radius: 50%;
}
&--round.add-btn--medium {
border-radius: 50%;
}
&--round.add-btn--small {
border-radius: 50%;
}
}
.line {
border-radius: 1px;
background: #fff;
}
</style>

View File

@@ -22,93 +22,38 @@ defineEmits(['click']);
const selected = ref<string>(props.selections[0]);
const showSelections = ref<boolean>(false);
const buttonSize = computed(() => {
switch (props.size) {
case 'big':
return 'rounded-10 text-h1 w-276 h-55 text-h1';
case 'normal':
return 'rounded-6 w-170 h-36';
case 'small':
return 'rounded-6 w-86 h-28 text-main';
}
});
const selectboxSize = computed(() => {
switch (props.size) {
case 'big':
return 'w-276 rounded-10 text-h1';
case 'normal':
return 'w-170 rounded-6';
case 'small':
return 'w-86 rounded-6 text-main';
}
});
const loadingSize = computed(() => {
switch (props.size) {
case 'big':
return 'large';
case 'normal':
return 'small';
case 'small':
return 18;
}
});
function onSelect(selection: string) {
selected.value = selection;
showSelections.value = false;
console.log(selected.value);
}
</script>
<template>
<div :class="buttonSize" f-cer overflow-hidden>
<div class="btn-multi" :class="[`btn-multi--${size}`, `btn-multi--${type}`]">
<Component
:is="link !== null ? 'a' : 'button'"
:href="link"
text-white
outline-none
wh-full
pl-12
:class="[`type-${type}`]"
class="btn-multi-main"
@click="$emit('click', selected)"
>
<NSpin :show="loading" :size="loadingSize">
<div text-main>{{ selected }}</div>
<NSpin :show="loading" :size="size === 'big' ? 'large' : 'small'">
<div class="btn-multi-label">{{ selected }}</div>
</NSpin>
</Component>
<div
is-btn
px-12
h-full
f-cer
:class="[`selector-${type}`]"
class="btn-multi-arrow"
@click="() => (showSelections = !showSelections)"
>
<Down fill="white" />
</div>
</div>
<div
v-if="showSelections"
abs
z-70
:class="selectboxSize"
overflow-hidden
class="select-box"
class="btn-multi-dropdown"
:class="[`btn-multi--${size}`, `btn-multi--${type}`]"
>
<div
v-for="selection in selections"
:key="selection"
is-btn
wh-full
f-cer
text-main
py-8
text-white
:class="[`type-${type}`]"
@click="onSelect(selection)"
class="btn-multi-option"
@click="() => { selected = selection; showSelections = false; }"
>
{{ selection }}
</div>
@@ -116,25 +61,108 @@ function onSelect(selection: string) {
</template>
<style lang="scss" scoped>
.type {
&-primary {
@include bg-mouse-event(#4e3c94, #281e52, #8e8a9c);
.btn-multi {
display: flex;
align-items: center;
overflow: hidden;
color: #fff;
&--big {
border-radius: var(--radius-md);
width: 276px;
height: 55px;
font-size: 24px;
}
&-warn {
@include bg-mouse-event(#943c61, #521e2a, #9c8a93);
&--normal {
border-radius: var(--radius-sm);
width: 170px;
height: 36px;
font-size: 14px;
}
}
.selector {
&-primary {
@include bg-mouse-event(#4e3c94, #281e52, #8e8a9c);
&--small {
border-radius: var(--radius-sm);
width: 86px;
height: 28px;
font-size: 12px;
}
&-warn {
@include bg-mouse-event(#943c61, #521e2a, #9c8a93);
&--primary {
.btn-multi-main,
.btn-multi-arrow,
.btn-multi-option {
background: var(--color-primary);
}
.btn-multi-main:hover,
.btn-multi-arrow:hover,
.btn-multi-option:hover {
background: var(--color-primary-hover);
}
}
&--warn {
.btn-multi-main,
.btn-multi-arrow,
.btn-multi-option {
background: var(--color-danger);
}
.btn-multi-main:hover,
.btn-multi-arrow:hover,
.btn-multi-option:hover {
filter: brightness(0.9);
}
}
}
.select-box {
transform: TranslateY(80%) TranslateX(-111%);
.btn-multi-main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding-left: 12px;
border: none;
outline: none;
color: inherit;
cursor: pointer;
transition: background-color var(--transition-fast);
}
.btn-multi-label {
font-size: inherit;
}
.btn-multi-arrow {
display: flex;
align-items: center;
justify-content: center;
padding: 0 12px;
height: 100%;
cursor: pointer;
user-select: none;
transition: background-color var(--transition-fast);
}
.btn-multi-dropdown {
position: absolute;
z-index: 70;
overflow: hidden;
transform: translateY(80%) translateX(-111%);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
}
.btn-multi-option {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 8px 0;
cursor: pointer;
user-select: none;
color: #fff;
font-size: inherit;
transition: background-color var(--transition-fast);
}
</style>

View File

@@ -21,22 +21,11 @@ defineEmits(['click']);
const buttonSize = computed(() => {
switch (props.size) {
case 'big':
return 'rounded-10 text-h1 w-276 h-55 text-h1';
return 'btn--big';
case 'normal':
return 'rounded-6 w-170 h-36';
return 'btn--normal';
case 'small':
return 'rounded-6 w-86 h-28 text-main';
}
});
const loadingSize = computed(() => {
switch (props.size) {
case 'big':
return 'large';
case 'normal':
return 'small';
case 'small':
return 18;
return 'btn--small';
}
});
</script>
@@ -45,26 +34,74 @@ const loadingSize = computed(() => {
<Component
:is="link !== null ? 'a' : 'button'"
:href="link"
text-white
outline-none
f-cer
:class="[`type-${type}`, buttonSize]"
class="btn"
:class="[`btn--${type}`, buttonSize]"
@click="$emit('click')"
>
<NSpin :show="loading" :size="loadingSize">
<NSpin :show="loading" :size="size === 'big' ? 'large' : 'small'">
<slot></slot>
</NSpin>
</Component>
</template>
<style lang="scss" scoped>
.type {
&-primary {
@include bg-mouse-event(#4e3c94, #281e52, #8e8a9c);
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
outline: none;
color: #fff;
font-weight: 500;
cursor: pointer;
user-select: none;
transition: background-color var(--transition-fast),
transform var(--transition-fast),
box-shadow var(--transition-fast);
&:active {
transform: scale(0.97);
}
&-warn {
@include bg-mouse-event(#943c61, #521e2a, #9c8a93);
// Sizes
&--big {
border-radius: var(--radius-md);
font-size: 18px;
width: 276px;
height: 55px;
}
&--normal {
border-radius: var(--radius-sm);
font-size: 14px;
width: 170px;
height: 36px;
}
&--small {
border-radius: var(--radius-sm);
font-size: 12px;
width: 86px;
height: 28px;
}
// Types
&--primary {
background: var(--color-primary);
&:hover {
background: var(--color-primary-hover);
box-shadow: 0 2px 8px color-mix(in srgb, var(--color-primary) 30%, transparent);
}
}
&--warn {
background: var(--color-danger);
&:hover {
filter: brightness(0.9);
box-shadow: 0 2px 8px color-mix(in srgb, var(--color-danger) 30%, transparent);
}
}
}
</style>

View File

@@ -15,28 +15,22 @@ const checked = defineModel<boolean>({ default: false });
<template>
<Switch v-model="checked" as="template">
<div flex="~ items-center gap-x-8" is-btn>
<div class="checkbox-wrapper">
<slot name="before"></slot>
<div
rel
f-cer
bg-white
border="solid #3c239f"
class="checkbox"
:class="[
small ? 'wh-16 border-2 rounded-4' : 'wh-32 border-4 rounded-6',
!checked && 'group',
small ? 'checkbox--small' : 'checkbox--normal',
checked && 'checkbox--checked',
]"
>
<div
rounded-2
transition="all duration-300"
class="checkbox-inner"
:class="[
small ? 'wh-8' : 'wh-16',
checked ? 'bg-[#3c239f]' : 'bg-transparent',
small ? 'checkbox-inner--small' : 'checkbox-inner--normal',
checked && 'checkbox-inner--checked',
]"
group-hover:bg="#cccad4"
group-active:bg="#3c239f"
></div>
</div>
@@ -44,3 +38,68 @@ const checked = defineModel<boolean>({ default: false });
</div>
</Switch>
</template>
<style lang="scss" scoped>
.checkbox-wrapper {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.checkbox {
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-surface);
border: 2px solid var(--color-primary);
transition: border-color var(--transition-fast),
background-color var(--transition-fast);
&--normal {
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
border-width: 2px;
}
&--small {
width: 16px;
height: 16px;
border-radius: 4px;
border-width: 2px;
}
&--checked {
border-color: var(--color-primary);
}
&:hover:not(.checkbox--checked) {
.checkbox-inner {
background: var(--color-border-hover);
}
}
}
.checkbox-inner {
border-radius: 2px;
transition: background-color var(--transition-fast);
background: transparent;
&--normal {
width: 12px;
height: 12px;
}
&--small {
width: 8px;
height: 8px;
}
&--checked {
background: var(--color-primary);
}
}
</style>

View File

@@ -10,8 +10,41 @@ withDefaults(
</script>
<template>
<div fx-cer gap-x-12>
<div text="pc:h1 h2">{{ title }}</div>
<div w-160 h-3 bg-theme-row rounded-full></div>
<div class="page-title">
<h1 class="page-title-text">{{ title }}</h1>
<div class="page-title-accent"></div>
</div>
</template>
<style lang="scss" scoped>
.page-title {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.page-title-text {
font-size: 22px;
font-weight: 600;
color: var(--color-text);
margin: 0;
transition: color var(--transition-normal);
@include forMobile {
font-size: 18px;
}
}
.page-title-accent {
width: 120px;
height: 3px;
border-radius: var(--radius-full);
background: linear-gradient(90deg, var(--color-primary), var(--color-primary-hover));
opacity: 0.6;
@include forMobile {
width: 80px;
}
}
</style>

View File

@@ -1,7 +1,6 @@
<script lang="ts" setup>
import { Down, Search } from '@icon-park/vue-next';
import { NSpin } from 'naive-ui';
import { watch } from 'vue';
withDefaults(
defineProps<{
@@ -17,65 +16,119 @@ withDefaults(
defineEmits(['select', 'search']);
const inputValue = defineModel<string>('inputValue');
watch(inputValue, (val) => {
console.log(val);
});
</script>
<template>
<div
bg="#7752B4"
text-white
fx-cer
rounded-12
h-36
pl-12
gap-x-12
w-400
overflow-hidden
shadow-inner
>
<Search
<div class="search-input" role="search">
<button
v-if="!loading"
theme="outline"
size="24"
fill="#fff"
is-btn
btn-click
class="search-icon-btn"
aria-label="Search"
@click="$emit('search')"
/>
<NSpin v-else :size="20" />
>
<Search theme="outline" size="20" class="search-icon" />
</button>
<NSpin v-else :size="18" />
<input
v-model="inputValue"
type="text"
:placeholder="$t('topbar.search.placeholder')"
input-reset
class="search-field"
aria-label="Search anime"
@keyup.enter="$emit('search')"
/>
<div
h-full
f-cer
justify-between
px-12
w-100
class="provider"
is-btn
<button
class="search-provider"
aria-label="Select search provider"
@click="$emit('select')"
>
<div text-h3 truncate>
{{ provider }}
</div>
<div class="provider">
<Down />
</div>
</div>
<div class="search-provider-label">{{ provider }}</div>
<Down :size="14" />
</button>
</div>
</template>
<style lang="scss" scoped>
.provider {
background: #4e2a94;
.search-input {
display: flex;
align-items: center;
height: 36px;
padding-left: 12px;
gap: 10px;
width: 360px;
border-radius: var(--radius-md);
background: var(--color-surface-hover);
border: 1px solid var(--color-border);
overflow: hidden;
transition: border-color var(--transition-fast),
background-color var(--transition-normal);
&:focus-within {
border-color: var(--color-primary);
background: var(--color-surface);
}
}
.search-icon-btn {
display: flex;
align-items: center;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
flex-shrink: 0;
}
.search-icon {
color: var(--color-text-muted);
transition: color var(--transition-fast);
.search-icon-btn:hover & {
color: var(--color-primary);
}
}
.search-field {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
font-size: 14px;
color: var(--color-text);
&::placeholder {
color: var(--color-text-muted);
}
}
.search-provider {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
height: 100%;
padding: 0 12px;
min-width: 80px;
background: var(--color-primary);
color: #fff;
border: none;
cursor: pointer;
user-select: none;
font-size: 13px;
font-family: inherit;
transition: background-color var(--transition-fast);
&:hover {
background: var(--color-primary-hover);
}
}
.search-provider-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -64,26 +64,17 @@ watchEffect(() => {
<template>
<Listbox v-slot="{ open }" v-model="selected">
<div
rel
flex="inline col"
rounded-6
border="1 black"
text-main
p="y-4 x-12"
>
<ListboxButton bg-transparent fx-cer justify-between gap-x-24>
<div>
{{ label }}
</div>
<div class="select-wrapper">
<ListboxButton class="select-button">
<div class="select-value">{{ label }}</div>
<div :class="[{ hidden: open }]">
<Down />
<Down :size="14" />
</div>
</ListboxButton>
<ListboxOptions mt-8>
<div flex="~ items-end justify-between gap-x-24">
<div flex="~ col gap-y-8">
<ListboxOptions class="select-options">
<div class="select-options-inner">
<div class="select-options-list">
<ListboxOption
v-for="item in otherItems"
v-slot="{ active }"
@@ -92,9 +83,10 @@ watchEffect(() => {
:disabled="getDisabled(item)"
>
<div
class="select-option"
:class="[
{ 'text-primary': active },
getDisabled(item) ? 'is-disabled' : 'is-btn',
active && 'select-option--active',
getDisabled(item) && 'select-option--disabled',
]"
>
{{ getLabel(item) }}
@@ -102,9 +94,75 @@ watchEffect(() => {
</ListboxOption>
</div>
<div :class="[{ hidden: !open }]"><Up /></div>
<div :class="[{ hidden: !open }]"><Up :size="14" /></div>
</div>
</ListboxOptions>
</div>
</Listbox>
</template>
<style lang="scss" scoped>
.select-wrapper {
position: relative;
display: inline-flex;
flex-direction: column;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
font-size: 12px;
padding: 4px 12px;
transition: border-color var(--transition-fast);
&:hover {
border-color: var(--color-primary);
}
}
.select-button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background: transparent;
border: none;
cursor: pointer;
color: var(--color-text);
padding: 0;
}
.select-value {
color: var(--color-text);
}
.select-options {
margin-top: 8px;
}
.select-options-inner {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
.select-options-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.select-option {
cursor: pointer;
user-select: none;
color: var(--color-text-secondary);
transition: color var(--transition-fast);
&--active {
color: var(--color-primary);
}
&--disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
</style>

View File

@@ -12,14 +12,59 @@ withDefaults(
</script>
<template>
<div f-cer :style="{ width: size, height: size }">
<div rounded="1/2" f-cer border="2 solid white" wh-full>
<div
class="status-indicator"
:style="{ width: size, height: size }"
role="status"
:aria-label="running ? 'System running' : 'System stopped'"
>
<div class="status-ring">
<div
:class="[running ? 'bg-running' : 'bg-stopped']"
rounded="1/2"
wh-10
transition-colors
class="status-dot"
:class="[running ? 'status-dot--running' : 'status-dot--stopped']"
></div>
</div>
</div>
</template>
<style lang="scss" scoped>
.status-indicator {
display: flex;
align-items: center;
justify-content: center;
}
.status-ring {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 2px solid var(--color-border);
transition: border-color var(--transition-normal);
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
transition: background-color var(--transition-fast);
&--running {
background: var(--color-success);
box-shadow: 0 0 6px color-mix(in srgb, var(--color-success) 40%, transparent);
animation: pulse 2s ease-in-out infinite;
}
&--stopped {
background: var(--color-danger);
box-shadow: 0 0 6px color-mix(in srgb, var(--color-danger) 40%, transparent);
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
</style>

View File

@@ -8,54 +8,44 @@ const checked = defineModel<boolean>('checked', {
<template>
<Switch v-model="checked" as="template">
<div
is-btn
w-48
h-28
rounded-full
rel
flex="inline items-center"
transition-colors
duration-300
p-3
shadow="~ inset"
class="box"
:class="{ checked }"
>
<div class="switch-track" :class="{ 'switch-track--checked': checked }">
<div
wh-22
rounded="1/2"
transition-all
duration-300
class="slider"
:class="{ checked, 'translate-x-20': checked }"
class="switch-thumb"
:class="{ 'switch-thumb--checked': checked }"
></div>
</div>
</Switch>
</template>
<style lang="scss" scope>
$bg-unchecked: #929292;
$bg-checked: #1c1259;
<style lang="scss" scoped>
.switch-track {
width: 44px;
height: 24px;
border-radius: var(--radius-full);
position: relative;
display: inline-flex;
align-items: center;
padding: 2px;
cursor: pointer;
user-select: none;
background: var(--color-border-hover);
transition: background-color var(--transition-fast);
$slider-unchecked: #ececef;
$slider-checked: #fff;
.box {
background: $bg-unchecked;
&.checked {
background: $bg-checked;
&--checked {
background: var(--color-primary);
}
}
.slider {
&:not(.checked) {
background: $slider-unchecked;
}
.switch-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
transition: transform var(--transition-fast);
&.checked {
background: $slider-checked;
&--checked {
transform: translateX(20px);
}
}
</style>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
const props = withDefaults(
withDefaults(
defineProps<{
type: 'primary' | 'warn' | 'inactive' | 'active' | 'notify';
title: string;
@@ -9,67 +9,59 @@ const props = withDefaults(
title: 'title',
}
);
const InnerStyle = computed(() => {
return `${props.type}-inner`;
});
</script>
<template>
<div p-1 rounded-16 inline-flex :class="type">
<div bg-white rounded-12 px-8 text-10 truncate max-w-72 :class="InnerStyle">
{{ title }}
</div>
</div>
<span class="tag" :class="`tag--${type}`">
{{ title }}
</span>
</template>
<style lang="scss" scoped>
$primary-map: (
border: linear-gradient(90.5deg, #492897 1.53%, #783674 96.48%),
inner: #eee5f4,
font: #000000,
);
.tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: 10px;
font-weight: 500;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border: 1px solid;
transition: background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
$warn-map: (
border: #892f2f,
inner: #ffdfdf,
font: #892f2f,
);
&--primary {
background: var(--color-primary-light);
border-color: var(--color-primary);
color: var(--color-primary);
}
$inactive-map: (
border: #797979,
inner: #e0e0e0,
font: #3f3f3f,
);
&--warn {
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border-color: var(--color-danger);
color: var(--color-danger);
}
$active-map: (
border: #104931,
inner: #e5f4e0,
font: #4c6643,
);
&--inactive {
background: var(--color-surface-hover);
border-color: var(--color-border);
color: var(--color-text-muted);
}
$notify-map: (
border: #f5c451,
inner: #fff4db,
font: #a76e18,
);
&--active {
background: color-mix(in srgb, var(--color-success) 10%, transparent);
border-color: var(--color-success);
color: var(--color-success);
}
$types-map: (
primary: $primary-map,
warn: $warn-map,
inactive: $inactive-map,
active: $active-map,
notify: $notify-map,
);
@each $type, $colors in $types-map {
.#{$type} {
background: map-get($colors, border);
&-inner {
background: map-get($colors, inner);
color: map-get($colors, font);
}
&--notify {
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
border-color: var(--color-warning);
color: var(--color-warning);
}
}
</style>

View File

@@ -6,8 +6,10 @@ import {
Log,
Logout,
MenuUnfold,
Moon,
Play,
SettingTwo,
Sun,
} from '@icon-park/vue-next';
import InlineSvg from 'vue-inline-svg';
@@ -24,14 +26,15 @@ const { t } = useMyI18n();
const { logout } = useAuth();
const route = useRoute();
const { isMobile } = useBreakpointQuery();
const { isDark, toggle: toggleDark } = useDarkMode();
const show = ref(props.open);
const toggle = () => (show.value = !show.value);
const RSS = h(
'span',
{ class: ['rel', 'left-2'] },
h(InlineSvg, { src: './images/RSS.svg' })
{ style: { display: 'flex', alignItems: 'center', justifyContent: 'center', width: '20px', height: '20px' } },
h(InlineSvg, { src: './images/RSS.svg', width: '16', height: '16' })
);
const items = [
@@ -86,21 +89,13 @@ function Exit() {
<div
title="logout"
class={[
`
mt-auto
fx-cer
gap-x-42
px-24
is-btn
transition-colors
`,
isMobile.value ? 'h-40' : 'h-48',
'sidebar-item sidebar-item--action',
isMobile.value ? 'h-40' : '',
]}
hover="bg-[#F1F5FA] text-[#2A1C52]"
onClick={logout}
>
<Logout size={24} />
{!isMobile.value && <div class="text-h2">{t('sidebar.logout')}</div>}
<Logout size={20} />
{!isMobile.value && show.value && <div class="sidebar-item-label">{t('sidebar.logout')}</div>}
</div>
);
}
@@ -111,91 +106,261 @@ const mobileItems = computed(() => items.filter((i) => i.id !== 4));
<template>
<media-query>
<div
:class="[show ? 'w-240' : 'w-72']"
bg-theme-col
text-white
transition-width
pb-12
rounded="pc:16 10"
class="sidebar"
:class="[show ? 'sidebar--expanded' : 'sidebar--collapsed']"
>
<div overflow-hidden wh-full flex="~ col">
<div
w-full
h-60
is-btn
f-cer
rounded-t-10
bg="#E7E7E7"
text="#2A1C52"
rel
<div class="sidebar-inner">
<!-- Toggle header -->
<button
class="sidebar-header"
:aria-label="show ? 'Collapse sidebar' : 'Expand sidebar'"
:aria-expanded="show"
@click="toggle"
>
<div :class="[!show && 'abs opacity-0']" transition-opacity>
<div text-h1>{{ $t('sidebar.title') }}</div>
<div v-show="show" class="sidebar-title">
{{ $t('sidebar.title') }}
</div>
<MenuUnfold
theme="outline"
size="24"
fill="#2A1C52"
abs
left-24
:class="[show && 'rotate-y-180']"
size="20"
class="sidebar-toggle-icon"
:class="[show && 'sidebar-toggle-icon--open']"
/>
</button>
<!-- Navigation -->
<nav class="sidebar-nav">
<RouterLink
v-for="i in items"
:key="i.id"
:to="i.path"
replace
:title="i.label()"
class="sidebar-item"
:class="[
route.path === i.path && 'sidebar-item--active',
i.hidden && 'hidden',
]"
>
<Component :is="i.icon" :size="20" />
<div v-show="show" class="sidebar-item-label">{{ i.label() }}</div>
</RouterLink>
</nav>
<!-- Bottom actions -->
<div class="sidebar-footer">
<button
class="sidebar-item sidebar-item--action sidebar-item--theme"
:title="isDark ? 'Light mode' : 'Dark mode'"
:aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
@click="toggleDark"
>
<Moon v-if="!isDark" :size="20" />
<Sun v-else :size="20" />
<div v-show="show" class="sidebar-item-label">
{{ isDark ? 'Light' : 'Dark' }}
</div>
</button>
<Exit />
</div>
<RouterLink
v-for="i in items"
:key="i.id"
:to="i.path"
replace
:title="i.label()"
fx-cer
px-24
gap-x-42
h-48
is-btn
transition-colors
hover="bg-[#F1F5FA] text-[#2A1C52]"
:class="[
route.path === i.path && 'bg-[#F1F5FA] text-[#2A1C52]',
i.hidden && 'hidden',
]"
>
<Component :is="i.icon" :size="24" />
<div text-h2 whitespace-nowrap>{{ i.label() }}</div>
</RouterLink>
<Exit />
</div>
</div>
<template #mobile>
<div bg-white flex rounded-10 overflow-hidden>
<div class="mobile-nav">
<RouterLink
v-for="i in mobileItems"
:key="i.id"
:to="i.path"
replace
flex-1
fx-cer
px-24
gap-x-42
h-40
is-btn
transition-colors
rounded-10
class="mobile-nav-item"
:class="[
route.path === i.path && 'bg-theme-row text-white',
route.path === i.path && 'mobile-nav-item--active',
i.hidden && 'hidden',
]"
>
<Component :is="i.icon" :size="24" />
<Component :is="i.icon" :size="20" />
</RouterLink>
<div
class="mobile-nav-item"
@click="toggleDark"
>
<Moon v-if="!isDark" :size="20" />
<Sun v-else :size="20" />
</div>
<Exit />
</div>
</template>
</media-query>
</template>
<style lang="scss" scoped>
.sidebar {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: width var(--transition-normal),
background-color var(--transition-normal),
border-color var(--transition-normal);
overflow: hidden;
&--expanded {
width: 200px;
}
&--collapsed {
width: 64px;
}
}
.sidebar-inner {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 52px;
padding: 0 20px;
cursor: pointer;
position: relative;
border: none;
border-bottom: 1px solid var(--color-border);
background: transparent;
transition: border-color var(--transition-normal),
background-color var(--transition-fast);
&:hover {
background: var(--color-surface-hover);
}
}
.sidebar-title {
font-size: 18px;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
transition: opacity var(--transition-fast);
}
.sidebar-toggle-icon {
position: absolute;
left: 20px;
color: var(--color-text-secondary);
transition: transform var(--transition-normal);
&--open {
transform: rotateY(180deg);
}
}
.sidebar-nav {
display: flex;
flex-direction: column;
padding: 8px;
gap: 2px;
flex: 1;
overflow-y: auto;
}
.sidebar-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-radius: var(--radius-md);
cursor: pointer;
user-select: none;
color: var(--color-text-secondary);
transition: color var(--transition-fast),
background-color var(--transition-fast);
white-space: nowrap;
&:hover {
color: var(--color-primary);
background: var(--color-primary-light);
}
&--active {
color: var(--color-primary);
background: var(--color-primary-light);
font-weight: 500;
}
&--action {
color: var(--color-text-muted);
border: none;
background: transparent;
width: 100%;
font: inherit;
&:hover {
color: var(--color-danger);
background: rgba(239, 68, 68, 0.08);
}
}
&--theme:hover {
color: var(--color-primary);
background: var(--color-primary-light);
}
}
.sidebar-item-label {
font-size: 14px;
}
.sidebar-footer {
display: flex;
flex-direction: column;
padding: 8px;
gap: 2px;
border-top: 1px solid var(--color-border);
margin-top: auto;
transition: border-color var(--transition-normal);
}
// Mobile bottom nav
.mobile-nav {
display: flex;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-sm);
transition: background-color var(--transition-normal),
border-color var(--transition-normal);
}
.mobile-nav-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
height: 44px;
cursor: pointer;
user-select: none;
color: var(--color-text-muted);
border-radius: var(--radius-md);
transition: color var(--transition-fast),
background-color var(--transition-fast);
&:hover {
color: var(--color-primary);
}
&--active {
color: var(--color-primary);
background: var(--color-primary-light);
}
}
</style>

View File

@@ -67,12 +67,12 @@ const items = [
},
];
const { isDark } = useDarkMode();
const onSearchFocus = ref(false);
function addSearchResult(bangumi: BangumiRule) {
showAddRSS.value = true;
searchRule.value = bangumi;
console.log('searchRule', searchRule.value);
}
watch(showAddRSS, (val) => {
@@ -94,33 +94,28 @@ onUnmounted(() => {
</script>
<template>
<div
h="pc:60 50"
bg-theme-row
text-white
rounded="pc:16 10"
fx-cer
px="pc:24 15"
>
<div flex="~ gap-x-16">
<div fx-cer gap-x="pc:16 10">
<img src="/images/logo-light.svg" alt="favicon" wh="pc:24 20" />
<div class="topbar">
<div class="topbar-left">
<div class="topbar-brand">
<img
:src="isDark ? '/images/logo-light.svg' : '/images/logo.svg'"
alt="favicon"
class="topbar-logo"
/>
<img
v-show="onSearchFocus === false"
src="/images/AutoBangumi.svg"
:src="isDark ? '/images/AutoBangumi.svg' : '/images/AutoBangumi-dark.svg'"
alt="AutoBangumi"
rel
h="18 pc:24"
pc:top-2
class="topbar-wordmark"
/>
</div>
<div hidden pc:block>
<div class="topbar-search">
<ab-search-bar @add-bangumi="addSearchResult" />
</div>
</div>
<div ml-auto>
<div class="topbar-right">
<ab-status-bar
:items="items"
:running="running"
@@ -128,6 +123,7 @@ onUnmounted(() => {
@change-lang="changeLocale"
/>
</div>
<ab-change-account v-model:show="showAccount"></ab-change-account>
<ab-add-rss
v-model:show="showAddRSS"
@@ -135,3 +131,69 @@ onUnmounted(() => {
></ab-add-rss>
</div>
</template>
<style lang="scss" scoped>
.topbar {
display: flex;
align-items: center;
height: 56px;
padding: 0 20px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: background-color var(--transition-normal),
border-color var(--transition-normal),
box-shadow var(--transition-normal);
@include forMobile {
height: 48px;
padding: 0 12px;
border-radius: var(--radius-md);
}
}
.topbar-left {
display: flex;
align-items: center;
gap: 16px;
}
.topbar-brand {
display: flex;
align-items: center;
gap: 10px;
}
.topbar-logo {
width: 24px;
height: 24px;
@include forMobile {
width: 20px;
height: 20px;
}
}
.topbar-wordmark {
height: 20px;
position: relative;
@include forMobile {
height: 16px;
}
}
.topbar-search {
display: none;
@include forPC {
display: block;
}
}
.topbar-right {
margin-left: auto;
}
</style>

View File

@@ -0,0 +1,49 @@
import { computed, ref, watch } from 'vue';
import { createSharedComposable, usePreferredDark } from '@vueuse/core';
type ThemeMode = 'light' | 'dark' | 'system';
export const useDarkMode = createSharedComposable(() => {
const prefersDark = usePreferredDark();
const stored = localStorage.getItem('theme') as ThemeMode | null;
const mode = ref<ThemeMode>(stored || 'system');
const isDark = computed(() => {
if (mode.value === 'system') return prefersDark.value;
return mode.value === 'dark';
});
function applyTheme() {
const html = document.documentElement;
if (isDark.value) {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
}
function setMode(newMode: ThemeMode) {
mode.value = newMode;
if (newMode === 'system') {
localStorage.removeItem('theme');
} else {
localStorage.setItem('theme', newMode);
}
}
function toggle() {
setMode(isDark.value ? 'light' : 'dark');
}
watch(isDark, applyTheme, { immediate: true });
watch(prefersDark, () => {
if (mode.value === 'system') applyTheme();
});
return {
mode,
isDark,
setMode,
toggle,
};
});

View File

@@ -7,18 +7,22 @@ definePage({
<template>
<div class="layout-container">
<a href="#main-content" class="skip-link">Skip to content</a>
<ab-topbar />
<main class="layout-main">
<ab-sidebar />
<div class="layout-content">
<div id="main-content" class="layout-content">
<ab-page-title :title="$route.name"></ab-page-title>
<RouterView v-slot="{ Component }">
<KeepAlive>
<component :is="Component" />
</KeepAlive>
<transition name="page" mode="out-in">
<KeepAlive>
<component :is="Component" />
</KeepAlive>
</transition>
</RouterView>
</div>
</main>
@@ -36,7 +40,8 @@ definePage({
display: flex;
flex-direction: column;
background: #f0f0f0;
background: var(--color-bg);
transition: background-color var(--transition-normal);
@include forPC {
min-width: 1024px;
@@ -51,10 +56,10 @@ definePage({
.layout-main {
display: flex;
gap: 20px;
gap: var(--layout-gap);
overflow: hidden;
height: calc(100vh - 2 * var(--layout-padding) - 60px - var(--layout-gap));
height: calc(100vh - 2 * var(--layout-padding) - 56px - var(--layout-gap));
@include forMobile {
flex-direction: column-reverse;
@@ -69,5 +74,24 @@ definePage({
display: flex;
flex-direction: column;
flex: 1;
gap: var(--layout-gap);
}
.skip-link {
position: absolute;
top: -100%;
left: 16px;
z-index: 100;
padding: 8px 16px;
background: var(--color-primary);
color: #fff;
border-radius: var(--radius-sm);
font-size: 14px;
text-decoration: none;
transition: top var(--transition-fast);
&:focus {
top: 16px;
}
}
</style>

View File

@@ -15,45 +15,60 @@ onActivated(() => {
</script>
<template>
<div overflow-auto mt-12 flex-grow>
<div>
<transition-group
name="bangumi"
tag="div"
flex="~ wrap"
gap="20"
:class="{ 'justify-center': isMobile }"
>
<ab-bangumi-card
v-for="i in bangumi"
:key="i.id"
:class="[i.deleted && 'grayscale']"
:bangumi="i"
type="primary"
@click="() => openEditPopup(i)"
></ab-bangumi-card>
</transition-group>
<div class="page-bangumi">
<transition-group
name="bangumi"
tag="div"
class="bangumi-grid"
:class="{ 'bangumi-grid--centered': isMobile }"
>
<ab-bangumi-card
v-for="i in bangumi"
:key="i.id"
:class="[i.deleted && 'grayscale']"
:bangumi="i"
type="primary"
@click="() => openEditPopup(i)"
></ab-bangumi-card>
</transition-group>
<ab-edit-rule
v-model:show="editRule.show"
v-model:rule="editRule.item"
@enable="(id) => enableRule(id)"
@delete-file="
(type, { id, deleteFile }) => ruleManage(type, id, deleteFile)
"
@apply="(rule) => updateRule(rule.id, rule)"
></ab-edit-rule>
</div>
<ab-edit-rule
v-model:show="editRule.show"
v-model:rule="editRule.item"
@enable="(id) => enableRule(id)"
@delete-file="
(type, { id, deleteFile }) => ruleManage(type, id, deleteFile)
"
@apply="(rule) => updateRule(rule.id, rule)"
></ab-edit-rule>
</div>
</template>
<style lang="scss" scoped>
.page-bangumi {
overflow: auto;
flex-grow: 1;
}
.bangumi-grid {
display: flex;
flex-wrap: wrap;
gap: 20px;
&--centered {
justify-content: center;
}
}
</style>
<style>
.bangumi-enter-active,
.bangumi-leave-active {
transition: all 0.5s ease;
transition: opacity var(--transition-normal), transform var(--transition-normal);
}
.bangumi-enter-from,
.bangumi-leave-to {
opacity: 0;
transform: translateY(8px);
}
</style>

View File

@@ -12,46 +12,70 @@ onActivated(() => {
</script>
<template>
<div overflow-auto mt-12 flex-grow>
<div h-full>
<div grid="~ pc:cols-2 gap-20" mb-auto>
<div space-y-20>
<config-normal></config-normal>
<config-parser></config-parser>
<config-download></config-download>
<config-manage></config-manage>
</div>
<div space-y-20>
<config-notification></config-notification>
<config-proxy></config-proxy>
<config-player></config-player>
<config-openai></config-openai>
</div>
<div class="page-config">
<div class="config-grid">
<div class="config-col">
<config-normal></config-normal>
<config-parser></config-parser>
<config-download></config-download>
<config-manage></config-manage>
</div>
<div fx-cer justify-end gap-8 mt-20>
<ab-button
:class="[{ 'flex-1': isMobile }]"
type="warn"
@click="getConfig"
>
{{ $t('config.cancel') }}
</ab-button>
<ab-button
:class="[{ 'flex-1': isMobile }]"
type="primary"
@click="setConfig"
>
{{ $t('config.apply') }}
</ab-button>
<div class="config-col">
<config-notification></config-notification>
<config-proxy></config-proxy>
<config-player></config-player>
<config-openai></config-openai>
</div>
</div>
<div class="config-actions">
<ab-button
:class="[{ 'flex-1': isMobile }]"
type="warn"
@click="getConfig"
>
{{ $t('config.cancel') }}
</ab-button>
<ab-button
:class="[{ 'flex-1': isMobile }]"
type="primary"
@click="setConfig"
>
{{ $t('config.apply') }}
</ab-button>
</div>
</div>
</template>
<style lang="scss" scoped>
.page-config {
overflow: auto;
flex-grow: 1;
}
.config-grid {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
margin-bottom: auto;
@media (min-width: 1024px) {
grid-template-columns: 1fr 1fr;
}
}
.config-col {
display: flex;
flex-direction: column;
gap: 20px;
}
.config-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
</style>

View File

@@ -24,22 +24,58 @@ onActivated(() => {
</script>
<template>
<div overflow-auto mt-12 flex-grow>
<div class="page-embed">
<template v-if="isNull">
<div wh-full f-cer text-h1 text-primary>
<RouterLink to="/config" hover:underline>{{
$t('downloader.hit')
}}</RouterLink>
<div class="embed-empty">
<RouterLink to="/config" class="embed-link">
{{ $t('downloader.hit') }}
</RouterLink>
</div>
</template>
<iframe
v-else
:src="url"
frameborder="0"
allowfullscreen="true"
wh-full
flex-1
rounded-12
class="embed-frame"
></iframe>
</div>
</template>
<style lang="scss" scoped>
.page-embed {
overflow: auto;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.embed-empty {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.embed-link {
font-size: 24px;
color: var(--color-primary);
text-decoration: none;
transition: color var(--transition-fast);
&:hover {
text-decoration: underline;
color: var(--color-primary-hover);
}
}
.embed-frame {
width: 100%;
height: 100%;
flex: 1;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
</style>

View File

@@ -29,13 +29,13 @@ const formatLog = computed(() => {
});
function typeColor(type: string) {
const M = {
INFO: '#4e3c94',
WARNING: '#A76E18',
ERROR: '#C70E0E',
DEBUG: '#A0A0A0',
const M: Record<string, string> = {
INFO: 'var(--color-primary)',
WARNING: 'var(--color-warning)',
ERROR: 'var(--color-danger)',
DEBUG: 'var(--color-text-muted)',
};
return M[type];
return M[type] || 'var(--color-text)';
}
const logContainer = ref<HTMLElement | null>(null);
@@ -66,40 +66,27 @@ onDeactivated(() => {
</script>
<template>
<div overflow-auto mt-12 flex-grow>
<div flex="~ wrap gap-12">
<ab-container :title="$t('log.title')" w-660 grow>
<div
ref="logContainer"
rounded-10
border="1 solid black"
overflow-auto
p-10
max-h-60vh
min-h-20vh
>
<div min-w-450>
<div class="page-log">
<div class="log-layout">
<ab-container :title="$t('log.title')" class="log-main">
<div ref="logContainer" class="log-viewer">
<div class="log-content">
<template v-for="i in formatLog" :key="i.index">
<div
p="y-10"
leading="1.5em"
border="0 b-1 solid"
last:border-b-0
flex="~ items-center gap-20"
class="log-entry"
:style="{ color: typeColor(i.type) }"
>
<div flex="~ col items-center gap-10" whitespace-nowrap>
<div text="center">{{ i.type }}</div>
<div>{{ i.date }}</div>
<div class="log-meta">
<div class="log-type">{{ i.type }}</div>
<div class="log-date">{{ i.date }}</div>
</div>
<div flex-1 break-all>{{ i.content }}</div>
<div class="log-message">{{ i.content }}</div>
</div>
</template>
</div>
</div>
<div flex="~ justify-end gap-x-10" mt-12>
<div class="log-actions">
<ab-button size="small" @click="getLog">
{{ $t('log.update_now') }}
</ab-button>
@@ -114,9 +101,9 @@ onDeactivated(() => {
</div>
</ab-container>
<div grow w-500 space-y-20>
<div class="log-sidebar">
<ab-container :title="$t('log.contact_info')">
<div space-y-12>
<div class="contact-list">
<ab-label label="Github">
<ab-button
size="small"
@@ -137,7 +124,7 @@ onDeactivated(() => {
</ab-button>
</ab-label>
<div line></div>
<div class="divider"></div>
<ab-label label="X">
<ab-button
@@ -162,21 +149,17 @@ onDeactivated(() => {
</ab-container>
<ab-container :title="$t('log.bug_repo')">
<div space-y-12>
<div class="bug-section">
<ab-button
mx-auto
text-16
w-300
h-46
rounded-10
class="issues-btn"
link="https://github.com/EstrellaXD/Auto_Bangumi/issues"
>
Github Issues
</ab-button>
<div line></div>
<div class="divider"></div>
<div text="center primary h3">
<div class="version-info">
<span>Version: </span>
<span>{{ version }}</span>
</div>
@@ -186,3 +169,138 @@ onDeactivated(() => {
</div>
</div>
</template>
<style lang="scss" scoped>
.page-log {
overflow: auto;
flex-grow: 1;
}
.log-layout {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.log-main {
flex-grow: 1;
min-width: 0;
@media (min-width: 1024px) {
min-width: 660px;
}
}
.log-viewer {
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
overflow: auto;
padding: 10px;
max-height: 60vh;
min-height: 20vh;
transition: border-color var(--transition-normal);
}
.log-content {
min-width: 0;
@media (min-width: 1024px) {
min-width: 450px;
}
}
.log-entry {
padding: 10px 0;
line-height: 1.5;
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: flex-start;
gap: 12px;
@media (min-width: 1024px) {
align-items: center;
gap: 20px;
}
&:last-child {
border-bottom: none;
}
}
.log-meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
white-space: nowrap;
}
.log-type {
text-align: center;
font-weight: 500;
font-size: 12px;
}
.log-date {
font-size: 11px;
opacity: 0.8;
}
.log-message {
flex: 1;
word-break: break-all;
font-size: 13px;
}
.log-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 12px;
}
.log-sidebar {
flex-grow: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 20px;
@media (min-width: 1024px) {
min-width: 400px;
}
}
.contact-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.divider {
width: 100%;
height: 1px;
background: var(--color-border);
}
.bug-section {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.issues-btn {
width: 100%;
max-width: 300px;
height: 46px;
font-size: 16px;
border-radius: var(--radius-md);
}
.version-info {
text-align: center;
color: var(--color-primary);
font-size: 16px;
}
</style>

View File

@@ -7,12 +7,12 @@ const { url } = storeToRefs(usePlayerStore());
</script>
<template>
<div overflow-auto mt-12 flex-grow>
<div class="page-embed">
<template v-if="url === ''">
<div wh-full f-cer text-h1 text-primary>
<RouterLink to="/config" hover:underline>{{
$t('player.hit')
}}</RouterLink>
<div class="embed-empty">
<RouterLink to="/config" class="embed-link">
{{ $t('player.hit') }}
</RouterLink>
</div>
</template>
<iframe
@@ -20,9 +20,44 @@ const { url } = storeToRefs(usePlayerStore());
:src="url"
frameborder="0"
allowfullscreen="true"
wh-full
flex-1
rounded-12
class="embed-frame"
></iframe>
</div>
</template>
<style lang="scss" scoped>
.page-embed {
overflow: auto;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.embed-empty {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.embed-link {
font-size: 24px;
color: var(--color-primary);
text-decoration: none;
transition: color var(--transition-fast);
&:hover {
text-decoration: underline;
color: var(--color-primary-hover);
}
}
.embed-frame {
width: 100%;
height: 100%;
flex: 1;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
</style>

View File

@@ -74,7 +74,7 @@ const RSSTableOptions = computed(() => {
</script>
<template>
<div overflow-auto mt-12 flex-grow>
<div class="page-rss">
<ab-container :title="$t('rss.title')">
<NDataTable
v-bind="RSSTableOptions"
@@ -82,13 +82,13 @@ const RSSTableOptions = computed(() => {
></NDataTable>
<div v-if="selectedRSS.length > 0">
<div line my-12></div>
<div flex="~ justify-end gap-x-10">
<div class="divider"></div>
<div class="rss-actions">
<ab-button @click="enableSelected">{{ $t('rss.enable') }}</ab-button>
<ab-button @click="disableSelected">{{
$t('rss.disable')
}}</ab-button>
<ab-button class="type-warn" @click="deleteSelected">{{
<ab-button type="warn" @click="deleteSelected">{{
$t('rss.delete')
}}</ab-button>
</div>
@@ -96,3 +96,23 @@ const RSSTableOptions = computed(() => {
</ab-container>
</div>
</template>
<style lang="scss" scoped>
.page-rss {
overflow: auto;
flex-grow: 1;
}
.divider {
width: 100%;
height: 1px;
background: var(--color-border);
margin: 12px 0;
}
.rss-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

View File

@@ -7,15 +7,15 @@ const { user, login } = useAuth();
</script>
<template>
<div wh-screen f-cer bg-page>
<ab-container :title="$t('login.title')" w-365 max-w="90%">
<div space-y-16>
<div class="page-login">
<ab-container :title="$t('login.title')" class="login-card">
<div class="login-form">
<ab-label :label="$t('login.username')">
<input
v-model="user.username"
type="text"
placeholder="username"
ab-input
class="login-input"
/>
</ab-label>
@@ -24,14 +24,14 @@ const { user, login } = useAuth();
v-model="user.password"
type="password"
placeholder="password"
ab-input
class="login-input"
@keyup.enter="login"
/>
</ab-label>
<div line></div>
<div class="divider"></div>
<div flex="~ justify-end">
<div class="login-actions">
<ab-button size="small" @click="login">
{{ $t('login.login_btn') }}
</ab-button>
@@ -40,3 +40,64 @@ const { user, login } = useAuth();
</ab-container>
</div>
</template>
<style lang="scss" scoped>
.page-login {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg);
}
.login-card {
width: 365px;
max-width: 90%;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.login-input {
outline: none;
min-width: 0;
width: 200px;
height: 28px;
padding: 0 12px;
font-size: 12px;
text-align: right;
border-radius: 6px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
&:hover {
border-color: var(--color-primary);
}
&:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(108, 74, 182, 0.2);
}
&::placeholder {
color: var(--color-text-muted);
}
}
.divider {
width: 100%;
height: 1px;
background: var(--color-border);
}
.login-actions {
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -1,20 +1,36 @@
// Base styles
html {
color-scheme: light;
&.dark {
color-scheme: dark;
}
}
body {
font-family: var(--font-family);
color: var(--color-text);
background-color: var(--color-bg);
transition: color var(--transition-normal), background-color var(--transition-normal);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
// Scrollbar
::-webkit-scrollbar {
width: var(--scrollbar-size);
height: var(--scrollbar-size);
}
/* 滚动槽--外层轨道 */
::-webkit-scrollbar-track {
background: var(--scrollbar-color);
}
/* 内层轨道(不包含滚动块部分) */
/* 透明度设置为全透明,使得滚动条背景色为网页颜色 */
::-webkit-scrollbar-track-piece {
opacity: 0;
}
/* 滚动条滑块 */
::-webkit-scrollbar-thumb {
border-radius: calc(var(--scrollbar-size) / 2);
background: var(--scrollbar-thumb-color);
@@ -24,18 +40,36 @@
}
}
/* 滚动条按钮 */
::-webkit-scrollbar-button {
display: none;
}
/* 横向滚动条和纵向滚动条相交处尖角的颜色 */
::-webkit-scrollbar-corner {
background-color: transparent;
}
// Remove number input spinners
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
// Reduced motion
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
// Focus visible ring
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: var(--radius-sm);
}

View File

@@ -14,7 +14,7 @@ $min-pc: 1024px;
@mixin bg-mouse-event($normal, $hover, $active) {
background: $normal;
transition: background 0.3s;
transition: background-color var(--transition-normal);
&:hover {
background: $hover;

View File

@@ -1,9 +1,9 @@
// transition
// Transitions
.fade {
&-enter-active,
&-leave-active {
transition: opacity 0.2s ease;
transition: opacity var(--transition-normal);
}
&-enter-from,
@@ -13,14 +13,71 @@
}
}
// transition-group
.fade-list-enter-active,
.fade-list-leave-active {
transition: all 0.3s ease;
transition: all var(--transition-slow);
}
.fade-list-enter-from,
.fade-list-leave-to {
opacity: 0;
}
// Slide transitions for sidebar/panels
.slide {
&-enter-active,
&-leave-active {
transition: transform var(--transition-normal), opacity var(--transition-normal);
}
&-enter-from {
transform: translateX(-8px);
opacity: 0;
}
&-leave-to {
transform: translateX(8px);
opacity: 0;
}
}
// Page route transition
.page {
&-enter-active {
transition: opacity var(--transition-normal), transform var(--transition-normal);
}
&-leave-active {
transition: opacity 100ms ease-in;
}
&-enter-from {
opacity: 0;
transform: translateY(6px);
}
&-leave-to {
opacity: 0;
}
}
// Scale-fade for dropdowns/menus
.dropdown {
&-enter-active {
transition: opacity var(--transition-fast), transform var(--transition-fast);
}
&-leave-active {
transition: opacity 100ms ease-in, transform 100ms ease-in;
}
&-enter-from {
opacity: 0;
transform: translateY(-4px) scale(0.97);
}
&-leave-to {
opacity: 0;
transform: translateY(-4px) scale(0.97);
}
}

View File

@@ -1,16 +1,92 @@
$scrollbar-color: #372a87;
// Design System Variables
// Light theme (default) + Dark theme (.dark class on html)
:root {
// --- Colors ---
--color-primary: #6C4AB6;
--color-primary-hover: #563A92;
--color-primary-light: #E8DEF8;
--color-accent: #F97316;
--color-success: #22C55E;
--color-danger: #EF4444;
--color-warning: #F59E0B;
--color-bg: #FAFAFA;
--color-surface: #FFFFFF;
--color-surface-hover: #F5F5F5;
--color-text: #1E293B;
--color-text-secondary: #64748B;
--color-text-muted: #94A3B8;
--color-border: #E2E8F0;
--color-border-hover: #CBD5E1;
// --- Shadows ---
--shadow-color: rgba(0, 0, 0, 0.08);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
// --- Radius ---
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
// --- Transitions ---
--transition-fast: 150ms ease-out;
--transition-normal: 200ms ease-out;
--transition-slow: 300ms ease-out;
// --- Scrollbar ---
--scrollbar-size: 6px;
--scrollbar-color: transparent;
--scrollbar-thumb-color: #{rgba($scrollbar-color, 0.5)};
--scrollbar-thumb-hover-color: #{rgba($scrollbar-color, 1)};
--scrollbar-thumb-color: rgba(108, 74, 182, 0.3);
--scrollbar-thumb-hover-color: rgba(108, 74, 182, 0.6);
// --- Layout ---
--layout-padding: 16px;
--layout-gap: 12px;
// --- Typography ---
--font-family: 'Inter', -apple-system, 'Noto Sans SC', 'Microsoft YaHei', system-ui, sans-serif;
@include forMobile {
--layout-padding: 10px;
--layout-padding: 12px;
--layout-gap: 10px;
}
}
// Dark theme
.dark {
--color-primary: #8B6CC7;
--color-primary-hover: #A78BDB;
--color-primary-light: #2D2250;
--color-accent: #FB923C;
--color-success: #4ADE80;
--color-danger: #F87171;
--color-warning: #FBBF24;
--color-bg: #0F172A;
--color-surface: #1E293B;
--color-surface-hover: #334155;
--color-text: #F1F5F9;
--color-text-secondary: #94A3B8;
--color-text-muted: #64748B;
--color-border: #334155;
--color-border-hover: #475569;
// --- Shadows (darker for dark mode) ---
--shadow-color: rgba(0, 0, 0, 0.3);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
// --- Scrollbar ---
--scrollbar-thumb-color: rgba(139, 108, 199, 0.3);
--scrollbar-thumb-hover-color: rgba(139, 108, 199, 0.6);
}

View File

@@ -15,56 +15,33 @@ declare global {
const apiRSS: typeof import('../../src/api/rss')['apiRSS']
const apiSearch: typeof import('../../src/api/search')['apiSearch']
const assert: typeof import('vitest')['assert']
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
const axios: typeof import('../../src/utils/axios')['axios']
const beforeAll: typeof import('vitest')['beforeAll']
const beforeEach: typeof import('vitest')['beforeEach']
const chai: typeof import('vitest')['chai']
const computed: typeof import('vue')['computed']
const computedAsync: typeof import('@vueuse/core')['computedAsync']
const computedEager: typeof import('@vueuse/core')['computedEager']
const computedInject: typeof import('@vueuse/core')['computedInject']
const computedWithControl: typeof import('@vueuse/core')['computedWithControl']
const controlledComputed: typeof import('@vueuse/core')['controlledComputed']
const controlledRef: typeof import('@vueuse/core')['controlledRef']
const createApp: typeof import('vue')['createApp']
const createEventHook: typeof import('@vueuse/core')['createEventHook']
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
const createPinia: typeof import('pinia')['createPinia']
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
const customRef: typeof import('vue')['customRef']
const debouncedRef: typeof import('@vueuse/core')['debouncedRef']
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineLoader: typeof import('vue-router/auto')['defineLoader']
const definePage: typeof import('unplugin-vue-router/runtime')['_definePage']
const defineStore: typeof import('pinia')['defineStore']
const describe: typeof import('vitest')['describe']
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
const effectScope: typeof import('vue')['effectScope']
const expect: typeof import('vitest')['expect']
const extendRef: typeof import('@vueuse/core')['extendRef']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
const inject: typeof import('vue')['inject']
const isDefined: typeof import('@vueuse/core')['isDefined']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const it: typeof import('vitest')['it']
const logicAnd: typeof import('@vueuse/core')['logicAnd']
const logicNot: typeof import('@vueuse/core')['logicNot']
const logicOr: typeof import('@vueuse/core')['logicOr']
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
@@ -78,37 +55,20 @@ declare global {
const onBeforeRouteUpdate: typeof import('vue-router/auto')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
const onLongPress: typeof import('@vueuse/core')['onLongPress']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onStartTyping: typeof import('@vueuse/core')['onStartTyping']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
const provide: typeof import('vue')['provide']
const reactify: typeof import('@vueuse/core')['reactify']
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
const reactive: typeof import('vue')['reactive']
const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed']
const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit']
const reactivePick: typeof import('@vueuse/core')['reactivePick']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
const refDebounced: typeof import('@vueuse/core')['refDebounced']
const refDefault: typeof import('@vueuse/core')['refDefault']
const refThrottled: typeof import('@vueuse/core')['refThrottled']
const refWithControl: typeof import('@vueuse/core')['refWithControl']
const resolveComponent: typeof import('vue')['resolveComponent']
const resolveRef: typeof import('@vueuse/core')['resolveRef']
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
@@ -116,188 +76,42 @@ declare global {
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const suite: typeof import('vitest')['suite']
const syncRef: typeof import('@vueuse/core')['syncRef']
const syncRefs: typeof import('@vueuse/core')['syncRefs']
const templateRef: typeof import('@vueuse/core')['templateRef']
const test: typeof import('vitest')['test']
const throttledRef: typeof import('@vueuse/core')['throttledRef']
const throttledWatch: typeof import('@vueuse/core')['throttledWatch']
const toRaw: typeof import('vue')['toRaw']
const toReactive: typeof import('@vueuse/core')['toReactive']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const triggerRef: typeof import('vue')['triggerRef']
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted']
const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose']
const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted']
const unref: typeof import('vue')['unref']
const unrefElement: typeof import('@vueuse/core')['unrefElement']
const until: typeof import('@vueuse/core')['until']
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
const useApi: typeof import('../../src/hooks/useApi')['useApi']
const useAppInfo: typeof import('../../src/hooks/useAppInfo')['useAppInfo']
const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue']
const useAsyncState: typeof import('@vueuse/core')['useAsyncState']
const useAttrs: typeof import('vue')['useAttrs']
const useAuth: typeof import('../../src/hooks/useAuth')['useAuth']
const useBangumiStore: typeof import('../../src/store/bangumi')['useBangumiStore']
const useBase64: typeof import('@vueuse/core')['useBase64']
const useBattery: typeof import('@vueuse/core')['useBattery']
const useBluetooth: typeof import('@vueuse/core')['useBluetooth']
const useBreakpointQuery: typeof import('../../src/hooks/useBreakpointQuery')['useBreakpointQuery']
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel']
const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation']
const useCached: typeof import('@vueuse/core')['useCached']
const useClamp: typeof import('@vueuse/core')['useClamp']
const useClipboard: typeof import('@vueuse/core')['useClipboard']
const useColorMode: typeof import('@vueuse/core')['useColorMode']
const useConfigStore: typeof import('../../src/store/config')['useConfigStore']
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
const useCounter: typeof import('@vueuse/core')['useCounter']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVar: typeof import('@vueuse/core')['useCssVar']
const useCssVars: typeof import('vue')['useCssVars']
const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement']
const useCycleList: typeof import('@vueuse/core')['useCycleList']
const useDark: typeof import('@vueuse/core')['useDark']
const useDateFormat: typeof import('@vueuse/core')['useDateFormat']
const useDebounce: typeof import('@vueuse/core')['useDebounce']
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
const useDevicesList: typeof import('@vueuse/core')['useDevicesList']
const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']
const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']
const useDraggable: typeof import('@vueuse/core')['useDraggable']
const useDropZone: typeof import('@vueuse/core')['useDropZone']
const useElementBounding: typeof import('@vueuse/core')['useElementBounding']
const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint']
const useElementHover: typeof import('@vueuse/core')['useElementHover']
const useElementSize: typeof import('@vueuse/core')['useElementSize']
const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility']
const useEventBus: typeof import('@vueuse/core')['useEventBus']
const useEventListener: typeof import('@vueuse/core')['useEventListener']
const useEventSource: typeof import('@vueuse/core')['useEventSource']
const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper']
const useFavicon: typeof import('@vueuse/core')['useFavicon']
const useFetch: typeof import('@vueuse/core')['useFetch']
const useFileDialog: typeof import('@vueuse/core')['useFileDialog']
const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess']
const useFocus: typeof import('@vueuse/core')['useFocus']
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
const useFps: typeof import('@vueuse/core')['useFps']
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
const useGamepad: typeof import('@vueuse/core')['useGamepad']
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useDarkMode: typeof import('../../src/hooks/useDarkMode')['useDarkMode']
const useI18n: typeof import('vue-i18n')['useI18n']
const useIdle: typeof import('@vueuse/core')['useIdle']
const useImage: typeof import('@vueuse/core')['useImage']
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver']
const useInterval: typeof import('@vueuse/core')['useInterval']
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
const useLogStore: typeof import('../../src/store/log')['useLogStore']
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
const useMemoize: typeof import('@vueuse/core')['useMemoize']
const useMemory: typeof import('@vueuse/core')['useMemory']
const useMessage: typeof import('../../src/hooks/useMessage')['useMessage']
const useMounted: typeof import('@vueuse/core')['useMounted']
const useMouse: typeof import('@vueuse/core')['useMouse']
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
const useMousePressed: typeof import('@vueuse/core')['useMousePressed']
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
const useMyI18n: typeof import('../../src/hooks/useMyI18n')['useMyI18n']
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
const useNetwork: typeof import('@vueuse/core')['useNetwork']
const useNow: typeof import('@vueuse/core')['useNow']
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
const useOnline: typeof import('@vueuse/core')['useOnline']
const usePageLeave: typeof import('@vueuse/core')['usePageLeave']
const useParallax: typeof import('@vueuse/core')['useParallax']
const usePermission: typeof import('@vueuse/core')['usePermission']
const usePlayerStore: typeof import('../../src/store/player')['usePlayerStore']
const usePointer: typeof import('@vueuse/core')['usePointer']
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme']
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
const useProgramStore: typeof import('../../src/store/program')['useProgramStore']
const useRSSStore: typeof import('../../src/store/rss')['useRSSStore']
const useRafFn: typeof import('@vueuse/core')['useRafFn']
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
const useRoute: typeof import('vue-router/auto')['useRoute']
const useRouter: typeof import('vue-router/auto')['useRouter']
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
const useScroll: typeof import('@vueuse/core')['useScroll']
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
const useSearchStore: typeof import('../../src/store/search')['useSearchStore']
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
const useShare: typeof import('@vueuse/core')['useShare']
const useSlots: typeof import('vue')['useSlots']
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
const useStepper: typeof import('@vueuse/core')['useStepper']
const useStorage: typeof import('@vueuse/core')['useStorage']
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
const useSwipe: typeof import('@vueuse/core')['useSwipe']
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize']
const useThrottle: typeof import('@vueuse/core')['useThrottle']
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
const useTimeout: typeof import('@vueuse/core')['useTimeout']
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
const useTimestamp: typeof import('@vueuse/core')['useTimestamp']
const useTitle: typeof import('@vueuse/core')['useTitle']
const useToggle: typeof import('@vueuse/core')['useToggle']
const useTransition: typeof import('@vueuse/core')['useTransition']
const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams']
const useUserMedia: typeof import('@vueuse/core')['useUserMedia']
const useVModel: typeof import('@vueuse/core')['useVModel']
const useVModels: typeof import('@vueuse/core')['useVModels']
const useVibrate: typeof import('@vueuse/core')['useVibrate']
const useVirtualList: typeof import('@vueuse/core')['useVirtualList']
const useWakeLock: typeof import('@vueuse/core')['useWakeLock']
const useWebNotification: typeof import('@vueuse/core')['useWebNotification']
const useWebSocket: typeof import('@vueuse/core')['useWebSocket']
const useWebWorker: typeof import('@vueuse/core')['useWebWorker']
const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn']
const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus']
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
const vi: typeof import('vitest')['vi']
const vitest: typeof import('vitest')['vitest']
const watch: typeof import('vue')['watch']
const watchArray: typeof import('@vueuse/core')['watchArray']
const watchAtMost: typeof import('@vueuse/core')['watchAtMost']
const watchDebounced: typeof import('@vueuse/core')['watchDebounced']
const watchEffect: typeof import('vue')['watchEffect']
const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable']
const watchOnce: typeof import('@vueuse/core')['watchOnce']
const watchPausable: typeof import('@vueuse/core')['watchPausable']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
const watchThrottled: typeof import('@vueuse/core')['watchThrottled']
const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable']
const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter']
const whenever: typeof import('@vueuse/core')['whenever']
}

View File

@@ -8,7 +8,9 @@ import presetRemToPx from '@unocss/preset-rem-to-px';
export default defineConfig({
presets: [
presetUno(),
presetUno({
dark: 'class',
}),
presetRemToPx({
baseFontSize: 4,
}),
@@ -32,38 +34,58 @@ export default defineConfig({
pc: '1024px',
},
colors: {
primary: '#493475',
running: '#A3D491',
stopped: '#DF7F91',
page: '#F0F0F0',
// Semantic colors via CSS variables (support light/dark)
primary: 'var(--color-primary)',
'primary-hover': 'var(--color-primary-hover)',
'primary-light': 'var(--color-primary-light)',
accent: 'var(--color-accent)',
success: 'var(--color-success)',
danger: 'var(--color-danger)',
warning: 'var(--color-warning)',
surface: 'var(--color-surface)',
'surface-hover': 'var(--color-surface-hover)',
'text-primary': 'var(--color-text)',
'text-secondary': 'var(--color-text-secondary)',
'text-muted': 'var(--color-text-muted)',
border: 'var(--color-border)',
'border-hover': 'var(--color-border-hover)',
page: 'var(--color-bg)',
// Legacy aliases (for gradual migration)
running: 'var(--color-success)',
stopped: 'var(--color-danger)',
},
},
rules: [
[
'bg-theme-row',
{
background: 'linear-gradient(90.5deg, #492897 1.53%, #783674 96.48%)',
background: 'linear-gradient(90.5deg, var(--color-primary) 1.53%, var(--color-primary-hover) 96.48%)',
},
],
[
'bg-theme-col',
{
background: 'linear-gradient(180deg, #492897 0%, #783674 100%)',
background: 'linear-gradient(180deg, var(--color-primary) 0%, var(--color-primary-hover) 100%)',
},
],
[
'poster-shandow',
{
filter: 'drop-shadow(2px 2px 2px rgba(0, 0, 0, 0.1))',
filter: 'drop-shadow(2px 2px 2px var(--shadow-color, rgba(0, 0, 0, 0.1)))',
},
],
[
'poster-pen-active',
{
background: '#B4ABC6',
'box-shadow': '2px 2px 4px rgba(0, 0, 0, 0.25)',
background: 'var(--color-primary-light)',
'box-shadow': '2px 2px 4px var(--shadow-color, rgba(0, 0, 0, 0.25))',
},
],
// Shadows
['shadow-sm', { 'box-shadow': 'var(--shadow-sm)' }],
['shadow-md', { 'box-shadow': 'var(--shadow-md)' }],
['shadow-lg', { 'box-shadow': 'var(--shadow-lg)' }],
],
shortcuts: [
[/^wh-(.*)$/, ([, t]) => `w-${t} h-${t}`],
@@ -87,17 +109,24 @@ export default defineConfig({
'text-h2': 'text-20',
'text-h3': 'text-16',
'text-main': 'text-12',
'text-body': 'text-14',
'text-sm': 'text-12',
'text-xs': 'text-10',
},
// input
{
'ab-input': `outline-none min-w-0 w-200 h-28
px-12 text-main text-right
rounded-6 shadow-inset
border-1 border-black hover:border-color-[#7A46AE]
rounded-6
border-1 border-border
bg-surface text-text-primary
hover:border-primary
focus:border-primary focus:ring-2 focus:ring-primary/20
transition-colors duration-150
`,
'input-error': 'border-color-[#CA0E0E]',
'input-error': 'border-danger',
'input-reset': 'bg-transparent min-w-0 flex-1 outline-none',
},
@@ -105,12 +134,12 @@ export default defineConfig({
{
'is-btn': 'cursor-pointer select-none',
'btn-click': 'hover:scale-110 active:scale-100',
'is-disabled': 'cursor-not-allowed select-none',
'is-disabled': 'cursor-not-allowed select-none opacity-50',
},
// other
{
line: 'w-full h-1 bg-[#DFE1EF]',
line: 'w-full h-1 bg-border',
},
],
});

View File

@@ -29,7 +29,16 @@ export default defineConfig(({ mode }) => ({
'vue',
'vitest',
'pinia',
'@vueuse/core',
{
'@vueuse/core': [
'createSharedComposable',
'useBreakpoints',
'usePreferredDark',
'useClipboard',
'useLocalStorage',
'useIntervalFn',
],
},
VueRouterAutoImports,
'vue-i18n',
],
@@ -107,8 +116,8 @@ export default defineConfig(({ mode }) => ({
},
server: {
proxy: {
'^/api/.*': 'http://192.168.0.100:7892',
'^/posters/.*': 'http://192.168.0.100:7892',
'^/api/.*': 'http://localhost:7892',
'^/posters/.*': 'http://localhost:7892',
},
},
}));