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
@@ -94,6 +94,7 @@ const enUS = {
tenantAdminLogin: "Tenant Admin Login",
loginAndEnter: "Sign in to workspace",
email: "Email",
loginIdentifier: "Phone / Email",
password: "Password",
defaultTestAccount: "Default account",
defaultTestPassword: "Default password",
@@ -101,6 +102,7 @@ const enUS = {
membershipBlocked: {
eyebrow: "Access Paused",
title: "This tenant plan is unavailable",
expiredMessage: "Membership expired. Please renew or contact support.",
reasonTrialExpired: "The free trial's 3-day page access window has ended. All business pages are now locked. Please contact an administrator to reactivate trial access.",
reasonRequired: "This tenant does not have an active plan. All business pages are now locked. Please contact an administrator.",
reasonInactive: "This tenant plan has expired or been disabled. All business pages are now locked. Please contact an administrator to restore access.",
@@ -118,6 +120,7 @@ const enUS = {
localeLabel: "Language",
quotaTitle: "Plan & Quota",
quotaStatus: "Healthy",
membershipExpiresAt: "Expires",
remainingQuota: "Article quota",
aiPoints: "AI points",
resetAt: "Reset at",
@@ -94,6 +94,7 @@ const zhCN = {
tenantAdminLogin: "租户后台登录",
loginAndEnter: "登录并进入工作台",
email: "邮箱",
loginIdentifier: "手机号 / 邮箱",
password: "密码",
defaultTestAccount: "默认测试账号",
defaultTestPassword: "默认测试密码",
@@ -101,6 +102,7 @@ const zhCN = {
membershipBlocked: {
eyebrow: "访问已暂停",
title: "当前租户套餐不可用",
expiredMessage: "会员到期,请充值或联系客服",
reasonTrialExpired: "免费试用的 3 天页面可访问期已经结束,当前业务页面已全部关闭,请联系管理员重新开通试用权限。",
reasonRequired: "当前租户尚未开通有效套餐,业务页面已全部关闭,请联系管理员处理。",
reasonInactive: "当前租户套餐已失效或已停用,业务页面已全部关闭,请联系管理员恢复访问。",
@@ -118,6 +120,7 @@ const zhCN = {
localeLabel: "语言",
quotaTitle: "套餐与额度",
quotaStatus: "状态正常",
membershipExpiresAt: "会员到期",
remainingQuota: "生成文章",
aiPoints: "AI 点数",
resetAt: "重置时间",
+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>
+18
View File
@@ -118,6 +118,7 @@ import {
clearStoredSession,
getStoredAccessToken,
getStoredRefreshToken,
markStoredMembershipBlocked,
readStoredSession,
setStoredTokens,
} from "./session";
@@ -271,6 +272,23 @@ export const apiClient = createApiClient({
},
});
const membershipBlockedErrors = new Set([
"trial_plan_expired",
"subscription_required",
"subscription_inactive",
]);
apiClient.raw.interceptors.response.use(
(response) => response,
(error) => {
if (error instanceof ApiClientError && membershipBlockedErrors.has(error.message)) {
markStoredMembershipBlocked(error.message);
}
return Promise.reject(error);
},
);
export const authApi = {
login(payload: LoginRequest) {
return publicClient.post<LoginResponse, LoginRequest>("/api/auth/login", payload);
+30
View File
@@ -95,6 +95,36 @@ export function setStoredUser(user: UserInfo | null): SessionSnapshot {
return updateStoredSession({ user });
}
export function markStoredMembershipBlocked(reason = "subscription_inactive"): SessionSnapshot {
const snapshot = readStoredSession();
if (!snapshot.user) {
return snapshot;
}
const current = snapshot.user.membership;
const nextUser: UserInfo = {
...snapshot.user,
membership: {
plan_code: current?.plan_code ?? "",
plan_name: current?.plan_name ?? "",
status: "expired",
blocked: true,
blocked_reason: reason,
start_at: current?.start_at ?? null,
end_at: current?.end_at ?? null,
article_generation_limit: current?.article_generation_limit ?? 0,
article_quota_cycle: current?.article_quota_cycle ?? "lifetime",
ai_points_monthly: current?.ai_points_monthly ?? 0,
ai_point_base_chars: current?.ai_point_base_chars ?? 1000,
company_limit: current?.company_limit ?? 0,
image_storage_bytes: current?.image_storage_bytes ?? 0,
contact_admin_on_expiry: current?.contact_admin_on_expiry ?? true,
},
};
return updateStoredSession({ user: nextUser });
}
export function clearStoredSession(options: { notifyAuthExpired?: boolean } = {}): SessionSnapshot {
if (hasStorage()) {
window.localStorage.removeItem(STORAGE_KEY);
+1 -12
View File
@@ -18,7 +18,7 @@ const router = createRouter({
{
path: "/membership-blocked",
name: "membership-blocked",
component: () => import("@/views/MembershipBlockedView.vue"),
redirect: "/workspace",
meta: {
requiresAuth: true,
},
@@ -261,22 +261,11 @@ router.beforeEach(async (to) => {
};
}
if (authStore.isAuthenticated && authStore.isMembershipBlocked && to.name !== "membership-blocked") {
return { name: "membership-blocked" };
}
if (to.name === "membership-blocked" && authStore.isAuthenticated && !authStore.isMembershipBlocked) {
return { name: "workspace" };
}
if (to.meta.requiresKol && !authStore.isActiveKol) {
return { name: "workspace" };
}
if (to.name === "login" && authStore.isAuthenticated) {
if (authStore.isMembershipBlocked) {
return { name: "membership-blocked" };
}
return { name: "workspace" };
}
+10 -1
View File
@@ -160,7 +160,16 @@ export const useAuthStore = defineStore("auth", () => {
const isAuthenticated = computed(() => Boolean(accessToken.value && user.value));
const membership = computed(() => user.value?.membership ?? null);
const isMembershipBlocked = computed(() => Boolean(membership.value?.blocked));
const isMembershipBlocked = computed(() => {
const value = membership.value;
return Boolean(
value?.blocked ||
value?.status === "expired" ||
value?.blocked_reason === "trial_plan_expired" ||
value?.blocked_reason === "subscription_required" ||
value?.blocked_reason === "subscription_inactive",
);
});
const kolProfile = computed(() => user.value?.kol_profile ?? null);
const isActiveKol = computed(() => kolProfile.value?.status === "active");
+3 -3
View File
@@ -33,8 +33,8 @@
</div>
<form @submit.prevent="handleSubmit" class="form">
<div class="field">
<label for="login-email">{{ t('auth.email') }}</label>
<input id="login-email" type="email" placeholder="admin@geo.local" v-model="formState.email"
<label for="login-identifier">{{ t('auth.loginIdentifier') }}</label>
<input id="login-identifier" type="text" placeholder="admin@geo.local / 13800138000" v-model="formState.identifier"
autocomplete="off" @focus="isTyping = true" @blur="isTyping = false" required />
</div>
<div class="field">
@@ -98,7 +98,7 @@ const isTyping = ref(false);
const remember = ref(false);
const formState = reactive({
email: "admin@geo.local",
identifier: "admin@geo.local",
password: "Admin@123",
});
@@ -41,8 +41,6 @@ const expiryText = computed(() => {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
});