Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)

含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-02 10:04:03 +00:00
commit 76f266645d
507 changed files with 99891 additions and 0 deletions
+577
View File
@@ -0,0 +1,577 @@
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) : '—'),
},
]}
/>
),
},
]}
/>
</>
);
}