feat: redesign retrieve workspace
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
import { ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { FileService } from '@/services'
|
import { FileService } from '@/services'
|
||||||
import { useAlertStore } from '@/stores/alertStore'
|
import { useAlertStore } from '@/stores/alertStore'
|
||||||
import { useFileDataStore } from '@/stores/fileData'
|
import { useFileDataStore } from '@/stores/fileData'
|
||||||
import type { ReceivedFileRecord } from '@/types'
|
import type { ReceivedFileRecord, ShareMetadataResponse, ShareSelectResponse } from '@/types'
|
||||||
import { copyToClipboard } from '@/utils/clipboard'
|
import { copyToClipboard } from '@/utils/clipboard'
|
||||||
import { getErrorMessage } from '@/utils/common'
|
import { getErrorMessage } from '@/utils/common'
|
||||||
import { renderMarkdownPreview } from '@/utils/content-preview'
|
import { renderMarkdownPreview } from '@/utils/content-preview'
|
||||||
@@ -22,6 +22,9 @@ export function useRetrieveFlow() {
|
|||||||
const { receiveData: records } = storeToRefs(fileStore)
|
const { receiveData: records } = storeToRefs(fileStore)
|
||||||
|
|
||||||
const code = ref('')
|
const code = ref('')
|
||||||
|
const inspectedFile = ref<ShareMetadataResponse | null>(null)
|
||||||
|
const isInspecting = ref(false)
|
||||||
|
const isRetrieving = ref(false)
|
||||||
const inputStatus = ref<InputStatus>({
|
const inputStatus = ref<InputStatus>({
|
||||||
readonly: false,
|
readonly: false,
|
||||||
loading: false
|
loading: false
|
||||||
@@ -31,6 +34,9 @@ export function useRetrieveFlow() {
|
|||||||
const showDrawer = ref(false)
|
const showDrawer = ref(false)
|
||||||
const showPreview = ref(false)
|
const showPreview = ref(false)
|
||||||
const renderedContent = ref('')
|
const renderedContent = ref('')
|
||||||
|
const normalizedCode = computed(() => code.value.trim())
|
||||||
|
const isWorking = computed(() => isInspecting.value || isRetrieving.value)
|
||||||
|
const hasValidCode = computed(() => normalizedCode.value.length === 5)
|
||||||
|
|
||||||
const formatFileSize = (bytes: number) => {
|
const formatFileSize = (bytes: number) => {
|
||||||
if (bytes === 0) return '0 ' + t('fileSize.bytes')
|
if (bytes === 0) return '0 ' + t('fileSize.bytes')
|
||||||
@@ -46,52 +52,103 @@ export function useRetrieveFlow() {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
const createRecord = (detail: {
|
const createRecord = (detail: ShareSelectResponse): ReceivedFileRecord => {
|
||||||
code: string
|
const isText =
|
||||||
name: string
|
typeof detail.is_text === 'boolean'
|
||||||
text: string
|
? detail.is_text
|
||||||
size: number
|
: detail.type
|
||||||
}): ReceivedFileRecord => {
|
? detail.type === 'text'
|
||||||
const isFile = detail.text.startsWith('/share/download') || detail.name !== 'Text'
|
: detail.name === 'Text' && !detail.text.startsWith('/share/download')
|
||||||
|
const content = isText ? (detail.content ?? detail.text) : null
|
||||||
|
const downloadUrl = isText ? null : (detail.download_url ?? detail.text)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
code: detail.code,
|
code: detail.code,
|
||||||
filename: detail.name,
|
filename: detail.name,
|
||||||
size: formatFileSize(detail.size),
|
size: formatFileSize(detail.size),
|
||||||
downloadUrl: isFile ? detail.text : null,
|
downloadUrl,
|
||||||
content: isFile ? null : detail.text,
|
content,
|
||||||
date: new Date().toLocaleString()
|
date: new Date().toLocaleString(),
|
||||||
|
type: isText ? 'text' : 'file',
|
||||||
|
remainingDownloads: detail.remaining_downloads
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const resetInspection = () => {
|
||||||
if (code.value.length !== 5) {
|
inspectedFile.value = null
|
||||||
|
error.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const inspectCode = async () => {
|
||||||
|
if (!hasValidCode.value) {
|
||||||
alertStore.showAlert(t('retrieve.messages.invalidCode'), 'error')
|
alertStore.showAlert(t('retrieve.messages.invalidCode'), 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isInspecting.value = true
|
||||||
inputStatus.value.readonly = true
|
inputStatus.value.readonly = true
|
||||||
inputStatus.value.loading = true
|
inputStatus.value.loading = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await FileService.selectFile(code.value)
|
const res = await FileService.inspectFile(normalizedCode.value)
|
||||||
|
if (res.code === 200 && res.detail) {
|
||||||
|
inspectedFile.value = res.detail
|
||||||
|
} else {
|
||||||
|
inspectedFile.value = null
|
||||||
|
error.value = String(res.detail || res.message || '')
|
||||||
|
alertStore.showAlert(t('retrieve.messages.retrieveFailure') + error.value, 'error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
inspectedFile.value = null
|
||||||
|
error.value = t('retrieve.messages.previewUnavailable')
|
||||||
|
alertStore.showAlert(error.value, 'warning')
|
||||||
|
} finally {
|
||||||
|
isInspecting.value = false
|
||||||
|
inputStatus.value.readonly = false
|
||||||
|
inputStatus.value.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!hasValidCode.value) {
|
||||||
|
alertStore.showAlert(t('retrieve.messages.invalidCode'), 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isRetrieving.value = true
|
||||||
|
inputStatus.value.readonly = true
|
||||||
|
inputStatus.value.loading = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await FileService.selectFile(normalizedCode.value)
|
||||||
if (res.code === 200 && res.detail) {
|
if (res.code === 200 && res.detail) {
|
||||||
const newFileData = createRecord(res.detail)
|
const newFileData = createRecord(res.detail)
|
||||||
if (!fileStore.receiveData.some((file) => file.code === newFileData.code)) {
|
const existingIndex = fileStore.receiveData.findIndex(
|
||||||
fileStore.addReceiveData(newFileData)
|
(file) => file.code === newFileData.code
|
||||||
|
)
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
fileStore.deleteReceiveData(existingIndex)
|
||||||
}
|
}
|
||||||
|
fileStore.addReceiveData(newFileData)
|
||||||
selectedRecord.value = newFileData
|
selectedRecord.value = newFileData
|
||||||
if (newFileData.content) {
|
if (newFileData.content) {
|
||||||
showPreview.value = true
|
showPreview.value = true
|
||||||
}
|
}
|
||||||
|
inspectedFile.value = null
|
||||||
alertStore.showAlert(t('retrieve.messages.retrieveSuccess'), 'success')
|
alertStore.showAlert(t('retrieve.messages.retrieveSuccess'), 'success')
|
||||||
} else {
|
} else {
|
||||||
alertStore.showAlert(t('retrieve.messages.retrieveFailure') + res.detail, 'error')
|
error.value = String(res.detail || '')
|
||||||
|
alertStore.showAlert(t('retrieve.messages.retrieveFailure') + error.value, 'error')
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errorMessage = getErrorMessage(err, t('retrieve.messages.unknownError'))
|
const errorMessage = getErrorMessage(err, t('retrieve.messages.unknownError'))
|
||||||
|
error.value = errorMessage
|
||||||
alertStore.showAlert(t('retrieve.messages.networkError') + errorMessage, 'error')
|
alertStore.showAlert(t('retrieve.messages.networkError') + errorMessage, 'error')
|
||||||
} finally {
|
} finally {
|
||||||
|
isRetrieving.value = false
|
||||||
inputStatus.value.readonly = false
|
inputStatus.value.readonly = false
|
||||||
inputStatus.value.loading = false
|
inputStatus.value.loading = false
|
||||||
code.value = ''
|
code.value = ''
|
||||||
@@ -151,8 +208,23 @@ export function useRetrieveFlow() {
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(normalizedCode, (nextCode) => {
|
||||||
|
if (inspectedFile.value && inspectedFile.value.code !== nextCode) {
|
||||||
|
resetInspection()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextCode.length < 5 && error.value) {
|
||||||
|
error.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code,
|
code,
|
||||||
|
inspectedFile,
|
||||||
|
isInspecting,
|
||||||
|
isRetrieving,
|
||||||
|
isWorking,
|
||||||
|
hasValidCode,
|
||||||
inputStatus,
|
inputStatus,
|
||||||
error,
|
error,
|
||||||
records,
|
records,
|
||||||
@@ -166,6 +238,8 @@ export function useRetrieveFlow() {
|
|||||||
deleteRecord,
|
deleteRecord,
|
||||||
downloadRecord,
|
downloadRecord,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
inspectCode,
|
||||||
|
resetInspection,
|
||||||
showContentPreview,
|
showContentPreview,
|
||||||
toggleDrawer,
|
toggleDrawer,
|
||||||
viewDetails
|
viewDetails
|
||||||
|
|||||||
@@ -202,7 +202,39 @@ export default {
|
|||||||
invalidCodeError: 'Invalid retrieval code',
|
invalidCodeError: 'Invalid retrieval code',
|
||||||
retrieveFailure: 'Failed to retrieve file: ',
|
retrieveFailure: 'Failed to retrieve file: ',
|
||||||
networkError: 'Retrieval failed, please try again later: ',
|
networkError: 'Retrieval failed, please try again later: ',
|
||||||
unknownError: 'Unknown error'
|
unknownError: 'Unknown error',
|
||||||
|
previewUnavailable: 'Preview is unavailable, you can still retrieve directly'
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
sendFile: 'Send File',
|
||||||
|
inspect: 'Preview',
|
||||||
|
inspecting: 'Previewing',
|
||||||
|
retrieving: 'Retrieving',
|
||||||
|
ready: 'Ready',
|
||||||
|
noPreview: 'Waiting for a code',
|
||||||
|
noPreviewDesc:
|
||||||
|
'Enter a 5-character retrieval code to preview file details before consuming a retrieval.',
|
||||||
|
latestRecord: 'Latest Retrieval',
|
||||||
|
noRecord: 'No retrieval records yet',
|
||||||
|
currentCode: 'Code',
|
||||||
|
fileSize: 'File Size',
|
||||||
|
viewDetail: 'View Detail',
|
||||||
|
historyCount: '{count} records',
|
||||||
|
openRecords: 'Open retrieval records',
|
||||||
|
security: 'Safe Retrieval',
|
||||||
|
securityState:
|
||||||
|
'Previewing does not consume retrieval count. Access is recorded only after confirmation.',
|
||||||
|
noExpiry: 'No limit',
|
||||||
|
unlimited: 'Unlimited',
|
||||||
|
remainingCount: '{count} left',
|
||||||
|
textType: 'Text Content',
|
||||||
|
fileType: 'File Download',
|
||||||
|
expiresAt: 'Expires At',
|
||||||
|
remainingDownloads: 'Remaining',
|
||||||
|
usedCount: 'Retrieved',
|
||||||
|
emptyCode: 'Empty',
|
||||||
|
previewState: 'Preview State',
|
||||||
|
waiting: 'Waiting'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -202,7 +202,37 @@ export default {
|
|||||||
invalidCodeError: '无效的取件码',
|
invalidCodeError: '无效的取件码',
|
||||||
retrieveFailure: '获取文件失败:',
|
retrieveFailure: '获取文件失败:',
|
||||||
networkError: '取件失败,请稍后重试:',
|
networkError: '取件失败,请稍后重试:',
|
||||||
unknownError: '未知错误'
|
unknownError: '未知错误',
|
||||||
|
previewUnavailable: '暂时无法预览文件信息,可直接取件'
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
sendFile: '发送文件',
|
||||||
|
inspect: '预览',
|
||||||
|
inspecting: '预览中',
|
||||||
|
retrieving: '取件中',
|
||||||
|
ready: '可取件',
|
||||||
|
noPreview: '等待取件码',
|
||||||
|
noPreviewDesc: '输入 5 位取件码后会先读取文件信息,确认后再消耗取件次数。',
|
||||||
|
latestRecord: '最近取件',
|
||||||
|
noRecord: '暂无取件记录',
|
||||||
|
currentCode: '取件码',
|
||||||
|
fileSize: '文件大小',
|
||||||
|
viewDetail: '查看详情',
|
||||||
|
historyCount: '{count} 条记录',
|
||||||
|
openRecords: '打开取件记录',
|
||||||
|
security: '安全取件',
|
||||||
|
securityState: '预览不会消耗取件次数,确认取件后才会记录访问。',
|
||||||
|
noExpiry: '不限制',
|
||||||
|
unlimited: '不限次数',
|
||||||
|
remainingCount: '剩余 {count} 次',
|
||||||
|
textType: '文本内容',
|
||||||
|
fileType: '文件下载',
|
||||||
|
expiresAt: '过期时间',
|
||||||
|
remainingDownloads: '剩余次数',
|
||||||
|
usedCount: '已取件',
|
||||||
|
emptyCode: '未输入',
|
||||||
|
previewState: '预览状态',
|
||||||
|
waiting: '等待输入'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
FileInfo,
|
FileInfo,
|
||||||
FileListResponse,
|
FileListResponse,
|
||||||
FileUploadResponse,
|
FileUploadResponse,
|
||||||
|
ShareMetadataResponse,
|
||||||
ShareSelectResponse,
|
ShareSelectResponse,
|
||||||
TextSendResponse,
|
TextSendResponse,
|
||||||
UploadProgress
|
UploadProgress
|
||||||
@@ -100,6 +101,10 @@ export class FileService {
|
|||||||
return api.post('/share/select/', { code })
|
return api.post('/share/select/', { code })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async inspectFile(code: string): Promise<ApiResponse<ShareMetadataResponse>> {
|
||||||
|
return api.post('/share/metadata/', { code })
|
||||||
|
}
|
||||||
|
|
||||||
static async getFile(code: string): Promise<ApiResponse<FileInfo>> {
|
static async getFile(code: string): Promise<ApiResponse<FileInfo>> {
|
||||||
return api.get(`/file/${code}`)
|
return api.get(`/file/${code}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,30 @@ export interface ShareSelectResponse {
|
|||||||
name: string
|
name: string
|
||||||
text: string
|
text: string
|
||||||
size: number
|
size: number
|
||||||
|
type?: 'file' | 'text'
|
||||||
|
is_text?: boolean
|
||||||
|
content?: string | null
|
||||||
|
download_url?: string | null
|
||||||
|
created_at?: string | null
|
||||||
|
expired_at?: string | null
|
||||||
|
expires_at?: string | null
|
||||||
|
expired_count?: number | null
|
||||||
|
used_count?: number
|
||||||
|
remaining_downloads?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareMetadataResponse {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
type: 'file' | 'text'
|
||||||
|
is_text: boolean
|
||||||
|
created_at?: string | null
|
||||||
|
expired_at?: string | null
|
||||||
|
expires_at?: string | null
|
||||||
|
expired_count?: number | null
|
||||||
|
used_count?: number
|
||||||
|
remaining_downloads?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReceivedFileRecord {
|
export interface ReceivedFileRecord {
|
||||||
@@ -67,6 +91,8 @@ export interface ReceivedFileRecord {
|
|||||||
downloadUrl: string | null
|
downloadUrl: string | null
|
||||||
content: string | null
|
content: string | null
|
||||||
date: string
|
date: string
|
||||||
|
type?: 'file' | 'text'
|
||||||
|
remainingDownloads?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SentFileRecord {
|
export interface SentFileRecord {
|
||||||
|
|||||||
+431
-32
@@ -1,36 +1,351 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="min-h-screen px-4 py-12 transition-colors duration-300 sm:px-6 lg:px-8">
|
||||||
class="min-h-screen flex items-center justify-center p-4 overflow-hidden transition-colors duration-300"
|
<main class="mx-auto flex min-h-[calc(100vh-6rem)] w-full max-w-6xl items-center">
|
||||||
>
|
<div class="grid w-full gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(320px,0.9fr)]">
|
||||||
<div class="w-full max-w-md relative z-10">
|
<section
|
||||||
<div
|
class="rounded-3xl border p-6 shadow-2xl transition-colors duration-300 sm:p-8"
|
||||||
class="rounded-3xl shadow-2xl overflow-hidden border transform transition-all duration-300"
|
|
||||||
:class="[
|
:class="[
|
||||||
isDarkMode
|
isDarkMode
|
||||||
? 'bg-gray-800 bg-opacity-50 backdrop-filter backdrop-blur-xl border-gray-700'
|
? 'border-gray-700 bg-gray-900/70 shadow-black/30'
|
||||||
: 'bg-white border-gray-200'
|
: 'border-white/80 bg-white/90 shadow-sky-100'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="p-8">
|
<div class="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<PageHeader :title="config.name" @title-click="toSend" />
|
<div>
|
||||||
<RetrieveForm
|
<button
|
||||||
v-model="code"
|
type="button"
|
||||||
:input-status="inputStatus"
|
@click="toSend"
|
||||||
:error="!!error"
|
class="text-left text-3xl font-extrabold leading-tight transition-colors"
|
||||||
@submit="handleSubmit"
|
:class="[
|
||||||
ref="retrieveFormRef"
|
isDarkMode ? 'text-white hover:text-sky-200' : 'text-gray-950 hover:text-sky-700'
|
||||||
/>
|
]"
|
||||||
|
>
|
||||||
|
{{ config.name }}
|
||||||
|
</button>
|
||||||
|
<p
|
||||||
|
class="mt-3 max-w-2xl text-sm leading-6"
|
||||||
|
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']"
|
||||||
|
>
|
||||||
|
{{ config.description }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<PageFooter
|
<router-link
|
||||||
:link-text="$t('retrieve.needSendFile')"
|
to="/send"
|
||||||
link-to="/send"
|
class="inline-flex shrink-0 items-center justify-center rounded-full border px-4 py-2 text-sm font-medium transition"
|
||||||
:drawer-text="$t('retrieve.recordsDrawer')"
|
:class="[
|
||||||
@toggle-drawer="toggleDrawer"
|
isDarkMode
|
||||||
/>
|
? 'border-gray-700 text-sky-200 hover:border-sky-400 hover:bg-sky-500/10'
|
||||||
|
: 'border-sky-100 bg-sky-50 text-sky-700 hover:border-sky-200 hover:bg-sky-100'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<SendIcon class="mr-2 h-4 w-4" />
|
||||||
|
{{ t('retrieve.workspace.sendFile') }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 grid gap-3 sm:grid-cols-3">
|
||||||
|
<div
|
||||||
|
v-for="item in workspaceStats"
|
||||||
|
:key="item.label"
|
||||||
|
class="rounded-2xl border px-4 py-3"
|
||||||
|
:class="[
|
||||||
|
isDarkMode ? 'border-gray-700 bg-gray-800/60' : 'border-gray-100 bg-gray-50'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<p class="text-xs" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']">
|
||||||
|
{{ item.label }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="mt-1 truncate text-sm font-semibold"
|
||||||
|
:class="[isDarkMode ? 'text-gray-100' : 'text-gray-900']"
|
||||||
|
>
|
||||||
|
{{ item.value }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SideDrawer :visible="showDrawer" :title="$t('retrieve.recordsDrawer')" @close="toggleDrawer">
|
<form class="mt-8 space-y-4" @submit.prevent="inspectCode">
|
||||||
|
<label
|
||||||
|
for="retrieve-code"
|
||||||
|
class="block text-sm font-medium"
|
||||||
|
:class="[isDarkMode ? 'text-gray-200' : 'text-gray-800']"
|
||||||
|
>
|
||||||
|
{{ t('retrieve.codeInput.label') }}
|
||||||
|
</label>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto_auto]">
|
||||||
|
<input
|
||||||
|
id="retrieve-code"
|
||||||
|
v-model="code"
|
||||||
|
type="text"
|
||||||
|
inputmode="text"
|
||||||
|
maxlength="5"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
class="h-12 w-full rounded-2xl border px-4 text-lg font-semibold tracking-[0.2em] outline-none transition focus:ring-2 focus:ring-sky-500"
|
||||||
|
:class="[
|
||||||
|
isDarkMode
|
||||||
|
? 'border-gray-700 bg-gray-800 text-white placeholder-gray-500'
|
||||||
|
: 'border-gray-200 bg-white text-gray-950 placeholder-gray-400',
|
||||||
|
error ? 'border-red-400 focus:ring-red-400' : ''
|
||||||
|
]"
|
||||||
|
:placeholder="t('retrieve.codeInput.placeholder')"
|
||||||
|
:readonly="isWorking"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
type="submit"
|
||||||
|
variant="secondary"
|
||||||
|
:loading="isInspecting"
|
||||||
|
:disabled="!hasValidCode || isRetrieving"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<SearchIcon class="mr-2 h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
{{
|
||||||
|
isInspecting
|
||||||
|
? t('retrieve.workspace.inspecting')
|
||||||
|
: t('retrieve.workspace.inspect')
|
||||||
|
}}
|
||||||
|
</BaseButton>
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
type="button"
|
||||||
|
:loading="isRetrieving"
|
||||||
|
:disabled="!hasValidCode || isInspecting"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<DownloadCloudIcon class="mr-2 h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
{{ isRetrieving ? t('retrieve.workspace.retrieving') : t('retrieve.submit') }}
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="text-sm text-red-500">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="mt-8 rounded-3xl border p-5"
|
||||||
|
:class="[isDarkMode ? 'border-gray-700 bg-gray-800/60' : 'border-gray-100 bg-gray-50']"
|
||||||
|
>
|
||||||
|
<div v-if="inspectedFile" class="space-y-5">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex min-w-0 items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl"
|
||||||
|
:class="[isDarkMode ? 'bg-sky-500/15 text-sky-200' : 'bg-sky-100 text-sky-700']"
|
||||||
|
>
|
||||||
|
<FileTextIcon v-if="inspectedFile.is_text" class="h-5 w-5" />
|
||||||
|
<FileIcon v-else class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p
|
||||||
|
class="truncate text-lg font-bold"
|
||||||
|
:class="[isDarkMode ? 'text-white' : 'text-gray-950']"
|
||||||
|
>
|
||||||
|
{{ inspectedFile.name }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="mt-1 text-sm"
|
||||||
|
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
|
||||||
|
>
|
||||||
|
{{ inspectedTypeLabel }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="rounded-full px-3 py-1 text-xs font-semibold"
|
||||||
|
:class="[
|
||||||
|
isDarkMode
|
||||||
|
? 'bg-emerald-500/15 text-emerald-200'
|
||||||
|
: 'bg-emerald-100 text-emerald-700'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ t('retrieve.workspace.ready') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div
|
||||||
|
v-for="item in inspectedMetaItems"
|
||||||
|
:key="item.label"
|
||||||
|
class="rounded-2xl px-4 py-3"
|
||||||
|
:class="[isDarkMode ? 'bg-gray-900/70' : 'bg-white']"
|
||||||
|
>
|
||||||
|
<dt class="text-xs" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']">
|
||||||
|
{{ item.label }}
|
||||||
|
</dt>
|
||||||
|
<dd
|
||||||
|
class="mt-1 truncate text-sm font-semibold"
|
||||||
|
:class="[isDarkMode ? 'text-gray-100' : 'text-gray-900']"
|
||||||
|
>
|
||||||
|
{{ item.value }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex min-h-48 flex-col items-center justify-center text-center">
|
||||||
|
<PackageSearchIcon
|
||||||
|
class="h-12 w-12"
|
||||||
|
:class="[isDarkMode ? 'text-gray-500' : 'text-gray-300']"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
class="mt-4 text-lg font-semibold"
|
||||||
|
:class="[isDarkMode ? 'text-white' : 'text-gray-900']"
|
||||||
|
>
|
||||||
|
{{ t('retrieve.workspace.noPreview') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="mt-2 max-w-md text-sm"
|
||||||
|
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
|
||||||
|
>
|
||||||
|
{{ t('retrieve.workspace.noPreviewDesc') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="grid gap-6">
|
||||||
|
<section
|
||||||
|
class="rounded-3xl border p-6 transition-colors duration-300"
|
||||||
|
:class="[isDarkMode ? 'border-gray-700 bg-gray-900/70' : 'border-white/80 bg-white/85']"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
class="text-sm font-medium"
|
||||||
|
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']"
|
||||||
|
>
|
||||||
|
{{ t('retrieve.workspace.latestRecord') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="mt-2 text-xl font-bold"
|
||||||
|
:class="[isDarkMode ? 'text-white' : 'text-gray-950']"
|
||||||
|
>
|
||||||
|
{{ latestRecord ? latestRecord.filename : t('retrieve.workspace.noRecord') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ArchiveRestoreIcon class="h-10 w-10 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="latestRecord" class="mt-5 space-y-3">
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div
|
||||||
|
class="rounded-2xl px-4 py-3"
|
||||||
|
:class="[isDarkMode ? 'bg-gray-800/70' : 'bg-gray-50']"
|
||||||
|
>
|
||||||
|
<p class="text-xs" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']">
|
||||||
|
{{ t('retrieve.workspace.currentCode') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="mt-1 text-sm font-semibold"
|
||||||
|
:class="[isDarkMode ? 'text-gray-100' : 'text-gray-900']"
|
||||||
|
>
|
||||||
|
{{ latestRecord.code }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-2xl px-4 py-3"
|
||||||
|
:class="[isDarkMode ? 'bg-gray-800/70' : 'bg-gray-50']"
|
||||||
|
>
|
||||||
|
<p class="text-xs" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']">
|
||||||
|
{{ t('retrieve.workspace.fileSize') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="mt-1 text-sm font-semibold"
|
||||||
|
:class="[isDarkMode ? 'text-gray-100' : 'text-gray-900']"
|
||||||
|
>
|
||||||
|
{{ latestRecord.size }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-xl px-4 py-2 text-sm font-medium transition"
|
||||||
|
:class="[
|
||||||
|
isDarkMode
|
||||||
|
? 'bg-gray-800 text-gray-100 hover:bg-gray-700'
|
||||||
|
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||||
|
]"
|
||||||
|
@click="viewDetails(latestRecord)"
|
||||||
|
>
|
||||||
|
{{ t('retrieve.workspace.viewDetail') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-xl px-4 py-2 text-sm font-medium text-white transition hover:opacity-90"
|
||||||
|
:class="[isDarkMode ? 'bg-sky-500' : 'bg-sky-600']"
|
||||||
|
@click="downloadRecord(latestRecord)"
|
||||||
|
>
|
||||||
|
{{ t('fileDetail.download') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="rounded-3xl border p-6 transition-colors duration-300"
|
||||||
|
:class="[isDarkMode ? 'border-gray-700 bg-gray-900/70' : 'border-white/80 bg-white/85']"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
class="text-sm font-medium"
|
||||||
|
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']"
|
||||||
|
>
|
||||||
|
{{ t('retrieve.recordsDrawer') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="mt-2 text-xl font-bold"
|
||||||
|
:class="[isDarkMode ? 'text-white' : 'text-gray-950']"
|
||||||
|
>
|
||||||
|
{{ t('retrieve.workspace.historyCount', { count: records.length }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="toggleDrawer"
|
||||||
|
class="inline-flex h-11 w-11 items-center justify-center rounded-full transition"
|
||||||
|
:class="[
|
||||||
|
isDarkMode
|
||||||
|
? 'bg-gray-800 text-sky-200 hover:bg-gray-700'
|
||||||
|
: 'bg-sky-50 text-sky-700 hover:bg-sky-100'
|
||||||
|
]"
|
||||||
|
:aria-label="t('retrieve.workspace.openRecords')"
|
||||||
|
>
|
||||||
|
<ClipboardListIcon class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="rounded-3xl border p-6 transition-colors duration-300"
|
||||||
|
:class="[isDarkMode ? 'border-gray-700 bg-gray-900/70' : 'border-white/80 bg-white/85']"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<ShieldCheckIcon class="h-10 w-10 text-teal-400" />
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
class="text-sm font-medium"
|
||||||
|
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']"
|
||||||
|
>
|
||||||
|
{{ t('retrieve.workspace.security') }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']">
|
||||||
|
{{ t('retrieve.workspace.securityState') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<SideDrawer :visible="showDrawer" :title="t('retrieve.recordsDrawer')" @close="toggleDrawer">
|
||||||
<FileRecordList
|
<FileRecordList
|
||||||
:records="records"
|
:records="records"
|
||||||
@view-details="viewDetails"
|
@view-details="viewDetails"
|
||||||
@@ -56,12 +371,22 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { inject, onMounted, watch } from 'vue'
|
import { computed, inject, onMounted, watch } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import PageHeader from '@/components/common/PageHeader.vue'
|
import {
|
||||||
import RetrieveForm from '@/components/common/RetrieveForm.vue'
|
ArchiveRestoreIcon,
|
||||||
import PageFooter from '@/components/common/PageFooter.vue'
|
ClipboardListIcon,
|
||||||
|
DownloadCloudIcon,
|
||||||
|
FileIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
PackageSearchIcon,
|
||||||
|
SearchIcon,
|
||||||
|
SendIcon,
|
||||||
|
ShieldCheckIcon
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import BaseButton from '@/components/common/BaseButton.vue'
|
||||||
import SideDrawer from '@/components/common/SideDrawer.vue'
|
import SideDrawer from '@/components/common/SideDrawer.vue'
|
||||||
import FileDetailModal from '@/components/common/FileDetailModal.vue'
|
import FileDetailModal from '@/components/common/FileDetailModal.vue'
|
||||||
import FileRecordList from '@/components/common/FileRecordList.vue'
|
import FileRecordList from '@/components/common/FileRecordList.vue'
|
||||||
@@ -70,13 +395,18 @@ import { useRetrieveFlow } from '@/composables'
|
|||||||
import { useConfigStore } from '@/stores/configStore'
|
import { useConfigStore } from '@/stores/configStore'
|
||||||
|
|
||||||
const isDarkMode = inject('isDarkMode')
|
const isDarkMode = inject('isDarkMode')
|
||||||
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const { config } = storeToRefs(configStore)
|
const { config } = storeToRefs(configStore)
|
||||||
const {
|
const {
|
||||||
code,
|
code,
|
||||||
inputStatus,
|
inspectedFile,
|
||||||
|
isInspecting,
|
||||||
|
isRetrieving,
|
||||||
|
isWorking,
|
||||||
|
hasValidCode,
|
||||||
error,
|
error,
|
||||||
records,
|
records,
|
||||||
selectedRecord,
|
selectedRecord,
|
||||||
@@ -89,6 +419,7 @@ const {
|
|||||||
deleteRecord,
|
deleteRecord,
|
||||||
downloadRecord,
|
downloadRecord,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
inspectCode,
|
||||||
showContentPreview,
|
showContentPreview,
|
||||||
toggleDrawer,
|
toggleDrawer,
|
||||||
viewDetails
|
viewDetails
|
||||||
@@ -98,16 +429,84 @@ const toSend = () => {
|
|||||||
router.push('/send')
|
router.push('/send')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes <= 0) return `0 ${t('fileSize.bytes')}`
|
||||||
|
const units = [t('fileSize.bytes'), t('fileSize.kb'), t('fileSize.mb'), t('fileSize.gb')]
|
||||||
|
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||||
|
return `${parseFloat((bytes / Math.pow(1024, index)).toFixed(2))} ${units[index]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (value?: string | null) => {
|
||||||
|
if (!value) return t('retrieve.workspace.noExpiry')
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return t('retrieve.workspace.noExpiry')
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatRemainingDownloads = (value?: number | null) => {
|
||||||
|
if (value === null || value === undefined) return t('retrieve.workspace.unlimited')
|
||||||
|
return t('retrieve.workspace.remainingCount', { count: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestRecord = computed(() => {
|
||||||
|
if (records.value.length === 0) return null
|
||||||
|
return records.value[records.value.length - 1]
|
||||||
|
})
|
||||||
|
|
||||||
|
const inspectedTypeLabel = computed(() => {
|
||||||
|
if (!inspectedFile.value) return ''
|
||||||
|
return inspectedFile.value.is_text
|
||||||
|
? t('retrieve.workspace.textType')
|
||||||
|
: t('retrieve.workspace.fileType')
|
||||||
|
})
|
||||||
|
|
||||||
|
const inspectedMetaItems = computed(() => {
|
||||||
|
if (!inspectedFile.value) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('retrieve.workspace.fileSize'),
|
||||||
|
value: formatFileSize(inspectedFile.value.size)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('retrieve.workspace.expiresAt'),
|
||||||
|
value: formatDate(inspectedFile.value.expires_at || inspectedFile.value.expired_at)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('retrieve.workspace.remainingDownloads'),
|
||||||
|
value: formatRemainingDownloads(inspectedFile.value.remaining_downloads)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('retrieve.workspace.usedCount'),
|
||||||
|
value: String(inspectedFile.value.used_count || 0)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const workspaceStats = computed(() => [
|
||||||
|
{
|
||||||
|
label: t('retrieve.workspace.currentCode'),
|
||||||
|
value: code.value || t('retrieve.workspace.emptyCode')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('retrieve.workspace.previewState'),
|
||||||
|
value: inspectedFile.value ? t('retrieve.workspace.ready') : t('retrieve.workspace.waiting')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('retrieve.recordsDrawer'),
|
||||||
|
value: t('retrieve.workspace.historyCount', { count: records.value.length })
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const queryCode = route.query.code
|
const queryCode = route.query.code
|
||||||
if (queryCode && typeof queryCode === 'string') {
|
if (queryCode && typeof queryCode === 'string') {
|
||||||
code.value = queryCode
|
code.value = queryCode.slice(0, 5)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(code, (newCode) => {
|
watch(code, (newCode) => {
|
||||||
if (newCode.length === 5) {
|
if (newCode.trim().length === 5 && !isWorking.value) {
|
||||||
void handleSubmit()
|
void inspectCode()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user