first commit
This commit is contained in:
38
frontend/src/utils/renderMarkdown.ts
Normal file
38
frontend/src/utils/renderMarkdown.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
breaks: true,
|
||||
})
|
||||
|
||||
/**
|
||||
* 将 Markdown 转为可安全 `v-html` 的 HTML。
|
||||
*/
|
||||
export function renderMarkdown(source: string): string {
|
||||
const raw = md.render(source || '')
|
||||
return DOMPurify.sanitize(raw, {
|
||||
ADD_ATTR: ['target'],
|
||||
ALLOWED_TAGS: [
|
||||
'p',
|
||||
'br',
|
||||
'strong',
|
||||
'em',
|
||||
'u',
|
||||
's',
|
||||
'blockquote',
|
||||
'code',
|
||||
'pre',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'a',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'hr',
|
||||
],
|
||||
})
|
||||
}
|
||||
100
frontend/src/utils/storyPageTransition.ts
Normal file
100
frontend/src/utils/storyPageTransition.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { Memory, MemoryFeatureType, NepalArtStyle } from '../api/client'
|
||||
|
||||
/** 与 <Transition name="…"> 对应,每种一套 enter/leave 动画 */
|
||||
export type StoryTransitionName =
|
||||
| 'story-none'
|
||||
| 'story-heart'
|
||||
| 'story-fissure'
|
||||
| 'story-dawn'
|
||||
| 'story-blush'
|
||||
| 'story-mint'
|
||||
| 'story-saffron'
|
||||
| 'story-indigo'
|
||||
| 'story-lotus'
|
||||
| 'story-rose'
|
||||
| 'story-blue'
|
||||
| 'story-night'
|
||||
| 'story-gold'
|
||||
| 'story-paper'
|
||||
|
||||
function transitionForStandardStyle(style: NepalArtStyle): StoryTransitionName {
|
||||
const map: Partial<Record<NepalArtStyle, StoryTransitionName>> = {
|
||||
dawn_meet: 'story-dawn',
|
||||
blush_argue: 'story-blush',
|
||||
mint_reconcile: 'story-mint',
|
||||
saffron_home: 'story-saffron',
|
||||
indigo_ikea: 'story-indigo',
|
||||
lotus_petal: 'story-lotus',
|
||||
mandala_pink: 'story-rose',
|
||||
spiral_rose: 'story-rose',
|
||||
mandala_blue: 'story-blue',
|
||||
spiral_cyan: 'story-blue',
|
||||
ocean_prayer: 'story-blue',
|
||||
night_star: 'story-night',
|
||||
lokta_gold: 'story-gold',
|
||||
terra_torn: 'story-gold',
|
||||
}
|
||||
return map[style] ?? 'story-paper'
|
||||
}
|
||||
|
||||
export function getStoryTransitionName(m: Memory | null): StoryTransitionName {
|
||||
if (!m) return 'story-paper'
|
||||
const t = m.unique_feature_type as MemoryFeatureType
|
||||
if (t === 'heart_window') return 'story-heart'
|
||||
if (t === 'fissure_page') return 'story-fissure'
|
||||
return transitionForStandardStyle(m.nepal_art_style)
|
||||
}
|
||||
|
||||
/** 书壳外框:随叙事微调描边与投影 */
|
||||
export function getStoryShellClasses(m: Memory | null): string {
|
||||
if (!m) {
|
||||
return 'border-lokta-deep/10 shadow-[0_20px_60px_-24px_rgba(30,53,80,0.25)]'
|
||||
}
|
||||
const t = m.unique_feature_type
|
||||
if (t === 'heart_window') {
|
||||
return 'border-rose-300/35 shadow-[0_24px_70px_-22px_rgba(190,80,120,0.32)] ring-rose-200/25'
|
||||
}
|
||||
if (t === 'fissure_page') {
|
||||
return 'border-fuchsia-300/30 shadow-[0_22px_65px_-24px_rgba(120,60,160,0.28)] ring-fuchsia-200/20'
|
||||
}
|
||||
const shells: Partial<Record<NepalArtStyle, string>> = {
|
||||
dawn_meet:
|
||||
'border-amber-200/45 shadow-[0_22px_68px_-22px_rgba(217,119,6,0.22)] ring-amber-100/30',
|
||||
blush_argue:
|
||||
'border-rose-200/40 shadow-[0_22px_64px_-24px_rgba(190,65,100,0.26)] ring-rose-100/25',
|
||||
mint_reconcile:
|
||||
'border-emerald-200/40 shadow-[0_22px_64px_-24px_rgba(16,185,129,0.2)] ring-emerald-100/25',
|
||||
saffron_home:
|
||||
'border-amber-300/35 shadow-[0_24px_68px_-22px_rgba(245,158,11,0.24)] ring-amber-100/30',
|
||||
indigo_ikea:
|
||||
'border-indigo-200/40 shadow-[0_22px_64px_-24px_rgba(79,70,229,0.22)] ring-indigo-100/25',
|
||||
lotus_petal:
|
||||
'border-violet-200/35 shadow-[0_24px_70px_-22px_rgba(139,92,246,0.22)] ring-violet-100/25',
|
||||
night_star:
|
||||
'border-slate-300/35 shadow-[0_22px_70px_-24px_rgba(51,65,85,0.35)] ring-slate-200/20',
|
||||
lokta_gold:
|
||||
'border-amber-200/40 shadow-[0_24px_68px_-22px_rgba(180,130,40,0.22)] ring-amber-50/35',
|
||||
}
|
||||
return (
|
||||
shells[m.nepal_art_style] ??
|
||||
'border-lokta-deep/10 shadow-[0_20px_60px_-24px_rgba(30,53,80,0.25)] ring-ink/5'
|
||||
)
|
||||
}
|
||||
|
||||
/** 顶栏上一页/下一页按钮点缀色 */
|
||||
export function getStoryNavAccentClass(m: Memory | null): string {
|
||||
if (!m) return 'hover:border-lokta-deep/25 hover:bg-paper-deep'
|
||||
const t = m.unique_feature_type
|
||||
if (t === 'heart_window') return 'hover:border-rose-300/50 hover:bg-rose-50/80'
|
||||
if (t === 'fissure_page') return 'hover:border-fuchsia-300/45 hover:bg-fuchsia-50/70'
|
||||
const accents: Partial<Record<NepalArtStyle, string>> = {
|
||||
dawn_meet: 'hover:border-amber-300/50 hover:bg-amber-50/80',
|
||||
blush_argue: 'hover:border-rose-300/45 hover:bg-rose-50/75',
|
||||
mint_reconcile: 'hover:border-emerald-300/45 hover:bg-emerald-50/75',
|
||||
saffron_home: 'hover:border-amber-400/40 hover:bg-amber-50/85',
|
||||
indigo_ikea: 'hover:border-indigo-300/45 hover:bg-indigo-50/75',
|
||||
lotus_petal: 'hover:border-violet-300/45 hover:bg-violet-50/75',
|
||||
night_star: 'hover:border-slate-400/35 hover:bg-slate-100/70',
|
||||
}
|
||||
return accents[m.nepal_art_style] ?? 'hover:border-lokta-deep/25 hover:bg-paper-deep'
|
||||
}
|
||||
48
frontend/src/utils/togetherDuration.ts
Normal file
48
frontend/src/utils/togetherDuration.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/** 将瞬时时刻换算为「上海」日历年月日(与后端 Asia/Shanghai 叙事一致)。 */
|
||||
export function ymdInShanghai(ms: number): { y: number; m: number; d: number } {
|
||||
const s = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date(ms))
|
||||
const [y, m, d] = s.split('-').map((x) => Number.parseInt(x, 10))
|
||||
return { y, m, d }
|
||||
}
|
||||
|
||||
/** 公历某月天数,month 为 1–12。 */
|
||||
function daysInMonth(y: number, month: number): number {
|
||||
return new Date(y, month, 0).getDate()
|
||||
}
|
||||
|
||||
/**
|
||||
* 从起点日历日到终点日历日的「年、月、日」差(与日常说的「在一起多久」同构)。
|
||||
* 假定 a 不晚于 b。
|
||||
*/
|
||||
export function calendarDiffYmd(
|
||||
a: { y: number; m: number; d: number },
|
||||
b: { y: number; m: number; d: number },
|
||||
): { years: number; months: number; days: number } {
|
||||
let years = b.y - a.y
|
||||
let months = b.m - a.m
|
||||
let days = b.d - a.d
|
||||
if (days < 0) {
|
||||
months -= 1
|
||||
const prevMonth = b.m === 1 ? 12 : b.m - 1
|
||||
const prevYear = b.m === 1 ? b.y - 1 : b.y
|
||||
days += daysInMonth(prevYear, prevMonth)
|
||||
}
|
||||
if (months < 0) {
|
||||
years -= 1
|
||||
months += 12
|
||||
}
|
||||
return { years, months, days }
|
||||
}
|
||||
|
||||
/** 从起点到当前的累计小时数(可带小数,用于「相当于多少小时」)。 */
|
||||
export function totalHoursBetween(startMs: number, endMs: number, fractionDigits = 1): number {
|
||||
const ms = Math.max(0, endMs - startMs)
|
||||
const h = ms / 3_600_000
|
||||
const p = 10 ** fractionDigits
|
||||
return Math.round(h * p) / p
|
||||
}
|
||||
Reference in New Issue
Block a user