File size: 16,042 Bytes
4283ada
1f4cfcb
3df0958
395e9dc
1f4cfcb
 
395e9dc
3df0958
1f4cfcb
 
 
395e9dc
 
 
3df0958
 
395e9dc
 
1f4cfcb
a6cca4c
40b21ae
395e9dc
 
 
a6cca4c
4283ada
2ddc3ee
1f4cfcb
395e9dc
2204456
395e9dc
 
1f4cfcb
 
 
395e9dc
4283ada
 
 
5db48a2
1f4cfcb
5db48a2
1f4cfcb
5db48a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395e9dc
 
1f4cfcb
 
 
3df0958
1f4cfcb
395e9dc
 
 
1f4cfcb
 
3df0958
 
4283ada
395e9dc
1f4cfcb
 
3df0958
1f4cfcb
 
395e9dc
4283ada
 
 
1f4cfcb
4283ada
 
 
 
 
 
3df0958
2204456
 
 
 
 
 
 
 
 
 
5db48a2
 
 
 
 
2204456
4283ada
395e9dc
1f4cfcb
5db48a2
4283ada
 
 
395e9dc
 
e6cc775
395e9dc
1f4cfcb
4283ada
 
 
1f4cfcb
 
 
5db48a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2204456
4283ada
2204456
 
1f4cfcb
 
2204456
 
 
 
 
4283ada
 
 
 
5aff1af
 
4283ada
1f4cfcb
3df0958
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f4cfcb
 
3df0958
 
1f4cfcb
3df0958
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395e9dc
3df0958
395e9dc
4283ada
 
40b21ae
a6cca4c
 
 
 
 
 
 
 
 
 
395e9dc
 
a6cca4c
 
2ddc3ee
395e9dc
 
 
 
 
e6cc775
2ddc3ee
1f4cfcb
2ddc3ee
 
e6cc775
2ddc3ee
 
4283ada
1f4cfcb
 
 
 
2204456
395e9dc
 
2204456
 
1f4cfcb
 
2204456
 
 
 
395e9dc
 
 
 
4283ada
2ddc3ee
4283ada
 
 
3df0958
5aff1af
3df0958
5aff1af
3df0958
5aff1af
3df0958
4283ada
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1f4cfcb
4283ada
 
 
 
1f4cfcb
4283ada
 
 
 
1f4cfcb
4283ada
 
 
 
