feat: redesign send workspace experience

This commit is contained in:
Lan
2026-06-03 02:22:24 +08:00
parent a11e7900b4
commit c83f958212
4 changed files with 442 additions and 119 deletions
+322 -106
View File
@@ -1,109 +1,270 @@
<template>
<div
class="min-h-screen flex items-center justify-center p-4 overflow-hidden transition-colors duration-300"
class="min-h-screen px-4 py-16 transition-colors duration-300 sm:px-6 lg:px-8"
@paste.prevent="handlePaste"
>
<div
class="rounded-3xl shadow-2xl overflow-hidden border w-full max-w-md transition-colors duration-300"
:class="[
isDarkMode
? 'bg-white bg-opacity-10 backdrop-filter backdrop-blur-xl border-gray-700'
: 'bg-white border-gray-200'
]"
>
<div class="p-8">
<PageHeader :title="config.name" @title-click="toRetrieve" />
<form @submit.prevent="handleSubmit" class="space-y-8">
<SendTypeSelector
:selected-type="sendType"
@update:selected-type="sendType = $event"
/>
<transition name="fade" mode="out-in">
<div v-if="sendType === 'file'" key="file" class="grid grid-cols-1 gap-8">
<FileUploadArea
:selected-file="selectedFile"
:selected-files="selectedFiles"
:progress="uploadProgress"
:description="uploadDescription"
@file-selected="handleFileSelected"
@files-selected="handleFilesSelected"
@file-drop="handleFileDrop"
/>
</div>
<div v-else key="text" class="grid grid-cols-1 gap-8">
<TextInputArea v-model="textContent" :placeholder="t('send.uploadArea.textInput')" />
</div>
</transition>
<ExpirationSelector
v-model:expiration-method="expirationMethod"
v-model:expiration-value="expirationValue"
:options="expirationOptions"
/>
<!-- 提交按钮 -->
<button
type="submit"
:disabled="isSubmitting"
class="w-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white font-bold py-4 px-6 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-opacity-50 transition-all duration-300 transform hover:scale-105 hover:shadow-lg relative overflow-hidden group disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none disabled:hover:scale-100"
>
<span
class="absolute top-0 left-0 w-full h-full bg-white opacity-0 group-hover:opacity-20 transition-opacity duration-300"
></span>
<span class="relative z-10 flex items-center justify-center text-lg">
<svg
v-if="isSubmitting"
class="w-6 h-6 mr-2 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
<main class="mx-auto flex min-h-[calc(100vh-8rem)] w-full max-w-6xl items-center">
<div class="grid w-full gap-6 lg:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]">
<section
class="rounded-3xl border p-6 shadow-2xl transition-colors duration-300 sm:p-8"
:class="[
isDarkMode
? 'border-gray-700 bg-gray-900/70 shadow-black/30'
: 'border-white/80 bg-white/90 shadow-indigo-100'
]"
>
<div class="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
<div>
<button
type="button"
@click="toRetrieve"
class="text-left text-3xl font-extrabold leading-tight transition-colors"
:class="[
isDarkMode
? 'text-white hover:text-indigo-200'
: 'text-gray-950 hover:text-indigo-700'
]"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<SendIcon v-else class="w-6 h-6 mr-2" />
<span>{{ isSubmitting ? t('send.submitting') : t('send.submit') }}</span>
</span>
</button>
</form>
<div class="mt-6 text-center">
<router-link to="/" class="text-indigo-400 hover:text-indigo-300 transition duration-300">
{{ t('send.needRetrieveFile') }}
</router-link>
</div>
</div>
{{ 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>
<router-link
to="/"
class="inline-flex shrink-0 items-center justify-center rounded-full border px-4 py-2 text-sm font-medium transition"
:class="[
isDarkMode
? 'border-gray-700 text-indigo-200 hover:border-indigo-400 hover:bg-indigo-500/10'
: 'border-indigo-100 bg-indigo-50 text-indigo-700 hover:border-indigo-200 hover:bg-indigo-100'
]"
>
{{ t('send.needRetrieveFile') }}
</router-link>
</div>
<div
class="px-8 py-4 bg-opacity-50 flex justify-between items-center"
:class="[isDarkMode ? 'bg-gray-800' : 'bg-gray-100']"
>
<span
class="text-sm flex items-center"
:class="[isDarkMode ? 'text-gray-300' : 'text-gray-800']"
>
<ShieldCheckIcon class="w-4 h-4 mr-1 text-green-400" />
{{ t('send.secureEncryption') }}
</span>
<button
@click="toggleDrawer"
class="text-sm hover:text-indigo-300 transition duration-300 flex items-center"
:class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']"
>
{{ t('send.sendRecords') }}
<ClipboardListIcon class="w-4 h-4 ml-1" />
</button>
<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>
<form @submit.prevent="handleSubmit" class="mt-8 space-y-7">
<SendTypeSelector :selected-type="sendType" @update:selected-type="sendType = $event" />
<transition name="fade" mode="out-in">
<div v-if="sendType === 'file'" key="file" class="grid grid-cols-1 gap-8">
<FileUploadArea
:selected-file="selectedFile"
:selected-files="selectedFiles"
:progress="uploadProgress"
:description="uploadDescription"
:upload-status="isSubmitting ? 'uploading' : 'idle'"
@file-selected="handleFileSelected"
@files-selected="handleFilesSelected"
@file-drop="handleFileDrop"
/>
</div>
<div v-else key="text" class="grid grid-cols-1 gap-8">
<TextInputArea
v-model="textContent"
:rows="10"
:placeholder="t('send.uploadArea.textInput')"
/>
</div>
</transition>
<ExpirationSelector
v-model:expiration-method="expirationMethod"
v-model:expiration-value="expirationValue"
:options="expirationOptions"
/>
<button
type="submit"
:disabled="isSubmitting"
class="group relative w-full overflow-hidden rounded-2xl bg-gray-950 px-6 py-4 text-white transition-all duration-300 hover:-translate-y-0.5 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:translate-y-0"
>
<span
class="absolute inset-0 bg-gradient-to-r from-indigo-500 via-sky-500 to-emerald-400 opacity-90 transition-opacity duration-300 group-hover:opacity-100"
></span>
<span class="relative z-10 flex items-center justify-center text-lg">
<svg
v-if="isSubmitting"
class="w-6 h-6 mr-2 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<SendIcon v-else class="w-6 h-6 mr-2" />
<span>{{ isSubmitting ? t('send.submitting') : t('send.submit') }}</span>
</span>
</button>
</form>
</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('send.workspace.currentTask') }}
</p>
<p
class="mt-2 text-2xl font-bold"
:class="[isDarkMode ? 'text-white' : 'text-gray-950']"
>
{{ selectedPayloadLabel }}
</p>
</div>
<ShieldCheckIcon class="h-10 w-10 text-emerald-400" />
</div>
<dl class="mt-6 space-y-3">
<div
v-for="item in readinessItems"
:key="item.label"
class="flex items-center justify-between gap-4 rounded-2xl px-4 py-3"
:class="[isDarkMode ? 'bg-gray-800/70' : 'bg-gray-50']"
>
<dt class="text-sm" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']">
{{ item.label }}
</dt>
<dd
class="text-right text-sm font-semibold"
:class="[isDarkMode ? 'text-gray-100' : 'text-gray-900']"
>
{{ item.value }}
</dd>
</div>
</dl>
</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('send.workspace.latestRecord') }}
</p>
<p
class="mt-2 text-xl font-bold"
:class="[isDarkMode ? 'text-white' : 'text-gray-950']"
>
{{ latestRecord ? latestRecord.retrieveCode : t('send.workspace.noRecord') }}
</p>
</div>
<ClipboardListIcon class="h-10 w-10 text-sky-400" />
</div>
<div v-if="latestRecord" class="mt-5 space-y-3">
<p class="truncate text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']">
{{ latestRecord.filename }}
</p>
<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="copySentRecordLink(latestRecord)"
>
{{ t('send.workspace.copyLink') }}
</button>
<button
type="button"
class="rounded-xl px-4 py-2 text-sm font-medium text-white transition hover:opacity-90"
:class="[isDarkMode ? 'bg-indigo-500' : 'bg-indigo-600']"
@click="viewDetails(latestRecord)"
>
{{ t('send.workspace.viewDetail') }}
</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('send.workspace.historyTitle') }}
</p>
<p
class="mt-2 text-xl font-bold"
:class="[isDarkMode ? 'text-white' : 'text-gray-950']"
>
{{ t('send.workspace.historyCount', { count: sendRecords.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-indigo-200 hover:bg-gray-700'
: 'bg-indigo-50 text-indigo-700 hover:bg-indigo-100'
]"
:aria-label="t('send.workspace.openRecords')"
>
<ClipboardListIcon class="h-5 w-5" />
</button>
</div>
</section>
</aside>
</div>
</div>
</main>
<SideDrawer :visible="showDrawer" :title="t('send.sendRecords')" @close="toggleDrawer">
<SentRecordList
@@ -126,15 +287,10 @@
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { computed, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import {
SendIcon,
ClipboardListIcon,
ShieldCheckIcon
} from 'lucide-vue-next'
import PageHeader from '@/components/common/PageHeader.vue'
import { SendIcon, ClipboardListIcon, ShieldCheckIcon } from 'lucide-vue-next'
import SendTypeSelector from '@/components/common/SendTypeSelector.vue'
import FileUploadArea from '@/components/common/FileUploadArea.vue'
import ExpirationSelector from '@/components/common/ExpirationSelector.vue'
@@ -143,6 +299,7 @@ import SideDrawer from '@/components/common/SideDrawer.vue'
import SentRecordList from '@/components/common/SentRecordList.vue'
import SentRecordDetailModal from '@/components/common/SentRecordDetailModal.vue'
import { useSendFlow } from '@/composables'
import { getStorageUnit } from '@/utils/convert'
const isDarkMode = inject('isDarkMode')
const { t } = useI18n()
@@ -173,6 +330,7 @@ const {
handleFilesSelected,
handlePaste,
handleSubmit,
getUnit,
toggleDrawer,
viewDetails
} = useSendFlow()
@@ -180,6 +338,64 @@ const {
const toRetrieve = () => {
router.push('/')
}
const selectedFileCount = computed(() =>
selectedFiles.value.length > 0 ? selectedFiles.value.length : selectedFile.value ? 1 : 0
)
const selectedPayloadLabel = computed(() => {
if (sendType.value === 'text') {
return t('send.workspace.textDraft', { count: textContent.value.trim().length })
}
if (selectedFileCount.value > 0) {
return t('send.workspace.fileReady', { count: selectedFileCount.value })
}
return t('send.workspace.awaitingFile')
})
const latestRecord = computed(() => {
if (sendRecords.value.length === 0) return null
return sendRecords.value[sendRecords.value.length - 1]
})
const expirationPreview = computed(() => {
if (expirationMethod.value === 'forever') {
return getUnit('forever')
}
return `${expirationValue.value || 1} ${getUnit(expirationMethod.value)}`
})
const workspaceStats = computed(() => [
{
label: t('send.workspace.uploadLimit'),
value: getStorageUnit(config.value.uploadSize)
},
{
label: t('send.workspace.uploadMode'),
value: config.value.enableChunk
? t('send.workspace.chunkMode')
: t('send.workspace.standardMode')
},
{
label: t('send.workspace.guestPolicy'),
value: config.value.openUpload ? t('send.workspace.guestOpen') : t('send.workspace.guestClosed')
}
])
const readinessItems = computed(() => [
{
label: t('send.workspace.payload'),
value: selectedPayloadLabel.value
},
{
label: t('send.workspace.expirationPreview'),
value: expirationPreview.value
},
{
label: t('send.workspace.security'),
value: t('send.secureEncryption')
}
])
</script>
<style scoped>