diff --git a/css/base.css b/css/base.css index 10c53fcd..f0574e7c 100644 --- a/css/base.css +++ b/css/base.css @@ -507,11 +507,11 @@ color: #fff; } -.workbench-apps span.violet { background: #7659d6; } -.workbench-apps span.orange { background: #f07835; } -.workbench-apps span.red { background: #df4e46; } -.workbench-apps span.blue { background: #3e78d6; } -.workbench-apps span.green { background: #35a96b; } +.workbench-apps span.tone-violet { background: #7659d6; } +.workbench-apps span.tone-orange { background: #f07835; } +.workbench-apps span.tone-red { background: #df4e46; } +.workbench-apps span.tone-blue { background: #3e78d6; } +.workbench-apps span.tone-green { background: #35a96b; } .workbench-apps .material-icons { font-size: 2rem; @@ -1072,7 +1072,11 @@ .report-cards article > header { display: grid; - grid-template-columns: 2.6rem minmax(0, 1fr) auto; + /* DailyReportCard (the only thing rendered into .report-cards) never renders an avatar -- + confirmed no JSX inserts an here -- so the leading 2.6rem avatar column was dead weight + that stole space from the name column with only 2 real children (name+badge span, status ), + squeezing the badge into wrapping onto its own line for any name long enough to fill it. */ + grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0.6rem; } @@ -1532,6 +1536,353 @@ background: #347bd7; } +/* Ch.9 additions: real data needs an arbitrary-depth org tree, repeatable daily-report time + blocks, meeting detail/create screens, and a couple of small mailbox extras that the original + demo (fixed 2-level tree, single-shot report form) didn't need. */ + +.organization-loading { + padding: 2rem 1rem; + color: #8b929c; + text-align: center; +} + +.organization-department { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + min-height: 3.4rem; + margin: 0; + padding: 0 1rem; + border: 0; + border-bottom: 1px solid #eceef1; + border-radius: 0; + box-shadow: none; + color: #30353c; + background: #fff; + text-align: left; + text-transform: none; +} + +.organization-department > .material-icons:first-child { + color: #8b929c; + font-size: 1.3rem; +} + +.organization-department.expanded { + color: #2094f3; +} + +.organization-department.expanded > .material-icons:first-child { + color: #2094f3; +} + +.organization-department > span { + flex: 1 1 auto; + min-width: 0; +} + +.organization-department > span strong { + display: block; + overflow: hidden; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.organization-department > .material-icons:last-child { + color: #b0b6bd; +} + +.organization-person { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + min-height: 3.2rem; + margin: 0; + padding: 0 1rem; + border: 0; + border-bottom: 1px solid #eceef1; + border-radius: 0; + box-shadow: none; + color: #30353c; + background: #fafbfc; + text-align: left; + text-transform: none; +} + +.organization-person > .material-icons:first-child { + color: #b0b6bd; + font-size: 1.1rem; +} + +.organization-person > span { + flex: 1 1 auto; + min-width: 0; +} + +.organization-person > span strong { + overflow: hidden; + font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; +} + +.organization-person.me { + color: #2094f3; +} + +.organization-person.me > span strong { + color: #2094f3; + font-weight: 500; +} + +.organization-person > .material-icons:last-child { + color: #b0b6bd; +} + +.meeting-more { + display: flex; + align-items: center; + flex: none; + margin: 0; + padding: 0; + border: 0; + box-shadow: none; + color: #b0b6bd; + background: transparent; + font-size: 0.85rem; +} + +.meeting-attendees { + color: #347bd7; +} + +.meeting-responded { + width: 100%; + min-height: 2.4rem; + margin: 0; + padding: 0 1rem; + border: 0; + border-radius: 0.3rem; + box-shadow: none; + color: #8b929c; + background: #f2f3f5; +} + +.meeting-participant-list { + margin: 0.8rem 0 0; + padding: 0.6rem 0 0; + border-top: 1px solid #eceef1; + list-style: none; +} + +.meeting-participant-list li { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 2.3rem; +} + +.meeting-participant-list em { + padding: 0.15rem 0.5rem; + border-radius: 0.25rem; + color: #8b929c; + background: #f2f3f5; + font-size: 0.72rem; + font-style: normal; +} + +.meeting-participant-list em.accepted { color: #28784f; background: #e5f5ec; } +.meeting-participant-list em.declined { color: #ad3d45; background: #fbe9eb; } +.meeting-participant-list em.tentative { color: #ad6c11; background: #fff2dd; } + +.meeting-status footer { + display: flex; + gap: 0.6rem; + margin-top: 0.8rem; +} + +.meeting-status footer button { + flex: 1 1 auto; + min-height: 2.6rem; + margin: 0; + border: 0; + border-radius: 0.3rem; + box-shadow: none; + color: #347bd7; + background: #eaf2fc; +} + +.meeting-status footer button.danger { + color: #ad3d45; + background: #fbe9eb; +} + +.meeting-time-range { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.meeting-time-range .report-field { + flex: 1 1 0; +} + +.meeting-time-range span { + color: #8b929c; +} + +.meeting-selected-attendees { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin: -0.4rem 0 0.8rem; +} + +.meeting-selected-attendees span { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.3rem 0.5rem; + border-radius: 999px; + background: #eaf2fc; + color: #347bd7; + font-size: 0.82rem; +} + +.meeting-selected-attendees .material-icons { + font-size: 0.9rem; + cursor: pointer; +} + +.daily-report-block { + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid #eceef1; +} + +.daily-report-block-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.6rem; +} + +.daily-report-block-header .material-icons { + color: #8b929c; + cursor: pointer; +} + +.daily-report-date { + display: block; + margin: -0.3rem 0 0.5rem; + color: #8b929c; +} + +.daily-report-add-block { + display: flex; + align-items: center; + justify-content: center; + gap: 0.3rem; + width: 100%; + min-height: 2.6rem; + margin: 0 0 1rem; + border: 1px dashed #d5d9dd; + border-radius: 0.3rem; + box-shadow: none; + color: #347bd7; + background: #fff; +} + +.report-draft-status { + margin: 0; + color: #8b929c; + font-size: 0.82rem; + text-align: right; +} + +.report-cards article > header .role-badge, +.report-author .role-badge { + display: inline-block; + flex-shrink: 0; + margin-left: 0.4rem; + padding: 0.1rem 0.4rem; + border-radius: 0.25rem; + color: #347bd7; + background: #eaf2fc; + font-size: 0.72rem; + white-space: nowrap; +} + +/* The name before this badge has no length cap of its own (falls back to the raw account id when + no nickname is set, which can run much longer than a real name) -- let it truncate instead of + squeezing the fixed-width badge next to it into wrapping onto its own line. */ +.report-cards article > header > span > strong, +.report-author > span > strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workplace-error { + padding: 0.8rem 1rem; + color: #ad3d45; + text-align: center; +} + +/* Specificity needs to beat `.workbench-profile span` (base.css, above) -- that rule's `display: + block` is meant for the plain text-label spans in this same row, but a bare `.workbench-avatar- + fallback` alone is one class vs. that rule's class+element, so it lost and fell back to block, + silently discarding this rule's own centering and leaving the icon glyph sitting in normal block + flow (top-left of the circle) instead of centered. */ +.workbench-profile .workbench-avatar-fallback { + display: flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + border-radius: 50%; + color: #fff; + background: #b0b6bd; +} + +.mailbox-anonymous-choice { + display: flex; + gap: 1.2rem; + justify-content: center; + margin-top: 0.8rem; +} + +.mailbox-anonymous-choice label { + display: flex; + align-items: center; + gap: 0.35rem; + color: #58606b; + font-size: 0.88rem; +} + +.mailbox-attachment-list { + margin: 0.5rem 0 0; + padding: 0; + list-style: none; +} + +.mailbox-attachment-list li { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 2.2rem; + color: #58606b; +} + +.mailbox-attachment-list .material-icons { + color: #8b929c; + cursor: pointer; + font-size: 1.1rem; +} + @media screen and (max-width: 480px) { .workbench-apps { grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -1694,7 +2045,17 @@ button.round.small { box-shadow: 0 2px 0.35rem 0 var(--clr-shadow-button); } -button:hover:not([disabled]), button:active:not([disabled]) { +/* Scoped to the variants whose *default* text is already white (.primary/.danger/.positive below) -- + for them this is a no-op safety net. It used to apply to every

