Compare commits

...
5 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 adea781b8e fix(一言): 句子下面多挂了个「· 游戏」「· 动画」的分类,看着啰嗦——不显示分类;本机不用再配 env 就能读到一言数据集
Deploy / deploy (push) Canceled after 0s
- 出处只写「—— 谁《出处》」。接口里的分类还在,只用来判断出处要不要加书名号
- HITOKOTO_DIRECTORY 默认值改成仓库旁边的 ../sentences-bundle,.env.example 里那几行撤掉。
  线上不受影响:镜像里 Dockerfile 写死了 /data/hitokoto

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:14:03 +08:00
xuyueandClaude Opus 5.5 c7a8e05385 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>
2026-10-09 14:19:55 +08:00
xuyueandClaude Opus 5.5 dab53c9098 fix(题目列表): 筛空了只说「没有符合条件的题」,点「清空筛选」连左边的知识点一起清掉——说清楚是哪个条件,每个条件一个按钮只去掉它
例如「『函数定义』里没有『画流程图』的题」+「看全部类型」;几个条件一起时每个条件各一个按钮
(看全部类型 / 看全部难度 / 看所有出题者 / 清掉搜索 / 关掉只看没做完)。
「你都做对了」只在「只看没做完」是唯一条件时才说,带着别的筛选空了不再误报。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:10:54 +08:00
xuyueandClaude Opus 5.5 99d3d99128 fix(题目列表): 「全部类型」里 0 道的类型还能点,点了只得到一个空列表——0 道的置灰不能点
已经选中的那一项除外:选了「画流程图」再切到没有这类题的知识点,它仍显示为选中,好让人看出列表为什么空。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:08:30 +08:00
xuyueandClaude Opus 5.5 9cc7f0f60c fix(题目列表): 左边换了知识点,「全部类型」下拉里还是全站的 13 / 13 / 14 道,看着像没生效——题数跟着当前知识点和筛选走
「全部类型」本身也带上题数。手机「筛选」里的类型下拉同样带数。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:06:43 +08:00
5 changed files with 321 additions and 87 deletions

No files matched your search

