first commit

This commit is contained in:
2026-03-28 15:43:18 +08:00
commit e5611df24e
54 changed files with 11065 additions and 0 deletions

7
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.gocache/
.gomodcache/
.gopath/
tmp/
*.exe
node_modules

17
frontend/dist/assets/index-BhftK8R5.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

13
frontend/dist/index.html vendored Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

18
frontend/package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

28
frontend/src/api/admin.js Normal file
View 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))
},
}

View 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
View 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}`,
}
}

View 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
View 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,
})
},
}

View File

@@ -0,0 +1,7 @@
import { http } from './http'
export const runtimeApi = {
config() {
return http.get('/api/runtime/config')
},
}

View 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
}
}

View 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>

View 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>
&copy; 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>

View 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>跨端局域网 &amp; 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

25
frontend/vite.config.js Normal file
View 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,
},
},
},
})