1f4cfcb
 
 
4283ada
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
<template>
  <div class="glass rounded-lg p-5">
    <div class="flex items-center justify-between mb-4 gap-3 flex-wrap">
      <div>
        <h2 class="text-base font-bold text-ink-950 tracking-tight">{{ panelTitle }}</h2>
        <p class="text-[11px] text-ink-500 mt-0.5">提交后立即进入后台任务观察窗口,完成后自动同步状态。</p>
      </div>
      <div v-if="runningTask" class="flex items-center gap-2 text-xs">
        <span class="text-ink-500 uppercase tracking-widest text-[10px]">Running</span>
        <span class="font-mono text-amber-800 px-2 py-0.5 rounded bg-amber-50 border border-amber-200">{{ runningTask.command }}</span>
        <span class="font-mono text-ink-600">{{ runningTask.task_id ? runningTask.task_id.slice(0, 8) : '' }}</span>
        <AtButton variant="danger" size="sm" :loading="cancelling" :disabled="cancelRequested" @click="cancelTask">
          {{ cancelRequested ? '停止中…' : '停止任务' }}
        </AtButton>
      </div>
    </div>

    <div v-if="showAdminHint"
      class="mb-4 px-4 py-2.5 rounded-lg text-sm border bg-amber-50 text-amber-800 border-amber-200">
      {{ adminHint }}
    </div>

    <!-- 操作按钮区:visualy grouped -->
    <div class="flex flex-wrap gap-2.5">
      <button v-for="action in visibleActions" :key="action.key"
        @click="execute(action)"
        :disabled="isDisabled(action)"
        class="relative h-10 px-4 rounded-lg text-sm font-semibold border transition-all
               lift-hover focus-ring select-none whitespace-nowrap
               disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-ink-100 disabled:text-ink-400 disabled:border-hairline"
        :class="actionColorClass(action)">
        <span class="inline-flex items-center gap-2">
          <span v-if="isSubmitting(action)" class="inline-block w-4 h-4 rounded-full border-2 border-current border-t-transparent animate-spin"></span>
          <component v-else :is="action.icon" class="w-4 h-4 shrink-0" :stroke-width="2" />
          {{ isSubmitting(action) ? '提交中' : action.label }}
        </span>
      </button>
    </div>

    <!-- round-12 F2 — rotate 实时进度面板 (SSE) -->
    <div class="mt-4 rounded-lg border border-hairline bg-surface">
      <button type="button"
        class="w-full flex items-center justify-between gap-3 px-4 py-2.5 text-sm focus-ring rounded-lg"
        @click="showProgress = !showProgress">
        <span class="inline-flex items-center gap-2 font-semibold text-ink-700">
          <Activity class="w-4 h-4" :class="rotateStream.isConnected.value ? 'text-emerald-600' : 'text-ink-400'" :stroke-width="2" />
          实时进度
          <span v-if="rotateStream.events.value.length"
            class="ml-1 px-1.5 py-0.5 rounded-full text-[10px] font-mono bg-indigo-50 text-indigo-700 border border-indigo-200">
            {{ rotateStream.events.value.length }}
          </span>
          <span v-if="!rotateStream.isConnected.value" class="text-[11px] text-ink-400 font-normal">
            (未连接 SSE)
          </span>
        </span>
        <component :is="showProgress ? ChevronUp : ChevronDown" class="w-4 h-4 text-ink-400" :stroke-width="2" />
      </button>
      <div v-if="showProgress" class="px-4 pb-3 pt-1 space-y-1.5 max-h-64 overflow-y-auto">
        <div v-if="!rotateStream.events.value.length" class="text-xs text-ink-400 py-2 text-center">
          暂无转移事件 — 启动一次轮转/补满任务,这里会实时显示账号状态变更。
        </div>
        <div v-for="(ev, idx) in rotateStream.events.value" :key="ev.ts + ':' + idx"
          class="flex items-start gap-2 px-2.5 py-1.5 rounded-lg border text-xs animate-rise"
          :class="transitionTone(ev)">
          <component :is="transitionIcon(ev)" class="w-3.5 h-3.5 mt-0.5 shrink-0" :stroke-width="2" />
          <div class="flex-1 min-w-0">
            <div class="font-mono truncate">{{ ev.email }}</div>
            <div class="text-[11px] opacity-80">
              <span class="font-mono">{{ statusLabel(ev.from) || '—' }}</span>
              <span class="mx-1"></span>
              <span class="font-mono font-semibold">{{ statusLabel(ev.to) }}</span>
              <span v-if="ev.reason" class="ml-1 opacity-70">· {{ ev.reason }}</span>
            </div>
          </div>
          <span class="text-[10px] font-mono opacity-60 mt-0.5 shrink-0">{{ formatTransitionTime(ev.ts) }}</span>
        </div>
      </div>
    </div>

    <!-- 注册域名切换(仅 pool 模式可见) -->
    <div v-if="mode === 'pool'"
      class="mt-5 p-3 rounded-lg border border-hairline bg-ink-50 flex flex-wrap items-center gap-2 text-sm">
      <span class="text-[10px] uppercase tracking-widest text-ink-500 font-semibold mr-1">注册域名</span>
      <span class="text-ink-400">@</span>
      <input v-model="domainInput" type="text" placeholder="your-domain.com"
        class="flex-1 min-w-[180px] px-3 py-1.5 bg-surface border border-hairline rounded-lg text-ink-950 text-sm font-mono focus-ring transition" />
      <AtButton variant="primary" size="sm" :loading="domainBusy" :disabled="!domainInput" @click="saveDomain">
        保存并验证
      </AtButton>
      <span v-if="currentDomain" class="text-[11px] text-ink-500 font-mono">当前: @{{ currentDomain }}</span>
      <span v-if="domainMsg" class="ml-1 text-[11px] font-medium" :class="domainMsgOk ? 'text-emerald-700' : 'text-rose-700'">{{ domainMsg }}</span>
    </div>

    <!-- 参数输入 -->
    <div v-if="showParams"
      class="mt-4 p-3 rounded-lg border border-indigo-200 bg-indigo-50 flex items-center gap-3 animate-rise">
      <label class="text-[11px] uppercase tracking-widest text-indigo-700 font-semibold">{{ paramLabel }}</label>
      <input v-model.number="paramValue" type="number" min="1" :max="paramMax"
        class="w-24 px-3 py-1.5 bg-surface border border-hairline rounded-lg text-ink-950 text-sm font-mono focus-ring tabular" />
      <AtButton variant="primary" size="sm" :loading="!!executingActionKey" @click="confirmAction">确认执行</AtButton>
      <AtButton variant="ghost" size="sm" @click="showParams = false">取消</AtButton>
    </div>

    <!-- 结果提示 -->
    <div v-if="message" class="mt-4 px-4 py-2.5 rounded-lg text-sm border animate-rise" :class="messageClass">
      {{ message }}
    </div>
  </div>
