diff --git a/website/src/App.jsx b/website/src/App.jsx index 4c5f1ae..fc0a7c8 100644 --- a/website/src/App.jsx +++ b/website/src/App.jsx @@ -29,6 +29,7 @@ import ParkApply from './user/ParkApply'; import CertApply from './user/CertApply'; import ParkTransfer from './user/ParkTransfer'; import Content from './user/Content'; +import ContentDetail from './user/ContentDetail'; /* 公开路由(无需登录) */ const PUBLIC_ROUTES = [ @@ -43,6 +44,7 @@ const PUBLIC_ROUTES = [ { path: 'cert-apply', Page: CertApply }, { path: 'park-transfer', Page: ParkTransfer }, { path: 'content', Page: Content }, + { path: 'content-detail', Page: ContentDetail }, { path: 'profile', Page: Profile } ]; diff --git a/website/src/services/content.js b/website/src/services/content.js index 1102cf4..f79623c 100644 --- a/website/src/services/content.js +++ b/website/src/services/content.js @@ -1,4 +1,5 @@ -/** C 端资讯(公开浏览 /opc/content) */ +/** C 端资讯(公开浏览 /opc/content + 详情/留言) */ +import { getToken } from '@/services/auth'; const API_BASE = 'https://opc.pinesound.cn'; export const CONTENT_CATS = [ @@ -17,3 +18,34 @@ export async function listContent(type) { return []; } } + +export async function getOne(id) { + try { + const res = await fetch(`${API_BASE}/opc/content/${id}`); + return await res.json().catch(() => null); + } catch { + return null; + } +} + +export async function getComments(id) { + try { + const res = await fetch(`${API_BASE}/opc/content/${id}/comments`); + const d = await res.json().catch(() => ({})); + return Array.isArray(d.items) ? d.items : []; + } catch { + return []; + } +} + +export async function addComment(id, content) { + const token = getToken(); + const res = await fetch(`${API_BASE}/opc/content/${id}/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: JSON.stringify({ content }), + }); + const r = await res.json().catch(() => ({})); + if (!res.ok || !r.ok) throw new Error(r.detail || r.error || '留言失败,请先登录'); + return r.comment || null; +} diff --git a/website/src/user/Content.jsx b/website/src/user/Content.jsx index c798b6d..b3cc82e 100644 --- a/website/src/user/Content.jsx +++ b/website/src/user/Content.jsx @@ -5,11 +5,10 @@ import { Reveal } from '@/components/organisms'; import { listContent, CONTENT_CATS } from '@/services/content'; import '@/styles/booking.css'; -/** 资讯中心(C端):按类别(政策/资讯/技能/动态)展示 admin 发布的资讯,点击展开正文。 */ +/** 资讯中心(C端):按类别展示 admin 发布的资讯,点击进入独立详情页。 */ export default function Content() { const [cat, setCat] = React.useState('policy'); const [items, setItems] = React.useState([]); - const [openId, setOpenId] = React.useState(null); const [loaded, setLoaded] = React.useState(false); React.useEffect(() => { @@ -19,11 +18,10 @@ export default function Content() { return ( - {/* 类别分段 */}
{CONTENT_CATS.map((c) => ( - + ))}
@@ -31,10 +29,7 @@ export default function Content() {
{!loaded ?

加载中…

: items.length === 0 ?

暂无{CONTENT_CATS.find((c) => c.key === cat)?.label}内容

- : items.map((it, i) => ( - setOpenId(openId === it.id ? null : it.id)} /> - ))} + : items.map((it, i) => )}
); @@ -42,42 +37,29 @@ export default function Content() { const CAT_COLOR = { policy: '#3b82f6', news: '#10b981', skill: '#f59e0b', dynamic: '#8b5cf6' }; -/** 资讯卡片:大图版(big) / 行式版,含封面/视频/发布人。 */ -function NewsCard({ it, big, open, onToggle }) { +/** 资讯卡片(大图/行式,点击链接到独立详情页)。 */ +function NewsCard({ it, big }) { const m = CAT_COLOR[it.type] || '#10b981'; const img = it.cover || (it.video ? it.video_cover : ''); const author = it.publisher_name || '云超服'; + const href = `#/content-detail?id=${it.id}`; return ( - -
- {big ? ( -
- {img ? : null} - {it.video ?
: null} -
- ) : null} -
-

{it.title}

-

- - {it.publisher_avatar ? : null} - - {author}·{fmt(it.created_at)} -

-
-
- {open && ( -
- {it.summary &&

{it.summary}

} -
- {Array.isArray(it.images) && it.images.length > 0 && ( -
- {it.images.map((u, i) => )} -
- )} - {it.link && 原文链接 ↗} + + {big && ( +
+ {img ? : null} + {it.video ?
: null}
)} +
+

{it.title}

+

+ + {it.publisher_avatar ? : null} + + {author}·{fmt(it.created_at)} +

+
); } diff --git a/website/src/user/ContentDetail.jsx b/website/src/user/ContentDetail.jsx new file mode 100644 index 0000000..6bf97cf --- /dev/null +++ b/website/src/user/ContentDetail.jsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { ContentTemplate } from '@/components/templates'; +import { Button } from '@/components/atoms'; +import AuthGate from '@/components/AuthGate'; +import { isAuthed } from '@/services/auth'; +import { getOne, getComments, addComment } from '@/services/content'; +import '@/styles/booking.css'; + +const rw = (s) => (s || '').replace('T', ' ').replace('Z', '').slice(0, 16); +const getId = () => new URLSearchParams((window.location.hash.split('?')[1] || '')).get('id'); + +/** 资讯独立详情页(微信文章式):标题/作者/阅读 + 正文 + 动作条 + 留言。 */ +export default function ContentDetail() { + const [item, setItem] = React.useState(null); + const [comments, setComments] = React.useState([]); + const [err, setErr] = React.useState(''); + const [loaded, setLoaded] = React.useState(false); + const [like, setLike] = React.useState(false); + const [fav, setFav] = React.useState(false); + const [draft, setDraft] = React.useState(''); + const [sending, setSending] = React.useState(false); + + React.useEffect(() => { + const id = getId(); + if (!id) { setErr('无效内容'); setLoaded(true); return; } + getOne(id).then((r) => { if (!r) setErr('内容不存在'); else setItem(r); }).catch(() => setErr('加载失败')).finally(() => setLoaded(true)); + getComments(id).then(setComments).catch(() => {}); + }, []); + + const send = async () => { + if (!draft.trim()) return; + setSending(true); + try { + const c = await addComment(item.id, draft.trim()); + if (c) { setComments((m) => [c, ...m]); setDraft(''); } + } catch (e) { setErr((e && e.message) || '留言失败,请先登录'); } + finally { setSending(false); } + }; + const copyLink = () => navigator.clipboard?.writeText(`https://opc.pinesound.cn/#/content-detail?id=${item?.id}`).then(() => setErr('')); + + if (!loaded) return
; + if (err || !item) return

{err || '内容不存在'}

; + + const author = item.publisher_name || '云超服'; + const views = item.read_count || 0; + return ( + +
+ {item.cover && } +
+ {item.publisher_avatar + ? + : } + {author} + · {rw(item.created_at)} + · 阅读 {views} +
+
+ {Array.isArray(item.images) && item.images.length > 0 && ( +
+ {item.images.map((u, i) => )} +
+ )} + {item.link && 原文链接 ↗} + + {/* 动作条 */} +
+ 👍 0{like ? ' · 已赞' : ''} + + + + +
+ + {/* 阅读 + 留言 */} +

阅读 {views}

+
+

留言

+ {isAuthed() ? ( +
+ setDraft(e.target.value)} /> + 😊 🖼 + +
+ ) : ( + + )} + {comments.length === 0 + ?

还没有留言,来写首评

+ : comments.map((c) => ( +
+
+ {c.avatar ? : } + {c.nickname || c.username || '用户'} + {rw(c.created_at)} +
+

{c.content}

+
+ ))} +
+
+
+ ); +}