+3 -3
View File
@@ -77,7 +77,7 @@ const envSchema = z.object({
AVATAR_DIRECTORY: text("data/avatar"), AVATAR_DIRECTORY: text("data/avatar"),
TEST_CASE_DIRECTORY: text("data/test_case"), TEST_CASE_DIRECTORY: text("data/test_case"),
UPLOAD_DIRECTORY: text("data/upload"), UPLOAD_DIRECTORY: text("data/upload"),
HITOKOTO_DIRECTORY: text("data/hitokoto"), HITOKOTO_DIRECTORY: text("../sentences-bundle"),
ALLOWED_WS_ORIGINS: text(""), ALLOWED_WS_ORIGINS: text(""),
UPLOAD_URI_PREFIX: text("/public/upload"), UPLOAD_URI_PREFIX: text("/public/upload"),
AVATAR_URI_PREFIX: text("/public/avatar"), AVATAR_URI_PREFIX: text("/public/avatar"),
@@ -150,8 +150,8 @@ export const config = {
// 判题沙箱把这个目录挂成只读的 /test_case,两边必须指同一处 // 判题沙箱把这个目录挂成只读的 /test_case,两边必须指同一处
testCaseDirectory: repoPath(env.TEST_CASE_DIRECTORY), testCaseDirectory: repoPath(env.TEST_CASE_DIRECTORY),
uploadDirectory: repoPath(env.UPLOAD_DIRECTORY), uploadDirectory: repoPath(env.UPLOAD_DIRECTORY),
// 一言数据集(hitokoto.cn 官方导出),和旧后端读同一份:容器里是 /data/hitokoto。 // 一言数据集(hitokoto.cn 官方的 sentences-bundle)。线上容器里是 /data/hitokoto(Dockerfile 写死,
// 本机 dev 默认路径下没有这份数据,读不到就回落到内置的几条,不影响启动。 // 这个默认值管不到);本机默认读仓库旁边那份 ../sentences-bundle,读不到就回落到内置的几条,不影响启动。
hitokotoDirectory: repoPath(env.HITOKOTO_DIRECTORY), hitokotoDirectory: repoPath(env.HITOKOTO_DIRECTORY),
/** /**
* WebSocket 升级时额外放行的来源(逗号分隔的完整 origin,如 https://oj.example.com)。 * WebSocket 升级时额外放行的来源(逗号分隔的完整 origin,如 https://oj.example.com)。
+32 -20
View File
@@ -34,34 +34,45 @@ siteRoutes.get("/site/online", async (c) => {
return success(c, { count: await onlineCount() } satisfies OnlineCount) return success(c, { count: await onlineCount() } satisfies OnlineCount)
}) })
// 数据集读不到时的兜底(本机 dev 没挂 data/hitokoto 就会走这里) // 数据集读不到时的兜底(本机旁边没有 sentences-bundle 就会走这里)
const fallbackQuotes = [ 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)]!
} }
+134 -19
View File
@@ -56,7 +56,7 @@ const theme = useThemeVars()
const tone = useTone() const tone = useTone()
const { isDesktop, isMobile } = useBreakpoints() const { isDesktop, isMobile } = useBreakpoints()
const { query, clearQuery } = usePagination<ProblemQuery>( const { query } = usePagination<ProblemQuery>(
{ {
keyword: useRouteQuery("keyword", "").value, keyword: useRouteQuery("keyword", "").value,
difficulty: useRouteQuery("difficulty", "").value, difficulty: useRouteQuery("difficulty", "").value,
@@ -202,6 +202,7 @@ async function loadAuthors(show: boolean) {
// ---- 类型 ---- // ---- 类型 ----
const showTypeMenu = ref(false) const showTypeMenu = ref(false)
const [showMobileFilters, toggleMobileFilters] = useToggle(false)
// 弹层被传送到 body 下面,样式里的 v-bind 变量挂在页面根上、够不着它,主题色只能就地给 // 弹层被传送到 body 下面,样式里的 v-bind 变量挂在页面根上、够不着它,主题色只能就地给
const menuVars = computed(() => ({ const menuVars = computed(() => ({
"--menu-bg": theme.value.popoverColor, "--menu-bg": theme.value.popoverColor,
@@ -213,13 +214,57 @@ const menuVars = computed(() => ({
"--menu-check": theme.value.successColor, "--menu-check": theme.value.successColor,
})) }))
// 下拉里每类后面的题数:一共三十几道,第一次打开时数一下就行 // 下拉里每类后面的题数:一共三十几道,第一次打开时数一下就行
const typeCounts = ref<Partial<Record<ProblemTypeFilter, number>>>({}) // 下拉里每类后面的题数,跟着左栏的知识点和其它筛选走(类型本身除外):
watch(showTypeMenu, async (open) => { // 选了「条件判断」就说「条件判断」里有几道画流程图的题,不然一直是全站那个固定数,看着像没生效。
if (!open || Object.keys(typeCounts.value).length) return // 只在下拉打开时数,条件没变就不重数
const counts = await Promise.all(TYPES.map((type) => getProblemList(0, 1, { type }))) type Counts = Partial<Record<ProblemTypeFilter | "all", number>>
typeCounts.value = Object.fromEntries(TYPES.map((type, i) => [type, counts[i]!.total])) const typeCounts = ref<Counts>({})
let countedFor = ""
const countFilters = computed(() => ({
keyword: query.keyword,
tag: query.tag,
difficulty: query.difficulty,
author: query.author,
undone: undoneOn.value ? "1" : "",
}))
async function refreshTypeCounts() {
const key = JSON.stringify(countFilters.value)
if (key === countedFor) return
countedFor = key
const kinds = ["all", ...TYPES] as const
const results = await Promise.all(
kinds.map((kind) =>
getProblemList(0, 1, { ...countFilters.value, type: kind === "all" ? "" : kind }),
),
)
// 数的过程中条件又变了:这一份已经过时,留给下一次
if (key !== JSON.stringify(countFilters.value)) return
typeCounts.value = Object.fromEntries(kinds.map((kind, i) => [kind, results[i]!.total]))
}
watch([showTypeMenu, () => showMobileFilters.value, countFilters], () => {
if (showTypeMenu.value || showMobileFilters.value) refreshTypeCounts()
}) })
/**
* 当前条件下一道都没有的类型置灰:点了只会得到一个空列表。
* 已经选中的那一项除外 —— 选了「画流程图」再切到一个没有这类题的知识点,它变成 0 道,
* 置灰的话看着像没选中,人也不知道列表为什么空了
*/
function typeEmpty(kind: ProblemTypeFilter) {
return typeCounts.value[kind] === 0 && query.type !== kind
}
const mobileTypeOptions = computed(() =>
TYPES.map((kind) => ({
disabled: typeEmpty(kind),
label:
typeCounts.value[kind] === undefined
? PROBLEM_TYPE_LABEL[kind]
: `${PROBLEM_TYPE_LABEL[kind]}(${typeCounts.value[kind]} 道)`,
value: kind,
})),
)
function pickType(value: string) { function pickType(value: string) {
query.type = value query.type = value
showTypeMenu.value = false showTypeMenu.value = false
@@ -254,7 +299,55 @@ const nextTag = computed(() => {
} }
return best return best
}) })
const allDone = computed(() => loaded.value && undoneOn.value && total.value === 0 && !!mine.value) // 只在「只看没做完」是唯一的条件时才说「你都做对了」:带着类型、难度这些筛选空了,
// 不能说成整个知识点都做对了
const allDone = computed(
() =>
loaded.value && undoneOn.value && total.value === 0 && !!mine.value && !hasExtraFilter.value,
)
/**
* 筛空了:说清楚是哪些条件把题筛没了,每个条件一个按钮,只去掉那一个。
* 原来只有一个「清空筛选」,点了连左栏的知识点一起清掉,人又得重新找回来
*/
const emptyConditions = computed(() => {
const list: { label: string; action: string; clear: () => void }[] = []
if (query.type) {
const label = PROBLEM_TYPE_LABEL[query.type as ProblemTypeFilter]
list.push({ label: `「${label}」的`, action: "看全部类型", clear: () => (query.type = "") })
}
if (query.difficulty) {
const label = DIFFICULTIES.find((d) => d.value === query.difficulty)?.label ?? ""
list.push({ label, action: "看全部难度", clear: () => (query.difficulty = "") })
}
if (query.author)
list.push({
label: `${query.author} 出的`,
action: "看所有出题者",
clear: () => (query.author = ""),
})
if (query.keyword)
list.push({
label: `和「${query.keyword}」对得上的`,
action: "清掉搜索",
clear: () => (query.keyword = ""),
})
if (undoneOn.value)
list.push({
label: "没做完的",
action: "关掉只看没做完",
clear: () => (undoneOn.value = false),
})
return list
})
const emptyText = computed(() => {
const where = query.tag ? `「${query.tag}」里` : "题库里"
const conds = emptyConditions.value
if (conds.length === 0) return `${where}还没有题`
if (conds.length === 1) return `${where}没有${conds[0]!.label}题`
return `${where}没有同时符合这些条件的题`
})
function knowledgeOf(row: ProblemRow) { function knowledgeOf(row: ProblemRow) {
return row.tags.filter((name) => tagByName.value.get(name)?.category === "knowledge").join(" · ") return row.tags.filter((name) => tagByName.value.get(name)?.category === "knowledge").join(" · ")
@@ -272,8 +365,6 @@ function difficultyStyle(row: ProblemRow) {
return { color: t.color, background: t.background } return { color: t.color, background: t.background }
} }
const [showMobileFilters, toggleMobileFilters] = useToggle(false)
// 手机上知识点横着滑:带着 ?tag= 进来、或者点了靠后的知识点,把它滑到看得见的地方 // 手机上知识点横着滑:带着 ?tag= 进来、或者点了靠后的知识点,把它滑到看得见的地方
const chipsRef = ref<HTMLElement | null>(null) const chipsRef = ref<HTMLElement | null>(null)
watch( watch(
@@ -367,12 +458,21 @@ const gridColumns = computed(() =>
<div class="type-menu" :style="menuVars"> <div class="type-menu" :style="menuVars">
<button :class="{ on: !query.type }" @click="pickType('')"> <button :class="{ on: !query.type }" @click="pickType('')">
<span class="check"><Icon v-if="!query.type" icon="ph:check-bold" /></span> <span class="check"><Icon v-if="!query.type" icon="ph:check-bold" /></span>
<span class="body"><span class="label">全部类型</span></span> <span class="body">
<span class="label-line">
<span class="label">全部类型</span>
<span v-if="typeCounts.all !== undefined" class="n"
>{{ typeCounts.all }} 道</span
>
</span>
</span>
</button> </button>
<button <button
v-for="kind in TYPES" v-for="kind in TYPES"
:key="kind" :key="kind"
:class="{ on: query.type === kind }" :class="{ on: query.type === kind }"
:disabled="typeEmpty(kind)"
:title="typeEmpty(kind) ? '这里没有这类题' : undefined"
@click="pickType(kind)" @click="pickType(kind)"
> >
<span class="check"><Icon v-if="query.type === kind" icon="ph:check-bold" /></span> <span class="check"><Icon v-if="query.type === kind" icon="ph:check-bold" /></span>
@@ -468,8 +568,12 @@ const gridColumns = computed(() =>
</div> </div>
</div> </div>
<div v-else-if="loaded && !problems.length" class="empty"> <div v-else-if="loaded && !problems.length" class="empty">
<b>没有符合条件的题</b> <b>{{ emptyText }}</b>
<n-button size="small" @click="clearQuery">清空筛选</n-button> <div class="empty-actions">
<n-button v-for="c in emptyConditions" :key="c.action" @click="c.clear">
{{ c.action }}
</n-button>
</div>
</div> </div>
</div> </div>
<!-- Pagination 根上自带 width: 100%,内边距得加在外面一层,不然右边溢出被截掉 --> <!-- Pagination 根上自带 width: 100%,内边距得加在外面一层,不然右边溢出被截掉 -->
@@ -543,7 +647,7 @@ const gridColumns = computed(() =>
size="small" size="small"
placeholder="全部类型" placeholder="全部类型"
clearable clearable
:options="TYPES.map((k) => ({ label: PROBLEM_TYPE_LABEL[k], value: k }))" :options="mobileTypeOptions"
@update:value="(v: string | null) => (query.type = v ?? '')" @update:value="(v: string | null) => (query.type = v ?? '')"
/> />
<n-select <n-select
@@ -585,8 +689,12 @@ const gridColumns = computed(() =>
<n-button size="small" @click="undoneOn = false">关掉只看没做完</n-button> <n-button size="small" @click="undoneOn = false">关掉只看没做完</n-button>
</div> </div>
<div v-else-if="loaded && !problems.length" class="empty"> <div v-else-if="loaded && !problems.length" class="empty">
<b>没有符合条件的题</b> <b>{{ emptyText }}</b>
<n-button size="small" @click="clearQuery">清空筛选</n-button> <div class="empty-actions">
<n-button v-for="c in emptyConditions" :key="c.action" size="small" @click="c.clear">
{{ c.action }}
</n-button>
</div>
</div> </div>
</div> </div>
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" /> <Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
@@ -638,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 {
@@ -827,10 +934,16 @@ const gridColumns = computed(() =>
color: var(--menu-text); color: var(--menu-text);
} }
.type-menu button:hover { .type-menu button:hover:not(:disabled) {
background: var(--menu-hover); background: var(--menu-hover);
} }
/* 没有这类题:整项淡下去(标签自己的颜色也一起),不能点 */
.type-menu button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.type-menu button.on { .type-menu button.on {
background: var(--menu-on); background: var(--menu-on);
} }
@@ -1049,6 +1162,8 @@ const gridColumns = computed(() =>
.empty-actions { .empty-actions {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px; gap: 8px;
margin-top: 6px; margin-top: 6px;
} }
+140 -44
View File
@@ -1,71 +1,167 @@
<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: "", * 分类(动画、诗词……)不显示;接口里的 type 只用来判断出处要不要加书名号。
}) * 原来只能点句子换,没人知道;出处被截成「来自 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 v-if="source" class="source">—— {{ source }}</span>
</div> </div>
</template> </template>
<div class="full" :style="popVars">
<div class="full-text">{{ quote.hitokoto }}</div>
<div v-if="source" class="full-source">—— {{ source }}</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");
}
.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>
+12 -1
View File
@@ -16,7 +16,18 @@ 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>