mirror of
https://github.com/ZeroCatDev/Classworks.git
synced 2025-12-07 21:13:11 +00:00
feat: 添加一言卡片及其设置功能,支持动态内容刷新
This commit is contained in:
parent
4627605178
commit
7d90e6ee33
184
src/components/HitokotoCard.vue
Normal file
184
src/components/HitokotoCard.vue
Normal 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>
|
||||
154
src/components/HitokotoSettings.vue
Normal file
154
src/components/HitokotoSettings.vue
Normal 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>
|
||||
@ -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');
|
||||
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: "随机点名",
|
||||
|
||||
@ -146,6 +146,8 @@
|
||||
gap: 16px;
|
||||
padding: 8px;
|
||||
grid-auto-flow: dense;
|
||||
grid-auto-rows: 1px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user