Files
geo/apps/ops-web/src/views/SiteDomainMappingsView.vue
T
root ff2bb77cdd
Desktop Client Build / Resolve Build Metadata (push) Successful in 32s
Frontend CI / Frontend (push) Successful in 4m4s
Desktop Client Build / Publish Client Artifacts to NAS (push) Has been cancelled
Desktop Client Build / Build Desktop Client (push) Has been cancelled
fix(web): suppress duplicate toasts when auth session expires
Mark 401/refresh-failure errors as handled so per-view catch blocks fall
through silently while a single global "登录已过期" warning is shown.
Centralize ops-web error rendering via showOpsError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 00:12:29 +08:00

445 lines
11 KiB
Vue

<template>
<div class="site-domain-page">
<div class="site-domain-header">
<div>
<h2 class="ops-page-title site-domain-title">站点映射</h2>
<div class="site-domain-summary">
<span>全部 {{ total }}</span>
<span>本页启用 {{ activeRows }}</span>
<span>本页停用 {{ inactiveRows }}</span>
</div>
</div>
<a-button type="primary" @click="openCreate">
<template #icon><PlusOutlined /></template>
新增映射
</a-button>
</div>
<div class="ops-card site-domain-card">
<div class="ops-toolbar site-domain-toolbar">
<a-input
v-model:value="filter.keyword"
class="site-domain-search"
placeholder="搜索域名 / Key / 中文名"
allow-clear
@press-enter="reload"
@change="onKeywordChange"
/>
<a-select
v-model:value="filter.status"
class="site-domain-status"
:options="statusOptions"
placeholder="状态"
allow-clear
@change="onStatusChange"
/>
<a-button @click="reload">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</div>
<a-table
:columns="columns"
:data-source="rows"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="onTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'registrable_domain'">
<div class="domain-cell">
<span class="domain-name">{{ record.registrable_domain }}</span>
<span class="domain-key">{{ record.site_key }}</span>
</div>
</template>
<template v-else-if="column.key === 'site_name'">
<span class="site-name">{{ record.site_name }}</span>
</template>
<template v-else-if="column.key === 'is_active'">
<a-switch
:checked="record.is_active"
:loading="statusLoadingId === record.id"
checked-children="启用"
un-checked-children="停用"
@change="onActiveSwitchChange(record, $event)"
/>
</template>
<template v-else-if="column.key === 'updated_at'">
{{ formatDate(record.updated_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<div class="table-actions-row" style="justify-content: flex-end">
<a-tooltip title="编辑">
<a-button
type="text"
shape="circle"
size="small"
class="action-btn action-edit"
@click="openEdit(record)"
>
<EditOutlined />
</a-button>
</a-tooltip>
<a-tooltip title="删除">
<a-popconfirm title="确认删除该映射?" @confirm="deleteMapping(record)">
<a-button
type="text"
shape="circle"
size="small"
class="action-btn action-delete"
>
<DeleteOutlined />
</a-button>
</a-popconfirm>
</a-tooltip>
</div>
</template>
</template>
</a-table>
</div>
<a-drawer
v-model:open="drawerOpen"
:title="editingRow ? '编辑映射' : '新增映射'"
width="480"
:destroy-on-close="true"
@close="closeDrawer"
>
<a-form layout="vertical" :model="form">
<a-form-item label="匹配域名" required>
<a-input v-model:value="form.registrable_domain" placeholder="zhihu.com" />
</a-form-item>
<a-form-item label="站点 Key">
<a-input v-model:value="form.site_key" placeholder="默认同匹配域名" />
</a-form-item>
<a-form-item label="中文名" required>
<a-input v-model:value="form.site_name" placeholder="知乎" />
</a-form-item>
<a-form-item label="状态">
<a-switch
v-model:checked="form.is_active"
checked-children="启用"
un-checked-children="停用"
/>
</a-form-item>
</a-form>
<template #footer>
<div class="drawer-footer">
<a-button @click="closeDrawer">取消</a-button>
<a-button type="primary" :loading="saving" @click="submitForm">保存</a-button>
</div>
</template>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import type { TablePaginationConfig } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import { computed, onMounted, reactive, ref } from 'vue'
import { showOpsError } from '@/lib/errors'
import { http } from '@/lib/http'
interface SiteDomainMappingRow {
id: number
registrable_domain: string
site_key: string
site_name: string
is_active: boolean
created_at: string
updated_at: string
}
interface ListResult {
items: SiteDomainMappingRow[]
total: number
page: number
size: number
}
const filter = reactive({
keyword: '',
status: undefined as string | undefined,
})
const statusOptions = [
{ label: '启用', value: 'true' },
{ label: '停用', value: 'false' },
]
const columns = [
{ title: '域名 / Key', key: 'registrable_domain', dataIndex: 'registrable_domain', width: '34%' },
{ title: '中文名', key: 'site_name', dataIndex: 'site_name', width: '22%' },
{ title: '状态', key: 'is_active', width: 120 },
{ title: '更新时间', key: 'updated_at', width: 180 },
{ title: '操作', key: 'actions', width: 120, align: 'right' as const },
]
const rows = ref<SiteDomainMappingRow[]>([])
const loading = ref(false)
const page = ref(1)
const size = ref(20)
const total = ref(0)
const statusLoadingId = ref<number | null>(null)
const activeRows = computed(() => rows.value.filter((row) => row.is_active).length)
const inactiveRows = computed(() => rows.value.length - activeRows.value)
const pagination = computed<TablePaginationConfig>(() => ({
current: page.value,
pageSize: size.value,
total: total.value,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100'],
}))
let keywordTimer: number | null = null
function onKeywordChange() {
if (keywordTimer) window.clearTimeout(keywordTimer)
keywordTimer = window.setTimeout(() => {
page.value = 1
void reload()
}, 300)
}
function onStatusChange() {
page.value = 1
void reload()
}
async function reload() {
loading.value = true
try {
const result = await http.get<ListResult>('/site-domain-mappings', {
keyword: filter.keyword || undefined,
is_active: filter.status || undefined,
page: page.value,
size: size.value,
})
rows.value = result.items
total.value = result.total
} catch (error) {
handleError(error)
} finally {
loading.value = false
}
}
function onTableChange(pag: TablePaginationConfig) {
page.value = pag.current ?? 1
size.value = pag.pageSize ?? 20
void reload()
}
const drawerOpen = ref(false)
const saving = ref(false)
const editingRow = ref<SiteDomainMappingRow | null>(null)
const form = reactive({
registrable_domain: '',
site_key: '',
site_name: '',
is_active: true,
})
function resetForm() {
form.registrable_domain = ''
form.site_key = ''
form.site_name = ''
form.is_active = true
editingRow.value = null
}
function openCreate() {
resetForm()
drawerOpen.value = true
}
function openEdit(row: SiteDomainMappingRow) {
editingRow.value = row
form.registrable_domain = row.registrable_domain
form.site_key = row.site_key
form.site_name = row.site_name
form.is_active = row.is_active
drawerOpen.value = true
}
function closeDrawer() {
drawerOpen.value = false
}
async function submitForm() {
const domain = form.registrable_domain.trim()
const siteName = form.site_name.trim()
if (!domain) {
message.warning('请输入匹配域名')
return
}
if (!siteName) {
message.warning('请输入中文名')
return
}
saving.value = true
try {
const payload = {
registrable_domain: domain,
site_key: form.site_key.trim(),
site_name: siteName,
is_active: form.is_active,
}
if (editingRow.value) {
await http.patch(`/site-domain-mappings/${editingRow.value.id}`, payload)
message.success('已保存')
} else {
await http.post('/site-domain-mappings', payload)
message.success('已新增')
}
drawerOpen.value = false
resetForm()
void reload()
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
async function setActive(row: SiteDomainMappingRow, active: boolean) {
statusLoadingId.value = row.id
try {
await http.post(`/site-domain-mappings/${row.id}/active`, { is_active: active })
message.success(active ? '已启用' : '已停用')
row.is_active = active
} catch (error) {
handleError(error)
} finally {
statusLoadingId.value = null
}
}
function onActiveSwitchChange(row: SiteDomainMappingRow, checked: unknown) {
void setActive(row, Boolean(checked))
}
async function deleteMapping(row: SiteDomainMappingRow) {
try {
await http.delete(`/site-domain-mappings/${row.id}`)
message.success('已删除')
void reload()
} catch (error) {
handleError(error)
}
}
function formatDate(value: string | null | undefined): string {
return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—'
}
function handleError(error: unknown) {
showOpsError(error)
}
onMounted(() => {
void reload()
})
</script>
<style scoped>
.site-domain-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.site-domain-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
.site-domain-title {
margin-bottom: 8px;
}
.site-domain-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: #64748b;
font-size: 13px;
}
.site-domain-summary span {
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
padding: 4px 8px;
}
.site-domain-card {
padding-top: 20px;
}
.site-domain-toolbar {
margin-bottom: 18px;
}
.site-domain-search {
width: 280px;
}
.site-domain-status {
width: 140px;
}
.domain-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.domain-name {
color: #0f172a;
font-weight: 650;
word-break: break-all;
}
.domain-key {
color: #94a3b8;
font-size: 12px;
word-break: break-all;
}
.site-name {
color: #334155;
font-weight: 600;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
@media (max-width: 768px) {
.site-domain-header {
align-items: stretch;
flex-direction: column;
}
.site-domain-search,
.site-domain-status {
width: 100%;
}
}
</style>