76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
578 lines
24 KiB
TypeScript
578 lines
24 KiB
TypeScript
import {
|
||
Alert,
|
||
Button,
|
||
Card,
|
||
Descriptions,
|
||
Form,
|
||
Input,
|
||
Modal,
|
||
Select,
|
||
Space,
|
||
Switch,
|
||
Table,
|
||
Tabs,
|
||
Tag,
|
||
AutoComplete,
|
||
message,
|
||
} from 'antd';
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { api } from '../api/client';
|
||
import { PageHeader } from './ResourcePage';
|
||
import { StatusTag } from '../StatusTag';
|
||
|
||
type SignRow = {
|
||
signName?: string;
|
||
SignName?: string;
|
||
auditStatus?: string | number;
|
||
AuditStatus?: string | number;
|
||
createDate?: string;
|
||
CreateDate?: string;
|
||
reason?: string;
|
||
Reason?: string;
|
||
signSource?: number;
|
||
SignSource?: number;
|
||
signType?: number;
|
||
SignType?: number;
|
||
businessType?: string;
|
||
BusinessType?: string;
|
||
};
|
||
type TplRow = {
|
||
templateCode?: string;
|
||
TemplateCode?: string;
|
||
templateName?: string;
|
||
TemplateName?: string;
|
||
auditStatus?: string | number;
|
||
AuditStatus?: string | number;
|
||
templateContent?: string;
|
||
TemplateContent?: string;
|
||
templateType?: number;
|
||
TemplateType?: number;
|
||
createDate?: string;
|
||
CreateDate?: string;
|
||
reason?: string;
|
||
Reason?: string;
|
||
};
|
||
|
||
const SIGN_SOURCE: Record<number, string> = {
|
||
0: '企事业单位全称或简称',
|
||
1: '工信部备案网站全称或简称',
|
||
2: 'App 全称或简称',
|
||
3: '公众号或小程序全称或简称',
|
||
4: '电商平台店铺名',
|
||
5: '商标全称或简称',
|
||
};
|
||
|
||
const SIGN_TYPE: Record<number, string> = { 0: '验证码', 1: '通用' };
|
||
const TPL_TYPE: Record<number, string> = {
|
||
0: '验证码',
|
||
1: '通知短信',
|
||
2: '推广短信',
|
||
3: '国际/港澳台',
|
||
7: '数字短信',
|
||
};
|
||
|
||
function auditText(v: unknown) {
|
||
const s = String(v ?? '');
|
||
if (s === '1' || s === 'AUDIT_STATE_PASS' || s === 'PASS') return { color: 'success', text: '已通过' };
|
||
if (s === '0' || s === 'AUDIT_STATE_INIT' || s === 'AUDITING') return { color: 'processing', text: '审核中' };
|
||
if (s === '2' || s === 'AUDIT_STATE_NOT_PASS' || s === 'NOT_PASS') return { color: 'error', text: '未通过' };
|
||
if (s === '3' || s === 'CANCEL' || s === 'AUDIT_STATE_CANCEL') return { color: 'default', text: '已取消' };
|
||
return { color: 'default', text: s ? zh(s) : '—' };
|
||
}
|
||
|
||
function unwrapList<T>(data: unknown, keys: string[]): T[] {
|
||
const body = (data as { data?: unknown })?.data ?? data;
|
||
if (!body || typeof body !== 'object') return [];
|
||
const obj = body as Record<string, unknown>;
|
||
for (const k of keys) {
|
||
const v = obj[k];
|
||
if (Array.isArray(v)) return v as T[];
|
||
}
|
||
if (Array.isArray(body)) return body as T[];
|
||
return [];
|
||
}
|
||
|
||
function zh(s: string) {
|
||
return s;
|
||
}
|
||
|
||
function dash(v?: string | null) {
|
||
return v && String(v).trim() ? v : '未绑定';
|
||
}
|
||
|
||
export default function SmsPage() {
|
||
const qc = useQueryClient();
|
||
const cfg = useQuery({ queryKey: ['/sms/config'], queryFn: () => api.get('/sms/config') });
|
||
const signs = useQuery({
|
||
queryKey: ['/sms/signs'],
|
||
queryFn: () => api.get('/sms/signs'),
|
||
retry: false,
|
||
});
|
||
const tpls = useQuery({
|
||
queryKey: ['/sms/templates'],
|
||
queryFn: () => api.get('/sms/templates'),
|
||
retry: false,
|
||
});
|
||
const quals = useQuery({
|
||
queryKey: ['/sms/qualifications'],
|
||
queryFn: () => api.get('/sms/qualifications'),
|
||
retry: false,
|
||
});
|
||
const logs = useQuery({ queryKey: ['/sms/logs'], queryFn: () => api.get('/sms/logs') });
|
||
const events = useQuery({ queryKey: ['/sms/events'], queryFn: () => api.get('/sms/events') });
|
||
const [signForm] = Form.useForm();
|
||
const [tplForm] = Form.useForm();
|
||
const info = (cfg.data?.data ?? {}) as {
|
||
configured?: boolean;
|
||
defaultSign?: string;
|
||
note?: string;
|
||
region?: string;
|
||
endpoint?: string;
|
||
ramLogin?: string;
|
||
accessKeyIdMasked?: string;
|
||
secretConfigured?: boolean;
|
||
securityPhone?: string;
|
||
securityEmail?: string;
|
||
presets?: { code: string; name: string }[];
|
||
};
|
||
const signRows = unwrapList<SignRow>(signs.data, ['smsSignList', 'SmsSignList']);
|
||
const tplRows = unwrapList<TplRow>(tpls.data, ['smsTemplateList', 'SmsTemplateList']);
|
||
const qualRows = unwrapList<{
|
||
qualificationId?: string;
|
||
QualificationId?: string;
|
||
qualificationName?: string;
|
||
QualificationName?: string;
|
||
companyName?: string;
|
||
CompanyName?: string;
|
||
state?: string;
|
||
State?: string;
|
||
}>(quals.data, ['qualificationInfos', 'QualificationInfos', 'records']);
|
||
const logRows = unwrapList<{
|
||
id: string;
|
||
phones: string;
|
||
signName: string;
|
||
templateCode: string;
|
||
eventKey?: string;
|
||
code: string;
|
||
message?: string;
|
||
createdAt: string;
|
||
}>(logs.data, ['items']);
|
||
const eventPack = (events.data?.data ?? {}) as {
|
||
configured?: boolean;
|
||
defaultSign?: string;
|
||
presets?: { code: string; name: string }[];
|
||
items?: {
|
||
key: string;
|
||
group: string;
|
||
name: string;
|
||
when: string;
|
||
to: string;
|
||
enabled: boolean;
|
||
automatic?: boolean;
|
||
templateCode: string;
|
||
signName: string;
|
||
sample: Record<string, string>;
|
||
vars?: string;
|
||
}[];
|
||
};
|
||
const eventRows = eventPack.items || [];
|
||
const eventMut = useMutation({
|
||
mutationFn: (body: Record<string, unknown>) => api.post('/sms/events', body),
|
||
onSuccess: () => {
|
||
message.success('已保存步骤关联');
|
||
qc.invalidateQueries({ queryKey: ['/sms/events'] });
|
||
},
|
||
onError: (e: Error) => message.error(e.message),
|
||
});
|
||
const aliyunErr = (signs.error as Error | undefined)?.message || (tpls.error as Error | undefined)?.message;
|
||
|
||
const signMut = useMutation({
|
||
mutationFn: (body: Record<string, unknown>) => api.post('/sms/signs', body),
|
||
onSuccess: () => {
|
||
message.success('已提交签名申请,阿里云审核中');
|
||
qc.invalidateQueries({ queryKey: ['/sms/signs'] });
|
||
},
|
||
onError: (e: Error) => message.error(e.message),
|
||
});
|
||
const tplMut = useMutation({
|
||
mutationFn: (body: Record<string, unknown>) => api.post('/sms/templates', body),
|
||
onSuccess: () => {
|
||
message.success('已提交模板申请,阿里云审核中');
|
||
qc.invalidateQueries({ queryKey: ['/sms/templates'] });
|
||
},
|
||
onError: (e: Error) => message.error(e.message),
|
||
});
|
||
|
||
const passedQuals = qualRows
|
||
.map((q) => ({
|
||
value: String(q.qualificationId || q.QualificationId || ''),
|
||
label: String(q.qualificationName || q.QualificationName || q.companyName || q.CompanyName || q.qualificationId || ''),
|
||
}))
|
||
.filter((q) => q.value);
|
||
|
||
return (
|
||
<>
|
||
<PageHeader
|
||
title="短信设置"
|
||
description="登录验证码、投标待办等步骤在这里开关。手工群发请到个人办公「发送短信」。签名和模板仍走阿里云审核。"
|
||
extra={<Tag color={info.configured ? 'success' : 'error'}>{info.configured ? '已配置密钥' : '未配置密钥'}</Tag>}
|
||
/>
|
||
{!info.configured ? <Alert type="warning" showIcon message="尚未配置阿里云短信密钥" style={{ marginBottom: 12 }} /> : null}
|
||
{aliyunErr ? (
|
||
<Alert type="error" showIcon message="阿里云短信接口不可用" description={aliyunErr} style={{ marginBottom: 12 }} />
|
||
) : null}
|
||
|
||
<Tabs
|
||
items={[
|
||
{
|
||
key: 'events',
|
||
label: '步骤关联',
|
||
children: (
|
||
<>
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
style={{ marginBottom: 12 }}
|
||
message="只有登录和投标步骤可自动发短信;公告、日报、报销、人事、用章、工作安排等一律只能到「发送工作通知短信」手工发送。投标短信只发给当前步骤实际选定的办理人。"
|
||
/>
|
||
<Table
|
||
className="ops-table"
|
||
rowKey="key"
|
||
loading={events.isLoading}
|
||
dataSource={eventRows}
|
||
pagination={false}
|
||
columns={[
|
||
{ title: '分组', dataIndex: 'group', width: 90 },
|
||
{ title: '步骤', dataIndex: 'name', width: 140 },
|
||
{ title: '何时发', dataIndex: 'when' },
|
||
{ title: '发给谁', dataIndex: 'to', width: 140 },
|
||
{
|
||
title: '变量',
|
||
dataIndex: 'vars',
|
||
width: 180,
|
||
render: (_: unknown, r) => r.vars || Object.keys(r.sample || {}).join('、') || '—',
|
||
},
|
||
{
|
||
title: '模板',
|
||
dataIndex: 'templateCode',
|
||
width: 220,
|
||
render: (_: unknown, r) => (
|
||
<AutoComplete
|
||
value={r.templateCode}
|
||
style={{ width: '100%' }}
|
||
options={(eventPack.presets || info.presets || []).map((p) => ({
|
||
value: p.code,
|
||
label: `${p.name}(${p.code})`,
|
||
}))}
|
||
onBlur={(e) => {
|
||
const v = (e.target as HTMLInputElement).value?.trim();
|
||
if (v && v !== r.templateCode) eventMut.mutate({ eventKey: r.key, templateCode: v });
|
||
}}
|
||
onSelect={(v) => eventMut.mutate({ eventKey: r.key, templateCode: String(v) })}
|
||
disabled={r.automatic === false}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
title: '启用',
|
||
dataIndex: 'enabled',
|
||
width: 80,
|
||
render: (v: boolean, r) => (
|
||
<Switch
|
||
checked={v}
|
||
disabled={r.automatic === false}
|
||
loading={eventMut.isPending}
|
||
onChange={(enabled) => eventMut.mutate({ eventKey: r.key, enabled })}
|
||
/>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</>
|
||
),
|
||
},
|
||
{
|
||
key: 'account',
|
||
label: '账号设置',
|
||
children: (
|
||
<Card size="small" className="sms-account" title="阿里云 RAM / 短信通道">
|
||
<Descriptions bordered size="small" column={1}>
|
||
<Descriptions.Item label="RAM 登录名称">{info.ramLogin || '—'}</Descriptions.Item>
|
||
<Descriptions.Item label="登录密码">不写入本系统,仅控制台登录使用</Descriptions.Item>
|
||
<Descriptions.Item label="AccessKey ID">{info.accessKeyIdMasked || '—'}</Descriptions.Item>
|
||
<Descriptions.Item label="AccessKey Secret">
|
||
{info.secretConfigured ? '已配置(密文,不在页面展示)' : '未配置'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="地域">{info.region || 'cn-hangzhou'}</Descriptions.Item>
|
||
<Descriptions.Item label="接口地址">{info.endpoint || 'dysmsapi.aliyuncs.com'}</Descriptions.Item>
|
||
<Descriptions.Item label="默认短信签名">{info.defaultSign || '—'}</Descriptions.Item>
|
||
<Descriptions.Item label="SecurityPhoneDevice">{dash(info.securityPhone)}</Descriptions.Item>
|
||
<Descriptions.Item label="SecurityEmailDevice">{dash(info.securityEmail)}</Descriptions.Item>
|
||
</Descriptions>
|
||
<p style={{ marginTop: 12, color: 'var(--mute)', fontSize: 12 }}>{info.note}</p>
|
||
</Card>
|
||
),
|
||
},
|
||
{
|
||
key: 'signs',
|
||
label: '签名',
|
||
children: (
|
||
<>
|
||
<Space style={{ marginBottom: 12 }}>
|
||
<Button
|
||
type="primary"
|
||
onClick={() => {
|
||
signForm.resetFields();
|
||
signForm.setFieldsValue({
|
||
signName: info.defaultSign,
|
||
signSource: 0,
|
||
signType: 1,
|
||
qualificationId: passedQuals[0]?.value,
|
||
remark: '江苏风影随行科技办公平台向内部员工发送工作通知,签名为本公司全称或简称。',
|
||
});
|
||
Modal.confirm({
|
||
title: '申请短信签名',
|
||
width: 520,
|
||
icon: null,
|
||
content: (
|
||
<Form form={signForm} layout="vertical" style={{ marginTop: 12 }}>
|
||
<Form.Item name="signName" label="签名名称" rules={[{ required: true }]} extra="2–12 个字,不要带括号和测试字样。">
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="signSource" label="签名来源" rules={[{ required: true }]}>
|
||
<Select
|
||
options={Object.entries(SIGN_SOURCE).map(([value, label]) => ({
|
||
value: Number(value),
|
||
label,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="signType" label="签名类型">
|
||
<Select
|
||
options={[
|
||
{ value: 1, label: '通用' },
|
||
{ value: 0, label: '验证码' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="qualificationId"
|
||
label="企业资质"
|
||
extra="从阿里云「资质管理」已通过的记录里选。没有请先在控制台做资质认证。"
|
||
>
|
||
{passedQuals.length ? (
|
||
<Select options={passedQuals} allowClear placeholder="选择已通过的资质" />
|
||
) : (
|
||
<Input placeholder="填写资质编号 QualificationId" />
|
||
)}
|
||
</Form.Item>
|
||
<Form.Item name="remark" label="申请说明">
|
||
<Input.TextArea rows={3} />
|
||
</Form.Item>
|
||
</Form>
|
||
),
|
||
onOk: async () => signMut.mutateAsync(await signForm.validateFields()),
|
||
});
|
||
}}
|
||
>
|
||
申请签名
|
||
</Button>
|
||
<Button onClick={() => signs.refetch()}>刷新列表</Button>
|
||
</Space>
|
||
<Table
|
||
className="ops-table"
|
||
rowKey={(r) => String(r.signName || r.SignName)}
|
||
loading={signs.isLoading}
|
||
dataSource={signRows}
|
||
pagination={false}
|
||
locale={{ emptyText: '还没有签名,点申请签名。' }}
|
||
columns={[
|
||
{ title: '签名', render: (_: unknown, r: SignRow) => r.signName || r.SignName },
|
||
{
|
||
title: '来源',
|
||
width: 180,
|
||
render: (_: unknown, r: SignRow) =>
|
||
SIGN_SOURCE[Number(r.signSource ?? r.SignSource)] || r.businessType || r.BusinessType || '—',
|
||
},
|
||
{
|
||
title: '类型',
|
||
width: 90,
|
||
render: (_: unknown, r: SignRow) => SIGN_TYPE[Number(r.signType ?? r.SignType)] || '—',
|
||
},
|
||
{
|
||
title: '审核',
|
||
width: 110,
|
||
render: (_: unknown, r: SignRow) => {
|
||
const s = auditText(r.auditStatus ?? r.AuditStatus);
|
||
return <Tag color={s.color}>{s.text}</Tag>;
|
||
},
|
||
},
|
||
{ title: '申请时间', width: 170, render: (_: unknown, r: SignRow) => r.createDate || r.CreateDate || '—' },
|
||
{ title: '原因', render: (_: unknown, r: SignRow) => r.reason || r.Reason || '—' },
|
||
]}
|
||
/>
|
||
</>
|
||
),
|
||
},
|
||
{
|
||
key: 'templates',
|
||
label: '模板',
|
||
children: (
|
||
<>
|
||
<Space style={{ marginBottom: 12 }}>
|
||
<Button
|
||
type="primary"
|
||
onClick={() => {
|
||
tplForm.resetFields();
|
||
tplForm.setFieldsValue({
|
||
templateName: '办公工作通知',
|
||
templateType: 1,
|
||
relatedSignName: info.defaultSign,
|
||
templateContent: '办公通知:${content}',
|
||
templateRule: '{"content":"others"}',
|
||
applySceneContent: 'https://oa.fysxkj.com',
|
||
remark:
|
||
'江苏风影随行科技办公平台向内部员工发送会议、审批待办、工作汇报、用章、报销等到岗通知。变量 content 为通知摘要,例如「请明日上午9点参加项目例会」。',
|
||
});
|
||
Modal.confirm({
|
||
title: '向阿里云申请短信模板',
|
||
width: 640,
|
||
icon: null,
|
||
content: (
|
||
<Form form={tplForm} layout="vertical" style={{ marginTop: 12 }}>
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
style={{ marginBottom: 12 }}
|
||
message={'有变量必须填 TemplateRule,例如 {"content":"others"}。模板通过后不能改内容,只能重新申请。'}
|
||
/>
|
||
<Form.Item name="templateName" label="模板名称" rules={[{ required: true }]}>
|
||
<Input maxLength={30} />
|
||
</Form.Item>
|
||
<Form.Item name="templateType" label="短信类型" rules={[{ required: true }]}>
|
||
<Select
|
||
options={Object.entries(TPL_TYPE).map(([value, label]) => ({
|
||
value: Number(value),
|
||
label,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="relatedSignName"
|
||
label="关联签名"
|
||
extra="只能选已通过的签名。此处关联仅用于审核,发送时仍用步骤关联里的签名。"
|
||
>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="templateContent"
|
||
label="模板内容"
|
||
rules={[{ required: true }]}
|
||
extra="变量写成 ${content}。通知类 others 最长 35 字。"
|
||
>
|
||
<Input.TextArea rows={4} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="templateRule"
|
||
label="变量属性 TemplateRule"
|
||
extra='模板有变量时必填 JSON。常用:others 通知正文/名称;user_nick 姓名;other_number2 单号;numberCaptcha 验证码;time 时间;address 地址;name 个人姓名。'
|
||
>
|
||
<Input.TextArea rows={2} placeholder='{"content":"others"}' />
|
||
</Form.Item>
|
||
<Form.Item name="applySceneContent" label="业务场景">
|
||
<Input placeholder="https://oa.fysxkj.com" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="remark"
|
||
label="申请说明"
|
||
extra="写清使用场景,并给出填入变量后的完整短信示例,否则容易被拒。"
|
||
>
|
||
<Input.TextArea rows={3} />
|
||
</Form.Item>
|
||
</Form>
|
||
),
|
||
onOk: async () => {
|
||
const v = await tplForm.validateFields();
|
||
const content = String(v.templateContent || '');
|
||
const hasVar = /\$\{[a-zA-Z0-9_]+\}/.test(content);
|
||
if (hasVar && !String(v.templateRule || '').trim()) {
|
||
throw new Error('模板含变量时必须填写 TemplateRule');
|
||
}
|
||
return tplMut.mutateAsync(v);
|
||
},
|
||
});
|
||
}}
|
||
>
|
||
申请模板
|
||
</Button>
|
||
<Button onClick={() => tpls.refetch()}>刷新列表</Button>
|
||
</Space>
|
||
<Table
|
||
className="ops-table"
|
||
rowKey={(r) => String(r.templateCode || r.TemplateCode)}
|
||
loading={tpls.isLoading}
|
||
dataSource={tplRows}
|
||
pagination={false}
|
||
locale={{ emptyText: '还没有模板,点申请模板,或先用已有编号发送。' }}
|
||
columns={[
|
||
{ title: '模板编号', width: 160, render: (_: unknown, r: TplRow) => r.templateCode || r.TemplateCode },
|
||
{ title: '名称', render: (_: unknown, r: TplRow) => r.templateName || r.TemplateName },
|
||
{
|
||
title: '类型',
|
||
width: 110,
|
||
render: (_: unknown, r: TplRow) => TPL_TYPE[Number(r.templateType ?? r.TemplateType)] || '—',
|
||
},
|
||
{
|
||
title: '审核',
|
||
width: 110,
|
||
render: (_: unknown, r: TplRow) => {
|
||
const s = auditText(r.auditStatus ?? r.AuditStatus);
|
||
return <Tag color={s.color}>{s.text}</Tag>;
|
||
},
|
||
},
|
||
{ title: '内容', ellipsis: true, render: (_: unknown, r: TplRow) => r.templateContent || r.TemplateContent || '—' },
|
||
{ title: '原因', ellipsis: true, render: (_: unknown, r: TplRow) => r.reason || r.Reason || '—' },
|
||
]}
|
||
/>
|
||
</>
|
||
),
|
||
},
|
||
{
|
||
key: 'logs',
|
||
label: '发送记录',
|
||
children: (
|
||
<Table
|
||
className="ops-table"
|
||
rowKey="id"
|
||
loading={logs.isLoading}
|
||
dataSource={logRows.length ? logRows : ((logs.data?.data ?? []) as typeof logRows)}
|
||
pagination={false}
|
||
locale={{ emptyText: '还没有从本系统发出的短信。' }}
|
||
columns={[
|
||
{ title: '手机号', dataIndex: 'phones', width: 180 },
|
||
{ title: '签名', dataIndex: 'signName', width: 160 },
|
||
{ title: '模板', dataIndex: 'templateCode', width: 150 },
|
||
{ title: '步骤', dataIndex: 'eventKey', width: 140, render: (v: string) => v || '手工发送' },
|
||
{
|
||
title: '结果',
|
||
dataIndex: 'code',
|
||
width: 90,
|
||
render: (v: string) => <StatusTag value={v} />,
|
||
},
|
||
{ title: '说明', dataIndex: 'message', render: (v: string) => v || '—' },
|
||
{
|
||
title: '时间',
|
||
dataIndex: 'createdAt',
|
||
width: 170,
|
||
render: (v: string) => (v ? String(v).replace('T', ' ').slice(0, 19) : '—'),
|
||
},
|
||
]}
|
||
/>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</>
|
||
);
|
||
}
|