</template>

<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import {
  RotateCw,
  ChartPie,
  Plus,
  Coins,
  UserPlus,
  Brush,
  RefreshCw,
  Download,
  Users,
  Activity,
  Check,
  AlertTriangle,
  ChevronDown,
  ChevronUp,
} from 'lucide-vue-next'
import { api } from '../api.js'
import AtButton from './AtButton.vue'
import { useRotateStream } from '../composables/useRotateStream.js'
import { statusLabel } from '../composables/useStatus.js'

const props = defineProps({
  runningTask: Object,
  adminStatus: { type: Object, default: null },
  mode: { type: String, default: 'all' },
  // Round 8 — 母号订阅健康度,degraded 时禁用 fill-personal
  masterHealth: { type: Object, default: null },
  rotateStream: { type: Object, default: null },
})
const emit = defineEmits(['task-started', 'refresh'])

// round-12 F2/F3 — 共享 SSE 流直接用于实时进度展示;
// App 级状态层负责统一 invalidation,这里不再额外挂一层桥接。
const rotateStream = props.rotateStream || useRotateStream()
const showProgress = ref(false)

function transitionIcon(ev) {
  // 派发 lucide icon:to=active/personal → Check;to=auth_invalid/orphan → AlertTriangle;
  // 其余 (pending/standby/exhausted/grace) → Activity
  const t = ev?.to
  if (t === 'active' || t === 'personal') return Check
  if (t === 'auth_invalid' || t === 'orphan' || t === 'exhausted') return AlertTriangle
  return Activity
}

function transitionTone(ev) {
  const t = ev?.to
  if (t === 'active' || t === 'personal') {
    return 'text-emerald-700 bg-emerald-50 border-emerald-200'
  }
  if (t === 'auth_invalid' || t === 'orphan' || t === 'exhausted') {
    return 'text-rose-700 bg-rose-50 border-rose-200'
  }
  if (t === 'degraded_grace' || t === 'standby') {
    return 'text-amber-700 bg-amber-50 border-amber-200'
  }
  return 'text-indigo-700 bg-indigo-50 border-indigo-200'
}

function formatTransitionTime(ts) {
  if (!ts) return ''
  const d = new Date(ts * 1000)
  const pad = (n) => String(n).padStart(2, '0')
  return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}

