评论与评分系统
This commit is contained in:
parent
b9abbd09b6
commit
5ae7e85eb7
@ -18,6 +18,8 @@ export type ModelSummary = {
|
||||
authorName?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
avgRating?: number | null
|
||||
ratingCount?: number
|
||||
}
|
||||
|
||||
export type ModelDetail = ModelSummary & {
|
||||
@ -25,6 +27,15 @@ export type ModelDetail = ModelSummary & {
|
||||
desc: string
|
||||
}
|
||||
|
||||
export type CommentItem = {
|
||||
id: string
|
||||
content: string
|
||||
authorId: string
|
||||
authorName?: string
|
||||
createdAt: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type ListModelsQuery = {
|
||||
q?: string
|
||||
authorId?: string
|
||||
@ -135,6 +146,44 @@ export const api = {
|
||||
previewUrl(id: string) {
|
||||
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 }
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, computed, watch } from 'vue'
|
||||
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 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
|
||||
import mermaid from 'mermaid'
|
||||
@ -18,11 +18,19 @@ type State = {
|
||||
error: string | null
|
||||
author: User | null
|
||||
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 { state: auth } = useAuth()
|
||||
|
||||
// markdown renderer with KaTeX and link attrs
|
||||
const md = new MarkdownIt({ html: false, linkify: true, typographer: true })
|
||||
@ -45,6 +53,20 @@ async function load() {
|
||||
if (data.authorId) {
|
||||
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) {
|
||||
st.error = e?.message || String(e)
|
||||
} finally { st.loading = false }
|
||||
@ -74,6 +96,44 @@ function goBack() {
|
||||
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 () => {
|
||||
await load()
|
||||
// Mermaid init after content mounted
|
||||
@ -108,7 +168,8 @@ watch(() => st.model?.desc, (v) => renderMarkdown(v))
|
||||
<div class="detail-page">
|
||||
<div class="back-btn-container">
|
||||
<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"
|
||||
stroke-width="2">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
返回
|
||||
@ -121,21 +182,33 @@ watch(() => st.model?.desc, (v) => renderMarkdown(v))
|
||||
<div class="head card">
|
||||
<img class="preview" :src="api.previewUrl(st.model.id)" alt="preview" />
|
||||
<div class="meta">
|
||||
<div>
|
||||
<h1 class="title">{{ st.model.title }}</h1>
|
||||
<div class="author-row">
|
||||
<img class="avatar" :src="api.avatarUrl(st.model.authorId)"
|
||||
alt="avatar" />
|
||||
<div class="author">
|
||||
<div class="name">{{ st.author?.username || st.model.authorName ||
|
||||
<div class="name">{{ st.author?.username || st.model.authorName
|
||||
||
|
||||
'未知用户' }}</div>
|
||||
<div class="dates">
|
||||
<span>创建于 {{ new Date(st.model.createdAt || '').toLocaleString()
|
||||
<span>创建于 {{ new Date(st.model.createdAt ||
|
||||
'').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 class="ops">
|
||||
<button class="btn" @click="downloadJson">下载 JSON</button>
|
||||
<button class="btn primary" @click="importToLab">导入到实验室</button>
|
||||
@ -162,6 +235,40 @@ watch(() => st.model?.desc, (v) => renderMarkdown(v))
|
||||
<div class="label">世界尺寸</div>
|
||||
<div class="value">{{ st.model.model?.world?.worldSize ?? '-' }}</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>
|
||||
</template>
|
||||
</div>
|
||||
@ -220,7 +327,7 @@ watch(() => st.model?.desc, (v) => renderMarkdown(v))
|
||||
.head {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.preview {
|
||||
@ -234,7 +341,10 @@ watch(() => st.model?.desc, (v) => renderMarkdown(v))
|
||||
|
||||
.meta {
|
||||
flex: 1;
|
||||
min-width: 0
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.title {
|
||||
@ -256,6 +366,11 @@ watch(() => st.model?.desc, (v) => renderMarkdown(v))
|
||||
border: 1px solid #e5e7eb
|
||||
}
|
||||
|
||||
.avatar.small {
|
||||
width: 28px;
|
||||
height: 28px
|
||||
}
|
||||
|
||||
.author .name {
|
||||
font-weight: 600
|
||||
}
|
||||
@ -268,7 +383,8 @@ watch(() => st.model?.desc, (v) => renderMarkdown(v))
|
||||
.ops {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px
|
||||
margin-top: 8px;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@ -315,7 +431,6 @@ watch(() => st.model?.desc, (v) => renderMarkdown(v))
|
||||
|
||||
.desc-card {
|
||||
margin-top: 12px
|
||||
|
||||
}
|
||||
|
||||
.grid {
|
||||
@ -343,4 +458,82 @@ watch(() => st.model?.desc, (v) => renderMarkdown(v))
|
||||
display: block;
|
||||
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>
|
||||
Loading…
x
Reference in New Issue
Block a user