feat: improve channel resilience and operations
This commit is contained in:
+12
-3
@@ -186,7 +186,7 @@ export type AdminChannel = {
|
||||
rateLimitPerSecond: number;
|
||||
unitPrice: number;
|
||||
status: string;
|
||||
config?: { desiredConnections?: number; windowSize?: number; extensionDigits?: number; [key: string]: unknown } | null;
|
||||
config?: { desiredConnections?: number; windowSize?: number; extensionDigits?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number; [key: string]: unknown } | null;
|
||||
connectionStates?: CmppConnectionState[];
|
||||
};
|
||||
|
||||
@@ -983,7 +983,9 @@ export type DailyReconciliationReport = {
|
||||
tenantName: string;
|
||||
applicationId: string;
|
||||
applicationName: string;
|
||||
submittedUnits: number;
|
||||
sentUnits: number;
|
||||
unknownUnits: number;
|
||||
successUnits: number;
|
||||
failedUnits: number;
|
||||
generatedAt: string;
|
||||
@@ -1000,7 +1002,9 @@ export type DailyProfitReport = {
|
||||
tenantName?: string | null;
|
||||
applicationId?: string | null;
|
||||
channelId?: string | null;
|
||||
submittedUnits: number;
|
||||
sentUnits: number;
|
||||
unknownUnits: number;
|
||||
successUnits: number;
|
||||
failedUnits: number;
|
||||
revenueCents: number;
|
||||
@@ -1024,7 +1028,9 @@ export type DailyQualityReport = {
|
||||
channelId?: string | null;
|
||||
signatureId?: string | null;
|
||||
drainageInfoId?: string | null;
|
||||
submittedUnits: number;
|
||||
sentUnits: number;
|
||||
unknownUnits: number;
|
||||
successUnits: number;
|
||||
failedUnits: number;
|
||||
successRateBps: number;
|
||||
@@ -1104,6 +1110,9 @@ export type CmppConnectionState = {
|
||||
lastDisconnectedAt?: string | null;
|
||||
lastHeartbeatAt?: string | null;
|
||||
reconnectCount: number;
|
||||
lastReconnectAttemptAt?: string | null;
|
||||
nextReconnectAt?: string | null;
|
||||
lastErrorCategory?: string | null;
|
||||
lastError?: string | null;
|
||||
updatedAt: string;
|
||||
channel?: AdminChannel;
|
||||
@@ -1403,9 +1412,9 @@ export const adminApi = {
|
||||
request<PagedResponse<DailyQualityReport> & { dimensionType: DailyQualityReport['dimensionType'] }>(withQuery('/admin/reports/quality', query)),
|
||||
exportQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string } = {}) =>
|
||||
requestBlob(withQuery('/admin/reports/quality/export', query)),
|
||||
createChannel: (body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number }) =>
|
||||
createChannel: (body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number }) =>
|
||||
request<AdminChannel>('/admin/channels', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateChannel: (id: string, body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number }) =>
|
||||
updateChannel: (id: string, body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number }) =>
|
||||
request<AdminChannel>(`/admin/channels/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
copyChannel: (id: string, body: { operatorId?: string } = {}) => request<AdminChannel>(`/admin/channels/${id}/copy`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -25,12 +25,15 @@ type SmsChannel = {
|
||||
failureCount: number;
|
||||
gatewayHost: string;
|
||||
gatewayPort: string;
|
||||
businessCode: string;
|
||||
corpCode: string;
|
||||
account: string;
|
||||
accessNo: string;
|
||||
cmppVersion: '2.0' | '3.0';
|
||||
desiredConnections: number;
|
||||
windowSize: number;
|
||||
heartbeatIntervalSeconds: number;
|
||||
heartbeatMissThreshold: number;
|
||||
extensionDigits: number;
|
||||
rateLimitPerSecond: number;
|
||||
passwordCipher?: string;
|
||||
@@ -83,12 +86,6 @@ const statusOptions = [
|
||||
{ label: '连接失败', value: 'failed' },
|
||||
];
|
||||
|
||||
const protocolOptions = [
|
||||
{ label: 'CMPP', value: 'CMPP' },
|
||||
{ label: 'HTTP', value: 'HTTP' },
|
||||
{ label: 'SGIP', value: 'SGIP' },
|
||||
];
|
||||
|
||||
const cmppVersionOptions = [
|
||||
{ label: 'CMPP 2.0', value: '2.0' },
|
||||
{ label: 'CMPP 3.0', value: '3.0' },
|
||||
@@ -161,12 +158,15 @@ function mapApiChannel(channel: AdminChannel, connections: CmppConnectionState[]
|
||||
failureCount: 0,
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: String(channel.gatewayPort),
|
||||
businessCode: String(channel.config?.serviceId ?? 'SMS'),
|
||||
corpCode: channel.enterpriseCode ?? channel.code,
|
||||
account: channel.account,
|
||||
accessNo: channel.srcId,
|
||||
cmppVersion: channel.cmppVersion === '3.0' ? '3.0' : '2.0',
|
||||
desiredConnections: Number(channel.config?.desiredConnections ?? 1),
|
||||
windowSize: Number(channel.config?.windowSize ?? 16),
|
||||
heartbeatIntervalSeconds: Number(channel.config?.heartbeatIntervalSeconds ?? 30),
|
||||
heartbeatMissThreshold: Number(channel.config?.heartbeatMissThreshold ?? 3),
|
||||
extensionDigits: Number(channel.config?.extensionDigits ?? 0),
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
};
|
||||
@@ -183,6 +183,7 @@ function buildChannelPayload(channel: SmsChannel, passwordCipher?: string) {
|
||||
sendRegion: channel.sendRegion,
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: Number(channel.gatewayPort),
|
||||
protocol: 'CMPP',
|
||||
enterpriseCode: channel.corpCode,
|
||||
account: channel.account,
|
||||
passwordCipher: passwordCipher || undefined,
|
||||
@@ -192,7 +193,9 @@ function buildChannelPayload(channel: SmsChannel, passwordCipher?: string) {
|
||||
unitPrice: Math.round(channel.unitPrice),
|
||||
desiredConnections: channel.desiredConnections,
|
||||
windowSize: channel.windowSize,
|
||||
config: { extensionDigits: channel.extensionDigits },
|
||||
heartbeatIntervalSeconds: channel.heartbeatIntervalSeconds,
|
||||
heartbeatMissThreshold: channel.heartbeatMissThreshold,
|
||||
config: { extensionDigits: channel.extensionDigits, serviceId: channel.businessCode },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,9 +224,9 @@ function ChannelFormModal({
|
||||
const [unitPrice, setUnitPrice] = useState(channel ? moneyUnitsToYuan(channel.unitPrice).toFixed(4) : '0.0300');
|
||||
const [unitPriceError, setUnitPriceError] = useState('');
|
||||
const [region, setRegion] = useState(channel?.sendRegion ?? '全国');
|
||||
const [protocol, setProtocol] = useState('CMPP');
|
||||
const [gatewayHost, setGatewayHost] = useState(channel?.gatewayHost ?? '');
|
||||
const [gatewayPort, setGatewayPort] = useState(channel?.gatewayPort ?? '17890');
|
||||
const [gatewayPort, setGatewayPort] = useState(channel?.gatewayPort ?? '7890');
|
||||
const [businessCode, setBusinessCode] = useState(channel?.businessCode ?? 'SMS');
|
||||
const [corpCode, setCorpCode] = useState(channel?.corpCode ?? '');
|
||||
const [account, setAccount] = useState(channel?.account ?? '');
|
||||
const [cmppVersion, setCmppVersion] = useState<'2.0' | '3.0'>(channel?.cmppVersion ?? '2.0');
|
||||
@@ -233,6 +236,8 @@ function ChannelFormModal({
|
||||
const [flowLimit, setFlowLimit] = useState(String(channel?.rateLimitPerSecond ?? 100));
|
||||
const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1));
|
||||
const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16));
|
||||
const [heartbeatIntervalSeconds, setHeartbeatIntervalSeconds] = useState(String(channel?.heartbeatIntervalSeconds ?? 30));
|
||||
const [heartbeatMissThreshold, setHeartbeatMissThreshold] = useState(String(channel?.heartbeatMissThreshold ?? 3));
|
||||
|
||||
function submit() {
|
||||
if (!isValidMoneyInput(unitPrice)) {
|
||||
@@ -255,12 +260,15 @@ function ChannelFormModal({
|
||||
failureCount: channel?.failureCount ?? 0,
|
||||
gatewayHost,
|
||||
gatewayPort,
|
||||
businessCode: businessCode.trim() || 'SMS',
|
||||
corpCode,
|
||||
account,
|
||||
accessNo,
|
||||
cmppVersion,
|
||||
desiredConnections: Number(desiredConnections) || 1,
|
||||
windowSize: Number(windowSize) || 16,
|
||||
heartbeatIntervalSeconds: Number(heartbeatIntervalSeconds) || 30,
|
||||
heartbeatMissThreshold: Number(heartbeatMissThreshold) || 3,
|
||||
extensionDigits: Number(extensionDigits),
|
||||
rateLimitPerSecond: Number(flowLimit),
|
||||
passwordCipher: password || undefined,
|
||||
@@ -302,11 +310,12 @@ function ChannelFormModal({
|
||||
<section>
|
||||
<h3>参数配置</h3>
|
||||
<div className="sms-channel-form-grid">
|
||||
<Select label="* 协议选择" onChange={(event) => setProtocol(event.target.value)} options={protocolOptions} value={protocol} />
|
||||
<Input disabled label="* 协议选择" value="CMPP" />
|
||||
<div className="sms-channel-inline-field">
|
||||
<Input label="* 网关地址" onChange={(event) => setGatewayHost(event.target.value)} placeholder="请输入网关地址" value={gatewayHost} />
|
||||
<Input label="端口" onChange={(event) => setGatewayPort(event.target.value)} value={gatewayPort} />
|
||||
</div>
|
||||
<Input hint="对应 CMPP Service_Id,最多 10 个 ASCII 字符。" label="* 业务代码" maxLength={10} onChange={(event) => setBusinessCode(event.target.value.toUpperCase())} value={businessCode} />
|
||||
<Input label="* 企业代码" onChange={(event) => setCorpCode(event.target.value)} placeholder="请输入企业代码" value={corpCode} />
|
||||
<Input label="* 网关账号" onChange={(event) => setAccount(event.target.value)} placeholder="请输入网关账号" value={account} />
|
||||
<Select label="* CMPP版本" onChange={(event) => setCmppVersion(event.target.value as '2.0' | '3.0')} options={cmppVersionOptions} value={cmppVersion} />
|
||||
@@ -326,6 +335,8 @@ function ChannelFormModal({
|
||||
<Input label="* 通道流速" max="2000" min="1" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" type="number" value={flowLimit} />
|
||||
<Input label="* 期望连接数" onChange={(event) => setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} />
|
||||
<Input label="* 提交窗口" onChange={(event) => setWindowSize(event.target.value)} placeholder="16" value={windowSize} />
|
||||
<Input hint="平台主动向供应商发送 ACTIVE_TEST 的间隔" label="* 心跳间隔" min="1" onChange={(event) => setHeartbeatIntervalSeconds(event.target.value)} suffix="秒" type="number" value={heartbeatIntervalSeconds} />
|
||||
<Input hint="连续未收到心跳响应达到该次数后重连" label="* 心跳失败阈值" min="1" onChange={(event) => setHeartbeatMissThreshold(event.target.value)} suffix="次" type="number" value={heartbeatMissThreshold} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -721,6 +732,10 @@ export function AdminChannelsPage() {
|
||||
<span>最近心跳</span>
|
||||
<strong>{formatDateTime(connection.lastHeartbeatAt)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>自动重连</span>
|
||||
<strong>{connection.reconnectCount} 次 / {formatDateTime(connection.nextReconnectAt)}</strong>
|
||||
</div>
|
||||
{connection.lastError ? <p>{connection.lastError}</p> : null}
|
||||
</article>
|
||||
))}
|
||||
|
||||
@@ -153,12 +153,6 @@ function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: Appli
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyError, setCopyError] = useState('');
|
||||
const paramsText = formatCmppParams(app, params);
|
||||
const host = params?.gatewayHost ?? app.cmppParams.host;
|
||||
const port = params?.gatewayPort ?? app.cmppParams.port;
|
||||
const password = params?.passwordCipher ?? app.cmppParams.password;
|
||||
const srcId = params?.srcId ?? app.cmppParams.accessNumber;
|
||||
const interfaceEnabled = params?.interfaceEnabled ?? app.cmppParams.interfaceEnabled;
|
||||
const interfaceType = params?.interfaceType ?? app.cmppParams.interfaceType;
|
||||
|
||||
async function copyParams() {
|
||||
try {
|
||||
@@ -185,19 +179,6 @@ function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: Appli
|
||||
title={<div className="template-modal-title"><h2>CMPP连接参数</h2><p>{app.enterprise} / {app.name}</p></div>}
|
||||
>
|
||||
<div className="cmpp-param-detail">
|
||||
<div className="cmpp-param-grid">
|
||||
<div><span>短信接口</span><strong>{interfaceEnabled ? '开通' : '关闭'}</strong></div>
|
||||
<div><span>接口类型</span><strong>{interfaceType === 'cmpp20' ? 'CMPP2.0' : 'HTTP接口'}</strong></div>
|
||||
<div><span>CMPP网关地址</span><strong>{host}</strong></div>
|
||||
<div><span>CMPP网关端口</span><strong>{port}</strong></div>
|
||||
<div><span>企业代码</span><strong>{params?.enterpriseCode ?? app.cmppParams.enterpriseCode}</strong></div>
|
||||
<div><span>接口账号</span><strong>{params?.account ?? app.cmppParams.account}</strong></div>
|
||||
<div><span>接口密码</span><strong>{password}</strong></div>
|
||||
<div><span>接入号</span><strong>{srcId}</strong></div>
|
||||
<div><span>最大连接数</span><strong>{params?.maxConnections ?? app.cmppParams.maxConnections}</strong></div>
|
||||
<div><span>心跳间隔</span><strong>{params?.heartbeatSeconds ?? app.cmppParams.heartbeatSeconds} 秒</strong></div>
|
||||
<div><span>协议版本</span><strong>{params?.protocolVersion ?? app.cmppParams.protocolVersion}</strong></div>
|
||||
</div>
|
||||
<pre className="cmpp-param-copy">{paramsText}</pre>
|
||||
{copyError ? <p className="form-error">{copyError}</p> : null}
|
||||
</div>
|
||||
@@ -391,21 +372,21 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
{ key: 'unitPrice', title: '单价', width: '130px', render: (record) => `${formatAmount(record.unitPrice)} 元` },
|
||||
{
|
||||
key: 'cmppStatus',
|
||||
title: 'CMPP状态',
|
||||
width: '230px',
|
||||
title: '客户连接状态',
|
||||
width: '250px',
|
||||
render: (record) => (
|
||||
<div className="cmpp-status-cell">
|
||||
<Tag tone={record.cmppStatus === 'connected' ? 'success' : record.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>
|
||||
{record.cmppStatus === 'connected' ? '已连接' : record.cmppStatus === 'disconnected' ? '已断开' : '未开通'}
|
||||
</Tag>
|
||||
<button onClick={() => setConnectionApp(record)} type="button">
|
||||
<button disabled={!record.cmppParams.interfaceEnabled} onClick={() => setConnectionApp(record)} type="button">
|
||||
{record.cmppConnections.filter((item) => item.state === 'open').length}
|
||||
</button>
|
||||
<button className="cmpp-status-cell__params" onClick={() => { void openParams(record); }} type="button">
|
||||
<button className={`cmpp-status-cell__params ${record.cmppParams.interfaceEnabled ? 'is-enabled' : 'is-disabled'}`} disabled={!record.cmppParams.interfaceEnabled} onClick={() => { void openParams(record); }} type="button">
|
||||
<Settings2 size={13} />
|
||||
CMPP参数
|
||||
</button>
|
||||
<button className="cmpp-status-cell__params" disabled={!record.httpEnabled} onClick={() => { void openHttpParams(record); }} type="button">HTTP参数</button>
|
||||
<button className={`cmpp-status-cell__params ${record.httpEnabled ? 'is-enabled' : 'is-disabled'}`} disabled={!record.httpEnabled} onClick={() => { void openHttpParams(record); }} type="button">HTTP参数</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -416,7 +397,7 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
align: 'right',
|
||||
width: '190px',
|
||||
render: (record) => (
|
||||
<div className="table-actions">
|
||||
<div className="table-actions enterprise-app-actions">
|
||||
<Button icon={<Edit3 size={15} />} onClick={() => navigate(`/admin/customers/${record.tenantId}/sms-apps/${record.id}/edit`)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button onClick={() => setConfirmAction({ action: 'toggle', id: record.id, name: record.name, enabled: record.enabled })} size="sm" variant={record.enabled ? 'warning' : 'success'}>
|
||||
{record.enabled ? '停用' : '启用'}
|
||||
|
||||
@@ -104,7 +104,6 @@ export function AdminEnterpriseAuditPage() {
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<EnterpriseAuditRecord>> = [
|
||||
{ key: 'id', title: '申请单号', render: (record) => <span className="muted">{record.id}</span> },
|
||||
{ key: 'companyName', title: '企业名称', render: (record) => <strong>{record.companyName}</strong> },
|
||||
{ key: 'creditCode', title: '统一社会信用代码', render: (record) => record.creditCode },
|
||||
{ key: 'contactName', title: '联系人', render: (record) => record.contactName },
|
||||
@@ -173,7 +172,7 @@ export function AdminEnterpriseAuditPage() {
|
||||
onClose={() => setDetailRecord(null)}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>企业认证详情</h2><p>{detailRecord.id}</p></div>}
|
||||
title={<div className="template-modal-title"><h2>企业认证详情</h2><p>{detailRecord.companyName}</p></div>}
|
||||
>
|
||||
<div className="enterprise-audit-detail">
|
||||
<section>
|
||||
|
||||
@@ -114,7 +114,7 @@ export function AdminEnterpriseBlacklistPage() {
|
||||
<Select
|
||||
label="状态"
|
||||
onChange={(event) => setStatus(event.target.value)}
|
||||
options={[{ label: '全部状态', value: 'all' }, { label: '生效中', value: 'active' }, { label: '已删除', value: 'deleted' }]}
|
||||
options={[{ label: '全部状态', value: 'all' }, { label: '生效中', value: 'active' }]}
|
||||
value={status}
|
||||
/>
|
||||
<div className="admin-security-filter__actions">
|
||||
|
||||
@@ -74,12 +74,12 @@ export function AdminProfitReportsPage() {
|
||||
<div className="surface">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table">
|
||||
<thead><tr><th>发送日期</th><th>{dimensionType === 'application' ? '企业 / 企业应用' : '通道'}</th><th>发送</th><th>成功</th><th>失败</th><th>净消费</th><th>返还</th><th>成本</th><th>利润</th><th>利润率</th><th>生成时间</th></tr></thead>
|
||||
<thead><tr><th>发送日期</th><th>{dimensionType === 'application' ? '企业 / 企业应用' : '通道'}</th><th>提交</th><th>发送</th><th>未知</th><th>成功</th><th>失败</th><th>净消费</th><th>返还</th><th>成本</th><th>利润</th><th>利润率</th><th>生成时间</th></tr></thead>
|
||||
<tbody>
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={11}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={11}>正在加载真实利润数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={11}>暂无已生成的利润报表</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>¥{formatCents(row.revenueCents)}</td><td>¥{formatCents(row.refundCents)}</td><td>¥{formatCents(row.costCents)}</td><td style={{ color: row.profitCents < 0 ? 'var(--danger)' : undefined }}>¥{formatCents(row.profitCents)}</td><td>{(row.profitRateBps / 100).toFixed(2)}%</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={13}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={13}>正在加载真实利润数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={13}>暂无已生成的利润报表</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.submittedUnits.toLocaleString('zh-CN')}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.unknownUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>¥{formatCents(row.revenueCents)}</td><td>¥{formatCents(row.refundCents)}</td><td>¥{formatCents(row.costCents)}</td><td style={{ color: row.profitCents < 0 ? 'var(--danger)' : undefined }}>¥{formatCents(row.profitCents)}</td><td>{(row.profitRateBps / 100).toFixed(2)}%</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -81,12 +81,12 @@ export function AdminQualityReportsPage() {
|
||||
<div className="surface">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table">
|
||||
<thead><tr><th>发送日期</th><th>{dimensionLabels[dimension]}</th><th>发送条数</th><th>成功条数</th><th>失败条数</th><th>成功率</th><th>平均到达时长</th><th>生成时间</th></tr></thead>
|
||||
<thead><tr><th>发送日期</th><th>{dimensionLabels[dimension]}</th><th>提交条数</th><th>发送条数</th><th>未知条数</th><th>成功条数</th><th>失败条数</th><th>成功率</th><th>平均到达时长</th><th>生成时间</th></tr></thead>
|
||||
<tbody>
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={8}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={8}>正在加载真实发送质量数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={8}>暂无已生成的发送质量报表</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>{(row.successRateBps / 100).toFixed(2)}%</td><td>{formatDuration(row.avgArrivalMs)}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={10}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={10}>正在加载真实发送质量数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={10}>暂无已生成的发送质量报表</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.submittedUnits.toLocaleString('zh-CN')}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.unknownUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>{(row.successRateBps / 100).toFixed(2)}%</td><td>{formatDuration(row.avgArrivalMs)}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -80,12 +80,12 @@ export function AdminReconciliationReportsPage() {
|
||||
<div className="surface">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table">
|
||||
<thead><tr><th>发送日期</th><th>企业</th><th>企业应用</th><th>发送条数</th><th>成功条数</th><th>失败条数</th><th>生成时间</th></tr></thead>
|
||||
<thead><tr><th>发送日期</th><th>企业</th><th>企业应用</th><th>提交条数</th><th>发送条数</th><th>未知条数</th><th>成功条数</th><th>失败条数</th><th>生成时间</th></tr></thead>
|
||||
<tbody>
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={7}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={7}>正在加载真实对账数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={7}>暂无已生成的对账单</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td>{row.tenantName}</td><td>{row.applicationName}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={9}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={9}>正在加载真实对账数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={9}>暂无已生成的对账单</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td>{row.tenantName}</td><td>{row.applicationName}</td><td>{row.submittedUnits.toLocaleString('zh-CN')}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.unknownUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{row.failedUnits.toLocaleString('zh-CN')}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -48,7 +48,7 @@ export function AdminSmsApplicationFormPage() {
|
||||
const [interfaceEnabled, setInterfaceEnabled] = useState(true);
|
||||
const [interfaceType, setInterfaceType] = useState<InterfaceType>('cmpp20');
|
||||
const [cmppMaxConnections, setCmppMaxConnections] = useState('1');
|
||||
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10');
|
||||
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10000');
|
||||
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
|
||||
const [downstreamReceiptRetryEnabled, setDownstreamReceiptRetryEnabled] = useState(true);
|
||||
const [downstreamUplinkRetryEnabled, setDownstreamUplinkRetryEnabled] = useState(true);
|
||||
@@ -73,6 +73,29 @@ export function AdminSmsApplicationFormPage() {
|
||||
let cancelled = false;
|
||||
async function loadForm() {
|
||||
try {
|
||||
if (!isEdit) {
|
||||
setAppName('');
|
||||
setScene('行业通知');
|
||||
setDailyLimit('100000');
|
||||
setCustomerUnitPrice('0.0300');
|
||||
setQueuePriority('normal');
|
||||
setCmppAccount('');
|
||||
setApplicationExtension('');
|
||||
setAccessNumberFillEnabled(false);
|
||||
setAccessNumberFillPrefix('');
|
||||
setPasswordCipher(generateApplicationPassword());
|
||||
setInterfaceEnabled(true);
|
||||
setInterfaceType('cmpp20');
|
||||
setCmppMaxConnections('1');
|
||||
setPhoneDailyLimit('10000');
|
||||
setMismatchPolicy('manual_review');
|
||||
setDownstreamReceiptRetryEnabled(true);
|
||||
setDownstreamUplinkRetryEnabled(true);
|
||||
setIpAddress('');
|
||||
setMobileGroupId('');
|
||||
setUnicomGroupId('');
|
||||
setTelecomGroupId('');
|
||||
}
|
||||
const [groupItems, application, routeRules] = await Promise.all([
|
||||
adminApi.listChannelGroups(),
|
||||
isEdit && appId ? adminApi.getEnterpriseApplication(appId) : Promise.resolve<EnterpriseApplication | null>(null),
|
||||
@@ -278,7 +301,7 @@ export function AdminSmsApplicationFormPage() {
|
||||
<span>优先队列会在发送调度中插队处理,但仍必须经过模板、签名、余额、通道组和通道限速校验。</span>
|
||||
</div>
|
||||
</div>
|
||||
<Input hint="单个发送任务超过该数量时,后端会拒绝整个任务,不会只发送前面的号码;请拆分后重新提交。" label="每任务最大号码数" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10" required value={phoneDailyLimit} />
|
||||
<Input hint="单个发送任务超过该数量时,后端会拒绝整个任务,不会只发送前面的号码;请拆分后重新提交。" label="每任务最大号码数" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10000" required value={phoneDailyLimit} />
|
||||
<Select
|
||||
label="不符合模板的短信"
|
||||
onChange={(event) => setMismatchPolicy(event.target.value)}
|
||||
|
||||
@@ -102,7 +102,6 @@ export function AdminSmsAuditPage() {
|
||||
width: '54px',
|
||||
render: (record) => <input aria-label={`选择审核任务${record.taskNo}`} checked={selectedIds.includes(record.id)} disabled={record.status !== 'pending_review'} onChange={(event) => setSelectedIds((current) => event.target.checked ? [...new Set([...current, record.id])] : current.filter((id) => id !== record.id))} type="checkbox" />,
|
||||
},
|
||||
{ key: 'taskNo', title: '任务编号', width: '180px', render: (record) => <strong>{record.taskNo}</strong> },
|
||||
{ key: 'sourceType', title: '审核来源', width: '180px', render: (record) => <Tag tone={record.sourceType === 'cmpp_template_mismatch' ? 'warning' : 'info'}>{sourceLabel(record.sourceType)}</Tag> },
|
||||
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
|
||||
{ key: 'phoneTotal', title: '聚合号码数', width: '140px', render: (record) => (record._count?.messageRecords ?? record.phoneTotal).toLocaleString('zh-CN') },
|
||||
@@ -149,7 +148,7 @@ export function AdminSmsAuditPage() {
|
||||
]}
|
||||
value={status}
|
||||
/>
|
||||
<Input label="短信内容" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入任务编号、内容或审核原因" value={keyword} />
|
||||
<Input label="短信内容" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入短信内容或审核原因" value={keyword} />
|
||||
<Input label="提交日期" onChange={(event) => setDate(event.target.value)} placeholder="yyyy-mm-dd" prefix={<CalendarDays size={16} />} value={date} />
|
||||
<div className="audit-filter-actions">
|
||||
<Button icon={<Search size={17} />} onClick={loadData}>查询</Button>
|
||||
@@ -183,7 +182,6 @@ export function AdminSmsAuditPage() {
|
||||
|
||||
{detailTarget ? <Modal footer={<Button onClick={() => setDetailTarget(null)}>关闭</Button>} onClose={() => setDetailTarget(null)} open title="审核任务更多信息">
|
||||
<div className="detail-grid">
|
||||
<div><span>任务编号</span><strong>{detailTarget.taskNo}</strong></div>
|
||||
<div><span>提交时间</span><strong>{formatDateTime(detailTarget.createdAt)}</strong></div>
|
||||
<div><span>审核人</span><strong>{detailTarget.reviewedBy?.displayName || detailTarget.reviewedBy?.username || '-'}</strong></div>
|
||||
<div><span>审核时间</span><strong>{formatDateTime(detailTarget.reviewedAt)}</strong></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, Download, MessageSquare, Search, Smartphone } from 'lucide-react';
|
||||
import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit, type SmsReceiptRecord, type SmsSubmitRecord } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, Table, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
|
||||
import { formatCents } from '@/utils/currency';
|
||||
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
@@ -166,13 +166,11 @@ function downloadCsv(records: SmsMessageRecord[]) {
|
||||
function SendDetailModal({
|
||||
record,
|
||||
segmentAudits,
|
||||
segmentColumns,
|
||||
segmentLoading,
|
||||
onClose,
|
||||
}: {
|
||||
record: SmsMessageRecord;
|
||||
segmentAudits: SmsMessageSegmentAudit[];
|
||||
segmentColumns: Array<TableColumn<SmsMessageSegmentAudit>>;
|
||||
segmentLoading: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
@@ -204,6 +202,10 @@ function SendDetailModal({
|
||||
<span>提交时间</span>
|
||||
<strong>{getTime(record.queuedAt)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发送号码</span>
|
||||
<strong>{record.phoneNumber || '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>号码归属</span>
|
||||
<strong>{record.province ?? '-'} / {getCarrierLabel(record.carrier)}</strong>
|
||||
@@ -272,13 +274,31 @@ function SendDetailModal({
|
||||
|
||||
<section>
|
||||
<h3>分片补偿审计</h3>
|
||||
<Table
|
||||
columns={segmentColumns}
|
||||
data={segmentAudits}
|
||||
emptyText={segmentLoading ? '加载中...' : '暂无分片审计'}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
/>
|
||||
{segmentLoading ? <div className="ui-table__empty">加载中...</div> : segmentAudits.length === 0 ? (
|
||||
<div className="ui-table__empty">暂无分片审计</div>
|
||||
) : (
|
||||
<div className="admin-sms-segment-list">
|
||||
{segmentAudits.map((segment) => (
|
||||
<article className="admin-sms-segment-card" key={segment.id}>
|
||||
<header>
|
||||
<strong>分片 {segment.segmentIndex}/{segment.segmentTotal}</strong>
|
||||
<div>
|
||||
<Tag tone={segment.submitStatus === 'accepted' ? 'success' : segment.submitStatus === 'queued' ? 'info' : 'danger'}>{segment.submitStatus}</Tag>
|
||||
{segment.receiptStatus ? <Tag tone={segment.receiptStatus === 'delivered' ? 'success' : segment.receiptStatus === 'unknown' ? 'neutral' : 'danger'}>{segment.receiptStatus}</Tag> : null}
|
||||
</div>
|
||||
</header>
|
||||
<dl>
|
||||
<div><dt>通道</dt><dd>{segment.channel?.name ?? segment.channelId ?? '-'}</dd></div>
|
||||
<div><dt>Sequence</dt><dd>{segment.sequenceId ?? '-'}</dd></div>
|
||||
<div><dt>提交 ID</dt><dd>{segment.submitId}</dd></div>
|
||||
<div><dt>网关 MsgId</dt><dd>{segment.gatewayMessageId ?? '-'}</dd></div>
|
||||
<div><dt>补偿方式</dt><dd>{segment.compensationType ?? '-'}</dd></div>
|
||||
<div><dt>错误信息</dt><dd>{segment.errorMessage ?? segment.errorCode ?? '-'}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -378,18 +398,6 @@ export function AdminSmsRecordsPage() {
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
const segmentColumns: Array<TableColumn<SmsMessageSegmentAudit>> = [
|
||||
{ key: 'segment', title: '分片', width: '90px', render: (record) => `${record.segmentIndex}/${record.segmentTotal}` },
|
||||
{ key: 'submitId', title: '提交ID', width: '190px', render: (record) => <strong className="admin-task-id">{record.submitId}</strong> },
|
||||
{ key: 'channel', title: '通道', width: '150px', render: (record) => record.channel?.name ?? record.channelId ?? '-' },
|
||||
{ key: 'sequenceId', title: 'Sequence', width: '110px', render: (record) => record.sequenceId ?? '-' },
|
||||
{ key: 'gatewayMessageId', title: 'MsgId', width: '180px', render: (record) => record.gatewayMessageId ?? '-' },
|
||||
{ key: 'submitStatus', title: '提交状态', width: '110px', render: (record) => <Tag tone={record.submitStatus === 'accepted' ? 'success' : record.submitStatus === 'queued' ? 'info' : 'danger'}>{record.submitStatus}</Tag> },
|
||||
{ key: 'receiptStatus', title: '回执状态', width: '110px', render: (record) => record.receiptStatus ? <Tag tone={record.receiptStatus === 'delivered' ? 'success' : record.receiptStatus === 'unknown' ? 'neutral' : 'danger'}>{record.receiptStatus}</Tag> : '-' },
|
||||
{ key: 'compensation', title: '补偿', width: '120px', render: (record) => record.compensationType ?? '-' },
|
||||
{ key: 'error', title: '错误', render: (record) => record.errorMessage ?? record.errorCode ?? '-' },
|
||||
];
|
||||
|
||||
function resetFilters() {
|
||||
setEnterprise('all');
|
||||
setApplication('all');
|
||||
@@ -485,7 +493,6 @@ export function AdminSmsRecordsPage() {
|
||||
onClose={() => setSelectedRecord(null)}
|
||||
record={selectedRecord}
|
||||
segmentAudits={segmentAudits}
|
||||
segmentColumns={segmentColumns}
|
||||
segmentLoading={segmentLoading}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -36,7 +36,6 @@ export function AdminTemplateAuditPage() {
|
||||
|
||||
const columns = useMemo<Array<TableColumn<SmsTemplateAudit>>>(
|
||||
() => [
|
||||
{ key: 'id', title: '审核编号', render: (record) => record.id },
|
||||
{ key: 'customer', title: '客户', render: (record) => record.tenant?.name ?? record.tenantId },
|
||||
{ key: 'application', title: '短信应用', render: (record) => record.application?.name ?? record.applicationId },
|
||||
{ key: 'content', title: '短信模板内容', render: (record) => record.content },
|
||||
@@ -83,7 +82,7 @@ export function AdminTemplateAuditPage() {
|
||||
</div>
|
||||
<div className="surface audit-filter-card">
|
||||
<div className="audit-filter-grid audit-filter-grid--template">
|
||||
<Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索客户、应用、模板内容或审核编号" value={keyword} />
|
||||
<Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索客户、应用或模板内容" value={keyword} />
|
||||
<Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
|
||||
<div className="audit-filter-actions">
|
||||
<Button icon={<Search size={17} />}>查询</Button>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
PanelLeftOpen,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { portalSessionApi } from '@/api/adminApi';
|
||||
import {
|
||||
clearSession,
|
||||
@@ -337,13 +337,20 @@ export function AppShell({
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<NavLink key={item.to} onClick={() => setMobileNavOpen(false)} to={item.to} end title={item.pending ? `${item.label}(待开发)` : item.label}>
|
||||
<Link
|
||||
aria-current={isShellNavItemActive(location.pathname, item.to) ? 'page' : undefined}
|
||||
className={isShellNavItemActive(location.pathname, item.to) ? 'active' : undefined}
|
||||
key={item.to}
|
||||
onClick={() => setMobileNavOpen(false)}
|
||||
to={item.to}
|
||||
title={item.pending ? `${item.label}(待开发)` : item.label}
|
||||
>
|
||||
<Icon size={17} strokeWidth={2.1} />
|
||||
<span className="side-nav-label">
|
||||
<span className="side-nav-label-text">{item.label}</span>
|
||||
{item.pending ? <span className="dev-status-badge side-nav-pending-badge">待开发</span> : null}
|
||||
</span>
|
||||
</NavLink>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -498,6 +505,15 @@ export function AppShell({
|
||||
);
|
||||
}
|
||||
|
||||
function isShellNavItemActive(pathname: string, itemPath: string) {
|
||||
if (pathname === itemPath) return true;
|
||||
if (itemPath === '/admin' || itemPath === '/client') return false;
|
||||
if (pathname.startsWith(`${itemPath}/`)) return true;
|
||||
if (itemPath === '/admin/enterprise-applications' && /^\/admin\/customers\/[^/]+\/sms-apps\//.test(pathname)) return true;
|
||||
if (itemPath === '/admin/customer-enterprises' && /^\/admin\/customers\/[^/]+\/edit$/.test(pathname)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function formatCountdown(seconds: number) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
||||
|
||||
+92
-13
@@ -3167,11 +3167,32 @@ h3 {
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.cmpp-status-cell button:disabled,
|
||||
.cmpp-status-cell button.is-disabled {
|
||||
background: var(--color-surface-muted);
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.cmpp-status-cell button.is-enabled {
|
||||
background: var(--color-success-soft);
|
||||
border-color: color-mix(in srgb, var(--color-success) 30%, var(--color-border));
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.cmpp-status-cell__params {
|
||||
gap: 4px;
|
||||
min-width: 58px !important;
|
||||
}
|
||||
|
||||
.enterprise-app-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, max-content);
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.cmpp-connection-detail {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
@@ -8061,9 +8082,9 @@ h3 {
|
||||
|
||||
.sms-test-title span {
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
background: var(--color-selected-soft);
|
||||
border-radius: var(--radius-md);
|
||||
color: white;
|
||||
color: var(--color-selected);
|
||||
display: inline-flex;
|
||||
height: 58px;
|
||||
justify-content: center;
|
||||
@@ -8072,7 +8093,7 @@ h3 {
|
||||
|
||||
.sms-test-title h2,
|
||||
.sms-test-title p {
|
||||
color: white;
|
||||
color: var(--color-text-strong);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -8088,12 +8109,12 @@ h3 {
|
||||
}
|
||||
|
||||
.ui-modal__header:has(.sms-test-title) {
|
||||
background: var(--color-selected);
|
||||
border-bottom: 0;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ui-modal__header:has(.sms-test-title) .ui-button {
|
||||
color: white;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.sms-test-modal {
|
||||
@@ -9007,8 +9028,8 @@ h3 {
|
||||
|
||||
.admin-sms-record-list {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.admin-sms-record-card {
|
||||
@@ -9016,8 +9037,8 @@ h3 {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
|
||||
.admin-sms-record-card:hover {
|
||||
@@ -9041,7 +9062,12 @@ h3 {
|
||||
background: var(--color-bg-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
max-width: none;
|
||||
padding: var(--space-4);
|
||||
display: -webkit-box;
|
||||
line-height: 1.55;
|
||||
overflow: hidden;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.admin-sms-record-card__meta {
|
||||
@@ -9069,7 +9095,7 @@ h3 {
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: var(--space-3);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.admin-sms-record-table {
|
||||
@@ -9258,12 +9284,65 @@ h3 {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-sms-segment-list {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-sms-segment-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-sms-segment-card header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.admin-sms-segment-card header > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.admin-sms-segment-card dl {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-sms-segment-card dl > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-sms-segment-card dt {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.admin-sms-segment-card dd {
|
||||
color: var(--color-text-strong);
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.admin-sms-record-card > header,
|
||||
.admin-sms-record-card__meta,
|
||||
.admin-sms-detail-overview,
|
||||
.admin-sms-detail-status-grid,
|
||||
.admin-sms-route-list dl {
|
||||
.admin-sms-route-list dl,
|
||||
.admin-sms-segment-list,
|
||||
.admin-sms-segment-card dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user