first commit

This commit is contained in:
flowerstonezl
2026-05-11 19:34:35 +08:00
commit c90b10cd5a
89 changed files with 6142 additions and 0 deletions

View 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',
],
})
}

View 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'
}

View 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 为 112。 */
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
}