优化界面

main
mula.liu 2026-08-18 00:55:44 +08:00
parent 416ef48395
commit 2dbff78698
8 changed files with 624 additions and 53 deletions

View File

@ -3,7 +3,7 @@
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete, select, or_
from sqlalchemy import delete, select, or_, func
from typing import List
import uuid
import secrets
@ -54,6 +54,18 @@ def get_document_count(storage_key: str) -> int:
return 0
async def attach_member_counts(db: AsyncSession, projects) -> dict:
"""批量查询项目的参与人数量(含所有者),返回 {project_id: count}"""
if not projects:
return {}
result = await db.execute(
select(ProjectMember.project_id, func.count(ProjectMember.id))
.where(ProjectMember.project_id.in_([p.id for p in projects]))
.group_by(ProjectMember.project_id)
)
return dict(result.all())
@router.get("/", response_model=dict)
async def get_my_projects(
current_user: User = Depends(get_current_user),
@ -80,10 +92,12 @@ async def get_my_projects(
# 合并结果
all_projects = owned_projects + member_projects
member_counts = await attach_member_counts(db, all_projects)
projects_data = []
for p in all_projects:
p_dict = ProjectResponse.from_orm(p).dict()
p_dict['doc_count'] = get_document_count(p.storage_key)
p_dict['member_count'] = member_counts.get(p.id, 0)
projects_data.append(p_dict)
return success_response(data=projects_data)
@ -99,10 +113,12 @@ async def get_owned_projects(
select(Project).where(Project.owner_id == current_user.id, Project.status == 1)
)
projects = result.scalars().all()
member_counts = await attach_member_counts(db, projects)
projects_data = []
for p in projects:
p_dict = ProjectResponse.from_orm(p).dict()
p_dict['doc_count'] = get_document_count(p.storage_key)
p_dict['member_count'] = member_counts.get(p.id, 0)
projects_data.append(p_dict)
return success_response(data=projects_data)
@ -125,6 +141,9 @@ async def get_shared_projects(
)
projects_with_info = result.all()
projects = [project for project, _, _ in projects_with_info]
member_counts = await attach_member_counts(db, projects)
projects_data = []
for project, owner, member in projects_with_info:
project_dict = ProjectResponse.from_orm(project).dict()
@ -132,6 +151,7 @@ async def get_shared_projects(
project_dict['owner_nickname'] = owner.nickname
project_dict['user_role'] = member.role # 添加用户角色
project_dict['doc_count'] = get_document_count(project.storage_key)
project_dict['member_count'] = member_counts.get(project.id, 0)
projects_data.append(project_dict)
return success_response(data=projects_data)

View File

@ -19,6 +19,7 @@
--toolbar-bg: #fafafa;
--link-color: #1677ff;
--panel-shadow: 0 6px 20px rgba(15, 23, 42, 0.06);
--selected-bg: rgba(22, 119, 255, 0.08);
}
body.dark {
@ -42,6 +43,7 @@ body.dark {
--toolbar-bg: #1f1f1f;
--link-color: #177ddc;
--panel-shadow: 0 10px 24px rgba(0, 0, 0, 0.22);
--selected-bg: rgba(23, 125, 220, 0.18);
}
body {

View File

@ -166,14 +166,36 @@
white-space: nowrap;
}
/* 选中的文件夹样式 */
/* ===== 选中节点:文字、图标与背景同步高亮 ===== */
/* 选中叶子节点(文件)的整行背景 */
.file-tree .ant-menu-item-selected {
background-color: var(--selected-bg) !important;
}
/* 选中文件夹标题的背景 */
.file-tree .folder-selected>.ant-menu-submenu-title {
background-color: var(--item-hover-bg) !important;
background-color: var(--selected-bg) !important;
color: var(--link-color) !important;
}
.file-tree .folder-selected>.ant-menu-submenu-title:hover {
background-color: var(--item-hover-bg) !important;
background-color: var(--selected-bg) !important;
}
/* 选中时文字颜色与字重(修复仅图标高亮、文字不变的问题) */
.file-tree .ant-menu-item-selected .tree-node-wrapper,
.file-tree .folder-selected>.ant-menu-submenu-title .tree-node-wrapper,
.file-tree .ant-menu-submenu-selected>.ant-menu-submenu-title .tree-node-wrapper {
color: var(--link-color) !important;
font-weight: 500;
}
/* 选中时图标颜色(文件夹图标不再依赖内联 style统一走主题色 */
.file-tree .ant-menu-item-selected .ant-menu-item-icon,
.file-tree .folder-selected>.ant-menu-submenu-title .ant-menu-item-icon,
.file-tree .ant-menu-submenu-selected>.ant-menu-submenu-title .ant-menu-item-icon {
color: var(--link-color) !important;
}
.file-tree .ant-menu-item,

View File

@ -1019,14 +1019,11 @@ function DocumentEditor() {
if (!node.isLeaf) {
const isOpen = openKeys.includes(node.key)
const folderIconStyle = isSelected ? { color: '#1890ff' } : undefined
// - classNamestyle
// - // CSS
return {
key: node.key,
label: labelContent,
icon: isOpen
? <FolderOpenOutlined style={folderIconStyle} />
: <FolderOutlined style={folderIconStyle} />,
icon: isOpen ? <FolderOpenOutlined /> : <FolderOutlined />,
children: node.children ? convertTreeToMenuItems(node.children) : [],
className: isSelected ? 'folder-selected' : '',
onTitleClick: () => {

View File

@ -280,6 +280,121 @@
font-size: 14px;
}
/* ===== 文件夹内容视图 ===== */
.docs-folder-view {
padding: 4px 0 24px;
}
.docs-folder-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
margin-bottom: 16px;
background: var(--bg-color-secondary);
border: 1px solid var(--border-color);
border-radius: 10px;
color: var(--text-color-secondary);
font-size: 13px;
min-width: 0;
}
.docs-folder-path {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.docs-folder-path-icon {
flex: none;
color: #faad14;
font-size: 15px;
}
.docs-folder-path-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
color: var(--text-color);
}
.docs-folder-counts {
flex: none;
white-space: nowrap;
}
.docs-folder-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 14px;
}
.docs-folder-item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding: 16px 18px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--card-bg);
cursor: pointer;
transition: all 0.2s ease;
min-width: 0;
}
.docs-folder-item:hover {
border-color: var(--link-color);
box-shadow: var(--panel-shadow);
transform: translateY(-2px);
}
.docs-folder-item:active {
transform: translateY(0);
}
.docs-folder-item-icon {
font-size: 28px;
line-height: 1;
color: #faad14;
}
.docs-folder-item-icon.md {
color: var(--link-color);
}
.docs-folder-item-icon.pdf {
color: #f5222d;
}
.docs-folder-item-name {
width: 100%;
display: inline-flex;
align-items: center;
gap: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 500;
color: var(--text-color);
min-width: 0;
}
.docs-folder-item-share {
flex: none;
color: var(--link-color);
font-size: 12px;
}
.docs-folder-item-count {
font-size: 12px;
color: var(--text-color-secondary);
}
.markdown-body {
font-size: 16px;
line-height: 1.6;

View File

@ -128,7 +128,7 @@ function DocumentPage() {
}
}
const selectFolder = (folderPath, { syncUrl = false } = {}) => {
const selectFolder = (folderPath, { syncUrl = false, expandSelf = false } = {}) => {
setSelectedFile('')
setSelectedNodeKey(folderPath)
setMarkdownContent('')
@ -136,11 +136,28 @@ function DocumentPage() {
setViewMode('folder')
expandParentFolders(`${folderPath}/placeholder`)
//
if (expandSelf) {
setOpenKeys(prev => (prev.includes(folderPath) ? prev : [...prev, folderPath]))
}
if (syncUrl) {
updateSelectedParam(folderPath, false)
}
}
// + md/pdf
const getFolderChildren = (folderPath) => {
const node = folderPath ? findNodeByKey(fileTree, folderPath) : null
if (!node || node.isLeaf) return { folders: [], files: [], node }
const children = node.children || []
const folders = children.filter(c => !c.isLeaf)
const files = children.filter(c =>
c.isLeaf && (c.title.toLowerCase().endsWith('.md') || c.title.toLowerCase().endsWith('.pdf'))
)
return { folders, files, node }
}
const openDocumentPath = (filePath, { syncUrl = false } = {}) => {
setSelectedFile(filePath)
setSelectedNodeKey(filePath)
@ -1257,10 +1274,75 @@ function DocumentPage() {
toolbarTarget={pdfToolbarTarget}
/>
) : viewMode === 'folder' ? (
<div className="docs-folder-placeholder">
<FolderOpenOutlined />
<span>已选择文件夹请从左侧选择 Markdown PDF 文件查看</span>
</div>
(() => {
const { folders, files, node } = getFolderChildren(selectedNodeKey)
if (!node || node.isLeaf) {
return (
<div className="docs-folder-placeholder">
<FolderOpenOutlined />
<span>已选择文件夹请从左侧选择 Markdown PDF 文件查看</span>
</div>
)
}
const total = folders.length + files.length
return (
<div className="docs-folder-view">
<div className="docs-folder-toolbar">
<div className="docs-folder-path" title={node.key}>
<FolderOpenOutlined className="docs-folder-path-icon" />
<span className="docs-folder-path-text">{node.key || '根目录'}</span>
</div>
<span className="docs-folder-counts">
{folders.length} 个文件夹 · {files.length} 个文档
</span>
</div>
{total === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该文件夹为空"
style={{ padding: '48px 0' }}
/>
) : (
<div className="docs-folder-grid">
{folders.map(sub => (
<div
key={sub.key}
className="docs-folder-item"
onClick={() => selectFolder(sub.key, { syncUrl: true, expandSelf: true })}
title={sub.title}
>
<FolderOutlined className="docs-folder-item-icon" />
<span className="docs-folder-item-name">{sub.title}</span>
<span className="docs-folder-item-count">{sub.children?.length || 0} </span>
</div>
))}
{files.map(file => {
const isPdf = file.title.toLowerCase().endsWith('.pdf')
const displayName = isPdf ? file.title : file.title.replace(/.md$/i, '')
return (
<div
key={file.key}
className="docs-folder-item docs-file-item"
onClick={() => openDocumentPath(file.key, { syncUrl: true })}
title={file.title}
>
{isPdf
? <FilePdfOutlined className="docs-folder-item-icon pdf" />
: <FileTextOutlined className="docs-folder-item-icon md" />}
<span className="docs-folder-item-name">
{displayName}
{file.is_shared && <ShareAltOutlined className="docs-folder-item-share" title="已分享" />}
</span>
<span className="docs-folder-item-count">{isPdf ? 'PDF 文档' : 'Markdown 文档'}</span>
</div>
)
})}
</div>
)}
</div>
)
})()
) : isLargeMarkdown ? (
<LargeMarkdownViewer
ref={largeMarkdownRef}

View File

@ -20,8 +20,11 @@
color: var(--text-color-secondary);
}
/* ========== 项目卡片(统一骨架 + 固定高度) ========== */
.project-card {
text-align: center;
height: 236px;
display: flex;
flex-direction: column;
cursor: pointer;
transition: all 0.3s;
position: relative;
@ -35,6 +38,18 @@
box-shadow: var(--panel-shadow);
}
.project-card .ant-card-body {
flex: 1;
display: flex;
flex-direction: column;
padding: 16px 18px 14px;
min-height: 0;
}
.project-card .ant-card-actions {
flex: none;
}
.project-card-public-badge {
position: absolute;
top: 8px;
@ -49,25 +64,67 @@
z-index: 1;
}
.project-card-icon {
margin-bottom: 16px;
/* 卡片主体:标题在上、描述占中间弹性空间、元信息贴底 */
.project-card-body {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.project-card-title-row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
margin-bottom: 8px;
}
.project-card-title-icon {
flex: none;
width: 32px;
height: 32px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
font-size: 17px;
color: #fff;
}
.project-card-title-icon.is-owner {
background: linear-gradient(135deg, #1677ff 0%, #69b1ff 100%);
box-shadow: 0 3px 8px rgba(22, 119, 255, 0.25);
}
body.dark .project-card-title-icon.is-owner {
background: linear-gradient(135deg, #1668dc 0%, #3c89e8 100%);
}
.project-card-title-icon.is-shared {
background: linear-gradient(135deg, #52c41a 0%, #95de64 100%);
box-shadow: 0 3px 8px rgba(82, 196, 26, 0.25);
}
.project-card h3 {
font-size: 16px;
flex: 1;
min-width: 0;
font-size: 15px;
font-weight: 600;
margin-bottom: 8px;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-color);
}
/* 描述为卡片主要信息,占据剩余空间保证高度一致 */
.project-description {
font-size: 14px;
flex: 1;
font-size: 13px;
line-height: 1.6;
color: var(--text-color-secondary);
margin-bottom: 12px;
min-height: 40px;
margin: 0 0 10px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
@ -75,9 +132,151 @@
-webkit-box-orient: vertical;
}
.project-meta {
font-size: 12px;
/* 底部元信息区:三项信息三等分网格(三分格,无分隔线) */
.project-card-meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: center;
column-gap: 6px;
padding-top: 10px;
border-top: 1px dashed var(--border-color);
color: var(--text-color-secondary);
font-size: 12px;
}
/* 每格内容水平垂直居中,超长省略 */
.project-card-meta > * {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
overflow: hidden;
}
.project-stat-item {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
white-space: nowrap;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.project-card-owner {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-width: 0;
max-width: 100%;
}
.project-card-owner-avatar {
flex: none;
width: 22px;
height: 22px;
border-radius: 50%;
background: linear-gradient(135deg, #1677ff 0%, #69b1ff 100%);
color: #fff;
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
.project-card-share .project-card-owner-avatar {
background: linear-gradient(135deg, #52c41a 0%, #95de64 100%);
}
.project-card-owner-name {
font-size: 12px;
font-weight: 500;
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
/* 项目角色徽章(重新设计:药丸式彩色徽章 + 角色图标) */
.project-card-role-badge {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 2px 9px;
border-radius: 999px;
font-size: 11px;
line-height: 16px;
font-weight: 500;
white-space: nowrap;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
/* 所有者 / 管理者:金色徽章 */
.project-card-role-badge.role-owner,
.project-card-role-badge.role-admin {
background: rgba(250, 173, 20, 0.16);
color: #d48806;
}
body.dark .project-card-role-badge.role-owner,
body.dark .project-card-role-badge.role-admin {
background: rgba(250, 173, 20, 0.22);
color: #ffc53d;
}
/* 编辑者:蓝色徽章 */
.project-card-role-badge.role-editor {
background: rgba(22, 119, 255, 0.12);
color: var(--link-color);
}
body.dark .project-card-role-badge.role-editor {
background: rgba(23, 125, 220, 0.25);
color: #4c9be8;
}
/* 查看者:灰色徽章 */
.project-card-role-badge.role-viewer {
background: var(--item-hover-bg);
color: var(--text-color-secondary);
}
/* ========== 我的项目卡片:蓝色管理风 ========== */
.project-card-my {
text-align: left;
border-top: 3px solid var(--link-color);
}
.project-card-my .ant-card-actions {
background: var(--bg-color-secondary);
}
/* ========== 参与的项目卡片:绿色协作风 ========== */
.project-card-share {
text-align: left;
border-top: 3px solid #52c41a;
}
.project-card-share .ant-card-actions {
background: rgba(82, 196, 26, 0.05);
}
.project-card-share .ant-card-actions li span {
color: #52c41a;
}
/* ========== 搜索结果卡片(保持旧版居中风格) ========== */
.project-card-icon {
margin-bottom: 16px;
text-align: center;
}
/* 知识库向量化弹窗:未配置向量模型时的提示条 */
@ -106,19 +305,28 @@ body.dark .kb-warning-banner {
margin-top: 8px;
}
/* 圆点分页指示器样式 */
/* ========== 圆点分页指示器(对称布局) ========== */
.dot-pagination.ant-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
flex-wrap: nowrap;
}
.dot-pagination .ant-pagination-item {
.dot-pagination .ant-pagination-item,
.dot-pagination .ant-pagination-prev,
.dot-pagination .ant-pagination-next {
border: none !important;
background: transparent !important;
min-width: 16px !important;
height: 16px !important;
line-height: 16px !important;
margin: 0 4px !important;
margin: 0 !important;
min-width: 24px !important;
height: 24px !important;
line-height: 24px !important;
display: inline-flex !important;
align-items: center;
justify-content: center;
vertical-align: middle;
}
.dot-pagination .ant-pagination-item a {
@ -132,19 +340,67 @@ body.dark .kb-warning-banner {
border-radius: 50%;
background-color: var(--text-color-secondary);
opacity: 0.3;
margin: 4px auto;
transition: all 0.3s;
}
.dot-pagination .ant-pagination-item-active .pagination-dot {
background-color: var(--link-color);
opacity: 1;
transform: scale(1.2);
transform: scale(1.25);
}
.dot-pagination .ant-pagination-prev,
.dot-pagination .ant-pagination-next {
.dot-pagination .ant-pagination-jump-prev,
.dot-pagination .ant-pagination-jump-next {
border: none !important;
background: transparent !important;
margin: 0 !important;
min-width: 24px !important;
height: 24px !important;
line-height: 24px !important;
display: inline-flex !important;
align-items: center;
justify-content: center;
color: var(--text-color-secondary);
font-size: 12px;
}
.dot-pagination .ant-pagination-jump-prev .ant-pagination-item-container,
.dot-pagination .ant-pagination-jump-next .ant-pagination-item-container {
display: inline-flex;
align-items: center;
justify-content: center;
}
.dot-pagination .ant-pagination-prev .ant-pagination-item-link,
.dot-pagination .ant-pagination-next .ant-pagination-item-link {
border: none !important;
background: transparent !important;
color: var(--text-color-secondary);
font-size: 12px;
line-height: 1;
height: 100%;
width: 100%;
display: inline-flex !important;
align-items: center;
justify-content: center;
border-radius: 6px;
padding: 0;
transition: all 0.2s;
}
.dot-pagination .ant-pagination-prev:hover .ant-pagination-item-link,
.dot-pagination .ant-pagination-next:hover .ant-pagination-item-link {
color: var(--link-color);
background: var(--item-hover-bg) !important;
}
.dot-pagination .ant-pagination-prev:active .ant-pagination-item-link,
.dot-pagination .ant-pagination-next:active .ant-pagination-item-link {
background: var(--item-hover-bg) !important;
}
.dot-pagination .ant-pagination-disabled .ant-pagination-item-link {
color: var(--text-color-secondary) !important;
opacity: 0.35;
cursor: not-allowed;
}

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination, Progress, Alert, List, Spin, Tooltip } from 'antd'
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined, SettingOutlined, DatabaseOutlined, ReloadOutlined } from '@ant-design/icons'
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, GithubOutlined, CheckOutlined, SwapOutlined, SettingOutlined, DatabaseOutlined, ReloadOutlined, CalendarOutlined, FileTextOutlined, CrownOutlined } from '@ant-design/icons'
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, transferProject } from '@/api/project'
import { getProjectShareInfo, updateProjectShareSettings } from '@/api/share'
import { getUserList } from '@/api/users'
@ -9,6 +9,7 @@ import { searchDocuments } from '@/api/search'
import ListActionBar from '@/components/ListActionBar/ListActionBar'
import Toast from '@/components/Toast/Toast'
import useProjectKnowledge from './useProjectKnowledge'
import useUserStore from '@/stores/userStore'
import './ProjectList.css'
function ProjectList({ type = 'my' }) {
@ -36,9 +37,62 @@ function ProjectList({ type = 'my' }) {
const [transferModalVisible, setTransferModalVisible] = useState(false)
const [transferForm] = Form.useForm()
const navigate = useNavigate()
const { user: currentUser } = useUserStore()
const [currentPage, setCurrentPage] = useState(1)
const pageSize = 8
// ->
const projectRoleLabel = (role) => {
const roleMap = { admin: '管理者', editor: '编辑者', viewer: '查看者' }
return roleMap[role] || role || '查看者'
}
//
const formatDate = (dateStr) => {
if (!dateStr) return ''
try {
const d = new Date(dateStr)
if (Number.isNaN(d.getTime())) return ''
return d.toLocaleDateString('zh-CN')
} catch {
return ''
}
}
// -> ->
const cardOwnerName = (project) => {
if (type === 'my') {
return currentUser?.nickname || currentUser?.username || '我'
}
return project.owner_nickname || project.owner_name || '未知'
}
//
const cardOwnerInitial = (project) => {
const name = cardOwnerName(project)
return (name || '?').charAt(0).toUpperCase()
}
// -> ->
const cardRoleLabel = (project) => {
if (type === 'my') return '所有者'
return projectRoleLabel(project.user_role)
}
//
const cardRoleClass = (project) => {
if (type === 'my') return 'role-owner'
const roleMap = { admin: 'role-admin', editor: 'role-editor', viewer: 'role-viewer' }
return roleMap[project.user_role] || 'role-viewer'
}
//
const cardRoleIcon = (project) => {
if (type === 'my' || project.user_role === 'admin') return <CrownOutlined />
if (project.user_role === 'editor') return <EditOutlined />
return <EyeOutlined />
}
const {
kbModalVisible,
progress,
@ -620,7 +674,7 @@ function ProjectList({ type = 'my' }) {
<Col xs={24} sm={12} md={8} lg={6} key={project.id}>
<Card
hoverable
className="project-card"
className={type === 'share' ? 'project-card project-card-share' : 'project-card project-card-my'}
onClick={() => handleOpenProject(project.id)}
actions={type === 'my' ? [
<Tooltip key="settings" title="项目设置"><SettingOutlined onClick={(e) => handleEdit(e, project)} /></Tooltip>,
@ -628,30 +682,53 @@ function ProjectList({ type = 'my' }) {
<Tooltip key="kb" title="知识库向量化"><DatabaseOutlined onClick={(e) => handleKnowledge(e, project)} /></Tooltip>,
<Tooltip key="members" title="成员管理"><TeamOutlined onClick={(e) => handleMembers(e, project)} /></Tooltip>,
] : [
<EyeOutlined key="view" />,
<Tooltip key="view" title="进入项目"><EyeOutlined /></Tooltip>,
]}
>
{/* 公开项目标识 */}
{project.is_public === 1 && (
<div className="project-card-public-badge">公开</div>
)}
<div className="project-card-icon">
<FolderOutlined style={{ fontSize: 48, color: '#1890ff' }} />
</div>
<h3>{project.name}</h3>
<p className="project-description">{project.description || '暂无描述'}</p>
<div className="project-meta">
<span>文档数: {project.doc_count || 0}</span>
{type === 'share' && project.owner_name && (
<span style={{ marginLeft: 12 }}>
所有者: {project.owner_nickname || project.owner_name}
{/* 统一的卡片骨架:标题 + 描述(主要信息)+ 底部统计/归属/角色 */}
<div className="project-card-body">
<div className="project-card-title-row">
<span className={`project-card-title-icon ${type === 'my' ? 'is-owner' : 'is-shared'}`}>
{type === 'my' ? <FolderOutlined /> : <TeamOutlined />}
</span>
)}
{type === 'share' && project.user_role && (
<span style={{ marginLeft: 12 }}>
角色: {project.user_role === 'admin' ? '管理者' : project.user_role === 'editor' ? '编辑者' : '查看者'}
</span>
)}
<h3 title={project.name}>{project.name}</h3>
</div>
<p className="project-description">{project.description || '暂无描述'}</p>
{/* 归属人信息 + 项目信息:单行展示 */}
<div className="project-card-meta">
{type === 'my' ? (
<>
<span className="project-stat-item" title="文档数量">
<FileTextOutlined /> {project.doc_count || 0} 文档
</span>
<span className="project-stat-item" title="创建时间">
<CalendarOutlined /> {formatDate(project.created_at) || '—'}
</span>
<span className="project-stat-item" title="项目参与人数量">
<TeamOutlined /> {project.member_count ?? 0} 人参与
</span>
</>
) : (
<>
<span className="project-stat-item" title="文档数量">
<FileTextOutlined /> {project.doc_count || 0} 文档
</span>
<span className="project-card-owner" title={cardOwnerName(project)}>
<span className="project-card-owner-avatar">{cardOwnerInitial(project)}</span>
<span className="project-card-owner-name">{cardOwnerName(project)}</span>
</span>
<span className={`project-card-role-badge ${cardRoleClass(project)}`} title={cardRoleLabel(project)}>
{cardRoleIcon(project)}
{cardRoleLabel(project)}
</span>
</>
)}
</div>
</div>
</Card>
</Col>