feat(content): 网站资讯独立详情页 + 留言

- 新增 user/ContentDetail.jsx 独立路由 #/content-detail?id=,微信式:标题/作者/日期/阅读X + 富文本 + 动作条(点赞/转发/收藏/写留言) + 留言区(输入+列表,登录 AuthGate)
- Content.jsx 卡片链接到独立详情页(去掉内联展开)
- services/content.js +getOne/getComments/addComment;App 路由 content-detail

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-27 15:32:56 +08:00
parent bab70c8eee
commit b8c8e31c7d
4 changed files with 160 additions and 39 deletions
+2
View File
@@ -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 }
];
+33 -1
View File
@@ -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;
}
+20 -38
View File
@@ -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 (
<ContentTemplate active="content" kicker="News" title="资讯中心" desc="政策 / 资讯 / 技能 / 动态(平台发布)">
{/* 类别分段 */}
<section className="section">
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{CONTENT_CATS.map((c) => (
<Button key={c.key} variant={cat === c.key ? 'cta' : 'white'} onClick={() => { setCat(c.key); setOpenId(null); }}>{c.label}</Button>
<Button key={c.key} variant={cat === c.key ? 'cta' : 'white'} onClick={() => setCat(c.key)}>{c.label}</Button>
))}
</div>
</section>
@@ -31,10 +29,7 @@ export default function Content() {
<section className="section" style={{ marginTop: 8 }}>
{!loaded ? <p className="pf-muted">加载中</p> : items.length === 0
? <p className="pf-muted">暂无{CONTENT_CATS.find((c) => c.key === cat)?.label}内容</p>
: items.map((it, i) => (
<NewsCard key={it.id} it={it} big={i % 3 === 0} open={openId === it.id}
onToggle={() => setOpenId(openId === it.id ? null : it.id)} />
))}
: items.map((it, i) => <NewsCard key={it.id} it={it} big={i % 3 === 0} />)}
</section>
</ContentTemplate>
);
@@ -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 (
<Reveal as="article" className="bk-ev-card" style={{ marginBottom: 10, padding: big ? 0 : '14px 16px', overflow: 'hidden' }}>
<div style={{ cursor: 'pointer' }} onClick={onToggle}>
{big ? (
<div style={{ position: 'relative', aspectRatio: '16/9', background: `linear-gradient(135deg, ${m}, ${m}cc)` }}>
{img ? <img src={img} alt="" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }} /> : null}
{it.video ? <div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)', width: 44, height: 44, borderRadius: '50%', background: 'rgba(255,255,255,.9)', color: '#111', display: 'grid', placeItems: 'center' }}></div> : null}
</div>
) : null}
<div style={{ padding: big ? '14px 16px 6px' : 0 }}>
<h3 style={{ margin: 0, fontSize: big ? 17 : 15, lineHeight: 1.4 }}>{it.title}</h3>
<p className="pf-muted" style={{ margin: '8px 0 0', display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ display: 'inline-block', width: 20, height: 20, borderRadius: '50%', background: `linear-gradient(135deg, ${m}, ${m}cc)`, overflow: 'hidden' }}>
{it.publisher_avatar ? <img src={it.publisher_avatar} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : null}
</span>
<span>{author}</span><span>·</span><span>{fmt(it.created_at)}</span>
</p>
</div>
</div>
{open && (
<div style={{ padding: '0 16px 16px' }}>
{it.summary && <p className="pf-muted">{it.summary}</p>}
<div className="pf-body ct-rich" style={{ lineHeight: 1.8 }} dangerouslySetInnerHTML={{ __html: it.body || '暂无正文' }} />
{Array.isArray(it.images) && it.images.length > 0 && (
<div style={{ display: 'flex', gap: 8, overflowX: 'auto', marginTop: 10 }}>
{it.images.map((u, i) => <img key={i} src={u} alt="" style={{ width: 120, height: 120, borderRadius: 8, objectFit: 'cover', flexShrink: 0 }} />)}
</div>
)}
{it.link && <a href={it.link} target="_blank" rel="noreferrer" style={{ display: 'inline-block', marginTop: 10, color: '#7fd087' }}>原文链接 </a>}
<Reveal as="a" href={href} className="bk-ev-card" style={{ marginBottom: 10, padding: big ? 0 : '14px 16px', overflow: 'hidden', display: 'block', color: 'inherit', textDecoration: 'none' }}>
{big && (
<div style={{ position: 'relative', aspectRatio: '16/9', background: `linear-gradient(135deg, ${m}, ${m}cc)` }}>
{img ? <img src={img} alt="" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }} /> : null}
{it.video ? <div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)', width: 44, height: 44, borderRadius: '50%', background: 'rgba(255,255,255,.9)', color: '#111', display: 'grid', placeItems: 'center' }}></div> : null}
</div>
)}
<div style={{ padding: big ? '14px 16px 6px' : 0 }}>
<h3 style={{ margin: 0, fontSize: big ? 17 : 15, lineHeight: 1.4 }}>{it.title}</h3>
<p className="pf-muted" style={{ margin: '8px 0 0', display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ display: 'inline-block', width: 20, height: 20, borderRadius: '50%', background: `linear-gradient(135deg, ${m}, ${m}cc)`, overflow: 'hidden' }}>
{it.publisher_avatar ? <img src={it.publisher_avatar} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : null}
</span>
<span>{author}</span><span>·</span><span>{fmt(it.created_at)}</span>
</p>
</div>
</Reveal>
);
}
+105
View File
@@ -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 <ContentTemplate active="content" kicker="News" title="加载中…"><section className="section" /></ContentTemplate>;
if (err || !item) return <ContentTemplate active="content" kicker="News" title="内容不存在"><section className="section"><p className="pf-muted">{err || '内容不存在'}</p><Button variant="cta" href="#/content">返回资讯中心</Button></section></ContentTemplate>;
const author = item.publisher_name || '云超服';
const views = item.read_count || 0;
return (
<ContentTemplate active="content" kicker="News" title={item.title} desc="">
<article className="section" style={{ maxWidth: 720, margin: '0 auto' }}>
{item.cover && <img src={item.cover} alt="" style={{ width: '100%', borderRadius: 12, marginBottom: 14 }} />}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 }}>
{item.publisher_avatar
? <img src={item.publisher_avatar} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
: <span style={{ width: 28, height: 28, borderRadius: '50%', background: '#555' }} />}
<span className="pf-muted">{author} <span style={{ color: '#26a69a' }}></span></span>
<span className="pf-muted">· {rw(item.created_at)}</span>
<span className="pf-muted">· 阅读 {views}</span>
</div>
<div className="ct-rich" style={{ marginTop: 18, lineHeight: 1.9 }} dangerouslySetInnerHTML={{ __html: item.body || '暂无正文' }} />
{Array.isArray(item.images) && item.images.length > 0 && (
<div style={{ display: 'flex', gap: 8, overflowX: 'auto', marginTop: 14 }}>
{item.images.map((u, i) => <img key={i} src={u} alt="" style={{ width: 120, height: 120, borderRadius: 8, objectFit: 'cover', flexShrink: 0 }} />)}
</div>
)}
{item.link && <a href={item.link} target="_blank" rel="noreferrer" style={{ display: 'inline-block', marginTop: 12, color: '#7fd087' }}>原文链接 </a>}
{/* 动作条 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 18, marginTop: 24, paddingTop: 14, borderTop: '1px solid #222' }}>
<span className="pf-muted">👍 0{like ? ' · 已赞' : ''}</span>
<button type="button" className="action-btn" onClick={() => setLike(v => !v)}>👍 点赞</button>
<button type="button" className="action-btn" onClick={copyLink}> 转发</button>
<button type="button" className="action-btn" onClick={() => setFav(v => !v)}>{fav ? ' 已藏' : ' 收藏'}</button>
<button type="button" className="action-btn" onClick={() => document.querySelector('.cmt-input')?.scrollIntoView({ behavior: 'smooth' })}> 写留言</button>
</div>
{/* 阅读 + 留言 */}
<p className="pf-muted" style={{ marginTop: 18 }}>阅读 {views}</p>
<div style={{ marginTop: 12 }}>
<h3 style={{ fontSize: 18, marginBottom: 12 }}>留言</h3>
{isAuthed() ? (
<div className="cmt-input" style={{ display: 'flex', gap: 8 }}>
<input className="pf-input" style={{ flex: 1, background: '#17171b', border: '1px solid #2a2a2d', borderRadius: 8, padding: '8px 12px', color: '#eaeaea' }}
placeholder="写留言…" value={draft} onChange={(e) => setDraft(e.target.value)} />
<span style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#9b9b9b' }}>😊 🖼</span>
<Button variant="cta" disabled={sending || !draft.trim()} onClick={send}>发送</Button>
</div>
) : (
<AuthGate title="登录后写留言" subtitle="手机号即账号,未注册自动注册" />
)}
{comments.length === 0
? <p className="pf-muted" style={{ textAlign: 'center', marginTop: 24 }}>还没有留言来写首评</p>
: comments.map((c) => (
<div key={c.id} style={{ marginTop: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{c.avatar ? <img src={c.avatar} alt="" style={{ width: 24, height: 24, borderRadius: '50%' }} /> : <span style={{ width: 24, height: 24, borderRadius: '50%', background: '#444' }} />}
<span className="pf-muted">{c.nickname || c.username || '用户'}</span>
<span className="pf-muted" style={{ fontSize: 12 }}>{rw(c.created_at)}</span>
</div>
<p style={{ margin: '6px 0 0', color: '#e4e4e6' }}>{c.content}</p>
</div>
))}
</div>
</article>
</ContentTemplate>
);
}