From 51fd90fc5157f968b8b59eae97473c8a22491a73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=AB=E5=9C=B0=E5=83=A7?= <357099073@qq.com> Date: Thu, 22 Oct 2026 00:27:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=97=A5=E6=8A=A5=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/components.d.ts | 95 --- backend/src/api/oaDailyReport.js | 22 +- .../oa/dailyreport/components/ReportView.vue | 173 ++++- .../src/views/apps/oa/dailyreport/index.vue | 696 ++++++++++++++++-- go/controllers/app_oa_dailyreport.go | 35 + go/controllers/backend_oa_dailyreport.go | 35 + go/docs/sql/create_oa_dailyreport_history.sql | 38 + go/models/init.go | 2 + go/models/oa_daily_report.go | 48 +- go/routers/api/api.go | 2 + go/routers/backend/backend.go | 2 + go/services/dailyreport/report.go | 287 +++++++- go/services/dailyreport/team.go | 163 ++-- uniapp/api/oaDailyReport.js | 17 + uniapp/pages/oa/dailyreport/detail.vue | 30 + uniapp/pages/oa/dailyreport/edit.vue | 12 + uniapp/pages/oa/dailyreport/index.vue | 367 ++++++++- 17 files changed, 1767 insertions(+), 257 deletions(-) create mode 100644 go/docs/sql/create_oa_dailyreport_history.sql diff --git a/backend/components.d.ts b/backend/components.d.ts index 75eea65..e69de29 100644 --- a/backend/components.d.ts +++ b/backend/components.d.ts @@ -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'] - } -} diff --git a/backend/src/api/oaDailyReport.js b/backend/src/api/oaDailyReport.js index 4ea62ef..2d2dd57 100644 --- a/backend/src/api/oaDailyReport.js +++ b/backend/src/api/oaDailyReport.js @@ -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}`, diff --git a/backend/src/views/apps/oa/dailyreport/components/ReportView.vue b/backend/src/views/apps/oa/dailyreport/components/ReportView.vue index 025516b..539b57e 100644 --- a/backend/src/views/apps/oa/dailyreport/components/ReportView.vue +++ b/backend/src/views/apps/oa/dailyreport/components/ReportView.vue @@ -25,7 +25,25 @@
+ 提交 + + + 撤销提交 + +
+
+ 日报已提交并锁定;超出补写窗口后不再支持撤销提交 +
+

今日完成

{{ detail.report.today_done || '—' }}
@@ -74,6 +99,29 @@
+
+

修改痕迹 {{ detail.histories.length }}

+
+
+
+ + {{ h.action === 2 ? '重新提交' : '撤销提交' }} + + {{ h.create_time }} +
+
+
今日完成:{{ h.today_done || '—' }}
+
明日计划:{{ h.tomorrow_plan || '—' }}
+
问题与建议:{{ h.issues || '—' }}
+
+
+
+
+

评论 {{ detail.comments.length }}

