Files
geo/apps/admin-web/src/views/ArticleEditorView.vue
T
root aa96143754 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>
2026-06-08 11:56:18 +08:00

995 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>