Files
stash/stash-web/src/components/content/ItemDetailView.tsx
T
stash 8f688885ed feat(edit): P2 条目就地编辑 + P3 备份/审计/改密
- 迁移 v17:entry.updated_at(乐观锁)+ audit 表
- GET/PATCH /api/items/{key}:字段编辑、标签替换、409 冲突、400 校验
- 详情页 [编辑] → Drawer 表单(标题/简介/标签/评价/正文/封面/跳转),保存后局部刷新
- GET /api/backup:VACUUM INTO 一致性快照,附件下载
- /admin 系统 Tab:修改密码(清空会话)、下载备份
- 审计:item.update / settings.update
- 测试 46 passed(entries 4 例)
- 文档:ADMIN-PLAN P2/P3 记录、README
2026-10-29 12:59:21 +08:00

134 lines
3.9 KiB
TypeScript

import {
Breadcrumb,
Card,
Icon,
IconButton,
Paragraph,
Space,
Tag,
Text,
Title,
type BreadcrumbItem,
} from '@stash/ui'
import type { ItemDetailModel } from '../../data/detail'
import type { RelatedItem } from '../../data/types'
import { RelatedList } from './RelatedList'
/**
* 条目详情页:应用与电影共用。
*
* 版式:面包屑 + 标题 + 主图(方形头像 / 竖版海报)+ 标签行 + 一句话评价 + 正文
* + 操作条(浏览 / 点赞 / 分享 / 跳转)+ 可选横幅 + 「推荐阅读」。
* 具体形态由 ItemDetailModel 决定,这里不判断条目类型。
*/
type ItemDetailViewProps = {
crumbs: BreadcrumbItem[]
model: ItemDetailModel
related: RelatedItem[]
onNavigate: (href: string) => void
onOpenItem: (itemId: string) => void
/** 打开编辑 Drawer */
onEdit: () => void
}
export function ItemDetailView({
crumbs,
model,
related,
onNavigate,
onOpenItem,
onEdit,
}: ItemDetailViewProps) {
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}
<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">
<Space gap="xs" className="item-stat">
<Icon name="eye_line" />
<Text size="xs" tone="sub">
{model.views}
</Text>
</Space>
<Space gap="xs" className="item-stat">
<Icon name="thumb_up_2_line" />
<Text size="xs" tone="sub">
{model.likes}
</Text>
</Space>
<IconButton label="分享(待接入)" size="sm" icon={<Icon name="share_3_line" />} />
</Space>
{/* 占位数据没有真实地址时,按钮置灰而不是打开空白页 */}
{model.href === '#' ? (
<button type="button" className="item-jump" disabled title="暂无跳转地址">
跳转到
<Icon name="a-external_link_line1" />
</button>
) : (
<a className="item-jump" href={model.href} 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>
)
}