做几方面优化
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<el-aside :width="width" class="common-aside">
|
||||
<el-aside
|
||||
:width="width"
|
||||
:class="['common-aside', { 'is-collapsed': isCollapse }]"
|
||||
>
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-spinner">
|
||||
<i class="el-icon-loading" style="font-size: 24px; color: #fff"></i>
|
||||
@@ -12,9 +15,12 @@
|
||||
<el-button size="small" @click="fetchMenus">重新加载</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 菜单标题:放在 el-menu 之外,避免随菜单一起滚动消失 -->
|
||||
<h3 v-if="showMenu">{{ isCollapse ? "管理" : asideTitle }}</h3>
|
||||
|
||||
<!-- 菜单主体 -->
|
||||
<el-menu
|
||||
v-else
|
||||
v-if="showMenu"
|
||||
:collapse="isCollapse"
|
||||
:collapse-transition="false"
|
||||
:background-color="asideBgColor"
|
||||
@@ -22,13 +28,11 @@
|
||||
:active-text-color="activeColor"
|
||||
:active-background-color="activeBgColor"
|
||||
class="el-menu-vertical-demo"
|
||||
popper-class="common-aside-popper"
|
||||
:unique-opened="true"
|
||||
@select="handleMenuSelect"
|
||||
:default-active="route.path"
|
||||
>
|
||||
<!-- 菜单标题 -->
|
||||
<h3>{{ isCollapse ? "管理" : asideTitle }}</h3>
|
||||
|
||||
<!-- 无模块时显示提示 -->
|
||||
<el-menu-item v-if="!currentModule" index="/home">
|
||||
<i class="fa-solid fa-house"></i>
|
||||
@@ -55,6 +59,7 @@
|
||||
v-else
|
||||
:index="item.path || item.id.toString()"
|
||||
:unique-opened="true"
|
||||
teleported
|
||||
:class="{ 'is-locked': item.locked }"
|
||||
>
|
||||
<template #title>
|
||||
@@ -81,6 +86,7 @@
|
||||
v-else
|
||||
:index="child.path || child.id.toString()"
|
||||
:unique-opened="true"
|
||||
teleported
|
||||
:class="{ 'is-locked': child.locked }"
|
||||
>
|
||||
<template #title>
|
||||
@@ -116,6 +122,7 @@
|
||||
v-else
|
||||
:index="grandchild.path || grandchild.id.toString()"
|
||||
:unique-opened="true"
|
||||
teleported
|
||||
:class="{ 'is-locked': grandchild.locked }"
|
||||
>
|
||||
<template #title>
|
||||
@@ -177,7 +184,9 @@ const errorMsg = computed(() => menuStore.error || "加载菜单失败");
|
||||
|
||||
const store = useAllDataStore();
|
||||
const isCollapse = computed(() => store.state.isCollapse);
|
||||
const width = computed(() => (store.state.isCollapse ? "64px" : "200px"));
|
||||
const width = computed(() => (store.state.isCollapse ? "64px" : "220px"));
|
||||
// 菜单与标题的显示条件(与加载/错误分支互斥,避免 v-else 绑定错元素)
|
||||
const showMenu = computed(() => !loading.value && !(hasError.value && list.value.length === 0));
|
||||
|
||||
const asideBgColor = ref("#304156");
|
||||
const asideTextColor = ref("#bfcbd9");
|
||||
@@ -508,7 +517,8 @@ h3 {
|
||||
// 菜单样式
|
||||
:deep(.el-menu) {
|
||||
border-right: none;
|
||||
height: calc(100% - 80px);
|
||||
// 标题已移到 el-menu 之外,这里只减掉标题区高度
|
||||
height: calc(100% - 60px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px 8px;
|
||||
@@ -577,14 +587,14 @@ h3 {
|
||||
}
|
||||
}
|
||||
|
||||
// 悬浮样式
|
||||
// 悬浮样式:一律用蓝色系,避免 EP 默认的深灰/黑色 hover
|
||||
.el-menu-item:hover:not(.is-active),
|
||||
.el-sub-menu__title:hover {
|
||||
html:not(.dark) & {
|
||||
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||
background-color: rgba(30, 86, 210, 0.5) !important;
|
||||
}
|
||||
html.dark & {
|
||||
background-color: rgba(60, 60, 60, 0.8) !important;
|
||||
background-color: rgba(57, 115, 255, 0.45) !important;
|
||||
}
|
||||
color: #ffffff !important;
|
||||
}
|
||||
@@ -649,12 +659,54 @@ h3 {
|
||||
|
||||
.el-menu-item:hover:not(.is-active),
|
||||
.el-sub-menu__title:hover {
|
||||
background: rgba(255, 255, 255, 0.08) !important;
|
||||
color: var(--el-color-primary-light-3) !important;
|
||||
background: rgba(57, 115, 255, 0.45) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 折叠态修复
|
||||
// 折叠(isCollapse)时 Element Plus 有一套自己的布局,下面逐条修正显示问题:
|
||||
// 1) EP 会把 .el-menu 强制成 64px,而 el-aside 的宽度有 0.3s 动画,
|
||||
// 两者不同步会导致收起瞬间菜单内容先跳变、右侧露出空白 → 改回 100% 跟随动画
|
||||
// 2) 菜单项保留默认左右内边距 + 图标右外边距,在 64px 宽下图标会偏左 → 归零并居中
|
||||
// 3) 激活项的 -3px 左移补偿在折叠态会把图标顶偏 → 折叠时取消
|
||||
// 4) 64px 宽的栏里再放 4px 滚动条很突兀 → 折叠时隐藏(仍可滚动)
|
||||
:deep(.el-menu.el-menu--collapse) {
|
||||
width: 100% !important;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.el-menu-item,
|
||||
.el-sub-menu > .el-sub-menu__title {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.el-menu-item.is-active {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 折叠后标题区收紧,避免 64px 下文字贴边
|
||||
.common-aside.is-collapsed h3 {
|
||||
font-size: 15px;
|
||||
padding: 0 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
// 注意:手机端(≤768px)侧栏的抽屉式布局由 Main.vue 统一控制(fixed 定位 + transform 动画),
|
||||
// 此处不要再对 .common-aside 设置 width,避免覆盖父级布局逻辑。
|
||||
@@ -664,3 +716,75 @@ h3 {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
// 折叠后 hover 弹出的子菜单浮层被 teleport 到 body,scoped 样式命中不了,
|
||||
// 只能用 popper-class + 全局样式配色,否则会是 Element Plus 默认的白底浮层
|
||||
.common-aside-popper.el-popper {
|
||||
background: #3973ff !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14) !important;
|
||||
box-shadow: 0 8px 24px rgba(6, 45, 163, 0.28);
|
||||
|
||||
.el-popper__arrow::before {
|
||||
background: #3973ff !important;
|
||||
border-color: rgba(255, 255, 255, 0.14) !important;
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
background: transparent !important;
|
||||
padding: 4px;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
// 菜单项与"还有下级"的父级标题统一配色,避免 EP 默认的深灰/黑色 hover
|
||||
.el-menu-item {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(30, 86, 210, 0.55) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: rgba(30, 86, 210, 0.7) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-sub-menu__title {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
border-radius: 8px;
|
||||
background: transparent !important;
|
||||
|
||||
&:hover {
|
||||
background: rgba(30, 86, 210, 0.55) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 折叠态下 EP 用 ArrowRight 表示"还有下级",但展开时会内联 rotateZ(180deg) 把箭头翻成向左;
|
||||
// 浮层里的子菜单应当始终指向右(表示继续向右展开),故强制不旋转(内联样式需 !important 覆盖)
|
||||
.el-sub-menu__icon-arrow {
|
||||
transform: rotateZ(0deg) !important;
|
||||
}
|
||||
|
||||
// 浮层宽度收敛,降低深层级因空间不足被 popper 翻转到左侧的概率
|
||||
.el-menu {
|
||||
min-width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark {
|
||||
.common-aside-popper.el-popper {
|
||||
background: #2d2d2d !important;
|
||||
border-color: rgba(255, 255, 255, 0.1) !important;
|
||||
|
||||
.el-popper__arrow::before {
|
||||
background: #2d2d2d !important;
|
||||
border-color: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -171,6 +171,22 @@ export async function logoutSSO() {
|
||||
window.location.href = `${AUTH_BASE}/logout?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取「当前登录的那一家企业」的名称。
|
||||
*
|
||||
* userinfo 的 tenants 是该账号可进入的全部企业,只能用于做企业切换列表;
|
||||
* 顶部展示必须是当前会话选中的企业(tid),不能把全部企业名拼接。
|
||||
* 取值顺序:后端 tenant_name → 按 tid 在 tenants 中匹配 → 兜底第一家。
|
||||
*/
|
||||
export function resolveTenantName(data) {
|
||||
if (!data) return '';
|
||||
if (data.tenant_name) return data.tenant_name;
|
||||
const list = data.tenants || [];
|
||||
const hit = list.find((t) => String(t.tid) === String(data.tid));
|
||||
if (hit) return hit.tenant_name || '';
|
||||
return list.length ? (list[0].tenant_name || '') : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保本地存在完整的用户信息(含 id / group_id)。
|
||||
*
|
||||
@@ -199,7 +215,7 @@ export async function ensureUserInfo() {
|
||||
name: data.name || data.nickname || '',
|
||||
group_id: data.group_id || '',
|
||||
tid: data.tid || '',
|
||||
tenant_name: (data.tenants || []).map((t) => t.tenant_name).join('、'),
|
||||
tenant_name: resolveTenantName(data),
|
||||
avatar: data.avatar || '',
|
||||
type: 'backend'
|
||||
};
|
||||
@@ -235,5 +251,16 @@ export async function switchTenant(tid) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.code !== 200) throw new Error(data.msg || "切换企业失败");
|
||||
if (data.data?.tokens) setTokens(data.data.tokens);
|
||||
// 切换后同步本地缓存的企业信息,避免顶部仍显示切换前的企业名
|
||||
try {
|
||||
const info = await fetchUserInfo();
|
||||
const raw = localStorage.getItem('userInfo');
|
||||
const merged = raw ? JSON.parse(raw) : {};
|
||||
merged.tid = info.tid || tid;
|
||||
merged.tenant_name = resolveTenantName(info);
|
||||
localStorage.setItem('userInfo', JSON.stringify(merged));
|
||||
} catch (e) {
|
||||
// 同步失败不阻断切换结果
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
@@ -82,14 +82,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建 / 编辑 -->
|
||||
<el-dialog
|
||||
<!-- 新建 / 编辑:右侧滑出抽屉,纵向空间比居中弹窗更充足 -->
|
||||
<el-drawer
|
||||
v-model="dialogVisible"
|
||||
:title="editing ? '编辑模板' : '新建模板'"
|
||||
width="820px"
|
||||
size="min(1200px, 92vw)"
|
||||
:close-on-click-modal="false"
|
||||
class="template-drawer"
|
||||
@closed="editorReady = false"
|
||||
>
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form :model="form" label-width="100px" class="template-form">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="模板名称" required>
|
||||
@@ -130,15 +132,19 @@
|
||||
<el-form-item label="模板描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="模板适用场景说明" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模板正文">
|
||||
<el-input v-model="form.content" type="textarea" :rows="12" placeholder="标书模板内容" />
|
||||
<el-form-item label="模板正文" class="editor-form-item">
|
||||
<div class="template-editor-box">
|
||||
<UmoEditor v-if="editorReady" v-model="form.content" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
<div class="drawer-footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 预览 -->
|
||||
<el-dialog v-model="previewVisible" :title="previewRow?.template_name || '模板预览'" width="800px">
|
||||
@@ -150,7 +156,13 @@
|
||||
<el-descriptions-item label="引用次数">{{ previewRow?.use_count || 0 }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-divider />
|
||||
<div class="preview-content">{{ previewRow?.content || "(空)" }}</div>
|
||||
<!-- 富文本内容按 HTML 渲染;旧数据为纯文本时保持原样换行展示 -->
|
||||
<div
|
||||
v-if="isRichText(previewRow?.content)"
|
||||
class="preview-content is-rich"
|
||||
v-html="previewRow.content"
|
||||
></div>
|
||||
<div v-else class="preview-content">{{ previewRow?.content || "(空)" }}</div>
|
||||
<template v-if="previewRow?.file_url">
|
||||
<el-divider />
|
||||
<el-link :href="previewRow.file_url" target="_blank" type="primary">打开模板文件</el-link>
|
||||
@@ -160,7 +172,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { ref, reactive, onMounted, nextTick } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Search, Refresh, RefreshLeft } from "@element-plus/icons-vue";
|
||||
import {
|
||||
@@ -170,6 +182,7 @@ import {
|
||||
deleteTemplate,
|
||||
} from "@/api/crmBidding";
|
||||
import { bidTemplateTypeOptions, templateTypeText } from "@/views/apps/crm/dict";
|
||||
import UmoEditor from "@/views/components/UmoEditor.vue";
|
||||
|
||||
const loading = ref(false);
|
||||
const tableData = ref([]);
|
||||
@@ -181,6 +194,8 @@ const previewVisible = ref(false);
|
||||
const previewRow = ref(null);
|
||||
const editing = ref(null);
|
||||
const submitting = ref(false);
|
||||
// UmoEditor 只在创建时读取一次初始值,故每次打开弹窗重建实例以保证载入当前内容
|
||||
const editorReady = ref(false);
|
||||
|
||||
const emptyForm = () => ({
|
||||
template_name: "",
|
||||
@@ -225,10 +240,19 @@ function resetFilters() {
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
// 每次打开都重建编辑器实例,避免残留上一条模板的内容
|
||||
function openEditorDialog() {
|
||||
editorReady.value = false;
|
||||
dialogVisible.value = true;
|
||||
nextTick(() => {
|
||||
editorReady.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null;
|
||||
Object.assign(form, emptyForm());
|
||||
dialogVisible.value = true;
|
||||
openEditorDialog();
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
@@ -242,7 +266,7 @@ function openEdit(row) {
|
||||
file_url: row.file_url || "",
|
||||
status: Number(row.status) || 1,
|
||||
});
|
||||
dialogVisible.value = true;
|
||||
openEditorDialog();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
@@ -288,11 +312,70 @@ function openPreview(row) {
|
||||
previewRow.value = row;
|
||||
previewVisible.value = true;
|
||||
}
|
||||
|
||||
// 判断内容是否为富文本 HTML(旧数据由纯文本 textarea 保存,需保持原样展示)
|
||||
function isRichText(content) {
|
||||
return /<(p|div|br|h[1-6]|ul|ol|li|table|img|strong|b|span)\b[\s\S]*>/i.test(content || "");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped src="../../styles/crm-page.less"></style>
|
||||
|
||||
<style scoped>
|
||||
/* 抽屉主体改为纵向 flex:表单撑满,正文编辑区吃掉剩余高度(真正的自适应) */
|
||||
.template-drawer :deep(.el-drawer__body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.template-form {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 正文字段吃掉表单剩余高度,内容区同样撑满 */
|
||||
.editor-form-item {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
:deep(.el-form-item__content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* umo-editor 以 100% 填充父容器;宽度锁死为容器宽度,杜绝横向拉伸 */
|
||||
.template-editor-box {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.template-editor-box :deep(.umo-editor),
|
||||
.template-editor-box :deep(umo-editor) {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
@@ -300,4 +383,25 @@ function openPreview(row) {
|
||||
font-size: 14px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
/* 富文本预览:按 HTML 结构展示,图片自适应 */
|
||||
.preview-content.is-rich {
|
||||
white-space: normal;
|
||||
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
:deep(table) {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #dcdfe6;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
handleAuthorizeCallback,
|
||||
redirectToAuthorize,
|
||||
fetchUserInfo,
|
||||
resolveTenantName,
|
||||
} from "@/utils/authClient";
|
||||
|
||||
const error = ref("");
|
||||
@@ -38,7 +39,8 @@ async function saveUserInfo() {
|
||||
name: info.name || info.nickname || "",
|
||||
group_id: info.group_id || "",
|
||||
tid: info.tid || "",
|
||||
tenant_name: (info.tenants || []).map((t) => t.tenant_name).join("、"),
|
||||
// 顶部展示的是当前登录的企业,不是该账号可进入的全部企业
|
||||
tenant_name: resolveTenantName(info),
|
||||
avatar: info.avatar || "",
|
||||
type: "backend",
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="umo-editor-wrapper">
|
||||
<div class="umo-editor-wrapper" :style="wrapperStyle">
|
||||
<umo-editor
|
||||
ref="editorRef"
|
||||
v-bind="options"
|
||||
@@ -11,7 +11,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { UmoEditor } from '@umoteam/editor'
|
||||
import '@umoteam/editor/style'
|
||||
import { uploadFile } from '@/api/file'
|
||||
@@ -21,6 +21,16 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// 编辑器高度:默认填满父容器(父容器需有确定高度);
|
||||
// 传 'auto' 时由 minHeight 兜底,配合父容器自适应高度
|
||||
height: {
|
||||
type: String,
|
||||
default: '100%',
|
||||
},
|
||||
minHeight: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
@@ -29,6 +39,11 @@ const content = ref(props.modelValue)
|
||||
const editorRef = ref(null)
|
||||
const isCreated = ref(false)
|
||||
|
||||
const wrapperStyle = computed(() => ({
|
||||
height: props.height,
|
||||
minHeight: props.minHeight ? props.minHeight + 'px' : '',
|
||||
}))
|
||||
|
||||
const options = {
|
||||
height: '100%',
|
||||
// umo-editor 会定时触发自动保存(onSave),内容已随表单统一提交,这里给空实现避免控制台报错
|
||||
@@ -77,10 +92,13 @@ watch(
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 注意:必须是 :deep,写成 ::deep 会被浏览器当作无效选择器丢弃,
|
||||
导致宽度限制失效、编辑器被横向拉伸出滚动条 */
|
||||
:deep(.umo-editor),
|
||||
:deep(umo-editor) {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -81,6 +81,12 @@ func (c *AuthLoginController) LoginSubmit() {
|
||||
ClientID string `json:"client_id"`
|
||||
Redirect string `json:"redirect"`
|
||||
DeviceID string `json:"device_id"`
|
||||
// 极验 4.0 验证结果:开启人机验证后由前端 SDK 产出,验证通过才允许继续登录
|
||||
CaptchaID string `json:"captcha_id"`
|
||||
LotNumber string `json:"lot_number"`
|
||||
PassToken string `json:"pass_token"`
|
||||
GenTime string `json:"gen_time"`
|
||||
CaptchaOutput string `json:"captcha_output"`
|
||||
}
|
||||
|
||||
body := c.Ctx.Input.RequestBody
|
||||
@@ -101,6 +107,18 @@ func (c *AuthLoginController) LoginSubmit() {
|
||||
return
|
||||
}
|
||||
|
||||
// 开启人机验证时:先极验通过,再走账号密码校验
|
||||
if err := authsvc.VerifyLoginCaptcha(0, authsvc.CaptchaResult{
|
||||
CaptchaID: req.CaptchaID,
|
||||
LotNumber: req.LotNumber,
|
||||
PassToken: req.PassToken,
|
||||
GenTime: req.GenTime,
|
||||
CaptchaOutput: req.CaptchaOutput,
|
||||
}); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
clientIP := c.Ctx.Input.IP()
|
||||
userAgent := c.Ctx.Request.UserAgent()
|
||||
|
||||
@@ -309,28 +327,31 @@ func (c *AuthLoginController) LogoutAction() {
|
||||
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已登出"})
|
||||
}
|
||||
|
||||
// VerifyConfig 租户登录验证配置(替代 /backend/login/getOpenVerify)
|
||||
// VerifyConfig 登录验证配置(替代 /backend/login/getOpenVerify)
|
||||
// GET /auth/verify-config?tid=
|
||||
//
|
||||
// 登录页在选企业之前调用,tid 可为空:此时以平台全局配置为准。
|
||||
// 返回的人机验证信息供登录页决定是否在点击登录后弹出极验。
|
||||
func (c *AuthLoginController) VerifyConfig() {
|
||||
tid, _ := c.GetInt64("tid", 0)
|
||||
cfg := authsvc.GetTenantSessionPolicy(uint64(tid))
|
||||
sessionCfg := authsvc.GetTenantSessionPolicy(uint64(tid))
|
||||
verify := authsvc.GetLoginVerifyConfig(uint64(tid))
|
||||
|
||||
var authCfg models.AuthTenantAuthConfig
|
||||
verifyType := "captcha"
|
||||
openVerify := 1
|
||||
if err := models.Orm.QueryTable(new(models.AuthTenantAuthConfig)).
|
||||
Filter("tid", tid).One(&authCfg); err == nil {
|
||||
verifyType = authCfg.VerifyType
|
||||
openVerify = int(authCfg.OpenVerify)
|
||||
openVerify := 0
|
||||
if verify.OpenVerify {
|
||||
openVerify = 1
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"data": map[string]interface{}{
|
||||
"openVerify": openVerify,
|
||||
"verifyType": verifyType,
|
||||
"sessionTTL": cfg.SessionTTL,
|
||||
"maxSession": cfg.MaxSession,
|
||||
"mfaRequired": 0,
|
||||
"openVerify": openVerify,
|
||||
"verifyType": verify.VerifyType,
|
||||
"needGeetest": verify.NeedGeetest(),
|
||||
"geetestVersion": verify.GeetestVersion,
|
||||
"captchaId": verify.CaptchaID,
|
||||
"sessionTTL": sessionCfg.SessionTTL,
|
||||
"maxSession": sessionCfg.MaxSession,
|
||||
"mfaRequired": 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -282,11 +282,22 @@ func (c *AuthOidcController) UserInfo() {
|
||||
name = profile.Nickname
|
||||
}
|
||||
|
||||
// 当前会话所在企业名称(一人多企业时必须是"已选中的那一家",
|
||||
// 而非全部可进入企业;前端直接展示该字段,避免自行拼接 tenants)
|
||||
tenantName := ""
|
||||
for _, t := range profile.Tenants {
|
||||
if t.Tid == uint64(claims.TenantId) {
|
||||
tenantName = t.TenantName
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"sub": fmt.Sprintf("%d", identity.ID),
|
||||
"id": identity.ID,
|
||||
"union_id": identity.UnionID,
|
||||
"tid": claims.TenantId,
|
||||
"sub": fmt.Sprintf("%d", identity.ID),
|
||||
"id": identity.ID,
|
||||
"union_id": identity.UnionID,
|
||||
"tid": claims.TenantId,
|
||||
"tenant_name": tenantName,
|
||||
// 诊断用:业务接口按 user_type 判定权限(backend / app),
|
||||
// 出现「无权访问」时可先看这里的值是否正确
|
||||
"user_type": claims.UserType,
|
||||
|
||||
@@ -1,105 +1,121 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
type PlatformLoginVerifyController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
type loginVerifyPayload struct {
|
||||
OpenVerifyEnabled *int8 `json:"openVerify_enabled"`
|
||||
VerifyType string `json:"use_geetest"`
|
||||
Geetest3ID *string `json:"geetest3_id"`
|
||||
Geetest3Key *string `json:"geetest3_key"`
|
||||
Geetest4ID *string `json:"geetest4_id"`
|
||||
Geetest4Key *string `json:"geetest4_key"`
|
||||
}
|
||||
|
||||
func normalizeVerifyType(v string) string {
|
||||
switch strings.TrimSpace(v) {
|
||||
case "sms", "geetest", "email", "captcha":
|
||||
return strings.TrimSpace(v)
|
||||
default:
|
||||
return "captcha"
|
||||
}
|
||||
}
|
||||
|
||||
// GetLoginVerifyInfos 获取登录验证配置
|
||||
// GET /platform/loginVerifyInfos
|
||||
func (c *PlatformLoginVerifyController) GetLoginVerifyInfos() {
|
||||
cfg, err := models.GetPlatformLoginVerify()
|
||||
if err != nil {
|
||||
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "获取配置失败"}
|
||||
_ = c.ServeJSON()
|
||||
return
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"openVerify_enabled": cfg.OpenVerifyEnabled,
|
||||
"use_geetest": cfg.VerifyType,
|
||||
"geetest3_id": cfg.Geetest3ID,
|
||||
"geetest3_key": cfg.Geetest3Key,
|
||||
"geetest4_id": cfg.Geetest4ID,
|
||||
"geetest4_key": cfg.Geetest4Key,
|
||||
},
|
||||
}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// SaveLoginVerifyInfos 保存登录验证配置
|
||||
// POST /platform/saveloginVerifyInfos
|
||||
func (c *PlatformLoginVerifyController) SaveLoginVerifyInfos() {
|
||||
var p loginVerifyPayload
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "参数错误"}
|
||||
_ = c.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
verifyType := normalizeVerifyType(p.VerifyType)
|
||||
openVerifyEnabled := int8(1)
|
||||
if p.OpenVerifyEnabled != nil {
|
||||
openVerifyEnabled = *p.OpenVerifyEnabled
|
||||
}
|
||||
if verifyType == "geetest" {
|
||||
if p.Geetest4ID == nil || strings.TrimSpace(*p.Geetest4ID) == "" {
|
||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "geetest4_id 不能为空"}
|
||||
_ = c.ServeJSON()
|
||||
return
|
||||
}
|
||||
if p.Geetest4Key == nil || strings.TrimSpace(*p.Geetest4Key) == "" {
|
||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "geetest4_key 不能为空"}
|
||||
_ = c.ServeJSON()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err := models.SavePlatformLoginVerify(&models.PlatformLoginVerify{
|
||||
OpenVerifyEnabled: openVerifyEnabled,
|
||||
VerifyType: verifyType,
|
||||
Geetest3ID: p.Geetest3ID,
|
||||
Geetest3Key: p.Geetest3Key,
|
||||
Geetest4ID: p.Geetest4ID,
|
||||
Geetest4Key: p.Geetest4Key,
|
||||
})
|
||||
if err != nil {
|
||||
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "保存失败"}
|
||||
_ = c.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "保存成功"}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
authsvc "server/services/auth"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
type PlatformLoginVerifyController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
type loginVerifyPayload struct {
|
||||
OpenVerifyEnabled *int8 `json:"openVerify_enabled"`
|
||||
VerifyType string `json:"use_geetest"`
|
||||
Geetest3ID *string `json:"geetest3_id"`
|
||||
Geetest3Key *string `json:"geetest3_key"`
|
||||
Geetest4ID *string `json:"geetest4_id"`
|
||||
Geetest4Key *string `json:"geetest4_key"`
|
||||
}
|
||||
|
||||
func normalizeVerifyType(v string) string {
|
||||
switch strings.TrimSpace(v) {
|
||||
case "sms", "geetest", "email", "captcha":
|
||||
return strings.TrimSpace(v)
|
||||
default:
|
||||
return "captcha"
|
||||
}
|
||||
}
|
||||
|
||||
// GetLoginVerifyInfos 获取登录验证配置
|
||||
// GET /platform/loginVerifyInfos
|
||||
func (c *PlatformLoginVerifyController) GetLoginVerifyInfos() {
|
||||
cfg, err := models.GetPlatformLoginVerify()
|
||||
if err != nil {
|
||||
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "获取配置失败"}
|
||||
_ = c.ServeJSON()
|
||||
return
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"openVerify_enabled": cfg.OpenVerifyEnabled,
|
||||
"use_geetest": cfg.VerifyType,
|
||||
"geetest3_id": cfg.Geetest3ID,
|
||||
"geetest3_key": cfg.Geetest3Key,
|
||||
"geetest4_id": cfg.Geetest4ID,
|
||||
"geetest4_key": cfg.Geetest4Key,
|
||||
},
|
||||
}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// GeetestCheck 极验配置自检:判断服务器能否访问极验、captcha_key 是否与 captcha_id 匹配
|
||||
// GET /platform/geetestCheck
|
||||
func (c *PlatformLoginVerifyController) GeetestCheck() {
|
||||
diag := authsvc.DiagnoseGeetest4()
|
||||
code := 200
|
||||
if ok, _ := diag["ok"].(bool); !ok {
|
||||
code = 500
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": code,
|
||||
"msg": diag["msg"],
|
||||
"data": diag,
|
||||
}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// SaveLoginVerifyInfos 保存登录验证配置
|
||||
// POST /platform/saveloginVerifyInfos
|
||||
func (c *PlatformLoginVerifyController) SaveLoginVerifyInfos() {
|
||||
var p loginVerifyPayload
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "参数错误"}
|
||||
_ = c.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
verifyType := normalizeVerifyType(p.VerifyType)
|
||||
openVerifyEnabled := int8(1)
|
||||
if p.OpenVerifyEnabled != nil {
|
||||
openVerifyEnabled = *p.OpenVerifyEnabled
|
||||
}
|
||||
if verifyType == "geetest" {
|
||||
if p.Geetest4ID == nil || strings.TrimSpace(*p.Geetest4ID) == "" {
|
||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "geetest4_id 不能为空"}
|
||||
_ = c.ServeJSON()
|
||||
return
|
||||
}
|
||||
if p.Geetest4Key == nil || strings.TrimSpace(*p.Geetest4Key) == "" {
|
||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "geetest4_key 不能为空"}
|
||||
_ = c.ServeJSON()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err := models.SavePlatformLoginVerify(&models.PlatformLoginVerify{
|
||||
OpenVerifyEnabled: openVerifyEnabled,
|
||||
VerifyType: verifyType,
|
||||
Geetest3ID: p.Geetest3ID,
|
||||
Geetest3Key: p.Geetest3Key,
|
||||
Geetest4ID: p.Geetest4ID,
|
||||
Geetest4Key: p.Geetest4Key,
|
||||
})
|
||||
if err != nil {
|
||||
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "保存失败"}
|
||||
_ = c.ServeJSON()
|
||||
return
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "保存成功"}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ func Register() {
|
||||
beego.Router("/platform/login/getOpenVerify", &controllers.PlatformAuthController{}, "get:GetOpenVerify")
|
||||
beego.Router("/platform/loginVerifyInfos", &controllers.PlatformLoginVerifyController{}, "get:GetLoginVerifyInfos")
|
||||
beego.Router("/platform/saveloginVerifyInfos", &controllers.PlatformLoginVerifyController{}, "post:SaveLoginVerifyInfos")
|
||||
beego.Router("/platform/geetestCheck", &controllers.PlatformLoginVerifyController{}, "get:GeetestCheck")
|
||||
|
||||
// 存储配置
|
||||
beego.Router("/platform/storageConfig", &controllers.StorageConfigController{}, "get:GetStorageConfig")
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/core/logs"
|
||||
)
|
||||
|
||||
// 人机验证方式(与平台登录验证配置 login_verify_type 保持一致)
|
||||
const (
|
||||
VerifyTypeCaptcha = "captcha" // 本地图形验证码
|
||||
VerifyTypeSMS = "sms" // 短信验证码
|
||||
VerifyTypeEmail = "email" // 邮箱验证码
|
||||
VerifyTypeGeetest = "geetest" // 极验 4.0
|
||||
VerifyTypeGeetest4 = "geetest4" // 极验 4.0
|
||||
VerifyTypeGeetest3 = "geetest3" // 极验 3.0
|
||||
)
|
||||
|
||||
// geetest4ValidateURL 极验 4.0 服务端二次校验地址
|
||||
const geetest4ValidateURL = "https://gcaptcha4.geetest.com/validate"
|
||||
|
||||
// 人机验证失败类别
|
||||
const (
|
||||
captchaFailRequired = "required" // 未完成验证 / 参数缺失
|
||||
captchaFailFailed = "failed" // 极验服务端判定未通过
|
||||
captchaFailService = "service" // 极验校验服务不可用(网络/超时/响应异常)
|
||||
captchaFailNotSet = "notset" // 未配置极验
|
||||
)
|
||||
|
||||
// captchaError 人机验证失败,msg 可直接展示给用户
|
||||
type captchaError struct {
|
||||
kind string
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *captchaError) Error() string { return e.msg }
|
||||
|
||||
// 人机验证错误
|
||||
var (
|
||||
ErrCaptchaRequired = errors.New("请先完成人机验证")
|
||||
ErrCaptchaFailed = errors.New("人机验证未通过,请重新验证")
|
||||
ErrCaptchaNotSet = errors.New("人机验证未配置,请联系管理员")
|
||||
)
|
||||
|
||||
// LoginVerifyConfig 登录页使用的人机验证配置
|
||||
type LoginVerifyConfig struct {
|
||||
OpenVerify bool `json:"openVerify"` // 是否开启登录验证
|
||||
VerifyType string `json:"verifyType"` // captcha/sms/email/geetest/geetest4/geetest3
|
||||
GeetestVersion string `json:"geetestVersion"` // 极验版本:4 / 3
|
||||
CaptchaID string `json:"captchaId"` // 极验 captcha_id,未配置时为空
|
||||
|
||||
geetestKey string // 服务端校验密钥,不下发给前端
|
||||
}
|
||||
|
||||
// NeedGeetest 是否需要弹出极验。
|
||||
// 目前认证中心只接入了极验 4.0(gt4),极验 3.0 待接入后再开启强制校验。
|
||||
func (c LoginVerifyConfig) NeedGeetest() bool {
|
||||
if !c.OpenVerify {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(c.VerifyType)) {
|
||||
case VerifyTypeGeetest, VerifyTypeGeetest4:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// GetLoginVerifyConfig 读取登录验证配置。
|
||||
//
|
||||
// 登录发生在选择企业之前,租户未知,因此以平台全局配置为准;
|
||||
// 若传入 tid 且该租户单独配置了验证方式,则用租户配置覆盖开关与类型,
|
||||
// 极验参数(captcha_id / key)始终取全局配置。
|
||||
func GetLoginVerifyConfig(tid uint64) LoginVerifyConfig {
|
||||
cfg := LoginVerifyConfig{VerifyType: VerifyTypeCaptcha}
|
||||
|
||||
openVerify := false
|
||||
verifyType := VerifyTypeCaptcha
|
||||
var g3ID, g3Key, g4ID, g4Key string
|
||||
|
||||
if global, err := models.GetPlatformLoginVerify(); err == nil && global != nil {
|
||||
openVerify = global.OpenVerifyEnabled == 1
|
||||
if t := strings.ToLower(strings.TrimSpace(global.VerifyType)); t != "" {
|
||||
verifyType = t
|
||||
}
|
||||
if global.Geetest3ID != nil {
|
||||
g3ID = *global.Geetest3ID
|
||||
}
|
||||
if global.Geetest3Key != nil {
|
||||
g3Key = *global.Geetest3Key
|
||||
}
|
||||
if global.Geetest4ID != nil {
|
||||
g4ID = *global.Geetest4ID
|
||||
}
|
||||
if global.Geetest4Key != nil {
|
||||
g4Key = *global.Geetest4Key
|
||||
}
|
||||
}
|
||||
|
||||
if tid > 0 {
|
||||
var tenantCfg models.AuthTenantAuthConfig
|
||||
if err := models.Orm.QueryTable(new(models.AuthTenantAuthConfig)).
|
||||
Filter("tid", tid).One(&tenantCfg); err == nil {
|
||||
openVerify = tenantCfg.OpenVerify == 1
|
||||
if t := strings.ToLower(strings.TrimSpace(tenantCfg.VerifyType)); t != "" {
|
||||
verifyType = t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cfg.OpenVerify = openVerify
|
||||
cfg.VerifyType = verifyType
|
||||
switch verifyType {
|
||||
case VerifyTypeGeetest, VerifyTypeGeetest4:
|
||||
cfg.GeetestVersion = "4"
|
||||
cfg.CaptchaID = g4ID
|
||||
cfg.geetestKey = g4Key
|
||||
case VerifyTypeGeetest3:
|
||||
cfg.GeetestVersion = "3"
|
||||
cfg.CaptchaID = g3ID
|
||||
cfg.geetestKey = g3Key
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// CaptchaResult 前端极验验证通过后提交的校验参数
|
||||
type CaptchaResult struct {
|
||||
CaptchaID string `json:"captcha_id"`
|
||||
LotNumber string `json:"lot_number"`
|
||||
PassToken string `json:"pass_token"`
|
||||
GenTime string `json:"gen_time"`
|
||||
CaptchaOutput string `json:"captcha_output"`
|
||||
}
|
||||
|
||||
// VerifyLoginCaptcha 校验人机验证结果,通过返回 nil。
|
||||
//
|
||||
// 仅在开启极验时拦截:未开启或为图形码/短信/邮箱/极验3 时不做处理,
|
||||
// 避免登录页没有对应 UI 却被服务端拒绝。
|
||||
func VerifyLoginCaptcha(tid uint64, result CaptchaResult) error {
|
||||
cfg := GetLoginVerifyConfig(tid)
|
||||
if !cfg.NeedGeetest() {
|
||||
return nil
|
||||
}
|
||||
if cfg.CaptchaID == "" {
|
||||
return &captchaError{kind: captchaFailNotSet, msg: ErrCaptchaNotSet.Error()}
|
||||
}
|
||||
if result.LotNumber == "" || result.PassToken == "" || result.GenTime == "" || result.CaptchaOutput == "" {
|
||||
logs.Warn("[auth] 登录缺少极验参数 captcha_id=%s lot_number=%q pass_token=%v gen_time=%v captcha_output=%v",
|
||||
cfg.CaptchaID, result.LotNumber, result.PassToken != "", result.GenTime, result.CaptchaOutput != "")
|
||||
return &captchaError{kind: captchaFailRequired, msg: ErrCaptchaRequired.Error()}
|
||||
}
|
||||
// 未配置极验 KEY 时无法做服务端二次校验,仅保证参数完整
|
||||
if cfg.geetestKey == "" {
|
||||
logs.Warn("[auth] 未配置极验 captcha_key,跳过服务端二次校验(仅校验参数完整性)")
|
||||
return nil
|
||||
}
|
||||
|
||||
err := verifyGeetest4(cfg, result)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// 逃生开关:极验服务端不可用时避免整站无法登录
|
||||
if geetestFailOpen() {
|
||||
logs.Warn("[auth] 极验服务端校验失败但已开启 fail_open,按配置放行: %v", err)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// geetestFailOpen 极验二次校验失败时是否放行。
|
||||
// 平台设置 login_verify_geetest4_fail_open = "1" 时开启(默认 0,严格拒绝)。
|
||||
// 用于极验密钥配置错误或服务不可用的应急恢复。
|
||||
func geetestFailOpen() bool {
|
||||
return strings.TrimSpace(models.GetPlatformSettingValue("login_verify_geetest4_fail_open", "0")) == "1"
|
||||
}
|
||||
|
||||
// verifyGeetest4 调用极验 4.0 二次校验接口,确认验证结果未被伪造
|
||||
func verifyGeetest4(cfg LoginVerifyConfig, result CaptchaResult) error {
|
||||
// sign_token = HMAC-SHA256(captcha_key, lot_number) 的十六进制串
|
||||
mac := hmac.New(sha256.New, []byte(cfg.geetestKey))
|
||||
mac.Write([]byte(result.LotNumber))
|
||||
signToken := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
payload, err := json.Marshal(map[string]string{
|
||||
"lot_number": result.LotNumber,
|
||||
"captcha_output": result.CaptchaOutput,
|
||||
"pass_token": result.PassToken,
|
||||
"gen_time": result.GenTime,
|
||||
"sign_token": signToken,
|
||||
})
|
||||
if err != nil {
|
||||
return ErrCaptchaFailed
|
||||
}
|
||||
|
||||
captchaID := strings.TrimSpace(result.CaptchaID)
|
||||
if captchaID == "" {
|
||||
captchaID = cfg.CaptchaID
|
||||
}
|
||||
target := geetest4ValidateURL + "?captcha_id=" + url.QueryEscape(captchaID)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, target, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return ErrCaptchaFailed
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
start := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
logs.Error("[auth] 调用极验二次校验失败: %v", err)
|
||||
return &captchaError{kind: captchaFailService, msg: "人机验证服务暂时不可用,请稍后重试"}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logs.Error("[auth] 极验二次校验返回异常 status=%d body=%s", resp.StatusCode, string(body))
|
||||
return &captchaError{kind: captchaFailService, msg: "人机验证服务暂时不可用,请稍后重试"}
|
||||
}
|
||||
|
||||
var res struct {
|
||||
Result string `json:"result"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &res); err != nil {
|
||||
logs.Error("[auth] 解析极验二次校验响应失败: %v body=%s", err, string(body))
|
||||
return &captchaError{kind: captchaFailService, msg: "人机验证服务暂时不可用,请稍后重试"}
|
||||
}
|
||||
|
||||
if res.Result == "success" || (res.Result == "" && res.Status == "success") {
|
||||
logs.Info("[auth] 极验二次校验通过 captcha_id=%s 耗时=%dms", captchaID, time.Since(start).Milliseconds())
|
||||
return nil
|
||||
}
|
||||
|
||||
// 校验未通过:记录原始响应,并把原因转成可读提示,便于定位是密钥配置问题还是验证过期
|
||||
logs.Warn("[auth] 极验二次校验未通过 captcha_id=%s lot_number=%s result=%q status=%q reason=%q body=%s",
|
||||
captchaID, result.LotNumber, res.Result, res.Status, res.Reason, string(body))
|
||||
return &captchaError{kind: captchaFailFailed, msg: geetestFailReason(res.Reason)}
|
||||
}
|
||||
|
||||
// DiagnoseGeetest4 极验 4.0 配置自检。
|
||||
//
|
||||
// 用一个固定的 lot_number 调二次校验接口,依据极验返回判断问题所在:
|
||||
// - 请求超时/失败 → 服务器无法访问极验服务(网络或防火墙)
|
||||
// - sign_token error → 网络可达,但 captcha_key 与 captcha_id 不匹配
|
||||
// - lot_number error → 网络可达且密钥正确(lot_number 是捏造的,报错属正常)
|
||||
func DiagnoseGeetest4() map[string]interface{} {
|
||||
cfg := GetLoginVerifyConfig(0)
|
||||
out := map[string]interface{}{
|
||||
"openVerify": cfg.OpenVerify,
|
||||
"verifyType": cfg.VerifyType,
|
||||
"needGeetest": cfg.NeedGeetest(),
|
||||
"captchaId": cfg.CaptchaID,
|
||||
"keySet": cfg.geetestKey != "",
|
||||
}
|
||||
if cfg.CaptchaID == "" {
|
||||
out["ok"] = false
|
||||
out["msg"] = "未配置极验 captcha_id"
|
||||
return out
|
||||
}
|
||||
if cfg.geetestKey == "" {
|
||||
out["ok"] = false
|
||||
out["msg"] = "未配置极验 captcha_key(此时认证中心只校验参数完整性,不做服务端二次校验)"
|
||||
return out
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(cfg.geetestKey))
|
||||
mac.Write([]byte("diagnose-lot-number"))
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"lot_number": "diagnose-lot-number",
|
||||
"captcha_output": "",
|
||||
"pass_token": "",
|
||||
"gen_time": "",
|
||||
"sign_token": hex.EncodeToString(mac.Sum(nil)),
|
||||
})
|
||||
target := geetest4ValidateURL + "?captcha_id=" + url.QueryEscape(cfg.CaptchaID)
|
||||
req, err := http.NewRequest(http.MethodPost, target, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
out["ok"] = false
|
||||
out["msg"] = "构造自检请求失败: " + err.Error()
|
||||
return out
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
out["ok"] = false
|
||||
out["msg"] = "无法访问极验服务(" + err.Error() + "),请检查服务器出网/防火墙"
|
||||
return out
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
||||
|
||||
var res struct {
|
||||
Result string `json:"result"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &res)
|
||||
out["raw"] = string(body)
|
||||
|
||||
switch {
|
||||
case resp.StatusCode != http.StatusOK:
|
||||
out["ok"] = false
|
||||
out["msg"] = fmt.Sprintf("极验返回 HTTP %d,请检查 captcha_id 是否正确", resp.StatusCode)
|
||||
case strings.Contains(strings.ToLower(res.Reason), "sign_token"):
|
||||
out["ok"] = false
|
||||
out["msg"] = "网络可达,但 captcha_key 与 captcha_id 不匹配,请核对极验后台的验证密钥"
|
||||
case strings.Contains(strings.ToLower(res.Reason), "lot_number"),
|
||||
strings.Contains(strings.ToLower(res.Reason), "captcha_output"),
|
||||
strings.Contains(strings.ToLower(res.Reason), "pass_token"):
|
||||
out["ok"] = true
|
||||
out["msg"] = "网络可达且 captcha_key 校验通过(lot_number 为自检用假数据,报错属正常)"
|
||||
default:
|
||||
out["ok"] = false
|
||||
out["msg"] = "极验返回异常: " + string(body)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// geetestFailReason 极验失败原因(reason)转中文提示,帮助区分“密钥配置错误”与“验证已失效”
|
||||
func geetestFailReason(reason string) string {
|
||||
r := strings.ToLower(strings.TrimSpace(reason))
|
||||
switch r {
|
||||
case "":
|
||||
return ErrCaptchaFailed.Error()
|
||||
case "sign_token error", "sign_token_error", "captcha_key error", "captcha_key_error":
|
||||
return "人机验证未通过:极验密钥(captcha_key)配置有误,请联系管理员"
|
||||
case "captcha_id error", "captcha_id_error":
|
||||
return "人机验证未通过:极验 captcha_id 与密钥不匹配,请联系管理员"
|
||||
case "lot_number error", "lot_number_error":
|
||||
return "人机验证已失效,请重新验证"
|
||||
case "gen_time error", "gen_time_error", "timeout":
|
||||
return "人机验证已超时,请重新验证"
|
||||
default:
|
||||
return fmt.Sprintf("人机验证未通过(%s),请重新验证", reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
"v4.2.0 Geetest Inc.";
|
||||
|
||||
(function (window) {
|
||||
"use strict";
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('Geetest requires browser environment');
|
||||
}
|
||||
|
||||
var document = window.document;
|
||||
var Math = window.Math;
|
||||
var head = document.getElementsByTagName("head")[0];
|
||||
var TIMEOUT = 10000;
|
||||
|
||||
function _Object(obj) {
|
||||
this._obj = obj;
|
||||
}
|
||||
|
||||
_Object.prototype = {
|
||||
_each: function (process) {
|
||||
var _obj = this._obj;
|
||||
for (var k in _obj) {
|
||||
if (_obj.hasOwnProperty(k)) {
|
||||
process(k, _obj[k]);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
},
|
||||
_extend: function (obj){
|
||||
var self = this;
|
||||
new _Object(obj)._each(function (key, value){
|
||||
self._obj[key] = value;
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
var uuid = function () {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
var r = Math.random() * 16 | 0;
|
||||
var v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
};
|
||||
|
||||
function Config(config) {
|
||||
var self = this;
|
||||
new _Object(config)._each(function (key, value) {
|
||||
self[key] = value;
|
||||
});
|
||||
}
|
||||
|
||||
Config.prototype = {
|
||||
apiServers: ['gcaptcha4.geetest.com','gcaptcha4.geevisit.com','gcaptcha4.gsensebot.com'],
|
||||
staticServers: ["static.geetest.com",'static.geevisit.com'],
|
||||
protocol: 'http://',
|
||||
typePath: '/load',
|
||||
fallback_config: {
|
||||
bypass: {
|
||||
staticServers: ["static.geetest.com",'static.geevisit.com'],
|
||||
type: 'bypass',
|
||||
bypass: '/v4/bypass.js'
|
||||
}
|
||||
},
|
||||
_get_fallback_config: function () {
|
||||
var self = this;
|
||||
if (isString(self.type)) {
|
||||
return self.fallback_config[self.type];
|
||||
} else {
|
||||
return self.fallback_config.bypass;
|
||||
}
|
||||
},
|
||||
_extend: function (obj) {
|
||||
var self = this;
|
||||
new _Object(obj)._each(function (key, value) {
|
||||
self[key] = value;
|
||||
})
|
||||
}
|
||||
};
|
||||
var isNumber = function (value) {
|
||||
return (typeof value === 'number');
|
||||
};
|
||||
var isString = function (value) {
|
||||
return (typeof value === 'string');
|
||||
};
|
||||
var isBoolean = function (value) {
|
||||
return (typeof value === 'boolean');
|
||||
};
|
||||
var isObject = function (value) {
|
||||
return (typeof value === 'object' && value !== null);
|
||||
};
|
||||
var isFunction = function (value) {
|
||||
return (typeof value === 'function');
|
||||
};
|
||||
var MOBILE = /Mobi/i.test(navigator.userAgent);
|
||||
|
||||
var callbacks = {};
|
||||
var status = {};
|
||||
|
||||
var random = function () {
|
||||
return parseInt(Math.random() * 10000) + (new Date()).valueOf();
|
||||
};
|
||||
|
||||
// bind 函数polify, ä¸å¸¦new功能的bind
|
||||
|
||||
var bind = function(target,context){
|
||||
if(typeof target !== 'function'){
|
||||
return;
|
||||
}
|
||||
var args = Array.prototype.slice.call(arguments,2);
|
||||
|
||||
if(Function.prototype.bind){
|
||||
return target.bind(context, args);
|
||||
}else {
|
||||
return function(){
|
||||
var _args = Array.prototype.slice.call(arguments);
|
||||
return target.apply(context,args.concat(_args));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
var toString = Object.prototype.toString;
|
||||
|
||||
var _isFunction = function(obj) {
|
||||
return typeof(obj) === 'function';
|
||||
};
|
||||
var _isObject = function(obj) {
|
||||
return obj === Object(obj);
|
||||
};
|
||||
var _isArray = function(obj) {
|
||||
return toString.call(obj) == '[object Array]';
|
||||
};
|
||||
var _isDate = function(obj) {
|
||||
return toString.call(obj) == '[object Date]';
|
||||
};
|
||||
var _isRegExp = function(obj) {
|
||||
return toString.call(obj) == '[object RegExp]';
|
||||
};
|
||||
var _isBoolean = function(obj) {
|
||||
return toString.call(obj) == '[object Boolean]';
|
||||
};
|
||||
|
||||
|
||||
function resolveKey(input){
|
||||
return input.replace(/(\S)(_([a-zA-Z]))/g, function(match, $1, $2, $3){
|
||||
return $1 + $3.toUpperCase() || "";
|
||||
})
|
||||
}
|
||||
|
||||
function camelizeKeys(input, convert){
|
||||
if(!_isObject(input) || _isDate(input) || _isRegExp(input) || _isBoolean(input) || _isFunction(input)){
|
||||
return convert ? resolveKey(input) : input;
|
||||
}
|
||||
|
||||
if(_isArray(input)){
|
||||
var temp = [];
|
||||
for(var i = 0; i < input.length; i++){
|
||||
temp.push(camelizeKeys(input[i]));
|
||||
}
|
||||
|
||||
}else {
|
||||
var temp = {};
|
||||
for(var prop in input){
|
||||
if(input.hasOwnProperty(prop)){
|
||||
temp[camelizeKeys(prop, true)] = camelizeKeys(input[prop]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return temp;
|
||||
}
|
||||
|
||||
var loadScript = function (url, cb, timeout) {
|
||||
var script = document.createElement("script");
|
||||
script.charset = "UTF-8";
|
||||
script.async = true;
|
||||
|
||||
// 对geetestçš„é™æ€èµ„æºæ·»åŠ crossOrigin
|
||||
if ( /static\.geetest\.com/g.test(url)) {
|
||||
script.crossOrigin = "anonymous";
|
||||
}
|
||||
|
||||
script.onerror = function () {
|
||||
cb(true);
|
||||
// 错误触å‘了,超时逻辑就ä¸ç”¨äº†
|
||||
loaded = true;
|
||||
};
|
||||
var loaded = false;
|
||||
script.onload = script.onreadystatechange = function () {
|
||||
if (!loaded &&
|
||||
(!script.readyState ||
|
||||
"loaded" === script.readyState ||
|
||||
"complete" === script.readyState)) {
|
||||
|
||||
loaded = true;
|
||||
setTimeout(function () {
|
||||
cb(false);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
script.src = url;
|
||||
head.appendChild(script);
|
||||
|
||||
setTimeout(function () {
|
||||
if (!loaded) {
|
||||
script.onerror = script.onload = null;
|
||||
script.remove && script.remove();
|
||||
cb(true);
|
||||
}
|
||||
}, timeout || TIMEOUT);
|
||||
};
|
||||
|
||||
var normalizeDomain = function (domain) {
|
||||
// special domain: uems.sysu.edu.cn/jwxt/geetest/
|
||||
// return domain.replace(/^https?:\/\/|\/.*$/g, ''); uems.sysu.edu.cn
|
||||
return domain.replace(/^https?:\/\/|\/$/g, ''); // uems.sysu.edu.cn/jwxt/geetest
|
||||
};
|
||||
var normalizePath = function (path) {
|
||||
|
||||
path = path && path.replace(/\/+/g, '/');
|
||||
if (path.indexOf('/') !== 0) {
|
||||
path = '/' + path;
|
||||
}
|
||||
return path;
|
||||
};
|
||||
var normalizeQuery = function (query) {
|
||||
if (!query) {
|
||||
return '';
|
||||
}
|
||||
var q = '?';
|
||||
new _Object(query)._each(function (key, value) {
|
||||
if (isString(value) || isNumber(value) || isBoolean(value)) {
|
||||
q = q + encodeURIComponent(key) + '=' + encodeURIComponent(value) + '&';
|
||||
}
|
||||
});
|
||||
if (q === '?') {
|
||||
q = '';
|
||||
}
|
||||
return q.replace(/&$/, '');
|
||||
};
|
||||
var makeURL = function (protocol, domain, path, query) {
|
||||
domain = normalizeDomain(domain);
|
||||
|
||||
var url = normalizePath(path) + normalizeQuery(query);
|
||||
if (domain) {
|
||||
url = protocol + domain + url;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
var load = function (config, protocol, domains, path, query, cb, handleCb) {
|
||||
var tryRequest = function (at) {
|
||||
// 处ç†jsonp回调,这里为了ä¿è¯æ¯ä¸ªä¸åŒjsonp都有唯一的回调函数
|
||||
if(handleCb){
|
||||
var cbName = "geetest_" + random();
|
||||
// 需è¦ä¸Žé¢„先定义好cbname傿•°ï¼Œåˆ 除对象
|
||||
window[cbName] = bind(handleCb, null, cbName);
|
||||
query.callback = cbName;
|
||||
}
|
||||
var url = makeURL(protocol, domains[at], path, query);
|
||||
loadScript(url, function (err) {
|
||||
if (err) {
|
||||
// 超时或者出错的时候 移除回调
|
||||
if(cbName){
|
||||
try {
|
||||
window[cbName] = function(){
|
||||
window[cbName] = null;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (at >= domains.length - 1) {
|
||||
cb(true);
|
||||
// report gettype error
|
||||
} else {
|
||||
tryRequest(at + 1);
|
||||
}
|
||||
} else {
|
||||
cb(false);
|
||||
}
|
||||
}, config.timeout);
|
||||
};
|
||||
tryRequest(0);
|
||||
};
|
||||
|
||||
|
||||
var jsonp = function (domains, path, config, callback) {
|
||||
|
||||
var handleCb = function (cbName, data) {
|
||||
|
||||
// ä¿è¯åªæ‰§è¡Œä¸€æ¬¡ï¼Œå…¨éƒ¨è¶…时的情况下ä¸ä¼šå†è§¦å‘;
|
||||
|
||||
if (data.status == 'success') {
|
||||
callback(data.data);
|
||||
} else if (!data.status) {
|
||||
callback(data);
|
||||
} else {
|
||||
//æŽ¥å£æœ‰è¿”回,但是返回了错误状æ€ï¼Œè¿›å…¥æŠ¥é”™é€»è¾‘
|
||||
callback(data);
|
||||
}
|
||||
window[cbName] = undefined;
|
||||
try {
|
||||
delete window[cbName];
|
||||
} catch (e) {
|
||||
}
|
||||
};
|
||||
load(config, config.protocol, domains, path, {
|
||||
callback: '',
|
||||
captcha_id: config.captchaId,
|
||||
challenge: config.challenge || uuid(),
|
||||
client_type: config.clientType ? config.clientType : (MOBILE? 'h5':'web'),
|
||||
risk_type: config.riskType,
|
||||
user_info: config.userInfo,
|
||||
call_type: config.callType,
|
||||
lang: config.language? config.language : navigator.appName === 'Netscape' ? navigator.language.toLowerCase() : navigator.userLanguage.toLowerCase()
|
||||
}, function (err) {
|
||||
// ç½‘ç»œé—®é¢˜æŽ¥å£æ²¡æœ‰è¿”回,直接使用本地验è¯ç ,走宕机模å¼
|
||||
// 这里å¯ä»¥æ·»åŠ ç”¨æˆ·çš„é€»è¾‘
|
||||
if(err && typeof config.offlineCb === 'function'){
|
||||
// 执行自己的宕机
|
||||
config.offlineCb();
|
||||
return;
|
||||
}
|
||||
if(err){
|
||||
callback(config._get_fallback_config());
|
||||
}
|
||||
}, handleCb);
|
||||
};
|
||||
|
||||
var reportError = function (config, url) {
|
||||
load(config, config.protocol, ['monitor.geetest.com'], '/monitor/send', {
|
||||
time: Date.now().getTime(),
|
||||
captcha_id: config.gt,
|
||||
challenge: config.challenge,
|
||||
exception_url: url,
|
||||
error_code: config.error_code
|
||||
}, function (err) {})
|
||||
}
|
||||
|
||||
var throwError = function (errorType, config, errObj) {
|
||||
var errors = {
|
||||
networkError: '网络错误',
|
||||
gtTypeError: 'gtå—æ®µä¸æ˜¯å—符串类型'
|
||||
};
|
||||
if (typeof config.onError === 'function') {
|
||||
config.onError({
|
||||
desc: errObj.desc,
|
||||
msg: errObj.msg,
|
||||
code: errObj.code
|
||||
});
|
||||
} else {
|
||||
throw new Error(errors[errorType]);
|
||||
}
|
||||
};
|
||||
|
||||
var detect = function () {
|
||||
return window.Geetest || document.getElementById("gt_lib");
|
||||
};
|
||||
|
||||
if (detect()) {
|
||||
status.slide = "loaded";
|
||||
}
|
||||
var GeetestIsLoad = function (fname) {
|
||||
var GeetestIsLoad = false;
|
||||
var tags = { js: 'script', css: 'link' };
|
||||
var tagname = fname && tags[fname.split('.').pop()];
|
||||
if (tagname !== undefined) {
|
||||
var elts = document.getElementsByTagName(tagname);
|
||||
for (var i in elts) {
|
||||
if ((elts[i].href && elts[i].href.toString().indexOf(fname) > 0)
|
||||
|| (elts[i].src && elts[i].src.toString().indexOf(fname) > 0)) {
|
||||
GeetestIsLoad = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return GeetestIsLoad;
|
||||
};
|
||||
window.initGeetest4 = function (userConfig,callback) {
|
||||
|
||||
var config = new Config(userConfig);
|
||||
if (userConfig.https) {
|
||||
config.protocol = 'https://';
|
||||
} else if (!userConfig.protocol) {
|
||||
config.protocol = window.location.protocol + '//';
|
||||
}
|
||||
|
||||
|
||||
if (isObject(userConfig.getType)) {
|
||||
config._extend(userConfig.getType);
|
||||
}
|
||||
|
||||
jsonp(config.apiServers , config.typePath, config, function (newConfig) {
|
||||
//错误æ•获,第一个load请求å¯èƒ½ç›´æŽ¥æŠ¥é”™
|
||||
var newConfig = camelizeKeys(newConfig);
|
||||
|
||||
if(newConfig.status === 'error'){
|
||||
return throwError('networkError', config, newConfig);
|
||||
}
|
||||
|
||||
var type = newConfig.type;
|
||||
if(config.debug){
|
||||
new _Object(newConfig)._extend(config.debug)
|
||||
}
|
||||
var init = function () {
|
||||
config._extend(newConfig);
|
||||
callback(new window.Geetest4(config));
|
||||
};
|
||||
|
||||
callbacks[type] = callbacks[type] || [];
|
||||
|
||||
var s = status[type] || 'init';
|
||||
if (s === 'init') {
|
||||
status[type] = 'loading';
|
||||
|
||||
callbacks[type].push(init);
|
||||
|
||||
if(newConfig.gctPath){
|
||||
load(config, config.protocol, Object.hasOwnProperty.call(config, 'staticServers') ? config.staticServers : newConfig.staticServers || config.staticServers , newConfig.gctPath, null, function (err){
|
||||
if(err){
|
||||
throwError('networkError', config, {
|
||||
code: '60205',
|
||||
msg: 'Network failure',
|
||||
desc: {
|
||||
detail: 'gct resource load timeout'
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
load(config, config.protocol, Object.hasOwnProperty.call(config, 'staticServers') ? config.staticServers : newConfig.staticServers || config.staticServers, newConfig.bypass || (newConfig.staticPath + newConfig.js), null, function (err) {
|
||||
if (err) {
|
||||
status[type] = 'fail';
|
||||
throwError('networkError', config, {
|
||||
code: '60204',
|
||||
msg: 'Network failure',
|
||||
desc: {
|
||||
detail: 'js resource load timeout'
|
||||
}
|
||||
});
|
||||
} else {
|
||||
|
||||
status[type] = 'loaded';
|
||||
var cbs = callbacks[type];
|
||||
for (var i = 0, len = cbs.length; i < len; i = i + 1) {
|
||||
var cb = cbs[i];
|
||||
if (isFunction(cb)) {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
callbacks[type] = [];
|
||||
status[type] = 'init';
|
||||
}
|
||||
});
|
||||
} else if (s === "loaded") {
|
||||
// 判æ–gct是å¦éœ€è¦é‡æ–°åŠ è½½
|
||||
if(newConfig.gctPath && !GeetestIsLoad(newConfig.gctPath)){
|
||||
load(config, config.protocol, Object.hasOwnProperty.call(config, 'staticServers') ? config.staticServers : newConfig.staticServers || config.staticServers , newConfig.gctPath, null, function (err){
|
||||
if(err){
|
||||
throwError('networkError', config, {
|
||||
code: '60205',
|
||||
msg: 'Network failure',
|
||||
desc: {
|
||||
detail: 'gct resource load timeout'
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
return init();
|
||||
} else if (s === "fail") {
|
||||
throwError('networkError', config, {
|
||||
code: '60204',
|
||||
msg: 'Network failure',
|
||||
desc: {
|
||||
detail: 'js resource load timeout'
|
||||
}
|
||||
});
|
||||
} else if (s === "loading") {
|
||||
callbacks[type].push(init);
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
|
||||
})(window);
|
||||
+676
-92
@@ -3,126 +3,614 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>统一认证中心 - 登录</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #3973ff;
|
||||
--primary-dark: #2560a9;
|
||||
--primary-light: #52a8ff;
|
||||
--text: #1f2b48;
|
||||
--text-sub: #5b6780;
|
||||
--muted: #8a94ad;
|
||||
--border: #e3e9f5;
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text);
|
||||
background: linear-gradient(120deg, #eef4ff 0%, #f7fbff 55%, #eaf3ff 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 20px;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.card {
|
||||
background: #fff; border-radius: 12px; box-shadow: 0 20px 60px rgba(0,0,0,.2);
|
||||
width: 420px; max-width: 92vw; padding: 40px 36px;
|
||||
|
||||
.glow {
|
||||
position: fixed;
|
||||
border-radius: 50%;
|
||||
filter: blur(60px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.logo { text-align: center; margin-bottom: 28px; }
|
||||
.logo h1 { font-size: 22px; color: #1a1a2e; font-weight: 600; }
|
||||
.logo p { font-size: 13px; color: #8a8fa3; margin-top: 6px; }
|
||||
.field { margin-bottom: 18px; }
|
||||
.field label { display: block; font-size: 13px; color: #5a6072; margin-bottom: 7px; }
|
||||
.field input {
|
||||
width: 100%; height: 44px; padding: 0 14px; font-size: 14px;
|
||||
border: 1px solid #dcdfe8; border-radius: 8px; outline: none; transition: border-color .2s;
|
||||
.glow-1 { width: 380px; height: 380px; top: -120px; left: -100px; background: rgba(82, 168, 255, 0.28); }
|
||||
.glow-2 { width: 320px; height: 320px; bottom: -120px; right: -80px; background: rgba(57, 115, 255, 0.20); }
|
||||
|
||||
.auth-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 940px;
|
||||
min-height: 560px;
|
||||
background: #fff;
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 18px 50px 0 rgba(37, 96, 169, 0.14), 0 2px 6px 0 rgba(31, 43, 72, 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
.field input:focus { border-color: #667eea; }
|
||||
.btn {
|
||||
width: 100%; height: 44px; border: none; border-radius: 8px; cursor: pointer;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff; font-size: 15px; font-weight: 500; transition: opacity .2s;
|
||||
|
||||
/* ---------- 左侧品牌区 ---------- */
|
||||
.auth-side {
|
||||
width: 340px;
|
||||
flex: 0 0 340px;
|
||||
padding: 44px 34px 28px;
|
||||
background: linear-gradient(160deg, #1b3b8f 0%, #2f6fe4 52%, #52a8ff 100%);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.btn:hover { opacity: .9; }
|
||||
.btn:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.auth-side::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 260px; height: 260px;
|
||||
top: -90px; right: -110px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
}
|
||||
.auth-side::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 190px; height: 190px;
|
||||
bottom: -70px; left: -60px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.brand { display: flex; align-items: center; gap: 12px; position: relative; z-index: 1; }
|
||||
.brand-logo {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 11px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border: 1px solid rgba(255, 255, 255, 0.30);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.brand-logo svg { width: 22px; height: 22px; }
|
||||
.brand-name { font-size: 16px; font-weight: 600; letter-spacing: .5px; }
|
||||
|
||||
.side-title {
|
||||
margin-top: 44px;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
.side-desc {
|
||||
margin-top: 12px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.9;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
|
||||
.side-illus { margin: auto 0; padding: 22px 0; position: relative; z-index: 1; }
|
||||
.side-illus svg { width: 100%; height: auto; display: block; }
|
||||
|
||||
.side-foot {
|
||||
position: relative; z-index: 1;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.16);
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.side-foot svg { width: 13px; height: 13px; }
|
||||
|
||||
/* ---------- 右侧表单区 ---------- */
|
||||
.auth-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 52px 56px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.panel-head { margin-bottom: 28px; }
|
||||
.panel-title { font-size: 27px; font-weight: 600; letter-spacing: .5px; }
|
||||
.panel-sub { margin-top: 9px; font-size: 13.5px; color: var(--text-sub); }
|
||||
|
||||
.error {
|
||||
background: #fff2f0; border: 1px solid #ffccc7; color: #cf1322;
|
||||
padding: 10px 12px; border-radius: 6px; font-size: 13px; margin-bottom: 16px; display: none;
|
||||
display: none;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
background: #fff2f0;
|
||||
border: 1px solid #ffd4cf;
|
||||
color: #cf1322;
|
||||
padding: 11px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 18px;
|
||||
animation: shake .32s ease;
|
||||
}
|
||||
.tenant-list { list-style: none; }
|
||||
.tenant-item {
|
||||
.error.show { display: flex; }
|
||||
.error svg { width: 16px; height: 16px; flex: 0 0 auto; margin-top: 2px; }
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-4px); }
|
||||
75% { transform: translateX(4px); }
|
||||
}
|
||||
|
||||
.field { margin-bottom: 18px; }
|
||||
.field-label {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 16px; border: 1px solid #e4e7ee; border-radius: 8px;
|
||||
margin-bottom: 12px; cursor: pointer; transition: all .2s;
|
||||
font-size: 13px; color: var(--text-sub); margin-bottom: 8px; font-weight: 500;
|
||||
}
|
||||
.tenant-item:hover { border-color: #667eea; background: #f7f8ff; }
|
||||
.tenant-name { font-size: 15px; color: #1a1a2e; font-weight: 500; }
|
||||
.tenant-meta { font-size: 12px; color: #8a8fa3; margin-top: 3px; }
|
||||
.tenant-arrow { color: #c0c4d0; font-size: 18px; }
|
||||
.tip { text-align: center; font-size: 12px; color: #8a8fa3; margin-top: 20px; }
|
||||
.input-wrap { position: relative; }
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 14px; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 18px; height: 18px;
|
||||
color: #a7b1c7;
|
||||
pointer-events: none;
|
||||
transition: color .2s;
|
||||
}
|
||||
.input-wrap input {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
padding: 0 44px 0 42px;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: #fbfcff;
|
||||
outline: none;
|
||||
transition: border-color .2s, box-shadow .2s, background .2s;
|
||||
}
|
||||
.input-wrap input::placeholder { color: #adb6c9; }
|
||||
.input-wrap input:hover { border-color: var(--border); background: #fff; }
|
||||
.input-wrap input:focus {
|
||||
border-color: var(--primary);
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 4px rgba(57, 115, 255, 0.12);
|
||||
}
|
||||
.input-wrap input:focus + .input-icon,
|
||||
.input-wrap:focus-within .input-icon { color: var(--primary); }
|
||||
|
||||
.eye-btn {
|
||||
position: absolute;
|
||||
right: 8px; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 32px; height: 32px;
|
||||
border: none; background: transparent;
|
||||
border-radius: 8px;
|
||||
color: #a7b1c7;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: color .2s, background .2s;
|
||||
}
|
||||
.eye-btn:hover { color: var(--primary); background: #f0f5ff; }
|
||||
.eye-btn svg { width: 18px; height: 18px; }
|
||||
|
||||
.row-extra {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin: -4px 0 22px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.remember { display: inline-flex; align-items: center; gap: 7px; cursor: pointer; color: var(--text-sub); user-select: none; }
|
||||
.remember input { width: 15px; height: 15px; accent-color: var(--primary); cursor: pointer; }
|
||||
.link { color: var(--primary); text-decoration: none; cursor: pointer; transition: color .2s; }
|
||||
.link:hover { color: var(--primary-dark); text-decoration: underline; }
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg, #3973ff 0%, #52a8ff 100%);
|
||||
color: #fff;
|
||||
font-size: 15.5px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 2px;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
box-shadow: 0 8px 20px rgba(57, 115, 255, 0.28);
|
||||
transition: transform .15s, box-shadow .2s, opacity .2s;
|
||||
}
|
||||
.btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 12px 26px rgba(57, 115, 255, 0.34); }
|
||||
.btn:active:not(:disabled) { transform: translateY(0); box-shadow: 0 6px 14px rgba(57, 115, 255, 0.26); }
|
||||
.btn:disabled { opacity: .72; cursor: not-allowed; box-shadow: none; }
|
||||
.spinner {
|
||||
display: none;
|
||||
width: 16px; height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.45);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin .7s linear infinite;
|
||||
}
|
||||
.btn.is-loading .spinner { display: block; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* 第三方登录 */
|
||||
.third-area { margin-top: 4px; }
|
||||
.divider {
|
||||
position: relative; text-align: center; margin: 24px 0 14px;
|
||||
font-size: 12px; color: #a0a5b5;
|
||||
position: relative; text-align: center;
|
||||
margin: 26px 0 16px;
|
||||
font-size: 12px; color: #a3acbf;
|
||||
}
|
||||
.divider::before {
|
||||
content: ''; position: absolute; top: 50%; left: 0; right: 0;
|
||||
height: 1px; background: #e4e7ee;
|
||||
height: 1px; background: var(--border);
|
||||
}
|
||||
.divider span { position: relative; background: #fff; padding: 0 12px; }
|
||||
.third-list { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
|
||||
.third-btn {
|
||||
padding: 8px 16px; border: 1px solid #dcdfe8; border-radius: 20px;
|
||||
background: #fff; font-size: 13px; color: #5a6072; cursor: pointer;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 22px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color: var(--text-sub);
|
||||
cursor: pointer;
|
||||
transition: all .2s;
|
||||
}
|
||||
.third-btn:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: #f5f9ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 企业选择 */
|
||||
.tenant-list { list-style: none; display: flex; flex-direction: column; gap: 10px; }
|
||||
.tenant-item {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
background: #fbfcff;
|
||||
transition: all .2s;
|
||||
}
|
||||
.tenant-item:hover {
|
||||
border-color: var(--primary);
|
||||
background: #f5f9ff;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 18px rgba(57, 115, 255, 0.10);
|
||||
}
|
||||
.tenant-avatar {
|
||||
width: 40px; height: 40px;
|
||||
flex: 0 0 40px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, rgba(57, 115, 255, 0.12) 0%, rgba(82, 168, 255, 0.16) 100%);
|
||||
color: var(--primary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.tenant-avatar svg { width: 20px; height: 20px; }
|
||||
.tenant-info { flex: 1; min-width: 0; }
|
||||
.tenant-name {
|
||||
font-size: 15px; font-weight: 500; color: var(--text);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.tenant-meta { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
||||
.tenant-arrow { color: #c8cfdf; transition: transform .2s, color .2s; }
|
||||
.tenant-arrow svg { width: 18px; height: 18px; display: block; }
|
||||
.tenant-item:hover .tenant-arrow { color: var(--primary); transform: translateX(3px); }
|
||||
|
||||
.back { text-align: center; margin-top: 22px; font-size: 13px; }
|
||||
|
||||
.foot-tip {
|
||||
margin-top: 26px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px dashed var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.foot-tip svg { width: 13px; height: 13px; flex: 0 0 auto; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
body { padding: 20px 14px; align-items: flex-start; }
|
||||
.auth-card { max-width: 520px; min-height: 0; flex-direction: column; }
|
||||
.auth-side { width: 100%; flex: none; padding: 30px 26px 24px; }
|
||||
.side-title { margin-top: 26px; font-size: 21px; }
|
||||
.side-illus { display: none; }
|
||||
.auth-main { padding: 34px 26px 30px; }
|
||||
.panel-title { font-size: 23px; }
|
||||
}
|
||||
.third-btn:hover { border-color: #667eea; color: #667eea; }
|
||||
.back { text-align: center; margin-top: 14px; }
|
||||
.back a { font-size: 13px; color: #667eea; text-decoration: none; cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<h1>云泽统一认证中心</h1>
|
||||
<p>{{if eq .Step "tenant"}}请选择要进入的企业{{else}}登录后即可使用全部应用{{end}}</p>
|
||||
</div>
|
||||
<div class="glow glow-1"></div>
|
||||
<div class="glow glow-2"></div>
|
||||
|
||||
<div class="error" id="error"></div>
|
||||
<div class="auth-card">
|
||||
<!-- 左侧品牌区 -->
|
||||
<aside class="auth-side">
|
||||
<div class="brand">
|
||||
<span class="brand-logo">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3l7 3v6c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6l7-3z"/>
|
||||
<path d="M9.2 12.2l2 2 3.6-4"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="brand-name">云泽统一认证中心</span>
|
||||
</div>
|
||||
|
||||
{{if eq .Step "tenant"}}
|
||||
<!-- 企业选择步骤 -->
|
||||
<ul class="tenant-list">
|
||||
{{range .Tenants}}
|
||||
<li class="tenant-item" onclick="chooseTenant({{.Tid}})">
|
||||
<div>
|
||||
<div class="tenant-name">{{.TenantName}}</div>
|
||||
<div class="tenant-meta">{{if .Name}}{{.Name}} · {{end}}企业ID {{.Tid}}</div>
|
||||
<div class="side-title">一次登录<br>全平台通行</div>
|
||||
<div class="side-desc">
|
||||
基于 OIDC / OAuth 2.0 的统一身份底座,<br>
|
||||
为所有接入应用提供安全、一致的登录体验。
|
||||
</div>
|
||||
|
||||
<div class="side-illus">
|
||||
<svg viewBox="0 0 260 170" fill="none">
|
||||
<ellipse cx="130" cy="150" rx="98" ry="11" fill="rgba(255,255,255,.13)"/>
|
||||
<rect x="26" y="60" width="56" height="38" rx="9" fill="rgba(255,255,255,.14)"/>
|
||||
<rect x="36" y="70" width="26" height="4" rx="2" fill="rgba(255,255,255,.45)"/>
|
||||
<rect x="36" y="79" width="36" height="4" rx="2" fill="rgba(255,255,255,.28)"/>
|
||||
<rect x="184" y="52" width="50" height="34" rx="9" fill="rgba(255,255,255,.10)"/>
|
||||
<rect x="193" y="62" width="22" height="4" rx="2" fill="rgba(255,255,255,.36)"/>
|
||||
<path d="M130 26l40 15v30c0 22-17 39-40 47-23-8-40-25-40-47V41l40-15z"
|
||||
fill="rgba(255,255,255,.17)" stroke="rgba(255,255,255,.55)" stroke-width="1.6"/>
|
||||
<path d="M116 82l11 11 21-23" stroke="#fff" stroke-width="5.5"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="side-foot">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<rect x="4" y="11" width="16" height="10" rx="2"/>
|
||||
<path d="M8 11V8a4 4 0 018 0v3"/>
|
||||
</svg>
|
||||
<span>© 2026 Yunzer · 连接已加密</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 右侧表单区 -->
|
||||
<main class="auth-main">
|
||||
{{if eq .Step "tenant"}}
|
||||
<div class="panel-head">
|
||||
<h1 class="panel-title">选择企业</h1>
|
||||
<p class="panel-sub">该账号已加入多家企业,请选择要进入的空间</p>
|
||||
</div>
|
||||
|
||||
<div class="error" id="error">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/>
|
||||
</svg>
|
||||
<span id="errorText"></span>
|
||||
</div>
|
||||
|
||||
<ul class="tenant-list">
|
||||
{{range .Tenants}}
|
||||
<li class="tenant-item" onclick="chooseTenant({{.Tid}})">
|
||||
<span class="tenant-avatar">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 21h18M5 21V7l7-4 7 4v14"/><path d="M10 21v-5h4v5"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="tenant-info">
|
||||
<span class="tenant-name">{{.TenantName}}</span>
|
||||
<span class="tenant-meta">{{if .Name}}{{.Name}} · {{end}}企业ID {{.Tid}}</span>
|
||||
</span>
|
||||
<span class="tenant-arrow">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 6l6 6-6 6"/>
|
||||
</svg>
|
||||
</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
|
||||
<div class="back"><a class="link" onclick="logout()">不是我的账号?切换账号</a></div>
|
||||
{{else}}
|
||||
<div class="panel-head">
|
||||
<h1 class="panel-title">欢迎登录</h1>
|
||||
<p class="panel-sub">请输入您的账号与密码以继续</p>
|
||||
</div>
|
||||
|
||||
<div class="error" id="error">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/>
|
||||
</svg>
|
||||
<span id="errorText"></span>
|
||||
</div>
|
||||
|
||||
<form id="loginForm" onsubmit="return submitLogin(event)" autocomplete="on">
|
||||
<div class="field">
|
||||
<label class="field-label" for="account">账号</label>
|
||||
<div class="input-wrap">
|
||||
<input id="account" type="text" placeholder="手机号 / 邮箱 / 企业账号" autocomplete="username" autofocus>
|
||||
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-6 8-6s8 2 8 6"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<span class="tenant-arrow">›</span>
|
||||
</li>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="password">密码</label>
|
||||
<div class="input-wrap">
|
||||
<input id="password" type="password" placeholder="请输入密码" autocomplete="current-password">
|
||||
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V8a4 4 0 018 0v3"/>
|
||||
</svg>
|
||||
<button class="eye-btn" type="button" id="eyeBtn" onclick="togglePassword()" title="显示密码" aria-label="显示密码">
|
||||
<svg id="eyeOpen" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 12s3.6-6 10-6 10 6 10 6-3.6 6-10 6-10-6-10-6z"/><circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
<svg id="eyeOff" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="display:none">
|
||||
<path d="M3 3l18 18"/><path d="M10.6 6.2A9.9 9.9 0 0112 6c6.4 0 10 6 10 6a17 17 0 01-3.3 3.9M6.3 8.1A16.7 16.7 0 002 12s3.6 6 10 6c1.3 0 2.5-.3 3.6-.7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row-extra">
|
||||
<label class="remember"><input type="checkbox" id="remember"> 记住账号</label>
|
||||
</div>
|
||||
|
||||
<button class="btn" id="submitBtn" type="submit">
|
||||
<span class="spinner"></span>
|
||||
<span id="btnLabel">登 录</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="third-area" id="thirdArea" style="display:none">
|
||||
<div class="divider"><span>其他登录方式</span></div>
|
||||
<div class="third-list" id="thirdList"></div>
|
||||
</div>
|
||||
{{end}}
|
||||
</ul>
|
||||
<div class="back"><a onclick="logout()">切换账号</a></div>
|
||||
{{else}}
|
||||
<!-- 账号密码登录 -->
|
||||
<form id="loginForm" onsubmit="return submitLogin(event)">
|
||||
<div class="field">
|
||||
<label>账号</label>
|
||||
<input id="account" type="text" placeholder="手机号 / 邮箱 / 企业账号" autocomplete="username" autofocus>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>密码</label>
|
||||
<input id="password" type="password" placeholder="请输入密码" autocomplete="current-password">
|
||||
</div>
|
||||
<button class="btn" id="submitBtn" type="submit">登 录</button>
|
||||
</form>
|
||||
|
||||
<!-- 第三方登录 -->
|
||||
<div class="third-area" id="thirdArea" style="display:none">
|
||||
<div class="divider"><span>其他登录方式</span></div>
|
||||
<div class="third-list" id="thirdList"></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="tip">统一身份认证 · 一次登录,全平台通行</div>
|
||||
<div class="foot-tip">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3l7 3v6c0 4.2-2.9 7.6-7 9-4.1-1.4-7-4.8-7-9V6l7-3z"/>
|
||||
</svg>
|
||||
<span>统一身份认证 · 一次登录,全平台通行</span>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var CLIENT_ID = "{{.ClientID}}";
|
||||
var REDIRECT = "{{.Redirect}}";
|
||||
|
||||
// 人机验证配置:页面加载时拉取,决定是否弹出极验
|
||||
var verifyCfg = { openVerify: 0, verifyType: '', needGeetest: false, geetestVersion: '4', captchaId: '' };
|
||||
// 极验 4.0 SDK:优先官方 CDN,失败回退到本地副本(static/js/gt4.js)
|
||||
var GEETEST_SDK_CDN = 'https://static.geetest.com/v4/gt4.js';
|
||||
var GEETEST_SDK_LOCAL = '/static/js/gt4.js';
|
||||
var geetestSDKPromise = null;
|
||||
|
||||
function showError(msg) {
|
||||
var el = document.getElementById('error');
|
||||
el.textContent = msg;
|
||||
el.style.display = 'block';
|
||||
var box = document.getElementById('error');
|
||||
document.getElementById('errorText').textContent = msg;
|
||||
box.classList.remove('show');
|
||||
void box.offsetWidth; // 重启动画
|
||||
box.classList.add('show');
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
var box = document.getElementById('error');
|
||||
if (box) box.classList.remove('show');
|
||||
}
|
||||
|
||||
function setBtnLoading(on, text) {
|
||||
var btn = document.getElementById('submitBtn');
|
||||
if (!btn) return;
|
||||
btn.disabled = !!on;
|
||||
if (on) {
|
||||
btn.classList.add('is-loading');
|
||||
document.getElementById('btnLabel').textContent = text || '登录中...';
|
||||
} else {
|
||||
btn.classList.remove('is-loading');
|
||||
document.getElementById('btnLabel').textContent = '登 录';
|
||||
}
|
||||
}
|
||||
|
||||
function togglePassword() {
|
||||
var input = document.getElementById('password');
|
||||
var open = document.getElementById('eyeOpen');
|
||||
var off = document.getElementById('eyeOff');
|
||||
var btn = document.getElementById('eyeBtn');
|
||||
var show = input.type === 'password';
|
||||
input.type = show ? 'text' : 'password';
|
||||
open.style.display = show ? 'none' : 'block';
|
||||
off.style.display = show ? 'block' : 'none';
|
||||
btn.title = show ? '隐藏密码' : '显示密码';
|
||||
}
|
||||
|
||||
// 记住账号:只存账号,绝不明文保存密码
|
||||
(function initRemember() {
|
||||
var box = document.getElementById('remember');
|
||||
if (!box) return;
|
||||
var saved = '';
|
||||
try { saved = localStorage.getItem('authCenterAccount') || ''; } catch (e) { saved = ''; }
|
||||
if (saved) {
|
||||
document.getElementById('account').value = saved;
|
||||
box.checked = true;
|
||||
}
|
||||
box.addEventListener('change', function () {
|
||||
try {
|
||||
if (box.checked) localStorage.setItem('authCenterAccount', document.getElementById('account').value.trim());
|
||||
else localStorage.removeItem('authCenterAccount');
|
||||
} catch (e) { /* 忽略隐私模式下的存储失败 */ }
|
||||
});
|
||||
})();
|
||||
|
||||
function loadScript(url) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var s = document.createElement('script');
|
||||
s.src = url;
|
||||
s.async = true;
|
||||
s.onload = function () { resolve(); };
|
||||
s.onerror = function () { reject(new Error('加载脚本失败')); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
// 加载极验 4.0 SDK(多次调用只加载一次)
|
||||
function loadGeetestSDK() {
|
||||
if (window.initGeetest4) return Promise.resolve();
|
||||
if (geetestSDKPromise) return geetestSDKPromise;
|
||||
geetestSDKPromise = loadScript(GEETEST_SDK_CDN)
|
||||
.catch(function () { return loadScript(GEETEST_SDK_LOCAL); })
|
||||
.then(function () {
|
||||
if (!window.initGeetest4) throw new Error('极验 SDK 加载失败');
|
||||
})
|
||||
.catch(function (err) {
|
||||
geetestSDKPromise = null;
|
||||
throw err;
|
||||
});
|
||||
return geetestSDKPromise;
|
||||
}
|
||||
|
||||
// 拉取登录验证配置(含极验 captcha_id)
|
||||
function loadVerifyConfig() {
|
||||
return fetch('/auth/verify-config')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
var d = (res && res.data) || {};
|
||||
verifyCfg.openVerify = Number(d.openVerify) || 0;
|
||||
verifyCfg.verifyType = d.verifyType || '';
|
||||
verifyCfg.needGeetest = !!d.needGeetest;
|
||||
verifyCfg.geetestVersion = d.geetestVersion || '4';
|
||||
verifyCfg.captchaId = d.captchaId || '';
|
||||
// 预加载 SDK,点击登录时能立刻弹出
|
||||
if (verifyCfg.needGeetest && verifyCfg.captchaId) {
|
||||
loadGeetestSDK().catch(function () { /* 点击时再提示 */ });
|
||||
}
|
||||
})
|
||||
.catch(function () { verifyCfg.needGeetest = false; });
|
||||
}
|
||||
|
||||
loadVerifyConfig();
|
||||
|
||||
// 加载已开通的第三方登录方式
|
||||
function loadThirdProviders() {
|
||||
fetch('/auth/third/list')
|
||||
@@ -135,6 +623,7 @@
|
||||
enabled.forEach(function (item) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'third-btn';
|
||||
btn.type = 'button';
|
||||
btn.textContent = item.name;
|
||||
btn.onclick = function () { startThird(item.provider); };
|
||||
box.appendChild(btn);
|
||||
@@ -151,28 +640,123 @@
|
||||
|
||||
loadThirdProviders();
|
||||
|
||||
// 弹出极验 4.0,验证通过才 resolve 出校验参数
|
||||
function showGeetest(captchaId) {
|
||||
return loadGeetestSDK().then(function () {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var settled = false;
|
||||
var done = function (fn) { if (settled) return; settled = true; fn(); };
|
||||
var instance = null;
|
||||
var teardown = function () {
|
||||
try { if (instance && instance.destroy) instance.destroy(); } catch (err) { /* ignore */ }
|
||||
};
|
||||
|
||||
window.initGeetest4({
|
||||
captchaId: captchaId,
|
||||
product: 'bind',
|
||||
language: 'zh-CN',
|
||||
https: true
|
||||
}, function (captcha) {
|
||||
instance = captcha;
|
||||
|
||||
captcha.onSuccess(function () {
|
||||
var r = captcha.getValidate() || {};
|
||||
teardown();
|
||||
done(function () {
|
||||
resolve({
|
||||
captcha_id: r.captcha_id || captchaId,
|
||||
lot_number: r.lot_number || '',
|
||||
pass_token: r.pass_token || '',
|
||||
gen_time: r.gen_time || '',
|
||||
captcha_output: r.captcha_output || ''
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
captcha.onFail(function () {
|
||||
teardown();
|
||||
done(function () { reject(new Error('人机验证未通过,请重新验证')); });
|
||||
});
|
||||
|
||||
captcha.onError(function () {
|
||||
teardown();
|
||||
done(function () { reject(new Error('人机验证加载失败,请重试')); });
|
||||
});
|
||||
|
||||
if (captcha.onClose) {
|
||||
captcha.onClose(function () {
|
||||
setTimeout(function () {
|
||||
if (settled) return;
|
||||
teardown();
|
||||
done(function () { reject(new Error('已取消人机验证')); });
|
||||
}, 400);
|
||||
});
|
||||
}
|
||||
|
||||
captcha.showCaptcha();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function submitLogin(e) {
|
||||
e.preventDefault();
|
||||
hideError();
|
||||
var account = document.getElementById('account').value.trim();
|
||||
var password = document.getElementById('password').value;
|
||||
if (!account) { showError('请输入账号'); return false; }
|
||||
if (!password) { showError('请输入密码'); return false; }
|
||||
|
||||
var btn = document.getElementById('submitBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '登录中...';
|
||||
// 记住账号
|
||||
try {
|
||||
if (document.getElementById('remember').checked) localStorage.setItem('authCenterAccount', account);
|
||||
else localStorage.removeItem('authCenterAccount');
|
||||
} catch (err) { /* ignore */ }
|
||||
|
||||
// 开启极验时:先弹验证,通过后才继续登录
|
||||
if (verifyCfg.needGeetest) {
|
||||
if (!verifyCfg.captchaId) {
|
||||
showError('人机验证未配置,请联系管理员');
|
||||
return false;
|
||||
}
|
||||
setBtnLoading(true, '安全验证中...');
|
||||
showGeetest(verifyCfg.captchaId)
|
||||
.then(function (result) {
|
||||
setBtnLoading(true, '登录中...');
|
||||
doLogin(account, password, result);
|
||||
})
|
||||
.catch(function (err) {
|
||||
showError(err.message || '人机验证失败,请重试');
|
||||
setBtnLoading(false);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
setBtnLoading(true, '登录中...');
|
||||
doLogin(account, password, null);
|
||||
return false;
|
||||
}
|
||||
|
||||
function doLogin(account, password, captcha) {
|
||||
var payload = { account: account, password: password, client_id: CLIENT_ID, redirect: REDIRECT };
|
||||
if (captcha) {
|
||||
payload.captcha_id = captcha.captcha_id;
|
||||
payload.lot_number = captcha.lot_number;
|
||||
payload.pass_token = captcha.pass_token;
|
||||
payload.gen_time = captcha.gen_time;
|
||||
payload.captcha_output = captcha.captcha_output;
|
||||
}
|
||||
|
||||
fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ account: account, password: password, client_id: CLIENT_ID, redirect: REDIRECT })
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res.code !== 200) {
|
||||
showError(res.msg || '登录失败');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '登 录';
|
||||
setBtnLoading(false);
|
||||
return;
|
||||
}
|
||||
if (res.need_choose_tenant) {
|
||||
@@ -184,13 +768,12 @@
|
||||
})
|
||||
.catch(function () {
|
||||
showError('网络异常,请稍后重试');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '登 录';
|
||||
setBtnLoading(false);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
function chooseTenant(tid) {
|
||||
hideError();
|
||||
fetch('/auth/switch-tenant', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -201,7 +784,8 @@
|
||||
if (res.code !== 200) { showError(res.msg || '切换失败'); return; }
|
||||
if (res.redirect) { window.location.href = res.redirect; return; }
|
||||
window.location.href = '/auth/login';
|
||||
});
|
||||
})
|
||||
.catch(function () { showError('网络异常,请稍后重试'); });
|
||||
}
|
||||
|
||||
function logout() {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<el-aside :width="width" :class="['common-aside', { 'mobile-open': isMobile && !isCollapse }]">
|
||||
<el-aside
|
||||
:width="width"
|
||||
:class="[
|
||||
'common-aside',
|
||||
{ 'mobile-open': isMobile && !isCollapse, 'is-collapsed': isCollapse },
|
||||
]"
|
||||
>
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-spinner">
|
||||
<i class="el-icon-loading" style="font-size: 24px; color: #fff"></i>
|
||||
@@ -12,8 +18,15 @@
|
||||
<el-button size="small" @click="fetchMenus">重新加载</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 菜单标题:放在 el-menu 之外,避免随菜单一起滚动消失 -->
|
||||
<h3 v-if="showMenu">
|
||||
{{ isCollapse ? "管理" : asideTitle }}
|
||||
<span class="mobile-close-btn" @click="closeMobile">✕</span>
|
||||
</h3>
|
||||
|
||||
<!-- 菜单主体 -->
|
||||
<el-menu
|
||||
v-if="showMenu"
|
||||
:collapse="isCollapse"
|
||||
:collapse-transition="false"
|
||||
:background-color="asideBgColor"
|
||||
@@ -21,17 +34,12 @@
|
||||
:active-text-color="activeColor"
|
||||
:active-background-color="activeBgColor"
|
||||
class="el-menu-vertical-demo"
|
||||
popper-class="common-aside-popper"
|
||||
:unique-opened="true"
|
||||
:default-openeds="defaultOpeneds"
|
||||
@select="handleMenuSelect"
|
||||
:default-active="route.path"
|
||||
>
|
||||
<!-- 菜单标题 -->
|
||||
<h3>
|
||||
{{ isCollapse ? "管理" : asideTitle }}
|
||||
<span class="mobile-close-btn" @click="closeMobile">✕</span>
|
||||
</h3>
|
||||
|
||||
<!-- 无模块时显示提示(在首页 /home 时不显示,避免重复) -->
|
||||
<el-menu-item v-if="!currentModule && route.path !== '/home'" index="/home">
|
||||
<i class="fa-solid fa-house"></i>
|
||||
@@ -56,6 +64,7 @@
|
||||
v-else
|
||||
:index="item.path || item.id.toString()"
|
||||
:unique-opened="true"
|
||||
teleported
|
||||
>
|
||||
<template #title>
|
||||
<i v-if="item.icon" :class="item.icon" class="menu-icon"></i>
|
||||
@@ -78,6 +87,7 @@
|
||||
v-else
|
||||
:index="child.path || child.id.toString()"
|
||||
:unique-opened="true"
|
||||
teleported
|
||||
>
|
||||
<template #title>
|
||||
<i v-if="child.icon" :class="child.icon" class="menu-icon"></i>
|
||||
@@ -109,6 +119,7 @@
|
||||
v-else
|
||||
:index="grandchild.path || grandchild.id.toString()"
|
||||
:unique-opened="true"
|
||||
teleported
|
||||
>
|
||||
<template #title>
|
||||
<i
|
||||
@@ -180,7 +191,9 @@ const errorMsg = computed(() => menuStore.error || "加载菜单失败");
|
||||
|
||||
const store = useAllDataStore();
|
||||
const isCollapse = computed(() => store.state.isCollapse);
|
||||
const width = computed(() => (store.state.isCollapse ? "64px" : "200px"));
|
||||
const width = computed(() => (store.state.isCollapse ? "64px" : "220px"));
|
||||
// 菜单与标题的显示条件(与加载/错误分支互斥,避免 v-else 绑定错元素)
|
||||
const showMenu = computed(() => !loading.value && !hasError.value);
|
||||
|
||||
const asideBgColor = ref("#304156");
|
||||
const asideTextColor = ref("#bfcbd9");
|
||||
@@ -598,14 +611,14 @@ h3 {
|
||||
}
|
||||
}
|
||||
|
||||
// 悬浮样式
|
||||
// 悬浮样式:一律用蓝色系,避免 EP 默认的深灰/黑色 hover
|
||||
.el-menu-item:hover:not(.is-active),
|
||||
.el-sub-menu__title:hover {
|
||||
html:not(.dark) & {
|
||||
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||
background-color: rgba(30, 86, 210, 0.5) !important;
|
||||
}
|
||||
html.dark & {
|
||||
background-color: rgba(60, 60, 60, 0.8) !important;
|
||||
background-color: rgba(57, 115, 255, 0.45) !important;
|
||||
}
|
||||
color: #ffffff !important;
|
||||
}
|
||||
@@ -662,8 +675,8 @@ h3 {
|
||||
|
||||
.el-menu-item:hover:not(.is-active),
|
||||
.el-sub-menu__title:hover {
|
||||
background: rgba(255, 255, 255, 0.08) !important;
|
||||
color: var(--el-color-primary-light-3) !important;
|
||||
background: rgba(57, 115, 255, 0.45) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.el-sub-menu .el-menu-item.is-active {
|
||||
@@ -697,6 +710,51 @@ h3 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 折叠态修复
|
||||
// 1) 底部折叠按钮在折叠态会隐藏,菜单高度却仍按按钮占位减掉 128px,
|
||||
// 导致收起后底部出现大片空白 → 折叠时只减掉标题区高度
|
||||
.common-aside.is-collapsed :deep(.el-menu) {
|
||||
height: calc(100% - 60px);
|
||||
}
|
||||
|
||||
// 2) EP 会把 .el-menu 强制成 64px,与 el-aside 的宽度动画不同步,
|
||||
// 收起瞬间内容先跳变 → 改回 100% 跟随动画
|
||||
// 3) 菜单项默认左右内边距 + 图标右外边距在 64px 下使图标偏左 → 归零并居中
|
||||
// 4) 64px 宽栏里的 4px 滚动条很突兀 → 折叠时隐藏(仍可滚动)
|
||||
:deep(.el-menu.el-menu--collapse) {
|
||||
width: 100% !important;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.el-menu-item,
|
||||
.el-sub-menu > .el-sub-menu__title {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.el-menu-item.is-active {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.common-aside.is-collapsed h3 {
|
||||
font-size: 15px;
|
||||
padding: 0 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.common-aside {
|
||||
@@ -742,3 +800,75 @@ h3 {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
// 折叠后 hover 弹出的子菜单浮层被 teleport 到 body,scoped 样式命中不了,
|
||||
// 只能用 popper-class + 全局样式配色,否则会是 Element Plus 默认的白底浮层
|
||||
.common-aside-popper.el-popper {
|
||||
background: #3973ff !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14) !important;
|
||||
box-shadow: 0 8px 24px rgba(6, 45, 163, 0.28);
|
||||
|
||||
.el-popper__arrow::before {
|
||||
background: #3973ff !important;
|
||||
border-color: rgba(255, 255, 255, 0.14) !important;
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
background: transparent !important;
|
||||
padding: 4px;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
// 菜单项与"还有下级"的父级标题统一配色,避免 EP 默认的深灰/黑色 hover
|
||||
.el-menu-item {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(30, 86, 210, 0.55) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: rgba(30, 86, 210, 0.7) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-sub-menu__title {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
border-radius: 8px;
|
||||
background: transparent !important;
|
||||
|
||||
&:hover {
|
||||
background: rgba(30, 86, 210, 0.55) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 折叠态下 EP 用 ArrowRight 表示"还有下级",但展开时会内联 rotateZ(180deg) 把箭头翻成向左;
|
||||
// 浮层里的子菜单应当始终指向右(表示继续向右展开),故强制不旋转(内联样式需 !important 覆盖)
|
||||
.el-sub-menu__icon-arrow {
|
||||
transform: rotateZ(0deg) !important;
|
||||
}
|
||||
|
||||
// 浮层宽度收敛,降低深层级因空间不足被 popper 翻转到左侧的概率
|
||||
.el-menu {
|
||||
min-width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark {
|
||||
.common-aside-popper.el-popper {
|
||||
background: #2d2d2d !important;
|
||||
border-color: rgba(255, 255, 255, 0.1) !important;
|
||||
|
||||
.el-popper__arrow::before {
|
||||
background: #2d2d2d !important;
|
||||
border-color: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -352,7 +352,12 @@ async function submitSSOLogin() {
|
||||
account,
|
||||
name: (data.identity && (data.identity.nickname || data.identity.mobile)) || account,
|
||||
tid: currentTid,
|
||||
tenant_name: (data.tenants || []).map(t => t.tenant_name).join('、')
|
||||
// 顶部展示当前登录的企业,而不是该账号可进入的全部企业
|
||||
tenant_name: (() => {
|
||||
const list = data.tenants || []
|
||||
const hit = list.find(t => t.tid === currentTid)
|
||||
return (hit && hit.tenant_name) || (list[0] && list[0].tenant_name) || ''
|
||||
})()
|
||||
}
|
||||
})
|
||||
navigateAfterLogin()
|
||||
|
||||
Reference in New Issue
Block a user