feat: add vue admin transition shell

This commit is contained in:
duanshuwen committed 2026-08-25 22:48:10 +08:00
1 parent 2cdf7f9d7a
commit f07a61f3a8
30 files changed
+2127 -7

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
VITE_API_BASE_URL=
VITE_API_PROXY_TARGET=http://localhost:4000
+28
View File
@@ -0,0 +1,28 @@
# WonderQ Admin UI Vue
WonderQ 管理端 Vue3 迁移端,采用 Vue 3、Element Plus、TypeScript、Pinia 和 Vue Router 4。
## 启动
```powershell
yarn install
yarn dev
```
开发地址:http://localhost:5604/admin/
默认通过 Vite `/api` 代理访问 `http://localhost:4000`,可使用 `VITE_API_PROXY_TARGET` 覆盖。生产构建使用 `yarn build`,静态产物由反向代理挂载到 `/admin`。
## 认证约定
- Access Token 只保存在运行时内存,不写入 localStorage。
- Refresh Token 由后端以 HttpOnly Cookie 保存。
- 401 会自动调用 `/api/admin/auth/refresh` 并重试一次;刷新失败后返回登录页。
- 菜单和按钮权限来自 `/api/admin/system/profile`,前端只加载预注册组件。
## 验证
```powershell
yarn test
yarn build
```
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="WonderQ 管理端 Vue3 过渡版" />
<title>WonderQ 管理端</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "wonderq-admin-ui-vue",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"test": "vitest run",
"preview": "vite preview"
},
"dependencies": {
"element-plus": "^2.11.3",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@rollup/rollup-win32-x64-msvc": "4.63.0",
"@rolldown/binding-win32-x64-msvc": "1.2.5",
"@types/node": "^24.0.0",
"lightningcss-win32-x64-msvc": "1.33.0",
"typescript": "^6.0.3",
"vite": "^8.1.0",
"vitest": "^3.2.4",
"vue-tsc": "^3.0.6"
}
}
+3
View File
@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>
+105
View File
@@ -0,0 +1,105 @@
import type { ApiErrorResponse, ApiResponse, AuthPayload, AdminProfile, Dashboard, Lead, SiteConfig, WanfaCategory, ConciergeAdvisor } from "@/types";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
let accessToken: string | null = null;
let refreshPromise: Promise<AuthPayload> | null = null;
export class ApiRequestError extends Error {
constructor(
message: string,
readonly statusCode: number,
readonly errorCode?: string,
readonly details?: unknown,
) {
super(message);
this.name = "ApiRequestError";
}
}
export class AuthExpiredError extends ApiRequestError {
constructor(message = "登录已失效,请重新登录") {
super(message, 401, "AUTH_EXPIRED");
this.name = "AuthExpiredError";
}
}
export function setAccessToken(token: string | null) {
accessToken = token;
}
export function getAccessToken() {
return accessToken;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
async function parseJson(response: Response): Promise<unknown> {
return response.json().catch(() => null);
}
function unwrap<T>(payload: unknown, statusCode: number): T {
if (!isRecord(payload) || typeof payload.code !== "number" || payload.code !== statusCode || typeof payload.msg !== "string" || !Object.prototype.hasOwnProperty.call(payload, "data")) {
throw new ApiRequestError("接口响应格式不正确", statusCode, "API_PROTOCOL_ERROR");
}
if (statusCode < 200 || statusCode >= 300) {
if (payload.data !== null) throw new ApiRequestError("接口响应格式不正确", statusCode, "API_PROTOCOL_ERROR");
const error = payload as unknown as ApiErrorResponse;
throw new ApiRequestError(error.msg, statusCode, error.errorCode, error.details);
}
if (payload.msg !== "success") throw new ApiRequestError("接口响应格式不正确", statusCode, "API_PROTOCOL_ERROR");
return payload.data as T;
}
async function rawRequest(path: string, options: RequestInit = {}): Promise<Response> {
const headers = new Headers(options.headers);
if (!(options.body instanceof FormData)) headers.set("Content-Type", "application/json");
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
return fetch(`${API_BASE}${path}`, { ...options, headers, credentials: "include" });
}
export async function refreshAccessToken(): Promise<AuthPayload> {
const response = await rawRequest("/api/admin/auth/refresh", { method: "POST" });
const payload = unwrap<AuthPayload>(await parseJson(response), response.status);
setAccessToken(payload.accessToken || payload.token);
return payload;
}
async function refreshOnce() {
if (!refreshPromise) {
refreshPromise = refreshAccessToken().finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
export async function request<T>(path: string, options: RequestInit = {}, retry = true): Promise<T> {
const response = await rawRequest(path, options);
if (response.status === 401 && retry && !path.includes("/auth/login") && !path.includes("/auth/refresh") && !path.includes("/auth/logout")) {
try {
await refreshOnce();
return request<T>(path, options, false);
} catch {
setAccessToken(null);
throw new AuthExpiredError();
}
}
return unwrap<T>(await parseJson(response), response.status);
}
export const login = (email: string, password: string) => request<AuthPayload>("/api/admin/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }, false);
export const getProfile = () => request<AdminProfile>("/api/admin/system/profile");
export const logout = () => request<{ ok: boolean }>("/api/admin/auth/logout", { method: "POST" }, false);
export const getDashboard = () => request<Dashboard>("/api/admin/dashboard");
export const getSiteConfig = () => request<SiteConfig>("/api/admin/site-config");
export const getWanfa = async () => (await request<{ categories: WanfaCategory[] }>("/api/admin/wanfa/categories")).categories;
export const getConcierge = async () => (await request<{ advisors: ConciergeAdvisor[] }>("/api/admin/concierge/advisors")).advisors;
export const getLeads = () => request<{ items: Lead[] }>("/api/admin/leads?take=50");
export const updateLeadStatus = (leadId: string, status: string) =>
request<Lead>(`/api/admin/leads/${leadId}/status`, {
method: "PATCH",
body: JSON.stringify({ status }),
});
export const getSystemResource = (resource: string) => request<{ items: unknown[] }>(`/api/admin/system/${resource}`);
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { AdminMenu } from "@/types";
defineProps<{ menus: AdminMenu[] }>();
</script>
<template>
<template v-for="menu in menus" :key="menu.id">
<el-sub-menu v-if="menu.type === 'directory' && menu.children?.length" :index="menu.id">
<template #title><span>{{ menu.name }}</span></template>
<MenuTree :menus="menu.children" />
</el-sub-menu>
<el-menu-item v-else-if="menu.type !== 'button' && menu.path" :index="menu.path">
<span>{{ menu.name }}</span>
</el-menu-item>
</template>
</template>
@@ -0,0 +1,12 @@
import type { ObjectDirective } from "vue";
import { useAuthStore } from "@/stores/auth";
export const permissionDirective: ObjectDirective<HTMLElement, string> = {
mounted(element, binding) {
const permission = binding.value;
if (!permission || !useAuthStore().hasPermission(permission)) {
element.style.display = "none";
element.setAttribute("aria-hidden", "true");
}
},
};
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessageBox } from "element-plus";
import { useAuthStore } from "@/stores/auth";
import MenuTree from "@/components/MenuTree.vue";
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const activePath = computed(() => route.path);
async function logout() {
await ElMessageBox.confirm("确定退出当前后台会话吗?", "退出登录", { type: "warning" });
await auth.logout();
await router.replace({ name: "login" });
}
</script>
<template>
<el-container class="admin-shell">
<el-aside width="244px" class="admin-aside">
<div class="brand">
<div class="brand-mark">WQ</div>
<div><strong>WonderQ</strong><span>内容运营后台</span></div>
</div>
<el-scrollbar>
<el-menu :default-active="activePath" router class="sidebar-menu">
<MenuTree :menus="auth.menus" />
</el-menu>
</el-scrollbar>
<div class="aside-note">Vue3 迁移过渡端</div>
</el-aside>
<el-container>
<el-header class="admin-header">
<div>
<p class="eyebrow">OPERATIONS / ADMIN</p>
<h1>{{ route.meta.title || "内容运营" }}</h1>
</div>
<div class="header-actions">
<el-tag type="success" effect="light">权限已加载</el-tag>
<span class="user-name">{{ auth.user?.name }}</span>
<el-button text @click="logout">退出</el-button>
</div>
</el-header>
<el-main class="admin-main"><RouterView /></el-main>
</el-container>
</el-container>
</template>
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
function visibleMenuPaths(menus: Array<{ type: string; path?: string | null; isVisible?: boolean }>) {
return menus.filter((menu) => menu.type !== "button" && menu.isVisible !== false && menu.path).map((menu) => menu.path);
}
describe("dynamic admin menu", () => {
it("keeps only visible navigable pages", () => {
expect(visibleMenuPaths([
{ type: "page", path: "/dashboard", isVisible: true },
{ type: "page", path: "/hidden", isVisible: false },
{ type: "button", path: null, isVisible: true },
])).toEqual(["/dashboard"]);
});
});
+11
View File
@@ -0,0 +1,11 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import App from "./App.vue";
import router from "./router";
import "./styles.css";
import { permissionDirective } from "./directives/permission";
const app = createApp(App);
app.use(createPinia()).use(router).use(ElementPlus).directive("permission", permissionDirective).mount("#app");
@@ -0,0 +1,10 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { getConcierge } from "@/api/client";
import type { ConciergeAdvisor } from "@/types";
const advisors = ref<ConciergeAdvisor[]>([]); const loading = ref(true);
onMounted(async () => { try { advisors.value = await getConcierge(); } catch (err) { ElMessage.error(err instanceof Error ? err.message : "管家加载失败"); } finally { loading.value = false; } });
</script>
<template><div class="page-stack"><div class="page-intro"><div><p class="eyebrow">SERVICE / CONCIERGE</p><h2>管家顾问</h2><p>顾问资料和服务入口继续沿用现有 Admin API。</p></div></div><el-skeleton v-if="loading" :rows="6" animated /><el-empty v-else-if="!advisors.length" description="暂无顾问资料" /><div v-else class="advisor-grid"><el-card v-for="advisor in advisors" :key="advisor.id" shadow="never" class="advisor-card"><div class="advisor-avatar">{{ advisor.name.slice(0, 1) }}</div><div><strong>{{ advisor.name }}</strong><span>{{ advisor.role }}</span></div><el-tag :type="advisor.isActive ? 'success' : 'info'" effect="plain">{{ advisor.isActive ? '启用' : '停用' }}</el-tag></el-card></div></div></template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { getDashboard } from "@/api/client";
import type { Dashboard } from "@/types";
const data = ref<Dashboard | null>(null);
const loading = ref(true);
const error = ref("");
async function load() {
loading.value = true;
error.value = "";
try { data.value = await getDashboard(); } catch (err) { error.value = err instanceof Error ? err.message : "仪表盘加载失败"; ElMessage.error(error.value); } finally { loading.value = false; }
}
onMounted(load);
</script>
<template>
<div class="page-stack">
<div class="page-intro"><div><p class="eyebrow">TODAY / SNAPSHOT</p><h2>运营概览</h2><p>快速查看当前内容和需求线索的状态。</p></div><el-button :loading="loading" @click="load">刷新数据</el-button></div>
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false" />
<div v-if="loading" class="loading-grid"><el-skeleton v-for="item in 2" :key="item" animated /></div>
<template v-else-if="data">
<div class="metric-grid"><el-card shadow="never" class="metric-card"><span>待处理线索</span><strong>{{ data.stats.newLeadCount }}</strong><small>需要及时跟进</small></el-card><el-card shadow="never" class="metric-card accent"><span>全部线索</span><strong>{{ data.stats.leadCount }}</strong><small>持续沉淀中的需求</small></el-card></div>
<el-card shadow="never" class="panel-card"><template #header><div class="panel-heading"><span>最近线索</span><el-tag type="info" effect="plain">最多 5 条</el-tag></div></template><el-empty v-if="!data.recentLeads.length" description="暂无线索" /><el-table v-else :data="data.recentLeads" stripe><el-table-column prop="contactName" label="联系人" /><el-table-column prop="phone" label="联系方式" /><el-table-column prop="destination" label="目的地" /><el-table-column prop="status" label="状态" /><el-table-column prop="createdAt" label="提交时间" /></el-table></el-card>
</template>
</div>
</template>
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { getSiteConfig } from "@/api/client";
import type { SiteConfig } from "@/types";
const config = ref<SiteConfig | null>(null);
const loading = ref(true);
onMounted(async () => { try { config.value = await getSiteConfig(); } catch (err) { ElMessage.error(err instanceof Error ? err.message : "首页配置加载失败"); } finally { loading.value = false; } });
</script>
<template>
<div class="page-stack"><div class="page-intro"><div><p class="eyebrow">CONTENT / HOME</p><h2>首页配置</h2><p>保留原有站点模块,先在 Vue 端完成读取和权限入口迁移。</p></div></div><el-skeleton v-if="loading" :rows="8" animated /><el-empty v-else-if="!config" description="暂时没有配置数据" /><div v-else class="module-grid"><el-card v-for="(value, key) in config" :key="key" shadow="never" class="module-card"><span class="module-label">{{ key }}</span><strong>{{ Array.isArray(value) ? value.length : 1 }}</strong><small>条配置记录</small><el-button text type="primary" @click="$router.push('/system/menus')">查看权限入口</el-button></el-card></div></div>
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { getLeads, updateLeadStatus } from "@/api/client";
import type { Lead } from "@/types";
const leads = ref<Lead[]>([]); const loading = ref(true);
const updatingId = ref("");
const statuses = [
{ value: "new", label: "待处理" },
{ value: "contacted", label: "已联系" },
{ value: "quoted", label: "已报价" },
{ value: "closed", label: "已关闭" },
];
onMounted(async () => { try { leads.value = (await getLeads()).items; } catch (err) { ElMessage.error(err instanceof Error ? err.message : "线索加载失败"); } finally { loading.value = false; } });
async function changeStatus(lead: Lead, status: string) {
const previous = lead.status;
updatingId.value = lead.id;
try {
const updated = await updateLeadStatus(lead.id, status);
lead.status = updated.status;
ElMessage.success("线索状态已更新");
} catch (err) {
lead.status = previous;
ElMessage.error(err instanceof Error ? err.message : "线索状态更新失败");
} finally { updatingId.value = ""; }
}
</script>
<template><div class="page-stack"><div class="page-intro"><div><p class="eyebrow">INBOX / LEADS</p><h2>需求线索</h2><p>保留原有线索字段,并按权限更新线索状态。</p></div></div><el-card shadow="never" class="panel-card"><el-skeleton v-if="loading" :rows="7" animated /><el-empty v-else-if="!leads.length" description="暂无需求线索" /><el-table v-else :data="leads" stripe><el-table-column prop="contactName" label="联系人" /><el-table-column prop="phone" label="联系方式" /><el-table-column prop="destination" label="目的地" /><el-table-column prop="leadType" label="类型" /><el-table-column label="状态" min-width="150"><template #default="{ row }"><el-select v-permission="'admin:leads:update'" :model-value="row.status" size="small" :loading="updatingId === row.id" @change="changeStatus(row, $event)"><el-option v-for="option in statuses" :key="option.value" :label="option.label" :value="option.value" /></el-select></template></el-table-column><el-table-column prop="createdAt" label="提交时间" /></el-table></el-card></div></template>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
const form = reactive({ email: "admin@example.com", password: "ChangeMe123!" });
const error = ref("");
async function submit() {
error.value = "";
try {
await auth.login(form.email, form.password);
await router.replace(typeof route.query.redirect === "string" ? route.query.redirect : "/dashboard");
} catch (err) {
error.value = err instanceof Error ? err.message : "登录失败,请稍后重试";
ElMessage.error(error.value);
}
}
</script>
<template>
<main class="login-page">
<section class="login-art">
<p class="eyebrow">WONDERQ / OPERATIONS</p>
<h1>把每一次出发,<br /><em>整理成可执行的体验。</em></h1>
<p>内容、玩法、管家与线索,在同一个运营工作台里保持清晰流动。</p>
</section>
<el-card class="login-card" shadow="never">
<div class="login-card-heading"><span class="brand-mark">WQ</span><div><h2>欢迎回来</h2><p>登录 WonderQ 管理端</p></div></div>
<el-form :model="form" label-position="top" @submit.prevent="submit">
<el-form-item label="账号"><el-input v-model="form.email" type="email" autocomplete="username" /></el-form-item>
<el-form-item label="密码"><el-input v-model="form.password" type="password" show-password autocomplete="current-password" @keyup.enter="submit" /></el-form-item>
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false" class="login-error" />
<el-button type="primary" native-type="submit" :loading="auth.loading" class="login-button">{{ auth.loading ? "登录中" : "进入后台" }}</el-button>
</el-form>
</el-card>
</main>
</template>
@@ -0,0 +1 @@
<template><el-result icon="warning" title="页面不存在" sub-title="请从左侧菜单重新进入"><template #extra><el-button type="primary" @click="$router.push('/dashboard')">返回仪表盘</el-button></template></el-result></template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import { getSystemResource } from "@/api/client";
const route = useRoute(); const items = ref<unknown[]>([]); const loading = ref(true); const error = ref("");
const resource = computed(() => String(route.params.resource || "users"));
const title = computed(() => ({ users: "用户管理", roles: "角色管理", menus: "菜单管理", depts: "部门管理" }[resource.value] || "系统管理"));
onMounted(async () => { try { items.value = (await getSystemResource(resource.value)).items; } catch (err) { error.value = err instanceof Error ? err.message : "系统数据加载失败"; ElMessage.error(error.value); } finally { loading.value = false; } });
</script>
<template><div class="page-stack"><div class="page-intro"><div><p class="eyebrow">SYSTEM / RBAC</p><h2>{{ title }}</h2><p>权限模型已接入,管理操作将按确认后的 RBAC 矩阵逐项开放。</p></div></div><el-alert v-if="error" :title="error" type="warning" show-icon :closable="false" /><el-card shadow="never" class="panel-card"><el-skeleton v-if="loading" :rows="6" animated /><el-empty v-else-if="!items.length" description="暂无数据" /><el-table v-else :data="items" stripe><el-table-column label="记录"><template #default="{ row }"><pre class="json-preview">{{ JSON.stringify(row, null, 2) }}</pre></template></el-table-column></el-table></el-card></div></template>
@@ -0,0 +1,10 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { getWanfa } from "@/api/client";
import type { WanfaCategory } from "@/types";
const categories = ref<WanfaCategory[]>([]); const loading = ref(true);
onMounted(async () => { try { categories.value = await getWanfa(); } catch (err) { ElMessage.error(err instanceof Error ? err.message : "玩法加载失败"); } finally { loading.value = false; } });
</script>
<template><div class="page-stack"><div class="page-intro"><div><p class="eyebrow">CONTENT / PLAY</p><h2>玩法与路线</h2><p>现有玩法数据已接入 Vue 过渡端,编辑能力将按切片继续迁移。</p></div></div><el-skeleton v-if="loading" :rows="8" animated /><el-empty v-else-if="!categories.length" description="暂无玩法分类" /><div v-else class="category-list"><el-card v-for="category in categories" :key="category.id" shadow="never" class="panel-card"><template #header><div class="panel-heading"><span>{{ category.label }}</span><el-tag effect="plain">{{ category.routes.length }} 条路线</el-tag></div></template><el-table :data="category.routes" size="small"><el-table-column prop="title" label="路线" /><el-table-column prop="subtitle" label="副标题" /><el-table-column prop="routeCount" label="天数" /></el-table></el-card></div></div></template>
+80
View File
@@ -0,0 +1,80 @@
import { createRouter, createWebHistory } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import AdminLayout from "@/layouts/AdminLayout.vue";
import LoginPage from "@/pages/LoginPage.vue";
import DashboardPage from "@/pages/DashboardPage.vue";
import HomePage from "@/pages/HomePage.vue";
import WanfaPage from "@/pages/WanfaPage.vue";
import ConciergePage from "@/pages/ConciergePage.vue";
import LeadsPage from "@/pages/LeadsPage.vue";
import SystemResourcePage from "@/pages/SystemResourcePage.vue";
import NotFoundPage from "@/pages/NotFoundPage.vue";
const componentRegistry = {
Dashboard: DashboardPage,
HomePage,
WanfaPage,
ConciergePage,
LeadsPage,
SystemUsers: SystemResourcePage,
SystemRoles: SystemResourcePage,
SystemMenus: SystemResourcePage,
SystemDepts: SystemResourcePage,
};
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: "/login", name: "login", component: LoginPage, meta: { public: true } },
{
path: "/",
name: "admin",
component: AdminLayout,
children: [
{ path: "dashboard", name: "dashboard", component: DashboardPage },
{ path: "home", name: "home", component: HomePage },
{ path: "play", name: "play", component: WanfaPage },
{ path: "concierge", name: "concierge", component: ConciergePage },
{ path: "leads", name: "leads", component: LeadsPage },
{ path: "system/:resource", name: "system-resource", component: SystemResourcePage },
{ path: "", redirect: "/dashboard" },
],
},
{ path: "/:pathMatch(.*)*", name: "not-found", component: NotFoundPage },
],
});
let dynamicRoutesRegistered = false;
function registerDynamicRoutes() {
if (dynamicRoutesRegistered) return;
const auth = useAuthStore();
for (const menu of auth.menus) {
if (menu.type !== "page" || !menu.path || !menu.component || !(menu.component in componentRegistry)) continue;
const path = menu.path.replace(/^\/+/, "");
if (router.getRoutes().some((route) => route.path === `/${path}`)) continue;
router.addRoute("admin", {
path,
name: `dynamic-${menu.id}`,
component: componentRegistry[menu.component as keyof typeof componentRegistry],
meta: { permission: menu.permission },
});
}
dynamicRoutesRegistered = true;
}
router.beforeEach(async (to) => {
const auth = useAuthStore();
if (to.meta.public) {
if (to.name === "login" && auth.isAuthenticated) return { name: "dashboard" };
return true;
}
await auth.bootstrap();
if (!auth.isAuthenticated) return { name: "login", query: { redirect: to.fullPath } };
registerDynamicRoutes();
const permission = to.meta.permission;
if (typeof permission === "string" && !auth.hasPermission(permission)) return { name: "not-found" };
return true;
});
export default router;
+70
View File
@@ -0,0 +1,70 @@
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import * as api from "@/api/client";
import type { AdminMenu, AdminProfile, AdminUser } from "@/types";
export const useAuthStore = defineStore("auth", () => {
const user = ref<AdminUser | null>(null);
const menus = ref<AdminMenu[]>([]);
const permissions = ref<string[]>([]);
const initialized = ref(false);
const loading = ref(false);
const isAuthenticated = computed(() => Boolean(user.value && api.getAccessToken()));
function applyProfile(profile: AdminProfile) {
user.value = profile.user;
menus.value = profile.menus;
permissions.value = profile.permissions;
}
async function loadProfile() {
applyProfile(await api.getProfile());
}
async function bootstrap() {
if (initialized.value) return;
loading.value = true;
try {
const session = await api.refreshAccessToken();
user.value = session.user;
await loadProfile();
} catch {
api.setAccessToken(null);
user.value = null;
menus.value = [];
permissions.value = [];
} finally {
initialized.value = true;
loading.value = false;
}
}
async function login(email: string, password: string) {
loading.value = true;
try {
const session = await api.login(email, password);
api.setAccessToken(session.accessToken || session.token);
user.value = session.user;
await loadProfile();
} finally {
loading.value = false;
}
}
async function logout() {
try {
await api.logout();
} finally {
api.setAccessToken(null);
user.value = null;
menus.value = [];
permissions.value = [];
}
}
function hasPermission(permission: string) {
return permissions.value.includes("*") || permissions.value.includes(permission);
}
return { user, menus, permissions, initialized, loading, isAuthenticated, bootstrap, login, logout, hasPermission };
});
+69
View File
@@ -0,0 +1,69 @@
:root {
color: #1e293b;
background: #f5f7fb;
font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
font-synthesis: none;
--ink: #1e293b;
--muted: #64748b;
--line: #e7ebf2;
--blue: #2563eb;
--surface: #ffffff;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; background: #f5f7fb; }
button, input { font: inherit; }
.admin-shell { min-height: 100vh; }
.admin-aside { position: relative; display: flex; flex-direction: column; border-right: 1px solid #e9edf3; background: #fff; }
.brand { display: flex; align-items: center; gap: 11px; padding: 25px 22px 23px; border-bottom: 1px solid #f0f2f6; }
.brand strong { display: block; letter-spacing: .02em; font-size: 16px; }
.brand span { display: block; color: #94a3b8; font-size: 11px; margin-top: 3px; }
.brand-mark { display: grid; place-items: center; width: 35px; height: 35px; border-radius: 10px; color: #fff; background: #1d4ed8; font-size: 11px; font-weight: 800; letter-spacing: .08em; }
.sidebar-menu { border-right: 0; padding: 14px 10px; }
.sidebar-menu .el-menu-item, .sidebar-menu .el-sub-menu__title { border-radius: 8px; margin: 3px 0; color: #64748b; }
.sidebar-menu .el-menu-item.is-active { color: #1d4ed8; background: #eff6ff; font-weight: 650; }
.aside-note { padding: 16px 22px 20px; color: #94a3b8; font-size: 11px; }
.admin-header { display: flex; justify-content: space-between; align-items: center; height: 88px; padding: 0 40px; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.82); }
.eyebrow { margin: 0 0 7px; color: #2563eb; font-size: 10px; font-weight: 800; letter-spacing: .16em; }
.admin-header h1, .page-intro h2 { margin: 0; color: var(--ink); font-size: 23px; line-height: 1.2; }
.header-actions { display: flex; align-items: center; gap: 15px; }
.user-name { color: #475569; font-size: 13px; }
.admin-main { padding: 34px 40px 54px; }
.page-stack { max-width: 1220px; margin: 0 auto; }
.page-intro { display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; margin-bottom: 26px; }
.page-intro p:not(.eyebrow) { margin: 9px 0 0; color: var(--muted); font-size: 13px; }
.metric-grid, .module-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-bottom: 20px; }
.metric-card, .module-card, .panel-card, .advisor-card { border: 1px solid var(--line); border-radius: 12px; }
.metric-card { position: relative; min-height: 145px; overflow: hidden; }
.metric-card::after { content: ""; position: absolute; right: -28px; bottom: -46px; width: 130px; height: 130px; border-radius: 50%; background: #eff6ff; }
.metric-card.accent::after { background: #ecfdf5; }
.metric-card span, .metric-card small, .module-card small { display: block; color: var(--muted); font-size: 12px; }
.metric-card strong { display: block; margin: 14px 0 5px; color: var(--ink); font-size: 34px; line-height: 1; }
.metric-card small { color: #94a3b8; }
.panel-heading { display: flex; justify-content: space-between; align-items: center; font-weight: 650; }
.loading-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.module-card { display: flex; flex-direction: column; gap: 8px; min-height: 140px; }
.module-label { color: #475569; font-weight: 650; }
.module-card strong { font-size: 29px; }
.module-card .el-button { align-self: flex-start; padding-left: 0; }
.category-list { display: grid; gap: 16px; }
.advisor-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.advisor-card { display: flex; align-items: center; gap: 12px; }
.advisor-card > div:nth-child(2) { flex: 1; }
.advisor-card strong, .advisor-card span { display: block; }
.advisor-card span { margin-top: 4px; color: var(--muted); font-size: 12px; }
.advisor-avatar { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 50%; color: #1d4ed8; background: #dbeafe; font-weight: 700; }
.json-preview { max-height: 220px; overflow: auto; margin: 0; color: #475569; font-size: 12px; white-space: pre-wrap; }
.login-page { display: grid; grid-template-columns: minmax(0, 1fr) 420px; min-height: 100vh; padding: 5vw 9vw; gap: 8vw; align-items: center; background: #f8fafc; }
.login-art { max-width: 560px; }
.login-art h1 { margin: 18px 0; color: #172554; font-size: clamp(34px, 4vw, 61px); letter-spacing: -.05em; line-height: 1.07; }
.login-art em { color: #2563eb; font-style: normal; }
.login-art > p:last-child { max-width: 390px; color: #64748b; line-height: 1.9; }
.login-card { padding: 30px; border: 1px solid #e2e8f0; border-radius: 16px; }
.login-card-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 30px; }
.login-card-heading h2 { margin: 0; font-size: 22px; }
.login-card-heading p { margin: 5px 0 0; color: var(--muted); font-size: 13px; }
.login-error { margin: 4px 0 17px; }
.login-button { width: 100%; height: 42px; }
@media (max-width: 900px) { .login-page { grid-template-columns: 1fr; padding: 48px 24px; gap: 24px; } .login-art { max-width: none; } .login-art h1 { font-size: 38px; } .login-card { max-width: 520px; width: 100%; } .admin-header { padding: 0 22px; } .admin-main { padding: 26px 22px 40px; } .advisor-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 640px) { .admin-aside { width: 0 !important; display: none; } .admin-header { height: 76px; } .admin-header h1 { font-size: 19px; } .header-actions .el-tag, .user-name { display: none; } .metric-grid, .module-grid, .loading-grid, .advisor-grid { grid-template-columns: 1fr; } .page-intro { align-items: flex-start; flex-direction: column; } .admin-main { padding: 22px 14px 32px; } }
+81
View File
@@ -0,0 +1,81 @@
export type ApiResponse<T> = {
code: number;
msg: string;
data: T;
errorCode?: string;
details?: unknown;
};
export type ApiErrorResponse = {
code: number;
msg: string;
data: null;
errorCode?: string;
details?: unknown;
};
export type AdminUser = {
id: string;
email: string;
name: string;
role: string;
isActive?: boolean;
};
export type AdminMenu = {
id: string;
parentId?: string | null;
name: string;
type: "directory" | "page" | "button";
path?: string | null;
component?: string | null;
permission?: string | null;
icon?: string | null;
children?: AdminMenu[];
sortOrder?: number;
};
export type AdminProfile = {
user: AdminUser;
roles: Array<{ id: string; name: string; code: string; dataScope: string }>;
permissions: string[];
menus: AdminMenu[];
dataScopes: string[];
};
export type AuthPayload = {
token: string;
accessToken: string;
expiresIn: number;
user: AdminUser;
};
export type Dashboard = {
stats: { newLeadCount: number; leadCount: number };
recentLeads: Lead[];
};
export type Lead = {
id: string;
contactName?: string | null;
phone: string;
destination?: string | null;
status: string;
leadType?: string;
createdAt?: string;
};
export type WanfaCategory = {
id: string;
label: string;
routes: Array<{ id: string; title: string; subtitle: string; routeCount: number }>;
};
export type ConciergeAdvisor = {
id: string;
name: string;
role: string;
isActive: boolean;
};
export type SiteConfig = Record<string, unknown>;
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
export default component;
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"ignoreDeprecations": "6.0",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "vite.config.ts"]
}
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig, loadEnv } from "vite";
import vue from "@vitejs/plugin-vue";
import path from "node:path";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
return {
base: "/admin/",
plugins: [vue()],
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
server: {
host: "0.0.0.0",
port: 5604,
strictPort: true,
proxy: {
"/api": {
target: env.VITE_API_PROXY_TARGET || "http://localhost:4000",
changeOrigin: true,
},
},
},
preview: { host: "0.0.0.0", port: 5605, strictPort: true },
};
});
File diff suppressed because it is too large. Load diff
+38 -3
View File
@@ -341,6 +341,8 @@ export function clearToken() {
window.localStorage.removeItem(TOKEN_KEY); window.localStorage.removeItem(TOKEN_KEY);
} }
let refreshPromise: Promise<string | null> | null = null;
function handleAuthExpired() { function handleAuthExpired() {
clearToken(); clearToken();
notifyAuthExpired(); notifyAuthExpired();
@@ -401,7 +403,30 @@ async function readApiResponse(response: Response): Promise<unknown> {
return response.json().catch(() => null); return response.json().catch(() => null);
} }
async function request<T>(path: string, options: RequestInit = {}): Promise<T> { async function refreshAdminToken(): Promise<string | null> {
if (refreshPromise) return refreshPromise;
refreshPromise = (async () => {
const response = await fetch(`${API_BASE}/api/admin/auth/refresh`, {
method: "POST",
credentials: "include",
});
if (!response.ok) return null;
try {
const body = await readApiResponse(response);
const session = parseApiResponse<{ token: string; accessToken?: string }>(body, response.status);
const token = session.accessToken ?? session.token;
setToken(token);
return token;
} catch {
return null;
}
})().finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
async function request<T>(path: string, options: RequestInit = {}, allowRefresh = true): Promise<T> {
const headers = new Headers(options.headers); const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
const token = getToken(); const token = getToken();
@@ -410,10 +435,12 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, { const response = await fetch(`${API_BASE}${path}`, {
...options, ...options,
headers, headers,
credentials: "include",
}); });
if (!response.ok) { if (!response.ok) {
if (path !== "/api/admin/auth/login" && shouldExpireAdminSession(response.status, Boolean(token))) { if (allowRefresh && path !== "/api/admin/auth/login" && shouldExpireAdminSession(response.status, Boolean(token))) {
if (await refreshAdminToken()) return request<T>(path, options, false);
handleAuthExpired(); handleAuthExpired();
} }
const body = await readApiResponse(response); const body = await readApiResponse(response);
@@ -424,12 +451,16 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
} }
export async function login(email: string, password: string) { export async function login(email: string, password: string) {
return request<{ token: string; user: { name: string; email: string; role: string } }>("/api/admin/auth/login", { return request<{ token: string; accessToken?: string; user: { name: string; email: string; role: string } }>("/api/admin/auth/login", {
method: "POST", method: "POST",
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
}); });
} }
export async function logoutAdmin() {
return request<{ ok: boolean }>("/api/admin/auth/logout", { method: "POST" }, false);
}
export async function uploadMediaAsset(file: File, group = "site-config") { export async function uploadMediaAsset(file: File, group = "site-config") {
const form = new FormData(); const form = new FormData();
form.set("file", file); form.set("file", file);
@@ -442,9 +473,13 @@ export async function uploadMediaAsset(file: File, group = "site-config") {
method: "POST", method: "POST",
headers, headers,
body: form, body: form,
credentials: "include",
}); });
if (!response.ok) { if (!response.ok) {
if (shouldExpireAdminSession(response.status, Boolean(token)) && (await refreshAdminToken())) {
return uploadMediaAsset(file, group);
}
if (shouldExpireAdminSession(response.status, Boolean(token))) { if (shouldExpireAdminSession(response.status, Boolean(token))) {
handleAuthExpired(); handleAuthExpired();
} }
@@ -1,7 +1,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { ClipboardList, Headset, Home, List, LogOut } from "lucide-react"; import { ClipboardList, Headset, Home, List, LogOut } from "lucide-react";
import { clearToken } from "@/api"; import { clearToken, logoutAdmin } from "@/api";
import { ToastStack } from "@/components/admin/ToastStack"; import { ToastStack } from "@/components/admin/ToastStack";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConciergePage } from "@/pages/structure/ConciergePage"; import { ConciergePage } from "@/pages/structure/ConciergePage";
@@ -56,8 +56,10 @@ export function AdminShell({ onLogout }: { onLogout: () => void }) {
}; };
const logout = () => { const logout = () => {
clearToken(); void logoutAdmin().finally(() => {
onLogout(); clearToken();
onLogout();
});
}; };
return ( return (
@@ -18,7 +18,7 @@ export function LoginPage({ onDone }: { onDone: () => void }) {
setError(""); setError("");
try { try {
const result = await login(email, password); const result = await login(email, password);
setToken(result.token); setToken(result.accessToken ?? result.token);
onDone(); onDone();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "登录失败"); setError(err instanceof Error ? err.message : "登录失败");