完成 IM Web Android 端产品页面开发

This commit is contained in:
richjohnrambo
2026-08-25 18:30:40 +08:00
parent ca5e74ee98
commit ba8f48ee83
19 changed files with 20783 additions and 54 deletions
+431
View File
@@ -0,0 +1,431 @@
import React from 'react';
import HashNavigation from '../lib/navigation.js';
const USERNAME_RE = /^[a-z0-9]{1,10}$/;
const PHONE_RE = /^\d{11}$/;
const INVITE_RE = /^[A-Za-z]{5}$/;
const CREDIT_CODE_RE = /^[0-9A-Z]{18}$/;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const navigate = view => HashNavigation.navigateTo(view);
function Icon({children, className = ''}) {
return <i className={'material-icons ' + className} aria-hidden="true">{children}</i>;
}
function AppMark({tenantName}) {
return <div className="auth-brand">
<div className="auth-logo"><span>J</span></div>
<h1>{tenantName || '即时通讯'}</h1>
</div>;
}
function Header({title, back = 'tenant'}) {
return <header className="auth-header">
<button type="button" className="auth-icon-button" onClick={() => navigate(back)} aria-label="返回">
<Icon>arrow_back_ios_new</Icon>
</button>
<h1>{title}</h1>
<span className="auth-header-spacer" />
</header>;
}
function Field({icon, action, error, className = '', ...props}) {
return <div className={'auth-field-wrap ' + className}>
<div className={'auth-field' + (error ? ' invalid' : '')}>
{icon ? <Icon className="auth-field-icon">{icon}</Icon> : null}
<input {...props} />
{action}
</div>
{error ? <div className="auth-field-error">{error}</div> : null}
</div>;
}
function PasswordField(props) {
const [visible, setVisible] = React.useState(false);
return <Field {...props} type={visible ? 'text' : 'password'}
action={<button type="button" className="auth-inline-icon" onClick={() => setVisible(!visible)}
aria-label={visible ? '隐藏密码' : '显示密码'}><Icon>{visible ? 'visibility' : 'visibility_off'}</Icon></button>} />;
}
function Agreement({checked, onChange, prefix = '同意捷信密使'}) {
return <label className="auth-agreement">
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} />
<span>{prefix} <a href="#agreement" onClick={e => e.preventDefault()}>《用户协议》</a> 和 <a href="#privacy" onClick={e => e.preventDefault()}>《隐私政策》</a></span>
</label>;
}
function Toast({message, onDone}) {
React.useEffect(() => {
if (!message) return undefined;
const timer = window.setTimeout(onDone, 2800);
return () => window.clearTimeout(timer);
}, [message, onDone]);
return message ? <div className="auth-toast" role="status"><Icon>check_circle</Icon>{message}</div> : null;
}
function TenantEntry({onTenantSelected}) {
const [code, setCode] = React.useState(sessionStorage.getItem('im-tenant-code') || '');
const [error, setError] = React.useState('');
const submit = 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');
};
return <div className="auth-landing">
<button className="auth-top-link" type="button" onClick={() => navigate('enterprise-register')}>企业注册</button>
<form className="auth-landing-form" onSubmit={submit}>
<AppMark />
<Field value={code} onChange={e => { setCode(e.target.value); setError(''); }}
placeholder="请输入企业码" autoComplete="organization" maxLength="64" error={error} autoFocus />
<button className="auth-primary" type="submit" disabled={!code.trim()}>进入企业</button>
</form>
<button className="auth-bottom-link" type="button" onClick={() => navigate('enterprise-feedback')}>遇到问题?</button>
</div>;
}
function Login({tenantName}) {
const [account, setAccount] = React.useState('');
const [password, setPassword] = React.useState('');
const [agreed, setAgreed] = React.useState(false);
const [error, setError] = React.useState('');
const [toast, setToast] = React.useState('');
const submit = e => {
e.preventDefault();
if (!account.trim() || !password || !agreed) return;
if (password === 'error') {
setPassword('');
setError('账号或密码错误');
} else {
setToast('登录成功');
window.setTimeout(() => navigate('messages-home'), 500);
}
};
return <div className="auth-landing auth-login-page">
<button type="button" className="auth-back-link" onClick={() => navigate('tenant')}><Icon>arrow_back_ios_new</Icon>切换企业</button>
<form className="auth-landing-form" onSubmit={submit}>
<AppMark tenantName={tenantName} />
{error ? <div className="auth-form-alert"><Icon>error_outline</Icon>{error}</div> : null}
<Field value={account} onChange={e => { setAccount(e.target.value); setError(''); }}
placeholder="请输入用户名/手机号" autoComplete="username" />
<PasswordField value={password} onChange={e => { setPassword(e.target.value); setError(''); }}
placeholder="请输入密码" autoComplete="current-password" />
<div className="auth-row auth-row-end"><button type="button" className="auth-text-button" onClick={() => navigate('reset')}>忘记密码</button></div>
<button className="auth-primary" type="submit" disabled={!account.trim() || !password || !agreed}>登录</button>
<p className="auth-switch">没有账户? <button type="button" onClick={() => navigate('register')}>马上注册</button></p>
</form>
<Agreement checked={agreed} onChange={setAgreed} />
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}
function EnterpriseRegister() {
const [creditCode, setCreditCode] = React.useState('');
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 [toast, setToast] = React.useState('');
React.useEffect(() => () => file && URL.revokeObjectURL(file.url), [file]);
const chooseFile = e => {
const selected = e.target.files[0];
if (!selected) return;
if (selected.size > 10 * 1024 * 1024 || !['image/jpeg', 'image/png', 'image/webp', 'application/pdf'].includes(selected.type)) {
setErrors({...errors, file: '支持 JPG、PNG、WebP、PDF,文件不超过 10MB'});
return;
}
if (file) URL.revokeObjectURL(file.url);
setFile({name: selected.name, type: selected.type, url: URL.createObjectURL(selected)});
setErrors({...errors, file: ''});
};
const submit = 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('提交成功,我们会尽快联系你');
};
return <div className="auth-page">
<Header title="企业注册" />
<main className="auth-content auth-content-narrow">
<ol className="auth-steps">
<li className="active"><span>1</span><b>填写企业信息</b></li>
<li><span>2</span><b>确定信息</b></li>
<li><span>3</span><b>提交审核</b></li>
</ol>
<form className="auth-form" onSubmit={submit}>
<label className="auth-label">统一社会信用代码</label>
<Field value={creditCode} onChange={e => setCreditCode(e.target.value.trim().toUpperCase().replace(/[^0-9A-Z]/g, ''))}
placeholder="请输入18位统一社会信用代码" maxLength="18" icon="qr_code_scanner" error={errors.creditCode} />
<p className="auth-help">请填写营业执照上的统一社会信用代码</p>
<label className="auth-label">上传营业执照 <span>选填</span></label>
<label className={'auth-upload' + (errors.file ? ' invalid' : '')}>
<input type="file" accept="image/jpeg,image/png,image/webp,application/pdf" onChange={chooseFile} />
{file && file.type !== 'application/pdf' ? <img src={file.url} alt="营业执照预览" /> : <Icon>add_photo_alternate</Icon>}
<strong>{file ? file.name : '点击上传营业执照'}</strong>
<small>支持 JPG、PNG、WebP、PDF,不超过 10MB</small>
</label>
{file ? <button type="button" className="auth-remove-file" onClick={() => { URL.revokeObjectURL(file.url); setFile(null); }}>移除文件</button> : null}
{errors.file ? <div className="auth-field-error">{errors.file}</div> : null}
<label className="auth-label">企业邮箱 <span>选填</span></label>
<Field icon="mail_outline" value={email} onChange={e => setEmail(e.target.value)}
placeholder="请输入企业邮箱" type="email" error={errors.email} />
<p className="auth-help">用于接收审核结果及企业通知</p>
<button className="auth-primary" type="submit" disabled={submitted || creditCode.length !== 18}>{submitted ? '已提交' : '提交资料'}</button>
<p className="auth-review-time"><Icon>schedule</Icon>资料提交后,预计 1 个工作日完成审核</p>
</form>
</main>
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}
function EnterpriseFeedback() {
const [description, setDescription] = React.useState('');
const [contact, setContact] = React.useState('');
const [files, setFiles] = React.useState([]);
const [error, setError] = React.useState('');
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)}))));
};
const submit = e => {
e.preventDefault();
const length = description.trim().length;
if (length < 10 || length > 1000) {
setError('问题描述需为 10 至 1000 个字符');
return;
}
setToast('已收到反馈,我们会尽快处理');
files.forEach(item => URL.revokeObjectURL(item.url));
setDescription(''); setContact(''); setFiles([]);
};
return <div className="auth-page auth-soft-bg">
<Header title="问题反馈" />
<main className="auth-content auth-content-narrow">
<form className="auth-form" onSubmit={submit}>
<label className="auth-label">问题描述</label>
<textarea value={description} maxLength="1000" onChange={e => { setDescription(e.target.value); setError(''); }}
placeholder="请详细描述遇到的问题,至少输入10个字" className={error ? 'invalid' : ''} />
<div className="auth-counter">{description.length}/1000</div>
{error ? <div className="auth-field-error">{error}</div> : null}
<label className="auth-label">上传截图 <span>最多 9 张</span></label>
<div className="auth-screenshot-grid">
{files.map((file, index) => <div className="auth-screenshot" key={file.url}>
<img src={file.url} alt={file.name} />
<button type="button" onClick={() => { URL.revokeObjectURL(file.url); setFiles(files.filter((_, i) => i !== index)); }} aria-label="删除截图"><Icon>close</Icon></button>
</div>)}
{files.length < 9 ? <label className="auth-add-screenshot"><input type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={addFiles} /><Icon>add</Icon></label> : null}
</div>
<label className="auth-label">联系方式 <span>选填</span></label>
<Field value={contact} onChange={e => setContact(e.target.value)} placeholder="手机号或邮箱,便于我们联系您" />
<button className="auth-primary" type="submit" disabled={description.trim().length < 10}>提交反馈</button>
</form>
</main>
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}
function VerificationButton({phone, scene = 'register'}) {
const [seconds, setSeconds] = React.useState(0);
React.useEffect(() => {
if (!seconds) return undefined;
const timer = window.setTimeout(() => setSeconds(seconds - 1), 1000);
return () => window.clearTimeout(timer);
}, [seconds]);
return <button type="button" className="auth-code-button" disabled={seconds > 0 || !PHONE_RE.test(phone)}
onClick={() => { sessionStorage.setItem('im-verification-scene', scene); setSeconds(60); }}>
{seconds > 0 ? `${seconds}s` : '获取验证码'}
</button>;
}
function Register() {
const [form, setForm] = React.useState({username: '', phone: '', code: '', password: '', invite: ''});
const [agreed, setAgreed] = React.useState(false);
const [errors, setErrors] = React.useState({});
const [toast, setToast] = React.useState('');
const change = key => e => setForm({...form, [key]: e.target.value});
const submit = e => {
e.preventDefault();
const next = {};
if (!form.username && !form.phone) next.username = '用户名和手机号至少填写一项';
if (form.username && !USERNAME_RE.test(form.username)) next.username = '用户名仅支持 1-10 位小写字母或数字';
if (form.phone && !PHONE_RE.test(form.phone)) next.phone = '请输入 11 位手机号';
if (!form.phone && !INVITE_RE.test(form.invite)) next.invite = '用户名注册需填写 5 位字母邀请码';
if (form.invite && !INVITE_RE.test(form.invite)) next.invite = '邀请码为 5 位大小写字母';
if (form.password.length < 8) next.password = '密码至少 8 位';
setErrors(next);
if (Object.keys(next).length) return;
setToast(form.phone ? '注册信息已准备,接口将在联调阶段启用' : '未绑定手机号,忘记密码需联系企业管理员');
};
return <div className="auth-landing auth-register-page">
<button type="button" className="auth-back-link" onClick={() => navigate('login')}><Icon>arrow_back_ios_new</Icon>返回登录</button>
<form className="auth-landing-form auth-register-form" onSubmit={submit}>
<AppMark tenantName={sessionStorage.getItem('im-tenant-name') || '讯工捷迅'} />
<Field value={form.username} onChange={e => setForm({...form, username: e.target.value.replace(/[^a-z0-9]/g, '').slice(0, 10)})}
placeholder="请输入用户名" error={errors.username} autoComplete="username" />
<Field value={form.phone} onChange={e => setForm({...form, phone: e.target.value.replace(/\D/g, '').slice(0, 11)})}
placeholder="请输入手机号" error={errors.phone} inputMode="numeric" />
<Field value={form.code} onChange={change('code')} placeholder="请输入短信验证码" inputMode="numeric"
action={<VerificationButton phone={form.phone} />} />
<PasswordField value={form.password} onChange={change('password')} placeholder="请输入密码" autoComplete="new-password" error={errors.password} />
<Field value={form.invite} onChange={e => setForm({...form, invite: e.target.value.replace(/[^A-Za-z]/g, '').slice(0, 5)})}
placeholder="请输入邀请码(非必填)" error={errors.invite} />
<button className="auth-primary" type="submit" disabled={(!form.username && !form.phone) || !form.password || !agreed}>注册</button>
<p className="auth-switch">已有讯工捷迅账号? <button type="button" onClick={() => navigate('login')}>登录</button></p>
</form>
<Agreement checked={agreed} onChange={setAgreed} prefix="注册即认同捷讯" />
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}
function PasswordReset() {
const [form, setForm] = React.useState({phone: '', code: '', password: '', confirm: ''});
const [error, setError] = React.useState('');
const [toast, setToast] = React.useState('');
const change = key => e => setForm({...form, [key]: e.target.value});
const submit = e => {
e.preventDefault();
if (!PHONE_RE.test(form.phone)) return setError('请输入 11 位手机号');
if (form.password.length < 8 || form.password.length > 20) return setError('密码需为 8-20 位字母或数字组合');
if (form.password !== form.confirm) return setError('两次输入的密码不一致');
setError(''); setToast('密码修改成功,请重新登录');
window.setTimeout(() => navigate('login'), 1200);
};
return <div className="auth-page auth-soft-bg">
<Header title="忘记密码" back="login" />
<main className="auth-content auth-content-narrow auth-reset-content">
<form className="auth-list-form" onSubmit={submit}>
<label><span>手机号</span><input value={form.phone} onChange={e => setForm({...form, phone: e.target.value.replace(/\D/g, '').slice(0, 11)})} placeholder="请输入手机号" inputMode="numeric" /></label>
<label><span>验证码</span><input value={form.code} onChange={change('code')} placeholder="请输入验证码" inputMode="numeric" /><VerificationButton phone={form.phone} scene="reset_password" /></label>
<div className="auth-list-gap" />
<label><span>设置密码</span><input type="password" value={form.password} onChange={change('password')} placeholder="8-20位字母或数字组合" /></label>
<label><span>再次输入</span><input type="password" value={form.confirm} onChange={change('confirm')} placeholder="请再次确定密码" /></label>
{error ? <div className="auth-form-alert"><Icon>error_outline</Icon>{error}</div> : null}
<button className="auth-primary auth-sticky-action" type="submit" disabled={!form.phone || !form.code || !form.password || !form.confirm}>修改密码</button>
</form>
</main>
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}
function IdentityCheck({authenticated}) {
const [phone] = React.useState('138****8899');
const [code, setCode] = React.useState('');
const [toast, setToast] = React.useState('');
return <div className="auth-page auth-soft-bg">
<Header title="身份验证" back={authenticated ? 'security' : 'login'} />
<main className="auth-content auth-content-narrow auth-reset-content">
<form className="auth-list-form" onSubmit={e => { e.preventDefault(); setToast('身份验证成功'); }}>
<label><span>手机号</span><input value={phone} readOnly /></label>
<label><span>验证码</span><input value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="请输入验证码" inputMode="numeric" /><VerificationButton phone="13800008899" scene="security" /></label>
<button className="auth-primary auth-sticky-action" type="submit" disabled={code.length !== 6}>确定</button>
</form>
</main>
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}
function Realname({verified = false, authenticated = false}) {
const [name, setName] = React.useState(verified ? '蒋欣' : '');
const [idNumber, setIdNumber] = React.useState(verified ? '43993838581999355454' : '');
const [status, setStatus] = React.useState(verified ? 'verified' : 'unverified');
const [error, setError] = React.useState('');
const submit = e => {
e.preventDefault();
if (name.trim().length < 2 || !/^\d{17}[\dXx]$/.test(idNumber)) {
setError('请输入有效姓名和 18 位身份证号码'); return;
}
setStatus('pending'); setError('');
};
return <div className="auth-page auth-realname-page">
<Header title="实名认证" back={authenticated ? 'security' : 'login'} />
<div className="auth-realname-hero"><div><strong>实名认证</strong><span>更加真实,更受欢迎</span></div><Icon>verified_user</Icon></div>
<main className="auth-realname-content">
<h2>{status === 'verified' ? '认证信息' : '请您使用有效身份证信息认证'}</h2>
<form onSubmit={submit}>
<label><span>姓名</span><input value={status === 'verified' ? name[0] + '*' : name} onChange={e => setName(e.target.value)} readOnly={status !== 'unverified'} placeholder="请输入真实姓名" /></label>
<label><span>证件号码</span><input value={status === 'verified' ? idNumber.slice(0, 3) + '***********' + idNumber.slice(-4) : idNumber} onChange={e => setIdNumber(e.target.value)} readOnly={status !== 'unverified'} placeholder="请输入证件号码" /></label>
{error ? <div className="auth-field-error">{error}</div> : null}
<button className="auth-primary" type="submit" disabled={status !== 'unverified'}>{status === 'verified' ? '已实名' : status === 'pending' ? '审核中' : '提交'}</button>
</form>
<p className="auth-privacy-note"><Icon>info</Icon>我们将获取您的姓名、证件号用于认证,信息会被严格保护</p>
</main>
</div>;
}
function RealnameReminder() {
return <div className="auth-page auth-reminder-demo">
<div className="auth-demo-chat"><h1>消息</h1><div className="auth-demo-search"><Icon>search</Icon>搜索</div>{['系统消息', '文件助手', '项目协作群', '陈经理'].map((item, i) => <div className="auth-demo-row" key={item}><span>{item[0]}</span><div><b>{item}</b><small>{i ? '最近一条消息内容' : '安全通知与账号动态'}</small></div></div>)}</div>
<div className="auth-modal-backdrop">
<div className="auth-modal"><h2>实名认证提醒</h2><p>根据国家法律法规要求,您需先进行实名认证才能使用该应用</p><button className="auth-primary" onClick={() => navigate('realname')}>去实名认证</button><button className="auth-text-button" onClick={() => navigate('tenant')}>稍后处理</button></div>
</div>
</div>;
}
function IndependentPassword({authenticated}) {
const [pin, setPin] = React.useState('');
const [error, setError] = React.useState('');
const [toast, setToast] = React.useState('');
const invalid = ['0000','1111','2222','3333','4444','5555','6666','7777','8888','9999','0123','1234','2345','3456','4567','5678','6789','9876','8765','7654','6543','5432','4321','3210'];
const submit = e => {
e.preventDefault();
if (invalid.includes(pin)) { setError('请勿使用连续或重复数字'); return; }
setError(''); setToast('独立密码设置成功'); setPin('');
};
return <div className="auth-page">
<Header title="" back={authenticated ? 'security' : 'login'} />
<main className="auth-pin-content">
<h1>设置独立密码</h1>
<div className="auth-pin-boxes" aria-hidden="true">{[0,1,2,3].map(i => <span key={i}>{pin[i] ? '●' : ''}</span>)}</div>
<input className="auth-pin-input" type="text" value={pin} onChange={e => setPin(e.target.value.replace(/\D/g, '').slice(0, 4))} inputMode="numeric" autoComplete="off" aria-label="四位独立密码" autoFocus />
{error ? <div className="auth-field-error">{error}</div> : null}
<button className="auth-primary" disabled={pin.length !== 4} onClick={submit}>确定</button>
</main>
<Toast message={toast} onDone={() => setToast('')} />
</div>;
}
export default function AuthOnboardingView({view, onTenantSelected, authenticated = false}) {
const tenantName = sessionStorage.getItem('im-tenant-name') || '';
switch (view) {
case 'tenant': return <TenantEntry onTenantSelected={onTenantSelected || (() => {})} />;
case 'login': return <Login tenantName={tenantName} />;
case 'enterprise-register': return <EnterpriseRegister />;
case 'enterprise-feedback': return <EnterpriseFeedback />;
case 'register': return <Register />;
case 'reset': return <PasswordReset />;
case 'identity-check': return <IdentityCheck authenticated={authenticated} />;
case 'realname': return <Realname authenticated={authenticated} />;
case 'realname-verified': return <Realname verified authenticated={authenticated} />;
case 'realname-reminder': return <RealnameReminder />;
case 'independent-password': return <IndependentPassword authenticated={authenticated} />;
default: return <TenantEntry onTenantSelected={onTenantSelected || (() => {})} />;
}
}