Files
Public/apps/web/src/pages/SmsPage.tsx
T
daiyongkang 76f266645d Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 10:04:03 +00:00

578 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) : '—'),
},
]}
/>
),
},
]}
/>
</>
);
}