Implement compression quota refunds and admin manual subscription

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

View File

@@ -0,0 +1,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>

View 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>

View 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>

View 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
}