1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2025-12-08 13:49:37 +00:00

feat: 添加一言卡片及其设置功能,支持动态内容刷新

This commit is contained in:
Sunwuyuan 2025-12-07 16:21:02 +08:00
parent 4627605178
commit 7d90e6ee33
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
7 changed files with 468 additions and 4 deletions

View File

@ -0,0 +1,184 @@
<template>
<v-card
class="hitokoto-card"
elevation="2"
border
rounded="xl"
:loading="loading"
height="100%"
@click="fetchSentence"
>
<v-card-text class="pa-6 d-flex flex-column justify-center" style="height: 100%">
<div class="text-h6 font-weight-medium mb-4 serif-font" style="white-space: pre-wrap; line-height: 1.6;">
{{ sentence }}
</div>
<div class="text-subtitle-2 text-medium-emphasis text-right serif-font">
<span v-if="author" class="mr-2">{{ author }}</span>
<span v-if="origin">{{ origin }}</span>
</div>
</v-card-text>
</v-card>
</template>
<script>
import { SettingsManager, watchSettings } from '@/utils/settings'
import dataProvider from '@/utils/dataProvider'
import axios from 'axios'
export default {
name: 'HitokotoCard',
data() {
return {
enabled: false,
refreshInterval: 60,
kvConfig: {
sources: ['hitokoto'],
sensitiveWords: []
},
sentence: '',
author: '',
origin: '',
loading: false,
timer: null,
unwatch: null
}
},
async mounted() {
this.loadLocalSettings()
await this.loadKvSettings()
this.fetchSentence()
this.startTimer()
this.unwatch = watchSettings(() => {
this.loadLocalSettings()
this.startTimer()
})
},
beforeUnmount() {
this.stopTimer()
if (this.unwatch) {
this.unwatch()
}
},
methods: {
loadLocalSettings() {
this.enabled = SettingsManager.getSetting('hitokoto.enabled')
this.refreshInterval = SettingsManager.getSetting('hitokoto.refreshInterval')
},
async loadKvSettings() {
try {
const res = await dataProvider.loadData('sentence-info')
let data = res
if (res && res.data) {
data = res.data
}
if (data) {
this.kvConfig = {
sources: Array.isArray(data.sources) && data.sources.length > 0 ? data.sources : ['hitokoto'],
sensitiveWords: data.sensitiveWords ? data.sensitiveWords.split(/[,]/).map(w => w.trim()).filter(w => w) : [],
jinrishiciToken: data.jinrishiciToken
}
}
} catch (e) {
console.error('Failed to load sentence-info', e)
}
},
startTimer() {
if (this.timer) clearInterval(this.timer)
if (this.refreshInterval > 0) {
this.timer = setInterval(this.fetchSentence, this.refreshInterval * 1000)
}
},
stopTimer() {
if (this.timer) clearInterval(this.timer)
},
async fetchSentence() {
if (this.loading) return
this.loading = true
try {
// Pick random source
const sources = this.kvConfig.sources
const source = sources[Math.floor(Math.random() * sources.length)]
let data = null
let content = ''
let author = ''
let origin = ''
if (source === 'hitokoto') {
const res = await axios.get('https://v1.hitokoto.cn/')
data = res.data
content = data.hitokoto
author = data.from_who
origin = data.from
} else if (source === 'zhaoyu') {
const res = await axios.get('https://hub.saintic.com/openservice/sentence/all.json')
if (res.data.success) {
data = res.data.data
content = data.sentence || data.content || data.name
author = data.author
origin = data.name || data.origin
}
} else if (source === 'jinrishici') {
if (this.kvConfig.jinrishiciToken) {
const res = await axios.get('https://v2.jinrishici.com/sentence', {
headers: {
'X-User-Token': this.kvConfig.jinrishiciToken
}
})
if (res.data.status === 'success') {
data = res.data.data
content = data.content
author = data.origin.author
origin = data.origin.title
}
} else {
// Token missing, maybe retry with another source or just fail silently
// For now, let's just log it. The settings page should handle token generation.
console.warn('Jinrishici token missing. Please enable it in settings to generate a token.')
// Retry to pick another source to avoid empty card
this.loading = false
return this.fetchSentence()
}
}
if (content) {
// Sensitive word check
const hasSensitiveWord = this.kvConfig.sensitiveWords.some(word => content.includes(word))
if (hasSensitiveWord) {
// Retry
this.loading = false
return this.fetchSentence()
}
this.sentence = content
this.author = author || ''
this.origin = origin || '未知'
}
} catch (e) {
console.error('Failed to fetch sentence', e)
this.sentence = '获取失败'
this.author = ''
this.origin = ''
} finally {
this.loading = false
}
}
}
}
</script>
<style scoped>
.hitokoto-card {
cursor: pointer;
transition: all 0.3s ease;
}
.hitokoto-card:hover {
transform: translateY(-2px);
}
.serif-font {
font-family: "Noto Serif SC", "Source Han Serif SC", "Source Han Serif", source-han-serif-sc, "Songti SC", "SimSun", "Hiragino Sans GB", system-ui, serif;
}
</style>

