React + SpringBoot實現圖片預覽和視頻在線播放,其中視頻實現切片保存和分段播放

圖片預覽和視頻在線播放

需求描述

實現播放視頻的需求時,往往是前端直接加載一個mp4文件,這樣做法在遇到視頻文件較大時,容易造成卡頓,不能及時加載出來。我們可以將視頻進行切片,然后分段加載。播放一點加載一點,這樣同一時間內只會加載一小部分的視頻,不容易出現播放卡頓的問題。下面是實現方法。

對視頻切片使用的是 ffmpeg,可查看我的這個文章安裝使用

后端接口處理

后端需要處理的邏輯有

  1. 根據視頻的完整地址找到視頻源文件
  2. 根據視頻名稱進行MD5,在同級目錄下創建MD5文件夾,用于存放生成的索引文件和視頻切片
  3. 前端調用視頻預覽接口時先判斷有沒有索引文件
    1. 如果沒有,則先將mp4轉為ts,然后對ts進行切片處理并生成index.m3u8索引文件,然后刪除ts文件
    2. 如果有,則直接讀取ts文件寫入到響應頭,以流的方式返回給瀏覽器
  4. 加載視頻分片文件時會重復調用視頻預覽接口,需要對請求進來的參數做判斷,判斷是否是請求的索引還是分片

首先定義好接口,接收一個文件ID獲取到對應的文件信息

@ApiOperation("文件預覽")
@GetMapping("preview/{fileId}")
public void preview(@PathVariable String fileId, HttpServletResponse response) {if (fileId.endsWith(".ts")) {filePanService.readFileTs(fileId, response);} else {LambdaUpdateWrapper<FilePan> qw = new LambdaUpdateWrapper<>();qw.eq(FilePan::getFileId, fileId);FilePan one = filePanService.getOne(qw);if (ObjectUtil.isEmpty(one)) {throw new CenterExceptionHandler("文件不存在");}filePanService.preview(one, response);}
}

視頻信息如下圖

image-20240608111547732

在磁盤上對應的視頻

image-20240608111647759

數據庫中存放是視頻信息

image-20240608111720899

當點擊視頻時,前端會拿到當前的文件ID請求上面定義好的接口,此時 fielId 肯定不是以 ts 結尾,所以會根據這個 fileId 查詢數據庫中保存的這條記錄,然后調用 filePanService.preview(one, response) 方法

preview方法

preview方法主要處理的幾個事情

  1. 首先判斷文件類型是圖片還是視頻
  2. 如果是圖片是直接讀取圖片并返回流
  3. 如果是視頻
    1. 首先拿到視頻名稱,對名稱進行md5處理,并生成文件夾
    2. 創建視頻ts文件,并對ts進行切片和生成索引
  4. 加載分片文件時調用readFileTs方法
