Compare commits

...

2 Commits

Author SHA1 Message Date
root 075e282c76 feat(tracking): pass question hash/text via history state and follow URL business date
Frontend CI / Frontend (push) Successful in 3m11s
Move question_hash and question_text out of the URL query into router
history state to keep tracking question links clean, and make the
tracking dashboard's business date follow the URL/today instead of the
previously persisted localStorage value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:27:32 +08:00
root 0d690371d2 feat(article-copy): copy articles as rich HTML with plain-text fallback
Render markdown to sanitized HTML and write both text/html and text/plain
to the clipboard so pasted articles preserve formatting in rich editors,
falling back to selection-based copy and then plain text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:27:24 +08:00
4 changed files with 119 additions and 17 deletions
+24 -1
View File
@@ -1,4 +1,7 @@
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
@@ -69,12 +72,32 @@ export function resolveArticleActionState(
}
}
const markdownRenderer = new MarkdownIt({
html: true,
linkify: true,
breaks: true,
})
export function buildArticleClipboardContent(
detail: Pick<ArticleDetail, 'title' | 'markdown_content'>,
): string {
): ClipboardPayload | null {
const title = detail.title?.trim() ?? ''
const markdown = detail.markdown_content?.trim() ?? ''
const text = buildArticleMarkdownText(title, markdown)
if (!text) {
return null
}
const html = DOMPurify.sanitize(markdownRenderer.render(text))
return {
text,
html,
}
}
function buildArticleMarkdownText(title: string, markdown: string): string {
if (!title) {
return markdown
}
+73 -3
View File
@@ -1,7 +1,37 @@
export async function copyTextToClipboard(text: string): Promise<void> {
export interface ClipboardPayload {
text: string
html?: string
}
export async function copyTextToClipboard(content: string | ClipboardPayload): Promise<void> {
const payload = normalizeClipboardPayload(content)
if (payload.html?.trim()) {
try {
if (navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') {
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([payload.html], { type: 'text/html' }),
'text/plain': new Blob([payload.text], { type: 'text/plain' }),
}),
])
return
}
} catch {
// Fall back to the selection-based copy path below.
}
try {
copyHtmlWithSelection(payload)
return
} catch {
// Fall back to plain text copy below.
}
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
await navigator.clipboard.writeText(payload.text)
return
}
} catch {
@@ -9,7 +39,7 @@ export async function copyTextToClipboard(text: string): Promise<void> {
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.value = payload.text
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.top = '0'
@@ -30,3 +60,43 @@ export async function copyTextToClipboard(text: string): Promise<void> {
document.body.removeChild(textarea)
}
}
function normalizeClipboardPayload(content: string | ClipboardPayload): ClipboardPayload {
if (typeof content === 'string') {
return { text: content }
}
return {
text: content.text,
html: content.html,
}
}
function copyHtmlWithSelection(payload: ClipboardPayload): void {
const container = document.createElement('div')
container.innerHTML = payload.html ?? ''
container.setAttribute('contenteditable', 'true')
container.style.position = 'fixed'
container.style.top = '0'
container.style.left = '-9999px'
container.style.opacity = '0'
container.style.pointerEvents = 'none'
document.body.appendChild(container)
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(container)
selection?.removeAllRanges()
selection?.addRange(range)
try {
const copied = document.execCommand('copy')
if (!copied) {
throw new Error('copy command failed')
}
} finally {
selection?.removeAllRanges()
document.body.removeChild(container)
}
}
@@ -30,8 +30,16 @@ const trackingToday = dayjs().format('YYYY-MM-DD')
const routeBrandId = computed(() => parsePositiveNumber(route.params.brandId))
const brandId = computed(() => companyStore.currentBrandId ?? routeBrandId.value)
const questionId = computed(() => parsePositiveNumber(route.params.questionId))
const questionHash = computed(() => normalizeQueryValue(route.query.question_hash))
const questionTitleFallback = computed(() => normalizeQueryValue(route.query.question_text))
const questionHash = computed(
() =>
normalizeHistoryStateValue(history.state?.tracking_question_hash) ||
normalizeQueryValue(route.query.question_hash),
)
const questionTitleFallback = computed(
() =>
normalizeHistoryStateValue(history.state?.tracking_question_text) ||
normalizeQueryValue(route.query.question_text),
)
const keywordId = computed(() => normalizeQueryValue(route.query.keyword_id))
const requestedPlatformId = computed(() => {
const platformId = normalizeMonitoringPlatformId(normalizeQueryValue(route.query.ai_platform_id))
@@ -1086,6 +1094,10 @@ function normalizeQueryValue(value: unknown): string {
return String(raw ?? '').trim()
}
function normalizeHistoryStateValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function normalizeTrackingBusinessDate(value: unknown): string | null {
const normalized = normalizeQueryValue(value)
if (!normalized) {
+8 -11
View File
@@ -101,12 +101,12 @@ const allQuestionOptionValue = 'all'
const selectedQuestionId = useStorage<number | null>('tracking_selected_question', null)
const selectedPlatformId = useStorage<string>('tracking_selected_ai_platform_id', 'all')
const selectedBusinessDate = useStorage<string>('tracking_selected_business_date', trackingToday)
const selectedBusinessDate = ref<string>(
normalizeTrackingBusinessDate(route.query.business_date) ?? trackingToday,
)
const selectedCitationWindowDays = useStorage<number>('tracking_selected_citation_window_days', 7)
selectedPlatformId.value = normalizeMonitoringPlatformFilter(selectedPlatformId.value)
selectedBusinessDate.value =
normalizeTrackingBusinessDate(selectedBusinessDate.value) ?? trackingToday
selectedCitationWindowDays.value = normalizeCitationWindowDays(selectedCitationWindowDays.value)
const selectedQuestionSetOptionValue = computed<string | number>({
@@ -168,10 +168,7 @@ watch(selectedBrandId, (newId, oldId) => {
watch(
() => route.query.business_date,
(value) => {
const requestedBusinessDate = normalizeTrackingBusinessDate(value)
if (requestedBusinessDate) {
selectedBusinessDate.value = requestedBusinessDate
}
selectedBusinessDate.value = normalizeTrackingBusinessDate(value) ?? trackingToday
},
{ immediate: true },
)
@@ -636,13 +633,13 @@ function openQuestion(question: MonitoringHotQuestion): void {
questionId: String(question.question_id),
},
query: {
question_hash: question.question_hash,
question_text: question.question_text,
business_date: selectedBusinessDate.value,
date_from: selectedBusinessDate.value,
date_to: selectedBusinessDate.value,
...(selectedPlatformId.value !== 'all' ? { ai_platform_id: selectedPlatformId.value } : {}),
},
state: {
tracking_question_hash: question.question_hash,
tracking_question_text: question.question_text,
},
})
}