评论与评分系统

This commit is contained in:
feie9454 2025-09-19 22:59:35 +08:00
parent b9abbd09b6
commit 5ae7e85eb7
2 changed files with 265 additions and 23 deletions

View File

@ -18,6 +18,8 @@ export type ModelSummary = {
authorName?: string authorName?: string
createdAt?: string createdAt?: string
updatedAt?: string updatedAt?: string
avgRating?: number | null
ratingCount?: number
} }
export type ModelDetail = ModelSummary & { export type ModelDetail = ModelSummary & {
@ -25,6 +27,15 @@ export type ModelDetail = ModelSummary & {
desc: string desc: string
} }
export type CommentItem = {
id: string
content: string
authorId: string
authorName?: string
createdAt: string
updatedAt?: string
}
export type ListModelsQuery = { export type ListModelsQuery = {
q?: string q?: string
authorId?: string authorId?: string
@ -135,6 +146,44 @@ export const api = {
previewUrl(id: string) { previewUrl(id: string) {
return `${API_BASE}/models/${encodeURIComponent(id)}/preview` return `${API_BASE}/models/${encodeURIComponent(id)}/preview`
}, },
// Ratings
async setRating(id: string, value: number, basicToken?: string) {
const res = await fetch(`${API_BASE}/models/${encodeURIComponent(id)}/rating`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...buildAuthHeader(basicToken) },
body: JSON.stringify({ value })
})
return handle<{ id: string; value: number }>(res)
},
async getMyRating(id: string, basicToken?: string) {
const res = await fetch(`${API_BASE}/models/${encodeURIComponent(id)}/my-rating`, {
headers: { ...buildAuthHeader(basicToken) }
})
return handle<{ myRating: number | null }>(res)
},
// Comments
async listComments(id: string, page = 1, pageSize = 20) {
const usp = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
const res = await fetch(`${API_BASE}/models/${encodeURIComponent(id)}/comments?${usp}`)
return handle<{ items: CommentItem[]; total: number; page: number; pageSize: number }>(res)
},
async addComment(id: string, content: string, basicToken?: string) {
const res = await fetch(`${API_BASE}/models/${encodeURIComponent(id)}/comments`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...buildAuthHeader(basicToken) },
body: JSON.stringify({ content })
})
return handle<{ id: string }>(res)
},
async deleteComment(id: string, commentId: string, basicToken?: string) {
const res = await fetch(`${API_BASE}/models/${encodeURIComponent(id)}/comments/${encodeURIComponent(commentId)}`, {
method: 'DELETE',
headers: { ...buildAuthHeader(basicToken) }
})
return handle<void>(res)
},
} }
export type BasicCredential = { username: string; password: string } export type BasicCredential = { username: string; password: string }

View File

