feat: 新增客片案例与团队共创模块,重构玩法与配置
- 新增客片案例全流程功能,包含前台页面、管理端配置、后端API、数据库迁移与文档 - 新增团队共创详情页面与对应接口 - 重构玩法模块:将静态playData替换为动态API获取,拆分类型定义到playTypes.ts - 优化vite构建配置与环境变量处理,调整详情页操作栏文案 - 更新全量相关文档与配图,新增客片案例、团队共创API文档 - 新增测试用例,完善数据归一化逻辑 - 调整路由配置与环境变量示例文件
This commit is contained in:
1 parent
cda8069630
commit
7ba92f3c5d
76 files changed
+7970
-137
No files matched your search
@@ -1,11 +1,12 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { BriefcaseBusiness, Home, LogOut, MapPin } from "lucide-react";
|
||||
import { Headset, Home, List, LogOut } from "lucide-react";
|
||||
|
||||
import { clearToken } from "@/api";
|
||||
import { EmptyState } from "@/components/admin/EmptyState";
|
||||
import { ToastStack } from "@/components/admin/ToastStack";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StructurePage } from "@/pages/structure/StructurePage";
|
||||
import { ConciergePage } from "@/pages/structure/ConciergePage";
|
||||
import { HomePage } from "@/pages/structure/HomePage";
|
||||
import { WanfaPage } from "@/pages/structure/WanfaPage";
|
||||
import type { Tab, Toast } from "@/types/admin";
|
||||
|
||||
const sidebarGroups: {
|
||||
@@ -18,8 +19,8 @@ const sidebarGroups: {
|
||||
label: "前台页面",
|
||||
items: [
|
||||
{ id: "home", label: "首页", icon: Home, description: "" },
|
||||
{ id: "play", label: "玩法", icon: MapPin, description: "" },
|
||||
{ id: "butler", label: "管家", icon: BriefcaseBusiness, description: "" },
|
||||
{ id: "play", label: "玩法", icon: List, description: "" },
|
||||
{ id: "butler", label: "管家", icon: Headset, description: "" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -61,6 +62,7 @@ export function AdminShell({ onLogout }: { onLogout: () => void }) {
|
||||
<main className="admin-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="brand-block">
|
||||
<span className="brand-mark" aria-hidden="true">万</span>
|
||||
<b>万趣管理端</b>
|
||||
</div>
|
||||
<nav>
|
||||
@@ -111,13 +113,11 @@ export function AdminShell({ onLogout }: { onLogout: () => void }) {
|
||||
</div>
|
||||
</header>
|
||||
{activeTab === "home" ? (
|
||||
<StructurePage key="home-page" fixedPage="home" notify={notify} />
|
||||
<HomePage key="home-page" notify={notify} />
|
||||
) : activeTab === "play" ? (
|
||||
<StructurePage key="play-page" fixedPage="play" notify={notify} />
|
||||
<WanfaPage key="wanfa-page" notify={notify} />
|
||||
) : activeTab === "butler" ? (
|
||||
<section className="work-area">
|
||||
<EmptyState text="管家页面内容待配置" />
|
||||
</section>
|
||||
<ConciergePage key="concierge-page" notify={notify} />
|
||||
) : null}
|
||||
</section>
|
||||
<ToastStack
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Save, Trash2, X } from "lucide-react";
|
||||
|
||||
import type { ConciergeAdvisor, ConciergeAdvisorCreate, ConciergeDetail } from "@/api";
|
||||
import { SingleImageUploader } from "@/components/admin/SingleImageUploader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
function advisorDraft(advisor?: ConciergeAdvisor): ConciergeAdvisorCreate {
|
||||
return {
|
||||
avatar: advisor?.avatar ?? "",
|
||||
name: advisor?.name ?? "",
|
||||
role: advisor?.role ?? "",
|
||||
details: advisor?.details.map((detail) => ({ ...detail })) ?? [],
|
||||
qrImage: advisor?.qrImage ?? "",
|
||||
isActive: advisor?.isActive ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
export function ConciergeAdvisorEditor({
|
||||
mode,
|
||||
initialAdvisor,
|
||||
saving,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
mode: "create" | "edit";
|
||||
initialAdvisor?: ConciergeAdvisor;
|
||||
saving: boolean;
|
||||
onSave: (input: ConciergeAdvisorCreate) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<ConciergeAdvisorCreate>(() => advisorDraft(initialAdvisor));
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const update = <K extends keyof ConciergeAdvisorCreate>(key: K, value: ConciergeAdvisorCreate[K]) => {
|
||||
setDraft((current) => ({ ...current, [key]: value }));
|
||||
if (error) setError("");
|
||||
};
|
||||
|
||||
const updateDetail = (index: number, key: keyof ConciergeDetail, value: string) => {
|
||||
update(
|
||||
"details",
|
||||
draft.details.map((detail, detailIndex) =>
|
||||
detailIndex === index ? { ...detail, [key]: value } : detail,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const input: ConciergeAdvisorCreate = {
|
||||
...draft,
|
||||
avatar: draft.avatar.trim(),
|
||||
name: draft.name.trim(),
|
||||
role: draft.role.trim(),
|
||||
qrImage: draft.qrImage.trim(),
|
||||
details: draft.details.map((detail) => ({ icon: detail.icon.trim(), label: detail.label.trim() })),
|
||||
};
|
||||
if (!input.avatar || !input.name || !input.role || !input.qrImage) {
|
||||
setError("请补充头像、姓名、职位和二维码。" );
|
||||
return;
|
||||
}
|
||||
if (input.details.some((detail) => !detail.icon || !detail.label)) {
|
||||
setError("服务详情的图标和文案都不能为空。" );
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
onSave(input);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mapped-editor flex h-full min-h-0 flex-col">
|
||||
<header className="editor-head">
|
||||
<span>
|
||||
<h3>{mode === "create" ? "新增管家顾问" : "编辑管家顾问"}</h3>
|
||||
<small>保存后,启用的顾问会通过 Public API 展示到 MiniAPP。</small>
|
||||
</span>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onClose} aria-label="关闭管家编辑" disabled={saving}>
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-slate-800">头像</p>
|
||||
<SingleImageUploader
|
||||
value={draft.avatar}
|
||||
onChange={(value) => update("avatar", value ?? "")}
|
||||
group="concierge"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-slate-800">二维码</p>
|
||||
<SingleImageUploader
|
||||
value={draft.qrImage}
|
||||
onChange={(value) => update("qrImage", value ?? "")}
|
||||
group="concierge"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm font-medium text-slate-800">
|
||||
姓名
|
||||
<Input value={draft.name} maxLength={80} placeholder="例如:Amanda" onChange={(event) => update("name", event.target.value)} />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm font-medium text-slate-800">
|
||||
职位
|
||||
<Input
|
||||
value={draft.role}
|
||||
maxLength={120}
|
||||
placeholder="例如:SENIOR TRAVEL ADVISOR"
|
||||
onChange={(event) => update("role", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<section className="mt-6 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-slate-900">服务详情</h4>
|
||||
<p className="mt-1 text-xs text-slate-500">每条详情由图标名称和展示文案组成,可按顺序维护。</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => update("details", [...draft.details, { icon: "", label: "" }])}
|
||||
>
|
||||
<Plus size={15} />
|
||||
添加详情
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3">
|
||||
{draft.details.map((detail, index) => (
|
||||
<div className="grid gap-2 sm:grid-cols-[150px_1fr_auto]" key={`${index}-${detail.icon}`}>
|
||||
<Input
|
||||
value={detail.icon}
|
||||
maxLength={40}
|
||||
placeholder="图标名,如 calendar"
|
||||
aria-label={`第${index + 1}条详情图标`}
|
||||
onChange={(event) => updateDetail(index, "icon", event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
value={detail.label}
|
||||
maxLength={120}
|
||||
placeholder="例如:服务经验:8年"
|
||||
aria-label={`第${index + 1}条详情文案`}
|
||||
onChange={(event) => updateDetail(index, "label", event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-rose-600 hover:bg-rose-50 hover:text-rose-700"
|
||||
aria-label={`删除第${index + 1}条服务详情`}
|
||||
onClick={() => update("details", draft.details.filter((_, detailIndex) => detailIndex !== index))}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{!draft.details.length ? <p className="text-xs text-slate-500">暂未添加服务详情。</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<label className="mt-5 flex items-center justify-between gap-4 rounded-xl border border-slate-200 bg-white p-4">
|
||||
<span>
|
||||
<b className="block text-sm font-semibold text-slate-900">前台启用</b>
|
||||
<small className="mt-1 block text-xs text-slate-500">关闭后仍保留在后台,但不会出现在 MiniAPP。</small>
|
||||
</span>
|
||||
<Switch checked={Boolean(draft.isActive)} onCheckedChange={(checked) => update("isActive", checked)} />
|
||||
</label>
|
||||
{error ? <p className="mt-3 text-sm text-rose-600">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<footer className="flex justify-end gap-3 border-t border-slate-100 px-5 py-4">
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={submit} disabled={saving}>
|
||||
<Save size={16} />
|
||||
{saving ? "保存中…" : mode === "create" ? "创建顾问" : "保存修改"}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import type { ConciergeAdvisor } from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function ConciergeAdvisorRail({
|
||||
advisors,
|
||||
onCreate,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMove,
|
||||
moving,
|
||||
}: {
|
||||
advisors: ConciergeAdvisor[];
|
||||
onCreate: () => void;
|
||||
onEdit: (advisor: ConciergeAdvisor) => void;
|
||||
onDelete: (advisor: ConciergeAdvisor) => void;
|
||||
onMove: (advisorId: string, direction: -1 | 1) => void;
|
||||
moving?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section className="item-rail min-h-[560px]">
|
||||
<header className="module-data-head">
|
||||
<span>
|
||||
<h3>管家顾问</h3>
|
||||
<p>维护前台管家卡片、服务详情和二维码。</p>
|
||||
</span>
|
||||
<Button type="button" className="primary-action compact" onClick={onCreate}>
|
||||
<Plus size={16} />
|
||||
新增管家
|
||||
</Button>
|
||||
</header>
|
||||
<div className="mapped-list">
|
||||
{advisors.map((advisor, index) => (
|
||||
<article className="mapped-list-row" key={advisor.id}>
|
||||
<div className="mapped-list-main min-w-0">
|
||||
{advisor.avatar ? <img src={advisor.avatar} alt="" /> : <span className="image-placeholder" />}
|
||||
<span className="min-w-0">
|
||||
<b className="block truncate">{advisor.name}</b>
|
||||
<small className="block truncate">
|
||||
{advisor.role} · {advisor.details.length} 条服务详情
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={`hidden rounded-full px-2.5 py-1 text-xs font-semibold sm:inline-flex ${
|
||||
advisor.isActive ? "bg-emerald-50 text-emerald-700" : "bg-slate-100 text-slate-500"
|
||||
}`}
|
||||
>
|
||||
{advisor.isActive ? "已启用" : "已停用"}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="module-rail-action"
|
||||
aria-label={`上移${advisor.name}`}
|
||||
title={`上移${advisor.name}`}
|
||||
disabled={index === 0 || moving}
|
||||
onClick={() => onMove(advisor.id, -1)}
|
||||
>
|
||||
<ArrowUp size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="module-rail-action"
|
||||
aria-label={`下移${advisor.name}`}
|
||||
title={`下移${advisor.name}`}
|
||||
disabled={index === advisors.length - 1 || moving}
|
||||
onClick={() => onMove(advisor.id, 1)}
|
||||
>
|
||||
<ArrowDown size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="module-rail-action"
|
||||
aria-label={`编辑${advisor.name}`}
|
||||
title={`编辑${advisor.name}`}
|
||||
onClick={() => onEdit(advisor)}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="module-rail-action danger"
|
||||
aria-label={`删除${advisor.name}`}
|
||||
title={`删除${advisor.name}`}
|
||||
onClick={() => onDelete(advisor)}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{!advisors.length ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50 p-8 text-center">
|
||||
<p className="text-sm font-medium text-slate-700">暂无管家顾问</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">新增顾问后,MiniAPP 会展示已启用的顾问卡片。</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import type { HomeExperience, HomeTeamBuilding, HomeWildArchive } from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export type HomeContentSection = "experiences" | "teamBuildings" | "wildArchives";
|
||||
export type HomeContentItem = HomeExperience | HomeTeamBuilding | HomeWildArchive;
|
||||
|
||||
const sectionMeta: Record<HomeContentSection, { label: string; description: string; addLabel: string }> = {
|
||||
experiences: { label: "体验推荐", description: "维护首页体验卡片和需求入口。", addLabel: "新增体验" },
|
||||
teamBuildings: { label: "团队共创", description: "维护首页团建卡片和需求入口。", addLabel: "新增团建" },
|
||||
wildArchives: { label: "极境视界", description: "维护首页案例卡片和需求入口。", addLabel: "新增案例" },
|
||||
};
|
||||
|
||||
export function homeContentSectionMeta(section: HomeContentSection) {
|
||||
return sectionMeta[section];
|
||||
}
|
||||
|
||||
export function HomeContentSectionRail({
|
||||
section,
|
||||
items,
|
||||
onSelect,
|
||||
}: {
|
||||
section: HomeContentSection;
|
||||
items: Record<HomeContentSection, HomeContentItem[]>;
|
||||
onSelect: (section: HomeContentSection) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="module-rail">
|
||||
<h3>首页内容</h3>
|
||||
{(Object.keys(sectionMeta) as HomeContentSection[]).map((itemSection, index) => {
|
||||
const meta = sectionMeta[itemSection];
|
||||
const active = itemSection === section;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`module-rail-item flex items-center gap-3 text-left${active ? " active" : ""}`}
|
||||
key={itemSection}
|
||||
aria-pressed={active}
|
||||
onClick={() => onSelect(itemSection)}
|
||||
>
|
||||
<em>{index + 1}</em>
|
||||
<span className="min-w-0">
|
||||
<b className="block">{meta.label}</b>
|
||||
<small className="block truncate">{items[itemSection].length} 条内容</small>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomeContentCardRail({
|
||||
section,
|
||||
items,
|
||||
onCreate,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMove,
|
||||
moving,
|
||||
}: {
|
||||
section: HomeContentSection;
|
||||
items: HomeContentItem[];
|
||||
onCreate: () => void;
|
||||
onEdit: (item: HomeContentItem) => void;
|
||||
onDelete: (item: HomeContentItem) => void;
|
||||
onMove: (itemId: string, direction: -1 | 1) => void;
|
||||
moving?: boolean;
|
||||
}) {
|
||||
const meta = sectionMeta[section];
|
||||
return (
|
||||
<section className="item-rail">
|
||||
<header className="module-data-head">
|
||||
<span>
|
||||
<h3>{meta.label}</h3>
|
||||
<p>{meta.description}</p>
|
||||
</span>
|
||||
<Button type="button" className="primary-action compact" onClick={onCreate}>
|
||||
<Plus size={16} />
|
||||
{meta.addLabel}
|
||||
</Button>
|
||||
</header>
|
||||
<div className="mapped-list">
|
||||
{items.map((item, index) => (
|
||||
<article className="mapped-list-row" key={item.id}>
|
||||
<div className="mapped-list-main min-w-0">
|
||||
{item.image ? <img src={item.image} alt="" /> : <span className="image-placeholder" />}
|
||||
<span className="min-w-0">
|
||||
<b className="block truncate">{item.title}</b>
|
||||
<small className="block truncate">
|
||||
{"badge" in item ? `${item.category} · ${item.englishTitle}` : "tag" in item ? `${item.tag} · ${item.detailParagraphs?.length ?? 0} 段正文` : `${item.images?.length ?? 0} 张照片 · ${item.demandKeyword}`}
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`hidden rounded-full px-2.5 py-1 text-xs font-semibold sm:inline-flex ${item.isActive ? "bg-emerald-50 text-emerald-700" : "bg-slate-100 text-slate-500"}`}>
|
||||
{item.isActive ? "已启用" : "已停用"}
|
||||
</span>
|
||||
<Button type="button" variant="ghost" size="icon" className="module-rail-action" aria-label={`上移${item.title}`} disabled={index === 0 || moving} onClick={() => onMove(item.id, -1)}>
|
||||
<ArrowUp size={15} />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="module-rail-action" aria-label={`下移${item.title}`} disabled={index === items.length - 1 || moving} onClick={() => onMove(item.id, 1)}>
|
||||
<ArrowDown size={15} />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="module-rail-action" aria-label={`编辑${item.title}`} onClick={() => onEdit(item)}>
|
||||
<Pencil size={15} />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="module-rail-action danger" aria-label={`删除${item.title}`} onClick={() => onDelete(item)}>
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{!items.length ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50 p-8 text-center">
|
||||
<p className="text-sm font-medium text-slate-700">暂无{meta.label}内容</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">点击右上角开始新增首页卡片。</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useState } from "react";
|
||||
import { Save, X } from "lucide-react";
|
||||
|
||||
import type { HomeExperience, HomeTeamBuilding, HomeWildArchive } from "@/api";
|
||||
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 { Textarea } from "@/components/ui/textarea";
|
||||
import type { HomeContentSection } from "./HomeContentCardRail";
|
||||
|
||||
export type HomeContentDraft = {
|
||||
badge: string;
|
||||
category: string;
|
||||
englishTitle: string;
|
||||
tag: string;
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
images: string[];
|
||||
demandKeyword: string;
|
||||
detailSubtitle: string;
|
||||
detailParagraphs: string[];
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
type HomeContentItem = HomeExperience | HomeTeamBuilding | HomeWildArchive;
|
||||
|
||||
function toDraft(item?: HomeContentItem): HomeContentDraft {
|
||||
return {
|
||||
badge: item && "badge" in item ? item.badge : "",
|
||||
category: item && "category" in item ? item.category : "",
|
||||
englishTitle: item && "englishTitle" in item ? item.englishTitle : "",
|
||||
tag: item && "tag" in item ? item.tag : "",
|
||||
title: item?.title ?? "",
|
||||
description: item && "description" in item ? item.description : "",
|
||||
image: item?.image ?? "",
|
||||
images: item && "images" in item ? item.images ?? [] : [],
|
||||
demandKeyword: item?.demandKeyword ?? "",
|
||||
detailSubtitle: item && "detailSubtitle" in item ? item.detailSubtitle ?? "" : "",
|
||||
detailParagraphs: item && "detailParagraphs" in item ? item.detailParagraphs ?? [] : [],
|
||||
isActive: item?.isActive ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
export function HomeContentEditor({
|
||||
section,
|
||||
mode,
|
||||
initialItem,
|
||||
saving,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
section: HomeContentSection;
|
||||
mode: "create" | "edit";
|
||||
initialItem?: HomeContentItem;
|
||||
saving: boolean;
|
||||
onSave: (draft: HomeContentDraft) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(() => toDraft(initialItem));
|
||||
const [error, setError] = useState("");
|
||||
const update = (patch: Partial<HomeContentDraft>) => {
|
||||
setDraft((current) => ({ ...current, ...patch }));
|
||||
if (error) setError("");
|
||||
};
|
||||
const sectionLabel = section === "experiences" ? "体验推荐" : section === "teamBuildings" ? "团队共创" : "极境视界";
|
||||
|
||||
const submit = () => {
|
||||
const input = Object.fromEntries(Object.entries(draft).map(([key, value]) => [key, typeof value === "string" ? value.trim() : value])) as HomeContentDraft;
|
||||
const required = section === "experiences"
|
||||
? [input.badge, input.category, input.title, input.englishTitle, input.image, input.demandKeyword]
|
||||
: section === "teamBuildings"
|
||||
? [input.tag, input.title, input.description, input.image, input.demandKeyword, input.detailSubtitle, input.detailParagraphs.length ? "has-paragraphs" : ""]
|
||||
: [input.title, input.image, input.demandKeyword, input.images.length ? "has-images" : ""];
|
||||
if (required.some((value) => !value)) {
|
||||
setError("请补充所有必填内容后再保存。" );
|
||||
return;
|
||||
}
|
||||
onSave(input);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mapped-editor flex h-full min-h-0 flex-col">
|
||||
<header className="editor-head">
|
||||
<span>
|
||||
<h3>{mode === "create" ? "新增" : "编辑"}{sectionLabel}</h3>
|
||||
<small>保存后会同步到首页 Public API 和 MiniAPP。</small>
|
||||
</span>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onClose} disabled={saving} aria-label="关闭首页内容编辑">
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
{section === "experiences" ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm font-medium text-slate-800">徽标<Input value={draft.badge} maxLength={40} onChange={(event) => update({ badge: event.target.value })} /></label>
|
||||
<label className="grid gap-2 text-sm font-medium text-slate-800">分类<Input value={draft.category} maxLength={80} onChange={(event) => update({ category: event.target.value })} /></label>
|
||||
<label className="grid gap-2 text-sm font-medium text-slate-800">标题<Input value={draft.title} maxLength={120} onChange={(event) => update({ title: event.target.value })} /></label>
|
||||
<label className="grid gap-2 text-sm font-medium text-slate-800">英文标题<Input value={draft.englishTitle} maxLength={120} onChange={(event) => update({ englishTitle: event.target.value })} /></label>
|
||||
</div>
|
||||
) : null}
|
||||
{section === "teamBuildings" ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm font-medium text-slate-800">标签<Input value={draft.tag} maxLength={40} onChange={(event) => update({ tag: event.target.value })} /></label>
|
||||
<label className="grid gap-2 text-sm font-medium text-slate-800">标题<Input value={draft.title} maxLength={120} onChange={(event) => update({ title: event.target.value })} /></label>
|
||||
</div>
|
||||
) : null}
|
||||
{section === "wildArchives" ? (
|
||||
<label className="grid gap-2 text-sm font-medium text-slate-800">案例标题<Input value={draft.title} maxLength={120} onChange={(event) => update({ title: event.target.value })} /></label>
|
||||
) : null}
|
||||
{section === "teamBuildings" ? (
|
||||
<label className="mt-4 grid gap-2 text-sm font-medium text-slate-800">描述<Textarea value={draft.description} maxLength={240} rows={4} onChange={(event) => update({ description: event.target.value })} /></label>
|
||||
) : null}
|
||||
{section === "teamBuildings" ? (
|
||||
<div className="mt-4 grid gap-4">
|
||||
<label className="grid gap-2 text-sm font-medium text-slate-800">
|
||||
详情副标题
|
||||
<Input value={draft.detailSubtitle} maxLength={160} placeholder="详情页首屏副标题" onChange={(event) => update({ detailSubtitle: event.target.value })} />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm font-medium text-slate-800">
|
||||
详情正文
|
||||
<Textarea
|
||||
value={draft.detailParagraphs.join("\n\n")}
|
||||
rows={10}
|
||||
placeholder="每段之间空一行,保存后按段落展示"
|
||||
onChange={(event) => update({ detailParagraphs: event.target.value.split(/\r?\n\s*\r?\n/).map((value) => value.trim()).filter(Boolean) })}
|
||||
/>
|
||||
<small className="text-xs font-normal text-slate-500">空行分隔多个段落,当前 {draft.detailParagraphs.length} 段。</small>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
<label className="mt-4 grid gap-2 text-sm font-medium text-slate-800">需求关键词<Input value={draft.demandKeyword} maxLength={120} placeholder="点击卡片后预填到需求页" onChange={(event) => update({ demandKeyword: event.target.value })} /></label>
|
||||
<div className="mt-5">
|
||||
<p className="mb-2 text-sm font-medium text-slate-800">封面图片</p>
|
||||
<SingleImageUploader value={draft.image} onChange={(value) => update({ image: value ?? "" })} group="home" />
|
||||
</div>
|
||||
{section === "wildArchives" ? (
|
||||
<label className="mt-5 grid gap-2 text-sm font-medium text-slate-800">
|
||||
详情图片
|
||||
<Textarea
|
||||
value={draft.images.join("\n")}
|
||||
rows={6}
|
||||
placeholder="每行填写一张图片 URL,至少一张"
|
||||
onChange={(event) => update({ images: event.target.value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean) })}
|
||||
/>
|
||||
<small className="text-xs font-normal text-slate-500">第一张建议与封面一致;接口只保存 URL,不上传图片二进制。</small>
|
||||
</label>
|
||||
) : null}
|
||||
<label className="mt-5 flex items-center justify-between gap-4 rounded-xl border border-slate-200 bg-white p-4">
|
||||
<span><b className="block text-sm font-semibold text-slate-900">前台启用</b><small className="mt-1 block text-xs text-slate-500">停用后保留在后台,不会进入 Public API。</small></span>
|
||||
<Switch checked={draft.isActive} onCheckedChange={(checked) => update({ isActive: checked })} />
|
||||
</label>
|
||||
{error ? <p className="mt-3 text-sm text-rose-600">{error}</p> : null}
|
||||
</div>
|
||||
<footer className="flex justify-end gap-3 border-t border-slate-100 px-5 py-4">
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>取消</Button>
|
||||
<Button type="button" onClick={submit} disabled={saving}><Save size={16} />{saving ? "保存中…" : mode === "create" ? "创建内容" : "保存修改"}</Button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { HomeContentSection } from "@/components/admin/HomeContentCardRail";
|
||||
import type { ModuleId } from "@/types/admin";
|
||||
|
||||
export type HomeModuleKey =
|
||||
| `structure:${ModuleId}`
|
||||
| `content:${HomeContentSection}`
|
||||
| "home:playRecommendations";
|
||||
|
||||
const modules: { key: HomeModuleKey; label: string }[] = [
|
||||
{ key: "structure:heroSlides", label: "顶部轮播" },
|
||||
{ key: "home:playRecommendations", label: "玩法推荐" },
|
||||
{ key: "structure:vehicleOptions", label: "万趣用车" },
|
||||
{ key: "content:experiences", label: "体验推荐" },
|
||||
{ key: "content:teamBuildings", label: "团队共创" },
|
||||
{ key: "content:wildArchives", label: "极境视界" },
|
||||
];
|
||||
|
||||
export function HomeModuleRail({
|
||||
activeModule,
|
||||
onSelect,
|
||||
}: {
|
||||
activeModule: HomeModuleKey;
|
||||
onSelect: (module: HomeModuleKey) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="module-rail">
|
||||
<h3>首页模块</h3>
|
||||
{modules.map((item, index) => {
|
||||
const active = item.key === activeModule;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`module-rail-item home-module-item${active ? " active" : ""}`}
|
||||
key={item.key}
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={() => onSelect(item.key)}
|
||||
>
|
||||
<em>{index + 1}</em>
|
||||
<span className="min-w-0">
|
||||
<b className="block">{item.label}</b>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from "react";
|
||||
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import type { HomeWanfaRecommendation, WanfaCategory } from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NativeSelect } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
export function HomeWanfaRecommendationRail({
|
||||
categories,
|
||||
items,
|
||||
onAdd,
|
||||
onDelete,
|
||||
onMove,
|
||||
onToggle,
|
||||
savingKey,
|
||||
}: {
|
||||
categories: WanfaCategory[];
|
||||
items: HomeWanfaRecommendation[];
|
||||
onAdd: (categoryId: string) => void;
|
||||
onDelete: (item: HomeWanfaRecommendation) => void;
|
||||
onMove: (itemId: string, direction: -1 | 1) => void;
|
||||
onToggle: (item: HomeWanfaRecommendation, isActive: boolean) => void;
|
||||
savingKey: string;
|
||||
}) {
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState("");
|
||||
const linkedCategoryIds = new Set(items.map((item) => item.categoryId));
|
||||
const availableCategories = categories.filter((category) => !linkedCategoryIds.has(category.id));
|
||||
|
||||
return (
|
||||
<section className="item-rail">
|
||||
<header className="module-data-head">
|
||||
<span>
|
||||
<h3>玩法推荐</h3>
|
||||
<p>关联玩法分类,维护首页推荐入口和展示顺序。</p>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<NativeSelect
|
||||
aria-label="选择要推荐的玩法分类"
|
||||
className="h-10 min-w-[148px] py-1.5 text-xs"
|
||||
value={selectedCategoryId}
|
||||
disabled={!availableCategories.length || savingKey === "add"}
|
||||
onChange={(event) => setSelectedCategoryId(event.target.value)}
|
||||
>
|
||||
<option value="">关联分类</option>
|
||||
{availableCategories.map((category) => (
|
||||
<option value={category.id} key={category.id}>
|
||||
{category.label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<Button
|
||||
type="button"
|
||||
className="primary-action compact"
|
||||
disabled={!selectedCategoryId || savingKey === "add"}
|
||||
onClick={() => {
|
||||
onAdd(selectedCategoryId);
|
||||
setSelectedCategoryId("");
|
||||
}}
|
||||
>
|
||||
<Plus size={16} />
|
||||
新增推荐
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="mapped-list">
|
||||
{items.map((item, index) => (
|
||||
<article className="mapped-list-row" key={item.id}>
|
||||
<div className="mapped-list-main min-w-0">
|
||||
<span className="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-teal-50 text-sm font-semibold text-teal-700">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<b className="block truncate">{item.categoryLabel}</b>
|
||||
<small className="block truncate">玩法分类 · {item.routeCount} 条路线</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="mr-1 flex items-center gap-2 text-xs text-slate-600">
|
||||
<Switch
|
||||
checked={item.isActive}
|
||||
aria-label={`${item.categoryLabel}${item.isActive ? "已启用" : "已停用"}`}
|
||||
disabled={savingKey === item.id}
|
||||
onCheckedChange={(checked) => onToggle(item, checked)}
|
||||
/>
|
||||
<span className="hidden sm:inline">{item.isActive ? "已启用" : "已停用"}</span>
|
||||
</label>
|
||||
<Button type="button" variant="ghost" size="icon" className="module-rail-action" aria-label={`上移${item.categoryLabel}`} disabled={index === 0 || savingKey === "reorder"} onClick={() => onMove(item.id, -1)}>
|
||||
<ArrowUp size={15} />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="module-rail-action" aria-label={`下移${item.categoryLabel}`} disabled={index === items.length - 1 || savingKey === "reorder"} onClick={() => onMove(item.id, 1)}>
|
||||
<ArrowDown size={15} />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="module-rail-action danger" aria-label={`移除${item.categoryLabel}`} disabled={savingKey === "delete"} onClick={() => onDelete(item)}>
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{!items.length ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50 p-8 text-center">
|
||||
<p className="text-sm font-medium text-slate-700">暂无玩法推荐</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">从右上角选择玩法分类,添加到首页推荐。</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from "react";
|
||||
import { Save, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export function WanfaCategoryEditor({
|
||||
mode,
|
||||
initialLabel,
|
||||
saving,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
mode: "create" | "edit";
|
||||
initialLabel: string;
|
||||
saving: boolean;
|
||||
onSave: (label: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [label, setLabel] = useState(initialLabel);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const submit = () => {
|
||||
const nextLabel = label.trim();
|
||||
if (!nextLabel) {
|
||||
setError("请填写分类名称。");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
onSave(nextLabel);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mapped-editor">
|
||||
<header className="editor-head">
|
||||
<span>
|
||||
<h3>{mode === "create" ? "添加玩法分类" : "编辑玩法分类"}</h3>
|
||||
<small>{mode === "create" ? "创建后即可在分类下维护路线。" : "修改后会同步更新前台分类名称。"}</small>
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
aria-label="关闭分类编辑"
|
||||
disabled={saving}
|
||||
>
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="min-h-0 overflow-y-auto px-5 py-5">
|
||||
<label className="grid gap-2 text-sm font-medium text-slate-800" htmlFor="wanfa-category-label">
|
||||
分类名称
|
||||
<Input
|
||||
id="wanfa-category-label"
|
||||
autoFocus
|
||||
value={label}
|
||||
maxLength={40}
|
||||
placeholder="例如:亲子路线"
|
||||
onChange={(event) => {
|
||||
setLabel(event.target.value);
|
||||
if (error) setError("");
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") submit();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{error ? <p className="mt-2 text-sm text-rose-600">{error}</p> : null}
|
||||
<p className="mt-3 text-xs leading-5 text-slate-500">
|
||||
分类名称用于后台列表和前台玩法入口展示,保存前会自动去除首尾空格。
|
||||
</p>
|
||||
</div>
|
||||
<footer className="flex justify-end gap-3 border-t border-slate-100 px-5 py-4">
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={submit} disabled={saving}>
|
||||
<Save size={16} />
|
||||
{saving ? "保存中…" : mode === "create" ? "创建分类" : "保存修改"}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import type { WanfaCategory } from "@/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function WanfaCategoryRail({
|
||||
categories,
|
||||
selectedCategoryId,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMove,
|
||||
movingId,
|
||||
}: {
|
||||
categories: WanfaCategory[];
|
||||
selectedCategoryId: string;
|
||||
onSelect: (categoryId: string) => void;
|
||||
onCreate: () => void;
|
||||
onEdit: (category: WanfaCategory) => void;
|
||||
onDelete: (category: WanfaCategory) => void;
|
||||
onMove: (categoryId: string, direction: -1 | 1) => void;
|
||||
movingId?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="module-rail">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<h3 className="mb-0">玩法分类</h3>
|
||||
<Button type="button" className="primary-action compact" aria-label="添加玩法分类" onClick={onCreate}>
|
||||
<Plus size={15} />
|
||||
添加分类
|
||||
</Button>
|
||||
</div>
|
||||
{categories.map((category, index) => {
|
||||
const isSelected = category.id === selectedCategoryId;
|
||||
return (
|
||||
<div className={`module-rail-item${isSelected ? " active" : ""}`} key={category.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="module-rail-select"
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => onSelect(category.id)}
|
||||
>
|
||||
<em>{index + 1}</em>
|
||||
<span>
|
||||
<b>{category.label}</b>
|
||||
<small>{category.routes.length ? `${category.routes.length} 条路线` : "暂无路线"}</small>
|
||||
</span>
|
||||
</button>
|
||||
<div className="module-rail-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="module-rail-action"
|
||||
aria-label={`上移${category.label}`}
|
||||
title={`上移${category.label}`}
|
||||
disabled={index === 0 || Boolean(movingId)}
|
||||
onClick={() => onMove(category.id, -1)}
|
||||
>
|
||||
<ArrowUp size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="module-rail-action"
|
||||
aria-label={`下移${category.label}`}
|
||||
title={`下移${category.label}`}
|
||||
disabled={index === categories.length - 1 || Boolean(movingId)}
|
||||
onClick={() => onMove(category.id, 1)}
|
||||
>
|
||||
<ArrowDown size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="module-rail-action"
|
||||
aria-label={`编辑${category.label}`}
|
||||
title={`编辑${category.label}`}
|
||||
onClick={() => onEdit(category)}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="module-rail-action danger"
|
||||
aria-label={`删除${category.label}`}
|
||||
title={`删除${category.label}`}
|
||||
onClick={() => onDelete(category)}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!categories.length ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50 p-5 text-center">
|
||||
<p className="text-sm font-medium text-slate-700">还没有玩法分类</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">添加一个分类,再维护分类下的路线。</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function WanfaConfirmDialog({
|
||||
title,
|
||||
description,
|
||||
confirmLabel = "确认删除",
|
||||
confirmDisabled = false,
|
||||
pending = false,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
confirmDisabled?: boolean;
|
||||
pending?: boolean;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
closeButtonRef.current?.focus();
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !pending) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose, pending]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] grid place-items-center bg-slate-950/30 p-4 backdrop-blur-[2px]">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 h-full w-full cursor-default"
|
||||
aria-label="关闭确认弹窗"
|
||||
onClick={() => !pending && onClose()}
|
||||
/>
|
||||
<section
|
||||
className="relative z-10 w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 shadow-2xl"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="wanfa-confirm-title"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-rose-50 text-rose-600">
|
||||
<AlertTriangle size={20} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h2 id="wanfa-confirm-title" className="text-base font-semibold text-slate-950">
|
||||
{title}
|
||||
</h2>
|
||||
<Button
|
||||
ref={closeButtonRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
aria-label="取消操作"
|
||||
onClick={onClose}
|
||||
disabled={pending}
|
||||
>
|
||||
<X size={17} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={pending}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
disabled={pending || confirmDisabled}
|
||||
>
|
||||
{pending ? "删除中…" : confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState } from "react";
|
||||
import { Save, X } from "lucide-react";
|
||||
|
||||
import type { WanfaRoute, WanfaRouteCreate } from "@/api";
|
||||
import { SingleImageUploader } from "@/components/admin/SingleImageUploader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const emptyRoute = (): WanfaRouteCreate => ({
|
||||
title: "",
|
||||
subtitle: "",
|
||||
image: "",
|
||||
routeCount: 0,
|
||||
demandKeyword: "",
|
||||
});
|
||||
|
||||
const routeDraft = (route?: WanfaRoute): WanfaRouteCreate =>
|
||||
route
|
||||
? {
|
||||
title: route.title,
|
||||
subtitle: route.subtitle,
|
||||
image: route.image,
|
||||
routeCount: route.routeCount,
|
||||
demandKeyword: route.demandKeyword,
|
||||
}
|
||||
: emptyRoute();
|
||||
|
||||
export function WanfaRouteEditor({
|
||||
mode,
|
||||
categoryLabel,
|
||||
initialRoute,
|
||||
saving,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
mode: "create" | "edit";
|
||||
categoryLabel: string;
|
||||
initialRoute?: WanfaRoute;
|
||||
saving: boolean;
|
||||
onSave: (input: WanfaRouteCreate) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<WanfaRouteCreate>(() => routeDraft(initialRoute));
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const update = <K extends keyof WanfaRouteCreate>(key: K, value: WanfaRouteCreate[K]) => {
|
||||
setDraft((current) => ({ ...current, [key]: value }));
|
||||
if (error) setError("");
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const nextDraft = {
|
||||
...draft,
|
||||
title: draft.title.trim(),
|
||||
subtitle: draft.subtitle.trim(),
|
||||
image: draft.image.trim(),
|
||||
demandKeyword: draft.demandKeyword.trim(),
|
||||
};
|
||||
if (!nextDraft.title || !nextDraft.subtitle || !nextDraft.image || !nextDraft.demandKeyword) {
|
||||
setError("请完整填写标题、副标题、封面和需求关键词。");
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(nextDraft.routeCount) || nextDraft.routeCount < 0) {
|
||||
setError("路线数量必须是大于等于 0 的整数。");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
onSave(nextDraft);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mapped-editor">
|
||||
<header className="editor-head">
|
||||
<span>
|
||||
<h3>{mode === "create" ? "新增分类路线" : "编辑分类路线"}</h3>
|
||||
<small>
|
||||
{mode === "create"
|
||||
? `添加到「${categoryLabel}」,保存后会排在当前路线末尾。`
|
||||
: `修改「${categoryLabel}」下的路线卡片内容。`}
|
||||
</small>
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
aria-label="关闭路线编辑"
|
||||
disabled={saving}
|
||||
>
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="min-h-0 space-y-5 overflow-y-auto px-5 py-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm font-medium text-slate-800 sm:col-span-2" htmlFor="wanfa-route-title">
|
||||
路线标题
|
||||
<Input
|
||||
id="wanfa-route-title"
|
||||
autoFocus
|
||||
value={draft.title}
|
||||
maxLength={80}
|
||||
placeholder="例如:亲子玩水"
|
||||
onChange={(event) => update("title", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm font-medium text-slate-800 sm:col-span-2" htmlFor="wanfa-route-subtitle">
|
||||
副标题/目的地说明
|
||||
<Textarea
|
||||
id="wanfa-route-subtitle"
|
||||
value={draft.subtitle}
|
||||
maxLength={160}
|
||||
placeholder="例如:贵州·轻松节奏与自然课堂"
|
||||
rows={3}
|
||||
onChange={(event) => update("subtitle", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm font-medium text-slate-800" htmlFor="wanfa-route-count">
|
||||
路线数量
|
||||
<Input
|
||||
id="wanfa-route-count"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={draft.routeCount}
|
||||
onChange={(event) => update("routeCount", Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm font-medium text-slate-800" htmlFor="wanfa-route-keyword">
|
||||
需求关键词
|
||||
<Input
|
||||
id="wanfa-route-keyword"
|
||||
value={draft.demandKeyword}
|
||||
maxLength={60}
|
||||
placeholder="例如:亲子玩水"
|
||||
onChange={(event) => update("demandKeyword", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm font-medium text-slate-800">
|
||||
<span>路线封面</span>
|
||||
<SingleImageUploader
|
||||
value={draft.image}
|
||||
onChange={(image) => update("image", image ?? "")}
|
||||
group="wanfa"
|
||||
/>
|
||||
</div>
|
||||
{error ? <p className="text-sm leading-5 text-rose-600">{error}</p> : null}
|
||||
</div>
|
||||
<footer className="flex justify-end gap-3 border-t border-slate-100 px-5 py-4">
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={submit} disabled={saving}>
|
||||
<Save size={16} />
|
||||
{saving ? (mode === "create" ? "创建中…" : "保存中…") : mode === "create" ? "创建路线" : "保存修改"}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import type { WanfaCategory, WanfaRoute } from "@/api";
|
||||
import { EmptyState } from "@/components/admin/EmptyState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function WanfaRouteRail({
|
||||
category,
|
||||
onCreate,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onMove,
|
||||
movingId,
|
||||
}: {
|
||||
category?: WanfaCategory;
|
||||
onCreate: () => void;
|
||||
onEdit: (route: WanfaRoute) => void;
|
||||
onDelete: (route: WanfaRoute) => void;
|
||||
onMove: (routeId: string, direction: -1 | 1) => void;
|
||||
movingId?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="item-rail">
|
||||
<header className="module-data-head">
|
||||
<span>
|
||||
<h3>{category?.label ?? "玩法路线"}</h3>
|
||||
<p>维护分类下的路线卡片,新增后会在前台玩法入口中展示。</p>
|
||||
</span>
|
||||
{category ? (
|
||||
<Button type="button" className="primary-action compact" onClick={onCreate}>
|
||||
<Plus size={16} />
|
||||
新增路线
|
||||
</Button>
|
||||
) : null}
|
||||
</header>
|
||||
{category ? (
|
||||
<div className="mapped-list">
|
||||
{category.routes.map((route, index) => (
|
||||
<div className="mapped-list-row" key={route.id}>
|
||||
<div className="mapped-list-main">
|
||||
{route.image ? <img src={route.image} alt="" /> : <span className="image-placeholder" />}
|
||||
<span>
|
||||
<b>{route.title}</b>
|
||||
<small>{route.subtitle} · 路线 {route.routeCount} 条 · 需求:{route.demandKeyword}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="hidden rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600 sm:inline-flex">
|
||||
已配置
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="module-rail-action"
|
||||
aria-label={`上移${route.title}`}
|
||||
title={`上移${route.title}`}
|
||||
disabled={index === 0 || Boolean(movingId)}
|
||||
onClick={() => onMove(route.id, -1)}
|
||||
>
|
||||
<ArrowUp size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="module-rail-action"
|
||||
aria-label={`下移${route.title}`}
|
||||
title={`下移${route.title}`}
|
||||
disabled={index === category.routes.length - 1 || Boolean(movingId)}
|
||||
onClick={() => onMove(route.id, 1)}
|
||||
>
|
||||
<ArrowDown size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="module-rail-action"
|
||||
aria-label={`编辑${route.title}`}
|
||||
title={`编辑${route.title}`}
|
||||
onClick={() => onEdit(route)}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="module-rail-action danger"
|
||||
aria-label={`删除${route.title}`}
|
||||
title={`删除${route.title}`}
|
||||
onClick={() => onDelete(route)}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!category.routes.length ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50 p-8 text-center">
|
||||
<p className="text-sm font-medium text-slate-700">暂无路线</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">点击右上角“新增路线”开始维护「{category.label}」。</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState text="请先添加或选择一个玩法分类。" />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user