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