feat: add tenant and user management with migrations, handlers, and tests
- Implemented tenant and user management features including: - Tenant creation and management with associated migrations. - User creation and management with associated migrations. - Tenant membership management with associated migrations. - Platform user roles management with associated migrations. - Quota management with associated migrations. - Article and template management with associated migrations. - Added HTTP handlers for templates and workspaces. - Created tests for protected and public routes. - Introduced a script to check tenant scope in SQL queries. - Documented task plan for backend completion and frontend foundation.
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
BookOutlined,
|
||||
GlobalOutlined,
|
||||
RadarChartOutlined,
|
||||
SearchOutlined,
|
||||
DownOutlined,
|
||||
LogoutOutlined,
|
||||
TranslationOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import { workspaceApi } from "@/lib/api";
|
||||
import { type AppLocale, setAppLocale } from "@/i18n";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const { locale, t } = useI18n();
|
||||
|
||||
const quotaQuery = useQuery({
|
||||
queryKey: ["workspace", "quota-summary", "shell"],
|
||||
queryFn: () => workspaceApi.quotaSummary(),
|
||||
});
|
||||
|
||||
const userInitial = computed(() => authStore.user?.name?.slice(0, 1).toUpperCase() ?? "A");
|
||||
const selectedKey = computed(() => String(route.meta.navKey ?? route.path));
|
||||
const pageTitle = computed(() => t(String(route.meta.titleKey ?? "route.workspace.title")));
|
||||
|
||||
const localeOptions = computed(() => [
|
||||
{ label: t("locale.zh-CN"), value: "zh-CN" },
|
||||
{ label: t("locale.en-US"), value: "en-US" },
|
||||
]);
|
||||
|
||||
const currentLocale = computed({
|
||||
get: () => locale.value as AppLocale,
|
||||
set: (value: AppLocale) => {
|
||||
setAppLocale(value);
|
||||
},
|
||||
});
|
||||
|
||||
const currentLocaleLabel = computed(() => {
|
||||
return localeOptions.value.find((opt) => opt.value === currentLocale.value)?.label ?? "Language";
|
||||
});
|
||||
|
||||
const navSections = computed(() => [
|
||||
{
|
||||
key: "workspace",
|
||||
title: "",
|
||||
items: [
|
||||
{
|
||||
key: "/workspace",
|
||||
label: t("nav.workspace"),
|
||||
icon: AppstoreOutlined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "articleCreation",
|
||||
title: t("nav.articleCreation"),
|
||||
items: [
|
||||
{ key: "/articles/templates", label: t("nav.templates") },
|
||||
{ key: "/articles/custom", label: t("nav.custom") },
|
||||
{ key: "/articles/optimize", label: t("nav.optimize") },
|
||||
{ key: "/media", label: t("nav.media"), icon: GlobalOutlined },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "brandManagement",
|
||||
title: t("nav.brandManagement"),
|
||||
items: [
|
||||
{ key: "/brands", label: t("nav.brands"), icon: SearchOutlined },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "tracking",
|
||||
title: t("nav.tracking"),
|
||||
items: [
|
||||
{ key: "/tracking", label: t("nav.trackingDetail"), icon: RadarChartOutlined },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "contentManagement",
|
||||
title: t("nav.contentManagement"),
|
||||
items: [
|
||||
{ key: "/knowledge", label: t("nav.knowledge"), icon: BookOutlined },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
function go(path: string): void {
|
||||
void router.push(path);
|
||||
}
|
||||
|
||||
function handleLocaleChange({ key }: { key: any }): void {
|
||||
currentLocale.value = key as AppLocale;
|
||||
}
|
||||
|
||||
async function handleLogout(): Promise<void> {
|
||||
await authStore.logout();
|
||||
await router.replace("/login");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout class="admin-layout">
|
||||
<a-layout-sider width="250" theme="light" class="admin-sider">
|
||||
<div class="admin-brand">
|
||||
{{ t("app.name") }}
|
||||
</div>
|
||||
|
||||
<a-menu
|
||||
mode="inline"
|
||||
theme="light"
|
||||
:selected-keys="[selectedKey]"
|
||||
class="admin-menu"
|
||||
>
|
||||
<template v-for="section in navSections" :key="section.key">
|
||||
<template v-if="section.title">
|
||||
<a-menu-item-group :title="section.title">
|
||||
<a-menu-item
|
||||
v-for="item in section.items"
|
||||
:key="item.key"
|
||||
@click="go(item.key)"
|
||||
>
|
||||
<template v-if="item.icon" #icon><component :is="item.icon" /></template>
|
||||
{{ item.label }}
|
||||
</a-menu-item>
|
||||
</a-menu-item-group>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<a-menu-item
|
||||
v-for="item in section.items"
|
||||
:key="item.key"
|
||||
@click="go(item.key)"
|
||||
>
|
||||
<template v-if="item.icon" #icon><component :is="item.icon" /></template>
|
||||
{{ item.label }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
</template>
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
|
||||
<a-layout>
|
||||
<a-layout-header class="admin-header">
|
||||
<h2 class="admin-header-title">{{ pageTitle }}</h2>
|
||||
|
||||
<div class="admin-header-actions">
|
||||
<div class="admin-locale-switch">
|
||||
<a-dropdown placement="bottomRight" :trigger="['click', 'hover']">
|
||||
<div class="locale-dropdown-trigger">
|
||||
<GlobalOutlined class="locale-icon" />
|
||||
<span>{{ currentLocaleLabel }}</span>
|
||||
</div>
|
||||
<template #overlay>
|
||||
<a-menu :selected-keys="[currentLocale]" @click="handleLocaleChange">
|
||||
<a-menu-item v-for="opt in localeOptions" :key="opt.value">
|
||||
{{ opt.label }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- Quota Indicator -->
|
||||
<div class="quota-pill">
|
||||
<a-tag color="blue" :bordered="false" class="quota-pill-tag">{{ quotaQuery.data.value?.plan_name || t("shell.planFallback") }}</a-tag>
|
||||
<span class="quota-pill-text">
|
||||
{{ t("shell.remainingQuota") }}: <strong>{{ quotaQuery.data.value?.balance ?? '--' }}</strong>
|
||||
<span class="quota-pill-total">/ {{ quotaQuery.data.value?.total_quota ?? '--' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- User Dropdown -->
|
||||
<a-dropdown placement="bottomRight" :trigger="['click', 'hover']">
|
||||
<div class="user-dropdown-trigger">
|
||||
<a-avatar size="small" class="user-avatar">{{ userInitial }}</a-avatar>
|
||||
<span class="user-name">{{ authStore.user?.name || t("shell.userFallback") }}</span>
|
||||
<DownOutlined class="user-dropdown-icon" />
|
||||
</div>
|
||||
<template #overlay>
|
||||
<a-menu class="user-dropdown-menu">
|
||||
<div class="user-dropdown-header">
|
||||
<span class="user-dropdown-name">{{ authStore.user?.name || t("shell.userFallback") }}</span>
|
||||
<span class="user-dropdown-role">{{ authStore.user?.email || "--" }}</span>
|
||||
</div>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="workspace" @click="go('/workspace')" class="user-dropdown-item">
|
||||
{{ t("nav.workspace") }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="logout" @click="handleLogout" class="user-dropdown-item user-dropdown-logout">
|
||||
<LogoutOutlined class="user-dropdown-logout-icon" />
|
||||
{{ t("shell.logout") }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
|
||||
<a-layout-content class="admin-content">
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.admin-sider {
|
||||
background: #fff;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.admin-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;
|
||||
}
|
||||
|
||||
.admin-menu {
|
||||
border-right: 0;
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background: #fff;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
z-index: 1;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
}
|
||||
|
||||
.admin-header-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.admin-locale-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-locale-select {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.locale-dropdown-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 14px;
|
||||
color: #141414;
|
||||
}
|
||||
|
||||
.locale-dropdown-trigger:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.locale-icon {
|
||||
font-size: 16px;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.quota-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.quota-pill-tag {
|
||||
border-radius: 999px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.quota-pill-text {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
transform: translateY(0.5px);
|
||||
}
|
||||
|
||||
.quota-pill-text strong {
|
||||
color: #141414;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.quota-pill-total {
|
||||
font-size: 12px;
|
||||
transform: scale(0.9);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.user-dropdown-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.user-dropdown-trigger:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
background-color: #1677ff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #141414;
|
||||
}
|
||||
|
||||
.user-dropdown-icon {
|
||||
font-size: 10px;
|
||||
color: #bfbfbf;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.user-dropdown-menu {
|
||||
min-width: 160px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.user-dropdown-header {
|
||||
padding: 4px 12px 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-dropdown-name {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: #141414;
|
||||
}
|
||||
|
||||
.user-dropdown-role {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.user-dropdown-item {
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-dropdown-logout {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.user-dropdown-logout-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
margin: 24px;
|
||||
min-height: auto;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user