feat: portal tenant apply flow
This commit is contained in:
@@ -101,6 +101,10 @@
|
||||
|
||||
.p-avatar {
|
||||
flex: 0 0 auto;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
min-width: 2.5rem;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
|
||||
const toast = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const { state: sessionState, isLoggedIn, username } = useSession();
|
||||
const { state: sessionState, isLoggedIn, username, isTenantApproved } = useSession();
|
||||
|
||||
const userMenuRef = ref();
|
||||
|
||||
@@ -20,6 +20,24 @@ const displayName = computed(() => {
|
||||
return username.value || '用户';
|
||||
});
|
||||
|
||||
const tenantApplyAction = computed(() => {
|
||||
const app = sessionState.tenantApplication;
|
||||
if (!isLoggedIn.value) return null;
|
||||
if (isTenantApproved.value) return null;
|
||||
|
||||
if (app?.hasApplication) {
|
||||
if (app.status === 'pending_verify') {
|
||||
return { label: '创作者审核中', to: '/tenant/apply/status', icon: 'pi pi-hourglass' };
|
||||
}
|
||||
if (app.status === 'banned') {
|
||||
return { label: '创作者申请结果', to: '/tenant/apply/status', icon: 'pi pi-info-circle' };
|
||||
}
|
||||
return { label: '创作者申请', to: '/tenant/apply/status', icon: 'pi pi-info-circle' };
|
||||
}
|
||||
|
||||
return { label: '申请创作者', to: '/tenant/apply', icon: 'pi pi-star' };
|
||||
});
|
||||
|
||||
const userMenuItems = computed(() => [
|
||||
{ label: '个人中心', icon: 'pi pi-user', command: () => router.push('/me') },
|
||||
{ separator: true },
|
||||
@@ -94,8 +112,12 @@ onMounted(() => {
|
||||
<div class="layout-topbar-menu hidden lg:block">
|
||||
<div class="layout-topbar-menu-content">
|
||||
<template v-if="isLoggedIn">
|
||||
<router-link v-if="tenantApplyAction" :to="tenantApplyAction.to" class="layout-topbar-action layout-topbar-action-text">
|
||||
<i :class="tenantApplyAction.icon"></i>
|
||||
<span>{{ tenantApplyAction.label }}</span>
|
||||
</router-link>
|
||||
<button type="button" class="layout-topbar-action layout-topbar-user" @click="toggleUserMenu">
|
||||
<Avatar shape="circle" class="bg-surface-200 dark:bg-surface-700" />
|
||||
<Avatar shape="circle" size="large" class="bg-surface-200 dark:bg-surface-700" />
|
||||
<span>{{ displayName }}</span>
|
||||
<i class="pi pi-angle-down"></i>
|
||||
</button>
|
||||
|
||||
@@ -42,8 +42,8 @@ const router = createRouter({
|
||||
|
||||
{ path: 'settings/account/close', name: 'closeAccount', component: TitlePage, meta: { title: '账号注销' } },
|
||||
|
||||
{ path: 'tenant/apply', name: 'tenantApply', component: TitlePage, meta: { title: '租户创建申请' } },
|
||||
{ path: 'tenant/apply/status', name: 'tenantApplyStatus', component: TitlePage, meta: { title: '租户申请进度/状态' } },
|
||||
{ path: 'tenant/apply', name: 'tenantApply', component: () => import('@/views/tenant/TenantApply.vue'), meta: { title: '申请创作者' } },
|
||||
{ path: 'tenant/apply/status', name: 'tenantApplyStatus', component: () => import('@/views/tenant/TenantApply.vue'), meta: { title: '创作者申请状态' } },
|
||||
{ path: 'tenant/switch', name: 'tenantSwitch', component: TitlePage, meta: { title: '租户切换' } },
|
||||
|
||||
{ path: 'admin', name: 'adminDashboard', component: TitlePage, meta: { title: '管理概览(仪表盘)' } },
|
||||
|
||||
@@ -5,7 +5,9 @@ import { getPortalAuthToken, requestJson, setPortalAuthToken } from './apiClient
|
||||
const state = reactive({
|
||||
token: getPortalAuthToken(),
|
||||
me: null,
|
||||
loadingMe: false
|
||||
loadingMe: false,
|
||||
tenantApplication: null,
|
||||
loadingTenantApplication: false
|
||||
});
|
||||
|
||||
let initPromise = null;
|
||||
@@ -14,14 +16,15 @@ export function useSession() {
|
||||
const isLoggedIn = computed(() => Boolean(state.token));
|
||||
const username = computed(() => {
|
||||
const raw = state.me?.username ?? state.me?.Username ?? '';
|
||||
console.log("Computed username:", state.me?.username);
|
||||
return String(raw || '').trim();
|
||||
});
|
||||
const isTenantApproved = computed(() => state.tenantApplication?.hasApplication && state.tenantApplication?.status === 'verified');
|
||||
|
||||
return {
|
||||
state,
|
||||
isLoggedIn,
|
||||
username
|
||||
username,
|
||||
isTenantApproved
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,6 +44,22 @@ export async function fetchMe() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchTenantApplication() {
|
||||
if (!state.token) {
|
||||
state.tenantApplication = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
state.loadingTenantApplication = true;
|
||||
try {
|
||||
const data = await requestJson('/v1/tenant/application', { auth: true });
|
||||
state.tenantApplication = data;
|
||||
return data;
|
||||
} finally {
|
||||
state.loadingTenantApplication = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
const normalized = String(token || '').trim();
|
||||
state.token = normalized;
|
||||
@@ -49,12 +68,15 @@ export function setToken(token) {
|
||||
|
||||
export async function setTokenAndLoadMe(token) {
|
||||
setToken(token);
|
||||
return await fetchMe();
|
||||
await fetchMe();
|
||||
await fetchTenantApplication();
|
||||
return state.me;
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
setToken('');
|
||||
state.me = null;
|
||||
state.tenantApplication = null;
|
||||
}
|
||||
|
||||
export async function initSession() {
|
||||
@@ -65,6 +87,7 @@ export async function initSession() {
|
||||
if (state.token) {
|
||||
try {
|
||||
await fetchMe();
|
||||
await fetchTenantApplication();
|
||||
} catch {
|
||||
// token 可能过期或无效:清理并让 UI 回到未登录态
|
||||
logout();
|
||||
|
||||
17
frontend/portal/src/service/tenantApply.js
Normal file
17
frontend/portal/src/service/tenantApply.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { requestJson } from './apiClient';
|
||||
import { fetchTenantApplication } from './session';
|
||||
|
||||
export async function getTenantApplication() {
|
||||
return await fetchTenantApplication();
|
||||
}
|
||||
|
||||
export async function applyTenantApplication({ code, name }) {
|
||||
const data = await requestJson('/v1/tenant/apply', {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: { code, name }
|
||||
});
|
||||
await fetchTenantApplication();
|
||||
return data;
|
||||
}
|
||||
|
||||
122
frontend/portal/src/views/tenant/TenantApply.vue
Normal file
122
frontend/portal/src/views/tenant/TenantApply.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script setup>
|
||||
import { applyTenantApplication, getTenantApplication } from '@/service/tenantApply';
|
||||
import { useSession } from '@/service/session';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
const toast = useToast();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const { isLoggedIn, isTenantApproved, state: sessionState } = useSession();
|
||||
|
||||
const submitting = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
const name = ref('');
|
||||
const code = ref('');
|
||||
|
||||
const application = computed(() => sessionState.tenantApplication);
|
||||
const hasApplication = computed(() => Boolean(application.value?.hasApplication));
|
||||
|
||||
const statusLabel = computed(() => application.value?.statusDescription || '');
|
||||
const statusSeverity = computed(() => {
|
||||
const s = application.value?.status;
|
||||
if (s === 'verified') return 'success';
|
||||
if (s === 'pending_verify') return 'warn';
|
||||
if (s === 'banned') return 'danger';
|
||||
return 'secondary';
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await getTenantApplication();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting.value) return;
|
||||
if (!name.value.trim() || !code.value.trim()) {
|
||||
toast.add({ severity: 'warn', summary: '请完善信息', detail: '请填写租户名称和租户 ID', life: 2500 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
submitting.value = true;
|
||||
await applyTenantApplication({ name: name.value.trim(), code: code.value.trim() });
|
||||
toast.add({ severity: 'success', summary: '申请已提交', detail: '等待管理员审核', life: 2000 });
|
||||
await router.push('/tenant/apply/status');
|
||||
} catch (err) {
|
||||
const payload = err?.payload;
|
||||
const message = String(payload?.message || err?.message || '').trim();
|
||||
toast.add({ severity: 'error', summary: '提交失败', detail: message || '请稍后重试', life: 3500 });
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isLoggedIn.value) {
|
||||
const redirect = typeof route.fullPath === 'string' ? route.fullPath : '/tenant/apply';
|
||||
await router.push(`/auth/login?redirect=${encodeURIComponent(redirect)}`);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">申请创作者</h1>
|
||||
<div class="text-muted-color mt-2">提交租户信息后,等待后台管理员审核;一个用户仅可申请一个租户创作者。</div>
|
||||
</div>
|
||||
<Button label="刷新" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="refresh" />
|
||||
</div>
|
||||
|
||||
<Divider class="my-6" />
|
||||
|
||||
<div v-if="isTenantApproved" class="flex flex-col gap-3">
|
||||
<Message severity="success">你已成为创作者,无需重复申请。</Message>
|
||||
<div v-if="application?.hasApplication" class="text-sm">
|
||||
<div>租户名称:{{ application.tenantName }}</div>
|
||||
<div>租户 ID:{{ application.tenantCode }}</div>
|
||||
<div>状态:<Tag :severity="statusSeverity" :value="statusLabel" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="hasApplication" class="flex flex-col gap-4">
|
||||
<Message :severity="statusSeverity">
|
||||
<span v-if="application?.status === 'pending_verify'">申请已提交,正在审核中。</span>
|
||||
<span v-else-if="application?.status === 'verified'">审核已通过,你已成为创作者。</span>
|
||||
<span v-else>申请状态:{{ statusLabel }}</span>
|
||||
</Message>
|
||||
|
||||
<div class="text-sm flex flex-col gap-2">
|
||||
<div>租户名称:{{ application.tenantName }}</div>
|
||||
<div>租户 ID:{{ application.tenantCode }}</div>
|
||||
<div>状态:<Tag :severity="statusSeverity" :value="statusLabel" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="max-w-2xl flex flex-col gap-6">
|
||||
<div>
|
||||
<label for="tenantName" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-1">租户名称</label>
|
||||
<InputText id="tenantName" v-model="name" size="large" class="w-full text-xl py-3" placeholder="请输入租户名称" autocomplete="organization" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="tenantCode" class="block text-surface-900 dark:text-surface-0 text-xl font-medium mb-1">租户 ID</label>
|
||||
<InputText id="tenantCode" v-model="code" size="large" class="w-full text-xl py-3" placeholder="3-64 位小写字母/数字/_/-" autocapitalize="off" autocomplete="off" />
|
||||
<small class="text-muted-color">将用于 URL(例如 `/t/<tenantCode>/...`),提交后不可随意变更。</small>
|
||||
</div>
|
||||
|
||||
<Button label="提交申请" icon="pi pi-send" size="large" class="w-full" :loading="submitting" @click="submit" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user