feat: integrate platform backend and application interfaces
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Miragen Flow Frontend Component Plan
|
||||
|
||||
Status: `ready` implemented in the first foundation pass, `planned` API and ownership reserved.
|
||||
|
||||
## Foundations
|
||||
|
||||
- `ready` Design tokens: color, type, spacing, radius, shadow, motion, z-index
|
||||
- `ready` Global reset, focus-visible, selection, scrollbar, responsive shell
|
||||
- `planned` Theme provider and light theme
|
||||
- `planned` Locale provider
|
||||
- `planned` Permission boundary
|
||||
- `planned` Feature flag boundary
|
||||
|
||||
## Primitives
|
||||
|
||||
- `ready` Button: primary, secondary, ghost, danger, loading, disabled, sizes
|
||||
- `ready` IconButton: label, tooltip, selected, disabled, sizes
|
||||
- `ready` TextInput, SearchInput, Field, Select, Switch
|
||||
- `ready` Tabs, FilterChip, Badge
|
||||
- `ready` Avatar
|
||||
- `ready` Spinner, Skeleton, InlineAlert, EmptyState, Dialog
|
||||
- `planned` Textarea, NumberInput, PasswordInput
|
||||
- `planned` Checkbox, RadioGroup, SegmentedControl
|
||||
- `planned` Slider, RangeSlider, Stepper
|
||||
- `planned` MultiSelect, Combobox, TagInput
|
||||
- `planned` Tooltip, Popover, DropdownMenu, ContextMenu
|
||||
- `planned` Divider, Breadcrumbs, Pagination
|
||||
- `planned` Progress, CircularProgress, Meter
|
||||
- `planned` Drawer, ConfirmDialog, CommandPalette
|
||||
- `planned` ToastViewport, UploadProgress, LoadingOverlay
|
||||
- `planned` Table, DataGrid, VirtualList
|
||||
|
||||
## Layout And Navigation
|
||||
|
||||
- `ready` AppShell, AppHeader, AppSidebar, SidebarNavItem
|
||||
- `ready` Responsive mobile navigation
|
||||
- `planned` PageHeader, PageToolbar, StickyBar
|
||||
- `planned` SplitPane, ResizablePanel, ScrollContainer
|
||||
- `planned` InspectorLayout, EditorLayout, FullscreenLayout
|
||||
- `planned` UserMenu, NotificationCenter, CreditPopover
|
||||
|
||||
## Discovery And Home
|
||||
|
||||
- `ready` HeroCarousel, BannerCard, ToolEntryCard
|
||||
- `ready` CategoryTabs, FilterBar, SortControl
|
||||
- `ready` ArtworkCard, ArtworkGrid, ArtworkMeta, ArtworkActions
|
||||
- `planned` VideoPreview, MediaProtectionOverlay
|
||||
- `planned` InfiniteScrollTrigger, LoginPrompt
|
||||
- `planned` ArtworkDetailDrawer, ShareDialog, ReportDialog
|
||||
|
||||
## Models
|
||||
|
||||
- `planned` ModelCard, ModelCover, ModelStats, ModelTypeBadge
|
||||
- `planned` ModelFilterPanel, ModelSearchToolbar
|
||||
- `planned` ModelDetailHeader, ModelGallery, ModelVersionSelect
|
||||
- `planned` ModelExampleGrid, ModelLicensePanel, ModelUseButton
|
||||
- `planned` ModelComparison, ModelFavoriteButton
|
||||
|
||||
## AI Creation
|
||||
|
||||
- `planned` PromptEditor, NegativePromptInput, PromptPresetMenu
|
||||
- `planned` PromptEnhanceButton, PromptHistory
|
||||
- `planned` GenerationModeTabs, ModelPicker, LoRAPicker
|
||||
- `planned` ImageUploader, ReferenceImageList, ReferenceWeightControl
|
||||
- `planned` AspectRatioPicker, ResolutionPicker, BatchCountStepper
|
||||
- `planned` SeedInput, AdvancedSettings, CreditCost
|
||||
- `planned` GenerateButton, GenerationQueue, GenerationResultCard
|
||||
- `planned` GenerationComparison, RetryAction, DownloadAction
|
||||
|
||||
## Canvas Editor
|
||||
|
||||
- `planned` CanvasStage, CanvasViewport, CanvasToolbar
|
||||
- `planned` ZoomControls, HistoryControls, SelectionBox, TransformHandles
|
||||
- `planned` LayerPanel, LayerItem, LayerGroup, LayerContextMenu
|
||||
- `planned` PropertyPanel, AlignmentTools, DistributionTools
|
||||
- `planned` TextControls, ColorPicker, GradientEditor
|
||||
- `planned` ImageControls, MaskEditor, BrushControls, CropTool
|
||||
- `planned` AssetDropzone, Minimap, GuideRuler
|
||||
- `planned` ExportDialog, AutosaveIndicator, VersionHistory
|
||||
|
||||
## Rendering And 3D
|
||||
|
||||
- `planned` SceneViewport, SceneToolbar, CameraControls
|
||||
- `planned` LightingPanel, MaterialPanel, EnvironmentPicker
|
||||
- `planned` ObjectTree, ObjectInspector, TransformPanel
|
||||
- `planned` RenderSettings, RenderQueue, RenderResultCard
|
||||
- `planned` BeforeAfterViewer, AnglePresetPicker
|
||||
- `planned` ModelUpload, TextureUpload, SceneLoadingState
|
||||
|
||||
## Upscale
|
||||
|
||||
- `planned` UpscaleUploader, ScalePicker, EnhancementSettings
|
||||
- `planned` FaceEnhancementControl, DenoiseControl
|
||||
- `planned` ComparisonSlider, UpscaleTaskCard, DownloadResult
|
||||
|
||||
## Assets
|
||||
|
||||
- `planned` AssetGrid, AssetCard, AssetList
|
||||
- `planned` AssetFolderTree, AssetTypeFilter, AssetSearch
|
||||
- `planned` AssetUploader, UploadQueue, AssetDetailDrawer
|
||||
- `planned` AssetRenameDialog, MoveAssetDialog, DeleteAssetDialog
|
||||
- `planned` BatchActionBar, StorageUsage
|
||||
|
||||
## Account And Commerce
|
||||
|
||||
- `planned` LoginDialog, AuthCodeInput, SocialLoginButtons
|
||||
- `planned` ProfileHeader, ProfileTabs, ProfileEditor
|
||||
- `planned` CreditBalance, CreditHistory, CreditPackageCard
|
||||
- `planned` MembershipCard, BillingHistory, InvoiceForm
|
||||
- `planned` NotificationItem, NotificationPreferences
|
||||
- `planned` SettingsSection, ThemeSwitch, SecuritySettings
|
||||
|
||||
## Required States
|
||||
|
||||
Every interactive component must cover `default`, `hover`, `active`, `focus-visible`, `selected`, `disabled`, and `loading` where applicable. Data surfaces must additionally cover `loading`, `empty`, `error`, `success`, partial data, and retry behavior.
|
||||
|
||||
## Delivery Order
|
||||
|
||||
1. Foundations, primitives, feedback, shell, discovery.
|
||||
2. Models and AI creation flow.
|
||||
3. Task queue, results, assets, profile.
|
||||
4. Canvas editor primitives and editing workflows.
|
||||
5. Rendering, 3D, and upscale workflows.
|
||||
6. Account, credits, membership, notifications, settings.
|
||||
7. Accessibility audit, visual regression, performance budgets.
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>miragenflow-front</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "miragenflow-front",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.32.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"jsdom": "^30.0.1",
|
||||
"oxlint": "^1.75.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AppShell, type AppRoute } from './components/layout/AppShell'
|
||||
import { ComponentsPage } from './pages/ComponentsPage'
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { PlaceholderPage } from './pages/PlaceholderPage'
|
||||
|
||||
const routeTitles: Record<Exclude<AppRoute, 'home' | 'components'>, { title: string; description: string }> = {
|
||||
models: { title: '模型广场', description: '模型筛选、模型卡片、详情画廊、版本选择和许可信息将在此组合。' },
|
||||
create: { title: 'AI 创作', description: '提示词编辑器、模型选择、比例控制、生成参数和任务队列将在此组合。' },
|
||||
canvas: { title: '自由画布', description: '画布舞台、图层、属性面板、工具栏和导出流程将在此组合。' },
|
||||
assets: { title: '我的资产', description: '文件夹树、素材网格、上传队列、详情抽屉和批量操作将在此组合。' },
|
||||
}
|
||||
|
||||
function readRoute(): AppRoute {
|
||||
const value = window.location.hash.replace('#/', '') as AppRoute
|
||||
return ['home', 'components', 'models', 'create', 'canvas', 'assets'].includes(value) ? value : 'home'
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [route, setRoute] = useState<AppRoute>(readRoute)
|
||||
useEffect(() => {
|
||||
const onHashChange = () => setRoute(readRoute())
|
||||
window.addEventListener('hashchange', onHashChange)
|
||||
return () => window.removeEventListener('hashchange', onHashChange)
|
||||
}, [])
|
||||
const navigate = (next: AppRoute) => { window.location.hash = `/${next}`; setRoute(next) }
|
||||
|
||||
let page
|
||||
if (route === 'home') page = <HomePage />
|
||||
else if (route === 'components') page = <ComponentsPage />
|
||||
else page = <PlaceholderPage {...routeTitles[route]} onGoComponents={() => navigate('components')} />
|
||||
|
||||
return <AppShell route={route} onRouteChange={navigate}>{page}</AppShell>
|
||||
}
|
||||
|
||||
export default App
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 58 KiB |
@@ -0,0 +1,7 @@
|
||||
.card { position: relative; overflow: hidden; border: 1px solid var(--color-border-strong); border-radius: var(--radius-lg); background: linear-gradient(154deg,#333,#181818); break-inside: avoid; }
|
||||
.media { position: relative; min-height: 110px; overflow: hidden; background: #242427; }.media img { display: block; width: 100%; height: auto; min-height: 110px; object-fit: cover; transition: transform .38s var(--ease-standard), filter .3s ease; }.card:hover .media img { transform: scale(1.025); filter: brightness(.7); }
|
||||
.overlay { position: absolute; inset: 0; background: linear-gradient(180deg,rgba(0,0,0,.08),rgba(0,0,0,.82)); opacity: .72; }
|
||||
.top { position: absolute; z-index: 2; top: 10px; left: 10px; right: 10px; display: flex; align-items: center; justify-content: space-between; }.top > .badge { opacity: 0; transform: translateY(-4px); transition: all var(--duration-normal); }.card:hover .top > .badge { opacity: 1; transform: none; }
|
||||
.like { display: grid; place-items: center; width: 30px; height: 30px; border: 0; border-radius: 50%; background: rgba(16,16,20,.65); color: white; cursor: pointer; opacity: 0; transform: translateY(-4px); transition: all var(--duration-normal); }.card:hover .like { opacity: 1; transform: none; }.like svg { width: 15px; height: 15px; }
|
||||
.play { position: absolute; z-index: 3; top: 10px; right: 10px; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; background: rgba(0,0,0,.56); color: white; transition: opacity var(--duration-normal); }.play svg { width: 10px; height: 10px; }.card:hover .play { opacity: 0; }
|
||||
.bottom { position: absolute; z-index: 2; left: 10px; right: 10px; bottom: 10px; height: 32px; }.meta { position: absolute; inset: 0; display: flex; align-items: center; gap: 7px; color: #fff; font-size: 12px; transition: opacity var(--duration-fast); }.stats { display: flex; gap: 10px; margin-left: auto; color: #eee; }.stats span { display: flex; align-items: center; gap: 3px; }.stats svg { width: 13px; height: 13px; }.bottom > button { position: absolute; inset: 0; width: 100%; opacity: 0; box-shadow: none; transform: translateY(32px); transition: opacity var(--duration-normal), transform var(--duration-slow) var(--ease-spring); }.card:hover .meta { opacity: 0; }.card:hover .bottom > button { opacity: 1; transform: none; }
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Copy, Heart, Play, Sparkles } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '../primitives/Button'
|
||||
import { Badge } from '../primitives/Selection'
|
||||
import { Avatar } from '../layout/Avatar'
|
||||
import type { Artwork } from '../../data/artworks'
|
||||
import styles from './ArtworkCard.module.css'
|
||||
|
||||
export function ArtworkCard({ artwork, onUse }: { artwork: Artwork; onUse?: (artwork: Artwork) => void }) {
|
||||
const [liked, setLiked] = useState(false)
|
||||
return <article className={styles.card}>
|
||||
<div className={styles.media}><img src={artwork.image} alt={artwork.title} loading="lazy" />{artwork.video && <span className={styles.play}><Play fill="currentColor" /></span>}<div className={styles.overlay} /></div>
|
||||
<div className={styles.top}><Badge tone="info">{artwork.category}</Badge><button type="button" className={styles.like} aria-label={liked ? '取消收藏' : '收藏'} onClick={() => setLiked(value => !value)}><Heart fill={liked ? 'currentColor' : 'none'} /></button></div>
|
||||
<div className={styles.bottom}><div className={styles.meta}><Avatar size="xs" fallback={artwork.author.slice(0, 1)} alt={`${artwork.author}头像`} /><span>{artwork.author}</span><span className={styles.stats}><span><Heart />{artwork.likes}</span><span><Copy />{artwork.uses}</span></span></div><Button size="sm" variant="primary" leadingIcon={<Sparkles />} onClick={() => onUse?.(artwork)}>一键同款</Button></div>
|
||||
</article>
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
.grid { display: grid; grid-template-columns: repeat(var(--column-count),minmax(0,1fr)); gap: 10px; align-items: start; }.column { display: flex; min-width: 0; flex-direction: column; gap: 10px; }
|
||||
@media (max-width: 1439px) { .grid { gap: 8px; }.column { gap: 8px; } }
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from 'react'
|
||||
import type { Artwork } from '../../data/artworks'
|
||||
import { ArtworkCard } from './ArtworkCard'
|
||||
import styles from './ArtworkGrid.module.css'
|
||||
|
||||
export function ArtworkGrid({ artworks, onUse }: { artworks: Artwork[]; onUse?: (artwork: Artwork) => void }) {
|
||||
const getColumnCount = () => window.innerWidth >= 1440 ? 5 : window.innerWidth > 720 ? 4 : 2
|
||||
const [columnCount, setColumnCount] = useState(getColumnCount)
|
||||
useEffect(() => {
|
||||
const onResize = () => setColumnCount(getColumnCount())
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [])
|
||||
const columns = useMemo(() => Array.from({ length: columnCount }, (_, index) => artworks.filter((_, itemIndex) => itemIndex % columnCount === index)), [artworks, columnCount])
|
||||
return <div className={styles.grid} style={{ '--column-count': columnCount } as CSSProperties}>{columns.map((column, index) => <div className={styles.column} key={index}>{column.map(artwork => <ArtworkCard key={artwork.id} artwork={artwork} onUse={onUse} />)}</div>)}</div>
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.layout { display: grid; grid-template-columns: minmax(380px,2fr) 260px minmax(260px,1.2fr); gap: 12px; height: 160px; }.hero,.side,.tools { min-width: 0; }.hero { position: relative; overflow: hidden; border-radius: var(--radius-lg); background: #2a2a30; }.track { display: flex; height: 100%; transition: transform .55s var(--ease-standard); }.track img { width: 100%; height: 100%; flex: 0 0 100%; object-fit: cover; }.arrows { position: absolute; z-index: 2; top: 64px; left: 12px; right: 12px; display: flex; justify-content: space-between; opacity: 0; transition: opacity var(--duration-normal); }.hero:hover .arrows { opacity: 1; }.dots { position: absolute; left: 0; right: 0; bottom: 12px; z-index: 2; display: flex; justify-content: center; gap: 5px; }.dots button { width: 6px; height: 6px; padding: 0; border: 0; border-radius: 999px; background: rgba(255,255,255,.48); cursor: pointer; transition: width var(--duration-normal); }.dots .activeDot { width: 20px; background: #fff; }
|
||||
.side,.tools { display: flex; flex-direction: column; gap: 8px; }.side img { width: 100%; height: 76px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); object-fit: cover; }.tool { display: flex; align-items: center; gap: 12px; height: 76px; padding: 8px 13px; border: 1px solid rgba(67,142,255,.45); border-radius: var(--radius-lg); background: linear-gradient(205deg,rgba(68,142,255,.20),rgba(112,206,255,.04) 66%,rgba(178,233,255,.18)); cursor: pointer; text-align: left; transition: transform var(--duration-normal), border-color var(--duration-normal); }.tool:hover { border-color: rgba(97,171,255,.75); transform: translateY(-1px); }.tool img { width: 44px; height: 44px; object-fit: contain; }.tool span { display: flex; min-width: 0; flex-direction: column; gap: 4px; }.tool strong { color: #fff; font-size: 14px; white-space: nowrap; }.tool small { overflow: hidden; color: #a9a8b3; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@media (max-width: 1040px) { .layout { grid-template-columns: minmax(360px,2fr) 220px; }.tools { display: none; } }
|
||||
@media (max-width: 720px) { .layout { height: auto; grid-template-columns: 1fr; }.hero { height: 180px; }.side { display: grid; grid-template-columns: 1fr 1fr; }.tools { display: grid; grid-template-columns: 1fr 1fr; }.tool { min-width: 0; } }
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { IconButton } from '../primitives/IconButton'
|
||||
import styles from './HeroCarousel.module.css'
|
||||
import heroOne from '../../assets/holopix/banner-main.webp'
|
||||
import heroTwo from '../../assets/holopix/banner-main-2.webp'
|
||||
import heroThree from '../../assets/holopix/banner-main-3.webp'
|
||||
import sideOne from '../../assets/holopix/banner-side-1.webp'
|
||||
import sideTwo from '../../assets/holopix/banner-side-2.webp'
|
||||
import board from '../../assets/holopix/board.webp'
|
||||
import explore from '../../assets/holopix/explore.webp'
|
||||
|
||||
export function HeroCarousel() {
|
||||
const [index, setIndex] = useState(0)
|
||||
const slides = [heroOne, heroTwo, heroThree]
|
||||
const change = (next: number) => setIndex((next + slides.length) % slides.length)
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setIndex(current => (current + 1) % slides.length), 6000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [slides.length])
|
||||
return <section className={styles.layout}>
|
||||
<div className={styles.hero}><div className={styles.track} style={{ transform: `translateX(-${index * 100}%)` }}>{slides.map((src, itemIndex) => <img key={src} src={src} alt={`首页宣传 ${itemIndex + 1}`} />)}</div><div className={styles.arrows}><IconButton label="上一张" icon={<ChevronLeft />} size="sm" onClick={() => change(index - 1)} /><IconButton label="下一张" icon={<ChevronRight />} size="sm" onClick={() => change(index + 1)} /></div><div className={styles.dots}>{slides.map((src, itemIndex) => <button type="button" key={src} className={itemIndex === index ? styles.activeDot : ''} aria-label={`第 ${itemIndex + 1} 张`} onClick={() => change(itemIndex)} />)}</div></div>
|
||||
<div className={styles.side}><img src={sideOne} alt="模型活动" /><img src={sideTwo} alt="3D 工具" /></div>
|
||||
<div className={styles.tools}><ToolEntry image={board} title="自由画布" description="自由创作,角色、UI、场景全覆盖" /><ToolEntry image={explore} title="一键成稿" description="文生图、图生图工作流" /></div>
|
||||
</section>
|
||||
}
|
||||
|
||||
function ToolEntry({ image, title, description }: { image: string; title: string; description: string }) {
|
||||
return <button type="button" className={styles.tool}><img src={image} alt="" /><span><strong>{title}</strong><small>{description}</small></span></button>
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
.shell { height: 100vh; overflow: hidden; background: var(--color-bg); }
|
||||
.header { position: fixed; inset: 0 0 auto; z-index: var(--z-header); height: var(--header-height); display: flex; align-items: center; gap: 16px; padding: 0 32px 0 20px; border-bottom: 1px solid var(--color-border); background: rgba(24,24,27,.94); backdrop-filter: blur(18px); }
|
||||
.brand { width: 172px; flex: 0 0 auto; display: flex; align-items: center; gap: 9px; color: #fff; font-size: 17px; letter-spacing: -.3px; }
|
||||
.brand em { background: var(--gradient-brand); background-clip: text; color: transparent; font-style: normal; }
|
||||
.brandMark { position: relative; width: 20px; height: 20px; }
|
||||
.brandMark i { position: absolute; top: 2px; width: 8px; height: 16px; border-radius: var(--radius-sm); background: var(--gradient-brand-strong); }
|
||||
.brandMark i:first-child { left: 0; clip-path: polygon(0 20%,100% 0,100% 80%,0 100%); }
|
||||
.brandMark i:last-child { right: 0; background: linear-gradient(180deg,#75d1ff,#2878ff); clip-path: polygon(0 0,100% 20%,100% 100%,0 80%); }
|
||||
.headerSearch { width: min(460px, 42vw); display: flex; align-items: center; gap: 8px; }
|
||||
.headerSearch > span { flex: 1; min-width: 0; }
|
||||
.headerSearch button { flex: 0 0 auto; }
|
||||
.headerSearch input { height: 36px; }
|
||||
.headerActions { margin-left: auto; display: flex; align-items: center; gap: 12px; }
|
||||
.headerAction { position: relative; display: inline-flex; align-items: center; }
|
||||
.headerAction::after { content: ""; position: absolute; top: 100%; right: 0; width: 100%; height: 12px; }
|
||||
.noticeButton { width: 36px; height: 36px; border-radius: 40px; border: 1px solid rgba(235, 235, 245, .08); background: rgba(235, 235, 245, .08); color: #fff; }
|
||||
.noticeButton:hover { border-color: rgba(235, 235, 245, .08); background: rgba(235, 235, 245, .08); color: #fff; }
|
||||
.loginButton { width: 168px; height: 36px; display: flex; align-items: center; justify-content: center; padding: 0; border: 0; background-size: contain; background-repeat: no-repeat; background-color: transparent; cursor: pointer; }
|
||||
.loginButton img { display: block; width: 136px; height: 20px; }
|
||||
.headerDivider { width: 1px; height: 12px; border-radius: 2px; background: rgba(235, 235, 245, .15); }
|
||||
.chevron { width: 15px; color: var(--color-text-subtle); }
|
||||
.avatarButton { width: 70px; height: 36px; display: flex; align-items: center; padding: 0; border: 0; background: transparent; cursor: pointer; }
|
||||
.headerAvatar { width: 36px; height: 36px; margin: 0 6px; overflow: hidden; border: 1px solid #fff; border-radius: 32px; object-fit: cover; }
|
||||
.avatarButton .chevron { width: 16px; height: 16px; margin-left: 6px; color: #fff; }
|
||||
.headerPopover { position: absolute; z-index: var(--z-popover); top: calc(100% + 4px); right: 0; visibility: hidden; opacity: 0; pointer-events: none; transform: scale(.96); transform-origin: top right; transition: opacity .18s ease, transform .18s ease, visibility 0s linear .18s; }
|
||||
.headerAction:hover > .headerPopover,.headerAction:focus-within > .headerPopover { visibility: visible; opacity: 1; pointer-events: auto; transform: translateY(0) scale(1); transition-delay: 0s; }
|
||||
.noticePopover { width: 418px; height: 700px; overflow: hidden; border: 1px solid rgba(235, 235, 245, .08); border-radius: 24px; background: #2c2c2e; box-shadow: 0 0 16px 8px rgba(0, 0, 0, .32); }
|
||||
.noticeHeader { box-sizing: content-box; height: 38px; display: flex; align-items: center; justify-content: space-between; padding: 12px 12px 12px 24px; border-bottom: 1px solid rgba(235, 235, 245, .08); color: #fff; font-size: 16px; font-weight: 500; }
|
||||
.noticeHeader strong { font: inherit; }
|
||||
.noticeHeader button { height: 38px; padding: 10px 24px; border: 1px solid rgba(235, 235, 245, .08); border-radius: 12px; background: rgba(235, 235, 245, .15); color: #fff; font-size: 13px; font-weight: 400; }
|
||||
.noticeHeader button:disabled { border-color: transparent; background: transparent; color: rgba(235, 235, 245, .6); }
|
||||
.noticeContent { height: calc(100% - 63px); display: flex; align-items: center; justify-content: center; padding: 8px 0 16px; color: rgba(235, 235, 245, .6); font-size: 14px; }
|
||||
.rewardPopover { top: calc(100% + 12px); width: min(450px, 92vw); aspect-ratio: 450 / 507; }
|
||||
.loginRewardPanel { display: block; width: 100%; height: 100%; object-fit: contain; }
|
||||
.loginRewardHitArea { position: absolute; left: 5.333%; bottom: 5.917%; width: 89.333%; height: 12.229%; min-height: 40px; border: 0; border-radius: 12px; background: transparent; cursor: pointer; opacity: 0; }
|
||||
.profilePopover { right: -32px; width: 286px; margin-top: 10px; padding: 8px; overflow: hidden; border: 1px solid rgba(235, 235, 245, .08); border-radius: 24px; background: #1c1c1e; box-shadow: 0 0 16px 8px rgba(0, 0, 0, .32); }
|
||||
.visitorBody { display: flex; flex-direction: column; gap: 8px; background: url("../../assets/holopix/visitor-card-bg.png") center / cover no-repeat; }
|
||||
.visitorInfo { height: 59px; display: flex; align-items: center; column-gap: 12px; padding: 8px 16px 8px 0; border-radius: 12px; }
|
||||
.visitorInfo img { width: 40px; height: 40px; flex: 0 0 auto; border: 1px solid #fff; border-radius: 50%; object-fit: cover; }
|
||||
.visitorInfo span { min-width: 0; overflow: hidden; color: #fff; font-size: 14px; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.visitorPanel { display: block; width: 268px; height: auto; cursor: pointer; }
|
||||
.mobileMenu { display: none; }
|
||||
.sidebar { position: fixed; z-index: 19; inset: var(--header-height) auto 0 0; width: var(--sidebar-width); display: flex; flex-direction: column; padding: 18px 10px 12px; border-right: 1px solid var(--color-border); background: var(--color-bg); }
|
||||
.nav { display: flex; flex-direction: column; gap: 3px; }
|
||||
.navLabel { margin: 4px 12px 7px; color: #5f5e67; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.navLabel:not(:first-child) { margin-top: 22px; }
|
||||
.navItem { position: relative; display: flex; align-items: center; gap: 11px; width: 100%; height: 40px; padding: 0 12px; border: 0; border-radius: var(--radius-lg); background: transparent; color: var(--color-text-muted); cursor: pointer; text-align: left; transition: all var(--duration-normal); }
|
||||
.navItem svg { width: 17px; height: 17px; color: #8c8995; }
|
||||
.navItem:hover { background: var(--color-surface-raised); color: #fff; }
|
||||
.navItem.active { background: var(--color-primary-soft); color: #fff; font-weight: 600; }
|
||||
.navItem.active svg { color: #75d1ff; }
|
||||
.navItem small { position: absolute; right: 7px; padding: 2px 5px; border: 1px solid #477bc5; border-radius: var(--radius-md); color: #8fc9ff; font-size: 9px; }
|
||||
.sidebarBottom { margin-top: auto; }
|
||||
.community { display: flex; align-items: center; gap: 8px; padding: 10px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface); color: #9e9ba7; }
|
||||
.community > svg:first-child { width: 16px; color: #75d1ff; flex: 0 0 auto; }.community > svg:last-child { width: 13px; margin-left: auto; }
|
||||
.community span { display: flex; flex-direction: column; min-width: 0; }.community strong { color: #e5e3e9; font-size: 11px; white-space: nowrap; }.community small { margin-top: 2px; font-size: 10px; white-space: nowrap; }
|
||||
.sideLinks { display: flex; flex-direction: column; gap: 2px; margin-top: 14px; }.sideLinks button { display: flex; align-items: center; gap: 9px; padding: 5px 9px; border: 0; background: transparent; color: #777580; cursor: pointer; font-size: 11px; text-align: left; }.sideLinks button:hover { color: #fff; }.sideLinks svg { width: 14px; }
|
||||
.legal { margin: 14px 2px 0; color: #4f4e57; font-size: 10px; line-height: 1.6; }
|
||||
.main { height: 100vh; padding: calc(var(--header-height) + 18px) 20px 36px calc(var(--sidebar-width) + 20px); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; }
|
||||
@media (max-width: 900px) {
|
||||
body { overflow: auto; }
|
||||
.header { gap: 10px; padding: 0 12px; }.brand { width: auto; }.headerSearch { flex: 1; width: auto; }.headerSearch button { display: none; }.headerDivider { display: none; }.headerActions { gap: 6px; }.profilePopover { right: -12px; }.mobileMenu { display: inline-grid; }
|
||||
.sidebar { transform: translateX(-100%); box-shadow: var(--shadow-popover); transition: transform var(--duration-slow) var(--ease-standard); }.sidebarOpen { transform: translateX(0); }
|
||||
.main { padding-left: 16px; padding-right: 16px; }
|
||||
}
|
||||
@media (max-width: 560px) { .brand strong { display: none; }.noticeAction { display: none; }.main { padding-left: 12px; padding-right: 12px; }.rewardPopover,.profilePopover { position: fixed; top: var(--header-height); right: 8px; } }
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Bell, ChevronDown, CircleUserRound, Folder, Grid2X2, Home, Layers3, LogOut, Menu, MessageCircle, Palette, Search, Settings2, Sparkles, WandSparkles } from 'lucide-react'
|
||||
import { Button } from '../primitives/Button'
|
||||
import { IconButton } from '../primitives/IconButton'
|
||||
import { SearchInput } from '../primitives/FormControls'
|
||||
import styles from './AppShell.module.css'
|
||||
import avatarImage from '../../assets/holopix/avatar.png'
|
||||
import loginRewardAnimation from '../../assets/holopix/login-reward.gif'
|
||||
import loginRewardLabel from '../../assets/holopix/login-reward-label.svg'
|
||||
import loginRewardPanel from '../../assets/holopix/login-reward-panel.webp'
|
||||
import visitorPanel from '../../assets/holopix/visitor-panel.png'
|
||||
|
||||
export type AppRoute = 'home' | 'components' | 'models' | 'create' | 'canvas' | 'assets'
|
||||
|
||||
interface AppShellProps { route: AppRoute; onRouteChange: (route: AppRoute) => void; children: ReactNode }
|
||||
|
||||
const primaryNav: Array<{ id: AppRoute; label: string; icon: typeof Home; badge?: string }> = [
|
||||
{ id: 'home', label: '首页', icon: Home },
|
||||
{ id: 'models', label: '模型广场', icon: Grid2X2 },
|
||||
{ id: 'create', label: 'AI 创作', icon: WandSparkles },
|
||||
{ id: 'canvas', label: '自由画布', icon: Layers3, badge: '热门' },
|
||||
{ id: 'assets', label: '我的资产', icon: Folder },
|
||||
]
|
||||
|
||||
export function AppShell({ route, onRouteChange, children }: AppShellProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
return <div className={styles.shell}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.brand}><span className={styles.brandMark}><i /><i /></span><strong>Miragen <em>Flow</em></strong></div>
|
||||
<IconButton className={styles.mobileMenu} label="打开导航" icon={<Menu />} onClick={() => setSidebarOpen(value => !value)} />
|
||||
<div className={styles.headerSearch}><SearchInput value={search} onChange={event => setSearch(event.target.value)} placeholder="搜索作品、模型或风格" /><Button size="sm" variant="primary" leadingIcon={<Search />}>找灵感</Button></div>
|
||||
<div className={styles.headerActions}>
|
||||
<div className={`${styles.headerAction} ${styles.noticeAction}`}>
|
||||
<IconButton className={styles.noticeButton} label="通知" icon={<Bell />} aria-haspopup="dialog" />
|
||||
<div className={`${styles.headerPopover} ${styles.noticePopover}`} role="dialog" aria-label="通知消息">
|
||||
<div className={styles.noticeHeader}><strong>通知</strong><button type="button" disabled>全部已读</button></div>
|
||||
<div className={styles.noticeContent}><span>暂无通知</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.headerAction} ${styles.loginAction}`}>
|
||||
<button type="button" className={styles.loginButton} style={{ backgroundImage: `url(${loginRewardAnimation})` }} aria-label="登录,免费领算力" aria-haspopup="dialog"><img src={loginRewardLabel} alt="登录 免费领算力" /></button>
|
||||
<div className={`${styles.headerPopover} ${styles.rewardPopover}`} role="dialog" aria-label="登录领取算力">
|
||||
<img className={styles.loginRewardPanel} src={loginRewardPanel} alt="注册即送专属算力" />
|
||||
<button type="button" className={styles.loginRewardHitArea} aria-label="立即登录领取" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className={styles.headerDivider} />
|
||||
|
||||
<div className={`${styles.headerAction} ${styles.profileAction}`}>
|
||||
<button type="button" className={styles.avatarButton} aria-label="用户菜单" aria-haspopup="dialog">
|
||||
<img className={styles.headerAvatar} src={avatarImage} alt="用户头像" />
|
||||
<ChevronDown className={styles.chevron} />
|
||||
</button>
|
||||
<div className={`${styles.headerPopover} ${styles.profilePopover}`} role="dialog" aria-label="用户菜单">
|
||||
<div className={styles.visitorBody}>
|
||||
<div className={styles.visitorInfo}><img src={avatarImage} alt="用户头像" /><span>游客 5ebe1365e9fdd2b7</span></div>
|
||||
<img className={styles.visitorPanel} src={visitorPanel} alt="注册领取新手大礼包" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<aside className={`${styles.sidebar} ${sidebarOpen ? styles.sidebarOpen : ''}`}>
|
||||
<nav className={styles.nav} aria-label="主导航">
|
||||
<p className={styles.navLabel}>工作台</p>
|
||||
{primaryNav.map(item => { const Icon = item.icon; return <button key={item.id} type="button" className={`${styles.navItem} ${route === item.id ? styles.active : ''}`} onClick={() => { onRouteChange(item.id); setSidebarOpen(false) }}><Icon /><span>{item.label}</span>{item.badge && <small>{item.badge}</small>}</button> })}
|
||||
<p className={styles.navLabel}>资源</p>
|
||||
<button type="button" className={styles.navItem}><Palette /><span>模型定制</span></button>
|
||||
<button type="button" className={styles.navItem}><Sparkles /><span>AI 学院</span></button>
|
||||
</nav>
|
||||
<div className={styles.sidebarBottom}><div className={styles.community}><MessageCircle /><span><strong>加入设计交流群</strong><small>与创作者一起交流</small></span><ChevronDown /></div><div className={styles.sideLinks}><button type="button" onClick={() => onRouteChange('components')}><Settings2 />组件中心</button><button type="button"><CircleUserRound />个人主页</button><button type="button"><LogOut />退出登录</button></div><p className={styles.legal}>©2026 Miragen Flow<br />用户协议 · 隐私协议</p></div>
|
||||
</aside>
|
||||
<main className={styles.main}>{children}</main>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
.avatar { display: inline-grid; place-items: center; flex: 0 0 auto; overflow: hidden; border: 0; border-radius: 50%; outline: 0; box-shadow: none; object-fit: cover; background: linear-gradient(135deg,#2878ff,#75d1ff); color: white; font-size: 10px; font-weight: 700; }
|
||||
.xs { width: 20px; height: 20px; }.sm { width: 28px; height: 28px; }.md { width: 34px; height: 34px; }.lg { width: 48px; height: 48px; font-size: 14px; }
|
||||
@@ -0,0 +1,6 @@
|
||||
import clsx from 'clsx'
|
||||
import styles from './Avatar.module.css'
|
||||
|
||||
export function Avatar({ src, alt, size = 'sm', fallback = 'MF' }: { src?: string; alt: string; size?: 'xs' | 'sm' | 'md' | 'lg'; fallback?: string }) {
|
||||
return src ? <img className={clsx(styles.avatar, styles[size])} src={src} alt={alt} /> : <span className={clsx(styles.avatar, styles[size], styles.fallback)} aria-label={alt}>{fallback}</span>
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
transition: background var(--duration-normal), border-color var(--duration-normal), color var(--duration-normal), transform var(--duration-fast);
|
||||
}
|
||||
.button:active:not(:disabled) { transform: translateY(1px); }
|
||||
.button:disabled { cursor: not-allowed; opacity: 0.45; }
|
||||
.sm { height: 32px; padding: 0 12px; font-size: 12px; border-radius: var(--radius-lg); }
|
||||
.md { height: 36px; padding: 0 16px; font-size: 13px; }
|
||||
.lg { height: 40px; padding: 0 20px; font-size: 14px; }
|
||||
.primary {
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
background: linear-gradient(90deg, #3286ff, #63c8ff);
|
||||
background-clip: padding-box;
|
||||
color: #fff;
|
||||
box-shadow: none;
|
||||
}
|
||||
.primary:hover:not(:disabled) { filter: brightness(1.08); }
|
||||
.secondary { background: var(--color-surface-raised); border-color: var(--color-border); color: var(--color-text); }
|
||||
.secondary:hover:not(:disabled) { background: var(--color-surface-hover); border-color: var(--color-border-strong); }
|
||||
.ghost { background: transparent; color: var(--color-text-muted); }
|
||||
.ghost:hover:not(:disabled) { background: var(--color-surface-raised); color: var(--color-text); }
|
||||
.danger { background: rgba(255, 102, 122, 0.12); border-color: rgba(255, 102, 122, 0.28); color: #ff9caa; }
|
||||
.danger:hover:not(:disabled) { background: rgba(255, 102, 122, 0.18); }
|
||||
.fullWidth { width: 100%; }
|
||||
.button svg { width: 16px; height: 16px; flex: 0 0 auto; }
|
||||
.spinner { animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import { LoaderCircle } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import styles from './Button.module.css'
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
leadingIcon?: ReactNode
|
||||
trailingIcon?: ReactNode
|
||||
loading?: boolean
|
||||
fullWidth?: boolean
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
leadingIcon,
|
||||
trailingIcon,
|
||||
loading = false,
|
||||
fullWidth = false,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={clsx(styles.button, styles[variant], styles[size], fullWidth && styles.fullWidth, className)}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? <LoaderCircle className={styles.spinner} aria-hidden="true" /> : leadingIcon}
|
||||
<span>{children}</span>
|
||||
{!loading && trailingIcon}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.spinner { width: 24px; height: 24px; display: inline-grid; place-items: center; }
|
||||
.spinner > span { width: 20px; height: 20px; border: 2px solid rgba(117,209,255,.2); border-top-color: #75d1ff; border-radius: 50%; animation: spin .7s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.skeleton { display: block; width: 100%; border-radius: var(--radius-lg); background: linear-gradient(100deg,#242429 20%,#303038 36%,#242429 52%); background-size: 220% 100%; animation: shimmer 1.5s infinite; }
|
||||
@keyframes shimmer { to { background-position-x: -220%; } }
|
||||
.empty { min-height: 210px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px dashed var(--color-border-strong); border-radius: var(--radius-xl); color: var(--color-text-subtle); text-align: center; }
|
||||
.empty > svg { width: 28px; color: #5e5d67; }
|
||||
.empty strong { color: var(--color-text); font-size: 14px; }
|
||||
.empty span { font-size: 12px; }
|
||||
.backdrop { position: fixed; inset: 0; z-index: var(--z-dialog); display: grid; place-items: center; padding: 24px; background: rgba(4,7,12,.7); backdrop-filter: blur(4px); }
|
||||
.dialog { width: min(440px,100%); overflow: hidden; border: 1px solid var(--color-border-strong); border-radius: var(--radius-xl); background: #202025; box-shadow: var(--shadow-popover); animation: enter var(--duration-slow) var(--ease-standard); }
|
||||
@keyframes enter { from { opacity: 0; transform: translateY(10px) scale(.98); } }
|
||||
.dialog header { display: flex; align-items: flex-start; justify-content: space-between; padding: 18px 18px 14px; border-bottom: 1px solid var(--color-border); }
|
||||
.dialog header > span { display: flex; flex-direction: column; gap: 3px; }
|
||||
.dialog header strong { font-size: 16px; }
|
||||
.dialog header small { color: var(--color-text-muted); }
|
||||
.dialogBody { padding: 18px; color: var(--color-text-muted); }
|
||||
.dialog footer { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 18px; background: #1b1b1f; }
|
||||
.alert { display: flex; align-items: center; gap: 9px; min-height: 40px; padding: 9px 12px; border: 1px solid rgba(40,120,255,.25); border-radius: var(--radius-lg); background: rgba(40,120,255,.1); color: #a9ddff; }
|
||||
.alert[data-tone="danger"] { border-color: rgba(255,102,122,.25); background: rgba(255,102,122,.1); color: #ffadb8; }
|
||||
.alert svg { width: 17px; flex: 0 0 auto; }
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertCircle, CheckCircle2, Inbox, X } from 'lucide-react'
|
||||
import { Button } from './Button'
|
||||
import { IconButton } from './IconButton'
|
||||
import styles from './Feedback.module.css'
|
||||
|
||||
export function Spinner({ label = '加载中' }: { label?: string }) {
|
||||
return <span className={styles.spinner} role="status"><span /> <span className="sr-only">{label}</span></span>
|
||||
}
|
||||
|
||||
export function Skeleton({ height = 80 }: { height?: number }) {
|
||||
return <span className={styles.skeleton} style={{ height }} aria-hidden="true" />
|
||||
}
|
||||
|
||||
export function EmptyState({ title = '暂无内容', description = '新的内容会显示在这里', action }: { title?: string; description?: string; action?: ReactNode }) {
|
||||
return <div className={styles.empty}><Inbox /><strong>{title}</strong><span>{description}</span>{action}</div>
|
||||
}
|
||||
|
||||
interface DialogProps { open: boolean; title: string; description?: string; onClose: () => void; children?: ReactNode }
|
||||
export function Dialog({ open, title, description, onClose, children }: DialogProps) {
|
||||
if (!open) return null
|
||||
return <div className={styles.backdrop} role="presentation" onMouseDown={onClose}><section className={styles.dialog} role="dialog" aria-modal="true" aria-labelledby="dialog-title" onMouseDown={event => event.stopPropagation()}><header><span><strong id="dialog-title">{title}</strong>{description && <small>{description}</small>}</span><IconButton label="关闭" icon={<X />} onClick={onClose} /></header><div className={styles.dialogBody}>{children}</div><footer><Button variant="ghost" onClick={onClose}>取消</Button><Button variant="primary" leadingIcon={<CheckCircle2 />}>确认</Button></footer></section></div>
|
||||
}
|
||||
|
||||
export function InlineAlert({ tone = 'info', children }: { tone?: 'info' | 'danger'; children: ReactNode }) {
|
||||
return <div className={styles.alert} data-tone={tone}>{tone === 'danger' ? <AlertCircle /> : <CheckCircle2 />}{children}</div>
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
.field { display: flex; flex-direction: column; gap: 7px; color: var(--color-text-muted); }
|
||||
.label { color: var(--color-text); font-size: 13px; font-weight: 600; }
|
||||
.hint { color: var(--color-text-subtle); font-size: 12px; }
|
||||
.error { color: #ff8c9c; }
|
||||
.inputWrap { height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 12px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface-raised); transition: border-color var(--duration-normal), box-shadow var(--duration-normal); }
|
||||
.inputWrap:focus-within { border-color: var(--color-primary-border); box-shadow: 0 0 0 3px rgba(40,120,255,.12); }
|
||||
.inputWrap > svg { width: 17px; color: var(--color-text-subtle); }
|
||||
.input { width: 100%; min-width: 0; padding: 0; border: 0; outline: 0; background: transparent; color: var(--color-text); }
|
||||
.input::placeholder { color: var(--color-text-subtle); }
|
||||
.invalid { border-color: rgba(255,102,122,.6); }
|
||||
.select { height: 38px; padding: 0 34px 0 12px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface-raised); color: var(--color-text); outline: 0; }
|
||||
.select:focus-visible { border-color: var(--color-primary-border); }
|
||||
.switchLabel { display: inline-flex; align-items: center; gap: 10px; color: var(--color-text-muted); cursor: pointer; }
|
||||
.switch { position: relative; width: 38px; height: 22px; }
|
||||
.switch input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.switchTrack { position: absolute; inset: 0; border-radius: 999px; background: #3c3c43; transition: background var(--duration-normal); }
|
||||
.switchThumb { position: absolute; left: 3px; top: 3px; width: 16px; height: 16px; display: grid; place-items: center; border-radius: 50%; background: #fff; color: var(--color-primary); transition: transform var(--duration-normal) var(--ease-spring); }
|
||||
.switchThumb svg { width: 10px; opacity: 0; }
|
||||
.switch input:checked + .switchTrack { background: var(--color-primary); }
|
||||
.switch input:checked + .switchTrack .switchThumb { transform: translateX(16px); }
|
||||
.switch input:checked + .switchTrack .switchThumb svg { opacity: 1; }
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from 'react'
|
||||
import { Check, Search } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import styles from './FormControls.module.css'
|
||||
|
||||
interface FieldProps {
|
||||
label?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Field({ label, hint, error, children }: FieldProps) {
|
||||
return <label className={styles.field}>{label && <span className={styles.label}>{label}</span>}{children}{(hint || error) && <span className={clsx(styles.hint, error && styles.error)}>{error ?? hint}</span>}</label>
|
||||
}
|
||||
|
||||
interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
invalid?: boolean
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
export function TextInput({ invalid, icon, className, ...props }: TextInputProps) {
|
||||
return <span className={clsx(styles.inputWrap, invalid && styles.invalid, className)}>{icon}<input className={styles.input} {...props} /></span>
|
||||
}
|
||||
|
||||
export function SearchInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <TextInput icon={<Search aria-hidden="true" />} {...props} />
|
||||
}
|
||||
|
||||
export function Select({ className, children, ...props }: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return <select className={clsx(styles.select, className)} {...props}>{children}</select>
|
||||
}
|
||||
|
||||
interface SwitchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> { label?: string }
|
||||
export function Switch({ label, className, ...props }: SwitchProps) {
|
||||
return <label className={clsx(styles.switchLabel, className)}><span className={styles.switch}><input type="checkbox" {...props} /><span className={styles.switchTrack}><span className={styles.switchThumb}><Check /></span></span></span>{label && <span>{label}</span>}</label>
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.button {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-normal);
|
||||
}
|
||||
.button:hover { border-color: var(--color-border-strong); background: var(--color-surface-hover); color: #fff; }
|
||||
.button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.button svg { width: 17px; height: 17px; }
|
||||
.sm { width: 32px; height: 32px; }
|
||||
.md { width: 36px; height: 36px; }
|
||||
.selected { background: var(--color-primary-soft); border-color: var(--color-primary-border); color: #86c7ff; }
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import styles from './IconButton.module.css'
|
||||
|
||||
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
label: string
|
||||
icon: ReactNode
|
||||
selected?: boolean
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
export function IconButton({ label, icon, selected, size = 'md', className, ...props }: IconButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(styles.button, styles[size], selected && styles.selected, className)}
|
||||
aria-label={label}
|
||||
aria-pressed={selected}
|
||||
title={label}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.tabs { min-width: 0; display: flex; align-items: flex-end; gap: 28px; border-bottom: 1px solid var(--color-border); overflow-x: auto; scrollbar-width: none; }
|
||||
.tabs::-webkit-scrollbar { display: none; }
|
||||
.tab { position: relative; height: 44px; flex: 0 0 auto; padding: 0 0 8px; border: 0; background: transparent; color: #8d8a96; cursor: pointer; font-family: var(--font-brand); font-size: 16px; font-weight: 500; transform: scale(.95); transform-origin: left bottom; transition: color var(--duration-normal), transform var(--duration-slow) var(--ease-spring); }
|
||||
.tab:hover { color: #c9c3d8; }
|
||||
.tab:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.activeTab { background: var(--gradient-brand); background-clip: text; color: transparent; font-weight: 600; transform: scale(1); }
|
||||
.activeTab::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 2px; border-radius: 2px; background: var(--gradient-brand-strong); }
|
||||
.chip { height: 32px; flex: 0 0 auto; padding: 0 15px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: #1a1920; color: var(--color-text-muted); cursor: pointer; font-size: 13px; transition: all var(--duration-normal); }
|
||||
.chip:hover { border-color: var(--color-border-strong); color: #fff; }
|
||||
.selectedChip { border-color: var(--color-primary-border); background: var(--color-primary-soft); color: #fff; font-weight: 600; }
|
||||
.badge { display: inline-flex; align-items: center; min-height: 22px; padding: 2px 8px; border: 1px solid var(--color-border); border-radius: var(--radius-md); font-size: 11px; font-weight: 600; }
|
||||
.neutral { color: var(--color-text-muted); background: var(--color-surface-raised); }
|
||||
.info { color: #9edbff; background: rgba(40,120,255,.12); border-color: rgba(40,120,255,.28); }
|
||||
.success { color: #75e2b8; background: rgba(54,201,143,.12); border-color: rgba(54,201,143,.28); }
|
||||
.warning { color: #ffd786; background: rgba(241,185,71,.12); border-color: rgba(241,185,71,.28); }
|
||||
.danger { color: #ff9caa; background: rgba(255,102,122,.12); border-color: rgba(255,102,122,.28); }
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import styles from './Selection.module.css'
|
||||
|
||||
export interface TabItem { id: string; label: string; disabled?: boolean }
|
||||
interface TabsProps { items: TabItem[]; value: string; onChange: (id: string) => void; ariaLabel?: string }
|
||||
|
||||
export function Tabs({ items, value, onChange, ariaLabel = '内容分类' }: TabsProps) {
|
||||
return <div className={styles.tabs} role="tablist" aria-label={ariaLabel}>{items.map(item => <button key={item.id} type="button" role="tab" aria-selected={item.id === value} className={clsx(styles.tab, item.id === value && styles.activeTab)} disabled={item.disabled} onClick={() => onChange(item.id)}>{item.label}</button>)}</div>
|
||||
}
|
||||
|
||||
interface ChipProps { selected?: boolean; children: ReactNode; onClick?: () => void }
|
||||
export function FilterChip({ selected, children, onClick }: ChipProps) {
|
||||
return <button type="button" className={clsx(styles.chip, selected && styles.selectedChip)} aria-pressed={selected} onClick={onClick}>{children}</button>
|
||||
}
|
||||
|
||||
interface BadgeProps { tone?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; children: ReactNode }
|
||||
export function Badge({ tone = 'neutral', children }: BadgeProps) {
|
||||
return <span className={clsx(styles.badge, styles[tone])}>{children}</span>
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Button } from './Button'
|
||||
import { Tabs } from './Selection'
|
||||
|
||||
describe('Button', () => {
|
||||
it('prevents interaction while loading', () => {
|
||||
render(<Button loading>生成</Button>)
|
||||
expect(screen.getByRole('button', { name: '生成' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tabs', () => {
|
||||
it('reports the selected tab and emits changes', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<Tabs items={[{ id: 'one', label: '作品灵感' }, { id: 'two', label: '画布模板' }]} value="one" onChange={onChange} />)
|
||||
expect(screen.getByRole('tab', { name: '作品灵感' })).toHaveAttribute('aria-selected', 'true')
|
||||
fireEvent.click(screen.getByRole('tab', { name: '画布模板' }))
|
||||
expect(onChange).toHaveBeenCalledWith('two')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './styles/global.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
.page { max-width: 1320px; margin: 0 auto; }.pageHeader { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin: 4px 0 16px; }.pageHeader small { color: #69c4ff; font-size: 10px; font-weight: 700; letter-spacing: .14em; }.pageHeader h1 { margin: 5px 0 3px; font-family: var(--font-brand); font-size: 24px; line-height: 1.3; }.pageHeader p { margin: 0; color: var(--color-text-muted); font-size: 13px; }.sections { display: grid; gap: 14px; margin-top: 14px; }.showcase { overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-xl); background: var(--color-surface); }.showcase > header { display: flex; flex-direction: column; gap: 3px; padding: 14px 16px; border-bottom: 1px solid var(--color-border); }.showcase > header strong { font-size: 14px; }.showcase > header span { color: var(--color-text-subtle); font-size: 12px; }.demo { padding: 18px; }.row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }.formGrid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 14px; }.statusStack { display: grid; gap: 12px; }.muted { color: var(--color-text-muted); }.dialogButton { margin-top: 14px; }.businessGrid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 12px; margin-top: 14px; }.businessItem { min-height: 150px; padding: 16px; border: 1px solid var(--color-border); border-radius: var(--radius-xl); background: linear-gradient(205deg,rgba(68,142,255,.12),rgba(36,36,41,.8) 60%); }.businessItem strong { font-size: 15px; }.businessItem p { min-height: 42px; color: var(--color-text-muted); font-size: 12px; line-height: 1.7; }
|
||||
@media (max-width: 980px) { .formGrid { grid-template-columns: repeat(2,minmax(0,1fr)); }.businessGrid { grid-template-columns: repeat(2,minmax(0,1fr)); } }
|
||||
@media (max-width: 620px) { .pageHeader { align-items: flex-start; flex-direction: column; }.formGrid,.businessGrid { grid-template-columns: 1fr; } }
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Bell, Download, Plus, Save, Trash2, WandSparkles } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '../components/primitives/Button'
|
||||
import { Dialog, EmptyState, InlineAlert, Skeleton, Spinner } from '../components/primitives/Feedback'
|
||||
import { Field, SearchInput, Select, Switch, TextInput } from '../components/primitives/FormControls'
|
||||
import { IconButton } from '../components/primitives/IconButton'
|
||||
import { Badge, FilterChip, Tabs } from '../components/primitives/Selection'
|
||||
import styles from './ComponentsPage.module.css'
|
||||
|
||||
const componentTabs = [{ id: 'primitives', label: '基础组件' }, { id: 'feedback', label: '反馈组件' }, { id: 'business', label: '业务组件' }]
|
||||
|
||||
export function ComponentsPage() {
|
||||
const [tab, setTab] = useState('primitives')
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [chip, setChip] = useState('全部')
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
return <div className={styles.page}>
|
||||
<header className={styles.pageHeader}><span><small>DESIGN SYSTEM</small><h1>Miragen Flow 组件中心</h1><p>用户端组件、交互状态与视觉约束的集中验收页面。</p></span><Button variant="primary" leadingIcon={<Plus />}>新建组件</Button></header>
|
||||
<Tabs items={componentTabs} value={tab} onChange={setTab} ariaLabel="组件分类" />
|
||||
{tab === 'primitives' && <div className={styles.sections}>
|
||||
<Showcase title="Button" description="主要命令、次要命令、低强调和危险操作。"><div className={styles.row}><Button variant="primary" leadingIcon={<WandSparkles />}>开始生成</Button><Button variant="secondary" leadingIcon={<Save />}>保存</Button><Button variant="ghost" leadingIcon={<Download />}>下载</Button><Button variant="danger" leadingIcon={<Trash2 />}>删除</Button><Button loading>处理中</Button><Button disabled>不可用</Button></div></Showcase>
|
||||
<Showcase title="IconButton & Badge" description="图标命令必须带可访问名称,状态标签使用固定语义色。"><div className={styles.row}><IconButton label="通知" icon={<Bell />} /><IconButton label="已选择" icon={<Save />} selected /><IconButton label="不可用" icon={<Trash2 />} disabled /><Badge>草稿</Badge><Badge tone="info">生成中</Badge><Badge tone="success">已完成</Badge><Badge tone="warning">等待中</Badge><Badge tone="danger">失败</Badge></div></Showcase>
|
||||
<Showcase title="Input & Select" description="覆盖提示、错误、禁用与搜索输入。"><div className={styles.formGrid}><Field label="作品名称" hint="最多 40 个字符"><TextInput placeholder="输入作品名称" /></Field><Field label="搜索"><SearchInput placeholder="搜索模型或风格" /></Field><Field label="模型类型"><Select defaultValue="general"><option value="general">通用模型</option><option value="style">风格模型</option></Select></Field><Field label="错误状态" error="请输入有效内容"><TextInput invalid defaultValue="无效内容" /></Field></div></Showcase>
|
||||
<Showcase title="Selection" description="筛选项、切换项与开关。"><div className={styles.row}>{['全部','游戏界面','角色','场景'].map(item => <FilterChip key={item} selected={chip === item} onClick={() => setChip(item)}>{item}</FilterChip>)}<Switch label="启用高清修复" checked={enabled} onChange={event => setEnabled(event.target.checked)} /></div></Showcase>
|
||||
</div>}
|
||||
{tab === 'feedback' && <div className={styles.sections}>
|
||||
<Showcase title="Status" description="加载、骨架和语义提示。"><div className={styles.statusStack}><InlineAlert>模型参数已经自动保存。</InlineAlert><InlineAlert tone="danger">上传失败,请检查文件格式。</InlineAlert><div className={styles.row}><Spinner /><span className={styles.muted}>正在生成预览</span></div><Skeleton height={88} /></div></Showcase>
|
||||
<Showcase title="Empty State & Dialog" description="空内容提示与模态任务确认。"><EmptyState title="还没有生成任务" description="完成第一次创作后,任务会出现在这里" action={<Button variant="primary">开始创作</Button>} /><Button className={styles.dialogButton} onClick={() => setDialogOpen(true)}>打开对话框</Button></Showcase>
|
||||
</div>}
|
||||
{tab === 'business' && <div className={styles.businessGrid}><BusinessItem title="内容发现" items="HeroCarousel · ArtworkCard · FilterBar · MasonryGrid" /><BusinessItem title="AI 创作" items="PromptEditor · ModelPicker · RatioPicker · GenerationQueue" /><BusinessItem title="自由画布" items="CanvasStage · LayerPanel · PropertyPanel · ExportDialog" /><BusinessItem title="资产管理" items="AssetCard · FolderTree · UploadQueue · BatchActionBar" /><BusinessItem title="3D 渲染" items="SceneViewport · LightingPanel · MaterialPanel · RenderQueue" /><BusinessItem title="账户系统" items="LoginDialog · CreditBalance · ProfileHeader · SettingsSection" /></div>}
|
||||
<Dialog open={dialogOpen} title="确认生成任务" description="预计消耗 2 点算力" onClose={() => setDialogOpen(false)}>当前模型为 Miragen General V2,生成比例 16:9,共生成 4 张。</Dialog>
|
||||
</div>
|
||||
}
|
||||
|
||||
function Showcase({ title, description, children }: { title: string; description: string; children: React.ReactNode }) { return <section className={styles.showcase}><header><strong>{title}</strong><span>{description}</span></header><div className={styles.demo}>{children}</div></section> }
|
||||
function BusinessItem({ title, items }: { title: string; items: string }) { return <article className={styles.businessItem}><strong>{title}</strong><p>{items}</p><Badge tone="info">已规划</Badge></article> }
|
||||
@@ -0,0 +1,2 @@
|
||||
.page { width: 100%; min-width: 0; }.filterBar { display: flex; align-items: center; gap: 12px; min-height: 62px; }.chips { display: flex; min-width: 0; flex: 1; align-items: center; gap: 8px; overflow-x: auto; scrollbar-width: none; }.chips::-webkit-scrollbar { display: none; }.filterBar > button { flex: 0 0 auto; }.placeholder { min-height: 360px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; border: 1px dashed var(--color-border-strong); border-radius: var(--radius-xl); color: var(--color-text-muted); }.placeholder strong { color: var(--color-text); font-size: 18px; }.placeholder span { font-size: 13px; }
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ChevronDown, SlidersHorizontal } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ArtworkGrid } from '../components/content/ArtworkGrid'
|
||||
import { HeroCarousel } from '../components/content/HeroCarousel'
|
||||
import { Button } from '../components/primitives/Button'
|
||||
import { IconButton } from '../components/primitives/IconButton'
|
||||
import { Dialog } from '../components/primitives/Feedback'
|
||||
import { FilterChip, Tabs } from '../components/primitives/Selection'
|
||||
import { artworks, type Artwork } from '../data/artworks'
|
||||
import styles from './HomePage.module.css'
|
||||
|
||||
const tabs = [
|
||||
{ id: 'inspiration', label: '作品灵感' },
|
||||
{ id: 'canvas', label: '画布模板' },
|
||||
{ id: 'style', label: '风格模型' },
|
||||
{ id: 'render', label: '万能渲染模型' },
|
||||
{ id: 'academy', label: 'AI 学院' },
|
||||
]
|
||||
|
||||
const categories = ['猜你喜欢', '小游戏', '游戏界面', '买量视频', '二次元', '装饰风格', '角色演绎', '场景展示', '循环动画', '特效', '插画', 'ICON']
|
||||
|
||||
export function HomePage() {
|
||||
const [tab, setTab] = useState('inspiration')
|
||||
const [category, setCategory] = useState('猜你喜欢')
|
||||
const [sort, setSort] = useState<'最热' | '最新'>('最热')
|
||||
const [selectedArtwork, setSelectedArtwork] = useState<Artwork | null>(null)
|
||||
const visibleArtworks = useMemo(() => {
|
||||
const filtered = category === '猜你喜欢' ? artworks : artworks.filter(item => item.category === category)
|
||||
return sort === '最热' ? [...filtered].sort((a, b) => b.likes - a.likes) : [...filtered].reverse()
|
||||
}, [category, sort])
|
||||
|
||||
return <div className={styles.page}>
|
||||
<HeroCarousel />
|
||||
<Tabs items={tabs} value={tab} onChange={setTab} />
|
||||
<div className={styles.filterBar}><div className={styles.chips}>{categories.map(item => <FilterChip key={item} selected={item === category} onClick={() => setCategory(item)}>{item}</FilterChip>)}<IconButton label="更多筛选" icon={<SlidersHorizontal />} /></div><Button variant="secondary" size="sm" trailingIcon={<ChevronDown />} onClick={() => setSort(value => value === '最热' ? '最新' : '最热')}>{sort}</Button></div>
|
||||
{tab === 'inspiration' ? <ArtworkGrid artworks={visibleArtworks} onUse={setSelectedArtwork} /> : <section className={styles.placeholder}><strong>{tabs.find(item => item.id === tab)?.label}</strong><span>该模块的组件结构已纳入后续实现队列。</span></section>}
|
||||
<Dialog open={Boolean(selectedArtwork)} title="创建同款作品" description={selectedArtwork?.title} onClose={() => setSelectedArtwork(null)}><p>将使用当前作品的模型、尺寸与风格参数创建新的生成任务。</p></Dialog>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
.page { min-height: calc(100vh - 112px); display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }.page > svg { width: 36px; height: 36px; margin-bottom: 13px; color: #75d1ff; }.page small { color: #69c4ff; font-weight: 700; letter-spacing: .14em; }.page h1 { margin: 8px 0; font-family: var(--font-brand); font-size: 26px; }.page p { max-width: 480px; margin: 0 0 22px; color: var(--color-text-muted); }
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ArrowRight, Construction } from 'lucide-react'
|
||||
import { Button } from '../components/primitives/Button'
|
||||
import styles from './PlaceholderPage.module.css'
|
||||
|
||||
export function PlaceholderPage({ title, description, onGoComponents }: { title: string; description: string; onGoComponents: () => void }) {
|
||||
return <section className={styles.page}><Construction /><small>MODULE SCAFFOLD</small><h1>{title}</h1><p>{description}</p><Button variant="primary" trailingIcon={<ArrowRight />} onClick={onGoComponents}>查看组件规划</Button></section>
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
@import "./tokens.css";
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #root { width: 100%; min-width: 320px; min-height: 100%; margin: 0; }
|
||||
|
||||
html { background: var(--color-bg); }
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button { color: inherit; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
img { max-width: 100%; }
|
||||
::selection { background: rgba(40, 120, 255, 0.4); color: #fff; }
|
||||
:focus-visible { outline: 2px solid #63c8ff; outline-offset: 2px; }
|
||||
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: #3c3c43; border-radius: 999px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
@font-face {
|
||||
font-family: "Alimama FangYuanTi VF";
|
||||
src: url("../assets/holopix/alimama.woff2") format("woff2");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--font-sans: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
--font-brand: "Alimama FangYuanTi VF", var(--font-sans);
|
||||
--color-bg: #18181b;
|
||||
--color-surface: #1d1d21;
|
||||
--color-surface-raised: #242429;
|
||||
--color-surface-hover: #2a2a30;
|
||||
--color-border: rgba(235, 235, 245, 0.08);
|
||||
--color-border-strong: #34343a;
|
||||
--color-text: #ece9f2;
|
||||
--color-text-muted: #a7a2b3;
|
||||
--color-text-subtle: #777580;
|
||||
--color-primary: #2878ff;
|
||||
--color-primary-hover: #3d8bff;
|
||||
--color-primary-soft: #15294d;
|
||||
--color-primary-border: #2d62b7;
|
||||
--gradient-brand: linear-gradient(90deg, #55a7ff, #75d1ff 52%, #c1eeff);
|
||||
--gradient-brand-strong: linear-gradient(135deg, #2878ff, #63c8ff);
|
||||
--color-success: #36c98f;
|
||||
--color-warning: #f1b947;
|
||||
--color-danger: #ff667a;
|
||||
--color-info: #55a7ff;
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--radius-sm: 2px;
|
||||
--radius-md: 3px;
|
||||
--radius-lg: 4px;
|
||||
--radius-xl: 4px;
|
||||
--shadow-popover: 0 16px 40px rgba(0, 0, 0, 0.36);
|
||||
--shadow-primary: 0 8px 24px rgba(40, 120, 255, 0.26);
|
||||
--duration-fast: 120ms;
|
||||
--duration-normal: 180ms;
|
||||
--duration-slow: 250ms;
|
||||
--ease-standard: cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--header-height: 56px;
|
||||
--sidebar-width: 172px;
|
||||
--z-header: 20;
|
||||
--z-popover: 40;
|
||||
--z-dialog: 60;
|
||||
--z-toast: 80;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
# 设计参考
|
||||
|
||||
`MiragenFlow-front/` 是一期视觉参考快照,不是运行项目、工作区或生产静态资源。生产入口只有 `web/`、`admin/` 和 `server/`;该快照中的 hash 路由、占位页和静态演示数据不参与构建。
|
||||
|
||||
其中的图片和字体仅用于设计核验,未作为生产业务数据或用户资产发布。
|
||||
|
||||
已接入的生产视觉组件位于 `web/src/user-console/`;该目录只迁移壳层、作品灵感、模型广场和基础 primitives,业务数据继续由 `web/src/services` 与 Zustand stores 提供。
|
||||