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:
@@ -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 }
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user