/*** 文件預覽*/
@Override
public void preview(FilePan filePan, HttpServletResponse response) {// 區分圖片還是視頻if (FileTypeUtil.isImage(filePan.getFileName())) {previewImg(filePan, response);} else if (FileTypeUtil.isVideo(filePan.getFileName())) {previewVideo(filePan, response);} else {throw new CenterExceptionHandler("該文件不支持預覽");}
}/*** 圖片預覽** @param filePan* @param response*/
private void previewImg(FilePan filePan, HttpServletResponse response) {if (StrUtil.isEmpty(filePan.getFileId())) {return;}// 源文件路徑String realTargetFile = filePan.getFilePath();File file = new File(filePan.getFilePath());if (!file.exists()) {return;}readFile(response, realTargetFile);
}/*** 視頻預覽** @param filePan* @param response*/
private void previewVideo(FilePan filePan, HttpServletResponse response) {// 根據文件名稱創建對應的MD5文件夾String md5Dir = FileChunkUtil.createMd5Dir(filePan.getFilePath());// 去這個目錄下查看是否有index.m3u8這個文件String m3u8Path = md5Dir + "/" + FileConstants.M3U8_NAME;if (!FileUtil.exist(m3u8Path)) {// 創建視頻ts文件createVideoTs(filePan.getFilePath(), filePan.getFileId(), md5Dir, response);} else {// 讀取切片文件readFile(response, m3u8Path);}
}// 創建視頻切片文件
private void createVideoTs(String videoPath, String fileId, String targetPath, HttpServletResponse response) {// 1.生成ts文件String video_2_TS = "ffmpeg -y -i %s -vcodec copy -acodec copy -bsf:v h264_mp4toannexb %s";String tsPath = targetPath + "/" + FileConstants.TS_NAME;String cmd = String.format(video_2_TS, videoPath, tsPath);ProcessUtils.executeCommand(cmd, false);// 2.創建切片文件String ts_chunk = "ffmpeg -i %s -c copy -map 0 -f segment -segment_list %s -segment_time 60 %s/%s_%%4d.ts";String m3u8Path = targetPath + "/" + FileConstants.M3U8_NAME;cmd = String.format(ts_chunk, tsPath, m3u8Path, targetPath, fileId);ProcessUtils.executeCommand(cmd, false);// 刪除index.ts文件FileUtil.del(tsPath);// 讀取切片文件readFile(response, m3u8Path);
}// 加載視頻切片文件
@Override
public void readFileTs(String tsFileId, HttpServletResponse response) {String[] tsArray = tsFileId.split("_");String videoFileId = tsArray[0];LambdaUpdateWrapper<FilePan> qw = new LambdaUpdateWrapper<>();qw.eq(FilePan::getFileId, videoFileId);FilePan one = this.getOne(qw);// 獲取文件對應的MD5文件夾地址String md5Dir = FileChunkUtil.createMd5Dir(one.getFilePath());// 去MD5目錄下讀取ts分片文件String tsFile = md5Dir + "/" + tsFileId;readFile(response, tsFile);
}

用到的幾個工具類代碼

FileTypeUtil

package com.szx.usercenter.util;/*** @author songzx* @create 2024-06-07 13:39*/
public class FileTypeUtil {/*** 是否是圖片類型的文件*/public static boolean isImage(String fileName) {String[] imageSuffix = {"jpg", "jpeg", "png", "gif", "bmp", "webp"};String suffix = fileName.substring(fileName.lastIndexOf(".") + 1);for (String s : imageSuffix) {if (s.equals(suffix)) {return true;}}return false;}/*** 是否是視頻文件*/public static boolean isVideo(String fileName) {String[] videoSuffix = {"mp4", "avi", "rmvb", "mkv", "flv", "wmv"};String suffix = fileName.substring(fileName.lastIndexOf(".") + 1);for (String s : videoSuffix) {if (s.equals(suffix)) {return true;}}return false;}
}

FileChunkUtil

package com.szx.usercenter.util;import cn.hutool.core.io.FileUtil;
import cn.hutool.crypto.digest.MD5;import java.io.File;/*** 文件上傳后的各種處理操作* @author songzx* @create 2024-06-07 13:25*/
public class FileChunkUtil {/*** 合并完文件后根據文件名稱創建MD5目錄* 用于存放文件縮略圖*/public static String createMd5Dir(String filePath) {File targetFile = new File(filePath);String md5Dir = MD5.create().digestHex(targetFile.getName());String targetDir = targetFile.getParent() + File.separator + md5Dir;FileUtil.mkdir(targetDir);return targetDir;}
}

readFile

/*** 讀取文件方法** @param response* @param filePath*/
public static void readFile(HttpServletResponse response, String filePath) {OutputStream out = null;FileInputStream in = null;try {File file = new File(filePath);if (!file.exists()) {return;}in = new FileInputStream(file);byte[] byteData = new byte[1024];out = response.getOutputStream();int len = 0;while ((len = in.read(byteData)) != -1) {out.write(byteData, 0, len);}out.flush();} catch (Exception e) {e.printStackTrace();} finally {if (out != null) {try {out.close();} catch (IOException e) {e.printStackTrace();}}if (in != null) {try {in.close();} catch (IOException e) {e.printStackTrace();}}}
}

ProcessUtils

這個方法用于執行CMD命令

