增加ai办公,考勤管理
This commit is contained in:
1 parent
eb9b7beff5
commit
31ae07175d
46 files changed
+9249
-295
No files matched your search
@@ -1,5 +1,19 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
|
|
||||||
// AI接入配置
|
// AI接入配置:scope='tenant' 只取租户级配置(OA 设置里的 AI 设置使用),
|
||||||
export function getAiProviderList() {
|
// 不传则返回「租户级 + 个人」配置(AI 广场选择接入时使用)
|
||||||
|
export function getAiProviderList(params) {
|
||||||
return request({
|
return request({
|
||||||
url: '/backend/ai/provider/list',
|
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',
|
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="header-left" :class="{ 'is-collapsed': isCollapse }">
|
||||||
<div class="brand" title="返回仪表盘" @click="goDashboard">
|
<div class="brand" title="返回仪表盘" @click="goDashboard">
|
||||||
<span class="logo-mark" aria-hidden="true">
|
<!-- 平台「系统设置 - 平台设置 - 平台信息」下发的长方形 Logo({yz:logo_rect}),
|
||||||
<span class="logo-grid"><i></i><i></i><i></i><i></i></span>
|
深色主题取深色版本;未配置时不显示 Logo -->
|
||||||
</span>
|
<img
|
||||||
<span class="brand-meta">
|
v-if="brandPrimary.src"
|
||||||
<span class="brand-title">温新云平台</span>
|
:src="brandPrimary.src"
|
||||||
<span class="brand-sub">{{ currentModule?.title || "数据赋能管理 · 科技创造价值" }}</span>
|
: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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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 { User, SwitchButton, Sunny, Moon, Monitor, Check, Refresh, Bell, Search, ArrowDown, Grid } from '@element-plus/icons-vue';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import { getMySiteReminders, readAllSiteReminders } from "@/api/sitereminder";
|
import { getMySiteReminders, readAllSiteReminders } from "@/api/sitereminder";
|
||||||
|
import { getBackendBrand } from "@/api/site";
|
||||||
import MessageDetailDialog from "./MessageDetailDialog.vue";
|
import MessageDetailDialog from "./MessageDetailDialog.vue";
|
||||||
import TenantSwitcher from "./TenantSwitcher.vue";
|
import TenantSwitcher from "./TenantSwitcher.vue";
|
||||||
import MenuIcon from "./MenuIcon.vue";
|
import MenuIcon from "./MenuIcon.vue";
|
||||||
@@ -216,15 +225,101 @@ const {
|
|||||||
|
|
||||||
const isCollapse = computed(() => store.state.isCollapse);
|
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 themeMode = ref(getThemeMode());
|
||||||
const themeOptions = THEME_OPTIONS;
|
const themeOptions = THEME_OPTIONS;
|
||||||
|
const isDark = ref(false);
|
||||||
const themeIconOf = (value) => {
|
const themeIconOf = (value) => {
|
||||||
if (value === "system") return Monitor;
|
if (value === "system") return Monitor;
|
||||||
if (value === "dark") return Moon;
|
if (value === "dark") return Moon;
|
||||||
return Sunny;
|
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) => {
|
const setThemeMode = (mode) => {
|
||||||
themeMode.value = mode;
|
themeMode.value = mode;
|
||||||
applyTheme();
|
applyTheme();
|
||||||
@@ -605,6 +700,9 @@ onMounted(async () => {
|
|||||||
if (themeMode.value === 'system') applyTheme();
|
if (themeMode.value === 'system') applyTheme();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 品牌 Logo 取自平台「官网管理 - 站点设置」,与官网页头保持同一份配置
|
||||||
|
fetchBrand();
|
||||||
|
|
||||||
if (authStore.token) {
|
if (authStore.token) {
|
||||||
// 仅在后台页面刷新/首次挂载时更新未读数量,不再自动轮询。
|
// 仅在后台页面刷新/首次挂载时更新未读数量,不再自动轮询。
|
||||||
fetchUnreadCount();
|
fetchUnreadCount();
|
||||||
@@ -989,6 +1087,7 @@ onUnmounted(() => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
|
overflow: hidden;
|
||||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
flex-basis 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;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-mark {
|
// 折叠后只剩 64px,长方形 Logo 等比缩小,避免溢出到右上导航
|
||||||
width: 26px;
|
.brand-logo-img.is-wide {
|
||||||
height: 26px;
|
height: 44px;
|
||||||
border-radius: 8px;
|
max-width: 56px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-mark {
|
// 平台「平台设置 - 平台信息」下发的 Logo:统一 60px,等比缩放避免变形
|
||||||
width: 34px;
|
.brand-logo-img {
|
||||||
height: 34px;
|
flex: 0 0 auto;
|
||||||
border-radius: 10px;
|
display: block;
|
||||||
display: grid;
|
object-fit: contain;
|
||||||
place-items: center;
|
|
||||||
background: linear-gradient(135deg, #4885ff 0%, #2f56e6 100%);
|
|
||||||
box-shadow: 0 4px 12px rgba(47, 107, 255, 0.24);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-grid {
|
// 正方形 Logo({yz:logo_square})
|
||||||
display: grid;
|
&.is-square {
|
||||||
grid-template-columns: repeat(2, 8px);
|
width: 60px;
|
||||||
gap: 3px;
|
height: 60px;
|
||||||
transform: rotate(-8deg);
|
border-radius: 14px;
|
||||||
|
|
||||||
i {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: #fff;
|
|
||||||
|
|
||||||
&:nth-child(2) {
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:nth-child(3) {
|
// 长方形 Logo({yz:logo_rect}):品牌名已在图里,右侧不再显示文字,可占满品牌区
|
||||||
opacity: 0.75;
|
&.is-wide {
|
||||||
}
|
height: 60px;
|
||||||
|
width: auto;
|
||||||
|
max-width: 190px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,27 @@ const staticMainChildren = [
|
|||||||
component: () => import("@/views/apps/oa/scheduling/index.vue"),
|
component: () => import("@/views/apps/oa/scheduling/index.vue"),
|
||||||
meta: { requiresAuth: true, title: "工作排班", modulePath: "/apps/oa" }
|
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 文章/产品/解决方案是系统内置功能,不依赖数据库菜单配置。
|
// CMS 文章/产品/解决方案是系统内置功能,不依赖数据库菜单配置。
|
||||||
// 统一结构:index 为列表页,type 为分类页。
|
// 统一结构: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>
|
<template>
|
||||||
<div class="not-found-container">
|
<div class="not-found-page">
|
||||||
<div class="not-found-content">
|
<!-- 背景装饰:柔光 + 网格 -->
|
||||||
<div class="not-found-big">404</div>
|
<div class="bg-decor" aria-hidden="true">
|
||||||
<div class="not-found-message">页面未找到</div>
|
<span class="orb orb-1"></span>
|
||||||
<div class="not-found-desc">很抱歉,您访问的页面不存在或已被删除。</div>
|
<span class="orb orb-2"></span>
|
||||||
<div class="action-btns">
|
<span class="grid"></span>
|
||||||
<a href="javascript:void(0)" class="back-home-btn" @click.prevent="backToHome">
|
</div>
|
||||||
返回首页
|
|
||||||
</a>
|
<div class="not-found-card">
|
||||||
<a href="javascript:void(0)" class="clear-cache-btn" @click.prevent="clearCache">
|
<div class="visual">
|
||||||
清除缓存
|
<p class="code">404</p>
|
||||||
</a>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
|
||||||
const router = useRouter();
|
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 = () => {
|
const backToHome = () => {
|
||||||
// 使用 /home 作为首页(Hash模式下的系统导航页)
|
// Hash 模式下 /home 是系统导航门户
|
||||||
router.push("/home");
|
router.push("/home");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const backToPrev = () => {
|
||||||
|
// 有站内历史就退一步,否则回首页
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
router.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
backToHome();
|
||||||
|
};
|
||||||
|
|
||||||
const clearAllCookies = () => {
|
const clearAllCookies = () => {
|
||||||
const cookies = document.cookie ? document.cookie.split(";") : [];
|
const cookies = document.cookie ? document.cookie.split(";") : [];
|
||||||
for (const cookie of cookies) {
|
for (const cookie of cookies) {
|
||||||
@@ -57,94 +145,329 @@ const clearCache = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.not-found-container {
|
.not-found-page {
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: linear-gradient(120deg, #6a82fb 0%, #fc5c7d 100%);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
|
||||||
align-items: 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 {
|
.bg-decor {
|
||||||
background: #fff;
|
position: absolute;
|
||||||
padding: 60px 48px 40px;
|
inset: 0;
|
||||||
border-radius: 16px;
|
pointer-events: none;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.not-found-big {
|
.orb {
|
||||||
font-size: 100px;
|
position: absolute;
|
||||||
font-weight: bold;
|
border-radius: 50%;
|
||||||
letter-spacing: 3px;
|
}
|
||||||
background: linear-gradient(90deg, #6a82fb 0%, #fc5c7d 100%);
|
|
||||||
|
.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-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;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.not-found-message {
|
.btn {
|
||||||
font-size: 28px;
|
height: 40px;
|
||||||
color: #222;
|
padding: 0 22px;
|
||||||
margin-bottom: 10px;
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
font-weight: 600;
|
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 {
|
.btn:active {
|
||||||
font-size: 16px;
|
transform: translateY(1px);
|
||||||
color: #888;
|
|
||||||
margin-bottom: 28px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btns {
|
.btn.primary {
|
||||||
display: flex;
|
border: none;
|
||||||
gap: 12px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-home-btn {
|
|
||||||
display: inline-block;
|
|
||||||
background: linear-gradient(90deg, #6a82fb 0%, #fc5c7d 100%);
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 10px 36px;
|
background: linear-gradient(135deg, #4d82ff 0%, #2f6bff 100%);
|
||||||
border-radius: 24px;
|
box-shadow: 0 10px 22px -10px rgba(47, 107, 255, 0.7);
|
||||||
font-size: 16px;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: background 0.2s;
|
|
||||||
box-shadow: 0 2px 8px rgba(252, 92, 125, 0.07);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-home-btn:hover {
|
.btn.primary:hover {
|
||||||
background: linear-gradient(-90deg, #6a82fb 0%, #fc5c7d 100%);
|
box-shadow: 0 14px 26px -12px rgba(47, 107, 255, 0.8);
|
||||||
|
background: linear-gradient(135deg, #5c8dff 0%, #3a74ff 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear-cache-btn {
|
.btn.ghost {
|
||||||
display: inline-block;
|
border: 1px solid #d7e2f6;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: #fc5c7d;
|
color: #40506e;
|
||||||
padding: 10px 36px;
|
|
||||||
border-radius: 24px;
|
|
||||||
font-size: 16px;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
border: 1px solid #fc5c7d;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear-cache-btn:hover {
|
.btn.ghost:hover {
|
||||||
background: #fff0f3;
|
border-color: #b9cdf4;
|
||||||
|
background: #f7faff;
|
||||||
|
color: #2f6bff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes float-in {
|
.footnote {
|
||||||
0% {
|
margin: 0;
|
||||||
transform: translateY(30px) scale(0.97);
|
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;
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
}
|
}
|
||||||
100% {
|
to {
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
opacity: 1;
|
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>
|
</style>
|
||||||
@@ -371,12 +371,15 @@ async function handleTestProvider(row) {
|
|||||||
|
|
||||||
testingProviderId.value = row.id
|
testingProviderId.value = row.id
|
||||||
try {
|
try {
|
||||||
const res = await testAiProvider({
|
const payload = {
|
||||||
|
id: row.id,
|
||||||
provider_type: row.provider_type,
|
provider_type: row.provider_type,
|
||||||
api_base: row.api_base,
|
api_base: row.api_base,
|
||||||
api_key: row.api_key,
|
|
||||||
models: row.models
|
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) {
|
if (res.code === 200 && res.data.results) {
|
||||||
const successCount = res.data.results.filter(r => r.success).length
|
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>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer">
|
||||||
|
<el-button type="danger" plain @click="handleDelete">删除</el-button>
|
||||||
<el-button @click="handleClose">关闭</el-button>
|
<el-button @click="handleClose">关闭</el-button>
|
||||||
<el-button type="primary" @click="handleEdit">编辑</el-button>
|
<el-button type="primary" @click="handleEdit">编辑</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,7 +116,7 @@ const props = defineProps({
|
|||||||
contact: { type: Object, default: null }
|
contact: { type: Object, default: null }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'edit'])
|
const emit = defineEmits(['update:modelValue', 'edit', 'delete'])
|
||||||
|
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
@@ -153,6 +154,10 @@ const handleEdit = () => {
|
|||||||
emit('edit', props.contact)
|
emit('edit', props.contact)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
emit('delete', props.contact)
|
||||||
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
visible.value = false
|
visible.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,11 +134,20 @@
|
|||||||
<span>{{ row.email || '-' }}</span>
|
<span>{{ row.email || '-' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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 }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" size="small" title="编辑" @click.stop="openEditContact(row)">
|
<el-button link type="primary" size="small" title="编辑" @click.stop="openEditContact(row)">
|
||||||
<el-icon><Edit /></el-icon>
|
<el-icon><Edit /></el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
title="删除"
|
||||||
|
@click.stop="handleDeleteContact(row)"
|
||||||
|
>
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -172,6 +181,7 @@
|
|||||||
v-model="detailDialogVisible"
|
v-model="detailDialogVisible"
|
||||||
:contact="detailContact"
|
:contact="detailContact"
|
||||||
@edit="handleDetailEdit"
|
@edit="handleDetailEdit"
|
||||||
|
@delete="handleDetailDelete"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 () => {
|
const handleSyncAll = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -16,6 +16,17 @@
|
|||||||
>
|
>
|
||||||
批量调岗({{ selectedRows.length }})
|
批量调岗({{ selectedRows.length }})
|
||||||
</el-button>
|
</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
|
<el-button
|
||||||
v-if="module === 'basic'"
|
v-if="module === 'basic'"
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -64,12 +75,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table
|
<el-table
|
||||||
|
ref="tableRef"
|
||||||
:data="pagedRows"
|
:data="pagedRows"
|
||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
stripe
|
stripe
|
||||||
@selection-change="(rows) => (selectedRows = rows)"
|
@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="id" label="ID" width="70" align="center" />-->
|
||||||
<el-table-column prop="account" label="账号" width="130" />
|
<el-table-column prop="account" label="账号" width="130" />
|
||||||
<el-table-column prop="name" label="姓名" width="110">
|
<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 { useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
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 EmployeeEditDialog from './EmployeeEditDialog.vue';
|
||||||
import EmployeeViewDialog from './EmployeeViewDialog.vue';
|
import EmployeeViewDialog from './EmployeeViewDialog.vue';
|
||||||
import MoveEmployeeDialog from './MoveEmployeeDialog.vue';
|
import MoveEmployeeDialog from './MoveEmployeeDialog.vue';
|
||||||
@@ -207,6 +221,7 @@ const handleAddEmployee = () => {
|
|||||||
|
|
||||||
const rows = ref([]);
|
const rows = ref([]);
|
||||||
const selectedRows = ref([]);
|
const selectedRows = ref([]);
|
||||||
|
const tableRef = ref();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const pageSize = ref(10);
|
const pageSize = ref(10);
|
||||||
@@ -257,6 +272,7 @@ const handleSearch = () => {
|
|||||||
|
|
||||||
const handleMoved = () => {
|
const handleMoved = () => {
|
||||||
selectedRows.value = [];
|
selectedRows.value = [];
|
||||||
|
tableRef.value?.clearSelection();
|
||||||
loadData();
|
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);
|
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">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
<div class="home-brand">
|
<div class="home-brand">
|
||||||
<span class="home-logo"><i></i><i></i><i></i><i></i></span>
|
<!-- 平台「平台设置 - 平台信息」的长方形 Logo({yz:logo_rect});
|
||||||
<span class="home-brand-name">温新云平台</span>
|
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>
|
</div>
|
||||||
<!-- 当前企业 + 一人多企业时可点击切换 -->
|
<!-- 当前企业 + 一人多企业时可点击切换 -->
|
||||||
<TenantSwitcher variant="plain" />
|
<TenantSwitcher variant="plain" />
|
||||||
@@ -207,7 +215,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="home-footer">
|
<div class="home-footer">
|
||||||
<span class="copyright">温新云平台</span>
|
<span class="copyright">{{ brandName }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -231,6 +239,7 @@ import {
|
|||||||
import { getTenantList } from "@/api/modules";
|
import { getTenantList } from "@/api/modules";
|
||||||
import { getProductOrderStatus } from "@/api/product";
|
import { getProductOrderStatus } from "@/api/product";
|
||||||
import { getCurrentUserProfile, logout } from "@/api/login";
|
import { getCurrentUserProfile, logout } from "@/api/login";
|
||||||
|
import { getBackendBrand } from "@/api/site";
|
||||||
import { reloadMenusAndDynamicRoutes } from "@/router";
|
import { reloadMenusAndDynamicRoutes } from "@/router";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useMenuStore } from "@/stores/menu";
|
import { useMenuStore } from "@/stores/menu";
|
||||||
@@ -255,6 +264,34 @@ const router = useRouter();
|
|||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const menuStore = useMenuStore();
|
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[] {
|
function sortUnlockedFirst(list: ModuleItem[]): ModuleItem[] {
|
||||||
return [...list].sort(
|
return [...list].sort(
|
||||||
@@ -296,6 +333,15 @@ const toggleTheme = () => {
|
|||||||
|
|
||||||
const currentTheme = computed(() => (isDark.value ? "dark" : "light"));
|
const currentTheme = computed(() => (isDark.value ? "dark" : "light"));
|
||||||
const themeIcon = computed(() => (isDark.value ? Sunny : Moon));
|
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;
|
let stopSystemThemeWatch = null;
|
||||||
|
|
||||||
const userName = computed(
|
const userName = computed(
|
||||||
@@ -472,6 +518,8 @@ onMounted(() => {
|
|||||||
loadModules();
|
loadModules();
|
||||||
syncReturnedPayment();
|
syncReturnedPayment();
|
||||||
initTheme();
|
initTheme();
|
||||||
|
// 品牌 Logo / 站点名称取自平台「平台设置 - 平台信息」
|
||||||
|
fetchBrand();
|
||||||
stopSystemThemeWatch = onSystemThemeChange(() => {
|
stopSystemThemeWatch = onSystemThemeChange(() => {
|
||||||
if (themeMode.value === "system") applyTheme();
|
if (themeMode.value === "system") applyTheme();
|
||||||
});
|
});
|
||||||
@@ -529,30 +577,15 @@ onUnmounted(() => {
|
|||||||
gap: 9px;
|
gap: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-logo {
|
// 平台下发的长方形 Logo({yz:logo_rect}),高度与后台顶栏一致(60px)
|
||||||
display: grid;
|
.home-logo-img {
|
||||||
grid-template-columns: repeat(2, 8px);
|
height: 60px;
|
||||||
gap: 3px;
|
width: auto;
|
||||||
place-content: center;
|
max-width: 240px;
|
||||||
width: 32px;
|
border-radius: 8px;
|
||||||
height: 32px;
|
object-fit: contain;
|
||||||
border-radius: 9px;
|
display: block;
|
||||||
background: @primary-gradient;
|
flex: 0 0 auto;
|
||||||
|
|
||||||
i {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: #fff;
|
|
||||||
|
|
||||||
&:nth-child(2) {
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:nth-child(3) {
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-brand-name {
|
.home-brand-name {
|
||||||
|
|||||||
@@ -0,0 +1,472 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
"server/pkg/identity"
|
||||||
|
"server/pkg/jwtutil"
|
||||||
|
authSvc "server/services/auth"
|
||||||
|
|
||||||
|
"github.com/beego/beego/v2/client/orm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 移动端 OA 考勤打卡接口(挂在 AppOaController 上)。
|
||||||
|
//
|
||||||
|
// 与 Web 后台 /backend/oa/attendance/* 共用同一套数据与判定逻辑:
|
||||||
|
// - 打卡:oaAttendPunch(联动排班明细,匹配不上只提醒、仍可签到);
|
||||||
|
// - 记录:本人维度,只能看到自己的考勤。
|
||||||
|
//
|
||||||
|
// 员工档案缺失时仍允许打卡(employee_id=0,按登录身份 uid 归档),
|
||||||
|
// 保证「排班 / 人事档案没配好也能先签到」,由管理员后续核对。
|
||||||
|
|
||||||
|
// oaAttendAppClaims 取 app 令牌并确认已归属租户;失败时返回可直接输出的状态码与文案。
|
||||||
|
func (c *AppOaController) oaAttendAppClaims() (*jwtutil.Claims, int, string) {
|
||||||
|
claims, err := c.oaClaims()
|
||||||
|
if err != nil {
|
||||||
|
code := identity.Status(err)
|
||||||
|
return nil, code, err.Error()
|
||||||
|
}
|
||||||
|
if claims.TenantId <= 0 {
|
||||||
|
return nil, 403, "当前账号未绑定企业,无法打卡"
|
||||||
|
}
|
||||||
|
return claims, 200, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendSelf 解析当前登录者对应的员工档案;未绑定时返回 nil 与兜底姓名。
|
||||||
|
func (c *AppOaController) oaAttendSelf(claims *jwtutil.Claims) (*models.BackendEmployee, string) {
|
||||||
|
tid := uint64(claims.TenantId)
|
||||||
|
uid := uint64(claims.UserID)
|
||||||
|
if employee, ok := findSelfEmployee(tid, uid, claims.Username); ok {
|
||||||
|
return &employee, employee.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(claims.Username)
|
||||||
|
if bind, err := authSvc.GetTenantUser(uid, tid); err == nil && bind != nil {
|
||||||
|
if v := authBindValue(bind.Name); v != "" {
|
||||||
|
name = v
|
||||||
|
} else if v := authBindValue(bind.Account); v != "" {
|
||||||
|
name = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, name
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendEmployeeMap 当前登录者的员工信息(未绑定时 bound=false)。
|
||||||
|
func oaAttendEmployeeMap(tid uint64, employee *models.BackendEmployee, fallbackName string) map[string]interface{} {
|
||||||
|
if employee == nil {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"id": 0, "name": fallbackName, "account": "",
|
||||||
|
"org_id": 0, "org_name": "", "position": "", "bound": false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
orgID := scEmployeeDepartmentID(*employee)
|
||||||
|
position := ""
|
||||||
|
if employee.Position != nil {
|
||||||
|
position = strings.TrimSpace(*employee.Position)
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"id": employee.ID,
|
||||||
|
"name": employee.Name,
|
||||||
|
"account": employee.Account,
|
||||||
|
"org_id": orgID,
|
||||||
|
"org_name": oaAttendOrgName(tid, orgID),
|
||||||
|
"position": position,
|
||||||
|
"bound": true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendEntryMap 当日排班(未排班返回 nil)。
|
||||||
|
func oaAttendEntryMap(entry *models.OaScheduleEntry) map[string]interface{} {
|
||||||
|
if entry == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"id": entry.ID,
|
||||||
|
"shift_id": entry.ShiftID,
|
||||||
|
"shift_name": entry.ShiftName,
|
||||||
|
"short_name": entry.ShortName,
|
||||||
|
"color": entry.Color,
|
||||||
|
"work_start": entry.WorkStart,
|
||||||
|
"work_end": entry.WorkEnd,
|
||||||
|
"shift_text": oaAttendShiftText(entry.WorkStart, entry.WorkEnd),
|
||||||
|
"source": entry.Source,
|
||||||
|
"note": entry.Note,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendTodayWarning 未匹配排班时的提醒文案(页面顶部横幅 + 打卡前二次确认共用)。
|
||||||
|
func oaAttendTodayWarning(entry *models.OaScheduleEntry, employee *models.BackendEmployee) string {
|
||||||
|
if entry != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if employee == nil {
|
||||||
|
return "未匹配到你的员工档案与今日排班,仍可签到,签到后将标记为「无排班打卡」"
|
||||||
|
}
|
||||||
|
return "今日未匹配到排班,仍可签到,签到后将标记为「无排班打卡」,请联系管理员核对排班"
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendRecentDays 最近 N 天的排班 + 打卡合并视图(一次性批量查询,避免逐天查库)。
|
||||||
|
func oaAttendRecentDays(tid, uid uint64, employeeID uint, endDate string, days int) []map[string]interface{} {
|
||||||
|
if days < 1 || days > 31 {
|
||||||
|
days = 7
|
||||||
|
}
|
||||||
|
startDate := nextOaDate(endDate, -(days - 1))
|
||||||
|
if startDate == "" {
|
||||||
|
startDate = endDate
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := map[string]*models.OaScheduleEntry{}
|
||||||
|
if employeeID > 0 {
|
||||||
|
var entryRows []models.OaScheduleEntry
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.OaScheduleEntry)).
|
||||||
|
Filter("tid", tid).
|
||||||
|
Filter("employee_id", employeeID).
|
||||||
|
Filter("is_deleted", 0).
|
||||||
|
Filter("schedule_date__gte", startDate).
|
||||||
|
Filter("schedule_date__lte", endDate).
|
||||||
|
All(&entryRows)
|
||||||
|
for i := range entryRows {
|
||||||
|
entries[normalizeOaDate(entryRows[i].ScheduleDate)] = &entryRows[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
records := map[string]*models.OaAttendance{}
|
||||||
|
qs := oaAttendQuery(tid).
|
||||||
|
Filter("attend_date__gte", startDate).
|
||||||
|
Filter("attend_date__lte", endDate)
|
||||||
|
if employeeID > 0 {
|
||||||
|
qs = qs.Filter("employee_id", employeeID)
|
||||||
|
} else if uid > 0 {
|
||||||
|
qs = qs.Filter("uid", uid)
|
||||||
|
}
|
||||||
|
var recordRows []models.OaAttendance
|
||||||
|
_, _ = qs.All(&recordRows)
|
||||||
|
for i := range recordRows {
|
||||||
|
records[normalizeOaDate(recordRows[i].AttendDate)] = &recordRows[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]map[string]interface{}, 0, days)
|
||||||
|
today := time.Now().Format("2006-01-02")
|
||||||
|
for date := startDate; date != "" && date <= endDate; date = nextOaDate(date, 1) {
|
||||||
|
entry := entries[date]
|
||||||
|
record := records[date]
|
||||||
|
|
||||||
|
item := map[string]interface{}{
|
||||||
|
"date": date,
|
||||||
|
"weekday": oaAttendWeekday(date),
|
||||||
|
"is_today": date == today,
|
||||||
|
"has_schedule": entry != nil,
|
||||||
|
"matched": entry != nil,
|
||||||
|
"shift_name": "",
|
||||||
|
"shift_text": "",
|
||||||
|
"color": "",
|
||||||
|
"clock_in_hm": "",
|
||||||
|
"clock_out_hm": "",
|
||||||
|
"day_status": oaDayStatusRest,
|
||||||
|
"day_status_text": "休息",
|
||||||
|
}
|
||||||
|
if entry != nil {
|
||||||
|
item["shift_name"] = entry.ShiftName
|
||||||
|
item["shift_text"] = oaAttendShiftText(entry.WorkStart, entry.WorkEnd)
|
||||||
|
item["color"] = entry.Color
|
||||||
|
}
|
||||||
|
if record != nil {
|
||||||
|
view := oaAttendView(record)
|
||||||
|
item["shift_name"] = view.ShiftName
|
||||||
|
item["shift_text"] = view.ShiftText
|
||||||
|
item["color"] = view.Color
|
||||||
|
item["clock_in_hm"] = view.ClockInHm
|
||||||
|
item["clock_out_hm"] = view.ClockOutHm
|
||||||
|
item["day_status"] = view.DayStatus
|
||||||
|
item["day_status_text"] = view.DayStatusText
|
||||||
|
item["has_schedule"] = entry != nil || view.MatchStatus == models.OaAttendMatchOK
|
||||||
|
} else if entry != nil {
|
||||||
|
item["day_status"] = oaDayStatusMissing
|
||||||
|
item["day_status_text"] = "未打卡"
|
||||||
|
}
|
||||||
|
list = append(list, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 倒序(最近的日期在前)
|
||||||
|
for i, j := 0, len(list)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
list[i], list[j] = list[j], list[i]
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendPersonalStats 个人区间考勤统计(员工档案缺失时只统计打卡次数)。
|
||||||
|
func oaAttendPersonalStats(tid, uid uint64, employeeID uint, startDate, endDate string) map[string]interface{} {
|
||||||
|
baseCond := func() *orm.Condition {
|
||||||
|
cond := orm.NewCondition().
|
||||||
|
And("tid", tid).
|
||||||
|
And("attend_date__gte", startDate).
|
||||||
|
And("attend_date__lte", endDate)
|
||||||
|
if employeeID > 0 {
|
||||||
|
cond = cond.And("employee_id", employeeID)
|
||||||
|
} else {
|
||||||
|
cond = cond.And("uid", uid)
|
||||||
|
}
|
||||||
|
return cond
|
||||||
|
}
|
||||||
|
countWith := func(extra *orm.Condition) int64 {
|
||||||
|
cond := baseCond()
|
||||||
|
if extra != nil {
|
||||||
|
cond = cond.AndCond(extra)
|
||||||
|
}
|
||||||
|
n, _ := models.Orm.QueryTable(new(models.OaAttendance)).SetCond(cond).Count()
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduled := 0
|
||||||
|
if employeeID > 0 {
|
||||||
|
n, _ := models.Orm.QueryTable(new(models.OaScheduleEntry)).
|
||||||
|
Filter("tid", tid).
|
||||||
|
Filter("employee_id", employeeID).
|
||||||
|
Filter("is_deleted", 0).
|
||||||
|
Filter("schedule_date__gte", startDate).
|
||||||
|
Filter("schedule_date__lte", endDate).
|
||||||
|
Count()
|
||||||
|
scheduled = int(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
clocked := countWith(orm.NewCondition().And("clock_in_time__isnull", false))
|
||||||
|
late := countWith(orm.NewCondition().And("clock_in_status", models.OaAttendClockLate))
|
||||||
|
early := countWith(orm.NewCondition().And("clock_out_status", models.OaAttendClockLate))
|
||||||
|
noSchedule := countWith(orm.NewCondition().And("match_status", models.OaAttendMatchMissing))
|
||||||
|
matchedClocked := countWith(orm.NewCondition().
|
||||||
|
And("clock_in_time__isnull", false).
|
||||||
|
And("match_status", models.OaAttendMatchOK))
|
||||||
|
|
||||||
|
missing := scheduled - int(matchedClocked)
|
||||||
|
if missing < 0 {
|
||||||
|
missing = 0
|
||||||
|
}
|
||||||
|
rate := 0
|
||||||
|
if scheduled > 0 {
|
||||||
|
rate = int(matchedClocked) * 100 / scheduled
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"scheduled": scheduled,
|
||||||
|
"clocked": clocked,
|
||||||
|
"late": late,
|
||||||
|
"early": early,
|
||||||
|
"noschedule": noSchedule,
|
||||||
|
"missing": missing,
|
||||||
|
"attendance_rate": rate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttendanceToday GET /api/oa/attendance/today?days=7
|
||||||
|
// 今日打卡状态 + 当日排班 + 最近 N 天记录 + 本月统计。
|
||||||
|
// 未匹配排班时 warning 非空,前端据此提醒,但 can_clock_in/can_clock_out 仍为 true。
|
||||||
|
func (c *AppOaController) AttendanceToday() {
|
||||||
|
claims, code, msg := c.oaAttendAppClaims()
|
||||||
|
if msg != "" {
|
||||||
|
c.oaErr(code, code, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tid := uint64(claims.TenantId)
|
||||||
|
uid := uint64(claims.UserID)
|
||||||
|
|
||||||
|
days, _ := c.GetInt("days", 7)
|
||||||
|
now := time.Now()
|
||||||
|
date := now.Format("2006-01-02")
|
||||||
|
|
||||||
|
employee, fallbackName := c.oaAttendSelf(claims)
|
||||||
|
employeeID := uint(0)
|
||||||
|
if employee != nil {
|
||||||
|
employeeID = employee.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := oaAttendEntryOf(tid, employeeID, date)
|
||||||
|
record := oaAttendFind(tid, date, employeeID, uid)
|
||||||
|
|
||||||
|
var recordView interface{}
|
||||||
|
if record != nil {
|
||||||
|
recordView = oaAttendView(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
canClockIn := record == nil || record.ClockInTime == nil
|
||||||
|
canClockOut := record == nil || record.ClockOutTime == nil
|
||||||
|
|
||||||
|
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).Format("2006-01-02")
|
||||||
|
c.oaOk(map[string]interface{}{
|
||||||
|
"date": date,
|
||||||
|
"now": now.Format("2006-01-02 15:04:05"),
|
||||||
|
"employee": oaAttendEmployeeMap(tid, employee, fallbackName),
|
||||||
|
"entry": oaAttendEntryMap(entry),
|
||||||
|
"record": recordView,
|
||||||
|
"matched": entry != nil,
|
||||||
|
"warning": oaAttendTodayWarning(entry, employee),
|
||||||
|
"can_clock_in": canClockIn,
|
||||||
|
"can_clock_out": canClockOut,
|
||||||
|
"recent": oaAttendRecentDays(tid, uid, employeeID, date, days),
|
||||||
|
"stats": oaAttendPersonalStats(tid, uid, employeeID, monthStart, date),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttendanceClock POST /api/oa/attendance/clock
|
||||||
|
// body: {"type":"in|out","location":"...","remark":"..."}
|
||||||
|
// 未匹配排班时会返回 warning,但打卡依然成功(match_status=2,clock_*_status=3)。
|
||||||
|
func (c *AppOaController) AttendanceClock() {
|
||||||
|
claims, code, msg := c.oaAttendAppClaims()
|
||||||
|
if msg != "" {
|
||||||
|
c.oaErr(code, code, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
if raw, readErr := io.ReadAll(c.Ctx.Request.Body); readErr == nil && len(raw) > 0 {
|
||||||
|
if json.Unmarshal(raw, &payload) != nil {
|
||||||
|
c.oaErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
employee, fallbackName := c.oaAttendSelf(claims)
|
||||||
|
result, code, msg := oaAttendPunch(uint64(claims.TenantId), uint64(claims.UserID), employee, oaAttendPunchInput{
|
||||||
|
Type: payload.Type,
|
||||||
|
Location: strings.TrimSpace(payload.Location),
|
||||||
|
Remark: strings.TrimSpace(payload.Remark),
|
||||||
|
FallbackName: fallbackName,
|
||||||
|
})
|
||||||
|
if msg != "" {
|
||||||
|
c.oaErr(code, code, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.oaOk(map[string]interface{}{
|
||||||
|
"record": result.View,
|
||||||
|
"warning": result.Warning,
|
||||||
|
"duplicated": result.Duplicated,
|
||||||
|
"message": result.Message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttendanceRecords GET /api/oa/attendance/records?page=&pageSize=&start_date=&end_date=
|
||||||
|
// 本人考勤记录(默认本月),返回 list/total/stats。
|
||||||
|
func (c *AppOaController) AttendanceRecords() {
|
||||||
|
claims, code, msg := c.oaAttendAppClaims()
|
||||||
|
if msg != "" {
|
||||||
|
c.oaErr(code, code, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tid := uint64(claims.TenantId)
|
||||||
|
uid := uint64(claims.UserID)
|
||||||
|
|
||||||
|
page, _ := c.GetInt("page", 1)
|
||||||
|
pageSize, _ := c.GetInt("pageSize", 20)
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 || pageSize > 100 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
startDate := strings.TrimSpace(c.GetString("start_date"))
|
||||||
|
endDate := strings.TrimSpace(c.GetString("end_date"))
|
||||||
|
if startDate == "" || endDate == "" {
|
||||||
|
startDate = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).Format("2006-01-02")
|
||||||
|
endDate = now.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
dates, msg := scDateSpan(startDate, endDate)
|
||||||
|
if msg != "" {
|
||||||
|
c.oaErr(400, 400, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(dates) > oaAttendMaxRangeDays {
|
||||||
|
c.oaErr(400, 400, "单次最多查询 92 天")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
employee, _ := c.oaAttendSelf(claims)
|
||||||
|
employeeID := uint(0)
|
||||||
|
if employee != nil {
|
||||||
|
employeeID = employee.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
cond := orm.NewCondition().
|
||||||
|
And("tid", tid).
|
||||||
|
And("attend_date__gte", startDate).
|
||||||
|
And("attend_date__lte", endDate)
|
||||||
|
if employeeID > 0 {
|
||||||
|
cond = cond.And("employee_id", employeeID)
|
||||||
|
} else {
|
||||||
|
cond = cond.And("uid", uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
qs := models.Orm.QueryTable(new(models.OaAttendance)).SetCond(cond)
|
||||||
|
total, _ := qs.Count()
|
||||||
|
var rows []models.OaAttendance
|
||||||
|
_, queryErr := qs.OrderBy("-attend_date", "id").
|
||||||
|
Limit(pageSize).Offset((page - 1) * pageSize).All(&rows)
|
||||||
|
if queryErr != nil && queryErr != orm.ErrNoRows {
|
||||||
|
c.oaErr(500, 500, "查询考勤记录失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]oaAttendanceView, 0, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
list = append(list, oaAttendView(&rows[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.oaOk(map[string]interface{}{
|
||||||
|
"list": list,
|
||||||
|
"total": total,
|
||||||
|
"start_date": startDate,
|
||||||
|
"end_date": endDate,
|
||||||
|
"stats": oaAttendPersonalStats(tid, uid, employeeID, startDate, endDate),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttendanceStats GET /api/oa/attendance/stats?period=month|quarter|year&date=YYYY-MM-DD
|
||||||
|
// 个人考勤卡:按「月 / 季 / 年」聚合,供移动端统计卡片切换维度。
|
||||||
|
func (c *AppOaController) AttendanceStats() {
|
||||||
|
claims, code, msg := c.oaAttendAppClaims()
|
||||||
|
if msg != "" {
|
||||||
|
c.oaErr(code, code, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tid := uint64(claims.TenantId)
|
||||||
|
uid := uint64(claims.UserID)
|
||||||
|
|
||||||
|
period := strings.ToLower(strings.TrimSpace(c.GetString("period")))
|
||||||
|
if period != "month" && period != "quarter" && period != "year" {
|
||||||
|
period = "month"
|
||||||
|
}
|
||||||
|
anchor := strings.TrimSpace(c.GetString("date"))
|
||||||
|
if !isValidOaDate(anchor) {
|
||||||
|
anchor = time.Now().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
startDate, endDate, label, rangeOK := oaAttendPeriodRange(period, anchor)
|
||||||
|
if !rangeOK {
|
||||||
|
c.oaErr(400, 400, "日期参数无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
endDate = oaAttendClampRange(startDate, endDate)
|
||||||
|
|
||||||
|
employee, _ := c.oaAttendSelf(claims)
|
||||||
|
employeeID := uint(0)
|
||||||
|
if employee != nil {
|
||||||
|
employeeID = employee.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
c.oaOk(map[string]interface{}{
|
||||||
|
"period": period,
|
||||||
|
"label": label,
|
||||||
|
"anchor": anchor,
|
||||||
|
"start_date": startDate,
|
||||||
|
"end_date": endDate,
|
||||||
|
"stats": oaAttendPersonalStats(tid, uid, employeeID, startDate, endDate),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"server/pkg/jwtutil"
|
"server/pkg/jwtutil"
|
||||||
"server/services"
|
"server/services"
|
||||||
|
|
||||||
|
"github.com/beego/beego/v2/client/orm"
|
||||||
beego "github.com/beego/beego/v2/server/web"
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,6 +52,8 @@ type chatSendPayload struct {
|
|||||||
McpServerIDs []uint64 `json:"mcp_server_ids"`
|
McpServerIDs []uint64 `json:"mcp_server_ids"`
|
||||||
// Images 用户上传的图片(base64 dataURL),仅多模态模型可用
|
// Images 用户上传的图片(base64 dataURL),仅多模态模型可用
|
||||||
Images []string `json:"images"`
|
Images []string `json:"images"`
|
||||||
|
// SystemPrompt 本轮自定义系统提示词(AI 广场的场景对话使用),为空时回落租户预设
|
||||||
|
SystemPrompt string `json:"system_prompt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// mcpEnabled 解析本轮是否启用 MCP(未传默认开启)
|
// mcpEnabled 解析本轮是否启用 MCP(未传默认开启)
|
||||||
@@ -542,7 +545,8 @@ func resolveOrCreateSession(c *BackendAiChatController, claims *jwtutil.Claims,
|
|||||||
return session, nil
|
return session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveProvider 解析 AI 接入配置
|
// resolveProvider 解析 AI 接入配置。
|
||||||
|
// 优先级:租户级配置(user_id=0,由 OA 设置统一维护)→ 个人配置(历史数据)。
|
||||||
func resolveProvider(claims *jwtutil.Claims, p *chatSendPayload, session *models.BackendAiChatSession) (models.BackendAiProvider, error) {
|
func resolveProvider(claims *jwtutil.Claims, p *chatSendPayload, session *models.BackendAiChatSession) (models.BackendAiProvider, error) {
|
||||||
providerID := p.ProviderID
|
providerID := p.ProviderID
|
||||||
if providerID == 0 {
|
if providerID == 0 {
|
||||||
@@ -555,24 +559,33 @@ func resolveProvider(claims *jwtutil.Claims, p *chatSendPayload, session *models
|
|||||||
if err := models.Orm.Read(&provider); err != nil {
|
if err := models.Orm.Read(&provider); err != nil {
|
||||||
return provider, fmt.Errorf("指定的AI接入配置不存在,请先在设置中配置")
|
return provider, fmt.Errorf("指定的AI接入配置不存在,请先在设置中配置")
|
||||||
}
|
}
|
||||||
if provider.TenantID != fmt.Sprintf("%d", claims.TenantId) || provider.UserID != uint64(claims.UserID) {
|
if provider.TenantID != fmt.Sprintf("%d", claims.TenantId) {
|
||||||
|
return provider, fmt.Errorf("无权使用该配置")
|
||||||
|
}
|
||||||
|
// user_id=0 是租户级配置,全员可用;其余只允许本人使用
|
||||||
|
if provider.UserID != 0 && provider.UserID != uint64(claims.UserID) {
|
||||||
return provider, fmt.Errorf("无权使用该配置")
|
return provider, fmt.Errorf("无权使用该配置")
|
||||||
}
|
}
|
||||||
if provider.Status != 1 {
|
if provider.Status != 1 {
|
||||||
return provider, fmt.Errorf("该AI接入配置已被禁用")
|
return provider, fmt.Errorf("该AI接入配置已被禁用")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
tid := fmt.Sprintf("%d", claims.TenantId)
|
||||||
|
cond := orm.NewCondition().
|
||||||
|
And("tenant_id", tid).
|
||||||
|
And("status", 1).
|
||||||
|
And("delete_time__isnull", true).
|
||||||
|
AndCond(orm.NewCondition().
|
||||||
|
Or("user_id", 0).
|
||||||
|
Or("user_id", uint64(claims.UserID)))
|
||||||
var providers []models.BackendAiProvider
|
var providers []models.BackendAiProvider
|
||||||
_, err := models.Orm.QueryTable(new(models.BackendAiProvider)).
|
_, err := models.Orm.QueryTable(new(models.BackendAiProvider)).
|
||||||
Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)).
|
SetCond(cond).
|
||||||
Filter("user_id", uint64(claims.UserID)).
|
OrderBy("user_id", "-is_default", "-id").
|
||||||
Filter("status", 1).
|
|
||||||
Filter("delete_time__isnull", true).
|
|
||||||
OrderBy("-id").
|
|
||||||
Limit(1).
|
Limit(1).
|
||||||
All(&providers)
|
All(&providers)
|
||||||
if err != nil || len(providers) == 0 {
|
if err != nil || len(providers) == 0 {
|
||||||
return provider, fmt.Errorf("尚未配置AI接入,请先在设置中配置OpenAI或Anthropic接入")
|
return provider, fmt.Errorf("尚未配置AI接入,请先联系管理员在「基础设置 → AI 设置」中填写接口地址与 API Key")
|
||||||
}
|
}
|
||||||
provider = providers[0]
|
provider = providers[0]
|
||||||
}
|
}
|
||||||
@@ -610,6 +623,17 @@ func loadSystemPrompt(claims *jwtutil.Claims) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// chatSystemPrompt 优先使用本轮传入的系统提示词(AI 广场的场景对话),否则回落租户预设。
|
||||||
|
func chatSystemPrompt(claims *jwtutil.Claims, p *chatSendPayload) string {
|
||||||
|
if v := strings.TrimSpace(p.SystemPrompt); v != "" {
|
||||||
|
if len([]rune(v)) > 4000 {
|
||||||
|
v = string([]rune(v)[:4000])
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return loadSystemPrompt(claims)
|
||||||
|
}
|
||||||
|
|
||||||
// loadHistory 加载会话历史(最近20条,正序)
|
// loadHistory 加载会话历史(最近20条,正序)
|
||||||
func loadHistory(sessionID uint64) []openaiMessage {
|
func loadHistory(sessionID uint64) []openaiMessage {
|
||||||
var history []models.BackendAiChatMessage
|
var history []models.BackendAiChatMessage
|
||||||
@@ -671,7 +695,7 @@ func (c *BackendAiChatController) Send() {
|
|||||||
c.chatJsonErr(400, 400, err.Error())
|
c.chatJsonErr(400, 400, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
systemPrompt := loadSystemPrompt(claims)
|
systemPrompt := chatSystemPrompt(claims, &p)
|
||||||
|
|
||||||
messages := loadHistory(session.ID)
|
messages := loadHistory(session.ID)
|
||||||
messages = append(messages, openaiMessage{Role: "user", Content: p.Content, Images: p.Images})
|
messages = append(messages, openaiMessage{Role: "user", Content: p.Content, Images: p.Images})
|
||||||
@@ -787,7 +811,7 @@ func (c *BackendAiChatController) SendStream() {
|
|||||||
c.chatJsonErr(400, 400, err.Error())
|
c.chatJsonErr(400, 400, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
systemPrompt := loadSystemPrompt(claims)
|
systemPrompt := chatSystemPrompt(claims, &p)
|
||||||
|
|
||||||
messages := loadHistory(session.ID)
|
messages := loadHistory(session.ID)
|
||||||
messages = append(messages, openaiMessage{Role: "user", Content: p.Content, Images: p.Images})
|
messages = append(messages, openaiMessage{Role: "user", Content: p.Content, Images: p.Images})
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"server/pkg/identity"
|
"server/pkg/identity"
|
||||||
"server/pkg/jwtutil"
|
"server/pkg/jwtutil"
|
||||||
|
|
||||||
|
"github.com/beego/beego/v2/client/orm"
|
||||||
beego "github.com/beego/beego/v2/server/web"
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,13 +47,22 @@ type aiProviderPayload struct {
|
|||||||
IsDefault int8 `json:"is_default"`
|
IsDefault int8 `json:"is_default"`
|
||||||
Status int8 `json:"status"`
|
Status int8 `json:"status"`
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
|
// Scope "tenant" 表示租户级配置(user_id=0,全员共用,由「基础设置 → AI 设置」维护);
|
||||||
|
// 其它值/空表示个人配置,仅创建者可用。
|
||||||
|
Scope string `json:"scope"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// clearTenantDefault 取消租户内所有默认配置
|
// aiTenantScope 本次请求是否在维护租户级配置
|
||||||
func clearTenantDefault(tenantID string) {
|
func aiTenantScope(scope string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(scope), "tenant")
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearTenantDefault 取消同一归属范围内(租户级 / 同一用户)的其他默认配置
|
||||||
|
func clearTenantDefault(tenantID string, userID uint64) {
|
||||||
var defaults []models.BackendAiProvider
|
var defaults []models.BackendAiProvider
|
||||||
_, _ = models.Orm.QueryTable(new(models.BackendAiProvider)).
|
_, _ = models.Orm.QueryTable(new(models.BackendAiProvider)).
|
||||||
Filter("tenant_id", tenantID).
|
Filter("tenant_id", tenantID).
|
||||||
|
Filter("user_id", userID).
|
||||||
Filter("is_default", 1).
|
Filter("is_default", 1).
|
||||||
All(&defaults)
|
All(&defaults)
|
||||||
for _, d := range defaults {
|
for _, d := range defaults {
|
||||||
@@ -75,7 +85,9 @@ func parseProviderModels(m string) []string {
|
|||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
// List GET /backend/ai/provider/list
|
// List GET /backend/ai/provider/list?scope=tenant
|
||||||
|
// scope=tenant 只返回租户级配置(user_id=0);不传时返回「租户级 + 当前用户」,
|
||||||
|
// 供 AI 广场选择可用接入。
|
||||||
func (c *BackendAiProviderController) List() {
|
func (c *BackendAiProviderController) List() {
|
||||||
claims, err := c.aiClaims()
|
claims, err := c.aiClaims()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -84,21 +96,34 @@ func (c *BackendAiProviderController) List() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cond := orm.NewCondition().
|
||||||
|
And("tenant_id", fmt.Sprintf("%d", claims.TenantId)).
|
||||||
|
And("delete_time__isnull", true)
|
||||||
|
if aiTenantScope(c.GetString("scope")) {
|
||||||
|
cond = cond.And("user_id", 0)
|
||||||
|
} else {
|
||||||
|
cond = cond.AndCond(orm.NewCondition().
|
||||||
|
Or("user_id", 0).
|
||||||
|
Or("user_id", uint64(claims.UserID)))
|
||||||
|
}
|
||||||
|
|
||||||
var list []models.BackendAiProvider
|
var list []models.BackendAiProvider
|
||||||
_, err = models.Orm.QueryTable(new(models.BackendAiProvider)).
|
_, err = models.Orm.QueryTable(new(models.BackendAiProvider)).
|
||||||
Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)).
|
SetCond(cond).
|
||||||
Filter("user_id", uint64(claims.UserID)).
|
OrderBy("user_id", "-id").
|
||||||
Filter("delete_time__isnull", true).
|
|
||||||
OrderBy("-id").
|
|
||||||
All(&list)
|
All(&list)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.aiJsonErr(500, 500, "查询失败: "+err.Error())
|
c.aiJsonErr(500, 500, "查询失败: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析模型列表(不脱敏api_key,前端需要完整api_key进行测试)
|
// 解析模型列表。个人配置不回传脱敏 Key(本人可见);
|
||||||
|
// 租户级配置(user_id=0)全租户共用,Key 必须脱敏,测试走 test 接口的 id 模式。
|
||||||
for i := range list {
|
for i := range list {
|
||||||
list[i].ModelsList = parseProviderModels(list[i].Models)
|
list[i].ModelsList = parseProviderModels(list[i].Models)
|
||||||
|
if list[i].UserID == 0 {
|
||||||
|
list[i].ApiKey = maskTenantAiKey(list[i].ApiKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.aiOk(map[string]interface{}{"list": list})
|
c.aiOk(map[string]interface{}{"list": list})
|
||||||
@@ -144,14 +169,20 @@ func (c *BackendAiProviderController) Create() {
|
|||||||
// 模型数组序列化为JSON
|
// 模型数组序列化为JSON
|
||||||
modelsJSON, _ := json.Marshal(p.Models)
|
modelsJSON, _ := json.Marshal(p.Models)
|
||||||
|
|
||||||
// 如果设为默认,先取消租户内其他默认
|
// 租户级配置(user_id=0)由 OA 设置统一维护,全员可用
|
||||||
|
ownerID := uint64(claims.UserID)
|
||||||
|
if aiTenantScope(p.Scope) {
|
||||||
|
ownerID = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果设为默认,先取消同一归属范围内的其他默认
|
||||||
if p.IsDefault == 1 {
|
if p.IsDefault == 1 {
|
||||||
clearTenantDefault(fmt.Sprintf("%d", claims.TenantId))
|
clearTenantDefault(fmt.Sprintf("%d", claims.TenantId), ownerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
provider := models.BackendAiProvider{
|
provider := models.BackendAiProvider{
|
||||||
TenantID: fmt.Sprintf("%d", claims.TenantId),
|
TenantID: fmt.Sprintf("%d", claims.TenantId),
|
||||||
UserID: uint64(claims.UserID),
|
UserID: ownerID,
|
||||||
ProviderType: p.ProviderType,
|
ProviderType: p.ProviderType,
|
||||||
Name: strings.TrimSpace(p.Name),
|
Name: strings.TrimSpace(p.Name),
|
||||||
ApiBase: strings.TrimSpace(p.ApiBase),
|
ApiBase: strings.TrimSpace(p.ApiBase),
|
||||||
@@ -199,6 +230,11 @@ func (c *BackendAiProviderController) Update() {
|
|||||||
c.aiJsonErr(403, 403, "无权操作")
|
c.aiJsonErr(403, 403, "无权操作")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 租户级配置(user_id=0)允许本租户管理员在 AI 设置中维护,个人配置仅本人可改
|
||||||
|
if provider.UserID != 0 && provider.UserID != uint64(claims.UserID) {
|
||||||
|
c.aiJsonErr(403, 403, "无权操作")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(c.Ctx.Request.Body)
|
body, err := io.ReadAll(c.Ctx.Request.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -211,9 +247,9 @@ func (c *BackendAiProviderController) Update() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果设为默认,先取消租户内其他默认
|
// 如果设为默认,先取消同一归属范围内的其他默认
|
||||||
if p.IsDefault == 1 && provider.IsDefault != 1 {
|
if p.IsDefault == 1 && provider.IsDefault != 1 {
|
||||||
clearTenantDefault(fmt.Sprintf("%d", claims.TenantId))
|
clearTenantDefault(fmt.Sprintf("%d", claims.TenantId), provider.UserID)
|
||||||
}
|
}
|
||||||
|
|
||||||
provider.Name = strings.TrimSpace(p.Name)
|
provider.Name = strings.TrimSpace(p.Name)
|
||||||
@@ -268,6 +304,10 @@ func (c *BackendAiProviderController) Delete() {
|
|||||||
c.aiJsonErr(403, 403, "无权操作")
|
c.aiJsonErr(403, 403, "无权操作")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if provider.UserID != 0 && provider.UserID != uint64(claims.UserID) {
|
||||||
|
c.aiJsonErr(403, 403, "无权操作")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
provider.DeleteTime = &now
|
provider.DeleteTime = &now
|
||||||
@@ -281,6 +321,8 @@ func (c *BackendAiProviderController) Delete() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type aiTestPayload struct {
|
type aiTestPayload struct {
|
||||||
|
// ID 已保存的配置ID:不传 api_key(或传脱敏值)时用库里的密钥测试
|
||||||
|
ID uint64 `json:"id"`
|
||||||
ProviderType string `json:"provider_type"`
|
ProviderType string `json:"provider_type"`
|
||||||
ApiBase string `json:"api_base"`
|
ApiBase string `json:"api_base"`
|
||||||
ApiKey string `json:"api_key"`
|
ApiKey string `json:"api_key"`
|
||||||
@@ -293,10 +335,26 @@ type modelTestResult struct {
|
|||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maskTenantAiKey 脱敏租户级配置的密钥(保留前后各 4 位)。
|
||||||
|
// 注意:结果包含 ****,Update 遇到含 **** 的 key 会跳过更新,不会把脱敏值写回库。
|
||||||
|
func maskTenantAiKey(key string) string {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(key) <= 8 {
|
||||||
|
return "****"
|
||||||
|
}
|
||||||
|
return key[:4] + "****" + key[len(key)-4:]
|
||||||
|
}
|
||||||
|
|
||||||
// Test POST /backend/ai/provider/test
|
// Test POST /backend/ai/provider/test
|
||||||
// 批量测试模型连通性
|
// 批量测试模型连通性。支持两种模式:
|
||||||
|
// 1. 传 api_base + api_key:直接测试(新增/修改未保存的表单);
|
||||||
|
// 2. 传 id(api_key 留空或为脱敏值):用已保存配置的密钥测试。
|
||||||
func (c *BackendAiProviderController) Test() {
|
func (c *BackendAiProviderController) Test() {
|
||||||
if _, err := c.aiClaims(); err != nil {
|
claims, err := c.aiClaims()
|
||||||
|
if err != nil {
|
||||||
code := identity.Status(err)
|
code := identity.Status(err)
|
||||||
c.aiJsonErr(code, code, err.Error())
|
c.aiJsonErr(code, code, err.Error())
|
||||||
return
|
return
|
||||||
@@ -313,7 +371,31 @@ func (c *BackendAiProviderController) Test() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(p.ApiBase) == "" || strings.TrimSpace(p.ApiKey) == "" {
|
apiKey := strings.TrimSpace(p.ApiKey)
|
||||||
|
if p.ID > 0 && (apiKey == "" || strings.Contains(apiKey, "****")) {
|
||||||
|
stored := models.BackendAiProvider{ID: p.ID}
|
||||||
|
if err := models.Orm.Read(&stored); err != nil {
|
||||||
|
c.aiJsonErr(404, 404, "配置不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if stored.TenantID != fmt.Sprintf("%d", claims.TenantId) {
|
||||||
|
c.aiJsonErr(403, 403, "无权操作")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if stored.UserID != 0 && stored.UserID != uint64(claims.UserID) {
|
||||||
|
c.aiJsonErr(403, 403, "无权操作")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiKey = stored.ApiKey
|
||||||
|
if strings.TrimSpace(p.ApiBase) == "" {
|
||||||
|
p.ApiBase = stored.ApiBase
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.ProviderType) == "" {
|
||||||
|
p.ProviderType = stored.ProviderType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(p.ApiBase) == "" || apiKey == "" {
|
||||||
c.aiJsonErr(400, 400, "接口地址和API Key不能为空")
|
c.aiJsonErr(400, 400, "接口地址和API Key不能为空")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -326,7 +408,7 @@ func (c *BackendAiProviderController) Test() {
|
|||||||
provider := models.BackendAiProvider{
|
provider := models.BackendAiProvider{
|
||||||
ProviderType: p.ProviderType,
|
ProviderType: p.ProviderType,
|
||||||
ApiBase: strings.TrimSpace(p.ApiBase),
|
ApiBase: strings.TrimSpace(p.ApiBase),
|
||||||
ApiKey: strings.TrimSpace(p.ApiKey),
|
ApiKey: apiKey,
|
||||||
}
|
}
|
||||||
testMessages := []openaiMessage{{Role: "user", Content: "hi"}}
|
testMessages := []openaiMessage{{Role: "user", Content: "hi"}}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,919 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
"server/pkg/identity"
|
||||||
|
"server/pkg/jwtutil"
|
||||||
|
|
||||||
|
"github.com/beego/beego/v2/client/orm"
|
||||||
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OA 考勤打卡:移动端签到 / 签退(app_oa_attendance.go)+ 后台考勤记录(本文件)。
|
||||||
|
//
|
||||||
|
// 打卡与排班联动的方式:
|
||||||
|
// 1. 打卡时按「员工 + 日期」查排班明细(yz_backend_oa_schedule_entry),
|
||||||
|
// 命中则把班次名称与上下班时间快照进考勤记录,并据此判定正常 / 迟到 / 早退;
|
||||||
|
// 2. 未命中排班(含员工档案缺失)不影响打卡,只把 match_status 记为 2、
|
||||||
|
// 在 clock_in_note 里写入提醒文案,前端据此做「先提醒、仍可签到」的交互;
|
||||||
|
// 3. 后台只读考勤记录 + 缺卡名单,异常说明由管理员在备注里补充。
|
||||||
|
|
||||||
|
const (
|
||||||
|
// oaAttendGraceMinutes 迟到 / 早退容差,避免卡点打卡被判异常。
|
||||||
|
oaAttendGraceMinutes = 5
|
||||||
|
// oaAttendEarlyLimitMinutes 允许提前签到的分钟数:超过则记为「不在班次时段」。
|
||||||
|
oaAttendEarlyLimitMinutes = 60
|
||||||
|
// oaAttendMaxRangeDays 单次查询的日期区间上限。
|
||||||
|
oaAttendMaxRangeDays = 92
|
||||||
|
// oaAttendMaxPageSize 分页大小上限。
|
||||||
|
oaAttendMaxPageSize = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
// 考勤「当日结果」:列表与统计统一按这个口径展示。
|
||||||
|
const (
|
||||||
|
oaDayStatusNormal int8 = 1 // 正常
|
||||||
|
oaDayStatusLate int8 = 2 // 迟到
|
||||||
|
oaDayStatusEarly int8 = 3 // 早退
|
||||||
|
oaDayStatusMissing int8 = 4 // 缺卡(未签到)
|
||||||
|
oaDayStatusNoSchedule int8 = 5 // 无排班打卡
|
||||||
|
oaDayStatusOffTime int8 = 6 // 不在班次时段
|
||||||
|
oaDayStatusNoClockOut int8 = 7 // 未签退
|
||||||
|
oaDayStatusRest int8 = 8 // 未排班且未打卡(休息)
|
||||||
|
)
|
||||||
|
|
||||||
|
// oaAttendanceView 考勤记录的对外结构:时间统一转成字符串,避免前端解析 RFC3339。
|
||||||
|
type oaAttendanceView struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Uid uint64 `json:"uid"`
|
||||||
|
EmployeeID uint `json:"employee_id"`
|
||||||
|
EmployeeName string `json:"employee_name"`
|
||||||
|
EmployeeAccount string `json:"employee_account"`
|
||||||
|
OrgID uint64 `json:"org_id"`
|
||||||
|
OrgName string `json:"org_name"`
|
||||||
|
Position string `json:"position"`
|
||||||
|
AttendDate string `json:"attend_date"`
|
||||||
|
Weekday int `json:"weekday"`
|
||||||
|
EntryID uint64 `json:"entry_id"`
|
||||||
|
ShiftID uint64 `json:"shift_id"`
|
||||||
|
ShiftName string `json:"shift_name"`
|
||||||
|
ShortName string `json:"short_name"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
WorkStart string `json:"work_start"`
|
||||||
|
WorkEnd string `json:"work_end"`
|
||||||
|
ShiftText string `json:"shift_text"`
|
||||||
|
MatchStatus int8 `json:"match_status"`
|
||||||
|
ClockInTime string `json:"clock_in_time"`
|
||||||
|
ClockInHm string `json:"clock_in_hm"`
|
||||||
|
ClockInStatus int8 `json:"clock_in_status"`
|
||||||
|
ClockInText string `json:"clock_in_text"`
|
||||||
|
ClockInNote string `json:"clock_in_note"`
|
||||||
|
ClockOutTime string `json:"clock_out_time"`
|
||||||
|
ClockOutHm string `json:"clock_out_hm"`
|
||||||
|
ClockOutStatus int8 `json:"clock_out_status"`
|
||||||
|
ClockOutText string `json:"clock_out_text"`
|
||||||
|
ClockOutNote string `json:"clock_out_note"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
Source int8 `json:"source"`
|
||||||
|
DayStatus int8 `json:"day_status"`
|
||||||
|
DayStatusText string `json:"day_status_text"`
|
||||||
|
CreateTime string `json:"create_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackendOaAttendanceController 后台考勤记录(只读 + 备注)。
|
||||||
|
type BackendOaAttendanceController struct {
|
||||||
|
beego.Controller
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 认证与响应
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (c *BackendOaAttendanceController) atClaims() (*jwtutil.Claims, bool) {
|
||||||
|
claims, err := identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndBackend)
|
||||||
|
if err != nil || claims.TenantId <= 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return claims, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *BackendOaAttendanceController) atOperator() (uint64, uint64, bool) {
|
||||||
|
claims, ok := c.atClaims()
|
||||||
|
if !ok {
|
||||||
|
c.atFail(401, 401, "未登录或登录已过期")
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return uint64(claims.TenantId), uint64(claims.UserID), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *BackendOaAttendanceController) atOK(data interface{}) {
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *BackendOaAttendanceController) atFail(httpStatus, bizCode int, msg string) {
|
||||||
|
if httpStatus > 0 {
|
||||||
|
c.Ctx.Output.SetStatus(httpStatus)
|
||||||
|
}
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 通用工具
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// oaAttendQuery 本租户考勤记录查询。
|
||||||
|
func oaAttendQuery(tid uint64) orm.QuerySeter {
|
||||||
|
return models.Orm.QueryTable(new(models.OaAttendance)).Filter("tid", tid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendFind 取某人某天的考勤记录:有员工档案按员工,否则按登录身份兜底。
|
||||||
|
func oaAttendFind(tid uint64, date string, employeeID uint, uid uint64) *models.OaAttendance {
|
||||||
|
qs := oaAttendQuery(tid).Filter("attend_date", date)
|
||||||
|
switch {
|
||||||
|
case employeeID > 0:
|
||||||
|
qs = qs.Filter("employee_id", employeeID)
|
||||||
|
case uid > 0:
|
||||||
|
qs = qs.Filter("uid", uid)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var row models.OaAttendance
|
||||||
|
if err := qs.OrderBy("-id").Limit(1).One(&row); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &row
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendEntryOf 取某员工某天的排班明细(一人一天一条有效记录)。
|
||||||
|
func oaAttendEntryOf(tid uint64, employeeID uint, date string) *models.OaScheduleEntry {
|
||||||
|
if employeeID == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var rows []models.OaScheduleEntry
|
||||||
|
_, err := models.Orm.QueryTable(new(models.OaScheduleEntry)).
|
||||||
|
Filter("tid", tid).
|
||||||
|
Filter("employee_id", employeeID).
|
||||||
|
Filter("schedule_date", date).
|
||||||
|
Filter("is_deleted", 0).
|
||||||
|
OrderBy("-id").Limit(1).All(&rows)
|
||||||
|
if err != nil || len(rows) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entry := rows[0]
|
||||||
|
entry.ScheduleDate = normalizeOaDate(entry.ScheduleDate)
|
||||||
|
return &entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendOrgName 组织名称快照(失败返回空串,不影响打卡)。
|
||||||
|
func oaAttendOrgName(tid, orgID uint64) string {
|
||||||
|
if orgID == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var org models.BackendOrganization
|
||||||
|
if err := models.Orm.QueryTable(new(models.BackendOrganization)).
|
||||||
|
Filter("tid", tid).Filter("id", orgID).One(&org); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return org.OrgName
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendOrgIDs 把组织节点展开成「自身 + 下级」的 ID 列表;org_id=0 表示不限。
|
||||||
|
func oaAttendOrgIDs(tid, root uint64, includeChild bool) []uint64 {
|
||||||
|
if root == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !includeChild {
|
||||||
|
return []uint64{root}
|
||||||
|
}
|
||||||
|
var rows []models.BackendOrganization
|
||||||
|
_, err := models.Orm.QueryTable(new(models.BackendOrganization)).
|
||||||
|
Filter("tid", tid).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
All(&rows)
|
||||||
|
if err != nil {
|
||||||
|
return []uint64{root}
|
||||||
|
}
|
||||||
|
children := map[uint64][]uint64{}
|
||||||
|
for i := range rows {
|
||||||
|
children[rows[i].ParentID] = append(children[rows[i].ParentID], rows[i].ID)
|
||||||
|
}
|
||||||
|
seen := map[uint64]bool{}
|
||||||
|
queue := []uint64{root}
|
||||||
|
for len(queue) > 0 {
|
||||||
|
current := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
if seen[current] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[current] = true
|
||||||
|
queue = append(queue, children[current]...)
|
||||||
|
}
|
||||||
|
out := make([]uint64, 0, len(seen))
|
||||||
|
for id := range seen {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendWeekday 返回 1=周一 ... 7=周日。
|
||||||
|
func oaAttendWeekday(date string) int {
|
||||||
|
parsed, ok := scParseDate(date)
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
weekday := int(parsed.Weekday())
|
||||||
|
if weekday == 0 {
|
||||||
|
return 7
|
||||||
|
}
|
||||||
|
return weekday
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendShiftText 班次时间文案。
|
||||||
|
func oaAttendShiftText(workStart, workEnd string) string {
|
||||||
|
if workStart == "" || workEnd == "" {
|
||||||
|
return "未排班"
|
||||||
|
}
|
||||||
|
return workStart + "-" + workEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
func oaAttendClockText(status int8, isOut bool) string {
|
||||||
|
switch status {
|
||||||
|
case models.OaAttendClockNone:
|
||||||
|
return "未打卡"
|
||||||
|
case models.OaAttendClockNormal:
|
||||||
|
return "正常"
|
||||||
|
case models.OaAttendClockLate:
|
||||||
|
if isOut {
|
||||||
|
return "早退"
|
||||||
|
}
|
||||||
|
return "迟到"
|
||||||
|
case models.OaAttendClockNoShift:
|
||||||
|
return "无排班"
|
||||||
|
case models.OaAttendClockOffTime:
|
||||||
|
return "时段异常"
|
||||||
|
}
|
||||||
|
return "未打卡"
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendDayResult 汇总「当天」的考勤结果,列表与统计共用同一口径。
|
||||||
|
func oaAttendDayResult(clockIn string, clockInStatus int8, clockOut string, clockOutStatus int8, matchStatus int8) (int8, string) {
|
||||||
|
switch {
|
||||||
|
case clockIn == "":
|
||||||
|
return oaDayStatusMissing, "缺卡"
|
||||||
|
case matchStatus == models.OaAttendMatchMissing:
|
||||||
|
return oaDayStatusNoSchedule, "无排班"
|
||||||
|
case clockInStatus == models.OaAttendClockLate:
|
||||||
|
return oaDayStatusLate, "迟到"
|
||||||
|
case clockOutStatus == models.OaAttendClockLate:
|
||||||
|
return oaDayStatusEarly, "早退"
|
||||||
|
case clockInStatus == models.OaAttendClockOffTime || clockOutStatus == models.OaAttendClockOffTime:
|
||||||
|
return oaDayStatusOffTime, "时段异常"
|
||||||
|
case clockOut == "":
|
||||||
|
return oaDayStatusNoClockOut, "未签退"
|
||||||
|
}
|
||||||
|
return oaDayStatusNormal, "正常"
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendView 记录 → 视图。
|
||||||
|
func oaAttendView(row *models.OaAttendance) oaAttendanceView {
|
||||||
|
date := normalizeOaDate(row.AttendDate)
|
||||||
|
inTime, inHm := "", ""
|
||||||
|
if row.ClockInTime != nil {
|
||||||
|
inTime = row.ClockInTime.Format("2006-01-02 15:04:05")
|
||||||
|
inHm = row.ClockInTime.Format("15:04")
|
||||||
|
}
|
||||||
|
outTime, outHm := "", ""
|
||||||
|
if row.ClockOutTime != nil {
|
||||||
|
outTime = row.ClockOutTime.Format("2006-01-02 15:04:05")
|
||||||
|
outHm = row.ClockOutTime.Format("15:04")
|
||||||
|
}
|
||||||
|
dayStatus, dayText := oaAttendDayResult(inHm, row.ClockInStatus, outHm, row.ClockOutStatus, row.MatchStatus)
|
||||||
|
return oaAttendanceView{
|
||||||
|
ID: row.ID,
|
||||||
|
Uid: row.Uid,
|
||||||
|
EmployeeID: row.EmployeeID,
|
||||||
|
EmployeeName: row.EmployeeName,
|
||||||
|
EmployeeAccount: row.EmployeeAccount,
|
||||||
|
OrgID: row.OrgID,
|
||||||
|
OrgName: row.OrgName,
|
||||||
|
Position: row.Position,
|
||||||
|
AttendDate: date,
|
||||||
|
Weekday: oaAttendWeekday(date),
|
||||||
|
EntryID: row.EntryID,
|
||||||
|
ShiftID: row.ShiftID,
|
||||||
|
ShiftName: row.ShiftName,
|
||||||
|
ShortName: row.ShortName,
|
||||||
|
Color: row.Color,
|
||||||
|
WorkStart: row.WorkStart,
|
||||||
|
WorkEnd: row.WorkEnd,
|
||||||
|
ShiftText: oaAttendShiftText(row.WorkStart, row.WorkEnd),
|
||||||
|
MatchStatus: row.MatchStatus,
|
||||||
|
ClockInTime: inTime,
|
||||||
|
ClockInHm: inHm,
|
||||||
|
ClockInStatus: row.ClockInStatus,
|
||||||
|
ClockInText: oaAttendClockText(row.ClockInStatus, false),
|
||||||
|
ClockInNote: row.ClockInNote,
|
||||||
|
ClockOutTime: outTime,
|
||||||
|
ClockOutHm: outHm,
|
||||||
|
ClockOutStatus: row.ClockOutStatus,
|
||||||
|
ClockOutText: oaAttendClockText(row.ClockOutStatus, true),
|
||||||
|
ClockOutNote: row.ClockOutNote,
|
||||||
|
Location: row.Location,
|
||||||
|
Remark: row.Remark,
|
||||||
|
Source: row.Source,
|
||||||
|
DayStatus: dayStatus,
|
||||||
|
DayStatusText: dayText,
|
||||||
|
CreateTime: row.CreateTime.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 打卡核心(移动端与后续补录共用)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// oaAttendPunchInput 打卡入参。
|
||||||
|
type oaAttendPunchInput struct {
|
||||||
|
Type string // in=签到 out=签退
|
||||||
|
Location string
|
||||||
|
Remark string
|
||||||
|
FallbackName string // 员工档案缺失时用于展示的姓名
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendPunchResult 打卡结果。
|
||||||
|
type oaAttendPunchResult struct {
|
||||||
|
View oaAttendanceView
|
||||||
|
Warning string
|
||||||
|
Duplicated bool
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaShiftWindow 把班次上下班换算成当日分钟区间;下班早于上班视为跨天班次。
|
||||||
|
func oaShiftWindow(workStart, workEnd string) (int, int, bool) {
|
||||||
|
start, okStart := scMinutes(workStart)
|
||||||
|
end, okEnd := scMinutes(workEnd)
|
||||||
|
if !okStart || !okEnd {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
if end <= start {
|
||||||
|
end += 24 * 60
|
||||||
|
}
|
||||||
|
return start, end, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaShiftNowMinutes 把当前时刻换算到班次时间轴上(跨天班次凌晨时刻 +24h)。
|
||||||
|
func oaShiftNowMinutes(now time.Time, startMinutes int) int {
|
||||||
|
value := now.Hour()*60 + now.Minute()
|
||||||
|
if startMinutes >= 12*60 && value < startMinutes {
|
||||||
|
value += 24 * 60
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendClockInStatus 签到结果判定:无排班 / 时段异常只提醒,不阻断打卡。
|
||||||
|
func oaAttendClockInStatus(row *models.OaAttendance, now time.Time) (int8, string) {
|
||||||
|
start, _, ok := oaShiftWindow(row.WorkStart, row.WorkEnd)
|
||||||
|
if !ok {
|
||||||
|
if row.MatchStatus == models.OaAttendMatchMissing {
|
||||||
|
return models.OaAttendClockNoShift, "今日未匹配到排班,已记录为「无排班打卡」,请联系管理员核对排班"
|
||||||
|
}
|
||||||
|
return models.OaAttendClockOffTime, "班次未配置上下班时间,已记录签到,请核对班次设置"
|
||||||
|
}
|
||||||
|
nowMinutes := oaShiftNowMinutes(now, start)
|
||||||
|
switch {
|
||||||
|
case nowMinutes < start-oaAttendEarlyLimitMinutes:
|
||||||
|
return models.OaAttendClockOffTime, fmt.Sprintf("当前时间不在班次时段内(班次 %s-%s),已记录签到",
|
||||||
|
row.WorkStart, row.WorkEnd)
|
||||||
|
case nowMinutes > start+oaAttendGraceMinutes:
|
||||||
|
return models.OaAttendClockLate, fmt.Sprintf("迟到 %d 分钟(应到 %s),已记录签到",
|
||||||
|
nowMinutes-start, row.WorkStart)
|
||||||
|
}
|
||||||
|
return models.OaAttendClockNormal, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendClockOutStatus 签退结果判定:早退只提醒,不阻断打卡。
|
||||||
|
func oaAttendClockOutStatus(row *models.OaAttendance, now time.Time) (int8, string) {
|
||||||
|
start, end, ok := oaShiftWindow(row.WorkStart, row.WorkEnd)
|
||||||
|
if !ok {
|
||||||
|
if row.MatchStatus == models.OaAttendMatchMissing {
|
||||||
|
return models.OaAttendClockNoShift, "今日未匹配到排班,已记录为「无排班签退」,请联系管理员核对排班"
|
||||||
|
}
|
||||||
|
return models.OaAttendClockOffTime, "班次未配置上下班时间,已记录签退,请核对班次设置"
|
||||||
|
}
|
||||||
|
nowMinutes := oaShiftNowMinutes(now, start)
|
||||||
|
if nowMinutes < end-oaAttendGraceMinutes {
|
||||||
|
return models.OaAttendClockLate, fmt.Sprintf("早退 %d 分钟(应退 %s),已记录签退",
|
||||||
|
end-nowMinutes, row.WorkEnd)
|
||||||
|
}
|
||||||
|
return models.OaAttendClockNormal, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendPrevOpen 跨天班次兜底:昨天签到未签退、且班次为跨天班次时,
|
||||||
|
// 今天的签退落到昨天那条记录上,避免凌晨签退被记成新的一天。
|
||||||
|
func oaAttendPrevOpen(tid uint64, uid uint64, employeeID uint, date string) *models.OaAttendance {
|
||||||
|
prevDate := nextOaDate(date, -1)
|
||||||
|
if prevDate == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
row := oaAttendFind(tid, prevDate, employeeID, uid)
|
||||||
|
if row == nil || row.ClockInTime == nil || row.ClockOutTime != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
start, end, ok := oaShiftWindow(row.WorkStart, row.WorkEnd)
|
||||||
|
if !ok || end <= start || end <= 24*60 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendPunch 考勤打卡核心:写库并返回记录、提醒文案与是否重复打卡。
|
||||||
|
func oaAttendPunch(tid, uid uint64, employee *models.BackendEmployee, input oaAttendPunchInput) (*oaAttendPunchResult, int, string) {
|
||||||
|
clockType := strings.ToLower(strings.TrimSpace(input.Type))
|
||||||
|
if clockType != "in" && clockType != "out" {
|
||||||
|
return nil, 400, "打卡类型无效"
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
date := now.Format("2006-01-02")
|
||||||
|
|
||||||
|
employeeID := uint(0)
|
||||||
|
name := strings.TrimSpace(input.FallbackName)
|
||||||
|
account := ""
|
||||||
|
orgID := uint64(0)
|
||||||
|
orgName := ""
|
||||||
|
position := ""
|
||||||
|
if employee != nil {
|
||||||
|
employeeID = employee.ID
|
||||||
|
name = employee.Name
|
||||||
|
account = employee.Account
|
||||||
|
orgID = scEmployeeDepartmentID(*employee)
|
||||||
|
orgName = oaAttendOrgName(tid, orgID)
|
||||||
|
if employee.Position != nil {
|
||||||
|
position = strings.TrimSpace(*employee.Position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = "未知员工"
|
||||||
|
}
|
||||||
|
|
||||||
|
row := oaAttendFind(tid, date, employeeID, uid)
|
||||||
|
if clockType == "out" && (row == nil || row.ClockInTime == nil) {
|
||||||
|
if prev := oaAttendPrevOpen(tid, uid, employeeID, date); prev != nil {
|
||||||
|
row = prev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
created := row == nil
|
||||||
|
if created {
|
||||||
|
row = &models.OaAttendance{
|
||||||
|
Tid: int(tid),
|
||||||
|
Uid: uid,
|
||||||
|
EmployeeID: employeeID,
|
||||||
|
EmployeeName: name,
|
||||||
|
EmployeeAccount: account,
|
||||||
|
OrgID: orgID,
|
||||||
|
OrgName: orgName,
|
||||||
|
Position: position,
|
||||||
|
AttendDate: date,
|
||||||
|
MatchStatus: models.OaAttendMatchUnknown,
|
||||||
|
Source: models.OaAttendSourceApp,
|
||||||
|
}
|
||||||
|
} else if row.EmployeeID == 0 && employeeID > 0 {
|
||||||
|
// 早前未绑定员工档案时打的卡,补齐员工信息
|
||||||
|
row.EmployeeID = employeeID
|
||||||
|
row.EmployeeName = name
|
||||||
|
row.EmployeeAccount = account
|
||||||
|
row.OrgID = orgID
|
||||||
|
row.OrgName = orgName
|
||||||
|
row.Position = position
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排班快照:以打卡时的排班为准,后续调班不影响历史记录
|
||||||
|
if entry := oaAttendEntryOf(tid, employeeID, normalizeOaDate(row.AttendDate)); entry != nil {
|
||||||
|
row.EntryID = entry.ID
|
||||||
|
row.ShiftID = entry.ShiftID
|
||||||
|
row.ShiftName = entry.ShiftName
|
||||||
|
row.ShortName = entry.ShortName
|
||||||
|
row.Color = entry.Color
|
||||||
|
row.WorkStart = entry.WorkStart
|
||||||
|
row.WorkEnd = entry.WorkEnd
|
||||||
|
row.MatchStatus = models.OaAttendMatchOK
|
||||||
|
} else if created {
|
||||||
|
row.MatchStatus = models.OaAttendMatchMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
warning := ""
|
||||||
|
switch clockType {
|
||||||
|
case "in":
|
||||||
|
if row.ClockInTime != nil {
|
||||||
|
return &oaAttendPunchResult{
|
||||||
|
View: oaAttendView(row),
|
||||||
|
Warning: "今日已于 " + row.ClockInTime.Format("15:04") + " 签到,无需重复签到",
|
||||||
|
Duplicated: true,
|
||||||
|
Message: "今日已签到",
|
||||||
|
}, 200, ""
|
||||||
|
}
|
||||||
|
row.ClockInTime = &now
|
||||||
|
row.ClockInStatus, warning = oaAttendClockInStatus(row, now)
|
||||||
|
row.ClockInNote = warning
|
||||||
|
if input.Location != "" {
|
||||||
|
row.Location = input.Location
|
||||||
|
}
|
||||||
|
case "out":
|
||||||
|
if row.ClockOutTime != nil {
|
||||||
|
return &oaAttendPunchResult{
|
||||||
|
View: oaAttendView(row),
|
||||||
|
Warning: "今日已于 " + row.ClockOutTime.Format("15:04") + " 签退,无需重复签退",
|
||||||
|
Duplicated: true,
|
||||||
|
Message: "今日已签退",
|
||||||
|
}, 200, ""
|
||||||
|
}
|
||||||
|
if row.ClockInTime == nil {
|
||||||
|
warning = "今日尚未签到,本次签退已记录,请联系管理员核实"
|
||||||
|
}
|
||||||
|
row.ClockOutTime = &now
|
||||||
|
status, msg := oaAttendClockOutStatus(row, now)
|
||||||
|
row.ClockOutStatus = status
|
||||||
|
notes := make([]string, 0, 2)
|
||||||
|
if warning != "" {
|
||||||
|
notes = append(notes, warning)
|
||||||
|
}
|
||||||
|
if msg != "" {
|
||||||
|
notes = append(notes, msg)
|
||||||
|
if warning == "" {
|
||||||
|
warning = msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.ClockOutNote = strings.Join(notes, ";")
|
||||||
|
}
|
||||||
|
if input.Remark != "" {
|
||||||
|
row.Remark = input.Remark
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := []string{
|
||||||
|
"uid", "employee_id", "employee_name", "employee_account", "org_id", "org_name", "position",
|
||||||
|
"entry_id", "shift_id", "shift_name", "short_name", "color", "work_start", "work_end", "match_status",
|
||||||
|
"clock_in_time", "clock_in_status", "clock_in_note",
|
||||||
|
"clock_out_time", "clock_out_status", "clock_out_note",
|
||||||
|
"location", "remark", "source",
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
if _, err := models.Orm.Insert(row); err != nil {
|
||||||
|
// 并发下唯一键冲突:按已打卡返回,避免前端误判失败
|
||||||
|
if exist := oaAttendFind(tid, date, employeeID, uid); exist != nil {
|
||||||
|
return &oaAttendPunchResult{
|
||||||
|
View: oaAttendView(exist),
|
||||||
|
Warning: "今日已打卡,请勿重复操作",
|
||||||
|
Duplicated: true,
|
||||||
|
Message: "今日已打卡",
|
||||||
|
}, 200, ""
|
||||||
|
}
|
||||||
|
return nil, 500, "打卡失败,请稍后重试"
|
||||||
|
}
|
||||||
|
} else if _, err := models.Orm.Update(row, fields...); err != nil {
|
||||||
|
return nil, 500, "打卡失败,请稍后重试"
|
||||||
|
}
|
||||||
|
|
||||||
|
message := "签到成功"
|
||||||
|
if clockType == "out" {
|
||||||
|
message = "签退成功"
|
||||||
|
}
|
||||||
|
return &oaAttendPunchResult{
|
||||||
|
View: oaAttendView(row),
|
||||||
|
Warning: warning,
|
||||||
|
Message: message,
|
||||||
|
}, 200, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 统计
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// oaAttendStats 区间考勤统计:应出勤按排班明细统计,实出勤按已签到(含无排班打卡)。
|
||||||
|
// orgIDs 非空时按组织过滤;employeeID>0 时只统计该员工。
|
||||||
|
func oaAttendStats(tid uint64, startDate, endDate string, orgIDs []uint64, employeeID uint) map[string]interface{} {
|
||||||
|
entryQS := models.Orm.QueryTable(new(models.OaScheduleEntry)).
|
||||||
|
Filter("tid", tid).
|
||||||
|
Filter("is_deleted", 0).
|
||||||
|
Filter("schedule_date__gte", startDate).
|
||||||
|
Filter("schedule_date__lte", endDate)
|
||||||
|
if len(orgIDs) > 0 {
|
||||||
|
entryQS = entryQS.Filter("org_id__in", orgIDs)
|
||||||
|
}
|
||||||
|
if employeeID > 0 {
|
||||||
|
entryQS = entryQS.Filter("employee_id", employeeID)
|
||||||
|
}
|
||||||
|
scheduled, _ := entryQS.Count()
|
||||||
|
|
||||||
|
recordQS := oaAttendQuery(tid).
|
||||||
|
Filter("attend_date__gte", startDate).
|
||||||
|
Filter("attend_date__lte", endDate)
|
||||||
|
if len(orgIDs) > 0 {
|
||||||
|
recordQS = recordQS.Filter("org_id__in", orgIDs)
|
||||||
|
}
|
||||||
|
if employeeID > 0 {
|
||||||
|
recordQS = recordQS.Filter("employee_id", employeeID)
|
||||||
|
}
|
||||||
|
total, _ := recordQS.Count()
|
||||||
|
|
||||||
|
baseCond := func() *orm.Condition {
|
||||||
|
cond := orm.NewCondition().
|
||||||
|
And("tid", tid).
|
||||||
|
And("attend_date__gte", startDate).
|
||||||
|
And("attend_date__lte", endDate)
|
||||||
|
if len(orgIDs) > 0 {
|
||||||
|
cond = cond.And("org_id__in", orgIDs)
|
||||||
|
}
|
||||||
|
if employeeID > 0 {
|
||||||
|
cond = cond.And("employee_id", employeeID)
|
||||||
|
}
|
||||||
|
return cond
|
||||||
|
}
|
||||||
|
countWith := func(cond *orm.Condition) int64 {
|
||||||
|
n, _ := models.Orm.QueryTable(new(models.OaAttendance)).SetCond(cond).Count()
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
clocked := countWith(baseCond().And("clock_in_time__isnull", false))
|
||||||
|
late := countWith(baseCond().And("clock_in_status", models.OaAttendClockLate))
|
||||||
|
early := countWith(baseCond().And("clock_out_status", models.OaAttendClockLate))
|
||||||
|
noSchedule := countWith(baseCond().And("match_status", models.OaAttendMatchMissing))
|
||||||
|
matchedClockIn := countWith(baseCond().
|
||||||
|
And("clock_in_time__isnull", false).
|
||||||
|
And("match_status", models.OaAttendMatchOK))
|
||||||
|
missing := scheduled - matchedClockIn
|
||||||
|
if missing < 0 {
|
||||||
|
missing = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
rate := 0
|
||||||
|
if scheduled > 0 {
|
||||||
|
rate = int(matchedClockIn * 100 / scheduled)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"scheduled": scheduled,
|
||||||
|
"records": total,
|
||||||
|
"clocked": clocked,
|
||||||
|
"late": late,
|
||||||
|
"early": early,
|
||||||
|
"noschedule": noSchedule,
|
||||||
|
"missing": missing,
|
||||||
|
"attendance_rate": rate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 后台:考勤记录
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// RecordList 后台考勤记录列表。
|
||||||
|
// GET /backend/oa/attendance/records?page=&pageSize=&start_date=&end_date=
|
||||||
|
//
|
||||||
|
// &keyword=&org_id=&include_child=1&employee_id=&status=&match_status=
|
||||||
|
func (c *BackendOaAttendanceController) RecordList() {
|
||||||
|
tid, _, ok := c.atOperator()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page, _ := c.GetInt("page", 1)
|
||||||
|
pageSize, _ := c.GetInt("pageSize", 20)
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 || pageSize > oaAttendMaxPageSize {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
startDate := strings.TrimSpace(c.GetString("start_date"))
|
||||||
|
endDate := strings.TrimSpace(c.GetString("end_date"))
|
||||||
|
if startDate == "" || endDate == "" {
|
||||||
|
now := time.Now()
|
||||||
|
start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||||
|
startDate = start.Format("2006-01-02")
|
||||||
|
endDate = now.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
dates, msg := scDateSpan(startDate, endDate)
|
||||||
|
if msg != "" {
|
||||||
|
c.atFail(400, 400, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(dates) > oaAttendMaxRangeDays {
|
||||||
|
c.atFail(400, 400, fmt.Sprintf("单次最多查询 %d 天", oaAttendMaxRangeDays))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orgID := uint64(0)
|
||||||
|
if raw := strings.TrimSpace(c.GetString("org_id")); raw != "" {
|
||||||
|
if parsed, err := strconvParseUint(raw); err == nil {
|
||||||
|
orgID = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
includeChild := c.GetString("include_child") != "0"
|
||||||
|
orgIDs := oaAttendOrgIDs(tid, orgID, includeChild)
|
||||||
|
|
||||||
|
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||||
|
employeeID := uint(0)
|
||||||
|
if raw := strings.TrimSpace(c.GetString("employee_id")); raw != "" {
|
||||||
|
if parsed, err := strconv.ParseUint(raw, 10, 64); err == nil {
|
||||||
|
employeeID = uint(parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// uid:未绑定员工档案的打卡按登录身份归属,汇总行展开明细时用
|
||||||
|
uid := uint64(0)
|
||||||
|
if raw := strings.TrimSpace(c.GetString("uid")); raw != "" {
|
||||||
|
if parsed, err := strconv.ParseUint(raw, 10, 64); err == nil {
|
||||||
|
uid = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status := strings.TrimSpace(c.GetString("status"))
|
||||||
|
matchStatus := strings.TrimSpace(c.GetString("match_status"))
|
||||||
|
|
||||||
|
cond := orm.NewCondition().
|
||||||
|
And("tid", tid).
|
||||||
|
And("attend_date__gte", startDate).
|
||||||
|
And("attend_date__lte", endDate)
|
||||||
|
if len(orgIDs) > 0 {
|
||||||
|
cond = cond.And("org_id__in", orgIDs)
|
||||||
|
}
|
||||||
|
if employeeID > 0 {
|
||||||
|
cond = cond.And("employee_id", employeeID)
|
||||||
|
}
|
||||||
|
if uid > 0 {
|
||||||
|
cond = cond.And("uid", uid)
|
||||||
|
}
|
||||||
|
if keyword != "" {
|
||||||
|
cond = cond.AndCond(orm.NewCondition().
|
||||||
|
Or("employee_name__icontains", keyword).
|
||||||
|
Or("employee_account__icontains", keyword))
|
||||||
|
}
|
||||||
|
if matchStatus == "1" || matchStatus == "2" {
|
||||||
|
value, _ := strconv.Atoi(matchStatus)
|
||||||
|
cond = cond.And("match_status", int8(value))
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case "normal":
|
||||||
|
cond = cond.And("clock_in_status", models.OaAttendClockNormal)
|
||||||
|
case "late":
|
||||||
|
cond = cond.And("clock_in_status", models.OaAttendClockLate)
|
||||||
|
case "early":
|
||||||
|
cond = cond.And("clock_out_status", models.OaAttendClockLate)
|
||||||
|
case "noschedule":
|
||||||
|
cond = cond.And("match_status", models.OaAttendMatchMissing)
|
||||||
|
case "missing":
|
||||||
|
cond = cond.And("clock_in_time__isnull", true)
|
||||||
|
case "offtime":
|
||||||
|
cond = cond.AndCond(orm.NewCondition().
|
||||||
|
Or("clock_in_status", models.OaAttendClockOffTime).
|
||||||
|
Or("clock_out_status", models.OaAttendClockOffTime))
|
||||||
|
}
|
||||||
|
|
||||||
|
qs := models.Orm.QueryTable(new(models.OaAttendance)).SetCond(cond)
|
||||||
|
total, _ := qs.Count()
|
||||||
|
var rows []models.OaAttendance
|
||||||
|
_, err := qs.OrderBy("-attend_date", "employee_name", "id").
|
||||||
|
Limit(pageSize).Offset((page - 1) * pageSize).All(&rows)
|
||||||
|
if err != nil && err != orm.ErrNoRows {
|
||||||
|
c.atFail(500, 500, "查询考勤记录失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]oaAttendanceView, 0, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
list = append(list, oaAttendView(&rows[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.atOK(map[string]interface{}{
|
||||||
|
"list": list,
|
||||||
|
"total": total,
|
||||||
|
"start_date": startDate,
|
||||||
|
"end_date": endDate,
|
||||||
|
"stats": oaAttendStats(tid, startDate, endDate, orgIDs, employeeID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MissingList 缺卡名单:指定日期有排班但未签到的人。
|
||||||
|
// GET /backend/oa/attendance/missing?date=&org_id=&include_child=1&keyword=
|
||||||
|
func (c *BackendOaAttendanceController) MissingList() {
|
||||||
|
tid, _, ok := c.atOperator()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
date := strings.TrimSpace(c.GetString("date"))
|
||||||
|
if !isValidOaDate(date) {
|
||||||
|
date = time.Now().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
orgID := uint64(0)
|
||||||
|
if raw := strings.TrimSpace(c.GetString("org_id")); raw != "" {
|
||||||
|
if parsed, err := strconvParseUint(raw); err == nil {
|
||||||
|
orgID = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
orgIDs := oaAttendOrgIDs(tid, orgID, c.GetString("include_child") != "0")
|
||||||
|
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||||
|
|
||||||
|
entryQS := models.Orm.QueryTable(new(models.OaScheduleEntry)).
|
||||||
|
Filter("tid", tid).
|
||||||
|
Filter("is_deleted", 0).
|
||||||
|
Filter("schedule_date", date)
|
||||||
|
if len(orgIDs) > 0 {
|
||||||
|
entryQS = entryQS.Filter("org_id__in", orgIDs)
|
||||||
|
}
|
||||||
|
if keyword != "" {
|
||||||
|
entryQS = entryQS.Filter("employee_name__icontains", keyword)
|
||||||
|
}
|
||||||
|
var entries []models.OaScheduleEntry
|
||||||
|
_, err := entryQS.OrderBy("org_id", "employee_name").Limit(1000).All(&entries)
|
||||||
|
if err != nil && err != orm.ErrNoRows {
|
||||||
|
c.atFail(500, 500, "查询排班失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clocked := map[uint]bool{}
|
||||||
|
var records []models.OaAttendance
|
||||||
|
recordQS := oaAttendQuery(tid).Filter("attend_date", date).Filter("clock_in_time__isnull", false)
|
||||||
|
if len(orgIDs) > 0 {
|
||||||
|
recordQS = recordQS.Filter("org_id__in", orgIDs)
|
||||||
|
}
|
||||||
|
_, _ = recordQS.All(&records)
|
||||||
|
for i := range records {
|
||||||
|
clocked[records[i].EmployeeID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]map[string]interface{}, 0, len(entries))
|
||||||
|
for i := range entries {
|
||||||
|
entry := entries[i]
|
||||||
|
if clocked[entry.EmployeeID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
list = append(list, map[string]interface{}{
|
||||||
|
"employee_id": entry.EmployeeID,
|
||||||
|
"employee_name": entry.EmployeeName,
|
||||||
|
"employee_account": entry.EmployeeAccount,
|
||||||
|
"org_id": entry.OrgID,
|
||||||
|
"org_name": entry.OrgName,
|
||||||
|
"position": entry.Position,
|
||||||
|
"date": normalizeOaDate(entry.ScheduleDate),
|
||||||
|
"shift_id": entry.ShiftID,
|
||||||
|
"shift_name": entry.ShiftName,
|
||||||
|
"color": entry.Color,
|
||||||
|
"work_start": entry.WorkStart,
|
||||||
|
"work_end": entry.WorkEnd,
|
||||||
|
"shift_text": oaAttendShiftText(entry.WorkStart, entry.WorkEnd),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.atOK(map[string]interface{}{
|
||||||
|
"date": date,
|
||||||
|
"list": list,
|
||||||
|
"total": len(list),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordRemark 管理员备注 / 异常说明。
|
||||||
|
// POST /backend/oa/attendance/remark/:id body: {"remark": "..."}
|
||||||
|
func (c *BackendOaAttendanceController) RecordRemark() {
|
||||||
|
tid, _, ok := c.atOperator()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseUint(strings.TrimSpace(c.Ctx.Input.Param(":id")), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.atFail(400, 400, "无效的记录ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
if len(c.Ctx.Input.RequestBody) > 0 {
|
||||||
|
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &payload); err != nil {
|
||||||
|
c.atFail(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remark := strings.TrimSpace(payload.Remark)
|
||||||
|
if len([]rune(remark)) > 255 {
|
||||||
|
c.atFail(400, 400, "备注不能超过 255 个字符")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var row models.OaAttendance
|
||||||
|
if err := oaAttendQuery(tid).Filter("id", id).One(&row); err != nil {
|
||||||
|
c.atFail(404, 404, "考勤记录不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row.Remark = remark
|
||||||
|
if _, err := models.Orm.Update(&row, "remark"); err != nil {
|
||||||
|
c.atFail(500, 500, "保存备注失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.atOK(oaAttendView(&row))
|
||||||
|
}
|
||||||
@@ -0,0 +1,719 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
|
||||||
|
"github.com/beego/beego/v2/client/orm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 考勤汇总:按月 / 季 / 年统计,覆盖全部在职员工(含无打卡记录的人)。
|
||||||
|
//
|
||||||
|
// 口径:
|
||||||
|
// - 应出勤 = 区间内排班明细条数(一人一天一条);
|
||||||
|
// - 已打卡 = 区间内有签到记录的考勤条数;
|
||||||
|
// - 缺卡 = 应出勤 - 匹配排班且已签到的条数(负值归零);
|
||||||
|
// - 出勤率 = 匹配排班且已签到 / 应出勤。
|
||||||
|
//
|
||||||
|
// 周期卡片的取数:
|
||||||
|
// - 以锚点日期所在年份为准,一次性查出「按月的排班人数、按月的打卡情况」,
|
||||||
|
// 再在内存里累加成季度卡与年度卡,避免 17 次独立查询。
|
||||||
|
|
||||||
|
// oaAttendCard 一个周期的考勤卡(月 / 季 / 年)。
|
||||||
|
type oaAttendCard struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Period string `json:"period"`
|
||||||
|
Anchor string `json:"anchor"`
|
||||||
|
StartDate string `json:"start_date"`
|
||||||
|
EndDate string `json:"end_date"`
|
||||||
|
Future bool `json:"future"`
|
||||||
|
Current bool `json:"current"`
|
||||||
|
Scheduled int64 `json:"scheduled"`
|
||||||
|
Clocked int64 `json:"clocked"`
|
||||||
|
Matched int64 `json:"matched"`
|
||||||
|
Late int64 `json:"late"`
|
||||||
|
Early int64 `json:"early"`
|
||||||
|
Missing int64 `json:"missing"`
|
||||||
|
NoSchedule int64 `json:"noschedule"`
|
||||||
|
AttendanceRate int `json:"attendance_rate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendSummaryRow 汇总表的一行:一名员工在一个周期内的考勤卡。
|
||||||
|
type oaAttendSummaryRow struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Uid uint64 `json:"uid"`
|
||||||
|
EmployeeID uint `json:"employee_id"`
|
||||||
|
EmployeeName string `json:"employee_name"`
|
||||||
|
EmployeeAccount string `json:"employee_account"`
|
||||||
|
OrgID uint64 `json:"org_id"`
|
||||||
|
OrgName string `json:"org_name"`
|
||||||
|
Position string `json:"position"`
|
||||||
|
Bound bool `json:"bound"`
|
||||||
|
ScheduledDays int64 `json:"scheduled_days"`
|
||||||
|
ClockedDays int64 `json:"clocked_days"`
|
||||||
|
LateCount int64 `json:"late_count"`
|
||||||
|
EarlyCount int64 `json:"early_count"`
|
||||||
|
MissingDays int64 `json:"missing_days"`
|
||||||
|
NoScheduleCount int64 `json:"noschedule_count"`
|
||||||
|
OffTimeCount int64 `json:"offtime_count"`
|
||||||
|
AttendanceRate int `json:"attendance_rate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendCountByMonth 按月排班条数。
|
||||||
|
type oaAttendCountByMonth struct {
|
||||||
|
Month int64 `orm:"column(m)"`
|
||||||
|
Cnt int64 `orm:"column(cnt)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendRecordByMonth 按月打卡统计。
|
||||||
|
type oaAttendRecordByMonth struct {
|
||||||
|
Month int64 `orm:"column(m)"`
|
||||||
|
Clocked int64 `orm:"column(clocked)"`
|
||||||
|
Matched int64 `orm:"column(matched)"`
|
||||||
|
Late int64 `orm:"column(late)"`
|
||||||
|
Early int64 `orm:"column(early)"`
|
||||||
|
NoShift int64 `orm:"column(noschedule)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendEmpCount 按员工统计的排班条数。
|
||||||
|
type oaAttendEmpCount struct {
|
||||||
|
EmployeeID uint `orm:"column(employee_id)"`
|
||||||
|
Cnt int64 `orm:"column(cnt)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendRowAgg 按员工聚合的打卡统计。
|
||||||
|
type oaAttendRowAgg struct {
|
||||||
|
EmployeeID uint `orm:"column(employee_id)"`
|
||||||
|
Uid uint64 `orm:"column(uid)"`
|
||||||
|
EmployeeName string `orm:"column(employee_name)"`
|
||||||
|
EmployeeAccount string `orm:"column(employee_account)"`
|
||||||
|
OrgID uint64 `orm:"column(org_id)"`
|
||||||
|
OrgName string `orm:"column(org_name)"`
|
||||||
|
Clocked int64 `orm:"column(clocked)"`
|
||||||
|
Matched int64 `orm:"column(matched)"`
|
||||||
|
Late int64 `orm:"column(late)"`
|
||||||
|
Early int64 `orm:"column(early)"`
|
||||||
|
NoShift int64 `orm:"column(noschedule)"`
|
||||||
|
OffTime int64 `orm:"column(offtime)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendAgg 汇总用的累加器。
|
||||||
|
type oaAttendAgg struct {
|
||||||
|
scheduled int64
|
||||||
|
clocked int64
|
||||||
|
matched int64
|
||||||
|
late int64
|
||||||
|
early int64
|
||||||
|
noShift int64
|
||||||
|
offTime int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendPeriodRange 把锚点日期换算成统计区间;
|
||||||
|
// period = month / quarter / year,返回的 end 未做「不超过今天」裁剪。
|
||||||
|
func oaAttendPeriodRange(period, anchor string) (string, string, string, bool) {
|
||||||
|
parsed, ok := scParseDate(anchor)
|
||||||
|
if !ok {
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
period = strings.ToLower(strings.TrimSpace(period))
|
||||||
|
var start, end time.Time
|
||||||
|
var label string
|
||||||
|
switch period {
|
||||||
|
case "year":
|
||||||
|
start = time.Date(parsed.Year(), 1, 1, 0, 0, 0, 0, parsed.Location())
|
||||||
|
end = time.Date(parsed.Year(), 12, 31, 0, 0, 0, 0, parsed.Location())
|
||||||
|
label = fmt.Sprintf("%d 年", parsed.Year())
|
||||||
|
case "quarter":
|
||||||
|
quarter := (int(parsed.Month()) - 1) / 3
|
||||||
|
start = time.Date(parsed.Year(), time.Month(quarter*3+1), 1, 0, 0, 0, 0, parsed.Location())
|
||||||
|
end = start.AddDate(0, 3, -1)
|
||||||
|
label = fmt.Sprintf("%d 年第 %d 季度", parsed.Year(), quarter+1)
|
||||||
|
default:
|
||||||
|
period = "month"
|
||||||
|
start = time.Date(parsed.Year(), parsed.Month(), 1, 0, 0, 0, 0, parsed.Location())
|
||||||
|
end = start.AddDate(0, 1, -1)
|
||||||
|
label = fmt.Sprintf("%d 年 %d 月", parsed.Year(), int(parsed.Month()))
|
||||||
|
}
|
||||||
|
return start.Format("2006-01-02"), end.Format("2006-01-02"), label, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendCapEnd 统计区间不跨过今天,避免未来排班被算成缺卡。
|
||||||
|
func oaAttendCapEnd(end string) string {
|
||||||
|
today := time.Now().Format("2006-01-02")
|
||||||
|
if end > today {
|
||||||
|
return today
|
||||||
|
}
|
||||||
|
return end
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendClampRange 把区间末尾裁到今天;整段都在未来时保留原区间(展示用,查询自然为空)。
|
||||||
|
func oaAttendClampRange(start, end string) string {
|
||||||
|
capped := oaAttendCapEnd(end)
|
||||||
|
if capped < start {
|
||||||
|
return end
|
||||||
|
}
|
||||||
|
return capped
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendFillCard 依据累计值补齐缺卡与出勤率。
|
||||||
|
func oaAttendFillCard(card *oaAttendCard) {
|
||||||
|
card.Missing = card.Scheduled - card.Matched
|
||||||
|
if card.Missing < 0 {
|
||||||
|
card.Missing = 0
|
||||||
|
}
|
||||||
|
if card.Scheduled > 0 {
|
||||||
|
card.AttendanceRate = int(card.Matched * 100 / card.Scheduled)
|
||||||
|
} else {
|
||||||
|
card.AttendanceRate = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendOrgSQL 生成组织过滤的 SQL 片段与参数。
|
||||||
|
func oaAttendOrgSQL(orgIDs []uint64) (string, []interface{}) {
|
||||||
|
if len(orgIDs) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
placeholders := make([]string, 0, len(orgIDs))
|
||||||
|
args := make([]interface{}, 0, len(orgIDs))
|
||||||
|
for _, id := range orgIDs {
|
||||||
|
placeholders = append(placeholders, "?")
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
return " AND org_id IN (" + strings.Join(placeholders, ",") + ")", args
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendMonthCards 生成锚点年份 1-12 月的考勤卡。
|
||||||
|
func oaAttendMonthCards(tid uint64, year int, orgIDs []uint64) []oaAttendCard {
|
||||||
|
yearStart := fmt.Sprintf("%d-01-01", year)
|
||||||
|
yearEnd := oaAttendCapEnd(fmt.Sprintf("%d-12-31", year))
|
||||||
|
|
||||||
|
entryFilter, entryArgs := oaAttendOrgSQL(orgIDs)
|
||||||
|
recordFilter, recordArgs := oaAttendOrgSQL(orgIDs)
|
||||||
|
|
||||||
|
entryCount := map[int64]int64{}
|
||||||
|
recordAgg := map[int64]oaAttendRecordByMonth{}
|
||||||
|
|
||||||
|
// 整年都在未来:不查库,全部为 0
|
||||||
|
if yearEnd >= yearStart {
|
||||||
|
var entryRows []oaAttendCountByMonth
|
||||||
|
entrySQL := "SELECT MONTH(schedule_date) AS m, COUNT(*) AS cnt FROM yz_backend_oa_schedule_entry" +
|
||||||
|
" WHERE tid = ? AND is_deleted = 0 AND schedule_date >= ? AND schedule_date <= ?" + entryFilter +
|
||||||
|
" GROUP BY MONTH(schedule_date)"
|
||||||
|
entryQueryArgs := append([]interface{}{tid, yearStart, yearEnd}, entryArgs...)
|
||||||
|
if _, err := models.Orm.Raw(entrySQL, entryQueryArgs...).QueryRows(&entryRows); err == nil {
|
||||||
|
for _, row := range entryRows {
|
||||||
|
entryCount[row.Month] = row.Cnt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var recordRows []oaAttendRecordByMonth
|
||||||
|
recordSQL := "SELECT MONTH(attend_date) AS m," +
|
||||||
|
" CAST(SUM(clock_in_time IS NOT NULL) AS SIGNED) AS clocked," +
|
||||||
|
" CAST(SUM(clock_in_time IS NOT NULL AND match_status = 1) AS SIGNED) AS matched," +
|
||||||
|
" CAST(SUM(clock_in_status = 2) AS SIGNED) AS late," +
|
||||||
|
" CAST(SUM(clock_out_status = 2) AS SIGNED) AS early," +
|
||||||
|
" CAST(SUM(match_status = 2) AS SIGNED) AS noschedule" +
|
||||||
|
" FROM yz_backend_oa_attendance" +
|
||||||
|
" WHERE tid = ? AND attend_date >= ? AND attend_date <= ?" + recordFilter +
|
||||||
|
" GROUP BY MONTH(attend_date)"
|
||||||
|
recordQueryArgs := append([]interface{}{tid, yearStart, yearEnd}, recordArgs...)
|
||||||
|
if _, err := models.Orm.Raw(recordSQL, recordQueryArgs...).QueryRows(&recordRows); err == nil {
|
||||||
|
for _, row := range recordRows {
|
||||||
|
recordAgg[row.Month] = row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
today := now.Format("2006-01-02")
|
||||||
|
cards := make([]oaAttendCard, 0, 12)
|
||||||
|
for month := 1; month <= 12; month++ {
|
||||||
|
start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, now.Location())
|
||||||
|
end := start.AddDate(0, 1, -1)
|
||||||
|
startDate := start.Format("2006-01-02")
|
||||||
|
endDate := end.Format("2006-01-02")
|
||||||
|
record := recordAgg[int64(month)]
|
||||||
|
card := oaAttendCard{
|
||||||
|
Key: fmt.Sprintf("%d-%02d", year, month),
|
||||||
|
Label: fmt.Sprintf("%d 月", month),
|
||||||
|
Period: "month",
|
||||||
|
Anchor: startDate,
|
||||||
|
StartDate: startDate,
|
||||||
|
EndDate: endDate,
|
||||||
|
Future: startDate > today,
|
||||||
|
Current: today >= startDate && today <= endDate,
|
||||||
|
Scheduled: entryCount[int64(month)],
|
||||||
|
Clocked: record.Clocked,
|
||||||
|
Matched: record.Matched,
|
||||||
|
Late: record.Late,
|
||||||
|
Early: record.Early,
|
||||||
|
NoSchedule: record.NoShift,
|
||||||
|
}
|
||||||
|
oaAttendFillCard(&card)
|
||||||
|
cards = append(cards, card)
|
||||||
|
}
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendAggregateCards 用月度卡累加出 4 个季度卡与年度卡。
|
||||||
|
func oaAttendAggregateCards(months []oaAttendCard, year int) ([]oaAttendCard, oaAttendCard) {
|
||||||
|
quarters := make([]oaAttendCard, 0, 4)
|
||||||
|
yearCard := oaAttendCard{
|
||||||
|
Key: fmt.Sprintf("%d", year),
|
||||||
|
Label: fmt.Sprintf("%d 年", year),
|
||||||
|
Period: "year",
|
||||||
|
Anchor: fmt.Sprintf("%d-01-01", year),
|
||||||
|
StartDate: fmt.Sprintf("%d-01-01", year),
|
||||||
|
EndDate: fmt.Sprintf("%d-12-31", year),
|
||||||
|
}
|
||||||
|
today := time.Now().Format("2006-01-02")
|
||||||
|
yearCard.Current = year == time.Now().Year()
|
||||||
|
yearCard.Future = yearCard.StartDate > today
|
||||||
|
|
||||||
|
for index := 0; index < 4; index++ {
|
||||||
|
start := index * 3
|
||||||
|
if start+2 >= len(months) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
first := months[start]
|
||||||
|
last := months[start+2]
|
||||||
|
quarter := oaAttendCard{
|
||||||
|
Key: fmt.Sprintf("%d-Q%d", year, index+1),
|
||||||
|
Label: fmt.Sprintf("%d 季度", index+1),
|
||||||
|
Period: "quarter",
|
||||||
|
Anchor: first.StartDate,
|
||||||
|
StartDate: first.StartDate,
|
||||||
|
EndDate: last.EndDate,
|
||||||
|
Future: first.StartDate > today,
|
||||||
|
Current: today >= first.StartDate && today <= last.EndDate,
|
||||||
|
}
|
||||||
|
for i := start; i < start+3; i++ {
|
||||||
|
quarter.Scheduled += months[i].Scheduled
|
||||||
|
quarter.Clocked += months[i].Clocked
|
||||||
|
quarter.Matched += months[i].Matched
|
||||||
|
quarter.Late += months[i].Late
|
||||||
|
quarter.Early += months[i].Early
|
||||||
|
quarter.NoSchedule += months[i].NoSchedule
|
||||||
|
}
|
||||||
|
oaAttendFillCard(&quarter)
|
||||||
|
quarters = append(quarters, quarter)
|
||||||
|
|
||||||
|
yearCard.Scheduled += quarter.Scheduled
|
||||||
|
yearCard.Clocked += quarter.Clocked
|
||||||
|
yearCard.Matched += quarter.Matched
|
||||||
|
yearCard.Late += quarter.Late
|
||||||
|
yearCard.Early += quarter.Early
|
||||||
|
yearCard.NoSchedule += quarter.NoSchedule
|
||||||
|
}
|
||||||
|
oaAttendFillCard(&yearCard)
|
||||||
|
return quarters, yearCard
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendDayStat 单日全员考勤数据,前端用日历格子展示。
|
||||||
|
type oaAttendDayStat struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Weekday int `json:"weekday"`
|
||||||
|
Scheduled int64 `json:"scheduled"`
|
||||||
|
Clocked int64 `json:"clocked"`
|
||||||
|
Matched int64 `json:"matched"`
|
||||||
|
Late int64 `json:"late"`
|
||||||
|
Early int64 `json:"early"`
|
||||||
|
Missing int64 `json:"missing"`
|
||||||
|
NoSchedule int64 `json:"noschedule"`
|
||||||
|
AttendanceRate int `json:"attendance_rate"`
|
||||||
|
Future bool `json:"future"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendDayCount 按日排班条数。
|
||||||
|
type oaAttendDayCount struct {
|
||||||
|
Day string `orm:"column(d)"`
|
||||||
|
Cnt int64 `orm:"column(cnt)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendDayRecord 按日打卡统计。
|
||||||
|
type oaAttendDayRecord struct {
|
||||||
|
Day string `orm:"column(d)"`
|
||||||
|
Clocked int64 `orm:"column(clocked)"`
|
||||||
|
Matched int64 `orm:"column(matched)"`
|
||||||
|
Late int64 `orm:"column(late)"`
|
||||||
|
Early int64 `orm:"column(early)"`
|
||||||
|
NoShift int64 `orm:"column(noschedule)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendDailyStats 区间内逐日的全员考勤数据(月/季/年日历共用)。
|
||||||
|
func oaAttendDailyStats(tid uint64, startDate, endDate string, orgIDs []uint64) []oaAttendDayStat {
|
||||||
|
result := make([]oaAttendDayStat, 0, 32)
|
||||||
|
|
||||||
|
entryFilter, entryArgs := oaAttendOrgSQL(orgIDs)
|
||||||
|
dayCount := map[string]int64{}
|
||||||
|
var entryRows []oaAttendDayCount
|
||||||
|
entrySQL := "SELECT DATE_FORMAT(schedule_date, '%Y-%m-%d') AS d, COUNT(*) AS cnt" +
|
||||||
|
" FROM yz_backend_oa_schedule_entry" +
|
||||||
|
" WHERE tid = ? AND is_deleted = 0 AND schedule_date >= ? AND schedule_date <= ?" + entryFilter +
|
||||||
|
" GROUP BY schedule_date"
|
||||||
|
entryQueryArgs := append([]interface{}{tid, startDate, endDate}, entryArgs...)
|
||||||
|
if _, err := models.Orm.Raw(entrySQL, entryQueryArgs...).QueryRows(&entryRows); err == nil {
|
||||||
|
for _, row := range entryRows {
|
||||||
|
dayCount[row.Day] = row.Cnt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recordFilter, recordArgs := oaAttendOrgSQL(orgIDs)
|
||||||
|
recordAgg := map[string]oaAttendDayRecord{}
|
||||||
|
var recordRows []oaAttendDayRecord
|
||||||
|
recordSQL := "SELECT DATE_FORMAT(attend_date, '%Y-%m-%d') AS d," +
|
||||||
|
" CAST(SUM(clock_in_time IS NOT NULL) AS SIGNED) AS clocked," +
|
||||||
|
" CAST(SUM(clock_in_time IS NOT NULL AND match_status = 1) AS SIGNED) AS matched," +
|
||||||
|
" CAST(SUM(clock_in_status = 2) AS SIGNED) AS late," +
|
||||||
|
" CAST(SUM(clock_out_status = 2) AS SIGNED) AS early," +
|
||||||
|
" CAST(SUM(match_status = 2) AS SIGNED) AS noschedule" +
|
||||||
|
" FROM yz_backend_oa_attendance" +
|
||||||
|
" WHERE tid = ? AND attend_date >= ? AND attend_date <= ?" + recordFilter +
|
||||||
|
" GROUP BY attend_date"
|
||||||
|
recordQueryArgs := append([]interface{}{tid, startDate, endDate}, recordArgs...)
|
||||||
|
if _, err := models.Orm.Raw(recordSQL, recordQueryArgs...).QueryRows(&recordRows); err == nil {
|
||||||
|
for _, row := range recordRows {
|
||||||
|
recordAgg[row.Day] = row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
today := time.Now().Format("2006-01-02")
|
||||||
|
for date := startDate; date != "" && date <= endDate; date = nextOaDate(date, 1) {
|
||||||
|
record := recordAgg[date]
|
||||||
|
item := oaAttendDayStat{
|
||||||
|
Date: date,
|
||||||
|
Weekday: oaAttendWeekday(date),
|
||||||
|
Scheduled: dayCount[date],
|
||||||
|
Clocked: record.Clocked,
|
||||||
|
Matched: record.Matched,
|
||||||
|
Late: record.Late,
|
||||||
|
Early: record.Early,
|
||||||
|
NoSchedule: record.NoShift,
|
||||||
|
Future: date > today,
|
||||||
|
}
|
||||||
|
item.Missing = item.Scheduled - item.Matched
|
||||||
|
if item.Missing < 0 {
|
||||||
|
item.Missing = 0
|
||||||
|
}
|
||||||
|
if item.Scheduled > 0 {
|
||||||
|
item.AttendanceRate = int(item.Matched * 100 / item.Scheduled)
|
||||||
|
}
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendOrgNames 租户组织 ID → 名称。
|
||||||
|
func oaAttendOrgNames(tid uint64) map[uint64]string {
|
||||||
|
var rows []models.BackendOrganization
|
||||||
|
_, err := models.Orm.QueryTable(new(models.BackendOrganization)).
|
||||||
|
Filter("tid", tid).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
All(&rows)
|
||||||
|
names := map[uint64]string{}
|
||||||
|
if err != nil && err != orm.ErrNoRows {
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
for i := range rows {
|
||||||
|
names[rows[i].ID] = rows[i].OrgName
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendRoster 统计范围内的在职员工(按组织过滤,关键字在内存里过滤)。
|
||||||
|
func oaAttendRoster(tid uint64, orgIDs []uint64) ([]models.BackendEmployee, bool, error) {
|
||||||
|
qs := employeeBaseQuery(tid)
|
||||||
|
if len(orgIDs) > 0 {
|
||||||
|
values := make([]string, 0, len(orgIDs))
|
||||||
|
for _, id := range orgIDs {
|
||||||
|
values = append(values, strconv.FormatUint(id, 10))
|
||||||
|
}
|
||||||
|
qs = qs.Filter("department__in", values)
|
||||||
|
}
|
||||||
|
var rows []models.BackendEmployee
|
||||||
|
_, err := qs.OrderBy("department", "id").Limit(2000).All(&rows)
|
||||||
|
if err != nil && err != orm.ErrNoRows {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
return rows, len(rows) >= 2000, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// oaAttendEmpAggs 区间内的员工聚合:scheduled 来自排班表,其余来自考勤表。
|
||||||
|
func oaAttendEmpAggs(tid uint64, startDate, endDate string, orgIDs []uint64) (map[uint]*oaAttendAgg, map[uint64]*oaAttendAgg, []oaAttendRowAgg) {
|
||||||
|
aggByEmp := map[uint]*oaAttendAgg{}
|
||||||
|
aggByUid := map[uint64]*oaAttendAgg{}
|
||||||
|
|
||||||
|
entryFilter, entryArgs := oaAttendOrgSQL(orgIDs)
|
||||||
|
var entryRows []oaAttendEmpCount
|
||||||
|
entrySQL := "SELECT employee_id, COUNT(*) AS cnt FROM yz_backend_oa_schedule_entry" +
|
||||||
|
" WHERE tid = ? AND is_deleted = 0 AND schedule_date >= ? AND schedule_date <= ?" + entryFilter +
|
||||||
|
" GROUP BY employee_id"
|
||||||
|
entryQueryArgs := append([]interface{}{tid, startDate, endDate}, entryArgs...)
|
||||||
|
if _, err := models.Orm.Raw(entrySQL, entryQueryArgs...).QueryRows(&entryRows); err == nil {
|
||||||
|
for _, row := range entryRows {
|
||||||
|
if row.EmployeeID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item, ok := aggByEmp[row.EmployeeID]
|
||||||
|
if !ok {
|
||||||
|
item = &oaAttendAgg{}
|
||||||
|
aggByEmp[row.EmployeeID] = item
|
||||||
|
}
|
||||||
|
item.scheduled += row.Cnt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recordFilter, recordArgs := oaAttendOrgSQL(orgIDs)
|
||||||
|
var recordRows []oaAttendRowAgg
|
||||||
|
recordSQL := "SELECT employee_id, uid," +
|
||||||
|
" MAX(employee_name) AS employee_name, MAX(employee_account) AS employee_account," +
|
||||||
|
" MAX(org_id) AS org_id, MAX(org_name) AS org_name," +
|
||||||
|
" CAST(SUM(clock_in_time IS NOT NULL) AS SIGNED) AS clocked," +
|
||||||
|
" CAST(SUM(clock_in_time IS NOT NULL AND match_status = 1) AS SIGNED) AS matched," +
|
||||||
|
" CAST(SUM(clock_in_status = 2) AS SIGNED) AS late," +
|
||||||
|
" CAST(SUM(clock_out_status = 2) AS SIGNED) AS early," +
|
||||||
|
" CAST(SUM(match_status = 2) AS SIGNED) AS noschedule," +
|
||||||
|
" CAST(SUM(clock_in_status = 4 OR clock_out_status = 4) AS SIGNED) AS offtime" +
|
||||||
|
" FROM yz_backend_oa_attendance" +
|
||||||
|
" WHERE tid = ? AND attend_date >= ? AND attend_date <= ?" + recordFilter +
|
||||||
|
" GROUP BY employee_id, uid"
|
||||||
|
recordQueryArgs := append([]interface{}{tid, startDate, endDate}, recordArgs...)
|
||||||
|
if _, err := models.Orm.Raw(recordSQL, recordQueryArgs...).QueryRows(&recordRows); err == nil {
|
||||||
|
for _, row := range recordRows {
|
||||||
|
if row.EmployeeID == 0 {
|
||||||
|
item, ok := aggByUid[row.Uid]
|
||||||
|
if !ok {
|
||||||
|
item = &oaAttendAgg{}
|
||||||
|
aggByUid[row.Uid] = item
|
||||||
|
}
|
||||||
|
item.clocked += row.Clocked
|
||||||
|
item.matched += row.Matched
|
||||||
|
item.late += row.Late
|
||||||
|
item.early += row.Early
|
||||||
|
item.noShift += row.NoShift
|
||||||
|
item.offTime += row.OffTime
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item, ok := aggByEmp[row.EmployeeID]
|
||||||
|
if !ok {
|
||||||
|
item = &oaAttendAgg{}
|
||||||
|
aggByEmp[row.EmployeeID] = item
|
||||||
|
}
|
||||||
|
item.clocked += row.Clocked
|
||||||
|
item.matched += row.Matched
|
||||||
|
item.late += row.Late
|
||||||
|
item.early += row.Early
|
||||||
|
item.noShift += row.NoShift
|
||||||
|
item.offTime += row.OffTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aggByEmp, aggByUid, recordRows
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary 全员考勤汇总(月 / 季 / 年)。
|
||||||
|
// GET /backend/oa/attendance/summary?period=month&date=2026-11-14&org_id=&include_child=1&keyword=&page=&pageSize=
|
||||||
|
func (c *BackendOaAttendanceController) Summary() {
|
||||||
|
tid, _, ok := c.atOperator()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
period := strings.ToLower(strings.TrimSpace(c.GetString("period")))
|
||||||
|
if period != "month" && period != "quarter" && period != "year" {
|
||||||
|
period = "month"
|
||||||
|
}
|
||||||
|
anchor := strings.TrimSpace(c.GetString("date"))
|
||||||
|
if !isValidOaDate(anchor) {
|
||||||
|
anchor = time.Now().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
startDate, endDate, label, rangeOK := oaAttendPeriodRange(period, anchor)
|
||||||
|
if !rangeOK {
|
||||||
|
c.atFail(400, 400, "日期参数无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
endDate = oaAttendClampRange(startDate, endDate)
|
||||||
|
|
||||||
|
page, _ := c.GetInt("page", 1)
|
||||||
|
pageSize, _ := c.GetInt("pageSize", 20)
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 || pageSize > oaAttendMaxPageSize {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
orgID := uint64(0)
|
||||||
|
if raw := strings.TrimSpace(c.GetString("org_id")); raw != "" {
|
||||||
|
if parsed, err := strconv.ParseUint(raw, 10, 64); err == nil {
|
||||||
|
orgID = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
orgIDs := oaAttendOrgIDs(tid, orgID, c.GetString("include_child") != "0")
|
||||||
|
keyword := strings.ToLower(strings.TrimSpace(c.GetString("keyword")))
|
||||||
|
|
||||||
|
year := 0
|
||||||
|
if parsed, parseErr := time.ParseInLocation("2006-01-02", anchor, time.Local); parseErr == nil {
|
||||||
|
year = parsed.Year()
|
||||||
|
} else {
|
||||||
|
year = time.Now().Year()
|
||||||
|
}
|
||||||
|
|
||||||
|
monthCards := oaAttendMonthCards(tid, year, orgIDs)
|
||||||
|
quarterCards, yearCard := oaAttendAggregateCards(monthCards, year)
|
||||||
|
|
||||||
|
// 当前选中周期对应的考勤卡
|
||||||
|
activeKey := fmt.Sprintf("%d-%02d", year, int(mustParseMonth(anchor)))
|
||||||
|
activeCard := yearCard
|
||||||
|
switch period {
|
||||||
|
case "year":
|
||||||
|
activeKey = fmt.Sprintf("%d", year)
|
||||||
|
activeCard = yearCard
|
||||||
|
case "quarter":
|
||||||
|
quarter := (int(mustParseMonth(anchor)) - 1) / 3
|
||||||
|
activeKey = fmt.Sprintf("%d-Q%d", year, quarter+1)
|
||||||
|
if quarter < len(quarterCards) {
|
||||||
|
activeCard = quarterCards[quarter]
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if month := int(mustParseMonth(anchor)); month >= 1 && month <= len(monthCards) {
|
||||||
|
activeCard = monthCards[month-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aggByEmp, aggByUid, recordRows := oaAttendEmpAggs(tid, startDate, endDate, orgIDs)
|
||||||
|
orgNames := oaAttendOrgNames(tid)
|
||||||
|
employees, truncated, err := oaAttendRoster(tid, orgIDs)
|
||||||
|
if err != nil {
|
||||||
|
c.atFail(500, 500, "查询员工列表失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]oaAttendSummaryRow, 0, len(employees)+len(aggByUid))
|
||||||
|
for i := range employees {
|
||||||
|
employee := employees[i]
|
||||||
|
position := ""
|
||||||
|
if employee.Position != nil {
|
||||||
|
position = strings.TrimSpace(*employee.Position)
|
||||||
|
}
|
||||||
|
if keyword != "" &&
|
||||||
|
!strings.Contains(strings.ToLower(employee.Name), keyword) &&
|
||||||
|
!strings.Contains(strings.ToLower(employee.Account), keyword) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
empOrgID := scEmployeeDepartmentID(employee)
|
||||||
|
row := oaAttendSummaryRow{
|
||||||
|
Key: fmt.Sprintf("e%d", employee.ID),
|
||||||
|
EmployeeID: employee.ID,
|
||||||
|
EmployeeName: employee.Name,
|
||||||
|
EmployeeAccount: employee.Account,
|
||||||
|
OrgID: empOrgID,
|
||||||
|
OrgName: orgNames[empOrgID],
|
||||||
|
Position: position,
|
||||||
|
Bound: true,
|
||||||
|
}
|
||||||
|
if agg, exists := aggByEmp[employee.ID]; exists {
|
||||||
|
row.ScheduledDays = agg.scheduled
|
||||||
|
row.ClockedDays = agg.clocked
|
||||||
|
row.LateCount = agg.late
|
||||||
|
row.EarlyCount = agg.early
|
||||||
|
row.NoScheduleCount = agg.noShift
|
||||||
|
row.OffTimeCount = agg.offTime
|
||||||
|
row.MissingDays = agg.scheduled - agg.matched
|
||||||
|
if row.MissingDays < 0 {
|
||||||
|
row.MissingDays = 0
|
||||||
|
}
|
||||||
|
if agg.scheduled > 0 {
|
||||||
|
row.AttendanceRate = int(agg.matched * 100 / agg.scheduled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未绑定员工档案但打过卡的人:按登录身份 uid 单列,保证数据不丢
|
||||||
|
for _, record := range recordRows {
|
||||||
|
if record.EmployeeID != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
agg := aggByUid[record.Uid]
|
||||||
|
if agg == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if keyword != "" &&
|
||||||
|
!strings.Contains(strings.ToLower(record.EmployeeName), keyword) &&
|
||||||
|
!strings.Contains(strings.ToLower(record.EmployeeAccount), keyword) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
row := oaAttendSummaryRow{
|
||||||
|
Key: fmt.Sprintf("u%d", record.Uid),
|
||||||
|
Uid: record.Uid,
|
||||||
|
EmployeeID: 0,
|
||||||
|
EmployeeName: record.EmployeeName,
|
||||||
|
EmployeeAccount: record.EmployeeAccount,
|
||||||
|
OrgID: record.OrgID,
|
||||||
|
OrgName: record.OrgName,
|
||||||
|
Bound: false,
|
||||||
|
ClockedDays: agg.clocked,
|
||||||
|
LateCount: agg.late,
|
||||||
|
EarlyCount: agg.early,
|
||||||
|
NoScheduleCount: agg.noShift,
|
||||||
|
OffTimeCount: agg.offTime,
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(rows, func(i, j int) bool {
|
||||||
|
if rows[i].Bound != rows[j].Bound {
|
||||||
|
return rows[i].Bound
|
||||||
|
}
|
||||||
|
if rows[i].OrgID != rows[j].OrgID {
|
||||||
|
return rows[i].OrgID < rows[j].OrgID
|
||||||
|
}
|
||||||
|
return rows[i].EmployeeName < rows[j].EmployeeName
|
||||||
|
})
|
||||||
|
|
||||||
|
total := len(rows)
|
||||||
|
start := (page - 1) * pageSize
|
||||||
|
if start > total {
|
||||||
|
start = total
|
||||||
|
}
|
||||||
|
end := start + pageSize
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
|
||||||
|
c.atOK(map[string]interface{}{
|
||||||
|
"period": period,
|
||||||
|
"label": label,
|
||||||
|
"anchor": anchor,
|
||||||
|
"year": year,
|
||||||
|
"start_date": startDate,
|
||||||
|
"end_date": endDate,
|
||||||
|
"active_key": activeKey,
|
||||||
|
"stats": activeCard,
|
||||||
|
"cards": map[string]interface{}{
|
||||||
|
"months": monthCards,
|
||||||
|
"quarters": quarterCards,
|
||||||
|
"year": yearCard,
|
||||||
|
},
|
||||||
|
"calendar": oaAttendDailyStats(tid, startDate, endDate, orgIDs),
|
||||||
|
"list": rows[start:end],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"pageSize": pageSize,
|
||||||
|
"truncated": truncated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustParseMonth 取日期字符串的月份,解析失败返回当前月。
|
||||||
|
func mustParseMonth(date string) time.Month {
|
||||||
|
if parsed, ok := scParseDate(date); ok {
|
||||||
|
return parsed.Month()
|
||||||
|
}
|
||||||
|
return time.Now().Month()
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestOaAttendPeriodRange 月 / 季 / 年区间换算(含闰年 2 月)。
|
||||||
|
func TestOaAttendPeriodRange(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
period, anchor, start, end string
|
||||||
|
}{
|
||||||
|
{"month", "2026-11-14", "2026-11-01", "2026-11-30"},
|
||||||
|
{"month", "2024-02-10", "2024-02-01", "2024-02-29"},
|
||||||
|
{"quarter", "2026-11-14", "2026-10-01", "2026-12-31"},
|
||||||
|
{"quarter", "2026-01-05", "2026-01-01", "2026-03-31"},
|
||||||
|
{"year", "2026-07-01", "2026-01-01", "2026-12-31"},
|
||||||
|
}
|
||||||
|
for _, item := range cases {
|
||||||
|
start, end, label, ok := oaAttendPeriodRange(item.period, item.anchor)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s %s 解析失败", item.period, item.anchor)
|
||||||
|
}
|
||||||
|
if start != item.start || end != item.end {
|
||||||
|
t.Errorf("%s %s 期望 %s~%s,得到 %s~%s", item.period, item.anchor,
|
||||||
|
item.start, item.end, start, end)
|
||||||
|
}
|
||||||
|
if label == "" {
|
||||||
|
t.Errorf("%s %s 缺少周期标题", item.period, item.anchor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, _, ok := oaAttendPeriodRange("month", "2026-13-01"); ok {
|
||||||
|
t.Error("非法日期应返回 ok=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOaAttendAggregateCards 季度卡与年度卡由月度卡累加得出。
|
||||||
|
func TestOaAttendAggregateCards(t *testing.T) {
|
||||||
|
months := make([]oaAttendCard, 0, 12)
|
||||||
|
for month := 1; month <= 12; month++ {
|
||||||
|
card := oaAttendCard{Scheduled: 10, Matched: 8, Clocked: 9}
|
||||||
|
oaAttendFillCard(&card)
|
||||||
|
months = append(months, card)
|
||||||
|
}
|
||||||
|
|
||||||
|
quarters, yearCard := oaAttendAggregateCards(months, 2026)
|
||||||
|
if len(quarters) != 4 {
|
||||||
|
t.Fatalf("期望 4 个季度卡,得到 %d", len(quarters))
|
||||||
|
}
|
||||||
|
if quarters[0].Scheduled != 30 || quarters[0].Matched != 24 {
|
||||||
|
t.Errorf("Q1 期望 应出勤 30 / 匹配 24,得到 %d / %d", quarters[0].Scheduled, quarters[0].Matched)
|
||||||
|
}
|
||||||
|
if quarters[0].Missing != 6 || quarters[0].AttendanceRate != 80 {
|
||||||
|
t.Errorf("Q1 期望 缺卡 6 / 出勤率 80,得到 %d / %d", quarters[0].Missing, quarters[0].AttendanceRate)
|
||||||
|
}
|
||||||
|
if yearCard.Scheduled != 120 || yearCard.Matched != 96 || yearCard.AttendanceRate != 80 {
|
||||||
|
t.Errorf("年度卡累计异常:%+v", yearCard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOaShiftWindow 班次时间窗:普通班次与跨天班次都应换算成分钟区间。
|
||||||
|
func TestOaShiftWindow(t *testing.T) {
|
||||||
|
start, end, ok := oaShiftWindow("09:00", "18:00")
|
||||||
|
if !ok || start != 540 || end != 1080 {
|
||||||
|
t.Fatalf("09:00-18:00 期望 540-1080,得到 %d-%d ok=%v", start, end, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
start, end, ok = oaShiftWindow("22:00", "06:00")
|
||||||
|
if !ok || start != 1320 || end != 1800 {
|
||||||
|
t.Fatalf("22:00-06:00 期望 1320-1800,得到 %d-%d ok=%v", start, end, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, ok := oaShiftWindow("", "18:00"); ok {
|
||||||
|
t.Error("缺少上班时间时应返回 ok=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOaAttendClockInStatus 签到判定:正常 / 迟到 / 过早(不在时段)/ 无排班。
|
||||||
|
func TestOaAttendClockInStatus(t *testing.T) {
|
||||||
|
matched := &models.OaAttendance{
|
||||||
|
WorkStart: "09:00",
|
||||||
|
WorkEnd: "18:00",
|
||||||
|
MatchStatus: models.OaAttendMatchOK,
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
now string
|
||||||
|
status int8
|
||||||
|
}{
|
||||||
|
{"08:55", models.OaAttendClockNormal}, // 容差内早到
|
||||||
|
{"09:05", models.OaAttendClockNormal}, // 容差内晚到
|
||||||
|
{"09:30", models.OaAttendClockLate}, // 迟到
|
||||||
|
{"07:30", models.OaAttendClockOffTime}, // 提前超过 60 分钟
|
||||||
|
}
|
||||||
|
for _, item := range cases {
|
||||||
|
now, err := time.ParseInLocation("2006-01-02 15:04", "2026-11-14 "+item.now, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("解析时间失败: %v", err)
|
||||||
|
}
|
||||||
|
status, _ := oaAttendClockInStatus(matched, now)
|
||||||
|
if status != item.status {
|
||||||
|
t.Errorf("%s 期望状态 %d,得到 %d", item.now, item.status, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未匹配排班:不阻断打卡,状态记为「无排班」
|
||||||
|
missing := &models.OaAttendance{MatchStatus: models.OaAttendMatchMissing}
|
||||||
|
now := time.Date(2026, 11, 14, 9, 0, 0, 0, time.Local)
|
||||||
|
if status, msg := oaAttendClockInStatus(missing, now); status != models.OaAttendClockNoShift || msg == "" {
|
||||||
|
t.Errorf("未匹配排班期望状态 %d 且有提醒文案,得到 %d %q", models.OaAttendClockNoShift, status, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOaAttendClockOutStatus 签退判定:早退与跨天班次凌晨签退。
|
||||||
|
func TestOaAttendClockOutStatus(t *testing.T) {
|
||||||
|
row := &models.OaAttendance{
|
||||||
|
WorkStart: "09:00",
|
||||||
|
WorkEnd: "18:00",
|
||||||
|
MatchStatus: models.OaAttendMatchOK,
|
||||||
|
}
|
||||||
|
early := time.Date(2026, 11, 14, 17, 0, 0, 0, time.Local)
|
||||||
|
if status, msg := oaAttendClockOutStatus(row, early); status != models.OaAttendClockLate || msg == "" {
|
||||||
|
t.Errorf("17:00 签退期望早退,得到 %d %q", status, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
night := &models.OaAttendance{
|
||||||
|
WorkStart: "22:00",
|
||||||
|
WorkEnd: "06:00",
|
||||||
|
MatchStatus: models.OaAttendMatchOK,
|
||||||
|
}
|
||||||
|
// 跨天班次:次日 06:10 签退应视为正常
|
||||||
|
nextMorning := time.Date(2026, 11, 15, 6, 10, 0, 0, time.Local)
|
||||||
|
if status, msg := oaAttendClockOutStatus(night, nextMorning); status != models.OaAttendClockNormal || msg != "" {
|
||||||
|
t.Errorf("06:10 跨天签退期望正常,得到 %d %q", status, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOaAttendDayResult 当日考勤结果口径:缺卡 > 无排班 > 迟到 > 早退 > 时段异常 > 未签退 > 正常。
|
||||||
|
func TestOaAttendDayResult(t *testing.T) {
|
||||||
|
if _, text := oaAttendDayResult("", 0, "", 0, models.OaAttendMatchMissing); text != "缺卡" {
|
||||||
|
t.Errorf("未签到应为缺卡,得到 %q", text)
|
||||||
|
}
|
||||||
|
if _, text := oaAttendDayResult("09:00", models.OaAttendClockOffTime, "18:00", models.OaAttendClockNormal, models.OaAttendMatchOK); text != "时段异常" {
|
||||||
|
t.Errorf("时段异常期望「时段异常」,得到 %q", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, text := oaAttendDayResult("09:00", models.OaAttendClockNormal, "18:00", models.OaAttendClockNormal, models.OaAttendMatchOK); text != "正常" {
|
||||||
|
t.Errorf("正常打卡期望「正常」,得到 %q", text)
|
||||||
|
}
|
||||||
|
if _, text := oaAttendDayResult("09:30", models.OaAttendClockLate, "", 0, models.OaAttendMatchOK); text != "迟到" {
|
||||||
|
t.Errorf("迟到期望「迟到」,得到 %q", text)
|
||||||
|
}
|
||||||
|
if _, text := oaAttendDayResult("09:00", models.OaAttendClockNormal, "17:00", models.OaAttendClockLate, models.OaAttendMatchOK); text != "早退" {
|
||||||
|
t.Errorf("早退期望「早退」,得到 %q", text)
|
||||||
|
}
|
||||||
|
if _, text := oaAttendDayResult("09:00", models.OaAttendClockNoShift, "18:00", models.OaAttendClockNoShift, models.OaAttendMatchMissing); text != "无排班" {
|
||||||
|
t.Errorf("无排班打卡期望「无排班」,得到 %q", text)
|
||||||
|
}
|
||||||
|
if _, text := oaAttendDayResult("09:00", models.OaAttendClockNormal, "", 0, models.OaAttendMatchOK); text != "未签退" {
|
||||||
|
t.Errorf("缺签退期望「未签退」,得到 %q", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -843,20 +843,25 @@ func (c *BackendOaSchedulingController) MyBoard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// scFindSelfEmployee 把登录身份映射到本租户的员工档案。
|
// scFindSelfEmployee 把登录身份映射到本租户的员工档案。
|
||||||
|
func (c *BackendOaSchedulingController) scFindSelfEmployee(tid, identityID uint64, username string) (models.BackendEmployee, bool) {
|
||||||
|
return findSelfEmployee(tid, identityID, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findSelfEmployee 把登录身份映射到本租户的员工档案(包级函数,排班与考勤共用)。
|
||||||
// 优先取认证中心「身份+租户」绑定记录里的账号(与 /auth/userinfo 同源,最可靠),
|
// 优先取认证中心「身份+租户」绑定记录里的账号(与 /auth/userinfo 同源,最可靠),
|
||||||
// 其次用令牌 username 按「账号 → 姓名」匹配。
|
// 其次用令牌 username 按「账号 → 姓名」匹配。
|
||||||
func (c *BackendOaSchedulingController) scFindSelfEmployee(tid, identityID uint64, username string) (models.BackendEmployee, bool) {
|
func findSelfEmployee(tid, identityID uint64, username string) (models.BackendEmployee, bool) {
|
||||||
var employee models.BackendEmployee
|
var employee models.BackendEmployee
|
||||||
|
|
||||||
if identityID > 0 {
|
if identityID > 0 {
|
||||||
if bind, err := authSvc.GetTenantUser(identityID, tid); err == nil && bind != nil {
|
if bind, err := authSvc.GetTenantUser(identityID, tid); err == nil && bind != nil {
|
||||||
if v := authBindValue(bind.Account); v != "" {
|
if v := authBindValue(bind.Account); v != "" {
|
||||||
if emp, found := c.scEmployeeByField(tid, "account", v); found {
|
if emp, found := findEmployeeByField(tid, "account", v); found {
|
||||||
return emp, true
|
return emp, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if v := authBindValue(bind.Name); v != "" {
|
if v := authBindValue(bind.Name); v != "" {
|
||||||
if emp, found := c.scEmployeeByField(tid, "name", v); found {
|
if emp, found := findEmployeeByField(tid, "name", v); found {
|
||||||
return emp, true
|
return emp, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -866,7 +871,7 @@ func (c *BackendOaSchedulingController) scFindSelfEmployee(tid, identityID uint6
|
|||||||
username = strings.TrimSpace(username)
|
username = strings.TrimSpace(username)
|
||||||
if username != "" {
|
if username != "" {
|
||||||
for _, field := range []string{"account", "name"} {
|
for _, field := range []string{"account", "name"} {
|
||||||
if emp, found := c.scEmployeeByField(tid, field, username); found {
|
if emp, found := findEmployeeByField(tid, field, username); found {
|
||||||
return emp, true
|
return emp, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -876,8 +881,13 @@ func (c *BackendOaSchedulingController) scFindSelfEmployee(tid, identityID uint6
|
|||||||
|
|
||||||
// scEmployeeByField 在本租户员工档案中按指定字段匹配,取第一条有效记录
|
// scEmployeeByField 在本租户员工档案中按指定字段匹配,取第一条有效记录
|
||||||
func (c *BackendOaSchedulingController) scEmployeeByField(tid uint64, field, value string) (models.BackendEmployee, bool) {
|
func (c *BackendOaSchedulingController) scEmployeeByField(tid uint64, field, value string) (models.BackendEmployee, bool) {
|
||||||
|
return findEmployeeByField(tid, field, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findEmployeeByField 包级实现:本租户员工档案按指定字段匹配,取第一条有效记录。
|
||||||
|
func findEmployeeByField(tid uint64, field, value string) (models.BackendEmployee, bool) {
|
||||||
var rows []models.BackendEmployee
|
var rows []models.BackendEmployee
|
||||||
if _, err := c.scBaseEmployeeQuery(tid).
|
if _, err := employeeBaseQuery(tid).
|
||||||
Filter(field, value).
|
Filter(field, value).
|
||||||
OrderBy("id").
|
OrderBy("id").
|
||||||
Limit(1).
|
Limit(1).
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ func (index *scOrgIndex) name(id uint64) string {
|
|||||||
|
|
||||||
// scBaseEmployeeQuery 排班可选员工:未删除、非离职。
|
// scBaseEmployeeQuery 排班可选员工:未删除、非离职。
|
||||||
func (c *BackendOaSchedulingController) scBaseEmployeeQuery(tid uint64) orm.QuerySeter {
|
func (c *BackendOaSchedulingController) scBaseEmployeeQuery(tid uint64) orm.QuerySeter {
|
||||||
|
return employeeBaseQuery(tid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// employeeBaseQuery 本租户在职员工查询(包级函数,排班与考勤共用)。
|
||||||
|
func employeeBaseQuery(tid uint64) orm.QuerySeter {
|
||||||
return models.Orm.QueryTable(new(models.BackendEmployee)).
|
return models.Orm.QueryTable(new(models.BackendEmployee)).
|
||||||
Filter("tid", tid).
|
Filter("tid", tid).
|
||||||
Filter("delete_time__isnull", true).
|
Filter("delete_time__isnull", true).
|
||||||
|
|||||||
@@ -141,6 +141,85 @@ func (c *BackendSiteSettingsController) GetNormalInfos() {
|
|||||||
_ = c.ServeJSON()
|
_ = c.ServeJSON()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// backendBrandOutput 后台顶栏品牌(Logo 优先用平台配置)
|
||||||
|
type backendBrandOutput struct {
|
||||||
|
Sitename string `json:"sitename"` // 主标题
|
||||||
|
Subtitle string `json:"subtitle"` // 副标题
|
||||||
|
Intro string `json:"intro"`
|
||||||
|
Ico string `json:"ico"` // 浏览器标签页图标(favicon)
|
||||||
|
LogoSquare string `json:"logoSquare"` // 正方形 Logo(浅色主题 / 顶栏小图标)
|
||||||
|
LogoRect string `json:"logoRect"` // 长方形 Logo(浅色主题 / 横向品牌图)
|
||||||
|
Logo string `json:"logo"` // 官网深色 Logo(浅色背景)
|
||||||
|
Logow string `json:"logow"` // 官网浅色 Logo(深色背景)
|
||||||
|
|
||||||
|
// 深色主题专用 Logo:留空时前端回退到上面的浅色主题版本
|
||||||
|
LogoSquareDark string `json:"logoSquareDark"`
|
||||||
|
LogoRectDark string `json:"logoRectDark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBrand GET /backend/brand 后台顶栏品牌信息(顶栏左上角 Logo / 站点名 / 简介)
|
||||||
|
// 取值优先级:
|
||||||
|
// 1. 平台「系统设置 - 平台设置 - 平台信息」(logo_square / logo_rect / sitename / platform_intro)
|
||||||
|
// 2. 平台「官网管理 - 站点设置」(yz_platform_site_setting:logo 深色 / logow 浅色)
|
||||||
|
// 3. 平台「平台设置 - 登录页设置」的 login_logo(仅在以上都没配 logo 时兜底)
|
||||||
|
// 4. 当前租户自己的站点设置(yz_tenant_site_setting,租户在 backend「站点设置」上传的图片)
|
||||||
|
//
|
||||||
|
// 都没有配置时返回空串,前端保留内置的品牌占位标记。
|
||||||
|
func (c *BackendSiteSettingsController) GetBrand() {
|
||||||
|
claims, err := c.claimsByPath()
|
||||||
|
if err != nil {
|
||||||
|
code := identity.Status(err)
|
||||||
|
c.jsonErr(code, code, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := models.GetPlatformSiteTagMap()
|
||||||
|
out := backendBrandOutput{
|
||||||
|
Sitename: strings.TrimSpace(tags["sitename"]),
|
||||||
|
Subtitle: strings.TrimSpace(tags["subtitle"]),
|
||||||
|
Intro: strings.TrimSpace(tags["platform_intro"]),
|
||||||
|
Ico: strings.TrimSpace(tags["ico"]),
|
||||||
|
LogoSquare: strings.TrimSpace(tags["logo_square"]),
|
||||||
|
LogoRect: strings.TrimSpace(tags["logo_rect"]),
|
||||||
|
LogoSquareDark: strings.TrimSpace(tags["logo_square_dark"]),
|
||||||
|
LogoRectDark: strings.TrimSpace(tags["logo_rect_dark"]),
|
||||||
|
Logo: strings.TrimSpace(tags["logo"]),
|
||||||
|
Logow: strings.TrimSpace(tags["logow"]),
|
||||||
|
}
|
||||||
|
if out.Logo == "" {
|
||||||
|
out.Logo = strings.TrimSpace(tags["login_logo"])
|
||||||
|
}
|
||||||
|
if out.Logo == "" {
|
||||||
|
out.Logo = out.LogoRect
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平台未配置的项回落到租户站点设置,避免顶栏退化成占位标记
|
||||||
|
if out.Logo == "" || out.Logow == "" || out.Sitename == "" {
|
||||||
|
if tid := c.resolveBackendTenantID(claims, nil); tid > 0 {
|
||||||
|
var rows []models.TenantSiteSetting
|
||||||
|
if _, qerr := models.Orm.QueryTable(new(models.TenantSiteSetting)).
|
||||||
|
Filter("tid", tid).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Limit(1).
|
||||||
|
All(&rows); qerr == nil && len(rows) > 0 {
|
||||||
|
r := rows[0]
|
||||||
|
if out.Logo == "" {
|
||||||
|
out.Logo = strings.TrimSpace(r.Logo)
|
||||||
|
}
|
||||||
|
if out.Logow == "" {
|
||||||
|
out.Logow = strings.TrimSpace(r.Logow)
|
||||||
|
}
|
||||||
|
if out.Sitename == "" {
|
||||||
|
out.Sitename = strings.TrimSpace(r.Sitename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": out}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
type backendNormalInfosPayload struct {
|
type backendNormalInfosPayload struct {
|
||||||
// 前端会传 tid(但我们仍优先使用 token 的 tenant_id)
|
// 前端会传 tid(但我们仍优先使用 token 的 tenant_id)
|
||||||
Tid interface{} `json:"tid"`
|
Tid interface{} `json:"tid"`
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- OA 考勤打卡 —— 表结构
|
||||||
|
--
|
||||||
|
-- 对应代码:
|
||||||
|
-- models/oa_attendance.go
|
||||||
|
-- controllers/backend_oa_attendance.go (打卡核心 + 后台考勤记录)
|
||||||
|
-- controllers/app_oa_attendance.go (移动端 /api/oa/attendance/*)
|
||||||
|
--
|
||||||
|
-- 执行顺序:
|
||||||
|
-- 1) 本脚本:创建考勤表
|
||||||
|
-- 2) oa_attendance_menus.sql:注册「OA → 排班考勤 → 考勤记录 / 考勤打卡」菜单
|
||||||
|
--
|
||||||
|
-- 设计要点:
|
||||||
|
-- 1. 一人一天一条:签到与签退写同一行,统计与列表都按「人 × 日期」聚合。
|
||||||
|
-- 2. 打卡时联动排班明细(yz_backend_oa_schedule_entry),把班次名称与
|
||||||
|
-- 上下班时间快照进本表;后续调班不影响历史考勤判定。
|
||||||
|
-- 3. 匹配不上排班(含员工档案缺失)不阻断打卡:match_status=2,
|
||||||
|
-- clock_in_status/clock_out_status=3(无排班打卡),提醒文案落在
|
||||||
|
-- clock_in_note / clock_out_note,后台可据此筛选与备注。
|
||||||
|
-- 4. 唯一键 (tid, uid, attend_date):uid 是登录身份,保证员工档案未建
|
||||||
|
-- 也能先打卡(此时 employee_id=0,后台按账号/姓名检索)。
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_backend_oa_attendance` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
|
||||||
|
`uid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '登录身份ID(打卡主体)',
|
||||||
|
`employee_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '员工ID(yz_backend_employee.id),0=未绑定员工档案',
|
||||||
|
`employee_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '员工姓名快照',
|
||||||
|
`employee_account` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '员工账号快照',
|
||||||
|
`org_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '所属组织ID',
|
||||||
|
`org_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '组织名称快照',
|
||||||
|
`position` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '职位快照',
|
||||||
|
`attend_date` DATE NOT NULL COMMENT '考勤日期',
|
||||||
|
`entry_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '排班明细ID,0=未匹配排班',
|
||||||
|
`shift_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '班次ID快照',
|
||||||
|
`shift_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '班次名称快照',
|
||||||
|
`short_name` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '班次简称快照',
|
||||||
|
`color` VARCHAR(20) NOT NULL DEFAULT '#409EFF' COMMENT '班次颜色快照',
|
||||||
|
`work_start` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '上班时间快照 HH:mm',
|
||||||
|
`work_end` VARCHAR(8) NOT NULL DEFAULT '' COMMENT '下班时间快照 HH:mm',
|
||||||
|
`match_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0=未知 1=已匹配排班 2=未匹配排班',
|
||||||
|
`clock_in_time` DATETIME DEFAULT NULL COMMENT '签到时间',
|
||||||
|
`clock_in_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0=未打卡 1=正常 2=迟到 3=无排班打卡 4=不在班次时段',
|
||||||
|
`clock_in_note` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '签到提醒/异常说明',
|
||||||
|
`clock_out_time` DATETIME DEFAULT NULL COMMENT '签退时间',
|
||||||
|
`clock_out_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0=未打卡 1=正常 2=早退 3=无排班打卡 4=不在班次时段',
|
||||||
|
`clock_out_note` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '签退提醒/异常说明',
|
||||||
|
`location` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '打卡地点(预留)',
|
||||||
|
`remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '管理员备注/异常说明',
|
||||||
|
`source` TINYINT NOT NULL DEFAULT 1 COMMENT '1=移动端打卡 2=后台补录',
|
||||||
|
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_tid_uid_date` (`tid`, `uid`, `attend_date`),
|
||||||
|
KEY `idx_tid_date` (`tid`, `attend_date`),
|
||||||
|
KEY `idx_tid_emp_date` (`tid`, `employee_id`, `attend_date`),
|
||||||
|
KEY `idx_tid_org_date` (`tid`, `org_id`, `attend_date`),
|
||||||
|
KEY `idx_tid_status` (`tid`, `clock_in_status`, `clock_out_status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-考勤打卡记录';
|
||||||
|
|
||||||
|
-- ---------------- 校验 ----------------
|
||||||
|
SELECT TABLE_NAME, TABLE_COMMENT
|
||||||
|
FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'yz_backend_oa_attendance';
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 人员管理(yz_backend_employee) 与 用户管理(yz_system_tenant_user) 老数据对齐
|
||||||
|
--
|
||||||
|
-- 背景:员工表早期建的是「账号全局唯一」索引 uk_account(account),多租户后同一账号
|
||||||
|
-- 在不同企业只能存在一份员工档案;再叠加历史手工录入的人员(如 hero001 这类旧账号),
|
||||||
|
-- 导致「OA 设置 → 人员管理」与「基本设置 → 用户管理」两边数据对不上。
|
||||||
|
--
|
||||||
|
-- 处理内容:
|
||||||
|
-- 1. 索引调整为 (tid, account) 唯一 ← 必须最先执行,否则第 3 步会撞全局唯一键
|
||||||
|
-- 2. 员工 tid 为空/0:按唯一账号补 tid
|
||||||
|
-- 3. 同一企业内「用户缺员工 + 存在同名无账号老员工」:把老员工账号改成用户账号
|
||||||
|
-- (保留员工 ID,排班 / 日报 / 人事档案的历史引用不受影响)
|
||||||
|
-- 4. 用户仍然没有员工:按用户资料补建员工记录(与「用户管理 → 同步员工」同规则)
|
||||||
|
-- 5. 复核查询 + 仍需人工确认的清单
|
||||||
|
--
|
||||||
|
-- 反向缺口(「人员管理有、用户管理没有」)不在这里处理:补账号要先在统一认证建身份
|
||||||
|
-- 并生成 argon2id 密码哈希,SQL 做不到,用 Go 工具:
|
||||||
|
-- go run scripts/syncemployeeusers/main.go -tid <企业ID> -apply
|
||||||
|
--
|
||||||
|
-- 特性:可重复执行(幂等),第 2 次执行不会产生任何变更。
|
||||||
|
-- 执行前请务必备份:mysqldump -u root -p go-platform > backup_$(date +%F).sql
|
||||||
|
-- 执行后请重启 backend 服务:services/employee_sync.go 已改为按 (tid, account) 匹配员工。
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 0. 执行前预检(只看不改)
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
SELECT '执行前:用户/员工数量' AS section;
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM yz_system_tenant_user WHERE delete_time IS NULL) AS user_rows,
|
||||||
|
(SELECT COUNT(*) FROM yz_backend_employee WHERE delete_time IS NULL) AS employee_rows,
|
||||||
|
(SELECT COUNT(*) FROM yz_backend_employee WHERE delete_time IS NULL AND (tid IS NULL OR tid = 0)) AS employee_tid_empty,
|
||||||
|
(SELECT COUNT(*) FROM yz_system_tenant_user u
|
||||||
|
WHERE u.delete_time IS NULL AND u.account IS NOT NULL AND TRIM(u.account) <> ''
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM yz_backend_employee e
|
||||||
|
WHERE e.delete_time IS NULL AND e.tid = u.tid AND TRIM(e.account) = TRIM(u.account))
|
||||||
|
) AS user_without_employee;
|
||||||
|
|
||||||
|
-- 若下面这条 > 0,需要先人工处理重复的 (tid, account),否则第 1 步加唯一索引会失败
|
||||||
|
SELECT '重复的 (tid, account)' AS section;
|
||||||
|
SELECT tid, TRIM(account) AS account, COUNT(*) AS cnt
|
||||||
|
FROM yz_backend_employee
|
||||||
|
WHERE delete_time IS NULL
|
||||||
|
GROUP BY tid, TRIM(account)
|
||||||
|
HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 1. 索引:账号全局唯一 → 企业内唯一
|
||||||
|
-- 关键:不先放开全局唯一,第 3 步把 hero001 改成 hero920103 会因另一企业已占用而失败
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
SET @has_old := (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_employee'
|
||||||
|
AND INDEX_NAME = 'uk_account');
|
||||||
|
SET @sql := IF(@has_old > 0, 'ALTER TABLE yz_backend_employee DROP INDEX uk_account', 'DO 0');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @has_plain := (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_employee'
|
||||||
|
AND INDEX_NAME = 'idx_tid_account');
|
||||||
|
SET @sql := IF(@has_plain > 0, 'ALTER TABLE yz_backend_employee DROP INDEX idx_tid_account', 'DO 0');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @has_uk := (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_backend_employee'
|
||||||
|
AND INDEX_NAME = 'uk_tid_account' AND NON_UNIQUE = 0);
|
||||||
|
SET @sql := IF(@has_uk = 0,
|
||||||
|
'ALTER TABLE yz_backend_employee ADD UNIQUE KEY uk_tid_account (tid, account)',
|
||||||
|
'DO 0');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 2. 员工 tid 为空/0:按账号唯一匹配到企业用户后补 tid
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
UPDATE yz_backend_employee e
|
||||||
|
JOIN (
|
||||||
|
SELECT TRIM(account) AS account, MIN(tid) AS tid
|
||||||
|
FROM yz_system_tenant_user
|
||||||
|
WHERE delete_time IS NULL AND account IS NOT NULL AND TRIM(account) <> ''
|
||||||
|
GROUP BY TRIM(account)
|
||||||
|
HAVING COUNT(DISTINCT tid) = 1
|
||||||
|
) u ON u.account = TRIM(e.account)
|
||||||
|
SET e.tid = u.tid,
|
||||||
|
e.update_time = NOW()
|
||||||
|
WHERE e.delete_time IS NULL
|
||||||
|
AND (e.tid IS NULL OR e.tid = 0);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 3. 同企业内同名老员工账号对齐
|
||||||
|
-- 条件:① 员工账号在本企业没有对应用户(历史手工人员)
|
||||||
|
-- ② 该用户在本企业还没有员工
|
||||||
|
-- ③ 按姓名能唯一匹配到一个用户
|
||||||
|
-- 只改 account,不动 id,历史引用全部保留
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DROP TEMPORARY TABLE IF EXISTS tmp_emp_align;
|
||||||
|
CREATE TEMPORARY TABLE tmp_emp_align (
|
||||||
|
emp_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
|
||||||
|
tid BIGINT NOT NULL,
|
||||||
|
old_account VARCHAR(64) NOT NULL,
|
||||||
|
new_account VARCHAR(64) NOT NULL
|
||||||
|
) ENGINE = InnoDB;
|
||||||
|
|
||||||
|
INSERT INTO tmp_emp_align (emp_id, tid, old_account, new_account)
|
||||||
|
SELECT e.id, e.tid, TRIM(e.account), MIN(TRIM(u.account))
|
||||||
|
FROM yz_backend_employee e
|
||||||
|
JOIN yz_system_tenant_user u
|
||||||
|
ON u.tid = e.tid
|
||||||
|
AND u.delete_time IS NULL
|
||||||
|
AND u.account IS NOT NULL
|
||||||
|
AND TRIM(u.account) <> ''
|
||||||
|
AND TRIM(u.name) = TRIM(e.name)
|
||||||
|
LEFT JOIN yz_system_tenant_user ux
|
||||||
|
ON ux.tid = e.tid
|
||||||
|
AND ux.delete_time IS NULL
|
||||||
|
AND TRIM(ux.account) = TRIM(e.account)
|
||||||
|
LEFT JOIN yz_backend_employee ex
|
||||||
|
ON ex.tid = e.tid
|
||||||
|
AND ex.delete_time IS NULL
|
||||||
|
AND TRIM(ex.account) = TRIM(u.account)
|
||||||
|
WHERE e.delete_time IS NULL
|
||||||
|
AND e.tid IS NOT NULL
|
||||||
|
AND e.tid > 0
|
||||||
|
AND ux.id IS NULL -- 员工账号在本企业没有用户
|
||||||
|
AND ex.id IS NULL -- 用户在本企业还没有员工
|
||||||
|
GROUP BY e.id, e.tid, TRIM(e.account)
|
||||||
|
HAVING COUNT(DISTINCT TRIM(u.account)) = 1; -- 同名用户不唯一时不动,留待人工
|
||||||
|
|
||||||
|
UPDATE yz_backend_employee e
|
||||||
|
JOIN tmp_emp_align t ON t.emp_id = e.id
|
||||||
|
SET e.account = t.new_account,
|
||||||
|
e.update_time = NOW();
|
||||||
|
|
||||||
|
SELECT '第 3 步:对齐的老员工账号' AS section, emp_id, tid, old_account, new_account
|
||||||
|
FROM tmp_emp_align;
|
||||||
|
|
||||||
|
DROP TEMPORARY TABLE IF EXISTS tmp_emp_align;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 4. 用户仍然没有员工:按用户资料补建员工记录
|
||||||
|
-- 与 services/EnsureEmployeeForTenantUser 的规则一致(账号、姓名、性别、生日、
|
||||||
|
-- 部门、手机、邮箱、启用状态),密码留空(登录走统一认证,不校验员工密码)
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
INSERT INTO yz_backend_employee
|
||||||
|
(tid, account, password, name, gender, birthday, department, phone, email,
|
||||||
|
account_status, create_time, update_time)
|
||||||
|
SELECT
|
||||||
|
u.tid,
|
||||||
|
TRIM(u.account),
|
||||||
|
'',
|
||||||
|
COALESCE(NULLIF(TRIM(u.name), ''), TRIM(u.account)),
|
||||||
|
IFNULL(u.sex, 0),
|
||||||
|
STR_TO_DATE(LEFT(TRIM(IFNULL(u.birth, '')), 10), '%Y-%m-%d'),
|
||||||
|
CASE WHEN IFNULL(u.org_id, 0) > 0 THEN CAST(u.org_id AS CHAR) ELSE NULL END,
|
||||||
|
NULLIF(TRIM(IFNULL(u.phone, '')), ''),
|
||||||
|
NULLIF(TRIM(IFNULL(u.email, '')), ''),
|
||||||
|
IF(IFNULL(u.status, 1) = 0, 0, 1),
|
||||||
|
NOW(), NOW()
|
||||||
|
FROM yz_system_tenant_user u
|
||||||
|
LEFT JOIN yz_backend_employee e
|
||||||
|
ON e.delete_time IS NULL
|
||||||
|
AND e.tid = u.tid
|
||||||
|
AND TRIM(e.account) = TRIM(u.account)
|
||||||
|
WHERE u.delete_time IS NULL
|
||||||
|
AND u.account IS NOT NULL
|
||||||
|
AND TRIM(u.account) <> ''
|
||||||
|
AND e.id IS NULL;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 5. 复核
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
SELECT '执行后:用户/员工数量' AS section;
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM yz_system_tenant_user WHERE delete_time IS NULL) AS user_rows,
|
||||||
|
(SELECT COUNT(*) FROM yz_backend_employee WHERE delete_time IS NULL) AS employee_rows,
|
||||||
|
(SELECT COUNT(*) FROM yz_backend_employee WHERE delete_time IS NULL AND (tid IS NULL OR tid = 0)) AS employee_tid_empty,
|
||||||
|
(SELECT COUNT(*) FROM yz_system_tenant_user u
|
||||||
|
WHERE u.delete_time IS NULL AND u.account IS NOT NULL AND TRIM(u.account) <> ''
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM yz_backend_employee e
|
||||||
|
WHERE e.delete_time IS NULL AND e.tid = u.tid AND TRIM(e.account) = TRIM(u.account))
|
||||||
|
) AS user_without_employee;
|
||||||
|
|
||||||
|
-- 按企业对比:用户数 / 员工数 / 本企业无账号的员工数
|
||||||
|
SELECT
|
||||||
|
u.tid,
|
||||||
|
u.user_cnt,
|
||||||
|
e.emp_cnt,
|
||||||
|
e.emp_without_user
|
||||||
|
FROM (
|
||||||
|
SELECT tid, COUNT(*) AS user_cnt
|
||||||
|
FROM yz_system_tenant_user
|
||||||
|
WHERE delete_time IS NULL
|
||||||
|
GROUP BY tid
|
||||||
|
) u
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT x.tid,
|
||||||
|
COUNT(*) AS emp_cnt,
|
||||||
|
SUM(CASE WHEN ux.id IS NULL THEN 1 ELSE 0 END) AS emp_without_user
|
||||||
|
FROM yz_backend_employee x
|
||||||
|
LEFT JOIN yz_system_tenant_user ux
|
||||||
|
ON ux.tid = x.tid
|
||||||
|
AND ux.delete_time IS NULL
|
||||||
|
AND TRIM(ux.account) = TRIM(x.account)
|
||||||
|
WHERE x.delete_time IS NULL
|
||||||
|
GROUP BY x.tid
|
||||||
|
) e ON e.tid = u.tid
|
||||||
|
ORDER BY u.tid;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 6. 仍需人工确认:员工在本企业没有登录账号(老数据,脚本不删除也不改)
|
||||||
|
-- 处理方式二选一:
|
||||||
|
-- a) 在「用户管理」用相同账号添加用户 → 会自动关联到已有员工档案,不会重复建档
|
||||||
|
-- b) 在「人员管理」把这些人员设为离职(account_status = 2)
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
SELECT '仍无登录账号的员工' AS section,
|
||||||
|
e.tid, e.id, TRIM(e.account) AS account, TRIM(e.name) AS name,
|
||||||
|
TRIM(IFNULL(e.department, '')) AS department
|
||||||
|
FROM yz_backend_employee e
|
||||||
|
LEFT JOIN yz_system_tenant_user u
|
||||||
|
ON u.tid = e.tid
|
||||||
|
AND u.delete_time IS NULL
|
||||||
|
AND TRIM(u.account) = TRIM(e.account)
|
||||||
|
WHERE e.delete_time IS NULL
|
||||||
|
AND u.id IS NULL
|
||||||
|
ORDER BY e.tid, e.id;
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- OA AI 办公 —— 菜单配置(yz_system_menu)
|
||||||
|
-- 幂等脚本:按 path 判重,重复执行不会产生重复菜单
|
||||||
|
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < oa_ai_menus.sql
|
||||||
|
--
|
||||||
|
-- 挂载位置:
|
||||||
|
-- OA → 办公 → AI办公(页面,/apps/oa/ai)
|
||||||
|
-- OA → 基础设置 → AI设置(页面,/basic-settings/ai)
|
||||||
|
--
|
||||||
|
-- 对应页面:
|
||||||
|
-- backend/src/views/apps/oa/ai/index.vue AI 广场(场景对话)
|
||||||
|
-- backend/src/views/basicSettings/ai/index.vue AI 设置(租户级接口 + Key)
|
||||||
|
--
|
||||||
|
-- 说明:
|
||||||
|
-- 1. AI 接入配置是租户级的(yz_backend_ai_provider.user_id = 0),
|
||||||
|
-- 由「AI设置」页统一维护,保存后全租户共用;
|
||||||
|
-- 2. 「AI办公」是 Web 页面(platforms=["web"]),移动端暂不提供;
|
||||||
|
-- 3. 租户端角色(cid=2)的 rights 是显式菜单 ID 列表,只插菜单不授权
|
||||||
|
-- 会被过滤掉,因此本脚本把菜单 ID 追加到角色 rights。
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
SET @oa_id := (
|
||||||
|
SELECT id FROM yz_system_menu
|
||||||
|
WHERE path = '/apps/oa' AND delete_time IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 「办公」目录:优先同名菜单,其次 /apps/oa/personal;都没有则回落 OA 根
|
||||||
|
SET @office_id := (
|
||||||
|
SELECT id FROM yz_system_menu
|
||||||
|
WHERE delete_time IS NULL
|
||||||
|
AND type = 1
|
||||||
|
AND (title = '办公' OR title = '个人办公' OR path = '/apps/oa/personal')
|
||||||
|
ORDER BY (title = '办公') DESC, (path = '/apps/oa/personal') DESC, sort, id
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- OA 基础设置目录(AI 设置挂这里)
|
||||||
|
SET @basic_id := (
|
||||||
|
SELECT id FROM yz_system_menu
|
||||||
|
WHERE path = '/apps/oa/normalsetting' AND delete_time IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @office_parent := IFNULL(@office_id, @oa_id);
|
||||||
|
SET @basic_parent := IFNULL(@basic_id, @office_parent);
|
||||||
|
|
||||||
|
-- ---------- AI办公(页面,挂在办公下) ----------
|
||||||
|
INSERT INTO yz_system_menu
|
||||||
|
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
|
||||||
|
SELECT @office_parent, 'AI办公', '/apps/oa/ai', '/apps/oa/ai/index.vue',
|
||||||
|
'MagicStick', 91, 1, 1, '[2]', '["web"]', 2, 'system',
|
||||||
|
'AI 广场:选择接入与模型进行场景对话,支持图片输入、系统提示词与多会话管理'
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @office_parent IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/ai' AND delete_time IS NULL) x);
|
||||||
|
|
||||||
|
SET @ai_page_id := (
|
||||||
|
SELECT id FROM yz_system_menu
|
||||||
|
WHERE path = '/apps/oa/ai' AND delete_time IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE yz_system_menu
|
||||||
|
SET pid = IFNULL(@office_parent, pid),
|
||||||
|
sort = 91,
|
||||||
|
platforms = '["web"]',
|
||||||
|
is_visible = 1
|
||||||
|
WHERE path = '/apps/oa/ai' AND delete_time IS NULL;
|
||||||
|
|
||||||
|
-- ---------- AI设置(页面,挂在 OA 基础设置下) ----------
|
||||||
|
INSERT INTO yz_system_menu
|
||||||
|
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
|
||||||
|
SELECT @basic_parent, 'AI设置', '/basic-settings/ai', '/basicSettings/ai/index.vue',
|
||||||
|
'Setting', 9, 1, 1, '[2]', '["web"]', 2, 'system',
|
||||||
|
'租户级 AI 接入配置:填写接口地址与 API Key,支持 OpenAI 兼容与 Anthropic,可测试连通性'
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @basic_parent IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/basic-settings/ai' AND delete_time IS NULL) x);
|
||||||
|
|
||||||
|
SET @ai_settings_id := (
|
||||||
|
SELECT id FROM yz_system_menu
|
||||||
|
WHERE path = '/basic-settings/ai' AND delete_time IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE yz_system_menu
|
||||||
|
SET pid = IFNULL(@basic_parent, pid),
|
||||||
|
sort = 9,
|
||||||
|
platforms = '["web"]',
|
||||||
|
is_visible = 1
|
||||||
|
WHERE path = '/basic-settings/ai' AND delete_time IS NULL;
|
||||||
|
|
||||||
|
-- ---------- 角色授权:把菜单追加到所有租户端角色的 rights ----------
|
||||||
|
-- 只处理 rights 非空(显式授权)的角色;rights 为空表示全权限,无需追加。
|
||||||
|
UPDATE yz_system_admin_role
|
||||||
|
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @ai_page_id) AS CHAR)
|
||||||
|
WHERE cid = 2
|
||||||
|
AND rights IS NOT NULL AND TRIM(rights) <> '' AND JSON_VALID(rights)
|
||||||
|
AND @ai_page_id IS NOT NULL
|
||||||
|
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@ai_page_id AS JSON));
|
||||||
|
|
||||||
|
UPDATE yz_system_admin_role
|
||||||
|
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @ai_settings_id) AS CHAR)
|
||||||
|
WHERE cid = 2
|
||||||
|
AND rights IS NOT NULL AND TRIM(rights) <> '' AND JSON_VALID(rights)
|
||||||
|
AND @ai_settings_id IS NOT NULL
|
||||||
|
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@ai_settings_id AS JSON));
|
||||||
|
|
||||||
|
-- ---------- 结果校验 ----------
|
||||||
|
SELECT id, pid, title, path, component_path, platforms, type, sort, is_visible
|
||||||
|
FROM yz_system_menu
|
||||||
|
WHERE delete_time IS NULL
|
||||||
|
AND (path = '/apps/oa/ai' OR path = '/basic-settings/ai')
|
||||||
|
ORDER BY pid, sort;
|
||||||
|
|
||||||
|
SELECT id, name, tenant_id,
|
||||||
|
JSON_CONTAINS(CAST(rights AS JSON), CAST(@ai_page_id AS JSON)) AS has_ai_page,
|
||||||
|
JSON_CONTAINS(CAST(rights AS JSON), CAST(@ai_settings_id AS JSON)) AS has_ai_settings
|
||||||
|
FROM yz_system_admin_role
|
||||||
|
WHERE cid = 2 AND rights IS NOT NULL AND TRIM(rights) <> ''
|
||||||
|
ORDER BY tenant_id, id;
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- OA 考勤管理 —— 菜单配置(yz_system_menu)
|
||||||
|
-- 幂等脚本:按 path 判重,重复执行不会产生重复菜单
|
||||||
|
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < oa_attendance_menus.sql
|
||||||
|
--
|
||||||
|
-- 挂载位置:OA → 办公(个人办公),直接是页面,不再套「排班考勤」目录
|
||||||
|
-- OA
|
||||||
|
-- └─ 办公(/apps/oa/personal,找不到时回落到 OA 根)
|
||||||
|
-- ├─ 工作排班(Web,/apps/oa/scheduling/board)
|
||||||
|
-- ├─ 考勤管理(Web,/apps/oa/attendance) ← 本脚本
|
||||||
|
-- └─ 考勤打卡(App,/apps/oa/attendance/clock) ← 本脚本
|
||||||
|
--
|
||||||
|
-- 页面:
|
||||||
|
-- backend/src/views/apps/oa/attendance/index.vue 考勤管理(考勤汇总 + 考勤明细)
|
||||||
|
-- uniapp/pages/oa/attendance/index.vue 考勤打卡(移动端)
|
||||||
|
--
|
||||||
|
-- 说明:
|
||||||
|
-- 1. 「考勤管理」是直接页面(type=2),点菜单即打开,不再有子菜单;
|
||||||
|
-- 2. 打卡动作在移动端完成,Web 端只做考勤汇总 / 明细 / 缺卡名单 / 备注,
|
||||||
|
-- 因此「考勤管理」platforms 为 ["web"];
|
||||||
|
-- 3. 「考勤打卡」platforms 为 ["app"],通过 uniapp 菜单跳转原生页面
|
||||||
|
-- (uniapp/utils/nav.js 的 NATIVE_RULES 按「考勤」匹配);
|
||||||
|
-- 4. 历史版本创建的「排班考勤」目录不再使用,脚本会把它隐藏(保留数据);
|
||||||
|
-- 其下的「工作排班」直接上移到「办公」;
|
||||||
|
-- 5. 租户端角色(cid=2)的 rights 是显式菜单 ID 列表,只插菜单不授权
|
||||||
|
-- 会被过滤掉,因此本脚本把菜单 ID 追加到角色 rights。
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
SET @oa_id := (
|
||||||
|
SELECT id FROM yz_system_menu
|
||||||
|
WHERE path = '/apps/oa' AND delete_time IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 「办公」目录:优先同名菜单,其次 /apps/oa/personal;都没有则回落 OA 根
|
||||||
|
SET @office_id := (
|
||||||
|
SELECT id FROM yz_system_menu
|
||||||
|
WHERE delete_time IS NULL
|
||||||
|
AND type = 1
|
||||||
|
AND (title = '办公' OR title = '个人办公' OR path = '/apps/oa/personal')
|
||||||
|
ORDER BY (title = '办公') DESC, (path = '/apps/oa/personal') DESC, sort, id
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @parent_id := IFNULL(@office_id, @oa_id);
|
||||||
|
|
||||||
|
-- ---------- 考勤管理(Web 页面,直接挂在办公下) ----------
|
||||||
|
INSERT INTO yz_system_menu
|
||||||
|
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
|
||||||
|
SELECT @parent_id, '考勤管理', '/apps/oa/attendance', '/apps/oa/attendance/index.vue',
|
||||||
|
'Stopwatch', 89, 1, 1, '[2]', '["web"]', 2, 'system',
|
||||||
|
'移动端签到签退数据,支持月/季/年日历汇总、全员考勤卡、缺卡名单与备注'
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @parent_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/attendance' AND delete_time IS NULL) x);
|
||||||
|
|
||||||
|
-- 已存在则纠正为「直接页面」:改名、上移、确保是页面与 Web 端可见
|
||||||
|
UPDATE yz_system_menu
|
||||||
|
SET title = '考勤管理',
|
||||||
|
pid = IFNULL(@parent_id, pid),
|
||||||
|
sort = 89,
|
||||||
|
type = 2,
|
||||||
|
component_path = '/apps/oa/attendance/index.vue',
|
||||||
|
platforms = '["web"]',
|
||||||
|
is_visible = 1
|
||||||
|
WHERE path = '/apps/oa/attendance' AND delete_time IS NULL;
|
||||||
|
|
||||||
|
SET @records_id := (
|
||||||
|
SELECT id FROM yz_system_menu
|
||||||
|
WHERE path = '/apps/oa/attendance' AND delete_time IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- 考勤打卡(移动端页面,同样直接挂在办公下) ----------
|
||||||
|
INSERT INTO yz_system_menu
|
||||||
|
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
|
||||||
|
SELECT @parent_id, '考勤打卡', '/apps/oa/attendance/clock', NULL,
|
||||||
|
'AlarmClock', 90, 1, 1, '[2]', '["app"]', 2, 'system',
|
||||||
|
'小程序签到/签退,联动工作排班;未匹配排班时提醒但允许打卡'
|
||||||
|
FROM DUAL
|
||||||
|
WHERE @parent_id IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/attendance/clock' AND delete_time IS NULL) x);
|
||||||
|
|
||||||
|
UPDATE yz_system_menu
|
||||||
|
SET pid = IFNULL(@parent_id, pid),
|
||||||
|
sort = 90,
|
||||||
|
platforms = '["app"]',
|
||||||
|
is_visible = 1
|
||||||
|
WHERE path = '/apps/oa/attendance/clock' AND delete_time IS NULL;
|
||||||
|
|
||||||
|
SET @clock_id := (
|
||||||
|
SELECT id FROM yz_system_menu
|
||||||
|
WHERE path = '/apps/oa/attendance/clock' AND delete_time IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- 工作排班上移:不再保留「排班考勤」目录 ----------
|
||||||
|
UPDATE yz_system_menu
|
||||||
|
SET pid = IFNULL(@parent_id, pid), sort = 88
|
||||||
|
WHERE path = '/apps/oa/scheduling/board'
|
||||||
|
AND delete_time IS NULL
|
||||||
|
AND @parent_id IS NOT NULL
|
||||||
|
AND (pid IS NULL OR pid <> @parent_id);
|
||||||
|
|
||||||
|
-- 历史的「排班考勤」目录:不再使用,隐藏(保留数据,避免影响已有角色授权)
|
||||||
|
-- 仅当目录下已无可见子菜单时才隐藏,避免误伤租户自行添加的子菜单
|
||||||
|
UPDATE yz_system_menu
|
||||||
|
SET is_visible = 0
|
||||||
|
WHERE path = '/apps/oa/scheduling'
|
||||||
|
AND type = 1
|
||||||
|
AND delete_time IS NULL
|
||||||
|
AND id NOT IN (
|
||||||
|
SELECT pid FROM (
|
||||||
|
SELECT pid FROM yz_system_menu
|
||||||
|
WHERE delete_time IS NULL AND is_visible = 1 AND pid > 0
|
||||||
|
) child
|
||||||
|
);
|
||||||
|
|
||||||
|
SET @board_id := (
|
||||||
|
SELECT id FROM yz_system_menu
|
||||||
|
WHERE path = '/apps/oa/scheduling/board' AND delete_time IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------- 角色授权:把菜单追加到所有租户端角色的 rights ----------
|
||||||
|
-- 只处理 rights 非空(显式授权)的角色;rights 为空表示全权限,无需追加。
|
||||||
|
UPDATE yz_system_admin_role
|
||||||
|
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @records_id) AS CHAR)
|
||||||
|
WHERE cid = 2
|
||||||
|
AND rights IS NOT NULL AND TRIM(rights) <> '' AND JSON_VALID(rights)
|
||||||
|
AND @records_id IS NOT NULL
|
||||||
|
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@records_id AS JSON));
|
||||||
|
|
||||||
|
UPDATE yz_system_admin_role
|
||||||
|
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @clock_id) AS CHAR)
|
||||||
|
WHERE cid = 2
|
||||||
|
AND rights IS NOT NULL AND TRIM(rights) <> '' AND JSON_VALID(rights)
|
||||||
|
AND @clock_id IS NOT NULL
|
||||||
|
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@clock_id AS JSON));
|
||||||
|
|
||||||
|
UPDATE yz_system_admin_role
|
||||||
|
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @board_id) AS CHAR)
|
||||||
|
WHERE cid = 2
|
||||||
|
AND rights IS NOT NULL AND TRIM(rights) <> '' AND JSON_VALID(rights)
|
||||||
|
AND @board_id IS NOT NULL
|
||||||
|
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@board_id AS JSON));
|
||||||
|
|
||||||
|
-- ---------- 结果校验 ----------
|
||||||
|
SELECT id, pid, title, path, component_path, platforms, type, sort, is_visible
|
||||||
|
FROM yz_system_menu
|
||||||
|
WHERE delete_time IS NULL
|
||||||
|
AND (path LIKE '/apps/oa/attendance%' OR path LIKE '/apps/oa/scheduling%')
|
||||||
|
ORDER BY pid, sort;
|
||||||
|
|
||||||
|
SELECT id, name, tenant_id,
|
||||||
|
JSON_CONTAINS(CAST(rights AS JSON), CAST(@records_id AS JSON)) AS has_attendance,
|
||||||
|
JSON_CONTAINS(CAST(rights AS JSON), CAST(@clock_id AS JSON)) AS has_clock
|
||||||
|
FROM yz_system_admin_role
|
||||||
|
WHERE cid = 2 AND rights IS NOT NULL AND TRIM(rights) <> ''
|
||||||
|
ORDER BY tenant_id, id;
|
||||||
@@ -3,10 +3,12 @@
|
|||||||
-- 幂等脚本:按 path 判重,重复执行不会产生重复菜单
|
-- 幂等脚本:按 path 判重,重复执行不会产生重复菜单
|
||||||
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < oa_scheduling_menus.sql
|
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < oa_scheduling_menus.sql
|
||||||
--
|
--
|
||||||
-- 挂载位置:OA 应用根(path = '/apps/oa')
|
-- 挂载位置:OA → 办公(个人办公),直接是页面,不再套「排班考勤」目录
|
||||||
-- OA
|
-- OA
|
||||||
-- └─ 排班考勤(目录,/apps/oa/scheduling)
|
-- └─ 办公
|
||||||
-- └─ 工作排班(页面,/apps/oa/scheduling/board)
|
-- ├─ 工作排班(页面,/apps/oa/scheduling/board) ← 本脚本
|
||||||
|
-- ├─ 考勤管理(页面,/apps/oa/attendance) ← oa_attendance_menus.sql
|
||||||
|
-- └─ 考勤打卡(App,/apps/oa/attendance/clock) ← oa_attendance_menus.sql
|
||||||
--
|
--
|
||||||
-- 对应页面:backend/src/views/apps/oa/scheduling/index.vue
|
-- 对应页面:backend/src/views/apps/oa/scheduling/index.vue
|
||||||
-- 同一页面内完成:班次管理、排班方案(公司/部门/小组/指定员工)、
|
-- 同一页面内完成:班次管理、排班方案(公司/部门/小组/指定员工)、
|
||||||
@@ -14,11 +16,11 @@
|
|||||||
--
|
--
|
||||||
-- 说明:
|
-- 说明:
|
||||||
-- 1. 排班看板是 Web 管理端功能,platforms 显式为 ["web"],
|
-- 1. 排班看板是 Web 管理端功能,platforms 显式为 ["web"],
|
||||||
-- 移动端菜单不出现(后续若开放员工查看排班,再补 APP 端页面)。
|
-- 移动端菜单不出现;
|
||||||
-- 2. 租户端角色(cid=2)的 rights 是显式菜单 ID 列表,菜单接口会按
|
-- 2. 历史版本创建的「排班考勤」目录不再使用,脚本会把它隐藏(保留数据);
|
||||||
-- rights 过滤。只插菜单不授权,会出现「目录可见但页面被过滤」,
|
-- 3. 租户端角色(cid=2)的 rights 是显式菜单 ID 列表,菜单接口会按
|
||||||
-- 因此本脚本同时把两个菜单 ID 追加到角色 rights。
|
-- rights 过滤。只插菜单不授权,会出现「菜单存在但被过滤」,
|
||||||
-- 目录与页面都必须授权,缺一不可。
|
-- 因此本脚本同时把菜单 ID 追加到角色 rights。
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
SET @oa_id := (
|
SET @oa_id := (
|
||||||
@@ -27,32 +29,51 @@ SET @oa_id := (
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ---------- 排班考勤(目录) ----------
|
-- 「办公」目录:优先同名菜单,其次 /apps/oa/personal;都没有则回落 OA 根
|
||||||
INSERT INTO yz_system_menu
|
SET @office_id := (
|
||||||
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
|
|
||||||
SELECT @oa_id, '排班考勤', '/apps/oa/scheduling', NULL,
|
|
||||||
'Calendar', 88, 1, 1, '[2]', '["web"]', 1, 'system',
|
|
||||||
'按公司、部门、小组或指定员工制定排班方案,支持临时调配'
|
|
||||||
FROM DUAL
|
|
||||||
WHERE @oa_id IS NOT NULL
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/scheduling' AND delete_time IS NULL) x);
|
|
||||||
|
|
||||||
SET @scheduling_id := (
|
|
||||||
SELECT id FROM yz_system_menu
|
SELECT id FROM yz_system_menu
|
||||||
WHERE path = '/apps/oa/scheduling' AND delete_time IS NULL
|
WHERE delete_time IS NULL
|
||||||
|
AND type = 1
|
||||||
|
AND (title = '办公' OR title = '个人办公' OR path = '/apps/oa/personal')
|
||||||
|
ORDER BY (title = '办公') DESC, (path = '/apps/oa/personal') DESC, sort, id
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ---------- 工作排班(页面) ----------
|
SET @parent_id := IFNULL(@office_id, @oa_id);
|
||||||
|
|
||||||
|
-- ---------- 工作排班(页面,直接挂在办公下) ----------
|
||||||
INSERT INTO yz_system_menu
|
INSERT INTO yz_system_menu
|
||||||
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
|
(pid, title, path, component_path, icon, sort, status, is_visible, views, platforms, type, creater, remark)
|
||||||
SELECT @scheduling_id, '工作排班', '/apps/oa/scheduling/board', '/apps/oa/scheduling/index.vue',
|
SELECT @parent_id, '工作排班', '/apps/oa/scheduling/board', '/apps/oa/scheduling/index.vue',
|
||||||
'Clock', 1, 1, 1, '[2]', '["web"]', 2, 'system',
|
'Clock', 88, 1, 1, '[2]', '["web"]', 2, 'system',
|
||||||
'排班看板:按周查看排班,支持方案发布、单条调班与批量临时排班'
|
'排班看板:按周查看排班,支持方案发布、单条调班与批量临时排班'
|
||||||
FROM DUAL
|
FROM DUAL
|
||||||
WHERE @scheduling_id IS NOT NULL
|
WHERE @parent_id IS NOT NULL
|
||||||
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/scheduling/board' AND delete_time IS NULL) x);
|
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/apps/oa/scheduling/board' AND delete_time IS NULL) x);
|
||||||
|
|
||||||
|
-- 已存在则上移到「办公」,并纠正为 Web 页面
|
||||||
|
UPDATE yz_system_menu
|
||||||
|
SET pid = IFNULL(@parent_id, pid),
|
||||||
|
sort = 88,
|
||||||
|
type = 2,
|
||||||
|
platforms = '["web"]',
|
||||||
|
is_visible = 1
|
||||||
|
WHERE path = '/apps/oa/scheduling/board' AND delete_time IS NULL;
|
||||||
|
|
||||||
|
-- 历史的「排班考勤」目录:不再使用,隐藏(保留数据)
|
||||||
|
-- 仅当目录下已无可见子菜单时才隐藏,避免误伤租户自行添加的子菜单
|
||||||
|
UPDATE yz_system_menu
|
||||||
|
SET is_visible = 0
|
||||||
|
WHERE path = '/apps/oa/scheduling'
|
||||||
|
AND type = 1
|
||||||
|
AND delete_time IS NULL
|
||||||
|
AND id NOT IN (
|
||||||
|
SELECT pid FROM (
|
||||||
|
SELECT pid FROM yz_system_menu
|
||||||
|
WHERE delete_time IS NULL AND is_visible = 1 AND pid > 0
|
||||||
|
) child
|
||||||
|
);
|
||||||
|
|
||||||
SET @board_id := (
|
SET @board_id := (
|
||||||
SELECT id FROM yz_system_menu
|
SELECT id FROM yz_system_menu
|
||||||
WHERE path = '/apps/oa/scheduling/board' AND delete_time IS NULL
|
WHERE path = '/apps/oa/scheduling/board' AND delete_time IS NULL
|
||||||
@@ -61,16 +82,6 @@ SET @board_id := (
|
|||||||
|
|
||||||
-- ---------- 角色授权:把菜单追加到所有租户端角色的 rights ----------
|
-- ---------- 角色授权:把菜单追加到所有租户端角色的 rights ----------
|
||||||
-- 只处理 rights 非空(显式授权)的角色;rights 为空表示全权限,无需追加。
|
-- 只处理 rights 非空(显式授权)的角色;rights 为空表示全权限,无需追加。
|
||||||
|
|
||||||
UPDATE yz_system_admin_role
|
|
||||||
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @scheduling_id) AS CHAR)
|
|
||||||
WHERE cid = 2
|
|
||||||
AND rights IS NOT NULL
|
|
||||||
AND TRIM(rights) <> ''
|
|
||||||
AND JSON_VALID(rights)
|
|
||||||
AND @scheduling_id IS NOT NULL
|
|
||||||
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@scheduling_id AS JSON));
|
|
||||||
|
|
||||||
UPDATE yz_system_admin_role
|
UPDATE yz_system_admin_role
|
||||||
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @board_id) AS CHAR)
|
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @board_id) AS CHAR)
|
||||||
WHERE cid = 2
|
WHERE cid = 2
|
||||||
@@ -81,13 +92,13 @@ WHERE cid = 2
|
|||||||
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@board_id AS JSON));
|
AND NOT JSON_CONTAINS(CAST(rights AS JSON), CAST(@board_id AS JSON));
|
||||||
|
|
||||||
-- ---------- 结果校验 ----------
|
-- ---------- 结果校验 ----------
|
||||||
SELECT id, pid, title, path, component_path, platforms, type, sort
|
SELECT id, pid, title, path, component_path, platforms, type, sort, is_visible
|
||||||
FROM yz_system_menu
|
FROM yz_system_menu
|
||||||
WHERE delete_time IS NULL AND path LIKE '/apps/oa/scheduling%'
|
WHERE delete_time IS NULL
|
||||||
|
AND (path LIKE '/apps/oa/scheduling%' OR path = '/apps/oa/attendance')
|
||||||
ORDER BY pid, sort;
|
ORDER BY pid, sort;
|
||||||
|
|
||||||
SELECT id, name, tenant_id,
|
SELECT id, name, tenant_id,
|
||||||
JSON_CONTAINS(CAST(rights AS JSON), CAST(@scheduling_id AS JSON)) AS has_dir,
|
|
||||||
JSON_CONTAINS(CAST(rights AS JSON), CAST(@board_id AS JSON)) AS has_board
|
JSON_CONTAINS(CAST(rights AS JSON), CAST(@board_id AS JSON)) AS has_board
|
||||||
FROM yz_system_admin_role
|
FROM yz_system_admin_role
|
||||||
WHERE cid = 2 AND rights IS NOT NULL AND TRIM(rights) <> ''
|
WHERE cid = 2 AND rights IS NOT NULL AND TRIM(rights) <> ''
|
||||||
|
|||||||
@@ -207,6 +207,8 @@ func Init(_ string) {
|
|||||||
new(OaSchedulePlan),
|
new(OaSchedulePlan),
|
||||||
new(OaScheduleEntry),
|
new(OaScheduleEntry),
|
||||||
new(OaScheduleChange),
|
new(OaScheduleChange),
|
||||||
|
// 考勤打卡(docs/sql/create_oa_attendance.sql)
|
||||||
|
new(OaAttendance),
|
||||||
new(OaHoliday),
|
new(OaHoliday),
|
||||||
new(OaNotice),
|
new(OaNotice),
|
||||||
// 工作日报(docs/sql/create_oa_dailyreport.sql)
|
// 工作日报(docs/sql/create_oa_dailyreport.sql)
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// 考勤与排班的匹配状态(match_status)
|
||||||
|
const (
|
||||||
|
OaAttendMatchUnknown int8 = 0 // 未判定(历史数据)
|
||||||
|
OaAttendMatchOK int8 = 1 // 已匹配到当日排班
|
||||||
|
OaAttendMatchMissing int8 = 2 // 未匹配到排班(仍允许打卡,只做提醒)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 单次打卡结果(clock_in_status / clock_out_status)
|
||||||
|
// - 签到侧:1 正常 2 迟到 3 无排班打卡 4 不在班次时段
|
||||||
|
// - 签退侧:1 正常 2 早退 3 无排班打卡 4 不在班次时段
|
||||||
|
const (
|
||||||
|
OaAttendClockNone int8 = 0 // 未打卡
|
||||||
|
OaAttendClockNormal int8 = 1 // 正常
|
||||||
|
OaAttendClockLate int8 = 2 // 迟到(签退侧表示早退)
|
||||||
|
OaAttendClockNoShift int8 = 3 // 未匹配排班时的打卡
|
||||||
|
OaAttendClockOffTime int8 = 4 // 不在班次时段内
|
||||||
|
)
|
||||||
|
|
||||||
|
// 考勤数据来源(source)
|
||||||
|
const (
|
||||||
|
OaAttendSourceApp int8 = 1 // 移动端打卡
|
||||||
|
OaAttendSourceWeb int8 = 2 // 后台补录
|
||||||
|
)
|
||||||
|
|
||||||
|
// OaAttendance OA 考勤打卡 yz_backend_oa_attendance
|
||||||
|
//
|
||||||
|
// 一人一天一条:签到与签退写同一行,便于按「人 × 日期」做统计与展示。
|
||||||
|
// 打卡时会实时联动排班明细(yz_backend_oa_schedule_entry):
|
||||||
|
// - 匹配上:把班次名称与上下班时间快照到本表(排班后续被改也不影响历史判定);
|
||||||
|
// - 匹配不上:match_status=2,仍正常落库,clock_*_note 记录提醒文案。
|
||||||
|
//
|
||||||
|
// 唯一键为 (tid, uid, attend_date):uid 是登录身份,员工档案缺失时也能打卡,
|
||||||
|
// employee_id=0 表示未绑定员工(后台只能按账号/姓名检索到)。
|
||||||
|
type OaAttendance struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
Tid int `orm:"column(tid)" json:"tid"`
|
||||||
|
Uid uint64 `orm:"column(uid);default(0)" json:"uid"`
|
||||||
|
EmployeeID uint `orm:"column(employee_id);default(0)" json:"employee_id"`
|
||||||
|
EmployeeName string `orm:"column(employee_name);size(50);default()" json:"employee_name"`
|
||||||
|
EmployeeAccount string `orm:"column(employee_account);size(50);default()" json:"employee_account"`
|
||||||
|
OrgID uint64 `orm:"column(org_id);default(0)" json:"org_id"`
|
||||||
|
OrgName string `orm:"column(org_name);size(128);default()" json:"org_name"`
|
||||||
|
Position string `orm:"column(position);size(50);default()" json:"position"`
|
||||||
|
AttendDate string `orm:"column(attend_date);type(date)" json:"attend_date"`
|
||||||
|
EntryID uint64 `orm:"column(entry_id);default(0)" json:"entry_id"`
|
||||||
|
ShiftID uint64 `orm:"column(shift_id);default(0)" json:"shift_id"`
|
||||||
|
ShiftName string `orm:"column(shift_name);size(50);default()" json:"shift_name"`
|
||||||
|
ShortName string `orm:"column(short_name);size(20);default()" json:"short_name"`
|
||||||
|
Color string `orm:"column(color);size(20);default(#409EFF)" json:"color"`
|
||||||
|
WorkStart string `orm:"column(work_start);size(8);default()" json:"work_start"`
|
||||||
|
WorkEnd string `orm:"column(work_end);size(8);default()" json:"work_end"`
|
||||||
|
MatchStatus int8 `orm:"column(match_status);default(0)" json:"match_status"`
|
||||||
|
ClockInTime *time.Time `orm:"column(clock_in_time);type(datetime);null" json:"clock_in_time"`
|
||||||
|
ClockInStatus int8 `orm:"column(clock_in_status);default(0)" json:"clock_in_status"`
|
||||||
|
ClockInNote string `orm:"column(clock_in_note);size(255);default()" json:"clock_in_note"`
|
||||||
|
ClockOutTime *time.Time `orm:"column(clock_out_time);type(datetime);null" json:"clock_out_time"`
|
||||||
|
ClockOutStatus int8 `orm:"column(clock_out_status);default(0)" json:"clock_out_status"`
|
||||||
|
ClockOutNote string `orm:"column(clock_out_note);size(255);default()" json:"clock_out_note"`
|
||||||
|
Location string `orm:"column(location);size(255);default()" json:"location"`
|
||||||
|
Remark string `orm:"column(remark);size(255);default()" json:"remark"`
|
||||||
|
Source int8 `orm:"column(source);default(1)" json:"source"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||||
|
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *OaAttendance) TableName() string {
|
||||||
|
return "yz_backend_oa_attendance"
|
||||||
|
}
|
||||||
@@ -66,6 +66,14 @@ var PlatformSiteSettingDefs = []PlatformSiteSettingDef{
|
|||||||
{Tag: "privacy", Title: "隐私政策", Remark: "隐私政策链接,如 /page/privacy | group:政策条款"},
|
{Tag: "privacy", Title: "隐私政策", Remark: "隐私政策链接,如 /page/privacy | group:政策条款"},
|
||||||
{Tag: "terms", Title: "使用条款", Remark: "使用条款链接,如 /page/terms | group:政策条款"},
|
{Tag: "terms", Title: "使用条款", Remark: "使用条款链接,如 /page/terms | group:政策条款"},
|
||||||
|
|
||||||
|
// 平台信息(系统设置 → 平台设置 → 平台信息)
|
||||||
|
{Tag: "logo_square", Title: "平台 Logo(正方形)", Remark: "正方形图标,用于后台顶栏等小尺寸场景 | group:平台信息 | type:image"},
|
||||||
|
{Tag: "logo_rect", Title: "平台 Logo(长方形)", Remark: "横向品牌图,用于页头等较宽场景 | group:平台信息 | type:image"},
|
||||||
|
{Tag: "logo_square_dark", Title: "平台 Logo(正方形·深色主题)", Remark: "深色主题下替换方形 Logo,留空沿用浅色主题版本 | group:平台信息 | type:image"},
|
||||||
|
{Tag: "logo_rect_dark", Title: "平台 Logo(长方形·深色主题)", Remark: "深色主题下替换横向 Logo,留空回退官网浅色 Logo | group:平台信息 | type:image"},
|
||||||
|
{Tag: "ico", Title: "站点图标(favicon)", Remark: "浏览器标签页图标,建议 32×32 / 64×64 的 .ico 或 PNG | group:平台信息 | type:image"},
|
||||||
|
{Tag: "platform_intro", Title: "平台简介", Remark: "平台的一句话简介,用于登录页/关于页等品牌说明 | group:平台信息 | type:textarea"},
|
||||||
|
|
||||||
// 登录页品牌(统一认证中心 /auth/login 左侧展示)
|
// 登录页品牌(统一认证中心 /auth/login 左侧展示)
|
||||||
{Tag: "login_title", Title: "登录页主标题", Remark: "登录页左侧主标题,留空则使用默认文案 | group:登录页设置"},
|
{Tag: "login_title", Title: "登录页主标题", Remark: "登录页左侧主标题,留空则使用默认文案 | group:登录页设置"},
|
||||||
{Tag: "login_subtitle", Title: "登录页副标题", Remark: "登录页左侧副标题,留空则使用默认文案 | group:登录页设置"},
|
{Tag: "login_subtitle", Title: "登录页副标题", Remark: "登录页左侧副标题,留空则使用默认文案 | group:登录页设置"},
|
||||||
|
|||||||
@@ -110,6 +110,12 @@ func Register() {
|
|||||||
// OA 工作台仪表盘(移动端首屏顶部聚合:待办/报销统计 + 通知公告 + 今日日程)
|
// OA 工作台仪表盘(移动端首屏顶部聚合:待办/报销统计 + 通知公告 + 今日日程)
|
||||||
beego.Router("/api/oa/dashboard", &controllers.AppOaController{}, "get:Dashboard")
|
beego.Router("/api/oa/dashboard", &controllers.AppOaController{}, "get:Dashboard")
|
||||||
|
|
||||||
|
// OA 考勤打卡(移动端签到/签退,联动工作排班;后台 /backend/oa/attendance/* 同源)
|
||||||
|
beego.Router("/api/oa/attendance/today", &controllers.AppOaController{}, "get:AttendanceToday")
|
||||||
|
beego.Router("/api/oa/attendance/clock", &controllers.AppOaController{}, "post:AttendanceClock")
|
||||||
|
beego.Router("/api/oa/attendance/records", &controllers.AppOaController{}, "get:AttendanceRecords")
|
||||||
|
beego.Router("/api/oa/attendance/stats", &controllers.AppOaController{}, "get:AttendanceStats")
|
||||||
|
|
||||||
// OA 工作日程(日历待办,与 /backend/oa/schedule/* 同一套数据;小程序「工作日程」页面使用)
|
// OA 工作日程(日历待办,与 /backend/oa/schedule/* 同一套数据;小程序「工作日程」页面使用)
|
||||||
beego.Router("/api/oa/schedule/list", &controllers.AppOaController{}, "get:ScheduleList")
|
beego.Router("/api/oa/schedule/list", &controllers.AppOaController{}, "get:ScheduleList")
|
||||||
beego.Router("/api/oa/schedule/detail/:id", &controllers.AppOaController{}, "get:ScheduleDetail")
|
beego.Router("/api/oa/schedule/detail/:id", &controllers.AppOaController{}, "get:ScheduleDetail")
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ func RegisterAuthRoutes() {
|
|||||||
beego.Router("/backend/loginLogs/:id", &controllers.BackendLoginLogController{}, "get:Detail;delete:Delete")
|
beego.Router("/backend/loginLogs/:id", &controllers.BackendLoginLogController{}, "get:Detail;delete:Delete")
|
||||||
beego.Router("/backend/loginLogs/batchDelete", &controllers.BackendLoginLogController{}, "post:BatchDelete")
|
beego.Router("/backend/loginLogs/batchDelete", &controllers.BackendLoginLogController{}, "post:BatchDelete")
|
||||||
|
|
||||||
|
// 后台顶栏品牌(Logo 取自平台「官网管理 - 站点设置」)
|
||||||
|
beego.Router("/backend/brand", &controllers.BackendSiteSettingsController{}, "get:GetBrand")
|
||||||
|
|
||||||
// 租户站点设置
|
// 租户站点设置
|
||||||
beego.Router("/backend/normalInfos", &controllers.BackendSiteSettingsController{}, "get:GetNormalInfos")
|
beego.Router("/backend/normalInfos", &controllers.BackendSiteSettingsController{}, "get:GetNormalInfos")
|
||||||
beego.Router("/backend/saveNormalInfos", &controllers.BackendSiteSettingsController{}, "post:SaveNormalInfos")
|
beego.Router("/backend/saveNormalInfos", &controllers.BackendSiteSettingsController{}, "post:SaveNormalInfos")
|
||||||
@@ -371,6 +374,12 @@ func RegisterAuthRoutes() {
|
|||||||
beego.Router("/backend/oa/scheduling/entries/clear", &controllers.BackendOaSchedulingController{}, "post:ClearEntries")
|
beego.Router("/backend/oa/scheduling/entries/clear", &controllers.BackendOaSchedulingController{}, "post:ClearEntries")
|
||||||
beego.Router("/backend/oa/scheduling/changes", &controllers.BackendOaSchedulingController{}, "get:ChangeList")
|
beego.Router("/backend/oa/scheduling/changes", &controllers.BackendOaSchedulingController{}, "get:ChangeList")
|
||||||
|
|
||||||
|
// OA考勤打卡(移动端签到/签退,后台查看考勤记录与缺卡名单)
|
||||||
|
beego.Router("/backend/oa/attendance/records", &controllers.BackendOaAttendanceController{}, "get:RecordList")
|
||||||
|
beego.Router("/backend/oa/attendance/summary", &controllers.BackendOaAttendanceController{}, "get:Summary")
|
||||||
|
beego.Router("/backend/oa/attendance/missing", &controllers.BackendOaAttendanceController{}, "get:MissingList")
|
||||||
|
beego.Router("/backend/oa/attendance/remark/:id", &controllers.BackendOaAttendanceController{}, "post:RecordRemark")
|
||||||
|
|
||||||
// OA日程管理(日历待办)
|
// OA日程管理(日历待办)
|
||||||
beego.Router("/backend/oa/schedule/list", &controllers.BackendOaScheduleController{}, "get:List")
|
beego.Router("/backend/oa/schedule/list", &controllers.BackendOaScheduleController{}, "get:List")
|
||||||
beego.Router("/backend/oa/schedule/stats", &controllers.BackendOaScheduleController{}, "get:Stats")
|
beego.Router("/backend/oa/schedule/stats", &controllers.BackendOaScheduleController{}, "get:Stats")
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
// 老数据补齐:为「人员管理里有、用户管理里没有」的老员工补建登录账号。
|
||||||
|
//
|
||||||
|
// 背景:早期人员是直接在 OA 人员管理里手工录入的(离线导入),没有对应的登录账号,
|
||||||
|
// 于是「基础设置 → 用户管理」人少、「OA → 人员管理」人多,两边对不上。
|
||||||
|
//
|
||||||
|
// 本脚本走的是和「用户管理 → 添加用户」完全相同的服务链路:
|
||||||
|
// authsvc.SyncTenantUserAndGetIdentity(建/取统一身份,返回 identity_id)
|
||||||
|
// → services.BindTenantUser(写 yz_system_tenant_user,uid = identity_id)
|
||||||
|
// 员工档案已存在,按 (tid, account) 自动关联,不会重复建档、也不会丢部门/职位/排班。
|
||||||
|
//
|
||||||
|
// 用法(在 go/ 目录下运行,读取 conf/app.conf):
|
||||||
|
//
|
||||||
|
// go run scripts/syncemployeeusers/main.go -tid 2 预演:只列出会建哪些账号
|
||||||
|
// go run scripts/syncemployeeusers/main.go -tid 2 -apply 真正创建账号并打印初始密码
|
||||||
|
// go run scripts/syncemployeeusers/main.go -tid 2 -apply -password "Abc@123456"
|
||||||
|
//
|
||||||
|
// 安全提示:
|
||||||
|
// - 未指定 -password 时每个账号生成随机初始密码,执行完请立即转告本人并让其修改;
|
||||||
|
// - 若员工手机号/邮箱已属于另一个身份,脚本会跳过并提示,避免把两个人错误合并。
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
"server/pkg/passwordutil"
|
||||||
|
"server/services"
|
||||||
|
authsvc "server/services/auth"
|
||||||
|
|
||||||
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
|
"github.com/beego/beego/v2/client/orm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
tid := flag.Int64("tid", 0, "只处理指定企业;0 表示全部企业")
|
||||||
|
fixedPwd := flag.String("password", "", "统一初始密码;留空则每个账号随机生成")
|
||||||
|
apply := flag.Bool("apply", false, "真正写入(默认只预演)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if err := beego.LoadAppConfig("ini", "conf/app.conf"); err != nil {
|
||||||
|
fmt.Println("读取 conf/app.conf 失败:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
models.Init("")
|
||||||
|
|
||||||
|
type pending struct {
|
||||||
|
empID int64
|
||||||
|
tid int64
|
||||||
|
account string
|
||||||
|
name string
|
||||||
|
phone string
|
||||||
|
email string
|
||||||
|
gender int64
|
||||||
|
birth string
|
||||||
|
dept string
|
||||||
|
status int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// 员工侧:所有未删除员工
|
||||||
|
empQuery := models.Orm.QueryTable(new(models.BackendEmployee)).Filter("delete_time__isnull", true)
|
||||||
|
if *tid > 0 {
|
||||||
|
empQuery = empQuery.Filter("tid", *tid)
|
||||||
|
}
|
||||||
|
var employees []models.BackendEmployee
|
||||||
|
if _, err := empQuery.OrderBy("tid", "id").All(&employees); err != nil && err != orm.ErrNoRows {
|
||||||
|
fmt.Println("读取员工失败:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户侧:(tid, account) 已有的账号
|
||||||
|
userQuery := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("delete_time__isnull", true)
|
||||||
|
if *tid > 0 {
|
||||||
|
userQuery = userQuery.Filter("tid", *tid)
|
||||||
|
}
|
||||||
|
var users []models.SystemTenantUser
|
||||||
|
if _, err := userQuery.All(&users); err != nil && err != orm.ErrNoRows {
|
||||||
|
fmt.Println("读取用户失败:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
exists := make(map[string]struct{}, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
if u.Account == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
exists[fmt.Sprintf("%d|%s", u.Tid, strings.TrimSpace(*u.Account))] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var todo []pending
|
||||||
|
for _, e := range employees {
|
||||||
|
if e.Tid == nil || *e.Tid <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
account := strings.TrimSpace(e.Account)
|
||||||
|
if account == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := exists[fmt.Sprintf("%d|%s", *e.Tid, account)]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := pending{
|
||||||
|
empID: int64(e.ID),
|
||||||
|
tid: int64(*e.Tid),
|
||||||
|
account: account,
|
||||||
|
name: strings.TrimSpace(e.Name),
|
||||||
|
gender: int64(e.Gender),
|
||||||
|
status: int64(e.AccountStatus),
|
||||||
|
}
|
||||||
|
if item.name == "" {
|
||||||
|
item.name = account
|
||||||
|
}
|
||||||
|
if e.Phone != nil {
|
||||||
|
item.phone = strings.TrimSpace(*e.Phone)
|
||||||
|
}
|
||||||
|
if e.Email != nil {
|
||||||
|
item.email = strings.TrimSpace(*e.Email)
|
||||||
|
}
|
||||||
|
if e.Birthday != nil {
|
||||||
|
item.birth = e.Birthday.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
if e.Department != nil {
|
||||||
|
item.dept = strings.TrimSpace(*e.Department)
|
||||||
|
}
|
||||||
|
todo = append(todo, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(todo) == 0 {
|
||||||
|
fmt.Println("没有需要补建账号的老员工,两边已经一致。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("待补建账号:%d 个\n", len(todo))
|
||||||
|
for _, item := range todo {
|
||||||
|
fmt.Printf(" tid=%d 员工id=%d account=%-16s name=%s dept=%s phone=%s\n",
|
||||||
|
item.tid, item.empID, item.account, item.name, item.dept, item.phone)
|
||||||
|
}
|
||||||
|
if !*apply {
|
||||||
|
fmt.Println("\n预演结束(未写入)。确认无误后加 -apply 执行。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
created := 0
|
||||||
|
for _, item := range todo {
|
||||||
|
if conflict := identityConflict(item.account, item.phone, item.email); conflict != "" {
|
||||||
|
fmt.Printf(" 跳过 %s:%s\n", item.account, conflict)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
plain := *fixedPwd
|
||||||
|
if plain == "" {
|
||||||
|
plain = randomPassword()
|
||||||
|
}
|
||||||
|
hashed, err := passwordutil.Hash(plain)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" 跳过 %s:密码加密失败 %v\n", item.account, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
uid, err := authsvc.SyncTenantUserAndGetIdentity(authsvc.SyncTenantUserInput{
|
||||||
|
Tid: uint64(item.tid),
|
||||||
|
Account: item.account,
|
||||||
|
Name: item.name,
|
||||||
|
Phone: item.phone,
|
||||||
|
Email: item.email,
|
||||||
|
PasswordHash: hashed,
|
||||||
|
Status: 1,
|
||||||
|
OrgID: parseUint(item.dept),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" 跳过 %s:创建统一身份失败 %v\n", item.account, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
account := item.account
|
||||||
|
name := item.name
|
||||||
|
var phonePtr, emailPtr, birthPtr, pwdPtr *string
|
||||||
|
if item.phone != "" {
|
||||||
|
phonePtr = &item.phone
|
||||||
|
}
|
||||||
|
if item.email != "" {
|
||||||
|
emailPtr = &item.email
|
||||||
|
}
|
||||||
|
if item.birth != "" {
|
||||||
|
birthPtr = &item.birth
|
||||||
|
}
|
||||||
|
pwdPtr = &hashed
|
||||||
|
var sexPtr *uint8
|
||||||
|
if item.gender > 0 {
|
||||||
|
sex := uint8(item.gender)
|
||||||
|
sexPtr = &sex
|
||||||
|
}
|
||||||
|
status := int8(1)
|
||||||
|
if item.status == 0 {
|
||||||
|
status = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := services.BindTenantUser(
|
||||||
|
uint64(item.tid), uid, &account, &name, phonePtr, emailPtr,
|
||||||
|
sexPtr, birthPtr, pwdPtr, 0, status, nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" 跳过 %s:写入租户用户失败 %v\n", item.account, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 部门:与员工档案保持一致(用户管理表单里的所属部门)
|
||||||
|
if deptID := parseUint(item.dept); deptID > 0 {
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||||
|
Filter("id", id).
|
||||||
|
Update(orm.Params{"org_id": deptID})
|
||||||
|
}
|
||||||
|
|
||||||
|
created++
|
||||||
|
fmt.Printf(" 已创建:tid=%d account=%s name=%s 初始密码=%s(员工id=%d 已自动关联)\n",
|
||||||
|
item.tid, item.account, item.name, plain, item.empID)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n完成:新建 %d 个账号,跳过 %d 个\n", created, len(todo)-created)
|
||||||
|
}
|
||||||
|
|
||||||
|
// identityConflict 手机号/邮箱已被其他账号占用时返回原因,避免错误合并两个自然人
|
||||||
|
func identityConflict(account, phone, email string) string {
|
||||||
|
if phone != "" {
|
||||||
|
var rows []models.AuthIdentity
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||||
|
Filter("mobile", phone).All(&rows)
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Account == nil || strings.TrimSpace(*r.Account) != account {
|
||||||
|
return "手机号 " + phone + " 已属于其他统一身份(identity " + strconv.FormatUint(r.ID, 10) + ")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if email != "" {
|
||||||
|
var rows []models.AuthIdentity
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||||
|
Filter("email", email).All(&rows)
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Account == nil || strings.TrimSpace(*r.Account) != account {
|
||||||
|
return "邮箱 " + email + " 已属于其他统一身份(identity " + strconv.FormatUint(r.ID, 10) + ")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUint(v string) uint64 {
|
||||||
|
n, err := strconv.ParseUint(strings.TrimSpace(v), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomPassword 生成 12 位含大小写与数字的随机初始密码
|
||||||
|
func randomPassword() string {
|
||||||
|
const lower = "abcdefghijkmnpqrstuvwxyz"
|
||||||
|
const upper = "ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||||
|
const digits = "23456789"
|
||||||
|
pool := lower + upper + digits
|
||||||
|
buf := make([]byte, 12)
|
||||||
|
for i := range buf {
|
||||||
|
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(pool))))
|
||||||
|
if err != nil {
|
||||||
|
buf[i] = pool[i%len(pool)]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
buf[i] = pool[n.Int64()]
|
||||||
|
}
|
||||||
|
// 保证至少各含一位,避免被前端复杂度校验拦住
|
||||||
|
buf[0] = upper[randIdx(len(upper))]
|
||||||
|
buf[1] = lower[randIdx(len(lower))]
|
||||||
|
buf[2] = digits[randIdx(len(digits))]
|
||||||
|
return string(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func randIdx(n int) int {
|
||||||
|
v, err := rand.Int(rand.Reader, big.NewInt(int64(n)))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int(v.Int64())
|
||||||
|
}
|
||||||
@@ -44,15 +44,13 @@ func EnsureEmployeeForTenantUser(tu *models.SystemTenantUser) (*models.BackendEm
|
|||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 员工表按 (tid, account) 唯一:同一账号在不同企业各有一份员工档案,
|
||||||
|
// 因此这里必须带上 tid,否则会把其他企业的同账号员工误判为本企业已有。
|
||||||
var emp models.BackendEmployee
|
var emp models.BackendEmployee
|
||||||
// 员工表的 uk_account 是全局唯一索引(不是 tid+account 复合索引)。
|
|
||||||
// 因此先按账号查全表,避免已有的历史/其他租户同账号记录被误判为缺失后再 INSERT。
|
|
||||||
err := models.Orm.QueryTable(new(models.BackendEmployee)).
|
err := models.Orm.QueryTable(new(models.BackendEmployee)).
|
||||||
|
Filter("tid", tid).
|
||||||
Filter("account", account).
|
Filter("account", account).
|
||||||
One(&emp)
|
One(&emp)
|
||||||
if err == nil && emp.Tid != nil && *emp.Tid > 0 && *emp.Tid != tid {
|
|
||||||
return nil, false, fmt.Errorf("员工账号 %s 已被其他租户使用,不能重复创建", account)
|
|
||||||
}
|
|
||||||
|
|
||||||
name := account
|
name := account
|
||||||
if tu.Name != nil && strings.TrimSpace(*tu.Name) != "" {
|
if tu.Name != nil && strings.TrimSpace(*tu.Name) != "" {
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { getWebsiteSetting } from '@/api/websiteSetting'
|
||||||
|
import { applyFavicon } from '@/utils/favicon'
|
||||||
|
|
||||||
|
// 浏览器标签页图标:取「平台设置 - 平台信息」的站点图标,未配置则保留默认图标
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!localStorage.getItem('token')) return
|
||||||
|
try {
|
||||||
|
const res = await getWebsiteSetting()
|
||||||
|
if (res?.code === 200) applyFavicon(res.data?.values?.ico)
|
||||||
|
} catch (error) {
|
||||||
|
// 图标属于装饰信息,取不到时不影响页面
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -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>
|
<template>
|
||||||
<div class="not-found-container">
|
<div class="not-found-page">
|
||||||
<div class="not-found-content">
|
<!-- 背景装饰:柔光 + 网格 -->
|
||||||
<div class="not-found-big">404</div>
|
<div class="bg-decor" aria-hidden="true">
|
||||||
<div class="not-found-message">页面未找到</div>
|
<span class="orb orb-1"></span>
|
||||||
<div class="not-found-desc">很抱歉,您访问的页面不存在或已被删除。</div>
|
<span class="orb orb-2"></span>
|
||||||
<div class="action-btns">
|
<span class="grid"></span>
|
||||||
<a href="javascript:void(0)" class="back-home-btn" @click.prevent="backToHome">
|
</div>
|
||||||
返回首页
|
|
||||||
</a>
|
<div class="not-found-card">
|
||||||
<a href="javascript:void(0)" class="clear-cache-btn" @click.prevent="clearCache">
|
<div class="visual">
|
||||||
清除缓存
|
<p class="code">404</p>
|
||||||
</a>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
|
||||||
const router = useRouter();
|
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 = () => {
|
const backToHome = () => {
|
||||||
// 使用 /home 作为首页(Hash模式下的系统导航页)
|
// Hash 模式下 /home 是系统导航门户
|
||||||
router.push("/home");
|
router.push("/home");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const backToPrev = () => {
|
||||||
|
// 有站内历史就退一步,否则回首页
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
router.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
backToHome();
|
||||||
|
};
|
||||||
|
|
||||||
const clearAllCookies = () => {
|
const clearAllCookies = () => {
|
||||||
const cookies = document.cookie ? document.cookie.split(";") : [];
|
const cookies = document.cookie ? document.cookie.split(";") : [];
|
||||||
for (const cookie of cookies) {
|
for (const cookie of cookies) {
|
||||||
@@ -57,94 +145,329 @@ const clearCache = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.not-found-container {
|
.not-found-page {
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: linear-gradient(120deg, #6a82fb 0%, #fc5c7d 100%);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
|
||||||
align-items: 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 {
|
.bg-decor {
|
||||||
background: #fff;
|
position: absolute;
|
||||||
padding: 60px 48px 40px;
|
inset: 0;
|
||||||
border-radius: 16px;
|
pointer-events: none;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.not-found-big {
|
.orb {
|
||||||
font-size: 100px;
|
position: absolute;
|
||||||
font-weight: bold;
|
border-radius: 50%;
|
||||||
letter-spacing: 3px;
|
}
|
||||||
background: linear-gradient(90deg, #6a82fb 0%, #fc5c7d 100%);
|
|
||||||
|
.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-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;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.not-found-message {
|
.btn {
|
||||||
font-size: 28px;
|
height: 40px;
|
||||||
color: #222;
|
padding: 0 22px;
|
||||||
margin-bottom: 10px;
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
font-weight: 600;
|
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 {
|
.btn:active {
|
||||||
font-size: 16px;
|
transform: translateY(1px);
|
||||||
color: #888;
|
|
||||||
margin-bottom: 28px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btns {
|
.btn.primary {
|
||||||
display: flex;
|
border: none;
|
||||||
gap: 12px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-home-btn {
|
|
||||||
display: inline-block;
|
|
||||||
background: linear-gradient(90deg, #6a82fb 0%, #fc5c7d 100%);
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 10px 36px;
|
background: linear-gradient(135deg, #4d82ff 0%, #2f6bff 100%);
|
||||||
border-radius: 24px;
|
box-shadow: 0 10px 22px -10px rgba(47, 107, 255, 0.7);
|
||||||
font-size: 16px;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: background 0.2s;
|
|
||||||
box-shadow: 0 2px 8px rgba(252, 92, 125, 0.07);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-home-btn:hover {
|
.btn.primary:hover {
|
||||||
background: linear-gradient(-90deg, #6a82fb 0%, #fc5c7d 100%);
|
box-shadow: 0 14px 26px -12px rgba(47, 107, 255, 0.8);
|
||||||
|
background: linear-gradient(135deg, #5c8dff 0%, #3a74ff 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear-cache-btn {
|
.btn.ghost {
|
||||||
display: inline-block;
|
border: 1px solid #d7e2f6;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: #fc5c7d;
|
color: #40506e;
|
||||||
padding: 10px 36px;
|
|
||||||
border-radius: 24px;
|
|
||||||
font-size: 16px;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
border: 1px solid #fc5c7d;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear-cache-btn:hover {
|
.btn.ghost:hover {
|
||||||
background: #fff0f3;
|
border-color: #b9cdf4;
|
||||||
|
background: #f7faff;
|
||||||
|
color: #2f6bff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes float-in {
|
.footnote {
|
||||||
0% {
|
margin: 0;
|
||||||
transform: translateY(30px) scale(0.97);
|
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;
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
}
|
}
|
||||||
100% {
|
to {
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
opacity: 1;
|
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>
|
</style>
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
<template>
|
||||||
|
<div class="platform-info-settings" v-loading="loading">
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
label-width="140px"
|
||||||
|
class="settings-form"
|
||||||
|
>
|
||||||
|
<el-card shadow="never" class="settings-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>平台信息</span>
|
||||||
|
<span class="card-tip">
|
||||||
|
平台品牌与备案信息,保存在 yz_platform_site_setting(key 即模板标签名),保存后立即生效
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form-item label="主标题" prop="sitename">
|
||||||
|
<el-input
|
||||||
|
v-model="form.sitename"
|
||||||
|
maxlength="60"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="如:温新云平台"
|
||||||
|
/>
|
||||||
|
<div class="form-tip">
|
||||||
|
平台对外展示的主标题,用于后台顶栏、登录页与官网页头
|
||||||
|
<code class="tag-chip" title="点击复制" @click="copyTag('{yz:sitename}')">{yz:sitename}</code>。
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="副标题" prop="subtitle">
|
||||||
|
<el-input
|
||||||
|
v-model="form.subtitle"
|
||||||
|
maxlength="80"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="如:数据赋能管理 · 科技创造价值"
|
||||||
|
/>
|
||||||
|
<div class="form-tip">
|
||||||
|
主标题下方的补充说明,显示在后台顶栏主标题下方,留空则显示当前功能模块名
|
||||||
|
<code class="tag-chip" title="点击复制" @click="copyTag('{yz:subtitle}')">{yz:subtitle}</code>。
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-for="item in logoFields" :key="item.key" :label="item.label">
|
||||||
|
<div class="upload-row">
|
||||||
|
<el-upload
|
||||||
|
class="asset-uploader"
|
||||||
|
:class="[item.shape, { 'is-dark': item.dark }]"
|
||||||
|
:show-file-list="false"
|
||||||
|
:auto-upload="false"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/svg+xml,image/gif"
|
||||||
|
:on-change="(file) => handleUpload(file, item.key)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="form[item.key]"
|
||||||
|
:src="previewUrl(form[item.key])"
|
||||||
|
class="asset-image"
|
||||||
|
:alt="item.label"
|
||||||
|
/>
|
||||||
|
<el-icon v-else class="uploader-icon"><Plus /></el-icon>
|
||||||
|
</el-upload>
|
||||||
|
<div class="upload-meta">
|
||||||
|
<p>{{ item.tip }}</p>
|
||||||
|
<p>
|
||||||
|
标签:
|
||||||
|
<code
|
||||||
|
class="tag-chip"
|
||||||
|
title="点击复制"
|
||||||
|
@click="copyTag(tagOf(item.key))"
|
||||||
|
>{{ tagOf(item.key) }}</code>
|
||||||
|
</p>
|
||||||
|
<el-button v-if="form[item.key]" link type="danger" @click="form[item.key] = ''">
|
||||||
|
移除 Logo
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="站点图标">
|
||||||
|
<div class="upload-row">
|
||||||
|
<el-upload
|
||||||
|
class="asset-uploader is-icon"
|
||||||
|
:show-file-list="false"
|
||||||
|
:auto-upload="false"
|
||||||
|
accept=".ico,image/png,image/svg+xml,image/jpeg,image/webp,image/gif"
|
||||||
|
:on-change="(file) => handleUpload(file, 'ico')"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="form.ico"
|
||||||
|
:src="previewUrl(form.ico)"
|
||||||
|
class="asset-image"
|
||||||
|
alt="站点图标"
|
||||||
|
/>
|
||||||
|
<el-icon v-else class="uploader-icon"><Plus /></el-icon>
|
||||||
|
</el-upload>
|
||||||
|
<div class="upload-meta">
|
||||||
|
<p>浏览器标签页图标(favicon),建议 32×32 / 64×64 的 .ico 或 PNG。</p>
|
||||||
|
<p>
|
||||||
|
标签:
|
||||||
|
<code class="tag-chip" title="点击复制" @click="copyTag('{yz:ico}')">{yz:ico}</code>
|
||||||
|
</p>
|
||||||
|
<el-button v-if="form.ico" link type="danger" @click="form.ico = ''">
|
||||||
|
移除图标
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="ICP 备案号" prop="icp">
|
||||||
|
<el-input v-model="form.icp" maxlength="80" placeholder="如:苏ICP备2026000000号-1" />
|
||||||
|
<div class="form-tip">
|
||||||
|
标签:
|
||||||
|
<code class="tag-chip" title="点击复制" @click="copyTag('{yz:icp}')">{yz:icp}</code>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="网警平台备案" prop="policeicp">
|
||||||
|
<el-input
|
||||||
|
v-model="form.policeicp"
|
||||||
|
maxlength="80"
|
||||||
|
placeholder="如:苏公网安备32010000000000号"
|
||||||
|
/>
|
||||||
|
<div class="form-tip">
|
||||||
|
标签:
|
||||||
|
<code class="tag-chip" title="点击复制" @click="copyTag('{yz:policeicp}')">{yz:policeicp}</code>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="平台简介" prop="platform_intro">
|
||||||
|
<el-input
|
||||||
|
v-model="form.platform_intro"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="300"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="一句话介绍平台定位,如:面向中小企业的一站式数字化管理与官网服务平台"
|
||||||
|
/>
|
||||||
|
<div class="form-tip">
|
||||||
|
标签:
|
||||||
|
<code class="tag-chip" title="点击复制" @click="copyTag('{yz:platform_intro}')">{yz:platform_intro}</code>,留空则不展示。
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-card>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="footer-actions">
|
||||||
|
<el-button @click="handleReset">重置</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存设置</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive, ref } from "vue";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import { Plus } from "@element-plus/icons-vue";
|
||||||
|
import { uploadToWebsiteMaterial } from "@/utils/websiteUpload";
|
||||||
|
import { getWebsiteSetting, saveWebsiteSetting } from "@/api/websiteSetting";
|
||||||
|
|
||||||
|
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
||||||
|
const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
const formRef = ref();
|
||||||
|
const loading = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
|
||||||
|
// 表单 key 与 yz_platform_site_setting.tag 一一对应
|
||||||
|
// sitename = 主标题(与官网页头、{yz:sitename} 同源),subtitle = 副标题
|
||||||
|
const form = reactive({
|
||||||
|
sitename: "",
|
||||||
|
subtitle: "",
|
||||||
|
logo_square: "",
|
||||||
|
logo_square_dark: "",
|
||||||
|
logo_rect: "",
|
||||||
|
logo_rect_dark: "",
|
||||||
|
ico: "",
|
||||||
|
icp: "",
|
||||||
|
policeicp: "",
|
||||||
|
platform_intro: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logo 四个变体:正方形 / 长方形 × 浅色 / 深色主题
|
||||||
|
const logoFields = [
|
||||||
|
{
|
||||||
|
key: "logo_square",
|
||||||
|
label: "Logo(正方形)",
|
||||||
|
shape: "is-square",
|
||||||
|
dark: false,
|
||||||
|
tip: "建议 1:1 方形图标(≥ 128×128),用于后台顶栏等小尺寸场景。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "logo_square_dark",
|
||||||
|
label: "Logo(正方形 · 深色主题)",
|
||||||
|
shape: "is-square",
|
||||||
|
dark: true,
|
||||||
|
tip: "深色主题下替换为这张方形图,留空则沿用浅色主题的方形 Logo。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "logo_rect",
|
||||||
|
label: "Logo(长方形)",
|
||||||
|
shape: "is-rect",
|
||||||
|
dark: false,
|
||||||
|
tip: "建议 4:1 ~ 3:1 横向品牌图,用于页头等较宽场景,透明背景 PNG 效果最佳。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "logo_rect_dark",
|
||||||
|
label: "Logo(长方形 · 深色主题)",
|
||||||
|
shape: "is-rect",
|
||||||
|
dark: true,
|
||||||
|
tip: "深色主题下替换为这张横向图(浅色文字),留空则回退官网浅色 Logo。",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
sitename: [{ required: true, message: "请输入主标题", trigger: "blur" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewUrl = (url) => {
|
||||||
|
if (!url) return "";
|
||||||
|
if (/^(https?:)?\/\//i.test(url) || url.startsWith("data:")) return url;
|
||||||
|
return `${API_BASE_URL}/${url.replace(/^\/+/, "")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 标签名 -> 「{yz:tag}」文本(模板里不能直接写花括号字面量,会与插值语法冲突)
|
||||||
|
const tagOf = (key) => `{yz:${key}}`;
|
||||||
|
|
||||||
|
// 标签点击即复制:模板里要反复粘贴 {yz:xxx},避免手打出错
|
||||||
|
const copyTag = async (text) => {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} else {
|
||||||
|
const input = document.createElement("textarea");
|
||||||
|
input.value = text;
|
||||||
|
input.setAttribute("readonly", "");
|
||||||
|
input.style.position = "fixed";
|
||||||
|
input.style.opacity = "0";
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(input);
|
||||||
|
}
|
||||||
|
ElMessage.success(`已复制 ${text}`);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error("复制失败,请手动选择复制");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 上传:与官网管理共用「官网素材」分组,便于在文件管理里集中复用
|
||||||
|
const handleUpload = async (file, field) => {
|
||||||
|
const raw = file?.raw;
|
||||||
|
if (!raw) return;
|
||||||
|
// .ico 在部分浏览器/系统上 MIME 为空或 image/x-icon,这里按扩展名一并放行
|
||||||
|
const isImage = String(raw.type || "").startsWith("image/") || /\.ico$/i.test(raw.name || "");
|
||||||
|
if (!isImage) {
|
||||||
|
ElMessage.error("仅支持图片格式");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (raw.size > MAX_IMAGE_SIZE) {
|
||||||
|
ElMessage.error("图片不能超过 5MB");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await uploadToWebsiteMaterial(raw);
|
||||||
|
if ((res.code === 200 || res.code === 201) && res.data && res.data.url) {
|
||||||
|
form[field] = res.data.url.replace(/\\/g, "/");
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || "上传失败");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error?.response?.data?.msg || "上传失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initSetting = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getWebsiteSetting();
|
||||||
|
if (res.code === 200 && res.data) {
|
||||||
|
const values = res.data.values || {};
|
||||||
|
Object.keys(form).forEach((key) => {
|
||||||
|
form[key] = values[key] || "";
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || "获取平台信息失败");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error("获取平台信息失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!formRef.value) return;
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return;
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
const res = await saveWebsiteSetting({
|
||||||
|
sitename: String(form.sitename || "").trim(),
|
||||||
|
subtitle: String(form.subtitle || "").trim(),
|
||||||
|
logo_square: String(form.logo_square || "").trim(),
|
||||||
|
logo_square_dark: String(form.logo_square_dark || "").trim(),
|
||||||
|
logo_rect: String(form.logo_rect || "").trim(),
|
||||||
|
logo_rect_dark: String(form.logo_rect_dark || "").trim(),
|
||||||
|
ico: String(form.ico || "").trim(),
|
||||||
|
icp: String(form.icp || "").trim(),
|
||||||
|
policeicp: String(form.policeicp || "").trim(),
|
||||||
|
platform_intro: String(form.platform_intro || "").trim(),
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success("保存成功");
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || "保存失败");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error?.response?.data?.msg || "保存失败");
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
initSetting();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(initSetting);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.platform-info-settings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tip {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可点击复制的模板标签
|
||||||
|
.tag-chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--el-color-primary-light-5);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: var(--el-color-primary-light-8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-meta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-uploader {
|
||||||
|
:deep(.el-upload) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px dashed #d9d9d9;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fafbfe;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #3973ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-square :deep(.el-upload) {
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-rect :deep(.el-upload) {
|
||||||
|
width: 176px;
|
||||||
|
height: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-icon :deep(.el-upload) {
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 深色主题 Logo:预览底色改成暗色,浅色 Logo 才看得见
|
||||||
|
&.is-dark :deep(.el-upload) {
|
||||||
|
background: #1b1f2b;
|
||||||
|
border-color: #3a4152;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploader-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #8c939d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-image {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,6 +8,11 @@
|
|||||||
|
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
<el-tabs v-model="activeTab" class="settings-tabs">
|
<el-tabs v-model="activeTab" class="settings-tabs">
|
||||||
|
<!-- 平台信息:站点名称 / Logo / 备案 / 平台简介(yz_platform_site_setting) -->
|
||||||
|
<el-tab-pane label="平台信息" name="platformInfo">
|
||||||
|
<platformInfoSettings v-if="activeTab === 'platformInfo'" />
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- 验证开关 -->
|
<!-- 验证开关 -->
|
||||||
<el-tab-pane label="验证开关" name="platform">
|
<el-tab-pane label="验证开关" name="platform">
|
||||||
<platformSettings
|
<platformSettings
|
||||||
@@ -43,12 +48,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
import platformInfoSettings from "./components/platformInfoSettings.vue";
|
||||||
import platformSettings from "./components/platformSettings.vue";
|
import platformSettings from "./components/platformSettings.vue";
|
||||||
import storageSettings from "./components/storageSettings.vue";
|
import storageSettings from "./components/storageSettings.vue";
|
||||||
import notificationSettings from "./components/notificationSettings.vue";
|
import notificationSettings from "./components/notificationSettings.vue";
|
||||||
import loginBrandSettings from "./components/loginBrandSettings.vue";
|
import loginBrandSettings from "./components/loginBrandSettings.vue";
|
||||||
|
|
||||||
const activeTab = ref("platform");
|
const activeTab = ref("platformInfo");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { request } from '@/api/request.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OA 考勤打卡接口封装,对应后端 /api/oa/attendance/*。
|
||||||
|
*
|
||||||
|
* 打卡联动工作排班:今日排班由后端匹配,匹配不上时 today.warning 非空,
|
||||||
|
* 但 can_clock_in / can_clock_out 仍为 true(提醒后仍可签到)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 今日打卡状态:{date, employee, entry, record, matched, warning, can_clock_in, can_clock_out, recent, stats} */
|
||||||
|
export function fetchAttendanceToday(days = 7) {
|
||||||
|
return request({ url: '/api/oa/attendance/today', data: { days } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 签到 / 签退
|
||||||
|
* @param {'in'|'out'} type
|
||||||
|
* @param {{location?:string,remark?:string}} extra
|
||||||
|
* @returns {Promise<{record:object,warning:string,duplicated:boolean,message:string}>}
|
||||||
|
*/
|
||||||
|
export function clockAttendance(type, extra = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/api/oa/attendance/clock',
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
type,
|
||||||
|
location: extra.location || '',
|
||||||
|
remark: extra.remark || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的考勤记录(默认本月)
|
||||||
|
* @param {{page?:number,pageSize?:number,startDate?:string,endDate?:string}} params
|
||||||
|
* @returns {Promise<{list:Array,total:number,stats:object}>}
|
||||||
|
*/
|
||||||
|
export function fetchMyAttendanceRecords(params = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/api/oa/attendance/records',
|
||||||
|
data: {
|
||||||
|
page: params.page || 1,
|
||||||
|
pageSize: params.pageSize || 20,
|
||||||
|
start_date: params.startDate || '',
|
||||||
|
end_date: params.endDate || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 个人考勤卡:按 月 / 季 / 年 聚合
|
||||||
|
* @param {'month'|'quarter'|'year'} period
|
||||||
|
* @param {string} date 锚点日期 "YYYY-MM-DD",默认今天
|
||||||
|
* @returns {Promise<{period:string,label:string,start_date:string,end_date:string,stats:object}>}
|
||||||
|
*/
|
||||||
|
export function fetchAttendanceStats(period = 'month', date = '') {
|
||||||
|
return request({
|
||||||
|
url: '/api/oa/attendance/stats',
|
||||||
|
data: { period, date }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 今日 "YYYY-MM-DD"(与后端本地日期口径一致) */
|
||||||
|
export function todayString() {
|
||||||
|
const now = new Date()
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||||
|
}
|
||||||
@@ -393,6 +393,13 @@
|
|||||||
"navigationBarBackgroundColor": "#F8FAFC"
|
"navigationBarBackgroundColor": "#F8FAFC"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "attendance/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "考勤打卡",
|
||||||
|
"navigationBarBackgroundColor": "#F8FAFC"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "notice/detail",
|
"path": "notice/detail",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@@ -0,0 +1,969 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="body">
|
||||||
|
<!-- ==================== 今日打卡 ==================== -->
|
||||||
|
<view class="punch-card">
|
||||||
|
<view class="punch-head">
|
||||||
|
<view class="head-left">
|
||||||
|
<text class="head-date">{{ dateText }}</text>
|
||||||
|
<text class="head-sub">{{ employeeText }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="head-clock">
|
||||||
|
<text class="clock-text">{{ clockText }}</text>
|
||||||
|
<text class="clock-label">{{ weekdayText }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="shift-row">
|
||||||
|
<view class="shift-badge" :style="{ background: shiftColor }">{{ shortName }}</view>
|
||||||
|
<view class="shift-body">
|
||||||
|
<text class="shift-name">{{ shiftName }}</text>
|
||||||
|
<text class="shift-time">{{ shiftTimeText }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="match-chip" :class="matched ? 'is-ok' : 'is-warn'">
|
||||||
|
{{ matched ? '已匹配排班' : '未匹配排班' }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 未匹配排班等提醒:只提示,不阻断打卡 -->
|
||||||
|
<view v-if="warning" class="warn-bar">
|
||||||
|
<FaIcon name="triangle-exclamation" color="#b45309" :size="14" />
|
||||||
|
<text class="warn-text">{{ warning }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="punch-times">
|
||||||
|
<view class="time-item">
|
||||||
|
<text class="time-label">签到</text>
|
||||||
|
<text class="time-value">{{ record.clock_in_hm || '--:--' }}</text>
|
||||||
|
<text v-if="record.clock_in_hm" class="time-status" :class="statusClass(record.clock_in_status)">
|
||||||
|
{{ record.clock_in_text }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="time-divider" />
|
||||||
|
<view class="time-item">
|
||||||
|
<text class="time-label">签退</text>
|
||||||
|
<text class="time-value">{{ record.clock_out_hm || '--:--' }}</text>
|
||||||
|
<text v-if="record.clock_out_hm" class="time-status" :class="statusClass(record.clock_out_status)">
|
||||||
|
{{ record.clock_out_text }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="punch-actions">
|
||||||
|
<view class="punch-btn is-in" :class="{ 'is-disabled': !canClockIn, 'is-busy': punching }" @tap="punch('in')">
|
||||||
|
<text class="btn-title">{{ record.clock_in_hm ? '已签到' : '签到' }}</text>
|
||||||
|
<text class="btn-sub">{{ record.clock_in_hm || '上班打卡' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="punch-btn is-out" :class="{ 'is-disabled': !canClockOut, 'is-busy': punching }" @tap="punch('out')">
|
||||||
|
<text class="btn-title">{{ record.clock_out_hm ? '已签退' : '签退' }}</text>
|
||||||
|
<text class="btn-sub">{{ record.clock_out_hm || '下班打卡' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- ==================== 近 7 天 ==================== -->
|
||||||
|
<view v-if="recentDays.length" class="week-card">
|
||||||
|
<view class="block-head">
|
||||||
|
<text class="block-title">近 7 天</text>
|
||||||
|
<text class="block-sub">排班与打卡概览</text>
|
||||||
|
</view>
|
||||||
|
<view class="week-row">
|
||||||
|
<view
|
||||||
|
v-for="day in recentDays"
|
||||||
|
:key="day.date"
|
||||||
|
class="week-cell"
|
||||||
|
:class="[dayClass(day.day_status), { 'is-today': day.is_today }]"
|
||||||
|
>
|
||||||
|
<text class="wc-week">{{ weekdayLabel(day.weekday) }}</text>
|
||||||
|
<text class="wc-date">{{ shortDate(day.date) }}</text>
|
||||||
|
<text class="wc-shift">{{ day.shift_name || '休息' }}</text>
|
||||||
|
<text class="wc-status">{{ day.day_status_text }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- ==================== 考勤卡(本月 / 本季 / 本年) ==================== -->
|
||||||
|
<view class="stat-card">
|
||||||
|
<view class="block-head">
|
||||||
|
<text class="block-title">考勤卡</text>
|
||||||
|
<view class="period-switch">
|
||||||
|
<text
|
||||||
|
v-for="item in STATS_PERIODS"
|
||||||
|
:key="item.key"
|
||||||
|
class="ps-item"
|
||||||
|
:class="{ 'is-active': statsPeriod === item.key }"
|
||||||
|
@tap="loadStats(item.key)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="stats-range">{{ statsLabel }}</text>
|
||||||
|
<view class="stat-grid" :class="{ 'is-loading': statsLoading }">
|
||||||
|
<view v-for="item in statItems" :key="item.key" class="stat-item">
|
||||||
|
<text class="stat-num" :class="item.cls">{{ item.value }}</text>
|
||||||
|
<text class="stat-label">{{ item.label }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- ==================== 考勤记录 ==================== -->
|
||||||
|
<view class="list-card">
|
||||||
|
<view class="block-head">
|
||||||
|
<text class="block-title">考勤记录</text>
|
||||||
|
<text class="block-sub">共 {{ recordTotal }} 条</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="loading && !records.length" class="state-box">
|
||||||
|
<text class="state-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
<u-empty
|
||||||
|
v-else-if="!records.length"
|
||||||
|
mode="list"
|
||||||
|
text="本月还没有考勤记录,先打个卡吧"
|
||||||
|
:icon-size="80"
|
||||||
|
/>
|
||||||
|
<view v-else class="record-list">
|
||||||
|
<view v-for="item in records" :key="item.id" class="record-row">
|
||||||
|
<view class="record-date">
|
||||||
|
<text class="rd-day">{{ shortDate(item.attend_date) }}</text>
|
||||||
|
<text class="rd-week">{{ weekdayLabel(item.weekday) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="record-main">
|
||||||
|
<view class="rm-line">
|
||||||
|
<text class="rm-shift">{{ item.shift_name || '未匹配排班' }}</text>
|
||||||
|
<text class="rm-shift-time">{{ item.shift_text }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="rm-line">
|
||||||
|
<text class="rm-punch">签到 {{ item.clock_in_hm || '--:--' }}</text>
|
||||||
|
<text class="rm-punch">签退 {{ item.clock_out_hm || '--:--' }}</text>
|
||||||
|
</view>
|
||||||
|
<text v-if="recordNote(item)" class="rm-note">{{ recordNote(item) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="record-status" :class="dayClass(item.day_status)">{{ item.day_status_text }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="load-more">
|
||||||
|
<text v-if="hasMore" class="load-link" @tap="loadMore">加载更多</text>
|
||||||
|
<text v-else class="load-done">没有更多了</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="bottom-space" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { onHide, onReachBottom, onShow, onUnload } from '@dcloudio/uni-app'
|
||||||
|
import { isLoggedIn } from '@/utils/auth.js'
|
||||||
|
import {
|
||||||
|
clockAttendance,
|
||||||
|
fetchAttendanceStats,
|
||||||
|
fetchAttendanceToday,
|
||||||
|
fetchMyAttendanceRecords
|
||||||
|
} from '@/api/oaAttendance.js'
|
||||||
|
|
||||||
|
const WEEKDAY_LABELS = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||||
|
const WEEKDAY_FULL = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||||
|
|
||||||
|
const STATS_PERIODS = [
|
||||||
|
{ key: 'month', label: '本月' },
|
||||||
|
{ key: 'quarter', label: '本季' },
|
||||||
|
{ key: 'year', label: '本年' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const today = ref({})
|
||||||
|
const clockText = ref('')
|
||||||
|
const records = ref([])
|
||||||
|
const recordPage = ref(1)
|
||||||
|
const recordTotal = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const punching = ref(false)
|
||||||
|
const statsData = ref({})
|
||||||
|
const statsPeriod = ref('month')
|
||||||
|
const statsLabel = ref('')
|
||||||
|
const statsLoading = ref(false)
|
||||||
|
let timer = null
|
||||||
|
|
||||||
|
const employee = computed(() => today.value.employee || {})
|
||||||
|
const record = computed(() => today.value.record || {})
|
||||||
|
const matched = computed(() => today.value.matched === true)
|
||||||
|
const warning = computed(() => today.value.warning || '')
|
||||||
|
const ready = computed(() => Boolean(today.value.date))
|
||||||
|
const canClockIn = computed(() => ready.value && today.value.can_clock_in !== false)
|
||||||
|
const canClockOut = computed(() => ready.value && today.value.can_clock_out !== false)
|
||||||
|
const hasMore = computed(() => records.value.length < recordTotal.value)
|
||||||
|
|
||||||
|
const dateText = computed(() => today.value.date || '')
|
||||||
|
const weekdayText = computed(() => {
|
||||||
|
if (!dateText.value) return ''
|
||||||
|
const date = new Date(`${dateText.value}T00:00:00`)
|
||||||
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
return WEEKDAY_FULL[date.getDay()]
|
||||||
|
})
|
||||||
|
const employeeText = computed(() => {
|
||||||
|
const parts = [employee.value.name, employee.value.org_name].filter(Boolean)
|
||||||
|
if (!employee.value.bound) {
|
||||||
|
parts.push('未绑定员工档案')
|
||||||
|
}
|
||||||
|
return parts.join(' · ') || '考勤打卡'
|
||||||
|
})
|
||||||
|
|
||||||
|
const shiftName = computed(() => {
|
||||||
|
const entry = today.value.entry
|
||||||
|
if (entry && entry.shift_name) return entry.shift_name
|
||||||
|
if (record.value.shift_name) return record.value.shift_name
|
||||||
|
return '今日未排班'
|
||||||
|
})
|
||||||
|
const shiftTimeText = computed(() => {
|
||||||
|
const entry = today.value.entry
|
||||||
|
if (entry && entry.shift_text) return entry.shift_text
|
||||||
|
if (record.value.shift_text) return record.value.shift_text
|
||||||
|
return '无排班时间,打卡将标记为「无排班」'
|
||||||
|
})
|
||||||
|
const shortName = computed(() => {
|
||||||
|
const entry = today.value.entry
|
||||||
|
if (entry && entry.short_name) return entry.short_name
|
||||||
|
return '班'
|
||||||
|
})
|
||||||
|
const shiftColor = computed(() => {
|
||||||
|
const entry = today.value.entry
|
||||||
|
return (entry && entry.color) || record.value.color || '#409EFF'
|
||||||
|
})
|
||||||
|
|
||||||
|
const statItems = computed(() => {
|
||||||
|
const stats = statsData.value || {}
|
||||||
|
return [
|
||||||
|
{ key: 'scheduled', label: '应出勤', value: stats.scheduled || 0, cls: '' },
|
||||||
|
{ key: 'clocked', label: '已打卡', value: stats.clocked || 0, cls: 'is-ok' },
|
||||||
|
{ key: 'late', label: '迟到', value: stats.late || 0, cls: 'is-warn' },
|
||||||
|
{ key: 'early', label: '早退', value: stats.early || 0, cls: 'is-warn' },
|
||||||
|
{ key: 'noschedule', label: '无排班', value: stats.noschedule || 0, cls: 'is-info' },
|
||||||
|
{ key: 'missing', label: '缺卡', value: stats.missing || 0, cls: 'is-danger' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 近 7 天:后端按「最近在前」返回,这里翻成从左到右的时间轴
|
||||||
|
const recentDays = computed(() => {
|
||||||
|
const list = Array.isArray(today.value.recent) ? today.value.recent : []
|
||||||
|
return list.slice().reverse()
|
||||||
|
})
|
||||||
|
|
||||||
|
function pad2(value) {
|
||||||
|
return String(value).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const now = new Date()
|
||||||
|
clockText.value = `${pad2(now.getHours())}:${pad2(now.getMinutes())}:${pad2(now.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTimer() {
|
||||||
|
tick()
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
timer = setInterval(tick, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTimer() {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekdayLabel(value) {
|
||||||
|
return WEEKDAY_LABELS[Number(value)] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDate(date) {
|
||||||
|
return String(date || '').slice(5)
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(status) {
|
||||||
|
switch (Number(status)) {
|
||||||
|
case 1:
|
||||||
|
return 'is-ok'
|
||||||
|
case 2:
|
||||||
|
return 'is-warn'
|
||||||
|
case 3:
|
||||||
|
return 'is-muted'
|
||||||
|
case 4:
|
||||||
|
return 'is-danger'
|
||||||
|
}
|
||||||
|
return 'is-muted'
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayClass(status) {
|
||||||
|
switch (Number(status)) {
|
||||||
|
case 1:
|
||||||
|
return 'is-ok'
|
||||||
|
case 2:
|
||||||
|
case 3:
|
||||||
|
case 7:
|
||||||
|
return 'is-warn'
|
||||||
|
case 4:
|
||||||
|
case 6:
|
||||||
|
return 'is-danger'
|
||||||
|
case 5:
|
||||||
|
return 'is-info'
|
||||||
|
}
|
||||||
|
return 'is-muted'
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordNote(item) {
|
||||||
|
return item.clock_out_note || item.clock_in_note || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDialog(title, content) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
uni.showModal({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
confirmText: '继续',
|
||||||
|
cancelText: '取消',
|
||||||
|
success: (res) => resolve(Boolean(res.confirm)),
|
||||||
|
fail: () => resolve(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadToday() {
|
||||||
|
try {
|
||||||
|
const data = await fetchAttendanceToday(7)
|
||||||
|
if (data) {
|
||||||
|
today.value = data
|
||||||
|
statsData.value = data.stats || {}
|
||||||
|
statsPeriod.value = 'month'
|
||||||
|
statsLabel.value = monthLabel()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
uni.showToast({ title: (error && error.message) || '考勤状态加载失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 月度卡标题(today 接口只返回区间数字,标题在前端拼) */
|
||||||
|
function monthLabel() {
|
||||||
|
const now = new Date()
|
||||||
|
return `${now.getFullYear()} 年 ${now.getMonth() + 1} 月`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换考勤卡维度:本月 / 本季 / 本年(后端返回统一口径的统计) */
|
||||||
|
async function loadStats(period) {
|
||||||
|
if (statsLoading.value || (statsPeriod.value === period && statsLabel.value)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
statsLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await fetchAttendanceStats(period, today.value.date || '')
|
||||||
|
if (data) {
|
||||||
|
statsPeriod.value = data.period || period
|
||||||
|
statsLabel.value = data.label || ''
|
||||||
|
statsData.value = data.stats || {}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
uni.showToast({ title: (error && error.message) || '考勤统计加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
statsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecords(reset = false) {
|
||||||
|
if (reset) {
|
||||||
|
recordPage.value = 1
|
||||||
|
loading.value = true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await fetchMyAttendanceRecords({ page: recordPage.value, pageSize: 20 })
|
||||||
|
const rows = (data && data.list) || []
|
||||||
|
records.value = reset ? rows : records.value.concat(rows)
|
||||||
|
recordTotal.value = (data && data.total) || 0
|
||||||
|
} catch (error) {
|
||||||
|
if (reset) uni.showToast({ title: (error && error.message) || '考勤记录加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMore() {
|
||||||
|
if (!hasMore.value || loading.value) return
|
||||||
|
recordPage.value += 1
|
||||||
|
loadRecords(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function punch(type) {
|
||||||
|
if (punching.value) return
|
||||||
|
if (!ready.value) {
|
||||||
|
uni.showToast({ title: '考勤状态加载中,请稍候', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (type === 'in' && !canClockIn.value) {
|
||||||
|
uni.showToast({ title: '今日已签到,无需重复打卡', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (type === 'out' && !canClockOut.value) {
|
||||||
|
uni.showToast({ title: '今日已签退,无需重复打卡', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排班匹配不上:先提醒,确认后仍可打卡
|
||||||
|
if (warning.value) {
|
||||||
|
const ok = await confirmDialog('排班提醒', `${warning.value}\n\n是否继续${type === 'in' ? '签到' : '签退'}?`)
|
||||||
|
if (!ok) return
|
||||||
|
}
|
||||||
|
if (type === 'out' && !record.value.clock_in_hm) {
|
||||||
|
const ok = await confirmDialog('未签到提醒', '今日尚未签到,是否直接签退?签退记录会标注「未签到」。')
|
||||||
|
if (!ok) return
|
||||||
|
}
|
||||||
|
|
||||||
|
punching.value = true
|
||||||
|
try {
|
||||||
|
const data = await clockAttendance(type)
|
||||||
|
uni.showToast({ title: (data && data.message) || '打卡成功', icon: 'success' })
|
||||||
|
if (data && data.warning) {
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.showModal({ title: '考勤提醒', content: data.warning, showCancel: false, confirmText: '知道了' })
|
||||||
|
}, 600)
|
||||||
|
}
|
||||||
|
await Promise.all([loadToday(), loadRecords(true)])
|
||||||
|
} catch (error) {
|
||||||
|
uni.showToast({ title: (error && error.message) || '打卡失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
punching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
uni.reLaunch({ url: '/pages/login/login' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startTimer()
|
||||||
|
loadToday()
|
||||||
|
loadRecords(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onHide(() => {
|
||||||
|
stopTimer()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnload(() => {
|
||||||
|
stopTimer()
|
||||||
|
})
|
||||||
|
|
||||||
|
onReachBottom(() => {
|
||||||
|
if (hasMore.value && !loading.value) {
|
||||||
|
loadMore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '@/src/styles/page-common.scss';
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $color-bg-page;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 28rpx $page-padding-x 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 今日打卡 ==================== */
|
||||||
|
.punch-card {
|
||||||
|
@include card;
|
||||||
|
padding: 28rpx 24rpx 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.punch-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-date {
|
||||||
|
display: block;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $color-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-sub {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-clock {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-text {
|
||||||
|
font-size: 44rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: $color-primary;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-label {
|
||||||
|
margin-top: 4rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shift-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
padding: 18rpx 20rpx;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shift-badge {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shift-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shift-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $color-text;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shift-time {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-chip {
|
||||||
|
@include chip($color-primary-bg, $color-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.is-ok {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: $color-success;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-warn {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.warn-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10rpx;
|
||||||
|
margin-top: 18rpx;
|
||||||
|
padding: 16rpx 18rpx;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1rpx solid #fde68a;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warn-text {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.punch-times {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-divider {
|
||||||
|
width: 1rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
background: $color-divider;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-value {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: $color-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-status {
|
||||||
|
font-size: 22rpx;
|
||||||
|
|
||||||
|
&.is-ok { color: $color-success; }
|
||||||
|
&.is-warn { color: $color-warning; }
|
||||||
|
&.is-danger { color: $color-danger; }
|
||||||
|
&.is-muted { color: $color-text-muted; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.punch-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 20rpx;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.punch-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
padding: 22rpx 0;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
color: #ffffff;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-in {
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-out {
|
||||||
|
background: linear-gradient(135deg, #34d399, #10b981);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-disabled {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-busy {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sub {
|
||||||
|
font-size: 22rpx;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 近 7 天 ==================== */
|
||||||
|
.week-card,
|
||||||
|
.stat-card,
|
||||||
|
.list-card {
|
||||||
|
@include card;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
padding: 24rpx 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-cell {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4rpx;
|
||||||
|
padding: 12rpx 4rpx;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
background: #f8fafc;
|
||||||
|
|
||||||
|
&.is-today {
|
||||||
|
background: $color-primary-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-danger {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-warn {
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wc-week {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wc-date {
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $color-text;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wc-shift {
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wc-status {
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
.is-ok & { color: $color-success; }
|
||||||
|
.is-warn & { color: $color-warning; }
|
||||||
|
.is-danger & { color: $color-danger; }
|
||||||
|
.is-info & { color: $color-primary; }
|
||||||
|
.is-muted & { color: $color-text-muted; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-head {
|
||||||
|
@include block-head;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-title {
|
||||||
|
@include section-title;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-sub {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-switch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4rpx;
|
||||||
|
padding: 4rpx;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-item {
|
||||||
|
padding: 6rpx 18rpx;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background: $color-card;
|
||||||
|
color: $color-primary;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: 0 2rpx 6rpx rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-range {
|
||||||
|
display: block;
|
||||||
|
margin: -8rpx 0 18rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-grid {
|
||||||
|
&.is-loading {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-right: -16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
width: 33.33%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding-right: 16rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4rpx;
|
||||||
|
|
||||||
|
&:nth-last-child(-n + 3) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-num {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $color-text;
|
||||||
|
|
||||||
|
&.is-ok { color: $color-success; }
|
||||||
|
&.is-warn { color: $color-warning; }
|
||||||
|
&.is-danger { color: $color-danger; }
|
||||||
|
&.is-info { color: $color-primary; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 考勤记录 ==================== */
|
||||||
|
.state-box {
|
||||||
|
padding: 60rpx 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 20rpx;
|
||||||
|
padding: 22rpx 0;
|
||||||
|
|
||||||
|
& + & {
|
||||||
|
border-top: 1rpx solid $color-divider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-date {
|
||||||
|
width: 92rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-day {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $color-text;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rd-week {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rm-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rm-shift {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $color-text;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rm-shift-time {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rm-punch {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rm-note {
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #b45309;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-status {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&.is-ok { color: $color-success; }
|
||||||
|
&.is-warn { color: $color-warning; }
|
||||||
|
&.is-danger { color: $color-danger; }
|
||||||
|
&.is-info { color: $color-primary; }
|
||||||
|
&.is-muted { color: $color-text-muted; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
padding: 24rpx 0 4rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-link {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-done {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: $color-text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-space {
|
||||||
|
height: 40rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -43,6 +43,9 @@ const NATIVE_RULES = [
|
|||||||
// 「我发起的审批 / 我的发起」也是同一页(业务口径就是申请人视角的实例列表)
|
// 「我发起的审批 / 我的发起」也是同一页(业务口径就是申请人视角的实例列表)
|
||||||
{ pattern: /审批列表|我的审批|我发起的|审批管理|审批申请|approvalList|approval\/list/i, route: '/pages/approval/index' },
|
{ pattern: /审批列表|我的审批|我发起的|审批管理|审批申请|approvalList|approval\/list/i, route: '/pages/approval/index' },
|
||||||
{ pattern: /待我审批|我审批的|审批待办|审批中心|approvalTodo|approval\/todo/i, route: '/pages/approval/verify' },
|
{ pattern: /待我审批|我审批的|审批待办|审批中心|approvalTodo|approval\/todo/i, route: '/pages/approval/verify' },
|
||||||
|
// OA 考勤打卡:必须排在「工作日程」之前——排班类菜单路径含 scheduling,
|
||||||
|
// 会被 schedule 关键词命中而错误跳到日程页
|
||||||
|
{ pattern: /考勤打卡|考勤|打卡|attendance|check-?in/i, route: '/pages/oa/attendance/index' },
|
||||||
{ pattern: /工作日程|日程|日历|待办|calendar|schedule/i, route: '/pages/tools/schedule/index' },
|
{ pattern: /工作日程|日程|日历|待办|calendar|schedule/i, route: '/pages/tools/schedule/index' },
|
||||||
// 报销管理(注意放在日程规则之后即可,与日程关键词无冲突)
|
// 报销管理(注意放在日程规则之后即可,与日程关键词无冲突)
|
||||||
{ pattern: /报销|reimburse|expense/i, route: '/pages/tools/expense/index' },
|
{ pattern: /报销|reimburse|expense/i, route: '/pages/tools/expense/index' },
|
||||||
|
|||||||
Reference in new issue
Block a user