feat: 添加一言卡片及其设置功能,支持动态内容刷新
This commit is contained in:
@@ -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>
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user