优化日程提醒模块
This commit is contained in:
@@ -47,3 +47,21 @@ export function finishSchedule(id) {
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
// 顺延单条日程到下一天(target_date 为空时后端默认取当前日程日期 +1 天)
|
||||
export function carrySchedule(id, data) {
|
||||
return request({
|
||||
url: `/backend/oa/schedule/carry/${id}`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 批量顺延指定日期的全部未完成日程
|
||||
export function carryPendingSchedules(data) {
|
||||
return request({
|
||||
url: '/backend/oa/schedule/carry-pending',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,33 +8,93 @@
|
||||
<el-button :icon="Refresh" @click="loadDashboard">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 报销统计卡片 -->
|
||||
<el-row :gutter="16" class="stat-row">
|
||||
<el-col :span="8" :xs="24" v-for="item in statCards" :key="item.key">
|
||||
<div class="stat-card">
|
||||
<div class="stat-title">{{ item.label }}</div>
|
||||
<div class="stat-amount">
|
||||
<span class="currency">¥</span>
|
||||
<span class="number">{{ money(item.data.total) }}</span>
|
||||
</div>
|
||||
<div class="stat-meta">
|
||||
<span>{{ item.data.count }} 单</span>
|
||||
<span>已报销 ¥ {{ money(item.data.paid) }}</span>
|
||||
<span>未报销 ¥ {{ money(item.data.unpaid) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="dashboard-columns">
|
||||
<!-- 左侧 60%:报销相关内容 -->
|
||||
<div class="col-left">
|
||||
<!-- 报销统计卡片 -->
|
||||
<el-row :gutter="16" class="stat-row">
|
||||
<el-col :span="8" :xs="24" v-for="item in statCards" :key="item.key">
|
||||
<div class="stat-card">
|
||||
<div class="stat-title">{{ item.label }}</div>
|
||||
<div class="stat-amount">
|
||||
<span class="currency">¥</span>
|
||||
<span class="number">{{ money(item.data.total) }}</span>
|
||||
</div>
|
||||
<div class="stat-meta">
|
||||
<span>{{ item.data.count }} 单</span>
|
||||
<span>已报销 ¥ {{ money(item.data.paid) }}</span>
|
||||
<span>未报销 ¥ {{ money(item.data.unpaid) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<el-row :gutter="16" class="chart-row">
|
||||
<el-col :span="14" :xs="24">
|
||||
<!-- 报销趋势 -->
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">近6个月报销趋势</div>
|
||||
<div ref="trendChartRef" class="chart-container"></div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="10" :xs="24">
|
||||
|
||||
<!-- 状态分布 -->
|
||||
<div class="chart-card status-card">
|
||||
<div class="chart-title">状态分布</div>
|
||||
<div class="status-dist">
|
||||
<div
|
||||
v-for="(item, key) in dashboard.status_dist"
|
||||
:key="key"
|
||||
class="dist-row"
|
||||
>
|
||||
<el-tag :type="statusType(Number(key))" size="small">{{
|
||||
item.name
|
||||
}}</el-tag>
|
||||
<div class="dist-bar-wrap">
|
||||
<div
|
||||
class="dist-bar"
|
||||
:style="{ width: distPercent(item.count) }"
|
||||
/>
|
||||
</div>
|
||||
<span class="dist-count"
|
||||
>{{ item.count }} 单 / ¥ {{ money(item.amount) }}</span
|
||||
>
|
||||
</div>
|
||||
<el-empty
|
||||
v-if="!Object.keys(dashboard.status_dist).length"
|
||||
description="暂无数据"
|
||||
:image-size="60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 报销类型分析 -->
|
||||
<div class="chart-card type-card">
|
||||
<div class="chart-title">报销类型分析</div>
|
||||
<div class="type-dist">
|
||||
<div
|
||||
v-for="item in dashboard.expense_types"
|
||||
:key="item.name"
|
||||
class="type-row"
|
||||
>
|
||||
<span class="type-name">{{ item.name }}</span>
|
||||
<div class="type-bar-wrap">
|
||||
<div
|
||||
class="type-bar"
|
||||
:style="{ width: typePercent(item.amount) }"
|
||||
/>
|
||||
</div>
|
||||
<span class="type-count">{{ item.count }} 笔</span>
|
||||
<span class="type-amount">¥ {{ money(item.amount) }}</span>
|
||||
</div>
|
||||
<el-empty
|
||||
v-if="!dashboard.expense_types.length"
|
||||
description="暂无费用明细"
|
||||
:image-size="60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧 40%:工作日程提醒 -->
|
||||
<div class="col-right">
|
||||
<div class="chart-card schedule-card">
|
||||
<div class="chart-title schedule-title">
|
||||
<span>工作日程提醒</span>
|
||||
@@ -42,19 +102,36 @@
|
||||
>查看全部</el-button
|
||||
>
|
||||
</div>
|
||||
<el-tabs v-model="scheduleTab" class="schedule-tabs">
|
||||
<el-tab-pane
|
||||
v-for="tab in scheduleTabs"
|
||||
:key="tab.value"
|
||||
:name="tab.value"
|
||||
>
|
||||
<template #label>
|
||||
<span class="tab-label">
|
||||
{{ tab.label }}
|
||||
<span class="tab-count">{{
|
||||
scheduleCounts[tab.value] || 0
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<div v-loading="scheduleLoading" class="schedule-list">
|
||||
<div
|
||||
v-for="item in scheduleReminders"
|
||||
v-for="item in filteredScheduleReminders"
|
||||
:key="item.id"
|
||||
class="schedule-item"
|
||||
:class="{ done: item.status === 1 }"
|
||||
:style="{ '--item-color': item.color }"
|
||||
@click="openScheduleDetail(item)"
|
||||
>
|
||||
<el-checkbox
|
||||
:model-value="item.status === 1"
|
||||
@change="toggleScheduleFinish(item)"
|
||||
<span
|
||||
class="item-status-dot"
|
||||
:class="{ done: item.status === 1 }"
|
||||
/>
|
||||
<div class="schedule-item-main" @click="toggleScheduleFinish(item)">
|
||||
<div class="schedule-item-main">
|
||||
<div
|
||||
class="schedule-item-title"
|
||||
:class="{ done: item.status === 1 }"
|
||||
@@ -68,6 +145,14 @@
|
||||
>
|
||||
{{ 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"
|
||||
@@ -81,68 +166,35 @@
|
||||
</div>
|
||||
</div>
|
||||
<el-empty
|
||||
v-if="!scheduleReminders.length && !scheduleLoading"
|
||||
description="近期暂无日程安排"
|
||||
v-if="!filteredScheduleReminders.length && !scheduleLoading"
|
||||
:description="
|
||||
scheduleTab === 'all'
|
||||
? '近期暂无日程安排'
|
||||
: '该分类下暂无日程'
|
||||
"
|
||||
:image-size="60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 状态分布 -->
|
||||
<div class="chart-card status-card">
|
||||
<div class="chart-title">状态分布</div>
|
||||
<div class="status-dist">
|
||||
<div
|
||||
v-for="(item, key) in dashboard.status_dist"
|
||||
:key="key"
|
||||
class="dist-row"
|
||||
>
|
||||
<el-tag :type="statusType(Number(key))" size="small">{{
|
||||
item.name
|
||||
}}</el-tag>
|
||||
<div class="dist-bar-wrap">
|
||||
<div
|
||||
class="dist-bar"
|
||||
:style="{ width: distPercent(item.count) }"
|
||||
/>
|
||||
</div>
|
||||
<span class="dist-count"
|
||||
>{{ item.count }} 单 / ¥ {{ money(item.amount) }}</span
|
||||
>
|
||||
</div>
|
||||
<el-empty
|
||||
v-if="!Object.keys(dashboard.status_dist).length"
|
||||
description="暂无数据"
|
||||
:image-size="60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 报销类型分析 -->
|
||||
<div class="chart-card type-card">
|
||||
<div class="chart-title">报销类型分析</div>
|
||||
<div class="type-dist">
|
||||
<div
|
||||
v-for="item in dashboard.expense_types"
|
||||
:key="item.name"
|
||||
class="type-row"
|
||||
>
|
||||
<span class="type-name">{{ item.name }}</span>
|
||||
<div class="type-bar-wrap">
|
||||
<div class="type-bar" :style="{ width: typePercent(item.amount) }" />
|
||||
</div>
|
||||
<span class="type-count">{{ item.count }} 笔</span>
|
||||
<span class="type-amount">¥ {{ money(item.amount) }}</span>
|
||||
</div>
|
||||
<el-empty
|
||||
v-if="!dashboard.expense_types.length"
|
||||
description="暂无费用明细"
|
||||
:image-size="60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 日程详情抽屉:点击任务后由用户明确选择延期 / 已完成 / 反审核 -->
|
||||
<ScheduleDetail
|
||||
v-model="scheduleDetailVisible"
|
||||
:schedule="scheduleDetailItem"
|
||||
:editable="false"
|
||||
@finish="handleScheduleFinish"
|
||||
@revert="handleScheduleRevert"
|
||||
@postpone="handleSchedulePostpone"
|
||||
/>
|
||||
|
||||
<!-- 延期对话框 -->
|
||||
<SchedulePostpone
|
||||
v-model="schedulePostponeVisible"
|
||||
:schedule="scheduleDetailItem"
|
||||
@saved="loadScheduleReminders"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -154,6 +206,8 @@ import * as echarts from "echarts";
|
||||
import { Refresh } from "@element-plus/icons-vue";
|
||||
import { getReimbursementDashboard } from "@/api/reimburse";
|
||||
import { finishSchedule, getScheduleList } from "@/api/oaSchedule";
|
||||
import ScheduleDetail from "../schedule/components/detail.vue";
|
||||
import SchedulePostpone from "../schedule/components/postponeDialog.vue";
|
||||
|
||||
const emptyStat = () => ({ total: 0, paid: 0, unpaid: 0, count: 0 });
|
||||
const dashboard = reactive({
|
||||
@@ -249,6 +303,51 @@ const router = useRouter();
|
||||
const scheduleReminders = ref([]);
|
||||
const scheduleLoading = ref(false);
|
||||
|
||||
// 待办分类:all-全部 today-今日 pending-未完成 done-已完成
|
||||
// 注:matchScheduleCategory 仍保留 ongoing 分支(进行中 = 跨天延期中的任务),
|
||||
// 当前不启用;以后要恢复,在下面数组里加一行 { value: "ongoing", label: "进行中" } 即可。
|
||||
const scheduleTabs = [
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "today", label: "今日" },
|
||||
{ value: "pending", label: "未完成" },
|
||||
{ value: "done", label: "已完成" },
|
||||
];
|
||||
const scheduleTab = ref("all");
|
||||
|
||||
// 判断一条日程是否属于某个分类(与后端 category 语义保持一致)
|
||||
const matchScheduleCategory = (item, category) => {
|
||||
switch (category) {
|
||||
case "today":
|
||||
return item.schedule_date === fmtDate(new Date());
|
||||
case "pending":
|
||||
return item.status === 0;
|
||||
// 进行中 = 跨天顺延中的任务:被顺延出去的源日程,或顺延出来的续做日程
|
||||
case "ongoing":
|
||||
return item.source_id > 0 || item.carry_over === 1;
|
||||
case "done":
|
||||
return item.status === 1;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredScheduleReminders = computed(() =>
|
||||
scheduleReminders.value.filter((item) =>
|
||||
matchScheduleCategory(item, scheduleTab.value),
|
||||
),
|
||||
);
|
||||
|
||||
// 各分类数量按当前面板范围(逾期 + 未来7天)统计
|
||||
const scheduleCounts = computed(() => {
|
||||
const result = {};
|
||||
for (const tab of scheduleTabs) {
|
||||
result[tab.value] = scheduleReminders.value.filter((item) =>
|
||||
matchScheduleCategory(item, tab.value),
|
||||
).length;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const pad2 = (n) => String(n).padStart(2, "0");
|
||||
const fmtDate = (d) =>
|
||||
`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
@@ -258,44 +357,29 @@ const loadScheduleReminders = async () => {
|
||||
scheduleLoading.value = true;
|
||||
try {
|
||||
const today = new Date();
|
||||
const todayStr = fmtDate(today);
|
||||
const weekLater = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate() + 7,
|
||||
);
|
||||
const yesterday = fmtDate(
|
||||
new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1),
|
||||
);
|
||||
const [overdueRes, upcomingRes] = await Promise.all([
|
||||
getScheduleList({
|
||||
end_date: yesterday,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
}),
|
||||
getScheduleList({
|
||||
start_date: todayStr,
|
||||
end_date: fmtDate(weekLater),
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
}),
|
||||
]);
|
||||
const overdue = (overdueRes?.data?.list || []).map(item => ({
|
||||
...item,
|
||||
// 统一日期格式,避免后端 parseTime 输出 Go 默认格式导致标签解析 NaN
|
||||
schedule_date: String(item.schedule_date || "").slice(0, 10),
|
||||
}));
|
||||
const upcoming = (upcomingRes?.data?.list || []).map(item => ({
|
||||
...item,
|
||||
schedule_date: String(item.schedule_date || "").slice(0, 10),
|
||||
}));
|
||||
scheduleReminders.value = [...overdue, ...upcoming].sort((a, b) => {
|
||||
// 未完成在前,已完成沉底,保证点击完成后条目保留可见(灰显)
|
||||
if (a.status !== b.status) return a.status - b.status;
|
||||
return `${a.schedule_date}${a.start_time}`.localeCompare(
|
||||
`${b.schedule_date}${b.start_time}`,
|
||||
);
|
||||
const res = await getScheduleList({
|
||||
end_date: fmtDate(weekLater),
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
});
|
||||
scheduleReminders.value = (res?.data?.list || [])
|
||||
.map((item) => ({
|
||||
...item,
|
||||
// 统一日期格式,避免后端 parseTime 输出 Go 默认格式导致标签解析 NaN
|
||||
schedule_date: String(item.schedule_date || "").slice(0, 10),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
// 未完成在前,已完成沉底,保证点击完成后条目保留可见(灰显)
|
||||
if (a.status !== b.status) return a.status - b.status;
|
||||
return `${a.schedule_date}${a.start_time}`.localeCompare(
|
||||
`${b.schedule_date}${b.start_time}`,
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("加载日程提醒失败:", error?.message);
|
||||
} finally {
|
||||
@@ -335,16 +419,36 @@ const scheduleTimeText = (item) =>
|
||||
const isOverdueSchedule = (item) =>
|
||||
item.status === 0 && item.schedule_date < fmtDate(new Date());
|
||||
|
||||
// 点击任务只打开详情抽屉,由用户明确选择延期 / 已完成 / 反审核,避免误点即完成
|
||||
const scheduleDetailVisible = ref(false);
|
||||
const scheduleDetailItem = ref(null);
|
||||
const schedulePostponeVisible = ref(false);
|
||||
|
||||
const openScheduleDetail = (item) => {
|
||||
scheduleDetailItem.value = item;
|
||||
scheduleDetailVisible.value = true;
|
||||
};
|
||||
|
||||
const toggleScheduleFinish = async (item) => {
|
||||
const res = await finishSchedule(item.id);
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success(res.data?.status === 1 ? "已完成" : "已恢复待办");
|
||||
ElMessage.success(res.data?.status === 1 ? "已完成" : "已反审核");
|
||||
scheduleDetailVisible.value = false;
|
||||
loadScheduleReminders();
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "操作失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleScheduleFinish = (item) => toggleScheduleFinish(item);
|
||||
const handleScheduleRevert = (item) => toggleScheduleFinish(item);
|
||||
|
||||
const handleSchedulePostpone = (item) => {
|
||||
scheduleDetailItem.value = item;
|
||||
scheduleDetailVisible.value = false;
|
||||
schedulePostponeVisible.value = true;
|
||||
};
|
||||
|
||||
const goSchedule = () => router.push("/apps/oa/schedule");
|
||||
|
||||
const loadDashboard = async () => {
|
||||
@@ -455,11 +559,28 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.chart-row {
|
||||
/* 左右分栏:左 70% 报销,右 30% 日程提醒 */
|
||||
.dashboard-columns {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.col-left {
|
||||
flex: 0 0 calc(70% - 8px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.col-right {
|
||||
flex: 0 0 calc(30% - 8px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.col-left > .chart-card {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.el-col {
|
||||
margin-bottom: 8px;
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,9 +610,10 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.schedule-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/*min-height: 340px; */
|
||||
height: 600px;
|
||||
|
||||
.schedule-title {
|
||||
display: flex;
|
||||
@@ -504,6 +626,40 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-tabs {
|
||||
:deep(.el-tabs__header) {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
/* 列表由下方 .schedule-list 承载,tabs 只作切换栏,隐藏空的面板容器 */
|
||||
:deep(.el-tabs__content) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item) {
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
min-width: 18px;
|
||||
padding: 0 5px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
border-radius: 8px;
|
||||
background: #f0f2f5;
|
||||
color: #909399;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -512,7 +668,9 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 80px;
|
||||
min-height: 120px;
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.schedule-item {
|
||||
@@ -523,12 +681,32 @@ onBeforeUnmount(() => {
|
||||
background: #fafbfc;
|
||||
border-left: 3px solid var(--item-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
&.done {
|
||||
background: #f5f7fa;
|
||||
border-left-color: #dcdfe6;
|
||||
}
|
||||
|
||||
/* 状态指示点:仅标识状态,点击整行弹出详情由用户选择操作 */
|
||||
.item-status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-top: 5px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--item-color);
|
||||
|
||||
&.done {
|
||||
background: #c0c4cc;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-item-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -633,6 +811,23 @@ onBeforeUnmount(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 窄屏下左右分栏改为上下堆叠 */
|
||||
@media (max-width: 1100px) {
|
||||
.dashboard-columns {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.col-left,
|
||||
.col-right {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.col-right {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.oa-dashboard {
|
||||
padding: 12px;
|
||||
|
||||
@@ -53,6 +53,38 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="学历" prop="education">
|
||||
<el-select
|
||||
v-model="form.education"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
placeholder="选择或输入"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="opt in educationOptions" :key="opt" :label="opt" :value="opt" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="民族" prop="nation">
|
||||
<el-select
|
||||
v-model="form.nation"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
placeholder="选择或输入"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="opt in nationOptions" :key="opt" :label="opt" :value="opt" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="婚姻状况" prop="marital_status">
|
||||
@@ -149,7 +181,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { createEmployeeFile, getFiledEmployeeIds, updateEmployeeFile } from '@/api/employeeFile';
|
||||
import { oaOrganizationApi } from '@/api/organization';
|
||||
@@ -176,10 +208,23 @@ const filedIds = ref([]);
|
||||
|
||||
const politicalOptions = ['群众', '中共党员', '中共预备党员', '共青团员', '民主党派', '无党派人士'];
|
||||
|
||||
const educationOptions = ['小学', '初中', '高中', '中专', '大专', '本科', '硕士研究生', '博士研究生', '其他'];
|
||||
|
||||
const nationOptions = [
|
||||
'汉族', '蒙古族', '回族', '藏族', '维吾尔族', '苗族', '彝族', '壮族', '布依族', '朝鲜族',
|
||||
'满族', '侗族', '瑶族', '白族', '土家族', '哈尼族', '哈萨克族', '傣族', '黎族', '傈僳族',
|
||||
'佤族', '畲族', '高山族', '拉祜族', '水族', '东乡族', '纳西族', '景颇族', '柯尔克孜族', '土族',
|
||||
'达斡尔族', '仫佬族', '羌族', '布朗族', '撒拉族', '毛南族', '仡佬族', '锡伯族', '阿昌族', '普米族',
|
||||
'塔吉克族', '怒族', '乌孜别克族', '俄罗斯族', '鄂温克族', '德昂族', '保安族', '裕固族', '京族', '塔塔尔族',
|
||||
'独龙族', '鄂伦春族', '赫哲族', '门巴族', '珞巴族', '基诺族',
|
||||
];
|
||||
|
||||
const form = reactive({
|
||||
employee_id: null,
|
||||
file_no: '',
|
||||
id_card: '',
|
||||
education: '',
|
||||
nation: '',
|
||||
political_status: '',
|
||||
marital_status: 0,
|
||||
native_place: '',
|
||||
@@ -213,10 +258,26 @@ const candidateEmployees = computed(() => {
|
||||
return allEmployees.value.filter((emp) => !filed.has(Number(emp.id)));
|
||||
});
|
||||
|
||||
/** 新建档案时选择员工,自动带出员工已有数据,避免重复录入 */
|
||||
watch(
|
||||
() => form.employee_id,
|
||||
(id) => {
|
||||
if (isEdit.value || !id) return;
|
||||
const emp = allEmployees.value.find((e) => Number(e.id) === Number(id));
|
||||
if (!emp) return;
|
||||
if (!form.work_email && emp.email) form.work_email = emp.email;
|
||||
if (!form.current_address && emp.home_address) form.current_address = emp.home_address;
|
||||
if (!form.education && emp.education) form.education = emp.education;
|
||||
if (!form.nation && emp.nation) form.nation = emp.nation;
|
||||
}
|
||||
);
|
||||
|
||||
const resetForm = () => {
|
||||
form.employee_id = null;
|
||||
form.file_no = '';
|
||||
form.id_card = '';
|
||||
form.education = '';
|
||||
form.nation = '';
|
||||
form.political_status = '';
|
||||
form.marital_status = 0;
|
||||
form.native_place = '';
|
||||
@@ -269,6 +330,8 @@ const open = async (file = null) => {
|
||||
form.employee_id = file.employee_id ? Number(file.employee_id) : null;
|
||||
form.file_no = file.file_no || '';
|
||||
form.id_card = file.id_card || '';
|
||||
form.education = file.education || '';
|
||||
form.nation = file.nation || '';
|
||||
form.political_status = file.political_status || '';
|
||||
form.marital_status = Number(file.marital_status ?? 0);
|
||||
form.native_place = file.native_place || '';
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
<span class="meta-label">状态</span>
|
||||
<span>{{ statusText }}</span>
|
||||
</div>
|
||||
<div v-if="carryText" class="meta-row">
|
||||
<span class="meta-label">延期</span>
|
||||
<span>{{ carryText }}</span>
|
||||
</div>
|
||||
<div v-if="schedule.content" class="meta-row">
|
||||
<span class="meta-label">备注</span>
|
||||
<span class="meta-content">{{ schedule.content }}</span>
|
||||
@@ -51,10 +55,31 @@
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">关闭</el-button>
|
||||
<el-button @click="emit('toggle', schedule)">{{
|
||||
schedule?.status === 1 ? "取消完成" : "完成"
|
||||
}}</el-button>
|
||||
<el-button type="primary" @click="emit('edit', schedule)"
|
||||
<!-- 待办:可选择延期或标记已完成;已完成:只能反审核回待办 -->
|
||||
<el-button
|
||||
v-if="schedule?.status !== 1"
|
||||
:icon="Promotion"
|
||||
@click="emit('postpone', schedule)"
|
||||
>延期</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="schedule?.status !== 1"
|
||||
type="success"
|
||||
:icon="CircleCheck"
|
||||
@click="emit('finish', schedule)"
|
||||
>已完成</el-button
|
||||
>
|
||||
<el-button
|
||||
v-else
|
||||
type="warning"
|
||||
:icon="RefreshLeft"
|
||||
@click="emit('revert', schedule)"
|
||||
>反审核</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="editable"
|
||||
type="primary"
|
||||
@click="emit('edit', schedule)"
|
||||
>编辑</el-button
|
||||
>
|
||||
</template>
|
||||
@@ -63,6 +88,11 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import {
|
||||
CircleCheck,
|
||||
Promotion,
|
||||
RefreshLeft
|
||||
} from "@element-plus/icons-vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -73,10 +103,21 @@ const props = defineProps({
|
||||
schedule: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
// 是否显示"编辑"按钮(仪表盘只读场景传 false)
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "edit", "toggle"]);
|
||||
const emit = defineEmits([
|
||||
"update:modelValue",
|
||||
"edit",
|
||||
"finish",
|
||||
"revert",
|
||||
"postpone"
|
||||
]);
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
@@ -105,6 +146,21 @@ const timeText = computed(() => {
|
||||
return s.end_time ? `${s.start_time} - ${s.end_time}` : s.start_time || "-";
|
||||
});
|
||||
|
||||
// 延期说明:已完成的是被延期出去的原日程,未完成的是被延期出来的续做日程
|
||||
const carryText = computed(() => {
|
||||
const s = props.schedule;
|
||||
if (!s) {
|
||||
return "";
|
||||
}
|
||||
if (s.carry_over === 1) {
|
||||
return `已延期至 ${s.carry_to_date || "下一天"}`;
|
||||
}
|
||||
if (s.source_id > 0) {
|
||||
return `由前序日程延期而来,已延期 ${s.carry_count || 0} 次`;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const statusText = computed(() => {
|
||||
const s = props.schedule;
|
||||
if (!s) {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="延期日程" width="440px" append-to-body>
|
||||
<div v-if="schedule" class="postpone-body">
|
||||
<p class="postpone-tip">
|
||||
<span v-if="overdueDays > 0" class="postpone-overdue"
|
||||
>该日程已逾期 {{ overdueDays }} 天。</span
|
||||
>
|
||||
延期后「{{ schedule.title }}」在 {{ schedule.schedule_date }}
|
||||
当天标记为已完成,并在目标日期自动生成一条待办继续跟进。
|
||||
</p>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="延期到">
|
||||
<el-date-picker
|
||||
v-model="targetDate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:clearable="false"
|
||||
:disabled-date="disabledDate"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="postpone-quick">
|
||||
<el-button
|
||||
v-for="opt in quickOptions"
|
||||
:key="opt.label"
|
||||
size="small"
|
||||
@click="targetDate = addDays(baseDate, opt.days)"
|
||||
>{{ opt.label }}</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submit"
|
||||
>确定延期</el-button
|
||||
>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { carrySchedule } from "@/api/oaSchedule";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 需要延期的日程对象
|
||||
schedule: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "saved"]);
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit("update:modelValue", value)
|
||||
});
|
||||
|
||||
const pad2 = n => String(n).padStart(2, "0");
|
||||
const fmtDate = d =>
|
||||
`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
|
||||
// 后端 DSN 开启 parseTime 时日期可能带时间部分,统一截取前 10 位
|
||||
function addDays(dateStr, days) {
|
||||
const parts = String(dateStr || "")
|
||||
.slice(0, 10)
|
||||
.split("-")
|
||||
.map(Number);
|
||||
if (parts.length !== 3 || parts.some(Number.isNaN)) {
|
||||
return "";
|
||||
}
|
||||
return fmtDate(new Date(parts[0], parts[1] - 1, parts[2] + days));
|
||||
}
|
||||
|
||||
const quickOptions = computed(() => {
|
||||
const d = String(props.schedule?.schedule_date || "").slice(0, 10);
|
||||
const today = fmtDate(new Date());
|
||||
// 逾期任务以"今天"为基准,快捷项给出今天/明天/下周
|
||||
if (d && d < today) {
|
||||
return [
|
||||
{ label: "今天", days: 0 },
|
||||
{ label: "明天", days: 1 },
|
||||
{ label: "下周", days: 7 }
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ label: "明天", days: 1 },
|
||||
{ label: "后天", days: 2 },
|
||||
{ label: "下周", days: 7 }
|
||||
];
|
||||
});
|
||||
|
||||
// 快捷键的基准日期:逾期任务按今天算,未到期任务按自身日期算
|
||||
const baseDate = computed(() => {
|
||||
const today = fmtDate(new Date());
|
||||
const d = String(props.schedule?.schedule_date || "").slice(0, 10);
|
||||
return d > today ? d : today;
|
||||
});
|
||||
|
||||
// 已逾期天数,用于提示(未逾期为 0)
|
||||
const overdueDays = computed(() => {
|
||||
const d = String(props.schedule?.schedule_date || "").slice(0, 10);
|
||||
if (!d || props.schedule?.status === 1) {
|
||||
return 0;
|
||||
}
|
||||
const today = fmtDate(new Date());
|
||||
if (d >= today) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round((new Date(today) - new Date(d)) / 86400000);
|
||||
});
|
||||
|
||||
const targetDate = ref("");
|
||||
const saving = ref(false);
|
||||
|
||||
// 打开时给出默认日期:逾期任务默认补到今天,未到期任务默认顺延到下一天
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
open => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const today = fmtDate(new Date());
|
||||
const d = String(props.schedule?.schedule_date || "").slice(0, 10);
|
||||
targetDate.value = d && d < today ? today : addDays(d, 1);
|
||||
}
|
||||
);
|
||||
|
||||
// 只能延期到当前日程日期之后
|
||||
function disabledDate(date) {
|
||||
if (!props.schedule) {
|
||||
return false;
|
||||
}
|
||||
return fmtDate(date) <= String(props.schedule.schedule_date || "").slice(0, 10);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!props.schedule || !targetDate.value) {
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const res = await carrySchedule(props.schedule.id, {
|
||||
target_date: targetDate.value
|
||||
});
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success(`已延期到 ${targetDate.value}`);
|
||||
visible.value = false;
|
||||
emit("saved");
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "延期失败");
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.postpone-tip {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #606266;
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.postpone-overdue {
|
||||
color: #f56c6c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.postpone-quick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -39,6 +39,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 待办分类 tabs -->
|
||||
<el-tabs
|
||||
v-model="category"
|
||||
class="category-tabs"
|
||||
@tab-change="handleCategoryChange"
|
||||
>
|
||||
<el-tab-pane
|
||||
v-for="tab in categoryTabs"
|
||||
:key="tab.value"
|
||||
:name="tab.value"
|
||||
>
|
||||
<template #label>
|
||||
<span class="tab-label">
|
||||
{{ tab.label }}
|
||||
<span class="tab-count">{{ categoryCounts[tab.value] || 0 }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 日历视图 -->
|
||||
<div v-if="viewMode === 'calendar'" class="calendar-layout">
|
||||
<div class="calendar-card">
|
||||
@@ -51,15 +71,6 @@
|
||||
<el-button :icon="ArrowRight" circle @click="changeMonth(1)" />
|
||||
<el-button @click="goToday">今天</el-button>
|
||||
</div>
|
||||
<el-radio-group
|
||||
v-model="calendarStatus"
|
||||
size="small"
|
||||
@change="loadCalendarData"
|
||||
>
|
||||
<el-radio-button value="">全部</el-radio-button>
|
||||
<el-radio-button value="0">待办</el-radio-button>
|
||||
<el-radio-button value="1">已完成</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="calendar-week-header">
|
||||
<div v-for="w in weekNames" :key="w" class="week-name">{{ w }}</div>
|
||||
@@ -118,15 +129,28 @@
|
||||
<div class="day-panel-header">
|
||||
<div>
|
||||
<div class="day-title">{{ selectedDateLabel }}</div>
|
||||
<div class="day-sub">{{ selectedItems.length }} 项日程</div>
|
||||
<div class="day-sub">
|
||||
{{ selectedItems.length }} 项日程<template v-if="pendingCount > 0"
|
||||
>,{{ pendingCount }} 项未完成</template
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<el-button
|
||||
v-if="pendingCount > 0"
|
||||
size="small"
|
||||
:icon="Promotion"
|
||||
@click="handleCarryPending"
|
||||
>延期未完成</el-button
|
||||
>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:icon="Plus"
|
||||
@click="openCreate(selectedDate)"
|
||||
>新增</el-button
|
||||
>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:icon="Plus"
|
||||
@click="openCreate(selectedDate)"
|
||||
>新增</el-button
|
||||
>
|
||||
</div>
|
||||
<div class="day-list">
|
||||
<div
|
||||
@@ -150,6 +174,22 @@
|
||||
>
|
||||
{{ priorityLabel(item.priority) }}
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-if="item.carry_over === 1"
|
||||
size="small"
|
||||
effect="light"
|
||||
type="info"
|
||||
>
|
||||
已延期至 {{ shortDate(item.carry_to_date) }}
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="item.source_id > 0"
|
||||
size="small"
|
||||
effect="light"
|
||||
type="warning"
|
||||
>
|
||||
延期第 {{ item.carry_count + 1 }} 天
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="day-item-time">{{ timeText(item) }}</div>
|
||||
</div>
|
||||
@@ -165,6 +205,11 @@
|
||||
/>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
command="postpone"
|
||||
:disabled="item.status === 1"
|
||||
>延期到下一天</el-dropdown-item
|
||||
>
|
||||
<el-dropdown-item command="edit">编辑</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" divided
|
||||
>删除</el-dropdown-item
|
||||
@@ -192,15 +237,6 @@
|
||||
style="width: 200px"
|
||||
@keyup.enter="loadListData"
|
||||
/>
|
||||
<el-select
|
||||
v-model="listFilters.status"
|
||||
clearable
|
||||
placeholder="全部状态"
|
||||
style="width: 120px"
|
||||
>
|
||||
<el-option label="待办" value="0" />
|
||||
<el-option label="已完成" value="1" />
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="listFilters.range"
|
||||
type="daterange"
|
||||
@@ -254,11 +290,18 @@
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<el-table-column label="操作" width="260" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="toggleFinish(row)">{{
|
||||
row.status === 1 ? "取消完成" : "完成"
|
||||
}}</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:disabled="row.status === 1"
|
||||
@click="openPostpone(row)"
|
||||
>延期</el-button
|
||||
>
|
||||
<el-button link type="primary" @click="openEdit(row)"
|
||||
>编辑</el-button
|
||||
>
|
||||
@@ -296,7 +339,16 @@
|
||||
v-model="detailVisible"
|
||||
:schedule="detailItem"
|
||||
@edit="handleDetailEdit"
|
||||
@toggle="toggleFromDetail"
|
||||
@finish="handleDetailFinish"
|
||||
@revert="handleDetailRevert"
|
||||
@postpone="handleDetailPostpone"
|
||||
/>
|
||||
|
||||
<!-- 延期对话框 -->
|
||||
<SchedulePostpone
|
||||
v-model="postponeVisible"
|
||||
:schedule="postponeItem"
|
||||
@saved="reloadAll"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -309,12 +361,15 @@ import {
|
||||
ArrowRight,
|
||||
MoreFilled,
|
||||
Plus,
|
||||
Promotion,
|
||||
Refresh,
|
||||
Search
|
||||
} from "@element-plus/icons-vue";
|
||||
import ScheduleEdit from "./components/edit.vue";
|
||||
import ScheduleDetail from "./components/detail.vue";
|
||||
import SchedulePostpone from "./components/postponeDialog.vue";
|
||||
import {
|
||||
carryPendingSchedules,
|
||||
deleteSchedule,
|
||||
finishSchedule,
|
||||
getScheduleList,
|
||||
@@ -324,6 +379,19 @@ import {
|
||||
const viewMode = ref("calendar");
|
||||
const stats = ref({});
|
||||
|
||||
// ---------- 待办分类 tabs ----------
|
||||
// all-全部 today-今日 pending-未完成 done-已完成
|
||||
// 注:后端仍支持 ongoing(进行中 = 跨天延期中的任务),当前不启用;
|
||||
// 以后要恢复,在下面数组里加一行 { value: "ongoing", label: "进行中" } 即可。
|
||||
const categoryTabs = [
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "today", label: "今日" },
|
||||
{ value: "pending", label: "未完成" },
|
||||
{ value: "done", label: "已完成" }
|
||||
];
|
||||
const category = ref("all");
|
||||
const categoryCounts = ref({});
|
||||
|
||||
// ---------- 日期工具(避免额外依赖) ----------
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, "0");
|
||||
@@ -346,12 +414,23 @@ function normDate(value) {
|
||||
: value || "";
|
||||
}
|
||||
|
||||
// 在 YYYY-MM-DD 上加减天数,用于计算"顺延到下一天"的目标日期
|
||||
function addDays(dateStr, days) {
|
||||
const parts = String(dateStr || "")
|
||||
.slice(0, 10)
|
||||
.split("-")
|
||||
.map(Number);
|
||||
if (parts.length !== 3 || parts.some(Number.isNaN)) {
|
||||
return "";
|
||||
}
|
||||
return fmtDate(new Date(parts[0], parts[1] - 1, parts[2] + days));
|
||||
}
|
||||
|
||||
// ---------- 日历视图 ----------
|
||||
const weekNames = ["一", "二", "三", "四", "五", "六", "日"];
|
||||
const now = new Date();
|
||||
const calendarYear = ref(now.getFullYear());
|
||||
const calendarMonth = ref(now.getMonth() + 1);
|
||||
const calendarStatus = ref("");
|
||||
const calendarItems = ref([]);
|
||||
const selectedDate = ref(todayStr());
|
||||
|
||||
@@ -403,6 +482,11 @@ const selectedItems = computed(
|
||||
() => calendarMap.value[selectedDate.value] || []
|
||||
);
|
||||
|
||||
// 当天仍未完成的日程数,用于"顺延未完成"批量操作
|
||||
const pendingCount = computed(
|
||||
() => selectedItems.value.filter(item => item.status !== 1).length
|
||||
);
|
||||
|
||||
const selectedDateLabel = computed(() => {
|
||||
const [y, m, d] = selectedDate.value.split("-").map(Number);
|
||||
const week = "日一二三四五六"[new Date(y, m - 1, d).getDay()];
|
||||
@@ -422,9 +506,12 @@ async function loadCalendarData() {
|
||||
end_date: fmtDate(end),
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
status: calendarStatus.value
|
||||
category: category.value
|
||||
});
|
||||
const data = res?.data || {};
|
||||
if (data.counts) {
|
||||
categoryCounts.value = data.counts;
|
||||
}
|
||||
calendarItems.value = (data.list || []).map(item => ({
|
||||
...item,
|
||||
schedule_date: normDate(item.schedule_date)
|
||||
@@ -455,7 +542,7 @@ async function loadStats() {
|
||||
}
|
||||
|
||||
// ---------- 列表视图 ----------
|
||||
const listFilters = reactive({ keyword: "", status: "", range: null });
|
||||
const listFilters = reactive({ keyword: "", range: null });
|
||||
const listData = ref([]);
|
||||
const listLoading = ref(false);
|
||||
const listPagination = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
@@ -467,7 +554,7 @@ async function loadListData() {
|
||||
page: listPagination.page,
|
||||
pageSize: listPagination.pageSize,
|
||||
keyword: listFilters.keyword,
|
||||
status: listFilters.status
|
||||
category: category.value
|
||||
};
|
||||
if (listFilters.range && listFilters.range.length === 2) {
|
||||
params.start_date = listFilters.range[0];
|
||||
@@ -475,6 +562,9 @@ async function loadListData() {
|
||||
}
|
||||
const res = await getScheduleList(params);
|
||||
const data = res?.data || {};
|
||||
if (data.counts) {
|
||||
categoryCounts.value = data.counts;
|
||||
}
|
||||
listData.value = (data.list || []).map(item => ({
|
||||
...item,
|
||||
schedule_date: normDate(item.schedule_date)
|
||||
@@ -487,12 +577,21 @@ async function loadListData() {
|
||||
|
||||
function resetListFilters() {
|
||||
listFilters.keyword = "";
|
||||
listFilters.status = "";
|
||||
listFilters.range = null;
|
||||
listPagination.page = 1;
|
||||
loadListData();
|
||||
}
|
||||
|
||||
// 切换分类:切到"今日"时日历自动回到今天所在月份,避免当月看不到数据
|
||||
function handleCategoryChange(value) {
|
||||
listPagination.page = 1;
|
||||
if (value === "today" && viewMode.value === "calendar") {
|
||||
goToday();
|
||||
return;
|
||||
}
|
||||
reloadAll();
|
||||
}
|
||||
|
||||
// 切到列表视图时按需加载
|
||||
watch(viewMode, mode => {
|
||||
if (mode === "list" && listData.value.length === 0) {
|
||||
@@ -530,23 +629,79 @@ function handleDetailEdit(item) {
|
||||
openEdit(item);
|
||||
}
|
||||
|
||||
// 详情内完成/取消完成后从最新数据重新定位,保持详情内容同步
|
||||
async function toggleFromDetail(item) {
|
||||
// 详情里点"已完成"
|
||||
async function handleDetailFinish(item) {
|
||||
detailVisible.value = false;
|
||||
await toggleFinish(item);
|
||||
detailItem.value = calendarItems.value.find(i => i.id === item.id) || null;
|
||||
if (!detailItem.value) {
|
||||
detailVisible.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 详情里点"反审核":把已完成的日程恢复为待办
|
||||
async function handleDetailRevert(item) {
|
||||
detailVisible.value = false;
|
||||
await toggleFinish(item);
|
||||
}
|
||||
|
||||
// 详情里点"延期":关闭详情后打开延期对话框
|
||||
function handleDetailPostpone(item) {
|
||||
detailVisible.value = false;
|
||||
openPostpone(item);
|
||||
}
|
||||
|
||||
function handleItemCommand(cmd, item) {
|
||||
if (cmd === "edit") {
|
||||
if (cmd === "postpone") {
|
||||
openPostpone(item);
|
||||
} else if (cmd === "edit") {
|
||||
openEdit(item);
|
||||
} else if (cmd === "delete") {
|
||||
handleDelete(item);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 延期到下一天 ----------
|
||||
const postponeVisible = ref(false);
|
||||
const postponeItem = ref(null);
|
||||
|
||||
function openPostpone(item) {
|
||||
if (!item || item.status === 1) {
|
||||
ElMessage.warning("该日程已完成,无需延期");
|
||||
return;
|
||||
}
|
||||
postponeItem.value = item;
|
||||
postponeVisible.value = true;
|
||||
}
|
||||
|
||||
// 批量延期:把选中日期的全部未完成日程一次性延期到下一天
|
||||
async function handleCarryPending() {
|
||||
if (pendingCount.value === 0) {
|
||||
return;
|
||||
}
|
||||
// 目标日期取"选定日期+1天",但不早于今天,避免把过去的任务再延到过去
|
||||
const nextDay = addDays(selectedDate.value, 1);
|
||||
const target = nextDay < todayStr() ? todayStr() : nextDay;
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将 ${selectedDate.value} 的 ${pendingCount.value} 条未完成日程延期到 ${target}?当天这些日程会标记为已完成。`,
|
||||
"批量延期",
|
||||
{ type: "warning", confirmButtonText: "延期", cancelButtonText: "取消" }
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const res = await carryPendingSchedules({
|
||||
date: selectedDate.value,
|
||||
target_date: target
|
||||
});
|
||||
if (res?.code === 200) {
|
||||
const d = res.data || {};
|
||||
ElMessage.success(
|
||||
`已延期 ${d.success || 0} 条${d.skipped ? `,跳过 ${d.skipped} 条` : ""}`
|
||||
);
|
||||
await reloadAll();
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "延期失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFinish(item) {
|
||||
const res = await finishSchedule(item.id);
|
||||
if (res?.code === 200) {
|
||||
@@ -585,6 +740,18 @@ function reloadAll() {
|
||||
}
|
||||
|
||||
// ---------- 展示辅助 ----------
|
||||
// YYYY-MM-DD -> M月D日,用于"已顺延至"标签
|
||||
function shortDate(value) {
|
||||
const parts = String(value || "")
|
||||
.slice(0, 10)
|
||||
.split("-")
|
||||
.map(Number);
|
||||
if (parts.length !== 3 || parts.some(Number.isNaN)) {
|
||||
return value || "";
|
||||
}
|
||||
return `${parts[1]}月${parts[2]}日`;
|
||||
}
|
||||
|
||||
function timeText(item) {
|
||||
if (item.all_day === 1) {
|
||||
return "全天";
|
||||
@@ -672,6 +839,42 @@ onMounted(() => {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
/* 待办分类 tabs */
|
||||
.category-tabs {
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
padding: 0 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.category-tabs :deep(.el-tabs__header) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 分类内容由下方日历/列表承载,tabs 只作切换栏,隐藏空的面板容器 */
|
||||
.category-tabs :deep(.el-tabs__content) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
min-width: 20px;
|
||||
padding: 0 6px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
border-radius: 9px;
|
||||
background: #f0f2f5;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 日历视图布局 */
|
||||
.calendar-layout {
|
||||
display: flex;
|
||||
@@ -864,6 +1067,13 @@ onMounted(() => {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.day-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -46,66 +46,6 @@
|
||||
<el-date-picker v-model="form.birthday" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider />
|
||||
<div class="form-title">组织信息</div>
|
||||
|
||||
<el-form-item label="隶属单位" prop="affiliate_unit">
|
||||
<el-tree-select
|
||||
v-model="form.affiliate_unit"
|
||||
:data="organizationTree"
|
||||
:props="treeSelectProps"
|
||||
check-strictly
|
||||
clearable
|
||||
node-key="idStr"
|
||||
placeholder="请选择隶属单位"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="部门" prop="department">
|
||||
<el-tree-select
|
||||
v-model="form.department"
|
||||
:data="departmentTree"
|
||||
:props="treeSelectProps"
|
||||
check-strictly
|
||||
clearable
|
||||
placeholder="请选择部门"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="职位" prop="position">
|
||||
<el-select
|
||||
v-model="form.position"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
placeholder="选择或输入职位"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in positionOptions"
|
||||
:key="item.id"
|
||||
:label="item.position_name"
|
||||
:value="item.position_name"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider />
|
||||
<div class="form-title">联系与其它</div>
|
||||
|
||||
<el-form-item label="学历" prop="education">
|
||||
<el-select v-model="form.education" clearable placeholder="请选择学历" style="width: 100%">
|
||||
<el-option v-for="item in educationOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="民族" prop="nation">
|
||||
<el-input v-model="form.nation" maxlength="20" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="手机" prop="phone">
|
||||
<el-input v-model="form.phone" maxlength="20" />
|
||||
</el-form-item>
|
||||
@@ -129,6 +69,56 @@
|
||||
<el-option v-if="isEdit" label="离职" :value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider />
|
||||
<div class="form-title">组织信息</div>
|
||||
|
||||
<el-form-item label="隶属单位" prop="affiliate_unit">
|
||||
<el-tree-select
|
||||
v-model="form.affiliate_unit"
|
||||
:data="companyTree"
|
||||
:props="treeSelectProps"
|
||||
check-strictly
|
||||
clearable
|
||||
node-key="idStr"
|
||||
placeholder="请选择隶属单位"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="部门" prop="department">
|
||||
<el-tree-select
|
||||
v-model="form.department"
|
||||
:data="departmentTree"
|
||||
:props="treeSelectProps"
|
||||
check-strictly
|
||||
clearable
|
||||
:disabled="!form.affiliate_unit"
|
||||
node-key="idStr"
|
||||
placeholder="请选择部门"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="职位" prop="position">
|
||||
<el-select
|
||||
v-model="form.position"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
:disabled="!form.department"
|
||||
placeholder="选择或输入职位"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in filteredPositions"
|
||||
:key="item.id"
|
||||
:label="item.position_name"
|
||||
:value="item.position_name"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
@@ -139,7 +129,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { buildOrgTree, moduleLabels, unwrap, useOrganizationApi } from '../composables';
|
||||
|
||||
@@ -158,13 +148,13 @@ const loading = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const editingId = ref(0);
|
||||
const formRef = ref();
|
||||
/** 编辑回填期间置 true,级联 watcher 跳过清空,避免回填值被覆盖 */
|
||||
const backfilling = ref(false);
|
||||
|
||||
const organizationTree = ref([]);
|
||||
const departmentTree = ref([]);
|
||||
const positionOptions = ref([]);
|
||||
|
||||
const treeSelectProps = { value: 'idStr', label: 'org_name', children: 'children' };
|
||||
const educationOptions = ['博士', '硕士', '本科', '专科', '高中', '其他'];
|
||||
|
||||
const emptyForm = () => ({
|
||||
account: '',
|
||||
@@ -176,8 +166,6 @@ const emptyForm = () => ({
|
||||
affiliate_unit: '',
|
||||
department: '',
|
||||
position: '',
|
||||
education: '',
|
||||
nation: '',
|
||||
phone: '',
|
||||
wechat: '',
|
||||
email: '',
|
||||
@@ -231,15 +219,74 @@ const withIdStr = (nodes) =>
|
||||
children: withIdStr(node.children || []),
|
||||
}));
|
||||
|
||||
/** 在树中按 id 查找节点 */
|
||||
const findNode = (nodes, id) => {
|
||||
for (const n of nodes || []) {
|
||||
if (String(n.id) === String(id)) return n;
|
||||
const found = findNode(n.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** 只保留单位节点(is_company=1),保持单位之间的层级 */
|
||||
const filterCompanyTree = (nodes) =>
|
||||
(nodes || [])
|
||||
.filter((n) => Number(n.is_company) === 1)
|
||||
.map((n) => ({ ...n, children: filterCompanyTree(n.children || []) }));
|
||||
|
||||
/** 从指定单位节点下提取部门子树(is_company=0) */
|
||||
const buildDepartmentSubtree = (companyNode) => {
|
||||
if (!companyNode) return [];
|
||||
const filterDepts = (nodes) =>
|
||||
(nodes || [])
|
||||
.filter((n) => Number(n.is_company) === 0)
|
||||
.map((n) => ({ ...n, children: filterDepts(n.children || []) }));
|
||||
return filterDepts(companyNode.children || []);
|
||||
};
|
||||
|
||||
/** 隶属单位下拉:只显示单位 */
|
||||
const companyTree = computed(() => withIdStr(filterCompanyTree(organizationTree.value)));
|
||||
|
||||
/** 部门下拉:只显示所选隶属单位下的部门 */
|
||||
const departmentTree = computed(() => {
|
||||
if (!form.affiliate_unit) return [];
|
||||
const companyNode = findNode(organizationTree.value, form.affiliate_unit);
|
||||
return withIdStr(buildDepartmentSubtree(companyNode));
|
||||
});
|
||||
|
||||
/** 职位下拉:只显示所选部门下的职位 */
|
||||
const filteredPositions = computed(() => {
|
||||
if (!form.department) return [];
|
||||
return (positionOptions.value || []).filter(
|
||||
(p) => Number(p.department_id) === Number(form.department)
|
||||
);
|
||||
});
|
||||
|
||||
// 级联:切换隶属单位时清空部门和职位;切换部门时清空职位(回填期间跳过)
|
||||
watch(
|
||||
() => form.affiliate_unit,
|
||||
() => {
|
||||
if (backfilling.value) return;
|
||||
form.department = '';
|
||||
form.position = '';
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => form.department,
|
||||
() => {
|
||||
if (backfilling.value) return;
|
||||
form.position = '';
|
||||
}
|
||||
);
|
||||
|
||||
const loadOptions = async () => {
|
||||
try {
|
||||
const [orgList, positions] = await Promise.all([
|
||||
api.value.getOrganizationList().then(unwrap),
|
||||
api.value.getPositionList().then(unwrap),
|
||||
]);
|
||||
const tree = withIdStr(buildOrgTree(orgList));
|
||||
organizationTree.value = tree;
|
||||
departmentTree.value = tree;
|
||||
organizationTree.value = withIdStr(buildOrgTree(orgList));
|
||||
positionOptions.value = positions || [];
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '加载选项失败');
|
||||
@@ -258,6 +305,7 @@ const open = async (row, defaults = {}) => {
|
||||
if (isEdit.value) {
|
||||
try {
|
||||
const detail = unwrap(await api.value.getEmployeeDetail(row.id)) || {};
|
||||
backfilling.value = true;
|
||||
Object.assign(form, {
|
||||
account: detail.account || '',
|
||||
name: detail.name || '',
|
||||
@@ -266,16 +314,19 @@ const open = async (row, defaults = {}) => {
|
||||
affiliate_unit: detail.affiliate_unit ? String(detail.affiliate_unit) : '',
|
||||
department: detail.department ? String(detail.department) : '',
|
||||
position: detail.position || '',
|
||||
education: detail.education || '',
|
||||
nation: detail.nation || '',
|
||||
phone: detail.phone || '',
|
||||
wechat: detail.wechat || '',
|
||||
email: detail.email || '',
|
||||
home_address: detail.home_address || '',
|
||||
account_status: Number(detail.account_status ?? detail.status ?? 1),
|
||||
});
|
||||
// watcher 为 pre-flush 异步执行,需等其执行完毕再复位标志,
|
||||
// 否则复位后 watcher 才触发,仍会清空 department / position。
|
||||
await nextTick();
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '加载详情失败');
|
||||
} finally {
|
||||
backfilling.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -304,8 +355,6 @@ const handleSubmit = async () => {
|
||||
affiliate_unit: form.affiliate_unit || '',
|
||||
department: form.department || '',
|
||||
position: form.position || '',
|
||||
education: form.education || '',
|
||||
nation: form.nation || '',
|
||||
phone: form.phone || '',
|
||||
wechat: form.wechat || '',
|
||||
email: form.email || '',
|
||||
|
||||
@@ -60,8 +60,8 @@
|
||||
stripe
|
||||
@selection-change="(rows) => (selectedRows = rows)"
|
||||
>
|
||||
<el-table-column type="selection" width="45" />
|
||||
<el-table-column prop="id" label="ID" width="70" align="center" />
|
||||
<!--<el-table-column type="selection" width="45" />-->
|
||||
<!--<el-table-column prop="id" label="ID" width="70" align="center" />-->
|
||||
<el-table-column prop="account" label="账号" width="130" />
|
||||
<el-table-column prop="name" label="姓名" width="110">
|
||||
<template #default="{ row }">
|
||||
@@ -85,11 +85,12 @@
|
||||
<el-tag :type="employeeStatusTagType(row)" size="small">{{ employeeStatusText(row) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right" align="center">
|
||||
<el-table-column label="操作" width="280" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="viewDialogRef?.open(row)">查看</el-button>
|
||||
<el-button link type="primary" size="small" @click="editDialogRef?.open(row)">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click="moveDialogRef?.open([row])">调岗</el-button>
|
||||
<el-button link type="warning" size="small" @click="handleResetPassword(row)">重置密码</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -112,6 +113,28 @@
|
||||
<EmployeeEditDialog ref="editDialogRef" :module="module" @success="loadData" />
|
||||
<EmployeeViewDialog ref="viewDialogRef" :module="module" />
|
||||
<MoveEmployeeDialog ref="moveDialogRef" :module="module" @success="handleMoved" />
|
||||
|
||||
<!-- 重置密码结果弹窗 -->
|
||||
<el-dialog
|
||||
v-model="resetDialogVisible"
|
||||
title="重置密码成功"
|
||||
width="420px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="reset-password-box">
|
||||
<p class="reset-tip">
|
||||
员工「<strong>{{ resetTargetName }}</strong>」的密码已重置为:
|
||||
</p>
|
||||
<div class="reset-password-display">
|
||||
<span class="reset-password-text">{{ resetNewPassword }}</span>
|
||||
</div>
|
||||
<p class="reset-warn">请及时将新密码告知员工,并提醒其登录后修改。</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="copyPassword">复制</el-button>
|
||||
<el-button @click="resetDialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -213,6 +236,58 @@ const handleDelete = async (row) => {
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------- 重置密码 ------------------------------- */
|
||||
|
||||
const resetDialogVisible = ref(false);
|
||||
const resetNewPassword = ref('');
|
||||
const resetTargetName = ref('');
|
||||
|
||||
/** 生成 10 位随机密码(排除易混淆字符) */
|
||||
const generateRandomPassword = () => {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleResetPassword = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定将「${row.name}」的密码重置为随机密码吗?`,
|
||||
'重置密码确认',
|
||||
{ type: 'warning', confirmButtonText: '重置', cancelButtonText: '取消' }
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const newPwd = generateRandomPassword();
|
||||
try {
|
||||
unwrap(await api.value.updateEmployee(row.id, { password: newPwd }));
|
||||
resetTargetName.value = row.name;
|
||||
resetNewPassword.value = newPwd;
|
||||
resetDialogVisible.value = true;
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '重置密码失败');
|
||||
}
|
||||
};
|
||||
|
||||
const copyPassword = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(resetNewPassword.value);
|
||||
ElMessage.success('已复制到剪贴板');
|
||||
} catch {
|
||||
const input = document.createElement('input');
|
||||
input.value = resetNewPassword.value;
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(input);
|
||||
ElMessage.success('已复制到剪贴板');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadDepartments();
|
||||
loadData();
|
||||
@@ -281,4 +356,34 @@ onMounted(() => {
|
||||
justify-content: flex-end;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.reset-password-box {
|
||||
.reset-tip {
|
||||
margin: 0 0 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.reset-password-display {
|
||||
background: #f5f7fa;
|
||||
border: 1px dashed #dcdfe6;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.reset-password-text {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 3px;
|
||||
color: #303133;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.reset-warn {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #e6a23c;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+4
-4
@@ -16,10 +16,10 @@ maxmemory = 10485760
|
||||
|
||||
# 数据库配置
|
||||
# MySQL - 远程连接配置
|
||||
mysqluser = go-platform
|
||||
mysqlpass = FSmJCSJ5wk8pjjDC
|
||||
# mysqlurls = 10.31.100.3:3306
|
||||
mysqlurls = 212.64.112.158:3388
|
||||
mysqluser = root
|
||||
mysqlpass = Lzq920103
|
||||
# mysqlurls = 10.31.100.4:3306
|
||||
mysqlurls = 212.64.112.158:5506
|
||||
mysqldb = go-platform
|
||||
|
||||
# ORM配置
|
||||
|
||||
@@ -211,8 +211,6 @@ type employeeFileDTO struct {
|
||||
Position string `json:"position"`
|
||||
Phone string `json:"phone"`
|
||||
Email string `json:"email"`
|
||||
Education string `json:"education"`
|
||||
Nation string `json:"nation"`
|
||||
HomeAddress string `json:"home_address"`
|
||||
AccountStatus int8 `json:"account_status"`
|
||||
HireDate string `json:"hire_date"`
|
||||
@@ -226,6 +224,8 @@ type employeeFileDTO struct {
|
||||
func efFileCompleteness(file models.BackendEmployeeFile) int {
|
||||
items := []bool{
|
||||
strings.TrimSpace(file.IDCard) != "",
|
||||
strings.TrimSpace(file.Education) != "",
|
||||
strings.TrimSpace(file.Nation) != "",
|
||||
strings.TrimSpace(file.PoliticalStatus) != "",
|
||||
file.MaritalStatus > 0,
|
||||
strings.TrimSpace(file.NativePlace) != "",
|
||||
@@ -305,8 +305,6 @@ func (c *BackendEmployeeFileController) efAssembleDTOList(tid int, files []model
|
||||
dto.Position = derefString(emp.Position)
|
||||
dto.Phone = derefString(emp.Phone)
|
||||
dto.Email = derefString(emp.Email)
|
||||
dto.Education = derefString(emp.Education)
|
||||
dto.Nation = derefString(emp.Nation)
|
||||
dto.HomeAddress = derefString(emp.HomeAddress)
|
||||
dto.AccountStatus = emp.AccountStatus
|
||||
}
|
||||
@@ -452,6 +450,8 @@ type employeeFilePayload struct {
|
||||
IDCardFront string `json:"id_card_front"`
|
||||
IDCardBack string `json:"id_card_back"`
|
||||
EducationPhoto string `json:"education_photo"`
|
||||
Education string `json:"education"`
|
||||
Nation string `json:"nation"`
|
||||
PoliticalStatus string `json:"political_status"`
|
||||
MaritalStatus int8 `json:"marital_status"`
|
||||
NativePlace string `json:"native_place"`
|
||||
@@ -479,6 +479,8 @@ func (c *BackendEmployeeFileController) efParsePayload() (employeeFilePayload, b
|
||||
|
||||
payload.FileNo = strings.TrimSpace(payload.FileNo)
|
||||
payload.IDCard = strings.TrimSpace(payload.IDCard)
|
||||
payload.Education = strings.TrimSpace(payload.Education)
|
||||
payload.Nation = strings.TrimSpace(payload.Nation)
|
||||
payload.WorkEmail = strings.TrimSpace(payload.WorkEmail)
|
||||
if payload.IDCard != "" && len(payload.IDCard) > 30 {
|
||||
c.efErr(400, 400, "身份证号过长")
|
||||
@@ -570,6 +572,8 @@ func (c *BackendEmployeeFileController) Create() {
|
||||
IDCardFront: payload.IDCardFront,
|
||||
IDCardBack: payload.IDCardBack,
|
||||
EducationPhoto: payload.EducationPhoto,
|
||||
Education: payload.Education,
|
||||
Nation: payload.Nation,
|
||||
PoliticalStatus: payload.PoliticalStatus,
|
||||
MaritalStatus: payload.MaritalStatus,
|
||||
NativePlace: payload.NativePlace,
|
||||
@@ -629,6 +633,8 @@ func (c *BackendEmployeeFileController) Update() {
|
||||
item.IDCard = payload.IDCard
|
||||
// 证照字段由 UpdateCertificates 独立维护,基础资料保存不得覆盖已有附件。
|
||||
item.PoliticalStatus = payload.PoliticalStatus
|
||||
item.Education = payload.Education
|
||||
item.Nation = payload.Nation
|
||||
item.MaritalStatus = payload.MaritalStatus
|
||||
item.NativePlace = payload.NativePlace
|
||||
item.HouseholdAddress = payload.HouseholdAddress
|
||||
@@ -645,7 +651,7 @@ func (c *BackendEmployeeFileController) Update() {
|
||||
item.UpdateTime = &now
|
||||
|
||||
if _, err := models.Orm.Update(&item,
|
||||
"FileNo", "IDCard", "PoliticalStatus", "MaritalStatus", "NativePlace",
|
||||
"FileNo", "IDCard", "PoliticalStatus", "Education", "Nation", "MaritalStatus", "NativePlace",
|
||||
"HouseholdAddress", "CurrentAddress", "EmergencyContact", "EmergencyPhone",
|
||||
"EmergencyRelation", "WorkEmail", "HireDate", "RegularDate", "LeaveDate",
|
||||
"EmploymentStatus", "Remark", "UpdateTime"); err != nil {
|
||||
|
||||
@@ -52,6 +52,11 @@ func (c *BackendOaScheduleController) oasOk(data interface{}) {
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// Prepare 所有日程接口执行前补齐顺延所需字段,避免历史库缺列导致查询报错。
|
||||
func (c *BackendOaScheduleController) Prepare() {
|
||||
_ = models.EnsureOaScheduleCarryColumns()
|
||||
}
|
||||
|
||||
var (
|
||||
oasDatePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||||
oasTimePattern = regexp.MustCompile(`^\d{2}:\d{2}$`)
|
||||
@@ -86,8 +91,61 @@ func normalizeOaDate(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// oaScheduleFilter 日程列表筛选条件,用于构造 ORM 查询条件。
|
||||
// 指针式的 Has*/On* 标记用于区分"未传参"与"传了默认值 0/空串"。
|
||||
type oaScheduleFilter struct {
|
||||
Keyword string
|
||||
Priority int8
|
||||
HasPriority bool
|
||||
StartDate string
|
||||
EndDate string
|
||||
OnDate string
|
||||
Status int8
|
||||
HasStatus bool
|
||||
Ongoing bool
|
||||
}
|
||||
|
||||
// oaScheduleCond 构造日程查询条件,始终按租户 + 用户双重隔离。
|
||||
// Ongoing 表示"进行中":跨天顺延中的任务,即被顺延出去的源日程(carry_over=1)
|
||||
// 或由前序日程顺延而来的续做日程(source_id>0)。
|
||||
func oaScheduleCond(tid int, userID int, f oaScheduleFilter) *orm.Condition {
|
||||
cond := orm.NewCondition()
|
||||
cond = cond.And("is_deleted", 0).And("tid", tid).And("user_id", userID)
|
||||
if f.Keyword != "" {
|
||||
cond = cond.And("title__icontains", f.Keyword)
|
||||
}
|
||||
if f.HasPriority {
|
||||
cond = cond.And("priority", f.Priority)
|
||||
}
|
||||
if f.StartDate != "" {
|
||||
cond = cond.And("schedule_date__gte", f.StartDate)
|
||||
}
|
||||
if f.EndDate != "" {
|
||||
cond = cond.And("schedule_date__lte", f.EndDate)
|
||||
}
|
||||
if f.OnDate != "" {
|
||||
cond = cond.And("schedule_date", f.OnDate)
|
||||
}
|
||||
if f.HasStatus {
|
||||
cond = cond.And("status", f.Status)
|
||||
}
|
||||
if f.Ongoing {
|
||||
sub := orm.NewCondition()
|
||||
sub = sub.Or("source_id__gt", 0).Or("carry_over", 1)
|
||||
cond = cond.AndCond(sub)
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
func oaScheduleQS(tid int, userID int, f oaScheduleFilter) orm.QuerySeter {
|
||||
return models.Orm.QueryTable(new(models.OaSchedule)).
|
||||
SetCond(oaScheduleCond(tid, userID, f))
|
||||
}
|
||||
|
||||
// List GET /backend/oa/schedule/list
|
||||
// 分页查询当前用户的日程,支持 keyword/status/priority/start_date/end_date 筛选。
|
||||
// 分页查询当前用户的日程,支持 keyword/status/priority/start_date/end_date 筛选,
|
||||
// 以及 category 待办分类:all-全部 today-今日 pending-未完成 ongoing-进行中 done-已完成。
|
||||
// 返回值额外带 counts(各分类数量),供页面 tabs 展示角标。
|
||||
// 日历视图传 start_date/end_date 且 pageSize 足够大时一次拉取区间全量数据。
|
||||
func (c *BackendOaScheduleController) List() {
|
||||
claims, err := c.oaScheduleClaims()
|
||||
@@ -103,6 +161,7 @@ func (c *BackendOaScheduleController) List() {
|
||||
priority := strings.TrimSpace(c.GetString("priority"))
|
||||
startDate := strings.TrimSpace(c.GetString("start_date"))
|
||||
endDate := strings.TrimSpace(c.GetString("end_date"))
|
||||
category := strings.TrimSpace(c.GetString("category"))
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
@@ -111,29 +170,54 @@ func (c *BackendOaScheduleController) List() {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.OaSchedule)).
|
||||
Filter("is_deleted", 0).
|
||||
Filter("tid", claims.TenantId).
|
||||
Filter("user_id", claims.UserID)
|
||||
|
||||
if keyword != "" {
|
||||
qs = qs.Filter("title__icontains", keyword)
|
||||
}
|
||||
base := oaScheduleFilter{Keyword: keyword}
|
||||
if status == "0" || status == "1" {
|
||||
statusVal, _ := strconv.Atoi(status)
|
||||
qs = qs.Filter("status", int8(statusVal))
|
||||
base.HasStatus = true
|
||||
base.Status = int8(statusVal)
|
||||
}
|
||||
if priority == "0" || priority == "1" || priority == "2" {
|
||||
priorityVal, _ := strconv.Atoi(priority)
|
||||
qs = qs.Filter("priority", int8(priorityVal))
|
||||
}
|
||||
if isValidOaDate(startDate) {
|
||||
qs = qs.Filter("schedule_date__gte", startDate)
|
||||
}
|
||||
if isValidOaDate(endDate) {
|
||||
qs = qs.Filter("schedule_date__lte", endDate)
|
||||
base.HasPriority = true
|
||||
base.Priority = int8(priorityVal)
|
||||
}
|
||||
|
||||
// 分类角标按全量统计(只受关键词/优先级影响),不随日历翻月或区间筛选跳动
|
||||
countOf := func(f oaScheduleFilter) int64 {
|
||||
n, _ := oaScheduleQS(claims.TenantId, claims.UserID, f).Count()
|
||||
return n
|
||||
}
|
||||
today := time.Now().Format("2006-01-02")
|
||||
counts := map[string]interface{}{
|
||||
"all": countOf(base),
|
||||
"today": countOf(oaScheduleFilter{Keyword: base.Keyword, Priority: base.Priority, HasPriority: base.HasPriority, OnDate: today}),
|
||||
"pending": countOf(oaScheduleFilter{Keyword: base.Keyword, Priority: base.Priority, HasPriority: base.HasPriority, HasStatus: true, Status: 0}),
|
||||
"ongoing": countOf(oaScheduleFilter{Keyword: base.Keyword, Priority: base.Priority, HasPriority: base.HasPriority, Ongoing: true}),
|
||||
"done": countOf(oaScheduleFilter{Keyword: base.Keyword, Priority: base.Priority, HasPriority: base.HasPriority, HasStatus: true, Status: 1}),
|
||||
}
|
||||
|
||||
// 列表数据 = 基础筛选 + 日期区间 + 当前分类
|
||||
listFilter := base
|
||||
if isValidOaDate(startDate) {
|
||||
listFilter.StartDate = startDate
|
||||
}
|
||||
if isValidOaDate(endDate) {
|
||||
listFilter.EndDate = endDate
|
||||
}
|
||||
switch category {
|
||||
case "today":
|
||||
listFilter.OnDate = today
|
||||
case "pending":
|
||||
listFilter.HasStatus = true
|
||||
listFilter.Status = 0
|
||||
case "ongoing":
|
||||
listFilter.Ongoing = true
|
||||
case "done":
|
||||
listFilter.HasStatus = true
|
||||
listFilter.Status = 1
|
||||
}
|
||||
|
||||
qs := oaScheduleQS(claims.TenantId, claims.UserID, listFilter)
|
||||
total, _ := qs.Count()
|
||||
|
||||
var list []models.OaSchedule
|
||||
@@ -153,7 +237,11 @@ func (c *BackendOaScheduleController) List() {
|
||||
list[i].ScheduleDate = normalizeOaDate(list[i].ScheduleDate)
|
||||
}
|
||||
|
||||
c.oasOk(map[string]interface{}{"list": list, "total": total})
|
||||
c.oasOk(map[string]interface{}{
|
||||
"list": list,
|
||||
"total": total,
|
||||
"counts": counts,
|
||||
})
|
||||
}
|
||||
|
||||
// Stats GET /backend/oa/schedule/stats
|
||||
@@ -436,3 +524,206 @@ func (c *BackendOaScheduleController) FinishSchedule() {
|
||||
|
||||
c.oasOk(map[string]interface{}{"status": newStatus})
|
||||
}
|
||||
|
||||
// nextOaDate 返回 dateStr 之后的第 days 天,格式仍为 "YYYY-MM-DD"。
|
||||
func nextOaDate(dateStr string, days int) string {
|
||||
t, err := time.ParseInLocation("2006-01-02", dateStr, time.Local)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return t.AddDate(0, 0, days).Format("2006-01-02")
|
||||
}
|
||||
|
||||
// oaScheduleRootID 取顺延链的源头日程ID:本身就是顺延出来的则沿用其来源,否则为自身。
|
||||
func oaScheduleRootID(item *models.OaSchedule) uint64 {
|
||||
if item.SourceID > 0 {
|
||||
return item.SourceID
|
||||
}
|
||||
return item.ID
|
||||
}
|
||||
|
||||
// carryOaSchedule 把一条待办日程顺延到目标日期:
|
||||
// 1) 原日程标记完成(status=1, carry_over=1, carry_to_date=目标日期);
|
||||
// 2) 在目标日期生成一条同内容的待办,source_id 指向顺延链源头。
|
||||
// 返回新生成的日程与失败原因(空串表示成功)。
|
||||
func carryOaSchedule(item *models.OaSchedule, targetDate string, claims *jwtutil.Claims, now time.Time) (*models.OaSchedule, string) {
|
||||
if item.Status == 1 {
|
||||
return nil, "该日程已完成,无需顺延"
|
||||
}
|
||||
if !isValidOaDate(targetDate) {
|
||||
return nil, "顺延日期无效"
|
||||
}
|
||||
if targetDate <= normalizeOaDate(item.ScheduleDate) {
|
||||
return nil, "顺延日期需晚于当前日程日期"
|
||||
}
|
||||
|
||||
rootID := oaScheduleRootID(item)
|
||||
|
||||
// 同一条顺延链在目标日期只允许存在一条,避免重复顺延刷出多份待办。
|
||||
exist := 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", targetDate).
|
||||
Exist()
|
||||
if exist {
|
||||
return nil, "该日期已存在顺延的日程"
|
||||
}
|
||||
|
||||
next := &models.OaSchedule{
|
||||
Tid: item.Tid,
|
||||
UserID: item.UserID,
|
||||
UserName: item.UserName,
|
||||
Title: item.Title,
|
||||
Content: item.Content,
|
||||
ScheduleDate: targetDate,
|
||||
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 _, err := models.Orm.Insert(next); err != nil {
|
||||
return nil, "生成顺延日程失败"
|
||||
}
|
||||
|
||||
item.Status = 1
|
||||
item.FinishTime = &now
|
||||
item.CarryOver = 1
|
||||
item.CarryToDate = targetDate
|
||||
item.UpdateTime = &now
|
||||
if _, err := models.Orm.Update(item,
|
||||
"Status", "FinishTime", "CarryOver", "CarryToDate", "UpdateTime"); err != nil {
|
||||
return nil, "标记原日程完成失败"
|
||||
}
|
||||
|
||||
return next, ""
|
||||
}
|
||||
|
||||
// CarrySchedule POST /backend/oa/schedule/carry/:id
|
||||
// 将单条待办顺延到指定日期(默认下一天):原日程标记完成,目标日期自动生成新待办。
|
||||
func (c *BackendOaScheduleController) CarrySchedule() {
|
||||
claims, err := c.oaScheduleClaims()
|
||||
if err != nil {
|
||||
c.oasJsonErr(401, 401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.oasJsonErr(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
|
||||
// 目标日期可选,不传则默认顺延到下一天
|
||||
var payload struct {
|
||||
TargetDate string `json:"target_date"`
|
||||
}
|
||||
if raw, e := io.ReadAll(c.Ctx.Request.Body); e == nil && len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &payload)
|
||||
}
|
||||
payload.TargetDate = strings.TrimSpace(payload.TargetDate)
|
||||
|
||||
var item models.OaSchedule
|
||||
err = models.Orm.QueryTable(new(models.OaSchedule)).
|
||||
Filter("id", id).
|
||||
Filter("is_deleted", 0).
|
||||
Filter("tid", claims.TenantId).
|
||||
Filter("user_id", claims.UserID).
|
||||
One(&item)
|
||||
if err != nil {
|
||||
c.oasJsonErr(404, 404, "日程不存在")
|
||||
return
|
||||
}
|
||||
|
||||
targetDate := payload.TargetDate
|
||||
if targetDate == "" {
|
||||
targetDate = nextOaDate(normalizeOaDate(item.ScheduleDate), 1)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
next, msg := carryOaSchedule(&item, targetDate, claims, now)
|
||||
if msg != "" {
|
||||
c.oasJsonErr(400, 400, msg)
|
||||
return
|
||||
}
|
||||
|
||||
c.oasOk(map[string]interface{}{
|
||||
"new_id": next.ID,
|
||||
"schedule_date": next.ScheduleDate,
|
||||
"carry_count": next.CarryCount,
|
||||
})
|
||||
}
|
||||
|
||||
// CarryPending POST /backend/oa/schedule/carry-pending
|
||||
// 批量顺延:把指定日期(默认今天)全部未完成的日程一次性顺延到目标日期(默认下一天)。
|
||||
// 已在目标日期存在顺延记录的会被跳过,不会重复生成。
|
||||
func (c *BackendOaScheduleController) CarryPending() {
|
||||
claims, err := c.oaScheduleClaims()
|
||||
if err != nil {
|
||||
c.oasJsonErr(401, 401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Date string `json:"date"`
|
||||
TargetDate string `json:"target_date"`
|
||||
}
|
||||
if raw, e := io.ReadAll(c.Ctx.Request.Body); e == nil && len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &payload)
|
||||
}
|
||||
payload.Date = strings.TrimSpace(payload.Date)
|
||||
payload.TargetDate = strings.TrimSpace(payload.TargetDate)
|
||||
|
||||
now := time.Now()
|
||||
date := payload.Date
|
||||
if !isValidOaDate(date) {
|
||||
date = now.Format("2006-01-02")
|
||||
}
|
||||
targetDate := payload.TargetDate
|
||||
if !isValidOaDate(targetDate) {
|
||||
targetDate = nextOaDate(date, 1)
|
||||
}
|
||||
if !isValidOaDate(targetDate) {
|
||||
c.oasJsonErr(400, 400, "顺延日期无效")
|
||||
return
|
||||
}
|
||||
|
||||
var list []models.OaSchedule
|
||||
_, err = models.Orm.QueryTable(new(models.OaSchedule)).
|
||||
Filter("is_deleted", 0).
|
||||
Filter("tid", claims.TenantId).
|
||||
Filter("user_id", claims.UserID).
|
||||
Filter("schedule_date", date).
|
||||
Filter("status", 0).
|
||||
OrderBy("start_time", "id").
|
||||
All(&list)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
c.oasJsonErr(500, 500, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
success, skipped := 0, 0
|
||||
lastDate := targetDate
|
||||
for i := range list {
|
||||
if _, msg := carryOaSchedule(&list[i], targetDate, claims, now); msg != "" {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
success++
|
||||
lastDate = targetDate
|
||||
}
|
||||
|
||||
c.oasOk(map[string]interface{}{
|
||||
"success": success,
|
||||
"skipped": skipped,
|
||||
"schedule_date": lastDate,
|
||||
})
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
@@ -15,6 +15,8 @@ type BackendEmployeeFile struct {
|
||||
IDCardFront string `orm:"column(id_card_front);size(500);default()" json:"id_card_front"`
|
||||
IDCardBack string `orm:"column(id_card_back);size(500);default()" json:"id_card_back"`
|
||||
EducationPhoto string `orm:"column(education_photo);size(500);default()" json:"education_photo"`
|
||||
Education string `orm:"column(education);size(50);default()" json:"education"`
|
||||
Nation string `orm:"column(nation);size(30);default()" json:"nation"`
|
||||
PoliticalStatus string `orm:"column(political_status);size(30);default()" json:"political_status"`
|
||||
MaritalStatus int8 `orm:"column(marital_status);default(0)" json:"marital_status"`
|
||||
NativePlace string `orm:"column(native_place);size(100);default()" json:"native_place"`
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OaSchedule OA日程(日历待办): yz_backend_oa_schedule
|
||||
// 日程按租户 tid + 用户 user_id 双重隔离,供 backend 日程管理页面的
|
||||
// 月历视图与待办列表读写。schedule_date/start_time/end_time 均以
|
||||
// "YYYY-MM-DD"、"HH:mm" 字符串存取,前端无需再做时区换算。
|
||||
//
|
||||
// 顺延相关字段(source_id/carry_over/carry_count/carry_to_date):
|
||||
// 当天做不完的日程可"顺延到下一天"——原日程标记完成并置 carry_over=1,
|
||||
// 同时在目标日期生成一条待办,source_id 指向整条顺延链的源头日程,
|
||||
// carry_count 记录累计顺延次数,便于追溯一个任务跨了多少天。
|
||||
type OaSchedule struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Tid int `orm:"column(tid)" json:"tid"`
|
||||
@@ -21,6 +29,10 @@ type OaSchedule struct {
|
||||
Priority int8 `orm:"column(priority);default(0)" json:"priority"`
|
||||
Status int8 `orm:"column(status);default(0)" json:"status"`
|
||||
FinishTime *time.Time `orm:"column(finish_time);type(datetime);null" json:"finish_time"`
|
||||
SourceID uint64 `orm:"column(source_id);default(0)" json:"source_id"`
|
||||
CarryOver int8 `orm:"column(carry_over);default(0)" json:"carry_over"`
|
||||
CarryCount int `orm:"column(carry_count);default(0)" json:"carry_count"`
|
||||
CarryToDate string `orm:"column(carry_to_date);size(10);default()" json:"carry_to_date"`
|
||||
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
|
||||
@@ -30,3 +42,29 @@ type OaSchedule struct {
|
||||
func (m *OaSchedule) TableName() string {
|
||||
return "yz_backend_oa_schedule"
|
||||
}
|
||||
|
||||
var oaScheduleCarryColumnsOnce sync.Once
|
||||
|
||||
// EnsureOaScheduleCarryColumns 为日程表补齐"顺延到下一天"所需字段。
|
||||
// 历史库可能早于该特性建表,这里在首次访问日程接口时自动补列,
|
||||
// 字段已存在时 MySQL 会报 duplicate column,统一忽略,不阻断主流程。
|
||||
func EnsureOaScheduleCarryColumns() error {
|
||||
if Orm == nil {
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
oaScheduleCarryColumnsOnce.Do(func() {
|
||||
alters := []string{
|
||||
"ALTER TABLE yz_backend_oa_schedule ADD COLUMN source_id bigint unsigned NOT NULL DEFAULT 0 COMMENT '顺延来源日程ID 0-原始日程'",
|
||||
"ALTER TABLE yz_backend_oa_schedule ADD COLUMN carry_over tinyint NOT NULL DEFAULT 0 COMMENT '是否已顺延到下一天 0-否 1-是'",
|
||||
"ALTER TABLE yz_backend_oa_schedule ADD COLUMN carry_count int NOT NULL DEFAULT 0 COMMENT '累计顺延次数'",
|
||||
"ALTER TABLE yz_backend_oa_schedule ADD COLUMN carry_to_date varchar(10) NOT NULL DEFAULT '' COMMENT '顺延目标日期(YYYY-MM-DD)'",
|
||||
}
|
||||
for _, sql := range alters {
|
||||
if _, e := Orm.Raw(sql).Exec(); e != nil {
|
||||
err = e
|
||||
}
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -263,6 +263,8 @@ func RegisterAuthRoutes() {
|
||||
beego.Router("/backend/oa/schedule/update/:id", &controllers.BackendOaScheduleController{}, "post:Update")
|
||||
beego.Router("/backend/oa/schedule/delete/:id", &controllers.BackendOaScheduleController{}, "delete:Delete")
|
||||
beego.Router("/backend/oa/schedule/finish/:id", &controllers.BackendOaScheduleController{}, "post:FinishSchedule")
|
||||
beego.Router("/backend/oa/schedule/carry/:id", &controllers.BackendOaScheduleController{}, "post:CarrySchedule")
|
||||
beego.Router("/backend/oa/schedule/carry-pending", &controllers.BackendOaScheduleController{}, "post:CarryPending")
|
||||
}
|
||||
|
||||
// registerOrganizationRoutes 为指定模块前缀注册组织架构路由。
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 日程表增加"顺延到下一天"相关字段(兼容已有数据库)
|
||||
-- 已存在字段时请跳过对应 ALTER 语句。
|
||||
-- 作用:当天没做完的日程可一键顺延,原日程标记完成(carry_over=1),
|
||||
-- 并在目标日期自动生成一条待办(source_id 指向源头日程)继续跟进。
|
||||
|
||||
ALTER TABLE `yz_backend_oa_schedule`
|
||||
ADD COLUMN `source_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '顺延来源日程ID 0-原始日程' AFTER `finish_time`;
|
||||
|
||||
ALTER TABLE `yz_backend_oa_schedule`
|
||||
ADD COLUMN `carry_over` tinyint NOT NULL DEFAULT '0' COMMENT '是否已顺延到下一天 0-否 1-是' AFTER `source_id`;
|
||||
|
||||
ALTER TABLE `yz_backend_oa_schedule`
|
||||
ADD COLUMN `carry_count` int NOT NULL DEFAULT '0' COMMENT '累计顺延次数' AFTER `carry_over`;
|
||||
|
||||
ALTER TABLE `yz_backend_oa_schedule`
|
||||
ADD COLUMN `carry_to_date` varchar(10) NOT NULL DEFAULT '' COMMENT '顺延目标日期(YYYY-MM-DD)' AFTER `carry_count`;
|
||||
|
||||
ALTER TABLE `yz_backend_oa_schedule`
|
||||
ADD KEY `idx_source_id` (`source_id`);
|
||||
@@ -17,11 +17,16 @@ CREATE TABLE `yz_backend_oa_schedule` (
|
||||
`priority` tinyint NOT NULL DEFAULT '0' COMMENT '优先级 0-普通 1-重要 2-紧急',
|
||||
`status` tinyint NOT NULL DEFAULT '0' COMMENT '状态 0-待办 1-完成',
|
||||
`finish_time` datetime DEFAULT NULL COMMENT '完成时间',
|
||||
`source_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '顺延来源日程ID 0-原始日程',
|
||||
`carry_over` tinyint NOT NULL DEFAULT '0' COMMENT '是否已顺延到下一天 0-否 1-是',
|
||||
`carry_count` int NOT NULL DEFAULT '0' COMMENT '累计顺延次数',
|
||||
`carry_to_date` varchar(10) NOT NULL DEFAULT '' COMMENT '顺延目标日期(YYYY-MM-DD)',
|
||||
`is_deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除 0-否 1-是',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tid_user_date` (`tid`,`user_id`,`schedule_date`),
|
||||
KEY `idx_schedule_date` (`schedule_date`)
|
||||
KEY `idx_schedule_date` (`schedule_date`),
|
||||
KEY `idx_source_id` (`source_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='OA日程管理表(日历待办)';
|
||||
|
||||
Reference in New Issue
Block a user