Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/contract/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ export type ResultNode =
| { kind: 'gua'; lines: GuaLine[]; title?: string; subtitle?: string }
/** 文本行(断语、解释) */
| { kind: 'text'; lines: string[]; lead?: boolean }
/**
* 可核出处:引文原样列出,并给一个"查原文"的入口。
*
* 权威性不是靠排版出来的——读者点进去必须在阅览室搜到这句话,
* 所以这一节单独成节点,而不是塞进 text 里。
*/
| { kind: 'quote'; book: string; loc?: string; text: string; gloss?: string }
/** 一句话提示 / 免责 */
| { kind: 'note'; text: string; tone?: Tone }

Expand Down
19 changes: 14 additions & 5 deletions src/engine/resonance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,20 @@ function statementNode(m: Matched, index: number): ResultNode {
}

const s = st.source
const lines =
s.kind === 'canon'
? [`《${s.book ?? '佚'}》${s.loc ?? ''}`, `「${s.quote ?? ''}」`, s.gloss ?? '']
: [s.kind === 'psych' ? '现代心理观察' : '经验观察', s.gloss ?? '']
children.push({ kind: 'text', lines: lines.filter(Boolean) })
if (s.kind === 'canon' && s.quote) {
// 出处必须是可点开的引文节点,不是排好版的字:读者点进去要真能搜到
children.push({
kind: 'quote',
book: s.book ?? '佚',
text: s.quote,
...(s.loc ? { loc: s.loc } : {}),
...(s.gloss ? { gloss: s.gloss } : {}),
})
} else {
const lines =
s.kind === 'psych' ? ['现代心理观察', s.gloss ?? ''] : ['经验观察', s.gloss ?? '']
children.push({ kind: 'text', lines: lines.filter(Boolean) })
}

if (st.action) children.push({ kind: 'note', text: '可以做的:' + st.action, tone: 'good' })

Expand Down
31 changes: 24 additions & 7 deletions src/pages/Home.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { CATEGORIES, TOOLS } from '@/tools/registry'
import { getManifest, type Manifest } from '@/data/corpus'
import { STATEMENTS } from '@/engine/resonance'
import { SITUATION_LABEL, SITUATIONS } from '@/engine/features'
import type { ResultNode } from '@/contract/result'
import ResultView from '@/ui/ResultView.vue'

