首页 > 编程语言 >使用react+node调用科大讯飞api实现实时语音听写(流式版)

使用react+node调用科大讯飞api实现实时语音听写(流式版)

时间:2024-08-05 15:56:35浏览次数:11  
标签:node console log FRAME react let const roomId 听写

前言--踩坑过程

一时间心血来潮,想用科大讯飞的api来做一个语音实时转文字,也是走了很多弯路,边写边查边生成,最后算是完成了。功能实现了但是没有做UI。

本来想试试光靠不要服务端光靠前端直接调用科大讯飞的api来实现,但是发现太慢了,四五秒才蹦出来一个字。

然后没办法,搭建了一个服务端,一开始用的是直接用上传的文件来做,但是也还是很慢,当然可能是我代码写得烂。

后面网上搜了一下,试着把上传的文件保存为pcm文件,然后读取pcm,快了特别多。

还有流式传输,一开始思路错了,我以为的是分段截取然后上传,但是这样识别的正确率简直是不堪入目。后面使用不暂停录音来截取而是直接上传目前已经录入的。

注册讯飞应用获取免费服务

控制台-讯飞开放平台 (xfyun.cn)

自行注册,如果一天500免费额度不够可以去买一个五万的免费的,一年内。

使用socket.io搭建服务

前面还有创建react项目我就跳过了。

下载socket.io和recorder

npm i js-audio-recorder
npm i socket.io-client

用户端搭建一个连接ws和recorder(录音的),同时加入房间。

  import Recorder from 'js-audio-recorder';
  import { useEffect, useState } from "react";
  import io from 'socket.io-client';
  const [roomId, setrooId] = useState('') // 定义 roomId 状态,初始值为空字符串
  const [ws, setWs] = useState(null) // 定义 ws 状态,用于存储 WebSocket 连接,初始值为 null
  const [recorder, setrecorder] = useState(null) // 定义 recorder 状态,用于存储录音器实例,初始值为 null
  useEffect(() => {
    // 创建新的 WebSocket 连接
    const socketIo = io(url);

    setWs(socketIo); // 保存 WebSocket 连接实例到 ws 状态中
    const roomid = new Date().getTime() // 获取当前时间的时间戳,作为房间 ID
    setrooId(roomid) // 保存房间 ID 到 roomId 状态中
    socketIo.emit('joinRoom', roomid) // 发送 joinRoom 事件,附带房间 ID,通知服务器加入房间
    socketIo.on('value', val => {
      setTemp(val); // 当从服务器接收到 value 事件时,更新 temp 状态
    })
    setrecorder(new Recorder({
      bitRate: 16, // 设置录音比特率为 16 kbps
      sampleRate: 16000, // 设置录音采样率为 16000 Hz
      bufferSize: 8192, // 设置录音缓冲区大小为 8192 字节
    }))
    // 清理函数,在组件卸载时断开 WebSocket 连接
    return () => {
      socketIo.disconnect();
    };
  }, [url]); // useEffect 钩子依赖 url,当 url 改变时重新执行

服务端

const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const { Server } = require('socket.io');
const cors = require('cors');
// 创建 Express 应用
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
    cors: {
        origin: '*',
        methods: ['GET', 'POST']
    }
});
// 配置 Socket.IO 事件
io.on('connection', (socket) => {
    console.log('a user connected');
    socket.on('disconnect', () => {
        console.log('user disconnected');
    });
    socket.on('joinRoom', (roomId) => {
        socket.join(roomId)
    })
});

// 启动服务器
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
    console.log(`Server is running on port ${PORT}`);
});

基本服务就起了。

录音并采用流式传输传递音频数据

现在开始就是要开始录音并且传递出去了。获取的Blob格式不适合用于传输,所以这里就转化成 base64 编码来传输。

