feat: enhance admin UI with responsive styles and new components
- Updated styles in src/styles.css for better responsiveness and layout adjustments. - Added new CSS classes for edit drawer and item rail components. - Introduced animations for drawer transitions. - Created new types in src/types/admin.ts for better type safety in admin features. - Modified vite.config.ts to change server port and enable strict port settings for development.
This commit is contained in:
23
src/components/admin/AdminDisclosure.tsx
Normal file
23
src/components/admin/AdminDisclosure.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function AdminDisclosure({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="admin-disclosure">
|
||||
<header className="admin-disclosure-head">
|
||||
<span className="admin-disclosure-copy">
|
||||
<strong>{title}</strong>
|
||||
{description ? <small>{description}</small> : null}
|
||||
</span>
|
||||
</header>
|
||||
<div className="admin-disclosure-body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
135
src/components/admin/AdminShell.tsx
Normal file
135
src/components/admin/AdminShell.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Blocks, Home, LogOut, MapPin, Package, UserRoundCheck } from "lucide-react";
|
||||
|
||||
import { clearToken, getDestinations } from "@/api";
|
||||
import type { Destination } from "@/api";
|
||||
import { ToastStack } from "@/components/admin/ToastStack";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LeadsPage } from "@/pages/leads/LeadsPage";
|
||||
import { ProductsPage } from "@/pages/products/ProductsPage";
|
||||
import { StructurePage } from "@/pages/structure/StructurePage";
|
||||
import type { Tab, Toast } from "@/types/admin";
|
||||
|
||||
const sidebarGroups: { id: string; label: string; items: { id: Tab; label: string; icon: typeof Home; description: string }[] }[] = [
|
||||
{
|
||||
id: "workspace",
|
||||
label: "工作台",
|
||||
items: [{ id: "structure", label: "维护地图", icon: Blocks, description: "前台结构总览" }],
|
||||
},
|
||||
{
|
||||
id: "frontend",
|
||||
label: "前台页面",
|
||||
items: [
|
||||
{ id: "home", label: "小程序首页", icon: Home, description: "轮播、主题、CTA" },
|
||||
{ id: "destinations", label: "目的地页", icon: MapPin, description: "字典、搜索、商品" },
|
||||
{ id: "products", label: "商品维护", icon: Package, description: "列表、卡片、详情" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "operations",
|
||||
label: "业务处理",
|
||||
items: [{ id: "leads", label: "需求线索", icon: UserRoundCheck, description: "表单和跟进" }],
|
||||
},
|
||||
];
|
||||
|
||||
const tabs = sidebarGroups.flatMap((group) => group.items);
|
||||
|
||||
export function AdminShell({ onLogout }: { onLogout: () => void }) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("structure");
|
||||
const [destinations, setDestinations] = useState<Destination[]>([]);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getDestinations()
|
||||
.then((result) => setDestinations(result.items))
|
||||
.catch(() => setDestinations([]));
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
setHasUnsavedChanges(false);
|
||||
}, [activeTab]);
|
||||
|
||||
const activeMeta = useMemo(() => tabs.find((tab) => tab.id === activeTab), [activeTab]);
|
||||
const activeLabel = activeMeta?.label ?? "";
|
||||
const activeDescription = activeMeta?.description ?? "";
|
||||
|
||||
const notify = (toast: Omit<Toast, "id">) => {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
setToasts((items) => [...items, { ...toast, id }]);
|
||||
window.setTimeout(() => {
|
||||
setToasts((items) => items.filter((item) => item.id !== id));
|
||||
}, 4200);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
clearToken();
|
||||
onLogout();
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="admin-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand-block">
|
||||
<b>万趣小程序管理端</b>
|
||||
<span>按前台结构管理</span>
|
||||
</div>
|
||||
<nav>
|
||||
{sidebarGroups.map((group) => (
|
||||
<section className="sidebar-group" key={group.id} aria-label={group.label}>
|
||||
<span>{group.label}</span>
|
||||
{group.items.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={tab.label}
|
||||
className={activeTab === tab.id ? "active" : ""}
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
title={tab.label}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>
|
||||
<b>{tab.label}</b>
|
||||
<small>{tab.description}</small>
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
))}
|
||||
</nav>
|
||||
<Button variant="ghost" aria-label="退出登录" className="logout-button" onClick={logout} title="退出登录">
|
||||
<LogOut size={17} />
|
||||
退出登录
|
||||
</Button>
|
||||
</aside>
|
||||
<section className="main-panel">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<span>{activeLabel}</span>
|
||||
<small>{activeDescription} · 结构化维护 · 内存开发模式</small>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<span className={hasUnsavedChanges ? "admin-dirty-chip is-dirty" : "admin-dirty-chip"}>
|
||||
{hasUnsavedChanges ? "有未提交修改" : "内容已同步"}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
{activeTab === "structure" ? (
|
||||
<StructurePage onJump={setActiveTab} onDirtyChange={setHasUnsavedChanges} notify={notify} />
|
||||
) : activeTab === "home" ? (
|
||||
<StructurePage key="home-workbench" fixedPage="home" onJump={setActiveTab} onDirtyChange={setHasUnsavedChanges} notify={notify} />
|
||||
) : activeTab === "destinations" ? (
|
||||
<StructurePage key="destination-workbench" fixedPage="destination" onJump={setActiveTab} onDirtyChange={setHasUnsavedChanges} notify={notify} />
|
||||
) : activeTab === "products" ? (
|
||||
<ProductsPage destinations={destinations} onDirtyChange={setHasUnsavedChanges} notify={notify} />
|
||||
) : (
|
||||
<LeadsPage notify={notify} />
|
||||
)}
|
||||
</section>
|
||||
<ToastStack toasts={toasts} onClose={(id) => setToasts((items) => items.filter((item) => item.id !== id))} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
3
src/components/admin/EmptyState.tsx
Normal file
3
src/components/admin/EmptyState.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export function EmptyState({ text }: { text: string }) {
|
||||
return <div className="empty-state">{text}</div>;
|
||||
}
|
||||
102
src/components/admin/SingleImageUploader.tsx
Normal file
102
src/components/admin/SingleImageUploader.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { ChangeEvent, useRef, useState } from "react";
|
||||
import { Image as ImageIcon, Trash2 } from "lucide-react";
|
||||
|
||||
import { uploadMediaAsset } from "@/api";
|
||||
|
||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||
const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
|
||||
|
||||
export function SingleImageUploader({
|
||||
value,
|
||||
onChange,
|
||||
group = "site-config",
|
||||
}: {
|
||||
value?: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
group?: string;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const openFilePicker = () => inputRef.current?.click();
|
||||
const displayValue = value ?? "";
|
||||
const hasImage = Boolean(displayValue);
|
||||
|
||||
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
|
||||
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
|
||||
setError("请选择 JPG、PNG、WebP 或 GIF 图片。");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
setError("图片大小不能超过 5MB。");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
const asset = await uploadMediaAsset(file, group);
|
||||
setError("");
|
||||
onChange(asset.url);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "图片上传失败,请重新选择。");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onChange(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="single-image-uploader">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="single-image-input"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={handleFileChange}
|
||||
aria-label="上传图片素材"
|
||||
disabled={uploading}
|
||||
/>
|
||||
<div className={`single-image-card ${hasImage ? "has-image" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`single-image-preview ${hasImage ? "has-image" : ""}`}
|
||||
onClick={openFilePicker}
|
||||
aria-label={hasImage ? "更换图片素材" : "上传图片素材"}
|
||||
disabled={uploading}
|
||||
>
|
||||
{hasImage ? (
|
||||
<img src={displayValue} alt="当前图片预览" />
|
||||
) : (
|
||||
<span className="single-image-placeholder">
|
||||
<ImageIcon size={30} />
|
||||
<b>{uploading ? "上传中" : "暂无图片"}</b>
|
||||
<small>{uploading ? "正在上传到 OSS" : "上传后在这里预览单张素材"}</small>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{hasImage ? (
|
||||
<div className="single-image-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="single-image-remove-button"
|
||||
onClick={handleRemove}
|
||||
aria-label="移除图片"
|
||||
title="移除图片"
|
||||
disabled={uploading}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{error ? <p className="field-error">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/components/admin/ToastStack.tsx
Normal file
25
src/components/admin/ToastStack.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { AlertTriangle, CheckCircle2, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Toast } from "@/types/admin";
|
||||
|
||||
export function ToastStack({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) {
|
||||
if (!toasts.length) return null;
|
||||
|
||||
return (
|
||||
<div className="admin-toast-stack" role="status" aria-live="polite">
|
||||
{toasts.map((toast) => (
|
||||
<article className={`admin-toast ${toast.tone}`} key={toast.id}>
|
||||
{toast.tone === "success" ? <CheckCircle2 size={20} /> : <AlertTriangle size={20} />}
|
||||
<span>
|
||||
<strong>{toast.title}</strong>
|
||||
<small>{toast.message}</small>
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" onClick={() => onClose(toast.id)} aria-label="关闭提示">
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user