// round-12 F1 — emoji → Lucide
const actions = [
  { key: 'rotate', group: 'pool', label: '智能轮转', icon: RotateCw, method: 'startRotate', needParam: true, paramName: 'target', tone: 'primary' },
  { key: 'check', group: 'pool', label: '检查额度', icon: ChartPie, method: 'startCheck', needParam: false, tone: 'emerald' },
  { key: 'fill', group: 'pool', label: '补满成员', icon: Plus, method: 'startFill', needParam: true, paramName: 'target', tone: 'teal' },
  { key: 'fill-personal', group: 'pool', label: '生成免费号', icon: Coins, method: 'startFillPersonal', needParam: true, paramName: 'count', tone: 'lime' },
  { key: 'add', group: 'pool', label: '添加账号', icon: UserPlus, method: 'startAdd', needParam: false, tone: 'amber' },
  { key: 'cleanup', group: 'pool', label: '清理成员', icon: Brush, method: 'startCleanup', needParam: false, tone: 'rose' },
  { key: 'sync', group: 'sync', label: '同步 CPA', icon: RefreshCw, method: 'postSync', needParam: false, sync: true, allowWithoutAdmin: true, tone: 'cyan' },
  { key: 'pull-cpa', group: 'sync', label: '拉取 CPA', icon: Download, method: 'postSyncFromCpa', needParam: false, sync: true, allowWithoutAdmin: true, tone: 'emerald' },
  { key: 'sync-accounts', group: 'sync', label: '同步账号', icon: Users, method: 'postSyncAccounts', needParam: false, sync: true, allowWithoutAdmin: true, tone: 'sky' },
]

const showParams = ref(false)
const paramLabel = ref('')
const paramValue = ref(3)
const paramMax = ref(3)
const pendingAction = ref(null)
const executingActionKey = ref('')

const cancelling = ref(false)
const cancelRequested = ref(false)

watch(() => props.runningTask?.task_id, (newId, oldId) => {
  if (newId !== oldId) {
    cancelling.value = false
    cancelRequested.value = false
  }
})
watch(() => props.runningTask?.cancel_requested, (v) => {
  if (v) cancelRequested.value = true
}, { immediate: true })

async function cancelTask() {
  if (cancelling.value || cancelRequested.value) return
  const task = props.runningTask
  if (!task) return
  const ok = window.confirm(`确认停止当前任务?\n\n命令: ${task.command}\nID: ${task.task_id}\n\n当前步骤(如正在浏览器内跑的账号)会先跑完,之后不再启动下一步。`)
  if (!ok) return
  cancelling.value = true
  try {
    const r = await api.cancelTask()
    cancelRequested.value = true
    message.value = r.message || '已请求停止'
    messageClass.value = 'bg-amber-50 text-amber-800 border-amber-200'
    emit('refresh')
  } catch (e) {
    message.value = `停止失败: ${e.message}`
    messageClass.value = 'bg-rose-50 text-rose-700 border-rose-200'
  } finally {
    cancelling.value = false
    setTimeout(() => { if (messageClass.value.includes('amber')) message.value = '' }, 10000)
  }
}

const domainInput = ref('')
const currentDomain = ref('')
const domainBusy = ref(false)
const domainMsg = ref('')
const domainMsgOk = ref(false)

async function loadDomain() {
  try {
    const d = await api.getRegisterDomain()
    currentDomain.value = d.domain || ''
    if (!domainInput.value) domainInput.value = d.domain || ''
  } catch (e) {
    domainMsg.value = `读取失败: ${e.message}`
    domainMsgOk.value = false
  }
}

async function saveDomain() {
  if (!domainInput.value) return
  domainBusy.value = true
  domainMsg.value = ''
  try {
    const r = await api.setRegisterDomain(domainInput.value.replace(/^@/, '').trim(), true)
    currentDomain.value = r.domain || ''
    domainMsg.value = r.message || '已保存'
    domainMsgOk.value = true
  } catch (e) {
    domainMsg.value = e.message
    domainMsgOk.value = false
  } finally {
    domainBusy.value = false
    setTimeout(() => { domainMsg.value = '' }, 8000)
  }
}

