TroovBookLimitManager.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. 'use client';
  2. import { useState, useEffect, useMemo } from 'react';
  3. import api from '@/lib/api';
  4. import {
  5. Loader2, Save, RefreshCw, Settings2, Plus, Trash2, AlertCircle
  6. } from 'lucide-react';
  7. import { toast } from 'react-hot-toast'; // 假设你项目中使用了 react-hot-toast,如果没有可以用 alert 替代
  8. export default function TroovBookLimitManager() {
  9. const [limits, setLimits] = useState<Record<string, number>>({});
  10. const [loading, setLoading] = useState(false);
  11. const [saving, setSaving] = useState(false);
  12. // 新增时间点的表单状态
  13. const [newTime, setNewTime] = useState('');
  14. const [newValue, setNewValue] = useState(1);
  15. useEffect(() => {
  16. fetchConfig();
  17. }, []);
  18. // 获取配置
  19. const fetchConfig = async () => {
  20. setLoading(true);
  21. try {
  22. const res = await api.get('/api/dynamic-configurations/key/troov.book.limit');
  23. let val = res.data?.data?.config_value;
  24. // 兼容后端可能返回 JSON 字符串或直接返回对象的情况
  25. if (typeof val === 'string') {
  26. try { val = JSON.parse(val); } catch (e) { val = {}; }
  27. }
  28. setLimits(val || {});
  29. } catch (error) {
  30. console.error('获取配置失败:', error);
  31. toast.error('加载名额配置失败');
  32. } finally {
  33. setLoading(false);
  34. }
  35. };
  36. // 保存配置
  37. const handleSave = async () => {
  38. setSaving(true);
  39. try {
  40. // 按照你提供的 curl 示例,PUT 接口需要将 config_value 作为序列化后的 JSON 字符串传入
  41. await api.put('/api/dynamic-configurations/key/troov.book.limit', {
  42. config_value: JSON.stringify(limits)
  43. });
  44. toast.success('配置保存成功!');
  45. fetchConfig();
  46. } catch (error) {
  47. console.error('保存配置失败:', error);
  48. toast.error('保存配置失败,请重试');
  49. } finally {
  50. setSaving(false);
  51. }
  52. };
  53. // 更新某个时间点的值
  54. const handleUpdateLimit = (time: string, value: string) => {
  55. const num = parseInt(value, 10);
  56. setLimits(prev => ({
  57. ...prev,
  58. [time]: isNaN(num) ? 0 : Math.max(0, num) // 保证不小于0
  59. }));
  60. };
  61. // 删除某个时间点
  62. const handleDeleteTime = (time: string) => {
  63. if (!confirm(`确定要删除 ${time} 的配置吗?`)) return;
  64. const newLimits = { ...limits };
  65. delete newLimits[time];
  66. setLimits(newLimits);
  67. };
  68. // 新增时间点
  69. const handleAddTime = (e: React.FormEvent) => {
  70. e.preventDefault();
  71. if (!newTime) return;
  72. // 简单校验格式 HH:mm
  73. if (!/^\d{2}:\d{2}$/.test(newTime)) {
  74. return toast.error('时间格式需为 HH:mm,如 09:30');
  75. }
  76. if (limits[newTime] !== undefined) {
  77. return toast.error('该时间点已存在!');
  78. }
  79. setLimits(prev => ({ ...prev, [newTime]: newValue }));
  80. setNewTime('');
  81. setNewValue(1);
  82. };
  83. // 对时间键进行排序 (09:00, 09:15, 10:00...) 方便展示
  84. const sortedTimes = useMemo(() => {
  85. return Object.keys(limits).sort((a, b) => {
  86. const [hA, mA] = a.split(':').map(Number);
  87. const [hB, mB] = b.split(':').map(Number);
  88. return (hA * 60 + mA) - (hB * 60 + mB);
  89. });
  90. }, [limits]);
  91. return (
  92. <div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden flex flex-col">
  93. {/* 头部 */}
  94. <div className="p-4 border-b border-slate-200 bg-slate-50 flex flex-wrap gap-4 items-center justify-between">
  95. <h2 className="font-bold text-slate-800 flex items-center gap-2">
  96. <Settings2 size={18} className="text-teal-600" />
  97. Troov 预约名额限制配置
  98. </h2>
  99. <div className="flex items-center gap-3">
  100. <button
  101. onClick={fetchConfig}
  102. disabled={loading}
  103. className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-600 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition disabled:opacity-50"
  104. >
  105. <RefreshCw size={14} className={loading ? 'animate-spin' : ''} /> 刷新
  106. </button>
  107. <button
  108. onClick={handleSave}
  109. disabled={saving}
  110. className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-bold text-white bg-teal-600 rounded-lg shadow-sm hover:bg-teal-700 transition disabled:opacity-50"
  111. >
  112. {saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
  113. 保存配置
  114. </button>
  115. </div>
  116. </div>
  117. {/* 主体内容 */}
  118. <div className="p-4 md:p-6 bg-slate-50/30">
  119. {loading && Object.keys(limits).length === 0 ? (
  120. <div className="py-12 flex justify-center text-slate-400">
  121. <Loader2 className="animate-spin" size={24} />
  122. </div>
  123. ) : (
  124. <div className="space-y-6">
  125. {/* 警告提示 */}
  126. <div className="flex items-start gap-2 bg-amber-50 text-amber-800 p-3 rounded-lg border border-amber-200 text-sm">
  127. <AlertCircle size={16} className="mt-0.5 shrink-0" />
  128. <p>修改此配置将实时影响 Troov 法签机器人对应时间点的<strong>单日最大预约数</strong>。点击右上角“保存配置”后生效。</p>
  129. </div>
  130. {/* 配置网格 */}
  131. <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
  132. {sortedTimes.map((time) => (
  133. <div key={time} className="flex items-center justify-between bg-white border border-slate-200 p-2 rounded-lg shadow-sm group">
  134. <span className="font-mono font-bold text-slate-700 text-sm pl-1">{time}</span>
  135. <div className="flex items-center gap-1">
  136. <input
  137. type="number"
  138. min="0"
  139. className="w-14 text-center text-sm font-bold border border-slate-200 rounded p-1 outline-none focus:border-teal-500 focus:ring-1 focus:ring-teal-500"
  140. value={limits[time]}
  141. onChange={(e) => handleUpdateLimit(time, e.target.value)}
  142. />
  143. <button
  144. onClick={() => handleDeleteTime(time)}
  145. className="p-1 text-slate-300 hover:text-red-500 hover:bg-red-50 rounded transition opacity-0 group-hover:opacity-100"
  146. title="删除时间点"
  147. >
  148. <Trash2 size={14} />
  149. </button>
  150. </div>
  151. </div>
  152. ))}
  153. </div>
  154. {/* 快速添加时间点 */}
  155. <form onSubmit={handleAddTime} className="flex items-center gap-2 pt-4 border-t border-slate-100">
  156. <span className="text-sm font-bold text-slate-500">新增时段:</span>
  157. <input
  158. type="time"
  159. required
  160. className="border border-slate-300 rounded-md px-2 py-1.5 text-sm font-mono outline-none focus:border-teal-500"
  161. value={newTime}
  162. onChange={(e) => setNewTime(e.target.value)}
  163. />
  164. <span className="text-sm font-bold text-slate-500">数量:</span>
  165. <input
  166. type="number"
  167. min="1"
  168. required
  169. className="w-16 border border-slate-300 rounded-md px-2 py-1.5 text-sm outline-none focus:border-teal-500 text-center"
  170. value={newValue}
  171. onChange={(e) => setNewValue(parseInt(e.target.value) || 1)}
  172. />
  173. <button
  174. type="submit"
  175. className="flex items-center gap-1 px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-sm font-medium rounded-md transition"
  176. >
  177. <Plus size={14} /> 添加
  178. </button>
  179. </form>
  180. </div>
  181. )}
  182. </div>
  183. </div>
  184. );
  185. }