- 条目保存时规范化 href(裸域名补 https,拒绝 javascript:/data: 等协议),详情按钮按解析结果置灰/跳转
- settings.sidebar 增加 hidden_groups / shown_groups / group_order,侧栏 Tab 支持分组显隐与排序
- 新增 PATCH /api/categories/{id} 与「归类」Tab:编辑类型/标题/图标/色条/条数;改类型自动迁移条目与首页卡片
- 测试 54 passed(href 规范化 3 例、分类更新 3 例)
231 lines
7.0 KiB
TypeScript
231 lines
7.0 KiB
TypeScript
import {
|
|
Breadcrumb,
|
|
Card,
|
|
Icon,
|
|
IconButton,
|
|
Paragraph,
|
|
Space,
|
|
Tag,
|
|
Text,
|
|
Title,
|
|
toast,
|
|
type BreadcrumbItem,
|
|
} from '@stash/ui'
|
|
import { useEffect, useState } from 'react'
|
|
import type { ItemDetailModel } from '../../data/detail'
|
|
import { resolveHref } from '../../data/detail'
|
|
import type { RelatedItem } from '../../data/types'
|
|
import { RelatedList } from './RelatedList'
|
|
|
|
/**
|
|
* 条目详情页:应用与电影共用。
|
|
*
|
|
* 版式:面包屑 + 标题 + 主图(方形头像 / 竖版海报)+ 标签行 + 一句话评价 + 正文
|
|
* + 操作条(浏览 / 点赞 / 分享 / 跳转)+ 可选横幅 + 「推荐阅读」。
|
|
* 具体形态由 ItemDetailModel 决定,这里不判断条目类型。
|
|
*/
|
|
type ItemDetailViewProps = {
|
|
/** 条目业务键:浏览 / 点赞要写回数据库 */
|
|
itemKey: string
|
|
crumbs: BreadcrumbItem[]
|
|
model: ItemDetailModel
|
|
related: RelatedItem[]
|
|
onNavigate: (href: string) => void
|
|
onOpenItem: (itemId: string) => void
|
|
/** 打开编辑 Drawer */
|
|
onEdit: () => void
|
|
/** 只有登录后才显示 [编辑] */
|
|
canEdit: boolean
|
|
}
|
|
|
|
export function ItemDetailView({
|
|
itemKey,
|
|
crumbs,
|
|
model,
|
|
related,
|
|
onNavigate,
|
|
onOpenItem,
|
|
onEdit,
|
|
canEdit,
|
|
}: ItemDetailViewProps) {
|
|
const [views, setViews] = useState(model.views)
|
|
const [likes, setLikes] = useState(model.likes)
|
|
// 历史数据可能是裸域名或占位符 #;统一解析成可跳转地址后再渲染
|
|
const jumpHref = resolveHref(model.href)
|
|
const [liked, setLiked] = useState(() => {
|
|
try {
|
|
return window.localStorage.getItem(`stash-liked:${itemKey}`) === '1'
|
|
} catch {
|
|
return false
|
|
}
|
|
})
|
|
|
|
// 进入详情页记一次浏览(每个页面打开一次)
|
|
useEffect(() => {
|
|
let alive = true
|
|
fetch(`/api/items/${encodeURIComponent(itemKey)}/view`, {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
})
|
|
.then((response) => (response.ok ? response.json() : null))
|
|
.then((payload: { views?: number; likes?: number } | null) => {
|
|
if (!alive || payload === null) return
|
|
if (typeof payload.views === 'number') setViews(payload.views)
|
|
if (typeof payload.likes === 'number') setLikes(payload.likes)
|
|
})
|
|
.catch(() => undefined)
|
|
|
|
return () => {
|
|
alive = false
|
|
}
|
|
}, [itemKey])
|
|
|
|
const toggleLike = () => {
|
|
const next = !liked
|
|
setLiked(next)
|
|
setLikes((current) => Math.max(0, current + (next ? 1 : -1)))
|
|
|
|
try {
|
|
window.localStorage.setItem(`stash-liked:${itemKey}`, next ? '1' : '0')
|
|
} catch {
|
|
// 隐私模式下 localStorage 不可用,忽略即可
|
|
}
|
|
|
|
fetch(`/api/items/${encodeURIComponent(itemKey)}/like`, {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ liked: next }),
|
|
})
|
|
.then((response) => (response.ok ? response.json() : null))
|
|
.then((payload: { views?: number; likes?: number } | null) => {
|
|
if (payload === null) return
|
|
if (typeof payload.views === 'number') setViews(payload.views)
|
|
if (typeof payload.likes === 'number') setLikes(payload.likes)
|
|
})
|
|
.catch(() => undefined)
|
|
}
|
|
|
|
const share = () => {
|
|
const url = window.location.href
|
|
const clipboard = navigator.clipboard
|
|
if (clipboard === undefined) {
|
|
toast.error('当前浏览器不支持复制,请手动复制地址栏')
|
|
return
|
|
}
|
|
void clipboard
|
|
.writeText(url)
|
|
.then(() => toast.success('链接已复制'))
|
|
.catch(() => toast.error('复制失败,请手动复制地址栏'))
|
|
}
|
|
return (
|
|
<div className="detail-view item-view">
|
|
<Card padding="none" className="detail-nav">
|
|
<Breadcrumb className="detail-breadcrumb" items={crumbs} onNavigate={onNavigate} />
|
|
</Card>
|
|
|
|
<Card padding="none" className="item-card">
|
|
<Title level={1} as="h2" weight="semibold" className="item-title">
|
|
{model.title}
|
|
{canEdit && (
|
|
<button type="button" className="item-edit" title="编辑条目" onClick={onEdit}>
|
|
[编辑]
|
|
</button>
|
|
)}
|
|
</Title>
|
|
|
|
<div className={`item-cover item-cover--${model.shape}`}>
|
|
{model.cover !== undefined ? (
|
|
<img src={model.cover} alt={model.title} loading="lazy" />
|
|
) : (
|
|
<span className="item-cover-letter">{model.fallbackIcon ?? '•'}</span>
|
|
)}
|
|
</div>
|
|
|
|
{model.tags.length > 0 && (
|
|
<Space gap="sm" wrap>
|
|
{model.tags.map((tag) => (
|
|
<Tag key={tag} tone="primary" className="item-tag">
|
|
{tag}
|
|
</Tag>
|
|
))}
|
|
</Space>
|
|
)}
|
|
|
|
{model.comment !== undefined && (
|
|
<p className="item-quote">
|
|
<span className="item-quote-mark" aria-hidden="true">
|
|
“
|
|
</span>
|
|
<span className="item-quote-text">{model.comment}</span>
|
|
</p>
|
|
)}
|
|
|
|
{model.paragraphs.map((paragraph, index) => (
|
|
<Paragraph
|
|
key={`${index}-${paragraph.slice(0, 8)}`}
|
|
tone="sub"
|
|
size="md"
|
|
className="item-desc"
|
|
>
|
|
{paragraph}
|
|
</Paragraph>
|
|
))}
|
|
|
|
<div className="item-actions">
|
|
<Space gap="sm">
|
|
<button
|
|
type="button"
|
|
className="item-stat"
|
|
title="浏览量"
|
|
onClick={() => toast.success(`浏览量 ${views}`)}
|
|
>
|
|
<Icon name="eye_line" />
|
|
<Text size="xs" tone="sub">
|
|
{views}
|
|
</Text>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`item-stat${liked ? ' is-liked' : ''}`}
|
|
title={liked ? '取消点赞' : '点赞'}
|
|
aria-pressed={liked}
|
|
onClick={toggleLike}
|
|
>
|
|
<Icon name="thumb_up_2_line" />
|
|
<Text size="xs" tone="sub">
|
|
{likes}
|
|
</Text>
|
|
</button>
|
|
<IconButton
|
|
label="分享链接"
|
|
size="sm"
|
|
icon={<Icon name="share_3_line" />}
|
|
onClick={share}
|
|
/>
|
|
</Space>
|
|
|
|
{/* 没有真实地址(空 / # / 不安全协议)时置灰,避免打开错误页面 */}
|
|
{jumpHref === undefined ? (
|
|
<button type="button" className="item-jump" disabled title="暂无跳转地址,可在编辑里添加">
|
|
跳转到
|
|
<Icon name="a-external_link_line1" />
|
|
</button>
|
|
) : (
|
|
<a className="item-jump" href={jumpHref} target="_blank" rel="noreferrer">
|
|
跳转到
|
|
<Icon name="a-external_link_line1" />
|
|
</a>
|
|
)}
|
|
</div>
|
|
|
|
{model.banner !== undefined && (
|
|
<img className="item-banner" src={model.banner} alt="" loading="lazy" />
|
|
)}
|
|
</Card>
|
|
|
|
<RelatedList items={related} onOpen={onOpenItem} />
|
|
</div>
|
|
)
|
|
}
|