Compare commits
5
Commits
e852bf4498
...
adea781b8e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adea781b8e | ||
|
|
c7a8e05385 | ||
|
|
dab53c9098 | ||
|
|
99d3d99128 | ||
|
|
9cc7f0f60c |
No files matched your search
@@ -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
@@ -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)]!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</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>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user