20260814173126
This commit is contained in:
1 parent
68de86aee9
commit
b6f9a4c912
17 files changed
+1210
-544
No files matched your search
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import Button from './components/ui/Button.vue'
|
||||
import Button from './components/ui/button/button.vue'
|
||||
import ColorThemePicker from './components/ui/ColorThemePicker.vue'
|
||||
import ThemeToggle from './components/ui/ThemeToggle.vue'
|
||||
</script>
|
||||
@@ -25,14 +25,83 @@ import ThemeToggle from './components/ui/ThemeToggle.vue'
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<Button>主要操作</Button>
|
||||
<Button variant="secondary">次要操作</Button>
|
||||
<Button variant="outline">描边按钮</Button>
|
||||
<Button variant="ghost">低强调</Button>
|
||||
<Button variant="destructive">危险操作</Button>
|
||||
<Button type="default">主要操作</Button>
|
||||
<Button type="secondary">次要操作</Button>
|
||||
<Button type="outline">描边按钮</Button>
|
||||
<Button type="ghost">低强调</Button>
|
||||
<Button type="destructive">危险操作</Button>
|
||||
<Button type="link">链接按钮</Button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.app-shell {
|
||||
width: min(100% - 2rem, 72rem);
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 2rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.theme-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.375rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.showcase-section {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
.section-heading > p:last-child {
|
||||
margin: 0.75rem 0 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
padding: 1.25rem 0;
|
||||
}
|
||||
|
||||
.theme-controls {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.showcase-section {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,26 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'button',
|
||||
},
|
||||
})
|
||||
|
||||
const classes = computed(() => ['button', `button-${props.variant}`, `button-size-${props.size}`])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :type="type" :class="classes">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,24 +1,133 @@
|
||||
<script setup>
|
||||
import { useTheme } from '../../composables/useTheme'
|
||||
import { useTheme } from '@/theme/useTheme'
|
||||
import { PRESETS } from '@/theme/color'
|
||||
|
||||
const { colorTheme, colorThemes, setColorTheme } = useTheme()
|
||||
const { themeColor, setThemeColor } = useTheme()
|
||||
|
||||
function handleColorInput(event) {
|
||||
setThemeColor(event.target.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="color-theme-picker" role="group" aria-label="主题色">
|
||||
<button
|
||||
v-for="item in colorThemes"
|
||||
:key="item.value"
|
||||
class="color-swatch"
|
||||
type="button"
|
||||
:class="{ 'color-swatch-active': colorTheme === item.value }"
|
||||
:style="{ '--swatch-color': item.color }"
|
||||
:aria-label="`${item.label}主题色`"
|
||||
:aria-pressed="colorTheme === item.value"
|
||||
:title="item.label"
|
||||
@click="setColorTheme(item.value)"
|
||||
>
|
||||
<span class="color-swatch-dot" aria-hidden="true" />
|
||||
</button>
|
||||
<div class="color-theme-picker">
|
||||
<span class="color-theme-label">主题色</span>
|
||||
<div class="theme-presets" aria-label="常用主题色">
|
||||
<button
|
||||
v-for="preset in PRESETS"
|
||||
:key="preset.value"
|
||||
class="theme-swatch"
|
||||
:class="{ 'is-selected': themeColor === preset.value }"
|
||||
type="button"
|
||||
:style="{ '--swatch-color': preset.value }"
|
||||
:aria-label="preset.name"
|
||||
:aria-pressed="themeColor === preset.value"
|
||||
:title="`${preset.name} ${preset.value}`"
|
||||
@click="setThemeColor(preset.value)"
|
||||
/>
|
||||
</div>
|
||||
<label class="custom-color" title="自定义主题色">
|
||||
<input
|
||||
class="color-theme-input"
|
||||
type="color"
|
||||
:value="themeColor"
|
||||
aria-label="自定义主题色"
|
||||
@input="handleColorInput"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.color-theme-picker {
|
||||
display: inline-flex;
|
||||
height: 2.5rem;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.375rem 0.25rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
color: var(--card-foreground);
|
||||
}
|
||||
|
||||
.theme-presets {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.theme-swatch {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex: 0 0 auto;
|
||||
padding: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 18%, transparent);
|
||||
border-radius: 50%;
|
||||
background: var(--swatch-color);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition:
|
||||
transform 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.theme-swatch:hover {
|
||||
transform: scale(1.12);
|
||||
}
|
||||
|
||||
.theme-swatch.is-selected {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--card),
|
||||
0 0 0 4px var(--ring);
|
||||
}
|
||||
|
||||
.theme-swatch:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--card),
|
||||
0 0 0 4px var(--ring);
|
||||
}
|
||||
|
||||
.custom-color {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.color-theme-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.color-theme-input {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.25rem);
|
||||
background: var(--background);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.color-theme-input::-webkit-color-swatch-wrapper {
|
||||
padding: 0.1875rem;
|
||||
}
|
||||
|
||||
.color-theme-input::-webkit-color-swatch {
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius) - 0.375rem);
|
||||
}
|
||||
|
||||
.color-theme-input::-moz-color-swatch {
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius) - 0.375rem);
|
||||
}
|
||||
|
||||
.color-theme-input:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--background),
|
||||
0 0 0 4px color-mix(in srgb, var(--ring) 50%, transparent);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import Button from './Button.vue'
|
||||
import { useTheme } from '../../composables/useTheme'
|
||||
import Button from './button/button.vue'
|
||||
import { useTheme } from '@/theme/useTheme'
|
||||
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
})
|
||||
|
||||
const classes = computed(() => ['button', `button-${props.type}`])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :class="classes">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use './index.scss';
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
height: 2.5rem;
|
||||
padding: 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
transition:
|
||||
color 120ms ease,
|
||||
background-color 120ms ease,
|
||||
border-color 120ms ease,
|
||||
transform 80ms ease;
|
||||
}
|
||||
|
||||
.button:active:not(:disabled) {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
|
||||
.button-default {
|
||||
color: var(--primary-foreground);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.button-default:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.button-default:active {
|
||||
background: var(--primary-active);
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
color: var(--secondary-foreground);
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.button-secondary:hover,
|
||||
.button-ghost:hover {
|
||||
background: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.button-secondary:active,
|
||||
.button-ghost:active {
|
||||
background: color-mix(in srgb, var(--accent) 88%, var(--foreground));
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.button-outline {
|
||||
color: var(--foreground);
|
||||
border-color: var(--input);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.button-outline:hover {
|
||||
color: var(--accent-foreground);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.button-outline:active {
|
||||
color: var(--accent-foreground);
|
||||
background: color-mix(in srgb, var(--accent) 88%, var(--foreground));
|
||||
}
|
||||
|
||||
.button-ghost {
|
||||
color: var(--foreground);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.button-destructive {
|
||||
color: var(--destructive-foreground);
|
||||
background: var(--destructive);
|
||||
}
|
||||
|
||||
.button-destructive:hover {
|
||||
background: color-mix(in srgb, var(--destructive) 90%, var(--background));
|
||||
}
|
||||
|
||||
.button-destructive:active {
|
||||
background: color-mix(in srgb, var(--destructive) 80%, var(--background));
|
||||
}
|
||||
|
||||
.button-link {
|
||||
color: var(--primary);
|
||||
background: transparent;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.button-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.button-link:active {
|
||||
color: var(--primary-active);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const MODE_STORAGE_KEY = 'fms-theme'
|
||||
const COLOR_STORAGE_KEY = 'fms-color-theme'
|
||||
|
||||
export const colorThemes = Object.freeze([
|
||||
{ value: 'neutral', label: '中性', color: 'oklch(0.205 0 0)' },
|
||||
{ value: 'blue', label: '蓝色', color: 'oklch(0.546 0.245 262.881)' },
|
||||
{ value: 'green', label: '绿色', color: 'oklch(0.527 0.154 150.069)' },
|
||||
{ value: 'violet', label: '紫色', color: 'oklch(0.496 0.265 301.924)' },
|
||||
{ value: 'orange', label: '橙色', color: 'oklch(0.57 0.2 49.5)' },
|
||||
{ value: 'red', label: '红色', color: 'oklch(0.506 0.232 25.7)' },
|
||||
{ value: 'pink', label: '粉色', color: 'oklch(0.523 0.214 349.4)' },
|
||||
{ value: 'teal', label: '青色', color: 'oklch(0.563 0.144 180)' },
|
||||
])
|
||||
|
||||
const theme = ref('light')
|
||||
const colorTheme = ref('neutral')
|
||||
let initialized = false
|
||||
|
||||
function isColorTheme(value) {
|
||||
return colorThemes.some((item) => item.value === value)
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
document.documentElement.dataset.theme = theme.value
|
||||
document.documentElement.dataset.colorTheme = colorTheme.value
|
||||
document.documentElement.style.colorScheme = theme.value
|
||||
}
|
||||
|
||||
function readStorage(key) {
|
||||
try {
|
||||
return window.localStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(key, value) {
|
||||
try {
|
||||
window.localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// The selected theme still applies when storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function initializeTheme() {
|
||||
if (initialized || typeof window === 'undefined') return
|
||||
|
||||
initialized = true
|
||||
const storedTheme = readStorage(MODE_STORAGE_KEY)
|
||||
const storedColorTheme = readStorage(COLOR_STORAGE_KEY)
|
||||
|
||||
theme.value = storedTheme === 'dark' ? 'dark' : 'light'
|
||||
colorTheme.value = isColorTheme(storedColorTheme) ? storedColorTheme : 'neutral'
|
||||
applyTheme()
|
||||
}
|
||||
|
||||
function setTheme(value) {
|
||||
theme.value = value === 'dark' ? 'dark' : 'light'
|
||||
applyTheme()
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
writeStorage(MODE_STORAGE_KEY, theme.value)
|
||||
}
|
||||
}
|
||||
|
||||
function setColorTheme(value) {
|
||||
colorTheme.value = isColorTheme(value) ? value : 'neutral'
|
||||
applyTheme()
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
writeStorage(COLOR_STORAGE_KEY, colorTheme.value)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
setTheme(theme.value === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
initializeTheme()
|
||||
|
||||
export function useTheme() {
|
||||
initializeTheme()
|
||||
|
||||
return {
|
||||
theme,
|
||||
colorTheme,
|
||||
colorThemes,
|
||||
setTheme,
|
||||
setColorTheme,
|
||||
toggleTheme,
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { createApp } from 'vue'
|
||||
import '@fontsource-variable/noto-sans-sc/wght.css'
|
||||
import './style.css'
|
||||
import './styles/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import 'stk-table-vue/lib/style.css'
|
||||
import { initTheme } from './theme/useTheme'
|
||||
|
||||
initTheme()
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
@@ -1,384 +0,0 @@
|
||||
:root {
|
||||
--font-sans: 'Noto Sans SC Variable', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--radius: 0.625rem;
|
||||
|
||||
font-family: var(--font-sans);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
line-height: 1.5;
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
:root[data-color-theme='blue'] {
|
||||
--primary: oklch(0.546 0.245 262.881);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--ring: oklch(0.546 0.245 262.881);
|
||||
}
|
||||
|
||||
:root[data-color-theme='green'] {
|
||||
--primary: oklch(0.527 0.154 150.069);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--ring: oklch(0.527 0.154 150.069);
|
||||
}
|
||||
|
||||
:root[data-color-theme='violet'] {
|
||||
--primary: oklch(0.496 0.265 301.924);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--ring: oklch(0.496 0.265 301.924);
|
||||
}
|
||||
|
||||
:root[data-color-theme='orange'] {
|
||||
--primary: oklch(0.57 0.2 49.5);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--ring: oklch(0.57 0.2 49.5);
|
||||
}
|
||||
|
||||
:root[data-color-theme='red'] {
|
||||
--primary: oklch(0.506 0.232 25.7);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--ring: oklch(0.506 0.232 25.7);
|
||||
}
|
||||
|
||||
:root[data-color-theme='pink'] {
|
||||
--primary: oklch(0.523 0.214 349.4);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--ring: oklch(0.523 0.214 349.4);
|
||||
}
|
||||
|
||||
:root[data-color-theme='teal'] {
|
||||
--primary: oklch(0.563 0.144 180);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--ring: oklch(0.563 0.144 180);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'][data-color-theme='blue'] {
|
||||
--primary: oklch(0.623 0.214 259.815);
|
||||
--ring: oklch(0.623 0.214 259.815);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'][data-color-theme='green'] {
|
||||
--primary: oklch(0.596 0.145 163.225);
|
||||
--ring: oklch(0.596 0.145 163.225);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'][data-color-theme='violet'] {
|
||||
--primary: oklch(0.558 0.238 301.358);
|
||||
--ring: oklch(0.558 0.238 301.358);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'][data-color-theme='orange'] {
|
||||
--primary: oklch(0.643 0.215 41.5);
|
||||
--ring: oklch(0.643 0.215 41.5);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'][data-color-theme='red'] {
|
||||
--primary: oklch(0.577 0.245 27.325);
|
||||
--ring: oklch(0.577 0.245 27.325);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'][data-color-theme='pink'] {
|
||||
--primary: oklch(0.592 0.225 349.5);
|
||||
--ring: oklch(0.592 0.225 349.5);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'][data-color-theme='teal'] {
|
||||
--primary: oklch(0.627 0.124 180);
|
||||
--ring: oklch(0.627 0.124 180);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
min-width: 320px;
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
transition:
|
||||
color 160ms ease,
|
||||
background-color 160ms ease;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
outline: none;
|
||||
transition:
|
||||
color 120ms ease,
|
||||
background-color 120ms ease,
|
||||
border-color 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.button:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--background),
|
||||
0 0 0 4px color-mix(in oklch, var(--ring) 50%, transparent);
|
||||
}
|
||||
|
||||
.button-default {
|
||||
color: var(--primary-foreground);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.button-default:hover {
|
||||
background: color-mix(in oklch, var(--primary) 90%, var(--background));
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
color: var(--secondary-foreground);
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.button-secondary:hover,
|
||||
.button-ghost:hover {
|
||||
background: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.button-outline {
|
||||
color: var(--foreground);
|
||||
border-color: var(--input);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.button-outline:hover {
|
||||
color: var(--accent-foreground);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.button-ghost {
|
||||
color: var(--foreground);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.button-destructive {
|
||||
color: var(--destructive-foreground);
|
||||
background: var(--destructive);
|
||||
}
|
||||
|
||||
.button-destructive:hover {
|
||||
background: color-mix(in oklch, var(--destructive) 90%, var(--background));
|
||||
}
|
||||
|
||||
.button-link {
|
||||
color: var(--primary);
|
||||
background: transparent;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.button-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.button-size-sm {
|
||||
height: 2.25rem;
|
||||
padding: 0 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.button-size-default {
|
||||
height: 2.5rem;
|
||||
padding: 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.button-size-lg {
|
||||
height: 2.75rem;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.button-size-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(100% - 2rem, 72rem);
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 2rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.theme-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.color-theme-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1.75rem);
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
display: inline-flex;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius) - 0.25rem);
|
||||
background: transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.color-swatch:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.color-swatch:focus-visible,
|
||||
.color-swatch-active {
|
||||
box-shadow:
|
||||
inset 0 0 0 2px var(--background),
|
||||
inset 0 0 0 3px var(--ring);
|
||||
}
|
||||
|
||||
.color-swatch-dot {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
background: var(--swatch-color);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.375rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.5rem, 4vw, 2rem);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.showcase-section {
|
||||
padding: 3rem 0;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
.section-heading > p:last-child {
|
||||
margin: 0.75rem 0 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
padding: 1.25rem 0;
|
||||
}
|
||||
|
||||
.theme-controls {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.showcase-section {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
:root {
|
||||
--font-sans: 'Noto Sans SC Variable', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
|
||||
--background: #ffffff;
|
||||
--foreground: #09090b;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #09090b;
|
||||
/* 主色系列(--primary / --primary-hover / --primary-active / --primary-foreground / --primary-border / --ring)
|
||||
由 theme/useTheme.js 在运行时根据主题色计算并写入 :root.style,此处不重复定义以避免双真相源。 */
|
||||
--secondary: #f4f4f5;
|
||||
--secondary-foreground: #18181b;
|
||||
--muted-foreground: #71717a;
|
||||
--accent: #f4f4f5;
|
||||
--accent-foreground: #18181b;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #fafafa;
|
||||
--border: #e4e4e7;
|
||||
--input: #e4e4e7;
|
||||
--radius: 0.625rem;
|
||||
|
||||
font-family: var(--font-sans);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
line-height: 1.5;
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--background: #09090b;
|
||||
--foreground: #fafafa;
|
||||
--card: #18181b;
|
||||
--card-foreground: #fafafa;
|
||||
--secondary: #27272a;
|
||||
--secondary-foreground: #fafafa;
|
||||
--muted-foreground: #a1a1aa;
|
||||
--accent: #27272a;
|
||||
--accent-foreground: #fafafa;
|
||||
--destructive: #7f1d1d;
|
||||
--destructive-foreground: #fafafa;
|
||||
--border: #ffffff1a;
|
||||
--input: #ffffff26;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
min-width: 320px;
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
transition:
|
||||
color 160ms ease,
|
||||
background-color 160ms ease;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.5rem, 4vw, 2rem);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// 约定:对外一律 hex('#rrggbb'),内部算法用 RGB 数组 [r, g, b]。
|
||||
|
||||
// ---------- 配置 ----------
|
||||
export const STORAGE_KEYS = {
|
||||
mode: 'fms-theme',
|
||||
color: 'fms-theme-color',
|
||||
}
|
||||
|
||||
export const DEFAULT_MODE = 'light'
|
||||
export const DEFAULT_COLOR = '#18181b'
|
||||
|
||||
export const PRESETS = [
|
||||
{ name: 'Shadcn 中性', value: '#18181b' },
|
||||
{ name: '蓝色', value: '#2563eb' },
|
||||
{ name: '靛蓝', value: '#4f46e5' },
|
||||
{ name: '紫色', value: '#7c3aed' },
|
||||
{ name: '青色', value: '#0d9488' },
|
||||
{ name: '绿色', value: '#16a34a' },
|
||||
{ name: '橙色', value: '#ea580c' },
|
||||
{ name: '红色', value: '#dc2626' },
|
||||
{ name: '粉色', value: '#db2777' },
|
||||
]
|
||||
|
||||
export const ALGORITHM = {
|
||||
minContrast: 3, // 主色与背景最小对比度
|
||||
hoverDelta: 0.06, // hover 明度偏移
|
||||
activeDelta: 0.14, // active 明度偏移
|
||||
borderMixRatio: 0.55, // 边框与背景混合比例
|
||||
}
|
||||
|
||||
// 背景参照色:用于主色对比度兜底(浅色=纯白、深色=zinc-900,与原始实现保持一致)。
|
||||
export const SURFACES = {
|
||||
light: '#ffffff',
|
||||
dark: '#18181b',
|
||||
}
|
||||
|
||||
// 主色上文字的前景候选(白 / 近黑),取对比度更大者。
|
||||
export const FOREGROUND_CANDIDATES = ['#ffffff', '#18181b']
|
||||
|
||||
// ---------- 颜色数学 ----------
|
||||
export function normalizeHex(value) {
|
||||
const color = String(value ?? '').trim().toLowerCase()
|
||||
|
||||
if (/^#[0-9a-f]{6}$/.test(color)) return color
|
||||
if (/^#[0-9a-f]{3}$/.test(color)) {
|
||||
const [red, green, blue] = color.slice(1)
|
||||
return `#${red}${red}${green}${green}${blue}${blue}`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function hexToRgb(hex) {
|
||||
return [
|
||||
Number.parseInt(hex.slice(1, 3), 16),
|
||||
Number.parseInt(hex.slice(3, 5), 16),
|
||||
Number.parseInt(hex.slice(5, 7), 16),
|
||||
]
|
||||
}
|
||||
|
||||
export function rgbToHex([red, green, blue]) {
|
||||
return `#${[red, green, blue]
|
||||
.map((channel) => Math.round(channel).toString(16).padStart(2, '0'))
|
||||
.join('')}`
|
||||
}
|
||||
|
||||
export function rgbToHsl([red, green, blue]) {
|
||||
const r = red / 255
|
||||
const g = green / 255
|
||||
const b = blue / 255
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
const lightness = (max + min) / 2
|
||||
|
||||
if (max === min) return { h: 0, s: 0, l: lightness }
|
||||
|
||||
const delta = max - min
|
||||
const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min)
|
||||
let hue
|
||||
|
||||
if (max === r) hue = (g - b) / delta + (g < b ? 6 : 0)
|
||||
else if (max === g) hue = (b - r) / delta + 2
|
||||
else hue = (r - g) / delta + 4
|
||||
|
||||
return { h: hue / 6, s: saturation, l: lightness }
|
||||
}
|
||||
|
||||
function hueToRgb(p, q, t) {
|
||||
let hue = t
|
||||
if (hue < 0) hue += 1
|
||||
if (hue > 1) hue -= 1
|
||||
if (hue < 1 / 6) return p + (q - p) * 6 * hue
|
||||
if (hue < 1 / 2) return q
|
||||
if (hue < 2 / 3) return p + (q - p) * (2 / 3 - hue) * 6
|
||||
return p
|
||||
}
|
||||
|
||||
export function hslToRgb({ h, s, l }) {
|
||||
if (s === 0) {
|
||||
const value = Math.round(l * 255)
|
||||
return [value, value, value]
|
||||
}
|
||||
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||
const p = 2 * l - q
|
||||
|
||||
return [
|
||||
Math.round(hueToRgb(p, q, h + 1 / 3) * 255),
|
||||
Math.round(hueToRgb(p, q, h) * 255),
|
||||
Math.round(hueToRgb(p, q, h - 1 / 3) * 255),
|
||||
]
|
||||
}
|
||||
|
||||
export function adjustLightness(rgb, delta) {
|
||||
const hsl = rgbToHsl(rgb)
|
||||
return hslToRgb({ ...hsl, l: Math.min(1, Math.max(0, hsl.l + delta)) })
|
||||
}
|
||||
|
||||
export function mixRgb(firstRgb, secondRgb, secondRatio) {
|
||||
const firstRatio = 1 - secondRatio
|
||||
return firstRgb.map((channel, index) =>
|
||||
Math.round(channel * firstRatio + secondRgb[index] * secondRatio),
|
||||
)
|
||||
}
|
||||
|
||||
export function getRelativeLuminance(rgb) {
|
||||
const [red, green, blue] = rgb.map((channel) => {
|
||||
const value = channel / 255
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
|
||||
})
|
||||
|
||||
return red * 0.2126 + green * 0.7152 + blue * 0.0722
|
||||
}
|
||||
|
||||
export function getContrastRatio(firstRgb, secondRgb) {
|
||||
const firstLuminance = getRelativeLuminance(firstRgb)
|
||||
const secondLuminance = getRelativeLuminance(secondRgb)
|
||||
const lighter = Math.max(firstLuminance, secondLuminance)
|
||||
const darker = Math.min(firstLuminance, secondLuminance)
|
||||
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
}
|
||||
|
||||
// ---------- 主色派生 ----------
|
||||
function getForegroundRgb(themeRgb) {
|
||||
const [light, dark] = FOREGROUND_CANDIDATES.map(hexToRgb)
|
||||
const lightContrast = getContrastRatio(themeRgb, light)
|
||||
const darkContrast = getContrastRatio(themeRgb, dark)
|
||||
|
||||
return lightContrast >= darkContrast ? light : dark
|
||||
}
|
||||
|
||||
function ensurePrimaryContrast(rgb, backgroundRgb) {
|
||||
if (getContrastRatio(rgb, backgroundRgb) >= ALGORITHM.minContrast) return rgb
|
||||
|
||||
const sourceLightness = rgbToHsl(rgb).l
|
||||
|
||||
for (let step = 1; step <= 100; step += 1) {
|
||||
const delta = step / 100
|
||||
const candidates = [adjustLightness(rgb, -delta), adjustLightness(rgb, delta)]
|
||||
const candidate = candidates.find(
|
||||
(item) => getContrastRatio(item, backgroundRgb) >= ALGORITHM.minContrast,
|
||||
)
|
||||
|
||||
if (candidate) return candidate
|
||||
}
|
||||
|
||||
return sourceLightness >= 0.5 ? adjustLightness(rgb, -0.5) : adjustLightness(rgb, 0.5)
|
||||
}
|
||||
|
||||
export function createPrimaryTokens(color, mode) {
|
||||
const sourceRgb = hexToRgb(color)
|
||||
const backgroundRgb = hexToRgb(SURFACES[mode])
|
||||
const primaryRgb = ensurePrimaryContrast(sourceRgb, backgroundRgb)
|
||||
const primaryLightness = rgbToHsl(primaryRgb).l
|
||||
const direction =
|
||||
mode === 'dark' ? (primaryLightness > 0.85 ? -1 : 1) : primaryLightness < 0.15 ? 1 : -1
|
||||
const hoverRgb = ensurePrimaryContrast(
|
||||
adjustLightness(primaryRgb, direction * ALGORITHM.hoverDelta),
|
||||
backgroundRgb,
|
||||
)
|
||||
const activeRgb = ensurePrimaryContrast(
|
||||
adjustLightness(primaryRgb, direction * ALGORITHM.activeDelta),
|
||||
backgroundRgb,
|
||||
)
|
||||
const primaryBorderRgb = mixRgb(primaryRgb, backgroundRgb, ALGORITHM.borderMixRatio)
|
||||
|
||||
return {
|
||||
primary: rgbToHex(primaryRgb),
|
||||
'primary-foreground': rgbToHex(getForegroundRgb(primaryRgb)),
|
||||
'primary-hover': rgbToHex(hoverRgb),
|
||||
'primary-active': rgbToHex(activeRgb),
|
||||
'primary-border': rgbToHex(primaryBorderRgb),
|
||||
ring: rgbToHex(primaryRgb),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
createPrimaryTokens,
|
||||
DEFAULT_COLOR,
|
||||
DEFAULT_MODE,
|
||||
normalizeHex,
|
||||
STORAGE_KEYS,
|
||||
} from './color'
|
||||
|
||||
const theme = ref(DEFAULT_MODE)
|
||||
const themeColor = ref(DEFAULT_COLOR)
|
||||
|
||||
function applyTheme() {
|
||||
const root = document.documentElement
|
||||
const tokens = createPrimaryTokens(themeColor.value, theme.value)
|
||||
|
||||
root.dataset.theme = theme.value
|
||||
root.style.colorScheme = theme.value
|
||||
|
||||
for (const [name, value] of Object.entries(tokens)) {
|
||||
root.style.setProperty(`--${name}`, value)
|
||||
}
|
||||
}
|
||||
|
||||
function readStorage(key) {
|
||||
try {
|
||||
return window.localStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(key, value) {
|
||||
try {
|
||||
window.localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// storage 不可用时主题仍生效。
|
||||
}
|
||||
}
|
||||
|
||||
export function initTheme() {
|
||||
const storedMode = readStorage(STORAGE_KEYS.mode)
|
||||
const storedColor = normalizeHex(readStorage(STORAGE_KEYS.color))
|
||||
|
||||
theme.value = storedMode === 'dark' ? 'dark' : 'light'
|
||||
themeColor.value = storedColor ?? DEFAULT_COLOR
|
||||
applyTheme()
|
||||
}
|
||||
|
||||
function setTheme(value) {
|
||||
theme.value = value === 'dark' ? 'dark' : 'light'
|
||||
applyTheme()
|
||||
writeStorage(STORAGE_KEYS.mode, theme.value)
|
||||
}
|
||||
|
||||
function setThemeColor(value) {
|
||||
const color = normalizeHex(value)
|
||||
if (!color) return false
|
||||
|
||||
themeColor.value = color
|
||||
applyTheme()
|
||||
writeStorage(STORAGE_KEYS.color, color)
|
||||
return true
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
setTheme(theme.value === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return { theme, themeColor, setTheme, setThemeColor, toggleTheme }
|
||||
}
|
||||
Reference in new issue
Block a user