注意这里的流式传输,最开始我写的是每隔两秒就结束录音然后截取发过去再重新启动录音,但是效果不好,这里采用的是200毫秒发送一次,但是不暂停录音器。这样就很有效果了。

  const startR = () => {
    console.log('开始');
    recorder.start(); // 开始录音
//流式传输**************
    const sendAudioData = () => {
      const pcmBlob = recorder.getPCMBlob(); // 获取 PCM 格式的音频 Blob 数据
      console.log(pcmBlob);

      const reader = new FileReader();
      reader.onload = () => {
        const arrayBuffer = reader.result; // 将 Blob 数据转换为 ArrayBuffer
        const base64 = arrayBufferToBase64(arrayBuffer); // 将 ArrayBuffer 转换为 base64 编码

        // 发送 base64 编码的音频数据到 WebSocket 服务器
        ws.emit('other', {
          roomId: roomId,
          value: base64 // 传递 base64 编码的音频数据
        });
      };
      reader.readAsArrayBuffer(pcmBlob); // 将 Blob 数据读取为 ArrayBuffer
    };

    const intervalId = setInterval(sendAudioData, 200); // 每 200 毫秒调用一次 sendAudioData 函数,发送音频数据
    setT(intervalId); // 保存定时器 ID 到 T 状态中
//流式传输**************
  };

  // 停止录音的函数
  const stopR = () => {
    clearInterval(T); // 清除定时器
    setT(null); // 将 T 状态重置为 null
    recorder.stop(); // 停止录音
  };

  // 将 ArrayBuffer 转换为 base64 编码的函数
  function arrayBufferToBase64(buffer) {
    let binary = '';
    const bytes = new Uint8Array(buffer);
    const len = bytes.byteLength;
    for (let i = 0; i < len; i++) {
      binary += String.fromCharCode(bytes[i]);
    }
    return window.btoa(binary); // 将二进制字符串转换为 base64 编码
  }

return (
    <>
      <div className='Box'>
        <button onClick={startR}>开始音频录制</button>
        <button onClick={stopR}>结束音频录制</button>
      </div>
      <div>{temp}</div>
    </>
  );

服务端连接星火服务端并传递音频

语音听写(流式版)WebAPI 文档 | 讯飞开放平台文档中心 (xfyun.cn)

具体的方式我是借鉴了一下这个博客

科大讯飞语音接口调用实现语音识别_科大讯飞语音识别接口-CSDN博客

我最开始是直接把上传的文件直接遍历每一帧来上传,但是发现很慢,而读取文件的形式能反应很快,然后我就选择先把上传获得的转存为pcm文件,然后再进行同样的操作。

const fs = require('fs');
const path = require('path');
//收到消息重新存为文件   
 socket.on('other', ({ value, roomId }) => {
        // 解码 Base64 数据
        const buffer = Buffer.from(value, 'base64');

        // 定义文件路径(例如:在 `public` 文件夹下)
        const filePath = path.join(__dirname, 'public', `${roomId}.pcm`);

        // 写入文件
        fs.writeFile(filePath, buffer, (err) => {
            if (err) {
                console.error('写入 PCM 文件失败:', err);
            } else {
                console.log('PCM 文件已成功保存:', filePath);
                let url = './public/' + roomId + '.pcm'
                SpeechToText(roomId, url, roomId, io)
            }
        });
    });

然后后面的和博客的差不多

const CryptoJS = require('crypto-js');
// 系统配置 
const config = {
    hostUrl: "wss://iat-api.xfyun.cn/v2/iat",
    host: "iat-api.xfyun.cn",
    appid: "",//看控制台
    apiSecret: "",//看控制台
    apiKey: "",//看控制台
    uri: "/v2/iat",
    highWaterMark: 1280
};

// 帧定义
const FRAME = {
    STATUS_FIRST_FRAME: 0,
    STATUS_CONTINUE_FRAME: 1,
    STATUS_LAST_FRAME: 2
};


