增加ai办公,考勤管理

This commit is contained in:
hero920103 committed 2026-11-14 18:15:55 +08:00
1 parent eb9b7beff5
commit 31ae07175d
46 files changed
+9250 -296

No files matched your search

+14
View File
@@ -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>
+5 -3
View File
@@ -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
})
}
+26
View File
@@ -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 } })
}
+8
View File
@@ -7,3 +7,11 @@ export function getSiteMiniApps() {
method: 'get',
});
}
// 后台顶栏品牌(站点名 / Logo,数据取自平台「官网管理 - 站点设置」)
export function getBackendBrand() {
return request({
url: '/backend/brand',
method: 'get',
});
}
+126 -38
View File
@@ -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;
}
}
+21
View File
@@ -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 为分类页。
{
+42
View File
@@ -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
View File
@@ -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
}
+45 -1
View File
@@ -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>
+60 -27
View File
@@ -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 {