mach-cms的前台,用vue写的
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

186 lines
4.4 KiB

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { tagApi } from '@/api/client'
import type { Tag } from '@/generated/api'
const router = useRouter()
const tags = ref<Tag[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const WIDTH = 260
const MIN_FONT = 11
const MAX_FONT = 22
interface PlacedTag {
tag: Tag
x: number
y: number
fontSize: number
top: boolean
}
const placedTags = ref<PlacedTag[]>([])
const viewBox = ref('0 0 260 40')
function fontScale(count: number, max: number): number {
if (max <= 1) return 16
return MIN_FONT + (count / max) * (MAX_FONT - MIN_FONT)
}
function measure(text: string, fs: number): number {
let w = 0
for (const ch of text) {
if (/[\u2e80-\u9fff\uf900-\ufaff\u3040-\u30ff\uac00-\ud7af]/.test(ch)) {
w += fs
} else if (/[A-Za-z0-9]/.test(ch)) {
w += fs * 0.6
} else {
w += fs * 0.5
}
}
return w
}
function buildLayout() {
if (tags.value.length === 0) {
placedTags.value = []
viewBox.value = '0 0 260 40'
return
}
const sorted = [...tags.value].sort((a, b) => b.articleCount - a.articleCount)
const maxCount = Math.max(...sorted.map((t) => t.articleCount), 1)
const sizes = sorted.map((t) => fontScale(t.articleCount, maxCount))
const placed: { x: number; y: number; w: number; h: number }[] = []
let maxX = 0
let maxY = 0
const collides = (x: number, y: number, w: number, h: number): boolean => {
if (x < 2 || x + w > WIDTH - 2 || y < 2) return true
return placed.some((r) => x < r.x + r.w && x + w > r.x && y < r.y + r.h && y + h > r.y)
}
const findSpot = (w: number, h: number): [number, number] => {
const cx = WIDTH / 2
const cy = maxY === 0 ? 34 : maxY / 2
let x = cx - w / 2
let y = cy - h / 2
if (!collides(x, y, w, h)) return [x, y]
for (let t = 1; t < 900; t++) {
const angle = 0.75 * t
const rad = 3.4 * t
x = cx + rad * Math.cos(angle) - w / 2
y = cy + rad * Math.sin(angle) - h / 2
if (!collides(x, y, w, h)) return [x, y]
}
x = 2
y = maxY + 8
while (collides(x, y, w, h) && y < maxY + 400) {
y += 2
}
return [x, y]
}
const result: PlacedTag[] = []
sorted.forEach((tag, idx) => {
const fs = sizes[idx]
const w = measure(tag.name, fs) + 6
const h = fs * 1.45
const [x, y] = findSpot(w, h)
placed.push({ x, y, w, h })
maxX = Math.max(maxX, x + w)
maxY = Math.max(maxY, y + h)
result.push({ tag, x, y, fontSize: fs, top: idx === 0 })
})
placedTags.value = result
viewBox.value = `0 0 ${Math.ceil(maxX) + 4} ${Math.ceil(maxY) + 4}`
}
function goToTag(tag: Tag) {
router.push({ name: 'Home', query: { tag: tag.slug || tag.name } })
}
async function load() {
loading.value = true
error.value = null
try {
const resp = await tagApi.apiTagsGet()
tags.value = resp.data.data ?? []
} catch (e) {
console.error(e)
error.value = '标签加载失败'
} finally {
loading.value = false
}
buildLayout()
}
onMounted(load)
</script>
<template>
<div class="tag-cloud">
<h3 class="tag-cloud-title">标签云</h3>
<p v-if="error" class="error-text">{{ error }}</p>
<p v-else-if="loading" class="hint-text">加载中...</p>
<p v-else-if="tags.length === 0" class="hint-text">暂无标签</p>
<svg v-else :viewBox="viewBox" class="word-cloud" role="img" aria-label="文章标签词云" preserveAspectRatio="xMidYMid meet">
<text
v-for="item in placedTags"
:key="item.tag.id"
class="cloud-tag"
:class="{ top: item.top }"
:x="item.x"
:y="item.y + item.fontSize"
:font-size="item.fontSize"
@click="goToTag(item.tag)"
>{{ item.tag.name }}</text>
</svg>
</div>
</template>
<style scoped>
.tag-cloud {
background: var(--ac-bg-card);
border: 1px solid var(--ac-border);
border-radius: var(--song-radius-l);
padding: 16px;
}
.tag-cloud-title {
margin: 0 0 12px;
font-size: 16px;
}
.word-cloud {
display: block;
width: 100%;
height: auto;
}
.cloud-tag {
font-family: 'Noto Serif SC', 'SimSun', 'STSong', serif;
fill: var(--ac-text);
cursor: pointer;
transition: fill var(--song-dur-1) var(--song-ease);
}
.cloud-tag.top {
fill: var(--ac-primary-ink);
font-weight: 700;
}
.cloud-tag:hover {
fill: var(--ac-primary);
}
.error-text {
color: var(--ac-danger-ink);
}
.hint-text {
color: var(--ac-text-disabled);
}
</style>