feat(一言): 一言挤在角落里,出处被截成「来自 Structure and Interpretatio…」、不知道点一下能换——改成引号 + 句子 + 「—— 谁《出处》· 分类」+ 换一句按钮
设计稿「一言重设计」A 版: - 句子一行,长句(数据集 p90 44 字、最长 255 字)截断,点句子弹出全文 - 出处写成「雷电将军《原神》· 游戏」:接口多下发 fromWho 和分类名,「佚名」「网络」这类充数的出处不写 - 「换一句」是个看得见的圆按钮,换的时候转一圈 - 契约 quoteSchema 从 string | Record 收成定形对象 - .env.example 说明本机怎么把 HITOKOTO_DIRECTORY 指到 sentences-bundle(不指就一直是内置的三句) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dab53c9098
commit
c7a8e05385
5 files changed
+200
-67
No files matched your search
@@ -16,3 +16,6 @@ TRIAL_CONCURRENCY=2
|
|||||||
ALLOWED_WS_ORIGINS=
|
ALLOWED_WS_ORIGINS=
|
||||||
OJ2_DEV_USERNAME=student
|
OJ2_DEV_USERNAME=student
|
||||||
OJ2_DEV_PASSWORD=student123
|
OJ2_DEV_PASSWORD=student123
|
||||||
|
# 一言数据集(hitokoto 官方的 sentences-bundle)。默认 data/hitokoto;本机放在仓库旁边的话指过去,
|
||||||
|
# 读不到就回落到内置的三句,不影响启动
|
||||||
|
# HITOKOTO_DIRECTORY=../sentences-bundle
|
||||||
+31
-19
@@ -39,29 +39,40 @@ const fallbackQuotes = [
|
|||||||
{
|
{
|
||||||
hitokoto: "程序首先是写给人读的,其次才是让机器执行。",
|
hitokoto: "程序首先是写给人读的,其次才是让机器执行。",
|
||||||
from: "Structure and Interpretation of Computer Programs",
|
from: "Structure and Interpretation of Computer Programs",
|
||||||
|
fromWho: null,
|
||||||
|
type: null,
|
||||||
},
|
},
|
||||||
{ hitokoto: "把大问题拆成足够小的问题,答案就会浮现。", from: "判题狗" },
|
{
|
||||||
{ hitokoto: "一次没通过,只是多得到了一条线索。", from: "判题狗" },
|
hitokoto: "把大问题拆成足够小的问题,答案就会浮现。",
|
||||||
]
|
from: "判题狗",
|
||||||
|
fromWho: null,
|
||||||
|
type: null,
|
||||||
|
},
|
||||||
|
{ hitokoto: "一次没通过,只是多得到了一条线索。", from: "判题狗", fromWho: null, type: null },
|
||||||
|
] satisfies Quote[]
|
||||||
|
|
||||||
// categories.json 里的 path 形如 "./sentences/a.json",12 个分类合计 20MB。
|
// categories.json 里的 path 形如 "./sentences/a.json",12 个分类合计 20MB。
|
||||||
// 按分类懒加载并常驻缓存,但**只留前端用得上的两个字段** —— 原样缓存的话,
|
// 按分类懒加载并常驻缓存,但**只留前端用得上的几个字段**(句子、出处、谁说的、分类名)——
|
||||||
// 解析后的对象要占 60~100MB,而 api 容器只有 512m。裁完全量也就几 MB。
|
// 原样缓存的话,解析后的对象要占 60~100MB,而 api 容器只有 512m。裁完全量也就几 MB。
|
||||||
let categoryPaths: string[] | null = null
|
let categories: { path: string; name: string | null }[] | null = null
|
||||||
const sentenceCache = new Map<string, Quote[]>()
|
const sentenceCache = new Map<string, Quote[]>()
|
||||||
|
|
||||||
async function loadSentences(path: string) {
|
async function loadSentences(path: string, type: string | null) {
|
||||||
const cached = sentenceCache.get(path)
|
const cached = sentenceCache.get(path)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
const raw = (await Bun.file(resolve(config.hitokotoDirectory, path)).json()) as {
|
const raw = (await Bun.file(resolve(config.hitokotoDirectory, path)).json()) as {
|
||||||
hitokoto?: unknown
|
hitokoto?: unknown
|
||||||
from?: unknown
|
from?: unknown
|
||||||
|
from_who?: unknown
|
||||||
}[]
|
}[]
|
||||||
|
const text = (value: unknown) => (typeof value === "string" && value.trim() ? value.trim() : null)
|
||||||
const rows = (Array.isArray(raw) ? raw : [])
|
const rows = (Array.isArray(raw) ? raw : [])
|
||||||
.filter((it) => typeof it.hitokoto === "string" && it.hitokoto.length > 0)
|
.filter((it) => typeof it.hitokoto === "string" && it.hitokoto.length > 0)
|
||||||
.map((it) => ({
|
.map((it) => ({
|
||||||
hitokoto: it.hitokoto as string,
|
hitokoto: it.hitokoto as string,
|
||||||
from: typeof it.from === "string" ? it.from : "佚名",
|
from: text(it.from) ?? "佚名",
|
||||||
|
fromWho: text(it.from_who),
|
||||||
|
type,
|
||||||
}))
|
}))
|
||||||
if (rows.length === 0) throw new Error(`empty hitokoto category: ${path}`)
|
if (rows.length === 0) throw new Error(`empty hitokoto category: ${path}`)
|
||||||
sentenceCache.set(path, rows)
|
sentenceCache.set(path, rows)
|
||||||
@@ -69,18 +80,19 @@ async function loadSentences(path: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function randomQuote() {
|
async function randomQuote() {
|
||||||
if (!categoryPaths) {
|
if (!categories) {
|
||||||
const categories = (await Bun.file(
|
const list = (await Bun.file(resolve(config.hitokotoDirectory, "categories.json")).json()) as {
|
||||||
resolve(config.hitokotoDirectory, "categories.json"),
|
path?: unknown
|
||||||
).json()) as { path?: string }[]
|
name?: unknown
|
||||||
const paths = categories
|
}[]
|
||||||
.map((it) => it.path)
|
const parsed = list
|
||||||
.filter((it): it is string => typeof it === "string")
|
.filter((it): it is { path: string; name?: unknown } => typeof it.path === "string")
|
||||||
if (paths.length === 0) throw new Error("no hitokoto categories")
|
.map((it) => ({ path: it.path, name: typeof it.name === "string" ? it.name : null }))
|
||||||
categoryPaths = paths
|
if (parsed.length === 0) throw new Error("no hitokoto categories")
|
||||||
|
categories = parsed
|
||||||
}
|
}
|
||||||
const path = categoryPaths[Math.floor(Math.random() * categoryPaths.length)]!
|
const category = categories[Math.floor(Math.random() * categories.length)]!
|
||||||
const sentences = await loadSentences(path)
|
const sentences = await loadSentences(category.path, category.name)
|
||||||
return sentences[Math.floor(Math.random() * sentences.length)]!
|
return sentences[Math.floor(Math.random() * sentences.length)]!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -746,9 +746,8 @@ const gridColumns = computed(() =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hitokoto {
|
.hitokoto {
|
||||||
flex: 0 1 420px;
|
flex: 0 1 460px;
|
||||||
max-width: 420px;
|
max-width: 460px;
|
||||||
font-size: 13px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.anon {
|
.anon {
|
||||||
|
|||||||
@@ -1,71 +1,180 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import type { Quote } from "@oj2/contract"
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
import { getHitokoto } from "../api"
|
import { getHitokoto } from "../api"
|
||||||
|
|
||||||
const hitokoto = reactive({
|
/**
|
||||||
sentence: "",
|
* 一言(设计稿「一言重设计」A 版):大引号 + 一行句子 + 「—— 谁《出处》· 分类」+ 一个看得见的「换一句」。
|
||||||
from: "",
|
* 原来只能点句子换,没人知道;出处被截成「来自 Structure and Interpretatio…」。
|
||||||
})
|
* 长句(数据集 p90 44 字、最长 255 字)一行放不下,点句子弹出全文。
|
||||||
|
* 留着它是给学生一点「无聊的乐趣」,别去掉、别挪到看不见的地方。
|
||||||
|
*/
|
||||||
|
const theme = useThemeVars()
|
||||||
|
|
||||||
|
const quote = ref<Quote | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
async function receive() {
|
async function receive() {
|
||||||
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getHitokoto()
|
quote.value = await getHitokoto()
|
||||||
// 契约是 string | Record —— 一言数据集不在仓库里,形状留了余地
|
} catch {
|
||||||
const quote = res
|
// 拿不到就不显示,别在这里摆一句「获取失败」
|
||||||
if (typeof quote === "string") {
|
} finally {
|
||||||
hitokoto.sentence = quote
|
loading.value = false
|
||||||
hitokoto.from = ""
|
|
||||||
} else {
|
|
||||||
hitokoto.sentence = String(quote.hitokoto ?? "")
|
|
||||||
hitokoto.from = String(quote.from ?? "")
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
hitokoto.sentence = "获取一言失败,请点击重试"
|
|
||||||
hitokoto.from = "DEV"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(receive)
|
onMounted(receive)
|
||||||
|
|
||||||
|
/** 数据集里拿来充数的出处:写出来没有信息量 */
|
||||||
|
const FILLER = new Set(["佚名", "无", "原创", "网络", "其他", "互联网"])
|
||||||
|
|
||||||
|
const source = computed(() => {
|
||||||
|
const q = quote.value
|
||||||
|
if (!q) return ""
|
||||||
|
const work = q.from && !FILLER.has(q.from) && q.from !== q.type ? q.from : ""
|
||||||
|
const who = q.fromWho && !FILLER.has(q.fromWho) && q.fromWho !== work ? q.fromWho : ""
|
||||||
|
// 内置的兜底句没有分类,出处(「判题狗」)不是作品名,不加书名号
|
||||||
|
if (!q.type) return who || work
|
||||||
|
if (who && work) return `${who}《${work}》`
|
||||||
|
return work ? `《${work}》` : who
|
||||||
|
})
|
||||||
|
|
||||||
|
// 全文弹层在 body 下面,样式里的 v-bind 变量够不着,主题色就地给
|
||||||
|
const popVars = computed(() => ({
|
||||||
|
"--pop-text": theme.value.textColor1,
|
||||||
|
"--pop-muted": theme.value.textColor3,
|
||||||
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="hitokoto" :title="hitokoto.sentence" @click="receive" v-if="hitokoto.sentence">
|
<div v-if="quote" class="hitokoto">
|
||||||
<span class="from">{{ "来自 " + hitokoto.from }}</span>
|
<Icon icon="ph:quotes-fill" class="mark" :width="22" />
|
||||||
<span class="sentence">{{ hitokoto.sentence }}</span>
|
<n-popover trigger="click" placement="bottom-end" :width="420">
|
||||||
|
<template #trigger>
|
||||||
|
<div class="body" title="点一下看全文">
|
||||||
|
<span class="sentence">{{ quote.hitokoto }}</span>
|
||||||
|
<span class="source">
|
||||||
|
<template v-if="source">—— {{ source }}</template>
|
||||||
|
<template v-if="quote.type">
|
||||||
|
<template v-if="source"> · </template><span class="type">{{ quote.type }}</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<div class="full" :style="popVars">
|
||||||
|
<div class="full-text">{{ quote.hitokoto }}</div>
|
||||||
|
<div class="full-source">
|
||||||
|
<template v-if="source">—— {{ source }}</template>
|
||||||
|
<template v-if="quote.type"
|
||||||
|
><template v-if="source"> · </template>{{ quote.type }}</template
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-popover>
|
||||||
|
<button
|
||||||
|
class="refresh"
|
||||||
|
:class="{ spinning: loading }"
|
||||||
|
:disabled="loading"
|
||||||
|
aria-label="换一句"
|
||||||
|
title="换一句"
|
||||||
|
@click="receive"
|
||||||
|
>
|
||||||
|
<Icon icon="ph:arrow-clockwise-bold" :width="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* 短句时整块靠右,别让「换一句」按钮孤零零地挂在中间 */
|
||||||
.hitokoto {
|
.hitokoto {
|
||||||
cursor: pointer;
|
|
||||||
height: 36px;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flow-root;
|
display: flex;
|
||||||
overflow: hidden;
|
align-items: center;
|
||||||
text-align: right;
|
justify-content: flex-end;
|
||||||
line-height: 18px;
|
gap: 10px;
|
||||||
word-break: break-all;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hitokoto::before {
|
.mark {
|
||||||
content: "";
|
flex: none;
|
||||||
float: right;
|
color: v-bind("theme.successColor");
|
||||||
width: 0;
|
opacity: 0.3;
|
||||||
height: 18px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hitokoto .sentence {
|
.body {
|
||||||
text-align: right;
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hitokoto .from {
|
.sentence,
|
||||||
float: right;
|
.source {
|
||||||
clear: right;
|
|
||||||
max-width: min(45%, 260px);
|
|
||||||
margin-left: 8px;
|
|
||||||
text-align: right;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 12px;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sentence {
|
||||||
|
font-size: 13px;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
color: grey;
|
color: v-bind("theme.textColor1");
|
||||||
|
}
|
||||||
|
|
||||||
|
.source {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.type {
|
||||||
|
color: v-bind("theme.primaryColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
flex: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid v-bind("theme.borderColor");
|
||||||
|
border-radius: 14px;
|
||||||
|
background: transparent;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh:hover {
|
||||||
|
border-color: v-bind("theme.primaryColor");
|
||||||
|
color: v-bind("theme.primaryColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh.spinning svg {
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-text {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: var(--pop-text);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-source {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--pop-muted);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -16,7 +16,17 @@ export const onlineCountSchema = z.object({
|
|||||||
count: z.number().int().nonnegative(),
|
count: z.number().int().nonnegative(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const quoteSchema = z.union([z.string(), z.record(z.string(), z.unknown())])
|
/**
|
||||||
|
* 一言。`from` 是出处(作品),`fromWho` 是谁说的,`type` 是数据集的分类名(动画、诗词……),
|
||||||
|
* 后两个数据集里可能没有。原来是 string | Record 的宽类型,前端拿到再猜形状;
|
||||||
|
* 出参是后端自己拼的,直接给定形
|
||||||
|
*/
|
||||||
|
export const quoteSchema = z.object({
|
||||||
|
hitokoto: z.string(),
|
||||||
|
from: z.string(),
|
||||||
|
fromWho: z.string().nullable(),
|
||||||
|
type: z.string().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
export type WebsiteConfig = z.infer<typeof websiteConfigSchema>
|
export type WebsiteConfig = z.infer<typeof websiteConfigSchema>
|
||||||
export type Quote = z.infer<typeof quoteSchema>
|
export type Quote = z.infer<typeof quoteSchema>
|
||||||
|
|||||||
Reference in new issue
Block a user