C++读取读取csv、xls文件的类

7 篇文章 0 订阅

由于经常要读取大量的数据进行分析,自己封装了一个C++的类,代替网上的rapidcsv库。一切以自己的需求为准,够用简略就好。真正体会到了C++程序员造轮子的乐趣。自己的拼装车就是好用。
文章需要的csv、xls数据文件,大家可以自己从网上下载:
1、网易财经频道可以下载到csv格式的股票日线数据,可以试着用我的quickcsv库读取
2、同花顺股票软件也可以导出xls格式的excel文件,也可以用我的库来读取。
所以,本文就不附加下载内容了,自己找材料测试
今天废话真多,好了,上代码:
QuickCsv.h头文件

#pragma once
#ifndef QUICKCSV_H
#define QUICKCSV_H
#include<vector>
#include<iostream>
#include <fstream>  
#include <sstream>
#include<assert.h>

using namespace std;

class QuickCsv
{
public:
	//QuickCsv(std::string Path):mPath(Path)
	//{
	//	ReadCsvData(mPath);
	//}
	QuickCsv()
	{


	}
	~QuickCsv()
	{
		VectorCsvOfAllLines.clear();
	}

	/*初始化数据,现在使用初始化函数可以保证一个类对象实例可以调用不同的市场数据*/
	void InitData(std::string Path)
	{
		mPath = Path;
		VectorCsvOfAllLines.clear();
		//mPath.assign(Path);	 //文件路径决定了计算哪个市场的数据
		//定义为私有函数、放在构造函数里面,更有面向对象编程的意味:实例化一个类对象,就对应一个csv文件的处理
		ReadCsvData(Path);
	}
	/*
	写入一行csv文件
	Mode(方式) 意义
		"r"		打开一个用于读取的文本文件
		"w"		创建一个用于写入的文本文件
		"a"		附加到一个文本文件
		"rb"	打开一个用于读取的二进制文件
		"wb"	创建一个用于写入的二进制文件
		"ab"	附加到一个二进制文件
		"r+"	打开一个用于读/写的文本文件
		"w+"	创建一个用于读/写的文本文件
		"a+"	打开一个用于读/写的文本文件
		"rb+"	打开一个用于读/写的二进制文件
		"wb+"	创建一个用于读/写的二进制文件
		"ab+"	打开一个用于读/写的二进制文件

	*/
	void WriteInDataToCsv(std::string Path, std::string VectorDataLine, const char* Mode)//写csv文件,保存文件
	{
		FILE* mFile;
		fopen_s(&mFile, Path.c_str(), Mode);
		if (!mFile)
		{
			printf_s("文件不存在,打开失败!!!");
			return;
		}
		fprintf_s(mFile, VectorDataLine.c_str());
		fflush(mFile);
		fclose(mFile);
	}
	/*重载,写入一行csv文件*/
	void WriteInDataToCsv(std::string Path, std::vector<std::string>VectorDataLine, const char* Mode)//写csv文件,保存文件
	{
		FILE* mFile;
		fopen_s(&mFile, Path.c_str(), Mode);
		if (!mFile)
		{
			printf_s("文件不存在,打开失败!!!");
			return;
		}
		for (auto itr = VectorDataLine.begin(); itr != VectorDataLine.end(); ++itr)
		{
			if (itr == VectorDataLine.end() - 1)
			{
				fprintf_s(mFile, "%s\0", itr->c_str());
				fflush(mFile);
			}
			else
			{
				fprintf_s(mFile, "%s\t", itr->c_str());
				fflush(mFile);
			}
		}
		fclose(mFile);
	}
	/*重载,写入多行csv文件*/
	void WriteInDataToCsv(std::string Path, std::vector<std::vector<std::string>>VectorDataLine, const char* Mode)//写csv文件,保存文件
	{
		FILE* mFile;
		fopen_s(&mFile, Path.c_str(), Mode);
		if (!mFile)
		{
			printf_s("文件不存在,打开失败!!!");
			return;
		}
		for (auto itr = VectorDataLine.begin(); itr != VectorDataLine.end(); ++itr)
		{
			//从itr->begin()到itr->end()是一系列string类型的数据(一行)
			for (auto data_itr = itr->begin(); data_itr != itr->end(); ++data_itr)
			{
				if (data_itr == itr->end() - 1)
				{
					fprintf_s(mFile, "%s\0", data_itr->c_str());
					fflush(mFile);
				}
				else
				{
					fprintf_s(mFile, "%s\t", data_itr->c_str());
					fflush(mFile);
				}
			}

		}
		fclose(mFile);
	}

