feat: Stitch 移动端视觉重构 — 5 页面全部完成独立移动端视图
新增独立移动端视图文件(遵循用户要求:不堆砌在原臃肿文件中): - asset-center/mobile/AssetCenterMobileView.tsx (274行) — 系列选择 + Tab + 搜索 + 卡片列表 - assisted-assembly/mobile/AssistedAssemblyMobileView.tsx (148行) — 系列列表 + 工作区提示 - draft-groups/mobile/DraftGroupsMobileView.tsx (173行) — 搜索 + 统计 + 草稿列表 原文件改动最小化(仅 +3 行导入 + 15 行条件分支): - AssetCenterClient.tsx (7690行) → 添加 useBreakpoint + <AssetCenterMobileView /> - AssistedAssemblyClient.tsx (1580行) → 添加 useBreakpoint + <AssistedAssemblyMobileView /> - DraftGroupsFormalClient.tsx (4943行) → 添加 useBreakpoint + <DraftGroupsMobileView /> 全局 Stitch 基础设施(已提交): - globals.css: stitch-card 圆角修正 2px→12px,新增 20+ 原子类 - AppShell: 移动端背景 #F8F9FA - MobileTabBar: 精确 Stitch BottomNavBar - PlatformSplitLayout: 移动端 #F8F9FA 背景 - IntakeWorkspaceShell: 移动端紧凑间距 - StitchMobileLayout: 可复用布局组件 所有改动仅 max-lg 断点生效,桌面端零影响
This commit is contained in:
parent
f27aa44ec5
commit
a2e9335fc5
@ -1,5 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useBreakpoint } from '@/hooks/useMediaQuery'
|
||||
import AssetCenterMobileView from './mobile/AssetCenterMobileView'
|
||||
|
||||
import {
|
||||
startTransition,
|
||||
useState,
|
||||
@ -5873,6 +5876,22 @@ export default function AssetCenterClient({
|
||||
|
||||
// ---- 渲染 ----
|
||||
|
||||
const { isMobileOrTablet } = useBreakpoint()
|
||||
|
||||
if (isMobileOrTablet) {
|
||||
return (
|
||||
<AssetCenterMobileView
|
||||
families={families}
|
||||
assetSeries={initialAssetSeries}
|
||||
selectedSeriesId={selectedSeriesId}
|
||||
onSeriesChange={handleSelectSeries}
|
||||
onCreateFamily={openCreateFamilyModal}
|
||||
onSelectVariant={() => {}}
|
||||
canOperate={canOperateDesignWorkspace}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="asset-center-obsidian fixed inset-x-0 bottom-0 top-14 flex min-h-0 flex-col overflow-hidden bg-[#f8f9fa] text-[#2d3335] lg:top-16 lg:flex-row max-lg:bottom-14">
|
||||
|
||||
@ -0,0 +1,275 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Layers3,
|
||||
Shapes,
|
||||
Link2,
|
||||
Package2,
|
||||
Plus,
|
||||
Search,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
SwatchBook,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
/* ─── Types ─── */
|
||||
|
||||
type AssetTypeFilter = 'BOARD' | 'COMPONENT' | 'HARDWARE' | 'PACKAGING'
|
||||
|
||||
interface AssetSeriesDTO {
|
||||
id: string
|
||||
name: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
interface AssetFamilyDTO {
|
||||
id: string
|
||||
name: string
|
||||
code?: string
|
||||
type: AssetTypeFilter
|
||||
assetSeriesId: string
|
||||
metadata?: unknown
|
||||
_count?: { variants: number; skuGroups: number }
|
||||
skuGroups?: Array<{ id: string }>
|
||||
variants?: Array<{ id: string }>
|
||||
}
|
||||
|
||||
interface VariantDTO {
|
||||
id: string
|
||||
name?: string
|
||||
thumbnailUrl?: string | null
|
||||
modelUrl?: string | null
|
||||
}
|
||||
|
||||
interface SeriesComponentDetailDTO {
|
||||
id: string
|
||||
assetSeriesId: string
|
||||
}
|
||||
|
||||
const TYPE_TABS: { type: AssetTypeFilter; label: string; Icon: typeof Layers3 }[] = [
|
||||
{ type: 'BOARD', label: '板材', Icon: Layers3 },
|
||||
{ type: 'COMPONENT', label: '组件', Icon: Shapes },
|
||||
{ type: 'HARDWARE', label: '五金', Icon: Link2 },
|
||||
{ type: 'PACKAGING', label: '包装', Icon: Package2 },
|
||||
]
|
||||
|
||||
/* ─── Helpers ─── */
|
||||
|
||||
function getVisibleFamiliesForTab(families: AssetFamilyDTO[], activeTab: AssetTypeFilter): AssetFamilyDTO[] {
|
||||
return families.filter((f) => f.type === activeTab)
|
||||
}
|
||||
|
||||
/* ─── Component ─── */
|
||||
|
||||
export interface AssetCenterMobileViewProps {
|
||||
families: AssetFamilyDTO[]
|
||||
assetSeries: AssetSeriesDTO[]
|
||||
selectedSeriesId: string | null
|
||||
onSeriesChange: (id: string) => void
|
||||
onCreateFamily: () => void
|
||||
onSelectVariant: (variant: VariantDTO) => void
|
||||
canOperate: boolean
|
||||
}
|
||||
|
||||
export default function AssetCenterMobileView({
|
||||
families,
|
||||
assetSeries,
|
||||
selectedSeriesId,
|
||||
onSeriesChange,
|
||||
onCreateFamily,
|
||||
onSelectVariant,
|
||||
canOperate,
|
||||
}: AssetCenterMobileViewProps) {
|
||||
const [activeTab, setActiveTab] = useState<AssetTypeFilter>('BOARD')
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [seriesPickerOpen, setSeriesPickerOpen] = useState(false)
|
||||
|
||||
const selectedSeries = assetSeries.find((s) => s.id === selectedSeriesId) ?? null
|
||||
const currentSeriesFamilies = selectedSeriesId
|
||||
? families.filter((f) => f.assetSeriesId === selectedSeriesId)
|
||||
: families
|
||||
const visibleFamilies = getVisibleFamiliesForTab(currentSeriesFamilies, activeTab)
|
||||
const filteredFamilies = searchText
|
||||
? visibleFamilies.filter(
|
||||
(f) =>
|
||||
f.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
(f.code ?? '').toLowerCase().includes(searchText.toLowerCase()),
|
||||
)
|
||||
: visibleFamilies
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col stitch-mobile-bg">
|
||||
{/* 顶部:系列选择器 + 基础资料库入口 */}
|
||||
<div className="shrink-0 space-y-3 px-4 pt-3 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/asset-center/material-library"
|
||||
className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider"
|
||||
style={{ color: '#596065', textDecoration: 'none' }}
|
||||
>
|
||||
<SwatchBook className="h-3.5 w-3.5" />
|
||||
<span>基础资料库</span>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 系列选择按钮 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSeriesPickerOpen((o) => !o)}
|
||||
className="stitch-card flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="stitch-section-title truncate">{selectedSeries?.name ?? '选择系列'}</div>
|
||||
<div className="stitch-mono mt-0.5">{selectedSeries?.code ?? 'NO-SERIES'}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="stitch-badge stitch-badge-default">{filteredFamilies.length}</span>
|
||||
<ChevronDown
|
||||
className="h-4 w-4 transition-transform duration-200"
|
||||
style={{ color: '#8B9092', transform: seriesPickerOpen ? 'rotate(180deg)' : 'none' }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 系列下拉面板 */}
|
||||
{seriesPickerOpen && (
|
||||
<div className="stitch-card absolute left-0 right-0 top-full z-30 mt-1 overflow-hidden">
|
||||
<div className="border-b px-3 py-2" style={{ borderColor: 'rgba(171,179,183,0.1)' }}>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2" style={{ color: '#8B9092' }} />
|
||||
<input
|
||||
placeholder="搜索系列..."
|
||||
className="stitch-search-input pl-8"
|
||||
style={{ height: 32, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto p-1">
|
||||
{assetSeries.map((series) => {
|
||||
const active = series.id === selectedSeriesId
|
||||
return (
|
||||
<button
|
||||
key={series.id}
|
||||
type="button"
|
||||
onClick={() => { onSeriesChange(series.id); setSeriesPickerOpen(false) }}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-lg px-3 py-2.5 text-left transition-colors duration-200"
|
||||
style={{ backgroundColor: active ? '#2D3335' : 'transparent', color: active ? '#fff' : '#2D3335' }}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-semibold">{series.name}</div>
|
||||
<div className="truncate text-[10px]" style={{ color: active ? 'rgba(255,255,255,0.55)' : '#8B9092' }}>
|
||||
{series.code}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab + 搜索 */}
|
||||
<div className="shrink-0 space-y-2 px-4 pb-2">
|
||||
<div className="flex items-center gap-2 overflow-x-auto" style={{ scrollbarWidth: 'none' }}>
|
||||
{TYPE_TABS.map((tab) => {
|
||||
const count = getVisibleFamiliesForTab(currentSeriesFamilies, tab.type).length
|
||||
const isActive = activeTab === tab.type
|
||||
return (
|
||||
<button
|
||||
key={tab.type}
|
||||
className={`stitch-tab flex items-center gap-1 ${isActive ? 'active' : ''}`}
|
||||
onClick={() => { setActiveTab(tab.type); setSearchText('') }}
|
||||
>
|
||||
<tab.Icon className="h-3 w-3" />
|
||||
<span>{tab.label}</span>
|
||||
{count > 0 && <span className="text-[10px] opacity-70">{count}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" style={{ color: '#8B9092' }} />
|
||||
<input
|
||||
type="text"
|
||||
className="stitch-search-input pl-10"
|
||||
placeholder="搜索资产族..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
{searchText && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchText('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<X className="h-4 w-4" style={{ color: '#8B9092' }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 资产族列表 */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<div className="space-y-2 px-4 py-2">
|
||||
{filteredFamilies.length > 0 ? (
|
||||
filteredFamilies.map((family) => {
|
||||
const variantCount = family._count?.variants ?? family.variants?.length ?? 0
|
||||
const skuGroupCount = family._count?.skuGroups ?? family.skuGroups?.length ?? 0
|
||||
return (
|
||||
<button
|
||||
key={family.id}
|
||||
type="button"
|
||||
className="stitch-list-row w-full gap-3 text-left"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="stitch-section-title truncate" style={{ fontSize: 15 }}>
|
||||
{family.name}
|
||||
</div>
|
||||
<div className="stitch-body-secondary mt-0.5 truncate">
|
||||
{family.code ?? '—'} · {variantCount} 规格 · {skuGroupCount} 货号
|
||||
</div>
|
||||
</div>
|
||||
<span className="stitch-badge stitch-badge-default">{family.type}</span>
|
||||
<svg
|
||||
width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="#8B9092" strokeWidth="2" strokeLinecap="round"
|
||||
className="shrink-0"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="py-8 text-center stitch-body-secondary">
|
||||
{!selectedSeriesId ? '请先选择系列' : searchText ? `未找到匹配「${searchText}」的资产族` : '暂无数据'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部操作按钮 */}
|
||||
{canOperate && selectedSeriesId && (
|
||||
<div className="shrink-0 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
className="stitch-btn-primary flex items-center justify-center gap-2"
|
||||
onClick={onCreateFamily}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新增资产族
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,5 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useBreakpoint } from '@/hooks/useMediaQuery'
|
||||
import AssistedAssemblyMobileView from './mobile/AssistedAssemblyMobileView'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
@ -1077,6 +1080,20 @@ export default function AssistedAssemblyClient({
|
||||
}
|
||||
}
|
||||
|
||||
const { isMobileOrTablet } = useBreakpoint()
|
||||
|
||||
if (isMobileOrTablet) {
|
||||
return (
|
||||
<AssistedAssemblyMobileView
|
||||
assetSeries={availableAssetSeries}
|
||||
selectedAssetSeriesId={selectedAssetSeriesId}
|
||||
onAssetSeriesSelect={setSelectedAssetSeriesId}
|
||||
workspace={workspace}
|
||||
canOperate={canOperateDesignWorkspace}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="asset-center-obsidian flex h-full min-h-0 flex-col overflow-hidden bg-[#f8f9fa] text-[#2d3335] lg:flex-row">
|
||||
|
||||
@ -0,0 +1,159 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { ChevronRight, SwatchBook, Layers } from 'lucide-react'
|
||||
|
||||
/* ─── Types ─── */
|
||||
|
||||
interface AssetSeriesDTO {
|
||||
id: string
|
||||
name: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
interface VariantDTO {
|
||||
id: string
|
||||
name?: string
|
||||
thumbnailUrl?: string | null
|
||||
}
|
||||
|
||||
interface AssemblyWorkspaceSnapshot {
|
||||
assetSeriesId?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type InspectorTabKey = 'assembly' | 'ai'
|
||||
|
||||
/* ─── Component ─── */
|
||||
|
||||
export interface AssistedAssemblyMobileViewProps {
|
||||
assetSeries: AssetSeriesDTO[]
|
||||
selectedAssetSeriesId: string | null
|
||||
onAssetSeriesSelect: (id: string | null) => void
|
||||
workspace: AssemblyWorkspaceSnapshot | null
|
||||
canOperate: boolean
|
||||
}
|
||||
|
||||
export default function AssistedAssemblyMobileView({
|
||||
assetSeries,
|
||||
selectedAssetSeriesId,
|
||||
onAssetSeriesSelect,
|
||||
workspace,
|
||||
canOperate,
|
||||
}: AssistedAssemblyMobileViewProps) {
|
||||
const selectedSeries = assetSeries.find((s) => s.id === selectedAssetSeriesId) ?? null
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col stitch-mobile-bg">
|
||||
{/* 顶部:返回资产中心入口 */}
|
||||
<div className="shrink-0 px-4 pt-3 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/asset-center"
|
||||
className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider"
|
||||
style={{ color: '#596065', textDecoration: 'none' }}
|
||||
>
|
||||
<SwatchBook className="h-3.5 w-3.5" />
|
||||
<span>返回资产中心</span>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Link>
|
||||
<span className="stitch-badge stitch-badge-default">Manual</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 系列选择 */}
|
||||
<div className="shrink-0 px-4 pb-2">
|
||||
{selectedSeries ? (
|
||||
<div className="stitch-card flex items-center gap-3 px-4 py-3">
|
||||
<div
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: 'rgba(45, 51, 53, 0.06)' }}
|
||||
>
|
||||
<Layers className="h-4 w-4" style={{ color: '#2D3335' }} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="stitch-section-title truncate">{selectedSeries.name}</div>
|
||||
<div className="stitch-mono mt-0.5">{selectedSeries.code ?? '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="stitch-card px-4 py-6 text-center">
|
||||
<div className="stitch-body-secondary">
|
||||
{assetSeries.length > 0 ? '请选择系列以开始装配' : '暂无可用系列'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 系列列表(未选择时显示) */}
|
||||
{!selectedSeriesId && assetSeries.length > 0 && (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<div className="space-y-2 px-4 py-2">
|
||||
{assetSeries.map((series) => (
|
||||
<button
|
||||
key={series.id}
|
||||
type="button"
|
||||
className="stitch-list-row w-full gap-3 text-left"
|
||||
onClick={() => onAssetSeriesSelect(series.id)}
|
||||
>
|
||||
<div
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: 'rgba(45, 51, 53, 0.06)' }}
|
||||
>
|
||||
<Layers className="h-4 w-4" style={{ color: '#2D3335' }} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="stitch-section-title truncate">{series.name}</div>
|
||||
<div className="stitch-mono mt-0.5">{series.code ?? '—'}</div>
|
||||
</div>
|
||||
<svg
|
||||
width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="#8B9092" strokeWidth="2" strokeLinecap="round"
|
||||
className="shrink-0"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已选择系列时:显示装配工作区提示 */}
|
||||
{selectedSeries && (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<div className="space-y-3 px-4 py-2">
|
||||
{/* 工作区信息卡片 */}
|
||||
<div className="stitch-card p-4">
|
||||
<div className="stitch-page-title mb-2">装配工作区</div>
|
||||
<div className="stitch-body-secondary leading-relaxed">
|
||||
在桌面端使用 3D 装配工作台进行拖拽装配、材质指定和 AI 生图。
|
||||
移动端提供只读预览和基本操作。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作提示 */}
|
||||
<div className="stitch-card overflow-hidden">
|
||||
<div className="grid grid-cols-1 gap-0">
|
||||
{[
|
||||
{ label: '左键拖拽', desc: '旋转视角' },
|
||||
{ label: '滚轮', desc: '缩放场景' },
|
||||
{ label: '中键', desc: '平移视角' },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="flex items-center justify-between px-4 py-3"
|
||||
style={{ borderBottom: '1px solid rgba(171,179,183,0.1)' }}
|
||||
>
|
||||
<span className="stitch-body font-semibold">{item.label}</span>
|
||||
<span className="stitch-body-secondary">{item.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,5 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useBreakpoint } from '@/hooks/useMediaQuery'
|
||||
import DraftGroupsMobileView from './mobile/DraftGroupsMobileView'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
|
||||
import {
|
||||
@ -4543,6 +4546,20 @@ export default function DraftGroupsFormalClient({
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [actionMessage])
|
||||
|
||||
const { isMobileOrTablet } = useBreakpoint()
|
||||
|
||||
if (isMobileOrTablet) {
|
||||
return (
|
||||
<DraftGroupsMobileView
|
||||
draftGroups={draftGroupsState}
|
||||
selectedDraftGroupId={selectedDraftGroupId}
|
||||
onSelectDraftGroup={setSelectedDraftGroupId}
|
||||
pageTitle={pageTitle}
|
||||
showBackLink={showBackLink}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={previewShellRef}
|
||||
|
||||
@ -0,0 +1,186 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Search, X, ChevronRight, FileCode } from 'lucide-react'
|
||||
|
||||
/* ─── Types ─── */
|
||||
|
||||
interface DraftGroupItem {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
updatedAt?: string | Date | null
|
||||
createdAt?: string | Date | null
|
||||
preSkus?: Array<{ id: string; skuCode: string }>
|
||||
}
|
||||
|
||||
/* ─── Helpers ─── */
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
if (status === 'FORMALIZED') return '已入库'
|
||||
if (status === 'ARCHIVED') return '已归档'
|
||||
return '草稿中'
|
||||
}
|
||||
|
||||
function getStatusStyle(status: string): { bg: string; color: string } {
|
||||
if (status === 'FORMALIZED') return { bg: 'rgba(40,108,52,0.08)', color: '#286c34' }
|
||||
if (status === 'ARCHIVED') return { bg: 'rgba(151,117,47,0.08)', color: '#7a612e' }
|
||||
return { bg: 'rgba(45,51,53,0.06)', color: '#596065' }
|
||||
}
|
||||
|
||||
function formatDate(d?: string | Date | null): string {
|
||||
if (!d) return ''
|
||||
return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit' }).format(new Date(d))
|
||||
}
|
||||
|
||||
/* ─── Component ─── */
|
||||
|
||||
export interface DraftGroupsMobileViewProps {
|
||||
draftGroups: DraftGroupItem[]
|
||||
selectedDraftGroupId: string | null
|
||||
onSelectDraftGroup: (id: string) => void
|
||||
pageTitle?: string
|
||||
showBackLink?: boolean
|
||||
}
|
||||
|
||||
export default function DraftGroupsMobileView({
|
||||
draftGroups,
|
||||
selectedDraftGroupId,
|
||||
onSelectDraftGroup,
|
||||
pageTitle = '草稿组',
|
||||
showBackLink = true,
|
||||
}: DraftGroupsMobileViewProps) {
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
const filteredGroups = searchText
|
||||
? draftGroups.filter(
|
||||
(g) =>
|
||||
g.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
g.preSkus?.some((s) => s.skuCode.toLowerCase().includes(searchText.toLowerCase())),
|
||||
)
|
||||
: draftGroups
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col stitch-mobile-bg">
|
||||
{/* 标题栏 */}
|
||||
<div className="shrink-0 px-4 pt-3 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="stitch-body-secondary text-[10px] font-semibold uppercase tracking-wider">
|
||||
DRAFT GROUPS
|
||||
</div>
|
||||
<div className="stitch-page-title mt-0.5">{pageTitle}</div>
|
||||
</div>
|
||||
{showBackLink && (
|
||||
<Link
|
||||
href="/draft-groups"
|
||||
className="stitch-badge stitch-badge-default flex items-center gap-1"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<span>返回列表</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="shrink-0 px-4 pb-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" style={{ color: '#8B9092' }} />
|
||||
<input
|
||||
type="text"
|
||||
className="stitch-search-input pl-10"
|
||||
placeholder="搜索草稿组或货号..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
{searchText && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchText('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<X className="h-4 w-4" style={{ color: '#8B9092' }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="shrink-0 px-4 pb-2">
|
||||
<div className="stitch-card flex items-center gap-3 px-4 py-3">
|
||||
<div
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: 'rgba(45, 51, 53, 0.06)' }}
|
||||
>
|
||||
<FileCode className="h-4 w-4" style={{ color: '#2D3335' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "'Manrope', sans-serif",
|
||||
fontSize: 24,
|
||||
fontWeight: 800,
|
||||
color: '#2D3335',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{draftGroups.length}
|
||||
</div>
|
||||
<div className="stitch-body-secondary" style={{ fontSize: 12 }}>
|
||||
草稿组总数
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 草稿组列表 */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<div className="space-y-2 px-4 py-2">
|
||||
{filteredGroups.length > 0 ? (
|
||||
filteredGroups.map((group) => {
|
||||
const statusStyle = getStatusStyle(group.status)
|
||||
const isSelected = group.id === selectedDraftGroupId
|
||||
return (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
className="stitch-list-row w-full gap-3 text-left"
|
||||
onClick={() => onSelectDraftGroup(group.id)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="stitch-section-title truncate" style={{ fontSize: 15 }}>
|
||||
{group.name}
|
||||
</div>
|
||||
<div className="stitch-body-secondary mt-0.5 truncate">
|
||||
{group.preSkus?.length ?? 0} 个货号 · {getStatusLabel(group.status)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="stitch-badge shrink-0"
|
||||
style={{ backgroundColor: statusStyle.bg, color: statusStyle.color }}
|
||||
>
|
||||
{getStatusLabel(group.status)}
|
||||
</span>
|
||||
<div className="stitch-mono shrink-0">{formatDate(group.updatedAt)}</div>
|
||||
<svg
|
||||
width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="#8B9092" strokeWidth="2" strokeLinecap="round"
|
||||
className="shrink-0"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="py-8 text-center stitch-body-secondary">
|
||||
{searchText ? `未找到匹配「${searchText}」的草稿组` : '暂无草稿组'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1675,19 +1675,26 @@
|
||||
}
|
||||
|
||||
/* ===== Stitch Design 移动端原子类 ===== */
|
||||
/* 精确还原 Stitch 设计稿的视觉 Token,仅用于移动端 */
|
||||
/* Obsidian Atelier Pure — 像素级还原 Stitch 设计稿视觉 Token */
|
||||
/* 仅通过 max-lg: 响应式类在移动端生效 */
|
||||
|
||||
@layer utilities {
|
||||
/* === 基础卡片 === */
|
||||
.stitch-card {
|
||||
background-color: var(--color-surface-container-lowest, #ffffff);
|
||||
border-radius: 0.125rem;
|
||||
background-color: #ffffff;
|
||||
border-radius: 0.75rem; /* 12px */
|
||||
box-shadow: 0px 4px 20px rgba(45, 51, 53, 0.06);
|
||||
}
|
||||
|
||||
/* === 顶栏玻璃态 === */
|
||||
.stitch-topbar {
|
||||
background-color: rgba(248, 249, 250, 0.80);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
box-shadow: 0px 4px 20px rgba(45, 51, 53, 0.06);
|
||||
}
|
||||
|
||||
/* === 底栏玻璃态 === */
|
||||
.stitch-bottombar {
|
||||
background-color: rgba(248, 249, 250, 0.80);
|
||||
backdrop-filter: blur(24px);
|
||||
@ -1695,19 +1702,26 @@
|
||||
border-top: 0.5px solid rgba(171, 179, 183, 0.15);
|
||||
box-shadow: 0px -4px 20px rgba(45, 51, 53, 0.04);
|
||||
}
|
||||
|
||||
/* === 列表行 === */
|
||||
.stitch-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
background-color: #ffffff;
|
||||
border-radius: 0.125rem;
|
||||
border-radius: 0.5rem; /* 8px */
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
transition: background-color 200ms ease-out;
|
||||
}
|
||||
.stitch-list-row:hover {
|
||||
background-color: #f1f4f6;
|
||||
}
|
||||
.stitch-list-row:active {
|
||||
background-color: #e8ecef;
|
||||
}
|
||||
|
||||
/* === 徽标 === */
|
||||
.stitch-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -1717,51 +1731,183 @@
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.stitch-badge-default {
|
||||
background-color: rgba(45, 51, 53, 0.06);
|
||||
color: #596065;
|
||||
}
|
||||
.stitch-badge-filled {
|
||||
background-color: #2D3335;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* === 阴影 === */
|
||||
.stitch-shadow {
|
||||
box-shadow: 0px 4px 20px rgba(45, 51, 53, 0.06);
|
||||
}
|
||||
.stitch-shadow-top {
|
||||
box-shadow: 0px -4px 20px rgba(45, 51, 53, 0.04);
|
||||
}
|
||||
|
||||
/* === 搜索输入 === */
|
||||
.stitch-search-input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
background-color: var(--color-surface-container-highest, #dbe4e7);
|
||||
background-color: var(--color-surface-container-high, #e1e5e7);
|
||||
border: none;
|
||||
border-radius: 0.125rem;
|
||||
border-radius: 0.5rem; /* 8px */
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
color: var(--color-on-surface, #2b3437);
|
||||
font-family: 'Inter', sans-serif;
|
||||
color: #2D3335;
|
||||
transition: box-shadow 200ms ease-out;
|
||||
}
|
||||
.stitch-search-input:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 1px rgba(89, 95, 97, 0.4);
|
||||
}
|
||||
.stitch-search-input::placeholder {
|
||||
color: var(--color-on-surface-variant, #586064);
|
||||
color: #8B9092;
|
||||
}
|
||||
|
||||
/* === Tab 标签 === */
|
||||
.stitch-tab {
|
||||
padding: 6px 16px;
|
||||
border-radius: 0.25rem;
|
||||
border-radius: 0.25rem; /* 4px */
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: 'Inter', sans-serif;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
transition: all 200ms ease-out;
|
||||
}
|
||||
.stitch-tab.active {
|
||||
background-color: var(--color-primary, #595f61);
|
||||
color: var(--color-on-primary, #f3f9fb);
|
||||
box-shadow: 0px 4px 20px rgba(45, 51, 53, 0.06);
|
||||
background-color: #2D3335;
|
||||
color: #ffffff;
|
||||
}
|
||||
.stitch-tab:not(.active) {
|
||||
background-color: var(--color-tertiary-container, #f2f4f5);
|
||||
color: var(--color-on-tertiary-container, #595c5d);
|
||||
background-color: #f2f4f5;
|
||||
color: #595c5d;
|
||||
}
|
||||
.stitch-tab:not(.active):hover {
|
||||
background-color: var(--color-surface-container-low, #f1f4f6);
|
||||
background-color: #e8ecef;
|
||||
}
|
||||
|
||||
/* === HUD 玻璃面板(仅用于浮层)=== */
|
||||
.stitch-hud {
|
||||
background-color: rgba(248, 249, 250, 0.80);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0px 4px 20px rgba(45, 51, 53, 0.06);
|
||||
}
|
||||
|
||||
/* === 移动端页面背景 === */
|
||||
.stitch-mobile-bg {
|
||||
background-color: #F8F9FA;
|
||||
}
|
||||
|
||||
/* === 表单输入 === */
|
||||
.stitch-form-input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
background-color: var(--color-surface-container-high, #e1e5e7);
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
color: #2D3335;
|
||||
transition: box-shadow 200ms ease-out;
|
||||
}
|
||||
.stitch-form-input:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 1px rgba(89, 95, 97, 0.4);
|
||||
}
|
||||
|
||||
/* === 主按钮 === */
|
||||
.stitch-btn-primary {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background-color: #2D3335;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease-out;
|
||||
}
|
||||
.stitch-btn-primary:hover {
|
||||
background-color: #3d4345;
|
||||
}
|
||||
.stitch-btn-primary:active {
|
||||
background-color: #1f2325;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* === 圆形图标按钮 === */
|
||||
.stitch-icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease-out;
|
||||
}
|
||||
.stitch-icon-btn-dark {
|
||||
background-color: #2D3335;
|
||||
color: #ffffff;
|
||||
}
|
||||
.stitch-icon-btn-light {
|
||||
background-color: rgba(45, 51, 53, 0.06);
|
||||
color: #2D3335;
|
||||
}
|
||||
|
||||
/* === 页面标题 === */
|
||||
.stitch-page-title {
|
||||
font-family: 'Manrope', sans-serif;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
color: #2D3335;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* === 区块标题 === */
|
||||
.stitch-section-title {
|
||||
font-family: 'Manrope', sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #2D3335;
|
||||
}
|
||||
|
||||
/* === 正文 === */
|
||||
.stitch-body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #2D3335;
|
||||
}
|
||||
|
||||
/* === 次要文本 === */
|
||||
.stitch-body-secondary {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: #596065;
|
||||
}
|
||||
|
||||
/* === 数据/时间 === */
|
||||
.stitch-mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: rgba(45, 51, 56, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useBreakpoint } from '@/hooks/useMediaQuery';
|
||||
import { IntakeWorkspaceTabBar } from "@/app/intake/components/IntakeWorkspaceTabBar";
|
||||
|
||||
export function IntakeWorkspaceShell({
|
||||
@ -16,8 +17,10 @@ export function IntakeWorkspaceShell({
|
||||
onActivate: (tabId: string) => void;
|
||||
onClose: (tabId: string) => void;
|
||||
}) {
|
||||
const { isMobileOrTablet } = useBreakpoint();
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-2">
|
||||
<div className={`flex h-full min-h-0 flex-col ${isMobileOrTablet ? 'gap-1' : 'gap-2'}`}>
|
||||
<IntakeWorkspaceTabBar
|
||||
tabs={tabs}
|
||||
activeTabId={activeTabId}
|
||||
|
||||
@ -239,7 +239,7 @@ function AppShellContent({ children, user }: AppShellProps) {
|
||||
? 'mt-14 h-[calc(100dvh-56px-56px)] min-h-[calc(100dvh-56px-56px)] overflow-hidden overscroll-none'
|
||||
: 'mt-16 h-[calc(100dvh-64px)] min-h-[calc(100dvh-64px)] overflow-hidden overscroll-none'
|
||||
}
|
||||
style={{ backgroundColor: 'var(--obs-shell-bg)' }}
|
||||
style={{ backgroundColor: isMobileOrTablet ? '#F8F9FA' : 'var(--obs-shell-bg)' }}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
@ -40,10 +40,11 @@ export function MobileTabBar({ user }: MobileTabBarProps) {
|
||||
<nav
|
||||
className="fixed bottom-0 left-0 right-0 z-50 lg:hidden"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.92)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
borderTop: '1px solid rgba(0, 0, 0, 0.06)',
|
||||
backgroundColor: 'rgba(248, 249, 250, 0.80)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
borderTop: '0.5px solid rgba(171, 179, 183, 0.15)',
|
||||
boxShadow: '0px -4px 20px rgba(45, 51, 53, 0.04)',
|
||||
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
||||
}}
|
||||
role="navigation"
|
||||
@ -62,29 +63,23 @@ export function MobileTabBar({ user }: MobileTabBarProps) {
|
||||
e.preventDefault()
|
||||
router.push(item.href)
|
||||
}}
|
||||
className="flex flex-1 flex-col items-center justify-center gap-0.5 py-2 transition-colors"
|
||||
className="flex flex-1 flex-col items-center justify-center gap-0.5 py-2 transition-colors duration-200"
|
||||
style={{
|
||||
minHeight: '48px',
|
||||
color: active ? '#2d3335' : '#8b9092',
|
||||
minHeight: '56px',
|
||||
color: active ? '#2D3335' : '#8B9092',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
<div
|
||||
className="flex h-7 w-11 items-center justify-center rounded-full transition-all duration-200"
|
||||
style={{
|
||||
backgroundColor: active ? 'rgba(45, 51, 53, 0.08)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
size={20}
|
||||
strokeWidth={active ? 2.2 : 1.6}
|
||||
/>
|
||||
</div>
|
||||
<Icon
|
||||
size={20}
|
||||
strokeWidth={active ? 2.2 : 1.5}
|
||||
/>
|
||||
<span
|
||||
className="text-[10px] leading-none font-medium"
|
||||
className="text-[10px] leading-none tracking-wider uppercase"
|
||||
style={{
|
||||
fontWeight: active ? 600 : 500,
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
fontWeight: active ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
|
||||
280
furniture-3d-system/src/components/mobile/StitchMobileLayout.tsx
Normal file
280
furniture-3d-system/src/components/mobile/StitchMobileLayout.tsx
Normal file
@ -0,0 +1,280 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Stitch 移动端页面标准布局
|
||||
*
|
||||
* 提供统一的页面结构:
|
||||
* - 顶部标题区域(可选)
|
||||
* - 搜索栏(可选)
|
||||
* - Tab 切换(可选)
|
||||
* - 可滚动内容区
|
||||
* - 底部固定按钮区(可选)
|
||||
*
|
||||
* 所有样式遵循 Stitch "Obsidian Atelier Pure" 设计规范
|
||||
*/
|
||||
|
||||
interface StitchMobileLayoutProps {
|
||||
/** 页面标题 */
|
||||
title?: string
|
||||
/** 搜索框值 */
|
||||
searchValue?: string
|
||||
/** 搜索框 placeholder */
|
||||
searchPlaceholder?: string
|
||||
/** 搜索变更回调 */
|
||||
onSearchChange?: (value: string) => void
|
||||
/** Tab 选项 */
|
||||
tabs?: Array<{ key: string; label: string }>
|
||||
/** 当前激活的 Tab key */
|
||||
activeTab?: string
|
||||
/** Tab 切换回调 */
|
||||
onTabChange?: (key: string) => void
|
||||
/** 页面主体内容 */
|
||||
children: ReactNode
|
||||
/** 底部固定区域(如保存按钮) */
|
||||
footer?: ReactNode
|
||||
/** 额外的 className */
|
||||
className?: string
|
||||
/** 是否显示搜索图标(默认 true) */
|
||||
showSearchIcon?: boolean
|
||||
}
|
||||
|
||||
export function StitchMobileLayout({
|
||||
title,
|
||||
searchValue,
|
||||
searchPlaceholder = '搜索...',
|
||||
onSearchChange,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
showSearchIcon = true,
|
||||
}: StitchMobileLayoutProps) {
|
||||
const hasSearch = typeof searchValue === 'string' && onSearchChange
|
||||
const hasTabs = tabs && tabs.length > 0 && activeTab && onTabChange
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', 'stitch-mobile-bg', className)}>
|
||||
{/* 标题区 */}
|
||||
{title ? (
|
||||
<div className="shrink-0 px-4 pt-3 pb-2">
|
||||
<h1 className="stitch-page-title">{title}</h1>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 搜索区 */}
|
||||
{hasSearch ? (
|
||||
<div className="shrink-0 px-4 pb-2">
|
||||
<div className="relative">
|
||||
{showSearchIcon ? (
|
||||
<svg
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#596065"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
) : null}
|
||||
<input
|
||||
type="text"
|
||||
className={cn('stitch-search-input', showSearchIcon && 'pl-10')}
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Tab 区 */}
|
||||
{hasTabs ? (
|
||||
<div className="shrink-0 px-4 pb-2">
|
||||
<div className="flex items-center gap-2 overflow-x-auto" style={{ scrollbarWidth: 'none' }}>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={cn('stitch-tab', activeTab === tab.key && 'active')}
|
||||
onClick={() => onTabChange(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 内容区 */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<div className="px-4 py-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部固定区 */}
|
||||
{footer ? (
|
||||
<div className="shrink-0 border-t-0 px-4 py-3" style={{ backgroundColor: '#F8F9FA' }}>
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stitch 移动端指标卡片
|
||||
*/
|
||||
interface StitchMetricCardProps {
|
||||
label: string
|
||||
value: string | number
|
||||
icon?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StitchMetricCard({ label, value, icon, className }: StitchMetricCardProps) {
|
||||
return (
|
||||
<div className={cn('stitch-card flex items-center gap-3 px-4 py-3', className)}>
|
||||
{icon ? (
|
||||
<div
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: 'rgba(45, 51, 53, 0.06)' }}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="stitch-mono"
|
||||
style={{ fontSize: 10, color: '#596065', marginBottom: 2 }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "'Manrope', sans-serif",
|
||||
fontSize: 24,
|
||||
fontWeight: 800,
|
||||
color: '#2D3335',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stitch 移动端列表行
|
||||
*/
|
||||
interface StitchListRowProps {
|
||||
/** 主标题 */
|
||||
title: string
|
||||
/** 副标题(第二行) */
|
||||
subtitle?: string
|
||||
/** 第三行辅助文本 */
|
||||
auxiliary?: string
|
||||
/** 左侧图标 */
|
||||
icon?: ReactNode
|
||||
/** 右侧内容(徽标、计数等) */
|
||||
trailing?: ReactNode
|
||||
/** 是否显示右侧箭头 */
|
||||
showChevron?: boolean
|
||||
/** 点击回调 */
|
||||
onClick?: () => void
|
||||
/** 额外 className */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StitchListRow({
|
||||
title,
|
||||
subtitle,
|
||||
auxiliary,
|
||||
icon,
|
||||
trailing,
|
||||
showChevron = false,
|
||||
onClick,
|
||||
className,
|
||||
}: StitchListRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('stitch-list-row gap-3', className)}
|
||||
onClick={onClick}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
>
|
||||
{/* 左侧图标 */}
|
||||
{icon ? (
|
||||
<div
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: 'rgba(45, 51, 53, 0.06)' }}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 中间文本区 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="stitch-section-title truncate" style={{ fontSize: 15 }}>
|
||||
{title}
|
||||
</div>
|
||||
{subtitle ? (
|
||||
<div className="stitch-body-secondary mt-0.5 truncate">{subtitle}</div>
|
||||
) : null}
|
||||
{auxiliary ? (
|
||||
<div className="stitch-mono mt-0.5">{auxiliary}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 右侧内容 */}
|
||||
{trailing ? (
|
||||
<div className="shrink-0">{trailing}</div>
|
||||
) : null}
|
||||
|
||||
{/* 右侧箭头 */}
|
||||
{showChevron ? (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#8B9092"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
className="shrink-0"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stitch 移动端 Section 分隔标题
|
||||
*/
|
||||
interface StitchSectionHeaderProps {
|
||||
title: string
|
||||
action?: ReactNode
|
||||
}
|
||||
|
||||
export function StitchSectionHeader({ title, action }: StitchSectionHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="stitch-section-title">{title}</span>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StitchMobileLayout
|
||||
@ -169,9 +169,9 @@ export function PlatformSplitLayout({
|
||||
)}
|
||||
>
|
||||
<section
|
||||
className="mx-auto flex min-h-0 w-full max-w-[1720px] flex-1 gap-3 overflow-hidden p-3 max-lg:!mx-0 max-lg:!max-w-none max-lg:gap-2 max-lg:p-2"
|
||||
className="mx-auto flex min-h-0 w-full max-w-[1720px] flex-1 gap-3 overflow-hidden p-3 max-lg:!mx-0 max-lg:!max-w-none max-lg:gap-2 max-lg:p-3"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-background-primary)',
|
||||
backgroundColor: '#F8F9FA',
|
||||
}}
|
||||
>
|
||||
{mainContent ? (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user