Files
im_webapp/src/views/workplace-collaboration-view.jsx
T
Your Name 623f7dc40f fix(导航): 排查全项目后修复其余同类tab切换污染历史记录问题
上一提交(e61eb402)只修了"我的收藏"/"查找聊天记录"两处,用户要求排查其他页面是否
也有同样问题。全项目排查<nav>/tabbar/active态切换元素,逐一确认onClick是否直接调用
HashNavigation.navigateTo()(每次都push一条历史记录),找到5处同类实例并修复:

- message-center-view.jsx / workplace-collaboration-view.jsx:底部导航"工作台"<->"消息"
  两个tab——这是全应用唯一的根级tab bar,也是点击频率最高的一处,之前同样每次切换都
  push一条历史记录。
- workplace-collaboration-view.jsx:日报/月报列表页的"我的xx"<->"团队统计"切换(同一个
  组件、同一个页面,只是team boolean不同),同样的问题。
- new-topic-view.jsx:"开始新会话"页的搜索/新群组/通过id三个tab。
- wallpapers.jsx:"壁纸"设置页的图案/图片两个tab。

均改用上一提交新增的HashNavigation.navigateReplace()。已在浏览器逐一实测:连续切换
多次tab后,无论真实浏览器返回还是应用自己的返回按钮,都能一次性返回到进入该页面之前
的真实上一页。

以下经排查确认不受影响,未改动:group-management-view.jsx的群聊分类tab(我创建的/
管理的/加入的)和contact-management-view.jsx的字母索引锚点均只用本地state/scrollIntoView,
不涉及hash跳转;auth-onboarding-view.jsx的登录<->注册切换、new-topic-view.jsx提交后的
三处跳转是真实的"离开当前页"操作,保留push语义符合预期。
2026-09-06 11:16:01 +08:00