View File

@ -0,0 +1,154 @@
<template>
<div>
<setting-group title="一言设置" icon="mdi-comment-quote">
<setting-item setting-key="hitokoto.enabled" />
<setting-item setting-key="hitokoto.refreshInterval" />
</setting-group>
<setting-group title="数据源配置" icon="mdi-cloud-sync" class="mt-4">
<div class="text-caption text-grey px-4 pt-2 pb-2">以下配置将同步到云端对所有连接此班级的设备生效</div>
<v-list-item>
<v-list-item-title class="mb-2">启用数据源</v-list-item-title>
<div class="d-flex flex-wrap gap-2">
<v-checkbox
v-model="kvConfig.sources"
label="一言 (Hitokoto)"
value="hitokoto"
hide-details
density="compact"
class="mr-4"
:disabled="loading"
@update:model-value="saveKvSettings"
/>
<v-checkbox
v-model="kvConfig.sources"
label="诏预 (Zhaoyu)"
value="zhaoyu"
hide-details
density="compact"
class="mr-4"
:disabled="loading"
@update:model-value="saveKvSettings"
/>
<v-checkbox
v-model="kvConfig.sources"
label="今日诗词 (Jinrishici)"
value="jinrishici"
hide-details
density="compact"
:disabled="loading"
@update:model-value="saveKvSettings"
/>
</div>
</v-list-item>
<v-list-item v-if="kvConfig.sources.includes('jinrishici')">
<v-text-field
v-model="kvConfig.jinrishiciToken"
label="今日诗词 Token"
variant="outlined"
density="comfortable"
:disabled="loading"
hint="留空则自动获取,也可以手动输入已有 Token"
persistent-hint
class="mt-2"
@change="saveKvSettings"
/>
</v-list-item>
<v-list-item>
<v-textarea
v-model="kvConfig.sensitiveWords"
:disabled="loading"
label="敏感词过滤 (用逗号分隔)"
variant="outlined"
rows="3"
auto-grow
hide-details
class="mt-2 mb-2"
@change="saveKvSettings"
/>
</v-list-item>
<div v-if="loading" class="text-center pb-4">
<v-progress-circular indeterminate size="24" color="primary" />
<span class="ml-2 text-caption">正在同步配置...</span>
</div>
</setting-group>
</div>
</template>
<script>
import SettingGroup from './settings/SettingGroup.vue'
import SettingItem from './settings/SettingItem.vue'
import dataProvider from '@/utils/dataProvider'
import axios from 'axios'
export default {
name: 'HitokotoSettings',
components: {
SettingGroup,
SettingItem
},
data() {
return {
kvConfig: {
sources: ['hitokoto'],
sensitiveWords: '',
jinrishiciToken: null
},
loading: false
}
},
mounted() {
this.loadKvSettings()
},
methods: {
async loadKvSettings() {
this.loading = true
try {
const res = await dataProvider.loadData('sentence-info')
let data = res
if (res && res.data) {
data = res.data
}
if (data) {
this.kvConfig = {
sources: Array.isArray(data.sources) ? data.sources : ['hitokoto'],
sensitiveWords: data.sensitiveWords || '',
jinrishiciToken: data.jinrishiciToken
}
}
} catch (e) {
console.error('Failed to load sentence-info', e)
} finally {
this.loading = false
}
},
async saveKvSettings() {
this.loading = true
try {
// Check if jinrishici is enabled and token is missing
if (this.kvConfig.sources.includes('jinrishici') && !this.kvConfig.jinrishiciToken) {
try {
const tokenRes = await axios.get('https://v2.jinrishici.com/token')
if (tokenRes.data.status === 'success') {
this.kvConfig.jinrishiciToken = tokenRes.data.data
}
} catch (e) {
console.error('Failed to get jinrishici token', e)
}
}
await dataProvider.saveData('sentence-info', this.kvConfig)
} catch (e) {
console.error('Failed to save sentence-info', e)
} finally {
this.loading = false
}
}
}
}
</script>

