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:
@@ -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: "系统设置",
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user