From 4f1acaa4517539d2f0d52662dbc01d7b8c30ce04 Mon Sep 17 00:00:00 2001 From: lzq <357099073@qq.com> Date: Wed, 11 Nov 2026 08:53:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/api/crmPipeline.js | 2 +- .../apps/crm/contract/components/detail.vue | 4 +- backend/src/views/apps/crm/contract/index.vue | 27 +- backend/src/views/apps/crm/project/index.vue | 28 +- .../components/DetailDrawer.vue | 18 +- .../src/views/apps/oa/approvalcenter/list.vue | 12 +- .../src/views/apps/oa/approvalcenter/mine.vue | 12 +- .../views/apps/oa/approvalcenter/settings.vue | 18 +- backend/src/views/apps/oa/dashboard/index.vue | 581 +++++++++++++----- .../components/fileDetailDrawer.vue | 17 +- .../components/recordEditDialog.vue | 4 +- .../oa/schedule/components/postponeDialog.vue | 17 +- backend/src/views/apps/oa/schedule/index.vue | 181 +++--- backend/src/views/dashboard/index.vue | 368 ++++++++++- go/controllers/backend_crm_bidding_bid.go | 2 +- go/controllers/backend_crm_bidding_tender.go | 2 +- go/controllers/backend_crm_contract.go | 6 +- go/controllers/backend_crm_payback.go | 2 +- go/controllers/backend_oa_schedule.go | 66 +- go/controllers/backend_oa_scheduling_board.go | 68 +- go/controllers/backend_weather.go | 146 +++++ go/middleware/operationLog.go | 4 + go/models/tenant_crm_operate_log.go | 2 +- go/routers/backend/backend.go | 3 + go/services/payback_overdue_scheduler.go | 10 +- 25 files changed, 1265 insertions(+), 335 deletions(-) create mode 100644 go/controllers/backend_weather.go diff --git a/backend/src/api/crmPipeline.js b/backend/src/api/crmPipeline.js index 4aba774..5b25e78 100644 --- a/backend/src/api/crmPipeline.js +++ b/backend/src/api/crmPipeline.js @@ -3,7 +3,7 @@ import request from "@/utils/request"; /** * CRM 业务管线接口:线索 → 商机 → 项目,回访贯穿全流程。 * - * 关联类型 related_type:1=线索 2=商机 3=项目 + * 关联类型 related_type:1=线索 2=商机 3=项目 4=投标 5=招标 6=合同 */ /* --------------------------------- 线索 --------------------------------- */ diff --git a/backend/src/views/apps/crm/contract/components/detail.vue b/backend/src/views/apps/crm/contract/components/detail.vue index fa3e03a..654a5d4 100644 --- a/backend/src/views/apps/crm/contract/components/detail.vue +++ b/backend/src/views/apps/crm/contract/components/detail.vue @@ -110,8 +110,8 @@ const props = defineProps({ const emit = defineEmits(["update:visible", "refresh", "edit"]); -// 合同操作日志与项目共用关联类型 3 -const RELATED_TYPE = 3; +// 合同操作日志独立关联类型 6(与项目 3 解耦,避免 ID 撞车时日志串数据) +const RELATED_TYPE = 6; // 电子合同附件专属关联类型(与线索 1 / 商机 2 / 项目 3 区分) const E_CONTRACT_TYPE = 4; // 发票管理附件专属关联类型 diff --git a/backend/src/views/apps/crm/contract/index.vue b/backend/src/views/apps/crm/contract/index.vue index 61f3a10..d747808 100644 --- a/backend/src/views/apps/crm/contract/index.vue +++ b/backend/src/views/apps/crm/contract/index.vue @@ -60,6 +60,18 @@ + +
+ + + + + + + + +
+
@@ -88,11 +100,6 @@ - - - - - 查询 重置 @@ -245,6 +252,15 @@ const filters = reactive({ }); const pagination = reactive({ page: 1, pageSize: 20, total: 0 }); +/** 状态 Tab 绑定值,空字符串表示"全部" */ +const activeStatusTab = ref(""); + +/** 切换状态 Tab:同步到 filters.status 并刷新列表 */ +function handleStatusTabChange(name) { + filters.status = name; + handleSearch(); +} + /** * 统计周期:all(全部)/ month / quarter / year / custom(自定义选区间)。 * 默认「本年」——列表与统计都按签订日期落在本年内归类,打开即看当年合同。 @@ -401,6 +417,7 @@ function resetFilters() { filters.contract_category = ""; filters.project_type = ""; filters.status = ""; + activeStatusTab.value = ""; handleSearch(); } diff --git a/backend/src/views/apps/crm/project/index.vue b/backend/src/views/apps/crm/project/index.vue index 68a39c1..9807bc8 100644 --- a/backend/src/views/apps/crm/project/index.vue +++ b/backend/src/views/apps/crm/project/index.vue @@ -11,6 +11,19 @@
+ +
+ + + + +
+
@@ -23,11 +36,6 @@ @keyup.enter="handleSearch" /> - - - - - @@ -179,6 +187,15 @@ const pagination = reactive({ page: 1, pageSize: 20, total: 0 }); /** 年度筛选项:由后端按现有项目的开始时间汇总返回(倒序),避免前端写死年份范围 */ const yearOptions = ref([]); +/** 状态 Tab 绑定值,空字符串表示"全部" */ +const activeStatusTab = ref(""); + +/** 切换状态 Tab:同步到 filters.status 并刷新列表 */ +function handleStatusTabChange(name) { + filters.status = name; + handleSearch(); +} + onMounted(() => { fetchList(); }); @@ -221,6 +238,7 @@ function resetFilters() { filters.keyword = ""; filters.status = ""; filters.year = ""; + activeStatusTab.value = ""; handleSearch(); } diff --git a/backend/src/views/apps/oa/approvalcenter/components/DetailDrawer.vue b/backend/src/views/apps/oa/approvalcenter/components/DetailDrawer.vue index ca036f9..52ee881 100644 --- a/backend/src/views/apps/oa/approvalcenter/components/DetailDrawer.vue +++ b/backend/src/views/apps/oa/approvalcenter/components/DetailDrawer.vue @@ -240,7 +240,7 @@ function tagType(type) { padding-left: 10px; font-size: 15px; font-weight: 600; - color: #303133; + color: var(--el-text-color-primary); &::before { content: ""; @@ -251,7 +251,7 @@ function tagType(type) { width: 3px; height: 14px; border-radius: 2px; - background: #2563eb; + background: var(--el-color-primary); } } @@ -300,7 +300,7 @@ function tagType(type) { width: 16px; height: 16px; border-radius: 8px; - border: 2px solid #fff; + border: 2px solid var(--el-bg-color); display: flex; align-items: center; justify-content: center; @@ -327,7 +327,7 @@ function tagType(type) { flex: 1; width: 0; margin-top: 6px; - border-left: 1px dashed #cbd5e1; + border-left: 1px dashed var(--el-border-color); } .flow-main { @@ -350,28 +350,28 @@ function tagType(type) { .flow-name { font-size: 14px; font-weight: 600; - color: #303133; + color: var(--el-text-color-primary); } .flow-sub { margin-top: 2px; font-size: 12px; - color: #909399; + color: var(--el-text-color-secondary); } .flow-time { margin-top: 4px; font-size: 12px; - color: #909399; + color: var(--el-text-color-secondary); } .flow-comment { margin-top: 8px; padding: 8px 10px; - background: #f1f5f9; + background: var(--el-fill-color-light); border-radius: 6px; font-size: 13px; - color: #606266; + color: var(--el-text-color-regular); line-height: 1.6; } diff --git a/backend/src/views/apps/oa/approvalcenter/list.vue b/backend/src/views/apps/oa/approvalcenter/list.vue index 3365a76..241e968 100644 --- a/backend/src/views/apps/oa/approvalcenter/list.vue +++ b/backend/src/views/apps/oa/approvalcenter/list.vue @@ -170,7 +170,7 @@ onMounted(loadList); diff --git a/backend/src/views/apps/oa/dashboard/index.vue b/backend/src/views/apps/oa/dashboard/index.vue index 75c9dd3..0b9efa4 100644 --- a/backend/src/views/apps/oa/dashboard/index.vue +++ b/backend/src/views/apps/oa/dashboard/index.vue @@ -121,8 +121,138 @@
- +
+ +
+
+
Hi,{{ displayName }}
+
+ {{ timeGreeting }},今天是 {{ todayWeekday }} {{ todayDay }} +
+
+ +
+
+
+ 天气 + + + +
+
+ + + +
+
{{ weather.temp }}°
+
+ {{ weather.text }} · {{ weather.city }} +
+
+
+
+ + 暂未获取 +
+
+ +
+
+ 今日排班 +
+
+ + {{ todaySchedule.label }} +
+
+ {{ todaySchedule.shiftName }} + {{ todaySchedule.time }} +
+
+
+ +
+
+ 工作日程 + 查看全部 +
+ + + + + +
+
+ +
+
+ + {{ item.title }} + + {{ item.priority === 2 ? "紧急" : "重要" }} + + + 延期第 {{ item.carry_count + 1 }} 天 + +
+
+ {{ + scheduleDateTag(item) + }} + {{ scheduleTimeText(item) }} +
+
+
+ +
+
+
+
通知公告 @@ -156,84 +286,6 @@ />
- -
-
- 工作日程提醒 - 查看全部 -
- - - - - -
-
- -
-
- - {{ item.title }} - - {{ item.priority === 2 ? "紧急" : "重要" }} - - - 延期第 {{ item.carry_count + 1 }} 天 - -
-
- {{ - scheduleDateTag(item) - }} - {{ scheduleTimeText(item) }} -
-
-
- -
-
@@ -290,17 +342,35 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue" import { useRouter } from "vue-router"; import { ElMessage } from "element-plus"; import * as echarts from "echarts"; -import { Refresh } from "@element-plus/icons-vue"; +import { + Refresh, + Sunny, + Cloudy, + PartlyCloudy, + Moon, + OfficeBuilding, + Lightning, + Pouring, + Drizzling +} from "@element-plus/icons-vue"; +import request from "@/utils/request"; import { getReimbursementDashboard } from "@/api/reimburse"; import { getNoticeDetail, getNoticePortal } from "@/api/oaNotice"; import { finishSchedule, getScheduleList, getScheduleStats } from "@/api/oaSchedule"; +import { getMyScheduleBoard } from "@/api/oaScheduling"; import ScheduleDetail from "../schedule/components/detail.vue"; import SchedulePostpone from "../schedule/components/postponeDialog.vue"; // 时间显示统一为:YYYY-MM-DD HH:mm:ss import { formatDateTime } from "@/utils/datetime"; +import { useAuthStore } from "@/stores/auth"; + +const authStore = useAuthStore(); +const displayName = computed( + () => authStore.user?.name || authStore.user?.account || "朋友" +); const emptyStat = () => ({ total: 0, paid: 0, unpaid: 0, count: 0 }); const dashboard = reactive({ @@ -318,6 +388,102 @@ const statCards = computed(() => [ { key: "year", label: "本年报销", data: dashboard.year }, ]); +// ---------- 今日概览 ---------- +const todayDate = new Date(); +const WEEKDAYS = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"]; +const todayWeekday = WEEKDAYS[todayDate.getDay()]; +const todayDay = `${todayDate.getMonth() + 1}月${todayDate.getDate()}日`; +const timeGreeting = (() => { + const h = new Date().getHours(); + if (h < 6) return "凌晨好"; + if (h < 9) return "早上好"; + if (h < 12) return "上午好"; + if (h < 14) return "中午好"; + if (h < 18) return "下午好"; + return "晚上好"; +})(); +const pad2 = (n) => String(n).padStart(2, "0"); +const todayStr = `${todayDate.getFullYear()}-${pad2(todayDate.getMonth() + 1)}-${pad2(todayDate.getDate())}`; + +const todayScheduleEntry = ref(null); +const todaySchedule = computed(() => { + const entry = todayScheduleEntry.value; + const isRest = !entry || !entry.shift_id || Number(entry.shift_id) === 0; + const workStart = entry?.work_start || ""; + const workEnd = entry?.work_end || ""; + const time = workStart && workEnd ? `${workStart} - ${workEnd}` : ""; + return { + status: isRest ? "rest" : "work", + label: isRest ? "今日休息" : "今日上班", + icon: isRest ? Moon : OfficeBuilding, + time, + shiftName: entry?.shift_name || "", + }; +}); + +const weather = ref({ + available: false, + temp: "", + text: "", + city: "", + icon: PartlyCloudy, +}); +// 天气图标:按中国天气网的天气现象文字(晴/多云/阴/小雨…)匹配 +const weatherIconOf = (desc) => { + const s = String(desc || ""); + if (s.includes("雷")) return Lightning; + if (s.includes("暴雨") || s.includes("大雨")) return Pouring; + if (s.includes("雨")) return Drizzling; + if (s.includes("雪")) return Cloudy; + if (s.includes("阴")) return Cloudy; + if (s.includes("云")) return PartlyCloudy; + if (s.includes("晴")) return Sunny; + return PartlyCloudy; +}; + +const loadTodaySchedule = async () => { + try { + const res = await getMyScheduleBoard({ start_date: todayStr, end_date: todayStr }); + const list = res?.data?.list || []; + todayScheduleEntry.value = list.length ? list[0] : null; + } catch (e) { + console.warn("加载今日排班失败:", e?.message, e); + todayScheduleEntry.value = null; + } +}; + +const WEATHER_CITY = "连云港"; +const weatherLoading = ref(false); +const loadWeather = async () => { + if (weatherLoading.value) return; + weatherLoading.value = true; + try { + const res = await request.get("/backend/weather/now", { + params: { city: WEATHER_CITY }, + }); + const d = res?.data || {}; + if (!d.temp && !d.text) throw new Error(res?.msg || "天气数据为空"); + weather.value = { + available: true, + temp: d.temp || "", + text: d.text || "", + city: d.city || WEATHER_CITY, + icon: weatherIconOf(d.text), + }; + } catch (e) { + console.warn("加载天气失败:", e?.message); + weather.value = { ...weather.value, available: false }; + } finally { + weatherLoading.value = false; + } +}; + +// 手动刷新天气(点击卡片右上角刷新图标) +const refreshWeather = async () => { + await loadWeather(); + if (weather.value.available) ElMessage.success("天气已更新"); +}; + // 趋势图 const trendChartRef = ref(null); let trendChart = null; @@ -423,7 +589,6 @@ const scheduleRanges = [ ]; const scheduleRange = ref("today"); -const pad2 = (n) => String(n).padStart(2, "0"); const fmtDate = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; @@ -660,6 +825,11 @@ const loadScheduleStats = async () => { }; const loadDashboard = async () => { + // 今日概览与经营看板并行拉取 + await Promise.allSettled([ + loadTodaySchedule(), + loadWeather(), + ]); try { const data = responseData(await getReimbursementDashboard()); dashboard.week = data.week || emptyStat(); @@ -730,11 +900,8 @@ onBeforeUnmount(() => { } .stat-row { - margin-bottom: 16px; - - .el-col { - margin-bottom: 8px; - } + /* 行内换行/窄屏堆叠时的行间距;与下一张卡的间距由 .col-left 的 gap 统一控制 */ + row-gap: 16px; } .stat-card { @@ -782,7 +949,6 @@ onBeforeUnmount(() => { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; - margin-bottom: 16px; } .todo-stat-card { @@ -828,12 +994,12 @@ onBeforeUnmount(() => { min-width: 0; } -.col-left > .chart-card { - margin-bottom: 16px; - - &:last-child { - margin-bottom: 0; - } +/* 统一卡片间距:两栏内部全部用 16px 的 flex gap,卡片自身不再写 margin */ +.col-left, +.col-right { + display: flex; + flex-direction: column; + gap: 16px; } .chart-card { @@ -869,13 +1035,182 @@ onBeforeUnmount(() => { } } -.type-card { - margin-bottom: 0; +/* 今日概览卡片:与左侧 6 张指标卡区域等高 */ +.today-card { + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px; + background: var(--el-bg-color); + border: 1px solid var(--el-border-color-lighter); + border-radius: 10px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04); + min-height: 250px; + + /* 打招呼 */ + .tc-greet { + .tc-hello { + font-size: 20px; + font-weight: 700; + line-height: 1.3; + color: var(--el-text-color-primary); + } + + .tc-sub { + margin-top: 4px; + font-size: 12px; + color: var(--el-text-color-secondary); + } + } + + /* 天气 / 排班 两块信息 */ + .tc-tiles { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + } + + .tc-tile { + min-width: 0; + padding: 10px; + background: var(--el-fill-color-light); + border-radius: 8px; + + .tc-tile-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + margin-bottom: 6px; + min-height: 16px; + } + + .tc-tile-label { + font-size: 11px; + color: var(--el-text-color-placeholder); + } + + .tc-refresh { + font-size: 12px; + color: var(--el-text-color-placeholder); + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: var(--el-color-primary); + } + + &.spinning { + animation: tc-spin 1s linear infinite; + color: var(--el-color-primary); + } + } + } + + .tc-weather { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + + .tc-weather-icon { + flex-shrink: 0; + color: #f59e0b; + } + + .tc-weather-text { + min-width: 0; + } + + .tc-weather-temp { + font-size: 18px; + font-weight: 600; + line-height: 1.1; + color: var(--el-text-color-primary); + } + + .tc-weather-desc { + margin-top: 2px; + font-size: 11px; + color: var(--el-text-color-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &.tc-weather-empty { + gap: 6px; + font-size: 12px; + color: var(--el-text-color-placeholder); + } + } + + .tc-shift { + display: flex; + align-items: center; + gap: 6px; + + .tc-shift-dot { + width: 7px; + height: 7px; + flex-shrink: 0; + border-radius: 50%; + background: var(--el-text-color-placeholder); + } + + .tc-shift-text { + font-size: 14px; + font-weight: 600; + color: var(--el-text-color-primary); + } + + &.work { + .tc-shift-dot { + background: var(--el-color-success); + } + .tc-shift-text { + color: var(--el-color-success); + } + } + } + + .tc-shift-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 4px; + font-size: 11px; + color: var(--el-text-color-secondary); + } + + /* 工作日程提醒(与今日概览合并展示) */ + .tc-schedule { + margin-top: 2px; + padding-top: 12px; + border-top: 1px dashed var(--el-border-color-lighter); + + .tc-schedule-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 4px; + } + + .tc-section-title { + font-size: 13px; + font-weight: 600; + color: var(--el-text-color-primary); + } + } +} + +@keyframes tc-spin { + to { + transform: rotate(360deg); + } } .notice-card { - margin-bottom: 16px; - .notice-title { display: flex; align-items: center; @@ -957,43 +1292,9 @@ onBeforeUnmount(() => { overflow-y: auto; } -.schedule-card { - display: flex; - flex-direction: column; - /*min-height: 340px; */ - height: 600px; - - .schedule-title { - display: flex; - align-items: center; - justify-content: space-between; - } - - .schedule-tabs { - margin-bottom: 20px; - - :deep(.el-tabs__header) { - margin: 0; - } - - :deep(.el-tabs__nav-wrap::after) { - height: 1px; - } - - :deep(.el-tabs__item) { - height: 36px; - line-height: 36px; - } - } - - .schedule-list { - flex: 1; - } -} - .schedule-tabs { :deep(.el-tabs__header) { - margin: 0 0 10px; + margin: 0 0 8px; } /* 列表由下方 .schedule-list 承载,tabs 只作切换栏,隐藏空的面板容器 */ @@ -1025,16 +1326,12 @@ onBeforeUnmount(() => { text-align: center; } -.status-card { - margin-bottom: 16px; -} - .schedule-list { display: flex; flex-direction: column; gap: 8px; min-height: 120px; - max-height: 520px; + max-height: 300px; overflow-y: auto; } @@ -1229,10 +1526,6 @@ onBeforeUnmount(() => { flex: 1 1 auto; width: 100%; } - - .col-right { - margin-top: 16px; - } } @media (max-width: 768px) { @@ -1261,6 +1554,12 @@ html.dark & { background: var(--el-bg-color-page); } + .today-card { + background: var(--el-bg-color); + border-color: var(--el-border-color); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); + } + .page-header { h2 { color: var(--el-text-color-primary); diff --git a/backend/src/views/apps/oa/employeefile/components/fileDetailDrawer.vue b/backend/src/views/apps/oa/employeefile/components/fileDetailDrawer.vue index 4e43655..ad2128f 100644 --- a/backend/src/views/apps/oa/employeefile/components/fileDetailDrawer.vue +++ b/backend/src/views/apps/oa/employeefile/components/fileDetailDrawer.vue @@ -436,13 +436,20 @@ const dateRangeText = (row) => { return `${startDate || '?'} ~ ${endDate || '至今'}`; }; -/** 合同状态:以结束日期判断履行中 / 已到期 */ +/** + * 合同档案状态:按起止日期自动推导三态。 + * - 过期:结束日期已过 + * - 废弃:开始日期尚未到来(签订后未生效) + * - 正常:履行中 / 无固定期限 / 仅一端有日期等其余场景 + */ const contractStatus = (row) => { + const startDate = formatDateOnly(row.start_date); const endDate = formatDateOnly(row.end_date); - if (!endDate) return { text: '长期', type: 'success' }; - return endDate >= new Date().toISOString().slice(0, 10) - ? { text: '履行中', type: 'success' } - : { text: '已到期', type: 'warning' }; + const today = new Date().toISOString().slice(0, 10); + + if (endDate && endDate < today) return { text: '过期', type: 'warning' }; + if (startDate && startDate > today) return { text: '废弃', type: 'info' }; + return { text: '正常', type: 'success' }; }; const isImage = (url) => /\.(png|jpe?g|gif|webp|bmp)(\?.*)?$/i.test(String(url || '')); diff --git a/backend/src/views/apps/oa/employeefile/components/recordEditDialog.vue b/backend/src/views/apps/oa/employeefile/components/recordEditDialog.vue index 0dd52c1..8572b7b 100644 --- a/backend/src/views/apps/oa/employeefile/components/recordEditDialog.vue +++ b/backend/src/views/apps/oa/employeefile/components/recordEditDialog.vue @@ -161,7 +161,7 @@ const uploadAccept = computed(() => ( )); const uploadTip = computed(() => ( meta.value.type === 3 - ? '支持图片、PDF、Word、Excel,大小不超过 20MB' + ? '支持图片、PDF、Word、Excel,大小不超过 30MB' : '支持图片 / PDF,大小不超过 10MB' )); @@ -218,7 +218,7 @@ const beforeUpload = (file) => { ? ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'] : ['application/pdf']; const allowed = file.type.startsWith('image/') || allowedTypes.includes(file.type); - const limitMB = isContract ? 20 : 10; + const limitMB = isContract ? 30 : 10; const withinLimit = file.size / 1024 / 1024 < limitMB; if (!allowed) ElMessage.error(isContract ? '仅支持图片、PDF、Word 或 Excel 格式' : '仅支持图片或 PDF 格式'); if (!withinLimit) ElMessage.error(`文件大小不能超过 ${limitMB}MB`); diff --git a/backend/src/views/apps/oa/schedule/components/postponeDialog.vue b/backend/src/views/apps/oa/schedule/components/postponeDialog.vue index 226affe..b5f1c30 100644 --- a/backend/src/views/apps/oa/schedule/components/postponeDialog.vue +++ b/backend/src/views/apps/oa/schedule/components/postponeDialog.vue @@ -5,11 +5,16 @@ 该日程已逾期 {{ overdueDays }} 天。 - 延期后「{{ schedule.title }}」在 {{ schedule.schedule_date }} - 当天标记为已完成,并在目标日期自动生成一条待办继续跟进。 + 「{{ schedule.title }}」在 {{ schedule.schedule_date }} 当天会标记为已完成。延期到会仅在目标日期创建一条待办;延期至会在次日至目标日期的每一天分别创建一条待办。

- + + + 延期到(点对点) + 延期至(逐日创建) + + + { }); const targetDate = ref(""); +const postponeMode = ref("to"); const saving = ref(false); // 打开时给出默认日期:逾期任务默认补到今天,未到期任务默认顺延到下一天 @@ -148,10 +154,11 @@ async function submit() { saving.value = true; try { const res = await carrySchedule(props.schedule.id, { - target_date: targetDate.value + target_date: targetDate.value, + mode: postponeMode.value }); if (res?.code === 200) { - ElMessage.success(`已延期到 ${targetDate.value}`); + ElMessage.success(postponeMode.value === "to" ? `已延期到 ${targetDate.value}` : `已延期至 ${targetDate.value}`); visible.value = false; emit("saved"); } else { diff --git a/backend/src/views/apps/oa/schedule/index.vue b/backend/src/views/apps/oa/schedule/index.vue index d94077d..7fb0ee8 100644 --- a/backend/src/views/apps/oa/schedule/index.vue +++ b/backend/src/views/apps/oa/schedule/index.vue @@ -113,17 +113,7 @@ 班 - 今 - {{ cell.month }}月 - {{ cell.myEntry.short_name || cell.myEntry.shift_name }} + {{ cell.month }}月
{{ cell.holidayName }} @@ -158,6 +148,8 @@ 还有 {{ cell.items.length - 3 }} 项
+
班
+
休
@@ -1372,6 +1364,7 @@ onMounted(() => { } .calendar-cell { + position: relative; border-right: 1px solid #f2f4f8; border-bottom: 1px solid #f2f4f8; padding: 6px; @@ -1393,12 +1386,6 @@ onMounted(() => { background: #fafbfc; } -.calendar-cell.selected { - background: #ecf5ff; - box-shadow: inset 0 0 0 2px #409eff; - border-radius: 4px; -} - .cell-date { display: flex; align-items: center; @@ -1411,18 +1398,6 @@ onMounted(() => { font-weight: 500; } -.calendar-cell.today .day-num { - background: #409eff; - color: #fff; - border-radius: 50%; - width: 22px; - height: 22px; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.today-tag, .month-tag { font-size: 11px; color: #409eff; @@ -1468,73 +1443,90 @@ onMounted(() => { background: #fff; } -/* 我的排班背景:上班浅绿 / 休息浅橙。排班是实际作息,覆盖节假日底色, - 但保留休/班角标与节日名;today / selected 在后面定义,优先级仍最高 */ +/* 我的排班背景:上班绿 / 休息橙。排班是实际作息,覆盖节假日底色, + 但保留休/班角标与节日名;today / selected 在后面定义,优先级仍最高。 + 仅靠提高底色饱和度区分,不加边框/竖条 */ .calendar-cell.is-my-work { - background: #f0f9eb; + background: #e2f6d2; } .calendar-cell.is-my-work:hover { - background: #e6f5db; + background: #d3f0bd; } +/* 休息日:斜纹底 + 右上角「休」角标。 + 斜纹比纯色更像「非工作日」,角标保证文字含义仍在,且不压住日程条目 */ .calendar-cell.is-my-rest { - background: #fdf6ec; + background: repeating-linear-gradient( + 135deg, + #fdf3e6 0, + #fdf3e6 7px, + #fbe6cd 7px, + #fbe6cd 14px + ); } .calendar-cell.is-my-rest:hover { - background: #faecd8; + background: repeating-linear-gradient( + 135deg, + #fbeadb 0, + #fbeadb 7px, + #f8dcbb 7px, + #f8dcbb 14px + ); } -/* 格内班次角标:用班次定义色做实底小色块 */ -.my-shift-chip { - display: inline-flex; - align-items: center; +/* 排班角标:上班「班」绿 / 休息「休」橙,统一放右上角,与日期行互不干扰 */ +.cell-sched-badge { + position: absolute; + top: 5px; + right: 5px; + width: 16px; height: 16px; - padding: 0 4px; - border-radius: 3px; + border-radius: 50%; + color: #fff; font-size: 10px; - line-height: 1; - color: #fff; - flex-shrink: 0; - max-width: 56px; - overflow: hidden; - white-space: nowrap; + font-weight: 600; + line-height: 16px; + text-align: center; + pointer-events: none; + user-select: none; + + &.is-work { + background: #67c23a; + box-shadow: 0 1px 2px rgba(103, 194, 58, 0.35); + } + + &.is-rest { + background: #e6a23c; + box-shadow: 0 1px 2px rgba(230, 162, 60, 0.35); + } } -/* 今天:整格实蓝底,最醒目。放在 is-off / is-work 之后, - 保证今天即使逢节假日也保留蓝色标识。 - 实底较深,格内文字统一转白(或白底反白)以保证对比度。 */ +/* 选中态优先级高于排班底色:蓝色斜条纹 + 主色描边 */ +.calendar-cell.selected { + background: repeating-linear-gradient( + 135deg, + #ecf5ff 0, + #ecf5ff 6px, + #d9ecff 6px, + #d9ecff 12px + ); + box-shadow: inset 0 0 0 2px #409eff; + border-radius: 4px; +} + +/* 今天:不再用实底蓝,改为绿色描边框,保留排班底色(上班绿 / 休息斜纹)不变。 + 放在 is-off / is-work / is-my-* 之后,保证框始终可见。 */ .calendar-cell.today { - background: #409eff; + box-shadow: inset 0 0 0 2px #67c23a; + border-radius: 4px; } -.calendar-cell.today:hover { - background: #66b1ff; -} - -/* 日期数字改为白底蓝字,避免在蓝底上"蓝圆叠蓝底"糊成一片 */ +/* 今天的日期数字用绿色加粗强调,替代原来的「今」字标签 */ .calendar-cell.today .day-num { - background: #fff; - color: #409eff; -} - -.calendar-cell.today .cell-holiday, -.calendar-cell.today .today-tag { - color: #fff; -} - -/* 休/班角标改为白底,保持原有红/灰字色可辨识 */ -.calendar-cell.today .holiday-tag.is-off { - background: #fff; - color: #f56c6c; - border-color: #fff; -} - -.calendar-cell.today .holiday-tag.is-work { - background: #fff; - color: #909399; - border-color: #fff; + color: #529b2e; + font-weight: 700; } /* 格内节日名称 */ @@ -1845,11 +1837,16 @@ html.dark & { background: var(--el-fill-color-light); } &.selected { - background: var(--el-color-primary-light-9); + background: repeating-linear-gradient( + 135deg, + rgba(64, 158, 255, 0.14) 0, + rgba(64, 158, 255, 0.14) 6px, + rgba(64, 158, 255, 0.3) 6px, + rgba(64, 158, 255, 0.3) 12px + ); box-shadow: inset 0 0 0 2px var(--el-color-primary); } } - .today-tag, .month-tag { color: var(--el-color-primary); } @@ -1888,34 +1885,12 @@ html.dark & { background: rgba(230, 162, 60, 0.16); } } - /* 今天:整格主色实底,与浅色主题保持一致 */ + /* 今天:与浅色主题一致,只用绿色描边框,不覆盖排班底色 */ .calendar-cell.today { - background: var(--el-color-primary); - - &:hover { - background: var(--el-color-primary-light-3); - } + box-shadow: inset 0 0 0 2px #67c23a; .day-num { - background: #fff; - color: var(--el-color-primary); - } - - .cell-holiday, - .today-tag { - color: #fff; - } - - .holiday-tag.is-off { - background: #fff; - color: #f56c6c; - border-color: #fff; - } - - .holiday-tag.is-work { - background: #fff; - color: #909399; - border-color: #fff; + color: #95d475; } } .cell-holiday { diff --git a/backend/src/views/dashboard/index.vue b/backend/src/views/dashboard/index.vue index 10c7e26..a3a6121 100644 --- a/backend/src/views/dashboard/index.vue +++ b/backend/src/views/dashboard/index.vue @@ -31,16 +31,69 @@ {{ rangeText }} - -
-
-
- + +
+
+
+
+
+ +
+
+
{{ item.label }}
+
{{ item.value }}
+
{{ item.hint }}
+
+
-
-
{{ item.label }}
-
{{ item.value }}
-
{{ item.hint }}
+
+ + +
+
+
+
{{ todayWeekday }}
+
{{ todayDay }}
+
+
+ + + +
+ {{ todaySchedule.label }} + {{ todaySchedule.time }} +
+
+
+ +
+
+ +
{{ weather.temp }}°
+
+
+ {{ weather.text }} + {{ weather.city }} +
+
+
+ +
+ 天气暂未获取 +
+
+ +
+
+ 是否上班 + + {{ todaySchedule.status === 'work' ? '上班' : '休息' }} + +
+
+ 班次 + {{ todaySchedule.shiftName }} +
@@ -144,9 +197,16 @@ import { TrendCharts, Coin, Refresh, + Sunny, + Cloudy, + PartlyCloudy, + Moon, + Sunset, + OfficeBuilding, } from '@element-plus/icons-vue'; import { getErpDashboard } from '@/api/erpReport'; import { getDocumentList } from '@/api/erpDocument'; +import { getMyScheduleBoard } from '@/api/oaScheduling'; import { docTypeLabel, docStatusText, @@ -207,6 +267,89 @@ const netChart = shallowRef(null); const displayName = computed(() => authStore.user.name || authStore.user.account || '管理员'); const tenantName = computed(() => authStore.user.tenant_name || ''); +/** 今日日期信息 */ +const today = new Date(); +const WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']; +const todayWeekday = WEEKDAYS[today.getDay()]; +const todayDay = `${today.getMonth() + 1}月${today.getDate()}日`; +const todayStr = today.toISOString().slice(0, 10); + +/** 今日排班(来自后端 /backend/oa/scheduling/my,仅今日) */ +const todayScheduleEntry = ref(null); +const SCHEDULE_STATUS_HOLIDAYS = [0]; // shift_id=0 或无记录 => 休息 +const todaySchedule = computed(() => { + const entry = todayScheduleEntry.value; + const isRest = !entry || !entry.shift_id || Number(entry.shift_id) === 0; + const workStart = entry?.work_start || ''; + const workEnd = entry?.work_end || ''; + const time = workStart && workEnd ? `${workStart} - ${workEnd}` : ''; + return { + status: isRest ? 'rest' : 'work', + label: isRest ? '今日休息' : '今日上班', + icon: isRest ? Moon : OfficeBuilding, + time, + shiftName: entry?.shift_name || '', + }; +}); + +/** 天气:用 wttr.in 免费接口(无需 key),失败时优雅降级 */ +type WeatherInfo = { + available: boolean; + temp: string; + text: string; + city: string; + icon: any; +}; +const weather = ref({ + available: false, + temp: '', + text: '', + city: '', + icon: PartlyCloudy, +}); +const WEATHER_ICON_MAP: Record = { + Sunny, Clear: Sunny, Sun: Sunny, + Cloudy, Overcast: Cloudy, + 'Partly cloudy': PartlyCloudy, + Rain: Cloudy, Snow: Cloudy, Stormy: Cloudy, +}; +const weatherIconOf = (desc: string) => { + const key = String(desc || '').trim(); + return WEATHER_ICON_MAP[key] || PartlyCloudy; +}; + +const loadTodaySchedule = async () => { + try { + const res = await getMyScheduleBoard({ start_date: todayStr, end_date: todayStr }); + const list = res?.data?.list || []; + todayScheduleEntry.value = list.length ? list[0] : null; + } catch { + todayScheduleEntry.value = null; + } +}; + +const loadWeather = async () => { + try { + const res = await fetch('https://wttr.in/?format=j1', { mode: 'cors' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + const cur = data?.current_condition?.[0] || {}; + const area = data?.nearest_area?.[0] || {}; + const areaName = + area?.areaName?.[0]?.value || area?.region?.[0]?.value || area?.country?.[0]?.value || ''; + const desc = cur.lang_zh?.[0]?.value || cur.weatherDesc?.[0]?.value || ''; + weather.value = { + available: true, + temp: cur.temp_C ?? '', + text: desc, + city: areaName, + icon: weatherIconOf(desc), + }; + } catch { + weather.value = { ...weather.value, available: false }; + } +}; + const currentDate = computed(() => { const date = new Date(); const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']; @@ -454,7 +597,14 @@ const loadAll = async () => { loading.value = true; noErp.value = false; try { - const status = await loadDashboard(); + // 今日概览(排班 + 天气)和经营看板并行拉取 + const [dashboardStatus, scheduleRes, weatherPromise] = await Promise.allSettled([ + loadDashboard(), + loadTodaySchedule(), + loadWeather(), + ]); + + const status = dashboardStatus.status === 'fulfilled' ? dashboardStatus.value : 'fail'; if (status === 'denied') return; const [approvingRes, draftRes, latestRes] = await Promise.allSettled([ @@ -578,6 +728,24 @@ onUnmounted(() => { } } +// 主指标卡 + 右侧今日概览双列 +.top-row { + display: grid; + grid-template-columns: 1fr 320px; + gap: 16px; + margin-bottom: 16px; + align-items: stretch; + + .top-row-left { + display: flex; + min-width: 0; + } + + .stats-grid { + flex: 1; + } +} + // 主指标卡 .stats-grid { display: grid; @@ -666,6 +834,163 @@ onUnmounted(() => { } } +// 今日概览卡片:与左侧 stats-grid 等高 +.today-card { + display: flex; + flex-direction: column; + gap: 14px; + padding: 20px; + background: linear-gradient(135deg, #f0f6ff 0%, #fef6f0 100%); + border: 1px solid var(--el-border-color-lighter); + border-radius: 12px; + min-height: 0; + + .today-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + + .today-date { + .today-weekday { + font-size: 14px; + font-weight: 600; + color: var(--el-text-color-secondary); + line-height: 1.3; + } + .today-day { + margin-top: 2px; + font-size: 22px; + font-weight: 700; + color: var(--el-text-color-primary); + line-height: 1.2; + } + } + + .today-schedule { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 8px; + font-size: 12px; + flex-shrink: 0; + + &.work { + background: rgba(57, 115, 255, 0.1); + color: var(--el-color-primary); + } + &.rest { + background: rgba(134, 144, 156, 0.12); + color: var(--el-text-color-secondary); + } + + .schedule-info { + display: flex; + flex-direction: column; + line-height: 1.3; + + .schedule-time { + font-size: 11px; + opacity: 0.8; + } + } + } + } + + .today-weather { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + background: rgba(255, 255, 255, 0.65); + border-radius: 10px; + + .weather-main { + display: flex; + align-items: center; + gap: 8px; + color: #f59e0b; + + .weather-temp { + font-size: 26px; + font-weight: 600; + color: var(--el-text-color-primary); + line-height: 1; + } + } + + .weather-detail { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + font-size: 12px; + color: var(--el-text-color-secondary); + text-align: right; + + .weather-city { + font-size: 11px; + opacity: 0.7; + } + } + + &.weather-fallback { + justify-content: center; + color: var(--el-text-color-placeholder); + .weather-main { + color: var(--el-text-color-placeholder); + flex-direction: column; + gap: 4px; + } + .weather-temp { + display: none; + } + } + } + + .today-extra { + margin-top: auto; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + + .extra-item { + display: flex; + flex-direction: column; + gap: 2px; + + .extra-label { + font-size: 11px; + color: var(--el-text-color-placeholder); + } + .extra-value { + font-size: 14px; + font-weight: 600; + color: var(--el-text-color-primary); + + &.text-success { + color: var(--el-color-success); + } + &.text-muted { + color: var(--el-text-color-secondary); + } + } + } + } +} + +html.dark { + .today-card { + background: linear-gradient(135deg, #1e2a44 0%, #2a1e18 100%); + border-color: #3a3a3a; + + .today-weather { + background: rgba(255, 255, 255, 0.06); + } + } +} + // 次级指标 .mini-grid { display: grid; @@ -839,6 +1164,29 @@ onUnmounted(() => { // 响应式 @media (max-width: 1200px) { + .top-row { + grid-template-columns: 1fr; + + .today-card { + flex-direction: row; + gap: 18px; + + .today-header { + flex: 1; + min-width: 0; + } + .today-weather { + flex-shrink: 0; + min-width: 180px; + } + .today-extra { + margin-top: 0; + grid-template-columns: 1fr; + flex-shrink: 0; + } + } + } + .charts-section, .lists-section { grid-template-columns: 1fr; diff --git a/go/controllers/backend_crm_bidding_bid.go b/go/controllers/backend_crm_bidding_bid.go index 246734c..0176302 100644 --- a/go/controllers/backend_crm_bidding_bid.go +++ b/go/controllers/backend_crm_bidding_bid.go @@ -450,7 +450,7 @@ func (c *BackendCrmBiddingBidController) ConvertContract() { } crmWriteLog(tenantID, 4, bidIDForLog, "convert-contract", "中标转合同:"+contractName, claims) - crmWriteLog(tenantID, 1, cid, "create", "由投标「"+bid.Title+"」中标转化生成", claims) + crmWriteLog(tenantID, 6, cid, "create", "由投标「"+bid.Title+"」中标转化生成", claims) pipelineOk(&c.Controller, map[string]interface{}{"contract_id": contractID}) } diff --git a/go/controllers/backend_crm_bidding_tender.go b/go/controllers/backend_crm_bidding_tender.go index 049ec10..dc03462 100644 --- a/go/controllers/backend_crm_bidding_tender.go +++ b/go/controllers/backend_crm_bidding_tender.go @@ -503,7 +503,7 @@ func (c *BackendCrmBiddingTenderController) AwardToContract() { } crmWriteLog(tenantID, 5, tenderID, "award-contract", "定标转合同:"+contractName, claims) - crmWriteLog(tenantID, 1, cid, "create", "由招标「"+row.Title+"」定标转化生成", claims) + crmWriteLog(tenantID, 6, cid, "create", "由招标「"+row.Title+"」定标转化生成", claims) pipelineOk(&c.Controller, map[string]interface{}{"contract_id": contractID}) } \ No newline at end of file diff --git a/go/controllers/backend_crm_contract.go b/go/controllers/backend_crm_contract.go index 32bcaf5..c321148 100644 --- a/go/controllers/backend_crm_contract.go +++ b/go/controllers/backend_crm_contract.go @@ -376,7 +376,7 @@ func (c *BackendCrmContractController) Create() { } // 参与方填写的签约人(姓名 + 电话)同步写入对应企业的联系人通讯录 syncContractPartyContacts(tenantID, row.Parties) - crmWriteLog(tenantID, 3, row.ID, "create", "创建合同:"+row.ContractName, claims) + crmWriteLog(tenantID, 6, row.ID, "create", "创建合同:"+row.ContractName, claims) pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID}) } @@ -493,7 +493,7 @@ func (c *BackendCrmContractController) Update() { } // 参与方填写的签约人(姓名 + 电话)同步写入对应企业的联系人通讯录 syncContractPartyContacts(tenantID, row.Parties) - crmWriteLog(tenantID, 3, row.ID, "update", "更新合同:"+row.ContractName, claims) + crmWriteLog(tenantID, 6, row.ID, "update", "更新合同:"+row.ContractName, claims) pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID}) } @@ -589,7 +589,7 @@ func (c *BackendCrmContractController) ChangeStatus() { pipelineErr(&c.Controller, 500, 500, "状态更新失败: "+err.Error()) return } - crmWriteLog(tenantID, 3, row.ID, "status", fmt.Sprintf("合同状态流转:%s → %s", contractStatusName(row.Status), contractStatusName(p.Status)), claims) + crmWriteLog(tenantID, 6, row.ID, "status", fmt.Sprintf("合同状态流转:%s → %s", contractStatusName(row.Status), contractStatusName(p.Status)), claims) pipelineOk(&c.Controller, map[string]interface{}{"id": row.ID, "status": p.Status}) } diff --git a/go/controllers/backend_crm_payback.go b/go/controllers/backend_crm_payback.go index 7002a50..83bb451 100644 --- a/go/controllers/backend_crm_payback.go +++ b/go/controllers/backend_crm_payback.go @@ -881,7 +881,7 @@ func (c *BackendCrmPaybackController) CreateRecord() { } refreshPlanReceived(tenantID, plan.ID) if contractID != nil { - crmWriteLog(tenantID, 3, *contractID, "payback", + crmWriteLog(tenantID, 6, *contractID, "payback", fmt.Sprintf("新建回款:%s 元(%s)", formatAmount(row.Amount), row.ReceivedDate), claims) } pipelineOk(&c.Controller, map[string]interface{}{"id": id}) diff --git a/go/controllers/backend_oa_schedule.go b/go/controllers/backend_oa_schedule.go index 431f800..13c2329 100644 --- a/go/controllers/backend_oa_schedule.go +++ b/go/controllers/backend_oa_schedule.go @@ -447,14 +447,14 @@ func (c *BackendOaScheduleController) Stats() { } type oaSchedulePayload struct { - Title string `json:"title"` - Content string `json:"content"` - ScheduleDate string `json:"schedule_date"` - StartTime string `json:"start_time"` - EndTime string `json:"end_time"` - AllDay int8 `json:"all_day"` - Color string `json:"color"` - Priority int8 `json:"priority"` + Title string `json:"title"` + Content string `json:"content"` + ScheduleDate string `json:"schedule_date"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + AllDay int8 `json:"all_day"` + Color string `json:"color"` + Priority int8 `json:"priority"` // 提醒绑定:开启后会在提醒工具生成一条提醒任务,并把其 ID 回写到 ReminderID。 ReminderEnabled bool `json:"reminder_enabled"` ReminderTime string `json:"reminder_time"` @@ -1157,6 +1157,7 @@ func (c *BackendOaScheduleController) CarrySchedule() { // 目标日期可选,不传则默认顺延到下一天 var payload struct { TargetDate string `json:"target_date"` + Mode string `json:"mode"` } if raw, e := io.ReadAll(c.Ctx.Request.Body); e == nil && len(raw) > 0 { _ = json.Unmarshal(raw, &payload) @@ -1180,7 +1181,56 @@ func (c *BackendOaScheduleController) CarrySchedule() { targetDate = nextOaDate(normalizeOaDate(item.ScheduleDate), 1) } + mode := strings.TrimSpace(payload.Mode) + if mode != "" && mode != "to" && mode != "through" { + c.oasJsonErr(400, 400, "延期方式无效") + return + } + now := time.Now() + if mode == "through" { + startDate := normalizeOaDate(item.ScheduleDate) + if targetDate <= startDate { + c.oasJsonErr(400, 400, "延期至日期需晚于当前日程日期") + return + } + // 延期至代表将原日程推至终点日期,并在起始日之后到终点日的每一天创建独立待办。 + rootID := oaScheduleRootID(&item) + created := 0 + for date := nextOaDate(startDate, 1); date != "" && date <= targetDate; date = nextOaDate(date, 1) { + exists := models.Orm.QueryTable(new(models.OaSchedule)).Filter("is_deleted", 0). + Filter("tid", claims.TenantId).Filter("user_id", claims.UserID). + Filter("source_id", rootID).Filter("schedule_date", date).Exist() + if exists { + continue + } + next := &models.OaSchedule{ + Tid: item.Tid, UserID: item.UserID, UserName: item.UserName, + Title: item.Title, Content: item.Content, Images: item.Images, + ScheduleDate: date, StartTime: item.StartTime, EndTime: item.EndTime, + AllDay: item.AllDay, Color: item.Color, Priority: item.Priority, + Status: 0, SourceID: rootID, CarryCount: item.CarryCount + 1, + IsDeleted: 0, CreateTime: now, UpdateTime: &now, + } + if _, insertErr := models.Orm.Insert(next); insertErr != nil { + c.oasJsonErr(500, 500, "生成延期日程失败") + return + } + created++ + } + item.Status = 1 + item.FinishTime = &now + item.CarryOver = 1 + item.CarryToDate = targetDate + item.UpdateTime = &now + if _, updateErr := models.Orm.Update(&item, "Status", "FinishTime", "CarryOver", "CarryToDate", "UpdateTime"); updateErr != nil { + c.oasJsonErr(500, 500, "更新原日程失败") + return + } + c.oasOk(map[string]interface{}{"created": created, "schedule_date": targetDate, "mode": "through"}) + return + } + next, msg := carryOaSchedule(&item, targetDate, claims, now) if msg != "" { c.oasJsonErr(400, 400, msg) diff --git a/go/controllers/backend_oa_scheduling_board.go b/go/controllers/backend_oa_scheduling_board.go index 3d2884a..78e66bc 100644 --- a/go/controllers/backend_oa_scheduling_board.go +++ b/go/controllers/backend_oa_scheduling_board.go @@ -8,6 +8,7 @@ import ( "time" "server/models" + authSvc "server/services/auth" "github.com/beego/beego/v2/client/orm" ) @@ -778,11 +779,11 @@ func (c *BackendOaSchedulingController) ClearEntries() { c.scOK(map[string]interface{}{"cleared": len(entries)}) } -// MyBoard 当前登录用户的排班:按登录账号匹配员工档案,返回区间内每天的排班, -// 供日程日历渲染上班/休息背景。 +// MyBoard 当前登录用户的排班:按登录身份匹配员工档案,返回区间内每天的排班, +// 供工作台今日卡片与日程日历渲染上班/休息背景。 // GET /backend/oa/scheduling/my?start_date=&end_date= func (c *BackendOaSchedulingController) MyBoard() { - tid, _, username, ok := c.scOperator() + tid, uid, username, ok := c.scOperator() if !ok { return } @@ -804,9 +805,11 @@ func (c *BackendOaSchedulingController) MyBoard() { return } - // 账号未关联员工档案时返回空列表,前端静默不渲染排班背景 - var employee models.BackendEmployee - if err := c.scBaseEmployeeQuery(tid).Filter("account", username).One(&employee); err != nil { + // 解析当前登录者对应的员工档案。OIDC 换码签发的令牌里 username 为空, + // 即使有值也是认证中心昵称而非员工工号,因此以「身份+租户」绑定账号为主, + // 再用令牌 username 按 账号 → 姓名 兜底。都匹配不到时返回空列表。 + employee, ok := c.scFindSelfEmployee(tid, uid, username) + if !ok { c.scOK(map[string]interface{}{"employee": nil, "list": []map[string]interface{}{}}) return } @@ -839,6 +842,59 @@ func (c *BackendOaSchedulingController) MyBoard() { }) } +// scFindSelfEmployee 把登录身份映射到本租户的员工档案。 +// 优先取认证中心「身份+租户」绑定记录里的账号(与 /auth/userinfo 同源,最可靠), +// 其次用令牌 username 按「账号 → 姓名」匹配。 +func (c *BackendOaSchedulingController) scFindSelfEmployee(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 { + return emp, true + } + } + if v := authBindValue(bind.Name); v != "" { + if emp, found := c.scEmployeeByField(tid, "name", v); found { + return emp, true + } + } + } + } + + username = strings.TrimSpace(username) + if username != "" { + for _, field := range []string{"account", "name"} { + if emp, found := c.scEmployeeByField(tid, field, username); found { + return emp, true + } + } + } + return employee, false +} + +// scEmployeeByField 在本租户员工档案中按指定字段匹配,取第一条有效记录 +func (c *BackendOaSchedulingController) scEmployeeByField(tid uint64, field, value string) (models.BackendEmployee, bool) { + var rows []models.BackendEmployee + if _, err := c.scBaseEmployeeQuery(tid). + Filter(field, value). + OrderBy("id"). + Limit(1). + All(&rows); err == nil && len(rows) > 0 { + return rows[0], true + } + return models.BackendEmployee{}, false +} + +// authBindValue 取认证绑定记录里的可空字符串 +func authBindValue(p *string) string { + if p == nil { + return "" + } + return strings.TrimSpace(*p) +} + // ChangeList 排班变更记录(最近 100 条),用于追溯临时调配。 // GET /backend/oa/scheduling/changes?start_date=&end_date=&employee_id= func (c *BackendOaSchedulingController) ChangeList() { diff --git a/go/controllers/backend_weather.go b/go/controllers/backend_weather.go new file mode 100644 index 0000000..ab6f5d0 --- /dev/null +++ b/go/controllers/backend_weather.go @@ -0,0 +1,146 @@ +package controllers + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + "time" + + beego "github.com/beego/beego/v2/server/web" +) + +// BackendWeatherController 天气实时数据代理。 +// +// 数据源:中国天气网 d1.weather.com.cn(国内真实数据)。 +// 该接口有防盗链,浏览器直连会被 Referer 校验拦掉、也会跨域, +// 因此由服务端带 Referer 抓取后统一返回 JSON。 +type BackendWeatherController struct { + beego.Controller +} + +// 常用城市编码(中国天气网 citycode),未命中时按入参 code 直取 +var weatherCityCodes = map[string]string{ + "连云港": "101191001", + "南京": "101190101", + "苏州": "101190401", + "无锡": "101190201", + "常州": "101191101", + "南通": "101190501", + "扬州": "101190601", + "徐州": "101190801", + "盐城": "101190701", + "淮安": "101190901", + "镇江": "101190301", + "泰州": "101191201", + "宿迁": "101191301", + "北京": "101010100", + "上海": "101020100", + "广州": "101280101", + "深圳": "101280601", + "杭州": "101210101", +} + +const defaultWeatherCity = "连云港" + +// Now GET /backend/weather/now?city=连云港 或 ?code=101191201 +func (c *BackendWeatherController) Now() { + city := strings.TrimSpace(c.GetString("city")) + code := strings.TrimSpace(c.GetString("code")) + if code == "" { + code = weatherCityCodes[city] + } + if code == "" { + code = weatherCityCodes[defaultWeatherCity] + city = defaultWeatherCity + } + + client := &http.Client{Timeout: 8 * time.Second} + ts := strconv.FormatInt(time.Now().UnixMilli(), 10) + referer := "https://www.weather.com.cn/weather1d/" + code + ".shtml" + + // 主数据源:dingzhi(含天气现象 + 气温)。中国天气网已拒绝 http 明文连接,必须走 https。 + info := weatherFetchJSON(client, "https://d1.weather.com.cn/dingzhi/"+code+".html?_="+ts, referer) + if wi, ok := info["weatherinfo"].(map[string]interface{}); ok { + info = wi + } + // 兜底:sk_2d(实况气温,无天气现象) + if len(info) == 0 { + info = weatherFetchJSON(client, "https://d1.weather.com.cn/sk_2d/"+code+".html?_="+ts, referer) + } + + if len(info) == 0 { + c.Ctx.Output.SetStatus(http.StatusBadGateway) + c.Data["json"] = map[string]interface{}{"code": 502, "msg": "天气数据获取失败,请稍后重试"} + _ = c.ServeJSON() + return + } + + cityName := weatherStr(info, "cityname", "city", "name") + if cityName == "" { + cityName = city + } + temp := strings.TrimSuffix(weatherStr(info, "temp", "real"), "℃") + text := weatherStr(info, "weather", "weather1") + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "ok", + "data": map[string]interface{}{ + "city": cityName, + "temp": temp, + "text": text, + "update": weatherStr(info, "time", "fctime", "date"), + "code": code, + }, + } + _ = c.ServeJSON() +} + +// weatherFetchJSON 带 Referer/UA 抓取中国天气网页面并解析其中的 JSON 对象 +func weatherFetchJSON(client *http.Client, url, referer string) map[string]interface{} { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36") + req.Header.Set("Referer", referer) + req.Header.Set("Accept", "*/*") + + resp, err := client.Do(req) + if err != nil { + return nil + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil + } + + // 返回体形如:var cityDZ101191001 ={"weatherinfo":{...}};var alarmDZ101191001 ={"w":[]} + // 从第一个 '{' 起用 json.Decoder 只解出第一个完整对象(正则贪婪匹配会跨越多个对象导致解析失败) + idx := bytes.IndexByte(body, '{') + if idx < 0 { + return nil + } + var out map[string]interface{} + if json.NewDecoder(bytes.NewReader(body[idx:])).Decode(&out) != nil { + return nil + } + return out +} + +// weatherStr 按候选字段顺序取第一个非空字符串值 +func weatherStr(m map[string]interface{}, keys ...string) string { + for _, k := range keys { + if v, ok := m[k]; ok { + if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) + } + } + } + return "" +} diff --git a/go/middleware/operationLog.go b/go/middleware/operationLog.go index 97f720e..bc2fbd5 100644 --- a/go/middleware/operationLog.go +++ b/go/middleware/operationLog.go @@ -313,6 +313,10 @@ func shouldSkipLogging(method, url string) bool { if strings.HasPrefix(url, "/api/softwareupgrade/check") { return true } + // 工作台天气实况(代理外部数据源,非业务写操作) + if strings.HasPrefix(url, "/backend/weather/") { + return true + } // 移动端(已并入 /api/)读接口跳过(只记录写操作:POST/PUT/DELETE) if isMobileAppPath(url) { return true diff --git a/go/models/tenant_crm_operate_log.go b/go/models/tenant_crm_operate_log.go index 822496f..5358216 100644 --- a/go/models/tenant_crm_operate_log.go +++ b/go/models/tenant_crm_operate_log.go @@ -3,7 +3,7 @@ package models import "time" // TenantCrmOperateLog 操作日志表: yz_backend_crm_operate_log -// related_type: 1线索 / 2商机 / 3项目 +// related_type: 1线索 / 2商机 / 3项目 / 4投标 / 5招标 / 6合同 type TenantCrmOperateLog struct { ID uint64 `orm:"column(id);pk;auto" json:"id"` TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"` diff --git a/go/routers/backend/backend.go b/go/routers/backend/backend.go index 38bbe67..df66354 100644 --- a/go/routers/backend/backend.go +++ b/go/routers/backend/backend.go @@ -159,6 +159,9 @@ func RegisterAuthRoutes() { // 模块接口 beego.Router("/backend/modules/getTenantList", &controllers.BackendModulesController{}, "get:GetTenantList") + // 天气实况(代理中国天气网,供工作台今日卡片使用) + beego.Router("/backend/weather/now", &controllers.BackendWeatherController{}, "get:Now") + // 用户接口 beego.Router("/backend/getTenantUsers/:tid", &controllers.BackendAdminUserController{}, "get:GetTenantUsers") beego.Router("/backend/getAllUsers", &controllers.BackendAdminUserController{}, "get:GetAllUsers") diff --git a/go/services/payback_overdue_scheduler.go b/go/services/payback_overdue_scheduler.go index 18556c5..1efc7e5 100644 --- a/go/services/payback_overdue_scheduler.go +++ b/go/services/payback_overdue_scheduler.go @@ -189,14 +189,14 @@ func crmContractStatusText(s int8) string { // writeOverdueStatusLog 逾期联动自动改状态时写入 CRM 操作日志。 // 自动置「异常」与人工流转同一口径:日志里必须写明原因,操作者记为「系统自动」,便于事后追溯。 -// relatedType=3 与控制器层一致(项目 / 合同的操作日志同属该分类)。 -func writeOverdueStatusLog(tenantID string, relatedID uint64, content string, now time.Time) { +// relatedType: 3=项目 6=合同(合同已独立分配 type=6,与项目解耦避免日志串数据)。 +func writeOverdueStatusLog(tenantID string, relatedType int8, relatedID uint64, content string, now time.Time) { if models.Orm == nil || relatedID == 0 { return } log := models.TenantCrmOperateLog{ TenantID: tenantID, - RelatedType: 3, + RelatedType: relatedType, RelatedID: relatedID, Action: "status", Content: content, @@ -228,7 +228,7 @@ func applyContractOverdue(tenantID string, contractID uint64, now time.Time) (in } contractUpdated = 1 // 自动改异常同样写明原因,否则日志里只看到"变异常"却不知为何 - writeOverdueStatusLog(tenantID, contractID, + writeOverdueStatusLog(tenantID, 6, contractID, "合同状态自动流转:"+crmContractStatusText(contract.Status)+" → 异常;异常原因:回款计划逾期未收齐", now) } // 合同关联项目联动置异常 @@ -242,7 +242,7 @@ func applyContractOverdue(tenantID string, contractID uint64, now time.Time) (in Filter("id", proj.ID).Filter("tenant_id", tenantID). Update(map[string]interface{}{"status": int8(5), "update_time": now}); err == nil { projectUpdated = 1 - writeOverdueStatusLog(tenantID, proj.ID, + writeOverdueStatusLog(tenantID, 3, proj.ID, "项目状态自动流转:"+crmProjectStatusText(proj.Status)+" → 异常;异常原因:关联合同「"+ contract.ContractName+"」回款计划逾期未收齐", now) }