onMounted(() => { if (props.mode === 'pool') loadDomain() })
watch(() => props.mode, (m) => { if (m === 'pool') loadDomain() })

const message = ref('')
const messageClass = ref('')
const adminReady = computed(() => !!props.adminStatus?.configured)
const visibleActions = computed(() => {
  if (props.mode === 'all') return actions
  return actions.filter(action => action.group === props.mode)
})
const panelTitle = computed(() => {
  if (props.mode === 'pool') return '账号池操作'
  if (props.mode === 'sync') return '同步操作'
  return '操作'
})
const adminHint = computed(() => {
  if (props.mode === 'sync') return '同步类操作可独立使用:同步账号、同步 CPA、拉取 CPA。'
  return '请先在「设置」页完成管理员登录后,轮转/补满/清理等账号池操作才会开放。'
})
const showAdminHint = computed(() => !adminReady.value && (props.mode === 'pool' || props.mode === 'sync'))

const masterDegraded = computed(() => !!(
  props.masterHealth
  && props.masterHealth.healthy === false
  && props.masterHealth.reason === 'subscription_cancelled'
))

function isDisabled(action) {
  if (executingActionKey.value) return true
  if (props.runningTask) return true
  if (!adminReady.value && !action.allowWithoutAdmin) return true
  if (action.key === 'fill-personal' && masterDegraded.value) return true
  return false
}

function isSubmitting(action) {
  return executingActionKey.value === action.key
}

// 按 tone 给按钮配色 — round-12 F1 Bright v1
function actionColorClass(action) {
  const map = {
    primary: 'text-indigo-700 bg-indigo-50 border-indigo-200 hover:bg-indigo-100 hover:border-indigo-300',
    emerald: 'text-emerald-700 bg-emerald-50 border-emerald-200 hover:bg-emerald-100',
    teal: 'text-teal-700 bg-teal-50 border-teal-200 hover:bg-teal-100',
    lime: 'text-lime-700 bg-lime-50 border-lime-200 hover:bg-lime-100',
    amber: 'text-amber-800 bg-amber-50 border-amber-200 hover:bg-amber-100',
    rose: 'text-rose-700 bg-rose-50 border-rose-200 hover:bg-rose-100',
    cyan: 'text-cyan-700 bg-cyan-50 border-cyan-200 hover:bg-cyan-100',
    sky: 'text-sky-700 bg-sky-50 border-sky-200 hover:bg-sky-100',
  }
  return map[action.tone] || map.primary
}

async function execute(action) {
  if (isDisabled(action)) return
  message.value = ''
  if (action.needParam) {
    pendingAction.value = action
    if (action.paramName === 'target') {
      paramLabel.value = '目标成员数'; paramMax.value = 3; paramValue.value = 3
    } else if (action.paramName === 'count') {
      paramLabel.value = '生成数量'; paramMax.value = 2; paramValue.value = 1
    } else {
      paramLabel.value = '最大席位'; paramMax.value = 3; paramValue.value = 3
    }
    showParams.value = true
    return
  }
  await doExecute(action)
}

async function confirmAction() {
  showParams.value = false
  if (pendingAction.value) {
    await doExecute(pendingAction.value, paramValue.value)
    pendingAction.value = null
  }
}

async function doExecute(action, param) {
  executingActionKey.value = action.key
  try {
    if (action.sync) {
      const result = await api[action.method]()
      message.value = result.message || '操作完成'
      messageClass.value = 'bg-emerald-50 text-emerald-700 border-emerald-200'
      emit('refresh')
    } else {
      const result = await api[action.method](param)
      message.value = `任务已提交: ${result.task_id}`
      messageClass.value = 'bg-sky-50 text-sky-700 border-sky-200'
      emit('task-started')
    }
  } catch (e) {
    message.value = e.message
    messageClass.value = 'bg-rose-50 text-rose-700 border-rose-200'
  } finally {
    executingActionKey.value = ''
  }
  setTimeout(() => { message.value = '' }, 8000)
}
</script>