-
404
-
页面未找到
-
很抱歉,您访问的页面不存在或已被删除。
-
-
- 返回首页
-
-
- 清除缓存
-
+
+
+
+
+
+
+
+
+
+
+
404
+
+
+
+
+
ERROR 404
+
页面走丢了
+
+ 你访问的页面不存在、已被移除,或没有访问权限。
+ 请检查链接是否正确,或返回首页继续操作。
+
+
{{ currentPath }}
+
+
+
+
+
+
+
diff --git a/backend/src/views/apps/ai/components/providerDialog.vue b/backend/src/views/apps/ai/components/providerDialog.vue
index 6556afd..a26a67e 100644
--- a/backend/src/views/apps/ai/components/providerDialog.vue
+++ b/backend/src/views/apps/ai/components/providerDialog.vue
@@ -371,12 +371,15 @@ async function handleTestProvider(row) {
testingProviderId.value = row.id
try {
- const res = await testAiProvider({
+ const payload = {
+ id: row.id,
provider_type: row.provider_type,
api_base: row.api_base,
- api_key: row.api_key,
models: row.models
- })
+ }
+ // 租户级配置的 Key 是脱敏值,交给后端按 id 取库里的完整 Key 测试
+ if (row.api_key && !String(row.api_key).includes('****')) payload.api_key = row.api_key
+ const res = await testAiProvider(payload)
if (res.code === 200 && res.data.results) {
const successCount = res.data.results.filter(r => r.success).length
diff --git a/backend/src/views/apps/oa/ai/index.vue b/backend/src/views/apps/oa/ai/index.vue
new file mode 100644
index 0000000..934bd3e
--- /dev/null
+++ b/backend/src/views/apps/oa/ai/index.vue
@@ -0,0 +1,1155 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 请先在「基础设置 → AI 设置」中填写接口地址与 API Key(支持 OpenAI 兼容 / Anthropic),保存后回到这里选择模型开始对话。
+ 前往配置
+
+
+
+
+ 场景
+
+ {{ s.label }}
+
+
+
+
+
+
+
+ 选择接入配置与模型后,输入场景问题开始对话
+
+
+
{{ m.role === 'user' ? '我' : 'AI' }}
+
+
+
+
+
+
{{ m.content }}
+
+
+
+
+
+
+ {{ m.providerName }}
+ {{ m.model }}
+ {{ displayTokens(m) }} tokens
+ {{ displaySpeed(m) }} tokens/s
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ≈ {{ totalTokens }} tokens
+
+ 发送
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/views/apps/oa/attendance/index.vue b/backend/src/views/apps/oa/attendance/index.vue
new file mode 100644
index 0000000..fe72bd1
--- /dev/null
+++ b/backend/src/views/apps/oa/attendance/index.vue
@@ -0,0 +1,1264 @@
+
+
+
+
+
+
+
+ 含下级组织
+
+ 查询
+ 重置
+
+
+
+
+
+
+
+
+ {{ periodTitle }}
+ {{ summary.label }}
+ 出勤率 {{ summary.stats.attendance_rate || 0 }}%
+ {{ summary.startDate }} 至 {{ summary.endDate }}
+
+
+
+
+
+ 月度
+ 季度
+ 年度
+
+
+
+ {{ summary.year }} 年
+
+
+
格子里为「已打卡 / 应出勤」,颜色按当日出勤率
+
+
+
+
+ 应出勤
+ {{ summary.stats.scheduled || 0 }}
+
+
+ 已打卡
+ {{ summary.stats.clocked || 0 }}
+
+
+ 迟到
+ {{ summary.stats.late || 0 }}
+
+
+ 早退
+ {{ summary.stats.early || 0 }}
+
+
+ 缺卡
+ {{ summary.stats.missing || 0 }}
+
+
+ 无排班
+ {{ summary.stats.noschedule || 0 }}
+
+
+
+
+
+
+ {{ cal.month }} 月
+ {{ cal.rate }}%
+
+
+ {{ week }}
+
+
+
+ {{ cell.day }}
+
+ {{ cell.stat.clocked }}/{{ cell.stat.scheduled }}
+
+
+
+
+
+
+
+ 出勤率 ≥ 90%
+ 70% ~ 90%
+ 低于 70%
+ 无排班打卡
+ 无排班
+
+
+
+
+
+
+ 人员考勤(全员)
+ 共 {{ summary.total }} 人 · {{ summary.label }} · 点姓名展开明细
+
+
+
+
+
+
+
+ {{ row.employee_name || '未绑定员工' }}
+
+ 应出勤 {{ row.scheduled_days }} 天 · 打卡 {{ row.clocked_days }} 天 · 迟到 {{ row.late_count }} ·
+ 早退 {{ row.early_count }} · 缺卡 {{ row.missing_days }}
+
+ {{ summary.label }}
+
+
+
+
+
+
+ {{ item.attend_date }}
+ {{ weekdayLabel(item.weekday) }}
+
+
+
+
+
+
+
+
+ {{ item.shift_name || '未匹配排班' }}
+ {{ item.shift_text }}
+
+
+
+
+
+
+
+ {{ item.clock_in_hm }}
+
+ {{ item.clock_in_text }}
+
+
+ 未签到
+
+
+
+
+
+ {{ item.clock_out_hm }}
+
+ {{ item.clock_out_text }}
+
+
+ 未签退
+
+
+
+
+ {{ item.day_status_text }}
+
+
+
+
+
+
+ {{ noteText(item) }}
+
+ —
+
+
+
+ {{ item.remark || '—' }}
+
+
+
+ 备注
+
+
+
+
+
+ 共 {{ rowDetail(row).total }} 条,仅展示前 {{ rowDetail(row).list.length }} 条
+
+
+
+
+
+
+
+
+
+
+ {{ row.employee_name || '未绑定员工' }}
+ 未绑定档案
+
+
{{ row.employee_account || '—' }}
+
+
+
+
+ {{ row.org_name || '—' }}
+
+
+ {{ row.position || '—' }}
+
+
+
+
+
+ {{ row.late_count }}
+
+
+
+
+ {{ row.early_count }}
+
+
+
+
+ {{ row.missing_days }}
+
+
+
+
+ {{ row.noschedule_count }}
+
+
+
+
+
+
+ {{ row.attendance_rate }}%
+
+ 无排班
+
+
+
+
+
+
+
+
+
+
+
+ 当日有排班但未签到的员工,可据此催办或核对排班
+
+
+
+
+
+
+
+
+
+
+ 共 {{ missingList.length }} 人
+ 关闭
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
+
+
diff --git a/backend/src/views/apps/oa/contact/components/contactDetailDialog.vue b/backend/src/views/apps/oa/contact/components/contactDetailDialog.vue
index 2ec4ef6..fcab3dd 100644
--- a/backend/src/views/apps/oa/contact/components/contactDetailDialog.vue
+++ b/backend/src/views/apps/oa/contact/components/contactDetailDialog.vue
@@ -99,6 +99,7 @@
@@ -115,7 +116,7 @@ const props = defineProps({
contact: { type: Object, default: null }
})
-const emit = defineEmits(['update:modelValue', 'edit'])
+const emit = defineEmits(['update:modelValue', 'edit', 'delete'])
const visible = computed({
get: () => props.modelValue,
@@ -153,6 +154,10 @@ const handleEdit = () => {
emit('edit', props.contact)
}
+const handleDelete = () => {
+ emit('delete', props.contact)
+}
+
const handleClose = () => {
visible.value = false
}
diff --git a/backend/src/views/apps/oa/contact/index.vue b/backend/src/views/apps/oa/contact/index.vue
index ad70456..a330386 100644
--- a/backend/src/views/apps/oa/contact/index.vue
+++ b/backend/src/views/apps/oa/contact/index.vue
@@ -134,11 +134,20 @@
{{ row.email || '-' }}
-
+
+
+
+
@@ -172,6 +181,7 @@
v-model="detailDialogVisible"
:contact="detailContact"
@edit="handleDetailEdit"
+ @delete="handleDetailDelete"
/>
@@ -294,6 +304,40 @@ const toggleStar = async (row) => {
}
}
+// ---- 删除 ----
+// 后端为软删除:只把联系人记录移出通讯录,员工档案 / 登录账号不受影响
+const handleDeleteContact = async (row) => {
+ if (!row?.id) return
+ const name = row.contact_name || '该联系人'
+ try {
+ await ElMessageBox.confirm(
+ `确定删除联系人「${name}」吗?删除后将从通讯录中移除,员工档案与登录账号不受影响。`,
+ '删除确认',
+ { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
+ )
+ } catch {
+ return
+ }
+
+ try {
+ await deleteContact(row.id)
+ ElMessage.success('删除成功')
+ // 删掉的是当前页最后一条时回退一页,避免停在空页
+ if (contactList.value.length === 1 && pagination.page > 1) {
+ pagination.page -= 1
+ }
+ await loadContactList()
+ } catch (error) {
+ ElMessage.error(error?.message || '删除失败')
+ }
+}
+
+// 详情弹窗里点删除:先关弹窗再走同一套删除流程
+const handleDetailDelete = (contact) => {
+ detailDialogVisible.value = false
+ handleDeleteContact(contact)
+}
+
// ---- 同步 ----
const handleSyncAll = async () => {
try {
diff --git a/backend/src/views/apps/organization/components/EmployeePage.vue b/backend/src/views/apps/organization/components/EmployeePage.vue
index 322d143..2905ccb 100644
--- a/backend/src/views/apps/organization/components/EmployeePage.vue
+++ b/backend/src/views/apps/organization/components/EmployeePage.vue
@@ -16,6 +16,17 @@
>
批量调岗({{ selectedRows.length }})
+
+ 批量删除({{ selectedRows.length }})
+
+
+ 导出{{ selectedRows.length ? `(${selectedRows.length})` : '' }}
+
(selectedRows = rows)"
>
-
+
+
@@ -153,7 +166,8 @@ import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
-import { Plus, Refresh, Search } from '@element-plus/icons-vue';
+import { Download, Plus, Refresh, Search } from '@element-plus/icons-vue';
+import * as XLSX from 'xlsx';
import EmployeeEditDialog from './EmployeeEditDialog.vue';
import EmployeeViewDialog from './EmployeeViewDialog.vue';
import MoveEmployeeDialog from './MoveEmployeeDialog.vue';
@@ -207,6 +221,7 @@ const handleAddEmployee = () => {
const rows = ref([]);
const selectedRows = ref([]);
+const tableRef = ref();
const loading = ref(false);
const page = ref(1);
const pageSize = ref(10);
@@ -257,6 +272,7 @@ const handleSearch = () => {
const handleMoved = () => {
selectedRows.value = [];
+ tableRef.value?.clearSelection();
loadData();
};
@@ -275,6 +291,85 @@ const handleDelete = async (row) => {
}
};
+/* ------------------------------- 批量操作 ------------------------------- */
+
+// 后端对员工不做物理删除:删除 = 置为离职并禁用对应登录账号,档案/通讯录/排班保留
+const handleBatchDelete = async () => {
+ const targets = [...selectedRows.value];
+ if (!targets.length) return;
+
+ const names = targets.slice(0, 5).map((r) => r.name || r.account).join('、');
+ const more = targets.length > 5 ? ` 等 ${targets.length} 人` : '';
+ try {
+ await ElMessageBox.confirm(
+ `将把「${names}」${more}置为离职(同时禁用其登录账号),人事档案、通讯录与排班记录会保留。确定继续吗?`,
+ '批量删除确认',
+ { type: 'warning', confirmButtonText: '置为离职', cancelButtonText: '取消' }
+ );
+ } catch {
+ return;
+ }
+
+ loading.value = true;
+ try {
+ const results = await Promise.allSettled(
+ targets.map((row) => api.value.deleteEmployee(row.id))
+ );
+ const failed = results.filter((r) => r.status === 'rejected');
+ const okCount = results.length - failed.length;
+ if (okCount > 0) {
+ ElMessage.success(`已处理 ${okCount} 人${failed.length ? `,失败 ${failed.length} 人` : ''}`);
+ }
+ if (failed.length) {
+ const reason = failed[0]?.reason?.message || '未知错误';
+ ElMessage.error(`${failed.length} 人处理失败:${reason}`);
+ }
+ tableRef.value?.clearSelection();
+ selectedRows.value = [];
+ await loadData();
+ } finally {
+ loading.value = false;
+ }
+};
+
+const exportStamp = () => {
+ const d = new Date();
+ const pad = (n) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}`;
+};
+
+// 导出:有勾选导勾选行,没有勾选导当前筛选结果
+const handleExport = () => {
+ const list = selectedRows.value.length ? selectedRows.value : rows.value;
+ if (!list.length) {
+ ElMessage.warning('暂无可导出的数据');
+ return;
+ }
+
+ const data = list.map((row, index) => ({
+ 序号: index + 1,
+ 账号: row.account || '',
+ 姓名: row.name || '',
+ 隶属单位: row.affiliate_unit_name || '',
+ 部门: row.department_name || '',
+ 职位: row.position || '',
+ 手机: row.phone || '',
+ 邮箱: row.email || '',
+ 状态: employeeStatusText(row),
+ 入职时间: row.create_time || '',
+ }));
+
+ const sheet = XLSX.utils.json_to_sheet(data);
+ sheet['!cols'] = [
+ { wch: 6 }, { wch: 16 }, { wch: 12 }, { wch: 24 }, { wch: 18 },
+ { wch: 14 }, { wch: 16 }, { wch: 24 }, { wch: 8 }, { wch: 20 },
+ ];
+ const book = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(book, sheet, `${labels.value.employeeLabel}列表`);
+ XLSX.writeFile(book, `${labels.value.employeeLabel}列表_${exportStamp()}.xlsx`);
+ ElMessage.success(`已导出 ${data.length} 条`);
+};
+
/* ------------------------------- 重置密码 ------------------------------- */
const resetDialogVisible = ref(false);
diff --git a/backend/src/views/basicSettings/ai/index.vue b/backend/src/views/basicSettings/ai/index.vue
new file mode 100644
index 0000000..dd65c73
--- /dev/null
+++ b/backend/src/views/basicSettings/ai/index.vue
@@ -0,0 +1,486 @@
+
+
+
+
+
+
+ 支持 OpenAI 兼容接口(Chat Completions,如 DeepSeek / 豆包 / OpenRouter)与 Anthropic Messages。
+ 接口地址填到版本号即可(如 https://api.deepseek.com、https://api.openai.com/v1),密钥仅保存在本租户内。
+
+
+
+
+
+
+
+
+ {{ row.name }}
+ 默认
+
+
+
+
+
+
+ {{ row.provider_type === 'anthropic' ? 'Anthropic' : 'OpenAI 兼容' }}
+
+
+
+
+
+
+ {{ m }}
+
+
+
+
+
+
+
+ {{ row.status === 1 ? '启用' : '禁用' }}
+
+
+
+
+ {{ row.remark || '—' }}
+
+
+
+ 编辑
+ 测试
+ 删除
+
+
+
+
+
+
+
+
+
+
+ OpenAI 兼容
+ Anthropic Messages
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 默认接入会被 AI 广场优先使用
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.model }} · {{ item.success ? '通' : '失败' }}
+
+ {{ item.error }}
+
+
+
+
+
+
+ 测试连接
+ 取消
+ 保存
+
+
+
+
+
+
+
+
diff --git a/backend/src/views/home/index.vue b/backend/src/views/home/index.vue
index 29a17ba..9f34d0c 100644
--- a/backend/src/views/home/index.vue
+++ b/backend/src/views/home/index.vue
@@ -4,8 +4,16 @@
@@ -231,6 +239,7 @@ import {
import { getTenantList } from "@/api/modules";
import { getProductOrderStatus } from "@/api/product";
import { getCurrentUserProfile, logout } from "@/api/login";
+import { getBackendBrand } from "@/api/site";
import { reloadMenusAndDynamicRoutes } from "@/router";
import { useAuthStore } from "@/stores/auth";
import { useMenuStore } from "@/stores/menu";
@@ -255,6 +264,34 @@ const router = useRouter();
const authStore = useAuthStore();
const menuStore = useMenuStore();
+// ---------- 门户品牌:平台「平台设置 - 平台信息」的主标题与方形 Logo ----------
+const API_BASE_URL = String(import.meta.env.VITE_API_BASE_URL || "").replace(/\/+$/, "");
+const brandName = ref("温新云平台");
+const brandLogoLight = ref("");
+const brandLogoDark = ref("");
+const brandLogoBroken = ref(false);
+const resolveBrandImage = (url) => {
+ const value = String(url || "").trim().replace(/\\/g, "/");
+ if (!value) return "";
+ if (/^(https?:)?\/\//i.test(value) || /^(data|blob):/i.test(value)) return value;
+ return `${API_BASE_URL}/${value.replace(/^\/+/, "")}`;
+};
+const fetchBrand = async () => {
+ try {
+ const res = await getBackendBrand();
+ if (res?.code !== 200 || !res.data) return;
+ const data = res.data;
+ if (data.sitename) brandName.value = data.sitename;
+ // 门户与后台顶栏统一用长方形 Logo({yz:logo_rect}),深色主题用其深色版本;
+ // 未配置时回退官网浅色/深色 Logo,仍未配置则不显示图片
+ brandLogoLight.value = data.logoRect || data.logo || "";
+ brandLogoDark.value =
+ data.logoRectDark || data.logow || data.logoRect || data.logo || "";
+ } catch (error) {
+ // 品牌信息属于装饰内容,取不到时保留默认文案
+ }
+};
+
// 已开通模块排在前面,套餐未开通(锁定)的模块沉到后方
function sortUnlockedFirst(list: ModuleItem[]): ModuleItem[] {
return [...list].sort(
@@ -296,6 +333,15 @@ const toggleTheme = () => {
const currentTheme = computed(() => (isDark.value ? "dark" : "light"));
const themeIcon = computed(() => (isDark.value ? Sunny : Moon));
+
+// 深色主题优先用深色主题专用方形 Logo,未配置回退浅色版本
+const brandLogoSrc = computed(() => {
+ if (brandLogoBroken.value) return "";
+ const value = isDark.value
+ ? brandLogoDark.value || brandLogoLight.value
+ : brandLogoLight.value;
+ return resolveBrandImage(value);
+});
let stopSystemThemeWatch = null;
const userName = computed(
@@ -472,6 +518,8 @@ onMounted(() => {
loadModules();
syncReturnedPayment();
initTheme();
+ // 品牌 Logo / 站点名称取自平台「平台设置 - 平台信息」
+ fetchBrand();
stopSystemThemeWatch = onSystemThemeChange(() => {
if (themeMode.value === "system") applyTheme();
});
@@ -529,30 +577,15 @@ onUnmounted(() => {
gap: 9px;
}
- .home-logo {
- display: grid;
- grid-template-columns: repeat(2, 8px);
- gap: 3px;
- place-content: center;
- width: 32px;
- height: 32px;
- border-radius: 9px;
- background: @primary-gradient;
-
- i {
- width: 8px;
- height: 8px;
- border-radius: 2px;
- background: #fff;
-
- &:nth-child(2) {
- opacity: 0.55;
- }
-
- &:nth-child(3) {
- opacity: 0.75;
- }
- }
+ // 平台下发的长方形 Logo({yz:logo_rect}),高度与后台顶栏一致(60px)
+ .home-logo-img {
+ height: 60px;
+ width: auto;
+ max-width: 240px;
+ border-radius: 8px;
+ object-fit: contain;
+ display: block;
+ flex: 0 0 auto;
}
.home-brand-name {
diff --git a/go/controllers/app_oa_attendance.go b/go/controllers/app_oa_attendance.go
new file mode 100644
index 0000000..9857be4
--- /dev/null
+++ b/go/controllers/app_oa_attendance.go
@@ -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),
+ })
+}
diff --git a/go/controllers/backend_ai_chat.go b/go/controllers/backend_ai_chat.go
index 69d25b3..7e0fd6f 100644
--- a/go/controllers/backend_ai_chat.go
+++ b/go/controllers/backend_ai_chat.go
@@ -16,6 +16,7 @@ import (
"server/pkg/jwtutil"
"server/services"
+ "github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
@@ -51,6 +52,8 @@ type chatSendPayload struct {
McpServerIDs []uint64 `json:"mcp_server_ids"`
// Images 用户上传的图片(base64 dataURL),仅多模态模型可用
Images []string `json:"images"`
+ // SystemPrompt 本轮自定义系统提示词(AI 广场的场景对话使用),为空时回落租户预设
+ SystemPrompt string `json:"system_prompt"`
}
// mcpEnabled 解析本轮是否启用 MCP(未传默认开启)
@@ -542,7 +545,8 @@ func resolveOrCreateSession(c *BackendAiChatController, claims *jwtutil.Claims,
return session, nil
}
-// resolveProvider 解析 AI 接入配置
+// resolveProvider 解析 AI 接入配置。
+// 优先级:租户级配置(user_id=0,由 OA 设置统一维护)→ 个人配置(历史数据)。
func resolveProvider(claims *jwtutil.Claims, p *chatSendPayload, session *models.BackendAiChatSession) (models.BackendAiProvider, error) {
providerID := p.ProviderID
if providerID == 0 {
@@ -555,24 +559,33 @@ func resolveProvider(claims *jwtutil.Claims, p *chatSendPayload, session *models
if err := models.Orm.Read(&provider); err != nil {
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("无权使用该配置")
}
if provider.Status != 1 {
return provider, fmt.Errorf("该AI接入配置已被禁用")
}
} 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
_, err := models.Orm.QueryTable(new(models.BackendAiProvider)).
- Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)).
- Filter("user_id", uint64(claims.UserID)).
- Filter("status", 1).
- Filter("delete_time__isnull", true).
- OrderBy("-id").
+ SetCond(cond).
+ OrderBy("user_id", "-is_default", "-id").
Limit(1).
All(&providers)
if err != nil || len(providers) == 0 {
- return provider, fmt.Errorf("尚未配置AI接入,请先在设置中配置OpenAI或Anthropic接入")
+ return provider, fmt.Errorf("尚未配置AI接入,请先联系管理员在「基础设置 → AI 设置」中填写接口地址与 API Key")
}
provider = providers[0]
}
@@ -610,6 +623,17 @@ func loadSystemPrompt(claims *jwtutil.Claims) string {
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条,正序)
func loadHistory(sessionID uint64) []openaiMessage {
var history []models.BackendAiChatMessage
@@ -671,7 +695,7 @@ func (c *BackendAiChatController) Send() {
c.chatJsonErr(400, 400, err.Error())
return
}
- systemPrompt := loadSystemPrompt(claims)
+ systemPrompt := chatSystemPrompt(claims, &p)
messages := loadHistory(session.ID)
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())
return
}
- systemPrompt := loadSystemPrompt(claims)
+ systemPrompt := chatSystemPrompt(claims, &p)
messages := loadHistory(session.ID)
messages = append(messages, openaiMessage{Role: "user", Content: p.Content, Images: p.Images})
diff --git a/go/controllers/backend_ai_provider.go b/go/controllers/backend_ai_provider.go
index a383799..83899f4 100644
--- a/go/controllers/backend_ai_provider.go
+++ b/go/controllers/backend_ai_provider.go
@@ -13,6 +13,7 @@ import (
"server/pkg/identity"
"server/pkg/jwtutil"
+ "github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
@@ -46,13 +47,22 @@ type aiProviderPayload struct {
IsDefault int8 `json:"is_default"`
Status int8 `json:"status"`
Remark string `json:"remark"`
+ // Scope "tenant" 表示租户级配置(user_id=0,全员共用,由「基础设置 → AI 设置」维护);
+ // 其它值/空表示个人配置,仅创建者可用。
+ Scope string `json:"scope"`
}
-// clearTenantDefault 取消租户内所有默认配置
-func clearTenantDefault(tenantID string) {
+// aiTenantScope 本次请求是否在维护租户级配置
+func aiTenantScope(scope string) bool {
+ return strings.EqualFold(strings.TrimSpace(scope), "tenant")
+}
+
+// clearTenantDefault 取消同一归属范围内(租户级 / 同一用户)的其他默认配置
+func clearTenantDefault(tenantID string, userID uint64) {
var defaults []models.BackendAiProvider
_, _ = models.Orm.QueryTable(new(models.BackendAiProvider)).
Filter("tenant_id", tenantID).
+ Filter("user_id", userID).
Filter("is_default", 1).
All(&defaults)
for _, d := range defaults {
@@ -75,7 +85,9 @@ func parseProviderModels(m string) []string {
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() {
claims, err := c.aiClaims()
if err != nil {
@@ -84,21 +96,34 @@ func (c *BackendAiProviderController) List() {
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
_, err = models.Orm.QueryTable(new(models.BackendAiProvider)).
- Filter("tenant_id", fmt.Sprintf("%d", claims.TenantId)).
- Filter("user_id", uint64(claims.UserID)).
- Filter("delete_time__isnull", true).
- OrderBy("-id").
+ SetCond(cond).
+ OrderBy("user_id", "-id").
All(&list)
if err != nil {
c.aiJsonErr(500, 500, "查询失败: "+err.Error())
return
}
- // 解析模型列表(不脱敏api_key,前端需要完整api_key进行测试)
+ // 解析模型列表。个人配置不回传脱敏 Key(本人可见);
+ // 租户级配置(user_id=0)全租户共用,Key 必须脱敏,测试走 test 接口的 id 模式。
for i := range list {
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})
@@ -144,14 +169,20 @@ func (c *BackendAiProviderController) Create() {
// 模型数组序列化为JSON
modelsJSON, _ := json.Marshal(p.Models)
- // 如果设为默认,先取消租户内其他默认
+ // 租户级配置(user_id=0)由 OA 设置统一维护,全员可用
+ ownerID := uint64(claims.UserID)
+ if aiTenantScope(p.Scope) {
+ ownerID = 0
+ }
+
+ // 如果设为默认,先取消同一归属范围内的其他默认
if p.IsDefault == 1 {
- clearTenantDefault(fmt.Sprintf("%d", claims.TenantId))
+ clearTenantDefault(fmt.Sprintf("%d", claims.TenantId), ownerID)
}
provider := models.BackendAiProvider{
TenantID: fmt.Sprintf("%d", claims.TenantId),
- UserID: uint64(claims.UserID),
+ UserID: ownerID,
ProviderType: p.ProviderType,
Name: strings.TrimSpace(p.Name),
ApiBase: strings.TrimSpace(p.ApiBase),
@@ -199,6 +230,11 @@ func (c *BackendAiProviderController) Update() {
c.aiJsonErr(403, 403, "无权操作")
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)
if err != nil {
@@ -211,9 +247,9 @@ func (c *BackendAiProviderController) Update() {
return
}
- // 如果设为默认,先取消租户内其他默认
+ // 如果设为默认,先取消同一归属范围内的其他默认
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)
@@ -268,6 +304,10 @@ func (c *BackendAiProviderController) Delete() {
c.aiJsonErr(403, 403, "无权操作")
return
}
+ if provider.UserID != 0 && provider.UserID != uint64(claims.UserID) {
+ c.aiJsonErr(403, 403, "无权操作")
+ return
+ }
now := time.Now()
provider.DeleteTime = &now
@@ -281,6 +321,8 @@ func (c *BackendAiProviderController) Delete() {
}
type aiTestPayload struct {
+ // ID 已保存的配置ID:不传 api_key(或传脱敏值)时用库里的密钥测试
+ ID uint64 `json:"id"`
ProviderType string `json:"provider_type"`
ApiBase string `json:"api_base"`
ApiKey string `json:"api_key"`
@@ -293,10 +335,26 @@ type modelTestResult struct {
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
-// 批量测试模型连通性
+// 批量测试模型连通性。支持两种模式:
+// 1. 传 api_base + api_key:直接测试(新增/修改未保存的表单);
+// 2. 传 id(api_key 留空或为脱敏值):用已保存配置的密钥测试。
func (c *BackendAiProviderController) Test() {
- if _, err := c.aiClaims(); err != nil {
+ claims, err := c.aiClaims()
+ if err != nil {
code := identity.Status(err)
c.aiJsonErr(code, code, err.Error())
return
@@ -313,7 +371,31 @@ func (c *BackendAiProviderController) Test() {
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不能为空")
return
}
@@ -326,7 +408,7 @@ func (c *BackendAiProviderController) Test() {
provider := models.BackendAiProvider{
ProviderType: p.ProviderType,
ApiBase: strings.TrimSpace(p.ApiBase),
- ApiKey: strings.TrimSpace(p.ApiKey),
+ ApiKey: apiKey,
}
testMessages := []openaiMessage{{Role: "user", Content: "hi"}}
diff --git a/go/controllers/backend_oa_attendance.go b/go/controllers/backend_oa_attendance.go
new file mode 100644
index 0000000..cd9bdc8
--- /dev/null
+++ b/go/controllers/backend_oa_attendance.go
@@ -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))
+}
diff --git a/go/controllers/backend_oa_attendance_summary.go b/go/controllers/backend_oa_attendance_summary.go
new file mode 100644
index 0000000..6fe113a
--- /dev/null
+++ b/go/controllers/backend_oa_attendance_summary.go
@@ -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()
+}
diff --git a/go/controllers/backend_oa_attendance_test.go b/go/controllers/backend_oa_attendance_test.go
new file mode 100644
index 0000000..77f6f1c
--- /dev/null
+++ b/go/controllers/backend_oa_attendance_test.go
@@ -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)
+ }
+}
diff --git a/go/controllers/backend_oa_scheduling_board.go b/go/controllers/backend_oa_scheduling_board.go
index 78e66bc..99a5a4d 100644
--- a/go/controllers/backend_oa_scheduling_board.go
+++ b/go/controllers/backend_oa_scheduling_board.go
@@ -843,20 +843,25 @@ func (c *BackendOaSchedulingController) MyBoard() {
}
// scFindSelfEmployee 把登录身份映射到本租户的员工档案。
+func (c *BackendOaSchedulingController) scFindSelfEmployee(tid, identityID uint64, username string) (models.BackendEmployee, bool) {
+ return findSelfEmployee(tid, identityID, username)
+}
+
+// findSelfEmployee 把登录身份映射到本租户的员工档案(包级函数,排班与考勤共用)。
// 优先取认证中心「身份+租户」绑定记录里的账号(与 /auth/userinfo 同源,最可靠),
// 其次用令牌 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
if identityID > 0 {
if bind, err := authSvc.GetTenantUser(identityID, tid); err == nil && bind != nil {
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
}
}
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
}
}
@@ -866,7 +871,7 @@ func (c *BackendOaSchedulingController) scFindSelfEmployee(tid, identityID uint6
username = strings.TrimSpace(username)
if username != "" {
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
}
}
@@ -876,8 +881,13 @@ func (c *BackendOaSchedulingController) scFindSelfEmployee(tid, identityID uint6
// scEmployeeByField 在本租户员工档案中按指定字段匹配,取第一条有效记录
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
- if _, err := c.scBaseEmployeeQuery(tid).
+ if _, err := employeeBaseQuery(tid).
Filter(field, value).
OrderBy("id").
Limit(1).
diff --git a/go/controllers/backend_oa_scheduling_scope.go b/go/controllers/backend_oa_scheduling_scope.go
index de42cc2..a42b748 100644
--- a/go/controllers/backend_oa_scheduling_scope.go
+++ b/go/controllers/backend_oa_scheduling_scope.go
@@ -80,6 +80,11 @@ func (index *scOrgIndex) name(id uint64) string {
// scBaseEmployeeQuery 排班可选员工:未删除、非离职。
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)).
Filter("tid", tid).
Filter("delete_time__isnull", true).
diff --git a/go/controllers/backend_site_settings.go b/go/controllers/backend_site_settings.go
index ee3049f..f15207e 100644
--- a/go/controllers/backend_site_settings.go
+++ b/go/controllers/backend_site_settings.go
@@ -141,6 +141,85 @@ func (c *BackendSiteSettingsController) GetNormalInfos() {
_ = 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 {
// 前端会传 tid(但我们仍优先使用 token 的 tenant_id)
Tid interface{} `json:"tid"`
diff --git a/go/docs/sql/create_oa_attendance.sql b/go/docs/sql/create_oa_attendance.sql
new file mode 100644
index 0000000..77facb6
--- /dev/null
+++ b/go/docs/sql/create_oa_attendance.sql
@@ -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';
diff --git a/go/docs/sql/fix_employee_tid_sync.sql b/go/docs/sql/fix_employee_tid_sync.sql
new file mode 100644
index 0000000..8d90819
--- /dev/null
+++ b/go/docs/sql/fix_employee_tid_sync.sql
@@ -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;
diff --git a/go/docs/sql/oa_ai_menus.sql b/go/docs/sql/oa_ai_menus.sql
new file mode 100644
index 0000000..5eb73ed
--- /dev/null
+++ b/go/docs/sql/oa_ai_menus.sql
@@ -0,0 +1,122 @@
+-- ============================================================
+-- OA AI 办公 —— 菜单配置(yz_system_menu)
+-- 幂等脚本:按 path 判重,重复执行不会产生重复菜单
+-- 执行:mysql -h -P -u -p < 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;
diff --git a/go/docs/sql/oa_attendance_menus.sql b/go/docs/sql/oa_attendance_menus.sql
new file mode 100644
index 0000000..0193f27
--- /dev/null
+++ b/go/docs/sql/oa_attendance_menus.sql
@@ -0,0 +1,160 @@
+-- ============================================================
+-- OA 考勤管理 —— 菜单配置(yz_system_menu)
+-- 幂等脚本:按 path 判重,重复执行不会产生重复菜单
+-- 执行:mysql -h -P -u -p < 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;
diff --git a/go/docs/sql/oa_scheduling_menus.sql b/go/docs/sql/oa_scheduling_menus.sql
index f5f244c..940ff2d 100644
--- a/go/docs/sql/oa_scheduling_menus.sql
+++ b/go/docs/sql/oa_scheduling_menus.sql
@@ -3,10 +3,12 @@
-- 幂等脚本:按 path 判重,重复执行不会产生重复菜单
-- 执行:mysql -h -P -u -p < oa_scheduling_menus.sql
--
--- 挂载位置:OA 应用根(path = '/apps/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
-- 同一页面内完成:班次管理、排班方案(公司/部门/小组/指定员工)、
@@ -14,11 +16,11 @@
--
-- 说明:
-- 1. 排班看板是 Web 管理端功能,platforms 显式为 ["web"],
--- 移动端菜单不出现(后续若开放员工查看排班,再补 APP 端页面)。
--- 2. 租户端角色(cid=2)的 rights 是显式菜单 ID 列表,菜单接口会按
--- rights 过滤。只插菜单不授权,会出现「目录可见但页面被过滤」,
--- 因此本脚本同时把两个菜单 ID 追加到角色 rights。
--- 目录与页面都必须授权,缺一不可。
+-- 移动端菜单不出现;
+-- 2. 历史版本创建的「排班考勤」目录不再使用,脚本会把它隐藏(保留数据);
+-- 3. 租户端角色(cid=2)的 rights 是显式菜单 ID 列表,菜单接口会按
+-- rights 过滤。只插菜单不授权,会出现「菜单存在但被过滤」,
+-- 因此本脚本同时把菜单 ID 追加到角色 rights。
-- ============================================================
SET @oa_id := (
@@ -27,32 +29,51 @@ SET @oa_id := (
LIMIT 1
);
--- ---------- 排班考勤(目录) ----------
-INSERT INTO yz_system_menu
- (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 := (
+-- 「办公」目录:优先同名菜单,其次 /apps/oa/personal;都没有则回落 OA 根
+SET @office_id := (
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
);
--- ---------- 工作排班(页面) ----------
+SET @parent_id := IFNULL(@office_id, @oa_id);
+
+-- ---------- 工作排班(页面,直接挂在办公下) ----------
INSERT INTO yz_system_menu
(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',
- 'Clock', 1, 1, 1, '[2]', '["web"]', 2, 'system',
+SELECT @parent_id, '工作排班', '/apps/oa/scheduling/board', '/apps/oa/scheduling/index.vue',
+ 'Clock', 88, 1, 1, '[2]', '["web"]', 2, 'system',
'排班看板:按周查看排班,支持方案发布、单条调班与批量临时排班'
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);
+-- 已存在则上移到「办公」,并纠正为 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 := (
SELECT id FROM yz_system_menu
WHERE path = '/apps/oa/scheduling/board' AND delete_time IS NULL
@@ -61,16 +82,6 @@ SET @board_id := (
-- ---------- 角色授权:把菜单追加到所有租户端角色的 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
SET rights = CAST(JSON_ARRAY_APPEND(CAST(rights AS JSON), '$', @board_id) AS CHAR)
WHERE cid = 2
@@ -81,13 +92,13 @@ WHERE cid = 2
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
-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;
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
FROM yz_system_admin_role
WHERE cid = 2 AND rights IS NOT NULL AND TRIM(rights) <> ''
diff --git a/go/models/init.go b/go/models/init.go
index 0c32a67..0061274 100644
--- a/go/models/init.go
+++ b/go/models/init.go
@@ -207,6 +207,8 @@ func Init(_ string) {
new(OaSchedulePlan),
new(OaScheduleEntry),
new(OaScheduleChange),
+ // 考勤打卡(docs/sql/create_oa_attendance.sql)
+ new(OaAttendance),
new(OaHoliday),
new(OaNotice),
// 工作日报(docs/sql/create_oa_dailyreport.sql)
diff --git a/go/models/oa_attendance.go b/go/models/oa_attendance.go
new file mode 100644
index 0000000..2c7a3b0
--- /dev/null
+++ b/go/models/oa_attendance.go
@@ -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"
+}
diff --git a/go/models/platform_site_setting.go b/go/models/platform_site_setting.go
index ca3eeba..5ea0516 100644
--- a/go/models/platform_site_setting.go
+++ b/go/models/platform_site_setting.go
@@ -66,6 +66,14 @@ var PlatformSiteSettingDefs = []PlatformSiteSettingDef{
{Tag: "privacy", Title: "隐私政策", Remark: "隐私政策链接,如 /page/privacy | 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 左侧展示)
{Tag: "login_title", Title: "登录页主标题", Remark: "登录页左侧主标题,留空则使用默认文案 | group:登录页设置"},
{Tag: "login_subtitle", Title: "登录页副标题", Remark: "登录页左侧副标题,留空则使用默认文案 | group:登录页设置"},
diff --git a/go/routers/api/api.go b/go/routers/api/api.go
index d7a2ff5..e1820bf 100644
--- a/go/routers/api/api.go
+++ b/go/routers/api/api.go
@@ -110,6 +110,12 @@ func Register() {
// OA 工作台仪表盘(移动端首屏顶部聚合:待办/报销统计 + 通知公告 + 今日日程)
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/* 同一套数据;小程序「工作日程」页面使用)
beego.Router("/api/oa/schedule/list", &controllers.AppOaController{}, "get:ScheduleList")
beego.Router("/api/oa/schedule/detail/:id", &controllers.AppOaController{}, "get:ScheduleDetail")
diff --git a/go/routers/backend/backend.go b/go/routers/backend/backend.go
index fc420fb..cf62aa7 100644
--- a/go/routers/backend/backend.go
+++ b/go/routers/backend/backend.go
@@ -118,6 +118,9 @@ func RegisterAuthRoutes() {
beego.Router("/backend/loginLogs/:id", &controllers.BackendLoginLogController{}, "get:Detail;delete:Delete")
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/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/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日程管理(日历待办)
beego.Router("/backend/oa/schedule/list", &controllers.BackendOaScheduleController{}, "get:List")
beego.Router("/backend/oa/schedule/stats", &controllers.BackendOaScheduleController{}, "get:Stats")
diff --git a/go/scripts/syncemployeeusers/main.go b/go/scripts/syncemployeeusers/main.go
new file mode 100644
index 0000000..36fc9f3
--- /dev/null
+++ b/go/scripts/syncemployeeusers/main.go
@@ -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())
+}
diff --git a/go/services/employee_sync.go b/go/services/employee_sync.go
index 48f4192..b003a75 100644
--- a/go/services/employee_sync.go
+++ b/go/services/employee_sync.go
@@ -44,15 +44,13 @@ func EnsureEmployeeForTenantUser(tu *models.SystemTenantUser) (*models.BackendEm
return nil, false, nil
}
+ // 员工表按 (tid, account) 唯一:同一账号在不同企业各有一份员工档案,
+ // 因此这里必须带上 tid,否则会把其他企业的同账号员工误判为本企业已有。
var emp models.BackendEmployee
- // 员工表的 uk_account 是全局唯一索引(不是 tid+account 复合索引)。
- // 因此先按账号查全表,避免已有的历史/其他租户同账号记录被误判为缺失后再 INSERT。
err := models.Orm.QueryTable(new(models.BackendEmployee)).
+ Filter("tid", tid).
Filter("account", account).
One(&emp)
- if err == nil && emp.Tid != nil && *emp.Tid > 0 && *emp.Tid != tid {
- return nil, false, fmt.Errorf("员工账号 %s 已被其他租户使用,不能重复创建", account)
- }
name := account
if tu.Name != nil && strings.TrimSpace(*tu.Name) != "" {
diff --git a/platform/src/App.vue b/platform/src/App.vue
index 6c5c720..3d0f7d7 100644
--- a/platform/src/App.vue
+++ b/platform/src/App.vue
@@ -1,5 +1,19 @@
diff --git a/platform/src/utils/favicon.js b/platform/src/utils/favicon.js
new file mode 100644
index 0000000..5c2d566
--- /dev/null
+++ b/platform/src/utils/favicon.js
@@ -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;
+}
diff --git a/platform/src/views/404/404.vue b/platform/src/views/404/404.vue
index f04cbfc..a1dbc66 100644
--- a/platform/src/views/404/404.vue
+++ b/platform/src/views/404/404.vue
@@ -1,32 +1,120 @@
-
-
-
404
-
页面未找到
-
很抱歉,您访问的页面不存在或已被删除。
-
-
- 返回首页
-
-
- 清除缓存
-
+
+
+
+
+
+
+
+
+
+
+
404
+
+
+
+
+
ERROR 404
+
页面走丢了
+
+ 你访问的页面不存在、已被移除,或没有访问权限。
+ 请检查链接是否正确,或返回首页继续操作。
+
+
{{ currentPath }}
+
+
+
+
+
+
+
diff --git a/platform/src/views/system/platformsettings/components/platformInfoSettings.vue b/platform/src/views/system/platformsettings/components/platformInfoSettings.vue
new file mode 100644
index 0000000..77c49ec
--- /dev/null
+++ b/platform/src/views/system/platformsettings/components/platformInfoSettings.vue
@@ -0,0 +1,473 @@
+
+
+
+
+
+
+
diff --git a/platform/src/views/system/platformsettings/index.vue b/platform/src/views/system/platformsettings/index.vue
index 3648581..030c9ea 100644
--- a/platform/src/views/system/platformsettings/index.vue
+++ b/platform/src/views/system/platformsettings/index.vue
@@ -8,6 +8,11 @@
+
+
+
+
+
import { ref } from "vue";
+import platformInfoSettings from "./components/platformInfoSettings.vue";
import platformSettings from "./components/platformSettings.vue";
import storageSettings from "./components/storageSettings.vue";
import notificationSettings from "./components/notificationSettings.vue";
import loginBrandSettings from "./components/loginBrandSettings.vue";
-const activeTab = ref("platform");
+const activeTab = ref("platformInfo");
diff --git a/uniapp/utils/nav.js b/uniapp/utils/nav.js
index e3a386e..b3124b6 100644
--- a/uniapp/utils/nav.js
+++ b/uniapp/utils/nav.js
@@ -43,6 +43,9 @@ const NATIVE_RULES = [
// 「我发起的审批 / 我的发起」也是同一页(业务口径就是申请人视角的实例列表)
{ pattern: /审批列表|我的审批|我发起的|审批管理|审批申请|approvalList|approval\/list/i, route: '/pages/approval/index' },
{ 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: /报销|reimburse|expense/i, route: '/pages/tools/expense/index' },