Files
ystp/frontend/src/pages/HomePage.vue

423 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { compressFile, getSubscription, getUsage, sendVerification, type CompressResponse } from '@/services/api'
import { ApiError } from '@/services/http'
import { formatBytes } from '@/utils/format'
type ItemStatus = 'idle' | 'compressing' | 'done' | 'error'
interface UploadItem {
id: string
file: File
previewUrl: string
status: ItemStatus
result?: CompressResponse
error?: string
}
const auth = useAuthStore()
const options = reactive({
compressionRate: 60,
maxWidth: '' as string,
maxHeight: '' as string,
})
const dragActive = ref(false)
const items = ref<UploadItem[]>([])
const busy = computed(() => items.value.some((x) => x.status === 'compressing'))
const alert = ref<{ type: 'info' | 'success' | 'error'; message: string } | null>(null)
const sendingVerification = ref(false)
const quotaLoading = ref(false)
const quotaError = ref<string | null>(null)
const usage = ref<Awaited<ReturnType<typeof getUsage>> | null>(null)
const subscription = ref<Awaited<ReturnType<typeof getSubscription>>['subscription'] | null>(null)
const needVerifyEmail = computed(() => auth.isLoggedIn && auth.user && !auth.user.email_verified)
onMounted(async () => {
if (!auth.token) return
quotaLoading.value = true
quotaError.value = null
try {
const [u, s] = await Promise.all([getUsage(auth.token), getSubscription(auth.token)])
usage.value = u
subscription.value = s.subscription
} catch (err) {
if (err instanceof ApiError) {
quotaError.value = `[${err.code}] ${err.message}`
} else {
quotaError.value = '额度加载失败'
}
} finally {
quotaLoading.value = false
}
})
function addFiles(fileList: FileList | null) {
if (!fileList || fileList.length === 0) return
alert.value = null
for (const file of Array.from(fileList)) {
const id = crypto.randomUUID()
const previewUrl = URL.createObjectURL(file)
items.value.push({ id, file, previewUrl, status: 'idle' })
}
}
function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement
addFiles(input.files)
input.value = ''
}
function handleDrop(event: DragEvent) {
dragActive.value = false
if (busy.value) return
addFiles(event.dataTransfer?.files ?? null)
}
function removeItem(id: string) {
const idx = items.value.findIndex((x) => x.id === id)
if (idx === -1) return
const item = items.value[idx]
if (!item) return
URL.revokeObjectURL(item.previewUrl)
items.value.splice(idx, 1)
}
function clearAll() {
for (const item of items.value) URL.revokeObjectURL(item.previewUrl)
items.value = []
alert.value = null
}
function toInt(v: string): number | undefined {
const n = Number(v)
if (!Number.isFinite(n) || n <= 0) return undefined
return Math.floor(n)
}
async function runOne(item: UploadItem) {
item.status = 'compressing'
item.error = undefined
try {
const result = await compressFile(
item.file,
{
compression_rate: options.compressionRate,
max_width: toInt(options.maxWidth),
max_height: toInt(options.maxHeight),
},
auth.token,
)
item.result = result
item.status = 'done'
} catch (err) {
item.status = 'error'
if (err instanceof ApiError) {
item.error = `[${err.code}] ${err.message}`
return
}
item.error = '压缩失败,请稍后再试'
}
}
async function runAll() {
alert.value = null
for (const item of items.value) {
if (item.status === 'done') continue
await runOne(item)
}
}
async function download(item: UploadItem) {
if (!item.result) return
const url = item.result.download_url
if (!auth.token) {
window.open(url, '_blank', 'noopener,noreferrer')
return
}
const res = await fetch(url, {
headers: { authorization: `Bearer ${auth.token}` },
})
if (!res.ok) {
alert.value = { type: 'error', message: `下载失败HTTP ${res.status}` }
return
}
const blob = await res.blob()
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = objectUrl
const base = item.file.name.replace(/\.[^/.]+$/, '')
const ext = item.result.format_out === 'jpeg' ? 'jpg' : item.result.format_out
a.download = `${base}.${ext}`
a.click()
URL.revokeObjectURL(objectUrl)
}
async function resendVerification() {
if (!auth.token) return
sendingVerification.value = true
alert.value = null
try {
const resp = await sendVerification(auth.token)
alert.value = { type: 'success', message: resp.message }
} catch (err) {
if (err instanceof ApiError) {
alert.value = { type: 'error', message: `[${err.code}] ${err.message}` }
} else {
alert.value = { type: 'error', message: '发送失败,请稍后再试' }
}
} finally {
sendingVerification.value = false
}
}
</script>
<template>
<div class="space-y-6">
<div class="space-y-1">
<h1 class="text-2xl font-semibold tracking-tight text-slate-900">图片压缩</h1>
<p class="text-sm text-slate-600">
默认移除 EXIF 等元数据匿名试用每天 10 UTC+8自然日Cookie + IP 双限制
</p>
</div>
<div
v-if="needVerifyEmail"
class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900"
>
<div class="font-medium">你的邮箱尚未验证</div>
<div class="mt-1 text-amber-800">验证后才能使用登录态压缩与 API 能力</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
class="rounded-md bg-amber-600 px-3 py-1.5 font-medium text-white hover:bg-amber-700 disabled:opacity-50"
:disabled="sendingVerification"
@click="resendVerification"
>
重新发送验证邮件
</button>
<router-link class="text-amber-900 underline" to="/dashboard/settings">前往账号设置</router-link>
</div>
</div>
<div
v-if="alert"
class="rounded-lg border p-4 text-sm"
:class="{
'border-slate-200 bg-white text-slate-700': alert.type === 'info',
'border-emerald-200 bg-emerald-50 text-emerald-900': alert.type === 'success',
'border-rose-200 bg-rose-50 text-rose-900': alert.type === 'error',
}"
>
{{ alert.message }}
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-12">
<div class="lg:col-span-7">
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-slate-900">上传图片</div>
<div class="text-xs text-slate-500">支持 PNG / JPG / JPEG / WebP / AVIF / GIF / BMP / TIFF / ICOGIF 仅静态</div>
</div>
<div class="mt-4 flex flex-col gap-3">
<div
class="relative flex flex-col items-center justify-center rounded-lg border-2 border-dashed px-6 py-8 text-center transition"
:class="[
dragActive ? 'border-indigo-400 bg-indigo-50' : 'border-slate-200 bg-slate-50',
busy ? 'opacity-60' : '',
]"
@dragenter.prevent="dragActive = true"
@dragover.prevent
@dragleave.prevent="dragActive = false"
@drop.prevent="handleDrop"
>
<div class="text-sm font-medium text-slate-700">拖拽图片到这里</div>
<div class="mt-1 text-xs text-slate-500">或点击选择文件支持批量上传</div>
<input
class="absolute inset-0 cursor-pointer opacity-0"
type="file"
accept="image/png,image/jpeg,image/jpg,image/webp,image/avif,image/gif,image/bmp,image/x-ms-bmp,image/tiff,image/x-icon,image/vnd.microsoft.icon"
multiple
:disabled="busy"
@change="handleFileChange"
/>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
:disabled="busy || items.length === 0"
@click="runAll"
>
开始压缩
</button>
<button
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
:disabled="busy || items.length === 0"
@click="clearAll"
>
清空
</button>
</div>
</div>
<div v-if="items.length" class="mt-6 space-y-3">
<div
v-for="item in items"
:key="item.id"
class="flex items-center gap-3 rounded-lg border border-slate-200 p-3"
>
<img :src="item.previewUrl" class="h-12 w-12 rounded object-cover" alt="" />
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-slate-900">{{ item.file.name }}</div>
<div class="text-xs text-slate-500">
原始{{ formatBytes(item.file.size) }}
<template v-if="item.result">
· 压缩后{{ formatBytes(item.result.compressed_size) }} · 节省
{{ item.result.saved_percent.toFixed(2) }}%
</template>
</div>
<div v-if="item.error" class="mt-1 text-xs text-rose-700">{{ item.error }}</div>
</div>
<div class="flex items-center gap-2">
<span
v-if="item.status === 'compressing'"
class="rounded-full bg-slate-100 px-2 py-1 text-xs text-slate-600"
>
处理中
</span>
<span
v-else-if="item.status === 'done'"
class="rounded-full bg-emerald-50 px-2 py-1 text-xs text-emerald-700"
>
完成
</span>
<span
v-else-if="item.status === 'error'"
class="rounded-full bg-rose-50 px-2 py-1 text-xs text-rose-700"
>
失败
</span>
<button
v-if="item.status !== 'compressing' && item.result"
type="button"
class="rounded-md border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-700 hover:bg-slate-50"
@click="download(item)"
>
下载
</button>
<button
v-if="item.status === 'error' && !busy"
type="button"
class="rounded-md border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-700 hover:bg-slate-50"
@click="runOne(item)"
>
重试
</button>
<button
type="button"
class="rounded-md border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-500 hover:bg-slate-50"
:disabled="busy"
@click="removeItem(item.id)"
>
移除
</button>
</div>
</div>
</div>
</div>
</div>
<div class="lg:col-span-5 space-y-6">
<div v-if="auth.isLoggedIn" class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-sm font-medium text-slate-900">我的额度</div>
<div v-if="quotaLoading" class="mt-2 text-sm text-slate-500">加载中</div>
<div v-else-if="quotaError" class="mt-2 text-sm text-rose-600">{{ quotaError }}</div>
<div v-else class="mt-2 space-y-1 text-sm text-slate-700">
<div class="text-2xl font-semibold text-slate-900">
{{ usage?.remaining_units ?? 0 }} / {{ usage?.total_units ?? usage?.included_units ?? 0 }}
</div>
<div>当期已用 {{ usage?.used_units ?? 0 }}</div>
<div v-if="(usage?.bonus_units ?? 0) > 0" class="text-xs text-slate-500">
套餐额度 {{ usage?.included_units ?? 0 }} + 赠送 {{ usage?.bonus_units ?? 0 }}
</div>
<div>套餐{{ subscription?.plan.name ?? 'Free' }}</div>
<router-link to="/dashboard/billing" class="mt-3 inline-flex text-sm text-indigo-600 hover:text-indigo-700">
充值额度
</router-link>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-sm font-medium text-slate-900">压缩参数</div>
<div class="mt-4 grid grid-cols-1 gap-4">
<label class="space-y-1">
<div class="flex items-center justify-between text-xs font-medium text-slate-600">
<span>压缩率</span>
<span class="text-slate-500">{{ options.compressionRate }}%</span>
</div>
<input
v-model.number="options.compressionRate"
type="range"
min="1"
max="100"
step="1"
class="w-full"
/>
<div class="text-xs text-slate-500">数值越小压缩越强目标为压缩后体积占原图比例100% 为不压缩</div>
</label>
<div class="grid grid-cols-2 gap-3">
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">最大宽度px</div>
<input
v-model="options.maxWidth"
inputmode="numeric"
placeholder="例如 2000"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
/>
</label>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">最大高度px</div>
<input
v-model="options.maxHeight"
inputmode="numeric"
placeholder="例如 2000"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
/>
</label>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4 text-xs text-slate-600">
<div class="font-medium text-slate-700">计量说明</div>
<ul class="mt-2 list-disc space-y-1 pl-4">
<li>成功压缩 1 个文件计 1 </li>
<li>超过当期配额将返回 <code>402 QUOTA_EXCEEDED</code>硬配额</li>
<li>下载链接按套餐/匿名试用的保留期自动过期</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</template>