Files
geo/apps/admin-web/src/views/ArticleEditorView.vue
T
root 0d2cedb1e8
Frontend CI / Frontend (push) Successful in 3m2s
feat(article-export): render PDF with embedded CJK font instead of canvas
Embed SourceHanSansSC and use native jsPDF text rendering for crisp,
selectable PDF output, replacing the canvas rasterization approach.
Also fix download menu item alignment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:09:38 +08:00

984 lines
26 KiB
Vue

<script setup lang="ts">
import {
CloseOutlined,
DownOutlined,
DownloadOutlined,
FileMarkdownOutlined,
FilePdfOutlined,
FileWordOutlined,
LeftOutlined,
SafetyCertificateOutlined,
SearchOutlined,
} from '@ant-design/icons-vue'
import type { ComplianceCheckResult, ComplianceViolation } from '@geo/shared-types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { message } from 'ant-design-vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import ArticleEditorCanvas from '@/components/ArticleEditorCanvas.vue'
import PublishArticleModal from '@/components/PublishArticleModal.vue'
import { articlesApi, complianceApi } from '@/lib/api'
import {
downloadArticleDocument,
downloadArticlePdf,
hasArticleMarkdownImages,
type ArticleExportFormat,
} from '@/lib/article-export'
import { formatError } from '@/lib/errors'
import { useCompanyStore } from '@/stores/company'
type EditorCanvasPublic = InstanceType<typeof ArticleEditorCanvas> & {
scrollToText?: (text: string) => boolean
}
const route = useRoute()
const router = useRouter()
const queryClient = useQueryClient()
const { t } = useI18n()
const companyStore = useCompanyStore()
const articleId = computed(() => Number(route.params.id))
const title = ref('')
const markdown = ref('')
const initialTitle = ref('')
const initialMarkdown = ref('')
const leaveModalOpen = ref(false)
const markdownExportConfirmOpen = ref(false)
const publishModalOpen = ref(false)
const compliancePanelOpen = ref(false)
const editorComplianceResult = ref<ComplianceCheckResult | null>(null)
const editorCanvasRef = ref<EditorCanvasPublic | null>(null)
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,
),
queryFn: () => articlesApi.detail(articleId.value),
})
watch(
() => detailQuery.data.value,
(detail) => {
if (!detail) {
return
}
const nextTitle = detail.title ?? ''
const nextMarkdown = stripLeadingTitleHeading(nextTitle, detail.markdown_content ?? '')
title.value = nextTitle
markdown.value = nextMarkdown
initialTitle.value = nextTitle
initialMarkdown.value = nextMarkdown
},
{ immediate: true },
)
const detail = computed(() => detailQuery.data.value)
const editorLocked = computed(() => detail.value?.generate_status !== 'completed')
const referencedImageAssetIds = computed(() => extractReferencedImageAssetIds(markdown.value))
const hasChanges = computed(
() =>
title.value.trim() !== initialTitle.value.trim() || markdown.value !== initialMarkdown.value,
)
const saveMutation = useMutation({
mutationFn: () =>
articlesApi.update(articleId.value, {
title: title.value.trim(),
markdown_content: markdown.value,
referenced_image_asset_ids: referencedImageAssetIds.value,
}),
onSuccess: async (data) => {
message.success(t('article.editor.messages.saved'))
title.value = data.title ?? ''
markdown.value = data.markdown_content ?? ''
initialTitle.value = title.value
initialMarkdown.value = markdown.value
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['articles'] }),
queryClient.invalidateQueries({ queryKey: ['workspace'] }),
queryClient.invalidateQueries({ queryKey: ['templates'] }),
queryClient.invalidateQueries({ queryKey: ['articles', 'detail', articleId.value] }),
queryClient.invalidateQueries({
queryKey: ['articles', 'detail', articleId.value, 'publish-modal'],
}),
])
},
onError: (error) => {
message.error(formatError(error))
},
})
const saveDisabled = computed(
() =>
editorLocked.value ||
saveMutation.isPending.value ||
title.value.trim() === '' ||
!hasChanges.value,
)
const complianceCheckLoading = ref(false)
const editorComplianceViolations = computed(() =>
compliancePanelOpen.value ? (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'
})
const complianceDecisionTone = computed(() =>
(editorComplianceResult.value?.hit_count ?? 0) > 0 ? 'warning' : 'success',
)
const leaveSaveDisabled = computed(
() => editorLocked.value || saveMutation.isPending.value || title.value.trim() === '',
)
async function saveArticle(): Promise<boolean> {
if (leaveSaveDisabled.value || !hasChanges.value) {
return false
}
try {
await saveMutation.mutateAsync()
return true
} catch {
return false
}
}
async function handleSave(): Promise<void> {
if (saveDisabled.value) {
return
}
await saveArticle()
}
function handleSaveShortcut(event: KeyboardEvent): void {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') {
return
}
event.preventDefault()
event.stopPropagation()
if (event.repeat || saveDisabled.value) {
return
}
void handleSave()
}
async function handlePublish(): Promise<void> {
if (editorLocked.value) {
return
}
if (hasChanges.value && !saveMutation.isPending.value) {
try {
await saveMutation.mutateAsync()
} catch {
return
}
}
publishModalOpen.value = true
}
async function handleComplianceCheck(): Promise<void> {
if (editorLocked.value || complianceCheckLoading.value) {
return
}
if (hasChanges.value && !saveMutation.isPending.value) {
try {
await saveMutation.mutateAsync()
} catch {
return
}
}
complianceCheckLoading.value = true
try {
const currentDetail = (await detailQuery.refetch()).data ?? detail.value
const versionId = currentDetail?.current_version_id ?? null
const result = await complianceApi.checkArticle(articleId.value, {
article_version_id: versionId,
title: title.value.trim(),
plaintext: markdown.value,
target_platforms: [],
trigger_source: 'editor_manual',
})
editorComplianceResult.value = result
compliancePanelOpen.value = true
if (result.hit_count > 0) {
message.warning(`发现 ${result.hit_count} 条合规风险`)
} else {
message.success('合规检测通过')
}
} catch (error) {
message.error(formatError(error))
} finally {
complianceCheckLoading.value = false
}
}
async function handleDownload(format: ArticleExportFormat): Promise<void> {
if (!detail.value || exportLoading.value) {
return
}
if (format === 'markdown') {
if (hasArticleMarkdownImages(markdown.value)) {
markdownExportConfirmOpen.value = true
return
}
await runDownload(format)
return
}
await runDownload(format)
}
async function handleConfirmMarkdownDownload(): Promise<void> {
markdownExportConfirmOpen.value = false
await runDownload('markdown')
}
function handleCancelMarkdownDownload(): void {
markdownExportConfirmOpen.value = false
}
async function runDownload(format: ArticleExportFormat): Promise<void> {
if (!detail.value || exportLoading.value) {
return
}
exportLoading.value = format
try {
const options = {
title: title.value,
markdown: markdown.value,
untitledLabel: t('article.untitled'),
}
if (format === 'pdf') {
await downloadArticlePdf(options)
message.success(t('article.editor.downloadMessages.success'))
return
}
await downloadArticleDocument(format, options)
message.success(t('article.editor.downloadMessages.success'))
} catch (error) {
message.error(t('article.editor.downloadMessages.failed'))
} finally {
window.setTimeout(() => {
exportLoading.value = null
}, 200)
}
}
function closeCompliancePanel(): void {
compliancePanelOpen.value = false
}
function focusViolation(violation: ComplianceViolation): void {
const ok = editorCanvasRef.value?.scrollToText?.(violation.matched_text)
if (!ok) {
message.info('未能在当前编辑器内容中定位该命中片段')
}
}
function groupViolations(violations: ComplianceViolation[]) {
const groups = new Map<string, ComplianceViolation[]>()
for (const violation of violations) {
const key = violation.platform_code || 'all'
const list = groups.get(key) ?? []
list.push(violation)
groups.set(key, list)
}
return Array.from(groups.entries()).map(([key, items]) => ({ key, items }))
}
function violationGroupTitle(key: string): string {
return key === 'all' ? '全部平台共用规则' : `平台规则:${key}`
}
function violationKey(violation: ComplianceViolation): string {
return [
violation.id || 0,
violation.source,
violation.platform_code ?? 'all',
violation.start_offset,
violation.end_offset,
violation.matched_text,
].join(':')
}
function levelColor(level: ComplianceViolation['level']): string {
switch (level) {
case 'block':
return 'red'
case 'high':
return 'orange'
case 'medium':
return 'gold'
default:
return 'blue'
}
}
function levelLabel(level: ComplianceViolation['level']): string {
switch (level) {
case 'block':
return '重点风险'
case 'high':
return '高风险'
case 'medium':
return '中风险'
default:
return '提示'
}
}
function sourceLabel(source: ComplianceViolation['source']): string {
switch (source) {
case 'dict':
return '词库'
case 'regex':
return '正则'
case 'llm':
return 'LLM'
default:
return source
}
}
async function uploadEditorImage(file: File): Promise<string> {
const result = await articlesApi.uploadImage(articleId.value, file)
return result.url
}
async function handlePublished(): Promise<void> {
await Promise.all([
detailQuery.refetch(),
queryClient.invalidateQueries({ queryKey: ['articles'] }),
queryClient.invalidateQueries({ queryKey: ['workspace'] }),
])
}
const isFreeCreateEmpty = computed(
() =>
detail.value?.source_type === 'free_create' &&
title.value.trim() === '' &&
markdown.value.trim() === '',
)
const isFreeCreateInitiallyEmpty = computed(
() =>
detail.value?.source_type === 'free_create' &&
initialTitle.value.trim() === '' &&
initialMarkdown.value.trim() === '',
)
function handleBack(): void {
if (saveMutation.isPending.value) {
return
}
if (isFreeCreateEmpty.value) {
void cleanupEmptyFreeCreate()
return
}
if (hasChanges.value) {
leaveModalOpen.value = true
return
}
navigateBack()
}
async function cleanupEmptyFreeCreate(): Promise<void> {
try {
await articlesApi.remove(articleId.value)
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['articles'] }),
queryClient.invalidateQueries({ queryKey: ['workspace'] }),
])
} catch {
// ignore cleanup errors
}
navigateBack()
}
function navigateBack(): void {
const returnTo = normalizeReturnTo(route.query.return_to)
if (returnTo) {
void router.push(returnTo)
return
}
if (window.history.length > 1) {
void router.back()
return
}
void router.push('/articles/templates')
}
function normalizeReturnTo(value: unknown): string | null {
const raw = Array.isArray(value) ? value[0] : value
if (typeof raw !== 'string') {
return null
}
const trimmed = raw.trim()
if (!trimmed || !trimmed.startsWith('/') || trimmed.startsWith('//')) {
return null
}
return trimmed
}
function handleStay(): void {
leaveModalOpen.value = false
}
function handleDiscardLeave(): void {
leaveModalOpen.value = false
if (isFreeCreateInitiallyEmpty.value) {
void cleanupEmptyFreeCreate()
return
}
navigateBack()
}
async function handleSaveAndLeave(): Promise<void> {
const saved = await saveArticle()
if (!saved) {
return
}
leaveModalOpen.value = false
navigateBack()
}
function stripLeadingTitleHeading(titleValue: string, markdownValue: string): string {
const normalizedTitle = titleValue.trim()
if (!normalizedTitle) {
return markdownValue
}
const normalizedMarkdown = markdownValue.replace(/^\uFEFF/, '')
const match = normalizedMarkdown.match(/^#\s+(.+?)\s*(\r?\n|$)/)
if (!match) {
return markdownValue
}
if (match[1].trim() !== normalizedTitle) {
return markdownValue
}
return normalizedMarkdown.slice(match[0].length).replace(/^\s*\r?\n/, '')
}
function extractReferencedImageAssetIds(markdownValue: string): number[] {
const ids = new Set<number>()
const matcher = /data-asset-id=(?:"|')(\d+)(?:"|')/g
for (const match of markdownValue.matchAll(matcher)) {
const id = Number.parseInt(match[1] ?? '', 10)
if (Number.isInteger(id) && id > 0) {
ids.add(id)
}
}
return [...ids]
}
onMounted(() => {
window.addEventListener('keydown', handleSaveShortcut)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleSaveShortcut)
})
</script>
<template>
<div class="article-editor-view">
<header class="article-editor-view__topbar">
<button class="article-editor-view__back" type="button" @click="handleBack">
<LeftOutlined />
<span>{{ t('common.back') }}</span>
</button>
<div class="article-editor-view__actions">
<a-dropdown :trigger="['click']" placement="bottomRight">
<a-button :disabled="!detail || Boolean(exportLoading)" :loading="Boolean(exportLoading)">
<template #icon><DownloadOutlined /></template>
{{ t('article.editor.download') }}
<DownOutlined v-if="!exportLoading" class="article-editor-view__download-caret" />
</a-button>
<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" />
<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" />
<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" />
<span>{{ t('article.editor.downloadFormats.markdown') }}</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-button
:disabled="editorLocked"
:loading="complianceCheckLoading"
@click="handleComplianceCheck"
>
<template #icon><SafetyCertificateOutlined /></template>
合规检测
</a-button>
<a-button
:disabled="saveDisabled"
:loading="saveMutation.isPending.value"
title="Ctrl/Cmd + S"
aria-keyshortcuts="Control+S Meta+S"
@click="handleSave"
>
{{ t('common.save') }}
</a-button>
<a-button type="primary" :disabled="editorLocked" @click="handlePublish">
{{ t('article.editor.publish') }}
</a-button>
</div>
</header>
<div v-if="detailQuery.isPending.value" class="article-editor-view__loading">
<a-skeleton active :paragraph="{ rows: 14 }" />
</div>
<template v-else-if="detail">
<a-alert
v-if="editorLocked"
class="article-editor-view__alert"
type="info"
show-icon
:message="t('article.editor.messages.locked')"
/>
<div
class="article-editor-view__layout"
:class="{ 'article-editor-view__layout--with-compliance': compliancePanelOpen }"
>
<section class="article-editor-view__main">
<ArticleEditorCanvas
ref="editorCanvasRef"
:key="String(articleId)"
v-model:title="title"
v-model="markdown"
:article-id="articleId"
:disabled="editorLocked"
:upload-image="uploadEditorImage"
:compliance-violations="editorComplianceViolations"
/>
</section>
<aside
v-if="compliancePanelOpen"
class="article-editor-view__compliance-panel"
aria-label="内容合规检测"
>
<div class="article-editor-view__compliance-panel-head">
<h2>内容合规检测</h2>
<a-button
type="text"
shape="circle"
aria-label="关闭合规检测"
@click="closeCompliancePanel"
>
<template #icon><CloseOutlined /></template>
</a-button>
</div>
<template v-if="editorComplianceResult">
<a-alert
show-icon
:type="complianceDecisionTone"
:message="
editorComplianceResult.hit_count > 0
? `发现 ${editorComplianceResult.hit_count} 条风险,仅供编辑参考`
: '当前版本未发现合规风险'
"
:description="
editorComplianceResult.truncated
? '命中明细已达到保存上限,请优先处理高风险词条后重新检测。'
: undefined
"
/>
<div class="article-editor-view__compliance-meta">
<a-tag :color="editorComplianceResult.hit_count > 0 ? 'orange' : 'green'">
{{ editorComplianceResult.hit_count > 0 ? '发现风险' : '检测通过' }}
</a-tag>
<span>耗时 {{ complianceElapsedLabel }}</span>
</div>
<a-empty v-if="!complianceGroups.length" description="没有命中项" />
<section
v-for="group in complianceGroups"
v-else
:key="group.key"
class="article-editor-view__violation-group"
>
<h3>{{ violationGroupTitle(group.key) }}</h3>
<article
v-for="violation in group.items"
:key="violationKey(violation)"
class="article-editor-view__violation"
>
<div class="article-editor-view__violation-head">
<a-tag :color="levelColor(violation.level)">
{{ levelLabel(violation.level) }}
</a-tag>
<a-tag>{{ sourceLabel(violation.source) }}</a-tag>
<strong>{{ violation.matched_text }}</strong>
</div>
<p v-if="violation.dictionary_name || violation.term_pattern">
{{ violation.dictionary_name || '词库规则' }}
<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">
<a-button size="small" @click="focusViolation(violation)">
<template #icon><SearchOutlined /></template>
定位
</a-button>
</div>
</article>
</section>
</template>
<a-empty v-else description="还没有检测结果" />
</aside>
</div>
</template>
<a-empty v-else :description="t('common.noData')" />
<a-modal
:open="markdownExportConfirmOpen"
:title="t('article.editor.markdownExportConfirm.title')"
:ok-text="t('article.editor.markdownExportConfirm.confirm')"
:cancel-text="t('common.cancel')"
:confirm-loading="exportLoading === 'markdown'"
@ok="handleConfirmMarkdownDownload"
@cancel="handleCancelMarkdownDownload"
>
<p class="article-editor-view__leave-copy">
{{ t('article.editor.markdownExportConfirm.description') }}
</p>
</a-modal>
<a-modal
:open="leaveModalOpen"
:title="t('article.editor.leaveConfirm.title')"
:closable="false"
:mask-closable="false"
@cancel="handleStay"
>
<p class="article-editor-view__leave-copy">
{{ t('article.editor.leaveConfirm.description') }}
</p>
<template #footer>
<a-button @click="handleStay">
{{ t('common.cancel') }}
</a-button>
<a-button @click="handleDiscardLeave">
{{ t('article.editor.leaveConfirm.discard') }}
</a-button>
<a-button
type="primary"
:loading="saveMutation.isPending.value"
:disabled="leaveSaveDisabled"
@click="handleSaveAndLeave"
>
{{ t('article.editor.leaveConfirm.saveAndLeave') }}
</a-button>
</template>
</a-modal>
<PublishArticleModal
v-model:open="publishModalOpen"
:article-id="detail?.id ?? null"
@published="handlePublished"
/>
</div>
</template>
<style scoped>
.article-editor-view {
display: flex;
flex-direction: column;
gap: 18px;
}
.article-editor-view__topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.article-editor-view__back {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 0;
border: 0;
background: transparent;
color: #111827;
font-size: 16px;
font-weight: 700;
cursor: pointer;
}
.article-editor-view__back :deep(.anticon) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(144, 157, 181, 0.24);
}
.article-editor-view__actions {
display: flex;
gap: 12px;
}
.article-editor-view__download-caret {
margin-left: 4px;
color: #667085;
font-size: 10px;
}
.article-editor-view__download-menu :deep(.ant-dropdown-menu-item) {
min-width: 170px;
padding: 11px 14px;
color: #1f2937;
font-size: 15px;
}
.article-editor-view__download-menu :deep(.ant-dropdown-menu-title-content) {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
width: 100%;
text-align: left;
}
.article-editor-view__download-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
color: #475467;
font-size: 18px;
}
.article-editor-view__download-icon--word {
color: #2563eb;
}
.article-editor-view__download-icon--pdf {
color: #f04438;
}
.article-editor-view__download-icon--markdown {
color: #3b82f6;
}
.article-editor-view__main {
height: calc(100vh - 180px);
overflow: hidden;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(216, 225, 237, 0.9);
border-radius: 28px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.05);
backdrop-filter: blur(14px);
}
.article-editor-view__loading {
padding: 24px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(216, 225, 237, 0.9);
border-radius: 28px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.05);
backdrop-filter: blur(14px);
}
.article-editor-view__alert {
border-radius: 20px;
}
.article-editor-view__layout {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
min-width: 0;
}
.article-editor-view__layout--with-compliance {
grid-template-columns: minmax(0, 1fr) minmax(360px, 420px);
align-items: stretch;
}
.article-editor-view__compliance-panel {
display: flex;
flex-direction: column;
min-width: 0;
height: calc(100vh - 180px);
overflow-y: auto;
padding: 18px;
border: 1px solid rgba(216, 225, 237, 0.9);
border-radius: 24px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.05);
backdrop-filter: blur(14px);
}
.article-editor-view__compliance-panel-head {
position: sticky;
top: -18px;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: -18px -18px 16px;
padding: 16px 16px 14px 18px;
border-bottom: 1px solid #eef2f8;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(12px);
}
.article-editor-view__compliance-panel-head h2 {
margin: 0;
color: #111827;
font-size: 16px;
font-weight: 800;
}
.article-editor-view__leave-copy {
margin: 0;
color: #475467;
line-height: 1.75;
}
.article-editor-view__compliance-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin: 16px 0 18px;
color: #667085;
font-size: 12px;
}
.article-editor-view__violation-group {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 18px;
}
.article-editor-view__violation-group h3 {
margin: 0;
color: #111827;
font-size: 14px;
font-weight: 700;
}
.article-editor-view__violation {
padding: 14px;
border: 1px solid #e6edf5;
border-radius: 12px;
background: #fbfcff;
}
.article-editor-view__violation-head {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.article-editor-view__violation-head strong {
color: #111827;
}
.article-editor-view__violation p {
margin: 10px 0 0;
color: #667085;
line-height: 1.65;
}
.article-editor-view__violation-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.article-editor-view__main {
display: flex;
flex-direction: column;
gap: 18px;
}
@media (max-width: 1200px) {
.article-editor-view__layout--with-compliance {
grid-template-columns: minmax(0, 1fr) minmax(320px, 360px);
}
}
@media (max-width: 960px) {
.article-editor-view__layout--with-compliance {
grid-template-columns: minmax(0, 1fr);
}
.article-editor-view__compliance-panel {
height: min(560px, calc(100vh - 180px));
}
}
@media (max-width: 768px) {
.article-editor-view__topbar {
flex-direction: column;
align-items: stretch;
}
.article-editor-view__actions {
width: 100%;
}
.article-editor-view__actions :deep(.ant-btn) {
flex: 1;
}
}
</style>