feat: add vue admin transition shell
This commit is contained in:
1 parent
2cdf7f9d7a
commit
f07a61f3a8
30 files changed
+2127
-7
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_BASE_URL=
|
||||||
|
VITE_API_PROXY_TARGET=http://localhost:4000
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
@@ -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>
|
||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
@@ -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; } }
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
@@ -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 : "登录失败");
|
||||||
|
|||||||
Reference in new issue
Block a user