支持故事logo,增加默认logo

This commit is contained in:
jiangh277
2025-07-25 20:25:28 +08:00
parent 04dde093a8
commit 56a0042011
6 changed files with 324 additions and 73 deletions

View File

@@ -5,10 +5,13 @@ import {
ProFormText,
ProFormTextArea,
} from '@ant-design/pro-components';
import { Button, Result } from 'antd';
import React, { FC } from 'react';
import type {StoryType} from '../data.d';
import { Button, message, Result, Upload, Radio } from 'antd';
import React, { FC, useState } from 'react';
import ImgCrop from 'antd-img-crop';
import type { StoryType } from '../data.d';
import useStyles from '../style.style';
import { defaultIcons } from '@/utils/commonConstant';
type OperationModalProps = {
done: boolean;
open: boolean;
@@ -17,22 +20,106 @@ type OperationModalProps = {
onSubmit: (values: StoryType) => void;
children?: React.ReactNode;
};
const OperationModal: FC<OperationModalProps> = (props) => {
const { styles } = useStyles();
const { done, open, current, onDone, onSubmit, children } = props;
// 图标状态管理
const [iconType, setIconType] = useState<'default' | 'upload'>(
current?.logo ? 'upload' : 'default',
);
const [selectedIcon, setSelectedIcon] = useState<string | null>(
current?.logo || defaultIcons[0],
);
const [iconPreview, setIconPreview] = useState<string | null>(
current?.logo || null,
);
const [fileList, setFileList] = useState<any[]>([]); // 控制上传图像展示
// 图标上传逻辑
const beforeUpload = (file: File) => {
const isValidType = file.type === 'image/png' || file.type === 'image/jpeg';
if (!isValidType) {
message.error('仅支持 PNG 或 JPEG 格式');
return false;
}
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 强制设置裁剪尺寸为 40x40
canvas.width = 40;
canvas.height = 40;
ctx.drawImage(img, 0, 0, 40, 40);
// 生成 Base64 图像
const base64 = canvas.toDataURL('image/png');
setSelectedIcon(base64);
setIconPreview(base64);
setFileList([
{
uid: '-1',
name: 'icon.png',
status: 'done',
url: base64,
originFileObj: new File([dataURLtoBlob(base64)], 'icon.png', {
type: 'image/png',
}),
},
]);
};
img.onerror = () => {
message.error('图像加载失败');
};
img.src = e.target?.result as string;
};
reader.onerror = () => {
message.error('读取图像失败');
};
reader.readAsDataURL(file);
return false; // 阻止自动上传
};
// Base64 → Blob 转换工具函数
const dataURLtoBlob = (dataurl: string) => {
const arr = dataurl.split(',');
const mime = arr[0].match(/:(.*?);/)[1];
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
};
if (!open) {
return null;
}
return (
<ModalForm<StoryType>
open={open}
title={done ? null : `任务${current ? '编辑' : '添加'}`}
title={done ? null : `故事${current ? '编辑' : '添加'}`}
className={styles.standardListForm}
width={640}
onFinish={async (values) => {
onSubmit(values);
onSubmit({
...values,
logo: selectedIcon || '',
});
}}
initialValues={{
...current,
logo: current?.logo ? 'upload' : 'default',
}}
initialValues={current}
submitter={{
render: (_, dom) => (done ? null : dom),
}}
@@ -42,8 +129,8 @@ const OperationModal: FC<OperationModalProps> = (props) => {
destroyOnClose: true,
bodyStyle: done
? {
padding: '72px 0',
}
padding: '72px 0',
}
: {},
}}
>
@@ -51,15 +138,145 @@ const OperationModal: FC<OperationModalProps> = (props) => {
<>
<ProFormText
name="title"
label="任务名称"
label="故事名称"
rules={[
{
required: true,
message: '请输入任务名称',
message: '请输入故事名称',
},
]}
placeholder="请输入"
/>
{/* 图标选择方式 */}
<ProFormText
name="logo"
label="图标选择"
hidden
rules={[{ required: true, message: '请选择图标' }]}
fieldProps={{
value: iconType,
}}
/>
<div style={{ marginBottom: 16 }}>
<span style={{ fontWeight: 'bold' }}></span>
<Radio.Group
value={iconType}
onChange={(e) => {
const type = e.target.value;
setIconType(type);
if (type === 'default') {
setSelectedIcon(defaultIcons[0]);
setIconPreview(defaultIcons[0]);
setFileList([]);
} else {
setSelectedIcon(null);
setIconPreview(null);
}
}}
style={{ display: 'flex', gap: 16, marginTop: 8 }}
>
<Radio value="default"></Radio>
<Radio value="upload"></Radio>
</Radio.Group>
</div>
{/* 默认图标库 */}
{iconType === 'default' && (
<div style={{ marginBottom: 16 }}>
<span style={{ fontWeight: 'bold' }}></span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 8 }}>
{defaultIcons.map((icon, index) => (
<img
key={index}
src={icon}
alt="icon"
style={{
width: 40,
height: 40,
cursor: 'pointer',
border: selectedIcon === icon ? '2px solid #1890ff' : 'none',
borderRadius: 4,
}}
onClick={() => {
setSelectedIcon(icon);
setIconPreview(icon);
}}
/>
))}
</div>
</div>
)}
{/* 图标上传 + 裁剪 */}
{iconType === 'upload' && (
<div style={{ marginBottom: 24 }}>
<span style={{ fontWeight: 'bold' }}>40x40</span>
<ImgCrop
rotationSlider
aspect={1} // 强制 1:1 宽高比
modalTitle="裁剪图像"
quality={0.8}
onModalOk={() => {
// 裁剪完成后自动更新 fileList 和 Base64 数据
const img = new Image();
img.onload = () => {
if (img.width !== 40 || img.height !== 40) {
message.error('裁剪图像尺寸必须为 40x40 像素');
setIconPreview(null);
setFileList([]);
return;
}
const canvas = document.createElement('canvas');
canvas.width = 40;
canvas.height = 40;
const ctx = canvas.getContext('2d');
ctx?.drawImage(img, 0, 0, 40, 40);
const base64 = canvas.toDataURL('image/png');
setSelectedIcon(base64);
setIconPreview(base64);
setFileList([
{
uid: '-1',
name: 'icon.png',
status: 'done',
url: base64,
originFileObj: new File([dataURLtoBlob(base64)], 'icon.png', {
type: 'image/png',
}),
},
]);
};
img.src = iconPreview;
}}
>
<Upload
name="icon"
listType="picture-card"
showUploadList={false}
beforeUpload={beforeUpload}
onChange={({ fileList }) => {
setFileList(fileList);
}}
fileList={fileList}
style={{ marginTop: 8 }}
>
{iconPreview ? (
<img
src={iconPreview}
alt="icon"
style={{ width: '100%', height: '100%' }}
/>
) : (
<div style={{ fontSize: 20 }}>+</div>
)}
</Upload>
</ImgCrop>
</div>
)}
{/* 其他表单项 */}
<ProFormDateTimePicker
name="createTime"
label="开始时间"
@@ -76,13 +293,14 @@ const OperationModal: FC<OperationModalProps> = (props) => {
}}
placeholder="请选择"
/>
<ProFormSelect
name="ownerId"
label="任务负责人"
label="故事负责人"
rules={[
{
required: true,
message: '请选择任务负责人',
message: '请选择故事负责人',
},
]}
options={[
@@ -97,6 +315,7 @@ const OperationModal: FC<OperationModalProps> = (props) => {
]}
placeholder="请选择管理员"
/>
<ProFormTextArea
name="description"
label="产品描述"
@@ -113,7 +332,7 @@ const OperationModal: FC<OperationModalProps> = (props) => {
<Result
status="success"
title="操作成功"
subTitle="一系列的信息描述,很短同样也可以带标点。"
subTitle={`${current?.instanceId ? '编辑' : '创建'}成功`}
extra={
<Button type="primary" onClick={onDone}>
@@ -125,4 +344,5 @@ const OperationModal: FC<OperationModalProps> = (props) => {
</ModalForm>
);
};
export default OperationModal;

View File

@@ -37,6 +37,7 @@ export interface StoryType {
ownerId?: string;
updatedId?: string;
updateTime?: string;
logo?: string;
}
export interface BaseResponse {
code: number;

View File

@@ -1,27 +1,18 @@
import { DownOutlined, PlusOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import { useRequest } from '@umijs/max';
import {
Avatar,
Button,
Card,
Dropdown,
Input,
List,
Modal,
Radio,
} from 'antd';
import { history, useRequest } from '@umijs/max';
import { Avatar, Button, Card, Dropdown, Input, List, Modal, Radio } from 'antd';
import type { FC } from 'react';
import React, { useState } from 'react';
import OperationModal from './components/OperationModal';
import type {StoryType} from './data.d';
import {addStory, deleteStory, queryTimelineList, updateStory} from './service';
import type { StoryType } from './data.d';
import { addStory, deleteStory, queryTimelineList, updateStory } from './service';
import useStyles from './style.style';
const RadioButton = Radio.Button;
const RadioGroup = Radio.Group;
/*const RadioButton = Radio.Button;
const RadioGroup = Radio.Group;*/
const { Search } = Input;
import { history } from '@umijs/max';
const ListContent = ({
data: { ownerId, updatedId, createTime, updateTime, status },
}: {
@@ -62,9 +53,10 @@ export const BasicList: FC = () => {
data: listData,
loading,
run,
} = useRequest(() => {
} = useRequest((storyName?: string) => {
return queryTimelineList({
count: 50,
storyName,
});
});
const { run: postRun } = useRequest(
@@ -101,12 +93,12 @@ export const BasicList: FC = () => {
});
};
const editAndDelete = (key: string | number, currentItem: StoryType) => {
console.log(currentItem)
console.log(currentItem);
if (key === 'edit') showEditModal(currentItem);
else if (key === 'delete') {
Modal.confirm({
title: '删除任务',
content: '确定删除该任务吗?',
title: '删除故事',
content: '确定删除该故事吗?',
okText: '确认',
cancelText: '取消',
onOk: () => deleteItem(currentItem.instanceId ?? ''),
@@ -115,14 +107,18 @@ export const BasicList: FC = () => {
};
const extraContent = (
<div>
<RadioGroup defaultValue="all">
{/*<RadioGroup defaultValue="all">
<RadioButton value="all">全部</RadioButton>
<RadioButton value="progress">进行中</RadioButton>
<RadioButton value="waiting">等待中</RadioButton>
</RadioGroup>
<Search className={styles.extraContentSearch} placeholder="请输入" onSearch={(value) => {
run();
}} />
</RadioGroup>*/}
<Search
className={styles.extraContentSearch}
placeholder="请输入"
onSearch={(value) => {
run(value);
}}
/>
</div>
);
const MoreBtn: React.FC<{
@@ -156,16 +152,16 @@ export const BasicList: FC = () => {
const handleSubmit = (values: StoryType) => {
setDone(true);
const method = current?.instanceId ? 'update' : 'add';
postRun(method, {...current, ...values});
postRun(method, { ...current, ...values });
run();
};
return (
<div>
<PageContainer>
<PageContainer title={"Timeline"}>
<div className={styles.standardList}>
<Card
className={styles.listCard}
variant={undefined}
title="我的Timeline"
style={{
marginTop: 24,
}}
@@ -180,7 +176,7 @@ export const BasicList: FC = () => {
loading={loading}
pagination={paginationProps}
dataSource={list}
renderItem={(item:StoryType) => (
renderItem={(item: StoryType) => (
<List.Item
actions={[
<a
@@ -197,7 +193,15 @@ export const BasicList: FC = () => {
>
<List.Item.Meta
avatar={<Avatar src={item.logo} shape="square" size="large" />}
title={<a onClick={() => {history.push(`/timeline/${item.instanceId}`)}}>{item.title}</a>}
title={
<a
onClick={() => {
history.push(`/timeline/${item.instanceId}`);
}}
>
{item.title}
</a>
}
description={item.description}
/>
<ListContent data={item} />

View File

@@ -1,30 +1,27 @@
import { request } from '@umijs/max';
import {AddStoryItem, BaseResponse, StoryItem, StoryType} from './data.d';
import { StoryItem, StoryType } from './data.d';
type ParamsType = {
count?: number;
instanceId?: string;
storyName?: string;
} & Partial<StoryType>;
export async function queryTimelineList(
params: ParamsType,
): Promise<{ data: { data: StoryType[] } }> {
): Promise<{ data: StoryType[] }> {
return await request('/story/owner/test11', {
params
params,
});
}
export async function deleteStory(
params: ParamsType,
): Promise<{ data: { list: StoryType[] } }> {
export async function deleteStory(params: ParamsType): Promise<{ data: { list: StoryType[] } }> {
return request(`/story/${params.instanceId}`, {
method: 'DELETE',
});
}
export async function addStory(
params: ParamsType,
): Promise<{ data: { list: StoryType[] } }> {
export async function addStory(params: ParamsType): Promise<{ data: { list: StoryType[] } }> {
return request('/story/add', {
method: 'POST',
data: {
@@ -33,9 +30,8 @@ export async function addStory(
},
});
}
export async function updateStory(
params: ParamsType,
): Promise<{ data: { list: StoryType[] } }> {
export async function updateStory(params: ParamsType): Promise<{ data: { list: StoryType[] } }> {
return await request(`/story/${params.instanceId}`, {
method: 'PUT',
data: {
@@ -45,9 +41,7 @@ export async function updateStory(
});
}
export async function addStoryItem(
params: FormData,
): Promise<any> {
export async function addStoryItem(params: FormData): Promise<any> {
return request(`/story/item`, {
method: 'POST',
data: params,
@@ -55,9 +49,8 @@ export async function addStoryItem(
getResponse: true,
});
}
export async function queryStoryItem(
masterItemId: string,
): Promise<{ data: StoryItem[] }> {
export async function queryStoryItem(masterItemId: string): Promise<{ data: StoryItem[] }> {
return request(`/story/item/list`, {
method: 'GET',
params: {
@@ -65,26 +58,23 @@ export async function queryStoryItem(
},
});
}
export async function queryStoryItemDetail(
itemId: string,
): Promise<{ data: StoryItem[] }> {
export async function queryStoryItemDetail(itemId: string): Promise<{ data: StoryItem[] }> {
return request(`/story/item/${itemId}`, {
method: 'GET',
});
}
export async function queryStoryItemImages(
itemId: string,
): Promise<{ data: string[] }> {
export async function queryStoryItemImages(itemId: string): Promise<{ data: string[] }> {
return request(`/story/item/images/${itemId}`, {
method: 'GET',
});
}
export async function fetchImage(
imageInstanceId: string,
): Promise<any> {
export async function fetchImage(imageInstanceId: string): Promise<any> {
return request(`/file/download/cover/${imageInstanceId}`, {
method: 'GET',
responseType: 'blob',
getResponse: true,
})
});
}

View File

@@ -134,6 +134,20 @@ const useStyles = createStyles(({ token }) => {
width: '100%',
"[class^='title']": { marginBottom: '8px' },
},
iconUploader: {
width: 20,
height: 20,
objectFit: 'contain',
border: '1px dashed #ddd',
borderRadius: 2,
cursor: 'pointer',
},
iconList: {
display: 'flex',
flexWrap: 'wrap',
gap: 8,
marginBottom: 16,
},
};
});