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);
};
// Same bug/fix as content-library-view.jsx's navigateReplace: DailyReportList/MonthlyReportList's
// 我的日报|月报 <-> 团队统计 switch is a same-page tab toggle (same component, just a `team` boolean),
// not a navigation to a different page -- using navigate() here pushed a history entry per click, so
// switching back and forth a few times made the system back button (and this app's own Header back
// button, gated on HashNavigation.goBack()) cycle through past toggle states instead of leaving the
// report page. replaceState-based navigateReplace keeps the hash in sync without that side effect.
const navigateReplace = (view) => HashNavigation.navigateReplace(HashNavigation.setUrlSidePanel(window.location.hash, view));
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 {children}; }
function Header({title, back = 'workbench', action, actionLabel, onAction, actionDisabled}) {
return
: 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
;
}
// 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
{!isOrganizer ? (
meeting.my_response && meeting.my_response !== 'pending' ?
:
// Live-repro'd (round 12): this branch used to render regardless of `permissions.can_respond`,
// so a canceled meeting the viewer never responded to (my_response still 'pending', a normal
// state for a canceled invite -- canceling doesn't force every invitee to respond first) kept
// showing live-looking 拒绝/接受 buttons here. Clicking either got a 403 PERMISSION_DENIED from
// PUT .../my-response (confirmed against a real canceled meeting, mtg_0w2TnfQiqaMwy8Bj) --
// the request body/version were fine, the backend just correctly refuses to accept a response
// once a meeting can no longer be responded to. MeetingDetail already gates its own copy of
// these same two buttons on this exact flag (line ~406 below); this list card independently
// duplicates the buttons but had never adopted the same guard. Requirements 9.4 also calls for
// expired meetings to become read-only history, which this same flag should cover -- can_respond
// is a general "still actionable" signal, not canceled-specific.
(meeting.permissions && meeting.permissions.can_respond ? : null)
) : 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
;
}
// 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
;
}
function DailyReportDetail({hashParams, myUserId, onToast}) {
const reportId = hashParams && hashParams.report_id;
// DailyReportCard renders identically for DailyReportList's team=true (团队统计) and team=false
// (我的日报) modes and always links here -- backTarget hardcoded to daily-report-mine used to send
// a manager who'd been browsing 团队统计 back to their own report list instead of the team roster
// they'd actually come from.
const backTarget = hashParams && hashParams.team ? 'daily-report-team' : 'daily-report-mine';
const [report, setReport] = React.useState(() => (reportId && readCachedData('daily-report:' + reportId)) || null);
const [loadError, setLoadError] = React.useState(false);
const names = useUserNames(report ? [report.author_id] : [], myUserId);
React.useEffect(() => {
if (!reportId) return;
// Keyed only by view-name upstream (not report_id), so reseed per-id here rather than relying
// on the initializer -- see the identical MeetingDetail comment for why that matters.
setReport(readCachedData('daily-report:' + reportId) || null);
getDailyReport(reportId).then(r => { writeCachedData('daily-report:' + reportId, r); setReport(r); }).catch(err => { onToast(err.message || '日报加载失败'); setLoadError(true); });
}, [reportId]);
if (!report) return
;
}
function toMonthInputValue(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; }
function formatMonthLabel(month) {
const [y, m] = month.split('-');
return `${y} 年 ${parseInt(m, 10)} 月`;
}
// docs/images/月报(1).png shows the second field labeled "其他事项" (optional-looking, no
// asterisk) but the backend's only real second field is `next_plan` (store/types/reports.go),
// declared required in the backend's own advertised contract (monthlyReportDetailPayload:
// next_plan_required:true). The 月报 image set also had a confirmed content mix-up elsewhere
// (月报.png's list content is verbatim 日报 demo text), so it's trusted less than usual here --
// labeling this field by what it actually stores/is required to avoid silently mislabeling
// "next month's plan" as generic "other matters".
function MonthlyReportEditor({onToast}) {
const month = toMonthInputValue(new Date());
const cacheKey = 'monthly-report-editor:' + month;
// Same rationale as DailyReportEditor's cache seed -- see the comment there.
const cached = readCachedData(cacheKey);
const [reportId, setReportId] = React.useState(cached ? cached.reportId : null);
const [version, setVersion] = React.useState(cached ? cached.version : null);
// See the matching comment in DailyReportEditor -- a ref avoids the debounced-save-in-flight
// stale-version race that a captured `version` state variable would be exposed to.
const versionRef = React.useRef(cached ? cached.version : null);
const setVersionBoth = v => { versionRef.current = v; setVersion(v); };
const [summary, setSummary] = React.useState(cached ? cached.summary : '');
const [plan, setPlan] = React.useState(cached ? cached.plan : '');
// monthlyReportDetailPayload's real validation.max_length is 10000, not the 1000 this page used to
// hardcode -- confirmed live (a 2000-char draft save was accepted while this UI would have blocked
// it at 1000). Keep 1000 as the pre-load fallback so the textarea has *some* limit before the first
// response comes back, not because it's the real cap.
const [maxLen, setMaxLen] = React.useState(1000);
// 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);
// Same serialized-save fix as DailyReportEditor -- see the comment there. Live-reproduced here: a
// real submit attempt raced the debounced autosave, both built from version 9, one landed and bumped
// the server to version 10, the other came back 409 VERSION_CONFLICT and the report never actually
// got submitted.
const saveChainRef = React.useRef(Promise.resolve());
const draftTimerRef = React.useRef(null);
const saveDraft = React.useCallback((s, p) => {
const run = () => updateMonthlyDraft(reportId, s, p, versionRef.current, []).then(r => {
setVersionBoth(r.version); setSaved(true); setSaveError(false);
writeCachedData(cacheKey, {reportId, version: r.version, summary: s, plan: p});
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(() => {
createMonthlyReport(month).then(r => {
// Same already-submitted-report trap as DailyReportEditor -- see the comment there.
if (r.permissions && r.permissions.can_edit === false) { navigate('monthly-report-detail', {report_id: r.report_id}); return; }
const nextSummary = r.work_summary || ''; const nextPlan = r.next_plan || '';
setReportId(r.report_id); setVersionBoth(r.version);
setSummary(nextSummary); setPlan(nextPlan);
if (r.validation && r.validation.max_length) setMaxLen(r.validation.max_length);
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 || !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, hydrated, saveDraft]);
const submit = () => {
if (!summary.trim() || !plan.trim()) { onToast('请完成必填内容'); return; }
setSubmitting(true);
if (draftTimerRef.current) { window.clearTimeout(draftTimerRef.current); draftTimerRef.current = null; }
saveDraft(summary, plan).then(r => submitMonthlyReport(reportId, r.version))
.then(_ => { onToast('月报已提交'); window.setTimeout(() => navigate('monthly-report-detail', {report_id: reportId}), 500); })
.catch(err => onToast(err.message || '提交失败'))
.finally(() => setSubmitting(false));
};
return
;
}
function MonthlyReportDetail({hashParams, myUserId, onToast}) {
const reportId = hashParams && hashParams.report_id;
// Same fix as DailyReportDetail's backTarget: the list row that links here renders identically for
// team=true (团队统计) and team=false (我的月报), so a hardcoded 'monthly-report-mine' sent a manager
// who'd been browsing 团队统计 back to their own list instead of the team roster they came from.
const backTarget = hashParams && hashParams.team ? 'monthly-report-team' : 'monthly-report-mine';
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(() => {
if (!reportId) return;
setReport(readCachedData('monthly-report:' + reportId) || null);
getMonthlyReport(reportId).then(r => { writeCachedData('monthly-report:' + reportId, r); setReport(r); }).catch(err => { onToast(err.message || '月报加载失败'); setLoadError(true); });
}, [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