新增i18n

This commit is contained in:
kura 2026-05-11 15:42:03 +08:00
parent a0a2a62d24
commit 71f4a91c3c
14 changed files with 1196 additions and 338 deletions

20
src/i18n/index.ts Normal file
View File

@ -0,0 +1,20 @@
import { createI18n } from "vue-i18n";
import zhCN from "./locales/zh-CN";
import enUS from "./locales/en-US";
import jaJP from "./locales/ja-JP";
const messages = {
"zh-CN": zhCN,
"en-US": enUS,
"ja-JP": jaJP,
};
const i18n = createI18n({
locale: 'zh-CN',
fallbackLocale: "zh-CN",
messages,
legacy: false,
globalInjection: true,
});
export default i18n;

219
src/i18n/locales/en-US.ts Normal file
View File

@ -0,0 +1,219 @@
export default {
common: {
confirm: 'Confirm',
cancel: 'Cancel',
copy: 'Copy',
delete: 'Delete',
create: 'Create',
save: 'Save',
edit: 'Edit',
close: 'Close',
rename: 'Rename',
refresh: 'Refresh',
download: 'Download',
send: 'Send',
receive: 'Receive',
share: 'Share',
connect: 'Connect',
connected: 'Connected',
connecting: 'Connecting...',
disconnect: 'Disconnect',
loading: 'Loading...',
noContent: 'No Content',
success: 'Success',
failed: 'Failed',
error: 'Error',
unknownError: 'Unknown Error',
pleaseWait: 'Please wait...',
inputPlaceholder: 'Please enter',
search: 'Search',
okText: 'OK',
cancelText: 'Cancel',
warning: 'Warning',
createSuccess: 'Created successfully',
deleteSuccess: 'Deleted successfully',
renameSuccess: 'Renamed successfully',
copySuccess: 'Copied successfully',
operationFailed: 'Operation failed',
},
fileView: {
parentDir: 'Parent Directory',
sortByName: 'By Name',
sortBySize: 'By Size',
sortByType: 'By Type',
sortByDate: 'By Date',
permissionSettings: 'Permission Settings',
initialize: 'Initialize',
selectDirHint: 'Please select a directory',
selectDirectory: 'Select Directory',
refresh: 'Refresh',
newFolder: 'New Folder',
newFile: 'New File',
delete: 'Delete',
rename: 'Rename',
download: 'Download',
selectLocalDir: 'Select Local Directory',
confirmDelete: 'Confirm Delete',
deleteConfirmContent: 'Are you sure to delete the following files?<br>{files}<br>Total {count} file(s)',
renameTitle: 'Rename',
inputNewName: 'Please enter a new name',
inputFolderName: 'Please enter folder name',
inputFileName: 'Please enter file name',
createFolderTitle: 'New Folder',
createFileTitle: 'New File',
warningInputFolderName: 'Please enter folder name',
warningInputFileName: 'Please enter file name',
deleteBtn: 'Delete',
cancelBtn: 'Cancel',
createBtn: 'Create',
},
fileReader: {
remoteTransfer: 'Remote Transfer',
browserDownload: 'Browser Download',
save: 'Save',
edit: 'Edit',
close: 'Close',
autoDetect: 'Auto Detect',
other: 'Other',
videoNotSupported: 'Your browser does not support video playback',
audioNotSupported: 'Your browser does not support audio playback',
fileTooLarge: 'File too large to preview, please transfer to view',
unsupportedType: 'Preview not supported for this file type',
openAsText: 'Open as Text',
downloadTitle: 'Download File',
downloadConfirm: 'Are you sure to download {name}?',
saveFailed: 'Failed to save file',
editor: 'Editor',
preview: 'Preview',
},
index: {
myId: 'My ID',
waitingForConnection: 'Waiting for connection...',
copy: 'Copy',
share: 'Share',
connectedBy: 'Connected By',
connected: 'Connected',
traffic: 'Traffic',
packets: 'Packets',
enterPeerId: 'Enter peer ID',
connect: 'Connect',
connectedBtn: 'Connected',
send: 'Send',
receive: 'Receive',
desktopPreview: 'Desktop Preview',
voiceCall: 'Voice Call',
camera: 'Camera',
linkCopied: 'Link Copied',
linkCopiedDesc: 'Successfully copied to clipboard',
idCopied: 'ID Copied',
idCopiedDesc: 'Successfully copied to clipboard',
idCopiedConnectDesc: 'Successfully copied to clipboard, ready to connect',
copyFailed: 'Copy failed',
notCompleted: 'Not Completed',
inDevelopment: 'In development...',
connectSuccess: 'Connected successfully',
newConnection: 'New connection appeared',
errorOccurred: 'An error occurred',
disconnected: 'Disconnected',
receiveFilesFailed: 'Failed to receive files',
kuraaFooter: 'kuraa',
shareUrl: 'Share Link',
},
clipboard: {
getRemoteClipboard: 'Get Remote Clipboard',
noContent: 'No Content',
fetchSuccess: 'Fetched successfully',
fetchFailed: 'Failed to fetch',
unknownError: 'Unknown Error',
},
transfer: {
title: 'Transfer List',
clear: 'Clear',
waiting: 'Waiting',
sending: 'Sending',
receiving: 'Receiving',
completed: 'Completed',
error: 'Error',
paused: 'Paused',
},
permission: {
title: 'Permission Settings',
edit: 'Edit Permission',
view: 'View Permission',
download: 'Download Permission',
desktop: 'Desktop Preview Permission',
call: 'Voice Call Permission',
camera: 'Camera Permission',
editDesc: 'Allow creating, modifying and deleting files and folders',
viewDesc: 'Allow viewing file contents and directory structure',
downloadDesc: 'Allow transferring files to local',
desktopDesc: 'Allow peer to request desktop preview',
callDesc: 'Allow peer to request voice call',
cameraDesc: 'Allow peer to request camera access',
},
desktop: {
remoteDesktop: 'Remote Desktop',
waitingForDesktop: 'Waiting for desktop sharing...',
endSharing: 'End Sharing',
collapseDesktop: 'Collapse Desktop',
expandDesktop: 'Expand Desktop',
soundOn: 'Sound On',
soundOff: 'Mute',
fullscreen: 'Fullscreen',
exitFullscreen: 'Exit',
enableSound: 'Enable Sound',
disableSound: 'Mute',
desktopBeingViewed: 'Desktop is being viewed',
peerViewingDesktop: 'Peer is viewing your desktop',
expandDesktopHint: 'Expand desktop preview hint',
remoteCamera: 'Remote Camera',
waitingForCamera: 'Waiting for camera feed...',
collapseCamera: 'Collapse Camera',
endCamera: 'End Camera',
expandCamera: 'Expand Camera',
cameraBeingViewed: 'Camera is being viewed',
peerViewingCamera: 'Peer is viewing your camera',
expandCameraHint: 'Expand camera preview hint',
cameraRequest: 'Camera Request',
voiceCallRequest: 'Voice Call Request',
peerRequestCamera: 'Peer requests to view your camera',
peerRequestCall: 'Peer requests a voice call',
reject: 'Reject',
accept: 'Accept',
voiceCall: 'Voice Call',
collapseCall: 'Collapse Call',
endCall: 'End Call',
inCall: 'In Call',
connecting: 'Connecting...',
unmute: 'Unmute',
mute: 'Mute',
expandCall: 'Expand Call',
},
voice: {
myId: 'My ID',
preparing: 'Preparing connection...',
ready: 'Ready',
inCall: 'In Call',
enterPeerId: 'Enter peer ID',
call: 'Call',
endCall: 'End Call',
unmute: 'Unmute',
mute: 'Mute',
delay: 'Delay',
copy: 'Copy',
cannotAccessMic: 'Cannot access microphone',
connectionError: 'Connection Error',
},
shader: {
uploadImageVideo: 'Upload Image/Video',
},
}

219
src/i18n/locales/ja-JP.ts Normal file
View File

