修复第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 = []) =>
|
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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user