const SpeechToText = (roomId, url, name, io) => {
    console.log(url)
    // 获取当前时间 RFC1123格式
    let date = (new Date().toUTCString())
    // 设置当前临时状态为初始化
    let status = FRAME.STATUS_FIRST_FRAME
    // 记录本次识别用sid
    let currentSid = ""
    // 识别结果
    let iatResult = []
    let str = ""
    let wssUrl = config.hostUrl + "?authorization=" + getAuthStr(date) + "&date=" + date + "&host=" + config.host
    let ws = new WebSocket(wssUrl)

    // 连接建立完毕,读取数据进行识别
    ws.on('open', (event) => {
        console.log("websocket connect!")
        var readerStream = fs.createReadStream(url, {
            highWaterMark: config.highWaterMark
        });
        readerStream.on('data', function (chunk) {
            // console.log(chunk)
            send(chunk)
        });
        // 最终帧发送结束
        readerStream.on('end', function () {
            status = FRAME.STATUS_LAST_FRAME
            send("")
        });
    })

    ws.on('message', (data, err) => {
        if (err) {
            console.log(`err:${err}`);
            return;
        }

        let res = JSON.parse(data);
        if (res.code != 0) {
            console.log(`error code ${res.code}, reason ${res.message}`);
            return;
        }

        if (res.data.status == 2) {
            // 识别完成
            console.log("最终识别结果");
            currentSid = res.sid;
            ws.close();
        } else {
            // 识别中
            // console.log("中间识别结果");
        }

        iatResult[res.data.result.sn] = res.data.result;

        if (res.data.result.pgs == 'rpl') {
            // 处理动态修正
            res.data.result.rg.forEach(i => {
                iatResult[i] = null;
            });
            // console.log("【动态修正】");
        }
        str = ""
        // 逐字打印
        iatResult.forEach(i => {
            if (i != null) {
                i.ws.forEach(j => {
                    j.cw.forEach(k => {
                        console.log(k.w); // 打印每个字
                        str += k.w
                    });
                });
            }
        });
        console.log('完整语句是:' + str)
    });

    // 资源释放
    ws.on('close', () => {
        console.log(`本次识别sid:${currentSid}`)
        io.to(roomId).emit('value', str)



        console.log('connect close!')
    })

    // 建连错误
    ws.on('error', (err) => {
        console.log("websocket connect err: " + err)
    })

    // 鉴权签名
    function getAuthStr(date) {
        let signatureOrigin = `host: ${config.host}\ndate: ${date}\nGET ${config.uri} HTTP/1.1`
        let signatureSha = CryptoJS.HmacSHA256(signatureOrigin, config.apiSecret)
        let signature = CryptoJS.enc.Base64.stringify(signatureSha)
        let authorizationOrigin = `api_key="${config.apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"`
        let authStr = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(authorizationOrigin))
        return authStr
    }

    // 传输数据
    function send(data) {
        let frame = "";
        let frameDataSection = {
            "status": status,
            "format": "audio/L16;rate=16000",
            "audio": data.toString('base64'),
            "encoding": "raw"
        }
        switch (status) {
            case FRAME.STATUS_FIRST_FRAME:
                frame = {
                    // 填充common
                    common: {
                        app_id: config.appid
                    },
                    //填充business
                    business: {
                        language: "zh_cn",
                        domain: "iat",
                        accent: "mandarin",
                        dwa: "wpgs" // 可选参数,动态修正
                    },
                    //填充data
                    data: frameDataSection
                }
                status = FRAME.STATUS_CONTINUE_FRAME;
                break;
            case FRAME.STATUS_CONTINUE_FRAME:
            case FRAME.STATUS_LAST_FRAME:
                //填充frame
                frame = {
                    data: frameDataSection
                }
                break;
        }
        ws.send(JSON.stringify(frame))
    }
}

然后就可以实现基本的功能了!

完整代码

用户端

import Recorder from 'js-audio-recorder';
import { useEffect, useState } from "react";
import io from 'socket.io-client';

