fix: harden real backend admin workflows and ui

This commit is contained in:
hectorzhao
2026-07-03 19:29:56 +08:00
parent dd09d91c1e
commit 8cca361441
71 changed files with 5111 additions and 4439 deletions
+130 -114
View File
@@ -1,105 +1,109 @@
import { useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { ClipboardCopy, FileText } from 'lucide-react';
import { clientApi, type ApplicationCmppParams, type ClientSmsApplication } from '@/api/adminApi';
import { Button, Modal, Tag } from '@/components/ui';
type LinkStatus = 'connected' | 'disconnected' | 'inactive';
type LinkStatus = 'connected' | 'degraded' | 'disconnected' | 'inactive';
type SmsApplication = {
id: string;
name: string;
appid: string;
todaySuccess: number;
deliveryRate?: number;
price: string;
score?: string;
status: LinkStatus;
params: Array<{ label: string; value: string; highlight?: boolean }>;
type ParamRow = {
label: string;
value: string;
highlight?: boolean;
};
const statusLabelMap: Record<LinkStatus, string> = {
connected: '已连接',
degraded: '部分连接',
disconnected: '已断',
inactive: '未开通',
};
const statusToneMap: Record<LinkStatus, 'success' | 'danger' | 'info'> = {
const statusToneMap: Record<LinkStatus, 'success' | 'warning' | 'danger' | 'info'> = {
connected: 'success',
degraded: 'warning',
disconnected: 'danger',
inactive: 'info',
};
const applications: SmsApplication[] = [
{
id: 'app-1',
name: '营销推广平台',
appid: 'AK_2024010912345678',
todaySuccess: 1500,
deliveryRate: 95,
price: '0.050 元',
score: '100分',
status: 'connected',
params: [
{ label: 'ID', value: '113009756' },
{ label: '企业名', value: '启瑞物业三网' },
{ label: '开通时间', value: '2023-12-13' },
{ label: '企业代码', value: 'qrhyyd' },
{ label: '账号', value: 'qrhyyd' },
{ label: '密码', value: 'm6yZvZKn', highlight: true },
{ label: '网关IP', value: '121.40.172.212' },
{ label: '网关端口', value: '17890' },
{ label: '接入号', value: '106999999' },
{ label: '绑定IP', value: '61.129.57.48' },
{ label: '连接数', value: '1' },
],
},
{
id: 'app-2',
name: '客服系统',
appid: 'AK_2024010987654321',
todaySuccess: 800,
deliveryRate: 90,
price: '0.060 元',
score: '80分',
status: 'disconnected',
params: [
{ label: 'ID', value: '113009812' },
{ label: '企业名', value: '客服系统三网' },
{ label: '开通时间', value: '2024-01-09' },
{ label: '企业代码', value: 'kfxt' },
{ label: '账号', value: 'kfxt' },
{ label: '密码', value: 'r8xKvP2m', highlight: true },
{ label: '网关IP', value: '121.40.172.213' },
{ label: '网关端口', value: '17890' },
{ label: '接入号', value: '106988888' },
{ label: '绑定IP', value: '61.129.57.49' },
{ label: '连接数', value: '1' },
],
},
{
id: 'app-3',
name: '验证码服务',
appid: 'AK_2024010811223344',
todaySuccess: 0,
price: '0.040 元',
status: 'inactive',
params: [
{ label: 'ID', value: '-' },
{ label: '企业名', value: '验证码服务' },
{ label: '开通时间', value: '-' },
{ label: '企业代码', value: '-' },
{ label: '账号', value: '-' },
{ label: '密码', value: '-' },
{ label: '网关IP', value: '-' },
{ label: '网关端口', value: '-' },
{ label: '接入号', value: '-' },
{ label: '绑定IP', value: '-' },
{ label: '连接数', value: '-' },
],
},
];
function normalizeStatus(application: ClientSmsApplication): LinkStatus {
if (application.status !== 'active') {
return 'inactive';
}
return application.cmppStatus ?? 'inactive';
}
function formatPrice(cents?: number | null) {
return `${((cents ?? 0) / 100).toFixed(4)}`;
}
function mapParams(params: ApplicationCmppParams): ParamRow[] {
return [
{ label: 'ID', value: params.applicationId },
{ label: '企业', value: params.tenantName },
{ label: '应用名称', value: params.applicationName },
{ label: 'AppID', value: params.appCode },
{ label: '企业代码', value: params.enterpriseCode },
{ label: '账号', value: params.account },
{ label: '密码', value: params.passwordCipher, highlight: true },
{ label: '网关IP', value: params.gatewayHost || '-' },
{ label: '网关端口', value: String(params.gatewayPort || '-') },
{ label: '接入号', value: params.srcId || '-' },
{ label: '连接数', value: String(params.maxConnections || '-') },
{ label: '心跳间隔', value: `${params.heartbeatSeconds}` },
{ label: '窗口大小', value: String(params.windowSize) },
{ label: '协议版本', value: params.protocolVersion },
];
}
export function ClientApplicationsPage() {
const [selectedApp, setSelectedApp] = useState<SmsApplication | null>(null);
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [selectedApp, setSelectedApp] = useState<ClientSmsApplication | null>(null);
const [params, setParams] = useState<ApplicationCmppParams | null>(null);
const [loading, setLoading] = useState(true);
const [paramsLoading, setParamsLoading] = useState(false);
const [error, setError] = useState('');
const [paramsError, setParamsError] = useState('');
const [copied, setCopied] = useState(false);
function loadApplications() {
setLoading(true);
clientApi.listApplications()
.then((items) => {
setApplications(items.filter((item) => item.status !== 'deleted'));
setError('');
})
.catch((reason: Error) => setError(reason.message || '短信应用加载失败'))
.finally(() => setLoading(false));
}
useEffect(() => {
loadApplications();
}, []);
function openParams(application: ClientSmsApplication) {
setSelectedApp(application);
setParams(null);
setParamsError('');
setCopied(false);
setParamsLoading(true);
clientApi.getApplicationCmppParams(application.id)
.then((data) => {
setParams(data);
setParamsError('');
})
.catch((reason: Error) => setParamsError(reason.message || '接口参数加载失败'))
.finally(() => setParamsLoading(false));
}
const selectedRows = useMemo(() => params ? mapParams(params) : [], [params]);
function copyParams() {
if (selectedRows.length === 0) {
return;
}
const text = selectedRows.map((item) => `${item.label}: ${item.value}`).join('\n');
void navigator.clipboard.writeText(text).then(() => setCopied(true));
}
return (
<section className="page-stack">
@@ -111,47 +115,59 @@ export function ClientApplicationsPage() {
<span className="muted"> {applications.length} </span>
</div>
{loading ? <p className="muted">...</p> : null}
{error ? <p className="form-error">{error}</p> : null}
{!loading && !error && applications.length === 0 ? (
<div className="surface ui-table__empty"></div>
) : null}
<div className="sms-app-grid">
{applications.map((application) => (
<article className="sms-app-card" key={application.id}>
<h2>{application.name}</h2>
<dl>
<div>
<dt>appid</dt>
<dd>{application.appid}</dd>
</div>
<div>
<dt></dt>
<dd>{application.todaySuccess.toLocaleString('zh-CN')} </dd>
</div>
<div>
<dt></dt>
<dd>{application.deliveryRate ? `${application.deliveryRate}%` : '-'}</dd>
</div>
<div>
<dt></dt>
<dd>{application.price}{application.score ? <span>{application.score}</span> : null}</dd>
</div>
<div>
<dt>CMPP链接状态</dt>
<dd><Tag tone={statusToneMap[application.status]}>{statusLabelMap[application.status]}</Tag></dd>
</div>
</dl>
<Button onClick={() => setSelectedApp(application)} variant="ghost"></Button>
</article>
))}
{applications.map((application) => {
const linkStatus = normalizeStatus(application);
return (
<article className="sms-app-card" key={application.id}>
<h2>{application.name}</h2>
<dl>
<div>
<dt>appid</dt>
<dd>{application.id}</dd>
</div>
<div>
<dt></dt>
<dd>{(application.sentToday ?? 0).toLocaleString('zh-CN')} </dd>
</div>
<div>
<dt></dt>
<dd>{application.deliveryRate !== undefined ? `${application.deliveryRate}%` : '-'}</dd>
</div>
<div>
<dt></dt>
<dd>{formatPrice(application.customerUnitPrice)}</dd>
</div>
<div>
<dt>CMPP链接状态</dt>
<dd><Tag tone={statusToneMap[linkStatus]}>{statusLabelMap[linkStatus]}</Tag></dd>
</div>
</dl>
<Button onClick={() => openParams(application)} variant="ghost"></Button>
</article>
);
})}
</div>
<Modal
footer={<Button icon={<ClipboardCopy size={16} />}></Button>}
footer={<Button disabled={!params} icon={<ClipboardCopy size={16} />} onClick={copyParams}>{copied ? '已复制' : '复制参数'}</Button>}
onClose={() => setSelectedApp(null)}
open={Boolean(selectedApp)}
size="xl"
title="接口参数"
>
{selectedApp ? (
{paramsLoading ? <p className="muted">...</p> : null}
{paramsError ? <p className="form-error">{paramsError}</p> : null}
{!paramsLoading && !paramsError && selectedRows.length > 0 ? (
<div className="sms-app-param-table">
{selectedApp.params.map((item) => (
{selectedRows.map((item) => (
<div key={item.label}>
<span>{item.label}</span>
<strong className={item.highlight ? 'text-blue' : undefined}>{item.value}</strong>
+6 -6
View File
@@ -86,9 +86,9 @@ function mapTask(task: SmsBatchTask): BatchTask {
wordCount: [...task.content].length,
sendType: task.scheduledAt ? 'scheduled' : 'immediate',
scheduledAt: task.scheduledAt,
sentCount: task.progressSent,
deliveredCount: task.progressDelivered,
failedCount: task.progressFailed,
sentCount: task.progressSent ?? task.submittedTotal ?? 0,
deliveredCount: task.progressDelivered ?? task.successTotal ?? 0,
failedCount: task.progressFailed ?? task.failedTotal ?? 0,
totalCount: task.progressTotal || task.phoneTotal,
templateContent: task.content,
status: normalizeTaskStatus(task.status),
@@ -157,10 +157,10 @@ export function ClientBatchTasksPage() {
</div>
),
},
{ key: 'applicationName', title: '应用名称', width: '110px', render: (record) => record.applicationName },
{ key: 'applicationName', title: '应用名称', width: '150px', render: (record) => record.applicationName },
{ key: 'submittedAt', title: '提交时间', width: '130px', render: (record) => record.submittedAt },
{ key: 'phoneCount', title: '发送号码数', width: '95px', render: (record) => record.phoneCount.toLocaleString('zh-CN') },
{ key: 'wordCount', title: '单号码字数', width: '86px', render: (record) => <strong>{record.wordCount} </strong> },
{ key: 'phoneCount', title: '发送号码数', width: '120px', render: (record) => record.phoneCount.toLocaleString('zh-CN') },
{ key: 'wordCount', title: '单号码字数', width: '120px', render: (record) => <strong>{record.wordCount} </strong> },
{
key: 'sendType',
title: '发送时间',
+187 -37
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
AlertCircle,
Check,
@@ -9,6 +9,7 @@ import {
UserCheck,
} from 'lucide-react';
import { Button, Input, Select, Textarea } from '@/components/ui';
import { clientApi, type EnterpriseCertification, type FileObject } from '@/api/adminApi';
type AuthStep = 'overview' | 'profile' | 'method' | 'recharge' | 'face' | 'faceScan' | 'pending' | 'success' | 'failed';
type AuthMethod = 'face' | 'recharge';
@@ -22,12 +23,47 @@ const companyInfo = {
address: '上海XXX区XX路XX号',
};
function UploadPanel() {
type CertificationForm = {
companyName: string;
licenseNo: string;
province: string;
city: string;
address: string;
contactName: string;
contactIdCard: string;
contactPhone: string;
contactEmail: string;
legalPerson: string;
legalPersonIdCard: string;
};
const emptyCertificationForm: CertificationForm = {
companyName: '',
licenseNo: '',
province: '',
city: '',
address: '',
contactName: '',
contactIdCard: '',
contactPhone: '',
contactEmail: '',
legalPerson: '',
legalPersonIdCard: '',
};
function UploadPanel({ file, uploading, onFile }: { file: FileObject | null; uploading: boolean; onFile: (file: File | undefined) => void }) {
return (
<div className="enterprise-upload">
<label className="enterprise-upload">
<Upload size={38} />
<strong></strong>
</div>
<strong>{uploading ? '上传中...' : file?.fileName ?? '点击上传'}</strong>
<input
accept="image/png,image/jpeg,image/webp,application/pdf"
disabled={uploading}
onChange={(event) => onFile(event.target.files?.[0])}
style={{ display: 'none' }}
type="file"
/>
</label>
);
}
@@ -74,12 +110,118 @@ function AuthHeader({ status }: { status: CertificationStatus }) {
);
}
function statusFromCertification(certification: EnterpriseCertification | null): CertificationStatus {
if (!certification) {
return 'uncertified';
}
if (certification.status === 'approved') {
return 'approved';
}
if (certification.status === 'rejected') {
return 'rejected';
}
return 'pending';
}
export function ClientEnterpriseAuthPage() {
const [step, setStep] = useState<AuthStep>('overview');
const [method, setMethod] = useState<AuthMethod>('face');
const [status, setStatus] = useState<CertificationStatus>('uncertified');
const [form, setForm] = useState<CertificationForm>(emptyCertificationForm);
const [latestCertification, setLatestCertification] = useState<EnterpriseCertification | null>(null);
const [licenseFile, setLicenseFile] = useState<FileObject | null>(null);
const [uploading, setUploading] = useState(false);
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 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));
function loadCertification() {
clientApi.listEnterpriseCertifications()
.then((items) => {
const latest = items[0] ?? null;
setLatestCertification(latest);
setStatus(statusFromCertification(latest));
if (latest) {
const materials = latest.materials ?? {};
setForm({
companyName: latest.companyName ?? '',
licenseNo: latest.licenseNo ?? '',
province: String(materials.province ?? ''),
city: String(materials.city ?? ''),
address: String(materials.address ?? ''),
contactName: latest.contactName ?? '',
contactIdCard: String(materials.contactIdCard ?? ''),
contactPhone: latest.contactPhone ?? '',
contactEmail: String(materials.contactEmail ?? ''),
legalPerson: String(materials.legalPerson ?? ''),
legalPersonIdCard: String(materials.legalPersonIdCard ?? ''),
});
}
setError('');
})
.catch((failure: Error) => setError(failure.message || '企业认证信息加载失败'));
}
useEffect(() => {
loadCertification();
}, []);
function updateForm<K extends keyof CertificationForm>(key: K, value: CertificationForm[K]) {
setForm((current) => ({ ...current, [key]: value }));
}
function uploadLicense(file: File | undefined) {
if (!file) return;
setUploading(true);
clientApi.uploadFileObject(file, { purpose: 'enterprise_certification', prefix: 'enterprise-certifications/license' })
.then((fileObject) => {
setLicenseFile(fileObject);
setError('');
})
.catch((failure: Error) => setError(failure.message || '营业执照上传失败'))
.finally(() => setUploading(false));
}
function submitCertification() {
if (!form.companyName.trim() || !form.licenseNo.trim() || !form.contactName.trim() || !form.contactPhone.trim()) {
setError('请填写企业名称、统一社会信用代码、联系人姓名和联系人手机号');
setStep('profile');
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,
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');
setStep('pending');
setError('');
})
.catch((failure: Error) => setError(failure.message || '企业认证提交失败'))
.finally(() => setSubmitting(false));
}
if (step === 'overview') {
const overviewCopy = status === 'approved'
@@ -93,6 +235,7 @@ export function ClientEnterpriseAuthPage() {
return (
<section className="page-stack enterprise-page">
<AuthHeader status={status} />
{error ? <p className="form-error">{error}</p> : null}
<div className={`surface enterprise-status-card enterprise-status-card--${status}`}>
<strong>{overviewCopy}</strong>
@@ -105,11 +248,11 @@ export function ClientEnterpriseAuthPage() {
<div className="surface enterprise-info-card">
<dl>
<div><dt></dt><dd>{status === 'approved' ? companyInfo.name : '待认证'}</dd></div>
<div><dt></dt><dd>{status === 'approved' ? companyInfo.certifiedAt : '待审核完成'}</dd></div>
<div><dt></dt><dd>{status === 'approved' ? companyInfo.code : '待认证'}</dd></div>
<div><dt></dt><dd>{status === 'approved' ? companyInfo.address : '待认证'}</dd></div>
<div><dt></dt><dd>{status === 'approved' ? companyInfo.legalPerson : '待认证'}</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>
@@ -119,6 +262,7 @@ export function ClientEnterpriseAuthPage() {
return (
<section className="page-stack enterprise-page">
<h1 className="enterprise-page-title"></h1>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface enterprise-flow-card">
<EnterpriseStepper current={currentStep} />
@@ -126,40 +270,46 @@ export function ClientEnterpriseAuthPage() {
{step === 'profile' ? (
<div className="enterprise-form-panel">
<label className="enterprise-required"></label>
<UploadPanel />
<UploadPanel file={licenseFile} onFile={uploadLicense} uploading={uploading} />
<p className="enterprise-help">JPG或PNG格式5M</p>
<Input label="* 企业名称" placeholder="请填写企业全称" hint="请严格按照营业执照上的企业名称进行填写" />
<Input label="* 统一社会信用代码/其他组织机构代码" placeholder="请填写统一社会信用代码(若无请填写其他组织机构代码)" />
<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>
<div>
<Select
onChange={(event) => updateForm('province', event.target.value)}
options={[
{ label: '请选择省/直辖市', value: '' },
{ label: '上海市', value: 'shanghai' },
{ label: '北京市', value: 'beijing' },
{ label: '上海市', value: '上海市' },
{ label: '北京市', value: '北京市' },
{ label: '山东省', value: '山东省' },
{ label: '河南省', value: '河南省' },
]}
defaultValue=""
value={form.province}
/>
<Select
onChange={(event) => updateForm('city', event.target.value)}
options={[
{ label: '请选择', value: '' },
{ label: '浦东新区', value: 'pudong' },
{ label: '徐汇区', value: 'xuhui' },
{ label: '济南市', value: '济南市' },
{ label: '郑州市', value: '郑州市' },
]}
defaultValue=""
value={form.city}
/>
</div>
</div>
<Textarea placeholder="请填写详细的通讯地址,可与证件上的地址不一致" rows={5} />
<Textarea onChange={(event) => updateForm('address', event.target.value)} placeholder="请填写详细的通讯地址,可与证件上的地址不一致" rows={5} value={form.address} />
<p className="enterprise-form-note">便</p>
<Input label="* 企业联系人姓名" placeholder="请填写企业联系人姓名" />
<Input label="* 企业联系人身份证号" placeholder="请填写企业联系人身份证号" />
<Input label="* 企业联系人手机号" placeholder="请填写企业联系人手机号" />
<Input label="企业联系人邮箱" placeholder="请填写企业联系人邮箱" />
<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>
@@ -212,7 +362,7 @@ export function ClientEnterpriseAuthPage() {
</p>
<h2></h2>
<p><span></span><strong>XXXXXXX公司</strong></p>
<p><span></span><strong>{form.companyName || '待填写企业名称'}</strong></p>
<small>使便</small>
<div className="enterprise-info-alert">
@@ -221,7 +371,7 @@ export function ClientEnterpriseAuthPage() {
</div>
<div className="enterprise-actions enterprise-actions--center">
<Button onClick={() => { setStatus('pending'); setStep('pending'); }}></Button>
<Button disabled={submitting} onClick={submitCertification}>{submitting ? '提交中...' : '确认并充值'}</Button>
<Button onClick={() => setStep('method')} variant="secondary"></Button>
</div>
</div>
@@ -231,8 +381,8 @@ export function ClientEnterpriseAuthPage() {
<div className="enterprise-face-panel">
<p><span></span><strong></strong></p>
<h2></h2>
<Input label="* 企业法人姓名" placeholder="请填写企业法人姓名" />
<Input label="* 企业法人身份证号" placeholder="请填写企业法人身份证号" />
<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>
@@ -246,8 +396,8 @@ export function ClientEnterpriseAuthPage() {
<p><span></span><strong></strong></p>
<h2></h2>
<dl className="enterprise-legal-summary">
<div><dt></dt><dd></dd></div>
<div><dt></dt><dd>162xxxxxxxxxxxxx</dd></div>
<div><dt></dt><dd>{form.legalPerson || '-'}</dd></div>
<div><dt></dt><dd>{form.legalPersonIdCard || '-'}</dd></div>
</dl>
<div className="enterprise-qr-section">
@@ -257,7 +407,7 @@ export function ClientEnterpriseAuthPage() {
<span></span>
<div className="enterprise-actions enterprise-actions--center">
<Button onClick={() => setStep('face')} variant="secondary"></Button>
<Button onClick={() => { setStatus('pending'); setStep('pending'); }}></Button>
<Button disabled={submitting} onClick={submitCertification}>{submitting ? '提交中...' : '提交审核'}</Button>
</div>
</div>
</div>
@@ -269,8 +419,8 @@ export function ClientEnterpriseAuthPage() {
<h2></h2>
<p></p>
<dl>
<div><dt></dt><dd>{companyInfo.name}</dd></div>
<div><dt></dt><dd>20260702 09:58:00</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>
@@ -285,11 +435,11 @@ export function ClientEnterpriseAuthPage() {
<span><Check size={70} /></span>
<h2></h2>
<dl>
<div><dt></dt><dd>{companyInfo.name}</dd></div>
<div><dt></dt><dd>{companyInfo.code}</dd></div>
<div><dt></dt><dd>{companyInfo.legalPerson}</dd></div>
<div><dt></dt><dd>{companyInfo.certifiedAt}</dd></div>
<div><dt></dt><dd>{companyInfo.address}</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>
</div>
@@ -299,7 +449,7 @@ export function ClientEnterpriseAuthPage() {
<div className="enterprise-result enterprise-result--failed">
<span>!</span>
<h2></h2>
<p></p>
<p>{latestCertification?.rejectReason ?? '请根据运营端审核意见修改资料后重新提交。'}</p>
<button type="button" onClick={() => setStep('profile')}> <ChevronRight size={18} /></button>
<div className="enterprise-actions enterprise-actions--center">
<Button onClick={() => setStep('profile')}></Button>
-372
View File
@@ -1,372 +0,0 @@
import { useMemo, useState } from 'react';
import { Clock3, Eye, FileImage, Search, TrendingUp, ZoomIn } from 'lucide-react';
import {
Button,
DateRangeInput,
DetailInfoGrid,
DetailProgressStats,
DetailSection,
DetailTitle,
getRateTone,
Input,
Modal,
ProgressBar,
QueryPanel,
RateCard,
RateOverview,
Select,
Tag,
type DateRangeValue,
type TableColumn,
} from '@/components/ui';
type MmsTaskStatus = 'completed' | 'sending';
type SendType = 'immediate' | 'scheduled';
type MmsTask = {
id: string;
status: MmsTaskStatus;
applicationName: string;
submittedAt: string;
title: string;
content: string;
image: string;
phoneCount: number;
sentCount: number;
totalCount: number;
sendType: SendType;
scheduledAt?: string;
};
const statusToneMap: Record<MmsTaskStatus, 'success' | 'info'> = {
completed: 'success',
sending: 'info',
};
const statusLabelMap: Record<MmsTaskStatus, string> = {
completed: '已完成',
sending: '发送中',
};
const tasksSeed: MmsTask[] = [
{
id: 'MMSTASK20260317001',
status: 'completed',
applicationName: '营销活动彩信',
submittedAt: '2026-03-17 10:30:15',
title: '新春佳节,福气满满',
content: '【优品商城】尊敬的客户,新春佳节来临之际,优品商城全体员工祝您新春快乐、万事如意!点击查看精美贺卡和新春优惠活动详情。',
image: 'https://images.unsplash.com/photo-1519671482749-fd09be7ccebf?auto=format&fit=crop&w=500&q=80',
phoneCount: 2000,
sentCount: 2000,
totalCount: 2000,
sendType: 'immediate',
},
{
id: 'MMSTASK20260317002',
status: 'sending',
applicationName: '营销活动彩信',
submittedAt: '2026-03-17 11:15:30',
title: '重磅新品震撼来袭',
content: '【优品商城】优品商城倾力推出全新智能手表,高颜值高性能!限时特惠价299元,前100名购买送蓝牙耳机一份。',
image: 'https://images.unsplash.com/photo-1434494878577-86c23bcb06b9?auto=format&fit=crop&w=500&q=80',
phoneCount: 1500,
sentCount: 850,
totalCount: 1500,
sendType: 'scheduled',
scheduledAt: '2026-03-18 09:00:00',
},
{
id: 'MMSTASK20260317003',
status: 'sending',
applicationName: '会员服务彩信',
submittedAt: '2026-03-17 14:20:45',
title: '会员专属优惠来了',
content: '【优品商城】尊敬的黄金会员,您享受一波50%的特价惊喜购!本月专享活动仅限开放,精选热门产品5折主打优惠。',
image: 'https://images.unsplash.com/photo-1567427017947-545c5f8d16ad?auto=format&fit=crop&w=500&q=80',
phoneCount: 3000,
sentCount: 2100,
totalCount: 3000,
sendType: 'immediate',
},
{
id: 'MMSTASK20260316001',
status: 'completed',
applicationName: '节日祝福彩信',
submittedAt: '2026-03-16 16:45:00',
title: '中秋团圆,月满人圆',
content: '【优品商城】月圆中秋,情满人间。优品商城全体员工祝您中秋快乐,阖家团圆!精选月饼礼盒8折优惠。',
image: 'https://images.unsplash.com/photo-1600861194942-f883de0dfe96?auto=format&fit=crop&w=500&q=80',
phoneCount: 1200,
sentCount: 1200,
totalCount: 1200,
sendType: 'scheduled',
scheduledAt: '2026-03-17 08:00:00',
},
{
id: 'MMSTASK20260316002',
status: 'sending',
applicationName: '营销活动彩信',
submittedAt: '2026-03-16 18:10:20',
title: '周年庆典,感恩回馈',
content: '【优品商城】优品商城5周年,感恩有你一路相伴。全场满减、买一送一,参与互动赢取千元购物卡。',
image: 'https://images.unsplash.com/photo-1464349095431-e9a21285b5f3?auto=format&fit=crop&w=500&q=80',
phoneCount: 800,
sentCount: 450,
totalCount: 800,
sendType: 'immediate',
},
];
const carrierStats = [
{ name: '中国移动', success: 983, total: 1000, rate: 98.3 },
{ name: '中国联通', success: 590, total: 600, rate: 98.33 },
{ name: '中国电信', success: 392, total: 400, rate: 98 },
];
const cityStats = [
{ city: '北京', total: 400, success: 393 },
{ city: '上海', total: 360, success: 354 },
{ city: '深圳', total: 320, success: 315 },
{ city: '广州', total: 280, success: 275 },
{ city: '杭州', total: 240, success: 236 },
{ city: '成都', total: 200, success: 196 },
{ city: '武汉', total: 200, success: 196 },
];
function getProgress(task: MmsTask) {
return Math.round((task.sentCount / task.totalCount) * 100);
}
function getDeliveredCount(task: MmsTask) {
if (task.status === 'completed') {
return Math.round(task.totalCount * 0.9825);
}
return task.sentCount;
}
export function ClientMmsBatchTasksPage() {
const [keyword, setKeyword] = useState('');
const [application, setApplication] = useState('all');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [selectedTask, setSelectedTask] = useState<MmsTask | null>(null);
const [previewTask, setPreviewTask] = useState<MmsTask | null>(null);
const applicationOptions = useMemo(() => {
const names = Array.from(new Set(tasksSeed.map((item) => item.applicationName)));
return [{ label: '全部应用', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
}, []);
const filteredTasks = tasksSeed.filter((item) => {
const matchesKeyword = !keyword || item.id.includes(keyword);
const matchesApplication = application === 'all' || item.applicationName === application;
const submittedDate = item.submittedAt.slice(0, 10);
const matchesStartDate = !submittedDateRange.start || submittedDate >= submittedDateRange.start;
const matchesEndDate = !submittedDateRange.end || submittedDate <= submittedDateRange.end;
return matchesKeyword && matchesApplication && matchesStartDate && matchesEndDate;
});
const columns: Array<TableColumn<MmsTask>> = [
{
key: 'id',
title: '任务编号',
width: '150px',
render: (record) => (
<div className="batch-task-id">
<strong>{record.id}</strong>
<Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag>
</div>
),
},
{ key: 'applicationName', title: '应用名称', width: '110px', render: (record) => record.applicationName },
{ key: 'submittedAt', title: '提交时间', width: '130px', render: (record) => record.submittedAt },
{
key: 'content',
title: '彩信内容',
width: '410px',
render: (record) => (
<div className="mms-task-content">
<img alt={record.title} src={record.image} />
<div>
<strong>{record.title}</strong>
<p>{record.content}</p>
</div>
</div>
),
},
{ key: 'phoneCount', title: '发送号码数', width: '96px', render: (record) => <strong>{record.phoneCount.toLocaleString('zh-CN')}</strong> },
{
key: 'sendType',
title: '发送时间',
width: '125px',
render: (record) => (
<div className="batch-send-time">
<span><Clock3 size={14} />{record.sendType === 'immediate' ? '立即发送' : '定时发送'}</span>
{record.scheduledAt ? <small>{record.scheduledAt}</small> : null}
</div>
),
},
{
key: 'progress',
title: '发送进度',
width: '170px',
render: (record) => {
const percent = getProgress(record);
return (
<div className="batch-progress">
<div>
<span>{record.sentCount.toLocaleString('zh-CN')} / {record.totalCount.toLocaleString('zh-CN')}</span>
<strong>{percent}%</strong>
</div>
<div className="batch-progress__track">
<span className={`batch-progress__bar batch-progress__bar--${record.status}`} style={{ width: `${percent}%` }} />
</div>
</div>
);
},
},
{
key: 'actions',
title: '操作',
align: 'right',
width: '150px',
render: (record) => (
<div className="batch-actions mms-task-actions">
<Button icon={<Eye size={14} />} onClick={() => setSelectedTask(record)} size="sm" variant="ghost"></Button>
<Button icon={<ZoomIn size={14} />} onClick={() => setPreviewTask(record)} size="sm" variant="ghost"></Button>
</div>
),
},
];
return (
<section className="page-stack">
<div className="sms-send-title">
<span className="sms-send-title__icon"><FileImage size={22} /></span>
<h1></h1>
</div>
<QueryPanel title="查询条件" summary={<> <strong>{filteredTasks.length}</strong> </>}>
<Input label="任务编号" onChange={(event) => setKeyword(event.target.value)} placeholder="输入任务编号搜索" prefix={<Search size={16} />} value={keyword} />
<Select label="选择应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
</QueryPanel>
<div className="surface batch-table-card">
<div className="ui-table-wrap">
<table className="ui-table batch-table mms-task-table">
<thead>
<tr>
{columns.map((column) => (
<th key={column.key} style={{ width: column.width, textAlign: column.align ?? 'left' }}>{column.title}</th>
))}
</tr>
</thead>
<tbody>
{filteredTasks.map((record, index) => (
<tr key={record.id}>
{columns.map((column) => (
<td key={column.key} style={{ textAlign: column.align ?? 'left' }}>{column.render(record, index)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
<Modal footer={<Button onClick={() => setSelectedTask(null)}></Button>} onClose={() => setSelectedTask(null)} open={Boolean(selectedTask)} size="xl" title={<DetailTitle title="任务详情" subtitle={selectedTask?.id} />}>
{selectedTask ? (
<div className="task-detail">
<DetailSection title="基本信息" extra={<Tag tone={statusToneMap[selectedTask.status]}>{statusLabelMap[selectedTask.status]}</Tag>}>
<DetailInfoGrid
items={[
{ label: '任务编号', value: selectedTask.id },
{ label: '应用名称', value: selectedTask.applicationName },
{ label: '提交时间', value: selectedTask.submittedAt },
{ label: '发送方式', value: <span className="task-send-type"><Clock3 size={16} />{selectedTask.sendType === 'immediate' ? '立即发送' : '定时发送'}</span> },
{ label: '任务总号码数', value: `${selectedTask.totalCount.toLocaleString('zh-CN')}`, tone: 'primary' },
{
label: '彩信模板内容',
value: (
<div className="mms-detail-template">
<img alt={selectedTask.title} src={selectedTask.image} />
<div><strong>{selectedTask.title}</strong><p>{selectedTask.content}</p></div>
</div>
),
full: true,
},
]}
/>
</DetailSection>
<DetailSection title="发送统计">
<DetailProgressStats
label="任务进度"
meta={`${selectedTask.sentCount.toLocaleString('zh-CN')} / ${selectedTask.totalCount.toLocaleString('zh-CN')} 已处理`}
percent={getProgress(selectedTask)}
status={selectedTask.status}
stats={[
{ label: '提交总数量', value: selectedTask.totalCount.toLocaleString('zh-CN') },
{ label: '提交成功数量', value: selectedTask.totalCount.toLocaleString('zh-CN') },
{ label: '发送成功数量', value: getDeliveredCount(selectedTask).toLocaleString('zh-CN') },
]}
/>
</DetailSection>
<DetailSection title={<><TrendingUp size={20} /> </>}>
{(() => {
const overallRate = (getDeliveredCount(selectedTask) / selectedTask.totalCount) * 100;
return (
<RateOverview
label="总体成功率"
metrics={[
{ label: '成功总数', value: getDeliveredCount(selectedTask).toLocaleString('zh-CN') },
{ label: '总计', value: selectedTask.totalCount.toLocaleString('zh-CN') },
]}
rate={overallRate}
tone={getRateTone(overallRate)}
/>
);
})()}
<h4></h4>
<div className="carrier-rate-grid">
{carrierStats.map((item) => <RateCard key={item.name} meta={<><span>: {item.success}</span><span>: {item.total}</span></>} rate={item.rate} title={item.name} tone={getRateTone(item.rate)} />)}
</div>
<h4></h4>
<div className="ui-table-wrap">
<table className="ui-table city-rate-table">
<thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{cityStats.map((item) => {
const rate = (item.success / item.total) * 100;
return (
<tr key={item.city}>
<td><strong>{item.city}</strong></td>
<td>{item.total}</td>
<td><span className={`rate-text ui-rate-tone-${getRateTone(rate)}`}>{item.success}</span></td>
<td><span className={`rate-text ui-rate-tone-${getRateTone(rate)}`}>{rate.toFixed(2)}%</span></td>
<td><ProgressBar percent={rate} tone={getRateTone(rate)} /></td>
</tr>
);
})}
</tbody>
</table>
</div>
</DetailSection>
</div>
) : null}
</Modal>
<Modal footer={<Button onClick={() => setPreviewTask(null)}></Button>} onClose={() => setPreviewTask(null)} open={Boolean(previewTask)} title={<div className="template-modal-title"><h2></h2><p>{previewTask?.id}</p></div>}>
{previewTask ? (
<div className="mms-preview">
<img alt={previewTask.title} src={previewTask.image} />
<h3>{previewTask.title}</h3>
<p>{previewTask.content}</p>
</div>
) : null}
</Modal>
</section>
);
}
-235
View File
@@ -1,235 +0,0 @@
import { useMemo, useState } from 'react';
import { Eye, FileImage, Search, Smartphone } from 'lucide-react';
import {
Button,
DateRangeInput,
Input,
Modal,
QueryPanel,
Select,
Tag,
type DateRangeValue,
type TableColumn,
} from '@/components/ui';
type MmsSendStatus = 'success' | 'unknown' | 'failed';
type MmsSendRecord = {
id: string;
applicationName: string;
sentAt: string;
title: string;
content: string;
image: string;
phone: string;
carrier: '中国移动' | '中国联通' | '中国电信';
region: string;
status: MmsSendStatus;
receipt: 'DELIVRD' | 'UNKNOWN' | 'UNDELIV';
receiptAt?: string;
};
const statusLabelMap: Record<MmsSendStatus, string> = {
success: '成功',
unknown: '未知',
failed: '失败',
};
const statusToneMap: Record<MmsSendStatus, 'success' | 'info' | 'danger'> = {
success: 'success',
unknown: 'info',
failed: 'danger',
};
const mmsSendRows: MmsSendRecord[] = [
{
id: 'MMSD20260317001',
applicationName: '营销活动彩信',
sentAt: '2026-03-17 10:30:15',
title: '新春佳节,福气满满',
content: '【优品商城】尊敬的客户,新春佳节来临之际,优品商城全体员工祝您新春快乐、万事如意!点击查看精美贺卡和新春优惠活动详情。',
image: 'https://images.unsplash.com/photo-1519671482749-fd09be7ccebf?auto=format&fit=crop&w=500&q=80',
phone: '13800138000',
carrier: '中国移动',
region: '北京市',
status: 'success',
receipt: 'DELIVRD',
receiptAt: '2026-03-17 10:30:20',
},
{
id: 'MMSD20260317002',
applicationName: '营销活动彩信',
sentAt: '2026-03-17 10:32:25',
title: '重磅新品震撼来袭',
content: '【优品商城】优品商城倾力推出全新智能手表,高颜值高性能!限时特惠价299元,前100名购买送蓝牙耳机一份。',
image: 'https://images.unsplash.com/photo-1434494878577-86c23bcb06b9?auto=format&fit=crop&w=500&q=80',
phone: '13900139000',
carrier: '中国联通',
region: '上海市',
status: 'success',
receipt: 'DELIVRD',
receiptAt: '2026-03-17 10:32:30',
},
{
id: 'MMSD20260317003',
applicationName: '会员服务彩信',
sentAt: '2026-03-17 10:35:40',
title: '会员专属优惠来了',
content: '【优品商城】尊敬的黄金会员,您享受一波50%的特价惊喜购!本月专享活动仅限开放,精选热门产品5折主打优惠,立即购买,先到先得!',
image: 'https://images.unsplash.com/photo-1567427017947-545c5f8d16ad?auto=format&fit=crop&w=500&q=80',
phone: '13700137000',
carrier: '中国电信',
region: '深圳市',
status: 'success',
receipt: 'DELIVRD',
receiptAt: '2026-03-17 10:35:46',
},
{
id: 'MMSD20260317004',
applicationName: '节日祝福彩信',
sentAt: '2026-03-17 10:38:10',
title: '中秋团圆,月满人圆',
content: '【优品商城】月圆中秋,情满人间。优品商城全体员工祝您中秋快乐,阖家团圆!精选月饼礼盒8折优惠,送礼佳品,立即选购!',
image: 'https://images.unsplash.com/photo-1600861194942-f883de0dfe96?auto=format&fit=crop&w=500&q=80',
phone: '13600136000',
carrier: '中国移动',
region: '广州市',
status: 'unknown',
receipt: 'UNKNOWN',
},
{
id: 'MMSD20260317005',
applicationName: '营销活动彩信',
sentAt: '2026-03-17 10:41:58',
title: '周年庆典,感恩回馈',
content: '【优品商城】优品商城5周年,感恩有你一路相伴。全场满减、买一送一,参与互动赢取千元购物卡。',
image: 'https://images.unsplash.com/photo-1464349095431-e9a21285b5f3?auto=format&fit=crop&w=500&q=80',
phone: '13500135000',
carrier: '中国联通',
region: '杭州市',
status: 'failed',
receipt: 'UNDELIV',
},
];
function getDate(value: string) {
return value.slice(0, 10);
}
export function ClientMmsSendDetailPage() {
const [applicationName, setApplicationName] = useState('all');
const [status, setStatus] = useState('all');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [contentKeyword, setContentKeyword] = useState('');
const [phoneKeyword, setPhoneKeyword] = useState('');
const [previewRecord, setPreviewRecord] = useState<MmsSendRecord | null>(null);
const applicationOptions = useMemo(() => {
const applications = Array.from(new Set(mmsSendRows.map((item) => item.applicationName)));
return [{ label: '全部应用', value: 'all' }, ...applications.map((item) => ({ label: item, value: item }))];
}, []);
const filteredRows = mmsSendRows.filter((item) => {
const sentDate = getDate(item.sentAt);
const matchesApplication = applicationName === 'all' || item.applicationName === applicationName;
const matchesStatus = status === 'all' || item.status === status;
const matchesStartDate = !dateRange.start || sentDate >= dateRange.start;
const matchesEndDate = !dateRange.end || sentDate <= dateRange.end;
const matchesContent = !contentKeyword || item.title.includes(contentKeyword) || item.content.includes(contentKeyword);
const matchesPhone = !phoneKeyword || item.phone.includes(phoneKeyword);
return matchesApplication && matchesStatus && matchesStartDate && matchesEndDate && matchesContent && matchesPhone;
});
const columns: Array<TableColumn<MmsSendRecord>> = [
{ key: 'applicationName', title: '应用名称', width: '92px', render: (record) => <strong className="send-detail-app-name">{record.applicationName}</strong> },
{ key: 'sentAt', title: '发送时间', width: '120px', render: (record) => <span className="send-detail-time">{record.sentAt.slice(0, 10)}<small>{record.sentAt.slice(11)}</small></span> },
{
key: 'content',
title: '彩信内容',
width: '450px',
render: (record) => (
<div className="mms-detail-row-content">
<img alt={record.title} src={record.image} />
<div>
<strong>{record.title}</strong>
<p>{record.content}</p>
</div>
</div>
),
},
{ key: 'phone', title: '手机号码', width: '128px', render: (record) => <strong>{record.phone}</strong> },
{ key: 'carrier', title: '所属运营商', width: '86px', render: (record) => <span className="send-detail-carrier">{record.carrier}</span> },
{ key: 'region', title: '号码归属地', width: '80px', render: (record) => <span className="send-detail-region">{record.region.slice(0, 2)}<small>{record.region.slice(2)}</small></span> },
{ key: 'status', title: '发送状态', width: '88px', align: 'center', render: (record) => <Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag> },
{ key: 'receipt', title: '彩信回执', width: '92px', align: 'center', render: (record) => <strong className={record.receipt === 'DELIVRD' ? 'send-detail-receipt-code' : 'muted'}>{record.receipt}</strong> },
{
key: 'receiptAt',
title: '回执时间',
width: '118px',
render: (record) => record.receiptAt ? <span className="send-detail-time">{record.receiptAt.slice(0, 10)}<small>{record.receiptAt.slice(11)}</small></span> : <span className="muted">-</span>,
},
{
key: 'actions',
title: '操作',
width: '92px',
align: 'center',
render: (record) => <Button icon={<Eye size={14} />} onClick={() => setPreviewRecord(record)} size="sm" variant="ghost"></Button>,
},
];
return (
<section className="page-stack">
<div className="sms-send-title">
<span className="sms-send-title__icon"><FileImage size={22} /></span>
<h1></h1>
</div>
<QueryPanel title="查询条件" summary={<> <strong>{filteredRows.length}</strong> </>}>
<Select label="应用名称" onChange={(event) => setApplicationName(event.target.value)} options={applicationOptions} value={applicationName} />
<DateRangeInput label="发送时间" onChange={setDateRange} value={dateRange} />
<Select
label="发送状态"
onChange={(event) => setStatus(event.target.value)}
options={[
{ label: '全部', value: 'all' },
{ label: '成功', value: 'success' },
{ label: '未知', value: 'unknown' },
{ label: '失败', value: 'failed' },
]}
value={status}
/>
<Input label="彩信内容" onChange={(event) => setContentKeyword(event.target.value)} placeholder="输入关键词搜索" prefix={<Search size={16} />} value={contentKeyword} />
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} placeholder="输入手机号搜索" prefix={<Smartphone size={16} />} value={phoneKeyword} />
</QueryPanel>
<div className="surface send-detail-table-card">
<div className="ui-table-wrap">
<table className="ui-table send-detail-table mms-send-detail-table">
<thead>
<tr>{columns.map((column) => <th key={column.key} style={{ width: column.width, textAlign: column.align ?? 'left' }}>{column.title}</th>)}</tr>
</thead>
<tbody>
{filteredRows.length === 0 ? (
<tr><td className="ui-table__empty" colSpan={columns.length}></td></tr>
) : filteredRows.map((record, index) => (
<tr key={record.id}>
{columns.map((column) => <td key={column.key} style={{ textAlign: column.align ?? 'left' }}>{column.render(record, index)}</td>)}
</tr>
))}
</tbody>
</table>
</div>
</div>
<Modal footer={<Button onClick={() => setPreviewRecord(null)}></Button>} onClose={() => setPreviewRecord(null)} open={Boolean(previewRecord)} title={<div className="template-modal-title"><h2></h2><p>{previewRecord?.phone}</p></div>}>
{previewRecord ? (
<div className="mms-preview">
<img alt={previewRecord.title} src={previewRecord.image} />
<h3>{previewRecord.title}</h3>
<p>{previewRecord.content}</p>
</div>
) : null}
</Modal>
</section>
);
}
-270
View File
@@ -1,270 +0,0 @@
import { useMemo, useState } from 'react';
import { Download, FileImage, FileText, ImageIcon, Plus, Send, Trash2, Upload } from 'lucide-react';
import { Button, DateTimeInput, Input, Select, Tag } from '@/components/ui';
type SendMode = 'now' | 'scheduled';
type ReceiverMode = 'manual' | 'import';
type Recipient = {
id: string;
phone: string;
};
const mmsApplications = [
{ label: '营销活动彩信', value: 'marketing' },
{ label: '会员运营彩信', value: 'member' },
{ label: '客户关怀彩信', value: 'care' },
];
const mmsSignatures = [
{ label: '【活动推广】', value: 'promo' },
{ label: '【优品发布】', value: 'product' },
{ label: '【周年庆典】', value: 'anniversary' },
];
const mmsTemplates = [
{
label: '春节祝福',
value: 'spring',
title: '新春佳节,福气满满',
content: '尊敬的客户,新春佳节来临之际,优品商城全体员工祝您新春快乐、万事如意!',
},
{
label: '新品发布',
value: 'product',
title: '重磅新品震撼来袭',
content: '优品商城倾力推出全新智能手表款高颜值,性能强!限时特惠价299元。',
},
{
label: '节日问候',
value: 'festival',
title: '中秋团圆,月满人圆',
content: '月圆中秋,情满人间。优品商城全体员工祝您中秋快乐,阖家团圆!',
},
];
export function ClientMmsSendPage() {
const [taskName, setTaskName] = useState('');
const [applicationId, setApplicationId] = useState('');
const [signatureId, setSignatureId] = useState('');
const [templateId, setTemplateId] = useState('');
const [sendMode, setSendMode] = useState<SendMode>('now');
const [scheduledAt, setScheduledAt] = useState('');
const [receiverMode, setReceiverMode] = useState<ReceiverMode>('manual');
const [recipients, setRecipients] = useState<Recipient[]>([{ id: '1', phone: '' }]);
const [submitted, setSubmitted] = useState(false);
const selectedSignature = useMemo(
() => mmsSignatures.find((item) => item.value === signatureId),
[signatureId],
);
const selectedTemplate = useMemo(
() => mmsTemplates.find((item) => item.value === templateId),
[templateId],
);
const validRecipients = recipients.filter((item) => item.phone.trim());
const previewTitle = selectedTemplate?.title ?? '请选择签名和模板';
const previewText = selectedSignature && selectedTemplate
? `${selectedSignature.label}${selectedTemplate.content}`
: '请选择签名和模板';
const wordCount = previewText.length;
const canSubmit = Boolean(taskName && applicationId && signatureId && templateId && (receiverMode === 'import' || validRecipients.length > 0) && (sendMode === 'now' || scheduledAt));
function updateRecipient(id: string, phone: string) {
setRecipients((items) => items.map((item) => (item.id === id ? { ...item, phone } : item)));
}
function addRecipient() {
setRecipients((items) => [...items, { id: Date.now().toString(), phone: '' }]);
}
function removeRecipient(id: string) {
setRecipients((items) => (items.length === 1 ? items : items.filter((item) => item.id !== id)));
}
function submitTask() {
if (!canSubmit) {
return;
}
setSubmitted(true);
}
return (
<section className="sms-send-page mms-send-page">
<div className="sms-send-title">
<span className="sms-send-title__icon">
<FileImage size={22} />
</span>
<h1></h1>
{submitted ? <Tag tone="success"></Tag> : null}
</div>
<div className="sms-send-layout">
<div className="sms-send-main">
<section className="send-card">
<div className="send-card__title">
<span>1</span>
<h2></h2>
</div>
<Input
label="任务名称"
onChange={(event) => setTaskName(event.target.value)}
placeholder="请输入任务名称,便于后续查找和管理"
value={taskName}
/>
<div className="send-form-row">
<Select
label="彩信应用"
onChange={(event) => setApplicationId(event.target.value)}
options={[{ label: '选择应用', value: '' }, ...mmsApplications]}
value={applicationId}
/>
<Select
label="彩信签名"
onChange={(event) => setSignatureId(event.target.value)}
options={[{ label: '选择签名', value: '' }, ...mmsSignatures]}
value={signatureId}
/>
<Select
label="彩信模板"
onChange={(event) => setTemplateId(event.target.value)}
options={[{ label: '选择模板', value: '' }, ...mmsTemplates.map(({ label, value }) => ({ label, value }))]}
value={templateId}
/>
</div>
</section>
<section className="send-card">
<div className="send-card__title">
<span>2</span>
<h2></h2>
</div>
<div className="radio-row">
<label>
<input checked={sendMode === 'now'} onChange={() => setSendMode('now')} type="radio" />
<span></span>
</label>
<label>
<input checked={sendMode === 'scheduled'} onChange={() => setSendMode('scheduled')} type="radio" />
<span></span>
</label>
{sendMode === 'scheduled' ? (
<DateTimeInput onChange={setScheduledAt} value={scheduledAt} />
) : null}
</div>
</section>
<section className="send-card">
<div className="send-card__title">
<span>3</span>
<h2></h2>
</div>
<div className="receiver-tabs">
<button
className={receiverMode === 'manual' ? 'active' : ''}
onClick={() => setReceiverMode('manual')}
type="button"
>
</button>
<button
className={receiverMode === 'import' ? 'active' : ''}
onClick={() => setReceiverMode('import')}
type="button"
>
</button>
</div>
{receiverMode === 'manual' ? (
<>
<div className="mms-send-tip">💡 </div>
<div className="receiver-table">
<div className="receiver-table__head">
<span></span>
<span></span>
<span></span>
</div>
{recipients.map((item, index) => (
<div className="receiver-table__row" key={item.id}>
<span>{index + 1}</span>
<input
inputMode="tel"
onChange={(event) => updateRecipient(item.id, event.target.value)}
placeholder="请输入手机号"
value={item.phone}
/>
<button
aria-label="删除接收人"
disabled={recipients.length === 1}
onClick={() => removeRecipient(item.id)}
type="button"
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
<button className="add-recipient" onClick={addRecipient} type="button">
<Plus size={16} />
</button>
</>
) : (
<div className="mms-import-mode">
<div className="mms-import-rules">
<div>
<strong>📋 </strong>
<p></p>
<p> .xlsx .csv </p>
<p></p>
<p></p>
</div>
<Button icon={<Download size={16} />}></Button>
</div>
<div className="import-panel mms-import-panel">
<div className="import-panel__icon">
<Upload size={28} />
</div>
<strong></strong>
<span> .xlsx.csv </span>
</div>
</div>
)}
</section>
<div className="send-submit-row">
<Button disabled={!canSubmit} icon={<Send size={18} />} onClick={submitTask}>
</Button>
</div>
</div>
<aside className="sms-preview-card mms-preview-card">
<div className="preview-title">
<FileImage size={19} />
<h2></h2>
</div>
<div className="mms-message-preview">
<div>
<strong>{previewTitle}</strong>
<p>{previewText}</p>
</div>
</div>
<div className="preview-stats">
<div>
<span></span>
<strong>{wordCount}</strong>
</div>
<div>
<span></span>
<strong>¥0.30/</strong>
</div>
</div>
<div className="preview-note">💡 0.30/</div>
</aside>
</div>
</section>
);
}
@@ -1,263 +0,0 @@
import { useMemo, useState } from 'react';
import { Edit3, FilePenLine, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
import { Button, Input, Modal, Select, Table, Tag } from '@/components/ui';
import type { TableColumn } from '@/components/ui/Table';
type ReportStatus = 'approved' | 'pending' | 'rejected' | 'waiting';
type MmsSignature = {
id: string;
name: string;
application: string;
mobile: ReportStatus;
unicom: ReportStatus;
telecom: ReportStatus;
editable: boolean;
};
const statusLabelMap: Record<ReportStatus, string> = {
approved: '已通过',
pending: '审核中',
rejected: '已驳回',
waiting: '待报备',
};
const statusToneMap: Record<ReportStatus, 'success' | 'info' | 'danger' | 'neutral'> = {
approved: 'success',
pending: 'info',
rejected: 'danger',
waiting: 'neutral',
};
const initialSignatures: MmsSignature[] = [
{
id: 'mms-sig-1',
name: '【科技公司】',
application: '营销推广平台',
mobile: 'approved',
unicom: 'approved',
telecom: 'approved',
editable: false,
},
{
id: 'mms-sig-2',
name: '【客户服务】',
application: '客户服务系统',
mobile: 'approved',
unicom: 'pending',
telecom: 'approved',
editable: true,
},
{
id: 'mms-sig-3',
name: '【验证码】',
application: '安全验证平台',
mobile: 'waiting',
unicom: 'waiting',
telecom: 'waiting',
editable: true,
},
{
id: 'mms-sig-4',
name: '【促销活动】',
application: '电商平台',
mobile: 'rejected',
unicom: 'approved',
telecom: 'pending',
editable: true,
},
{
id: 'mms-sig-5',
name: '【会员中心】',
application: '会员管理系统',
mobile: 'pending',
unicom: 'pending',
telecom: 'pending',
editable: true,
},
];
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 CarrierStatusTag({ status }: { status: ReportStatus }) {
return <Tag tone={statusToneMap[status]}>{statusLabelMap[status]}</Tag>;
}
function MmsSignatureForm({ signature }: { signature?: MmsSignature }) {
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
defaultValue={signature ? 'company' : ''}
label="* 签名依据"
options={[
{ label: '请选择签名依据', value: '' },
{ label: '企事业单位证明', value: 'company' },
{ label: '商标注册证', value: 'trademark' },
{ label: '授权委托书', value: 'authorization' },
]}
/>
<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>
);
}
export function ClientMmsSignatureReportPage() {
const [signatures, setSignatures] = useState(initialSignatures);
const [keyword, setKeyword] = useState('');
const [modalState, setModalState] = useState<{ mode: 'add' | 'edit'; signature?: MmsSignature } | 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));
}
const columns = useMemo<Array<TableColumn<MmsSignature>>>(() => [
{
key: 'name',
title: '签名',
render: (record) => <strong className="mms-signature-name">{record.name}</strong>,
},
{
key: 'application',
title: '所属应用',
render: (record) => <span className="muted">{record.application}</span>,
},
{
key: 'mobile',
title: '移动',
render: (record) => <CarrierStatusTag status={record.mobile} />,
},
{
key: 'unicom',
title: '联通',
render: (record) => <CarrierStatusTag status={record.unicom} />,
},
{
key: 'telecom',
title: '电信',
render: (record) => <CarrierStatusTag status={record.telecom} />,
},
{
key: 'actions',
title: '操作',
width: '220px',
render: (record) => (
<div className="mms-signature-actions">
<Button
disabled={!record.editable}
icon={<Edit3 size={16} />}
onClick={() => setModalState({ mode: 'edit', signature: record })}
size="sm"
variant="ghost"
>
</Button>
<Button
className="mms-signature-delete"
icon={<Trash2 size={16} />}
onClick={() => deleteSignature(record.id)}
size="sm"
variant="ghost"
>
</Button>
</div>
),
},
], []);
return (
<section className="page-stack">
<div className="signature-page-header">
<div className="sms-send-title">
<span className="sms-send-title__icon">
<FilePenLine size={22} />
</span>
<h1></h1>
</div>
<Button icon={<Plus size={17} />} onClick={() => setModalState({ mode: 'add' })}></Button>
</div>
<div className="signature-search-row">
<Input
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索签名名称或应用"
prefix={<Search size={17} />}
value={keyword}
/>
</div>
<div className="mms-signature-table-card">
<Table columns={columns} data={filteredSignatures} rowKey="id" />
</div>
<div className="mms-pagination">
<button disabled type="button">&lt;</button>
<button className="active" type="button">1</button>
<button type="button">2</button>
<button type="button">&gt;</button>
</div>
<Modal
footer={
<>
<Button variant="ghost" onClick={() => setModalState(null)}></Button>
<Button onClick={() => setModalState(null)}></Button>
</>
}
onClose={() => setModalState(null)}
open={Boolean(modalState)}
size="xl"
title={<div className="signature-modal-title"><h2>{modalState?.mode === 'edit' ? '编辑签名' : '添加签名'}</h2><p>{modalState?.mode === 'edit' ? '修改彩信签名的相关信息' : '新增彩信签名的相关信息'}</p></div>}
>
<MmsSignatureForm signature={modalState?.signature} />
</Modal>
</section>
);
}
-467
View File
@@ -1,467 +0,0 @@
import { useMemo, useState } from 'react';
import { Eye, FileText, ImageIcon, Music, Plus, Search, Trash2, Video } from 'lucide-react';
import { Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
type CarrierStatus = 'approved' | 'pending' | 'rejected';
type TemplateAccent = 'green' | 'blue' | 'gray';
type FrameType = 'text' | 'image' | 'video' | 'audio';
type MmsFrame = {
id: string;
type: FrameType;
text?: string;
};
type MmsTemplate = {
id: string;
name: string;
code: string;
application: string;
signature: string;
title: string;
image: string;
content: string;
mobile: CarrierStatus;
unicom: CarrierStatus;
telecom: CarrierStatus;
updatedAt: string;
accent: TemplateAccent;
frames: MmsFrame[];
};
const statusLabelMap: Record<CarrierStatus, string> = {
approved: '已通过',
pending: '审核中',
rejected: '已驳回',
};
const statusToneMap: Record<CarrierStatus, 'success' | 'info' | 'danger'> = {
approved: 'success',
pending: 'info',
rejected: 'danger',
};
const frameTypeOptions = [
{ label: '文字', value: 'text' },
{ label: '图片', value: 'image' },
{ label: '视频', value: 'video' },
{ label: '音频', value: 'audio' },
];
const frameIconMap: Record<FrameType, typeof FileText> = {
text: FileText,
image: ImageIcon,
video: Video,
audio: Music,
};
const frameFormatMap: Record<FrameType, string> = {
text: '',
image: '支持格式:jpg, jpeg, png, gif',
video: '支持格式:mp4, mpg, 3gp, 3gpp',
audio: '支持格式:mp3, mpeg3',
};
const initialTemplates: MmsTemplate[] = [
{
id: 'mms-tpl-1',
name: '春节祝福',
code: 'MMS_1a2b3c4d5e6f',
application: '营销活动彩信',
signature: '【活动推广】',
title: '新春佳节,福气满满',
image: 'https://images.unsplash.com/photo-1519671482749-fd09be7ccebf?auto=format&fit=crop&w=900&q=80',
content: '【活动推广】尊敬的客户,新春佳节来临之际,优品商城全体员工祝您新春快乐、万事如意!点击查看精美贺卡和新春优惠活动详情。',
mobile: 'approved',
unicom: 'approved',
telecom: 'approved',
updatedAt: '2028-01-15 10:30:00',
accent: 'green',
frames: [
{ id: 'frame-1', type: 'text', text: '请输入文字内容' },
{ id: 'frame-2', type: 'image' },
],
},
{
id: 'mms-tpl-2',
name: '新品发布',
code: 'MMS_2b3c4d5e6f7a',
application: '营销活动彩信',
signature: '【优品发布】',
title: '重磅新品震撼来袭',
image: 'https://images.unsplash.com/photo-1434494878577-86c23bcb06b9?auto=format&fit=crop&w=900&q=80',
content: '【优品发布】优品商城倾力推出全新智能手表款高颜值,性能强!限时特惠价299元,前100名购买送蓝牙耳机一份。图片展示高端产品,点击立即抢购!',
mobile: 'approved',
unicom: 'approved',
telecom: 'pending',
updatedAt: '2028-01-16 14:20:00',
accent: 'blue',
frames: [
{ id: 'frame-3', type: 'text', text: '新品发布文案' },
{ id: 'frame-4', type: 'image' },
{ id: 'frame-5', type: 'video' },
],
},
{
id: 'mms-tpl-3',
name: '会员专享',
code: 'MMS_3c4d5e6f7a8b',
application: '会员运营彩信',
signature: '【节日特惠】',
title: '会员专属优惠来了',
image: 'https://images.unsplash.com/photo-1567427017947-545c5f8d16ad?auto=format&fit=crop&w=900&q=80',
content: '【节日特惠】尊享的黄金会员,您享受一波50%的特价惊喜购!本月专享活动仅开放,精选热门产品5折主打优惠,立即购买,先到先得!',
mobile: 'pending',
unicom: 'pending',
telecom: 'pending',
updatedAt: '2028-01-14 09:15:00',
accent: 'gray',
frames: [
{ id: 'frame-6', type: 'image' },
{ id: 'frame-7', type: 'text', text: '会员专享优惠说明' },
],
},
{
id: 'mms-tpl-4',
name: '促销活动',
code: 'MMS_4d5e6f7a8b9c',
application: '营销活动彩信',
signature: '【限时特惠】',
title: '限时抢购,低至3折',
image: 'https://images.unsplash.com/photo-1607083206968-13611e3d76db?auto=format&fit=crop&w=900&q=80',
content: '【限时特惠】优品商城年中大促火热进行中!全场3折起,满299减50,满599减120。精选商品限时抢购,数量有限,先到先得!',
mobile: 'approved',
unicom: 'approved',
telecom: 'approved',
updatedAt: '2028-01-13 16:45:00',
accent: 'green',
frames: [
{ id: 'frame-8', type: 'text', text: '促销活动介绍' },
{ id: 'frame-9', type: 'image' },
],
},
{
id: 'mms-tpl-5',
name: '节日问候',
code: 'MMS_5e6f7a8b9c0d',
application: '客户关怀彩信',
signature: '【节日祝福】',
title: '中秋团圆,月满人圆',
image: 'https://images.unsplash.com/photo-1600861194942-f883de0dfe96?auto=format&fit=crop&w=900&q=80',
content: '【节日祝福】月圆中秋,情满人间。优品商城全体员工祝您中秋快乐,阖家团圆!精选月饼礼盒8折优惠,送礼佳品,立即选购!',
mobile: 'approved',
unicom: 'pending',
telecom: 'approved',
updatedAt: '2028-01-12 11:00:00',
accent: 'blue',
frames: [
{ id: 'frame-10', type: 'image' },
{ id: 'frame-11', type: 'audio' },
],
},
{
id: 'mms-tpl-6',
name: '品牌活动',
code: 'MMS_6f7a8b9c0d1e',
application: '品牌运营彩信',
signature: '【周年庆典】',
title: '周年庆典,感恩回馈',
image: 'https://images.unsplash.com/photo-1464349095431-e9a21285b5f3?auto=format&fit=crop&w=900&q=80',
content: '【周年庆典】优品商城5周年庆典,感恩回馈!全场满减,买一送一,更有神秘大奖等你来拿。参与互动赢取千元购物卡,机会难得!',
mobile: 'approved',
unicom: 'approved',
telecom: 'approved',
updatedAt: '2028-01-11 15:30:00',
accent: 'green',
frames: [
{ id: 'frame-12', type: 'video' },
{ id: 'frame-13', type: 'text', text: '品牌周年庆介绍' },
],
},
];
function FrameEditor({
frame,
index,
onRemove,
onTypeChange,
}: {
frame: MmsFrame;
index: number;
onRemove: () => void;
onTypeChange: (type: FrameType) => void;
}) {
const Icon = frameIconMap[frame.type];
return (
<div className="mms-frame">
<div className="mms-frame__top">
<strong> {index + 1} </strong>
<Select
className="mms-frame-type"
onChange={(event) => onTypeChange(event.target.value as FrameType)}
options={frameTypeOptions}
value={frame.type}
/>
<button aria-label="删除帧" onClick={onRemove} type="button">
<Trash2 size={18} />
</button>
</div>
{frame.type === 'text' ? (
<Textarea placeholder="请输入文字内容" defaultValue={frame.text} />
) : (
<div className="mms-file-drop">
<Icon size={22} />
<div>
<strong></strong>
<span></span>
</div>
<small>{frameFormatMap[frame.type]}</small>
</div>
)}
</div>
);
}
function MmsTemplateModal({
mode,
template,
onClose,
}: {
mode: 'create' | 'edit';
template?: MmsTemplate;
onClose: () => void;
}) {
const [previewOpen, setPreviewOpen] = useState(false);
const [frames, setFrames] = useState<MmsFrame[]>(template?.frames ?? [
{ id: 'new-frame-1', type: 'text' },
{ id: 'new-frame-2', type: 'text' },
]);
const totalSize = useMemo(() => {
const textSize = frames.filter((frame) => frame.type === 'text').length * 0.2;
const mediaSize = frames.filter((frame) => frame.type !== 'text').length * 180;
return Math.min(2000, textSize + mediaSize).toFixed(1);
}, [frames]);
function addFrame() {
if (frames.length >= 9) {
return;
}
setFrames((items) => [...items, { id: `new-frame-${Date.now()}`, type: 'text' }]);
}
function removeFrame(id: string) {
setFrames((items) => items.filter((item) => item.id !== id));
}
function changeFrameType(id: string, type: FrameType) {
setFrames((items) => items.map((item) => (item.id === id ? { ...item, type } : item)));
}
return (
<Modal
footer={
<>
<Button variant="secondary" onClick={() => setPreviewOpen(true)}></Button>
<Button variant="ghost" onClick={onClose}></Button>
<Button className="mms-save-button" onClick={onClose}></Button>
</>
}
onClose={onClose}
open
size="xl"
title={
<div className="mms-template-modal-title">
<h2>{mode === 'edit' ? '编辑彩信模板' : '创建彩信模板'}</h2>
<p>92000KB1-3</p>
</div>
}
>
<div className="mms-template-form">
<div className="mms-template-form-grid">
<Input label="彩信模板名称 *" defaultValue={template?.name ?? ''} placeholder="春节祝福" />
<Select
defaultValue={template?.application ?? '营销活动彩信'}
label="彩信应用 *"
options={[
{ label: '营销活动彩信', value: '营销活动彩信' },
{ label: '会员运营彩信', value: '会员运营彩信' },
{ label: '客户关怀彩信', value: '客户关怀彩信' },
]}
/>
</div>
<Select
defaultValue={template?.signature ?? '【活动推广】'}
label="签名 *"
options={[
{ label: '【活动推广】', value: '【活动推广】' },
{ label: '【优品发布】', value: '【优品发布】' },
{ label: '【周年庆典】', value: '【周年庆典】' },
]}
/>
<Input label="彩信标题 *" defaultValue={template?.title ?? ''} placeholder="新春佳节,福气满满" />
<div className="mms-frame-header">
<div>
<strong> *</strong>
<span>{frames.length}/9 使 {totalSize}KB/2000KB</span>
</div>
<Button icon={<Plus size={16} />} onClick={addFrame} variant="ghost"></Button>
</div>
<div className="mms-frame-list">
{frames.map((frame, index) => (
<FrameEditor
frame={frame}
index={index}
key={frame.id}
onRemove={() => removeFrame(frame.id)}
onTypeChange={(type) => changeFrameType(frame.id, type)}
/>
))}
</div>
</div>
<Modal
footer={<Button onClick={() => setPreviewOpen(false)}></Button>}
onClose={() => setPreviewOpen(false)}
open={previewOpen}
size="md"
title={<div className="template-modal-title"><h2></h2><p>{template?.name ?? '新建彩信模板'}</p></div>}
>
<div className="mms-preview">
{template?.image ? <img alt={template.name} src={template.image} /> : null}
<h3>{template?.title ?? '新春佳节,福气满满'}</h3>
<p>{template?.content ?? '这里展示当前彩信模板的文字、图片、视频或音频帧内容。保存前可先核对标题、签名和各帧顺序。'}</p>
<div className="mms-preview-frames">
{frames.map((frame, index) => {
const Icon = frameIconMap[frame.type];
return (
<span key={frame.id}>
<Icon size={15} />
{index + 1} · {frameTypeOptions.find((option) => option.value === frame.type)?.label}
</span>
);
})}
</div>
</div>
</Modal>
</Modal>
);
}
export function ClientMmsTemplatesPage() {
const [templates, setTemplates] = useState(initialTemplates);
const [keyword, setKeyword] = useState('');
const [modalState, setModalState] = useState<{ mode: 'create' | 'edit'; template?: MmsTemplate } | null>(null);
const [previewTemplate, setPreviewTemplate] = useState<MmsTemplate | null>(null);
const filteredTemplates = templates.filter((item) => (
item.name.includes(keyword) || item.application.includes(keyword) || item.title.includes(keyword)
));
function deleteTemplate(id: string) {
setTemplates((items) => items.filter((item) => item.id !== id));
}
return (
<section className="page-stack">
<div className="mms-template-header">
<div className="sms-send-title">
<span className="sms-send-title__icon">
<FileText size={22} />
</span>
<div>
<h1></h1>
<p> {templates.length} </p>
</div>
</div>
</div>
<div className="mms-template-toolbar">
<Input
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索模板名称或应用"
prefix={<Search size={17} />}
value={keyword}
/>
<Button icon={<Plus size={17} />} onClick={() => setModalState({ mode: 'create' })}></Button>
</div>
<div className="mms-template-grid">
{filteredTemplates.map((template) => (
<article className={`mms-template-card mms-template-card--${template.accent}`} key={template.id}>
<span className="mms-template-app-tag">{template.application}</span>
<div className="mms-template-card__body">
<div className="mms-template-meta">
<h2>{template.name}</h2>
<p className="mms-template-code">{template.code}</p>
<h3>{template.title}</h3>
</div>
<img alt={template.name} src={template.image} />
<p className="mms-template-content">{template.content}</p>
<div className="mms-template-status">
<span></span>
<div>
<section>
<small></small>
<Tag tone={statusToneMap[template.mobile]}>{statusLabelMap[template.mobile]}</Tag>
</section>
<section>
<small></small>
<Tag tone={statusToneMap[template.unicom]}>{statusLabelMap[template.unicom]}</Tag>
</section>
<section>
<small></small>
<Tag tone={statusToneMap[template.telecom]}>{statusLabelMap[template.telecom]}</Tag>
</section>
</div>
</div>
</div>
<footer className="mms-template-card__footer">
<span>{template.updatedAt}</span>
<div>
<button onClick={() => setPreviewTemplate(template)} type="button"><Eye size={17} /></button>
<button onClick={() => setModalState({ mode: 'edit', template })} type="button"></button>
<button onClick={() => deleteTemplate(template.id)} type="button"><Trash2 size={16} /></button>
</div>
</footer>
</article>
))}
</div>
<div className="mms-pagination">
<button disabled type="button">&lt;</button>
<button className="active" type="button">1</button>
<button type="button">2</button>
<button type="button">&gt;</button>
</div>
{modalState ? (
<MmsTemplateModal
mode={modalState.mode}
onClose={() => setModalState(null)}
template={modalState.template}
/>
) : null}
<Modal
footer={<Button onClick={() => setPreviewTemplate(null)}></Button>}
onClose={() => setPreviewTemplate(null)}
open={Boolean(previewTemplate)}
size="md"
title={<div className="template-modal-title"><h2></h2><p>{previewTemplate?.name}</p></div>}
>
{previewTemplate ? (
<div className="mms-preview">
<img alt={previewTemplate.name} src={previewTemplate.image} />
<h3>{previewTemplate.title}</h3>
<p>{previewTemplate.content}</p>
</div>
) : null}
</Modal>
</section>
);
}
@@ -1,178 +0,0 @@
import { useMemo, useState } from 'react';
import { Eye, FileImage, Search, Smartphone } from 'lucide-react';
import {
Button,
DateRangeInput,
DetailInfoGrid,
DetailSection,
Input,
Modal,
QueryPanel,
Table,
type DateRangeValue,
type TableColumn,
} from '@/components/ui';
type MmsUplinkMessage = {
id: string;
phone: string;
receivedAt: string;
content: string;
};
type MatchedMmsRecord = {
id: string;
sentAt: string;
applicationName: string;
title: string;
image: string;
content: string;
};
const uplinkMessages: MmsUplinkMessage[] = [
{ id: 'MMSMO20260316001', phone: '13500000888', receivedAt: '2026-03-16 10:27:10', content: 'R' },
{ id: 'MMSMO20260316002', phone: '13800138000', receivedAt: '2026-03-16 14:20:35', content: 'TD' },
{ id: 'MMSMO20260316003', phone: '13900139000', receivedAt: '2026-03-16 09:15:42', content: '查询活动' },
{ id: 'MMSMO20260316004', phone: '13700137000', receivedAt: '2026-03-16 10:05:18', content: 'R' },
{ id: 'MMSMO20260316005', phone: '13600136000', receivedAt: '2026-03-16 11:30:25', content: '退订' },
{ id: 'MMSMO20260316006', phone: '13400134000', receivedAt: '2026-03-16 13:45:10', content: '1' },
];
const matchedMmsRecords: MatchedMmsRecord[] = [
{
id: 'MMSD20260314001',
sentAt: '2026-03-14 12:25:28',
applicationName: '营销活动彩信',
title: '新春佳节,福气满满',
image: 'https://images.unsplash.com/photo-1519671482749-fd09be7ccebf?auto=format&fit=crop&w=900&q=80',
content: '【活动推广】尊敬的客户,新春佳节来临之际,优品商城全体员工祝您新春快乐、万事如意!点击查看精美贺卡和新春优惠活动详情。拒收请回复R',
},
{
id: 'MMSD20260315002',
sentAt: '2026-03-15 09:18:42',
applicationName: '会员服务彩信',
title: '会员专属优惠来了',
image: 'https://images.unsplash.com/photo-1567427017947-545c5f8d16ad?auto=format&fit=crop&w=900&q=80',
content: '【优品商城】尊敬的黄金会员,您享受一波50%的特价惊喜购!本月专享活动仅限开放,精选热门产品5折主打优惠。拒收请回复R',
},
];
function getDate(value: string) {
return value.slice(0, 10);
}
export function ClientMmsUplinkMessagesPage() {
const [phoneKeyword, setPhoneKeyword] = useState('');
const [contentKeyword, setContentKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [selectedMessage, setSelectedMessage] = useState<MmsUplinkMessage | null>(null);
const filteredMessages = uplinkMessages.filter((item) => {
const receivedDate = getDate(item.receivedAt);
const matchesPhone = !phoneKeyword || item.phone.includes(phoneKeyword);
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
const matchesStartDate = !dateRange.start || receivedDate >= dateRange.start;
const matchesEndDate = !dateRange.end || receivedDate <= dateRange.end;
return matchesPhone && matchesContent && matchesStartDate && matchesEndDate;
});
const columns = useMemo<Array<TableColumn<MmsUplinkMessage>>>(() => [
{ key: 'phone', title: '手机号码', width: '180px', render: (record) => <strong>{record.phone}</strong> },
{ key: 'receivedAt', title: '上行时间', width: '220px', render: (record) => <span className="muted">{record.receivedAt}</span> },
{ key: 'content', title: '上行内容', render: (record) => <span className="uplink-content">{record.content}</span> },
{
key: 'actions',
title: '操作',
width: '160px',
align: 'center',
render: (record) => (
<Button icon={<Eye size={15} />} onClick={() => setSelectedMessage(record)} size="sm" variant="ghost">
</Button>
),
},
], []);
return (
<section className="page-stack">
<div className="sms-send-title">
<span className="sms-send-title__icon"><FileImage size={22} /></span>
<h1></h1>
</div>
<QueryPanel title="查询条件" summary={<> <strong>{filteredMessages.length}</strong> </>}>
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} placeholder="输入手机号搜索" prefix={<Smartphone size={16} />} value={phoneKeyword} />
<DateRangeInput label="上行时间" onChange={setDateRange} value={dateRange} />
<Input label="上行内容" onChange={(event) => setContentKeyword(event.target.value)} placeholder="输入关键词搜索" prefix={<Search size={16} />} value={contentKeyword} />
</QueryPanel>
<div className="surface uplink-table-card">
<Table columns={columns} data={filteredMessages} emptyText="暂无上行彩信记录" rowKey="id" />
<div className="mms-uplink-pagination">
<span> {filteredMessages.length} </span>
<div>
<Button disabled size="sm" variant="secondary"></Button>
<Button disabled size="sm" variant="secondary"></Button>
</div>
</div>
</div>
<Modal
footer={<Button variant="ghost" onClick={() => setSelectedMessage(null)}></Button>}
onClose={() => setSelectedMessage(null)}
open={Boolean(selectedMessage)}
size="xl"
title="上行彩信详情"
>
{selectedMessage ? (
<div className="uplink-detail mms-uplink-detail">
<DetailSection title="上行信息">
<DetailInfoGrid
items={[
{ label: '手机号码', value: selectedMessage.phone },
{ label: '上行时间', value: selectedMessage.receivedAt },
{ label: '上行内容', value: selectedMessage.content, full: true },
]}
/>
</DetailSection>
<DetailSection title="匹配发送记录">
<p className="uplink-detail-hint">7</p>
<div className="uplink-match-list">
{matchedMmsRecords.map((record) => (
<article className="uplink-match-card mms-uplink-match-card" key={record.id}>
<div className="uplink-match-grid">
<div>
<span></span>
<strong>{record.sentAt}</strong>
</div>
<div>
<span></span>
<strong>{record.applicationName}</strong>
</div>
</div>
<div className="uplink-match-content">
<span></span>
<strong>{record.title}</strong>
</div>
<div className="mms-uplink-image">
<span></span>
<img alt={record.title} src={record.image} />
</div>
<div className="uplink-match-content">
<span></span>
<p>{record.content}</p>
</div>
<div className="uplink-match-actions">
<Button size="sm" variant="ghost"></Button>
</div>
</article>
))}
</div>
</DetailSection>
</div>
) : null}
</Modal>
</section>
);
}
+124 -229
View File
@@ -1,5 +1,6 @@
import { Fragment, useMemo, useState } from 'react';
import { Fragment, useEffect, useMemo, useState } from 'react';
import { FileText, Search, Smartphone } from 'lucide-react';
import { clientApi, type SmsMessageRecord } from '@/api/adminApi';
import {
DateRangeInput,
Input,
@@ -9,203 +10,95 @@ import {
type DateRangeValue,
} from '@/components/ui';
type SendStatus = 'success' | 'unknown' | 'failed';
type SmsSendDetail = {
id: string;
applicationName: string;
sentAt: string;
content: string;
wordCount: number;
billingCount: number;
phone: string;
carrier: '中国移动' | '中国联通' | '中国电信';
region: string;
status: SendStatus;
receipt: 'DELIVRD' | 'UNKNOWN' | 'UNDELIV';
receiptAt?: string;
};
const statusLabelMap: Record<SendStatus, string> = {
success: '成功',
const statusLabelMap: Record<string, string> = {
delivered: '成功',
queued: '排队中',
submitted: '已提交',
accepted: '已受理',
unknown: '未知',
failed: '失败',
rejected: '失败',
timeout: '超时',
};
const statusToneMap: Record<SendStatus, 'success' | 'info' | 'danger'> = {
success: 'success',
unknown: 'info',
const statusToneMap: Record<string, 'success' | 'info' | 'danger' | 'neutral'> = {
delivered: 'success',
queued: 'info',
submitted: 'info',
accepted: 'info',
unknown: 'neutral',
failed: 'danger',
rejected: 'danger',
timeout: 'danger',
};
const sendDetailRows: SmsSendDetail[] = [
{
id: 'SMSD20260316001',
applicationName: '营销推广平台',
sentAt: '2026-03-16 10:30:15',
content: '【启瑞物业】尊敬的业主,您本月物业费500元,请及时缴纳。感谢您的配合!',
wordCount: 45,
billingCount: 1,
phone: '13800138000',
carrier: '中国移动',
region: '北京市',
status: 'success',
receipt: 'DELIVRD',
receiptAt: '2026-03-16 10:30:18',
},
{
id: 'SMSD20260316002',
applicationName: '客服系统',
sentAt: '2026-03-16 10:32:20',
content: '【客服中心】尊敬的张先生,您已成功预约上门维修服务,时间:2026-03-18 14:00。',
wordCount: 48,
billingCount: 1,
phone: '13900139000',
carrier: '中国联通',
region: '上海市',
status: 'success',
receipt: 'DELIVRD',
receiptAt: '2026-03-16 10:32:25',
},
{
id: 'SMSD20260316003',
applicationName: '验证码服务',
sentAt: '2026-03-16 10:35:40',
content: '【验证码】您的验证码是123456,5分钟内有效,请勿泄露给他人。',
wordCount: 34,
billingCount: 1,
phone: '13700137000',
carrier: '中国电信',
region: '深圳市',
status: 'success',
receipt: 'DELIVRD',
receiptAt: '2026-03-16 10:35:43',
},
{
id: 'SMSD20260316004',
applicationName: '营销推广平台',
sentAt: '2026-03-16 10:38:10',
content: '【启瑞物业】您好!春季业主大会将于2026-03-20在小区会议室举行,欢迎参加。',
wordCount: 46,
billingCount: 1,
phone: '13600136000',
carrier: '中国移动',
region: '广州市',
status: 'unknown',
receipt: 'UNKNOWN',
},
{
id: 'SMSD20260316005',
applicationName: '验证码服务',
sentAt: '2026-03-16 10:40:30',
content: '【验证码】您的验证码是654321,5分钟内有效,请勿泄露给他人。',
wordCount: 34,
billingCount: 1,
phone: '13500135000',
carrier: '中国联通',
region: '杭州市',
status: 'success',
receipt: 'DELIVRD',
receiptAt: '2026-03-16 10:40:33',
},
{
id: 'SMSD20260316006',
applicationName: '订单通知系统',
sentAt: '2026-03-16 10:45:12',
content: '【订单通知】您的订单已发货,请留意物流信息。',
wordCount: 28,
billingCount: 1,
phone: '18800188000',
carrier: '中国电信',
region: '成都市',
status: 'failed',
receipt: 'UNDELIV',
},
{
id: 'SMSD20260316007',
applicationName: '营销推广平台',
sentAt: '2026-03-16 10:48:01',
content: '【启瑞物业】尊敬的客户,值此佳节之际,祝您节日快乐,阖家幸福。',
wordCount: 39,
billingCount: 1,
phone: '15900159000',
carrier: '中国移动',
region: '武汉市',
status: 'success',
receipt: 'DELIVRD',
receiptAt: '2026-03-16 10:48:06',
},
{
id: 'SMSD20260316008',
applicationName: '客服系统',
sentAt: '2026-03-16 10:51:36',
content: '【客服中心】您的服务工单已受理,工作人员将在24小时内联系您。',
wordCount: 36,
billingCount: 1,
phone: '15000150000',
carrier: '中国联通',
region: '南京市',
status: 'unknown',
receipt: 'UNKNOWN',
},
{
id: 'SMSD20260316009',
applicationName: '订单通知系统',
sentAt: '2026-03-16 10:55:44',
content: '【订单通知】您的退款申请已提交,预计1-3个工作日内到账。',
wordCount: 35,
billingCount: 1,
phone: '18900189000',
carrier: '中国电信',
region: '西安市',
status: 'success',
receipt: 'DELIVRD',
receiptAt: '2026-03-16 10:55:49',
},
{
id: 'SMSD20260316010',
applicationName: '验证码服务',
sentAt: '2026-03-16 10:59:18',
content: '【验证码】您的登录验证码为908172,请在5分钟内完成验证。',
wordCount: 33,
billingCount: 1,
phone: '13200132000',
carrier: '中国移动',
region: '重庆市',
status: 'success',
receipt: 'DELIVRD',
receiptAt: '2026-03-16 10:59:21',
},
];
const carrierLabelMap: Record<string, string> = {
mobile: '中国移动',
unicom: '中国联通',
telecom: '中国电信',
all: '三网',
};
function getDate(value: string) {
return value.slice(0, 10);
function getDate(value?: string | null) {
return value ? value.slice(0, 10) : '';
}
function getReceipt(record: SmsMessageRecord) {
const latest = record.receiptRecords?.[0] as { rawStatus?: string; receiptStatus?: string; deliveredAt?: string } | undefined;
return {
status: latest?.rawStatus ?? latest?.receiptStatus ?? record.receiptStatus ?? '-',
time: latest?.deliveredAt ?? record.deliveredAt,
};
}
export function ClientSendDetailPage() {
const [applicationName, setApplicationName] = useState('all');
const [records, setRecords] = useState<SmsMessageRecord[]>([]);
const [applicationId, setApplicationId] = useState('all');
const [status, setStatus] = useState('all');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [contentKeyword, setContentKeyword] = useState('');
const [phoneKeyword, setPhoneKeyword] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
function loadData() {
setLoading(true);
clientApi.listMessages({
applicationId: applicationId === 'all' ? undefined : applicationId,
phoneNumber: phoneKeyword || undefined,
status: status === 'all' ? undefined : status,
})
.then((items) => {
setRecords(items);
setError('');
})
.catch((reason: Error) => setError(reason.message || '短信发送详情加载失败'))
.finally(() => setLoading(false));
}
useEffect(() => {
loadData();
}, [applicationId, phoneKeyword, status]);
const applicationOptions = useMemo(() => {
const applications = Array.from(new Set(sendDetailRows.map((item) => item.applicationName)));
const applications = new Map<string, string>();
records.forEach((item) => {
if (item.applicationId) {
applications.set(item.applicationId, item.application?.name ?? item.applicationId);
}
});
return [
{ label: '全部应用', value: 'all' },
...applications.map((item) => ({ label: item, value: item })),
...Array.from(applications.entries()).map(([value, label]) => ({ label, value })),
];
}, []);
}, [records]);
const filteredRows = sendDetailRows.filter((item) => {
const matchesApplication = applicationName === 'all' || item.applicationName === applicationName;
const matchesStatus = status === 'all' || item.status === status;
const sentDate = getDate(item.sentAt);
const filteredRows = records.filter((item) => {
const sentDate = getDate(item.queuedAt);
const matchesStartDate = !dateRange.start || sentDate >= dateRange.start;
const matchesEndDate = !dateRange.end || sentDate <= dateRange.end;
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
const matchesPhone = !phoneKeyword || item.phone.includes(phoneKeyword);
return matchesApplication && matchesStatus && matchesStartDate && matchesEndDate && matchesContent && matchesPhone;
return matchesStartDate && matchesEndDate && matchesContent;
});
return (
@@ -221,14 +114,14 @@ export function ClientSendDetailPage() {
title="查询条件"
summary={<> <strong>{filteredRows.length}</strong> </>}
>
<Select label="应用名称" onChange={(event) => setApplicationName(event.target.value)} options={applicationOptions} value={applicationName} />
<Select label="应用名称" onChange={(event) => setApplicationId(event.target.value)} options={applicationOptions} value={applicationId} />
<DateRangeInput label="发送时间" onChange={setDateRange} value={dateRange} />
<Select
label="发送状态"
onChange={(event) => setStatus(event.target.value)}
options={[
{ label: '全部', value: 'all' },
{ label: '成功', value: 'success' },
{ label: '成功', value: 'delivered' },
{ label: '未知', value: 'unknown' },
{ label: '失败', value: 'failed' },
]}
@@ -250,72 +143,74 @@ export function ClientSendDetailPage() {
/>
</QueryPanel>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface send-detail-table-card">
<div className="ui-table-wrap">
<table className="ui-table send-detail-table">
<thead>
<tr>
<th style={{ width: '112px' }}></th>
<th style={{ width: '150px' }}></th>
<th style={{ width: '128px' }}></th>
<th style={{ width: '90px', textAlign: 'center' }}>/</th>
<th style={{ width: '120px', textAlign: 'center' }}>/</th>
<th style={{ width: '130px' }}></th>
<th style={{ width: '90px' }}></th>
<th style={{ width: '90px' }}></th>
<th style={{ width: '96px', textAlign: 'center' }}></th>
<th style={{ width: '95px', textAlign: 'center' }}></th>
<th style={{ width: '120px' }}></th>
<th style={{ width: '120px' }}></th>
<th style={{ width: '120px', textAlign: 'center' }}></th>
<th style={{ width: '120px', textAlign: 'center' }}></th>
<th style={{ width: '128px' }}></th>
</tr>
</thead>
<tbody>
{filteredRows.length === 0 ? (
<tr>
<td className="ui-table__empty" colSpan={9}></td>
</tr>
) : filteredRows.map((record) => (
<Fragment key={record.id}>
<tr className="send-detail-main-row">
<td><strong className="send-detail-app-name">{record.applicationName}</strong></td>
<td>
<span className="send-detail-time">
{record.sentAt.slice(0, 10)}
<small>{record.sentAt.slice(11)}</small>
</span>
</td>
<td style={{ textAlign: 'center' }}>
<span className="send-detail-count">
<strong>{record.wordCount}</strong>
<small>{record.billingCount}</small>
</span>
</td>
<td><strong>{record.phone}</strong></td>
<td><strong className="send-detail-carrier">{record.carrier}</strong></td>
<td>
<span className="send-detail-region">
{record.region.slice(0, 2)}
<small>{record.region.slice(2)}</small>
</span>
</td>
<td style={{ textAlign: 'center' }}><Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag></td>
<td style={{ textAlign: 'center' }}><strong className="send-detail-receipt-code">{record.receipt}</strong></td>
<td>
{record.receiptAt ? (
{loading ? (
<tr><td className="ui-table__empty" colSpan={9}>...</td></tr>
) : filteredRows.length === 0 ? (
<tr><td className="ui-table__empty" colSpan={9}></td></tr>
) : filteredRows.map((record) => {
const receipt = getReceipt(record);
const carrier = record.channel?.carrier ? carrierLabelMap[record.channel.carrier] ?? record.channel.carrier : '-';
const region = record.channel?.sendRegion ?? '-';
return (
<Fragment key={record.id}>
<tr className="send-detail-main-row">
<td><strong className="send-detail-app-name">{record.application?.name ?? record.applicationId ?? '-'}</strong></td>
<td>
<span className="send-detail-time">
{record.receiptAt.slice(0, 10)}
<small>{record.receiptAt.slice(11)}</small>
{record.queuedAt.slice(0, 10)}
<small>{record.queuedAt.slice(11, 19)}</small>
</span>
) : <span className="muted">-</span>}
</td>
</tr>
<tr className="send-detail-content-row">
<td colSpan={9}>
<div className="send-detail-content-block">
<span></span>
<p>{record.content}</p>
</div>
</td>
</tr>
</Fragment>
))}
</td>
<td style={{ textAlign: 'center' }}>
<span className="send-detail-count">
<strong>{[...record.content].length}</strong>
<small>{record.billingUnits}</small>
</span>
</td>
<td><strong>{record.phoneNumber}</strong></td>
<td><strong className="send-detail-carrier">{carrier}</strong></td>
<td><span className="send-detail-region">{region}</span></td>
<td style={{ textAlign: 'center' }}><Tag tone={statusToneMap[record.status] ?? 'info'}>{statusLabelMap[record.status] ?? record.status}</Tag></td>
<td style={{ textAlign: 'center' }}><strong className="send-detail-receipt-code">{receipt.status}</strong></td>
<td>
{receipt.time ? (
<span className="send-detail-time">
{receipt.time.slice(0, 10)}
<small>{receipt.time.slice(11, 19)}</small>
</span>
) : <span className="muted">-</span>}
</td>
</tr>
<tr className="send-detail-content-row">
<td colSpan={9}>
<div className="send-detail-content-block">
<span></span>
<p>{record.content}</p>
</div>
</td>
</tr>
</Fragment>
);
})}
</tbody>
</table>
</div>
+89 -15
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Check, FileText, Plus, Search, Send, Trash2 } from 'lucide-react';
import { Button, DateTimeInput, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type SmsBatchTask } from '@/api/adminApi';
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type ImportPreviewResponse, type SmsBatchTask } from '@/api/adminApi';
type Recipient = {
id: string;
@@ -27,6 +27,10 @@ export function ClientSendPage() {
const [scheduledAt, setScheduledAt] = useState('');
const [receiverMode, setReceiverMode] = useState<ReceiverMode>('manual');
const [recipients, setRecipients] = useState<Recipient[]>([{ id: '1', phone: '' }]);
const [importContent, setImportContent] = useState('');
const [importFileName, setImportFileName] = useState('');
const [importPreview, setImportPreview] = useState<ImportPreviewResponse | null>(null);
const [importLoading, setImportLoading] = useState(false);
const [submittedRecord, setSubmittedRecord] = useState<SmsBatchTask | null>(null);
useEffect(() => {
@@ -52,13 +56,16 @@ export function ClientSendPage() {
item.name.includes(templateKeyword) || item.content.includes(templateKeyword)
));
const validRecipients = recipients.filter((item) => item.phone.trim());
const importedValidCount = importPreview?.validCount ?? 0;
const receiverCount = receiverMode === 'manual' ? validRecipients.length : importedValidCount;
const previewText = selectedSignature && messageContent
? `${selectedSignature.name}${messageContent}`
: messageContent;
const wordCount = previewText.length;
const smsParts = wordCount > 0 ? Math.max(1, Math.ceil(wordCount / 70)) : 0;
const estimatedCount = validRecipients.length * smsParts;
const canSubmit = Boolean(taskName && applicationId && signatureId && templateId && validRecipients.length > 0 && (sendMode === 'now' || scheduledAt));
const estimatedCount = receiverCount * smsParts;
const requiredVariables = selectedTemplate?.variables?.map((item) => item.name) ?? [];
const canSubmit = Boolean(taskName && applicationId && signatureId && templateId && receiverCount > 0 && (sendMode === 'now' || scheduledAt));
function updateRecipient(id: string, phone: string) {
setRecipients((items) => items.map((item) => (item.id === id ? { ...item, phone } : item)));
@@ -84,15 +91,28 @@ export function ClientSendPage() {
return;
}
clientApi.createBatchTask({
applicationId,
templateId,
content: previewText,
category: selectedTemplate?.category ?? taskName,
phones: validRecipients.map((item) => item.phone.trim()),
sendMode: sendMode === 'now' ? 'immediate' : 'scheduled',
scheduledAt: sendMode === 'scheduled' ? scheduledAt : undefined,
})
const submitRequest = receiverMode === 'manual'
? clientApi.createBatchTask({
applicationId,
templateId,
content: previewText,
category: selectedTemplate?.category ?? taskName,
phones: validRecipients.map((item) => item.phone.trim()),
sendMode: sendMode === 'now' ? 'immediate' : 'scheduled',
scheduledAt: sendMode === 'scheduled' ? scheduledAt : undefined,
})
: clientApi.confirmImport({
applicationId,
templateId,
content: previewText,
category: selectedTemplate?.category ?? taskName,
importContent,
requiredVariables,
sendMode: sendMode === 'now' ? 'immediate' : 'scheduled',
scheduledAt: sendMode === 'scheduled' ? scheduledAt : undefined,
});
submitRequest
.then((task) => {
setSubmittedRecord(task);
setError('');
@@ -100,6 +120,30 @@ export function ClientSendPage() {
.catch((reason: Error) => setError(reason.message || '发送任务提交失败'));
}
async function previewImportFile(file: File) {
setImportLoading(true);
setError('');
try {
const content = await file.text();
const preview = await clientApi.previewImport({
content,
fileName: file.name,
delimiter: file.name.endsWith('.tsv') ? '\t' : ',',
requiredVariables,
});
setImportContent(content);
setImportFileName(file.name);
setImportPreview(preview);
} catch (reason) {
setImportContent('');
setImportFileName('');
setImportPreview(null);
setError(reason instanceof Error ? reason.message : '导入预览失败');
} finally {
setImportLoading(false);
}
}
return (
<section className="sms-send-page">
<div className="sms-send-title">
@@ -233,12 +277,42 @@ export function ClientSendPage() {
<FileText size={26} />
</div>
<strong></strong>
<span> .xlsx / .csv </span>
<Button variant="ghost"></Button>
<span> CSV / TSV / TXT </span>
<input
accept=".csv,.tsv,.txt,text/csv,text/plain"
id="sms-import-file"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
void previewImportFile(file);
}
event.currentTarget.value = '';
}}
style={{ display: 'none' }}
type="file"
/>
<Button disabled={importLoading || !templateId} onClick={() => document.getElementById('sms-import-file')?.click()} variant="ghost">
{importLoading ? '解析中...' : '选择文件'}
</Button>
{importFileName ? <span>{importFileName}</span> : null}
{importPreview ? (
<div className="detail-grid">
<div><span></span><strong>{importPreview.totalRows}</strong></div>
<div><span></span><strong>{importPreview.validCount}</strong></div>
<div><span></span><strong>{importPreview.errorCount}</strong></div>
<div><span></span><strong>{requiredVariables.length ? requiredVariables.join(', ') : '无必填变量'}</strong></div>
{importPreview.errors.length ? (
<div className="detail-grid__wide">
<span></span>
<strong>{importPreview.errors.slice(0, 5).map((item) => `${item.rowNumber}${item.phoneNumber ? ` ${item.phoneNumber}` : ''}${item.reason}`).join('')}</strong>
</div>
) : null}
</div>
) : null}
</div>
)}
<p className="send-tip"> {validRecipients.length} </p>
<p className="send-tip"> {receiverCount} </p>
</section>
<div className="send-submit-row">
-29
View File
@@ -1,29 +0,0 @@
import { Save } from 'lucide-react';
import { Button, Input } from '@/components/ui';
export function ClientSettingsPage() {
return (
<section className="page-stack">
<div className="page-heading">
<div>
<p className="eyebrow"></p>
<h1></h1>
</div>
</div>
<div className="surface content-grid">
<div className="form-grid">
<Input label="企业名称" defaultValue="上海云舟科技有限公司" />
<div className="form-grid form-grid--two">
<Input label="联系人" defaultValue="赵先生" />
<Input label="联系电话" defaultValue="13800000000" />
</div>
<Button icon={<Save size={16} />}></Button>
</div>
<aside className="soft-panel">
<h3></h3>
<p className="muted"></p>
</aside>
</div>
</section>
);
}
+2 -5
View File
@@ -54,12 +54,9 @@ export function ClientSignaturesPage() {
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,
const fileObject = await clientApi.uploadFileObject(file, {
purpose: 'signature_material',
prefix: `signature-materials/${signature.id}`,
});
await clientApi.createSignatureMaterial(signature.id, {
fileObjectId: fileObject.id,
+1 -1
View File
@@ -62,7 +62,7 @@ export function ClientSystemLogsPage() {
const columns = useMemo<Array<TableColumn<OperationLogItem>>>(() => [
{ key: 'time', title: '时间', width: '190px', render: (record) => <span className="muted">{new Date(record.time).toLocaleString('zh-CN')}</span> },
{ key: 'level', title: '级别', width: '110px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
{ key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
{ key: 'module', title: '模块', width: '150px', render: (record) => <strong>{record.module}</strong> },
{ key: 'operator', title: '操作人', width: '130px', render: (record) => <strong>{record.operator}</strong> },
{ key: 'action', title: '操作', width: '160px', render: (record) => <strong>{record.action}</strong> },
+198 -51
View File
@@ -1,7 +1,22 @@
import { useEffect, useMemo, useState } from 'react';
import { MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
import { Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsTemplate } from '@/api/adminApi';
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate } from '@/api/adminApi';
type TemplateVariable = {
name: string;
example?: string;
required?: boolean;
};
type TemplateFormState = {
applicationId: string;
signatureId: string;
name: string;
category: string;
content: string;
variables: TemplateVariable[];
};
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
approved: 'success',
@@ -18,27 +33,161 @@ const statusLabel: Record<string, string> = {
disabled: '已禁用',
};
function extractVariables(content: string) {
return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1])));
const recommendedVariables = [
['验证码', 'code'],
['手机号', 'phone'],
['姓名', 'name'],
['日期', 'date'],
['金额', 'amount'],
['时间', 'time'],
['余额', 'balance'],
['地址', 'address'],
['快递单号', 'trackingNumber'],
['链接', 'link'],
];
function extractVariables(content: string): TemplateVariable[] {
return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1])))
.map((name) => ({ name, required: true }));
}
function billingUnits(content: string) {
if (!content) return 1;
return content.length <= 70 ? 1 : Math.ceil(content.length / 67);
}
function TemplateModal({
applications,
item,
onClose,
onSubmit,
signatures,
}: {
applications: ClientSmsApplication[];
item?: ClientSmsTemplate;
onClose: () => void;
onSubmit: (state: TemplateFormState) => void;
signatures: ClientSmsSignature[];
}) {
const [customVariable, setCustomVariable] = useState('');
const [variablesOpen, setVariablesOpen] = useState(false);
const [form, setForm] = useState<TemplateFormState>({
applicationId: item?.applicationId ?? '',
signatureId: item?.signatureId ?? '',
name: item?.name ?? '',
category: item?.category ?? '行业通知',
content: item?.content ?? '',
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
});
const application = applications.find((candidate) => candidate.id === form.applicationId);
const availableSignatures = signatures.filter((signature) => (
signature.auditStatus === 'approved'
&& (!application || signature.tenantId === application.tenantId)
&& (!signature.applicationId || signature.applicationId === form.applicationId)
));
const variables = form.variables.length ? form.variables : extractVariables(form.content);
function update<Key extends keyof TemplateFormState>(key: Key, value: TemplateFormState[Key]) {
setForm((current) => ({ ...current, [key]: value }));
}
function setContent(content: string) {
setForm((current) => ({ ...current, content, variables: extractVariables(content) }));
}
function insertVariable(name: string) {
const normalized = name.trim();
if (!normalized) return;
setContent(`${form.content}\${${normalized}}`);
}
function updateVariableExample(name: string, example: string) {
update('variables', variables.map((variable) => variable.name === name ? { ...variable, example } : variable));
}
return (
<Modal
footer={(
<>
<Button onClick={onClose} variant="ghost"></Button>
<Button disabled={!form.applicationId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables })}></Button>
</>
)}
onClose={onClose}
open
size="xl"
title={item ? '编辑短信模板' : '添加短信模板'}
>
<div className="template-form">
<Select
label="短信应用"
onChange={(event) => update('applicationId', event.target.value)}
options={[{ label: '请选择应用', value: '' }, ...applications.map((app) => ({ label: app.name, value: app.id }))]}
value={form.applicationId}
/>
<Select
label="短信签名"
onChange={(event) => update('signatureId', event.target.value)}
options={[{ label: '不绑定签名', value: '' }, ...availableSignatures.map((signature) => ({ label: signature.name, value: signature.id }))]}
value={form.signatureId}
/>
<Input label="模板名称" onChange={(event) => update('name', event.target.value)} placeholder="请输入模板名称" value={form.name} />
<Input label="模板分类" onChange={(event) => update('category', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.category} />
<Textarea label="模板内容" onChange={(event) => setContent(event.target.value)} placeholder="变量格式:${code}" rows={6} value={form.content} />
<div className="template-form-meta">
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
</button>
<span>{form.content.length} {billingUnits(form.content)} </span>
</div>
{variablesOpen ? (
<div className="template-variable-panel">
<h3></h3>
<div className="template-variable-buttons">
{recommendedVariables.map(([label, value]) => (
<button key={value} onClick={() => insertVariable(value)} type="button">{label} ({value})</button>
))}
</div>
<h3></h3>
<div className="template-custom-variable">
<Input onChange={(event) => setCustomVariable(event.target.value)} placeholder="英文字符或数字" value={customVariable} />
<Button onClick={() => { insertVariable(customVariable); setCustomVariable(''); }}></Button>
</div>
</div>
) : null}
<div className="template-variable-panel">
<h3></h3>
{variables.length ? variables.map((variable) => (
<Input
key={variable.name}
label={`\${${variable.name}}`}
onChange={(event) => updateVariableExample(variable.name, event.target.value)}
placeholder="请输入变量示例值"
value={variable.example ?? ''}
/>
)) : <p className="muted"></p>}
</div>
</div>
</Modal>
);
}
export function ClientTemplatesPage() {
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [keyword, setKeyword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [applicationId, setApplicationId] = useState('');
const [name, setName] = useState('');
const [content, setContent] = useState('');
const [modalTemplate, setModalTemplate] = useState<ClientSmsTemplate | 'new' | null>(null);
function loadData() {
setLoading(true);
Promise.all([clientApi.listApplications(), clientApi.listTemplates()])
.then(([applicationItems, templateItems]) => {
Promise.all([clientApi.listApplications(), clientApi.listTemplates(), clientApi.listSignatures()])
.then(([applicationItems, templateItems, signatureItems]) => {
setApplications(applicationItems.filter((item) => item.status === 'active'));
setTemplates(templateItems.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'));
setSignatures(signatureItems);
setError('');
})
.catch((reason: Error) => setError(reason.message || '短信模板加载失败'))
@@ -50,21 +199,29 @@ export function ClientTemplatesPage() {
}, []);
const filteredTemplates = useMemo(() => templates.filter((item) => (
!keyword || [item.name, item.content, item.application?.name].join(' ').includes(keyword)
!keyword || [item.name, item.content, item.application?.name, item.signature?.name].join(' ').includes(keyword)
)), [keyword, templates]);
function createTemplate() {
const variables = extractVariables(content).map((variable) => ({ name: variable, required: true }));
clientApi.createTemplate({ applicationId, name, content, variables })
.then((created) => clientApi.submitTemplate(created.id))
.then(() => {
setModalOpen(false);
setApplicationId('');
setName('');
setContent('');
loadData();
})
.catch((reason: Error) => setError(reason.message || '模板提交失败'));
async function saveTemplate(state: TemplateFormState) {
const existing = modalTemplate && modalTemplate !== 'new' ? modalTemplate : null;
try {
const payload = {
applicationId: state.applicationId,
signatureId: state.signatureId || undefined,
name: state.name,
content: state.content,
category: state.category,
variables: state.variables,
};
const template = existing
? await clientApi.updateTemplate(existing.id, { ...payload, signatureId: state.signatureId || null })
: await clientApi.createTemplate(payload);
await clientApi.submitTemplate(template.id);
setModalTemplate(null);
loadData();
} catch (reason) {
setError(reason instanceof Error ? reason.message : '模板提交失败');
}
}
function disableTemplate(id: string) {
@@ -87,22 +244,22 @@ export function ClientTemplatesPage() {
<div className="template-toolbar">
<Input
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索模板名称、应用或内容"
placeholder="搜索模板名称、应用、签名或内容"
prefix={<Search size={17} />}
value={keyword}
/>
<Button icon={<Plus size={17} />} onClick={() => setModalOpen(true)}></Button>
<Button icon={<Plus size={17} />} onClick={() => setModalTemplate('new')}></Button>
</div>
{loading ? <p className="muted">...</p> : null}
{error ? <p className="form-error">{error}</p> : null}
<div className="template-card-grid">
{filteredTemplates.map((template) => {
const variables = template.variables?.map((item) => item.name) ?? extractVariables(template.content);
const variables = template.variables?.map((item) => item.name) ?? extractVariables(template.content).map((item) => item.name);
return (
<article className="template-card template-card--green" key={template.id}>
<h2>{template.name}</h2>
<p className="muted">{template.application?.name ?? template.applicationId}</p>
<p className="muted">{template.application?.name ?? template.applicationId} / {template.signature?.name ?? '未绑定签名'}</p>
<Tag tone={statusTone[template.auditStatus] ?? 'info'}>{statusLabel[template.auditStatus] ?? template.auditStatus}</Tag>
<p className="template-content">{template.content}</p>
<div className="template-vars">
@@ -110,8 +267,12 @@ export function ClientTemplatesPage() {
{variables.length > 0 ? variables.map((item) => <strong key={item}>${`{${item}}`}</strong>) : <span className="muted"></span>}
</div>
<div className="template-card-footer">
<span>{template.updatedAt}</span>
<span>{new Date(template.updatedAt).toLocaleString('zh-CN')}</span>
<div>
<button onClick={() => setModalTemplate(template)} type="button">
<Edit3 size={14} />
</button>
<button onClick={() => disableTemplate(template.id)} type="button">
<Trash2 size={14} />
@@ -124,29 +285,15 @@ export function ClientTemplatesPage() {
</div>
{!loading && !error && filteredTemplates.length === 0 ? <p className="muted"></p> : null}
<Modal
footer={(
<>
<Button onClick={() => setModalOpen(false)} variant="ghost"></Button>
<Button disabled={!applicationId || !name || !content} onClick={createTemplate}></Button>
</>
)}
onClose={() => setModalOpen(false)}
open={modalOpen}
size="xl"
title="添加短信模板"
>
<div className="template-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} />
<Textarea label="模板内容" onChange={(event) => setContent(event.target.value)} placeholder="变量格式:${code}" rows={5} value={content} />
</div>
</Modal>
{modalTemplate ? (
<TemplateModal
applications={applications}
item={modalTemplate === 'new' ? undefined : modalTemplate}
onClose={() => setModalTemplate(null)}
onSubmit={(state) => { void saveTemplate(state); }}
signatures={signatures}
/>
) : null}
</section>
);
}
+84 -74
View File
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Eye, MessageSquareReply, Search, Smartphone } from 'lucide-react';
import { clientApi, type SmsMessageRecord, type SmsUplinkMessage } from '@/api/adminApi';
import {
Button,
DateRangeInput,
@@ -13,68 +14,69 @@ import {
type TableColumn,
} from '@/components/ui';
type UplinkMessage = {
id: string;
phone: string;
receivedAt: string;
content: string;
};
function getDate(value?: string | null) {
return value ? value.slice(0, 10) : '';
}
type MatchedSendRecord = {
id: string;
sentAt: string;
applicationName: string;
content: string;
};
const uplinkMessages: UplinkMessage[] = [
{ id: 'MO20260112001', phone: '13500000888', receivedAt: '2026-01-12 10:27:10', content: 'R' },
{ id: 'MO20260315001', phone: '13800138000', receivedAt: '2026-03-15 14:20:35', content: 'TD' },
{ id: 'MO20260316001', phone: '13900139000', receivedAt: '2026-03-16 09:15:42', content: '查询余额' },
{ id: 'MO20260316002', phone: '13700137000', receivedAt: '2026-03-16 10:05:18', content: 'R' },
{ id: 'MO20260316003', phone: '13600136000', receivedAt: '2026-03-16 11:30:25', content: '退订' },
{ id: 'MO20260316004', phone: '13400134000', receivedAt: '2026-03-16 13:45:10', content: '1' },
{ id: 'MO20260316005', phone: '13300133000', receivedAt: '2026-03-16 14:20:55', content: '取消预约' },
{ id: 'MO20260316006', phone: '13200132000', receivedAt: '2026-03-16 15:10:30', content: 'R' },
];
const matchedSendRecords: MatchedSendRecord[] = [
{
id: 'MT20260119001',
sentAt: '2026-01-19 12:25:28',
applicationName: 'XXX催收',
content: '【XXX科技】如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 拒收请回复R',
},
{
id: 'MT20260119002',
sentAt: '2026-01-19 12:25:28',
applicationName: 'XXX催收',
content: '【XXX科技】如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行',
},
];
function getDate(value: string) {
return value.slice(0, 10);
function getTime(value?: string | null) {
return value ? `${value.slice(0, 10)} ${value.slice(11, 19)}` : '-';
}
export function ClientUplinkMessagesPage() {
const [messages, setMessages] = useState<SmsUplinkMessage[]>([]);
const [matchedRecords, setMatchedRecords] = useState<SmsMessageRecord[]>([]);
const [phoneKeyword, setPhoneKeyword] = useState('');
const [contentKeyword, setContentKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [selectedMessage, setSelectedMessage] = useState<UplinkMessage | null>(null);
const [selectedMessage, setSelectedMessage] = useState<SmsUplinkMessage | null>(null);
const [loading, setLoading] = useState(true);
const [matching, setMatching] = useState(false);
const [error, setError] = useState('');
const [detailError, setDetailError] = useState('');
const filteredMessages = uplinkMessages.filter((item) => {
function loadData() {
setLoading(true);
clientApi.listUplinkMessages()
.then((items) => {
setMessages(items);
setError('');
})
.catch((reason: Error) => setError(reason.message || '上行短信加载失败'))
.finally(() => setLoading(false));
}
function openDetail(message: SmsUplinkMessage) {
setSelectedMessage(message);
setMatchedRecords([]);
setDetailError('');
if (!message.messageId) {
return;
}
setMatching(true);
clientApi.listMessages({ messageId: message.messageId })
.then((items) => setMatchedRecords(items))
.catch((reason: Error) => setDetailError(reason.message || '匹配发送记录加载失败'))
.finally(() => setMatching(false));
}
useEffect(() => {
loadData();
}, []);
const filteredMessages = messages.filter((item) => {
const receivedDate = getDate(item.receivedAt);
const matchesPhone = !phoneKeyword || item.phone.includes(phoneKeyword);
const matchesPhone = !phoneKeyword || item.phoneNumber.includes(phoneKeyword);
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
const matchesStartDate = !dateRange.start || receivedDate >= dateRange.start;
const matchesEndDate = !dateRange.end || receivedDate <= dateRange.end;
return matchesPhone && matchesContent && matchesStartDate && matchesEndDate;
});
const columns = useMemo<Array<TableColumn<UplinkMessage>>>(() => [
{ key: 'phone', title: '手机号码', width: '180px', render: (record) => <strong>{record.phone}</strong> },
{ key: 'receivedAt', title: '上行时间', width: '220px', render: (record) => <span className="muted">{record.receivedAt}</span> },
const columns = useMemo<Array<TableColumn<SmsUplinkMessage>>>(() => [
{ key: 'phoneNumber', title: '手机号码', width: '180px', render: (record) => <strong>{record.phoneNumber}</strong> },
{ key: 'receivedAt', title: '上行时间', width: '220px', render: (record) => <span className="muted">{getTime(record.receivedAt)}</span> },
{ key: 'content', title: '上行内容', render: (record) => <span className="uplink-content">{record.content}</span> },
{
key: 'actions',
@@ -82,7 +84,7 @@ export function ClientUplinkMessagesPage() {
width: '160px',
align: 'center',
render: (record) => (
<Button icon={<Eye size={15} />} onClick={() => setSelectedMessage(record)} size="sm" variant="ghost">
<Button icon={<Eye size={15} />} onClick={() => openDetail(record)} size="sm" variant="ghost">
</Button>
),
@@ -116,8 +118,10 @@ export function ClientUplinkMessagesPage() {
/>
</QueryPanel>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface uplink-table-card">
<Table columns={columns} data={filteredMessages} emptyText="暂无上行记录" rowKey="id" />
<Table columns={columns} data={loading ? [] : filteredMessages} emptyText={loading ? '正在加载真实上行记录...' : '暂无上行记录'} rowKey="id" />
</div>
<Modal
@@ -132,38 +136,44 @@ export function ClientUplinkMessagesPage() {
<DetailSection title="上行信息">
<DetailInfoGrid
items={[
{ label: '手机号码', value: selectedMessage.phone },
{ label: '上行时间', value: selectedMessage.receivedAt },
{ label: '手机号码', value: selectedMessage.phoneNumber },
{ label: '上行时间', value: getTime(selectedMessage.receivedAt) },
{ label: '接入号码', value: selectedMessage.destId },
{ label: '网关消息ID', value: selectedMessage.messageId || '-' },
{ label: '上行内容', value: selectedMessage.content, full: true },
]}
/>
</DetailSection>
<DetailSection title="匹配发送记录">
<p className="uplink-detail-hint">7</p>
<div className="uplink-match-list">
{matchedSendRecords.map((record) => (
<article className="uplink-match-card" key={record.id}>
<div className="uplink-match-grid">
<div>
<span></span>
<strong>{record.sentAt}</strong>
{matching ? <p className="uplink-detail-hint">...</p> : null}
{detailError ? <p className="form-error">{detailError}</p> : null}
{!matching && !selectedMessage.messageId ? <p className="uplink-detail-hint">ID</p> : null}
{!matching && selectedMessage.messageId && matchedRecords.length === 0 && !detailError ? (
<p className="uplink-detail-hint"></p>
) : null}
{matchedRecords.length > 0 ? (
<div className="uplink-match-list">
{matchedRecords.map((record) => (
<article className="uplink-match-card" key={record.id}>
<div className="uplink-match-grid">
<div>
<span></span>
<strong>{getTime(record.queuedAt)}</strong>
</div>
<div>
<span></span>
<strong>{record.application?.name ?? record.applicationId ?? '-'}</strong>
</div>
</div>
<div>
<span></span>
<strong>{record.applicationName}</strong>
<div className="uplink-match-content">
<span></span>
<p>{record.content}</p>
</div>
</div>
<div className="uplink-match-content">
<span></span>
<p>{record.content}</p>
</div>
<div className="uplink-match-actions">
<Button size="sm" variant="ghost"></Button>
</div>
</article>
))}
</div>
</article>
))}
</div>
) : null}
</DetailSection>
</div>
) : null}