feat(enterprise-site): add WordPress support and scheduled auto-publish to sites
Frontend CI / Frontend (push) Successful in 4m17s
Backend CI / Backend (push) Failing after 6m42s

Add WordPress as an enterprise-site CMS type alongside pbootcms, and let
schedule tasks auto-publish generated articles to enterprise sites.

- WordPress connection/publisher service and tests; register wordpress
  media platform and relax cms_type CHECK constraint
- New publish_enterprise_site_targets JSONB column on schedule_tasks with
  array type constraint; thread targets through scheduler dispatch,
  prompt/KOL generation, and worker
- Admin UI: enterprise-site targets in generate/schedule/publish flows,
  KOL package form, MediaView, and i18n strings

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 19:44:24 +08:00
parent 41f5623791
commit c7bad83496
30 changed files with 2946 additions and 696 deletions
+1
View File
@@ -12,6 +12,7 @@ import ImageUploadProgressOverlay from '@/components/ImageUploadProgressOverlay.
colorPrimary: '#1677ff',
borderRadius: 6,
colorBgLayout: '#f0f2f5',
zIndexPopupBase: 3000,
},
}"
>
@@ -251,6 +251,13 @@ function enterprisePlatformFallback(platformId: string) {
accent: '#0f766e',
}
}
if (platformId === 'wordpress') {
return {
name: 'WordPress',
shortName: 'WP',
accent: '#21759b',
}
}
return getPublishPlatformMeta(platformId)
}
</script>
@@ -4,12 +4,16 @@ import {
MinusCircleFilled,
PlusOutlined,
ScheduleOutlined,
SyncOutlined,
} from '@ant-design/icons-vue'
import type {
EnterpriseSiteCategory,
EnterpriseSiteConnection,
JsonValue,
KolSubscriptionPromptCard,
KolVariableDefinition,
PromptRuleSimple,
ScheduleEnterpriseSiteTarget,
ScheduleKind,
ScheduleTargetType,
ScheduleTask,
@@ -25,6 +29,7 @@ import KolDynamicForm from '@/components/kol/KolDynamicForm.vue'
import KolPlatformTags from '@/components/kol/KolPlatformTags.vue'
import PromptRuleModal from '@/components/PromptRuleModal.vue'
import {
enterpriseSitesApi,
generateApi,
imagesApi,
kolGenerateApi,
@@ -67,6 +72,21 @@ const companyStore = useCompanyStore()
const promptDrawerOpen = ref(false)
const coverPickerOpen = ref(false)
type SchedulePublishTargetTab = 'media_accounts' | 'enterprise_sites'
type SchedulePublishType = 'publish' | 'draft'
interface ScheduleEnterpriseSiteCard {
id: number
name: string
siteUrl: string
cmsType: string
status: string
statusText: string
selectable: boolean
disabledReason: string
categoryCount: number
defaultCategoryId: string
}
const scheduleDayOptions = [
{ labelKey: 'custom.schedule.weekdays.mon', value: 'mon' },
@@ -87,7 +107,11 @@ const form = reactive({
kolEnableWebSearch: false,
kolKnowledgeGroupIds: [] as number[],
autoPublish: false,
publishTargetTab: 'media_accounts' as SchedulePublishTargetTab,
publishAccountIds: [] as string[],
publishEnterpriseSiteIds: [] as number[],
enterpriseSiteCategoryIds: {} as Record<number, string>,
enterprisePublishType: 'publish' as SchedulePublishType,
coverEnabled: false,
coverAssetUrl: '',
coverFileName: '',
@@ -143,6 +167,12 @@ const accountsQuery = useQuery({
queryFn: () => tenantAccountsApi.list(),
})
const enterpriseSitesQuery = useQuery({
queryKey: ['tenant', 'enterprise-sites', 'schedule-task-drawer'],
enabled: schedulePublishQueryEnabled,
queryFn: () => enterpriseSitesApi.list(),
})
const promptOptions = computed(() =>
(rulesQuery.data.value ?? []).map((rule: PromptRuleSimple) => ({
label: rule.name,
@@ -179,8 +209,37 @@ const schedulePublishAccounts = computed<PublishAccountCard[]>(() =>
const schedulePublishLoading = computed(
() => platformsQuery.isPending.value || accountsQuery.isPending.value,
)
const scheduleEnterpriseSitesLoading = computed(() => enterpriseSitesQuery.isPending.value)
const scheduleEnterpriseSites = computed(() => enterpriseSitesQuery.data.value ?? [])
const scheduleEnterpriseSiteCards = computed<ScheduleEnterpriseSiteCard[]>(() =>
scheduleEnterpriseSites.value.map((site) => {
const siteName =
site.name?.trim() || site.site_name?.trim() || site.site_url?.trim() || '企业站点'
const status = enterpriseSiteConnectionStatus(site)
const disabledReason = resolveScheduleEnterpriseSiteDisabledReason(site, status)
return {
id: site.id,
name: siteName,
siteUrl: site.site_url,
cmsType: cmsTypeLabel(site.cms_type),
status,
statusText: enterpriseSiteStatusLabel(status),
selectable: !disabledReason,
disabledReason,
categoryCount: site.category_count,
defaultCategoryId: site.default_scode?.trim() || '',
}
}),
)
const enterpriseCategoriesBySiteId = ref<Record<number, EnterpriseSiteCategory[]>>({})
const selectedSchedulePublishPlatformIds = computed(() => {
if (!form.autoPublish || form.publishTargetTab !== 'media_accounts') {
return []
}
const selected = new Set(form.publishAccountIds)
return Array.from(
new Set(
@@ -241,6 +300,23 @@ watch(
},
)
watch(
scheduleEnterpriseSiteCards,
(cards) => {
if (enterpriseSitesQuery.isPending.value || enterpriseSitesQuery.isFetching.value) {
return
}
if (cards.length === 0) {
return
}
const selectableIds = new Set(cards.filter((card) => card.selectable).map((card) => card.id))
form.publishEnterpriseSiteIds = form.publishEnterpriseSiteIds.filter((id) =>
selectableIds.has(id),
)
},
{ immediate: true },
)
watch(currentBrandId, () => {
form.promptRuleId = undefined
})
@@ -331,6 +407,16 @@ const updateScheduleMutation = useMutation({
onError: (error) => message.error(formatError(error)),
})
const syncEnterpriseCategoriesMutation = useMutation({
mutationFn: (siteId: number) => enterpriseSitesApi.syncCategories(siteId),
onSuccess: (categories, siteId) => {
setEnterpriseCategories(siteId, categories)
void queryClient.invalidateQueries({ queryKey: ['tenant', 'enterprise-sites'] })
message.success(t('custom.messages.enterpriseCategoriesSynced'))
},
onError: (error) => message.error(formatError(error)),
})
const instantGenerateMutation = useMutation({
mutationFn: () => generateApi.fromRule(buildInstantPayload()),
onSuccess: async () => {
@@ -362,7 +448,27 @@ function hydrateForm(): void {
form.kolEnableWebSearch = Boolean(task?.generation_input?.enable_web_search)
form.kolKnowledgeGroupIds = normalizeNumberList(task?.generation_input?.knowledge_group_ids)
form.autoPublish = isSchedule.value ? Boolean(task?.auto_publish) : false
form.publishTargetTab =
task?.publish_enterprise_site_targets?.length && !task?.publish_account_ids?.length
? 'enterprise_sites'
: 'media_accounts'
form.publishAccountIds = isSchedule.value ? [...(task?.publish_account_ids ?? [])] : []
const enterpriseTargets = isSchedule.value ? (task?.publish_enterprise_site_targets ?? []) : []
form.publishEnterpriseSiteIds = enterpriseTargets.map((target) => target.site_id)
form.enterpriseSiteCategoryIds = enterpriseTargets.reduce<Record<number, string>>(
(acc, target) => {
acc[target.site_id] = target.category_id
return acc
},
{},
)
form.enterprisePublishType =
enterpriseTargets.find((target) => target.publish_type === 'draft')?.publish_type === 'draft'
? 'draft'
: 'publish'
enterpriseTargets.forEach((target) => {
void loadEnterpriseCategories(target.site_id)
})
form.coverAssetUrl = isSchedule.value ? resolveApiURL(task?.cover_asset_url) : ''
form.coverFileName = deriveCoverFileName(form.coverAssetUrl)
form.coverImageAssetId = isSchedule.value ? (task?.cover_image_asset_id ?? null) : null
@@ -400,6 +506,12 @@ function selectDefaultSubscriptionPrompt(): void {
function buildSchedulePayload() {
const coverEnabled = form.autoPublish && effectiveScheduleCoverEnabled.value
const coverAssetUrl = coverEnabled ? form.coverAssetUrl.trim() : ''
const publishAccountIds =
form.autoPublish && form.publishTargetTab === 'media_accounts' ? form.publishAccountIds : []
const publishEnterpriseSiteTargets =
form.autoPublish && form.publishTargetTab === 'enterprise_sites'
? buildScheduleEnterpriseSiteTargets()
: []
const basePayload = {
name: form.name.trim(),
target_type: form.targetType,
@@ -414,7 +526,8 @@ function buildSchedulePayload() {
random_window_start: '01:00:00',
random_window_end: '05:00:00',
auto_publish: form.autoPublish,
publish_account_ids: form.autoPublish ? form.publishAccountIds : [],
publish_account_ids: publishAccountIds,
publish_enterprise_site_targets: publishEnterpriseSiteTargets,
cover_asset_url: coverAssetUrl || null,
cover_image_asset_id: coverAssetUrl ? form.coverImageAssetId : null,
enable_web_search: form.targetType === 'prompt_rule' ? form.enableWebSearch : false,
@@ -476,6 +589,88 @@ function isSchedulePublishAccountSelected(accountId: string): boolean {
return form.publishAccountIds.includes(accountId)
}
function buildScheduleEnterpriseSiteTargets(): ScheduleEnterpriseSiteTarget[] {
return form.publishEnterpriseSiteIds
.map((siteId) => ({
site_id: siteId,
category_id: form.enterpriseSiteCategoryIds[siteId]?.trim() ?? '',
publish_type: form.enterprisePublishType,
}))
.filter((target) => target.site_id > 0 && target.category_id)
}
function toggleScheduleEnterpriseSite(site: ScheduleEnterpriseSiteCard): void {
if (!site.selectable) {
return
}
if (form.publishEnterpriseSiteIds.includes(site.id)) {
form.publishEnterpriseSiteIds = form.publishEnterpriseSiteIds.filter((id) => id !== site.id)
return
}
form.publishEnterpriseSiteIds = [...form.publishEnterpriseSiteIds, site.id]
if (!form.enterpriseSiteCategoryIds[site.id]) {
form.enterpriseSiteCategoryIds = {
...form.enterpriseSiteCategoryIds,
[site.id]: site.defaultCategoryId,
}
}
void loadEnterpriseCategories(site.id)
}
function isScheduleEnterpriseSiteSelected(siteId: number): boolean {
return form.publishEnterpriseSiteIds.includes(siteId)
}
function enterpriseCategoryOptions(siteId: number) {
return (enterpriseCategoriesBySiteId.value[siteId] ?? []).map((category) => ({
label: enterpriseCategoryLabel(category),
value: category.remote_id,
}))
}
function enterpriseCategoryLabel(category: EnterpriseSiteCategory): string {
const slug = category.slug?.trim()
return slug ? `${category.name} / ${slug}` : category.name
}
function setEnterpriseCategories(siteId: number, categories: EnterpriseSiteCategory[]): void {
enterpriseCategoriesBySiteId.value = {
...enterpriseCategoriesBySiteId.value,
[siteId]: categories,
}
const current = form.enterpriseSiteCategoryIds[siteId]?.trim()
const currentExists = categories.some((category) => category.remote_id === current)
if ((!current || !currentExists) && categories.length > 0) {
form.enterpriseSiteCategoryIds = {
...form.enterpriseSiteCategoryIds,
[siteId]: categories[0].remote_id,
}
}
}
async function loadEnterpriseCategories(siteId: number): Promise<void> {
if (enterpriseCategoriesBySiteId.value[siteId]) {
return
}
try {
const categories = await enterpriseSitesApi.categories(siteId)
setEnterpriseCategories(siteId, categories)
} catch {
enterpriseCategoriesBySiteId.value = {
...enterpriseCategoriesBySiteId.value,
[siteId]: [],
}
}
}
function syncEnterpriseCategories(siteId: number): void {
syncEnterpriseCategoriesMutation.mutate(siteId)
}
function enterpriseSelectPopupContainer(triggerNode?: HTMLElement | null): HTMLElement {
return (triggerNode?.closest('.ant-drawer-content') as HTMLElement | null) ?? document.body
}
async function uploadScheduleCover(
file: File,
): Promise<{ url: string; fileName: string; assetId?: number | null }> {
@@ -568,11 +763,28 @@ function validateForm(): boolean {
}
if (isSchedule.value && form.autoPublish) {
if (form.publishAccountIds.length === 0) {
message.warning(t('custom.messages.missingPublishAccounts'))
if (form.publishTargetTab === 'media_accounts' && form.publishAccountIds.length === 0) {
message.warning(t('custom.messages.missingPublishTargets'))
return false
}
if (scheduleCoverRequired.value && !form.coverAssetUrl.trim()) {
if (form.publishTargetTab === 'enterprise_sites') {
if (form.publishEnterpriseSiteIds.length === 0) {
message.warning(t('custom.messages.missingPublishTargets'))
return false
}
const missingCategorySite = form.publishEnterpriseSiteIds.find(
(siteId) => !form.enterpriseSiteCategoryIds[siteId]?.trim(),
)
if (missingCategorySite) {
message.warning(t('custom.messages.missingEnterpriseSiteCategory'))
return false
}
}
if (
form.publishTargetTab === 'media_accounts' &&
scheduleCoverRequired.value &&
!form.coverAssetUrl.trim()
) {
message.warning(t('custom.messages.missingScheduleCover'))
return false
}
@@ -637,6 +849,47 @@ function normalizeScheduleDays(days?: string[] | null): string[] {
.filter((day) => allowed.has(day) && input.includes(day))
}
function cmsTypeLabel(cmsType: string): string {
switch (String(cmsType ?? '').trim()) {
case 'pbootcms':
return 'PBootCMS'
case 'wordpress':
return 'WordPress'
default:
return cmsType || 'CMS'
}
}
function enterpriseSiteConnectionStatus(site: EnterpriseSiteConnection): string {
return String(site.status ?? '').trim()
}
function enterpriseSiteStatusLabel(status: string): string {
switch (String(status ?? '').trim()) {
case 'connected':
return t('custom.task.enterpriseSiteConnected')
case 'error':
return t('custom.task.enterpriseSiteError')
case 'disabled':
return t('custom.task.enterpriseSiteDisabled')
default:
return t('custom.task.enterpriseSiteDraft')
}
}
function resolveScheduleEnterpriseSiteDisabledReason(
site: EnterpriseSiteConnection,
status: string,
): string {
if (status === 'disabled') {
return t('custom.task.enterpriseSiteDisabledHint')
}
if (status === 'error') {
return site.last_error?.trim() || t('custom.task.enterpriseSiteErrorHint')
}
return ''
}
function fieldKey(key?: string | null, id?: string): string {
return key?.trim() || id || ''
}
@@ -754,7 +1007,7 @@ function normalizeJsonRecord(value: Record<string, unknown>): Record<string, Jso
:title="drawerTitle"
placement="right"
width="1120"
:mask-closable="false"
:mask-closable="true"
class="generate-task-drawer"
@close="emit('update:open', false)"
>
@@ -1001,18 +1254,29 @@ function normalizeJsonRecord(value: Record<string, unknown>): Record<string, Jso
<template v-if="form.autoPublish">
<div class="generate-task-drawer__field">
<label class="generate-task-drawer__label generate-task-drawer__label--required">
{{ t('custom.task.publishAccounts') }}
{{ t('custom.task.publishTargets') }}
</label>
<p class="generate-task-drawer__hint">{{ t('custom.task.publishAccountsHint') }}</p>
<p class="generate-task-drawer__hint">{{ t('custom.task.publishTargetsHint') }}</p>
<a-segmented
v-model:value="form.publishTargetTab"
:options="[
{ label: t('custom.task.publishTargetMediaAccounts'), value: 'media_accounts' },
{ label: t('custom.task.publishTargetEnterpriseSites'), value: 'enterprise_sites' },
]"
class="generate-task-drawer__target-tabs"
/>
<a-skeleton
v-if="schedulePublishLoading"
v-if="form.publishTargetTab === 'media_accounts' && schedulePublishLoading"
active
:title="false"
:paragraph="{ rows: 4 }"
/>
<div
v-else-if="schedulePublishAccounts.length"
v-else-if="
form.publishTargetTab === 'media_accounts' && schedulePublishAccounts.length
"
class="generate-task-drawer__account-grid"
>
<button
@@ -1057,10 +1321,114 @@ function normalizeJsonRecord(value: Record<string, unknown>): Record<string, Jso
</a-tooltip>
</button>
</div>
<div v-else class="generate-task-drawer__empty">
<div
v-else-if="form.publishTargetTab === 'media_accounts'"
class="generate-task-drawer__empty"
>
<strong>{{ t('custom.task.publishAccountEmptyTitle') }}</strong>
<p>{{ t('custom.task.publishAccountEmptyHint') }}</p>
</div>
<a-skeleton
v-if="form.publishTargetTab === 'enterprise_sites' && scheduleEnterpriseSitesLoading"
active
:title="false"
:paragraph="{ rows: 4 }"
/>
<template v-else-if="form.publishTargetTab === 'enterprise_sites'">
<div class="generate-task-drawer__enterprise-toolbar">
<span>{{ t('custom.task.enterprisePublishType') }}</span>
<a-segmented
v-model:value="form.enterprisePublishType"
:options="[
{ label: t('custom.task.enterprisePublishNow'), value: 'publish' },
{ label: t('custom.task.enterprisePublishDraft'), value: 'draft' },
]"
/>
</div>
<div
v-if="scheduleEnterpriseSiteCards.length"
class="generate-task-drawer__account-grid"
>
<article
v-for="site in scheduleEnterpriseSiteCards"
:key="site.id"
class="generate-task-drawer__site-card"
:class="{
'generate-task-drawer__site-card--active': isScheduleEnterpriseSiteSelected(
site.id,
),
'generate-task-drawer__site-card--disabled': !site.selectable,
}"
>
<button
type="button"
class="generate-task-drawer__site-main"
:aria-disabled="!site.selectable"
@click="toggleScheduleEnterpriseSite(site)"
>
<span class="generate-task-drawer__account-check">
<span v-if="isScheduleEnterpriseSiteSelected(site.id)"></span>
</span>
<span class="generate-task-drawer__site-avatar">
{{ site.name.slice(0, 1).toUpperCase() }}
</span>
<span class="generate-task-drawer__account-copy">
<strong>{{ site.name }}</strong>
<small>{{ site.cmsType }} · {{ site.siteUrl }}</small>
</span>
<a-tooltip :title="site.disabledReason || site.statusText" placement="top">
<span
class="generate-task-drawer__account-status"
:class="
site.status === 'connected'
? 'generate-task-drawer__account-status--immediate'
: 'generate-task-drawer__account-status--unavailable'
"
>
{{ site.statusText }}
</span>
</a-tooltip>
</button>
<div
v-if="isScheduleEnterpriseSiteSelected(site.id)"
class="generate-task-drawer__site-config"
@click.stop
>
<a-select
v-model:value="form.enterpriseSiteCategoryIds[site.id]"
:options="enterpriseCategoryOptions(site.id)"
:loading="syncEnterpriseCategoriesMutation.isPending.value"
:placeholder="t('custom.task.enterpriseCategoryPlaceholder')"
:get-popup-container="enterpriseSelectPopupContainer"
:dropdown-match-select-width="true"
popup-class-name="generate-task-drawer__enterprise-select-dropdown"
class="generate-task-drawer__site-category-select"
@focus="loadEnterpriseCategories(site.id)"
@click.stop
/>
<a-button
class="generate-task-drawer__site-sync"
:loading="syncEnterpriseCategoriesMutation.isPending.value"
@click="syncEnterpriseCategories(site.id)"
>
<template #icon><SyncOutlined /></template>
{{ t('custom.task.enterpriseSyncCategories') }}
</a-button>
</div>
<p v-if="site.disabledReason" class="generate-task-drawer__site-warning">
{{ site.disabledReason }}
</p>
</article>
</div>
<div v-else class="generate-task-drawer__empty">
<strong>{{ t('custom.task.enterpriseSiteEmptyTitle') }}</strong>
<p>{{ t('custom.task.enterpriseSiteEmptyHint') }}</p>
</div>
</template>
</div>
<div class="generate-task-drawer__field">
@@ -1419,10 +1787,14 @@ function normalizeJsonRecord(value: Record<string, unknown>): Record<string, Jso
.generate-task-drawer__account-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
.generate-task-drawer__target-tabs {
margin-bottom: 16px;
}
.generate-task-drawer__account {
display: grid;
grid-template-columns: 20px 36px minmax(0, 1fr);
@@ -1473,6 +1845,11 @@ function normalizeJsonRecord(value: Record<string, unknown>): Record<string, Jso
background: #355dff;
}
.generate-task-drawer__site-card--active .generate-task-drawer__account-check {
border-color: #355dff;
background: #355dff;
}
.generate-task-drawer__account-check span {
width: 6px;
height: 6px;
@@ -1553,6 +1930,117 @@ function normalizeJsonRecord(value: Record<string, unknown>): Record<string, Jso
color: #d92d20;
}
.generate-task-drawer__enterprise-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f9fafb;
}
.generate-task-drawer__enterprise-toolbar span {
color: #374151;
font-size: 13px;
font-weight: 700;
}
.generate-task-drawer__site-card {
border: 1px solid #e1e8f2;
border-radius: 8px;
background: #fff;
transition:
border-color 0.2s ease,
background 0.2s ease,
box-shadow 0.2s ease;
}
.generate-task-drawer__site-card:hover:not(.generate-task-drawer__site-card--disabled) {
border-color: #b7c7ec;
background: #fbfdff;
}
.generate-task-drawer__site-card--active {
border-color: #355dff;
background: #f4f7ff;
box-shadow: 0 4px 12px rgba(53, 93, 255, 0.08);
}
.generate-task-drawer__site-card--disabled {
opacity: 0.58;
}
.generate-task-drawer__site-main {
display: grid;
grid-template-columns: 20px 36px minmax(0, 1fr);
gap: 12px;
align-items: center;
width: 100%;
min-height: 78px;
padding: 12px 14px;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
}
.generate-task-drawer__site-card--disabled .generate-task-drawer__site-main {
cursor: not-allowed;
}
.generate-task-drawer__site-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid #e4e9f2;
border-radius: 8px;
background: #f2f4f7;
color: #344054;
font-size: 13px;
font-weight: 800;
}
.generate-task-drawer__site-config {
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
margin: 0 14px 14px;
padding: 12px 14px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #e2e8f0;
z-index: 2;
transition: all 0.2s ease;
}
.generate-task-drawer__site-card--active .generate-task-drawer__site-config {
background: #ffffff;
border-color: #bfdbfe;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.02);
}
.generate-task-drawer__site-category-select {
width: 100%;
}
.generate-task-drawer__site-sync {
width: 100%;
white-space: nowrap;
}
.generate-task-drawer__site-warning {
margin: -4px 14px 14px;
color: #b42318;
font-size: 12px;
line-height: 1.6;
}
.generate-task-drawer__cover-head {
display: flex;
align-items: center;
@@ -54,6 +54,7 @@ interface EnterpriseSiteCard {
id: number
name: string
siteUrl: string
cmsTypeId: string
cmsType: string
status: string
statusText: string
@@ -265,6 +266,9 @@ const accountCards = computed<PublishAccountCard[]>(() =>
const enterpriseSites = computed(() => enterpriseSitesQuery.data.value ?? [])
const enterpriseSiteCards = computed<EnterpriseSiteCard[]>(() =>
enterpriseSites.value.map((site) => {
const siteName =
site.name?.trim() || site.site_name?.trim() || site.site_url?.trim() || '企业站点'
const cmsType = String(site.cms_type ?? '').trim()
const alreadyPublished = successfulEnterpriseSiteIds.value.has(site.id)
const activeStatus = activeEnterpriseRecordStatusBySiteId.value.get(site.id)
const connectionStatus = enterpriseSiteConnectionStatus(site)
@@ -277,9 +281,10 @@ const enterpriseSiteCards = computed<EnterpriseSiteCard[]>(() =>
)
return {
id: site.id,
name: site.name,
name: siteName,
siteUrl: site.site_url,
cmsType: cmsTypeLabel(site.cms_type),
cmsTypeId: cmsType,
cmsType: cmsTypeLabel(cmsType),
status: connectionStatus,
statusText: alreadyPublished
? '已发布'
@@ -364,14 +369,18 @@ const selectedEnterpriseSiteCards = computed(() => {
})
const modalTitle = computed(() => detailQuery.data.value?.title || t('article.untitled'))
const enterpriseTargetPlatformId = computed(() =>
selectedEnterpriseSiteCards.value.length > 0 ? 'pbootcms' : '',
const enterpriseTargetPlatformIds = computed(() =>
Array.from(
new Set(
selectedEnterpriseSiteCards.value
.map((site) => normalizeEnterprisePlatformId(site.cmsTypeId))
.filter((platformId): platformId is string => Boolean(platformId)),
),
),
)
const selectedPlatformIds = computed(() =>
activeTargetTab.value === 'enterprise_sites'
? enterpriseTargetPlatformId.value
? [enterpriseTargetPlatformId.value]
: []
? enterpriseTargetPlatformIds.value
: Array.from(new Set(selectedCards.value.map((card) => card.platformId))),
)
watch([selectedPlatformIds, activeTargetTab], () => {
@@ -441,8 +450,8 @@ const publishAdmissionPassed = computed(
() => complianceDisplayDecision(publishAdmissionResult.value) === 'pass',
)
function enterpriseSelectPopupContainer(triggerNode: HTMLElement): HTMLElement {
return (triggerNode.closest('.ant-modal-content') as HTMLElement | null) ?? document.body
function enterpriseSelectPopupContainer(triggerNode?: HTMLElement | null): HTMLElement {
return (triggerNode?.closest('.ant-modal-content') as HTMLElement | null) ?? document.body
}
const mandatoryAdmissionBlocking = computed(
() => publishComplianceMandatory.value && !publishAdmissionPassed.value,
@@ -878,7 +887,7 @@ async function handleEnterprisePublishSuccess(
message: '企业站发布失败',
description:
formatStoredErrorMessage(failed[0]?.error_message) ||
'请检查插件、栏目和 PBootCMS API 配置。',
'请检查站点凭证、栏目和 CMS 接口配置。',
placement: 'topRight',
duration: 6,
})
@@ -887,7 +896,7 @@ async function handleEnterprisePublishSuccess(
message: enterprisePublishType.value === 'draft' ? '企业站草稿已保存' : '企业站发布成功',
description:
succeeded.length === 1
? succeeded[0]?.external_article_url || 'PBootCMS 已返回成功。'
? succeeded[0]?.external_article_url || 'CMS 已返回成功。'
: `${succeeded.length} 个企业站点已完成。`,
placement: 'topRight',
duration: 5,
@@ -1129,15 +1138,23 @@ function cmsTypeLabel(cmsType: string): string {
return 'PBootCMS'
case 'wordpress':
return 'WordPress'
case 'dedecms':
return 'DedeCMS'
case 'phpcms':
return 'PHPCMS'
default:
return cmsType || 'CMS'
}
}
function normalizeEnterprisePlatformId(cmsType: string): string {
const normalized = String(cmsType ?? '').trim().toLowerCase()
switch (normalized) {
case 'pbootcms':
return 'pbootcms'
case 'wordpress':
return 'wordpress'
default:
return normalized
}
}
function enterpriseSiteStatusLabel(status: string): string {
switch (String(status ?? '').trim()) {
case 'connected':
@@ -1167,6 +1184,8 @@ function enterpriseSiteStatusColor(status: string): string {
function enterpriseCategoryOptions(siteId: number) {
return (enterpriseCategoriesBySiteId.value[siteId] ?? []).map((category) => ({
label: enterpriseCategoryLabel(category),
name: category.name,
slug: category.slug?.trim() ?? '',
value: category.remote_id,
}))
}
@@ -1182,7 +1201,8 @@ function setEnterpriseCategories(siteId: number, categories: EnterpriseSiteCateg
[siteId]: categories,
}
const current = enterpriseSiteCategoryIds.value[siteId]?.trim()
if (!current && categories.length > 0) {
const currentExists = categories.some((category) => category.remote_id === current)
if ((!current || !currentExists) && categories.length > 0) {
enterpriseSiteCategoryIds.value = {
...enterpriseSiteCategoryIds.value,
[siteId]: categories[0].remote_id,
@@ -1618,7 +1638,10 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
/>
</div>
<div v-if="enterpriseSiteCards.length" class="publish-modal__list">
<div
v-if="enterpriseSiteCards.length"
class="publish-modal__list publish-modal__list--enterprise"
>
<article
v-for="site in enterpriseSiteCards"
:key="site.id"
@@ -1758,12 +1781,22 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
:options="enterpriseCategoryOptions(site.id)"
:loading="syncEnterpriseCategoriesMutation.isPending.value"
:get-popup-container="enterpriseSelectPopupContainer"
:dropdown-match-select-width="true"
:dropdown-style="{ maxWidth: 'calc(100vw - 96px)' }"
popup-class-name="publish-modal__enterprise-select-dropdown"
class="publish-modal__enterprise-select"
placeholder="选择栏目"
@focus="loadEnterpriseCategories(site.id)"
@click.stop
/>
>
<template #option="item">
<a-tooltip :title="item.label" placement="left">
<div class="publish-modal__select-option-text">
{{ item.label }}
</div>
</a-tooltip>
</template>
</a-select>
<a-button
class="publish-modal__sync-btn"
:loading="syncEnterpriseCategoriesMutation.isPending.value"
@@ -1781,10 +1814,7 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
</article>
</div>
<a-empty
v-else
description="当前还没有企业自建站点,请先到媒体管理添加 PBootCMS 站点。"
/>
<a-empty v-else description="当前还没有企业自建站点,请先到媒体管理添加站点连接。" />
</template>
</a-tab-pane>
</a-tabs>
@@ -2157,6 +2187,12 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
padding-bottom: 4px;
}
.publish-modal__list--enterprise {
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
align-items: start;
max-height: 520px;
}
/* Custom scrollbar for list */
.publish-modal__list::-webkit-scrollbar {
width: 6px;
@@ -2213,6 +2249,7 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
padding: 0;
gap: 0;
overflow: visible;
min-width: 0;
}
.publish-modal__card--enterprise:hover:not(.publish-modal__card--disabled):not(
@@ -2283,17 +2320,19 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
.publish-modal__config-body {
display: flex;
align-items: center;
flex-direction: column;
align-items: stretch;
gap: 8px;
width: 100%;
}
.publish-modal__enterprise-select {
flex: 1;
min-width: 0;
}
.publish-modal__enterprise-select {
width: 100%;
}
.publish-modal__sync-btn {
width: 100%;
flex-shrink: 0;
display: inline-flex;
align-items: center;
@@ -2314,6 +2353,24 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
z-index: 3200;
}
:global(.publish-modal__enterprise-select-dropdown .ant-select-item-option) {
padding: 6px 12px;
}
:global(.publish-modal__enterprise-select-dropdown .ant-select-item-option-content) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.publish-modal__select-option-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
.publish-modal__site-warning {
margin: -4px 16px 14px;
color: #b45309;
@@ -2454,6 +2511,37 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
border-top-color: #bfdbfe;
}
.publish-modal__card--enterprise .publish-modal__card-identity {
align-items: flex-start;
flex: 1;
}
.publish-modal__card--enterprise .publish-modal__identity-copy {
gap: 4px;
}
.publish-modal__card--enterprise .publish-modal__identity-copy strong {
white-space: normal;
overflow-wrap: anywhere;
}
.publish-modal__card--enterprise .publish-modal__identity-copy span {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.publish-modal__card--enterprise .publish-modal__platform-line {
align-items: flex-start;
width: 100%;
}
.publish-modal__card--enterprise .publish-modal__platform-text {
overflow: visible;
text-overflow: clip;
overflow-wrap: anywhere;
}
.publish-modal__check {
width: 18px;
height: 18px;
@@ -353,6 +353,19 @@ function dispatchStatusMeta(status?: string | null): { label: string; color: str
}
return { label: t('custom.schedule.notDispatched'), color: 'default' }
}
function schedulePublishTargetsText(record: ScheduleTask): string {
const parts: string[] = []
const platformText = formatPublishPlatformList(record.auto_publish_platforms)
if (platformText !== '--') {
parts.push(platformText)
}
const enterpriseSiteCount = record.publish_enterprise_site_targets?.length ?? 0
if (enterpriseSiteCount > 0) {
parts.push(t('custom.schedule.enterpriseSiteTargetCount', { count: enterpriseSiteCount }))
}
return parts.length ? parts.join('、') : '--'
}
</script>
<template>
@@ -490,7 +503,7 @@ function dispatchStatusMeta(status?: string | null): { label: string; color: str
<a-tag :color="record.auto_publish ? 'blue' : 'default'">
{{
record.auto_publish
? formatPublishPlatformList(record.auto_publish_platforms)
? schedulePublishTargetsText(record)
: t('custom.schedule.manualPublish')
}}
</a-tag>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { DeleteOutlined, PictureOutlined } from '@ant-design/icons-vue'
import { CloseOutlined, DeleteOutlined, PictureOutlined } from '@ant-design/icons-vue'
import type { CreateKolPackageRequest } from '@geo/shared-types'
import { message } from 'ant-design-vue'
import { computed, reactive, ref, watch } from 'vue'
@@ -52,6 +52,13 @@ watch(
const coverPreviewUrl = computed(() => resolveApiURL(formState.cover_url))
const coverFileName = computed(() => deriveCoverFileName(formState.cover_url))
const tagOptions = computed(() =>
Array.from(new Set(formState.tags.map((tag) => tag.trim()).filter(Boolean))).map((tag) => ({
label: tag,
value: tag,
title: null,
})),
)
function handleOk() {
if (!formState.name.trim()) {
@@ -70,6 +77,21 @@ function handleRemoveCover() {
formState.cover_url = ''
}
function getModalPopupContainer(triggerNode: HTMLElement): HTMLElement {
return (triggerNode.closest('.ant-modal-content') as HTMLElement | null) ?? document.body
}
function preventTagMouseDown(event: MouseEvent): void {
event.preventDefault()
event.stopPropagation()
}
function handleTagClose(onClose: (event?: MouseEvent) => void, event: MouseEvent): void {
event.preventDefault()
event.stopPropagation()
onClose(event)
}
function handleCoverConfirmed(payload: { url: string }) {
formState.cover_url = resolveApiURL(payload.url)
}
@@ -111,8 +133,29 @@ async function uploadCover(file: File) {
v-model:value="formState.tags"
mode="tags"
style="width: 100%"
:options="tagOptions"
:placeholder="t('kol.manage.packageForm.tagsPlaceholder')"
/>
:get-popup-container="getModalPopupContainer"
popup-class-name="kol-package-tags-dropdown"
>
<template #tagRender="{ label, closable, onClose }">
<span class="package-tag" @mousedown="preventTagMouseDown">
<span class="package-tag__label">{{ label }}</span>
<button
v-if="closable"
type="button"
class="package-tag__remove"
:aria-label="t('common.delete')"
@click="handleTagClose(onClose, $event)"
>
<CloseOutlined />
</button>
</span>
</template>
<template #option="{ label }">
<span class="package-tag-option">{{ label }}</span>
</template>
</a-select>
</a-form-item>
<a-form-item :label="t('kol.manage.packageForm.priceNote')">
@@ -240,4 +283,53 @@ async function uploadCover(file: File) {
display: flex;
gap: 8px;
}
.package-tag {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 4px;
margin-inline-end: 4px;
padding: 1px 4px 1px 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: #f5f5f5;
color: #1f2937;
line-height: 20px;
}
.package-tag__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.package-tag__remove {
display: inline-flex;
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
border: 0;
padding: 0;
background: transparent;
color: #667085;
cursor: pointer;
}
.package-tag__remove:hover {
color: #1f2937;
}
:global(.kol-package-tags-dropdown .ant-select-item-option) {
max-width: 100%;
}
:global(.kol-package-tags-dropdown .ant-select-item-option-content),
.package-tag-option {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+26 -2
View File
@@ -1624,13 +1624,33 @@ const enUS = {
'No platform data is available yet. Please verify the Media Management setup first.',
publishSection: 'Publish Settings',
autoPublish: 'Auto publish',
autoPublishHint: 'When enabled, scheduled articles enter the publish queue after generation.',
autoPublishHint:
'When enabled, scheduled articles publish to selected media accounts or enterprise sites.',
publishTargets: 'Publish targets',
publishTargetsHint:
'Choose desktop media accounts or enterprise sites. At least one target is required.',
publishTargetMediaAccounts: 'Media accounts',
publishTargetEnterpriseSites: 'Enterprise sites',
publishAccounts: 'Media accounts',
publishAccountsHint:
'Choose specific accounts. Offline clients queue first and continue after reconnecting.',
publishAccountEmptyTitle: 'No media accounts',
publishAccountEmptyHint:
'Bind media accounts in the desktop client before configuring auto publish.',
enterprisePublishType: 'Publish type',
enterprisePublishNow: 'Publish now',
enterprisePublishDraft: 'Save draft',
enterpriseCategoryPlaceholder: 'Choose category',
enterpriseSyncCategories: 'Sync categories',
enterpriseSiteEmptyTitle: 'No enterprise sites',
enterpriseSiteEmptyHint:
'Add a site connection in Media Management and sync categories first.',
enterpriseSiteConnected: 'Connected',
enterpriseSiteError: 'Connection error',
enterpriseSiteDisabled: 'Disabled',
enterpriseSiteDraft: 'Pending check',
enterpriseSiteDisabledHint: 'This enterprise site is disabled.',
enterpriseSiteErrorHint: 'This enterprise site has a connection error. Test it first.',
cover: 'Cover image',
coverHint: 'Keep the cover clear, complete, and visually polished.',
scheduleCoverHint:
@@ -1683,7 +1703,8 @@ const enUS = {
'New tasks default to a random time between 01:00 and 05:00 to smooth overnight load. You can edit it before saving.',
executionTimeDefault: 'Default staggered time',
to: 'to',
autoPublishPlatforms: 'Auto publish platforms',
autoPublishPlatforms: 'Auto publish targets',
enterpriseSiteTargetCount: '{count} enterprise sites',
autoPublish: 'Auto publish',
manualPublish: 'Manual publish',
nextRun: 'Next run',
@@ -1775,6 +1796,9 @@ const enUS = {
invalidRandomWindow: 'The schedule time configuration is invalid.',
missingScheduleDays: 'Select at least one run day.',
missingPublishAccounts: 'Choose at least one media account when auto publish is enabled.',
missingPublishTargets: 'Choose at least one publish target when auto publish is enabled.',
missingEnterpriseSiteCategory: 'Choose an enterprise site category.',
enterpriseCategoriesSynced: 'Enterprise site categories synced.',
missingScheduleCover: 'A selected media account platform requires a cover image.',
missingPromptName: 'Enter a prompt name first.',
missingPromptContent: 'Enter prompt content first.',
+23 -2
View File
@@ -1550,11 +1550,28 @@ const zhCN = {
platformEmptyHint: '当前还没有可用的平台数据,请先检查媒体管理配置。',
publishSection: '发布设置',
autoPublish: '是否自动发布',
autoPublishHint: '开启后,定时生成完成的文章会使用指定媒体账号自动进入发布队列。',
autoPublishHint: '开启后,定时生成完成的文章会自动发布到选中的媒体账号或企业自建站点。',
publishTargets: '发布目标',
publishTargetsHint: '可选择桌面媒体账号,也可选择企业自建站点;至少选择一个目标。',
publishTargetMediaAccounts: '媒体账号',
publishTargetEnterpriseSites: '企业自建站点',
publishAccounts: '媒体账号',
publishAccountsHint: '请选择具体账号。客户端离线时会先排队,上线后继续发布。',
publishAccountEmptyTitle: '暂无可选媒体账号',
publishAccountEmptyHint: '请先在桌面端绑定媒体账号,再回到这里配置自动发布。',
enterprisePublishType: '发布方式',
enterprisePublishNow: '直接发布',
enterprisePublishDraft: '保存草稿',
enterpriseCategoryPlaceholder: '选择栏目',
enterpriseSyncCategories: '同步栏目',
enterpriseSiteEmptyTitle: '暂无企业自建站点',
enterpriseSiteEmptyHint: '请先到媒体管理添加站点连接,并同步栏目。',
enterpriseSiteConnected: '已连通',
enterpriseSiteError: '连接异常',
enterpriseSiteDisabled: '已禁用',
enterpriseSiteDraft: '待校验',
enterpriseSiteDisabledHint: '该企业站点已禁用。',
enterpriseSiteErrorHint: '该企业站点连接异常,请先测试连通性。',
cover: '封面图',
coverHint: '请保证封面清晰、美观和完整',
scheduleCoverHint: '默认不设置封面。需要时可单独指定,生成完成后会写入文章并用于发布。',
@@ -1604,7 +1621,8 @@ const zhCN = {
'新建任务会默认随机落在 01:00-05:00,避免凌晨任务集中;你也可以手动修改为固定执行时间。',
executionTimeDefault: '默认错峰生成',
to: '至',
autoPublishPlatforms: '自动发布平台',
autoPublishPlatforms: '自动发布目标',
enterpriseSiteTargetCount: '企业自建站点 {count} 个',
autoPublish: '自动发布',
manualPublish: '手动发布',
nextRun: '下次执行',
@@ -1695,6 +1713,9 @@ const zhCN = {
invalidRandomWindow: '执行时间配置不正确',
missingScheduleDays: '请至少选择一个执行日',
missingPublishAccounts: '开启自动发布后,请至少选择一个媒体账号',
missingPublishTargets: '开启自动发布后,请至少选择一个发布目标',
missingEnterpriseSiteCategory: '请选择企业自建站点栏目',
enterpriseCategoriesSynced: '企业站点栏目已同步',
missingScheduleCover: '已选媒体账号的平台要求封面图,请先设置封面',
missingPromptName: '请先填写 Prompt 名称',
missingPromptContent: '请先填写 Prompt 内容',
+28
View File
@@ -136,6 +136,8 @@ const errorMessageMap: Record<string, string> = {
brand_not_found: '品牌不存在或已删除',
keyword_not_found: '关键词不存在或已删除',
publish_cover_required: '当前选择的平台要求上传封面图,请先上传封面图',
publish_targets_required: '开启自动发布后,请至少选择一个发布目标',
invalid_publish_enterprise_site_targets: '企业站点发布目标格式不正确',
desktop_account_client_missing: '所选账号还没有绑定桌面客户端,暂时无法发布',
desktop_account_not_found: '所选桌面账号不存在,请刷新后重试',
desktop_client_offline: '当前登录账号的桌面客户端未在线,请先打开 desktop-client',
@@ -264,6 +266,7 @@ const LEGACY_ENTERPRISE_SITE_CATEGORY_SYNC_FAILED_PATTERN =
/^enterprise site category sync failed: /i
const LEGACY_PBOOTCMS_HTTP_PATTERN = /^pbootcms\s+\w+\s+returned http \d+$/i
const LEGACY_PBOOTCMS_CODE_PATTERN = /^pbootcms\s+\w+\s+failed with code \d+$/i
const LEGACY_WORDPRESS_HTTP_PATTERN = /^wordpress\s+.*\s+returned http \d+$/i
const LEGACY_ENTERPRISE_SITE_FAILED_DETAIL_PATTERN = /^failed to .*enterprise site/i
const LEGACY_ENTERPRISE_SITE_EXISTS_PATTERN =
/^(enterprise site connection already exists|another enterprise site already uses this domain)$/i
@@ -321,6 +324,31 @@ function translateRawErrorMessage(raw: string): string | null {
if (lower.startsWith('pbootcms ') && lower.includes(' endpoint is unavailable')) {
return 'CMS 接口不可用,请确认接口安装路径'
}
if (lower.startsWith('request wordpress ')) {
return 'WordPress 请求失败,请检查站点地址和网络连通性'
}
if (lower.startsWith('parse wordpress response')) {
return 'WordPress 响应解析失败,请确认 REST API 返回 JSON 格式'
}
if (
lower.includes('rest_not_logged_in') ||
lower.includes('incorrect_password') ||
lower.includes('invalid_username')
) {
return 'WordPress 认证失败,请使用 WordPress 用户名和 Application Password'
}
if (lower.startsWith('invalid wordpress site url')) {
return '站点域名格式不正确'
}
if (lower.includes('invalid wordpress category id')) {
return 'WordPress 栏目 ID 不正确,请先同步栏目后再发布'
}
if (LEGACY_WORDPRESS_HTTP_PATTERN.test(normalized)) {
return 'WordPress REST API 请求失败,请检查站点权限和接口状态'
}
if (lower.includes('unsupported cms type')) {
return '当前版本暂不支持该 CMS 类型'
}
if (LEGACY_ENTERPRISE_SITE_FAILED_DETAIL_PATTERN.test(normalized)) {
return '企业站点操作失败,请稍后重试'
}
File diff suppressed because it is too large Load Diff