|
|
@@ -13,191 +13,48 @@
|
|
|
<main class="main-content">
|
|
|
<div class="upload-container">
|
|
|
<!-- 左侧文件上传区 -->
|
|
|
- <section class="upload-section">
|
|
|
- <div class="section-header">
|
|
|
- <h2>📁 文件上传</h2>
|
|
|
- <span class="file-count">{{ uploadStore.selectedFiles.length }}/{{ maxFiles }}</span>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 拖拽上传区域 -->
|
|
|
- <div
|
|
|
- class="drop-zone"
|
|
|
- :class="{ active: isDragging }"
|
|
|
- @dragover.prevent="isDragging = true"
|
|
|
- @dragleave.prevent="isDragging = false"
|
|
|
- @drop.prevent="handleDrop"
|
|
|
- @click="selectFiles"
|
|
|
- >
|
|
|
- <div class="drop-zone-content">
|
|
|
- <svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
|
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
|
|
- <polyline points="17 8 12 3 7 8"></polyline>
|
|
|
- <line x1="12" y1="3" x2="12" y2="15"></line>
|
|
|
- </svg>
|
|
|
- <p class="drop-text">拖拽文件到这里,或点击选择文件</p>
|
|
|
- <p class="drop-hint">最多上传 {{ maxFiles }} 个文件,每个文件不超过 {{ maxSize }}MB</p>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 文件列表 -->
|
|
|
- <div v-if="uploadStore.selectedFiles.length > 0" class="file-list">
|
|
|
- <div
|
|
|
- v-for="file in uploadStore.selectedFiles"
|
|
|
- :key="file.id"
|
|
|
- class="file-item"
|
|
|
- >
|
|
|
- <div class="file-info">
|
|
|
- <svg class="file-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
|
- <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
|
|
|
- <polyline points="13 2 13 9 20 9"></polyline>
|
|
|
- </svg>
|
|
|
- <div class="file-details">
|
|
|
- <p class="file-name">{{ file.name }}</p>
|
|
|
- <p class="file-size">{{ formatFileSize(file.size) }}</p>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <button class="btn-remove" @click="removeFile(file.id)">×</button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 清空按钮 -->
|
|
|
- <button
|
|
|
- v-if="uploadStore.selectedFiles.length > 0"
|
|
|
- class="btn-clear"
|
|
|
- @click="uploadStore.clearFiles"
|
|
|
- >
|
|
|
- 清空文件列表
|
|
|
- </button>
|
|
|
- </section>
|
|
|
+ <FileUploader
|
|
|
+ :files="uploadStore.selectedFiles"
|
|
|
+ @remove="removeFile"
|
|
|
+ @clear="uploadStore.clearFiles"
|
|
|
+ @files-selected="handleFilesAdded"
|
|
|
+ @files-dropped="handleFilesAdded"
|
|
|
+ />
|
|
|
|
|
|
<!-- 右侧文本输入区 -->
|
|
|
- <section class="text-section">
|
|
|
- <div class="section-header">
|
|
|
- <h2>📝 文本分享</h2>
|
|
|
- </div>
|
|
|
-
|
|
|
- <textarea
|
|
|
- v-model="uploadStore.textContent"
|
|
|
- class="text-input"
|
|
|
- placeholder="在此输入要分享的文本内容..."
|
|
|
- rows="10"
|
|
|
- ></textarea>
|
|
|
-
|
|
|
- <!-- 文本类型选择 -->
|
|
|
- <div class="text-type-selector">
|
|
|
- <label>文本类型:</label>
|
|
|
- <select v-model="uploadStore.textType">
|
|
|
- <option v-for="type in textTypeOptions" :key="type.value" :value="type.value">
|
|
|
- {{ type.label }}
|
|
|
- </option>
|
|
|
- </select>
|
|
|
- </div>
|
|
|
- </section>
|
|
|
+ <TextShare
|
|
|
+ :text-content="uploadStore.textContent"
|
|
|
+ :text-type="uploadStore.textType"
|
|
|
+ @update:text-content="uploadStore.textContent = $event"
|
|
|
+ @update:text-type="uploadStore.textType = $event"
|
|
|
+ />
|
|
|
</div>
|
|
|
|
|
|
<!-- 底部设置和操作区 -->
|
|
|
- <div class="settings-container">
|
|
|
- <!-- 过期时间设置 -->
|
|
|
- <div class="setting-item">
|
|
|
- <label>⏰ 过期时间:</label>
|
|
|
- <div class="option-buttons">
|
|
|
- <button
|
|
|
- v-for="hours in expirationOptions"
|
|
|
- :key="hours"
|
|
|
- :class="['option-btn', { active: uploadStore.expirationHours === hours }]"
|
|
|
- @click="uploadStore.expirationHours = hours"
|
|
|
- >
|
|
|
- {{ getExpirationLabel(hours) }}
|
|
|
- </button>
|
|
|
- <input
|
|
|
- v-model.number="customHours"
|
|
|
- type="number"
|
|
|
- min="1"
|
|
|
- max="24"
|
|
|
- class="custom-input"
|
|
|
- placeholder="自定义"
|
|
|
- >
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 下载次数限制 -->
|
|
|
- <div class="setting-item">
|
|
|
- <label>📥 下载次数:</label>
|
|
|
- <select v-model="uploadStore.maxDownloadCount" class="setting-select">
|
|
|
- <option v-for="option in downloadCountOptions" :key="option.value" :value="option.value">
|
|
|
- {{ option.label }}
|
|
|
- </option>
|
|
|
- </select>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 密码设置 -->
|
|
|
- <div class="setting-item">
|
|
|
- <label>🔒 访问密码:</label>
|
|
|
- <input
|
|
|
- v-model="uploadStore.password"
|
|
|
- type="password"
|
|
|
- class="password-input"
|
|
|
- placeholder="选填,留空则无需密码"
|
|
|
- >
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 上传按钮 -->
|
|
|
- <button
|
|
|
- class="btn-upload"
|
|
|
- :disabled="!uploadStore.canUpload || uploadStore.uploading"
|
|
|
- @click="handleUpload"
|
|
|
- >
|
|
|
- <span v-if="uploadStore.uploading" class="spinner"></span>
|
|
|
- <span v-else>🚀 立即上传</span>
|
|
|
- </button>
|
|
|
-
|
|
|
- <!-- 上传进度条 -->
|
|
|
- <div v-if="uploadStore.uploading && uploadStore.uploadProgress > 0" class="upload-progress-container">
|
|
|
- <div class="progress-info">
|
|
|
- <span class="progress-percent">{{ uploadStore.uploadProgress }}%</span>
|
|
|
- <span class="progress-speed">{{ formatSpeed(uploadStore.uploadSpeed) }}</span>
|
|
|
- <span v-if="uploadStore.remainingTime > 0" class="progress-time">
|
|
|
- 剩余 {{ formatTime(uploadStore.remainingTime) }}
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- <div class="progress-bar">
|
|
|
- <div class="progress-fill" :style="{ width: uploadStore.uploadProgress + '%' }"></div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
+ <UploadConfig
|
|
|
+ :expiration-hours="uploadStore.expirationHours"
|
|
|
+ :max-download-count="uploadStore.maxDownloadCount"
|
|
|
+ :password="uploadStore.password"
|
|
|
+ :can-upload="uploadStore.canUpload"
|
|
|
+ :uploading="uploadStore.uploading"
|
|
|
+ :upload-progress="uploadStore.uploadProgress"
|
|
|
+ :upload-speed="uploadStore.uploadSpeed"
|
|
|
+ :remaining-time="uploadStore.remainingTime"
|
|
|
+ @upload="handleUpload"
|
|
|
+ @update:expiration-hours="uploadStore.expirationHours = $event"
|
|
|
+ @update:max-download-count="uploadStore.maxDownloadCount = $event"
|
|
|
+ @update:password="uploadStore.password = $event"
|
|
|
+ />
|
|
|
|
|
|
<!-- ID查询区 -->
|
|
|
- <div class="query-section">
|
|
|
- <h3>🔍 查看分享内容</h3>
|
|
|
- <div class="query-input-group">
|
|
|
- <input
|
|
|
- v-model="queryId"
|
|
|
- type="text"
|
|
|
- class="query-input"
|
|
|
- placeholder="输入分享内容ID..."
|
|
|
- @keyup.enter="handleQuery"
|
|
|
- >
|
|
|
- <button
|
|
|
- class="btn-query"
|
|
|
- :disabled="!isQueryValid || isQueryChecking"
|
|
|
- :aria-disabled="!isQueryValid || isQueryChecking"
|
|
|
- :aria-busy="isQueryChecking"
|
|
|
- @click="handleQuery"
|
|
|
- >
|
|
|
- <svg v-if="isQueryChecking" class="spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
|
- <circle cx="12" cy="12" r="10" stroke-width="4" stroke-opacity="0.3"></circle>
|
|
|
- <path d="M12 2a10 10 0 0 1 10 10" stroke-width="4" stroke-linecap="round"></path>
|
|
|
- </svg>
|
|
|
- <svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
|
- <circle cx="11" cy="11" r="8"></circle>
|
|
|
- <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
|
|
- </svg>
|
|
|
- <span>{{ isQueryChecking ? '验证中...' : '查看' }}</span>
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- <p v-if="queryError" class="query-error">{{ queryError }}</p>
|
|
|
- <p v-else class="query-hint">提示:输入ID的前几位即可自动跳转(如果前缀唯一)</p>
|
|
|
- </div>
|
|
|
+ <QueryInput
|
|
|
+ :query-id="queryId"
|
|
|
+ :is-valid="isQueryValid"
|
|
|
+ :is-checking="isQueryChecking"
|
|
|
+ :error="queryError"
|
|
|
+ @update:query-id="onQueryIdChange"
|
|
|
+ @query="handleQuery"
|
|
|
+ />
|
|
|
</main>
|
|
|
|
|
|
<!-- 上传成功弹窗 -->
|
|
|
@@ -230,44 +87,34 @@
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
-import { ref, computed, watch } from 'vue'
|
|
|
+import { ref, watch } from 'vue'
|
|
|
import { useRouter } from 'vue-router'
|
|
|
import { useUploadStore } from '@/stores/upload'
|
|
|
-import { createUpload, uploadFiles, resolveIdByPrefix } from '@/api'
|
|
|
-import config from '@/config'
|
|
|
+import { resolveIdByPrefix } from '@/api'
|
|
|
+import { useFileUpload } from '@/composables/useFileUpload'
|
|
|
+import FileUploader from '@/components/FileUploader.vue'
|
|
|
+import TextShare from '@/components/TextShare.vue'
|
|
|
+import UploadConfig from '@/components/UploadConfig.vue'
|
|
|
+import QueryInput from '@/components/QueryInput.vue'
|
|
|
import toast from '@/utils/toast'
|
|
|
|
|
|
const router = useRouter()
|
|
|
const uploadStore = useUploadStore()
|
|
|
-
|
|
|
-// 响应式数据
|
|
|
-const isDragging = ref(false)
|
|
|
+const {
|
|
|
+ handleUpload,
|
|
|
+ closeModal,
|
|
|
+ copyUrl,
|
|
|
+ resetUpload,
|
|
|
+ removeFile,
|
|
|
+ handleFilesAdded,
|
|
|
+ formatDateTime
|
|
|
+} = useFileUpload()
|
|
|
+
|
|
|
+// ID查询状态
|
|
|
const queryId = ref('')
|
|
|
-const customHours = ref(null)
|
|
|
-
|
|
|
-// ID查询验证状态
|
|
|
-const isQueryValid = ref(false) // 前缀是否有效
|
|
|
-const isQueryChecking = ref(false) // 是否正在验证
|
|
|
-const queryError = ref('') // 错误提示信息
|
|
|
-
|
|
|
-// 计算属性
|
|
|
-const maxFiles = computed(() => config.maxFiles)
|
|
|
-const maxSize = computed(() => config.uploadLimit / 1024 / 1024)
|
|
|
-const expirationOptions = computed(() => config.expirationOptions)
|
|
|
-const downloadCountOptions = computed(() => config.downloadCountOptions)
|
|
|
-const textTypeOptions = computed(() => config.textTypeOptions)
|
|
|
-
|
|
|
-// 获取过期时间选项的显示标签
|
|
|
-function getExpirationLabel(hours) {
|
|
|
- return config.expirationLabels[hours] || `${hours}小时`
|
|
|
-}
|
|
|
-
|
|
|
-// 监听自定义小时数
|
|
|
-watch(customHours, (newVal) => {
|
|
|
- if (newVal && newVal >= 1) {
|
|
|
- uploadStore.expirationHours = newVal
|
|
|
- }
|
|
|
-})
|
|
|
+const isQueryValid = ref(false)
|
|
|
+const isQueryChecking = ref(false)
|
|
|
+const queryError = ref('')
|
|
|
|
|
|
// 防抖函数
|
|
|
function debounce(fn, delay) {
|
|
|
@@ -280,26 +127,20 @@ function debounce(fn, delay) {
|
|
|
|
|
|
// 实时验证ID前缀
|
|
|
const validatePrefix = debounce(async (prefix) => {
|
|
|
- // 空输入或仅空格
|
|
|
if (!prefix || prefix.trim().length === 0) {
|
|
|
isQueryValid.value = false
|
|
|
queryError.value = ''
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- // 显示加载状态
|
|
|
isQueryChecking.value = true
|
|
|
|
|
|
try {
|
|
|
- // 调用后端API验证前缀
|
|
|
await resolveIdByPrefix(prefix.trim())
|
|
|
- // 验证成功
|
|
|
isQueryValid.value = true
|
|
|
queryError.value = ''
|
|
|
} catch (error) {
|
|
|
- // 验证失败
|
|
|
isQueryValid.value = false
|
|
|
- // 解析错误信息
|
|
|
const errorMsg = error.message || '未知错误'
|
|
|
if (errorMsg.includes('前缀匹配多个记录')) {
|
|
|
queryError.value = '前缀匹配多个记录,请提供更完整的ID'
|
|
|
@@ -311,145 +152,14 @@ const validatePrefix = debounce(async (prefix) => {
|
|
|
} finally {
|
|
|
isQueryChecking.value = false
|
|
|
}
|
|
|
-}, 300) // 300ms防抖延迟
|
|
|
-
|
|
|
-// 监听ID输入变化
|
|
|
-watch(queryId, (newVal) => {
|
|
|
- validatePrefix(newVal)
|
|
|
-})
|
|
|
-
|
|
|
-// 方法
|
|
|
-function selectFiles() {
|
|
|
- const input = document.createElement('input')
|
|
|
- input.type = 'file'
|
|
|
- input.multiple = true
|
|
|
- input.accept = config.allowedFileTypes.join(',')
|
|
|
- input.onchange = (e) => {
|
|
|
- const files = Array.from(e.target.files)
|
|
|
- const result = uploadStore.addFiles(files)
|
|
|
+}, 300)
|
|
|
|
|
|
- // 显示被拒绝的文件提示
|
|
|
- if (result.rejected.length > 0) {
|
|
|
- result.rejected.forEach(({ file }) => {
|
|
|
- const fileSizeMB = (file.size / 1024 / 1024).toFixed(2)
|
|
|
- const maxSizeMB = (config.uploadLimit / 1024 / 1024).toFixed(0)
|
|
|
- toast.error(`文件 "${file.name}" (${fileSizeMB}MB) 超过${maxSizeMB}MB限制,无法上传`)
|
|
|
- })
|
|
|
- }
|
|
|
- }
|
|
|
- input.click()
|
|
|
-}
|
|
|
-
|
|
|
-function handleDrop(e) {
|
|
|
- isDragging.value = false
|
|
|
- const files = Array.from(e.dataTransfer.files)
|
|
|
- const result = uploadStore.addFiles(files)
|
|
|
-
|
|
|
- // 显示被拒绝的文件提示
|
|
|
- if (result.rejected.length > 0) {
|
|
|
- result.rejected.forEach(({ file }) => {
|
|
|
- const fileSizeMB = (file.size / 1024 / 1024).toFixed(2)
|
|
|
- const maxSizeMB = (config.uploadLimit / 1024 / 1024).toFixed(0)
|
|
|
- toast.error(`文件 "${file.name}" (${fileSizeMB}MB) 超过${maxSizeMB}MB限制,无法上传`)
|
|
|
- })
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-function removeFile(id) {
|
|
|
- uploadStore.removeFile(id)
|
|
|
-}
|
|
|
-
|
|
|
-function formatFileSize(bytes) {
|
|
|
- if (bytes === 0) return '0 B'
|
|
|
- const k = 1024
|
|
|
- const sizes = ['B', 'KB', 'MB', 'GB']
|
|
|
- const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
|
- return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
|
|
-}
|
|
|
-
|
|
|
-// 格式化上传速度(字节/秒转为可读格式)
|
|
|
-function formatSpeed(bytesPerSecond) {
|
|
|
- if (bytesPerSecond === 0) return '0 B/s'
|
|
|
- const k = 1024
|
|
|
- const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s']
|
|
|
- const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k))
|
|
|
- const speed = bytesPerSecond / Math.pow(k, i)
|
|
|
- return Math.round(speed * 100) / 100 + ' ' + sizes[i]
|
|
|
-}
|
|
|
-
|
|
|
-// 格式化剩余时间(秒转为可读格式)
|
|
|
-function formatTime(seconds) {
|
|
|
- if (seconds < 60) {
|
|
|
- return `${seconds}秒`
|
|
|
- } else if (seconds < 3600) {
|
|
|
- const minutes = Math.floor(seconds / 60)
|
|
|
- const remainingSeconds = seconds % 60
|
|
|
- return remainingSeconds > 0 ? `${minutes}分${remainingSeconds}秒` : `${minutes}分钟`
|
|
|
- } else {
|
|
|
- const hours = Math.floor(seconds / 3600)
|
|
|
- const minutes = Math.floor((seconds % 3600) / 60)
|
|
|
- return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-function formatDateTime(timeStr) {
|
|
|
- return new Date(timeStr).toLocaleString('zh-CN')
|
|
|
-}
|
|
|
-
|
|
|
-async function handleUpload() {
|
|
|
- if (!uploadStore.canUpload) {
|
|
|
- toast.warning('请先选择文件或输入文本内容')
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- uploadStore.setUploading(true)
|
|
|
-
|
|
|
- try {
|
|
|
- // 创建上传记录
|
|
|
- const result = await createUpload({
|
|
|
- expirationHours: uploadStore.expirationHours,
|
|
|
- maxDownloadCount: uploadStore.maxDownloadCount,
|
|
|
- password: uploadStore.password,
|
|
|
- textContent: uploadStore.textContent,
|
|
|
- textType: uploadStore.textType
|
|
|
- })
|
|
|
-
|
|
|
- // 如果有文件,上传文件(带进度回调)
|
|
|
- if (uploadStore.hasFiles) {
|
|
|
- const files = uploadStore.selectedFiles.map(f => f.file)
|
|
|
- await uploadFiles(result.id, files, (percent, speed, loaded, total) => {
|
|
|
- // 更新进度状态
|
|
|
- uploadStore.setUploadProgress(percent, speed, loaded, total)
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- // 设置上传结果
|
|
|
- uploadStore.setUploadResult(result)
|
|
|
-
|
|
|
- } catch (error) {
|
|
|
- toast.error('上传失败:' + error.message)
|
|
|
- } finally {
|
|
|
- uploadStore.setUploading(false)
|
|
|
- uploadStore.resetUploadProgress() // 重置进度
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-function closeModal() {
|
|
|
- uploadStore.setUploadResult(null)
|
|
|
-}
|
|
|
-
|
|
|
-function copyUrl() {
|
|
|
- navigator.clipboard.writeText(uploadStore.uploadResult.shareUrl)
|
|
|
- toast.success('链接已复制到剪贴板')
|
|
|
-}
|
|
|
-
|
|
|
-function resetUpload() {
|
|
|
- uploadStore.reset()
|
|
|
- closeModal()
|
|
|
+function onQueryIdChange(val) {
|
|
|
+ queryId.value = val
|
|
|
+ validatePrefix(val)
|
|
|
}
|
|
|
|
|
|
async function handleQuery() {
|
|
|
- // 按钮已禁用或正在验证,不执行操作
|
|
|
if (!isQueryValid.value || isQueryChecking.value) {
|
|
|
return
|
|
|
}
|
|
|
@@ -462,20 +172,15 @@ async function handleQuery() {
|
|
|
const prefix = queryId.value.trim()
|
|
|
|
|
|
try {
|
|
|
- // 调用后端前缀解析接口(此时已经验证过,应该会成功)
|
|
|
const response = await resolveIdByPrefix(prefix)
|
|
|
|
|
|
- // 如果成功返回,跳转到完整ID
|
|
|
if (response.fullId) {
|
|
|
router.push(`/s/${response.fullId}`)
|
|
|
} else {
|
|
|
- // 如果没有返回完整ID,直接使用输入的ID
|
|
|
router.push(`/s/${prefix}`)
|
|
|
}
|
|
|
} catch (error) {
|
|
|
- // 如果前缀解析失败(404或其他错误),提示用户
|
|
|
toast.error(`未找到ID前缀为 "${prefix}" 的分享内容,请检查输入是否正确`)
|
|
|
- // 重置验证状态
|
|
|
isQueryValid.value = false
|
|
|
}
|
|
|
}
|
|
|
@@ -540,435 +245,6 @@ async function handleQuery() {
|
|
|
margin-bottom: $spacing-xl;
|
|
|
}
|
|
|
|
|
|
-.upload-section,
|
|
|
-.text-section {
|
|
|
- @extend .card !optional;
|
|
|
- min-width: 0; // 防止grid子项被内容撑大
|
|
|
- overflow: hidden; // 防止内容溢出
|
|
|
-}
|
|
|
-
|
|
|
-.section-header {
|
|
|
- display: flex;
|
|
|
- justify-content: space-between;
|
|
|
- align-items: center;
|
|
|
- margin-bottom: $spacing-lg;
|
|
|
-
|
|
|
- h2 {
|
|
|
- font-size: $font-size-xl;
|
|
|
- color: $text-secondary;
|
|
|
- }
|
|
|
-
|
|
|
- .file-count {
|
|
|
- font-size: $font-size-sm;
|
|
|
- color: $text-muted;
|
|
|
- background: $bg-tertiary;
|
|
|
- padding: $spacing-xs $spacing-sm;
|
|
|
- border-radius: $radius-full;
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.drop-zone {
|
|
|
- border: 2px dashed $border-color;
|
|
|
- border-radius: $radius-lg;
|
|
|
- padding: $spacing-2xl;
|
|
|
- text-align: center;
|
|
|
- cursor: pointer;
|
|
|
- transition: all $transition-base ease;
|
|
|
-
|
|
|
- &:hover {
|
|
|
- border-color: $primary-color;
|
|
|
- background: rgba($primary-color, 0.05);
|
|
|
- }
|
|
|
-
|
|
|
- &.active {
|
|
|
- border-color: $secondary-color;
|
|
|
- background: rgba($secondary-color, 0.1);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.drop-zone-content {
|
|
|
- .upload-icon {
|
|
|
- width: 64px;
|
|
|
- height: 64px;
|
|
|
- margin: 0 auto $spacing-md;
|
|
|
- color: $primary-color;
|
|
|
- }
|
|
|
-
|
|
|
- .drop-text {
|
|
|
- font-size: $font-size-lg;
|
|
|
- color: $text-secondary;
|
|
|
- margin-bottom: $spacing-sm;
|
|
|
- }
|
|
|
-
|
|
|
- .drop-hint {
|
|
|
- font-size: $font-size-sm;
|
|
|
- color: $text-muted;
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.file-list {
|
|
|
- margin-top: $spacing-lg;
|
|
|
-}
|
|
|
-
|
|
|
-.file-item {
|
|
|
- display: flex;
|
|
|
- justify-content: space-between;
|
|
|
- align-items: center;
|
|
|
- padding: $spacing-md;
|
|
|
- background: $bg-tertiary;
|
|
|
- border-radius: $radius-md;
|
|
|
- margin-bottom: $spacing-sm;
|
|
|
-}
|
|
|
-
|
|
|
-.file-info {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: $spacing-md;
|
|
|
- flex: 1;
|
|
|
- min-width: 0;
|
|
|
-}
|
|
|
-
|
|
|
-.file-icon {
|
|
|
- width: 40px;
|
|
|
- height: 40px;
|
|
|
- flex-shrink: 0;
|
|
|
- color: $accent-color;
|
|
|
-}
|
|
|
-
|
|
|
-.file-details {
|
|
|
- flex: 1;
|
|
|
- min-width: 0;
|
|
|
-}
|
|
|
-
|
|
|
-.file-name {
|
|
|
- font-size: $font-size-base;
|
|
|
- color: $text-primary;
|
|
|
- margin-bottom: $spacing-xs;
|
|
|
- white-space: nowrap;
|
|
|
- overflow: hidden;
|
|
|
- text-overflow: ellipsis;
|
|
|
-}
|
|
|
-
|
|
|
-.file-size {
|
|
|
- font-size: $font-size-sm;
|
|
|
- color: $text-muted;
|
|
|
-}
|
|
|
-
|
|
|
-.btn-remove {
|
|
|
- width: 32px;
|
|
|
- height: 32px;
|
|
|
- border-radius: $radius-md;
|
|
|
- border: none;
|
|
|
- background: $error-color;
|
|
|
- color: white;
|
|
|
- font-size: $font-size-xl;
|
|
|
- cursor: pointer;
|
|
|
- transition: all $transition-base ease;
|
|
|
-
|
|
|
- &:hover {
|
|
|
- background: darken($error-color, 10%);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.btn-clear {
|
|
|
- width: 100%;
|
|
|
- margin-top: $spacing-md;
|
|
|
- padding: $spacing-sm $spacing-md;
|
|
|
- background: $bg-tertiary;
|
|
|
- border: 1px solid $border-color;
|
|
|
- border-radius: $radius-md;
|
|
|
- color: $text-primary;
|
|
|
- cursor: pointer;
|
|
|
- transition: all $transition-base ease;
|
|
|
-
|
|
|
- &:hover {
|
|
|
- background: $bg-hover;
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.text-input {
|
|
|
- width: 100%;
|
|
|
- min-height: 200px;
|
|
|
- margin-bottom: $spacing-md;
|
|
|
-}
|
|
|
-
|
|
|
-.text-type-selector {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: $spacing-sm;
|
|
|
-
|
|
|
- label {
|
|
|
- color: $text-secondary;
|
|
|
- }
|
|
|
-
|
|
|
- select {
|
|
|
- flex: 1;
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.settings-container {
|
|
|
- @extend .card !optional;
|
|
|
- margin-bottom: $spacing-xl;
|
|
|
-}
|
|
|
-
|
|
|
-.setting-item {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: $spacing-md;
|
|
|
- margin-bottom: $spacing-lg;
|
|
|
-
|
|
|
- &:last-child {
|
|
|
- margin-bottom: 0;
|
|
|
- }
|
|
|
-
|
|
|
- label {
|
|
|
- min-width: 120px;
|
|
|
- color: $text-secondary;
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.option-buttons {
|
|
|
- display: flex;
|
|
|
- gap: $spacing-sm;
|
|
|
- flex-wrap: wrap;
|
|
|
- flex: 1;
|
|
|
-}
|
|
|
-
|
|
|
-.option-btn {
|
|
|
- padding: $spacing-sm $spacing-md;
|
|
|
- background: $bg-tertiary;
|
|
|
- border: 1px solid $border-color;
|
|
|
- border-radius: $radius-md;
|
|
|
- color: $text-primary;
|
|
|
- cursor: pointer;
|
|
|
- transition: all $transition-base ease;
|
|
|
-
|
|
|
- &:hover {
|
|
|
- background: $bg-hover;
|
|
|
- }
|
|
|
-
|
|
|
- &.active {
|
|
|
- background: linear-gradient(135deg, $primary-color, $secondary-color);
|
|
|
- border-color: transparent;
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.custom-input {
|
|
|
- width: 100px;
|
|
|
- padding: $spacing-sm $spacing-md;
|
|
|
- background: $bg-tertiary;
|
|
|
- border: 1px solid $border-color;
|
|
|
- border-radius: $radius-md;
|
|
|
- color: $text-primary;
|
|
|
- text-align: center;
|
|
|
-}
|
|
|
-
|
|
|
-.setting-select,
|
|
|
-.password-input {
|
|
|
- flex: 1;
|
|
|
-}
|
|
|
-
|
|
|
-.btn-upload {
|
|
|
- width: 100%;
|
|
|
- margin-top: $spacing-lg;
|
|
|
- padding: $spacing-lg;
|
|
|
- font-size: $font-size-xl;
|
|
|
- font-weight: 600;
|
|
|
- border-radius: $radius-lg;
|
|
|
-}
|
|
|
-
|
|
|
-// 上传进度条样式
|
|
|
-.upload-progress-container {
|
|
|
- margin-top: $spacing-md;
|
|
|
- padding: $spacing-md;
|
|
|
- background: rgba($bg-secondary, 0.6);
|
|
|
- backdrop-filter: blur(10px);
|
|
|
- border: 1px solid $border-color;
|
|
|
- border-radius: $radius-lg;
|
|
|
- animation: progressSlideIn 0.3s ease-out;
|
|
|
-}
|
|
|
-
|
|
|
-@keyframes progressSlideIn {
|
|
|
- from {
|
|
|
- opacity: 0;
|
|
|
- transform: translateY(-10px);
|
|
|
- }
|
|
|
- to {
|
|
|
- opacity: 1;
|
|
|
- transform: translateY(0);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.progress-info {
|
|
|
- display: flex;
|
|
|
- justify-content: space-between;
|
|
|
- align-items: center;
|
|
|
- margin-bottom: $spacing-sm;
|
|
|
- font-size: $font-size-sm;
|
|
|
- color: $text-secondary;
|
|
|
-
|
|
|
- .progress-percent {
|
|
|
- font-weight: 600;
|
|
|
- color: $primary-color;
|
|
|
- font-size: $font-size-base;
|
|
|
- }
|
|
|
-
|
|
|
- .progress-speed {
|
|
|
- color: $text-muted;
|
|
|
- }
|
|
|
-
|
|
|
- .progress-time {
|
|
|
- color: $warning-color;
|
|
|
- font-weight: 500;
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.progress-bar {
|
|
|
- width: 100%;
|
|
|
- height: 8px;
|
|
|
- background: $bg-tertiary;
|
|
|
- border-radius: $radius-full;
|
|
|
- overflow: hidden;
|
|
|
- position: relative;
|
|
|
-}
|
|
|
-
|
|
|
-.progress-fill {
|
|
|
- height: 100%;
|
|
|
- background: linear-gradient(90deg, $primary-color 0%, $secondary-color 100%);
|
|
|
- border-radius: $radius-full;
|
|
|
- transition: width 0.3s ease;
|
|
|
- position: relative;
|
|
|
- overflow: hidden;
|
|
|
-
|
|
|
- // 添加闪烁动画效果
|
|
|
- &::after {
|
|
|
- content: '';
|
|
|
- position: absolute;
|
|
|
- top: 0;
|
|
|
- left: 0;
|
|
|
- bottom: 0;
|
|
|
- right: 0;
|
|
|
- background: linear-gradient(
|
|
|
- 90deg,
|
|
|
- transparent,
|
|
|
- rgba(255, 255, 255, 0.3),
|
|
|
- transparent
|
|
|
- );
|
|
|
- animation: progressShimmer 2s infinite;
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-@keyframes progressShimmer {
|
|
|
- 0% {
|
|
|
- transform: translateX(-100%);
|
|
|
- }
|
|
|
- 100% {
|
|
|
- transform: translateX(100%);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.query-section {
|
|
|
- @extend .card !optional;
|
|
|
- text-align: center;
|
|
|
-
|
|
|
- h3 {
|
|
|
- margin-bottom: $spacing-lg;
|
|
|
- color: $text-secondary;
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.query-input-group {
|
|
|
- display: flex;
|
|
|
- gap: $spacing-md;
|
|
|
- margin-bottom: $spacing-sm;
|
|
|
-}
|
|
|
-
|
|
|
-.query-input {
|
|
|
- flex: 1;
|
|
|
-}
|
|
|
-
|
|
|
-.btn-query {
|
|
|
- display: inline-flex;
|
|
|
- align-items: center;
|
|
|
- gap: $spacing-xs;
|
|
|
- padding: $spacing-sm $spacing-xl;
|
|
|
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
|
- color: white;
|
|
|
- border: none;
|
|
|
- border-radius: $radius-md;
|
|
|
- font-size: $font-size-base;
|
|
|
- font-weight: 500;
|
|
|
- cursor: pointer;
|
|
|
- box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
|
|
- transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
|
- white-space: nowrap;
|
|
|
-
|
|
|
- svg {
|
|
|
- width: 18px;
|
|
|
- height: 18px;
|
|
|
- stroke-width: 2;
|
|
|
- }
|
|
|
-
|
|
|
- &:hover:not(:disabled) {
|
|
|
- transform: translateY(-1px);
|
|
|
- box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
|
|
- background: linear-gradient(135deg, #7c8efc 0%, #8a5db8 100%);
|
|
|
- }
|
|
|
-
|
|
|
- &:active:not(:disabled) {
|
|
|
- transform: translateY(0);
|
|
|
- box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
|
|
- }
|
|
|
-
|
|
|
- &:disabled {
|
|
|
- background: #4a5568;
|
|
|
- box-shadow: none;
|
|
|
- cursor: not-allowed;
|
|
|
- opacity: 0.6;
|
|
|
- transform: none !important;
|
|
|
-
|
|
|
- &:hover {
|
|
|
- background: #4a5568;
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.query-hint {
|
|
|
- font-size: $font-size-sm;
|
|
|
- color: $text-muted;
|
|
|
-}
|
|
|
-
|
|
|
-.query-error {
|
|
|
- font-size: $font-size-sm;
|
|
|
- color: $error-color;
|
|
|
- margin-top: $spacing-xs;
|
|
|
- animation: fadeIn 0.3s ease;
|
|
|
-}
|
|
|
-
|
|
|
-@keyframes fadeIn {
|
|
|
- from {
|
|
|
- opacity: 0;
|
|
|
- transform: translateY(-5px);
|
|
|
- }
|
|
|
- to {
|
|
|
- opacity: 1;
|
|
|
- transform: translateY(0);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.spinner {
|
|
|
- animation: spin 1s linear infinite;
|
|
|
-}
|
|
|
-
|
|
|
-@keyframes spin {
|
|
|
- from {
|
|
|
- transform: rotate(0deg);
|
|
|
- }
|
|
|
- to {
|
|
|
- transform: rotate(360deg);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
// 模态框
|
|
|
.modal-overlay {
|
|
|
position: fixed;
|
|
|
@@ -1091,71 +367,10 @@ async function handleQuery() {
|
|
|
grid-template-columns: 1fr;
|
|
|
gap: $spacing-md;
|
|
|
}
|
|
|
-
|
|
|
- .section-header h2 {
|
|
|
- font-size: $font-size-lg;
|
|
|
- }
|
|
|
-
|
|
|
- .option-buttons {
|
|
|
- display: grid;
|
|
|
- grid-template-columns: repeat(3, 1fr); // 3列网格布局
|
|
|
- gap: $spacing-xs;
|
|
|
- }
|
|
|
-
|
|
|
- .option-btn {
|
|
|
- padding: $spacing-xs $spacing-sm;
|
|
|
- font-size: $font-size-sm; // 缩小字体
|
|
|
- text-align: center;
|
|
|
- }
|
|
|
-
|
|
|
- .custom-input {
|
|
|
- width: 100%; // 占满整行
|
|
|
- grid-column: 1 / -1; // 跨越所有列
|
|
|
- }
|
|
|
-
|
|
|
- .setting-item {
|
|
|
- flex-direction: column;
|
|
|
- align-items: stretch;
|
|
|
- gap: $spacing-xs;
|
|
|
-
|
|
|
- label {
|
|
|
- min-width: auto;
|
|
|
- margin-bottom: $spacing-xs;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- .query-input-group {
|
|
|
- flex-direction: column;
|
|
|
- }
|
|
|
-
|
|
|
- .btn-query {
|
|
|
- padding: $spacing-sm $spacing-lg;
|
|
|
- font-size: $font-size-sm;
|
|
|
- }
|
|
|
-
|
|
|
- // 进度条移动端适配
|
|
|
- .upload-progress-container {
|
|
|
- padding: $spacing-sm;
|
|
|
-
|
|
|
- .progress-info {
|
|
|
- flex-wrap: wrap;
|
|
|
- gap: $spacing-xs;
|
|
|
- font-size: $font-size-xs;
|
|
|
-
|
|
|
- .progress-percent {
|
|
|
- font-size: $font-size-sm;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- .progress-bar {
|
|
|
- height: 6px;
|
|
|
- }
|
|
|
- }
|
|
|
}
|
|
|
</style>
|
|
|
|
|
|
<script>
|
|
|
-import { watch } from 'vue'
|
|
|
export default {
|
|
|
name: 'HomePage'
|
|
|
}
|