修复第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:
co-authored by
Claude Opus 5
parent
3a78c40389
commit
4337c45674
+8
-2
@@ -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');
|
||||
|
||||
@@ -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}) {
|
||||
</nav> : null}
|
||||
<section className="report-cards">
|
||||
{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} />)}
|
||||
</section>
|
||||
</main>
|
||||
@@ -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 <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" />
|
||||
<main className="report-detail">
|
||||
<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><h3>本月工作总结</h3><p>{report.work_summary || '未填写'}</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>
|
||||
</div>;
|
||||
}
|
||||
@@ -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}) {
|
||||
<label className="mailbox-content"><span>反馈内容</span>
|
||||
<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>
|
||||
<span>附件</span>
|
||||
<button type="button" className="mailbox-upload" onClick={pickFile} disabled={uploading}>
|
||||
<span>附件({attachments.length}/{maxFiles})</span>
|
||||
<button type="button" className="mailbox-upload" onClick={pickFile} disabled={uploading || attachments.length >= maxFiles}>
|
||||
<Icon>{uploading ? 'hourglass_top' : 'upload_file'}</Icon>
|
||||
<span>{uploading ? '上传中…' : '上传文件'}</span>
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user