feat(ops-web/site-mappings): add site domain mappings management view

Add a CRUD page for site_domain_mappings with search, pagination, create/
edit/enable/disable/delete actions, and register it in the AppShell nav.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 17:10:17 +08:00
parent 9760bec8bd
commit 665405763c
3 changed files with 448 additions and 0 deletions
+7
View File
@@ -48,6 +48,7 @@
<script setup lang="ts">
import {
AuditOutlined,
GlobalOutlined,
SafetyCertificateOutlined,
SettingOutlined,
TeamOutlined,
@@ -72,6 +73,7 @@ const collapsed = ref(false);
const menuLeaves: MenuLeaf[] = [
{ key: "/admin-users", label: "用户管理", path: "/admin-users" },
{ key: "/site-domain-mappings", label: "站点映射", path: "/site-domain-mappings" },
{ key: "/accounts", label: "操作员管理", path: "/accounts" },
{ key: "/audits", label: "审计日志", path: "/audits" },
];
@@ -82,6 +84,11 @@ const menuItems = computed<ItemType[]>(() => [
label: "用户管理",
icon: () => h(TeamOutlined),
},
{
key: "/site-domain-mappings",
label: "站点映射",
icon: () => h(GlobalOutlined),
},
{
key: "system",
label: "系统设置",
+6
View File
@@ -36,6 +36,12 @@ export const router = createRouter({
component: () => import("@/views/AuditLogsView.vue"),
meta: { title: "审计日志" },
},
{
path: "site-domain-mappings",
name: "site-domain-mappings",
component: () => import("@/views/SiteDomainMappingsView.vue"),
meta: { title: "站点映射" },
},
{
path: "profile",
name: "profile",
@@ -0,0 +1,435 @@
<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'">
<a-space :size="4">
<a-tooltip title="编辑">
<a-button type="text" size="small" @click="openEdit(record)">
<template #icon><EditOutlined /></template>
</a-button>
</a-tooltip>
<a-popconfirm title="确认删除该映射?" @confirm="deleteMapping(record)">
<a-tooltip title="删除">
<a-button type="text" danger size="small">
<template #icon><DeleteOutlined /></template>
</a-button>
</a-tooltip>
</a-popconfirm>
</a-space>
</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 { OpsApiError, 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) {
if (error instanceof OpsApiError) {
message.error(error.detail || error.message);
}
}
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>