first commit
This commit is contained in:
7
frontend/.gitignore
vendored
Normal file
7
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.gocache/
|
||||
.gomodcache/
|
||||
.gopath/
|
||||
tmp/
|
||||
*.exe
|
||||
node_modules
|
||||
|
||||
17
frontend/dist/assets/index-BhftK8R5.js
vendored
Normal file
17
frontend/dist/assets/index-BhftK8R5.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-jSzxC_eO.css
vendored
Normal file
1
frontend/dist/assets/index-jSzxC_eO.css
vendored
Normal file
File diff suppressed because one or more lines are too long
13
frontend/dist/index.html
vendored
Normal file
13
frontend/dist/index.html
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AirShare Pro</title>
|
||||
<script type="module" crossorigin src="/assets/index-BhftK8R5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-jSzxC_eO.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AirShare Pro</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1130
frontend/package-lock.json
generated
Normal file
1130
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
frontend/package.json
Normal file
18
frontend/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "airshare-pro-vue",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
2737
frontend/src/App.vue
Normal file
2737
frontend/src/App.vue
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/src/api/admin.js
Normal file
28
frontend/src/api/admin.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { http, withBearerToken } from './http'
|
||||
|
||||
function withAdminAuth(token) {
|
||||
return {
|
||||
headers: withBearerToken(token),
|
||||
}
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
login(username, password) {
|
||||
return http.post('/api/admin/login', {
|
||||
username,
|
||||
password,
|
||||
})
|
||||
},
|
||||
stats(token) {
|
||||
return http.get('/api/admin/stats', withAdminAuth(token))
|
||||
},
|
||||
config(token) {
|
||||
return http.get('/api/admin/config', withAdminAuth(token))
|
||||
},
|
||||
updateConfig(token, input) {
|
||||
return http.put('/api/admin/config', input, withAdminAuth(token))
|
||||
},
|
||||
recentTransfers(token) {
|
||||
return http.get('/api/admin/transfers/recent', withAdminAuth(token))
|
||||
},
|
||||
}
|
||||
18
frontend/src/api/devices.js
Normal file
18
frontend/src/api/devices.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { http } from './http'
|
||||
|
||||
export const devicesApi = {
|
||||
register(input) {
|
||||
return http.post('/api/devices/register', input)
|
||||
},
|
||||
heartbeat(deviceId) {
|
||||
return http.post('/api/devices/heartbeat', { device_id: deviceId })
|
||||
},
|
||||
listCandidates(deviceId) {
|
||||
return http.get('/api/devices/candidates', {
|
||||
query: { deviceId },
|
||||
})
|
||||
},
|
||||
listPendingDownloads(deviceId) {
|
||||
return http.get(`/api/devices/${encodeURIComponent(deviceId)}/pending-downloads`)
|
||||
},
|
||||
}
|
||||
94
frontend/src/api/http.js
Normal file
94
frontend/src/api/http.js
Normal file
@@ -0,0 +1,94 @@
|
||||
let deviceSession = {
|
||||
deviceId: '',
|
||||
token: '',
|
||||
}
|
||||
|
||||
function buildDeviceHeaders() {
|
||||
if (!deviceSession.deviceId || !deviceSession.token) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
'X-Device-ID': deviceSession.deviceId,
|
||||
'X-Device-Token': deviceSession.token,
|
||||
}
|
||||
}
|
||||
|
||||
function buildHeaders(headers = {}, hasBody = false) {
|
||||
return {
|
||||
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
|
||||
...buildDeviceHeaders(),
|
||||
...headers,
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(path, query) {
|
||||
if (!query || Object.keys(query).length === 0) {
|
||||
return path
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
params.set(key, String(value))
|
||||
}
|
||||
})
|
||||
|
||||
const queryString = params.toString()
|
||||
return queryString ? `${path}?${queryString}` : path
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const hasBody = options.body !== undefined
|
||||
const response = await fetch(buildUrl(path, options.query), {
|
||||
method: options.method || 'GET',
|
||||
headers: buildHeaders(options.headers, hasBody),
|
||||
body: hasBody ? JSON.stringify(options.body) : undefined,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload.error || `Request failed: ${response.status}`)
|
||||
error.status = response.status
|
||||
throw error
|
||||
}
|
||||
|
||||
return payload.data
|
||||
}
|
||||
|
||||
export const http = {
|
||||
get(path, options = {}) {
|
||||
return request(path, { ...options, method: 'GET' })
|
||||
},
|
||||
post(path, body, options = {}) {
|
||||
return request(path, { ...options, method: 'POST', body })
|
||||
},
|
||||
put(path, body, options = {}) {
|
||||
return request(path, { ...options, method: 'PUT', body })
|
||||
},
|
||||
patch(path, body, options = {}) {
|
||||
return request(path, { ...options, method: 'PATCH', body })
|
||||
},
|
||||
}
|
||||
|
||||
export function setDeviceSession(deviceId, token) {
|
||||
deviceSession = {
|
||||
deviceId: deviceId || '',
|
||||
token: token || '',
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDeviceSession() {
|
||||
setDeviceSession('', '')
|
||||
}
|
||||
|
||||
export function getDeviceSessionHeaders() {
|
||||
return buildDeviceHeaders()
|
||||
}
|
||||
|
||||
export function withBearerToken(token) {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
}
|
||||
}
|
||||
5
frontend/src/api/index.js
Normal file
5
frontend/src/api/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export { adminApi } from './admin'
|
||||
export { devicesApi } from './devices'
|
||||
export { roomsApi } from './rooms'
|
||||
export { runtimeApi } from './runtime'
|
||||
export { transfersApi } from './transfers'
|
||||
23
frontend/src/api/rooms.js
Normal file
23
frontend/src/api/rooms.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { http } from './http'
|
||||
|
||||
export const roomsApi = {
|
||||
create(creatorDeviceId) {
|
||||
return http.post('/api/rooms', {
|
||||
creator_device_id: creatorDeviceId,
|
||||
})
|
||||
},
|
||||
get(code) {
|
||||
return http.get(`/api/rooms/${encodeURIComponent(code)}`)
|
||||
},
|
||||
join(code, joinerDeviceId) {
|
||||
return http.post('/api/rooms/join', {
|
||||
code,
|
||||
joiner_device_id: joinerDeviceId,
|
||||
})
|
||||
},
|
||||
cancel(code, requesterId) {
|
||||
return http.post(`/api/rooms/${encodeURIComponent(code)}/cancel`, {
|
||||
requester_id: requesterId,
|
||||
})
|
||||
},
|
||||
}
|
||||
7
frontend/src/api/runtime.js
Normal file
7
frontend/src/api/runtime.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { http } from './http'
|
||||
|
||||
export const runtimeApi = {
|
||||
config() {
|
||||
return http.get('/api/runtime/config')
|
||||
},
|
||||
}
|
||||
55
frontend/src/api/transfers.js
Normal file
55
frontend/src/api/transfers.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { getDeviceSessionHeaders, http } from './http'
|
||||
|
||||
export const transfersApi = {
|
||||
create(input) {
|
||||
return http.post('/api/transfers', input)
|
||||
},
|
||||
presignFallback(id) {
|
||||
return http.post(`/api/transfers/${encodeURIComponent(id)}/fallback/presign`, {})
|
||||
},
|
||||
uploadFallback(id, file, onProgress) {
|
||||
return uploadFile(`/api/transfers/${encodeURIComponent(id)}/fallback/upload`, file, onProgress)
|
||||
},
|
||||
updateStatus(id, input) {
|
||||
return http.patch(`/api/transfers/${encodeURIComponent(id)}/status`, input)
|
||||
},
|
||||
}
|
||||
|
||||
function uploadFile(url, file, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('PUT', url)
|
||||
xhr.responseType = 'json'
|
||||
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
|
||||
Object.entries(getDeviceSessionHeaders()).forEach(([key, value]) => {
|
||||
xhr.setRequestHeader(key, value)
|
||||
})
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (!event.lengthComputable || typeof onProgress !== 'function') {
|
||||
return
|
||||
}
|
||||
onProgress(Math.round((event.loaded / event.total) * 100))
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
const payload = xhr.response || safeParseJson(xhr.responseText)
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(payload.data)
|
||||
return
|
||||
}
|
||||
reject(new Error(payload?.error || `Upload failed: ${xhr.status}`))
|
||||
}
|
||||
|
||||
xhr.onerror = () => reject(new Error('Upload failed'))
|
||||
xhr.send(file)
|
||||
})
|
||||
}
|
||||
|
||||
function safeParseJson(value) {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
219
frontend/src/components/AdminPanel.vue
Normal file
219
frontend/src/components/AdminPanel.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<script setup>
|
||||
import GlassCard from './GlassCard.vue'
|
||||
import LocalIcon from './LocalIcon.vue'
|
||||
|
||||
defineProps({
|
||||
stats: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
records: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
fileLimit: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
minioCapacity: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['exit', 'save-config', 'update:file-limit', 'update:minio-capacity'])
|
||||
|
||||
function formatLimitValue(limitMb) {
|
||||
const value = Number(limitMb) || 0
|
||||
if (value >= 1024) {
|
||||
return `${(value / 1024).toFixed(value % 1024 === 0 ? 0 : 1)} GB`
|
||||
}
|
||||
|
||||
return `${value} MB`
|
||||
}
|
||||
|
||||
function formatCapacityValue(capacityGb) {
|
||||
const value = Number(capacityGb) || 0
|
||||
if (value >= 1024) {
|
||||
return `${(value / 1024).toFixed(value % 1024 === 0 ? 0 : 1)} TB`
|
||||
}
|
||||
|
||||
return `${value} GB`
|
||||
}
|
||||
|
||||
function getStatStyle(tone) {
|
||||
if (tone === 'blue') {
|
||||
return { color: 'var(--accent-blue)' }
|
||||
}
|
||||
|
||||
if (tone === 'cyan') {
|
||||
return { color: 'var(--accent-cyan)' }
|
||||
}
|
||||
|
||||
if (tone === 'success') {
|
||||
return { color: 'var(--success-green)' }
|
||||
}
|
||||
|
||||
if (tone === 'danger') {
|
||||
return { color: 'var(--danger-red)' }
|
||||
}
|
||||
|
||||
return { color: 'var(--text-main)' }
|
||||
}
|
||||
|
||||
function getRecordStyle(tone) {
|
||||
if (tone === 'success') {
|
||||
return { color: 'var(--success-green)', fontWeight: 500 }
|
||||
}
|
||||
|
||||
if (tone === 'primary') {
|
||||
return { color: 'var(--accent-blue)', fontWeight: 500 }
|
||||
}
|
||||
|
||||
return { color: 'var(--danger-red)', fontWeight: 500 }
|
||||
}
|
||||
|
||||
function getFluidStyle(percent) {
|
||||
const value = Number(percent) || 0
|
||||
return {
|
||||
'--fluid-level': `${Math.max(0, Math.min(value, 100))}%`,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-panel active">
|
||||
<div class="card admin-header-card">
|
||||
<div class="transfer-head transfer-head-compact">
|
||||
<div class="connected-to">
|
||||
<h2 class="admin-title">管理控制台</h2>
|
||||
<p class="admin-subtitle">AirShare Pro System Dashboard</p>
|
||||
</div>
|
||||
<button class="btn-small-primary" type="button" @click="$emit('exit')">退出管理</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-grid admin-summary-grid">
|
||||
<GlassCard class="admin-stats-card" title="系统运行状态">
|
||||
<div class="admin-stats-panel">
|
||||
<div class="admin-stats-row">
|
||||
<div
|
||||
v-for="stat in stats"
|
||||
:key="stat.label"
|
||||
class="admin-stat-item"
|
||||
:class="{ 'admin-stat-item-fluid': stat.kind === 'minio' }"
|
||||
>
|
||||
<template v-if="stat.kind === 'minio'">
|
||||
<div class="admin-fluid-card" :style="getFluidStyle(stat.percent)">
|
||||
<div class="admin-fluid-fill">
|
||||
<span class="admin-fluid-wave admin-fluid-wave-a"></span>
|
||||
<span class="admin-fluid-wave admin-fluid-wave-b"></span>
|
||||
</div>
|
||||
<div class="admin-fluid-content">
|
||||
<div class="admin-fluid-icon">
|
||||
<LocalIcon name="save" size="18" />
|
||||
</div>
|
||||
<div class="admin-fluid-copy">
|
||||
<h3 :style="getStatStyle(stat.tone)">{{ stat.value }}</h3>
|
||||
<p>{{ stat.label }}</p>
|
||||
<small>{{ stat.detail }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="admin-stat-kicker">实时指标</span>
|
||||
<h3 :style="getStatStyle(stat.tone)">
|
||||
{{ stat.value }}<span v-if="stat.suffix" class="stat-suffix">{{ stat.suffix }}</span>
|
||||
</h3>
|
||||
<p>{{ stat.label }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard class="admin-config-card" title="核心参数配置">
|
||||
<div class="admin-config-stack">
|
||||
<div class="text-input-group admin-config-row admin-config-row-field admin-config-row-last">
|
||||
<div class="admin-field-meta">
|
||||
<label class="admin-field-label" for="admin-file-limit">单文件大小限制</label>
|
||||
<p class="admin-field-hint">单位为 MB,超过该阈值的文件会按当前后端策略处理。</p>
|
||||
</div>
|
||||
<div class="admin-field-control-row">
|
||||
<input
|
||||
id="admin-file-limit"
|
||||
:value="fileLimit"
|
||||
min="1"
|
||||
placeholder="10240"
|
||||
type="number"
|
||||
@input="$emit('update:file-limit', Number($event.target.value) || 0)"
|
||||
/>
|
||||
<button title="保存配置" type="button" @click="$emit('save-config')">
|
||||
<LocalIcon name="save" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-input-group admin-config-row admin-config-row-field admin-config-row-last">
|
||||
<div class="admin-field-meta">
|
||||
<label class="admin-field-label" for="admin-minio-capacity">MinIO 总容量</label>
|
||||
<p class="admin-field-hint">单位为 GB,用于容量卡和液位比例计算。</p>
|
||||
</div>
|
||||
<div class="admin-field-control-row">
|
||||
<input
|
||||
id="admin-minio-capacity"
|
||||
:value="minioCapacity"
|
||||
min="1"
|
||||
placeholder="120"
|
||||
type="number"
|
||||
@input="$emit('update:minio-capacity', Number($event.target.value) || 0)"
|
||||
/>
|
||||
<button title="保存配置" type="button" @click="$emit('save-config')">
|
||||
<LocalIcon name="save" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-config-insights">
|
||||
<div class="admin-config-highlight">
|
||||
<span class="admin-config-badge">ACTIVE POLICY</span>
|
||||
<h3>{{ formatLimitValue(fileLimit) }}</h3>
|
||||
<p>当前单文件阈值。超过该体积后,文件会按后端已设定的传输与存档策略处理。</p>
|
||||
</div>
|
||||
<div class="admin-config-highlight">
|
||||
<span class="admin-config-badge">MINIO CAPACITY</span>
|
||||
<h3>{{ formatCapacityValue(minioCapacity) }}</h3>
|
||||
<p>当前 MinIO 总容量基线,用于后台容量展示与液位占比计算。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
<GlassCard class="admin-table-card" title="最近传输记录(Top 5)">
|
||||
<div class="admin-table-wrapper">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>发送端特征</th>
|
||||
<th>传输类型</th>
|
||||
<th>数据量</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="record in records" :key="`${record.time}-${record.peer}`">
|
||||
<td>{{ record.time }}</td>
|
||||
<td>{{ record.peer }}</td>
|
||||
<td>{{ record.type }}</td>
|
||||
<td>{{ record.size }}</td>
|
||||
<td :style="getRecordStyle(record.tone)">{{ record.status }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</template>
|
||||
50
frontend/src/components/AppFooter.vue
Normal file
50
frontend/src/components/AppFooter.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
const emit = defineEmits(['request-admin'])
|
||||
|
||||
const clickCount = ref(0)
|
||||
let resetTimer = null
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (resetTimer) {
|
||||
window.clearTimeout(resetTimer)
|
||||
}
|
||||
})
|
||||
|
||||
function handleVersionClick() {
|
||||
clickCount.value += 1
|
||||
|
||||
if (clickCount.value === 1) {
|
||||
resetTimer = window.setTimeout(() => {
|
||||
clickCount.value = 0
|
||||
resetTimer = null
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
if (clickCount.value >= 5) {
|
||||
if (resetTimer) {
|
||||
window.clearTimeout(resetTimer)
|
||||
resetTimer = null
|
||||
}
|
||||
|
||||
clickCount.value = 0
|
||||
emit('request-admin')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="footer">
|
||||
<div>
|
||||
© 2026 AirShare Pro. All rights reserved.
|
||||
<span class="divider-line">|</span>
|
||||
<span id="admin-trigger" title="点击 5 次进入后台" @click="handleVersionClick">V 1.0.0</span>
|
||||
</div>
|
||||
<div style="font-size: 12px; margin-top: 4px">
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noreferrer">
|
||||
粤ICP备2026888888号-1
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
22
frontend/src/components/AppHeader.vue
Normal file
22
frontend/src/components/AppHeader.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
import LocalIcon from './LocalIcon.vue'
|
||||
|
||||
defineProps({
|
||||
theme: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['toggle-theme'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="header">
|
||||
<h1>AirShare Pro</h1>
|
||||
<p>跨端局域网 & P2P 传输中心</p>
|
||||
<button class="theme-toggle" title="切换日夜模式" @click="$emit('toggle-theme')">
|
||||
<LocalIcon id="theme-icon" :name="theme === 'dark' ? 'dark_mode' : 'light_mode'" size="22" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
70
frontend/src/components/DeviceDiscoveryCard.vue
Normal file
70
frontend/src/components/DeviceDiscoveryCard.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup>
|
||||
import GlassCard from './GlassCard.vue'
|
||||
import LocalIcon from './LocalIcon.vue'
|
||||
|
||||
defineProps({
|
||||
isScanning: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
localDeviceName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
devices: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select-device'])
|
||||
|
||||
function selectDevice(device) {
|
||||
emit('select-device', device)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GlassCard title="局域网自动发现">
|
||||
<p class="local-device-name">
|
||||
本机:<strong>{{ localDeviceName || '识别中' }}</strong>
|
||||
</p>
|
||||
|
||||
<div v-if="isScanning" class="radar-container">
|
||||
<div class="radar">
|
||||
<LocalIcon class="radar-icon" name="sensors" size="36" />
|
||||
</div>
|
||||
<p class="scan-status">正在扫描附近设备...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="devices.length" class="device-list">
|
||||
<button
|
||||
v-for="device in devices"
|
||||
:key="device.id"
|
||||
class="device-item"
|
||||
type="button"
|
||||
@click="selectDevice(device)"
|
||||
>
|
||||
<div class="device-icon">
|
||||
<LocalIcon :name="device.icon" size="24" />
|
||||
</div>
|
||||
<div class="device-info">
|
||||
<h4>{{ device.name }}</h4>
|
||||
<p>{{ device.description }}</p>
|
||||
</div>
|
||||
<div class="device-status-beacon" aria-hidden="true">
|
||||
<span class="device-status-dot"></span>
|
||||
<span class="device-status-ring"></span>
|
||||
<span class="device-status-ring device-status-ring-delay"></span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="radar-container">
|
||||
<div class="radar">
|
||||
<LocalIcon class="radar-icon" name="devices" size="36" />
|
||||
</div>
|
||||
<p class="scan-status">暂未发现局域网设备,请保持页面开启后重试</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</template>
|
||||
15
frontend/src/components/GlassCard.vue
Normal file
15
frontend/src/components/GlassCard.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div v-if="title" class="section-title">{{ title }}</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
173
frontend/src/components/LocalIcon.vue
Normal file
173
frontend/src/components/LocalIcon.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
size: {
|
||||
type: [Number, String],
|
||||
default: 24,
|
||||
},
|
||||
})
|
||||
|
||||
const icons = {
|
||||
light_mode: {
|
||||
type: 'stroke',
|
||||
shapes: [
|
||||
{ tag: 'circle', attrs: { cx: '12', cy: '12', r: '4' } },
|
||||
{ tag: 'path', attrs: { d: 'M12 2v2.2' } },
|
||||
{ tag: 'path', attrs: { d: 'M12 19.8V22' } },
|
||||
{ tag: 'path', attrs: { d: 'M4.93 4.93 6.5 6.5' } },
|
||||
{ tag: 'path', attrs: { d: 'm17.5 17.5 1.57 1.57' } },
|
||||
{ tag: 'path', attrs: { d: 'M2 12h2.2' } },
|
||||
{ tag: 'path', attrs: { d: 'M19.8 12H22' } },
|
||||
{ tag: 'path', attrs: { d: 'm4.93 19.07 1.57-1.57' } },
|
||||
{ tag: 'path', attrs: { d: 'M17.5 6.5 19.07 4.93' } },
|
||||
],
|
||||
},
|
||||
dark_mode: {
|
||||
type: 'fill',
|
||||
shapes: [{ tag: 'path', attrs: { d: 'M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z' } }],
|
||||
},
|
||||
add_circle: {
|
||||
type: 'stroke',
|
||||
shapes: [
|
||||
{ tag: 'circle', attrs: { cx: '12', cy: '12', r: '9' } },
|
||||
{ tag: 'path', attrs: { d: 'M12 8v8' } },
|
||||
{ tag: 'path', attrs: { d: 'M8 12h8' } },
|
||||
],
|
||||
},
|
||||
sensors: {
|
||||
type: 'stroke',
|
||||
shapes: [
|
||||
{ tag: 'path', attrs: { d: 'M12 12h.01' } },
|
||||
{ tag: 'path', attrs: { d: 'M9.2 14.8a4 4 0 0 1 0-5.6' } },
|
||||
{ tag: 'path', attrs: { d: 'M14.8 9.2a4 4 0 0 1 0 5.6' } },
|
||||
{ tag: 'path', attrs: { d: 'M6.4 17.6a8 8 0 0 1 0-11.2' } },
|
||||
{ tag: 'path', attrs: { d: 'M17.6 6.4a8 8 0 0 1 0 11.2' } },
|
||||
],
|
||||
},
|
||||
smartphone: {
|
||||
type: 'stroke',
|
||||
shapes: [
|
||||
{ tag: 'rect', attrs: { x: '7', y: '2.5', width: '10', height: '19', rx: '2.5' } },
|
||||
{ tag: 'path', attrs: { d: 'M10 5h4' } },
|
||||
{ tag: 'circle', attrs: { cx: '12', cy: '18', r: '0.8' } },
|
||||
],
|
||||
},
|
||||
laptop_mac: {
|
||||
type: 'stroke',
|
||||
shapes: [
|
||||
{ tag: 'rect', attrs: { x: '5', y: '4', width: '14', height: '10', rx: '1.5' } },
|
||||
{ tag: 'path', attrs: { d: 'M3 18h18' } },
|
||||
{ tag: 'path', attrs: { d: 'M8 18a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2' } },
|
||||
],
|
||||
},
|
||||
close: {
|
||||
type: 'stroke',
|
||||
shapes: [
|
||||
{ tag: 'path', attrs: { d: 'M6 6l12 12' } },
|
||||
{ tag: 'path', attrs: { d: 'M18 6 6 18' } },
|
||||
],
|
||||
},
|
||||
cloud_upload: {
|
||||
type: 'stroke',
|
||||
shapes: [
|
||||
{ tag: 'path', attrs: { d: 'M7 18a4 4 0 1 1 .7-7.94A5.5 5.5 0 0 1 18 11a3.5 3.5 0 1 1-.5 7' } },
|
||||
{ tag: 'path', attrs: { d: 'M12 10v8' } },
|
||||
{ tag: 'path', attrs: { d: 'm8.8 13.2 3.2-3.2 3.2 3.2' } },
|
||||
],
|
||||
},
|
||||
arrow_upward: {
|
||||
type: 'stroke',
|
||||
shapes: [
|
||||
{ tag: 'path', attrs: { d: 'M12 19V6' } },
|
||||
{ tag: 'path', attrs: { d: 'm6.5 11.5 5.5-5.5 5.5 5.5' } },
|
||||
],
|
||||
},
|
||||
send_and_archive: {
|
||||
type: 'stroke',
|
||||
shapes: [
|
||||
{ tag: 'path', attrs: { d: 'M3 6h18l-2 4H5Z' } },
|
||||
{ tag: 'path', attrs: { d: 'M5 10v8a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-8' } },
|
||||
{ tag: 'path', attrs: { d: 'M12 11v5' } },
|
||||
{ tag: 'path', attrs: { d: 'm9.5 13.5 2.5 2.5 2.5-2.5' } },
|
||||
],
|
||||
},
|
||||
chat_bubble: {
|
||||
type: 'stroke',
|
||||
shapes: [
|
||||
{ tag: 'path', attrs: { d: 'M6 18.5 3.5 21v-5A7.5 7.5 0 0 1 11 4.5h2A7.5 7.5 0 0 1 20.5 12v.5A7.5 7.5 0 0 1 13 20H8.5' } },
|
||||
],
|
||||
},
|
||||
content_copy: {
|
||||
type: 'stroke',
|
||||
shapes: [
|
||||
{ tag: 'rect', attrs: { x: '9', y: '9', width: '10', height: '10', rx: '2' } },
|
||||
{ tag: 'path', attrs: { d: 'M7 15H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v1' } },
|
||||
],
|
||||
},
|
||||
check: {
|
||||
type: 'stroke',
|
||||
shapes: [{ tag: 'path', attrs: { d: 'm5 12 4.2 4.2L19 7.5' } }],
|
||||
},
|
||||
draft: {
|
||||
type: 'stroke',
|
||||
shapes: [
|
||||
{ tag: 'rect', attrs: { x: '4', y: '5', width: '16', height: '14', rx: '2' } },
|
||||
{ tag: 'path', attrs: { d: 'm5 7 7 5 7-5' } },
|
||||
],
|
||||
},
|
||||
save: {
|
||||
type: 'stroke',
|
||||
shapes: [
|
||||
{ tag: 'path', attrs: { d: 'M5 20h14a1 1 0 0 0 1-1V7.5L16.5 4H5a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1Z' } },
|
||||
{ tag: 'path', attrs: { d: 'M8 4v5h7' } },
|
||||
{ tag: 'rect', attrs: { x: '8', y: '14', width: '8', height: '4', rx: '1' } },
|
||||
],
|
||||
},
|
||||
download: {
|
||||
type: 'stroke',
|
||||
shapes: [
|
||||
{ tag: 'path', attrs: { d: 'M12 5v10' } },
|
||||
{ tag: 'path', attrs: { d: 'm7.5 10.5 4.5 4.5 4.5-4.5' } },
|
||||
{ tag: 'path', attrs: { d: 'M5 19h14' } },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const icon = computed(() => icons[props.name] || icons.close)
|
||||
const pixelSize = computed(() => {
|
||||
if (typeof props.size === 'number') {
|
||||
return `${props.size}px`
|
||||
}
|
||||
|
||||
if (/^\d+(\.\d+)?$/.test(props.size)) {
|
||||
return `${props.size}px`
|
||||
}
|
||||
|
||||
return props.size
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="app-icon" :style="{ width: pixelSize, height: pixelSize }" aria-hidden="true">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
:fill="icon.type === 'fill' ? 'currentColor' : 'none'"
|
||||
:stroke="icon.type === 'stroke' ? 'currentColor' : 'none'"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<component
|
||||
:is="shape.tag"
|
||||
v-for="(shape, index) in icon.shapes"
|
||||
:key="`${name}-${index}`"
|
||||
v-bind="shape.attrs"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
91
frontend/src/components/RemoteRoomCard.vue
Normal file
91
frontend/src/components/RemoteRoomCard.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import GlassCard from './GlassCard.vue'
|
||||
import LocalIcon from './LocalIcon.vue'
|
||||
|
||||
defineProps({
|
||||
roomCodeInput: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isWaiting: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
generatedCode: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
pendingDownloads: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update-room-code', 'create-room', 'join-room', 'cancel-room'])
|
||||
|
||||
function handleInput(event) {
|
||||
emit('update-room-code', event.target.value)
|
||||
}
|
||||
|
||||
function handleEnter() {
|
||||
emit('join-room')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GlassCard title="远程直连">
|
||||
<div v-if="!isWaiting" class="room-action-area">
|
||||
<button class="btn-create" type="button" @click="$emit('create-room')">
|
||||
<LocalIcon name="add_circle" size="22" />
|
||||
创建专属传输房间
|
||||
</button>
|
||||
|
||||
<div class="divider">或</div>
|
||||
|
||||
<div class="room-input-group">
|
||||
<input
|
||||
class="room-code"
|
||||
inputmode="numeric"
|
||||
maxlength="4"
|
||||
pattern="\d*"
|
||||
placeholder="输入4位房间号"
|
||||
type="text"
|
||||
:value="roomCodeInput"
|
||||
@input="handleInput"
|
||||
@keyup.enter="handleEnter"
|
||||
/>
|
||||
<button class="btn-primary" type="button" @click="$emit('join-room')">加入房间</button>
|
||||
</div>
|
||||
|
||||
<div v-if="pendingDownloads.length" class="pending-downloads">
|
||||
<div class="pending-downloads-head">
|
||||
<span>待领取文件</span>
|
||||
<span>{{ pendingDownloads.length }}</span>
|
||||
</div>
|
||||
|
||||
<a
|
||||
v-for="item in pendingDownloads"
|
||||
:key="item.transfer_id"
|
||||
class="pending-download-item"
|
||||
:href="item.download_path"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div class="pending-download-copy">
|
||||
<strong>{{ item.name }}</strong>
|
||||
<p>{{ item.size_label }} · {{ item.created_label }}</p>
|
||||
</div>
|
||||
<LocalIcon name="download" size="18" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="waiting-area">
|
||||
<p class="waiting-subtitle">您的房间号码</p>
|
||||
<div class="huge-code">{{ generatedCode }}</div>
|
||||
<div class="spinner"></div>
|
||||
<p class="waiting-tip">等待对方加入...</p>
|
||||
<button class="btn-cancel" type="button" @click="$emit('cancel-room')">取消建房</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</template>
|
||||
138
frontend/src/components/TransferPanel.vue
Normal file
138
frontend/src/components/TransferPanel.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<script setup>
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import LocalIcon from './LocalIcon.vue'
|
||||
import TransferQueueItem from './TransferQueueItem.vue'
|
||||
|
||||
const props = defineProps({
|
||||
peerName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
connectionType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
hasPendingItems: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'close',
|
||||
'send-text',
|
||||
'files-selected',
|
||||
'send-all-pending',
|
||||
'remove-item',
|
||||
'start-upload',
|
||||
'copy-item',
|
||||
])
|
||||
|
||||
const textValue = ref('')
|
||||
const isDragOver = ref(false)
|
||||
const batchContainer = ref(null)
|
||||
const fileInput = ref(null)
|
||||
|
||||
watch(
|
||||
() => props.items.length,
|
||||
async () => {
|
||||
await nextTick()
|
||||
|
||||
if (batchContainer.value) {
|
||||
batchContainer.value.scrollTop = batchContainer.value.scrollHeight
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function handleTextSend() {
|
||||
emit('send-text', textValue.value)
|
||||
textValue.value = ''
|
||||
}
|
||||
|
||||
function handleFileChange(event) {
|
||||
const files = Array.from(event.target.files || [])
|
||||
|
||||
if (files.length) {
|
||||
emit('files-selected', files)
|
||||
}
|
||||
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
function handleDrop(event) {
|
||||
isDragOver.value = false
|
||||
const files = Array.from(event.dataTransfer?.files || [])
|
||||
|
||||
if (files.length) {
|
||||
emit('files-selected', files)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="transfer-panel active">
|
||||
<div class="card">
|
||||
<div class="transfer-head">
|
||||
<div class="connected-to">
|
||||
<h2>{{ peerName }}</h2>
|
||||
<p>{{ connectionType }}</p>
|
||||
</div>
|
||||
<button class="close-btn" type="button" @click="$emit('close')">
|
||||
<LocalIcon name="close" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="drop-zone"
|
||||
:class="{ 'drop-zone-active': isDragOver }"
|
||||
@click="triggerFileInput"
|
||||
@dragenter.prevent="isDragOver = true"
|
||||
@dragover.prevent="isDragOver = true"
|
||||
@dragleave.prevent="isDragOver = false"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<LocalIcon class="drop-zone-icon" name="cloud_upload" size="42" />
|
||||
<p class="drop-zone-text">点击或拖拽多个文件到这里</p>
|
||||
<input ref="fileInput" class="hidden" multiple type="file" @change="handleFileChange" />
|
||||
</div>
|
||||
|
||||
<div class="text-input-group">
|
||||
<input
|
||||
v-model="textValue"
|
||||
placeholder="输入要发送的文本或链接..."
|
||||
type="text"
|
||||
@keyup.enter="handleTextSend"
|
||||
/>
|
||||
<button title="发送文本" type="button" @click="handleTextSend">
|
||||
<LocalIcon name="arrow_upward" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="batch-actions" :class="{ active: hasPendingItems }">
|
||||
<button class="btn-small-primary" type="button" @click="$emit('send-all-pending')">
|
||||
<LocalIcon name="send_and_archive" size="16" />
|
||||
一键发送全部
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="items.length" ref="batchContainer" class="batch-progress-container">
|
||||
<TransferQueueItem
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
@copy="$emit('copy-item', $event)"
|
||||
@remove="$emit('remove-item', $event)"
|
||||
@start-upload="$emit('start-upload', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
103
frontend/src/components/TransferQueueItem.vue
Normal file
103
frontend/src/components/TransferQueueItem.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import LocalIcon from './LocalIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['remove', 'start-upload', 'copy'])
|
||||
|
||||
const statusStyle = computed(() => {
|
||||
if (props.item.tone === 'success') {
|
||||
return { color: 'var(--success-green)' }
|
||||
}
|
||||
|
||||
if (props.item.tone === 'primary') {
|
||||
return { color: 'var(--accent-blue)' }
|
||||
}
|
||||
|
||||
if (props.item.tone === 'danger') {
|
||||
return { color: 'var(--danger-red)' }
|
||||
}
|
||||
|
||||
return { color: 'var(--text-secondary)' }
|
||||
})
|
||||
|
||||
const fileIconStyle = computed(() => {
|
||||
if (props.item.kind === 'text') {
|
||||
return {
|
||||
color: 'var(--success-green)',
|
||||
background: 'rgba(48, 209, 88, 0.1)',
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="batch-item" :class="{ 'pending-file': item.kind === 'file' && item.pending }">
|
||||
<div class="file-info">
|
||||
<div class="file-info-left" :style="item.kind === 'text' ? { maxWidth: '70%' } : null">
|
||||
<div class="file-icon-wrapper" :style="fileIconStyle">
|
||||
<LocalIcon :name="item.kind === 'text' ? 'chat_bubble' : 'draft'" size="18" />
|
||||
</div>
|
||||
<span class="file-name" :title="item.kind === 'text' ? item.text : item.name">
|
||||
{{ item.kind === 'text' ? item.text : item.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="file-info-right">
|
||||
<span class="file-status" :style="statusStyle">
|
||||
{{ item.kind === 'text' && item.copied ? '已复制' : item.status }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
v-if="item.kind === 'text'"
|
||||
class="action-btn"
|
||||
title="复制文本"
|
||||
type="button"
|
||||
@click="$emit('copy', item.id)"
|
||||
>
|
||||
<LocalIcon :name="item.copied ? 'check' : 'content_copy'" size="16" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="item.kind === 'file' && item.pending"
|
||||
class="action-btn primary"
|
||||
title="发送文件"
|
||||
type="button"
|
||||
@click="$emit('start-upload', item.id)"
|
||||
>
|
||||
<LocalIcon name="arrow_upward" size="16" />
|
||||
</button>
|
||||
|
||||
<a
|
||||
v-if="item.kind === 'file' && item.downloadUrl"
|
||||
class="action-btn primary"
|
||||
:download="item.name"
|
||||
:href="item.downloadUrl"
|
||||
title="保存文件"
|
||||
>
|
||||
<LocalIcon name="download" size="16" />
|
||||
</a>
|
||||
|
||||
<button class="action-btn danger" title="移除记录" type="button" @click="$emit('remove', item.id)">
|
||||
<LocalIcon name="close" size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="item.kind === 'file'" class="progress-bar-container">
|
||||
<div
|
||||
class="progress-bar-fill"
|
||||
:class="{ success: item.tone === 'success' }"
|
||||
:style="{ width: `${item.progress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
65
frontend/src/data/mock.js
Normal file
65
frontend/src/data/mock.js
Normal file
@@ -0,0 +1,65 @@
|
||||
export const discoveredDevices = [
|
||||
{
|
||||
id: 'iphone-15-pro',
|
||||
name: 'iPhone 15 Pro',
|
||||
description: '局域网在线',
|
||||
icon: 'smartphone',
|
||||
connectionType: '局域网直连',
|
||||
},
|
||||
{
|
||||
id: 'macbook-pro',
|
||||
name: 'MacBook Pro',
|
||||
description: '局域网在线',
|
||||
icon: 'laptop_mac',
|
||||
connectionType: '局域网直连',
|
||||
},
|
||||
]
|
||||
|
||||
export const adminStats = [
|
||||
{ label: '今日请求(次)', value: '3,421', tone: 'blue' },
|
||||
{ label: '中继流量', value: '15.2', suffix: 'G', tone: 'cyan' },
|
||||
{ label: '活跃设备', value: '42', tone: 'default' },
|
||||
]
|
||||
|
||||
export const transferRecords = [
|
||||
{
|
||||
time: '2 分钟前',
|
||||
peer: 'iPhone 15 Pro (192.168.1.5)',
|
||||
type: '图片 (3张)',
|
||||
size: '12.5 MB',
|
||||
status: '直连成功',
|
||||
tone: 'success',
|
||||
},
|
||||
{
|
||||
time: '15 分钟前',
|
||||
peer: 'MacBook Pro (192.168.1.8)',
|
||||
type: '压缩包 (.zip)',
|
||||
size: '1.2 GB',
|
||||
status: '直连成功',
|
||||
tone: 'success',
|
||||
},
|
||||
{
|
||||
time: '1 小时前',
|
||||
peer: 'Windows PC (Remote)',
|
||||
type: '视频 (.mp4)',
|
||||
size: '450 MB',
|
||||
status: '中继成功',
|
||||
tone: 'primary',
|
||||
},
|
||||
{
|
||||
time: '3 小时前',
|
||||
peer: 'Android Device (Remote)',
|
||||
type: '文本消息',
|
||||
size: '< 1 KB',
|
||||
status: '中继成功',
|
||||
tone: 'primary',
|
||||
},
|
||||
{
|
||||
time: '昨天 23:15',
|
||||
peer: 'Unknown IP (114.x.x.x)',
|
||||
type: '大文件 (.iso)',
|
||||
size: '4.5 GB',
|
||||
status: '连接中断',
|
||||
tone: 'danger',
|
||||
},
|
||||
]
|
||||
5
frontend/src/main.js
Normal file
5
frontend/src/main.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './styles.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
1496
frontend/src/styles.css
Normal file
1496
frontend/src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
25
frontend/vite.config.js
Normal file
25
frontend/vite.config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
const backendTarget = 'http://127.0.0.1:8080'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: backendTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/healthz': {
|
||||
target: backendTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: backendTarget.replace('http://', 'ws://'),
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user