style: format web apps with prettier and sort imports
Apply repo-wide Prettier/lint normalization across admin-web, desktop-client and ops-web: single quotes, no semicolons, trailing commas, consistent line wrapping, and import ordering. Also drop an unused brand-logo import in DesktopShell.vue. No behavior changes — formatting only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -178,9 +178,12 @@ watch(
|
||||
return
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
void refreshArticleData()
|
||||
}, streamFeatureEnabled ? 5000 : 2000)
|
||||
const timer = window.setInterval(
|
||||
() => {
|
||||
void refreshArticleData()
|
||||
},
|
||||
streamFeatureEnabled ? 5000 : 2000,
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
window.clearInterval(timer)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import type { ComplianceViolation } from '@geo/shared-types'
|
||||
import {
|
||||
AlignCenterOutlined,
|
||||
AlignLeftOutlined,
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
TableOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { ComplianceViolation } from '@geo/shared-types'
|
||||
import { Crepe, CrepeFeature } from '@milkdown/crepe'
|
||||
import { commandsCtx, editorViewCtx, parserCtx, prosePluginsCtx } from '@milkdown/kit/core'
|
||||
import type { Ctx } from '@milkdown/kit/ctx'
|
||||
@@ -108,19 +108,22 @@ import {
|
||||
type RichImageAlign,
|
||||
} from '@/lib/milkdown/richImageBlock'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
articleId?: number | null
|
||||
title: string
|
||||
modelValue: string
|
||||
disabled?: boolean
|
||||
toolbar?: boolean
|
||||
aiOptimizeEnabled?: boolean
|
||||
complianceViolations?: ComplianceViolation[]
|
||||
uploadImage?: (file: File) => Promise<string>
|
||||
}>(), {
|
||||
toolbar: true,
|
||||
aiOptimizeEnabled: true,
|
||||
})
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
articleId?: number | null
|
||||
title: string
|
||||
modelValue: string
|
||||
disabled?: boolean
|
||||
toolbar?: boolean
|
||||
aiOptimizeEnabled?: boolean
|
||||
complianceViolations?: ComplianceViolation[]
|
||||
uploadImage?: (file: File) => Promise<string>
|
||||
}>(),
|
||||
{
|
||||
toolbar: true,
|
||||
aiOptimizeEnabled: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:title': [value: string]
|
||||
@@ -432,9 +435,7 @@ function findComplianceHighlightRange(
|
||||
if (a.used !== b.used) {
|
||||
return a.used ? 1 : -1
|
||||
}
|
||||
return (
|
||||
Math.abs(a.textIndex - request.startOffset) - Math.abs(b.textIndex - request.startOffset)
|
||||
)
|
||||
return Math.abs(a.textIndex - request.startOffset) - Math.abs(b.textIndex - request.startOffset)
|
||||
})
|
||||
|
||||
const selected = candidates[0]
|
||||
|
||||
@@ -309,10 +309,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<GeneratedArticleLinksDrawer
|
||||
v-model:open="articleLinksOpen"
|
||||
:articles="selectedTaskArticles"
|
||||
/>
|
||||
<GeneratedArticleLinksDrawer v-model:open="articleLinksOpen" :articles="selectedTaskArticles" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -51,7 +51,12 @@ const ruleListParams = computed(() => {
|
||||
})
|
||||
|
||||
const rulesQuery = useQuery({
|
||||
queryKey: computed(() => ['promptRules', 'list', companyStore.currentBrandId, ruleListParams.value]),
|
||||
queryKey: computed(() => [
|
||||
'promptRules',
|
||||
'list',
|
||||
companyStore.currentBrandId,
|
||||
ruleListParams.value,
|
||||
]),
|
||||
enabled: computed(() => Boolean(companyStore.currentBrandId)),
|
||||
queryFn: () => promptRulesApi.list(ruleListParams.value),
|
||||
})
|
||||
|
||||
@@ -877,7 +877,8 @@ async function handleEnterprisePublishSuccess(
|
||||
notification.error({
|
||||
message: '企业站发布失败',
|
||||
description:
|
||||
formatStoredErrorMessage(failed[0]?.error_message) || '请检查插件、栏目和 PBootCMS API 配置。',
|
||||
formatStoredErrorMessage(failed[0]?.error_message) ||
|
||||
'请检查插件、栏目和 PBootCMS API 配置。',
|
||||
placement: 'topRight',
|
||||
duration: 6,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import type { CreateKolPromptRequest } from '@geo/shared-types'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
|
||||
@@ -293,8 +293,7 @@ const enUS = {
|
||||
subtitle: 'You have access to multiple brands or companies. Choose one to start working.',
|
||||
createBrand: 'Create new brand or company',
|
||||
createHint: 'After creation, keyword initialization starts automatically.',
|
||||
emptyDescription:
|
||||
'New users need to create a brand or company before entering the workspace.',
|
||||
emptyDescription: 'New users need to create a brand or company before entering the workspace.',
|
||||
noWebsite: 'No website yet',
|
||||
form: {
|
||||
title: 'Create brand or company',
|
||||
@@ -303,7 +302,8 @@ const enUS = {
|
||||
website: 'Website (optional)',
|
||||
websitePlaceholder: 'https://example.com',
|
||||
description: 'Description',
|
||||
descriptionPlaceholder: 'Briefly describe the business, products, service area, or core value.',
|
||||
descriptionPlaceholder:
|
||||
'Briefly describe the business, products, service area, or core value.',
|
||||
},
|
||||
ai: {
|
||||
title: 'Generating brand or company questions',
|
||||
@@ -319,7 +319,8 @@ const enUS = {
|
||||
'Choose up to {limit} questions to save to the keyword library. You can add or edit more later.',
|
||||
confirm: 'Save and enter workspace',
|
||||
skip: 'Skip and enter',
|
||||
empty: 'No usable questions were generated. You can enter the workspace and add them manually.',
|
||||
empty:
|
||||
'No usable questions were generated. You can enter the workspace and add them manually.',
|
||||
selectionLimit: 'Choose up to {limit} questions.',
|
||||
},
|
||||
messages: {
|
||||
@@ -1054,8 +1055,7 @@ const enUS = {
|
||||
messages: {
|
||||
requiredField: 'Please fill in {field} first.',
|
||||
missingBrand: 'Select the current company in the top bar first.',
|
||||
selectBrandBeforeQuestion:
|
||||
'Select the current company in the top bar before continuing.',
|
||||
selectBrandBeforeQuestion: 'Select the current company in the top bar before continuing.',
|
||||
missingPrimaryQuestion: 'Choose one primary brand question.',
|
||||
missingTitle: 'Please choose or enter an article title.',
|
||||
missingOutline: 'Please select at least one outline section.',
|
||||
@@ -1376,7 +1376,8 @@ const enUS = {
|
||||
brands: {
|
||||
eyebrow: 'Company & Lexicon',
|
||||
title: 'Company & Lexicon',
|
||||
description: 'Manage the keyword library (search terms) and competitor assets around each brand.',
|
||||
description:
|
||||
'Manage the keyword library (search terms) and competitor assets around each brand.',
|
||||
railTitle: 'Brands',
|
||||
selectBrand: 'Pick a brand to continue managing the keyword library (search terms).',
|
||||
newBrand: 'New brand',
|
||||
@@ -1384,8 +1385,7 @@ const enUS = {
|
||||
deleteBrand: 'Delete brand',
|
||||
guide: {
|
||||
title: 'Brand management guide',
|
||||
body:
|
||||
'Manage brands, the keyword library (search terms), and competitor libraries for AI platform monitoring and GEO analysis.',
|
||||
body: 'Manage brands, the keyword library (search terms), and competitor libraries for AI platform monitoring and GEO analysis.',
|
||||
},
|
||||
tabs: {
|
||||
questions: 'Keyword library (search terms)',
|
||||
@@ -1517,7 +1517,8 @@ const enUS = {
|
||||
waitingTitle: 'AI is generating candidate search terms. Please wait...',
|
||||
waitingStages: {
|
||||
context: 'Organizing user search terms and the target term...',
|
||||
generate: 'Generating commercial candidate search terms suited for recommending companies...',
|
||||
generate:
|
||||
'Generating commercial candidate search terms suited for recommending companies...',
|
||||
refine: 'Deduplicating and filtering savable search terms...',
|
||||
},
|
||||
},
|
||||
|
||||
+1360
-1341
File diff suppressed because it is too large
Load Diff
@@ -29,8 +29,8 @@ import {
|
||||
import { ApiClientError } from '@geo/http-client'
|
||||
import type { BrandLibrarySummary, ChangePasswordRequest } from '@geo/shared-types'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, type Component, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -389,9 +389,17 @@ const navSections = computed<NavSection[]>(() => {
|
||||
key: 'authorityMedia',
|
||||
title: t('nav.authorityMedia'),
|
||||
items: [
|
||||
{ key: '/media-supply/resources', label: t('nav.mediaSupplyResources'), icon: BankOutlined },
|
||||
{
|
||||
key: '/media-supply/resources',
|
||||
label: t('nav.mediaSupplyResources'),
|
||||
icon: BankOutlined,
|
||||
},
|
||||
{ key: '/media-supply/orders', label: t('nav.mediaSupplyOrders'), icon: SendOutlined },
|
||||
{ key: '/media-supply/favorites', label: t('nav.mediaSupplyFavorites'), icon: TagsOutlined },
|
||||
{
|
||||
key: '/media-supply/favorites',
|
||||
label: t('nav.mediaSupplyFavorites'),
|
||||
icon: TagsOutlined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -623,7 +631,10 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div class="current-brand-trigger">
|
||||
<span class="current-brand-trigger__name">{{ currentBrandName }}</span>
|
||||
<DownOutlined class="current-brand-trigger__arrow" :class="{ 'current-brand-trigger__arrow--open': popoverVisible }" />
|
||||
<DownOutlined
|
||||
class="current-brand-trigger__arrow"
|
||||
:class="{ 'current-brand-trigger__arrow--open': popoverVisible }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #content>
|
||||
@@ -641,7 +652,10 @@ onBeforeUnmount(() => {
|
||||
<TagsOutlined />
|
||||
</span>
|
||||
<span class="brand-popover__menu-text">我创建的</span>
|
||||
<span class="brand-popover__badge" :class="{ 'brand-popover__badge--active': currentFilter === 'created' }">
|
||||
<span
|
||||
class="brand-popover__badge"
|
||||
:class="{ 'brand-popover__badge--active': currentFilter === 'created' }"
|
||||
>
|
||||
{{ companyStore.brands.length }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -690,7 +704,9 @@ onBeforeUnmount(() => {
|
||||
v-for="brand in filteredBrands"
|
||||
:key="brand.id"
|
||||
class="brand-popover__item"
|
||||
:class="{ 'brand-popover__item--active': companyStore.currentBrandId === brand.id }"
|
||||
:class="{
|
||||
'brand-popover__item--active': companyStore.currentBrandId === brand.id,
|
||||
}"
|
||||
@pointerdown.prevent.stop="selectBrand(brand.id)"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -24,11 +24,11 @@ import type {
|
||||
ComplianceRuntimeStatus,
|
||||
CreateAckRequest,
|
||||
CreateArticleRequest,
|
||||
CreateEnterpriseSiteConnectionRequest,
|
||||
CreateKolPackageRequest,
|
||||
CreateKolPromptRequest,
|
||||
CreateMediaSupplyOrderRequest,
|
||||
CreateMediaSupplyOrderResponse,
|
||||
CreateEnterpriseSiteConnectionRequest,
|
||||
CreatePluginSessionRequest,
|
||||
CreatePluginSessionResponse,
|
||||
CreatePublishBatchRequest,
|
||||
@@ -85,11 +85,11 @@ import type {
|
||||
KolWorkspaceCard,
|
||||
ListCustomerSupplierMediaResourcesResponse,
|
||||
ListDesktopPublishTasksParams,
|
||||
ListPublishRecordsParams,
|
||||
ListMediaSupplyOrdersParams,
|
||||
ListMediaSupplyOrdersResponse,
|
||||
ListMediaSupplyWalletLedgersParams,
|
||||
ListMediaSupplyWalletLedgersResponse,
|
||||
ListPublishRecordsParams,
|
||||
ListSupplierMediaResourcesParams,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
@@ -114,8 +114,8 @@ import type {
|
||||
PromptRuleRequest,
|
||||
PromptRuleSimple,
|
||||
PromptRuleStatusRequest,
|
||||
PublishKolPromptRequest,
|
||||
PublishEnterpriseSiteArticleRequest,
|
||||
PublishKolPromptRequest,
|
||||
PublishRecord,
|
||||
PublishRecordListResponse,
|
||||
Question,
|
||||
@@ -1417,14 +1417,10 @@ export const enterpriseSitesApi = {
|
||||
return apiClient.remove<null>(`/api/tenant/enterprise-sites/${id}`)
|
||||
},
|
||||
ping(id: number) {
|
||||
return apiClient.post<EnterpriseSiteCapability>(
|
||||
`/api/tenant/enterprise-sites/${id}/ping`,
|
||||
)
|
||||
return apiClient.post<EnterpriseSiteCapability>(`/api/tenant/enterprise-sites/${id}/ping`)
|
||||
},
|
||||
categories(id: number) {
|
||||
return apiClient.get<EnterpriseSiteCategory[]>(
|
||||
`/api/tenant/enterprise-sites/${id}/categories`,
|
||||
)
|
||||
return apiClient.get<EnterpriseSiteCategory[]>(`/api/tenant/enterprise-sites/${id}/categories`)
|
||||
},
|
||||
syncCategories(id: number) {
|
||||
return apiClient.post<EnterpriseSiteCategory[]>(
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { readStoredCurrentBrandId } from './current-brand'
|
||||
import { getStoredAccessToken } from './session'
|
||||
import type {
|
||||
FileChild as WordFileChild,
|
||||
ParagraphChild as WordParagraphChild,
|
||||
TableCell as WordTableCell,
|
||||
} from 'docx'
|
||||
import DOMPurify from 'dompurify'
|
||||
import type { jsPDF } from 'jspdf'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { readStoredCurrentBrandId } from './current-brand'
|
||||
import { getStoredAccessToken } from './session'
|
||||
|
||||
export type ArticleExportFormat = 'markdown' | 'word' | 'pdf'
|
||||
|
||||
@@ -137,7 +137,10 @@ export async function downloadArticleDocument(
|
||||
return
|
||||
}
|
||||
|
||||
downloadBlob(await buildWordDocumentBlob(title, options.markdown, options.untitledLabel), `${filenameBase}.docx`)
|
||||
downloadBlob(
|
||||
await buildWordDocumentBlob(title, options.markdown, options.untitledLabel),
|
||||
`${filenameBase}.docx`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function downloadArticlePdf(options: ArticleExportOptions): Promise<void> {
|
||||
@@ -163,7 +166,9 @@ export async function downloadArticlePdf(options: ArticleExportOptions): Promise
|
||||
}
|
||||
|
||||
function buildMarkdownDocument(title: string, markdown: string): string {
|
||||
const body = stripMarkdownImages(markdown).replace(/^\uFEFF/, '').trim()
|
||||
const body = stripMarkdownImages(markdown)
|
||||
.replace(/^\uFEFF/, '')
|
||||
.trim()
|
||||
|
||||
if (!title) {
|
||||
return ensureTrailingNewline(body)
|
||||
@@ -221,7 +226,8 @@ function collectMarkdownImageReferenceLabels(markdown: string): Set<string> {
|
||||
|
||||
function collectImageReferenceLabelsFromToken(token: MarkdownTokenLike, labels: Set<string>): void {
|
||||
if (token.type === 'image') {
|
||||
const label = getMarkdownTokenAttribute(token, 'label') || getMarkdownTokenAttribute(token, 'alt')
|
||||
const label =
|
||||
getMarkdownTokenAttribute(token, 'label') || getMarkdownTokenAttribute(token, 'alt')
|
||||
if (label) {
|
||||
labels.add(normalizeMarkdownReferenceLabel(label))
|
||||
}
|
||||
@@ -539,7 +545,9 @@ async function buildWordBlock(
|
||||
}
|
||||
|
||||
if (tagName === 'blockquote') {
|
||||
const quoteChildren = await buildWordInlineChildren(docx, element, { color: DOCX_MUTED_TEXT_COLOR })
|
||||
const quoteChildren = await buildWordInlineChildren(docx, element, {
|
||||
color: DOCX_MUTED_TEXT_COLOR,
|
||||
})
|
||||
return [
|
||||
new docx.Paragraph({
|
||||
alignment: docx.AlignmentType.LEFT,
|
||||
@@ -611,7 +619,10 @@ async function buildWordBlock(
|
||||
alignment: docx.AlignmentType.LEFT,
|
||||
children: inlineChildren.length ? inlineChildren : [new docx.TextRun('')],
|
||||
numbering: listState
|
||||
? { reference: listState.ordered ? 'article-number' : 'article-bullet', level: listState.level }
|
||||
? {
|
||||
reference: listState.ordered ? 'article-number' : 'article-bullet',
|
||||
level: listState.level,
|
||||
}
|
||||
: undefined,
|
||||
spacing: { after: listState ? 120 : 160, line: 360 },
|
||||
}),
|
||||
@@ -638,7 +649,9 @@ async function buildWordList(
|
||||
): Promise<WordFileChild[]> {
|
||||
const children: WordFileChild[] = []
|
||||
|
||||
for (const item of Array.from(element.children).filter((child) => child.tagName.toLowerCase() === 'li')) {
|
||||
for (const item of Array.from(element.children).filter(
|
||||
(child) => child.tagName.toLowerCase() === 'li',
|
||||
)) {
|
||||
const itemInlineChildren: Node[] = []
|
||||
const nestedLists: Element[] = []
|
||||
|
||||
@@ -672,7 +685,12 @@ async function buildWordList(
|
||||
|
||||
for (const nestedList of nestedLists) {
|
||||
children.push(
|
||||
...(await buildWordList(docx, nestedList, nestedList.tagName.toLowerCase() === 'ol', level + 1)),
|
||||
...(await buildWordList(
|
||||
docx,
|
||||
nestedList,
|
||||
nestedList.tagName.toLowerCase() === 'ol',
|
||||
level + 1,
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -735,7 +753,9 @@ async function buildWordTableCell(docx: DocxModule, cell: Element): Promise<Word
|
||||
}
|
||||
|
||||
function emptyWordTableCell(docx: DocxModule): WordTableCell {
|
||||
return new docx.TableCell({ children: [new docx.Paragraph({ alignment: docx.AlignmentType.LEFT })] })
|
||||
return new docx.TableCell({
|
||||
children: [new docx.Paragraph({ alignment: docx.AlignmentType.LEFT })],
|
||||
})
|
||||
}
|
||||
|
||||
function wordTableBorder(docx: DocxModule) {
|
||||
@@ -817,7 +837,11 @@ async function buildWordInlineChildren(
|
||||
return children
|
||||
}
|
||||
|
||||
function buildWordTextRun(docx: DocxModule, text: string, style: WordTextStyle): WordParagraphChild {
|
||||
function buildWordTextRun(
|
||||
docx: DocxModule,
|
||||
text: string,
|
||||
style: WordTextStyle,
|
||||
): WordParagraphChild {
|
||||
return new docx.TextRun({
|
||||
text: normalizeWordText(text),
|
||||
bold: style.bold,
|
||||
@@ -857,7 +881,12 @@ async function buildWordImageRun(
|
||||
return null
|
||||
}
|
||||
|
||||
const size = fitImageSize(imageData.width, imageData.height, DOCX_IMAGE_MAX_WIDTH_PX, DOCX_IMAGE_MAX_HEIGHT_PX)
|
||||
const size = fitImageSize(
|
||||
imageData.width,
|
||||
imageData.height,
|
||||
DOCX_IMAGE_MAX_WIDTH_PX,
|
||||
DOCX_IMAGE_MAX_HEIGHT_PX,
|
||||
)
|
||||
return new docx.ImageRun({
|
||||
type: imageData.type,
|
||||
data: imageData.data,
|
||||
@@ -881,7 +910,8 @@ async function loadWordImageData(image: HTMLImageElement | null): Promise<WordIm
|
||||
const resolvedSrc = resolveBrowserURL(src)
|
||||
const fetchSrc = buildWordImageFetchURL(resolvedSrc)
|
||||
const alt = image.getAttribute('alt') ?? image.getAttribute('title') ?? ''
|
||||
const fallbackWidth = Number.parseInt(image.getAttribute('width') ?? '', 10) || DOCX_IMAGE_MAX_WIDTH_PX
|
||||
const fallbackWidth =
|
||||
Number.parseInt(image.getAttribute('width') ?? '', 10) || DOCX_IMAGE_MAX_WIDTH_PX
|
||||
const fallbackHeight = Number.parseInt(image.getAttribute('height') ?? '', 10) || 360
|
||||
|
||||
try {
|
||||
@@ -966,11 +996,19 @@ function headersHasEntries(headers: Headers): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
function inferWordImageType(src: string, contentType?: string | null): WordImageData['type'] | null {
|
||||
function inferWordImageType(
|
||||
src: string,
|
||||
contentType?: string | null,
|
||||
): WordImageData['type'] | null {
|
||||
const mediaTypeMatch = src.match(/^data:image\/(png|jpe?g|gif|bmp);/i)
|
||||
const extensionMatch = src.split(/[?#]/, 1)[0]?.match(/\.(png|jpe?g|gif|bmp)$/i)
|
||||
const contentTypeMatch = contentType?.match(/^image\/(png|jpe?g|gif|bmp)/i)
|
||||
const rawType = (mediaTypeMatch?.[1] ?? extensionMatch?.[1] ?? contentTypeMatch?.[1] ?? '').toLowerCase()
|
||||
const rawType = (
|
||||
mediaTypeMatch?.[1] ??
|
||||
extensionMatch?.[1] ??
|
||||
contentTypeMatch?.[1] ??
|
||||
''
|
||||
).toLowerCase()
|
||||
|
||||
if (rawType === 'jpg' || rawType === 'jpeg') {
|
||||
return 'jpg'
|
||||
@@ -1013,7 +1051,9 @@ function convertImageSourceToPng(src: string): Promise<Omit<WordImageData, 'alt'
|
||||
return new Promise((resolve) => {
|
||||
const image = new Image()
|
||||
image.onload = () => {
|
||||
drawImageToPng(image).then(resolve).catch(() => resolve(null))
|
||||
drawImageToPng(image)
|
||||
.then(resolve)
|
||||
.catch(() => resolve(null))
|
||||
}
|
||||
image.onerror = () => resolve(null)
|
||||
image.src = src
|
||||
@@ -1117,7 +1157,11 @@ async function registerPdfFont(pdf: PdfJsPDF): Promise<void> {
|
||||
pdf.setFont(PDF_FONT_NAME, 'normal')
|
||||
}
|
||||
|
||||
async function appendArticlePdfContent(pdf: PdfJsPDF, title: string, markdown: string): Promise<void> {
|
||||
async function appendArticlePdfContent(
|
||||
pdf: PdfJsPDF,
|
||||
title: string,
|
||||
markdown: string,
|
||||
): Promise<void> {
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = buildArticleHtml(title, markdown)
|
||||
const context: PdfContext = {
|
||||
@@ -1137,7 +1181,14 @@ async function appendPdfElement(
|
||||
): Promise<void> {
|
||||
const tagName = element.tagName.toLowerCase()
|
||||
|
||||
if (tagName === 'h1' || tagName === 'h2' || tagName === 'h3' || tagName === 'h4' || tagName === 'h5' || tagName === 'h6') {
|
||||
if (
|
||||
tagName === 'h1' ||
|
||||
tagName === 'h2' ||
|
||||
tagName === 'h3' ||
|
||||
tagName === 'h4' ||
|
||||
tagName === 'h5' ||
|
||||
tagName === 'h6'
|
||||
) {
|
||||
const level = Number.parseInt(tagName.slice(1), 10)
|
||||
appendPdfTextBlock(context, getElementPdfText(element), getPdfHeadingStyle(level), {
|
||||
before: level === 1 ? 0 : 6,
|
||||
@@ -1157,20 +1208,30 @@ async function appendPdfElement(
|
||||
}
|
||||
|
||||
if (tagName === 'blockquote') {
|
||||
appendPdfTextBlock(context, getElementPdfText(element), getPdfBodyStyle({ color: PDF_MUTED_TEXT_COLOR }), {
|
||||
after: 5,
|
||||
borderLeft: true,
|
||||
indent: 5,
|
||||
})
|
||||
appendPdfTextBlock(
|
||||
context,
|
||||
getElementPdfText(element),
|
||||
getPdfBodyStyle({ color: PDF_MUTED_TEXT_COLOR }),
|
||||
{
|
||||
after: 5,
|
||||
borderLeft: true,
|
||||
indent: 5,
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (tagName === 'pre') {
|
||||
appendPdfTextBlock(context, element.textContent ?? '', getPdfBodyStyle({ code: true, color: PDF_CODE_BLOCK_TEXT }), {
|
||||
after: 5,
|
||||
fillColor: PDF_CODE_BLOCK_FILL,
|
||||
padding: 4,
|
||||
})
|
||||
appendPdfTextBlock(
|
||||
context,
|
||||
element.textContent ?? '',
|
||||
getPdfBodyStyle({ code: true, color: PDF_CODE_BLOCK_TEXT }),
|
||||
{
|
||||
after: 5,
|
||||
fillColor: PDF_CODE_BLOCK_FILL,
|
||||
padding: 4,
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1186,7 +1247,13 @@ async function appendPdfElement(
|
||||
return
|
||||
}
|
||||
|
||||
if (tagName === 'p' || tagName === 'div' || tagName === 'section' || tagName === 'article' || tagName === 'li') {
|
||||
if (
|
||||
tagName === 'p' ||
|
||||
tagName === 'div' ||
|
||||
tagName === 'section' ||
|
||||
tagName === 'article' ||
|
||||
tagName === 'li'
|
||||
) {
|
||||
const text = getElementPdfText(element)
|
||||
if (!text && tagName !== 'li') {
|
||||
return
|
||||
@@ -1214,7 +1281,9 @@ async function appendPdfList(
|
||||
): Promise<void> {
|
||||
let index = 1
|
||||
|
||||
for (const item of Array.from(element.children).filter((child) => child.tagName.toLowerCase() === 'li')) {
|
||||
for (const item of Array.from(element.children).filter(
|
||||
(child) => child.tagName.toLowerCase() === 'li',
|
||||
)) {
|
||||
const itemInlineChildren: Node[] = []
|
||||
const nestedLists: Element[] = []
|
||||
|
||||
@@ -1267,7 +1336,15 @@ function appendPdfTextBlock(
|
||||
const blockTop = context.y
|
||||
if (options.fillColor) {
|
||||
context.pdf.setFillColor(options.fillColor)
|
||||
context.pdf.roundedRect(PDF_MARGIN_X_MM + indent, blockTop, PDF_CONTENT_WIDTH_MM - indent, blockHeight, 2, 2, 'F')
|
||||
context.pdf.roundedRect(
|
||||
PDF_MARGIN_X_MM + indent,
|
||||
blockTop,
|
||||
PDF_CONTENT_WIDTH_MM - indent,
|
||||
blockHeight,
|
||||
2,
|
||||
2,
|
||||
'F',
|
||||
)
|
||||
}
|
||||
|
||||
if (options.borderLeft) {
|
||||
@@ -1282,7 +1359,11 @@ function appendPdfTextBlock(
|
||||
})
|
||||
|
||||
if (options.bullet) {
|
||||
context.pdf.text(options.bullet, PDF_MARGIN_X_MM + indent, context.y + padding + style.fontSize * 0.36)
|
||||
context.pdf.text(
|
||||
options.bullet,
|
||||
PDF_MARGIN_X_MM + indent,
|
||||
context.y + padding + style.fontSize * 0.36,
|
||||
)
|
||||
}
|
||||
|
||||
let lineY = context.y + padding + style.fontSize * 0.36
|
||||
@@ -1321,10 +1402,18 @@ function appendPdfTable(context: PdfContext, table: Element): void {
|
||||
const cell = row[index]
|
||||
return {
|
||||
header: Boolean(cell?.header),
|
||||
lines: wrapPdfText(context.pdf, normalizePdfText(cell?.text ?? ' '), columnWidth - cellPadding * 2, style),
|
||||
lines: wrapPdfText(
|
||||
context.pdf,
|
||||
normalizePdfText(cell?.text ?? ' '),
|
||||
columnWidth - cellPadding * 2,
|
||||
style,
|
||||
),
|
||||
}
|
||||
})
|
||||
const rowHeight = Math.max(...cellLines.map((cell) => cell.lines.length * style.lineHeight + cellPadding * 2), 9)
|
||||
const rowHeight = Math.max(
|
||||
...cellLines.map((cell) => cell.lines.length * style.lineHeight + cellPadding * 2),
|
||||
9,
|
||||
)
|
||||
|
||||
ensurePdfSpace(context, rowHeight)
|
||||
|
||||
@@ -1371,15 +1460,31 @@ async function appendPdfImage(context: PdfContext, image: HTMLImageElement): Pro
|
||||
if (!imageData) {
|
||||
const alt = image.getAttribute('alt') ?? image.getAttribute('title') ?? ''
|
||||
if (alt.trim()) {
|
||||
appendPdfTextBlock(context, alt, getPdfBodyStyle({ color: PDF_MUTED_TEXT_COLOR }), { after: 4 })
|
||||
appendPdfTextBlock(context, alt, getPdfBodyStyle({ color: PDF_MUTED_TEXT_COLOR }), {
|
||||
after: 4,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const size = fitImageSize(imageData.width, imageData.height, PDF_CONTENT_WIDTH_MM, PDF_IMAGE_MAX_HEIGHT_MM)
|
||||
const size = fitImageSize(
|
||||
imageData.width,
|
||||
imageData.height,
|
||||
PDF_CONTENT_WIDTH_MM,
|
||||
PDF_IMAGE_MAX_HEIGHT_MM,
|
||||
)
|
||||
ensurePdfSpace(context, size.height + 6)
|
||||
const x = PDF_MARGIN_X_MM + (PDF_CONTENT_WIDTH_MM - size.width) / 2
|
||||
context.pdf.addImage(imageData.dataUrl, imageData.format, x, context.y, size.width, size.height, undefined, 'FAST')
|
||||
context.pdf.addImage(
|
||||
imageData.dataUrl,
|
||||
imageData.format,
|
||||
x,
|
||||
context.y,
|
||||
size.width,
|
||||
size.height,
|
||||
undefined,
|
||||
'FAST',
|
||||
)
|
||||
context.y += size.height + 6
|
||||
}
|
||||
|
||||
@@ -1420,7 +1525,10 @@ async function loadPdfImageData(image: HTMLImageElement): Promise<PdfImageData |
|
||||
}
|
||||
}
|
||||
|
||||
function convertPdfImageElementToData(image: HTMLImageElement, alt: string): Promise<PdfImageData | null> {
|
||||
function convertPdfImageElementToData(
|
||||
image: HTMLImageElement,
|
||||
alt: string,
|
||||
): Promise<PdfImageData | null> {
|
||||
if (!image.complete || !image.naturalWidth || !image.naturalHeight) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
@@ -1460,7 +1568,10 @@ function convertPdfImageSourceToPng(src: string, alt: string): Promise<PdfImageD
|
||||
})
|
||||
}
|
||||
|
||||
function inferPdfImageFormat(src: string, contentType?: string | null): PdfImageData['format'] | null {
|
||||
function inferPdfImageFormat(
|
||||
src: string,
|
||||
contentType?: string | null,
|
||||
): PdfImageData['format'] | null {
|
||||
const rawType = (
|
||||
src.match(/^data:image\/(png|jpe?g);/i)?.[1] ??
|
||||
src.split(/[?#]/, 1)[0]?.match(/\.(png|jpe?g)$/i)?.[1] ??
|
||||
@@ -1532,7 +1643,11 @@ function getPdfBodyStyle(overrides: Partial<PdfTextStyle> = {}): PdfTextStyle {
|
||||
function getElementPdfText(element: Node): string {
|
||||
const parts: string[] = []
|
||||
collectElementPdfText(element, parts)
|
||||
return parts.join('').replace(/[ \t]{2,}/g, ' ').replace(/[ \t]+\n/g, '\n').trim()
|
||||
return parts
|
||||
.join('')
|
||||
.replace(/[ \t]{2,}/g, ' ')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function collectElementPdfText(node: Node, parts: string[]): void {
|
||||
@@ -1575,7 +1690,10 @@ function collectElementPdfText(node: Node, parts: string[]): void {
|
||||
}
|
||||
|
||||
function normalizePdfText(text: string): string {
|
||||
return text.replace(/\u00a0/g, ' ').replace(/\r\n?/g, '\n').trim()
|
||||
return text
|
||||
.replace(/\u00a0/g, ' ')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ClipboardPayload } from '@/lib/clipboard'
|
||||
import type { ArticleDetail } from '@geo/shared-types'
|
||||
import DOMPurify from 'dompurify'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import type { ArticleDetail } from '@geo/shared-types'
|
||||
import type { ClipboardPayload } from '@/lib/clipboard'
|
||||
|
||||
export interface ArticleActionState {
|
||||
showPublish: boolean
|
||||
|
||||
@@ -36,12 +36,7 @@ const CHUNK_ERROR_PATTERNS = [
|
||||
]
|
||||
|
||||
export function isChunkLoadError(err: unknown): boolean {
|
||||
const msg =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: typeof err === 'string'
|
||||
? err
|
||||
: ''
|
||||
const msg = err instanceof Error ? err.message : typeof err === 'string' ? err : ''
|
||||
return CHUNK_ERROR_PATTERNS.some((re) => re.test(msg))
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,9 @@ export function getGenerateStatusMeta(status?: string | null): { label: string;
|
||||
}
|
||||
|
||||
export function hasActiveGenerationStatus(status?: string | null): boolean {
|
||||
return status === 'queued' || status === 'pending' || status === 'generating' || status === 'running'
|
||||
return (
|
||||
status === 'queued' || status === 'pending' || status === 'generating' || status === 'running'
|
||||
)
|
||||
}
|
||||
|
||||
export function getPublishStatusMeta(status?: string | null): { label: string; color: string } {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Modal } from 'ant-design-vue'
|
||||
import { ApiClientError } from '@geo/http-client'
|
||||
import { Modal } from 'ant-design-vue'
|
||||
|
||||
import { router } from '@/router'
|
||||
|
||||
@@ -247,8 +247,7 @@ const VALIDATION_MIN_SEED_TOPIC_PATTERN =
|
||||
|
||||
const LEGACY_MEDIA_SUPPLY_REFRESH_PATTERN =
|
||||
/^failed to refresh supplier price(?: for \d+ selected resources?)?$/i
|
||||
const LEGACY_MEDIA_SUPPLY_LOCKED_COST_PATTERN =
|
||||
/^supplier cost increased above locked sell price/i
|
||||
const LEGACY_MEDIA_SUPPLY_LOCKED_COST_PATTERN = /^supplier cost increased above locked sell price/i
|
||||
const LEGACY_CMS_CREDENTIAL_PATTERN =
|
||||
/^CMS request failed; please verify site credentials and plugin installation$/i
|
||||
const LEGACY_CMS_REQUEST_FAILED_PATTERN = /^CMS request failed$/i
|
||||
|
||||
@@ -31,8 +31,8 @@ import {
|
||||
Radio,
|
||||
Result,
|
||||
Row,
|
||||
Select,
|
||||
Segmented,
|
||||
Select,
|
||||
Skeleton,
|
||||
Slider,
|
||||
Space,
|
||||
@@ -108,7 +108,6 @@ const IconFont = createFromIconfontCN({
|
||||
scriptUrl: '//at.alicdn.com/t/c/font_5154382_922oh81rh1.js',
|
||||
})
|
||||
app.component('IconFont', IconFont)
|
||||
|
||||
;[
|
||||
Alert,
|
||||
AntApp,
|
||||
|
||||
@@ -40,9 +40,7 @@ export const useCompanyStore = defineStore('company', () => {
|
||||
const nextBrands = normalizeBrandList(value)
|
||||
const storedId = readStoredCurrentBrandId()
|
||||
const preferredId = currentBrandId.value ?? storedId
|
||||
const matched = preferredId
|
||||
? nextBrands.find((brand) => brand.id === preferredId)
|
||||
: undefined
|
||||
const matched = preferredId ? nextBrands.find((brand) => brand.id === preferredId) : undefined
|
||||
|
||||
if (matched) {
|
||||
setCurrentBrand(matched.id)
|
||||
|
||||
@@ -247,10 +247,7 @@ function getAIPointAmountMeta(item: AIPointUsageLog): {
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'points'">
|
||||
<div
|
||||
class="points-cell"
|
||||
:class="`points-cell--${getAIPointAmountMeta(record).tone}`"
|
||||
>
|
||||
<div class="points-cell" :class="`points-cell--${getAIPointAmountMeta(record).tone}`">
|
||||
<strong>{{ getAIPointAmountMeta(record).primary }}</strong>
|
||||
<span>{{ getAIPointAmountMeta(record).detail }}</span>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,10 @@ const exportLoading = ref<ArticleExportFormat | null>(null)
|
||||
const detailQuery = useQuery({
|
||||
queryKey: computed(() => ['articles', 'detail', companyStore.currentBrandId, articleId.value]),
|
||||
enabled: computed(
|
||||
() => Boolean(companyStore.currentBrandId) && Number.isInteger(articleId.value) && articleId.value > 0,
|
||||
() =>
|
||||
Boolean(companyStore.currentBrandId) &&
|
||||
Number.isInteger(articleId.value) &&
|
||||
articleId.value > 0,
|
||||
),
|
||||
queryFn: () => articlesApi.detail(articleId.value),
|
||||
})
|
||||
@@ -125,7 +128,9 @@ const complianceCheckLoading = ref(false)
|
||||
const editorComplianceViolations = computed(() =>
|
||||
compliancePanelOpen.value ? (editorComplianceResult.value?.violations ?? []) : [],
|
||||
)
|
||||
const complianceGroups = computed(() => groupViolations(editorComplianceResult.value?.violations ?? []))
|
||||
const complianceGroups = computed(() =>
|
||||
groupViolations(editorComplianceResult.value?.violations ?? []),
|
||||
)
|
||||
const complianceElapsedLabel = computed(() => {
|
||||
const duration = editorComplianceResult.value?.duration_ms ?? 0
|
||||
return duration > 0 ? `${duration} ms` : '< 1 ms'
|
||||
@@ -527,15 +532,21 @@ onBeforeUnmount(() => {
|
||||
<template #overlay>
|
||||
<a-menu class="article-editor-view__download-menu">
|
||||
<a-menu-item key="word" @click="handleDownload('word')">
|
||||
<FileWordOutlined class="article-editor-view__download-icon article-editor-view__download-icon--word" />
|
||||
<FileWordOutlined
|
||||
class="article-editor-view__download-icon article-editor-view__download-icon--word"
|
||||
/>
|
||||
<span>{{ t('article.editor.downloadFormats.word') }}</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="pdf" @click="handleDownload('pdf')">
|
||||
<FilePdfOutlined class="article-editor-view__download-icon article-editor-view__download-icon--pdf" />
|
||||
<FilePdfOutlined
|
||||
class="article-editor-view__download-icon article-editor-view__download-icon--pdf"
|
||||
/>
|
||||
<span>{{ t('article.editor.downloadFormats.pdf') }}</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="markdown" @click="handleDownload('markdown')">
|
||||
<FileMarkdownOutlined class="article-editor-view__download-icon article-editor-view__download-icon--markdown" />
|
||||
<FileMarkdownOutlined
|
||||
class="article-editor-view__download-icon article-editor-view__download-icon--markdown"
|
||||
/>
|
||||
<span>{{ t('article.editor.downloadFormats.markdown') }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
@@ -656,7 +667,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<p v-if="violation.dictionary_name || violation.term_pattern">
|
||||
{{ violation.dictionary_name || '词库规则' }}
|
||||
<span v-if="violation.term_pattern"> · {{ violation.term_pattern }}</span>
|
||||
<span v-if="violation.term_pattern">· {{ violation.term_pattern }}</span>
|
||||
</p>
|
||||
<p v-if="violation.hint">{{ violation.hint }}</p>
|
||||
<div class="article-editor-view__violation-actions">
|
||||
|
||||
@@ -8,12 +8,7 @@ import {
|
||||
TagsOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { ApiClientError } from '@geo/http-client'
|
||||
import type {
|
||||
Brand,
|
||||
BrandLibrarySummary,
|
||||
BrandRequest,
|
||||
QuestionCandidate,
|
||||
} from '@geo/shared-types'
|
||||
import type { Brand, BrandLibrarySummary, BrandRequest, QuestionCandidate } from '@geo/shared-types'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { computed, onBeforeUnmount, reactive, ref } from 'vue'
|
||||
@@ -226,9 +221,7 @@ function applyDefaultSelection(rows: CandidateRow[]): void {
|
||||
}
|
||||
|
||||
let selected = 0
|
||||
const preferredRows = rows.filter(
|
||||
(row) => !row.suggest_skip && !row.too_short && !row.duplicate,
|
||||
)
|
||||
const preferredRows = rows.filter((row) => !row.suggest_skip && !row.too_short && !row.duplicate)
|
||||
const orderedRows = [...preferredRows, ...rows.filter((row) => !preferredRows.includes(row))]
|
||||
|
||||
for (const row of orderedRows) {
|
||||
@@ -585,12 +578,16 @@ async function saveSelectedQuestions(): Promise<void> {
|
||||
padding: 40px 24px;
|
||||
color: #0f172a;
|
||||
background-color: #f8fafc;
|
||||
background-image:
|
||||
background-image:
|
||||
radial-gradient(circle at 10% 20%, rgba(22, 119, 255, 0.05) 0%, transparent 40%),
|
||||
radial-gradient(circle at 90% 80%, rgba(99, 102, 241, 0.06) 0%, transparent 50%),
|
||||
radial-gradient(circle at 50% 0%, rgba(6, 182, 212, 0.04) 0%, transparent 35%),
|
||||
radial-gradient(rgba(148, 163, 184, 0.1) 1px, transparent 1px);
|
||||
background-size: 100% 100%, 100% 100%, 100% 100%, 24px 24px;
|
||||
background-size:
|
||||
100% 100%,
|
||||
100% 100%,
|
||||
100% 100%,
|
||||
24px 24px;
|
||||
}
|
||||
|
||||
.glow-orb {
|
||||
@@ -641,7 +638,7 @@ async function saveSelectedQuestions(): Promise<void> {
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
border-radius: 24px;
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.02),
|
||||
0 4px 12px rgba(0, 0, 0, 0.02),
|
||||
0 16px 36px rgba(22, 119, 255, 0.04),
|
||||
@@ -738,7 +735,7 @@ async function saveSelectedQuestions(): Promise<void> {
|
||||
.brand-choice:hover {
|
||||
background: #ffffff;
|
||||
border-color: rgba(22, 119, 255, 0.4);
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 10px 25px -5px rgba(22, 119, 255, 0.08),
|
||||
0 4px 12px -5px rgba(22, 119, 255, 0.05);
|
||||
transform: translateY(-2px);
|
||||
@@ -862,7 +859,7 @@ async function saveSelectedQuestions(): Promise<void> {
|
||||
.create-brand-card:hover {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-color: #1677ff;
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 12px 30px -5px rgba(22, 119, 255, 0.08),
|
||||
0 4px 12px -5px rgba(22, 119, 255, 0.04);
|
||||
transform: translateY(-2px);
|
||||
|
||||
@@ -8,11 +8,7 @@ import {
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { ApiClientError } from '@geo/http-client'
|
||||
import type {
|
||||
BrandLibrarySummary,
|
||||
QuestionCandidate,
|
||||
QuestionSource,
|
||||
} from '@geo/shared-types'
|
||||
import type { BrandLibrarySummary, QuestionCandidate, QuestionSource } from '@geo/shared-types'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { computed, onBeforeUnmount, reactive, ref } from 'vue'
|
||||
@@ -896,7 +892,9 @@ function backToBrands(): void {
|
||||
{{ t('brands.questions.aiFill.button') }}
|
||||
</a-button>
|
||||
<div class="tool-estimate">
|
||||
{{ t('brands.questions.combination.estimate', { count: expansionToolPreviewCount }) }}
|
||||
{{
|
||||
t('brands.questions.combination.estimate', { count: expansionToolPreviewCount })
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -905,10 +903,19 @@ function backToBrands(): void {
|
||||
<div class="word-column" data-col="region">
|
||||
<div class="word-column__head">
|
||||
<strong>{{ t('brands.questions.combination.region') }}</strong>
|
||||
<span>{{ splitLines(expansionForm.region).length || t('brands.questions.combination.defaultWord') }}</span>
|
||||
<span>
|
||||
{{
|
||||
splitLines(expansionForm.region).length ||
|
||||
t('brands.questions.combination.defaultWord')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="word-column__list">
|
||||
<div v-for="(item, index) in expansionForm.region" :key="index" class="word-column__item">
|
||||
<div
|
||||
v-for="(item, index) in expansionForm.region"
|
||||
:key="index"
|
||||
class="word-column__item"
|
||||
>
|
||||
<span class="word-column__dot"></span>
|
||||
<input
|
||||
v-model="expansionForm.region[index]"
|
||||
@@ -919,17 +926,33 @@ function backToBrands(): void {
|
||||
@keydown.backspace="handleBackspace('region', index, $event)"
|
||||
@blur="handleBlur('region', index)"
|
||||
/>
|
||||
<button v-if="index < expansionForm.region.length - 1" class="word-column__remove" tabindex="-1" @click="removeItem('region', index)">×</button>
|
||||
<button
|
||||
v-if="index < expansionForm.region.length - 1"
|
||||
class="word-column__remove"
|
||||
tabindex="-1"
|
||||
@click="removeItem('region', index)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="word-column" data-col="prefix">
|
||||
<div class="word-column__head">
|
||||
<strong>{{ t('brands.questions.combination.prefix') }}</strong>
|
||||
<span>{{ splitLines(expansionForm.prefix).length || t('brands.questions.combination.defaultWord') }}</span>
|
||||
<span>
|
||||
{{
|
||||
splitLines(expansionForm.prefix).length ||
|
||||
t('brands.questions.combination.defaultWord')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="word-column__list">
|
||||
<div v-for="(item, index) in expansionForm.prefix" :key="index" class="word-column__item">
|
||||
<div
|
||||
v-for="(item, index) in expansionForm.prefix"
|
||||
:key="index"
|
||||
class="word-column__item"
|
||||
>
|
||||
<span class="word-column__dot"></span>
|
||||
<input
|
||||
v-model="expansionForm.prefix[index]"
|
||||
@@ -940,7 +963,14 @@ function backToBrands(): void {
|
||||
@keydown.backspace="handleBackspace('prefix', index, $event)"
|
||||
@blur="handleBlur('prefix', index)"
|
||||
/>
|
||||
<button v-if="index < expansionForm.prefix.length - 1" class="word-column__remove" tabindex="-1" @click="removeItem('prefix', index)">×</button>
|
||||
<button
|
||||
v-if="index < expansionForm.prefix.length - 1"
|
||||
class="word-column__remove"
|
||||
tabindex="-1"
|
||||
@click="removeItem('prefix', index)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -950,7 +980,11 @@ function backToBrands(): void {
|
||||
<span>{{ splitLines(expansionForm.core).length }}</span>
|
||||
</div>
|
||||
<div class="word-column__list">
|
||||
<div v-for="(item, index) in expansionForm.core" :key="index" class="word-column__item">
|
||||
<div
|
||||
v-for="(item, index) in expansionForm.core"
|
||||
:key="index"
|
||||
class="word-column__item"
|
||||
>
|
||||
<span class="word-column__dot"></span>
|
||||
<input
|
||||
v-model="expansionForm.core[index]"
|
||||
@@ -961,7 +995,14 @@ function backToBrands(): void {
|
||||
@keydown.backspace="handleBackspace('core', index, $event)"
|
||||
@blur="handleBlur('core', index)"
|
||||
/>
|
||||
<button v-if="index < expansionForm.core.length - 1" class="word-column__remove" tabindex="-1" @click="removeItem('core', index)">×</button>
|
||||
<button
|
||||
v-if="index < expansionForm.core.length - 1"
|
||||
class="word-column__remove"
|
||||
tabindex="-1"
|
||||
@click="removeItem('core', index)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -971,7 +1012,11 @@ function backToBrands(): void {
|
||||
<span>{{ splitLines(expansionForm.industry).length }}</span>
|
||||
</div>
|
||||
<div class="word-column__list">
|
||||
<div v-for="(item, index) in expansionForm.industry" :key="index" class="word-column__item">
|
||||
<div
|
||||
v-for="(item, index) in expansionForm.industry"
|
||||
:key="index"
|
||||
class="word-column__item"
|
||||
>
|
||||
<span class="word-column__dot"></span>
|
||||
<input
|
||||
v-model="expansionForm.industry[index]"
|
||||
@@ -982,17 +1027,33 @@ function backToBrands(): void {
|
||||
@keydown.backspace="handleBackspace('industry', index, $event)"
|
||||
@blur="handleBlur('industry', index)"
|
||||
/>
|
||||
<button v-if="index < expansionForm.industry.length - 1" class="word-column__remove" tabindex="-1" @click="removeItem('industry', index)">×</button>
|
||||
<button
|
||||
v-if="index < expansionForm.industry.length - 1"
|
||||
class="word-column__remove"
|
||||
tabindex="-1"
|
||||
@click="removeItem('industry', index)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="word-column" data-col="suffix">
|
||||
<div class="word-column__head">
|
||||
<strong>{{ t('brands.questions.combination.suffix') }}</strong>
|
||||
<span>{{ splitLines(expansionForm.suffix).length || t('brands.questions.combination.defaultWord') }}</span>
|
||||
<span>
|
||||
{{
|
||||
splitLines(expansionForm.suffix).length ||
|
||||
t('brands.questions.combination.defaultWord')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="word-column__list">
|
||||
<div v-for="(item, index) in expansionForm.suffix" :key="index" class="word-column__item">
|
||||
<div
|
||||
v-for="(item, index) in expansionForm.suffix"
|
||||
:key="index"
|
||||
class="word-column__item"
|
||||
>
|
||||
<span class="word-column__dot"></span>
|
||||
<input
|
||||
v-model="expansionForm.suffix[index]"
|
||||
@@ -1003,7 +1064,14 @@ function backToBrands(): void {
|
||||
@keydown.backspace="handleBackspace('suffix', index, $event)"
|
||||
@blur="handleBlur('suffix', index)"
|
||||
/>
|
||||
<button v-if="index < expansionForm.suffix.length - 1" class="word-column__remove" tabindex="-1" @click="removeItem('suffix', index)">×</button>
|
||||
<button
|
||||
v-if="index < expansionForm.suffix.length - 1"
|
||||
class="word-column__remove"
|
||||
tabindex="-1"
|
||||
@click="removeItem('suffix', index)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1068,15 +1136,14 @@ function backToBrands(): void {
|
||||
<div v-if="!candidateRows.length" class="candidate-empty">
|
||||
{{ t('brands.questions.emptyCandidates') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="row in candidateRows"
|
||||
:key="row.id"
|
||||
class="candidate-row"
|
||||
>
|
||||
<div v-for="row in candidateRows" :key="row.id" class="candidate-row">
|
||||
<a-checkbox
|
||||
:checked="row.selected"
|
||||
:disabled="isCandidateSelectionDisabled(row)"
|
||||
@change="(event: Event) => updateCandidateSelection(row, (event.target as HTMLInputElement).checked)"
|
||||
@change="
|
||||
(event: Event) =>
|
||||
updateCandidateSelection(row, (event.target as HTMLInputElement).checked)
|
||||
"
|
||||
/>
|
||||
<a-textarea
|
||||
v-if="editingCandidateId === row.id"
|
||||
@@ -1087,11 +1154,7 @@ function backToBrands(): void {
|
||||
@blur="stopEditingCandidate(row)"
|
||||
@keydown.enter="stopEditingCandidate(row)"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="candidate-row__text"
|
||||
@dblclick="startEditingCandidate(row.id)"
|
||||
>
|
||||
<span v-else class="candidate-row__text" @dblclick="startEditingCandidate(row.id)">
|
||||
{{ row.text }}
|
||||
</span>
|
||||
<a-tag v-if="editingCandidateId !== row.id && row.duplicate" color="orange">
|
||||
@@ -1101,7 +1164,12 @@ function backToBrands(): void {
|
||||
{{ t('brands.questions.badges.tooShort') }}
|
||||
</a-tag>
|
||||
<div v-if="editingCandidateId !== row.id" class="candidate-row__actions">
|
||||
<a-button type="text" shape="circle" class="action-btn-edit" @click="startEditingCandidate(row.id)">
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
class="action-btn-edit"
|
||||
@click="startEditingCandidate(row.id)"
|
||||
>
|
||||
<EditOutlined />
|
||||
</a-button>
|
||||
<a-button type="text" shape="circle" danger @click="removeCandidate(row.id)">
|
||||
@@ -1429,8 +1497,6 @@ function backToBrands(): void {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.tool-estimate {
|
||||
padding: 8px 12px;
|
||||
color: #475569;
|
||||
@@ -1460,7 +1526,9 @@ function backToBrands(): void {
|
||||
.word-column:focus-within {
|
||||
background: #ffffff;
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05), 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.05),
|
||||
0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
.word-column__head {
|
||||
@@ -1573,7 +1641,9 @@ function backToBrands(): void {
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
transition: opacity 0.2s, color 0.2s;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
color 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -6,12 +6,7 @@ import {
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { ApiClientError } from '@geo/http-client'
|
||||
import type {
|
||||
Brand,
|
||||
BrandLibrarySummary,
|
||||
Competitor,
|
||||
Question,
|
||||
} from '@geo/shared-types'
|
||||
import type { Brand, BrandLibrarySummary, Competitor, Question } from '@geo/shared-types'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import type { TableColumnsType } from 'ant-design-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
@@ -288,7 +283,11 @@ watch(
|
||||
selectedBrandId.value = brands[0].id
|
||||
return
|
||||
}
|
||||
if (selectedBrandId.value && brands && !brands.some((item) => item.id === selectedBrandId.value)) {
|
||||
if (
|
||||
selectedBrandId.value &&
|
||||
brands &&
|
||||
!brands.some((item) => item.id === selectedBrandId.value)
|
||||
) {
|
||||
selectedBrandId.value = brands[0]?.id ?? null
|
||||
}
|
||||
},
|
||||
@@ -464,7 +463,13 @@ async function invalidateBrandQueries(): Promise<void> {
|
||||
</div>
|
||||
<div class="brand-card__actions">
|
||||
<a-tooltip :title="t('common.edit')">
|
||||
<a-button type="text" shape="circle" size="small" class="action-btn-edit" @click.stop="openBrandModal(brand)">
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
size="small"
|
||||
class="action-btn-edit"
|
||||
@click.stop="openBrandModal(brand)"
|
||||
>
|
||||
<EditOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
@@ -478,7 +483,9 @@ async function invalidateBrandQueries(): Promise<void> {
|
||||
</div>
|
||||
<div class="brand-card__meta">
|
||||
<span>{{ t('brands.meta.questionCount', { count: brand.question_count }) }}</span>
|
||||
<span>{{ t('brands.meta.competitorCount', { count: brand.competitor_count ?? 0 }) }}</span>
|
||||
<span>
|
||||
{{ t('brands.meta.competitorCount', { count: brand.competitor_count ?? 0 }) }}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
<button type="button" class="brand-card brand-card--add" @click="openBrandModal()">
|
||||
@@ -498,7 +505,11 @@ async function invalidateBrandQueries(): Promise<void> {
|
||||
<div class="brand-detail__head">
|
||||
<div>
|
||||
<p class="eyebrow">{{ selectedBrand?.name }}</p>
|
||||
<h3>{{ activeTab === 'questions' ? t('brands.tabs.questions') : t('brands.tabs.competitors') }}</h3>
|
||||
<h3>
|
||||
{{
|
||||
activeTab === 'questions' ? t('brands.tabs.questions') : t('brands.tabs.competitors')
|
||||
}}
|
||||
</h3>
|
||||
</div>
|
||||
<div v-if="activeTab === 'questions'" class="brand-detail__actions">
|
||||
<a-button :disabled="!selectedBrand" @click="openQuestionExpansionPage">
|
||||
@@ -538,7 +549,13 @@ async function invalidateBrandQueries(): Promise<void> {
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<div class="table-actions-row">
|
||||
<a-tooltip :title="t('common.edit')">
|
||||
<a-button type="text" shape="circle" size="small" class="action-btn-edit" @click="openQuestionEditModal(record)">
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
size="small"
|
||||
class="action-btn-edit"
|
||||
@click="openQuestionEditModal(record)"
|
||||
>
|
||||
<EditOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
@@ -556,7 +573,11 @@ async function invalidateBrandQueries(): Promise<void> {
|
||||
|
||||
<a-tab-pane key="competitors" :tab="t('brands.tabs.competitors')">
|
||||
<div v-if="competitorsQuery.data.value?.length" class="competitor-grid">
|
||||
<article v-for="competitor in competitorsQuery.data.value" :key="competitor.id" class="competitor-card">
|
||||
<article
|
||||
v-for="competitor in competitorsQuery.data.value"
|
||||
:key="competitor.id"
|
||||
class="competitor-card"
|
||||
>
|
||||
<div class="competitor-card__head">
|
||||
<div>
|
||||
<h4>{{ competitor.name }}</h4>
|
||||
@@ -565,7 +586,13 @@ async function invalidateBrandQueries(): Promise<void> {
|
||||
</a>
|
||||
</div>
|
||||
<div class="inline-actions">
|
||||
<a-button type="text" shape="circle" size="small" class="action-btn-edit" @click="openCompetitorModal(competitor)">
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
size="small"
|
||||
class="action-btn-edit"
|
||||
@click="openCompetitorModal(competitor)"
|
||||
>
|
||||
<EditOutlined />
|
||||
</a-button>
|
||||
<a-popconfirm @confirm="competitorMutations.remove.mutate(competitor.id)">
|
||||
@@ -911,5 +938,8 @@ async function invalidateBrandQueries(): Promise<void> {
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
.action-btn-edit:hover { color: #52c41a !important; background: #f6ffed !important; }
|
||||
.action-btn-edit:hover {
|
||||
color: #52c41a !important;
|
||||
background: #f6ffed !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,7 +19,11 @@ import { articlesApi } from '@/lib/api'
|
||||
import { buildArticleClipboardContent, resolveArticleActionState } from '@/lib/article-list-actions'
|
||||
import { useArticleRegenerateAction } from '@/lib/article-regenerate-action'
|
||||
import { copyTextToClipboard } from '@/lib/clipboard'
|
||||
import { getGenerateStatusMeta, getPublishStatusMeta, hasActiveGenerationStatus } from '@/lib/display'
|
||||
import {
|
||||
getGenerateStatusMeta,
|
||||
getPublishStatusMeta,
|
||||
hasActiveGenerationStatus,
|
||||
} from '@/lib/display'
|
||||
import { formatError } from '@/lib/errors'
|
||||
import { useCompanyStore } from '@/stores/company'
|
||||
|
||||
@@ -432,9 +436,13 @@ async function copyArticle(articleId: number): Promise<void> {
|
||||
<template v-else-if="column.key === 'generate_status'">
|
||||
<div class="imitation-view__status-cell">
|
||||
<ArticleGenerateStatus :status="record.generate_status" />
|
||||
<a-tooltip v-if="record.generate_status === 'failed' && record.generation_error_message">
|
||||
<a-tooltip
|
||||
v-if="record.generate_status === 'failed' && record.generation_error_message"
|
||||
>
|
||||
<template #title>{{ record.generation_error_message }}</template>
|
||||
<span class="imitation-view__error-text">{{ record.generation_error_message }}</span>
|
||||
<span class="imitation-view__error-text">
|
||||
{{ record.generation_error_message }}
|
||||
</span>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
}"
|
||||
>
|
||||
<div class="brand">
|
||||
<img class="brand-logo brand-logo--white" src="/logos/icon-with-title.svg" :alt="t('app.name')" />
|
||||
<img
|
||||
class="brand-logo brand-logo--white"
|
||||
src="/logos/icon-with-title.svg"
|
||||
:alt="t('app.name')"
|
||||
/>
|
||||
</div>
|
||||
<div class="characters-area">
|
||||
<AnimatedCharacters
|
||||
@@ -28,7 +32,11 @@
|
||||
<div class="right">
|
||||
<div class="form-wrapper">
|
||||
<div class="mobile-brand">
|
||||
<img class="brand-logo brand-logo--mobile" src="/logos/icon-with-title.svg" :alt="t('app.name')" />
|
||||
<img
|
||||
class="brand-logo brand-logo--mobile"
|
||||
src="/logos/icon-with-title.svg"
|
||||
:alt="t('app.name')"
|
||||
/>
|
||||
</div>
|
||||
<div class="header">
|
||||
<h1>{{ t('auth.welcomeBack') }}</h1>
|
||||
|
||||
@@ -879,11 +879,7 @@ function toResourceRow(item: CustomerSupplierMediaResource): MediaSupplyResource
|
||||
</a-table>
|
||||
</a-drawer>
|
||||
|
||||
<a-modal
|
||||
v-model:open="favoriteModalOpen"
|
||||
title="加入常发分组"
|
||||
width="440px"
|
||||
>
|
||||
<a-modal v-model:open="favoriteModalOpen" title="加入常发分组" width="440px">
|
||||
<div class="favorite-modal">
|
||||
<div class="favorite-modal__resource">
|
||||
<div class="favorite-modal__resource-header">
|
||||
@@ -901,7 +897,13 @@ function toResourceRow(item: CustomerSupplierMediaResource): MediaSupplyResource
|
||||
<span class="resource-tag" v-if="pendingFavoriteResource.region">
|
||||
{{ pendingFavoriteResource.region }}
|
||||
</span>
|
||||
<span class="resource-tag resource-tag--weight" v-if="pendingFavoriteResource.baidu_weight !== null && pendingFavoriteResource.baidu_weight !== undefined">
|
||||
<span
|
||||
class="resource-tag resource-tag--weight"
|
||||
v-if="
|
||||
pendingFavoriteResource.baidu_weight !== null &&
|
||||
pendingFavoriteResource.baidu_weight !== undefined
|
||||
"
|
||||
>
|
||||
百度 {{ pendingFavoriteResource.baidu_weight }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -912,7 +914,11 @@ function toResourceRow(item: CustomerSupplierMediaResource): MediaSupplyResource
|
||||
<div class="favorite-modal__form-item">
|
||||
<span class="favorite-modal__form-label">选择已有分组</span>
|
||||
<a-radio-group v-model:value="selectedFavoriteGroupId" class="favorite-modal__groups">
|
||||
<a-radio-button v-for="group in favoriteGroupOptions" :key="group.id" :value="group.id">
|
||||
<a-radio-button
|
||||
v-for="group in favoriteGroupOptions"
|
||||
:key="group.id"
|
||||
:value="group.id"
|
||||
>
|
||||
{{ group.name }}
|
||||
</a-radio-button>
|
||||
</a-radio-group>
|
||||
@@ -929,14 +935,16 @@ function toResourceRow(item: CustomerSupplierMediaResource): MediaSupplyResource
|
||||
placeholder="输入新分组名称"
|
||||
@press-enter="confirmAddFavorite"
|
||||
>
|
||||
<template #prefix><PlusOutlined style="color: #98a2b3;" /></template>
|
||||
<template #prefix><PlusOutlined style="color: #98a2b3" /></template>
|
||||
</a-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="favorite-modal-footer">
|
||||
<button type="button" class="modal-btn-cancel" @click="favoriteModalOpen = false">取消</button>
|
||||
<button type="button" class="modal-btn-cancel" @click="favoriteModalOpen = false">
|
||||
取消
|
||||
</button>
|
||||
<button type="button" class="modal-btn-ok" @click="confirmAddFavorite">确认加入</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1271,7 +1279,6 @@ div.favorite-modal :deep(.ant-input::placeholder) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
|
||||
.confirm-btn,
|
||||
.danger-action {
|
||||
gap: 4px;
|
||||
|
||||
@@ -22,17 +22,13 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import ArticleEditorCanvas from '@/components/ArticleEditorCanvas.vue'
|
||||
import { articlesApi, imagesApi, mediaSupplyApi } from '@/lib/api'
|
||||
import { formatDateTime, getGenerateStatusMeta, getSourceTypeLabel } from '@/lib/display'
|
||||
import { formatError } from '@/lib/errors'
|
||||
import {
|
||||
clearMediaSupplySubmitSelection,
|
||||
loadMediaSupplySubmitSelection,
|
||||
type MediaSupplySubmitResourceSnapshot,
|
||||
} from '@/lib/media-supply-submit-selection'
|
||||
import {
|
||||
formatDateTime,
|
||||
getGenerateStatusMeta,
|
||||
getSourceTypeLabel,
|
||||
} from '@/lib/display'
|
||||
import { formatError } from '@/lib/errors'
|
||||
import { useCompanyStore } from '@/stores/company'
|
||||
|
||||
const MODEL_ID_AUTHORITY_NEWS = 1
|
||||
@@ -225,7 +221,9 @@ function displayValue(value?: string | null): string {
|
||||
return value?.trim() || '--'
|
||||
}
|
||||
|
||||
function sourceLabel(article: Pick<ArticleDetail | ArticleListItem, 'source_type' | 'generation_mode'>): string {
|
||||
function sourceLabel(
|
||||
article: Pick<ArticleDetail | ArticleListItem, 'source_type' | 'generation_mode'>,
|
||||
): string {
|
||||
return getSourceTypeLabel(article.source_type, article.generation_mode)
|
||||
}
|
||||
|
||||
@@ -292,7 +290,9 @@ function stripLeadingTitleHeading(titleValue: string, markdownValue: string): st
|
||||
<article v-for="resource in resources" :key="resource.id" class="media-row">
|
||||
<div>
|
||||
<strong>{{ resource.name }}</strong>
|
||||
<span>{{ displayValue(resource.channel_type) }} · {{ displayValue(resource.region) }}</span>
|
||||
<span>
|
||||
{{ displayValue(resource.channel_type) }} · {{ displayValue(resource.region) }}
|
||||
</span>
|
||||
</div>
|
||||
<em>{{ formatSellPrice(resource.sell_price_cents) }}</em>
|
||||
<p v-if="resource.resource_remark">{{ resource.resource_remark }}</p>
|
||||
@@ -335,7 +335,11 @@ function stripLeadingTitleHeading(titleValue: string, markdownValue: string): st
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
<a-select v-model:value="articleSourceType" :options="sourceOptions" @change="applyArticleSearch" />
|
||||
<a-select
|
||||
v-model:value="articleSourceType"
|
||||
:options="sourceOptions"
|
||||
@change="applyArticleSearch"
|
||||
/>
|
||||
</div>
|
||||
<a-spin :spinning="articlesQuery.isPending.value || selectedArticleLoading">
|
||||
<div v-if="articleRows.length" class="article-list">
|
||||
@@ -386,14 +390,15 @@ function stripLeadingTitleHeading(titleValue: string, markdownValue: string): st
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer class="submit-footer">
|
||||
<div class="footer-balance" :class="{ 'footer-balance--danger': !balanceEnough }">
|
||||
<WalletOutlined />
|
||||
<span>合计 {{ formatSellPrice(selectedTotal) }},余额 {{ formatMoney(walletBalance) }}</span>
|
||||
<span>
|
||||
合计 {{ formatSellPrice(selectedTotal) }},余额 {{ formatMoney(walletBalance) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="footer-actions">
|
||||
<a-button @click="cancelSubmit">取消</a-button>
|
||||
|
||||
@@ -24,7 +24,7 @@ function goHome(): void {
|
||||
<section class="not-found-page" role="alert" aria-labelledby="not-found-title">
|
||||
<div class="not-found-card">
|
||||
<div class="ambient-glow" aria-hidden="true"></div>
|
||||
|
||||
|
||||
<div class="error-code" aria-hidden="true">404</div>
|
||||
<h1 id="not-found-title" class="not-found-title">{{ t('notFound.title') }}</h1>
|
||||
<p class="not-found-description">{{ t('notFound.description') }}</p>
|
||||
@@ -67,7 +67,7 @@ function goHome(): void {
|
||||
background-size: 24px 24px;
|
||||
background-position: center top;
|
||||
border: 1px solid rgba(235, 240, 245, 0.8);
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 24px 48px -12px rgba(19, 94, 204, 0.05),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.8) inset;
|
||||
text-align: center;
|
||||
@@ -83,7 +83,7 @@ function goHome(): void {
|
||||
transform: translate(-50%, -50%);
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: radial-gradient(circle, rgba(47, 124, 246, 0.08) 0%, rgba(255,255,255,0) 70%);
|
||||
background: radial-gradient(circle, rgba(47, 124, 246, 0.08) 0%, rgba(255, 255, 255, 0) 70%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
border-radius: 50%;
|
||||
@@ -108,9 +108,15 @@ function goHome(): void {
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-10px); }
|
||||
100% { transform: translateY(0px); }
|
||||
0% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
|
||||
.not-found-title {
|
||||
@@ -153,8 +159,7 @@ function goHome(): void {
|
||||
}
|
||||
|
||||
.not-found-path code {
|
||||
font-family:
|
||||
'SFMono-Regular', Menlo, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
font-family: 'SFMono-Regular', Menlo, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
word-break: break-all;
|
||||
@@ -172,7 +177,8 @@ function goHome(): void {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary {
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -248,7 +254,8 @@ function goHome(): void {
|
||||
.not-found-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
.btn-primary, .btn-secondary {
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -80,10 +80,15 @@ function isPendingStatus(status: string): boolean {
|
||||
}
|
||||
|
||||
function hasActivePublishingRecord(page: PublishRecordListResponse | undefined): boolean {
|
||||
return Boolean(page?.items.some((record) => isPendingStatus(normalizeRecordStatus(record.status))))
|
||||
return Boolean(
|
||||
page?.items.some((record) => isPendingStatus(normalizeRecordStatus(record.status))),
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeRecordErrorMessage(messageText: string | null, platform?: string | null): string | null {
|
||||
function normalizeRecordErrorMessage(
|
||||
messageText: string | null,
|
||||
platform?: string | null,
|
||||
): string | null {
|
||||
const normalized = extractString(messageText)
|
||||
if (!normalized) {
|
||||
return null
|
||||
@@ -222,8 +227,7 @@ const publishRecords = computed<PublishRecordItem[]>(() =>
|
||||
const platformMeta = getPublishPlatformMeta(platformId)
|
||||
const isEnterpriseSite = record.target_type === 'enterprise_site'
|
||||
const accountName =
|
||||
extractString(record.platform_nickname) ??
|
||||
(isEnterpriseSite ? '企业自建站点' : '待同步账号')
|
||||
extractString(record.platform_nickname) ?? (isEnterpriseSite ? '企业自建站点' : '待同步账号')
|
||||
const statusMeta = statusMetaForRecord(status)
|
||||
const externalUrl =
|
||||
extractString(record.external_article_url) ?? extractString(record.external_manage_url)
|
||||
@@ -538,7 +542,11 @@ async function copyErrorMessage(record: PublishRecordItem): Promise<void> {
|
||||
</a-button>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="record.canOpenWorkbench" :title="`打开 ${record.accountName} 的${record.platformName}工作台`" placement="top">
|
||||
<a-tooltip
|
||||
v-if="record.canOpenWorkbench"
|
||||
:title="`打开 ${record.accountName} 的${record.platformName}工作台`"
|
||||
placement="top"
|
||||
>
|
||||
<a-button
|
||||
type="text"
|
||||
class="publish-action-btn"
|
||||
|
||||
@@ -409,17 +409,17 @@ const structureStepCopy = computed<TemplateStepConfig>(
|
||||
const generateStepCopy = computed<TemplateStepConfig>(
|
||||
() => wizardConfig.value?.steps?.generate ?? {},
|
||||
)
|
||||
const brandCardCopy = computed<TemplateCardCopy>(
|
||||
() => sanitizeLegacyKeywordCopy(wizardConfig.value?.basic?.cards?.brand ?? {}),
|
||||
const brandCardCopy = computed<TemplateCardCopy>(() =>
|
||||
sanitizeLegacyKeywordCopy(wizardConfig.value?.basic?.cards?.brand ?? {}),
|
||||
)
|
||||
const questionCardCopy = computed<TemplateCardCopy>(
|
||||
() => sanitizeQuestionCardCopy(wizardConfig.value?.basic?.cards?.keywords ?? {}),
|
||||
const questionCardCopy = computed<TemplateCardCopy>(() =>
|
||||
sanitizeQuestionCardCopy(wizardConfig.value?.basic?.cards?.keywords ?? {}),
|
||||
)
|
||||
const competitorsCardCopy = computed<TemplateCardCopy>(
|
||||
() => wizardConfig.value?.basic?.cards?.competitors ?? {},
|
||||
)
|
||||
const titleCardCopy = computed<TemplateCardCopy>(
|
||||
() => sanitizeLegacyKeywordCopy(wizardConfig.value?.structure?.cards?.choose_title ?? {}),
|
||||
const titleCardCopy = computed<TemplateCardCopy>(() =>
|
||||
sanitizeLegacyKeywordCopy(wizardConfig.value?.structure?.cards?.choose_title ?? {}),
|
||||
)
|
||||
const outlineCardCopy = computed<TemplateCardCopy>(
|
||||
() => wizardConfig.value?.structure?.cards?.outline ?? {},
|
||||
@@ -427,9 +427,7 @@ const outlineCardCopy = computed<TemplateCardCopy>(
|
||||
const previewCardCopy = computed<TemplateCardCopy>(
|
||||
() => wizardConfig.value?.structure?.cards?.preview ?? {},
|
||||
)
|
||||
const analyzeButtonLabel = computed(
|
||||
() => t('templates.wizard.actions.analyzeCompetitors'),
|
||||
)
|
||||
const analyzeButtonLabel = computed(() => t('templates.wizard.actions.analyzeCompetitors'))
|
||||
const showBrandSummaryBox = computed(() => Boolean(selectedBrandId.value))
|
||||
const reviewAlertMessage = computed(
|
||||
() => wizardConfig.value?.review?.alert_message || t('templates.wizard.hints.async'),
|
||||
@@ -509,9 +507,9 @@ function resetWizardState(detail: NonNullable<typeof templateDetail.value>): voi
|
||||
detail.template_key,
|
||||
buildTemplateRenderContext({
|
||||
locale: 'zh-CN',
|
||||
brandName: normalizedBrandName.value,
|
||||
officialWebsite: officialWebsite.value,
|
||||
brandSummary: brandSummary.value,
|
||||
brandName: normalizedBrandName.value,
|
||||
officialWebsite: officialWebsite.value,
|
||||
brandSummary: brandSummary.value,
|
||||
primaryQuestion: '',
|
||||
supplementalQuestions: [],
|
||||
inputParams: {},
|
||||
@@ -525,9 +523,9 @@ function resetWizardState(detail: NonNullable<typeof templateDetail.value>): voi
|
||||
detail.template_key,
|
||||
buildTemplateRenderContext({
|
||||
locale: 'zh-CN',
|
||||
brandName: normalizedBrandName.value,
|
||||
officialWebsite: officialWebsite.value,
|
||||
brandSummary: brandSummary.value,
|
||||
brandName: normalizedBrandName.value,
|
||||
officialWebsite: officialWebsite.value,
|
||||
brandSummary: brandSummary.value,
|
||||
primaryQuestion: '',
|
||||
supplementalQuestions: [],
|
||||
inputParams: {},
|
||||
@@ -565,7 +563,8 @@ function applyDraftArticleState(detail: ArticleDetail): void {
|
||||
selectedKnowledgeGroupIds.value = numberListFromUnknown(draftState.knowledge_group_ids) ?? []
|
||||
customOutlineSections.value = parseCustomOutlineSections(draftState.custom_outline_sections)
|
||||
outlineOptionOrder.value =
|
||||
sanitizeOutlineKeys(stringListFromUnknown(draftState.outline_option_order)) ?? outlineOptionOrder.value
|
||||
sanitizeOutlineKeys(stringListFromUnknown(draftState.outline_option_order)) ??
|
||||
outlineOptionOrder.value
|
||||
outlineSections.value =
|
||||
sanitizeOutlineKeys(stringListFromUnknown(draftState.outline_sections)) ?? outlineSections.value
|
||||
generatedOutline.value = decorateOutlineNodes(parseOutlineNodes(draftState.generated_outline))
|
||||
@@ -652,46 +651,50 @@ watch(supplementalQuestionIds, (ids) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedBrandId, async (brandId) => {
|
||||
if (restoringDraft.value) {
|
||||
return
|
||||
}
|
||||
if (!brandId) {
|
||||
competitorDrafts.value = competitorDrafts.value.map((item) => ({
|
||||
...item,
|
||||
libraryId: undefined,
|
||||
saved: false,
|
||||
watch(
|
||||
selectedBrandId,
|
||||
async (brandId) => {
|
||||
if (restoringDraft.value) {
|
||||
return
|
||||
}
|
||||
if (!brandId) {
|
||||
competitorDrafts.value = competitorDrafts.value.map((item) => ({
|
||||
...item,
|
||||
libraryId: undefined,
|
||||
saved: false,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
const brand = selectedBrand.value
|
||||
if (brand?.website && !officialWebsite.value.trim()) {
|
||||
officialWebsite.value = brand.website
|
||||
}
|
||||
if (brand?.description && !brandSummary.value.trim()) {
|
||||
brandSummary.value = brand.description
|
||||
}
|
||||
|
||||
primaryQuestionId.value = null
|
||||
supplementalQuestionIds.value = []
|
||||
await questionsQuery.refetch()
|
||||
|
||||
if (!showCompetitorsCard.value) {
|
||||
competitorDrafts.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const competitorsResult = await competitorsQuery.refetch()
|
||||
competitorDrafts.value = (competitorsResult.data ?? []).map((item) => ({
|
||||
key: nextCompetitorKey(),
|
||||
libraryId: item.id,
|
||||
name: item.name,
|
||||
website: item.website ?? '',
|
||||
description: item.description ?? '',
|
||||
saved: true,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
const brand = selectedBrand.value
|
||||
if (brand?.website && !officialWebsite.value.trim()) {
|
||||
officialWebsite.value = brand.website
|
||||
}
|
||||
if (brand?.description && !brandSummary.value.trim()) {
|
||||
brandSummary.value = brand.description
|
||||
}
|
||||
|
||||
primaryQuestionId.value = null
|
||||
supplementalQuestionIds.value = []
|
||||
await questionsQuery.refetch()
|
||||
|
||||
if (!showCompetitorsCard.value) {
|
||||
competitorDrafts.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const competitorsResult = await competitorsQuery.refetch()
|
||||
competitorDrafts.value = (competitorsResult.data ?? []).map((item) => ({
|
||||
key: nextCompetitorKey(),
|
||||
libraryId: item.id,
|
||||
name: item.name,
|
||||
website: item.website ?? '',
|
||||
description: item.description ?? '',
|
||||
saved: true,
|
||||
}))
|
||||
}, { immediate: true })
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function hydrateBrandContextFromCurrentBrand(): void {
|
||||
const brand = selectedBrand.value
|
||||
@@ -1778,9 +1781,13 @@ function sanitizeOutlineKeys(keys: string[] | undefined): string[] | undefined {
|
||||
return undefined
|
||||
}
|
||||
const blockedKeys = new Set(
|
||||
baseOutlineOptions.value.filter((section) => isBlockedOutlineOption(section)).map((section) => section.key),
|
||||
baseOutlineOptions.value
|
||||
.filter((section) => isBlockedOutlineOption(section))
|
||||
.map((section) => section.key),
|
||||
)
|
||||
const filtered = keys.filter(
|
||||
(key) => !blockedKeys.has(key) && key.trim().toLowerCase() !== 'site_list',
|
||||
)
|
||||
const filtered = keys.filter((key) => !blockedKeys.has(key) && key.trim().toLowerCase() !== 'site_list')
|
||||
return filtered.length > 0 ? filtered : []
|
||||
}
|
||||
|
||||
|
||||
@@ -40,8 +40,8 @@ import {
|
||||
formatDateTime,
|
||||
getGenerateStatusMeta,
|
||||
getPublishStatusMeta,
|
||||
hasActiveGenerationStatus,
|
||||
getTemplateMeta,
|
||||
hasActiveGenerationStatus,
|
||||
} from '@/lib/display'
|
||||
import { formatError } from '@/lib/errors'
|
||||
import { useCompanyStore } from '@/stores/company'
|
||||
@@ -94,7 +94,12 @@ const kolCardsQuery = useQuery({
|
||||
})
|
||||
|
||||
const recentArticlesQuery = useQuery({
|
||||
queryKey: computed(() => ['workspace', 'recent-articles', 'templates', companyStore.currentBrandId]),
|
||||
queryKey: computed(() => [
|
||||
'workspace',
|
||||
'recent-articles',
|
||||
'templates',
|
||||
companyStore.currentBrandId,
|
||||
]),
|
||||
enabled: computed(() => Boolean(companyStore.currentBrandId)),
|
||||
queryFn: () => workspaceApi.recentArticles(),
|
||||
})
|
||||
|
||||
@@ -810,7 +810,10 @@ function normalizeMarkdownTableLine(line: string, targetCellCount?: number): str
|
||||
normalized = `| ${cells.join(' | ')} |`
|
||||
}
|
||||
|
||||
return normalized.replace(/\s*\|\s*/g, ' | ').replace(/\s{2,}/g, ' ').trim()
|
||||
return normalized
|
||||
.replace(/\s*\|\s*/g, ' | ')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function normalizeLooseMarkdownTables(content: string): string {
|
||||
@@ -822,9 +825,7 @@ function normalizeLooseMarkdownTables(content: string): string {
|
||||
)
|
||||
|
||||
const lines = detachedTableBlocks.split('\n').flatMap((line: string) => {
|
||||
const separatorWithFirstRow = line
|
||||
.trim()
|
||||
.match(/^((?:\|\s*:?-{3,}:?\s*){2,}\|?)(\S.*)$/)
|
||||
const separatorWithFirstRow = line.trim().match(/^((?:\|\s*:?-{3,}:?\s*){2,}\|?)(\S.*)$/)
|
||||
if (
|
||||
!separatorWithFirstRow?.[1] ||
|
||||
!separatorWithFirstRow[2] ||
|
||||
@@ -878,7 +879,10 @@ function normalizeLooseMarkdownTables(content: string): string {
|
||||
function normalizeAnswerMarkdown(content: string): string {
|
||||
const headingMarkerNormalized = content
|
||||
.replace(/(^|\n)[\u200b-\u200f\ufeff]*([ \t]{0,12})(?:\\#|#|#|#)/gi, '$1$2#')
|
||||
.replace(/([^\n])(?=[\u200b-\u200f\ufeff]*[ \t]{0,12}(?:\\#|#|#|#|#){1,6}\s*(?:\d{1,3}[.)、]|[一二三四五六七八九十]+[、..]|第[一二三四五六七八九十0-9]+))/gi, '$1\n\n')
|
||||
.replace(
|
||||
/([^\n])(?=[\u200b-\u200f\ufeff]*[ \t]{0,12}(?:\\#|#|#|#|#){1,6}\s*(?:\d{1,3}[.)、]|[一二三四五六七八九十]+[、..]|第[一二三四五六七八九十0-9]+))/gi,
|
||||
'$1\n\n',
|
||||
)
|
||||
|
||||
const detachedHeadings = normalizeLooseMarkdownTables(headingMarkerNormalized)
|
||||
.replace(
|
||||
|
||||
@@ -150,7 +150,10 @@ watch(
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedQuestionId.value && questions.some((item) => item.id === selectedQuestionId.value)) {
|
||||
if (
|
||||
selectedQuestionId.value &&
|
||||
questions.some((item) => item.id === selectedQuestionId.value)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user