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: "管理营销内容和素材",
|
description: "管理营销内容和素材",
|
||||||
icon: <FolderOpenOutlined />,
|
icon: <FolderOpenOutlined />,
|
||||||
count: stats.content,
|
count: stats.content,
|
||||||
path: "/content",
|
path: "/mine/content",
|
||||||
bgColor: "#fff7e6",
|
bgColor: "#fff7e6",
|
||||||
iconColor: "#fa8c16",
|
iconColor: "#fa8c16",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,36 +1,36 @@
|
|||||||
import ContentLibraryList from "@/pages/mobile/content/list/index";
|
import ContentLibraryList from "@/pages/mobile/mine/content/list/index";
|
||||||
import ContentLibraryForm from "@/pages/mobile/content/form/index";
|
import ContentLibraryForm from "@/pages/mobile/mine/content/form/index";
|
||||||
import MaterialsList from "@/pages/mobile/content/materials/list/index";
|
import MaterialsList from "@/pages/mobile/mine/content/materials/list/index";
|
||||||
import MaterialForm from "@/pages/mobile/content/materials/form/index";
|
import MaterialForm from "@/pages/mobile/mine/content/materials/form/index";
|
||||||
|
|
||||||
const contentRoutes = [
|
const contentRoutes = [
|
||||||
{
|
{
|
||||||
path: "/content",
|
path: "/mine/content",
|
||||||
element: <ContentLibraryList />,
|
element: <ContentLibraryList />,
|
||||||
auth: true,
|
auth: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/content/new",
|
path: "/mine/content/new",
|
||||||
element: <ContentLibraryForm />,
|
element: <ContentLibraryForm />,
|
||||||
auth: true,
|
auth: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/content/edit/:id",
|
path: "/mine/content/edit/:id",
|
||||||
element: <ContentLibraryForm />,
|
element: <ContentLibraryForm />,
|
||||||
auth: true,
|
auth: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/content/materials/:id",
|
path: "/mine/content/materials/:id",
|
||||||
element: <MaterialsList />,
|
element: <MaterialsList />,
|
||||||
auth: true,
|
auth: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/content/materials/new/:id",
|
path: "/mine/content/materials/new/:id",
|
||||||
element: <MaterialForm />,
|
element: <MaterialForm />,
|
||||||
auth: true,
|
auth: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/content/materials/edit/:id/:materialId",
|
path: "/mine/content/materials/edit/:id/:materialId",
|
||||||
element: <MaterialForm />,
|
element: <MaterialForm />,
|
||||||
auth: true,
|
auth: true,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user