feat: 本次提交更新内容如下

存一版
This commit is contained in:
笔记本里的永平
2025-07-24 11:59:30 +08:00
parent a1261ebf68
commit d2d13f5125
21 changed files with 2668 additions and 46 deletions

View File

@@ -1,10 +0,0 @@
import React from "react";
import PlaceholderPage from "@/components/PlaceholderPage";
const Content: React.FC = () => {
return (
<PlaceholderPage title="内容管理" showAddButton addButtonText="新建内容" />
);
};
export default Content;

View File

@@ -1,8 +0,0 @@
import React from "react";
import PlaceholderPage from "@/components/PlaceholderPage";
const NewContent: React.FC = () => {
return <PlaceholderPage title="新建内容" />;
};
export default NewContent;

View File

@@ -0,0 +1,29 @@
import { get, post, put } from "@/api/request";
import {
ApiResponse,
ContentLibrary,
CreateContentLibraryParams,
UpdateContentLibraryParams,
} from "./data";
// 获取内容库详情
export async function getContentLibraryDetail(
id: string
): Promise<ApiResponse<ContentLibrary>> {
return get<ApiResponse<ContentLibrary>>(`/v1/content/library/${id}`);
}
// 创建内容库
export async function createContentLibrary(
params: CreateContentLibraryParams
): Promise<ApiResponse<ContentLibrary>> {
return post<ApiResponse<ContentLibrary>>("/v1/content/library", params);
}
// 更新内容库
export async function updateContentLibrary(
params: UpdateContentLibraryParams
): Promise<ApiResponse<ContentLibrary>> {
const { id, ...data } = params;
return put<ApiResponse<ContentLibrary>>(`/v1/content/library/${id}`, data);
}

View File

@@ -0,0 +1,61 @@
// 内容库表单数据类型定义
export interface ContentLibrary {
id: string;
name: string;
sourceType: number; // 1=微信好友, 2=聊天群
creatorName?: string;
updateTime: string;
status: number; // 0=未启用, 1=已启用
itemCount?: number;
createTime: string;
sourceFriends?: string[];
sourceGroups?: string[];
keywordInclude?: string[];
keywordExclude?: string[];
aiPrompt?: string;
timeEnabled?: number;
timeStart?: string;
timeEnd?: string;
selectedFriends?: any[];
selectedGroups?: any[];
selectedGroupMembers?: WechatGroupMember[];
}
// 微信群成员
export interface WechatGroupMember {
id: string;
nickname: string;
wechatId: string;
avatar: string;
gender?: "male" | "female";
role?: "owner" | "admin" | "member";
joinTime?: string;
}
// API 响应类型
export interface ApiResponse<T = any> {
code: number;
msg: string;
data: T;
}
// 创建内容库参数
export interface CreateContentLibraryParams {
name: string;
sourceType: number;
sourceFriends?: string[];
sourceGroups?: string[];
keywordInclude?: string[];
keywordExclude?: string[];
aiPrompt?: string;
timeEnabled?: number;
timeStart?: string;
timeEnd?: string;
}
// 更新内容库参数
export interface UpdateContentLibraryParams
extends Partial<CreateContentLibraryParams> {
id: string;
status?: number;
}

View File

