完成 IM Web Android 端产品页面开发
This commit is contained in:
+9228
File diff suppressed because it is too large
Load Diff
@@ -130,6 +130,18 @@ class AccSecurityView extends React.Component {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="hr" />
|
<div className="hr" />
|
||||||
|
<div className="panel-form-column security-product-links">
|
||||||
|
<button type="button" className="flat-button" onClick={() => this.props.onNavigate('realname')}>
|
||||||
|
<i className="material-icons">verified_user</i><span>实名认证</span><i className="material-icons trailing">chevron_right</i>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="flat-button" onClick={() => this.props.onNavigate('identity-check')}>
|
||||||
|
<i className="material-icons">phonelink_lock</i><span>身份验证</span><i className="material-icons trailing">chevron_right</i>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="flat-button" onClick={() => this.props.onNavigate('independent-password')}>
|
||||||
|
<i className="material-icons">password</i><span>设置独立密码</span><i className="material-icons trailing">chevron_right</i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="hr" />
|
||||||
<div className="panel-form-column">
|
<div className="panel-form-column">
|
||||||
<a href="#" className="danger flat-button" onClick={(e) => {e.preventDefault(); this.props.onLogout();}}>
|
<a href="#" className="danger flat-button" onClick={(e) => {e.preventDefault(); this.props.onLogout();}}>
|
||||||
<i className="material-icons">exit_to_app</i> <FormattedMessage id="button_logout"
|
<i className="material-icons">exit_to_app</i> <FormattedMessage id="button_logout"
|
||||||
|
|||||||
@@ -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 || (() => {})} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import HashNavigation from '../lib/navigation.js';
|
||||||
|
|
||||||
|
const navigate = view => HashNavigation.navigateTo(view);
|
||||||
|
|
||||||
|
export const CALL_EXPERIENCE_VIEWS = [
|
||||||
|
'call-history', 'call-incoming', 'audio-call-ringing', 'audio-call-active',
|
||||||
|
'audio-call-muted', 'audio-call-speaker', 'video-call-ringing',
|
||||||
|
'video-call-camera-off', 'video-call-active', 'video-call-peer-camera-off',
|
||||||
|
'call-minimized'
|
||||||
|
];
|
||||||
|
|
||||||
|
const PEER = {name: '安妮', avatar: 'img/bkg/p09.jpg'};
|
||||||
|
|
||||||
|
const CALL_RECORDS = [
|
||||||
|
{id: 1, type: 'video', direction: 'outgoing', state: 'cancelled', label: '已取消', time: '今天 10:24'},
|
||||||
|
{id: 2, type: 'audio', direction: 'incoming', state: 'declined', label: '已拒绝', time: '昨天 21:03'},
|
||||||
|
{id: 3, type: 'video', direction: 'incoming', state: 'missed', label: '未接听', time: '昨天 18:42'},
|
||||||
|
{id: 4, type: 'audio', direction: 'outgoing', state: 'busy', label: '对方忙线', time: '星期一 15:10'},
|
||||||
|
{id: 5, type: 'audio', direction: 'outgoing', state: 'finished', label: '通话时长 12:36', time: '8月20日'}
|
||||||
|
];
|
||||||
|
|
||||||
|
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
|
||||||
|
|
||||||
|
function Toast({message, onDone}) {
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!message) return undefined;
|
||||||
|
const timer = window.setTimeout(onDone, 1800);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [message, onDone]);
|
||||||
|
return message ? <div className="call-toast" role="status">{message}</div> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CallAvatar({small = false}) {
|
||||||
|
return <span className={'call-product-avatar' + (small ? ' small' : '')}><img src={PEER.avatar} alt="" /></span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CallHeader({onMinimize, light = false}) {
|
||||||
|
return <header className={'call-product-header' + (light ? ' light' : '')}>
|
||||||
|
<button type="button" onClick={onMinimize} aria-label="最小化通话"><Icon>keyboard_arrow_down</Icon></button>
|
||||||
|
<span>端到端加密通话</span>
|
||||||
|
<button type="button" aria-label="更多通话设置"><Icon>more_vert</Icon></button>
|
||||||
|
</header>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Control({icon, label, active, danger, onClick}) {
|
||||||
|
return <button type="button" className={'call-control' + (active ? ' active' : '') + (danger ? ' danger' : '')} onClick={onClick}><span><Icon>{icon}</Icon></span><small>{label}</small></button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CallHistory() {
|
||||||
|
return <div className="call-history-page">
|
||||||
|
<header><button type="button" onClick={() => navigate('chat-standard')} aria-label="返回会话"><Icon>arrow_back_ios_new</Icon></button><h1>安妮</h1><button type="button" onClick={() => navigate('friend-profile')} aria-label="个人资料"><Icon>person_outline</Icon></button></header>
|
||||||
|
<main><p className="chat-time">最近通话</p>{CALL_RECORDS.map(item => <button type="button" className={'call-record ' + item.state} key={item.id} onClick={() => navigate(item.type === 'audio' ? 'audio-call-ringing' : 'video-call-ringing')}><Icon>{item.type === 'audio' ? 'call' : 'videocam'}</Icon><span><strong>{item.type === 'audio' ? '语音通话' : '视频通话'}</strong><small>{item.label}</small></span><time>{item.time}</time><Icon>{item.direction === 'incoming' ? 'call_received' : 'call_made'}</Icon></button>)}</main>
|
||||||
|
<footer><button type="button" onClick={() => navigate('audio-call-ringing')}><Icon>call</Icon><span>语音通话</span></button><button type="button" onClick={() => navigate('video-call-ringing')}><Icon>videocam</Icon><span>视频通话</span></button></footer>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IncomingCall({onToast}) {
|
||||||
|
return <div className="call-fullscreen call-audio-surface incoming">
|
||||||
|
<div className="call-ambient" />
|
||||||
|
<CallHeader onMinimize={() => navigate('call-minimized')} />
|
||||||
|
<main className="call-peer-card"><CallAvatar /><h1>{PEER.name}</h1><p>邀请你进行视频通话</p><span className="call-ringing-dots"><i /><i /><i /></span></main>
|
||||||
|
<footer className="call-incoming-actions"><Control icon="call_end" label="拒绝" danger onClick={() => {onToast('已拒绝视频通话'); window.setTimeout(() => navigate('call-history'), 600);}} /><Control icon="videocam" label="接听" active onClick={() => navigate('video-call-active')} /></footer>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
const mins = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||||
|
return `${mins}:${(seconds % 60).toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AudioCall({view, onToast}) {
|
||||||
|
const ringing = view === 'audio-call-ringing';
|
||||||
|
const [seconds, setSeconds] = React.useState(ringing ? 0 : 68);
|
||||||
|
const [mic, setMic] = React.useState(view !== 'audio-call-muted');
|
||||||
|
const [speaker, setSpeaker] = React.useState(view === 'audio-call-speaker');
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (ringing) return undefined;
|
||||||
|
const timer = window.setInterval(() => setSeconds(value => value + 1), 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [ringing]);
|
||||||
|
const hangup = () => {onToast(ringing ? '已取消呼叫' : `通话结束 ${formatDuration(seconds)}`); window.setTimeout(() => navigate('call-history'), 650);};
|
||||||
|
return <div className="call-fullscreen call-audio-surface">
|
||||||
|
<div className="call-ambient" />
|
||||||
|
<CallHeader onMinimize={() => navigate('call-minimized')} />
|
||||||
|
<main className="call-peer-card"><CallAvatar /><h1>{PEER.name}</h1><p>{ringing ? '正在等待对方接听…' : formatDuration(seconds)}</p>{!ringing ? <small className="call-network"><i />网络良好</small> : <span className="call-ringing-dots"><i /><i /><i /></span>}</main>
|
||||||
|
{ringing ? <footer className="call-single-action"><Control icon="call_end" label="取消" danger onClick={hangup} /></footer> : <footer className="call-active-controls"><Control icon={mic ? 'mic' : 'mic_off'} label={mic ? '麦克风' : '麦克风已关'} active={mic} onClick={() => {setMic(!mic); onToast(mic ? '麦克风已关闭' : '麦克风已开启');}} /><Control icon={speaker ? 'volume_up' : 'hearing'} label={speaker ? '扬声器' : '听筒'} active={speaker} onClick={() => {setSpeaker(!speaker); onToast(speaker ? '已切换到听筒' : '扬声器已开启');}} /><Control icon="call_end" label="挂断" danger onClick={hangup} /></footer>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoBackdrop({cameraOff, peerOff}) {
|
||||||
|
if (peerOff) return <div className="call-video-off"><CallAvatar /><strong>对方摄像头已关闭</strong><small>语音通话仍在继续</small></div>;
|
||||||
|
if (cameraOff) return <div className="call-video-off local"><CallAvatar /><strong>摄像头已关闭</strong></div>;
|
||||||
|
return <img className="call-video-backdrop" src="img/bkg/w06.jpg" alt="视频通话画面" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoCall({view, onToast}) {
|
||||||
|
const ringing = view === 'video-call-ringing' || view === 'video-call-camera-off';
|
||||||
|
const [camera, setCamera] = React.useState(view !== 'video-call-camera-off');
|
||||||
|
const [mic, setMic] = React.useState(true);
|
||||||
|
const [seconds, setSeconds] = React.useState(ringing ? 0 : 128);
|
||||||
|
const peerOff = view === 'video-call-peer-camera-off';
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (ringing) return undefined;
|
||||||
|
const timer = window.setInterval(() => setSeconds(value => value + 1), 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [ringing]);
|
||||||
|
const hangup = () => {onToast(ringing ? '已取消视频呼叫' : `视频通话结束 ${formatDuration(seconds)}`); window.setTimeout(() => navigate('call-history'), 650);};
|
||||||
|
return <div className="call-fullscreen call-video-surface">
|
||||||
|
<VideoBackdrop cameraOff={ringing && !camera} peerOff={peerOff} />
|
||||||
|
<div className="call-video-shade" />
|
||||||
|
<CallHeader light onMinimize={() => navigate('call-minimized')} />
|
||||||
|
<div className="call-video-status"><h1>{PEER.name}</h1><p>{ringing ? '正在等待对方接听…' : formatDuration(seconds)}</p></div>
|
||||||
|
{!ringing ? <button type="button" className="call-local-preview" aria-label="本地视频预览"><img src={camera ? 'img/bkg/p05.jpg' : PEER.avatar} alt="" />{!camera ? <Icon>videocam_off</Icon> : null}</button> : null}
|
||||||
|
<footer className="call-video-controls"><Control icon="flip_camera_android" label="翻转" onClick={() => onToast('已切换摄像头')} /><Control icon={camera ? 'videocam' : 'videocam_off'} label={camera ? '摄像头' : '摄像头已关'} active={camera} onClick={() => {setCamera(!camera); onToast(camera ? '摄像头已关闭' : '摄像头已开启');}} /><Control icon="call_end" label={ringing ? '取消' : '挂断'} danger onClick={hangup} />{!ringing ? <Control icon={mic ? 'mic' : 'mic_off'} label={mic ? '麦克风' : '麦克风已关'} active={mic} onClick={() => setMic(!mic)} /> : null}</footer>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MinimizedCall({onToast}) {
|
||||||
|
const [position, setPosition] = React.useState(() => ({x: Math.max(16, window.innerWidth - 134), y: 168}));
|
||||||
|
const drag = React.useRef(null);
|
||||||
|
const onPointerDown = event => {drag.current = {x: event.clientX - position.x, y: event.clientY - position.y}; event.currentTarget.setPointerCapture(event.pointerId);};
|
||||||
|
const onPointerMove = event => {if (!drag.current) return; setPosition({x: Math.max(8, Math.min(window.innerWidth - 132, event.clientX - drag.current.x)), y: Math.max(70, Math.min(window.innerHeight - 190, event.clientY - drag.current.y))});};
|
||||||
|
return <div className="call-minimized-page"><header className="call-chat-preview-header"><button type="button" onClick={() => navigate('messages-home')}><Icon>arrow_back_ios_new</Icon></button><div><strong>安妮</strong><small>在线</small></div><button type="button"><Icon>more_vert</Icon></button></header><main><p className="chat-time">今天 10:24</p><div className="call-chat-preview-message">下午的评审材料我已经发到群里了。</div></main><button type="button" className="call-floating-window" style={{transform: `translate(${position.x}px, ${position.y}px)`}} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={() => {drag.current = null;}} onClick={() => navigate('video-call-active')} aria-label="返回全屏通话"><img src="img/bkg/w06.jpg" alt="视频通话悬浮画面" /><span><i />02:18</span></button><footer className="call-chat-composer"><input placeholder="输入消息" /><button type="button"><Icon>add_circle_outline</Icon></button></footer><button type="button" className="call-mini-hangup" onClick={() => {onToast('通话已结束'); window.setTimeout(() => navigate('call-history'), 600);}} aria-label="挂断通话"><Icon>call_end</Icon></button></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CallExperienceView({view}) {
|
||||||
|
const [toast, setToast] = React.useState('');
|
||||||
|
let content;
|
||||||
|
if (view === 'call-history') content = <CallHistory />;
|
||||||
|
else if (view === 'call-incoming') content = <IncomingCall onToast={setToast} />;
|
||||||
|
else if (view.startsWith('audio-call')) content = <AudioCall view={view} onToast={setToast} />;
|
||||||
|
else if (view === 'call-minimized') content = <MinimizedCall onToast={setToast} />;
|
||||||
|
else content = <VideoCall view={view} onToast={setToast} />;
|
||||||
|
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import QRCode from 'qrcodejs';
|
||||||
|
|
||||||
|
import HashNavigation from '../lib/navigation.js';
|
||||||
|
|
||||||
|
const navigate = view => HashNavigation.navigateTo(view);
|
||||||
|
|
||||||
|
export const CONTACT_MANAGEMENT_VIEWS = [
|
||||||
|
'address-book', 'friend-requests', 'add-friend', 'my-friend-qr', 'friend-search-result',
|
||||||
|
'friend-verification', 'friend-profile', 'group-member-profile', 'friend-settings',
|
||||||
|
'friend-settings-blocked', 'friend-delete-confirm', 'block-list', 'block-confirm',
|
||||||
|
'clear-chat-confirm'
|
||||||
|
];
|
||||||
|
|
||||||
|
const FRIENDS = [
|
||||||
|
{id: 'annie', name: '安妮', remark: '安妮 · 产品', initial: 'A', avatar: 'img/bkg/p09.jpg', account: 'AN1024'},
|
||||||
|
{id: 'chen', name: '陈晨', initial: 'C', avatar: 'img/bkg/p05.jpg', account: 'CHEN88'},
|
||||||
|
{id: 'lizi', name: '栗子来了', remark: '栗子', initial: 'L', avatar: 'img/bkg/p15.jpg', account: 'LIZI66'},
|
||||||
|
{id: 'wang', name: '王珊珊', initial: 'W', avatar: 'img/bkg/p14.jpg', account: 'WSS2026'}
|
||||||
|
];
|
||||||
|
|
||||||
|
const REQUESTS = [
|
||||||
|
{id: 'r1', name: '祈万事安好', message: '我是设计交流群里的小祈', time: '10:24', status: 'pending', avatar: 'img/bkg/p03.jpg'},
|
||||||
|
{id: 'r2', name: '一颗栗子', message: '你好,想认识一下', time: '昨天', status: 'accepted', avatar: 'img/bkg/p15.jpg'},
|
||||||
|
{id: 'r3', name: '林间风', message: '通过手机号搜索到你', time: '星期一', status: 'rejected', avatar: 'img/bkg/p18.jpg'}
|
||||||
|
];
|
||||||
|
|
||||||
|
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
|
||||||
|
|
||||||
|
function Header({title, back = 'messages-home', right, onRight}) {
|
||||||
|
return <header className="people-header"><button type="button" onClick={() => navigate(back)} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1>{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}</header>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Avatar({person, icon}) {
|
||||||
|
return <span className="people-avatar">{person && person.avatar ? <img src={person.avatar} alt="" /> : <Icon>{icon || 'person'}</Icon>}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toast({message, onDone}) {
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!message) return undefined;
|
||||||
|
const timer = window.setTimeout(onDone, 2000);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [message, onDone]);
|
||||||
|
return message ? <div className="chat-toast" role="status">{message}</div> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Search({value, onChange, placeholder}) {
|
||||||
|
return <label className="people-search"><Icon>search</Icon><input value={value} onChange={event => onChange(event.target.value)} placeholder={placeholder} /></label>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function QrCard({group = false}) {
|
||||||
|
const ref = React.useRef(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
ref.current.innerHTML = '';
|
||||||
|
new QRCode(ref.current, {text: group ? 'im://qr?token=demo-group-2026&purpose=join_group' : 'im://qr?token=demo-user-2026&purpose=add_friend', width: 190, height: 190, correctLevel: QRCode.CorrectLevel.M});
|
||||||
|
}, [group]);
|
||||||
|
return <section className="people-qr-card"><div className="people-qr-profile"><Avatar person={group ? {avatar: 'img/bkg/p22.jpg'} : FRIENDS[1]} /><span><strong>{group ? '产品研发群' : '陈晨'}</strong><small>{group ? '18 位成员' : '账号 CHEN88'}</small></span></div><div className="people-qrcode" ref={ref} /><p>{group ? '扫码申请加入群聊' : '扫一扫上面的二维码,加我为好友'}</p><small>二维码 24 小时内有效</small></section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddressBook() {
|
||||||
|
const [query, setQuery] = React.useState('');
|
||||||
|
const visible = FRIENDS.filter(item => `${item.name}${item.remark || ''}`.includes(query));
|
||||||
|
const groups = visible.reduce((result, item) => ({...result, [item.initial]: (result[item.initial] || []).concat(item)}), {});
|
||||||
|
const entries = [
|
||||||
|
{icon: 'person_add_alt', label: '新朋友', route: 'friend-requests', badge: 1, tone: 'blue'},
|
||||||
|
{icon: 'group', label: '群聊', route: 'group-list', tone: 'green'},
|
||||||
|
{icon: 'folder_special', label: '文件助手', route: 'chat-standard', tone: 'orange'},
|
||||||
|
{icon: 'notifications', label: '系统消息', route: 'system-messages', tone: 'red'}
|
||||||
|
];
|
||||||
|
return <div className="people-page"><Header title="通讯录" right="添加" onRight={() => navigate('add-friend')} /><Search value={query} onChange={setQuery} placeholder="搜索联系人" /><main className="people-scroll"><section className="people-entry-list">{entries.map(item => <button type="button" key={item.label} onClick={() => navigate(item.route)}><Avatar icon={item.icon} /><strong>{item.label}</strong>{item.badge ? <em>{item.badge}</em> : null}<Icon>chevron_right</Icon></button>)}</section>{Object.keys(groups).map(initial => <section className="people-group" key={initial}><h2>{initial}</h2>{groups[initial].map(person => <button type="button" key={person.id} onClick={() => navigate('friend-profile')}><Avatar person={person} /><span><strong>{person.remark || person.name}</strong>{person.remark ? <small>{person.name}</small> : null}</span></button>)}</section>)}</main><nav className="people-index">{Object.keys(groups).map(value => <a href={`#people-${value}`} key={value}>{value}</a>)}</nav></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FriendRequests({onToast}) {
|
||||||
|
const [items, setItems] = React.useState(REQUESTS);
|
||||||
|
const handle = (id, status) => {setItems(values => values.map(item => item.id === id ? {...item, status} : item)); onToast(status === 'accepted' ? '已通过好友申请' : '已拒绝好友申请');};
|
||||||
|
return <div className="people-page"><Header title="新朋友" back="address-book" /><main className="people-request-list">{items.map(item => <article key={item.id}><Avatar person={item} /><div><strong>{item.name}</strong><p>{item.message}</p><small>{item.time}</small></div>{item.status === 'pending' ? <span><button type="button" onClick={() => handle(item.id, 'rejected')}>拒绝</button><button type="button" className="primary" onClick={() => handle(item.id, 'accepted')}>通过</button></span> : <em>{item.status === 'accepted' ? '已通过' : '已拒绝'}</em>}</article>)}</main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddFriend({result = false}) {
|
||||||
|
const [query, setQuery] = React.useState(result ? 'AN1024' : '');
|
||||||
|
return <div className="people-page"><Header title="添加好友" back="address-book" /><Search value={query} onChange={setQuery} placeholder="输入账号或手机号" /><main>{query ? <section className="people-search-result"><h2>搜索结果</h2><button type="button" onClick={() => navigate('group-member-profile')}><Avatar person={FRIENDS[0]} /><span><strong>安妮</strong><small>账号 AN1024 · 产品设计组</small></span><em>添加</em></button></section> : <section className="people-add-actions"><button type="button"><Icon>qr_code_scanner</Icon><span><strong>扫一扫</strong><small>扫描好友或群二维码</small></span><Icon>chevron_right</Icon></button><button type="button" onClick={() => navigate('my-friend-qr')}><Icon>qr_code_2</Icon><span><strong>我的二维码</strong><small>让朋友扫一扫添加我</small></span><Icon>chevron_right</Icon></button><button type="button"><Icon>share</Icon><span><strong>分享好友</strong><small>发送我的个人名片</small></span><Icon>chevron_right</Icon></button></section>}<p className="people-my-account">我的账号:CHEN88</p></main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function QrPage({onToast}) {
|
||||||
|
return <div className="people-page people-qr-page"><Header title="我的二维码" back="add-friend" /><main><QrCard /><div className="people-qr-actions"><button type="button" onClick={() => onToast('二维码名片已分享')}><Icon>share</Icon><span>分享好友</span></button><button type="button" onClick={() => onToast('二维码已保存到相册')}><Icon>download</Icon><span>保存到相册</span></button></div></main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Verification({onToast}) {
|
||||||
|
const [message, setMessage] = React.useState('我是产品研发群里的陈晨');
|
||||||
|
return <div className="people-page"><Header title="好友验证" back="group-member-profile" /><main className="people-verify"><Avatar person={FRIENDS[0]} /><h2>安妮</h2><label><span>验证留言</span><textarea value={message} maxLength="50" onChange={event => setMessage(event.target.value)} /><small>{message.length}/50</small></label><button type="button" className="people-primary" disabled={!message.trim()} onClick={() => {onToast('好友申请已发送'); window.setTimeout(() => navigate('group-member-profile'), 800);}}>发送申请</button></main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Profile({friend, onToast}) {
|
||||||
|
const person = friend ? FRIENDS[0] : FRIENDS[2];
|
||||||
|
return <div className="people-page"><Header title="个人资料" back={friend ? 'address-book' : 'chat-standard'} right={friend ? '设置' : null} onRight={() => navigate('friend-settings')} /><main className="people-profile"><section><Avatar person={person} /><div><h2>{person.remark || person.name}</h2><p>{person.remark ? `昵称:${person.name}` : '产品研发群成员'}</p><p>账号:{person.account}</p></div><Icon>qr_code_2</Icon></section><dl><div><dt>性别</dt><dd>女</dd></div><div><dt>地区</dt><dd>上海 浦东新区</dd></div><div><dt>个性签名</dt><dd>保持好奇,认真生活。</dd></div></dl>{friend ? <div className="people-profile-actions"><button type="button" className="people-primary" onClick={() => navigate('chat-standard')}><Icon>chat_bubble_outline</Icon>发消息</button><button type="button" onClick={() => navigate('call-history')}><Icon>call</Icon>音视频通话</button></div> : <button type="button" className="people-primary" onClick={() => navigate('friend-verification')}><Icon>person_add_alt</Icon>添加好友</button>}</main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toggle({checked, onChange}) { return <button type="button" role="switch" aria-checked={checked} className={'people-switch' + (checked ? ' checked' : '')} onClick={() => onChange(!checked)}><i /></button>; }
|
||||||
|
|
||||||
|
function Settings({initialBlocked, onToast}) {
|
||||||
|
const [pinned, setPinned] = React.useState(false);
|
||||||
|
const [muted, setMuted] = React.useState(false);
|
||||||
|
const [blocked, setBlocked] = React.useState(initialBlocked);
|
||||||
|
return <div className="people-page"><Header title="聊天设置" back="friend-profile" /><main className="people-settings"><section className="people-settings-profile"><Avatar person={FRIENDS[0]} /><strong>安妮 · 产品</strong></section><section><button type="button" onClick={() => navigate('history-search')}><span>查找聊天记录</span><Icon>chevron_right</Icon></button><button type="button"><span>消息保存方式</span><small>永久</small><Icon>chevron_right</Icon></button></section><section><div><span>置顶聊天</span><Toggle checked={pinned} onChange={setPinned} /></div><div><span>消息免打扰</span><Toggle checked={muted} onChange={setMuted} /></div><div><span>加入黑名单</span><Toggle checked={blocked} onChange={value => value ? navigate('block-confirm') : (setBlocked(false), onToast('已移出黑名单'))} /></div></section><section><button type="button"><span>设置备注</span><Icon>chevron_right</Icon></button><button type="button" onClick={() => navigate('abuse-report-reasons')}><span>投诉与举报</span><Icon>chevron_right</Icon></button><button type="button" onClick={() => navigate('clear-chat-confirm')}><span>清除聊天记录</span><Icon>chevron_right</Icon></button></section><button type="button" className="people-danger" onClick={() => navigate('friend-delete-confirm')}>删除好友</button></main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlockList({onToast}) {
|
||||||
|
const [people, setPeople] = React.useState([FRIENDS[3], FRIENDS[2]]);
|
||||||
|
return <div className="people-page"><Header title="黑名单" back="friend-settings" /><main className="people-block-list">{people.length ? people.map(person => <article key={person.id}><Avatar person={person} /><span><strong>{person.name}</strong><small>已阻止消息和通话</small></span><button type="button" onClick={() => {setPeople(items => items.filter(item => item.id !== person.id)); onToast('已移出黑名单');}}>移出</button></article>) : <div className="people-empty"><Icon>person_off</Icon><strong>黑名单为空</strong></div>}</main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmSheet({type, onToast}) {
|
||||||
|
const config = {
|
||||||
|
delete: {title: '删除好友', text: '删除后将解除好友关系,对方将无法继续向你发送消息。聊天记录仍会保留。', action: '确认删除', route: 'address-book', toast: '好友已删除'},
|
||||||
|
block: {title: '加入黑名单', text: '加入黑名单后,你将不再收到对方的消息和通话。', action: '确认拉黑', route: 'friend-settings-blocked', toast: '已加入黑名单'},
|
||||||
|
clear: {title: '清除聊天记录', text: '仅清除你自己的聊天记录,不会影响对方,也不会删除已收藏内容。', action: '确认清除', route: 'friend-settings', toast: '聊天记录已清除'}
|
||||||
|
}[type];
|
||||||
|
return <div className="people-page"><Settings initialBlocked={type === 'block'} onToast={onToast} /><div className="people-sheet-backdrop"><section className="people-confirm-sheet" role="dialog" aria-modal="true"><i /><h2>{config.title}</h2><p>{config.text}</p><button type="button" className="danger" onClick={() => {onToast(config.toast); window.setTimeout(() => navigate(config.route), 700);}}>{config.action}</button><button type="button" onClick={() => navigate('friend-settings')}>取消</button></section></div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContactManagementView({view}) {
|
||||||
|
const [toast, setToast] = React.useState('');
|
||||||
|
let content;
|
||||||
|
if (view === 'address-book') content = <AddressBook />;
|
||||||
|
else if (view === 'friend-requests') content = <FriendRequests onToast={setToast} />;
|
||||||
|
else if (view === 'add-friend' || view === 'friend-search-result') content = <AddFriend result={view === 'friend-search-result'} />;
|
||||||
|
else if (view === 'my-friend-qr') content = <QrPage onToast={setToast} />;
|
||||||
|
else if (view === 'friend-verification') content = <Verification onToast={setToast} />;
|
||||||
|
else if (view === 'friend-profile' || view === 'group-member-profile') content = <Profile friend={view === 'friend-profile'} onToast={setToast} />;
|
||||||
|
else if (view === 'friend-settings' || view === 'friend-settings-blocked') content = <Settings initialBlocked={view === 'friend-settings-blocked'} onToast={setToast} />;
|
||||||
|
else if (view === 'block-list') content = <BlockList onToast={setToast} />;
|
||||||
|
else if (view === 'friend-delete-confirm') content = <ConfirmSheet type="delete" onToast={setToast} />;
|
||||||
|
else if (view === 'block-confirm') content = <ConfirmSheet type="block" onToast={setToast} />;
|
||||||
|
else content = <ConfirmSheet type="clear" onToast={setToast} />;
|
||||||
|
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import HashNavigation from '../lib/navigation.js';
|
||||||
|
|
||||||
|
const navigate = view => HashNavigation.navigateTo(view);
|
||||||
|
|
||||||
|
export const CONTENT_LIBRARY_VIEWS = [
|
||||||
|
'favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice',
|
||||||
|
'favorites-links', 'favorites-select', 'favorite-share', 'file-download',
|
||||||
|
'file-downloading', 'file-downloaded', 'history-search', 'history-files',
|
||||||
|
'history-links', 'history-images', 'history-videos'
|
||||||
|
];
|
||||||
|
|
||||||
|
const FAVORITES = [
|
||||||
|
{id: 'text', type: 'text', text: '评审结论已经整理好了,下午三点一起确认新版流程。', source: '产品研发群', time: '今天 10:32'},
|
||||||
|
{id: 'file', type: 'file', name: '产品需求说明书.pdf', size: '2.4 MB', source: '产品研发群', time: '昨天 18:40'},
|
||||||
|
{id: 'voice', type: 'voice', duration: 12, source: '安妮', time: '星期一 09:20'},
|
||||||
|
{id: 'link', type: 'link', title: '即时通讯产品体验设计指南', summary: '从消息模型、会话体验到文件安全的完整设计方法。', url: 'design.example.com', source: '设计共创小组', time: '8月20日'},
|
||||||
|
{id: 'image', type: 'image', source: '产品研发群', time: '8月18日', image: 'img/bkg/p22.jpg'},
|
||||||
|
{id: 'video', type: 'video', source: '新版发布协作群', time: '8月16日', image: 'img/bkg/p23.jpg', duration: '00:24'}
|
||||||
|
];
|
||||||
|
|
||||||
|
const TARGETS = [
|
||||||
|
{id: 'assistant', name: '文件助手', note: '仅自己可见', icon: 'folder_special'},
|
||||||
|
{id: 'product', name: '产品研发群', note: '18人', image: 'img/bkg/p22.jpg'},
|
||||||
|
{id: 'annie', name: '安妮', note: '在线', image: 'img/bkg/p09.jpg'},
|
||||||
|
{id: 'lizi', name: '栗子来了', note: '刚刚活跃', image: 'img/bkg/p15.jpg'}
|
||||||
|
];
|
||||||
|
|
||||||
|
const FILE_RECORDS = [
|
||||||
|
{id: 'f1', name: '产品需求说明书.pdf', size: '2.4 MB', sender: '陈晨', time: '今天 10:20', state: 'available'},
|
||||||
|
{id: 'f2', name: '交互原型最终版.fig', size: '18.6 MB', sender: '安妮', time: '昨天 18:42', state: 'available'},
|
||||||
|
{id: 'f3', name: '旧版本接口说明.docx', size: '760 KB', sender: '栗子来了', time: '8月18日', state: 'removed'}
|
||||||
|
];
|
||||||
|
|
||||||
|
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
|
||||||
|
function Header({title, back = 'messages-home', right, onRight}) { return <header className="library-header"><button type="button" onClick={() => navigate(back)} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1>{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}</header>; }
|
||||||
|
function Avatar({item}) { return <span className="library-avatar">{item.image ? <img src={item.image} alt="" /> : <Icon>{item.icon || 'person'}</Icon>}</span>; }
|
||||||
|
function Toast({message, onDone}) { React.useEffect(() => {if (!message) return undefined; const timer = window.setTimeout(onDone, 2000); return () => window.clearTimeout(timer);}, [message, onDone]); return message ? <div className="chat-toast" role="status">{message}</div> : null; }
|
||||||
|
function Search({value, onChange, placeholder = '搜索'}) { return <label className="library-search"><Icon>search</Icon><input value={value} onChange={event => onChange(event.target.value)} placeholder={placeholder} /></label>; }
|
||||||
|
|
||||||
|
function FileCard({item, onOpen}) {
|
||||||
|
const removed = item.state === 'removed';
|
||||||
|
return <button type="button" className={'library-file-card' + (removed ? ' removed' : '')} onClick={removed ? undefined : onOpen}><span>PDF</span><div><strong>{item.name}</strong><small>{item.size}{item.sender ? ` · ${item.sender}` : ''}</small></div>{removed ? <em>文件已失效</em> : <Icon>chevron_right</Icon>}</button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VoiceCard({item, playing, onPlay}) {
|
||||||
|
return <button type="button" className={'library-voice-card' + (playing ? ' playing' : '')} onClick={onPlay}><Icon>{playing ? 'pause_circle' : 'play_circle'}</Icon><span className="library-wave" /><strong>{item.duration}″</strong></button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkCard({item, onOpen}) {
|
||||||
|
return <button type="button" className="library-link-card" onClick={onOpen}><img src="img/bkg/a03.jpg" alt="链接缩略图" /><div><strong>{item.title}</strong><p>{item.summary}</p><small><Icon>link</Icon>{item.url}</small></div></button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FavoriteItem({item, playing, onPlay, onOpen, selecting, selected, onSelect}) {
|
||||||
|
return <article className={'library-favorite-item' + (selecting ? ' selecting' : '')}>
|
||||||
|
{selecting ? <button type="button" className={'library-check' + (selected ? ' checked' : '')} onClick={onSelect} aria-label="选择收藏"><Icon>{selected ? 'check' : ''}</Icon></button> : null}
|
||||||
|
<div className="library-favorite-content">
|
||||||
|
{item.type === 'text' ? <p className="library-text-content">{item.text}</p> : null}
|
||||||
|
{item.type === 'file' ? <FileCard item={item} onOpen={onOpen} /> : null}
|
||||||
|
{item.type === 'voice' ? <VoiceCard item={item} playing={playing} onPlay={onPlay} /> : null}
|
||||||
|
{item.type === 'link' ? <LinkCard item={item} onOpen={onOpen} /> : null}
|
||||||
|
{item.type === 'image' || item.type === 'video' ? <button type="button" className="library-media-thumb" onClick={onOpen}><img src={item.image} alt={item.type === 'image' ? '收藏图片' : '收藏视频'} />{item.type === 'video' ? <><Icon>play_circle</Icon><small>{item.duration}</small></> : null}</button> : null}
|
||||||
|
<footer><span>来自 {item.source}</span><time>{item.time}</time><button type="button" aria-label="更多操作"><Icon>more_horiz</Icon></button></footer>
|
||||||
|
</div>
|
||||||
|
</article>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_ROUTE = {all: 'favorites-mixed', file: 'favorites-files', text: 'favorites-text', voice: 'favorites-voice', link: 'favorites-links'};
|
||||||
|
const TYPE_LABELS = [['all', '全部'], ['text', '文本'], ['image', '图片'], ['video', '视频'], ['voice', '语音'], ['file', '文件'], ['link', '链接']];
|
||||||
|
|
||||||
|
function Favorites({view, onToast}) {
|
||||||
|
const initialType = view === 'favorites-files' ? 'file' : view === 'favorites-text' ? 'text' : view === 'favorites-voice' ? 'voice' : view === 'favorites-links' ? 'link' : 'all';
|
||||||
|
const selecting = view === 'favorites-select';
|
||||||
|
const [type, setType] = React.useState(selecting ? 'file' : initialType);
|
||||||
|
const [query, setQuery] = React.useState('');
|
||||||
|
const [playing, setPlaying] = React.useState(null);
|
||||||
|
const [selected, setSelected] = React.useState(selecting ? ['file'] : []);
|
||||||
|
const [items, setItems] = React.useState(FAVORITES);
|
||||||
|
const visible = items.filter(item => (type === 'all' || item.type === type) && JSON.stringify(item).toLowerCase().includes(query.toLowerCase()));
|
||||||
|
const toggle = id => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : values.concat(id));
|
||||||
|
const deleteSelected = () => {setItems(values => values.filter(item => !selected.includes(item.id))); setSelected([]); onToast('已删除所选收藏');};
|
||||||
|
return <div className="library-page"><Header title={selecting ? `已选择 ${selected.length} 项` : '我的收藏'} back={selecting ? 'favorites-files' : 'messages-home'} right={selecting ? '取消' : '多选'} onRight={() => navigate(selecting ? 'favorites-files' : 'favorites-select')} /><Search value={query} onChange={setQuery} placeholder="搜索收藏内容" /><nav className="library-type-tabs">{TYPE_LABELS.map(([id, label]) => <button type="button" key={id} className={type === id ? 'active' : ''} onClick={() => {setType(id); if (TYPE_ROUTE[id] && !selecting) navigate(TYPE_ROUTE[id]);}}>{label}</button>)}</nav><main className="library-favorite-list">{visible.map(item => <FavoriteItem key={item.id} item={item} selecting={selecting} selected={selected.includes(item.id)} onSelect={() => toggle(item.id)} playing={playing === item.id} onPlay={() => setPlaying(playing === item.id ? null : item.id)} onOpen={() => item.type === 'file' ? navigate('file-download') : item.type === 'link' ? onToast('链接安全检查通过,可继续访问') : onToast('已打开收藏详情')} />)}{!visible.length ? <div className="library-empty"><Icon>bookmark_border</Icon><strong>暂无此类收藏</strong></div> : null}</main>{selecting ? <nav className="library-batch-bar"><button type="button" disabled={!selected.length} onClick={() => navigate('favorite-share')}><Icon>share</Icon><span>转发</span></button><button type="button" disabled={!selected.length} onClick={deleteSelected}><Icon>delete_outline</Icon><span>删除</span></button></nav> : null}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FavoriteShare({onToast}) {
|
||||||
|
const [query, setQuery] = React.useState(''); const [selected, setSelected] = React.useState([]); const [confirming, setConfirming] = React.useState(false); const [comment, setComment] = React.useState('');
|
||||||
|
const visible = TARGETS.filter(item => `${item.name}${item.note}`.includes(query));
|
||||||
|
const toggle = id => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : values.concat(id).slice(0, 9));
|
||||||
|
return <div className="library-page"><Header title="分享收藏文件" back="favorites-select" />{!confirming ? <><Search value={query} onChange={setQuery} placeholder="搜索联系人或群聊" /><p className="library-section-title">最近会话</p><main className="library-target-list">{visible.map(item => <button type="button" key={item.id} onClick={() => toggle(item.id)}><Avatar item={item} /><span><strong>{item.name}</strong><small>{item.note}</small></span><i className={'library-check' + (selected.includes(item.id) ? ' checked' : '')}><Icon>{selected.includes(item.id) ? 'check' : ''}</Icon></i></button>)}</main><footer className="library-next-bar"><span>已选择 {selected.length}/9</span><button type="button" disabled={!selected.length} onClick={() => setConfirming(true)}>下一步</button></footer></> : <main className="library-share-confirm"><h2>发送给</h2><div className="library-target-summary">{TARGETS.filter(item => selected.includes(item.id)).map(item => <span key={item.id}><Avatar item={item} /><small>{item.name}</small></span>)}</div><section><FileCard item={FAVORITES[1]} /><p>收藏原件会继续保留,并在目标会话中生成一条新文件消息。</p></section><textarea value={comment} onChange={event => setComment(event.target.value)} placeholder="给朋友留言" /><button type="button" onClick={() => {onToast(`已分享给 ${selected.length} 个会话`); window.setTimeout(() => navigate('favorites-files'), 700);}}>发送</button></main>}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadPage({view, onToast}) {
|
||||||
|
const initial = view === 'file-downloaded' ? 100 : view === 'file-downloading' ? 36 : 0;
|
||||||
|
const [progress, setProgress] = React.useState(initial); const [paused, setPaused] = React.useState(false);
|
||||||
|
React.useEffect(() => {if (view !== 'file-downloading' || paused || progress >= 100) return undefined; const timer = window.setTimeout(() => setProgress(value => Math.min(100, value + 4)), 280); return () => window.clearTimeout(timer);}, [view, paused, progress]);
|
||||||
|
React.useEffect(() => {if (progress === 100 && view === 'file-downloading') navigate('file-downloaded');}, [progress, view]);
|
||||||
|
const state = progress === 100 ? 'done' : progress > 0 ? 'loading' : 'ready';
|
||||||
|
return <div className="library-page"><Header title="文件详情" back="favorites-files" /><main className="library-download"><span className="library-download-icon">PDF</span><h2>产品需求说明书.pdf</h2><p>2.4 MB · PDF 文档</p><dl><div><dt>来源</dt><dd>产品研发群</dd></div><div><dt>上传者</dt><dd>陈晨</dd></div><div><dt>收藏时间</dt><dd>昨天 18:40</dd></div></dl>{state === 'loading' ? <section className="library-download-progress"><div><span>正在下载</span><strong>{progress}%</strong></div><progress value={progress} max="100" /><small>{(2.4 * progress / 100).toFixed(1)} MB / 2.4 MB</small></section> : null}{state === 'ready' ? <button type="button" className="library-primary" onClick={() => navigate('file-downloading')}><Icon>download</Icon>下载文件</button> : state === 'loading' ? <div className="library-download-actions"><button type="button" onClick={() => setPaused(!paused)}><Icon>{paused ? 'play_arrow' : 'pause'}</Icon>{paused ? '继续' : '暂停'}</button><button type="button" onClick={() => navigate('file-download')}><Icon>close</Icon>取消</button></div> : <><div className="library-complete"><Icon>check_circle</Icon><span><strong>下载完成</strong><small>文件已保存到受控目录</small></span></div><div className="library-download-actions"><button type="button" className="primary" onClick={() => onToast('已调用系统文件查看器')}><Icon>open_in_new</Icon>打开文件</button><button type="button" onClick={() => onToast('已打开系统分享面板')}><Icon>ios_share</Icon>其他应用</button></div></>}</main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Highlight({text, query}) {
|
||||||
|
if (!query) return text;
|
||||||
|
const index = text.indexOf(query); if (index < 0) return text;
|
||||||
|
return <>{text.slice(0, index)}<mark>{query}</mark>{text.slice(index + query.length)}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryNav({active}) {
|
||||||
|
const tabs = [['history-search', '消息'], ['history-files', '文件'], ['history-links', '链接'], ['history-images', '图片'], ['history-videos', '视频']];
|
||||||
|
return <nav className="library-history-tabs">{tabs.map(item => <button type="button" key={item[0]} className={active === item[0] ? 'active' : ''} onClick={() => navigate(item[0])}>{item[1]}</button>)}</nav>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function History({view, onToast}) {
|
||||||
|
const [query, setQuery] = React.useState(view === 'history-search' ? '评审' : '');
|
||||||
|
const messages = [{id: 1, sender: '安妮', text: '下午三点进行版本评审,请大家提前准备。', time: '今天 09:30'}, {id: 2, sender: '陈晨', text: '评审结论已经整理到产品需求文档。', time: '今天 16:42'}];
|
||||||
|
return <div className="library-page"><Header title="查找聊天记录" back="friend-settings" /><Search value={query} onChange={setQuery} placeholder="搜索当前会话" /><HistoryNav active={view} /><main className="library-history-content">{view === 'history-search' ? <section className="library-message-results">{messages.filter(item => item.text.includes(query)).map(item => <button type="button" key={item.id} onClick={() => {onToast('已定位到原消息'); window.setTimeout(() => navigate('chat-standard'), 600);}}><Avatar item={{image: item.sender === '安妮' ? 'img/bkg/p09.jpg' : 'img/bkg/p05.jpg'}} /><span><strong>{item.sender}</strong><p><Highlight text={item.text} query={query} /></p><small>{item.time}</small></span><Icon>chevron_right</Icon></button>)}</section> : null}{view === 'history-files' ? <section className="library-record-files">{FILE_RECORDS.filter(item => item.name.includes(query)).map(item => <FileCard key={item.id} item={item} onOpen={() => navigate('file-download')} />)}</section> : null}{view === 'history-links' ? <section className="library-record-links">{FAVORITES.filter(item => item.type === 'link').map(item => <LinkCard key={item.id} item={item} onOpen={() => onToast('链接安全检查通过')} />)}</section> : null}{view === 'history-images' || view === 'history-videos' ? <MediaGrid video={view === 'history-videos'} onToast={onToast} /> : null}</main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaGrid({video, onToast}) {
|
||||||
|
const images = video ? ['p23.jpg', 'p21.jpg', 'p19.jpg', 'p24.jpg'] : ['p22.jpg', 'p10.jpg', 'p17.jpg', 'p20.jpg', 'p06.jpg', 'p13.jpg'];
|
||||||
|
return <><h2 className="library-date-title">2026年8月</h2><section className="library-media-grid">{images.map((image, index) => <button type="button" key={image} onClick={() => onToast(video ? '已打开视频播放器' : '已打开大图预览')}><img src={`img/bkg/${image}`} alt={video ? '聊天视频缩略图' : '聊天图片缩略图'} />{video ? <><Icon>play_circle</Icon><small>00:{18 + index * 5}</small></> : null}</button>)}</section></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContentLibraryView({view}) {
|
||||||
|
const [toast, setToast] = React.useState(''); let content;
|
||||||
|
if (view.startsWith('favorites')) content = <Favorites view={view} onToast={setToast} />;
|
||||||
|
else if (view === 'favorite-share') content = <FavoriteShare onToast={setToast} />;
|
||||||
|
else if (view.startsWith('file-download')) content = <DownloadPage view={view} onToast={setToast} />;
|
||||||
|
else content = <History view={view} onToast={setToast} />;
|
||||||
|
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import HashNavigation from '../lib/navigation.js';
|
||||||
|
|
||||||
|
const navigate = view => HashNavigation.navigateTo(view);
|
||||||
|
|
||||||
|
const VIEW_CONFIG = {
|
||||||
|
'chat-inbound-request': {relationship: 'pending_inbound', variant: 'inbound-request'},
|
||||||
|
'chat-inbound-message': {relationship: 'pending_inbound', variant: 'inbound-message'},
|
||||||
|
'chat-outbound-request': {relationship: 'pending_outbound', variant: 'outbound-request'},
|
||||||
|
'chat-status': {relationship: 'friend', variant: 'status'},
|
||||||
|
'chat-friend': {relationship: 'friend', variant: 'friend'},
|
||||||
|
'chat-media': {relationship: 'friend', variant: 'media'},
|
||||||
|
'chat-blocked': {relationship: 'blocked_by_peer', variant: 'blocked'},
|
||||||
|
'chat-deleted': {relationship: 'none', variant: 'deleted'},
|
||||||
|
'chat-standard': {relationship: 'friend', variant: 'standard'},
|
||||||
|
'chat-attachments': {relationship: 'friend', variant: 'attachments'},
|
||||||
|
'chat-mixed': {relationship: 'friend', variant: 'mixed'},
|
||||||
|
'chat-contact-card': {relationship: 'friend', variant: 'contact-card'}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ATTACHMENTS = [
|
||||||
|
{id: 'photo', icon: 'image', label: '照片'},
|
||||||
|
{id: 'camera', icon: 'photo_camera', label: '拍摄'},
|
||||||
|
{id: 'call', icon: 'call', label: '视频语音'},
|
||||||
|
{id: 'card', icon: 'contact_page', label: '名片'},
|
||||||
|
{id: 'file', icon: 'folder', label: '文件'},
|
||||||
|
{id: 'favorite', icon: 'bookmark', label: '收藏'},
|
||||||
|
{id: 'video', icon: 'videocam', label: '视频通话'}
|
||||||
|
];
|
||||||
|
|
||||||
|
function Icon({children}) {
|
||||||
|
return <i className="material-icons" aria-hidden="true">{children}</i>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toast({message, onDone}) {
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!message) return undefined;
|
||||||
|
const timer = window.setTimeout(onDone, 2200);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [message, onDone]);
|
||||||
|
return message ? <div className="chat-toast" role="status">{message}</div> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatAvatar({mine = false, peer = false}) {
|
||||||
|
return <div className="chat-avatar"><img src={mine ? 'img/bkg/p05.jpg' : peer ? 'img/bkg/p09.jpg' : 'img/bkg/p14.jpg'} alt="" /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Status({value, failedReason}) {
|
||||||
|
if (!value && !failedReason) return null;
|
||||||
|
return <div className={'chat-message-status' + (failedReason ? ' failed' : '')}>
|
||||||
|
{failedReason ? <Icon>error</Icon> : null}<span>{failedReason || value}</span>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileCard() {
|
||||||
|
return <div className="chat-file-card">
|
||||||
|
<div><strong>365天艾宾浩斯记忆曲线复习计划.pdf</strong><small>115KB</small></div>
|
||||||
|
<span className="chat-file-icon">P</span>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContactCard({onOpen}) {
|
||||||
|
return <button type="button" className="chat-contact-card" onClick={onOpen}>
|
||||||
|
<span><img src="img/bkg/p15.jpg" alt="" /><strong>栗子来了</strong></span>
|
||||||
|
<small>个人名片 · 账号 QL1024</small>
|
||||||
|
</button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VoiceMessage({duration = 6, playing = false}) {
|
||||||
|
return <div className="chat-voice-message"><Icon>{playing ? 'graphic_eq' : 'volume_up'}</Icon><strong>{duration}″</strong><span /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageMessage({progress}) {
|
||||||
|
return <div className="chat-image-message">
|
||||||
|
<img src="img/bkg/p22.jpg" alt="会话图片" />
|
||||||
|
{progress < 100 ? <div className="chat-upload-progress"><progress value={progress} max="100" /><strong>{progress}%</strong></div> : null}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Bubble({mine = false, children, status, failedReason, type = 'text', onOpenCard}) {
|
||||||
|
return <div className={'chat-message-row' + (mine ? ' mine' : '')}>
|
||||||
|
{!mine ? <ChatAvatar peer /> : null}
|
||||||
|
<div className="chat-message-stack">
|
||||||
|
{type === 'file' ? <FileCard /> : type === 'card' ? <ContactCard onOpen={onOpenCard} /> :
|
||||||
|
<div className={'chat-bubble ' + type + (mine ? ' mine' : '')}>{children}</div>}
|
||||||
|
<Status value={status} failedReason={failedReason} />
|
||||||
|
</div>
|
||||||
|
{mine ? <ChatAvatar mine /> : null}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RelationNotice({children, action, onAction}) {
|
||||||
|
return <div className="chat-relation-notice"><span>{children}</span>{action ? <button type="button" onClick={onAction}>{action}</button> : null}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatTimeline({variant, uploadProgress, sendStatus, onRetry, onOpenCard, onFriendAction}) {
|
||||||
|
if (variant === 'inbound-request' || variant === 'inbound-message') {
|
||||||
|
return <>
|
||||||
|
<p className="chat-time">7月30日 11:40</p>
|
||||||
|
<Bubble>{variant === 'inbound-request' ? '请求添加你为好友' : '我是祈万事安好'}</Bubble>
|
||||||
|
<RelationNotice>以上是打招呼的消息</RelationNotice>
|
||||||
|
<div className="chat-request-actions"><button type="button" onClick={() => onFriendAction('declined')}>拒绝</button><button type="button" className="primary" onClick={() => onFriendAction('accepted')}>通过申请</button></div>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'outbound-request') {
|
||||||
|
return <>
|
||||||
|
<p className="chat-time">7月30日 11:40</p>
|
||||||
|
<Bubble mine status="已发送">请求添加你为好友</Bubble>
|
||||||
|
<RelationNotice>好友申请已发送,等待对方处理</RelationNotice>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'status') {
|
||||||
|
return <>
|
||||||
|
<p className="chat-time">7月30日 11:40</p>
|
||||||
|
<Bubble mine status={sendStatus}>我是祈万事安好</Bubble>
|
||||||
|
<RelationNotice>你已添加安妮,现在可以开始聊天。</RelationNotice>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'friend') {
|
||||||
|
return <>
|
||||||
|
<p className="chat-time">8月27日 11:11</p>
|
||||||
|
<RelationNotice>你们已成为好友,现在可以开始聊天</RelationNotice>
|
||||||
|
<Bubble>我们已成为好友,现在我们可以开始聊天</Bubble>
|
||||||
|
<Bubble mine status="已读">你还好吗?</Bubble>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'media') {
|
||||||
|
return <>
|
||||||
|
<p className="chat-time">8月27日 11:11</p>
|
||||||
|
<RelationNotice>接雨彤创建群聊</RelationNotice>
|
||||||
|
<Bubble mine status="已读">你还好吗?</Bubble>
|
||||||
|
<Bubble mine type="image"><ImageMessage progress={uploadProgress} /></Bubble>
|
||||||
|
<Bubble mine type="voice"><VoiceMessage duration={6} playing /></Bubble>
|
||||||
|
<Bubble type="voice"><VoiceMessage duration={6} /></Bubble>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'blocked') {
|
||||||
|
return <>
|
||||||
|
<p className="chat-time">7月30日 11:40</p>
|
||||||
|
<Bubble mine status="已读">请求添加你为好友</Bubble>
|
||||||
|
<RelationNotice>你已添加了安妮,现在可以开始聊天。</RelationNotice>
|
||||||
|
<p className="chat-time">7月30日 13:00</p>
|
||||||
|
<Bubble mine status="已读">这边在网上看见你的留言,你想了解什么课程了</Bubble>
|
||||||
|
<Bubble>有课程辅导的老师?对新手友好吗?</Bubble>
|
||||||
|
<Bubble mine failedReason="消息已发送,但被对方拒收了">这个不用担心特别好上手,学起来特别简单</Bubble>
|
||||||
|
<Bubble mine failedReason="消息已发送,但被对方拒收了">拉黑我这么速度,真的牛</Bubble>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'deleted') {
|
||||||
|
return <>
|
||||||
|
<p className="chat-time">7月30日 13:00</p>
|
||||||
|
<Bubble mine status="已读">你好我是XXX推荐过来的,这边想了解课程是吗</Bubble>
|
||||||
|
<Bubble>这边目前找到更好的课程,不考虑你们家,谢谢</Bubble>
|
||||||
|
<Bubble mine status="已读">这是我们导师名片,你可以加一下,对比一下价格</Bubble>
|
||||||
|
<RelationNotice action="发送朋友验证" onAction={() => onFriendAction('resent')}>你还不是他(她)朋友。</RelationNotice>
|
||||||
|
<Bubble mine failedReason="发送失败,需重新添加好友">这么突然把我删了,真心累了</Bubble>
|
||||||
|
<RelationNotice action="发送朋友验证" onAction={() => onFriendAction('resent')}>你还不是他(她)朋友。</RelationNotice>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = <>
|
||||||
|
<p className="chat-time">7月30日 11:40</p>
|
||||||
|
<Bubble mine status="已读">请求添加你为好友</Bubble>
|
||||||
|
<RelationNotice>你已添加了安妮,现在可以开始聊天。</RelationNotice>
|
||||||
|
<p className="chat-time">7月30日 13:00</p>
|
||||||
|
<Bubble mine status="已读">你好,我是王珊珊,很高兴认识你,你怎么称呼</Bubble>
|
||||||
|
<Bubble>你好,我是安妮,想跟你咨询一下设计课程,方便?</Bubble>
|
||||||
|
</>;
|
||||||
|
|
||||||
|
if (variant === 'standard') {
|
||||||
|
return <>{base}<Bubble mine status="未读">非常方便,这是我们的课程目录,你可以看看了解一下</Bubble><Bubble mine type="file" status="未读" /></>;
|
||||||
|
}
|
||||||
|
if (variant === 'mixed') {
|
||||||
|
return <>{base}<Bubble mine type="file" status="已送达" /><Bubble type="card" onOpenCard={onOpenCard} /></>;
|
||||||
|
}
|
||||||
|
if (variant === 'contact-card') {
|
||||||
|
return <>{base}<Bubble mine status="已读">这是我们导师名片,你可以加一下,有问题随时联系他</Bubble><Bubble mine type="card" status="已送达" onOpenCard={onOpenCard} /><Bubble>好的,非常感谢,那我这边就直接联系他吧</Bubble><Bubble mine status="已读">没问题的,有疑问随时沟通</Bubble></>;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentPanel({onChoose}) {
|
||||||
|
return <div className="chat-attachment-panel">
|
||||||
|
{ATTACHMENTS.map(item => <button type="button" key={item.id} onClick={() => onChoose(item)}><span><Icon>{item.icon}</Icon></span><small>{item.label}</small></button>)}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Composer({relationship, attachmentOpen, setAttachmentOpen, onSend}) {
|
||||||
|
const [draft, setDraft] = React.useState(sessionStorage.getItem('im-chat-draft') || '');
|
||||||
|
const canSend = relationship === 'friend';
|
||||||
|
const updateDraft = value => {
|
||||||
|
setDraft(value);
|
||||||
|
sessionStorage.setItem('im-chat-draft', value);
|
||||||
|
};
|
||||||
|
const submit = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canSend || !draft.trim()) return;
|
||||||
|
onSend(draft.trim());
|
||||||
|
updateDraft('');
|
||||||
|
};
|
||||||
|
return <form className={'chat-composer' + (!canSend ? ' locked' : '')} onSubmit={submit}>
|
||||||
|
<button type="button" aria-label="语音" disabled={!canSend} onClick={() => navigate('voice-ready')}><Icon>record_voice_over</Icon></button>
|
||||||
|
<input value={draft} onChange={e => updateDraft(e.target.value)} placeholder={canSend ? '输入消息' : relationship === 'pending_inbound' ? '请先处理好友申请' : relationship === 'pending_outbound' ? '等待对方通过好友申请' : '当前无法发送消息'} disabled={!canSend} />
|
||||||
|
<button type="button" aria-label="表情" disabled={!canSend} onClick={() => navigate('emoji-panel')}><Icon>sentiment_satisfied_alt</Icon></button>
|
||||||
|
{draft.trim() ? <button type="submit" className="chat-send-button">发送</button> : <button type="button" aria-label="更多" disabled={!canSend} onClick={() => setAttachmentOpen(!attachmentOpen)}><Icon>{attachmentOpen ? 'keyboard' : 'add_circle_outline'}</Icon></button>}
|
||||||
|
</form>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DirectMessageView({view}) {
|
||||||
|
const config = VIEW_CONFIG[view] || VIEW_CONFIG['chat-standard'];
|
||||||
|
const [relationship, setRelationship] = React.useState(config.relationship);
|
||||||
|
const [variant, setVariant] = React.useState(config.variant);
|
||||||
|
const [attachmentOpen, setAttachmentOpen] = React.useState(config.variant === 'attachments');
|
||||||
|
const [uploadProgress, setUploadProgress] = React.useState(config.variant === 'media' ? 76 : 100);
|
||||||
|
const [sendStatus, setSendStatus] = React.useState('发送中');
|
||||||
|
const [extraMessages, setExtraMessages] = React.useState([]);
|
||||||
|
const [toast, setToast] = React.useState('');
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (variant !== 'status') return undefined;
|
||||||
|
const stages = ['已发送', '已送达', '已读'];
|
||||||
|
const timers = stages.map((status, index) => window.setTimeout(() => setSendStatus(status), 600 * (index + 1)));
|
||||||
|
return () => timers.forEach(timer => window.clearTimeout(timer));
|
||||||
|
}, [variant]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (variant !== 'media' || uploadProgress >= 100) return undefined;
|
||||||
|
const timer = window.setTimeout(() => setUploadProgress(value => Math.min(value + 4, 100)), 350);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [variant, uploadProgress]);
|
||||||
|
|
||||||
|
const friendAction = action => {
|
||||||
|
if (action === 'declined') {
|
||||||
|
setRelationship('none');
|
||||||
|
setToast('已拒绝好友申请');
|
||||||
|
} else {
|
||||||
|
setRelationship(action === 'accepted' ? 'friend' : 'pending_outbound');
|
||||||
|
if (action === 'accepted') setVariant('friend');
|
||||||
|
setToast(action === 'accepted' ? '好友申请已通过' : '好友验证已发送');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return <div className="chat-product-page">
|
||||||
|
<header className="chat-product-header">
|
||||||
|
<button type="button" onClick={() => navigate('messages-home')} aria-label="返回消息"><Icon>arrow_back_ios_new</Icon></button>
|
||||||
|
<div><h1>接语彤</h1><span><i />在线</span></div>
|
||||||
|
<button type="button" onClick={() => navigate('friend-settings')} aria-label="会话设置"><Icon>more_vert</Icon></button>
|
||||||
|
</header>
|
||||||
|
<main className="chat-product-timeline">
|
||||||
|
<ChatTimeline variant={variant} uploadProgress={uploadProgress} sendStatus={sendStatus}
|
||||||
|
onRetry={() => setToast('正在重试发送')} onOpenCard={() => navigate('friend-profile')} onFriendAction={friendAction} />
|
||||||
|
{extraMessages.map(item => <Bubble mine status={item.status} key={item.id}>{item.text}</Bubble>)}
|
||||||
|
</main>
|
||||||
|
{relationship === 'friend' ? null : <div className="chat-permission-banner"><Icon>info_outline</Icon>{relationship === 'pending_inbound' ? '通过好友申请后即可发送消息' : relationship === 'pending_outbound' ? '好友申请正在等待处理' : relationship === 'none' ? '对方已不是你的好友,发送消息前需重新验证' : '当前关系状态下无法发送消息'}</div>}
|
||||||
|
<Composer relationship={relationship} attachmentOpen={attachmentOpen} setAttachmentOpen={setAttachmentOpen}
|
||||||
|
onSend={text => {
|
||||||
|
const id = Date.now();
|
||||||
|
setExtraMessages(items => items.concat({id, text, status: '发送中'}));
|
||||||
|
window.setTimeout(() => setExtraMessages(items => items.map(item => item.id === id ? {...item, status: '已发送'} : item)), 600);
|
||||||
|
}} />
|
||||||
|
{attachmentOpen && relationship === 'friend' ? <AttachmentPanel onChoose={item => {
|
||||||
|
setToast(`已选择${item.label},上传接口将在联调阶段接入`);
|
||||||
|
if (item.id === 'card') navigate('chat-contact-card');
|
||||||
|
if (item.id === 'photo' || item.id === 'camera') navigate('media-uploading');
|
||||||
|
if (item.id === 'call' || item.id === 'video') navigate('call-history');
|
||||||
|
if (item.id === 'file') navigate('upload-file');
|
||||||
|
if (item.id === 'favorite') navigate('favorites');
|
||||||
|
}} /> : null}
|
||||||
|
<Toast message={toast} onDone={() => setToast('')} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import QRCode from 'qrcodejs';
|
||||||
|
|
||||||
|
import HashNavigation from '../lib/navigation.js';
|
||||||
|
|
||||||
|
const navigate = view => HashNavigation.navigateTo(view);
|
||||||
|
|
||||||
|
export const GROUP_MANAGEMENT_VIEWS = [
|
||||||
|
'group-list', 'group-empty', 'group-invite', 'group-events', 'group-join-requests',
|
||||||
|
'group-new-request', 'group-announcement', 'group-summary', 'group-announcement-detail',
|
||||||
|
'group-settings', 'group-profile', 'group-avatar', 'group-nickname', 'group-description',
|
||||||
|
'group-role-message', 'group-qr', 'group-governance', 'group-admins', 'group-admin-add',
|
||||||
|
'group-admin-success', 'group-admin-partial', 'group-admin-removed', 'group-mute-add',
|
||||||
|
'group-muted', 'group-owner-select', 'group-owner-confirm', 'group-files', 'group-file-upload'
|
||||||
|
];
|
||||||
|
|
||||||
|
const MEMBERS = [
|
||||||
|
{id: 'annie', name: '安妮', role: '管理员', avatar: 'img/bkg/p09.jpg'},
|
||||||
|
{id: 'lizi', name: '栗子来了', role: '成员', avatar: 'img/bkg/p15.jpg'},
|
||||||
|
{id: 'wang', name: '王珊珊', role: '成员', avatar: 'img/bkg/p14.jpg'},
|
||||||
|
{id: 'sun', name: '孙光洪', role: '成员', avatar: 'img/bkg/p03.jpg'},
|
||||||
|
{id: 'lin', name: '林间风', role: '成员', avatar: 'img/bkg/p18.jpg'}
|
||||||
|
];
|
||||||
|
|
||||||
|
const GROUPS = {
|
||||||
|
owned: [{id: 'product', name: '产品研发群', count: 18, avatar: 'img/bkg/p22.jpg'}, {id: 'design', name: '设计共创小组', count: 9, avatar: 'img/bkg/p23.jpg'}],
|
||||||
|
managed: [{id: 'launch', name: '新版发布协作群', count: 36, avatar: 'img/bkg/p21.jpg'}],
|
||||||
|
joined: [{id: 'company', name: '全员沟通群', count: 268, avatar: 'img/bkg/p24.jpg'}, {id: 'reading', name: '读书分享会', count: 42, avatar: 'img/bkg/p19.jpg'}]
|
||||||
|
};
|
||||||
|
|
||||||
|
const FILES = [
|
||||||
|
{id: 'f1', name: '产品需求说明书.pdf', size: '2.4 MB', owner: '陈晨', time: '今天 10:20', type: 'PDF'},
|
||||||
|
{id: 'f2', name: '交互原型最终版.fig', size: '18.6 MB', owner: '安妮', time: '昨天 18:42', type: 'FIG'},
|
||||||
|
{id: 'f3', name: '发布计划.xlsx', size: '860 KB', owner: '栗子来了', time: '星期一', type: 'XLS'}
|
||||||
|
];
|
||||||
|
|
||||||
|
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
|
||||||
|
function Header({title, back = 'group-list', right, onRight}) { return <header className="group-header"><button type="button" onClick={() => navigate(back)} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1>{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}</header>; }
|
||||||
|
function Avatar({item, icon}) { return <span className="group-avatar">{item && item.avatar ? <img src={item.avatar} alt="" /> : <Icon>{icon || 'group'}</Icon>}</span>; }
|
||||||
|
function Toast({message, onDone}) { React.useEffect(() => {if (!message) return undefined; const timer = window.setTimeout(onDone, 2000); return () => window.clearTimeout(timer);}, [message, onDone]); return message ? <div className="chat-toast" role="status">{message}</div> : null; }
|
||||||
|
function Search({value, onChange, placeholder = '搜索'}) { return <label className="group-search"><Icon>search</Icon><input value={value} onChange={event => onChange(event.target.value)} placeholder={placeholder} /></label>; }
|
||||||
|
function Toggle({checked, onChange}) { return <button type="button" role="switch" aria-checked={checked} className={'group-switch' + (checked ? ' checked' : '')} onClick={() => onChange(!checked)}><i /></button>; }
|
||||||
|
|
||||||
|
function GroupList({empty, onToast}) {
|
||||||
|
const [category, setCategory] = React.useState(empty ? 'managed' : 'owned');
|
||||||
|
const [query, setQuery] = React.useState('');
|
||||||
|
const [creating, setCreating] = React.useState(false);
|
||||||
|
const [name, setName] = React.useState('');
|
||||||
|
const groups = (empty ? [] : GROUPS[category]).filter(item => item.name.includes(query));
|
||||||
|
return <div className="group-page"><Header title="群聊" back="address-book" right="创建" onRight={() => setCreating(true)} /><Search value={query} onChange={setQuery} placeholder="搜索群聊" /><nav className="group-tabs">{[['owned', '我创建的'], ['managed', '我管理的'], ['joined', '我加入的']].map(item => <button type="button" key={item[0]} className={category === item[0] ? 'active' : ''} onClick={() => setCategory(item[0])}>{item[1]}</button>)}</nav><main className="group-list">{groups.length ? groups.map(group => <button type="button" key={group.id} onClick={() => navigate('group-settings')}><Avatar item={group} /><span><strong>{group.name}</strong><small>{group.count} 位成员</small></span><Icon>chevron_right</Icon></button>) : <div className="group-empty"><Icon>groups</Icon><strong>这里还没有群聊</strong><p>{category === 'owned' ? '创建一个群聊,邀请成员一起沟通' : '加入或被设置为管理员后会显示在这里'}</p><button type="button" onClick={() => setCreating(true)}>创建群聊</button></div>}</main>{creating ? <div className="group-modal-backdrop"><section className="group-modal"><h2>创建群聊</h2><label>群名称<input autoFocus value={name} maxLength="30" onChange={event => setName(event.target.value)} placeholder="输入群名称" /></label><p>创建后可继续邀请成员和设置群资料。</p><footer><button type="button" onClick={() => setCreating(false)}>取消</button><button type="button" className="primary" disabled={!name.trim()} onClick={() => {setCreating(false); onToast('群聊创建成功');}}>创建</button></footer></section></div> : null}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatHeader({onToast}) { return <header className="group-chat-header"><button type="button" onClick={() => navigate('group-list')}><Icon>arrow_back_ios_new</Icon></button><div><h1>产品研发群</h1><small>18人</small></div><button type="button" onClick={() => navigate('group-settings')}><Icon>more_vert</Icon></button></header>; }
|
||||||
|
function SystemEvent({children}) { return <p className="group-system-event">{children}</p>; }
|
||||||
|
|
||||||
|
function GroupChat({mode, onToast}) {
|
||||||
|
const [joined, setJoined] = React.useState(false);
|
||||||
|
return <div className="group-page"><ChatHeader onToast={onToast} />{['group-new-request', 'group-summary'].includes(mode) ? <button type="button" className="group-top-notice" onClick={() => navigate('group-join-requests')}><Icon>person_add_alt</Icon><span><strong>新的进群申请</strong><small>2 条申请等待处理</small></span><Icon>chevron_right</Icon></button> : null}{['group-announcement', 'group-summary'].includes(mode) ? <button type="button" className="group-top-notice announcement" onClick={() => navigate('group-announcement-detail')}><Icon>campaign</Icon><span><strong>群公告</strong><small>下午三点进行版本评审,请准时参加</small></span><Icon>chevron_right</Icon></button> : null}<main className="group-chat-timeline"><p className="chat-time">今天 10:24</p>{mode === 'group-invite' ? <article className="group-invite-card"><div><Avatar item={GROUPS.owned[0]} /><span><strong>产品研发群</strong><small>安妮邀请你加入群聊</small></span></div><p>一起参与新版产品的设计与研发讨论</p><button type="button" disabled={joined} onClick={() => {setJoined(true); onToast('已加入产品研发群');}}>{joined ? '已加入' : '立即加入'}</button></article> : <><SystemEvent>安妮邀请栗子来了加入群聊</SystemEvent><SystemEvent>管理员通过了王珊珊的进群申请</SystemEvent><div className="group-chat-message"><Avatar item={MEMBERS[0]} /><div><span>安妮 <em>管理员</em></span><p>下午评审前请大家把问题同步到文档里。</p></div></div><div className="group-chat-message owner"><Avatar item={{...MEMBERS[3], avatar: 'img/bkg/p05.jpg'}} /><div><span>陈晨 <em>群主</em></span><p>收到,我会在两点前整理一版。</p></div></div></>}</main><div className="group-chat-composer"><button type="button"><Icon>record_voice_over</Icon></button><input placeholder="输入消息" /><button type="button"><Icon>sentiment_satisfied_alt</Icon></button><button type="button"><Icon>add_circle_outline</Icon></button></div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function JoinRequests({onToast}) {
|
||||||
|
const [items, setItems] = React.useState([{...MEMBERS[3], inviter: '安妮', status: 'pending'}, {...MEMBERS[4], inviter: '栗子来了', status: 'pending'}]);
|
||||||
|
const handle = (id, status) => {setItems(values => values.map(item => item.id === id ? {...item, status} : item)); onToast(status === 'accepted' ? '已同意进群申请' : '已拒绝进群申请');};
|
||||||
|
return <div className="group-page"><Header title="邀请进群申请" back="group-summary" /><main className="group-request-list">{items.map(item => <article key={item.id}><Avatar item={item} /><div><strong>{item.name}</strong><p>{item.inviter} 邀请加入群聊</p><small>今天 10:24</small></div>{item.status === 'pending' ? <span><button type="button" onClick={() => handle(item.id, 'rejected')}>拒绝</button><button type="button" className="primary" onClick={() => handle(item.id, 'accepted')}>同意</button></span> : <em>{item.status === 'accepted' ? '已同意' : '已拒绝'}</em>}</article>)}</main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Announcement() { return <div className="group-page"><Header title="群公告" back="group-announcement" /><main className="group-announcement-detail"><Icon>campaign</Icon><h2>版本评审安排</h2><p>本周三下午三点进行新版产品评审,请各模块负责人提前更新进度,并将待确认问题同步至需求文档。</p><footer><Avatar item={MEMBERS[0]} /><span><strong>安妮</strong><small>发布于今天 09:30</small></span></footer><small>你已阅读此公告</small></main></div>; }
|
||||||
|
|
||||||
|
const SETTING_LINKS = [
|
||||||
|
['group-profile', '群资料', '产品研发群'], ['group-announcement-detail', '群公告', '版本评审安排'],
|
||||||
|
['group-files', '群文件', '12'], ['group-governance', '群组管理', ''], ['group-nickname', '我在本群的昵称', '陈晨']
|
||||||
|
];
|
||||||
|
|
||||||
|
function GroupSettings({onToast}) {
|
||||||
|
const [pinned, setPinned] = React.useState(true); const [muted, setMuted] = React.useState(false);
|
||||||
|
return <div className="group-page"><Header title="群聊设置" back="group-events" /><main className="group-settings"><section className="group-member-strip"><div>{MEMBERS.slice(0, 4).map(item => <button type="button" key={item.id} onClick={() => navigate('group-member-profile')}><Avatar item={item} /><small>{item.name}</small></button>)}</div><button type="button" aria-label="邀请成员" onClick={() => onToast('已打开成员邀请')}><Icon>person_add_alt</Icon></button></section><section>{SETTING_LINKS.map(item => <button type="button" key={item[0]} onClick={() => navigate(item[0])}><span>{item[1]}</span><small>{item[2]}</small><Icon>chevron_right</Icon></button>)}</section><section><div><span>置顶聊天</span><Toggle checked={pinned} onChange={setPinned} /></div><div><span>消息免打扰</span><Toggle checked={muted} onChange={setMuted} /></div><button type="button" onClick={() => navigate('history-search')}><span>查找聊天记录</span><Icon>chevron_right</Icon></button><button type="button" onClick={() => onToast('仅清除了自己的聊天记录')}><span>清除聊天记录</span><Icon>chevron_right</Icon></button></section><button type="button" className="group-danger" onClick={() => onToast('群主需先转让群主或解散群聊')}>退出群聊</button></main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupProfile({onToast}) {
|
||||||
|
const [name, setName] = React.useState('产品研发群');
|
||||||
|
return <div className="group-page"><Header title="群资料" back="group-settings" right="保存" onRight={() => onToast('群资料已保存')} /><main className="group-profile"><button type="button" className="group-profile-avatar" onClick={() => navigate('group-avatar')}><Avatar item={GROUPS.owned[0]} /><span>群头像</span><Icon>chevron_right</Icon></button><label><span>群名称</span><input value={name} maxLength="30" onChange={event => setName(event.target.value)} /></label><button type="button" onClick={() => navigate('group-description')}><span>群简介</span><small>一起把产品做得更好</small><Icon>chevron_right</Icon></button><button type="button" onClick={() => navigate('group-qr')}><span>群二维码</span><Icon>qr_code_2</Icon><Icon>chevron_right</Icon></button><p>18 位成员 · 群号 GRP202608</p></main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditText({type, onToast}) {
|
||||||
|
const nickname = type === 'nickname'; const [value, setValue] = React.useState(nickname ? '陈晨' : '一起把产品做得更好,及时同步进度和风险。'); const max = nickname ? 20 : 200;
|
||||||
|
return <div className="group-page"><Header title={nickname ? '设置群昵称' : '群简介'} back="group-profile" right="完成" onRight={() => {onToast('保存成功'); window.setTimeout(() => navigate('group-profile'), 600);}} /><main className="group-edit-text">{nickname ? <input autoFocus value={value} maxLength={max} onChange={event => setValue(event.target.value)} /> : <textarea autoFocus value={value} maxLength={max} onChange={event => setValue(event.target.value)} />}<small>{value.length}/{max}</small><p>{nickname ? '群昵称仅在当前群内显示。' : '请填写群用途、规则或成员需要了解的信息。'}</p></main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarSheet({onToast}) { return <div className="group-page"><GroupProfile onToast={onToast} /><div className="group-sheet-backdrop"><section className="group-source-sheet"><h2>设置群头像</h2><button type="button" onClick={() => {onToast('正在处理相册图片'); window.setTimeout(() => navigate('group-profile'), 600);}}><Icon>photo_library</Icon>从相册选择</button><button type="button" onClick={() => onToast('正在打开相机')}><Icon>photo_camera</Icon>拍照</button><button type="button" onClick={() => navigate('group-profile')}>取消</button></section></div></div>; }
|
||||||
|
|
||||||
|
function GroupQr({onToast}) {
|
||||||
|
const ref = React.useRef(null);
|
||||||
|
React.useEffect(() => {if (!ref.current) return; ref.current.innerHTML = ''; new QRCode(ref.current, {text: 'im://qr?token=demo-group-2026&purpose=join_group', width: 190, height: 190, correctLevel: QRCode.CorrectLevel.M});}, []);
|
||||||
|
return <div className="group-page group-qr-page"><Header title="群二维码" back="group-profile" /><main><section className="group-qr-card"><div><Avatar item={GROUPS.owned[0]} /><span><strong>产品研发群</strong><small>18 位成员</small></span></div><div className="group-qrcode" ref={ref} /><p>扫码申请加入群聊</p><small>二维码 24 小时内有效</small></section><div className="group-qr-actions"><button type="button" onClick={() => onToast('群二维码已分享')}><Icon>share</Icon>分享</button><button type="button" onClick={() => onToast('群二维码已保存')}><Icon>download</Icon>保存</button></div></main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GOVERNANCE = [
|
||||||
|
['join', '进群验证', '加入群聊需要管理员确认'], ['mute', '全员禁言', '仅群主和管理员可发言'],
|
||||||
|
['files', '成员上传群文件', '允许普通成员上传文件'], ['private', '成员私聊', '允许成员通过群聊添加好友'],
|
||||||
|
['invite', '成员邀请', '允许普通成员邀请新成员']
|
||||||
|
];
|
||||||
|
|
||||||
|
function Governance({onToast}) {
|
||||||
|
const [values, setValues] = React.useState({join: true, mute: false, files: true, private: true, invite: true});
|
||||||
|
return <div className="group-page"><Header title="群组管理" back="group-settings" /><main className="group-governance"><section>{GOVERNANCE.map(item => <div key={item[0]}><span><strong>{item[1]}</strong><small>{item[2]}</small></span><Toggle checked={values[item[0]]} onChange={value => setValues(current => ({...current, [item[0]]: value}))} /></div>)}</section><section><button type="button" onClick={() => navigate('group-admins')}><span>设置群管理员</span><Icon>chevron_right</Icon></button><button type="button" onClick={() => navigate('group-muted')}><span>禁言成员</span><Icon>chevron_right</Icon></button><button type="button" onClick={() => navigate('group-owner-select')}><span>转让群主</span><Icon>chevron_right</Icon></button></section><button type="button" className="group-danger" onClick={() => onToast('解散群聊需要再次进行身份验证')}>解散群聊</button></main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminList({mode, onToast}) {
|
||||||
|
const removed = mode === 'group-admin-removed';
|
||||||
|
const admins = removed ? [] : [MEMBERS[0]];
|
||||||
|
React.useEffect(() => {if (mode === 'group-admin-success') onToast('管理员设置成功'); if (mode === 'group-admin-partial') onToast('成功设置 1 人,1 人因名额限制失败'); if (removed) onToast('管理员已移除');}, [mode]);
|
||||||
|
return <div className="group-page"><Header title="设置群管理员" back="group-governance" right="添加" onRight={() => navigate('group-admin-add')} /><main className="group-admin-page"><aside><Icon>info_outline</Icon><p>管理员可协助审批进群申请、管理公告和群成员。最多设置 5 名管理员。</p></aside><h2>群主</h2><article><Avatar item={{...MEMBERS[3], avatar: 'img/bkg/p05.jpg'}} /><span><strong>陈晨</strong><small>群主</small></span></article><h2>管理员 {admins.length}/5</h2>{admins.length ? admins.map(item => <article key={item.id}><Avatar item={item} /><span><strong>{item.name}</strong><small>管理员</small></span><button type="button" onClick={() => navigate('group-admin-removed')}>移除</button></article>) : <div className="group-inline-empty">暂无管理员</div>}</main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MemberPicker({type, onToast}) {
|
||||||
|
const single = type === 'owner'; const mute = type === 'mute';
|
||||||
|
const [selected, setSelected] = React.useState([]); const [query, setQuery] = React.useState('');
|
||||||
|
const visible = MEMBERS.filter(item => item.name.includes(query) && (type !== 'admin' || item.role !== '管理员'));
|
||||||
|
const toggle = id => setSelected(values => single ? [id] : values.includes(id) ? values.filter(value => value !== id) : values.concat(id));
|
||||||
|
const done = () => {if (single) {sessionStorage.setItem('im-next-owner', selected[0]); navigate('group-owner-confirm');} else if (mute) {onToast(`已禁言 ${selected.length} 名成员`); window.setTimeout(() => navigate('group-muted'), 600);} else {navigate('group-admin-success');}};
|
||||||
|
return <div className="group-page"><Header title={single ? '选择新群主' : mute ? '添加禁言成员' : '添加管理员'} back={single ? 'group-governance' : mute ? 'group-muted' : 'group-admins'} right={`完成${selected.length ? `(${selected.length})` : ''}`} onRight={selected.length ? done : undefined} /><Search value={query} onChange={setQuery} placeholder="搜索群成员" /><main className="group-member-picker">{visible.map(item => <button type="button" key={item.id} onClick={() => toggle(item.id)}><Avatar item={item} /><span><strong>{item.name}</strong><small>{item.role}</small></span><i className={'group-check' + (selected.includes(item.id) ? ' checked' : '')}>{selected.includes(item.id) ? <Icon>check</Icon> : null}</i></button>)}</main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OwnerConfirm({onToast}) {
|
||||||
|
return <div className="group-page"><MemberPicker type="owner" onToast={onToast} /><div className="group-sheet-backdrop"><section className="group-confirm-sheet"><h2>转让群主</h2><p>确认将群主转让给“栗子来了”?转让后你将变为普通管理员,此操作不可直接撤销。</p><button type="button" className="danger" onClick={() => {onToast('群主已转让'); window.setTimeout(() => navigate('group-governance'), 700);}}>确认转让</button><button type="button" onClick={() => navigate('group-owner-select')}>取消</button></section></div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Muted({onToast}) {
|
||||||
|
const [items, setItems] = React.useState([MEMBERS[4]]);
|
||||||
|
return <div className="group-page"><Header title="禁言成员" back="group-governance" right="添加" onRight={() => navigate('group-mute-add')} /><main className="group-muted-list">{items.map(item => <article key={item.id}><Avatar item={item} /><span><strong>{item.name}</strong><small>永久禁言 · 安妮操作</small></span><button type="button" onClick={() => {setItems([]); onToast('已解除禁言');}}>解除</button></article>)}</main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupFiles({upload, onToast}) {
|
||||||
|
const [query, setQuery] = React.useState(''); const [progress, setProgress] = React.useState(upload ? 18 : 100);
|
||||||
|
React.useEffect(() => {if (!upload || progress >= 100) return undefined; const timer = window.setTimeout(() => setProgress(value => Math.min(100, value + 7)), 300); return () => window.clearTimeout(timer);}, [upload, progress]);
|
||||||
|
const visible = FILES.filter(item => item.name.includes(query));
|
||||||
|
return <div className="group-page"><Header title="群文件" back="group-settings" right="上传" onRight={() => navigate('group-file-upload')} /><Search value={query} onChange={setQuery} placeholder="搜索群文件" /><main className="group-file-list">{upload ? <article className="uploading"><span className="group-file-type">ZIP</span><div><strong>研发资料归档.zip</strong><small>32.8 MB · 上传中 {progress}%</small><progress value={progress} max="100" /></div><button type="button"><Icon>close</Icon></button></article> : null}{visible.map(file => <article key={file.id}><span className="group-file-type">{file.type}</span><div><strong>{file.name}</strong><small>{file.size} · {file.owner} · {file.time}</small></div><button type="button" onClick={() => navigate('file-download')}><Icon>download</Icon></button></article>)}</main>{upload ? <div className="group-upload-sources"><button type="button"><Icon>insert_drive_file</Icon><span>文件</span></button><button type="button"><Icon>photo_library</Icon><span>照片</span></button><button type="button"><Icon>videocam</Icon><span>视频</span></button></div> : null}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GroupManagementView({view}) {
|
||||||
|
const [toast, setToast] = React.useState(''); let content;
|
||||||
|
if (view === 'group-list' || view === 'group-empty') content = <GroupList empty={view === 'group-empty'} onToast={setToast} />;
|
||||||
|
else if (['group-invite', 'group-events', 'group-new-request', 'group-announcement', 'group-summary', 'group-role-message'].includes(view)) content = <GroupChat mode={view} onToast={setToast} />;
|
||||||
|
else if (view === 'group-join-requests') content = <JoinRequests onToast={setToast} />;
|
||||||
|
else if (view === 'group-announcement-detail') content = <Announcement />;
|
||||||
|
else if (view === 'group-settings') content = <GroupSettings onToast={setToast} />;
|
||||||
|
else if (view === 'group-profile') content = <GroupProfile onToast={setToast} />;
|
||||||
|
else if (view === 'group-avatar') content = <AvatarSheet onToast={setToast} />;
|
||||||
|
else if (view === 'group-nickname' || view === 'group-description') content = <EditText type={view === 'group-nickname' ? 'nickname' : 'description'} onToast={setToast} />;
|
||||||
|
else if (view === 'group-qr') content = <GroupQr onToast={setToast} />;
|
||||||
|
else if (view === 'group-governance') content = <Governance onToast={setToast} />;
|
||||||
|
else if (['group-admins', 'group-admin-success', 'group-admin-partial', 'group-admin-removed'].includes(view)) content = <AdminList mode={view} onToast={setToast} />;
|
||||||
|
else if (view === 'group-admin-add') content = <MemberPicker type="admin" onToast={setToast} />;
|
||||||
|
else if (view === 'group-mute-add') content = <MemberPicker type="mute" onToast={setToast} />;
|
||||||
|
else if (view === 'group-muted') content = <Muted onToast={setToast} />;
|
||||||
|
else if (view === 'group-owner-select') content = <MemberPicker type="owner" onToast={setToast} />;
|
||||||
|
else if (view === 'group-owner-confirm') content = <OwnerConfirm onToast={setToast} />;
|
||||||
|
else content = <GroupFiles upload={view === 'group-file-upload'} onToast={setToast} />;
|
||||||
|
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import HashNavigation from '../lib/navigation.js';
|
||||||
|
|
||||||
|
const navigate = view => HashNavigation.navigateTo(view);
|
||||||
|
|
||||||
|
const DEMO_CONVERSATIONS = [
|
||||||
|
{id: 'system', title: '系统消息', summary: '您有一条新的安全通知', time: '昨天 21:03', icon: 'notifications', tone: 'orange', system: true},
|
||||||
|
{id: 'sun', title: '孙光洪', summary: '最近忙什么', time: '昨天 21:03', unread: 3, avatar: 'img/bkg/p03.jpg', online: true},
|
||||||
|
{id: 'files', title: '文件助手', summary: '你上传了 1 份文件', time: '昨天 21:03', icon: 'folder', tone: 'green', fileAssistant: true},
|
||||||
|
{id: 'zhao', title: '赵吾行', summary: '好久不见,怎样了,都挺好的', time: '昨天 21:03', unread: 3, avatar: 'img/bkg/p07.jpg'},
|
||||||
|
{id: 'wang', title: '王厚哲', summary: '恭喜你呀', time: '昨天 21:03', unread: 3, avatar: 'img/bkg/p12.jpg', online: true},
|
||||||
|
{id: 'sunmu', title: '孙牧森', summary: '真有意思', time: '昨天 21:03', muted: true, avatar: 'img/bkg/p18.jpg'}
|
||||||
|
];
|
||||||
|
|
||||||
|
const FREQUENT_CONTACTS = [
|
||||||
|
{id: 'a', name: '吃火锅', avatar: 'img/bkg/p02.jpg', online: true},
|
||||||
|
{id: 'b', name: '爱上草莓', avatar: 'img/bkg/p05.jpg', online: true},
|
||||||
|
{id: 'c', name: '好久鼻尖', avatar: 'img/bkg/p09.jpg'},
|
||||||
|
{id: 'd', name: '太想念你', avatar: 'img/bkg/p14.jpg', online: true},
|
||||||
|
{id: 'e', name: '夏纳', avatar: 'img/bkg/p21.jpg'}
|
||||||
|
];
|
||||||
|
|
||||||
|
const SYSTEM_NOTIFICATIONS = [
|
||||||
|
{
|
||||||
|
id: 'device-1', date: '6月28日 10:04', title: '新设备登录成功',
|
||||||
|
loginAt: '2026/08/24 14:17', device: 'Chrome · macOS', ip: '220.198.***.212', read: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'device-2', date: '6月30日 13:04', title: '新设备登录成功',
|
||||||
|
loginAt: '2026/08/20 09:42', device: 'iPhone 15', ip: '116.25.***.86', read: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const QUICK_ACTIONS = [
|
||||||
|
{id: 'friends', icon: 'group', label: '朋友'},
|
||||||
|
{id: 'profile', icon: 'person_outline', label: '我的'},
|
||||||
|
{id: 'create-group', icon: 'add_comment', label: '创建群组'},
|
||||||
|
{id: 'add-friend', icon: 'person_add_alt', label: '添加好友'},
|
||||||
|
{id: 'scan', icon: 'qr_code_scanner', label: '扫一扫'}
|
||||||
|
];
|
||||||
|
|
||||||
|
const MESSAGE_ACTIONS = [
|
||||||
|
{id: 'quote', icon: 'format_quote', label: '引用'},
|
||||||
|
{id: 'copy', icon: 'content_copy', label: '复制'},
|
||||||
|
{id: 'favorite', icon: 'inventory_2', label: '收藏'},
|
||||||
|
{id: 'translate', icon: 'translate', label: '翻译'},
|
||||||
|
{id: 'forward', icon: 'reply', label: '转发'},
|
||||||
|
{id: 'recall', icon: 'undo', label: '撤回'},
|
||||||
|
{id: 'delete', icon: 'delete_outline', label: '删除'},
|
||||||
|
{id: 'select', icon: 'select_all', label: '多选'},
|
||||||
|
{id: 'receipts', icon: 'published_with_changes', label: '群回执'}
|
||||||
|
];
|
||||||
|
|
||||||
|
function Icon({children}) {
|
||||||
|
return <i className="material-icons" aria-hidden="true">{children}</i>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toast({message, onDone}) {
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!message) return undefined;
|
||||||
|
const timer = window.setTimeout(onDone, 2200);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [message, onDone]);
|
||||||
|
return message ? <div className="messages-toast" role="status">{message}</div> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Avatar({item, small = false}) {
|
||||||
|
const className = 'messages-avatar ' + (small ? 'small ' : '') + (item.tone || 'blue');
|
||||||
|
return <div className={className}>
|
||||||
|
{item.avatar ? <img src={item.avatar} alt="" /> : <Icon>{item.icon || 'person'}</Icon>}
|
||||||
|
{item.online !== undefined ? <span className={item.online ? 'online' : 'offline'} /> : null}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConversations(chatList, myUserId) {
|
||||||
|
if (!chatList || chatList.length === 0) return DEMO_CONVERSATIONS;
|
||||||
|
return chatList.map((item, index) => {
|
||||||
|
const latest = typeof item.latestMessage === 'function' ? item.latestMessage() : null;
|
||||||
|
let summary = item.private && item.private.comment ? item.private.comment : '暂无消息';
|
||||||
|
if (latest && typeof latest.content === 'string') summary = latest.content;
|
||||||
|
if (latest && latest.from === myUserId) summary = '我:' + summary;
|
||||||
|
return {
|
||||||
|
id: item.topic || item.user || String(index),
|
||||||
|
title: item.public && item.public.fn ? item.public.fn : '未命名会话',
|
||||||
|
summary,
|
||||||
|
time: item.touched ? new Date(item.touched).toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'}) : '',
|
||||||
|
unread: Math.max(Number(item.unread) || 0, 0),
|
||||||
|
online: !!item.online,
|
||||||
|
muted: !!(item.acs && item.acs.isMuted && item.acs.isMuted()),
|
||||||
|
pinned: !!item.pinned
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function BottomNavigation() {
|
||||||
|
return <nav className="messages-bottom-nav" aria-label="主导航">
|
||||||
|
<button type="button" onClick={() => navigate('workbench')}><Icon>dashboard</Icon><span>工作台</span></button>
|
||||||
|
<button type="button" className="active"><Icon>person</Icon><span>消息</span></button>
|
||||||
|
</nav>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickMenu({onClose, onAction}) {
|
||||||
|
return <div className="messages-menu-backdrop" onMouseDown={onClose}>
|
||||||
|
<div className="messages-quick-menu" onMouseDown={e => e.stopPropagation()} role="menu">
|
||||||
|
{QUICK_ACTIONS.map(item => <button type="button" role="menuitem" key={item.id} onClick={() => onAction(item)}>
|
||||||
|
<Icon>{item.icon}</Icon><span>{item.label}</span>
|
||||||
|
</button>)}
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageHome({view, connected, chatList, myUserId, onTopicSelected}) {
|
||||||
|
const [query, setQuery] = React.useState('');
|
||||||
|
const [menuOpen, setMenuOpen] = React.useState(view === 'messages-menu');
|
||||||
|
const [toast, setToast] = React.useState('');
|
||||||
|
const offline = view === 'messages-offline' || (!!myUserId && connected === false);
|
||||||
|
const conversations = normalizeConversations(chatList, myUserId);
|
||||||
|
const filtered = conversations.filter(item => `${item.title}${item.summary}`.toLowerCase().includes(query.trim().toLowerCase()));
|
||||||
|
|
||||||
|
const selectConversation = item => {
|
||||||
|
if (item.system || item.id === 'system') {
|
||||||
|
navigate('system-messages');
|
||||||
|
} else if (chatList && chatList.length && onTopicSelected) {
|
||||||
|
onTopicSelected(item.id);
|
||||||
|
} else {
|
||||||
|
navigate('chat-standard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runQuickAction = item => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
if (item.id === 'friends') return navigate('address-book');
|
||||||
|
if (item.id === 'profile') return navigate('personal-center');
|
||||||
|
if (item.id === 'create-group') return navigate('group-list');
|
||||||
|
if (item.id === 'add-friend') return navigate('add-friend');
|
||||||
|
setToast(item.id === 'scan' ? '扫码功能将在客户端能力章节提供' : `${item.label}页面将在对应章节中提供`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <div className="messages-product-page">
|
||||||
|
<header className="messages-home-header">
|
||||||
|
{offline ? <div className="messages-connection"><span className="messages-spinner" />加载中</div> : <h1>消息</h1>}
|
||||||
|
<button type="button" className="messages-menu-button" onClick={() => setMenuOpen(true)} aria-label="更多功能"><Icon>menu</Icon></button>
|
||||||
|
</header>
|
||||||
|
<main className="messages-home-content">
|
||||||
|
<label className="messages-search"><Icon>search</Icon><input value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索" /></label>
|
||||||
|
<section className="messages-frequent">
|
||||||
|
<h2>常联系的人</h2>
|
||||||
|
<div className="messages-frequent-scroll">
|
||||||
|
{FREQUENT_CONTACTS.map(item => <button type="button" key={item.id} onClick={() => navigate('chat-standard')}>
|
||||||
|
<Avatar item={item} small /><span>{item.name}</span>
|
||||||
|
</button>)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="messages-conversations" aria-label="会话列表">
|
||||||
|
{filtered.map(item => <button type="button" className={'messages-conversation' + (item.pinned ? ' pinned' : '')}
|
||||||
|
key={item.id} onClick={() => selectConversation(item)}>
|
||||||
|
<Avatar item={item} />
|
||||||
|
<span className="messages-conversation-copy"><strong>{item.title}</strong><small>{item.summary}</small></span>
|
||||||
|
<span className="messages-conversation-meta"><time>{item.time}</time>
|
||||||
|
{item.unread ? <b>{item.unread > 99 ? '99+' : item.unread}</b> : item.muted ? <Icon>notifications_off</Icon> : null}
|
||||||
|
</span>
|
||||||
|
</button>)}
|
||||||
|
{filtered.length === 0 ? <div className="messages-empty"><Icon>search_off</Icon><p>未找到相关会话</p></div> : null}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<BottomNavigation />
|
||||||
|
{menuOpen ? <QuickMenu onClose={() => setMenuOpen(false)} onAction={runQuickAction} /> : null}
|
||||||
|
<Toast message={toast} onDone={() => setToast('')} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageActions() {
|
||||||
|
const [visible, setVisible] = React.useState(true);
|
||||||
|
const [toast, setToast] = React.useState('');
|
||||||
|
const [selected, setSelected] = React.useState('');
|
||||||
|
|
||||||
|
const runAction = item => {
|
||||||
|
setSelected(item.id);
|
||||||
|
if (item.id === 'copy' && navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText('明天上午十点开项目会,请准时参加。').catch(() => {});
|
||||||
|
}
|
||||||
|
setToast(`${item.label}操作已选择`);
|
||||||
|
setVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <div className="message-actions-page">
|
||||||
|
<header className="messages-sub-header"><button onClick={() => navigate('messages-home')} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>项目协作群</h1><span /></header>
|
||||||
|
<main className="message-actions-chat">
|
||||||
|
<p className="message-time">今天 10:24</p>
|
||||||
|
<div className="message-demo-row"><Avatar item={{avatar: 'img/bkg/p09.jpg'}} /><button type="button" className="message-demo-bubble" onContextMenu={e => {e.preventDefault(); setVisible(true);}}>明天上午十点开项目会,请准时参加。</button></div>
|
||||||
|
<div className="message-demo-row mine"><button type="button" className="message-demo-bubble mine" onContextMenu={e => {e.preventDefault(); setVisible(true);}}>收到,我会提前准备材料。</button><Avatar item={{avatar: 'img/bkg/p05.jpg'}} /></div>
|
||||||
|
{selected ? <p className="message-operation-result">已选择:{MESSAGE_ACTIONS.find(item => item.id === selected).label}</p> : null}
|
||||||
|
</main>
|
||||||
|
<footer className="message-demo-composer"><Icon>mic_none</Icon><span>输入消息</span><Icon>sentiment_satisfied_alt</Icon><Icon>add_circle_outline</Icon></footer>
|
||||||
|
{visible ? <div className="message-action-backdrop" onMouseDown={() => setVisible(false)}>
|
||||||
|
<div className="message-action-menu" onMouseDown={e => e.stopPropagation()} role="menu">
|
||||||
|
{MESSAGE_ACTIONS.map(item => <button type="button" role="menuitem" key={item.id} onClick={() => runAction(item)}>
|
||||||
|
<span>{item.label}</span><Icon>{item.icon}</Icon>
|
||||||
|
</button>)}
|
||||||
|
</div>
|
||||||
|
</div> : null}
|
||||||
|
<Toast message={toast} onDone={() => setToast('')} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SystemMessages({authenticated}) {
|
||||||
|
const [items, setItems] = React.useState(SYSTEM_NOTIFICATIONS);
|
||||||
|
const [toast, setToast] = React.useState('');
|
||||||
|
const markRead = id => setItems(items.map(item => item.id === id ? {...item, read: true} : item));
|
||||||
|
return <div className="system-messages-page">
|
||||||
|
<header className="messages-sub-header"><button onClick={() => navigate('messages-home')} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>系统消息</h1><span /></header>
|
||||||
|
<main className="system-notification-list">
|
||||||
|
{items.map(item => <article key={item.id} className={item.read ? 'read' : ''} onClick={() => markRead(item.id)}>
|
||||||
|
<time>{item.date}</time>
|
||||||
|
<div className="system-notification-card">
|
||||||
|
<header><h2>{item.title}</h2>{!item.read ? <span>新</span> : null}</header>
|
||||||
|
<p>亲爱的用户,您的账号于 <button type="button" onClick={() => setToast('登录时间:' + item.loginAt)}>{item.loginAt}</button> 在新设备上登录成功。</p>
|
||||||
|
<dl><div><dt>设备:</dt><dd>{item.device}</dd></div><div><dt>IP地址:</dt><dd>{item.ip}</dd></div></dl>
|
||||||
|
<p>如果不是本人操作,请及时修改密码并检查登录设备。</p>
|
||||||
|
<button type="button" className="system-security-link" onClick={() => navigate(authenticated ? 'security' : 'identity-check')}>前往安全设置 <Icon>chevron_right</Icon></button>
|
||||||
|
<footer>保持警惕<br />即时通讯安全团队</footer>
|
||||||
|
</div>
|
||||||
|
</article>)}
|
||||||
|
</main>
|
||||||
|
<Toast message={toast} onDone={() => setToast('')} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageCenterView(props) {
|
||||||
|
switch (props.view) {
|
||||||
|
case 'system-messages': return <SystemMessages authenticated={!!props.myUserId} />;
|
||||||
|
case 'message-actions': return <MessageActions />;
|
||||||
|
case 'messages-offline':
|
||||||
|
case 'messages-menu':
|
||||||
|
case 'messages-home':
|
||||||
|
default: return <MessageHome {...props} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import HashNavigation from '../lib/navigation.js';
|
||||||
|
|
||||||
|
const navigate = view => HashNavigation.navigateTo(view);
|
||||||
|
|
||||||
|
export const MESSAGE_COMPOSER_VIEWS = [
|
||||||
|
'compose-keyboard', 'mentions-select', 'mentions-complete', 'mentions-message',
|
||||||
|
'emoji-panel', 'emoji-message', 'voice-ready', 'voice-hold', 'voice-recording',
|
||||||
|
'media-uploading', 'media-gallery', 'media-retry', 'upload-file', 'file-message',
|
||||||
|
'message-multiselect', 'favorite-success', 'forward-select', 'forward-multiple',
|
||||||
|
'forward-individual', 'forward-bundle'
|
||||||
|
];
|
||||||
|
|
||||||
|
const MEMBERS = [
|
||||||
|
{id: 'all', name: '所有人', note: '提醒群内所有成员', avatar: 'campaign'},
|
||||||
|
{id: 'annie', name: '安妮', note: '产品设计组', image: 'img/bkg/p09.jpg'},
|
||||||
|
{id: 'lizi', name: '栗子来了', note: '前端开发组', image: 'img/bkg/p15.jpg'},
|
||||||
|
{id: 'wang', name: '王珊珊', note: '市场运营组', image: 'img/bkg/p14.jpg'},
|
||||||
|
{id: 'chen', name: '陈晨', note: '项目管理组', image: 'img/bkg/p05.jpg'}
|
||||||
|
];
|
||||||
|
|
||||||
|
const TARGETS = [
|
||||||
|
{id: 'file', name: '文件助手', note: '仅自己可见', icon: 'folder_special'},
|
||||||
|
{id: 'team', name: '产品研发群', note: '18人', image: 'img/bkg/p22.jpg'},
|
||||||
|
{id: 'annie', name: '安妮', note: '在线', image: 'img/bkg/p09.jpg'},
|
||||||
|
{id: 'lizi', name: '栗子来了', note: '刚刚活跃', image: 'img/bkg/p15.jpg'},
|
||||||
|
{id: 'wang', name: '王珊珊', note: '市场运营组', image: 'img/bkg/p14.jpg'}
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMOJIS = ['😀', '😄', '😂', '🥰', '😎', '🤔', '👍', '👏', '🎉', '❤️', '🔥', '💡', '✅', '🙏', '💪', '🌟', '📌', '🚀'];
|
||||||
|
|
||||||
|
function Icon({children}) {
|
||||||
|
return <i className="material-icons" aria-hidden="true">{children}</i>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Avatar({item}) {
|
||||||
|
return <span className="compose-avatar">
|
||||||
|
{item.image ? <img src={item.image} alt="" /> : <Icon>{item.icon || item.avatar || 'person'}</Icon>}
|
||||||
|
</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header({title = '产品研发群', subtitle = '18人', onMore}) {
|
||||||
|
return <header className="chat-product-header">
|
||||||
|
<button type="button" onClick={() => navigate('messages-home')} aria-label="返回消息"><Icon>arrow_back_ios_new</Icon></button>
|
||||||
|
<div><h1>{title}</h1>{subtitle ? <span>{subtitle}</span> : null}</div>
|
||||||
|
<button type="button" onClick={onMore} aria-label="会话设置"><Icon>more_vert</Icon></button>
|
||||||
|
</header>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toast({message, onDone}) {
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!message) return undefined;
|
||||||
|
const timer = window.setTimeout(onDone, 2000);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [message, onDone]);
|
||||||
|
return message ? <div className="chat-toast" role="status">{message}</div> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VoiceBubble({mine = false, duration = 8, unread = false}) {
|
||||||
|
return <div className={'compose-message-row' + (mine ? ' mine' : '')}>
|
||||||
|
{!mine ? <Avatar item={MEMBERS[1]} /> : null}
|
||||||
|
<button type="button" className={'compose-voice-bubble' + (mine ? ' mine' : '')} aria-label={`播放${duration}秒语音`}>
|
||||||
|
<Icon>graphic_eq</Icon><span className="compose-wave" /><strong>{duration}″</strong>{unread ? <i /> : null}
|
||||||
|
</button>
|
||||||
|
{mine ? <Avatar item={{image: 'img/bkg/p05.jpg'}} /> : null}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextBubble({mine = false, children, selected, onSelect}) {
|
||||||
|
return <div className={'compose-select-row' + (selected !== undefined ? ' selectable' : '')}>
|
||||||
|
{selected !== undefined ? <button type="button" className={'compose-check' + (selected ? ' checked' : '')} onClick={onSelect} aria-label={selected ? '取消选择' : '选择消息'}><Icon>{selected ? 'check' : ''}</Icon></button> : null}
|
||||||
|
<div className={'compose-message-row' + (mine ? ' mine' : '')}>
|
||||||
|
{!mine ? <Avatar item={MEMBERS[2]} /> : null}
|
||||||
|
<div className={'compose-text-bubble' + (mine ? ' mine' : '')}>{children}</div>
|
||||||
|
{mine ? <Avatar item={{image: 'img/bkg/p05.jpg'}} /> : null}
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageBubble({progress, failed = false, selected, onSelect}) {
|
||||||
|
return <div className={'compose-select-row' + (selected !== undefined ? ' selectable' : '')}>
|
||||||
|
{selected !== undefined ? <button type="button" className={'compose-check' + (selected ? ' checked' : '')} onClick={onSelect} aria-label="选择图片"><Icon>{selected ? 'check' : ''}</Icon></button> : null}
|
||||||
|
<div className="compose-message-row mine">
|
||||||
|
<div className="compose-media-card"><img src="img/bkg/p22.jpg" alt="会议白板照片" />
|
||||||
|
{progress < 100 || failed ? <div className="compose-media-state"><Icon>{failed ? 'refresh' : 'cloud_upload'}</Icon><strong>{failed ? '上传失败,点击重试' : `${progress}%`}</strong>{!failed ? <progress value={progress} max="100" /> : null}</div> : null}
|
||||||
|
</div>
|
||||||
|
<Avatar item={{image: 'img/bkg/p05.jpg'}} />
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoBubble() {
|
||||||
|
return <div className="compose-message-row"><Avatar item={MEMBERS[3]} /><button type="button" className="compose-video-card" aria-label="播放视频"><img src="img/bkg/p23.jpg" alt="项目讨论视频封面" /><Icon>play_circle</Icon><small>00:24</small></button></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileBubble({progress = 100, name = '产品需求说明书.pdf', selected, onSelect}) {
|
||||||
|
return <div className={'compose-select-row' + (selected !== undefined ? ' selectable' : '')}>
|
||||||
|
{selected !== undefined ? <button type="button" className={'compose-check' + (selected ? ' checked' : '')} onClick={onSelect} aria-label="选择文件"><Icon>{selected ? 'check' : ''}</Icon></button> : null}
|
||||||
|
<div className="compose-message-row mine"><div className="compose-file-card"><span className="compose-file-type">PDF</span><div><strong>{name}</strong><small>2.4 MB · {progress < 100 ? `上传中 ${progress}%` : '已发送'}</small>{progress < 100 ? <progress value={progress} max="100" /> : null}</div><Icon>{progress < 100 ? 'close' : 'download'}</Icon></div><Avatar item={{image: 'img/bkg/p05.jpg'}} /></div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BaseTimeline({mode, uploadProgress, selected, toggleSelected}) {
|
||||||
|
const selectable = mode === 'message-multiselect' || mode === 'favorite-success';
|
||||||
|
return <main className="chat-product-timeline compose-timeline">
|
||||||
|
<p className="chat-time">今天 10:24</p>
|
||||||
|
<TextBubble>上午的评审结论已经整理好了,大家有空看一下。</TextBubble>
|
||||||
|
<TextBubble mine selected={selectable ? selected.includes('text') : undefined} onSelect={() => toggleSelected('text')}>好的,我补充一下交互细节。</TextBubble>
|
||||||
|
{mode === 'mentions-message' ? <TextBubble><strong className="compose-mention">@安妮 @栗子来了</strong> 下午三点一起确认新版流程。</TextBubble> : null}
|
||||||
|
{['media-uploading', 'media-gallery', 'media-retry'].includes(mode) ? <>
|
||||||
|
<VoiceBubble duration={6} />
|
||||||
|
<ImageBubble progress={mode === 'media-retry' ? 48 : uploadProgress} failed={mode === 'media-retry'} />
|
||||||
|
{mode === 'media-gallery' ? <><VoiceBubble mine duration={12} unread /><VideoBubble /></> : null}
|
||||||
|
</> : null}
|
||||||
|
{['file-message', 'message-multiselect', 'favorite-success'].includes(mode) ? <FileBubble selected={selectable ? selected.includes('file') : undefined} onSelect={() => toggleSelected('file')} /> : null}
|
||||||
|
{selectable ? <ImageBubble progress={100} selected={selected.includes('image')} onSelect={() => toggleSelected('image')} /> : null}
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmojiPanel({onEmoji, onDelete}) {
|
||||||
|
return <section className="compose-emoji-panel" aria-label="表情面板">
|
||||||
|
<nav><button type="button" className="active" aria-label="最近使用"><Icon>schedule</Icon></button><button type="button" aria-label="笑脸"><Icon>sentiment_satisfied_alt</Icon></button><button type="button" aria-label="收藏表情"><Icon>favorite_border</Icon></button></nav>
|
||||||
|
<div>{EMOJIS.map(emoji => <button type="button" key={emoji} onClick={() => onEmoji(emoji)}>{emoji}</button>)}</div>
|
||||||
|
<button type="button" className="compose-emoji-delete" onClick={onDelete} aria-label="删除字符"><Icon>backspace</Icon></button>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Composer({mode, draft, setDraft, onSend, setMode}) {
|
||||||
|
const voice = ['voice-ready', 'voice-hold', 'voice-recording'].includes(mode);
|
||||||
|
const emoji = ['emoji-panel', 'emoji-message'].includes(mode);
|
||||||
|
const keyDown = event => {
|
||||||
|
if (event.key === '@' && mode !== 'mentions-message') window.setTimeout(() => navigate('mentions-select'), 0);
|
||||||
|
};
|
||||||
|
return <>
|
||||||
|
<form className="compose-toolbar" onSubmit={event => {event.preventDefault(); onSend();}}>
|
||||||
|
<button type="button" onClick={() => setMode(voice ? 'compose-keyboard' : 'voice-ready')} aria-label={voice ? '切换键盘' : '切换语音'}><Icon>{voice ? 'keyboard' : 'record_voice_over'}</Icon></button>
|
||||||
|
{voice ? <button type="button" className="compose-hold-button" onPointerDown={() => setMode('voice-hold')} onPointerUp={() => setMode('media-uploading')}>按住说话</button> : <input autoFocus={mode === 'compose-keyboard'} value={draft} onChange={event => setDraft(event.target.value)} onKeyDown={keyDown} placeholder="输入消息" />}
|
||||||
|
<button type="button" onClick={() => setMode(emoji ? 'compose-keyboard' : 'emoji-panel')} aria-label="表情"><Icon>sentiment_satisfied_alt</Icon></button>
|
||||||
|
{draft.trim() && !voice ? <button type="submit" className="compose-send">发送</button> : <button type="button" onClick={() => navigate('upload-file')} aria-label="更多"><Icon>add_circle_outline</Icon></button>}
|
||||||
|
</form>
|
||||||
|
{emoji ? <EmojiPanel onEmoji={value => setDraft(draft + value)} onDelete={() => setDraft(draft.slice(0, -1))} /> : null}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VoiceOverlay({cancel = false}) {
|
||||||
|
return <div className={'compose-record-overlay' + (cancel ? ' cancel' : '')} role="status">
|
||||||
|
<Icon>{cancel ? 'delete_outline' : 'mic'}</Icon>
|
||||||
|
<div className="compose-meter">{[2, 5, 8, 12, 9, 6, 11, 4].map((height, index) => <i key={index} style={{height: `${height * 2}px`}} />)}</div>
|
||||||
|
<strong>{cancel ? '松开手指,取消发送' : '手指上滑,取消发送'}</strong><small>00:08</small>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MemberPicker({complete}) {
|
||||||
|
const [query, setQuery] = React.useState('');
|
||||||
|
const [selected, setSelected] = React.useState(complete ? ['annie', 'lizi'] : []);
|
||||||
|
const visible = MEMBERS.filter(item => item.name.includes(query) || item.note.includes(query));
|
||||||
|
const toggle = id => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : values.concat(id));
|
||||||
|
return <div className="compose-picker-page">
|
||||||
|
<header className="compose-picker-header"><button type="button" onClick={() => navigate('compose-keyboard')}><Icon>arrow_back_ios_new</Icon></button><h1>选择提醒人</h1><button type="button" className="text" disabled={!selected.length} onClick={() => {sessionStorage.setItem('im-mentions', JSON.stringify(selected)); navigate('mentions-message');}}>完成{selected.length ? `(${selected.length})` : ''}</button></header>
|
||||||
|
<label className="compose-search"><Icon>search</Icon><input value={query} onChange={event => setQuery(event.target.value)} placeholder="搜索群成员" /></label>
|
||||||
|
<p className="compose-section-title">群成员</p>
|
||||||
|
<div className="compose-picker-list">{visible.map(item => <button type="button" key={item.id} onClick={() => toggle(item.id)}><Avatar item={item} /><span><strong>{item.name}</strong><small>{item.note}</small></span><i className={'compose-checkbox' + (selected.includes(item.id) ? ' checked' : '')}>{selected.includes(item.id) ? <Icon>check</Icon> : null}</i></button>)}</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UploadFilePage({onToast}) {
|
||||||
|
const [files, setFiles] = React.useState([{name: '产品需求说明书.pdf', size: '2.4 MB', progress: 64}]);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (files.every(file => file.progress >= 100)) return undefined;
|
||||||
|
const timer = window.setTimeout(() => setFiles(items => items.map(file => ({...file, progress: Math.min(100, file.progress + 6)}))), 300);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [files]);
|
||||||
|
return <div className="compose-upload-page"><Header title="上传文件" subtitle="最多选择 9 个文件" onMore={() => onToast('单个文件最大 100 MB')} />
|
||||||
|
<main><label className="compose-upload-drop"><input type="file" multiple onChange={event => setFiles(Array.from(event.target.files).map(file => ({name: file.name, size: `${Math.max(1, file.size / 1024 / 1024).toFixed(1)} MB`, progress: 1})))} /><Icon>cloud_upload</Icon><strong>选择文件</strong><small>支持文档、压缩包及常用媒体格式</small></label>
|
||||||
|
<section className="compose-upload-list"><h2>待发送文件</h2>{files.map(file => <div key={file.name}><span className="compose-file-type">{file.name.split('.').pop().slice(0, 4).toUpperCase()}</span><div><strong>{file.name}</strong><small>{file.size} · {file.progress < 100 ? `上传中 ${file.progress}%` : '上传完成'}</small><progress value={file.progress} max="100" /></div><button type="button" aria-label="移除文件" onClick={() => setFiles(items => items.filter(item => item.name !== file.name))}><Icon>close</Icon></button></div>)}</section>
|
||||||
|
</main><footer><button type="button" disabled={!files.length} onClick={() => navigate('file-message')}>发送{files.length ? ` (${files.length})` : ''}</button></footer>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ForwardPage({mode, onToast}) {
|
||||||
|
const multi = mode !== 'forward-select';
|
||||||
|
const confirm = mode === 'forward-individual' || mode === 'forward-bundle';
|
||||||
|
const [selected, setSelected] = React.useState(() => {
|
||||||
|
if (!confirm) return [];
|
||||||
|
try {
|
||||||
|
return JSON.parse(sessionStorage.getItem('im-forward-targets')) || ['team', 'annie'];
|
||||||
|
} catch (_) {
|
||||||
|
return ['team', 'annie'];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const [query, setQuery] = React.useState('');
|
||||||
|
const [comment, setComment] = React.useState('');
|
||||||
|
const toggle = id => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : multi ? values.concat(id).slice(0, 9) : [id]);
|
||||||
|
const visible = TARGETS.filter(item => item.name.includes(query) || item.note.includes(query));
|
||||||
|
return <div className="compose-forward-page">
|
||||||
|
<header className="compose-picker-header"><button type="button" onClick={() => navigate('message-multiselect')}><Icon>close</Icon></button><h1>{confirm ? (mode === 'forward-bundle' ? '合并转发' : '逐条转发') : '选择转发对象'}</h1>{!confirm ? <button type="button" className="text" onClick={() => navigate(multi ? 'forward-select' : 'forward-multiple')}>{multi ? '单选' : '多选'}</button> : <span />}</header>
|
||||||
|
{!confirm ? <><label className="compose-search"><Icon>search</Icon><input value={query} onChange={event => setQuery(event.target.value)} placeholder="搜索会话或联系人" /></label><p className="compose-section-title">最近会话</p><div className="compose-picker-list">{visible.map(item => <button type="button" key={item.id} onClick={() => {toggle(item.id); if (!multi) {sessionStorage.setItem('im-forward-targets', JSON.stringify([item.id])); window.setTimeout(() => navigate('forward-individual'), 0);}}}><Avatar item={item} /><span><strong>{item.name}</strong><small>{item.note}</small></span><i className={'compose-checkbox' + (selected.includes(item.id) ? ' checked' : '')}>{selected.includes(item.id) ? <Icon>check</Icon> : null}</i></button>)}</div>{multi ? <footer className="compose-target-footer"><span>已选择 {selected.length}/9 个会话</span><button type="button" disabled={!selected.length} onClick={() => {sessionStorage.setItem('im-forward-targets', JSON.stringify(selected)); navigate('forward-individual');}}>下一步</button></footer> : null}</> : <main className="compose-forward-confirm"><section><h2>发送给</h2><div className="compose-target-summary">{TARGETS.filter(item => selected.includes(item.id)).map(item => <span key={item.id}><Avatar item={item} /><small>{item.name}</small></span>)}</div></section><section className="compose-forward-preview"><h2>{mode === 'forward-bundle' ? '产品研发群的聊天记录' : '将逐条发送 3 条消息'}</h2><p>栗子来了:上午的评审结论已经整理好了</p><p>我:好的,我补充一下交互细节。</p><p>我:[图片]</p></section><textarea value={comment} onChange={event => setComment(event.target.value)} placeholder="给朋友留言" /><div className="compose-forward-mode"><button type="button" className={mode === 'forward-individual' ? 'active' : ''} onClick={() => navigate('forward-individual')}>逐条转发</button><button type="button" className={mode === 'forward-bundle' ? 'active' : ''} onClick={() => navigate('forward-bundle')}>合并转发</button></div><button type="button" className="compose-confirm-send" onClick={() => {onToast(`已发送给 ${selected.length} 个会话`); window.setTimeout(() => navigate('chat-standard'), 900);}}>发送</button></main>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageComposerView({view}) {
|
||||||
|
const [mode, setMode] = React.useState(view);
|
||||||
|
const [draft, setDraftState] = React.useState(() => sessionStorage.getItem('im-message-composer-draft') || (view === 'emoji-message' ? '收到啦 ' : ''));
|
||||||
|
const [uploadProgress, setUploadProgress] = React.useState(view === 'media-uploading' ? 22 : 100);
|
||||||
|
const [selected, setSelected] = React.useState(view === 'favorite-success' ? ['text', 'file', 'image'] : ['text', 'image']);
|
||||||
|
const [toast, setToast] = React.useState(view === 'favorite-success' ? '已收藏 3 条消息' : '');
|
||||||
|
const setDraft = value => {setDraftState(value); sessionStorage.setItem('im-message-composer-draft', value);};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (mode !== 'media-uploading' || uploadProgress >= 100) return undefined;
|
||||||
|
const timer = window.setTimeout(() => setUploadProgress(value => Math.min(100, value + 5)), 350);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [mode, uploadProgress]);
|
||||||
|
|
||||||
|
if (view === 'mentions-select' || view === 'mentions-complete') return <MemberPicker complete={view === 'mentions-complete'} />;
|
||||||
|
if (view === 'upload-file') return <><UploadFilePage onToast={setToast} /><Toast message={toast} onDone={() => setToast('')} /></>;
|
||||||
|
if (['forward-select', 'forward-multiple', 'forward-individual', 'forward-bundle'].includes(view)) return <><ForwardPage mode={view} onToast={setToast} /><Toast message={toast} onDone={() => setToast('')} /></>;
|
||||||
|
|
||||||
|
const toggleSelected = id => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : values.concat(id));
|
||||||
|
const recording = mode === 'voice-hold' || mode === 'voice-recording';
|
||||||
|
const multiselect = mode === 'message-multiselect' || mode === 'favorite-success';
|
||||||
|
|
||||||
|
return <div className="chat-product-page message-composer-page">
|
||||||
|
<Header onMore={() => setToast('会话设置')} />
|
||||||
|
{multiselect ? <div className="compose-selection-bar"><button type="button" onClick={() => navigate('chat-standard')}><Icon>close</Icon></button><strong>已选择 {selected.length} 条</strong><button type="button" onClick={() => setSelected(selected.length ? [] : ['text', 'file', 'image'])}>{selected.length ? '取消全选' : '全选'}</button></div> : null}
|
||||||
|
<BaseTimeline mode={mode} uploadProgress={uploadProgress} selected={selected} toggleSelected={toggleSelected} />
|
||||||
|
{multiselect ? <nav className="compose-batch-actions"><button type="button" disabled={!selected.length} onClick={() => navigate('forward-select')}><Icon>forward</Icon><span>转发</span></button><button type="button" disabled={!selected.length} onClick={() => {setMode('favorite-success'); setToast(`已收藏 ${selected.length} 条消息`);}}><Icon>bookmark_border</Icon><span>收藏</span></button><button type="button" disabled={!selected.length} onClick={() => {setSelected([]); setToast('已删除所选消息');}}><Icon>delete_outline</Icon><span>删除</span></button></nav> : <Composer mode={mode} draft={draft} setDraft={setDraft} setMode={setMode} onSend={() => {if (!draft.trim()) return; setToast('消息已发送'); setDraft('');}} />}
|
||||||
|
{recording ? <VoiceOverlay cancel={false} /> : null}
|
||||||
|
<Toast message={toast} onDone={() => setToast('')} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import QRCode from 'qrcodejs';
|
||||||
|
|
||||||
|
import HashNavigation from '../lib/navigation.js';
|
||||||
|
|
||||||
|
const navigate = view => HashNavigation.navigateTo(view);
|
||||||
|
|
||||||
|
export const PERSONAL_SETTINGS_VIEWS = [
|
||||||
|
'personal-center', 'personal-profile', 'profile-nickname', 'profile-birth-date',
|
||||||
|
'profile-signature', 'personal-qrcode', 'android-settings', 'cache-clear-confirm',
|
||||||
|
'app-version', 'app-update-available', 'privacy-settings', 'friend-discovery-settings',
|
||||||
|
'youth-mode-off', 'youth-mode-confirm', 'youth-mode-on', 'youth-mode-disable',
|
||||||
|
'notification-permission', 'permission-settings-reminder', 'camera-permission-info',
|
||||||
|
'microphone-permission-info'
|
||||||
|
];
|
||||||
|
|
||||||
|
const PROFILE_KEY = 'im-demo-personal-profile';
|
||||||
|
const PRIVACY_KEY = 'im-demo-privacy-settings';
|
||||||
|
const YOUTH_KEY = 'im-demo-youth-mode';
|
||||||
|
const DEFAULT_PROFILE = {nickname: '陈晨', birthDate: '1998-06-18', signature: '保持好奇,认真生活。'};
|
||||||
|
|
||||||
|
function readObject(key, fallback) {
|
||||||
|
try { return {...fallback, ...JSON.parse(window.localStorage.getItem(key))}; } catch (_) { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
|
||||||
|
|
||||||
|
function Header({title, back = 'personal-center', action, actionLabel, onAction}) {
|
||||||
|
return <header className="personal-header"><button type="button" onClick={() => navigate(back)} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1>{action ? <button type="button" className="text" onClick={onAction}>{actionLabel || action}</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="personal-toast" role="status">{message}</div> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toggle({checked, disabled, onChange}) {
|
||||||
|
return <button type="button" role="switch" aria-checked={checked} disabled={disabled} className={'personal-switch' + (checked ? ' checked' : '')} onClick={() => onChange(!checked)}><i /></button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuRow({icon, title, value, route, onClick, danger}) {
|
||||||
|
return <button type="button" className={'personal-menu-row' + (danger ? ' danger' : '')} onClick={onClick || (() => route && navigate(route))}>{icon ? <Icon>{icon}</Icon> : null}<span><strong>{title}</strong>{value ? <small>{value}</small> : null}</span>{route || onClick ? <Icon>chevron_right</Icon> : null}</button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonalCenter() {
|
||||||
|
const profile = readObject(PROFILE_KEY, DEFAULT_PROFILE);
|
||||||
|
return <div className="personal-page personal-center-page"><Header title="我的" back="messages-home" /><main><button type="button" className="personal-identity" onClick={() => navigate('personal-profile')}><img src="img/bkg/p05.jpg" alt="" /><span><strong>{profile.nickname}</strong><small>产品研发部 · 产品经理</small><small>账号:CHEN88</small></span><Icon>chevron_right</Icon></button><section><MenuRow icon="qr_code_2" title="我的二维码" route="personal-qrcode" /></section><section><MenuRow icon="inventory_2" title="我的收藏" route="favorites" /><MenuRow icon="feedback" title="意见反馈" route="user-feedback" /><MenuRow icon="settings" title="设置" route="android-settings" /></section></main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonalProfile({onToast}) {
|
||||||
|
const profile = readObject(PROFILE_KEY, DEFAULT_PROFILE);
|
||||||
|
return <div className="personal-page"><Header title="个人信息" /><main className="personal-profile-list"><button type="button" onClick={() => onToast('头像选择器已打开')}><strong>头像</strong><img src="img/bkg/p05.jpg" alt="" /><Icon>chevron_right</Icon></button><MenuRow title="昵称" value={profile.nickname} route="profile-nickname" /><MenuRow title="出生日期" value={profile.birthDate} route="profile-birth-date" /><MenuRow title="二维码名片" value="" route="personal-qrcode" /><MenuRow title="账号" value="CHEN88" /><MenuRow title="职位" value="产品经理" /><MenuRow title="公司" value="星海科技有限公司" /><MenuRow title="个性签名" value={profile.signature || '未填写'} route="profile-signature" /></main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextEditor({field, multiline, title, maxLength, onToast}) {
|
||||||
|
const profile = readObject(PROFILE_KEY, DEFAULT_PROFILE);
|
||||||
|
const [value, setValue] = React.useState(profile[field] || '');
|
||||||
|
const save = () => {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (field === 'nickname' && !normalized) return onToast('昵称不能为空');
|
||||||
|
const next = {...profile, [field]: field === 'signature' ? value.trim() : normalized};
|
||||||
|
window.localStorage.setItem(PROFILE_KEY, JSON.stringify(next));
|
||||||
|
onToast('资料已保存');
|
||||||
|
window.setTimeout(() => navigate('personal-profile'), 650);
|
||||||
|
};
|
||||||
|
return <div className="personal-page profile-editor-page"><Header title={title} back="personal-profile" action="完成" onAction={save} /><main><label>{multiline ? <textarea value={value} maxLength={maxLength} autoFocus onChange={event => setValue(event.target.value)} /> : <input value={value} maxLength={maxLength} autoFocus onChange={event => setValue(event.target.value)} />}<small>{value.length}/{maxLength}</small>{value ? <button type="button" onClick={() => setValue('')} aria-label="清空"><Icon>cancel</Icon></button> : null}</label><p>{field === 'nickname' ? '昵称将展示在联系人资料和聊天页面中' : '签名将展示在你的个人资料页'}</p></main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BirthDateEditor({onToast}) {
|
||||||
|
const profile = readObject(PROFILE_KEY, DEFAULT_PROFILE);
|
||||||
|
const [value, setValue] = React.useState(profile.birthDate);
|
||||||
|
const save = () => {window.localStorage.setItem(PROFILE_KEY, JSON.stringify({...profile, birthDate: value})); onToast('出生日期已保存'); window.setTimeout(() => navigate('personal-profile'), 650);};
|
||||||
|
return <div className="personal-page profile-editor-page"><Header title="出生日期" back="personal-profile" action="完成" onAction={save} /><main className="birth-date-editor"><Icon>cake</Icon><label><span>选择出生日期</span><input type="date" value={value} max="2026-08-25" min="1900-01-01" onChange={event => setValue(event.target.value)} /></label><p>出生日期的可见范围由隐私设置决定</p></main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonalQr({onToast}) {
|
||||||
|
const profile = readObject(PROFILE_KEY, DEFAULT_PROFILE);
|
||||||
|
const [token, setToken] = React.useState(Date.now());
|
||||||
|
const qrRef = React.useRef(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
qrRef.current.innerHTML = '';
|
||||||
|
new QRCode(qrRef.current, {text: `im://profile-card?token=demo-${token}&purpose=add_friend`, width: 210, height: 210, correctLevel: QRCode.CorrectLevel.M});
|
||||||
|
}, [token]);
|
||||||
|
return <div className="personal-page personal-qr-page"><Header title="我的二维码" /><main><section><div className="personal-qr-profile"><img src="img/bkg/p05.jpg" alt="" /><span><strong>{profile.nickname}</strong><small>星海科技有限公司</small></span></div><div className="personal-qrcode" ref={qrRef} /><p>扫一扫上面的二维码,加我为好友</p><small>二维码凭证有效期为 24 小时</small></section><div className="personal-qr-actions"><button type="button" onClick={() => {setToken(Date.now()); onToast('二维码已刷新');}}><Icon>refresh</Icon><span>刷新</span></button><button type="button" onClick={() => onToast('二维码名片已分享')}><Icon>share</Icon><span>分享</span></button></div></main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AndroidSettings({onToast}) {
|
||||||
|
return <div className="personal-page"><Header title="设置" /><main className="android-settings-list"><section><MenuRow icon="shield" title="账号与安全" route="security" /><MenuRow icon="notifications_none" title="消息通知" route="notification-permission" /><MenuRow icon="lock_outline" title="隐私设置" route="privacy-settings" /></section><section><MenuRow icon="photo_camera" title="相机权限说明" route="camera-permission-info" /><MenuRow icon="mic_none" title="麦克风权限说明" route="microphone-permission-info" /></section><section><MenuRow icon="delete_sweep" title="清空缓存" value="126 MB" route="cache-clear-confirm" /><MenuRow icon="child_care" title="青少年模式" value={window.localStorage.getItem(YOUTH_KEY) === 'enabled' ? '已开启' : '未开启'} route={window.localStorage.getItem(YOUTH_KEY) === 'enabled' ? 'youth-mode-on' : 'youth-mode-off'} /><MenuRow icon="system_update" title="版本更新" value="1.2.4" route="app-version" /></section><section><MenuRow icon="block" title="黑名单" route="block-list" /><MenuRow icon="wallpaper" title="聊天背景" route="wallpapers" /><MenuRow icon="info_outline" title="关于应用" route="app-version" /></section><button type="button" className="personal-logout" onClick={() => onToast('退出登录需在后端对接阶段完成会话撤销')}>退出当前登录</button></main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CacheClear({onToast}) {
|
||||||
|
const [cleared, setCleared] = React.useState(false);
|
||||||
|
return <div className="personal-page"><AndroidSettings onToast={onToast} /><div className="personal-sheet-backdrop"><section className="personal-confirm-sheet" role="dialog" aria-modal="true"><Icon>{cleared ? 'check_circle' : 'delete_sweep'}</Icon><h2>{cleared ? '缓存已清理' : '清空缓存'}</h2><p>{cleared ? '已释放 126 MB 存储空间。' : '将清除缩略图、临时文件和可重新下载的数据,不会删除聊天记录、收藏或登录信息。'}</p>{cleared ? <button type="button" className="primary" onClick={() => navigate('android-settings')}>完成</button> : <><button type="button" className="danger" onClick={() => {setCleared(true); onToast('缓存清理完成');}}>确认清理</button><button type="button" onClick={() => navigate('android-settings')}>取消</button></>}</section></div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppVersion({updatePrompt, onToast}) {
|
||||||
|
const [checking, setChecking] = React.useState(false);
|
||||||
|
const check = () => {setChecking(true); window.setTimeout(() => {setChecking(false); navigate('app-update-available');}, 700);};
|
||||||
|
return <div className="personal-page app-version-page"><Header title="关于与更新" back="android-settings" /><main><section className="app-brand"><span><Icon>forum</Icon></span><h2>光讯通讯</h2><p>Android 版本 1.2.4</p></section><section className="personal-menu-section"><MenuRow title="用户条款" route="app-version" /><MenuRow title="隐私协议" route="app-version" /><MenuRow title="检测新版本" value={checking ? '检测中…' : '有新版本'} onClick={check} /><MenuRow title="联系我们" value="support@im.example" /></section></main>{updatePrompt ? <div className="update-backdrop"><section className="update-dialog" role="dialog" aria-modal="true"><span><Icon>rocket_launch</Icon></span><h2>发现新版本</h2><strong>版本 1.3.0</strong><ul><li>优化消息与工作台加载速度</li><li>完善隐私设置和 Android 权限引导</li><li>修复已知问题</li></ul><button type="button" onClick={() => onToast('更新任务已交给 Android 下载管理器')}>立即更新</button><button type="button" onClick={() => navigate('app-version')}>稍后提醒</button></section></div> : null}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrivacySettings({onToast}) {
|
||||||
|
const initial = readObject(PRIVACY_KEY, {groupVerification: true, readReceipts: true, lastSeen: true});
|
||||||
|
const [settings, setSettings] = React.useState(initial);
|
||||||
|
const update = (key, value) => {const next = {...settings, [key]: value}; setSettings(next); window.localStorage.setItem(PRIVACY_KEY, JSON.stringify(next)); onToast('隐私设置已保存');};
|
||||||
|
return <div className="personal-page"><Header title="隐私设置" back="android-settings" /><main className="privacy-list"><section><MenuRow title="添加我的方式" value="全部允许" route="friend-discovery-settings" /></section><section><div><span><strong>邀请我入群需要验证</strong><small>关闭后仍受企业安全策略保护</small></span><Toggle checked={settings.groupVerification} onChange={value => update('groupVerification', value)} /></div><div><span><strong>显示消息已读状态</strong><small>关闭后你也无法查看对方的已读状态</small></span><Toggle checked={settings.readReceipts} onChange={value => update('readReceipts', value)} /></div><div><span><strong>展示最后在线时间</strong><small>仅向联系人显示</small></span><Toggle checked={settings.lastSeen} onChange={value => update('lastSeen', value)} /></div></section><section><MenuRow title="黑名单" route="block-list" /></section></main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FriendDiscoverySettings({onToast}) {
|
||||||
|
const initial = readObject(PRIVACY_KEY, {account: true, phone: true, qr: true, group: true});
|
||||||
|
const [methods, setMethods] = React.useState(initial);
|
||||||
|
const update = (key, value) => {const next = {...methods, [key]: value}; setMethods(next); window.localStorage.setItem(PRIVACY_KEY, JSON.stringify(next)); onToast('添加方式已更新');};
|
||||||
|
const rows = [{key: 'account', title: '账号'}, {key: 'phone', title: '手机号'}, {key: 'qr', title: '二维码'}, {key: 'group', title: '群聊'}];
|
||||||
|
return <div className="personal-page"><Header title="添加我的方式" back="privacy-settings" /><main className="privacy-list"><p className="privacy-note">关闭某种方式后,其他人将无法通过该渠道搜索或添加你。</p><section>{rows.map(row => <div key={row.key}><span><strong>可通过{row.title}添加我</strong></span><Toggle checked={methods[row.key]} onChange={value => update(row.key, value)} /></div>)}</section></main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const YOUTH_RULES = ['每日使用时长不超过 40 分钟', '限制主动或被动添加好友、加入群聊的次数', '隐藏不适合未成年人的内容与功能'];
|
||||||
|
|
||||||
|
function YouthMode({state, onToast}) {
|
||||||
|
const enabled = state === 'on' || state === 'disable';
|
||||||
|
const [password, setPassword] = React.useState('');
|
||||||
|
const enable = () => {window.localStorage.setItem(YOUTH_KEY, 'enabled'); onToast('青少年模式已开启'); window.setTimeout(() => navigate('youth-mode-on'), 650);};
|
||||||
|
const disable = () => {if (password.length < 6) return onToast('请输入 6 位独立密码'); window.localStorage.removeItem(YOUTH_KEY); setPassword(''); onToast('青少年模式已关闭'); window.setTimeout(() => navigate('youth-mode-off'), 650);};
|
||||||
|
return <div className="personal-page youth-mode-page"><Header title="青少年模式" back="android-settings" /><main><Icon>{enabled ? 'verified_user' : 'child_care'}</Icon><h2>{enabled ? '青少年模式已开启' : '青少年模式未开启'}</h2><ul>{YOUTH_RULES.map(rule => <li key={rule}>{rule}</li>)}</ul>{enabled ? <p className="youth-usage">今日已使用:00:24:05</p> : null}</main><footer><button type="button" className={enabled ? 'outline' : ''} onClick={() => navigate(enabled ? 'youth-mode-disable' : 'youth-mode-confirm')}>{enabled ? '关闭青少年模式' : '开启青少年模式'}</button><small>可在设置中查看青少年模式状态</small></footer>{state === 'confirm' ? <div className="personal-sheet-backdrop"><section className="personal-confirm-sheet youth-confirm" role="dialog" aria-modal="true"><Icon>family_restroom</Icon><h2>开启青少年模式</h2><p>开启后将按照当前企业的青少年保护策略限制使用时长、社交次数和部分内容。独立密码用于关闭模式。</p><button type="button" className="primary" onClick={enable}>确认并开启</button><button type="button" onClick={() => navigate('youth-mode-off')}>取消</button></section></div> : null}{state === 'disable' ? <div className="personal-sheet-backdrop"><section className="personal-confirm-sheet youth-confirm" role="dialog" aria-modal="true"><Icon>password</Icon><h2>关闭青少年模式</h2><p>请输入开启青少年模式时设置的 6 位独立密码。</p><input type="password" inputMode="numeric" maxLength="6" value={password} autoFocus onChange={event => setPassword(event.target.value.replace(/\D/g, ''))} placeholder="独立密码" /><button type="button" className="primary" onClick={disable}>验证并关闭</button><button type="button" onClick={() => navigate('youth-mode-on')}>取消</button></section></div> : null}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERMISSIONS = {
|
||||||
|
notification: {icon: 'notifications_active', title: '获取通知权限', text: '用于及时送达新消息、会议提醒和安全通知。', action: '开启通知', route: 'permission-settings-reminder'},
|
||||||
|
reminder: {icon: 'settings', title: '消息推送暂未开启', text: '请前往 Android 系统设置,在通知管理中允许光讯通讯发送通知。', action: '前往设置', route: 'android-settings'},
|
||||||
|
camera: {icon: 'photo_camera', title: '相机权限使用说明', text: '用于拍照、扫码和视频通话。拒绝后仍可从相册选择图片。', action: '继续授权', route: 'messages-home'},
|
||||||
|
microphone: {icon: 'mic', title: '麦克风权限使用说明', text: '用于语音消息、语音输入和音视频通话,未授权时不会创建录音文件。', action: '继续授权', route: 'messages-home'}
|
||||||
|
};
|
||||||
|
|
||||||
|
function PermissionGuide({type, onToast}) {
|
||||||
|
const config = PERMISSIONS[type];
|
||||||
|
const proceed = () => {onToast(type === 'reminder' ? '已打开 Android 应用设置' : '等待 Android 系统授权结果'); window.setTimeout(() => navigate(config.route), 650);};
|
||||||
|
return <div className="personal-page permission-guide-page"><div className="permission-preview"><header><h1>消息</h1><Icon>menu</Icon></header><label><Icon>search</Icon><span>搜索</span></label>{['系统消息', '产品研发群', '文件助手', '安妮'].map((name, index) => <div key={name}><span className={'permission-avatar tone-' + index}><Icon>{index === 0 ? 'notifications' : index === 2 ? 'folder' : 'person'}</Icon></span><span><strong>{name}</strong><small>{index ? '最近有一条新消息' : '账号安全提醒'}</small></span></div>)}</div><div className="permission-overlay"><section role="dialog" aria-modal="true"><span><Icon>{config.icon}</Icon></span><h2>{config.title}</h2><p>{config.text}</p><button type="button" onClick={proceed}>{config.action}</button><button type="button" onClick={() => navigate('android-settings')}>暂不开启</button></section></div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PersonalSettingsView({view}) {
|
||||||
|
const [toast, setToast] = React.useState('');
|
||||||
|
let content;
|
||||||
|
if (view === 'personal-center') content = <PersonalCenter />;
|
||||||
|
else if (view === 'personal-profile') content = <PersonalProfile onToast={setToast} />;
|
||||||
|
else if (view === 'profile-nickname') content = <TextEditor field="nickname" title="设置昵称" maxLength={32} onToast={setToast} />;
|
||||||
|
else if (view === 'profile-signature') content = <TextEditor field="signature" title="个性签名" maxLength={120} multiline onToast={setToast} />;
|
||||||
|
else if (view === 'profile-birth-date') content = <BirthDateEditor onToast={setToast} />;
|
||||||
|
else if (view === 'personal-qrcode') content = <PersonalQr onToast={setToast} />;
|
||||||
|
else if (view === 'android-settings') content = <AndroidSettings onToast={setToast} />;
|
||||||
|
else if (view === 'cache-clear-confirm') content = <CacheClear onToast={setToast} />;
|
||||||
|
else if (view === 'app-version' || view === 'app-update-available') content = <AppVersion updatePrompt={view === 'app-update-available'} onToast={setToast} />;
|
||||||
|
else if (view === 'privacy-settings') content = <PrivacySettings onToast={setToast} />;
|
||||||
|
else if (view === 'friend-discovery-settings') content = <FriendDiscoverySettings onToast={setToast} />;
|
||||||
|
else if (view.startsWith('youth-mode')) content = <YouthMode state={view.replace('youth-mode-', '')} onToast={setToast} />;
|
||||||
|
else content = <PermissionGuide type={view === 'notification-permission' ? 'notification' : view === 'permission-settings-reminder' ? 'reminder' : view === 'camera-permission-info' ? 'camera' : 'microphone'} onToast={setToast} />;
|
||||||
|
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import HashNavigation from '../lib/navigation.js';
|
||||||
|
|
||||||
|
const navigate = view => HashNavigation.navigateTo(view);
|
||||||
|
|
||||||
|
export const SECURITY_SUPPORT_VIEWS = [
|
||||||
|
'fraud-risk-warning', 'fraud-risk-confirmation', 'abuse-report-reasons',
|
||||||
|
'abuse-report-details', 'user-feedback'
|
||||||
|
];
|
||||||
|
|
||||||
|
const REPORT_REASON_KEY = 'im-demo-abuse-reason';
|
||||||
|
const REPORT_REASONS = [
|
||||||
|
{code: 'fraud', label: '诈骗或骗取钱财', description: '诱导转账、刷单、投资或提供验证码', icon: 'currency_yuan', required: true},
|
||||||
|
{code: 'harassment', label: '骚扰或恶意联系', description: '频繁打扰、辱骂、威胁或跟踪', icon: 'record_voice_over'},
|
||||||
|
{code: 'sexual', label: '色情或低俗内容', description: '发送色情图片、文字或相关服务信息', icon: 'visibility_off'},
|
||||||
|
{code: 'illegal', label: '违法违禁内容', description: '赌博、毒品、暴力或其他违法信息', icon: 'gavel'},
|
||||||
|
{code: 'misinformation', label: '谣言或虚假信息', description: '发布误导性、冒充身份或不实信息', icon: 'campaign'},
|
||||||
|
{code: 'infringement', label: '侵权行为', description: '侵犯隐私、肖像、著作权或商标权', icon: 'copyright'},
|
||||||
|
{code: 'other', label: '其他违规行为', description: '不属于以上类型的问题', icon: 'more_horiz', required: true}
|
||||||
|
];
|
||||||
|
|
||||||
|
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
|
||||||
|
|
||||||
|
function Header({title, back}) {
|
||||||
|
return <header className="support-header"><button type="button" onClick={() => navigate(back)} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1><span /></header>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toast({message, onDone}) {
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!message) return undefined;
|
||||||
|
const timer = window.setTimeout(onDone, 2200);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [message, onDone]);
|
||||||
|
return message ? <div className="support-toast" role="status">{message}</div> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConversationPreview() {
|
||||||
|
return <div className="risk-conversation-preview"><header><button type="button"><Icon>arrow_back_ios_new</Icon></button><span><strong>安妮</strong><small>在线</small></span><Icon>more_vert</Icon></header><main><p>今天 14:28</p><div className="peer">这个内部项目收益很高,名额有限。</div><div className="peer">先转一笔保证金,我马上帮你办理。</div><div className="mine">好的,我现在转账。</div></main><footer><span>输入消息</span><Icon>add_circle_outline</Icon></footer></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RiskWarning({secondary, onToast}) {
|
||||||
|
const continueAction = () => {
|
||||||
|
if (!secondary) return navigate('fraud-risk-confirmation');
|
||||||
|
onToast('已记录风险确认,将返回原操作');
|
||||||
|
window.setTimeout(() => navigate('chat-standard'), 750);
|
||||||
|
};
|
||||||
|
return <div className="support-page risk-warning-page"><ConversationPreview /><div className="risk-overlay"><section className={'risk-dialog' + (secondary ? ' secondary' : '')} role="alertdialog" aria-modal="true"><span className="risk-symbol"><Icon>{secondary ? 'warning_amber' : 'shield_lock'}</Icon></span><h2>安全提醒</h2>{secondary ? <><p className="risk-emphasis">当前操作可能涉及诈骗风险,请再次确认。</p><p>请勿轻信陌生人的刷单返利、投资理财、网络贷款等信息。验证码、密码和转账信息不要提供给任何人。</p><aside><Icon>phone_in_talk</Icon><span>如遇诈骗,请立即停止操作并拨打 110 报警。</span></aside></> : <><p>为保障你的账号和资金安全,请注意:</p><ul><li><Icon>money_off</Icon>不参与刷单返利或高收益投资</li><li><Icon>badge</Icon>不出租、出借或出售账号和实名信息</li><li><Icon>password</Icon>不向任何人泄露验证码和密码</li><li><Icon>link_off</Icon>不打开来源不明的链接或安装包</li></ul></>}<button type="button" className={secondary ? 'danger' : 'primary'} onClick={continueAction}>{secondary ? '我已确认风险,仍要继续' : '我已阅读,继续'}</button><button type="button" onClick={() => {onToast('操作已取消'); window.setTimeout(() => navigate('chat-standard'), 650);}}>{secondary ? '停止操作' : '取消'}</button></section></div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportReasons() {
|
||||||
|
const select = reason => {window.sessionStorage.setItem(REPORT_REASON_KEY, JSON.stringify(reason)); navigate('abuse-report-details');};
|
||||||
|
return <div className="support-page"><Header title="举报" back="friend-settings" /><main className="report-reasons"><section className="report-target"><img src="img/bkg/p09.jpg" alt="" /><span><strong>举报安妮</strong><small>举报人身份不会向对方公开</small></span><Icon>verified_user</Icon></section><h2>请选择举报原因</h2><section className="report-reason-list">{REPORT_REASONS.map(reason => <button type="button" key={reason.code} onClick={() => select(reason)}><span><Icon>{reason.icon}</Icon></span><span><strong>{reason.label}</strong><small>{reason.description}</small></span><Icon>chevron_right</Icon></button>)}</section></main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportDetails({onToast}) {
|
||||||
|
const reason = (() => {try { return JSON.parse(window.sessionStorage.getItem(REPORT_REASON_KEY)) || REPORT_REASONS[0]; } catch (_) { return REPORT_REASONS[0]; }})();
|
||||||
|
const [description, setDescription] = React.useState('');
|
||||||
|
const [evidence, setEvidence] = React.useState([]);
|
||||||
|
const [submitted, setSubmitted] = React.useState(false);
|
||||||
|
const addEvidence = () => {if (evidence.length >= 5) return onToast('最多上传 5 个证据文件'); setEvidence(items => items.concat({id: Date.now(), name: `聊天截图_${items.length + 1}.png`}));};
|
||||||
|
const submit = () => {if (reason.required && !description.trim()) return onToast('请补充问题描述'); setSubmitted(true);};
|
||||||
|
return <div className="support-page"><Header title="举报" back="abuse-report-reasons" />{submitted ? <main className="support-success"><Icon>task_alt</Icon><h2>举报已受理</h2><p>我们会依据平台规则核实处理,举报人身份不会向被举报方公开。</p><dl><div><dt>受理编号</dt><dd>ABR-20260825-1024</dd></div><div><dt>当前状态</dt><dd>审核中</dd></div></dl><button type="button" onClick={() => navigate('messages-home')}>完成</button></main> : <main className="report-details"><section className="report-selected-reason"><Icon>{reason.icon}</Icon><span><small>举报原因</small><strong>{reason.label}</strong></span><button type="button" onClick={() => navigate('abuse-report-reasons')}>修改</button></section><label className="support-textarea"><span>问题描述{reason.required ? <b>*</b> : null}</span><textarea value={description} maxLength="500" onChange={event => setDescription(event.target.value)} placeholder="请描述具体情况、发生时间和相关行为" /><small>{description.length}/500</small></label><section className="evidence-section"><header><span><strong>证据材料</strong><small>图片或文件,最多 5 个</small></span><em>{evidence.length}/5</em></header><div>{evidence.map(file => <article key={file.id}><Icon>image</Icon><span>{file.name}</span><button type="button" onClick={() => setEvidence(items => items.filter(item => item.id !== file.id))} aria-label="删除证据"><Icon>close</Icon></button></article>)}<button type="button" className="evidence-upload" onClick={addEvidence}><Icon>add_photo_alternate</Icon><span>添加证据</span></button></div></section><aside className="report-privacy-note"><Icon>lock</Icon><span>证据仅用于平台审核,将按照安全策略保存和访问。</span></aside></main>} {!submitted ? <footer className="support-submit-bar"><button type="button" onClick={submit}>提交举报</button></footer> : null}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserFeedback({onToast}) {
|
||||||
|
const [category, setCategory] = React.useState('bug');
|
||||||
|
const [content, setContent] = React.useState('');
|
||||||
|
const [contact, setContact] = React.useState('');
|
||||||
|
const [attachments, setAttachments] = React.useState(0);
|
||||||
|
const [diagnostics, setDiagnostics] = React.useState(false);
|
||||||
|
const [submitted, setSubmitted] = React.useState(false);
|
||||||
|
const submit = () => {if (!content.trim()) return onToast('请填写反馈内容'); setSubmitted(true);};
|
||||||
|
if (submitted) return <div className="support-page"><Header title="意见反馈" back="personal-center" /><main className="support-success"><Icon>mark_email_read</Icon><h2>反馈已受理</h2><p>感谢你的反馈,我们会尽快查看并用于改进产品。</p><dl><div><dt>反馈编号</dt><dd>FB-20260825-0836</dd></div><div><dt>提交信息</dt><dd>Android 1.2.4</dd></div></dl><button type="button" onClick={() => navigate('personal-center')}>返回我的</button></main></div>;
|
||||||
|
const categories = [{id: 'bug', label: '功能异常', icon: 'bug_report'}, {id: 'suggestion', label: '产品建议', icon: 'lightbulb'}, {id: 'complaint', label: '服务投诉', icon: 'support_agent'}, {id: 'other', label: '其他', icon: 'more_horiz'}];
|
||||||
|
return <div className="support-page feedback-page"><Header title="意见反馈" back="personal-center" /><main><section className="feedback-categories"><h2>反馈类型</h2><div>{categories.map(item => <button type="button" className={category === item.id ? 'active' : ''} key={item.id} onClick={() => setCategory(item.id)}><Icon>{item.icon}</Icon><span>{item.label}</span></button>)}</div></section><label className="support-textarea"><span>问题或建议<b>*</b></span><textarea value={content} maxLength="1000" onChange={event => setContent(event.target.value)} placeholder="请详细描述你遇到的问题或建议" /><small>{content.length}/1000</small></label><section className="feedback-upload"><header><span><strong>截图或附件</strong><small>最多 5 个,单个不超过 20 MB</small></span><em>{attachments}/5</em></header><button type="button" onClick={() => attachments < 5 ? setAttachments(value => value + 1) : onToast('最多上传 5 个附件')}><Icon>add_photo_alternate</Icon><span>{attachments ? `已选择 ${attachments} 个附件` : '添加附件'}</span></button></section><label className="feedback-contact"><span>联系方式</span><input value={contact} maxLength="80" onChange={event => setContact(event.target.value)} placeholder="手机号或邮箱(选填)" /></label><label className="diagnostics-consent"><input type="checkbox" checked={diagnostics} onChange={event => setDiagnostics(event.target.checked)} /><span><strong>同意上传脱敏诊断信息</strong><small>仅包含 Android 版本、应用版本和错误日志,不包含聊天内容、密码或 Token。</small></span></label></main><footer className="support-submit-bar"><button type="button" onClick={submit}>提交反馈</button></footer></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SecuritySupportView({view}) {
|
||||||
|
const [toast, setToast] = React.useState('');
|
||||||
|
let content;
|
||||||
|
if (view === 'fraud-risk-warning' || view === 'fraud-risk-confirmation') content = <RiskWarning secondary={view === 'fraud-risk-confirmation'} onToast={setToast} />;
|
||||||
|
else if (view === 'abuse-report-reasons') content = <ReportReasons />;
|
||||||
|
else if (view === 'abuse-report-details') content = <ReportDetails onToast={setToast} />;
|
||||||
|
else content = <UserFeedback onToast={setToast} />;
|
||||||
|
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
|
||||||
|
}
|
||||||
@@ -5,6 +5,17 @@ import ErrorPanel from '../widgets/error-panel.jsx';
|
|||||||
import LoadSpinner from '../widgets/load-spinner.jsx';
|
import LoadSpinner from '../widgets/load-spinner.jsx';
|
||||||
import SideNavbar from '../widgets/side-navbar.jsx';
|
import SideNavbar from '../widgets/side-navbar.jsx';
|
||||||
import TopicCommonView from './topic-common-view.jsx';
|
import TopicCommonView from './topic-common-view.jsx';
|
||||||
|
import AuthOnboardingView from './auth-onboarding-view.jsx';
|
||||||
|
import MessageCenterView from './message-center-view.jsx';
|
||||||
|
import DirectMessageView from './direct-message-view.jsx';
|
||||||
|
import MessageComposerView, {MESSAGE_COMPOSER_VIEWS} from './message-composer-view.jsx';
|
||||||
|
import ContactManagementView, {CONTACT_MANAGEMENT_VIEWS} from './contact-management-view.jsx';
|
||||||
|
import GroupManagementView, {GROUP_MANAGEMENT_VIEWS} from './group-management-view.jsx';
|
||||||
|
import ContentLibraryView, {CONTENT_LIBRARY_VIEWS} from './content-library-view.jsx';
|
||||||
|
import CallExperienceView, {CALL_EXPERIENCE_VIEWS} from './call-experience-view.jsx';
|
||||||
|
import WorkplaceCollaborationView, {WORKPLACE_COLLABORATION_VIEWS} from './workplace-collaboration-view.jsx';
|
||||||
|
import PersonalSettingsView, {PERSONAL_SETTINGS_VIEWS} from './personal-settings-view.jsx';
|
||||||
|
import SecuritySupportView, {SECURITY_SUPPORT_VIEWS} from './security-support-view.jsx';
|
||||||
|
|
||||||
const AccountSettingsView = React.lazy(_ => import('./account-settings-view.jsx'));
|
const AccountSettingsView = React.lazy(_ => import('./account-settings-view.jsx'));
|
||||||
import ContactsView from './contacts-view.jsx';
|
import ContactsView from './contacts-view.jsx';
|
||||||
@@ -117,7 +128,85 @@ class SidepanelView extends React.PureComponent {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {formatMessage} = this.props.intl;
|
const {formatMessage} = this.props.intl;
|
||||||
const view = this.props.state || (this.props.myUserId ? 'contacts' : 'login');
|
const view = this.props.state || (this.props.myUserId ? 'messages-home' : 'tenant');
|
||||||
|
const chapterOneViews = ['tenant', 'login', 'enterprise-register', 'enterprise-feedback', 'register',
|
||||||
|
'reset', 'identity-check', 'realname', 'realname-verified', 'realname-reminder', 'independent-password'];
|
||||||
|
|
||||||
|
if (chapterOneViews.includes(view)) {
|
||||||
|
return <div id="sidepanel" className="auth-onboarding-panel">
|
||||||
|
<AuthOnboardingView view={view} authenticated={!!this.props.myUserId} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chapterTwoViews = ['messages-home', 'messages-offline', 'messages-menu', 'message-actions', 'system-messages'];
|
||||||
|
if (chapterTwoViews.includes(view)) {
|
||||||
|
const fullPage = !this.props.myUserId || view !== 'messages-home';
|
||||||
|
return <div id="sidepanel" className={'message-center-panel' + (fullPage ? ' full-page' : '')}>
|
||||||
|
<MessageCenterView
|
||||||
|
view={view}
|
||||||
|
connected={this.props.connected}
|
||||||
|
chatList={this.props.chatList}
|
||||||
|
myUserId={this.props.myUserId}
|
||||||
|
onTopicSelected={this.props.onTopicSelected} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chapterThreeViews = ['chat-inbound-request', 'chat-inbound-message', 'chat-outbound-request',
|
||||||
|
'chat-status', 'chat-friend', 'chat-media', 'chat-blocked', 'chat-deleted', 'chat-standard',
|
||||||
|
'chat-attachments', 'chat-mixed', 'chat-contact-card'];
|
||||||
|
if (chapterThreeViews.includes(view)) {
|
||||||
|
return <div id="sidepanel" className="direct-message-panel">
|
||||||
|
<DirectMessageView view={view} key={view} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MESSAGE_COMPOSER_VIEWS.includes(view)) {
|
||||||
|
return <div id="sidepanel" className="message-composer-panel">
|
||||||
|
<MessageComposerView view={view} key={view} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CONTACT_MANAGEMENT_VIEWS.includes(view)) {
|
||||||
|
return <div id="sidepanel" className="contact-management-panel">
|
||||||
|
<ContactManagementView view={view} key={view} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (GROUP_MANAGEMENT_VIEWS.includes(view)) {
|
||||||
|
return <div id="sidepanel" className="group-management-panel">
|
||||||
|
<GroupManagementView view={view} key={view} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CONTENT_LIBRARY_VIEWS.includes(view)) {
|
||||||
|
return <div id="sidepanel" className="content-library-panel">
|
||||||
|
<ContentLibraryView view={view} key={view} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CALL_EXPERIENCE_VIEWS.includes(view)) {
|
||||||
|
return <div id="sidepanel" className="call-experience-panel">
|
||||||
|
<CallExperienceView view={view} key={view} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WORKPLACE_COLLABORATION_VIEWS.includes(view)) {
|
||||||
|
return <div id="sidepanel" className="workplace-collaboration-panel">
|
||||||
|
<WorkplaceCollaborationView view={view} key={view} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PERSONAL_SETTINGS_VIEWS.includes(view)) {
|
||||||
|
return <div id="sidepanel" className="personal-settings-panel">
|
||||||
|
<PersonalSettingsView view={view} key={view} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SECURITY_SUPPORT_VIEWS.includes(view)) {
|
||||||
|
return <div id="sidepanel" className="security-support-panel">
|
||||||
|
<SecuritySupportView view={view} key={view} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
let title, avatar, badges;
|
let title, avatar, badges;
|
||||||
if (view == 'contacts') {
|
if (view == 'contacts') {
|
||||||
@@ -236,12 +325,13 @@ class SidepanelView extends React.PureComponent {
|
|||||||
<AccSecurityView
|
<AccSecurityView
|
||||||
tinode={this.props.tinode}
|
tinode={this.props.tinode}
|
||||||
incognitoMode={this.props.incognitoMode}
|
incognitoMode={this.props.incognitoMode}
|
||||||
|
onNavigate={this.props.onNavigate}
|
||||||
onUpdateAccountDesc={this.props.onUpdateAccountDesc}
|
onUpdateAccountDesc={this.props.onUpdateAccountDesc}
|
||||||
onUpdatePassword={this.props.onUpdatePassword}
|
onUpdatePassword={this.props.onUpdatePassword}
|
||||||
onLogout={this.props.onLogout}
|
onLogout={this.props.onLogout}
|
||||||
onDeleteAccount={this.props.onDeleteAccount}
|
onDeleteAccount={this.props.onDeleteAccount}
|
||||||
onShowAlert={this.props.onShowAlert}
|
onShowAlert={this.props.onShowAlert}
|
||||||
onShowBlocked={this.props.onShowBlocked}
|
onShowBlocked={() => this.props.onNavigate('block-list')}
|
||||||
onToggleIncognitoMode={this.props.onToggleIncognitoMode} />
|
onToggleIncognitoMode={this.props.onToggleIncognitoMode} />
|
||||||
</Suspense> :
|
</Suspense> :
|
||||||
|
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ class TinodeWeb extends React.Component {
|
|||||||
errorAction: undefined,
|
errorAction: undefined,
|
||||||
errorActionText: null,
|
errorActionText: null,
|
||||||
|
|
||||||
sidePanelSelected: 'login',
|
sidePanelSelected: 'tenant',
|
||||||
sidePanelTitle: null,
|
sidePanelTitle: null,
|
||||||
sidePanelAvatar: null,
|
sidePanelAvatar: null,
|
||||||
myTrustedBadges: [],
|
myTrustedBadges: [],
|
||||||
@@ -576,6 +576,44 @@ class TinodeWeb extends React.Component {
|
|||||||
// Left-side panel selector.
|
// Left-side panel selector.
|
||||||
if (['acc_general','archive','blocked','contacts','cred','crop','edit','general',
|
if (['acc_general','archive','blocked','contacts','cred','crop','edit','general',
|
||||||
'newtpk','notif','register','reset','security','settings','support','wallpapers',
|
'newtpk','notif','register','reset','security','settings','support','wallpapers',
|
||||||
|
'tenant','login','enterprise-register','enterprise-feedback','identity-check',
|
||||||
|
'realname','realname-verified','realname-reminder','independent-password',
|
||||||
|
'messages-home','messages-offline','messages-menu','message-actions','system-messages',
|
||||||
|
'chat-inbound-request','chat-inbound-message','chat-outbound-request','chat-status',
|
||||||
|
'chat-friend','chat-media','chat-blocked','chat-deleted','chat-standard',
|
||||||
|
'chat-attachments','chat-mixed','chat-contact-card',
|
||||||
|
'compose-keyboard','mentions-select','mentions-complete','mentions-message',
|
||||||
|
'emoji-panel','emoji-message','voice-ready','voice-hold','voice-recording',
|
||||||
|
'media-uploading','media-gallery','media-retry','upload-file','file-message',
|
||||||
|
'message-multiselect','favorite-success','forward-select','forward-multiple',
|
||||||
|
'forward-individual','forward-bundle',
|
||||||
|
'address-book','friend-requests','add-friend','my-friend-qr','friend-search-result',
|
||||||
|
'friend-verification','friend-profile','group-member-profile','friend-settings',
|
||||||
|
'friend-settings-blocked','friend-delete-confirm','block-list','block-confirm','clear-chat-confirm',
|
||||||
|
'group-list','group-empty','group-invite','group-events','group-join-requests',
|
||||||
|
'group-new-request','group-announcement','group-summary','group-announcement-detail',
|
||||||
|
'group-settings','group-profile','group-avatar','group-nickname','group-description',
|
||||||
|
'group-role-message','group-qr','group-governance','group-admins','group-admin-add',
|
||||||
|
'group-admin-success','group-admin-partial','group-admin-removed','group-mute-add',
|
||||||
|
'group-muted','group-owner-select','group-owner-confirm','group-files','group-file-upload',
|
||||||
|
'favorites','favorites-mixed','favorites-files','favorites-text','favorites-voice',
|
||||||
|
'favorites-links','favorites-select','favorite-share','file-download','file-downloading',
|
||||||
|
'file-downloaded','history-search','history-files','history-links','history-images','history-videos',
|
||||||
|
'call-history','call-incoming','audio-call-ringing','audio-call-active','audio-call-muted',
|
||||||
|
'audio-call-speaker','video-call-ringing','video-call-camera-off','video-call-active',
|
||||||
|
'video-call-peer-camera-off','call-minimized',
|
||||||
|
'workbench','organization-directory','meeting-organizer','meeting-attendee',
|
||||||
|
'daily-report-editor','daily-report-mine','daily-report-team','daily-report-detail',
|
||||||
|
'monthly-report-editor','monthly-report-list','monthly-report-detail',
|
||||||
|
'executive-mailbox','executive-mailbox-notice',
|
||||||
|
'personal-center','personal-profile','profile-nickname','profile-birth-date',
|
||||||
|
'profile-signature','personal-qrcode','android-settings','cache-clear-confirm',
|
||||||
|
'app-version','app-update-available','privacy-settings','friend-discovery-settings',
|
||||||
|
'youth-mode-off','youth-mode-confirm','youth-mode-on','youth-mode-disable',
|
||||||
|
'notification-permission','permission-settings-reminder','camera-permission-info',
|
||||||
|
'microphone-permission-info',
|
||||||
|
'fraud-risk-warning','fraud-risk-confirmation','abuse-report-reasons',
|
||||||
|
'abuse-report-details','user-feedback',
|
||||||
''].includes(hash.path[0])) {
|
''].includes(hash.path[0])) {
|
||||||
newState.sidePanelSelected = hash.path[0];
|
newState.sidePanelSelected = hash.path[0];
|
||||||
} else {
|
} else {
|
||||||
@@ -2120,6 +2158,51 @@ class TinodeWeb extends React.Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const colorSchema = this.state.colorSchema !== 'auto' ? this.state.colorSchema : this.state.systemColorSchema;
|
const colorSchema = this.state.colorSchema !== 'auto' ? this.state.colorSchema : this.state.systemColorSchema;
|
||||||
|
const activeProductView = this.state.sidePanelSelected || (this.state.myUserId ? 'messages-home' : 'tenant');
|
||||||
|
const chapterOneViews = ['tenant', 'login', 'enterprise-register', 'enterprise-feedback', 'register',
|
||||||
|
'reset', 'identity-check', 'realname', 'realname-verified', 'realname-reminder', 'independent-password'];
|
||||||
|
const chapterOneActive = chapterOneViews.includes(activeProductView);
|
||||||
|
const chapterTwoFullPage = ['messages-offline', 'messages-menu', 'message-actions', 'system-messages'].includes(activeProductView) ||
|
||||||
|
(!this.state.myUserId && activeProductView === 'messages-home');
|
||||||
|
const chapterThreeActive = ['chat-inbound-request', 'chat-inbound-message', 'chat-outbound-request',
|
||||||
|
'chat-status', 'chat-friend', 'chat-media', 'chat-blocked', 'chat-deleted', 'chat-standard',
|
||||||
|
'chat-attachments', 'chat-mixed', 'chat-contact-card'].includes(activeProductView);
|
||||||
|
const chapterFourActive = ['compose-keyboard', 'mentions-select', 'mentions-complete', 'mentions-message',
|
||||||
|
'emoji-panel', 'emoji-message', 'voice-ready', 'voice-hold', 'voice-recording',
|
||||||
|
'media-uploading', 'media-gallery', 'media-retry', 'upload-file', 'file-message',
|
||||||
|
'message-multiselect', 'favorite-success', 'forward-select', 'forward-multiple',
|
||||||
|
'forward-individual', 'forward-bundle'].includes(activeProductView);
|
||||||
|
const chapterFiveActive = ['address-book', 'friend-requests', 'add-friend', 'my-friend-qr',
|
||||||
|
'friend-search-result', 'friend-verification', 'friend-profile', 'group-member-profile',
|
||||||
|
'friend-settings', 'friend-settings-blocked', 'friend-delete-confirm', 'block-list',
|
||||||
|
'block-confirm', 'clear-chat-confirm'].includes(activeProductView);
|
||||||
|
const chapterSixActive = ['group-list', 'group-empty', 'group-invite', 'group-events',
|
||||||
|
'group-join-requests', 'group-new-request', 'group-announcement', 'group-summary',
|
||||||
|
'group-announcement-detail', 'group-settings', 'group-profile', 'group-avatar',
|
||||||
|
'group-nickname', 'group-description', 'group-role-message', 'group-qr', 'group-governance',
|
||||||
|
'group-admins', 'group-admin-add', 'group-admin-success', 'group-admin-partial',
|
||||||
|
'group-admin-removed', 'group-mute-add', 'group-muted', 'group-owner-select',
|
||||||
|
'group-owner-confirm', 'group-files', 'group-file-upload'].includes(activeProductView);
|
||||||
|
const chapterSevenActive = ['favorites', 'favorites-mixed', 'favorites-files', 'favorites-text',
|
||||||
|
'favorites-voice', 'favorites-links', 'favorites-select', 'favorite-share', 'file-download',
|
||||||
|
'file-downloading', 'file-downloaded', 'history-search', 'history-files', 'history-links',
|
||||||
|
'history-images', 'history-videos'].includes(activeProductView);
|
||||||
|
const callExperienceActive = ['call-history', 'call-incoming', 'audio-call-ringing',
|
||||||
|
'audio-call-active', 'audio-call-muted', 'audio-call-speaker', 'video-call-ringing',
|
||||||
|
'video-call-camera-off', 'video-call-active', 'video-call-peer-camera-off',
|
||||||
|
'call-minimized'].includes(activeProductView);
|
||||||
|
const workplaceCollaborationActive = ['workbench', 'organization-directory', 'meeting-organizer',
|
||||||
|
'meeting-attendee', 'daily-report-editor', 'daily-report-mine', 'daily-report-team',
|
||||||
|
'daily-report-detail', 'monthly-report-editor', 'monthly-report-list',
|
||||||
|
'monthly-report-detail', 'executive-mailbox', 'executive-mailbox-notice'].includes(activeProductView);
|
||||||
|
const personalSettingsActive = ['personal-center', 'personal-profile', 'profile-nickname',
|
||||||
|
'profile-birth-date', 'profile-signature', 'personal-qrcode', 'android-settings',
|
||||||
|
'cache-clear-confirm', 'app-version', 'app-update-available', 'privacy-settings',
|
||||||
|
'friend-discovery-settings', 'youth-mode-off', 'youth-mode-confirm', 'youth-mode-on',
|
||||||
|
'youth-mode-disable', 'notification-permission', 'permission-settings-reminder',
|
||||||
|
'camera-permission-info', 'microphone-permission-info'].includes(activeProductView);
|
||||||
|
const securitySupportActive = ['fraud-risk-warning', 'fraud-risk-confirmation',
|
||||||
|
'abuse-report-reasons', 'abuse-report-details', 'user-feedback'].includes(activeProductView);
|
||||||
return (
|
return (
|
||||||
<div id="app-container" ref={this.selfRef}>
|
<div id="app-container" ref={this.selfRef}>
|
||||||
{this.state.contextMenuVisible ?
|
{this.state.contextMenuVisible ?
|
||||||
@@ -2268,7 +2351,7 @@ class TinodeWeb extends React.Component {
|
|||||||
showContextMenu={this.handleShowContextMenu} />
|
showContextMenu={this.handleShowContextMenu} />
|
||||||
: null}
|
: null}
|
||||||
|
|
||||||
{!this.state.displayMobile || (this.state.mobilePanel == 'topic-view' && !this.state.infoPanel) ?
|
{!chapterOneActive && !chapterTwoFullPage && !chapterThreeActive && !chapterFourActive && !chapterFiveActive && !chapterSixActive && !chapterSevenActive && !callExperienceActive && !workplaceCollaborationActive && !personalSettingsActive && !securitySupportActive && (!this.state.displayMobile || (this.state.mobilePanel == 'topic-view' && !this.state.infoPanel)) ?
|
||||||
<Suspense fallback={<div><FormattedMessage id="loading_note" defaultMessage="Loading..."
|
<Suspense fallback={<div><FormattedMessage id="loading_note" defaultMessage="Loading..."
|
||||||
description="Message shown when component is loading"/></div>}>
|
description="Message shown when component is loading"/></div>}>
|
||||||
<MessagesView
|
<MessagesView
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import HashNavigation from '../lib/navigation.js';
|
||||||
|
|
||||||
|
const navigate = view => HashNavigation.navigateTo(view);
|
||||||
|
|
||||||
|
export const WORKPLACE_COLLABORATION_VIEWS = [
|
||||||
|
'workbench', 'organization-directory', 'meeting-organizer', 'meeting-attendee',
|
||||||
|
'daily-report-editor', 'daily-report-mine', 'daily-report-team', 'daily-report-detail',
|
||||||
|
'monthly-report-editor', 'monthly-report-list', 'monthly-report-detail',
|
||||||
|
'executive-mailbox', 'executive-mailbox-notice'
|
||||||
|
];
|
||||||
|
|
||||||
|
const APPS = [
|
||||||
|
{code: 'monthly', name: '月报', icon: 'event_note', tone: 'violet', route: 'monthly-report-list'},
|
||||||
|
{code: 'daily', name: '日报', icon: 'task_alt', tone: 'orange', route: 'daily-report-mine', badge: 1},
|
||||||
|
{code: 'organization', name: '组织架构', icon: 'account_tree', tone: 'red', route: 'organization-directory'},
|
||||||
|
{code: 'meeting', name: '会议通知', icon: 'notifications_active', tone: 'blue', route: 'meeting-attendee', badge: 2},
|
||||||
|
{code: 'mailbox', name: '总经理信箱', icon: 'mail_outline', tone: 'green', route: 'executive-mailbox'}
|
||||||
|
];
|
||||||
|
|
||||||
|
const PEOPLE = [
|
||||||
|
{name: '李家家', title: '财务专员', avatar: 'img/bkg/p03.jpg'},
|
||||||
|
{name: '何耐耐', title: '前端工程师', avatar: 'img/bkg/p05.jpg'},
|
||||||
|
{name: '符小小', title: '产品经理', avatar: 'img/bkg/p09.jpg'},
|
||||||
|
{name: '王子明', title: '测试工程师', avatar: 'img/bkg/p14.jpg'}
|
||||||
|
];
|
||||||
|
|
||||||
|
const DAILY_REPORTS = [
|
||||||
|
{name: '清爽的白鸽', date: '2026-08-25', status: '已提交', content: '完成工作台与组织架构页面开发,梳理会议通知交互方案;修复两个样式问题并完善产品文档。'},
|
||||||
|
{name: '轻松的小虾', date: '2026-08-25', status: '已提交', content: '梳理用户反馈,输出需求优先级清单;与设计沟通新功能交互方案。'},
|
||||||
|
{name: '轻松的雨雨', date: '2026-08-25', status: '已提交', content: '完成接口文档整理,联调日报列表与统计页面。'}
|
||||||
|
];
|
||||||
|
|
||||||
|
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
|
||||||
|
|
||||||
|
function Header({title, back = 'workbench', action, actionLabel, onAction}) {
|
||||||
|
return <header className="workplace-header"><button type="button" onClick={() => navigate(back)} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1>{action ? <button type="button" onClick={onAction} aria-label={actionLabel || '操作'}><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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Workbench() {
|
||||||
|
return <div className="workplace-page workbench-page"><main>
|
||||||
|
<section className="workbench-profile"><img src="img/bkg/p05.jpg" alt="" /><span><strong>陈晨</strong><small>产品研发部 · 员工</small></span><button type="button" aria-label="工作台设置"><Icon>tune</Icon></button></section>
|
||||||
|
<section className="workbench-banner"><div><strong>高效协作,从工作台开始</strong><small>今天有 3 项待处理事项</small></div><Icon>business_center</Icon></section>
|
||||||
|
<section className="workbench-apps" aria-label="办公应用">{APPS.map(app => <button type="button" key={app.code} onClick={() => navigate(app.route)}><span className={app.tone}><Icon>{app.icon}</Icon>{app.badge ? <em>{app.badge}</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={() => navigate('messages-home')}><Icon>chat_bubble_outline</Icon><span>消息</span></button></nav></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrganizationDirectory() {
|
||||||
|
const [query, setQuery] = React.useState('');
|
||||||
|
const [expanded, setExpanded] = React.useState(true);
|
||||||
|
const visible = PEOPLE.filter(person => `${person.name}${person.title}`.includes(query));
|
||||||
|
return <div className="workplace-page"><Header title="组织架构" /><main className="organization-content"><label className="workplace-search"><Icon>search</Icon><input value={query} onChange={event => setQuery(event.target.value)} placeholder="搜索成员或部门" /></label><section className="organization-tree"><button type="button" className="organization-root" onClick={() => setExpanded(!expanded)}><Icon>domain</Icon><span><strong>星海科技有限公司</strong><small>3 个部门 · 50 人</small></span><Icon>{expanded ? 'expand_more' : 'chevron_right'}</Icon></button>{expanded ? <><button type="button" className="organization-department"><Icon>folder_open</Icon><span><strong>产品研发部</strong><small>18 人</small></span><Icon>expand_more</Icon></button><div className="organization-members">{visible.map(person => <button type="button" key={person.name} onClick={() => navigate('group-member-profile')}><img src={person.avatar} alt="" /><span><strong>{person.name}</strong><small>{person.title}</small></span><Icon>chevron_right</Icon></button>)}</div><button type="button" className="organization-department"><Icon>folder</Icon><span><strong>财务部</strong><small>6 人</small></span><Icon>chevron_right</Icon></button><button type="button" className="organization-department"><Icon>folder</Icon><span><strong>市场销售部</strong><small>26 人</small></span><Icon>chevron_right</Icon></button></> : null}</section></main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MeetingCard({organizer, response, onResponse, onToast}) {
|
||||||
|
return <article className="meeting-card"><header><span><small>今天 14:30</small><strong>产品版本评审会</strong></span><em className={response || 'pending'}>{response === 'accepted' ? '已接受' : response === 'declined' ? '已拒绝' : response === 'tentative' ? '待定' : '待确认'}</em></header><dl><div><dt><Icon>person_outline</Icon>发起人</dt><dd>李女士</dd></div><div><dt><Icon>schedule</Icon>时间</dt><dd>8月25日 14:30 - 15:30</dd></div><div><dt><Icon>location_on</Icon>地点</dt><dd>5F 海棠会议室</dd></div><div><dt><Icon>groups</Icon>参会人</dt><dd>李明、张婷、陈宇等 8 人</dd></div></dl>{organizer ? <footer><button type="button" onClick={() => onToast('已提醒 3 位未响应成员')}><Icon>notifications</Icon>提醒未响应</button><button type="button" onClick={() => onToast('会议管理面板已打开')}>管理会议</button></footer> : response ? <footer><button type="button" onClick={() => onResponse('')}>修改回应</button></footer> : <footer className="meeting-response"><button type="button" onClick={() => onResponse('declined')}>无法参加</button><button type="button" onClick={() => onResponse('tentative')}>待定</button><button type="button" className="primary" onClick={() => onResponse('accepted')}>接受</button></footer>}</article>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Meetings({organizer, onToast}) {
|
||||||
|
const [response, setResponse] = React.useState('');
|
||||||
|
const [created, setCreated] = React.useState(false);
|
||||||
|
const respond = value => {setResponse(value); if (value) onToast(value === 'accepted' ? '已接受会议邀请' : value === 'declined' ? '已拒绝会议邀请' : '已标记为待定');};
|
||||||
|
const createMeeting = () => {
|
||||||
|
if (!organizer) return navigate('meeting-organizer');
|
||||||
|
setCreated(true);
|
||||||
|
onToast('已新建会议草稿');
|
||||||
|
};
|
||||||
|
return <div className="workplace-page"><Header title="会议通知" action="add" actionLabel="发起会议" onAction={createMeeting} /><main className="meeting-list"><nav className="workplace-tabs"><button type="button" className="active">待处理</button><button type="button">已响应</button><button type="button">历史</button></nav>{created ? <article className="meeting-draft"><Icon>edit_calendar</Icon><span><strong>新会议草稿</strong><small>时间、地点和参会人待完善</small></span><button type="button" onClick={() => setCreated(false)}>继续编辑</button></article> : null}<MeetingCard organizer={organizer} response={response} onResponse={respond} onToast={onToast} />{organizer ? <section className="meeting-status"><h2>参会确认</h2><div><span><b>5</b><small>已接受</small></span><span><b>3</b><small>待响应</small></span><span><b>0</b><small>已拒绝</small></span></div></section> : null}</main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportEditor({monthly, onToast}) {
|
||||||
|
const storageKey = monthly ? 'monthly-report-draft' : 'daily-report-draft';
|
||||||
|
const initial = (() => {try { return JSON.parse(window.localStorage.getItem(storageKey)) || {}; } catch (_) { return {}; }})();
|
||||||
|
const [summary, setSummary] = React.useState(initial.summary || '');
|
||||||
|
const [plan, setPlan] = React.useState(initial.plan || '');
|
||||||
|
const [saved, setSaved] = React.useState(false);
|
||||||
|
React.useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => {window.localStorage.setItem(storageKey, JSON.stringify({summary, plan})); setSaved(true);}, 600);
|
||||||
|
setSaved(false);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [summary, plan, storageKey]);
|
||||||
|
const submit = () => {
|
||||||
|
if (!summary.trim() || (monthly && !plan.trim())) return onToast('请完成必填内容');
|
||||||
|
window.localStorage.removeItem(storageKey);
|
||||||
|
onToast(monthly ? '月报已提交' : '日报已提交');
|
||||||
|
window.setTimeout(() => navigate(monthly ? 'monthly-report-detail' : 'daily-report-detail'), 650);
|
||||||
|
};
|
||||||
|
return <div className="workplace-page report-editor-page"><Header title={monthly ? '填写月报' : '填写日报'} back={monthly ? 'monthly-report-list' : 'daily-report-mine'} /><main><section className="report-period"><Icon>{monthly ? 'calendar_month' : 'today'}</Icon><span><strong>{monthly ? '2026 年 8 月' : '2026 年 8 月 25 日'}</strong><small>{saved ? '草稿已自动保存' : '正在保存草稿…'}</small></span></section><label className="report-field"><span><b>*</b>{monthly ? '本月工作总结' : '今日工作总结'}</span><textarea value={summary} maxLength="1000" onChange={event => setSummary(event.target.value)} placeholder="请输入工作内容、进展与结果" /><small>{summary.length}/1000</small></label>{monthly ? <label className="report-field"><span><b>*</b>下月工作计划</span><textarea value={plan} maxLength="1000" onChange={event => setPlan(event.target.value)} placeholder="请输入目标、计划与需要的支持" /><small>{plan.length}/1000</small></label> : <button type="button" className="report-attachment" onClick={() => onToast('已选择附件:项目进度表.xlsx')}><Icon>attach_file</Icon><span><strong>添加附件</strong><small>支持图片、文档,最多 9 个</small></span><Icon>chevron_right</Icon></button>}</main><footer className="workplace-submit-bar"><button type="button" onClick={submit}>提交</button></footer></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportList({monthly, team, onToast}) {
|
||||||
|
const [query, setQuery] = React.useState('');
|
||||||
|
const reports = DAILY_REPORTS.filter(item => `${item.name}${item.content}${item.date}`.includes(query));
|
||||||
|
return <div className="workplace-page report-list-page"><Header title={monthly ? '月报' : '日报'} action="add" actionLabel={monthly ? '填写月报' : '填写日报'} onAction={() => navigate(monthly ? 'monthly-report-editor' : 'daily-report-editor')} /><main>{team ? <section className="report-statistics"><header><span><small>2026年8月25日</small><strong>产品研发部</strong></span><button type="button" onClick={() => navigate('daily-report-mine')}>我的日报</button></header><div><span><b>18</b><small>应提交</small></span><span><b>14</b><small>已提交</small></span><span><b>4</b><small>未提交</small></span></div><button type="button" onClick={() => onToast('已提醒 4 位未提交成员')}><Icon>notifications_active</Icon>提醒未提交成员</button></section> : null}<label className="workplace-search"><Icon>search</Icon><input value={query} onChange={event => setQuery(event.target.value)} placeholder={monthly ? '搜索月份或内容' : '搜索姓名、部门或日期'} /></label>{!monthly && !team ? <nav className="report-role-switch"><button type="button" className="active">我的日报</button><button type="button" onClick={() => navigate('daily-report-team')}>团队统计</button></nav> : null}<section className="report-cards">{monthly ? ['2026 年 8 月', '2026 年 7 月', '2026 年 6 月'].map((month, index) => <button type="button" key={month} onClick={() => navigate(index ? 'monthly-report-detail' : 'monthly-report-editor')}><span className="report-month"><Icon>calendar_month</Icon></span><span><strong>{month}</strong><small>{index ? '工作总结与下月计划' : '截止 8月31日 18:00'}</small></span><em className={index ? 'submitted' : 'draft'}>{index ? '已提交' : '待填写'}</em><Icon>chevron_right</Icon></button>) : reports.map(report => <article key={report.name}><header><img src={PEOPLE[reports.indexOf(report) % PEOPLE.length].avatar} alt="" /><span><strong>{report.name}</strong><small>{team ? '产品研发部 · 员工' : report.date}</small></span><em>{report.status}</em></header><p>{report.content}</p><footer><span><Icon>schedule</Icon>{report.date}</span><button type="button" onClick={() => navigate('daily-report-detail')}>查看详情<Icon>chevron_right</Icon></button></footer></article>)}</section></main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportDetail({monthly}) {
|
||||||
|
return <div className="workplace-page"><Header title={monthly ? '月报详情' : '日报详情'} back={monthly ? 'monthly-report-list' : 'daily-report-mine'} /><main className="report-detail"><section className="report-author"><img src="img/bkg/p05.jpg" alt="" /><span><strong>陈晨</strong><small>产品研发部 · 产品经理</small></span><em>已提交</em></section><section><h2>{monthly ? '2026 年 7 月月报' : '2026 年 8 月 25 日日报'}</h2><small>提交于 18:06 · 当前为最新版本</small></section><section><h3>{monthly ? '本月工作总结' : '今日工作总结'}</h3><p>完成企业工作台页面和办公协同模块的交互开发;梳理组织架构、会议通知与报告流程,补充 Android 端适配并完成回归验证。</p></section>{monthly ? <section><h3>下月工作计划</h3><p>推进后端接口联调,完善冲突处理、权限状态及数据加载反馈,持续跟进测试结果。</p></section> : <section className="report-file"><Icon>description</Icon><span><strong>项目进度表.xlsx</strong><small>128 KB</small></span><Icon>download</Icon></section>}<p className="report-updated"><Icon>history</Icon>最后更新于 2026-08-25 18:06</p></main></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExecutiveMailbox({noticeOnly, onToast}) {
|
||||||
|
const [noticeOpen, setNoticeOpen] = React.useState(noticeOnly);
|
||||||
|
const [subject, setSubject] = React.useState('');
|
||||||
|
const [content, setContent] = React.useState('');
|
||||||
|
const [anonymous, setAnonymous] = React.useState(false);
|
||||||
|
const submit = () => {
|
||||||
|
if (!subject.trim() || !content.trim()) return onToast('请填写主题和反馈内容');
|
||||||
|
setSubject(''); setContent(''); onToast('提交成功');
|
||||||
|
};
|
||||||
|
return <div className="workplace-page mailbox-page"><Header title="总经理信箱" action="info_outline" actionLabel="查看使用说明" onAction={() => setNoticeOpen(true)} /><main><button type="button" className="mailbox-notice" onClick={() => setNoticeOpen(true)}><Icon>verified_user</Icon><span><strong>提交前请阅读隐私说明</strong><small>了解信息接收方与匿名处理规则</small></span><Icon>chevron_right</Icon></button><label><span>反馈主题</span><input value={subject} maxLength="80" onChange={event => setSubject(event.target.value)} placeholder="请简要概括问题或建议" /></label><label className="mailbox-content"><span>反馈内容</span><textarea value={content} maxLength="1000" onChange={event => setContent(event.target.value)} placeholder="请详细描述事情经过与诉求" /><small>{content.length}/1000</small></label><button type="button" className="mailbox-upload" onClick={() => onToast('已选择附件')}><Icon>upload_file</Icon><span>上传附件</span><small>图片或文档,最多 5 个</small></button><label className="mailbox-anonymous"><input type="checkbox" checked={anonymous} onChange={event => setAnonymous(event.target.checked)} /><span><strong>匿名提交</strong><small>接收人员将无法看到你的员工身份</small></span></label><button type="button" className="workplace-primary" onClick={submit}>提交反馈</button></main>{noticeOpen ? <div className="mailbox-notice-backdrop"><section role="dialog" aria-modal="true" className="mailbox-notice-dialog"><Icon>policy</Icon><h2>信箱使用与隐私说明</h2><p>本信箱用于收集员工对经营管理、工作环境和制度流程的意见。提交内容由公司授权管理人员处理,请勿填写与事项无关的敏感信息。</p><p>匿名提交时,业务接收人员不会看到你的身份。具体处理和审计范围以企业当前政策为准。</p><small>政策版本:2026.08 · 更新于 2026-08-20</small><button type="button" onClick={() => {setNoticeOpen(false); if (noticeOnly) navigate('executive-mailbox');}}>我已了解</button></section></div> : null}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkplaceCollaborationView({view}) {
|
||||||
|
const [toast, setToast] = React.useState('');
|
||||||
|
let content;
|
||||||
|
if (view === 'workbench') content = <Workbench />;
|
||||||
|
else if (view === 'organization-directory') content = <OrganizationDirectory />;
|
||||||
|
else if (view === 'meeting-organizer' || view === 'meeting-attendee') content = <Meetings organizer={view === 'meeting-organizer'} onToast={setToast} />;
|
||||||
|
else if (view === 'daily-report-editor' || view === 'monthly-report-editor') content = <ReportEditor monthly={view === 'monthly-report-editor'} onToast={setToast} />;
|
||||||
|
else if (view === 'daily-report-mine' || view === 'daily-report-team' || view === 'monthly-report-list') content = <ReportList monthly={view === 'monthly-report-list'} team={view === 'daily-report-team'} onToast={setToast} />;
|
||||||
|
else if (view === 'daily-report-detail' || view === 'monthly-report-detail') content = <ReportDetail monthly={view === 'monthly-report-detail'} />;
|
||||||
|
else content = <ExecutiveMailbox noticeOnly={view === 'executive-mailbox-notice'} onToast={setToast} />;
|
||||||
|
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
|
||||||
|
}
|
||||||
+9193
-48
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -154,6 +154,50 @@ class AccSecurityView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Comp
|
|||||||
}, void 0, false)
|
}, void 0, false)
|
||||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("div", {
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("div", {
|
||||||
className: "hr"
|
className: "hr"
|
||||||
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("div", {
|
||||||
|
className: "panel-form-column security-product-links",
|
||||||
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("button", {
|
||||||
|
type: "button",
|
||||||
|
className: "flat-button",
|
||||||
|
onClick: () => this.props.onNavigate('realname'),
|
||||||
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("i", {
|
||||||
|
className: "material-icons",
|
||||||
|
children: "verified_user"
|
||||||
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("span", {
|
||||||
|
children: "实名认证"
|
||||||
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("i", {
|
||||||
|
className: "material-icons trailing",
|
||||||
|
children: "chevron_right"
|
||||||
|
}, void 0, false)]
|
||||||
|
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("button", {
|
||||||
|
type: "button",
|
||||||
|
className: "flat-button",
|
||||||
|
onClick: () => this.props.onNavigate('identity-check'),
|
||||||
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("i", {
|
||||||
|
className: "material-icons",
|
||||||
|
children: "phonelink_lock"
|
||||||
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("span", {
|
||||||
|
children: "身份验证"
|
||||||
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("i", {
|
||||||
|
className: "material-icons trailing",
|
||||||
|
children: "chevron_right"
|
||||||
|
}, void 0, false)]
|
||||||
|
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("button", {
|
||||||
|
type: "button",
|
||||||
|
className: "flat-button",
|
||||||
|
onClick: () => this.props.onNavigate('independent-password'),
|
||||||
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("i", {
|
||||||
|
className: "material-icons",
|
||||||
|
children: "password"
|
||||||
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("span", {
|
||||||
|
children: "设置独立密码"
|
||||||
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("i", {
|
||||||
|
className: "material-icons trailing",
|
||||||
|
children: "chevron_right"
|
||||||
|
}, void 0, false)]
|
||||||
|
}, void 0, true)]
|
||||||
|
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("div", {
|
||||||
|
className: "hr"
|
||||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("div", {
|
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("div", {
|
||||||
className: "panel-form-column",
|
className: "panel-form-column",
|
||||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("a", {
|
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_5__.jsxDEV)("a", {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user