305 lines
11 KiB
Vue
305 lines
11 KiB
Vue
<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>
|