feat: 添加语音生成历史页面的下载到指定目录和批量下载功能
后端新增功能: � download_audio_to_directory - 下载单个音频文件到指定目录 � batch_download_audio_to_directory - 批量下载音频文件到指定目录 � 智能文件名生成,基于文本内容和记录ID �️ 文件名安全处理,移除非法字符 前端新增功能: � 批量选择模式,支持选择多个已完成的记录 � 全选/取消全选功能 � 下载到指定目录(使用文件夹选择对话框) ⚡ 快速下载(原浏览器下载方式) � 批量下载进度显示和状态管理 用户体验改进: ✅ 双重下载选项:快速下载 + 指定目录下载 ✅ 批量操作界面,支持选择和批量下载 ✅ 智能文件命名,包含文本内容片段 ✅ 完整的错误处理和用户反馈 ✅ 响应式UI设计,适配不同操作模式 技术实现: - 使用Tauri的文件夹选择API - 异步批量下载处理 - 状态管理和UI交互优化 - 类型安全的TypeScript实现
This commit is contained in:
parent
afb7ff538d
commit
f025f1daf8
|
|
@ -435,6 +435,8 @@ pub fn run() {
|
||||||
commands::voice_clone_commands::get_voices,
|
commands::voice_clone_commands::get_voices,
|
||||||
commands::voice_clone_commands::generate_speech,
|
commands::voice_clone_commands::generate_speech,
|
||||||
commands::voice_clone_commands::download_audio,
|
commands::voice_clone_commands::download_audio,
|
||||||
|
commands::voice_clone_commands::download_audio_to_directory,
|
||||||
|
commands::voice_clone_commands::batch_download_audio_to_directory,
|
||||||
commands::voice_clone_commands::get_speech_generation_records,
|
commands::voice_clone_commands::get_speech_generation_records,
|
||||||
commands::voice_clone_commands::get_speech_generation_records_by_voice_id,
|
commands::voice_clone_commands::get_speech_generation_records_by_voice_id,
|
||||||
// 系统音色命令
|
// 系统音色命令
|
||||||
|
|
|
||||||
|
|
@ -561,6 +561,99 @@ pub async fn download_audio(audio_url: String, save_path: String) -> Result<Stri
|
||||||
Ok(save_path)
|
Ok(save_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 下载单个音频文件到指定目录
|
||||||
|
#[command]
|
||||||
|
pub async fn download_audio_to_directory(
|
||||||
|
record_id: String,
|
||||||
|
audio_url: String,
|
||||||
|
directory_path: String,
|
||||||
|
filename: Option<String>
|
||||||
|
) -> Result<String, String> {
|
||||||
|
info!("下载音频文件到目录: {} -> {}", audio_url, directory_path);
|
||||||
|
|
||||||
|
// 生成文件名
|
||||||
|
let file_name = filename.unwrap_or_else(|| {
|
||||||
|
format!("speech_{}.wav", record_id)
|
||||||
|
});
|
||||||
|
|
||||||
|
let save_path = std::path::Path::new(&directory_path).join(&file_name);
|
||||||
|
let save_path_str = save_path.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
// 使用现有的下载函数
|
||||||
|
download_audio(audio_url, save_path_str.clone()).await?;
|
||||||
|
|
||||||
|
Ok(save_path_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量下载音频文件到指定目录
|
||||||
|
#[command]
|
||||||
|
pub async fn batch_download_audio_to_directory(
|
||||||
|
records: Vec<serde_json::Value>,
|
||||||
|
directory_path: String
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
info!("批量下载 {} 个音频文件到目录: {}", records.len(), directory_path);
|
||||||
|
|
||||||
|
let mut downloaded_files = Vec::new();
|
||||||
|
let mut failed_downloads = Vec::new();
|
||||||
|
|
||||||
|
for (index, record) in records.iter().enumerate() {
|
||||||
|
let record_id = record.get("id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(&format!("unknown_{}", index));
|
||||||
|
|
||||||
|
let audio_url = record.get("audio_url")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.or_else(|| record.get("local_file_path").and_then(|v| v.as_str()));
|
||||||
|
|
||||||
|
if let Some(url) = audio_url {
|
||||||
|
// 生成安全的文件名
|
||||||
|
let text = record.get("text")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("speech");
|
||||||
|
|
||||||
|
// 清理文件名,移除非法字符
|
||||||
|
let safe_text = text.chars()
|
||||||
|
.take(30) // 限制长度
|
||||||
|
.filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-' || *c == '_')
|
||||||
|
.collect::<String>()
|
||||||
|
.trim()
|
||||||
|
.replace(' ', "_");
|
||||||
|
|
||||||
|
let filename = if safe_text.is_empty() {
|
||||||
|
format!("speech_{}.wav", record_id)
|
||||||
|
} else {
|
||||||
|
format!("{}_{}.wav", safe_text, record_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
match download_audio_to_directory(
|
||||||
|
record_id.to_string(),
|
||||||
|
url.to_string(),
|
||||||
|
directory_path.clone(),
|
||||||
|
Some(filename)
|
||||||
|
).await {
|
||||||
|
Ok(file_path) => {
|
||||||
|
info!("成功下载: {}", file_path);
|
||||||
|
downloaded_files.push(file_path);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("下载失败 {}: {}", record_id, e);
|
||||||
|
failed_downloads.push(format!("{}: {}", record_id, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!("记录 {} 没有可用的音频URL", record_id);
|
||||||
|
failed_downloads.push(format!("{}: 没有可用的音频URL", record_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !failed_downloads.is_empty() {
|
||||||
|
warn!("部分下载失败: {:?}", failed_downloads);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("批量下载完成: 成功 {}, 失败 {}", downloaded_files.len(), failed_downloads.len());
|
||||||
|
Ok(downloaded_files)
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取语音生成记录列表
|
/// 获取语音生成记录列表
|
||||||
#[command]
|
#[command]
|
||||||
pub async fn get_speech_generation_records(
|
pub async fn get_speech_generation_records(
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,10 @@ import {
|
||||||
Mic,
|
Mic,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
AlertCircle
|
AlertCircle,
|
||||||
|
FolderDown,
|
||||||
|
Square,
|
||||||
|
CheckSquare
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { useNotifications } from '../../components/NotificationSystem';
|
import { useNotifications } from '../../components/NotificationSystem';
|
||||||
|
|
@ -50,6 +53,11 @@ const VoiceGenerationHistory: React.FC = () => {
|
||||||
const [showVoiceCloneModal, setShowVoiceCloneModal] = useState(false);
|
const [showVoiceCloneModal, setShowVoiceCloneModal] = useState(false);
|
||||||
const [showSpeechGenerationModal, setShowSpeechGenerationModal] = useState(false);
|
const [showSpeechGenerationModal, setShowSpeechGenerationModal] = useState(false);
|
||||||
|
|
||||||
|
// 批量选择状态
|
||||||
|
const [selectedRecords, setSelectedRecords] = useState<Set<string>>(new Set());
|
||||||
|
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
|
||||||
// ============= 数据加载函数 =============
|
// ============= 数据加载函数 =============
|
||||||
|
|
||||||
// 加载语音生成记录
|
// 加载语音生成记录
|
||||||
|
|
@ -170,8 +178,116 @@ const VoiceGenerationHistory: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [playingRecordId, audioElement, addNotification]);
|
}, [playingRecordId, audioElement, addNotification]);
|
||||||
|
|
||||||
// 下载音频文件
|
// 下载音频文件到指定目录
|
||||||
const handleDownload = useCallback((record: SpeechGenerationRecord) => {
|
const handleDownloadToDirectory = useCallback(async (record: SpeechGenerationRecord) => {
|
||||||
|
if (!record.audio_url && !record.local_file_path) {
|
||||||
|
addNotification({
|
||||||
|
type: 'warning',
|
||||||
|
title: '无法下载',
|
||||||
|
message: '该记录没有可下载的音频文件'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 选择下载目录
|
||||||
|
const selectedDirectory = await invoke<string | null>('select_directory_with_options', {
|
||||||
|
title: '选择下载目录',
|
||||||
|
defaultDirectory: null
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selectedDirectory) {
|
||||||
|
return; // 用户取消选择
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioUrl = record.local_file_path || record.audio_url;
|
||||||
|
|
||||||
|
// 生成文件名
|
||||||
|
const text = record.text.substring(0, 30).replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '_');
|
||||||
|
const filename = text ? `${text}_${record.id}.wav` : `speech_${record.id}.wav`;
|
||||||
|
|
||||||
|
// 下载文件
|
||||||
|
const savedPath = await invoke<string>('download_audio_to_directory', {
|
||||||
|
recordId: record.id,
|
||||||
|
audioUrl: audioUrl!,
|
||||||
|
directoryPath: selectedDirectory,
|
||||||
|
filename
|
||||||
|
});
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: '下载成功',
|
||||||
|
message: `文件已保存到: ${savedPath}`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('下载失败:', error);
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: '下载失败',
|
||||||
|
message: `下载失败: ${error}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [addNotification]);
|
||||||
|
|
||||||
|
// 批量下载到指定目录
|
||||||
|
const handleBatchDownload = useCallback(async () => {
|
||||||
|
const downloadableRecords = records.filter(record =>
|
||||||
|
selectedRecords.has(record.id) &&
|
||||||
|
(record.audio_url || record.local_file_path) &&
|
||||||
|
record.status === SpeechGenerationRecordStatus.COMPLETED
|
||||||
|
);
|
||||||
|
|
||||||
|
if (downloadableRecords.length === 0) {
|
||||||
|
addNotification({
|
||||||
|
type: 'warning',
|
||||||
|
title: '无可下载文件',
|
||||||
|
message: '请选择至少一个有音频文件的已完成记录'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 选择下载目录
|
||||||
|
const selectedDirectory = await invoke<string | null>('select_directory_with_options', {
|
||||||
|
title: '选择批量下载目录',
|
||||||
|
defaultDirectory: null
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selectedDirectory) {
|
||||||
|
return; // 用户取消选择
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDownloading(true);
|
||||||
|
|
||||||
|
// 批量下载
|
||||||
|
const downloadedFiles = await invoke<string[]>('batch_download_audio_to_directory', {
|
||||||
|
records: downloadableRecords,
|
||||||
|
directoryPath: selectedDirectory
|
||||||
|
});
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: '批量下载完成',
|
||||||
|
message: `成功下载 ${downloadedFiles.length} 个文件到: ${selectedDirectory}`
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清空选择
|
||||||
|
setSelectedRecords(new Set());
|
||||||
|
setIsSelectionMode(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('批量下载失败:', error);
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: '批量下载失败',
|
||||||
|
message: `批量下载失败: ${error}`
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(false);
|
||||||
|
}
|
||||||
|
}, [records, selectedRecords, addNotification]);
|
||||||
|
|
||||||
|
// 兼容旧的下载方法(浏览器直接下载)
|
||||||
|
const handleQuickDownload = useCallback((record: SpeechGenerationRecord) => {
|
||||||
if (!record.audio_url && !record.local_file_path) {
|
if (!record.audio_url && !record.local_file_path) {
|
||||||
addNotification({
|
addNotification({
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
|
|
@ -188,6 +304,45 @@ const VoiceGenerationHistory: React.FC = () => {
|
||||||
link.click();
|
link.click();
|
||||||
}, [addNotification]);
|
}, [addNotification]);
|
||||||
|
|
||||||
|
// ============= 批量选择处理函数 =============
|
||||||
|
|
||||||
|
// 切换选择模式
|
||||||
|
const toggleSelectionMode = useCallback(() => {
|
||||||
|
setIsSelectionMode(!isSelectionMode);
|
||||||
|
if (isSelectionMode) {
|
||||||
|
setSelectedRecords(new Set());
|
||||||
|
}
|
||||||
|
}, [isSelectionMode]);
|
||||||
|
|
||||||
|
// 切换单个记录选择
|
||||||
|
const toggleRecordSelection = useCallback((recordId: string) => {
|
||||||
|
setSelectedRecords(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(recordId)) {
|
||||||
|
newSet.delete(recordId);
|
||||||
|
} else {
|
||||||
|
newSet.add(recordId);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 全选/取消全选
|
||||||
|
const toggleSelectAll = useCallback(() => {
|
||||||
|
const downloadableRecords = records.filter(record =>
|
||||||
|
(record.audio_url || record.local_file_path) &&
|
||||||
|
record.status === SpeechGenerationRecordStatus.COMPLETED
|
||||||
|
);
|
||||||
|
|
||||||
|
const allSelected = downloadableRecords.every(record => selectedRecords.has(record.id));
|
||||||
|
|
||||||
|
if (allSelected) {
|
||||||
|
setSelectedRecords(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedRecords(new Set(downloadableRecords.map(record => record.id)));
|
||||||
|
}
|
||||||
|
}, [records, selectedRecords]);
|
||||||
|
|
||||||
// ============= Modal处理函数 =============
|
// ============= Modal处理函数 =============
|
||||||
|
|
||||||
// 声音克隆成功回调
|
// 声音克隆成功回调
|
||||||
|
|
@ -339,20 +494,65 @@ const VoiceGenerationHistory: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
{/* 批量操作按钮 */}
|
||||||
onClick={() => setShowVoiceCloneModal(true)}
|
{!isSelectionMode ? (
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
<>
|
||||||
>
|
<button
|
||||||
<Mic className="w-4 h-4" />
|
onClick={toggleSelectionMode}
|
||||||
声音克隆
|
className="flex items-center gap-2 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
|
||||||
</button>
|
>
|
||||||
<button
|
<CheckSquare className="w-4 h-4" />
|
||||||
onClick={() => setShowSpeechGenerationModal(true)}
|
批量下载
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
</button>
|
||||||
>
|
<button
|
||||||
<Plus className="w-4 h-4" />
|
onClick={() => setShowVoiceCloneModal(true)}
|
||||||
语音合成
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||||
</button>
|
>
|
||||||
|
<Mic className="w-4 h-4" />
|
||||||
|
声音克隆
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSpeechGenerationModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
语音合成
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={toggleSelectAll}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<CheckSquare className="w-4 h-4" />
|
||||||
|
{records.filter(r => (r.audio_url || r.local_file_path) && r.status === SpeechGenerationRecordStatus.COMPLETED).every(r => selectedRecords.has(r.id)) ? '取消全选' : '全选'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleBatchDownload}
|
||||||
|
disabled={selectedRecords.size === 0 || isDownloading}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
下载中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FolderDown className="w-4 h-4" />
|
||||||
|
下载选中 ({selectedRecords.size})
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleSelectionMode}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -437,8 +637,23 @@ const VoiceGenerationHistory: React.FC = () => {
|
||||||
{records.map((record) => (
|
{records.map((record) => (
|
||||||
<div key={record.id} className="p-6 hover:bg-gray-50 transition-colors">
|
<div key={record.id} className="p-6 hover:bg-gray-50 transition-colors">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex items-start gap-4 flex-1 min-w-0">
|
||||||
{/* 状态和基本信息 */}
|
{/* 选择框 */}
|
||||||
|
{isSelectionMode && (record.audio_url || record.local_file_path) && record.status === SpeechGenerationRecordStatus.COMPLETED && (
|
||||||
|
<button
|
||||||
|
onClick={() => toggleRecordSelection(record.id)}
|
||||||
|
className="mt-1 p-1 text-gray-400 hover:text-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
{selectedRecords.has(record.id) ? (
|
||||||
|
<CheckSquare className="w-5 h-5 text-blue-600" />
|
||||||
|
) : (
|
||||||
|
<Square className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* 状态和基本信息 */}
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium border ${getStatusStyle(record.status)}`}>
|
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium border ${getStatusStyle(record.status)}`}>
|
||||||
{getStatusIcon(record.status)}
|
{getStatusIcon(record.status)}
|
||||||
|
|
@ -490,6 +705,7 @@ const VoiceGenerationHistory: React.FC = () => {
|
||||||
<p className="text-sm text-red-700">{record.error_message}</p>
|
<p className="text-sm text-red-700">{record.error_message}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
|
|
@ -511,13 +727,25 @@ const VoiceGenerationHistory: React.FC = () => {
|
||||||
|
|
||||||
{/* 下载按钮 */}
|
{/* 下载按钮 */}
|
||||||
{(record.audio_url || record.local_file_path) && record.status === SpeechGenerationRecordStatus.COMPLETED && (
|
{(record.audio_url || record.local_file_path) && record.status === SpeechGenerationRecordStatus.COMPLETED && (
|
||||||
<button
|
<div className="flex items-center gap-1">
|
||||||
onClick={() => handleDownload(record)}
|
{/* 快速下载(浏览器下载) */}
|
||||||
className="p-2 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors"
|
<button
|
||||||
title="下载音频"
|
onClick={() => handleQuickDownload(record)}
|
||||||
>
|
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||||
<Download className="w-4 h-4" />
|
title="快速下载"
|
||||||
</button>
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 下载到指定目录 */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownloadToDirectory(record)}
|
||||||
|
className="p-2 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors"
|
||||||
|
title="下载到指定目录"
|
||||||
|
>
|
||||||
|
<FolderDown className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 删除按钮 */}
|
{/* 删除按钮 */}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue