mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-25 07:15:36 +00:00
feat(dashboard): provider init
This commit is contained in:
@@ -3,18 +3,18 @@ import { z } from 'zod'
|
||||
import type { SettingDefinition, SettingMetadata } from './setting.type'
|
||||
|
||||
export const DEFAULT_SETTING_DEFINITIONS = {
|
||||
'ai.openai.apiKey': {
|
||||
isSensitive: true,
|
||||
schema: z.string().min(1, 'OpenAI API key cannot be empty'),
|
||||
},
|
||||
'ai.openai.baseUrl': {
|
||||
isSensitive: false,
|
||||
schema: z.url('OpenAI Base URL cannot be empty'),
|
||||
},
|
||||
'ai.embedding.model': {
|
||||
isSensitive: false,
|
||||
schema: z.string().min(1, 'AI Model name cannot be empty'),
|
||||
},
|
||||
// 'ai.openai.apiKey': {
|
||||
// isSensitive: true,
|
||||
// schema: z.string().min(1, 'OpenAI API key cannot be empty'),
|
||||
// },
|
||||
// 'ai.openai.baseUrl': {
|
||||
// isSensitive: false,
|
||||
// schema: z.url('OpenAI Base URL cannot be empty'),
|
||||
// },
|
||||
// 'ai.embedding.model': {
|
||||
// isSensitive: false,
|
||||
// schema: z.string().min(1, 'AI Model name cannot be empty'),
|
||||
// },
|
||||
'auth.google.clientId': {
|
||||
isSensitive: false,
|
||||
schema: z.string().min(1, 'Google Client ID cannot be empty'),
|
||||
@@ -31,6 +31,37 @@ export const DEFAULT_SETTING_DEFINITIONS = {
|
||||
isSensitive: true,
|
||||
schema: z.string().min(1, 'GitHub Client secret cannot be empty'),
|
||||
},
|
||||
'builder.storage.providers': {
|
||||
isSensitive: false,
|
||||
schema: z.string().transform((value, ctx) => {
|
||||
const normalized = value.trim()
|
||||
if (normalized.length === 0) {
|
||||
return '[]'
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(normalized)
|
||||
if (!Array.isArray(parsed)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Builder storage providers must be a JSON array',
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
return JSON.stringify(parsed)
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Builder storage providers must be valid JSON',
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
}),
|
||||
},
|
||||
'builder.storage.activeProvider': {
|
||||
isSensitive: false,
|
||||
schema: z.string().transform((value) => value.trim()),
|
||||
},
|
||||
'http.cors.allowedOrigins': {
|
||||
isSensitive: false,
|
||||
schema: z
|
||||
@@ -38,10 +69,6 @@ export const DEFAULT_SETTING_DEFINITIONS = {
|
||||
.min(1, 'CORS allowed origins cannot be empty')
|
||||
.transform((value) => value.trim()),
|
||||
},
|
||||
'services.amap.apiKey': {
|
||||
isSensitive: true,
|
||||
schema: z.string().min(1, 'Gaode Map API key cannot be empty'),
|
||||
},
|
||||
} as const satisfies Record<string, SettingDefinition>
|
||||
|
||||
export const DEFAULT_SETTING_METADATA = Object.fromEntries(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { settings } from '@memora/db'
|
||||
import type { settings } from '@afilmory/db'
|
||||
import type { z } from 'zod'
|
||||
|
||||
import type {
|
||||
|
||||
@@ -6,97 +6,26 @@ function getIsSensitive(key: SettingKeyType): boolean {
|
||||
return DEFAULT_SETTING_METADATA[key]?.isSensitive ?? false
|
||||
}
|
||||
|
||||
export const SETTING_UI_SCHEMA_VERSION = '1.2.0'
|
||||
export const SETTING_UI_SCHEMA_VERSION = '1.1.0'
|
||||
|
||||
export const SETTING_UI_SCHEMA: SettingUiSchema = {
|
||||
version: SETTING_UI_SCHEMA_VERSION,
|
||||
title: '系统设置',
|
||||
description: '管理 Memora 平台的全局行为与第三方服务接入。',
|
||||
description: '管理 AFilmory 系统的全局行为与第三方服务接入。',
|
||||
sections: [
|
||||
{
|
||||
type: 'section',
|
||||
id: 'ai',
|
||||
title: 'AI 与智能功能',
|
||||
description: '配置 OpenAI 以及嵌入式模型以启用智能特性。',
|
||||
icon: 'i-lucide-brain-circuit',
|
||||
children: [
|
||||
{
|
||||
type: 'group',
|
||||
id: 'ai-openai',
|
||||
title: 'OpenAI 接入',
|
||||
description: '为 API 请求配置服务端所需的 OpenAI 凭据。',
|
||||
icon: 'i-lucide-bot',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
id: 'ai.openai.apiKey',
|
||||
title: 'API Key',
|
||||
description: '用于调用 OpenAI 接口的密钥,通常以 “sk-” 开头。',
|
||||
helperText: '出于安全考虑仅在受信环境中填写,提交后会进行加密存储。',
|
||||
key: 'ai.openai.apiKey',
|
||||
isSensitive: getIsSensitive('ai.openai.apiKey'),
|
||||
component: {
|
||||
type: 'secret',
|
||||
placeholder: 'sk-********************************',
|
||||
autoComplete: 'off',
|
||||
revealable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'ai.openai.baseUrl',
|
||||
title: '自定义 Base URL',
|
||||
description: '可选,若你使用自建代理,填写代理的完整 URL。',
|
||||
key: 'ai.openai.baseUrl',
|
||||
helperText: '例如 https://api.openai.com/v1,末尾无需斜杠。',
|
||||
isSensitive: getIsSensitive('ai.openai.baseUrl'),
|
||||
component: {
|
||||
type: 'text',
|
||||
inputType: 'url',
|
||||
placeholder: 'https://api.openai.com/v1',
|
||||
autoComplete: 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
id: 'ai-embedding',
|
||||
title: '向量嵌入模型',
|
||||
description: '用于语义搜索或文本向量化的模型。',
|
||||
icon: 'i-lucide-fingerprint',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
id: 'ai.embedding.model',
|
||||
title: 'Embedding 模型标识',
|
||||
description: '例如 text-embedding-3-large、text-embedding-3-small 等。',
|
||||
key: 'ai.embedding.model',
|
||||
helperText: '填写完整的模型名称,留空将导致相关功能不可用。',
|
||||
isSensitive: getIsSensitive('ai.embedding.model'),
|
||||
component: {
|
||||
type: 'text',
|
||||
placeholder: 'text-embedding-3-large',
|
||||
autoComplete: 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
id: 'auth',
|
||||
title: '登录与认证',
|
||||
description: '配置第三方 OAuth 登录用于后台访问控制。',
|
||||
icon: 'i-lucide-shield-check',
|
||||
icon: 'shield-check',
|
||||
children: [
|
||||
{
|
||||
type: 'group',
|
||||
id: 'auth-google',
|
||||
title: 'Google OAuth',
|
||||
description: '在 Google Cloud Console 中创建 OAuth 应用后填写以下信息。',
|
||||
icon: 'i-lucide-badge-check',
|
||||
icon: 'badge-check',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
@@ -133,7 +62,7 @@ export const SETTING_UI_SCHEMA: SettingUiSchema = {
|
||||
id: 'auth-github',
|
||||
title: 'GitHub OAuth',
|
||||
description: '在 GitHub OAuth Apps 中创建应用后填写。',
|
||||
icon: 'i-lucide-github',
|
||||
icon: 'github',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
@@ -172,14 +101,14 @@ export const SETTING_UI_SCHEMA: SettingUiSchema = {
|
||||
id: 'http',
|
||||
title: 'HTTP 与安全',
|
||||
description: '控制跨域访问等 Web 层配置。',
|
||||
icon: 'i-lucide-globe-2',
|
||||
icon: 'globe-2',
|
||||
children: [
|
||||
{
|
||||
type: 'group',
|
||||
id: 'http-cors',
|
||||
title: '跨域策略 (CORS)',
|
||||
description: '配置允许访问后台接口的来源列表。',
|
||||
icon: 'i-lucide-shield-alert',
|
||||
icon: 'shield-alert',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
@@ -200,39 +129,6 @@ export const SETTING_UI_SCHEMA: SettingUiSchema = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
id: 'services',
|
||||
title: '地图与定位',
|
||||
description: '配置地图底图与地理编码等服务。',
|
||||
icon: 'i-lucide-map',
|
||||
children: [
|
||||
{
|
||||
type: 'group',
|
||||
id: 'services-amap',
|
||||
title: '高德地图接入',
|
||||
description: '填写高德地图 Web 服务 Key 以启用后台地图选点与地理搜索能力。',
|
||||
icon: 'i-lucide-map-pinned',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
id: 'services.amap.apiKey',
|
||||
title: '高德地图 Key',
|
||||
description: '前往高德开发者控制台创建 Web 服务 Key,并授权所需的 IP/域名后填入。',
|
||||
helperText: '提交后将加密存储,仅后台调用地图与地理编码接口。',
|
||||
key: 'services.amap.apiKey',
|
||||
isSensitive: getIsSensitive('services.amap.apiKey'),
|
||||
component: {
|
||||
type: 'secret',
|
||||
placeholder: '****************',
|
||||
autoComplete: 'off',
|
||||
revealable: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} satisfies SettingUiSchema
|
||||
|
||||
|
||||
89
be/apps/core/src/modules/ui-schema/ui-schema.type.ts
Normal file
89
be/apps/core/src/modules/ui-schema/ui-schema.type.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
export type UiFieldComponentType = 'text' | 'secret' | 'textarea' | 'select' | 'slot'
|
||||
|
||||
interface UiFieldComponentBase<Type extends UiFieldComponentType> {
|
||||
readonly type: Type
|
||||
}
|
||||
|
||||
export interface UiTextInputComponent extends UiFieldComponentBase<'text'> {
|
||||
readonly inputType?: 'text' | 'email' | 'url' | 'number'
|
||||
readonly placeholder?: string
|
||||
readonly autoComplete?: string
|
||||
}
|
||||
|
||||
export interface UiSecretInputComponent extends UiFieldComponentBase<'secret'> {
|
||||
readonly placeholder?: string
|
||||
readonly autoComplete?: string
|
||||
readonly revealable?: boolean
|
||||
}
|
||||
|
||||
export interface UiTextareaComponent extends UiFieldComponentBase<'textarea'> {
|
||||
readonly placeholder?: string
|
||||
readonly minRows?: number
|
||||
readonly maxRows?: number
|
||||
}
|
||||
|
||||
export interface UiSelectComponent extends UiFieldComponentBase<'select'> {
|
||||
readonly placeholder?: string
|
||||
readonly options?: readonly string[]
|
||||
readonly allowCustom?: boolean
|
||||
}
|
||||
|
||||
export interface UiSlotComponent<Key extends string = string> extends UiFieldComponentBase<'slot'> {
|
||||
readonly name: string
|
||||
readonly fields?: ReadonlyArray<{ key: Key; label?: string; required?: boolean }>
|
||||
readonly props?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export type UiFieldComponentDefinition<Key extends string = string> =
|
||||
| UiTextInputComponent
|
||||
| UiSecretInputComponent
|
||||
| UiTextareaComponent
|
||||
| UiSelectComponent
|
||||
| UiSlotComponent<Key>
|
||||
|
||||
interface BaseUiNode {
|
||||
readonly id: string
|
||||
readonly title: string
|
||||
readonly description?: string | null
|
||||
}
|
||||
|
||||
export interface UiFieldNode<Key extends string = string> extends BaseUiNode {
|
||||
readonly type: 'field'
|
||||
readonly key: Key
|
||||
readonly component: UiFieldComponentDefinition<Key>
|
||||
readonly helperText?: string | null
|
||||
readonly docUrl?: string | null
|
||||
readonly isSensitive?: boolean
|
||||
readonly required?: boolean
|
||||
readonly icon?: string
|
||||
readonly hidden?: boolean
|
||||
}
|
||||
|
||||
export interface UiGroupNode<Key extends string = string> extends BaseUiNode {
|
||||
readonly type: 'group'
|
||||
readonly icon?: string
|
||||
readonly children: ReadonlyArray<UiNode<Key>>
|
||||
}
|
||||
|
||||
export interface UiSectionNode<Key extends string = string> extends BaseUiNode {
|
||||
readonly type: 'section'
|
||||
readonly icon?: string
|
||||
readonly layout?: {
|
||||
readonly columns?: number
|
||||
}
|
||||
readonly children: ReadonlyArray<UiNode<Key>>
|
||||
}
|
||||
|
||||
export type UiNode<Key extends string = string> = UiSectionNode<Key> | UiGroupNode<Key> | UiFieldNode<Key>
|
||||
|
||||
export interface UiSchema<Key extends string = string> {
|
||||
readonly version: string
|
||||
readonly title: string
|
||||
readonly description?: string | null
|
||||
readonly sections: ReadonlyArray<UiSectionNode<Key>>
|
||||
}
|
||||
|
||||
export interface UiSchemaResponse<Key extends string = string, Value = string | null> {
|
||||
readonly schema: UiSchema<Key>
|
||||
readonly values?: Partial<Record<Key, Value>>
|
||||
}
|
||||
@@ -27,7 +27,6 @@
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-label": "2.1.7",
|
||||
"@radix-ui/react-scroll-area": "1.2.10",
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-slider": "1.3.6",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
|
||||
23
be/apps/dashboard/src/modules/settings/api.ts
Normal file
23
be/apps/dashboard/src/modules/settings/api.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { coreApi } from '~/lib/api-client'
|
||||
|
||||
import type { SettingEntryInput, SettingUiSchemaResponse } from './types'
|
||||
|
||||
export const getSettingUiSchema = async () => {
|
||||
return await coreApi<SettingUiSchemaResponse>('/settings/ui-schema')
|
||||
}
|
||||
|
||||
export const getSettings = async (keys: ReadonlyArray<string>) => {
|
||||
return await coreApi<{
|
||||
keys: string[]
|
||||
values: Record<string, string | null>
|
||||
}>('/settings', {
|
||||
query: { keys },
|
||||
})
|
||||
}
|
||||
|
||||
export const updateSettings = async (entries: ReadonlyArray<SettingEntryInput>) => {
|
||||
return await coreApi<{ updated: ReadonlyArray<SettingEntryInput> }>('/settings', {
|
||||
method: 'POST',
|
||||
body: { entries },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
/* eslint-disable react-hooks/refs */
|
||||
import {
|
||||
FormHelperText,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Textarea,
|
||||
} from '@afilmory/ui'
|
||||
import { clsxm, Spring } from '@afilmory/utils'
|
||||
import { DynamicIcon } from 'lucide-react/dynamic'
|
||||
import { m } from 'motion/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { useSettingUiSchemaQuery, useUpdateSettingsMutation } from '../hooks'
|
||||
import type {
|
||||
SettingEntryInput,
|
||||
SettingUiSchemaResponse,
|
||||
SettingValueState,
|
||||
UiFieldComponentDefinition,
|
||||
UiFieldNode,
|
||||
UiGroupNode,
|
||||
UiNode,
|
||||
UiSectionNode,
|
||||
} from '../types'
|
||||
|
||||
const glassCardStyles = {
|
||||
backgroundImage:
|
||||
'linear-gradient(to bottom right, color-mix(in srgb, var(--color-background) 98%, transparent), color-mix(in srgb, var(--color-background) 95%, transparent))',
|
||||
boxShadow:
|
||||
'0 8px 32px color-mix(in srgb, var(--color-accent) 8%, transparent), 0 4px 16px color-mix(in srgb, var(--color-accent) 6%, transparent), 0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||
} as const
|
||||
|
||||
const glassGlowStyles = {
|
||||
background:
|
||||
'linear-gradient(to bottom right, color-mix(in srgb, var(--color-accent) 5%, transparent), transparent, color-mix(in srgb, var(--color-accent) 5%, transparent))',
|
||||
} as const
|
||||
|
||||
const providerGroupVisibility: Record<string, string> = {
|
||||
'builder-storage-s3': 's3',
|
||||
'builder-storage-github': 'github',
|
||||
'builder-storage-local': 'local',
|
||||
'builder-storage-eagle': 'eagle',
|
||||
}
|
||||
|
||||
const collectFieldNodes = (
|
||||
nodes: ReadonlyArray<UiNode<string>>,
|
||||
): UiFieldNode<string>[] => {
|
||||
const fields: UiFieldNode<string>[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'field') {
|
||||
fields.push(node)
|
||||
continue
|
||||
}
|
||||
|
||||
fields.push(...collectFieldNodes(node.children))
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
const buildInitialState = (
|
||||
schema: SettingUiSchemaResponse['schema'],
|
||||
values: SettingUiSchemaResponse['values'],
|
||||
): SettingValueState<string> => {
|
||||
const state: SettingValueState<string> = {} as SettingValueState<string>
|
||||
const fields = collectFieldNodes(schema.sections)
|
||||
|
||||
for (const field of fields) {
|
||||
const rawValue = values[field.key]
|
||||
state[field.key] = typeof rawValue === 'string' ? rawValue : ''
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
const GlassPanel = ({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) => (
|
||||
<div
|
||||
className={clsxm(
|
||||
'group relative overflow-hidden rounded-2xl border border-accent/20 backdrop-blur-2xl',
|
||||
className,
|
||||
)}
|
||||
style={glassCardStyles}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-2xl opacity-60"
|
||||
style={glassGlowStyles}
|
||||
/>
|
||||
<div className="relative">{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const FieldDescription = ({ description }: { description?: string | null }) => {
|
||||
if (!description) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <p className="mt-1 text-xs text-text-tertiary">{description}</p>
|
||||
}
|
||||
|
||||
const SchemaIcon = ({
|
||||
name,
|
||||
className,
|
||||
}: {
|
||||
name?: string | null
|
||||
className?: string
|
||||
}) => {
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<DynamicIcon name={name as any} className={clsxm('h-4 w-4', className)} />
|
||||
)
|
||||
}
|
||||
|
||||
const SecretFieldInput = ({
|
||||
component,
|
||||
fieldKey,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
component: Extract<UiFieldComponentDefinition<string>, { type: 'secret' }>
|
||||
fieldKey: string
|
||||
value: string
|
||||
onChange: (key: string, value: string) => void
|
||||
}) => {
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Input
|
||||
type={revealed ? 'text' : 'password'}
|
||||
value={value}
|
||||
onInput={(event) => onChange(fieldKey, event.currentTarget.value)}
|
||||
placeholder={component.placeholder ?? ''}
|
||||
autoComplete={component.autoComplete}
|
||||
className="flex-1 bg-background/60"
|
||||
/>
|
||||
{component.revealable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealed((prev) => !prev)}
|
||||
className="h-9 rounded-lg border border-accent/30 px-3 text-xs font-medium text-accent transition-all duration-200 hover:bg-accent/10"
|
||||
>
|
||||
{revealed ? '隐藏' : '显示'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FieldRenderer = ({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
field: UiFieldNode<string>
|
||||
value: string
|
||||
onChange: (key: string, value: string) => void
|
||||
}) => {
|
||||
const { component } = field
|
||||
|
||||
if (component.type === 'slot') {
|
||||
// Slot components are handled by a dedicated renderer once implemented.
|
||||
return null
|
||||
}
|
||||
|
||||
if (component.type === 'textarea') {
|
||||
return (
|
||||
<Textarea
|
||||
value={value}
|
||||
onInput={(event) => onChange(field.key, event.currentTarget.value)}
|
||||
placeholder={component.placeholder ?? ''}
|
||||
rows={component.minRows ?? 3}
|
||||
className="bg-background/60"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (component.type === 'select') {
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(value) => onChange(field.key, value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={component.placeholder ?? '请选择'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{component.options?.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
if (component.type === 'secret') {
|
||||
return (
|
||||
<SecretFieldInput
|
||||
component={component}
|
||||
fieldKey={field.key}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputType =
|
||||
component.type === 'text' ? (component.inputType ?? 'text') : 'text'
|
||||
|
||||
return (
|
||||
<Input
|
||||
type={inputType}
|
||||
value={value}
|
||||
onInput={(event) => onChange(field.key, event.currentTarget.value)}
|
||||
placeholder={component.placeholder ?? ''}
|
||||
autoComplete={component.autoComplete}
|
||||
className="bg-background/60"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const renderGroup = (
|
||||
node: UiGroupNode<string>,
|
||||
provider: string,
|
||||
formState: SettingValueState<string>,
|
||||
handleChange: (key: string, value: string) => void,
|
||||
) => {
|
||||
const expectedProvider = providerGroupVisibility[node.id]
|
||||
if (expectedProvider && expectedProvider !== provider) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
className="rounded-2xl border border-accent/10 bg-accent/[0.02] p-5 backdrop-blur-xl transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SchemaIcon name={node.icon} className="text-accent" />
|
||||
<h3 className="text-sm font-semibold text-text">{node.title}</h3>
|
||||
</div>
|
||||
<FieldDescription description={node.description} />
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{node.children.map((child) =>
|
||||
renderNode(child, provider, formState, handleChange),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderField = (
|
||||
field: UiFieldNode<string>,
|
||||
formState: SettingValueState<string>,
|
||||
handleChange: (key: string, value: string) => void,
|
||||
) => {
|
||||
const value = formState[field.key] ?? ''
|
||||
const { isSensitive } = field
|
||||
const showSensitiveHint = isSensitive && value.length === 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
className="space-y-2 rounded-xl border border-fill-tertiary/40 bg-background/30 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-text">{field.title}</Label>
|
||||
<FieldDescription description={field.description} />
|
||||
</div>
|
||||
<SchemaIcon name={field.icon} className="text-text-tertiary" />
|
||||
</div>
|
||||
|
||||
<FieldRenderer field={field} value={value} onChange={handleChange} />
|
||||
|
||||
{showSensitiveHint ? (
|
||||
<FormHelperText>出于安全考虑,仅在更新时填写新的值。</FormHelperText>
|
||||
) : null}
|
||||
|
||||
<FormHelperText>{field.helperText ?? undefined}</FormHelperText>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderNode = (
|
||||
node: UiNode<string>,
|
||||
provider: string,
|
||||
formState: SettingValueState<string>,
|
||||
handleChange: (key: string, value: string) => void,
|
||||
): ReactNode => {
|
||||
if (node.type === 'group') {
|
||||
return renderGroup(node, provider, formState, handleChange)
|
||||
}
|
||||
|
||||
if (node.type === 'field') {
|
||||
return renderField(node, formState, handleChange)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={node.id} className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SchemaIcon name={node.icon} className="h-5 w-5 text-accent" />
|
||||
<h2 className="text-base font-semibold text-text">{node.title}</h2>
|
||||
</div>
|
||||
<FieldDescription description={node.description} />
|
||||
<div className="grid gap-4">
|
||||
{node.children.map((child) =>
|
||||
renderNode(child, provider, formState, handleChange),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsForm = () => {
|
||||
const { data, isLoading, isError, error } = useSettingUiSchemaQuery()
|
||||
const updateSettingsMutation = useUpdateSettingsMutation()
|
||||
const [formState, setFormState] = useState<SettingValueState<string>>(
|
||||
{} as SettingValueState<string>,
|
||||
)
|
||||
const initialStateRef = useRef<SettingValueState<string>>(
|
||||
{} as SettingValueState<string>,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
|
||||
const initialValues = buildInitialState(data.schema, data.values)
|
||||
setFormState(initialValues)
|
||||
initialStateRef.current = initialValues
|
||||
}, [data])
|
||||
|
||||
const providerValue = formState['builder.storage.provider'] ?? ''
|
||||
|
||||
const changedEntries = useMemo<SettingEntryInput[]>(() => {
|
||||
const entries: SettingEntryInput[] = []
|
||||
|
||||
for (const [key, value] of Object.entries(formState)) {
|
||||
if (
|
||||
(initialStateRef.current as SettingValueState<string>)[key] !== value
|
||||
) {
|
||||
entries.push({ key, value })
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}, [formState])
|
||||
|
||||
const handleChange = (key: string, value: string) => {
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit: React.FormEventHandler<HTMLFormElement> = (event) => {
|
||||
event.preventDefault()
|
||||
if (changedEntries.length === 0 || updateSettingsMutation.isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
updateSettingsMutation.mutate(changedEntries)
|
||||
}
|
||||
|
||||
const mutationErrorMessage =
|
||||
updateSettingsMutation.isError && updateSettingsMutation.error
|
||||
? updateSettingsMutation.error instanceof Error
|
||||
? updateSettingsMutation.error.message
|
||||
: '未知错误'
|
||||
: null
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<GlassPanel className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="h-5 w-1/2 animate-pulse rounded-full bg-fill/40" />
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-20 animate-pulse rounded-xl bg-fill/30"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<GlassPanel className="p-6">
|
||||
<div className="flex items-center gap-3 text-sm text-red">
|
||||
<i className="i-mingcute-close-circle-fill text-lg" />
|
||||
<span>
|
||||
无法加载设置:{error instanceof Error ? error.message : '未知错误'}
|
||||
</span>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
// Should never reach here since loading and error states are handled.
|
||||
}
|
||||
|
||||
const { schema } = data
|
||||
|
||||
return (
|
||||
<m.form
|
||||
onSubmit={handleSubmit}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className="space-y-6"
|
||||
>
|
||||
{schema.sections.map((section: UiSectionNode<string>) => (
|
||||
<GlassPanel key={section.id} className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SchemaIcon
|
||||
name={section.icon}
|
||||
className="h-5 w-5 text-accent"
|
||||
/>
|
||||
<h2 className="text-lg font-semibold text-text">
|
||||
{section.title}
|
||||
</h2>
|
||||
</div>
|
||||
<FieldDescription description={section.description} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{section.children.map((child) =>
|
||||
renderNode(child, providerValue, formState, handleChange),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<div className="text-xs text-text-tertiary">
|
||||
{mutationErrorMessage
|
||||
? `保存失败:${mutationErrorMessage}`
|
||||
: updateSettingsMutation.isSuccess && changedEntries.length === 0
|
||||
? '保存成功,设置已同步'
|
||||
: changedEntries.length > 0
|
||||
? `有 ${changedEntries.length} 项设置待保存`
|
||||
: '所有设置已同步'}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
changedEntries.length === 0 || updateSettingsMutation.isPending
|
||||
}
|
||||
className={clsxm(
|
||||
'rounded-xl border border-accent/40 bg-accent px-4 py-2 text-sm font-semibold text-white transition-all duration-200',
|
||||
'hover:bg-accent/90 disabled:cursor-not-allowed disabled:border-accent/20 disabled:bg-accent/30 disabled:text-white/60',
|
||||
)}
|
||||
>
|
||||
{updateSettingsMutation.isPending ? '保存中…' : '保存修改'}
|
||||
</button>
|
||||
</div>
|
||||
</m.form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { clsxm } from '@afilmory/utils'
|
||||
import { NavLink } from 'react-router'
|
||||
|
||||
const SETTINGS_TABS = [
|
||||
{
|
||||
id: 'general',
|
||||
label: '通用设置',
|
||||
path: '/settings',
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
id: 'storage',
|
||||
label: '素材存储',
|
||||
path: '/settings/storage',
|
||||
end: false,
|
||||
},
|
||||
] as const
|
||||
|
||||
interface SettingsNavigationProps {
|
||||
active: (typeof SETTINGS_TABS)[number]['id']
|
||||
}
|
||||
|
||||
export const SettingsNavigation = ({ active }: SettingsNavigationProps) => {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{SETTINGS_TABS.map((tab) => (
|
||||
<NavLink key={tab.id} to={tab.path} end={tab.end}>
|
||||
{({ isActive }) => {
|
||||
const selected = isActive || active === tab.id
|
||||
return (
|
||||
<span
|
||||
className={clsxm(
|
||||
'inline-flex items-center rounded-lg px-3 py-1.5 text-xs font-medium transition-all',
|
||||
selected
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'bg-fill/10 text-text-secondary hover:bg-fill/20 hover:text-text',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
)
|
||||
}}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
be/apps/dashboard/src/modules/settings/hooks.ts
Normal file
26
be/apps/dashboard/src/modules/settings/hooks.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { getSettingUiSchema, updateSettings } from './api'
|
||||
import type { SettingEntryInput } from './types'
|
||||
|
||||
export const SETTING_UI_SCHEMA_QUERY_KEY = ['settings', 'ui-schema'] as const
|
||||
|
||||
export const useSettingUiSchemaQuery = () => {
|
||||
return useQuery({
|
||||
queryKey: SETTING_UI_SCHEMA_QUERY_KEY,
|
||||
queryFn: getSettingUiSchema,
|
||||
})
|
||||
}
|
||||
|
||||
export const useUpdateSettingsMutation = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (entries: ReadonlyArray<SettingEntryInput>) => {
|
||||
await updateSettings(entries)
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: SETTING_UI_SCHEMA_QUERY_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
5
be/apps/dashboard/src/modules/settings/index.ts
Normal file
5
be/apps/dashboard/src/modules/settings/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './api'
|
||||
export * from './hooks'
|
||||
export * from './types'
|
||||
export * from './components/SettingsForm'
|
||||
export * from './components/SettingsNavigation'
|
||||
96
be/apps/dashboard/src/modules/settings/types.ts
Normal file
96
be/apps/dashboard/src/modules/settings/types.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export type UiFieldComponentType = 'text' | 'secret' | 'textarea' | 'select' | 'slot'
|
||||
|
||||
interface UiFieldComponentBase<Type extends UiFieldComponentType> {
|
||||
readonly type: Type
|
||||
}
|
||||
|
||||
export interface UiTextInputComponent extends UiFieldComponentBase<'text'> {
|
||||
readonly inputType?: 'text' | 'email' | 'url' | 'number'
|
||||
readonly placeholder?: string
|
||||
readonly autoComplete?: string
|
||||
}
|
||||
|
||||
export interface UiSecretInputComponent extends UiFieldComponentBase<'secret'> {
|
||||
readonly placeholder?: string
|
||||
readonly autoComplete?: string
|
||||
readonly revealable?: boolean
|
||||
}
|
||||
|
||||
export interface UiTextareaComponent extends UiFieldComponentBase<'textarea'> {
|
||||
readonly placeholder?: string
|
||||
readonly minRows?: number
|
||||
readonly maxRows?: number
|
||||
}
|
||||
|
||||
export interface UiSelectComponent extends UiFieldComponentBase<'select'> {
|
||||
readonly placeholder?: string
|
||||
readonly options?: ReadonlyArray<string>
|
||||
readonly allowCustom?: boolean
|
||||
}
|
||||
|
||||
export interface UiSlotComponent<Key extends string = string> extends UiFieldComponentBase<'slot'> {
|
||||
readonly name: string
|
||||
readonly fields?: ReadonlyArray<{ key: Key; label?: string; required?: boolean }>
|
||||
readonly props?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export type UiFieldComponentDefinition<Key extends string = string> =
|
||||
| UiTextInputComponent
|
||||
| UiSecretInputComponent
|
||||
| UiTextareaComponent
|
||||
| UiSelectComponent
|
||||
| UiSlotComponent<Key>
|
||||
|
||||
interface BaseUiNode {
|
||||
readonly id: string
|
||||
readonly title: string
|
||||
readonly description?: string | null
|
||||
}
|
||||
|
||||
export interface UiFieldNode<Key extends string = string> extends BaseUiNode {
|
||||
readonly type: 'field'
|
||||
readonly key: Key
|
||||
readonly component: UiFieldComponentDefinition<Key>
|
||||
readonly helperText?: string | null
|
||||
readonly docUrl?: string | null
|
||||
readonly isSensitive?: boolean
|
||||
readonly required?: boolean
|
||||
readonly icon?: string
|
||||
readonly hidden?: boolean
|
||||
}
|
||||
|
||||
export interface UiGroupNode<Key extends string = string> extends BaseUiNode {
|
||||
readonly type: 'group'
|
||||
readonly icon?: string
|
||||
readonly children: ReadonlyArray<UiNode<Key>>
|
||||
}
|
||||
|
||||
export interface UiSectionNode<Key extends string = string> extends BaseUiNode {
|
||||
readonly type: 'section'
|
||||
readonly icon?: string
|
||||
readonly layout?: {
|
||||
readonly columns?: number
|
||||
}
|
||||
readonly children: ReadonlyArray<UiNode<Key>>
|
||||
}
|
||||
|
||||
export type UiNode<Key extends string = string> = UiSectionNode<Key> | UiGroupNode<Key> | UiFieldNode<Key>
|
||||
|
||||
export interface UiSchema<Key extends string = string> {
|
||||
readonly version: string
|
||||
readonly title: string
|
||||
readonly description?: string | null
|
||||
readonly sections: ReadonlyArray<UiSectionNode<Key>>
|
||||
}
|
||||
|
||||
export interface SettingUiSchemaResponse<Key extends string = string> {
|
||||
readonly schema: UiSchema<Key>
|
||||
readonly values: Partial<Record<Key, string | null>>
|
||||
}
|
||||
|
||||
export type SettingValueState<Key extends string = string> = Record<Key, string>
|
||||
|
||||
export type SettingEntryInput<Key extends string = string> = {
|
||||
readonly key: Key
|
||||
readonly value: string
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
import { FormHelperText, Input, Label, Textarea } from '@afilmory/ui'
|
||||
import { clsxm } from '@afilmory/utils'
|
||||
import { DynamicIcon } from 'lucide-react/dynamic'
|
||||
import { m } from 'motion/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
STORAGE_PROVIDER_FIELD_DEFINITIONS,
|
||||
STORAGE_PROVIDER_TYPE_OPTIONS,
|
||||
} from '../constants'
|
||||
import { useStorageProvidersQuery, useUpdateStorageProvidersMutation } from '../hooks'
|
||||
import type { StorageProvider, StorageProviderType } from '../types'
|
||||
import {
|
||||
createEmptyProvider,
|
||||
getDefaultConfigForType,
|
||||
reorderProvidersByActive,
|
||||
} from '../utils'
|
||||
|
||||
const glassCardStyles = {
|
||||
backgroundImage:
|
||||
'linear-gradient(to bottom right, color-mix(in srgb, var(--color-background) 98%, transparent), color-mix(in srgb, var(--color-background) 95%, transparent))',
|
||||
boxShadow:
|
||||
'0 8px 32px color-mix(in srgb, var(--color-accent) 8%, transparent), 0 4px 16px color-mix(in srgb, var(--color-accent) 6%, transparent), 0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||
} as const
|
||||
|
||||
const glassGlowStyles = {
|
||||
background:
|
||||
'linear-gradient(to bottom right, color-mix(in srgb, var(--color-accent) 5%, transparent), transparent, color-mix(in srgb, var(--color-accent) 5%, transparent))',
|
||||
} as const
|
||||
|
||||
const typeLabelMap = new Map(
|
||||
STORAGE_PROVIDER_TYPE_OPTIONS.map((option) => [option.value, option.label]),
|
||||
)
|
||||
|
||||
const GlassPanel = ({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) => (
|
||||
<div
|
||||
className={clsxm(
|
||||
'group relative overflow-hidden rounded-2xl border border-accent/20 backdrop-blur-2xl',
|
||||
className,
|
||||
)}
|
||||
style={glassCardStyles}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-2xl opacity-60"
|
||||
style={glassGlowStyles}
|
||||
/>
|
||||
<div className="relative">{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ProviderBadge = ({ type }: { type: StorageProviderType }) => {
|
||||
const label = typeLabelMap.get(type) ?? type
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent/10 px-2 py-0.5 text-[11px] font-medium text-accent">
|
||||
<DynamicIcon name="database" className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const StorageProvidersManager = () => {
|
||||
const { data, isLoading, isError, error } = useStorageProvidersQuery()
|
||||
const updateMutation = useUpdateStorageProvidersMutation()
|
||||
|
||||
const [providers, setProviders] = useState<StorageProvider[]>([])
|
||||
const [activeProviderId, setActiveProviderId] = useState<string | null>(null)
|
||||
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
|
||||
const initialProviders = data.providers
|
||||
const activeId =
|
||||
data.activeProviderId ??
|
||||
(initialProviders.length > 0 ? initialProviders[0].id : null)
|
||||
setProviders(initialProviders)
|
||||
setActiveProviderId(activeId)
|
||||
setSelectedProviderId(activeId ?? initialProviders[0]?.id ?? null)
|
||||
setIsDirty(false)
|
||||
}, [data])
|
||||
|
||||
const orderedProviders = useMemo(
|
||||
() => reorderProvidersByActive(providers, activeProviderId),
|
||||
[providers, activeProviderId],
|
||||
)
|
||||
|
||||
const selectedProvider = useMemo(
|
||||
() => providers.find((provider) => provider.id === selectedProviderId) ?? null,
|
||||
[providers, selectedProviderId],
|
||||
)
|
||||
|
||||
const handleSelectProvider = (providerId: string) => {
|
||||
setSelectedProviderId(providerId)
|
||||
}
|
||||
|
||||
const markDirty = () => setIsDirty(true)
|
||||
|
||||
const handleAddProvider = () => {
|
||||
const newProvider = createEmptyProvider('s3')
|
||||
setProviders((prev) => [...prev, newProvider])
|
||||
setActiveProviderId((prev) => prev ?? newProvider.id)
|
||||
setSelectedProviderId(newProvider.id)
|
||||
markDirty()
|
||||
}
|
||||
|
||||
const handleDeleteProvider = (providerId: string) => {
|
||||
setProviders((prev) => {
|
||||
const next = prev.filter((provider) => provider.id !== providerId)
|
||||
const nextActive = next.some((provider) => provider.id === activeProviderId)
|
||||
? activeProviderId
|
||||
: next[0]?.id ?? null
|
||||
setActiveProviderId(nextActive)
|
||||
setSelectedProviderId((currentSelected) => {
|
||||
if (currentSelected && next.some((provider) => provider.id === currentSelected)) {
|
||||
return currentSelected
|
||||
}
|
||||
return nextActive
|
||||
})
|
||||
return next
|
||||
})
|
||||
markDirty()
|
||||
}
|
||||
|
||||
const updateProvider = (
|
||||
providerId: string,
|
||||
updater: (provider: StorageProvider) => StorageProvider,
|
||||
) => {
|
||||
setProviders((prev) =>
|
||||
prev.map((provider) =>
|
||||
provider.id === providerId
|
||||
? updater({
|
||||
...provider,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
: provider,
|
||||
),
|
||||
)
|
||||
markDirty()
|
||||
}
|
||||
|
||||
const handleUpdateName = (providerId: string, name: string) => {
|
||||
updateProvider(providerId, (provider) => ({
|
||||
...provider,
|
||||
name,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleUpdateType = (providerId: string, nextType: StorageProviderType) => {
|
||||
updateProvider(providerId, (provider) => {
|
||||
if (provider.type === nextType) {
|
||||
return provider
|
||||
}
|
||||
|
||||
const nextConfigFields = STORAGE_PROVIDER_FIELD_DEFINITIONS[nextType]
|
||||
const preserved = nextConfigFields.reduce<Record<string, string>>(
|
||||
(acc, field) => {
|
||||
acc[field.key] = provider.config[field.key] ?? ''
|
||||
return acc
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
return {
|
||||
...provider,
|
||||
type: nextType,
|
||||
config: {
|
||||
...getDefaultConfigForType(nextType),
|
||||
...preserved,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleConfigChange = (
|
||||
providerId: string,
|
||||
key: string,
|
||||
value: string,
|
||||
) => {
|
||||
updateProvider(providerId, (provider) => ({
|
||||
...provider,
|
||||
config: {
|
||||
...provider.config,
|
||||
[key]: value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSetActive = (providerId: string) => {
|
||||
setActiveProviderId(providerId)
|
||||
markDirty()
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
const resolvedActiveId =
|
||||
activeProviderId && providers.some((provider) => provider.id === activeProviderId)
|
||||
? activeProviderId
|
||||
: providers[0]?.id ?? null
|
||||
|
||||
updateMutation.mutate(
|
||||
{
|
||||
providers,
|
||||
activeProviderId: resolvedActiveId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsDirty(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<GlassPanel className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="h-5 w-1/3 animate-pulse rounded-full bg-fill/40" />
|
||||
<div className="grid gap-3 lg:grid-cols-[280px_1fr]">
|
||||
<div className="h-40 animate-pulse rounded-xl bg-fill/20" />
|
||||
<div className="h-40 animate-pulse rounded-xl bg-fill/20" />
|
||||
</div>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<GlassPanel className="p-6">
|
||||
<div className="flex items-center gap-3 text-sm text-red">
|
||||
<DynamicIcon name="alert-triangle" className="h-5 w-5" />
|
||||
<span>
|
||||
无法加载存储配置:
|
||||
{error instanceof Error ? error.message : '未知错误'}
|
||||
</span>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
)
|
||||
}
|
||||
|
||||
const mutationErrorMessage =
|
||||
updateMutation.isError && updateMutation.error
|
||||
? updateMutation.error instanceof Error
|
||||
? updateMutation.error.message
|
||||
: '未知错误'
|
||||
: null
|
||||
|
||||
const hasProviders = providers.length > 0
|
||||
const selectedFields =
|
||||
selectedProvider != null
|
||||
? STORAGE_PROVIDER_FIELD_DEFINITIONS[selectedProvider.type]
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[280px_1fr]">
|
||||
<GlassPanel className="p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text">存储提供商</h2>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
可以配置多个提供商并快速切换。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddProvider}
|
||||
className="flex items-center gap-1 rounded-lg border border-accent/30 bg-accent/10 px-3 py-1.5 text-xs font-medium text-accent transition-all duration-200 hover:bg-accent/20"
|
||||
>
|
||||
<DynamicIcon name="plus" className="h-3.5 w-3.5" />
|
||||
新增
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasProviders ? (
|
||||
<div className="mt-4 space-y-2">
|
||||
{orderedProviders.map((provider) => {
|
||||
const isSelected = provider.id === selectedProviderId
|
||||
const isActive = provider.id === activeProviderId
|
||||
return (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectProvider(provider.id)}
|
||||
className={clsxm(
|
||||
'w-full rounded-xl border px-3 py-2 text-left transition-all duration-200',
|
||||
'flex flex-col gap-1.5',
|
||||
isSelected
|
||||
? 'border-accent/40 bg-accent/15 text-accent'
|
||||
: 'border-fill-tertiary bg-background/60 hover:border-accent/20 hover:bg-accent/10',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{provider.name || '未命名存储'}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent px-2 py-0.5 text-[11px] font-medium text-white">
|
||||
<DynamicIcon name="check-circle" className="h-3.5 w-3.5" />
|
||||
Active
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<ProviderBadge type={provider.type} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-xl border border-dashed border-accent/30 bg-accent/5 p-4 text-center text-xs text-text-tertiary">
|
||||
暂无存储提供商,点击「新增」开始配置。
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
|
||||
<GlassPanel className="p-6">
|
||||
{selectedProvider ? (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<DynamicIcon name="hard-drive" className="h-5 w-5 text-accent" />
|
||||
<h2 className="text-base font-semibold text-text">
|
||||
{selectedProvider.name || '未命名存储'}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
更新提供商的连接信息并保存,即可让 Builder 使用最新配置。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSetActive(selectedProvider.id)}
|
||||
disabled={selectedProvider.id === activeProviderId}
|
||||
className={clsxm(
|
||||
'rounded-lg border px-3 py-1.5 text-xs font-medium transition-all duration-200',
|
||||
selectedProvider.id === activeProviderId
|
||||
? 'border-accent/20 bg-accent/10 text-accent/60 cursor-not-allowed'
|
||||
: 'border-accent/40 bg-accent/10 text-accent hover:bg-accent/20',
|
||||
)}
|
||||
>
|
||||
设为 Active
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteProvider(selectedProvider.id)}
|
||||
className="rounded-lg border border-red/40 bg-red/10 px-3 py-1.5 text-xs font-medium text-red transition-all duration-200 hover:bg-red/15"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-text">显示名称</Label>
|
||||
<Input
|
||||
value={selectedProvider.name}
|
||||
onInput={(event) =>
|
||||
handleUpdateName(selectedProvider.id, event.currentTarget.value)
|
||||
}
|
||||
placeholder="例如:生产环境 S3"
|
||||
className="bg-background/60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-text">类型</Label>
|
||||
<select
|
||||
value={selectedProvider.type}
|
||||
onChange={(event) =>
|
||||
handleUpdateType(
|
||||
selectedProvider.id,
|
||||
event.currentTarget.value as StorageProviderType,
|
||||
)
|
||||
}
|
||||
className="h-10 w-full rounded-lg border border-fill-tertiary bg-background/70 px-3 text-sm text-text transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-accent/40"
|
||||
>
|
||||
{STORAGE_PROVIDER_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-text">连接配置</h3>
|
||||
<div className="space-y-4">
|
||||
{selectedFields.map((field) => {
|
||||
const value = selectedProvider.config[field.key] ?? ''
|
||||
const handler = (nextValue: string) =>
|
||||
handleConfigChange(selectedProvider.id, field.key, nextValue)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.key}
|
||||
className="space-y-2 rounded-xl border border-fill-tertiary/40 bg-background/30 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<Label className="text-xs font-semibold text-text">
|
||||
{field.label}
|
||||
</Label>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{field.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{field.multiline ? (
|
||||
<Textarea
|
||||
value={value}
|
||||
onInput={(event) => handler(event.currentTarget.value)}
|
||||
placeholder={field.placeholder}
|
||||
rows={field.multiline ? 3 : 2}
|
||||
className="bg-background/60"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={field.sensitive ? 'password' : 'text'}
|
||||
value={value}
|
||||
onInput={(event) => handler(event.currentTarget.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="bg-background/60"
|
||||
autoComplete="off"
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormHelperText>{field.helper}</FormHelperText>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-accent/10 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-xs text-text-tertiary">
|
||||
{mutationErrorMessage
|
||||
? `保存失败:${mutationErrorMessage}`
|
||||
: updateMutation.isSuccess && !isDirty
|
||||
? '保存成功,配置已同步'
|
||||
: isDirty
|
||||
? '有未保存的更改'
|
||||
: '暂无更改'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
!isDirty || updateMutation.isPending || providers.length === 0
|
||||
}
|
||||
className={clsxm(
|
||||
'rounded-xl border border-accent/40 bg-accent px-4 py-2 text-sm font-semibold text-white transition-all duration-200',
|
||||
'hover:bg-accent/90 disabled:cursor-not-allowed disabled:border-accent/20 disabled:bg-accent/30 disabled:text-white/60',
|
||||
)}
|
||||
>
|
||||
{updateMutation.isPending ? '保存中…' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
|
||||
<DynamicIcon name="hard-drive" className="h-8 w-8 text-accent/60" />
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-text">
|
||||
选择或创建一个提供商
|
||||
</div>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
从左侧列表中选择一个提供商进行配置,或新建一个提供商。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddProvider}
|
||||
className="rounded-lg border border-accent/30 bg-accent/10 px-4 py-2 text-sm font-medium text-accent transition-all duration-200 hover:bg-accent/20"
|
||||
>
|
||||
立即创建
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
181
be/apps/dashboard/src/modules/storage-providers/constants.ts
Normal file
181
be/apps/dashboard/src/modules/storage-providers/constants.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { StorageProviderFieldDefinition, StorageProviderType } from './types'
|
||||
|
||||
export const STORAGE_SETTING_KEYS = {
|
||||
providers: 'builder.storage.providers',
|
||||
activeProvider: 'builder.storage.activeProvider',
|
||||
} as const
|
||||
|
||||
export const STORAGE_PROVIDER_TYPES: readonly StorageProviderType[] = [
|
||||
's3',
|
||||
'github',
|
||||
'local',
|
||||
'eagle',
|
||||
]
|
||||
|
||||
export const STORAGE_PROVIDER_TYPE_OPTIONS: ReadonlyArray<{
|
||||
value: StorageProviderType
|
||||
label: string
|
||||
}> = [
|
||||
{ value: 's3', label: 'S3 / 兼容对象存储' },
|
||||
{ value: 'github', label: 'GitHub 仓库' },
|
||||
{ value: 'local', label: '本地文件系统' },
|
||||
{ value: 'eagle', label: 'Eagle 素材库' },
|
||||
]
|
||||
|
||||
export const STORAGE_PROVIDER_FIELD_DEFINITIONS: Record<
|
||||
StorageProviderType,
|
||||
ReadonlyArray<StorageProviderFieldDefinition>
|
||||
> = {
|
||||
s3: [
|
||||
{
|
||||
key: 'bucket',
|
||||
label: 'Bucket 名称',
|
||||
placeholder: 'afilmory-photos',
|
||||
description: 'S3 存储桶名称,用于读取图片文件。',
|
||||
},
|
||||
{
|
||||
key: 'region',
|
||||
label: '区域 (Region)',
|
||||
placeholder: 'ap-southeast-1',
|
||||
description: 'S3 区域代码,例如 ap-southeast-1。',
|
||||
},
|
||||
{
|
||||
key: 'endpoint',
|
||||
label: '自定义 Endpoint',
|
||||
placeholder: 'https://s3.example.com',
|
||||
description: '可选,S3 兼容服务的自定义 Endpoint 地址。',
|
||||
helper: '对于 AWS 官方 S3 可留空;MinIO 等第三方服务需要填写。',
|
||||
},
|
||||
{
|
||||
key: 'accessKeyId',
|
||||
label: 'Access Key ID',
|
||||
placeholder: 'AKIAxxxxxxxxxxxx',
|
||||
},
|
||||
{
|
||||
key: 'secretAccessKey',
|
||||
label: 'Secret Access Key',
|
||||
placeholder: '************',
|
||||
sensitive: true,
|
||||
},
|
||||
{
|
||||
key: 'prefix',
|
||||
label: '文件前缀',
|
||||
placeholder: 'photos/',
|
||||
description: '可选,仅访问指定前缀下的文件。',
|
||||
},
|
||||
{
|
||||
key: 'customDomain',
|
||||
label: '自定义访问域名',
|
||||
placeholder: 'https://cdn.example.com',
|
||||
description: '设置公开访问照片时使用的自定义域名。',
|
||||
},
|
||||
{
|
||||
key: 'excludeRegex',
|
||||
label: '排除规则 (正则)',
|
||||
placeholder: '\\.(tmp|bak)$',
|
||||
description: '可选,排除不需要的文件。',
|
||||
multiline: true,
|
||||
helper: '正则表达式需符合 JavaScript 语法。',
|
||||
},
|
||||
{
|
||||
key: 'maxFileLimit',
|
||||
label: '最大文件数量',
|
||||
placeholder: '1000',
|
||||
description: '可选,为扫描过程设置最大文件数量限制。',
|
||||
},
|
||||
],
|
||||
github: [
|
||||
{
|
||||
key: 'owner',
|
||||
label: '仓库 Owner',
|
||||
placeholder: 'afilmory',
|
||||
description: 'GitHub 仓库的拥有者(用户或组织名称)。',
|
||||
},
|
||||
{
|
||||
key: 'repo',
|
||||
label: '仓库名称',
|
||||
placeholder: 'photo-assets',
|
||||
description: '存储照片的仓库名称。',
|
||||
},
|
||||
{
|
||||
key: 'branch',
|
||||
label: '分支',
|
||||
placeholder: 'main',
|
||||
description: '可选,指定需要同步的分支。',
|
||||
helper: '默认 master/main,如需其它分支请填写完整名称。',
|
||||
},
|
||||
{
|
||||
key: 'token',
|
||||
label: '访问令牌',
|
||||
placeholder: 'ghp_xxxxxxxxxxxxxxxxxxxx',
|
||||
description: '用于访问私有仓库的 GitHub Personal Access Token。',
|
||||
sensitive: true,
|
||||
},
|
||||
{
|
||||
key: 'path',
|
||||
label: '仓库路径',
|
||||
placeholder: 'public/photos',
|
||||
description: '可选,仅同步仓库中的特定路径。',
|
||||
},
|
||||
{
|
||||
key: 'useRawUrl',
|
||||
label: '使用 Raw URL',
|
||||
placeholder: 'true / false',
|
||||
description: '是否使用 raw.githubusercontent.com 生成公开访问链接。',
|
||||
helper: '使用自定义域名则可填写 false。',
|
||||
},
|
||||
],
|
||||
local: [
|
||||
{
|
||||
key: 'basePath',
|
||||
label: '基础路径',
|
||||
placeholder: './apps/web/public/photos',
|
||||
description: '本地素材所在的绝对或相对路径。',
|
||||
},
|
||||
{
|
||||
key: 'baseUrl',
|
||||
label: '访问 URL',
|
||||
placeholder: '/photos',
|
||||
description: '用于生成公开访问链接的基础 URL。',
|
||||
},
|
||||
{
|
||||
key: 'distPath',
|
||||
label: '输出目录',
|
||||
placeholder: './dist/photos',
|
||||
description: '可选,构建时复制素材到的目标目录。',
|
||||
},
|
||||
{
|
||||
key: 'excludeRegex',
|
||||
label: '排除规则 (正则)',
|
||||
placeholder: '\\.(tmp|bak)$',
|
||||
description: '可选,排除不需要复制的文件。',
|
||||
multiline: true,
|
||||
},
|
||||
{
|
||||
key: 'maxFileLimit',
|
||||
label: '最大文件数量',
|
||||
placeholder: '1000',
|
||||
description: '可选,限制扫描时的最大文件数量。',
|
||||
},
|
||||
],
|
||||
eagle: [
|
||||
{
|
||||
key: 'libraryPath',
|
||||
label: 'Eagle Library 路径',
|
||||
placeholder: '/Users/you/Library/Application Support/Eagle',
|
||||
description: 'Eagle 应用素材库的安装路径。',
|
||||
},
|
||||
{
|
||||
key: 'distPath',
|
||||
label: '输出目录',
|
||||
placeholder: './apps/web/public/originals',
|
||||
description: '可选,原图导出的目标目录。',
|
||||
},
|
||||
{
|
||||
key: 'baseUrl',
|
||||
label: '访问 URL',
|
||||
placeholder: '/originals',
|
||||
description: '用于公开访问原图的基础 URL。',
|
||||
},
|
||||
],
|
||||
}
|
||||
63
be/apps/dashboard/src/modules/storage-providers/hooks.ts
Normal file
63
be/apps/dashboard/src/modules/storage-providers/hooks.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { getSettings, updateSettings } from '~/modules/settings'
|
||||
|
||||
import { STORAGE_SETTING_KEYS } from './constants'
|
||||
import type { StorageProvidersPayload } from './types'
|
||||
import {
|
||||
ensureActiveProviderId,
|
||||
parseStorageProviders,
|
||||
serializeStorageProviders,
|
||||
} from './utils'
|
||||
|
||||
export const STORAGE_PROVIDERS_QUERY_KEY = ['settings', 'storage-providers'] as const
|
||||
|
||||
export const useStorageProvidersQuery = () => {
|
||||
return useQuery({
|
||||
queryKey: STORAGE_PROVIDERS_QUERY_KEY,
|
||||
queryFn: async () => {
|
||||
const response = await getSettings([
|
||||
STORAGE_SETTING_KEYS.providers,
|
||||
STORAGE_SETTING_KEYS.activeProvider,
|
||||
])
|
||||
|
||||
const rawProviders = response.values[STORAGE_SETTING_KEYS.providers] ?? '[]'
|
||||
const providers = parseStorageProviders(rawProviders)
|
||||
const activeProviderRaw =
|
||||
response.values[STORAGE_SETTING_KEYS.activeProvider] ?? ''
|
||||
const activeProviderId =
|
||||
typeof activeProviderRaw === 'string' && activeProviderRaw.trim().length > 0
|
||||
? activeProviderRaw.trim()
|
||||
: null
|
||||
|
||||
return {
|
||||
providers,
|
||||
activeProviderId: ensureActiveProviderId(providers, activeProviderId),
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useUpdateStorageProvidersMutation = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: StorageProvidersPayload) => {
|
||||
await updateSettings([
|
||||
{
|
||||
key: STORAGE_SETTING_KEYS.providers,
|
||||
value: serializeStorageProviders(payload.providers),
|
||||
},
|
||||
{
|
||||
key: STORAGE_SETTING_KEYS.activeProvider,
|
||||
value: payload.activeProviderId ?? '',
|
||||
},
|
||||
])
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: STORAGE_PROVIDERS_QUERY_KEY,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
4
be/apps/dashboard/src/modules/storage-providers/index.ts
Normal file
4
be/apps/dashboard/src/modules/storage-providers/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './constants'
|
||||
export * from './hooks'
|
||||
export * from './types'
|
||||
export * from './components/StorageProvidersManager'
|
||||
25
be/apps/dashboard/src/modules/storage-providers/types.ts
Normal file
25
be/apps/dashboard/src/modules/storage-providers/types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type StorageProviderType = 's3' | 'github' | 'local' | 'eagle'
|
||||
|
||||
export interface StorageProvider {
|
||||
id: string
|
||||
name: string
|
||||
type: StorageProviderType
|
||||
config: Record<string, string>
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface StorageProvidersPayload {
|
||||
providers: StorageProvider[]
|
||||
activeProviderId: string | null
|
||||
}
|
||||
|
||||
export interface StorageProviderFieldDefinition {
|
||||
key: string
|
||||
label: string
|
||||
placeholder?: string
|
||||
description?: string
|
||||
helper?: string
|
||||
multiline?: boolean
|
||||
sensitive?: boolean
|
||||
}
|
||||
153
be/apps/dashboard/src/modules/storage-providers/utils.ts
Normal file
153
be/apps/dashboard/src/modules/storage-providers/utils.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
STORAGE_PROVIDER_FIELD_DEFINITIONS,
|
||||
STORAGE_PROVIDER_TYPES,
|
||||
} from './constants'
|
||||
import type { StorageProvider, StorageProviderType } from './types'
|
||||
|
||||
const generateId = () => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return Math.random().toString(36).slice(2, 10)
|
||||
}
|
||||
|
||||
export const isStorageProviderType = (
|
||||
value: unknown,
|
||||
): value is StorageProviderType => {
|
||||
return STORAGE_PROVIDER_TYPES.includes(value as StorageProviderType)
|
||||
}
|
||||
|
||||
const normaliseConfigForType = (
|
||||
type: StorageProviderType,
|
||||
config: Record<string, unknown>,
|
||||
): Record<string, string> => {
|
||||
return STORAGE_PROVIDER_FIELD_DEFINITIONS[type].reduce<Record<string, string>>(
|
||||
(acc, field) => {
|
||||
const raw = config[field.key]
|
||||
acc[field.key] =
|
||||
typeof raw === 'string'
|
||||
? raw
|
||||
: raw == null
|
||||
? ''
|
||||
: String(raw)
|
||||
return acc
|
||||
},
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
const coerceProvider = (input: unknown): StorageProvider | null => {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const record = input as Record<string, unknown>
|
||||
const type = isStorageProviderType(record.type) ? record.type : 'local'
|
||||
const configInput =
|
||||
record.config && typeof record.config === 'object' && !Array.isArray(record.config)
|
||||
? (record.config as Record<string, unknown>)
|
||||
: {}
|
||||
|
||||
const provider: StorageProvider = {
|
||||
id:
|
||||
typeof record.id === 'string' && record.id.trim().length > 0
|
||||
? record.id.trim()
|
||||
: generateId(),
|
||||
name:
|
||||
typeof record.name === 'string' && record.name.trim().length > 0
|
||||
? record.name.trim()
|
||||
: '未命名存储',
|
||||
type,
|
||||
config: normaliseConfigForType(type, configInput),
|
||||
}
|
||||
|
||||
if (typeof record.createdAt === 'string') {
|
||||
provider.createdAt = record.createdAt
|
||||
}
|
||||
|
||||
if (typeof record.updatedAt === 'string') {
|
||||
provider.updatedAt = record.updatedAt
|
||||
}
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
export const parseStorageProviders = (raw: string | null): StorageProvider[] => {
|
||||
if (!raw) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return parsed
|
||||
.map((item) => coerceProvider(item))
|
||||
.filter((item): item is StorageProvider => item !== null)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const serializeStorageProviders = (
|
||||
providers: ReadonlyArray<StorageProvider>,
|
||||
): string => {
|
||||
return JSON.stringify(
|
||||
providers.map((provider) => ({
|
||||
...provider,
|
||||
config: normaliseConfigForType(provider.type, provider.config),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
export const getDefaultConfigForType = (
|
||||
type: StorageProviderType,
|
||||
): Record<string, string> => {
|
||||
return STORAGE_PROVIDER_FIELD_DEFINITIONS[type].reduce<Record<string, string>>(
|
||||
(acc, field) => {
|
||||
acc[field.key] = ''
|
||||
return acc
|
||||
},
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
export const createEmptyProvider = (type: StorageProviderType): StorageProvider => {
|
||||
const timestamp = new Date().toISOString()
|
||||
return {
|
||||
id: generateId(),
|
||||
name: '未命名存储',
|
||||
type,
|
||||
config: getDefaultConfigForType(type),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
export const ensureActiveProviderId = (
|
||||
providers: ReadonlyArray<StorageProvider>,
|
||||
activeId: string | null,
|
||||
): string | null => {
|
||||
if (!activeId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return providers.some((provider) => provider.id === activeId) ? activeId : null
|
||||
}
|
||||
|
||||
export const reorderProvidersByActive = (
|
||||
providers: ReadonlyArray<StorageProvider>,
|
||||
activeId: string | null,
|
||||
): StorageProvider[] => {
|
||||
if (!activeId) {
|
||||
return [...providers]
|
||||
}
|
||||
|
||||
return [...providers].sort((a, b) => {
|
||||
if (a.id === activeId) return -1
|
||||
if (b.id === activeId) return 1
|
||||
return a.name.localeCompare(b.name, 'zh-cn')
|
||||
})
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Input, Label, Textarea } from '@afilmory/ui'
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
|
||||
export const Component = () => {
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Page Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text">Settings</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
Configure your account and application preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Settings Form */}
|
||||
<div className="max-w-2xl space-y-4">
|
||||
{/* Profile Section */}
|
||||
<div className="rounded-lg border border-border/50 bg-background-tertiary p-5">
|
||||
<h2 className="mb-5 text-sm font-semibold text-text">
|
||||
Profile Settings
|
||||
</h2>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="display-name">Display Name</Label>
|
||||
<Input
|
||||
id="display-name"
|
||||
type="text"
|
||||
placeholder="Enter your display name"
|
||||
defaultValue="John Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email Address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="your.email@example.com"
|
||||
defaultValue="john@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bio">Bio</Label>
|
||||
<Textarea
|
||||
id="bio"
|
||||
rows={3}
|
||||
placeholder="Tell us about yourself..."
|
||||
defaultValue="Photo enthusiast and traveler"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preferences Section */}
|
||||
<div className="rounded-lg border border-border/50 bg-background-tertiary p-5">
|
||||
<h2 className="mb-5 text-sm font-semibold text-text">Preferences</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="language">Language</Label>
|
||||
<Input id="language" type="text" defaultValue="English" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="timezone">Timezone</Label>
|
||||
<Input id="timezone" type="text" defaultValue="UTC-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md px-3 py-1.5 text-[13px] font-medium text-text-secondary transition-all duration-150 hover:bg-fill/30 hover:text-text"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-accent px-3 py-1.5 text-[13px] font-medium text-white transition-all duration-150 hover:bg-accent/90"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</m.div>
|
||||
)
|
||||
}
|
||||
28
be/apps/dashboard/src/pages/(main)/settings/index.tsx
Normal file
28
be/apps/dashboard/src/pages/(main)/settings/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
|
||||
import { SettingsForm, SettingsNavigation } from '~/modules/settings'
|
||||
|
||||
export const Component = () => {
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<h1 className="text-2xl font-semibold text-text">系统设置</h1>
|
||||
<p className="text-sm text-text-secondary">
|
||||
管理后台与核心功能的通用配置,修改后会立即同步生效。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsNavigation active="general" />
|
||||
</header>
|
||||
|
||||
<SettingsForm />
|
||||
</m.div>
|
||||
)
|
||||
}
|
||||
29
be/apps/dashboard/src/pages/(main)/settings/storage.tsx
Normal file
29
be/apps/dashboard/src/pages/(main)/settings/storage.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
|
||||
import { SettingsNavigation } from '~/modules/settings'
|
||||
import { StorageProvidersManager } from '~/modules/storage-providers'
|
||||
|
||||
export const Component = () => {
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<h1 className="text-2xl font-semibold text-text">素材存储与 Builder</h1>
|
||||
<p className="text-sm text-text-secondary">
|
||||
在此配置多个素材存储提供商,并选择一个作为 Builder 的活跃源。保存后请重新运行 Builder 以加载最新配置。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsNavigation active="storage" />
|
||||
</header>
|
||||
|
||||
<StorageProvidersManager />
|
||||
</m.div>
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
"@radix-ui/react-hover-card": "1.1.15",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-scroll-area": "1.2.10",
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
|
||||
@@ -12,6 +12,7 @@ export * from './lazy-image'
|
||||
export * from './modal'
|
||||
export * from './portal'
|
||||
export * from './scroll-areas'
|
||||
export * from './select'
|
||||
export * from './sonner'
|
||||
export * from './sonner'
|
||||
export * from './switch'
|
||||
|
||||
209
packages/ui/src/select/index.tsx
Normal file
209
packages/ui/src/select/index.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { clsxm as cn } from '@afilmory/utils'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import * as React from 'react'
|
||||
|
||||
import { RootPortal } from '../portal'
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = ({
|
||||
ref,
|
||||
size = 'default',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
|
||||
size?: 'default' | 'sm'
|
||||
} & {
|
||||
ref?: React.Ref<React.ElementRef<typeof SelectPrimitive.Trigger> | null>
|
||||
}) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-lg bg-transparent whitespace-nowrap',
|
||||
'focus-within:ring-material-medium transition-all duration-200 outline-none focus-within:ring-2 focus-within:outline-transparent',
|
||||
'border-border hover:border-fill border',
|
||||
size === 'sm' ? 'h-8 px-3 text-sm' : 'h-9 px-3.5 py-2 text-sm',
|
||||
'placeholder:text-text-secondary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'[&>span]:line-clamp-1',
|
||||
'shadow-material-thin shadow-sm hover:shadow',
|
||||
className,
|
||||
props.disabled && 'cursor-not-allowed opacity-30',
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<i className="i-mingcute-down-line -mr-1 ml-2 size-4 shrink-0 opacity-60 transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> & {
|
||||
ref?: React.Ref<React.ElementRef<
|
||||
typeof SelectPrimitive.ScrollUpButton
|
||||
> | null>
|
||||
}) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'cursor-menu flex items-center justify-center py-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<i className="i-mingcute-up-line size-3.5" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> & {
|
||||
ref?: React.Ref<React.ElementRef<
|
||||
typeof SelectPrimitive.ScrollDownButton
|
||||
> | null>
|
||||
}) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'cursor-menu flex items-center justify-center py-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<i className="i-mingcute-down-line size-3.5" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = ({
|
||||
ref,
|
||||
className,
|
||||
children,
|
||||
position = 'item-aligned',
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> & {
|
||||
ref?: React.Ref<React.ElementRef<typeof SelectPrimitive.Content> | null>
|
||||
}) => (
|
||||
<RootPortal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-material-medium backdrop-blur-background text-text z-[60] max-h-96 min-w-32 overflow-hidden rounded-[6px] border p-1',
|
||||
'shadow-context-menu',
|
||||
'motion-scale-in-75 motion-duration-150 text-body lg:animate-none',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-0',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</RootPortal>
|
||||
)
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = ({
|
||||
ref,
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
} & {
|
||||
ref?: React.Ref<React.ElementRef<typeof SelectPrimitive.Label> | null>
|
||||
}) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-text px-2 py-1.5 font-semibold',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = ({
|
||||
ref,
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
} & {
|
||||
ref?: React.Ref<React.ElementRef<typeof SelectPrimitive.Item> | null>
|
||||
}) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'cursor-menu focus:bg-theme-selection-active focus:text-theme-selection-foreground relative flex items-center rounded-[5px] px-2.5 py-1 outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'data-[highlighted]:bg-theme-selection-hover focus-within:outline-transparent',
|
||||
'h-[28px] w-full',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<i className="i-mgc-check-filled size-3" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = ({
|
||||
ref,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> & {
|
||||
ref?: React.Ref<React.ElementRef<typeof SelectPrimitive.Separator> | null>
|
||||
}) => (
|
||||
<SelectPrimitive.Separator
|
||||
className="bg-border mx-2 my-1 h-px w-full"
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
85
pnpm-lock.yaml
generated
85
pnpm-lock.yaml
generated
@@ -98,7 +98,7 @@ importers:
|
||||
version: 9.38.0(jiti@2.6.1)
|
||||
eslint-config-hyoban:
|
||||
specifier: 4.0.10
|
||||
version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.38.0(jiti@2.6.1))(tailwindcss@4.1.16)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3)
|
||||
version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.38.0(jiti@2.6.1))(tailwindcss@4.1.16)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3)
|
||||
fast-glob:
|
||||
specifier: 3.3.3
|
||||
version: 3.3.3
|
||||
@@ -597,7 +597,7 @@ importers:
|
||||
version: 1.19.5(hono@4.10.2)
|
||||
better-auth:
|
||||
specifier: 1.3.29
|
||||
version: 1.3.29(next@16.0.0(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
version: 1.3.29(next@16.0.0(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
drizzle-orm:
|
||||
specifier: ^0.44.7
|
||||
version: 0.44.7(@types/pg@8.15.5)(@vercel/postgres@0.10.0)(kysely@0.28.8)(pg@8.16.3)(postgres@3.4.7)
|
||||
@@ -680,9 +680,6 @@ importers:
|
||||
'@radix-ui/react-scroll-area':
|
||||
specifier: 1.2.10
|
||||
version: 1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-select':
|
||||
specifier: 2.2.6
|
||||
version: 2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-slider':
|
||||
specifier: 1.3.6
|
||||
version: 1.3.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
@@ -703,7 +700,7 @@ importers:
|
||||
version: 5.90.5(react@19.2.0)
|
||||
better-auth:
|
||||
specifier: 1.3.29
|
||||
version: 1.3.29(next@16.0.0(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
version: 1.3.29(next@16.0.0(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
class-variance-authority:
|
||||
specifier: 0.7.1
|
||||
version: 0.7.1
|
||||
@@ -721,7 +718,7 @@ importers:
|
||||
version: 10.1.3
|
||||
jotai:
|
||||
specifier: 2.15.0
|
||||
version: 2.15.0(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0)
|
||||
version: 2.15.0(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0)
|
||||
lucide-react:
|
||||
specifier: 0.547.0
|
||||
version: 0.547.0(react@19.2.0)
|
||||
@@ -745,7 +742,7 @@ importers:
|
||||
version: 7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
react-scan:
|
||||
specifier: 0.4.3
|
||||
version: 0.4.3(@types/react@19.2.2)(next@16.0.0(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.52.5)
|
||||
version: 0.4.3(@types/react@19.2.2)(next@16.0.0(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.52.5)
|
||||
sonner:
|
||||
specifier: 2.0.7
|
||||
version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
@@ -800,7 +797,7 @@ importers:
|
||||
version: 9.38.0(jiti@2.6.1)
|
||||
eslint-config-hyoban:
|
||||
specifier: 4.0.10
|
||||
version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.38.0(jiti@2.6.1))(tailwindcss@4.1.16)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3)
|
||||
version: 4.0.10(@types/estree@1.0.8)(@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/utils@8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.38.0(jiti@2.6.1))(tailwindcss@4.1.16)(ts-api-utils@2.1.0(typescript@5.9.3))(typescript@5.9.3)
|
||||
lint-staged:
|
||||
specifier: 16.2.6
|
||||
version: 16.2.6
|
||||
@@ -1228,6 +1225,9 @@ importers:
|
||||
'@radix-ui/react-scroll-area':
|
||||
specifier: 1.2.10
|
||||
version: 1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-select':
|
||||
specifier: 2.2.6
|
||||
version: 2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: 1.2.3
|
||||
version: 1.2.3(@types/react@19.2.2)(react@19.2.0)
|
||||
@@ -6711,10 +6711,6 @@ packages:
|
||||
resolution: {integrity: sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==}
|
||||
engines: {node: '>=12.20'}
|
||||
|
||||
detect-libc@2.1.0:
|
||||
resolution: {integrity: sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -13731,7 +13727,7 @@ snapshots:
|
||||
|
||||
'@img/sharp-wasm32@0.34.4':
|
||||
dependencies:
|
||||
'@emnapi/runtime': 1.5.0
|
||||
'@emnapi/runtime': 1.6.0
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.34.4':
|
||||
@@ -16764,7 +16760,7 @@ snapshots:
|
||||
'@unocss/rule-utils@66.5.1':
|
||||
dependencies:
|
||||
'@unocss/core': 66.5.1
|
||||
magic-string: 0.30.19
|
||||
magic-string: 0.30.21
|
||||
|
||||
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
|
||||
optional: true
|
||||
@@ -17284,7 +17280,7 @@ snapshots:
|
||||
|
||||
batch-cluster@15.0.1: {}
|
||||
|
||||
better-auth@1.3.29(next@16.0.0(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||
better-auth@1.3.29(next@16.0.0(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.3.29(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)
|
||||
'@better-auth/telemetry': 1.3.29(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)
|
||||
@@ -17301,7 +17297,7 @@ snapshots:
|
||||
nanostores: 1.0.1
|
||||
zod: 4.1.12
|
||||
optionalDependencies:
|
||||
next: 16.0.0(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
next: 16.0.0(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
|
||||
@@ -18010,8 +18006,6 @@ snapshots:
|
||||
|
||||
detect-indent@7.0.2: {}
|
||||
|
||||
detect-libc@2.1.0: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
detect-newline@4.0.1: {}
|
||||
@@ -19741,13 +19735,6 @@ snapshots:
|
||||
|
||||
jose@6.1.0: {}
|
||||
|
||||
jotai@2.15.0(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0):
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.28.4
|
||||
'@babel/template': 7.27.2
|
||||
'@types/react': 19.2.2
|
||||
react: 19.2.0
|
||||
|
||||
jotai@2.15.0(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0):
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.28.5
|
||||
@@ -20778,30 +20765,6 @@ snapshots:
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
|
||||
next@16.0.0(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||
dependencies:
|
||||
'@next/env': 16.0.0
|
||||
'@swc/helpers': 0.5.15
|
||||
caniuse-lite: 1.0.30001751
|
||||
postcss: 8.4.31
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
styled-jsx: 5.1.6(@babel/core@7.28.4)(react@19.2.0)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.0.0
|
||||
'@next/swc-darwin-x64': 16.0.0
|
||||
'@next/swc-linux-arm64-gnu': 16.0.0
|
||||
'@next/swc-linux-arm64-musl': 16.0.0
|
||||
'@next/swc-linux-x64-gnu': 16.0.0
|
||||
'@next/swc-linux-x64-musl': 16.0.0
|
||||
'@next/swc-win32-arm64-msvc': 16.0.0
|
||||
'@next/swc-win32-x64-msvc': 16.0.0
|
||||
sharp: 0.34.4
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
optional: true
|
||||
|
||||
next@16.0.0(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||
dependencies:
|
||||
'@next/env': 16.0.0
|
||||
@@ -21910,7 +21873,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
|
||||
react-scan@0.4.3(@types/react@19.2.2)(next@16.0.0(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.52.5):
|
||||
react-scan@0.4.3(@types/react@19.2.2)(next@16.0.0(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@2.79.2):
|
||||
dependencies:
|
||||
'@babel/core': 7.28.4
|
||||
'@babel/generator': 7.28.3
|
||||
@@ -21919,7 +21882,7 @@ snapshots:
|
||||
'@clack/prompts': 0.8.2
|
||||
'@pivanov/utils': 0.0.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@preact/signals': 1.3.2(preact@10.27.2)
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.52.5)
|
||||
'@rollup/pluginutils': 5.3.0(rollup@2.79.2)
|
||||
'@types/node': 20.19.23
|
||||
bippy: 0.3.27(@types/react@19.2.2)(react@19.2.0)
|
||||
esbuild: 0.25.11
|
||||
@@ -21932,7 +21895,7 @@ snapshots:
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
tsx: 4.20.6
|
||||
optionalDependencies:
|
||||
next: 16.0.0(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
next: 16.0.0(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
react-router: 7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
react-router-dom: 6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
unplugin: 2.1.0
|
||||
@@ -21941,7 +21904,7 @@ snapshots:
|
||||
- rollup
|
||||
- supports-color
|
||||
|
||||
react-scan@0.4.3(@types/react@19.2.2)(next@16.0.0(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@2.79.2):
|
||||
react-scan@0.4.3(@types/react@19.2.2)(next@16.0.0(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-router-dom@6.30.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.52.5):
|
||||
dependencies:
|
||||
'@babel/core': 7.28.4
|
||||
'@babel/generator': 7.28.3
|
||||
@@ -21950,7 +21913,7 @@ snapshots:
|
||||
'@clack/prompts': 0.8.2
|
||||
'@pivanov/utils': 0.0.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@preact/signals': 1.3.2(preact@10.27.2)
|
||||
'@rollup/pluginutils': 5.3.0(rollup@2.79.2)
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.52.5)
|
||||
'@types/node': 20.19.23
|
||||
bippy: 0.3.27(@types/react@19.2.2)(react@19.2.0)
|
||||
esbuild: 0.25.11
|
||||
@@ -22489,8 +22452,8 @@ snapshots:
|
||||
sharp@0.34.4:
|
||||
dependencies:
|
||||
'@img/colour': 1.0.0
|
||||
detect-libc: 2.1.0
|
||||
semver: 7.7.2
|
||||
detect-libc: 2.1.2
|
||||
semver: 7.7.3
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.34.4
|
||||
'@img/sharp-darwin-x64': 0.34.4
|
||||
@@ -22809,14 +22772,6 @@ snapshots:
|
||||
dependencies:
|
||||
inline-style-parser: 0.2.4
|
||||
|
||||
styled-jsx@5.1.6(@babel/core@7.28.4)(react@19.2.0):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.2.0
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.28.4
|
||||
optional: true
|
||||
|
||||
styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.0):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
|
||||
Reference in New Issue
Block a user