| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434 |
- <template>
- <div class="audio-asr-wrapper">
- <div class="content-header">
- <div class="title-info">
- <h2 class="main-title">音频 ASR 识别</h2>
- <p class="sub-title">支持多种格式音频上传,利用 GPU 加速提取语音文本</p>
- </div>
- </div>
- <el-row :gutter="20">
- <el-col :xs="24" :sm="24" :md="8" :lg="7">
- <el-card shadow="never" class="config-card">
- <div slot="header" class="card-header">
- <span><i class="el-icon-setting"></i> 任务配置</span>
- </div>
- <el-form label-position="top">
- <el-form-item label="音频上传">
- <el-upload
- class="audio-uploader-v2"
- drag
- action=""
- :auto-upload="false"
- :show-file-list="true"
- :limit="1"
- accept="audio/*"
- :on-change="handleFileChange"
- >
- <i class="el-icon-upload"></i>
- <div class="el-upload__text">拖拽音频到此处 或 <em>点击上传</em></div>
- <div class="el-upload__tip" slot="tip">建议格式:mp3, wav (不超过 50MB)</div>
- </el-upload>
- </el-form-item>
- <el-button
- type="primary"
- class="submit-btn"
- :loading="submitting"
- :disabled="!file"
- @click="submitAsrTask"
- >
- {{ submitting ? '正在上传...' : '提交识别任务' }}
- </el-button>
- </el-form>
- <transition name="el-zoom-in-top">
- <div v-if="taskId" class="task-monitor-box">
- <div class="monitor-header">
- <span class="status-dot" :class="status"></span>
- <span class="status-label">{{ statusText }}</span>
- </div>
- <div class="task-details">
- <p>任务 ID: <code>{{ taskId }}</code></p>
- <el-progress
- v-if="status !== 'completed' && status !== 'error'"
- :percentage="status === 'processing' ? 70 : 30"
- :status="status === 'error' ? 'exception' : ''"
- :stroke-width="8"
- striped
- striped-animated
- ></el-progress>
- </div>
- </div>
- </transition>
- </el-card>
- </el-col>
- <el-col :xs="24" :sm="24" :md="16" :lg="17">
- <el-card shadow="never" v-if="asrResult" class="result-card">
- <div slot="header" class="card-header-flex">
- <div class="header-left">
- <span class="result-title">识别结果</span>
- <el-tag size="mini" effect="plain" class="duration-tag">时长: {{ asrResult.duration.toFixed(1) }}s</el-tag>
- <el-button
- type="text"
- size="small"
- icon="el-icon-document"
- @click="showFullText = true"
- >全文预览</el-button>
- </div>
- <div class="custom-model-tag">
- <i class="el-icon-cpu"></i> GPU 识别加速
- </div>
- </div>
- <div class="audio-control-section">
- <audio ref="audioPlayer" controls :src="asrResult.audio_url" class="modern-audio-player"></audio>
- </div>
- <div class="srt-list-wrapper">
- <div class="srt-list-header">
- <span class="h-time">起始时间</span>
- <span class="h-text">识别内容</span>
- </div>
- <div class="srt-scroll-area">
- <div
- v-for="(item, index) in asrResult.srt"
- :key="index"
- class="srt-entry"
- @click="seekAudio(item.time)"
- >
- <div class="entry-time">
- <span class="time-badge">{{ formatTimeLabel(item.time) }}</span>
- </div>
- <div class="entry-content">
- <p class="text">{{ item.text }}</p>
- <el-tooltip content="复制此行" placement="top">
- <i class="el-icon-copy-document copy-icon" @click.stop="handleCopy(item.text)"></i>
- </el-tooltip>
- </div>
- </div>
- </div>
- </div>
- </el-card>
- <div v-else class="empty-placeholder">
- <el-empty :description="emptyDescription">
- <template #image>
- <i class="el-icon-service" style="font-size: 60px; color: #dcdfe6;"></i>
- </template>
- </el-empty>
- </div>
- </el-col>
- </el-row>
- <el-dialog
- title="识别全文预览"
- :visible.sync="showFullText"
- width="50%"
- custom-class="full-text-dialog"
- append-to-body
- >
- <div class="full-text-body">
- {{ asrResult ? asrResult.text : '' }}
- </div>
- <div slot="footer">
- <el-button @click="showFullText = false">关闭</el-button>
- <el-button type="primary" icon="el-icon-document-copy" @click="handleCopy(asrResult.text)">复制全文</el-button>
- </div>
- </el-dialog>
- </div>
- </template>
- <script>
- import axios from 'axios';
- export default {
- name: 'AudioAsrDetail',
- data() {
- return {
- submitting: false,
- file: null,
- taskId: '',
- status: '',
- asrResult: null,
- timer: null,
- showFullText: false
- };
- },
- computed: {
- statusText() {
- const maps = { 'queued': '队列中', 'processing': '正在提取语音...', 'completed': '识别成功', 'error': '识别失败' };
- return maps[this.status] || '等待操作';
- },
- emptyDescription() {
- if (this.status === 'processing' || this.status === 'queued') return '正在努力识别中,请稍后...';
- return '暂无数据,请在左侧上传音频文件';
- }
- },
- beforeDestroy() {
- this.stopPolling();
- },
- methods: {
- handleFileChange(file) {
- this.file = file.raw;
- this.taskId = '';
- this.status = '';
- this.asrResult = null;
- this.stopPolling();
- },
- async submitAsrTask() {
- this.submitting = true;
- try {
- const formData = new FormData();
- formData.append('file', this.file);
- const res = await axios.post('/api1/audio/asr', formData, {
- headers: { 'Content-Type': 'multipart/form-data' }
- });
- this.taskId = res.data.task_id;
- this.status = res.data.status;
- this.$message.success('任务已加入 GPU 队列');
- this.startPolling();
- } catch (error) {
- this.$message.error('提交失败');
- } finally {
- this.submitting = false;
- }
- },
- startPolling() {
- this.stopPolling();
- this.timer = setInterval(this.fetchAsrResult, 3000);
- },
- stopPolling() {
- if (this.timer) clearInterval(this.timer);
- },
- async fetchAsrResult() {
- try {
- const res = await axios.get('/api1/audio/result/' + this.taskId);
- if (res.data && (res.data.srt || res.data.text)) {
- this.asrResult = res.data;
- this.status = 'completed';
- this.stopPolling();
- }
- } catch (e) { /* 继续轮询 */ }
- },
- // 提取时间戳并跳转播放
- seekAudio(timeStr) {
- const player = this.$refs.audioPlayer;
- if (!player || !timeStr) return;
- // 假设格式为 "00:00:01,500 --> ..."
- const startPart = timeStr.split('-->')[0].trim().replace(',', '.');
- const parts = startPart.split(':');
- const seconds = (+parts[0]) * 3600 + (+parts[1]) * 60 + (+parts[2]);
- player.currentTime = seconds;
- player.play();
- },
- formatTimeLabel(timeStr) {
- return timeStr.split('-->')[0].trim().split(',')[0]; // 简化显示为 HH:mm:ss
- },
- async handleCopy(text) {
- try {
- await navigator.clipboard.writeText(text);
- this.$message.success('已复制到剪贴板');
- } catch (e) {
- const input = document.createElement('textarea');
- input.value = text;
- document.body.appendChild(input);
- input.select();
- document.execCommand('copy');
- document.body.removeChild(input);
- this.$message.success('已复制到剪贴板');
- }
- }
- }
- };
- </script>
- <style scoped>
- .audio-asr-wrapper {
- padding: 0 4px;
- }
- /* 标题区 */
- .content-header {
- margin-bottom: 24px;
- padding-bottom: 16px;
- border-bottom: 1px solid #ebeef5;
- }
- .main-title {
- margin: 0;
- font-size: 20px;
- color: #303133;
- }
- .sub-title {
- margin: 8px 0 0;
- font-size: 13px;
- color: #909399;
- }
- /* 卡片通用 */
- .config-card, .result-card {
- border-radius: 8px;
- border: 1px solid #ebeef5;
- }
- /* 上传区优化 */
- .audio-uploader-v2 {
- width: 100%;
- }
- ::v-deep .el-upload-dragger {
- width: 100%;
- height: 160px;
- }
- ::v-deep .el-upload-dragger .el-icon-upload {
- margin: 20px 0 10px;
- }
- .submit-btn {
- width: 100%;
- padding: 12px;
- font-weight: bold;
- }
- /* 状态监控 */
- .task-monitor-box {
- margin-top: 20px;
- padding: 16px;
- background: #f8f9fb;
- border-radius: 6px;
- }
- .monitor-header {
- display: flex;
- align-items: center;
- margin-bottom: 12px;
- }
- .status-dot {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- margin-right: 8px;
- background: #909399;
- }
- .status-dot.processing { background: #409EFF; box-shadow: 0 0 5px #409EFF; }
- .status-dot.completed { background: #67C23A; }
- .status-label { font-size: 14px; font-weight: 500; }
- .task-details { font-size: 12px; color: #606266; }
- .task-details code { background: #eee; padding: 2px 4px; border-radius: 3px; }
- /* 结果区 Header */
- .card-header-flex {
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
- .result-title {
- font-weight: bold;
- font-size: 16px;
- }
- .duration-tag {
- margin-left: 12px;
- }
- /* 播放器 */
- .audio-control-section {
- background: #f0f2f5;
- padding: 12px;
- border-radius: 4px;
- margin-bottom: 20px;
- }
- .modern-audio-player {
- width: 100%;
- }
- /* SRT 列表 */
- .srt-list-wrapper {
- border: 1px solid #f0f0f0;
- border-radius: 4px;
- }
- .srt-list-header {
- display: flex;
- padding: 10px 16px;
- background: #fafafa;
- border-bottom: 1px solid #f0f0f0;
- color: #909399;
- font-size: 13px;
- font-weight: 500;
- }
- .h-time { width: 120px; }
- .srt-scroll-area {
- max-height: 480px;
- overflow-y: auto;
- }
- .srt-entry {
- display: flex;
- padding: 14px 16px;
- border-bottom: 1px solid #f9f9f9;
- cursor: pointer;
- transition: all 0.2s;
- }
- .srt-entry:hover {
- background-color: #f5f7fa;
- }
- .entry-time {
- width: 120px;
- flex-shrink: 0;
- }
- .time-badge {
- font-family: monospace;
- font-size: 12px;
- color: #409EFF;
- background: #ecf5ff;
- padding: 2px 6px;
- border-radius: 4px;
- }
- .entry-content {
- flex: 1;
- display: flex;
- justify-content: space-between;
- }
- .text {
- margin: 0;
- font-size: 14px;
- line-height: 1.6;
- color: #303133;
- }
- .copy-icon {
- margin-left: 10px;
- color: #c0c4cc;
- cursor: pointer;
- visibility: hidden;
- }
- .srt-entry:hover .copy-icon {
- visibility: visible;
- }
- .copy-icon:hover {
- color: #409EFF;
- }
- /* 渐变标签 */
- .custom-model-tag {
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white !important;
- font-size: 12px;
- padding: 4px 12px;
- border-radius: 20px;
- font-weight: 500;
- }
- .full-text-body {
- white-space: pre-wrap;
- line-height: 1.8;
- background: #f8f9fb;
- padding: 20px;
- border-radius: 4px;
- max-height: 400px;
- overflow-y: auto;
- }
- .empty-placeholder {
- padding: 80px 0;
- background: #fff;
- border-radius: 8px;
- }
- </style>
|