chore: initial commit - maqt-desktop v0.2
- Phase 1-5: UI framework, auth, weapon schemes, color filters, system optimization - Industrial/tech design style with Chinese localization - Points to gch3n.online/delta backend API - Hardware monitor, filter editor, and all module pages
This commit is contained in:
65
src/components/filters/FilterCard.tsx
Normal file
65
src/components/filters/FilterCard.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface FilterData {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
category: string;
|
||||
likes: number;
|
||||
downloads: number;
|
||||
preview?: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface FilterCardProps {
|
||||
filter: FilterData;
|
||||
liked: boolean;
|
||||
onLike: (id: string) => void;
|
||||
onDownload: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function FilterCard({ filter, liked, onLike, onDownload }: FilterCardProps) {
|
||||
return (
|
||||
<div className="border border-[#333] bg-[#1a1a1a] relative transition-colors duration-75 hover:border-[#555]">
|
||||
<div className="absolute top-0 left-0 w-2 h-2 border-t border-l border-[#ff4500]/30 pointer-events-none" />
|
||||
<div className="absolute bottom-0 right-0 w-2 h-2 border-b border-r border-[#ff4500]/30 pointer-events-none" />
|
||||
|
||||
{/* 预览占位 */}
|
||||
<div className="h-20 bg-[#222] flex items-center justify-center border-b border-[#333]">
|
||||
<span className="material-symbols-outlined text-[20px] text-[#444]">image</span>
|
||||
</div>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="p-2 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-[9px] font-semibold tracking-[0.1em] uppercase text-[#e0e0e0] truncate">
|
||||
{filter.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[8px] font-mono text-[#555] truncate">{filter.author}</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-1 border-t border-[#333]">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onLike(filter.id)}
|
||||
className={`text-[8px] font-mono transition-colors duration-75
|
||||
${liked ? 'text-[#ff4500]' : 'text-[#555] hover:text-[#888]'}`}
|
||||
>
|
||||
♥ {filter.likes + (liked ? 1 : 0)}
|
||||
</button>
|
||||
<span className="text-[8px] font-mono text-[#555]">↓ {filter.downloads}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onDownload(filter.id)}
|
||||
className="text-[7px] font-semibold tracking-[0.1em] uppercase text-[#888] border border-[#333] px-1.5 py-0.5
|
||||
hover:border-[#ff4500] hover:text-[#ff4500] transition-colors duration-75"
|
||||
>
|
||||
获取
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="tech-sn absolute top-1 right-1">{filter.category.toUpperCase().slice(0, 4)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
src/components/filters/FilterEditor.tsx
Normal file
152
src/components/filters/FilterEditor.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React, { useState } from 'react';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
interface FilterParams {
|
||||
brightness: number;
|
||||
contrast: number;
|
||||
saturation: number;
|
||||
gamma: number;
|
||||
temperature: number;
|
||||
rGain: number;
|
||||
gGain: number;
|
||||
bGain: number;
|
||||
vibrance: number;
|
||||
}
|
||||
|
||||
interface FilterEditorProps {
|
||||
initialParams?: Partial<FilterParams>;
|
||||
onSave?: (params: FilterParams) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
function SliderControl({ label, value, min, max, step, unit, onChange }: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
unit: string;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
const pct = ((value - min) / (max - min)) * 100;
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1.5 border-b border-[#333] last:border-b-0">
|
||||
<span className="text-[8px] font-mono text-[#555] w-16 shrink-0 uppercase tracking-wider">{label}</span>
|
||||
<div className="flex-1 relative h-2 border border-[#333] bg-[#111]">
|
||||
<div className="absolute left-0 top-0 h-full bg-[#ff4500]" style={{ width: `${pct}%` }} />
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={e => onChange(+e.target.value)}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[8px] font-mono text-[#888] w-12 text-right">{value.toFixed(step < 0.1 ? 2 : 1)}{unit}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_PARAMS: FilterParams = {
|
||||
brightness: 0,
|
||||
contrast: 0,
|
||||
saturation: 0,
|
||||
gamma: 1.0,
|
||||
temperature: 6500,
|
||||
rGain: 1.0,
|
||||
gGain: 1.0,
|
||||
bGain: 1.0,
|
||||
vibrance: 0,
|
||||
};
|
||||
|
||||
export default function FilterEditor({ initialParams, onSave, onCancel }: FilterEditorProps) {
|
||||
const [params, setParams] = useState<FilterParams>({ ...DEFAULT_PARAMS, ...initialParams });
|
||||
const [name, setName] = useState('');
|
||||
|
||||
const update = (key: keyof FilterParams) => (val: number) => setParams(prev => ({ ...prev, [key]: val }));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Name */}
|
||||
<Card serial="FLT-EDIT">
|
||||
<div className="p-2.5 space-y-2">
|
||||
<div className="flex items-center gap-2 border-b border-[#333] pb-1.5">
|
||||
<span className="material-symbols-outlined text-[14px] text-[#ff4500]">tune</span>
|
||||
<h2 className="text-[9px] font-bold tracking-[0.15em] uppercase text-[#e0e0e0]">滤镜编辑器</h2>
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="滤镜名称"
|
||||
className="w-full border border-[#333] bg-[#111] text-[#e0e0e0] px-2 py-1.5 text-[9px] font-mono
|
||||
placeholder:text-[#444] outline-none focus:border-[#ff4500] transition-colors duration-75"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tone controls */}
|
||||
<Card serial="FLT-TONE">
|
||||
<div className="p-2.5">
|
||||
<h3 className="text-[8px] font-bold tracking-[0.15em] uppercase text-[#888] mb-1">色调</h3>
|
||||
<SliderControl label="亮度" value={params.brightness} min={-100} max={100} step={1} unit="" onChange={update('brightness')} />
|
||||
<SliderControl label="对比度" value={params.contrast} min={-100} max={100} step={1} unit="" onChange={update('contrast')} />
|
||||
<SliderControl label="饱和度" value={params.saturation} min={-100} max={100} step={1} unit="" onChange={update('saturation')} />
|
||||
<SliderControl label="鲜艳度" value={params.vibrance} min={-100} max={100} step={1} unit="" onChange={update('vibrance')} />
|
||||
<SliderControl label="Gamma" value={params.gamma} min={0.1} max={3.0} step={0.01} unit="" onChange={update('gamma')} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Color balance */}
|
||||
<Card serial="FLT-RGB">
|
||||
<div className="p-2.5">
|
||||
<h3 className="text-[8px] font-bold tracking-[0.15em] uppercase text-[#888] mb-1">色彩平衡</h3>
|
||||
<SliderControl label="R" value={params.rGain} min={0} max={2} step={0.01} unit="" onChange={update('rGain')} />
|
||||
<SliderControl label="G" value={params.gGain} min={0} max={2} step={0.01} unit="" onChange={update('gGain')} />
|
||||
<SliderControl label="B" value={params.bGain} min={0} max={2} step={0.01} unit="" onChange={update('bGain')} />
|
||||
<SliderControl label="色温" value={params.temperature} min={3000} max={10000} step={50} unit="K" onChange={update('temperature')} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Preview bar */}
|
||||
<Card serial="FLT-PREV">
|
||||
<div className="p-2.5 space-y-1">
|
||||
<h3 className="text-[8px] font-bold tracking-[0.15em] uppercase text-[#888]">预览</h3>
|
||||
<div className="flex gap-1 h-16">
|
||||
{/* Color gradient preview affected by params */}
|
||||
<div
|
||||
className="flex-1 border border-[#333] transition-all duration-200"
|
||||
style={{
|
||||
background: `linear-gradient(90deg,
|
||||
hsl(0, ${50 + params.saturation * 0.5}%, ${50 + params.brightness * 0.3 + params.contrast * 0.2}%),
|
||||
hsl(60, ${50 + params.saturation * 0.5}%, ${50 + params.brightness * 0.3 + params.contrast * 0.2}%),
|
||||
hsl(120, ${50 + params.saturation * 0.5}%, ${50 + params.brightness * 0.3 + params.contrast * 0.2}%),
|
||||
hsl(180, ${50 + params.saturation * 0.5}%, ${50 + params.brightness * 0.3 + params.contrast * 0.2}%),
|
||||
hsl(240, ${50 + params.saturation * 0.5}%, ${50 + params.brightness * 0.3 + params.contrast * 0.2}%),
|
||||
hsl(300, ${50 + params.saturation * 0.5}%, ${50 + params.brightness * 0.3 + params.contrast * 0.2}%)
|
||||
),
|
||||
linear-gradient(180deg, transparent 0%, rgba(255,69,0,${params.temperature > 6500 ? 0.1 : 0}) 100%)`,
|
||||
}}
|
||||
/>
|
||||
{/* RGB indicator */}
|
||||
<div className="flex flex-col justify-center gap-0.5 w-6 border border-[#333] bg-[#111] items-center">
|
||||
<div className="w-4 h-1" style={{ background: `rgba(255,0,0,${params.rGain})` }} />
|
||||
<div className="w-4 h-1" style={{ background: `rgba(0,255,0,${params.gGain})` }} />
|
||||
<div className="w-4 h-1" style={{ background: `rgba(0,0,255,${params.bGain})` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="primary" size="sm" className="flex-1 text-[9px]" onClick={() => onSave?.(params)}>保存滤镜</Button>
|
||||
<Button variant="ghost" size="sm" className="text-[9px]" onClick={onCancel}>取消</Button>
|
||||
<Button variant="ghost" size="sm" className="text-[9px]" onClick={() => setParams({ ...DEFAULT_PARAMS })}>重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/components/filters/FilterGrid.tsx
Normal file
51
src/components/filters/FilterGrid.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import FilterCard from './FilterCard';
|
||||
import type { FilterData } from './FilterCard';
|
||||
|
||||
interface FilterGridProps {
|
||||
filters: FilterData[];
|
||||
loading: boolean;
|
||||
likedIds: Set<string>;
|
||||
onLike: (id: string) => void;
|
||||
onDownload: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function FilterGrid({ filters, loading, likedIds, onLike, onDownload }: FilterGridProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="border border-[#333] bg-[#1a1a1a] p-2 animate-pulse">
|
||||
<div className="h-20 bg-[#222] mb-2" />
|
||||
<div className="h-3 bg-[#222] w-3/4 mb-1" />
|
||||
<div className="h-2 bg-[#222] w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2 border border-[#333]">
|
||||
<span className="material-symbols-outlined text-[24px] text-[#444]">photo_library</span>
|
||||
<p className="text-[10px] font-mono text-[#555]">暂无滤镜数据</p>
|
||||
<span className="tech-sn">空状态 / 暂无数据</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{filters.map(f => (
|
||||
<FilterCard
|
||||
key={f.id}
|
||||
filter={f}
|
||||
liked={likedIds.has(f.id)}
|
||||
onLike={onLike}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/components/filters/FilterPreview.tsx
Normal file
34
src/components/filters/FilterPreview.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import Card from '../ui/Card';
|
||||
|
||||
interface FilterPreviewProps {
|
||||
filter: { title: string; description?: string; previewUrl?: string };
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export default function FilterPreview({ filter, onClose }: FilterPreviewProps) {
|
||||
return (
|
||||
<Card className="max-w-2xl mx-auto p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-[15px] font-bold tracking-[0.12em]">{filter.title}</h2>
|
||||
{onClose && <button onClick={onClose} className="text-tactical-textDim hover:text-tactical-text text-[14px]">✕</button>}
|
||||
</div>
|
||||
|
||||
{/* Preview image area */}
|
||||
<div className="h-48 rounded-[4px] border border-tactical-gray/30 bg-gradient-to-br from-tactical-dark via-tactical-gray/20 to-tactical-dark flex items-center justify-center mb-4">
|
||||
{filter.previewUrl ? (
|
||||
<img src={filter.previewUrl} alt={filter.title} className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 text-tactical-textDim/50">
|
||||
<span className="material-symbols-outlined text-[48px]">image</span>
|
||||
<span className="text-[11px]">预览区域</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filter.description && (
|
||||
<p className="text-[12px] text-tactical-textMuted/70 leading-relaxed">{filter.description}</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
43
src/components/layout/BottomDock.tsx
Normal file
43
src/components/layout/BottomDock.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
interface DockItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface BottomDockProps {
|
||||
items: DockItem[];
|
||||
currentPage: string;
|
||||
onNavigate: (page: string) => void;
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
export default function BottomDock({ items, currentPage, onNavigate, visible = true }: BottomDockProps) {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-30 flex justify-center pb-2">
|
||||
<div className="flex border border-[#333] bg-[#1a1a1a]">
|
||||
{items.map(item => {
|
||||
const isActive = currentPage === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
className={`relative px-3.5 py-2 text-[9px] font-semibold tracking-[0.12em] uppercase
|
||||
border-r border-[#333] last:border-r-0 transition-colors duration-75
|
||||
${isActive ? 'bg-[#ff4500]/10 text-[#ff4500]' : 'text-[#555] hover:text-[#e0e0e0] hover:bg-[#222]'}`}
|
||||
>
|
||||
{isActive && (
|
||||
<span className="absolute top-0 left-0 right-0 h-[1px] bg-[#ff4500]" />
|
||||
)}
|
||||
<span className="material-symbols-outlined text-[14px] mr-1.5 align-middle">{item.icon}</span>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/components/layout/DesktopGrid.tsx
Normal file
43
src/components/layout/DesktopGrid.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import DesktopIcon from './DesktopIcon';
|
||||
|
||||
interface DesktopItem {
|
||||
id: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
vip?: boolean;
|
||||
loginRequired?: boolean;
|
||||
locked?: boolean;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
interface DesktopGridProps {
|
||||
items: DesktopItem[];
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
}
|
||||
|
||||
export default function DesktopGrid({ items, rows = 2, cols = 4 }: DesktopGridProps) {
|
||||
return (
|
||||
<div
|
||||
className="grid border border-[#333] bg-[#1a1a1a]"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${rows}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{items.map((item, idx) => (
|
||||
<DesktopIcon
|
||||
key={item.id}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
onClick={item.action}
|
||||
disabled={false}
|
||||
locked={item.locked}
|
||||
vip={item.vip}
|
||||
index={idx}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/components/layout/DesktopIcon.tsx
Normal file
64
src/components/layout/DesktopIcon.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
|
||||
interface DesktopIconProps {
|
||||
icon: string;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
locked?: boolean;
|
||||
vip?: boolean;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export default function DesktopIcon({ icon, label, onClick, disabled, locked, vip, index = 0 }: DesktopIconProps) {
|
||||
const isBlocked = locked || disabled;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="relative flex flex-col items-center justify-center gap-1.5 py-5 border border-[#333]
|
||||
transition-colors duration-75 cursor-pointer disabled:cursor-not-allowed
|
||||
hover:bg-[#222] hover:border-[#555] group"
|
||||
>
|
||||
{/* 坐标标记 */}
|
||||
<span className="tech-sn absolute top-1 left-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
X{index % 4}Y{Math.floor(index / 4)}
|
||||
</span>
|
||||
|
||||
{/* 锁定遮罩 */}
|
||||
{isBlocked && (
|
||||
<div className="absolute inset-0 bg-[#1a1a1a]/80 flex items-center justify-center z-10">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-[#555] text-[12px]">🔒</span>
|
||||
<span className="text-[7px] font-mono text-[#444] tracking-wider">
|
||||
{vip ? 'VIP ONLY' : '需登录'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图标 */}
|
||||
<span className={`material-symbols-outlined text-[24px] transition-colors duration-75
|
||||
${isBlocked ? 'text-[#444]' : 'text-[#888] group-hover:text-[#ff4500]'}`}>
|
||||
{icon}
|
||||
</span>
|
||||
|
||||
{/* 标签 */}
|
||||
<span className={`text-[9px] font-semibold tracking-[0.12em] uppercase transition-colors duration-75
|
||||
${isBlocked ? 'text-[#444]' : 'text-[#555] group-hover:text-[#e0e0e0]'}`}>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{/* VIP 标记 */}
|
||||
{vip && !isBlocked && (
|
||||
<span className="absolute top-1.5 right-1.5 text-[7px] text-[#ff4500]">✦</span>
|
||||
)}
|
||||
|
||||
{/* 底部强调线 */}
|
||||
{!isBlocked && (
|
||||
<span className="absolute bottom-0 left-1/4 right-1/4 h-[1px] bg-[#ff4500] scale-x-0 group-hover:scale-x-100 transition-transform duration-75" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
14
src/components/layout/PageContainer.tsx
Normal file
14
src/components/layout/PageContainer.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
interface PageContainerProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PageContainer({ children, className = '' }: PageContainerProps) {
|
||||
return (
|
||||
<div className={`h-screen overflow-y-auto border border-[#333] bg-[#1a1a1a] flex flex-col ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
src/components/layout/TopBar.tsx
Normal file
79
src/components/layout/TopBar.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import Compass from '../ui/Compass';
|
||||
|
||||
interface TopBarProps {
|
||||
cpuTemp?: number;
|
||||
gpuTemp?: number;
|
||||
fps?: number;
|
||||
isVip?: boolean;
|
||||
ping?: number;
|
||||
onMinimize?: () => void;
|
||||
onMaximize?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export default function TopBar({
|
||||
cpuTemp = 52, gpuTemp = 68, fps = 144, isVip, ping = 20,
|
||||
onMinimize, onMaximize, onClose,
|
||||
}: TopBarProps) {
|
||||
return (
|
||||
<div
|
||||
className="fixed top-0 left-0 right-0 z-40 flex items-center justify-between px-4 py-2.5
|
||||
backdrop-blur-[12px] bg-tactical-black/70 border-b border-tactical-gray/20 select-none"
|
||||
style={{ WebkitAppRegion: 'drag' as any }}
|
||||
>
|
||||
{/* Left: Time + Compass */}
|
||||
<div className="flex items-center gap-3" style={{ WebkitAppRegion: 'no-drag' as any }}>
|
||||
<Compass className="w-5 h-5" />
|
||||
<span className="text-[12px] font-semibold text-tactical-textMuted tracking-wider">
|
||||
{new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 text-[10px] text-tactical-textMuted">
|
||||
<span className="w-1 h-1 rounded-full bg-tactical-army" />
|
||||
<span>ONLINE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: Hardware status */}
|
||||
<div className="flex items-center gap-4" style={{ WebkitAppRegion: 'no-drag' as any }}>
|
||||
<div className="flex items-center gap-1.5 text-[11px]">
|
||||
<span className="text-tactical-textDim">CPU</span>
|
||||
<span className="font-bold text-tactical-textMuted">{cpuTemp}°</span>
|
||||
<div className="w-12 h-[3px] bg-tactical-dark/80 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-tactical-army transition-all" style={{ width: `${Math.min(cpuTemp, 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[11px]">
|
||||
<span className="text-tactical-textDim">GPU</span>
|
||||
<span className="font-bold text-tactical-textMuted">{gpuTemp}°</span>
|
||||
<div className="w-12 h-[3px] bg-tactical-dark/80 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-tactical-olive transition-all" style={{ width: `${Math.min(gpuTemp, 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[11px]">
|
||||
<span className="text-tactical-textDim">FPS</span>
|
||||
<span className="font-bold text-tactical-textMuted">{fps}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: VIP + Network + Controls */}
|
||||
<div className="flex items-center gap-3" style={{ WebkitAppRegion: 'no-drag' as any }}>
|
||||
{isVip && (
|
||||
<div className="flex items-center gap-1 text-[10px] tracking-[0.15em] text-tactical-goldLight bg-tactical-gold/10 px-2 py-0.5 border border-tactical-gold/30">
|
||||
<span>✦ VIP</span>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-tactical-goldLight animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1 text-[10px] text-tactical-army">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-tactical-army" />
|
||||
{ping}ms
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<button onClick={onMinimize} className="text-tactical-textDim hover:text-tactical-textMuted text-[12px] px-1.5 py-0.5 transition-colors">━</button>
|
||||
<button onClick={onMaximize} className="text-tactical-textDim hover:text-tactical-textMuted text-[12px] px-1.5 py-0.5 transition-colors">☐</button>
|
||||
<button onClick={onClose} className="text-tactical-textDim hover:text-tactical-warn text-[12px] px-1.5 py-0.5 transition-colors">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/components/layout/TopHud.tsx
Normal file
34
src/components/layout/TopHud.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
|
||||
interface TopHudProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
sections?: { label: string; value: string }[];
|
||||
}
|
||||
|
||||
export default function TopHud({ title = '码枪堂 2.0', subtitle, sections = [] }: TopHudProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-[#333] bg-[#1a1a1a] px-3 py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[#ff4500] text-[8px]">◆</span>
|
||||
<h1 className="text-[9px] font-bold tracking-[0.15em] uppercase text-[#e0e0e0]">{title}</h1>
|
||||
{subtitle && (
|
||||
<>
|
||||
<span className="text-[#444] text-[8px]">|</span>
|
||||
<span className="tech-sn">{subtitle}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{sections.map((s, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<span className="text-[7px] tracking-[0.15em] uppercase text-[#555]">{s.label}</span>
|
||||
<span className="text-[9px] font-mono text-[#e0e0e0]">{s.value}</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="w-[1px] h-3 bg-[#333]" />
|
||||
<span className="text-[8px] font-mono text-[#444]">V0.2.1</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
src/components/optimization/OptimizeItem.tsx
Normal file
77
src/components/optimization/OptimizeItem.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface OptimizeItemData {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
category: string;
|
||||
icon: string;
|
||||
status: 'pending' | 'optimized' | 'restored' | 'error';
|
||||
}
|
||||
|
||||
interface OptimizeItemProps {
|
||||
item: OptimizeItemData;
|
||||
onToggle: (id: string, action: 'optimize' | 'restore') => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const statusStyles: Record<string, string> = {
|
||||
pending: 'border-[#333]',
|
||||
optimized: 'border-[#ff4500]/50 bg-[#ff4500]/5',
|
||||
restored: 'border-[#333]',
|
||||
error: 'border-[#cc3300]/50 bg-[#cc3300]/5',
|
||||
};
|
||||
|
||||
const statusTextStyles: Record<string, string> = {
|
||||
pending: 'text-[#555]',
|
||||
optimized: 'text-[#ff4500]',
|
||||
restored: 'text-[#555]',
|
||||
error: 'text-[#cc3300]',
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: '待优化',
|
||||
optimized: '已优化',
|
||||
restored: '已恢复',
|
||||
error: '失败',
|
||||
};
|
||||
|
||||
export default function OptimizeItem({ item, onToggle, disabled }: OptimizeItemProps) {
|
||||
const isOptimized = item.status === 'optimized';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border transition-colors duration-75 bg-[#1a1a1a]/60 ${statusStyles[item.status]}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-2 py-2">
|
||||
<div className={`w-1 h-1 shrink-0 rounded-full ${isOptimized ? 'bg-[#ff4500]' : 'bg-[#333]'}`} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-[12px] text-[#555]">{item.icon}</span>
|
||||
<span className="text-[9px] font-semibold tracking-[0.1em] uppercase text-[#e0e0e0] truncate">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className={`text-[7px] font-mono tracking-wider px-1 py-[1px] border ${statusTextStyles[item.status]} border-current/30`}>
|
||||
{statusLabels[item.status]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[8px] text-[#555] mt-0.5 truncate">{item.description}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onToggle(item.id, isOptimized ? 'restore' : 'optimize')}
|
||||
disabled={disabled || item.status === 'error'}
|
||||
className={`w-[64px] shrink-0 py-1.5 text-[8px] font-semibold tracking-[0.1em] uppercase border transition-colors duration-75
|
||||
${isOptimized
|
||||
? 'border-[#555] text-[#555] hover:border-[#ff4500] hover:text-[#ff4500]'
|
||||
: 'border-[#555] text-[#888] hover:border-[#ff4500] hover:text-[#ff4500]'
|
||||
}
|
||||
disabled:opacity-40 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{isOptimized ? '恢复' : '优化'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/components/optimization/OptimizePanel.tsx
Normal file
27
src/components/optimization/OptimizePanel.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import Panel from '../ui/Panel';
|
||||
import OptimizeItem from './OptimizeItem';
|
||||
import type { OptimizeItemData } from './OptimizeItem';
|
||||
|
||||
interface OptimizePanelProps {
|
||||
title: string;
|
||||
icon: string;
|
||||
items: OptimizeItemData[];
|
||||
onToggle: (id: string, action: 'optimize' | 'restore') => void;
|
||||
disabled?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
serial?: string;
|
||||
}
|
||||
|
||||
export default function OptimizePanel({ title, icon, items, onToggle, disabled, defaultOpen = true, serial }: OptimizePanelProps) {
|
||||
const optimized = items.filter(i => i.status === 'optimized').length;
|
||||
return (
|
||||
<Panel title={`${icon} ${title} [${optimized}/${items.length}]`} collapsible defaultOpen={defaultOpen} serial={serial}>
|
||||
<div className="space-y-1">
|
||||
{items.map(item => (
|
||||
<OptimizeItem key={item.id} item={item} onToggle={onToggle} disabled={disabled} />
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
38
src/components/optimization/OptimizeResult.tsx
Normal file
38
src/components/optimization/OptimizeResult.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
|
||||
interface OptimizeResultProps {
|
||||
success: boolean;
|
||||
message: string;
|
||||
details?: string[];
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export default function OptimizeResult({ success, message, details, onClose }: OptimizeResultProps) {
|
||||
return (
|
||||
<div className={`border p-2.5 ${success ? 'border-[#ff4500] bg-[#ff4500]/5' : 'border-[#cc3300] bg-[#cc3300]/5'}`}>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className={`text-[14px] leading-none ${success ? 'text-[#ff4500]' : 'text-[#cc3300]'}`}>
|
||||
{success ? '▸' : '✕'}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-[10px] font-bold tracking-[0.12em] uppercase text-[#e0e0e0]">
|
||||
{success ? '优化完成' : '优化失败'}
|
||||
</h3>
|
||||
<p className="text-[8px] font-mono text-[#888] mt-0.5">{message}</p>
|
||||
{details && details.length > 0 && (
|
||||
<div className="mt-1.5 space-y-0.5">
|
||||
{details.map((d, i) => (
|
||||
<p key={i} className="text-[7px] font-mono text-[#555]">▸ {d}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{onClose && (
|
||||
<button onClick={onClose} className="text-[#555] hover:text-[#e0e0e0] text-[10px] leading-none transition-colors duration-75">
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/components/schemes/CategoryTabs.tsx
Normal file
39
src/components/schemes/CategoryTabs.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Category {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CategoryTabsProps {
|
||||
categories: Category[];
|
||||
activeCode: string;
|
||||
onSelect: (code: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function CategoryTabs({ categories, activeCode, onSelect, className = '' }: CategoryTabsProps) {
|
||||
return (
|
||||
<div className={`overflow-x-auto scrollbar-none ${className}`}>
|
||||
<div className="flex gap-0 border border-[#333] bg-[#1a1a1a]">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.code}
|
||||
onClick={() => onSelect(cat.code)}
|
||||
className={`relative px-2.5 py-1.5 text-[8px] font-semibold tracking-[0.08em] uppercase border-r border-[#333] last:border-r-0 transition-colors duration-75
|
||||
${activeCode === cat.code
|
||||
? 'text-[#ff4500] bg-[#ff4500]/10'
|
||||
: 'text-[#555] hover:text-[#888] hover:bg-[#222]'
|
||||
}`}
|
||||
>
|
||||
{cat.name}
|
||||
<span className="ml-1 text-[7px] text-[#444]">{cat.code}</span>
|
||||
{activeCode === cat.code && (
|
||||
<span className="absolute bottom-0 left-1 right-1 h-[1px] bg-[#ff4500]" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
src/components/schemes/SchemeCard.tsx
Normal file
89
src/components/schemes/SchemeCard.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import Button from '../ui/Button';
|
||||
import Card from '../ui/Card';
|
||||
|
||||
export interface SchemeData {
|
||||
id: string;
|
||||
title: string | null;
|
||||
weaponName: string | null;
|
||||
category: string | null;
|
||||
viewsCount: number;
|
||||
downloadsCount: number;
|
||||
likesCount: number;
|
||||
favoritesCount: number;
|
||||
price: number;
|
||||
isOfficial: boolean;
|
||||
user?: { username: string };
|
||||
}
|
||||
|
||||
interface SchemeCardProps {
|
||||
scheme: SchemeData;
|
||||
isFavorited?: boolean;
|
||||
onFavorite?: (id: string) => void;
|
||||
onUse?: (id: string) => void;
|
||||
onClick?: (id: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SchemeCard({ scheme, isFavorited, onFavorite, onUse, onClick, className = '' }: SchemeCardProps) {
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
onClick={() => onClick?.(scheme.id)}
|
||||
className={`flex flex-col p-3 gap-2 ${className}`}
|
||||
serial={scheme.isOfficial ? 'OFFICIAL' : undefined}
|
||||
>
|
||||
{/* 标题 */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-[10px] font-bold tracking-[0.08em] uppercase text-[#e0e0e0] truncate">
|
||||
{scheme.title || '未命名方案'}
|
||||
</h3>
|
||||
<p className="text-[8px] font-mono text-[#555] mt-0.5">{scheme.weaponName || scheme.category || '通用'}</p>
|
||||
</div>
|
||||
{scheme.isOfficial && (
|
||||
<span className="text-[7px] font-semibold tracking-[0.1em] uppercase text-[#ff4500] border border-[#ff4500]/30 px-1 py-0.5">
|
||||
官方
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 作者 */}
|
||||
{scheme.user?.username && (
|
||||
<p className="text-[8px] font-mono text-[#555]">作者: {scheme.user.username}</p>
|
||||
)}
|
||||
|
||||
{/* 价格 */}
|
||||
{scheme.price > 0 && (
|
||||
<p className="text-[8px] font-mono text-[#555]">💰 {scheme.price}</p>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-2 mt-auto pt-1 border-t border-[#333]">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onFavorite?.(scheme.id); }}
|
||||
className={`text-[8px] font-mono px-1.5 py-1 border transition-colors duration-75
|
||||
${isFavorited
|
||||
? 'border-[#ff4500] text-[#ff4500]'
|
||||
: 'border-[#333] text-[#555] hover:border-[#555]'}`}
|
||||
>
|
||||
{isFavorited ? '★' : '☆'} {scheme.favoritesCount}
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onUse?.(scheme.id); }}
|
||||
className="ml-auto text-[8px] font-semibold tracking-[0.1em] uppercase text-[#888] border border-[#333] px-1.5 py-1
|
||||
hover:border-[#ff4500] hover:text-[#ff4500] transition-colors duration-75"
|
||||
>
|
||||
使用
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 统计数据 */}
|
||||
<div className="flex items-center gap-3 text-[7px] font-mono text-[#555]">
|
||||
<span>👁 {scheme.viewsCount}</span>
|
||||
<span>⬇ {scheme.downloadsCount}</span>
|
||||
<span>👍 {scheme.likesCount}</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
11
src/components/schemes/SchemeEditor.tsx
Normal file
11
src/components/schemes/SchemeEditor.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function SchemeEditor() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<span className="material-symbols-outlined text-[40px] text-tactical-textDim">construction</span>
|
||||
<p className="text-[13px] text-tactical-textMuted tracking-wider">方案编辑器</p>
|
||||
<p className="text-[11px] text-tactical-textDim">开发中</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
src/components/schemes/SchemeList.tsx
Normal file
95
src/components/schemes/SchemeList.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import SchemeCard from './SchemeCard';
|
||||
import type { SchemeData } from './SchemeCard';
|
||||
|
||||
interface SchemeListProps {
|
||||
schemes: SchemeData[];
|
||||
loading?: boolean;
|
||||
favoritedIds?: Set<string>;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onFavorite: (id: string) => void;
|
||||
onUse: (id: string) => void;
|
||||
onClick: (id: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SchemeList({
|
||||
schemes, loading, favoritedIds = new Set(),
|
||||
page, totalPages, onPageChange,
|
||||
onFavorite, onUse, onClick, className = ''
|
||||
}: SchemeListProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-1.5 ${className}`}>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="border border-[#333] bg-[#1a1a1a] p-3 animate-pulse">
|
||||
<div className="h-3 bg-[#222] w-3/4 mb-2" />
|
||||
<div className="h-2.5 bg-[#222] w-1/2 mb-3" />
|
||||
<div className="h-6 bg-[#222] w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!schemes.length) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2 border border-[#333]">
|
||||
<span className="text-[24px] text-[#444]">📭</span>
|
||||
<p className="text-[10px] font-mono text-[#555]">暂无方案数据</p>
|
||||
<span className="tech-sn">EMPTY / 404</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-3 ${className}`}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-1.5">
|
||||
{schemes.map(s => (
|
||||
<SchemeCard
|
||||
key={s.id}
|
||||
scheme={s}
|
||||
isFavorited={favoritedIds.has(s.id)}
|
||||
onFavorite={onFavorite}
|
||||
onUse={onUse}
|
||||
onClick={onClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-1 border-t border-[#333] pt-2">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
className="px-2 py-1 text-[8px] font-mono text-[#555] border border-[#333] disabled:opacity-30 hover:border-[#555]"
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
{Array.from({ length: totalPages }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onPageChange(i + 1)}
|
||||
className={`px-2 py-1 text-[8px] font-mono border transition-colors duration-75
|
||||
${page === i + 1
|
||||
? 'border-[#ff4500] text-[#ff4500] bg-[#ff4500]/10'
|
||||
: 'border-[#333] text-[#555] hover:border-[#555]'}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
className="px-2 py-1 text-[8px] font-mono text-[#555] border border-[#333] disabled:opacity-30 hover:border-[#555]"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/components/schemes/SchemePreviewer.tsx
Normal file
35
src/components/schemes/SchemePreviewer.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SchemePreviewerProps {
|
||||
content?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SchemePreviewer({ content, className = '' }: SchemePreviewerProps) {
|
||||
if (!content) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center py-8 text-[12px] text-tactical-textDim ${className}`}>
|
||||
暂无预览内容
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
return (
|
||||
<div className={`rounded-[4px] border border-tactical-gray/30 bg-tactical-dark/50 p-4 ${className}`}>
|
||||
<pre className="text-[11px] text-tactical-textMuted/80 leading-relaxed whitespace-pre-wrap break-all max-h-[400px] overflow-y-auto scrollbar-none">
|
||||
{JSON.stringify(parsed, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
return (
|
||||
<div className={`rounded-[4px] border border-tactical-gray/30 bg-tactical-dark/50 p-4 ${className}`}>
|
||||
<p className="text-[11px] text-tactical-textMuted/80 whitespace-pre-wrap break-all max-h-[400px] overflow-y-auto scrollbar-none">
|
||||
{content}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
25
src/components/ui/Badge.tsx
Normal file
25
src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
type BadgeVariant = 'default' | 'vip' | 'success' | 'warning' | 'error';
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant?: BadgeVariant;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const styleMap: Record<BadgeVariant, string> = {
|
||||
default: 'border-[#333] text-[#555]',
|
||||
vip: 'border-[#ff4500]/40 text-[#ff4500]',
|
||||
success: 'border-[#ff4500]/40 text-[#ff4500]',
|
||||
warning: 'border-[#ff4500]/40 text-[#ff4500]',
|
||||
error: 'border-[#cc3300]/40 text-[#cc3300]',
|
||||
};
|
||||
|
||||
export default function Badge({ children, variant = 'default', className = '' }: BadgeProps) {
|
||||
return (
|
||||
<span className={`inline-block px-1.5 py-0.5 text-[8px] font-semibold tracking-[0.08em] uppercase border ${styleMap[variant]} ${className}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
55
src/components/ui/Button.tsx
Normal file
55
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
'bg-[#ff4500] text-[#111] border-[#ff4500] hover:bg-[#111] hover:text-[#ff4500]',
|
||||
secondary:
|
||||
'bg-transparent text-[#e0e0e0] border-[#555] hover:bg-[#e0e0e0] hover:text-[#111]',
|
||||
ghost:
|
||||
'bg-transparent text-[#888] border-[#333] hover:border-[#e0e0e0] hover:text-[#e0e0e0]',
|
||||
danger:
|
||||
'bg-transparent text-[#cc3300] border-[#cc3300] hover:bg-[#cc3300] hover:text-[#111]',
|
||||
};
|
||||
|
||||
const sizeStyles: Record<ButtonSize, string> = {
|
||||
sm: 'px-3 py-1.5 text-[10px]',
|
||||
md: 'px-5 py-2.5 text-[11px]',
|
||||
lg: 'px-7 py-3 text-[13px]',
|
||||
};
|
||||
|
||||
export default function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading,
|
||||
disabled,
|
||||
className = '',
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
disabled={disabled || loading}
|
||||
className={`inline-flex items-center justify-center gap-2 font-semibold tracking-[0.08em] uppercase
|
||||
border transition-colors duration-75
|
||||
${variantStyles[variant]}
|
||||
${sizeStyles[size]}
|
||||
${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
|
||||
${className}`}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<span className="w-3 h-3 border border-current border-t-transparent animate-spin" />
|
||||
)}
|
||||
{!loading && children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
39
src/components/ui/Card.tsx
Normal file
39
src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
highlighted?: boolean;
|
||||
hoverable?: boolean;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
serial?: string;
|
||||
}
|
||||
|
||||
export default function Card({
|
||||
children,
|
||||
highlighted,
|
||||
hoverable,
|
||||
className = '',
|
||||
onClick,
|
||||
serial,
|
||||
}: CardProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`relative border bg-[#1a1a1a] transition-colors duration-75
|
||||
${highlighted ? 'border-[#ff4500]' : 'border-[#333]'}
|
||||
${hoverable ? 'hover:border-[#ff4500] cursor-pointer' : ''}
|
||||
${className}`}
|
||||
>
|
||||
{/* Cut corner decorations */}
|
||||
<div className="absolute top-0 left-0 w-2 h-2 border-t border-l border-[#ff4500]/40 pointer-events-none" />
|
||||
<div className="absolute bottom-0 right-0 w-2 h-2 border-b border-r border-[#ff4500]/40 pointer-events-none" />
|
||||
|
||||
{serial && (
|
||||
<span className="tech-sn absolute top-1 right-1">{serial}</span>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/components/ui/Compass.tsx
Normal file
28
src/components/ui/Compass.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Compass({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" className={className}>
|
||||
<circle cx="16" cy="16" r="14" fill="none" stroke="rgba(200,169,91,0.2)" strokeWidth="1" />
|
||||
<circle cx="16" cy="16" r="11" fill="none" stroke="rgba(200,169,91,0.15)" strokeWidth="0.5" strokeDasharray="2 2" />
|
||||
{[0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330].map((deg, i) => {
|
||||
const rad = (deg - 90) * (Math.PI / 180);
|
||||
const inner = i % 3 === 0 ? 12 : 13;
|
||||
const outer = i % 3 === 0 ? 15 : 14.5;
|
||||
return (
|
||||
<line
|
||||
key={deg}
|
||||
x1={16 + inner * Math.cos(rad)}
|
||||
y1={16 + inner * Math.sin(rad)}
|
||||
x2={16 + outer * Math.cos(rad)}
|
||||
y2={16 + outer * Math.sin(rad)}
|
||||
stroke={i % 3 === 0 ? 'rgba(200,169,91,0.4)' : 'rgba(200,169,91,0.2)'}
|
||||
strokeWidth={i % 3 === 0 ? 1.2 : 0.8}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<polygon points="16,4 17,14 16,15 15,14" fill="rgba(200,169,91,0.6)" />
|
||||
<polygon points="16,28 17,18 16,17 15,18" fill="rgba(200,169,91,0.3)" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
20
src/components/ui/Input.tsx
Normal file
20
src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export default function Input({ label, className = '', ...props }: InputProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{label && <span className="text-[8px] font-mono text-[#555] uppercase tracking-wider">{label}</span>}
|
||||
<input
|
||||
className={`border border-[#333] bg-[#111] text-[#e0e0e0] px-2.5 py-2 text-[10px] font-mono outline-none
|
||||
placeholder:text-[#444] transition-colors duration-75
|
||||
focus:border-[#ff4500] focus:bg-[#1a1a1a]
|
||||
${className}`}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/components/ui/Modal.tsx
Normal file
45
src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const sizeMap = { sm: 'max-w-sm', md: 'max-w-lg', lg: 'max-w-2xl' };
|
||||
|
||||
export default function Modal({ open, onClose, title, children, size = 'md' }: ModalProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 backdrop-blur-[4px] bg-black/60"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
|
||||
className={`relative w-full ${sizeMap[size]} mx-4 border border-tactical-gray/40 backdrop-blur-[16px] bg-tactical-black/85`}
|
||||
>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-tactical-gray/30">
|
||||
<h2 className="text-[15px] font-bold tracking-[0.12em] text-tactical-text">{title}</h2>
|
||||
<button onClick={onClose} className="text-tactical-textMuted hover:text-tactical-text transition-colors text-lg">✕</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-5">{children}</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
54
src/components/ui/Panel.tsx
Normal file
54
src/components/ui/Panel.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface PanelProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
collapsible?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
className?: string;
|
||||
serial?: string;
|
||||
}
|
||||
|
||||
export default function Panel({
|
||||
title,
|
||||
children,
|
||||
collapsible,
|
||||
defaultOpen = true,
|
||||
className = '',
|
||||
serial,
|
||||
}: PanelProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className={`border border-[#333] bg-[#1a1a1a] relative ${className}`}>
|
||||
{/* Cut corner */}
|
||||
<div className="absolute top-0 left-0 w-2 h-2 border-t border-l border-[#ff4500]/40 pointer-events-none z-10" />
|
||||
<div className="absolute bottom-0 right-0 w-2 h-2 border-b border-r border-[#ff4500]/40 pointer-events-none z-10" />
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
className={`flex items-center justify-between px-3 py-2.5 border-b border-[#333] select-none
|
||||
${collapsible ? 'cursor-pointer hover:bg-[#222]' : ''}`}
|
||||
onClick={() => collapsible && setOpen(!open)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-[#555] text-[9px] font-mono">▌</span>
|
||||
<h3 className="text-[10px] font-semibold tracking-[0.12em] uppercase text-[#e0e0e0] truncate">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{serial && <span className="tech-sn">{serial}</span>}
|
||||
{collapsible && (
|
||||
<span className={`text-[#555] text-[8px] transition-transform duration-75 ${open ? 'rotate-180' : ''}`}>
|
||||
▼
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{open && <div className="px-3 py-2.5">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/components/ui/ProgressBar.tsx
Normal file
29
src/components/ui/ProgressBar.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ProgressBarProps {
|
||||
value: number;
|
||||
max?: number;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ProgressBar({ value, max = 100, label, className = '' }: ProgressBarProps) {
|
||||
const pct = Math.min((value / max) * 100, 100);
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${className}`}>
|
||||
<div className="flex-1 h-[6px] bg-tactical-dark/90 border border-tactical-gray/40 relative overflow-hidden">
|
||||
<div
|
||||
className="h-full transition-all duration-500 ease-out relative"
|
||||
style={{ width: `${pct}%`, background: 'linear-gradient(90deg, #3A6B35 0%, #5C7A3E 50%, #7A6B4A 100%)' }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ backgroundImage: 'repeating-linear-gradient(90deg, transparent 0px, rgba(255,255,255,0.03) 2px, transparent 4px)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{label && <span className="text-[11px] font-bold text-tactical-textMuted whitespace-nowrap">{label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
src/components/ui/Skeleton.tsx
Normal file
9
src/components/ui/Skeleton.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Skeleton({ className = '' }: SkeletonProps) {
|
||||
return <div className={`animate-pulse bg-[#222] ${className}`} />;
|
||||
}
|
||||
57
src/components/ui/Toast.tsx
Normal file
57
src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
interface ToastMessage {
|
||||
id: number;
|
||||
type: ToastType;
|
||||
text: string;
|
||||
}
|
||||
|
||||
let toastId = 0;
|
||||
const listeners: Set<(msg: ToastMessage) => void> = new Set();
|
||||
|
||||
export function showToast(text: string, type: ToastType = 'info') {
|
||||
const msg: ToastMessage = { id: ++toastId, type, text };
|
||||
listeners.forEach(fn => fn(msg));
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return (type: ToastType, text: string) => showToast(text, type);
|
||||
}
|
||||
|
||||
const typeColors: Record<ToastType, string> = {
|
||||
success: 'border-[#ff4500] text-[#ff4500]',
|
||||
error: 'border-[#cc3300] text-[#cc3300]',
|
||||
warning: 'border-[#ff4500] text-[#ff4500]',
|
||||
info: 'border-[#555] text-[#e0e0e0]',
|
||||
};
|
||||
|
||||
export default function ToastContainer() {
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (msg: ToastMessage) => {
|
||||
setToasts(prev => [...prev, msg]);
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== msg.id));
|
||||
}, 3000);
|
||||
};
|
||||
listeners.add(handler);
|
||||
return () => { listeners.delete(handler); };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-1.5 pointer-events-none">
|
||||
{toasts.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`border bg-[#1a1a1a] px-3 py-2 font-mono text-[10px] tracking-wider
|
||||
animate-in slide-in-from-right-2 ${typeColors[t.type]}`}
|
||||
>
|
||||
▸ {t.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user