@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, computed, watch } from 'vue' import { onMounted, reactive, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { api, type ModelDetail as MD, type User } from '../api/client' import { api, type ModelDetail as MD, type User, type CommentItem } from '../api/client'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import mk from 'markdown-it-katex' import mk from 'markdown-it-katex'
import mila from 'markdown-it-link-attributes' import { useAuth } from '../store/auth'
// Mermaid is optional; we will lazy-init when needed // Mermaid is optional; we will lazy-init when needed
import mermaid from 'mermaid' import mermaid from 'mermaid'
@ -18,11 +18,19 @@ type State = {
error: string | null error: string | null
author: User | null author: User | null
mdHtml: string mdHtml: string
comments: CommentItem[]
commentsPage: number
commentsTotal: number
myRating: number | null
ratingBusy: boolean
commentInput: string
commentBusy: boolean
} }
const st = reactive<State>({ model: null, loading: false, error: null, author: null, mdHtml: '' }) const st = reactive<State>({ model: null, loading: false, error: null, author: null, mdHtml: '', comments: [], commentsPage: 1, commentsTotal: 0, myRating: null, ratingBusy: false, commentInput: '', commentBusy: false })
const id = computed(() => String(route.params.id)) const id = computed(() => String(route.params.id))
const { state: auth } = useAuth()
// markdown renderer with KaTeX and link attrs // markdown renderer with KaTeX and link attrs
const md = new MarkdownIt({ html: false, linkify: true, typographer: true }) const md = new MarkdownIt({ html: false, linkify: true, typographer: true })
@ -45,6 +53,20 @@ async function load() {
if (data.authorId) { if (data.authorId) {
try { st.author = await api.getUser(data.authorId) } catch { } try { st.author = await api.getUser(data.authorId) } catch { }
} }
// load rating
try {
if (auth.basicToken) {
const r = await api.getMyRating(id.value, auth.basicToken)
st.myRating = r.myRating
} else st.myRating = null
} catch { }
// load comments
try {
const { items, total } = await api.listComments(id.value, 1, 20)
st.comments = items
st.commentsTotal = total
st.commentsPage = 1
} catch { }
} catch (e: any) { } catch (e: any) {
st.error = e?.message || String(e) st.error = e?.message || String(e)
} finally { st.loading = false } } finally { st.loading = false }
@ -74,6 +96,44 @@ function goBack() {
router.back() router.back()
} }
async function setRating(value: number) {
if (!auth.basicToken) { alert('请先登录后评分'); return }
if (value < 1 || value > 5) return
st.ratingBusy = true
try {
await api.setRating(id.value, value, auth.basicToken)
st.myRating = value
// optimistic update avg/count if available
if (st.model) {
const prevCount = st.model.ratingCount || 0
const prevAvg = st.model.avgRating || 0
const hadMy = st.myRating !== null
const newCount = hadMy ? prevCount : prevCount + 1
const newAvg = hadMy
? prevCount > 0 ? (prevAvg * prevCount - (st.myRating ?? 0) + value) / prevCount : value
: (prevAvg * prevCount + value) / newCount
st.model = { ...st.model, ratingCount: newCount, avgRating: newAvg }
}
} catch (e: any) { alert(e?.message || String(e)) }
finally { st.ratingBusy = false }
}
async function addComment() {
if (!auth.basicToken) { alert('请先登录后评论'); return }
const text = st.commentInput.trim()
if (!text) return
st.commentBusy = true
try {
await api.addComment(id.value, text, auth.basicToken)
st.commentInput = ''
const { items, total } = await api.listComments(id.value, 1, 20)
st.comments = items
st.commentsTotal = total
st.commentsPage = 1
} catch (e: any) { alert(e?.message || String(e)) }
finally { st.commentBusy = false }
}
onMounted(async () => { onMounted(async () => {
await load() await load()
// Mermaid init after content mounted // Mermaid init after content mounted
@ -108,8 +168,9 @@ watch(() => st.model?.desc, (v) => renderMarkdown(v))
<div class="detail-page"> <div class="detail-page">
<div class="back-btn-container"> <div class="back-btn-container">
<button class="back-btn" @click="goBack"> <button class="back-btn" @click="goBack">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
<path d="M19 12H5M12 19l-7-7 7-7"/> stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg> </svg>
返回 返回
</button> </button>
@ -121,18 +182,30 @@ watch(() => st.model?.desc, (v) => renderMarkdown(v))
<div class="head card"> <div class="head card">
<img class="preview" :src="api.previewUrl(st.model.id)" alt="preview" /> <img class="preview" :src="api.previewUrl(st.model.id)" alt="preview" />
<div class="meta"> <div class="meta">
<h1 class="title">{{ st.model.title }}</h1> <div>
<div class="author-row"> <h1 class="title">{{ st.model.title }}</h1>
<img class="avatar" :src="api.avatarUrl(st.model.authorId)" <div class="author-row">
alt="avatar" /> <img class="avatar" :src="api.avatarUrl(st.model.authorId)"
<div class="author"> alt="avatar" />
<div class="name">{{ st.author?.username || st.model.authorName || <div class="author">
'未知用户' }}</div> <div class="name">{{ st.author?.username || st.model.authorName
<div class="dates"> ||
<span>创建于 {{ new Date(st.model.createdAt || '').toLocaleString() '未知用户' }}</div>
}}</span> <div class="dates">
<span v-if="st.model.updatedAt"> · 更新于 {{ new <span>创建于 {{ new Date(st.model.createdAt ||
Date(st.model.updatedAt).toLocaleString() }}</span> '').toLocaleString()
}}</span>
<span v-if="st.model.updatedAt"> · 更新于 {{ new
Date(st.model.updatedAt).toLocaleString() }}</span>
</div>
</div>
</div>
<div class="rate-box">
<div class="label">我的评分</div>
<div class="stars" :class="{ busy: st.ratingBusy }">
<button v-for="n in 5" :key="n" class="star"
:class="{ on: (st.myRating || 0) >= n }"
:disabled="st.ratingBusy" @click="setRating(n)"></button>
</div> </div>
</div> </div>
</div> </div>
@ -162,6 +235,40 @@ watch(() => st.model?.desc, (v) => renderMarkdown(v))
<div class="label">世界尺寸</div> <div class="label">世界尺寸</div>
<div class="value">{{ st.model.model?.world?.worldSize ?? '-' }}</div> <div class="value">{{ st.model.model?.world?.worldSize ?? '-' }}</div>
</div> </div>
<div class="card stat">
<div class="label">平均评分</div>
<div class="value">{{ (st.model.avgRating ?? 0).toFixed(1) }} <span
class="count">({{ st.model.ratingCount || 0 }})</span></div>
</div>
</div>
<div class="card comments">
<div v-if="st.comments.length === 0" class="empty">还没有评论来当第一位评论者吧
</div>
<ul class="list">
<li v-for="c in st.comments" :key="c.id" class="comment-item">
<img class="avatar small" :src="api.avatarUrl(c.authorId)"
alt="avatar" />
<div class="body">
<div class="meta-line">
<span class="name">{{ c.authorName || '用户' }}</span>
<span class="time">{{ new Date(c.createdAt).toLocaleString()
}}</span>
</div>
<div class="content">{{ c.content }}</div>
</div>
</li>
</ul>
<h2>评论</h2>
<div class="add" v-if="auth.me">
<textarea v-model="st.commentInput" placeholder="写下你的看法…"
rows="3"></textarea>
<button class="btn primary"
:disabled="st.commentBusy || !st.commentInput.trim()"
@click="addComment">发表评论</button>
</div>
</div> </div>
</template> </template>
</div> </div>
@ -220,7 +327,7 @@ watch(() => st.model?.desc, (v) => renderMarkdown(v))
.head { .head {
display: flex; display: flex;
gap: 12px; gap: 12px;
align-items: flex-start align-items: stretch;
} }
.preview { .preview {
@ -234,7 +341,10 @@ watch(() => st.model?.desc, (v) => renderMarkdown(v))
.meta { .meta {
flex: 1; flex: 1;
min-width: 0 min-width: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
} }
.title { .title {
@ -256,6 +366,11 @@ watch(() => st.model?.desc, (v) => renderMarkdown(v))
border: 1px solid #e5e7eb border: 1px solid #e5e7eb
} }
.avatar.small {
width: 28px;
height: 28px
}
.author .name { .author .name {
font-weight: 600 font-weight: 600
} }
@ -268,7 +383,8 @@ watch(() => st.model?.desc, (v) => renderMarkdown(v))
.ops { .ops {
display: flex; display: flex;
gap: 8px; gap: 8px;
margin-top: 8px margin-top: 8px;
align-self: end;
} }
.btn { .btn {
@ -313,9 +429,8 @@ watch(() => st.model?.desc, (v) => renderMarkdown(v))
overflow: auto overflow: auto
} }
.desc-card{ .desc-card {
margin-top: 12px margin-top: 12px
} }
.grid { .grid {
@ -343,4 +458,82 @@ watch(() => st.model?.desc, (v) => renderMarkdown(v))
display: block; display: block;
overflow: auto overflow: auto
} }
.rate-box {
margin-top: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.rate-box .stars {
display: inline-flex;
gap: 6px
}
.rate-box .star {
font-size: 20px;
background: transparent;
border: none;
cursor: pointer;
color: #9ca3af
}
.rate-box .star.on {
color: #f59e0b
}
.rate-box .stars.busy {
opacity: .6;
pointer-events: none
}
.comments {
margin-top: 12px
}
.comments .add {
display: flex;
flex-direction: column;
gap: 8px
}
.comments textarea {
width: 100%;
box-sizing: border-box;
padding: 8px;
border: 1px solid #e5e7eb;
border-radius: 8px
}
.comments .list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px
}
.comments .comment-item {
display: flex;
gap: 8px
}
.comments .comment-item .meta-line {
color: #6b7280;
font-size: 12px
}
.comments .comment-item .name {
font-weight: 600;
color: #111827;
margin-right: 6px
}
.comments .empty {
color: #6b7280;
font-size: 14px
}
</style> </style>