function App() {

  // 导入 React 库中的 useState 和 useEffect 钩子
  const [roomId, setrooId] = useState('') // 定义 roomId 状态,初始值为空字符串
  const [ws, setWs] = useState(null) // 定义 ws 状态,用于存储 WebSocket 连接,初始值为 null
  const [recorder, setrecorder] = useState(null) // 定义 recorder 状态,用于存储录音器实例,初始值为 null
  const url = 'http://127.0.0.1:3000' // 定义 WebSocket 服务器的 URL
  const [T, setT] = useState(null) // 定义 T 状态,用于存储定时器 ID,初始值为 null
  const [temp, setTemp] = useState("") // 定义 temp 状态,用于存储从服务器接收到的值,初始值为空字符串

  useEffect(() => {
    // 创建新的 WebSocket 连接
    const socketIo = io(url);

    setWs(socketIo); // 保存 WebSocket 连接实例到 ws 状态中
    const T = new Date().getTime() // 获取当前时间的时间戳,作为房间 ID
    setrooId(T) // 保存房间 ID 到 roomId 状态中
    socketIo.emit('joinRoom', T) // 发送 joinRoom 事件,附带房间 ID,通知服务器加入房间
    socketIo.on('value', val => {
      setTemp(val); // 当从服务器接收到 value 事件时,更新 temp 状态
    })
    setrecorder(new Recorder({
      bitRate: 16, // 设置录音比特率为 16 kbps
      sampleRate: 16000, // 设置录音采样率为 16000 Hz
      bufferSize: 8192, // 设置录音缓冲区大小为 8192 字节
    }))
    // 清理函数,在组件卸载时断开 WebSocket 连接
    return () => {
      socketIo.disconnect();
    };
  }, [url]); // useEffect 钩子依赖 url,当 url 改变时重新执行

  // 开始录音的函数
  const startR = () => {
    console.log('开始');
    recorder.start(); // 开始录音

    const sendAudioData = () => {
      const pcmBlob = recorder.getPCMBlob(); // 获取 PCM 格式的音频 Blob 数据
      console.log(pcmBlob);

      const reader = new FileReader();
      reader.onload = () => {
        const arrayBuffer = reader.result; // 将 Blob 数据转换为 ArrayBuffer
        const base64 = arrayBufferToBase64(arrayBuffer); // 将 ArrayBuffer 转换为 base64 编码

        // 发送 base64 编码的音频数据到 WebSocket 服务器
        ws.emit('other', {
          roomId: roomId,
          value: base64 // 传递 base64 编码的音频数据
        });
      };
      reader.readAsArrayBuffer(pcmBlob); // 将 Blob 数据读取为 ArrayBuffer
    };

    const intervalId = setInterval(sendAudioData, 200); // 每 200 毫秒调用一次 sendAudioData 函数,发送音频数据
    setT(intervalId); // 保存定时器 ID 到 T 状态中
  };

  // 停止录音的函数
  const stopR = () => {
    clearInterval(T); // 清除定时器
    setT(null); // 将 T 状态重置为 null
    recorder.stop(); // 停止录音
  };

  // 将 ArrayBuffer 转换为 base64 编码的函数
  function arrayBufferToBase64(buffer) {
    let binary = '';
    const bytes = new Uint8Array(buffer);
    const len = bytes.byteLength;
    for (let i = 0; i < len; i++) {
      binary += String.fromCharCode(bytes[i]);
    }
    return window.btoa(binary); // 将二进制字符串转换为 base64 编码
  }

  return (
    <>
      <div className='Box'>
        <button onClick={startR}>开始音频录制</button>
        <button onClick={stopR}>结束音频录制</button>
      </div>
      <div>{temp}</div>
    </>
  );
}

export default App;

服务端

const express = require('express');
const http = require('http');
const CryptoJS = require('crypto-js');
const WebSocket = require('ws');
const { Server } = require('socket.io');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
// 创建 Express 应用
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
    cors: {
        origin: '*',
        methods: ['GET', 'POST']
    }
});

// 使用 CORS 中间件
app.use(cors({
    origin: '*'
}));

// 系统配置 
const config = {
    hostUrl: "wss://iat-api.xfyun.cn/v2/iat",
    host: "iat-api.xfyun.cn",
    appid: "",//看控制台
    apiSecret: "",//看控制台
    apiKey: "",//看控制台
    uri: "/v2/iat",
    highWaterMark: 1280
};

// 帧定义
const FRAME = {
    STATUS_FIRST_FRAME: 0,
    STATUS_CONTINUE_FRAME: 1,
    STATUS_LAST_FRAME: 2
};


