上一提交(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语义符合预期。
1152 lines
72 KiB
React
1152 lines
72 KiB
React
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('')} /></>;
|
||
}
|