	void DeletRow(int RowID)//删除行
	{
		//(一)先操作内存数据
		for (int i = 0; i < VectorCsvOfAllLines.size(); i++)
		{
			if (i == RowID)//要删除的行,忽略掉
			{
				continue;
			}
			TmpVectorCsvOfAllLines.push_back(VectorCsvOfAllLines.at(i));//其余的内容保留在临时的vector里面
		}
		VectorCsvOfAllLines.swap(TmpVectorCsvOfAllLines);				//用临时的vector交换原来的VectorCsvOfAllLines数据,实现修改的目的
		TmpVectorCsvOfAllLines.clear();									//清空临时的vector
		//(2)接下来写入本地文件
		WriteInDataToCsv(mPath, VectorCsvOfAllLines, "w");

	}
	void DeleteColumn(int ColumnID)//删除列
	{
		std::vector<std::string>tmpLine;//临时的行
		//(一)先操作内存数据
		for (auto itr = VectorCsvOfAllLines.begin(); itr!=  VectorCsvOfAllLines.end(); ++itr)
		{
			int num = 0;
			for (auto lineitr = itr->begin(); lineitr != itr->end();++lineitr)//itr就是每一行数据,遍历每一行
			{
				if (num == ColumnID)//如果遇到我们要删除的列,跳过
				{
					continue;
				}
				else//否则就把每一行不准备删除的数据保留在tmpLine里面
				{
					tmpLine.push_back(*lineitr);
				}
				num++;
			}
			TmpVectorCsvOfAllLines.push_back(tmpLine);//其余的内容保留在临时的vector里面
			tmpLine.clear();//每次循环处理一行数据,用完清理一次
		}
		VectorCsvOfAllLines.swap(TmpVectorCsvOfAllLines);				//用临时的vector交换原来的VectorCsvOfAllLines数据,实现修改的目的
		TmpVectorCsvOfAllLines.clear();									//清空临时的vector
		//(2)接下来写入本地文件
		WriteInDataToCsv(mPath, VectorCsvOfAllLines, "w");
	}
	void InsertRow(int RowID,std::vector<std::string>myLine)//插入行
	{
		//(1)先操作内存数据
		std::vector<std::vector<std::string>>::iterator myitr;//找到第RowID行的迭代器
		int num = 0;
		for (auto itr = VectorCsvOfAllLines.begin(); itr != VectorCsvOfAllLines.end(); ++itr)
		{
			if (num == RowID)
			{
				myitr = itr --;//num++是前一次循环
				break;
			}
			else
			{
				num++;
			}
		}
		VectorCsvOfAllLines.insert(myitr, myLine);
		//(2)接下来写入本地文件
		WriteInDataToCsv(mPath, VectorCsvOfAllLines, "w");

	}
	void InsertColumn(int ColumnID,std::vector<std::string>myColumData)//插入列
	{
		std::vector<std::string>tmpLine;//临时的行
		//(1)先操作内存数据
		int RowID = 0;
		for (auto itr = VectorCsvOfAllLines.begin(); itr != VectorCsvOfAllLines.end(); ++itr)
		{
			RowID++;
			tmpLine = *itr;//把一行的数据保存在临时行里面
			std::vector<std::string>::iterator myitr= GetIteratorByID(ColumnID, tmpLine);//找到每一行第ColumnID列的迭代器
			if (RowID < myColumData.size())
			{
				tmpLine.insert(myitr, myColumData.at(RowID));//此处有风险:有可能使用者给的列数据myColumData不够多,导致内存溢出
				TmpVectorCsvOfAllLines.push_back(tmpLine);//其余的内容保留在临时的vector里面
			}
			tmpLine.clear();//每次循环处理一行数据,用完清理一次
		}
		VectorCsvOfAllLines.swap(TmpVectorCsvOfAllLines);				//用临时的vector交换原来的VectorCsvOfAllLines数据,实现修改的目的
		TmpVectorCsvOfAllLines.clear();									//清空临时的vector
		//(2)接下来写入本地文件
		WriteInDataToCsv(mPath, VectorCsvOfAllLines, "w");

	}