package com.szx.usercenter.util;import com.szx.usercenter.handle.CenterExceptionHandler;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;/*** 可以執行命令行命令的工具** @author songzx* @create 2024-06-06 8:56*/
public class ProcessUtils {private static final Logger logger = LoggerFactory.getLogger(ProcessUtils.class);public static String executeCommand(String cmd, Boolean outPrintLog) {if (StringUtils.isEmpty(cmd)) {logger.error("--- 指令執行失敗!---");return null;}Runtime runtime = Runtime.getRuntime();Process process = null;try {process = Runtime.getRuntime().exec(cmd);// 取出輸出流PrintStream errorStream = new PrintStream(process.getErrorStream());PrintStream inputStream = new PrintStream(process.getInputStream());errorStream.start();inputStream.start();// 獲取執行的命令信息process.waitFor();// 獲取執行結果字符串String result = errorStream.stringBuffer.append(inputStream.stringBuffer + "\n").toString();// 輸出執行的命令信息if (outPrintLog) {logger.info("執行命令:{},已執行完畢,執行結果:{}", cmd, result);} else {logger.info("執行命令:{},已執行完畢", cmd);}return result;} catch (Exception e) {e.printStackTrace();throw new CenterExceptionHandler("命令執行失敗");} finally {if (null != process) {ProcessKiller processKiller = new ProcessKiller(process);runtime.addShutdownHook(processKiller);}}}private static class ProcessKiller extends Thread {private Process process;public ProcessKiller(Process process) {this.process = process;}@Overridepublic void run() {this.process.destroy();}}static class PrintStream extends Thread {InputStream inputStream = null;BufferedReader bufferedReader = null;StringBuffer stringBuffer = new StringBuffer();public PrintStream(InputStream inputStream) {this.inputStream = inputStream;}@Overridepublic void run() {try {if (null == inputStream) {return;}bufferedReader = new BufferedReader(new InputStreamReader(inputStream));String line = null;while ((line = bufferedReader.readLine()) != null) {stringBuffer.append(line);}} catch (Exception e) {logger.error("讀取輸入流出錯了!錯誤信息:" + e.getMessage());} finally {try {if (null != bufferedReader) {bufferedReader.close();}if (null != inputStream) {inputStream.close();}} catch (IOException e) {logger.error("關閉流時出錯!");}}}}
}

前端方法實現

前端使用的是React

定義圖片預覽組件 PreviewImage

import React, { forwardRef, useImperativeHandle } from 'react';
import {DownloadOutlined,UndoOutlined,RotateLeftOutlined,RotateRightOutlined,SwapOutlined,ZoomInOutlined,ZoomOutOutlined,
} from '@ant-design/icons';
import { Image, Space } from 'antd';const PreviewImage: React.FC = forwardRef((props, ref) => {const [src, setSrc] = React.useState('');const showPreview = (fileId: string) => {setSrc(`/api/pan/preview/${fileId}`);document.getElementById('previewImage').click();};useImperativeHandle(ref, () => {return {showPreview,};});const onDownload = () => {fetch(src).then((response) => response.blob()).then((blob) => {const url = URL.createObjectURL(new Blob([blob]));const link = document.createElement('a');link.href = url;link.download = 'image.png';document.body.appendChild(link);link.click();URL.revokeObjectURL(url);link.remove();});};return (<Imageid={'previewImage'}style={{ display: 'none' }}src={src}preview={{toolbarRender: (_,{transform: { scale },actions: {onFlipY,onFlipX,onRotateLeft,onRotateRight,onZoomOut,onZoomIn,onReset,},},) => (<Space size={12} className="toolbar-wrapper"><DownloadOutlined onClick={onDownload} /><SwapOutlined rotate={90} onClick={onFlipY} /><SwapOutlined onClick={onFlipX} /><RotateLeftOutlined onClick={onRotateLeft} /><RotateRightOutlined onClick={onRotateRight} /><ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} /><ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} /><UndoOutlined onClick={onReset} /></Space>),}}/>);
});export default PreviewImage;

定義視頻預覽組件

視頻預覽用到了 dplayer ,安裝

