Files
geo/apps/ops-web/src/layouts/AppShell.vue
T
root 7d8e82c69f feat(ops): add object storage management and site-mapping CSV import/export
Ship the Ops backstage 对象存储 page (browse / upload / move / delete /
folder ops) backed by a new object-storage service + handler, with the
shared storage client extended with List/Stat/Copy/Usage/DownloadURL so
both MinIO and Aliyun providers cover the new surface. Add
ForcePathStyle to the object-storage config and treat r2/s3/custom_s3
as external providers so Cloudflare R2 and other S3-compatibles can
share the MinIO client path without spinning up the bundled MinIO.

Also add CSV import/export + template download to the site-domain
mappings page, raise gin MaxMultipartMemory to 8 MiB for the new
multipart endpoints, register Ops swagger routes, and extend the
descriptions-parity test to cover the ops router.

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

294 lines
7.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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"
:open-keys="openKeys"
:items="menuItems"
@update:open-keys="onOpenKeysChange"
@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 {
AuditOutlined,
BookOutlined,
ClockCircleOutlined,
CloudDownloadOutlined,
ControlOutlined,
CrownOutlined,
DatabaseOutlined,
FileSearchOutlined,
GlobalOutlined,
LineChartOutlined,
PartitionOutlined,
SafetyCertificateOutlined,
SettingOutlined,
TeamOutlined,
UserSwitchOutlined,
} from '@ant-design/icons-vue'
import type { ItemType } from 'ant-design-vue'
import { computed, h, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
interface MenuLeaf {
key: string
label: string
path: string
}
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const collapsed = ref(false)
const menuLeaves: MenuLeaf[] = [
{ key: '/admin-users', label: '用户管理', path: '/admin-users' },
{ key: '/kol-subscriptions', label: '订阅包审批', path: '/kol-subscriptions' },
{ key: '/site-domain-mappings', label: '站点映射', path: '/site-domain-mappings' },
{ key: '/object-storage', label: '对象存储', path: '/object-storage' },
{ key: '/desktop-client/releases', label: '客户端版本', path: '/desktop-client/releases' },
{ key: '/compliance/policy', label: '全局策略', path: '/compliance/policy' },
{ key: '/compliance/dictionaries', label: '合规词库', path: '/compliance/dictionaries' },
{ key: '/compliance/manual-reviews', label: '人工审阅', path: '/compliance/manual-reviews' },
{ key: '/compliance/records', label: '检测记录', path: '/compliance/records' },
{ key: '/compliance/stats', label: '合规统计', path: '/compliance/stats' },
{ key: '/accounts', label: '操作员管理', path: '/accounts' },
{ key: '/jobs', label: '任务中心', path: '/jobs' },
{ key: '/scheduler', label: '调度中心', path: '/scheduler' },
{ key: '/audits', label: '审计日志', path: '/audits' },
]
const menuItems = computed<ItemType[]>(() => [
{
key: '/admin-users',
label: '用户管理',
icon: () => h(TeamOutlined),
},
{
key: '/kol-subscriptions',
label: '订阅包审批',
icon: () => h(CrownOutlined),
},
{
key: '/site-domain-mappings',
label: '站点映射',
icon: () => h(GlobalOutlined),
},
{
key: '/object-storage',
label: '对象存储',
icon: () => h(DatabaseOutlined),
},
{
key: '/desktop-client/releases',
label: '客户端版本',
icon: () => h(CloudDownloadOutlined),
},
{
key: 'compliance',
label: '内容安全',
icon: () => h(SafetyCertificateOutlined),
children: [
{
key: '/compliance/policy',
label: '全局策略',
icon: () => h(ControlOutlined),
},
{
key: '/compliance/dictionaries',
label: '合规词库',
icon: () => h(BookOutlined),
},
{
key: '/compliance/manual-reviews',
label: '人工审阅',
icon: () => h(UserSwitchOutlined),
},
{
key: '/compliance/records',
label: '检测记录',
icon: () => h(FileSearchOutlined),
},
{
key: '/compliance/stats',
label: '合规统计',
icon: () => h(LineChartOutlined),
},
],
},
{
key: 'system',
label: '系统设置',
icon: () => h(SettingOutlined),
children: [
{
key: '/accounts',
label: '操作员管理',
icon: () => h(SafetyCertificateOutlined),
},
{
key: '/jobs',
label: '任务中心',
icon: () => h(PartitionOutlined),
},
{
key: '/scheduler',
label: '调度中心',
icon: () => h(ClockCircleOutlined),
},
{
key: '/audits',
label: '审计日志',
icon: () => h(AuditOutlined),
},
],
},
])
const openKeys = ref<string[]>(['compliance', 'system'])
function onOpenKeysChange(keys: string[]) {
openKeys.value = keys
}
const selectedKeys = computed(() => [route.path])
function onMenuClick(info: { key: string | number }) {
const item = menuLeaves.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: #eef2ff;
border: 1px solid #c7d2fe;
margin: 16px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: #4f46e5;
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: #4f46e5;
color: #fff;
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
}
</style>