• Scratch二次开发8:背景、角色、造型、声音后台管理


    时隔两年,再次接着写

    为公共事业做贡献,做了个开源版本:scratch.lite

    开源版本带MySQL后台服务器,功能:注册、登录、保存作品、分享、修改作品名称、保存作品缩略图。

    有兴趣的朋友可以去下载参考:lite: 一个轻量级的Scratch编程分享平台:注册登录、作品创作、作品管理、素材管理、用户管理,作品点赞、收藏、分享。

    Scratch二次开发的纯技术交流QQ群:115224892/914159821

    (有搭建手册)

    这次我们来聊聊Scratch的素材管理定制方面的内容(扩展管理后续再放上来吧)

    整个Scratch作品,基本上就是围绕背景、角色、造型、声音这4类素材来操作的。

    但官方开源时,已直接把这几类素材直接放在Scratch中处理了。

    要求不高的话,这也无所谓。

    如果能放在后台去管理,大体上会有三个好处:

    1、减小lib.min.js文件的大小;

    2、可以灵活的管理各类素材(各类节假日等时机,可以放上应景的素材);

    3、每次打开素材选择窗口时,就不必要一次性加载了(默认的是会从服务器上下载全部素材的。举例:当打开背景选择窗口时,会直接把全部的背景图片都下载下来,严重浪费带宽,如果网络慢点的话,同时也会影响体验)。

    指定默认作品时,也能做些应景的事,或是每机构都可以有自己的默认作品,这样用户一打开或是新建作品时,就直接使用默认作品模板了(开源版本中,已实现了这个功能,后续也可以聊一聊)。

    正题:Scratch背景 后台管理功能的实现

    下面将从三个方面来说:

    1、Scratch中的源代码;

    2、Scratch中的接口;

    3、服务器端的源代码。

    • 一、Scratch中的关键源代码

    a.背景选择窗口的入口文件:/src/containers/backdrop-library.jsx

    此文件仅仅只是一个入口,它会在此准备好背景的分类、背景数据。

    b.它后面会直接调用/src/components/library/library.jsx来具体执行(这个文件官方原版本:教程、造型、背景、角色、声音、扩展等界面共用。此文件耦合太紧了,很是让人头疼的)。

    c.library.jsx会实现一个Modal,并会使用/src/containers/library-item.jsx显示具体的背景(这个Modal及LibraryItem的属性就乱的很(狠)啊,毕竟是好几个不同的东西共用的。)

    本人已把各娄素材选择窗口,已从上面的共用文件中分离出来了(舒服... ...)

    在贴代码前,说说都做了些什么:

    1、直接从服务器获取背景的分类数据;

    2、直接从服务器上获取背景数据(采用了流式加载:第一次只加载30来个,后续当用户鼠标滚动到底部时,会再次加载30来个,直到加载完全部的背景);

    3、优化了原搜索功能:可以在不同分类下搜索;

    4、直接把原backdrop-library.jsx、library.jsx、LibraryItem直接在一次性搞定了(虽然少了些共用性及同代码片段的复用,但确实简洁、舒服多了)。

    上代码,上完整的源代码,上香香的源代码:

    1. // 背景选择窗口
    2. import bindAll from 'lodash.bindall';
    3. import PropTypes from 'prop-types';
    4. import React from 'react';
    5. import VM from 'scratch-vm';
    6. import classNames from 'classnames';
    7. import Filter from '../components/filter/filter.jsx';
    8. import Divider from '../components/divider/divider.jsx';
    9. import TagButton from './tag-button.jsx';
    10. import Spinner from '../components/spinner/spinner.jsx';
    11. import {Card} from 'antd';
    12. const {Meta} = Card;
    13. import Modal from '../containers/modal.jsx'; // 选择窗口组件
    14. import {getBackdropLibrary} from '../session-api';
    15. import styles from './project-library.css';
    16. class BackdropLibrary extends React.Component {
    17. constructor (props) {
    18. super(props);
    19. bindAll(this, [
    20. 'handleClose',
    21. 'handleFilterChange',
    22. 'handleFilterKeyUp',
    23. 'handleFilterClear',
    24. 'handleTagClick',
    25. 'handleOnScroll',
    26. 'handleItemSelect',
    27. 'setFilteredDataRef',
    28. 'handleGetMoreItem'
    29. ]);
    30. this.state = {
    31. isSearch: false, // 开始搜索
    32. isEnterSearched: false, // 是否已经通过回车搜索过:在未回车搜索过时,直接清空搜索框时,不必去取分类的全部
    33. filterQuery: '',
    34. selectedTag: 0,
    35. tagList: [], // 分类标签
    36. itemList: [], // itemList.length 已获取的背景数,用于流方式加载
    37. isOver: false, // 是否已获取了分类下的全部背景
    38. loading: true // 是否已加载完
    39. };
    40. }
    41. componentWillMount (){
    42. this.getItemList(true, 1); // 加载完后,第一次获取背景列表
    43. }
    44. componentDidUpdate (prevProps, prevState) {
    45. if (prevState.selectedTag !== this.state.selectedTag || this.state.isSearch) {
    46. this.getItemList(true, 0);
    47. }
    48. }
    49. // 获取背景列表:newList:新列表, newTag:1:同时获取分类数据
    50. getItemList (newList, newTag) {
    51. // console.info("当前状态值:", this.state);
    52. if (newList) { // 只有滚动到底时,才不清空已获取的背景列表
    53. this.setState({itemList: [], isOver: false});
    54. }
    55. if (this.state.isSearch) {
    56. this.setState({isSearch: false});
    57. }
    58. if (this.state.isOver) {
    59. return;
    60. }
    61. // 组件获取条件 tag:是否获取分类; f: 搜索字符串; t: 分类; l: 已经获取的背景数; n: 每次获取的背景数,默认为32
    62. const searchParam = `tag=${newTag}&&f=${this.state.filterQuery}&&t=${this.state.selectedTag}&&l=${this.state.itemList.length}&n=32`;
    63. getBackdropLibrary(searchParam).then(res => {
    64. // console.error("getBackdropLibrary", res);
    65. this.setState({loading: false});
    66. if (1 == newTag) {
    67. this.setState({tagList: res.tags});
    68. }
    69. if (res.data.length < 32) {
    70. this.setState({isOver: true});
    71. }
    72. if (newList){
    73. this.setState({itemList: res.data});
    74. } else {
    75. this.setState({itemList: this.state.itemList.concat(res.data)});
    76. }
    77. });
    78. }
    79. // 关闭窗口
    80. handleClose () {
    81. this.props.onRequestClose();
    82. }
    83. // 切换标签
    84. handleTagClick (id) {
    85. if (this.state.selectedTag != id){
    86. this.setState({filterQuery: '', isEnterSearched: false, selectedTag: id, itemList: [], isOver: false, loading: true});
    87. }
    88. }
    89. // 搜索:输入字符串
    90. handleFilterChange (event) {
    91. this.setState({filterQuery: event.target.value});
    92. }
    93. // 搜索:按回车时开始搜索
    94. handleFilterKeyUp (event) {
    95. if (event.keyCode === 13 && event.target.value.length > 0) {
    96. this.setState({isSearch: true, isEnterSearched: true, itemList: [], isOver: false, loading: true}); // 触发搜索
    97. }
    98. }
    99. // 搜索:清空字符串
    100. handleFilterClear () {
    101. this.setState({filterQuery: ''});
    102. if (this.state.isEnterSearched) { // 在未回车搜索过时,直接清空搜索框时,不必去取分类的全部
    103. this.setState({isSearch: true, isEnterSearched: false, itemList: [], isOver: false, loading: true});
    104. }
    105. }
    106. // 绑定组件变量,为监听页面滚动
    107. setFilteredDataRef (ref) {
    108. this.filteredDataRef = ref;
    109. }
    110. // 监听页面滚动,到底后,再次自动加载背景
    111. handleOnScroll () {
    112. if (this.filteredDataRef) {
    113. const contentScrollTop = this.filteredDataRef.scrollTop; // 滚动条距离顶部
    114. const clientHeight = this.filteredDataRef.clientHeight; // 可视区域
    115. const scrollHeight = this.filteredDataRef.scrollHeight; // 滚动条内容的总高度
    116. if (contentScrollTop + clientHeight >= scrollHeight - 10) { // 提前 10 个位置开始获取后续元素
    117. if (!this.state.isOver) {
    118. this.getItemList(false, 0); // 继续获取数据的方法
    119. }
    120. // console.error('已到底');
    121. }
    122. }
    123. }
    124. // 手动点击按钮,继续获取数据的方法
    125. handleGetMoreItem () {
    126. if (!this.state.isOver) {
    127. this.getItemList(false, 0);
    128. }
    129. }
    130. // 打开背景
    131. handleItemSelect (index) {
    132. this.handleClose();
    133. const item = this.state.itemList[index];
    134. const vmBackdrop = {
    135. name: item.name,
    136. rotationCenterX: item.info0,
    137. rotationCenterY: item.info1,
    138. bitmapResolution: item.info2,
    139. skinId: null
    140. };
    141. // Do not switch to stage, just add the backdrop
    142. this.props.vm.addBackdrop(item.md5, vmBackdrop);
    143. }
    144. render () {
    145. return (
    146. <Modal
    147. fullScreen
    148. contentLabel="选择一个背景"
    149. id="backdropLibrary"
    150. onRequestClose={this.handleClose}
    151. >
    152. {/* 搜索、分类部分 */}
    153. <div className={styles.filterBar}>
    154. <Filter
    155. className={classNames(styles.filterBarItem, styles.filter)}
    156. filterQuery={this.state.filterQuery}
    157. inputClassName={styles.filterInput}
    158. placeholderText="搜索"
    159. onChange={this.handleFilterChange}
    160. onClear={this.handleFilterClear}
    161. onKeyUp={this.handleFilterKeyUp}
    162. />
    163. <Divider className={classNames(styles.filterBarItem, styles.divider)} />
    164. <div className={styles.tagWrapper}>
    165. {[{tag: "全部", id: 0}].concat(this.state.tagList).map((tag, index) => (
    166. <TagButton
    167. active={this.state.selectedTag === tag.id}
    168. className={classNames(styles.filterBarItem, styles.tagButton)}
    169. key={`tag-button-${index}`}
    170. onClick={()=>this.handleTagClick(tag.id)}
    171. tag={tag.tag}
    172. />
    173. ))}
    174. </div>
    175. </div>
    176. <div
    177. className={classNames(styles.libraryScrollGrid, styles.withFilterBar)}
    178. ref={this.setFilteredDataRef}
    179. onScrollCapture={this.handleOnScroll}
    180. >
    181. {this.state.loading ? (
    182. <div className={styles.spinnerWrapper}>
    183. <Spinner
    184. large
    185. level="primary"
    186. />
    187. </div>
    188. ) : (<>
    189. {(this.state.itemList.length == 0)&&(
    190. <div className={styles.spinnerWrapper}>
    191. <div className={styles.spanBox}></div>
    192. </div>
    193. )}
    194. {this.state.itemList.map((item, index) => (
    195. <Card
    196. hoverable
    197. className={styles.ItemCard}
    198. style={{width: 180, margin: 6, borderRadius: 6}}
    199. cover={<img src={`/scratch/assets/${item.md5}`} style={{width: 160, height: 120, margin: 10}}/>}
    200. key={index}
    201. onClick={()=>this.handleItemSelect(index)}
    202. >
    203. <Meta title={item.name} />
    204. </Card>
    205. ))}
    206. {!this.state.isOver && (
    207. <div
    208. className={styles.spinnerWrapper1}
    209. onClick={this.handleGetMoreItem}
    210. >
    211. <div className={styles.spanBox}>点我可继续</div>
    212. </div>
    213. )}
    214. </>)}
    215. </div>
    216. </Modal>
    217. );
    218. }
    219. }
    220. BackdropLibrary.propTypes = {
    221. onRequestClose: PropTypes.func,
    222. vm: PropTypes.instanceOf(VM).isRequired
    223. };
    224. export default BackdropLibrary;

    哦哦哦,本人技术男,总觉得自己做的CSS不好看,所以,这次引入了阿里的Ant Design。

    (具体怎么引入Ant Design到Scratch,那又是另一个小话题了)。

    上面只是选择背景的窗口,还有一个地方要注意:Scratch可以直接获取一个随机背景的,此功能有两处,所在的文件分别是:/src/containers/costume-tab.jsx与/src/containers/stage-selector.jsx,

    此处源代码为:

    1. // 下面源代码所在的文件:/src/containers/costume-tab.jsx
    2. // 先引入接口
    3. import {getRandomBackdrop, getRandomCostume} from '../session-api';
    4. // 从上面也引入了getRandomCostume可以看出,此文件也同时实现的随机获取一个造型的功能
    5. ... ...
    6. handleSurpriseBackdrop () {
    7. // 随机选择一个背景
    8. // 这里要特别注意,要彻底明白背景的各个属性数据的意义
    9. // 官方的是日积月累搞出来的,为了兼容,做了很多“坏”事
    10. getRandomBackdrop().then(res => {
    11. if (res.status == "ok"){
    12. const vmBackdrop = {
    13. name: res.data.name,
    14. md5: res.data.md5,
    15. rotationCenterX: res.data.info0,
    16. rotationCenterY: res.data.info1,
    17. bitmapResolution: res.data.info2,
    18. skinId: null
    19. };
    20. this.props.vm.addBackdrop(res.data.md5, vmBackdrop);
    21. }
    22. });
    23. // const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)];
    24. // const vmCostume = {
    25. // name: item.name,
    26. // md5: item.md5,
    27. // rotationCenterX: item.info[0] && item.info[0] / 2,
    28. // rotationCenterY: item.info[1] && item.info[1] / 2,
    29. // bitmapResolution: item.info.length > 2 ? item.info[2] : 1,
    30. // skinId: null
    31. // };
    32. // this.handleNewCostume(vmCostume);
    33. }
    1. // 源代码所在的文件:/src/containers/stage-selector.jsx
    2. // 引入接口API
    3. import {getRandomBackdrop} from '../session-api';
    4. ... ...
    5. handleSurpriseBackdrop (e) {
    6. e.stopPropagation(); // Prevent click from falling through to selecting stage.
    7. getRandomBackdrop().then(res => {
    8. if (res.status == "ok"){
    9. const vmBackdrop = {
    10. name: res.data.name,
    11. md5: res.data.md5,
    12. rotationCenterX: res.data.info0,
    13. rotationCenterY: res.data.info1,
    14. bitmapResolution: res.data.info2,
    15. skinId: null
    16. };
    17. this.props.vm.addBackdrop(res.data.md5, vmBackdrop);
    18. }
    19. });
    20. // const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)];
    21. // this.addBackdropFromLibraryItem(item, false);
    22. }
    • 二、Scratch与服务器对接的接口源代码

    主要是二个接口:

    1、getBackdropLibrary:获取背景数据接口(第一次获取时,会同时取回背景的分类的数据);

    2、getRandomBackdrop:随机获取一个背景接口。

    上代码,上完整的源代码,上香香的源代码:

    (本人直接把一些Scratch与服务器对接的API都统一放在了一个文件中,这次一并多贴点吧。现在我们只看看 getBackdropLibrary、getRandomBackdrop就好)

    1. // 登录一:首页打开Scratch时,自动获取一次用户登录信息
    2. module.exports.requestSession = (resolve, reject) => (
    3. miniFetch(resolve, reject, '/user/getSession')
    4. );
    5. // 登录二:提交账号、密码进行登录
    6. module.exports.requestLogin = (data) => new Promise((resolve, reject) => {
    7. miniFetch(resolve, reject, '/user/login', {body:data})
    8. });
    9. // 退出:提交账号,退出登录状态
    10. module.exports.requestLogout = (resolve, reject, data) => (
    11. miniFetch(resolve, reject, '/user/logout', {body: data})
    12. );
    13. // 获取项目源代码
    14. module.exports.requestProject = (resolve, reject, projectId) => (
    15. miniFetch(resolve, reject, `/scratch/project/${projectId}`)
    16. );
    17. // 保存标题
    18. module.exports.requestSaveProjectTitle = (resolve, reject, projectId, projectTitle) => {
    19. miniFetch(resolve, reject, '/scratch/saveProjcetTitle', {body:`id=${projectId}&title=${projectTitle}`})
    20. };
    21. // 保存缩略图
    22. module.exports.requestSaveProjectThumbnail = (resolve, reject, projectId, thumbnailBlob) => {
    23. miniFetch(resolve, reject, `/scratch/thumbnail/${projectId}`, {body:thumbnailBlob, headers:{'Content-Type': 'image/png'}})
    24. };
    25. // 分享作品 或 取消分享
    26. module.exports.requestShareProject = (projectId, s) => new Promise((resolve, reject) => {
    27. miniFetch(resolve, reject, `/scratch/shareProject/${projectId}`, {body:`s=${s}`});
    28. });
    29. // 获取课程卡数据
    30. module.exports.requestCousreCard = (resolve, reject, lessonId) => {
    31. miniFetch(resolve, reject, `/course/getcard/${lessonId}`);
    32. };
    33. // 获取我的作品
    34. module.exports.getMyProjectLibrary = (data) => new Promise((resolve, reject) => {
    35. miniFetch(resolve, reject, '/scratch/getMyProjectLibrary', {body: data});
    36. });
    37. // 获取优秀作品
    38. module.exports.getYxProjectLibrary = (data) => new Promise((resolve, reject) => {
    39. miniFetch(resolve, reject, '/scratch/getYxProjectLibrary', {body: data});
    40. });
    41. // 获取背景
    42. module.exports.getBackdropLibrary = (data) => new Promise((resolve, reject) => {
    43. miniFetch(resolve, reject, '/scratch/getBackdropLibrary', {body: data});
    44. });
    45. // 随机获取背景
    46. module.exports.getRandomBackdrop = () => new Promise((resolve, reject) => {
    47. miniFetch(resolve, reject, '/scratch/getRandomBackdrop');
    48. });
    49. // 获取造型
    50. module.exports.getCostumeLibrary = (data) => new Promise((resolve, reject) => {
    51. miniFetch(resolve, reject, '/scratch/getCostumeLibrary', {body: data});
    52. });
    53. // 随机获取造型
    54. module.exports.getRandomCostume = () => new Promise((resolve, reject) => {
    55. miniFetch(resolve, reject, '/scratch/getRandomCostume');
    56. });
    57. // 获取声音
    58. module.exports.getSoundLibrary = (data) => new Promise((resolve, reject) => {
    59. miniFetch(resolve, reject, '/scratch/getSoundLibrary', {body: data});
    60. });
    61. // 随机获取声音
    62. module.exports.getRandomSound = () => new Promise((resolve, reject) => {
    63. miniFetch(resolve, reject, '/scratch/getRandomSound');
    64. });
    65. // 获取角色
    66. module.exports.getSpriteLibrary = (data) => new Promise((resolve, reject) => {
    67. miniFetch(resolve, reject, '/scratch/getSpriteLibrary', {body: data});
    68. });
    69. // 随机获取角色
    70. module.exports.getRandomSprite = () => new Promise((resolve, reject) => {
    71. miniFetch(resolve, reject, '/scratch/getRandomSprite');
    72. });
    73. // 公用函数
    74. function miniFetch(resolve, reject, uri, params){
    75. // uri = "https://comecode.net"+uri;
    76. var opts = {
    77. headers:{
    78. 'Accept':'application/json,text/plain,*/*',/* 格式限制:json、文本、其他格式 */
    79. 'Content-Type':'application/x-www-form-urlencoded'/* 请求内容类型 */
    80. },
    81. method:'post'
    82. }
    83. if (params){
    84. if (params.headers) {opts['headers'] = Object.assign(opts['headers'], params.headers)}
    85. if (params.method) {opts["method"] = params.method}
    86. if (params.body) {opts["body"] = params.body}
    87. }
    88. fetch(uri, opts).then(response=>{
    89. var body = response.json();
    90. if(response.status == 200){
    91. return resolve(body);
    92. }
    93. return reject(body)
    94. })
    95. .catch(err=>reject(err))
    96. };

    此处不用多说,一看便知。

    • 三、服务器端源代码

    这块比较重要,也比较简单(注:开源的是NodeJS + Express实现的,如果要用其他服务器端(JAVA、PHP、Go... ...)可以参数一二):

    1. // 获取背景
    2. // 组件获取条件 tag:是否获取分类; f: 搜索字符串; t: 分类; l: 已经获取的背景数; n: 每次获取的背景数,默认为20
    3. router.post('/getBackdropLibrary', function (req, res) {
    4. var WHERE = '';
    5. if (req.body.t != 0){
    6. WHERE = ' AND tagId='+req.body.t;
    7. }
    8. if (req.body.f && req.body.f!=''){
    9. WHERE += ` AND name LIKE '%${req.body.f}%'`;
    10. }
    11. var SELECT =`SELECT id, name, md5, info0, info1, info2 FROM material_backdrop WHERE state=1 ${WHERE} ORDER BY name DESC LIMIT ${req.body.l},${req.body.n}`;
    12. DB.query(SELECT, function(err, Backdrop){
    13. if (err) {
    14. res.status(200).send({status:"err", data: [], tags: []});
    15. return;
    16. }
    17. if (req.body.tag == 0) {
    18. res.status(200).send({status:"ok", data: Backdrop, tags: []});
    19. return;
    20. }
    21. // 取一次背景分类
    22. SELECT =`SELECT id, tag FROM material_tags WHERE type=1 ORDER BY tag DESC`;
    23. DB.query(SELECT, function(err, tags){
    24. if (err) {
    25. res.status(200).send({status:"err", data: [], tags: []});
    26. return;
    27. }
    28. res.status(200).send({status:"ok", data: Backdrop, tags: tags});
    29. })
    30. })
    31. });
    32. // 随机获取一个背景
    33. router.post('/getRandomBackdrop', function (req, res) {
    34. const SELECT = `SELECT name, md5, info0, info1, info2 FROM material_backdrop` +
    35. ` JOIN (SELECT MAX(id) AS maxId, MIN(id) AS minId FROM material_backdrop WHERE state=1) AS m ` +
    36. ` WHERE id >= ROUND(RAND()*(m.maxId - m.minId) + m.minId) AND state=1 LIMIT 1`;
    37. DB.query(SELECT, function(err, B){
    38. if (err || B.length < 1) {
    39. res.status(200).send({status:"err", data: {}});
    40. return;
    41. }
    42. res.status(200).send({status:"ok", data: B[0]});
    43. })
    44. });
    • 重要:数据库结构(仅提供MySQL版本):

    1. -- ----------------------------
    2. -- Table structure for material_backdrop
    3. -- ----------------------------
    4. DROP TABLE IF EXISTS `material_backdrop`;
    5. CREATE TABLE `material_backdrop` (
    6. `id` int unsigned NOT NULL AUTO_INCREMENT,
    7. `tagId` int unsigned NOT NULL COMMENT '分类ID',
    8. `name` char(32) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT '新背景',
    9. `md5` char(64) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL,
    10. `info0` int DEFAULT '960' COMMENT '图片宽',
    11. `info1` int DEFAULT '720' COMMENT '图片高',
    12. `info2` int DEFAULT '2',
    13. `state` tinyint DEFAULT '1' COMMENT '状态:0停用,1正常',
    14. PRIMARY KEY (`id`,`tagId`)
    15. ) ENGINE=InnoDB AUTO_INCREMENT=89 DEFAULT CHARSET=utf8;

    关于在服务器上怎么去增、删、改背景数据,则会后端不同而不同,大体上就是对上表的操作。

    主要是要明白各属性的意义,就可以灵活的操作了。

    说了这么多,也只大体上把Scratch背景怎么实现后台管理说了个大概,实在是篇幅有限,如有需要交流的,可加上面的群,一起交流。

    Scratch素材,除了背景,还有造型、声音、角色,(角色其实就是由N个造型+N个声音组合而成的,Scratch中的角色数据文件sprite.json实在是太累赘了,后面有空可以贴点这方面的数据结构分析方面的内容,这样就可以知道哪些数据有用了,Scratch的历史积累,有很多数据段,是完全没有意义也没用到的)。

    后续再慢慢把造型、声音、角色这方面的后台管理功能也放上来吧,大体上与背景管理差不多。

    想提前了解的,下载开源版本,就都有了。。。 。。。

    写在后面:

    如果本文章对您有帮助,请不吝点个赞再走(点赞不要钱,只管拼命赞)!!!

    您的支持,就是本人继续分享的源动力,后续内容更加硬核+精彩,请 收藏+关注 ,方便您及时看到更新的内容!!!

    Bailee 了个Bye!!!

  • 相关阅读:
    如何从一门编程语言过渡到另一门编程语言?
    python / pyside6 + pymysql 实现简单的个人资金管理系统
    程序员35岁之后有什么出路?
    Kafka的消息存储机制
    LeetCode 86. 分隔链表
    图片像素缩放,支持个性化自定义与精准比例调整,让图像处理更轻松便捷!
    布兰德 • 斯奈德节拍表
    云服务器 通过docker安装配置Nacos 图文操作
    《安全物联网系统设计》:我强烈建议你给你的物联网系统加一把安全锁
    【Java】包装类
  • 原文地址:https://blog.csdn.net/bailee/article/details/127662633