const SpeechToText = (roomId, url, name, io) => {
    console.log(url)
    // 获取当前时间 RFC1123格式
    let date = (new Date().toUTCString())
    // 设置当前临时状态为初始化
    let status = FRAME.STATUS_FIRST_FRAME
    // 记录本次识别用sid
    let currentSid = ""
    // 识别结果
    let iatResult = []
    let str = ""
    let wssUrl = config.hostUrl + "?authorization=" + getAuthStr(date) + "&date=" + date + "&host=" + config.host
    let ws = new WebSocket(wssUrl)

    // 连接建立完毕,读取数据进行识别
    ws.on('open', (event) => {
        console.log("websocket connect!")
        var readerStream = fs.createReadStream(url, {
            highWaterMark: config.highWaterMark
        });
        readerStream.on('data', function (chunk) {
            // console.log(chunk)
            send(chunk)
        });
        // 最终帧发送结束
        readerStream.on('end', function () {
            status = FRAME.STATUS_LAST_FRAME
            send("")
        });
    })

    ws.on('message', (data, err) => {
        if (err) {
            console.log(`err:${err}`);
            return;
        }

        let res = JSON.parse(data);
        if (res.code != 0) {
            console.log(`error code ${res.code}, reason ${res.message}`);
            return;
        }

        if (res.data.status == 2) {
            // 识别完成
            console.log("最终识别结果");
            currentSid = res.sid;
            ws.close();
        } else {
            // 识别中
            // console.log("中间识别结果");
        }

        iatResult[res.data.result.sn] = res.data.result;

        if (res.data.result.pgs == 'rpl') {
            // 处理动态修正
            res.data.result.rg.forEach(i => {
                iatResult[i] = null;
            });
            // console.log("【动态修正】");
        }
        str = ""
        // 逐字打印
        iatResult.forEach(i => {
            if (i != null) {
                i.ws.forEach(j => {
                    j.cw.forEach(k => {
                        console.log(k.w); // 打印每个字
                        str += k.w
                    });
                });
            }
        });
        console.log('完整语句是:' + str)
    });

    // 资源释放
    ws.on('close', () => {
        console.log(`本次识别sid:${currentSid}`)
        io.to(roomId).emit('value', str)
        console.log('connect close!')
    })

    // 建连错误
    ws.on('error', (err) => {
        console.log("websocket connect err: " + err)
    })

    // 鉴权签名
    function getAuthStr(date) {
        let signatureOrigin = `host: ${config.host}\ndate: ${date}\nGET ${config.uri} HTTP/1.1`
        let signatureSha = CryptoJS.HmacSHA256(signatureOrigin, config.apiSecret)
        let signature = CryptoJS.enc.Base64.stringify(signatureSha)
        let authorizationOrigin = `api_key="${config.apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"`
        let authStr = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(authorizationOrigin))
        return authStr
    }

    // 传输数据
    function send(data) {
        let frame = "";
        let frameDataSection = {
            "status": status,
            "format": "audio/L16;rate=16000",
            "audio": data.toString('base64'),
            "encoding": "raw"
        }
        switch (status) {
            case FRAME.STATUS_FIRST_FRAME:
                frame = {
                    // 填充common
                    common: {
                        app_id: config.appid
                    },
                    //填充business
                    business: {
                        language: "zh_cn",
                        domain: "iat",
                        accent: "mandarin",
                        dwa: "wpgs" // 可选参数,动态修正
                    },
                    //填充data
                    data: frameDataSection
                }
                status = FRAME.STATUS_CONTINUE_FRAME;
                break;
            case FRAME.STATUS_CONTINUE_FRAME:
            case FRAME.STATUS_LAST_FRAME:
                //填充frame
                frame = {
                    data: frameDataSection
                }
                break;
        }
        ws.send(JSON.stringify(frame))
    }
}




// 配置 Socket.IO 事件
io.on('connection', (socket) => {
    console.log('a user connected');
    socket.on('disconnect', () => {
        console.log('user disconnected');
    });
    socket.on('joinRoom', (roomId) => {
        socket.join(roomId)
    })
    socket.on('other', ({ value, roomId }) => {
        // 解码 Base64 数据
        const buffer = Buffer.from(value, 'base64');

        // 定义文件路径(例如:在 `public` 文件夹下)
        const filePath = path.join(__dirname, 'public', `${roomId}.pcm`);

        // 写入文件
        fs.writeFile(filePath, buffer, (err) => {
            if (err) {
                console.error('写入 PCM 文件失败:', err);
            } else {
                console.log('PCM 文件已成功保存:', filePath);
                let url = './public/' + roomId + '.pcm'
                SpeechToText(roomId, url, roomId, io)
            }
        });
    });
});

// 设置静态文件目录(可选)
// app.use(express.static('public'));

// 启动服务器
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
    console.log(`Server is running on port ${PORT}`);
});

标签:node,console,log,FRAME,react,let,const,roomId,听写
From: https://blog.csdn.net/m0_73459939/article/details/140926019

