feat(ops-web): add operations console frontend

Vue 3 + Ant Design Vue SPA scaffold for the internal ops console. Ships
the auth, account list, and audit log views that consume the new ops-api
endpoints, plus the nginx/vite/typecheck plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 11:33:17 +08:00
parent f63e800f21
commit f5254f6a27
20 changed files with 1830 additions and 7 deletions
+168
View File
@@ -0,0 +1,168 @@
<template>
<a-layout style="min-height: 100vh">
<a-layout-sider v-model:collapsed="collapsed" collapsible breakpoint="lg" :trigger="null">
<div class="ops-brand">
<span v-if="!collapsed">省心推 · 运营</span>
<span v-else>SXT</span>
</div>
<a-menu
theme="light"
mode="inline"
:selected-keys="selectedKeys"
:items="menuItems"
@click="onMenuClick"
/>
</a-layout-sider>
<a-layout>
<a-layout-header class="ops-header">
<a-button type="text" class="header-trigger" @click="collapsed = !collapsed">
{{ collapsed ? "" : "" }}
</a-button>
<span style="flex: 1" />
<a-dropdown placement="bottomRight">
<div class="user-dropdown-btn" style="cursor: pointer;">
<div class="user-avatar">
{{ auth.operator?.display_name?.charAt(0) ?? "U" }}
</div>
<span>{{ auth.operator?.display_name ?? "未登录" }}</span>
</div>
<template #overlay>
<a-menu @click="onUserMenuClick">
<a-menu-item key="profile">个人资料</a-menu-item>
<a-menu-item key="logout">退出登录</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-layout-header>
<a-layout-content class="ops-shell-content">
<router-view />
</a-layout-content>
</a-layout>
</a-layout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
interface MenuItem {
key: string;
label: string;
path: string;
}
const router = useRouter();
const route = useRoute();
const auth = useAuthStore();
const collapsed = ref(false);
const menuItems = computed<MenuItem[]>(() => [
{ key: "/accounts", label: "账号管理", path: "/accounts" },
{ key: "/audits", label: "审计日志", path: "/audits" },
]);
const selectedKeys = computed(() => [route.path]);
function onMenuClick(info: { key: string | number }) {
const item = menuItems.value.find((m) => m.key === info.key);
if (item) void router.push(item.path);
}
function onUserMenuClick(info: { key: string | number }) {
if (info.key === "logout") {
auth.logout();
void router.replace({ name: "login" });
} else if (info.key === "profile") {
void router.push({ name: "profile" });
}
}
onMounted(() => {
if (auth.isAuthenticated) {
void auth.refreshSelf();
}
});
</script>
<style scoped>
:deep(.ant-layout-sider) {
background: #fff !important;
border-right: 1px solid #f0f0f0;
}
.ops-brand {
height: 40px;
background: #f0f8ff;
border: 1px solid #bae0ff;
margin: 16px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: #1677ff;
font-weight: 800;
font-size: 16px;
letter-spacing: 0.05em;
}
.ops-header {
background: #fff;
padding: 0 24px;
display: flex;
align-items: center;
height: 64px;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
z-index: 1;
position: relative;
}
.header-trigger {
font-size: 18px;
color: #8c8c8c;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: all 0.2s;
}
.header-trigger:hover {
background-color: #f5f5f5;
color: #141414;
}
.user-dropdown-btn {
display: flex;
align-items: center;
gap: 8px;
color: #141414;
font-weight: 500;
border-radius: 6px;
padding: 4px 8px;
transition: all 0.2s;
}
.user-dropdown-btn:hover {
background-color: #f5f5f5;
}
.user-avatar {
background-color: #1677ff;
color: #fff;
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
}
</style>