Files
quyun-v2/frontend/superadmin/src/views/TenantsPage.vue

305 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="grid gap-4">
<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>
<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>
<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>
<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" icon="pi pi-check" label="保存" @click="saveExtend" />
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Column from 'primevue/column'
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 = {
id: number
code: string
uuid: string
name: string
status: string
created_at?: string
updated_at?: string
expired_at?: string
userCount?: number
userBalance?: number
}
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)
const name = ref('')
const sortField = ref<string>('id')
const sortOrder = ref<number>(-1) // -1 desc, 1 asc
async function load() {
loading.value = true
error.value = ''
try {
const params: any = {
page: page.value,
limit: limit.value,
}
if (name.value.trim()) params.name = name.value.trim()
if (sortField.value) {
if (sortOrder.value === 1) params.asc = sortField.value
else if (sortOrder.value === -1) params.desc = sortField.value
}
const resp = await api.get('/tenants', { params })
items.value = Array.isArray(resp.data?.items) ? resp.data.items : []
total.value = Number(resp.data?.total || 0)
} catch (e: any) {
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
}
}
function reload() {
page.value = 1
load()
}
function onPage(e: any) {
page.value = Math.floor(e.first / e.rows) + 1
limit.value = e.rows
load()
}
function onSort(e: any) {
sortField.value = e.sortField || 'id'
sortOrder.value = e.sortOrder || -1
load()
}
function statusSeverity(status: string) {
if (status === 'verified') return 'success'
if (status === 'pending_verify') return 'warning'
if (status === 'banned') return 'danger'
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)
const duration = ref<number>(30)
const durationOptions = [
{ label: '7 天', value: 7 },
{ label: '30 天', value: 30 },
{ label: '90 天', value: 90 },
{ label: '180 天', value: 180 },
{ label: '365 天', value: 365 },
]
function openExtend(row: TenantItem) {
selected.value = row
duration.value = 30
extendVisible.value = true
}
async function saveExtend() {
if (!selected.value) return
extendLoading.value = true
try {
await api.patch(`/tenants/${selected.value.id}`, { duration: duration.value })
toast.add({ severity: 'success', summary: '已更新', life: 1500 })
extendVisible.value = false
await load()
} catch (e: any) {
toast.add({ severity: 'error', summary: '更新失败', detail: e?.response?.data?.message || e?.message || 'error', life: 3000 })
} finally {
extendLoading.value = false
}
}
onMounted(load)
</script>