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 }}
+
+
+
+
+
+
+ 工作日程
+ 查看全部
+
+
+
+
+ {{ tab.label }}
+
+
+
+
+
+
+
+
+
+ {{ item.title }}
+
+ {{ item.priority === 2 ? "紧急" : "重要" }}
+
+
+ 延期第 {{ item.carry_count + 1 }} 天
+
+
+
+ {{
+ scheduleDateTag(item)
+ }}
+ {{ scheduleTimeText(item) }}
+
+
+
+
+
+
+
+
通知公告
@@ -156,84 +286,6 @@
/>
-
-
-
- 工作日程提醒
- 查看全部
-
-
-
-
- {{ tab.label }}
-
-
-
-
-
-
-
-
-
- {{ 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 }}
+
+
+
+
+
+
+
+
+
+
{{ weather.temp }}°
+
+
+ {{ weather.text }}
+ {{ weather.city }}
+
+
+
+
+
@@ -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)
}