feat: 本次提交更新内容如下
存一版
This commit is contained in:
@@ -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;
|
||||
@@ -1,8 +0,0 @@
|
||||
import React from "react";
|
||||
import PlaceholderPage from "@/components/PlaceholderPage";
|
||||
|
||||
const NewContent: React.FC = () => {
|
||||
return <PlaceholderPage title="新建内容" />;
|
||||
};
|
||||
|
||||
export default NewContent;
|
||||
29
nkebao/src/pages/content/form/api.ts
Normal file
29
nkebao/src/pages/content/form/api.ts
Normal 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);
|
||||
}
|
||||
61
nkebao/src/pages/content/form/data.ts
Normal file
61
nkebao/src/pages/content/form/data.ts
Normal 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;
|
||||
}
|
||||
116
nkebao/src/pages/content/form/index.module.scss
Normal file
116
nkebao/src/pages/content/form/index.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
331
nkebao/src/pages/content/form/index.tsx
Normal file
331
nkebao/src/pages/content/form/index.tsx
Normal 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;
|
||||
65
nkebao/src/pages/content/list/api.ts
Normal file
65
nkebao/src/pages/content/list/api.ts
Normal 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 });
|
||||
}
|
||||
66
nkebao/src/pages/content/list/data.ts
Normal file
66
nkebao/src/pages/content/list/data.ts
Normal 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;
|
||||
}
|
||||
261
nkebao/src/pages/content/list/index.module.scss
Normal file
261
nkebao/src/pages/content/list/index.module.scss
Normal 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;
|
||||
}
|
||||
344
nkebao/src/pages/content/list/index.tsx
Normal file
344
nkebao/src/pages/content/list/index.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -1,8 +0,0 @@
|
||||
import React from "react";
|
||||
import PlaceholderPage from "@/components/PlaceholderPage";
|
||||
|
||||
const MaterialsNew: React.FC = () => {
|
||||
return <PlaceholderPage title="新建素材" />;
|
||||
};
|
||||
|
||||
export default MaterialsNew;
|
||||
37
nkebao/src/pages/content/materials/form/api.ts
Normal file
37
nkebao/src/pages/content/materials/form/api.ts
Normal 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}`);
|
||||
}
|
||||
85
nkebao/src/pages/content/materials/form/data.ts
Normal file
85
nkebao/src/pages/content/materials/form/data.ts
Normal 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;
|
||||
}
|
||||
125
nkebao/src/pages/content/materials/form/index.module.scss
Normal file
125
nkebao/src/pages/content/materials/form/index.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
327
nkebao/src/pages/content/materials/form/index.tsx
Normal file
327
nkebao/src/pages/content/materials/form/index.tsx
Normal 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;
|
||||
62
nkebao/src/pages/content/materials/list/api.ts
Normal file
62
nkebao/src/pages/content/materials/list/api.ts
Normal 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}`);
|
||||
}
|
||||
98
nkebao/src/pages/content/materials/list/data.ts
Normal file
98
nkebao/src/pages/content/materials/list/data.ts
Normal 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;
|
||||
}
|
||||
263
nkebao/src/pages/content/materials/list/index.module.scss
Normal file
263
nkebao/src/pages/content/materials/list/index.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
388
nkebao/src/pages/content/materials/list/index.tsx
Normal file
388
nkebao/src/pages/content/materials/list/index.tsx
Normal 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;
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user