feat: add HTTP signature tools and fix independent HTTP send validation
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-15 15:58:29 +08:00
parent 18ecf8045f
commit c781313de5
23 changed files with 986 additions and 255 deletions
@@ -24,7 +24,6 @@ export function ChannelReportStatusModal({
onSaved: () => void;
}) {
const targets = item.reportTargets ?? [];
const carriers = ['mobile', 'unicom', 'telecom'] as const;
const [statuses, setStatuses] = useState<Record<string, string>>(() =>
Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status])),
);
@@ -80,45 +79,11 @@ export function ChannelReportStatusModal({
<span></span>
</div>
{error ? <p className="form-error">{error}</p> : null}
{targets.length ? (
<div className="signature-report-status__carriers">
{carriers.map((carrier) => {
const carrierTargets = targets.filter((target) => target.carrier === carrier);
return (
<section className="signature-report-status__carrier" key={carrier}>
<header>
<CarrierTag carrier={carrier} />
<span>{carrierTargets.length} </span>
</header>
<div className="signature-report-status__list">
{carrierTargets.length ? (
carrierTargets.map((target) => {
const key = `${target.channelId}:${target.carrier}`;
return (
<div className="signature-report-status__row" key={key}>
<strong title={target.channel.name}>{target.channel.name}</strong>
<Select
aria-label={`${target.channel.name}${carrierLabel(target.carrier)}报备状态`}
onChange={(event) =>
setStatuses((current) => ({ ...current, [key]: event.target.value }))
}
options={reportStatusOptions}
value={statuses[key] ?? target.status}
/>
</div>
);
})
) : (
<div className="signature-report-status__empty">{carrierLabel(carrier)}</div>
)}
</div>
</section>
);
})}
</div>
) : (
<div className="empty-state"></div>
)}
<CarrierReportColumns
targets={targets}
statuses={statuses}
onChange={(key, status) => setStatuses((current) => ({ ...current, [key]: status }))}
/>
<Textarea
label="修改原因"
onChange={(event) => setReason(event.target.value)}
@@ -143,7 +108,6 @@ export function DrainageReportStatusModal({
onSaved: () => void;
}) {
const targets = signature.drainageReportTargets?.[item.id] ?? [];
const carriers = ['mobile', 'unicom', 'telecom'] as const;
const [statuses, setStatuses] = useState<Record<string, string>>(() =>
Object.fromEntries(targets.map((target) => [`${target.channelId}:${target.carrier}`, target.status])),
);
@@ -201,48 +165,11 @@ export function DrainageReportStatusModal({
<span></span>
</div>
{error ? <p className="form-error">{error}</p> : null}
{targets.length ? (
<div className="signature-report-status__carriers">
{carriers.map((carrier) => {
const carrierTargets = targets.filter((target) => target.carrier === carrier);
return (
<section className="signature-report-status__carrier" key={carrier}>
<header>
<CarrierTag carrier={carrier} />
<span>{carrierTargets.length} </span>
</header>
<div className="signature-report-status__list">
{carrierTargets.length ? (
carrierTargets.map((target) => {
const key = `${target.channelId}:${target.carrier}`;
return (
<div className="signature-report-status__row" key={key}>
<strong title={target.channel.name}>
{target.channel.name}
{target.approvalScope === 'legacy_channel' ? '(继承历史通道状态)' : ''}
</strong>
<Select
aria-label={`${target.channel.name}${carrierLabel(target.carrier)}报备状态`}
onChange={(event) =>
setStatuses((current) => ({ ...current, [key]: event.target.value }))
}
options={reportStatusOptions}
value={statuses[key] ?? target.status}
/>
</div>
);
})
) : (
<div className="signature-report-status__empty">{carrierLabel(carrier)}</div>
)}
</div>
</section>
);
})}
</div>
) : (
<div className="empty-state"></div>
)}
<CarrierReportColumns
targets={targets}
statuses={statuses}
onChange={(key, status) => setStatuses((current) => ({ ...current, [key]: status }))}
/>
<Textarea
label="修改原因"
onChange={(event) => setReason(event.target.value)}
@@ -284,3 +211,65 @@ export function ConfirmModal({
</Modal>
);
}
function CarrierReportColumns({
targets,
statuses,
onChange,
}: {
targets: Array<{
channelId: string;
carrier: 'mobile' | 'unicom' | 'telecom';
status: string;
channel: { name: string };
approvalScope?: string;
}>;
statuses: Record<string, string>;
onChange: (key: string, status: string) => void;
}) {
const carriers = ['mobile', 'unicom', 'telecom'] as const;
return (
<>
{targets.length ? (
<div className="signature-report-status__carriers">
{carriers.map((carrier) => {
const carrierTargets = targets.filter((target) => target.carrier === carrier);
return (
<section className="signature-report-status__carrier" key={carrier}>
<header>
<CarrierTag carrier={carrier} />
<span>{carrierTargets.length} </span>
</header>
<div className="signature-report-status__list">
{carrierTargets.length ? (
carrierTargets.map((target) => {
const key = `${target.channelId}:${target.carrier}`;
return (
<div className="signature-report-status__row" key={key}>
<strong title={target.channel.name}>
{target.channel.name}
{target.approvalScope === 'legacy_channel' ? '(继承历史通道状态)' : ''}
</strong>
<Select
aria-label={`${target.channel.name}${carrierLabel(target.carrier)}报备状态`}
onChange={(event) => onChange(key, event.target.value)}
options={reportStatusOptions}
value={statuses[key] ?? target.status}
/>
</div>
);
})
) : (
<div className="signature-report-status__empty">{carrierLabel(carrier)}</div>
)}
</div>
</section>
);
})}
</div>
) : (
<div className="empty-state"></div>
)}
</>
);
}
+266 -96
View File
@@ -1,13 +1,5 @@
import { useEffect, useState } from 'react';
import {
AlertCircle,
Check,
ChevronRight,
Landmark,
ShieldCheck,
Upload,
UserCheck,
} from 'lucide-react';
import { AlertCircle, Check, ChevronRight, Landmark, ShieldCheck, Upload, UserCheck } from 'lucide-react';
import { Button, FileActions, Input, Select, Textarea } from '@/components/ui';
import { clientApi, type EnterpriseCertification, type FileObject, type FileRef } from '@/api/adminApi';
import { displayFileName } from '@/utils/fileName';
@@ -52,7 +44,15 @@ const emptyCertificationForm: CertificationForm = {
legalPersonIdCard: '',
};
function UploadPanel({ file, uploading, onFile }: { file: FileObject | null; uploading: boolean; onFile: (file: File | undefined) => void }) {
function UploadPanel({
file,
uploading,
onFile,
}: {
file: FileObject | null;
uploading: boolean;
onFile: (file: File | undefined) => void;
}) {
const fileRef: FileRef | null = file
? { contentType: file.contentType, fileName: file.fileName, fileObjectId: file.id }
: null;
@@ -85,7 +85,11 @@ function EnterpriseStepper({ current }: { current: number }) {
return (
<div className="enterprise-stepper__item" key={label}>
<span className={['enterprise-stepper__dot', complete ? 'is-complete' : '', active ? 'is-active' : ''].filter(Boolean).join(' ')}>
<span
className={['enterprise-stepper__dot', complete ? 'is-complete' : '', active ? 'is-active' : '']
.filter(Boolean)
.join(' ')}
>
{complete ? <Check size={20} /> : step}
</span>
<strong className={active || complete ? 'is-active' : ''}>{label}</strong>
@@ -108,7 +112,9 @@ function AuthHeader({ status }: { status: CertificationStatus }) {
return (
<div className="system-page-toolbar">
<div className="sms-send-title">
<span className="sms-send-title__icon"><ShieldCheck size={22} /></span>
<span className="sms-send-title__icon">
<ShieldCheck size={22} />
</span>
<h1></h1>
</div>
<span className={`enterprise-review-status enterprise-review-status--${status}`}>{statusText[status]}</span>
@@ -140,15 +146,27 @@ export function ClientEnterpriseAuthPage() {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const currentStep = step === 'profile' ? 1 : step === 'method' ? 2 : step === 'recharge' || step === 'face' || step === 'faceScan' ? 3 : step === 'pending' || step === 'success' || step === 'failed' ? 4 : 1;
const currentStep =
step === 'profile'
? 1
: step === 'method'
? 2
: step === 'recharge' || step === 'face' || step === 'faceScan'
? 3
: step === 'pending' || step === 'success' || step === 'failed'
? 4
: 1;
const certificationMaterials = latestCertification?.materials ?? {};
const displayCompany = latestCertification?.companyName || form.companyName || companyInfo.name;
const displayLicenseNo = latestCertification?.licenseNo || form.licenseNo || companyInfo.code;
const displayAddress = String(certificationMaterials.address ?? (form.address || companyInfo.address));
const displayLegalPerson = String(certificationMaterials.legalPerson ?? (form.legalPerson || companyInfo.legalPerson));
const displayLegalPerson = String(
certificationMaterials.legalPerson ?? (form.legalPerson || companyInfo.legalPerson),
);
function loadCertification() {
clientApi.listEnterpriseCertifications()
clientApi
.listEnterpriseCertifications()
.then((items) => {
const latest = items[0] ?? null;
setLatestCertification(latest);
@@ -185,7 +203,8 @@ export function ClientEnterpriseAuthPage() {
function uploadLicense(file: File | undefined) {
if (!file) return;
setUploading(true);
clientApi.uploadFileObject(file, { purpose: 'enterprise_certification', prefix: 'enterprise-certifications/license' })
clientApi
.uploadFileObject(file, { purpose: 'enterprise_certification', prefix: 'enterprise-certifications/license' })
.then((fileObject) => {
setLicenseFile(fileObject);
setError('');
@@ -201,25 +220,26 @@ export function ClientEnterpriseAuthPage() {
return;
}
setSubmitting(true);
clientApi.submitEnterpriseCertification({
companyName: form.companyName.trim(),
licenseNo: form.licenseNo.trim(),
contactName: form.contactName.trim(),
contactPhone: form.contactPhone.trim(),
materials: {
licenseFileObjectId: licenseFile?.id,
licenseFileName: licenseFile?.fileName,
licenseFileContentType: licenseFile?.contentType,
province: form.province,
city: form.city,
address: form.address,
contactIdCard: form.contactIdCard,
contactEmail: form.contactEmail,
legalPerson: form.legalPerson,
legalPersonIdCard: form.legalPersonIdCard,
authMethod: method,
},
})
clientApi
.submitEnterpriseCertification({
companyName: form.companyName.trim(),
licenseNo: form.licenseNo.trim(),
contactName: form.contactName.trim(),
contactPhone: form.contactPhone.trim(),
materials: {
licenseFileObjectId: licenseFile?.id,
licenseFileName: licenseFile?.fileName,
licenseFileContentType: licenseFile?.contentType,
province: form.province,
city: form.city,
address: form.address,
contactIdCard: form.contactIdCard,
contactEmail: form.contactEmail,
legalPerson: form.legalPerson,
legalPersonIdCard: form.legalPersonIdCard,
authMethod: method,
},
})
.then((created) => {
setLatestCertification(created);
setStatus('pending');
@@ -231,13 +251,14 @@ export function ClientEnterpriseAuthPage() {
}
if (step === 'overview') {
const overviewCopy = status === 'approved'
? '企业认证已审核通过,可正常使用发送、签名报备等能力。'
: status === 'pending'
? '企业认证资料已提交,平台运营将在 1 个工作日内完成审核。'
: status === 'rejected'
? '企业认证未通过,请根据驳回原因修改资料后重新提交。'
: '您还未进行企业认证';
const overviewCopy =
status === 'approved'
? '企业认证已审核通过,可正常使用发送、签名报备等能力。'
: status === 'pending'
? '企业认证资料已提交,平台运营将在 1 个工作日内完成审核。'
: status === 'rejected'
? '企业认证未通过,请根据驳回原因修改资料后重新提交。'
: '您还未进行企业认证';
return (
<section className="page-stack enterprise-page">
@@ -246,20 +267,35 @@ export function ClientEnterpriseAuthPage() {
<div className={`surface enterprise-status-card enterprise-status-card--${status}`}>
<strong>{overviewCopy}</strong>
{status === 'approved' ? null : (
<button type="button" onClick={() => setStep(status === 'pending' ? 'pending' : 'profile')}>
{status === 'pending' ? '查看审核进度 >' : '企业认证 >'}
{status === 'pending' ? (
<button type="button" onClick={() => setStep('pending')}>
&gt;
</button>
)}
) : null}
</div>
<div className="surface enterprise-info-card">
<dl>
<div><dt></dt><dd>{latestCertification ? displayCompany : '待认证'}</dd></div>
<div><dt></dt><dd>{latestCertification?.reviewedAt ?? latestCertification?.submittedAt ?? '待审核完成'}</dd></div>
<div><dt></dt><dd>{latestCertification ? displayLicenseNo : '待认证'}</dd></div>
<div><dt></dt><dd>{latestCertification ? displayAddress : '待认证'}</dd></div>
<div><dt></dt><dd>{latestCertification ? displayLegalPerson : '待认证'}</dd></div>
<div>
<dt></dt>
<dd>{latestCertification ? displayCompany : '待认证'}</dd>
</div>
<div>
<dt></dt>
<dd>{latestCertification?.reviewedAt ?? latestCertification?.submittedAt ?? '待审核完成'}</dd>
</div>
<div>
<dt></dt>
<dd>{latestCertification ? displayLicenseNo : '待认证'}</dd>
</div>
<div>
<dt></dt>
<dd>{latestCertification ? displayAddress : '待认证'}</dd>
</div>
<div>
<dt></dt>
<dd>{latestCertification ? displayLegalPerson : '待认证'}</dd>
</div>
</dl>
</div>
</section>
@@ -280,8 +316,19 @@ export function ClientEnterpriseAuthPage() {
<UploadPanel file={licenseFile} onFile={uploadLicense} uploading={uploading} />
<p className="enterprise-help">JPG或PNG格式5M</p>
<Input label="* 企业名称" onChange={(event) => updateForm('companyName', event.target.value)} placeholder="请填写企业全称" hint="请严格按照营业执照上的企业名称进行填写" value={form.companyName} />
<Input label="* 统一社会信用代码/其他组织机构代码" onChange={(event) => updateForm('licenseNo', event.target.value)} placeholder="请填写统一社会信用代码(若无请填写其他组织机构代码)" value={form.licenseNo} />
<Input
label="* 企业名称"
onChange={(event) => updateForm('companyName', event.target.value)}
placeholder="请填写企业全称"
hint="请严格按照营业执照上的企业名称进行填写"
value={form.companyName}
/>
<Input
label="* 统一社会信用代码/其他组织机构代码"
onChange={(event) => updateForm('licenseNo', event.target.value)}
placeholder="请填写统一社会信用代码(若无请填写其他组织机构代码)"
value={form.licenseNo}
/>
<div className="enterprise-address-selects">
<span>* </span>
@@ -310,16 +357,43 @@ export function ClientEnterpriseAuthPage() {
/>
</div>
</div>
<Textarea onChange={(event) => updateForm('address', event.target.value)} placeholder="请填写详细的通讯地址,可与证件上的地址不一致" rows={5} value={form.address} />
<Textarea
onChange={(event) => updateForm('address', event.target.value)}
placeholder="请填写详细的通讯地址,可与证件上的地址不一致"
rows={5}
value={form.address}
/>
<p className="enterprise-form-note">便</p>
<Input label="* 企业联系人姓名" onChange={(event) => updateForm('contactName', event.target.value)} placeholder="请填写企业联系人姓名" value={form.contactName} />
<Input label="* 企业联系人身份证号" onChange={(event) => updateForm('contactIdCard', event.target.value)} placeholder="请填写企业联系人身份证号" value={form.contactIdCard} />
<Input label="* 企业联系人手机号" onChange={(event) => updateForm('contactPhone', event.target.value)} placeholder="请填写企业联系人手机号" value={form.contactPhone} />
<Input label="企业联系人邮箱" onChange={(event) => updateForm('contactEmail', event.target.value)} placeholder="请填写企业联系人邮箱" value={form.contactEmail} />
<Input
label="* 企业联系人姓名"
onChange={(event) => updateForm('contactName', event.target.value)}
placeholder="请填写企业联系人姓名"
value={form.contactName}
/>
<Input
label="* 企业联系人身份证号"
onChange={(event) => updateForm('contactIdCard', event.target.value)}
placeholder="请填写企业联系人身份证号"
value={form.contactIdCard}
/>
<Input
label="* 企业联系人手机号"
onChange={(event) => updateForm('contactPhone', event.target.value)}
placeholder="请填写企业联系人手机号"
value={form.contactPhone}
/>
<Input
label="企业联系人邮箱"
onChange={(event) => updateForm('contactEmail', event.target.value)}
placeholder="请填写企业联系人邮箱"
value={form.contactEmail}
/>
<div className="enterprise-actions">
<Button onClick={() => setStep('overview')} variant="secondary"></Button>
<Button onClick={() => setStep('overview')} variant="secondary">
</Button>
<Button onClick={() => setStep('method')}></Button>
</div>
</div>
@@ -334,20 +408,26 @@ export function ClientEnterpriseAuthPage() {
>
<UserCheck size={56} />
<div>
<strong> <span></span></strong>
<strong>
<span></span>
</strong>
<p></p>
<p></p>
</div>
</button>
<button
className={['enterprise-method-card', method === 'recharge' ? 'is-selected' : ''].filter(Boolean).join(' ')}
className={['enterprise-method-card', method === 'recharge' ? 'is-selected' : '']
.filter(Boolean)
.join(' ')}
onClick={() => setMethod('recharge')}
type="button"
>
<Landmark size={56} />
<div>
<strong> <span>1</span></strong>
<strong>
<span>1</span>
</strong>
<p>使1</p>
<p>使</p>
<p>退</p>
@@ -355,7 +435,9 @@ export function ClientEnterpriseAuthPage() {
</button>
<div className="enterprise-actions">
<Button onClick={() => setStep('profile')} variant="secondary"></Button>
<Button onClick={() => setStep('profile')} variant="secondary">
</Button>
<Button onClick={() => setStep(method === 'face' ? 'face' : 'recharge')}></Button>
</div>
</div>
@@ -363,36 +445,68 @@ export function ClientEnterpriseAuthPage() {
{step === 'recharge' ? (
<div className="enterprise-verify-panel">
<p><span></span><strong></strong></p>
<p>
<span></span>
<strong></strong>
</p>
<p className="enterprise-verify-copy">
使 <em></em> <em>1</em>
使 <em></em> <em>1</em>{' '}
</p>
<h2></h2>
<p><span></span><strong>{form.companyName || '待填写企业名称'}</strong></p>
<small>使便</small>
<p>
<span></span>
<strong>{form.companyName || '待填写企业名称'}</strong>
</p>
<small>
使便
</small>
<div className="enterprise-info-alert">
<AlertCircle size={20} />
<span> <strong></strong> <em>7</em>2</span>
<span>
<strong></strong>{' '}
<em>7</em>
2
</span>
</div>
<div className="enterprise-actions enterprise-actions--center">
<Button disabled={submitting} onClick={submitCertification}>{submitting ? '提交中...' : '确认并充值'}</Button>
<Button onClick={() => setStep('method')} variant="secondary"></Button>
<Button disabled={submitting} onClick={submitCertification}>
{submitting ? '提交中...' : '确认并充值'}
</Button>
<Button onClick={() => setStep('method')} variant="secondary">
</Button>
</div>
</div>
) : null}
{step === 'face' ? (
<div className="enterprise-face-panel">
<p><span></span><strong></strong></p>
<p>
<span></span>
<strong></strong>
</p>
<h2></h2>
<Input label="* 企业法人姓名" onChange={(event) => updateForm('legalPerson', event.target.value)} placeholder="请填写企业法人姓名" value={form.legalPerson} />
<Input label="* 企业法人身份证号" onChange={(event) => updateForm('legalPersonIdCard', event.target.value)} placeholder="请填写企业法人身份证号" value={form.legalPersonIdCard} />
<Input
label="* 企业法人姓名"
onChange={(event) => updateForm('legalPerson', event.target.value)}
placeholder="请填写企业法人姓名"
value={form.legalPerson}
/>
<Input
label="* 企业法人身份证号"
onChange={(event) => updateForm('legalPersonIdCard', event.target.value)}
placeholder="请填写企业法人身份证号"
value={form.legalPersonIdCard}
/>
<div className="enterprise-actions">
<Button onClick={() => setStep('method')} variant="secondary"></Button>
<Button onClick={() => setStep('method')} variant="secondary">
</Button>
<Button onClick={() => setStep('faceScan')}></Button>
</div>
</div>
@@ -400,21 +514,36 @@ export function ClientEnterpriseAuthPage() {
{step === 'faceScan' ? (
<div className="enterprise-face-panel">
<p><span></span><strong></strong></p>
<p>
<span></span>
<strong></strong>
</p>
<h2></h2>
<dl className="enterprise-legal-summary">
<div><dt></dt><dd>{form.legalPerson || '-'}</dd></div>
<div><dt></dt><dd>{form.legalPersonIdCard || '-'}</dd></div>
<div>
<dt></dt>
<dd>{form.legalPerson || '-'}</dd>
</div>
<div>
<dt></dt>
<dd>{form.legalPersonIdCard || '-'}</dd>
</div>
</dl>
<div className="enterprise-qr-section">
<h2></h2>
<p>使<em>5957</em></p>
<p>
使<em>5957</em>
</p>
<div className="enterprise-qr"></div>
<span></span>
<div className="enterprise-actions enterprise-actions--center">
<Button onClick={() => setStep('face')} variant="secondary"></Button>
<Button disabled={submitting} onClick={submitCertification}>{submitting ? '提交中...' : '提交审核'}</Button>
<Button onClick={() => setStep('face')} variant="secondary">
</Button>
<Button disabled={submitting} onClick={submitCertification}>
{submitting ? '提交中...' : '提交审核'}
</Button>
</div>
</div>
</div>
@@ -422,33 +551,70 @@ export function ClientEnterpriseAuthPage() {
{step === 'pending' ? (
<div className="enterprise-result enterprise-result--pending">
<span><ShieldCheck size={70} /></span>
<span>
<ShieldCheck size={70} />
</span>
<h2></h2>
<p></p>
<p>
</p>
<dl>
<div><dt></dt><dd>{displayCompany}</dd></div>
<div><dt></dt><dd>{latestCertification?.submittedAt ?? '-'}</dd></div>
<div><dt></dt><dd>1 </dd></div>
<div><dt></dt><dd></dd></div>
<div>
<dt></dt>
<dd>{displayCompany}</dd>
</div>
<div>
<dt></dt>
<dd>{latestCertification?.submittedAt ?? '-'}</dd>
</div>
<div>
<dt></dt>
<dd>1 </dd>
</div>
<div>
<dt></dt>
<dd></dd>
</div>
</dl>
<div className="enterprise-actions enterprise-actions--center">
<Button onClick={() => setStep('overview')} variant="secondary"></Button>
<Button onClick={() => setStep('overview')} variant="secondary">
</Button>
</div>
</div>
) : null}
{step === 'success' ? (
<div className="enterprise-result enterprise-result--success">
<span><Check size={70} /></span>
<span>
<Check size={70} />
</span>
<h2></h2>
<dl>
<div><dt></dt><dd>{displayCompany}</dd></div>
<div><dt></dt><dd>{displayLicenseNo}</dd></div>
<div><dt></dt><dd>{displayLegalPerson}</dd></div>
<div><dt></dt><dd>{latestCertification?.reviewedAt ?? latestCertification?.submittedAt ?? '-'}</dd></div>
<div><dt></dt><dd>{displayAddress}</dd></div>
<div>
<dt></dt>
<dd>{displayCompany}</dd>
</div>
<div>
<dt></dt>
<dd>{displayLicenseNo}</dd>
</div>
<div>
<dt></dt>
<dd>{displayLegalPerson}</dd>
</div>
<div>
<dt></dt>
<dd>{latestCertification?.reviewedAt ?? latestCertification?.submittedAt ?? '-'}</dd>
</div>
<div>
<dt></dt>
<dd>{displayAddress}</dd>
</div>
</dl>
<Button onClick={() => setStep('overview')} variant="secondary"></Button>
<Button onClick={() => setStep('overview')} variant="secondary">
</Button>
</div>
) : null}
@@ -457,10 +623,14 @@ export function ClientEnterpriseAuthPage() {
<span>!</span>
<h2></h2>
<p>{latestCertification?.rejectReason ?? '请根据运营端审核意见修改资料后重新提交。'}</p>
<button type="button" onClick={() => setStep('profile')}> <ChevronRight size={18} /></button>
<button type="button" onClick={() => setStep('profile')}>
<ChevronRight size={18} />
</button>
<div className="enterprise-actions enterprise-actions--center">
<Button onClick={() => setStep('profile')}></Button>
<Button onClick={() => setStep('overview')} variant="secondary"></Button>
<Button onClick={() => setStep('overview')} variant="secondary">
</Button>
</div>
</div>
) : null}
@@ -0,0 +1,34 @@
.http-signature-page .http-signature-page__form,
.http-signature-page .http-signature-page__result {
display: grid;
gap: var(--space-4);
padding: var(--space-5);
min-width: 0;
}
.http-signature-page .http-signature-page__fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
}
.http-signature-page .http-signature-page__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
}
.http-signature-page .ui-textarea {
font-family: monospace;
overflow-wrap: anywhere;
}
.http-signature-page .ui-button {
justify-self: start;
}
@media (max-width: 640px) {
.http-signature-page .http-signature-page__fields {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -0,0 +1,17 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { HttpSignaturePage } from './HttpSignaturePage';
describe('HTTP signature page', () => {
it('invalidates results after editing and clears the secret', () => {
render(<HttpSignaturePage />);
fireEvent.change(screen.getByLabelText('AccessSecret'), { target: { value: 'test-secret' } });
fireEvent.change(screen.getByLabelText(/原始请求正文/), { target: { value: '{"mobile":"13800138000"}' } });
fireEvent.click(screen.getByRole('button', { name: '生成签名' }));
expect((screen.getByLabelText('X-Signature') as HTMLTextAreaElement).value).toMatch(/^[a-f0-9]{64}$/);
fireEvent.change(screen.getByLabelText('时间戳(秒)'), { target: { value: '1789344000' } });
expect(screen.queryByLabelText('X-Signature')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '清空' }));
expect(screen.getByLabelText('AccessSecret')).toHaveValue('');
});
});
@@ -0,0 +1,159 @@
import { useRef, useState } from 'react';
import { Breadcrumb, Button, Input, Select, Textarea } from '@/components/ui';
import { calculateSignature, createNonce, type SignatureInput } from './signature';
import './HttpSignaturePage.css';
function emptyInput(): SignatureInput {
return {
method: 'POST',
path: '/api/openapi/v1/sms/messages',
timestamp: String(Math.floor(Date.now() / 1000)),
nonce: createNonce(),
secret: '',
body: '',
};
}
export function HttpSignaturePage() {
const [input, setInput] = useState(emptyInput);
const [result, setResult] = useState<ReturnType<typeof calculateSignature> | null>(null);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const signatureRef = useRef<HTMLTextAreaElement>(null);
function change<K extends keyof SignatureInput>(key: K, value: SignatureInput[K]) {
setInput((current) => ({ ...current, [key]: value }));
setResult(null);
setError('');
setNotice('');
}
function generate() {
setNotice('');
try {
setResult(calculateSignature(input));
setError('');
} catch (failure) {
setResult(null);
setError(failure instanceof Error ? failure.message : '签名计算失败');
}
}
async function copy() {
if (!result) return;
try {
if (!navigator.clipboard) throw new Error('Clipboard unavailable');
await navigator.clipboard.writeText(result.signature);
setNotice('已复制签名');
} catch {
signatureRef.current?.focus();
signatureRef.current?.select();
setNotice('已选中签名,请按 Ctrl+C 或长按复制');
}
}
return (
<section className="page-stack http-signature-page">
<Breadcrumb items={['接口工具', 'HTTP签名计算']} />
<p className="muted"></p>
<form
className="surface http-signature-page__form"
autoComplete="off"
onSubmit={(event) => {
event.preventDefault();
generate();
}}
>
<div className="http-signature-page__fields">
<Select
label="请求方法"
value={input.method}
options={[
{ label: 'POST', value: 'POST' },
{ label: 'GET', value: 'GET' },
]}
onChange={(event) => {
change('method', event.target.value as 'GET' | 'POST');
if (event.target.value === 'GET') change('body', '');
}}
/>
<Input
label="请求路径"
value={input.path}
onChange={(event) => change('path', event.target.value)}
hint="不含域名和 ? 后的查询参数"
/>
<Input
label="时间戳(秒)"
inputMode="numeric"
value={input.timestamp}
onChange={(event) => change('timestamp', event.target.value)}
/>
<Input label="nonce" value={input.nonce} onChange={(event) => change('nonce', event.target.value)} />
</div>
<Button
type="button"
variant="ghost"
onClick={() => {
change('timestamp', String(Math.floor(Date.now() / 1000)));
change('nonce', createNonce());
}}
>
nonce
</Button>
<Input
label="AccessSecret"
type="password"
autoComplete="new-password"
value={input.secret}
onChange={(event) => change('secret', event.target.value)}
/>
{input.method === 'POST' ? (
<Textarea
label="原始请求正文"
rows={8}
spellCheck={false}
value={input.body}
onChange={(event) => change('body', event.target.value)}
hint="粘贴实际发送的 JSON 文本。空格和换行参与签名,不会自动格式化。"
/>
) : (
<p className="muted">GET </p>
)}
{error ? (
<p className="form-error" role="alert">
{error}
</p>
) : null}
<div className="http-signature-page__actions">
<Button
type="button"
variant="ghost"
onClick={() => {
setInput(emptyInput());
setResult(null);
setError('');
setNotice('');
}}
>
</Button>
<Button type="submit"></Button>
</div>
</form>
{result ? (
<div className="surface http-signature-page__result">
<Textarea label="X-Signature" ref={signatureRef} rows={2} readOnly value={result.signature} />
<Button onClick={() => void copy()} variant="secondary">
</Button>
<Textarea
label="签名原文"
rows={8}
readOnly
value={result.original}
hint={`UTF-8 共 ${result.byteLength} 字节;字段之间的换行为 LF(0x0A)`}
/>
</div>
) : null}
<p role="status">{notice}</p>
</section>
);
}
@@ -0,0 +1,41 @@
/// <reference types="node" />
import { createHmac } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import { calculateSignature, type SignatureInput } from './signature';
const base: SignatureInput = {
method: 'GET',
path: '/api/openapi/v1/sms/uplinks',
timestamp: '1789344000',
nonce: '550e8400-e29b-41d4-a716-446655440000',
secret: 'doc-example-secret',
body: '',
};
describe('HTTP signature calculator', () => {
it('matches the published GET vector without trailing LF', () => {
const result = calculateSignature(base);
expect(result.signature).toBe('3db9c015c2b1c5365a0ef296a79b419653b0087daed2792cdb5717b0802eec51');
expect(result.original.endsWith('\n')).toBe(false);
});
it.each(['{"content":"中文🙂"}', '{ "content": "中文🙂" }\n', '{\r\n"content":"中文🙂"\r\n}'])(
'signs exact POST bytes: %s',
(body) => {
const input = { ...base, method: 'POST' as const, body };
const expected = ['POST', base.path, base.timestamp, base.nonce, body].join('\n');
const result = calculateSignature(input);
expect(result.signature).toBe(createHmac('sha256', base.secret).update(expected).digest('hex'));
expect(result.byteLength).toBe(Buffer.byteLength(expected));
},
);
it.each([
{ path: '/api/openapi/v1/sms/uplinks?limit=1' },
{ timestamp: '1.5' },
{ nonce: 'short' },
{ secret: '' },
{ body: '{}' },
{ method: 'POST', body: '[]' },
{ method: 'POST', body: '{' },
])('rejects invalid inputs %o', (change) => {
expect(() => calculateSignature({ ...base, ...change } as SignatureInput)).toThrow();
});
});
@@ -0,0 +1,47 @@
import { hmac } from '@noble/hashes/hmac.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex } from '@noble/hashes/utils.js';
export type SignatureInput = {
method: 'GET' | 'POST';
path: string;
timestamp: string;
nonce: string;
secret: string;
body: string;
};
export function calculateSignature(input: SignatureInput) {
if (!['GET', 'POST'].includes(input.method)) throw new Error('请选择 GET 或 POST');
if (!/^\/api\/openapi\/v1\/sms\/[^?#\s]+$/.test(input.path)) {
throw new Error('请输入 /api/openapi/v1/sms/ 开头的请求路径,不含域名、查询参数或空格');
}
if (!/^\d+$/.test(input.timestamp) || !Number.isSafeInteger(Number(input.timestamp))) {
throw new Error('时间戳必须是整数秒');
}
if (!/^[A-Za-z0-9_-]{8,128}$/.test(input.nonce)) throw new Error('nonce 须为 8–128 位字母、数字、下划线或连字符');
if (!input.secret) throw new Error('请输入 AccessSecret');
if (input.method === 'GET' && input.body) throw new Error('GET 请求不能包含正文');
if (input.method === 'POST') {
try {
const parsed: unknown = JSON.parse(input.body);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error();
} catch {
throw new Error('POST 正文必须是 JSON 对象;计算时保留输入的原始文本');
}
}
const original =
[input.method, input.path, input.timestamp, input.nonce].join('\n') +
(input.method === 'POST' ? `\n${input.body}` : '');
const encoder = new TextEncoder();
const bytes = encoder.encode(original);
return {
original,
byteLength: bytes.length,
signature: bytesToHex(hmac(sha256, encoder.encode(input.secret), bytes)),
};
}
export function createNonce() {
return bytesToHex(crypto.getRandomValues(new Uint8Array(16)));
}