index.vue 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260
  1. <template>
  2. <div class="accounts-page">
  3. <div class="page-header">
  4. <h2>账号管理</h2>
  5. <div class="header-actions">
  6. <el-button @click="showGroupManage = true">
  7. <el-icon><Setting /></el-icon>
  8. 管理分组
  9. </el-button>
  10. <el-button @click="refreshAllAccounts" :disabled="!accounts.length">
  11. <el-icon><Refresh /></el-icon>
  12. 刷新所有
  13. </el-button>
  14. <el-button type="primary" @click="showPlatformSelect = true">
  15. <el-icon><Monitor /></el-icon>
  16. 浏览器登录
  17. </el-button>
  18. <el-button @click="showAddDialog = true">
  19. <el-icon><Plus /></el-icon>
  20. 手动添加
  21. </el-button>
  22. </div>
  23. </div>
  24. <!-- 筛选栏 -->
  25. <div class="page-card filter-bar">
  26. <el-select v-model="filter.platform" placeholder="平台" clearable style="width: 150px">
  27. <el-option
  28. v-for="platform in platforms"
  29. :key="platform.type"
  30. :label="platform.name"
  31. :value="platform.type"
  32. />
  33. </el-select>
  34. <el-select v-model="filter.groupId" placeholder="分组" clearable style="width: 150px">
  35. <el-option
  36. v-for="group in groups"
  37. :key="group.id"
  38. :label="group.name"
  39. :value="group.id"
  40. />
  41. </el-select>
  42. <el-select v-model="filter.status" placeholder="状态" clearable style="width: 120px">
  43. <el-option label="正常" value="active" />
  44. <el-option label="已过期" value="expired" />
  45. <el-option label="已禁用" value="disabled" />
  46. </el-select>
  47. <el-button @click="loadAccounts">
  48. <el-icon><Refresh /></el-icon>
  49. 刷新
  50. </el-button>
  51. </div>
  52. <!-- 账号列表 -->
  53. <div class="page-card">
  54. <el-table :data="accounts" v-loading="loading" style="width: 100%">
  55. <el-table-column label="账号" min-width="200">
  56. <template #default="{ row }">
  57. <div class="account-cell">
  58. <el-avatar :size="40" :src="row.avatarUrl || undefined">
  59. {{ row.accountName?.[0] }}
  60. </el-avatar>
  61. <div class="account-info">
  62. <div class="account-name">{{ row.accountName }}</div>
  63. <div class="account-id">ID: {{ row.accountId }}</div>
  64. </div>
  65. </div>
  66. </template>
  67. </el-table-column>
  68. <el-table-column label="平台" width="120">
  69. <template #default="{ row }">
  70. <el-tag>{{ getPlatformName(row.platform) }}</el-tag>
  71. </template>
  72. </el-table-column>
  73. <el-table-column label="粉丝数" width="100">
  74. <template #default="{ row }">
  75. {{ formatNumber(row.fansCount) }}
  76. </template>
  77. </el-table-column>
  78. <el-table-column label="作品数" width="100">
  79. <template #default="{ row }">
  80. {{ row.worksCount }}
  81. </template>
  82. </el-table-column>
  83. <el-table-column label="状态" width="100">
  84. <template #default="{ row }">
  85. <el-tag :type="getStatusType(row.status)">
  86. {{ getStatusText(row.status) }}
  87. </el-tag>
  88. </template>
  89. </el-table-column>
  90. <el-table-column label="分组" width="150">
  91. <template #default="{ row }">
  92. <el-select
  93. :model-value="row.groupId"
  94. placeholder="无分组"
  95. clearable
  96. size="small"
  97. style="width: 100%"
  98. @change="(val: number | undefined) => handleChangeGroup(row.id, val)"
  99. >
  100. <el-option
  101. v-for="group in groups"
  102. :key="group.id"
  103. :label="group.name"
  104. :value="group.id"
  105. />
  106. </el-select>
  107. </template>
  108. </el-table-column>
  109. <el-table-column label="操作" width="180" fixed="right">
  110. <template #default="{ row }">
  111. <el-button type="primary" link size="small" @click="openPlatformAdmin(row)">
  112. 后台
  113. </el-button>
  114. <el-button type="primary" link size="small" @click="refreshAccount(row.id)">
  115. 刷新
  116. </el-button>
  117. <el-button type="danger" link size="small" @click="deleteAccount(row.id)">
  118. 删除
  119. </el-button>
  120. </template>
  121. </el-table-column>
  122. </el-table>
  123. </div>
  124. <!-- 添加账号对话框 -->
  125. <el-dialog v-model="showAddDialog" title="添加账号" width="500px">
  126. <el-form :model="addForm" label-width="80px">
  127. <el-form-item label="平台">
  128. <el-select v-model="addForm.platform" placeholder="选择平台" style="width: 100%">
  129. <el-option
  130. v-for="platform in platforms"
  131. :key="platform.type"
  132. :label="platform.name"
  133. :value="platform.type"
  134. :disabled="!platform.supported"
  135. >
  136. <span class="platform-option">
  137. <span>{{ platform.name }}</span>
  138. <el-tag v-if="!platform.supported" size="small" type="info">适配中</el-tag>
  139. </span>
  140. </el-option>
  141. </el-select>
  142. </el-form-item>
  143. <el-form-item label="Cookie">
  144. <el-input
  145. v-model="addForm.cookieData"
  146. type="textarea"
  147. :rows="4"
  148. placeholder="粘贴从浏览器复制的 Cookie"
  149. />
  150. </el-form-item>
  151. <el-form-item label="分组">
  152. <el-select v-model="addForm.groupId" placeholder="选择分组" clearable style="width: 100%">
  153. <el-option
  154. v-for="group in groups"
  155. :key="group.id"
  156. :label="group.name"
  157. :value="group.id"
  158. />
  159. </el-select>
  160. </el-form-item>
  161. </el-form>
  162. <template #footer>
  163. <el-button @click="showAddDialog = false">取消</el-button>
  164. <el-button type="primary" @click="handleAddAccount" :loading="submitting">
  165. 添加
  166. </el-button>
  167. </template>
  168. </el-dialog>
  169. <!-- 平台选择对话框 -->
  170. <el-dialog
  171. v-model="showPlatformSelect"
  172. title="选择登录平台"
  173. width="480px"
  174. :close-on-click-modal="true"
  175. >
  176. <div class="platform-grid">
  177. <div
  178. v-for="platform in supportedPlatforms"
  179. :key="platform.type"
  180. class="platform-card"
  181. :style="{ '--platform-color': platform.color }"
  182. @click="selectPlatformAndLogin(platform.type)"
  183. >
  184. <div class="platform-icon" :class="platform.type">
  185. {{ platform.name[0] }}
  186. </div>
  187. <span class="platform-name">{{ platform.name }}</span>
  188. </div>
  189. </div>
  190. <div class="platform-hint">
  191. 点击平台图标后会在右侧标签页中打开登录界面,登录成功后系统会自动获取Cookie
  192. </div>
  193. </el-dialog>
  194. <!-- 刷新账号对话框 -->
  195. <el-dialog
  196. v-model="showRefreshDialog"
  197. title="刷新账号信息"
  198. width="450px"
  199. :close-on-click-modal="false"
  200. :show-close="refreshState.status !== 'loading'"
  201. @closed="resetRefreshState"
  202. >
  203. <div class="refresh-status">
  204. <el-icon
  205. class="status-icon"
  206. :class="refreshState.status"
  207. >
  208. <Loading v-if="refreshState.status === 'loading'" />
  209. <CircleCheck v-else-if="refreshState.status === 'success'" />
  210. <CircleClose v-else />
  211. </el-icon>
  212. <div class="status-text">
  213. <template v-if="refreshState.status === 'loading'">
  214. <h3>
  215. <span class="fetching-text">正在刷新账号信息</span>
  216. <span class="fetching-dots"></span>
  217. </h3>
  218. <div class="fetching-progress">
  219. <el-progress :percentage="refreshState.progress" :show-text="false" :stroke-width="4" />
  220. </div>
  221. <p class="fetching-hint">正在后台获取最新数据,请稍候</p>
  222. <div class="fetching-steps">
  223. <div class="step" :class="{ active: refreshState.step >= 1, done: refreshState.step > 1 }">
  224. <el-icon v-if="refreshState.step > 1"><CircleCheck /></el-icon>
  225. <el-icon v-else-if="refreshState.step === 1"><Loading /></el-icon>
  226. <span v-else>1</span>
  227. <span class="step-text">验证Cookie</span>
  228. </div>
  229. <div class="step" :class="{ active: refreshState.step >= 2, done: refreshState.step > 2 }">
  230. <el-icon v-if="refreshState.step > 2"><CircleCheck /></el-icon>
  231. <el-icon v-else-if="refreshState.step === 2"><Loading /></el-icon>
  232. <span v-else>2</span>
  233. <span class="step-text">获取资料</span>
  234. </div>
  235. <div class="step" :class="{ active: refreshState.step >= 3, done: refreshState.step > 3 }">
  236. <el-icon v-if="refreshState.step > 3"><CircleCheck /></el-icon>
  237. <el-icon v-else-if="refreshState.step === 3"><Loading /></el-icon>
  238. <span v-else>3</span>
  239. <span class="step-text">获取作品</span>
  240. </div>
  241. </div>
  242. </template>
  243. <template v-else-if="refreshState.status === 'success'">
  244. <h3>刷新成功!</h3>
  245. <div v-if="refreshState.account" class="account-preview">
  246. <el-avatar :size="48" :src="refreshState.account.avatarUrl || undefined">
  247. {{ refreshState.account.accountName?.[0] }}
  248. </el-avatar>
  249. <div class="account-preview-info">
  250. <div class="account-preview-name">{{ refreshState.account.accountName }}</div>
  251. <div class="account-preview-stats">
  252. <span>粉丝: {{ formatNumber(refreshState.account.fansCount) }}</span>
  253. <span v-if="refreshState.account.worksCount">
  254. · 作品: {{ refreshState.account.worksCount }}
  255. </span>
  256. </div>
  257. </div>
  258. </div>
  259. <p>账号信息已更新</p>
  260. </template>
  261. <template v-else-if="refreshState.status === 'expired'">
  262. <h3>登录已过期</h3>
  263. <p>账号Cookie已失效,需要重新登录</p>
  264. </template>
  265. <template v-else>
  266. <h3>刷新失败</h3>
  267. <p>{{ refreshState.error || '未知错误' }}</p>
  268. </template>
  269. </div>
  270. </div>
  271. <template #footer>
  272. <template v-if="refreshState.status === 'loading'">
  273. <el-button disabled>请稍候...</el-button>
  274. </template>
  275. <template v-else-if="refreshState.status === 'expired'">
  276. <el-button @click="showRefreshDialog = false">关闭</el-button>
  277. <el-button type="primary" @click="handleReLoginFromRefresh">
  278. 重新登录
  279. </el-button>
  280. </template>
  281. <template v-else>
  282. <el-button type="primary" @click="showRefreshDialog = false">确定</el-button>
  283. </template>
  284. </template>
  285. </el-dialog>
  286. <!-- 分组管理对话框 -->
  287. <el-dialog
  288. v-model="showGroupManage"
  289. title="管理分组"
  290. width="500px"
  291. :close-on-click-modal="false"
  292. >
  293. <div class="group-manage">
  294. <!-- 新增分组 -->
  295. <div class="group-add">
  296. <el-input
  297. v-model="newGroupName"
  298. placeholder="输入新分组名称"
  299. style="width: 300px"
  300. @keyup.enter="handleAddGroup"
  301. />
  302. <el-button type="primary" @click="handleAddGroup" :loading="groupSaving">
  303. <el-icon><Plus /></el-icon>
  304. 新增
  305. </el-button>
  306. </div>
  307. <!-- 分组列表 -->
  308. <div class="group-list">
  309. <div v-if="groups.length === 0" class="group-empty">
  310. <el-empty description="暂无分组" :image-size="80" />
  311. </div>
  312. <div
  313. v-else
  314. v-for="group in groups"
  315. :key="group.id"
  316. class="group-item"
  317. >
  318. <template v-if="editingGroupId === group.id">
  319. <el-input
  320. v-model="editingGroupName"
  321. size="small"
  322. style="flex: 1"
  323. @keyup.enter="handleSaveGroup(group.id)"
  324. @keyup.escape="cancelEditGroup"
  325. />
  326. <el-button type="primary" size="small" @click="handleSaveGroup(group.id)" :loading="groupSaving">
  327. 保存
  328. </el-button>
  329. <el-button size="small" @click="cancelEditGroup">
  330. 取消
  331. </el-button>
  332. </template>
  333. <template v-else>
  334. <span class="group-name">{{ group.name }}</span>
  335. <span class="group-count">{{ getGroupAccountCount(group.id) }} 个账号</span>
  336. <div class="group-actions">
  337. <el-button type="primary" link size="small" @click="startEditGroup(group)">
  338. 编辑
  339. </el-button>
  340. <el-button
  341. type="danger"
  342. link
  343. size="small"
  344. @click="handleDeleteGroup(group)"
  345. :disabled="getGroupAccountCount(group.id) > 0"
  346. >
  347. 删除
  348. </el-button>
  349. </div>
  350. </template>
  351. </div>
  352. </div>
  353. </div>
  354. <template #footer>
  355. <el-button @click="showGroupManage = false">关闭</el-button>
  356. </template>
  357. </el-dialog>
  358. </div>
  359. </template>
  360. <script setup lang="ts">
  361. import { ref, reactive, onMounted, onUnmounted, watch } from 'vue';
  362. import { Plus, Refresh, Monitor, Loading, CircleCheck, CircleClose, Setting } from '@element-plus/icons-vue';
  363. import { ElMessage, ElMessageBox } from 'element-plus';
  364. import { accountsApi } from '@/api/accounts';
  365. import { PLATFORMS, PLATFORM_TYPES } from '@media-manager/shared';
  366. import type { PlatformAccount, AccountGroup, PlatformType } from '@media-manager/shared';
  367. import { useTaskQueueStore } from '@/stores/taskQueue';
  368. import { useTabsStore } from '@/stores/tabs';
  369. const taskStore = useTaskQueueStore();
  370. const tabsStore = useTabsStore();
  371. // 监听任务列表变化,当 sync_account 任务完成时自动刷新账号列表
  372. watch(() => taskStore.tasks, (newTasks, oldTasks) => {
  373. const hasSyncAccountComplete = newTasks.some(task => {
  374. if (task.type !== 'sync_account') return false;
  375. const oldTask = oldTasks?.find(t => t.id === task.id);
  376. // 任务状态变为 completed 或 failed
  377. if (oldTask && oldTask.status !== task.status &&
  378. (task.status === 'completed' || task.status === 'failed')) {
  379. return true;
  380. }
  381. return false;
  382. });
  383. if (hasSyncAccountComplete) {
  384. console.log('[Accounts] Sync account task completed, refreshing list...');
  385. loadAccounts();
  386. }
  387. }, { deep: true });
  388. // 监听账号刷新信号(当浏览器登录添加账号后或账号同步任务完成时触发)
  389. watch(() => tabsStore.accountRefreshTrigger, () => {
  390. console.log('[Accounts] Account refresh triggered (from tabs), reloading list...');
  391. loadAccounts();
  392. });
  393. // 监听任务队列的账号刷新信号
  394. watch(() => taskStore.accountRefreshTrigger, () => {
  395. console.log('[Accounts] Account refresh triggered (from task), reloading list...');
  396. loadAccounts();
  397. });
  398. const loading = ref(false);
  399. const submitting = ref(false);
  400. const showAddDialog = ref(false);
  401. const showPlatformSelect = ref(false);
  402. const showRefreshDialog = ref(false);
  403. const showGroupManage = ref(false);
  404. // 分组管理相关
  405. const newGroupName = ref('');
  406. const groupSaving = ref(false);
  407. const editingGroupId = ref<number | null>(null);
  408. const editingGroupName = ref('');
  409. // 刷新账号的状态
  410. const refreshState = reactive({
  411. status: '' as '' | 'loading' | 'success' | 'expired' | 'failed',
  412. progress: 0,
  413. step: 0,
  414. error: '',
  415. account: null as PlatformAccount | null,
  416. platform: '' as PlatformType | '',
  417. });
  418. let refreshTimer: ReturnType<typeof setInterval> | null = null;
  419. const accounts = ref<PlatformAccount[]>([]);
  420. const groups = ref<AccountGroup[]>([]);
  421. // 浏览器登录相关
  422. const browserLoginForm = reactive({
  423. platform: '' as PlatformType | '',
  424. groupId: undefined as number | undefined,
  425. });
  426. const platforms = PLATFORM_TYPES.map(type => PLATFORMS[type]);
  427. // 只显示已支持的平台
  428. const supportedPlatforms = platforms.filter(p => p.supported);
  429. const filter = reactive({
  430. platform: '',
  431. groupId: undefined as number | undefined,
  432. status: '',
  433. });
  434. const addForm = reactive({
  435. platform: '' as PlatformType | '',
  436. cookieData: '',
  437. groupId: undefined as number | undefined,
  438. });
  439. function getPlatformName(platform: PlatformType) {
  440. return PLATFORMS[platform]?.name || platform;
  441. }
  442. function getStatusType(status: string): 'success' | 'danger' | 'info' | 'primary' | 'warning' {
  443. const types: Record<string, 'success' | 'danger' | 'info' | 'primary' | 'warning'> = {
  444. active: 'success',
  445. expired: 'danger',
  446. disabled: 'info',
  447. };
  448. return types[status] || 'info';
  449. }
  450. function getStatusText(status: string) {
  451. const texts: Record<string, string> = {
  452. active: '正常',
  453. expired: '已过期',
  454. disabled: '已禁用',
  455. };
  456. return texts[status] || status;
  457. }
  458. function formatNumber(num: number) {
  459. if (num >= 10000) {
  460. return (num / 10000).toFixed(1) + 'w';
  461. }
  462. return num.toString();
  463. }
  464. async function loadAccounts() {
  465. loading.value = true;
  466. try {
  467. const [accountList, groupList] = await Promise.all([
  468. accountsApi.getAccounts({
  469. platform: filter.platform || undefined,
  470. groupId: filter.groupId,
  471. status: filter.status || undefined,
  472. }),
  473. accountsApi.getGroups(),
  474. ]);
  475. accounts.value = accountList;
  476. groups.value = groupList;
  477. } catch {
  478. // 错误已处理
  479. } finally {
  480. loading.value = false;
  481. }
  482. }
  483. async function handleAddAccount() {
  484. if (!addForm.platform || !addForm.cookieData) {
  485. ElMessage.warning('请填写完整信息');
  486. return;
  487. }
  488. submitting.value = true;
  489. try {
  490. await accountsApi.addAccount({
  491. platform: addForm.platform,
  492. cookieData: addForm.cookieData,
  493. groupId: addForm.groupId,
  494. });
  495. ElMessage.success('账号添加成功');
  496. showAddDialog.value = false;
  497. addForm.platform = '';
  498. addForm.cookieData = '';
  499. addForm.groupId = undefined;
  500. loadAccounts();
  501. } catch {
  502. // 错误已处理
  503. } finally {
  504. submitting.value = false;
  505. }
  506. }
  507. async function refreshAccount(id: number) {
  508. // 找到要刷新的账号
  509. const account = accounts.value.find(a => a.id === id);
  510. if (!account) {
  511. ElMessage.error('账号不存在');
  512. return;
  513. }
  514. // 使用任务队列
  515. await taskStore.syncAccount(id, account.accountName);
  516. ElMessage.success('账号刷新任务已创建');
  517. taskStore.openDialog();
  518. }
  519. // 刷新所有账号
  520. async function refreshAllAccounts() {
  521. if (!accounts.value.length) {
  522. ElMessage.warning('暂无账号');
  523. return;
  524. }
  525. // 为每个账号创建刷新任务
  526. for (const account of accounts.value) {
  527. await taskStore.syncAccount(account.id, account.accountName);
  528. }
  529. ElMessage.success(`已创建 ${accounts.value.length} 个账号刷新任务`);
  530. taskStore.openDialog();
  531. }
  532. // 启动刷新进度动画
  533. function startRefreshAnimation() {
  534. if (refreshTimer) {
  535. clearInterval(refreshTimer);
  536. }
  537. let elapsed = 0;
  538. refreshTimer = setInterval(() => {
  539. elapsed += 100;
  540. // 进度条模拟
  541. if (refreshState.progress < 95) {
  542. const targetProgress = Math.min(95, Math.log(elapsed / 500 + 1) * 30);
  543. refreshState.progress = Math.min(95, targetProgress);
  544. }
  545. // 步骤更新
  546. if (elapsed > 800 && refreshState.step < 2) {
  547. refreshState.step = 2;
  548. }
  549. if (elapsed > 2500 && refreshState.step < 3) {
  550. refreshState.step = 3;
  551. }
  552. }, 100);
  553. }
  554. // 停止刷新进度动画
  555. function stopRefreshAnimation() {
  556. if (refreshTimer) {
  557. clearInterval(refreshTimer);
  558. refreshTimer = null;
  559. }
  560. refreshState.progress = 100;
  561. refreshState.step = 4;
  562. }
  563. // 重置刷新状态
  564. function resetRefreshState() {
  565. refreshState.status = '';
  566. refreshState.progress = 0;
  567. refreshState.step = 0;
  568. refreshState.error = '';
  569. refreshState.account = null;
  570. refreshState.platform = '';
  571. stopRefreshAnimation();
  572. }
  573. // 从刷新对话框触发重新登录
  574. function handleReLoginFromRefresh() {
  575. showRefreshDialog.value = false;
  576. browserLoginForm.platform = refreshState.platform;
  577. showPlatformSelect.value = true;
  578. }
  579. // 修改账号分组
  580. async function handleChangeGroup(accountId: number, groupId: number | undefined) {
  581. try {
  582. await accountsApi.updateAccount(accountId, { groupId: groupId || null });
  583. // 更新本地数据
  584. const account = accounts.value.find(a => a.id === accountId);
  585. if (account) {
  586. account.groupId = groupId || null;
  587. }
  588. ElMessage.success('分组已更新');
  589. } catch {
  590. // 错误已处理,重新加载数据以恢复
  591. loadAccounts();
  592. }
  593. }
  594. // 打开平台后台管理页面
  595. async function openPlatformAdmin(account: PlatformAccount) {
  596. const platformInfo = PLATFORMS[account.platform as PlatformType];
  597. if (!platformInfo) {
  598. ElMessage.warning('未知平台');
  599. return;
  600. }
  601. try {
  602. // 获取账号的 Cookie 数据
  603. const { cookieData } = await accountsApi.getAccountCookie(account.id);
  604. // 在标签页中打开平台后台,带上 Cookie,设置为管理后台模式
  605. tabsStore.openBrowserTab(
  606. account.platform,
  607. `${platformInfo.name} - ${account.accountName}`,
  608. undefined,
  609. platformInfo.creatorUrl,
  610. cookieData,
  611. true // isAdminMode: 管理后台模式,不校验登录和保存账号
  612. );
  613. } catch (error) {
  614. console.error('获取账号 Cookie 失败:', error);
  615. // 即使获取 Cookie 失败,也打开后台(用户需要重新登录),仍然是管理后台模式
  616. tabsStore.openBrowserTab(
  617. account.platform,
  618. `${platformInfo.name} - ${account.accountName}`,
  619. undefined,
  620. platformInfo.creatorUrl,
  621. undefined,
  622. true // isAdminMode: 管理后台模式
  623. );
  624. }
  625. }
  626. async function deleteAccount(id: number) {
  627. try {
  628. await ElMessageBox.confirm('确定要删除该账号吗?', '提示', {
  629. type: 'warning',
  630. });
  631. await accountsApi.deleteAccount(id);
  632. ElMessage.success('删除成功');
  633. loadAccounts();
  634. } catch {
  635. // 取消或错误
  636. }
  637. }
  638. // 分组管理方法
  639. function getGroupAccountCount(groupId: number): number {
  640. return accounts.value.filter(a => a.groupId === groupId).length;
  641. }
  642. async function handleAddGroup() {
  643. if (!newGroupName.value.trim()) {
  644. ElMessage.warning('请输入分组名称');
  645. return;
  646. }
  647. groupSaving.value = true;
  648. try {
  649. await accountsApi.createGroup({ name: newGroupName.value.trim() });
  650. ElMessage.success('分组创建成功');
  651. newGroupName.value = '';
  652. loadAccounts(); // 重新加载以获取最新分组列表
  653. } catch {
  654. // 错误已处理
  655. } finally {
  656. groupSaving.value = false;
  657. }
  658. }
  659. function startEditGroup(group: AccountGroup) {
  660. editingGroupId.value = group.id;
  661. editingGroupName.value = group.name;
  662. }
  663. function cancelEditGroup() {
  664. editingGroupId.value = null;
  665. editingGroupName.value = '';
  666. }
  667. async function handleSaveGroup(groupId: number) {
  668. if (!editingGroupName.value.trim()) {
  669. ElMessage.warning('分组名称不能为空');
  670. return;
  671. }
  672. groupSaving.value = true;
  673. try {
  674. await accountsApi.updateGroup(groupId, { name: editingGroupName.value.trim() });
  675. ElMessage.success('分组更新成功');
  676. cancelEditGroup();
  677. loadAccounts(); // 重新加载以获取最新分组列表
  678. } catch {
  679. // 错误已处理
  680. } finally {
  681. groupSaving.value = false;
  682. }
  683. }
  684. async function handleDeleteGroup(group: AccountGroup) {
  685. const count = getGroupAccountCount(group.id);
  686. if (count > 0) {
  687. ElMessage.warning(`该分组下还有 ${count} 个账号,请先移除账号`);
  688. return;
  689. }
  690. try {
  691. await ElMessageBox.confirm(`确定要删除分组"${group.name}"吗?`, '提示', {
  692. type: 'warning',
  693. });
  694. await accountsApi.deleteGroup(group.id);
  695. ElMessage.success('分组删除成功');
  696. loadAccounts(); // 重新加载以获取最新分组列表
  697. } catch {
  698. // 取消或错误
  699. }
  700. }
  701. // 选择平台并开始登录(九宫格点击)
  702. function selectPlatformAndLogin(platformType: PlatformType) {
  703. const platformName = getPlatformName(platformType);
  704. // 在标签页中打开浏览器
  705. tabsStore.openBrowserTab(
  706. platformType,
  707. `${platformName} 登录`
  708. );
  709. // 关闭平台选择对话框
  710. showPlatformSelect.value = false;
  711. ElMessage.success('已打开浏览器登录标签页');
  712. }
  713. // 浏览器登录功能 - 改为在标签页中打开(保留用于重新登录场景)
  714. function startBrowserLogin() {
  715. if (!browserLoginForm.platform) {
  716. ElMessage.warning('请选择平台');
  717. return;
  718. }
  719. selectPlatformAndLogin(browserLoginForm.platform as PlatformType);
  720. browserLoginForm.platform = '';
  721. }
  722. // 检查过期账号并提示重新登录
  723. async function checkExpiredAccounts() {
  724. const expiredAccounts = accounts.value.filter(a => a.status === 'expired');
  725. if (expiredAccounts.length > 0) {
  726. const account = expiredAccounts[0]; // 处理第一个过期账号
  727. ElMessageBox.confirm(
  728. `账号 "${account.accountName}" 登录已过期,是否重新登录?`,
  729. '登录过期',
  730. {
  731. confirmButtonText: '重新登录',
  732. cancelButtonText: '稍后再说',
  733. type: 'warning',
  734. }
  735. ).then(() => {
  736. browserLoginForm.platform = account.platform;
  737. showPlatformSelect.value = true;
  738. }).catch(() => {
  739. // 用户取消
  740. });
  741. }
  742. }
  743. // 验证单个账号的 Cookie 状态
  744. async function verifyAccountStatus(account: PlatformAccount) {
  745. try {
  746. const result = await accountsApi.checkAccountStatus(account.id);
  747. if (result.needReLogin) {
  748. // 更新本地状态
  749. const idx = accounts.value.findIndex(a => a.id === account.id);
  750. if (idx !== -1) {
  751. accounts.value[idx].status = 'expired';
  752. }
  753. return false;
  754. }
  755. return true;
  756. } catch {
  757. return true; // 检查失败时不影响
  758. }
  759. }
  760. onMounted(async () => {
  761. await loadAccounts();
  762. // 页面加载后不自动检查过期账号,避免服务启动时网络不稳定导致误判
  763. // 过期账号会在定时任务刷新后或用户手动刷新后提示
  764. });
  765. onUnmounted(() => {
  766. stopRefreshAnimation();
  767. });
  768. </script>
  769. <style lang="scss" scoped>
  770. @use '@/styles/variables.scss' as *;
  771. .accounts-page {
  772. max-width: 1400px;
  773. margin: 0 auto;
  774. }
  775. .page-header {
  776. display: flex;
  777. align-items: center;
  778. justify-content: space-between;
  779. margin-bottom: 24px;
  780. h2 {
  781. margin: 0;
  782. font-size: 22px;
  783. font-weight: 600;
  784. color: $text-primary;
  785. }
  786. .header-actions {
  787. display: flex;
  788. gap: 12px;
  789. .el-button {
  790. border-radius: $radius-base;
  791. font-weight: 500;
  792. }
  793. }
  794. }
  795. .filter-bar {
  796. display: flex;
  797. gap: 12px;
  798. margin-bottom: 20px;
  799. padding: 16px 20px !important;
  800. :deep(.el-select) {
  801. .el-input__wrapper {
  802. border-radius: $radius-base;
  803. }
  804. }
  805. }
  806. .page-card {
  807. background: #fff;
  808. border-radius: $radius-lg;
  809. padding: 20px 24px;
  810. box-shadow: $shadow-sm;
  811. border: 1px solid $border-light;
  812. :deep(.el-table) {
  813. --el-table-border-color: #{$border-light};
  814. --el-table-header-bg-color: #{$bg-base};
  815. th.el-table__cell {
  816. font-weight: 600;
  817. color: $text-primary;
  818. background: $bg-base !important;
  819. }
  820. .el-table__row {
  821. &:hover > td.el-table__cell {
  822. background: $primary-color-light !important;
  823. }
  824. }
  825. .el-button--link {
  826. font-weight: 500;
  827. }
  828. }
  829. }
  830. .account-cell {
  831. display: flex;
  832. align-items: center;
  833. gap: 14px;
  834. :deep(.el-avatar) {
  835. flex-shrink: 0;
  836. border: 2px solid $border-light;
  837. background: linear-gradient(135deg, $primary-color-light, #fff);
  838. color: $primary-color;
  839. font-weight: 600;
  840. }
  841. .account-info {
  842. .account-name {
  843. font-weight: 600;
  844. color: $text-primary;
  845. }
  846. .account-id {
  847. font-size: 12px;
  848. color: $text-secondary;
  849. margin-top: 2px;
  850. }
  851. }
  852. }
  853. .refresh-status {
  854. display: flex;
  855. flex-direction: column;
  856. align-items: center;
  857. padding: 40px 20px;
  858. text-align: center;
  859. .status-icon {
  860. font-size: 56px;
  861. margin-bottom: 20px;
  862. &.loading {
  863. color: $primary-color;
  864. animation: spin 1s linear infinite;
  865. }
  866. &.success {
  867. color: $success-color;
  868. }
  869. &.error,
  870. &.failed,
  871. &.expired {
  872. color: $danger-color;
  873. }
  874. }
  875. .status-text {
  876. h3 {
  877. margin: 0 0 10px;
  878. font-size: 18px;
  879. font-weight: 600;
  880. color: $text-primary;
  881. }
  882. p {
  883. margin: 0;
  884. color: $text-secondary;
  885. }
  886. }
  887. .account-preview {
  888. display: flex;
  889. align-items: center;
  890. gap: 14px;
  891. margin: 15px 0;
  892. padding: 16px 24px;
  893. background: $primary-color-light;
  894. border-radius: $radius-lg;
  895. border: 1px solid $border-light;
  896. .account-preview-info {
  897. text-align: left;
  898. .account-preview-name {
  899. font-weight: 600;
  900. font-size: 16px;
  901. color: $text-primary;
  902. }
  903. .account-preview-stats {
  904. font-size: 13px;
  905. color: $text-secondary;
  906. margin-top: 4px;
  907. span + span {
  908. margin-left: 4px;
  909. }
  910. }
  911. }
  912. }
  913. .fetching-text {
  914. display: inline-block;
  915. }
  916. .fetching-dots::after {
  917. content: '';
  918. animation: dots 1.5s steps(4, end) infinite;
  919. }
  920. .fetching-progress {
  921. width: 220px;
  922. margin: 16px 0;
  923. :deep(.el-progress-bar__outer) {
  924. background: $border-light;
  925. }
  926. :deep(.el-progress-bar__inner) {
  927. background: linear-gradient(90deg, $primary-color, #64b5f6);
  928. }
  929. }
  930. .fetching-hint {
  931. font-size: 13px;
  932. color: $text-secondary;
  933. margin-bottom: 20px;
  934. }
  935. .fetching-steps {
  936. display: flex;
  937. gap: 36px;
  938. .step {
  939. display: flex;
  940. flex-direction: column;
  941. align-items: center;
  942. gap: 10px;
  943. color: $text-secondary;
  944. > span:first-child,
  945. > .el-icon:first-child {
  946. width: 32px;
  947. height: 32px;
  948. border-radius: 50%;
  949. background: $bg-base;
  950. display: flex;
  951. align-items: center;
  952. justify-content: center;
  953. font-size: 14px;
  954. font-weight: 600;
  955. }
  956. .step-text {
  957. font-size: 13px;
  958. font-weight: 500;
  959. }
  960. &.active {
  961. color: $primary-color;
  962. > span:first-child,
  963. > .el-icon:first-child {
  964. background: $primary-color-light;
  965. color: $primary-color;
  966. }
  967. .el-icon {
  968. animation: spin 1s linear infinite;
  969. }
  970. }
  971. &.done {
  972. color: $success-color;
  973. > .el-icon:first-child {
  974. background: $success-color-light;
  975. color: $success-color;
  976. animation: none;
  977. }
  978. }
  979. }
  980. }
  981. }
  982. @keyframes spin {
  983. from {
  984. transform: rotate(0deg);
  985. }
  986. to {
  987. transform: rotate(360deg);
  988. }
  989. }
  990. @keyframes dots {
  991. 0%, 20% { content: ''; }
  992. 40% { content: '.'; }
  993. 60% { content: '..'; }
  994. 80%, 100% { content: '...'; }
  995. }
  996. // 平台选择九宫格
  997. .platform-grid {
  998. display: grid;
  999. grid-template-columns: repeat(3, 1fr);
  1000. gap: 16px;
  1001. padding: 8px 0;
  1002. }
  1003. .platform-card {
  1004. display: flex;
  1005. flex-direction: column;
  1006. align-items: center;
  1007. gap: 10px;
  1008. padding: 20px 16px;
  1009. border-radius: 12px;
  1010. background: $bg-light;
  1011. cursor: pointer;
  1012. transition: all 0.2s ease;
  1013. border: 2px solid transparent;
  1014. &:hover {
  1015. background: #fff;
  1016. border-color: var(--platform-color, $primary-color);
  1017. transform: translateY(-2px);
  1018. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  1019. .platform-icon {
  1020. transform: scale(1.1);
  1021. }
  1022. }
  1023. &:active {
  1024. transform: translateY(0);
  1025. }
  1026. }
  1027. .platform-icon {
  1028. width: 48px;
  1029. height: 48px;
  1030. border-radius: 12px;
  1031. display: flex;
  1032. align-items: center;
  1033. justify-content: center;
  1034. font-size: 20px;
  1035. font-weight: 600;
  1036. color: #fff;
  1037. transition: transform 0.2s ease;
  1038. // 各平台颜色
  1039. &.douyin {
  1040. background: linear-gradient(135deg, #000000, #333333);
  1041. }
  1042. &.xiaohongshu {
  1043. background: linear-gradient(135deg, #FE2C55, #FF5C7C);
  1044. }
  1045. &.kuaishou {
  1046. background: linear-gradient(135deg, #FF5000, #FF7733);
  1047. }
  1048. &.weixin_video {
  1049. background: linear-gradient(135deg, #07C160, #2DC76D);
  1050. }
  1051. &.bilibili {
  1052. background: linear-gradient(135deg, #00A1D6, #33B5E5);
  1053. }
  1054. &.toutiao {
  1055. background: linear-gradient(135deg, #F85959, #FF7B7B);
  1056. }
  1057. &.baijiahao {
  1058. background: linear-gradient(135deg, #2932E1, #5B6AE8);
  1059. }
  1060. }
  1061. .platform-name {
  1062. font-size: 14px;
  1063. font-weight: 500;
  1064. color: $text-primary;
  1065. }
  1066. .platform-hint {
  1067. margin-top: 16px;
  1068. padding: 12px 16px;
  1069. background: $bg-light;
  1070. border-radius: 8px;
  1071. font-size: 13px;
  1072. color: $text-secondary;
  1073. text-align: center;
  1074. }
  1075. // 分组管理样式
  1076. .group-manage {
  1077. .group-add {
  1078. display: flex;
  1079. gap: 12px;
  1080. margin-bottom: 20px;
  1081. padding-bottom: 16px;
  1082. border-bottom: 1px solid $border-light;
  1083. }
  1084. .group-list {
  1085. max-height: 400px;
  1086. overflow-y: auto;
  1087. }
  1088. .group-empty {
  1089. padding: 20px 0;
  1090. }
  1091. .group-item {
  1092. display: flex;
  1093. align-items: center;
  1094. gap: 12px;
  1095. padding: 12px 16px;
  1096. background: $bg-light;
  1097. border-radius: 8px;
  1098. margin-bottom: 8px;
  1099. &:last-child {
  1100. margin-bottom: 0;
  1101. }
  1102. .group-name {
  1103. flex: 1;
  1104. font-weight: 500;
  1105. color: $text-primary;
  1106. }
  1107. .group-count {
  1108. font-size: 13px;
  1109. color: $text-secondary;
  1110. }
  1111. .group-actions {
  1112. display: flex;
  1113. gap: 4px;
  1114. }
  1115. }
  1116. }
  1117. </style>