@@ -7,19 +7,17 @@ import { Button } from "@/components/ui/button";
77import { Select , SelectContent , SelectItem , SelectTrigger , SelectValue } from "@/components/ui/select" ;
88import {
99 useUpdateRenewal ,
10- useTerminateService ,
11- useConfirmTermination ,
10+ useUpdateTerminationPolicy ,
1211 type ServiceInfo ,
1312} from "@/hooks/use-server-control" ;
14- import { Input } from "@/components/ui/input" ;
1513import { toast } from "sonner" ;
1614
1715type RenewMode = "auto" | "manual" | "delete" ;
1816
1917const MODE_OPTIONS : Array < { value : RenewMode ; label : string ; desc : string } > = [
2018 { value : "auto" , label : "自动续费" , desc : "到期前 OVH 自动扣款续费" } ,
2119 { value : "manual" , label : "手动续费" , desc : "到期前需手动付款,不付则服务终止" } ,
22- { value : "delete" , label : "到期注销 " , desc : "取消续费,到期后销毁(需邮件确认) " } ,
20+ { value : "delete" , label : "到期终止 " , desc : "到期日之前照常使用,到期后才销毁 " } ,
2321] ;
2422
2523/** 用 VPS / dedicated 各自的 update hook 都行,Dialog 只关心 mutation 接口形状 */
@@ -31,9 +29,8 @@ export type RenewalMutation = {
3129/** 终止流程的两步。VPS 和独服的端点不同(/vps-control 与 /server-control),
3230 * 所以必须由调用方注入 —— 写死一边会让另一边打到错误的端点上。 */
3331export type TerminationMutations = {
34- terminate : { mutateAsync : ( ) => Promise < any > ; isPending : boolean } ;
35- confirm : {
36- mutateAsync : ( vars : { token : string } ) => Promise < any > ;
32+ policy : {
33+ mutateAsync : ( vars : { policy : string } ) => Promise < any > ;
3734 isPending : boolean ;
3835 } ;
3936} ;
@@ -67,25 +64,20 @@ export function RenewalDialog({
6764 const defaultUpdate = useUpdateRenewal ( serviceName ) ;
6865 const update = mutation ?? defaultUpdate ;
6966
70- // 到期注销走的是 OVH 的服务终止流程,不是 serviceInfos 里的 renew 标志位。
71- // 那条路会被 OVH 回 400 "Arguments conflicting",而且它自己的 issue 里记着
72- // 这组标志位行为不可预测。终止有专用端点,也是 OVH 控制台走的那条。
73- // 代价是必须邮件确认 —— 这是 OVH 的规定,绕不开,所以对话框要有第二步。
74- const defaultTerminate = useTerminateService ( serviceName ) ;
75- const defaultConfirm = useConfirmTermination ( serviceName ) ;
76- const terminate = termination ?. terminate ?? defaultTerminate ;
77- const confirmTerm = termination ?. confirm ?? defaultConfirm ;
78- // 已经发起终止、正在等用户填邮件里的 token
79- const [ awaitingToken , setAwaitingToken ] = useState ( false ) ;
80- const [ token , setToken ] = useState ( "" ) ;
67+ // 到期终止走 PUT /services/{serviceId} 的 terminationPolicy。
68+ //
69+ // **不要**用 POST /terminate —— 那是「立即终止」,提交后 OVH 当场把服务器暂停,
70+ // 并邮件通知「5 天内不付款就彻底清除硬盘数据」。这个坑真实踩过一次。
71+ // OVH 的生命周期动作枚举里 terminate 与 terminateAtExpirationDate 是两个不同的动作,
72+ // /terminate 端点只对应前者,没有「到期」这个选项可选。
73+ const defaultPolicy = useUpdateTerminationPolicy ( serviceName ) ;
74+ const policyMut = termination ?. policy ?? defaultPolicy ;
8175
8276 // 弹窗每次打开同步当前状态
8377 useEffect ( ( ) => {
8478 if ( open ) {
8579 setMode ( currentMode ) ;
8680 setPeriod ( info . renewalPeriod || 1 ) ;
87- setAwaitingToken ( false ) ;
88- setToken ( "" ) ;
8981 }
9082 // eslint-disable-next-line react-hooks/exhaustive-deps
9183 } , [ open ] ) ;
@@ -100,38 +92,29 @@ export function RenewalDialog({
10092 const handleSubmit = async ( ) => {
10193 if ( mode === "delete" ) {
10294 try {
103- const res = await terminate . mutateAsync ( ) ;
104- setAwaitingToken ( true ) ;
105- toast . info ( res ?. message || "终止请求已提交,确认码已发到管理员邮箱" , { duration : 8000 } ) ;
95+ const res = await policyMut . mutateAsync ( { policy : "terminateAtExpirationDate" } ) ;
96+ toast . success ( res ?. message || "已设为到期终止" , { duration : 6000 } ) ;
97+ onOpenChange ( false ) ;
10698 } catch ( e : any ) {
107- toast . error ( errText ( e , "发起终止失败 " ) , { duration : 6000 } ) ;
99+ toast . error ( errText ( e , "设置失败 " ) , { duration : 8000 } ) ;
108100 }
109101 return ;
110102 }
103+ // 从「到期终止」切回自动/手动续费时,先把终止策略撤掉,
104+ // 否则续费模式改了、终止标记还挂着,到期照样销毁。
111105 try {
106+ if ( currentMode === "delete" ) {
107+ await policyMut . mutateAsync ( { policy : "empty" } ) ;
108+ }
112109 await update . mutateAsync ( { mode, period } ) ;
113- toast . success ( "续费策略已更新" ) ;
110+ toast . success ( currentMode === "delete" ? "已取消终止并更新续费策略" : "续费策略已更新" ) ;
114111 onOpenChange ( false ) ;
115112 } catch ( e : any ) {
116113 toast . error ( errText ( e , "更新失败" ) , { duration : 6000 } ) ;
117114 }
118115 } ;
119116
120- const handleConfirmTerm = async ( ) => {
121- if ( ! token . trim ( ) ) {
122- toast . error ( "请填写邮件里的确认码" ) ;
123- return ;
124- }
125- try {
126- await confirmTerm . mutateAsync ( { token : token . trim ( ) } ) ;
127- toast . success ( "已确认终止。到期日之前服务器照常运行,到期后才销毁" ) ;
128- onOpenChange ( false ) ;
129- } catch ( e : any ) {
130- toast . error ( errText ( e , "确认失败" ) , { duration : 8000 } ) ;
131- }
132- } ;
133-
134- const busy = update . isPending || terminate . isPending || confirmTerm . isPending ;
117+ const busy = update . isPending || policyMut . isPending ;
135118
136119 return (
137120 < Dialog open = { open } onOpenChange = { onOpenChange } >
@@ -213,45 +196,17 @@ export function RenewalDialog({
213196 </ div >
214197 ) }
215198
216- { mode === "delete" && ! awaitingToken && (
199+ { mode === "delete" && (
217200 < div className = "border border-destructive/40 bg-destructive/5 rounded-xl p-2.5 flex gap-2" >
218201 < AlertCircle className = "w-3.5 h-3.5 text-destructive flex-shrink-0 mt-0.5" />
219202 < div className = "text-[11px] text-muted-foreground space-y-1" >
220203 < p >
221- 服务器将在到期日 (
204+ 到期日 (
222205 { info . expiration ? new Date ( info . expiration ) . toLocaleDateString ( "zh-CN" ) : "—" }
223- ) 销毁, 数据无法恢复。
206+ ) 之前 < b > 照常使用 </ b > ,到期后才销毁, 数据无法恢复。
224207 </ p >
225- < p >
226- OVH 要求邮件确认:点下面的按钮后会往账户管理员邮箱发一封确认邮件,
227- 把里面的确认码填回来才真正生效。
228- </ p >
229- < p >
230- 到期之前服务器< b > 照常运行</ b > ,不会立即停机 —— OVH 的终止是
231- 「当期订阅结束时才生效」。立即释放资源是另一个接口,本控制台不调用。
232- </ p >
233- < p >
234- < b > 反悔要去 OVH 控制台</ b > (API 没有取消终止的端点):
235- My offers and services → 该服务右侧 < code > ...</ code > →
236- Stop cancellation of service。撤销是立即生效的。
237- </ p >
238- </ div >
239- </ div >
240- ) }
241-
242- { mode === "delete" && awaitingToken && (
243- < div className = "space-y-2 pt-1" >
244- < div className = "border border-amber-500/40 bg-amber-500/10 rounded-xl p-2.5 text-[11px] text-muted-foreground" >
245- 确认邮件已发到账户管理员邮箱。把邮件里的确认码填在下面 ——
246- < b > 没填之前终止不会生效</ b > ,服务器仍会照常续费。
208+ < p > 改主意了随时回到这里选「自动续费」或「手动续费」即可撤销。</ p >
247209 </ div >
248- < label className = "text-[12px] font-semibold block" > 邮件里的确认码</ label >
249- < Input
250- value = { token }
251- onChange = { ( e ) => setToken ( e . target . value ) }
252- placeholder = "粘贴邮件中的 token"
253- autoFocus
254- />
255210 </ div >
256211 ) }
257212 </ div >
@@ -261,23 +216,17 @@ export function RenewalDialog({
261216 < Button variant = "outline" onClick = { ( ) => onOpenChange ( false ) } >
262217 取消
263218 </ Button >
264- { ! info . renewalForced &&
265- ( awaitingToken ? (
266- < Button onClick = { handleConfirmTerm } disabled = { busy } variant = "destructive" >
267- { confirmTerm . isPending ? "确认中…" : "确认终止" }
268- </ Button >
269- ) : (
270- < Button
271- onClick = { handleSubmit }
272- disabled = {
273- busy ||
274- ( mode !== "delete" && mode === currentMode && period === info . renewalPeriod )
275- }
276- variant = { mode === "delete" ? "destructive" : "default" }
277- >
278- { busy ? "提交中…" : mode === "delete" ? "申请终止" : "保存" }
279- </ Button >
280- ) ) }
219+ { ! info . renewalForced && (
220+ < Button
221+ onClick = { handleSubmit }
222+ disabled = {
223+ busy || ( mode !== "delete" && mode === currentMode && period === info . renewalPeriod )
224+ }
225+ variant = { mode === "delete" ? "destructive" : "default" }
226+ >
227+ { busy ? "提交中…" : "保存" }
228+ </ Button >
229+ ) }
281230 </ DialogFooter >
282231 </ DialogContent >
283232 </ Dialog >
0 commit comments