	/*
	功能:
	获取第ColumID列的所有内容,比如股票代码、名称、换手率等等
	也可以获取所有的行名称,即所谓第0列
	参数:
	int ColumID:		列的序号
	bool GetHeader:	为真,保留列头(第0行);为假,只保留数据,没有表头项
	*/
	std::vector<std::string> GetColumStrings(int ColumID, bool GetHeader)
	{
		std::vector<std::string> VectorCsvRowNames;//保存行名称数据
		std::string tmpStr;
		for (auto itr = VectorCsvOfAllLines.begin(); itr != VectorCsvOfAllLines.end(); ++itr)
		{
			tmpStr = itr->at(ColumID);
			//tmpStr.erase(0, 2);
			VectorCsvRowNames.push_back(tmpStr);
			tmpStr.clear();
		}

		if (!GetHeader)
		{
			VectorCsvRowNames.erase(VectorCsvRowNames.begin());
		}
		return VectorCsvRowNames;
	}

	/*
	功能:
	同时获取若干列的所有内容,比如同时获取股票代码、名称、换手率、市盈率等等,方便以后提取数据
	参数:
	int ColumID:		列的序号
	bool GetHeader:	为真,保留列头(第0行);为假,只保留数据,没有表头项
	*/
	std::vector<std::vector<std::string>> GetColumsStrings(std::vector<int> ColumIDs, bool GetHeader)
	{
		std::vector<std::vector<std::string>> VectorCsvRowNames;//保存行名称数据
		if (VectorCsvOfAllLines.size() == 0)
		{
			std::cout << "类内部GetColumsStrings函数调用时VectorCsvOfAllLines没有数据:" << std::endl;
			return VectorCsvRowNames;//空的,刚刚定义的
		}
		std::vector<std::string>oneLine;
		for (auto itr = VectorCsvOfAllLines.begin(); itr != VectorCsvOfAllLines.end(); ++itr)//一大行数据
		{
			for (size_t i = 0; i < ColumIDs.size(); i++)//生成的一小行数据0、1、4、8
			{
				//cout << "itr->at(*int_itr)=" << itr->at(*int_itr) << "\n" << endl;
				//itr代表VectorCsvOfAllLines中的一行数据,这是一个vector;
				//现在只按照ColumIDs提取*int_itr这几项:itr->at(0)、itr->at(3)、itr->at(4)、itr->at(8)等等
				oneLine.push_back(itr->at(ColumIDs[i]));//把提取出来的这几项放入oneLine组成新的一行数据
			}
			VectorCsvRowNames.push_back(oneLine);//把新生成的一行数据oneLine放入VectorCsvRowNames
			oneLine.clear();
		}
		if (!GetHeader)
		{
			VectorCsvRowNames.erase(VectorCsvRowNames.begin());
		}
		return VectorCsvRowNames;
	}