View File

@ -4,15 +4,21 @@
<div
v-for="item in sortedItems"
:key="item.key"
ref="items"
:data-key="item.key"
:style="{
'grid-row-end': `span ${item.rowSpan}`,
order: item.order,
}"
class="grid-item"
>
<!-- 一言卡片 -->
<div v-if="item.type === 'hitokoto'" style="height: 100%">
<hitokoto-card />
</div>
<!-- 出勤卡片 -->
<v-card
v-if="item.type === 'attendance'"
v-else-if="item.type === 'attendance'"
:class="{ 'glow-highlight': highlightedCards[item.key], 'cursor-not-allowed': isEditingDisabled, 'cursor-pointer': !isEditingDisabled }"
border
class="glow-track"
@ -178,8 +184,13 @@
</template>
<script>
import HitokotoCard from "@/components/HitokotoCard.vue";
export default {
name: "HomeworkGrid",
components: {
HitokotoCard,
},
props: {
sortedItems: {
type: Array,
@ -212,7 +223,76 @@ export default {
return this.$vuetify.display.mobile;
},
},
mounted() {
this.resizeObserver = new ResizeObserver(() => {
this.resizeAllGridItems();
});
// Observe the grid container for width changes
if (this.$refs.gridContainer) {
this.resizeObserver.observe(this.$refs.gridContainer);
}
// Initial resize
this.$nextTick(() => {
this.resizeAllGridItems();
// Observe all items
if (this.$refs.items) {
this.$refs.items.forEach(item => {
// Observe the content inside the grid item
if (item.firstElementChild) {
this.resizeObserver.observe(item.firstElementChild);
}
});
}
});
},
updated() {
// When items change, re-observe new items
this.$nextTick(() => {
this.resizeAllGridItems();
if (this.$refs.items) {
this.$refs.items.forEach(item => {
if (item.firstElementChild) {
this.resizeObserver.observe(item.firstElementChild);
}
});
}
});
},
beforeUnmount() {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
},
methods: {
resizeGridItem(item) {
const grid = this.$refs.gridContainer;
if (!grid) return;
const rowHeight = parseInt(window.getComputedStyle(grid).getPropertyValue('grid-auto-rows'));
const rowGap = parseInt(window.getComputedStyle(grid).getPropertyValue('gap'));
// Find the content element (v-card or div)
const content = item.firstElementChild;
if (!content) return;
// Calculate required span
// We use scrollHeight to get the full height of content
// Add a small buffer to prevent scrollbars
const contentHeight = content.getBoundingClientRect().height;
// Formula: span = ceil((contentHeight + gap) / (rowHeight + gap))
const rowSpan = Math.ceil((contentHeight + rowGap) / (rowHeight + rowGap));
item.style.gridRowEnd = `span ${rowSpan}`;
},
resizeAllGridItems() {
const items = this.$refs.items;
if (items) {
items.forEach(item => this.resizeGridItem(item));
}
},
handleCardClick(type, key) {
if (this.isEditingDisabled) {
this.$emit('disabled-click');

View File

@ -331,6 +331,7 @@ import AttendanceSidebar from "@/components/attendance/AttendanceSidebar.vue";
import AttendanceManagementDialog from "@/components/attendance/AttendanceManagementDialog.vue";
import HomeworkGrid from "@/components/home/HomeworkGrid.vue";
import HomeActions from "@/components/home/HomeActions.vue";
import HitokotoCard from "@/components/HitokotoCard.vue";
import dataProvider from "@/utils/dataProvider";
import {
getSetting,
@ -550,28 +551,46 @@ export default {
const subjectData = this.state.boardData.homework[subjectKey];
if (subjectData && subjectData.content) {
const lineCount = subjectData.content.split("\n").filter((line) => line.trim()).length;
// Estimate height in pixels: title(64) + padding(32) + lines * line-height(24) + extra
const estimatedHeight = 100 + lineCount * 24;
items.push({
key: subjectKey,
name: subjectKey,
type: 'homework',
content: subjectData.content,
order: subject.order,
rowSpan: Math.ceil((subjectData.content.split("\n").filter((line) => line.trim()).length + 1) * 0.8),
rowSpan: estimatedHeight, // Used for sorting only
});
}
}
//
if (getSetting("hitokoto.enabled")) {
items.push({
key: "hitokoto-card",
name: "一言",
type: "hitokoto",
order: 9998,
rowSpan: 150, // Default estimated height
});
}
//
for (const key in this.state.boardData.homework) {
if (key.startsWith('custom-')) {
const card = this.state.boardData.homework[key];
const lineCount = card.content.split("\n").filter((line) => line.trim()).length;
const estimatedHeight = 100 + lineCount * 24;
items.push({
key: key,
name: card.name,
type: 'custom',
content: card.content,
order: 9999, // Put at the end
rowSpan: Math.ceil((card.content.split("\n").filter((line) => line.trim()).length + 1) * 0.8),
rowSpan: estimatedHeight, // Used for sorting only
});
}
}

View File

@ -181,6 +181,10 @@
/>
</v-tabs-window-item>
<v-tabs-window-item value="hitokoto">
<hitokoto-settings border />
</v-tabs-window-item>
<v-tabs-window-item value="randomPicker">
<random-picker-card :is-mobile="isMobile" border/>
</v-tabs-window-item>
@ -272,6 +276,7 @@ import RandomPickerCard from "@/components/settings/cards/RandomPickerCard.vue";
import HomeworkTemplateCard from "@/components/settings/cards/HomeworkTemplateCard.vue";
import SubjectManagementCard from "@/components/settings/cards/SubjectManagementCard.vue";
import KvDatabaseCard from "@/components/settings/cards/KvDatabaseCard.vue";
import HitokotoSettings from "@/components/HitokotoSettings.vue";
export default {
name: "Settings",
@ -293,6 +298,7 @@ export default {
HomeworkTemplateCard,
SubjectManagementCard,
KvDatabaseCard,
HitokotoSettings,
},
setup() {
const {mobile} = useDisplay();
@ -414,6 +420,11 @@ export default {
icon: "mdi-theme-light-dark",
value: "theme",
},
{
title: "一言",
icon: "mdi-comment-quote",
value: "hitokoto",
},
{
title: "随机点名",

View File

@ -146,6 +146,8 @@
gap: 16px;
padding: 8px;
grid-auto-flow: dense;
grid-auto-rows: 1px;
align-items: start;
}
.grid-item {

View File

@ -102,6 +102,20 @@ const settingsDefinitions = {
description: "空科目的显示方式",
icon: "mdi-card-outline",
},
// 一言设置
"hitokoto.enabled": {
type: "boolean",
default: false,
description: "启用一言",
icon: "mdi-comment-quote",
},
"hitokoto.refreshInterval": {
type: "number",
default: 300,
description: "刷新时间0为不自动刷新",
icon: "mdi-timer-refresh",
},
"display.dynamicSort": {
type: "boolean",
default: true,