{title}

@@ -65,21 +70,42 @@ function Toast({message, onDone}) { return message ?
check_circle{message}
: null; } -function TenantEntry({onTenantSelected}) { +// Shared alert for errors coming back from a real API/WS call (as opposed to local form validation, +// which each form already reports inline per-field). +function FormAlert({text}) { + return text ?
error_outline{text}
: null; +} + +function TenantEntry({onTenantResolved}) { const [code, setCode] = React.useState(sessionStorage.getItem('im-tenant-code') || ''); const [error, setError] = React.useState(''); + const [busy, setBusy] = React.useState(false); - const submit = e => { + const submit = async e => { e.preventDefault(); const value = code.trim(); if (!value || value.length > 64) { setError(value ? '企业码不能超过 64 个字符' : '请输入企业码'); return; } - sessionStorage.setItem('im-tenant-code', value); - sessionStorage.setItem('im-tenant-name', value === 'acme' ? '示例企业' : value); - onTenantSelected(value); - navigate('login'); + setBusy(true); + setError(''); + try { + const data = await resolveTenant(value); + // Kept for the mock UI's own display of the tenant name while on this screen; the durable + // context (read by every subsequent /v1/im/* call and by the {hi} tenant field) is persisted + // by onTenantResolved. + sessionStorage.setItem('im-tenant-code', data.tenant.code || value); + sessionStorage.setItem('im-tenant-name', data.tenant.name || data.tenant.code || value); + onTenantResolved(data, value); + navigate('login'); + } catch (err) { + // Per the product spec, tenant-resolve failures are shown as one generic message + // regardless of reason (not found vs. disabled vs. rate limited). + setError('企业码不存在或已停用,请核实后重试'); + } finally { + setBusy(false); + } }; return
@@ -88,46 +114,57 @@ function TenantEntry({onTenantSelected}) { { setCode(e.target.value); setError(''); }} placeholder="请输入企业码" autoComplete="organization" maxLength="64" error={error} autoFocus /> - +
; } -function Login({tenantName}) { - const [account, setAccount] = React.useState(''); +function Login({tenantName, login, loginDisabled, persist, errorText, errorLevel, onLoginRequest, onPersistenceChange, onError}) { + const [account, setAccount] = React.useState(login || ''); const [password, setPassword] = React.useState(''); const [agreed, setAgreed] = React.useState(false); - const [error, setError] = React.useState(''); - const [toast, setToast] = React.useState(''); + + // The prototype (docs/images/登录页面.png) has no "stay logged in" checkbox -- a consumer IM app + // just keeps you signed in. Opt in silently instead of exposing the old debug-era toggle. + React.useEffect(() => { + if (!persist) { + onPersistenceChange(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Real login failures (wrong password, disabled account, ...) clear the password and keep the account, + // matching the product spec. + React.useEffect(() => { + if (errorLevel === 'err' && errorText) { + setPassword(''); + } + }, [errorText, errorLevel]); const submit = e => { e.preventDefault(); - if (!account.trim() || !password || !agreed) return; - if (password === 'error') { - setPassword(''); - setError('账号或密码错误'); - } else { - setToast('登录成功'); - window.setTimeout(() => navigate('messages-home'), 500); - } + if (!account.trim() || !password || !agreed || loginDisabled) return; + onLoginRequest(account.trim(), password); }; return
- +
- {error ?
error_outline{error}
: null} - { setAccount(e.target.value); setError(''); }} + + setAccount(e.target.value)} placeholder="请输入用户名/手机号" autoComplete="username" /> - { setPassword(e.target.value); setError(''); }} + setPassword(e.target.value)} placeholder="请输入密码" autoComplete="current-password" />
- +

没有账户?

- setToast('')} />
; } @@ -136,7 +173,9 @@ function EnterpriseRegister() { const [email, setEmail] = React.useState(''); const [file, setFile] = React.useState(null); const [errors, setErrors] = React.useState({}); - const [submitted, setSubmitted] = React.useState(sessionStorage.getItem('im-enterprise-application-status') === 'received'); + const [submitting, setSubmitting] = React.useState(false); + const [submitted, setSubmitted] = React.useState(false); + const [reviewDays, setReviewDays] = React.useState(1); const [toast, setToast] = React.useState(''); React.useEffect(() => () => file && URL.revokeObjectURL(file.url), [file]); @@ -149,31 +188,52 @@ function EnterpriseRegister() { return; } if (file) URL.revokeObjectURL(file.url); - setFile({name: selected.name, type: selected.type, url: URL.createObjectURL(selected)}); + setFile({name: selected.name, type: selected.type, url: URL.createObjectURL(selected), blob: selected}); setErrors({...errors, file: ''}); }; - const submit = e => { + const submit = async e => { e.preventDefault(); const next = {}; if (!CREDIT_CODE_RE.test(creditCode)) next.creditCode = '请输入 18 位统一社会信用代码'; if (email && !EMAIL_RE.test(email)) next.email = '请输入有效的企业邮箱'; setErrors(next); if (Object.keys(next).length) return; - setSubmitted(true); - sessionStorage.setItem('im-enterprise-application-status', 'received'); - setToast('提交成功,我们会尽快联系你'); + + setSubmitting(true); + try { + let licenseFileId = ''; + if (file) { + const uploaded = await uploadPublicFile(file.blob, 'enterprise_license'); + licenseFileId = uploaded.file_id; + } + const data = await submitTenantApplication({ + credit_code: creditCode, + enterprise_email: email, + license_file_id: licenseFileId, + tenant_code_hint: sessionStorage.getItem('im-tenant-code') || '' + }); + // Prototype (docs/images/企业注册-填写注册信息.png) states this in business days, not hours. + setReviewDays(Math.max(1, Math.round((data.estimated_review_hours || 24) / 24))); + setSubmitted(true); + setToast('提交成功,我们会尽快联系你'); + } catch (err) { + setErrors({...next, form: err.message || '提交失败,请稍后重试'}); + } finally { + setSubmitting(false); + } }; return
    -
  1. 1填写企业信息
  2. +
  3. 1填写企业信息
  4. 2确定信息
  5. -
  6. 3提交审核
  7. +
  8. 3提交审核
+ setCreditCode(e.target.value.trim().toUpperCase().replace(/[^0-9A-Z]/g, ''))} placeholder="请输入18位统一社会信用代码" maxLength="18" icon="qr_code_scanner" error={errors.creditCode} /> @@ -193,187 +253,397 @@ function EnterpriseRegister() { setEmail(e.target.value)} placeholder="请输入企业邮箱" type="email" error={errors.email} />

用于接收审核结果及企业通知

- -

schedule资料提交后,预计 1 个工作日完成审核

+ +

schedule资料提交后,预计 {reviewDays} 个工作日完成审核

setToast('')} />
; } +// Per docs/images/企业注册-问题反馈.png: titled "意见反馈", 300-char textarea with the instructions +// as its placeholder (not a separate label), "图片(N/9)" counter-style label, and no contact field. function EnterpriseFeedback() { const [description, setDescription] = React.useState(''); - const [contact, setContact] = React.useState(''); const [files, setFiles] = React.useState([]); const [error, setError] = React.useState(''); + const [submitting, setSubmitting] = React.useState(false); const [toast, setToast] = React.useState(''); const addFiles = e => { const selected = Array.from(e.target.files || []).slice(0, 9 - files.length); const valid = selected.filter(file => file.size <= 10 * 1024 * 1024 && ['image/jpeg', 'image/png', 'image/webp'].includes(file.type)); - setFiles(files.concat(valid.map(file => ({name: file.name, url: URL.createObjectURL(file)})))); + setFiles(files.concat(valid.map(file => ({name: file.name, url: URL.createObjectURL(file), blob: file})))); }; - const submit = e => { + const submit = async e => { e.preventDefault(); const length = description.trim().length; - if (length < 10 || length > 1000) { - setError('问题描述需为 10 至 1000 个字符'); + if (length < 10 || length > 300) { + setError('问题描述需为 10 至 300 个字符'); return; } - setToast('已收到反馈,我们会尽快处理'); - files.forEach(item => URL.revokeObjectURL(item.url)); - setDescription(''); setContact(''); setFiles([]); + setSubmitting(true); + try { + const screenshotFileIds = []; + for (const item of files) { + const uploaded = await uploadPublicFile(item.blob, 'tenant_entry_feedback'); + screenshotFileIds.push(uploaded.file_id); + } + await submitTenantEntryFeedback({ + description: description.trim(), + tenant_code_hint: sessionStorage.getItem('im-tenant-code') || '', + screenshot_file_ids: screenshotFileIds + }); + setToast('已收到反馈,我们会尽快处理'); + files.forEach(item => URL.revokeObjectURL(item.url)); + setDescription(''); setFiles([]); setError(''); + } catch (err) { + setError(err.message || '提交失败,请稍后重试'); + } finally { + setSubmitting(false); + } }; return
-
+
- -