	//获取csv文件数据的列表表头,
	std::vector<std::string> GetHeaders()//读取和处理一行csv数据
	{
		//vector<std::string>VectorCsvHeader;
		//ifstream inFile(mPath.c_str(), ios::in); //声明一个ifstream对象,与文件名关联mPath已经构造函数传参
		//string HeaderLineStr;
		//getline(inFile, HeaderLineStr);//不放在while循环里只读取第一行,csv文件的数据表头
		//istringstream sin(HeaderLineStr);         //将整行字符串lineStr读入到字符串流istringstream中
		//string headerDatas;
		把字符串行HeaderLineStr按照字符“,”分割为每一“行”(实际上就是数据块)
		每一个数据块(字符“,"分割的)放入headerDatas
		//while (getline(sin, headerDatas, mch))
		//{
		//	VectorCsvHeader.push_back(Trim(headerDatas));
		//	headerDatas.clear();
		//}
		//HeaderLineStr.clear();
		//return VectorCsvHeader;

		return VectorCsvOfAllLines.at(0);
	}

	/*
	功能:获取第RowID行的数据
	参数:
	int RowID:				行的序号
	bool IsRowName:		为真,有行名称;(行的第0项为行名称,不是要计算、比较的数据)
							为假,无行名称(行的第0项为直接是数据,比如服务器得到的API数据,自己需要按照约定规则的处理),只保留数据

	bool IsHeader			为真,存在列头(第0行不是数据);为假,第0行直接是数据,只保留数据,没有表头项
							是否存在表头有的数据列表没有标头,第0行直接是数据,比如服务器得到的API数据,自己需要按照约定规则的处理
	*/
	std::vector<double>GetRowData(int RowID, bool IsRowName, bool IsHeader)
	{
		if (IsHeader)//为真,存在列头(第0行不是数据);
		{
			RowID += 1;
		}
		//需要判断RowID是否为第0行,有的数据列表没有标头,第0行直接是数据,比如服务器得到的API数据,自己需要按照约定规则的处理
		std::vector<double> VectorCsvRowDatas;//保存行数据
		std::vector<std::string> VectormyLine = VectorCsvOfAllLines.at(RowID);//得到第RowID行

		if (IsRowName)//为真,有行名称;(行的第0项为行名称,不是要计算、比较的数据)
		{
			for (auto itr = VectormyLine.begin() + 1; itr != VectormyLine.end(); ++itr)
			{
				VectorCsvRowDatas.push_back(StringToDouble(*itr));
				//printf_s("%s\n",itr->at(0).c_str());
			}
		}
		else//为假,无行名称(行的第0项直接就是数据,比如服务器得到的API数据,自己需要按照约定规则的处理)
		{
			for (auto itr = VectormyLine.begin(); itr != VectormyLine.end(); ++itr)
			{
				VectorCsvRowDatas.push_back(StringToDouble(*itr));
				//printf_s("%s\n",itr->at(0).c_str());
			}
		}
		return VectorCsvRowDatas;
	}

	/*
	功能:(函数重载)获取第RowID行的数据
	参数:
	int RowID:				行的序号
	bool IsRowName:		为真,有行名称;(行的第0项为行名称,不是要计算、比较的数据)
							为假,无行名称(行的第0项为直接是数据,比如服务器得到的API数据,自己需要按照约定规则的处理),只保留数据

	bool IsHeader			为真,存在列头(第0行不是数据);为假,第0行直接是数据,只保留数据,没有表头项
							是否存在表头有的数据列表没有标头,第0行直接是数据,比如服务器得到的API数据,自己需要按照约定规则的处理
	*/
	std::vector<double>GetRowData(int RowID, std::vector<int>ColumsID, bool IsHeader)
	{
		if (IsHeader)//为真,存在列头(第0行不是数据);
		{
			RowID += 1;
		}
		//需要判断RowID是否为第0行,有的数据列表没有标头,第0行直接是数据,比如服务器得到的API数据,自己需要按照约定规则的处理
		std::vector<double> VectorCsvRowDatas;//保存行数据
		std::vector<std::string> VectormyLine = VectorCsvOfAllLines.at(RowID);//得到第RowID行
		for (auto int_itr = ColumsID.begin(); int_itr != ColumsID.end(); ++int_itr)
		{
			//cout <<"看看  *int_itr的值:"<< * int_itr << endl;
			VectorCsvRowDatas.push_back(StringToDouble(VectormyLine[*int_itr]));
		}
		return VectorCsvRowDatas;
	}
private:
	/*
	从MyVector里面获取第DataID项的迭代器
	*/
	std::vector<std::string>::iterator GetIteratorByID(int DataID, std::vector<std::string>MyVector)//找到每一行第ColumnID列的迭代器
	{
		std::vector<std::string>::iterator myitr;//找到每一行第ColumnID列的迭代器
		int num = 0;
		for (auto lineitr = MyVector.begin(); lineitr != MyVector.end(); ++lineitr)//tmpLine就是每一行数据,遍历每一行
		{
			if (num == DataID)//如果遇到我们要添加的列,记录下来迭代器
			{
				myitr = lineitr;
				break;
			}
			else//否则就跳过
			{
				num++;
			}
		}
		return myitr;
	}

	//定义为私有函数、放在构造函数里面,更有面向对象编程的意味:实例化一个类对象,就对应一个csv文件的处理
	void ReadCsvData(std::string Path)//读取csv文件
	{
		FILE* mFile;
		fopen_s(&mFile, Path.c_str(), "r");
		if (!mFile)
		{
			printf_s("文件%s不存在、或者文件名、文件路径有误,文件打开失败!!!\n", Path.c_str());
			return;
		}
		fclose(mFile);
		/*
		getline()函数用于输入流,读取字符到buffer中,直到下列情况发生:
		num - 1个字符已经读入,
		碰到一个换行标志,
		碰到一个EOF,
		或者,任意地读入,直到读到字符delim。delim字符不会被放入buffer中。
		*/
		std::ifstream fin(Path.c_str()); //打开文件流操作,构造函数传参
		std::string line;
		getline(fin, line);//先判断是什么类型的数据文件
		if (line.find(',') != std::string::npos)//存在分隔符“,”
		{
			VectorCsvOfAllLines.push_back(ParseLineStringToVector(line, ','));//别把文件中的第一行给忘掉了
			//把mPath文件按行整行读取,读取到行line当中,这个行是我们人类所理解的行,以换行符“\n”结束。
			//遇到文件尾标志eof终止读取,结束while循环
			while (getline(fin, line))
			{
				//csv文件某一行数据项
				std::vector<std::string> VectorCsvDataOfOneLine = ParseLineStringToVector(line, ',');
				VectorCsvOfAllLines.push_back(VectorCsvDataOfOneLine);
				VectorCsvDataOfOneLine.clear();
				//strtod函数也可以实现string到double的转换
			}
		}
		else if (line.find('\t') != std::string::npos)//存在空格“tab”为分隔符
		{
			VectorCsvOfAllLines.push_back(ParseLineStringToVector(line, '\t'));//别把文件中的第一行给忘掉了
			//把mPath文件按行整行读取,读取到行line当中,这个行是我们人类所理解的行,以换行符“\n”结束。
			//遇到文件尾标志eof终止读取,结束while循环
			while (getline(fin, line))
			{
				//csv文件某一行数据项
				std::vector<std::string> VectorCsvDataOfOneLine = ParseLineStringToVector(line, '\t');
				VectorCsvOfAllLines.push_back(VectorCsvDataOfOneLine);
				VectorCsvDataOfOneLine.clear();
				//strtod函数也可以实现string到double的转换
			}
		}
		else//既不存在“,”也不存在“\t”符
		{
			while (getline(fin, line))
			{
				//csv文件某一行数据项
				std::vector<std::string> VectorCsvDataOfOneLine;
				VectorCsvDataOfOneLine.push_back(line);
				VectorCsvOfAllLines.push_back(VectorCsvDataOfOneLine);
				VectorCsvDataOfOneLine.clear();
				//strtod函数也可以实现string到double的转换
			}
		}
		//看看结果
		//cout << "ReadCsvData函数运算结束,VectorCsvOfAllLines里面有:"<< VectorCsvOfAllLines.size()<<"行数据\n" << endl;
	}
	//按照某个特殊的字符ch切割每一行数据,放在vector<std::string> 里面
	std::vector<std::string> ParseLineStringToVector(std::string LineStr, char ch)
	{
		std::istringstream sin(LineStr);         //将整行字符串line读入到字符串流istringstream中
		std::string StrOfOneLine;
		std::vector<std::string> VectorLineStr;//csv文件某一行数据项

		//sin(line)是从文件中读取的一行的数据(回车结束的自然行);
		//StrOfOneLine是从sin(line)中读取一行一行的数据(实际上是数据块),每“行”以字符“,"为分隔符;
		//每“行”字符串(数据块)StrOfOneLine又放入vector当中。
		while (getline(sin, StrOfOneLine, ch))
		{
			VectorLineStr.push_back(Trim(StrOfOneLine));     //将刚刚读取的每一行的数据块字符串添加到vector中
			LineStr.clear();//看看是否有必要
		}
		return VectorLineStr;
		//strtod函数也可以实现string到double的转换

	}
	//整理数据
	std::string Trim(std::string& str)
	{
		str.find_first_not_of(" \t\r\n");// 在字符串str中从索引0开始,返回首次不匹配"\t\r\n"的位置
		str.erase(0, str.find_first_not_of(" \t\r\n"));
		str.erase(str.find_last_not_of(" \t\r\n") + 1);
		return str;
	}

	/*C++实现atof()--string to double的转化*/
	double StringToDouble(std::string str)
	{
		assert(str.size() != NULL);//断言非常重要,发现bug
		//if(str.size() == NULL);//断言非常重要,发现bug
		//{
		//	str = "00";
		//}
		int i = 0;
		double dou_num = 0;
		double t = 10;
		bool fh_ = false;

		if (str[i] == '-')
		{
			fh_ = true;
			i++;
		}
		while (str[i] != '\0')//不是换行符,数据结束符
		{
			if (str[i] == '.')//找到小数点就移位到下一个字符
			{
				i++;//移位到下一个字符
				break;
			}
			dou_num = dou_num * 10 + str[i] - '0';
			i++;
		}
		while (str[i] != '\0')
		{
			dou_num = dou_num + ((double)str[i] - '0') / t;
			t *= 10;
			i++;
		}
		if (fh_)
			return -1.0 * dou_num;
		else
			return dou_num;
	}
private:
	//对于修改操作必须把结果写入本地文件,所以必须要保存文件名和文件路径
	std::string mPath;//csv文件的路径,初始化函数传参
	std::vector<std::vector<std::string>> VectorCsvOfAllLines;//所有的行,包括列表表头、行名称
	std::vector<std::vector<std::string>> TmpVectorCsvOfAllLines;//修改数据时需要的临时变量;所有的行,包括列表表头、行名称
	int ColumnNum = 0;//csv文件数据的列数,根据表头计算出来
};

#endif // !QUICKCSV_H



调用QuickCsv类的cpp文件

#include"QuickCsv.h"
QuickCsv qc("../shanghai_a.xls");
//QuickCsv qc("../DaLian.csv");

int main()
{
	//调用GetRowNames()函数
	//vector<std::string> VectorCsvRowNames=qc.GetColumStrings(0,1);//参数为真,把列的表头一项也加进来
	//int i = 0;
	//for (auto itr = VectorCsvRowNames.begin(); itr != VectorCsvRowNames.end(); ++itr)
	//{
	//	printf_s("沪市有%d只股票,第%d只股票代码:%s\n", VectorCsvRowNames.size(), i, itr->c_str());
	//	i++;
	//}
	//VectorCsvRowNames.clear();

	//调用GetRowData()函数(一)
	int RowNum =6;
	//vector<double>mRow = qc.GetRowData(RowNum, 1, 1);//第0行的数据
	//for (auto itr = mRow.begin(); itr != mRow.end(); ++itr)
	//{
	//	printf_s("调用GetRowData()函数(一):第%d行有%d项数据:%.5f\n", RowNum, mRow.size(), *itr);
	//}
	//mRow.clear();

	调用GetRowData()函数(二)重载
	//vector<int> myColumsID = {3,4, 5 };//我只获取第RowNum行--第2、3、4、5列的数据
	//vector<double>mRow2 = qc.GetRowData(RowNum, myColumsID, 1);//第0行的数据
	//for (auto itr = mRow2.begin(); itr != mRow2.end(); ++itr)
	//{
	//	printf_s("重载调用GetRowData()函数:第%d行有%d项数据:%.5f\n", RowNum, mRow2.size(), *itr);
	//}
	//mRow2.clear();			//先进后出
	//myColumsID.clear();		//先进后出


	//调用GetHeaders()函数
	//vector<std::string>VectorCsvHeader=qc.GetHeaders();
	//看看效果:
	//int i = 0;
	//for (auto itr = VectorCsvHeader.begin(); itr != VectorCsvHeader.end(); ++itr)
	//{
	//	printf_s("csv文件有%d列;第%d列表头:%s\n", VectorCsvHeader.size(), i, itr->c_str());
	//	i++;
	//}
	//VectorCsvHeader.clear();


	vector<int>myColums = { 0,1,4,7 };//代码、名称、换手率、流通比
	vector<vector<string>> myVector=qc.GetColumsStrings(myColums,1);
	int i = 0;
	for (auto itr = myVector.begin(); itr != myVector.end(); ++itr)
	{
		if (i > 50)
			break;
		//通过itr得到了每一个数据行
		for (auto mitr = itr->begin(); mitr != itr->end(); ++mitr)
		{
			if (mitr != itr->end() - 1)
			{
				printf_s("%s---", mitr->c_str());
			}
			else
			{
				printf_s("%s\n", mitr->c_str());
			}
		}
		i++;
	}
	myColums.clear();//先进后出
	myVector.clear();//先进后出


	getchar();

	qc.~QuickCsv();
	return 0;
}

效果图:
在这里插入图片描述

  • 3
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值