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