index.vue 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242
  1. <template>
  2. <div class="publish-page">
  3. <div class="page-header">
  4. <h2>发布管理</h2>
  5. <div class="header-actions">
  6. <el-input
  7. v-model="searchKeyword"
  8. placeholder="搜索标题/文件名..."
  9. style="width: 200px; margin-right: 12px"
  10. clearable
  11. @clear="loadTasks"
  12. @keyup.enter="loadTasks"
  13. >
  14. <template #prefix>
  15. <el-icon><Search /></el-icon>
  16. </template>
  17. </el-input>
  18. <el-button @click="loadTasks" :loading="loading">
  19. <el-icon><Refresh /></el-icon>
  20. 刷新
  21. </el-button>
  22. <el-button type="primary" @click="showCreateDialog = true">
  23. <el-icon><Plus /></el-icon>
  24. 新建发布
  25. </el-button>
  26. </div>
  27. </div>
  28. <div class="page-card">
  29. <el-table :data="filteredTasks" v-loading="loading">
  30. <el-table-column label="视频" min-width="200">
  31. <template #default="{ row }">
  32. <div class="video-info">
  33. <div class="video-title">{{ row.title }}</div>
  34. <div class="video-file">{{ row.videoFilename }}</div>
  35. </div>
  36. </template>
  37. </el-table-column>
  38. <el-table-column label="目标账号" min-width="180">
  39. <template #default="{ row }">
  40. <span>{{ row.targetAccounts?.length || 0 }} 个</span>
  41. <el-tag
  42. v-for="platform in getTaskPlatforms(row)"
  43. :key="platform"
  44. size="small"
  45. style="margin-left: 4px"
  46. >
  47. {{ getPlatformName(platform) }}
  48. </el-tag>
  49. </template>
  50. </el-table-column>
  51. <el-table-column label="状态" width="100">
  52. <template #default="{ row }">
  53. <el-tag :type="getStatusType(row.status)">
  54. {{ getStatusText(row.status) }}
  55. </el-tag>
  56. </template>
  57. </el-table-column>
  58. <el-table-column label="定时发布" width="160">
  59. <template #default="{ row }">
  60. {{ row.scheduledAt ? formatDate(row.scheduledAt) : '-' }}
  61. </template>
  62. </el-table-column>
  63. <el-table-column label="创建时间" width="160">
  64. <template #default="{ row }">
  65. {{ formatDate(row.createdAt) }}
  66. </template>
  67. </el-table-column>
  68. <el-table-column label="操作" width="150" fixed="right">
  69. <template #default="{ row }">
  70. <el-button type="primary" link size="small" @click="viewDetail(row)">
  71. 详情
  72. </el-button>
  73. <el-button
  74. v-if="row.status === 'pending'"
  75. type="danger"
  76. link
  77. size="small"
  78. @click="cancelTask(row.id)"
  79. >
  80. 取消
  81. </el-button>
  82. <el-button
  83. v-if="row.status === 'failed' || row.status === 'processing'"
  84. type="warning"
  85. link
  86. size="small"
  87. @click="retryTask(row.id)"
  88. >
  89. 重试
  90. </el-button>
  91. <el-button
  92. v-if="row.status !== 'processing'"
  93. type="danger"
  94. link
  95. size="small"
  96. @click="deleteTask(row.id)"
  97. >
  98. 删除
  99. </el-button>
  100. </template>
  101. </el-table-column>
  102. </el-table>
  103. <el-pagination
  104. v-model:current-page="pagination.page"
  105. v-model:page-size="pagination.pageSize"
  106. :total="pagination.total"
  107. :page-sizes="[10, 20, 50]"
  108. layout="total, sizes, prev, pager, next"
  109. style="margin-top: 20px"
  110. @change="loadTasks"
  111. />
  112. </div>
  113. <!-- 创建发布对话框 -->
  114. <el-dialog v-model="showCreateDialog" title="新建发布" width="600px">
  115. <el-form :model="createForm" label-width="100px">
  116. <!-- 平台提示:根据选择的目标平台动态显示必填要求 -->
  117. <el-alert
  118. v-if="createSelectedPlatforms.length > 0"
  119. :title="createPlatformHint"
  120. type="info"
  121. :closable="false"
  122. show-icon
  123. style="margin-bottom: 16px"
  124. />
  125. <el-form-item label="视频文件" :required="createRequireVideo">
  126. <el-upload
  127. action=""
  128. :auto-upload="false"
  129. :on-change="handleFileChange"
  130. :show-file-list="false"
  131. >
  132. <el-button type="primary">选择视频</el-button>
  133. </el-upload>
  134. <span v-if="createForm.videoFile" style="margin-left: 12px">
  135. {{ createForm.videoFile.name }}
  136. </span>
  137. <span v-if="createRequireImage && !createForm.videoFile" class="form-tip">
  138. 也可上传图片发布
  139. </span>
  140. </el-form-item>
  141. <el-form-item v-if="createRequireTitle" label="标题" required>
  142. <el-input v-model="createForm.title" placeholder="视频标题" show-word-limit :maxlength="createTitleMaxLength" />
  143. </el-form-item>
  144. <el-form-item v-else label="标题">
  145. <el-input v-model="createForm.title" placeholder="视频标题(可选)" />
  146. </el-form-item>
  147. <el-form-item v-if="createRequireDescription" label="描述" required>
  148. <el-input v-model="createForm.description" type="textarea" :rows="3" placeholder="视频描述" show-word-limit :maxlength="createDescMaxLength" />
  149. </el-form-item>
  150. <el-form-item v-else label="描述">
  151. <el-input v-model="createForm.description" type="textarea" :rows="3" placeholder="视频描述(可选)" />
  152. </el-form-item>
  153. <el-form-item v-if="createShowTags" label="标签">
  154. <el-select v-model="createForm.tags" multiple filterable allow-create placeholder="添加标签" style="width: 100%">
  155. </el-select>
  156. </el-form-item>
  157. <el-form-item label="目标账号">
  158. <el-checkbox-group v-model="createForm.targetAccounts" v-if="accounts.length > 0">
  159. <el-checkbox
  160. v-for="account in accounts"
  161. :key="account.id"
  162. :label="account.id"
  163. :disabled="account.status !== 'active'"
  164. >
  165. <div class="account-option">
  166. <el-avatar :size="24" :src="account.avatarUrl || undefined">
  167. {{ account.accountName?.[0] }}
  168. </el-avatar>
  169. <span class="account-name">{{ account.accountName }}</span>
  170. <el-tag size="small" :type="account.platform === 'douyin' ? 'danger' : 'primary'">
  171. {{ getPlatformName(account.platform) }}
  172. </el-tag>
  173. <el-tag v-if="account.status !== 'active'" size="small" type="warning">
  174. {{ account.status === 'expired' ? '已过期' : '异常' }}
  175. </el-tag>
  176. </div>
  177. </el-checkbox>
  178. </el-checkbox-group>
  179. <el-empty v-else description="暂无可用账号,请先在账号管理中添加" :image-size="60" />
  180. </el-form-item>
  181. <el-form-item label="定时发布">
  182. <el-date-picker
  183. v-model="createForm.scheduledAt"
  184. type="datetime"
  185. placeholder="选择时间(留空则立即发布)"
  186. />
  187. </el-form-item>
  188. <el-form-item label="发布代理">
  189. <el-switch v-model="createForm.usePublishProxy" />
  190. </el-form-item>
  191. <el-form-item v-if="createForm.usePublishProxy" label="代理城市">
  192. <el-cascader
  193. v-model="createForm.publishProxyRegionPath"
  194. :options="publishProxyCityRegions"
  195. :props="{ checkStrictly: false }"
  196. placeholder="选择城市(省/市)"
  197. clearable
  198. filterable
  199. style="width: 100%"
  200. />
  201. </el-form-item>
  202. </el-form>
  203. <template #footer>
  204. <el-button @click="showCreateDialog = false">取消</el-button>
  205. <el-button type="primary" @click="handleCreate" :loading="submitting">
  206. 创建
  207. </el-button>
  208. </template>
  209. </el-dialog>
  210. <!-- 任务详情对话框 -->
  211. <el-dialog v-model="showDetailDialog" title="发布详情" width="700px">
  212. <template v-if="currentTask">
  213. <el-descriptions :column="2" border>
  214. <el-descriptions-item label="任务ID">{{ currentTask.id }}</el-descriptions-item>
  215. <el-descriptions-item label="状态">
  216. <el-tag :type="getStatusType(currentTask.status)">
  217. {{ getStatusText(currentTask.status) }}
  218. </el-tag>
  219. </el-descriptions-item>
  220. <el-descriptions-item label="标题" :span="2">{{ currentTask.title }}</el-descriptions-item>
  221. <el-descriptions-item label="描述" :span="2">{{ currentTask.description || '-' }}</el-descriptions-item>
  222. <el-descriptions-item label="视频文件" :span="2">{{ currentTask.videoFilename || currentTask.videoPath }}</el-descriptions-item>
  223. <el-descriptions-item label="标签" :span="2">
  224. <el-tag v-for="tag in (currentTask.tags || [])" :key="tag" size="small" style="margin-right: 4px">
  225. {{ tag }}
  226. </el-tag>
  227. <span v-if="!currentTask.tags?.length">-</span>
  228. </el-descriptions-item>
  229. <el-descriptions-item label="目标账号">{{ taskDetail?.results?.length || currentTask?.targetAccounts?.length || 0 }} 个</el-descriptions-item>
  230. <el-descriptions-item label="定时发布">
  231. {{ currentTask.scheduledAt ? formatDate(currentTask.scheduledAt) : '立即发布' }}
  232. </el-descriptions-item>
  233. <el-descriptions-item label="发布代理" :span="2">
  234. {{ formatPublishProxy(currentTask.publishProxy as any) }}
  235. </el-descriptions-item>
  236. <el-descriptions-item label="创建时间">{{ formatDate(currentTask.createdAt) }}</el-descriptions-item>
  237. <el-descriptions-item label="发布时间">
  238. {{ currentTask.publishedAt ? formatDate(currentTask.publishedAt) : '-' }}
  239. </el-descriptions-item>
  240. </el-descriptions>
  241. <!-- 发布结果 -->
  242. <div v-if="taskDetail?.results?.length" class="publish-results">
  243. <h4>发布结果</h4>
  244. <el-table :data="taskDetail.results" size="small">
  245. <el-table-column label="账号" prop="accountId" width="80" />
  246. <el-table-column label="平台" width="100">
  247. <template #default="{ row }">
  248. {{ getPlatformName(row.platform) }}
  249. </template>
  250. </el-table-column>
  251. <el-table-column label="状态" width="100">
  252. <template #default="{ row }">
  253. <el-tag :type="row.status === 'success' ? 'success' : (row.status === 'failed' ? 'danger' : 'info')" size="small">
  254. {{ row.status === 'success' ? '成功' : (row.status === 'failed' ? '失败' : '待发布') }}
  255. </el-tag>
  256. </template>
  257. </el-table-column>
  258. <el-table-column label="错误信息" min-width="260">
  259. <template #default="{ row }">
  260. <div v-if="isCaptchaError(row.errorMessage)" class="captcha-error">
  261. <el-text type="warning">检测到验证码,需要手动验证</el-text>
  262. <el-button
  263. type="primary"
  264. size="small"
  265. link
  266. @click="openBrowserForCaptcha(row.accountId, row.platform)"
  267. >
  268. 打开浏览器验证
  269. </el-button>
  270. </div>
  271. <div v-else-if="isScreenshotPendingError(row.errorMessage)" class="screenshot-pending">
  272. <span>{{ row.errorMessage?.replace('请查看截图', '').trim() || '发布结果待确认' }}</span>
  273. <el-button
  274. type="primary"
  275. size="small"
  276. link
  277. @click="openScreenshotView(row)"
  278. >
  279. 查看截图
  280. </el-button>
  281. <el-button
  282. v-if="canRetryWithHeadful(row)"
  283. type="primary"
  284. size="small"
  285. link
  286. @click="openBrowserForCaptcha(row.accountId, row.platform)"
  287. >
  288. 使用有头浏览器重新发布
  289. </el-button>
  290. </div>
  291. <div v-else class="normal-error">
  292. <span>{{ row.errorMessage || '-' }}</span>
  293. <el-button
  294. v-if="canRetryWithHeadful(row)"
  295. type="primary"
  296. size="small"
  297. link
  298. @click="openBrowserForCaptcha(row.accountId, row.platform)"
  299. >
  300. 使用有头浏览器重新发布
  301. </el-button>
  302. </div>
  303. </template>
  304. </el-table-column>
  305. <el-table-column label="发布时间" width="160">
  306. <template #default="{ row }">
  307. {{ row.publishedAt ? formatDate(row.publishedAt) : '-' }}
  308. </template>
  309. </el-table-column>
  310. </el-table>
  311. </div>
  312. </template>
  313. <template #footer>
  314. <el-button @click="showDetailDialog = false">关闭</el-button>
  315. <el-button
  316. v-if="hasPendingConfirmResults"
  317. type="success"
  318. @click="confirmAllPendingResults"
  319. >
  320. 确认
  321. </el-button>
  322. <el-button type="primary" @click="openEditDialog">
  323. <el-icon><Edit /></el-icon>
  324. 修改并重新发布
  325. </el-button>
  326. </template>
  327. </el-dialog>
  328. <!-- 修改重新发布对话框 -->
  329. <el-dialog v-model="showEditDialog" title="修改并重新发布" width="600px">
  330. <el-alert type="info" :closable="false" style="margin-bottom: 16px">
  331. 修改内容后将创建一条新的发布任务
  332. </el-alert>
  333. <el-form :model="editForm" label-width="100px">
  334. <el-form-item label="视频文件">
  335. <div>
  336. <span>{{ editForm.videoFilename || '使用原视频' }}</span>
  337. <el-upload
  338. action=""
  339. :auto-upload="false"
  340. :on-change="handleEditFileChange"
  341. :show-file-list="false"
  342. style="display: inline-block; margin-left: 12px"
  343. >
  344. <el-button size="small">更换视频</el-button>
  345. </el-upload>
  346. </div>
  347. </el-form-item>
  348. <el-form-item label="标题">
  349. <el-input v-model="editForm.title" placeholder="视频标题" />
  350. </el-form-item>
  351. <el-form-item label="描述">
  352. <el-input v-model="editForm.description" type="textarea" :rows="3" placeholder="视频描述" />
  353. </el-form-item>
  354. <el-form-item label="标签">
  355. <el-select v-model="editForm.tags" multiple filterable allow-create placeholder="添加标签" style="width: 100%">
  356. </el-select>
  357. </el-form-item>
  358. <el-form-item label="目标账号">
  359. <el-checkbox-group v-model="editForm.targetAccounts" v-if="accounts.length > 0">
  360. <el-checkbox
  361. v-for="account in accounts"
  362. :key="account.id"
  363. :label="account.id"
  364. :disabled="account.status !== 'active'"
  365. >
  366. <div class="account-option">
  367. <el-avatar :size="24" :src="account.avatarUrl || undefined">
  368. {{ account.accountName?.[0] }}
  369. </el-avatar>
  370. <span class="account-name">{{ account.accountName }}</span>
  371. <el-tag size="small" :type="account.platform === 'douyin' ? 'danger' : 'primary'">
  372. {{ getPlatformName(account.platform) }}
  373. </el-tag>
  374. <el-tag v-if="account.status !== 'active'" size="small" type="warning">
  375. {{ account.status === 'expired' ? '已过期' : '异常' }}
  376. </el-tag>
  377. </div>
  378. </el-checkbox>
  379. </el-checkbox-group>
  380. <el-empty v-else description="暂无可用账号" :image-size="60" />
  381. </el-form-item>
  382. <el-form-item label="定时发布">
  383. <el-date-picker
  384. v-model="editForm.scheduledAt"
  385. type="datetime"
  386. placeholder="选择时间(留空则立即发布)"
  387. />
  388. </el-form-item>
  389. <el-form-item label="发布代理">
  390. <el-switch v-model="editForm.usePublishProxy" />
  391. </el-form-item>
  392. <el-form-item v-if="editForm.usePublishProxy" label="代理城市">
  393. <el-cascader
  394. v-model="editForm.publishProxyRegionPath"
  395. :options="publishProxyCityRegions"
  396. :props="{ checkStrictly: false }"
  397. placeholder="选择城市(省/市)"
  398. clearable
  399. filterable
  400. style="width: 100%"
  401. />
  402. </el-form-item>
  403. </el-form>
  404. <template #footer>
  405. <el-button @click="showEditDialog = false">取消</el-button>
  406. <el-button type="primary" @click="handleRepublish" :loading="submitting">
  407. 创建新发布
  408. </el-button>
  409. </template>
  410. </el-dialog>
  411. </div>
  412. </template>
  413. <script setup lang="ts">
  414. import { ref, reactive, computed, onMounted, watch } from 'vue';
  415. import { Plus, Refresh, Search, Edit } from '@element-plus/icons-vue';
  416. import { ElMessage, ElMessageBox, ElLoading, type UploadFile } from 'element-plus';
  417. import { accountsApi } from '@/api/accounts';
  418. import request from '@/api/request';
  419. import { PLATFORMS } from '@media-manager/shared';
  420. import type { PublishTask, PublishTaskDetail, PlatformAccount, PlatformType } from '@media-manager/shared';
  421. import { useTaskQueueStore } from '@/stores/taskQueue';
  422. import { useTabsStore } from '@/stores/tabs';
  423. import dayjs from 'dayjs';
  424. const taskStore = useTaskQueueStore();
  425. const tabsStore = useTabsStore();
  426. const loading = ref(false);
  427. const submitting = ref(false);
  428. const showCreateDialog = ref(false);
  429. const showDetailDialog = ref(false);
  430. const showEditDialog = ref(false);
  431. const searchKeyword = ref('');
  432. const tasks = ref<PublishTask[]>([]);
  433. const accounts = ref<PlatformAccount[]>([]);
  434. const currentTask = ref<PublishTask | null>(null);
  435. const taskDetail = ref<PublishTaskDetail | null>(null);
  436. // 过滤后的任务列表
  437. const filteredTasks = computed(() => {
  438. if (!searchKeyword.value) return tasks.value;
  439. const keyword = searchKeyword.value.toLowerCase();
  440. return tasks.value.filter(t =>
  441. t.title?.toLowerCase().includes(keyword) ||
  442. t.videoFilename?.toLowerCase().includes(keyword)
  443. );
  444. });
  445. // ===== Bug #6069: 创建表单平台感知字段 =====
  446. // 当前选中的目标平台列表
  447. const createSelectedPlatforms = computed<PlatformType[]>(() => {
  448. const ids = new Set(createForm.targetAccounts);
  449. const platforms = new Set<PlatformType>();
  450. for (const account of accounts.value) {
  451. if (ids.has(Number(account.id))) {
  452. platforms.add(account.platform);
  453. }
  454. }
  455. return Array.from(platforms);
  456. });
  457. // 各平台发布要求
  458. const PLATFORM_PUBLISH_REQUIREMENTS: Record<string, {
  459. requireTitle: boolean;
  460. requireDescription: boolean;
  461. requireVideo: boolean;
  462. requireImage: boolean;
  463. showTags: boolean;
  464. }> = {
  465. douyin: { requireTitle: true, requireDescription: false, requireVideo: true, requireImage: false, showTags: true },
  466. xiaohongshu: { requireTitle: true, requireDescription: true, requireVideo: false, requireImage: true, showTags: true },
  467. weixin_video: { requireTitle: true, requireDescription: false, requireVideo: true, requireImage: false, showTags: true },
  468. baijiahao: { requireTitle: true, requireDescription: true, requireVideo: false, requireImage: false, showTags: true },
  469. kuaishou: { requireTitle: true, requireDescription: false, requireVideo: true, requireImage: false, showTags: true },
  470. bilibili: { requireTitle: true, requireDescription: false, requireVideo: true, requireImage: false, showTags: true },
  471. };
  472. // 只要任一选中平台要求某字段,就显示必填
  473. const createRequireTitle = computed(() => createSelectedPlatforms.value.some(p => PLATFORM_PUBLISH_REQUIREMENTS[p]?.requireTitle));
  474. const createRequireDescription = computed(() => createSelectedPlatforms.value.some(p => PLATFORM_PUBLISH_REQUIREMENTS[p]?.requireDescription));
  475. const createRequireVideo = computed(() => createSelectedPlatforms.value.some(p => PLATFORM_PUBLISH_REQUIREMENTS[p]?.requireVideo));
  476. const createRequireImage = computed(() => createSelectedPlatforms.value.some(p => PLATFORM_PUBLISH_REQUIREMENTS[p]?.requireImage));
  477. const createShowTags = computed(() => createSelectedPlatforms.value.some(p => PLATFORM_PUBLISH_REQUIREMENTS[p]?.showTags));
  478. // 取最严格的标题/描述长度限制
  479. const createTitleMaxLength = computed(() => {
  480. let max = 100;
  481. for (const p of createSelectedPlatforms.value) {
  482. const info = PLATFORMS[p];
  483. if (info?.maxTitleLength && info.maxTitleLength < max) max = info.maxTitleLength;
  484. }
  485. return max;
  486. });
  487. const createDescMaxLength = computed(() => {
  488. let max = 2000;
  489. for (const p of createSelectedPlatforms.value) {
  490. const info = PLATFORMS[p];
  491. if (info?.maxDescriptionLength && info.maxDescriptionLength < max) max = info.maxDescriptionLength;
  492. }
  493. return max;
  494. });
  495. // 平台提示文案
  496. const createPlatformHint = computed(() => {
  497. const platforms = createSelectedPlatforms.value;
  498. if (!platforms.length) return '';
  499. const names = platforms.map(p => PLATFORMS[p]?.name || p).join('、');
  500. const tips: string[] = [];
  501. if (createRequireTitle.value) tips.push('标题必填');
  502. if (createRequireDescription.value) tips.push('正文必填');
  503. if (createRequireVideo.value) tips.push('视频必填');
  504. if (createRequireImage.value) tips.push('图片或视频必填');
  505. return `已选平台:${names}。要求:${tips.join('、')}`;
  506. });
  507. const pagination = reactive({
  508. page: 1,
  509. pageSize: 20,
  510. total: 0,
  511. });
  512. const publishProxyRegions = ref<any[]>([]);
  513. const publishProxyCityRegions = computed(() => {
  514. const provinces = Array.isArray(publishProxyRegions.value) ? publishProxyRegions.value : [];
  515. return provinces.map((p: any) => ({
  516. ...p,
  517. children: Array.isArray(p?.children)
  518. ? p.children.map((c: any) => ({
  519. ...c,
  520. children: undefined,
  521. }))
  522. : [],
  523. }));
  524. });
  525. const createForm = reactive({
  526. videoFile: null as File | null,
  527. title: '',
  528. description: '',
  529. tags: [] as string[],
  530. targetAccounts: [] as number[],
  531. scheduledAt: null as Date | null,
  532. usePublishProxy: false,
  533. publishProxyRegionPath: [] as string[],
  534. });
  535. const editForm = reactive({
  536. videoFile: null as File | null,
  537. videoPath: '',
  538. videoFilename: '',
  539. title: '',
  540. description: '',
  541. tags: [] as string[],
  542. targetAccounts: [] as number[],
  543. scheduledAt: null as Date | null,
  544. usePublishProxy: false,
  545. publishProxyRegionPath: [] as string[],
  546. });
  547. function getSelectableAccountIds(): Set<number> {
  548. return new Set(
  549. accounts.value
  550. .filter(account => account.status === 'active')
  551. .map(account => Number(account.id))
  552. .filter(id => Number.isFinite(id))
  553. );
  554. }
  555. function sanitizeTargetAccounts(targetAccounts: Array<number | string | null | undefined>): number[] {
  556. const selectableIds = getSelectableAccountIds();
  557. const normalized = (targetAccounts || [])
  558. .map(id => Number(id))
  559. .filter(id => Number.isFinite(id) && selectableIds.has(id));
  560. return Array.from(new Set(normalized));
  561. }
  562. async function loadSystemConfig() {
  563. try {
  564. await loadPublishProxyRegions();
  565. } catch {
  566. publishProxyRegions.value = [];
  567. }
  568. }
  569. async function loadPublishProxyRegions() {
  570. try {
  571. const res = await request.get('/api/system/publish-proxy/regions');
  572. publishProxyRegions.value = Array.isArray(res?.regions) ? res.regions : [];
  573. } catch {
  574. publishProxyRegions.value = [];
  575. }
  576. }
  577. function getRegionLabelsByPath(options: any[], regionPath: string[]): string[] {
  578. const labels: string[] = [];
  579. let currentOptions = options;
  580. for (const value of regionPath) {
  581. const hit = currentOptions?.find((o: any) => String(o?.value) === String(value));
  582. if (!hit) break;
  583. labels.push(String(hit.label || ''));
  584. currentOptions = hit.children || [];
  585. }
  586. return labels.filter(Boolean);
  587. }
  588. function resolvePublishProxyFromRegionPath(regionPath: string[]) {
  589. const path = Array.isArray(regionPath) ? regionPath.map(v => String(v)) : [];
  590. if (!path.length) return null;
  591. const labels = getRegionLabelsByPath(publishProxyRegions.value, path);
  592. const regionCode = path[path.length - 1];
  593. const regionName = labels[labels.length - 1] || '';
  594. const specialCityLabels = new Set(['市辖区', '县', '省直辖县级行政区划', '自治区直辖县级行政区划']);
  595. let city = '';
  596. if (labels.length >= 2) {
  597. city = labels[1];
  598. if (specialCityLabels.has(city)) city = labels[0] || city;
  599. } else {
  600. city = labels[0] || '';
  601. }
  602. return {
  603. city: city || undefined,
  604. regionCode: regionCode || undefined,
  605. regionName: regionName || undefined,
  606. regionPath: path,
  607. };
  608. }
  609. function normalizePublishProxyCityRegionPath(regionPath: string[]) {
  610. const path = Array.isArray(regionPath) ? regionPath.map(v => String(v)) : [];
  611. if (path.length <= 2) return path;
  612. return path.slice(0, 2);
  613. }
  614. function formatPublishProxy(publishProxy: any): string {
  615. const enabled = Boolean(publishProxy?.enabled);
  616. if (!enabled) return '-';
  617. const provider = String(publishProxy?.provider || 'shenlong');
  618. const city = String(publishProxy?.city || '').trim();
  619. const regionCode = String(publishProxy?.regionCode || '').trim();
  620. const regionName = String(publishProxy?.regionName || '').trim();
  621. const regionPath = Array.isArray(publishProxy?.regionPath) ? publishProxy.regionPath.map((v: any) => String(v)) : [];
  622. let regionText = '';
  623. if (regionPath.length && publishProxyRegions.value.length) {
  624. const labels = getRegionLabelsByPath(publishProxyRegions.value, regionPath);
  625. if (labels.length) {
  626. regionText = labels.join('/');
  627. }
  628. }
  629. if (!regionText && regionName) regionText = regionName;
  630. const parts: string[] = [];
  631. parts.push(`启用(${provider})`);
  632. if (regionText && regionCode) parts.push(`地区: ${regionText}(${regionCode})`);
  633. else if (regionText) parts.push(`地区: ${regionText}`);
  634. else if (regionCode) parts.push(`地区编号: ${regionCode}`);
  635. if (city) parts.push(`城市: ${city}`);
  636. return parts.join(',');
  637. }
  638. function findRegionPathByCode(options: any[], targetCode: string): string[] | null {
  639. for (const opt of options || []) {
  640. if (!opt) continue;
  641. const value = String(opt.value || '');
  642. if (value === targetCode) return [value];
  643. const children = Array.isArray(opt.children) ? opt.children : [];
  644. const childPath = findRegionPathByCode(children, targetCode);
  645. if (childPath) return [value, ...childPath];
  646. }
  647. return null;
  648. }
  649. function getPlatformName(platform: PlatformType) {
  650. return PLATFORMS[platform]?.name || platform;
  651. }
  652. // 根据 targetAccounts 获取关联的平台列表(Bug #6066: 显示渠道)
  653. function getTaskPlatforms(task: PublishTask): PlatformType[] {
  654. const ids = new Set(task.targetAccounts || []);
  655. const platforms = new Set<PlatformType>();
  656. for (const account of accounts.value) {
  657. if (ids.has(Number(account.id))) {
  658. platforms.add(account.platform);
  659. }
  660. }
  661. return Array.from(platforms);
  662. }
  663. function getStatusType(status: string) {
  664. const types: Record<string, string> = {
  665. pending: 'info',
  666. processing: 'warning',
  667. completed: 'success',
  668. failed: 'danger',
  669. cancelled: 'info',
  670. };
  671. return types[status] || 'info';
  672. }
  673. function getStatusText(status: string) {
  674. const texts: Record<string, string> = {
  675. pending: '待发布',
  676. processing: '发布中',
  677. completed: '已完成',
  678. failed: '失败',
  679. cancelled: '已取消',
  680. };
  681. return texts[status] || status;
  682. }
  683. function formatDate(date: string) {
  684. return dayjs(date).format('YYYY-MM-DD HH:mm');
  685. }
  686. // 检查是否是验证码错误
  687. function isCaptchaError(errorMessage: string | null | undefined): boolean {
  688. return !!errorMessage && errorMessage.includes('CAPTCHA_REQUIRED');
  689. }
  690. // 检查是否是"发布结果待确认,请查看截图"类错误
  691. function isScreenshotPendingError(errorMessage: string | null | undefined): boolean {
  692. return !!errorMessage && errorMessage.includes('请查看截图');
  693. }
  694. // 是否可以使用有头浏览器重新发布
  695. function canRetryWithHeadful(row: { platform: string; status: string | null | undefined }): boolean {
  696. if (row.status !== 'failed') return false;
  697. return row.platform === 'xiaohongshu' || row.platform === 'baijiahao' || row.platform === 'douyin' || row.platform === 'weixin_video';
  698. }
  699. // 打开查看截图(平台暂打开创作者中心,用户可自行查看发布状态)
  700. function openScreenshotView(row: { platform: string; accountId: number }) {
  701. if (row.platform === 'xiaohongshu') {
  702. window.open('https://creator.xiaohongshu.com/publish/publish', '_blank', 'noopener,noreferrer');
  703. return;
  704. }
  705. if (row.platform === 'baijiahao') {
  706. window.open('https://baijiahao.baidu.com/builder/rc/content', '_blank', 'noopener,noreferrer');
  707. return;
  708. }
  709. if (row.platform === 'douyin') {
  710. window.open('https://creator.douyin.com/creator-micro/content/upload', '_blank', 'noopener,noreferrer');
  711. return;
  712. }
  713. if (row.platform === 'weixin_video') {
  714. window.open('https://channels.weixin.qq.com/platform', '_blank', 'noopener,noreferrer');
  715. return;
  716. }
  717. ElMessage.info('请前往对应平台查看发布状态');
  718. }
  719. // 是否存在待确认的发布结果
  720. const hasPendingConfirmResults = computed(() => {
  721. const results = taskDetail.value?.results || [];
  722. return results.some(r => isScreenshotPendingError(r.errorMessage));
  723. });
  724. // 确认所有待确认的发布结果
  725. async function confirmAllPendingResults() {
  726. if (!currentTask.value || !taskDetail.value?.results?.length) return;
  727. const pending = taskDetail.value.results.filter(r => isScreenshotPendingError(r.errorMessage));
  728. if (!pending.length) return;
  729. try {
  730. for (const r of pending) {
  731. await request.post(`/api/publish/${currentTask.value.id}/results/${r.id}/confirm`);
  732. }
  733. ElMessage.success('已确认发布成功');
  734. const detail = await request.get(`/api/publish/${currentTask.value.id}`);
  735. taskDetail.value = detail;
  736. } catch {
  737. ElMessage.error('确认失败');
  738. }
  739. }
  740. // 使用有头浏览器重新执行发布流程(用于验证码场景)
  741. async function openBrowserForCaptcha(accountId: number, platform: string) {
  742. if (!currentTask.value) {
  743. ElMessage.error('任务信息不存在');
  744. return;
  745. }
  746. const account = accounts.value.find(a => a.id === accountId);
  747. const accountName = account?.accountName || `账号${accountId}`;
  748. const taskId = currentTask.value.id;
  749. // 确认操作
  750. try {
  751. await ElMessageBox.confirm(
  752. `即将使用有头浏览器重新发布到 ${accountName}。\n\n浏览器会自动执行发布流程,遇到验证码时会暂停等待您手动操作。\n\n确认继续吗?`,
  753. '有头浏览器发布',
  754. {
  755. confirmButtonText: '开始发布',
  756. cancelButtonText: '取消',
  757. type: 'info',
  758. }
  759. );
  760. } catch {
  761. // 用户取消
  762. return;
  763. }
  764. // 关闭详情对话框
  765. showDetailDialog.value = false;
  766. // 显示正在执行的提示
  767. const loadingInstance = ElLoading.service({
  768. lock: true,
  769. text: `正在使用有头浏览器发布到 ${accountName},请在弹出的浏览器窗口中完成验证码验证...`,
  770. background: 'rgba(0, 0, 0, 0.7)',
  771. });
  772. try {
  773. // 调用后端 API 执行有头浏览器发布
  774. const result = await request.post(`/api/publish/${taskId}/retry-headful/${accountId}`);
  775. loadingInstance.close();
  776. if (result.success) {
  777. ElMessage.success(`${accountName} 发布成功!`);
  778. } else {
  779. ElMessage.error(result.error || `${accountName} 发布失败`);
  780. }
  781. // 刷新任务列表
  782. await loadTasks();
  783. } catch (error: unknown) {
  784. loadingInstance.close();
  785. const errorMsg = error instanceof Error ? error.message : '发布失败';
  786. ElMessage.error(errorMsg);
  787. }
  788. }
  789. async function loadTasks() {
  790. loading.value = true;
  791. try {
  792. const result = await request.get('/api/publish', {
  793. params: {
  794. page: pagination.page,
  795. pageSize: pagination.pageSize,
  796. },
  797. });
  798. tasks.value = result.items;
  799. pagination.total = result.total;
  800. } catch {
  801. // 错误已处理
  802. } finally {
  803. loading.value = false;
  804. }
  805. }
  806. async function loadAccounts() {
  807. try {
  808. // 获取所有账号,不限制状态
  809. accounts.value = await accountsApi.getAccounts();
  810. } catch {
  811. // 错误已处理
  812. }
  813. }
  814. function handleFileChange(file: UploadFile) {
  815. createForm.videoFile = file.raw || null;
  816. }
  817. async function handleCreate() {
  818. if (createForm.targetAccounts.length === 0) {
  819. ElMessage.warning('请至少选择一个目标账号');
  820. return;
  821. }
  822. if (createRequireTitle.value && !createForm.title) {
  823. ElMessage.warning('所选平台要求标题必填');
  824. return;
  825. }
  826. if (createRequireDescription.value && !createForm.description) {
  827. ElMessage.warning('所选平台要求正文必填');
  828. return;
  829. }
  830. if (createRequireVideo.value && !createForm.videoFile) {
  831. ElMessage.warning('所选平台要求视频必填');
  832. return;
  833. }
  834. if (createForm.usePublishProxy && publishProxyRegions.value.length > 0 && !createForm.publishProxyRegionPath.length) {
  835. ElMessage.warning('请选择代理城市');
  836. return;
  837. }
  838. submitting.value = true;
  839. try {
  840. // 1. 上传视频
  841. const formData = new FormData();
  842. formData.append('video', createForm.videoFile);
  843. const uploadResult = await request.post('/api/upload/video', formData, {
  844. headers: {
  845. 'Content-Type': 'multipart/form-data',
  846. },
  847. });
  848. // 2. 创建发布任务
  849. const proxy = createForm.usePublishProxy
  850. ? resolvePublishProxyFromRegionPath(createForm.publishProxyRegionPath)
  851. : null;
  852. await request.post('/api/publish', {
  853. videoPath: uploadResult.path,
  854. videoFilename: uploadResult.originalname,
  855. title: createForm.title,
  856. description: createForm.description,
  857. tags: createForm.tags,
  858. targetAccounts: createForm.targetAccounts,
  859. scheduledAt: createForm.scheduledAt ? createForm.scheduledAt.toISOString() : null,
  860. publishProxy: proxy
  861. ? {
  862. enabled: true,
  863. provider: 'shenlong',
  864. city: proxy.city,
  865. regionCode: proxy.regionCode,
  866. regionName: proxy.regionName,
  867. regionPath: proxy.regionPath,
  868. }
  869. : null,
  870. });
  871. ElMessage.success('发布任务创建成功');
  872. showCreateDialog.value = false;
  873. // 重置表单
  874. createForm.videoFile = null;
  875. createForm.title = '';
  876. createForm.description = '';
  877. createForm.tags = [];
  878. createForm.targetAccounts = [];
  879. createForm.scheduledAt = null;
  880. createForm.usePublishProxy = false;
  881. createForm.publishProxyRegionPath = [];
  882. loadTasks();
  883. } catch {
  884. // 错误已处理
  885. } finally {
  886. submitting.value = false;
  887. }
  888. }
  889. async function viewDetail(task: PublishTask) {
  890. currentTask.value = task;
  891. showDetailDialog.value = true;
  892. // 加载任务详情(包含发布结果)
  893. try {
  894. const detail = await request.get(`/api/publish/${task.id}`);
  895. taskDetail.value = detail;
  896. } catch {
  897. // 错误已处理
  898. }
  899. }
  900. function openEditDialog() {
  901. if (!currentTask.value) return;
  902. // 复制当前任务信息到编辑表单
  903. editForm.videoFile = null;
  904. editForm.videoPath = currentTask.value.videoPath || '';
  905. editForm.videoFilename = currentTask.value.videoFilename || '';
  906. editForm.title = currentTask.value.title || '';
  907. editForm.description = currentTask.value.description || '';
  908. editForm.tags = [...(currentTask.value.tags || [])];
  909. const originalTargetAccounts = [...(currentTask.value.targetAccounts || [])];
  910. editForm.targetAccounts = sanitizeTargetAccounts(originalTargetAccounts);
  911. editForm.scheduledAt = null;
  912. editForm.usePublishProxy = Boolean(currentTask.value.publishProxy?.enabled);
  913. editForm.publishProxyRegionPath = Array.isArray((currentTask.value.publishProxy as any)?.regionPath)
  914. ? normalizePublishProxyCityRegionPath((currentTask.value.publishProxy as any).regionPath)
  915. : [];
  916. if (originalTargetAccounts.length > 0 && editForm.targetAccounts.length !== originalTargetAccounts.length) {
  917. ElMessage.warning('原任务中的部分账号已不可用,已自动移除,请重新选择可用账号');
  918. }
  919. const regionCode = String((currentTask.value.publishProxy as any)?.regionCode || '').trim();
  920. if (editForm.usePublishProxy && !editForm.publishProxyRegionPath.length && regionCode && publishProxyRegions.value.length > 0) {
  921. const inferred = findRegionPathByCode(publishProxyRegions.value, regionCode);
  922. if (inferred) editForm.publishProxyRegionPath = normalizePublishProxyCityRegionPath(inferred);
  923. }
  924. showDetailDialog.value = false;
  925. showEditDialog.value = true;
  926. }
  927. function handleEditFileChange(file: UploadFile) {
  928. editForm.videoFile = file.raw || null;
  929. if (file.raw) {
  930. editForm.videoFilename = file.raw.name;
  931. }
  932. }
  933. async function handleRepublish() {
  934. const targetAccounts = sanitizeTargetAccounts(editForm.targetAccounts);
  935. editForm.targetAccounts = targetAccounts;
  936. if (!editForm.title || targetAccounts.length === 0) {
  937. ElMessage.warning('请填写完整信息');
  938. return;
  939. }
  940. if (editForm.usePublishProxy && publishProxyRegions.value.length > 0 && !editForm.publishProxyRegionPath.length) {
  941. ElMessage.warning('请选择代理城市');
  942. return;
  943. }
  944. submitting.value = true;
  945. try {
  946. let videoPath = editForm.videoPath;
  947. // 如果选择了新视频,先上传
  948. if (editForm.videoFile) {
  949. const formData = new FormData();
  950. formData.append('video', editForm.videoFile);
  951. const uploadResult = await request.post('/api/upload/video', formData, {
  952. headers: { 'Content-Type': 'multipart/form-data' },
  953. });
  954. videoPath = uploadResult.path;
  955. }
  956. // 创建新的发布任务
  957. const proxy = editForm.usePublishProxy
  958. ? resolvePublishProxyFromRegionPath(editForm.publishProxyRegionPath)
  959. : null;
  960. await request.post('/api/publish', {
  961. videoPath,
  962. videoFilename: editForm.videoFilename,
  963. title: editForm.title,
  964. description: editForm.description,
  965. tags: editForm.tags,
  966. targetAccounts,
  967. scheduledAt: editForm.scheduledAt ? editForm.scheduledAt.toISOString() : null,
  968. publishProxy: proxy
  969. ? {
  970. enabled: true,
  971. provider: 'shenlong',
  972. city: proxy.city,
  973. regionCode: proxy.regionCode,
  974. regionName: proxy.regionName,
  975. regionPath: proxy.regionPath,
  976. }
  977. : null,
  978. });
  979. ElMessage.success('新发布任务已创建');
  980. showEditDialog.value = false;
  981. loadTasks();
  982. } catch {
  983. // 错误已处理
  984. } finally {
  985. submitting.value = false;
  986. }
  987. }
  988. async function cancelTask(id: number) {
  989. try {
  990. await ElMessageBox.confirm('确定要取消该任务吗?', '提示');
  991. await request.post(`/api/publish/${id}/cancel`);
  992. ElMessage.success('任务已取消');
  993. loadTasks();
  994. } catch {
  995. // 取消或错误
  996. }
  997. }
  998. async function retryTask(id: number) {
  999. try {
  1000. await request.post(`/api/publish/${id}/retry`);
  1001. ElMessage.success('任务已重新开始');
  1002. loadTasks();
  1003. } catch {
  1004. // 错误已处理
  1005. }
  1006. }
  1007. async function deleteTask(id: number) {
  1008. try {
  1009. await ElMessageBox.confirm('确定要删除该任务吗?删除后不可恢复。', '提示', {
  1010. type: 'warning',
  1011. });
  1012. await request.delete(`/api/publish/${id}`);
  1013. ElMessage.success('任务已删除');
  1014. loadTasks();
  1015. } catch {
  1016. // 取消或错误
  1017. }
  1018. }
  1019. // 监听 taskStore 的任务列表变化,当发布任务完成时刷新列表
  1020. watch(() => taskStore.tasks, (newTasks, oldTasks) => {
  1021. // 检查是否有发布任务状态变化
  1022. const hasPublishTaskChange = newTasks.some(task => {
  1023. if (task.type !== 'publish_video') return false;
  1024. const oldTask = oldTasks?.find(t => t.id === task.id);
  1025. return !oldTask || oldTask.status !== task.status;
  1026. });
  1027. if (hasPublishTaskChange) {
  1028. loadTasks();
  1029. }
  1030. }, { deep: true });
  1031. onMounted(() => {
  1032. loadTasks();
  1033. loadAccounts();
  1034. loadSystemConfig();
  1035. });
  1036. </script>
  1037. <style lang="scss" scoped>
  1038. @use '@/styles/variables.scss' as *;
  1039. .page-header {
  1040. display: flex;
  1041. align-items: center;
  1042. justify-content: space-between;
  1043. margin-bottom: 20px;
  1044. h2 {
  1045. margin: 0;
  1046. }
  1047. .header-actions {
  1048. display: flex;
  1049. align-items: center;
  1050. }
  1051. }
  1052. .video-info {
  1053. .video-title {
  1054. font-weight: 500;
  1055. }
  1056. .video-file {
  1057. font-size: 12px;
  1058. color: $text-secondary;
  1059. margin-top: 4px;
  1060. }
  1061. }
  1062. .form-tip {
  1063. margin-left: 12px;
  1064. color: $text-secondary;
  1065. font-size: 13px;
  1066. }
  1067. .publish-results {
  1068. margin-top: 20px;
  1069. h4 {
  1070. margin: 0 0 12px;
  1071. font-size: 14px;
  1072. color: var(--el-text-color-primary);
  1073. }
  1074. }
  1075. .captcha-error,
  1076. .screenshot-pending {
  1077. display: flex;
  1078. flex-direction: row;
  1079. align-items: center;
  1080. gap: 8px;
  1081. flex-wrap: wrap;
  1082. }
  1083. .normal-error {
  1084. display: flex;
  1085. flex-direction: row;
  1086. align-items: center;
  1087. gap: 8px;
  1088. flex-wrap: wrap;
  1089. }
  1090. .account-option {
  1091. display: inline-flex;
  1092. align-items: center;
  1093. gap: 8px;
  1094. .account-name {
  1095. font-weight: 500;
  1096. }
  1097. }
  1098. :deep(.el-checkbox-group) {
  1099. display: flex;
  1100. flex-direction: column;
  1101. gap: 12px;
  1102. }
  1103. :deep(.el-checkbox) {
  1104. height: auto;
  1105. .el-checkbox__label {
  1106. padding-left: 8px;
  1107. }
  1108. }
  1109. </style>