feat: 添加后台登录验证码、记住密码功能,优化媒体资源与前端规范
- 新增后台登录图形验证码功能,完善登录安全防护 - 新增登录rememberMe参数,控制Refresh Token的会话持久化策略 - 实现OSS私有桶媒体URL自动签名,统一处理图片资源的临时访问签名 - 新增素材库数据库表与上传API,规范媒体资源管理流程 - 统一前端UI图标使用@element-plus/icons-vue,重构布局图标组件 - 登录页新增验证码输入、刷新功能,添加账号记忆与记住密码逻辑 - 更新全套文档,补充API契约、技术决策记录与集成流程说明 - 修复多个业务页面的图标展示问题,新增认证流程相关测试用例
This commit is contained in:
1 parent
2c8c327de7
commit
6245159e7c
35 files changed
+914
-79
No files matched your search
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getCaptcha, login } from "@/api/client";
|
||||
|
||||
describe("admin auth client", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("loads a captcha challenge without an access token", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse(200, {
|
||||
captchaEnabled: true,
|
||||
captchaId: "captcha-1",
|
||||
image: "data:image/svg+xml;base64,PHN2Zy8+",
|
||||
expiresIn: 120,
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(getCaptcha()).resolves.toMatchObject({ captchaId: "captcha-1", captchaEnabled: true });
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/admin/auth/captcha", expect.objectContaining({ credentials: "include" }));
|
||||
});
|
||||
|
||||
it("sends the captcha fields with login", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(jsonResponse(200, {
|
||||
token: "access-token",
|
||||
accessToken: "access-token",
|
||||
expiresIn: 900,
|
||||
user: { id: "admin-1", email: "admin@example.com", name: "Admin", role: "admin" },
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await login("admin@example.com", "password", "captcha-1", "ABCD", true);
|
||||
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
|
||||
email: "admin@example.com",
|
||||
password: "password",
|
||||
captchaId: "captcha-1",
|
||||
captchaCode: "ABCD",
|
||||
rememberMe: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(code: number, data: unknown) {
|
||||
return { status: code, json: async () => ({ code, msg: "success", data }) };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
ApiErrorResponse,
|
||||
ApiResponse,
|
||||
AuthPayload,
|
||||
CaptchaPayload,
|
||||
AdminMenu,
|
||||
AdminProfile,
|
||||
ConciergeAdvisorCreate,
|
||||
@@ -139,7 +140,8 @@ export async function request<T>(path: string, options: RequestInit = {}, retry
|
||||
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 getCaptcha = () => request<CaptchaPayload>("/api/admin/auth/captcha", {}, false);
|
||||
export const login = (email: string, password: string, captchaId: string, captchaCode: string, rememberMe = false) => request<AuthPayload>("/api/admin/auth/login", { method: "POST", body: JSON.stringify({ email, password, captchaId, captchaCode, rememberMe }) }, false);
|
||||
export const getProfile = () => request<AdminProfile>("/api/admin/system/profile");
|
||||
/** Load the authenticated user's visible menu tree for dynamic route registration. */
|
||||
export const getRouters = () => request<AdminMenu[]>("/api/admin/system/routers");
|
||||
|
||||
@@ -1,46 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, type Component } from "vue";
|
||||
import {
|
||||
ArrowDown,
|
||||
Back as BackIcon,
|
||||
Close as CloseIcon,
|
||||
Collection as CollectionIcon,
|
||||
FullScreen as FullScreenIcon,
|
||||
Grid as GridIcon,
|
||||
Guide,
|
||||
Menu as MenuIcon,
|
||||
Moon as MoonIcon,
|
||||
More as MoreIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Right as RightIcon,
|
||||
Search as SearchIcon,
|
||||
Setting as SettingIcon,
|
||||
Sunny as SunnyIcon,
|
||||
User as UserIcon,
|
||||
} from "@element-plus/icons-vue";
|
||||
import type { LayoutIconName } from "@/lib/menu-display";
|
||||
|
||||
withDefaults(defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
name: LayoutIconName;
|
||||
size?: number | string;
|
||||
title?: string;
|
||||
}>(), {
|
||||
size: 24,
|
||||
});
|
||||
|
||||
const iconRegistry: Record<LayoutIconName, Component> = {
|
||||
Menu: MenuIcon,
|
||||
Grid: GridIcon,
|
||||
// Element Plus does not expose a Route icon; Guide is the closest semantic match.
|
||||
Route: Guide,
|
||||
User: UserIcon,
|
||||
Collection: CollectionIcon,
|
||||
Setting: SettingIcon,
|
||||
Search: SearchIcon,
|
||||
FullScreen: FullScreenIcon,
|
||||
Moon: MoonIcon,
|
||||
Sunny: SunnyIcon,
|
||||
Refresh: RefreshIcon,
|
||||
Close: CloseIcon,
|
||||
More: MoreIcon,
|
||||
Back: BackIcon,
|
||||
Right: RightIcon,
|
||||
Chevron: ArrowDown,
|
||||
};
|
||||
|
||||
const iconComponent = computed(() => iconRegistry[props.name] ?? MenuIcon);
|
||||
const iconSize = computed(() => typeof props.size === "number"
|
||||
? `${props.size}px`
|
||||
: /^\d+$/.test(props.size)
|
||||
? `${props.size}px`
|
||||
: props.size);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
<component
|
||||
:is="iconComponent"
|
||||
class="layout-icon"
|
||||
:style="{ width: iconSize, height: iconSize }"
|
||||
focusable="false"
|
||||
:aria-hidden="title ? undefined : true"
|
||||
:aria-label="title"
|
||||
role="img"
|
||||
>
|
||||
<title v-if="title">{{ title }}</title>
|
||||
<path v-if="name === 'Menu'" d="M4 6h16M4 12h16M4 18h16" />
|
||||
<path v-else-if="name === 'Grid'" d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z" />
|
||||
<path v-else-if="name === 'Route'" d="M5 5h.01M5 19h.01M19 5h.01M7 5h5a3 3 0 0 1 3 3v8a3 3 0 0 0 3 3h1M5 7v10" />
|
||||
<path v-else-if="name === 'User'" d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4 21a8 8 0 0 1 16 0" />
|
||||
<path v-else-if="name === 'Collection'" d="M4 5h16v14H4zM8 5v14M8 9h12M8 14h12" />
|
||||
<path v-else-if="name === 'Setting'" d="m12 3 1.2 2.4 2.6.4.9 2.5 2.2 1.5-.9 2.5.9 2.5-2.2 1.5-.9 2.5-2.6.4L12 21l-1.2-2.4-2.6-.4-.9-2.5-2.2-1.5.9-2.5-.9-2.5 2.2-1.5.9-2.5 2.6-.4zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" />
|
||||
<path v-else-if="name === 'Search'" d="m20 20-4.5-4.5M10.5 17a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Z" />
|
||||
<path v-else-if="name === 'FullScreen'" d="M8 4H4v4M16 4h4v4M20 16v4h-4M4 16v4h4" />
|
||||
<path v-else-if="name === 'Moon'" d="M20 15.5A8.5 8.5 0 0 1 8.5 4 8.5 8.5 0 1 0 20 15.5Z" />
|
||||
<path v-else-if="name === 'Sunny'" d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z" />
|
||||
<path v-else-if="name === 'Refresh'" d="M20 11a8 8 0 0 0-14.8-3L3 11M3 5v6h6M4 13a8 8 0 0 0 14.8 3L21 13M21 19v-6h-6" />
|
||||
<path v-else-if="name === 'Close'" d="m6 6 12 12M18 6 6 18" />
|
||||
<path v-else-if="name === 'More'" d="M5 12h.01M12 12h.01M19 12h.01" stroke-width="3" />
|
||||
<path v-else-if="name === 'Back'" d="m15 18-6-6 6-6M9 12h10" />
|
||||
<path v-else-if="name === 'Right'" d="m9 6 6 6-6 6M5 12h10" />
|
||||
<path v-else-if="name === 'Chevron'" d="m8 9 4 4 4-4" />
|
||||
</svg>
|
||||
/>
|
||||
</template>
|
||||
@@ -74,14 +74,15 @@ describe("menu display primitives", () => {
|
||||
expect(getMenuIconName(menu({ id: "unknown", name: "未知", type: "page", icon: "NotAnIcon" }))).toBe("Menu");
|
||||
});
|
||||
|
||||
it("keeps the LayoutIcon SFC contract static and dependency-free", () => {
|
||||
it("renders every layout icon through the Element Plus icon registry", () => {
|
||||
const source = readFileSync(new URL("../components/layout/LayoutIcon.vue", import.meta.url), "utf8");
|
||||
expect(source).toContain('viewBox="0 0 24 24"');
|
||||
expect(source).toContain('from "@element-plus/icons-vue"');
|
||||
for (const name of ["Menu", "Grid", "Route", "User", "Collection", "Setting", "Search", "FullScreen", "Moon", "Sunny", "Refresh", "Close", "More", "Back", "Right", "Chevron"]) {
|
||||
expect(source).toContain(`name === '${name}'`);
|
||||
expect(source).toContain(`${name}:`);
|
||||
}
|
||||
expect(source).toContain('<title v-if="title">');
|
||||
expect(source).toContain(':aria-hidden="title ? undefined : true"');
|
||||
expect(source).not.toMatch(/emoji|external|lucide|element-plus/i);
|
||||
expect(source).toContain("Route: Guide");
|
||||
expect(source).toContain("Chevron: ArrowDown");
|
||||
expect(source).not.toMatch(/<svg|<path|emoji|external|lucide/);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { CircleCloseFilled } from "@element-plus/icons-vue";
|
||||
import {
|
||||
createHomePlayRecommendation,
|
||||
createHomeTeamBuilding,
|
||||
@@ -226,7 +227,7 @@ onMounted(loadConfig);
|
||||
<div class="page-stack">
|
||||
<div class="page-intro site-config-intro"><div><p class="eyebrow">CONTENT / HOME</p><h2>首页配置</h2><p>统一维护顶部轮播、玩法推荐、万趣用车、团队共创和极境视界;保存后直接同步首页展示。</p></div></div>
|
||||
<el-skeleton v-if="loading" :rows="10" animated />
|
||||
<el-result v-else-if="loadError" icon="error" title="首页配置加载失败" :sub-title="loadError"><template #extra><el-button type="primary" @click="loadConfig">重试</el-button></template></el-result>
|
||||
<el-result v-else-if="loadError" title="首页配置加载失败" :sub-title="loadError"><template #icon><CircleCloseFilled /></template><template #extra><el-button type="primary" @click="loadConfig">重试</el-button></template></el-result>
|
||||
<template v-else>
|
||||
<el-tabs v-model="activeModule" class="site-config-tabs" @tab-change="closeEditor">
|
||||
<el-tab-pane name="heroSlides" label="顶部轮播" />
|
||||
|
||||
@@ -1,25 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from "vue";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import * as api from "@/api/client";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const form = reactive({ email: "admin@example.com", password: "ChangeMe123!" });
|
||||
const rememberedEmailKey = "wonderq-admin-remembered-email";
|
||||
const rememberedEmail = typeof window === "undefined" ? "" : window.localStorage.getItem(rememberedEmailKey) ?? "";
|
||||
const form = reactive({ email: rememberedEmail || "admin@example.com", password: "ChangeMe123!", captchaId: "", captchaCode: "", rememberMe: Boolean(rememberedEmail) });
|
||||
const error = ref("");
|
||||
const captchaImage = ref("");
|
||||
const captchaLoading = ref(false);
|
||||
|
||||
async function loadCaptcha() {
|
||||
captchaLoading.value = true;
|
||||
try {
|
||||
const captcha = await api.getCaptcha();
|
||||
form.captchaId = captcha.captchaId;
|
||||
form.captchaCode = "";
|
||||
captchaImage.value = captcha.image;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "验证码加载失败,请稍后重试";
|
||||
} finally {
|
||||
captchaLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error.value = "";
|
||||
try {
|
||||
await auth.login(form.email, form.password);
|
||||
await auth.login(form.email, form.password, form.captchaId, form.captchaCode, form.rememberMe);
|
||||
if (form.rememberMe) window.localStorage.setItem(rememberedEmailKey, form.email.trim());
|
||||
else window.localStorage.removeItem(rememberedEmailKey);
|
||||
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);
|
||||
await loadCaptcha();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadCaptcha);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,6 +58,16 @@ async function submit() {
|
||||
<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-form-item label="图形验证码">
|
||||
<div class="login-captcha-row">
|
||||
<el-input v-model="form.captchaCode" maxlength="8" autocomplete="off" placeholder="请输入验证码" @keyup.enter="submit" />
|
||||
<button type="button" class="login-captcha-image" :disabled="captchaLoading" aria-label="刷新图形验证码" @click="loadCaptcha">
|
||||
<img v-if="captchaImage" :src="captchaImage" alt="图形验证码" />
|
||||
<span v-else>{{ captchaLoading ? "加载中" : "点击刷新" }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-checkbox v-model="form.rememberMe" class="login-remember">记住密码</el-checkbox>
|
||||
<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>
|
||||
|
||||
@@ -1 +1,10 @@
|
||||
<template><el-result icon="warning" title="页面不存在" sub-title="请从左侧菜单重新进入"><template #extra><el-button type="primary" @click="$router.push('/dashboard')">返回仪表盘</el-button></template></el-result></template>
|
||||
<script setup lang="ts">
|
||||
import { WarningFilled } from "@element-plus/icons-vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-result title="页面不存在" sub-title="请从左侧菜单重新进入">
|
||||
<template #icon><WarningFilled /></template>
|
||||
<template #extra><el-button type="primary" @click="$router.push('/dashboard')">返回仪表盘</el-button></template>
|
||||
</el-result>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { CircleCloseFilled } from "@element-plus/icons-vue";
|
||||
import {
|
||||
createDetail,
|
||||
createWanfaCategory,
|
||||
@@ -243,7 +244,7 @@ onMounted(loadData);
|
||||
</div>
|
||||
|
||||
<el-skeleton v-if="loading" :rows="10" animated />
|
||||
<el-result v-else-if="error" icon="error" title="玩法与详情加载失败" :sub-title="error"><template #extra><el-button type="primary" @click="loadData">重试</el-button></template></el-result>
|
||||
<el-result v-else-if="error" title="玩法与详情加载失败" :sub-title="error"><template #icon><CircleCloseFilled /></template><template #extra><el-button type="primary" @click="loadData">重试</el-button></template></el-result>
|
||||
<el-empty v-else-if="!categories.length" description="暂无玩法分类"><el-button v-permission="'admin:wanfa:read'" type="primary" @click="openCategoryCreate">创建第一条分类</el-button></el-empty>
|
||||
|
||||
<div v-else class="wanfa-workspace">
|
||||
|
||||
@@ -41,10 +41,10 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
async function login(email: string, password: string, captchaId: string, captchaCode: string, rememberMe = false) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const session = await api.login(email, password);
|
||||
const session = await api.login(email, password, captchaId, captchaCode, rememberMe);
|
||||
api.setAccessToken(session.accessToken || session.token);
|
||||
user.value = session.user;
|
||||
await loadProfile();
|
||||
|
||||
@@ -262,6 +262,7 @@ button, input { font: inherit; }
|
||||
.sidebar-menu .el-menu--inline { background: transparent !important; }
|
||||
.sidebar-item__content { display: inline-flex; width: 100%; align-items: center; gap: 12px; }
|
||||
.sidebar-item__content svg { flex: 0 0 auto; }
|
||||
.layout-icon { display: block; flex: 0 0 auto; vertical-align: middle; }
|
||||
.sidebar-item__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.el-menu--collapse .sidebar-item__content { justify-content: center; }
|
||||
|
||||
@@ -349,6 +350,12 @@ button, input { font: inherit; }
|
||||
.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-remember { margin: -4px 0 12px; }
|
||||
.login-captcha-row { display: grid; grid-template-columns: minmax(0, 1fr) 160px; gap: 10px; align-items: center; width: 100%; }
|
||||
.login-captcha-image { display: flex; width: 160px; height: 32px; align-items: center; justify-content: center; padding: 0; overflow: hidden; border: 1px solid #dcdfe6; border-radius: 4px; color: #909399; background: #f5f7fa; cursor: pointer; }
|
||||
.login-captcha-image:hover:not(:disabled), .login-captcha-image:focus-visible { border-color: var(--primary-color); }
|
||||
.login-captcha-image:disabled { cursor: wait; opacity: .7; }
|
||||
.login-captcha-image img { display: block; width: 160px; height: 48px; object-fit: cover; }
|
||||
.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%; } .advisor-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 640px) { .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; } }
|
||||
@@ -117,6 +117,13 @@ export type AuthPayload = {
|
||||
user: AdminUser;
|
||||
};
|
||||
|
||||
export type CaptchaPayload = {
|
||||
captchaEnabled: boolean;
|
||||
captchaId: string;
|
||||
image: string;
|
||||
expiresIn: number;
|
||||
};
|
||||
|
||||
export type Dashboard = {
|
||||
stats: { newLeadCount: number; leadCount: number };
|
||||
recentLeads: Lead[];
|
||||
|
||||
Reference in new issue
Block a user