增加ai办公,考勤管理
This commit is contained in:
1 parent
eb9b7beff5
commit
31ae07175d
46 files changed
+9250
-296
No files matched your search
@@ -1,5 +1,19 @@
|
||||
<script setup>
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import { onMounted } from 'vue'
|
||||
import { getBackendBrand } from '@/api/site'
|
||||
import { applyFavicon } from '@/utils/favicon'
|
||||
|
||||
// 浏览器标签页图标:取平台「平台设置 - 平台信息」的站点图标,未配置则保留默认图标
|
||||
onMounted(async () => {
|
||||
if (!localStorage.getItem('token')) return
|
||||
try {
|
||||
const res = await getBackendBrand()
|
||||
if (res?.code === 200) applyFavicon(res.data?.ico)
|
||||
} catch (error) {
|
||||
// 图标属于装饰信息,取不到时不影响页面
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// AI接入配置
|
||||
export function getAiProviderList() {
|
||||
// AI接入配置:scope='tenant' 只取租户级配置(OA 设置里的 AI 设置使用),
|
||||
// 不传则返回「租户级 + 个人」配置(AI 广场选择接入时使用)
|
||||
export function getAiProviderList(params) {
|
||||
return request({
|
||||
url: '/backend/ai/provider/list',
|
||||
method: 'get'
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// OA 考勤打卡:后台只做记录查看与备注,打卡动作在移动端(/api/oa/attendance/*)
|
||||
// 响应拦截器只返回 {code, data, msg} 包装,调用方需自行取 res.data
|
||||
|
||||
// 考勤记录:start_date/end_date 必传或由后端默认本月
|
||||
// keyword 支持姓名/账号,org_id + include_child 按部门过滤,status 为异常口径
|
||||
export function getAttendanceRecords(params) {
|
||||
return request({ url: '/backend/oa/attendance/records', method: 'get', params })
|
||||
}
|
||||
|
||||
// 全员考勤汇总:period=month|quarter|year,date 为锚点日期(默认今天)
|
||||
// 返回 {period,label,year,start_date,end_date,active_key,stats,cards:{months,quarters,year},list,total}
|
||||
export function getAttendanceSummary(params) {
|
||||
return request({ url: '/backend/oa/attendance/summary', method: 'get', params })
|
||||
}
|
||||
|
||||
// 缺卡名单:指定日期有排班但未签到的人
|
||||
export function getAttendanceMissing(params) {
|
||||
return request({ url: '/backend/oa/attendance/missing', method: 'get', params })
|
||||
}
|
||||
|
||||
// 管理员备注 / 异常说明
|
||||
export function updateAttendanceRemark(id, remark) {
|
||||
return request({ url: `/backend/oa/attendance/remark/${id}`, method: 'post', data: { remark } })
|
||||
}
|
||||
@@ -7,3 +7,11 @@ export function getSiteMiniApps() {
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 后台顶栏品牌(站点名 / Logo,数据取自平台「官网管理 - 站点设置」)
|
||||
export function getBackendBrand() {
|
||||
return request({
|
||||
url: '/backend/brand',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
@@ -3,12 +3,20 @@
|
||||
<!-- 顶栏品牌区 -->
|
||||
<div class="header-left" :class="{ 'is-collapsed': isCollapse }">
|
||||
<div class="brand" title="返回仪表盘" @click="goDashboard">
|
||||
<span class="logo-mark" aria-hidden="true">
|
||||
<span class="logo-grid"><i></i><i></i><i></i><i></i></span>
|
||||
</span>
|
||||
<span class="brand-meta">
|
||||
<span class="brand-title">温新云平台</span>
|
||||
<span class="brand-sub">{{ currentModule?.title || "数据赋能管理 · 科技创造价值" }}</span>
|
||||
<!-- 平台「系统设置 - 平台设置 - 平台信息」下发的长方形 Logo({yz:logo_rect}),
|
||||
深色主题取深色版本;未配置时不显示 Logo -->
|
||||
<img
|
||||
v-if="brandPrimary.src"
|
||||
:src="brandPrimary.src"
|
||||
:alt="brandTitle"
|
||||
class="brand-logo-img"
|
||||
:class="brandPrimary.shape"
|
||||
@error="onBrandLogoError"
|
||||
/>
|
||||
<!-- Logo 本身已带品牌名,配置 Logo 后不再重复主/副标题文字 -->
|
||||
<span v-if="!brandPrimary.src" class="brand-meta">
|
||||
<span class="brand-title">{{ brandTitle }}</span>
|
||||
<span class="brand-sub">{{ brandSubtitle }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,6 +201,7 @@ import { logout, getCurrentUserProfile } from "@/api/login";
|
||||
import { User, SwitchButton, Sunny, Moon, Monitor, Check, Refresh, Bell, Search, ArrowDown, Grid } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { getMySiteReminders, readAllSiteReminders } from "@/api/sitereminder";
|
||||
import { getBackendBrand } from "@/api/site";
|
||||
import MessageDetailDialog from "./MessageDetailDialog.vue";
|
||||
import TenantSwitcher from "./TenantSwitcher.vue";
|
||||
import MenuIcon from "./MenuIcon.vue";
|
||||
@@ -216,15 +225,101 @@ const {
|
||||
|
||||
const isCollapse = computed(() => store.state.isCollapse);
|
||||
|
||||
// ---------- 品牌区 Logo:平台「系统设置 - 平台设置 - 平台信息」 ----------
|
||||
const API_BASE_URL = String(import.meta.env.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
||||
const brand = ref({
|
||||
sitename: "",
|
||||
subtitle: "",
|
||||
logoSquare: "",
|
||||
logoSquareDark: "",
|
||||
logoRect: "",
|
||||
logoRectDark: "",
|
||||
logo: "",
|
||||
logow: "",
|
||||
});
|
||||
// 相对路径(/uploads/xxx.png)补上接口基址,完整 URL / data: 原样使用
|
||||
const resolveBrandImage = (url) => {
|
||||
const value = String(url || "").trim().replace(/\\/g, "/");
|
||||
if (!value) return "";
|
||||
if (/^(https?:)?\/\//i.test(value) || /^(data|blob):/i.test(value)) return value;
|
||||
return `${API_BASE_URL}/${value.replace(/^\/+/, "")}`;
|
||||
};
|
||||
|
||||
// 主题模式:跟随系统 / 亮色 / 暗色(与门户页共用一份状态)
|
||||
const themeMode = ref(getThemeMode());
|
||||
const themeOptions = THEME_OPTIONS;
|
||||
const isDark = ref(false);
|
||||
const themeIconOf = (value) => {
|
||||
if (value === "system") return Monitor;
|
||||
if (value === "dark") return Moon;
|
||||
return Sunny;
|
||||
};
|
||||
const applyTheme = () => applyThemeMode(themeMode.value);
|
||||
// 正方形 Logo:深色主题优先用「深色主题」专用图,未配置才回退浅色版本
|
||||
const brandSquareSrc = computed(() =>
|
||||
resolveBrandImage(
|
||||
isDark.value
|
||||
? brand.value.logoSquareDark || brand.value.logoSquare
|
||||
: brand.value.logoSquare,
|
||||
),
|
||||
);
|
||||
// 长方形 Logo:深色主题优先深色主题图(其次官网浅色 Logo),
|
||||
// 都未配置时仍回退长方形图,避免深色主题下 Logo 直接消失
|
||||
const brandWideSrc = computed(() => {
|
||||
const value = isDark.value
|
||||
? brand.value.logoRectDark ||
|
||||
brand.value.logow ||
|
||||
brand.value.logoRect ||
|
||||
brand.value.logo
|
||||
: brand.value.logo || brand.value.logoRect || brand.value.logow;
|
||||
return resolveBrandImage(value);
|
||||
});
|
||||
// 顶栏左上角用平台信息里的长方形 Logo({yz:logo_rect});
|
||||
// 未配置时再退回方形图标,都没有则不显示 Logo
|
||||
const brandPrimary = computed(() => {
|
||||
const square = { src: brandSquareSrc.value, shape: "is-square" };
|
||||
const wide = { src: brandWideSrc.value, shape: "is-wide" };
|
||||
return [wide, square].find((item) => item.src) || { src: "", shape: "" };
|
||||
});
|
||||
const brandTitle = computed(() => brand.value.sitename || "温新云平台");
|
||||
// 副标题优先用平台设置里的副标题,未配置时退回当前功能模块名
|
||||
const brandSubtitle = computed(
|
||||
() => brand.value.subtitle || currentModule?.value?.title || "数据赋能管理 · 科技创造价值",
|
||||
);
|
||||
const fetchBrand = async () => {
|
||||
try {
|
||||
const res = await getBackendBrand();
|
||||
if (res?.code === 200 && res.data) {
|
||||
const data = res.data;
|
||||
brand.value = {
|
||||
sitename: data.sitename || "",
|
||||
subtitle: data.subtitle || "",
|
||||
logoSquare: data.logoSquare || "",
|
||||
logoSquareDark: data.logoSquareDark || "",
|
||||
logoRect: data.logoRect || "",
|
||||
logoRectDark: data.logoRectDark || "",
|
||||
logo: data.logo || "",
|
||||
logow: data.logow || "",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// 顶栏品牌属于装饰信息,取不到时保留内置占位标记,不打扰用户
|
||||
}
|
||||
};
|
||||
// 图片地址失效(文件被删/存储迁移)时不再显示 Logo,避免顶栏出现破图
|
||||
const onBrandLogoError = () => {
|
||||
brand.value = {
|
||||
...brand.value,
|
||||
logoSquare: "",
|
||||
logoSquareDark: "",
|
||||
logoRect: "",
|
||||
logoRectDark: "",
|
||||
logo: "",
|
||||
logow: "",
|
||||
};
|
||||
};
|
||||
const applyTheme = () => {
|
||||
isDark.value = applyThemeMode(themeMode.value);
|
||||
};
|
||||
const setThemeMode = (mode) => {
|
||||
themeMode.value = mode;
|
||||
applyTheme();
|
||||
@@ -605,6 +700,9 @@ onMounted(async () => {
|
||||
if (themeMode.value === 'system') applyTheme();
|
||||
});
|
||||
|
||||
// 品牌 Logo 取自平台「官网管理 - 站点设置」,与官网页头保持同一份配置
|
||||
fetchBrand();
|
||||
|
||||
if (authStore.token) {
|
||||
// 仅在后台页面刷新/首次挂载时更新未读数量,不再自动轮询。
|
||||
fetchUnreadCount();
|
||||
@@ -989,6 +1087,7 @@ onUnmounted(() => {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding-left: 8px;
|
||||
overflow: hidden;
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
flex-basis 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
@@ -1072,42 +1171,31 @@ onUnmounted(() => {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logo-mark {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
// 折叠后只剩 64px,长方形 Logo 等比缩小,避免溢出到右上导航
|
||||
.brand-logo-img.is-wide {
|
||||
height: 44px;
|
||||
max-width: 56px;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-mark {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(135deg, #4885ff 0%, #2f56e6 100%);
|
||||
box-shadow: 0 4px 12px rgba(47, 107, 255, 0.24);
|
||||
}
|
||||
// 平台「平台设置 - 平台信息」下发的 Logo:统一 60px,等比缩放避免变形
|
||||
.brand-logo-img {
|
||||
flex: 0 0 auto;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
|
||||
.logo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 8px);
|
||||
gap: 3px;
|
||||
transform: rotate(-8deg);
|
||||
// 正方形 Logo({yz:logo_square})
|
||||
&.is-square {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
|
||||
&:nth-child(2) {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
opacity: 0.75;
|
||||
}
|
||||
// 长方形 Logo({yz:logo_rect}):品牌名已在图里,右侧不再显示文字,可占满品牌区
|
||||
&.is-wide {
|
||||
height: 60px;
|
||||
width: auto;
|
||||
max-width: 190px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,27 @@ const staticMainChildren = [
|
||||
component: () => import("@/views/apps/oa/scheduling/index.vue"),
|
||||
meta: { requiresAuth: true, title: "工作排班", modulePath: "/apps/oa" }
|
||||
},
|
||||
// OA 考勤管理(移动端打卡产生的数据,后台汇总 / 明细 / 备注)
|
||||
{
|
||||
path: "/apps/oa/attendance",
|
||||
name: "OaAttendance",
|
||||
component: () => import("@/views/apps/oa/attendance/index.vue"),
|
||||
meta: { requiresAuth: true, title: "考勤管理", modulePath: "/apps/oa" }
|
||||
},
|
||||
// OA AI办公(AI 广场):三级菜单直链兜底
|
||||
{
|
||||
path: "/apps/oa/ai",
|
||||
name: "OaAiPlayground",
|
||||
component: () => import("@/views/apps/oa/ai/index.vue"),
|
||||
meta: { requiresAuth: true, title: "AI办公", modulePath: "/apps/oa" }
|
||||
},
|
||||
// OA 基础设置 → AI设置(租户级接入配置)
|
||||
{
|
||||
path: "/basic-settings/ai",
|
||||
name: "BasicSettingsAi",
|
||||
component: () => import("@/views/basicSettings/ai/index.vue"),
|
||||
meta: { requiresAuth: true, title: "AI设置", modulePath: "/apps/oa" }
|
||||
},
|
||||
// CMS 文章/产品/解决方案是系统内置功能,不依赖数据库菜单配置。
|
||||
// 统一结构:index 为列表页,type 为分类页。
|
||||
{
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 浏览器标签页图标(favicon)。
|
||||
*
|
||||
* 图标来源:平台「系统设置 - 平台设置 - 平台信息」的「站点图标」(标签 ico)。
|
||||
* 未配置时保留 index.html 里的默认图标,不做任何改写。
|
||||
*/
|
||||
|
||||
const API_BASE_URL = String(import.meta.env.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
||||
|
||||
/** 相对路径(/uploads/x.ico)补上接口基址,完整 URL 原样使用 */
|
||||
function resolveIconUrl(url) {
|
||||
const value = String(url || "").trim().replace(/\\/g, "/");
|
||||
if (!value) return "";
|
||||
if (/^(https?:)?\/\//i.test(value) || /^(data|blob):/i.test(value)) return value;
|
||||
return `${API_BASE_URL}/${value.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把指定地址应用为页面 favicon。
|
||||
* @param {string} url 图片地址(可相对)
|
||||
* @returns {boolean} 是否已应用
|
||||
*/
|
||||
export function applyFavicon(url) {
|
||||
const href = resolveIconUrl(url);
|
||||
if (!href) return false;
|
||||
|
||||
let link = document.querySelector('link[rel="icon"]');
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.setAttribute("rel", "icon");
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
// .ico 显式声明 MIME,其余交给浏览器按扩展名/内容识别
|
||||
if (/\.ico(\?|#|$)/i.test(href)) {
|
||||
link.setAttribute("type", "image/x-icon");
|
||||
} else {
|
||||
link.removeAttribute("type");
|
||||
}
|
||||
link.setAttribute("href", href);
|
||||
return true;
|
||||
}
|
||||
+396
-73
@@ -1,32 +1,120 @@
|
||||
<template>
|
||||
<div class="not-found-container">
|
||||
<div class="not-found-content">
|
||||
<div class="not-found-big">404</div>
|
||||
<div class="not-found-message">页面未找到</div>
|
||||
<div class="not-found-desc">很抱歉,您访问的页面不存在或已被删除。</div>
|
||||
<div class="action-btns">
|
||||
<a href="javascript:void(0)" class="back-home-btn" @click.prevent="backToHome">
|
||||
返回首页
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="clear-cache-btn" @click.prevent="clearCache">
|
||||
清除缓存
|
||||
</a>
|
||||
<div class="not-found-page">
|
||||
<!-- 背景装饰:柔光 + 网格 -->
|
||||
<div class="bg-decor" aria-hidden="true">
|
||||
<span class="orb orb-1"></span>
|
||||
<span class="orb orb-2"></span>
|
||||
<span class="grid"></span>
|
||||
</div>
|
||||
|
||||
<div class="not-found-card">
|
||||
<div class="visual">
|
||||
<p class="code">404</p>
|
||||
<svg class="illustration" viewBox="0 0 360 240" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="nf-blue" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#5b8cff" />
|
||||
<stop offset="100%" stop-color="#2f6bff" />
|
||||
</linearGradient>
|
||||
<linearGradient id="nf-paper" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="100%" stop-color="#eef4ff" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<ellipse cx="180" cy="214" rx="104" ry="12" fill="#2f6bff" opacity=".08" />
|
||||
|
||||
<g transform="rotate(-6 180 118)">
|
||||
<rect x="96" y="40" width="168" height="152" rx="16" fill="url(#nf-paper)" stroke="#d8e3f8" />
|
||||
<rect x="120" y="68" width="86" height="10" rx="5" fill="#dce7fb" />
|
||||
<rect x="120" y="90" width="120" height="10" rx="5" fill="#e9f0fd" />
|
||||
<rect x="120" y="112" width="70" height="10" rx="5" fill="#e9f0fd" />
|
||||
<rect
|
||||
x="120"
|
||||
y="140"
|
||||
width="120"
|
||||
height="26"
|
||||
rx="8"
|
||||
fill="none"
|
||||
stroke="#b9cdf4"
|
||||
stroke-dasharray="6 5"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(214 94)">
|
||||
<circle cx="34" cy="34" r="34" fill="#ffffff" fill-opacity=".92" stroke="url(#nf-blue)" stroke-width="6" />
|
||||
<path d="M58 58L86 86" stroke="url(#nf-blue)" stroke-width="10" stroke-linecap="round" />
|
||||
<text
|
||||
x="34"
|
||||
y="46"
|
||||
text-anchor="middle"
|
||||
font-size="34"
|
||||
font-weight="700"
|
||||
fill="#2f6bff"
|
||||
font-family="inherit"
|
||||
>
|
||||
?
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<circle cx="74" cy="70" r="6" fill="#7aa2ff" opacity=".7" />
|
||||
<circle cx="306" cy="158" r="9" fill="#9dbcff" opacity=".5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p class="eyebrow"><i class="dot"></i>ERROR 404</p>
|
||||
<h1>页面走丢了</h1>
|
||||
<p class="desc">
|
||||
你访问的页面不存在、已被移除,或没有访问权限。<br />
|
||||
请检查链接是否正确,或返回首页继续操作。
|
||||
</p>
|
||||
<p v-if="currentPath" class="path" :title="currentPath">{{ currentPath }}</p>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn ghost" @click="backToPrev">返回上一页</button>
|
||||
<button type="button" class="btn primary" @click="backToHome">返回首页</button>
|
||||
</div>
|
||||
|
||||
<p class="footnote">
|
||||
<button type="button" class="link" @click="clearCache">清除本地缓存</button>
|
||||
<span class="sep">·</span>
|
||||
若是系统内部链接失效,请联系管理员
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRouter } from "vue-router";
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// 当前访问路径:方便用户/管理员定位是哪个链接失效
|
||||
const currentPath = computed(() => {
|
||||
const full = route.fullPath || "";
|
||||
if (!full || full === "/") return "";
|
||||
return full.length > 60 ? `${full.slice(0, 60)}…` : full;
|
||||
});
|
||||
|
||||
const backToHome = () => {
|
||||
// 使用 /home 作为首页(Hash模式下的系统导航页)
|
||||
// Hash 模式下 /home 是系统导航门户
|
||||
router.push("/home");
|
||||
};
|
||||
|
||||
const backToPrev = () => {
|
||||
// 有站内历史就退一步,否则回首页
|
||||
if (window.history.length > 1) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
backToHome();
|
||||
};
|
||||
|
||||
const clearAllCookies = () => {
|
||||
const cookies = document.cookie ? document.cookie.split(";") : [];
|
||||
for (const cookie of cookies) {
|
||||
@@ -57,94 +145,329 @@ const clearCache = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found-container {
|
||||
.not-found-page {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(120deg, #6a82fb 0%, #fc5c7d 100%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 24px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(900px 480px at 10% 6%, #e9f0ff 0%, rgba(233, 240, 255, 0) 62%),
|
||||
radial-gradient(760px 420px at 92% 96%, #eef4ff 0%, rgba(238, 244, 255, 0) 64%),
|
||||
#f6f9ff;
|
||||
color: #24304a;
|
||||
}
|
||||
|
||||
.not-found-content {
|
||||
background: #fff;
|
||||
padding: 60px 48px 40px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
animation: float-in 0.8s cubic-bezier(0.7, 0.13, 0.45, 0.81) both;
|
||||
.bg-decor {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.not-found-big {
|
||||
font-size: 100px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 3px;
|
||||
background: linear-gradient(90deg, #6a82fb 0%, #fc5c7d 100%);
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.orb-1 {
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
left: -110px;
|
||||
top: -120px;
|
||||
background: radial-gradient(circle at 35% 35%, #cfdcff 0%, rgba(207, 220, 255, 0) 70%);
|
||||
}
|
||||
|
||||
.orb-2 {
|
||||
width: 460px;
|
||||
height: 460px;
|
||||
right: -150px;
|
||||
bottom: -170px;
|
||||
background: radial-gradient(circle at 60% 40%, #d9e4ff 0%, rgba(217, 228, 255, 0) 70%);
|
||||
}
|
||||
|
||||
.grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(47, 107, 255, 0.07) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(47, 107, 255, 0.07) 1px, transparent 1px);
|
||||
background-size: 56px 56px;
|
||||
-webkit-mask-image: radial-gradient(ellipse at center, #000 28%, transparent 76%);
|
||||
mask-image: radial-gradient(ellipse at center, #000 28%, transparent 76%);
|
||||
}
|
||||
|
||||
.not-found-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 380px) minmax(300px, 400px);
|
||||
align-items: center;
|
||||
gap: 8px 32px;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
padding: 36px 44px;
|
||||
border: 1px solid #e6eefc;
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
box-shadow:
|
||||
0 26px 60px -30px rgba(37, 74, 161, 0.3),
|
||||
0 2px 10px rgba(37, 74, 161, 0.05);
|
||||
backdrop-filter: blur(8px);
|
||||
animation: nf-rise 0.5s cubic-bezier(0.22, 0.68, 0.32, 1) both;
|
||||
}
|
||||
|
||||
.visual {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.code {
|
||||
margin: 0;
|
||||
font-size: 84px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: 4px;
|
||||
background: linear-gradient(135deg, #6f97ff 0%, #2f6bff 58%, #1d4ed8 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.illustration {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 10px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
background: #eef4ff;
|
||||
color: #2f6bff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #2f6bff;
|
||||
box-shadow: 0 0 0 3px rgba(47, 107, 255, 0.16);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 30px;
|
||||
line-height: 1.25;
|
||||
font-weight: 700;
|
||||
color: #1b2740;
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 0 0 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: #66738c;
|
||||
}
|
||||
|
||||
.path {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
margin: 0 0 22px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
background: #f2f6ff;
|
||||
border: 1px dashed #c9d9f7;
|
||||
color: #5a6a88;
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.not-found-message {
|
||||
font-size: 28px;
|
||||
color: #222;
|
||||
margin-bottom: 10px;
|
||||
.btn {
|
||||
height: 40px;
|
||||
padding: 0 22px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.not-found-desc {
|
||||
font-size: 16px;
|
||||
color: #888;
|
||||
margin-bottom: 28px;
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.action-btns {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.back-home-btn {
|
||||
display: inline-block;
|
||||
background: linear-gradient(90deg, #6a82fb 0%, #fc5c7d 100%);
|
||||
.btn.primary {
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 10px 36px;
|
||||
border-radius: 24px;
|
||||
font-size: 16px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(252, 92, 125, 0.07);
|
||||
background: linear-gradient(135deg, #4d82ff 0%, #2f6bff 100%);
|
||||
box-shadow: 0 10px 22px -10px rgba(47, 107, 255, 0.7);
|
||||
}
|
||||
|
||||
.back-home-btn:hover {
|
||||
background: linear-gradient(-90deg, #6a82fb 0%, #fc5c7d 100%);
|
||||
.btn.primary:hover {
|
||||
box-shadow: 0 14px 26px -12px rgba(47, 107, 255, 0.8);
|
||||
background: linear-gradient(135deg, #5c8dff 0%, #3a74ff 100%);
|
||||
}
|
||||
|
||||
.clear-cache-btn {
|
||||
display: inline-block;
|
||||
.btn.ghost {
|
||||
border: 1px solid #d7e2f6;
|
||||
background: #fff;
|
||||
color: #fc5c7d;
|
||||
padding: 10px 36px;
|
||||
border-radius: 24px;
|
||||
font-size: 16px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
border: 1px solid #fc5c7d;
|
||||
transition: background 0.2s;
|
||||
color: #40506e;
|
||||
}
|
||||
|
||||
.clear-cache-btn:hover {
|
||||
background: #fff0f3;
|
||||
.btn.ghost:hover {
|
||||
border-color: #b9cdf4;
|
||||
background: #f7faff;
|
||||
color: #2f6bff;
|
||||
}
|
||||
|
||||
@keyframes float-in {
|
||||
0% {
|
||||
transform: translateY(30px) scale(0.97);
|
||||
.footnote {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #93a0b8;
|
||||
}
|
||||
|
||||
.link {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #6b8fd9;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: #2f6bff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.sep {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
@keyframes nf-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.not-found-card {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
padding: 28px 24px 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
.illustration {
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.footnote,
|
||||
.path {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色主题(backend 支持亮/暗切换;platform 无暗色时不会命中) */
|
||||
html.dark .not-found-page {
|
||||
background:
|
||||
radial-gradient(900px 480px at 10% 6%, rgba(47, 107, 255, 0.18) 0%, rgba(47, 107, 255, 0) 62%),
|
||||
radial-gradient(760px 420px at 92% 96%, rgba(84, 122, 214, 0.14) 0%, rgba(84, 122, 214, 0) 64%),
|
||||
#10151f;
|
||||
color: #e5ebf7;
|
||||
}
|
||||
|
||||
html.dark .not-found-card {
|
||||
border-color: #263049;
|
||||
background: rgba(24, 31, 46, 0.9);
|
||||
box-shadow: 0 26px 60px -30px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
html.dark .eyebrow {
|
||||
background: rgba(47, 107, 255, 0.16);
|
||||
color: #8fb0ff;
|
||||
}
|
||||
|
||||
html.dark h1 {
|
||||
color: #eef2fb;
|
||||
}
|
||||
|
||||
html.dark .desc {
|
||||
color: #9aa7c2;
|
||||
}
|
||||
|
||||
html.dark .path {
|
||||
border-color: #33415e;
|
||||
background: #1a2233;
|
||||
color: #9fb0cd;
|
||||
}
|
||||
|
||||
html.dark .btn.ghost {
|
||||
border-color: #303c56;
|
||||
background: #1c2434;
|
||||
color: #cfdaf0;
|
||||
}
|
||||
|
||||
html.dark .btn.ghost:hover {
|
||||
border-color: #3f5580;
|
||||
background: #212b3f;
|
||||
color: #8fb0ff;
|
||||
}
|
||||
|
||||
html.dark .footnote {
|
||||
color: #7d8aa6;
|
||||
}
|
||||
|
||||
html.dark .link {
|
||||
color: #7fa0e0;
|
||||
}
|
||||
|
||||
html.dark .link:hover {
|
||||
color: #a9c4ff;
|
||||
}
|
||||
</style>
|
||||
@@ -371,12 +371,15 @@ async function handleTestProvider(row) {
|
||||
|
||||
testingProviderId.value = row.id
|
||||
try {
|
||||
const res = await testAiProvider({
|
||||
const payload = {
|
||||
id: row.id,
|
||||
provider_type: row.provider_type,
|
||||
api_base: row.api_base,
|
||||
api_key: row.api_key,
|
||||
models: row.models
|
||||
})
|
||||
}
|
||||
// 租户级配置的 Key 是脱敏值,交给后端按 id 取库里的完整 Key 测试
|
||||
if (row.api_key && !String(row.api_key).includes('****')) payload.api_key = row.api_key
|
||||
const res = await testAiProvider(payload)
|
||||
|
||||
if (res.code === 200 && res.data.results) {
|
||||
const successCount = res.data.results.filter(r => r.success).length
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -99,6 +99,7 @@
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="danger" plain @click="handleDelete">删除</el-button>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
<el-button type="primary" @click="handleEdit">编辑</el-button>
|
||||
</div>
|
||||
@@ -115,7 +116,7 @@ const props = defineProps({
|
||||
contact: { type: Object, default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'edit'])
|
||||
const emit = defineEmits(['update:modelValue', 'edit', 'delete'])
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
@@ -153,6 +154,10 @@ const handleEdit = () => {
|
||||
emit('edit', props.contact)
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete', props.contact)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
@@ -134,11 +134,20 @@
|
||||
<span>{{ row.email || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" fixed="right" width="140" align="center">
|
||||
<el-table-column label="操作" fixed="right" width="170" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" title="编辑" @click.stop="openEditContact(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
title="删除"
|
||||
@click.stop="handleDeleteContact(row)"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -172,6 +181,7 @@
|
||||
v-model="detailDialogVisible"
|
||||
:contact="detailContact"
|
||||
@edit="handleDetailEdit"
|
||||
@delete="handleDetailDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -294,6 +304,40 @@ const toggleStar = async (row) => {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 删除 ----
|
||||
// 后端为软删除:只把联系人记录移出通讯录,员工档案 / 登录账号不受影响
|
||||
const handleDeleteContact = async (row) => {
|
||||
if (!row?.id) return
|
||||
const name = row.contact_name || '该联系人'
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除联系人「${name}」吗?删除后将从通讯录中移除,员工档案与登录账号不受影响。`,
|
||||
'删除确认',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteContact(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
// 删掉的是当前页最后一条时回退一页,避免停在空页
|
||||
if (contactList.value.length === 1 && pagination.page > 1) {
|
||||
pagination.page -= 1
|
||||
}
|
||||
await loadContactList()
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 详情弹窗里点删除:先关弹窗再走同一套删除流程
|
||||
const handleDetailDelete = (contact) => {
|
||||
detailDialogVisible.value = false
|
||||
handleDeleteContact(contact)
|
||||
}
|
||||
|
||||
// ---- 同步 ----
|
||||
const handleSyncAll = async () => {
|
||||
try {
|
||||
|
||||
@@ -16,6 +16,17 @@
|
||||
>
|
||||
批量调岗({{ selectedRows.length }})
|
||||
</el-button>
|
||||
<el-button
|
||||
:disabled="!selectedRows.length"
|
||||
type="danger"
|
||||
plain
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
批量删除({{ selectedRows.length }})
|
||||
</el-button>
|
||||
<el-button :icon="Download" plain @click="handleExport">
|
||||
导出{{ selectedRows.length ? `(${selectedRows.length})` : '' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="module === 'basic'"
|
||||
type="primary"
|
||||
@@ -64,12 +75,14 @@
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
:data="pagedRows"
|
||||
v-loading="loading"
|
||||
stripe
|
||||
@selection-change="(rows) => (selectedRows = rows)"
|
||||
>
|
||||
<!--<el-table-column type="selection" width="45" />-->
|
||||
<!-- 批量操作(调岗 / 删除 / 导出)依赖多选 -->
|
||||
<el-table-column type="selection" width="45" :reserve-selection="false" />
|
||||
<!--<el-table-column prop="id" label="ID" width="70" align="center" />-->
|
||||
<el-table-column prop="account" label="账号" width="130" />
|
||||
<el-table-column prop="name" label="姓名" width="110">
|
||||
@@ -153,7 +166,8 @@ import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Plus, Refresh, Search } from '@element-plus/icons-vue';
|
||||
import { Download, Plus, Refresh, Search } from '@element-plus/icons-vue';
|
||||
import * as XLSX from 'xlsx';
|
||||
import EmployeeEditDialog from './EmployeeEditDialog.vue';
|
||||
import EmployeeViewDialog from './EmployeeViewDialog.vue';
|
||||
import MoveEmployeeDialog from './MoveEmployeeDialog.vue';
|
||||
@@ -207,6 +221,7 @@ const handleAddEmployee = () => {
|
||||
|
||||
const rows = ref([]);
|
||||
const selectedRows = ref([]);
|
||||
const tableRef = ref();
|
||||
const loading = ref(false);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(10);
|
||||
@@ -257,6 +272,7 @@ const handleSearch = () => {
|
||||
|
||||
const handleMoved = () => {
|
||||
selectedRows.value = [];
|
||||
tableRef.value?.clearSelection();
|
||||
loadData();
|
||||
};
|
||||
|
||||
@@ -275,6 +291,85 @@ const handleDelete = async (row) => {
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------- 批量操作 ------------------------------- */
|
||||
|
||||
// 后端对员工不做物理删除:删除 = 置为离职并禁用对应登录账号,档案/通讯录/排班保留
|
||||
const handleBatchDelete = async () => {
|
||||
const targets = [...selectedRows.value];
|
||||
if (!targets.length) return;
|
||||
|
||||
const names = targets.slice(0, 5).map((r) => r.name || r.account).join('、');
|
||||
const more = targets.length > 5 ? ` 等 ${targets.length} 人` : '';
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将把「${names}」${more}置为离职(同时禁用其登录账号),人事档案、通讯录与排班记录会保留。确定继续吗?`,
|
||||
'批量删除确认',
|
||||
{ type: 'warning', confirmButtonText: '置为离职', cancelButtonText: '取消' }
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
targets.map((row) => api.value.deleteEmployee(row.id))
|
||||
);
|
||||
const failed = results.filter((r) => r.status === 'rejected');
|
||||
const okCount = results.length - failed.length;
|
||||
if (okCount > 0) {
|
||||
ElMessage.success(`已处理 ${okCount} 人${failed.length ? `,失败 ${failed.length} 人` : ''}`);
|
||||
}
|
||||
if (failed.length) {
|
||||
const reason = failed[0]?.reason?.message || '未知错误';
|
||||
ElMessage.error(`${failed.length} 人处理失败:${reason}`);
|
||||
}
|
||||
tableRef.value?.clearSelection();
|
||||
selectedRows.value = [];
|
||||
await loadData();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const exportStamp = () => {
|
||||
const d = new Date();
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}`;
|
||||
};
|
||||
|
||||
// 导出:有勾选导勾选行,没有勾选导当前筛选结果
|
||||
const handleExport = () => {
|
||||
const list = selectedRows.value.length ? selectedRows.value : rows.value;
|
||||
if (!list.length) {
|
||||
ElMessage.warning('暂无可导出的数据');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = list.map((row, index) => ({
|
||||
序号: index + 1,
|
||||
账号: row.account || '',
|
||||
姓名: row.name || '',
|
||||
隶属单位: row.affiliate_unit_name || '',
|
||||
部门: row.department_name || '',
|
||||
职位: row.position || '',
|
||||
手机: row.phone || '',
|
||||
邮箱: row.email || '',
|
||||
状态: employeeStatusText(row),
|
||||
入职时间: row.create_time || '',
|
||||
}));
|
||||
|
||||
const sheet = XLSX.utils.json_to_sheet(data);
|
||||
sheet['!cols'] = [
|
||||
{ wch: 6 }, { wch: 16 }, { wch: 12 }, { wch: 24 }, { wch: 18 },
|
||||
{ wch: 14 }, { wch: 16 }, { wch: 24 }, { wch: 8 }, { wch: 20 },
|
||||
];
|
||||
const book = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(book, sheet, `${labels.value.employeeLabel}列表`);
|
||||
XLSX.writeFile(book, `${labels.value.employeeLabel}列表_${exportStamp()}.xlsx`);
|
||||
ElMessage.success(`已导出 ${data.length} 条`);
|
||||
};
|
||||
|
||||
/* ------------------------------- 重置密码 ------------------------------- */
|
||||
|
||||
const resetDialogVisible = ref(false);
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
<template>
|
||||
<div class="ai-settings-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>AI 设置</h2>
|
||||
<p>租户级 AI 接入配置,保存后全租户共用(「办公 → AI办公」读取这里的配置)</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新增接入</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert class="tip" type="info" :closable="false" show-icon>
|
||||
<template #title>
|
||||
支持 OpenAI 兼容接口(Chat Completions,如 DeepSeek / 豆包 / OpenRouter)与 Anthropic Messages。
|
||||
接口地址填到版本号即可(如 <code>https://api.deepseek.com</code>、<code>https://api.openai.com/v1</code>),密钥仅保存在本租户内。
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<div class="table-card">
|
||||
<el-table v-loading="loading" :data="list" border stripe empty-text="尚未配置 AI 接入">
|
||||
<el-table-column prop="name" label="配置名称" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="name-cell">
|
||||
{{ row.name }}
|
||||
<el-tag v-if="row.is_default === 1" size="small" type="warning" effect="plain">默认</el-tag>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="接入类型" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.provider_type === 'anthropic' ? 'success' : 'primary'" effect="plain">
|
||||
{{ row.provider_type === 'anthropic' ? 'Anthropic' : 'OpenAI 兼容' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="模型" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="model-tags">
|
||||
<el-tag
|
||||
v-for="m in row.models"
|
||||
:key="m"
|
||||
size="small"
|
||||
type="info"
|
||||
effect="plain"
|
||||
:class="{ 'is-default': m === row.default_model }"
|
||||
>{{ m }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="api_base" label="接口地址" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.status === 1 ? 'success' : 'info'">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.remark || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="190" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="warning" :loading="testingId === row.id" @click="testRow(row)">测试</el-button>
|
||||
<el-button link type="danger" @click="removeRow(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 编辑 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑 AI 接入' : '新增 AI 接入'"
|
||||
width="620px"
|
||||
:close-on-click-modal="false"
|
||||
@closed="resetForm"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="接入类型" prop="provider_type">
|
||||
<el-radio-group v-model="form.provider_type">
|
||||
<el-radio value="openai">OpenAI 兼容</el-radio>
|
||||
<el-radio value="anthropic">Anthropic Messages</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="配置名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="如:DeepSeek 生产环境" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="接口地址" prop="api_base">
|
||||
<el-input v-model="form.api_base" :placeholder="apiBasePlaceholder" />
|
||||
</el-form-item>
|
||||
<el-form-item label="API Key" prop="api_key">
|
||||
<el-input
|
||||
v-model="form.api_key"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="isEdit ? '留空表示不修改已保存的密钥' : '请输入 API Key'"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="模型列表" prop="models">
|
||||
<div class="model-editor">
|
||||
<div v-for="(m, index) in form.models" :key="index" class="model-row">
|
||||
<el-input v-model="form.models[index]" :placeholder="modelPlaceholder" />
|
||||
<el-button link type="danger" :icon="Delete" :disabled="form.models.length <= 1" @click="removeModel(index)" />
|
||||
</div>
|
||||
<el-button link type="primary" :icon="Plus" @click="addModel">添加模型</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="默认模型">
|
||||
<el-select v-model="form.default_model" placeholder="选择默认模型" clearable style="width: 100%">
|
||||
<el-option v-for="m in validModels" :key="m" :label="m" :value="m" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="设为默认">
|
||||
<el-switch v-model="form.is_default" :active-value="1" :inactive-value="0" />
|
||||
<span class="form-tip">默认接入会被 AI 广场优先使用</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="255" show-word-limit />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="testResults.length" label="连通性">
|
||||
<div class="test-results">
|
||||
<div v-for="item in testResults" :key="item.model" class="test-item">
|
||||
<el-tag size="small" :type="item.success ? 'success' : 'danger'" effect="plain">
|
||||
{{ item.model }} · {{ item.success ? '通' : '失败' }}
|
||||
</el-tag>
|
||||
<span v-if="!item.success" class="test-error">{{ item.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button :loading="testing" @click="testForm">测试连接</el-button>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Delete, Plus, Refresh } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
createAiProvider,
|
||||
deleteAiProvider,
|
||||
getAiProviderList,
|
||||
testAiProvider,
|
||||
updateAiProvider
|
||||
} from '@/api/ai'
|
||||
|
||||
const API_BASE_PLACEHOLDERS = {
|
||||
openai: 'https://api.deepseek.com 或 https://api.openai.com/v1',
|
||||
anthropic: 'https://api.anthropic.com'
|
||||
}
|
||||
const MODEL_PLACEHOLDERS = {
|
||||
openai: 'deepseek-chat / gpt-4o / doubao-1.5-pro',
|
||||
anthropic: 'claude-3-5-sonnet-20241022'
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const testingId = ref(0)
|
||||
const list = ref([])
|
||||
const testResults = ref([])
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref(null)
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
provider_type: 'openai',
|
||||
name: '',
|
||||
api_base: '',
|
||||
api_key: '',
|
||||
models: [''],
|
||||
default_model: '',
|
||||
is_default: 0,
|
||||
status: 1,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const apiBasePlaceholder = computed(() => API_BASE_PLACEHOLDERS[form.provider_type] || 'https://...')
|
||||
const modelPlaceholder = computed(() => MODEL_PLACEHOLDERS[form.provider_type] || '模型 ID')
|
||||
const validModels = computed(() => form.models.map((m) => (m || '').trim()).filter(Boolean))
|
||||
|
||||
const rules = {
|
||||
provider_type: [{ required: true, message: '请选择接入类型', trigger: 'change' }],
|
||||
name: [{ required: true, message: '请填写配置名称', trigger: 'blur' }],
|
||||
api_base: [{ required: true, message: '请填写接口地址', trigger: 'blur' }],
|
||||
models: [
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (!value.some((m) => (m || '').trim())) callback(new Error('至少填写一个模型 ID'))
|
||||
else callback()
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function unwrap(res) {
|
||||
if (res && typeof res === 'object' && 'code' in res) {
|
||||
if (Number(res.code) !== 200) throw new Error(res.msg || '请求失败')
|
||||
return res.data
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = unwrap(await getAiProviderList({ scope: 'tenant' })) || {}
|
||||
list.value = data.list || []
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '加载 AI 接入配置失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
isEdit.value = true
|
||||
testResults.value = []
|
||||
Object.assign(form, {
|
||||
id: row.id,
|
||||
provider_type: row.provider_type === 'anthropic' ? 'anthropic' : 'openai',
|
||||
name: row.name || '',
|
||||
api_base: row.api_base || '',
|
||||
api_key: '',
|
||||
models: (row.models || []).length ? [...row.models] : [''],
|
||||
default_model: row.default_model || '',
|
||||
is_default: row.is_default || 0,
|
||||
status: row.status ?? 1,
|
||||
remark: row.remark || ''
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
testResults.value = []
|
||||
Object.assign(form, {
|
||||
id: 0,
|
||||
provider_type: 'openai',
|
||||
name: '',
|
||||
api_base: '',
|
||||
api_key: '',
|
||||
models: [''],
|
||||
default_model: '',
|
||||
is_default: 0,
|
||||
status: 1,
|
||||
remark: ''
|
||||
})
|
||||
}
|
||||
|
||||
function addModel() {
|
||||
form.models.push('')
|
||||
}
|
||||
|
||||
function removeModel(index) {
|
||||
form.models.splice(index, 1)
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
return {
|
||||
scope: 'tenant',
|
||||
provider_type: form.provider_type,
|
||||
name: form.name.trim(),
|
||||
api_base: form.api_base.trim().replace(/\/+$/, ''),
|
||||
api_key: form.api_key.trim(),
|
||||
models: validModels.value,
|
||||
default_model: form.default_model,
|
||||
is_default: form.is_default,
|
||||
status: form.status,
|
||||
remark: form.remark
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!isEdit.value && !form.api_key.trim()) {
|
||||
ElMessage.warning('请填写 API Key')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = buildPayload()
|
||||
if (isEdit.value) {
|
||||
const data = unwrap(await updateAiProvider(form.id, payload))
|
||||
if (data) ElMessage.success('保存成功')
|
||||
} else {
|
||||
unwrap(await createAiProvider(payload))
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await loadList()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runTest(payload) {
|
||||
const data = unwrap(await testAiProvider(payload)) || {}
|
||||
return data.results || []
|
||||
}
|
||||
|
||||
async function testForm() {
|
||||
if (!form.api_base.trim()) return ElMessage.warning('请先填写接口地址')
|
||||
const models = validModels.value
|
||||
if (!models.length) return ElMessage.warning('请先填写模型 ID')
|
||||
const payload = {
|
||||
provider_type: form.provider_type,
|
||||
api_base: form.api_base.trim().replace(/\/+$/, ''),
|
||||
models
|
||||
}
|
||||
if (form.api_key.trim()) {
|
||||
payload.api_key = form.api_key.trim()
|
||||
} else if (isEdit.value) {
|
||||
// 编辑时未重新输入密钥:用已保存的密钥测试
|
||||
payload.id = form.id
|
||||
} else {
|
||||
return ElMessage.warning('请填写 API Key')
|
||||
}
|
||||
testing.value = true
|
||||
try {
|
||||
testResults.value = await runTest(payload)
|
||||
const ok = testResults.value.filter((item) => item.success).length
|
||||
ElMessage.success(`测试完成:${ok}/${testResults.value.length} 个模型连通`)
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '测试失败')
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testRow(row) {
|
||||
testingId.value = row.id
|
||||
try {
|
||||
// 密钥已脱敏,按 id 让后端用库里的 Key 测试
|
||||
const results = await runTest({
|
||||
id: row.id,
|
||||
provider_type: row.provider_type,
|
||||
api_base: row.api_base,
|
||||
models: row.models || []
|
||||
})
|
||||
const ok = results.filter((item) => item.success).length
|
||||
if (ok === results.length) {
|
||||
ElMessage.success(`「${row.name}」全部 ${ok} 个模型连通`)
|
||||
} else {
|
||||
const failed = results.filter((item) => !item.success)
|
||||
ElMessage.warning(
|
||||
`「${row.name}」${ok}/${results.length} 连通,失败:${failed.map((f) => `${f.model}(${f.error})`).join(';')}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '测试失败')
|
||||
} finally {
|
||||
testingId.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRow(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除「${row.name}」吗?删除后 AI 广场将无法使用该接入。`, '删除确认', {
|
||||
type: 'warning'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
unwrap(await deleteAiProvider(row.id))
|
||||
ElMessage.success('已删除')
|
||||
await loadList()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadList)
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.ai-settings-page {
|
||||
padding: 16px 20px 28px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.tip {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.table-card {
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.name-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.model-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
|
||||
.is-default {
|
||||
color: #e6a23c;
|
||||
border-color: #f3d19e;
|
||||
background: #fdf6ec;
|
||||
}
|
||||
}
|
||||
.model-editor {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.model-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.form-tip {
|
||||
margin-left: 10px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
.test-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
.test-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.test-error {
|
||||
color: #f56c6c;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -4,8 +4,16 @@
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<div class="home-brand">
|
||||
<span class="home-logo"><i></i><i></i><i></i><i></i></span>
|
||||
<span class="home-brand-name">温新云平台</span>
|
||||
<!-- 平台「平台设置 - 平台信息」的长方形 Logo({yz:logo_rect});
|
||||
Logo 本身已带品牌名,配置后不再重复显示文字 -->
|
||||
<img
|
||||
v-if="brandLogoSrc"
|
||||
:src="brandLogoSrc"
|
||||
:alt="brandName"
|
||||
class="home-logo-img"
|
||||
@error="brandLogoBroken = true"
|
||||
/>
|
||||
<span v-else class="home-brand-name">{{ brandName }}</span>
|
||||
</div>
|
||||
<!-- 当前企业 + 一人多企业时可点击切换 -->
|
||||
<TenantSwitcher variant="plain" />
|
||||
@@ -207,7 +215,7 @@
|
||||
</div>
|
||||
|
||||
<div class="home-footer">
|
||||
<span class="copyright">温新云平台</span>
|
||||
<span class="copyright">{{ brandName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -231,6 +239,7 @@ import {
|
||||
import { getTenantList } from "@/api/modules";
|
||||
import { getProductOrderStatus } from "@/api/product";
|
||||
import { getCurrentUserProfile, logout } from "@/api/login";
|
||||
import { getBackendBrand } from "@/api/site";
|
||||
import { reloadMenusAndDynamicRoutes } from "@/router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useMenuStore } from "@/stores/menu";
|
||||
@@ -255,6 +264,34 @@ const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const menuStore = useMenuStore();
|
||||
|
||||
// ---------- 门户品牌:平台「平台设置 - 平台信息」的主标题与方形 Logo ----------
|
||||
const API_BASE_URL = String(import.meta.env.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
||||
const brandName = ref("温新云平台");
|
||||
const brandLogoLight = ref("");
|
||||
const brandLogoDark = ref("");
|
||||
const brandLogoBroken = ref(false);
|
||||
const resolveBrandImage = (url) => {
|
||||
const value = String(url || "").trim().replace(/\\/g, "/");
|
||||
if (!value) return "";
|
||||
if (/^(https?:)?\/\//i.test(value) || /^(data|blob):/i.test(value)) return value;
|
||||
return `${API_BASE_URL}/${value.replace(/^\/+/, "")}`;
|
||||
};
|
||||
const fetchBrand = async () => {
|
||||
try {
|
||||
const res = await getBackendBrand();
|
||||
if (res?.code !== 200 || !res.data) return;
|
||||
const data = res.data;
|
||||
if (data.sitename) brandName.value = data.sitename;
|
||||
// 门户与后台顶栏统一用长方形 Logo({yz:logo_rect}),深色主题用其深色版本;
|
||||
// 未配置时回退官网浅色/深色 Logo,仍未配置则不显示图片
|
||||
brandLogoLight.value = data.logoRect || data.logo || "";
|
||||
brandLogoDark.value =
|
||||
data.logoRectDark || data.logow || data.logoRect || data.logo || "";
|
||||
} catch (error) {
|
||||
// 品牌信息属于装饰内容,取不到时保留默认文案
|
||||
}
|
||||
};
|
||||
|
||||
// 已开通模块排在前面,套餐未开通(锁定)的模块沉到后方
|
||||
function sortUnlockedFirst(list: ModuleItem[]): ModuleItem[] {
|
||||
return [...list].sort(
|
||||
@@ -296,6 +333,15 @@ const toggleTheme = () => {
|
||||
|
||||
const currentTheme = computed(() => (isDark.value ? "dark" : "light"));
|
||||
const themeIcon = computed(() => (isDark.value ? Sunny : Moon));
|
||||
|
||||
// 深色主题优先用深色主题专用方形 Logo,未配置回退浅色版本
|
||||
const brandLogoSrc = computed(() => {
|
||||
if (brandLogoBroken.value) return "";
|
||||
const value = isDark.value
|
||||
? brandLogoDark.value || brandLogoLight.value
|
||||
: brandLogoLight.value;
|
||||
return resolveBrandImage(value);
|
||||
});
|
||||
let stopSystemThemeWatch = null;
|
||||
|
||||
const userName = computed(
|
||||
@@ -472,6 +518,8 @@ onMounted(() => {
|
||||
loadModules();
|
||||
syncReturnedPayment();
|
||||
initTheme();
|
||||
// 品牌 Logo / 站点名称取自平台「平台设置 - 平台信息」
|
||||
fetchBrand();
|
||||
stopSystemThemeWatch = onSystemThemeChange(() => {
|
||||
if (themeMode.value === "system") applyTheme();
|
||||
});
|
||||
@@ -529,30 +577,15 @@ onUnmounted(() => {
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.home-logo {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 8px);
|
||||
gap: 3px;
|
||||
place-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 9px;
|
||||
background: @primary-gradient;
|
||||
|
||||
i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
|
||||
&:nth-child(2) {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
// 平台下发的长方形 Logo({yz:logo_rect}),高度与后台顶栏一致(60px)
|
||||
.home-logo-img {
|
||||
height: 60px;
|
||||
width: auto;
|
||||
max-width: 240px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.home-brand-name {
|
||||
|
||||
Reference in new issue
Block a user