@@ -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); diff --git a/backend/src/views/apps/oa/dailyreport/index.vue b/backend/src/views/apps/oa/dailyreport/index.vue index 574ad0e..533dd24 100644 --- a/backend/src/views/apps/oa/dailyreport/index.vue +++ b/backend/src/views/apps/oa/dailyreport/index.vue @@ -16,61 +16,118 @@ -
-
-
本月日报
-
{{ summary.month_total || 0 }}
-
条记录
-
-
-
本月已提交
-
{{ summary.month_submitted || 0 }}
-
条已提交
-
-
-
今日状态
-
- {{ todayStatusText }} +
+
+
+
+
本月日报
+
{{ summary.month_total || 0 }}
+
条记录
+
+
+
本月已提交
+
{{ summary.month_submitted || 0 }}
+
条已提交
+
+
+
今日状态
+
+ {{ todayStatusText }} +
+
{{ summary.window_end || todayStr() }}
+
+
+
补写窗口
+
+ {{ summary.window_start || '—' }} +
+
至 {{ summary.window_end || '—' }},共 {{ profile.backfill_days || 7 }} 天
+
-
{{ summary.window_end || todayStr() }}
-
-
-
补写窗口
-
- {{ summary.window_start || '—' }} + +
+
+ + 查询 + 重置 +
+
+ 补写往日 + + 发布 +
-
至 {{ summary.window_end || '—' }},共 {{ profile.backfill_days || 7 }} 天
-
-
- - - 查询 - - 重置 - -
- 补写往日 - - 填写该日 +
+
+
+
+
日报热力图
+
+ {{ calendarSubmitted }} 篇已发布 · 共 {{ calendarTotal }} 条 +
+
+
+
@@ -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); + } } diff --git a/go/controllers/app_oa_dailyreport.go b/go/controllers/app_oa_dailyreport.go index 974aea1..cd6997e 100644 --- a/go/controllers/app_oa_dailyreport.go +++ b/go/controllers/app_oa_dailyreport.go @@ -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() diff --git a/go/controllers/backend_oa_dailyreport.go b/go/controllers/backend_oa_dailyreport.go index 8742f60..f002ddb 100644 --- a/go/controllers/backend_oa_dailyreport.go +++ b/go/controllers/backend_oa_dailyreport.go @@ -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() { diff --git a/go/docs/sql/create_oa_dailyreport_history.sql b/go/docs/sql/create_oa_dailyreport_history.sql new file mode 100644 index 0000000..79dc18c --- /dev/null +++ b/go/docs/sql/create_oa_dailyreport_history.sql @@ -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-工作日报修改痕迹'; diff --git a/go/models/init.go b/go/models/init.go index 555d8b7..ea656fe 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -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), diff --git a/go/models/oa_daily_report.go b/go/models/oa_daily_report.go index b3e354a..ff74564 100644 --- a/go/models/oa_daily_report.go +++ b/go/models/oa_daily_report.go @@ -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 表示直接评论日报。 diff --git a/go/routers/api/api.go b/go/routers/api/api.go index d85783c..bdb5137 100644 --- a/go/routers/api/api.go +++ b/go/routers/api/api.go @@ -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") diff --git a/go/routers/backend/backend.go b/go/routers/backend/backend.go index aba7c7d..cfb8601 100644 --- a/go/routers/backend/backend.go +++ b/go/routers/backend/backend.go @@ -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") diff --git a/go/services/dailyreport/report.go b/go/services/dailyreport/report.go index 8ea2971..1c8a0a4 100644 --- a/go/services/dailyreport/report.go +++ b/go/services/dailyreport/report.go @@ -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) != "" { diff --git a/go/services/dailyreport/team.go b/go/services/dailyreport/team.go index 7c6abc0..5473ff8 100644 --- a/go/services/dailyreport/team.go +++ b/go/services/dailyreport/team.go @@ -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 返回团队筛选可选部门;管理员为全租户,主管仅自己管辖的部门子树。 diff --git a/uniapp/api/oaDailyReport.js b/uniapp/api/oaDailyReport.js index efe48e2..f4e0b59 100644 --- a/uniapp/api/oaDailyReport.js +++ b/uniapp/api/oaDailyReport.js @@ -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 diff --git a/uniapp/pages/oa/dailyreport/detail.vue b/uniapp/pages/oa/dailyreport/detail.vue index c3e0efd..6063d4e 100644 --- a/uniapp/pages/oa/dailyreport/detail.vue +++ b/uniapp/pages/oa/dailyreport/detail.vue @@ -25,6 +25,13 @@ 编辑 + + 撤销提交 + 删除 @@ -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 { diff --git a/uniapp/pages/oa/dailyreport/edit.vue b/uniapp/pages/oa/dailyreport/edit.vue index bb2a803..cf952ad 100644 --- a/uniapp/pages/oa/dailyreport/edit.vue +++ b/uniapp/pages/oa/dailyreport/edit.vue @@ -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 || '' diff --git a/uniapp/pages/oa/dailyreport/index.vue b/uniapp/pages/oa/dailyreport/index.vue index 1a10136..7577723 100644 --- a/uniapp/pages/oa/dailyreport/index.vue +++ b/uniapp/pages/oa/dailyreport/index.vue @@ -47,6 +47,64 @@ + + + + 日报热力图 + {{ calendarSubmitted }} 篇已发布 · 共 {{ calendarTotal }} 条 + + {{ calendarRangeText }} + + + + + 已提交 + + + + 草稿 + + + + 未填写 + + + 补 + 可补写 + + + + + + {{ m.label }} + + {{ m.submitted }} 篇 + + + + + + 补 + + + + + + + 补写往日 {{ backfillDate }} - 填写该日 + 发布 @@ -142,7 +200,7 @@ @@ -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; }