@ -0,0 +1,219 @@
export default {
common: {
confirm: '確認',
cancel: 'キャンセル',
copy: 'コピー',
delete: '削除',
create: '作成',
save: '保存',
edit: '編集',
close: '閉じる',
rename: '名前変更',
refresh: '更新',
download: 'ダウンロード',
send: '送信',
receive: '受信',
share: '共有',
connect: '接続',
connected: '接続済み',
connecting: '接続中...',
disconnect: '切断',
loading: '読み込み中...',
noContent: 'コンテンツなし',
success: '成功',
failed: '失敗',
error: 'エラー',
unknownError: '不明なエラー',
pleaseWait: 'しばらくお待ちください...',
inputPlaceholder: '入力してください',
search: '検索',
okText: '確認',
cancelText: 'キャンセル',
warning: '注意',
createSuccess: '作成しました',
deleteSuccess: '削除しました',
renameSuccess: '名前を変更しました',
copySuccess: 'コピーしました',
operationFailed: '操作に失敗しました',
},
fileView: {
parentDir: '親ディレクトリ',
sortByName: '名前順',
sortBySize: 'サイズ順',
sortByType: '種類順',
sortByDate: '日付順',
permissionSettings: '権限設定',
initialize: '初期化',
selectDirHint: 'ディレクトリを選択してください',
selectDirectory: 'ディレクトリを選択',
refresh: '更新',
newFolder: '新しいフォルダ',
newFile: '新しいファイル',
delete: '削除',
rename: '名前変更',
download: 'ダウンロード',
selectLocalDir: 'ローカルディレクトリを選択',
confirmDelete: '削除の確認',
deleteConfirmContent: '以下のファイルを削除してもよろしいですか?<br>{files}<br>合計 {count} 個のファイル',
renameTitle: '名前変更',
inputNewName: '新しい名前を入力してください',
inputFolderName: 'フォルダ名を入力してください',
inputFileName: 'ファイル名を入力してください',
createFolderTitle: '新しいフォルダ',
createFileTitle: '新しいファイル',
warningInputFolderName: 'フォルダ名を入力してください',
warningInputFileName: 'ファイル名を入力してください',
deleteBtn: '削除',
cancelBtn: 'キャンセル',
createBtn: '作成',
},
fileReader: {
remoteTransfer: 'リモート転送',
browserDownload: 'ブラウザダウンロード',
save: '保存',
edit: '編集',
close: '閉じる',
autoDetect: '自動検出',
other: 'その他',
videoNotSupported: 'お使いのブラウザは動画再生に対応していません',
audioNotSupported: 'お使いのブラウザは音声再生に対応していません',
fileTooLarge: 'ファイルが大きすぎるためプレビューできません。転送してご確認ください',
unsupportedType: 'このファイル形式はプレビューに対応していません',
openAsText: 'テキストとして開く',
downloadTitle: 'ファイルのダウンロード',
downloadConfirm: '{name}をダウンロードしてもよろしいですか?',
saveFailed: 'ファイルの保存に失敗しました',
editor: 'エディタ',
preview: 'プレビュー',
},
index: {
myId: 'マイID',
waitingForConnection: '接続を待っています...',
copy: 'コピー',
share: '共有',
connectedBy: '接続されました',
connected: '接続済み',
traffic: 'トラフィック',
packets: 'パケット数',
enterPeerId: '相手のIDを入力',
connect: '接続',
connectedBtn: '接続済み',
send: '送信',
receive: '受信',
desktopPreview: 'デスクトッププレビュー',
voiceCall: '音声通話',
camera: 'カメラ',
linkCopied: 'リンクをコピーしました',
linkCopiedDesc: 'クリップボードにコピーしました',
idCopied: 'IDをコピーしました',
idCopiedDesc: 'クリップボードにコピーしました',
idCopiedConnectDesc: 'クリップボードにコピーしました。接続に使用できます',
copyFailed: 'コピーに失敗しました',
notCompleted: '未完了',
inDevelopment: '開発中...',
connectSuccess: '接続しました',
newConnection: '新しい接続がありました',
errorOccurred: 'エラーが発生しました',
disconnected: '切断されました',
receiveFilesFailed: 'ファイルの受信に失敗しました',
kuraaFooter: 'kuraa',
shareUrl: 'リンクを共有',
},
clipboard: {
getRemoteClipboard: '相手のクリップボードを取得',
noContent: 'コンテンツなし',
fetchSuccess: '取得成功',
fetchFailed: '取得に失敗しました',
unknownError: '不明なエラー',
},
transfer: {
title: '転送リスト',
clear: 'クリア',
waiting: '待機中',
sending: '送信中',
receiving: '受信中',
completed: '完了',
error: 'エラー',
paused: '一時停止',
},
permission: {
title: '権限設定',
edit: '編集権限',
view: '閲覧権限',
download: 'ダウンロード権限',
desktop: 'デスクトッププレビュー権限',
call: '音声通話権限',
camera: 'カメラ権限',
editDesc: 'ファイルとフォルダの作成、変更、削除を許可',
viewDesc: 'ファイルの内容とディレクトリ構成の表示を許可',
downloadDesc: 'ローカルへのファイル転送を許可',
desktopDesc: '相手からのデスクトッププレビュー要求を許可',
callDesc: '相手からの音声通話要求を許可',
cameraDesc: '相手からのカメラアクセス要求を許可',
},
desktop: {
remoteDesktop: 'リモートデスクトップ',
waitingForDesktop: 'デスクトップ共有を待っています...',
endSharing: '共有を終了',
collapseDesktop: 'デスクトップを折りたたむ',
expandDesktop: 'デスクトップを展開',
soundOn: '音声ON',
soundOff: 'ミュート',
fullscreen: '全画面',
exitFullscreen: '終了',
enableSound: '音声を有効にする',
disableSound: 'ミュート',
desktopBeingViewed: 'デスクトップが表示されています',
peerViewingDesktop: '相手があなたのデスクトップを表示しています',
expandDesktopHint: 'デスクトッププレビューヒントを展開',
remoteCamera: 'リモートカメラ',
waitingForCamera: 'カメラ映像を待っています...',
collapseCamera: 'カメラを折りたたむ',
endCamera: 'カメラを終了',
expandCamera: 'カメラを展開',
cameraBeingViewed: 'カメラが表示されています',
peerViewingCamera: '相手があなたのカメラを表示しています',
expandCameraHint: 'カメラプレビューヒントを展開',
cameraRequest: 'カメラリクエスト',
voiceCallRequest: '音声通話リクエスト',
peerRequestCamera: '相手がカメラの表示を要求しています',
peerRequestCall: '相手が音声通話を要求しています',
reject: '拒否',
accept: '応答',
voiceCall: '音声通話',
collapseCall: '通話を折りたたむ',
endCall: '通話を終了',
inCall: '通話中',
connecting: '接続を確立しています...',
unmute: 'ミュート解除',
mute: 'ミュート',
expandCall: '通話を展開',
},
voice: {
myId: 'マイID',
preparing: '接続準備中...',
ready: '準備完了',
inCall: '通話中',
enterPeerId: '相手のIDを入力',
call: '発信',
endCall: '通話を終了',
unmute: 'ミュート解除',
mute: 'ミュート',
delay: '遅延時間',
copy: 'コピー',
cannotAccessMic: 'マイクにアクセスできません',
connectionError: '接続エラー',
},
shader: {
uploadImageVideo: '画像/動画をアップロード',
},
}

219
src/i18n/locales/zh-CN.ts Normal file
View File

@ -0,0 +1,219 @@
export default {
common: {
confirm: '确定',
cancel: '取消',
copy: '复制',
delete: '删除',
create: '创建',
save: '保存',
edit: '编辑',
close: '关闭',
rename: '重命名',
refresh: '刷新',
download: '下载',
send: '发送',
receive: '接收',
share: '分享',
connect: '连接',
connected: '已连接',
connecting: '连接中...',
disconnect: '断开',
loading: '加载中...',
noContent: '暂无内容',
success: '成功',
failed: '失败',
error: '错误',
unknownError: '未知错误',
pleaseWait: '请稍后...',
inputPlaceholder: '请输入',
search: '搜索',
okText: '确定',
cancelText: '取消',
warning: '提示',
createSuccess: '创建成功',
deleteSuccess: '删除成功',
renameSuccess: '重命名成功',
copySuccess: '复制成功',
operationFailed: '操作失败',
},
fileView: {
parentDir: '上级目录',
sortByName: '按名称',
sortBySize: '按大小',
sortByType: '按类型',
sortByDate: '按日期',
permissionSettings: '权限设置',
initialize: '初始化',
selectDirHint: '请选择一个目录',
selectDirectory: '选择目录',
refresh: '刷新',
newFolder: '新建文件夹',
newFile: '新建文件',
delete: '删除',
rename: '重命名',
download: '下载',
selectLocalDir: '选择本地目录',
confirmDelete: '确认删除',
deleteConfirmContent: '确定要删除以下文件吗?<br>{files}<br>共 {count} 个文件',
renameTitle: '重命名',
inputNewName: '请输入新的名称',
inputFolderName: '请输入文件夹名称',
inputFileName: '请输入文件名称',
createFolderTitle: '新建文件夹',
createFileTitle: '新建文件',
warningInputFolderName: '请输入文件夹名称',
warningInputFileName: '请输入文件名称',
deleteBtn: '删除',
cancelBtn: '取消',
createBtn: '创建',
},
fileReader: {
remoteTransfer: '远程传输',
browserDownload: '浏览器下载',
save: '保存',
edit: '编辑',
close: '关闭',
autoDetect: '自动检测',
other: '其他',
videoNotSupported: '您的浏览器不支持视频播放',
audioNotSupported: '您的浏览器不支持音频播放',
fileTooLarge: '文件过大,无法预览,请传输后查看',
unsupportedType: '不支持预览该类型的文件',
openAsText: '以文本打开',
downloadTitle: '下载文件',
downloadConfirm: '确定要下载{name}吗?',
saveFailed: '保存文件失败',
editor: '编辑器',
preview: '预览',
},
index: {
myId: '我的ID',
waitingForConnection: '等待连接...',
copy: '复制',
share: '分享',
connectedBy: '被连接',
connected: '已连接',
traffic: '流量',
packets: '包数',
enterPeerId: '输入对方ID',
connect: '连接',
connectedBtn: '已连接',
send: '发送',
receive: '接收',
desktopPreview: '桌面预览',
voiceCall: '语音通话',
camera: '摄像头',
linkCopied: '链接已复制',
linkCopiedDesc: '已成功复制到剪贴板',
idCopied: 'ID已复制',
idCopiedDesc: '已成功复制到剪贴板',
idCopiedConnectDesc: '已成功复制到剪贴板,可用于发起连接',
copyFailed: '复制失败',
notCompleted: '未完成',
inDevelopment: '开发中...',
connectSuccess: '连接成功',
newConnection: '出现新的连接',
errorOccurred: '发生错误',
disconnected: '连接已断开',
receiveFilesFailed: '接收文件失败',
kuraaFooter: 'kuraa',
shareUrl: '分享链接',
},
clipboard: {
getRemoteClipboard: '获取对方粘贴板',
noContent: '暂无内容',
fetchSuccess: '获取成功',
fetchFailed: '获取失败',
unknownError: '未知错误',
},
transfer: {
title: '传输列表',
clear: '清理',
waiting: '等待中',
sending: '发送中',
receiving: '接收中',
completed: '已完成',
error: '失败',
paused: '已暂停',
},
permission: {
title: '权限设置',
edit: '编辑权限',
view: '查看权限',
download: '下载权限',
desktop: '桌面预览权限',
call: '语音通话权限',
camera: '摄像头权限',
editDesc: '允许创建、修改、删除文件和文件夹',
viewDesc: '允许查看文件内容和目录结构',
downloadDesc: '允许传输文件到本地',
desktopDesc: '允许对方请求预览当前桌面',
callDesc: '允许对方请求语音通话',
cameraDesc: '允许对方请求查看当前摄像头',
},
desktop: {
remoteDesktop: '远程桌面',
waitingForDesktop: '等待桌面共享...',
endSharing: '结束共享',
collapseDesktop: '收起桌面',
expandDesktop: '展开桌面',
soundOn: '出声',
soundOff: '静音',
fullscreen: '全屏',
exitFullscreen: '退出',
enableSound: '开启声音',
disableSound: '静音',
desktopBeingViewed: '桌面正在被预览',
peerViewingDesktop: '对方正在查看你的桌面',
expandDesktopHint: '展开桌面预览提示',
remoteCamera: '远程摄像头',
waitingForCamera: '等待摄像头画面...',
collapseCamera: '收起摄像头',
endCamera: '结束摄像头',
expandCamera: '展开摄像头',
cameraBeingViewed: '摄像头正在被预览',
peerViewingCamera: '对方正在查看你的摄像头',
expandCameraHint: '展开摄像头预览提示',
cameraRequest: '摄像头请求',
voiceCallRequest: '语音通话请求',
peerRequestCamera: '对方请求查看摄像头',
peerRequestCall: '对方请求语音通话',
reject: '拒绝',
accept: '接听',
voiceCall: '语音通话',
collapseCall: '收起通话',
endCall: '结束通话',
inCall: '通话中',
connecting: '正在建立连接...',
unmute: '取消静音',
mute: '静音',
expandCall: '展开通话',
},
voice: {
myId: '我的ID',
preparing: '准备连接...',
ready: '准备就绪',
inCall: '通话中',
enterPeerId: '输入对方的ID',
call: '呼叫',
endCall: '结束通话',
unmute: '取消静音',
mute: '静音',
delay: '延迟时间',
copy: '复制',
cannotAccessMic: '无法访问麦克风',
connectionError: '连接错误',
},
shader: {
uploadImageVideo: '上传图片/视频',
},
}

