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 { ArticleDetail } from '@geo/shared-types'
import type { ClipboardPayload } from '@/lib/clipboard'
export interface ArticleActionState { export interface ArticleActionState {
showPublish: boolean showPublish: boolean
@@ -69,12 +72,32 @@ export function resolveArticleActionState(
} }
} }
const markdownRenderer = new MarkdownIt({
html: true,
linkify: true,
breaks: true,
})
export function buildArticleClipboardContent( export function buildArticleClipboardContent(
detail: Pick<ArticleDetail, 'title' | 'markdown_content'>, detail: Pick<ArticleDetail, 'title' | 'markdown_content'>,
): string { ): ClipboardPayload | null {
const title = detail.title?.trim() ?? '' const title = detail.title?.trim() ?? ''
const markdown = detail.markdown_content?.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) { if (!title) {
return markdown 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 { try {
if (navigator.clipboard?.writeText) { if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(payload.text)
return return
} }
} catch { } catch {
@@ -9,7 +39,7 @@ export async function copyTextToClipboard(text: string): Promise<void> {
} }
const textarea = document.createElement('textarea') const textarea = document.createElement('textarea')
textarea.value = text textarea.value = payload.text
textarea.setAttribute('readonly', 'true') textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed' textarea.style.position = 'fixed'
textarea.style.top = '0' textarea.style.top = '0'
@@ -30,3 +60,43 @@ export async function copyTextToClipboard(text: string): Promise<void> {
document.body.removeChild(textarea) 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 routeBrandId = computed(() => parsePositiveNumber(route.params.brandId))
const brandId = computed(() => companyStore.currentBrandId ?? routeBrandId.value) const brandId = computed(() => companyStore.currentBrandId ?? routeBrandId.value)
const questionId = computed(() => parsePositiveNumber(route.params.questionId)) const questionId = computed(() => parsePositiveNumber(route.params.questionId))
const questionHash = computed(() => normalizeQueryValue(route.query.question_hash)) const questionHash = computed(
const questionTitleFallback = computed(() => normalizeQueryValue(route.query.question_text)) () =>
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 keywordId = computed(() => normalizeQueryValue(route.query.keyword_id))
const requestedPlatformId = computed(() => { const requestedPlatformId = computed(() => {
const platformId = normalizeMonitoringPlatformId(normalizeQueryValue(route.query.ai_platform_id)) const platformId = normalizeMonitoringPlatformId(normalizeQueryValue(route.query.ai_platform_id))
@@ -1086,6 +1094,10 @@ function normalizeQueryValue(value: unknown): string {
return String(raw ?? '').trim() return String(raw ?? '').trim()
} }
function normalizeHistoryStateValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function normalizeTrackingBusinessDate(value: unknown): string | null { function normalizeTrackingBusinessDate(value: unknown): string | null {
const normalized = normalizeQueryValue(value) const normalized = normalizeQueryValue(value)
if (!normalized) { if (!normalized) {
+8 -11
View File
@@ -101,12 +101,12 @@ const allQuestionOptionValue = 'all'
const selectedQuestionId = useStorage<number | null>('tracking_selected_question', null) const selectedQuestionId = useStorage<number | null>('tracking_selected_question', null)
const selectedPlatformId = useStorage<string>('tracking_selected_ai_platform_id', 'all') 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) const selectedCitationWindowDays = useStorage<number>('tracking_selected_citation_window_days', 7)
selectedPlatformId.value = normalizeMonitoringPlatformFilter(selectedPlatformId.value) selectedPlatformId.value = normalizeMonitoringPlatformFilter(selectedPlatformId.value)
selectedBusinessDate.value =
normalizeTrackingBusinessDate(selectedBusinessDate.value) ?? trackingToday
selectedCitationWindowDays.value = normalizeCitationWindowDays(selectedCitationWindowDays.value) selectedCitationWindowDays.value = normalizeCitationWindowDays(selectedCitationWindowDays.value)
const selectedQuestionSetOptionValue = computed<string | number>({ const selectedQuestionSetOptionValue = computed<string | number>({
@@ -168,10 +168,7 @@ watch(selectedBrandId, (newId, oldId) => {
watch( watch(
() => route.query.business_date, () => route.query.business_date,
(value) => { (value) => {
const requestedBusinessDate = normalizeTrackingBusinessDate(value) selectedBusinessDate.value = normalizeTrackingBusinessDate(value) ?? trackingToday
if (requestedBusinessDate) {
selectedBusinessDate.value = requestedBusinessDate
}
}, },
{ immediate: true }, { immediate: true },
) )
@@ -636,13 +633,13 @@ function openQuestion(question: MonitoringHotQuestion): void {
questionId: String(question.question_id), questionId: String(question.question_id),
}, },
query: { query: {
question_hash: question.question_hash,
question_text: question.question_text,
business_date: selectedBusinessDate.value, business_date: selectedBusinessDate.value,
date_from: selectedBusinessDate.value,
date_to: selectedBusinessDate.value,
...(selectedPlatformId.value !== 'all' ? { ai_platform_id: selectedPlatformId.value } : {}), ...(selectedPlatformId.value !== 'all' ? { ai_platform_id: selectedPlatformId.value } : {}),
}, },
state: {
tracking_question_hash: question.question_hash,
tracking_question_text: question.question_text,
},
}) })
} }