FEAT => 本次更新项目为:删除消费记录相关的 API、数据定义、样式和组件,以简化代码结构并移除不再使用的功能。
This commit is contained in:
@@ -1,17 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
import { ConsumptionRecordsResponse, ConsumptionRecordDetail } from "./data";
|
||||
|
||||
// 获取消费记录列表
|
||||
export function getConsumptionRecords(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
}): Promise<ConsumptionRecordsResponse> {
|
||||
return request("/v1/consumption-records", params, "GET");
|
||||
}
|
||||
|
||||
// 获取消费记录详情
|
||||
export function getConsumptionRecordDetail(
|
||||
id: string,
|
||||
): Promise<ConsumptionRecordDetail> {
|
||||
return request(`/v1/consumption-records/${id}`, {}, "GET");
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
// 消费记录类型定义
|
||||
export interface ConsumptionRecord {
|
||||
id: string;
|
||||
type: "recharge" | "ai_service" | "version_upgrade";
|
||||
amount: number;
|
||||
description: string;
|
||||
createTime: string;
|
||||
status: "success" | "pending" | "failed";
|
||||
balance?: number;
|
||||
}
|
||||
|
||||
// API响应类型
|
||||
export interface ConsumptionRecordsResponse {
|
||||
list: ConsumptionRecord[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
// 消费记录详情
|
||||
export interface ConsumptionRecordDetail extends ConsumptionRecord {
|
||||
orderNo?: string;
|
||||
paymentMethod?: string;
|
||||
remark?: string;
|
||||
operator?: string;
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
.records-page {
|
||||
padding: 16px;
|
||||
background: #f7f8fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.records-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.record-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.record-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.type-icon-wrapper {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
font-size: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.record-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.record-description {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #222;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.record-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.time-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.record-amount {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.amount-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.balance-info {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
color: var(--primary-color);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
color: #ccc;
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Card, List, Tag, SpinLoading, Empty } from "antd-mobile";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
import style from "./index.module.scss";
|
||||
import {
|
||||
WalletOutlined,
|
||||
RobotOutlined,
|
||||
CrownOutlined,
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import { getConsumptionRecords } from "./api";
|
||||
import { ConsumptionRecord } from "./data";
|
||||
|
||||
const ConsumptionRecords: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useUserStore();
|
||||
const [records, setRecords] = useState<ConsumptionRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
loadRecords();
|
||||
}, []);
|
||||
|
||||
const loadRecords = async (reset = false) => {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const currentPage = reset ? 1 : page;
|
||||
const response = await getConsumptionRecords({
|
||||
page: currentPage,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const newRecords = response.list || [];
|
||||
setRecords(prev => (reset ? newRecords : [...prev, ...newRecords]));
|
||||
setHasMore(newRecords.length === 20);
|
||||
if (reset) setPage(1);
|
||||
else setPage(currentPage + 1);
|
||||
} catch (error) {
|
||||
console.error("加载消费记录失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "recharge":
|
||||
return <WalletOutlined className={style["type-icon"]} />;
|
||||
case "ai_service":
|
||||
return <RobotOutlined className={style["type-icon"]} />;
|
||||
case "version_upgrade":
|
||||
return <CrownOutlined className={style["type-icon"]} />;
|
||||
default:
|
||||
return <WalletOutlined className={style["type-icon"]} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case "recharge":
|
||||
return "#52c41a";
|
||||
case "ai_service":
|
||||
return "#1890ff";
|
||||
case "version_upgrade":
|
||||
return "#722ed1";
|
||||
default:
|
||||
return "#666";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return "成功";
|
||||
case "pending":
|
||||
return "处理中";
|
||||
case "failed":
|
||||
return "失败";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return "#52c41a";
|
||||
case "pending":
|
||||
return "#faad14";
|
||||
case "failed":
|
||||
return "#ff4d4f";
|
||||
default:
|
||||
return "#666";
|
||||
}
|
||||
};
|
||||
|
||||
const formatAmount = (amount: number, type: string) => {
|
||||
if (type === "recharge") {
|
||||
return `+¥${amount.toFixed(2)}`;
|
||||
} else {
|
||||
return `-¥${amount.toFixed(2)}`;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timeStr: string) => {
|
||||
const date = new Date(timeStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) {
|
||||
return date.toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} else if (days === 1) {
|
||||
return (
|
||||
"昨天 " +
|
||||
date.toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
);
|
||||
} else if (days < 7) {
|
||||
return `${days}天前`;
|
||||
} else {
|
||||
return date.toLocaleDateString("zh-CN");
|
||||
}
|
||||
};
|
||||
|
||||
const renderRecordItem = (record: ConsumptionRecord) => (
|
||||
<Card key={record.id} className={style["record-card"]}>
|
||||
<div className={style["record-header"]}>
|
||||
<div className={style["record-info"]}>
|
||||
<div
|
||||
className={style["type-icon-wrapper"]}
|
||||
style={{ backgroundColor: `${getTypeColor(record.type)}20` }}
|
||||
>
|
||||
{getTypeIcon(record.type)}
|
||||
</div>
|
||||
<div className={style["record-details"]}>
|
||||
<div className={style["record-description"]}>
|
||||
{record.description}
|
||||
</div>
|
||||
<div className={style["record-time"]}>
|
||||
<ClockCircleOutlined className={style["time-icon"]} />
|
||||
{formatTime(record.createTime)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["record-amount"]}>
|
||||
<div
|
||||
className={style["amount-text"]}
|
||||
style={{
|
||||
color: record.type === "recharge" ? "#52c41a" : "#ff4d4f",
|
||||
}}
|
||||
>
|
||||
{formatAmount(record.amount, record.type)}
|
||||
</div>
|
||||
<Tag
|
||||
color={getStatusColor(record.status)}
|
||||
className={style["status-tag"]}
|
||||
>
|
||||
{getStatusText(record.status)}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
{record.balance !== undefined && (
|
||||
<div className={style["balance-info"]}>
|
||||
余额: ¥{record.balance.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout header={<NavCommon title="消费记录" />}>
|
||||
<div className={style["records-page"]}>
|
||||
{records.length === 0 && !loading ? (
|
||||
<Empty
|
||||
className={style["empty-state"]}
|
||||
description="暂无消费记录"
|
||||
image={<WalletOutlined className={style["empty-icon"]} />}
|
||||
/>
|
||||
) : (
|
||||
<div className={style["records-list"]}>
|
||||
{records.map(renderRecordItem)}
|
||||
{loading && (
|
||||
<div className={style["loading-container"]}>
|
||||
<SpinLoading color="primary" />
|
||||
<div className={style["loading-text"]}>加载中...</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && hasMore && (
|
||||
<div className={style["load-more"]} onClick={() => loadRecords()}>
|
||||
加载更多
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConsumptionRecords;
|
||||
26
nkebao/src/pages/mobile/mine/content/form/api.ts
Normal file
26
nkebao/src/pages/mobile/mine/content/form/api.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import request from "@/api/request";
|
||||
import {
|
||||
ContentLibrary,
|
||||
CreateContentLibraryParams,
|
||||
UpdateContentLibraryParams,
|
||||
} from "./data";
|
||||
|
||||
// 获取内容库详情
|
||||
export function getContentLibraryDetail(id: string): Promise<any> {
|
||||
return request("/v1/content/library/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
// 创建内容库
|
||||
export function createContentLibrary(
|
||||
params: CreateContentLibraryParams,
|
||||
): Promise<any> {
|
||||
return request("/v1/content/library/create", params, "POST");
|
||||
}
|
||||
|
||||
// 更新内容库
|
||||
export function updateContentLibrary(
|
||||
params: UpdateContentLibraryParams,
|
||||
): Promise<any> {
|
||||
const { id, ...data } = params;
|
||||
return request(`/v1/content/library/update`, { id, ...data }, "POST");
|
||||
}
|
||||
61
nkebao/src/pages/mobile/mine/content/form/data.ts
Normal file
61
nkebao/src/pages/mobile/mine/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;
|
||||
}
|
||||
140
nkebao/src/pages/mobile/mine/content/form/index.module.scss
Normal file
140
nkebao/src/pages/mobile/mine/content/form/index.module.scss
Normal file
@@ -0,0 +1,140 @@
|
||||
.form-page {
|
||||
background: #f7f8fa;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.form-main {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 0 0 0;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||
padding: 24px 18px 18px 18px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #222;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
margin-top: 28px;
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.section-block {
|
||||
padding: 12px 0 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tabs-bar {
|
||||
.adm-tabs-header {
|
||||
background: #f7f8fa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.adm-tabs-tab {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.collapse {
|
||||
margin-top: 12px;
|
||||
.adm-collapse-panel-content {
|
||||
padding-bottom: 8px;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
padding: 18px 14px 10px 14px;
|
||||
margin-top: 2px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
.form-section {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.form-label {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
color: #333;
|
||||
}
|
||||
.adm-input {
|
||||
min-height: 42px;
|
||||
font-size: 15px;
|
||||
border-radius: 7px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-row,
|
||||
.section-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ai-desc {
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.date-row,
|
||||
.section-block {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.adm-input {
|
||||
min-height: 44px;
|
||||
font-size: 15px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
margin-top: 32px;
|
||||
height: 48px !important;
|
||||
border-radius: 10px !important;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-main {
|
||||
max-width: 100vw;
|
||||
padding: 0;
|
||||
}
|
||||
.form-card {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 16px 6px 12px 6px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
margin-top: 22px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.submit-btn {
|
||||
height: 44px !important;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
330
nkebao/src/pages/mobile/mine/content/form/index.tsx
Normal file
330
nkebao/src/pages/mobile/mine/content/form/index.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Input as AntdInput, Switch } from "antd";
|
||||
import { Button, Collapse, Toast, DatePicker, Tabs } from "antd-mobile";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import FriendSelection from "@/components/FriendSelection";
|
||||
import GroupSelection from "@/components/GroupSelection";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import style from "./index.module.scss";
|
||||
import request from "@/api/request";
|
||||
import { getContentLibraryDetail, updateContentLibrary } from "./api";
|
||||
import { GroupSelectionItem } from "@/components/GroupSelection/data";
|
||||
import { FriendSelectionItem } from "@/components/FriendSelection/data";
|
||||
|
||||
const { TextArea } = AntdInput;
|
||||
|
||||
function formatDate(date: Date | null) {
|
||||
if (!date) return "";
|
||||
// 格式化为 YYYY-MM-DD
|
||||
const y = date.getFullYear();
|
||||
const m = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const d = date.getDate().toString().padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
export default function ContentForm() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const isEdit = !!id;
|
||||
const [sourceType, setSourceType] = useState<"friends" | "groups">("friends");
|
||||
const [name, setName] = useState("");
|
||||
const [friendsGroups, setSelectedFriends] = useState<string[]>([]);
|
||||
const [friendsGroupsOptions, setSelectedFriendsOptions] = useState<
|
||||
FriendSelectionItem[]
|
||||
>([]);
|
||||
const [selectedGroups, setSelectedGroups] = useState<string[]>([]);
|
||||
const [selectedGroupsOptions, setSelectedGroupsOptions] = useState<
|
||||
GroupSelectionItem[]
|
||||
>([]);
|
||||
const [useAI, setUseAI] = useState(false);
|
||||
const [aiPrompt, setAIPrompt] = useState("");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [dateRange, setDateRange] = useState<[Date | null, Date | null]>([
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
const [showStartPicker, setShowStartPicker] = useState(false);
|
||||
const [showEndPicker, setShowEndPicker] = useState(false);
|
||||
const [keywordsInclude, setKeywordsInclude] = useState("");
|
||||
const [keywordsExclude, setKeywordsExclude] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 编辑模式下拉详情并回填
|
||||
useEffect(() => {
|
||||
if (isEdit && id) {
|
||||
setLoading(true);
|
||||
getContentLibraryDetail(id)
|
||||
.then(data => {
|
||||
setName(data.name || "");
|
||||
setSourceType(data.sourceType === 1 ? "friends" : "groups");
|
||||
setSelectedFriends(data.sourceFriends || []);
|
||||
setSelectedGroups(data.selectedGroups || []);
|
||||
setSelectedGroupsOptions(data.selectedGroupsOptions || []);
|
||||
|
||||
setSelectedFriendsOptions(data.sourceFriendsOptions || []);
|
||||
|
||||
setKeywordsInclude((data.keywordInclude || []).join(","));
|
||||
setKeywordsExclude((data.keywordExclude || []).join(","));
|
||||
setAIPrompt(data.aiPrompt || "");
|
||||
setUseAI(!!data.aiPrompt);
|
||||
setEnabled(data.status === 1);
|
||||
// 时间范围
|
||||
const start = data.timeStart || data.startTime;
|
||||
const end = data.timeEnd || data.endTime;
|
||||
setDateRange([
|
||||
start ? new Date(start) : null,
|
||||
end ? new Date(end) : null,
|
||||
]);
|
||||
})
|
||||
.catch(e => {
|
||||
Toast.show({
|
||||
content: e?.message || "获取详情失败",
|
||||
position: "top",
|
||||
});
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [isEdit, id]);
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
Toast.show({ content: "请输入内容库名称", position: "top" });
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload = {
|
||||
name,
|
||||
sourceType: sourceType === "friends" ? 1 : 2,
|
||||
friends: friendsGroups,
|
||||
groups: selectedGroups,
|
||||
groupMembers: {},
|
||||
keywordInclude: keywordsInclude
|
||||
.split(/,|,|\n|\s+/)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean),
|
||||
keywordExclude: keywordsExclude
|
||||
.split(/,|,|\n|\s+/)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean),
|
||||
aiPrompt,
|
||||
timeEnabled: dateRange[0] || dateRange[1] ? 1 : 0,
|
||||
startTime: dateRange[0] ? formatDate(dateRange[0]) : "",
|
||||
endTime: dateRange[1] ? formatDate(dateRange[1]) : "",
|
||||
status: enabled ? 1 : 0,
|
||||
};
|
||||
if (isEdit && id) {
|
||||
await updateContentLibrary({ id, ...payload });
|
||||
Toast.show({ content: "保存成功", position: "top" });
|
||||
} else {
|
||||
await request("/v1/content/library/create", payload, "POST");
|
||||
Toast.show({ content: "创建成功", position: "top" });
|
||||
}
|
||||
navigate("/content");
|
||||
} catch (e: any) {
|
||||
Toast.show({
|
||||
content: e?.message || (isEdit ? "保存失败" : "创建失败"),
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGroupsChange = (groups: GroupSelectionItem[]) => {
|
||||
setSelectedGroups(groups.map(g => g.id.toString()));
|
||||
setSelectedGroupsOptions(groups);
|
||||
};
|
||||
|
||||
const handleFriendsChange = (friends: FriendSelectionItem[]) => {
|
||||
setSelectedFriends(friends.map(f => f.id.toString()));
|
||||
setSelectedFriendsOptions(friends);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={<NavCommon title={isEdit ? "编辑内容库" : "新建内容库"} />}
|
||||
footer={
|
||||
<div style={{ padding: "16px", backgroundColor: "#fff" }}>
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
loading={submitting || loading}
|
||||
disabled={submitting || loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isEdit
|
||||
? submitting
|
||||
? "保存中..."
|
||||
: "保存内容库"
|
||||
: submitting
|
||||
? "创建中..."
|
||||
: "创建内容库"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={style["form-page"]}>
|
||||
<form
|
||||
className={style["form-main"]}
|
||||
onSubmit={e => e.preventDefault()}
|
||||
autoComplete="off"
|
||||
>
|
||||
<div className={style["form-section"]}>
|
||||
<label className={style["form-label"]}>
|
||||
<span style={{ color: "#ff4d4f", marginRight: 4 }}>*</span>
|
||||
内容库名称
|
||||
</label>
|
||||
<AntdInput
|
||||
placeholder="请输入内容库名称"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className={style["input"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={style["section-title"]}>数据来源配置</div>
|
||||
<div className={style["form-section"]}>
|
||||
<Tabs
|
||||
activeKey={sourceType}
|
||||
onChange={key => setSourceType(key as "friends" | "groups")}
|
||||
className={style["tabs-bar"]}
|
||||
>
|
||||
<Tabs.Tab title="选择微信好友" key="friends">
|
||||
<FriendSelection
|
||||
selectedFriends={friendsGroupsOptions}
|
||||
onSelect={handleFriendsChange}
|
||||
placeholder="选择微信好友"
|
||||
/>
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab title="选择聊天群" key="groups">
|
||||
<GroupSelection
|
||||
selectedOptions={selectedGroupsOptions}
|
||||
onSelect={handleGroupsChange}
|
||||
placeholder="选择聊天群"
|
||||
/>
|
||||
</Tabs.Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<Collapse
|
||||
defaultActiveKey={["keywords"]}
|
||||
className={style["collapse"]}
|
||||
>
|
||||
<Collapse.Panel
|
||||
key="keywords"
|
||||
title={<span className={style["form-label"]}>关键词设置</span>}
|
||||
>
|
||||
<div className={style["form-section"]}>
|
||||
<label className={style["form-label"]}>包含关键词</label>
|
||||
<TextArea
|
||||
placeholder="多个关键词用逗号分隔"
|
||||
value={keywordsInclude}
|
||||
onChange={e => setKeywordsInclude(e.target.value)}
|
||||
className={style["input"]}
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
/>
|
||||
</div>
|
||||
<div className={style["form-section"]}>
|
||||
<label className={style["form-label"]}>排除关键词</label>
|
||||
<TextArea
|
||||
placeholder="多个关键词用逗号分隔"
|
||||
value={keywordsExclude}
|
||||
onChange={e => setKeywordsExclude(e.target.value)}
|
||||
className={style["input"]}
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
/>
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<div className={style["section-title"]}>是否启用AI</div>
|
||||
<div
|
||||
className={style["form-section"]}
|
||||
style={{ display: "flex", alignItems: "center", gap: 12 }}
|
||||
>
|
||||
<Switch checked={useAI} onChange={setUseAI} />
|
||||
<span className={style["ai-desc"]}>
|
||||
启用AI后,该内容库下的所有内容都会通过AI生成
|
||||
</span>
|
||||
</div>
|
||||
{useAI && (
|
||||
<div className={style["form-section"]}>
|
||||
<label className={style["form-label"]}>AI提示词</label>
|
||||
<AntdInput
|
||||
placeholder="请输入AI提示词"
|
||||
value={aiPrompt}
|
||||
onChange={e => setAIPrompt(e.target.value)}
|
||||
className={style["input"]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={style["section-title"]}>时间限制</div>
|
||||
<div
|
||||
className={style["form-section"]}
|
||||
style={{ display: "flex", gap: 12 }}
|
||||
>
|
||||
<label>开始时间</label>
|
||||
<div style={{ flex: 1 }}>
|
||||
<AntdInput
|
||||
readOnly
|
||||
value={dateRange[0] ? dateRange[0].toLocaleDateString() : ""}
|
||||
placeholder="年/月/日"
|
||||
className={style["input"]}
|
||||
onClick={() => setShowStartPicker(true)}
|
||||
/>
|
||||
<DatePicker
|
||||
visible={showStartPicker}
|
||||
title="开始时间"
|
||||
value={dateRange[0]}
|
||||
onClose={() => setShowStartPicker(false)}
|
||||
onConfirm={val => {
|
||||
setDateRange([val, dateRange[1]]);
|
||||
setShowStartPicker(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label>结束时间</label>
|
||||
<div style={{ flex: 1 }}>
|
||||
<AntdInput
|
||||
readOnly
|
||||
value={dateRange[1] ? dateRange[1].toLocaleDateString() : ""}
|
||||
placeholder="年/月/日"
|
||||
className={style["input"]}
|
||||
onClick={() => setShowEndPicker(true)}
|
||||
/>
|
||||
<DatePicker
|
||||
visible={showEndPicker}
|
||||
title="结束时间"
|
||||
value={dateRange[1]}
|
||||
onClose={() => setShowEndPicker(false)}
|
||||
onConfirm={val => {
|
||||
setDateRange([dateRange[0], val]);
|
||||
setShowEndPicker(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={style["section-title"]}
|
||||
style={{
|
||||
marginTop: 24,
|
||||
marginBottom: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>是否启用</span>
|
||||
<Switch checked={enabled} onChange={setEnabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
49
nkebao/src/pages/mobile/mine/content/list/api.ts
Normal file
49
nkebao/src/pages/mobile/mine/content/list/api.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import request from "@/api/request";
|
||||
import {
|
||||
ContentLibrary,
|
||||
CreateContentLibraryParams,
|
||||
UpdateContentLibraryParams,
|
||||
} from "./data";
|
||||
|
||||
// 获取内容库列表
|
||||
export function getContentLibraryList(params: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
keyword?: string;
|
||||
sourceType?: number;
|
||||
}): Promise<any> {
|
||||
return request("/v1/content/library/list", params, "GET");
|
||||
}
|
||||
|
||||
// 获取内容库详情
|
||||
export function getContentLibraryDetail(id: string): Promise<any> {
|
||||
return request("/v1/content/library/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
// 创建内容库
|
||||
export function createContentLibrary(
|
||||
params: CreateContentLibraryParams,
|
||||
): Promise<any> {
|
||||
return request("/v1/content/library/create", params, "POST");
|
||||
}
|
||||
|
||||
// 更新内容库
|
||||
export function updateContentLibrary(
|
||||
params: UpdateContentLibraryParams,
|
||||
): Promise<any> {
|
||||
const { id, ...data } = params;
|
||||
return request(`/v1/content/library/update`, { id, ...data }, "POST");
|
||||
}
|
||||
|
||||
// 删除内容库
|
||||
export function deleteContentLibrary(id: string): Promise<any> {
|
||||
return request("/v1/content/library/delete", { id }, "DELETE");
|
||||
}
|
||||
|
||||
// 切换内容库状态
|
||||
export function toggleContentLibraryStatus(
|
||||
id: string,
|
||||
status: number,
|
||||
): Promise<any> {
|
||||
return request("/v1/content/library/update-status", { id, status }, "POST");
|
||||
}
|
||||
66
nkebao/src/pages/mobile/mine/content/list/data.ts
Normal file
66
nkebao/src/pages/mobile/mine/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;
|
||||
}
|
||||
217
nkebao/src/pages/mobile/mine/content/list/index.module.scss
Normal file
217
nkebao/src/pages/mobile/mine/content/list/index.module.scss
Normal file
@@ -0,0 +1,217 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
flex: 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;
|
||||
}
|
||||
317
nkebao/src/pages/mobile/mine/content/list/index.tsx
Normal file
317
nkebao/src/pages/mobile/mine/content/list/index.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
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";
|
||||
import { Tabs } from "antd-mobile";
|
||||
|
||||
// 卡片菜单组件
|
||||
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,
|
||||
});
|
||||
|
||||
setLibraries(response.list || []);
|
||||
} catch (error: any) {
|
||||
console.error("获取内容库列表失败:", error);
|
||||
} 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="内容库"
|
||||
right={
|
||||
<Button size="small" color="primary" onClick={handleCreateNew}>
|
||||
<PlusOutlined /> 新建内容库
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrapper">
|
||||
<Input
|
||||
placeholder="搜索内容库"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleRefresh}
|
||||
loading={loading}
|
||||
className="refresh-btn"
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<div className={style["tabs"]}>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.Tab title="全部" key="all" />
|
||||
<Tabs.Tab title="微信好友" key="friends" />
|
||||
<Tabs.Tab title="聊天群" key="groups" />
|
||||
</Tabs>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={style["content-library-page"]}>
|
||||
{/* 内容库列表 */}
|
||||
<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;
|
||||
20
nkebao/src/pages/mobile/mine/content/materials/form/api.ts
Normal file
20
nkebao/src/pages/mobile/mine/content/materials/form/api.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取素材详情
|
||||
export function getContentItemDetail(id: string) {
|
||||
return request("/v1/content/library/get-item-detail", { id }, "GET");
|
||||
}
|
||||
|
||||
// 创建素材
|
||||
export function createContentItem(params: any) {
|
||||
return request("/v1/content/library/create-item", params, "POST");
|
||||
}
|
||||
|
||||
// 更新素材
|
||||
export function updateContentItem(params: any) {
|
||||
return request(`/v1/content/library/update-item`, params, "POST");
|
||||
}
|
||||
// 获取内容库详情
|
||||
export function getContentLibraryDetail(id: string) {
|
||||
return request("/v1/content/library/detail", { id }, "GET");
|
||||
}
|
||||
93
nkebao/src/pages/mobile/mine/content/materials/form/data.ts
Normal file
93
nkebao/src/pages/mobile/mine/content/materials/form/data.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// 素材数据类型定义
|
||||
export interface ContentItem {
|
||||
id: number; // 修改为number类型
|
||||
libraryId: number; // 修改为number类型
|
||||
type?: string;
|
||||
contentType: number; // 0=未知, 1=图片, 2=链接, 3=视频, 4=文本, 5=小程序, 6=图文
|
||||
title: string;
|
||||
content: string;
|
||||
contentAi?: string;
|
||||
contentData?: any;
|
||||
snsId?: string | null;
|
||||
msgId?: string | null;
|
||||
wechatId?: string | null;
|
||||
friendId?: string | null;
|
||||
createMomentTime?: number;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
coverImage?: string;
|
||||
resUrls?: string[];
|
||||
urls?: any[];
|
||||
location?: string | null;
|
||||
lat?: string;
|
||||
lng?: string;
|
||||
status?: number;
|
||||
isDel?: number;
|
||||
delTime?: number;
|
||||
wechatChatroomId?: string | null;
|
||||
senderNickname?: string;
|
||||
createMessageTime?: string | null;
|
||||
comment?: string;
|
||||
sendTime?: string; // 字符串格式的时间
|
||||
sendTimes?: number;
|
||||
contentTypeName?: 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;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.required {
|
||||
color: #ff4d4f;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d9d9d9;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
|
||||
&:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.form-select {
|
||||
width: 100%;
|
||||
border-radius: 6px;
|
||||
|
||||
&:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #d9d9d9;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
|
||||
&: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);
|
||||
}
|
||||
}
|
||||
}
|
||||
403
nkebao/src/pages/mobile/mine/content/materials/form/index.tsx
Normal file
403
nkebao/src/pages/mobile/mine/content/materials/form/index.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Button, Toast, SpinLoading, Card } from "antd-mobile";
|
||||
import { Input, Select } from "antd";
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
SaveOutlined,
|
||||
PictureOutlined,
|
||||
LinkOutlined,
|
||||
VideoCameraOutlined,
|
||||
FileTextOutlined,
|
||||
AppstoreOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import UploadComponent from "@/components/Upload/ImageUpload/ImageUpload";
|
||||
import VideoUpload from "@/components/Upload/VideoUpload";
|
||||
import {
|
||||
getContentItemDetail,
|
||||
createContentItem,
|
||||
updateContentItem,
|
||||
} from "./api";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
// 内容类型选项
|
||||
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 /> },
|
||||
];
|
||||
|
||||
const MaterialForm: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id: libraryId, materialId } = useParams<{
|
||||
id: string;
|
||||
materialId: string;
|
||||
}>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 表单状态
|
||||
const [contentType, setContentType] = useState<number>(4);
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
const [sendTime, setSendTime] = useState("");
|
||||
const [resUrls, setResUrls] = useState<string[]>([]);
|
||||
|
||||
// 链接相关状态
|
||||
const [linkDesc, setLinkDesc] = useState("");
|
||||
const [linkImage, setLinkImage] = useState("");
|
||||
const [linkUrl, setLinkUrl] = useState("");
|
||||
|
||||
// 小程序相关状态
|
||||
const [appTitle, setAppTitle] = useState("");
|
||||
const [appId, setAppId] = useState("");
|
||||
|
||||
const isEdit = !!materialId;
|
||||
|
||||
// 获取素材详情
|
||||
const fetchMaterialDetail = useCallback(async () => {
|
||||
if (!materialId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getContentItemDetail(materialId);
|
||||
// 填充表单数据
|
||||
setTitle(response.title || "");
|
||||
setContent(response.content || "");
|
||||
setContentType(response.contentType || 4);
|
||||
setComment(response.comment || "");
|
||||
|
||||
// 处理时间格式 - sendTime是字符串格式,需要转换为datetime-local格式
|
||||
if (response.sendTime) {
|
||||
// 将 "2025-07-28 16:11:00" 转换为 "2025-07-28T16:11"
|
||||
const dateTime = new Date(response.sendTime);
|
||||
setSendTime(dateTime.toISOString().slice(0, 16));
|
||||
} else {
|
||||
setSendTime("");
|
||||
}
|
||||
|
||||
setResUrls(response.resUrls || []);
|
||||
|
||||
// 设置链接相关数据
|
||||
if (response.urls && response.urls.length > 0) {
|
||||
const firstUrl = response.urls[0];
|
||||
if (typeof firstUrl === "object" && firstUrl !== null) {
|
||||
setLinkDesc(firstUrl.desc || "");
|
||||
setLinkImage(firstUrl.image || "");
|
||||
setLinkUrl(firstUrl.url || "");
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("获取素材详情失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [materialId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && materialId) {
|
||||
fetchMaterialDetail();
|
||||
}
|
||||
}, [isEdit, materialId, fetchMaterialDetail]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!libraryId) return;
|
||||
|
||||
if (!content.trim()) {
|
||||
Toast.show({
|
||||
content: "请输入素材内容",
|
||||
position: "top",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// 构建urls数据
|
||||
let finalUrls: { desc: string; image: string; url: string }[] = [];
|
||||
if (contentType === 2 && linkUrl) {
|
||||
finalUrls = [
|
||||
{
|
||||
desc: linkDesc,
|
||||
image: linkImage,
|
||||
url: linkUrl,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const params = {
|
||||
libraryId,
|
||||
title,
|
||||
content,
|
||||
contentType,
|
||||
comment,
|
||||
sendTime: sendTime || "",
|
||||
resUrls,
|
||||
urls: finalUrls,
|
||||
type: contentType,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
await updateContentItem({
|
||||
id: materialId!,
|
||||
...params,
|
||||
});
|
||||
} else {
|
||||
await createContentItem(params);
|
||||
}
|
||||
|
||||
// 直接使用返回数据,无需判断code
|
||||
Toast.show({
|
||||
content: isEdit ? "更新成功" : "创建成功",
|
||||
position: "top",
|
||||
});
|
||||
navigate(`/content/materials/${libraryId}`);
|
||||
} catch (error: unknown) {
|
||||
console.error("保存素材失败:", error);
|
||||
Toast.show({
|
||||
content: error instanceof Error ? 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 ? "编辑素材" : "新建素材"} />}
|
||||
footer={
|
||||
<div className={style["form-actions"]}>
|
||||
<Button
|
||||
fill="outline"
|
||||
onClick={handleBack}
|
||||
className={style["back-btn"]}
|
||||
>
|
||||
<ArrowLeftOutlined />
|
||||
返回
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleSubmit}
|
||||
loading={saving}
|
||||
className={style["submit-btn"]}
|
||||
>
|
||||
<SaveOutlined />
|
||||
{isEdit ? " 保存修改" : " 保存素材"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={style["form-page"]}>
|
||||
<div className={style["form"]}>
|
||||
{/* 基础信息 */}
|
||||
<Card className={style["form-card"]}>
|
||||
<div className={style["card-title"]}>基础信息</div>
|
||||
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>发布时间</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={sendTime}
|
||||
onChange={e => setSendTime(e.target.value)}
|
||||
placeholder="请选择发布时间"
|
||||
className={style["form-input"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>
|
||||
<span className={style["required"]}>*</span>类型
|
||||
</label>
|
||||
<Select
|
||||
value={contentType}
|
||||
onChange={value => setContentType(value)}
|
||||
placeholder="请选择类型"
|
||||
className={style["form-select"]}
|
||||
>
|
||||
{contentTypeOptions.map(option => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
<div className={style["select-option"]}>
|
||||
{option.icon}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 内容信息 */}
|
||||
<Card className={style["form-card"]}>
|
||||
<div className={style["card-title"]}>内容信息</div>
|
||||
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>
|
||||
<span className={style["required"]}>*</span>内容
|
||||
</label>
|
||||
<TextArea
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
placeholder="请输入内容"
|
||||
rows={6}
|
||||
className={style["form-textarea"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 链接类型特有字段 */}
|
||||
{contentType === 2 && (
|
||||
<>
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>
|
||||
<span className={style["required"]}>*</span>描述
|
||||
</label>
|
||||
<Input
|
||||
value={linkDesc}
|
||||
onChange={e => setLinkDesc(e.target.value)}
|
||||
placeholder="请输入描述"
|
||||
className={style["form-input"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>封面图</label>
|
||||
<UploadComponent
|
||||
value={linkImage ? [linkImage] : []}
|
||||
onChange={urls => setLinkImage(urls[0] || "")}
|
||||
count={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>
|
||||
<span className={style["required"]}>*</span>链接地址
|
||||
</label>
|
||||
<Input
|
||||
value={linkUrl}
|
||||
onChange={e => setLinkUrl(e.target.value)}
|
||||
placeholder="请输入链接地址"
|
||||
className={style["form-input"]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 视频类型特有字段 */}
|
||||
{contentType === 3 && (
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>视频上传</label>
|
||||
<VideoUpload
|
||||
value={resUrls[0] || ""}
|
||||
onChange={url => setResUrls([url])}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 素材上传(仅图片类型和小程序类型) */}
|
||||
{[1, 5].includes(contentType) && (
|
||||
<Card className={style["form-card"]}>
|
||||
<div className={style["card-title"]}>
|
||||
素材上传 (当前类型: {contentType})
|
||||
</div>
|
||||
|
||||
{contentType === 1 && (
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>图片上传</label>
|
||||
<div>
|
||||
<UploadComponent
|
||||
value={resUrls}
|
||||
onChange={setResUrls}
|
||||
count={9}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#666",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
当前内容类型: {contentType}, 图片数量: {resUrls.length}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contentType === 5 && (
|
||||
<>
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>小程序名称</label>
|
||||
<Input
|
||||
value={appTitle}
|
||||
onChange={e => setAppTitle(e.target.value)}
|
||||
placeholder="请输入小程序名称"
|
||||
className={style["form-input"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>AppID</label>
|
||||
<Input
|
||||
value={appId}
|
||||
onChange={e => setAppId(e.target.value)}
|
||||
placeholder="请输入AppID"
|
||||
className={style["form-input"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>小程序封面图</label>
|
||||
<UploadComponent
|
||||
value={resUrls}
|
||||
onChange={setResUrls}
|
||||
count={9}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 评论/备注 */}
|
||||
<Card className={style["form-card"]}>
|
||||
<div className={style["card-title"]}>评论/备注</div>
|
||||
|
||||
<div className={style["form-item"]}>
|
||||
<label className={style["form-label"]}>备注</label>
|
||||
<TextArea
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
placeholder="请输入评论或备注"
|
||||
rows={4}
|
||||
className={style["form-textarea"]}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialForm;
|
||||
37
nkebao/src/pages/mobile/mine/content/materials/list/api.ts
Normal file
37
nkebao/src/pages/mobile/mine/content/materials/list/api.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import request from "@/api/request";
|
||||
import {
|
||||
GetContentItemListParams,
|
||||
CreateContentItemParams,
|
||||
UpdateContentItemParams,
|
||||
} from "./data";
|
||||
|
||||
// 获取素材列表
|
||||
export function getContentItemList(params: GetContentItemListParams) {
|
||||
return request("/v1/content/library/item-list", params, "GET");
|
||||
}
|
||||
|
||||
// 获取素材详情
|
||||
export function getContentItemDetail(id: string) {
|
||||
return request("/v1/content/item/detail", { id }, "GET");
|
||||
}
|
||||
|
||||
// 创建素材
|
||||
export function createContentItem(params: CreateContentItemParams) {
|
||||
return request("/v1/content/item/create", params, "POST");
|
||||
}
|
||||
|
||||
// 更新素材
|
||||
export function updateContentItem(params: UpdateContentItemParams) {
|
||||
const { id, ...data } = params;
|
||||
return request(`/v1/content/item/update`, { id, ...data }, "POST");
|
||||
}
|
||||
|
||||
// 删除素材
|
||||
export function deleteContentItem(id: string) {
|
||||
return request("/v1/content/library/delete-item", { id }, "DELETE");
|
||||
}
|
||||
|
||||
// 获取内容库详情
|
||||
export function getContentLibraryDetail(id: string) {
|
||||
return request("/v1/content/library/detail", { id }, "GET");
|
||||
}
|
||||
106
nkebao/src/pages/mobile/mine/content/materials/list/data.ts
Normal file
106
nkebao/src/pages/mobile/mine/content/materials/list/data.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// 素材数据类型定义
|
||||
export interface ContentItem {
|
||||
id: number;
|
||||
libraryId: number;
|
||||
type: string;
|
||||
contentType: number; // 0=未知, 1=图片, 2=链接, 3=视频, 4=文本, 5=小程序, 6=图文
|
||||
title: string;
|
||||
content: string;
|
||||
contentAi?: string | null;
|
||||
contentData?: string | null;
|
||||
snsId?: string | null;
|
||||
msgId?: string | null;
|
||||
wechatId?: string | null;
|
||||
friendId?: string | null;
|
||||
createMomentTime: number;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
coverImage: string;
|
||||
resUrls: string[];
|
||||
urls: { desc: string; image: string; url: string }[];
|
||||
location?: string | null;
|
||||
lat: string;
|
||||
lng: string;
|
||||
status: number;
|
||||
isDel: number;
|
||||
delTime: number;
|
||||
wechatChatroomId?: string | null;
|
||||
senderNickname: string;
|
||||
createMessageTime?: string | null;
|
||||
comment: string;
|
||||
sendTime: number;
|
||||
sendTimes: number;
|
||||
contentTypeName: 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 | { desc?: string; image?: string; url: string })[];
|
||||
comment?: string;
|
||||
sendTime?: string;
|
||||
}
|
||||
|
||||
// 更新素材参数
|
||||
export interface UpdateContentItemParams
|
||||
extends Partial<CreateContentItemParams> {
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
.materials-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
.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: 16px;
|
||||
}
|
||||
|
||||
.avatar-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #e6f7ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-icon {
|
||||
font-size: 24px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.creator-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.material-id {
|
||||
background: #e6f7ff;
|
||||
color: #1677ff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
padding: 2px 8px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.material-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.link-preview {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #e9ecef;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #e9ecef;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.link-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.link-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link-url {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
margin-top: 16px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.action-btn-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #d9d9d9;
|
||||
background: white;
|
||||
color: #333;
|
||||
|
||||
&:hover {
|
||||
border-color: #1677ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
padding: 6px 12px;
|
||||
background: #ff4d4f;
|
||||
border-color: #ff4d4f;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: #ff7875;
|
||||
border-color: #ff7875;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
// 内容类型标签样式
|
||||
.content-type-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
|
||||
// 图片类型预览样式
|
||||
.material-image-preview {
|
||||
margin: 12px 0;
|
||||
|
||||
.image-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
// 1张图片:宽度拉伸,高度自适应
|
||||
&.single {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// 2张图片:左右并列
|
||||
&.double {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// 3张图片:三张并列
|
||||
&.triple {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// 4张图片:2x2网格布局
|
||||
&.quad {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// 5张及以上:网格布局
|
||||
&.grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.image-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-image {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 80px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// 链接类型预览样式
|
||||
.material-link-preview {
|
||||
margin: 12px 0;
|
||||
|
||||
.link-card {
|
||||
display: flex;
|
||||
background: #e9f8ff;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid #cde6ff;
|
||||
&:hover {
|
||||
background: #cde6ff;
|
||||
}
|
||||
|
||||
.link-image {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.link-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.link-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link-url {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 视频类型预览样式
|
||||
.material-video-preview {
|
||||
margin: 12px 0;
|
||||
|
||||
.video-thumbnail {
|
||||
video {
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.no-video {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 120px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// 文本类型预览样式
|
||||
.material-text-preview {
|
||||
margin: 12px 0;
|
||||
|
||||
.text-content {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// 小程序类型预览样式
|
||||
.material-miniprogram-preview {
|
||||
margin: 12px 0;
|
||||
|
||||
.miniprogram-card {
|
||||
display: flex;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
width: 100%;
|
||||
|
||||
img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.miniprogram-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.miniprogram-title {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 图文类型预览样式
|
||||
.material-article-preview {
|
||||
margin: 12px 0;
|
||||
|
||||
.article-image {
|
||||
margin-bottom: 12px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.article-content {
|
||||
.article-title {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.article-text {
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 默认预览样式
|
||||
.material-default-preview {
|
||||
margin: 12px 0;
|
||||
|
||||
.default-content {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
413
nkebao/src/pages/mobile/mine/content/materials/list/index.tsx
Normal file
413
nkebao/src/pages/mobile/mine/content/materials/list/index.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Toast, SpinLoading, Dialog, Card } from "antd-mobile";
|
||||
import { Input, Pagination, Button } from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
UserOutlined,
|
||||
BarChartOutlined,
|
||||
PictureOutlined,
|
||||
LinkOutlined,
|
||||
VideoCameraOutlined,
|
||||
FileTextOutlined,
|
||||
AppstoreOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import NavCommon from "@/components/NavCommon";
|
||||
import { getContentItemList, deleteContentItem } from "./api";
|
||||
import { ContentItem } from "./data";
|
||||
import style from "./index.module.scss";
|
||||
|
||||
// 内容类型配置
|
||||
const contentTypeConfig = {
|
||||
1: { label: "图片", icon: PictureOutlined, color: "#52c41a" },
|
||||
2: { label: "链接", icon: LinkOutlined, color: "#1890ff" },
|
||||
3: { label: "视频", icon: VideoCameraOutlined, color: "#722ed1" },
|
||||
4: { label: "文本", icon: FileTextOutlined, color: "#fa8c16" },
|
||||
5: { label: "小程序", icon: AppstoreOutlined, color: "#eb2f96" },
|
||||
6: { label: "图文", icon: PictureOutlined, color: "#13c2c2" },
|
||||
};
|
||||
|
||||
const MaterialsList: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [materials, setMaterials] = useState<ContentItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const pageSize = 20;
|
||||
|
||||
// 获取素材列表
|
||||
const fetchMaterials = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getContentItemList({
|
||||
libraryId: id,
|
||||
page: currentPage,
|
||||
limit: pageSize,
|
||||
keyword: searchQuery,
|
||||
});
|
||||
|
||||
setMaterials(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
} catch (error: unknown) {
|
||||
console.error("获取素材列表失败:", error);
|
||||
Toast.show({
|
||||
content: error instanceof Error ? error.message : "请检查网络连接",
|
||||
position: "top",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, currentPage, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMaterials();
|
||||
}, [fetchMaterials]);
|
||||
|
||||
const handleCreateNew = () => {
|
||||
navigate(`/content/materials/new/${id}`);
|
||||
};
|
||||
|
||||
const handleEdit = (materialId: number) => {
|
||||
navigate(`/content/materials/edit/${id}/${materialId}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (materialId: number) => {
|
||||
const result = await Dialog.confirm({
|
||||
content: "确定要删除这个素材吗?",
|
||||
confirmText: "删除",
|
||||
cancelText: "取消",
|
||||
});
|
||||
|
||||
if (result) {
|
||||
try {
|
||||
await deleteContentItem(materialId.toString());
|
||||
Toast.show({
|
||||
content: "删除成功",
|
||||
position: "top",
|
||||
});
|
||||
fetchMaterials();
|
||||
} catch (error: unknown) {
|
||||
console.error("删除素材失败:", error);
|
||||
Toast.show({
|
||||
content: error instanceof Error ? error.message : "请检查网络连接",
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleView = (materialId: number) => {
|
||||
// 可以跳转到素材详情页面或显示弹窗
|
||||
console.log("查看素材:", materialId);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
fetchMaterials();
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchMaterials();
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
// 渲染内容类型标签
|
||||
const renderContentTypeTag = (contentType: number) => {
|
||||
const config =
|
||||
contentTypeConfig[contentType as keyof typeof contentTypeConfig];
|
||||
if (!config) return null;
|
||||
|
||||
const IconComponent = config.icon;
|
||||
return (
|
||||
<div
|
||||
className={style["content-type-tag"]}
|
||||
style={{ backgroundColor: config.color + "20", color: config.color }}
|
||||
>
|
||||
<IconComponent style={{ fontSize: 12, marginRight: 4 }} />
|
||||
{config.label}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染素材内容预览
|
||||
const renderContentPreview = (material: ContentItem) => {
|
||||
const { contentType, content, resUrls, urls, coverImage } = material;
|
||||
|
||||
switch (contentType) {
|
||||
case 1: // 图片
|
||||
return (
|
||||
<div className={style["material-image-preview"]}>
|
||||
{resUrls && resUrls.length > 0 ? (
|
||||
<div
|
||||
className={`${style["image-grid"]} ${
|
||||
resUrls.length === 1
|
||||
? style.single
|
||||
: resUrls.length === 2
|
||||
? style.double
|
||||
: resUrls.length === 3
|
||||
? style.triple
|
||||
: resUrls.length === 4
|
||||
? style.quad
|
||||
: style.grid
|
||||
}`}
|
||||
>
|
||||
{resUrls.slice(0, 9).map((url, index) => (
|
||||
<img key={index} src={url} alt={`图片${index + 1}`} />
|
||||
))}
|
||||
{resUrls.length > 9 && (
|
||||
<div className={style["image-more"]}>
|
||||
+{resUrls.length - 9}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : coverImage ? (
|
||||
<div className={`${style["image-grid"]} ${style.single}`}>
|
||||
<img src={coverImage} alt="封面图" />
|
||||
</div>
|
||||
) : (
|
||||
<div className={style["no-image"]}>暂无图片</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 2: // 链接
|
||||
return (
|
||||
<div className={style["material-link-preview"]}>
|
||||
{urls && urls.length > 0 && (
|
||||
<div
|
||||
className={style["link-card"]}
|
||||
onClick={() => {
|
||||
window.open(urls[0].url, "_blank");
|
||||
}}
|
||||
>
|
||||
{urls[0].image && (
|
||||
<div className={style["link-image"]}>
|
||||
<img src={urls[0].image} alt="链接预览" />
|
||||
</div>
|
||||
)}
|
||||
<div className={style["link-content"]}>
|
||||
<div className={style["link-title"]}>
|
||||
{urls[0].desc || "链接"}
|
||||
</div>
|
||||
<div className={style["link-url"]}>{urls[0].url}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 3: // 视频
|
||||
return (
|
||||
<div className={style["material-video-preview"]}>
|
||||
{resUrls && resUrls.length > 0 ? (
|
||||
<div className={style["video-thumbnail"]}>
|
||||
<video src={resUrls[0]} controls />
|
||||
</div>
|
||||
) : (
|
||||
<div className={style["no-video"]}>暂无视频</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 4: // 文本
|
||||
return (
|
||||
<div className={style["material-text-preview"]}>
|
||||
<div className={style["text-content"]}>
|
||||
{content.length > 100
|
||||
? `${content.substring(0, 100)}...`
|
||||
: content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 5: // 小程序
|
||||
return (
|
||||
<div className={style["material-miniprogram-preview"]}>
|
||||
{resUrls && resUrls.length > 0 && (
|
||||
<div className={style["miniprogram-card"]}>
|
||||
<img src={resUrls[0]} alt="小程序封面" />
|
||||
<div className={style["miniprogram-info"]}>
|
||||
<div className={style["miniprogram-title"]}>
|
||||
{material.title || "小程序"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 6: // 图文
|
||||
return (
|
||||
<div className={style["material-article-preview"]}>
|
||||
{coverImage && (
|
||||
<div className={style["article-image"]}>
|
||||
<img src={coverImage} alt="文章封面" />
|
||||
</div>
|
||||
)}
|
||||
<div className={style["article-content"]}>
|
||||
<div className={style["article-title"]}>
|
||||
{material.title || "图文内容"}
|
||||
</div>
|
||||
<div className={style["article-text"]}>
|
||||
{content.length > 80
|
||||
? `${content.substring(0, 80)}...`
|
||||
: content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className={style["material-default-preview"]}>
|
||||
<div className={style["default-content"]}>{content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<NavCommon
|
||||
title="素材管理"
|
||||
right={
|
||||
<Button type="primary" onClick={handleCreateNew}>
|
||||
<PlusOutlined /> 新建素材
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{/* 搜索栏 */}
|
||||
<div className="search-bar">
|
||||
<div className="search-input-wrapper">
|
||||
<Input
|
||||
placeholder="搜索素材内容"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
allowClear
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
loading={loading}
|
||||
icon={<ReloadOutlined />}
|
||||
size="large"
|
||||
></Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<div className={style["pagination-wrapper"]}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onChange={handlePageChange}
|
||||
showSizeChanger={false}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
loading={loading}
|
||||
>
|
||||
<div className={style["materials-page"]}>
|
||||
{/* 素材列表 */}
|
||||
<div className={style["materials-list"]}>
|
||||
{loading ? (
|
||||
<div className={style["loading"]}>
|
||||
<SpinLoading color="primary" style={{ fontSize: 32 }} />
|
||||
</div>
|
||||
) : materials.length === 0 ? (
|
||||
<div className={style["empty-state"]}>
|
||||
<div className={style["empty-icon"]}>📄</div>
|
||||
<div className={style["empty-text"]}>
|
||||
暂无素材,快去新建一个吧!
|
||||
</div>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={handleCreateNew}
|
||||
className={style["empty-btn"]}
|
||||
>
|
||||
新建素材
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{materials.map(material => (
|
||||
<Card key={material.id} className={style["material-card"]}>
|
||||
{/* 顶部信息 */}
|
||||
<div className={style["card-header"]}>
|
||||
<div className={style["avatar-section"]}>
|
||||
<div className={style["avatar"]}>
|
||||
<UserOutlined className={style["avatar-icon"]} />
|
||||
</div>
|
||||
<div className={style["header-info"]}>
|
||||
<span className={style["creator-name"]}>
|
||||
{material.senderNickname || "系统创建"}
|
||||
</span>
|
||||
<span className={style["material-id"]}>
|
||||
ID: {material.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{renderContentTypeTag(material.contentType)}
|
||||
</div>
|
||||
{/* 标题 */}
|
||||
{material.contentType != 4 && (
|
||||
<div className={style["card-title"]}>
|
||||
{material.content}
|
||||
</div>
|
||||
)}
|
||||
{/* 内容预览 */}
|
||||
{renderContentPreview(material)}
|
||||
|
||||
{/* 操作按钮区 */}
|
||||
<div className={style["action-buttons"]}>
|
||||
<div className={style["action-btn-group"]}>
|
||||
<Button
|
||||
onClick={() => handleEdit(material.id)}
|
||||
className={style["action-btn"]}
|
||||
>
|
||||
<EditOutlined />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleView(material.id)}
|
||||
className={style["action-btn"]}
|
||||
>
|
||||
<BarChartOutlined />
|
||||
AI改写
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
color="danger"
|
||||
onClick={() => handleDelete(material.id)}
|
||||
className={style["delete-btn"]}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialsList;
|
||||
@@ -74,7 +74,7 @@ const Mine: React.FC = () => {
|
||||
description: "管理营销内容和素材",
|
||||
icon: <FolderOpenOutlined />,
|
||||
count: stats.content,
|
||||
path: "/content",
|
||||
path: "/mine/content",
|
||||
bgColor: "#fff7e6",
|
||||
iconColor: "#fa8c16",
|
||||
},
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
import ContentLibraryList from "@/pages/mobile/content/list/index";
|
||||
import ContentLibraryForm from "@/pages/mobile/content/form/index";
|
||||
import MaterialsList from "@/pages/mobile/content/materials/list/index";
|
||||
import MaterialForm from "@/pages/mobile/content/materials/form/index";
|
||||
import ContentLibraryList from "@/pages/mobile/mine/content/list/index";
|
||||
import ContentLibraryForm from "@/pages/mobile/mine/content/form/index";
|
||||
import MaterialsList from "@/pages/mobile/mine/content/materials/list/index";
|
||||
import MaterialForm from "@/pages/mobile/mine/content/materials/form/index";
|
||||
|
||||
const contentRoutes = [
|
||||
{
|
||||
path: "/content",
|
||||
path: "/mine/content",
|
||||
element: <ContentLibraryList />,
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
path: "/content/new",
|
||||
path: "/mine/content/new",
|
||||
element: <ContentLibraryForm />,
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
path: "/content/edit/:id",
|
||||
path: "/mine/content/edit/:id",
|
||||
element: <ContentLibraryForm />,
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
path: "/content/materials/:id",
|
||||
path: "/mine/content/materials/:id",
|
||||
element: <MaterialsList />,
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
path: "/content/materials/new/:id",
|
||||
path: "/mine/content/materials/new/:id",
|
||||
element: <MaterialForm />,
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
path: "/content/materials/edit/:id/:materialId",
|
||||
path: "/mine/content/materials/edit/:id/:materialId",
|
||||
element: <MaterialForm />,
|
||||
auth: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user