@@ -0,0 +1,116 @@
.form-page {
padding: 16px;
background: #f5f5f5;
min-height: 100vh;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 40px 0;
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-card {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: none;
padding: 16px;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
.textarea {
border-radius: 6px;
border: 1px solid #d9d9d9;
&:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
}
.time-settings {
display: flex;
gap: 16px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.time-picker {
width: 100%;
border-radius: 6px;
&:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
}
.form-actions {
display: flex;
gap: 12px;
padding: 16px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-top: 16px;
}
.back-btn {
flex: 1;
border-radius: 6px;
border: 1px solid #d9d9d9;
&:hover {
border-color: #1677ff;
color: #1677ff;
}
}
.submit-btn {
flex: 1;
border-radius: 6px;
}
// 覆盖 antd-mobile 的默认样式
:global {
.adm-form-item {
margin-bottom: 16px;
}
.adm-form-item-label {
font-size: 14px;
color: #333;
font-weight: 500;
}
.adm-input {
border-radius: 6px;
border: 1px solid #d9d9d9;
&:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
}
.adm-switch {
--checked-color: #1677ff;
}
}

View File

@@ -0,0 +1,331 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Button,
Toast,
SpinLoading,
Form,
Input,
Switch,
Card,
Space,
} from "antd-mobile";
import { Input as AntdInput, Select, TimePicker } from "antd";
import {
ArrowLeftOutlined,
SaveOutlined,
UserOutlined,
TeamOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import {
getContentLibraryDetail,
createContentLibrary,
updateContentLibrary,
} from "./api";
import { ContentLibrary, CreateContentLibraryParams } from "./data";
import style from "./index.module.scss";
const { Option } = Select;
const { TextArea } = AntdInput;
const ContentLibraryForm: React.FC = () => {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [library, setLibrary] = useState<ContentLibrary | null>(null);
const [sourceType, setSourceType] = useState<number>(1);
const isEdit = !!id;
// 获取内容库详情
useEffect(() => {
if (isEdit && id) {
fetchLibraryDetail();
}
}, [isEdit, id]);
const fetchLibraryDetail = async () => {
setLoading(true);
try {
const response = await getContentLibraryDetail(id!);
if (response.code === 200 && response.data) {
setLibrary(response.data);
setSourceType(response.data.sourceType);
// 填充表单数据
form.setFieldsValue({
name: response.data.name,
sourceType: response.data.sourceType,
keywordInclude: response.data.keywordInclude?.join(",") || "",
keywordExclude: response.data.keywordExclude?.join(",") || "",
aiPrompt: response.data.aiPrompt || "",
timeEnabled: response.data.timeEnabled === 1,
timeStart: response.data.timeStart || "09:00",
timeEnd: response.data.timeEnd || "18:00",
});
} else {
Toast.show({
content: response.msg || "获取内容库详情失败",
position: "top",
});
}
} catch (error: any) {
console.error("获取内容库详情失败:", error);
Toast.show({
content: error?.message || "请检查网络连接",
position: "top",
});
} finally {
setLoading(false);
}
};
const handleSubmit = async (values: any) => {
setSaving(true);
try {
const params: CreateContentLibraryParams = {
name: values.name,
sourceType: values.sourceType,
keywordInclude: values.keywordInclude
? values.keywordInclude
.split(",")
.map((k: string) => k.trim())
.filter(Boolean)
: [],
keywordExclude: values.keywordExclude
? values.keywordExclude
.split(",")
.map((k: string) => k.trim())
.filter(Boolean)
: [],
aiPrompt: values.aiPrompt || "",
timeEnabled: values.timeEnabled ? 1 : 0,
timeStart: values.timeStart || "09:00",
timeEnd: values.timeEnd || "18:00",
};
let response;
if (isEdit) {
response = await updateContentLibrary({
id: id!,
...params,
});
} else {
response = await createContentLibrary(params);
}
if (response.code === 200) {
Toast.show({
content: isEdit ? "更新成功" : "创建成功",
position: "top",
});
navigate("/content");
} else {
Toast.show({
content: response.msg || (isEdit ? "更新失败" : "创建失败"),
position: "top",
});
}
} catch (error: any) {
console.error("保存内容库失败:", error);
Toast.show({
content: error?.message || "请检查网络连接",
position: "top",
});
} finally {
setSaving(false);
}
};
const handleBack = () => {
navigate("/content");
};
if (loading) {
return (
<Layout
header={<NavCommon title={isEdit ? "编辑内容库" : "新建内容库"} />}
>
<div className={style["loading"]}>
<SpinLoading color="primary" style={{ fontSize: 32 }} />
</div>
</Layout>
);
}
return (
<Layout header={<NavCommon title={isEdit ? "编辑内容库" : "新建内容库"} />}>
<div className={style["form-page"]}>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
className={style["form"]}
initialValues={{
sourceType: 1,
timeEnabled: false,
timeStart: "09:00",
timeEnd: "18:00",
}}
>
{/* 基本信息 */}
<Card className={style["form-card"]}>
<div className={style["card-title"]}></div>
<Form.Item
name="name"
label="内容库名称"
rules={[{ required: true, message: "请输入内容库名称" }]}
>
<Input placeholder="请输入内容库名称" />
</Form.Item>
<Form.Item
name="sourceType"
label="数据来源"
rules={[{ required: true, message: "请选择数据来源" }]}
>
<Select
placeholder="请选择数据来源"
onChange={(value) => setSourceType(value)}
>
<Option value={1}>
<Space>
<UserOutlined />
</Space>
</Option>
<Option value={2}>
<Space>
<TeamOutlined />
</Space>
</Option>
</Select>
</Form.Item>
</Card>
{/* 关键词设置 */}
<Card className={style["form-card"]}>
<div className={style["card-title"]}></div>
<Form.Item
name="keywordInclude"
label="包含关键词"
extra="多个关键词用逗号分隔"
>
<TextArea
placeholder="请输入包含的关键词,多个用逗号分隔"
rows={3}
className={style["textarea"]}
/>
</Form.Item>
<Form.Item
name="keywordExclude"
label="排除关键词"
extra="多个关键词用逗号分隔"
>
<TextArea
placeholder="请输入排除的关键词,多个用逗号分隔"
rows={3}
className={style["textarea"]}
/>
</Form.Item>
</Card>
{/* AI 设置 */}
<Card className={style["form-card"]}>
<div className={style["card-title"]}>AI </div>
<Form.Item
name="aiPrompt"
label="AI 提示词"
extra="用于AI处理内容的提示词"
>
<TextArea
placeholder="请输入AI提示词"
rows={4}
className={style["textarea"]}
/>
</Form.Item>
</Card>
{/* 时间设置 */}
<Card className={style["form-card"]}>
<div className={style["card-title"]}></div>
<Form.Item name="timeEnabled" label="启用时间限制">
<Switch />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) =>
prevValues.timeEnabled !== currentValues.timeEnabled
}
>
{({ getFieldValue }) => {
const timeEnabled = getFieldValue("timeEnabled");
return timeEnabled ? (
<div className={style["time-settings"]}>
<Form.Item
name="timeStart"
label="开始时间"
rules={[{ required: true, message: "请选择开始时间" }]}
>
<TimePicker
format="HH:mm"
placeholder="选择开始时间"
className={style["time-picker"]}
/>
</Form.Item>
<Form.Item
name="timeEnd"
label="结束时间"
rules={[{ required: true, message: "请选择结束时间" }]}
>
<TimePicker
format="HH:mm"
placeholder="选择结束时间"
className={style["time-picker"]}
/>
</Form.Item>
</div>
) : null;
}}
</Form.Item>
</Card>
{/* 操作按钮 */}
<div className={style["form-actions"]}>
<Button
fill="outline"
onClick={handleBack}
className={style["back-btn"]}
>
<ArrowLeftOutlined />
</Button>
<Button
color="primary"
type="submit"
loading={saving}
className={style["submit-btn"]}
>
<SaveOutlined />
{isEdit ? "更新" : "创建"}
</Button>
</div>
</Form>
</div>
</Layout>
);
};
export default ContentLibraryForm;

View File

@@ -0,0 +1,65 @@
import { get, post, put, del } from "@/api/request";
import {
ApiResponse,
LibraryListResponse,
ContentLibrary,
CreateContentLibraryParams,
UpdateContentLibraryParams,
} from "./data";
// 获取内容库列表
export async function getContentLibraryList(params: {
page?: number;
limit?: number;
keyword?: string;
sourceType?: number;
}): Promise<ApiResponse<LibraryListResponse>> {
const queryParams = new URLSearchParams();
if (params.page) queryParams.append("page", params.page.toString());
if (params.limit) queryParams.append("limit", params.limit.toString());
if (params.keyword) queryParams.append("keyword", params.keyword);
if (params.sourceType)
queryParams.append("sourceType", params.sourceType.toString());
return get<ApiResponse<LibraryListResponse>>(
`/v1/content/library/list?${queryParams.toString()}`
);
}
// 获取内容库详情
export async function getContentLibraryDetail(
id: string
): Promise<ApiResponse<ContentLibrary>> {
return get<ApiResponse<ContentLibrary>>(`/v1/content/library/${id}`);
}
// 创建内容库
export async function createContentLibrary(
params: CreateContentLibraryParams
): Promise<ApiResponse<ContentLibrary>> {
return post<ApiResponse<ContentLibrary>>("/v1/content/library", params);
}
// 更新内容库
export async function updateContentLibrary(
params: UpdateContentLibraryParams
): Promise<ApiResponse<ContentLibrary>> {
const { id, ...data } = params;
return put<ApiResponse<ContentLibrary>>(`/v1/content/library/${id}`, data);
}
// 删除内容库
export async function deleteContentLibrary(
id: string
): Promise<ApiResponse<void>> {
return del<ApiResponse<void>>(`/v1/content/library/delete?id=${id}`);
}
// 切换内容库状态
export async function toggleContentLibraryStatus(
id: string,
status: number
): Promise<ApiResponse<void>> {
return put<ApiResponse<void>>(`/v1/content/library/${id}/status`, { status });
}

View File

@@ -0,0 +1,66 @@
// 内容库接口类型定义
export interface ContentLibrary {
id: string;
name: string;
sourceType: number; // 1=微信好友, 2=聊天群
creatorName?: string;
updateTime: string;
status: number; // 0=未启用, 1=已启用
itemCount?: number;
createTime: string;
sourceFriends?: string[];
sourceGroups?: string[];
keywordInclude?: string[];
keywordExclude?: string[];
aiPrompt?: string;
timeEnabled?: number;
timeStart?: string;
timeEnd?: string;
selectedFriends?: any[];
selectedGroups?: any[];
selectedGroupMembers?: WechatGroupMember[];
}
// 微信群成员
export interface WechatGroupMember {
id: string;
nickname: string;
wechatId: string;
avatar: string;
gender?: "male" | "female";
role?: "owner" | "admin" | "member";
joinTime?: string;
}
// API 响应类型
export interface ApiResponse<T = any> {
code: number;
msg: string;
data: T;
}
export interface LibraryListResponse {
list: ContentLibrary[];
total: number;
}
// 创建内容库参数
export interface CreateContentLibraryParams {
name: string;
sourceType: number;
sourceFriends?: string[];
sourceGroups?: string[];
keywordInclude?: string[];
keywordExclude?: string[];
aiPrompt?: string;
timeEnabled?: number;
timeStart?: string;
timeEnd?: string;
}
// 更新内容库参数
export interface UpdateContentLibraryParams
extends Partial<CreateContentLibraryParams> {
id: string;
status?: number;
}

View File

@@ -0,0 +1,261 @@
.content-library-page {
padding: 16px;
background: #f5f5f5;
min-height: 100vh;
}
.search-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
background: white;
padding: 12px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.search-input-wrapper {
position: relative;
flex: 1;
}
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #999;
z-index: 1;
}
.search-input {
padding-left: 36px;
border-radius: 20px;
border: 1px solid #e0e0e0;
&:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
}
.refresh-btn {
border-radius: 20px;
border: 1px solid #e0e0e0;
background: white;
&:hover {
border-color: #1677ff;
color: #1677ff;
}
}
.create-btn {
border-radius: 20px;
padding: 0 16px;
}
.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.tabs-wrapper {
margin-bottom: 16px;
background: white;
border-radius: 8px;
padding: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.tabs {
display: flex;
background: #f5f5f5;
border-radius: 6px;
padding: 2px;
}
.tab {
flex: 1;
text-align: center;
padding: 8px 12px;
border-radius: 4px;
font-size: 14px;
color: #666;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(22, 119, 255, 0.1);
}
&.active {
background: white;
color: #1677ff;
font-weight: 500;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
}
.library-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 40px 0;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.6;
}
.empty-text {
color: #999;
margin-bottom: 20px;
font-size: 14px;
}
.empty-btn {
border-radius: 20px;
padding: 0 20px;
}
.library-card {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: none;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.library-info {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.library-name {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0;
}
.status-tag {
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
}
.menu-btn {
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: #999;
border-radius: 4px;
&:hover {
background: #f5f5f5;
color: #666;
}
}
.menu-dropdown {
position: absolute;
right: 0;
top: 100%;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
min-width: 120px;
padding: 4px;
margin-top: 4px;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
font-size: 14px;
color: #333;
transition: background 0.2s;
&:hover {
background: #f5f5f5;
}
&.danger {
color: #ff4d4f;
&:hover {
background: #fff2f0;
}
}
}
.card-content {
display: flex;
flex-direction: column;
gap: 6px;
}
.info-row {
display: flex;
align-items: center;
font-size: 13px;
}
.label {
color: #999;
min-width: 70px;
margin-right: 8px;
}
.value {
color: #333;
flex: 1;
}

View File

@@ -0,0 +1,344 @@
import React, { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import {
Button,
Toast,
SpinLoading,
Dialog,
Card,
Avatar,
Tag,
} from "antd-mobile";
import { Input } from "antd";
import {
PlusOutlined,
SearchOutlined,
ReloadOutlined,
EyeOutlined,
EditOutlined,
DeleteOutlined,
MoreOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import { getContentLibraryList, deleteContentLibrary } from "./api";
import { ContentLibrary } from "./data";
import style from "./index.module.scss";
// 卡片菜单组件
interface CardMenuProps {
onView: () => void;
onEdit: () => void;
onDelete: () => void;
onViewMaterials: () => void;
}
const CardMenu: React.FC<CardMenuProps> = ({
onView,
onEdit,
onDelete,
onViewMaterials,
}) => {
const [open, setOpen] = useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
if (open) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
return (
<div style={{ position: "relative" }}>
<button onClick={() => setOpen((v) => !v)} className={style["menu-btn"]}>
<MoreOutlined />
</button>
{open && (
<div ref={menuRef} className={style["menu-dropdown"]}>
<div
onClick={() => {
onEdit();
setOpen(false);
}}
className={style["menu-item"]}
>
<EditOutlined />
</div>
<div
onClick={() => {
onDelete();
setOpen(false);
}}
className={`${style["menu-item"]} ${style["danger"]}`}
>
<DeleteOutlined />
</div>
<div
onClick={() => {
onViewMaterials();
setOpen(false);
}}
className={style["menu-item"]}
>
<EyeOutlined />
</div>
</div>
)}
</div>
);
};
const ContentLibraryList: React.FC = () => {
const navigate = useNavigate();
const [libraries, setLibraries] = useState<ContentLibrary[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [activeTab, setActiveTab] = useState("all");
const [loading, setLoading] = useState(false);
// 获取内容库列表
const fetchLibraries = useCallback(async () => {
setLoading(true);
try {
const response = await getContentLibraryList({
page: 1,
limit: 100,
keyword: searchQuery,
sourceType:
activeTab !== "all" ? (activeTab === "friends" ? 1 : 2) : undefined,
});
if (response.code === 200 && response.data) {
setLibraries(response.data.list || []);
} else {
Toast.show({
content: response.msg || "获取内容库列表失败",
position: "top",
});
}
} catch (error: any) {
console.error("获取内容库列表失败:", error);
Toast.show({
content: error?.message || "请检查网络连接",
position: "top",
});
} finally {
setLoading(false);
}
}, [searchQuery, activeTab]);
useEffect(() => {
fetchLibraries();
}, [fetchLibraries]);
const handleCreateNew = () => {
navigate("/content/new");
};
const handleEdit = (id: string) => {
navigate(`/content/edit/${id}`);
};
const handleDelete = async (id: string) => {
const result = await Dialog.confirm({
content: "确定要删除这个内容库吗?",
confirmText: "删除",
cancelText: "取消",
});
if (result) {
try {
const response = await deleteContentLibrary(id);
if (response.code === 200) {
Toast.show({
content: "删除成功",
position: "top",
});
fetchLibraries();
} else {
Toast.show({
content: response.msg || "删除失败",
position: "top",
});
}
} catch (error: any) {
console.error("删除内容库失败:", error);
Toast.show({
content: error?.message || "请检查网络连接",
position: "top",
});
}
}
};
const handleViewMaterials = (id: string) => {
navigate(`/content/materials/${id}`);
};
const handleSearch = () => {
fetchLibraries();
};
const handleRefresh = () => {
fetchLibraries();
};
const filteredLibraries = libraries.filter(
(library) =>
library.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
library.creatorName?.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<Layout header={<NavCommon title="内容库" />}>
<div className={style["content-library-page"]}>
{/* 搜索和操作栏 */}
<div className={style["search-bar"]}>
<div className={style["search-input-wrapper"]}>
<SearchOutlined className={style["search-icon"]} />
<Input
placeholder="搜索内容库..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onPressEnter={handleSearch}
className={style["search-input"]}
/>
</div>
<Button
size="small"
onClick={handleRefresh}
disabled={loading}
className={style["refresh-btn"]}
>
<ReloadOutlined className={loading ? style["spinning"] : ""} />
</Button>
<Button
color="primary"
size="small"
onClick={handleCreateNew}
className={style["create-btn"]}
>
<PlusOutlined />
</Button>
</div>
{/* 标签页 */}
<div className={style["tabs-wrapper"]}>
<div className={style["tabs"]}>
<div
className={`${style["tab"]} ${
activeTab === "all" ? style["active"] : ""
}`}
onClick={() => setActiveTab("all")}
>
</div>
<div
className={`${style["tab"]} ${
activeTab === "friends" ? style["active"] : ""
}`}
onClick={() => setActiveTab("friends")}
>
</div>
<div
className={`${style["tab"]} ${
activeTab === "groups" ? style["active"] : ""
}`}
onClick={() => setActiveTab("groups")}
>
</div>
</div>
</div>
{/* 内容库列表 */}
<div className={style["library-list"]}>
{loading ? (
<div className={style["loading"]}>
<SpinLoading color="primary" style={{ fontSize: 32 }} />
</div>
) : filteredLibraries.length === 0 ? (
<div className={style["empty-state"]}>
<div className={style["empty-icon"]}>📚</div>
<div className={style["empty-text"]}>
</div>
<Button
color="primary"
size="small"
onClick={handleCreateNew}
className={style["empty-btn"]}
>
</Button>
</div>
) : (
filteredLibraries.map((library) => (
<Card key={library.id} className={style["library-card"]}>
<div className={style["card-header"]}>
<div className={style["library-info"]}>
<h3 className={style["library-name"]}>{library.name}</h3>
<Tag
color={library.status === 1 ? "success" : "default"}
className={style["status-tag"]}
>
{library.status === 1 ? "已启用" : "未启用"}
</Tag>
</div>
<CardMenu
onView={() => navigate(`/content/${library.id}`)}
onEdit={() => handleEdit(library.id)}
onDelete={() => handleDelete(library.id)}
onViewMaterials={() => handleViewMaterials(library.id)}
/>
</div>
<div className={style["card-content"]}>
<div className={style["info-row"]}>
<span className={style["label"]}></span>
<span className={style["value"]}>
{library.sourceType === 1 ? "微信好友" : "聊天群"}
</span>
</div>
<div className={style["info-row"]}>
<span className={style["label"]}></span>
<span className={style["value"]}>
{library.creatorName || "系统"}
</span>
</div>
<div className={style["info-row"]}>
<span className={style["label"]}></span>
<span className={style["value"]}>
{library.itemCount || 0}
</span>
</div>
<div className={style["info-row"]}>
<span className={style["label"]}></span>
<span className={style["value"]}>
{new Date(library.updateTime).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
</div>
</Card>
))
)}
</div>
</div>
</Layout>
);
};
export default ContentLibraryList;

View File

@@ -1,10 +0,0 @@
import React from "react";
import PlaceholderPage from "@/components/PlaceholderPage";
const Materials: React.FC = () => {
return (
<PlaceholderPage title="素材管理" showAddButton addButtonText="新建素材" />
);
};
export default Materials;

View File

@@ -1,8 +0,0 @@
import React from "react";
import PlaceholderPage from "@/components/PlaceholderPage";
const MaterialsNew: React.FC = () => {
return <PlaceholderPage title="新建素材" />;
};
export default MaterialsNew;

View File

@@ -0,0 +1,37 @@
import { get, post, put } from "@/api/request";
import {
ApiResponse,
ContentItem,
ContentLibrary,
CreateContentItemParams,
UpdateContentItemParams,
} from "./data";
// 获取素材详情
export async function getContentItemDetail(
id: string
): Promise<ApiResponse<ContentItem>> {
return get<ApiResponse<ContentItem>>(`/v1/content/item/${id}`);
}
// 创建素材
export async function createContentItem(
params: CreateContentItemParams
): Promise<ApiResponse<ContentItem>> {
return post<ApiResponse<ContentItem>>("/v1/content/item", params);
}
// 更新素材
export async function updateContentItem(
params: UpdateContentItemParams
): Promise<ApiResponse<ContentItem>> {
const { id, ...data } = params;
return put<ApiResponse<ContentItem>>(`/v1/content/item/${id}`, data);
}
// 获取内容库详情
export async function getContentLibraryDetail(
id: string
): Promise<ApiResponse<ContentLibrary>> {
return get<ApiResponse<ContentLibrary>>(`/v1/content/library/${id}`);
}

View File

@@ -0,0 +1,85 @@
// 素材数据类型定义
export interface ContentItem {
id: string;
libraryId: string;
title: string;
content: string;
contentType: number; // 0=未知, 1=图片, 2=链接, 3=视频, 4=文本, 5=小程序, 6=图文
contentTypeName?: string;
resUrls?: string[];
urls?: string[];
comment?: string;
sendTime?: string;
createTime: string;
updateTime: string;
wechatId?: string;
wechatNickname?: string;
wechatAvatar?: string;
snsId?: string;
msgId?: string;
type?: string;
contentData?: string;
createMomentTime?: string;
createMessageTime?: string;
createMomentTimeFormatted?: string;
createMessageTimeFormatted?: string;
}
// 内容库类型
export interface ContentLibrary {
id: string;
name: string;
sourceType: number; // 1=微信好友, 2=聊天群
creatorName?: string;
updateTime: string;
status: number; // 0=未启用, 1=已启用
itemCount?: number;
createTime: string;
sourceFriends?: string[];
sourceGroups?: string[];
keywordInclude?: string[];
keywordExclude?: string[];
aiPrompt?: string;
timeEnabled?: number;
timeStart?: string;
timeEnd?: string;
selectedFriends?: any[];
selectedGroups?: any[];
selectedGroupMembers?: WechatGroupMember[];
}
// 微信群成员
export interface WechatGroupMember {
id: string;
nickname: string;
wechatId: string;
avatar: string;
gender?: "male" | "female";
role?: "owner" | "admin" | "member";
joinTime?: string;
}
// API 响应类型
export interface ApiResponse<T = any> {
code: number;
msg: string;
data: T;
}
// 创建素材参数
export interface CreateContentItemParams {
libraryId: string;
title: string;
content: string;
contentType: number;
resUrls?: string[];
urls?: string[];
comment?: string;
sendTime?: string;
}
// 更新素材参数
export interface UpdateContentItemParams
extends Partial<CreateContentItemParams> {
id: string;
}

View File

@@ -0,0 +1,125 @@
.form-page {
padding: 16px;
background: #f5f5f5;
min-height: 100vh;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 40px 0;
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-card {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: none;
padding: 16px;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
.textarea {
border-radius: 6px;
border: 1px solid #d9d9d9;
&:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
}
.time-picker {
width: 100%;
border-radius: 6px;
&:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
}
.select-option {
display: flex;
align-items: center;
gap: 8px;
.anticon {
font-size: 16px;
color: #1677ff;
}
}
.form-actions {
display: flex;
gap: 12px;
padding: 16px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-top: 16px;
}
.back-btn {
flex: 1;
border-radius: 6px;
border: 1px solid #d9d9d9;
&:hover {
border-color: #1677ff;
color: #1677ff;
}
}
.submit-btn {
flex: 1;
border-radius: 6px;
}
// 覆盖 antd-mobile 的默认样式
:global {
.adm-form-item {
margin-bottom: 16px;
}
.adm-form-item-label {
font-size: 14px;
color: #333;
font-weight: 500;
}
.adm-input {
border-radius: 6px;
border: 1px solid #d9d9d9;
&:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
}
.adm-select {
border-radius: 6px;
border: 1px solid #d9d9d9;
&:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
}
}

View File

@@ -0,0 +1,327 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Button,
Toast,
SpinLoading,
Form,
Input,
Card,
Select,
Upload,
} from "antd-mobile";
import { Input as AntdInput, TimePicker } from "antd";
import {
ArrowLeftOutlined,
SaveOutlined,
UploadOutlined,
PictureOutlined,
LinkOutlined,
VideoCameraOutlined,
FileTextOutlined,
AppstoreOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import {
getContentItemDetail,
createContentItem,
updateContentItem,
getContentLibraryDetail,
} from "./api";
import { ContentItem, ContentLibrary } from "./data";
import style from "./index.module.scss";
const { Option } = Select;
const { TextArea } = AntdInput;
// 内容类型选项
const contentTypeOptions = [
{ value: 1, label: "图片", icon: <PictureOutlined /> },
{ value: 2, label: "链接", icon: <LinkOutlined /> },
{ value: 3, label: "视频", icon: <VideoCameraOutlined /> },
{ value: 4, label: "文本", icon: <FileTextOutlined /> },
{ value: 5, label: "小程序", icon: <AppstoreOutlined /> },
{ value: 6, label: "图文", icon: <PictureOutlined /> },
];
const MaterialForm: React.FC = () => {
const navigate = useNavigate();
const { id: libraryId, materialId } = useParams<{
id: string;
materialId: string;
}>();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [material, setMaterial] = useState<ContentItem | null>(null);
const [library, setLibrary] = useState<ContentLibrary | null>(null);
const [contentType, setContentType] = useState<number>(4);
const isEdit = !!materialId;
// 获取内容库详情
useEffect(() => {
if (libraryId) {
fetchLibraryDetail();
}
}, [libraryId]);
// 获取素材详情
useEffect(() => {
if (isEdit && materialId) {
fetchMaterialDetail();
}
}, [isEdit, materialId]);
const fetchLibraryDetail = async () => {
if (!libraryId) return;
try {
const response = await getContentLibraryDetail(libraryId);
if (response.code === 200 && response.data) {
setLibrary(response.data);
}
} catch (error) {
console.error("获取内容库详情失败:", error);
}
};
const fetchMaterialDetail = async () => {
if (!materialId) return;
setLoading(true);
try {
const response = await getContentItemDetail(materialId);
if (response.code === 200 && response.data) {
setMaterial(response.data);
setContentType(response.data.contentType);
// 填充表单数据
form.setFieldsValue({
title: response.data.title,
content: response.data.content,
contentType: response.data.contentType,
comment: response.data.comment || "",
sendTime: response.data.sendTime || "",
resUrls: response.data.resUrls || [],
urls: response.data.urls || [],
});
} else {
Toast.show({
content: response.msg || "获取素材详情失败",
position: "top",
});
}
} catch (error: any) {
console.error("获取素材详情失败:", error);
Toast.show({
content: error?.message || "请检查网络连接",
position: "top",
});
} finally {
setLoading(false);
}
};
const handleSubmit = async (values: any) => {
if (!libraryId) return;
setSaving(true);
try {
const params = {
libraryId,
title: values.title,
content: values.content,
contentType: values.contentType,
comment: values.comment || "",
sendTime: values.sendTime || "",
resUrls: values.resUrls || [],
urls: values.urls || [],
};
let response;
if (isEdit) {
response = await updateContentItem({
id: materialId!,
...params,
});
} else {
response = await createContentItem(params);
}
if (response.code === 200) {
Toast.show({
content: isEdit ? "更新成功" : "创建成功",
position: "top",
});
navigate(`/content/materials/${libraryId}`);
} else {
Toast.show({
content: response.msg || (isEdit ? "更新失败" : "创建失败"),
position: "top",
});
}
} catch (error: any) {
console.error("保存素材失败:", error);
Toast.show({
content: error?.message || "请检查网络连接",
position: "top",
});
} finally {
setSaving(false);
}
};
const handleBack = () => {
navigate(`/content/materials/${libraryId}`);
};
if (loading) {
return (
<Layout header={<NavCommon title={isEdit ? "编辑素材" : "新建素材"} />}>
<div className={style["loading"]}>
<SpinLoading color="primary" style={{ fontSize: 32 }} />
</div>
</Layout>
);
}
return (
<Layout header={<NavCommon title={isEdit ? "编辑素材" : "新建素材"} />}>
<div className={style["form-page"]}>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
className={style["form"]}
initialValues={{
contentType: 4,
resUrls: [],
urls: [],
}}
>
{/* 基本信息 */}
<Card className={style["form-card"]}>
<div className={style["card-title"]}></div>
<Form.Item
name="title"
label="素材标题"
rules={[{ required: true, message: "请输入素材标题" }]}
>
<Input placeholder="请输入素材标题" />
</Form.Item>
<Form.Item
name="contentType"
label="内容类型"
rules={[{ required: true, message: "请选择内容类型" }]}
>
<Select
placeholder="请选择内容类型"
onChange={(value) => setContentType(value)}
>
{contentTypeOptions.map((option) => (
<Option key={option.value} value={option.value}>
<div className={style["select-option"]}>
{option.icon}
<span>{option.label}</span>
</div>
</Option>
))}
</Select>
</Form.Item>
<Form.Item
name="content"
label="内容"
rules={[{ required: true, message: "请输入内容" }]}
>
<TextArea
placeholder="请输入内容"
rows={6}
className={style["textarea"]}
/>
</Form.Item>
</Card>
{/* 资源设置 */}
<Card className={style["form-card"]}>
<div className={style["card-title"]}></div>
<Form.Item
name="resUrls"
label="资源链接"
extra="图片、视频等资源链接,多个用换行分隔"
>
<TextArea
placeholder="请输入资源链接,多个用换行分隔"
rows={4}
className={style["textarea"]}
/>
</Form.Item>
<Form.Item
name="urls"
label="外部链接"
extra="外部网页链接,多个用换行分隔"
>
<TextArea
placeholder="请输入外部链接,多个用换行分隔"
rows={4}
className={style["textarea"]}
/>
</Form.Item>
</Card>
{/* 其他设置 */}
<Card className={style["form-card"]}>
<div className={style["card-title"]}></div>
<Form.Item name="comment" label="备注" extra="素材备注信息">
<TextArea
placeholder="请输入备注信息"
rows={3}
className={style["textarea"]}
/>
</Form.Item>
<Form.Item
name="sendTime"
label="发送时间"
extra="计划发送时间(可选)"
>
<TimePicker
format="YYYY-MM-DD HH:mm:ss"
placeholder="选择发送时间"
className={style["time-picker"]}
/>
</Form.Item>
</Card>
{/* 操作按钮 */}
<div className={style["form-actions"]}>
<Button
fill="outline"
onClick={handleBack}
className={style["back-btn"]}
>
<ArrowLeftOutlined />
</Button>
<Button
color="primary"
type="submit"
loading={saving}
className={style["submit-btn"]}
>
<SaveOutlined />
{isEdit ? "更新" : "创建"}
</Button>
</div>
</Form>
</div>
</Layout>
);
};
export default MaterialForm;

View File

@@ -0,0 +1,62 @@
import { get, post, put, del } from "@/api/request";
import {
ApiResponse,
ItemListResponse,
ContentItem,
ContentLibrary,
GetContentItemListParams,
CreateContentItemParams,
UpdateContentItemParams,
} from "./data";
// 获取素材列表
export async function getContentItemList(
params: GetContentItemListParams
): Promise<ApiResponse<ItemListResponse>> {
const queryParams = new URLSearchParams();
queryParams.append("libraryId", params.libraryId);
if (params.page) queryParams.append("page", params.page.toString());
if (params.limit) queryParams.append("limit", params.limit.toString());
if (params.keyword) queryParams.append("keyword", params.keyword);
return get<ApiResponse<ItemListResponse>>(
`/v1/content/item/list?${queryParams.toString()}`
);
}
// 获取素材详情
export async function getContentItemDetail(
id: string
): Promise<ApiResponse<ContentItem>> {
return get<ApiResponse<ContentItem>>(`/v1/content/item/${id}`);
}
// 创建素材
export async function createContentItem(
params: CreateContentItemParams
): Promise<ApiResponse<ContentItem>> {
return post<ApiResponse<ContentItem>>("/v1/content/item", params);
}
// 更新素材
export async function updateContentItem(
params: UpdateContentItemParams
): Promise<ApiResponse<ContentItem>> {
const { id, ...data } = params;
return put<ApiResponse<ContentItem>>(`/v1/content/item/${id}`, data);
}
// 删除素材
export async function deleteContentItem(
id: string
): Promise<ApiResponse<void>> {
return del<ApiResponse<void>>(`/v1/content/item/delete?id=${id}`);
}
// 获取内容库详情
export async function getContentLibraryDetail(
id: string
): Promise<ApiResponse<ContentLibrary>> {
return get<ApiResponse<ContentLibrary>>(`/v1/content/library/${id}`);
}

View File

@@ -0,0 +1,98 @@
// 素材数据类型定义
export interface ContentItem {
id: string;
libraryId: string;
title: string;
content: string;
contentType: number; // 0=未知, 1=图片, 2=链接, 3=视频, 4=文本, 5=小程序, 6=图文
contentTypeName?: string;
resUrls?: string[];
urls?: string[];
comment?: string;
sendTime?: string;
createTime: string;
updateTime: string;
wechatId?: string;
wechatNickname?: string;
wechatAvatar?: string;
snsId?: string;
msgId?: string;
type?: string;
contentData?: string;
createMomentTime?: string;
createMessageTime?: string;
createMomentTimeFormatted?: string;
createMessageTimeFormatted?: string;
}
// 内容库类型
export interface ContentLibrary {
id: string;
name: string;
sourceType: number; // 1=微信好友, 2=聊天群
creatorName?: string;
updateTime: string;
status: number; // 0=未启用, 1=已启用
itemCount?: number;
createTime: string;
sourceFriends?: string[];
sourceGroups?: string[];
keywordInclude?: string[];
keywordExclude?: string[];
aiPrompt?: string;
timeEnabled?: number;
timeStart?: string;
timeEnd?: string;
selectedFriends?: any[];
selectedGroups?: any[];
selectedGroupMembers?: WechatGroupMember[];
}
// 微信群成员
export interface WechatGroupMember {
id: string;
nickname: string;
wechatId: string;
avatar: string;
gender?: "male" | "female";
role?: "owner" | "admin" | "member";
joinTime?: string;
}
// API 响应类型
export interface ApiResponse<T = any> {
code: number;
msg: string;
data: T;
}
export interface ItemListResponse {
list: ContentItem[];
total: number;
}
// 获取素材列表参数
export interface GetContentItemListParams {
libraryId: string;
page?: number;
limit?: number;
keyword?: string;
}
// 创建素材参数
export interface CreateContentItemParams {
libraryId: string;
title: string;
content: string;
contentType: number;
resUrls?: string[];
urls?: string[];
comment?: string;
sendTime?: string;
}
// 更新素材参数
export interface UpdateContentItemParams
extends Partial<CreateContentItemParams> {
id: string;
}

View File

@@ -0,0 +1,263 @@
.materials-page {
padding: 16px;
background: #f5f5f5;
min-height: 100vh;
}
.search-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
background: white;
padding: 12px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.search-input-wrapper {
position: relative;
flex: 1;
}
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #999;
z-index: 1;
}
.search-input {
padding-left: 36px;
border-radius: 20px;
border: 1px solid #e0e0e0;
&:focus {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
}
}
.refresh-btn {
border-radius: 20px;
border: 1px solid #e0e0e0;
background: white;
&:hover {
border-color: #1677ff;
color: #1677ff;
}
}
.create-btn {
border-radius: 20px;
padding: 0 16px;
}
.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.materials-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 40px 0;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.6;
}
.empty-text {
color: #999;
margin-bottom: 20px;
font-size: 14px;
}
.empty-btn {
border-radius: 20px;
padding: 0 20px;
}
.material-card {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: none;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.material-info {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.material-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 16px;
font-weight: 600;
color: #333;
}
.content-icon {
font-size: 16px;
color: #1677ff;
}
.type-tag {
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
}
.menu-btn {
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: #999;
border-radius: 4px;
&:hover {
background: #f5f5f5;
color: #666;
}
}
.menu-dropdown {
position: absolute;
right: 0;
top: 100%;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
min-width: 120px;
padding: 4px;
margin-top: 4px;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
font-size: 14px;
color: #333;
transition: background 0.2s;
&:hover {
background: #f5f5f5;
}
&.danger {
color: #ff4d4f;
&:hover {
background: #fff2f0;
}
}
}
.card-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.content-preview {
color: #666;
font-size: 14px;
line-height: 1.5;
max-height: 60px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.material-meta {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #999;
}
.meta-item {
display: flex;
align-items: center;
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 20px;
padding: 16px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.pagination {
:global {
.adm-pagination-item {
border-radius: 6px;
margin: 0 2px;
&.adm-pagination-item-active {
background: #1677ff;
color: white;
}
}
}
}

View File

@@ -0,0 +1,388 @@
import React, { useState, useEffect, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Button,
Toast,
SpinLoading,
Dialog,
Card,
Avatar,
Tag,
Pagination,
} from "antd-mobile";
import { Input } from "antd";
import {
PlusOutlined,
SearchOutlined,
ReloadOutlined,
EditOutlined,
DeleteOutlined,
EyeOutlined,
MoreOutlined,
PictureOutlined,
LinkOutlined,
VideoCameraOutlined,
FileTextOutlined,
AppstoreOutlined,
} from "@ant-design/icons";
import Layout from "@/components/Layout/Layout";
import NavCommon from "@/components/NavCommon";
import {
getContentItemList,
deleteContentItem,
getContentLibraryDetail,
} from "./api";
import { ContentItem, ContentLibrary } from "./data";
import style from "./index.module.scss";
// 卡片菜单组件
interface CardMenuProps {
onView: () => void;
onEdit: () => void;
onDelete: () => void;
}
const CardMenu: React.FC<CardMenuProps> = ({ onView, onEdit, onDelete }) => {
const [open, setOpen] = useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
if (open) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
return (
<div style={{ position: "relative" }}>
<button onClick={() => setOpen((v) => !v)} className={style["menu-btn"]}>
<MoreOutlined />
</button>
{open && (
<div ref={menuRef} className={style["menu-dropdown"]}>
<div
onClick={() => {
onView();
setOpen(false);
}}
className={style["menu-item"]}
>
<EyeOutlined />
</div>
<div
onClick={() => {
onEdit();
setOpen(false);
}}
className={style["menu-item"]}
>
<EditOutlined />
</div>
<div
onClick={() => {
onDelete();
setOpen(false);
}}
className={`${style["menu-item"]} ${style["danger"]}`}
>
<DeleteOutlined />
</div>
</div>
)}
</div>
);
};
// 内容类型图标映射
const getContentTypeIcon = (type: number) => {
switch (type) {
case 1:
return <PictureOutlined className={style["content-icon"]} />;
case 2:
return <LinkOutlined className={style["content-icon"]} />;
case 3:
return <VideoCameraOutlined className={style["content-icon"]} />;
case 4:
return <FileTextOutlined className={style["content-icon"]} />;
case 5:
return <AppstoreOutlined className={style["content-icon"]} />;
default:
return <FileTextOutlined className={style["content-icon"]} />;
}
};
// 内容类型文字映射
const getContentTypeText = (type: number) => {
switch (type) {
case 1:
return "图片";
case 2:
return "链接";
case 3:
return "视频";
case 4:
return "文本";
case 5:
return "小程序";
case 6:
return "图文";
default:
return "未知";
}
};
const MaterialsList: React.FC = () => {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [materials, setMaterials] = useState<ContentItem[]>([]);
const [library, setLibrary] = useState<ContentLibrary | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0);
const pageSize = 20;
// 获取内容库详情
const fetchLibraryDetail = useCallback(async () => {
if (!id) return;
try {
const response = await getContentLibraryDetail(id);
if (response.code === 200 && response.data) {
setLibrary(response.data);
}
} catch (error) {
console.error("获取内容库详情失败:", error);
}
}, [id]);
// 获取素材列表
const fetchMaterials = useCallback(async () => {
if (!id) return;
setLoading(true);
try {
const response = await getContentItemList({
libraryId: id,
page: currentPage,
limit: pageSize,
keyword: searchQuery,
});
if (response.code === 200 && response.data) {
setMaterials(response.data.list || []);
setTotal(response.data.total || 0);
} else {
Toast.show({
content: response.msg || "获取素材列表失败",
position: "top",
});
}
} catch (error: any) {
console.error("获取素材列表失败:", error);
Toast.show({
content: error?.message || "请检查网络连接",
position: "top",
});
} finally {
setLoading(false);
}
}, [id, currentPage, searchQuery]);
useEffect(() => {
fetchLibraryDetail();
}, [fetchLibraryDetail]);
useEffect(() => {
fetchMaterials();
}, [fetchMaterials]);
const handleCreateNew = () => {
navigate(`/content/materials/new/${id}`);
};
const handleEdit = (materialId: string) => {
navigate(`/content/materials/edit/${id}/${materialId}`);
};
const handleDelete = async (materialId: string) => {
const result = await Dialog.confirm({
content: "确定要删除这个素材吗?",
confirmText: "删除",
cancelText: "取消",
});
if (result) {
try {
const response = await deleteContentItem(materialId);
if (response.code === 200) {
Toast.show({
content: "删除成功",
position: "top",
});
fetchMaterials();
} else {
Toast.show({
content: response.msg || "删除失败",
position: "top",
});
}
} catch (error: any) {
console.error("删除素材失败:", error);
Toast.show({
content: error?.message || "请检查网络连接",
position: "top",
});
}
}
};
const handleView = (materialId: string) => {
// 可以跳转到素材详情页面或显示弹窗
console.log("查看素材:", materialId);
};
const handleSearch = () => {
setCurrentPage(1);
fetchMaterials();
};
const handleRefresh = () => {
fetchMaterials();
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const filteredMaterials = materials.filter(
(material) =>
material.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
material.content?.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<Layout
header={<NavCommon title={`${library?.name || "内容库"} - 素材管理`} />}
>
<div className={style["materials-page"]}>
{/* 搜索和操作栏 */}
<div className={style["search-bar"]}>
<div className={style["search-input-wrapper"]}>
<SearchOutlined className={style["search-icon"]} />
<Input
placeholder="搜索素材..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onPressEnter={handleSearch}
className={style["search-input"]}
/>
</div>
<Button
size="small"
onClick={handleRefresh}
disabled={loading}
className={style["refresh-btn"]}
>
<ReloadOutlined className={loading ? style["spinning"] : ""} />
</Button>
<Button
color="primary"
size="small"
onClick={handleCreateNew}
className={style["create-btn"]}
>
<PlusOutlined />
</Button>
</div>
{/* 素材列表 */}
<div className={style["materials-list"]}>
{loading ? (
<div className={style["loading"]}>
<SpinLoading color="primary" style={{ fontSize: 32 }} />
</div>
) : filteredMaterials.length === 0 ? (
<div className={style["empty-state"]}>
<div className={style["empty-icon"]}>📄</div>
<div className={style["empty-text"]}>
</div>
<Button
color="primary"
size="small"
onClick={handleCreateNew}
className={style["empty-btn"]}
>
</Button>
</div>
) : (
<>
{filteredMaterials.map((material) => (
<Card key={material.id} className={style["material-card"]}>
<div className={style["card-header"]}>
<div className={style["material-info"]}>
<div className={style["material-title"]}>
{getContentTypeIcon(material.contentType)}
<span>{material.title || "无标题"}</span>
</div>
<Tag color="blue" className={style["type-tag"]}>
{getContentTypeText(material.contentType)}
</Tag>
</div>
<CardMenu
onView={() => handleView(material.id)}
onEdit={() => handleEdit(material.id)}
onDelete={() => handleDelete(material.id)}
/>
</div>
<div className={style["card-content"]}>
<div className={style["content-preview"]}>
{material.content?.substring(0, 100)}
{material.content &&
material.content.length > 100 &&
"..."}
</div>
<div className={style["material-meta"]}>
<span className={style["meta-item"]}>
{new Date(material.createTime).toLocaleString("zh-CN")}
</span>
{material.sendTime && (
<span className={style["meta-item"]}>
{new Date(material.sendTime).toLocaleString("zh-CN")}
</span>
)}
</div>
</div>
</Card>
))}
{/* 分页 */}
{total > pageSize && (
<div className={style["pagination-wrapper"]}>
<Pagination
total={total}
pageSize={pageSize}
current={currentPage}
onChange={handlePageChange}
className={style["pagination"]}
/>
</div>
)}
</>
)}
</div>
</div>
</Layout>
);
};
export default MaterialsList;

View File

@@ -1,37 +1,37 @@
import Content from "@/pages/content/Content";
import NewContent from "@/pages/content/NewContent";
import Materials from "@/pages/content/materials/List";
import MaterialsNew from "@/pages/content/materials/New";
import ContentLibraryList from "@/pages/content/list/index";
import ContentLibraryForm from "@/pages/content/form/index";
import MaterialsList from "@/pages/content/materials/list/index";
import MaterialForm from "@/pages/content/materials/form/index";
const contentRoutes = [
{
path: "/content",
element: <Content />,
element: <ContentLibraryList />,
auth: true,
},
{
path: "/content/new",
element: <NewContent />,
element: <ContentLibraryForm />,
auth: true,
},
{
path: "/content/edit/:id",
element: <NewContent />,
element: <ContentLibraryForm />,
auth: true,
},
{
path: "/content/materials/:id",
element: <Materials />,
element: <MaterialsList />,
auth: true,
},
{
path: "/content/materials/new/:id",
element: <MaterialsNew />,
element: <MaterialForm />,
auth: true,
},
{
path: "/content/materials/edit/:id/:materialId",
element: <MaterialsNew />,
element: <MaterialForm />,
auth: true,
},
];