View File

@ -3,6 +3,7 @@ import App from './App.vue'
import 'ant-design-vue/dist/reset.css';
import Antd from 'ant-design-vue';
import router from './router/router';
import i18n from './i18n';
import { registerSW } from 'virtual:pwa-register'
import { isPwa, showPwaInstallPrompt } from './utils/pwa';
@ -25,4 +26,5 @@ const updateSW = registerSW({
const app = createApp(App);
app.use(Antd);
app.use(router);
app.use(i18n);
app.mount('#app')

View File

@ -4,84 +4,127 @@
<!-- 连接状态 -->
<div class="status-bar">
<div class="peer-info">
我的ID: <span class="id-text">{{ myId || "等待连接..." }}</span>
<Button type="primary" @click="copyId" v-if="myId">复制</Button>
<Button type="link" @click="shareUrl" v-if="myId">分享</Button>
{{ $t("index.myId") }}:
<span class="id-text">{{
myId || $t("index.waitingForConnection")
}}</span>
<Button type="primary" @click="copyId" v-if="myId">{{
$t("index.copy")
}}</Button>
<Button type="link" @click="shareUrl" v-if="myId">{{
$t("index.share")
}}</Button>
<span
v-if="connectedPeerId"
class="connection-badge"
:class="{ inbound: isInboundConnected }"
>
{{ isInboundConnected ? "被连接" : "已连接" }}:
{{
isInboundConnected
? $t("index.connectedBy")
: $t("index.connected")
}}:
<span class="connected-peer">{{ connectedPeerLabel }}</span>
<Button
v-if="isInboundConnected && connectedPeerSign"
type="link"
size="small"
@click="copyPeerSign"
>复制</Button
>{{ $t("index.copy") }}</Button
>
</span>
</div>
<!-- 显示流量 丢包率-->
<div class="status-info">
<div class="status-item">
<span>流量:</span>
<span>{{ $t("index.traffic") }}:</span>
<span>{{ formatSize(transInfo.bytes) }}/s</span>
</div>
<div class="status-item">
<span>包数:</span>
<span>{{ $t("index.packets") }}:</span>
<span>{{ transInfo.packets }}/s</span>
</div>
</div>
<div class="connect-section">
<div class="connect-actions">
<div
class="connect-item"
:class="{ disabled: !isConnected, active: isDesktopActive }"
title="桌面预览"
@click="requestDesktop"
>
<img src="/static/desktop.png" alt="桌面" />
</div>
<div
class="connect-item"
:class="{ disabled: !isConnected, active: isCallActive }"
title="语音通话"
@click="requestCall"
>
<img src="/static/phone.png" alt="通话" />
</div>
<div
class="connect-item"
:class="{ disabled: !isConnected, active: isCameraActive }"
title="摄像头"
@click="requestCamera"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
<div class="status-actions">
<div class="connect-section">
<div class="connect-actions">
<div
class="connect-item"
:class="{ disabled: !isConnected, active: isDesktopActive }"
:title="$t('index.desktopPreview')"
@click="requestDesktop"
>
<path d="M23 7l-7 5 7 5V7z" />
<rect x="1" y="5" width="15" height="14" rx="2" />
<img
src="/static/desktop.png"
:alt="$t('index.desktopPreview')"
/>
</div>
<div
class="connect-item"
:class="{ disabled: !isConnected, active: isCallActive }"
:title="$t('index.voiceCall')"
@click="requestCall"
>
<img src="/static/phone.png" :alt="$t('index.voiceCall')" />
</div>
<div
class="connect-item"
:class="{ disabled: !isConnected, active: isCameraActive }"
:title="$t('index.camera')"
@click="requestCamera"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M23 7l-7 5 7 5V7z" />
<rect x="1" y="5" width="15" height="14" rx="2" />
</svg>
</div>
</div>
<input
v-model="targetId"
:placeholder="
!myId ? $t('common.pleaseWait') : $t('index.enterPeerId')
"
:disabled="isConnected || !myId"
/>
<Button
@click="handleConnect"
:disabled="!targetId || isConnected"
:loading="isConnecting"
>
{{ isConnected ? $t("index.connectedBtn") : $t("index.connect") }}
</Button>
</div>
<a-dropdown>
<div class="lang-switcher">
<svg
t="1778484837023"
class="icon lang-switcher-icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="1747"
>
<path
d="M846.04 866.77c-17.08 2.03-32.57-10.18-34.59-27.26-0.22-1.9-0.27-3.81-0.15-5.71v-123c0-33.73-22.17-33.73-30.53-33.73-21.28-0.46-38.91 16.43-39.36 37.72-0.01 0.46-0.01 0.92 0 1.37v117.66c-0.76 18.9-16.71 33.61-35.61 32.84-17.83-0.72-32.12-15.01-32.84-32.84V647.68c-1.23-17.23 11.74-32.19 28.97-33.41 1.69-0.12 3.39-0.1 5.08 0.05a31.953 31.953 0 0 1 31.33 17.76 89.435 89.435 0 0 1 54.99-17.76c54.11 0 86.45 33.59 86.45 90.03V833.8a32.25 32.25 0 0 1-8.88 23.72 34.026 34.026 0 0 1-24.82 9.33l-0.04-0.08z m-233.12-7.46h-134.7c-42.77 0-61.85-18.96-61.85-61.57V608.07c0-42.52 19.09-61.57 61.85-61.57h128.74c17.92 0 32.45 14.53 32.45 32.45s-14.53 32.45-32.45 32.45H490.73c-1.22-0.08-2.45 0.09-3.6 0.5 0.13 0-0.15 0.8-0.15 2.89v52.58h106c16.33-1.66 30.91 10.24 32.57 26.57 0.17 1.68 0.2 3.37 0.08 5.06 0.98 16.66-11.73 30.97-28.4 31.95-1.41 0.08-2.83 0.07-4.24-0.05H486.9V791c-0.04 1.06 0.08 2.13 0.35 3.15 1.12 0.15 2.25 0.23 3.38 0.24h122.31c16.96-1.07 31.58 11.81 32.65 28.76 0.07 1.16 0.08 2.33 0.02 3.5 1.35 16.68-11.07 31.3-27.75 32.65-1.64 0.13-3.28 0.13-4.92 0h-0.02zM327.54 482.85c-17.36 2.36-33.34-9.8-35.7-27.16-0.3-2.21-0.37-4.44-0.2-6.67V370.5h-85.27c-45.86 0-66.31-20.52-66.31-66.31v-93.87c0-45.58 20.52-65.9 66.31-65.9h85.27v-31.53c-1.38-17.11 11.37-32.11 28.48-33.49 1.92-0.15 3.84-0.13 5.76 0.07 30.26 0 36.63 18.17 36.63 33.42v31.59h86.09c45.86 0 66.33 20.34 66.33 65.88v93.89c0 45.86-20.52 66.29-66.33 66.29h-86.05v78.52c1.25 17.47-11.9 32.65-29.37 33.91-1.88 0.13-3.76 0.1-5.63-0.1v-0.02zM217.21 211.27c-6.47 0-7.07 0.6-7.07 7.07v78.2c0 6.53 0.6 7.15 7.07 7.15h74.43v-92.42h-74.43z m145.35 92.38h75.29c6.29 0 7.09-0.8 7.09-7.07v-78.25c0-6.29-0.8-7.09-7.09-7.09h-75.31v92.42h0.02z m151.42 655.91C266.43 958.82 66.36 757.55 67.1 510c0.1-35 4.31-69.86 12.52-103.88 4.81-19 23.92-30.68 43.03-26.29 19.1 4.61 30.86 23.81 26.29 42.91-48.93 202.33 75.42 406.01 277.75 454.94a376.924 376.924 0 0 0 87.29 10.56c19.69 0.02 35.64 15.99 35.63 35.69-0.02 19.67-15.96 35.61-35.63 35.63z m398.49-310.05c-19.69 0-35.66-15.96-35.66-35.65 0-2.95 0.37-5.9 1.09-8.76 51.31-201.82-70.7-407.02-272.52-458.33-29.89-7.6-60.59-11.5-91.43-11.62-19.68 0-35.64-15.95-35.64-35.63 0-19.68 15.95-35.64 35.63-35.64h0.01c247.57 0.76 447.65 202.08 446.89 449.65-0.11 36.8-4.76 73.44-13.83 109.1-4 15.8-18.23 26.88-34.54 26.88z"
fill="currentColor"
p-id="1748"
></path>
</svg>
</div>
</div>
<input
v-model="targetId"
:placeholder="!myId ? '请稍后...' : '输入对方ID'"
:disabled="isConnected || !myId"
/>
<Button
@click="handleConnect"
:disabled="!targetId || isConnected"
:loading="isConnecting"
>
{{ isConnected ? "已连接" : "连接" }}
</Button>
<template #overlay>
<a-menu @click="handleLangMenuClick">
<a-menu-item key="zh-CN">中文</a-menu-item>
<a-menu-item key="en-US">English</a-menu-item>
<a-menu-item key="ja-JP">日本語</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
@ -106,7 +149,7 @@
:disabled="!isConnected || selectedLocalFiles.length === 0"
@click="handleSend"
>
发送
{{ $t("index.send") }}
</Button>
<Button
type="primary"
@ -114,7 +157,7 @@
@click="handleReceive"
:loading="receiveLoading"
>
接收
{{ $t("index.receive") }}
</Button>
</div>
@ -123,7 +166,9 @@
</div>
</div>
<div class="author-footer">
<a href="https://kuraa.cc" target="_blank" rel="noopener">kuraa</a>
<a href="https://kuraa.cc" target="_blank" rel="noopener">{{
$t("index.kuraaFooter")
}}</a>
</div>
<FileTransferView />
</template>
@ -131,6 +176,7 @@
<script setup lang="ts">
import Clipboard from "./item/clipboard.vue";
import { ref, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { peer } from "./utils/peer";
import FileView from "./item/fileView.vue";
import { notification } from "ant-design-vue";
@ -146,6 +192,12 @@ import {
} from "./utils/common";
import FileTransferView from "./item/fileTranserView.vue";
const { locale, t } = useI18n();
const handleLangMenuClick = ({ key }: { key: string }) => {
locale.value = key;
};
const isPhone = ref(false);
const receiveLoading = ref(false);
const myId = ref("");
@ -198,8 +250,8 @@ const shareUrl = async () => {
window.location.origin + window.location.pathname + "?sign=" + myId.value;
await copyToClipboard(url);
notification.success({
message: "链接已复制",
description: "已成功复制到剪贴板",
message: t("index.linkCopied"),
description: t("index.linkCopiedDesc"),
});
}
};
@ -210,12 +262,12 @@ const copyId = async () => {
try {
await copyToClipboard(myId.value);
notification.success({
message: "ID已复制",
description: "已成功复制到剪贴板",
message: t("index.idCopied"),
description: t("index.idCopiedDesc"),
});
} catch (error) {
notification.error({
message: "复制失败",
message: t("index.copyFailed"),
icon: "error",
});
}
@ -227,11 +279,11 @@ const copyPeerSign = async () => {
try {
await copyToClipboard(connectedPeerSign.value);
notification.success({
message: "ID已复制",
description: "已成功复制到剪贴板,可用于发起连接",
message: t("index.idCopied"),
description: t("index.idCopiedConnectDesc"),
});
} catch {
notification.error({ message: "复制失败" });
notification.error({ message: t("index.copyFailed") });
}
}
};
@ -289,8 +341,8 @@ const checkBytes = () => {
};
const handleSend = async () => {
notification.info({
message: "未完成",
description: "开发中...",
message: t("index.notCompleted"),
description: t("index.inDevelopment"),
});
};
const handleReceive = async () => {
@ -313,7 +365,7 @@ const handleReceive = async () => {
await Promise.all(getFileHandles);
} catch (err) {
notification.error({
message: "接收文件失败",
message: t("index.receiveFilesFailed"),
});
}
receiveLoading.value = false;
@ -338,12 +390,12 @@ onMounted(async () => {
updateConnectedPeer(event.detail.peer, false);
fileMgrInstance.remoteRootFile.loadLocalDirectory();
notification.success({
message: "连接成功",
message: t("index.connectSuccess"),
});
} else {
updateConnectedPeer(event.detail.peer, true);
notification.success({
message: "出现新的连接",
message: t("index.newConnection"),
});
}
}) as EventListener);
@ -354,7 +406,7 @@ onMounted(async () => {
isCallActive.value = false;
isCameraActive.value = false;
notification.error({
message: event.detail.peer + "连接已断开",
message: event.detail.peer + t("index.disconnected"),
});
}) as EventListener);
@ -370,7 +422,7 @@ onMounted(async () => {
peer.on("error", ((event: CustomEvent) => {
isConnecting.value = false;
notification.error({
message: "发生错误",
message: t("index.errorOccurred"),
});
console.error("连接错误:", event.detail);
}) as EventListener);
@ -428,8 +480,9 @@ onMounted(async () => {
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
padding: 10px;
background: #f5f5f5;
border-radius: 8px;
@ -439,6 +492,7 @@ onMounted(async () => {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.connection-badge {
@ -497,24 +551,74 @@ onMounted(async () => {
.connect-item.disabled img {
filter: grayscale(1);
}
.connect-item img {
width: 100%;
height: 100%;
}
.connect-item svg {
width: 22px;
height: 22px;
width: 35px;
height: 35px;
margin: auto;
color: #1677ff;
}
.status-info {
display: flex;
align-items: center;
gap: 12px;
}
.status-actions,
.connect-actions,
.connect-section {
display: flex;
align-items: center;
gap: 10px;
}
.status-actions {
margin-left: auto;
flex-wrap: wrap;
justify-content: flex-end;
}
.connect-section {
flex-wrap: wrap;
}
.connect-section input {
min-width: 220px;
}
.lang-switcher {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: 1px solid #d9e6f7;
border-radius: 999px;
background: #ffffff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
cursor: pointer;
color: #1677ff;
transition:
background 0.2s,
border-color 0.2s;
}
.lang-switcher:hover {
background: #f0f6ff;
border-color: #91caff;
}
.lang-switcher-icon {
width: 22px;
height: 22px;
}
.file-transfer {
display: flex;
flex: 1;
@ -666,21 +770,33 @@ input:disabled {
@media screen and (max-width: 768px) {
.status-bar {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.peer-info,
.status-info,
.status-actions,
.connect-section {
width: 100%;
}
.status-actions {
margin-left: 0;
justify-content: space-between;
}
.connect-section input {
flex: 1;
min-width: 0;
}
.status-info {
width: 100%;
display: flex;
justify-content: space-around;
}
.lang-switcher {
margin-left: auto;
}
}
</style>

View File

@ -14,14 +14,14 @@
<div v-else>
<div class="clipboard-header">
<div class="clipboard-actions">
<Button @click="getRemoteClipboard" :loading="loading"
>获取对方粘贴板</Button
>
<Button @click="getRemoteClipboard" :loading="loading">{{
$t("clipboard.getRemoteClipboard")
}}</Button>
</div>
</div>
<div class="clipboard-content">
<div class="clipboard-text">
{{ remoteClipboard || "暂无内容" }}
{{ remoteClipboard || $t("clipboard.noContent") }}
</div>
</div>
</div>
@ -31,10 +31,13 @@
<script setup lang="ts">
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "ant-design-vue";
import { notification } from "ant-design-vue";
import { peer, MessageType } from "../utils/peer";
const { t } = useI18n();
const loading = ref(false);
const remoteClipboard = ref("");
const mouseEntering = ref(false);
@ -49,12 +52,12 @@ const getRemoteClipboard = async () => {
});
remoteClipboard.value = result;
notification.success({
message: "获取成功",
message: t("clipboard.fetchSuccess"),
});
} catch (error: any) {
notification.error({
message: "获取失败",
description: error?.message || "未知错误",
message: t("clipboard.fetchFailed"),
description: error?.message || t("clipboard.unknownError"),
});
} finally {
loading.value = false;
@ -65,7 +68,9 @@ const getRemoteClipboard = async () => {
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease, max-height 0.5s ease;
transition:
opacity 0.5s ease,
max-height 0.5s ease;
max-height: 200px;
overflow: hidden;
}

View File

@ -29,7 +29,7 @@
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
<span>远程桌面</span>
<span>{{ $t("desktop.remoteDesktop") }}</span>
<span class="duration-text" v-if="desktopStream">{{
callDurationStr
}}</span>
@ -40,7 +40,11 @@
class="header-btn"
:class="{ 'sound-enabled': isDesktopSoundEnabled }"
@click="toggleDesktopSound"
:title="isDesktopSoundEnabled ? '静音' : '开启声音'"
:title="
isDesktopSoundEnabled
? $t('desktop.disableSound')
: $t('desktop.enableSound')
"
>
<svg
v-if="isDesktopSoundEnabled"
@ -68,13 +72,21 @@
<line x1="23" y1="9" x2="17" y2="15" />
<line x1="17" y1="9" x2="23" y2="15" />
</svg>
<span>{{ isDesktopSoundEnabled ? "出声" : "静音" }}</span>
<span>{{
isDesktopSoundEnabled
? $t("desktop.soundOn")
: $t("desktop.soundOff")
}}</span>
</button>
<button
v-if="desktopStream"
class="header-btn"
@click="toggleFullscreen"
:title="isFullscreen ? '退出全屏' : '全屏'"
:title="
isFullscreen
? $t('desktop.exitFullscreen')
: $t('desktop.fullscreen')
"
>
<svg
v-if="!isFullscreen"
@ -104,7 +116,11 @@
<line x1="14" y1="10" x2="21" y2="3" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
<span>{{ isFullscreen ? "退出" : "全屏" }}</span>
<span>{{
isFullscreen
? $t("desktop.exitFullscreen")
: $t("desktop.fullscreen")
}}</span>
</button>
<button class="header-btn danger" @click="endDesktop">
<svg
@ -118,7 +134,7 @@
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
<span>结束共享</span>
<span>{{ $t("desktop.endSharing") }}</span>
</button>
</div>
</div>
@ -144,14 +160,14 @@
stroke-linecap="round"
/>
</svg>
<span>等待桌面共享...</span>
<span>{{ $t("desktop.waitingForDesktop") }}</span>
</div>
</div>
<button
class="desktop-side-toggle"
@click="toggleDesktopCollapse"
title="收起桌面"
aria-label="收起桌面"
:title="$t('desktop.collapseDesktop')"
:aria-label="$t('desktop.collapseDesktop')"
>
<svg
width="18"
@ -174,8 +190,8 @@
class="desktop-collapsed-tab"
:class="desktopCollapsedClasses"
@click="toggleDesktopCollapse"
title="展开桌面"
aria-label="展开桌面"
:title="$t('desktop.expandDesktop')"
:aria-label="$t('desktop.expandDesktop')"
>
<span class="sharing-status-dot"></span>
<svg
@ -214,13 +230,13 @@
<path d="M8 20h8" />
<path d="M12 16v4" />
</svg>
<span>桌面正在被预览</span>
<span>{{ $t("desktop.desktopBeingViewed") }}</span>
</div>
<div class="panel-header-actions">
<button
class="panel-icon-btn"
@click="toggleDesktopShareCollapse"
title="收起提示"
:title="$t('desktop.collapseDesktop')"
>
<svg
width="14"
@ -233,7 +249,11 @@
<polyline points="18 15 12 9 6 15" />
</svg>
</button>
<button class="panel-icon-btn" @click="endDesktop" title="结束预览">
<button
class="panel-icon-btn"
@click="endDesktop"
:title="$t('desktop.endSharing')"
>
<svg
width="14"
height="14"
@ -250,7 +270,7 @@
</div>
<div class="sharing-panel-body">
<div class="sharing-status-dot"></div>
<span>对方正在查看你的桌面</span>
<span>{{ $t("desktop.peerViewingDesktop") }}</span>
</div>
</div>
</Transition>
@ -261,8 +281,8 @@
class="desktop-sharing-tab"
:class="desktopCollapsedClasses"
@click="toggleDesktopShareCollapse"
title="展开桌面预览提示"
aria-label="展开桌面预览提示"
:title="$t('desktop.expandDesktopHint')"
:aria-label="$t('desktop.expandDesktopHint')"
>
<span class="sharing-status-dot"></span>
<svg
@ -307,13 +327,13 @@
<rect x="1" y="5" width="15" height="14" rx="2" />
</svg>
</span>
<span>远程摄像头</span>
<span>{{ $t("desktop.remoteCamera") }}</span>
</div>
<div class="panel-header-actions">
<button
class="panel-icon-btn"
@click="toggleCameraCollapse"
title="收起摄像头"
:title="$t('desktop.collapseCamera')"
>
<svg
width="14"
@ -329,7 +349,7 @@
<button
class="panel-icon-btn"
@click="endCamera"
title="结束摄像头"
:title="$t('desktop.endCamera')"
>
<svg
width="14"
@ -353,7 +373,7 @@
playsinline
></video>
<div v-else class="loading-state">
<span>等待摄像头画面...</span>
<span>{{ $t("desktop.waitingForCamera") }}</span>
</div>
</div>
</div>
@ -365,8 +385,8 @@
class="camera-collapsed-tab"
:class="cameraCollapsedClasses"
@click="toggleCameraCollapse"
title="展开摄像头"
aria-label="展开摄像头"
:title="$t('desktop.expandCamera')"
:aria-label="$t('desktop.expandCamera')"
>
<span class="sharing-status-dot"></span>
<svg
@ -402,13 +422,13 @@
<path d="M23 7l-7 5 7 5V7z" />
<rect x="1" y="5" width="15" height="14" rx="2" />
</svg>
<span>摄像头正在被预览</span>
<span>{{ $t("desktop.cameraBeingViewed") }}</span>
</div>
<div class="panel-header-actions">
<button
class="panel-icon-btn"
@click="toggleCameraShareCollapse"
title="收起提示"
:title="$t('desktop.collapseCamera')"
>
<svg
width="14"
@ -424,7 +444,7 @@
<button
class="panel-icon-btn"
@click="endCamera"
title="结束摄像头"
:title="$t('desktop.endCamera')"
>
<svg
width="14"
@ -442,7 +462,7 @@
</div>
<div class="sharing-panel-body">
<div class="sharing-status-dot"></div>
<span>对方正在查看你的摄像头</span>
<span>{{ $t("desktop.peerViewingCamera") }}</span>
</div>
</div>
</Transition>
@ -453,8 +473,8 @@
class="camera-sharing-tab"
:class="cameraCollapsedClasses"
@click="toggleCameraShareCollapse"
title="展开摄像头预览提示"
aria-label="展开摄像头预览提示"
:title="$t('desktop.expandCameraHint')"
:aria-label="$t('desktop.expandCameraHint')"
>
<span class="sharing-status-dot"></span>
<svg
@ -515,8 +535,8 @@
</span>
<span>{{
incomingCallRequest.type === "camera"
? "摄像头请求"
: "语音通话请求"
? $t("desktop.cameraRequest")
: $t("desktop.voiceCallRequest")
}}</span>
</div>
</div>
@ -554,8 +574,8 @@
<div class="call-status-text">
{{
incomingCallRequest.type === "camera"
? "对方请求查看摄像头"
: "对方请求语音通话"
? $t("desktop.peerRequestCamera")
: $t("desktop.peerRequestCall")
}}
</div>
</div>
@ -565,14 +585,14 @@
:disabled="isIncomingCallHandling"
@click="rejectIncomingCall"
>
拒绝
{{ $t("desktop.reject") }}
</button>
<button
class="incoming-action-btn accept"
:disabled="isIncomingCallHandling"
@click="acceptIncomingCall"
>
接听
{{ $t("desktop.accept") }}
</button>
</div>
</div>
@ -601,13 +621,13 @@
/>
</svg>
</span>
<span>语音通话</span>
<span>{{ $t("desktop.voiceCall") }}</span>
</div>
<div class="panel-header-actions">
<button
class="panel-icon-btn"
@click="toggleCallCollapse"
title="收起通话"
:title="$t('desktop.collapseCall')"
>
<svg
width="14"
@ -620,7 +640,11 @@
<polyline points="18 15 12 9 6 15" />
</svg>
</button>
<button class="panel-icon-btn" @click="endCall" title="结束通话">
<button
class="panel-icon-btn"
@click="endCall"
:title="$t('desktop.endCall')"
>
<svg
width="14"
height="14"
@ -651,7 +675,7 @@
</svg>
</div>
<div class="call-status-text">
{{ callStream ? "通话中" : "正在建立连接..." }}
{{ callStream ? $t("desktop.inCall") : $t("desktop.connecting") }}
</div>
<div class="call-duration">{{ callDurationStr }}</div>
</div>
@ -660,7 +684,7 @@
class="control-btn mute-btn"
:class="{ 'is-muted': isCallMuted }"
@click="toggleCallMuted"
:title="isCallMuted ? '取消静音' : '静音'"
:title="isCallMuted ? $t('desktop.unmute') : $t('desktop.mute')"
>
<svg
v-if="!isCallMuted"
@ -696,7 +720,7 @@
<button
class="control-btn end-call-btn"
@click="endCall"
title="结束通话"
:title="$t('desktop.endCall')"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path
@ -719,7 +743,7 @@
(isCameraLocalSharing && !isCameraShareCollapsed),
}"
@click="toggleCallCollapse"
title="展开通话"
:title="$t('desktop.expandCall')"
>
<div class="call-collapsed-pulse"></div>
<svg
@ -741,9 +765,12 @@
<script setup lang="ts">
import { computed, nextTick, ref, onMounted, onUnmounted } from "vue";
import { useI18n } from "vue-i18n";
import { peer } from "../utils/peer";
import { message } from "ant-design-vue";
const { t } = useI18n();
const props = defineProps<{
peerId: string;
}>();

View File

@ -1,7 +1,7 @@
<template>
<Modal
:open="visible"
title="权限设置"
:title="$t('permission.title')"
@ok="handleOk"
@cancel="handleCancel"
:maskClosable="false"
@ -13,9 +13,11 @@
:key="permission"
>
<Checkbox v-model:checked="localPermissionSet[permission]">
{{ permissionLabels[permission] }}
{{ $t(`permission.${permission}`) }}
</Checkbox>
<div class="permission-desc">{{ permissionDescs[permission] }}</div>
<div class="permission-desc">
{{ $t(`permission.${permission}Desc`) }}
</div>
</div>
</div>
</Modal>
@ -36,28 +38,11 @@ const permissionsToShow = computed(() =>
),
);
const permissionLabels: Record<Permission, string> = {
[Permission.edit]: "编辑权限",
[Permission.view]: "查看权限",
[Permission.download]: "下载权限",
[Permission.desktop]: "桌面预览权限",
[Permission.call]: "语音通话权限",
[Permission.camera]: "摄像头权限",
};
const permissionDescs: Record<Permission, string> = {
[Permission.edit]: "允许创建、修改、删除文件和文件夹",
[Permission.view]: "允许查看文件内容和目录结构",
[Permission.download]: "允许传输文件到本地",
[Permission.desktop]: "允许对方请求预览当前桌面",
[Permission.call]: "允许对方请求语音通话",
[Permission.camera]: "允许对方请求查看当前摄像头",
};
const checkCamera = async () => {
try {
const devices = await navigator.mediaDevices?.enumerateDevices?.();
hasCamera.value = devices?.some((device) => device.kind === "videoinput") ?? false;
hasCamera.value =
devices?.some((device) => device.kind === "videoinput") ?? false;
} catch {
hasCamera.value = false;
}

View File

@ -8,12 +8,16 @@
:disabled="!loadedFile.name"
@click="downloadFile"
>
{{ fileTypeCheck == "unsupported-size" ? "远程传输" : "浏览器下载" }}
{{
fileTypeCheck == "unsupported-size"
? $t("fileReader.remoteTransfer")
: $t("fileReader.browserDownload")
}}
</button>
<button v-if="canEdit" @click="toggleEdit">
{{ isEditing ? "保存" : "编辑" }}
{{ isEditing ? $t("fileReader.save") : $t("fileReader.edit") }}
</button>
<button @click="$emit('close')">关闭</button>
<button @click="$emit('close')">{{ $t("fileReader.close") }}</button>
</div>
</div>
@ -59,7 +63,7 @@
<div v-else-if="fileTypeCheck === 'video'" class="video-viewer">
<video controls>
<source :src="fileUrl" :type="mimeType" />
您的浏览器不支持视频播放
{{ $t("fileReader.videoNotSupported") }}
</video>
</div>
@ -67,7 +71,7 @@
<div v-else-if="fileTypeCheck === 'audio'" class="audio-viewer">
<audio controls>
<source :src="fileUrl" :type="mimeType" />
您的浏览器不支持音频播放
{{ $t("fileReader.audioNotSupported") }}
</audio>
</div>
@ -75,7 +79,7 @@
<div v-else-if="fileTypeCheck === 'code'" class="code-viewer">
<div class="code-header">
<select v-model="selectedLanguage">
<option value="auto">自动检测</option>
<option value="auto">{{ $t("fileReader.autoDetect") }}</option>
<option value="javascript">JavaScript</option>
<option value="typescript">TypeScript</option>
<option value="html">HTML</option>
@ -89,7 +93,7 @@
<option value="sql">SQL</option>
<option value="shell">Shell</option>
<option value="json">JSON</option>
<option value="other">其他</option>
<option value="other">{{ $t("fileReader.other") }}</option>
</select>
</div>
<div v-if="isEditing" class="editor">
@ -107,15 +111,15 @@
<div>
{{
fileTypeCheck === "unsupported-size"
? "文件过大,无法预览,请传输后查看"
: "不支持预览该类型的文件"
? $t("fileReader.fileTooLarge")
: $t("fileReader.unsupportedType")
}}
</div>
<div>
<Button
v-if="fileTypeCheck != 'unsupported-size'"
@click="openWithText = true"
>以文本打开</Button
>{{ $t("fileReader.openAsText") }}</Button
>
</div>
</div>
@ -125,6 +129,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from "vue";
import { useI18n } from "vue-i18n";
import {
fileMgrInstance,
type FileData,
@ -136,6 +141,8 @@ import { Modal } from "ant-design-vue";
import { localCurrentFile, remoteCurrentFile } from "../utils/common";
import { NEED_CHUNK_FILE_SIZE_PREVIEW } from "../utils/fileTransfer";
const { t } = useI18n();
const props = defineProps<{
file: FileInfo;
}>();
@ -167,19 +174,17 @@ const downloadFile = async () => {
await props.file.getFile(
false,
localCurrentFile.value.path.replace(
(
await fileMgrInstance.getRootFile()
).path,
""
(await fileMgrInstance.getRootFile()).path,
"",
),
remoteCurrentFile.value.path
remoteCurrentFile.value.path,
);
localCurrentFile.value.loadLocalDirectory();
return;
}
Modal.confirm({
title: "下载文件",
content: `确定要下载${props.file.name}吗?`,
title: t("fileReader.downloadTitle"),
content: t("fileReader.downloadConfirm", { name: props.file.name }),
onOk: () => {
const blob = new Blob([loadedFile.value.buffer], {
type: loadedFile.value.type || "application/octet-stream",
@ -313,7 +318,7 @@ const toggleEdit = async () => {
isEditing.value = false;
} catch (err) {
console.error("保存文件失败:", err);
alert("保存文件失败: " + (err as Error).message);
alert(t("fileReader.saveFailed") + ": " + (err as Error).message);
}
} else {
//
@ -326,7 +331,7 @@ const toggleEdit = async () => {
//
const adjustEditorHeight = () => {
const textarea = document.querySelector(
".editor textarea"
".editor textarea",
) as HTMLTextAreaElement;
if (textarea) {
textarea.style.height = "auto";
@ -388,7 +393,7 @@ const handleTouchStart = (e: TouchEvent) => {
const touch2 = e.touches[1];
lastTouchDistance.value = Math.hypot(
touch2.clientX - touch1.clientX,
touch2.clientY - touch1.clientY
touch2.clientY - touch1.clientY,
);
} else if (e.touches.length === 1) {
isDragging.value = true;
@ -404,7 +409,7 @@ const handleTouchMove = (e: TouchEvent) => {
const touch2 = e.touches[1];
const distance = Math.hypot(
touch2.clientX - touch1.clientX,
touch2.clientY - touch1.clientY
touch2.clientY - touch1.clientY,
);
const delta = distance - lastTouchDistance.value;

View File

@ -1,9 +1,11 @@
<template>
<div class="transfer-container">
<div class="transfer-header">
传输列表 ({{ tasks.length }})
{{ $t("transfer.title") }} ({{ tasks.length }})
<div class="transfer-header-btns">
<Button type="ghost" class="clear-btn" @click="clearTasks">清理</Button>
<Button type="ghost" class="clear-btn" @click="clearTasks">{{
$t("transfer.clear")
}}</Button>
<Button
type="ghost"
class="toggle-btn"
@ -49,8 +51,11 @@ import {
TransferStatus,
} from "../utils/fileTransfer";
import { type Ref, ref } from "vue";
import { useI18n } from "vue-i18n";
import { formatSize } from "../utils/common";
const { t } = useI18n();
const isExpanded = ref(true);
const tasks: Ref<TransferTask[]> = ref([]);
@ -63,12 +68,12 @@ const clearTasks = () => {
const getStatusText = (status: TransferStatus) => {
const statusMap: Record<TransferStatus, string> = {
[TransferStatus.WAITING]: "等待中",
[TransferStatus.SENDING]: "发送中",
[TransferStatus.RECEIVING]: "接收中",
[TransferStatus.COMPLETED]: "已完成",
[TransferStatus.ERROR]: "失败",
[TransferStatus.PAUSED]: "已暂停",
[TransferStatus.WAITING]: t("transfer.waiting"),
[TransferStatus.SENDING]: t("transfer.sending"),
[TransferStatus.RECEIVING]: t("transfer.receiving"),
[TransferStatus.COMPLETED]: t("transfer.completed"),
[TransferStatus.ERROR]: t("transfer.error"),
[TransferStatus.PAUSED]: t("transfer.paused"),
};
return statusMap[status] || status;
};

View File

@ -3,17 +3,25 @@
<div class="panel-header">
<div class="path-nav">
<Button @click="navigateLocal('..')" :disabled="!canNavigateUpLocal">
上级目录
{{ $t("fileView.parentDir") }}
</Button>
<Select class="sort-select" :value="sortType" @change="refreshLocal">
<SelectOption value="name">按名称</SelectOption>
<SelectOption value="size">按大小</SelectOption>
<SelectOption value="type">按类型</SelectOption>
<SelectOption value="date">按日期</SelectOption>
<SelectOption value="name">{{
$t("fileView.sortByName")
}}</SelectOption>
<SelectOption value="size">{{
$t("fileView.sortBySize")
}}</SelectOption>
<SelectOption value="type">{{
$t("fileView.sortByType")
}}</SelectOption>
<SelectOption value="date">{{
$t("fileView.sortByDate")
}}</SelectOption>
</Select>
<Button danger v-if="!isRemote" @click="showPermissionSetting"
>权限设置</Button
>
<Button danger v-if="!isRemote" @click="showPermissionSetting">{{
$t("fileView.permissionSettings")
}}</Button>
</div>
<div>
<Breadcrumb>
@ -28,13 +36,13 @@
style="text-align: center"
v-if="isRemote && !currentDirInfo?.isInit"
>
<Button @click="refreshLocal">初始化</Button>
<Button @click="refreshLocal">{{ $t("fileView.initialize") }}</Button>
</div>
<div v-else-if="!currentDirInfo?.isInit">
<div @click="selectLocalDirectory" class="file-item">
<div class="file-icon">📁</div>
<div class="file-info">
<div class="file-name">请选择一个目录</div>
<div class="file-name">{{ $t("fileView.selectDirHint") }}</div>
</div>
</div>
</div>
@ -49,37 +57,51 @@
/>
</div>
<div v-if="currentDirInfo?.isInit" class="panel-footer">
<Button v-if="!isRemote" @click="selectLocalDirectory">选择目录</Button>
<Button @click="refreshLocal">刷新</Button>
<Button @click="createNewFolder">新建文件夹</Button>
<Button @click="createNewFile">新建文件</Button>
<Button v-if="!isRemote" @click="selectLocalDirectory">{{
$t("fileView.selectDirectory")
}}</Button>
<Button @click="refreshLocal">{{ $t("fileView.refresh") }}</Button>
<Button @click="createNewFolder">{{ $t("fileView.newFolder") }}</Button>
<Button @click="createNewFile">{{ $t("fileView.newFile") }}</Button>
<Button @click="deleteSelected" :disabled="!hasSelectedFiles">
删除
{{ $t("fileView.delete") }}
</Button>
</div>
<!-- 右键菜单 -->
<div v-if="showMenu" class="context-menu" :style="menuPosition">
<div v-if="activeFile">
<div class="menu-item" @click="handleRename">重命名</div>
<div class="menu-item" @click="handleDelete">删除</div>
<div class="menu-item" @click="handleRename">
{{ $t("fileView.rename") }}
</div>
<div class="menu-item" @click="handleDelete">
{{ $t("fileView.delete") }}
</div>
<div
class="menu-item"
v-if="props.isRemote && remoteCurrentFile?.isInit"
@click="handleDownload"
>
下载
{{ $t("fileView.download") }}
</div>
</div>
<div v-else-if="!currentDirInfo?.isInit">
<div class="menu-item" @click="selectLocalDirectory">选择本地目录</div>
<div class="menu-item" @click="selectLocalDirectory">
{{ $t("fileView.selectLocalDir") }}
</div>
</div>
<div v-else>
<div class="menu-item" @click="refreshLocal">刷新</div>
<div class="menu-item" @click="createNewFolder">新建文件夹</div>
<div class="menu-item" @click="createNewFile">新建文件</div>
<div class="menu-item" @click="refreshLocal">
{{ $t("fileView.refresh") }}
</div>
<div class="menu-item" @click="createNewFolder">
{{ $t("fileView.newFolder") }}
</div>
<div class="menu-item" @click="createNewFile">
{{ $t("fileView.newFile") }}
</div>
<div class="menu-item" @click="deleteSelected" v-if="hasSelectedFiles">
删除
{{ $t("fileView.delete") }}
</div>
</div>
</div>
@ -106,6 +128,7 @@ import {
h,
reactive,
} from "vue";
import { useI18n } from "vue-i18n";
import { Button, Select, SelectOption, Modal } from "ant-design-vue";
import { FileInfo, fileMgrInstance } from "../utils/fileMgr";
import FileItem from "./fileItem.vue";
@ -114,6 +137,8 @@ import { Breadcrumb, BreadcrumbItem } from "ant-design-vue";
import emitter, { EmitterEvent } from "../utils/emitter";
import { localCurrentFile, remoteCurrentFile } from "../utils/common";
import FilePermissionSetDialog from "./filePermissionSetDialog.vue";
const { t } = useI18n();
const props = defineProps<{
isRemote: boolean;
selectedFiles: string[];
@ -173,12 +198,10 @@ const handleDownload = async () => {
await file.getFile(
false,
localCurrentFile.value.path.replace(
(
await fileMgrInstance.getRootFile()
).path,
""
(await fileMgrInstance.getRootFile()).path,
"",
),
remoteCurrentFile.value.path
remoteCurrentFile.value.path,
);
};
@ -274,12 +297,12 @@ const handleRename = async () => {
newFileName.value = activeFile.value.name;
const acf = activeFile.value;
Modal.confirm({
title: "重命名",
title: t("fileView.renameTitle"),
class: "custom-modal",
content: h("div", [
h("input", {
class: "ant-input",
placeholder: "请输入新的名称",
placeholder: t("fileView.inputNewName"),
value: newFileName.value,
onInput: (e: Event) => {
newFileName.value = (e.target as HTMLInputElement).value;
@ -290,8 +313,8 @@ const handleRename = async () => {
},
}),
]),
okText: "确定",
cancelText: "取消",
okText: t("common.okText"),
cancelText: t("common.cancelText"),
async onOk() {
if (!newFileName.value || newFileName.value === acf.name) {
return Promise.reject();
@ -302,7 +325,7 @@ const handleRename = async () => {
} catch (err) {
console.error("重命名失败:", err);
Modal.error({
title: "重命名失败",
title: t("fileView.renameTitle") + t("common.failed"),
content: (err as Error).message || (err as string),
});
}
@ -327,13 +350,14 @@ const deleteSelected = async () => {
if (!selectedLocalFiles.value.length) return;
Modal.confirm({
title: "确认删除",
content: `确定要删除以下文件吗?<br>${selectedLocalFiles.value.join(
"<br>"
)}<br> ${selectedLocalFiles.value.length} 个文件`,
okText: "删除",
title: t("fileView.confirmDelete"),
content: t("fileView.deleteConfirmContent", {
files: selectedLocalFiles.value.join("<br>"),
count: selectedLocalFiles.value.length,
}),
okText: t("fileView.deleteBtn"),
okType: "danger",
cancelText: "取消",
cancelText: t("fileView.cancelBtn"),
async onOk() {
try {
for (const fileName of selectedLocalFiles.value) {
@ -347,7 +371,7 @@ const deleteSelected = async () => {
} catch (err) {
console.error("批量删除失败:", err);
Modal.error({
title: "删除失败",
title: t("common.delete") + t("common.failed"),
content: (err as Error).message,
});
}
@ -361,12 +385,12 @@ const createNewFolder = async () => {
folderName.value = "";
Modal.confirm({
title: "新建文件夹",
title: t("fileView.createFolderTitle"),
class: "custom-modal",
content: h("div", [
h("input", {
class: "ant-input",
placeholder: "请输入文件夹名称",
placeholder: t("fileView.inputFolderName"),
value: folderName.value,
onInput: (e: Event) => {
folderName.value = (e.target as HTMLInputElement).value;
@ -377,13 +401,13 @@ const createNewFolder = async () => {
},
}),
]),
okText: "创建",
cancelText: "取消",
okText: t("fileView.createBtn"),
cancelText: t("fileView.cancelBtn"),
async onOk() {
if (!folderName.value) {
Modal.warning({
title: "提示",
content: "请输入文件夹名称",
title: t("common.warning"),
content: t("fileView.warningInputFolderName"),
});
return Promise.reject();
}
@ -393,7 +417,7 @@ const createNewFolder = async () => {
} catch (err) {
console.error("创建文件夹失败:", err);
Modal.error({
title: "创建失败",
title: t("fileView.createFolderTitle") + t("common.failed"),
content: (err as Error).message,
});
}
@ -407,12 +431,12 @@ const createNewFile = async () => {
fileName.value = "";
Modal.confirm({
title: "新建文件",
title: t("fileView.createFileTitle"),
class: "custom-modal",
content: h("div", [
h("input", {
class: "ant-input",
placeholder: "请输入文件名称",
placeholder: t("fileView.inputFileName"),
value: fileName.value,
onInput: (e: Event) => {
fileName.value = (e.target as HTMLInputElement).value;
@ -423,13 +447,13 @@ const createNewFile = async () => {
},
}),
]),
okText: "创建",
cancelText: "取消",
okText: t("fileView.createBtn"),
cancelText: t("fileView.cancelBtn"),
async onOk() {
if (!fileName.value) {
Modal.warning({
title: "提示",
content: "请输入文件名称",
title: t("common.warning"),
content: t("fileView.warningInputFileName"),
});
return Promise.reject();
}
@ -440,7 +464,7 @@ const createNewFile = async () => {
} catch (err) {
console.error("创建文件失败:", err);
Modal.error({
title: "创建失败",
title: t("fileView.createFileTitle") + t("common.failed"),
content: (err as Error).message,
});
}

View File

@ -20,7 +20,7 @@
style="display: none"
/>
<button class="upload-btn" @click="triggerFileUpload">
上传图片/视频
{{ $t('shader.uploadImageVideo') }}
</button>
</div>
</div>

View File

@ -3,185 +3,197 @@
<div class="call-status">
<h2>{{ callStatus }}</h2>
<div v-if="myPeerId" class="peer-id">
我的ID: {{ myPeerId }}
{{ $t("voice.myId") }}: {{ myPeerId }}
</div>
<button @click="copyPeerId">复制</button>
<button @click="copyPeerId">{{ $t("voice.copy") }}</button>
</div>
<div class="delay-time">
<h2>延迟时间: {{ delayTime + 'ms' }}</h2>
<h2>{{ $t("voice.delay") }}: {{ delayTime + "ms" }}</h2>
</div>
<div class="connection-controls" v-if="!isInCall">
<input type="text" v-model="remotePeerId" placeholder="输入对方的ID" :disabled="isInCall" />
<button type="reset" class="call-btn start-call" @click="startCall" :disabled="!remotePeerId || isInCall">
呼叫
<input
type="text"
v-model="remotePeerId"
:placeholder="$t('voice.enterPeerId')"
:disabled="isInCall"
/>
<button
type="reset"
class="call-btn start-call"
@click="startCall"
:disabled="!remotePeerId || isInCall"
>
{{ $t("voice.call") }}
</button>
</div>
<div class="call-controls" v-if="isInCall">
<button class="call-btn end-call" @click="endCall">
结束通话
{{ $t("voice.endCall") }}
</button>
<button class="mute-btn" :class="{ muted: isMuted }" @click="toggleMute">
{{ isMuted ? '取消静音' : '静音' }}
{{ isMuted ? $t("voice.unmute") : $t("voice.mute") }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import Peer, { type MediaConnection } from 'peerjs'
import { ref, onMounted, onUnmounted } from "vue";
import Peer, { type MediaConnection } from "peerjs";
const myPeerId = ref('')
const remotePeerId = ref('')
const isInCall = ref(false)
const isMuted = ref(false)
const callStatus = ref('准备连接...')
const localStream = ref<MediaStream | null>(null)
const peer = ref<Peer | null>(null)
const currentCall = ref<MediaConnection | null>(null)
const delayTime = ref(0)
let statsInterval: number | null = null
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const myPeerId = ref("");
const remotePeerId = ref("");
const isInCall = ref(false);
const isMuted = ref(false);
const callStatus = ref(t("voice.preparing"));
const localStream = ref<MediaStream | null>(null);
const peer = ref<Peer | null>(null);
const currentCall = ref<MediaConnection | null>(null);
const delayTime = ref(0);
let statsInterval: number | null = null;
const initPeer = () => {
peer.value = new Peer(
{
config: {
iceServers: [
{ urls: 'stun:8.134.35.244:3478' }
]
}
}
)
peer.value = new Peer({
config: {
iceServers: [{ urls: "stun:8.134.35.244:3478" }],
},
});
peer.value.on('open', (id) => {
myPeerId.value = id
callStatus.value = '准备就绪'
})
peer.value.on("open", (id) => {
myPeerId.value = id;
callStatus.value = t("voice.ready");
});
peer.value.on('call', async (call) => {
peer.value.on("call", async (call) => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false
})
video: false,
});
localStream.value = stream
call.answer(stream)
handleCall(call)
localStream.value = stream;
call.answer(stream);
handleCall(call);
isInCall.value = true
callStatus.value = '通话中'
isInCall.value = true;
callStatus.value = t("voice.inCall");
} catch (error) {
console.error('获取音频设备失败:', error)
callStatus.value = '无法访问麦克风'
console.error("获取音频设备失败:", error);
callStatus.value = t("voice.cannotAccessMic");
}
})
});
peer.value.on('error', (error) => {
console.error('PeerJS错误:', error)
callStatus.value = '连接错误'
})
}
peer.value.on("error", (error) => {
console.error("PeerJS错误:", error);
callStatus.value = t("voice.connectionError");
});
};
const copyPeerId = () => {
navigator.clipboard.writeText(myPeerId.value)
}
navigator.clipboard.writeText(myPeerId.value);
};
const updateDelayTime = async () => {
delayTime.value = 0
delayTime.value = 0;
if (currentCall.value && currentCall.value.peerConnection) {
const stats = await currentCall.value.peerConnection.getStats()
stats.forEach(report => {
if (report.type === 'candidate-pair' && report.state === 'succeeded') {
delayTime.value += report.currentRoundTripTime * 1000 //currentRoundTripTime
const stats = await currentCall.value.peerConnection.getStats();
stats.forEach((report) => {
if (report.type === "candidate-pair" && report.state === "succeeded") {
delayTime.value += report.currentRoundTripTime * 1000; //currentRoundTripTime
}
if (report.type === 'inbound-rtp' && report.kind === 'audio') {
if (report.type === "inbound-rtp" && report.kind === "audio") {
delayTime.value += Math.round(
report.jitter * 1000 || 0 + //
report.playoutDelay || 0 //
)
report.jitter * 1000 ||
0 + //
report.playoutDelay ||
0, //
);
}
})
});
}
}
};
const handleCall = (call: MediaConnection) => {
currentCall.value = call
currentCall.value = call;
statsInterval = window.setInterval(updateDelayTime, 1000)
statsInterval = window.setInterval(updateDelayTime, 1000);
call.on('stream', (remoteStream) => {
const audio = new Audio()
audio.srcObject = remoteStream
audio.play()
})
call.on("stream", (remoteStream) => {
const audio = new Audio();
audio.srcObject = remoteStream;
audio.play();
});
call.on('close', () => {
endCall()
})
}
call.on("close", () => {
endCall();
});
};
const startCall = async () => {
if (!peer.value || !remotePeerId.value) return
if (!peer.value || !remotePeerId.value) return;
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false
})
video: false,
});
localStream.value = stream
const call = peer.value.call(remotePeerId.value, stream)
handleCall(call)
localStream.value = stream;
const call = peer.value.call(remotePeerId.value, stream);
handleCall(call);
isInCall.value = true
callStatus.value = '通话中'
isInCall.value = true;
callStatus.value = t("voice.inCall");
} catch (error) {
console.error('获取音频设备失败:', error)
callStatus.value = '无法访问麦克风'
console.error("获取音频设备失败:", error);
callStatus.value = t("voice.cannotAccessMic");
}
}
};
const toggleMute = () => {
if (localStream.value) {
const audioTrack = localStream.value.getAudioTracks()[0]
isMuted.value = !isMuted.value
audioTrack.enabled = !isMuted.value
const audioTrack = localStream.value.getAudioTracks()[0];
isMuted.value = !isMuted.value;
audioTrack.enabled = !isMuted.value;
}
}
};
const endCall = () => {
if (statsInterval) {
clearInterval(statsInterval)
statsInterval = null
clearInterval(statsInterval);
statsInterval = null;
}
if (currentCall.value) {
currentCall.value.close()
currentCall.value = null
currentCall.value.close();
currentCall.value = null;
}
if (localStream.value) {
localStream.value.getTracks().forEach(track => track.stop())
localStream.value = null
localStream.value.getTracks().forEach((track) => track.stop());
localStream.value = null;
}
isInCall.value = false
isMuted.value = false
callStatus.value = '准备就绪'
delayTime.value = 0 //
}
isInCall.value = false;
isMuted.value = false;
callStatus.value = t("voice.ready");
delayTime.value = 0;
};
onMounted(() => {
initPeer()
})
initPeer();
});
onUnmounted(() => {
endCall()
endCall();
if (peer.value) {
peer.value.destroy()
peer.value.destroy();
}
})
});
</script>
<style scoped>
@ -230,7 +242,7 @@ onUnmounted(() => {
}
.connection-controls input:focus {
border-color: #2196F3;
border-color: #2196f3;
}
.call-controls {
@ -259,7 +271,7 @@ button:disabled {
}
.start-call {
background-color: #4CAF50;
background-color: #4caf50;
color: white;
}
@ -269,12 +281,12 @@ button:disabled {
}
.mute-btn {
background-color: #2196F3;
background-color: #2196f3;
color: white;
}
.mute-btn.muted {
background-color: #9E9E9E;
background-color: #9e9e9e;
}
button:hover:not(:disabled) {