feat(admin): 分区增删、资料设置、操作条与交互收尾

- POST/DELETE /api/categories(需登录,级联,local 保护)
- profile 设置(头像/名称/角色/简介/位置)应用到资料卡
- 详情操作条:浏览 +1、点赞切换、分享复制、跳转外链
- 编辑按钮垂直居中且留 12px;设置页按钮上间距、保存 Toast、Tabs 去滚动条
- 登录密码可见切换
- 测试 48 passed
This commit is contained in:
stash
2026-10-29 20:15:56 +08:00
parent 3c6c20e534
commit 427338b329
15 changed files with 851 additions and 54 deletions
@@ -8,8 +8,10 @@ import {
Tag,
Text,
Title,
toast,
type BreadcrumbItem,
} from '@stash/ui'
import { useEffect, useState } from 'react'
import type { ItemDetailModel } from '../../data/detail'
import type { RelatedItem } from '../../data/types'
import { RelatedList } from './RelatedList'
@@ -22,6 +24,8 @@ import { RelatedList } from './RelatedList'
* 具体形态由 ItemDetailModel 决定,这里不判断条目类型。
*/
type ItemDetailViewProps = {
/** 条目业务键:浏览 / 点赞要写回数据库 */
itemKey: string
crumbs: BreadcrumbItem[]
model: ItemDetailModel
related: RelatedItem[]
@@ -34,6 +38,7 @@ type ItemDetailViewProps = {
}
export function ItemDetailView({
itemKey,
crumbs,
model,
related,
@@ -42,6 +47,74 @@ export function ItemDetailView({
onEdit,
canEdit,
}: ItemDetailViewProps) {
const [views, setViews] = useState(model.views)
const [likes, setLikes] = useState(model.likes)
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">
@@ -98,19 +171,35 @@ export function ItemDetailView({
<div className="item-actions">
<Space gap="sm">
<Space gap="xs" className="item-stat">
<button
type="button"
className="item-stat"
title="浏览量"
onClick={() => toast.success(`浏览量 ${views}`)}
>
<Icon name="eye_line" />
<Text size="xs" tone="sub">
{model.views}
{views}
</Text>
</Space>
<Space gap="xs" className="item-stat">
</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">
{model.likes}
{likes}
</Text>
</Space>
<IconButton label="分享(待接入)" size="sm" icon={<Icon name="share_3_line" />} />
</button>
<IconButton
label="分享链接"
size="sm"
icon={<Icon name="share_3_line" />}
onClick={share}
/>
</Space>
{/* 占位数据没有真实地址时,按钮置灰而不是打开空白页 */}