refactor: modularize frontend flows
This commit is contained in:
+39
-416
@@ -42,24 +42,23 @@
|
||||
<FileDetailModal
|
||||
:visible="!!selectedRecord"
|
||||
:record="selectedRecord"
|
||||
@close="selectedRecord = null"
|
||||
@show-content-preview="showContentPreview"
|
||||
:get-download-url="getDownloadUrl"
|
||||
:get-qr-code-value="getQRCodeValue"
|
||||
@close="closeDetails"
|
||||
@preview-content="showContentPreview"
|
||||
/>
|
||||
|
||||
<ContentPreviewModal
|
||||
:visible="showPreview"
|
||||
:rendered-content="renderedContent"
|
||||
@close="showPreview = false"
|
||||
@close="closeContentPreview"
|
||||
@copy-content="copyContent"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, inject, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { inject, onMounted, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
import RetrieveForm from '@/components/common/RetrieveForm.vue'
|
||||
import PageFooter from '@/components/common/PageFooter.vue'
|
||||
@@ -67,424 +66,48 @@ import SideDrawer from '@/components/common/SideDrawer.vue'
|
||||
import FileDetailModal from '@/components/common/FileDetailModal.vue'
|
||||
import FileRecordList from '@/components/common/FileRecordList.vue'
|
||||
import ContentPreviewModal from '@/components/common/ContentPreviewModal.vue'
|
||||
import { useRetrieveFlow } from '@/composables'
|
||||
import { useConfigStore } from '@/stores/configStore'
|
||||
|
||||
// 定义数据接口
|
||||
interface FileRecord {
|
||||
id: number
|
||||
code: string
|
||||
filename: string
|
||||
size: string
|
||||
downloadUrl: string | null
|
||||
content: string | null
|
||||
date: string
|
||||
}
|
||||
|
||||
interface InputStatus {
|
||||
readonly: boolean
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useFileDataStore } from '@/stores/fileData'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import api from '@/utils/api'
|
||||
import type { ApiResponse } from '@/types'
|
||||
|
||||
interface RetrieveResponse {
|
||||
code: string
|
||||
name: string
|
||||
text: string
|
||||
size: number
|
||||
}
|
||||
import { saveAs } from 'file-saver'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { useAlertStore } from '@/stores/alertStore'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
|
||||
const { t } = useI18n()
|
||||
const alertStore = useAlertStore()
|
||||
const baseUrl = window.location.origin
|
||||
|
||||
const router = useRouter()
|
||||
const isDarkMode = inject('isDarkMode')
|
||||
const fileStore = useFileDataStore()
|
||||
const { receiveData } = storeToRefs(fileStore)
|
||||
const code = ref('')
|
||||
const inputStatus = ref<InputStatus>({
|
||||
readonly: false,
|
||||
loading: false
|
||||
})
|
||||
|
||||
const error = ref('')
|
||||
const selectedRecord = ref<FileRecord | null>(null)
|
||||
const showDrawer = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
// 使用 receiveData 替代原来的 records
|
||||
const records = receiveData
|
||||
const config = JSON.parse(localStorage.getItem('config') || '{}')
|
||||
const codeInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
if (codeInput.value) {
|
||||
codeInput.value.focus()
|
||||
}
|
||||
const query_code = route.query.code
|
||||
if (query_code && typeof query_code === 'string') {
|
||||
code.value = query_code
|
||||
}
|
||||
})
|
||||
watch(code, (newVal) => {
|
||||
if (newVal.length === 5) {
|
||||
handleSubmit()
|
||||
}
|
||||
})
|
||||
|
||||
const getDownloadUrl = (record: FileRecord) => {
|
||||
if (record.downloadUrl) {
|
||||
if (record.downloadUrl.startsWith('http')) {
|
||||
return record.downloadUrl
|
||||
} else {
|
||||
return `${baseUrl}${record.downloadUrl}`
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
// 在其他代码后添加复制功能
|
||||
const copyContent = async () => {
|
||||
if (selectedRecord.value && selectedRecord.value.content) {
|
||||
await copyToClipboard(selectedRecord.value.content, {
|
||||
successMsg: t('fileRecord.contentCopied'),
|
||||
errorMsg: t('fileRecord.copyFailed')
|
||||
})
|
||||
}
|
||||
}
|
||||
const handleSubmit = async () => {
|
||||
if (code.value.length !== 5) {
|
||||
alertStore.showAlert(t('retrieve.messages.invalidCode'), 'error')
|
||||
return
|
||||
}
|
||||
|
||||
inputStatus.value.readonly = true
|
||||
inputStatus.value.loading = true
|
||||
|
||||
try {
|
||||
const response = await api.post('/share/select/', {
|
||||
code: code.value
|
||||
})
|
||||
const res = (response.data || response) as ApiResponse<RetrieveResponse>
|
||||
|
||||
if (res && res.code === 200) {
|
||||
if (res.detail) {
|
||||
const isFile = res.detail.text.startsWith('/share/download') || res.detail.name !== 'Text'
|
||||
const newFileData = {
|
||||
id: Date.now(),
|
||||
code: res.detail.code,
|
||||
filename: res.detail.name,
|
||||
size: formatFileSize(res.detail.size),
|
||||
downloadUrl: isFile ? res.detail.text : null,
|
||||
content: isFile ? null : res.detail.text,
|
||||
date: new Date().toLocaleString()
|
||||
}
|
||||
let flag = true
|
||||
fileStore.receiveData.forEach((file: FileRecord) => {
|
||||
if (file.code === newFileData.code) {
|
||||
flag = false
|
||||
}
|
||||
})
|
||||
if (flag) {
|
||||
fileStore.addReceiveData(newFileData)
|
||||
}
|
||||
if (isFile) {
|
||||
selectedRecord.value = newFileData
|
||||
} else {
|
||||
selectedRecord.value = newFileData
|
||||
showPreview.value = true
|
||||
}
|
||||
alertStore.showAlert(t('retrieve.messages.retrieveSuccess'), 'success')
|
||||
} else {
|
||||
alertStore.showAlert(t('retrieve.messages.invalidCodeError'), 'error')
|
||||
}
|
||||
} else {
|
||||
alertStore.showAlert(t('retrieve.messages.retrieveFailure') + res.detail, 'error')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('Retrieve failed:', err)
|
||||
const error = err as { response?: { data?: { detail?: string } }; message?: string }
|
||||
const errorMessage = error?.response?.data?.detail || error?.message || t('retrieve.messages.unknownError')
|
||||
alertStore.showAlert(t('retrieve.messages.networkError') + errorMessage, 'error')
|
||||
} finally {
|
||||
inputStatus.value.readonly = false
|
||||
inputStatus.value.loading = false
|
||||
code.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 ' + t('fileSize.bytes')
|
||||
const k = 1024
|
||||
const sizes = [t('fileSize.bytes'), t('fileSize.kb'), t('fileSize.mb'), t('fileSize.gb'), t('fileSize.tb')]
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const viewDetails = (record: FileRecord) => {
|
||||
selectedRecord.value = record
|
||||
}
|
||||
|
||||
const deleteRecord = (id: number) => {
|
||||
const index = records.value.findIndex((record: FileRecord) => record.id === id)
|
||||
if (index !== -1) {
|
||||
fileStore.deleteReceiveData(index)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDrawer = () => {
|
||||
showDrawer.value = !showDrawer.value
|
||||
}
|
||||
const router = useRouter()
|
||||
const configStore = useConfigStore()
|
||||
const { config } = storeToRefs(configStore)
|
||||
const {
|
||||
code,
|
||||
inputStatus,
|
||||
error,
|
||||
records,
|
||||
selectedRecord,
|
||||
showDrawer,
|
||||
showPreview,
|
||||
renderedContent,
|
||||
closeContentPreview,
|
||||
closeDetails,
|
||||
copyContent,
|
||||
deleteRecord,
|
||||
downloadRecord,
|
||||
handleSubmit,
|
||||
showContentPreview,
|
||||
toggleDrawer,
|
||||
viewDetails
|
||||
} = useRetrieveFlow()
|
||||
|
||||
const toSend = () => {
|
||||
router.push('/send')
|
||||
}
|
||||
|
||||
const getQRCodeValue = (record: FileRecord) => {
|
||||
if (record.downloadUrl) {
|
||||
return `${baseUrl}${record.downloadUrl}`
|
||||
} else {
|
||||
return `${baseUrl}?code=${record.code}`
|
||||
onMounted(() => {
|
||||
const queryCode = route.query.code
|
||||
if (queryCode && typeof queryCode === 'string') {
|
||||
code.value = queryCode
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const downloadRecord = (record: FileRecord) => {
|
||||
if (record.downloadUrl) {
|
||||
// 如果是文件,直接下载
|
||||
window.open(
|
||||
`${record.downloadUrl.startsWith('http') ? '' : baseUrl}${record.downloadUrl}`,
|
||||
'_blank'
|
||||
)
|
||||
} else if (record.content) {
|
||||
// 如果是文本,转成txt下载
|
||||
const blob = new Blob([record.content], { type: 'text/plain;charset=utf-8' })
|
||||
saveAs(blob, `${record.filename}.txt`)
|
||||
watch(code, (newCode) => {
|
||||
if (newCode.length === 5) {
|
||||
void handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
const showPreview = ref(false)
|
||||
const renderedContent = ref('')
|
||||
|
||||
// 监听selectedRecord变化,异步渲染内容
|
||||
watch(
|
||||
() => selectedRecord.value?.content,
|
||||
async (content) => {
|
||||
if (content) {
|
||||
try {
|
||||
// 使用 marked 解析 Markdown,然后用 DOMPurify 清理 HTML 防止 XSS
|
||||
const rawHtml = await marked(content)
|
||||
renderedContent.value = DOMPurify.sanitize(rawHtml, {
|
||||
// 允许的标签和属性
|
||||
ALLOWED_TAGS: [
|
||||
'p',
|
||||
'br',
|
||||
'strong',
|
||||
'em',
|
||||
'u',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'blockquote',
|
||||
'code',
|
||||
'pre',
|
||||
'a',
|
||||
'img'
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class'],
|
||||
// 禁用危险的协议
|
||||
ALLOWED_URI_REGEXP:
|
||||
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|xxx):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Markdown 渲染失败:', error)
|
||||
renderedContent.value = content // fallback 到原始内容
|
||||
}
|
||||
} else {
|
||||
renderedContent.value = ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const showContentPreview = () => {
|
||||
showPreview.value = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes blob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translate(20px, -50px) scale(1.1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-20px, 20px) scale(0.9);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translate(50px, 50px) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.sm\:w-120 {
|
||||
width: 30rem;
|
||||
/* 480px */
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin 8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.w-97-100 {
|
||||
width: 97%;
|
||||
}
|
||||
|
||||
/* 添加 Markdown 样式 */
|
||||
:deep(.prose) {
|
||||
text-align: left;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:deep(.prose h1),
|
||||
:deep(.prose h2),
|
||||
:deep(.prose h3),
|
||||
:deep(.prose h4),
|
||||
:deep(.prose h5),
|
||||
:deep(.prose h6) {
|
||||
color: rgb(79, 70, 229);
|
||||
/* text-indigo-600 */
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
:deep(.prose p),
|
||||
:deep(.prose div),
|
||||
:deep(.prose span),
|
||||
:deep(.prose code),
|
||||
:deep(.prose pre) {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:deep(.prose pre) {
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
:deep(.prose code) {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:deep(.prose h1),
|
||||
:deep(.prose h2),
|
||||
:deep(.prose h3),
|
||||
:deep(.prose h4),
|
||||
:deep(.prose h5),
|
||||
:deep(.prose h6) {
|
||||
color: rgb(129, 140, 248);
|
||||
/* text-indigo-400 */
|
||||
}
|
||||
}
|
||||
|
||||
/* 添加新的宽度类 */
|
||||
@media (min-width: 640px) {
|
||||
.sm\:w-120 {
|
||||
width: 30rem;
|
||||
/* 480px */
|
||||
}
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(156, 163, 175, 0.3) rgba(243, 244, 246, 0.5);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(243, 244, 246, 0.5);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(156, 163, 175, 0.7);
|
||||
}
|
||||
|
||||
/* 深色模式下的滚动条样式 */
|
||||
:deep([class*='dark']) .custom-scrollbar {
|
||||
scrollbar-color: rgba(75, 85, 99, 0.5) rgba(31, 41, 55, 0.5);
|
||||
}
|
||||
|
||||
:deep([class*='dark']) .custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(31, 41, 55, 0.5);
|
||||
}
|
||||
|
||||
:deep([class*='dark']) .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(75, 85, 99, 0.5);
|
||||
}
|
||||
|
||||
:deep([class*='dark']) .custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(75, 85, 99, 0.7);
|
||||
}
|
||||
|
||||
/* 确保滚动容器有背景色 */
|
||||
.custom-scrollbar {
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
/* 文本换行相关样式 */
|
||||
.break-words {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.overflow-wrap-anywhere {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user