This commit is contained in:
hero920103 committed 2026-11-11 08:53:14 +08:00
1 parent d9a50e23dd
commit 4f1acaa451
25 files changed
+1265 -335

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@ import request from "@/utils/request";
/**
* CRM 业务管线接口:线索 → 商机 → 项目,回访贯穿全流程。
*
* 关联类型 related_type:1=线索 2=商机 3=项目
* 关联类型 related_type:1=线索 2=商机 3=项目 4=投标 5=招标 6=合同
*/
/* --------------------------------- 线索 --------------------------------- */
@@ -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;
// 发票管理附件专属关联类型
+22 -5
View File
@@ -60,6 +60,18 @@
</div>
</div>
<!-- 状态 Tab 栏:按业务流程顺序展示 -->
<div class="status-tabs">
<el-tabs v-model="activeStatusTab" @tab-change="handleStatusTabChange">
<el-tab-pane label="全部" name="" />
<el-tab-pane label="草稿" name="1" />
<el-tab-pane label="已完成" name="2" />
<el-tab-pane label="履约中" name="4" />
<el-tab-pane label="异常" name="5" />
<el-tab-pane label="已作废" name="3" />
</el-tabs>
</div>
<div class="filter-bar">
<el-form :inline="true" :model="filters" @submit.prevent>
<el-form-item>
@@ -88,11 +100,6 @@
<el-option label="无头合同" value="headless" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 110px">
<el-option v-for="i in CONTRACT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
@@ -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();
}
+23 -5
View File
@@ -11,6 +11,19 @@
</div>
</div>
<!-- 状态 Tab 栏 -->
<div class="status-tabs">
<el-tabs v-model="activeStatusTab" @tab-change="handleStatusTabChange">
<el-tab-pane label="全部" name="" />
<el-tab-pane
v-for="i in PROJECT_STATUS_OPTIONS"
:key="i.value"
:label="i.label"
:name="i.value"
/>
</el-tabs>
</div>
<div class="filter-bar">
<el-form :inline="true" :model="filters" @submit.prevent>
<el-form-item>
@@ -23,11 +36,6 @@
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="项目状态">
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 130px">
<el-option v-for="i in PROJECT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<!-- 年度筛选:按项目开始时间(关联合同汇总)所在年份过滤,选项由后端按现有项目汇总 -->
<el-form-item label="年度">
<el-select v-model="filters.year" clearable placeholder="全部" style="width: 120px">
@@ -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();
}
@@ -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;
}
@@ -170,7 +170,7 @@ onMounted(loadList);
<style lang="less" scoped>
.approval-page {
background: #f5f7fa;
background: var(--el-bg-color-page);
padding: 20px;
}
@@ -182,13 +182,13 @@ onMounted(loadList);
h2 {
margin: 0 0 8px;
color: #303133;
color: var(--el-text-color-primary);
font-size: 22px;
}
p {
margin: 0;
color: #909399;
color: var(--el-text-color-secondary);
font-size: 13px;
}
}
@@ -202,10 +202,10 @@ onMounted(loadList);
.filter-card,
.table-card {
background: #fff;
border-radius: 6px;
background: var(--el-bg-color);
border-radius: var(--el-border-radius-base);
padding: 18px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
border: 1px solid var(--el-border-color-lighter);
}
.filter-card {
@@ -181,7 +181,7 @@ onMounted(loadList);
<style lang="less" scoped>
.approval-page {
background: #f5f7fa;
background: var(--el-bg-color-page);
padding: 20px;
}
@@ -193,23 +193,23 @@ onMounted(loadList);
h2 {
margin: 0 0 8px;
color: #303133;
color: var(--el-text-color-primary);
font-size: 22px;
}
p {
margin: 0;
color: #909399;
color: var(--el-text-color-secondary);
font-size: 13px;
}
}
.filter-card,
.table-card {
background: #fff;
border-radius: 6px;
background: var(--el-bg-color);
border-radius: var(--el-border-radius-base);
padding: 18px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
border: 1px solid var(--el-border-color-lighter);
}
.filter-card {
@@ -60,7 +60,7 @@ onMounted(loadFlows);
<style lang="less" scoped>
.approval-settings-page {
background: #f5f7fa;
background: var(--el-bg-color-page);
padding: 20px;
}
@@ -69,23 +69,23 @@ onMounted(loadFlows);
h2 {
margin: 0 0 8px;
color: #303133;
color: var(--el-text-color-primary);
font-size: 22px;
}
p {
margin: 0;
color: #909399;
color: var(--el-text-color-secondary);
font-size: 13px;
}
}
.reference-card {
margin-top: 16px;
background: #fff;
border-radius: 6px;
background: var(--el-bg-color);
border-radius: var(--el-border-radius-base);
padding: 18px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
border: 1px solid var(--el-border-color-lighter);
}
.section-title {
@@ -94,7 +94,7 @@ onMounted(loadFlows);
padding-left: 10px;
font-size: 15px;
font-weight: 600;
color: #303133;
color: var(--el-text-color-primary);
&::before {
content: "";
@@ -105,13 +105,13 @@ onMounted(loadFlows);
width: 3px;
height: 14px;
border-radius: 2px;
background: #2563eb;
background: var(--el-color-primary);
}
}
.hint {
margin: 0 0 12px;
font-size: 13px;
color: #909399;
color: var(--el-text-color-secondary);
}
</style>
+440 -141
View File
@@ -121,8 +121,138 @@
</div>
</div>
<!-- 右侧 30%:通知公告 + 工作日程提醒 -->
<!-- 右侧 30%:今日概览 + 通知公告 + 工作日程提醒 -->
<div class="col-right">
<!-- 今日概览卡片(顶部,与左侧 6 张指标卡区域等高) -->
<div class="today-card">
<div class="tc-greet">
<div class="tc-hello">Hi,{{ displayName }}</div>
<div class="tc-sub">
{{ timeGreeting }},今天是 {{ todayWeekday }} {{ todayDay }}
</div>
</div>
<div class="tc-tiles">
<div class="tc-tile">
<div class="tc-tile-head">
<span class="tc-tile-label">天气</span>
<el-icon
class="tc-refresh"
:class="{ spinning: weatherLoading }"
@click="refreshWeather"
>
<Refresh />
</el-icon>
</div>
<div class="tc-weather" v-if="weather.available">
<el-icon :size="26" class="tc-weather-icon">
<component :is="weather.icon" />
</el-icon>
<div class="tc-weather-text">
<div class="tc-weather-temp">{{ weather.temp }}°</div>
<div class="tc-weather-desc">
{{ weather.text }}<span v-if="weather.city"> · {{ weather.city }}</span>
</div>
</div>
</div>
<div class="tc-weather tc-weather-empty" v-else>
<el-icon :size="22"><Cloudy /></el-icon>
<span>暂未获取</span>
</div>
</div>
<div class="tc-tile">
<div class="tc-tile-head">
<span class="tc-tile-label">今日排班</span>
</div>
<div class="tc-shift" :class="todaySchedule.status">
<span class="tc-shift-dot" />
<span class="tc-shift-text">{{ todaySchedule.label }}</span>
</div>
<div class="tc-shift-meta">
<span v-if="todaySchedule.shiftName">{{ todaySchedule.shiftName }}</span>
<span v-if="todaySchedule.time">{{ todaySchedule.time }}</span>
</div>
</div>
</div>
<div class="tc-schedule">
<div class="tc-schedule-head">
<span class="tc-section-title">工作日程</span>
<el-button link type="primary" @click="goSchedule">查看全部</el-button>
</div>
<el-tabs v-model="scheduleRange" class="schedule-tabs">
<el-tab-pane
v-for="tab in scheduleRanges"
:key="tab.value"
:name="tab.value"
>
<template #label>
<span class="tab-label">{{ tab.label }}</span>
</template>
</el-tab-pane>
</el-tabs>
<div v-loading="scheduleLoading" class="schedule-list">
<div
v-for="item in filteredScheduleReminders"
:key="item.id"
class="schedule-item"
:class="{ done: item.status === 1 }"
:style="{ '--item-color': item.color }"
@click="openScheduleDetail(item)"
>
<span
class="item-status-dot"
:class="{ done: item.status === 1 }"
/>
<div class="schedule-item-main">
<div
class="schedule-item-title"
:class="{ done: item.status === 1 }"
>
<i v-if="hasImages(item)" class="fa-solid fa-image schedule-image-icon"></i>
{{ item.title }}
<el-tag
v-if="item.priority > 0"
:type="item.priority === 2 ? 'danger' : 'warning'"
size="small"
effect="light"
>
{{ item.priority === 2 ? "紧急" : "重要" }}
</el-tag>
<el-tag
v-if="item.source_id > 0 && item.status === 0"
type="warning"
size="small"
effect="plain"
>
延期第 {{ item.carry_count + 1 }} 天
</el-tag>
</div>
<div
class="schedule-item-time"
:class="{ done: item.status === 1 }"
>
<span :class="{ overdue: isOverdueSchedule(item) }">{{
scheduleDateTag(item)
}}</span>
<span>{{ scheduleTimeText(item) }}</span>
</div>
</div>
</div>
<el-empty
v-if="!filteredScheduleReminders.length && !scheduleLoading"
:description="
scheduleRange === 'today'
? '今日暂无日程安排'
: '该时间范围内暂无日程'
"
:image-size="60"
/>
</div>
</div>
</div>
<div class="chart-card notice-card">
<div class="chart-title notice-title">
<span>通知公告</span>
@@ -156,84 +286,6 @@
/>
</div>
</div>
<div class="chart-card schedule-card">
<div class="chart-title schedule-title">
<span>工作日程提醒</span>
<el-button link type="primary" @click="goSchedule"
>查看全部</el-button
>
</div>
<el-tabs v-model="scheduleRange" class="schedule-tabs">
<el-tab-pane
v-for="tab in scheduleRanges"
:key="tab.value"
:name="tab.value"
>
<template #label>
<span class="tab-label">{{ tab.label }}</span>
</template>
</el-tab-pane>
</el-tabs>
<div v-loading="scheduleLoading" class="schedule-list">
<div
v-for="item in filteredScheduleReminders"
:key="item.id"
class="schedule-item"
:class="{ done: item.status === 1 }"
:style="{ '--item-color': item.color }"
@click="openScheduleDetail(item)"
>
<span
class="item-status-dot"
:class="{ done: item.status === 1 }"
/>
<div class="schedule-item-main">
<div
class="schedule-item-title"
:class="{ done: item.status === 1 }"
>
<i v-if="hasImages(item)" class="fa-solid fa-image schedule-image-icon"></i>
{{ item.title }}
<el-tag
v-if="item.priority > 0"
:type="item.priority === 2 ? 'danger' : 'warning'"
size="small"
effect="light"
>
{{ item.priority === 2 ? "紧急" : "重要" }}
</el-tag>
<el-tag
v-if="item.source_id > 0 && item.status === 0"
type="warning"
size="small"
effect="plain"
>
延期第 {{ item.carry_count + 1 }} 天
</el-tag>
</div>
<div
class="schedule-item-time"
:class="{ done: item.status === 1 }"
>
<span :class="{ overdue: isOverdueSchedule(item) }">{{
scheduleDateTag(item)
}}</span>
<span>{{ scheduleTimeText(item) }}</span>
</div>
</div>
</div>
<el-empty
v-if="!filteredScheduleReminders.length && !scheduleLoading"
:description="
scheduleRange === 'today'
? '今日暂无日程安排'
: '该时间范围内暂无日程'
"
:image-size="60"
/>
</div>
</div>
</div>
</div>
@@ -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);
@@ -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 || ''));
@@ -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`);
@@ -5,11 +5,16 @@
<span v-if="overdueDays > 0" class="postpone-overdue"
>该日程已逾期 {{ overdueDays }} 天。</span
>
延期后「{{ schedule.title }}」在 {{ schedule.schedule_date }}
当天标记为已完成,并在目标日期自动生成一条待办继续跟进。
「{{ schedule.title }}」在 {{ schedule.schedule_date }} 当天会标记为已完成。延期到会仅在目标日期创建一条待办;延期至会在次日至目标日期的每一天分别创建一条待办。
</p>
<el-form label-position="top">
<el-form-item label="延期到">
<el-form-item label="延期方式">
<el-radio-group v-model="postponeMode">
<el-radio-button label="to">延期到(点对点)</el-radio-button>
<el-radio-button label="through">延期至(逐日创建)</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item :label="postponeMode === 'to' ? '延期到' : '延期至'">
<el-date-picker
v-model="targetDate"
type="date"
@@ -118,6 +123,7 @@ const overdueDays = computed(() => {
});
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 {
+78 -103
View File
@@ -113,17 +113,7 @@
<span v-else-if="cell.tag === 'work'" class="holiday-tag is-work"
>班</span
>
<span v-if="cell.isToday" class="today-tag">今</span>
<span v-else-if="cell.day === 1" class="month-tag"
>{{ cell.month }}月</span
>
<span
v-if="cell.myEntry"
class="my-shift-chip"
:style="{ background: cell.myEntry.color }"
:title="`${cell.myEntry.shift_name} ${cell.myEntry.work_start}-${cell.myEntry.work_end}`"
>{{ cell.myEntry.short_name || cell.myEntry.shift_name }}</span
>
<span v-if="cell.day === 1" class="month-tag">{{ cell.month }}月</span>
</div>
<div v-if="cell.holidayName" class="cell-holiday" :title="cell.holidayName">
{{ cell.holidayName }}
@@ -158,6 +148,8 @@
还有 {{ cell.items.length - 3 }} 项
</div>
</div>
<div v-if="cell.myTag === 'work'" class="cell-sched-badge is-work">班</div>
<div v-else-if="cell.myTag === 'rest'" class="cell-sched-badge is-rest">休</div>
</div>
</div>
</div>
@@ -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 {
+358 -10
View File
@@ -31,16 +31,69 @@
<span class="period-range">{{ rangeText }}</span>
</div>
<!-- 主指标:本期购销、库存与应收 -->
<div class="stats-grid">
<div v-for="item in primaryStats" :key="item.label" class="stat-card" :class="item.tone">
<div class="stat-icon">
<el-icon :size="24"><component :is="item.icon" /></el-icon>
<!-- 主指标:本期购销、库存与应收 + 右侧今日概览 -->
<div class="top-row">
<div class="top-row-left">
<div class="stats-grid">
<div v-for="item in primaryStats" :key="item.label" class="stat-card" :class="item.tone">
<div class="stat-icon">
<el-icon :size="24"><component :is="item.icon" /></el-icon>
</div>
<div class="stat-body">
<div class="stat-label">{{ item.label }}</div>
<div class="stat-value">{{ item.value }}</div>
<div class="stat-hint">{{ item.hint }}</div>
</div>
</div>
</div>
<div class="stat-body">
<div class="stat-label">{{ item.label }}</div>
<div class="stat-value">{{ item.value }}</div>
<div class="stat-hint">{{ item.hint }}</div>
</div>
<!-- 今日概览卡片:星期几、是否上班、天气 -->
<div class="today-card">
<div class="today-header">
<div class="today-date">
<div class="today-weekday">{{ todayWeekday }}</div>
<div class="today-day">{{ todayDay }}</div>
</div>
<div class="today-schedule" :class="todaySchedule.status">
<el-icon :size="18">
<component :is="todaySchedule.icon" />
</el-icon>
<div class="schedule-info">
<span class="schedule-label">{{ todaySchedule.label }}</span>
<span class="schedule-time" v-if="todaySchedule.time">{{ todaySchedule.time }}</span>
</div>
</div>
</div>
<div class="today-weather" v-if="weather.available">
<div class="weather-main">
<el-icon :size="36"><component :is="weather.icon" /></el-icon>
<div class="weather-temp">{{ weather.temp }}°</div>
</div>
<div class="weather-detail">
<span>{{ weather.text }}</span>
<span v-if="weather.city" class="weather-city">{{ weather.city }}</span>
</div>
</div>
<div class="today-weather weather-fallback" v-else>
<el-icon :size="32"><Cloudy /></el-icon>
<div class="weather-detail">
<span>天气暂未获取</span>
</div>
</div>
<div class="today-extra">
<div class="extra-item">
<span class="extra-label">是否上班</span>
<span class="extra-value" :class="todaySchedule.status === 'work' ? 'text-success' : 'text-muted'">
{{ todaySchedule.status === 'work' ? '上班' : '休息' }}
</span>
</div>
<div class="extra-item" v-if="todaySchedule.shiftName">
<span class="extra-label">班次</span>
<span class="extra-value">{{ todaySchedule.shiftName }}</span>
</div>
</div>
</div>
</div>
@@ -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<echarts.ECharts | null>(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<any>(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<WeatherInfo>({
available: false,
temp: '',
text: '',
city: '',
icon: PartlyCloudy,
});
const WEATHER_ICON_MAP: Record<string, any> = {
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;
+1 -1
View File
@@ -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})
}
+1 -1
View File
@@ -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})
}
+3 -3
View File
@@ -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})
}
+1 -1
View File
@@ -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})
+58 -8
View File
@@ -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)
+62 -6
View File
@@ -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() {
+146
View File
@@ -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 ""
}
+4
View File
@@ -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
+1 -1
View File
@@ -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"`
+3
View File
@@ -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")
+5 -5
View File
@@ -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)
}