相关文章

  • prometheus中的node_exporter中linux系统中取磁盘使用率
    (((node_filesystem_size_bytes{job="exp-server-node",mountpoint=~".*",fstype=~"ext4|xfs|ext2|ext3|tmpfs"}-node_filesystem_free_bytes{job="exp-server-node",mountpoint=~".*",fstype=~"ext4|xfs|ext2|ext3|t......
  • nodejs遇到的一个小问题分享给大家
    今天在调试项目的时候突然发现  const{name}=ctx.request.body 无法接收到参数了,后来检查了一下代码发现路由中间件和bodyparser中间件的加载顺序错了,导致无法接收参数,正确应该是这样:app.use(bodyParser());app.use(router.routes()).use(router.allowedMethods());......
  • nvm--node【 node.js version management】node.js的版本管理工具
    1.卸载node如果你已经安装了node,那么你需要先卸载node(不然安装nvm可能会失败),如果你没有安装那直接跳过这一步到下一步。打开控制面板->打开程序和功能->右上角搜索输入node->右键卸载为了确保彻底删除node在看看你的node安装目录中还有没有node文件夹,有的话一起删除。再......
  • 基于nodejs+vue家庭财务管理系统[程序+论文+开题]-计算机毕业设计
    本系统(程序+源码+数据库+调试部署+开发环境)带文档lw万字以上,文末可获取源码系统程序文件列表开题报告内容研究背景随着家庭经济的不断发展和复杂化,家庭成员间的财务管理逐渐成为一项重要而繁琐的任务。传统的手工记账方式不仅效率低下,而且难以实现家庭成员间财务信息的共......
  • 基于nodejs+vue家庭财务管理系统[程序+论文+开题]-计算机毕业设计
    本系统(程序+源码+数据库+调试部署+开发环境)带文档lw万字以上,文末可获取源码系统程序文件列表开题报告内容研究背景随着现代家庭经济的日益复杂,家庭成员对于个人及家庭财务状况的掌握与管理需求日益增长。传统的财务管理方式往往依赖于纸质账本或简单的电子表格,这种方式不......
  • 基于nodejs+vue家庭财务管理系统[程序+论文+开题]-计算机毕业设计
    本系统(程序+源码+数据库+调试部署+开发环境)带文档lw万字以上,文末可获取源码系统程序文件列表开题报告内容研究背景随着社会经济的快速发展和家庭结构的多元化,家庭财务管理成为每个家庭日常生活中不可或缺的一部分。然而,传统的手工记账方式已难以满足现代家庭对财务管理高......
  • 基于nodejs+vue家庭健康预警系统[程序+论文+开题]-计算机毕业设计
    本系统(程序+源码+数据库+调试部署+开发环境)带文档lw万字以上,文末可获取源码系统程序文件列表开题报告内容研究背景随着现代生活节奏的加快和人口老龄化的加剧,家庭成员的健康问题日益成为社会各界关注的焦点。传统的健康管理方式往往依赖于个人自觉和定期体检,但在面对突发......
  • 基于nodejs+vue家教管理系统[程序+论文+开题]-计算机毕业设计
    本系统(程序+源码+数据库+调试部署+开发环境)带文档lw万字以上,文末可获取源码系统程序文件列表开题报告内容研究背景随着教育市场的日益繁荣和家庭教育需求的不断增长,家教作为一种灵活高效的教育服务形式,在提升学生学习成绩、培养综合素质方面发挥着重要作用。然而,传统家......
  • 基于nodejs+vue家居产品的进销存系统[程序+论文+开题]-计算机毕业设计
    本系统(程序+源码+数据库+调试部署+开发环境)带文档lw万字以上,文末可获取源码系统程序文件列表开题报告内容研究背景随着科技的飞速发展和生活水平的提高,家居市场迎来了前所未有的繁荣。然而,传统家居产品的进销存管理方式逐渐暴露出效率低下、信息滞后、成本高昂等问题。......
  • 基于nodejs+vue家具商城系统[程序+论文+开题]-计算机毕业设计
    本系统(程序+源码+数据库+调试部署+开发环境)带文档lw万字以上,文末可获取源码系统程序文件列表开题报告内容研究背景随着互联网技术的飞速发展和电子商务的普及,线上购物已成为人们日常生活中不可或缺的一部分。在家居消费领域,传统的家具购买方式往往受限于地理位置、时间......