优化日报模块
This commit is contained in:
1 parent
79d5ae6b66
commit
51fd90fc51
17 files changed
+1767
-257
No files matched your search
Vendored
-95
@@ -1,95 +0,0 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
CommonAside: typeof import('./src/components/CommonAside.vue')['default']
|
||||
CommonHeader: typeof import('./src/components/CommonHeader.vue')['default']
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete']
|
||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||
ElBacktop: typeof import('element-plus/es')['ElBacktop']
|
||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCascader: typeof import('element-plus/es')['ElCascader']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDivider: typeof import('element-plus/es')['ElDivider']
|
||||
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElImage: typeof import('element-plus/es')['ElImage']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElLink: typeof import('element-plus/es')['ElLink']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElOptionGroup: typeof import('element-plus/es')['ElOptionGroup']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElSlider: typeof import('element-plus/es')['ElSlider']
|
||||
ElStep: typeof import('element-plus/es')['ElStep']
|
||||
ElSteps: typeof import('element-plus/es')['ElSteps']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTimeline: typeof import('element-plus/es')['ElTimeline']
|
||||
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
|
||||
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ElTree: typeof import('element-plus/es')['ElTree']
|
||||
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
|
||||
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||
FlowCanvas: typeof import('./src/components/approval/FlowCanvas.vue')['default']
|
||||
MessageDetailDialog: typeof import('./src/components/MessageDetailDialog.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
TenantSwitcher: typeof import('./src/components/TenantSwitcher.vue')['default']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,19 @@ export function getMyDailyReports(params) {
|
||||
})
|
||||
}
|
||||
|
||||
// 我的日报近期热力图:params = { months }(默认 3,含本月往前数)
|
||||
// data = { start_date, end_date, total, submitted, days: [{ date, status, id }] }
|
||||
export function getMyDailyReportCalendar(params) {
|
||||
return request({
|
||||
url: '/backend/oa/dailyreport/calendar',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 保存日报:{ date, today_done, tomorrow_plan, issues, attachments, submit }
|
||||
// submit=true 提交,false 存草稿;同一人同一天只会有一条记录
|
||||
// 注意:已提交的日报会被锁定,需先调用 withdrawDailyReport 撤销提交后才能修改
|
||||
export function saveDailyReport(data) {
|
||||
return request({
|
||||
url: '/backend/oa/dailyreport/save',
|
||||
@@ -31,7 +42,16 @@ export function saveDailyReport(data) {
|
||||
})
|
||||
}
|
||||
|
||||
// 日报详情:{ report, comments, can_edit, can_delete, can_comment }
|
||||
// 撤销提交:已提交日报退回草稿,同时把本次提交的内容快照写入修改痕迹,改完需重新提交
|
||||
export function withdrawDailyReport(id) {
|
||||
return request({
|
||||
url: `/backend/oa/dailyreport/withdraw/${id}`,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
// 日报详情:{ report, comments, histories, is_author, can_edit, can_delete, can_comment, can_withdraw }
|
||||
// can_edit 仅在「本人草稿且处于补写窗口」时为 true;can_withdraw 仅在已提交且可撤销时为 true
|
||||
export function getDailyReportDetail(id) {
|
||||
return request({
|
||||
url: `/backend/oa/dailyreport/detail/${id}`,
|
||||
|
||||
@@ -25,7 +25,25 @@
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<el-button
|
||||
v-if="detail.can_edit"
|
||||
v-if="detail.report.status === 0 && detail.can_edit"
|
||||
link
|
||||
type="success"
|
||||
:loading="submittingReport"
|
||||
@click="handleSubmitReport"
|
||||
>
|
||||
提交
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="detail.report.status === 1 && detail.can_withdraw"
|
||||
link
|
||||
type="warning"
|
||||
:loading="withdrawing"
|
||||
@click="handleWithdraw"
|
||||
>
|
||||
撤销提交
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="detail.report.status === 0 && detail.can_edit"
|
||||
link
|
||||
type="primary"
|
||||
@click="$emit('edit', detail.report)"
|
||||
@@ -33,7 +51,7 @@
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="detail.can_delete"
|
||||
v-if="detail.report.status === 0 && detail.can_delete"
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDeleteReport"
|
||||
@@ -43,6 +61,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="detail.is_author && detail.report.status === 1 && !detail.can_withdraw"
|
||||
class="lock-tip"
|
||||
>
|
||||
日报已提交并锁定;超出补写窗口后不再支持撤销提交
|
||||
</div>
|
||||
|
||||
<section class="detail-section">
|
||||
<h4>今日完成</h4>
|
||||
<div class="section-text">{{ detail.report.today_done || '—' }}</div>
|
||||
@@ -74,6 +99,29 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="detail.histories?.length" class="detail-section">
|
||||
<h4>修改痕迹 <span class="comment-count">{{ detail.histories.length }}</span></h4>
|
||||
<div class="history-list">
|
||||
<div v-for="h in detail.histories" :key="h.id" class="history-item">
|
||||
<div class="history-head">
|
||||
<el-tag
|
||||
:type="h.action === 2 ? 'success' : 'warning'"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
{{ h.action === 2 ? '重新提交' : '撤销提交' }}
|
||||
</el-tag>
|
||||
<span class="history-time">{{ h.create_time }}</span>
|
||||
</div>
|
||||
<div class="history-text">
|
||||
<div><span class="history-label">今日完成:</span>{{ h.today_done || '—' }}</div>
|
||||
<div><span class="history-label">明日计划:</span>{{ h.tomorrow_plan || '—' }}</div>
|
||||
<div><span class="history-label">问题与建议:</span>{{ h.issues || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-section comment-section">
|
||||
<h4>评论 <span class="comment-count">{{ detail.comments.length }}</span></h4>
|
||||
|
||||
@@ -155,6 +203,8 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Paperclip } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getDailyReportDetail,
|
||||
saveDailyReport,
|
||||
withdrawDailyReport,
|
||||
addDailyReportComment,
|
||||
deleteDailyReportComment,
|
||||
deleteDailyReport
|
||||
@@ -171,6 +221,8 @@ const detail = ref(null)
|
||||
const commentText = ref('')
|
||||
const replyTo = ref(null)
|
||||
const submitting = ref(false)
|
||||
const submittingReport = ref(false)
|
||||
const withdrawing = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.reportId,
|
||||
@@ -201,6 +253,62 @@ function startReply(comment) {
|
||||
replyTo.value = { id: comment.id, user_name: comment.user_name }
|
||||
}
|
||||
|
||||
// 草稿直接在详情页提交:提交后内容锁定,需撤销提交才能再改。
|
||||
async function handleSubmitReport() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'提交后日报将锁定,如需修改需先撤销提交。确定提交这条日报吗?',
|
||||
'提交日报',
|
||||
{ type: 'warning', confirmButtonText: '提交', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
submittingReport.value = true
|
||||
try {
|
||||
const report = detail.value.report
|
||||
await saveDailyReport({
|
||||
date: report.report_date,
|
||||
today_done: report.today_done || '',
|
||||
tomorrow_plan: report.tomorrow_plan || '',
|
||||
issues: report.issues || '',
|
||||
attachments: JSON.stringify(report.attachments || []),
|
||||
submit: true
|
||||
})
|
||||
ElMessage.success('日报已提交')
|
||||
await loadDetail()
|
||||
emit('changed')
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '提交失败')
|
||||
} finally {
|
||||
submittingReport.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 撤销提交:退回草稿并留存修改痕迹,修改后可重新提交。
|
||||
async function handleWithdraw() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'撤销提交后日报退回草稿,本次提交的内容会作为修改痕迹保留,修改完成后需重新提交。确定撤销吗?',
|
||||
'撤销提交',
|
||||
{ type: 'warning', confirmButtonText: '撤销提交', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
withdrawing.value = true
|
||||
try {
|
||||
await withdrawDailyReport(props.reportId)
|
||||
ElMessage.success('已撤销提交,可修改后重新提交')
|
||||
await loadDetail()
|
||||
emit('changed')
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '撤销提交失败')
|
||||
} finally {
|
||||
withdrawing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitComment() {
|
||||
const content = commentText.value.trim()
|
||||
if (!content) {
|
||||
@@ -321,6 +429,53 @@ function formatSize(size) {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.lock-tip {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--el-color-warning-light-9, #fdf6ec);
|
||||
font-size: 12px;
|
||||
color: var(--el-color-warning, #e6a23c);
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
padding: 10px 12px;
|
||||
border: 1px dashed #ebeef5;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.history-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.history-time {
|
||||
margin-left: auto;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.history-text {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #606266;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.history-label {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px dashed #ebeef5;
|
||||
@@ -470,10 +625,24 @@ html.dark & {
|
||||
.comment-time,
|
||||
.file-size,
|
||||
.comment-disabled,
|
||||
.history-label,
|
||||
.reply-banner {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.history-item {
|
||||
background: var(--el-fill-color-light);
|
||||
border-color: var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.history-text {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.lock-tip {
|
||||
background: var(--el-color-warning-light-9);
|
||||
}
|
||||
|
||||
.attachment-item,
|
||||
.comment-item {
|
||||
background: var(--el-fill-color-light);
|
||||
|
||||
@@ -16,61 +16,118 @@
|
||||
<el-tabs v-model="activeTab" class="report-tabs" @tab-change="handleTabChange">
|
||||
<!-- ==================== 我的日报 ==================== -->
|
||||
<el-tab-pane label="我的日报" name="mine">
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">本月日报</div>
|
||||
<div class="stat-value is-blue">{{ summary.month_total || 0 }}</div>
|
||||
<div class="stat-sub">条记录</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">本月已提交</div>
|
||||
<div class="stat-value is-green">{{ summary.month_submitted || 0 }}</div>
|
||||
<div class="stat-sub">条已提交</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">今日状态</div>
|
||||
<div class="stat-value" :class="todayStatusClass">
|
||||
{{ todayStatusText }}
|
||||
<div class="mine-overview">
|
||||
<div class="overview-left">
|
||||
<div class="mine-stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">本月日报</div>
|
||||
<div class="stat-value is-blue">{{ summary.month_total || 0 }}</div>
|
||||
<div class="stat-sub">条记录</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">本月已提交</div>
|
||||
<div class="stat-value is-green">{{ summary.month_submitted || 0 }}</div>
|
||||
<div class="stat-sub">条已提交</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">今日状态</div>
|
||||
<div class="stat-value" :class="todayStatusClass">
|
||||
{{ todayStatusText }}
|
||||
</div>
|
||||
<div class="stat-sub">{{ summary.window_end || todayStr() }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">补写窗口</div>
|
||||
<div class="stat-value is-orange window-value">
|
||||
{{ summary.window_start || '—' }}
|
||||
</div>
|
||||
<div class="stat-sub">至 {{ summary.window_end || '—' }},共 {{ profile.backfill_days || 7 }} 天</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-sub">{{ summary.window_end || todayStr() }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">补写窗口</div>
|
||||
<div class="stat-value is-orange window-value">
|
||||
{{ summary.window_start || '—' }}
|
||||
|
||||
<div class="mine-filters">
|
||||
<div class="filter-row">
|
||||
<el-date-picker
|
||||
v-model="mineRange"
|
||||
class="mine-range"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:disabled-date="disableFutureDate"
|
||||
unlink-panels
|
||||
/>
|
||||
<el-button type="primary" :icon="Search" @click="searchMine">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="resetMineFilters">重置</el-button>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<span class="backfill-label">补写往日</span>
|
||||
<el-date-picker
|
||||
v-model="backfillDate"
|
||||
class="backfill-date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="选择日期"
|
||||
:disabled-date="disableOutsideWindow"
|
||||
:clearable="false"
|
||||
/>
|
||||
<el-button @click="openBackfill">发布</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-sub">至 {{ summary.window_end || '—' }},共 {{ profile.backfill_days || 7 }} 天</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-date-picker
|
||||
v-model="mineRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:disabled-date="disableFutureDate"
|
||||
unlink-panels
|
||||
style="width: 260px"
|
||||
/>
|
||||
<el-button type="primary" :icon="Search" @click="searchMine">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button :icon="Refresh" @click="resetMineFilters">重置</el-button>
|
||||
|
||||
<div class="backfill-group">
|
||||
<span class="backfill-label">补写往日</span>
|
||||
<el-date-picker
|
||||
v-model="backfillDate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="选择日期"
|
||||
:disabled-date="disableOutsideWindow"
|
||||
:clearable="false"
|
||||
style="width: 150px"
|
||||
/>
|
||||
<el-button @click="openBackfill">填写该日</el-button>
|
||||
<div class="overview-right">
|
||||
<div class="heatmap-card">
|
||||
<div class="heatmap-head">
|
||||
<div>
|
||||
<div class="heatmap-title">日报热力图</div>
|
||||
<div class="heatmap-sub">
|
||||
{{ calendarSubmitted }} 篇已发布 · 共 {{ calendarTotal }} 条
|
||||
</div>
|
||||
</div>
|
||||
<div class="heatmap-controls">
|
||||
<span class="legend-item"><i class="legend-cell is-submitted" />已提交</span>
|
||||
<span class="legend-item"><i class="legend-cell is-draft" />草稿</span>
|
||||
<span class="legend-item"><i class="legend-cell is-none" />未填写</span>
|
||||
<span class="legend-item"><i class="legend-cell is-backfill">补</i>可补写</span>
|
||||
<span class="heatmap-range">{{ calendarRangeText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-loading="calendarLoading" class="heatmap-body">
|
||||
<div class="heatmap-months">
|
||||
<div v-for="m in calendarMonths" :key="m.key" class="heatmap-month">
|
||||
<div class="month-head">
|
||||
<span>{{ m.label }}</span>
|
||||
<span class="month-count" :class="{ 'is-zero': !m.submitted }">
|
||||
{{ m.submitted }} 篇
|
||||
</span>
|
||||
</div>
|
||||
<div class="month-weeks">
|
||||
<div
|
||||
v-for="(week, wi) in m.weeks"
|
||||
:key="wi"
|
||||
class="week-col"
|
||||
:class="{ 'is-pad': week.pad }"
|
||||
>
|
||||
<div
|
||||
v-for="(cell, ci) in week.cells"
|
||||
:key="ci"
|
||||
class="heat-cell"
|
||||
:class="[
|
||||
cellClass(cell),
|
||||
{ 'is-today': cell && cell.date === todayStr(), 'is-backfill': cellBackfill(cell) }
|
||||
]"
|
||||
:title="cellTooltip(cell)"
|
||||
@click="handleCellClick(cell)"
|
||||
>
|
||||
<span v-if="cellBackfill(cell)" class="cell-pad">补</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -132,7 +189,7 @@
|
||||
:report-id="mineSelectedId"
|
||||
@edit="handleDetailEdit"
|
||||
@deleted="handleMineDeleted"
|
||||
@changed="loadMine"
|
||||
@changed="handleMineChanged"
|
||||
/>
|
||||
<el-empty
|
||||
v-else
|
||||
@@ -202,8 +259,8 @@
|
||||
<div class="split-layout">
|
||||
<aside class="list-pane">
|
||||
<div class="list-head">
|
||||
<span>团队成员</span>
|
||||
<span class="list-count">共 {{ teamPagination.total }} 人</span>
|
||||
<span>日报池</span>
|
||||
<span class="list-count">共 {{ teamPagination.total }} 条已提交</span>
|
||||
</div>
|
||||
<div v-loading="teamLoading" class="report-cards">
|
||||
<div
|
||||
@@ -216,15 +273,16 @@
|
||||
<div class="card-top">
|
||||
<span class="member-name">{{ row.user_name }}</span>
|
||||
<el-tag v-if="row.submitted" type="success" size="small">已提交</el-tag>
|
||||
<el-tag v-else-if="row.status === 0" type="info" size="small">草稿</el-tag>
|
||||
<el-tag v-else type="danger" size="small" effect="plain">未提交</el-tag>
|
||||
</div>
|
||||
<div class="card-summary member-org">
|
||||
{{ row.org_name || '未分配部门' }}
|
||||
<span v-if="row.account" class="member-account">{{ row.account }}</span>
|
||||
</div>
|
||||
<div class="card-summary member-content">
|
||||
{{ row.summary || '(内容为空)' }}
|
||||
</div>
|
||||
<div class="card-foot">
|
||||
<span>{{ row.submit_time || '—' }}</span>
|
||||
<span>发布于 {{ row.submit_time || '—' }}</span>
|
||||
<el-tag v-if="row.has_issue" type="warning" size="small" effect="plain">
|
||||
有反馈
|
||||
</el-tag>
|
||||
@@ -232,7 +290,7 @@
|
||||
</div>
|
||||
<el-empty
|
||||
v-if="!teamLoading && !teamList.length"
|
||||
description="暂无团队成员"
|
||||
description="当日还没有成员发布日报"
|
||||
:image-size="70"
|
||||
/>
|
||||
</div>
|
||||
@@ -259,7 +317,7 @@
|
||||
/>
|
||||
<el-empty
|
||||
v-else
|
||||
description="选择左侧已提交的成员查看日报"
|
||||
description="选择左侧日报查看内容"
|
||||
:image-size="80"
|
||||
/>
|
||||
</section>
|
||||
@@ -285,6 +343,7 @@ import { Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getDailyReportProfile,
|
||||
getMyDailyReports,
|
||||
getMyDailyReportCalendar,
|
||||
getDailyReportDetail,
|
||||
getTeamDailyReports,
|
||||
getTeamDailyReportOrgs
|
||||
@@ -310,6 +369,13 @@ const backfillDate = ref(todayStr())
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
const mineSelectedId = ref(0)
|
||||
|
||||
// 近期热力图(近三个月):按天展示草稿/已提交,按月分块、按周分列
|
||||
const calendarLoading = ref(false)
|
||||
const calendarDays = ref([])
|
||||
const calendarRange = ref({ start: '', end: '' })
|
||||
const calendarTotal = ref(0)
|
||||
const calendarSubmitted = ref(0)
|
||||
|
||||
// ---------------- 团队日报 ----------------
|
||||
const teamLoading = ref(false)
|
||||
const teamList = ref([])
|
||||
@@ -343,9 +409,71 @@ const todayStatusClass = computed(() => {
|
||||
return 'is-red'
|
||||
})
|
||||
|
||||
const calendarMap = computed(() => {
|
||||
const map = new Map()
|
||||
calendarDays.value.forEach((day) => {
|
||||
// 后端 DATE 字段可能带出 "2026-10-21 00:00:00 +0800 CST",统一截取到 YYYY-MM-DD 再建索引
|
||||
const key = String(day?.date || '').slice(0, 10)
|
||||
if (key) map.set(key, day)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const calendarRangeText = computed(() => {
|
||||
const { start, end } = calendarRange.value
|
||||
if (!start || !end) return ''
|
||||
return `${start} 至 ${end}`
|
||||
})
|
||||
|
||||
// 按区间生成热力图网格:每列为一周(周一在上、周日在下),月内首尾补空格
|
||||
const calendarMonths = computed(() => {
|
||||
const map = calendarMap.value
|
||||
const today = todayStr()
|
||||
const { start, end } = calendarRange.value
|
||||
if (!start || !end) return []
|
||||
const [startYear, startMonth] = start.split('-').map(Number)
|
||||
const [endYear, endMonth] = end.split('-').map(Number)
|
||||
const months = []
|
||||
let year = startYear
|
||||
let month = startMonth
|
||||
while (year < endYear || (year === endYear && month <= endMonth)) {
|
||||
const prefix = `${year}-${pad2(month)}`
|
||||
const daysInMonth = new Date(year, month, 0).getDate()
|
||||
const offset = (new Date(year, month - 1, 1).getDay() + 6) % 7
|
||||
const cells = []
|
||||
for (let i = 0; i < offset; i++) cells.push(null)
|
||||
let total = 0
|
||||
let submitted = 0
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const date = `${prefix}-${pad2(d)}`
|
||||
const record = map.get(date)
|
||||
if (record) {
|
||||
total++
|
||||
if (record.status === 1) submitted++
|
||||
}
|
||||
cells.push({ date, record, isFuture: date > today })
|
||||
}
|
||||
// 每月固定按 6 周(42 天)渲染,保证三个月格子大小一致
|
||||
while (cells.length < 42) cells.push(null)
|
||||
const weeks = []
|
||||
for (let i = 0; i < 42; i += 7) {
|
||||
const weekCells = cells.slice(i, i + 7)
|
||||
weeks.push({ cells: weekCells, pad: weekCells.every((c) => !c) })
|
||||
}
|
||||
months.push({ key: prefix, label: `${month} 月`, weeks, total, submitted })
|
||||
month++
|
||||
if (month > 12) {
|
||||
month = 1
|
||||
year++
|
||||
}
|
||||
}
|
||||
return months
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProfile()
|
||||
await loadMine()
|
||||
loadCalendar()
|
||||
})
|
||||
|
||||
async function loadProfile() {
|
||||
@@ -393,6 +521,61 @@ function handleMinePageChange(page) {
|
||||
loadMine()
|
||||
}
|
||||
|
||||
async function loadCalendar() {
|
||||
calendarLoading.value = true
|
||||
try {
|
||||
const res = await getMyDailyReportCalendar({ months: 3 })
|
||||
const data = res?.data || {}
|
||||
calendarDays.value = data.days || []
|
||||
calendarRange.value = {
|
||||
start: String(data.start_date || '').slice(0, 10),
|
||||
end: String(data.end_date || '').slice(0, 10)
|
||||
}
|
||||
calendarTotal.value = data.total || 0
|
||||
calendarSubmitted.value = data.submitted || 0
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '热力图加载失败')
|
||||
} finally {
|
||||
calendarLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cellClass(cell) {
|
||||
if (!cell) return 'is-empty'
|
||||
if (cell.record?.status === 1) return 'is-submitted'
|
||||
if (cell.record?.status === 0) return 'is-draft'
|
||||
return cell.isFuture ? 'is-future' : 'is-none'
|
||||
}
|
||||
|
||||
// 补写窗口内、尚未写日报的日期:格子上标注「补」,提示可直接补写
|
||||
function cellBackfill(cell) {
|
||||
if (!cell || cell.isFuture || cell.record) return false
|
||||
return inWindow(cell.date)
|
||||
}
|
||||
|
||||
function cellTooltip(cell) {
|
||||
if (!cell) return ''
|
||||
if (!cell.record) {
|
||||
if (cell.isFuture) return `${cell.date}(未到)`
|
||||
if (cellBackfill(cell)) return `${cell.date}(可补写,点击填写)`
|
||||
return `${cell.date}(未填写)`
|
||||
}
|
||||
return `${cell.date}(${cell.record.status === 1 ? '已提交' : '草稿'})`
|
||||
}
|
||||
|
||||
function handleCellClick(cell) {
|
||||
if (!cell || cell.isFuture) return
|
||||
if (cell.record?.id) {
|
||||
mineSelectedId.value = cell.record.id
|
||||
return
|
||||
}
|
||||
if (inWindow(cell.date)) {
|
||||
openEdit(null, cell.date)
|
||||
return
|
||||
}
|
||||
ElMessage.info('该日期没有日报,且已超出补写窗口')
|
||||
}
|
||||
|
||||
function searchMine() {
|
||||
pagination.page = 1
|
||||
loadMine()
|
||||
@@ -426,6 +609,13 @@ async function openBackfill() {
|
||||
const res = await getMyDailyReports({ page: 1, pageSize: 1, start_date: date, end_date: date })
|
||||
const existed = res?.data?.list?.[0]
|
||||
if (existed) {
|
||||
// 已提交日报已锁定,不再直接打开编辑弹窗,引导到详情中撤销提交。
|
||||
if (existed.status === 1) {
|
||||
mineSelectedId.value = existed.id
|
||||
detailVersion.value++
|
||||
ElMessage.info('该日日报已提交并锁定,如需修改请先在详情中撤销提交')
|
||||
return
|
||||
}
|
||||
editingReport.value = existed
|
||||
editDefaultDate.value = date
|
||||
editVisible.value = true
|
||||
@@ -442,7 +632,15 @@ async function openEditById(id) {
|
||||
try {
|
||||
const res = await getDailyReportDetail(id)
|
||||
const report = res?.data?.report
|
||||
if (report) openEdit(report)
|
||||
if (!report) return
|
||||
// 已提交日报已锁定,引导到详情中撤销提交后再改。
|
||||
if (report.status === 1) {
|
||||
mineSelectedId.value = report.id
|
||||
detailVersion.value++
|
||||
ElMessage.info('该日报已提交并锁定,如需修改请先在详情中撤销提交')
|
||||
return
|
||||
}
|
||||
openEdit(report)
|
||||
} catch (err) {
|
||||
ElMessage.error(err?.message || '日报加载失败')
|
||||
}
|
||||
@@ -461,9 +659,15 @@ function handleDetailEdit(report) {
|
||||
function handleMineDeleted() {
|
||||
mineSelectedId.value = 0
|
||||
loadMine()
|
||||
loadCalendar()
|
||||
if (profile.value.can_view_team && activeTab.value === 'team') loadTeam()
|
||||
}
|
||||
|
||||
function handleMineChanged() {
|
||||
loadMine()
|
||||
loadCalendar()
|
||||
}
|
||||
|
||||
function handleTeamDeleted() {
|
||||
teamSelectedId.value = 0
|
||||
loadTeam()
|
||||
@@ -472,6 +676,7 @@ function handleTeamDeleted() {
|
||||
function handleSaved() {
|
||||
detailVersion.value++
|
||||
loadMine()
|
||||
loadCalendar()
|
||||
if (profile.value.can_view_team) loadTeam()
|
||||
}
|
||||
|
||||
@@ -553,9 +758,14 @@ function refreshAll() {
|
||||
} else {
|
||||
loadTeam()
|
||||
}
|
||||
loadCalendar()
|
||||
}
|
||||
|
||||
// ---------------- 工具 ----------------
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
function todayStr() {
|
||||
const now = new Date()
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
@@ -642,6 +852,275 @@ function buildOrgTree(rows) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
// ---------------- 我的日报:概览 + 热力图 ----------------
|
||||
.mine-overview {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 7fr) minmax(0, 3fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.overview-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mine-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mine-stats .stat-card {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.mine-stats .stat-value {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.mine-filters {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-row :deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.mine-range {
|
||||
flex: 1 1 230px;
|
||||
min-width: 230px;
|
||||
}
|
||||
|
||||
.backfill-date {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.overview-right {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.heatmap-card {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 14px 14px;
|
||||
background: #f7f9fc;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.heatmap-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.heatmap-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.heatmap-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.heatmap-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.legend-cell {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
background: #eef1f6;
|
||||
|
||||
&.is-submitted {
|
||||
background: #67c23a;
|
||||
}
|
||||
|
||||
&.is-draft {
|
||||
background: #e6a23c;
|
||||
}
|
||||
|
||||
// 可补写图例:与热力格子一致的虚线 + 「补」
|
||||
&.is-backfill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f4f8ff;
|
||||
outline: 1px dashed #a0cfff;
|
||||
outline-offset: -1px;
|
||||
font-size: 8px;
|
||||
line-height: 1;
|
||||
color: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
.heatmap-range {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.heatmap-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.heatmap-months {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heatmap-month {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.heatmap-month + .heatmap-month {
|
||||
border-left: 1px dashed #dcdfe6;
|
||||
}
|
||||
|
||||
.month-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.month-count {
|
||||
color: #67c23a;
|
||||
|
||||
&.is-zero {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
}
|
||||
|
||||
.month-weeks {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
align-items: stretch;
|
||||
column-gap: 3px;
|
||||
}
|
||||
|
||||
.week-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.week-col + .week-col {
|
||||
border-left: 1px dashed #ebeef5;
|
||||
}
|
||||
|
||||
.week-col.is-pad {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.heat-cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 2px;
|
||||
background: #eef1f6;
|
||||
transition: transform 0.12s ease;
|
||||
|
||||
&.is-empty,
|
||||
&.is-future {
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&.is-future {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
&.is-submitted {
|
||||
background: #67c23a;
|
||||
}
|
||||
|
||||
&.is-draft {
|
||||
background: #e6a23c;
|
||||
}
|
||||
|
||||
&.is-none {
|
||||
background: #eef1f6;
|
||||
}
|
||||
|
||||
// 可补写:虚线描边 + 中间「补」字
|
||||
&.is-backfill {
|
||||
background: #f4f8ff;
|
||||
outline: 1px dashed #a0cfff;
|
||||
outline-offset: -1px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cell-pad {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
color: #409eff;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&:not(.is-empty):not(.is-future):hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
&.is-today {
|
||||
box-shadow: 0 0 0 1.5px var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef5;
|
||||
@@ -695,18 +1174,36 @@ function buildOrgTree(rows) {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.backfill-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.backfill-label {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
@media (max-width: 1500px) {
|
||||
.heatmap-months {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.month-weeks {
|
||||
column-gap: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.mine-overview {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mine-stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.heatmap-months {
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 左右结构 ----------------
|
||||
.split-layout {
|
||||
display: flex;
|
||||
@@ -815,6 +1312,11 @@ function buildOrgTree(rows) {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.member-content {
|
||||
margin-top: 4px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.card-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -868,6 +1370,7 @@ html.dark & {
|
||||
.stat-label,
|
||||
.stat-sub,
|
||||
.member-account,
|
||||
.member-content,
|
||||
.list-count,
|
||||
.card-foot {
|
||||
color: var(--el-text-color-secondary);
|
||||
@@ -914,5 +1417,64 @@ html.dark & {
|
||||
.backfill-label {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.mine-filters {
|
||||
background: var(--el-bg-color);
|
||||
border-color: var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.heatmap-card {
|
||||
background: var(--el-fill-color-lighter);
|
||||
border-color: var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.heatmap-title {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.heatmap-sub,
|
||||
.heatmap-range,
|
||||
.legend-item,
|
||||
.month-head {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.heatmap-month + .heatmap-month,
|
||||
.week-col + .week-col {
|
||||
border-color: var(--el-border-color);
|
||||
}
|
||||
|
||||
.heat-cell {
|
||||
&.is-none {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&.is-future {
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
|
||||
&.is-empty {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&.is-backfill {
|
||||
background: var(--el-fill-color-light);
|
||||
outline-color: var(--el-color-primary-light-5);
|
||||
}
|
||||
|
||||
.cell-pad {
|
||||
color: var(--el-color-primary-light-3);
|
||||
}
|
||||
}
|
||||
|
||||
.legend-cell.is-none {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.legend-cell.is-backfill {
|
||||
background: var(--el-fill-color-light);
|
||||
outline-color: var(--el-color-primary-light-5);
|
||||
color: var(--el-color-primary-light-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -97,6 +97,22 @@ func (c *AppOaController) DailyReportMineList() {
|
||||
c.oaOk(res)
|
||||
}
|
||||
|
||||
// DailyReportCalendar GET /api/oa/dailyreport/calendar
|
||||
// 我的日报近期热力图:months 默认 3,返回区间内逐日状态(草稿/已提交)。
|
||||
func (c *AppOaController) DailyReportCalendar() {
|
||||
u, ok := c.dailyReportCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
months, _ := c.GetInt("months", 3)
|
||||
res, err := dailyreport.Calendar(u, months)
|
||||
if err != nil {
|
||||
c.dailyReportFail(err)
|
||||
return
|
||||
}
|
||||
c.oaOk(res)
|
||||
}
|
||||
|
||||
// DailyReportSave POST /api/oa/dailyreport/save
|
||||
func (c *AppOaController) DailyReportSave() {
|
||||
u, ok := c.dailyReportCtx()
|
||||
@@ -152,6 +168,25 @@ func (c *AppOaController) DailyReportDelete() {
|
||||
c.oaOk(map[string]interface{}{"id": id})
|
||||
}
|
||||
|
||||
// DailyReportWithdraw POST /api/oa/dailyreport/withdraw/:id
|
||||
// 撤销提交:已提交日报退回草稿并留存本次提交的内容快照,改完需重新提交。
|
||||
func (c *AppOaController) DailyReportWithdraw() {
|
||||
u, ok := c.dailyReportCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id := c.dailyReportID()
|
||||
if id == 0 {
|
||||
return
|
||||
}
|
||||
item, err := dailyreport.Withdraw(u, id)
|
||||
if err != nil {
|
||||
c.dailyReportFail(err)
|
||||
return
|
||||
}
|
||||
c.oaOk(item)
|
||||
}
|
||||
|
||||
// DailyReportTeamStatus GET /api/oa/dailyreport/team/status
|
||||
func (c *AppOaController) DailyReportTeamStatus() {
|
||||
u, ok := c.dailyReportCtx()
|
||||
|
||||
@@ -175,6 +175,41 @@ func (c *BackendOaDailyReportController) Delete() {
|
||||
c.odrOk(map[string]interface{}{"id": id})
|
||||
}
|
||||
|
||||
// Withdraw POST /backend/oa/dailyreport/withdraw/:id
|
||||
// 撤销提交:已提交日报退回草稿并留存本次提交的内容快照,改完需重新提交。
|
||||
func (c *BackendOaDailyReportController) Withdraw() {
|
||||
u, ok := c.odrCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id := c.odrIDParam(":id")
|
||||
if id == 0 {
|
||||
return
|
||||
}
|
||||
item, err := dailyreport.Withdraw(u, id)
|
||||
if err != nil {
|
||||
c.odrFail(err)
|
||||
return
|
||||
}
|
||||
c.odrOk(item)
|
||||
}
|
||||
|
||||
// Calendar GET /backend/oa/dailyreport/calendar
|
||||
// 我的日报近期热力图:months 默认 3,返回区间内逐日状态(草稿/已提交),供前端按月、按周渲染。
|
||||
func (c *BackendOaDailyReportController) Calendar() {
|
||||
u, ok := c.odrCtx()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
months, _ := c.GetInt("months", 3)
|
||||
res, err := dailyreport.Calendar(u, months)
|
||||
if err != nil {
|
||||
c.odrFail(err)
|
||||
return
|
||||
}
|
||||
c.odrOk(res)
|
||||
}
|
||||
|
||||
// TeamStatus GET /backend/oa/dailyreport/team/status
|
||||
// 团队日报汇总:date/org_id/keyword/page/pageSize。
|
||||
func (c *BackendOaDailyReportController) TeamStatus() {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
-- =============================================================
|
||||
-- OA 工作日报 —— 修改痕迹表
|
||||
--
|
||||
-- 对应代码:
|
||||
-- models/oa_daily_report.go(OaDailyReportHistory)
|
||||
-- services/dailyreport/report.go(Withdraw / Save / GetDetail)
|
||||
-- controllers/backend_oa_dailyreport.go(后台 PC 端)
|
||||
-- controllers/app_oa_dailyreport.go(小程序端)
|
||||
--
|
||||
-- 设计要点:
|
||||
-- 1. 日报提交后锁定(status=1),不允许直接修改;
|
||||
-- 需要修改时必须先「撤销提交」退回草稿(status=0),改完再重新提交。
|
||||
-- 2. 每次撤销提交写入一条 action=1 的痕迹,保存本次提交时的内容快照;
|
||||
-- 撤销后重新提交写入一条 action=2 的痕迹,保存新版本内容快照。
|
||||
-- 3. 痕迹只增不改:日报删除后痕迹保留(report_id 仍可追溯),不做软删除。
|
||||
-- 4. 不显式指定 COLLATE,沿用数据库默认排序规则,避免 JOIN 时排序规则冲突。
|
||||
-- =============================================================
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `yz_backend_oa_daily_report_history` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
|
||||
`report_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '日报ID(yz_backend_oa_daily_report.id)',
|
||||
`user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '操作人uid(yz_system_tenant_user.uid)',
|
||||
`user_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '操作人姓名快照',
|
||||
`report_date` DATE NOT NULL COMMENT '日报日期',
|
||||
`action` TINYINT NOT NULL DEFAULT 1 COMMENT '1撤销提交 2重新提交',
|
||||
`today_done` TEXT COMMENT '快照:今日完成',
|
||||
`tomorrow_plan` TEXT COMMENT '快照:明日计划',
|
||||
`issues` TEXT COMMENT '快照:问题与建议',
|
||||
`attachments` TEXT COMMENT '快照:附件JSON数组',
|
||||
`submit_time` DATETIME DEFAULT NULL COMMENT '快照:提交时间',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tid_report` (`tid`, `report_id`, `id`),
|
||||
KEY `idx_tid_date` (`tid`, `report_date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-工作日报修改痕迹';
|
||||
@@ -195,6 +195,8 @@ func Init(_ string) {
|
||||
// 工作日报(docs/sql/create_oa_dailyreport.sql)
|
||||
new(OaDailyReport),
|
||||
new(OaDailyReportComment),
|
||||
// 日报修改痕迹(docs/sql/create_oa_dailyreport_history.sql)
|
||||
new(OaDailyReportHistory),
|
||||
new(OaDocCategory),
|
||||
new(OaDoc),
|
||||
new(OaDocLink),
|
||||
|
||||
@@ -6,21 +6,22 @@ import "time"
|
||||
//
|
||||
// 一人一天一条:user_id 存 yz_system_tenant_user.uid(不是 JWT 的 identity_id),
|
||||
// 由 (tid, user_id, report_date, is_deleted) 唯一键保证。
|
||||
// 草稿 status=0,提交后 status=1;删除为软删除。
|
||||
// 草稿 status=0,提交后 status=1 并锁定(需撤销提交退回草稿后才能修改);删除为软删除。
|
||||
// 每次撤销提交 / 撤销后重新提交都会在 yz_backend_oa_daily_report_history 留下内容快照。
|
||||
//
|
||||
// IsDeleted 同时承担两个职责:0=未删除;删除时写入记录 ID 作为删除批次标记,
|
||||
// 这样唯一键在「删掉后同一天重新填写」时不会与历史软删记录冲突。
|
||||
type OaDailyReport struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Tid uint64 `orm:"column(tid)" json:"tid"`
|
||||
UserID uint64 `orm:"column(user_id)" json:"user_id"`
|
||||
UserName string `orm:"column(user_name);size(50);default()" json:"user_name"`
|
||||
OrgID uint64 `orm:"column(org_id);default(0)" json:"org_id"`
|
||||
OrgName string `orm:"column(org_name);size(100);default()" json:"org_name"`
|
||||
ReportDate string `orm:"column(report_date);type(date)" json:"report_date"`
|
||||
TodayDone string `orm:"column(today_done);type(text);null" json:"today_done"`
|
||||
TomorrowPlan string `orm:"column(tomorrow_plan);type(text);null" json:"tomorrow_plan"`
|
||||
Issues string `orm:"column(issues);type(text);null" json:"issues"`
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Tid uint64 `orm:"column(tid)" json:"tid"`
|
||||
UserID uint64 `orm:"column(user_id)" json:"user_id"`
|
||||
UserName string `orm:"column(user_name);size(50);default()" json:"user_name"`
|
||||
OrgID uint64 `orm:"column(org_id);default(0)" json:"org_id"`
|
||||
OrgName string `orm:"column(org_name);size(100);default()" json:"org_name"`
|
||||
ReportDate string `orm:"column(report_date);type(date)" json:"report_date"`
|
||||
TodayDone string `orm:"column(today_done);type(text);null" json:"today_done"`
|
||||
TomorrowPlan string `orm:"column(tomorrow_plan);type(text);null" json:"tomorrow_plan"`
|
||||
Issues string `orm:"column(issues);type(text);null" json:"issues"`
|
||||
// Attachments 附件JSON数组:[{"id":1,"url":"","name":"","size":0}]
|
||||
Attachments string `orm:"column(attachments);type(text);null" json:"attachments"`
|
||||
Status int8 `orm:"column(status);default(1)" json:"status"` // 0草稿 1已提交
|
||||
@@ -35,6 +36,31 @@ func (m *OaDailyReport) TableName() string {
|
||||
return "yz_backend_oa_daily_report"
|
||||
}
|
||||
|
||||
// OaDailyReportHistory OA工作日报修改痕迹 yz_backend_oa_daily_report_history
|
||||
//
|
||||
// 已提交日报被撤销提交(action=1)时写入本次提交的内容快照;
|
||||
// 撤销后重新提交(action=2)时写入新版本内容快照,保证「改了有据可查」。
|
||||
// 痕迹只增不改,随日报一起逻辑上归档,不做软删除。
|
||||
type OaDailyReportHistory struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Tid uint64 `orm:"column(tid)" json:"tid"`
|
||||
ReportID uint64 `orm:"column(report_id)" json:"report_id"`
|
||||
UserID uint64 `orm:"column(user_id)" json:"user_id"`
|
||||
UserName string `orm:"column(user_name);size(50);default()" json:"user_name"`
|
||||
ReportDate string `orm:"column(report_date);type(date)" json:"report_date"`
|
||||
Action int8 `orm:"column(action);default(1)" json:"action"` // 1撤销提交 2重新提交
|
||||
TodayDone string `orm:"column(today_done);type(text);null" json:"today_done"`
|
||||
TomorrowPlan string `orm:"column(tomorrow_plan);type(text);null" json:"tomorrow_plan"`
|
||||
Issues string `orm:"column(issues);type(text);null" json:"issues"`
|
||||
Attachments string `orm:"column(attachments);type(text);null" json:"attachments"`
|
||||
SubmitTime *time.Time `orm:"column(submit_time);type(datetime);null" json:"submit_time"`
|
||||
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||
}
|
||||
|
||||
func (m *OaDailyReportHistory) TableName() string {
|
||||
return "yz_backend_oa_daily_report_history"
|
||||
}
|
||||
|
||||
// OaDailyReportComment OA工作日报评论 yz_backend_oa_daily_report_comment
|
||||
//
|
||||
// ReplyToID 指向同表评论ID,实现两级回复;0 表示直接评论日报。
|
||||
|
||||
@@ -111,7 +111,9 @@ func Register() {
|
||||
// OA 工作日报(与 /backend/oa/dailyreport/* 同一套数据;小程序「工作日报」页面使用)
|
||||
beego.Router("/api/oa/dailyreport/profile", &controllers.AppOaController{}, "get:DailyReportProfile")
|
||||
beego.Router("/api/oa/dailyreport/mine/list", &controllers.AppOaController{}, "get:DailyReportMineList")
|
||||
beego.Router("/api/oa/dailyreport/calendar", &controllers.AppOaController{}, "get:DailyReportCalendar")
|
||||
beego.Router("/api/oa/dailyreport/save", &controllers.AppOaController{}, "post:DailyReportSave")
|
||||
beego.Router("/api/oa/dailyreport/withdraw/:id", &controllers.AppOaController{}, "post:DailyReportWithdraw")
|
||||
beego.Router("/api/oa/dailyreport/detail/:id", &controllers.AppOaController{}, "get:DailyReportDetail")
|
||||
beego.Router("/api/oa/dailyreport/delete/:id", &controllers.AppOaController{}, "delete:DailyReportDelete")
|
||||
beego.Router("/api/oa/dailyreport/team/status", &controllers.AppOaController{}, "get:DailyReportTeamStatus")
|
||||
|
||||
@@ -352,7 +352,9 @@ func RegisterAuthRoutes() {
|
||||
// OA工作日报(本人 / 部门主管 / 管理员三级可见,支持评论)
|
||||
beego.Router("/backend/oa/dailyreport/profile", &controllers.BackendOaDailyReportController{}, "get:Profile")
|
||||
beego.Router("/backend/oa/dailyreport/mine/list", &controllers.BackendOaDailyReportController{}, "get:MineList")
|
||||
beego.Router("/backend/oa/dailyreport/calendar", &controllers.BackendOaDailyReportController{}, "get:Calendar")
|
||||
beego.Router("/backend/oa/dailyreport/save", &controllers.BackendOaDailyReportController{}, "post:Save")
|
||||
beego.Router("/backend/oa/dailyreport/withdraw/:id", &controllers.BackendOaDailyReportController{}, "post:Withdraw")
|
||||
beego.Router("/backend/oa/dailyreport/detail/:id", &controllers.BackendOaDailyReportController{}, "get:Detail")
|
||||
beego.Router("/backend/oa/dailyreport/delete/:id", &controllers.BackendOaDailyReportController{}, "delete:Delete")
|
||||
beego.Router("/backend/oa/dailyreport/team/status", &controllers.BackendOaDailyReportController{}, "get:TeamStatus")
|
||||
|
||||
@@ -22,21 +22,21 @@ type Attachment struct {
|
||||
|
||||
// ReportItem 日报列表/详情中的主体。
|
||||
type ReportItem struct {
|
||||
ID uint64 `json:"id"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
OrgID uint64 `json:"org_id"`
|
||||
OrgName string `json:"org_name"`
|
||||
ReportDate string `json:"report_date"`
|
||||
TodayDone string `json:"today_done"`
|
||||
TomorrowPlan string `json:"tomorrow_plan"`
|
||||
Issues string `json:"issues"`
|
||||
Attachments []Attachment `json:"attachments"`
|
||||
Status int8 `json:"status"`
|
||||
SubmitTime string `json:"submit_time"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
ID uint64 `json:"id"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
OrgID uint64 `json:"org_id"`
|
||||
OrgName string `json:"org_name"`
|
||||
ReportDate string `json:"report_date"`
|
||||
TodayDone string `json:"today_done"`
|
||||
TomorrowPlan string `json:"tomorrow_plan"`
|
||||
Issues string `json:"issues"`
|
||||
Attachments []Attachment `json:"attachments"`
|
||||
Status int8 `json:"status"`
|
||||
SubmitTime string `json:"submit_time"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
}
|
||||
|
||||
// Comment 日报评论(两级回复)。
|
||||
@@ -52,13 +52,29 @@ type Comment struct {
|
||||
CanDelete bool `json:"can_delete"`
|
||||
}
|
||||
|
||||
// Detail 日报详情:主体 + 评论 + 当前用户可执行的操作。
|
||||
// Detail 日报详情:主体 + 评论 + 修改痕迹 + 当前用户可执行的操作。
|
||||
type Detail struct {
|
||||
Report ReportItem `json:"report"`
|
||||
Comments []Comment `json:"comments"`
|
||||
CanEdit bool `json:"can_edit"`
|
||||
CanDelete bool `json:"can_delete"`
|
||||
CanComment bool `json:"can_comment"`
|
||||
Report ReportItem `json:"report"`
|
||||
Comments []Comment `json:"comments"`
|
||||
Histories []HistoryItem `json:"histories"`
|
||||
IsAuthor bool `json:"is_author"`
|
||||
CanEdit bool `json:"can_edit"`
|
||||
CanDelete bool `json:"can_delete"`
|
||||
CanComment bool `json:"can_comment"`
|
||||
CanWithdraw bool `json:"can_withdraw"`
|
||||
}
|
||||
|
||||
// HistoryItem 日报修改痕迹:撤销提交 / 撤销后重新提交时的内容快照。
|
||||
type HistoryItem struct {
|
||||
ID uint64 `json:"id"`
|
||||
Action int8 `json:"action"` // 1撤销提交 2重新提交
|
||||
UserName string `json:"user_name"`
|
||||
TodayDone string `json:"today_done"`
|
||||
TomorrowPlan string `json:"tomorrow_plan"`
|
||||
Issues string `json:"issues"`
|
||||
Attachments []Attachment `json:"attachments"`
|
||||
SubmitTime string `json:"submit_time"`
|
||||
CreateTime string `json:"create_time"`
|
||||
}
|
||||
|
||||
// SaveInput 保存日报入参;Submit=true 表示提交,否则保存草稿。
|
||||
@@ -97,6 +113,7 @@ type MineResult struct {
|
||||
}
|
||||
|
||||
// Save 新建或更新一条日报(同一人同一天唯一)。
|
||||
// 已提交的日报处于锁定状态,必须先撤销提交退回草稿才能修改。
|
||||
func Save(u *UserContext, in SaveInput) (*ReportItem, error) {
|
||||
date, err := ParseDate(in.Date)
|
||||
if err != nil {
|
||||
@@ -166,11 +183,18 @@ func Save(u *UserContext, in SaveInput) (*ReportItem, error) {
|
||||
return nil, findErr
|
||||
}
|
||||
|
||||
// 已提交的日报在窗口内允许继续修改,状态保持已提交。
|
||||
// 已提交的日报锁定:必须先撤销提交退回草稿,才能修改内容。
|
||||
if existing.Status == 1 {
|
||||
status = 1
|
||||
return nil, errBad("日报已提交并锁定,如需修改请先撤销提交")
|
||||
}
|
||||
if status == 1 && existing.SubmitTime == nil {
|
||||
// 撤销过提交的日报再次提交时,留下重新提交痕迹。
|
||||
resubmit := false
|
||||
if in.Submit {
|
||||
if existing.SubmitTime != nil {
|
||||
resubmit = true
|
||||
} else {
|
||||
resubmit = hasWithdrawHistory(u.Tid, existing.ID)
|
||||
}
|
||||
existing.SubmitTime = &now
|
||||
}
|
||||
existing.TodayDone = in.TodayDone
|
||||
@@ -182,8 +206,35 @@ func Save(u *UserContext, in SaveInput) (*ReportItem, error) {
|
||||
existing.OrgID = u.OrgID
|
||||
existing.OrgName = u.OrgName
|
||||
|
||||
if _, err := models.Orm.Update(&existing, "today_done", "tomorrow_plan", "issues",
|
||||
tx, err := models.Orm.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tx.Update(&existing, "today_done", "tomorrow_plan", "issues",
|
||||
"attachments", "status", "submit_time", "user_name", "org_id", "org_name"); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
if resubmit {
|
||||
history := models.OaDailyReportHistory{
|
||||
Tid: u.Tid,
|
||||
ReportID: existing.ID,
|
||||
UserID: u.UID,
|
||||
UserName: u.Name,
|
||||
ReportDate: date,
|
||||
Action: 2,
|
||||
TodayDone: existing.TodayDone,
|
||||
TomorrowPlan: existing.TomorrowPlan,
|
||||
Issues: existing.Issues,
|
||||
Attachments: existing.Attachments,
|
||||
SubmitTime: existing.SubmitTime,
|
||||
}
|
||||
if _, err := tx.Insert(&history); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count := commentCount(u.Tid, existing.ID)
|
||||
@@ -245,6 +296,67 @@ func ListMine(u *UserContext, q MineQuery) (*MineResult, error) {
|
||||
return &MineResult{List: list, Total: total, Summary: *summary}, nil
|
||||
}
|
||||
|
||||
// CalendarDay 我的日报日历热力图单日状态。
|
||||
type CalendarDay struct {
|
||||
Date string `json:"date"`
|
||||
Status int8 `json:"status"` // 0草稿 1已提交
|
||||
ID uint64 `json:"id"`
|
||||
}
|
||||
|
||||
// CalendarResult 近期热力图数据(默认近三个月)。
|
||||
type CalendarResult struct {
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
Total int `json:"total"`
|
||||
Submitted int `json:"submitted"`
|
||||
Days []CalendarDay `json:"days"`
|
||||
}
|
||||
|
||||
// Calendar 返回最近 months 个月(含本月)本人日报的逐日状态,供热力图按月/周渲染。
|
||||
// months 越界时回落到 3 个月。
|
||||
func Calendar(u *UserContext, months int) (*CalendarResult, error) {
|
||||
if months < 1 || months > 12 {
|
||||
months = 3
|
||||
}
|
||||
now := time.Now()
|
||||
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
start := monthStart.AddDate(0, -(months - 1), 0)
|
||||
end := monthStart.AddDate(0, 1, -1)
|
||||
startDate := start.Format("2006-01-02")
|
||||
endDate := end.Format("2006-01-02")
|
||||
|
||||
var rows []models.OaDailyReport
|
||||
if _, err := models.Orm.QueryTable(new(models.OaDailyReport)).
|
||||
Filter("tid", u.Tid).
|
||||
Filter("user_id", u.UID).
|
||||
Filter("is_deleted", 0).
|
||||
Filter("report_date__gte", startDate).
|
||||
Filter("report_date__lte", endDate).
|
||||
OrderBy("report_date").
|
||||
All(&rows, "id", "report_date", "status"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := &CalendarResult{
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
Days: make([]CalendarDay, 0, len(rows)),
|
||||
}
|
||||
for i := range rows {
|
||||
res.Days = append(res.Days, CalendarDay{
|
||||
// DATE 列在 parseTime=true 下会带上时间与时区,必须归一化为 YYYY-MM-DD,
|
||||
// 否则前端按日期匹配热力图格子时永远匹配不到。
|
||||
Date: dateOnly(rows[i].ReportDate),
|
||||
Status: rows[i].Status,
|
||||
ID: rows[i].ID,
|
||||
})
|
||||
if rows[i].Status == 1 {
|
||||
res.Submitted++
|
||||
}
|
||||
}
|
||||
res.Total = len(rows)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// mineSummary 本月提交统计与今天的状态。
|
||||
func mineSummary(u *UserContext) (*MineSummary, error) {
|
||||
now := time.Now()
|
||||
@@ -293,15 +405,85 @@ func GetDetail(u *UserContext, id uint64) (*Detail, error) {
|
||||
}
|
||||
|
||||
author := report.UserID == u.UID
|
||||
canEdit := author && report.Status == 0 && InWindow(report.ReportDate)
|
||||
canWithdraw := author && report.Status == 1 && InWindow(report.ReportDate)
|
||||
// 删除仅限草稿:已提交日报需先撤销提交才能删除。
|
||||
canDelete := report.Status == 0 && (u.IsAdmin || (author && InWindow(report.ReportDate)))
|
||||
|
||||
// 修改痕迹只对作者本人与租户管理员可见。
|
||||
histories := make([]HistoryItem, 0)
|
||||
if author || u.IsAdmin {
|
||||
histories, err = loadHistories(u.Tid, report.ID)
|
||||
if err != nil {
|
||||
// 痕迹表缺失或查询异常时降级为空,避免详情整体 500(部署时需执行 create_oa_dailyreport_history.sql)。
|
||||
histories = make([]HistoryItem, 0)
|
||||
}
|
||||
}
|
||||
return &Detail{
|
||||
Report: *buildItem(report, len(comments)),
|
||||
Comments: comments,
|
||||
CanEdit: author && InWindow(report.ReportDate),
|
||||
CanDelete: (author && InWindow(report.ReportDate)) || u.IsAdmin,
|
||||
CanComment: report.Status == 1 && (author || u.IsAdmin || u.InTeamOrg(report.OrgID)),
|
||||
Report: *buildItem(report, len(comments)),
|
||||
Comments: comments,
|
||||
Histories: histories,
|
||||
IsAuthor: author,
|
||||
CanEdit: canEdit,
|
||||
CanDelete: canDelete,
|
||||
CanComment: report.Status == 1 && (author || u.IsAdmin || u.InTeamOrg(report.OrgID)),
|
||||
CanWithdraw: canWithdraw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Withdraw 撤销提交:把已提交日报退回草稿,并把本次提交的内容快照写入修改痕迹。
|
||||
// 仅作者本人、且日报在补写窗口内时可操作;撤销后需修改并重新提交。
|
||||
func Withdraw(u *UserContext, id uint64) (*ReportItem, error) {
|
||||
report, err := loadReport(u.Tid, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if report.UserID != u.UID {
|
||||
return nil, errForbidden("只能撤销自己提交的日报")
|
||||
}
|
||||
if report.Status != 1 {
|
||||
return nil, errBad("日报尚未提交,无需撤销")
|
||||
}
|
||||
if !InWindow(report.ReportDate) {
|
||||
return nil, errBad(fmt.Sprintf("只能撤销最近%d天(%s 至 %s)的日报", BackfillDays, WindowStart(), Today()))
|
||||
}
|
||||
|
||||
tx, err := models.Orm.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
history := models.OaDailyReportHistory{
|
||||
Tid: u.Tid,
|
||||
ReportID: report.ID,
|
||||
UserID: u.UID,
|
||||
UserName: u.Name,
|
||||
ReportDate: report.ReportDate,
|
||||
Action: 1,
|
||||
TodayDone: report.TodayDone,
|
||||
TomorrowPlan: report.TomorrowPlan,
|
||||
Issues: report.Issues,
|
||||
Attachments: report.Attachments,
|
||||
SubmitTime: report.SubmitTime,
|
||||
}
|
||||
if _, err := tx.Insert(&history); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tx.QueryTable(new(models.OaDailyReport)).
|
||||
Filter("tid", u.Tid).Filter("id", report.ID).
|
||||
Update(map[string]interface{}{"status": 0, "submit_time": nil}); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
report.Status = 0
|
||||
count := commentCount(u.Tid, report.ID)
|
||||
return buildItem(report, count), nil
|
||||
}
|
||||
|
||||
// Delete 软删除日报及其评论:作者限补写窗口内,管理员不受限。
|
||||
//
|
||||
// 日报的 is_deleted 写入记录 ID(而非 1),既标记删除,又让唯一键
|
||||
@@ -311,6 +493,10 @@ func Delete(u *UserContext, id uint64) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 已提交日报锁定:不允许直接删除,需先撤销提交退回草稿。
|
||||
if report.Status == 1 {
|
||||
return errBad("日报已提交并锁定,请先撤销提交后再删除")
|
||||
}
|
||||
author := report.UserID == u.UID
|
||||
if !author && !u.IsAdmin {
|
||||
return errForbidden("无权删除该日报")
|
||||
@@ -462,6 +648,47 @@ func buildComment(u *UserContext, r *models.OaDailyReportComment) Comment {
|
||||
}
|
||||
}
|
||||
|
||||
// hasWithdrawHistory 判断一条日报是否被撤销过提交,用于识别“重新提交”。
|
||||
func hasWithdrawHistory(tid, reportID uint64) bool {
|
||||
n, err := models.Orm.QueryTable(new(models.OaDailyReportHistory)).
|
||||
Filter("tid", tid).Filter("report_id", reportID).Filter("action", 1).
|
||||
Count()
|
||||
return err == nil && n > 0
|
||||
}
|
||||
|
||||
// loadHistories 读取一条日报的全部修改痕迹,按写入顺序返回。
|
||||
func loadHistories(tid, reportID uint64) ([]HistoryItem, error) {
|
||||
var rows []models.OaDailyReportHistory
|
||||
if _, err := models.Orm.QueryTable(new(models.OaDailyReportHistory)).
|
||||
Filter("tid", tid).Filter("report_id", reportID).
|
||||
OrderBy("id").All(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]HistoryItem, 0, len(rows))
|
||||
for i := range rows {
|
||||
out = append(out, buildHistory(&rows[i]))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildHistory(h *models.OaDailyReportHistory) HistoryItem {
|
||||
attachments := make([]Attachment, 0)
|
||||
if strings.TrimSpace(h.Attachments) != "" {
|
||||
_ = json.Unmarshal([]byte(h.Attachments), &attachments)
|
||||
}
|
||||
return HistoryItem{
|
||||
ID: h.ID,
|
||||
Action: h.Action,
|
||||
UserName: h.UserName,
|
||||
TodayDone: h.TodayDone,
|
||||
TomorrowPlan: h.TomorrowPlan,
|
||||
Issues: h.Issues,
|
||||
Attachments: attachments,
|
||||
SubmitTime: formatTime(h.SubmitTime),
|
||||
CreateTime: h.CreateTime.Format("2006-01-02 15:04"),
|
||||
}
|
||||
}
|
||||
|
||||
func buildItem(r *models.OaDailyReport, commentCount int) *ReportItem {
|
||||
attachments := make([]Attachment, 0)
|
||||
if strings.TrimSpace(r.Attachments) != "" {
|
||||
|
||||
+114
-49
@@ -2,6 +2,7 @@ package dailyreport
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
@@ -26,7 +27,7 @@ type TeamStats struct {
|
||||
Rate float64 `json:"rate"` // 提交率百分比,保留一位小数
|
||||
}
|
||||
|
||||
// TeamRow 团队汇总中的一名成员。
|
||||
// TeamRow 团队汇总中的一名成员(仅包含当日已提交日报的成员)。
|
||||
type TeamRow struct {
|
||||
UserID uint64 `json:"user_id"`
|
||||
UserName string `json:"user_name"`
|
||||
@@ -35,9 +36,10 @@ type TeamRow struct {
|
||||
OrgName string `json:"org_name"`
|
||||
Submitted bool `json:"submitted"`
|
||||
ReportID uint64 `json:"report_id"`
|
||||
Status int8 `json:"status"` // -1未填写 0草稿 1已提交
|
||||
Status int8 `json:"status"` // 0草稿 1已提交(团队列表只返回 1)
|
||||
SubmitTime string `json:"submit_time"`
|
||||
HasIssue bool `json:"has_issue"`
|
||||
Summary string `json:"summary"` // 日报内容摘要(优先今日完成)
|
||||
}
|
||||
|
||||
// TeamResult 团队汇总结果。
|
||||
@@ -56,7 +58,8 @@ type OrgOption struct {
|
||||
MemberCount int `json:"member_count"`
|
||||
}
|
||||
|
||||
// TeamStatus 团队日报汇总:按日期返回管辖范围内每位成员的提交情况。
|
||||
// TeamStatus 团队日报日志池:只返回当日已提交的日报,未提交(含仅存草稿)的成员不展示。
|
||||
// 列表按提交时间倒序排列(按“谁发布了内容”展示,不按人分组),右侧根据选中项展示日报内容。
|
||||
// 管理员不选部门时统计全租户(含未分配部门的成员)。
|
||||
func TeamStatus(u *UserContext, q TeamQuery) (*TeamResult, error) {
|
||||
if !u.CanViewTeam {
|
||||
@@ -101,73 +104,135 @@ func TeamStatus(u *UserContext, q TeamQuery) (*TeamResult, error) {
|
||||
return nil, errForbidden("你还没有管辖的部门")
|
||||
}
|
||||
|
||||
cond := orm.NewCondition().
|
||||
// 管辖范围内的成员:用于姓名/部门补全与提交统计,不受关键词与分页影响。
|
||||
memberCond := orm.NewCondition().
|
||||
And("tid", u.Tid).
|
||||
And("delete_time__isnull", true).
|
||||
And("status", 1).
|
||||
AndNot("uid", u.UID)
|
||||
And("status", 1)
|
||||
if scopeIDs != nil {
|
||||
cond = cond.And("org_id__in", scopeIDs)
|
||||
memberCond = memberCond.And("org_id__in", scopeIDs)
|
||||
}
|
||||
if kw := strings.TrimSpace(q.Keyword); kw != "" {
|
||||
cond = cond.AndCond(orm.NewCondition().
|
||||
Or("name__icontains", kw).
|
||||
Or("account__icontains", kw))
|
||||
}
|
||||
base := models.Orm.QueryTable(new(models.SystemTenantUser)).SetCond(cond)
|
||||
|
||||
total, _ := base.Count()
|
||||
var members []models.SystemTenantUser
|
||||
if _, err := base.OrderBy("org_id", "uid").
|
||||
Limit(q.PageSize).Offset((q.Page - 1) * q.PageSize).
|
||||
All(&members); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 统计口径覆盖全部成员,不受分页影响。
|
||||
var allMembers []models.SystemTenantUser
|
||||
if _, err := base.OrderBy("uid").All(&allMembers, "uid"); err != nil {
|
||||
if _, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
SetCond(memberCond).OrderBy("org_id", "uid").All(&allMembers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memberByUID := make(map[uint64]*models.SystemTenantUser, len(allMembers))
|
||||
for i := range allMembers {
|
||||
memberByUID[allMembers[i].Uid] = &allMembers[i]
|
||||
}
|
||||
|
||||
reports, err := loadReportsByDate(u.Tid, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats := TeamStats{Total: len(allMembers)}
|
||||
for _, m := range allMembers {
|
||||
if r := reports[m.Uid]; r != nil && r.Status == 1 {
|
||||
stats.Submitted++
|
||||
kw := strings.TrimSpace(q.Keyword)
|
||||
rows := make([]TeamRow, 0, len(allMembers))
|
||||
for i := range allMembers {
|
||||
m := &allMembers[i]
|
||||
r := reports[m.Uid]
|
||||
if r == nil || r.Status != 1 {
|
||||
continue
|
||||
}
|
||||
stats.Submitted++
|
||||
row := buildTeamRow(r, m.OrgID, userNameOf(m), ptrString(m.Account), "")
|
||||
if o := orgByID[m.OrgID]; o != nil {
|
||||
row.OrgName = o.OrgName
|
||||
}
|
||||
if kw != "" && !matchTeamKeyword(row.UserName, row.Account, kw) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
// 作者不在成员表(成员已删除 / 组织映射异常)时用日报快照补齐,
|
||||
// 保证已提交的内容不会因人员变动而看不到。
|
||||
for uid, r := range reports {
|
||||
if r.Status != 1 {
|
||||
continue
|
||||
}
|
||||
if _, ok := memberByUID[uid]; ok {
|
||||
continue
|
||||
}
|
||||
if scopeIDs != nil && !containsID(scopeIDs, r.OrgID) {
|
||||
continue
|
||||
}
|
||||
row := buildTeamRow(r, r.OrgID, r.UserName, "", r.OrgName)
|
||||
if kw != "" && !matchTeamKeyword(row.UserName, row.Account, kw) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
// 日志池排序:按发布时间(提交时间)倒序,最新提交排在最前。
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
if rows[i].SubmitTime != rows[j].SubmitTime {
|
||||
return rows[i].SubmitTime > rows[j].SubmitTime
|
||||
}
|
||||
return rows[i].ReportID > rows[j].ReportID
|
||||
})
|
||||
|
||||
stats.Missing = stats.Total - stats.Submitted
|
||||
if stats.Missing < 0 {
|
||||
stats.Missing = 0
|
||||
}
|
||||
if stats.Total > 0 {
|
||||
stats.Rate = math.Round(float64(stats.Submitted)*1000/float64(stats.Total)) / 10
|
||||
}
|
||||
|
||||
list := make([]TeamRow, 0, len(members))
|
||||
for i := range members {
|
||||
m := &members[i]
|
||||
row := TeamRow{
|
||||
UserID: m.Uid,
|
||||
UserName: userNameOf(m),
|
||||
Account: ptrString(m.Account),
|
||||
OrgID: m.OrgID,
|
||||
Status: -1,
|
||||
}
|
||||
if o := orgByID[m.OrgID]; o != nil {
|
||||
row.OrgName = o.OrgName
|
||||
}
|
||||
if r := reports[m.Uid]; r != nil {
|
||||
row.ReportID = r.ID
|
||||
row.Status = r.Status
|
||||
row.Submitted = r.Status == 1
|
||||
row.SubmitTime = formatTime(r.SubmitTime)
|
||||
row.HasIssue = strings.TrimSpace(r.Issues) != ""
|
||||
}
|
||||
list = append(list, row)
|
||||
// 列表分页:只对已提交的日报分页。
|
||||
total := len(rows)
|
||||
start := (q.Page - 1) * q.PageSize
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
return &TeamResult{Date: date, List: list, Total: total, Stats: stats}, nil
|
||||
end := start + q.PageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
return &TeamResult{Date: date, List: rows[start:end], Total: int64(total), Stats: stats}, nil
|
||||
}
|
||||
|
||||
// buildTeamRow 组装一条团队日报行;摘要取日报正文。
|
||||
func buildTeamRow(r *models.OaDailyReport, orgID uint64, userName, account, orgName string) TeamRow {
|
||||
return TeamRow{
|
||||
UserID: r.UserID,
|
||||
UserName: userName,
|
||||
Account: account,
|
||||
OrgID: orgID,
|
||||
OrgName: orgName,
|
||||
Submitted: true,
|
||||
ReportID: r.ID,
|
||||
Status: r.Status,
|
||||
SubmitTime: formatTime(r.SubmitTime),
|
||||
HasIssue: strings.TrimSpace(r.Issues) != "",
|
||||
Summary: reportSummary(r),
|
||||
}
|
||||
}
|
||||
|
||||
// reportSummary 生成团队列表用的内容摘要(优先今日完成),超长截断。
|
||||
func reportSummary(r *models.OaDailyReport) string {
|
||||
for _, text := range []string{r.TodayDone, r.TomorrowPlan, r.Issues} {
|
||||
s := strings.TrimSpace(text)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) > 60 {
|
||||
return string(runes[:60]) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// matchTeamKeyword 按姓名/账号模糊匹配团队筛选项。
|
||||
func matchTeamKeyword(name, account, kw string) bool {
|
||||
kw = strings.ToLower(kw)
|
||||
return strings.Contains(strings.ToLower(name), kw) ||
|
||||
strings.Contains(strings.ToLower(account), kw)
|
||||
}
|
||||
|
||||
// TeamOrgs 返回团队筛选可选部门;管理员为全租户,主管仅自己管辖的部门子树。
|
||||
|
||||
@@ -30,6 +30,18 @@ export function fetchMyDailyReports(params = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的日报近期热力图(默认含本月往前 3 个月)
|
||||
* @param {{months?:number}} params
|
||||
* @returns {{start_date:string,end_date:string,total:number,submitted:number,days:Array<{date:string,status:number,id:number}>}}
|
||||
*/
|
||||
export function fetchMyDailyReportCalendar(params = {}) {
|
||||
return request({
|
||||
url: '/api/oa/dailyreport/calendar',
|
||||
data: { months: params.months || 3 }
|
||||
})
|
||||
}
|
||||
|
||||
/** 日报详情:{ report, comments, can_edit, can_delete, can_comment } */
|
||||
export function fetchDailyReportDetail(id) {
|
||||
return request({ url: `/api/oa/dailyreport/detail/${id}` })
|
||||
@@ -58,6 +70,11 @@ export function deleteDailyReport(id) {
|
||||
return request({ url: `/api/oa/dailyreport/delete/${id}`, method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** 撤销提交:已提交日报退回草稿,本次提交内容写入修改痕迹,需重新提交 */
|
||||
export function withdrawDailyReport(id) {
|
||||
return request({ url: `/api/oa/dailyreport/withdraw/${id}`, method: 'POST' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 团队日报汇总
|
||||
* @param {{date?:string,orgId?:number,keyword?:string,page?:number,pageSize?:number}} params
|
||||
|
||||
@@ -25,6 +25,13 @@
|
||||
</view>
|
||||
<view class="head-actions">
|
||||
<text v-if="detail.can_edit" class="action-link" @tap="openEdit">编辑</text>
|
||||
<text
|
||||
v-if="detail.can_withdraw"
|
||||
class="action-link is-warning"
|
||||
@tap="handleWithdraw"
|
||||
>
|
||||
撤销提交
|
||||
</text>
|
||||
<text v-if="detail.can_delete" class="action-link is-danger" @tap="handleDelete">
|
||||
删除
|
||||
</text>
|
||||
@@ -138,6 +145,7 @@ import { computed, ref } from 'vue'
|
||||
import { onLoad, onShow } from '@dcloudio/uni-app'
|
||||
import {
|
||||
fetchDailyReportDetail,
|
||||
withdrawDailyReport,
|
||||
addDailyReportComment,
|
||||
deleteDailyReportComment,
|
||||
deleteDailyReport
|
||||
@@ -183,6 +191,24 @@ function openEdit() {
|
||||
})
|
||||
}
|
||||
|
||||
// 撤销提交:退回草稿并留存修改痕迹,改完需重新提交
|
||||
function handleWithdraw() {
|
||||
uni.showModal({
|
||||
title: '撤销提交',
|
||||
content: '撤销后日报退回草稿,本次提交的内容会留痕,修改完成后需重新提交',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
try {
|
||||
await withdrawDailyReport(reportId.value)
|
||||
uni.showToast({ title: '已撤销提交', icon: 'success' })
|
||||
await loadDetail()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e?.message || '撤销提交失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function startReply(item) {
|
||||
replyTo.value = { id: item.id, user_name: item.user_name }
|
||||
}
|
||||
@@ -347,6 +373,10 @@ function formatSize(size) {
|
||||
&.is-danger {
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
&.is-warning {
|
||||
color: $color-warning;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-time {
|
||||
|
||||
@@ -147,6 +147,18 @@ async function loadDetail(id) {
|
||||
function applyDetail(data) {
|
||||
const report = data?.report
|
||||
if (!report) return
|
||||
// 已提交的日报已锁定:不进入编辑,引导到详情中撤销提交
|
||||
if (report.status === 1) {
|
||||
uni.showModal({
|
||||
title: '日报已提交',
|
||||
content: '已提交的日报已锁定,如需修改请先在详情中撤销提交',
|
||||
showCancel: false,
|
||||
success: () => {
|
||||
uni.redirectTo({ url: `/pages/oa/dailyreport/detail?id=${report.id}` })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
reportId.value = report.id
|
||||
reportDate.value = report.report_date
|
||||
form.todayDone = report.today_done || ''
|
||||
|
||||
@@ -47,6 +47,64 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="heatmap-card">
|
||||
<view class="heatmap-head">
|
||||
<view class="heatmap-title-wrap">
|
||||
<text class="heatmap-title">日报热力图</text>
|
||||
<text class="heatmap-sub">{{ calendarSubmitted }} 篇已发布 · 共 {{ calendarTotal }} 条</text>
|
||||
</view>
|
||||
<text class="heatmap-range">{{ calendarRangeText }}</text>
|
||||
</view>
|
||||
<view class="heatmap-legend">
|
||||
<view class="legend-item">
|
||||
<view class="legend-cell is-submitted"></view>
|
||||
<text class="legend-text">已提交</text>
|
||||
</view>
|
||||
<view class="legend-item">
|
||||
<view class="legend-cell is-draft"></view>
|
||||
<text class="legend-text">草稿</text>
|
||||
</view>
|
||||
<view class="legend-item">
|
||||
<view class="legend-cell is-none"></view>
|
||||
<text class="legend-text">未填写</text>
|
||||
</view>
|
||||
<view class="legend-item">
|
||||
<view class="legend-cell is-backfill">补</view>
|
||||
<text class="legend-text">可补写</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="calendarMonths.length" class="heatmap-months">
|
||||
<view v-for="m in calendarMonths" :key="m.key" class="heatmap-month">
|
||||
<view class="month-head">
|
||||
<text class="month-label">{{ m.label }}</text>
|
||||
<text class="month-count" :class="{ 'is-zero': !m.submitted }">
|
||||
{{ m.submitted }} 篇
|
||||
</text>
|
||||
</view>
|
||||
<view class="month-weeks">
|
||||
<view
|
||||
v-for="(week, wi) in m.weeks"
|
||||
:key="wi"
|
||||
class="week-col"
|
||||
>
|
||||
<view
|
||||
v-for="(cell, ci) in week.cells"
|
||||
:key="ci"
|
||||
class="heat-cell"
|
||||
:class="[
|
||||
cellClass(cell),
|
||||
{ 'is-today': cell && cell.date === today, 'is-backfill': cellBackfill(cell) }
|
||||
]"
|
||||
@tap="onCalendarCellTap(cell)"
|
||||
>
|
||||
<text v-if="cellBackfill(cell)" class="cell-pad">补</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="backfill-bar">
|
||||
<text class="backfill-label">补写往日</text>
|
||||
<picker
|
||||
@@ -58,7 +116,7 @@
|
||||
>
|
||||
<view class="date-chip">{{ backfillDate }}</view>
|
||||
</picker>
|
||||
<view class="backfill-btn" @tap="openBackfill">填写该日</view>
|
||||
<view class="backfill-btn" @tap="openBackfill">发布</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading && !mineList.length" class="state-box">
|
||||
@@ -142,7 +200,7 @@
|
||||
<u-empty
|
||||
v-else-if="!teamList.length"
|
||||
mode="list"
|
||||
text="暂无团队成员"
|
||||
text="当日暂无成员提交日报"
|
||||
:icon-size="80"
|
||||
/>
|
||||
<view v-else class="card-list">
|
||||
@@ -193,6 +251,7 @@ import { computed, ref } from 'vue'
|
||||
import { onShow, onReachBottom } from '@dcloudio/uni-app'
|
||||
import {
|
||||
fetchDailyReportProfile,
|
||||
fetchMyDailyReportCalendar,
|
||||
fetchMyDailyReports,
|
||||
fetchTeamDailyReports,
|
||||
fetchTeamDailyReportOrgs,
|
||||
@@ -209,6 +268,12 @@ const mineList = ref([])
|
||||
const minePage = ref(1)
|
||||
const hasMore = ref(false)
|
||||
|
||||
// 近期热力图(近三个月)
|
||||
const calendarDays = ref([])
|
||||
const calendarRange = ref({ start: '', end: '' })
|
||||
const calendarTotal = ref(0)
|
||||
const calendarSubmitted = ref(0)
|
||||
|
||||
const backfillDate = ref(today)
|
||||
|
||||
const teamDate = ref(today)
|
||||
@@ -235,6 +300,116 @@ const todayStatusClass = computed(() => {
|
||||
return 'is-red'
|
||||
})
|
||||
|
||||
const calendarMap = computed(() => {
|
||||
const map = new Map()
|
||||
calendarDays.value.forEach((day) => {
|
||||
// 后端 DATE 字段可能带出时间,统一截到 YYYY-MM-DD 再建索引
|
||||
const key = String(day?.date || '').slice(0, 10)
|
||||
if (key) map.set(key, day)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const calendarRangeText = computed(() => {
|
||||
const { start, end } = calendarRange.value
|
||||
return start && end ? `${start} 至 ${end}` : ''
|
||||
})
|
||||
|
||||
// 按区间生成热力图:每列一周(周一在上、周日在下),月内补空格,每月固定 6 周
|
||||
const calendarMonths = computed(() => {
|
||||
const map = calendarMap.value
|
||||
const { start, end } = calendarRange.value
|
||||
if (!start || !end) return []
|
||||
const [startYear, startMonth] = start.split('-').map(Number)
|
||||
const [endYear, endMonth] = end.split('-').map(Number)
|
||||
const months = []
|
||||
let year = startYear
|
||||
let month = startMonth
|
||||
while (year < endYear || (year === endYear && month <= endMonth)) {
|
||||
const prefix = `${year}-${pad2(month)}`
|
||||
const daysInMonth = new Date(year, month, 0).getDate()
|
||||
const offset = (new Date(year, month - 1, 1).getDay() + 6) % 7
|
||||
const cells = []
|
||||
for (let i = 0; i < offset; i++) cells.push(null)
|
||||
let total = 0
|
||||
let submitted = 0
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const date = `${prefix}-${pad2(d)}`
|
||||
const record = map.get(date)
|
||||
if (record) {
|
||||
total++
|
||||
if (record.status === 1) submitted++
|
||||
}
|
||||
cells.push({ date, record, isFuture: date > today })
|
||||
}
|
||||
while (cells.length < 42) cells.push(null)
|
||||
const weeks = []
|
||||
for (let i = 0; i < 42; i += 7) {
|
||||
const weekCells = cells.slice(i, i + 7)
|
||||
weeks.push({ cells: weekCells, pad: weekCells.every((c) => !c) })
|
||||
}
|
||||
months.push({ key: prefix, label: `${month} 月`, weeks, total, submitted })
|
||||
month++
|
||||
if (month > 12) {
|
||||
month = 1
|
||||
year++
|
||||
}
|
||||
}
|
||||
return months
|
||||
})
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
async function loadCalendar() {
|
||||
try {
|
||||
const data = await fetchMyDailyReportCalendar({ months: 3 })
|
||||
calendarDays.value = data?.days || []
|
||||
calendarRange.value = {
|
||||
start: String(data?.start_date || '').slice(0, 10),
|
||||
end: String(data?.end_date || '').slice(0, 10)
|
||||
}
|
||||
calendarTotal.value = data?.total || 0
|
||||
calendarSubmitted.value = data?.submitted || 0
|
||||
} catch (e) {
|
||||
// 热力图加载失败不阻断列表展示
|
||||
}
|
||||
}
|
||||
|
||||
function inWindow(date) {
|
||||
const start = profile.value.window_start || summary.value.window_start
|
||||
const end = profile.value.window_end || summary.value.window_end
|
||||
if (!start || !end) return date === today
|
||||
return date >= start && date <= end
|
||||
}
|
||||
|
||||
function cellClass(cell) {
|
||||
if (!cell) return 'is-empty'
|
||||
if (cell.record?.status === 1) return 'is-submitted'
|
||||
if (cell.record?.status === 0) return 'is-draft'
|
||||
return cell.isFuture ? 'is-future' : 'is-none'
|
||||
}
|
||||
|
||||
// 补写窗口内、尚未写日报的日期标注「补」,点击直接进入补写
|
||||
function cellBackfill(cell) {
|
||||
if (!cell || cell.isFuture || cell.record) return false
|
||||
return inWindow(cell.date)
|
||||
}
|
||||
|
||||
function onCalendarCellTap(cell) {
|
||||
if (!cell || cell.isFuture) return
|
||||
if (cell.record?.id) {
|
||||
openDetail(cell.record.id)
|
||||
return
|
||||
}
|
||||
if (cellBackfill(cell)) {
|
||||
uni.navigateTo({ url: `/pages/oa/dailyreport/edit?date=${cell.date}` })
|
||||
return
|
||||
}
|
||||
uni.showToast({ title: '该日期没有日报,且已超出补写窗口', icon: 'none' })
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
if (!isLoggedIn()) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
@@ -254,6 +429,7 @@ onReachBottom(() => {
|
||||
async function reload() {
|
||||
await loadProfile()
|
||||
if (activeTab.value === 'mine') {
|
||||
loadCalendar()
|
||||
await loadMine(true)
|
||||
} else {
|
||||
if (!orgOptions.value.length) await loadTeamOrgs()
|
||||
@@ -300,6 +476,7 @@ function switchTab(name) {
|
||||
if (activeTab.value === name) return
|
||||
activeTab.value = name
|
||||
if (name === 'mine') {
|
||||
loadCalendar()
|
||||
loadMine(true)
|
||||
} else {
|
||||
if (!orgOptions.value.length) loadTeamOrgs()
|
||||
@@ -318,6 +495,12 @@ function openBackfill() {
|
||||
function writeToday() {
|
||||
const id = summary.value.today_report_id
|
||||
if (id) {
|
||||
// 已提交的日报已锁定,直接进详情,可在详情中撤销提交
|
||||
if (summary.value.today_status === 1) {
|
||||
uni.showToast({ title: '今日日报已提交并锁定,可先在详情中撤销提交', icon: 'none' })
|
||||
openDetail(id)
|
||||
return
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/oa/dailyreport/edit?id=${id}&date=${today}` })
|
||||
} else {
|
||||
uni.navigateTo({ url: `/pages/oa/dailyreport/edit?date=${today}` })
|
||||
@@ -547,6 +730,186 @@ function openTeamReport(item) {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.heatmap-card {
|
||||
margin-top: 20rpx;
|
||||
padding: 22rpx 24rpx 24rpx;
|
||||
@include card;
|
||||
}
|
||||
|
||||
.heatmap-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.heatmap-title {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.heatmap-sub {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
font-size: 20rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.heatmap-range {
|
||||
flex-shrink: 0;
|
||||
font-size: 20rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.heatmap-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 18rpx;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.legend-text {
|
||||
font-size: 20rpx;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.legend-cell {
|
||||
width: 18rpx;
|
||||
height: 18rpx;
|
||||
box-sizing: border-box;
|
||||
border-radius: 4rpx;
|
||||
background: #eef1f6;
|
||||
|
||||
&.is-submitted {
|
||||
background: $color-success;
|
||||
}
|
||||
|
||||
&.is-draft {
|
||||
background: $color-warning;
|
||||
}
|
||||
|
||||
&.is-backfill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $color-primary-bg;
|
||||
border: 1rpx dashed $color-primary;
|
||||
font-size: 14rpx;
|
||||
line-height: 1;
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.heatmap-months {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.heatmap-month {
|
||||
flex: 0 0 auto;
|
||||
|
||||
& + & {
|
||||
padding-left: 8rpx;
|
||||
border-left: 1rpx dashed $color-border;
|
||||
}
|
||||
}
|
||||
|
||||
.month-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.month-count {
|
||||
font-size: 18rpx;
|
||||
color: $color-primary;
|
||||
|
||||
&.is-zero {
|
||||
color: $color-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.month-weeks {
|
||||
display: flex;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.week-col {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.heat-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
box-sizing: border-box;
|
||||
border: 1rpx solid transparent;
|
||||
border-radius: 6rpx;
|
||||
background: #eef1f6;
|
||||
|
||||
&.is-empty,
|
||||
&.is-future {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&.is-future {
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
&.is-submitted {
|
||||
background: $color-success;
|
||||
}
|
||||
|
||||
&.is-draft {
|
||||
background: $color-warning;
|
||||
}
|
||||
|
||||
&.is-none {
|
||||
background: #eef1f6;
|
||||
}
|
||||
|
||||
&.is-backfill {
|
||||
background: $color-primary-bg;
|
||||
border-color: $color-primary;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
&.is-today {
|
||||
box-shadow: 0 0 0 2rpx $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-pad {
|
||||
font-size: 19rpx;
|
||||
line-height: 1;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user