更新
This commit is contained in:
+55
-1
@@ -20,7 +20,7 @@ from settings import CORS_ORIGINS, SERVE_WEB, STATIC_DIR
|
||||
from help_pages import serve_credential_tool
|
||||
from web_static import mount_frontend
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, update, delete, text, func
|
||||
from sqlalchemy import select, update, delete, text, func, case
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.database import engine, Base, get_db, AsyncSessionLocal
|
||||
@@ -625,6 +625,15 @@ class AccountResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DashboardAccountStatsResponse(BaseModel):
|
||||
"""Safe account totals shown to every authenticated dashboard user."""
|
||||
|
||||
total_accounts: int = 0
|
||||
online_accounts: int = 0
|
||||
my_accounts: int = 0
|
||||
my_online_accounts: int = 0
|
||||
|
||||
|
||||
class AccountUpdate(BaseModel):
|
||||
phone: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
@@ -1010,6 +1019,51 @@ def _account_matches_keyword(account: Account, keyword: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/api/dashboard/account-stats",
|
||||
response_model=DashboardAccountStatsResponse,
|
||||
)
|
||||
async def get_dashboard_account_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return public platform totals without exposing other users' accounts."""
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.count(Account.id).label("total_accounts"),
|
||||
func.coalesce(
|
||||
func.sum(case((Account.status == "online", 1), else_=0)),
|
||||
0,
|
||||
).label("online_accounts"),
|
||||
func.coalesce(
|
||||
func.sum(case((Account.owner_id == user.id, 1), else_=0)),
|
||||
0,
|
||||
).label("my_accounts"),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
(Account.owner_id == user.id)
|
||||
& (Account.status == "online"),
|
||||
1,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
).label("my_online_accounts"),
|
||||
)
|
||||
)
|
||||
row = result.one()
|
||||
return DashboardAccountStatsResponse(
|
||||
total_accounts=int(row.total_accounts or 0),
|
||||
online_accounts=int(row.online_accounts or 0),
|
||||
my_accounts=int(row.my_accounts or 0),
|
||||
my_online_accounts=int(row.my_online_accounts or 0),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/accounts")
|
||||
async def get_accounts(
|
||||
page: Optional[int] = Query(None, ge=1),
|
||||
|
||||
@@ -87,20 +87,29 @@ class AccountReplyQueue:
|
||||
details: Optional[dict[str, Any]] = None,
|
||||
merge_key: str = "",
|
||||
merge_keys: Optional[Iterable[str]] = None,
|
||||
immediate_if_idle: bool = False,
|
||||
) -> int:
|
||||
"""Append one reply job and return its current 1-based queue position."""
|
||||
"""Append one reply job and return its current 1-based queue position.
|
||||
|
||||
When ``immediate_if_idle`` is enabled, the first job in a completely
|
||||
idle account queue reserves a zero-second slot. Jobs arriving behind
|
||||
it still reserve the configured interval, so the normal per-account
|
||||
pacing resumes from the second job onward.
|
||||
"""
|
||||
interval = max(0.0, float(delay_seconds or 0))
|
||||
loop = asyncio.get_running_loop()
|
||||
async with self._state_lock:
|
||||
if not self._running or not self._task or self._task.done():
|
||||
raise RuntimeError("reply queue is not running")
|
||||
due_at = max(loop.time(), self._tail_due_at) + interval
|
||||
queue_is_idle = self.pending_count == 0
|
||||
slot_seconds = 0.0 if immediate_if_idle and queue_is_idle else interval
|
||||
due_at = max(loop.time(), self._tail_due_at) + slot_seconds
|
||||
self._tail_due_at = due_at
|
||||
self._waiting.append(
|
||||
_QueueItem(
|
||||
job_id=uuid.uuid4().hex,
|
||||
due_at=due_at,
|
||||
slot_seconds=interval,
|
||||
slot_seconds=slot_seconds,
|
||||
callback=callback,
|
||||
description=description,
|
||||
queued_at=time.time(),
|
||||
@@ -256,9 +265,10 @@ class AccountReplyQueue:
|
||||
item = self._waiting.pop(selected_index)
|
||||
shift_seconds = max(0.0, item.slot_seconds)
|
||||
shifted_count = 0
|
||||
for later in self._waiting[selected_index:]:
|
||||
later.due_at -= shift_seconds
|
||||
shifted_count += 1
|
||||
if shift_seconds > 0:
|
||||
for later in self._waiting[selected_index:]:
|
||||
later.due_at -= shift_seconds
|
||||
shifted_count += 1
|
||||
|
||||
item.due_at = asyncio.get_running_loop().time()
|
||||
item.expedited = True
|
||||
|
||||
@@ -578,19 +578,32 @@ class DouyinImService:
|
||||
"replies": list(replies),
|
||||
},
|
||||
merge_keys=queue_merge_keys,
|
||||
immediate_if_idle=True,
|
||||
)
|
||||
scheduled_wait = 0 if position == 1 else delay_seconds
|
||||
logger.info(
|
||||
"Queued reply to %s for account %s: position=%s interval=%ss",
|
||||
"Queued reply to %s for account %s: position=%s wait=%ss interval=%ss",
|
||||
sender,
|
||||
self.account_id,
|
||||
position,
|
||||
scheduled_wait,
|
||||
delay_seconds,
|
||||
)
|
||||
if position == 1:
|
||||
queue_detail = (
|
||||
f"{sender} 是当前账号队列的首条任务,等待时间为 0 秒;"
|
||||
f"后续任务仍按 {delay_seconds} 秒间隔排队。"
|
||||
)
|
||||
else:
|
||||
queue_detail = (
|
||||
f"{sender} 当前排在第 {position} 位;账号生效间隔为 {delay_seconds} 秒,"
|
||||
"后续任务继续依次排队。"
|
||||
)
|
||||
system_logger.record(
|
||||
"自动回复已进入账号队列",
|
||||
detail=(
|
||||
f"{sender} 当前排在第 {position} 位;账号生效间隔为 {delay_seconds} 秒,"
|
||||
"账号内计时与排位独立;到点后再进入全局带宽队列逐条投递。"
|
||||
f"{queue_detail} 账号内计时与排位独立;"
|
||||
"发送时仍进入全局带宽队列逐条投递。"
|
||||
),
|
||||
level="info",
|
||||
category="send",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from collections import namedtuple
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
os.environ["KEFU_DB_TYPE"] = "sqlite"
|
||||
os.environ["KEFU_DATABASE_URL"] = ""
|
||||
os.environ["KEFU_DB_PATH"] = str(BACKEND_DIR / "kefu.db")
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
import main
|
||||
|
||||
|
||||
_AggregateRow = namedtuple(
|
||||
"_AggregateRow",
|
||||
(
|
||||
"total_accounts",
|
||||
"online_accounts",
|
||||
"my_accounts",
|
||||
"my_online_accounts",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _AggregateResult:
|
||||
def __init__(self, row):
|
||||
self._row = row
|
||||
|
||||
def one(self):
|
||||
return self._row
|
||||
|
||||
|
||||
class DashboardAccountStatsTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_conditional_aggregate_maps_global_and_personal_counts(self):
|
||||
row = _AggregateRow(
|
||||
total_accounts=12,
|
||||
online_accounts=5,
|
||||
my_accounts=3,
|
||||
my_online_accounts=2,
|
||||
)
|
||||
db = SimpleNamespace(
|
||||
execute=AsyncMock(return_value=_AggregateResult(row))
|
||||
)
|
||||
user = SimpleNamespace(id=42, role="operator")
|
||||
|
||||
response = await main.get_dashboard_account_stats(db=db, user=user)
|
||||
|
||||
self.assertIsInstance(response, main.DashboardAccountStatsResponse)
|
||||
self.assertEqual(response.total_accounts, 12)
|
||||
self.assertEqual(response.online_accounts, 5)
|
||||
self.assertEqual(response.my_accounts, 3)
|
||||
self.assertEqual(response.my_online_accounts, 2)
|
||||
db.execute.assert_awaited_once()
|
||||
|
||||
async def test_owner_scope_is_only_inside_personal_aggregates(self):
|
||||
row = _AggregateRow(
|
||||
total_accounts=8,
|
||||
online_accounts=4,
|
||||
my_accounts=2,
|
||||
my_online_accounts=1,
|
||||
)
|
||||
db = SimpleNamespace(
|
||||
execute=AsyncMock(return_value=_AggregateResult(row))
|
||||
)
|
||||
user = SimpleNamespace(id=73, role="viewer")
|
||||
|
||||
await main.get_dashboard_account_stats(db=db, user=user)
|
||||
|
||||
statement = db.execute.await_args.args[0]
|
||||
sql = " ".join(str(statement).lower().split())
|
||||
compiled_params = list(statement.compile().params.values())
|
||||
|
||||
# All roles receive the same global totals. The current user id may
|
||||
# appear in CASE expressions for the two personal counters, but must
|
||||
# never filter the entire aggregate query through a global WHERE.
|
||||
self.assertIn("owner_id", sql)
|
||||
self.assertGreaterEqual(sql.count("case when"), 3)
|
||||
self.assertEqual(sql.count("accounts.owner_id"), 2)
|
||||
self.assertIn(73, compiled_params)
|
||||
self.assertNotIn(" where ", f" {sql} ")
|
||||
self.assertEqual(db.execute.await_count, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -30,36 +30,127 @@ class AccountReplyQueueTests(unittest.IsolatedAsyncioTestCase):
|
||||
await asyncio.sleep(0.002)
|
||||
self.assertEqual(queue.pending_count, 0)
|
||||
|
||||
async def test_one_account_runs_three_jobs_at_successive_fifo_slots(self):
|
||||
async def test_immediate_if_idle_runs_first_now_then_successive_fifo_slots(self):
|
||||
queue = await self._start_queue(account_id=101)
|
||||
interval = 0.05
|
||||
loop = asyncio.get_running_loop()
|
||||
started_at = loop.time()
|
||||
calls: list[tuple[int, float]] = []
|
||||
finished = asyncio.Event()
|
||||
first_started = asyncio.Event()
|
||||
release_first = asyncio.Event()
|
||||
|
||||
def callback_for(index: int):
|
||||
async def callback() -> None:
|
||||
calls.append((index, loop.time() - started_at))
|
||||
if index == 0:
|
||||
first_started.set()
|
||||
await release_first.wait()
|
||||
if len(calls) == 3:
|
||||
finished.set()
|
||||
|
||||
return callback
|
||||
|
||||
for index in range(3):
|
||||
await queue.enqueue(interval, callback_for(index), description=str(index))
|
||||
await queue.enqueue(
|
||||
interval,
|
||||
callback_for(0),
|
||||
description="0",
|
||||
immediate_if_idle=True,
|
||||
)
|
||||
await asyncio.wait_for(first_started.wait(), timeout=0.1)
|
||||
for index in (1, 2):
|
||||
await queue.enqueue(
|
||||
interval,
|
||||
callback_for(index),
|
||||
description=str(index),
|
||||
immediate_if_idle=True,
|
||||
)
|
||||
release_first.set()
|
||||
|
||||
await asyncio.wait_for(finished.wait(), timeout=0.75)
|
||||
await self._wait_until_idle(queue)
|
||||
|
||||
self.assertEqual([index for index, _ in calls], [0, 1, 2])
|
||||
elapsed = [timestamp for _, timestamp in calls]
|
||||
for timestamp, expected in zip(elapsed, (interval, interval * 2, interval * 3)):
|
||||
for timestamp, expected in zip(elapsed, (0, interval, interval * 2)):
|
||||
self.assertGreaterEqual(timestamp, expected - 0.015)
|
||||
self.assertLess(timestamp, expected + 0.15)
|
||||
self.assertGreaterEqual(elapsed[1] - elapsed[0], interval - 0.02)
|
||||
self.assertGreaterEqual(elapsed[2] - elapsed[1], interval - 0.02)
|
||||
|
||||
async def test_immediate_if_idle_does_not_bypass_active_send(self):
|
||||
queue = await self._start_queue(account_id=102)
|
||||
interval = 1.0
|
||||
first_started = asyncio.Event()
|
||||
release_first = asyncio.Event()
|
||||
second_started = asyncio.Event()
|
||||
|
||||
async def first_callback() -> None:
|
||||
first_started.set()
|
||||
await release_first.wait()
|
||||
|
||||
async def second_callback() -> None:
|
||||
second_started.set()
|
||||
|
||||
await queue.enqueue(
|
||||
interval,
|
||||
first_callback,
|
||||
description="first",
|
||||
immediate_if_idle=True,
|
||||
)
|
||||
await asyncio.wait_for(first_started.wait(), timeout=0.1)
|
||||
await queue.enqueue(
|
||||
interval,
|
||||
second_callback,
|
||||
description="second",
|
||||
immediate_if_idle=True,
|
||||
)
|
||||
|
||||
snapshot = await queue.snapshot()
|
||||
self.assertEqual([item["status"] for item in snapshot], ["sending", "waiting"])
|
||||
self.assertEqual(snapshot[0]["interval_seconds"], 0)
|
||||
self.assertEqual(snapshot[1]["interval_seconds"], int(interval))
|
||||
|
||||
release_first.set()
|
||||
await asyncio.sleep(0.02)
|
||||
self.assertFalse(second_started.is_set())
|
||||
|
||||
async def test_immediate_if_idle_resets_after_queue_drains(self):
|
||||
queue = await self._start_queue(account_id=103)
|
||||
interval = 1.0
|
||||
first_finished = asyncio.Event()
|
||||
second_started = asyncio.Event()
|
||||
release_second = asyncio.Event()
|
||||
|
||||
async def first_callback() -> None:
|
||||
first_finished.set()
|
||||
|
||||
async def second_callback() -> None:
|
||||
second_started.set()
|
||||
await release_second.wait()
|
||||
|
||||
await queue.enqueue(
|
||||
interval,
|
||||
first_callback,
|
||||
description="first wave",
|
||||
immediate_if_idle=True,
|
||||
)
|
||||
await asyncio.wait_for(first_finished.wait(), timeout=0.1)
|
||||
await self._wait_until_idle(queue)
|
||||
|
||||
await queue.enqueue(
|
||||
interval,
|
||||
second_callback,
|
||||
description="second wave",
|
||||
immediate_if_idle=True,
|
||||
)
|
||||
await asyncio.wait_for(second_started.wait(), timeout=0.1)
|
||||
snapshot = await queue.snapshot()
|
||||
self.assertEqual(len(snapshot), 1)
|
||||
self.assertEqual(snapshot[0]["status"], "sending")
|
||||
self.assertEqual(snapshot[0]["interval_seconds"], 0)
|
||||
release_second.set()
|
||||
|
||||
async def test_separate_account_queues_reach_first_slot_without_blocking(self):
|
||||
first_queue = await self._start_queue(account_id=201)
|
||||
second_queue = await self._start_queue(account_id=202)
|
||||
@@ -194,6 +285,47 @@ class AccountReplyQueueTests(unittest.IsolatedAsyncioTestCase):
|
||||
new_due = datetime.fromisoformat(new["scheduled_at"]).timestamp()
|
||||
self.assertAlmostEqual(old_due - new_due, interval, delta=0.05)
|
||||
|
||||
async def test_send_now_on_zero_slot_does_not_claim_later_jobs_shifted(self):
|
||||
queue = await self._start_queue(account_id=512)
|
||||
interval = 1.0
|
||||
|
||||
async def noop() -> None:
|
||||
pass
|
||||
|
||||
# Both enqueues complete without yielding to the consumer, preserving
|
||||
# the narrow management-API window where the zero-slot first job is
|
||||
# still waiting and can be selected by send-now.
|
||||
await queue.enqueue(
|
||||
interval,
|
||||
noop,
|
||||
description="immediate first",
|
||||
immediate_if_idle=True,
|
||||
)
|
||||
await queue.enqueue(
|
||||
interval,
|
||||
noop,
|
||||
description="scheduled second",
|
||||
immediate_if_idle=True,
|
||||
)
|
||||
before = await queue.snapshot()
|
||||
second_before = next(
|
||||
item for item in before if item["description"] == "scheduled second"
|
||||
)
|
||||
|
||||
result = await queue.send_now(before[0]["job_id"])
|
||||
after = await queue.snapshot()
|
||||
second_after = next(
|
||||
item for item in after if item["description"] == "scheduled second"
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "accepted")
|
||||
self.assertEqual(result["shifted_count"], 0)
|
||||
second_due_before = datetime.fromisoformat(
|
||||
second_before["scheduled_at"]
|
||||
).timestamp()
|
||||
second_due_after = datetime.fromisoformat(second_after["scheduled_at"]).timestamp()
|
||||
self.assertAlmostEqual(second_due_after, second_due_before, delta=0.01)
|
||||
|
||||
async def test_send_now_middle_runs_first_and_only_shifts_jobs_behind_it(self):
|
||||
queue = await self._start_queue(account_id=503)
|
||||
interval = 0.12
|
||||
|
||||
@@ -23,7 +23,9 @@ from rpa_engine.playwright_worker import DouyinWorker
|
||||
|
||||
class _RecordingQueue:
|
||||
def __init__(self) -> None:
|
||||
self.jobs: list[tuple[float, object, str, dict, frozenset[str]]] = []
|
||||
self.jobs: list[
|
||||
tuple[float, object, str, dict, frozenset[str], bool]
|
||||
] = []
|
||||
|
||||
async def enqueue(
|
||||
self,
|
||||
@@ -33,11 +35,19 @@ class _RecordingQueue:
|
||||
details=None,
|
||||
merge_key="",
|
||||
merge_keys=None,
|
||||
immediate_if_idle=False,
|
||||
) -> int:
|
||||
keys = merge_keys if merge_keys is not None else [merge_key]
|
||||
normalized_keys = frozenset(str(key) for key in keys if str(key or "").strip())
|
||||
self.jobs.append(
|
||||
(delay_seconds, callback, description, dict(details or {}), normalized_keys)
|
||||
(
|
||||
delay_seconds,
|
||||
callback,
|
||||
description,
|
||||
dict(details or {}),
|
||||
normalized_keys,
|
||||
bool(immediate_if_idle),
|
||||
)
|
||||
)
|
||||
return len(self.jobs)
|
||||
|
||||
@@ -75,6 +85,7 @@ class _RecordingQueue:
|
||||
job[2],
|
||||
merged_details,
|
||||
frozenset(job[4] | incoming_keys),
|
||||
job[5],
|
||||
)
|
||||
return {
|
||||
"status": "merged",
|
||||
@@ -123,6 +134,8 @@ class ReplyQueueIntegrationTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
self.assertEqual(len(service._reply_queue.jobs), 1)
|
||||
self.assertEqual(match_reply.await_count, 1)
|
||||
self.assertEqual(service._reply_queue.jobs[0][0], 60)
|
||||
self.assertTrue(service._reply_queue.jobs[0][5])
|
||||
details = service._reply_queue.jobs[0][3]
|
||||
self.assertEqual(details["sender_name"], "张三")
|
||||
self.assertEqual(details["conversation_id"], "conv-1")
|
||||
|
||||
@@ -63,7 +63,7 @@ const handleLogout = () => {
|
||||
</a-menu-item>
|
||||
<a-menu-item key="/accounts">
|
||||
<template #icon><UserOutlined /></template>
|
||||
<span>账号管理</span>
|
||||
<span>{{ auth.isAdmin ? '账号管理' : '我的账号' }}</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="/messages">
|
||||
<template #icon><MessageOutlined /></template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import api from '../api'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import { useIsMobile } from '../composables/useIsMobile'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isMobile = useIsMobile()
|
||||
const profileModalWidth = computed(() => (isMobile.value ? 'calc(100vw - 32px)' : 860))
|
||||
@@ -983,6 +984,10 @@ const goAccountRulesPage = (accountId) => {
|
||||
}
|
||||
|
||||
const openAddModal = () => {
|
||||
if (!auth.canWrite) {
|
||||
message.warning('当前账号为只读角色,不能添加托管账号')
|
||||
return
|
||||
}
|
||||
if (!canAddAccount.value) {
|
||||
if (canPurchaseSlots.value) {
|
||||
purchaseVisible.value = true
|
||||
@@ -1705,13 +1710,21 @@ const clearCookie = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
auth.fetchMe()
|
||||
fetchPaymentConfig()
|
||||
fetchDeviceProfiles()
|
||||
fetchAccounts()
|
||||
fetchRules()
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
auth.fetchMe(),
|
||||
fetchPaymentConfig(),
|
||||
fetchDeviceProfiles(),
|
||||
fetchAccounts(),
|
||||
fetchRules(),
|
||||
])
|
||||
startReplyQueueSummaryPolling()
|
||||
if (route.query.action === 'add') {
|
||||
openAddModal()
|
||||
const nextQuery = { ...route.query }
|
||||
delete nextQuery.action
|
||||
router.replace({ path: route.path, query: nextQuery })
|
||||
}
|
||||
// 不再定时轮询账号列表;进入页面加载一次,之后由各操作(启动/停止/登录等)
|
||||
// 或手动点击「刷新」按钮按需刷新
|
||||
})
|
||||
@@ -1797,6 +1810,7 @@ onUnmounted(() => {
|
||||
购买额度
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="auth.canWrite"
|
||||
type="primary"
|
||||
class="gradient-btn"
|
||||
:disabled="!canAddAccount && !canPurchaseSlots"
|
||||
@@ -2052,7 +2066,7 @@ onUnmounted(() => {
|
||||
<UserOutlined style="font-size: 4rem; color: var(--text-muted); margin-bottom: 16px;" />
|
||||
<h3>暂无托管账号</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 20px;">添加一个抖音账号,开始自动化回复工作吧</p>
|
||||
<a-button type="primary" class="gradient-btn" @click="openAddModal">
|
||||
<a-button v-if="auth.canWrite" type="primary" class="gradient-btn" @click="openAddModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
立即添加
|
||||
</a-button>
|
||||
@@ -2257,7 +2271,7 @@ onUnmounted(() => {
|
||||
placeholder="0 或留空则继承系统默认"
|
||||
/>
|
||||
<div class="field-hint">
|
||||
设置 N 秒后,同一账号的待回复会话会依次排队:第 1 条在 N 秒后发送,第 2 条在 2N 秒后发送,以此类推;各账号队列互不影响。0 或留空表示继承系统默认,当前生效 {{ editForm.reply_delay_effective }} 秒(0 表示不启用兜底排队、立即回复)。
|
||||
设置 N 秒后,同一账号队列为空时,首条回复等待 0 秒并立即发送;后续待回复会话按 N 秒、2N 秒依次排队。各账号队列互不影响,实际发送仍受全局带宽队列保护。0 或留空表示继承系统默认,当前生效 {{ editForm.reply_delay_effective }} 秒(0 表示不启用兜底排队、收到消息后立即回复)。
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
@@ -2,18 +2,25 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import api from '../api'
|
||||
import MessageBubble from '../components/MessageBubble.vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import {
|
||||
UserOutlined,
|
||||
MessageOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
ArrowRightOutlined,
|
||||
ThunderboltOutlined
|
||||
ThunderboltOutlined,
|
||||
SettingOutlined,
|
||||
PlusOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const stats = ref({
|
||||
totalAccounts: 0,
|
||||
activeAccounts: 0,
|
||||
myAccounts: 0,
|
||||
myActiveAccounts: 0,
|
||||
totalMessages: 0,
|
||||
repliedMessages: 0,
|
||||
replyRate: '0%'
|
||||
@@ -24,14 +31,15 @@ const loading = ref(true)
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const [accountsRes, statsRes] = await Promise.all([
|
||||
api.get(`/accounts`),
|
||||
const [accountStatsRes, statsRes] = await Promise.all([
|
||||
api.get(`/dashboard/account-stats`),
|
||||
api.get(`/logs/stats`)
|
||||
])
|
||||
|
||||
const accounts = accountsRes.data
|
||||
stats.value.totalAccounts = accounts.length
|
||||
stats.value.activeAccounts = accounts.filter(a => a.status === 'online').length
|
||||
stats.value.totalAccounts = accountStatsRes.data.total_accounts || 0
|
||||
stats.value.activeAccounts = accountStatsRes.data.online_accounts || 0
|
||||
stats.value.myAccounts = accountStatsRes.data.my_accounts || 0
|
||||
stats.value.myActiveAccounts = accountStatsRes.data.my_online_accounts || 0
|
||||
|
||||
// 后端全量统计所有消息,不受列表条数限制
|
||||
stats.value.totalMessages = statsRes.data.total || 0
|
||||
@@ -84,8 +92,27 @@ onUnmounted(() => {
|
||||
多账户自动回复RPA后台。支持快捷扫码登录、状态持久化保存、以及自定义关键字规则精准答复。
|
||||
</p>
|
||||
</div>
|
||||
<div class="banner-icon">
|
||||
<ThunderboltOutlined style="font-size: 4rem; color: #c084fc; opacity: 0.3;" />
|
||||
<div class="banner-side">
|
||||
<div v-if="auth.canWrite" class="banner-actions">
|
||||
<router-link to="/accounts">
|
||||
<a-button size="large">
|
||||
<template #icon><UserOutlined /></template>
|
||||
{{ auth.isAdmin ? '账号管理' : `我的账号(${stats.myAccounts})` }}
|
||||
</a-button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="auth.canWrite"
|
||||
:to="{ path: '/accounts', query: { action: 'add' } }"
|
||||
>
|
||||
<a-button type="primary" size="large" class="gradient-btn">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
{{ auth.isAdmin ? '添加账号' : '添加自己的账号' }}
|
||||
</a-button>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="banner-icon">
|
||||
<ThunderboltOutlined style="font-size: 4rem; color: #c084fc; opacity: 0.3;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,7 +125,7 @@ onUnmounted(() => {
|
||||
<UserOutlined />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-label">托管账号</span>
|
||||
<span class="stat-label">全平台托管账号</span>
|
||||
<h2 class="stat-value">{{ stats.totalAccounts }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,7 +138,7 @@ onUnmounted(() => {
|
||||
<CheckCircleOutlined />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-label">在线运行</span>
|
||||
<span class="stat-label">全平台在线运行</span>
|
||||
<h2 class="stat-value text-green">{{ stats.activeAccounts }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,8 +226,9 @@ onUnmounted(() => {
|
||||
<div class="quick-actions-grid" style="margin-top: 20px;">
|
||||
<router-link to="/accounts" class="quick-action-card">
|
||||
<UserOutlined class="action-icon text-gradient" />
|
||||
<span>账号配置</span>
|
||||
<p>扫码登录并托管多个抖音账号</p>
|
||||
<span>{{ auth.isAdmin ? '账号管理' : '我的账号' }}</span>
|
||||
<p v-if="auth.isAdmin">管理全平台账号配置与运行状态</p>
|
||||
<p v-else>仅查看和管理自己添加的账号,当前 {{ stats.myAccounts }} 个</p>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/rules" class="quick-action-card">
|
||||
@@ -225,6 +253,20 @@ onUnmounted(() => {
|
||||
border-left: 4px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.banner-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.banner-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -436,6 +478,20 @@ onUnmounted(() => {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.banner-side,
|
||||
.banner-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.banner-actions > a {
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
|
||||
.banner-actions :deep(.ant-btn) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@@ -740,7 +740,7 @@ onMounted(() => {
|
||||
/>
|
||||
|
||||
<div class="field-hint">
|
||||
账号未单独设置时使用此间隔。同一账号的第 1 条待回复在 N 秒后发送,第 2 条在 2N 秒后发送,以此类推;各账号队列互不影响。设为 0 表示不启用兜底排队,收到消息后立即回复。
|
||||
账号未单独设置时使用此间隔。同一账号队列为空时,首条回复等待 0 秒并立即发送;后续待回复会话按 N 秒、2N 秒依次排队。各账号队列互不影响,实际发送仍受全局带宽队列保护。设为 0 表示不启用兜底排队,收到消息后立即回复。
|
||||
</div>
|
||||
|
||||
</a-form-item>
|
||||
|
||||
Reference in New Issue
Block a user