feat(admin-web/auth): support phone-or-email login and dim shell on expiry

Login form takes a phone-or-email identifier; user info now exposes phone
so the shell can fall back through name → phone → email. When membership
expires, the app stays on the current route but renders a minimal blocked
state in the shell instead of redirecting to a dedicated page; an Axios
interceptor flips the local session into the blocked state when the API
returns trial/subscription errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 00:01:47 +08:00
parent ee21f512a9
commit 62b824520c
10 changed files with 170 additions and 33 deletions
+97 -12
View File
@@ -24,13 +24,37 @@ const router = useRouter();
const authStore = useAuthStore();
const { locale, t } = useI18n();
const isMembershipBlocked = computed(() => authStore.isMembershipBlocked);
const quotaQuery = useQuery({
queryKey: ["workspace", "quota-summary", "shell"],
queryFn: () => workspaceApi.quotaSummary(),
enabled: computed(() => !isMembershipBlocked.value),
});
const userInitial = computed(() => authStore.user?.name?.slice(0, 1).toUpperCase() ?? "A");
const userDisplayName = computed(() => {
return authStore.user?.name || authStore.user?.phone || authStore.user?.email || t("shell.userFallback");
});
const userSecondaryIdentifier = computed(() => authStore.user?.email || authStore.user?.phone || "--");
const userInitial = computed(() => userDisplayName.value.slice(0, 1).toUpperCase() || "A");
const quotaSummary = computed(() => quotaQuery.data.value);
const membershipExpiryText = computed(() => {
const value = authStore.membership?.end_at;
if (!value) {
return "--";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat(locale.value, {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
});
const selectedKeys = computed(() => {
if (route.meta.navKey === null) {
return [];
@@ -242,9 +266,9 @@ async function handleLogout(): Promise<void> {
</a-menu>
</a-layout-sider>
<a-layout class="admin-main-layout">
<a-layout-header class="admin-header">
<nav class="admin-header-breadcrumb" aria-label="Breadcrumb">
<a-layout class="admin-main-layout" :class="{ 'admin-main-layout--blocked': isMembershipBlocked }">
<a-layout-header class="admin-header" :class="{ 'admin-header--blocked': isMembershipBlocked }">
<nav v-if="!isMembershipBlocked" class="admin-header-breadcrumb" aria-label="Breadcrumb">
<span
v-for="(crumb, index) in headerBreadcrumbs"
:key="`${crumb.label}-${index}`"
@@ -269,7 +293,7 @@ async function handleLogout(): Promise<void> {
</nav>
<div class="admin-header-actions">
<div class="admin-locale-switch">
<div v-if="!isMembershipBlocked" class="admin-locale-switch">
<a-dropdown placement="bottomRight" :trigger="['click', 'hover']">
<div class="locale-dropdown-trigger">
<GlobalOutlined class="locale-icon" />
@@ -286,9 +310,11 @@ async function handleLogout(): Promise<void> {
</div>
<!-- Quota Indicator -->
<div class="quota-pill">
<div v-if="!isMembershipBlocked" class="quota-pill">
<a-tag color="blue" :bordered="false" class="quota-pill-tag">{{ quotaSummary?.plan_name || t("shell.planFallback") }}</a-tag>
<span class="quota-pill-text">
{{ t("shell.membershipExpiresAt") }}: <strong>{{ membershipExpiryText }}</strong>
<span class="quota-pill-divider">·</span>
{{ t("shell.remainingQuota") }}: <strong>{{ quotaSummary?.balance ?? '--' }}</strong>
<span class="quota-pill-total">/ {{ quotaSummary?.total_quota ?? '--' }}</span>
<span class="quota-pill-divider">·</span>
@@ -301,17 +327,17 @@ async function handleLogout(): Promise<void> {
<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>
<span class="user-name">{{ userDisplayName }}</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>
<span class="user-dropdown-name">{{ userDisplayName }}</span>
<span class="user-dropdown-role">{{ userSecondaryIdentifier }}</span>
</div>
<a-menu-divider />
<a-menu-item key="workspace" @click="go('/workspace')" class="user-dropdown-item">
<a-menu-item v-if="!isMembershipBlocked" 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">
@@ -324,8 +350,17 @@ async function handleLogout(): Promise<void> {
</div>
</a-layout-header>
<a-layout-content class="admin-content">
<router-view />
<a-layout-content class="admin-content" :class="{ 'admin-content--blocked': isMembershipBlocked }">
<section
v-if="isMembershipBlocked"
class="membership-expired-state"
role="alert"
aria-live="polite"
>
<div class="membership-expired-logo" :aria-label="t('app.name')">GEO</div>
<p>{{ t("membershipBlocked.expiredMessage") }}</p>
</section>
<router-view v-else />
</a-layout-content>
</a-layout>
</a-layout>
@@ -352,6 +387,10 @@ async function handleLogout(): Promise<void> {
margin-left: 250px;
}
.admin-main-layout--blocked {
background: #fff;
}
.admin-brand {
height: 40px;
background: #f0f8ff;
@@ -384,6 +423,10 @@ async function handleLogout(): Promise<void> {
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.admin-header--blocked {
justify-content: flex-end;
}
.admin-header-breadcrumb {
min-width: 0;
flex: 1;
@@ -436,6 +479,7 @@ async function handleLogout(): Promise<void> {
display: flex;
align-items: center;
gap: 20px;
margin-left: auto;
}
.admin-locale-switch {
@@ -574,4 +618,45 @@ async function handleLogout(): Promise<void> {
margin: 24px;
min-height: auto;
}
.admin-content--blocked {
margin: 0;
min-height: calc(100vh - 64px);
background: #fff;
}
.membership-expired-state {
min-height: calc(100vh - 64px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 18px;
padding: 32px;
color: #262626;
}
.membership-expired-logo {
width: 72px;
height: 72px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 18px;
background: #1677ff;
color: #fff;
font-size: 22px;
font-weight: 800;
line-height: 1;
letter-spacing: 0;
box-shadow: 0 14px 32px rgba(22, 119, 255, 0.18);
}
.membership-expired-state p {
margin: 0;
font-size: 18px;
font-weight: 600;
line-height: 1.6;
text-align: center;
}
</style>