pnpm add dplayer hls.js
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import DPlayer from 'dplayer';
import './style/video-model.less';const Hls = require('hls.js');const PreviewVideo = forwardRef((props, ref) => {let dp = useRef();const [modal2Open, setModal2Open] = useState(false);const [fileId, setFileId] = useState('');const showPreview = (fileId) => {setFileId(fileId);setModal2Open(true);};const hideModal = () => {setModal2Open(false);};const clickModal = (e) => {if (e.target.dataset.tagName === 'parentBox') {hideModal();}};useEffect(() => {if (modal2Open) {console.log(fileId, 'videovideovideo');dp.current = new DPlayer({container: document.getElementById('video'), // 注意:這里一定要寫div的domlang: 'zh-cn',video: {url: `/api/pan/preview/${fileId}`, // 這里填寫.m3u8視頻連接type: 'customHls',customType: {customHls: function (video) {const hls = new Hls();hls.loadSource(video.src);hls.attachMedia(video);},},},});dp.current.play();}}, [modal2Open]);useImperativeHandle(ref, () => {return {showPreview,};});return (<>{modal2Open && (<div className={'video-box'} data-tag-name={'parentBox'} onClick={clickModal}><div id="video"></div><button className="ant-image-preview-close" onClick={hideModal}><span role="img" aria-label="close" className="anticon anticon-close"><svgfill-rule="evenodd"viewBox="64 64 896 896"focusable="false"data-icon="close"width="1em"height="1em"fill="currentColor"aria-hidden="true"><path d="M799.86 166.31c.02 0 .04.02.08.06l57.69 57.7c.04.03.05.05.06.08a.12.12 0 010 .06c0 .03-.02.05-.06.09L569.93 512l287.7 287.7c.04.04.05.06.06.09a.12.12 0 010 .07c0 .02-.02.04-.06.08l-57.7 57.69c-.03.04-.05.05-.07.06a.12.12 0 01-.07 0c-.03 0-.05-.02-.09-.06L512 569.93l-287.7 287.7c-.04.04-.06.05-.09.06a.12.12 0 01-.07 0c-.02 0-.04-.02-.08-.06l-57.69-57.7c-.04-.03-.05-.05-.06-.07a.12.12 0 010-.07c0-.03.02-.05.06-.09L454.07 512l-287.7-287.7c-.04-.04-.05-.06-.06-.09a.12.12 0 010-.07c0-.02.02-.04.06-.08l57.7-57.69c.03-.04.05-.05.07-.06a.12.12 0 01.07 0c.03 0 .05.02.09.06L512 454.07l287.7-287.7c.04-.04.06-.05.09-.06a.12.12 0 01.07 0z"></path></svg></span></button></div>)}</>);
});export default PreviewVideo;

父組件引入并使用

import PreviewImage from '@/components/Preview/PreviewImage';
import PreviewVideo from '@/components/Preview/PreviewVideo';const previewRef = useRef();
const previewVideoRef = useRef();// 點擊的是文件
const clickFile = async (item) => {// 預覽圖片if (isImage(item.fileType)) {previewRef.current.showPreview(item.fileId);return;}// 預覽視頻if (isVideo(item.fileType)) {previewVideoRef.current.showPreview(item.fileId);return;}message.error('暫不支持預覽該文件');
};// 點擊的文件夾
const clickFolder = (item) => {props.pushBread(item);  // 更新面包屑
};// 點擊某一行時觸發
const clickRow = (item: { fileType?: string }) => {if (item.fileType) {clickFile(item);} else {clickFolder(item);}
};<PreviewImage ref={previewRef} />
<PreviewVideo ref={previewVideoRef} />

判斷文件類型的方法

// 判斷文件是否為圖片
export function isImage(fileType): boolean {const imageTypes = ['.jpg', '.png', '.jpeg', '.gif', '.bmp', '.webp']return imageTypes.includes(fileType);
}// 判斷是否為視頻
export function isVideo(fileType): boolean {const videoTypes = ['.mp4', '.avi', '.rmvb', '.mkv', '.flv', '.wmv']return videoTypes.includes(fileType);
}

實現效果

圖片預覽效果

image-20240608121403097

視頻預覽效果

image-20240608121438971

并且在播放過程中是分段加載的視頻

image-20240608121550836

查看源文件,根據文件名創建一個MD5的文件夾

image-20240608121012668

文件夾中對視頻進行了分片處理,每一片都是以文件ID開頭,方便加載分片時找到分片對應的位置

image-20240608121809812

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/web/24505.shtml
繁體地址,請注明出處:http://hk.pswp.cn/web/24505.shtml
英文地址,請注明出處:http://en.pswp.cn/web/24505.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

tcp aimd 窗口的推導

舊事重提&#xff0c;今天用微分方程的數值解觀測 tcp aimd 窗口值。 設系統 AI&#xff0c;MD 參數分別為 a 1&#xff0c;b 0.5&#xff0c;丟包率由 buffer 大小&#xff0c;red 配置以及線路誤碼率共同決定&#xff0c;設為 p&#xff0c;窗口為 W&#xff0c;則有&…

云原生技術助力某國際化商業集團打造數字化轉型新引擎

某國際化商業集團&#xff08;以下簡稱&#xff1a;集團&#xff09;&#xff0c;成立于1988年&#xff0c;現已發展成為擁有總資產800多億元&#xff0c;員工13000多人&#xff0c;涵蓋港口碼頭、石油化工、國際貿易等產業于一體的國際化現代化企業集團&#xff0c;連續多年進…

HAL STM32F1 通過查表方式實現SVPWM驅動無刷電機測試

HAL STM32F1 通過查表方式實現SVPWM驅動無刷電機測試 &#x1f4cd;相關篇《基于開源項目HAL STM32F4 DSP庫跑SVPWM開環速度測試》 ?針對STM32F1系列&#xff0c;沒有專門的可依賴的DSP庫&#xff0c;為了實現特定函數的浮點運算快速計算&#xff0c;通過查表方式來實現&#…

番外篇 | 利用華為2023最新Gold-YOLO中的Gatherand-Distribute對特征融合模塊進行改進

前言:Hello大家好,我是小哥談。論文提出一種改進的信息融合機制Gather-and-Distribute (GD) ,通過全局融合多層特征并將全局信息注入高層,以提高YOLO系列模型的信息融合能力和檢測性能。通過引入MAE-style預訓練方法,進一步提高模型的準確性。?? 目錄 ??1.論文解…

如何解鎖植物大戰僵尸雜交版v2.0.88所有植物

如何解鎖植物大戰僵尸雜交版v2.0.88所有植物 前言安裝相關軟件快速解鎖方法 前言 經過探索植物大戰僵尸雜交版植物解鎖和關卡有關&#xff0c;所以通過所有關卡就可以解鎖所有植物。 安裝相關軟件 1.安裝植物大戰僵尸 2.安裝Hex Editor Neo 快速解鎖方法 本文參考如何修改…

<vs2022><問題記錄>visual studio 2022使用console打印輸出時,輸出窗口不顯示內容

前言 本文為問題記錄。 問題概述 在使用visual studio 2022編寫代碼時&#xff0c;如C#&#xff0c;在代碼中使用console.writeline來打印某些內容&#xff0c;以便于觀察&#xff0c;但發現輸出窗口不顯示&#xff0c;而代碼是完全沒有問題的。 解決辦法 根據網上提供的辦法…

深入解析力扣183題:從不訂購的客戶(LEFT JOIN與子查詢方法詳解)

在本篇文章中&#xff0c;我們將詳細解讀力扣第183題“從不訂購的客戶”。通過學習本篇文章&#xff0c;讀者將掌握如何使用SQL語句來解決這一問題&#xff0c;并了解相關的復雜度分析和模擬面試問答。每種方法都將配以詳細的解釋&#xff0c;以便于理解。 問題描述 力扣第18…

Java Web學習筆記23——Vue項目簡介

Vue項目簡介&#xff1a; Vue項目-創建&#xff1a; 命令行&#xff1a;vue create vue-project01 圖形化界面&#xff1a;vue ui 在命令行中切換到項目文件夾中&#xff0c;然后執行vue ui命令。 只需要路由功能。這個路由功能&#xff0c;開始不是很理解。 創建項目部保存…

html+css示例

HTML HTML&#xff08;超文本標記語言&#xff09;和CSS&#xff08;層疊樣式表&#xff09;是構建和設計網頁的兩種主要技術。HTML用于創建網頁的結構和內容&#xff0c;而CSS用于控制其外觀和布局。 HTML基礎 HTML使用標簽來標記網頁中的不同部分。每個標簽通常有一個開始…

【原創】海為PLC與RS-WS-ETH-6傳感器的MUDBUS_TCP通訊

點擊“藍字”關注我們吧 一、關于RS-WS-ETH-6傳感器的準備工作 要完成MODBUS_TCP通訊,我們必須要知道設備的IP地址如何分配,只有PLC和設備的IP在同一網段上,才能建立通訊。然后還要選擇TCP的工作模式,來建立設備端和PC端的端口號。接下來了解設備的報文格式,方便之后發送…

前端:快捷 復制chrome 控制臺打印出來的 數組對象

程序中console.log出來的對象。按照以下步驟操作 1.右鍵點擊需要處理的對象&#xff0c;會出現Store as global variable&#xff0c;點擊 2.點擊 Store as global variable 控制臺會出現 3.在控制臺 輸入 copy(temp1) 這樣對象就復制到了你的黏貼面板里面 在代碼中直接 c…

基于STM32開發的智能語音控制系統

目錄 引言環境準備智能語音控制系統基礎代碼實現&#xff1a;實現智能語音控制系統 4.1 語音識別模塊數據讀取4.2 設備控制4.3 實時數據監控與處理4.4 用戶界面與反饋顯示應用場景&#xff1a;語音控制的家居設備管理問題解決方案與優化收尾與總結 1. 引言 隨著人工智能技術…

Vuepress 2從0-1保姆級進階教程——標準化流程

Vuepress 2 專欄目錄 1. 入門階段 Vuepress 2從0-1保姆級入門教程——環境配置篇Vuepress 2從0-1保姆級入門教程——安裝流程篇Vuepress 2從0-1保姆級入門教程——文檔配置篇Vuepress 2從0-1保姆級入門教程——范例與部署 2.進階階段 Vuepress 2從0-1保姆級進階教程——全文搜索…

Inpaint9.1軟件下載附加詳細安裝教程

軟件簡介: Inpaint 是個人開發者Max開發的圖片處理軟件&#xff0c;可以高效去除水印&#xff0c;修復照片等。使用方法和操作都很簡單&#xff0c;非常適合不會PS等軟件的小白用戶。 安 裝 包 獲 取 地 址&#xff1a; Iinpaint win版&#xff1a;??https://souurl.cn/b…

了解JVM中的Server和Client參數

了解JVM中的Server和Client參數 Java虛擬機&#xff08;Java Virtual Machine&#xff0c;JVM&#xff09;作為Java程序運行的核心&#xff0c;提供了多種參數來優化和調整程序的性能和行為。其中&#xff0c;-server和-client是兩個重要的參數&#xff0c;分別用于配置JVM在服…

【Android面試八股文】synochnized修飾普通方法和靜態方法的區別?什么是原子性、可見性、有序性?

文章目錄 synochnized修飾普通方法和靜態方法的區別?什么是原子性、可見性、有序性?這道題想考察什么 ?考察的知識點考生應該如何回答synchronized 的基本原理synchronized 修飾普通方法和靜態方法的區別synchronized定義同步代碼塊synochnized修飾普通方法和靜態方法的區別…

微生物共生與致病性:動態變化與識別挑戰

谷禾健康 細菌耐藥性 抗生素耐藥性細菌感染的發生率正在上升&#xff0c;而新抗生素的開發由于種種原因在制藥行業受重視程度下降。 最新在《柳葉刀-微生物》&#xff08;The Lancet Microbe&#xff09;上&#xff0c;科學家提出了基于細菌適應性、競爭和傳播的生態原則的跨學…

Tongweb7重置密碼優化版*(by lqw )

如圖所示&#xff0c;輸入初始密碼是會報錯的&#xff0c;說明已經修改了密碼 首先我們先備份一下tongweb的安裝目錄&#xff0c;避免因為修改過程中出現的差錯而導致tongweb無法啟動&#xff1a; 備份好了之后&#xff0c;我們關閉掉tongweb。 方式一&#xff1a; Cd 到tong…

C# WPF入門學習主線篇(十)—— DataGrid常見屬性和事件

C# WPF入門學習主線篇&#xff08;十&#xff09;—— DataGrid常見屬性和事件 歡迎來到C# WPF入門學習系列的第十篇。在前面的文章中&#xff0c;我們已經學習了 Button、TextBox、Label、ListBox 和 ComboBox 控件。今天&#xff0c;我們將探討 WPF 中的另一個重要控件——D…

Python私教張大鵬 Vue3整合AntDesignVue之Anchor 錨點

用于跳轉到頁面指定位置。 何時使用 需要展現當前頁面上可供跳轉的錨點鏈接&#xff0c;以及快速在錨點之間跳轉。 案例&#xff1a;錨點的基本使用 核心代碼&#xff1a; <template><a-anchor:items"[{key: part-1,href: #part-1,title: () > h(span, {…