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

+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);