| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242 |
- <template>
- <div class="publish-page">
- <div class="page-header">
- <h2>发布管理</h2>
- <div class="header-actions">
- <el-input
- v-model="searchKeyword"
- placeholder="搜索标题/文件名..."
- style="width: 200px; margin-right: 12px"
- clearable
- @clear="loadTasks"
- @keyup.enter="loadTasks"
- >
- <template #prefix>
- <el-icon><Search /></el-icon>
- </template>
- </el-input>
- <el-button @click="loadTasks" :loading="loading">
- <el-icon><Refresh /></el-icon>
- 刷新
- </el-button>
- <el-button type="primary" @click="showCreateDialog = true">
- <el-icon><Plus /></el-icon>
- 新建发布
- </el-button>
- </div>
- </div>
-
- <div class="page-card">
- <el-table :data="filteredTasks" v-loading="loading">
- <el-table-column label="视频" min-width="200">
- <template #default="{ row }">
- <div class="video-info">
- <div class="video-title">{{ row.title }}</div>
- <div class="video-file">{{ row.videoFilename }}</div>
- </div>
- </template>
- </el-table-column>
-
- <el-table-column label="目标账号" min-width="180">
- <template #default="{ row }">
- <span>{{ row.targetAccounts?.length || 0 }} 个</span>
- <el-tag
- v-for="platform in getTaskPlatforms(row)"
- :key="platform"
- size="small"
- style="margin-left: 4px"
- >
- {{ getPlatformName(platform) }}
- </el-tag>
- </template>
- </el-table-column>
-
- <el-table-column label="状态" width="100">
- <template #default="{ row }">
- <el-tag :type="getStatusType(row.status)">
- {{ getStatusText(row.status) }}
- </el-tag>
- </template>
- </el-table-column>
-
- <el-table-column label="定时发布" width="160">
- <template #default="{ row }">
- {{ row.scheduledAt ? formatDate(row.scheduledAt) : '-' }}
- </template>
- </el-table-column>
-
- <el-table-column label="创建时间" width="160">
- <template #default="{ row }">
- {{ formatDate(row.createdAt) }}
- </template>
- </el-table-column>
-
- <el-table-column label="操作" width="150" fixed="right">
- <template #default="{ row }">
- <el-button type="primary" link size="small" @click="viewDetail(row)">
- 详情
- </el-button>
- <el-button
- v-if="row.status === 'pending'"
- type="danger"
- link
- size="small"
- @click="cancelTask(row.id)"
- >
- 取消
- </el-button>
- <el-button
- v-if="row.status === 'failed' || row.status === 'processing'"
- type="warning"
- link
- size="small"
- @click="retryTask(row.id)"
- >
- 重试
- </el-button>
- <el-button
- v-if="row.status !== 'processing'"
- type="danger"
- link
- size="small"
- @click="deleteTask(row.id)"
- >
- 删除
- </el-button>
- </template>
- </el-table-column>
- </el-table>
-
- <el-pagination
- v-model:current-page="pagination.page"
- v-model:page-size="pagination.pageSize"
- :total="pagination.total"
- :page-sizes="[10, 20, 50]"
- layout="total, sizes, prev, pager, next"
- style="margin-top: 20px"
- @change="loadTasks"
- />
- </div>
-
- <!-- 创建发布对话框 -->
- <el-dialog v-model="showCreateDialog" title="新建发布" width="600px">
- <el-form :model="createForm" label-width="100px">
- <!-- 平台提示:根据选择的目标平台动态显示必填要求 -->
- <el-alert
- v-if="createSelectedPlatforms.length > 0"
- :title="createPlatformHint"
- type="info"
- :closable="false"
- show-icon
- style="margin-bottom: 16px"
- />
-
- <el-form-item label="视频文件" :required="createRequireVideo">
- <el-upload
- action=""
- :auto-upload="false"
- :on-change="handleFileChange"
- :show-file-list="false"
- >
- <el-button type="primary">选择视频</el-button>
- </el-upload>
- <span v-if="createForm.videoFile" style="margin-left: 12px">
- {{ createForm.videoFile.name }}
- </span>
- <span v-if="createRequireImage && !createForm.videoFile" class="form-tip">
- 也可上传图片发布
- </span>
- </el-form-item>
-
- <el-form-item v-if="createRequireTitle" label="标题" required>
- <el-input v-model="createForm.title" placeholder="视频标题" show-word-limit :maxlength="createTitleMaxLength" />
- </el-form-item>
- <el-form-item v-else label="标题">
- <el-input v-model="createForm.title" placeholder="视频标题(可选)" />
- </el-form-item>
-
- <el-form-item v-if="createRequireDescription" label="描述" required>
- <el-input v-model="createForm.description" type="textarea" :rows="3" placeholder="视频描述" show-word-limit :maxlength="createDescMaxLength" />
- </el-form-item>
- <el-form-item v-else label="描述">
- <el-input v-model="createForm.description" type="textarea" :rows="3" placeholder="视频描述(可选)" />
- </el-form-item>
-
- <el-form-item v-if="createShowTags" label="标签">
- <el-select v-model="createForm.tags" multiple filterable allow-create placeholder="添加标签" style="width: 100%">
- </el-select>
- </el-form-item>
-
- <el-form-item label="目标账号">
- <el-checkbox-group v-model="createForm.targetAccounts" v-if="accounts.length > 0">
- <el-checkbox
- v-for="account in accounts"
- :key="account.id"
- :label="account.id"
- :disabled="account.status !== 'active'"
- >
- <div class="account-option">
- <el-avatar :size="24" :src="account.avatarUrl || undefined">
- {{ account.accountName?.[0] }}
- </el-avatar>
- <span class="account-name">{{ account.accountName }}</span>
- <el-tag size="small" :type="account.platform === 'douyin' ? 'danger' : 'primary'">
- {{ getPlatformName(account.platform) }}
- </el-tag>
- <el-tag v-if="account.status !== 'active'" size="small" type="warning">
- {{ account.status === 'expired' ? '已过期' : '异常' }}
- </el-tag>
- </div>
- </el-checkbox>
- </el-checkbox-group>
- <el-empty v-else description="暂无可用账号,请先在账号管理中添加" :image-size="60" />
- </el-form-item>
-
- <el-form-item label="定时发布">
- <el-date-picker
- v-model="createForm.scheduledAt"
- type="datetime"
- placeholder="选择时间(留空则立即发布)"
- />
- </el-form-item>
- <el-form-item label="发布代理">
- <el-switch v-model="createForm.usePublishProxy" />
- </el-form-item>
- <el-form-item v-if="createForm.usePublishProxy" label="代理城市">
- <el-cascader
- v-model="createForm.publishProxyRegionPath"
- :options="publishProxyCityRegions"
- :props="{ checkStrictly: false }"
- placeholder="选择城市(省/市)"
- clearable
- filterable
- style="width: 100%"
- />
- </el-form-item>
- </el-form>
-
- <template #footer>
- <el-button @click="showCreateDialog = false">取消</el-button>
- <el-button type="primary" @click="handleCreate" :loading="submitting">
- 创建
- </el-button>
- </template>
- </el-dialog>
-
- <!-- 任务详情对话框 -->
- <el-dialog v-model="showDetailDialog" title="发布详情" width="700px">
- <template v-if="currentTask">
- <el-descriptions :column="2" border>
- <el-descriptions-item label="任务ID">{{ currentTask.id }}</el-descriptions-item>
- <el-descriptions-item label="状态">
- <el-tag :type="getStatusType(currentTask.status)">
- {{ getStatusText(currentTask.status) }}
- </el-tag>
- </el-descriptions-item>
- <el-descriptions-item label="标题" :span="2">{{ currentTask.title }}</el-descriptions-item>
- <el-descriptions-item label="描述" :span="2">{{ currentTask.description || '-' }}</el-descriptions-item>
- <el-descriptions-item label="视频文件" :span="2">{{ currentTask.videoFilename || currentTask.videoPath }}</el-descriptions-item>
- <el-descriptions-item label="标签" :span="2">
- <el-tag v-for="tag in (currentTask.tags || [])" :key="tag" size="small" style="margin-right: 4px">
- {{ tag }}
- </el-tag>
- <span v-if="!currentTask.tags?.length">-</span>
- </el-descriptions-item>
- <el-descriptions-item label="目标账号">{{ taskDetail?.results?.length || currentTask?.targetAccounts?.length || 0 }} 个</el-descriptions-item>
- <el-descriptions-item label="定时发布">
- {{ currentTask.scheduledAt ? formatDate(currentTask.scheduledAt) : '立即发布' }}
- </el-descriptions-item>
- <el-descriptions-item label="发布代理" :span="2">
- {{ formatPublishProxy(currentTask.publishProxy as any) }}
- </el-descriptions-item>
- <el-descriptions-item label="创建时间">{{ formatDate(currentTask.createdAt) }}</el-descriptions-item>
- <el-descriptions-item label="发布时间">
- {{ currentTask.publishedAt ? formatDate(currentTask.publishedAt) : '-' }}
- </el-descriptions-item>
- </el-descriptions>
-
- <!-- 发布结果 -->
- <div v-if="taskDetail?.results?.length" class="publish-results">
- <h4>发布结果</h4>
- <el-table :data="taskDetail.results" size="small">
- <el-table-column label="账号" prop="accountId" width="80" />
- <el-table-column label="平台" width="100">
- <template #default="{ row }">
- {{ getPlatformName(row.platform) }}
- </template>
- </el-table-column>
- <el-table-column label="状态" width="100">
- <template #default="{ row }">
- <el-tag :type="row.status === 'success' ? 'success' : (row.status === 'failed' ? 'danger' : 'info')" size="small">
- {{ row.status === 'success' ? '成功' : (row.status === 'failed' ? '失败' : '待发布') }}
- </el-tag>
- </template>
- </el-table-column>
- <el-table-column label="错误信息" min-width="260">
- <template #default="{ row }">
- <div v-if="isCaptchaError(row.errorMessage)" class="captcha-error">
- <el-text type="warning">检测到验证码,需要手动验证</el-text>
- <el-button
- type="primary"
- size="small"
- link
- @click="openBrowserForCaptcha(row.accountId, row.platform)"
- >
- 打开浏览器验证
- </el-button>
- </div>
- <div v-else-if="isScreenshotPendingError(row.errorMessage)" class="screenshot-pending">
- <span>{{ row.errorMessage?.replace('请查看截图', '').trim() || '发布结果待确认' }}</span>
- <el-button
- type="primary"
- size="small"
- link
- @click="openScreenshotView(row)"
- >
- 查看截图
- </el-button>
- <el-button
- v-if="canRetryWithHeadful(row)"
- type="primary"
- size="small"
- link
- @click="openBrowserForCaptcha(row.accountId, row.platform)"
- >
- 使用有头浏览器重新发布
- </el-button>
- </div>
- <div v-else class="normal-error">
- <span>{{ row.errorMessage || '-' }}</span>
- <el-button
- v-if="canRetryWithHeadful(row)"
- type="primary"
- size="small"
- link
- @click="openBrowserForCaptcha(row.accountId, row.platform)"
- >
- 使用有头浏览器重新发布
- </el-button>
- </div>
- </template>
- </el-table-column>
- <el-table-column label="发布时间" width="160">
- <template #default="{ row }">
- {{ row.publishedAt ? formatDate(row.publishedAt) : '-' }}
- </template>
- </el-table-column>
- </el-table>
- </div>
- </template>
-
- <template #footer>
- <el-button @click="showDetailDialog = false">关闭</el-button>
- <el-button
- v-if="hasPendingConfirmResults"
- type="success"
- @click="confirmAllPendingResults"
- >
- 确认
- </el-button>
- <el-button type="primary" @click="openEditDialog">
- <el-icon><Edit /></el-icon>
- 修改并重新发布
- </el-button>
- </template>
- </el-dialog>
-
- <!-- 修改重新发布对话框 -->
- <el-dialog v-model="showEditDialog" title="修改并重新发布" width="600px">
- <el-alert type="info" :closable="false" style="margin-bottom: 16px">
- 修改内容后将创建一条新的发布任务
- </el-alert>
-
- <el-form :model="editForm" label-width="100px">
- <el-form-item label="视频文件">
- <div>
- <span>{{ editForm.videoFilename || '使用原视频' }}</span>
- <el-upload
- action=""
- :auto-upload="false"
- :on-change="handleEditFileChange"
- :show-file-list="false"
- style="display: inline-block; margin-left: 12px"
- >
- <el-button size="small">更换视频</el-button>
- </el-upload>
- </div>
- </el-form-item>
-
- <el-form-item label="标题">
- <el-input v-model="editForm.title" placeholder="视频标题" />
- </el-form-item>
-
- <el-form-item label="描述">
- <el-input v-model="editForm.description" type="textarea" :rows="3" placeholder="视频描述" />
- </el-form-item>
-
- <el-form-item label="标签">
- <el-select v-model="editForm.tags" multiple filterable allow-create placeholder="添加标签" style="width: 100%">
- </el-select>
- </el-form-item>
-
- <el-form-item label="目标账号">
- <el-checkbox-group v-model="editForm.targetAccounts" v-if="accounts.length > 0">
- <el-checkbox
- v-for="account in accounts"
- :key="account.id"
- :label="account.id"
- :disabled="account.status !== 'active'"
- >
- <div class="account-option">
- <el-avatar :size="24" :src="account.avatarUrl || undefined">
- {{ account.accountName?.[0] }}
- </el-avatar>
- <span class="account-name">{{ account.accountName }}</span>
- <el-tag size="small" :type="account.platform === 'douyin' ? 'danger' : 'primary'">
- {{ getPlatformName(account.platform) }}
- </el-tag>
- <el-tag v-if="account.status !== 'active'" size="small" type="warning">
- {{ account.status === 'expired' ? '已过期' : '异常' }}
- </el-tag>
- </div>
- </el-checkbox>
- </el-checkbox-group>
- <el-empty v-else description="暂无可用账号" :image-size="60" />
- </el-form-item>
-
- <el-form-item label="定时发布">
- <el-date-picker
- v-model="editForm.scheduledAt"
- type="datetime"
- placeholder="选择时间(留空则立即发布)"
- />
- </el-form-item>
- <el-form-item label="发布代理">
- <el-switch v-model="editForm.usePublishProxy" />
- </el-form-item>
- <el-form-item v-if="editForm.usePublishProxy" label="代理城市">
- <el-cascader
- v-model="editForm.publishProxyRegionPath"
- :options="publishProxyCityRegions"
- :props="{ checkStrictly: false }"
- placeholder="选择城市(省/市)"
- clearable
- filterable
- style="width: 100%"
- />
- </el-form-item>
- </el-form>
-
- <template #footer>
- <el-button @click="showEditDialog = false">取消</el-button>
- <el-button type="primary" @click="handleRepublish" :loading="submitting">
- 创建新发布
- </el-button>
- </template>
- </el-dialog>
- </div>
- </template>
- <script setup lang="ts">
- import { ref, reactive, computed, onMounted, watch } from 'vue';
- import { Plus, Refresh, Search, Edit } from '@element-plus/icons-vue';
- import { ElMessage, ElMessageBox, ElLoading, type UploadFile } from 'element-plus';
- import { accountsApi } from '@/api/accounts';
- import request from '@/api/request';
- import { PLATFORMS } from '@media-manager/shared';
- import type { PublishTask, PublishTaskDetail, PlatformAccount, PlatformType } from '@media-manager/shared';
- import { useTaskQueueStore } from '@/stores/taskQueue';
- import { useTabsStore } from '@/stores/tabs';
- import dayjs from 'dayjs';
- const taskStore = useTaskQueueStore();
- const tabsStore = useTabsStore();
- const loading = ref(false);
- const submitting = ref(false);
- const showCreateDialog = ref(false);
- const showDetailDialog = ref(false);
- const showEditDialog = ref(false);
- const searchKeyword = ref('');
- const tasks = ref<PublishTask[]>([]);
- const accounts = ref<PlatformAccount[]>([]);
- const currentTask = ref<PublishTask | null>(null);
- const taskDetail = ref<PublishTaskDetail | null>(null);
- // 过滤后的任务列表
- const filteredTasks = computed(() => {
- if (!searchKeyword.value) return tasks.value;
- const keyword = searchKeyword.value.toLowerCase();
- return tasks.value.filter(t =>
- t.title?.toLowerCase().includes(keyword) ||
- t.videoFilename?.toLowerCase().includes(keyword)
- );
- });
- // ===== Bug #6069: 创建表单平台感知字段 =====
- // 当前选中的目标平台列表
- const createSelectedPlatforms = computed<PlatformType[]>(() => {
- const ids = new Set(createForm.targetAccounts);
- const platforms = new Set<PlatformType>();
- for (const account of accounts.value) {
- if (ids.has(Number(account.id))) {
- platforms.add(account.platform);
- }
- }
- return Array.from(platforms);
- });
- // 各平台发布要求
- const PLATFORM_PUBLISH_REQUIREMENTS: Record<string, {
- requireTitle: boolean;
- requireDescription: boolean;
- requireVideo: boolean;
- requireImage: boolean;
- showTags: boolean;
- }> = {
- douyin: { requireTitle: true, requireDescription: false, requireVideo: true, requireImage: false, showTags: true },
- xiaohongshu: { requireTitle: true, requireDescription: true, requireVideo: false, requireImage: true, showTags: true },
- weixin_video: { requireTitle: true, requireDescription: false, requireVideo: true, requireImage: false, showTags: true },
- baijiahao: { requireTitle: true, requireDescription: true, requireVideo: false, requireImage: false, showTags: true },
- kuaishou: { requireTitle: true, requireDescription: false, requireVideo: true, requireImage: false, showTags: true },
- bilibili: { requireTitle: true, requireDescription: false, requireVideo: true, requireImage: false, showTags: true },
- };
- // 只要任一选中平台要求某字段,就显示必填
- const createRequireTitle = computed(() => createSelectedPlatforms.value.some(p => PLATFORM_PUBLISH_REQUIREMENTS[p]?.requireTitle));
- const createRequireDescription = computed(() => createSelectedPlatforms.value.some(p => PLATFORM_PUBLISH_REQUIREMENTS[p]?.requireDescription));
- const createRequireVideo = computed(() => createSelectedPlatforms.value.some(p => PLATFORM_PUBLISH_REQUIREMENTS[p]?.requireVideo));
- const createRequireImage = computed(() => createSelectedPlatforms.value.some(p => PLATFORM_PUBLISH_REQUIREMENTS[p]?.requireImage));
- const createShowTags = computed(() => createSelectedPlatforms.value.some(p => PLATFORM_PUBLISH_REQUIREMENTS[p]?.showTags));
- // 取最严格的标题/描述长度限制
- const createTitleMaxLength = computed(() => {
- let max = 100;
- for (const p of createSelectedPlatforms.value) {
- const info = PLATFORMS[p];
- if (info?.maxTitleLength && info.maxTitleLength < max) max = info.maxTitleLength;
- }
- return max;
- });
- const createDescMaxLength = computed(() => {
- let max = 2000;
- for (const p of createSelectedPlatforms.value) {
- const info = PLATFORMS[p];
- if (info?.maxDescriptionLength && info.maxDescriptionLength < max) max = info.maxDescriptionLength;
- }
- return max;
- });
- // 平台提示文案
- const createPlatformHint = computed(() => {
- const platforms = createSelectedPlatforms.value;
- if (!platforms.length) return '';
- const names = platforms.map(p => PLATFORMS[p]?.name || p).join('、');
- const tips: string[] = [];
- if (createRequireTitle.value) tips.push('标题必填');
- if (createRequireDescription.value) tips.push('正文必填');
- if (createRequireVideo.value) tips.push('视频必填');
- if (createRequireImage.value) tips.push('图片或视频必填');
- return `已选平台:${names}。要求:${tips.join('、')}`;
- });
- const pagination = reactive({
- page: 1,
- pageSize: 20,
- total: 0,
- });
- const publishProxyRegions = ref<any[]>([]);
- const publishProxyCityRegions = computed(() => {
- const provinces = Array.isArray(publishProxyRegions.value) ? publishProxyRegions.value : [];
- return provinces.map((p: any) => ({
- ...p,
- children: Array.isArray(p?.children)
- ? p.children.map((c: any) => ({
- ...c,
- children: undefined,
- }))
- : [],
- }));
- });
- const createForm = reactive({
- videoFile: null as File | null,
- title: '',
- description: '',
- tags: [] as string[],
- targetAccounts: [] as number[],
- scheduledAt: null as Date | null,
- usePublishProxy: false,
- publishProxyRegionPath: [] as string[],
- });
- const editForm = reactive({
- videoFile: null as File | null,
- videoPath: '',
- videoFilename: '',
- title: '',
- description: '',
- tags: [] as string[],
- targetAccounts: [] as number[],
- scheduledAt: null as Date | null,
- usePublishProxy: false,
- publishProxyRegionPath: [] as string[],
- });
- function getSelectableAccountIds(): Set<number> {
- return new Set(
- accounts.value
- .filter(account => account.status === 'active')
- .map(account => Number(account.id))
- .filter(id => Number.isFinite(id))
- );
- }
- function sanitizeTargetAccounts(targetAccounts: Array<number | string | null | undefined>): number[] {
- const selectableIds = getSelectableAccountIds();
- const normalized = (targetAccounts || [])
- .map(id => Number(id))
- .filter(id => Number.isFinite(id) && selectableIds.has(id));
- return Array.from(new Set(normalized));
- }
- async function loadSystemConfig() {
- try {
- await loadPublishProxyRegions();
- } catch {
- publishProxyRegions.value = [];
- }
- }
- async function loadPublishProxyRegions() {
- try {
- const res = await request.get('/api/system/publish-proxy/regions');
- publishProxyRegions.value = Array.isArray(res?.regions) ? res.regions : [];
- } catch {
- publishProxyRegions.value = [];
- }
- }
- function getRegionLabelsByPath(options: any[], regionPath: string[]): string[] {
- const labels: string[] = [];
- let currentOptions = options;
- for (const value of regionPath) {
- const hit = currentOptions?.find((o: any) => String(o?.value) === String(value));
- if (!hit) break;
- labels.push(String(hit.label || ''));
- currentOptions = hit.children || [];
- }
- return labels.filter(Boolean);
- }
- function resolvePublishProxyFromRegionPath(regionPath: string[]) {
- const path = Array.isArray(regionPath) ? regionPath.map(v => String(v)) : [];
- if (!path.length) return null;
- const labels = getRegionLabelsByPath(publishProxyRegions.value, path);
- const regionCode = path[path.length - 1];
- const regionName = labels[labels.length - 1] || '';
- const specialCityLabels = new Set(['市辖区', '县', '省直辖县级行政区划', '自治区直辖县级行政区划']);
- let city = '';
- if (labels.length >= 2) {
- city = labels[1];
- if (specialCityLabels.has(city)) city = labels[0] || city;
- } else {
- city = labels[0] || '';
- }
- return {
- city: city || undefined,
- regionCode: regionCode || undefined,
- regionName: regionName || undefined,
- regionPath: path,
- };
- }
- function normalizePublishProxyCityRegionPath(regionPath: string[]) {
- const path = Array.isArray(regionPath) ? regionPath.map(v => String(v)) : [];
- if (path.length <= 2) return path;
- return path.slice(0, 2);
- }
- function formatPublishProxy(publishProxy: any): string {
- const enabled = Boolean(publishProxy?.enabled);
- if (!enabled) return '-';
- const provider = String(publishProxy?.provider || 'shenlong');
- const city = String(publishProxy?.city || '').trim();
- const regionCode = String(publishProxy?.regionCode || '').trim();
- const regionName = String(publishProxy?.regionName || '').trim();
- const regionPath = Array.isArray(publishProxy?.regionPath) ? publishProxy.regionPath.map((v: any) => String(v)) : [];
- let regionText = '';
- if (regionPath.length && publishProxyRegions.value.length) {
- const labels = getRegionLabelsByPath(publishProxyRegions.value, regionPath);
- if (labels.length) {
- regionText = labels.join('/');
- }
- }
- if (!regionText && regionName) regionText = regionName;
- const parts: string[] = [];
- parts.push(`启用(${provider})`);
- if (regionText && regionCode) parts.push(`地区: ${regionText}(${regionCode})`);
- else if (regionText) parts.push(`地区: ${regionText}`);
- else if (regionCode) parts.push(`地区编号: ${regionCode}`);
- if (city) parts.push(`城市: ${city}`);
- return parts.join(',');
- }
- function findRegionPathByCode(options: any[], targetCode: string): string[] | null {
- for (const opt of options || []) {
- if (!opt) continue;
- const value = String(opt.value || '');
- if (value === targetCode) return [value];
- const children = Array.isArray(opt.children) ? opt.children : [];
- const childPath = findRegionPathByCode(children, targetCode);
- if (childPath) return [value, ...childPath];
- }
- return null;
- }
- function getPlatformName(platform: PlatformType) {
- return PLATFORMS[platform]?.name || platform;
- }
- // 根据 targetAccounts 获取关联的平台列表(Bug #6066: 显示渠道)
- function getTaskPlatforms(task: PublishTask): PlatformType[] {
- const ids = new Set(task.targetAccounts || []);
- const platforms = new Set<PlatformType>();
- for (const account of accounts.value) {
- if (ids.has(Number(account.id))) {
- platforms.add(account.platform);
- }
- }
- return Array.from(platforms);
- }
- function getStatusType(status: string) {
- const types: Record<string, string> = {
- pending: 'info',
- processing: 'warning',
- completed: 'success',
- failed: 'danger',
- cancelled: 'info',
- };
- return types[status] || 'info';
- }
- function getStatusText(status: string) {
- const texts: Record<string, string> = {
- pending: '待发布',
- processing: '发布中',
- completed: '已完成',
- failed: '失败',
- cancelled: '已取消',
- };
- return texts[status] || status;
- }
- function formatDate(date: string) {
- return dayjs(date).format('YYYY-MM-DD HH:mm');
- }
- // 检查是否是验证码错误
- function isCaptchaError(errorMessage: string | null | undefined): boolean {
- return !!errorMessage && errorMessage.includes('CAPTCHA_REQUIRED');
- }
- // 检查是否是"发布结果待确认,请查看截图"类错误
- function isScreenshotPendingError(errorMessage: string | null | undefined): boolean {
- return !!errorMessage && errorMessage.includes('请查看截图');
- }
- // 是否可以使用有头浏览器重新发布
- function canRetryWithHeadful(row: { platform: string; status: string | null | undefined }): boolean {
- if (row.status !== 'failed') return false;
- return row.platform === 'xiaohongshu' || row.platform === 'baijiahao' || row.platform === 'douyin' || row.platform === 'weixin_video';
- }
- // 打开查看截图(平台暂打开创作者中心,用户可自行查看发布状态)
- function openScreenshotView(row: { platform: string; accountId: number }) {
- if (row.platform === 'xiaohongshu') {
- window.open('https://creator.xiaohongshu.com/publish/publish', '_blank', 'noopener,noreferrer');
- return;
- }
- if (row.platform === 'baijiahao') {
- window.open('https://baijiahao.baidu.com/builder/rc/content', '_blank', 'noopener,noreferrer');
- return;
- }
- if (row.platform === 'douyin') {
- window.open('https://creator.douyin.com/creator-micro/content/upload', '_blank', 'noopener,noreferrer');
- return;
- }
- if (row.platform === 'weixin_video') {
- window.open('https://channels.weixin.qq.com/platform', '_blank', 'noopener,noreferrer');
- return;
- }
- ElMessage.info('请前往对应平台查看发布状态');
- }
- // 是否存在待确认的发布结果
- const hasPendingConfirmResults = computed(() => {
- const results = taskDetail.value?.results || [];
- return results.some(r => isScreenshotPendingError(r.errorMessage));
- });
- // 确认所有待确认的发布结果
- async function confirmAllPendingResults() {
- if (!currentTask.value || !taskDetail.value?.results?.length) return;
- const pending = taskDetail.value.results.filter(r => isScreenshotPendingError(r.errorMessage));
- if (!pending.length) return;
- try {
- for (const r of pending) {
- await request.post(`/api/publish/${currentTask.value.id}/results/${r.id}/confirm`);
- }
- ElMessage.success('已确认发布成功');
- const detail = await request.get(`/api/publish/${currentTask.value.id}`);
- taskDetail.value = detail;
- } catch {
- ElMessage.error('确认失败');
- }
- }
- // 使用有头浏览器重新执行发布流程(用于验证码场景)
- async function openBrowserForCaptcha(accountId: number, platform: string) {
- if (!currentTask.value) {
- ElMessage.error('任务信息不存在');
- return;
- }
-
- const account = accounts.value.find(a => a.id === accountId);
- const accountName = account?.accountName || `账号${accountId}`;
- const taskId = currentTask.value.id;
-
- // 确认操作
- try {
- await ElMessageBox.confirm(
- `即将使用有头浏览器重新发布到 ${accountName}。\n\n浏览器会自动执行发布流程,遇到验证码时会暂停等待您手动操作。\n\n确认继续吗?`,
- '有头浏览器发布',
- {
- confirmButtonText: '开始发布',
- cancelButtonText: '取消',
- type: 'info',
- }
- );
- } catch {
- // 用户取消
- return;
- }
-
- // 关闭详情对话框
- showDetailDialog.value = false;
-
- // 显示正在执行的提示
- const loadingInstance = ElLoading.service({
- lock: true,
- text: `正在使用有头浏览器发布到 ${accountName},请在弹出的浏览器窗口中完成验证码验证...`,
- background: 'rgba(0, 0, 0, 0.7)',
- });
-
- try {
- // 调用后端 API 执行有头浏览器发布
- const result = await request.post(`/api/publish/${taskId}/retry-headful/${accountId}`);
-
- loadingInstance.close();
-
- if (result.success) {
- ElMessage.success(`${accountName} 发布成功!`);
- } else {
- ElMessage.error(result.error || `${accountName} 发布失败`);
- }
-
- // 刷新任务列表
- await loadTasks();
-
- } catch (error: unknown) {
- loadingInstance.close();
- const errorMsg = error instanceof Error ? error.message : '发布失败';
- ElMessage.error(errorMsg);
- }
- }
- async function loadTasks() {
- loading.value = true;
- try {
- const result = await request.get('/api/publish', {
- params: {
- page: pagination.page,
- pageSize: pagination.pageSize,
- },
- });
- tasks.value = result.items;
- pagination.total = result.total;
- } catch {
- // 错误已处理
- } finally {
- loading.value = false;
- }
- }
- async function loadAccounts() {
- try {
- // 获取所有账号,不限制状态
- accounts.value = await accountsApi.getAccounts();
- } catch {
- // 错误已处理
- }
- }
- function handleFileChange(file: UploadFile) {
- createForm.videoFile = file.raw || null;
- }
- async function handleCreate() {
- if (createForm.targetAccounts.length === 0) {
- ElMessage.warning('请至少选择一个目标账号');
- return;
- }
- if (createRequireTitle.value && !createForm.title) {
- ElMessage.warning('所选平台要求标题必填');
- return;
- }
- if (createRequireDescription.value && !createForm.description) {
- ElMessage.warning('所选平台要求正文必填');
- return;
- }
- if (createRequireVideo.value && !createForm.videoFile) {
- ElMessage.warning('所选平台要求视频必填');
- return;
- }
- if (createForm.usePublishProxy && publishProxyRegions.value.length > 0 && !createForm.publishProxyRegionPath.length) {
- ElMessage.warning('请选择代理城市');
- return;
- }
-
- submitting.value = true;
- try {
- // 1. 上传视频
- const formData = new FormData();
- formData.append('video', createForm.videoFile);
-
- const uploadResult = await request.post('/api/upload/video', formData, {
- headers: {
- 'Content-Type': 'multipart/form-data',
- },
- });
-
- // 2. 创建发布任务
- const proxy = createForm.usePublishProxy
- ? resolvePublishProxyFromRegionPath(createForm.publishProxyRegionPath)
- : null;
- await request.post('/api/publish', {
- videoPath: uploadResult.path,
- videoFilename: uploadResult.originalname,
- title: createForm.title,
- description: createForm.description,
- tags: createForm.tags,
- targetAccounts: createForm.targetAccounts,
- scheduledAt: createForm.scheduledAt ? createForm.scheduledAt.toISOString() : null,
- publishProxy: proxy
- ? {
- enabled: true,
- provider: 'shenlong',
- city: proxy.city,
- regionCode: proxy.regionCode,
- regionName: proxy.regionName,
- regionPath: proxy.regionPath,
- }
- : null,
- });
-
- ElMessage.success('发布任务创建成功');
- showCreateDialog.value = false;
-
- // 重置表单
- createForm.videoFile = null;
- createForm.title = '';
- createForm.description = '';
- createForm.tags = [];
- createForm.targetAccounts = [];
- createForm.scheduledAt = null;
- createForm.usePublishProxy = false;
- createForm.publishProxyRegionPath = [];
-
- loadTasks();
- } catch {
- // 错误已处理
- } finally {
- submitting.value = false;
- }
- }
- async function viewDetail(task: PublishTask) {
- currentTask.value = task;
- showDetailDialog.value = true;
-
- // 加载任务详情(包含发布结果)
- try {
- const detail = await request.get(`/api/publish/${task.id}`);
- taskDetail.value = detail;
- } catch {
- // 错误已处理
- }
- }
- function openEditDialog() {
- if (!currentTask.value) return;
-
- // 复制当前任务信息到编辑表单
- editForm.videoFile = null;
- editForm.videoPath = currentTask.value.videoPath || '';
- editForm.videoFilename = currentTask.value.videoFilename || '';
- editForm.title = currentTask.value.title || '';
- editForm.description = currentTask.value.description || '';
- editForm.tags = [...(currentTask.value.tags || [])];
- const originalTargetAccounts = [...(currentTask.value.targetAccounts || [])];
- editForm.targetAccounts = sanitizeTargetAccounts(originalTargetAccounts);
- editForm.scheduledAt = null;
- editForm.usePublishProxy = Boolean(currentTask.value.publishProxy?.enabled);
- editForm.publishProxyRegionPath = Array.isArray((currentTask.value.publishProxy as any)?.regionPath)
- ? normalizePublishProxyCityRegionPath((currentTask.value.publishProxy as any).regionPath)
- : [];
- if (originalTargetAccounts.length > 0 && editForm.targetAccounts.length !== originalTargetAccounts.length) {
- ElMessage.warning('原任务中的部分账号已不可用,已自动移除,请重新选择可用账号');
- }
- const regionCode = String((currentTask.value.publishProxy as any)?.regionCode || '').trim();
- if (editForm.usePublishProxy && !editForm.publishProxyRegionPath.length && regionCode && publishProxyRegions.value.length > 0) {
- const inferred = findRegionPathByCode(publishProxyRegions.value, regionCode);
- if (inferred) editForm.publishProxyRegionPath = normalizePublishProxyCityRegionPath(inferred);
- }
-
- showDetailDialog.value = false;
- showEditDialog.value = true;
- }
- function handleEditFileChange(file: UploadFile) {
- editForm.videoFile = file.raw || null;
- if (file.raw) {
- editForm.videoFilename = file.raw.name;
- }
- }
- async function handleRepublish() {
- const targetAccounts = sanitizeTargetAccounts(editForm.targetAccounts);
- editForm.targetAccounts = targetAccounts;
- if (!editForm.title || targetAccounts.length === 0) {
- ElMessage.warning('请填写完整信息');
- return;
- }
- if (editForm.usePublishProxy && publishProxyRegions.value.length > 0 && !editForm.publishProxyRegionPath.length) {
- ElMessage.warning('请选择代理城市');
- return;
- }
-
- submitting.value = true;
- try {
- let videoPath = editForm.videoPath;
-
- // 如果选择了新视频,先上传
- if (editForm.videoFile) {
- const formData = new FormData();
- formData.append('video', editForm.videoFile);
-
- const uploadResult = await request.post('/api/upload/video', formData, {
- headers: { 'Content-Type': 'multipart/form-data' },
- });
- videoPath = uploadResult.path;
- }
-
- // 创建新的发布任务
- const proxy = editForm.usePublishProxy
- ? resolvePublishProxyFromRegionPath(editForm.publishProxyRegionPath)
- : null;
- await request.post('/api/publish', {
- videoPath,
- videoFilename: editForm.videoFilename,
- title: editForm.title,
- description: editForm.description,
- tags: editForm.tags,
- targetAccounts,
- scheduledAt: editForm.scheduledAt ? editForm.scheduledAt.toISOString() : null,
- publishProxy: proxy
- ? {
- enabled: true,
- provider: 'shenlong',
- city: proxy.city,
- regionCode: proxy.regionCode,
- regionName: proxy.regionName,
- regionPath: proxy.regionPath,
- }
- : null,
- });
-
- ElMessage.success('新发布任务已创建');
- showEditDialog.value = false;
- loadTasks();
- } catch {
- // 错误已处理
- } finally {
- submitting.value = false;
- }
- }
- async function cancelTask(id: number) {
- try {
- await ElMessageBox.confirm('确定要取消该任务吗?', '提示');
- await request.post(`/api/publish/${id}/cancel`);
- ElMessage.success('任务已取消');
- loadTasks();
- } catch {
- // 取消或错误
- }
- }
- async function retryTask(id: number) {
- try {
- await request.post(`/api/publish/${id}/retry`);
- ElMessage.success('任务已重新开始');
- loadTasks();
- } catch {
- // 错误已处理
- }
- }
- async function deleteTask(id: number) {
- try {
- await ElMessageBox.confirm('确定要删除该任务吗?删除后不可恢复。', '提示', {
- type: 'warning',
- });
- await request.delete(`/api/publish/${id}`);
- ElMessage.success('任务已删除');
- loadTasks();
- } catch {
- // 取消或错误
- }
- }
- // 监听 taskStore 的任务列表变化,当发布任务完成时刷新列表
- watch(() => taskStore.tasks, (newTasks, oldTasks) => {
- // 检查是否有发布任务状态变化
- const hasPublishTaskChange = newTasks.some(task => {
- if (task.type !== 'publish_video') return false;
- const oldTask = oldTasks?.find(t => t.id === task.id);
- return !oldTask || oldTask.status !== task.status;
- });
-
- if (hasPublishTaskChange) {
- loadTasks();
- }
- }, { deep: true });
- onMounted(() => {
- loadTasks();
- loadAccounts();
- loadSystemConfig();
- });
- </script>
- <style lang="scss" scoped>
- @use '@/styles/variables.scss' as *;
- .page-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 20px;
-
- h2 {
- margin: 0;
- }
-
- .header-actions {
- display: flex;
- align-items: center;
- }
- }
- .video-info {
- .video-title {
- font-weight: 500;
- }
-
- .video-file {
- font-size: 12px;
- color: $text-secondary;
- margin-top: 4px;
- }
- }
- .form-tip {
- margin-left: 12px;
- color: $text-secondary;
- font-size: 13px;
- }
- .publish-results {
- margin-top: 20px;
-
- h4 {
- margin: 0 0 12px;
- font-size: 14px;
- color: var(--el-text-color-primary);
- }
- }
- .captcha-error,
- .screenshot-pending {
- display: flex;
- flex-direction: row;
- align-items: center;
- gap: 8px;
- flex-wrap: wrap;
- }
- .normal-error {
- display: flex;
- flex-direction: row;
- align-items: center;
- gap: 8px;
- flex-wrap: wrap;
- }
- .account-option {
- display: inline-flex;
- align-items: center;
- gap: 8px;
-
- .account-name {
- font-weight: 500;
- }
- }
- :deep(.el-checkbox-group) {
- display: flex;
- flex-direction: column;
- gap: 12px;
- }
- :deep(.el-checkbox) {
- height: auto;
-
- .el-checkbox__label {
- padding-left: 8px;
- }
- }
- </style>
|