fix: connect remaining sms pages to real backend

This commit is contained in:
hectorzhao
2026-07-02 19:30:04 +08:00
parent 06562e42dd
commit 131f344ac4
50 changed files with 2074 additions and 6400 deletions
+129 -287
View File
@@ -1,175 +1,88 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight, Edit3, FilePenLine, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { FilePenLine, Plus, Search, Trash2, Upload } from 'lucide-react';
import { Button, Input, Modal, Select, Tag } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignature } from '@/api/adminApi';
type CarrierStatus = 'approved' | 'pending' | 'rejected';
type DrainageInfo = {
id: string;
siteName: string;
content: string;
mobile: CarrierStatus;
unicom: CarrierStatus;
telecom: CarrierStatus;
submittedAt: string;
};
type SignatureItem = {
id: string;
name: string;
application: string;
accent: 'green' | 'blue' | 'red' | 'gray';
mobile: CarrierStatus;
unicom: CarrierStatus;
telecom: CarrierStatus;
drainage: DrainageInfo[];
};
const statusLabelMap: Record<CarrierStatus, string> = {
approved: '已通过',
pending: '审核中',
rejected: '已驳回',
};
const statusToneMap: Record<CarrierStatus, 'success' | 'info' | 'danger'> = {
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
approved: 'success',
pending: 'info',
rejected: 'danger',
draft: 'warning',
};
const initialSignatures: SignatureItem[] = [
{
id: 'sig-1',
name: '【科技公司】',
application: '营销推广',
accent: 'green',
mobile: 'approved',
unicom: 'approved',
telecom: 'approved',
drainage: [
{ id: 'drain-1', siteName: '官方网站', content: 'https://www.example.com', mobile: 'approved', unicom: 'approved', telecom: 'approved', submittedAt: '2024-01-08 11:00:00' },
{ id: 'drain-2', siteName: '促销活动页', content: 'https://promo.example.com', mobile: 'approved', unicom: 'pending', telecom: 'pending', submittedAt: '2024-01-09 10:30:00' },
],
},
{
id: 'sig-2',
name: '【客户服务】',
application: '通知服务',
accent: 'blue',
mobile: 'approved',
unicom: 'pending',
telecom: 'approved',
drainage: [
{ id: 'drain-3', siteName: '客服入口', content: 'https://service.example.com', mobile: 'approved', unicom: 'pending', telecom: 'approved', submittedAt: '2024-01-10 09:12:00' },
],
},
{
id: 'sig-3',
name: '【验证码】',
application: '验证码',
accent: 'green',
mobile: 'approved',
unicom: 'approved',
telecom: 'approved',
drainage: [],
},
{
id: 'sig-4',
name: '【促销活动】',
application: '百<>会员推广',
accent: 'red',
mobile: 'rejected',
unicom: 'approved',
telecom: 'pending',
drainage: [
{ id: 'drain-4', siteName: '会员活动页', content: 'https://vip.example.com', mobile: 'rejected', unicom: 'approved', telecom: 'pending', submittedAt: '2024-01-11 13:42:00' },
],
},
{
id: 'sig-5',
name: '【会员中心】',
application: '会员服务',
accent: 'gray',
mobile: 'pending',
unicom: 'pending',
telecom: 'pending',
drainage: [],
},
];
function UploadBox({ label, compact = false }: { label?: string; compact?: boolean }) {
return (
<div className={compact ? 'signature-upload signature-upload--compact' : 'signature-upload'}>
{label ? <span>{label}</span> : null}
<Upload size={compact ? 30 : 42} />
<strong>{compact ? '上传文件' : '点击上传 或拖拽文件到此处'}</strong>
{!compact ? <small> PNGJPGJPEG 3M</small> : null}
</div>
);
}
function SignatureForm({ signature }: { signature?: SignatureItem }) {
return (
<div className="signature-form">
<section>
<h3></h3>
<div className="signature-alert">
<Info size={18} />
<span>使PNGJPG或JPEG格式的正版文件3M</span>
</div>
<div className="signature-form-grid">
<Select label="* 签名依据" options={[{ label: '请选择签名依据', value: '' }, { label: '企事业单位证明', value: 'company' }]} defaultValue={signature ? 'company' : ''} />
<Input label="* 短信签名" defaultValue={signature?.name ?? ''} placeholder="请输入短信签名,如【XXXX公司】" />
</div>
<UploadBox label="* 资质凭证" />
</section>
<section>
<h3></h3>
<div className="signature-form-grid">
<Input label="* 公司名称" defaultValue={signature?.application ?? ''} placeholder="请输入公司名称" />
<Input label="* 统一社会信用代码" placeholder="请输入统一社会信用代码" />
<Input label="* 法人姓名" placeholder="请输入法人姓名" />
<Input label="法人身份证号" placeholder="请输入法人身份证号" />
<UploadBox compact label="法人身份证照片-人像面" />
<UploadBox compact label="法人身份证照片-国徽面" />
</div>
</section>
<section>
<h3></h3>
<div className="signature-form-grid">
<Input label="* 责任人姓名" placeholder="请输入责任人姓名" />
<Input label="* 责任人手机号" placeholder="请输入责任人手机号" />
<Input label="* 责任人身份证号" placeholder="请输入责任人身份证号" />
<Input label="责任人邮箱" placeholder="请输入责任人邮箱" />
<UploadBox compact label="责任人身份证照片-人像面" />
<UploadBox compact label="责任人身份证照片-国徽面" />
</div>
</section>
</div>
);
}
const statusLabel: Record<string, string> = {
approved: '已通过',
pending: '审核中',
rejected: '已驳回',
draft: '草稿',
disabled: '已禁用',
};
export function ClientSignaturesPage() {
const [signatures, setSignatures] = useState(initialSignatures);
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [keyword, setKeyword] = useState('');
const [expandedId, setExpandedId] = useState('');
const [signatureModal, setSignatureModal] = useState<{ mode: 'add' | 'edit'; signature?: SignatureItem } | null>(null);
const [editingDrainage, setEditingDrainage] = useState<{ signature: SignatureItem; drainage?: DrainageInfo } | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [applicationId, setApplicationId] = useState('');
const [name, setName] = useState('');
const [purpose, setPurpose] = useState('');
const [file, setFile] = useState<File | null>(null);
const filteredSignatures = signatures.filter((item) => (
item.name.includes(keyword) || item.application.includes(keyword)
));
function deleteSignature(id: string) {
setSignatures((items) => items.filter((item) => item.id !== id));
function loadData() {
setLoading(true);
Promise.all([clientApi.listApplications(), clientApi.listSignatures()])
.then(([applicationItems, signatureItems]) => {
setApplications(applicationItems.filter((item) => item.status === 'active'));
setSignatures(signatureItems.filter((item) => item.auditStatus !== 'disabled' && item.auditStatus !== 'deleted'));
setError('');
})
.catch((reason: Error) => setError(reason.message || '签名数据加载失败'))
.finally(() => setLoading(false));
}
function deleteDrainage(signatureId: string, drainageId: string) {
setSignatures((items) => items.map((item) => (
item.id === signatureId ? { ...item, drainage: item.drainage.filter((drainage) => drainage.id !== drainageId) } : item
)));
useEffect(() => {
loadData();
}, []);
const filteredSignatures = useMemo(() => signatures.filter((item) => (
!keyword || [item.name, item.purpose, item.applicationId].join(' ').includes(keyword)
)), [keyword, signatures]);
async function createSignature() {
try {
const signature = await clientApi.createSignature({ applicationId: applicationId || undefined, name, purpose });
if (file) {
const fileObject = await clientApi.createFileObject({
objectKey: `signature-materials/${signature.id}/${Date.now()}-${file.name}`,
fileName: file.name,
contentType: file.type || 'application/octet-stream',
sizeBytes: file.size,
purpose: 'signature_material',
});
await clientApi.createSignatureMaterial(signature.id, {
fileObjectId: fileObject.id,
materialType: file.type.startsWith('image/') ? 'image' : 'file',
title: file.name,
});
}
await clientApi.submitSignature(signature.id);
setModalOpen(false);
setApplicationId('');
setName('');
setPurpose('');
setFile(null);
loadData();
} catch (reason) {
setError(reason instanceof Error ? reason.message : '签名提交失败');
}
}
function disableSignature(id: string) {
clientApi.changeSignatureStatus(id, 'disabled')
.then(loadData)
.catch((reason: Error) => setError(reason.message || '签名禁用失败'));
}
return (
@@ -179,154 +92,83 @@ export function ClientSignaturesPage() {
<span className="sms-send-title__icon">
<FilePenLine size={22} />
</span>
<h1></h1>
<h1></h1>
</div>
<Button icon={<Plus size={17} />} onClick={() => setSignatureModal({ mode: 'add' })}></Button>
<Button icon={<Plus size={17} />} onClick={() => setModalOpen(true)}></Button>
</div>
<div className="signature-search-row">
<Input
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索签名名称或用途"
placeholder="搜索签名名称、用途或应用"
prefix={<Search size={17} />}
value={keyword}
/>
</div>
{loading ? <p className="muted">...</p> : null}
{error ? <p className="form-error">{error}</p> : null}
<div className="signature-list">
{filteredSignatures.map((signature) => {
const expanded = expandedId === signature.id;
return (
<article className={`signature-card signature-card--${signature.accent}`} key={signature.id}>
<div className="signature-summary">
<button aria-label="展开签名" onClick={() => setExpandedId(expanded ? '' : signature.id)} type="button">
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
<div>
<span></span>
<strong>{signature.name}</strong>
</div>
<div>
<span></span>
<strong>{signature.application}</strong>
</div>
<div>
<span></span>
<Tag tone={statusToneMap[signature.mobile]}>{statusLabelMap[signature.mobile]}</Tag>
</div>
<div>
<span></span>
<Tag tone={statusToneMap[signature.unicom]}>{statusLabelMap[signature.unicom]}</Tag>
</div>
<div>
<span></span>
<Tag tone={statusToneMap[signature.telecom]}>{statusLabelMap[signature.telecom]}</Tag>
</div>
<div>
<span></span>
<strong>{signature.drainage.length} </strong>
</div>
<div className="signature-actions">
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal({ mode: 'edit', signature })} size="sm" variant="ghost"></Button>
<Button icon={<Trash2 size={16} />} onClick={() => deleteSignature(signature.id)} size="sm" variant="danger"></Button>
</div>
{filteredSignatures.map((signature) => (
<article className="signature-card signature-card--green" key={signature.id}>
<div className="signature-summary">
<div>
<span></span>
<strong>{signature.name}</strong>
</div>
{expanded ? (
<div className="drainage-panel">
<h2></h2>
<div className="drainage-table">
<div className="drainage-table__head">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{signature.drainage.map((item) => (
<div className="drainage-table__row" key={item.id}>
<strong>{item.siteName}</strong>
<a href={item.content}>{item.content}</a>
<Tag tone={statusToneMap[item.mobile]}>{statusLabelMap[item.mobile]}</Tag>
<Tag tone={statusToneMap[item.unicom]}>{statusLabelMap[item.unicom]}</Tag>
<Tag tone={statusToneMap[item.telecom]}>{statusLabelMap[item.telecom]}</Tag>
<span className="muted">{item.submittedAt}</span>
<span className="drainage-row-actions">
<Button onClick={() => setEditingDrainage({ signature, drainage: item })} size="sm" variant="ghost"></Button>
<Button onClick={() => deleteDrainage(signature.id, item.id)} size="sm" variant="danger"></Button>
</span>
</div>
))}
</div>
<div className="drainage-panel__footer">
<Button icon={<Plus size={16} />} onClick={() => setEditingDrainage({ signature })} size="sm" variant="ghost">
</Button>
</div>
</div>
) : null}
</article>
);
})}
<div>
<span></span>
<strong>{signature.purpose ?? '-'}</strong>
</div>
<div>
<span></span>
<Tag tone={statusTone[signature.auditStatus] ?? 'info'}>{statusLabel[signature.auditStatus] ?? signature.auditStatus}</Tag>
</div>
<div>
<span></span>
<strong>{signature.materials?.length ?? 0} </strong>
</div>
<div className="signature-actions">
<Button icon={<Trash2 size={16} />} onClick={() => disableSignature(signature.id)} size="sm" variant="danger"></Button>
</div>
</div>
</article>
))}
</div>
{!loading && !error && filteredSignatures.length === 0 ? <p className="muted"></p> : null}
<Modal
footer={
footer={(
<>
<Button variant="ghost" onClick={() => setSignatureModal(null)}></Button>
<Button onClick={() => setSignatureModal(null)}></Button>
<Button onClick={() => setModalOpen(false)} variant="ghost"></Button>
<Button disabled={!name} onClick={createSignature}></Button>
</>
}
onClose={() => setSignatureModal(null)}
open={Boolean(signatureModal)}
)}
onClose={() => setModalOpen(false)}
open={modalOpen}
size="xl"
title={<div className="signature-modal-title"><h2>{signatureModal?.mode === 'edit' ? '编辑签名' : '添加签名'}</h2><p></p></div>}
title="添加签名"
>
<SignatureForm signature={signatureModal?.signature} />
</Modal>
<Modal
footer={
<>
<Button variant="ghost" onClick={() => setEditingDrainage(null)}></Button>
<Button onClick={() => setEditingDrainage(null)}></Button>
</>
}
onClose={() => setEditingDrainage(null)}
open={Boolean(editingDrainage)}
size="xl"
title={<div className="signature-modal-title"><h2></h2><p>{editingDrainage?.signature.name}</p></div>}
>
{editingDrainage ? (
<div className="signature-form">
<section>
<h3></h3>
<Input label="* 引流信息:" defaultValue={editingDrainage.drainage?.content ?? 'https://www.example.com'} />
<div className="signature-alert">
<Info size={18} />
<span>1 使2 PNGJPG或JPEG格式的正版文件3M3 pdf格式或者图片10M</span>
</div>
<div className="signature-form-grid">
<UploadBox compact label="* 字段名称1" />
<Input label="* 字段名称2" defaultValue={editingDrainage.drainage?.siteName ?? '官方网站'} />
<Input label="* 字段名称3" placeholder="请输入公司名称" />
<Input label="* 字段名称4" placeholder="请输入统一社会信用代码" />
<Input label="* 字段名称5" placeholder="请输入法人姓名" />
<Input label="* 字段名称6" placeholder="请输入法人身份证号" />
<div className="signature-file-line">
<span>7</span>
<Button size="sm"></Button>
<small></small>
</div>
<Input label="* 字段名称8" placeholder="请输入责任人身份证号" />
<Input label="* 字段名称9" placeholder="请输入责任人姓名" />
<Input label="* 字段名称10" placeholder="请输入责任人手机号" />
</div>
</section>
</div>
) : null}
<div className="signature-form">
<Select
label="短信应用"
onChange={(event) => setApplicationId(event.target.value)}
options={[{ label: '不绑定应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]}
value={applicationId}
/>
<Input label="短信签名" onChange={(event) => setName(event.target.value)} placeholder="请输入短信签名,如【某某科技】" value={name} />
<Input label="用途" onChange={(event) => setPurpose(event.target.value)} placeholder="请输入签名用途" value={purpose} />
<label className="signature-upload">
<Upload size={36} />
<strong>{file ? file.name : '上传资质图片或文件'}</strong>
<small>PDFWord </small>
<input
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
style={{ display: 'none' }}
type="file"
/>
</label>
</div>
</Modal>
</section>
);