From 4337c456745e59f1b8c9f81abd39045b4cc4fcf8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 4 Sep 2026 16:25:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AC=AC9=E7=AB=A0=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=E4=B8=89=E5=A4=84=E9=97=AE=E9=A2=98=EF=BC=9A?= =?UTF-8?q?=E6=97=A5=E6=8A=A5=E5=88=97=E8=A1=A8=E9=94=99=E8=AF=AF=E5=90=9E?= =?UTF-8?q?=E6=B2=A1/=E6=9C=88=E6=8A=A5=E9=87=8D=E6=96=B0=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E6=9C=AA=E6=8E=A5=E7=BA=BF/=E4=BF=A1=E7=AE=B1?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E8=B6=85=E9=99=90=EF=BC=88=E5=A4=8D=E6=B5=8B?= =?UTF-8?q?=E5=8F=91=E7=8E=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. DailyReportList 的 load() 把请求失败(网络错误、403等)整个 catch 吞掉,只留一个 空/旧列表,跟"今天真的没有日报"是同一种"暂无日报"空态展示——用户没法分辨到底是 没数据还是加载失败了。补上 loadError + onToast,参照 DailyReportDetail/ MonthlyReportList 已有的同款处理方式。 2. reopenMonthlyReport 早就在 im-api.js 里写好但零调用点:MonthlyReportDetail 一直 没有"重新编辑"入口,已提交的月报只能干看着改不了。核对 im_be 源码发现原函数还多 传了一个 reason 字段——serveIMMonthlyReportReopen 用 DisallowUnknownFields 只认 {version},真调用起来会先 400。同时确认这是纯自助操作(SQL 的 UPDATE 条件里 author_id=调用者自己),不存在"管理员帮别人退回"这回事,去掉多余字段后在 MonthlyReportDetail 给本人的已提交月报加上这个入口。 接上之后在真实账号上跑通整条链路时,又炸出一个新竞态:reopen 成功后跳转编辑器, 编辑器用同一个月份的模块级缓存做"再次进入不清空"的初始渲染,但缓存里存的是 reopen 之前的旧 version——首次自动保存草稿用这个旧 version 发出去,服务端真实 返回 409,界面弹出"请检查网络后重新编辑"(其实跟网络毫不相干)。加了个 hydrated 门槛:只有当组件自己这次挂载真正拿到新鲜数据确认过之后,才允许自动保存的 effect 生效;没有旧缓存要甄别的场景(绝大多数情况)行为完全不变。修复前后各跑 了一次完整的"提交→查看详情→重新编辑",前者复现了 409,后者干净无报错。 3. 总经理信箱的附件上传一直没有数量上限——真实 config.attachment_policy.max_files (联调确认默认租户是5,不是后端Go默认结构体里那个从未在这个环境出现过的 max_count)从来没被读取过,界面上能一直点"上传文件"加到任意多个。补上跟 意见反馈页一致的"数量/上限"展示与到达上限后禁用。 Co-Authored-By: Claude Opus 5 --- src/lib/im-api.js | 10 +++- src/views/workplace-collaboration-view.jsx | 58 +++++++++++++++++++--- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/src/lib/im-api.js b/src/lib/im-api.js index 7a10db89..3d74c3a0 100644 --- a/src/lib/im-api.js +++ b/src/lib/im-api.js @@ -695,8 +695,14 @@ export const getMonthlyReport = reportId => imGet('/reports/monthly/' + encodeUR export const updateMonthlyDraft = (reportId, workSummary, nextPlan, version, attachments = []) => imPut('/reports/monthly/' + encodeURIComponent(reportId) + '/draft', {work_summary: workSummary, next_plan: nextPlan, attachments, version}); export const submitMonthlyReport = (reportId, version) => imPost('/reports/monthly/' + encodeURIComponent(reportId) + '/submit', {version}); -export const reopenMonthlyReport = (reportId, version, reason = '补充内容') => - imPost('/reports/monthly/' + encodeURIComponent(reportId) + '/reopen', {version, reason}); +// server/im_http.go's serveIMMonthlyReportReopen decodes only {version} (DisallowUnknownFields +// rejects anything else) -- an earlier draft of this function sent an extra `reason` field that +// would 400 MALFORMED_REQUEST on every real call. Also: the store layer's UPDATE has +// `WHERE ... AND author_id=?` keyed off the *caller's* uid, so this only ever lets a report's own +// author reopen their own already-submitted report for further editing -- there is no +// manager-reopens-someone-else's-report capability on the backend. +export const reopenMonthlyReport = (reportId, version) => + imPost('/reports/monthly/' + encodeURIComponent(reportId) + '/reopen', {version}); // department_id is ignored here too -- statistics are always tenant-wide. export const getMonthlyStatistics = month => imGet('/reports/monthly/statistics' + qs({month})); export const getExecutiveMailboxConfig = () => imGet('/executive-mailbox/config'); diff --git a/src/views/workplace-collaboration-view.jsx b/src/views/workplace-collaboration-view.jsx index 42eac353..bf15c395 100644 --- a/src/views/workplace-collaboration-view.jsx +++ b/src/views/workplace-collaboration-view.jsx @@ -9,7 +9,7 @@ import { getOrganizationDepartments, getOrganizationDepartmentMembers, getOrganizationMembers, searchOrganization, getMeetings, createMeeting, getMeeting, cancelMeeting, sendMeetingReminders, respondMeeting, getDailyReports, createDailyReport, getDailyReport, updateDailyDraft, submitDailyReport, getDailyStatistics, sendDailyReminders, - getMonthlyReports, createMonthlyReport, getMonthlyReport, updateMonthlyDraft, submitMonthlyReport, getMonthlyStatistics, + getMonthlyReports, createMonthlyReport, getMonthlyReport, updateMonthlyDraft, submitMonthlyReport, reopenMonthlyReport, getMonthlyStatistics, getExecutiveMailboxConfig, submitExecutiveMailboxMessage } from '../lib/im-api.js'; import { useCachedData, readCachedData, writeCachedData } from '../lib/use-cached-data.js'; @@ -677,17 +677,24 @@ function DailyReportList({team, myUserId, onToast}) { // Seed from cache so re-entering "我的日报"/"团队统计" (a real user flow: check the list, go fill // in today's report, come back) doesn't blank the list out while the identical request re-runs. const [reports, setReports] = React.useState(() => readCachedData(cacheKey) || null); + const [loadError, setLoadError] = React.useState(false); const [query, setQuery] = React.useState(''); const [stats, setStats] = React.useState(() => (team && readCachedData(statsCacheKey)) || null); const ids = (reports || []).map(r => r.author_id); const names = useUserNames(ids, myUserId); const load = () => { + setLoadError(false); + // Previously swallowed every failure (network error, 403, ...) into a bare empty/stale list -- + // indistinguishable from "genuinely no reports today" (which also renders as an empty list). + // A real failure now surfaces via toast + loadError so the list can say "加载失败" instead of + // silently implying "暂无日报", matching how DailyReportDetail/MonthlyReportList already report + // their own load failures. getDailyReports({scope: team ? 'managed' : 'mine'}).then(d => { const list = d.reports || d.items || []; writeCachedData(cacheKey, list); setReports(list); - }).catch(() => setReports(prev => prev || [])); + }).catch(err => { onToast(err.message || '日报列表加载失败'); setLoadError(true); setReports(prev => prev || []); }); if (team) getDailyStatistics(toDateInputValue(new Date())).then(s => { writeCachedData(statsCacheKey, s); setStats(s); }).catch(() => {}); }; React.useEffect(load, [team]); @@ -724,7 +731,7 @@ function DailyReportList({team, myUserId, onToast}) { : null}
{reports == null ?

加载中…

: - filtered.length === 0 ?

暂无日报

: + filtered.length === 0 ?

{loadError ? '日报列表加载失败' : '暂无日报'}

: filtered.map(r => )}
@@ -782,6 +789,14 @@ function MonthlyReportEditor({onToast}) { const setVersionBoth = v => { versionRef.current = v; setVersion(v); }; const [summary, setSummary] = React.useState(cached ? cached.summary : ''); const [plan, setPlan] = React.useState(cached ? cached.plan : ''); + // A cached seed can go stale the instant something outside this component's lifetime mutates the + // report's version -- concretely, MonthlyReportDetail's "重新编辑" (reopenMonthlyReport) does that, + // then navigates straight here. Live-reproduced: reopen -> land here with the cache still holding + // the pre-reopen version -> the autosave effect below fired on mount before this component's own + // fresh fetch (below) could correct versionRef, 409'd, and surfaced a scary (and wrong) "请检查 + // 网络" toast. Gate the very first autosave on that fresh confirmation whenever there was a cache + // to distrust; skip the wait entirely (unchanged fast path) when there wasn't one. + const [hydrated, setHydrated] = React.useState(!cached); const [saved, setSaved] = React.useState(false); const [saveError, setSaveError] = React.useState(false); const [submitting, setSubmitting] = React.useState(false); @@ -811,19 +826,20 @@ function MonthlyReportEditor({onToast}) { setReportId(r.report_id); setVersionBoth(r.version); setSummary(nextSummary); setPlan(nextPlan); writeCachedData(cacheKey, {reportId: r.report_id, version: r.version, summary: nextSummary, plan: nextPlan}); + setHydrated(true); }).catch(err => onToast(err.message || '创建月报失败')); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); React.useEffect(() => { - if (!reportId) return undefined; + if (!reportId || !hydrated) return undefined; setSaved(false); draftTimerRef.current = window.setTimeout(() => { draftTimerRef.current = null; saveDraft(summary, plan).catch(() => setSaveError(true)); }, 600); return () => window.clearTimeout(draftTimerRef.current); - }, [summary, plan, reportId, saveDraft]); + }, [summary, plan, reportId, hydrated, saveDraft]); const submit = () => { if (!summary.trim() || !plan.trim()) { onToast('请完成必填内容'); return; } @@ -891,6 +907,7 @@ function MonthlyReportDetail({hashParams, myUserId, onToast}) { const reportId = hashParams && hashParams.report_id; const [report, setReport] = React.useState(() => (reportId && readCachedData('monthly-report:' + reportId)) || null); const [loadError, setLoadError] = React.useState(false); + const [reopening, setReopening] = React.useState(false); const names = useUserNames(report ? [report.author_id] : [], myUserId); React.useEffect(() => { @@ -900,6 +917,18 @@ function MonthlyReportDetail({hashParams, myUserId, onToast}) { }, [reportId]); if (!report) return

{loadError ? '月报加载失败' : '加载中…'}

; + // reopenMonthlyReport is self-service only (server keys the UPDATE off the caller's own uid -- + // see the comment on the im-api.js export), so this button only ever makes sense for the report's + // own author looking at their own already-submitted report, never a manager viewing someone else's. + const canReopen = report.author_id === myUserId && report.status === 'submitted'; + const reopen = () => { + setReopening(true); + reopenMonthlyReport(report.report_id, report.version) + .then(() => { onToast('已重新打开,可继续编辑'); navigate('monthly-report-editor'); }) + .catch(err => onToast(err.message || '重新打开失败')) + .finally(() => setReopening(false)); + }; + return
{names[report.author_id] || '…'}员工 @@ -907,6 +936,8 @@ function MonthlyReportDetail({hashParams, myUserId, onToast}) {
schedule{formatMonthLabel(report.month)}

本月工作总结

{report.work_summary || '未填写'}

下月工作计划

{report.next_plan || '未填写'}

+ {canReopen ?
: null}
; } @@ -936,11 +967,22 @@ function ExecutiveMailbox({tinode, noticeOnly, onToast}) { .catch(() => setConfig(prev => prev || {enabled: false})); }, []); - const pickFile = () => fileInputRef.current && fileInputRef.current.click(); + // Real tenant config uses attachment_policy.max_files (confirmed live -- matches the key the + // 意见反馈 config uses); the Go-side struct literal that only ever backs a tenant with no config + // row at all names it max_count instead, so check both rather than assuming a single unlimited + // upload button is fine. Previously nothing here capped the count at all. + const attachmentPolicy = (config && config.attachment_policy) || {}; + const maxFiles = attachmentPolicy.max_files || attachmentPolicy.max_count || 5; + + const pickFile = () => { + if (attachments.length >= maxFiles) { onToast(`最多上传 ${maxFiles} 个附件`); return; } + fileInputRef.current && fileInputRef.current.click(); + }; const handleFile = event => { const file = event.target.files && event.target.files[0]; event.target.value = ''; if (!file || !tinode) return; + if (attachments.length >= maxFiles) { onToast(`最多上传 ${maxFiles} 个附件`); return; } setUploading(true); uploadLargeFile(tinode, file).then(({fileId}) => { setAttachments(v => v.concat({fileId, name: file.name})); @@ -973,8 +1015,8 @@ function ExecutiveMailbox({tinode, noticeOnly, onToast}) {