修复第9章工作台三处问题:日报列表错误吞没/月报重新编辑未接线/信箱附件超限(复测发现)

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 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-04 16:25:05 +08:00
co-authored by Claude Opus 5
parent 3a78c40389
commit 4337c45674
2 changed files with 58 additions and 10 deletions
+8 -2
View File
@@ -695,8 +695,14 @@ export const getMonthlyReport = reportId => imGet('/reports/monthly/' + encodeUR
export const updateMonthlyDraft = (reportId, workSummary, nextPlan, version, attachments = []) => export const updateMonthlyDraft = (reportId, workSummary, nextPlan, version, attachments = []) =>
imPut('/reports/monthly/' + encodeURIComponent(reportId) + '/draft', {work_summary: workSummary, next_plan: nextPlan, attachments, version}); 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 submitMonthlyReport = (reportId, version) => imPost('/reports/monthly/' + encodeURIComponent(reportId) + '/submit', {version});
export const reopenMonthlyReport = (reportId, version, reason = '补充内容') => // server/im_http.go's serveIMMonthlyReportReopen decodes only {version} (DisallowUnknownFields
imPost('/reports/monthly/' + encodeURIComponent(reportId) + '/reopen', {version, reason}); // 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. // department_id is ignored here too -- statistics are always tenant-wide.
export const getMonthlyStatistics = month => imGet('/reports/monthly/statistics' + qs({month})); export const getMonthlyStatistics = month => imGet('/reports/monthly/statistics' + qs({month}));
export const getExecutiveMailboxConfig = () => imGet('/executive-mailbox/config'); export const getExecutiveMailboxConfig = () => imGet('/executive-mailbox/config');
+50 -8
View File
@@ -9,7 +9,7 @@ import {
getOrganizationDepartments, getOrganizationDepartmentMembers, getOrganizationMembers, searchOrganization, getOrganizationDepartments, getOrganizationDepartmentMembers, getOrganizationMembers, searchOrganization,
getMeetings, createMeeting, getMeeting, cancelMeeting, sendMeetingReminders, respondMeeting, getMeetings, createMeeting, getMeeting, cancelMeeting, sendMeetingReminders, respondMeeting,
getDailyReports, createDailyReport, getDailyReport, updateDailyDraft, submitDailyReport, getDailyStatistics, sendDailyReminders, getDailyReports, createDailyReport, getDailyReport, updateDailyDraft, submitDailyReport, getDailyStatistics, sendDailyReminders,
getMonthlyReports, createMonthlyReport, getMonthlyReport, updateMonthlyDraft, submitMonthlyReport, getMonthlyStatistics, getMonthlyReports, createMonthlyReport, getMonthlyReport, updateMonthlyDraft, submitMonthlyReport, reopenMonthlyReport, getMonthlyStatistics,
getExecutiveMailboxConfig, submitExecutiveMailboxMessage getExecutiveMailboxConfig, submitExecutiveMailboxMessage
} from '../lib/im-api.js'; } from '../lib/im-api.js';
import { useCachedData, readCachedData, writeCachedData } from '../lib/use-cached-data.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 // 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. // 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 [reports, setReports] = React.useState(() => readCachedData(cacheKey) || null);
const [loadError, setLoadError] = React.useState(false);
const [query, setQuery] = React.useState(''); const [query, setQuery] = React.useState('');
const [stats, setStats] = React.useState(() => (team && readCachedData(statsCacheKey)) || null); const [stats, setStats] = React.useState(() => (team && readCachedData(statsCacheKey)) || null);
const ids = (reports || []).map(r => r.author_id); const ids = (reports || []).map(r => r.author_id);
const names = useUserNames(ids, myUserId); const names = useUserNames(ids, myUserId);
const load = () => { 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 => { getDailyReports({scope: team ? 'managed' : 'mine'}).then(d => {
const list = d.reports || d.items || []; const list = d.reports || d.items || [];
writeCachedData(cacheKey, list); writeCachedData(cacheKey, list);
setReports(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(() => {}); if (team) getDailyStatistics(toDateInputValue(new Date())).then(s => { writeCachedData(statsCacheKey, s); setStats(s); }).catch(() => {});
}; };
React.useEffect(load, [team]); React.useEffect(load, [team]);
@@ -724,7 +731,7 @@ function DailyReportList({team, myUserId, onToast}) {
</nav> : null} </nav> : null}
<section className="report-cards"> <section className="report-cards">
{reports == null ? <p className="organization-loading">加载中…</p> : {reports == null ? <p className="organization-loading">加载中…</p> :
filtered.length === 0 ? <p className="organization-loading">暂无日报</p> : filtered.length === 0 ? <p className="organization-loading">{loadError ? '日报列表加载失败' : '暂无日报'}</p> :
filtered.map(r => <DailyReportCard key={r.report_id} report={r} names={names} team={team} />)} filtered.map(r => <DailyReportCard key={r.report_id} report={r} names={names} team={team} />)}
</section> </section>
</main> </main>
@@ -782,6 +789,14 @@ function MonthlyReportEditor({onToast}) {
const setVersionBoth = v => { versionRef.current = v; setVersion(v); }; const setVersionBoth = v => { versionRef.current = v; setVersion(v); };
const [summary, setSummary] = React.useState(cached ? cached.summary : ''); const [summary, setSummary] = React.useState(cached ? cached.summary : '');
const [plan, setPlan] = React.useState(cached ? cached.plan : ''); 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 [saved, setSaved] = React.useState(false);
const [saveError, setSaveError] = React.useState(false); const [saveError, setSaveError] = React.useState(false);
const [submitting, setSubmitting] = React.useState(false); const [submitting, setSubmitting] = React.useState(false);
@@ -811,19 +826,20 @@ function MonthlyReportEditor({onToast}) {
setReportId(r.report_id); setVersionBoth(r.version); setReportId(r.report_id); setVersionBoth(r.version);
setSummary(nextSummary); setPlan(nextPlan); setSummary(nextSummary); setPlan(nextPlan);
writeCachedData(cacheKey, {reportId: r.report_id, version: r.version, summary: nextSummary, plan: nextPlan}); writeCachedData(cacheKey, {reportId: r.report_id, version: r.version, summary: nextSummary, plan: nextPlan});
setHydrated(true);
}).catch(err => onToast(err.message || '创建月报失败')); }).catch(err => onToast(err.message || '创建月报失败'));
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
if (!reportId) return undefined; if (!reportId || !hydrated) return undefined;
setSaved(false); setSaved(false);
draftTimerRef.current = window.setTimeout(() => { draftTimerRef.current = window.setTimeout(() => {
draftTimerRef.current = null; draftTimerRef.current = null;
saveDraft(summary, plan).catch(() => setSaveError(true)); saveDraft(summary, plan).catch(() => setSaveError(true));
}, 600); }, 600);
return () => window.clearTimeout(draftTimerRef.current); return () => window.clearTimeout(draftTimerRef.current);
}, [summary, plan, reportId, saveDraft]); }, [summary, plan, reportId, hydrated, saveDraft]);
const submit = () => { const submit = () => {
if (!summary.trim() || !plan.trim()) { onToast('请完成必填内容'); return; } if (!summary.trim() || !plan.trim()) { onToast('请完成必填内容'); return; }
@@ -891,6 +907,7 @@ function MonthlyReportDetail({hashParams, myUserId, onToast}) {
const reportId = hashParams && hashParams.report_id; const reportId = hashParams && hashParams.report_id;
const [report, setReport] = React.useState(() => (reportId && readCachedData('monthly-report:' + reportId)) || null); const [report, setReport] = React.useState(() => (reportId && readCachedData('monthly-report:' + reportId)) || null);
const [loadError, setLoadError] = React.useState(false); const [loadError, setLoadError] = React.useState(false);
const [reopening, setReopening] = React.useState(false);
const names = useUserNames(report ? [report.author_id] : [], myUserId); const names = useUserNames(report ? [report.author_id] : [], myUserId);
React.useEffect(() => { React.useEffect(() => {
@@ -900,6 +917,18 @@ function MonthlyReportDetail({hashParams, myUserId, onToast}) {
}, [reportId]); }, [reportId]);
if (!report) return <div className="workplace-page"><Header title="查看详情" back="monthly-report-list" /><main><p className="organization-loading">{loadError ? '月报加载失败' : '加载中…'}</p></main></div>; if (!report) return <div className="workplace-page"><Header title="查看详情" back="monthly-report-list" /><main><p className="organization-loading">{loadError ? '月报加载失败' : '加载中…'}</p></main></div>;
// 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 <div className="workplace-page"><Header title="查看详情" back="monthly-report-list" /> return <div className="workplace-page"><Header title="查看详情" back="monthly-report-list" />
<main className="report-detail"> <main className="report-detail">
<section className="report-author"><span><strong>{names[report.author_id] || '…'}</strong><small className="role-badge">员工</small></span> <section className="report-author"><span><strong>{names[report.author_id] || '…'}</strong><small className="role-badge">员工</small></span>
@@ -907,6 +936,8 @@ function MonthlyReportDetail({hashParams, myUserId, onToast}) {
<section><small><Icon>schedule</Icon>{formatMonthLabel(report.month)}</small></section> <section><small><Icon>schedule</Icon>{formatMonthLabel(report.month)}</small></section>
<section><h3>本月工作总结</h3><p>{report.work_summary || '未填写'}</p></section> <section><h3>本月工作总结</h3><p>{report.work_summary || '未填写'}</p></section>
<section><h3>下月工作计划</h3><p>{report.next_plan || '未填写'}</p></section> <section><h3>下月工作计划</h3><p>{report.next_plan || '未填写'}</p></section>
{canReopen ? <section><button type="button" className="workplace-primary" disabled={reopening} onClick={reopen}>
{reopening ? '处理中…' : '重新编辑'}</button></section> : null}
</main> </main>
</div>; </div>;
} }
@@ -936,11 +967,22 @@ function ExecutiveMailbox({tinode, noticeOnly, onToast}) {
.catch(() => setConfig(prev => prev || {enabled: false})); .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 handleFile = event => {
const file = event.target.files && event.target.files[0]; const file = event.target.files && event.target.files[0];
event.target.value = ''; event.target.value = '';
if (!file || !tinode) return; if (!file || !tinode) return;
if (attachments.length >= maxFiles) { onToast(`最多上传 ${maxFiles} 个附件`); return; }
setUploading(true); setUploading(true);
uploadLargeFile(tinode, file).then(({fileId}) => { uploadLargeFile(tinode, file).then(({fileId}) => {
setAttachments(v => v.concat({fileId, name: file.name})); setAttachments(v => v.concat({fileId, name: file.name}));
@@ -973,8 +1015,8 @@ function ExecutiveMailbox({tinode, noticeOnly, onToast}) {
<label className="mailbox-content"><span>反馈内容</span> <label className="mailbox-content"><span>反馈内容</span>
<textarea value={content} maxLength={(config && config.max_length) || 1000} placeholder="请输入" onChange={e => setContent(e.target.value)} /> <textarea value={content} maxLength={(config && config.max_length) || 1000} placeholder="请输入" onChange={e => setContent(e.target.value)} />
<small>{content.length}/{(config && config.max_length) || 1000}</small></label> <small>{content.length}/{(config && config.max_length) || 1000}</small></label>
<span>附件</span> <span>附件({attachments.length}/{maxFiles})</span>
<button type="button" className="mailbox-upload" onClick={pickFile} disabled={uploading}> <button type="button" className="mailbox-upload" onClick={pickFile} disabled={uploading || attachments.length >= maxFiles}>
<Icon>{uploading ? 'hourglass_top' : 'upload_file'}</Icon> <Icon>{uploading ? 'hourglass_top' : 'upload_file'}</Icon>
<span>{uploading ? '上传中…' : '上传文件'}</span> <span>{uploading ? '上传中…' : '上传文件'}</span>
</button> </button>