feat: enhance tenant management with status description and improved UI components
This commit is contained in:
@@ -18,8 +18,9 @@ type TenantFilter struct {
|
||||
type TenantItem struct {
|
||||
*models.Tenant
|
||||
|
||||
UserCount int64
|
||||
UserBalance int64
|
||||
UserCount int64 `json:"user_count"`
|
||||
UserBalance int64 `json:"user_balance"`
|
||||
StatusDescription string `json:"status_description"`
|
||||
}
|
||||
|
||||
type TenantExpireUpdateForm struct {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package super
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
@@ -12,9 +15,14 @@ type staticController struct{}
|
||||
// @Tags Super
|
||||
// @Router /super/*
|
||||
func (ctl *staticController) static(ctx fiber.Ctx) error {
|
||||
root := "/home/rogee/Projects/quyun_v2/frontend/superadmin/dist/"
|
||||
param := ctx.Params("*")
|
||||
if param == "" {
|
||||
param = "index.html"
|
||||
file := filepath.Join(root, param)
|
||||
|
||||
// if file not exits use index.html
|
||||
if _, err := os.Stat(file); os.IsNotExist(err) {
|
||||
file = filepath.Join(root, "index.html")
|
||||
}
|
||||
return ctx.SendFile("/home/rogee/Projects/quyun_v2/frontend/superadmin/dist/" + param)
|
||||
|
||||
return ctx.SendFile(file)
|
||||
}
|
||||
|
||||
@@ -102,9 +102,10 @@ func (t *tenant) Pager(ctx context.Context, filter *dto.TenantFilter) (*requests
|
||||
|
||||
items := lo.Map(mm, func(model *models.Tenant, _ int) *dto.TenantItem {
|
||||
return &dto.TenantItem{
|
||||
Tenant: model,
|
||||
UserCount: lo.ValueOr(userCountMapping, model.ID, 0),
|
||||
UserBalance: lo.ValueOr(userBalanceMapping, model.ID, 0),
|
||||
Tenant: model,
|
||||
UserCount: lo.ValueOr(userCountMapping, model.ID, 0),
|
||||
UserBalance: lo.ValueOr(userBalanceMapping, model.ID, 0),
|
||||
StatusDescription: model.Status.Description(),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -21,6 +21,20 @@ type UserStatus string
|
||||
// ENUM( pending_verify, verified, banned )
|
||||
type TenantStatus string
|
||||
|
||||
// Description in chinese
|
||||
func (t TenantStatus) Description() string {
|
||||
switch t {
|
||||
case "pending_verify":
|
||||
return "待审核"
|
||||
case "verified":
|
||||
return "已审核"
|
||||
case "banned":
|
||||
return "已封禁"
|
||||
default:
|
||||
return "未知状态"
|
||||
}
|
||||
}
|
||||
|
||||
// swagger:enum TenantUserRole
|
||||
// ENUM( member, tenant_admin)
|
||||
type TenantUserRole string
|
||||
|
||||
4
frontend/superadmin/dist/index.html
vendored
4
frontend/superadmin/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>QuyUn Super Admin</title>
|
||||
<script type="module" crossorigin src="./assets/index-CafT6NN-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-bKnkKIcU.css">
|
||||
<script type="module" crossorigin src="./assets/index-CHzhCW76.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-B1dbpKz4.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
<template>
|
||||
<Toast />
|
||||
<ConfirmDialog />
|
||||
<div class="min-h-screen bg-slate-50 text-slate-900">
|
||||
<header v-if="authed" class="border-b bg-white">
|
||||
<div class="mx-auto flex max-w-6xl items-center gap-4 px-4 py-3">
|
||||
<header
|
||||
v-if="authed"
|
||||
class="sticky top-0 z-10 border-b bg-white/80 backdrop-blur supports-[backdrop-filter]:bg-white/70"
|
||||
>
|
||||
<div class="mx-auto flex max-w-7xl items-center gap-4 px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-shield text-slate-700"></i>
|
||||
<span class="font-semibold">Super Admin</span>
|
||||
</div>
|
||||
<nav class="flex items-center gap-3 text-sm">
|
||||
<router-link class="hover:text-indigo-600" to="/tenants">租户列表</router-link>
|
||||
<nav class="flex items-center gap-2 text-sm">
|
||||
<router-link
|
||||
class="rounded-lg px-3 py-2 text-slate-600 hover:bg-slate-50 hover:text-indigo-700"
|
||||
active-class="bg-indigo-50 text-indigo-700"
|
||||
to="/tenants"
|
||||
>
|
||||
<i class="pi pi-building mr-2"></i>租户
|
||||
</router-link>
|
||||
</nav>
|
||||
<div class="text-sm text-slate-500">{{ title }}</div>
|
||||
<div class="flex-1"></div>
|
||||
<Button size="small" severity="secondary" label="退出" @click="logout" />
|
||||
<Button size="small" severity="secondary" icon="pi pi-sign-out" label="退出" @click="logout" />
|
||||
</div>
|
||||
</header>
|
||||
<main class="mx-auto max-w-6xl px-4 py-6">
|
||||
<main class="mx-auto max-w-7xl px-4 py-6">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
@@ -22,16 +33,30 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Toast from 'primevue/toast'
|
||||
import ConfirmDialog from 'primevue/confirmdialog'
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { isAuthed, setToken } from './auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const confirm = useConfirm()
|
||||
const authed = computed(() => isAuthed.value)
|
||||
const title = computed(() => String(route.meta?.title || ''))
|
||||
|
||||
function logout() {
|
||||
setToken('')
|
||||
router.push('/login')
|
||||
confirm.require({
|
||||
header: '确认退出',
|
||||
message: '将清除当前登录状态。',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: '退出',
|
||||
rejectLabel: '取消',
|
||||
accept: async () => {
|
||||
setToken('')
|
||||
await router.push('/login')
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'super_admin_token'
|
||||
const USERNAME_KEY = 'super_admin_username'
|
||||
const tokenRef = ref<string>(localStorage.getItem(STORAGE_KEY) || '')
|
||||
const usernameRef = ref<string>(localStorage.getItem(USERNAME_KEY) || '')
|
||||
|
||||
export const isAuthed = computed(() => Boolean(tokenRef.value))
|
||||
|
||||
@@ -16,3 +18,13 @@ export function setToken(token: string) {
|
||||
else localStorage.setItem(STORAGE_KEY, t)
|
||||
}
|
||||
|
||||
export function getRememberedUsername(): string {
|
||||
return usernameRef.value
|
||||
}
|
||||
|
||||
export function setRememberedUsername(username: string) {
|
||||
const u = username.trim()
|
||||
usernameRef.value = u
|
||||
if (!u) localStorage.removeItem(USERNAME_KEY)
|
||||
else localStorage.setItem(USERNAME_KEY, u)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
import Aura from '@primevue/themes/aura'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
|
||||
import 'primeicons/primeicons.css'
|
||||
import './styles.css'
|
||||
@@ -12,4 +14,6 @@ createApp(App)
|
||||
.use(router)
|
||||
.use(PrimeVue, { theme: { preset: Aura } })
|
||||
.use(ToastService)
|
||||
.use(ConfirmationService)
|
||||
.directive('tooltip', Tooltip)
|
||||
.mount('#app')
|
||||
|
||||
@@ -6,9 +6,9 @@ import { isAuthed } from './auth'
|
||||
export const router = createRouter({
|
||||
history: createWebHistory('/super/'),
|
||||
routes: [
|
||||
{ path: '/login', component: LoginPage },
|
||||
{ path: '/login', component: LoginPage, meta: { title: '登录' } },
|
||||
{ path: '/', redirect: '/tenants' },
|
||||
{ path: '/tenants', component: TenantsPage },
|
||||
{ path: '/tenants', component: TenantsPage, meta: { title: '租户' } },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,44 +1,70 @@
|
||||
<template>
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="rounded-xl border bg-white p-6 shadow-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-lock text-slate-700"></i>
|
||||
<h1 class="text-lg font-semibold">超级管理员登录</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-4">
|
||||
<div class="grid gap-2">
|
||||
<label class="text-sm text-slate-600">用户名</label>
|
||||
<InputText v-model="username" autocomplete="username" />
|
||||
<div
|
||||
class="fixed inset-0 grid place-items-center overflow-auto bg-gradient-to-br from-slate-950 via-slate-900 to-indigo-950 px-4 py-12">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="rounded-2xl border border-white/10 bg-white/95 shadow-xl backdrop-blur">
|
||||
<div class="border-b px-6 py-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="grid h-10 w-10 place-items-center rounded-xl bg-indigo-50 text-indigo-700">
|
||||
<i class="pi pi-shield"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold leading-tight">Super Admin</div>
|
||||
<div class="text-xs text-slate-500">租户与运营管理控制台</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<label class="text-sm text-slate-600">密码</label>
|
||||
<Password v-model="password" :feedback="false" toggleMask autocomplete="current-password" />
|
||||
</div>
|
||||
<Button :loading="loading" label="登录" class="w-full" @click="login" />
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-xs text-slate-500">接口:<code>/super/v1/auth/login</code></p>
|
||||
<div class="px-6 py-6">
|
||||
<Message v-if="banner" severity="warn" :closable="false" class="mb-4">{{ banner }}</Message>
|
||||
|
||||
<form class="grid gap-4" @submit.prevent="login">
|
||||
<div class="grid gap-2">
|
||||
<label class="text-sm font-medium text-slate-700">用户名</label>
|
||||
<InputText v-model="username" autocomplete="username" placeholder="请输入用户名" class="w-full" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label class="text-sm font-medium text-slate-700">密码</label>
|
||||
<Password v-model="password" :feedback="false" toggleMask autocomplete="current-password"
|
||||
placeholder="请输入密码" class="w-full" inputClass="w-full" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox v-model="remember" binary inputId="remember" />
|
||||
<label class="text-sm text-slate-600" for="remember">记住用户名</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button :loading="loading" type="submit" label="登录" icon="pi pi-sign-in" class="w-full" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import Password from 'primevue/password'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import Button from 'primevue/button'
|
||||
import { api } from '../api'
|
||||
import { setToken } from '../auth'
|
||||
import { getRememberedUsername, setRememberedUsername, setToken } from '../auth'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const username = ref('')
|
||||
const username = ref(getRememberedUsername())
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const remember = ref(Boolean(username.value))
|
||||
const banner = ref('')
|
||||
|
||||
async function login() {
|
||||
if (!username.value.trim() || !password.value) {
|
||||
@@ -46,17 +72,21 @@ async function login() {
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
banner.value = ''
|
||||
try {
|
||||
const resp = await api.post('/auth/login', { username: username.value.trim(), password: password.value })
|
||||
const token = String(resp.data?.token || '').trim()
|
||||
if (!token) throw new Error('missing token')
|
||||
setToken(token)
|
||||
if (remember.value) setRememberedUsername(username.value.trim())
|
||||
else setRememberedUsername('')
|
||||
await router.push('/tenants')
|
||||
} catch (e: any) {
|
||||
toast.add({ severity: 'error', summary: '登录失败', detail: e?.response?.data?.message || e?.message || 'error', life: 3000 })
|
||||
const msg = e?.response?.data?.message || e?.message || 'error'
|
||||
banner.value = String(msg)
|
||||
toast.add({ severity: 'error', summary: '登录失败', detail: msg, life: 3000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,72 +1,140 @@
|
||||
<template>
|
||||
<div class="grid gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-lg font-semibold">租户列表</div>
|
||||
<div class="flex flex-col gap-3 rounded-2xl border bg-white p-4 shadow-sm md:flex-row md:items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="grid h-10 w-10 place-items-center rounded-xl bg-indigo-50 text-indigo-700">
|
||||
<i class="pi pi-building"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-base font-semibold leading-tight">租户列表</div>
|
||||
<div class="text-xs text-slate-500">查询、排序、续期与快速入口</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1"></div>
|
||||
<span class="p-input-icon-left w-72">
|
||||
<i class="pi pi-search" />
|
||||
<InputText v-model="name" placeholder="按租户名称筛选(name)" class="w-full" @keyup.enter="reload" />
|
||||
</span>
|
||||
<Button severity="secondary" label="查询" @click="reload" />
|
||||
<div class="flex w-full flex-col gap-2 md:w-auto md:flex-row md:items-center">
|
||||
<span class="w-full md:w-80">
|
||||
<InputText v-model="name" placeholder="按租户名称筛选(name)" class="w-full" @keyup.enter="reload" />
|
||||
</span>
|
||||
<Button severity="secondary" icon="pi pi-search" label="查询" @click="reload" />
|
||||
<Button severity="secondary" icon="pi pi-refresh" label="刷新" @click="load" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="items"
|
||||
:loading="loading"
|
||||
dataKey="id"
|
||||
lazy
|
||||
paginator
|
||||
:rows="limit"
|
||||
:totalRecords="total"
|
||||
:first="(page - 1) * limit"
|
||||
:rowsPerPageOptions="[10, 20, 50, 100]"
|
||||
:sortField="sortField"
|
||||
:sortOrder="sortOrder"
|
||||
@page="onPage"
|
||||
@sort="onSort"
|
||||
class="rounded-xl border bg-white"
|
||||
>
|
||||
<Column field="id" header="ID" sortable style="width: 90px" />
|
||||
<Column field="code" header="Code" sortable style="width: 160px" />
|
||||
<Column field="name" header="Name" sortable />
|
||||
<Column field="status" header="Status" sortable style="width: 150px">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status" :severity="statusSeverity(data.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="expired_at" header="Expired At" sortable style="width: 200px" />
|
||||
<Column field="userCount" header="Users" sortable style="width: 120px" />
|
||||
<Column field="userBalance" header="Balance" sortable style="width: 140px" />
|
||||
<Column header="Action" style="width: 140px">
|
||||
<template #body="{ data }">
|
||||
<Button size="small" label="续期" @click="openExtend(data)" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
<div v-if="error" class="rounded-2xl border bg-white p-4 shadow-sm">
|
||||
<Message severity="error" :closable="false">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium">加载失败</div>
|
||||
<div class="mt-1 break-all text-xs text-slate-600">{{ error }}</div>
|
||||
</div>
|
||||
<Button icon="pi pi-refresh" label="重试" @click="load" />
|
||||
</div>
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="extendVisible" modal header="更新过期时间" :style="{ width: '420px' }">
|
||||
<div class="grid gap-3">
|
||||
<div class="text-sm text-slate-600">租户:{{ selected?.name }}({{ selected?.code }})</div>
|
||||
<Dropdown v-model="duration" :options="durationOptions" optionLabel="label" optionValue="value" class="w-full" />
|
||||
<div class="rounded-2xl border bg-white shadow-sm">
|
||||
<DataTable :value="items" :loading="loading" dataKey="id" lazy paginator stripedRows rowHover :rows="limit"
|
||||
:totalRecords="total" :first="(page - 1) * limit" :rowsPerPageOptions="[10, 20, 50, 100]" :sortField="sortField"
|
||||
:sortOrder="sortOrder" @page="onPage" @sort="onSort" class="rounded-2xl">
|
||||
<template #empty>
|
||||
<div class="flex flex-col items-center gap-2 py-10 text-slate-500">
|
||||
<i class="pi pi-inbox text-2xl"></i>
|
||||
<div class="text-sm">暂无数据</div>
|
||||
<div class="text-xs">可以尝试调整筛选条件或点击刷新。</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Column field="id" header="ID" sortable style="width: 90px" />
|
||||
<Column field="code" header="Code" sortable style="width: 180px">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-sm">{{ data.code }}</span>
|
||||
<Button text rounded size="small" icon="pi pi-copy" severity="secondary" v-tooltip.top="'复制访问链接'"
|
||||
@click="copyTenantLinks(data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="name" header="Name" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-medium">{{ data.name || '-' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="Status" sortable style="width: 160px">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status_description" :severity="statusSeverity(data.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="expired_at" header="Expired At" sortable style="width: 210px">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="formatDate(data.expired_at)" :severity="expiredSeverity(data.expired_at)"
|
||||
v-tooltip.top="expiredHint(data.expired_at)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="userCount" header="Users" sortable style="width: 120px">
|
||||
<template #body="{ data }">{{ Number(data.userCount || 0).toLocaleString() }}</template>
|
||||
</Column>
|
||||
<Column field="userBalance" header="Balance" sortable style="width: 140px">
|
||||
<template #body="{ data }">{{ formatMoney(data.userBalance) }}</template>
|
||||
</Column>
|
||||
<Column header="Action" style="width: 170px">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button size="small" icon="pi pi-calendar-plus" label="续期" @click="openExtend(data)" />
|
||||
<Button size="small" severity="secondary" icon="pi pi-external-link" label="打开"
|
||||
@click="openTenant(data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:visible="extendVisible" modal :style="{ width: '520px' }" :draggable="false">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-calendar-plus text-slate-700"></i>
|
||||
<span class="font-semibold">更新过期时间</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="rounded-xl bg-slate-50 p-3 text-sm text-slate-700">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-medium">{{ selected?.name || '-' }}</div>
|
||||
<div class="mt-1 truncate font-mono text-xs text-slate-500">{{ selected?.code || '-' }}</div>
|
||||
</div>
|
||||
<Tag v-if="selected" :value="selected.status_description" :severity="statusSeverity(selected.status)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label class="text-sm font-medium text-slate-700">续期时长</label>
|
||||
<Dropdown v-model="duration" :options="durationOptions" optionLabel="label" optionValue="value"
|
||||
class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button severity="secondary" label="取消" @click="extendVisible = false" />
|
||||
<Button :loading="extendLoading" label="保存" @click="saveExtend" />
|
||||
<Button :loading="extendLoading" icon="pi pi-check" label="保存" @click="saveExtend" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Button from 'primevue/button'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Column from 'primevue/column'
|
||||
import Tag from 'primevue/tag'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import Tag from 'primevue/tag'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api } from '../api'
|
||||
|
||||
type TenantItem = {
|
||||
@@ -86,6 +154,7 @@ const toast = useToast()
|
||||
const loading = ref(false)
|
||||
const items = ref<TenantItem[]>([])
|
||||
const total = ref(0)
|
||||
const error = ref('')
|
||||
|
||||
const page = ref(1)
|
||||
const limit = ref(20)
|
||||
@@ -96,6 +165,7 @@ const sortOrder = ref<number>(-1) // -1 desc, 1 asc
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const params: any = {
|
||||
page: page.value,
|
||||
@@ -110,7 +180,9 @@ async function load() {
|
||||
items.value = Array.isArray(resp.data?.items) ? resp.data.items : []
|
||||
total.value = Number(resp.data?.total || 0)
|
||||
} catch (e: any) {
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: e?.response?.data?.message || e?.message || 'error', life: 3000 })
|
||||
const msg = e?.response?.data?.message || e?.message || 'error'
|
||||
error.value = String(msg)
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: msg, life: 3500 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -140,6 +212,61 @@ function statusSeverity(status: string) {
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
function formatDate(s?: string) {
|
||||
if (!s) return '-'
|
||||
const d = new Date(s)
|
||||
if (Number.isNaN(d.getTime())) return String(s)
|
||||
return d.toLocaleString(undefined, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function daysUntil(expiredAt?: string) {
|
||||
if (!expiredAt) return null
|
||||
const d = new Date(expiredAt)
|
||||
if (Number.isNaN(d.getTime())) return null
|
||||
const diffMs = d.getTime() - Date.now()
|
||||
return Math.floor(diffMs / (24 * 60 * 60 * 1000))
|
||||
}
|
||||
|
||||
function expiredSeverity(expiredAt?: string) {
|
||||
const days = daysUntil(expiredAt)
|
||||
if (days === null) return 'secondary'
|
||||
if (days < 10) return 'danger'
|
||||
if (days < 30) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
function expiredHint(expiredAt?: string) {
|
||||
const days = daysUntil(expiredAt)
|
||||
if (days === null) return ''
|
||||
if (days < 0) return `已过期 ${Math.abs(days)} 天`
|
||||
if (days === 0) return '今天到期'
|
||||
return `剩余 ${days} 天`
|
||||
}
|
||||
|
||||
function formatMoney(v?: number) {
|
||||
const n = Number(v || 0)
|
||||
return `¥${(n / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
async function copyTenantLinks(row: TenantItem) {
|
||||
const origin = window.location.origin
|
||||
const userUrl = `${origin}/t/${encodeURIComponent(row.code)}/`
|
||||
const adminUrl = `${origin}/t/${encodeURIComponent(row.code)}/admin/`
|
||||
const text = `User: ${userUrl}\nAdmin: ${adminUrl}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast.add({ severity: 'success', summary: '已复制', detail: row.code, life: 1500 })
|
||||
} catch {
|
||||
toast.add({ severity: 'warn', summary: '复制失败', detail: '浏览器不支持剪贴板或权限不足', life: 2500 })
|
||||
}
|
||||
}
|
||||
|
||||
function openTenant(row: TenantItem) {
|
||||
const origin = window.location.origin
|
||||
const url = `${origin}/t/${encodeURIComponent(row.code)}/admin/`
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const extendVisible = ref(false)
|
||||
const extendLoading = ref(false)
|
||||
const selected = ref<TenantItem | null>(null)
|
||||
|
||||
Reference in New Issue
Block a user