fix: recover gateway callbacks and client send flows
This commit is contained in:
@@ -4909,3 +4909,17 @@ npm run verify:phase8
|
|||||||
| TC-ADMIN-SMS-RECORD-DENSITY-005 | 使用真实本地API数据加载、查询、翻页和打开详情 | 页面非空、无异常遮罩,控制台无新增错误;数据仍来自原有真实API,不引入mock或localStorage业务数据 |
|
| TC-ADMIN-SMS-RECORD-DENSITY-005 | 使用真实本地API数据加载、查询、翻页和打开详情 | 页面非空、无异常遮罩,控制台无新增错误;数据仍来自原有真实API,不引入mock或localStorage业务数据 |
|
||||||
|
|
||||||
执行记录:本地真实API/PostgreSQL渲染通过,9项条件全部存在,1/2分片均显示在计费列;右箭头成功打开原有详情弹窗,干净页面控制台日志为空。R11契约、前后端构建、159项API专项测试及Gateway全包测试/vet通过。
|
执行记录:本地真实API/PostgreSQL渲染通过,9项条件全部存在,1/2分片均显示在计费列;右箭头成功打开原有详情弹窗,干净页面控制台日志为空。R11契约、前后端构建、159项API专项测试及Gateway全包测试/vet通过。
|
||||||
|
|
||||||
|
## TC-LG-STEP46-47 客户端发送与 Gateway 回调回归(2026-08-27)
|
||||||
|
|
||||||
|
| 用例ID | 场景 | 预期 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| TC-LG-STEP46-47-001 | Gateway 批量结果消费时 Redis 短暂 I/O timeout | 批量消费协程不永久退出;重试并重建 consumer group 后继续消费 SubmitResult、ReceiptEvent 和 UplinkEvent |
|
||||||
|
| TC-LG-STEP46-47-002 | 普通 MO 上行没有可关联的平台 messageId | Outbox 允许事件入流和回调;API 按接入号/手机号窗口匹配,未匹配也必须落库 |
|
||||||
|
| TC-LG-STEP46-47-003 | `templateMismatchMode=direct_send` 应用选择已审核签名、输入自由正文和号码 | 不要求模板,提交按钮可用;仍进入后端签名、风控、余额、路由和发送策略 |
|
||||||
|
| TC-LG-STEP46-47-004 | 任意必填项未完成时查看提交区 | 禁用按钮旁明确显示当前第一个可操作原因,不出现无说明禁用 |
|
||||||
|
| TC-LG-STEP46-47-005 | 绕过系统文件选择器注入 XLSX | 前端同时核对扩展名和非空 MIME,明确提示“仅支持 CSV、TSV 或 TXT 文本文件”,不调用导入预览 API |
|
||||||
|
| TC-LG-STEP46-47-006 | 390×844 首次打开定时选择器 | 弹层限制在视口内,清空/今天/确定操作区首屏可见可点,内容过高时仅弹层内滚动 |
|
||||||
|
| TC-LG-STEP46-47-007 | 客户端批量任务显示 UTC ISO 时间 | 提交时间和定时时间统一转为 Asia/Shanghai `YYYY-MM-DD HH:mm:ss`,不显示原始 `T...Z` 字符串 |
|
||||||
|
|
||||||
|
执行记录:Gateway `resultoutbox` 定向测试及全包测试通过;API 45套528项通过;API构建、前端TypeScript与Vite生产构建通过。390×844 Chromium 渲染回归中,无模板直发按钮可用,日期弹层为366×476且操作区Y=431~471,XLSX明确拒绝,批次时间显示为北京时间,控制台无错误。
|
||||||
|
|||||||
@@ -4037,3 +4037,12 @@ git diff --check
|
|||||||
- 本地真实API和PostgreSQL数据渲染验证通过:9项搜索条件全部存在,列表可见1/2分片计费记录,点击右箭头可打开原有发送详情、通道发送与回执、状态信息和分片补偿审计;另开干净页面控制台日志为空。
|
- 本地真实API和PostgreSQL数据渲染验证通过:9项搜索条件全部存在,列表可见1/2分片计费记录,点击右箭头可打开原有发送详情、通道发送与回执、状态信息和分片补偿审计;另开干净页面控制台日志为空。
|
||||||
- 门禁通过:R11页面契约、前端TypeScript检查与Vite生产构建、API正式构建与Prisma校验、队列契约、SendChain/Operations 2套159项、Gateway全包`go test ./... -count=1`和`go vet ./...`。pnpm包装器因既有`msgpackr-extract`构建脚本未批准而中止,未放宽依赖策略,改用已安装的TypeScript/Vite入口完成等价构建。
|
- 门禁通过:R11页面契约、前端TypeScript检查与Vite生产构建、API正式构建与Prisma校验、队列契约、SendChain/Operations 2套159项、Gateway全包`go test ./... -count=1`和`go vet ./...`。pnpm包装器因既有`msgpackr-extract`构建脚本未批准而中止,未放宽依赖策略,改用已安装的TypeScript/Vite入口完成等价构建。
|
||||||
- 本轮仅使用本地隔离环境,没有访问或修改预生产/生产,没有发送短信或压测。为渲染验证启动的PostgreSQL、API、前端预览和临时Redis均已停止;多Gateway P2未实施。
|
- 本轮仅使用本地隔离环境,没有访问或修改预生产/生产,没有发送短信或压测。为渲染验证启动的PostgreSQL、API、前端预览和临时Redis均已停止;多Gateway P2未实施。
|
||||||
|
|
||||||
|
## 2026-08-27 LG第46/47步确认缺陷本地修复
|
||||||
|
|
||||||
|
- 对测试机`100.93.204.60`做只读复核:`gateway.submit.results` consumer group 为`lag=1974/pending=0`;Gateway日志明确记录`2026-08-26 05:48:18 ... worker stopped: ... i/o timeout`。批量回调消费在一次Redis短暂读超时后永久退出,解释了随后SubmitResult、回执和上行事件均入流但不回写的两个P0。
|
||||||
|
- Gateway批量结果消费增加持续重试外层,每次重试重新确保consumer group存在;一次Redis I/O错误不再结束唯一结果消费协程。Outbox校验同时改为仅Submit结果必须具有平台`messageId`,普通MO上行可在没有历史MT关联时继续回调并落库。
|
||||||
|
- 客户端发送页按应用真实`templateMismatchMode`放行`direct_send`,保留签名和正文必填,并在提交区显示禁用原因;导入在读文件前校验CSV/TSV/TXT扩展名及MIME,XLSX不再当文本解析。
|
||||||
|
- 移动端日期选择器使用视口内固定弹层和内部滚动,操作区置底;批量任务提交/定时时间统一为Asia/Shanghai格式。
|
||||||
|
- 门禁通过:API 45套528项,Gateway全包(含新增Outbox重试与无messageId上行用例),API正式构建,前端TypeScript检查和Vite生产构建。390×844 Chromium回归的直发、XLSX拦截、定时弹层和批次时间均通过,控制台错误/警告0。
|
||||||
|
- 本轮没有部署、重启服务、发送短信或处理测试机1974条积压;没有访问或修改预生产/生产。因此当前是本地代码与自动化/渲染回归通过,测试机真实CMPP闭环需在后续部署授权后再验收。
|
||||||
|
|||||||
@@ -232,9 +232,12 @@ func EventFromStreamValues(values map[string]interface{}) (Event, error) {
|
|||||||
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
||||||
return Event{}, err
|
return Event{}, err
|
||||||
}
|
}
|
||||||
if event.SchemaVersion != queue.SchemaVersion || event.EventID == "" || event.MessageID == "" {
|
if event.SchemaVersion != queue.SchemaVersion || event.EventID == "" || event.ChannelID == "" {
|
||||||
return Event{}, fmt.Errorf("invalid result Outbox envelope")
|
return Event{}, fmt.Errorf("invalid result Outbox envelope")
|
||||||
}
|
}
|
||||||
|
if (event.EventType == "submit_result" || event.EventType == "submit_segment_result") && event.MessageID == "" {
|
||||||
|
return Event{}, fmt.Errorf("submit result Outbox messageId is required")
|
||||||
|
}
|
||||||
if event.Path != "/gateway/events/submit-result" && event.Path != "/gateway/events/submit-segment-result" && event.Path != "/gateway/events/receipt/intake" && event.Path != "/gateway/events/uplink" && event.Path != "/gateway/events/dead-letter" {
|
if event.Path != "/gateway/events/submit-result" && event.Path != "/gateway/events/submit-segment-result" && event.Path != "/gateway/events/receipt/intake" && event.Path != "/gateway/events/uplink" && event.Path != "/gateway/events/dead-letter" {
|
||||||
return Event{}, fmt.Errorf("unsupported result Outbox path %q", event.Path)
|
return Event{}, fmt.Errorf("unsupported result Outbox path %q", event.Path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package resultoutbox
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -33,6 +34,52 @@ func TestPublishSubmitSegmentIsIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUplinkWithoutPlatformMessageIDIsAValidOutboxEvent(t *testing.T) {
|
||||||
|
mr := miniredis.RunT(t)
|
||||||
|
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||||
|
outbox := New(client)
|
||||||
|
event := queue.UplinkEvent{
|
||||||
|
Envelope: queue.Envelope{
|
||||||
|
SchemaVersion: queue.SchemaVersion,
|
||||||
|
MessageType: queue.MessageTypeUplinkEvent,
|
||||||
|
ChannelID: "channel-1",
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
},
|
||||||
|
SequenceID: 1, PhoneNumber: "13800138000", DestID: "10690001",
|
||||||
|
Content: "UP", ReceivedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
if err := outbox.PublishUplink(context.Background(), event); err != nil {
|
||||||
|
t.Fatalf("publish uplink: %v", err)
|
||||||
|
}
|
||||||
|
streams, err := client.XRange(context.Background(), outbox.StreamName(), "-", "+").Result()
|
||||||
|
if err != nil || len(streams) != 1 {
|
||||||
|
t.Fatalf("read uplink stream: entries=%d err=%v", len(streams), err)
|
||||||
|
}
|
||||||
|
parsed, err := EventFromStreamValues(streams[0].Values)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse uplink without messageId: %v", err)
|
||||||
|
}
|
||||||
|
if parsed.EventType != "uplink" || parsed.MessageID != "" {
|
||||||
|
t.Fatalf("unexpected uplink envelope: %+v", parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryUntilCanceledRestartsAfterTransientFailure(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
calls := 0
|
||||||
|
err := retryUntilCanceled(ctx, time.Millisecond, func() error {
|
||||||
|
calls++
|
||||||
|
if calls == 1 {
|
||||||
|
return errors.New("transient Redis read timeout")
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
return ctx.Err()
|
||||||
|
})
|
||||||
|
if !errors.Is(err, context.Canceled) || calls != 2 {
|
||||||
|
t.Fatalf("retry result err=%v calls=%d, want context canceled after 2 calls", err, calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBatchCallbackSendsMultipleEventsInOneRequest(t *testing.T) {
|
func TestBatchCallbackSendsMultipleEventsInOneRequest(t *testing.T) {
|
||||||
mr := miniredis.RunT(t)
|
mr := miniredis.RunT(t)
|
||||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||||
|
|||||||
@@ -43,12 +43,24 @@ func (o *Outbox) Run(ctx context.Context) error {
|
|||||||
if strings.TrimSpace(o.APIBaseURL) == "" {
|
if strings.TrimSpace(o.APIBaseURL) == "" {
|
||||||
return fmt.Errorf("result Outbox API base URL is required")
|
return fmt.Errorf("result Outbox API base URL is required")
|
||||||
}
|
}
|
||||||
|
if o.BatchEnabled {
|
||||||
|
return retryUntilCanceled(ctx, time.Second, func() error {
|
||||||
|
if err := o.ensureGroup(ctx); err != nil {
|
||||||
|
log.Printf("gateway result Outbox group initialization failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := o.runBatches(ctx); err != nil && ctx.Err() == nil {
|
||||||
|
// A transient Redis read error must not permanently stop the only
|
||||||
|
// consumer for submit results, receipts, and uplink events.
|
||||||
|
log.Printf("gateway result Outbox batch consume failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
})
|
||||||
|
}
|
||||||
if err := o.ensureGroup(ctx); err != nil {
|
if err := o.ensureGroup(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if o.BatchEnabled {
|
|
||||||
return o.runBatches(ctx)
|
|
||||||
}
|
|
||||||
pool := newCallbackPool(ctx, o, o.concurrency())
|
pool := newCallbackPool(ctx, o, o.concurrency())
|
||||||
defer pool.wait()
|
defer pool.wait()
|
||||||
for {
|
for {
|
||||||
@@ -276,3 +288,12 @@ func sleep(ctx context.Context, duration time.Duration) {
|
|||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func retryUntilCanceled(ctx context.Context, delay time.Duration, operation func() error) error {
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
if err := operation(); err != nil && ctx.Err() == nil {
|
||||||
|
sleep(ctx, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ export type ClientSmsApplication = {
|
|||||||
scene?: string | null;
|
scene?: string | null;
|
||||||
customerUnitPrice?: number | null;
|
customerUnitPrice?: number | null;
|
||||||
queuePriority?: 'normal' | 'priority' | string | null;
|
queuePriority?: 'normal' | 'priority' | string | null;
|
||||||
|
templateMismatchMode?: 'reject' | 'manual_review' | 'direct_send' | string | null;
|
||||||
status: string;
|
status: string;
|
||||||
dailyLimit?: number | null;
|
dailyLimit?: number | null;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
type TableColumn,
|
type TableColumn,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { clientApi, type SmsBatchTask } from '@/api/adminApi';
|
import { clientApi, type SmsBatchTask } from '@/api/adminApi';
|
||||||
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
|
||||||
type BatchTaskStatus = 'completed' | 'sending' | 'terminated';
|
type BatchTaskStatus = 'completed' | 'sending' | 'terminated';
|
||||||
|
|
||||||
@@ -170,7 +171,7 @@ export function ClientBatchTasksPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ key: 'applicationName', title: '应用名称', width: '150px', render: (record) => record.applicationName },
|
{ key: 'applicationName', title: '应用名称', width: '150px', render: (record) => record.applicationName },
|
||||||
{ key: 'submittedAt', title: '提交时间', width: '130px', render: (record) => record.submittedAt },
|
{ key: 'submittedAt', title: '提交时间', width: '150px', render: (record) => formatDateTime(record.submittedAt) },
|
||||||
{ key: 'phoneCount', title: '发送号码数', width: '120px', render: (record) => record.phoneCount.toLocaleString('zh-CN') },
|
{ key: 'phoneCount', title: '发送号码数', width: '120px', render: (record) => record.phoneCount.toLocaleString('zh-CN') },
|
||||||
{ key: 'wordCount', title: '单号码字数', width: '120px', render: (record) => <strong>{record.wordCount} 字</strong> },
|
{ key: 'wordCount', title: '单号码字数', width: '120px', render: (record) => <strong>{record.wordCount} 字</strong> },
|
||||||
{
|
{
|
||||||
@@ -183,7 +184,7 @@ export function ClientBatchTasksPage() {
|
|||||||
<Clock3 size={14} />
|
<Clock3 size={14} />
|
||||||
{record.sendType === 'immediate' ? '立即发送' : '定时发送'}
|
{record.sendType === 'immediate' ? '立即发送' : '定时发送'}
|
||||||
</span>
|
</span>
|
||||||
{record.scheduledAt ? <small>{record.scheduledAt}</small> : null}
|
{record.scheduledAt ? <small>{formatDateTime(record.scheduledAt)}</small> : null}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,24 @@ type Recipient = {
|
|||||||
type SendMode = 'now' | 'scheduled';
|
type SendMode = 'now' | 'scheduled';
|
||||||
type ReceiverMode = 'manual' | 'import';
|
type ReceiverMode = 'manual' | 'import';
|
||||||
|
|
||||||
|
const supportedImportExtensions = new Set(['csv', 'tsv', 'txt']);
|
||||||
|
const supportedImportMimeTypes = new Set([
|
||||||
|
'text/csv',
|
||||||
|
'application/csv',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'text/tab-separated-values',
|
||||||
|
'text/tsv',
|
||||||
|
'text/plain',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function assertSupportedImportFile(file: File) {
|
||||||
|
const extension = file.name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
const mimeType = file.type.toLowerCase();
|
||||||
|
if (!supportedImportExtensions.has(extension) || (mimeType && !supportedImportMimeTypes.has(mimeType))) {
|
||||||
|
throw new Error('仅支持 CSV、TSV 或 TXT 文本文件');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ClientSendPage() {
|
export function ClientSendPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||||
@@ -82,7 +100,23 @@ export function ClientSendPage() {
|
|||||||
const smsParts = wordCount === 0 ? 0 : wordCount <= 70 ? 1 : Math.ceil(wordCount / 67);
|
const smsParts = wordCount === 0 ? 0 : wordCount <= 70 ? 1 : Math.ceil(wordCount / 67);
|
||||||
const estimatedCount = receiverCount * smsParts;
|
const estimatedCount = receiverCount * smsParts;
|
||||||
const requiredVariables = selectedTemplate?.variables?.map((item) => item.name) ?? [];
|
const requiredVariables = selectedTemplate?.variables?.map((item) => item.name) ?? [];
|
||||||
const canSubmit = Boolean(taskName && applicationId && signatureId && templateId && receiverCount > 0 && (sendMode === 'now' || scheduledAt));
|
const allowsDirectSend = selectedApplication?.templateMismatchMode === 'direct_send';
|
||||||
|
const validationMessage = !taskName.trim()
|
||||||
|
? '请填写任务名称'
|
||||||
|
: !applicationId
|
||||||
|
? '请选择短信应用'
|
||||||
|
: !signatureId
|
||||||
|
? '请选择已审核通过的短信签名'
|
||||||
|
: !templateId && !allowsDirectSend
|
||||||
|
? '当前应用必须选择已审核通过的短信模板'
|
||||||
|
: !messageContent.trim()
|
||||||
|
? '请填写短信内容'
|
||||||
|
: receiverCount < 1
|
||||||
|
? receiverMode === 'manual' ? '请至少填写一个接收号码' : '请导入至少一个有效号码'
|
||||||
|
: sendMode === 'scheduled' && !scheduledAt
|
||||||
|
? '请选择定时发送时间'
|
||||||
|
: '';
|
||||||
|
const canSubmit = validationMessage === '';
|
||||||
|
|
||||||
function updateRecipient(id: string, phone: string) {
|
function updateRecipient(id: string, phone: string) {
|
||||||
setRecipients((items) => items.map((item) => (item.id === id ? { ...item, phone } : item)));
|
setRecipients((items) => items.map((item) => (item.id === id ? { ...item, phone } : item)));
|
||||||
@@ -116,7 +150,7 @@ export function ClientSendPage() {
|
|||||||
const submitRequest = receiverMode === 'manual'
|
const submitRequest = receiverMode === 'manual'
|
||||||
? clientApi.createBatchTask({
|
? clientApi.createBatchTask({
|
||||||
applicationId,
|
applicationId,
|
||||||
templateId,
|
templateId: templateId || undefined,
|
||||||
content: previewText,
|
content: previewText,
|
||||||
category: selectedTemplate?.category ?? taskName,
|
category: selectedTemplate?.category ?? taskName,
|
||||||
phones: validRecipients.map((item) => item.phone.trim()),
|
phones: validRecipients.map((item) => item.phone.trim()),
|
||||||
@@ -125,7 +159,7 @@ export function ClientSendPage() {
|
|||||||
})
|
})
|
||||||
: clientApi.confirmImport({
|
: clientApi.confirmImport({
|
||||||
applicationId,
|
applicationId,
|
||||||
templateId,
|
templateId: templateId || undefined,
|
||||||
content: previewText,
|
content: previewText,
|
||||||
category: selectedTemplate?.category ?? taskName,
|
category: selectedTemplate?.category ?? taskName,
|
||||||
importContent,
|
importContent,
|
||||||
@@ -172,6 +206,7 @@ export function ClientSendPage() {
|
|||||||
setImportLoading(true);
|
setImportLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
|
assertSupportedImportFile(file);
|
||||||
const content = await file.text();
|
const content = await file.text();
|
||||||
const preview = await clientApi.previewImport({
|
const preview = await clientApi.previewImport({
|
||||||
applicationId: applicationId || undefined,
|
applicationId: applicationId || undefined,
|
||||||
@@ -352,7 +387,7 @@ export function ClientSendPage() {
|
|||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
type="file"
|
type="file"
|
||||||
/>
|
/>
|
||||||
<Button disabled={importLoading || !templateId} onClick={() => document.getElementById('sms-import-file')?.click()} variant="ghost">
|
<Button disabled={importLoading || (!templateId && !allowsDirectSend)} onClick={() => document.getElementById('sms-import-file')?.click()} variant="ghost">
|
||||||
{importLoading ? '解析中...' : '选择文件'}
|
{importLoading ? '解析中...' : '选择文件'}
|
||||||
</Button>
|
</Button>
|
||||||
{importFileName ? (
|
{importFileName ? (
|
||||||
@@ -390,6 +425,7 @@ export function ClientSendPage() {
|
|||||||
<Button disabled={!canSubmit || submitting} icon={<Send size={18} />} onClick={submitTask}>
|
<Button disabled={!canSubmit || submitting} icon={<Send size={18} />} onClick={submitTask}>
|
||||||
{submitting ? '提交中...' : '提交发送任务'}
|
{submitting ? '提交中...' : '提交发送任务'}
|
||||||
</Button>
|
</Button>
|
||||||
|
{!canSubmit ? <span className="send-submit-hint">{validationMessage}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -742,6 +742,24 @@
|
|||||||
width: min(360px, calc(100vw - 48px));
|
width: min(360px, calc(100vw - 48px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 780px) {
|
||||||
|
.ui-date-time-popover {
|
||||||
|
inset: auto 12px;
|
||||||
|
max-height: calc(100dvh - 24px);
|
||||||
|
overflow-y: auto;
|
||||||
|
position: fixed;
|
||||||
|
top: 12px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-date-time-popover .ui-date-range-actions {
|
||||||
|
background: var(--color-surface);
|
||||||
|
bottom: 0;
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
position: sticky;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ui-time-picker {
|
.ui-time-picker {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
|||||||
@@ -574,10 +574,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.send-submit-row {
|
.send-submit-row {
|
||||||
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.send-submit-hint {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.mms-send-page .send-card {
|
.mms-send-page .send-card {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user