Implement compression quota refunds and admin manual subscription
This commit is contained in:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
frontend/.vscode/extensions.json
vendored
Normal file
3
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
frontend/README.md
Normal file
5
frontend/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ImageForge - 图片压缩</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2756
frontend/package-lock.json
generated
Normal file
2756
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
frontend/package.json
Normal file
29
frontend/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.4",
|
||||
"vue-tsc": "^3.1.4"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
3
frontend/src/App.vue
Normal file
3
frontend/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
69
frontend/src/app/layouts/AdminLayout.vue
Normal file
69
frontend/src/app/layouts/AdminLayout.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="mx-auto max-w-6xl px-4 py-8">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">管理后台</h1>
|
||||
<p class="text-sm text-slate-600">仅管理员可访问系统概览与运营配置。</p>
|
||||
</div>
|
||||
<router-link
|
||||
to="/dashboard"
|
||||
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 class="mt-6 flex flex-col gap-6 lg:flex-row">
|
||||
<nav class="w-full shrink-0 rounded-xl border border-slate-200 bg-white p-4 lg:w-60">
|
||||
<div class="space-y-1 text-sm text-slate-700">
|
||||
<router-link
|
||||
to="/admin"
|
||||
class="block rounded-md px-3 py-2 hover:bg-slate-100"
|
||||
active-class="bg-slate-100 text-slate-900"
|
||||
>
|
||||
概览
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/admin/users"
|
||||
class="block rounded-md px-3 py-2 hover:bg-slate-100"
|
||||
active-class="bg-slate-100 text-slate-900"
|
||||
>
|
||||
用户管理
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/admin/tasks"
|
||||
class="block rounded-md px-3 py-2 hover:bg-slate-100"
|
||||
active-class="bg-slate-100 text-slate-900"
|
||||
>
|
||||
任务管理
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/admin/billing"
|
||||
class="block rounded-md px-3 py-2 hover:bg-slate-100"
|
||||
active-class="bg-slate-100 text-slate-900"
|
||||
>
|
||||
订阅与额度
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/admin/integrations"
|
||||
class="block rounded-md px-3 py-2 hover:bg-slate-100"
|
||||
active-class="bg-slate-100 text-slate-900"
|
||||
>
|
||||
支付与邮件
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/admin/config"
|
||||
class="block rounded-md px-3 py-2 hover:bg-slate-100"
|
||||
active-class="bg-slate-100 text-slate-900"
|
||||
>
|
||||
系统配置
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
91
frontend/src/app/layouts/DashboardLayout.vue
Normal file
91
frontend/src/app/layouts/DashboardLayout.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const username = computed(() => auth.user?.username ?? auth.user?.email ?? '用户')
|
||||
|
||||
function logout() {
|
||||
auth.logout()
|
||||
void router.push({ name: 'home' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-full bg-slate-50">
|
||||
<header class="border-b border-slate-200 bg-white">
|
||||
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
|
||||
<div class="flex items-center gap-4">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="text-lg font-semibold tracking-tight text-slate-900 hover:no-underline"
|
||||
>
|
||||
ImageForge
|
||||
</RouterLink>
|
||||
<span class="text-sm text-slate-500">控制台</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="hidden text-sm text-slate-600 md:inline">你好,{{ username }}</span>
|
||||
<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="logout"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mx-auto grid max-w-6xl grid-cols-1 gap-6 px-4 py-8 md:grid-cols-12">
|
||||
<aside class="md:col-span-3">
|
||||
<nav class="rounded-lg border border-slate-200 bg-white p-2 text-sm">
|
||||
<RouterLink
|
||||
to="/dashboard"
|
||||
class="block rounded-md px-3 py-2 text-slate-700 hover:bg-slate-50"
|
||||
exact-active-class="bg-indigo-50 text-indigo-700"
|
||||
>
|
||||
概览
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard/history"
|
||||
class="block rounded-md px-3 py-2 text-slate-700 hover:bg-slate-50"
|
||||
exact-active-class="bg-indigo-50 text-indigo-700"
|
||||
>
|
||||
历史任务
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard/api-keys"
|
||||
class="block rounded-md px-3 py-2 text-slate-700 hover:bg-slate-50"
|
||||
exact-active-class="bg-indigo-50 text-indigo-700"
|
||||
>
|
||||
API Keys
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard/billing"
|
||||
class="block rounded-md px-3 py-2 text-slate-700 hover:bg-slate-50"
|
||||
exact-active-class="bg-indigo-50 text-indigo-700"
|
||||
>
|
||||
订阅与发票
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/dashboard/settings"
|
||||
class="block rounded-md px-3 py-2 text-slate-700 hover:bg-slate-50"
|
||||
exact-active-class="bg-indigo-50 text-indigo-700"
|
||||
>
|
||||
账号设置
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="md:col-span-9">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
86
frontend/src/app/layouts/PublicLayout.vue
Normal file
86
frontend/src/app/layouts/PublicLayout.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const isLoggedIn = computed(() => auth.isLoggedIn)
|
||||
|
||||
function logout() {
|
||||
auth.logout()
|
||||
void router.push({ name: 'home' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-full">
|
||||
<header class="sticky top-0 z-20 border-b border-slate-200 bg-white/80 backdrop-blur">
|
||||
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
|
||||
<div class="flex items-center gap-6">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="text-lg font-semibold tracking-tight text-slate-900 hover:no-underline"
|
||||
>
|
||||
ImageForge
|
||||
</RouterLink>
|
||||
<nav class="hidden items-center gap-4 text-sm text-slate-600 md:flex">
|
||||
<RouterLink to="/pricing" class="hover:text-slate-900">价格</RouterLink>
|
||||
<RouterLink to="/docs" class="hover:text-slate-900">开发者</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<template v-if="isLoggedIn">
|
||||
<RouterLink
|
||||
to="/dashboard"
|
||||
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
控制台
|
||||
</RouterLink>
|
||||
<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="logout"
|
||||
>
|
||||
退出
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<RouterLink
|
||||
to="/login"
|
||||
class="rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
登录
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/register"
|
||||
class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700"
|
||||
>
|
||||
注册
|
||||
</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-6xl px-4 py-8">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<footer class="border-t border-slate-200 bg-white">
|
||||
<div
|
||||
class="mx-auto flex max-w-6xl flex-col items-start justify-between gap-2 px-4 py-6 text-sm text-slate-500 md:flex-row md:items-center"
|
||||
>
|
||||
<div>© {{ new Date().getFullYear() }} ImageForge</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<RouterLink to="/terms" class="hover:text-slate-700">服务条款</RouterLink>
|
||||
<RouterLink to="/privacy" class="hover:text-slate-700">隐私政策</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
93
frontend/src/app/router.ts
Normal file
93
frontend/src/app/router.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { Pinia } from 'pinia'
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
export function createAppRouter(pinia: Pinia) {
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/app/layouts/PublicLayout.vue'),
|
||||
children: [
|
||||
{ path: '', name: 'home', component: () => import('@/pages/HomePage.vue') },
|
||||
{ path: 'pricing', name: 'pricing', component: () => import('@/pages/PricingPage.vue') },
|
||||
{ path: 'docs', name: 'docs', component: () => import('@/pages/DocsPage.vue') },
|
||||
{ path: 'login', name: 'login', component: () => import('@/pages/LoginPage.vue') },
|
||||
{ path: 'register', name: 'register', component: () => import('@/pages/RegisterPage.vue') },
|
||||
{ path: 'verify-email', name: 'verify-email', component: () => import('@/pages/VerifyEmailPage.vue') },
|
||||
{ path: 'forgot-password', name: 'forgot-password', component: () => import('@/pages/ForgotPasswordPage.vue') },
|
||||
{ path: 'reset-password', name: 'reset-password', component: () => import('@/pages/ResetPasswordPage.vue') },
|
||||
{ path: 'terms', name: 'terms', component: () => import('@/pages/TermsPage.vue') },
|
||||
{ path: 'privacy', name: 'privacy', component: () => import('@/pages/PrivacyPage.vue') },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
component: () => import('@/app/layouts/DashboardLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{ path: '', name: 'dashboard', component: () => import('@/pages/dashboard/DashboardHomePage.vue') },
|
||||
{
|
||||
path: 'history',
|
||||
name: 'dashboard-history',
|
||||
component: () => import('@/pages/dashboard/DashboardHistoryPage.vue'),
|
||||
},
|
||||
{
|
||||
path: 'api-keys',
|
||||
name: 'dashboard-api-keys',
|
||||
component: () => import('@/pages/dashboard/DashboardApiKeysPage.vue'),
|
||||
},
|
||||
{
|
||||
path: 'billing',
|
||||
name: 'dashboard-billing',
|
||||
component: () => import('@/pages/dashboard/DashboardBillingPage.vue'),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'dashboard-settings',
|
||||
component: () => import('@/pages/dashboard/DashboardSettingsPage.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('@/app/layouts/AdminLayout.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
children: [
|
||||
{ path: '', name: 'admin', component: () => import('@/pages/admin/AdminHomePage.vue') },
|
||||
{ path: 'users', name: 'admin-users', component: () => import('@/pages/admin/AdminUsersPage.vue') },
|
||||
{ path: 'tasks', name: 'admin-tasks', component: () => import('@/pages/admin/AdminTasksPage.vue') },
|
||||
{ path: 'billing', name: 'admin-billing', component: () => import('@/pages/admin/AdminBillingPage.vue') },
|
||||
{ path: 'integrations', name: 'admin-integrations', component: () => import('@/pages/admin/AdminIntegrationsPage.vue') },
|
||||
{ path: 'config', name: 'admin-config', component: () => import('@/pages/admin/AdminConfigPage.vue') },
|
||||
],
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('@/pages/NotFoundPage.vue') },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const auth = useAuthStore(pinia)
|
||||
|
||||
if (to.meta?.requiresAuth && !auth.isLoggedIn) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
if ((to.name === 'login' || to.name === 'register') && auth.isLoggedIn) {
|
||||
return { name: 'dashboard' }
|
||||
}
|
||||
|
||||
if (to.meta?.requiresAdmin && auth.user?.role !== 'admin') {
|
||||
return { name: 'dashboard' }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
41
frontend/src/components/HelloWorld.vue
Normal file
41
frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
20
frontend/src/main.ts
Normal file
20
frontend/src/main.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
import { createAppRouter } from './app/router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
|
||||
const auth = useAuthStore(pinia)
|
||||
auth.initFromStorage()
|
||||
|
||||
const router = createAppRouter(pinia)
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
30
frontend/src/pages/DocsPage.vue
Normal file
30
frontend/src/pages/DocsPage.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<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">对外 API + 计费 + 额度(硬配额)一体化。</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-5 text-sm text-slate-700">
|
||||
<div class="font-medium text-slate-900">快速开始</div>
|
||||
<ol class="mt-2 list-decimal space-y-1 pl-5">
|
||||
<li>登录后,在控制台创建 API Key(仅 Pro/Business)。</li>
|
||||
<li>调用 <code>POST /api/v1/compress/direct</code> 获得二进制输出。</li>
|
||||
<li>配额不足返回 <code>402 QUOTA_EXCEEDED</code>,请升级或等待周期重置。</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-5 text-sm text-slate-700">
|
||||
<div class="font-medium text-slate-900">示例(curl)</div>
|
||||
<pre class="mt-3 overflow-auto rounded-lg bg-slate-950 p-4 text-xs text-slate-100"><code>curl -X POST \\
|
||||
-H \"X-API-Key: if_live_xxx\" \\
|
||||
-F \"file=@./demo.png\" \\
|
||||
-F \"compression_rate=70\" \\
|
||||
https://your-domain.com/api/v1/compress/direct -o out.png</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-slate-600">
|
||||
更完整的接口说明请查看仓库内文档:<code>docs/api.md</code>。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
75
frontend/src/pages/ForgotPasswordPage.vue
Normal file
75
frontend/src/pages/ForgotPasswordPage.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { forgotPassword } from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
|
||||
const email = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const success = ref<string | null>(null)
|
||||
|
||||
async function submit() {
|
||||
busy.value = true
|
||||
error.value = null
|
||||
success.value = null
|
||||
try {
|
||||
const resp = await forgotPassword(email.value.trim())
|
||||
success.value = resp.message
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
error.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
error.value = '发送失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h1 class="text-xl font-semibold text-slate-900">找回密码</h1>
|
||||
<p class="mt-1 text-sm text-slate-600">输入邮箱,我们会发送重置链接。</p>
|
||||
|
||||
<div v-if="error" class="mt-4 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div
|
||||
v-if="success"
|
||||
class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900"
|
||||
>
|
||||
{{ success }}
|
||||
</div>
|
||||
|
||||
<form class="mt-5 space-y-4" @submit.prevent="submit">
|
||||
<label class="block space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">邮箱</div>
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="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"
|
||||
>
|
||||
{{ busy ? '发送中…' : '发送重置链接' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-sm text-slate-600">
|
||||
<router-link to="/login" class="text-indigo-600 hover:text-indigo-700">返回登录</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
422
frontend/src/pages/HomePage.vue
Normal file
422
frontend/src/pages/HomePage.vue
Normal file
@@ -0,0 +1,422 @@
|
||||
<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 / ICO(GIF 仅静态)</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">数值越大压缩越强,输出保持原格式。</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>
|
||||
93
frontend/src/pages/LoginPage.vue
Normal file
93
frontend/src/pages/LoginPage.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { login } from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function submit() {
|
||||
error.value = null
|
||||
busy.value = true
|
||||
try {
|
||||
const resp = await login(email.value.trim(), password.value)
|
||||
auth.setAuth(resp.token, resp.user)
|
||||
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/dashboard'
|
||||
await router.push(redirect)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
error.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
error.value = '登录失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h1 class="text-xl font-semibold text-slate-900">登录</h1>
|
||||
<p class="mt-1 text-sm text-slate-600">登录后可查看用量、订阅与 API Keys。</p>
|
||||
|
||||
<div v-if="error" class="mt-4 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<form class="mt-5 space-y-4" @submit.prevent="submit">
|
||||
<label class="block space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">账号</div>
|
||||
<input
|
||||
v-model="email"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs font-medium text-slate-600">密码</div>
|
||||
<router-link to="/forgot-password" class="text-xs text-indigo-600 hover:text-indigo-700">
|
||||
忘记密码?
|
||||
</router-link>
|
||||
</div>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="至少 8 位"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="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"
|
||||
>
|
||||
{{ busy ? '登录中…' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-sm text-slate-600">
|
||||
还没有账号?
|
||||
<router-link to="/register" class="text-indigo-600 hover:text-indigo-700">去注册</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/pages/NotFoundPage.vue
Normal file
17
frontend/src/pages/NotFoundPage.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="mx-auto max-w-lg">
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6 text-center">
|
||||
<div class="text-2xl font-semibold text-slate-900">404</div>
|
||||
<div class="mt-2 text-sm text-slate-600">页面不存在</div>
|
||||
<div class="mt-5">
|
||||
<router-link
|
||||
to="/"
|
||||
class="inline-flex items-center justify-center rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
|
||||
>
|
||||
返回首页
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
73
frontend/src/pages/PricingPage.vue
Normal file
73
frontend/src/pages/PricingPage.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { listPlans, type PlanView } from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
import { formatCents } from '@/utils/format'
|
||||
|
||||
const loading = ref(true)
|
||||
const plans = ref<PlanView[]>([])
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const resp = await listPlans()
|
||||
plans.value = resp.plans
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
error.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
error.value = '加载失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-8">
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-slate-900">价格</h1>
|
||||
<p class="text-sm text-slate-600">硬配额计费:到达当期额度会直接返回 402 QUOTA_EXCEEDED。</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="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div v-for="plan in plans" :key="plan.id" class="rounded-xl border border-slate-200 bg-white p-5">
|
||||
<div class="text-sm font-medium text-slate-900">{{ plan.name }}</div>
|
||||
<div class="mt-2 flex items-baseline gap-2">
|
||||
<div class="text-2xl font-semibold text-slate-900">
|
||||
{{ plan.amount_cents > 0 ? formatCents(plan.amount_cents, plan.currency) : '免费' }}
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">/ {{ plan.interval }}</div>
|
||||
</div>
|
||||
<div class="mt-4 space-y-2 text-sm text-slate-700">
|
||||
<div>当期额度:{{ plan.included_units_per_period.toLocaleString() }} 次</div>
|
||||
<div>单文件:{{ plan.max_file_size_mb }} MB</div>
|
||||
<div>批量:{{ plan.max_files_per_batch }} / 次</div>
|
||||
<div>保留:{{ plan.retention_days }} 天</div>
|
||||
</div>
|
||||
<router-link
|
||||
to="/dashboard/billing"
|
||||
class="mt-5 inline-flex w-full items-center justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700"
|
||||
>
|
||||
立即开始
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-5 text-sm text-slate-700">
|
||||
<div class="font-medium text-slate-900">FAQ</div>
|
||||
<ul class="mt-2 list-disc space-y-1 pl-4">
|
||||
<li>计量单位:成功压缩 1 个文件计 1 次。</li>
|
||||
<li>超额策略:硬配额(超额直接拒绝,不创建任务)。</li>
|
||||
<li>隐私:默认移除 EXIF 等元数据;下载链接到期后自动删除。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
18
frontend/src/pages/PrivacyPage.vue
Normal file
18
frontend/src/pages/PrivacyPage.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div class="prose prose-slate max-w-none">
|
||||
<h1>隐私政策</h1>
|
||||
<p>最后更新:2025-12-18</p>
|
||||
<h2>1. 我们收集什么</h2>
|
||||
<ul>
|
||||
<li>账号信息:邮箱、用户名(用于登录与计费)。</li>
|
||||
<li>调用信息:为防滥用与计费统计,可能记录请求时间、IP、用量等。</li>
|
||||
</ul>
|
||||
<h2>2. 图片与元数据</h2>
|
||||
<p>默认会移除 EXIF 等元数据(可能包含定位/设备信息)。</p>
|
||||
<h2>3. 保留期</h2>
|
||||
<p>压缩结果仅在保留期内可下载,到期后自动删除。</p>
|
||||
<h2>4. Cookie</h2>
|
||||
<p>匿名试用会使用 Cookie(结合 IP)进行每日次数限制。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
100
frontend/src/pages/RegisterPage.vue
Normal file
100
frontend/src/pages/RegisterPage.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { register } from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const email = ref('')
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function submit() {
|
||||
error.value = null
|
||||
busy.value = true
|
||||
try {
|
||||
const resp = await register(email.value.trim(), password.value, username.value.trim())
|
||||
auth.setAuth(resp.token, resp.user)
|
||||
await router.push({ name: 'dashboard', query: { welcome: '1' } })
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
error.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
error.value = '注册失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h1 class="text-xl font-semibold text-slate-900">注册</h1>
|
||||
<p class="mt-1 text-sm text-slate-600">注册后必须验证邮箱才能使用登录态压缩与 API 能力。</p>
|
||||
|
||||
<div v-if="error" class="mt-4 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<form class="mt-5 space-y-4" @submit.prevent="submit">
|
||||
<label class="block space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">邮箱</div>
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">用户名</div>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="例如 imagefan"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">密码</div>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="至少 8 位"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="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"
|
||||
>
|
||||
{{ busy ? '注册中…' : '注册并进入控制台' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-sm text-slate-600">
|
||||
已有账号?
|
||||
<router-link to="/login" class="text-indigo-600 hover:text-indigo-700">去登录</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
83
frontend/src/pages/ResetPasswordPage.vue
Normal file
83
frontend/src/pages/ResetPasswordPage.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { resetPassword } from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
|
||||
const route = useRoute()
|
||||
const token = computed(() => (typeof route.query.token === 'string' ? route.query.token : ''))
|
||||
|
||||
const newPassword = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const success = ref<string | null>(null)
|
||||
|
||||
async function submit() {
|
||||
error.value = null
|
||||
success.value = null
|
||||
if (!token.value) {
|
||||
error.value = '缺少 token'
|
||||
return
|
||||
}
|
||||
|
||||
busy.value = true
|
||||
try {
|
||||
const resp = await resetPassword(token.value, newPassword.value)
|
||||
success.value = resp.message
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
error.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
error.value = '重置失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h1 class="text-xl font-semibold text-slate-900">重置密码</h1>
|
||||
|
||||
<div v-if="error" class="mt-4 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div
|
||||
v-if="success"
|
||||
class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900"
|
||||
>
|
||||
{{ success }}
|
||||
</div>
|
||||
|
||||
<form class="mt-5 space-y-4" @submit.prevent="submit">
|
||||
<label class="block space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">新密码</div>
|
||||
<input
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="至少 8 位"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="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"
|
||||
>
|
||||
{{ busy ? '提交中…' : '重置密码' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-sm text-slate-600">
|
||||
<router-link to="/login" class="text-indigo-600 hover:text-indigo-700">返回登录</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
20
frontend/src/pages/TermsPage.vue
Normal file
20
frontend/src/pages/TermsPage.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="prose prose-slate max-w-none">
|
||||
<h1>服务条款</h1>
|
||||
<p>最后更新:2025-12-18</p>
|
||||
<h2>1. 服务说明</h2>
|
||||
<p>ImageForge 提供图片压缩与相关开发者 API 服务。你需要对上传内容拥有合法权利。</p>
|
||||
<h2>2. 计费与配额</h2>
|
||||
<ul>
|
||||
<li>成功压缩 1 个文件计 1 次。</li>
|
||||
<li>采用硬配额:当期额度不足时,新的压缩请求会被拒绝(HTTP 402 / QUOTA_EXCEEDED)。</li>
|
||||
</ul>
|
||||
<h2>3. 内容与合规</h2>
|
||||
<p>禁止上传违法、侵权或包含敏感个人信息且未经授权的内容。</p>
|
||||
<h2>4. 免责声明</h2>
|
||||
<p>服务按“现状”提供,我们会尽力保证稳定,但不对不可抗力导致的服务中断承担责任。</p>
|
||||
<h2>5. 联系方式</h2>
|
||||
<p>如有问题请联系站点管理员。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
61
frontend/src/pages/VerifyEmailPage.vue
Normal file
61
frontend/src/pages/VerifyEmailPage.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { verifyEmail } from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const token = computed(() => (typeof route.query.token === 'string' ? route.query.token : ''))
|
||||
|
||||
const loading = ref(true)
|
||||
const message = ref<string>('')
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
onMounted(async () => {
|
||||
if (!token.value) {
|
||||
loading.value = false
|
||||
error.value = '缺少 token'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await verifyEmail(token.value)
|
||||
message.value = resp.message
|
||||
auth.markEmailVerified()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
error.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
error.value = '验证失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h1 class="text-xl font-semibold text-slate-900">验证邮箱</h1>
|
||||
|
||||
<div v-if="loading" class="mt-4 text-sm text-slate-600">处理中…</div>
|
||||
<div v-else-if="error" class="mt-4 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-else class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
|
||||
{{ message || '邮箱验证成功' }}
|
||||
</div>
|
||||
|
||||
<div class="mt-5 text-sm text-slate-600">
|
||||
<router-link to="/login" class="text-indigo-600 hover:text-indigo-700">去登录</router-link>
|
||||
或
|
||||
<router-link to="/" class="text-indigo-600 hover:text-indigo-700">返回首页</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
354
frontend/src/pages/admin/AdminBillingPage.vue
Normal file
354
frontend/src/pages/admin/AdminBillingPage.vue
Normal file
@@ -0,0 +1,354 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
createAdminSubscription,
|
||||
grantAdminCredits,
|
||||
listAdminSubscriptions,
|
||||
listAdminPlans,
|
||||
type AdminManualSubscriptionResponse,
|
||||
type AdminPlanView,
|
||||
type AdminSubscriptionView,
|
||||
type AdminCreditResponse,
|
||||
} 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 subscriptions = ref<AdminSubscriptionView[]>([])
|
||||
const plans = ref<AdminPlanView[]>([])
|
||||
const page = ref(1)
|
||||
const limit = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
const subForm = ref({ user_id: '', plan_id: '', months: 1, note: '' })
|
||||
const subBusy = ref(false)
|
||||
const subMessage = ref<string | null>(null)
|
||||
const subError = ref<string | null>(null)
|
||||
const subResult = ref<AdminManualSubscriptionResponse | null>(null)
|
||||
|
||||
const creditForm = ref({ user_id: '', units: 100, note: '' })
|
||||
const creditBusy = ref(false)
|
||||
const creditMessage = ref<string | null>(null)
|
||||
const creditError = ref<string | null>(null)
|
||||
const creditResult = ref<AdminCreditResponse | null>(null)
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit.value)))
|
||||
|
||||
async function loadSubscriptions(targetPage = page.value) {
|
||||
if (!auth.token) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await listAdminSubscriptions(auth.token, { page: targetPage, limit: limit.value })
|
||||
subscriptions.value = resp.subscriptions
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlans() {
|
||||
if (!auth.token) return
|
||||
try {
|
||||
const resp = await listAdminPlans(auth.token)
|
||||
plans.value = resp.plans
|
||||
if (!subForm.value.plan_id) {
|
||||
const active = resp.plans.find((plan) => plan.is_active)
|
||||
if (active) subForm.value.plan_id = active.id
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore plan load errors; subscription list can still render.
|
||||
}
|
||||
}
|
||||
|
||||
async function createSubscription() {
|
||||
if (!auth.token) return
|
||||
subBusy.value = true
|
||||
subMessage.value = null
|
||||
subError.value = null
|
||||
subResult.value = null
|
||||
try {
|
||||
if (!subForm.value.user_id.trim()) {
|
||||
subError.value = '请填写用户 ID'
|
||||
return
|
||||
}
|
||||
if (!subForm.value.plan_id) {
|
||||
subError.value = '请选择套餐'
|
||||
return
|
||||
}
|
||||
if (!subForm.value.months || subForm.value.months <= 0) {
|
||||
subError.value = '月份必须大于 0'
|
||||
return
|
||||
}
|
||||
const resp = await createAdminSubscription(auth.token, {
|
||||
user_id: subForm.value.user_id.trim(),
|
||||
plan_id: subForm.value.plan_id,
|
||||
months: Number(subForm.value.months),
|
||||
note: subForm.value.note.trim() || undefined,
|
||||
})
|
||||
subResult.value = resp
|
||||
subMessage.value = resp.message
|
||||
await loadSubscriptions(1)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
subError.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
subError.value = '操作失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
subBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function grantCredits() {
|
||||
if (!auth.token) return
|
||||
creditBusy.value = true
|
||||
creditMessage.value = null
|
||||
creditError.value = null
|
||||
creditResult.value = null
|
||||
try {
|
||||
if (!creditForm.value.user_id.trim()) {
|
||||
creditError.value = '请填写用户 ID'
|
||||
return
|
||||
}
|
||||
if (!creditForm.value.units || creditForm.value.units <= 0) {
|
||||
creditError.value = '增加单位必须大于 0'
|
||||
return
|
||||
}
|
||||
const resp = await grantAdminCredits(auth.token, {
|
||||
user_id: creditForm.value.user_id.trim(),
|
||||
units: Number(creditForm.value.units),
|
||||
note: creditForm.value.note.trim() || undefined,
|
||||
})
|
||||
creditResult.value = resp
|
||||
creditMessage.value = resp.message
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
creditError.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
creditError.value = '操作失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
creditBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadSubscriptions(1), loadPlans()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-lg font-semibold text-slate-900">订阅与额度</h2>
|
||||
<p class="text-sm text-slate-600">查看订阅列表,并为用户增加当期额度。</p>
|
||||
</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-4">
|
||||
<label class="space-y-1 md:col-span-2">
|
||||
<div class="text-xs font-medium text-slate-600">用户 ID / 邮箱 / 用户名</div>
|
||||
<input
|
||||
v-model="subForm.user_id"
|
||||
type="text"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="UUID / 邮箱 / 用户名"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">套餐</div>
|
||||
<select
|
||||
v-model="subForm.plan_id"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
>
|
||||
<option value="" disabled>请选择套餐</option>
|
||||
<option v-for="plan in plans" :key="plan.id" :value="plan.id" :disabled="!plan.is_active">
|
||||
{{ plan.name }} · {{ plan.code }}{{ plan.is_active ? '' : '(已停用)' }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">开通月数</div>
|
||||
<input
|
||||
v-model.number="subForm.months"
|
||||
type="number"
|
||||
min="1"
|
||||
max="24"
|
||||
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 md:col-span-2">
|
||||
<div class="text-xs font-medium text-slate-600">备注</div>
|
||||
<input
|
||||
v-model="subForm.note"
|
||||
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 class="mt-3 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="subBusy"
|
||||
@click="createSubscription"
|
||||
>
|
||||
{{ subBusy ? '提交中…' : '立即开通' }}
|
||||
</button>
|
||||
<span class="text-xs text-slate-500">会取消该用户当前有效订阅,并按月数顺延。</span>
|
||||
</div>
|
||||
|
||||
<div v-if="subMessage" class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
|
||||
{{ subMessage }}
|
||||
</div>
|
||||
<div v-if="subError" class="mt-3 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
|
||||
{{ subError }}
|
||||
</div>
|
||||
<div v-if="subResult" class="mt-2 text-xs text-slate-500">
|
||||
周期:{{ new Date(subResult.period_start).toLocaleString() }} →
|
||||
{{ new Date(subResult.period_end).toLocaleString() }},套餐 {{ subResult.plan_name }}
|
||||
</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-4">
|
||||
<label class="space-y-1 md:col-span-2">
|
||||
<div class="text-xs font-medium text-slate-600">用户 ID / 邮箱 / 用户名</div>
|
||||
<input
|
||||
v-model="creditForm.user_id"
|
||||
type="text"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="UUID / 邮箱 / 用户名"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">增加单位</div>
|
||||
<input
|
||||
v-model.number="creditForm.units"
|
||||
type="number"
|
||||
min="1"
|
||||
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">备注</div>
|
||||
<input
|
||||
v-model="creditForm.note"
|
||||
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 class="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
:disabled="creditBusy"
|
||||
@click="grantCredits"
|
||||
>
|
||||
{{ creditBusy ? '提交中…' : '提交增加' }}
|
||||
</button>
|
||||
<span class="text-xs text-slate-500">会增加赠送额度,叠加到当期总额度。</span>
|
||||
</div>
|
||||
|
||||
<div v-if="creditMessage" class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
|
||||
{{ creditMessage }}
|
||||
</div>
|
||||
<div v-if="creditError" class="mt-3 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
|
||||
{{ creditError }}
|
||||
</div>
|
||||
<div v-if="creditResult" class="mt-2 text-xs text-slate-500">
|
||||
当前周期:{{ new Date(creditResult.period_start).toLocaleString() }} →
|
||||
{{ new Date(creditResult.period_end).toLocaleString() }},已用 {{ creditResult.used_units }} /
|
||||
{{ creditResult.total_units }}(含赠送 {{ creditResult.bonus_units }}),剩余 {{ creditResult.remaining_units }}
|
||||
</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="subscriptions.length === 0" class="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600">
|
||||
暂无订阅
|
||||
</div>
|
||||
<div v-else class="overflow-auto rounded-xl border border-slate-200 bg-white">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead class="text-xs text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">用户</th>
|
||||
<th class="px-4 py-3">套餐</th>
|
||||
<th class="px-4 py-3">状态</th>
|
||||
<th class="px-4 py-3">周期</th>
|
||||
<th class="px-4 py-3">金额</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-slate-700">
|
||||
<tr v-for="sub in subscriptions" :key="sub.id" class="border-t border-slate-100">
|
||||
<td class="px-4 py-3">
|
||||
<div class="text-sm font-medium text-slate-900">{{ sub.user_email }}</div>
|
||||
<div class="text-xs text-slate-400">ID {{ sub.user_id.slice(0, 8) }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{{ sub.plan_name }}
|
||||
<div class="text-xs text-slate-400">{{ sub.plan_code }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">{{ sub.status }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{{ new Date(sub.current_period_start).toLocaleDateString() }} →
|
||||
{{ new Date(sub.current_period_end).toLocaleDateString() }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{{ formatCents(sub.amount_cents, sub.currency) }}
|
||||
<span class="text-xs text-slate-500">/ {{ sub.interval }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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="loadSubscriptions(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="loadSubscriptions(page + 1)"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
134
frontend/src/pages/admin/AdminConfigPage.vue
Normal file
134
frontend/src/pages/admin/AdminConfigPage.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { listAdminConfig, updateAdminConfig, type AdminConfigEntry } 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 configs = ref<AdminConfigEntry[]>([])
|
||||
const drafts = ref<Record<string, string>>({})
|
||||
const savingKey = ref<string | null>(null)
|
||||
const messages = ref<Record<string, string>>({})
|
||||
const errors = ref<Record<string, string>>({})
|
||||
|
||||
function formatJson(value: unknown) {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value ?? '')
|
||||
}
|
||||
}
|
||||
|
||||
function setDrafts(list: AdminConfigEntry[]) {
|
||||
const next: Record<string, string> = {}
|
||||
for (const item of list) {
|
||||
next[item.key] = formatJson(item.value)
|
||||
}
|
||||
drafts.value = next
|
||||
}
|
||||
|
||||
async function loadConfigs() {
|
||||
if (!auth.token) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await listAdminConfig(auth.token)
|
||||
configs.value = resp.configs
|
||||
setDrafts(resp.configs)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
error.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
error.value = '加载失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(key: string) {
|
||||
if (!auth.token) return
|
||||
savingKey.value = key
|
||||
messages.value[key] = ''
|
||||
errors.value[key] = ''
|
||||
try {
|
||||
const raw = drafts.value[key] ?? ''
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
throw new Error('JSON 解析失败,请检查格式')
|
||||
}
|
||||
const resp = await updateAdminConfig(auth.token, { key, value: parsed })
|
||||
configs.value = configs.value.map((item) => (item.key === key ? resp : item))
|
||||
messages.value[key] = '已更新'
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
errors.value[key] = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
errors.value[key] = err instanceof Error ? err.message : '更新失败'
|
||||
}
|
||||
} finally {
|
||||
savingKey.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadConfigs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-lg font-semibold text-slate-900">系统配置</h2>
|
||||
<p class="text-sm text-slate-600">更新全局开关、限流与文件限制配置。</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-4">
|
||||
<div v-if="configs.length === 0" class="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600">
|
||||
暂无配置
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="item in configs" :key="item.key" class="rounded-xl border border-slate-200 bg-white p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-slate-900">{{ item.key }}</div>
|
||||
<div v-if="item.description" class="text-xs text-slate-500">{{ item.description }}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
|
||||
:disabled="savingKey === item.key"
|
||||
@click="saveConfig(item.key)"
|
||||
>
|
||||
{{ savingKey === item.key ? '保存中…' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="drafts[item.key]"
|
||||
class="mt-3 h-40 w-full rounded-md border border-slate-200 bg-white px-3 py-2 font-mono text-xs text-slate-800"
|
||||
></textarea>
|
||||
|
||||
<div v-if="messages[item.key]" class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
|
||||
{{ messages[item.key] }}
|
||||
</div>
|
||||
<div v-if="errors[item.key]" class="mt-3 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
|
||||
{{ errors[item.key] }}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-slate-400">最近更新:{{ new Date(item.updated_at).toLocaleString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
70
frontend/src/pages/admin/AdminHomePage.vue
Normal file
70
frontend/src/pages/admin/AdminHomePage.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { getAdminStats, type AdminStats } 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 stats = ref<AdminStats | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.token) return
|
||||
try {
|
||||
stats.value = await getAdminStats(auth.token)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
error.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
error.value = '加载失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-lg font-semibold text-slate-900">系统概览</h2>
|
||||
<p class="text-sm text-slate-600">快速查看用户、任务与订阅的关键指标。</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="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<div class="text-xs font-medium text-slate-500">总用户数</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ stats?.total_users ?? 0 }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">活跃 {{ stats?.active_users ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<div class="text-xs font-medium text-slate-500">有效订阅</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ stats?.active_subscriptions ?? 0 }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">含试用/欠费</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<div class="text-xs font-medium text-slate-500">24h 使用次数</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ stats?.usage_events_24h ?? 0 }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">近 24 小时</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<div class="text-xs font-medium text-slate-500">任务处理中</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ stats?.processing_tasks ?? 0 }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">排队 {{ stats?.pending_tasks ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<div class="text-xs font-medium text-slate-500">任务完成</div>
|
||||
<div class="mt-2 text-2xl font-semibold text-slate-900">{{ stats?.completed_tasks ?? 0 }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">失败 {{ stats?.failed_tasks ?? 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
464
frontend/src/pages/admin/AdminIntegrationsPage.vue
Normal file
464
frontend/src/pages/admin/AdminIntegrationsPage.vue
Normal file
@@ -0,0 +1,464 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
getMailConfig,
|
||||
getStripeConfig,
|
||||
listAdminPlans,
|
||||
sendMailTest,
|
||||
updateAdminPlan,
|
||||
updateMailConfig,
|
||||
updateStripeConfig,
|
||||
type AdminMailConfig,
|
||||
type AdminPlanView,
|
||||
type AdminStripeConfig,
|
||||
type MailCustomSmtp,
|
||||
} 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 stripeConfig = ref<AdminStripeConfig | null>(null)
|
||||
const stripeForm = ref({ secret_key: '', webhook_secret: '' })
|
||||
const stripeBusy = ref(false)
|
||||
const stripeMessage = ref<string | null>(null)
|
||||
|
||||
const plans = ref<AdminPlanView[]>([])
|
||||
const planBusy = ref<string | null>(null)
|
||||
const planMessage = ref<string | null>(null)
|
||||
|
||||
const mailConfig = ref<AdminMailConfig | null>(null)
|
||||
const mailForm = ref({
|
||||
enabled: false,
|
||||
provider: 'qq',
|
||||
from: '',
|
||||
from_name: 'ImageForge',
|
||||
password: '',
|
||||
log_links_when_disabled: false,
|
||||
custom_smtp: {
|
||||
host: '',
|
||||
port: 465,
|
||||
encryption: 'ssl',
|
||||
} as MailCustomSmtp,
|
||||
})
|
||||
const mailBusy = ref(false)
|
||||
const mailMessage = ref<string | null>(null)
|
||||
const mailError = ref<string | null>(null)
|
||||
|
||||
const testEmail = ref('')
|
||||
const testBusy = ref(false)
|
||||
const testMessage = ref<string | null>(null)
|
||||
const testError = ref<string | null>(null)
|
||||
|
||||
const isCustomProvider = computed(() => mailForm.value.provider === 'custom')
|
||||
const intervalLabel = (interval: string) => {
|
||||
switch (interval) {
|
||||
case 'monthly':
|
||||
return '月付'
|
||||
case 'yearly':
|
||||
return '年付'
|
||||
case 'weekly':
|
||||
return '周付'
|
||||
default:
|
||||
return interval
|
||||
}
|
||||
}
|
||||
|
||||
const providerOptions = [
|
||||
{ value: 'qq', label: 'QQ 邮箱' },
|
||||
{ value: '163', label: '163 邮箱' },
|
||||
{ value: 'aliyun_enterprise', label: '阿里企业邮' },
|
||||
{ value: 'tencent_enterprise', label: '腾讯企业邮' },
|
||||
{ value: 'gmail', label: 'Gmail' },
|
||||
{ value: 'outlook', label: 'Outlook' },
|
||||
{ value: 'custom', label: '自定义 SMTP' },
|
||||
]
|
||||
|
||||
function applyMailConfig(config: AdminMailConfig) {
|
||||
mailForm.value.enabled = config.enabled
|
||||
mailForm.value.provider = config.provider || 'qq'
|
||||
mailForm.value.from = config.from || ''
|
||||
mailForm.value.from_name = config.from_name || 'ImageForge'
|
||||
mailForm.value.log_links_when_disabled = config.log_links_when_disabled ?? false
|
||||
if (config.custom_smtp) {
|
||||
mailForm.value.custom_smtp = {
|
||||
host: config.custom_smtp.host || '',
|
||||
port: config.custom_smtp.port || 465,
|
||||
encryption: config.custom_smtp.encryption || 'ssl',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
if (!auth.token) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const [stripe, mailCfg, planResp] = await Promise.all([
|
||||
getStripeConfig(auth.token),
|
||||
getMailConfig(auth.token),
|
||||
listAdminPlans(auth.token),
|
||||
])
|
||||
stripeConfig.value = stripe
|
||||
mailConfig.value = mailCfg
|
||||
applyMailConfig(mailCfg)
|
||||
plans.value = planResp.plans
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
error.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
error.value = '加载失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStripe() {
|
||||
if (!auth.token) return
|
||||
stripeBusy.value = true
|
||||
stripeMessage.value = null
|
||||
try {
|
||||
const payload: { secret_key?: string; webhook_secret?: string } = {}
|
||||
if (stripeForm.value.secret_key.trim()) payload.secret_key = stripeForm.value.secret_key.trim()
|
||||
if (stripeForm.value.webhook_secret.trim()) payload.webhook_secret = stripeForm.value.webhook_secret.trim()
|
||||
const resp = await updateStripeConfig(auth.token, payload)
|
||||
stripeConfig.value = resp
|
||||
stripeMessage.value = 'Stripe 配置已更新'
|
||||
stripeForm.value.secret_key = ''
|
||||
stripeForm.value.webhook_secret = ''
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
stripeMessage.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
stripeMessage.value = '更新失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
stripeBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function savePlan(plan: AdminPlanView) {
|
||||
if (!auth.token) return
|
||||
planBusy.value = plan.id
|
||||
planMessage.value = null
|
||||
try {
|
||||
const resp = await updateAdminPlan(auth.token, plan.id, {
|
||||
stripe_price_id: plan.stripe_price_id?.trim() || undefined,
|
||||
stripe_product_id: plan.stripe_product_id?.trim() || undefined,
|
||||
is_active: plan.is_active,
|
||||
})
|
||||
plans.value = plans.value.map((p) => (p.id === resp.id ? resp : p))
|
||||
planMessage.value = `已更新 ${resp.name}`
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
planMessage.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
planMessage.value = '更新失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
planBusy.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMail() {
|
||||
if (!auth.token) return
|
||||
mailBusy.value = true
|
||||
mailMessage.value = null
|
||||
mailError.value = null
|
||||
try {
|
||||
const payload: {
|
||||
enabled: boolean
|
||||
provider: string
|
||||
from: string
|
||||
from_name: string
|
||||
password?: string
|
||||
custom_smtp?: MailCustomSmtp | null
|
||||
log_links_when_disabled?: boolean
|
||||
} = {
|
||||
enabled: mailForm.value.enabled,
|
||||
provider: mailForm.value.provider,
|
||||
from: mailForm.value.from.trim(),
|
||||
from_name: mailForm.value.from_name.trim(),
|
||||
log_links_when_disabled: mailForm.value.log_links_when_disabled,
|
||||
}
|
||||
if (mailForm.value.password.trim()) {
|
||||
payload.password = mailForm.value.password.trim()
|
||||
}
|
||||
payload.custom_smtp = isCustomProvider.value ? { ...mailForm.value.custom_smtp } : null
|
||||
|
||||
const resp = await updateMailConfig(auth.token, payload)
|
||||
mailConfig.value = resp
|
||||
mailMessage.value = '邮件配置已保存'
|
||||
mailForm.value.password = ''
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
mailError.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
mailError.value = '更新失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
mailBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTest() {
|
||||
if (!auth.token) return
|
||||
testBusy.value = true
|
||||
testMessage.value = null
|
||||
testError.value = null
|
||||
try {
|
||||
const resp = await sendMailTest(auth.token, testEmail.value.trim() || undefined)
|
||||
testMessage.value = resp.message
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
testError.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
testError.value = '发送失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
testBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadAll)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-lg font-semibold text-slate-900">支付与邮件配置</h2>
|
||||
<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="rounded-xl border border-slate-200 bg-white p-5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="text-sm font-medium text-slate-900">Stripe 配置</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
{{ stripeConfig?.secret_key_configured ? '已配置' : '未配置' }}
|
||||
<span v-if="stripeConfig?.secret_key_prefix">({{ stripeConfig?.secret_key_prefix }})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<label class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">Secret Key</div>
|
||||
<input
|
||||
v-model="stripeForm.secret_key"
|
||||
type="password"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="sk_live_..."
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">Webhook Secret</div>
|
||||
<input
|
||||
v-model="stripeForm.webhook_secret"
|
||||
type="password"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="whsec_..."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-3 flex 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="stripeBusy"
|
||||
@click="saveStripe"
|
||||
>
|
||||
{{ stripeBusy ? '保存中…' : '保存配置' }}
|
||||
</button>
|
||||
<div v-if="stripeMessage" class="text-xs text-slate-600">{{ stripeMessage }}</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 v-if="plans.length === 0" class="mt-3 text-sm text-slate-600">暂无套餐</div>
|
||||
<div v-else class="mt-4 space-y-3">
|
||||
<div v-for="plan in plans" :key="plan.id" class="rounded-lg border border-slate-200 p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-slate-900">{{ plan.name }}</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
{{ plan.code }} · {{ formatCents(plan.amount_cents, plan.currency) }} / {{ intervalLabel(plan.interval) }}
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-xs text-slate-600">
|
||||
<input v-model="plan.is_active" type="checkbox" class="h-4 w-4 rounded border-slate-300" />
|
||||
启用
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<label class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">Stripe 产品 ID</div>
|
||||
<input
|
||||
v-model="plan.stripe_product_id"
|
||||
type="text"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="例如 prod_xxx"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">Stripe 价格 ID</div>
|
||||
<input
|
||||
v-model="plan.stripe_price_id"
|
||||
type="text"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="例如 price_xxx"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<button
|
||||
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:opacity-50"
|
||||
:disabled="planBusy === plan.id"
|
||||
@click="savePlan(plan)"
|
||||
>
|
||||
{{ planBusy === plan.id ? '保存中…' : '保存' }}
|
||||
</button>
|
||||
<div v-if="planMessage && planBusy !== plan.id" class="text-xs text-slate-500">{{ planMessage }}</div>
|
||||
</div>
|
||||
</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-2">
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input v-model="mailForm.enabled" type="checkbox" class="h-4 w-4 rounded border-slate-300" />
|
||||
启用邮件服务
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input v-model="mailForm.log_links_when_disabled" type="checkbox" class="h-4 w-4 rounded border-slate-300" />
|
||||
关闭时记录链接
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-3 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>
|
||||
<select v-model="mailForm.provider" class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800">
|
||||
<option v-for="opt in providerOptions" :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>
|
||||
<input
|
||||
v-model="mailForm.from"
|
||||
type="email"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="noreply@example.com"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">发件人名称</div>
|
||||
<input
|
||||
v-model="mailForm.from_name"
|
||||
type="text"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="ImageForge"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">授权码/密码</div>
|
||||
<input
|
||||
v-model="mailForm.password"
|
||||
type="password"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
:placeholder="mailConfig?.password_configured ? '已配置(留空不修改)' : '输入授权码'"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="isCustomProvider" 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">SMTP Host</div>
|
||||
<input
|
||||
v-model="mailForm.custom_smtp.host"
|
||||
type="text"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">端口</div>
|
||||
<input
|
||||
v-model.number="mailForm.custom_smtp.port"
|
||||
type="number"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="465"
|
||||
/>
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">加密方式</div>
|
||||
<select
|
||||
v-model="mailForm.custom_smtp.encryption"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
>
|
||||
<option value="ssl">SSL</option>
|
||||
<option value="starttls">STARTTLS</option>
|
||||
<option value="none">无</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="mailMessage" class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
|
||||
{{ mailMessage }}
|
||||
</div>
|
||||
<div v-if="mailError" class="mt-3 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
|
||||
{{ mailError }}
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
:disabled="mailBusy"
|
||||
@click="saveMail"
|
||||
>
|
||||
{{ mailBusy ? '保存中…' : '保存邮件配置' }}
|
||||
</button>
|
||||
<div class="text-xs text-slate-500">保存后可发送测试邮件确认</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="text-sm font-medium text-slate-900">测试发送</div>
|
||||
<div class="mt-2 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="testEmail"
|
||||
type="email"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="留空则发送到当前管理员邮箱"
|
||||
/>
|
||||
</label>
|
||||
<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:opacity-50"
|
||||
:disabled="testBusy"
|
||||
@click="sendTest"
|
||||
>
|
||||
{{ testBusy ? '发送中…' : '发送测试邮件' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="testMessage" class="mt-3 text-sm text-emerald-700">{{ testMessage }}</div>
|
||||
<div v-if="testError" class="mt-3 text-sm text-rose-700">{{ testError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
204
frontend/src/pages/admin/AdminTasksPage.vue
Normal file
204
frontend/src/pages/admin/AdminTasksPage.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { cancelAdminTask, listAdminTasks, type AdminTaskView } 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 tasks = ref<AdminTaskView[]>([])
|
||||
const page = ref(1)
|
||||
const limit = ref(20)
|
||||
const total = ref(0)
|
||||
const status = ref('')
|
||||
const cancelling = ref<string | null>(null)
|
||||
|
||||
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 progress(task: AdminTaskView) {
|
||||
if (!task.total_files) return 0
|
||||
return Math.round(((task.completed_files + task.failed_files) / task.total_files) * 100)
|
||||
}
|
||||
|
||||
async function loadTasks(targetPage = page.value) {
|
||||
if (!auth.token) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await listAdminTasks(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() {
|
||||
loadTasks(1)
|
||||
}
|
||||
|
||||
async function cancelTask(taskId: string) {
|
||||
if (!auth.token) return
|
||||
if (!confirm('确定取消该任务吗?')) return
|
||||
cancelling.value = taskId
|
||||
error.value = null
|
||||
try {
|
||||
await cancelAdminTask(auth.token, taskId)
|
||||
await loadTasks(page.value)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
error.value = `[${err.code}] ${err.message}`
|
||||
} else {
|
||||
error.value = '操作失败,请稍后再试'
|
||||
}
|
||||
} finally {
|
||||
cancelling.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTasks(1)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-lg font-semibold text-slate-900">任务管理</h2>
|
||||
<p class="text-sm text-slate-600">查看任务状态、失败原因并手动取消。</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<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>
|
||||
<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>
|
||||
</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="tasks.length === 0" class="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600">
|
||||
暂无任务
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="task in tasks" :key="task.id" class="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-slate-900">任务 {{ task.id.slice(0, 8) }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">
|
||||
来源 {{ task.source }} · 用户 {{ task.user_email ?? '匿名' }} · 创建
|
||||
{{ new Date(task.created_at).toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex 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.status === 'pending' || task.status === 'processing'"
|
||||
type="button"
|
||||
class="rounded-md border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs text-rose-700 hover:bg-rose-100 disabled:opacity-50"
|
||||
:disabled="cancelling === task.id"
|
||||
@click="cancelTask(task.id)"
|
||||
>
|
||||
{{ cancelling === task.id ? '取消中…' : '取消任务' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-2">
|
||||
<div class="text-xs text-slate-500">
|
||||
进度 {{ progress(task) }}% · 完成 {{ task.completed_files }}/{{ task.total_files }} · 失败
|
||||
{{ task.failed_files }}
|
||||
</div>
|
||||
<div class="h-2 w-full rounded-full bg-slate-100">
|
||||
<div class="h-2 rounded-full bg-indigo-500" :style="{ width: `${progress(task)}%` }"></div>
|
||||
</div>
|
||||
<div v-if="task.error_message" class="text-xs text-rose-600">错误:{{ task.error_message }}</div>
|
||||
</div>
|
||||
</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="loadTasks(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="loadTasks(page + 1)"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
163
frontend/src/pages/admin/AdminUsersPage.vue
Normal file
163
frontend/src/pages/admin/AdminUsersPage.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { listAdminUsers, type AdminUserView } 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 users = ref<AdminUserView[]>([])
|
||||
const page = ref(1)
|
||||
const limit = ref(20)
|
||||
const total = ref(0)
|
||||
const search = ref('')
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit.value)))
|
||||
|
||||
async function loadUsers(targetPage = page.value) {
|
||||
if (!auth.token) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await listAdminUsers(auth.token, {
|
||||
page: targetPage,
|
||||
limit: limit.value,
|
||||
search: search.value.trim() || undefined,
|
||||
})
|
||||
users.value = resp.users
|
||||
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 applySearch() {
|
||||
loadUsers(1)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadUsers(1)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-lg font-semibold text-slate-900">用户管理</h2>
|
||||
<p class="text-sm text-slate-600">支持检索用户、查看订阅状态与验证情况。</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<label class="flex-1 space-y-1">
|
||||
<div class="text-xs font-medium text-slate-600">搜索邮箱/用户名</div>
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
|
||||
placeholder="输入关键词"
|
||||
/>
|
||||
</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="loading"
|
||||
@click="applySearch"
|
||||
>
|
||||
{{ loading ? '查询中…' : '查询' }}
|
||||
</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="space-y-4">
|
||||
<div v-if="users.length === 0" class="rounded-xl border border-slate-200 bg-white p-6 text-sm text-slate-600">
|
||||
暂无用户
|
||||
</div>
|
||||
<div v-else class="overflow-auto rounded-xl border border-slate-200 bg-white">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead class="text-xs text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">邮箱</th>
|
||||
<th class="px-4 py-3">用户名</th>
|
||||
<th class="px-4 py-3">角色</th>
|
||||
<th class="px-4 py-3">状态</th>
|
||||
<th class="px-4 py-3">验证</th>
|
||||
<th class="px-4 py-3">订阅</th>
|
||||
<th class="px-4 py-3">创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-slate-700">
|
||||
<tr v-for="user in users" :key="user.id" class="border-t border-slate-100">
|
||||
<td class="px-4 py-3">
|
||||
<div class="text-sm font-medium text-slate-900">{{ user.email }}</div>
|
||||
<div class="text-xs text-slate-400">ID {{ user.id.slice(0, 8) }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">{{ user.username }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full px-2 py-1 text-xs" :class="user.role === 'admin' ? 'bg-indigo-50 text-indigo-700' : 'bg-slate-100 text-slate-600'">
|
||||
{{ user.role }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="rounded-full px-2 py-1 text-xs"
|
||||
:class="user.is_active ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-700'"
|
||||
>
|
||||
{{ user.is_active ? '启用' : '禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="rounded-full px-2 py-1 text-xs"
|
||||
:class="user.email_verified ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'"
|
||||
>
|
||||
{{ user.email_verified ? '已验证' : '未验证' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">{{ user.subscription_status ?? '—' }}</td>
|
||||
<td class="px-4 py-3">{{ new Date(user.created_at).toLocaleString() }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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="loadUsers(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="loadUsers(page + 1)"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
236
frontend/src/pages/dashboard/DashboardApiKeysPage.vue
Normal file
236
frontend/src/pages/dashboard/DashboardApiKeysPage.vue
Normal 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>
|
||||
202
frontend/src/pages/dashboard/DashboardBillingPage.vue
Normal file
202
frontend/src/pages/dashboard/DashboardBillingPage.vue
Normal 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>
|
||||
344
frontend/src/pages/dashboard/DashboardHistoryPage.vue
Normal file
344
frontend/src/pages/dashboard/DashboardHistoryPage.vue
Normal 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>
|
||||
143
frontend/src/pages/dashboard/DashboardHomePage.vue
Normal file
143
frontend/src/pages/dashboard/DashboardHomePage.vue
Normal 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>
|
||||
275
frontend/src/pages/dashboard/DashboardSettingsPage.vue
Normal file
275
frontend/src/pages/dashboard/DashboardSettingsPage.vue
Normal 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>
|
||||
524
frontend/src/services/api.ts
Normal file
524
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
import type { User } from '@/stores/auth'
|
||||
|
||||
import { apiGet, apiJson, apiMultipart } from './http'
|
||||
|
||||
export type CompressionLevel = 'high' | 'medium' | 'low'
|
||||
export type OutputFormat = 'png' | 'jpeg' | 'webp' | 'avif' | 'gif' | 'bmp' | 'tiff' | 'ico'
|
||||
|
||||
export interface RegisterResponse {
|
||||
user: User
|
||||
token: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
expires_at: string
|
||||
user: User
|
||||
}
|
||||
|
||||
export async function register(email: string, password: string, username: string): Promise<RegisterResponse> {
|
||||
return apiJson<RegisterResponse>('/api/v1/auth/register', { email, password, username }, null)
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string): Promise<LoginResponse> {
|
||||
return apiJson<LoginResponse>('/api/v1/auth/login', { email, password }, null)
|
||||
}
|
||||
|
||||
export async function sendVerification(token: string): Promise<{ message: string }> {
|
||||
return apiJson<{ message: string }>('/api/v1/auth/send-verification', undefined, token, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function verifyEmail(verificationToken: string): Promise<{ message: string }> {
|
||||
return apiJson<{ message: string }>('/api/v1/auth/verify-email', { token: verificationToken }, null)
|
||||
}
|
||||
|
||||
export async function forgotPassword(email: string): Promise<{ message: string }> {
|
||||
return apiJson<{ message: string }>('/api/v1/auth/forgot-password', { email }, null)
|
||||
}
|
||||
|
||||
export async function resetPassword(resetToken: string, newPassword: string): Promise<{ message: string }> {
|
||||
return apiJson<{ message: string }>('/api/v1/auth/reset-password', { token: resetToken, new_password: newPassword }, null)
|
||||
}
|
||||
|
||||
export type UserProfile = User
|
||||
|
||||
export async function getProfile(token: string): Promise<UserProfile> {
|
||||
return apiGet<UserProfile>('/api/v1/user/profile', token)
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
token: string,
|
||||
payload: { email?: string; username?: string },
|
||||
): Promise<{ user: UserProfile; message: string }> {
|
||||
return apiJson<{ user: UserProfile; message: string }>('/api/v1/user/profile', payload, token, { method: 'PUT' })
|
||||
}
|
||||
|
||||
export async function updatePassword(
|
||||
token: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<{ message: string }> {
|
||||
return apiJson<{ message: string }>(
|
||||
'/api/v1/user/password',
|
||||
{ current_password: currentPassword, new_password: newPassword },
|
||||
token,
|
||||
{ method: 'PUT' },
|
||||
)
|
||||
}
|
||||
|
||||
export interface CompressResponse {
|
||||
task_id: string
|
||||
file_id: string
|
||||
format_in: string
|
||||
format_out: string
|
||||
original_size: number
|
||||
compressed_size: number
|
||||
saved_bytes: number
|
||||
saved_percent: number
|
||||
download_url: string
|
||||
expires_at: string
|
||||
billing: { units_charged: number }
|
||||
}
|
||||
|
||||
export interface CompressOptions {
|
||||
level?: CompressionLevel
|
||||
compression_rate?: number
|
||||
output_format?: OutputFormat
|
||||
max_width?: number
|
||||
max_height?: number
|
||||
preserve_metadata?: boolean
|
||||
}
|
||||
|
||||
export async function compressFile(file: File, options: CompressOptions, token?: string | null): Promise<CompressResponse> {
|
||||
const form = new FormData()
|
||||
form.append('file', file, file.name)
|
||||
|
||||
if (options.level) form.append('level', options.level)
|
||||
if (options.compression_rate) form.append('compression_rate', String(options.compression_rate))
|
||||
if (options.output_format) form.append('output_format', options.output_format)
|
||||
if (options.max_width) form.append('max_width', String(options.max_width))
|
||||
if (options.max_height) form.append('max_height', String(options.max_height))
|
||||
if (options.preserve_metadata) form.append('preserve_metadata', 'true')
|
||||
|
||||
return apiMultipart<CompressResponse>('/api/v1/compress', form, token)
|
||||
}
|
||||
|
||||
export interface PlanView {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
currency: string
|
||||
amount_cents: number
|
||||
interval: string
|
||||
included_units_per_period: number
|
||||
max_file_size_mb: number
|
||||
max_files_per_batch: number
|
||||
retention_days: number
|
||||
features: unknown
|
||||
}
|
||||
|
||||
export async function listPlans(): Promise<{ plans: PlanView[] }> {
|
||||
return apiGet<{ plans: PlanView[] }>('/api/v1/billing/plans', null)
|
||||
}
|
||||
|
||||
export interface UsageResponse {
|
||||
period_start: string
|
||||
period_end: string
|
||||
used_units: number
|
||||
included_units: number
|
||||
bonus_units: number
|
||||
total_units: number
|
||||
remaining_units: number
|
||||
}
|
||||
|
||||
export async function getUsage(token: string): Promise<UsageResponse> {
|
||||
return apiGet<UsageResponse>('/api/v1/billing/usage', token)
|
||||
}
|
||||
|
||||
export interface SubscriptionView {
|
||||
status: string
|
||||
current_period_start: string
|
||||
current_period_end: string
|
||||
cancel_at_period_end: boolean
|
||||
plan: PlanView
|
||||
}
|
||||
|
||||
export async function getSubscription(token: string): Promise<{ subscription: SubscriptionView }> {
|
||||
return apiGet<{ subscription: SubscriptionView }>('/api/v1/billing/subscription', token)
|
||||
}
|
||||
|
||||
export interface InvoiceView {
|
||||
invoice_number: string
|
||||
status: string
|
||||
currency: string
|
||||
total_amount_cents: number
|
||||
period_start?: string | null
|
||||
period_end?: string | null
|
||||
hosted_invoice_url?: string | null
|
||||
pdf_url?: string | null
|
||||
paid_at?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export async function listInvoices(
|
||||
token: string,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
): Promise<{ invoices: InvoiceView[]; page: number; limit: number }> {
|
||||
const qs = new URLSearchParams({ page: String(page), limit: String(limit) }).toString()
|
||||
return apiGet<{ invoices: InvoiceView[]; page: number; limit: number }>(`/api/v1/billing/invoices?${qs}`, token)
|
||||
}
|
||||
|
||||
export async function createCheckout(token: string, planId: string): Promise<{ checkout_url: string }> {
|
||||
return apiJson<{ checkout_url: string }>('/api/v1/billing/checkout', { plan_id: planId }, token)
|
||||
}
|
||||
|
||||
export async function createPortal(token: string): Promise<{ url: string }> {
|
||||
return apiJson<{ url: string }>('/api/v1/billing/portal', undefined, token, { method: 'POST' })
|
||||
}
|
||||
|
||||
export interface ApiKeyView {
|
||||
id: string
|
||||
name: string
|
||||
key_prefix: string
|
||||
permissions: unknown
|
||||
rate_limit: number
|
||||
is_active: boolean
|
||||
last_used_at?: string | null
|
||||
last_used_ip?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export async function listApiKeys(token: string): Promise<{ api_keys: ApiKeyView[] }> {
|
||||
return apiGet<{ api_keys: ApiKeyView[] }>('/api/v1/user/api-keys', token)
|
||||
}
|
||||
|
||||
export interface CreateApiKeyResponse {
|
||||
id: string
|
||||
name: string
|
||||
key_prefix: string
|
||||
key: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export async function createApiKey(
|
||||
token: string,
|
||||
name: string,
|
||||
permissions?: string[],
|
||||
): Promise<CreateApiKeyResponse> {
|
||||
return apiJson<CreateApiKeyResponse>('/api/v1/user/api-keys', { name, permissions }, token)
|
||||
}
|
||||
|
||||
export async function disableApiKey(token: string, keyId: string): Promise<{ message: string }> {
|
||||
return apiJson<{ message: string }>(`/api/v1/user/api-keys/${keyId}`, undefined, token, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function rotateApiKey(token: string, keyId: string): Promise<CreateApiKeyResponse> {
|
||||
return apiJson<CreateApiKeyResponse>(`/api/v1/user/api-keys/${keyId}/rotate`, undefined, token)
|
||||
}
|
||||
|
||||
export interface HistoryFileView {
|
||||
file_id: string
|
||||
original_name: string
|
||||
original_size: number
|
||||
compressed_size?: number | null
|
||||
saved_percent?: number | null
|
||||
status: string
|
||||
output_format: string
|
||||
error_message?: string | null
|
||||
download_url?: string | null
|
||||
}
|
||||
|
||||
export interface HistoryTaskView {
|
||||
task_id: string
|
||||
status: string
|
||||
source: string
|
||||
progress: number
|
||||
total_files: number
|
||||
completed_files: number
|
||||
failed_files: number
|
||||
created_at: string
|
||||
completed_at?: string | null
|
||||
expires_at: string
|
||||
download_all_url?: string | null
|
||||
files: HistoryFileView[]
|
||||
}
|
||||
|
||||
export interface HistoryResponse {
|
||||
tasks: HistoryTaskView[]
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export async function listHistory(
|
||||
token: string,
|
||||
params: { page?: number; limit?: number; status?: string } = {},
|
||||
): Promise<HistoryResponse> {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.page) qs.set('page', String(params.page))
|
||||
if (params.limit) qs.set('limit', String(params.limit))
|
||||
if (params.status) qs.set('status', params.status)
|
||||
const suffix = qs.toString()
|
||||
return apiGet<HistoryResponse>(`/api/v1/user/history${suffix ? `?${suffix}` : ''}`, token)
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
total_users: number
|
||||
active_users: number
|
||||
pending_tasks: number
|
||||
processing_tasks: number
|
||||
failed_tasks: number
|
||||
completed_tasks: number
|
||||
usage_events_24h: number
|
||||
active_subscriptions: number
|
||||
}
|
||||
|
||||
export async function getAdminStats(token: string): Promise<AdminStats> {
|
||||
return apiGet<AdminStats>('/api/v1/admin/stats', token)
|
||||
}
|
||||
|
||||
export interface AdminUserView {
|
||||
id: string
|
||||
email: string
|
||||
username: string
|
||||
role: string
|
||||
is_active: boolean
|
||||
email_verified: boolean
|
||||
rate_limit_override?: number | null
|
||||
storage_limit_mb?: number | null
|
||||
created_at: string
|
||||
subscription_status?: string | null
|
||||
}
|
||||
|
||||
export interface AdminUserListResponse {
|
||||
users: AdminUserView[]
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export async function listAdminUsers(
|
||||
token: string,
|
||||
params: { page?: number; limit?: number; search?: string } = {},
|
||||
): Promise<AdminUserListResponse> {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.page) qs.set('page', String(params.page))
|
||||
if (params.limit) qs.set('limit', String(params.limit))
|
||||
if (params.search) qs.set('search', params.search)
|
||||
const suffix = qs.toString()
|
||||
return apiGet<AdminUserListResponse>(`/api/v1/admin/users${suffix ? `?${suffix}` : ''}`, token)
|
||||
}
|
||||
|
||||
export interface AdminTaskView {
|
||||
id: string
|
||||
status: string
|
||||
source: string
|
||||
total_files: number
|
||||
completed_files: number
|
||||
failed_files: number
|
||||
error_message?: string | null
|
||||
created_at: string
|
||||
completed_at?: string | null
|
||||
expires_at: string
|
||||
user_id?: string | null
|
||||
user_email?: string | null
|
||||
}
|
||||
|
||||
export interface AdminTaskListResponse {
|
||||
tasks: AdminTaskView[]
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export async function listAdminTasks(
|
||||
token: string,
|
||||
params: { page?: number; limit?: number; status?: string } = {},
|
||||
): Promise<AdminTaskListResponse> {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.page) qs.set('page', String(params.page))
|
||||
if (params.limit) qs.set('limit', String(params.limit))
|
||||
if (params.status) qs.set('status', params.status)
|
||||
const suffix = qs.toString()
|
||||
return apiGet<AdminTaskListResponse>(`/api/v1/admin/tasks${suffix ? `?${suffix}` : ''}`, token)
|
||||
}
|
||||
|
||||
export async function cancelAdminTask(token: string, taskId: string): Promise<{ message: string }> {
|
||||
return apiJson<{ message: string }>(`/api/v1/admin/tasks/${taskId}/cancel`, undefined, token)
|
||||
}
|
||||
|
||||
export interface AdminSubscriptionView {
|
||||
id: string
|
||||
status: string
|
||||
current_period_start: string
|
||||
current_period_end: string
|
||||
cancel_at_period_end: boolean
|
||||
plan_name: string
|
||||
plan_code: string
|
||||
currency: string
|
||||
amount_cents: number
|
||||
interval: string
|
||||
user_id: string
|
||||
user_email: string
|
||||
}
|
||||
|
||||
export interface AdminSubscriptionListResponse {
|
||||
subscriptions: AdminSubscriptionView[]
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export async function listAdminSubscriptions(
|
||||
token: string,
|
||||
params: { page?: number; limit?: number } = {},
|
||||
): Promise<AdminSubscriptionListResponse> {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.page) qs.set('page', String(params.page))
|
||||
if (params.limit) qs.set('limit', String(params.limit))
|
||||
const suffix = qs.toString()
|
||||
return apiGet<AdminSubscriptionListResponse>(
|
||||
`/api/v1/admin/billing/subscriptions${suffix ? `?${suffix}` : ''}`,
|
||||
token,
|
||||
)
|
||||
}
|
||||
|
||||
export interface AdminManualSubscriptionResponse {
|
||||
message: string
|
||||
subscription_id: string
|
||||
user_id: string
|
||||
plan_id: string
|
||||
plan_name: string
|
||||
period_start: string
|
||||
period_end: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export async function createAdminSubscription(
|
||||
token: string,
|
||||
payload: { user_id: string; plan_id: string; months?: number; note?: string },
|
||||
): Promise<AdminManualSubscriptionResponse> {
|
||||
return apiJson<AdminManualSubscriptionResponse>('/api/v1/admin/billing/subscriptions/manual', payload, token)
|
||||
}
|
||||
|
||||
export interface AdminCreditResponse {
|
||||
message: string
|
||||
period_start: string
|
||||
period_end: string
|
||||
used_units: number
|
||||
bonus_units: number
|
||||
total_units: number
|
||||
remaining_units: number
|
||||
}
|
||||
|
||||
export async function grantAdminCredits(
|
||||
token: string,
|
||||
payload: { user_id: string; units: number; note?: string },
|
||||
): Promise<AdminCreditResponse> {
|
||||
return apiJson<AdminCreditResponse>('/api/v1/admin/billing/credits', payload, token)
|
||||
}
|
||||
|
||||
export interface AdminConfigEntry {
|
||||
key: string
|
||||
value: unknown
|
||||
description?: string | null
|
||||
updated_at: string
|
||||
updated_by?: string | null
|
||||
}
|
||||
|
||||
export async function listAdminConfig(token: string): Promise<{ configs: AdminConfigEntry[] }> {
|
||||
return apiGet<{ configs: AdminConfigEntry[] }>('/api/v1/admin/config', token)
|
||||
}
|
||||
|
||||
export async function updateAdminConfig(
|
||||
token: string,
|
||||
payload: { key: string; value: unknown; description?: string | null },
|
||||
): Promise<AdminConfigEntry> {
|
||||
return apiJson<AdminConfigEntry>('/api/v1/admin/config', payload, token, { method: 'PUT' })
|
||||
}
|
||||
|
||||
export interface AdminPlanView {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
currency: string
|
||||
amount_cents: number
|
||||
interval: string
|
||||
included_units_per_period: number
|
||||
max_file_size_mb: number
|
||||
max_files_per_batch: number
|
||||
retention_days: number
|
||||
stripe_product_id?: string | null
|
||||
stripe_price_id?: string | null
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export async function listAdminPlans(token: string): Promise<{ plans: AdminPlanView[] }> {
|
||||
return apiGet<{ plans: AdminPlanView[] }>('/api/v1/admin/plans', token)
|
||||
}
|
||||
|
||||
export async function updateAdminPlan(
|
||||
token: string,
|
||||
planId: string,
|
||||
payload: { stripe_product_id?: string | null; stripe_price_id?: string | null; is_active?: boolean },
|
||||
): Promise<AdminPlanView> {
|
||||
return apiJson<AdminPlanView>(`/api/v1/admin/plans/${planId}`, payload, token, { method: 'PUT' })
|
||||
}
|
||||
|
||||
export interface AdminStripeConfig {
|
||||
secret_key_configured: boolean
|
||||
webhook_secret_configured: boolean
|
||||
secret_key_prefix?: string | null
|
||||
}
|
||||
|
||||
export async function getStripeConfig(token: string): Promise<AdminStripeConfig> {
|
||||
return apiGet<AdminStripeConfig>('/api/v1/admin/stripe', token)
|
||||
}
|
||||
|
||||
export async function updateStripeConfig(
|
||||
token: string,
|
||||
payload: { secret_key?: string; webhook_secret?: string },
|
||||
): Promise<AdminStripeConfig> {
|
||||
return apiJson<AdminStripeConfig>('/api/v1/admin/stripe', payload, token, { method: 'PUT' })
|
||||
}
|
||||
|
||||
export interface MailCustomSmtp {
|
||||
host: string
|
||||
port: number
|
||||
encryption: string
|
||||
}
|
||||
|
||||
export interface AdminMailConfig {
|
||||
enabled: boolean
|
||||
provider: string
|
||||
from: string
|
||||
from_name: string
|
||||
custom_smtp?: MailCustomSmtp | null
|
||||
password_configured: boolean
|
||||
log_links_when_disabled: boolean
|
||||
}
|
||||
|
||||
export async function getMailConfig(token: string): Promise<AdminMailConfig> {
|
||||
return apiGet<AdminMailConfig>('/api/v1/admin/mail', token)
|
||||
}
|
||||
|
||||
export async function updateMailConfig(
|
||||
token: string,
|
||||
payload: {
|
||||
enabled: boolean
|
||||
provider: string
|
||||
from: string
|
||||
from_name: string
|
||||
password?: string
|
||||
custom_smtp?: MailCustomSmtp | null
|
||||
log_links_when_disabled?: boolean
|
||||
},
|
||||
): Promise<AdminMailConfig> {
|
||||
return apiJson<AdminMailConfig>('/api/v1/admin/mail', payload, token, { method: 'PUT' })
|
||||
}
|
||||
|
||||
export async function sendMailTest(token: string, to?: string): Promise<{ message: string }> {
|
||||
return apiJson<{ message: string }>('/api/v1/admin/mail/test', { to }, token)
|
||||
}
|
||||
129
frontend/src/services/http.ts
Normal file
129
frontend/src/services/http.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
export type ApiSuccess<T> = {
|
||||
success: true
|
||||
data: T
|
||||
}
|
||||
|
||||
export type ApiFailure = {
|
||||
success: false
|
||||
error: {
|
||||
code: string
|
||||
message: string
|
||||
request_id: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ApiEnvelope<T> = ApiSuccess<T> | ApiFailure
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly code: string
|
||||
readonly status: number
|
||||
readonly requestId?: string
|
||||
|
||||
constructor(code: string, status: number, message: string, requestId?: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
this.status = status
|
||||
this.requestId = requestId
|
||||
}
|
||||
}
|
||||
|
||||
async function parseEnvelope<T>(res: Response): Promise<ApiEnvelope<T>> {
|
||||
const text = await res.text()
|
||||
if (!text) {
|
||||
if (res.ok) {
|
||||
return { success: true, data: undefined as T }
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'HTTP_ERROR',
|
||||
message: `HTTP ${res.status}`,
|
||||
request_id: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as ApiEnvelope<T>
|
||||
} catch {
|
||||
if (res.ok) {
|
||||
return { success: true, data: text as T }
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_JSON',
|
||||
message: `无法解析响应(HTTP ${res.status})`,
|
||||
request_id: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeHeaders(a?: HeadersInit, b?: HeadersInit): Headers {
|
||||
const out = new Headers(a ?? {})
|
||||
for (const [k, v] of new Headers(b ?? {})) out.set(k, v)
|
||||
return out
|
||||
}
|
||||
|
||||
export async function apiJson<T>(
|
||||
path: string,
|
||||
body: unknown | undefined,
|
||||
token?: string | null,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const headers = mergeHeaders(init?.headers, { accept: 'application/json' })
|
||||
if (body !== undefined) headers.set('content-type', 'application/json')
|
||||
if (token) headers.set('authorization', `Bearer ${token}`)
|
||||
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
method: init?.method ?? 'POST',
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
|
||||
const envelope = await parseEnvelope<T>(res)
|
||||
if (envelope.success) return envelope.data
|
||||
|
||||
throw new ApiError(envelope.error.code, res.status, envelope.error.message, envelope.error.request_id)
|
||||
}
|
||||
|
||||
export async function apiGet<T>(path: string, token?: string | null, init?: RequestInit): Promise<T> {
|
||||
const headers = mergeHeaders(init?.headers, { accept: 'application/json' })
|
||||
if (token) headers.set('authorization', `Bearer ${token}`)
|
||||
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
method: 'GET',
|
||||
headers,
|
||||
})
|
||||
|
||||
const envelope = await parseEnvelope<T>(res)
|
||||
if (envelope.success) return envelope.data
|
||||
|
||||
throw new ApiError(envelope.error.code, res.status, envelope.error.message, envelope.error.request_id)
|
||||
}
|
||||
|
||||
export async function apiMultipart<T>(
|
||||
path: string,
|
||||
form: FormData,
|
||||
token?: string | null,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const headers = mergeHeaders(init?.headers, { accept: 'application/json' })
|
||||
if (token) headers.set('authorization', `Bearer ${token}`)
|
||||
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: form,
|
||||
})
|
||||
|
||||
const envelope = await parseEnvelope<T>(res)
|
||||
if (envelope.success) return envelope.data
|
||||
|
||||
throw new ApiError(envelope.error.code, res.status, envelope.error.message, envelope.error.request_id)
|
||||
}
|
||||
|
||||
66
frontend/src/stores/auth.ts
Normal file
66
frontend/src/stores/auth.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export type UserRole = 'user' | 'admin'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
username: string
|
||||
role: UserRole
|
||||
email_verified: boolean
|
||||
}
|
||||
|
||||
interface StoredAuth {
|
||||
token: string
|
||||
user: User
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'imageforge_auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
token: null as string | null,
|
||||
user: null as User | null,
|
||||
}),
|
||||
getters: {
|
||||
isLoggedIn: (state) => Boolean(state.token),
|
||||
},
|
||||
actions: {
|
||||
initFromStorage() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw) as StoredAuth
|
||||
if (!parsed?.token || !parsed?.user) return
|
||||
this.token = parsed.token
|
||||
this.user = parsed.user
|
||||
} catch {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
},
|
||||
setAuth(token: string, user: User) {
|
||||
this.token = token
|
||||
this.user = user
|
||||
const stored: StoredAuth = { token, user }
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored))
|
||||
},
|
||||
updateUser(user: User) {
|
||||
this.user = user
|
||||
if (!this.token) return
|
||||
const stored: StoredAuth = { token: this.token, user }
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored))
|
||||
},
|
||||
logout() {
|
||||
this.token = null
|
||||
this.user = null
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
},
|
||||
markEmailVerified() {
|
||||
if (!this.user || this.user.email_verified) return
|
||||
this.user = { ...this.user, email_verified: true }
|
||||
if (!this.token) return
|
||||
const stored: StoredAuth = { token: this.token, user: this.user }
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored))
|
||||
},
|
||||
},
|
||||
})
|
||||
45
frontend/src/style.css
Normal file
45
frontend/src/style.css
Normal file
@@ -0,0 +1,45 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--bg: 248 250 252;
|
||||
--card: 255 255 255;
|
||||
--text: 15 23 42;
|
||||
--muted: 71 85 105;
|
||||
--border: 226 232 240;
|
||||
|
||||
--brand: 99 102 241;
|
||||
--brand-strong: 79 70 229;
|
||||
|
||||
--success: 34 197 94;
|
||||
--warning: 245 158 11;
|
||||
--danger: 239 68 68;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: rgb(var(--bg));
|
||||
color: rgb(var(--text));
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji';
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(var(--brand));
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
color: rgb(var(--brand-strong));
|
||||
text-decoration: underline;
|
||||
}
|
||||
23
frontend/src/utils/format.ts
Normal file
23
frontend/src/utils/format.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024
|
||||
unit += 1
|
||||
}
|
||||
const digits = unit === 0 ? 0 : unit === 1 ? 1 : 2
|
||||
return `${value.toFixed(digits)} ${units[unit]}`
|
||||
}
|
||||
|
||||
export function formatCents(amountCents: number, currency: string): string {
|
||||
const amount = (amountCents ?? 0) / 100
|
||||
const cc = (currency ?? 'CNY').toUpperCase()
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: cc,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
10
frontend/tailwind.config.js
Normal file
10
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import typography from '@tailwindcss/typography'
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [typography],
|
||||
}
|
||||
20
frontend/tsconfig.app.json
Normal file
20
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
29
frontend/vite.config.ts
Normal file
29
frontend/vite.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_PROXY_TARGET ?? 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/downloads': {
|
||||
target: process.env.VITE_PROXY_TARGET ?? 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/health': {
|
||||
target: process.env.VITE_PROXY_TARGET ?? 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user