feat: 添加后台登录验证码、记住密码功能,优化媒体资源与前端规范

- 新增后台登录图形验证码功能,完善登录安全防护
- 新增登录rememberMe参数,控制Refresh Token的会话持久化策略
- 实现OSS私有桶媒体URL自动签名,统一处理图片资源的临时访问签名
- 新增素材库数据库表与上传API,规范媒体资源管理流程
- 统一前端UI图标使用@element-plus/icons-vue,重构布局图标组件
- 登录页新增验证码输入、刷新功能,添加账号记忆与记住密码逻辑
- 更新全套文档,补充API契约、技术决策记录与集成流程说明
- 修复多个业务页面的图标展示问题,新增认证流程相关测试用例
This commit is contained in:
duanshuwen committed 2026-08-27 07:50:59 +08:00
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 }) };
}
+3 -1
View File
@@ -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/);
});
});
+2 -1
View File
@@ -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="顶部轮播" />
+37 -3
View File
@@ -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>
+2 -1
View File
@@ -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">
+2 -2
View File
@@ -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();
+7
View File
@@ -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; } }
+7
View File
@@ -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[];