mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
Share account settings across import, single-account editing, and batch editing. Commit credentials and import settings in the same transaction. Improve modal actions and fix the flash at the end of light/dark theme transitions. Reauthenticate each execution against the current key policy so existing WebSocket connections apply updated RPM, concurrency limits, and enabled state. Add regression coverage for policy updates and unavailable snapshots. Validation: all 1,950 backend tests, workspace Clippy, frontend lint/typecheck/build, migration checks, Compose validation, and workflow lint passed. Completed 24 real requests with a free account using gpt-5.6-luna to verify daily/weekly budgets, RPM, concurrency, reconciliation, WebSocket policy updates, and budget persistence across restarts and usage-log cleanup. Co-authored-by: Codex <noreply@openai.com>
320 lines
9.6 KiB
TypeScript
320 lines
9.6 KiB
TypeScript
// @env browser
|
|
|
|
import type {
|
|
ResolvedTheme,
|
|
ThemeColorId,
|
|
ThemeCustomization,
|
|
ThemeMode,
|
|
} from '@/theme'
|
|
|
|
import { usePreferredDark, usePreferredReducedMotion, useTimeoutFn } from '@vueuse/core'
|
|
import { defineStore } from 'pinia'
|
|
import { computed, ref, shallowRef, watch } from 'vue'
|
|
|
|
import {
|
|
applyResolvedTheme,
|
|
DEFAULT_CUSTOM_THEME_COLOR,
|
|
DEFAULT_THEME_COLOR,
|
|
DEFAULT_THEME_MODE,
|
|
isThemeColorId,
|
|
isThemeMode,
|
|
normalizeThemeCustomization,
|
|
resolveTheme,
|
|
resolveThemeName,
|
|
} from '@/theme'
|
|
import { normalizeHexColor } from '@/utils/color'
|
|
|
|
interface ThemeTransitionOrigin {
|
|
x: number
|
|
y: number
|
|
}
|
|
|
|
interface ThemeConfigurationInput {
|
|
mode: ThemeMode
|
|
color: ThemeColorId
|
|
customColor: string
|
|
customization: ThemeCustomization
|
|
}
|
|
|
|
interface ViewTransition {
|
|
ready: Promise<void>
|
|
finished: Promise<void>
|
|
}
|
|
|
|
type ViewTransitionDocument = Document & {
|
|
startViewTransition?: (callback: () => void) => ViewTransition
|
|
}
|
|
|
|
export const useThemeStore = defineStore(
|
|
'theme',
|
|
() => {
|
|
const themeMode = shallowRef<ThemeMode>(DEFAULT_THEME_MODE)
|
|
const themeColor = shallowRef<ThemeColorId>(DEFAULT_THEME_COLOR)
|
|
const customThemeColor = shallowRef(DEFAULT_CUSTOM_THEME_COLOR)
|
|
const themeCustomization = ref<ThemeCustomization>({})
|
|
const themeRevision = shallowRef(0)
|
|
const themeTransitioning = shallowRef(false)
|
|
const preferredDark = usePreferredDark()
|
|
const preferredMotion = usePreferredReducedMotion()
|
|
let appliedThemeSignature: string | undefined
|
|
let themeTransitionOrigin: ThemeTransitionOrigin | undefined
|
|
let themeTransitionRequested = false
|
|
|
|
const effectiveTheme = computed(() => resolveThemeName(themeMode.value, preferredDark.value))
|
|
const resolvedTheme = computed(() =>
|
|
resolveTheme(
|
|
effectiveTheme.value,
|
|
themeColor.value,
|
|
customThemeColor.value,
|
|
themeCustomization.value,
|
|
),
|
|
)
|
|
|
|
const { start: startFallbackTransitionTimer, stop: stopFallbackTransitionTimer } = useTimeoutFn(
|
|
() => {
|
|
document.documentElement.classList.remove('theme-fallback-transition')
|
|
themeTransitioning.value = false
|
|
},
|
|
180,
|
|
{ immediate: false },
|
|
)
|
|
|
|
watch(resolvedTheme, theme => applyTheme(theme))
|
|
|
|
function initializeTheme(): void {
|
|
normalizePersistedTheme()
|
|
applyTheme(resolvedTheme.value)
|
|
}
|
|
|
|
function normalizePersistedTheme(): void {
|
|
if (!isThemeMode(themeMode.value))
|
|
themeMode.value = DEFAULT_THEME_MODE
|
|
if (!isThemeColorId(themeColor.value))
|
|
themeColor.value = DEFAULT_THEME_COLOR
|
|
customThemeColor.value = normalizeHexColor(customThemeColor.value) ?? DEFAULT_CUSTOM_THEME_COLOR
|
|
themeCustomization.value = normalizeThemeCustomization(themeCustomization.value)
|
|
}
|
|
|
|
function applyTheme(theme: ResolvedTheme): void {
|
|
if (typeof document === 'undefined')
|
|
return
|
|
|
|
const signature = themeSignature(theme)
|
|
if (appliedThemeSignature === signature) {
|
|
clearThemeTransitionRequest()
|
|
return
|
|
}
|
|
|
|
if (
|
|
appliedThemeSignature === undefined
|
|
|| !themeTransitionRequested
|
|
|| preferredMotion.value === 'reduce'
|
|
|| themeTransitioning.value
|
|
) {
|
|
commitTheme(theme)
|
|
clearThemeTransitionRequest()
|
|
return
|
|
}
|
|
|
|
runThemeTransition(theme)
|
|
clearThemeTransitionRequest()
|
|
}
|
|
|
|
function commitTheme(theme: ResolvedTheme): void {
|
|
applyResolvedTheme(document.documentElement, theme)
|
|
appliedThemeSignature = themeSignature(theme)
|
|
themeRevision.value += 1
|
|
}
|
|
|
|
function runThemeTransition(theme: ResolvedTheme): void {
|
|
const transitionDocument = document as ViewTransitionDocument
|
|
const shrinkingDarkLayer
|
|
= document.documentElement.dataset.theme === 'dark' && theme.name === 'light'
|
|
|
|
if (!transitionDocument.startViewTransition) {
|
|
runFallbackThemeTransition(theme)
|
|
return
|
|
}
|
|
|
|
const origin = themeTransitionOrigin ?? {
|
|
x: window.innerWidth - 44,
|
|
y: 44,
|
|
}
|
|
const maxX = Math.max(origin.x, window.innerWidth - origin.x)
|
|
const maxY = Math.max(origin.y, window.innerHeight - origin.y)
|
|
const radius = Math.hypot(maxX, maxY)
|
|
document.documentElement.classList.toggle('theme-view-transition-shrink', shrinkingDarkLayer)
|
|
themeTransitioning.value = true
|
|
let transition: ViewTransition
|
|
try {
|
|
transition = transitionDocument.startViewTransition(() => commitTheme(theme))
|
|
}
|
|
catch {
|
|
document.documentElement.classList.remove('theme-view-transition-shrink')
|
|
themeTransitioning.value = false
|
|
runFallbackThemeTransition(theme)
|
|
return
|
|
}
|
|
|
|
let activeAnimation: Animation | undefined
|
|
void transition.finished
|
|
.catch(() => undefined)
|
|
.finally(() => {
|
|
// 旧快照移除后再取消裁剪,避免深色图层在最后一帧重新铺满页面。
|
|
activeAnimation?.cancel()
|
|
document.documentElement.classList.remove('theme-view-transition-shrink')
|
|
themeTransitioning.value = false
|
|
})
|
|
void transition.ready
|
|
.then(() => {
|
|
activeAnimation = document.documentElement.animate(
|
|
{
|
|
clipPath: shrinkingDarkLayer
|
|
? [
|
|
`circle(${radius}px at ${origin.x}px ${origin.y}px)`,
|
|
`circle(0px at ${origin.x}px ${origin.y}px)`,
|
|
]
|
|
: [
|
|
`circle(0px at ${origin.x}px ${origin.y}px)`,
|
|
`circle(${radius}px at ${origin.x}px ${origin.y}px)`,
|
|
],
|
|
},
|
|
{
|
|
duration: 420,
|
|
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
|
fill: 'both',
|
|
pseudoElement: shrinkingDarkLayer
|
|
? '::view-transition-old(root)'
|
|
: '::view-transition-new(root)',
|
|
},
|
|
)
|
|
})
|
|
.catch(() => undefined)
|
|
}
|
|
|
|
function runFallbackThemeTransition(theme: ResolvedTheme): void {
|
|
themeTransitioning.value = true
|
|
stopFallbackTransitionTimer()
|
|
document.documentElement.classList.add('theme-fallback-transition')
|
|
commitTheme(theme)
|
|
startFallbackTransitionTimer()
|
|
}
|
|
|
|
function setThemeMode(mode: ThemeMode, event?: MouseEvent): void {
|
|
if (mode === themeMode.value) {
|
|
clearThemeTransitionRequest()
|
|
return
|
|
}
|
|
requestThemeTransition(event)
|
|
themeMode.value = mode
|
|
}
|
|
|
|
function setThemeColor(color: ThemeColorId, event?: MouseEvent): void {
|
|
if (color === themeColor.value) {
|
|
clearThemeTransitionRequest()
|
|
return
|
|
}
|
|
requestThemeTransition(event)
|
|
themeColor.value = color
|
|
}
|
|
|
|
function setCustomThemeColor(color: string, event?: MouseEvent): boolean {
|
|
const normalized = normalizeHexColor(color)
|
|
if (!normalized)
|
|
return false
|
|
if (themeColor.value === 'custom' && customThemeColor.value === normalized) {
|
|
clearThemeTransitionRequest()
|
|
return true
|
|
}
|
|
requestThemeTransition(event)
|
|
customThemeColor.value = normalized
|
|
themeColor.value = 'custom'
|
|
return true
|
|
}
|
|
|
|
function setThemeConfiguration(
|
|
configuration: ThemeConfigurationInput,
|
|
event?: MouseEvent,
|
|
): boolean {
|
|
const customColor = normalizeHexColor(configuration.customColor)
|
|
if (!isThemeMode(configuration.mode) || !isThemeColorId(configuration.color) || !customColor)
|
|
return false
|
|
|
|
requestThemeTransition(event)
|
|
themeMode.value = configuration.mode
|
|
themeColor.value = configuration.color
|
|
customThemeColor.value = customColor
|
|
themeCustomization.value = normalizeThemeCustomization(configuration.customization)
|
|
return true
|
|
}
|
|
|
|
function resetTheme(event?: MouseEvent): void {
|
|
const unchanged
|
|
= themeMode.value === DEFAULT_THEME_MODE
|
|
&& themeColor.value === DEFAULT_THEME_COLOR
|
|
&& customThemeColor.value === DEFAULT_CUSTOM_THEME_COLOR
|
|
&& Object.keys(themeCustomization.value).length === 0
|
|
if (unchanged) {
|
|
clearThemeTransitionRequest()
|
|
return
|
|
}
|
|
requestThemeTransition(event)
|
|
themeMode.value = DEFAULT_THEME_MODE
|
|
themeColor.value = DEFAULT_THEME_COLOR
|
|
customThemeColor.value = DEFAULT_CUSTOM_THEME_COLOR
|
|
themeCustomization.value = {}
|
|
}
|
|
|
|
function toggleTheme(event?: MouseEvent): void {
|
|
if (themeTransitioning.value)
|
|
return
|
|
requestThemeTransition(event)
|
|
themeMode.value = effectiveTheme.value === 'dark' ? 'light' : 'dark'
|
|
}
|
|
|
|
function requestThemeTransition(event?: MouseEvent): void {
|
|
themeTransitionOrigin = event
|
|
? { x: event.clientX, y: event.clientY }
|
|
: undefined
|
|
themeTransitionRequested = Boolean(event)
|
|
}
|
|
|
|
function clearThemeTransitionRequest(): void {
|
|
themeTransitionOrigin = undefined
|
|
themeTransitionRequested = false
|
|
}
|
|
|
|
return {
|
|
themeMode,
|
|
themeColor,
|
|
customThemeColor,
|
|
themeCustomization,
|
|
themeRevision,
|
|
themeTransitioning,
|
|
effectiveTheme,
|
|
initializeTheme,
|
|
setThemeMode,
|
|
setThemeColor,
|
|
setCustomThemeColor,
|
|
setThemeConfiguration,
|
|
resetTheme,
|
|
toggleTheme,
|
|
}
|
|
},
|
|
{
|
|
persist: {
|
|
key: 'codex-proxy-rs-theme',
|
|
pick: [
|
|
'themeMode',
|
|
'themeColor',
|
|
'customThemeColor',
|
|
'themeCustomization',
|
|
],
|
|
},
|
|
},
|
|
)
|
|
|
|
function themeSignature(theme: ResolvedTheme): string {
|
|
return `${theme.name}:${theme.colorId}:${theme.seed}:${JSON.stringify(theme.tokens)}`
|
|
}
|