feat: integrate platform backend and application interfaces

This commit is contained in:
Qiufeng
2026-09-02 21:09:47 +08:00
parent cd9ac1af70
commit b16390dd41
648 changed files with 102488 additions and 17737 deletions
@@ -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>
File diff suppressed because it is too large Load Diff
@@ -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"
}
}
File diff suppressed because one or more lines are too long

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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

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',
},
})
+7
View File
@@ -0,0 +1,7 @@
# 设计参考
`MiragenFlow-front/` 是一期视觉参考快照,不是运行项目、工作区或生产静态资源。生产入口只有 `web/`、`admin/` 和 `server/`;该快照中的 hash 路由、占位页和静态演示数据不参与构建。
其中的图片和字体仅用于设计核验,未作为生产业务数据或用户资产发布。
已接入的生产视觉组件位于 `web/src/user-console/`;该目录只迁移壳层、作品灵感、模型广场和基础 primitives,业务数据继续由 `web/src/services` 与 Zustand stores 提供。