优化日报模块

This commit is contained in:
hero920103 committed 2026-10-22 00:27:26 +08:00
1 parent 79d5ae6b66
commit 51fd90fc51
17 files changed
+1767 -257

No files matched your search

-95
View File
@@ -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 -1
View File
@@ -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);
+629 -67
View File
@@ -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>
+35
View File
@@ -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()
+35
View File
@@ -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-工作日报修改痕迹';
+2
View File
@@ -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),
+37 -11
View File
@@ -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 表示直接评论日报。
+2
View File
@@ -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")
+2
View File
@@ -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")
+257 -30
View File
@@ -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
View File
@@ -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 返回团队筛选可选部门;管理员为全租户,主管仅自己管辖的部门子树。
+17
View File
@@ -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
+30
View File
@@ -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 {
+12
View File
@@ -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 || ''
+365 -2
View File
@@ -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;
}