- 新增客片案例全流程功能,包含前台页面、管理端配置、后端API、数据库迁移与文档 - 新增团队共创详情页面与对应接口 - 重构玩法模块:将静态playData替换为动态API获取,拆分类型定义到playTypes.ts - 优化vite构建配置与环境变量处理,调整详情页操作栏文案 - 更新全量相关文档与配图,新增客片案例、团队共创API文档 - 新增测试用例,完善数据归一化逻辑 - 调整路由配置与环境变量示例文件
163 lines
8.6 KiB
TypeScript
163 lines
8.6 KiB
TypeScript
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>
|
|
);
|
|
}
|