1152 lines
72 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <i className="material-icons" aria-hidden="true">{children}</i>; }
function Header({title, back = 'workbench', action, actionLabel, onAction, actionDisabled}) {
return <header className="workplace-header">
<button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<h1>{title}</h1>
{action ? <button type="button" onClick={onAction} aria-label={actionLabel || '操作'} disabled={actionDisabled}><Icon>{action}</Icon></button> : <span />}
</header>;
}
function Toast({message, onDone}) {
React.useEffect(() => {
if (!message) return undefined;
const timer = window.setTimeout(onDone, 2100);
return () => window.clearTimeout(timer);
}, [message, onDone]);
return message ? <div className="workplace-toast" role="status">{message}</div> : 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 <div className="workplace-page workbench-page"><main>
<section className="workbench-profile">
{profile && profile.avatar_file_id ?
<ImgFromFile fileId={profile.avatar_file_id} /> :
<span className="workbench-avatar-fallback"><Icon>person</Icon></span>}
<span><strong>{(profile && (profile.nickname || profile.public_account)) || '…'}</strong>
<small>员工</small></span>
<button type="button" aria-label="工作台设置" onClick={() => navigate('personal-center')}><Icon>tune</Icon></button>
</section>
<section className="workbench-banner"><div><strong>提高效率,从工作台开始</strong><small>Business applications start from the workbench</small></div><Icon>business_center</Icon></section>
{error ? <p className="workplace-error">{error}</p> : null}
<section className="workbench-apps" aria-label="办公应用">{modules.filter(m => m.enabled).map(app => <button type="button" key={app.code} onClick={() => openApp(app)}>
{/* Namespaced ('tone-red', not 'red') because base.css's original Tinode styles already define
bare `.red`/`.orange`/`.blue`/`.green` as generic `color: ... !important` text-color
utilities -- an unprefixed tone class here collided with those and, since `!important`
beats this span's own `color: #fff`, silently retinted 5 of these 6 icons to whichever
generic accent color happened to share their tone's name (only 月报's 'violet' has no such
collision, which is why it alone rendered the intended plain white) instead of the uniform
white every icon here is meant to have -- same bug, same fix, as message-center-view.jsx's
Avatar tone class. */}
<span className={'tone-' + (APP_TONES[app.code] || 'blue')}>
{icons[app.code] ? <img src={icons[app.code]} alt="" /> : <Icon>{APP_ICONS[app.code] || 'apps'}</Icon>}
{app.unread_count ? <em>{app.unread_count > 99 ? '99+' : app.unread_count}</em> : null}
</span>
<strong>{app.name}</strong>
</button>)}</section>
</main><nav className="workplace-bottom-nav" aria-label="主导航">
<button type="button" className="active"><Icon>dashboard</Icon><span>工作台</span></button>
<button type="button" onClick={() => navigateReplace('messages-home')}><Icon>chat_bubble_outline</Icon><span>消息</span></button>
</nav></div>;
}
// Resolves a file id to an <img>, 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 ? <img className={className} src={url} alt="" /> : <span className={className} />;
}
// 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 <button type="button" className={`organization-department depth-${depth}${expanded ? ' expanded' : ''}`}
style={{paddingLeft: `${1 + depth * 1.1}rem`}} onClick={onToggle}>
<Icon>{icon}</Icon>
<span><strong>{dept.name}{typeof dept.member_count === 'number' ? `${dept.member_count}人` : ''}({levelLabel(depth + 1)}级)</strong></span>
<Icon>{expanded ? 'expand_more' : 'chevron_right'}</Icon>
</button>;
}
// 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 <div className="organization-department depth-0" style={{paddingLeft: '1rem'}}>
<Icon>business</Icon>
<span><strong>{dept.name}{typeof dept.member_count === 'number' ? `(${dept.member_count}人)` : ''}</strong></span>
</div>;
}
function PersonRow({person, depth, isMe, level}) {
return <button type="button" className={`organization-person${isMe ? ' me' : ''}`}
style={{paddingLeft: `${1 + depth * 1.1}rem`}} onClick={() => navigate('group-member-profile', {uid: person.user_id})}>
<Icon>radio_button_unchecked</Icon>
<span><strong>{person.display_name}({levelLabel(level)}级)</strong></span>
<Icon>chevron_right</Icon>
</button>;
}
// 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 <>
<DeptRow dept={dept} depth={depth} expanded={expanded} onToggle={toggle} />
{expanded && loading ? <p className="organization-loading">加载中…</p> : null}
{expanded && !loading && loadError ?
<p className="organization-loading">加载失败 <a href="#" onClick={e => { e.preventDefault(); load(); }}>重试</a></p> : null}
{expanded && !loading && !loadError ? <>
{(children || []).map(child => <DeptNode key={child.department_id} dept={child} depth={depth + 1} myUserId={myUserId} />)}
{(members || []).map(person => <PersonRow key={person.user_id} person={person} depth={depth + 1}
level={depth + 2} isMe={person.user_id === myUserId || 'usr' + person.user_id === myUserId} />)}
</> : 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 <article className="meeting-card">
<header><span><small>{formatMeetingDate(new Date(meeting.start_at))} {formatTime(new Date(meeting.start_at))}</small>
<strong>会议主题:{meeting.title}</strong></span>
<button type="button" className="meeting-more" onClick={() => navigate('meeting-detail', {meeting_id: meeting.meeting_id})}>查看更多<Icon>chevron_right</Icon></button>
</header>
<dl>
<div><dt>发起人</dt><dd>{organizerName}</dd></div>
<div><dt>时间</dt><dd>{formatMeetingDate(new Date(meeting.start_at))}</dd></div>
{meeting.location ? <div><dt>地点</dt><dd>{meeting.location}</dd></div> : null}
<div><dt>参会人</dt><dd className="meeting-attendees">{attendeeNames || '无'}</dd></div>
<div><dt>会议时间</dt><dd>{durationLabel(meeting.start_at, meeting.end_at)}</dd></div>
</dl>
{!isOrganizer ? (
meeting.my_response && meeting.my_response !== 'pending' ?
<footer><button type="button" className="meeting-responded" disabled>{RESPONSE_LABEL[meeting.my_response] || meeting.my_response}</button></footer> :
// 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 ? <footer className="meeting-response">
<button type="button" disabled={busy} onClick={() => respond('declined')}>拒绝</button>
<button type="button" className="primary" disabled={busy} onClick={() => respond('accepted')}>接受</button>
</footer> : null)
) : null}
</article>;
}
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 <div className="workplace-page"><Header title="会议通知" action="add" actionLabel="发起会议" onAction={() => navigate('meeting-create')} />
<main className="meeting-list">
{meetings == null ? <p className="organization-loading">加载中…</p> :
meetings.length === 0 ? <p className="organization-loading">暂无会议通知</p> :
meetings.map(m => <MeetingCard key={m.meeting_id} meeting={m} myUserId={myUserId} onRespond={load} onToast={onToast} />)}
</main>
</div>;
}
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 <div className="workplace-page"><Header title="会议详情" back="meeting-attendee" /><main><p className="organization-loading">{loadError ? '会议信息加载失败' : '加载中…'}</p></main></div>;
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 <div className="workplace-page"><Header title="会议详情" back="meeting-attendee" />
<main className="meeting-list">
<article className="meeting-card">
<header><span><small>{formatMeetingDate(new Date(meeting.start_at))} {formatTime(new Date(meeting.start_at))}</small>
<strong>会议主题:{meeting.title}</strong></span></header>
<dl>
<div><dt>发起人</dt><dd>{nameFor(meeting.organizer_id, (meeting.organizer && meeting.organizer.display_name) || meeting.organizer_display_name)}</dd></div>
<div><dt>时间</dt><dd>{formatMeetingDate(new Date(meeting.start_at))} {formatTime(new Date(meeting.start_at))} - {formatTime(new Date(meeting.end_at))}</dd></div>
{meeting.location ? <div><dt>地点</dt><dd>{meeting.location}</dd></div> : null}
<div><dt>会议时间</dt><dd>{durationLabel(meeting.start_at, meeting.end_at)}</dd></div>
</dl>
<ul className="meeting-participant-list">
{(meeting.participants || []).map(p => <li key={p.user_id}>
<span>{nameFor(p.user_id, p.display_name)}</span>
<em className={p.response}>{RESPONSE_LABEL[p.response] || '待确认'}</em>
</li>)}
</ul>
{!isOrganizer && meeting.permissions && meeting.permissions.can_respond && (!meeting.my_response || meeting.my_response === 'pending') ?
<footer className="meeting-response">
<button type="button" disabled={busy} onClick={() => respond('declined')}>无法参加</button>
<button type="button" disabled={busy} onClick={() => respond('tentative')}>待定</button>
<button type="button" className="primary" disabled={busy} onClick={() => respond('accepted')}>接受</button>
</footer> : null}
</article>
{isOrganizer ? <section className="meeting-status">
<h2>参会确认</h2>
<div><span><b>{counts.accepted}</b><small>已接受</small></span><span><b>{counts.pending}</b><small>待响应</small></span><span><b>{counts.declined}</b><small>已拒绝</small></span></div>
<footer>
{meeting.permissions && meeting.permissions.can_remind ? <button type="button" disabled={busy} onClick={remind}><Icon>notifications</Icon>提醒未响应</button> : null}
{/* 后端常量是 "canceled" 单 l(store/types/meetings.go:7),原来写成 "cancelled" 永远不相等,
会议取消之后"取消会议"按钮还挂在那儿 */}
{meeting.permissions && meeting.permissions.can_cancel && meeting.state !== 'canceled' ? <button type="button" className="danger" disabled={busy} onClick={cancel}>取消会议</button> : null}
</footer>
</section> : null}
</main>
</div>;
}
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 <div className="workplace-page report-editor-page"><Header title="发起会议" back="meeting-attendee" />
<main>
<label className="report-field"><span><b>*</b>会议主题</span>
<input type="text" value={title} maxLength="80" onChange={e => setTitle(e.target.value)} placeholder="请输入会议主题" /></label>
<label className="report-field"><span><b>*</b>日期</span><input type="date" value={date} onChange={e => setDate(e.target.value)} /></label>
<div className="meeting-time-range">
<label className="report-field"><span><b>*</b>开始时间</span><input type="time" value={startTime} onChange={e => setStartTime(e.target.value)} /></label>
<label className="report-field"><span><b>*</b>结束时间</span><input type="time" value={endTime} onChange={e => setEndTime(e.target.value)} /></label>
</div>
<label className="report-field"><span>地点</span>
<input type="text" value={location} maxLength="60" onChange={e => setLocation(e.target.value)} placeholder="请输入会议地点(可选)" /></label>
<label className="report-field"><span><b>*</b>参会人</span>
<input type="text" value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索姓名添加参会人" /></label>
{selected.length ? <div className="meeting-selected-attendees">
{selected.map(uid => <span key={uid}>{names[uid] || uid}<i className="material-icons" onClick={() => toggle(uid)}>close</i></span>)}
</div> : null}
{tinode ? <ContactList tinode={tinode} contacts={contacts} myUserId={myUserId}
emptyListMessage="搜索同事姓名" showOnline={false} showUnread={false} showContextMenu={false}
topicSelected={selected} onTopicSelected={toggle} /> : null}
</main>
<footer className="workplace-submit-bar"><button type="button" disabled={submitting} onClick={submit}>{submitting ? '发起中…' : '发起会议'}</button></footer>
</div>;
}
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 <div className="workplace-page"><Header title="部门组织框架" back="workbench" />
<main className="organization-content">
<label className="workplace-search"><Icon>search</Icon>
<input value={query} onChange={event => setQuery(event.target.value)} placeholder="搜索成员或部门" />
</label>
{searchResults ?
<section className="organization-tree">
{searchResults.length === 0 ? <p className="organization-loading">未找到匹配的成员</p> :
searchResults.map((item, idx) => item.type === 'department' ?
<SearchDeptRow key={'d' + (item.department && item.department.department_id || idx)} dept={item.department} /> :
<PersonRow key={'u' + (item.user && item.user.user_id || idx)} person={item.user} depth={0} level={3}
isMe={item.user && (item.user.user_id === myUserId || 'usr' + item.user.user_id === myUserId)} />)}
</section> :
<section className="organization-tree">
{roots == null ? <p className="organization-loading">加载中…</p> :
roots.length === 0 ? <p className="organization-loading">
{rootsError ? <>加载失败 <a href="#" onClick={e => { e.preventDefault(); loadRoots(); }}>重试</a></> : '暂无组织架构数据'}
</p> :
roots.map(dept => <DeptNode key={dept.department_id} dept={dept} depth={0} myUserId={myUserId} />)}
</section>}
</main>
</div>;
}
// 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 <div className="workplace-page report-editor-page"><Header title="日报" back="daily-report-mine" />
<main>
{blocks.map((block, idx) => <section key={idx} className="daily-report-block">
<div className="daily-report-block-header"><strong>日报</strong>
{blocks.length > 1 ? <button type="button" onClick={() => removeBlock(idx)} aria-label="删除"><Icon>close</Icon></button> : null}
</div>
<label className="report-field"><span>起止时间</span><small className="daily-report-date">{formatMeetingDate(new Date(today))}</small>
<div className="meeting-time-range">
<input type="time" value={block.start} onChange={e => updateBlock(idx, 'start', e.target.value)} />
<span>至</span>
<input type="time" value={block.end} onChange={e => updateBlock(idx, 'end', e.target.value)} />
</div>
</label>
<label className="report-field"><span>今日工作总结</span>
<textarea value={block.content} maxLength="1000" placeholder="请输入" onChange={e => updateBlock(idx, 'content', e.target.value)} />
<small>{block.content.length}/1000</small>
</label>
</section>)}
<button type="button" className="daily-report-add-block" onClick={addBlock}><Icon>add</Icon>添加时间段</button>
<p className="report-draft-status">{saveError ? '草稿保存失败,请检查网络后重新编辑' : saved ? '草稿已自动保存' : '正在保存草稿…'}</p>
</main>
<footer className="workplace-submit-bar"><button type="button" disabled={submitting || !reportId} onClick={submit}>{submitting ? '提交中…' : '提交'}</button></footer>
</div>;
}
const STATUS_LABEL = {submitted: '已提交', draft: '未提交'};
function DailyReportCard({report, names, team}) {
return <article>
<header><span><strong>{names[report.author_id] || report.author_id}</strong><small className="role-badge">员工</small></span>
<em className={report.status}>{STATUS_LABEL[report.status] || report.status}</em></header>
<p>{report.content_short || '(未填写内容)'}</p>
<footer><span><Icon>schedule</Icon>{report.report_date}</span>
<button type="button" onClick={() => navigate('daily-report-detail', {report_id: report.report_id, team: team ? '1' : ''})}>查看详情<Icon>chevron_right</Icon></button></footer>
</article>;
}
function DailyReportList({team, myUserId, onToast}) {
const cacheKey = 'daily-reports:' + (team ? 'managed' : 'mine');
const statsCacheKey = 'daily-stats:' + toDateInputValue(new Date());
// 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(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]);
const filtered = (reports || []).filter(r => {
// department_name 后端根本不返回(dailyReportListPayload 的字段只有 report_id/author_id/
// report_date/content_short/status/draft_updated_at/read_state/version),拼进来恒为空串,
// 等于占位符承诺的"部门"那半个搜索永远搜不到任何东西。改为按真实有的字段搜:姓名 + 摘要内容。
const hay = team ? `${names[r.author_id] || ''}${r.content_short || ''}` : r.report_date;
return hay.includes(query);
});
const remindMissing = () => {
sendDailyReminders(toDateInputValue(new Date())).then(_ => onToast('已提醒未提交成员'))
.catch(err => onToast(err.message || '提醒失败'));
};
return <div className="workplace-page report-list-page">
<Header title="日报" action="add" actionLabel="填写日报" onAction={() => navigate('daily-report-editor')} back={team ? 'daily-report-mine' : 'workbench'} />
<main>
{team && stats ? <section className="report-statistics">
<header><span><small>{formatMeetingDate(new Date())}</small><strong>全公司</strong></span>
<button type="button" onClick={() => navigateReplace('daily-report-mine')}>我的日报</button></header>
<div><span><b>{stats.expected_count || 0}</b><small>应提交</small></span>
<span><b>{stats.submitted_count || 0}</b><small>已提交</small></span>
<span><b>{(stats.expected_count || 0) - (stats.submitted_count || 0)}</b><small>未提交</small></span></div>
<button type="button" onClick={remindMissing}><Icon>notifications_active</Icon>提醒未提交成员</button>
</section> : null}
<label className="workplace-search"><Icon>search</Icon>
<input value={query} onChange={e => setQuery(e.target.value)} placeholder={team ? '搜索姓名或内容' : '搜索日期'} /></label>
{!team ? <nav className="report-role-switch">
<button type="button" className="active">我的日报</button>
<button type="button" onClick={() => navigateReplace('daily-report-team')}>团队统计</button>
</nav> : null}
<section className="report-cards">
{reports == null ? <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>
</div>;
}
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 <div className="workplace-page"><Header title="查看详情" back={backTarget} /><main><p className="organization-loading">{loadError ? '日报加载失败' : '加载中…'}</p></main></div>;
return <div className="workplace-page"><Header title="查看详情" back={backTarget} />
<main className="report-detail">
<section className="report-author"><span><strong>{names[report.author_id] || '…'}</strong><small className="role-badge">员工</small></span>
<em className={report.status}>{STATUS_LABEL[report.status] || report.status}</em></section>
<section><small><Icon>schedule</Icon>{report.report_date}</small></section>
<section><h3>今日工作总结</h3><p>{report.content || '未填写'}</p></section>
</main>
</div>;
}
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 <div className="workplace-page report-editor-page"><Header title="月报" back="monthly-report-mine" />
<main>
<section className="daily-report-block-header"><strong>月报汇总</strong></section>
<label className="report-field"><span><b>*</b>本月工作总结</span>
<textarea value={summary} maxLength={maxLen} placeholder="请输入" onChange={e => setSummary(e.target.value)} /><small>{summary.length}/{maxLen}</small></label>
<label className="report-field"><span><b>*</b>下月工作计划</span>
<textarea value={plan} maxLength={maxLen} placeholder="请输入" onChange={e => setPlan(e.target.value)} /><small>{plan.length}/{maxLen}</small></label>
<p className="report-draft-status">{saveError ? '草稿保存失败,请检查网络后重新编辑' : saved ? '草稿已自动保存' : '正在保存草稿…'}</p>
</main>
<footer className="workplace-submit-bar"><button type="button" disabled={submitting || !reportId} onClick={submit}>{submitting ? '提交中…' : '提交'}</button></footer>
</div>;
}
// 团队统计视图镜像 DailyReportList 的同款设计(这个页面原来只有"我的月报"一种,没有任何管理员/
// 团队入口,日报有的东西月报完全没有)。核对 im_be 源码确认后端接口本身是对称的:
// TenantMonthlyReportList 的 scope=managed 分支跟日报那边字面上是同一段
// canUseManagedReports(visibility) 权限校验代码,GET /reports/monthly/statistics 的响应字段
// (expected_count/submitted_count/missing_count)也跟日报统计完全一致——真实请求过一次确认
// 无权限账号会收到 403 PERMISSION_DENIED 而不是被拒之前那种误报全公司数据。
// 唯一确认过不对称的一点:月报没有"提醒未提交成员"这个后端能力(日报有
// POST /reports/daily/reminders,月报连对应路由都不存在),所以团队视图不画这个按钮——
// 画一个点了必炸404的按钮不算"镜像",是给用户挖坑。
function MonthlyReportList({team, myUserId, onToast}) {
const cacheKey = 'monthly-reports:' + (team ? 'managed' : 'mine');
const statsCacheKey = 'monthly-stats:' + toMonthInputValue(new Date());
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);
getMonthlyReports({scope: team ? 'managed' : 'mine'}).then(d => {
const list = d.reports || d.items || [];
writeCachedData(cacheKey, list);
setReports(list);
}).catch(err => { onToast(err.message || '月报列表加载失败'); setLoadError(true); setReports(prev => prev || []); });
if (team) getMonthlyStatistics(toMonthInputValue(new Date())).then(s => { writeCachedData(statsCacheKey, s); setStats(s); }).catch(() => {});
};
React.useEffect(load, [team]);
const filtered = (reports || []).filter(r => {
const hay = team ? `${names[r.author_id] || ''}${r.work_summary_short || ''}` : r.month;
return (hay || '').includes(query);
});
return <div className="workplace-page report-list-page">
<Header title="月报" action="add" actionLabel="填写月报" onAction={() => navigate('monthly-report-editor')} back={team ? 'monthly-report-mine' : 'workbench'} />
<main>
{team && stats ? <section className="report-statistics">
<header><span><small>{formatMonthLabel(toMonthInputValue(new Date()))}</small><strong>全公司</strong></span>
<button type="button" onClick={() => navigateReplace('monthly-report-mine')}>我的月报</button></header>
<div><span><b>{stats.expected_count || 0}</b><small>应提交</small></span>
<span><b>{stats.submitted_count || 0}</b><small>已提交</small></span>
<span><b>{(stats.expected_count || 0) - (stats.submitted_count || 0)}</b><small>未提交</small></span></div>
</section> : null}
<label className="workplace-search"><Icon>search</Icon>
<input value={query} onChange={e => setQuery(e.target.value)} placeholder={team ? '搜索姓名或内容' : '搜索月份或内容'} /></label>
{!team ? <nav className="report-role-switch">
<button type="button" className="active">我的月报</button>
<button type="button" onClick={() => navigateReplace('monthly-report-team')}>团队统计</button>
</nav> : null}
<section className="report-cards">
{reports == null ? <p className="organization-loading">加载中…</p> :
filtered.length === 0 ? <p className="organization-loading">{loadError ? '月报列表加载失败' : '暂无月报'}</p> :
filtered.map(r => <button type="button" key={r.report_id}
onClick={() => navigate((r.action || (r.status === 'submitted' ? 'view' : 'edit')) === 'view' ? 'monthly-report-detail' : 'monthly-report-editor', {report_id: r.report_id, team: team ? '1' : ''})}>
<span className="report-month"><Icon>calendar_month</Icon></span>
<span><strong>{team ? (names[r.author_id] || r.author_id) : formatMonthLabel(r.month)}</strong>
<small>{team ? formatMonthLabel(r.month) : '工作总结与下月计划'}</small></span>
<em className={r.status}>{STATUS_LABEL[r.status] || r.status}</em>
<Icon>chevron_right</Icon>
</button>)}
</section>
</main>
</div>;
}
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 <div className="workplace-page"><Header title="查看详情" back={backTarget} /><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={backTarget} />
<main className="report-detail">
<section className="report-author"><span><strong>{names[report.author_id] || '…'}</strong><small className="role-badge">员工</small></span>
<em className={report.status}>{STATUS_LABEL[report.status] || report.status}</em></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.next_plan || '未填写'}</p></section>
{canReopen ? <section><button type="button" className="workplace-primary" disabled={reopening} onClick={reopen}>
{reopening ? '处理中…' : '重新编辑'}</button></section> : null}
</main>
</div>;
}
// docs/images/总经理信箱.png has no subject input at all -- just 反馈内容 + 附件 + 实名/匿名. The
// backend's submission body does accept a real, stored `subject` (store/types/mailbox.go), but
// never validates it, so a derived summary is sent without exposing a box the image doesn't show.
function deriveSubject(content) {
const first = content.trim().split('\n')[0];
return first.length > 30 ? first.slice(0, 30) + '…' : first;
}
function ExecutiveMailbox({tinode, noticeOnly, onToast}) {
const [config, setConfig] = React.useState(() => readCachedData('executive-mailbox-config') || null);
const [content, setContent] = React.useState('');
const [anonymous, setAnonymous] = React.useState(null);
const [attachments, setAttachments] = React.useState([]);
const [uploading, setUploading] = React.useState(false);
const [submitting, setSubmitting] = React.useState(false);
const [noticeOpen, setNoticeOpen] = React.useState(!!noticeOnly);
const fileInputRef = React.useRef(null);
React.useEffect(() => {
getExecutiveMailboxConfig().then(c => { writeCachedData('executive-mailbox-config', c); setConfig(c); })
// Keep a prior good value on a transient failure rather than clobbering it to the disabled
// placeholder -- only fall back to {enabled:false} when there's truly nothing cached yet.
.catch(() => setConfig(prev => prev || {enabled: false}));
}, []);
// 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}));
}).catch(err => onToast(err.message || '附件上传失败')).finally(() => setUploading(false));
};
const submit = () => {
if (!content.trim()) { onToast('请填写反馈内容'); return; }
if (config && config.allow_anonymous && !anonymous) { onToast('请选择实名反馈或匿名反馈'); return; }
setSubmitting(true);
submitExecutiveMailboxMessage({
subject: deriveSubject(content),
content: content.trim(),
anonymous: anonymous === 'anonymous',
attachment_file_ids: attachments.map(a => a.fileId)
}).then(_ => {
onToast('反馈成功');
setContent(''); setAttachments([]); setAnonymous(null);
}).catch(err => onToast(err.message || '提交失败')).finally(() => setSubmitting(false));
};
if (config && !config.enabled) {
return <div className="workplace-page mailbox-page"><Header title="总经理信箱" back="workbench" />
<main><p className="organization-loading">总经理信箱当前不可用</p></main></div>;
}
return <div className="workplace-page mailbox-page">
<Header title="总经理信箱" back="workbench" action={config && config.notice ? 'info_outline' : null} actionLabel="查看使用说明" onAction={() => setNoticeOpen(true)} />
<main>
<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>附件({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>
<input ref={fileInputRef} type="file" style={{display: 'none'}} onChange={handleFile} />
{attachments.length ? <ul className="mailbox-attachment-list">
{attachments.map((a, i) => <li key={a.fileId}>{a.name}<i className="material-icons" onClick={() => setAttachments(v => v.filter((_, idx) => idx !== i))}>close</i></li>)}
</ul> : null}
<button type="button" className="workplace-primary" disabled={submitting || (config == null)} onClick={submit}>{submitting ? '提交中…' : '提交'}</button>
<div className="mailbox-anonymous-choice">
<label><input type="radio" name="mailbox-anon" checked={anonymous === 'named'} onChange={() => setAnonymous('named')} /><span>实名反馈</span></label>
<label><input type="radio" name="mailbox-anon" checked={anonymous === 'anonymous'} onChange={() => setAnonymous('anonymous')}
disabled={config && !config.allow_anonymous} /><span>匿名反馈</span></label>
</div>
</main>
{noticeOpen && config && config.notice ? <div className="mailbox-notice-backdrop">
<section role="dialog" aria-modal="true" className="mailbox-notice-dialog">
<Icon>policy</Icon>
<h2>{config.notice.title || '信箱使用与隐私说明'}</h2>
<p>{config.notice.content}</p>
<button type="button" onClick={() => { setNoticeOpen(false); if (noticeOnly) navigate('executive-mailbox'); }}>我已了解</button>
</section>
</div> : null}
</div>;
}
export default function WorkplaceCollaborationView({view, hashParams, myUserId, tinode, onTopicSelected}) {
const [toast, setToast] = React.useState('');
let content;
if (view === 'workbench') content = <Workbench onToast={setToast} />;
else if (view === 'organization-directory') content = <OrganizationDirectory myUserId={myUserId} />;
else if (view === 'meeting-organizer' || view === 'meeting-attendee') content = <Meetings onToast={setToast} />;
else if (view === 'meeting-create') content = <MeetingCreate myUserId={myUserId} tinode={tinode} onToast={setToast} />;
else if (view === 'meeting-detail') content = <MeetingDetail hashParams={hashParams} onToast={setToast} />;
else if (view === 'daily-report-editor') content = <DailyReportEditor onToast={setToast} />;
else if (view === 'monthly-report-editor') content = <MonthlyReportEditor onToast={setToast} />;
else if (view === 'daily-report-mine' || view === 'daily-report-team') content = <DailyReportList team={view === 'daily-report-team'} myUserId={myUserId} onToast={setToast} />;
else if (view === 'monthly-report-mine' || view === 'monthly-report-team') content = <MonthlyReportList team={view === 'monthly-report-team'} myUserId={myUserId} onToast={setToast} />;
else if (view === 'daily-report-detail') content = <DailyReportDetail hashParams={hashParams} myUserId={myUserId} onToast={setToast} />;
else if (view === 'monthly-report-detail') content = <MonthlyReportDetail hashParams={hashParams} myUserId={myUserId} onToast={setToast} />;
else content = <ExecutiveMailbox tinode={tinode} noticeOnly={view === 'executive-mailbox-notice'} onToast={setToast} />;
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
}