const manifest = ref<Manifest | null>(null)
onMounted(async () => {
Expand All @@ -34,6 +36,21 @@ const daily = computed(() => {
})

const sitChips = SITUATIONS.slice(0, 9).map((s) => SITUATION_LABEL[s])

/** 今日一签的出处也走同一个 quote 节点:能点开核对才算出处,不然只是排版 */
const dailySource = computed<ResultNode | null>(() => {
const s = daily.value.source
if (!s) return null
if (s.kind === 'canon' && s.quote)
return {
kind: 'quote',
book: s.book ?? '佚',
text: s.quote,
...(s.loc ? { loc: s.loc } : {}),
...(s.gloss ? { gloss: s.gloss } : {}),
}
return { kind: 'text', lines: [s.gloss ?? '经验观察'] }
})
</script>

<template>
Expand All @@ -46,13 +63,8 @@ const sitChips = SITUATIONS.slice(0, 9).map((s) => SITUATION_LABEL[s])
<div class="hm-daily">
<p class="hm-daily-k">今日一签</p>
<p class="hm-daily-x">{{ daily.text }}</p>
<p class="hm-daily-s">
<span v-if="daily.source?.kind === 'canon'">
《{{ daily.source.book }}》{{ daily.source.loc }} 「{{ daily.source.quote }}」
</span>
<span v-else>{{ daily.source?.gloss }}</span>
· 人群适用约 {{ Math.round(daily.baseRate * 100) }}%
</p>
<ResultView v-if="dailySource" class="hm-daily-s" :node="dailySource" />
<p class="hm-daily-b">人群适用约 {{ Math.round(daily.baseRate * 100) }}%</p>
</div>
</section>

Expand Down Expand Up @@ -111,6 +123,11 @@ const sitChips = SITUATIONS.slice(0, 9).map((s) => SITUATION_LABEL[s])
border-radius: var(--radius);
background: var(--bg-1);
}
.hm-daily-b {
margin: 8px 0 0;
font-size: 11px;
color: var(--ink-3);
}
.hm-daily-k {
margin: 0 0 6px;
font-size: 11px;
Expand Down
82 changes: 80 additions & 2 deletions src/pages/Reading.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
* 参考书要能公开浏览——断语的权威性只有回到原文才成立。
* 正文不进 bundle:选定书目才 fetch,一部一部懒取。
*/
import { computed, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import {
getChapter,
getManifest,
resolveBooks,
searchCorpus,
simplifyAsync,
type BookMeta,
Expand All @@ -19,6 +21,8 @@ import {

const props = defineProps<{ book?: string }>()

const route = useRoute()

const manifest = ref<Manifest | null>(null)
const error = ref('')
const query = ref('')
Expand All @@ -33,12 +37,73 @@ const toSimplified = ref(false)
onMounted(async () => {
try {
manifest.value = await getManifest()
if (props.book) await openBook(props.book)
const q = str(route.query.q)
const bk = str(route.query.bk)
if (q || bk) await openCitation(bk, q)
else if (props.book) await openBook(props.book)
} catch (e) {
error.value = `语料未就绪:${(e as Error).message}。(部署前需跑 node scripts/sync-corpus.mjs)`
}
})

const citation = ref('')

const str = (v: unknown): string => (typeof v === 'string' ? v : '')

/** 由出处跳转写入 query 时,不要惊动输入框那个防抖检索 */
let suppressQueryWatch = false

/**
* 从断语的"查原文"进来:带的是引用书名(可能是《黄帝内经》这种合称)与一段引文。
* 先按别名解析站内书目,再在这些书里搜引文,命中就直达那一章。
* 搜不到要如实说这本书里没这句话,不能悄悄全域乱搜出一个像样的结果。
*/
async function openCitation(citedTitle: string, quote: string) {
const m = manifest.value
if (!m) return
if (!citedTitle) {
if (quote) await runSearch(quote)
return
}
const books = resolveBooks(m, citedTitle)
if (!books.length) {
error.value = `站内没有《${citedTitle}》——这条出处暂时无法核对(需要补书,或在 book-aliases.json 里给别名)。`
return
}
citation.value = `${citedTitle} → ${books.map((b) => `《${b.title}》`).join('、')}`
if (!quote) {
await openBook(books[0]!.id)
return
}
// 只列命中(含章节与高亮片段),点一下才进正文:
// 一进来就跳章会把"这句话到底在不在"的判断权交给页面,读者看不到核的过程。
await runSearch(quote, books.map((b) => b.id))
}

/** 程序化检索:写入输入框、限定书目、拿命中 */
async function runSearch(q: string, bookIds?: string[]) {
clearTimeout(timer)
suppressQueryWatch = true
query.value = q
hits.value = await searchCorpus(q, bookIds, 5)
await nextTick()
suppressQueryWatch = false
}

watch(
() => [route.query.q, route.query.bk],
async ([q, bk]) => {
if (!manifest.value) return
const quote = str(q)
const title = str(bk)
if (quote || title) {
error.value = ''
citation.value = ''
await openCitation(title, quote)
}
},
)

watch(
() => props.book,
(id) => {
Expand Down Expand Up @@ -71,6 +136,8 @@ async function openChapter(chapterId: string) {
let timer: ReturnType<typeof setTimeout> | undefined
watch(query, (q) => {
clearTimeout(timer)
// 出处跳转自己会把限定书目的命中算好,这里再跑一遍会退化成全域搜索
if (suppressQueryWatch) return
if (q.trim().length < 2) {
hits.value = null
return
Expand Down Expand Up @@ -158,6 +225,11 @@ async function jumpTo(hit: Hit) {

<p v-if="error" class="rd-err">{{ error }}</p>

<p v-if="citation" class="rd-cite">
正在核对出处:{{ citation }}
<span v-if="hits && !hits.length">—— 这句话在本段引文里没搜到,可手动改上面的检索词</span>
</p>

<input v-model="query" class="du-input rd-q" placeholder="跨全书检索,例如 恬淡虚无 / 体用 / 顺逆之机" />

<nav v-if="manifest" class="rd-books">
Expand Down Expand Up @@ -246,6 +318,12 @@ async function jumpTo(hit: Hit) {
border: 1px dashed var(--tone-bad);
border-radius: 6px;
}
.rd-cite {
margin: 0;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--accent-2);
}
.rd-q {
margin-top: 4px;
}
Expand Down
55 changes: 55 additions & 0 deletions src/ui/ResultView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ import type { MatrixCell, ResultNode } from '@/contract/result'

const props = defineProps<{ node: ResultNode }>()

/**
* 出处跳转:带引用书名 + 首段引文进阅览室,由阅览室做书名别名解析与检索。
*
* 这里不猜站内书目 id(那要 fetch manifest,渲染器不该有 IO),也不装 RouterLink
* (ResultView 是递归组件,被单测和首页独立挂载时没有 router 实例):
* hash 路由下 `#/reading?...` 就是一个合法的相对链接,两个平台同一份产物。
*/
const quoteHref = computed(() => {
if (props.node.kind !== 'quote') return null
const seg = props.node.text.split(/…+/)[0]!.trim()
const q = new URLSearchParams({ bk: props.node.book, q: seg })
if (props.node.loc) q.set('loc', props.node.loc)
return `#/reading?${q.toString()}`
})

/** 卦画:上爻显示在最上 */
const guaRows = computed(() =>
props.node.kind === 'gua' ? [...props.node.lines].reverse() : [],
Expand Down Expand Up @@ -143,6 +158,15 @@ function widthOf(value: number, max?: number): string {
<p v-for="(l, i) in node.lines" :key="i">{{ l }}</p>
</div>

<div v-else-if="node.kind === 'quote'" class="qt">
<p class="qt-x">「{{ node.text }}」</p>
<p class="qt-s">
<span class="qt-b">《{{ node.book }}》{{ node.loc ?? '' }}</span>
<span v-if="node.gloss" class="qt-g">{{ node.gloss }}</span>
<a v-if="quoteHref" class="qt-a" :href="quoteHref">查原文</a>
</p>
</div>

<p v-else-if="node.kind === 'note'" class="nt" :class="toneClass(node.tone)">{{ node.text }}</p>
</template>

Expand Down Expand Up @@ -452,4 +476,35 @@ function widthOf(value: number, max?: number): string {
border: 1px dashed var(--line);
color: var(--ink-3);
}
.qt {
display: grid;
gap: 4px;
padding: 10px 12px;
border-left: 2px solid var(--accent-2);
background: var(--surface-2);
border-radius: 0 6px 6px 0;
}
.qt-x {
margin: 0;
font-size: 13px;
line-height: 1.9;
color: var(--ink-1);
}
.qt-s {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
margin: 0;
font-size: 11px;
color: var(--ink-3);
}
.qt-a {
margin-left: auto;
padding: 2px 9px;
border: 1px solid var(--accent-2);
border-radius: 999px;
color: var(--accent-2);
text-decoration: none;
}
</style>
28 changes: 26 additions & 2 deletions tests/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,10 @@ describe('界面冒烟', () => {
await flushPromises()
expect(w.text()).toContain('梅花易数')
expect(w.findAll('.hm-card')).toHaveLength(1)
// 每日一签必须带出处或释义,否则等于匿名断语
// 每日一签必须带出处或释义,否则等于匿名断语;canon 的话还得能点开核对
expect(w.find('.hm-daily-s').text().length).toBeGreaterThan(4)
const dailyLink = w.find('.hm-daily-s .qt-a')
if (dailyLink.exists()) expect(dailyLink.attributes('href')).toContain('#/reading')
})

it('工具页:填领域 → 点起一卦 → 出卦画、断语与反馈区', async () => {
Expand Down Expand Up @@ -128,6 +130,7 @@ describe('界面冒烟', () => {
it('ResultView 能解释全部节点类型(新工具不需要新组件)', () => {
const nodes: ResultNode[] = [
{ kind: 'text', lines: ['x'], lead: true },
{ kind: 'quote', book: '黄帝内经', loc: '素问 · 四气调神大论', text: '不治已病治未病', gloss: '先防' },
{ kind: 'note', text: 'n', tone: 'warn' },
{ kind: 'chips', items: [{ text: 'c', tone: 'good' }] },
{ kind: 'kv', items: [{ label: 'a', value: 'b', sub: 'c' }] },
Expand All @@ -145,10 +148,31 @@ describe('界面冒烟', () => {
{ kind: 'section', title: 'S', children: [{ kind: 'note', text: 'in' }] },
]
const w = mount(ResultView, { props: { node: { kind: 'stack', children: nodes } } })
for (const cls of ['.tx', '.nt', '.ch', '.kv', '.gr', '.br', '.sco', '.tb', '.mx', '.ga', '.rw', '.sc'])
for (const cls of [
'.tx',
'.nt',
'.ch',
'.kv',
'.gr',
'.br',
'.sco',
'.tb',
'.mx',
'.ga',
'.rw',
'.sc',
'.qt',
])
expect(w.find(cls).exists(), `节点未渲染:${cls}`).toBe(true)
// 环形评分的描边长度必须随分值变化,否则是摆设
expect(w.find('.sco-fg').attributes('stroke-dasharray')).toBe('215.6 326.7')
// 出处的"查原文"必须把书名与引文带进阅览室查询串,别名交给阅览室解析
const href = w.find('.qt-a').attributes('href')!
expect(href).toContain('#/reading')
const q = new URLSearchParams(href.split('?')[1]!)
expect(q.get('bk')).toBe('黄帝内经')
expect(q.get('q')).toBe('不治已病治未病')
expect(q.get('loc')).toBe('素问 · 四气调神大论')
})

it('FormRenderer 按 schema 生成控件,必填未填时提示', async () => {
Expand Down
Loading
Loading