import React from 'react'; import ContactList from '../widgets/contact-list.jsx'; import HashNavigation from '../lib/navigation.js'; import { uploadLargeFile, getMe, getFileUrl, getUserProfile, getWorkbench, getOrganizationDepartments, getOrganizationDepartmentMembers, getOrganizationMembers, searchOrganization, getMeetings, createMeeting, getMeeting, cancelMeeting, sendMeetingReminders, respondMeeting, getDailyReports, createDailyReport, getDailyReport, updateDailyDraft, submitDailyReport, getDailyStatistics, sendDailyReminders, getMonthlyReports, createMonthlyReport, getMonthlyReport, updateMonthlyDraft, submitMonthlyReport, reopenMonthlyReport, getMonthlyStatistics, getExecutiveMailboxConfig, submitExecutiveMailboxMessage } from '../lib/im-api.js'; import { useCachedData, readCachedData, writeCachedData } from '../lib/use-cached-data.js'; const navigate = (view, params) => { let hash = HashNavigation.setUrlSidePanel(window.location.hash, view); Object.entries(params || {}).forEach(([key, value]) => { hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key); }); HashNavigation.navigateTo(hash); }; export const WORKPLACE_COLLABORATION_VIEWS = [ 'workbench', 'organization-directory', 'meeting-organizer', 'meeting-attendee', 'meeting-create', 'meeting-detail', 'daily-report-editor', 'daily-report-mine', 'daily-report-team', 'daily-report-detail', 'monthly-report-editor', 'monthly-report-mine', 'monthly-report-team', 'monthly-report-detail', 'executive-mailbox', 'executive-mailbox-notice' ]; function Icon({children}) { return ; } function Header({title, back = 'workbench', action, actionLabel, onAction, actionDisabled}) { return

{title}

{action ? : }
; } function Toast({message, onDone}) { React.useEffect(() => { if (!message) return undefined; const timer = window.setTimeout(onDone, 2100); return () => window.clearTimeout(timer); }, [message, onDone]); return message ?
{message}
: null; } // "6月25日 周四(今天)" style used throughout this chapter's reference screenshots. const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; function formatMeetingDate(d) { const now = new Date(); const dayDiff = Math.round((new Date(d.getFullYear(), d.getMonth(), d.getDate()) - new Date(now.getFullYear(), now.getMonth(), now.getDate())) / 86_400_000); const suffix = dayDiff === 0 ? '(今天)' : dayDiff === 1 ? '(明天)' : dayDiff === -1 ? '(昨天)' : ''; return `${d.getMonth() + 1}月${d.getDate()}日 ${WEEKDAYS[d.getDay()]}${suffix}`; } function formatTime(d) { return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; } // toISOString() is always UTC -- for any timezone ahead of UTC (e.g. China, UTC+8) this returned // the *previous* calendar day for the first several hours of every local day (00:00-08:00 local // still reads as "yesterday" in UTC), so opening the daily report editor in the early morning // loaded/redirected to yesterday's already-submitted report instead of starting today's. Match // toMonthInputValue's already-correct local-getters approach instead. function toDateInputValue(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } // Resolves a bare/prefixed user id to a display name, cached -- organization member/meeting // participant ids returned by different Ch.9 endpoints aren't consistently confirmed to carry the // 'usr' prefix, so try the id as given first and fall back to prefixing it before giving up. const nameCache = new Map(); function resolveUserName(id, onDone) { if (!id) return; if (nameCache.has(id)) { onDone(nameCache.get(id)); return; } const withPrefix = id.startsWith('usr') ? null : 'usr' + id; getUserProfile(id).catch(err => withPrefix ? getUserProfile(withPrefix) : Promise.reject(err)).then(p => { const name = (p && (p.display_name || p.nickname || p.public_account)) || id; nameCache.set(id, name); onDone(name); }).catch(() => { nameCache.set(id, id); onDone(id); }); } // GET /users/{id}/profile 400s ("malformed request") when {id} is the caller's own uid -- same // backend restriction AdminList already routes around by never calling this endpoint for the owner. // author_id/organizer_id fields here have no such alternative source, so the caller's own id has to // be special-cased directly rather than resolved through the same lookup as everyone else's. function useUserNames(ids, myUserId) { const [names, setNames] = React.useState({}); React.useEffect(() => { (ids || []).forEach(id => { if (!id || names[id] !== undefined) return; const prefixed = id.startsWith('usr') ? id : 'usr' + id; if (myUserId && prefixed === myUserId) { setNames(v => ({...v, [id]: '我'})); return; } resolveUserName(id, name => setNames(v => ({...v, [id]: name}))); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(ids), myUserId]); return names; } // Real module codes confirmed live from GET /workbench (differ from the mock's guessed // monthly/daily/organization/meeting/mailbox). 'attendance' ("打卡") is a real, enabled module // with no reference screenshot or requirements text anywhere in this chapter -- routed to a // "not yet available" toast rather than guessing a feature with zero design reference. const APP_TONES = {organization: 'red', attendance: 'orange', meetings: 'blue', daily_reports: 'orange', monthly_reports: 'violet', executive_mailbox: 'green'}; const APP_ICONS = {organization: 'account_tree', attendance: 'schedule', meetings: 'notifications_active', daily_reports: 'task_alt', monthly_reports: 'event_note', executive_mailbox: 'mail_outline'}; const APP_ROUTES = {organization: 'organization-directory', meetings: 'meeting-attendee', daily_reports: 'daily-report-mine', monthly_reports: 'monthly-report-mine', executive_mailbox: 'executive-mailbox'}; function Workbench({onToast}) { // `me` and `modules` decide whether this screen renders its real content or a blank/loading // placeholder -- caching just these two (not the per-icon `icons` map, which fills in gradually // regardless) removes the "blank workbench for a beat" flash every time the user tabs back to it. const [me] = useCachedData('me', getMe, []); const [modules, setModules] = React.useState(() => readCachedData('workbench-modules') || []); const [icons, setIcons] = React.useState({}); const [error, setError] = React.useState(null); React.useEffect(() => { getWorkbench().then(data => { const list = (data && data.modules) || []; writeCachedData('workbench-modules', list); setModules(list); list.forEach(m => { if (m.icon_file_id) { getFileUrl(m.icon_file_id).then(url => url && setIcons(v => ({...v, [m.code]: url}))).catch(() => {}); } }); }).catch(err => setError(err.message || '工作台加载失败')); }, []); const openApp = app => { if (APP_ROUTES[app.code]) { navigate(APP_ROUTES[app.code]); return; } onToast && onToast('该功能暂未开放'); }; const profile = me && me.profile; return
{profile && profile.avatar_file_id ? : person} {(profile && (profile.nickname || profile.public_account)) || '…'} 员工
提高效率,从工作台开始Business applications start from the workbench
business_center
{error ?

{error}

: null}
{modules.filter(m => m.enabled).map(app => )}
; } // Resolves a file id to an , used for the few real photo/icon spots in this chapter // (workbench module icons, my own avatar) -- most Ch.9 screens use plain icons/text only, // confirmed against every reference screenshot, so this is intentionally not used everywhere. function ImgFromFile({fileId, className}) { const [url, setUrl] = React.useState(null); React.useEffect(() => { let live = true; getFileUrl(fileId).then(u => live && setUrl(u)).catch(() => {}); return () => { live = false; }; }, [fileId]); return url ? : ; } // Depth-based icon per docs/images/部门组织框架.png: level 1 = building, level 2 (collapsed) = // hollow diamond / (expanded) = filled blue diamond, level 3+ (a person, not a sub-department) = // hollow circle. The backend never returns a depth field (confirmed against organizationDepartmentPayload), // so depth is purely how far this node is from the root in the client's own recursion. // 层级标注统一用中文数字。原来树形分支只手写到"二级"、更深就退化成"3级", // 而搜索分支又硬传字符串 "三",同一个标注两条路径两种格式。 const LEVEL_NUMERALS = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十']; const levelLabel = n => LEVEL_NUMERALS[n - 1] || String(n); function DeptRow({dept, depth, expanded, onToggle}) { const icon = depth === 0 ? 'business' : 'diamond'; return ; } // Search-result-only department row: no tree position to expand into (search returns a flat // match list, not a subtree), so unlike DeptRow this is informational, not clickable. function SearchDeptRow({dept}) { return
business {dept.name}{typeof dept.member_count === 'number' ? `(${dept.member_count}人)` : ''}
; } function PersonRow({person, depth, isMe, level}) { return ; } // One department node: on first expand, fetches its child departments AND its direct members // (two separate calls -- there is no combined endpoint) and renders both, recursing for children. function DeptNode({dept, depth, myUserId}) { const [expanded, setExpanded] = React.useState(depth === 0); const [children, setChildren] = React.useState(null); const [members, setMembers] = React.useState(null); const [loading, setLoading] = React.useState(false); const [loadError, setLoadError] = React.useState(false); const load = () => { if (children != null) return; setLoading(true); setLoadError(false); Promise.all([ dept.child_count ? getOrganizationDepartments(dept.department_id).then(d => d.departments || d.items || []) : Promise.resolve([]), dept.member_count ? getOrganizationDepartmentMembers(dept.department_id).then(d => d.members || d.items || []) : Promise.resolve([]) ]).then(([depts, mems]) => { setChildren(depts); setMembers(mems); }) // Without this, a rejected Promise.all left children/members at null forever -- rendered as // an empty (but not loading) node, indistinguishable from "this department genuinely has no // sub-departments or members". loadError lets the retry link below tell the two apart. .catch(() => setLoadError(true)) .finally(() => setLoading(false)); }; const toggle = () => { const next = !expanded; setExpanded(next); if (next) load(); }; React.useEffect(() => { if (expanded) load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, []); return <> {expanded && loading ?

