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:
duanshuwen
2026-07-01 16:55:23 +08:00
parent b9a57fe03c
commit 462498660e
20 changed files with 2840 additions and 1554 deletions

View File

@@ -0,0 +1,851 @@
import { useEffect, useState } from "react";
import {
ArrowDown,
ArrowUp,
ChevronRight,
Eye,
Plus,
RefreshCcw,
Save,
Send,
Trash2,
X,
} from "lucide-react";
import {
createSiteConfigItem,
deleteSiteConfigItem,
getProducts,
getSiteConfig,
publishSite,
reorderSiteConfigItems,
resetGuizhouContent,
updateSiteConfigItem,
} from "@/api";
import type { Product, SiteConfig, SiteItemPatch, SiteModule } from "@/api";
import { AdminDisclosure } from "@/components/admin/AdminDisclosure";
import { EmptyState } from "@/components/admin/EmptyState";
import { SingleImageUploader } from "@/components/admin/SingleImageUploader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
compactSiteItemPayload,
createEmptySiteItemDraft,
itemImage,
itemMeta,
itemName,
itemSortOrder,
moduleEditable,
moduleItems,
siteItemPrimaryKey,
} from "@/lib/admin-utils";
import type {
DirtyChangeHandler,
EditableSiteItem,
ModuleId,
Notify,
PageId,
Tab,
} from "@/types/admin";
const NEW_SITE_ITEM_ID = "__new_site_item__";
const pageSpecs: {
id: PageId;
title: string;
subtitle: string;
frontPath: string;
modules: {
id: ModuleId;
label: string;
hint: string;
frontPosition: string;
}[];
}[] = [
{
id: "home",
title: "万趣首页",
subtitle: "维护用户进入小程序后第一屏到页尾的内容顺序。",
frontPath: "前台:首页 / bottom tab「万趣」",
modules: [
{
id: "heroSlides",
label: "顶部轮播",
hint: "控制首屏大图、活动入口和主视觉顺序。",
frontPosition: "首页第一屏",
},
{
id: "destinations",
label: "探索贵州",
hint: "控制省内目的地宫格、热门标记和搜索入口。",
frontPosition: "首页「探索贵州」",
},
{
id: "themes",
label: "主题甄选",
hint: "控制横向主题卡和搜索跳转。",
frontPosition: "首页「主题甄选」",
},
{
id: "routeProducts",
label: "精选线路",
hint: "从商品库选择上架线路,决定首页线路池。",
frontPosition: "首页精选线路",
},
{
id: "ctaBanners",
label: "底部运营入口",
hint: "控制权益卡、服务管家入口、目的地搜索、提交需求入口。",
frontPosition: "首页页尾 CTA",
},
],
},
{
id: "destination",
title: "目的地页",
subtitle: "维护目的地、别名和与商品的关联,影响搜索和目的地 tab。",
frontPath: "前台bottom tab「目的地」/ 搜索弹层",
modules: [
{
id: "destinations",
label: "目的地字典",
hint: "维护名称、封面、热门状态和启停。",
frontPosition: "目的地首页和搜索列表",
},
{
id: "routeProducts",
label: "目的地商品",
hint: "商品绑定目的地后会进入对应结果页。",
frontPosition: "搜索结果页产品列表",
},
],
},
{
id: "detail",
title: "商品维护",
subtitle: "维护用户点击商品卡后的详情页内容和售卖状态。",
frontPath: "前台:产品卡点击后详情页",
modules: [
{
id: "routeProducts",
label: "商品库",
hint: "编辑标题、价格、封面、标签、详情模块和上下架。",
frontPosition: "详情页、搜索页、首页商品卡",
},
],
},
{
id: "campaign",
title: "活动专题",
subtitle: "维护轮播、主题卡和 CTA 指向的活动内容。",
frontPath: "前台:轮播 / 主题卡 / CTA 跳转",
modules: [
{
id: "heroSlides",
label: "轮播活动入口",
hint: "首屏主视觉上的活动入口。",
frontPosition: "首页轮播",
},
{
id: "themes",
label: "主题活动入口",
hint: "主题卡片进入对应活动专题。",
frontPosition: "主题甄选横滑区",
},
{
id: "ctaBanners",
label: "营销 CTA",
hint: "页尾活动和需求入口。",
frontPosition: "首页底部运营区",
},
{
id: "campaignProducts",
label: "活动商品池",
hint: "活动专题内展示的商品来自商品库。",
frontPosition: "活动专题产品列表",
},
],
},
{
id: "demand",
title: "需求线索",
subtitle: "维护用户提交出行需求后的分配和跟进。",
frontPath: "前台:提交出行需求 / 立即咨询",
modules: [
{
id: "leadFlow",
label: "线索跟进",
hint: "查看联系方式、目的地、来源商品并推进状态。",
frontPosition: "需求表单提交后",
},
],
},
];
export function StructurePage({
fixedPage,
onJump,
onDirtyChange,
notify,
}: {
fixedPage?: PageId;
onJump: (tab: Tab) => void;
onDirtyChange: DirtyChangeHandler;
notify: Notify;
}) {
const [config, setConfig] = useState<SiteConfig | null>(null);
const [products, setProducts] = useState<Product[]>([]);
const [pageId, setPageId] = useState<PageId>(fixedPage ?? "home");
const [moduleId, setModuleId] = useState<ModuleId>("heroSlides");
const [selectedId, setSelectedId] = useState("");
const [draft, setDraft] = useState<SiteItemPatch>({});
const [message, setMessage] = useState("");
const [editorOpen, setEditorOpen] = useState(false);
const activePage =
pageSpecs.find((page) => page.id === (fixedPage ?? pageId)) ?? pageSpecs[0];
const activeModule =
activePage.modules.find((module) => module.id === moduleId) ??
activePage.modules[0];
const editableItems =
config && moduleEditable(moduleId) ? moduleItems(config, moduleId) : [];
const isCreatingSiteItem = selectedId === NEW_SITE_ITEM_ID;
const selectedItem = isCreatingSiteItem
? undefined
: (editableItems.find((item) => item.id === selectedId) ??
editableItems[0]);
const showEditorDrawer =
editorOpen &&
moduleEditable(moduleId) &&
(isCreatingSiteItem || Boolean(selectedItem));
const gridClassName = [
"maintenance-grid",
fixedPage ? "focused-page-grid" : "",
]
.filter(Boolean)
.join(" ");
const setDraftFromItem = (item: EditableSiteItem) => {
setDraft({
title: "title" in item ? item.title : undefined,
kicker: "kicker" in item ? item.kicker || "" : undefined,
name: "name" in item ? item.name : undefined,
slug: "slug" in item ? item.slug : undefined,
region: "region" in item ? item.region || "" : undefined,
label: "label" in item ? item.label : undefined,
alt: "alt" in item ? item.alt : undefined,
image: "image" in item ? item.image || "" : "",
isHot: "isHot" in item ? item.isHot : undefined,
isActive: item.isActive,
sortOrder: itemSortOrder(item),
});
};
const load = async () => {
const [site, productResult] = await Promise.all([
getSiteConfig(),
getProducts(),
]);
setConfig(site);
setProducts(productResult.items);
const firstModule = activePage.modules[0]?.id ?? "heroSlides";
setModuleId((current) =>
activePage.modules.some((module) => module.id === current)
? current
: firstModule,
);
if (moduleEditable(firstModule)) {
const first = moduleItems(site, firstModule)[0];
if (first && !selectedId) {
setSelectedId(first.id);
setDraftFromItem(first);
}
}
};
useEffect(() => {
load().catch((err) => setMessage(err.message));
}, []);
useEffect(() => {
if (!fixedPage) return;
setPageId(fixedPage);
const next =
pageSpecs.find((page) => page.id === fixedPage)?.modules[0]?.id ??
"heroSlides";
setModuleId(next);
setSelectedId("");
setEditorOpen(false);
}, [fixedPage]);
useEffect(() => {
if (!config || !moduleEditable(moduleId)) {
setSelectedId("");
setDraft({});
setEditorOpen(false);
return;
}
const first = moduleItems(config, moduleId)[0];
if (!first) {
setSelectedId("");
setDraft({});
setEditorOpen(false);
return;
}
setSelectedId(first.id);
setDraftFromItem(first);
setEditorOpen(false);
}, [moduleId, config]);
const selectPage = (next: PageId) => {
setPageId(next);
const page = pageSpecs.find((item) => item.id === next);
setModuleId(page?.modules[0]?.id ?? "heroSlides");
setSelectedId("");
setMessage("");
setEditorOpen(false);
onDirtyChange(false);
};
const startCreateSiteItem = () => {
if (!moduleEditable(moduleId)) return;
setSelectedId(NEW_SITE_ITEM_ID);
setDraft(createEmptySiteItemDraft(moduleId, editableItems.length));
setMessage("");
setEditorOpen(true);
onDirtyChange(true);
};
const selectItem = (item: EditableSiteItem) => {
setSelectedId(item.id);
setDraftFromItem(item);
setMessage("");
setEditorOpen(true);
onDirtyChange(false);
};
const closeEditorDrawer = () => {
setEditorOpen(false);
if (isCreatingSiteItem) {
const first = editableItems[0];
if (first) {
setSelectedId(first.id);
setDraftFromItem(first);
} else {
setSelectedId("");
setDraft({});
}
onDirtyChange(false);
}
};
const save = async () => {
if (
!config ||
!moduleEditable(moduleId) ||
(!selectedId && !isCreatingSiteItem)
)
return;
const payload = compactSiteItemPayload(moduleId, draft);
const primaryKey = siteItemPrimaryKey(moduleId);
const primaryValue = String(payload[primaryKey] ?? "").trim();
if (!primaryValue) {
const messageText = "请先填写当前模块的主标题/名称。";
notify({ tone: "warning", title: "内容未保存", message: messageText });
return;
}
try {
const saved = isCreatingSiteItem
? await createSiteConfigItem(moduleId, payload)
: await updateSiteConfigItem(moduleId, selectedId, payload);
setSelectedId(saved.id);
setDraftFromItem(saved as EditableSiteItem);
setMessage("");
onDirtyChange(false);
notify({
tone: "success",
title: isCreatingSiteItem ? "内容已新增" : "内容已保存",
message: `${activeModule.label} 已同步到维护数据。`,
});
await load();
} catch (err) {
const messageText = err instanceof Error ? err.message : "保存失败";
setMessage(messageText);
notify({ tone: "danger", title: "内容保存失败", message: messageText });
}
};
const removeSiteItem = async (item: EditableSiteItem) => {
if (!moduleEditable(moduleId)) return;
const confirmed = window.confirm(
`确认删除「${itemName(item)}」吗?删除后需要后端同步移除对应模块配置。`,
);
if (!confirmed) return;
try {
await deleteSiteConfigItem(moduleId, item.id);
setSelectedId("");
setDraft({});
setMessage("");
onDirtyChange(false);
notify({
tone: "success",
title: "内容已删除",
message: `${activeModule.label} 已移除一项配置。`,
});
await load();
} catch (err) {
const messageText = err instanceof Error ? err.message : "删除失败";
setMessage(messageText);
notify({ tone: "danger", title: "内容删除失败", message: messageText });
}
};
const moveSiteItem = async (item: EditableSiteItem, direction: -1 | 1) => {
if (!moduleEditable(moduleId)) return;
const currentIndex = editableItems.findIndex(
(candidate) => candidate.id === item.id,
);
const nextIndex = currentIndex + direction;
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= editableItems.length)
return;
const nextIds = editableItems.map((candidate) => candidate.id);
[nextIds[currentIndex], nextIds[nextIndex]] = [
nextIds[nextIndex],
nextIds[currentIndex],
];
try {
await reorderSiteConfigItems(moduleId, nextIds);
setMessage("");
onDirtyChange(false);
notify({
tone: "success",
title: "顺序已更新",
message: `${activeModule.label} 已按新顺序保存。`,
});
await load();
} catch (err) {
const messageText = err instanceof Error ? err.message : "排序失败";
setMessage(messageText);
notify({ tone: "danger", title: "排序保存失败", message: messageText });
}
};
const publish = async () => {
const result = await publishSite();
setMessage("");
notify({ tone: "success", title: "已生成发布版本", message: result.title });
};
const resetToGuizhou = async () => {
const result = await resetGuizhouContent();
setMessage("");
notify({
tone: "success",
title: "贵州内容已重置",
message: "后台配置、商品库和素材引用已切换为贵州省内定制游。",
});
setSelectedId("");
setDraft({});
onDirtyChange(false);
await load();
};
if (!config) return <EmptyState text={message || "正在读取小程序结构"} />;
return (
<section className="work-area structure-workbench">
<header className="section-head">
<div>
<h2>{fixedPage ? activePage.title : "小程序维护地图"}</h2>
<p>
{fixedPage
? activePage.subtitle
: "按用户看到的前台页面组织后台,逐层展开到可维护内容。"}
</p>
</div>
<div className="action-row">
<Button variant="outline" onClick={load}>
<RefreshCcw size={17} />
</Button>
<Button className="primary-action compact" onClick={publish}>
<Send size={17} />
</Button>
<Button variant="outline" onClick={resetToGuizhou}>
<RefreshCcw size={17} />
</Button>
</div>
</header>
<div className={gridClassName}>
{!fixedPage ? (
<aside className="page-rail">
<h3></h3>
{pageSpecs.map((page) => (
<Button
variant="ghost"
className={activePage.id === page.id ? "active" : ""}
key={page.id}
onClick={() => selectPage(page.id)}
>
<span>
<b>{page.title}</b>
<small>{page.frontPath}</small>
</span>
<ChevronRight size={16} />
</Button>
))}
</aside>
) : (
<section className="page-focus-card">
<span className="focus-kicker"></span>
<h3>{activePage.title}</h3>
<p>{activePage.frontPath}</p>
<small>{activePage.subtitle}</small>
</section>
)}
<section className="module-rail">
<div className="front-context">
<Eye size={18} />
<span>
<b>{activePage.frontPath}</b>
<small>{activePage.subtitle}</small>
</span>
</div>
<h3></h3>
{activePage.modules.map((module, index) => (
<Button
variant="ghost"
className={activeModule.id === module.id ? "active" : ""}
key={module.id}
onClick={() => setModuleId(module.id)}
>
<em>{index + 1}</em>
<span>
<b>{module.label}</b>
<small>{module.frontPosition}</small>
</span>
</Button>
))}
</section>
<section className="item-rail">
<header className="module-data-head">
<span>
<h3>{activeModule.label}</h3>
<p>{activeModule.hint}</p>
</span>
{moduleEditable(moduleId) ? (
<Button
className="primary-action compact"
onClick={startCreateSiteItem}
>
<Plus size={16} />
</Button>
) : null}
</header>
{moduleEditable(moduleId) ? (
<div className="mapped-list">
{editableItems.map((item, index) => (
<div className="mapped-list-row" key={item.id}>
<Button
variant="ghost"
className={`mapped-list-main ${selectedItem?.id === item.id ? "selected" : ""}`}
onClick={() => selectItem(item)}
>
{itemImage(item) ? (
<img src={itemImage(item)} alt="" />
) : (
<span className="image-placeholder" />
)}
<span>
<b>{itemName(item)}</b>
<small>
{itemMeta(item)}
{typeof itemSortOrder(item) === "number"
? ` · 排序 ${itemSortOrder(item)}`
: ""}
</small>
</span>
</Button>
<div
className="mapped-list-actions"
aria-label={`${itemName(item)} 操作`}
>
<Button
variant="ghost"
size="icon"
disabled={index === 0}
onClick={() => moveSiteItem(item, -1)}
aria-label={`上移 ${itemName(item)}`}
>
<ArrowUp size={15} />
</Button>
<Button
variant="ghost"
size="icon"
disabled={index === editableItems.length - 1}
onClick={() => moveSiteItem(item, 1)}
aria-label={`下移 ${itemName(item)}`}
>
<ArrowDown size={15} />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => removeSiteItem(item)}
aria-label={`删除 ${itemName(item)}`}
>
<Trash2 size={15} />
</Button>
</div>
</div>
))}
{!editableItems.length ? (
<p className="mapped-empty">
</p>
) : null}
</div>
) : (
<ReferencePanel
moduleId={moduleId}
products={products}
onJump={onJump}
/>
)}
</section>
</div>
{showEditorDrawer && moduleEditable(moduleId) ? (
<div className="edit-drawer-layer">
<button
className="edit-drawer-backdrop"
type="button"
aria-label="关闭编辑抽屉"
onClick={closeEditorDrawer}
/>
<section
className="edit-rail edit-drawer"
role="dialog"
aria-modal="true"
aria-labelledby="site-item-editor-title"
>
<SiteItemEditor
moduleId={moduleId}
moduleLabel={activeModule.label}
draft={draft}
isCreating={isCreatingSiteItem}
onDraft={(nextDraft) => {
setDraft(nextDraft);
onDirtyChange(true);
}}
onSave={save}
onClose={closeEditorDrawer}
/>
</section>
</div>
) : null}
</section>
);
}
function ReferencePanel({
moduleId,
products,
onJump,
}: {
moduleId: ModuleId;
products: Product[];
onJump: (tab: Tab) => void;
}) {
if (moduleId === "leadFlow") {
return (
<div className="reference-panel">
<p>
</p>
<Button
className="primary-action compact"
onClick={() => onJump("leads")}
>
线
</Button>
</div>
);
}
return (
<div className="reference-panel">
<p>
使
</p>
<div className="mini-product-stack">
{products.slice(0, 4).map((product) => (
<span key={product.id}>
<img src={product.coverImage || ""} alt="" />
<b>{product.title}</b>
</span>
))}
</div>
<Button
className="primary-action compact"
onClick={() => onJump("products")}
>
</Button>
</div>
);
}
function SiteItemEditor({
moduleId,
moduleLabel,
draft,
isCreating,
onDraft,
onSave,
onClose,
}: {
moduleId: SiteModule;
moduleLabel: string;
draft: SiteItemPatch;
isCreating: boolean;
onDraft: (draft: SiteItemPatch) => void;
onSave: () => void;
onClose: () => void;
}) {
const primaryKey = siteItemPrimaryKey(moduleId);
const primaryLabel =
moduleId === "heroSlides"
? "轮播标题"
: moduleId === "destinations"
? "目的地名称"
: moduleId === "themes"
? "主题名称"
: "入口文案";
const editorTitle = `${isCreating ? "新增" : "编辑"}${moduleLabel}`;
return (
<div className="mapped-editor">
<header className="editor-head">
<span>
<h3 id="site-item-editor-title">{editorTitle}</h3>
<small>
{isCreating
? `创建后会进入「${moduleLabel}」的数据列表`
: `保存后会影响「${moduleLabel}」前台模块`}
</small>
</span>
<Button
variant="ghost"
size="icon"
onClick={onClose}
aria-label="关闭编辑抽屉"
>
<X size={18} />
</Button>
</header>
<div className="admin-disclosure-stack">
<AdminDisclosure title="展示内容">
<label>
{primaryLabel}
<Input
value={String(draft[primaryKey] ?? "")}
onChange={(event) =>
onDraft({ ...draft, [primaryKey]: event.target.value })
}
/>
</label>
{moduleId === "heroSlides" ? (
<label>
<Input
value={draft.kicker ?? ""}
onChange={(event) =>
onDraft({ ...draft, kicker: event.target.value })
}
/>
</label>
) : null}
{moduleId === "destinations" ? (
<div className="form-grid compact-grid">
<label>
<Input
value={draft.slug ?? ""}
onChange={(event) =>
onDraft({ ...draft, slug: event.target.value })
}
/>
</label>
<label>
<Input
value={draft.region ?? ""}
onChange={(event) =>
onDraft({ ...draft, region: event.target.value })
}
/>
</label>
</div>
) : null}
</AdminDisclosure>
<AdminDisclosure title="资源图片">
<SingleImageUploader
value={draft.image}
onChange={(image) => onDraft({ ...draft, image })}
group={moduleId}
/>
</AdminDisclosure>
<AdminDisclosure title="排序与状态">
<label>
<Input
type="number"
value={draft.sortOrder ?? 0}
onChange={(event) =>
onDraft({ ...draft, sortOrder: Number(event.target.value) })
}
/>
</label>
{moduleId === "destinations" ? (
<label className="switch-line">
<Switch
checked={Boolean(draft.isHot)}
onCheckedChange={(checked) =>
onDraft({ ...draft, isHot: checked })
}
/>
</label>
) : null}
<label className="switch-line">
<Switch
checked={Boolean(draft.isActive)}
onCheckedChange={(checked) =>
onDraft({ ...draft, isActive: checked })
}
/>
</label>
</AdminDisclosure>
</div>
<footer className="drawer-editor-footer">
<Button variant="outline" onClick={onClose}>
</Button>
<Button className="primary-action compact" onClick={onSave}>
<Save size={17} />
{isCreating ? "创建" : "保存"}
</Button>
</footer>
</div>
);
}