Implement compression quota refunds and admin manual subscription

This commit is contained in:
2025-12-19 23:28:32 +08:00
commit 11f48fd3dd
106 changed files with 27848 additions and 0 deletions

View File

@@ -0,0 +1,236 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import {
createApiKey,
disableApiKey,
listApiKeys,
rotateApiKey,
type ApiKeyView,
type CreateApiKeyResponse,
} from '@/services/api'
import { ApiError } from '@/services/http'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const loading = ref(true)
const error = ref<string | null>(null)
const apiKeys = ref<ApiKeyView[]>([])
const name = ref('')
const creating = ref(false)
const created = ref<CreateApiKeyResponse | null>(null)
const createdContext = ref<'create' | 'rotate' | null>(null)
const copyStatus = ref<string | null>(null)
const rotating = ref<string | null>(null)
async function refresh() {
if (!auth.token) return
error.value = null
try {
const resp = await listApiKeys(auth.token)
apiKeys.value = resp.api_keys
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '加载失败,请稍后再试'
}
}
}
onMounted(async () => {
await refresh()
loading.value = false
})
async function create() {
if (!auth.token) return
creating.value = true
error.value = null
copyStatus.value = null
try {
const resp = await createApiKey(auth.token, name.value.trim())
created.value = resp
createdContext.value = 'create'
name.value = ''
await refresh()
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '创建失败,请稍后再试'
}
} finally {
creating.value = false
}
}
async function disable(keyId: string) {
if (!auth.token) return
if (!confirm('确定要禁用这个 Key 吗?')) return
try {
await disableApiKey(auth.token, keyId)
await refresh()
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '操作失败,请稍后再试'
}
}
}
async function rotate(keyId: string) {
if (!auth.token) return
if (!confirm('确定要轮换这个 Key 吗?旧 Key 将立即失效。')) return
rotating.value = keyId
error.value = null
copyStatus.value = null
try {
const resp = await rotateApiKey(auth.token, keyId)
created.value = resp
createdContext.value = 'rotate'
await refresh()
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '操作失败,请稍后再试'
}
} finally {
rotating.value = null
}
}
async function copyKey() {
if (!created.value?.key) return
try {
await navigator.clipboard.writeText(created.value.key)
copyStatus.value = '已复制'
} catch {
copyStatus.value = '复制失败'
}
}
function clearCreated() {
created.value = null
createdContext.value = null
copyStatus.value = null
}
</script>
<template>
<div class="space-y-6">
<div class="space-y-1">
<h1 class="text-xl font-semibold text-slate-900">API Keys</h1>
<p class="text-sm text-slate-600"> Pro/Business 可创建创建时只展示一次完整 Key</p>
</div>
<div v-if="error" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
{{ error }}
</div>
<div v-if="created" class="rounded-xl border border-emerald-200 bg-emerald-50 p-5 text-sm text-emerald-950">
<div class="font-medium">{{ createdContext === 'rotate' ? '已轮换' : '已创建' }}{{ created.name }}</div>
<div class="mt-2 text-xs text-emerald-900">请保存此 Key它只会显示一次</div>
<pre class="mt-2 overflow-auto rounded-lg bg-slate-950 p-3 text-xs text-slate-100"><code>{{ created.key }}</code></pre>
<div class="mt-3 flex items-center gap-2">
<button
type="button"
class="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700"
@click="copyKey"
>
一键复制
</button>
<div v-if="copyStatus" class="text-xs text-emerald-900">{{ copyStatus }}</div>
<button
type="button"
class="ml-auto rounded-md border border-emerald-200 bg-white px-3 py-1.5 text-sm text-emerald-800 hover:bg-emerald-100"
@click="clearCreated"
>
我已保存
</button>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-sm font-medium text-slate-900">创建 API Key</div>
<div class="mt-3 flex flex-col gap-2 sm:flex-row sm:items-end">
<label class="flex-1 space-y-1">
<div class="text-xs font-medium text-slate-600">名称</div>
<input
v-model="name"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="例如 CI / prod / local"
/>
</label>
<button
type="button"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
:disabled="creating || !name.trim()"
@click="create"
>
{{ creating ? '创建中…' : '创建' }}
</button>
</div>
</div>
<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">已有 Keys</div>
<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"
@click="refresh"
>
刷新
</button>
</div>
<div v-if="loading" class="mt-3 text-sm text-slate-600">加载中</div>
<div v-else-if="apiKeys.length === 0" class="mt-3 text-sm text-slate-600">暂无</div>
<div v-else class="mt-4 space-y-2">
<div
v-for="k in apiKeys"
:key="k.id"
class="flex flex-col gap-2 rounded-lg border border-slate-200 p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0">
<div class="truncate text-sm font-medium text-slate-900">{{ k.name }}</div>
<div class="text-xs text-slate-500">
{{ k.key_prefix }} · rate: {{ k.rate_limit }}/min
<span v-if="k.last_used_at"> · 上次使用 {{ new Date(k.last_used_at).toLocaleString() }}</span>
</div>
</div>
<div class="flex items-center gap-2">
<span
class="rounded-full px-2 py-1 text-xs"
:class="k.is_active ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-600'"
>
{{ k.is_active ? '启用' : '禁用' }}
</span>
<button
v-if="k.is_active"
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-50"
:disabled="rotating === k.id"
@click="rotate(k.id)"
>
{{ rotating === k.id ? '轮换中…' : '轮换' }}
</button>
<button
v-if="k.is_active"
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-50"
@click="disable(k.id)"
>
禁用
</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,202 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import {
createCheckout,
createPortal,
getSubscription,
getUsage,
listInvoices,
listPlans,
type InvoiceView,
type PlanView,
type SubscriptionView,
type UsageResponse,
} from '@/services/api'
import { ApiError } from '@/services/http'
import { useAuthStore } from '@/stores/auth'
import { formatCents } from '@/utils/format'
const auth = useAuthStore()
const loading = ref(true)
const error = ref<string | null>(null)
const plans = ref<PlanView[]>([])
const subscription = ref<SubscriptionView | null>(null)
const usage = ref<UsageResponse | null>(null)
const invoices = ref<InvoiceView[]>([])
const busy = ref(false)
onMounted(async () => {
if (!auth.token) return
try {
const [p, s, u, inv] = await Promise.all([
listPlans(),
getSubscription(auth.token),
getUsage(auth.token),
listInvoices(auth.token),
])
plans.value = p.plans
subscription.value = s.subscription
usage.value = u
invoices.value = inv.invoices
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '加载失败,请稍后再试'
}
} finally {
loading.value = false
}
})
async function openCheckout(planId: string) {
if (!auth.token) return
busy.value = true
error.value = null
try {
const resp = await createCheckout(auth.token, planId)
window.location.href = resp.checkout_url
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '创建支付链接失败'
}
} finally {
busy.value = false
}
}
async function openPortal() {
if (!auth.token) return
busy.value = true
error.value = null
try {
const resp = await createPortal(auth.token)
window.location.href = resp.url
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '打开 Portal 失败'
}
} finally {
busy.value = false
}
}
</script>
<template>
<div class="space-y-6">
<div class="space-y-1">
<h1 class="text-xl font-semibold text-slate-900">订阅与额度</h1>
<p class="text-sm text-slate-600">充值额度或购买套餐通过 Stripe 完成</p>
</div>
<div v-if="loading" class="text-sm text-slate-600">加载中</div>
<div v-else-if="error" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
{{ error }}
</div>
<div v-else class="space-y-6">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-xs font-medium text-slate-500">当前套餐</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ subscription?.plan.name ?? 'Free' }}</div>
<div class="mt-1 text-sm text-slate-600">状态{{ subscription?.status ?? 'free' }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-xs font-medium text-slate-500">当期用量</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">
{{ usage?.used_units ?? 0 }} / {{ usage?.total_units ?? usage?.included_units ?? 0 }}
</div>
<div class="mt-1 text-sm text-slate-600">剩余 {{ usage?.remaining_units ?? 0 }}</div>
<div v-if="(usage?.bonus_units ?? 0) > 0" class="mt-1 text-xs text-slate-500">
套餐额度 {{ usage?.included_units ?? 0 }} + 赠送 {{ usage?.bonus_units ?? 0 }}
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-xs font-medium text-slate-500">周期</div>
<div class="mt-2 text-sm text-slate-700">
{{ subscription?.current_period_start ? new Date(subscription.current_period_start).toLocaleString() : '—' }}
<span class="mx-1"></span>
{{ subscription?.current_period_end ? new Date(subscription.current_period_end).toLocaleString() : '—' }}
</div>
<div class="mt-3">
<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"
@click="openPortal"
>
打开 Stripe Portal
</button>
</div>
</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-3 grid grid-cols-1 gap-3 md:grid-cols-3">
<div v-for="plan in plans" :key="plan.id" class="rounded-lg border border-slate-200 p-4">
<div class="text-sm font-medium text-slate-900">{{ plan.name }}</div>
<div class="mt-1 text-sm text-slate-700">
{{ plan.amount_cents > 0 ? formatCents(plan.amount_cents, plan.currency) : '免费' }}
<span class="text-xs text-slate-500">/ {{ plan.interval }}</span>
</div>
<div class="mt-2 text-xs text-slate-600">
{{ plan.included_units_per_period.toLocaleString() }} / 周期
</div>
<button
type="button"
class="mt-3 w-full rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
:disabled="busy || plan.amount_cents <= 0"
@click="openCheckout(plan.id)"
>
充值额度
</button>
<div v-if="plan.amount_cents <= 0" class="mt-2 text-xs text-slate-500">Free 无需订阅</div>
</div>
</div>
<div class="mt-3 text-xs text-slate-500">
提示示例数据中的 Stripe Price ID 为占位符接入真实 Price 后即可用管理员赠送额度会直接叠加到当期总额度
</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 v-if="invoices.length === 0" class="mt-3 text-sm text-slate-600">暂无发票</div>
<div v-else class="mt-3 overflow-auto">
<table class="min-w-full text-left text-sm">
<thead class="text-xs text-slate-500">
<tr>
<th class="py-2 pr-4">编号</th>
<th class="py-2 pr-4">状态</th>
<th class="py-2 pr-4">金额</th>
<th class="py-2 pr-4">创建时间</th>
<th class="py-2 pr-4">链接</th>
</tr>
</thead>
<tbody class="text-slate-700">
<tr v-for="inv in invoices" :key="inv.invoice_number" class="border-t border-slate-100">
<td class="py-2 pr-4">{{ inv.invoice_number }}</td>
<td class="py-2 pr-4">{{ inv.status }}</td>
<td class="py-2 pr-4">{{ formatCents(inv.total_amount_cents, inv.currency) }}</td>
<td class="py-2 pr-4">{{ new Date(inv.created_at).toLocaleString() }}</td>
<td class="py-2 pr-4">
<a v-if="inv.hosted_invoice_url" :href="inv.hosted_invoice_url" target="_blank" rel="noreferrer">
查看
</a>
<span v-else class="text-slate-400"></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,344 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { listHistory, type HistoryTaskView } from '@/services/api'
import { ApiError } from '@/services/http'
import { useAuthStore } from '@/stores/auth'
import { formatBytes } from '@/utils/format'
const auth = useAuthStore()
const loading = ref(true)
const error = ref<string | null>(null)
const downloadError = ref<string | null>(null)
const downloadBusy = ref(false)
const tasks = ref<HistoryTaskView[]>([])
const page = ref(1)
const limit = ref(10)
const total = ref(0)
const status = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit.value)))
const statusOptions = [
{ value: '', label: '全部状态' },
{ value: 'pending', label: '排队' },
{ value: 'processing', label: '处理中' },
{ value: 'completed', label: '已完成' },
{ value: 'failed', label: '失败' },
{ value: 'cancelled', label: '已取消' },
]
function statusLabel(value: string) {
return statusOptions.find((item) => item.value === value)?.label ?? value
}
function statusClass(value: string) {
switch (value) {
case 'completed':
return 'bg-emerald-50 text-emerald-700'
case 'processing':
return 'bg-indigo-50 text-indigo-700'
case 'failed':
return 'bg-rose-50 text-rose-700'
case 'cancelled':
return 'bg-slate-100 text-slate-600'
default:
return 'bg-amber-50 text-amber-700'
}
}
function formatDate(value?: string | null) {
if (!value) return '—'
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return parsed.toLocaleString()
}
function formatPercent(value?: number | null) {
if (value === null || value === undefined) return '—'
return `${value.toFixed(1)}%`
}
function sourceLabel(value: string) {
switch (value) {
case 'web':
return '网页'
case 'api':
return 'API'
case 'batch':
return '批量'
default:
return value || '—'
}
}
function extractFilename(disposition: string | null) {
if (!disposition) return null
const match = /filename="([^"]+)"/i.exec(disposition)
return match?.[1] ?? null
}
function outputExt(format: string) {
return format.toLowerCase() === 'jpeg' ? 'jpg' : format.toLowerCase()
}
function buildFileName(originalName: string, outputFormat: string) {
const trimmed = originalName.trim()
const base = trimmed ? trimmed.replace(/\.[^/.]+$/, '') : 'download'
return `${base}.${outputExt(outputFormat)}`
}
async function downloadWithAuth(url: string, fallbackName: string) {
if (!auth.token) {
window.open(url, '_blank', 'noopener,noreferrer')
return
}
downloadBusy.value = true
downloadError.value = null
try {
const res = await fetch(url, {
headers: { authorization: `Bearer ${auth.token}` },
})
if (!res.ok) {
downloadError.value = `下载失败HTTP ${res.status}`
return
}
const blob = await res.blob()
const filename = extractFilename(res.headers.get('content-disposition')) ?? fallbackName
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = objectUrl
a.download = filename
a.click()
URL.revokeObjectURL(objectUrl)
} catch (err) {
downloadError.value = '下载失败,请稍后再试'
} finally {
downloadBusy.value = false
}
}
async function downloadTaskZip(task: HistoryTaskView) {
if (!task.download_all_url) return
await downloadWithAuth(task.download_all_url, `task_${task.task_id}.zip`)
}
async function downloadFile(file: HistoryTaskView['files'][number]) {
if (!file.download_url) return
const fallback = buildFileName(file.original_name, file.output_format)
await downloadWithAuth(file.download_url, fallback)
}
async function loadHistory(targetPage = page.value) {
if (!auth.token) return
loading.value = true
error.value = null
try {
const resp = await listHistory(auth.token, {
page: targetPage,
limit: limit.value,
status: status.value || undefined,
})
tasks.value = resp.tasks
page.value = resp.page
limit.value = resp.limit
total.value = resp.total
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '加载失败,请稍后再试'
}
} finally {
loading.value = false
}
}
function applyFilters() {
loadHistory(1)
}
function resetFilters() {
status.value = ''
limit.value = 10
loadHistory(1)
}
onMounted(async () => {
await loadHistory(1)
})
</script>
<template>
<div class="space-y-6">
<div class="space-y-1">
<h1 class="text-xl font-semibold text-slate-900">历史任务</h1>
<p class="text-sm text-slate-600">查看压缩任务下载结果与过期时间</p>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div class="flex flex-1 flex-wrap items-end gap-3">
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">状态</div>
<select v-model="status" class="w-44 rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800">
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</label>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">每页数量</div>
<select v-model.number="limit" class="w-32 rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800">
<option :value="10">10</option>
<option :value="20">20</option>
<option :value="50">50</option>
</select>
</label>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
:disabled="loading"
@click="applyFilters"
>
{{ loading ? '查询中…' : '查询' }}
</button>
<button
type="button"
class="rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"
:disabled="loading"
@click="resetFilters"
>
重置
</button>
</div>
</div>
</div>
<div v-if="loading" class="text-sm text-slate-600">加载中</div>
<div v-else-if="error" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
{{ error }}
</div>
<div v-else class="space-y-4">
<div v-if="downloadError" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
{{ downloadError }}
</div>
<div v-if="tasks.length === 0" class="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600">
暂无历史任务
</div>
<div v-for="task in tasks" :key="task.task_id" class="rounded-xl border border-slate-200 bg-white p-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-medium text-slate-900">任务 {{ task.task_id.slice(0, 8) }}</div>
<div class="text-xs text-slate-500">
来源 {{ sourceLabel(task.source) }} · 创建 {{ formatDate(task.created_at) }} · 过期 {{ formatDate(task.expires_at) }}
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full px-2 py-1 text-xs" :class="statusClass(task.status)">
{{ statusLabel(task.status) }}
</span>
<button
v-if="task.download_all_url"
type="button"
class="rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
:disabled="downloadBusy"
@click="downloadTaskZip(task)"
>
下载全部 ZIP
</button>
<span v-else class="rounded-md border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs text-slate-400">
ZIP 未就绪
</span>
</div>
</div>
<div class="mt-4 space-y-2">
<div class="flex flex-wrap items-center justify-between text-xs text-slate-500">
<span>
进度 {{ task.progress }}% · 完成 {{ task.completed_files }}/{{ task.total_files }} · 失败
{{ task.failed_files }}
</span>
<span>完成时间 {{ formatDate(task.completed_at) }}</span>
</div>
<div class="h-2 w-full rounded-full bg-slate-100">
<div class="h-2 rounded-full bg-indigo-500" :style="{ width: `${task.progress}%` }"></div>
</div>
</div>
<div v-if="task.files.length > 0" class="mt-4 overflow-auto">
<table class="min-w-full text-left text-xs">
<thead class="text-slate-500">
<tr>
<th class="py-2 pr-4">文件</th>
<th class="py-2 pr-4">状态</th>
<th class="py-2 pr-4">原始大小</th>
<th class="py-2 pr-4">压缩后</th>
<th class="py-2 pr-4">节省</th>
<th class="py-2 pr-4">输出格式</th>
<th class="py-2 pr-4">下载/错误</th>
</tr>
</thead>
<tbody class="text-slate-700">
<tr v-for="file in task.files" :key="file.file_id" class="border-t border-slate-100">
<td class="py-2 pr-4">
<div class="max-w-[240px] truncate text-sm font-medium text-slate-900">{{ file.original_name }}</div>
<div class="text-[11px] text-slate-400">ID {{ file.file_id.slice(0, 8) }}</div>
</td>
<td class="py-2 pr-4">
<span class="rounded-full px-2 py-1 text-[11px]" :class="statusClass(file.status)">
{{ statusLabel(file.status) }}
</span>
</td>
<td class="py-2 pr-4">{{ formatBytes(file.original_size) }}</td>
<td class="py-2 pr-4">
{{ file.compressed_size ? formatBytes(file.compressed_size) : '—' }}
</td>
<td class="py-2 pr-4">{{ formatPercent(file.saved_percent) }}</td>
<td class="py-2 pr-4 uppercase">{{ file.output_format }}</td>
<td class="py-2 pr-4">
<button
v-if="file.download_url"
type="button"
class="text-indigo-600 hover:text-indigo-700 disabled:opacity-50"
:disabled="downloadBusy"
@click="downloadFile(file)"
>
下载
</button>
<span v-else-if="file.error_message" class="text-rose-600">{{ file.error_message }}</span>
<span v-else class="text-slate-400"></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="totalPages > 1" class="flex items-center justify-between text-sm text-slate-600">
<div> {{ page }} / {{ totalPages }} {{ total }} </div>
<div class="flex items-center gap-2">
<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="page <= 1 || loading"
@click="loadHistory(page - 1)"
>
上一页
</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="page >= totalPages || loading"
@click="loadHistory(page + 1)"
>
下一页
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { getSubscription, getUsage, sendVerification } from '@/services/api'
import { ApiError } from '@/services/http'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const route = useRoute()
const usage = ref<Awaited<ReturnType<typeof getUsage>> | null>(null)
const subscription = ref<Awaited<ReturnType<typeof getSubscription>>['subscription'] | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
const alert = ref<{ type: 'success' | 'error'; message: string } | null>(null)
const sendingVerification = ref(false)
onMounted(async () => {
if (!auth.token) return
try {
const [u, s] = await Promise.all([getUsage(auth.token), getSubscription(auth.token)])
usage.value = u
subscription.value = s.subscription
if (route.query.welcome === '1') {
alert.value = { type: 'success', message: '欢迎加入 ImageForge请尽快完成邮箱验证。' }
}
} catch (err) {
if (err instanceof ApiError) {
error.value = `[${err.code}] ${err.message}`
} else {
error.value = '加载失败,请稍后再试'
}
} finally {
loading.value = false
}
})
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="flex items-start justify-between gap-3">
<div class="space-y-1">
<h1 class="text-xl font-semibold text-slate-900">概览</h1>
<p class="text-sm text-slate-600">查看当期用量套餐与订阅状态</p>
</div>
<router-link
to="/"
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50"
>
返回首页工具
</router-link>
</div>
<div
v-if="alert"
class="rounded-lg border p-4 text-sm"
:class="{
'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
v-if="auth.user && !auth.user.email_verified"
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">
<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>
</div>
</div>
<div v-if="loading" class="text-sm text-slate-600">加载中</div>
<div v-else-if="error" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
{{ error }}
</div>
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-xs font-medium text-slate-500">当期用量</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">
{{ usage?.used_units ?? 0 }} / {{ usage?.total_units ?? usage?.included_units ?? 0 }}
</div>
<div class="mt-1 text-sm text-slate-600">剩余 {{ usage?.remaining_units ?? 0 }}</div>
<div v-if="(usage?.bonus_units ?? 0) > 0" class="mt-1 text-xs text-slate-500">
含赠送 {{ usage?.bonus_units ?? 0 }}
</div>
<div class="mt-3">
<router-link to="/dashboard/billing" class="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-xs font-medium text-slate-500">当前套餐</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ subscription?.plan.name ?? 'Free' }}</div>
<div class="mt-1 text-sm text-slate-600">状态{{ subscription?.status ?? 'free' }}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-5">
<div class="text-xs font-medium text-slate-500">周期结束</div>
<div class="mt-2 text-2xl font-semibold text-slate-900">
{{ subscription?.current_period_end ? new Date(subscription.current_period_end).toLocaleDateString() : '—' }}
</div>
<div class="mt-1 text-sm text-slate-600">
<router-link to="/dashboard/billing" class="text-indigo-600 hover:text-indigo-700">管理订阅</router-link>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,275 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { getProfile, sendVerification, updatePassword, updateProfile, type UserProfile } from '@/services/api'
import { ApiError } from '@/services/http'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const loading = ref(true)
const profileForm = ref({ email: '', username: '' })
const profileBusy = ref(false)
const profileMessage = ref<string | null>(null)
const profileError = ref<string | null>(null)
const passwordForm = ref({ current: '', next: '', confirm: '' })
const passwordBusy = ref(false)
const passwordMessage = ref<string | null>(null)
const passwordError = ref<string | null>(null)
const verificationBusy = ref(false)
const verificationMessage = ref<string | null>(null)
const verificationError = ref<string | null>(null)
const canResendVerification = computed(() => Boolean(auth.user && !auth.user.email_verified))
function syncProfile(user: UserProfile) {
profileForm.value = { email: user.email ?? '', username: user.username ?? '' }
}
onMounted(async () => {
if (!auth.token) {
loading.value = false
return
}
try {
const user = await getProfile(auth.token)
auth.updateUser(user)
syncProfile(user)
} catch (err) {
if (err instanceof ApiError) {
profileError.value = `[${err.code}] ${err.message}`
} else {
profileError.value = '加载失败,请稍后再试'
}
} finally {
loading.value = false
}
})
async function resendVerification() {
if (!auth.token) return
verificationBusy.value = true
verificationMessage.value = null
verificationError.value = null
try {
const resp = await sendVerification(auth.token)
verificationMessage.value = resp.message
} catch (err) {
if (err instanceof ApiError) {
verificationError.value = `[${err.code}] ${err.message}`
} else {
verificationError.value = '发送失败,请稍后再试'
}
} finally {
verificationBusy.value = false
}
}
async function saveProfile() {
if (!auth.token || !auth.user) return
profileBusy.value = true
profileMessage.value = null
profileError.value = null
try {
const email = profileForm.value.email.trim().toLowerCase()
const username = profileForm.value.username.trim()
const payload: { email?: string; username?: string } = {}
if (email && email !== auth.user.email) payload.email = email
if (username && username !== auth.user.username) payload.username = username
if (!payload.email && !payload.username) {
profileMessage.value = '暂无更新'
return
}
const resp = await updateProfile(auth.token, payload)
auth.updateUser(resp.user)
syncProfile(resp.user)
profileMessage.value = resp.message
} catch (err) {
if (err instanceof ApiError) {
profileError.value = `[${err.code}] ${err.message}`
} else {
profileError.value = '更新失败,请稍后再试'
}
} finally {
profileBusy.value = false
}
}
async function changePassword() {
if (!auth.token) return
passwordBusy.value = true
passwordMessage.value = null
passwordError.value = null
try {
const currentPassword = passwordForm.value.current.trim()
const nextPassword = passwordForm.value.next.trim()
const confirm = passwordForm.value.confirm.trim()
if (!currentPassword || !nextPassword) {
passwordError.value = '请填写当前密码与新密码'
return
}
if (nextPassword !== confirm) {
passwordError.value = '两次输入的新密码不一致'
return
}
const resp = await updatePassword(auth.token, currentPassword, nextPassword)
passwordMessage.value = resp.message
passwordForm.value = { current: '', next: '', confirm: '' }
} catch (err) {
if (err instanceof ApiError) {
passwordError.value = `[${err.code}] ${err.message}`
} else {
passwordError.value = '更新失败,请稍后再试'
}
} finally {
passwordBusy.value = false
}
}
</script>
<template>
<div class="space-y-6">
<div class="space-y-1">
<h1 class="text-xl font-semibold text-slate-900">账号设置</h1>
<p class="text-sm text-slate-600">更新个人资料邮箱验证与密码</p>
</div>
<div v-if="loading" class="text-sm text-slate-600">加载中</div>
<div v-else class="space-y-6">
<div class="rounded-xl border border-slate-200 bg-white p-6">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-sm font-medium text-slate-900">账号资料</div>
<span
class="rounded-full px-2 py-1 text-xs"
:class="auth.user?.email_verified ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-800'"
>
{{ auth.user?.email_verified ? '邮箱已验证' : '邮箱未验证' }}
</span>
</div>
<div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">邮箱</div>
<input
v-model="profileForm.email"
type="email"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="name@company.com"
/>
</label>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">用户名</div>
<input
v-model="profileForm.username"
type="text"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="你的用户名"
/>
</label>
</div>
<div v-if="profileMessage" class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
{{ profileMessage }}
</div>
<div v-if="profileError" class="mt-4 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
{{ profileError }}
</div>
<div class="mt-4 flex flex-wrap items-center gap-3">
<button
type="button"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
:disabled="profileBusy"
@click="saveProfile"
>
{{ profileBusy ? '保存中…' : '保存资料' }}
</button>
<div v-if="canResendVerification">
<button
type="button"
class="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 hover:bg-amber-100 disabled:opacity-50"
:disabled="verificationBusy"
@click="resendVerification"
>
{{ verificationBusy ? '发送中…' : '重新发送验证邮件' }}
</button>
</div>
</div>
<div
v-if="verificationMessage"
class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900"
>
{{ verificationMessage }}
</div>
<div v-if="verificationError" class="mt-3 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
{{ verificationError }}
</div>
<div class="mt-2 text-xs text-slate-500">开发环境邮件关闭时链接会打印在后端日志中</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white p-6">
<div class="text-sm font-medium text-slate-900">修改密码</div>
<div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-3">
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">当前密码</div>
<input
v-model="passwordForm.current"
type="password"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="当前密码"
/>
</label>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">新密码</div>
<input
v-model="passwordForm.next"
type="password"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="至少 8 位"
/>
</label>
<label class="space-y-1">
<div class="text-xs font-medium text-slate-600">确认新密码</div>
<input
v-model="passwordForm.confirm"
type="password"
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
placeholder="再次输入新密码"
/>
</label>
</div>
<div v-if="passwordMessage" class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
{{ passwordMessage }}
</div>
<div v-if="passwordError" class="mt-4 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
{{ passwordError }}
</div>
<div class="mt-4">
<button
type="button"
class="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-950 disabled:opacity-50"
:disabled="passwordBusy"
@click="changePassword"
>
{{ passwordBusy ? '更新中…' : '更新密码' }}
</button>
</div>
</div>
</div>
</div>
</template>