Files
qihuaiflow/frontend/src/pages/admin/SmsConfig.tsx
T

253 lines
11 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { adminAPI } from '@/api';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const SmsConfig: React.FC = () => {
const [cfg, setCfg] = useState<Record<string, string>>({
provider: '',
api_key: '',
api_secret: '',
sign_name: '',
template_code: '',
username: '',
password: '',
goods_id: '',
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [querying, setQuerying] = useState(false);
const [savedMsg, setSavedMsg] = useState<string | null>(null);
const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null);
const [balance, setBalance] = useState<{ sent: number; remain: number } | null>(null);
useEffect(() => {
(async () => {
try {
const res = await adminAPI.getSmsConfig();
const data = (res as any)?.data ?? res;
setCfg({
provider: data.provider || '',
api_key: data.api_key || '',
api_secret: data.api_secret || '',
sign_name: data.sign_name || '',
template_code: data.template_code || '',
username: data.username || '',
password: data.password || '',
goods_id: data.goods_id || '',
});
} catch (e) {
// ignore
} finally {
setLoading(false);
}
})();
}, []);
const update = (patch: Partial<Record<string, string>>) =>
setCfg((prev) => ({ ...prev, ...patch }));
const save = async () => {
setSaving(true);
setSavedMsg(null);
setTestResult(null);
try {
await adminAPI.updateSmsConfig({
provider: cfg.provider,
api_key: cfg.api_key,
api_secret: cfg.api_secret,
sign_name: cfg.sign_name,
template_code: cfg.template_code,
username: cfg.username,
password: cfg.password,
goods_id: cfg.goods_id,
});
setSavedMsg('短信配置已保存');
} catch (e: any) {
setSavedMsg('保存失败:' + (e?.response?.data?.detail || e?.message || '未知错误'));
} finally {
setSaving(false);
}
};
const sendTest = async () => {
setTesting(true);
setTestResult(null);
setSavedMsg(null);
try {
const res = await adminAPI.testSms();
const data = (res as any)?.data ?? res;
setTestResult({ ok: true, msg: data?.message || '测试短信已发送' });
} catch (e: any) {
setTestResult({ ok: false, msg: e?.response?.data?.detail || e?.message || '发送失败' });
} finally {
setTesting(false);
}
};
const queryBalance = async () => {
setQuerying(true);
setBalance(null);
setTestResult(null);
try {
const res = await adminAPI.getSmsBalance();
const data = (res as any)?.data ?? res;
setBalance({ sent: data?.sent ?? 0, remain: data?.remain ?? 0 });
} catch (e: any) {
setTestResult({ ok: false, msg: e?.response?.data?.detail || e?.message || '查询余额失败' });
} finally {
setQuerying(false);
}
};
const isSmsBao = cfg.provider === '短信宝';
if (loading) {
return <div className="admin-page"><div className="admin-page-title">短信配置</div><p style={{ color: 'var(--color-text-secondary)' }}>加载中…</p></div>;
}
return (
<div className="admin-page">
<div className="admin-page-header">
<div>
<h2 className="admin-page-title">短信配置</h2>
<p className="admin-page-subtitle">配置短信服务商,用于验证码、通知类短信的发送</p>
</div>
</div>
{savedMsg && (
<div className={`admin-alert ${savedMsg.startsWith('保存失败') ? 'admin-alert-error' : 'admin-alert-success'}`}>
{savedMsg}
</div>
)}
{testResult && (
<div className={`admin-alert ${testResult.ok ? 'admin-alert-success' : 'admin-alert-error'}`}>
{testResult.msg}
</div>
)}
{balance && (
<div className="admin-alert admin-alert-info">
短信宝账户:已发送 <b>{balance.sent}</b> 条,剩余 <b>{balance.remain}</b> 条
</div>
)}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}>
<rect x="5" y="2" width="14" height="20" rx="2" />
<line x1="12" y1="18" x2="12.01" y2="18" />
</SVGIcon>
{' '}服务商与账号
</h3>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">短信服务商</label>
<select className="admin-form-input" value={cfg.provider} onChange={(e) => update({ provider: e.target.value })}>
<option value="">请选择</option>
<option value="阿里云短信">阿里云短信</option>
<option value="腾讯云短信">腾讯云短信</option>
<option value="华为云短信">华为云短信</option>
<option value="短信宝">短信宝</option>
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">短信签名</label>
<input
className="admin-form-input"
placeholder={isSmsBao ? '如:七虎AI(发送时自动包裹【】)' : '如:七虎AI'}
value={cfg.sign_name}
onChange={(e) => update({ sign_name: e.target.value })}
/>
</div>
</div>
{isSmsBao ? (
<>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">用户名 (u)</label>
<input className="admin-form-input" value={cfg.username} onChange={(e) => update({ username: e.target.value })} placeholder="短信宝登录用户名" />
</div>
<div className="admin-form-group">
<label className="admin-form-label">密码 (p)</label>
<input className="admin-form-input" type="password" value={cfg.password} onChange={(e) => update({ password: e.target.value })} placeholder="短信宝登录密码(发送时自动 MD5)" />
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group" style={{ maxWidth: 360 }}>
<label className="admin-form-label">专属通道产品 ID (g) <span style={{ color: 'var(--color-text-muted)' }}>选填</span></label>
<input className="admin-form-input" value={cfg.goods_id} onChange={(e) => update({ goods_id: e.target.value })} placeholder="留空使用默认通用产品" />
</div>
</div>
<div className="admin-form-hint">
短信宝发送接口:<code>api.smsbao.com/sms</code>,密码以登录密码的 MD5(32位) 提交,短信内容需包含签名 <code>【签名】</code>。
</div>
</>
) : (
<>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">API Key</label>
<input className="admin-form-input" value={cfg.api_key} onChange={(e) => update({ api_key: e.target.value })} placeholder={cfg.provider === '阿里云短信' ? 'AccessKey ID' : 'API Key'} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">API Secret</label>
<input className="admin-form-input" type="password" value={cfg.api_secret} onChange={(e) => update({ api_secret: e.target.value })} placeholder={cfg.provider === '阿里云短信' ? 'AccessKey Secret' : 'API Secret'} />
</div>
</div>
{cfg.provider === '阿里云短信' && (
<div className="admin-form-row">
<div className="admin-form-group" style={{ maxWidth: 360 }}>
<label className="admin-form-label">模板 Code <span style={{ color: 'var(--color-text-muted)' }}>选填</span></label>
<input className="admin-form-input" value={cfg.template_code} onChange={(e) => update({ template_code: e.target.value })} placeholder="如 SMS_00000001" />
</div>
</div>
)}
</>
)}
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" onClick={save} disabled={saving}>
<SVGIcon><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" /><polyline points="17 21 17 13 7 13 7 21" /><polyline points="7 3 7 8 15 8" /></SVGIcon>
{saving ? '保存中…' : '保存设置'}
</button>
<button className="admin-btn admin-btn-outline" onClick={sendTest} disabled={testing}>
<SVGIcon><line x1="22" y1="2" x2="11" y2="13" /><polygon points="22 2 15 22 11 13 2 9 2 2" /></SVGIcon>
{testing ? '发送中…' : '发送测试短信'}
</button>
{isSmsBao && (
<button className="admin-btn admin-btn-outline" onClick={queryBalance} disabled={querying}>
<SVGIcon><line x1="12" y1="1" x2="12" y2="23" /><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /></SVGIcon>
{querying ? '查询中…' : '查询余额'}
</button>
)}
</div>
</div>
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><circle cx="12" cy="12" r="10" /><line x1="12" y1="16" x2="12" y2="12" /><line x1="12" y1="8" x2="12.01" y2="8" /></SVGIcon>
{' '}说明
</h3>
</div>
<ul className="admin-tips">
<li>测试短信将发送至<strong>当前管理员账号绑定的手机号</strong>,请先在用户管理中完善手机号。</li>
<li>阿里云、腾讯云、华为云使用平台统一的短信网关;短信宝为独立通道,支持余额查询。</li>
<li>短信内容须符合运营商规范,包含签名与退订提示,避免触发敏感词拦截。</li>
</ul>
</div>
</div>
);
};
export default SmsConfig;