更新
This commit is contained in:
1 parent
b6b528490c
commit
4ab63e77ed
28 files changed
+1858
-695
No files matched your search
@@ -21,6 +21,23 @@ const staticMainChildren = [
|
||||
component: () => import("@/views/system/email/index.vue"),
|
||||
meta: { requiresAuth: true, title: "邮箱管理" }
|
||||
},
|
||||
// 平台 AI 服务商配置与 Playground(设置页保留 /setting 兼容路径)
|
||||
{
|
||||
path: "/system/aisettings",
|
||||
name: "PlatformAiSettings",
|
||||
component: () => import("@/views/system/aisettings/index.vue"),
|
||||
meta: { requiresAuth: true, title: "AI 管理" }
|
||||
},
|
||||
{
|
||||
path: "/system/aisettings/setting",
|
||||
redirect: "/system/aisettings"
|
||||
},
|
||||
{
|
||||
path: "/system/aisettings/playground",
|
||||
name: "PlatformAiPlayground",
|
||||
component: () => import("@/views/system/aisettings/playground.vue"),
|
||||
meta: { requiresAuth: true, title: "AI Playground" }
|
||||
},
|
||||
// 统一认证中心:接入应用(OIDC Client)注册管理
|
||||
{
|
||||
path: "/system/authClient",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 将 Font Awesome HTML 片段或普通 class 字符串统一转换为可存储的 class 字符串。
|
||||
* 例如:<i class="fa-solid fa-water-ladder"></i> -> fa-solid fa-water-ladder
|
||||
*/
|
||||
export function normalizeFontAwesomeIcon(value) {
|
||||
const input = String(value ?? "").trim();
|
||||
if (!input) return "";
|
||||
|
||||
const classAttribute = input.match(/\bclass\s*=\s*(["'])(.*?)\1/i);
|
||||
const source = classAttribute ? classAttribute[2] : input;
|
||||
const tokens = source
|
||||
.replace(/<\/?i\b[^>]*>/gi, " ")
|
||||
.replace(/[<>"'`]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
|
||||
// FA 图标家族/样式前缀不是图形本身。保留一个样式前缀和最后一个图标名,
|
||||
// 这样旧值中意外累积的多个 fa-* 图标也不会同时渲染。
|
||||
const styleClasses = new Set([
|
||||
"fas", "far", "fab", "fal", "fad", "fat", "fass", "fasr", "fasl", "fasds",
|
||||
"fa-solid", "fa-regular", "fa-brands", "fa-light", "fa-thin", "fa-duotone",
|
||||
"fa-sharp", "fa-sharp-solid", "fa-sharp-regular", "fa-sharp-light", "fa-sharp-thin",
|
||||
]);
|
||||
const modifierClasses = new Set([
|
||||
"fa-fw", "fa-ul", "fa-li", "fa-border", "fa-pull-left", "fa-pull-right",
|
||||
"fa-spin", "fa-spin-pulse", "fa-pulse", "fa-fade", "fa-beat", "fa-beat-fade",
|
||||
"fa-bounce", "fa-flip", "fa-shake", "fa-xs", "fa-sm", "fa-lg", "fa-xl", "fa-2xl",
|
||||
]);
|
||||
const styleClass = tokens.find((token) => styleClasses.has(token));
|
||||
const iconClasses = tokens.filter(
|
||||
(token) => token.startsWith("fa-") && !styleClasses.has(token) && !modifierClasses.has(token),
|
||||
);
|
||||
const iconClass = iconClasses.at(-1);
|
||||
|
||||
return [styleClass || (tokens.includes("fa") ? "fa" : ""), iconClass]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
@@ -82,17 +82,22 @@
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<div class="dialog-footer">
|
||||
<el-button v-if="isEdit" type="danger" plain :loading="deleting" @click="handleDelete">删除配置</el-button>
|
||||
<el-button v-if="isEdit" plain :loading="testing" @click="handleTest">测试连接</el-button>
|
||||
<span class="dialog-footer__spacer"></span>
|
||||
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, computed } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import { createAiProvider, updateAiProvider } from "@/api/aisettings";
|
||||
import { createAiProvider, updateAiProvider, testAiProvider, deleteAiProvider } from "@/api/aisettings";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
@@ -118,6 +123,8 @@ const apiTypeTip = computed(() => API_TYPE_TIPS[form.api_type] || "");
|
||||
const apiBasePlaceholder = computed(() => API_BASE_PLACEHOLDERS[form.api_type] || "https://...");
|
||||
|
||||
const submitting = ref(false);
|
||||
const testing = ref(false);
|
||||
const deleting = ref(false);
|
||||
const form = reactive({
|
||||
name: "",
|
||||
api_base: "",
|
||||
@@ -150,6 +157,43 @@ const addModelRow = () => {
|
||||
form.models.push({ id: "", name: "" });
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!props.item?.id) return;
|
||||
testing.value = true;
|
||||
try {
|
||||
const res = await testAiProvider({ id: props.item.id, model: form.default_model || form.models.find((m) => m.id)?.id || "" });
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success(`连接成功 · ${res.data?.latency_ms ?? "-"} ms`);
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "连接测试失败");
|
||||
}
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!props.item?.id) return;
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${props.item.name}」配置吗?`, "删除确认", { type: "warning" });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
deleting.value = true;
|
||||
try {
|
||||
const res = await deleteAiProvider(props.item.id);
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success("配置已删除");
|
||||
emit("update:modelValue", false);
|
||||
emit("saved");
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "删除失败");
|
||||
}
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim()) return ElMessage.warning("请填写配置名称");
|
||||
if (!form.api_base.trim()) return ElMessage.warning("请填写 API 地址");
|
||||
@@ -197,6 +241,17 @@ const handleSubmit = async () => {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
|
||||
&__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.models-table {
|
||||
width: 100%;
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ const loadList = async () => {
|
||||
listLoading.value = true;
|
||||
try {
|
||||
const res = await getAiProviderList();
|
||||
if (res?.code === 200) list.value = res.data || [];
|
||||
if (res?.code === 200) list.value = res.data?.list || res.data || [];
|
||||
} finally {
|
||||
listLoading.value = false;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,15 @@
|
||||
>{{ s.label }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-input
|
||||
v-model="systemPrompt"
|
||||
class="pg-system-prompt"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
resize="vertical"
|
||||
placeholder="系统提示词(可选):定义这个智能体的角色、目标和回答规则"
|
||||
/>
|
||||
|
||||
<div class="pg-chat" ref="chatRef">
|
||||
<div v-if="!messages.length" class="pg-empty">
|
||||
选择服务商与模型后,输入场景问题开始测试对话效果
|
||||
@@ -126,7 +135,7 @@
|
||||
</div>
|
||||
|
||||
<div class="pg-input">
|
||||
<el-tooltip content="上传附件(图片/PDF,直传模型不存储)" placement="top">
|
||||
<el-tooltip content="上传图片(直传模型,不存储)" placement="top">
|
||||
<el-button class="pg-attach-btn" @click="fileInputRef?.click()">
|
||||
<el-icon><Paperclip /></el-icon>
|
||||
</el-button>
|
||||
@@ -134,7 +143,7 @@
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept="image/*,.pdf"
|
||||
accept="image/*"
|
||||
multiple
|
||||
style="display: none"
|
||||
@change="handleFiles"
|
||||
@@ -184,6 +193,7 @@ const providerId = ref(null);
|
||||
const model = ref("");
|
||||
const messages = ref([]);
|
||||
const input = ref("");
|
||||
const systemPrompt = ref("");
|
||||
const sending = ref(false);
|
||||
const chatRef = ref(null);
|
||||
const fileInputRef = ref(null);
|
||||
@@ -195,9 +205,12 @@ const renamingText = ref("");
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
const TYPE_LABELS = { deepseek: "DeepSeek", doubao: "豆包", codex: "Codex", custom: "自定义" };
|
||||
const API_TYPES = { chat_completions: "Chat", responses: "Resp", anthropic: "Anthropic" };
|
||||
const apiTypeLabel = (t) => TYPE_LABELS[t] || t;
|
||||
const API_TYPES = {
|
||||
chat_completions: "OpenAI 兼容",
|
||||
responses: "OpenAI Responses",
|
||||
anthropic: "Anthropic Messages",
|
||||
};
|
||||
const apiTypeLabel = (t) => API_TYPES[t] || t || "OpenAI 兼容";
|
||||
|
||||
const currentProvider = computed(() => providers.value.find((p) => p.id === providerId.value));
|
||||
const modelOptions = computed(() =>
|
||||
@@ -459,13 +472,17 @@ const handleFiles = async (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
e.target.value = "";
|
||||
for (const f of files) {
|
||||
if (!f.type?.startsWith("image/")) {
|
||||
ElMessage.warning(`${f.name} 不是支持的图片格式,已跳过`);
|
||||
continue;
|
||||
}
|
||||
if (f.size > MAX_FILE_SIZE) {
|
||||
ElMessage.warning(`${f.name} 超过 10MB,已跳过`);
|
||||
continue;
|
||||
}
|
||||
let dataUrl = await fileToDataURL(f);
|
||||
const isImage = f.type?.startsWith("image/");
|
||||
if (isImage) dataUrl = await compressImage(dataUrl);
|
||||
const isImage = true;
|
||||
dataUrl = await compressImage(dataUrl);
|
||||
// 上游网关一般限制请求体 ~4-10MB,base64 约膨胀 33%,超过 5MB 原始文件风险较高
|
||||
if (f.size > 5 * 1024 * 1024 && !isImage) {
|
||||
ElMessage.warning(`${f.name} 较大(${(f.size / 1024 / 1024).toFixed(1)}MB),上游可能拒绝,请尝试压缩后重试`);
|
||||
@@ -519,6 +536,9 @@ const send = async () => {
|
||||
const history = messages.value
|
||||
.filter((m) => !m._streaming && (m.content || m.attachments?.length))
|
||||
.map((m) => ({ role: m.role, content: buildApiContent(m) }));
|
||||
if (systemPrompt.value.trim()) {
|
||||
history.unshift({ role: "system", content: systemPrompt.value.trim() });
|
||||
}
|
||||
try {
|
||||
await doStream(history, assistantMsg);
|
||||
} catch (e) {
|
||||
@@ -720,6 +740,10 @@ onMounted(() => {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.pg-system-prompt {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.pg-chat {
|
||||
flex: 1;
|
||||
min-height: 280px;
|
||||
|
||||
@@ -68,10 +68,19 @@
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="图标" prop="icon">
|
||||
<el-input
|
||||
v-model="currentMenu.icon"
|
||||
placeholder="例如:fas fa-tachometer-alt"
|
||||
/>
|
||||
<div class="icon-field">
|
||||
<el-input
|
||||
v-model="iconInput"
|
||||
placeholder='输入 Font Awesome class 或 <i class="fa-solid fa-water-ladder"></i>'
|
||||
@input="handleIconInput"
|
||||
@blur="normalizeIconInput"
|
||||
/>
|
||||
<div class="icon-preview">
|
||||
<i v-if="normalizedIcon" :class="normalizedIcon"></i>
|
||||
<span v-if="!normalizedIcon">图标预览</span>
|
||||
</div>
|
||||
<div class="icon-tip">支持直接粘贴官网的 <i class="..."></i>,保存时会自动提取 class 内容。</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="排序" prop="sort">
|
||||
@@ -136,6 +145,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ElMessage, ElForm } from "element-plus";
|
||||
import { normalizeFontAwesomeIcon } from "@/utils/fontAwesomeIcon";
|
||||
|
||||
// 定义菜单数据类型
|
||||
interface Menu {
|
||||
@@ -182,6 +192,19 @@ const emit = defineEmits<{
|
||||
|
||||
// 表单引用
|
||||
const menuFormRef = ref<InstanceType<typeof ElForm>>();
|
||||
const iconInput = ref("");
|
||||
const normalizedIcon = computed(() => normalizeFontAwesomeIcon(iconInput.value));
|
||||
|
||||
const handleIconInput = (value: string) => {
|
||||
const normalized = normalizeFontAwesomeIcon(value);
|
||||
iconInput.value = normalized;
|
||||
currentMenu.value.icon = normalized;
|
||||
};
|
||||
|
||||
const normalizeIconInput = () => {
|
||||
iconInput.value = normalizeFontAwesomeIcon(iconInput.value);
|
||||
currentMenu.value.icon = iconInput.value;
|
||||
};
|
||||
|
||||
// 当前操作的菜单
|
||||
const currentMenu = ref<Partial<Menu>>({
|
||||
@@ -252,7 +275,9 @@ watch(() => props.menu, (newMenu) => {
|
||||
currentMenu.value = {
|
||||
...newMenu,
|
||||
pid: normalizedPid,
|
||||
icon: normalizeFontAwesomeIcon(newMenu.icon),
|
||||
};
|
||||
iconInput.value = normalizeFontAwesomeIcon(newMenu.icon);
|
||||
} else {
|
||||
// 重置表单
|
||||
currentMenu.value = {
|
||||
@@ -270,6 +295,7 @@ watch(() => props.menu, (newMenu) => {
|
||||
type: 1,
|
||||
permission: '',
|
||||
};
|
||||
iconInput.value = "";
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
@@ -309,6 +335,7 @@ watch(() => props.visible, (newVisible) => {
|
||||
type: 1,
|
||||
permission: '',
|
||||
};
|
||||
iconInput.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
@@ -446,6 +473,7 @@ const handleSave = async () => {
|
||||
|
||||
// 解决后端时间字段问题:过滤掉不需要的字段
|
||||
const payload = { ...currentMenu.value };
|
||||
payload.icon = normalizeFontAwesomeIcon(iconInput.value || currentMenu.value.icon);
|
||||
|
||||
// 再次确保 pid 是整数类型(双重保险)
|
||||
// 处理数组情况:如果 pid 是数组,取最后一个元素(el-cascader 返回的是路径数组)
|
||||
@@ -503,4 +531,32 @@ const handleSave = async () => {
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.icon-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.icon-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
margin-top: 8px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
|
||||
i {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-tip {
|
||||
margin-top: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -198,6 +198,7 @@ import {
|
||||
deleteMenu,
|
||||
} from "@/api/menu";
|
||||
import MenuEdit from "./components/edit.vue";
|
||||
import { normalizeFontAwesomeIcon } from "@/utils/fontAwesomeIcon";
|
||||
|
||||
// 定义菜单数据类型
|
||||
interface Menu {
|
||||
@@ -264,6 +265,9 @@ const fetchMenus = async () => {
|
||||
const result = await getAllMenus();
|
||||
if (result.code === 200) {
|
||||
const sortedMenus = sortMenuTree(result.data || []);
|
||||
getAllMenuRows(sortedMenus).forEach((menu) => {
|
||||
menu.icon = normalizeFontAwesomeIcon(menu.icon);
|
||||
});
|
||||
menuTree.value = sortedMenus;
|
||||
parentMenuOptions.value = [
|
||||
{
|
||||
@@ -445,6 +449,7 @@ const handleMenuSave = async (menu: Partial<Menu>) => {
|
||||
try {
|
||||
// 解决后端时间字段问题:过滤掉不需要的字段
|
||||
const payload = { ...menu };
|
||||
payload.icon = normalizeFontAwesomeIcon(payload.icon);
|
||||
|
||||
// 确保 pid 是整数类型(后端要求必须是整数)
|
||||
// 处理数组情况:如果 pid 是数组(el-cascader 返回的是路径数组),取最后一个元素
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>统一登录页品牌</span>
|
||||
<span>统一登录页设置</span>
|
||||
<span class="card-tip">
|
||||
保存后用于统一认证中心登录页(/auth/login),未配置项使用默认文案
|
||||
配置统一认证中心登录页(/auth/login)的品牌展示及开放的登录方式,保存后立即生效
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 品牌基本信息 -->
|
||||
<el-form-item label="主标题" prop="login_title">
|
||||
<el-input
|
||||
v-model="form.login_title"
|
||||
@@ -51,7 +52,7 @@
|
||||
<el-icon v-else class="uploader-icon"><Plus /></el-icon>
|
||||
</el-upload>
|
||||
<div class="upload-meta">
|
||||
<p>显示在登录页左上角,建议透明背景 PNG / SVG,高度不小于 88px。</p>
|
||||
<p>显示在登录表单「登录」字样上方,建议透明背景 PNG / SVG,高度不小于 88px。</p>
|
||||
<el-button v-if="form.login_logo" link type="danger" @click="form.login_logo = ''">
|
||||
移除 Logo
|
||||
</el-button>
|
||||
@@ -85,37 +86,129 @@
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">效果预览</el-divider>
|
||||
<!-- 登录方式开关 -->
|
||||
<el-divider content-position="left">登录方式开关</el-divider>
|
||||
|
||||
<el-form-item label="账号密码登录">
|
||||
<el-switch v-model="form.login_pwd_enabled" active-value="1" inactive-value="0" />
|
||||
<div class="form-tip">账号 + 密码登录。关闭后登录页不再显示该选项卡,接口侧同步拒绝。</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="短信登录">
|
||||
<el-switch v-model="form.login_sms_enabled" active-value="1" inactive-value="0" />
|
||||
<div class="form-tip">手机号 + 短信验证码登录。关闭后登录页不再显示该选项卡。</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="扫码登录">
|
||||
<el-switch v-model="form.login_qr_enabled" active-value="1" inactive-value="0" />
|
||||
<div class="form-tip">微信扫码登录(温新云小程序内确认)。关闭后登录页不再显示该选项卡。</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-alert
|
||||
v-if="allDisabled"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 16px;"
|
||||
title="当前已关闭全部登录方式,统一认证中心将提示「登录功能已关闭」,所有用户无法登录,请谨慎操作。"
|
||||
/>
|
||||
|
||||
<el-divider content-position="left">效果预览(实时联动)</el-divider>
|
||||
|
||||
<div class="brand-preview">
|
||||
<!-- 左侧品牌区预览 -->
|
||||
<div class="preview-left" :class="{ 'has-bg': !!form.login_bg }">
|
||||
<img v-if="form.login_bg" :src="previewUrl(form.login_bg)" class="preview-bg" alt="" />
|
||||
<span v-if="form.login_bg" class="preview-veil"></span>
|
||||
<div class="preview-inner">
|
||||
<img
|
||||
v-if="form.login_logo"
|
||||
:src="previewUrl(form.login_logo)"
|
||||
class="preview-logo"
|
||||
alt="Logo"
|
||||
/>
|
||||
<div v-else class="preview-logo-fallback">温新云统一认证中心</div>
|
||||
<div class="preview-badge">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
|
||||
</div>
|
||||
<p class="preview-title">{{ form.login_title || "一次登录,全平台通行" }}</p>
|
||||
<p class="preview-sub">
|
||||
{{ form.login_subtitle || "基于 OIDC / OAuth 2.0 的统一身份底座" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧表单区预览:实时响应品牌与登录方式开关 -->
|
||||
<div class="preview-right">
|
||||
<p class="mock-title">登录</p>
|
||||
<div class="mock-tabs">
|
||||
<span class="active">账号密码</span>
|
||||
<span>短信登录</span>
|
||||
<span>扫码登录</span>
|
||||
<!-- 登录字样上方的 Logo / 品牌标识 -->
|
||||
<div class="mock-brand">
|
||||
<img
|
||||
v-if="form.login_logo"
|
||||
:src="previewUrl(form.login_logo)"
|
||||
class="preview-logo"
|
||||
alt="Logo"
|
||||
/>
|
||||
<div v-else class="preview-logo-fallback">
|
||||
<span class="mock-logo-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" 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="mock-logo-name">温新云统一认证中心</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mock-input"></div>
|
||||
<div class="mock-input"></div>
|
||||
<div class="mock-btn">登 录</div>
|
||||
<div class="mock-third">微信登录 · QQ 登录</div>
|
||||
|
||||
<p class="mock-title">欢迎登陆您的工作空间</p>
|
||||
|
||||
<!-- 全部关闭时提示已关闭 -->
|
||||
<div v-if="allDisabled" class="mock-disabled">
|
||||
<el-icon class="mock-disabled-icon"><Lock /></el-icon>
|
||||
<p class="mock-disabled-title">登录功能已关闭</p>
|
||||
<p class="mock-disabled-desc">已关闭全部登录方式,无法登录</p>
|
||||
</div>
|
||||
|
||||
<!-- 至少开启一种登录方式时 -->
|
||||
<template v-else>
|
||||
<!-- 开启方式多于 1 种时展示选项卡切换,实时响应各开关 -->
|
||||
<div v-if="showTabs" class="mock-tabs">
|
||||
<span
|
||||
v-if="pwdEnabled"
|
||||
:class="{ active: currentActiveTab === 'account' }"
|
||||
@click="currentActiveTab = 'account'"
|
||||
>账号密码</span>
|
||||
<span
|
||||
v-if="smsEnabled"
|
||||
:class="{ active: currentActiveTab === 'sms' }"
|
||||
@click="currentActiveTab = 'sms'"
|
||||
>短信登录</span>
|
||||
<span
|
||||
v-if="qrEnabled"
|
||||
:class="{ active: currentActiveTab === 'qr' }"
|
||||
@click="currentActiveTab = 'qr'"
|
||||
>扫码登录</span>
|
||||
</div>
|
||||
|
||||
<!-- 面板内容:按当前选中的登录方式切换 -->
|
||||
<div v-if="currentActiveTab === 'account' || currentActiveTab === 'sms'" class="mock-panel">
|
||||
<div class="mock-input">
|
||||
<span class="mock-placeholder">{{ currentActiveTab === 'account' ? '手机号 / 邮箱 / 账号' : '手机号' }}</span>
|
||||
</div>
|
||||
<div class="mock-input">
|
||||
<span class="mock-placeholder">{{ currentActiveTab === 'account' ? '密码' : '短信验证码' }}</span>
|
||||
</div>
|
||||
<div class="mock-btn">登 录</div>
|
||||
</div>
|
||||
|
||||
<!-- 扫码登录面板 -->
|
||||
<div v-else-if="currentActiveTab === 'qr'" class="mock-qr-panel">
|
||||
<div class="mock-qr-box">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 3h8v8H3V3zm2 2v4h4V5H5zm8-2h8v8h-8V3zm2 2v4h4V5h-4zM3 13h8v8H3v-8zm2 2v4h4v-4H5zm13-2h3v2h-3v-2zm-5 0h2v3h-2v-3zm2 3h2v2h-2v-2zm-2 2h2v3h-2v-3zm5-2h3v5h-2v-3h-1v-2zm0-3h1v2h-1v-2zm-3 5h2v2h-2v-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mock-qr-text">微信扫码登录</p>
|
||||
</div>
|
||||
|
||||
<div class="mock-third">微信登录 · QQ 登录</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
@@ -129,9 +222,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import { Plus, Lock } from "@element-plus/icons-vue";
|
||||
import { uploadToWebsiteMaterial } from "@/utils/websiteUpload";
|
||||
import { getLoginBrand, saveLoginBrand } from "@/api/sitesettings";
|
||||
|
||||
@@ -147,11 +240,60 @@ const form = reactive({
|
||||
login_subtitle: "",
|
||||
login_logo: "",
|
||||
login_bg: "",
|
||||
login_pwd_enabled: "1",
|
||||
login_sms_enabled: "1",
|
||||
login_qr_enabled: "1",
|
||||
});
|
||||
|
||||
// 服务端快照,用于“重置”丢弃未保存的改动
|
||||
let snapshot = { ...form };
|
||||
|
||||
// 开关状态实时计算
|
||||
const pwdEnabled = computed(() => form.login_pwd_enabled !== "0");
|
||||
const smsEnabled = computed(() => form.login_sms_enabled !== "0");
|
||||
const qrEnabled = computed(() => form.login_qr_enabled !== "0");
|
||||
|
||||
const allDisabled = computed(
|
||||
() => !pwdEnabled.value && !smsEnabled.value && !qrEnabled.value
|
||||
);
|
||||
|
||||
const enabledCount = computed(() => {
|
||||
let count = 0;
|
||||
if (pwdEnabled.value) count++;
|
||||
if (smsEnabled.value) count++;
|
||||
if (qrEnabled.value) count++;
|
||||
return count;
|
||||
});
|
||||
|
||||
// 开启方式多于1种时才展示 tab 栏,与登录页实际渲染逻辑保持一致
|
||||
const showTabs = computed(() => enabledCount.value > 1);
|
||||
|
||||
// 预览中当前选中的 tab
|
||||
const currentActiveTab = ref("account");
|
||||
|
||||
watch(
|
||||
[pwdEnabled, smsEnabled, qrEnabled],
|
||||
() => {
|
||||
const isCurrentValid =
|
||||
(currentActiveTab.value === "account" && pwdEnabled.value) ||
|
||||
(currentActiveTab.value === "sms" && smsEnabled.value) ||
|
||||
(currentActiveTab.value === "qr" && qrEnabled.value);
|
||||
|
||||
if (!isCurrentValid) {
|
||||
if (pwdEnabled.value) {
|
||||
currentActiveTab.value = "account";
|
||||
} else if (smsEnabled.value) {
|
||||
currentActiveTab.value = "sms";
|
||||
} else if (qrEnabled.value) {
|
||||
currentActiveTab.value = "qr";
|
||||
} else {
|
||||
currentActiveTab.value = "";
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const previewUrl = (url) => {
|
||||
const value = String(url || "").trim().replace(/\\/g, "/");
|
||||
if (!value) return "";
|
||||
@@ -168,6 +310,9 @@ const loadConfig = async () => {
|
||||
form.login_subtitle = res.data.login_subtitle || "";
|
||||
form.login_logo = res.data.login_logo || "";
|
||||
form.login_bg = res.data.login_bg || "";
|
||||
form.login_pwd_enabled = res.data.login_pwd_enabled === "0" ? "0" : "1";
|
||||
form.login_sms_enabled = res.data.login_sms_enabled === "0" ? "0" : "1";
|
||||
form.login_qr_enabled = res.data.login_qr_enabled === "0" ? "0" : "1";
|
||||
snapshot = { ...form };
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "获取登录页配置失败");
|
||||
@@ -214,6 +359,9 @@ const handleSubmit = async () => {
|
||||
login_subtitle: String(form.login_subtitle || "").trim(),
|
||||
login_logo: String(form.login_logo || "").trim(),
|
||||
login_bg: String(form.login_bg || "").trim(),
|
||||
login_pwd_enabled: form.login_pwd_enabled,
|
||||
login_sms_enabled: form.login_sms_enabled,
|
||||
login_qr_enabled: form.login_qr_enabled,
|
||||
});
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success("保存成功,统一登录页已同步更新");
|
||||
@@ -337,8 +485,8 @@ onMounted(loadConfig);
|
||||
.brand-preview {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
height: 260px;
|
||||
max-width: 740px;
|
||||
min-height: 290px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
@@ -380,18 +528,29 @@ onMounted(loadConfig);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.preview-logo {
|
||||
height: 26px;
|
||||
max-width: 140px;
|
||||
object-fit: contain;
|
||||
margin-bottom: 18px;
|
||||
.preview-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 14px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: #fff;
|
||||
|
||||
svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-logo-fallback {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 18px;
|
||||
opacity: 0.9;
|
||||
.preview-left.has-bg .preview-badge {
|
||||
background: rgba(57, 115, 255, 0.12);
|
||||
border-color: rgba(57, 115, 255, 0.18);
|
||||
color: #3973ff;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
@@ -415,54 +574,197 @@ onMounted(loadConfig);
|
||||
.preview-right {
|
||||
flex: 0 0 42%;
|
||||
background: #fff;
|
||||
padding: 26px 24px;
|
||||
padding: 20px 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mock-brand {
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.preview-logo {
|
||||
height: 24px;
|
||||
max-width: 130px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.preview-logo-fallback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mock-logo-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
background: rgba(57, 115, 255, 0.12);
|
||||
color: #3973ff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.mock-logo-name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #1f2b48;
|
||||
}
|
||||
|
||||
.mock-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 18px;
|
||||
margin: 0 0 10px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #1f2b48;
|
||||
}
|
||||
|
||||
.mock-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #eef2fa;
|
||||
font-size: 9px;
|
||||
font-size: 9.5px;
|
||||
color: #8a94ad;
|
||||
|
||||
.active {
|
||||
color: #3973ff;
|
||||
font-weight: 600;
|
||||
span {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #3973ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #3973ff;
|
||||
font-weight: 600;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -7px;
|
||||
height: 2px;
|
||||
background: #3973ff;
|
||||
border-radius: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mock-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mock-input {
|
||||
height: 24px;
|
||||
border: 1px solid #e3e9f5;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 6px;
|
||||
background: #fbfcff;
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mock-placeholder {
|
||||
font-size: 9px;
|
||||
color: #b0b8c8;
|
||||
line-height: 24px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mock-btn {
|
||||
height: 28px;
|
||||
margin-top: 14px;
|
||||
height: 26px;
|
||||
margin-top: 6px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(135deg, #3973ff 0%, #52a8ff 100%);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.mock-qr-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.mock-qr-box {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
background: #f8fafc;
|
||||
border: 1px dashed #c0c8d6;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #4a5568;
|
||||
|
||||
svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.mock-qr-text {
|
||||
margin: 4px 0 0;
|
||||
font-size: 9px;
|
||||
color: #8a94ad;
|
||||
}
|
||||
|
||||
.mock-disabled {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px 8px;
|
||||
text-align: center;
|
||||
background: #fafbfe;
|
||||
border: 1px dashed #e3e9f5;
|
||||
border-radius: 8px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.mock-disabled-icon {
|
||||
font-size: 22px;
|
||||
color: #e6a23c;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.mock-disabled-title {
|
||||
margin: 0 0 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #1f2b48;
|
||||
}
|
||||
|
||||
.mock-disabled-desc {
|
||||
margin: 0;
|
||||
font-size: 9px;
|
||||
color: #a3acbf;
|
||||
}
|
||||
|
||||
.mock-third {
|
||||
margin-top: 10px;
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
font-size: 9px;
|
||||
color: #a3acbf;
|
||||
|
||||
@@ -32,15 +32,10 @@
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 登录页设置(统一认证中心 /auth/login) -->
|
||||
<!-- 登录页设置(统一认证中心 /auth/login:品牌配置 + 登录方式开关) -->
|
||||
<el-tab-pane label="登录页设置" name="loginBrand">
|
||||
<loginBrandSettings v-if="activeTab === 'loginBrand'" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 登录方式开关(账号密码 / 短信 / 扫码) -->
|
||||
<el-tab-pane label="登录方式" name="loginMethods">
|
||||
<loginSettings v-if="activeTab === 'loginMethods'" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,7 +47,6 @@ import platformSettings from "./components/platformSettings.vue";
|
||||
import storageSettings from "./components/storageSettings.vue";
|
||||
import notificationSettings from "./components/notificationSettings.vue";
|
||||
import loginBrandSettings from "./components/loginBrandSettings.vue";
|
||||
import loginSettings from "./components/loginSettings.vue";
|
||||
|
||||
const activeTab = ref("platform");
|
||||
</script>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="版权信息" prop="copyright">
|
||||
<el-input v-model="form.copyright" placeholder="如:© 2026 公司名称 版权所有" />
|
||||
<el-input v-model="form.copyright" placeholder="如:© 2026 云泽网 版权所有" />
|
||||
<TagTip tag="{yz:copyright}" desc="常用于页脚" />
|
||||
</el-form-item>
|
||||
|
||||
|
||||
Reference in new issue
Block a user