Add spell practice, memory analytics, auth persistence, and word library UX.
Includes per-word training stats and curves, quiz session auto-save, remember-login, paginated word list with floating page arrows, and Obsidian-style relationship graph baseline. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import * as echarts from 'echarts'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import type { MemoryCurvePoint, MemoryFutureRiskPoint } from '../api/request'
|
||||
|
||||
const props = defineProps<{
|
||||
curvePoints: MemoryCurvePoint[]
|
||||
futureRisk: MemoryFutureRiskPoint[]
|
||||
}>()
|
||||
|
||||
const chartRef = ref<HTMLDivElement | null>(null)
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
function render() {
|
||||
if (!chartRef.value) return
|
||||
if (!chart) chart = echarts.init(chartRef.value)
|
||||
|
||||
const dates = props.curvePoints.map((p) => p.date)
|
||||
const futureDates = props.futureRisk.map((p) => p.date)
|
||||
const allDates = [...dates, ...futureDates.slice(1)]
|
||||
|
||||
const forgetting = props.curvePoints.map((p) => p.forgetting)
|
||||
const mastery = props.curvePoints.map((p) => p.mastery)
|
||||
const riskHist = props.curvePoints.map((p) => p.risk)
|
||||
const riskFuture = [
|
||||
...Array(Math.max(0, dates.length - 1)).fill(null),
|
||||
props.curvePoints.length ? props.curvePoints[props.curvePoints.length - 1].risk : null,
|
||||
...props.futureRisk.map((p) => p.risk),
|
||||
]
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: {
|
||||
data: ['遗忘曲线', '熟练曲线', '可能遗忘(历史)', '可能遗忘(预测)'],
|
||||
bottom: 0,
|
||||
textStyle: { fontSize: 11 },
|
||||
},
|
||||
grid: { left: 48, right: 16, top: 24, bottom: 56 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: allDates,
|
||||
axisLabel: { rotate: 35, fontSize: 10 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 100,
|
||||
name: '记忆指数 %',
|
||||
nameTextStyle: { fontSize: 11 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '遗忘曲线',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: [...forgetting, ...Array(futureDates.length).fill(null)],
|
||||
lineStyle: { color: '#ef4444', width: 2 },
|
||||
itemStyle: { color: '#ef4444' },
|
||||
areaStyle: { color: 'rgba(239,68,68,0.08)' },
|
||||
},
|
||||
{
|
||||
name: '熟练曲线',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: [...mastery, ...Array(futureDates.length).fill(null)],
|
||||
lineStyle: { color: '#22c55e', width: 2 },
|
||||
itemStyle: { color: '#22c55e' },
|
||||
},
|
||||
{
|
||||
name: '可能遗忘(历史)',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: [...riskHist, ...Array(futureDates.length).fill(null)],
|
||||
lineStyle: { color: '#f59e0b', width: 2, type: 'dashed' },
|
||||
itemStyle: { color: '#f59e0b' },
|
||||
},
|
||||
{
|
||||
name: '可能遗忘(预测)',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: riskFuture,
|
||||
lineStyle: { color: '#a855f7', width: 2, type: 'dotted' },
|
||||
itemStyle: { color: '#a855f7' },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
chart?.resize()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
render()
|
||||
window.addEventListener('resize', onResize)
|
||||
})
|
||||
|
||||
watch(() => [props.curvePoints, props.futureRisk], render, { deep: true })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
chart?.dispose()
|
||||
chart = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="chartRef" class="memory-chart" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.memory-chart {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import type { QuizQuestion } from '../api/request'
|
||||
|
||||
const props = defineProps<{
|
||||
question: QuizQuestion
|
||||
index: number
|
||||
total: number
|
||||
showResult?: boolean
|
||||
isCorrect?: boolean
|
||||
submittedAnswer?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [answer: string]
|
||||
}>()
|
||||
|
||||
const input = ref('')
|
||||
|
||||
watch(
|
||||
() => props.index,
|
||||
() => {
|
||||
input.value = ''
|
||||
}
|
||||
)
|
||||
|
||||
function onSubmit() {
|
||||
const answer = input.value.trim()
|
||||
if (!answer || props.showResult) return
|
||||
emit('submit', answer)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getDraft: () => input.value.trim(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card quiz-card">
|
||||
<div class="quiz-progress">{{ index + 1 }} / {{ total }}</div>
|
||||
<div class="quiz-type">根据中文与读音拼写英文</div>
|
||||
<div class="quiz-prompt">{{ question.prompt }}</div>
|
||||
<div v-if="question.phonetic" class="phonetic">{{ question.phonetic }}</div>
|
||||
<div v-else class="phonetic muted">暂无音标</div>
|
||||
<input
|
||||
v-model="input"
|
||||
class="spell-input"
|
||||
type="text"
|
||||
placeholder="输入英文单词"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
:disabled="showResult"
|
||||
@keydown.enter="onSubmit"
|
||||
/>
|
||||
<button
|
||||
v-if="!showResult"
|
||||
class="btn btn-primary submit-btn"
|
||||
:disabled="!input.trim()"
|
||||
@click="onSubmit"
|
||||
>
|
||||
提交
|
||||
</button>
|
||||
<div v-if="showResult" class="result" :class="isCorrect ? 'ok' : 'fail'">
|
||||
{{ isCorrect ? '拼写正确 ✅' : '拼写错误 ❌' }}
|
||||
<template v-if="!isCorrect">
|
||||
— 你的答案:{{ submittedAnswer }} · 正确答案:{{ question.correct_answer }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quiz-progress {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.quiz-type {
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.quiz-prompt {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin: 20px 0 8px;
|
||||
}
|
||||
.phonetic {
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
color: var(--primary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.phonetic.muted {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
.spell-input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.spell-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.spell-input:disabled {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.result {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.result.ok {
|
||||
background: #d1fae5;
|
||||
color: #047857;
|
||||
}
|
||||
.result.fail {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Word } from '../api/request'
|
||||
import { formatTrainSeconds } from '../composables/useQuizTimer'
|
||||
|
||||
defineProps<{
|
||||
word: Word
|
||||
@@ -23,6 +24,12 @@ function enText(word: Word) {
|
||||
function zhText(word: Word) {
|
||||
return word.source_lang === 'zh' ? word.source_text : word.target_text
|
||||
}
|
||||
|
||||
function formatEnteredAt(iso: string) {
|
||||
if (!iso) return '—'
|
||||
const d = iso.replace('T', ' ').replace('Z', '')
|
||||
return d.length >= 16 ? d.slice(0, 16) : d.slice(0, 10)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -35,11 +42,14 @@ function zhText(word: Word) {
|
||||
<button class="btn btn-danger" @click="$emit('delete', word.id)">删除</button>
|
||||
</div>
|
||||
<div class="word-meta">
|
||||
<span>训练 {{ word.train_count ?? 0 }} 次</span>
|
||||
<span>答对 {{ word.correct_count }}</span>
|
||||
<span>答错 {{ word.wrong_count }}</span>
|
||||
<span>连续 {{ word.consecutive_correct_count }}</span>
|
||||
<span>掌握率 {{ word.mastery_score }}%</span>
|
||||
<span v-if="word.total_train_seconds">用时 {{ formatTrainSeconds(word.total_train_seconds) }}</span>
|
||||
</div>
|
||||
<div class="word-entered">进入词库:{{ formatEnteredAt(word.entered_at || word.created_at) }}</div>
|
||||
<div v-if="word.review_due_date" class="word-due">下次复习:{{ word.review_due_date }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -64,9 +74,14 @@ function zhText(word: Word) {
|
||||
color: var(--muted);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.word-entered {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.word-due {
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
margin-top: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import type { MemoryGraphLink, MemoryGraphNode } from '../api/request'
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: MemoryGraphNode[]
|
||||
links: MemoryGraphLink[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [id: string]
|
||||
}>()
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const selectedId = ref<string | null>(null)
|
||||
const cursorStyle = ref('default')
|
||||
|
||||
interface SimNode extends MemoryGraphNode {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
pinned?: boolean
|
||||
}
|
||||
|
||||
let simNodes: SimNode[] = []
|
||||
let animId = 0
|
||||
let width = 0
|
||||
let height = 0
|
||||
let draggingId: string | null = null
|
||||
let dragOffsetX = 0
|
||||
let dragOffsetY = 0
|
||||
let pointerMoved = false
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
new: '#94a3b8',
|
||||
learning: '#4f6ef7',
|
||||
mastered: '#22c55e',
|
||||
weak: '#ef4444',
|
||||
}
|
||||
|
||||
function nodeRadius(n: SimNode) {
|
||||
return 6 + n.size * 0.25
|
||||
}
|
||||
|
||||
function clampPos(x: number, y: number) {
|
||||
return {
|
||||
x: Math.max(24, Math.min(width - 24, x)),
|
||||
y: Math.max(24, Math.min(height - 24, y)),
|
||||
}
|
||||
}
|
||||
|
||||
function pointerPos(e: PointerEvent) {
|
||||
const canvas = canvasRef.value!
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
return {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
}
|
||||
}
|
||||
|
||||
function hitNode(x: number, y: number): SimNode | null {
|
||||
for (let i = simNodes.length - 1; i >= 0; i--) {
|
||||
const n = simNodes[i]
|
||||
const r = nodeRadius(n) + 6
|
||||
if ((x - n.x) ** 2 + (y - n.y) ** 2 <= r * r) return n
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function initSim() {
|
||||
simNodes = props.nodes.map((n, i) => {
|
||||
const angle = (i / Math.max(props.nodes.length, 1)) * Math.PI * 2
|
||||
const r = Math.min(width, height) * 0.28
|
||||
return {
|
||||
...n,
|
||||
x: width / 2 + Math.cos(angle) * r,
|
||||
y: height / 2 + Math.sin(angle) * r,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const centerX = width / 2
|
||||
const centerY = height / 2
|
||||
|
||||
for (let i = 0; i < simNodes.length; i++) {
|
||||
for (let j = i + 1; j < simNodes.length; j++) {
|
||||
const a = simNodes[i]
|
||||
const b = simNodes[j]
|
||||
if (a.id === draggingId || b.id === draggingId) continue
|
||||
let dx = a.x - b.x
|
||||
let dy = a.y - b.y
|
||||
let dist = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
const repulse = (12000 / (dist * dist)) * 0.016
|
||||
dx /= dist
|
||||
dy /= dist
|
||||
a.vx += dx * repulse
|
||||
a.vy += dy * repulse
|
||||
b.vx -= dx * repulse
|
||||
b.vy -= dy * repulse
|
||||
}
|
||||
}
|
||||
|
||||
for (const l of props.links) {
|
||||
const a = simNodes.find((n) => n.id === l.source)
|
||||
const b = simNodes.find((n) => n.id === l.target)
|
||||
if (!a || !b) continue
|
||||
if (a.id === draggingId) {
|
||||
const dx = b.x - a.x
|
||||
const dy = b.y - a.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
const pull = (dist - 90) * 0.004 * l.strength
|
||||
b.vx -= (dx / dist) * pull * 0.5
|
||||
b.vy -= (dy / dist) * pull * 0.5
|
||||
continue
|
||||
}
|
||||
if (b.id === draggingId) {
|
||||
const dx = b.x - a.x
|
||||
const dy = b.y - a.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
const pull = (dist - 90) * 0.004 * l.strength
|
||||
a.vx += (dx / dist) * pull * 0.5
|
||||
a.vy += (dy / dist) * pull * 0.5
|
||||
continue
|
||||
}
|
||||
const dx = b.x - a.x
|
||||
const dy = b.y - a.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
const pull = (dist - 90) * 0.004 * l.strength
|
||||
a.vx += (dx / dist) * pull
|
||||
a.vy += (dy / dist) * pull
|
||||
b.vx -= (dx / dist) * pull
|
||||
b.vy -= (dy / dist) * pull
|
||||
}
|
||||
|
||||
for (const n of simNodes) {
|
||||
if (n.id === draggingId) continue
|
||||
n.vx += (centerX - n.x) * 0.0008
|
||||
n.vy += (centerY - n.y) * 0.0008
|
||||
n.vx *= 0.86
|
||||
n.vy *= 0.86
|
||||
n.x += n.vx
|
||||
n.y += n.vy
|
||||
const c = clampPos(n.x, n.y)
|
||||
n.x = c.x
|
||||
n.y = c.y
|
||||
}
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
for (const l of props.links) {
|
||||
const a = simNodes.find((n) => n.id === l.source)
|
||||
const b = simNodes.find((n) => n.id === l.target)
|
||||
if (!a || !b) continue
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(a.x, a.y)
|
||||
ctx.lineTo(b.x, b.y)
|
||||
ctx.strokeStyle =
|
||||
l.kind === 'co_review' ? 'rgba(79,110,247,0.35)' : 'rgba(148,163,184,0.25)'
|
||||
ctx.lineWidth = l.kind === 'co_review' ? 1.5 : 1
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
for (const n of simNodes) {
|
||||
const r = nodeRadius(n)
|
||||
const color = statusColor[n.status] || '#64748b'
|
||||
ctx.beginPath()
|
||||
ctx.arc(n.x, n.y, r, 0, Math.PI * 2)
|
||||
ctx.fillStyle = n.id === selectedId.value ? color : color + 'cc'
|
||||
ctx.fill()
|
||||
if (n.id === selectedId.value) {
|
||||
ctx.strokeStyle = '#1e293b'
|
||||
ctx.lineWidth = 2
|
||||
ctx.stroke()
|
||||
}
|
||||
ctx.fillStyle = '#1e293b'
|
||||
ctx.font = '10px system-ui'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(n.label, n.x, n.y + r + 11)
|
||||
}
|
||||
}
|
||||
|
||||
function loop() {
|
||||
tick()
|
||||
draw()
|
||||
animId = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
function resize() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas?.parentElement) return
|
||||
width = canvas.parentElement.clientWidth
|
||||
height = 320
|
||||
canvas.width = width * devicePixelRatio
|
||||
canvas.height = height * devicePixelRatio
|
||||
canvas.style.width = `${width}px`
|
||||
canvas.style.height = `${height}px`
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
const { x, y } = pointerPos(e)
|
||||
const hit = hitNode(x, y)
|
||||
if (!hit) return
|
||||
|
||||
draggingId = hit.id
|
||||
dragOffsetX = x - hit.x
|
||||
dragOffsetY = y - hit.y
|
||||
hit.vx = 0
|
||||
hit.vy = 0
|
||||
pointerMoved = false
|
||||
selectedId.value = hit.id
|
||||
emit('select', hit.id)
|
||||
cursorStyle.value = 'grabbing'
|
||||
canvasRef.value?.setPointerCapture(e.pointerId)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
const { x, y } = pointerPos(e)
|
||||
|
||||
if (draggingId) {
|
||||
const n = simNodes.find((node) => node.id === draggingId)
|
||||
if (n) {
|
||||
pointerMoved = true
|
||||
const c = clampPos(x - dragOffsetX, y - dragOffsetY)
|
||||
n.x = c.x
|
||||
n.y = c.y
|
||||
n.vx = 0
|
||||
n.vy = 0
|
||||
}
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
cursorStyle.value = hitNode(x, y) ? 'grab' : 'default'
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (draggingId) {
|
||||
const n = simNodes.find((node) => node.id === draggingId)
|
||||
if (n && !pointerMoved) {
|
||||
selectedId.value = n.id
|
||||
emit('select', n.id)
|
||||
}
|
||||
draggingId = null
|
||||
canvasRef.value?.releasePointerCapture(e.pointerId)
|
||||
}
|
||||
pointerMoved = false
|
||||
cursorStyle.value = 'default'
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
resize()
|
||||
if (!draggingId) initSim()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
resize()
|
||||
initSim()
|
||||
loop()
|
||||
window.addEventListener('resize', onResize)
|
||||
canvas.addEventListener('pointerdown', onPointerDown)
|
||||
canvas.addEventListener('pointermove', onPointerMove)
|
||||
canvas.addEventListener('pointerup', onPointerUp)
|
||||
canvas.addEventListener('pointercancel', onPointerUp)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.nodes, props.links],
|
||||
() => {
|
||||
resize()
|
||||
draggingId = null
|
||||
initSim()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(animId)
|
||||
window.removeEventListener('resize', onResize)
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
canvas.removeEventListener('pointerdown', onPointerDown)
|
||||
canvas.removeEventListener('pointermove', onPointerMove)
|
||||
canvas.removeEventListener('pointerup', onPointerUp)
|
||||
canvas.removeEventListener('pointercancel', onPointerUp)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="graph-wrap">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="graph-canvas"
|
||||
:style="{ cursor: cursorStyle }"
|
||||
/>
|
||||
<p class="graph-hint">拖动节点调整位置(画布固定)· 点击节点查看单词</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.graph-wrap {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: #fafbff;
|
||||
overflow: hidden;
|
||||
}
|
||||
.graph-canvas {
|
||||
display: block;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
.graph-hint {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
padding: 8px 12px;
|
||||
margin: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user