评论与评分系统
This commit is contained in:
parent
b9abbd09b6
commit
5ae7e85eb7
@ -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 }
|
||||||
|
|||||||
@ -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,21 +182,33 @@ 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">
|
||||||
|
<div>
|
||||||
<h1 class="title">{{ st.model.title }}</h1>
|
<h1 class="title">{{ st.model.title }}</h1>
|
||||||
<div class="author-row">
|
<div class="author-row">
|
||||||
<img class="avatar" :src="api.avatarUrl(st.model.authorId)"
|
<img class="avatar" :src="api.avatarUrl(st.model.authorId)"
|
||||||
alt="avatar" />
|
alt="avatar" />
|
||||||
<div class="author">
|
<div class="author">
|
||||||
<div class="name">{{ st.author?.username || st.model.authorName ||
|
<div class="name">{{ st.author?.username || st.model.authorName
|
||||||
|
||
|
||||||
'未知用户' }}</div>
|
'未知用户' }}</div>
|
||||||
<div class="dates">
|
<div class="dates">
|
||||||
<span>创建于 {{ new Date(st.model.createdAt || '').toLocaleString()
|
<span>创建于 {{ new Date(st.model.createdAt ||
|
||||||
|
'').toLocaleString()
|
||||||
}}</span>
|
}}</span>
|
||||||
<span v-if="st.model.updatedAt"> · 更新于 {{ new
|
<span v-if="st.model.updatedAt"> · 更新于 {{ new
|
||||||
Date(st.model.updatedAt).toLocaleString() }}</span>
|
Date(st.model.updatedAt).toLocaleString() }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div class="ops">
|
||||||
<button class="btn" @click="downloadJson">下载 JSON</button>
|
<button class="btn" @click="downloadJson">下载 JSON</button>
|
||||||
<button class="btn primary" @click="importToLab">导入到实验室</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="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>
|
||||||
Loading…
x
Reference in New Issue
Block a user