加载中…

: null} {expanded && !loading && loadError ?

加载失败 { e.preventDefault(); load(); }}>重试

: null} {expanded && !loading && !loadError ? <> {(children || []).map(child => )} {(members || []).map(person => )} : null} ; } function durationLabel(startAt, endAt) { const mins = Math.round((new Date(endAt) - new Date(startAt)) / 60_000); if (mins <= 0) return ''; if (mins % 60 === 0) return `${mins / 60}小时`; if (mins < 60) return `${mins}分钟`; return `${Math.floor(mins / 60)}小时${mins % 60}分钟`; } const RESPONSE_LABEL = {accepted: '已接受', declined: '已拒绝', tentative: '待定'}; // 这条只对 GET /meetings(列表)成立——实测确认只有列表接口会解析真实姓名,MeetingCard 只在 // 列表页使用,不需要额外查询。**创建/详情/更新/取消/我的回应这5个接口不会**(display_name // 恒等于裸 user_id,第一次记录这条时误以为"响应已带姓名解析"对所有会议接口都成立,被这次复测 // 用真实创建的会议证伪)——MeetingDetail 复用的是这5个接口之一,接了 useUserNames 兜底。 function MeetingCard({meeting, myUserId, onRespond, onToast}) { const organizerName = (meeting.organizer && meeting.organizer.display_name) || meeting.organizer_display_name || meeting.organizer_id; const attendeeNames = (meeting.participants || []).map(p => p.display_name || p.user_id).join('、'); const isOrganizer = meeting.organizer_id === myUserId; const [busy, setBusy] = React.useState(false); const respond = response => { setBusy(true); // Same stale-version hazard MeetingDetail's respond/cancel already guard against (every // participant's response bumps the meeting's version) -- this card's `meeting` prop is // whatever the list loaded, which can be behind if another invitee responded since. getMeeting(meeting.meeting_id).then(fresh => respondMeeting(fresh.meeting_id, response, fresh.version)).then(_ => { onRespond(); onToast(response === 'accepted' ? '已接受会议邀请' : response === 'declined' ? '已拒绝会议邀请' : '已标记为待定'); }).catch(err => onToast(err.message || '操作失败')).finally(() => setBusy(false)); }; return
{formatMeetingDate(new Date(meeting.start_at))} {formatTime(new Date(meeting.start_at))} 会议主题:{meeting.title}
发起人
{organizerName}
时间
{formatMeetingDate(new Date(meeting.start_at))}
{meeting.location ?
地点
{meeting.location}
: null}
参会人
{attendeeNames || '无'}
会议时间
{durationLabel(meeting.start_at, meeting.end_at)}
{!isOrganizer ? ( meeting.my_response && meeting.my_response !== 'pending' ?
:
) : null}
; } function Meetings({onToast}) { const [meetings, setMeetings] = React.useState(() => readCachedData('meetings') || null); const [me] = useCachedData('me', getMe, []); const myUserId = me && me.user_id; const load = () => getMeetings().then(d => { const list = d.meetings || d.items || []; writeCachedData('meetings', list); setMeetings(list); }).catch(() => setMeetings(prev => prev || [])); // `load` has a concise arrow body, so it returns the whole promise chain -- passing it directly as // the effect callback made React treat that promise as an (invalid) cleanup return value. React.useEffect(() => { load(); }, []); return
navigate('meeting-create')} />
{meetings == null ?

加载中…

: meetings.length === 0 ?

暂无会议通知

: meetings.map(m => )}
; } function MeetingDetail({hashParams, onToast}) { const meetingId = hashParams && hashParams.meeting_id; const [meeting, setMeeting] = React.useState(() => (meetingId && readCachedData('meeting:' + meetingId)) || null); const [me] = useCachedData('me', getMe, []); const myUserId = me && me.user_id; const [busy, setBusy] = React.useState(false); const [loadError, setLoadError] = React.useState(false); // 详情/创建/更新/取消/我的回应这5个接口的 organizer.display_name / participants[].display_name // 实测就是裸 user_id 本身(只有列表接口 GET /meetings 真的解析姓名)——上面 MeetingCard 那段 // "响应已带姓名解析、不需要额外查询"的注释只对列表成立,本页面复用同一份 useUserNames 兜底 // (日报/月报详情页已经在用同一套机制)。 const participantIds = (meeting && meeting.participants || []).map(p => p.user_id); const nameIds = meeting ? [meeting.organizer_id, ...participantIds] : []; const resolvedNames = useUserNames(nameIds, myUserId); const nameFor = (id, fallback) => { if (fallback && fallback !== id) return fallback; return (id && resolvedNames[id]) || fallback || id; }; const load = () => meetingId && getMeeting(meetingId).then(m => { writeCachedData('meeting:' + meetingId, m); setMeeting(m); }).catch(err => { onToast(err.message || '加载失败'); setLoadError(true); }); React.useEffect(() => { // This view is only ever keyed by view-name in sidepanel-view.jsx, not by meeting_id, so jumping // directly between two different meetings' details would otherwise briefly show the previous // meeting's cached data under the new id -- reseed explicitly from the new id's own cache entry. if (meetingId) setMeeting(readCachedData('meeting:' + meetingId) || null); load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [meetingId]); if (!meeting) return

{loadError ? '会议信息加载失败' : '加载中…'}

; const counts = {accepted: 0, pending: 0, declined: 0, tentative: 0}; (meeting.participants || []).forEach(p => { counts[p.response] = (counts[p.response] || 0) + 1; }); const isOrganizer = meeting.organizer_id === myUserId; const remind = () => { setBusy(true); sendMeetingReminders(meeting.meeting_id).then(_ => onToast('已提醒未响应成员')).catch(err => onToast(err.message || '提醒失败')).finally(() => setBusy(false)); }; const cancel = () => { setBusy(true); // Every participant response bumps the meeting's version, so refetch immediately before // cancelling to avoid a stale-version rejection. getMeeting(meeting.meeting_id).then(fresh => cancelMeeting(fresh.meeting_id, fresh.version)) .then(_ => { onToast('会议已取消'); load(); }).catch(err => onToast(err.message || '取消失败')).finally(() => setBusy(false)); }; const respond = response => { setBusy(true); getMeeting(meeting.meeting_id).then(fresh => respondMeeting(fresh.meeting_id, response, fresh.version)) .then(_ => { onToast(RESPONSE_LABEL[response] ? `已${RESPONSE_LABEL[response]}` : '已回应'); load(); }) .catch(err => onToast(err.message || '操作失败')).finally(() => setBusy(false)); }; return
{formatMeetingDate(new Date(meeting.start_at))} {formatTime(new Date(meeting.start_at))} 会议主题:{meeting.title}
发起人
{nameFor(meeting.organizer_id, (meeting.organizer && meeting.organizer.display_name) || meeting.organizer_display_name)}
时间
{formatMeetingDate(new Date(meeting.start_at))} {formatTime(new Date(meeting.start_at))} - {formatTime(new Date(meeting.end_at))}
{meeting.location ?
地点
{meeting.location}
: null}
会议时间
{durationLabel(meeting.start_at, meeting.end_at)}
    {(meeting.participants || []).map(p =>
  • {nameFor(p.user_id, p.display_name)} {RESPONSE_LABEL[p.response] || '待确认'}
  • )}
{!isOrganizer && meeting.permissions && meeting.permissions.can_respond && (!meeting.my_response || meeting.my_response === 'pending') ?
: null}
{isOrganizer ?

参会确认

{counts.accepted}已接受{counts.pending}待响应{counts.declined}已拒绝
{meeting.permissions && meeting.permissions.can_remind ? : null} {/* 后端常量是 "canceled" 单 l(store/types/meetings.go:7),原来写成 "cancelled" 永远不相等, 会议取消之后"取消会议"按钮还挂在那儿 */} {meeting.permissions && meeting.permissions.can_cancel && meeting.state !== 'canceled' ? : null}
: null}
; } function MeetingCreate({myUserId, tinode, onToast}) { const [title, setTitle] = React.useState(''); const [date, setDate] = React.useState(toDateInputValue(new Date())); const [startTime, setStartTime] = React.useState(''); const [endTime, setEndTime] = React.useState(''); const [location, setLocation] = React.useState(''); const [query, setQuery] = React.useState(''); const [candidates, setCandidates] = React.useState([]); const [selected, setSelected] = React.useState([]); const [names, setNames] = React.useState({}); const [submitting, setSubmitting] = React.useState(false); React.useEffect(() => { const handle = window.setTimeout(() => { getOrganizationMembers({query}).then(d => setCandidates(d.members || d.items || [])).catch(() => setCandidates([])); }, 300); return () => window.clearTimeout(handle); }, [query]); const toggle = uid => { setSelected(v => v.includes(uid) ? v.filter(x => x != uid) : v.concat(uid)); const cand = candidates.find(c => c.user_id === uid); if (cand) setNames(v => ({...v, [uid]: cand.display_name})); }; const contacts = candidates.map(c => ({user: c.user_id, public: {fn: c.display_name}})); const submit = () => { if (!title.trim() || !date || !startTime || !endTime || selected.length === 0) { onToast('请完善会议主题、时间与参会人'); return; } const startAt = new Date(`${date}T${startTime}:00`).toISOString(); const endAt = new Date(`${date}T${endTime}:00`).toISOString(); // Backend 400s with {details:{field:"end_at"}} for this (real request confirmed), but nothing // upstream of onToast reads `details` -- catching it locally avoids a round-trip for the single // most likely input mistake (single shared date, two independent time inputs). if (endAt <= startAt) { onToast('结束时间需晚于开始时间'); return; } setSubmitting(true); createMeeting({title: title.trim(), start_at: startAt, end_at: endAt, location: location.trim(), attendee_user_ids: selected}) .then(_ => { onToast('会议已发起'); navigate('meeting-attendee'); }) .catch(err => onToast((err.details && err.details.field === 'end_at') ? '结束时间需晚于开始时间' : (err.message || '发起会议失败'))) .finally(() => setSubmitting(false)); }; return
{selected.length ?
{selected.map(uid => {names[uid] || uid} toggle(uid)}>close)}
: null} {tinode ? : null}
; } function OrganizationDirectory({myUserId}) { const [query, setQuery] = React.useState(''); const [searchResults, setSearchResults] = React.useState(null); const [roots, setRoots] = React.useState(() => readCachedData('org-roots') || null); const [rootsError, setRootsError] = React.useState(false); const [tenantName, setTenantName] = React.useState(''); const loadRoots = React.useCallback(() => { setRootsError(false); getOrganizationDepartments('root').then(d => { const list = d.departments || d.items || []; writeCachedData('org-roots', list); setRoots(list); }).catch(() => { // Was collapsing a genuine load failure into the exact same `roots: []` state as "this // tenant truly has no departments" -- rootsError lets the render below tell them apart // instead of showing "暂无组织架构数据" for what might just be a network blip. setRoots(prev => prev || []); setRootsError(true); }); }, []); React.useEffect(() => { loadRoots(); try { const tenant = JSON.parse(window.localStorage.getItem('im-tenant') || '{}'); setTenantName(tenant.name || ''); } catch (_) { /* ignore */ } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Was calling getOrganizationMembers({query}) -- besides that function's own now-fixed q/query // param bug, it only ever returns users, so "搜索成员或部门" (search members OR departments) could // never actually find a department. searchOrganization is the documented endpoint for this exact // box (GET /organization/search?q=&types=department,user) and already existed in im-api.js, matching // the real backend, but had no caller anywhere -- a correct implementation nothing used. React.useEffect(() => { if (!query.trim()) { setSearchResults(null); return; } const handle = window.setTimeout(() => { searchOrganization(query).then(d => setSearchResults(d.items || [])).catch(() => setSearchResults([])); }, 300); return () => window.clearTimeout(handle); }, [query]); return
{searchResults ?
{searchResults.length === 0 ?

未找到匹配的成员

: searchResults.map((item, idx) => item.type === 'department' ? : )}
:
{roots == null ?

加载中…

: roots.length === 0 ?

{rootsError ? <>加载失败 { e.preventDefault(); loadRoots(); }}>重试 : '暂无组织架构数据'}

: roots.map(dept => )}
}
; } // The backend's daily report Content field is a flat string (confirmed against // store/types/reports.go -- no entries array exists server-side). docs/images/日报.png shows a // repeatable "起止时间 + 今日工作总结" block editor, so that's purely a front-end authoring // convenience: blocks are joined into one formatted string before being sent as `content`. function blocksToContent(blocks) { return blocks .filter(b => b.content.trim()) .map(b => (b.start && b.end ? `${b.start}-${b.end}\n` : '') + b.content.trim()) .join('\n\n'); } function DailyReportEditor({onToast}) { const today = toDateInputValue(new Date()); const cacheKey = 'daily-report-editor:' + today; // This composer sits in a side panel next to chat -- a user plausibly starts today's report, // switches away to answer a message, and comes back mid-edit, so seed from cache (not just the // read-only hub pages) to avoid the blocks textarea flashing back to empty on that round trip. // createDailyReport() below always still runs and overwrites this with the server's own state, // so a stale/wrong seed here self-corrects within one request -- same "revalidate in background" // shape as everywhere else, just with a same-shape object rather than a single field. const cached = readCachedData(cacheKey); const [reportId, setReportId] = React.useState(cached ? cached.reportId : null); const [version, setVersion] = React.useState(cached ? cached.version : null); // The debounced autosave below reads `version` from inside a setTimeout scheduled by an effect // keyed on `blocks`/`reportId` (not `version`) -- if a save request is still in flight when the // next edit re-schedules the timer, the new timer's closure captures whatever `version` was at // that moment, which can be one save behind the real server version by the time it actually // fires (the earlier save's response, which bumps `version`, can land after the new timer was // created but before it runs). A ref always reflects the latest value regardless of when the // closure was created, so reads through it can't be stale the way a captured state variable can. const versionRef = React.useRef(cached ? cached.version : null); const setVersionBoth = v => { versionRef.current = v; setVersion(v); }; const [blocks, setBlocks] = React.useState(cached ? cached.blocks : [{start: '', end: '', content: ''}]); const [saved, setSaved] = React.useState(false); const [saveError, setSaveError] = React.useState(false); const [submitting, setSubmitting] = React.useState(false); // Serializes every draft save (the debounced autosave below AND submit()'s own pre-submit save) // through one chain, so two overlapping requests can never both read the same not-yet-updated // versionRef and race the server -- confirmed live: clicking submit right as/after the debounce // timer's own autosave fires can otherwise send two saves built from the same stale version, and // whichever the server sees second comes back 409 VERSION_CONFLICT, leaving the report un-submitted // with no obvious explanation. Reopening a cached draft and only touching a little before submitting // (now a normal flow, where blank-every-time wasn't) makes this window much easier to hit than before. const saveChainRef = React.useRef(Promise.resolve()); const draftTimerRef = React.useRef(null); const saveDraft = React.useCallback(nextBlocks => { const run = () => updateDailyDraft(reportId, blocksToContent(nextBlocks), versionRef.current, []).then(r => { setVersionBoth(r.version); setSaved(true); setSaveError(false); writeCachedData(cacheKey, {reportId, version: r.version, blocks: nextBlocks}); return r; }); const next = saveChainRef.current.then(run); saveChainRef.current = next.catch(() => {}); return next; // eslint-disable-next-line react-hooks/exhaustive-deps }, [reportId]); React.useEffect(() => { createDailyReport(today).then(r => { // createDailyReport is idempotent -- reopening this editor for a day whose report was already // submitted returns that same (now non-draft) report instead of erroring. The draft-save // endpoint's UPDATE is scoped to `WHERE status='draft'` (im_be's TenantDailyReportSaveDraft), // so every autosave against a submitted report's affected-rows count is 0 -> a 409 that used // to be silently swallowed. can_edit is the backend's own real-time signal for this (same // status check), so redirect to the read-only detail view instead of handing the user a // composer that looks editable but can never actually save. if (r.permissions && r.permissions.can_edit === false) { navigate('daily-report-detail', {report_id: r.report_id}); return; } const nextBlocks = r.content ? [{start: '', end: '', content: r.content}] : [{start: '', end: '', content: ''}]; setReportId(r.report_id); setVersionBoth(r.version); setBlocks(nextBlocks); writeCachedData(cacheKey, {reportId: r.report_id, version: r.version, blocks: nextBlocks}); }).catch(err => onToast(err.message || '创建日报失败')); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); React.useEffect(() => { if (!reportId) return undefined; setSaved(false); draftTimerRef.current = window.setTimeout(() => { draftTimerRef.current = null; saveDraft(blocks).catch(() => setSaveError(true)); }, 600); return () => window.clearTimeout(draftTimerRef.current); }, [blocks, reportId, saveDraft]); const updateBlock = (idx, field, value) => setBlocks(v => v.map((b, i) => i === idx ? {...b, [field]: value} : b)); const addBlock = () => setBlocks(v => v.concat({start: '', end: '', content: ''})); const removeBlock = idx => setBlocks(v => v.length > 1 ? v.filter((_, i) => i !== idx) : v); const submit = () => { const content = blocksToContent(blocks); if (!content.trim()) { onToast('请填写今日工作总结'); return; } setSubmitting(true); if (draftTimerRef.current) { window.clearTimeout(draftTimerRef.current); draftTimerRef.current = null; } saveDraft(blocks).then(r => submitDailyReport(reportId, r.version)) .then(_ => { onToast('日报已提交'); window.setTimeout(() => navigate('daily-report-detail', {report_id: reportId}), 500); }) .catch(err => onToast(err.message || '提交失败')) .finally(() => setSubmitting(false)); }; return
{blocks.map((block, idx) =>
日报 {blocks.length > 1 ? : null}