a
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# Backend
|
||||
DEBUG=True
|
||||
DATABASE_URL=sqlite:///./cms.db
|
||||
SECRET_KEY=your-secret-key-change-in-production
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:10002
|
||||
|
||||
# Frontend
|
||||
VITE_API_URL=http://localhost:10003
|
||||
Binary file not shown.
@@ -0,0 +1,763 @@
|
||||
# Personal Blog - Admin Dashboard UI 设计与实现文档
|
||||
|
||||
> **设计风格**: Modern Admin Dashboard
|
||||
> **版本**: v2.0
|
||||
> **更新日期**: 2026-04-19
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计概述
|
||||
|
||||
### 1.1 设计理念
|
||||
|
||||
Admin Dashboard 采用现代化的 SaaS 管理后台设计,强调:
|
||||
|
||||
- **专业感**: 深色侧边栏 + 浅色内容区的高对比度设计
|
||||
- **效率优先**: 信息密度适中,关键数据一目了然
|
||||
- **视觉层次**: 清晰的视觉层级引导用户操作
|
||||
- **一致性**: 所有组件遵循统一的设计语言
|
||||
|
||||
### 1.2 与访客端的区别
|
||||
|
||||
| 维度 | Admin Dashboard | 访客端 (Neobrutalism) |
|
||||
|------|-----------------|----------------------|
|
||||
| 用户 | 管理员 | 普通访客 |
|
||||
| 目标 | 高效管理内容 | 阅读体验 |
|
||||
| 风格 | 专业、现代 | 个性、艺术 |
|
||||
| 色调 | 深墨绿 + 翠绿 | 奶油色 + 粉红 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 色彩系统
|
||||
|
||||
### 2.1 CSS 变量定义
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* 侧边栏 */
|
||||
--admin-sidebar-bg: #0F172A;
|
||||
--admin-sidebar-hover: #1E293B;
|
||||
--admin-sidebar-active: #334155;
|
||||
--admin-sidebar-text: #94A3B8;
|
||||
--admin-sidebar-text-active: #FFFFFF;
|
||||
|
||||
/* 主内容区 */
|
||||
--admin-bg: #F8FAFC;
|
||||
--admin-card: #FFFFFF;
|
||||
--admin-border: #E2E8F0;
|
||||
|
||||
/* 主色调 - 翠绿 */
|
||||
--admin-primary: #10B981;
|
||||
--admin-primary-hover: #059669;
|
||||
--admin-primary-light: #D1FAE5;
|
||||
--admin-primary-subtle: #ECFDF5;
|
||||
|
||||
/* 状态色 */
|
||||
--admin-success: #10B981;
|
||||
--admin-warning: #F59E0B;
|
||||
--admin-danger: #EF4444;
|
||||
--admin-info: #3B82F6;
|
||||
|
||||
/* 文字 */
|
||||
--admin-text-primary: #0F172A;
|
||||
--admin-text-secondary: #64748B;
|
||||
--admin-text-muted: #94A3B8;
|
||||
--admin-text-inverse: #FFFFFF;
|
||||
|
||||
/* 阴影 */
|
||||
--admin-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--admin-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
--admin-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--admin-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
/* 圆角 */
|
||||
--admin-radius-sm: 6px;
|
||||
--admin-radius: 8px;
|
||||
--admin-radius-lg: 12px;
|
||||
--admin-radius-xl: 16px;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Tailwind 配置扩展
|
||||
|
||||
```javascript
|
||||
// tailwind.config.js
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
admin: {
|
||||
sidebar: {
|
||||
DEFAULT: '#0F172A',
|
||||
hover: '#1E293B',
|
||||
active: '#334155',
|
||||
text: '#94A3B8',
|
||||
'text-active': '#FFFFFF',
|
||||
},
|
||||
bg: '#F8FAFC',
|
||||
card: '#FFFFFF',
|
||||
border: '#E2E8F0',
|
||||
primary: {
|
||||
DEFAULT: '#10B981',
|
||||
hover: '#059669',
|
||||
light: '#D1FAE5',
|
||||
subtle: '#ECFDF5',
|
||||
},
|
||||
text: {
|
||||
primary: '#0F172A',
|
||||
secondary: '#64748B',
|
||||
muted: '#94A3B8',
|
||||
}
|
||||
}
|
||||
},
|
||||
boxShadow: {
|
||||
'admin': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
|
||||
'admin-md': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
'admin-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 布局架构
|
||||
|
||||
### 3.1 整体结构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ┌────────────┐ ┌─────────────────────────────────────────┐ │
|
||||
│ │ │ │ Header: Logo + Search + Actions │ │
|
||||
│ │ Sidebar │ ├─────────────────────────────────────────┤ │
|
||||
│ │ (240px) │ │ │ │
|
||||
│ │ │ │ Breadcrumbs (可选) │ │
|
||||
│ │ 🏠 Dashboard│ │ │ │
|
||||
│ │ 📝 Posts │ │ ┌─────────────────────────────────┐ │ │
|
||||
│ │ 🏷️ Tags │ │ │ Page Title + Actions │ │ │
|
||||
│ │ 📁 Categories│ │ └─────────────────────────────────┘ │ │
|
||||
│ │ 🖼️ Images │ │ │ │
|
||||
│ │ 💬 Comments│ │ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ │
|
||||
│ │ ⚙️ Settings│ │ │Stat │ │Stat │ │Stat │ │Stat │ │ │
|
||||
│ │ │ │ └─────┘ └─────┘ └─────┘ └─────┘ │ │
|
||||
│ │ ───────────│ │ │ │
|
||||
│ │ 🔗 External│ │ ┌─────────────────────────────────┐ │ │
|
||||
│ │ 📊 Analytics│ │ │ Main Content │ │ │
|
||||
│ │ 🛒 Shop │ │ │ │ │ │
|
||||
│ └────────────┘ │ └─────────────────────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 布局组件
|
||||
|
||||
```jsx
|
||||
// AdminLayout.jsx
|
||||
const AdminLayout = ({ children }) => {
|
||||
return (
|
||||
<div className="min-h-screen bg-admin-bg flex">
|
||||
{/* 侧边栏 */}
|
||||
<Sidebar className="w-60 bg-admin-sidebar fixed h-full" />
|
||||
|
||||
{/* 主内容区 */}
|
||||
<main className="flex-1 ml-60 min-h-screen">
|
||||
<Header className="sticky top-0 z-30 bg-white border-b border-admin-border" />
|
||||
<div className="p-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 组件实现规范
|
||||
|
||||
### 4.1 侧边栏 (Sidebar)
|
||||
|
||||
```jsx
|
||||
const Sidebar = () => {
|
||||
const menuGroups = [
|
||||
{
|
||||
title: '内容管理',
|
||||
items: [
|
||||
{ icon: LayoutDashboard, label: 'Dashboard', path: '/admin' },
|
||||
{ icon: FileText, label: '文章管理', path: '/admin/posts' },
|
||||
{ icon: FolderOpen, label: '分类管理', path: '/admin/categories' },
|
||||
{ icon: Tags, label: '标签管理', path: '/admin/tags' },
|
||||
{ icon: Image, label: '图片管理', path: '/admin/images' },
|
||||
{ icon: MessageSquare, label: '评论管理', path: '/admin/comments' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '系统设置',
|
||||
items: [
|
||||
{ icon: Settings, label: '系统设置', path: '/admin/settings' },
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<aside className="w-60 bg-admin-sidebar min-h-screen flex flex-col">
|
||||
{/* Logo */}
|
||||
<div className="h-16 flex items-center px-6 border-b border-gray-800">
|
||||
<span className="text-xl font-bold text-white">Blog Admin</span>
|
||||
</div>
|
||||
|
||||
{/* Menu */}
|
||||
<nav className="flex-1 py-4 px-3 space-y-6 overflow-y-auto">
|
||||
{menuGroups.map((group, idx) => (
|
||||
<div key={idx}>
|
||||
<h3 className="px-3 text-xs font-semibold text-admin-sidebar-text uppercase tracking-wider mb-2">
|
||||
{group.title}
|
||||
</h3>
|
||||
<ul className="space-y-1">
|
||||
{group.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) => `
|
||||
flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
transition-colors duration-200
|
||||
${isActive
|
||||
? 'bg-admin-sidebar-active text-white'
|
||||
: 'text-admin-sidebar-text hover:bg-admin-sidebar-hover hover:text-white'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User */}
|
||||
<div className="p-4 border-t border-gray-800">
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-admin-sidebar-hover">
|
||||
<div className="w-8 h-8 rounded-full bg-admin-primary flex items-center justify-center text-white font-medium">
|
||||
A
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">Admin</p>
|
||||
<p className="text-xs text-admin-sidebar-text truncate">admin@blog.com</p>
|
||||
</div>
|
||||
<LogOut className="w-4 h-4 text-admin-sidebar-text hover:text-white cursor-pointer" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 头部 (Header)
|
||||
|
||||
```jsx
|
||||
const Header = () => {
|
||||
return (
|
||||
<header className="h-16 bg-white border-b border-admin-border flex items-center justify-between px-6">
|
||||
{/* Search */}
|
||||
<div className="flex-1 max-w-md">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-admin-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索文章、评论..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-50 border border-transparent rounded-lg text-sm
|
||||
placeholder:text-admin-text-muted
|
||||
focus:bg-white focus:border-admin-primary/30 focus:ring-2 focus:ring-admin-primary/20
|
||||
transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="relative p-2 text-admin-text-secondary hover:text-admin-text-primary hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-admin-danger rounded-full"></span>
|
||||
</button>
|
||||
<button className="p-2 text-admin-text-secondary hover:text-admin-text-primary hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<HelpCircle className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="h-6 w-px bg-admin-border mx-1"></div>
|
||||
<a href="/" target="_blank" className="flex items-center gap-2 px-3 py-1.5 text-sm text-admin-primary hover:bg-admin-primary-subtle rounded-lg transition-colors">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
查看网站
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 统计卡片 (StatsCard)
|
||||
|
||||
```jsx
|
||||
const StatsCard = ({ title, value, change, changeType, icon: Icon, color = 'primary' }) => {
|
||||
const colorMap = {
|
||||
primary: 'bg-admin-primary-light text-admin-primary',
|
||||
success: 'bg-green-100 text-green-600',
|
||||
warning: 'bg-amber-100 text-amber-600',
|
||||
danger: 'bg-red-100 text-red-600',
|
||||
info: 'bg-blue-100 text-blue-600',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl p-6 border border-admin-border shadow-admin hover:shadow-admin-md transition-shadow duration-200">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-admin-text-secondary">{title}</p>
|
||||
<p className="text-2xl font-bold text-admin-text-primary mt-2">{value}</p>
|
||||
{change && (
|
||||
<div className="flex items-center mt-2 text-sm">
|
||||
<span className={`flex items-center font-medium ${
|
||||
changeType === 'up' ? 'text-admin-success' : 'text-admin-danger'
|
||||
}`}>
|
||||
{changeType === 'up' ? <TrendingUp className="w-4 h-4 mr-1" /> : <TrendingDown className="w-4 h-4 mr-1" />}
|
||||
{change}
|
||||
</span>
|
||||
<span className="text-admin-text-muted ml-2">较上月</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-3 rounded-xl ${colorMap[color]}`}>
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 数据表格 (DataTable)
|
||||
|
||||
```jsx
|
||||
const DataTable = ({ columns, data, onEdit, onDelete }) => {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-admin-border shadow-admin overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-admin-border">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={col.key} className="px-6 py-4 text-left text-xs font-semibold text-admin-text-secondary uppercase tracking-wider">
|
||||
{col.title}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-admin-text-secondary uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-admin-border">
|
||||
{data.map((row, idx) => (
|
||||
<tr key={idx} className="hover:bg-gray-50 transition-colors">
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-6 py-4">
|
||||
{col.render ? col.render(row[col.key], row) : row[col.key]}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => onEdit(row)}
|
||||
className="p-2 text-admin-text-muted hover:text-admin-primary hover:bg-admin-primary-subtle rounded-lg transition-colors"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(row)}
|
||||
className="p-2 text-admin-text-muted hover:text-admin-danger hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 按钮组件 (Button)
|
||||
|
||||
```jsx
|
||||
const Button = ({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
disabled = false,
|
||||
...props
|
||||
}) => {
|
||||
const variants = {
|
||||
primary: 'bg-admin-primary text-white hover:bg-admin-primary-hover focus:ring-admin-primary',
|
||||
secondary: 'bg-white text-admin-text-primary border border-admin-border hover:bg-gray-50 focus:ring-gray-200',
|
||||
ghost: 'bg-transparent text-admin-text-secondary hover:bg-gray-100 hover:text-admin-text-primary',
|
||||
danger: 'bg-admin-danger text-white hover:bg-red-600 focus:ring-red-500',
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-xs rounded-md',
|
||||
md: 'px-4 py-2 text-sm rounded-lg',
|
||||
lg: 'px-6 py-2.5 text-base rounded-lg',
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
disabled={disabled || loading}
|
||||
className={`
|
||||
inline-flex items-center justify-center gap-2 font-medium
|
||||
transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${variants[variant]}
|
||||
${sizes[size]}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 表单组件
|
||||
|
||||
```jsx
|
||||
// Input
|
||||
const Input = ({ label, error, ...props }) => {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="block text-sm font-medium text-admin-text-secondary">{label}</label>}
|
||||
<input
|
||||
className={`
|
||||
w-full px-4 py-2.5 rounded-lg border bg-white text-admin-text-primary
|
||||
placeholder:text-admin-text-muted
|
||||
focus:outline-none focus:ring-2 focus:ring-admin-primary/20 focus:border-admin-primary
|
||||
transition-all duration-200
|
||||
${error ? 'border-admin-danger focus:border-admin-danger focus:ring-red-200' : 'border-admin-border'}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="text-sm text-admin-danger">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Select
|
||||
const Select = ({ label, options, ...props }) => {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="block text-sm font-medium text-admin-text-secondary">{label}</label>}
|
||||
<div className="relative">
|
||||
<select
|
||||
className="w-full px-4 py-2.5 rounded-lg border border-admin-border bg-white text-admin-text-primary
|
||||
appearance-none focus:outline-none focus:ring-2 focus:ring-admin-primary/20 focus:border-admin-primary
|
||||
transition-all duration-200"
|
||||
{...props}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-admin-text-muted pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Textarea
|
||||
const Textarea = ({ label, error, rows = 4, ...props }) => {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="block text-sm font-medium text-admin-text-secondary">{label}</label>}
|
||||
<textarea
|
||||
rows={rows}
|
||||
className={`
|
||||
w-full px-4 py-2.5 rounded-lg border bg-white text-admin-text-primary
|
||||
placeholder:text-admin-text-muted resize-vertical
|
||||
focus:outline-none focus:ring-2 focus:ring-admin-primary/20 focus:border-admin-primary
|
||||
transition-all duration-200
|
||||
${error ? 'border-admin-danger focus:border-admin-danger focus:ring-red-200' : 'border-admin-border'}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="text-sm text-admin-danger">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.7 卡片组件 (Card)
|
||||
|
||||
```jsx
|
||||
const Card = ({ children, className = '' }) => {
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border border-admin-border shadow-admin ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CardHeader = ({ title, description, action }) => {
|
||||
return (
|
||||
<div className="px-6 py-4 border-b border-admin-border flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-admin-text-primary">{title}</h3>
|
||||
{description && <p className="text-sm text-admin-text-secondary mt-1">{description}</p>}
|
||||
</div>
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CardContent = ({ children, className = '' }) => {
|
||||
return <div className={`px-6 py-4 ${className}`}>{children}</div>
|
||||
}
|
||||
|
||||
const CardFooter = ({ children, className = '' }) => {
|
||||
return (
|
||||
<div className={`px-6 py-4 border-t border-admin-border bg-gray-50 rounded-b-xl ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.8 模态框 (Modal)
|
||||
|
||||
```jsx
|
||||
const Modal = ({ isOpen, onClose, title, children, footer }) => {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-xl shadow-admin-lg w-full max-w-lg mx-4 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-admin-border">
|
||||
<h3 className="text-lg font-semibold text-admin-text-primary">{title}</h3>
|
||||
<button onClick={onClose} className="p-1 text-admin-text-muted hover:text-admin-text-primary rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<div className="px-6 py-4 border-t border-admin-border bg-gray-50 rounded-b-xl flex justify-end gap-3">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.9 Toast 通知
|
||||
|
||||
```jsx
|
||||
const Toast = ({ message, type = 'success', onClose }) => {
|
||||
const icons = {
|
||||
success: CheckCircle,
|
||||
error: XCircle,
|
||||
warning: AlertTriangle,
|
||||
info: Info,
|
||||
}
|
||||
|
||||
const colors = {
|
||||
success: 'bg-green-50 border-green-200 text-green-800',
|
||||
error: 'bg-red-50 border-red-200 text-red-800',
|
||||
warning: 'bg-amber-50 border-amber-200 text-amber-800',
|
||||
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||
}
|
||||
|
||||
const Icon = icons[type]
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onClose, 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div className={`fixed top-4 right-4 z-50 flex items-center gap-3 px-4 py-3 rounded-lg border shadow-admin-lg animate-in slide-in-from-right ${colors[type]}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">{message}</span>
|
||||
<button onClick={onClose} className="ml-2 hover:opacity-70">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.10 分页组件 (Pagination)
|
||||
|
||||
```jsx
|
||||
const Pagination = ({ currentPage, totalPages, onPageChange }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-white border-t border-admin-border">
|
||||
<div className="text-sm text-admin-text-secondary">
|
||||
第 <span className="font-medium text-admin-text-primary">{currentPage}</span> 页,共 <span className="font-medium">{totalPages}</span> 页
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 rounded-lg border border-admin-border text-admin-text-secondary hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
const page = i + 1
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
className={`w-9 h-9 rounded-lg text-sm font-medium transition-colors ${
|
||||
currentPage === page
|
||||
? 'bg-admin-primary text-white'
|
||||
: 'text-admin-text-secondary hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 rounded-lg border border-admin-border text-admin-text-secondary hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 页面实现
|
||||
|
||||
### 5.1 Dashboard 页面
|
||||
|
||||
```jsx
|
||||
const Dashboard = () => {
|
||||
const stats = [
|
||||
{ title: '总文章数', value: '128', change: '12%', changeType: 'up', icon: FileText },
|
||||
{ title: '本月发布', value: '24', change: '8%', changeType: 'up', icon: TrendingUp },
|
||||
{ title: '总浏览量', value: '45.2K', change: '23%', changeType: 'up', icon: Eye },
|
||||
{ title: '待审评论', value: '18', change: '5%', changeType: 'down', icon: MessageSquare },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-admin-text-primary">Dashboard</h1>
|
||||
<p className="text-admin-text-secondary mt-1">欢迎回来,今天也是创作的好日子!</p>
|
||||
</div>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4" />
|
||||
写文章
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map((stat) => (
|
||||
<StatsCard key={stat.title} {...stat} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recent Posts */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="最近文章"
|
||||
description="最近发布的 5 篇文章"
|
||||
action={<Button variant="ghost" size="sm">查看全部 →</Button>}
|
||||
/>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'title', title: '标题' },
|
||||
{ key: 'category', title: '分类' },
|
||||
{ key: 'views', title: '浏览量' },
|
||||
{ key: 'date', title: '发布时间' },
|
||||
]}
|
||||
data={recentPosts}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 文件结构
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── components/
|
||||
│ └── admin/
|
||||
│ ├── AdminLayout.jsx # 后台布局
|
||||
│ ├── Sidebar.jsx # 侧边栏
|
||||
│ ├── Header.jsx # 顶部导航
|
||||
│ ├── StatsCard.jsx # 统计卡片
|
||||
│ ├── DataTable.jsx # 数据表格
|
||||
│ ├── Button.jsx # 按钮
|
||||
│ ├── Card.jsx # 卡片组件
|
||||
│ ├── Input.jsx # 输入框
|
||||
│ ├── Select.jsx # 下拉选择
|
||||
│ ├── Modal.jsx # 模态框
|
||||
│ ├── Toast.jsx # 通知提示
|
||||
│ └── Pagination.jsx # 分页
|
||||
├── pages/
|
||||
│ └── admin/
|
||||
│ ├── Dashboard.jsx
|
||||
│ ├── Posts.jsx
|
||||
│ ├── PostEdit.jsx
|
||||
│ ├── Categories.jsx
|
||||
│ ├── Tags.jsx
|
||||
│ ├── Images.jsx
|
||||
│ ├── Comments.jsx
|
||||
│ └── Settings.jsx
|
||||
├── styles/
|
||||
│ └── admin.css # Admin 主题样式
|
||||
└── hooks/
|
||||
└── useToast.js # Toast 状态管理
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 更新日志
|
||||
|
||||
| 日期 | 版本 | 变更内容 |
|
||||
|------|------|----------|
|
||||
| 2026-04-19 | v2.0 | 初始版本,完整的 Admin Dashboard 设计规范 |
|
||||
|
||||
---
|
||||
|
||||
*本文档定义了个人博客后台管理的完整 UI 设计规范与组件实现。*
|
||||
@@ -0,0 +1,69 @@
|
||||
# 飞点健康 CMS
|
||||
|
||||
官网内容管理系统 - 首页模块可配置。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**: FastAPI + SQLAlchemy + SQLite
|
||||
- **前端**: React + Vite + TailwindCSS
|
||||
- **风格**: 新粗野主义 (Neobrutalism)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 启动后端
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
source .venv/bin/activate
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 10003 --reload
|
||||
```
|
||||
|
||||
### 2. 启动前端
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 3. 访问
|
||||
|
||||
- 前台: http://localhost:10002
|
||||
- 后台: http://localhost:10002/admin
|
||||
- API文档: http://localhost:10003/docs
|
||||
|
||||
## 默认账号
|
||||
|
||||
- 用户名: `admin`
|
||||
- 密码: `admin123`
|
||||
|
||||
## 功能
|
||||
|
||||
- [x] JWT 认证
|
||||
- [x] 首页模块管理(6个预设模块)
|
||||
- [x] 导航菜单管理
|
||||
- [x] 系统设置管理
|
||||
- [x] 图片上传
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
website-new/
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── models/ # 数据模型
|
||||
│ │ ├── routers/ # API路由
|
||||
│ │ ├── auth.py # 认证逻辑
|
||||
│ │ ├── config.py # 配置
|
||||
│ │ ├── database.py # 数据库
|
||||
│ │ └── main.py # 应用入口
|
||||
│ ├── .venv/
|
||||
│ └── requirements.txt
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API客户端
|
||||
│ │ ├── components/ # 组件
|
||||
│ │ ├── contexts/ # React Context
|
||||
│ │ └── pages/ # 页面
|
||||
│ └── package.json
|
||||
└── scripts/ # 启动脚本
|
||||
```
|
||||
@@ -0,0 +1,769 @@
|
||||
# Feidian Health - UI 设计规范
|
||||
|
||||
> **文档类型**: 设计系统 (Design System)
|
||||
> **适用范围**: Admin Dashboard 后台管理
|
||||
> **版本**: v1.0
|
||||
> **更新日期**: 2026-04-19
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [设计原则](#1-设计原则)
|
||||
2. [色彩系统](#2-色彩系统)
|
||||
3. [排版系统](#3-排版系统)
|
||||
4. [间距系统](#4-间距系统)
|
||||
5. [阴影与圆角](#5-阴影与圆角)
|
||||
6. [组件规范](#6-组件规范)
|
||||
7. [布局规范](#7-布局规范)
|
||||
8. [动效规范](#8-动效规范)
|
||||
9. [图标规范](#9-图标规范)
|
||||
10. [响应式规范](#10-响应式规范)
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计原则
|
||||
|
||||
### 1.1 核心原则
|
||||
|
||||
| 原则 | 描述 | 实践方式 |
|
||||
|------|------|---------|
|
||||
| **清晰 (Clarity)** | 信息层级明确,用户一眼找到重点 | 足够的对比度、合理的留白 |
|
||||
| **效率 (Efficiency)** | 减少操作步骤,提高工作流效率 | 快捷操作、批量处理 |
|
||||
| **一致 (Consistency)** | 相同元素表现一致,降低学习成本 | 统一的组件、颜色、间距 |
|
||||
| **反馈 (Feedback)** | 操作后有明确的状态反馈 | Toast、Loading、过渡动画 |
|
||||
|
||||
### 1.2 设计价值观
|
||||
|
||||
- **内容优先**: 界面服务于内容,而非干扰
|
||||
- **克制美学**: 减少不必要的装饰,保持专业感
|
||||
- **渐进披露**: 复杂功能分层展示,避免信息过载
|
||||
|
||||
---
|
||||
|
||||
## 2. 色彩系统
|
||||
|
||||
### 2.1 色彩层级
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 色彩层级金字塔 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ▲ 强调色 │
|
||||
│ ╱ ╲ 10% (主按钮、链接) │
|
||||
│ ╱ ╲ │
|
||||
│ ▲ 主色 │
|
||||
│ ╱ ╲ 20% (交互元素、状态) │
|
||||
│ ╱ ╲ │
|
||||
│ ▲ 文字色 │
|
||||
│ ╱ ╲ 30% (标题、正文) │
|
||||
│ ╱ ╲ │
|
||||
│ ▲ 背景色 │
|
||||
│ ╱ ╲ 40% (页面、卡片背景) │
|
||||
│ ╱ ╲ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 主色板
|
||||
|
||||
#### 主色调 (Primary)
|
||||
|
||||
| 色值 | 名称 | 用途 |
|
||||
|------|------|------|
|
||||
| `#10B981` | Primary | 主按钮、主链接、强调 |
|
||||
| `#059669` | Primary Hover | 悬停状态 |
|
||||
| `#D1FAE5` | Primary Light | 图标背景、标签背景 |
|
||||
| `#ECFDF5` | Primary Subtle | 选中状态背景 |
|
||||
|
||||
#### 侧边栏色 (Sidebar)
|
||||
|
||||
| 色值 | 名称 | 用途 |
|
||||
|------|------|------|
|
||||
| `#0F172A` | Sidebar BG | 侧边栏背景 |
|
||||
| `#1E293B` | Sidebar Hover | 菜单项悬停 |
|
||||
| `#334155` | Sidebar Active | 当前选中项 |
|
||||
| `#94A3B8` | Sidebar Text | 默认文字 |
|
||||
| `#FFFFFF` | Sidebar Text Active | 选中文字 |
|
||||
|
||||
#### 中性色 (Neutral)
|
||||
|
||||
| 色值 | 名称 | 用途 |
|
||||
|------|------|------|
|
||||
| `#0F172A` | Text Primary | 主要文字、标题 |
|
||||
| `#64748B` | Text Secondary | 次要文字、描述 |
|
||||
| `#94A3B8` | Text Muted | 弱化文字、占位符 |
|
||||
| `#E2E8F0` | Border | 分割线、边框 |
|
||||
| `#F8FAFC` | BG Main | 主背景 |
|
||||
| `#FFFFFF` | BG Card | 卡片背景 |
|
||||
|
||||
#### 功能色 (Functional)
|
||||
|
||||
| 色值 | 名称 | 用途 |
|
||||
|------|------|------|
|
||||
| `#10B981` | Success | 成功、增长 |
|
||||
| `#F59E0B` | Warning | 警告、提示 |
|
||||
| `#EF4444` | Danger | 错误、删除 |
|
||||
| `#3B82F6` | Info | 信息、提示 |
|
||||
|
||||
### 2.3 色彩使用规则
|
||||
|
||||
```
|
||||
✅ 正确用法:
|
||||
- 主色仅用于主要操作按钮和重要链接
|
||||
- 功能色用于状态指示和反馈
|
||||
- 文字色保持 3 个层级以内
|
||||
|
||||
❌ 错误用法:
|
||||
- 同一页面使用超过 3 种强调色
|
||||
- 正文使用纯黑 (#000000)
|
||||
- 边框颜色过深,喧宾夺主
|
||||
```
|
||||
|
||||
### 2.4 对比度要求
|
||||
|
||||
| 场景 | 最小对比度 | 推荐对比度 |
|
||||
|------|-----------|-----------|
|
||||
| 正文文字 | 4.5:1 | 7:1 |
|
||||
| 大号文字 | 3:1 | 4.5:1 |
|
||||
| 图标 | 3:1 | 4.5:1 |
|
||||
| 按钮 | 3:1 | 4.5:1 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 排版系统
|
||||
|
||||
### 3.1 字体族
|
||||
|
||||
```css
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
```
|
||||
|
||||
- **主要**: Inter (现代、清晰、专业)
|
||||
- **后备**: 系统字体栈
|
||||
|
||||
### 3.2 字号规范
|
||||
|
||||
| 名称 | 类名 | 尺寸 | 行高 | 字重 | 用途 |
|
||||
|------|------|------|------|------|------|
|
||||
| Display | `text-3xl` | 30px | 36px | 700 | 大数字展示 |
|
||||
| H1 | `text-2xl` | 24px | 32px | 600 | 页面标题 |
|
||||
| H2 | `text-xl` | 20px | 28px | 600 | 区块标题 |
|
||||
| H3 | `text-lg` | 18px | 28px | 500 | 卡片标题 |
|
||||
| Body | `text-base` | 16px | 24px | 400 | 正文 |
|
||||
| Body Sm | `text-sm` | 14px | 20px | 400 | 次要正文 |
|
||||
| Caption | `text-xs` | 12px | 16px | 500 | 标签、辅助 |
|
||||
|
||||
### 3.3 字重使用
|
||||
|
||||
| 字重 | 数值 | 用途 |
|
||||
|------|------|------|
|
||||
| Regular | 400 | 正文、描述 |
|
||||
| Medium | 500 | 按钮、标签、导航 |
|
||||
| Semibold | 600 | 标题、重要文字 |
|
||||
| Bold | 700 | 大数字、强调 |
|
||||
|
||||
### 3.4 排版规则
|
||||
|
||||
```
|
||||
标题规范:
|
||||
- 页面标题: text-2xl font-semibold
|
||||
- 卡片标题: text-lg font-semibold
|
||||
- 表单标签: text-sm font-medium
|
||||
|
||||
正文规范:
|
||||
- 主要文字: text-base text-admin-text-primary
|
||||
- 次要文字: text-sm text-admin-text-secondary
|
||||
- 辅助文字: text-xs text-admin-text-muted
|
||||
|
||||
行高规则:
|
||||
- 标题: 行高 = 1.25 × 字号
|
||||
- 正文: 行高 = 1.5 × 字号
|
||||
- 紧凑: 行高 = 1.25 × 字号
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 间距系统
|
||||
|
||||
### 4.1 间距 Token
|
||||
|
||||
| Token | 值 | 用途 |
|
||||
|-------|-----|------|
|
||||
| `space-1` | 4px | 图标与文字间距 |
|
||||
| `space-2` | 8px | 紧凑内边距、小间隙 |
|
||||
| `space-3` | 12px | 按钮内边距、列表项间距 |
|
||||
| `space-4` | 16px | 标准内边距、卡片间距 |
|
||||
| `space-6` | 24px | 区块间距、大内边距 |
|
||||
| `space-8` | 32px | 页面级间距 |
|
||||
| `space-10` | 40px | 大区块分隔 |
|
||||
| `space-12` | 48px | 章节间距 |
|
||||
|
||||
### 4.2 组件间距
|
||||
|
||||
| 组件 | 内边距 | 外边距 |
|
||||
|------|--------|--------|
|
||||
| 按钮 (sm) | px-3 py-1.5 | - |
|
||||
| 按钮 (md) | px-4 py-2 | - |
|
||||
| 按钮 (lg) | px-6 py-2.5 | - |
|
||||
| 卡片 | p-6 | mb-6 |
|
||||
| 卡片头部 | px-6 py-4 | - |
|
||||
| 卡片内容 | px-6 py-4 | - |
|
||||
| 表单输入框 | px-4 py-2.5 | mb-4 |
|
||||
| 表格行 | px-6 py-4 | - |
|
||||
|
||||
### 4.3 布局间距
|
||||
|
||||
```
|
||||
页面布局:
|
||||
├── 主内容区: p-6 (24px)
|
||||
├── 区块间距: space-y-6 (24px)
|
||||
├── 卡片网格: gap-6 (24px)
|
||||
└── 卡片内部: space-y-4 (16px)
|
||||
|
||||
表单布局:
|
||||
├── 表单区块: space-y-6 (24px)
|
||||
├── 表单项: space-y-1.5 (6px)
|
||||
├── 标签与输入: 间距 6px
|
||||
└── 按钮组: gap-3 (12px)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 阴影与圆角
|
||||
|
||||
### 5.1 阴影规范
|
||||
|
||||
| 名称 | 值 | 用途 |
|
||||
|------|-----|------|
|
||||
| `shadow-sm` | `0 1px 2px 0 rgba(0,0,0,0.05)` | 输入框、小按钮 |
|
||||
| `shadow` | `0 1px 3px 0 rgba(0,0,0,0.1), 0 1px 2px 0 rgba(0,0,0,0.06)` | 标准卡片 |
|
||||
| `shadow-md` | `0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06)` | 悬停卡片 |
|
||||
| `shadow-lg` | `0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05)` | 下拉菜单、模态框 |
|
||||
| `shadow-xl` | `0 20px 25px -5px rgba(0,0,0,0.1), 0 10px 10px -5px rgba(0,0,0,0.04)` | 特殊强调 |
|
||||
|
||||
### 5.2 阴影使用场景
|
||||
|
||||
```
|
||||
✅ 使用阴影:
|
||||
- 卡片组件: shadow (默认), shadow-md (悬停)
|
||||
- 下拉菜单: shadow-lg
|
||||
- 模态框: shadow-lg
|
||||
- Toast 通知: shadow-lg
|
||||
- 按钮: shadow-sm (主按钮)
|
||||
|
||||
❌ 避免阴影:
|
||||
- 输入框 (使用 border 代替)
|
||||
- 分割线
|
||||
- 纯文本元素
|
||||
```
|
||||
|
||||
### 5.3 圆角规范
|
||||
|
||||
| Token | 值 | 用途 |
|
||||
|-------|-----|------|
|
||||
| `rounded-sm` | 6px | 小按钮、标签 |
|
||||
| `rounded` | 8px | 标准按钮、输入框、小卡片 |
|
||||
| `rounded-lg` | 12px | 卡片、模态框 |
|
||||
| `rounded-xl` | 16px | 大卡片、特殊组件 |
|
||||
| `rounded-full` | 50% | 头像、圆形按钮 |
|
||||
|
||||
### 5.4 圆角使用规则
|
||||
|
||||
```
|
||||
组件圆角:
|
||||
├── 按钮 (sm): rounded-md (6px)
|
||||
├── 按钮 (md/lg): rounded-lg (8px)
|
||||
├── 卡片: rounded-xl (12px)
|
||||
├── 输入框: rounded-lg (8px)
|
||||
├── 下拉菜单: rounded-lg (8px)
|
||||
├── 模态框: rounded-xl (12px)
|
||||
├── 头像: rounded-full
|
||||
└── Toast: rounded-lg (8px)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 组件规范
|
||||
|
||||
### 6.1 按钮 (Button)
|
||||
|
||||
#### 变体规范
|
||||
|
||||
```
|
||||
Primary (主按钮):
|
||||
├── 背景: bg-admin-primary (#10B981)
|
||||
├── 文字: text-white
|
||||
├── 悬停: bg-admin-primary-hover (#059669)
|
||||
├── 阴影: shadow-sm
|
||||
└── 用途: 主要操作、保存、提交
|
||||
|
||||
Secondary (次按钮):
|
||||
├── 背景: bg-white
|
||||
├── 边框: border border-admin-border
|
||||
├── 文字: text-admin-text-primary
|
||||
├── 悬停: bg-gray-50
|
||||
└── 用途: 次要操作、取消
|
||||
|
||||
Ghost (幽灵按钮):
|
||||
├── 背景: transparent
|
||||
├── 文字: text-admin-text-secondary
|
||||
├── 悬停: bg-gray-100 text-admin-text-primary
|
||||
└── 用途: 低优先级操作、图标按钮
|
||||
|
||||
Danger (危险按钮):
|
||||
├── 背景: bg-admin-danger (#EF4444)
|
||||
├── 文字: text-white
|
||||
├── 悬停: bg-red-600
|
||||
└── 用途: 删除、危险操作
|
||||
```
|
||||
|
||||
#### 尺寸规范
|
||||
|
||||
| 尺寸 | 内边距 | 字号 | 圆角 | 用途 |
|
||||
|------|--------|------|------|------|
|
||||
| sm | px-3 py-1.5 | text-xs | rounded-md | 表格内、紧凑布局 |
|
||||
| md | px-4 py-2 | text-sm | rounded-lg | 标准按钮 |
|
||||
| lg | px-6 py-2.5 | text-base | rounded-lg | 强调操作 |
|
||||
|
||||
#### 状态规范
|
||||
|
||||
```
|
||||
默认状态:
|
||||
- 正常显示
|
||||
|
||||
悬停状态:
|
||||
- 背景色加深
|
||||
- 阴影增强
|
||||
- 过渡时间: 200ms
|
||||
|
||||
点击状态:
|
||||
- 轻微下沉 (transform: translateY(1px))
|
||||
- 过渡时间: 100ms
|
||||
|
||||
禁用状态:
|
||||
- opacity: 0.5
|
||||
- cursor: not-allowed
|
||||
- 无交互效果
|
||||
|
||||
加载状态:
|
||||
- 显示 Loading 图标
|
||||
- 禁用点击
|
||||
```
|
||||
|
||||
### 6.2 输入框 (Input)
|
||||
|
||||
#### 基础样式
|
||||
|
||||
```
|
||||
默认状态:
|
||||
├── 背景: bg-white
|
||||
├── 边框: border border-admin-border
|
||||
├── 圆角: rounded-lg
|
||||
├── 内边距: px-4 py-2.5
|
||||
├── 文字: text-admin-text-primary
|
||||
└── 占位符: placeholder:text-admin-text-muted
|
||||
|
||||
聚焦状态:
|
||||
├── 边框: border-admin-primary
|
||||
├── 阴影: ring-2 ring-admin-primary/20
|
||||
└── 过渡: transition-all duration-200
|
||||
|
||||
错误状态:
|
||||
├── 边框: border-admin-danger
|
||||
├── 阴影: ring-2 ring-red-200
|
||||
└── 错误文字: text-sm text-admin-danger
|
||||
```
|
||||
|
||||
#### 尺寸规范
|
||||
|
||||
| 尺寸 | 内边距 | 高度 | 用途 |
|
||||
|------|--------|------|------|
|
||||
| sm | px-3 py-2 | 36px | 紧凑表单 |
|
||||
| md | px-4 py-2.5 | 42px | 标准输入 |
|
||||
| lg | px-4 py-3 | 48px | 强调输入 |
|
||||
|
||||
### 6.3 卡片 (Card)
|
||||
|
||||
#### 结构规范
|
||||
|
||||
```
|
||||
Card:
|
||||
├── 背景: bg-white
|
||||
├── 圆角: rounded-xl (12px)
|
||||
├── 阴影: shadow
|
||||
├── 边框: border border-admin-border
|
||||
└── 内边距: 根据内容调整
|
||||
|
||||
CardHeader:
|
||||
├── 下边框: border-b border-admin-border
|
||||
├── 内边距: px-6 py-4
|
||||
├── 标题: text-lg font-semibold
|
||||
└── 描述: text-sm text-admin-text-secondary
|
||||
|
||||
CardContent:
|
||||
└── 内边距: px-6 py-4
|
||||
|
||||
CardFooter:
|
||||
├── 上边框: border-t border-admin-border
|
||||
├── 背景: bg-gray-50
|
||||
├── 圆角: rounded-b-xl
|
||||
└── 内边距: px-6 py-4
|
||||
```
|
||||
|
||||
#### 变体规范
|
||||
|
||||
```
|
||||
标准卡片:
|
||||
- shadow
|
||||
- hover:shadow-md (可选)
|
||||
|
||||
扁平卡片:
|
||||
- shadow-none
|
||||
- bg-gray-50
|
||||
|
||||
强调卡片:
|
||||
- border-admin-primary/30
|
||||
- bg-admin-primary-subtle
|
||||
```
|
||||
|
||||
### 6.4 表格 (Table)
|
||||
|
||||
#### 结构规范
|
||||
|
||||
```
|
||||
Table:
|
||||
├── 背景: bg-white
|
||||
├── 圆角: rounded-xl
|
||||
├── 阴影: shadow
|
||||
└── overflow: hidden
|
||||
|
||||
TableHead:
|
||||
├── 背景: bg-gray-50
|
||||
├── 下边框: border-b border-admin-border
|
||||
└── 文字: text-xs font-semibold uppercase
|
||||
|
||||
TableRow:
|
||||
├── 下边框: border-b border-admin-border
|
||||
└── 悬停: bg-gray-50
|
||||
|
||||
TableCell:
|
||||
├── 内边距: px-6 py-4
|
||||
├── 表头: text-admin-text-secondary
|
||||
└── 内容: text-admin-text-primary
|
||||
```
|
||||
|
||||
#### 行高规范
|
||||
|
||||
| 密度 | 内边距 | 用途 |
|
||||
|------|--------|------|
|
||||
| 紧凑 | px-4 py-3 | 数据量大时 |
|
||||
| 标准 | px-6 py-4 | 默认 |
|
||||
| 宽松 | px-6 py-5 | 强调行 |
|
||||
|
||||
### 6.5 标签 (Tag/Badge)
|
||||
|
||||
#### 变体规范
|
||||
|
||||
```
|
||||
Primary:
|
||||
├── 背景: bg-admin-primary-light
|
||||
├── 文字: text-admin-primary
|
||||
└── 圆角: rounded-full
|
||||
|
||||
Success:
|
||||
├── 背景: bg-green-100
|
||||
├── 文字: text-green-700
|
||||
└── 圆角: rounded-full
|
||||
|
||||
Warning:
|
||||
├── 背景: bg-amber-100
|
||||
├── 文字: text-amber-700
|
||||
└── 圆角: rounded-full
|
||||
|
||||
Danger:
|
||||
├── 背景: bg-red-100
|
||||
├── 文字: text-red-700
|
||||
└── 圆角: rounded-full
|
||||
|
||||
Secondary:
|
||||
├── 背景: bg-gray-100
|
||||
├── 文字: text-gray-700
|
||||
└── 圆角: rounded-full
|
||||
```
|
||||
|
||||
#### 尺寸规范
|
||||
|
||||
| 尺寸 | 内边距 | 字号 | 用途 |
|
||||
|------|--------|------|------|
|
||||
| sm | px-2 py-0.5 | text-xs | 表格内、紧凑 |
|
||||
| md | px-2.5 py-1 | text-xs | 默认 |
|
||||
| lg | px-3 py-1 | text-sm | 强调 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 布局规范
|
||||
|
||||
### 7.1 页面布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Sidebar (240px) │ Main Content (flex-1) │
|
||||
│ │ │
|
||||
│ Logo │ Header (64px) │
|
||||
│ │ ──────────────────────── │
|
||||
│ Navigation │ │
|
||||
│ │ Page Header │
|
||||
│ ─────────── │ ──────────────────────── │
|
||||
│ │ │
|
||||
│ User Profile │ Content Area │
|
||||
│ │ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 7.2 网格系统
|
||||
|
||||
#### 统计卡片网格
|
||||
|
||||
```
|
||||
桌面端 (lg+):
|
||||
- grid-cols-4
|
||||
- gap-6
|
||||
|
||||
平板端 (md):
|
||||
- grid-cols-2
|
||||
- gap-6
|
||||
|
||||
移动端:
|
||||
- grid-cols-1
|
||||
- gap-4
|
||||
```
|
||||
|
||||
#### 内容网格
|
||||
|
||||
```
|
||||
两栏布局:
|
||||
├── 左侧: w-2/3 (主要内容)
|
||||
└── 右侧: w-1/3 (侧边栏)
|
||||
|
||||
表单布局:
|
||||
├── 单列: max-w-2xl mx-auto
|
||||
├── 双列: grid-cols-2 gap-6
|
||||
└── 三列: grid-cols-3 gap-6
|
||||
```
|
||||
|
||||
### 7.3 内容区域
|
||||
|
||||
| 区域 | 最大宽度 | 对齐 | 用途 |
|
||||
|------|---------|------|------|
|
||||
| 全宽 | 100% | - | 表格、Dashboard |
|
||||
| 适中 | max-w-4xl (896px) | mx-auto | 表单页面 |
|
||||
| 窄 | max-w-2xl (672px) | mx-auto | 设置页面 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 动效规范
|
||||
|
||||
### 8.1 过渡时间
|
||||
|
||||
| 时长 | 用途 |
|
||||
|------|------|
|
||||
| 100ms | 按钮点击、快速反馈 |
|
||||
| 150ms | 颜色变化、透明度 |
|
||||
| 200ms | 阴影、边框、transform |
|
||||
| 300ms | 页面切换、模态框 |
|
||||
| 500ms | Toast 进入/退出 |
|
||||
|
||||
### 8.2 缓动函数
|
||||
|
||||
```css
|
||||
/* 标准 */
|
||||
transition-timing-function: ease-in-out;
|
||||
|
||||
/* 进入 */
|
||||
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
|
||||
/* 退出 */
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
|
||||
|
||||
/* 弹性 */
|
||||
transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
```
|
||||
|
||||
### 8.3 常用动效
|
||||
|
||||
#### 按钮悬停
|
||||
|
||||
```css
|
||||
transition: all 200ms ease-in-out;
|
||||
/* 悬停: 背景色变深 + 阴影增强 */
|
||||
```
|
||||
|
||||
#### 卡片悬停
|
||||
|
||||
```css
|
||||
transition: all 200ms ease-in-out;
|
||||
/* 悬停: shadow → shadow-md + translateY(-2px) */
|
||||
```
|
||||
|
||||
#### Toast 进入
|
||||
|
||||
```css
|
||||
animation: slideIn 300ms cubic-bezier(0, 0, 0.2, 1);
|
||||
/* 从右侧滑入 + 淡入 */
|
||||
```
|
||||
|
||||
#### 模态框进入
|
||||
|
||||
```css
|
||||
animation: modalIn 200ms cubic-bezier(0, 0, 0.2, 1);
|
||||
/* 淡入 + 轻微缩放 */
|
||||
```
|
||||
|
||||
### 8.4 性能优化
|
||||
|
||||
```
|
||||
✅ 性能友好:
|
||||
- transform (位移、缩放)
|
||||
- opacity (淡入淡出)
|
||||
- will-change (提前声明)
|
||||
|
||||
❌ 避免使用:
|
||||
- width/height 动画
|
||||
- top/left 动画
|
||||
- box-shadow 大范围变化
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 图标规范
|
||||
|
||||
### 9.1 图标库
|
||||
|
||||
- **主要**: [Lucide React](https://lucide.dev/)
|
||||
- **风格**: 线性图标 (outline)
|
||||
- **线宽**: 2px
|
||||
- **端点**: 圆角
|
||||
|
||||
### 9.2 图标尺寸
|
||||
|
||||
| 尺寸 | 值 | 用途 |
|
||||
|------|-----|------|
|
||||
| xs | 14px | 内联文字 |
|
||||
| sm | 16px | 按钮、表单 |
|
||||
| md | 20px | 导航菜单 |
|
||||
| lg | 24px | 独立图标 |
|
||||
| xl | 32px | 空状态、强调 |
|
||||
|
||||
### 9.3 图标使用规则
|
||||
|
||||
```
|
||||
图标 + 文字:
|
||||
├── 间距: gap-2 (8px)
|
||||
├── 图标位置: 文字左侧 (默认)
|
||||
└── 对齐: items-center
|
||||
|
||||
图标按钮:
|
||||
├── 尺寸: p-2 (按钮整体)
|
||||
├── 图标: w-4 h-4 或 w-5 h-5
|
||||
└── 圆角: rounded-lg
|
||||
|
||||
独立图标:
|
||||
└── 尺寸: w-6 h-6 或更大
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 响应式规范
|
||||
|
||||
### 10.1 断点定义
|
||||
|
||||
| 断点 | 宽度 | 设备 |
|
||||
|------|------|------|
|
||||
| sm | 640px | 大手机 |
|
||||
| md | 768px | 平板 |
|
||||
| lg | 1024px | 小桌面 |
|
||||
| xl | 1280px | 桌面 |
|
||||
| 2xl | 1536px | 大桌面 |
|
||||
|
||||
### 10.2 响应式规则
|
||||
|
||||
#### 侧边栏
|
||||
|
||||
```
|
||||
桌面端 (lg+):
|
||||
├── 固定显示
|
||||
├── 宽度: 240px
|
||||
└── 位置: fixed left-0
|
||||
|
||||
平板/移动端 (< lg):
|
||||
├── 默认隐藏
|
||||
├── 触发: 汉堡菜单
|
||||
└── 遮罩: 半透明背景
|
||||
```
|
||||
|
||||
#### 统计卡片
|
||||
|
||||
```
|
||||
桌面端: grid-cols-4
|
||||
平板端: grid-cols-2
|
||||
移动端: grid-cols-1
|
||||
```
|
||||
|
||||
#### 表格
|
||||
|
||||
```
|
||||
桌面端: 完整显示
|
||||
移动端:
|
||||
├── 横向滚动
|
||||
└── 或卡片式重构
|
||||
```
|
||||
|
||||
### 10.3 触摸优化
|
||||
|
||||
```
|
||||
最小点击区域:
|
||||
├── 按钮: 44px × 44px
|
||||
├── 图标按钮: 40px × 40px
|
||||
└── 列表项: 48px 高度
|
||||
|
||||
间距调整:
|
||||
├── 移动端增加间距
|
||||
└── 按钮尺寸增大
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 设计检查清单
|
||||
|
||||
### 11.1 新建组件检查
|
||||
|
||||
- [ ] 颜色使用设计系统 token
|
||||
- [ ] 间距使用标准 spacing
|
||||
- [ ] 字号符合排版规范
|
||||
- [ ] 圆角统一为 8px/12px
|
||||
- [ ] 阴影符合层级规范
|
||||
- [ ] 包含所有状态样式 (默认/悬停/禁用)
|
||||
- [ ] 过渡动画 150-200ms
|
||||
- [ ] 支持响应式适配
|
||||
|
||||
### 11.2 代码审查检查
|
||||
|
||||
- [ ] 无硬编码颜色值
|
||||
- [ ] 无硬编码间距值
|
||||
- [ ] 可访问性达标 (对比度)
|
||||
- [ ] 键盘导航支持
|
||||
- [ ] 屏幕阅读器友好
|
||||
|
||||
---
|
||||
|
||||
## 12. 更新日志
|
||||
|
||||
| 日期 | 版本 | 变更内容 |
|
||||
|------|------|----------|
|
||||
| 2026-04-19 | v1.0 | 初始版本,完整的 UI 设计规范 |
|
||||
|
||||
---
|
||||
|
||||
*本文档为 Personal Blog Admin Dashboard 的 UI 设计规范,所有组件和页面应遵循此规范进行设计和开发。*
|
||||
@@ -0,0 +1,64 @@
|
||||
import time
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=False)
|
||||
page = browser.new_page(viewport={'width': 1400, 'height': 900})
|
||||
|
||||
# 1. 打开登录页
|
||||
page.goto('http://localhost:10002/admin/login')
|
||||
page.wait_for_load_state('networkidle')
|
||||
page.screenshot(path='e:/nanocode/website-new/screenshot_01_login.png')
|
||||
print('Step 1: 打开登录页')
|
||||
|
||||
# 2. 填写账号密码
|
||||
page.fill('input[type=text]', 'admin')
|
||||
page.fill('input[type=password]', 'abcd1234')
|
||||
page.screenshot(path='e:/nanocode/website-new/screenshot_02_filled.png')
|
||||
print('Step 2: 填写账号密码')
|
||||
|
||||
# 3. 点击登录
|
||||
page.click('button[type=submit]')
|
||||
page.wait_for_url('**/admin')
|
||||
page.wait_for_load_state('networkidle')
|
||||
page.screenshot(path='e:/nanocode/website-new/screenshot_03_dashboard.png')
|
||||
print('Step 3: 登录成功,进入后台')
|
||||
|
||||
# 4. 点击"新闻资讯"展开子菜单
|
||||
page.click('button:has-text("新闻资讯")')
|
||||
time.sleep(0.5)
|
||||
page.screenshot(path='e:/nanocode/website-new/screenshot_04_menu_expanded.png')
|
||||
print('Step 4: 展开新闻资讯菜单')
|
||||
|
||||
# 5. 点击"栏目管理"
|
||||
page.click('a:has-text("栏目管理")')
|
||||
page.wait_for_url('**/admin/news/categories')
|
||||
page.wait_for_load_state('networkidle')
|
||||
page.screenshot(path='e:/nanocode/website-new/screenshot_05_categories.png')
|
||||
print('Step 5: 进入栏目管理页面')
|
||||
|
||||
# 6. 点击"添加栏目"
|
||||
page.click('button:has-text("添加栏目")')
|
||||
time.sleep(0.5)
|
||||
page.screenshot(path='e:/nanocode/website-new/screenshot_06_modal_open.png')
|
||||
print('Step 6: 打开添加栏目弹窗')
|
||||
|
||||
# 7. 填写栏目信息
|
||||
page.fill('input[placeholder*="如:公司新闻"]', '测试栏目')
|
||||
page.fill('input[placeholder*="如:company-news"]', 'test-column')
|
||||
page.fill('textarea[placeholder*="栏目简介"]', '这是一个用于测试的栏目')
|
||||
page.screenshot(path='e:/nanocode/website-new/screenshot_07_form_filled.png')
|
||||
print('Step 7: 填写栏目信息')
|
||||
|
||||
# 8. 点击创建
|
||||
page.click('button:has-text("创建")')
|
||||
time.sleep(1)
|
||||
page.screenshot(path='e:/nanocode/website-new/screenshot_08_created.png')
|
||||
print('Step 8: 创建栏目完成')
|
||||
|
||||
# 9. 最终截图
|
||||
page.screenshot(path='e:/nanocode/website-new/screenshot_09_final.png')
|
||||
print('Step 9: 最终页面截图')
|
||||
|
||||
browser.close()
|
||||
print('All done!')
|
||||
@@ -0,0 +1,7 @@
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
engine = create_engine('sqlite:///cms.db')
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("ALTER TABLE columns ADD COLUMN icon_name VARCHAR(50)"))
|
||||
conn.commit()
|
||||
print('icon_name column added successfully')
|
||||
@@ -0,0 +1 @@
|
||||
# app package
|
||||
@@ -0,0 +1,86 @@
|
||||
# app/auth.py
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
import bcrypt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models.admin import Admin
|
||||
|
||||
settings = get_settings()
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=15)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
async def get_current_admin(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
) -> Admin:
|
||||
token = credentials.credentials
|
||||
payload = decode_token(token)
|
||||
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
admin = db.query(Admin).filter(Admin.username == username).first()
|
||||
if admin is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return admin
|
||||
|
||||
|
||||
async def get_optional_admin(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
db: Session = Depends(get_db)
|
||||
) -> Optional[Admin]:
|
||||
if not credentials:
|
||||
return None
|
||||
try:
|
||||
return await get_current_admin(credentials, db)
|
||||
except HTTPException:
|
||||
return None
|
||||
@@ -0,0 +1,32 @@
|
||||
# app/config.py
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# App
|
||||
APP_NAME: str = "飞点健康 CMS"
|
||||
DEBUG: bool = True
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "sqlite:///./cms.db"
|
||||
|
||||
# JWT
|
||||
SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 # 24 hours
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: list = ["http://localhost:5173","http://49.235.37.80:18888", "http://localhost:10002", "http://129.211.173.87:10002", "http://49.235.37.80:8888"]
|
||||
|
||||
# Upload
|
||||
UPLOAD_DIR: str = "../storage/admin/images"
|
||||
MAX_UPLOAD_SIZE: int = 5 * 1024 * 1024 # 5MB
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings():
|
||||
return Settings()
|
||||
@@ -0,0 +1,24 @@
|
||||
# app/database.py
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
connect_args={"check_same_thread": False} if settings.DATABASE_URL.startswith("sqlite") else {}
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,119 @@
|
||||
# app/main.py
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import os
|
||||
import time
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import engine, Base
|
||||
from app.routers import auth, home_modules, nav_menus, settings as settings_router, upload, images, crawl, wechat_crawl, batch_wechat_crawl, contact_qrcodes, app_downloads, home_experts, home_partners, columns, articles, seo, prerender
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Create database tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
description="飞点健康官网 CMS 系统 API",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Static files for uploads
|
||||
# Use the actual uploads directory where crawl.py saves images
|
||||
# Resolve relative to backend root to match config.UPLOAD_DIR
|
||||
backend_root = os.path.dirname(os.path.dirname(__file__))
|
||||
upload_dir = os.path.join(backend_root, settings.UPLOAD_DIR)
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
app.mount("/uploads", StaticFiles(directory=upload_dir), name="uploads")
|
||||
|
||||
# New unified storage structure
|
||||
storage_admin_dir = os.path.join(backend_root, "..", "storage", "admin")
|
||||
storage_public_dir = os.path.join(backend_root, "..", "storage", "public")
|
||||
storage_tmp_dir = os.path.join(backend_root, "..", "storage", "tmp")
|
||||
os.makedirs(storage_admin_dir, exist_ok=True)
|
||||
os.makedirs(storage_public_dir, exist_ok=True)
|
||||
os.makedirs(storage_tmp_dir, exist_ok=True)
|
||||
app.mount("/storage/admin", StaticFiles(directory=storage_admin_dir), name="storage_admin")
|
||||
app.mount("/storage/public", StaticFiles(directory=storage_public_dir), name="storage_public")
|
||||
app.mount("/storage/tmp", StaticFiles(directory=storage_tmp_dir), name="storage_tmp")
|
||||
|
||||
|
||||
def cleanup_old_temp_files():
|
||||
"""启动时清理超过24小时的采集临时文件"""
|
||||
try:
|
||||
if os.name == 'nt': # Windows
|
||||
temp_dir = os.path.join(backend_root, "..", "storage", "tmp", "crawl")
|
||||
else:
|
||||
temp_dir = "/tmp/news_crawl"
|
||||
|
||||
if not os.path.exists(temp_dir):
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
max_age = 24 * 3600 # 24小时
|
||||
count = 0
|
||||
|
||||
for root, dirs, files in os.walk(temp_dir):
|
||||
for f in files:
|
||||
filepath = os.path.join(root, f)
|
||||
try:
|
||||
if now - os.path.getmtime(filepath) > max_age:
|
||||
os.remove(filepath)
|
||||
count += 1
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if count > 0:
|
||||
print(f"[cleanup] 已清理 {count} 个过期临时文件")
|
||||
except Exception as e:
|
||||
print(f"[cleanup] 清理临时文件失败: {e}")
|
||||
|
||||
|
||||
# 启动时执行清理
|
||||
cleanup_old_temp_files()
|
||||
|
||||
# Routers
|
||||
# SEO 路由(无 /api 前缀,直接暴露于根域名)
|
||||
app.include_router(seo.router)
|
||||
app.include_router(prerender.router)
|
||||
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["认证"])
|
||||
app.include_router(home_modules.router, prefix="/api", tags=["首页模块"])
|
||||
app.include_router(nav_menus.router, prefix="/api", tags=["导航菜单"])
|
||||
app.include_router(settings_router.router, prefix="/api", tags=["系统设置"])
|
||||
app.include_router(upload.router, prefix="/api", tags=["文件上传"])
|
||||
app.include_router(images.router, prefix="/api", tags=["图片管理"])
|
||||
app.include_router(crawl.router, prefix="/api", tags=["文章采集"])
|
||||
app.include_router(wechat_crawl.router, prefix="/api", tags=["公众号采集"])
|
||||
app.include_router(batch_wechat_crawl.router, prefix="/api", tags=["批量公众号采集"])
|
||||
app.include_router(contact_qrcodes.router, prefix="/api", tags=["咨询二维码"])
|
||||
app.include_router(app_downloads.router, prefix="/api", tags=["下载应用"])
|
||||
app.include_router(home_experts.router, prefix="/api", tags=["专家团队"])
|
||||
app.include_router(home_partners.router, prefix="/api", tags=["合作伙伴"])
|
||||
app.include_router(columns.router, prefix="/api", tags=["栏目管理"])
|
||||
app.include_router(articles.router, prefix="/api", tags=["内容管理"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {
|
||||
"message": f"Welcome to {settings.APP_NAME}",
|
||||
"docs": "/docs",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,13 @@
|
||||
# models package
|
||||
from app.models.admin import Admin
|
||||
from app.models.home_module import HomeModule
|
||||
from app.models.nav_menu import NavMenu
|
||||
from app.models.setting import Setting
|
||||
from app.models.image import Image
|
||||
|
||||
from app.models.contact_qrcode import ContactQRCode
|
||||
from app.models.app_download import AppDownload
|
||||
from app.models.home_expert import HomeExpert
|
||||
from app.models.home_partner import HomePartner
|
||||
|
||||
__all__ = ["Admin", "HomeModule", "NavMenu", "Setting", "Image", "ContactQRCode", "AppDownload", "HomeExpert", "HomePartner"]
|
||||
@@ -0,0 +1,14 @@
|
||||
# app/models/admin.py
|
||||
from sqlalchemy import Column, Integer, String, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Admin(Base):
|
||||
__tablename__ = "admins"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
@@ -0,0 +1,21 @@
|
||||
# app/models/app_download.py
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AppDownload(Base):
|
||||
__tablename__ = "app_downloads"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
app_name = Column(String(100), nullable=False)
|
||||
platform = Column(String(20), nullable=False)
|
||||
download_url = Column(String(500), nullable=False)
|
||||
description = Column(String(200), nullable=True)
|
||||
icon = Column(String(255), nullable=True)
|
||||
qr_image_path = Column(String(255), nullable=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
is_enabled = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
内容管理模型
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, func
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Article(Base):
|
||||
"""内容文章"""
|
||||
__tablename__ = "articles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(200), nullable=False, comment="标题")
|
||||
slug = Column(String(200), unique=True, nullable=False, comment="URL友好的标识")
|
||||
content = Column(Text, nullable=False, comment="正文")
|
||||
content_type = Column(String(10), default="markdown", comment="内容类型:markdown/html")
|
||||
summary = Column(String(500), comment="摘要")
|
||||
cover_image = Column(String(500), comment="封面图路径")
|
||||
column_id = Column(Integer, ForeignKey("columns.id"), nullable=False, comment="所属栏目ID")
|
||||
author = Column(String(50), comment="作者")
|
||||
source_url = Column(String(1000), nullable=True, comment="来源URL(公众号等采集来源,用于去重)")
|
||||
status = Column(String(20), default="draft", comment="状态:published/draft")
|
||||
sort_order = Column(Integer, default=0, comment="排序权重")
|
||||
view_count = Column(Integer, default=0, comment="浏览次数")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
# 关联
|
||||
column = relationship("ColumnModel", backref="articles")
|
||||
|
||||
def to_dict(self, include_content: bool = False):
|
||||
"""转换为字典"""
|
||||
data = {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"slug": self.slug,
|
||||
"summary": self.summary,
|
||||
"cover_image": self.cover_image,
|
||||
"column_id": self.column_id,
|
||||
"column": self.column.to_dict() if self.column else None,
|
||||
"content_type": self.content_type,
|
||||
"author": self.author,
|
||||
"status": self.status,
|
||||
"sort_order": self.sort_order,
|
||||
"view_count": self.view_count,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
if include_content:
|
||||
data["content"] = self.content
|
||||
return data
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
栏目管理模型
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, func
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ColumnModel(Base):
|
||||
"""CMS 栏目(两级架构:顶级栏目 + 二级子栏目)"""
|
||||
__tablename__ = "columns"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(50), nullable=False, comment="栏目名称")
|
||||
slug = Column(String(50), unique=True, nullable=False, comment="URL标识")
|
||||
description = Column(String(200), comment="栏目描述")
|
||||
parent_id = Column(Integer, ForeignKey("columns.id"), nullable=True, comment="父栏目ID")
|
||||
sort_order = Column(Integer, default=0, comment="排序权重")
|
||||
is_enabled = Column(Boolean, default=True, comment="是否前端可见")
|
||||
display_mode = Column(String(20), default="list", comment="展示模式: list|card")
|
||||
layout_style = Column(String(20), default="left", comment="布局样式: left|right|top")
|
||||
icon_name = Column(String(50), nullable=True, comment="图标名称(lucide图标)")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
# 自关联关系
|
||||
parent = relationship("ColumnModel", remote_side=[id], back_populates="children")
|
||||
children = relationship("ColumnModel", back_populates="parent", cascade="all, delete-orphan")
|
||||
|
||||
def to_dict(self, include_content_count: bool = False):
|
||||
"""转换为字典"""
|
||||
data = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"slug": self.slug,
|
||||
"description": self.description,
|
||||
"parent_id": self.parent_id,
|
||||
"sort_order": self.sort_order,
|
||||
"is_enabled": self.is_enabled,
|
||||
"display_mode": self.display_mode,
|
||||
"layout_style": self.layout_style,
|
||||
"icon_name": self.icon_name,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
if include_content_count and hasattr(self, "content_count"):
|
||||
data["content_count"] = self.content_count
|
||||
return data
|
||||
@@ -0,0 +1,18 @@
|
||||
# app/models/contact_qrcode.py
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ContactQRCode(Base):
|
||||
__tablename__ = "contact_qrcodes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
content = Column(String(500), nullable=False)
|
||||
description = Column(String(200), nullable=True)
|
||||
image_path = Column(String(255), nullable=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
is_enabled = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
@@ -0,0 +1,16 @@
|
||||
# app/models/home_expert.py
|
||||
from sqlalchemy import Column, Integer, Boolean, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class HomeExpert(Base):
|
||||
__tablename__ = "home_experts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
post_id = Column(Integer, nullable=False, index=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
is_enabled = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
@@ -0,0 +1,22 @@
|
||||
# app/models/home_module.py
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Text, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class HomeModule(Base):
|
||||
__tablename__ = "home_modules"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(50), nullable=False) # 显示名称
|
||||
code = Column(String(30), unique=True, index=True, nullable=False) # 唯一标识
|
||||
type = Column(String(20), nullable=False) # 模块类型
|
||||
sort_order = Column(Integer, default=0) # 排序权重
|
||||
is_enabled = Column(Boolean, default=True) # 是否启用
|
||||
is_fixed = Column(Boolean, default=False) # 是否固定(不可删除)
|
||||
title = Column(String(100)) # 模块标题
|
||||
subtitle = Column(String(200)) # 副标题
|
||||
content = Column(Text) # 内容数据(JSON字符串)
|
||||
style_config = Column(Text) # 样式配置(JSON字符串)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
@@ -0,0 +1,19 @@
|
||||
# app/models/home_partner.py
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class HomePartner(Base):
|
||||
__tablename__ = "home_partners"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(200), nullable=False, comment="伙伴名称")
|
||||
logo_url = Column(String(500), comment="Logo图片路径")
|
||||
description = Column(String(500), comment="简介")
|
||||
website_url = Column(String(500), comment="网站URL")
|
||||
sort_order = Column(Integer, default=0)
|
||||
is_enabled = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
图片资源模型
|
||||
复用自 personal-blog,添加 module 字段支持多模块共用
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, DateTime, func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Image(Base):
|
||||
"""上传的图片资源"""
|
||||
__tablename__ = "images"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
filename = Column(String(255), nullable=False, comment="存储文件名")
|
||||
original_name = Column(String(255), comment="原始文件名")
|
||||
file_path = Column(String(500), nullable=False, comment="原图路径")
|
||||
thumb_path = Column(String(500), comment="缩略图路径")
|
||||
description = Column(String(500), comment="图片描述")
|
||||
file_size = Column(Integer, comment="文件大小(字节)")
|
||||
width = Column(Integer, comment="图片宽度")
|
||||
height = Column(Integer, comment="图片高度")
|
||||
module = Column(String(20), default="news", comment="所属模块:news/product/page等")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
|
||||
def to_dict(self):
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"filename": self.filename,
|
||||
"original_name": self.original_name,
|
||||
"file_path": self.file_path,
|
||||
"thumb_path": self.thumb_path,
|
||||
"description": self.description,
|
||||
"file_size": self.file_size,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"module": self.module,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# app/models/nav_menu.py
|
||||
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship, backref
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class NavMenu(Base):
|
||||
__tablename__ = "nav_menus"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(50), nullable=False)
|
||||
link = Column(String(255), nullable=False)
|
||||
sort_order = Column(Integer, default=0)
|
||||
is_enabled = Column(Boolean, default=True)
|
||||
is_external = Column(Boolean, default=False)
|
||||
parent_id = Column(Integer, ForeignKey("nav_menus.id"), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationship for sub-menus
|
||||
children = relationship("NavMenu", backref=backref("parent", remote_side=[id]))
|
||||
@@ -0,0 +1,39 @@
|
||||
# ⚠️ 此新闻模块已废弃,不再使用。
|
||||
# 已被「栏目管理 + 内容管理」(columns.py + articles.py)取代。
|
||||
# 请勿删除此注释,供后续维护人员参考。
|
||||
|
||||
"""
|
||||
新闻栏目模型(已废弃)
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class NewsCategory(Base):
|
||||
"""新闻栏目/分类"""
|
||||
__tablename__ = "news_categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(50), nullable=False, comment="栏目名称")
|
||||
slug = Column(String(50), unique=True, nullable=False, comment="URL标识")
|
||||
description = Column(String(200), comment="栏目描述")
|
||||
sort_order = Column(Integer, default=0, comment="排序权重")
|
||||
is_enabled = Column(Boolean, default=True, comment="是否启用")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def to_dict(self, include_post_count: bool = False):
|
||||
"""转换为字典"""
|
||||
data = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"slug": self.slug,
|
||||
"description": self.description,
|
||||
"sort_order": self.sort_order,
|
||||
"is_enabled": self.is_enabled,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
if include_post_count and hasattr(self, "post_count"):
|
||||
data["post_count"] = self.post_count
|
||||
return data
|
||||
@@ -0,0 +1,52 @@
|
||||
# ⚠️ 此新闻模块已废弃,不再使用。
|
||||
# 已被「栏目管理 + 内容管理」(columns.py + articles.py)取代。
|
||||
# 请勿删除此注释,供后续维护人员参考。
|
||||
|
||||
"""
|
||||
新闻资讯模型(已废弃)
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey, func
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class NewsPost(Base):
|
||||
"""新闻资讯文章"""
|
||||
__tablename__ = "news_posts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(200), nullable=False, comment="资讯标题")
|
||||
slug = Column(String(200), unique=True, nullable=False, comment="URL友好的标识")
|
||||
content = Column(Text, nullable=False, comment="内容")
|
||||
content_type = Column(String(10), default="markdown", comment="内容类型:markdown/html")
|
||||
summary = Column(String(500), comment="摘要")
|
||||
cover_image = Column(String(500), comment="封面图路径")
|
||||
category_id = Column(Integer, ForeignKey("news_categories.id"), nullable=False, comment="栏目ID")
|
||||
is_published = Column(Boolean, default=False, comment="是否发布")
|
||||
is_draft = Column(Boolean, default=True, comment="是否草稿")
|
||||
view_count = Column(Integer, default=0, comment="浏览次数")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
# 关联
|
||||
category = relationship("NewsCategory", backref="posts")
|
||||
|
||||
def to_dict(self, include_content: bool = False):
|
||||
"""转换为字典"""
|
||||
data = {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"slug": self.slug,
|
||||
"summary": self.summary,
|
||||
"cover_image": self.cover_image,
|
||||
"category": self.category.to_dict() if self.category else None,
|
||||
"content_type": self.content_type,
|
||||
"is_published": self.is_published,
|
||||
"is_draft": self.is_draft,
|
||||
"view_count": self.view_count,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
if include_content:
|
||||
data["content"] = self.content
|
||||
return data
|
||||
@@ -0,0 +1,16 @@
|
||||
# app/models/setting.py
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
__tablename__ = "settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
key = Column(String(50), unique=True, index=True, nullable=False)
|
||||
value = Column(Text)
|
||||
group = Column(String(20), default="general")
|
||||
description = Column(String(200))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
@@ -0,0 +1 @@
|
||||
# routers package
|
||||
@@ -0,0 +1,210 @@
|
||||
# app/routers/app_downloads.py
|
||||
import os
|
||||
import uuid
|
||||
from typing import List, Union
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
import qrcode
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.app_download import AppDownload
|
||||
from app.models.admin import Admin
|
||||
from app.auth import get_current_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Schemas
|
||||
class AppDownloadCreate(BaseModel):
|
||||
app_name: str
|
||||
platform: str
|
||||
download_url: str
|
||||
description: str = ""
|
||||
icon: str = ""
|
||||
sort_order: int = 0
|
||||
is_enabled: bool = True
|
||||
|
||||
|
||||
class AppDownloadUpdate(BaseModel):
|
||||
app_name: str = None
|
||||
platform: str = None
|
||||
download_url: str = None
|
||||
description: str = None
|
||||
icon: str = None
|
||||
sort_order: int = None
|
||||
is_enabled: bool = None
|
||||
|
||||
|
||||
class AppDownloadResponse(BaseModel):
|
||||
code: int = 200
|
||||
data: Union[dict, list] = None
|
||||
message: str = "success"
|
||||
|
||||
|
||||
# Helper
|
||||
|
||||
def get_qrcode_dir():
|
||||
routers_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
app_dir = os.path.dirname(routers_dir)
|
||||
backend_dir = os.path.dirname(app_dir)
|
||||
project_root = os.path.dirname(backend_dir)
|
||||
qrcode_dir = os.path.join(project_root, "storage", "admin", "images", "qrcode")
|
||||
os.makedirs(qrcode_dir, exist_ok=True)
|
||||
return qrcode_dir
|
||||
|
||||
|
||||
def generate_qrcode_image(content: str, filename: str) -> str:
|
||||
"""Generate QR code image and save to storage, return URL path"""
|
||||
qrcode_dir = get_qrcode_dir()
|
||||
filepath = os.path.join(qrcode_dir, filename)
|
||||
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_H,
|
||||
box_size=10,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(content)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
img.save(filepath)
|
||||
|
||||
return f"/storage/admin/images/qrcode/{filename}"
|
||||
|
||||
|
||||
def delete_qrcode_image(image_path: str):
|
||||
"""Delete QR code image file if exists"""
|
||||
if not image_path:
|
||||
return
|
||||
filename = os.path.basename(image_path)
|
||||
filepath = os.path.join(get_qrcode_dir(), filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
|
||||
|
||||
def app_download_to_dict(ad: AppDownload) -> dict:
|
||||
return {
|
||||
"id": ad.id,
|
||||
"app_name": ad.app_name,
|
||||
"platform": ad.platform,
|
||||
"download_url": ad.download_url,
|
||||
"description": ad.description,
|
||||
"icon": ad.icon,
|
||||
"qr_image_path": ad.qr_image_path,
|
||||
"sort_order": ad.sort_order,
|
||||
"is_enabled": ad.is_enabled,
|
||||
}
|
||||
|
||||
|
||||
# Public routes
|
||||
@router.get("/app-downloads", response_model=AppDownloadResponse)
|
||||
def get_public_app_downloads(db: Session = Depends(get_db)):
|
||||
"""Get enabled app downloads for public display"""
|
||||
items = db.query(AppDownload).filter(
|
||||
AppDownload.is_enabled == True
|
||||
).order_by(AppDownload.sort_order.asc()).all()
|
||||
return AppDownloadResponse(data=[app_download_to_dict(i) for i in items])
|
||||
|
||||
|
||||
# Admin routes
|
||||
@router.get("/admin/app-downloads", response_model=AppDownloadResponse)
|
||||
def get_admin_app_downloads(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Get all app downloads for admin"""
|
||||
items = db.query(AppDownload).order_by(AppDownload.sort_order.asc()).all()
|
||||
return AppDownloadResponse(data=[app_download_to_dict(i) for i in items])
|
||||
|
||||
|
||||
@router.post("/admin/app-downloads", response_model=AppDownloadResponse)
|
||||
def create_app_download(
|
||||
request: AppDownloadCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Create new app download, auto-generate QR code"""
|
||||
if not request.app_name.strip():
|
||||
raise HTTPException(status_code=400, detail="应用名称不能为空")
|
||||
if not request.download_url.strip():
|
||||
raise HTTPException(status_code=400, detail="下载链接不能为空")
|
||||
if not request.platform.strip():
|
||||
raise HTTPException(status_code=400, detail="平台不能为空")
|
||||
|
||||
# Generate QR code
|
||||
filename = f"app_download_{uuid.uuid4().hex[:8]}.png"
|
||||
qr_image_path = generate_qrcode_image(request.download_url.strip(), filename)
|
||||
|
||||
item = AppDownload(
|
||||
app_name=request.app_name.strip(),
|
||||
platform=request.platform.strip(),
|
||||
download_url=request.download_url.strip(),
|
||||
description=request.description.strip() if request.description else "",
|
||||
icon=request.icon.strip() if request.icon else "",
|
||||
qr_image_path=qr_image_path,
|
||||
sort_order=request.sort_order,
|
||||
is_enabled=request.is_enabled,
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return AppDownloadResponse(message="创建成功", data=app_download_to_dict(item))
|
||||
|
||||
|
||||
@router.put("/admin/app-downloads/{item_id}", response_model=AppDownloadResponse)
|
||||
def update_app_download(
|
||||
item_id: int,
|
||||
request: AppDownloadUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Update app download, regenerate QR code if URL changed"""
|
||||
item = db.query(AppDownload).filter(AppDownload.id == item_id).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="下载链接不存在")
|
||||
|
||||
update_data = request.dict(exclude_unset=True)
|
||||
|
||||
# If download_url changed, regenerate QR code
|
||||
new_url = update_data.get("download_url", "").strip()
|
||||
if new_url and new_url != item.download_url:
|
||||
delete_qrcode_image(item.qr_image_path)
|
||||
filename = f"app_download_{uuid.uuid4().hex[:8]}.png"
|
||||
item.qr_image_path = generate_qrcode_image(new_url, filename)
|
||||
item.download_url = new_url
|
||||
update_data.pop("download_url", None)
|
||||
|
||||
for field, value in update_data.items():
|
||||
if field == "app_name" and value:
|
||||
value = value.strip()
|
||||
elif field == "platform" and value:
|
||||
value = value.strip()
|
||||
elif field == "description" and value is not None:
|
||||
value = value.strip()
|
||||
elif field == "icon" and value is not None:
|
||||
value = value.strip()
|
||||
setattr(item, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return AppDownloadResponse(message="更新成功", data=app_download_to_dict(item))
|
||||
|
||||
|
||||
@router.delete("/admin/app-downloads/{item_id}", response_model=AppDownloadResponse)
|
||||
def delete_app_download(
|
||||
item_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Delete app download and its QR code image"""
|
||||
item = db.query(AppDownload).filter(AppDownload.id == item_id).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="下载链接不存在")
|
||||
|
||||
delete_qrcode_image(item.qr_image_path)
|
||||
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
return AppDownloadResponse(message="删除成功")
|
||||
@@ -0,0 +1,526 @@
|
||||
"""
|
||||
内容管理 API 路由
|
||||
复用新闻资讯模块的核心逻辑,关联到新的栏目(columns)体系
|
||||
"""
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import func
|
||||
from pydantic import BaseModel, Field
|
||||
from pypinyin import lazy_pinyin
|
||||
|
||||
from app.models.article import Article
|
||||
from app.models.column import ColumnModel
|
||||
from app.models.image import Image
|
||||
from app.routers.auth import get_current_admin
|
||||
from app.utils.file_utils import cleanup_file_by_path
|
||||
from app.database import get_db
|
||||
|
||||
router = APIRouter(tags=["内容管理"])
|
||||
|
||||
|
||||
# ========== 辅助函数 ==========
|
||||
|
||||
def extract_summary(content: str, max_length: int = 100) -> str:
|
||||
"""从 HTML/Markdown 内容中提取纯文本摘要"""
|
||||
if not content:
|
||||
return ""
|
||||
text = re.sub(r'<[^>]+>', '', content)
|
||||
text = html.unescape(text)
|
||||
text = re.sub(r'[#*_`~\[\]\(\)!>-]', '', text)
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
if len(text) > max_length:
|
||||
return text[:max_length] + "..."
|
||||
return text
|
||||
|
||||
|
||||
def generate_slug(text: str) -> str:
|
||||
"""将中文标题转为拼音 slug"""
|
||||
if not text:
|
||||
return ""
|
||||
py = lazy_pinyin(text, style=0)
|
||||
slug = "-".join(py)
|
||||
slug = re.sub(r"[^a-zA-Z0-9-]", "", slug).lower()
|
||||
slug = re.sub(r"-+", "-", slug).strip("-")
|
||||
return slug or f"article-{int(__import__('time').time())}"
|
||||
|
||||
|
||||
TEMP_STORAGE_PREFIX = "/storage/tmp/crawl"
|
||||
|
||||
|
||||
def get_upload_dir():
|
||||
"""获取上传目录路径"""
|
||||
from app.config import get_settings
|
||||
settings = get_settings()
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
return os.path.join(base_dir, settings.UPLOAD_DIR)
|
||||
|
||||
|
||||
def get_crawl_temp_dir():
|
||||
"""获取采集临时目录 - 跨平台"""
|
||||
if os.name == 'nt':
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
return os.path.join(base_dir, 'storage', 'tmp', 'crawl')
|
||||
return '/tmp/news_crawl'
|
||||
|
||||
|
||||
def migrate_temp_images(content: str, cover_image: Optional[str], db: Session):
|
||||
"""将内容中的临时图片迁移到正式目录,并写入 images 表"""
|
||||
upload_dir = get_upload_dir()
|
||||
temp_dir = get_crawl_temp_dir()
|
||||
|
||||
temp_paths = set()
|
||||
if cover_image and cover_image.startswith(TEMP_STORAGE_PREFIX):
|
||||
temp_paths.add(cover_image)
|
||||
|
||||
pattern = re.escape(TEMP_STORAGE_PREFIX) + r'/[^\\)"\'>\s]+'
|
||||
for match in re.finditer(pattern, content):
|
||||
temp_paths.add(match.group())
|
||||
|
||||
if not temp_paths:
|
||||
return content, cover_image
|
||||
|
||||
# 按年月分目录存储(与 batch_crawl / wechat_crawl 一致)
|
||||
year_month = datetime.now().strftime("%Y%m")
|
||||
dest_dir = os.path.join(upload_dir, year_month)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
|
||||
path_mapping = {}
|
||||
for temp_path in temp_paths:
|
||||
filename = os.path.basename(temp_path)
|
||||
temp_file = os.path.join(temp_dir, filename)
|
||||
if not os.path.exists(temp_file):
|
||||
continue
|
||||
dest_file = os.path.join(dest_dir, filename)
|
||||
shutil.move(temp_file, dest_file)
|
||||
|
||||
thumb_filename = f"{os.path.splitext(filename)[0]}.jpg"
|
||||
temp_thumb = os.path.join(temp_dir, "thumbnails", thumb_filename)
|
||||
dest_thumb = os.path.join(upload_dir, "thumbnails", thumb_filename)
|
||||
if os.path.exists(temp_thumb):
|
||||
shutil.move(temp_thumb, dest_thumb)
|
||||
|
||||
formal_path = f"/storage/admin/images/{year_month}/{filename}"
|
||||
formal_thumb = f"/storage/admin/images/thumbnails/{thumb_filename}"
|
||||
path_mapping[temp_path] = formal_path
|
||||
|
||||
try:
|
||||
file_size = os.path.getsize(dest_file)
|
||||
except OSError:
|
||||
file_size = 0
|
||||
existing = db.query(Image).filter(Image.file_path == formal_path).first()
|
||||
if not existing:
|
||||
img_record = Image(
|
||||
filename=filename,
|
||||
original_name=filename,
|
||||
file_path=formal_path,
|
||||
thumb_path=formal_thumb,
|
||||
file_size=file_size,
|
||||
module="articles"
|
||||
)
|
||||
db.add(img_record)
|
||||
|
||||
new_content = content
|
||||
for temp_path, formal_path in path_mapping.items():
|
||||
new_content = new_content.replace(temp_path, formal_path)
|
||||
|
||||
new_cover = cover_image
|
||||
if cover_image and cover_image in path_mapping:
|
||||
new_cover = path_mapping[cover_image]
|
||||
|
||||
return new_content, new_cover
|
||||
|
||||
|
||||
def _validate_column_for_article(column_id: int, db: Session, exclude_article_id: Optional[int] = None):
|
||||
"""校验栏目是否可关联内容:
|
||||
- 栏目必须存在
|
||||
- 顶级栏目若有二级栏目,则禁止直接关联内容
|
||||
"""
|
||||
column = db.query(ColumnModel).filter(ColumnModel.id == column_id).first()
|
||||
if not column:
|
||||
raise HTTPException(status_code=404, detail="栏目不存在")
|
||||
# 如果是顶级栏目,检查是否有子栏目
|
||||
if column.parent_id is None:
|
||||
child_count = db.query(ColumnModel).filter(ColumnModel.parent_id == column_id).count()
|
||||
if child_count > 0:
|
||||
raise HTTPException(status_code=400, detail="该栏目存在子栏目,内容必须关联到子栏目")
|
||||
return column
|
||||
|
||||
|
||||
# ========== Pydantic 模型 ==========
|
||||
|
||||
class ArticleCreate(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
slug: Optional[str] = Field(None, max_length=200)
|
||||
content: str = Field(..., min_length=1)
|
||||
content_type: str = Field(default="markdown", pattern="^(markdown|html)$")
|
||||
summary: Optional[str] = Field(None, max_length=500)
|
||||
cover_image: Optional[str] = Field(None, max_length=500)
|
||||
column_id: int
|
||||
author: Optional[str] = Field(None, max_length=50)
|
||||
status: str = Field(default="draft", pattern="^(published|draft)$")
|
||||
sort_order: int = Field(default=0)
|
||||
|
||||
|
||||
class ArticleUpdate(BaseModel):
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
slug: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
content: Optional[str] = Field(None, min_length=1)
|
||||
content_type: Optional[str] = Field(None, pattern="^(markdown|html)$")
|
||||
summary: Optional[str] = Field(None, max_length=500)
|
||||
cover_image: Optional[str] = Field(None, max_length=500)
|
||||
column_id: Optional[int] = None
|
||||
author: Optional[str] = Field(None, max_length=50)
|
||||
status: Optional[str] = Field(None, pattern="^(published|draft)$")
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class ArticlePublish(BaseModel):
|
||||
is_published: bool
|
||||
|
||||
|
||||
class BatchDelete(BaseModel):
|
||||
ids: List[int]
|
||||
|
||||
|
||||
class BatchMove(BaseModel):
|
||||
ids: List[int]
|
||||
column_id: int
|
||||
|
||||
|
||||
# ========== API ==========
|
||||
|
||||
@router.get("/admin/articles")
|
||||
def list_articles(
|
||||
page: int = Query(default=1, ge=1),
|
||||
size: int = Query(default=10, ge=1, le=100),
|
||||
column_id: Optional[int] = Query(default=None),
|
||||
keyword: Optional[str] = Query(default=None),
|
||||
status: Optional[str] = Query(default=None, pattern="^(published|draft|rewriting|all)$"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin),
|
||||
):
|
||||
"""获取内容列表(分页 + 筛选)"""
|
||||
query = db.query(Article).options(joinedload(Article.column))
|
||||
|
||||
if column_id:
|
||||
query = query.filter(Article.column_id == column_id)
|
||||
|
||||
if keyword:
|
||||
query = query.filter(Article.title.contains(keyword))
|
||||
|
||||
if status == "published":
|
||||
query = query.filter(Article.status == "published")
|
||||
elif status == "draft":
|
||||
query = query.filter(Article.status == "draft")
|
||||
|
||||
query = query.order_by(Article.updated_at.desc())
|
||||
|
||||
total = query.count()
|
||||
articles = query.offset((page - 1) * size).limit(size).all()
|
||||
|
||||
return {
|
||||
"items": [a.to_dict(include_content=False) for a in articles],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size
|
||||
}
|
||||
|
||||
|
||||
@router.get("/admin/articles/{article_id}")
|
||||
def get_article(
|
||||
article_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin),
|
||||
):
|
||||
"""获取内容详情"""
|
||||
article = db.query(Article).options(joinedload(Article.column)).filter(Article.id == article_id).first()
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="内容不存在")
|
||||
return article.to_dict(include_content=True)
|
||||
|
||||
|
||||
@router.post("/admin/articles")
|
||||
def create_article(
|
||||
data: ArticleCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin),
|
||||
):
|
||||
"""创建内容"""
|
||||
# 校验栏目
|
||||
_validate_column_for_article(data.column_id, db)
|
||||
|
||||
# 自动生成 slug
|
||||
slug = data.slug or generate_slug(data.title)
|
||||
if not slug:
|
||||
raise HTTPException(status_code=400, detail="URL标识生成失败,请手动填写")
|
||||
|
||||
existing = db.query(Article).filter(Article.slug == slug).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="内容标识已存在")
|
||||
|
||||
# 自动生成摘要
|
||||
summary = data.summary
|
||||
if not summary:
|
||||
summary = extract_summary(data.content, max_length=100)
|
||||
|
||||
# 迁移临时图片
|
||||
migrated_content, migrated_cover = migrate_temp_images(data.content, data.cover_image, db)
|
||||
|
||||
article = Article(
|
||||
title=data.title,
|
||||
slug=slug,
|
||||
content=migrated_content,
|
||||
content_type=data.content_type,
|
||||
summary=summary,
|
||||
cover_image=migrated_cover,
|
||||
column_id=data.column_id,
|
||||
author=data.author,
|
||||
status=data.status,
|
||||
sort_order=data.sort_order,
|
||||
)
|
||||
db.add(article)
|
||||
db.commit()
|
||||
db.refresh(article)
|
||||
return article.to_dict(include_content=True)
|
||||
|
||||
|
||||
@router.put("/admin/articles/{article_id}")
|
||||
def update_article(
|
||||
article_id: int,
|
||||
data: ArticleUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin),
|
||||
):
|
||||
"""更新内容"""
|
||||
article = db.query(Article).filter(Article.id == article_id).first()
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="内容不存在")
|
||||
|
||||
# 检查 slug 冲突
|
||||
slug = data.slug
|
||||
if slug is not None:
|
||||
if not slug.strip():
|
||||
slug = generate_slug(data.title or article.title)
|
||||
if slug != article.slug:
|
||||
existing = db.query(Article).filter(Article.slug == slug).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="内容标识已存在")
|
||||
|
||||
# 校验栏目
|
||||
if data.column_id is not None:
|
||||
_validate_column_for_article(data.column_id, db, exclude_article_id=article_id)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if slug is not None:
|
||||
update_data["slug"] = slug
|
||||
|
||||
# 摘要被清空时自动提取
|
||||
if "summary" in update_data and not update_data.get("summary", "").strip():
|
||||
content_to_extract = update_data.get("content") or article.content
|
||||
if content_to_extract:
|
||||
update_data["summary"] = extract_summary(content_to_extract, max_length=100)
|
||||
|
||||
# 迁移临时图片(如果内容/封面包含临时路径)
|
||||
if update_data.get("content"):
|
||||
migrated_content, migrated_cover = migrate_temp_images(
|
||||
update_data["content"],
|
||||
update_data.get("cover_image") or article.cover_image,
|
||||
db,
|
||||
)
|
||||
update_data["content"] = migrated_content
|
||||
update_data["cover_image"] = migrated_cover
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(article, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(article)
|
||||
return article.to_dict(include_content=True)
|
||||
|
||||
|
||||
@router.delete("/admin/articles/{article_id}")
|
||||
def delete_article(
|
||||
article_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin),
|
||||
):
|
||||
"""删除内容"""
|
||||
article = db.query(Article).filter(Article.id == article_id).first()
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="内容不存在")
|
||||
|
||||
if article.cover_image:
|
||||
cleanup_file_by_path(article.cover_image)
|
||||
|
||||
db.delete(article)
|
||||
db.commit()
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
@router.post("/admin/articles/batch-delete")
|
||||
def batch_delete_articles(
|
||||
data: BatchDelete,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin),
|
||||
):
|
||||
"""批量删除内容"""
|
||||
articles = db.query(Article).filter(Article.id.in_(data.ids)).all()
|
||||
for article in articles:
|
||||
if article.cover_image:
|
||||
cleanup_file_by_path(article.cover_image)
|
||||
db.delete(article)
|
||||
db.commit()
|
||||
return {"message": f"成功删除 {len(articles)} 条内容"}
|
||||
|
||||
|
||||
@router.post("/admin/articles/batch-move")
|
||||
def batch_move_articles(
|
||||
data: BatchMove,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin),
|
||||
):
|
||||
"""批量转移栏目"""
|
||||
# 校验目标栏目
|
||||
_validate_column_for_article(data.column_id, db)
|
||||
|
||||
articles = db.query(Article).filter(Article.id.in_(data.ids)).all()
|
||||
for article in articles:
|
||||
article.column_id = data.column_id
|
||||
db.commit()
|
||||
return {"message": f"成功转移 {len(articles)} 条内容"}
|
||||
|
||||
|
||||
@router.post("/admin/articles/{article_id}/publish")
|
||||
def publish_article(
|
||||
article_id: int,
|
||||
data: ArticlePublish,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin),
|
||||
):
|
||||
"""发布/取消发布内容"""
|
||||
article = db.query(Article).filter(Article.id == article_id).first()
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="内容不存在")
|
||||
|
||||
article.status = "published" if data.is_published else "draft"
|
||||
db.commit()
|
||||
db.refresh(article)
|
||||
return article.to_dict()
|
||||
|
||||
|
||||
# ========== 改写发布 ==========
|
||||
|
||||
VENV_PYTHON = "/mnt/datadisk0/www/new-media-platform/website-new/backend/.venv/bin/python3"
|
||||
PIPELINE_PATH = "/mnt/datadisk0/www/new-media-platform/website-new/rewrite/pipeline.py"
|
||||
LOG_FILE = "/mnt/datadisk0/www/new-media-platform/website-new/rewrite/logs/pipeline.log"
|
||||
|
||||
|
||||
@router.post("/admin/articles/{article_id}/rewrite-publish")
|
||||
def rewrite_publish_article(
|
||||
article_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin),
|
||||
):
|
||||
"""一键改写+发布到公众号(异步后台任务)"""
|
||||
article = db.query(Article).filter(Article.id == article_id).first()
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="内容不存在")
|
||||
|
||||
if article.status != "draft":
|
||||
raise HTTPException(status_code=400, detail="只有草稿状态的内容可以改写发布")
|
||||
|
||||
# 并发控制:检查是否有正在改写中的文章
|
||||
rewriting_count = db.query(Article).filter(
|
||||
Article.status == "rewriting"
|
||||
).count()
|
||||
if rewriting_count > 0:
|
||||
raise HTTPException(status_code=429, detail="已有文章正在改写中,请稍后再试")
|
||||
|
||||
# 设置为改写中
|
||||
article.status = "rewriting"
|
||||
db.commit()
|
||||
db.refresh(article)
|
||||
|
||||
# 启动后台子进程,stderr 写入日志文件便于排查
|
||||
stderr_log = open(LOG_FILE, "a")
|
||||
subprocess.Popen(
|
||||
[VENV_PYTHON, PIPELINE_PATH, str(article_id)],
|
||||
stdout=stderr_log,
|
||||
stderr=stderr_log,
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "改写任务已启动",
|
||||
"article_id": article_id,
|
||||
"status": "rewriting",
|
||||
}
|
||||
|
||||
|
||||
# ========== 访客端 Public API ==========
|
||||
|
||||
@router.get("/public/articles")
|
||||
def list_public_articles(
|
||||
page: int = Query(default=1, ge=1),
|
||||
size: int = Query(default=10, ge=1, le=100),
|
||||
column_slug: Optional[str] = Query(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""访客端:获取已发布的内容列表"""
|
||||
query = db.query(Article).options(joinedload(Article.column)).filter(
|
||||
Article.status == "published"
|
||||
)
|
||||
|
||||
# 如果指定了栏目 slug,筛选该栏目及其子栏目的内容
|
||||
if column_slug:
|
||||
column = db.query(ColumnModel).filter(
|
||||
ColumnModel.slug == column_slug,
|
||||
ColumnModel.is_enabled == True
|
||||
).first()
|
||||
if column:
|
||||
# 获取该栏目及所有子栏目的 ID
|
||||
column_ids = [column.id]
|
||||
children = db.query(ColumnModel).filter(
|
||||
ColumnModel.parent_id == column.id
|
||||
).all()
|
||||
column_ids.extend([c.id for c in children])
|
||||
query = query.filter(Article.column_id.in_(column_ids))
|
||||
|
||||
query = query.order_by(Article.sort_order.asc(), Article.updated_at.desc())
|
||||
|
||||
total = query.count()
|
||||
articles = query.offset((page - 1) * size).limit(size).all()
|
||||
|
||||
return {
|
||||
"items": [a.to_dict(include_content=False) for a in articles],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/articles/{slug}")
|
||||
def get_public_article(
|
||||
slug: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""访客端:通过 slug 获取内容详情"""
|
||||
article = db.query(Article).options(joinedload(Article.column)).filter(
|
||||
Article.slug == slug,
|
||||
Article.status == "published"
|
||||
).first()
|
||||
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="内容不存在")
|
||||
|
||||
# 增加浏览次数
|
||||
article.view_count += 1
|
||||
db.commit()
|
||||
|
||||
return article.to_dict(include_content=True)
|
||||
@@ -0,0 +1,93 @@
|
||||
# app/routers/auth.py
|
||||
from datetime import timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import get_db
|
||||
from app.models.admin import Admin
|
||||
from app.auth import verify_password, get_password_hash, create_access_token, get_current_admin
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
# Schemas
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
code: int = 200
|
||||
message: str
|
||||
data: dict
|
||||
|
||||
|
||||
class AdminInfoResponse(BaseModel):
|
||||
code: int = 200
|
||||
data: dict
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
# Routes
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
def login(request: LoginRequest, db: Session = Depends(get_db)):
|
||||
admin = db.query(Admin).filter(Admin.username == request.username).first()
|
||||
|
||||
if not admin or not verify_password(request.password, admin.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户名或密码错误"
|
||||
)
|
||||
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": admin.username}, expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
return LoginResponse(
|
||||
message="登录成功",
|
||||
data={
|
||||
"token": access_token,
|
||||
"admin": {
|
||||
"id": admin.id,
|
||||
"username": admin.username
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=AdminInfoResponse)
|
||||
def get_me(current_admin: Admin = Depends(get_current_admin)):
|
||||
return AdminInfoResponse(
|
||||
data={
|
||||
"id": current_admin.id,
|
||||
"username": current_admin.username
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
def change_password(
|
||||
request: ChangePasswordRequest,
|
||||
current_admin: Admin = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
if not verify_password(request.old_password, current_admin.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="原密码错误"
|
||||
)
|
||||
|
||||
current_admin.password_hash = get_password_hash(request.new_password)
|
||||
db.commit()
|
||||
|
||||
return {"code": 200, "message": "密码修改成功"}
|
||||
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
栏目管理 API 路由
|
||||
"""
|
||||
import re
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from pydantic import BaseModel, Field
|
||||
from pypinyin import lazy_pinyin
|
||||
|
||||
from app.models.column import ColumnModel
|
||||
from app.routers.auth import get_current_admin
|
||||
from app.database import get_db
|
||||
|
||||
router = APIRouter(tags=["栏目管理"])
|
||||
|
||||
|
||||
def generate_slug(text: str) -> str:
|
||||
"""将中文标题转为拼音 slug"""
|
||||
if not text:
|
||||
return ""
|
||||
py = lazy_pinyin(text, style=0)
|
||||
slug = "-".join(py)
|
||||
slug = re.sub(r"[^a-zA-Z0-9-]", "", slug).lower()
|
||||
slug = re.sub(r"-+", "-", slug).strip("-")
|
||||
return slug or f"column-{int(__import__('time').time())}"
|
||||
|
||||
|
||||
# ========== Pydantic 模型 ==========
|
||||
|
||||
class ColumnCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=50)
|
||||
slug: Optional[str] = Field(None, max_length=50)
|
||||
description: Optional[str] = Field(None, max_length=200)
|
||||
parent_id: Optional[int] = Field(None)
|
||||
sort_order: int = Field(default=0)
|
||||
is_enabled: bool = Field(default=True)
|
||||
display_mode: str = Field(default="list", pattern="^(list|card)$")
|
||||
layout_style: str = Field(default="left", pattern="^(left|right|top)$")
|
||||
icon_name: Optional[str] = Field(None, max_length=50)
|
||||
|
||||
|
||||
class ColumnUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=50)
|
||||
slug: Optional[str] = Field(None, min_length=1, max_length=50)
|
||||
description: Optional[str] = Field(None, max_length=200)
|
||||
parent_id: Optional[int] = Field(None)
|
||||
sort_order: Optional[int] = None
|
||||
is_enabled: Optional[bool] = None
|
||||
display_mode: Optional[str] = Field(None, pattern="^(list|card)$")
|
||||
layout_style: Optional[str] = Field(None, pattern="^(left|right|top)$")
|
||||
icon_name: Optional[str] = Field(None, max_length=50)
|
||||
|
||||
|
||||
class ColumnTreeOut(BaseModel):
|
||||
"""树形栏目输出"""
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str]
|
||||
parent_id: Optional[int]
|
||||
sort_order: int
|
||||
is_enabled: bool
|
||||
display_mode: str
|
||||
layout_style: str
|
||||
children: List["ColumnTreeOut"] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ========== 辅助函数 ==========
|
||||
|
||||
def _build_tree(columns: list, parent_id: Optional[int] = None) -> list:
|
||||
"""递归构建栏目树"""
|
||||
tree = []
|
||||
for col in columns:
|
||||
if col.parent_id == parent_id:
|
||||
node = col.to_dict()
|
||||
node["children"] = _build_tree(columns, col.id)
|
||||
tree.append(node)
|
||||
return tree
|
||||
|
||||
|
||||
def _validate_parent(column: ColumnModel, new_parent_id: Optional[int], db: Session):
|
||||
"""校验 parent_id 是否合法:最多两级"""
|
||||
if new_parent_id is None:
|
||||
return
|
||||
# 不能把自己设为自己的父栏目
|
||||
if column and new_parent_id == column.id:
|
||||
raise HTTPException(status_code=400, detail="不能将栏目设为自己的上级栏目")
|
||||
# 父栏目必须存在
|
||||
parent = db.query(ColumnModel).filter(ColumnModel.id == new_parent_id).first()
|
||||
if not parent:
|
||||
raise HTTPException(status_code=404, detail="上级栏目不存在")
|
||||
# 父栏目本身不能是二级栏目(最多两级)
|
||||
if parent.parent_id is not None:
|
||||
raise HTTPException(status_code=400, detail="最多支持两级栏目,不能将二级栏目设为上级")
|
||||
|
||||
|
||||
def _has_children(column_id: int, db: Session) -> bool:
|
||||
"""检查栏目是否有子栏目"""
|
||||
return db.query(ColumnModel).filter(ColumnModel.parent_id == column_id).first() is not None
|
||||
|
||||
|
||||
# ========== API ==========
|
||||
|
||||
@router.get("/admin/columns")
|
||||
def list_columns(
|
||||
tree: bool = Query(default=False, description="是否返回树形结构"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin),
|
||||
):
|
||||
"""获取栏目列表(后台管理)"""
|
||||
columns = db.query(ColumnModel).order_by(ColumnModel.sort_order.asc()).all()
|
||||
|
||||
if tree:
|
||||
return {"items": _build_tree(columns), "total": len(columns)}
|
||||
|
||||
return {"items": [col.to_dict() for col in columns], "total": len(columns)}
|
||||
|
||||
|
||||
@router.get("/admin/columns/tree")
|
||||
def get_column_tree(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin),
|
||||
):
|
||||
"""获取树形栏目列表(用于下拉选择,后台管理)"""
|
||||
columns = db.query(ColumnModel).order_by(ColumnModel.sort_order.asc()).all()
|
||||
return {"items": _build_tree(columns), "total": len(columns)}
|
||||
|
||||
|
||||
@router.get("/admin/columns/{column_id}")
|
||||
def get_column(
|
||||
column_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin),
|
||||
):
|
||||
"""获取栏目详情(后台管理)"""
|
||||
column = db.query(ColumnModel).filter(ColumnModel.id == column_id).first()
|
||||
if not column:
|
||||
raise HTTPException(status_code=404, detail="栏目不存在")
|
||||
return column.to_dict()
|
||||
|
||||
|
||||
@router.post("/admin/columns")
|
||||
def create_column(
|
||||
data: ColumnCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin),
|
||||
):
|
||||
"""创建栏目(后台管理)"""
|
||||
# 校验上级栏目
|
||||
_validate_parent(None, data.parent_id, db)
|
||||
|
||||
# 自动生成 slug
|
||||
slug = data.slug or generate_slug(data.name)
|
||||
if not slug:
|
||||
raise HTTPException(status_code=400, detail="URL标识生成失败,请手动填写")
|
||||
|
||||
# 检查 slug 唯一性
|
||||
existing = db.query(ColumnModel).filter(ColumnModel.slug == slug).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="栏目标识已存在")
|
||||
|
||||
column = ColumnModel(
|
||||
name=data.name,
|
||||
slug=slug,
|
||||
description=data.description,
|
||||
parent_id=data.parent_id,
|
||||
sort_order=data.sort_order,
|
||||
is_enabled=data.is_enabled,
|
||||
display_mode=data.display_mode,
|
||||
layout_style=data.layout_style,
|
||||
icon_name=data.icon_name,
|
||||
)
|
||||
db.add(column)
|
||||
db.commit()
|
||||
db.refresh(column)
|
||||
return column.to_dict()
|
||||
|
||||
|
||||
@router.put("/admin/columns/{column_id}")
|
||||
def update_column(
|
||||
column_id: int,
|
||||
data: ColumnUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin),
|
||||
):
|
||||
"""更新栏目(后台管理)"""
|
||||
column = db.query(ColumnModel).filter(ColumnModel.id == column_id).first()
|
||||
if not column:
|
||||
raise HTTPException(status_code=404, detail="栏目不存在")
|
||||
|
||||
# 校验上级栏目
|
||||
if data.parent_id is not None:
|
||||
_validate_parent(column, data.parent_id, db)
|
||||
|
||||
# 检查 slug 唯一性
|
||||
slug = data.slug
|
||||
if slug is not None:
|
||||
if not slug.strip():
|
||||
slug = generate_slug(data.name or column.name)
|
||||
if slug != column.slug:
|
||||
existing = db.query(ColumnModel).filter(ColumnModel.slug == slug).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="栏目标识已存在")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if slug is not None:
|
||||
update_data["slug"] = slug
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(column, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(column)
|
||||
return column.to_dict()
|
||||
|
||||
|
||||
@router.delete("/admin/columns/{column_id}")
|
||||
def delete_column(
|
||||
column_id: int,
|
||||
force: bool = Query(default=False, description="强制删除关联内容"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin),
|
||||
):
|
||||
"""删除栏目(后台管理)"""
|
||||
column = db.query(ColumnModel).filter(ColumnModel.id == column_id).first()
|
||||
if not column:
|
||||
raise HTTPException(status_code=404, detail="栏目不存在")
|
||||
|
||||
# 检查是否有子栏目
|
||||
child_count = db.query(ColumnModel).filter(ColumnModel.parent_id == column_id).count()
|
||||
if child_count > 0:
|
||||
if not force:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"该栏目存在 {child_count} 个子栏目,请先删除子栏目或勾选强制删除"
|
||||
)
|
||||
# 强制删除:递归删除所有子栏目
|
||||
db.query(ColumnModel).filter(ColumnModel.parent_id == column_id).delete(synchronize_session=False)
|
||||
|
||||
# TODO: 检查关联的内容数量(等 articles 模型创建后补充)
|
||||
# content_count = db.query(Article).filter(Article.column_id == column_id).count()
|
||||
# if content_count > 0:
|
||||
# if not force:
|
||||
# raise HTTPException(status_code=400, detail=f"该栏目下还有 {content_count} 条内容,是否强制删除?")
|
||||
# db.query(Article).filter(Article.column_id == column_id).delete(synchronize_session=False)
|
||||
|
||||
db.delete(column)
|
||||
db.commit()
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
# ========== 访客端 Public API ==========
|
||||
|
||||
@router.get("/public/columns")
|
||||
def list_public_columns(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""访客端:获取启用的栏目树"""
|
||||
columns = db.query(ColumnModel).filter(
|
||||
ColumnModel.is_enabled == True
|
||||
).order_by(ColumnModel.sort_order.asc()).all()
|
||||
return {"items": _build_tree(columns), "total": len(columns)}
|
||||
|
||||
|
||||
@router.get("/public/columns/{slug}")
|
||||
def get_public_column(
|
||||
slug: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""访客端:获取栏目详情(包含子栏目)"""
|
||||
column = db.query(ColumnModel).filter(
|
||||
ColumnModel.slug == slug,
|
||||
ColumnModel.is_enabled == True
|
||||
).first()
|
||||
if not column:
|
||||
raise HTTPException(status_code=404, detail="栏目不存在")
|
||||
|
||||
result = column.to_dict()
|
||||
# 加载子栏目
|
||||
children = db.query(ColumnModel).filter(
|
||||
ColumnModel.parent_id == column.id,
|
||||
ColumnModel.is_enabled == True
|
||||
).order_by(ColumnModel.sort_order.asc()).all()
|
||||
result["children"] = [c.to_dict() for c in children]
|
||||
# 加载栏目下的内容数量
|
||||
from app.models.article import Article
|
||||
article_count = db.query(Article).filter(
|
||||
Article.column_id == column.id,
|
||||
Article.status == "published"
|
||||
).count()
|
||||
result["article_count"] = article_count
|
||||
return result
|
||||
@@ -0,0 +1,190 @@
|
||||
# app/routers/contact_qrcodes.py
|
||||
import os
|
||||
import uuid
|
||||
from typing import List, Union
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
import qrcode
|
||||
from qrcode.image.pil import PilImage
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.contact_qrcode import ContactQRCode
|
||||
from app.models.admin import Admin
|
||||
from app.auth import get_current_admin
|
||||
from app.config import get_settings
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
# Schemas
|
||||
class ContactQRCodeCreate(BaseModel):
|
||||
content: str
|
||||
description: str = ""
|
||||
sort_order: int = 0
|
||||
is_enabled: bool = True
|
||||
|
||||
|
||||
class ContactQRCodeUpdate(BaseModel):
|
||||
content: str = None
|
||||
description: str = None
|
||||
sort_order: int = None
|
||||
is_enabled: bool = None
|
||||
|
||||
|
||||
class ContactQRCodeResponse(BaseModel):
|
||||
code: int = 200
|
||||
data: Union[dict, list] = None
|
||||
message: str = "success"
|
||||
|
||||
|
||||
# Helper
|
||||
def get_qrcode_dir():
|
||||
# __file__ = backend/app/routers/contact_qrcodes.py
|
||||
# 需要向上3级才能到达项目根目录
|
||||
routers_dir = os.path.dirname(os.path.abspath(__file__)) # backend/app/routers
|
||||
app_dir = os.path.dirname(routers_dir) # backend/app
|
||||
backend_dir = os.path.dirname(app_dir) # backend
|
||||
project_root = os.path.dirname(backend_dir) # project root
|
||||
qrcode_dir = os.path.join(project_root, "storage", "admin", "images", "qrcode")
|
||||
os.makedirs(qrcode_dir, exist_ok=True)
|
||||
return qrcode_dir
|
||||
|
||||
|
||||
def generate_qrcode_image(content: str, filename: str) -> str:
|
||||
"""Generate QR code image and save to storage, return URL path"""
|
||||
qrcode_dir = get_qrcode_dir()
|
||||
filepath = os.path.join(qrcode_dir, filename)
|
||||
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_H,
|
||||
box_size=10,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(content)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
img.save(filepath)
|
||||
|
||||
return f"/storage/admin/images/qrcode/{filename}"
|
||||
|
||||
|
||||
def qrcode_to_dict(qr: ContactQRCode) -> dict:
|
||||
return {
|
||||
"id": qr.id,
|
||||
"content": qr.content,
|
||||
"description": qr.description,
|
||||
"image_path": qr.image_path,
|
||||
"sort_order": qr.sort_order,
|
||||
"is_enabled": qr.is_enabled,
|
||||
}
|
||||
|
||||
|
||||
# Public routes
|
||||
@router.get("/contact-qrcodes", response_model=ContactQRCodeResponse)
|
||||
def get_public_qrcodes(db: Session = Depends(get_db)):
|
||||
"""Get enabled QR codes for public display"""
|
||||
qrcodes = db.query(ContactQRCode).filter(
|
||||
ContactQRCode.is_enabled == True
|
||||
).order_by(ContactQRCode.sort_order.asc()).all()
|
||||
return ContactQRCodeResponse(data=[qrcode_to_dict(q) for q in qrcodes])
|
||||
|
||||
|
||||
# Admin routes
|
||||
@router.get("/admin/contact-qrcodes", response_model=ContactQRCodeResponse)
|
||||
def get_admin_qrcodes(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Get all QR codes for admin"""
|
||||
qrcodes = db.query(ContactQRCode).order_by(ContactQRCode.sort_order.asc()).all()
|
||||
return ContactQRCodeResponse(data=[qrcode_to_dict(q) for q in qrcodes])
|
||||
|
||||
|
||||
@router.post("/admin/contact-qrcodes", response_model=ContactQRCodeResponse)
|
||||
def create_qrcode(
|
||||
request: ContactQRCodeCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Create new QR code, auto-generate image"""
|
||||
if not request.content.strip():
|
||||
raise HTTPException(status_code=400, detail="内容不能为空")
|
||||
|
||||
filename = f"qrcode_{uuid.uuid4().hex[:8]}.png"
|
||||
image_path = generate_qrcode_image(request.content.strip(), filename)
|
||||
|
||||
qr = ContactQRCode(
|
||||
content=request.content.strip(),
|
||||
description=request.description.strip() if request.description else "",
|
||||
image_path=image_path,
|
||||
sort_order=request.sort_order,
|
||||
is_enabled=request.is_enabled,
|
||||
)
|
||||
db.add(qr)
|
||||
db.commit()
|
||||
db.refresh(qr)
|
||||
return ContactQRCodeResponse(message="创建成功", data=qrcode_to_dict(qr))
|
||||
|
||||
|
||||
@router.put("/admin/contact-qrcodes/{qr_id}", response_model=ContactQRCodeResponse)
|
||||
def update_qrcode(
|
||||
qr_id: int,
|
||||
request: ContactQRCodeUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Update QR code, regenerate image if content changed"""
|
||||
qr = db.query(ContactQRCode).filter(ContactQRCode.id == qr_id).first()
|
||||
if not qr:
|
||||
raise HTTPException(status_code=404, detail="QR code not found")
|
||||
|
||||
update_data = request.dict(exclude_unset=True)
|
||||
|
||||
# If content changed, regenerate QR code image
|
||||
new_content = update_data.get("content", "").strip()
|
||||
if new_content and new_content != qr.content:
|
||||
# Delete old image
|
||||
if qr.image_path:
|
||||
old_filename = os.path.basename(qr.image_path)
|
||||
old_filepath = os.path.join(get_qrcode_dir(), old_filename)
|
||||
if os.path.exists(old_filepath):
|
||||
os.remove(old_filepath)
|
||||
# Generate new image
|
||||
filename = f"qrcode_{uuid.uuid4().hex[:8]}.png"
|
||||
qr.image_path = generate_qrcode_image(new_content, filename)
|
||||
qr.content = new_content
|
||||
update_data.pop("content", None)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(qr, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(qr)
|
||||
return ContactQRCodeResponse(message="更新成功", data=qrcode_to_dict(qr))
|
||||
|
||||
|
||||
@router.delete("/admin/contact-qrcodes/{qr_id}", response_model=ContactQRCodeResponse)
|
||||
def delete_qrcode(
|
||||
qr_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Delete QR code and its image file"""
|
||||
qr = db.query(ContactQRCode).filter(ContactQRCode.id == qr_id).first()
|
||||
if not qr:
|
||||
raise HTTPException(status_code=404, detail="QR code not found")
|
||||
|
||||
# Delete image file
|
||||
if qr.image_path:
|
||||
filename = os.path.basename(qr.image_path)
|
||||
filepath = os.path.join(get_qrcode_dir(), filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
|
||||
db.delete(qr)
|
||||
db.commit()
|
||||
return ContactQRCodeResponse(message="删除成功")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
||||
# app/routers/home_experts.py
|
||||
# 已迁移到新栏目体系(columns/articles),post_id 语义现在指向 Article.id
|
||||
from typing import List, Union, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.home_expert import HomeExpert
|
||||
from app.models.article import Article
|
||||
from app.models.admin import Admin
|
||||
from app.auth import get_current_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Schemas
|
||||
class HomeExpertCreate(BaseModel):
|
||||
post_id: int # 语义:article.id
|
||||
sort_order: int = 0
|
||||
is_enabled: bool = True
|
||||
|
||||
|
||||
class HomeExpertUpdate(BaseModel):
|
||||
post_id: Optional[int] = None
|
||||
sort_order: Optional[int] = None
|
||||
is_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
class HomeExpertResponse(BaseModel):
|
||||
code: int = 200
|
||||
data: Union[dict, list] = None
|
||||
message: str = "success"
|
||||
|
||||
|
||||
# Helper
|
||||
|
||||
def expert_to_dict(expert: HomeExpert, article: Article = None) -> dict:
|
||||
"""转换为字典。保持对外字段名 `post` 不变,避免破坏前端展示。"""
|
||||
result = {
|
||||
"id": expert.id,
|
||||
"post_id": expert.post_id,
|
||||
"sort_order": expert.sort_order,
|
||||
"is_enabled": expert.is_enabled,
|
||||
}
|
||||
if article:
|
||||
result["post"] = {
|
||||
"id": article.id,
|
||||
"title": article.title,
|
||||
"slug": article.slug,
|
||||
"summary": article.summary,
|
||||
"cover_image": article.cover_image,
|
||||
"column_id": article.column_id,
|
||||
# 兼容老字段名
|
||||
"category_id": article.column_id,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
# Public routes
|
||||
@router.get("/home-experts", response_model=HomeExpertResponse)
|
||||
def get_public_experts(
|
||||
limit: int = 6,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""访客端:获取启用的专家团队(关联已发布的文章)"""
|
||||
experts = db.query(HomeExpert).filter(
|
||||
HomeExpert.is_enabled == True
|
||||
).order_by(HomeExpert.sort_order.asc()).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for expert in experts:
|
||||
article = db.query(Article).filter(
|
||||
Article.id == expert.post_id,
|
||||
Article.status == "published"
|
||||
).first()
|
||||
if article:
|
||||
result.append(expert_to_dict(expert, article))
|
||||
return HomeExpertResponse(data=result)
|
||||
|
||||
|
||||
# Admin routes
|
||||
@router.get("/admin/home-experts", response_model=HomeExpertResponse)
|
||||
def get_admin_experts(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""后台:获取所有专家团队(含文章信息)"""
|
||||
experts = db.query(HomeExpert).order_by(HomeExpert.sort_order.asc()).all()
|
||||
result = []
|
||||
for expert in experts:
|
||||
article = db.query(Article).filter(Article.id == expert.post_id).first()
|
||||
result.append(expert_to_dict(expert, article))
|
||||
return HomeExpertResponse(data=result)
|
||||
|
||||
|
||||
@router.get("/admin/home-experts/available-posts", response_model=HomeExpertResponse)
|
||||
def get_available_posts(
|
||||
keyword: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""后台:获取可选的已发布文章列表(供挑选作为专家)"""
|
||||
query = db.query(Article).filter(Article.status == "published")
|
||||
|
||||
if keyword:
|
||||
query = query.filter(Article.title.contains(keyword))
|
||||
|
||||
articles = query.order_by(Article.created_at.desc()).limit(50).all()
|
||||
return HomeExpertResponse(data=[
|
||||
{
|
||||
"id": a.id,
|
||||
"title": a.title,
|
||||
"slug": a.slug,
|
||||
"summary": a.summary,
|
||||
"cover_image": a.cover_image,
|
||||
"column_id": a.column_id,
|
||||
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||
}
|
||||
for a in articles
|
||||
])
|
||||
|
||||
|
||||
@router.post("/admin/home-experts", response_model=HomeExpertResponse)
|
||||
def create_expert(
|
||||
request: HomeExpertCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""添加一篇文章为专家团队成员"""
|
||||
article = db.query(Article).filter(Article.id == request.post_id).first()
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="资讯不存在")
|
||||
|
||||
existing = db.query(HomeExpert).filter(HomeExpert.post_id == request.post_id).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="该资讯已添加到专家团队")
|
||||
|
||||
expert = HomeExpert(
|
||||
post_id=request.post_id,
|
||||
sort_order=request.sort_order,
|
||||
is_enabled=request.is_enabled,
|
||||
)
|
||||
db.add(expert)
|
||||
db.commit()
|
||||
db.refresh(expert)
|
||||
return HomeExpertResponse(message="添加成功", data=expert_to_dict(expert, article))
|
||||
|
||||
|
||||
@router.put("/admin/home-experts/{expert_id}", response_model=HomeExpertResponse)
|
||||
def update_expert(
|
||||
expert_id: int,
|
||||
request: HomeExpertUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""更新专家团队条目"""
|
||||
expert = db.query(HomeExpert).filter(HomeExpert.id == expert_id).first()
|
||||
if not expert:
|
||||
raise HTTPException(status_code=404, detail="专家不存在")
|
||||
|
||||
for field, value in request.dict(exclude_unset=True).items():
|
||||
setattr(expert, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(expert)
|
||||
article = db.query(Article).filter(Article.id == expert.post_id).first()
|
||||
return HomeExpertResponse(message="更新成功", data=expert_to_dict(expert, article))
|
||||
|
||||
|
||||
@router.delete("/admin/home-experts/{expert_id}", response_model=HomeExpertResponse)
|
||||
def delete_expert(
|
||||
expert_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""从首页专家团队移除(不删除原文章)"""
|
||||
expert = db.query(HomeExpert).filter(HomeExpert.id == expert_id).first()
|
||||
if not expert:
|
||||
raise HTTPException(status_code=404, detail="专家不存在")
|
||||
|
||||
db.delete(expert)
|
||||
db.commit()
|
||||
return HomeExpertResponse(message="移除成功")
|
||||
@@ -0,0 +1,184 @@
|
||||
# app/routers/home_modules.py
|
||||
import json
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.home_module import HomeModule
|
||||
from app.models.admin import Admin
|
||||
from app.auth import get_current_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Schemas
|
||||
class ModuleContent(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class ModuleStyleConfig(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class ModuleUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
subtitle: Optional[str] = None
|
||||
content: Optional[dict] = None
|
||||
style_config: Optional[dict] = None
|
||||
is_enabled: Optional[bool] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class ModuleSortItem(BaseModel):
|
||||
id: int
|
||||
sort_order: int
|
||||
|
||||
|
||||
class ModuleSortRequest(BaseModel):
|
||||
orders: List[ModuleSortItem]
|
||||
|
||||
|
||||
class ModuleResponse(BaseModel):
|
||||
code: int = 200
|
||||
data: dict | list = None
|
||||
|
||||
|
||||
# Helper functions
|
||||
def module_to_dict(module: HomeModule) -> dict:
|
||||
return {
|
||||
"id": module.id,
|
||||
"name": module.name,
|
||||
"code": module.code,
|
||||
"type": module.type,
|
||||
"sort_order": module.sort_order,
|
||||
"is_enabled": module.is_enabled,
|
||||
"is_fixed": module.is_fixed,
|
||||
"title": module.title,
|
||||
"subtitle": module.subtitle,
|
||||
"content": json.loads(module.content) if module.content else {},
|
||||
"style_config": json.loads(module.style_config) if module.style_config else {}
|
||||
}
|
||||
|
||||
|
||||
# Public routes
|
||||
@router.get("/home/modules", response_model=ModuleResponse)
|
||||
def get_public_modules(db: Session = Depends(get_db)):
|
||||
"""Get all enabled home modules for public display"""
|
||||
modules = db.query(HomeModule).filter(
|
||||
HomeModule.is_enabled == True
|
||||
).order_by(HomeModule.sort_order.asc()).all()
|
||||
|
||||
return ModuleResponse(data=[module_to_dict(m) for m in modules])
|
||||
|
||||
|
||||
# Admin routes
|
||||
@router.get("/admin/home/modules", response_model=ModuleResponse)
|
||||
def get_admin_modules(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Get all home modules for admin management"""
|
||||
modules = db.query(HomeModule).order_by(HomeModule.sort_order.asc()).all()
|
||||
|
||||
# Auto-create missing default modules
|
||||
existing_types = {m.type for m in modules}
|
||||
if 'experts' not in existing_types:
|
||||
expert_module = HomeModule(
|
||||
name="专家团队",
|
||||
code="experts",
|
||||
type="experts",
|
||||
sort_order=7,
|
||||
is_enabled=True,
|
||||
is_fixed=False,
|
||||
title="专家团队",
|
||||
content=json.dumps({"view_more_link": ""}),
|
||||
style_config=json.dumps({"backgroundColor": "#ffffff"})
|
||||
)
|
||||
db.add(expert_module)
|
||||
db.commit()
|
||||
db.refresh(expert_module)
|
||||
modules = db.query(HomeModule).order_by(HomeModule.sort_order.asc()).all()
|
||||
|
||||
return ModuleResponse(data=[module_to_dict(m) for m in modules])
|
||||
|
||||
|
||||
@router.get("/admin/home/modules/{module_id}", response_model=ModuleResponse)
|
||||
def get_module_detail(
|
||||
module_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Get single module detail"""
|
||||
module = db.query(HomeModule).filter(HomeModule.id == module_id).first()
|
||||
if not module:
|
||||
raise HTTPException(status_code=404, detail="Module not found")
|
||||
return ModuleResponse(data=module_to_dict(module))
|
||||
|
||||
|
||||
@router.put("/admin/home/modules/{module_id}", response_model=ModuleResponse)
|
||||
def update_module(
|
||||
module_id: int,
|
||||
request: ModuleUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Update module content and settings"""
|
||||
module = db.query(HomeModule).filter(HomeModule.id == module_id).first()
|
||||
if not module:
|
||||
raise HTTPException(status_code=404, detail="Module not found")
|
||||
|
||||
if request.title is not None:
|
||||
module.title = request.title
|
||||
if request.subtitle is not None:
|
||||
module.subtitle = request.subtitle
|
||||
if request.content is not None:
|
||||
module.content = json.dumps(request.content, ensure_ascii=False)
|
||||
if request.style_config is not None:
|
||||
module.style_config = json.dumps(request.style_config, ensure_ascii=False)
|
||||
if request.is_enabled is not None:
|
||||
module.is_enabled = request.is_enabled
|
||||
if request.sort_order is not None:
|
||||
module.sort_order = request.sort_order
|
||||
|
||||
db.commit()
|
||||
db.refresh(module)
|
||||
|
||||
return ModuleResponse(message="更新成功", data=module_to_dict(module))
|
||||
|
||||
|
||||
@router.put("/admin/home/modules/sort", response_model=ModuleResponse)
|
||||
def update_module_sort(
|
||||
request: ModuleSortRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Batch update module sort order"""
|
||||
for item in request.orders:
|
||||
module = db.query(HomeModule).filter(HomeModule.id == item.id).first()
|
||||
if module:
|
||||
module.sort_order = item.sort_order
|
||||
|
||||
db.commit()
|
||||
return ModuleResponse(message="排序更新成功")
|
||||
|
||||
|
||||
@router.put("/admin/home/modules/{module_id}/toggle", response_model=ModuleResponse)
|
||||
def toggle_module(
|
||||
module_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Toggle module enabled status"""
|
||||
module = db.query(HomeModule).filter(HomeModule.id == module_id).first()
|
||||
if not module:
|
||||
raise HTTPException(status_code=404, detail="Module not found")
|
||||
|
||||
module.is_enabled = not module.is_enabled
|
||||
db.commit()
|
||||
|
||||
return ModuleResponse(
|
||||
message="状态更新成功",
|
||||
data={"id": module.id, "is_enabled": module.is_enabled}
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
# app/routers/home_partners.py
|
||||
from typing import Union, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.home_partner import HomePartner
|
||||
from app.models.admin import Admin
|
||||
from app.auth import get_current_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Schemas
|
||||
class HomePartnerCreate(BaseModel):
|
||||
name: str
|
||||
logo_url: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
website_url: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
is_enabled: bool = True
|
||||
|
||||
|
||||
class HomePartnerUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
logo_url: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
website_url: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
is_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
class HomePartnerResponse(BaseModel):
|
||||
code: int = 200
|
||||
data: Union[dict, list, None] = None
|
||||
message: str = "success"
|
||||
|
||||
|
||||
# Helper
|
||||
def partner_to_dict(partner: HomePartner) -> dict:
|
||||
return {
|
||||
"id": partner.id,
|
||||
"name": partner.name,
|
||||
"logo_url": partner.logo_url,
|
||||
"description": partner.description,
|
||||
"website_url": partner.website_url,
|
||||
"sort_order": partner.sort_order,
|
||||
"is_enabled": partner.is_enabled,
|
||||
"created_at": partner.created_at.isoformat() if partner.created_at else None,
|
||||
"updated_at": partner.updated_at.isoformat() if partner.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
# Public routes
|
||||
@router.get("/home-partners", response_model=HomePartnerResponse)
|
||||
def get_public_partners(
|
||||
limit: int = 20,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get enabled home partners for public display"""
|
||||
partners = db.query(HomePartner).filter(
|
||||
HomePartner.is_enabled == True
|
||||
).order_by(HomePartner.sort_order.asc()).limit(limit).all()
|
||||
|
||||
result = [partner_to_dict(p) for p in partners]
|
||||
return HomePartnerResponse(data=result)
|
||||
|
||||
|
||||
# Admin routes
|
||||
@router.get("/admin/home-partners", response_model=HomePartnerResponse)
|
||||
def get_admin_partners(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Get all home partners for admin"""
|
||||
partners = db.query(HomePartner).order_by(HomePartner.sort_order.asc()).all()
|
||||
result = [partner_to_dict(p) for p in partners]
|
||||
return HomePartnerResponse(data=result)
|
||||
|
||||
|
||||
@router.post("/admin/home-partners", response_model=HomePartnerResponse)
|
||||
def create_partner(
|
||||
request: HomePartnerCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Create a new home partner"""
|
||||
partner = HomePartner(
|
||||
name=request.name,
|
||||
logo_url=request.logo_url,
|
||||
description=request.description,
|
||||
website_url=request.website_url,
|
||||
sort_order=request.sort_order,
|
||||
is_enabled=request.is_enabled,
|
||||
)
|
||||
db.add(partner)
|
||||
db.commit()
|
||||
db.refresh(partner)
|
||||
return HomePartnerResponse(message="添加成功", data=partner_to_dict(partner))
|
||||
|
||||
|
||||
@router.put("/admin/home-partners/{partner_id}", response_model=HomePartnerResponse)
|
||||
def update_partner(
|
||||
partner_id: int,
|
||||
request: HomePartnerUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Update home partner"""
|
||||
partner = db.query(HomePartner).filter(HomePartner.id == partner_id).first()
|
||||
if not partner:
|
||||
raise HTTPException(status_code=404, detail="合作伙伴不存在")
|
||||
|
||||
for field, value in request.dict(exclude_unset=True).items():
|
||||
setattr(partner, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(partner)
|
||||
return HomePartnerResponse(message="更新成功", data=partner_to_dict(partner))
|
||||
|
||||
|
||||
@router.delete("/admin/home-partners/{partner_id}", response_model=HomePartnerResponse)
|
||||
def delete_partner(
|
||||
partner_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Delete home partner"""
|
||||
partner = db.query(HomePartner).filter(HomePartner.id == partner_id).first()
|
||||
if not partner:
|
||||
raise HTTPException(status_code=404, detail="合作伙伴不存在")
|
||||
|
||||
db.delete(partner)
|
||||
db.commit()
|
||||
return HomePartnerResponse(message="删除成功")
|
||||
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
图片资源管理 API 路由
|
||||
复用自 personal-blog,添加 module 字段支持多模块共用
|
||||
图片扁平存储在 /uploads 目录下,缩略图在 /uploads/thumbnails/
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.image import Image
|
||||
from app.models.article import Article
|
||||
from app.routers.auth import get_current_admin
|
||||
from app.config import get_settings
|
||||
from app.utils.file_utils import cleanup_file_by_path
|
||||
|
||||
router = APIRouter(prefix="/images", tags=["images"], redirect_slashes=False)
|
||||
|
||||
# 上传配置 - 使用与静态文件服务相同的目录
|
||||
settings = get_settings()
|
||||
UPLOAD_DIR = Path(settings.UPLOAD_DIR)
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 缩略图目录
|
||||
THUMBNAIL_DIR = UPLOAD_DIR / "thumbnails"
|
||||
THUMBNAIL_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# 允许的图片格式
|
||||
ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'}
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
|
||||
|
||||
def generate_filename(original_name: str) -> str:
|
||||
"""生成唯一文件名"""
|
||||
ext = Path(original_name).suffix.lower()
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的文件格式,允许: {', '.join(ALLOWED_EXTENSIONS)}")
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
unique_id = uuid.uuid4().hex[:8]
|
||||
return f"{timestamp}_{unique_id}{ext}"
|
||||
|
||||
|
||||
def create_thumbnail(image_path: Path, thumb_path: Path, max_size: int = 300):
|
||||
"""创建缩略图"""
|
||||
try:
|
||||
with PILImage.open(image_path) as img:
|
||||
# 转换为 RGB(处理透明图片)
|
||||
if img.mode in ('RGBA', 'P'):
|
||||
img = img.convert('RGB')
|
||||
|
||||
# 等比缩放到最大尺寸
|
||||
img.thumbnail((max_size, max_size), PILImage.Resampling.LANCZOS)
|
||||
|
||||
# 保存
|
||||
img.save(thumb_path, "JPEG", quality=85)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"创建缩略图失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@router.get("")
|
||||
@router.get("/")
|
||||
def list_images(
|
||||
page: int = Query(default=1, ge=1),
|
||||
size: int = Query(default=20, ge=1, le=100),
|
||||
module: Optional[str] = Query(default=None, description="按模块筛选"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin)
|
||||
):
|
||||
"""获取图片列表(同时支持 /api/images 和 /api/images/)"""
|
||||
query = db.query(Image).order_by(Image.created_at.desc())
|
||||
|
||||
if module:
|
||||
query = query.filter(Image.module == module)
|
||||
|
||||
total = query.count()
|
||||
images = query.offset((page - 1) * size).limit(size).all()
|
||||
|
||||
return {
|
||||
"items": [img.to_dict() for img in images],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size
|
||||
}
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_image(
|
||||
file: UploadFile = File(...),
|
||||
description: Optional[str] = Form(default=None),
|
||||
module: str = Form(default="news"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin)
|
||||
):
|
||||
"""上传图片 - 扁平存储在 /uploads 目录"""
|
||||
# 检查文件大小
|
||||
file_size = 0
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
raise HTTPException(status_code=400, detail=f"文件大小超过限制 ({MAX_FILE_SIZE / 1024 / 1024}MB)")
|
||||
|
||||
# 生成文件名
|
||||
try:
|
||||
filename = generate_filename(file.filename)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail="文件名处理失败")
|
||||
|
||||
# 扁平存储 - 直接放在 /uploads 目录下
|
||||
file_path = UPLOAD_DIR / filename
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# 获取图片尺寸
|
||||
width, height = None, None
|
||||
try:
|
||||
with PILImage.open(file_path) as img:
|
||||
width, height = img.size
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 创建缩略图 - 统一放在 thumbnails 目录下
|
||||
# 缩略图与原图同名,但扩展名统一为 .jpg
|
||||
thumbnail_name = f"{Path(filename).stem}.jpg"
|
||||
thumb_file_path = THUMBNAIL_DIR / thumbnail_name
|
||||
|
||||
thumb_relative_path = None
|
||||
if create_thumbnail(file_path, thumb_file_path):
|
||||
thumb_relative_path = f"/storage/admin/images/thumbnails/{thumbnail_name}"
|
||||
|
||||
# 保存到数据库 - 使用新的存储路径
|
||||
relative_file_path = f"/storage/admin/images/{filename}"
|
||||
|
||||
image = Image(
|
||||
filename=filename,
|
||||
original_name=file.filename,
|
||||
file_path=relative_file_path,
|
||||
thumb_path=thumb_relative_path,
|
||||
description=description,
|
||||
file_size=file_size,
|
||||
width=width,
|
||||
height=height,
|
||||
module=module
|
||||
)
|
||||
|
||||
db.add(image)
|
||||
db.commit()
|
||||
db.refresh(image)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": image.to_dict()
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{image_id}")
|
||||
def delete_image(
|
||||
image_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin)
|
||||
):
|
||||
"""删除图片"""
|
||||
image = db.query(Image).filter(Image.id == image_id).first()
|
||||
if not image:
|
||||
raise HTTPException(status_code=404, detail="图片不存在")
|
||||
|
||||
# 删除物理文件及其缩略图
|
||||
cleanup_file_by_path(image.file_path)
|
||||
|
||||
db.delete(image)
|
||||
db.commit()
|
||||
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
@router.put("/{image_id}")
|
||||
def update_image(
|
||||
image_id: int,
|
||||
description: Optional[str] = Form(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin)
|
||||
):
|
||||
"""更新图片描述"""
|
||||
image = db.query(Image).filter(Image.id == image_id).first()
|
||||
if not image:
|
||||
raise HTTPException(status_code=404, detail="图片不存在")
|
||||
|
||||
if description is not None:
|
||||
image.description = description
|
||||
|
||||
db.commit()
|
||||
db.refresh(image)
|
||||
return image.to_dict()
|
||||
|
||||
|
||||
@router.get("/{image_id}/insert")
|
||||
def get_image_insert_info(
|
||||
image_id: int,
|
||||
size: str = Query(default="original", pattern="^(original|thumb)$"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin)
|
||||
):
|
||||
"""获取图片插入信息(用于编辑器插入)"""
|
||||
image = db.query(Image).filter(Image.id == image_id).first()
|
||||
if not image:
|
||||
raise HTTPException(status_code=404, detail="图片不存在")
|
||||
|
||||
url = image.file_path if size == "original" else (image.thumb_path or image.file_path)
|
||||
|
||||
return {
|
||||
"id": image.id,
|
||||
"url": url,
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"original_name": image.original_name
|
||||
}
|
||||
|
||||
|
||||
@router.post("/batch-delete")
|
||||
def batch_delete_images(
|
||||
image_ids: list[int],
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin)
|
||||
):
|
||||
"""批量删除图片"""
|
||||
deleted_count = 0
|
||||
|
||||
for image_id in image_ids:
|
||||
image = db.query(Image).filter(Image.id == image_id).first()
|
||||
if image:
|
||||
# 删除物理文件及其缩略图
|
||||
cleanup_file_by_path(image.file_path)
|
||||
|
||||
db.delete(image)
|
||||
deleted_count += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"message": f"成功删除 {deleted_count} 张图片"}
|
||||
|
||||
|
||||
def _collect_referenced_paths(db: Session) -> set:
|
||||
"""收集所有被文章引用的图片路径(封面图 + 内容中的图片)。
|
||||
|
||||
优化:仅加载有图片引用的文章,跳过无封面且内容无图片路径的文章。
|
||||
"""
|
||||
referenced_paths = set()
|
||||
|
||||
# 预过滤:只查有 cover_image 或内容中可能包含图片的文章
|
||||
articles = db.query(Article).filter(
|
||||
or_(
|
||||
Article.cover_image.isnot(None),
|
||||
Article.cover_image != '',
|
||||
Article.content.like('%/storage/%'),
|
||||
Article.content.like('%/uploads/%'),
|
||||
Article.content.like('%.jpg%'),
|
||||
Article.content.like('%.png%'),
|
||||
Article.content.like('%.gif%'),
|
||||
Article.content.like('%.webp%'),
|
||||
)
|
||||
).all()
|
||||
|
||||
for post in articles:
|
||||
if post.cover_image:
|
||||
referenced_paths.add(post.cover_image)
|
||||
if post.content:
|
||||
# Markdown:  或 <img src="path">
|
||||
md_images = re.findall(r'!\[.*?\]\((.*?)\)', post.content)
|
||||
html_images = re.findall(r'<img[^>]+src=["\']([^"\']+)["\']', post.content)
|
||||
for path in md_images + html_images:
|
||||
referenced_paths.add(path)
|
||||
|
||||
return referenced_paths
|
||||
|
||||
|
||||
@router.get("/unreferenced")
|
||||
def get_unreferenced_images(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin)
|
||||
):
|
||||
"""扫描未被任何文章引用的图片"""
|
||||
referenced_paths = _collect_referenced_paths(db)
|
||||
|
||||
# 仅获取有 file_path 的图片
|
||||
all_images = db.query(Image).filter(Image.file_path.isnot(None)).all()
|
||||
|
||||
# 找出未引用的图片
|
||||
unreferenced = []
|
||||
for img in all_images:
|
||||
if img.file_path not in referenced_paths:
|
||||
unreferenced.append(img.to_dict())
|
||||
|
||||
return {
|
||||
"items": unreferenced,
|
||||
"total": len(unreferenced)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/cleanup")
|
||||
def cleanup_unreferenced_images(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin)
|
||||
):
|
||||
"""批量删除未被任何文章引用的图片"""
|
||||
referenced_paths = _collect_referenced_paths(db)
|
||||
|
||||
# 仅获取有 file_path 的图片
|
||||
all_images = db.query(Image).filter(Image.file_path.isnot(None)).all()
|
||||
|
||||
# 删除未引用的图片
|
||||
deleted_count = 0
|
||||
deleted_files = []
|
||||
for img in all_images:
|
||||
if img.file_path not in referenced_paths:
|
||||
cleanup_file_by_path(img.file_path)
|
||||
deleted_files.append(img.to_dict())
|
||||
db.delete(img)
|
||||
deleted_count += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"成功清理 {deleted_count} 张未引用图片",
|
||||
"deleted_count": deleted_count,
|
||||
"deleted_files": deleted_files
|
||||
}
|
||||
|
||||
|
||||
# 静态文件服务(开发环境使用,生产环境建议用 Nginx)
|
||||
@router.get("/file/{path:path}")
|
||||
def serve_image(path: str):
|
||||
"""提供图片文件访问"""
|
||||
file_path = UPLOAD_DIR / path
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
|
||||
# 安全检查,防止目录遍历
|
||||
try:
|
||||
file_path.resolve().relative_to(UPLOAD_DIR.resolve())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=403, detail="禁止访问")
|
||||
|
||||
return FileResponse(file_path)
|
||||
@@ -0,0 +1,124 @@
|
||||
# app/routers/nav_menus.py
|
||||
from typing import List, Union, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.nav_menu import NavMenu
|
||||
from app.models.admin import Admin
|
||||
from app.auth import get_current_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Schemas
|
||||
class NavMenuCreate(BaseModel):
|
||||
name: str
|
||||
link: str
|
||||
sort_order: int = 0
|
||||
is_enabled: bool = True
|
||||
is_external: bool = False
|
||||
parent_id: Optional[int] = None
|
||||
|
||||
|
||||
class NavMenuUpdate(BaseModel):
|
||||
name: str = None
|
||||
link: str = None
|
||||
sort_order: int = None
|
||||
is_enabled: bool = None
|
||||
is_external: bool = None
|
||||
parent_id: Optional[int] = None
|
||||
|
||||
|
||||
class NavMenuResponse(BaseModel):
|
||||
code: int = 200
|
||||
data: Union[dict, list] = None
|
||||
message: str = "success"
|
||||
|
||||
|
||||
# Helper
|
||||
def menu_to_dict(menu: NavMenu) -> dict:
|
||||
return {
|
||||
"id": menu.id,
|
||||
"name": menu.name,
|
||||
"link": menu.link,
|
||||
"sort_order": menu.sort_order,
|
||||
"is_enabled": menu.is_enabled,
|
||||
"is_external": menu.is_external,
|
||||
"parent_id": menu.parent_id,
|
||||
"children": [menu_to_dict(c) for c in menu.children if c.is_enabled] if menu.children else []
|
||||
}
|
||||
|
||||
|
||||
# Public routes
|
||||
@router.get("/nav", response_model=NavMenuResponse)
|
||||
def get_public_menus(db: Session = Depends(get_db)):
|
||||
"""Get enabled nav menus for public display"""
|
||||
menus = db.query(NavMenu).filter(
|
||||
NavMenu.is_enabled == True,
|
||||
NavMenu.parent_id == None
|
||||
).order_by(NavMenu.sort_order.asc()).all()
|
||||
|
||||
return NavMenuResponse(data=[menu_to_dict(m) for m in menus])
|
||||
|
||||
|
||||
# Admin routes
|
||||
@router.get("/admin/nav", response_model=NavMenuResponse)
|
||||
def get_admin_menus(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Get all nav menus for admin"""
|
||||
menus = db.query(NavMenu).order_by(NavMenu.sort_order.asc()).all()
|
||||
return NavMenuResponse(data=[menu_to_dict(m) for m in menus])
|
||||
|
||||
|
||||
@router.post("/admin/nav", response_model=NavMenuResponse)
|
||||
def create_menu(
|
||||
request: NavMenuCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Create new nav menu"""
|
||||
menu = NavMenu(**request.dict())
|
||||
db.add(menu)
|
||||
db.commit()
|
||||
db.refresh(menu)
|
||||
return NavMenuResponse(message="创建成功", data=menu_to_dict(menu))
|
||||
|
||||
|
||||
@router.put("/admin/nav/{menu_id}", response_model=NavMenuResponse)
|
||||
def update_menu(
|
||||
menu_id: int,
|
||||
request: NavMenuUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Update nav menu"""
|
||||
menu = db.query(NavMenu).filter(NavMenu.id == menu_id).first()
|
||||
if not menu:
|
||||
raise HTTPException(status_code=404, detail="Menu not found")
|
||||
|
||||
for field, value in request.dict(exclude_unset=True).items():
|
||||
setattr(menu, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(menu)
|
||||
return NavMenuResponse(message="更新成功", data=menu_to_dict(menu))
|
||||
|
||||
|
||||
@router.delete("/admin/nav/{menu_id}", response_model=NavMenuResponse)
|
||||
def delete_menu(
|
||||
menu_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Delete nav menu"""
|
||||
menu = db.query(NavMenu).filter(NavMenu.id == menu_id).first()
|
||||
if not menu:
|
||||
raise HTTPException(status_code=404, detail="Menu not found")
|
||||
|
||||
db.delete(menu)
|
||||
db.commit()
|
||||
return NavMenuResponse(message="删除成功")
|
||||
@@ -0,0 +1,574 @@
|
||||
# ⚠️ 此新闻模块已废弃,不再使用。
|
||||
# 已被「栏目管理 + 内容管理」(columns.py + articles.py)取代。
|
||||
# 请勿删除此注释,供后续维护人员参考。
|
||||
|
||||
"""
|
||||
新闻资讯 API 路由(已废弃)
|
||||
包含栏目管理和资讯管理
|
||||
"""
|
||||
import html
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import func
|
||||
from pydantic import BaseModel, Field
|
||||
from pypinyin import lazy_pinyin
|
||||
|
||||
def extract_summary(content: str, max_length: int = 100) -> str:
|
||||
"""从 HTML/Markdown 内容中提取纯文本摘要"""
|
||||
if not content:
|
||||
return ""
|
||||
# 去除 HTML 标签
|
||||
text = re.sub(r'<[^>]+>', '', content)
|
||||
# 解码 HTML 实体(如 " < 等)
|
||||
text = html.unescape(text)
|
||||
# 去除 Markdown 标记(简化处理)
|
||||
text = re.sub(r'[#*_`~\[\]\(\)!>-]', '', text)
|
||||
# 去除多余空白
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
# 截取摘要
|
||||
if len(text) > max_length:
|
||||
return text[:max_length] + "..."
|
||||
return text
|
||||
from app.models.news_category import NewsCategory
|
||||
from app.models.news_post import NewsPost
|
||||
from app.models.image import Image
|
||||
from app.routers.auth import get_current_admin
|
||||
from app.utils.file_utils import cleanup_file_by_path
|
||||
from app.database import get_db
|
||||
|
||||
router = APIRouter(prefix="/news", tags=["news"])
|
||||
|
||||
|
||||
def generate_slug(text: str) -> str:
|
||||
"""将中文标题转为拼音 slug"""
|
||||
if not text:
|
||||
return ''
|
||||
# 转拼音,无音调
|
||||
py = lazy_pinyin(text, style=0)
|
||||
slug = '-'.join(py)
|
||||
# 清理特殊字符
|
||||
slug = re.sub(r'[^a-zA-Z0-9-]', '', slug).lower()
|
||||
# 去重横线
|
||||
slug = re.sub(r'-+', '-', slug).strip('-')
|
||||
return slug or f'post-{int(__import__("time").time())}'
|
||||
|
||||
|
||||
# ========== Pydantic 模型 ==========
|
||||
|
||||
class CategoryCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=50)
|
||||
slug: Optional[str] = Field(None, max_length=50)
|
||||
description: Optional[str] = Field(None, max_length=200)
|
||||
sort_order: int = Field(default=0)
|
||||
is_enabled: bool = Field(default=True)
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=50)
|
||||
slug: Optional[str] = Field(None, min_length=1, max_length=50)
|
||||
description: Optional[str] = Field(None, max_length=200)
|
||||
sort_order: Optional[int] = None
|
||||
is_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
class PostCreate(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
slug: Optional[str] = Field(None, max_length=200)
|
||||
content: str = Field(..., min_length=1)
|
||||
content_type: str = Field(default="markdown", pattern="^(markdown|html)$")
|
||||
summary: Optional[str] = Field(None, max_length=500)
|
||||
cover_image: Optional[str] = Field(None, max_length=500)
|
||||
category_id: int
|
||||
is_draft: bool = Field(default=True)
|
||||
|
||||
|
||||
class PostUpdate(BaseModel):
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
slug: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
content: Optional[str] = Field(None, min_length=1)
|
||||
content_type: Optional[str] = Field(None, pattern="^(markdown|html)$")
|
||||
summary: Optional[str] = Field(None, max_length=500)
|
||||
cover_image: Optional[str] = Field(None, max_length=500)
|
||||
category_id: Optional[int] = None
|
||||
is_draft: Optional[bool] = None
|
||||
|
||||
|
||||
class PostPublish(BaseModel):
|
||||
is_published: bool
|
||||
|
||||
|
||||
# ========== 临时图片迁移辅助函数 ==========
|
||||
|
||||
TEMP_STORAGE_PREFIX = "/storage/tmp/crawl"
|
||||
|
||||
|
||||
def get_upload_dir():
|
||||
"""获取上传目录路径"""
|
||||
from app.config import get_settings
|
||||
settings = get_settings()
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
return os.path.join(base_dir, settings.UPLOAD_DIR)
|
||||
|
||||
|
||||
def get_crawl_temp_dir():
|
||||
"""获取采集临时目录 - 跨平台"""
|
||||
if os.name == 'nt': # Windows
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
return os.path.join(base_dir, 'storage', 'tmp', 'crawl')
|
||||
return '/tmp/news_crawl'
|
||||
|
||||
|
||||
def migrate_temp_images(content: str, cover_image: Optional[str], db: Session):
|
||||
"""
|
||||
将内容中的临时图片迁移到正式目录,并写入 images 表。
|
||||
返回更新后的 (content, cover_image)。
|
||||
"""
|
||||
upload_dir = get_upload_dir()
|
||||
temp_dir = get_crawl_temp_dir()
|
||||
|
||||
# 收集所有需要迁移的临时路径
|
||||
temp_paths = set()
|
||||
if cover_image and cover_image.startswith(TEMP_STORAGE_PREFIX):
|
||||
temp_paths.add(cover_image)
|
||||
|
||||
# 从 content 中查找所有临时图片路径
|
||||
pattern = re.escape(TEMP_STORAGE_PREFIX) + r'/[^\\)"\'>\s]+'
|
||||
for match in re.finditer(pattern, content):
|
||||
temp_paths.add(match.group())
|
||||
|
||||
if not temp_paths:
|
||||
return content, cover_image
|
||||
|
||||
# 路径映射: 临时路径 -> 正式路径
|
||||
path_mapping = {}
|
||||
|
||||
for temp_path in temp_paths:
|
||||
filename = os.path.basename(temp_path)
|
||||
temp_file = os.path.join(temp_dir, filename)
|
||||
|
||||
if not os.path.exists(temp_file):
|
||||
continue
|
||||
|
||||
# 移动到正式目录
|
||||
dest_file = os.path.join(upload_dir, filename)
|
||||
shutil.move(temp_file, dest_file)
|
||||
|
||||
# 迁移缩略图
|
||||
thumb_filename = f"{os.path.splitext(filename)[0]}.jpg"
|
||||
temp_thumb = os.path.join(temp_dir, "thumbnails", thumb_filename)
|
||||
dest_thumb = os.path.join(upload_dir, "thumbnails", thumb_filename)
|
||||
if os.path.exists(temp_thumb):
|
||||
shutil.move(temp_thumb, dest_thumb)
|
||||
|
||||
formal_path = f"/storage/admin/images/{filename}"
|
||||
formal_thumb = f"/storage/admin/images/thumbnails/{thumb_filename}"
|
||||
path_mapping[temp_path] = formal_path
|
||||
|
||||
# 写入 images 表
|
||||
try:
|
||||
file_size = os.path.getsize(dest_file)
|
||||
except OSError:
|
||||
file_size = 0
|
||||
existing = db.query(Image).filter(Image.file_path == formal_path).first()
|
||||
if not existing:
|
||||
img_record = Image(
|
||||
filename=filename,
|
||||
original_name=filename,
|
||||
file_path=formal_path,
|
||||
thumb_path=formal_thumb,
|
||||
file_size=file_size,
|
||||
module="news"
|
||||
)
|
||||
db.add(img_record)
|
||||
|
||||
# 替换 content 中的路径
|
||||
new_content = content
|
||||
for temp_path, formal_path in path_mapping.items():
|
||||
new_content = new_content.replace(temp_path, formal_path)
|
||||
|
||||
# 替换 cover_image
|
||||
new_cover = cover_image
|
||||
if cover_image and cover_image in path_mapping:
|
||||
new_cover = path_mapping[cover_image]
|
||||
|
||||
return new_content, new_cover
|
||||
|
||||
|
||||
# ========== 栏目管理 API ==========
|
||||
|
||||
@router.get("/categories")
|
||||
def list_categories(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin),
|
||||
include_count: bool = Query(default=True, description="是否包含文章数量")
|
||||
):
|
||||
"""获取栏目列表"""
|
||||
query = db.query(NewsCategory).order_by(NewsCategory.sort_order.asc())
|
||||
|
||||
categories = query.all()
|
||||
|
||||
# 获取每个栏目的文章数量
|
||||
if include_count:
|
||||
counts = db.query(
|
||||
NewsPost.category_id,
|
||||
func.count(NewsPost.id).label("count")
|
||||
).group_by(NewsPost.category_id).all()
|
||||
count_map = {c.category_id: c.count for c in counts}
|
||||
for cat in categories:
|
||||
cat.post_count = count_map.get(cat.id, 0)
|
||||
|
||||
return {
|
||||
"items": [cat.to_dict(include_post_count=include_count) for cat in categories],
|
||||
"total": len(categories)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/categories")
|
||||
def create_category(
|
||||
data: CategoryCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin)
|
||||
):
|
||||
"""创建栏目"""
|
||||
# 自动生成 slug(如果未提供)
|
||||
slug = data.slug or generate_slug(data.name)
|
||||
|
||||
# 检查 slug 是否已存在
|
||||
existing = db.query(NewsCategory).filter(NewsCategory.slug == slug).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="栏目标识已存在")
|
||||
|
||||
category = NewsCategory(**{**data.model_dump(), "slug": slug})
|
||||
db.add(category)
|
||||
db.commit()
|
||||
db.refresh(category)
|
||||
return category.to_dict()
|
||||
|
||||
|
||||
@router.get("/categories/{category_id}")
|
||||
def get_category(
|
||||
category_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin)
|
||||
):
|
||||
"""获取栏目详情"""
|
||||
category = db.query(NewsCategory).filter(NewsCategory.id == category_id).first()
|
||||
if not category:
|
||||
raise HTTPException(status_code=404, detail="栏目不存在")
|
||||
return category.to_dict(include_post_count=True)
|
||||
|
||||
|
||||
@router.put("/categories/{category_id}")
|
||||
def update_category(
|
||||
category_id: int,
|
||||
data: CategoryUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin)
|
||||
):
|
||||
"""更新栏目"""
|
||||
category = db.query(NewsCategory).filter(NewsCategory.id == category_id).first()
|
||||
if not category:
|
||||
raise HTTPException(status_code=404, detail="栏目不存在")
|
||||
|
||||
# 检查 slug 是否冲突
|
||||
if data.slug and data.slug != category.slug:
|
||||
existing = db.query(NewsCategory).filter(NewsCategory.slug == data.slug).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="栏目标识已存在")
|
||||
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(category, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(category)
|
||||
return category.to_dict()
|
||||
|
||||
|
||||
@router.delete("/categories/{category_id}")
|
||||
def delete_category(
|
||||
category_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin)
|
||||
):
|
||||
"""删除栏目"""
|
||||
category = db.query(NewsCategory).filter(NewsCategory.id == category_id).first()
|
||||
if not category:
|
||||
raise HTTPException(status_code=404, detail="栏目不存在")
|
||||
|
||||
# 检查是否有文章使用此栏目
|
||||
post_count = db.query(NewsPost).filter(NewsPost.category_id == category_id).count()
|
||||
if post_count > 0:
|
||||
raise HTTPException(status_code=400, detail="该栏目下还有文章,无法删除")
|
||||
|
||||
db.delete(category)
|
||||
db.commit()
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
# ========== 资讯管理 API ==========
|
||||
|
||||
@router.get("/posts")
|
||||
def list_posts(
|
||||
page: int = Query(default=1, ge=1),
|
||||
size: int = Query(default=10, ge=1, le=100),
|
||||
category_id: Optional[int] = Query(default=None),
|
||||
keyword: Optional[str] = Query(default=None),
|
||||
status: Optional[str] = Query(default=None, pattern="^(published|draft|all)$"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin)
|
||||
):
|
||||
"""获取资讯列表"""
|
||||
query = db.query(NewsPost).options(joinedload(NewsPost.category))
|
||||
|
||||
# 筛选条件
|
||||
if category_id:
|
||||
query = query.filter(NewsPost.category_id == category_id)
|
||||
|
||||
if keyword:
|
||||
query = query.filter(NewsPost.title.contains(keyword))
|
||||
|
||||
if status == "published":
|
||||
query = query.filter(NewsPost.is_published == True)
|
||||
elif status == "draft":
|
||||
query = query.filter(NewsPost.is_draft == True)
|
||||
|
||||
# 排序:先按创建时间倒序
|
||||
query = query.order_by(NewsPost.created_at.desc())
|
||||
|
||||
# 分页
|
||||
total = query.count()
|
||||
posts = query.offset((page - 1) * size).limit(size).all()
|
||||
|
||||
return {
|
||||
"items": [post.to_dict(include_content=False) for post in posts],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size
|
||||
}
|
||||
|
||||
|
||||
@router.post("/posts")
|
||||
def create_post(
|
||||
data: PostCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin)
|
||||
):
|
||||
"""创建资讯"""
|
||||
# 自动生成 slug(如果未提供)
|
||||
slug = data.slug or generate_slug(data.title)
|
||||
|
||||
# 检查 slug 是否已存在
|
||||
existing = db.query(NewsPost).filter(NewsPost.slug == slug).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="文章标识已存在")
|
||||
|
||||
# 检查栏目是否存在
|
||||
category = db.query(NewsCategory).filter(NewsCategory.id == data.category_id).first()
|
||||
if not category:
|
||||
raise HTTPException(status_code=404, detail="栏目不存在")
|
||||
|
||||
# 自动生成摘要(如果未提供)
|
||||
summary = data.summary
|
||||
if not summary:
|
||||
summary = extract_summary(data.content, max_length=100)
|
||||
|
||||
# 迁移临时图片到正式目录
|
||||
migrated_content, migrated_cover = migrate_temp_images(data.content, data.cover_image, db)
|
||||
|
||||
post = NewsPost(
|
||||
title=data.title,
|
||||
slug=slug,
|
||||
content=migrated_content,
|
||||
content_type=data.content_type,
|
||||
summary=summary,
|
||||
cover_image=migrated_cover,
|
||||
category_id=data.category_id,
|
||||
is_draft=data.is_draft,
|
||||
is_published=not data.is_draft
|
||||
)
|
||||
|
||||
db.add(post)
|
||||
db.commit()
|
||||
db.refresh(post)
|
||||
return post.to_dict(include_content=True)
|
||||
|
||||
|
||||
@router.get("/posts/{post_id}")
|
||||
def get_post(
|
||||
post_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin)
|
||||
):
|
||||
"""获取资讯详情"""
|
||||
post = db.query(NewsPost).options(joinedload(NewsPost.category)).filter(NewsPost.id == post_id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="文章不存在")
|
||||
return post.to_dict(include_content=True)
|
||||
|
||||
|
||||
@router.put("/posts/{post_id}")
|
||||
def update_post(
|
||||
post_id: int,
|
||||
data: PostUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin)
|
||||
):
|
||||
"""更新资讯"""
|
||||
post = db.query(NewsPost).filter(NewsPost.id == post_id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="文章不存在")
|
||||
|
||||
# 检查 slug 是否冲突(如果提供了新的 slug)
|
||||
slug = data.slug
|
||||
if slug is not None:
|
||||
if not slug.strip():
|
||||
slug = generate_slug(data.title or post.title)
|
||||
if slug != post.slug:
|
||||
existing = db.query(NewsPost).filter(NewsPost.slug == slug).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="文章标识已存在")
|
||||
|
||||
# 检查栏目是否存在
|
||||
if data.category_id:
|
||||
category = db.query(NewsCategory).filter(NewsCategory.id == data.category_id).first()
|
||||
if not category:
|
||||
raise HTTPException(status_code=404, detail="栏目不存在")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if slug is not None:
|
||||
update_data['slug'] = slug
|
||||
|
||||
# 如果摘要被清空,自动从内容重新提取
|
||||
if "summary" in update_data and not update_data["summary"].strip():
|
||||
content_to_extract = update_data.get("content") or post.content
|
||||
if content_to_extract:
|
||||
update_data["summary"] = extract_summary(content_to_extract, max_length=100)
|
||||
|
||||
# 处理草稿/发布状态联动
|
||||
if "is_draft" in update_data:
|
||||
update_data["is_published"] = not update_data["is_draft"]
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(post, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(post)
|
||||
return post.to_dict(include_content=True)
|
||||
|
||||
|
||||
@router.delete("/posts/{post_id}")
|
||||
def delete_post(
|
||||
post_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin)
|
||||
):
|
||||
"""删除资讯"""
|
||||
post = db.query(NewsPost).filter(NewsPost.id == post_id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="文章不存在")
|
||||
|
||||
# 删除封面图及其缩略图
|
||||
if post.cover_image:
|
||||
cleanup_file_by_path(post.cover_image)
|
||||
|
||||
db.delete(post)
|
||||
db.commit()
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
@router.post("/posts/{post_id}/publish")
|
||||
def publish_post(
|
||||
post_id: int,
|
||||
data: PostPublish,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin=Depends(get_current_admin)
|
||||
):
|
||||
"""发布/取消发布资讯"""
|
||||
post = db.query(NewsPost).filter(NewsPost.id == post_id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="文章不存在")
|
||||
|
||||
post.is_published = data.is_published
|
||||
post.is_draft = not data.is_published
|
||||
|
||||
db.commit()
|
||||
db.refresh(post)
|
||||
return post.to_dict()
|
||||
|
||||
|
||||
# ========== 访客端 API(无需认证)==========
|
||||
|
||||
@router.get("/public/posts")
|
||||
def list_public_posts(
|
||||
page: int = Query(default=1, ge=1),
|
||||
size: int = Query(default=10, ge=1, le=50),
|
||||
category_slug: Optional[str] = Query(default=None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""访客端:获取已发布的资讯列表(仅显示 is_enabled=True 栏目下的文章)"""
|
||||
query = db.query(NewsPost).options(joinedload(NewsPost.category)).join(
|
||||
NewsCategory, NewsPost.category_id == NewsCategory.id
|
||||
).filter(
|
||||
NewsPost.is_published == True,
|
||||
NewsCategory.is_enabled == True
|
||||
)
|
||||
|
||||
# 按栏目筛选
|
||||
if category_slug:
|
||||
category = db.query(NewsCategory).filter(NewsCategory.slug == category_slug).first()
|
||||
if category:
|
||||
query = query.filter(NewsPost.category_id == category.id)
|
||||
|
||||
query = query.order_by(NewsPost.created_at.desc())
|
||||
|
||||
total = query.count()
|
||||
posts = query.offset((page - 1) * size).limit(size).all()
|
||||
|
||||
return {
|
||||
"items": [post.to_dict(include_content=False) for post in posts],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size
|
||||
}
|
||||
|
||||
|
||||
@router.get("/public/posts/{slug}")
|
||||
def get_public_post(
|
||||
slug: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""访客端:通过 slug 获取文章详情"""
|
||||
post = db.query(NewsPost).options(joinedload(NewsPost.category)).filter(
|
||||
NewsPost.slug == slug,
|
||||
NewsPost.is_published == True
|
||||
).first()
|
||||
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="文章不存在")
|
||||
|
||||
# 增加浏览次数
|
||||
post.view_count += 1
|
||||
db.commit()
|
||||
|
||||
return post.to_dict(include_content=True)
|
||||
|
||||
|
||||
@router.get("/public/categories")
|
||||
def list_public_categories(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""访客端:获取启用的栏目列表"""
|
||||
categories = db.query(NewsCategory).filter(
|
||||
NewsCategory.is_enabled == True
|
||||
).order_by(NewsCategory.sort_order.asc()).all()
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"items": [cat.to_dict() for cat in categories],
|
||||
"total": len(categories)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
# app/routers/settings.py
|
||||
from typing import Dict
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.setting import Setting
|
||||
from app.models.admin import Admin
|
||||
from app.auth import get_current_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Public settings keys that can be accessed without auth
|
||||
PUBLIC_SETTINGS = [
|
||||
"site_name", "site_logo", "site_favicon", "site_description",
|
||||
"contact_phones", "contact_emails", "contact_address",
|
||||
"social_links", "friend_links",
|
||||
"copyright", "bottom_links",
|
||||
"privacy_url", "terms_url", "sitemap_url",
|
||||
"seo_title", "seo_description", "seo_keywords"
|
||||
]
|
||||
|
||||
|
||||
# Schemas
|
||||
class SettingsUpdate(BaseModel):
|
||||
settings: Dict[str, str]
|
||||
|
||||
|
||||
class SettingsResponse(BaseModel):
|
||||
code: int = 200
|
||||
data: dict = None
|
||||
message: str = "success"
|
||||
|
||||
|
||||
# Helper
|
||||
def get_setting_value(db: Session, key: str) -> str:
|
||||
setting = db.query(Setting).filter(Setting.key == key).first()
|
||||
return setting.value if setting else None
|
||||
|
||||
|
||||
def set_setting_value(db: Session, key: str, value: str):
|
||||
setting = db.query(Setting).filter(Setting.key == key).first()
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
setting = Setting(key=key, value=value)
|
||||
db.add(setting)
|
||||
|
||||
|
||||
# Public routes
|
||||
@router.get("/settings", response_model=SettingsResponse)
|
||||
def get_public_settings(db: Session = Depends(get_db)):
|
||||
"""Get public settings"""
|
||||
result = {}
|
||||
for key in PUBLIC_SETTINGS:
|
||||
value = get_setting_value(db, key)
|
||||
if value:
|
||||
result[key] = value
|
||||
return SettingsResponse(data=result)
|
||||
|
||||
|
||||
# Admin routes
|
||||
@router.get("/admin/settings", response_model=SettingsResponse)
|
||||
def get_all_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Get all settings"""
|
||||
settings = db.query(Setting).all()
|
||||
result = {}
|
||||
for s in settings:
|
||||
result[s.key] = {
|
||||
"value": s.value,
|
||||
"group": s.group,
|
||||
"description": s.description
|
||||
}
|
||||
return SettingsResponse(data=result)
|
||||
|
||||
|
||||
@router.put("/admin/settings", response_model=SettingsResponse)
|
||||
def update_settings(
|
||||
request: SettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: Admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Batch update settings"""
|
||||
for key, value in request.settings.items():
|
||||
set_setting_value(db, key, value)
|
||||
|
||||
db.commit()
|
||||
return SettingsResponse(message="设置更新成功")
|
||||
@@ -0,0 +1,122 @@
|
||||
# app/routers/upload.py
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from PIL import Image as PILImage
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Form
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.admin import Admin
|
||||
from app.auth import get_current_admin
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
|
||||
# Allowed file types (MIME)
|
||||
ALLOWED_IMAGE_TYPES = {
|
||||
"image/jpeg", "image/png", "image/gif", "image/webp",
|
||||
"image/x-icon", "image/vnd.microsoft.icon", # .ico favicon
|
||||
"image/svg+xml", # .svg
|
||||
}
|
||||
# Allowed extensions (fallback when browser reports wrong MIME)
|
||||
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".ico", ".svg"}
|
||||
MAX_FILE_SIZE = settings.MAX_UPLOAD_SIZE # 5MB
|
||||
|
||||
|
||||
# Schemas
|
||||
class UploadResponse(BaseModel):
|
||||
code: int = 200
|
||||
data: dict = None
|
||||
message: str = "success"
|
||||
|
||||
|
||||
def create_thumbnail(image_path: Path, thumb_path: Path, max_size: int = 300):
|
||||
"""创建缩略图"""
|
||||
try:
|
||||
with PILImage.open(image_path) as img:
|
||||
# 转换为 RGB(处理透明图片)
|
||||
if img.mode in ('RGBA', 'P'):
|
||||
img = img.convert('RGB')
|
||||
|
||||
# 等比缩放到最大尺寸
|
||||
img.thumbnail((max_size, max_size), PILImage.Resampling.LANCZOS)
|
||||
|
||||
# 保存
|
||||
img.save(thumb_path, "JPEG", quality=85)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"创建缩略图失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@router.post("/upload/image", response_model=UploadResponse)
|
||||
async def upload_image(
|
||||
file: UploadFile = File(...),
|
||||
subdir: str = Form(default=""),
|
||||
current_admin = Depends(get_current_admin)
|
||||
):
|
||||
"""Upload image file - 支持按子目录存储"""
|
||||
|
||||
# Check file type - allow by MIME or by extension (browsers may report wrong MIME for .ico)
|
||||
file_ext = os.path.splitext(file.filename or "")[1].lower()
|
||||
if file.content_type not in ALLOWED_IMAGE_TYPES and file_ext not in ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"不支持的文件类型: {file.content_type} ({file_ext})"
|
||||
)
|
||||
|
||||
# Check file size
|
||||
content = await file.read()
|
||||
if len(content) > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"文件大小超过限制: {MAX_FILE_SIZE / 1024 / 1024}MB"
|
||||
)
|
||||
|
||||
# Generate unique filename with timestamp
|
||||
ext = os.path.splitext(file.filename)[1].lower()
|
||||
if not ext:
|
||||
ext = ".jpg" if file.content_type == "image/jpeg" else ".png"
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{timestamp}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
|
||||
# 根据子目录存储
|
||||
upload_path = Path(settings.UPLOAD_DIR)
|
||||
if subdir:
|
||||
upload_path = upload_path / subdir
|
||||
upload_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 缩略图目录(始终放在 thumbnails 下)
|
||||
thumbnail_dir = Path(settings.UPLOAD_DIR) / "thumbnails"
|
||||
thumbnail_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Save file
|
||||
file_path = upload_path / filename
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# 创建缩略图(.ico 和 .svg 跳过缩略图生成)
|
||||
thumbnail_name = f"{Path(filename).stem}.jpg"
|
||||
thumb_path = thumbnail_dir / thumbnail_name
|
||||
thumb_relative_path = None
|
||||
if ext not in ('.ico', '.svg') and create_thumbnail(file_path, thumb_path):
|
||||
thumb_relative_path = f"/storage/admin/images/thumbnails/{thumbnail_name}"
|
||||
|
||||
# Return URL
|
||||
if subdir:
|
||||
url = f"/storage/admin/images/{subdir}/{filename}"
|
||||
else:
|
||||
url = f"/storage/admin/images/{filename}"
|
||||
|
||||
return UploadResponse(
|
||||
message="上传成功",
|
||||
data={
|
||||
"url": url,
|
||||
"filename": filename,
|
||||
"size": len(content),
|
||||
"thumb_url": thumb_relative_path
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,530 @@
|
||||
"""
|
||||
微信公众号文章采集路由
|
||||
使用 Playwright + Stealth 采集微信公众号文章内容
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
import json
|
||||
import asyncio
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
from bs4 import BeautifulSoup, NavigableString
|
||||
|
||||
# 异步 Playwright 支持
|
||||
try:
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from playwright.async_api import async_playwright
|
||||
PLAYWRIGHT_AVAILABLE = True
|
||||
except ImportError:
|
||||
PLAYWRIGHT_AVAILABLE = False
|
||||
|
||||
try:
|
||||
import markdownify
|
||||
MARKDOWNIFY_AVAILABLE = True
|
||||
except ImportError:
|
||||
MARKDOWNIFY_AVAILABLE = False
|
||||
|
||||
# 尝试导入 Stealth 插件
|
||||
try:
|
||||
from playwright_stealth import stealth_async
|
||||
STEALTH_AVAILABLE = True
|
||||
except ImportError:
|
||||
STEALTH_AVAILABLE = False
|
||||
|
||||
from app.database import SessionLocal, get_db
|
||||
from app.models.article import Article
|
||||
from app.models.column import ColumnModel
|
||||
from app.models.image import Image
|
||||
from app.routers.auth import get_current_admin
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
router = APIRouter(tags=["公众号采集"])
|
||||
|
||||
# ========== Pydantic 模型 ==========
|
||||
|
||||
class WechatPreviewRequest(BaseModel):
|
||||
url: str = Field(..., min_length=1, description="微信公众号文章URL(支持 mp.weixin.qq.com 和 weixin.sogou.com)")
|
||||
|
||||
class WechatPreviewResponse(BaseModel):
|
||||
code: int = 200
|
||||
message: str = "success"
|
||||
data: Optional[dict] = None
|
||||
|
||||
class WechatSaveRequest(BaseModel):
|
||||
url: str = Field(..., description="原始URL")
|
||||
title: str = Field(..., min_length=1, max_length=200, description="标题")
|
||||
author: Optional[str] = Field(None, max_length=50, description="作者")
|
||||
summary: Optional[str] = Field(None, max_length=500, description="摘要")
|
||||
cover_image: Optional[str] = Field(None, description="封面图片URL")
|
||||
content: str = Field(..., description="Markdown内容")
|
||||
images: List[str] = Field(default=[], description="文章中的图片URL列表")
|
||||
column_id: int = Field(..., description="所属栏目ID")
|
||||
source_url: Optional[str] = Field(None, description="来源URL(用于去重)")
|
||||
|
||||
class WechatSaveResponse(BaseModel):
|
||||
code: int = 200
|
||||
message: str = "success"
|
||||
data: Optional[dict] = None
|
||||
|
||||
# ========== 核心工具函数 ==========
|
||||
|
||||
def fix_wechat_html(html: str) -> str:
|
||||
"""修复微信公众号文章的HTML"""
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
# 修复图片:微信用 data-src 存真实URL,src 常为占位图
|
||||
for img in soup.find_all('img'):
|
||||
data_src = img.get('data-src', '')
|
||||
src = img.get('src', '')
|
||||
# 只要 data-src 是真实http图片URL,且 src 不是有效http图片,就替换
|
||||
if data_src and data_src.startswith('http') and (
|
||||
not src or src.startswith('data:image') or not src.startswith('http')
|
||||
):
|
||||
img['src'] = data_src
|
||||
if 'data-src' in img.attrs:
|
||||
del img['data-src']
|
||||
|
||||
# 修复微信代码块:<pre class="code-snippet__js"> 内多个 span 行
|
||||
for pre in soup.find_all('pre', class_=re.compile('code-snippet')):
|
||||
code_tag = pre.find('code')
|
||||
if code_tag:
|
||||
lines = []
|
||||
for span in code_tag.find_all('span', class_='code-snippet__line'):
|
||||
line_text = span.get_text(separator='', strip=False)
|
||||
if line_text:
|
||||
lines.append(line_text)
|
||||
if lines:
|
||||
code_tag.clear()
|
||||
for line in lines:
|
||||
code_tag.append(NavigableString(line + '\n'))
|
||||
|
||||
# 移除空标签(保留包含图片的标签)
|
||||
for tag in soup.find_all(['p', 'div', 'section']):
|
||||
if tag.get_text(strip=True) == '' and not tag.find_all('img'):
|
||||
tag.decompose()
|
||||
|
||||
return str(soup)
|
||||
|
||||
|
||||
def html_to_markdown(html: str) -> str:
|
||||
"""将微信HTML转换为Markdown"""
|
||||
if not MARKDOWNIFY_AVAILABLE:
|
||||
return html
|
||||
try:
|
||||
md = markdownify.markdownify(
|
||||
html,
|
||||
heading_style="ATX",
|
||||
bullets="-",
|
||||
strip=["script", "style"],
|
||||
code_language="",
|
||||
)
|
||||
# 清理多余空行
|
||||
md = re.sub(r'\n{3,}', '\n\n', md)
|
||||
return md.strip()
|
||||
except Exception:
|
||||
return html
|
||||
|
||||
|
||||
async def crawl_wechat_article(url: str) -> dict:
|
||||
"""使用 Playwright 采集微信公众号文章"""
|
||||
if not PLAYWRIGHT_AVAILABLE:
|
||||
raise HTTPException(status_code=400, detail="Playwright 未安装,无法采集公众号文章")
|
||||
|
||||
result = {"title": "", "content": "", "author": "", "summary": ""}
|
||||
result["images"] = []
|
||||
result["cover_image"] = ""
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(
|
||||
headless=True,
|
||||
args=[
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--disable-web-security",
|
||||
"--disable-features=IsolateOrigins,site-per-process",
|
||||
],
|
||||
)
|
||||
context = await browser.new_context(
|
||||
viewport={"width": 1920, "height": 1080},
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/120.0.0.0 Safari/537.36"
|
||||
),
|
||||
locale="zh-CN",
|
||||
)
|
||||
|
||||
# 尝试应用 Stealth
|
||||
try:
|
||||
page = await context.new_page()
|
||||
if STEALTH_AVAILABLE:
|
||||
await stealth_async(page)
|
||||
except Exception:
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
# 搜狗链接需要先激活会话(搜索一次获取 cookies),否则会被反爬拦截
|
||||
is_sogou = "weixin.sogou.com" in url
|
||||
if is_sogou:
|
||||
await page.goto(
|
||||
"https://weixin.sogou.com/weixin?type=2&query=test",
|
||||
wait_until="networkidle", timeout=30000,
|
||||
)
|
||||
await asyncio.sleep(2)
|
||||
|
||||
await page.goto(url, wait_until="networkidle", timeout=30000)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# 微信文章常有的弹窗
|
||||
try:
|
||||
await page.wait_for_selector("#js_pc_qr_code_img", timeout=5000)
|
||||
# 有二维码,尝试取消
|
||||
close_btn = await page.query_selector(".js_pc_qr_code_close_btn, .weui-desktop-media__tips-close")
|
||||
if close_btn:
|
||||
await close_btn.click()
|
||||
await asyncio.sleep(2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 获取页面标题
|
||||
title = await page.title()
|
||||
if title:
|
||||
title = title.strip()
|
||||
# 处理微信的 "页面标题 - 文章" 格式
|
||||
if " - " in title:
|
||||
title = title.split(" - ")[0]
|
||||
result["title"] = title
|
||||
|
||||
# 尝试获取 HTML 内容
|
||||
html = await page.content()
|
||||
|
||||
# 提取微信公众号的正文
|
||||
content_html = ""
|
||||
# 新版微信文章
|
||||
content_elem = await page.query_selector("#js_content")
|
||||
if not content_elem:
|
||||
content_elem = await page.query_selector(".rich_media_content")
|
||||
if not content_elem:
|
||||
content_elem = await page.query_selector("#js_article")
|
||||
if not content_elem:
|
||||
# 通用:取 body 内容
|
||||
body = await page.query_selector("body")
|
||||
if body:
|
||||
content_html = await body.inner_html()
|
||||
else:
|
||||
content_html = await content_elem.inner_html()
|
||||
|
||||
if content_html:
|
||||
content_html = fix_wechat_html(content_html)
|
||||
result["content"] = html_to_markdown(content_html)
|
||||
result["raw_html"] = content_html
|
||||
|
||||
# 提取作者
|
||||
try:
|
||||
author_elem = await page.query_selector(
|
||||
"#js_name, .rich_media_meta_nickname, .profile_nickname, .rich_media_meta_text"
|
||||
)
|
||||
if not author_elem:
|
||||
author_elem = await page.query_selector(
|
||||
# 备用选择器
|
||||
"[id$='_name'], [class*='_name'], .wx_article_author"
|
||||
)
|
||||
if author_elem:
|
||||
author = await author_elem.inner_text()
|
||||
result["author"] = author.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 提取摘要/描述
|
||||
try:
|
||||
desc_elem = await page.query_selector(
|
||||
"#js_description, .rich_media_meta_desc, [class*='_desc'], meta[name='description']"
|
||||
)
|
||||
if desc_elem:
|
||||
desc = await desc_elem.inner_text()
|
||||
result["summary"] = desc.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 提取封面图片
|
||||
try:
|
||||
# 尝试从 meta 标签获取
|
||||
cover_meta = await page.query_selector('meta[property="og:image"]')
|
||||
if cover_meta:
|
||||
cover_url = await cover_meta.get_attribute("content")
|
||||
if cover_url:
|
||||
result["cover_image"] = cover_url
|
||||
if not result["cover_image"]:
|
||||
cover_img = await page.query_selector(
|
||||
"#js_cover, .rich_media_cover img, .cover_img, img.cover"
|
||||
)
|
||||
if cover_img:
|
||||
cover_url = await cover_img.get_attribute("src") or await cover_img.get_attribute("data-src")
|
||||
if cover_url:
|
||||
result["cover_image"] = cover_url
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 提取文章中所有图片
|
||||
try:
|
||||
img_tags = await page.query_selector_all("#js_content img, .rich_media_content img")
|
||||
img_urls = set()
|
||||
for img in img_tags:
|
||||
src = await img.get_attribute("src") or await img.get_attribute("data-src")
|
||||
if src and ("mmbiz.qpic.cn" in src or src.startswith("http")):
|
||||
img_urls.add(src)
|
||||
result["images"] = list(img_urls)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await browser.close()
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
await browser.close()
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"微信文章采集失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def download_image(url: str, save_dir: str) -> Optional[str]:
|
||||
"""下载图片到本地"""
|
||||
try:
|
||||
if not url or not url.startswith("http"):
|
||||
return url
|
||||
|
||||
# 生成唯一文件名
|
||||
ext = ".jpg"
|
||||
url_lower = url.lower()
|
||||
if ".png" in url_lower:
|
||||
ext = ".png"
|
||||
elif ".gif" in url_lower:
|
||||
ext = ".gif"
|
||||
elif ".webp" in url_lower:
|
||||
ext = ".webp"
|
||||
elif ".jpeg" in url_lower or ".jpg" in url_lower:
|
||||
ext = ".jpg"
|
||||
|
||||
filename = f"{uuid.uuid4().hex}{ext}"
|
||||
filepath = os.path.join(save_dir, filename)
|
||||
|
||||
# 创建目录
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
|
||||
# 下载
|
||||
headers = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/120.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Referer": "https://mp.weixin.qq.com/",
|
||||
}
|
||||
resp = requests.get(url, headers=headers, timeout=30, stream=True)
|
||||
if resp.status_code == 200:
|
||||
with open(filepath, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
return filename
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"下载图片失败 {url}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ========== API 端点 ==========
|
||||
|
||||
@router.post("/wechat-crawl/preview", response_model=WechatPreviewResponse)
|
||||
async def wechat_preview(
|
||||
request: WechatPreviewRequest,
|
||||
admin: dict = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""预览微信公众号文章(支持 mp.weixin.qq.com 和 weixin.sogou.com 链接)"""
|
||||
if not (request.url.startswith("https://mp.weixin.qq.com/") or
|
||||
request.url.startswith("https://weixin.sogou.com/")):
|
||||
raise HTTPException(status_code=400, detail="请输入正确的微信公众号文章链接")
|
||||
|
||||
result = await crawl_wechat_article(request.url)
|
||||
|
||||
# 生成摘要(取内容前200个字符)
|
||||
if not result.get("summary") and result.get("content"):
|
||||
content_text = re.sub(r'[#*`\[\]()>\-\n]', '', result["content"])
|
||||
result["summary"] = content_text[:200].strip()
|
||||
|
||||
# 获取栏目列表供前端选择
|
||||
columns = db.query(ColumnModel).filter(
|
||||
ColumnModel.is_enabled == True
|
||||
).order_by(ColumnModel.sort_order).all()
|
||||
|
||||
return WechatPreviewResponse(
|
||||
code=200,
|
||||
message="success",
|
||||
data={
|
||||
"title": result.get("title", ""),
|
||||
"author": result.get("author", ""),
|
||||
"summary": result.get("summary", ""),
|
||||
"cover_image": result.get("cover_image", ""),
|
||||
"content": result.get("content", ""),
|
||||
"images": result.get("images", []),
|
||||
"columns": [c.to_dict() for c in columns],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/wechat-crawl/save", response_model=WechatSaveResponse)
|
||||
async def wechat_save(
|
||||
request: WechatSaveRequest,
|
||||
admin: dict = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""保存采集的微信公众号文章"""
|
||||
# 验证栏目
|
||||
column = db.query(ColumnModel).filter(
|
||||
ColumnModel.id == request.column_id,
|
||||
ColumnModel.is_enabled == True
|
||||
).first()
|
||||
if not column:
|
||||
raise HTTPException(status_code=400, detail="所选栏目不存在或已禁用")
|
||||
|
||||
# 准备图片存储目录(与 crawl 模块保持一致,使用项目根 storage/admin/images/YYYYMM)
|
||||
settings_obj = get_settings()
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
upload_dir = os.path.join(base_dir, settings_obj.UPLOAD_DIR)
|
||||
# 按年月创建子目录
|
||||
year_month = datetime.now().strftime("%Y%m")
|
||||
upload_dir = os.path.join(upload_dir, year_month)
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
# 下载封面图
|
||||
local_cover = None
|
||||
if request.cover_image:
|
||||
filename = download_image(request.cover_image, upload_dir)
|
||||
if filename:
|
||||
local_cover = f"/storage/admin/images/{year_month}/{filename}"
|
||||
# 添加到 Image 表
|
||||
try:
|
||||
img = Image(
|
||||
filename=filename,
|
||||
original_name=filename,
|
||||
file_path=f"/storage/admin/images/{year_month}/{filename}",
|
||||
module="wechat",
|
||||
)
|
||||
db.add(img)
|
||||
db.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 下载内容中的图片并替换URL
|
||||
content = request.content
|
||||
image_urls_to_download = set()
|
||||
|
||||
# 从 markdown 中找到所有图片 URL
|
||||
img_pattern = re.compile(r'!\[.*?\]\((https?://[^\s)]+)\)')
|
||||
for match in img_pattern.finditer(content):
|
||||
img_url = match.group(1)
|
||||
image_urls_to_download.add(img_url)
|
||||
|
||||
# 也包括请求中提供的图片列表
|
||||
for img_url in request.images:
|
||||
if img_url.startswith("http"):
|
||||
image_urls_to_download.add(img_url)
|
||||
|
||||
# 下载并替换
|
||||
url_map = {}
|
||||
for img_url in image_urls_to_download:
|
||||
filename = download_image(img_url, upload_dir)
|
||||
if filename:
|
||||
local_path = f"/storage/admin/images/{year_month}/{filename}"
|
||||
url_map[img_url] = local_path
|
||||
# 添加到 Image 表
|
||||
try:
|
||||
img = Image(
|
||||
filename=filename,
|
||||
original_name=filename,
|
||||
file_path=f"/storage/admin/images/{year_month}/{filename}",
|
||||
module="wechat",
|
||||
)
|
||||
db.add(img)
|
||||
db.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 替换 Markdown 中的图片URL
|
||||
for old_url, local_path in url_map.items():
|
||||
content = content.replace(
|
||||
f"]({old_url})",
|
||||
f"]({local_path})"
|
||||
)
|
||||
|
||||
# 生成 slug
|
||||
def generate_slug(text: str) -> str:
|
||||
try:
|
||||
from pypinyin import lazy_pinyin
|
||||
py = lazy_pinyin(text, style=0)
|
||||
slug = "-".join(py)
|
||||
slug = re.sub(r"[^a-zA-Z0-9-]", "", slug).lower()
|
||||
slug = re.sub(r"-+", "-", slug).strip("-")
|
||||
return slug or f"article-{int(datetime.now().timestamp())}"
|
||||
except ImportError:
|
||||
import hashlib
|
||||
return hashlib.md5(text.encode()).hexdigest()[:12]
|
||||
|
||||
slug = generate_slug(request.title)
|
||||
# 检查 slug 唯一性
|
||||
existing = db.query(Article).filter(Article.slug == slug).first()
|
||||
if existing:
|
||||
slug = f"{slug}-{int(datetime.now().timestamp())}"
|
||||
|
||||
# 创建文章
|
||||
article = Article(
|
||||
title=request.title,
|
||||
slug=slug,
|
||||
content=content,
|
||||
content_type="markdown",
|
||||
summary=request.summary or "",
|
||||
cover_image=local_cover,
|
||||
column_id=request.column_id,
|
||||
author=request.author or "",
|
||||
status="draft",
|
||||
source_url=request.source_url or request.url,
|
||||
)
|
||||
db.add(article)
|
||||
db.commit()
|
||||
db.refresh(article)
|
||||
|
||||
return WechatSaveResponse(
|
||||
code=200,
|
||||
message="公众号文章采集并保存成功",
|
||||
data=article.to_dict(include_content=True)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/wechat-crawl/columns")
|
||||
async def get_columns(
|
||||
admin: dict = Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取栏目列表(供前端下拉选择)"""
|
||||
columns = db.query(ColumnModel).filter(
|
||||
ColumnModel.is_enabled == True
|
||||
).order_by(ColumnModel.sort_order).all()
|
||||
return {
|
||||
"code": 200,
|
||||
"data": [c.to_dict() for c in columns]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Utils package
|
||||
from .file_utils import cleanup_file, cleanup_file_by_path
|
||||
|
||||
__all__ = ["cleanup_file", "cleanup_file_by_path"]
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
文件工具函数
|
||||
提供统一的文件删除功能,自动处理缩略图同步删除
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# 文件系统路径(从配置读取)
|
||||
UPLOAD_DIR = Path(settings.UPLOAD_DIR)
|
||||
THUMBNAIL_DIR = UPLOAD_DIR / "thumbnails"
|
||||
|
||||
# URL 路径
|
||||
STORAGE_URL = "/storage/admin/images"
|
||||
THUMBNAIL_URL = f"{STORAGE_URL}/thumbnails"
|
||||
|
||||
|
||||
def cleanup_file(file_path: str | Path) -> bool:
|
||||
"""
|
||||
删除文件并同步删除对应的缩略图
|
||||
|
||||
Args:
|
||||
file_path: 文件路径(相对 /uploads 的路径、/storage/admin/images/ 格式或绝对路径)
|
||||
|
||||
Returns:
|
||||
bool: 是否成功删除原文件
|
||||
"""
|
||||
if not file_path:
|
||||
return False
|
||||
|
||||
# 统一处理为 Path 对象
|
||||
if isinstance(file_path, str):
|
||||
# 处理新的存储路径格式 /storage/admin/images/...
|
||||
if file_path.startswith("/storage/admin/images/"):
|
||||
# 提取相对路径部分
|
||||
relative_path = file_path[len("/storage/admin/images/"):]
|
||||
full_path = UPLOAD_DIR / relative_path
|
||||
else:
|
||||
# 移除开头的 / 并构建完整路径(兼容旧格式)
|
||||
relative_path = file_path.lstrip("/")
|
||||
full_path = UPLOAD_DIR / relative_path
|
||||
else:
|
||||
full_path = file_path
|
||||
|
||||
# 删除原文件
|
||||
deleted = False
|
||||
try:
|
||||
if full_path.exists():
|
||||
full_path.unlink()
|
||||
deleted = True
|
||||
print(f"[cleanup] 已删除文件: {full_path}")
|
||||
except Exception as e:
|
||||
print(f"[cleanup] 删除文件失败 {full_path}: {e}")
|
||||
|
||||
# 同步删除缩略图
|
||||
# 缩略图与原图同名,但扩展名统一为 .jpg,存放在 /uploads/thumbnails/
|
||||
try:
|
||||
thumbnail_name = f"{full_path.stem}.jpg"
|
||||
thumb_path = THUMBNAIL_DIR / thumbnail_name
|
||||
if thumb_path.exists():
|
||||
thumb_path.unlink()
|
||||
print(f"[cleanup] 已删除缩略图: {thumb_path}")
|
||||
except Exception as e:
|
||||
print(f"[cleanup] 删除缩略图失败: {e}")
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
def cleanup_file_by_path(relative_path: str) -> bool:
|
||||
"""
|
||||
根据相对路径删除文件及其缩略图
|
||||
|
||||
Args:
|
||||
relative_path: 相对于 /uploads 的路径,如 "20250420_xxx.jpg"
|
||||
或者完整的存储路径如 "/storage/admin/images/xxx.jpg"
|
||||
|
||||
Returns:
|
||||
bool: 是否成功删除原文件
|
||||
"""
|
||||
if not relative_path:
|
||||
return False
|
||||
|
||||
# 排除外部 URL
|
||||
if relative_path.startswith(("http://", "https://")):
|
||||
print(f"[cleanup] 跳过外部 URL: {relative_path}")
|
||||
return False
|
||||
|
||||
# 处理新的存储路径格式 /storage/admin/images/...
|
||||
if relative_path.startswith("/storage/admin/images/"):
|
||||
# 提取相对路径部分
|
||||
rel_path = relative_path[len("/storage/admin/images/"):]
|
||||
full_path = UPLOAD_DIR / rel_path
|
||||
else:
|
||||
# 处理可能包含开头 / 的路径(兼容旧格式)
|
||||
rel_path = relative_path.lstrip("/")
|
||||
full_path = UPLOAD_DIR / rel_path
|
||||
|
||||
return cleanup_file(full_path)
|
||||
@@ -0,0 +1,543 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
批量采集 niaodaifu.cn 健康资讯文章
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import hashlib
|
||||
import requests
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from playwright.sync_api import sync_playwright
|
||||
from bs4 import BeautifulSoup, NavigableString
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# ============ 配置 ============
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
UPLOAD_DIR = os.path.join(BASE_DIR, "..", "storage", "admin", "images")
|
||||
DB_PATH = os.path.join(BASE_DIR, "cms.db")
|
||||
|
||||
MENU_TO_CATEGORY = {
|
||||
15: (2, "科普文章"),
|
||||
7: (3, "肾友"),
|
||||
8: (4, "糖友"),
|
||||
6: (5, "健康"),
|
||||
}
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
}
|
||||
|
||||
|
||||
# ============ 工具函数 ============
|
||||
def slugify(title):
|
||||
"""生成 URL 友好的 slug"""
|
||||
import unicodedata
|
||||
slug = unicodedata.normalize('NFKC', title)
|
||||
slug = re.sub(r'[^\w\s-]', '', slug).strip().lower()
|
||||
slug = re.sub(r'[-\s]+', '-', slug)
|
||||
if not slug:
|
||||
slug = 'article'
|
||||
return slug
|
||||
|
||||
|
||||
def ensure_unique_slug(conn, slug):
|
||||
"""确保 slug 唯一"""
|
||||
cursor = conn.cursor()
|
||||
original = slug
|
||||
suffix = 1
|
||||
while True:
|
||||
cursor.execute('SELECT 1 FROM news_posts WHERE slug = ?', (slug,))
|
||||
if not cursor.fetchone():
|
||||
break
|
||||
slug = f"{original}-{suffix}"
|
||||
suffix += 1
|
||||
return slug
|
||||
|
||||
|
||||
def download_image(url, module='news'):
|
||||
"""下载图片到本地存储目录,返回文件路径和数据库记录信息"""
|
||||
if not url or url.startswith('data:'):
|
||||
return None, None
|
||||
|
||||
# 过滤 135editor 占位图
|
||||
if '135editor.com' in url and url.endswith('.gif'):
|
||||
return None, None
|
||||
|
||||
try:
|
||||
resp = requests.get(url, headers=HEADERS, timeout=30)
|
||||
if resp.status_code != 200:
|
||||
return None, None
|
||||
|
||||
content = resp.content
|
||||
if len(content) < 100:
|
||||
return None, None
|
||||
|
||||
# 判断扩展名
|
||||
ext = os.path.splitext(urlparse(url).path)[1].lower()
|
||||
if ext not in ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp'):
|
||||
# 尝试通过内容判断
|
||||
if content[:2] == b'\xff\xd8':
|
||||
ext = '.jpg'
|
||||
elif content[:8] == b'\x89PNG\r\n\x1a\n':
|
||||
ext = '.png'
|
||||
elif content[:4] == b'GIF8':
|
||||
ext = '.gif'
|
||||
elif content[:4] == b'<svg':
|
||||
ext = '.svg'
|
||||
else:
|
||||
ext = '.jpg'
|
||||
|
||||
# 生成文件名
|
||||
filename = hashlib.md5(url.encode()).hexdigest() + ext
|
||||
subdir = datetime.now().strftime('%Y%m')
|
||||
dest_dir = os.path.join(UPLOAD_DIR, subdir)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
|
||||
filepath = os.path.join(dest_dir, filename)
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(content)
|
||||
|
||||
# 生成缩略图
|
||||
thumb_path = None
|
||||
if ext in ('.jpg', '.jpeg', '.png', '.webp', '.bmp'):
|
||||
try:
|
||||
img = Image.open(io.BytesIO(content))
|
||||
img.thumbnail((300, 300))
|
||||
thumb_filename = f"thumb_{filename}"
|
||||
thumb_filepath = os.path.join(dest_dir, thumb_filename)
|
||||
if ext == '.png':
|
||||
img.save(thumb_filepath, 'PNG')
|
||||
else:
|
||||
img.save(thumb_filepath, 'JPEG', quality=85)
|
||||
thumb_path = f'/storage/admin/images/{subdir}/{thumb_filename}'
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
rel_path = f'/storage/admin/images/{subdir}/{filename}'
|
||||
file_size = len(content)
|
||||
|
||||
# 获取图片尺寸
|
||||
width, height = None, None
|
||||
try:
|
||||
img = Image.open(io.BytesIO(content))
|
||||
width, height = img.size
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return rel_path, {
|
||||
'filename': filename,
|
||||
'original_name': os.path.basename(urlparse(url).path) or filename,
|
||||
'file_path': rel_path,
|
||||
'thumb_path': thumb_path,
|
||||
'file_size': file_size,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'module': module,
|
||||
}
|
||||
except Exception as e:
|
||||
print(f" [图片下载失败] {url}: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def insert_image_to_db(conn, img_info):
|
||||
"""插入图片记录到 images 表,返回 image_id"""
|
||||
if not img_info:
|
||||
return None
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO images (filename, original_name, file_path, thumb_path, description, file_size, width, height, module, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
img_info['filename'],
|
||||
img_info['original_name'],
|
||||
img_info['file_path'],
|
||||
img_info.get('thumb_path'),
|
||||
None,
|
||||
img_info['file_size'],
|
||||
img_info.get('width'),
|
||||
img_info.get('height'),
|
||||
img_info['module'],
|
||||
datetime.now().isoformat(),
|
||||
))
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def html_to_markdown(element, base_url, downloaded_images):
|
||||
"""递归将 HTML 转换为 Markdown,同时下载图片"""
|
||||
if element is None:
|
||||
return ''
|
||||
|
||||
parts = []
|
||||
for child in element.children:
|
||||
if isinstance(child, NavigableString):
|
||||
text = str(child)
|
||||
# 保留换行,但压缩多余空格
|
||||
parts.append(text)
|
||||
continue
|
||||
|
||||
tag = child.name
|
||||
if tag is None:
|
||||
continue
|
||||
|
||||
# 跳过 135editor 装饰性元素和脚本
|
||||
if tag in ('script', 'style', 'iframe', 'video', 'audio'):
|
||||
continue
|
||||
|
||||
# 跳过纯装饰性 section/div(没有文本内容且没有图片)
|
||||
inner_text = child.get_text(strip=True)
|
||||
has_img = child.find('img') is not None
|
||||
if tag in ('section', 'div') and not inner_text and not has_img:
|
||||
continue
|
||||
|
||||
if tag == 'p':
|
||||
text = html_to_markdown(child, base_url, downloaded_images).strip()
|
||||
if text:
|
||||
parts.append(text + '\n\n')
|
||||
elif tag in ('h1', 'h2', 'h3', 'h4', 'h5', 'h6'):
|
||||
level = int(tag[1])
|
||||
text = html_to_markdown(child, base_url, downloaded_images).strip()
|
||||
if text:
|
||||
parts.append('#' * level + ' ' + text + '\n\n')
|
||||
elif tag == 'img':
|
||||
src = child.get('src') or child.get('_src') or child.get('data-src', '')
|
||||
if src and not src.endswith('.gif'):
|
||||
full_src = urljoin(base_url, src)
|
||||
# 下载图片
|
||||
if full_src not in downloaded_images:
|
||||
rel_path, img_info = download_image(full_src)
|
||||
if rel_path:
|
||||
downloaded_images[full_src] = rel_path
|
||||
# 同时记录到数据库
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
insert_image_to_db(conn, img_info)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if full_src in downloaded_images:
|
||||
alt = child.get('alt', '图片')
|
||||
parts.append(f'\n\n')
|
||||
elif tag in ('strong', 'b'):
|
||||
text = html_to_markdown(child, base_url, downloaded_images).strip()
|
||||
if text:
|
||||
parts.append(f'**{text}**')
|
||||
elif tag in ('em', 'i'):
|
||||
text = html_to_markdown(child, base_url, downloaded_images).strip()
|
||||
if text:
|
||||
parts.append(f'*{text}*')
|
||||
elif tag == 'br':
|
||||
parts.append('\n')
|
||||
elif tag == 'a':
|
||||
href = child.get('href', '')
|
||||
text = html_to_markdown(child, base_url, downloaded_images).strip()
|
||||
if text and href:
|
||||
full_href = urljoin(base_url, href)
|
||||
parts.append(f'[{text}]({full_href})')
|
||||
elif text:
|
||||
parts.append(text)
|
||||
elif tag in ('ul', 'ol'):
|
||||
is_ordered = (tag == 'ol')
|
||||
items = []
|
||||
for li in child.find_all('li', recursive=False):
|
||||
li_text = html_to_markdown(li, base_url, downloaded_images).strip()
|
||||
if li_text:
|
||||
prefix = f"{len(items)+1}. " if is_ordered else "- "
|
||||
items.append(prefix + li_text)
|
||||
if items:
|
||||
parts.append('\n' + '\n'.join(items) + '\n\n')
|
||||
elif tag == 'blockquote':
|
||||
text = html_to_markdown(child, base_url, downloaded_images).strip()
|
||||
if text:
|
||||
lines = text.split('\n')
|
||||
quoted = '\n'.join('> ' + line for line in lines)
|
||||
parts.append(quoted + '\n\n')
|
||||
elif tag in ('table', 'thead', 'tbody', 'tr', 'td', 'th'):
|
||||
# 简单处理表格:提取文本
|
||||
text = html_to_markdown(child, base_url, downloaded_images).strip()
|
||||
if text:
|
||||
parts.append(text + ' ')
|
||||
elif tag == 'pre':
|
||||
code = child.get_text()
|
||||
parts.append(f'\n```\n{code}\n```\n\n')
|
||||
elif tag == 'code':
|
||||
code = child.get_text()
|
||||
parts.append(f'`{code}`')
|
||||
else:
|
||||
# 其他标签递归处理
|
||||
text = html_to_markdown(child, base_url, downloaded_images)
|
||||
if text:
|
||||
parts.append(text)
|
||||
|
||||
return ''.join(parts)
|
||||
|
||||
|
||||
def clean_markdown(md):
|
||||
"""清理 Markdown 文本"""
|
||||
# 合并多个空行
|
||||
md = re.sub(r'\n{3,}', '\n\n', md)
|
||||
# 去除行首行尾空白
|
||||
md = md.strip()
|
||||
return md
|
||||
|
||||
|
||||
def parse_articles_from_page(page):
|
||||
"""从当前页面解析文章列表"""
|
||||
articles = []
|
||||
html = page.content()
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
medias = soup.find_all('div', class_='media')
|
||||
for media in medias:
|
||||
heading_a = media.find('a', class_='media-heading')
|
||||
if not heading_a:
|
||||
continue
|
||||
title = heading_a.get_text(strip=True)
|
||||
href = heading_a.get('href', '')
|
||||
if not href:
|
||||
continue
|
||||
m = re.search(r'article_id=(\d+)', href)
|
||||
if not m:
|
||||
continue
|
||||
article_id = int(m.group(1))
|
||||
cover_url = None
|
||||
left_a = media.find('a', class_='media-left')
|
||||
if left_a:
|
||||
img = left_a.find('img')
|
||||
if img:
|
||||
cover_url = img.get('src')
|
||||
time_str = ''
|
||||
time_el = media.find('span', class_='time')
|
||||
if time_el:
|
||||
time_str = time_el.get_text(strip=True)
|
||||
articles.append({
|
||||
'article_id': article_id,
|
||||
'title': title,
|
||||
'href': urljoin('https://niaodaifu.cn', href),
|
||||
'cover_url': cover_url,
|
||||
'time': time_str,
|
||||
})
|
||||
return articles, soup
|
||||
|
||||
|
||||
# ============ 采集函数 ============
|
||||
def get_article_list(page, menu_id):
|
||||
"""获取指定栏目的所有文章列表(通过点击分页按钮翻页)"""
|
||||
base_url = f"https://niaodaifu.cn/health-information?menu_id={menu_id}"
|
||||
all_articles = []
|
||||
seen_ids = set()
|
||||
|
||||
page.goto(base_url, wait_until='networkidle', timeout=30000)
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
# 获取总条数
|
||||
html = page.content()
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
total = 0
|
||||
total_el = soup.find('span', class_='el-pagination__total')
|
||||
if total_el:
|
||||
m = re.search(r'(\d+)', total_el.get_text())
|
||||
if m:
|
||||
total = int(m.group(1))
|
||||
|
||||
# 获取页码按钮总数(最后一个数字页码)
|
||||
pager_btns = page.query_selector_all('.el-pager li.number')
|
||||
total_pages = 1
|
||||
if pager_btns:
|
||||
# 最后一个页码按钮的文本就是总页数(可能被省略,需要点击更多后才知道)
|
||||
# 先查 el-pagination__total
|
||||
if total > 0:
|
||||
page_size_text = ''
|
||||
size_select = soup.find('input', {'class': 'el-input__inner'})
|
||||
page_size = 10 # 默认每页10条
|
||||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||
|
||||
print(f" 栏目 {menu_id} 共 {total} 篇文章,预计 {total_pages} 页")
|
||||
|
||||
current_page = 1
|
||||
while True:
|
||||
articles, soup = parse_articles_from_page(page)
|
||||
new_count = 0
|
||||
for art in articles:
|
||||
if art['article_id'] not in seen_ids:
|
||||
seen_ids.add(art['article_id'])
|
||||
all_articles.append(art)
|
||||
new_count += 1
|
||||
print(f" 第 {current_page} 页采集到 {len(articles)} 篇(新增 {new_count} 篇)")
|
||||
|
||||
# 查找下一页按钮
|
||||
next_btn = page.query_selector('button.btn-next')
|
||||
if not next_btn:
|
||||
break
|
||||
# 检查是否禁用
|
||||
is_disabled = next_btn.get_attribute('disabled')
|
||||
if is_disabled is not None:
|
||||
break
|
||||
|
||||
next_btn.click()
|
||||
page.wait_for_timeout(2000)
|
||||
current_page += 1
|
||||
|
||||
return all_articles
|
||||
|
||||
|
||||
def get_article_detail(page, url):
|
||||
"""获取文章详情"""
|
||||
page.goto(url, wait_until='networkidle', timeout=30000)
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
html = page.content()
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
# 标题:在 article > h3 中,格式 "首页 》栏目 》标题"
|
||||
title = ''
|
||||
article_el = soup.find('article')
|
||||
if article_el:
|
||||
h3 = article_el.find('h3')
|
||||
if h3:
|
||||
text = h3.get_text(strip=True)
|
||||
parts = [p.strip() for p in text.split('》')]
|
||||
# 过滤 "首页" 和栏目名,取最后的标题
|
||||
if len(parts) >= 2:
|
||||
title = parts[-1]
|
||||
else:
|
||||
title = text
|
||||
|
||||
# 来源和时间
|
||||
source = ''
|
||||
time_str = ''
|
||||
if article_el:
|
||||
info_p = article_el.find('p', class_='more-info')
|
||||
if info_p:
|
||||
src_el = info_p.find('span', class_='source')
|
||||
if src_el:
|
||||
source = src_el.get_text(strip=True).replace('来源:', '')
|
||||
time_el = info_p.find('span', class_='time')
|
||||
if time_el:
|
||||
time_str = time_el.get_text(strip=True)
|
||||
|
||||
# 正文 HTML
|
||||
content_div = soup.find('div', class_='article-content')
|
||||
content_html = ''
|
||||
if content_div:
|
||||
content_html = str(content_div)
|
||||
|
||||
return {
|
||||
'title': title,
|
||||
'source': source,
|
||||
'time': time_str,
|
||||
'content_html': content_html,
|
||||
}
|
||||
|
||||
|
||||
def save_article(article, detail, category_id, cover_path, content_md):
|
||||
"""保存文章到数据库"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
title = detail['title'] or article['title']
|
||||
slug = ensure_unique_slug(conn, slugify(title))
|
||||
|
||||
# 解析发布时间
|
||||
created_at = datetime.now().isoformat()
|
||||
if detail.get('time') or article.get('time'):
|
||||
t = detail.get('time') or article.get('time')
|
||||
try:
|
||||
created_at = datetime.strptime(t, '%Y-%m-%d %H:%M').isoformat()
|
||||
except ValueError:
|
||||
try:
|
||||
created_at = datetime.strptime(t, '%Y-%m-%d').isoformat()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
summary = title[:200]
|
||||
if detail.get('source'):
|
||||
summary = f"来源:{detail['source']} | {summary}"
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO news_posts (title, slug, content, content_type, summary, cover_image, category_id, is_published, is_draft, view_count, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'markdown', ?, ?, ?, 1, 0, 0, ?, ?)
|
||||
''', (
|
||||
title,
|
||||
slug,
|
||||
content_md,
|
||||
summary,
|
||||
cover_path,
|
||||
category_id,
|
||||
created_at,
|
||||
created_at,
|
||||
))
|
||||
|
||||
post_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return post_id
|
||||
|
||||
|
||||
# ============ 主程序 ============
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("开始批量采集 niaodaifu.cn 文章")
|
||||
print("=" * 60)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={'width': 1920, 'height': 1080})
|
||||
|
||||
for menu_id, (category_id, cat_name) in MENU_TO_CATEGORY.items():
|
||||
print(f"\n>>> 采集栏目 [{cat_name}] menu_id={menu_id} -> category_id={category_id}")
|
||||
|
||||
articles = get_article_list(page, menu_id)
|
||||
print(f" 共找到 {len(articles)} 篇文章")
|
||||
|
||||
for idx, article in enumerate(articles, 1):
|
||||
print(f" [{idx}/{len(articles)}] {article['title']}")
|
||||
|
||||
try:
|
||||
detail = get_article_detail(page, article['href'])
|
||||
except Exception as e:
|
||||
print(f" [详情获取失败] {e}")
|
||||
continue
|
||||
|
||||
# 下载封面图
|
||||
cover_path = None
|
||||
if article.get('cover_url'):
|
||||
cover_path, cover_info = download_image(article['cover_url'])
|
||||
if cover_info:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
insert_image_to_db(conn, cover_info)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# 转换正文为 Markdown
|
||||
content_md = ''
|
||||
if detail.get('content_html'):
|
||||
soup = BeautifulSoup(detail['content_html'], 'html.parser')
|
||||
content_div = soup.find('div', class_='article-content')
|
||||
if content_div:
|
||||
downloaded_images = {}
|
||||
content_md = html_to_markdown(content_div, article['href'], downloaded_images)
|
||||
content_md = clean_markdown(content_md)
|
||||
|
||||
if not content_md.strip():
|
||||
print(f" [警告] 正文为空,跳过")
|
||||
continue
|
||||
|
||||
# 保存
|
||||
try:
|
||||
post_id = save_article(article, detail, category_id, cover_path, content_md)
|
||||
print(f" [已入库] post_id={post_id}")
|
||||
except Exception as e:
|
||||
print(f" [入库失败] {e}")
|
||||
|
||||
browser.close()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("采集完成")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,39 @@
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
for db_name in ['app.db', 'cms.db', 'website.db']:
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Database: {db_name}")
|
||||
print('='*60)
|
||||
try:
|
||||
engine = create_engine(f'sqlite:///{db_name}')
|
||||
with engine.connect() as conn:
|
||||
tables = [r[0] for r in conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"))]
|
||||
print(f"Tables: {tables}")
|
||||
|
||||
if 'news_categories' in tables:
|
||||
print("\n--- news_categories ---")
|
||||
for row in conn.execute(text("SELECT id, name, slug, sort_order, is_enabled FROM news_categories ORDER BY id")):
|
||||
print(row)
|
||||
|
||||
if 'news_posts' in tables:
|
||||
print("\n--- news_posts count ---")
|
||||
for row in conn.execute(text("SELECT COUNT(*) FROM news_posts")):
|
||||
print(f"Total: {row[0]}")
|
||||
print("\n--- news_posts sample ---")
|
||||
for row in conn.execute(text("SELECT id, title, slug, category_id, is_published, view_count FROM news_posts LIMIT 5")):
|
||||
print(row)
|
||||
|
||||
if 'columns' in tables:
|
||||
print("\n--- columns ---")
|
||||
for row in conn.execute(text("SELECT id, name, slug, parent_id, sort_order, is_enabled FROM columns ORDER BY id")):
|
||||
print(row)
|
||||
|
||||
if 'articles' in tables:
|
||||
print("\n--- articles count ---")
|
||||
for row in conn.execute(text("SELECT COUNT(*) FROM articles")):
|
||||
print(f"Total: {row[0]}")
|
||||
print("\n--- articles sample ---")
|
||||
for row in conn.execute(text("SELECT id, title, slug, column_id, status, view_count FROM articles LIMIT 5")):
|
||||
print(row)
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
@@ -0,0 +1,35 @@
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
engine = create_engine('sqlite:///cms.db')
|
||||
with engine.connect() as conn:
|
||||
print("=== home_experts 表数据 ===")
|
||||
rows = conn.execute(text("""
|
||||
SELECT he.id, he.post_id, he.sort_order, he.is_enabled,
|
||||
np.title AS old_post_title
|
||||
FROM home_experts he
|
||||
LEFT JOIN news_posts np ON np.id = he.post_id
|
||||
ORDER BY he.sort_order
|
||||
""")).fetchall()
|
||||
print(f"共 {len(rows)} 条记录")
|
||||
for r in rows:
|
||||
print(f" id={r[0]} post_id={r[1]} sort={r[2]} enabled={r[3]} title={r[4]}")
|
||||
|
||||
print("\n=== 用 title 匹配 articles 表 ===")
|
||||
for r in rows:
|
||||
if not r[4]:
|
||||
print(f" home_expert id={r[0]}: 老 post 已不存在")
|
||||
continue
|
||||
art = conn.execute(text("SELECT id, title FROM articles WHERE title = :t"), {"t": r[4]}).fetchone()
|
||||
if art:
|
||||
print(f" home_expert id={r[0]}: 老post_id={r[1]} -> 新article_id={art[0]} ({art[1]})")
|
||||
else:
|
||||
print(f" home_expert id={r[0]}: 老post_id={r[1]} 找不到对应的新 article (title={r[4]})")
|
||||
|
||||
print("\n=== home_modules 表结构 ===")
|
||||
cols = conn.execute(text("PRAGMA table_info(home_modules)")).fetchall()
|
||||
for c in cols:
|
||||
print(f" {c[1]} {c[2]}")
|
||||
print("\n=== home_modules 所有记录 ===")
|
||||
rows = conn.execute(text("SELECT * FROM home_modules")).fetchall()
|
||||
for r in rows:
|
||||
print(f" {r}"[:300])
|
||||
@@ -0,0 +1,11 @@
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('cms.db')
|
||||
c = conn.cursor()
|
||||
c.execute("PRAGMA table_info(nav_menus)")
|
||||
for row in c.fetchall():
|
||||
print(row)
|
||||
print('\n--- data ---')
|
||||
c.execute("SELECT * FROM nav_menus")
|
||||
for row in c.fetchall():
|
||||
print(row)
|
||||
conn.close()
|
||||
@@ -0,0 +1,7 @@
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('cms.db')
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT id, code, type, content FROM home_modules WHERE type='news'")
|
||||
row = c.fetchone()
|
||||
print('news module:', row)
|
||||
conn.close()
|
||||
@@ -0,0 +1,40 @@
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
engine = create_engine('sqlite:///cms.db')
|
||||
with engine.connect() as conn:
|
||||
# 老表数量
|
||||
print("=== 老数据量 ===")
|
||||
for row in conn.execute(text("SELECT COUNT(*) FROM news_categories")):
|
||||
print(f"news_categories: {row[0]} 条")
|
||||
for row in conn.execute(text("SELECT COUNT(*) FROM news_posts")):
|
||||
print(f"news_posts: {row[0]} 条")
|
||||
|
||||
# 新表数量
|
||||
print("\n=== 新数据量 ===")
|
||||
for row in conn.execute(text("SELECT COUNT(*) FROM columns")):
|
||||
print(f"columns: {row[0]} 条")
|
||||
for row in conn.execute(text("SELECT COUNT(*) FROM articles")):
|
||||
print(f"articles: {row[0]} 条")
|
||||
|
||||
# 老表中 cover_image 有值的数量(即有图片的文章)
|
||||
print("\n=== 老 news_posts 图片情况 ===")
|
||||
for row in conn.execute(text("SELECT COUNT(*) FROM news_posts WHERE cover_image IS NOT NULL AND cover_image != ''")):
|
||||
print(f"有封面图的文章: {row[0]} 条")
|
||||
|
||||
# 新表中 cover_image 有值的数量
|
||||
print("\n=== 新 articles 图片情况 ===")
|
||||
for row in conn.execute(text("SELECT COUNT(*) FROM articles WHERE cover_image IS NOT NULL AND cover_image != ''")):
|
||||
print(f"有封面图的文章: {row[0]} 条")
|
||||
|
||||
# 抽样对比图片路径是否一致
|
||||
print("\n=== 图片路径抽样对比(前5条)===")
|
||||
old_imgs = [r[0] for r in conn.execute(text(
|
||||
"SELECT cover_image FROM news_posts WHERE cover_image IS NOT NULL AND cover_image != '' LIMIT 5"
|
||||
))]
|
||||
new_imgs = [r[0] for r in conn.execute(text(
|
||||
"SELECT cover_image FROM articles WHERE cover_image IS NOT NULL AND cover_image != '' LIMIT 5"
|
||||
))]
|
||||
print("老 cover_image:")
|
||||
for p in old_imgs: print(f" {p}")
|
||||
print("新 cover_image:")
|
||||
for p in new_imgs: print(f" {p}")
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-cn">
|
||||
<head>
|
||||
<script type="module" crossorigin src="/js/polyfills-B1iEewpt.js"></script>
|
||||
|
||||
<title>ææºå°±è½åå°¿æ£</title>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="shortcut icon" href="/ico/favicon-_Z0jsgTN.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="keywords" content="å°¿æ£,使£,ç³å°¿ç
,è¾ç
,å°¿æ¶²,è¿ç¤,ç»ç³,尿常è§,è¯çº¸,é£ç¹å¥åº·,å¾å¤§å¤«,å´è¯çº¸" />
|
||||
<meta name="description" content="â尿大夫ãå¾å¤§å¤«âæ¯å½å
ç§»å¨å»çè¡ä¸å¤äºé¢å
å°ä½çå¥åº·æå¡åçãå¨å®¶å使£ï¼ç¾ç
æ©åç°ï¼æ¯ç³å°¿ç
è¾ç
æ
¢æ§ç
å¥åº·ç®¡çå¹³å°ï¼å¯ä»¥çæ¥ç³å°¿ç
ãè¾ç
ãå¦ç§ç
çäºåå¤ç§ç¾ç
ï¼" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="format-detection" content="email=no" />
|
||||
<script type="module" crossorigin src="/js/index-C5YcNH8a.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/js/@vue-JFNH94p8.js">
|
||||
<link rel="modulepreload" crossorigin href="/js/@element-plus-BjkBb207.js">
|
||||
<link rel="modulepreload" crossorigin href="/js/lodash-es-CuaXstk6.js">
|
||||
<link rel="modulepreload" crossorigin href="/js/@vueuse-Cv7zzI7E.js">
|
||||
<link rel="modulepreload" crossorigin href="/js/dayjs-TUi1EdFu.js">
|
||||
<link rel="modulepreload" crossorigin href="/js/element-plus-XGZyc0-m.js">
|
||||
<link rel="modulepreload" crossorigin href="/js/vue-router-BLXIbAH3.js">
|
||||
<link rel="modulepreload" crossorigin href="/js/@vant-Cjgwz4fi.js">
|
||||
<link rel="modulepreload" crossorigin href="/js/vant-7nl52rYU.js">
|
||||
<link rel="stylesheet" crossorigin href="/css/element-plus-BKM98fYY.css">
|
||||
<link rel="stylesheet" crossorigin href="/css/vant-CO-T41ew.css">
|
||||
<link rel="stylesheet" crossorigin href="/css/index-CPRJOQxN.css">
|
||||
<script type="module">import.meta.url;import("_").catch(()=>1);(async function*(){})().next();if(location.protocol!="file:"){window.__vite_is_modern_browser=true}</script>
|
||||
<script type="module">!function(){if(window.__vite_is_modern_browser)return;console.warn("vite: loading legacy chunks, syntax error above and the same error below should be ignored");var e=document.getElementById("vite-legacy-polyfill"),n=document.createElement("script");n.src=e.src,n.onload=function(){System.import(document.getElementById('vite-legacy-entry').getAttribute('data-src'))},document.body.appendChild(n)}();</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script nomodule>!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",(function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()}),!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();</script>
|
||||
<script nomodule crossorigin id="vite-legacy-polyfill" src="/js/polyfills-legacy-O1vO_gOd.js"></script>
|
||||
<script nomodule crossorigin id="vite-legacy-entry" data-src="/js/index-legacy-Bf-YSZBI.js">System.import(document.getElementById('vite-legacy-entry').getAttribute('data-src'))</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,328 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
采集静态页面到本地目录
|
||||
用法: python3 crawl_static_page.py <url> <output_dir>
|
||||
示例: python3 crawl_static_page.py https://mubaitea.cn/zsjm /var/www/new-media-platform/website-new/static/
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import hashlib
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from playwright.sync_api import sync_playwright
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept": "*/*",
|
||||
}
|
||||
|
||||
|
||||
def slugify(url):
|
||||
"""从 URL 生成文件名"""
|
||||
path = urlparse(url).path.strip('/')
|
||||
if path:
|
||||
name = path.split('/')[-1]
|
||||
if '.' in name:
|
||||
name = name.rsplit('.', 1)[0]
|
||||
return name or 'index'
|
||||
return 'index'
|
||||
|
||||
|
||||
def download_file(url, dest_dir, skip_existing=True):
|
||||
"""下载文件到指定目录,返回本地相对路径"""
|
||||
if not url or url.startswith('data:'):
|
||||
return None
|
||||
|
||||
# 补全协议
|
||||
if url.startswith('//'):
|
||||
url = 'https:' + url
|
||||
|
||||
try:
|
||||
# 生成文件名
|
||||
parsed = urlparse(url)
|
||||
path_parts = parsed.path.strip('/').split('/')
|
||||
filename = path_parts[-1] if path_parts else 'file'
|
||||
|
||||
# 如果文件名没有扩展名或太泛,用hash重命名
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if not ext or len(filename) < 5:
|
||||
url_hash = hashlib.md5(url.encode()).hexdigest()[:10]
|
||||
filename = f"{url_hash}{ext or '.bin'}"
|
||||
|
||||
dest_path = os.path.join(dest_dir, filename)
|
||||
|
||||
if skip_existing and os.path.exists(dest_path):
|
||||
return filename
|
||||
|
||||
resp = requests.get(url, headers=HEADERS, timeout=30)
|
||||
if resp.status_code != 200:
|
||||
print(f" [下载失败] {url} HTTP {resp.status_code}")
|
||||
return None
|
||||
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
with open(dest_path, 'wb') as f:
|
||||
f.write(resp.content)
|
||||
|
||||
print(f" [已下载] {url} -> {filename} ({len(resp.content)} bytes)")
|
||||
return filename
|
||||
|
||||
except Exception as e:
|
||||
print(f" [下载失败] {url}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def extract_resource_urls(html_snippet, base_url):
|
||||
"""从 HTML 片段中提取所有资源 URL"""
|
||||
soup = BeautifulSoup(html_snippet, 'html.parser')
|
||||
|
||||
images = set()
|
||||
scripts = set()
|
||||
stylesheets = set()
|
||||
|
||||
# 图片
|
||||
for tag in soup.find_all(['img', 'image']):
|
||||
for attr in ['src', '_src', 'data-src', 'data-original']:
|
||||
src = tag.get(attr)
|
||||
if src and not src.startswith('data:'):
|
||||
full = urljoin(base_url, src)
|
||||
if any(ext in full.lower() for ext in ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico']):
|
||||
images.add(full)
|
||||
|
||||
# CSS background-image (inline style)
|
||||
for tag in soup.find_all(style=True):
|
||||
style = tag['style']
|
||||
urls = re.findall(r'url\([\"\']?([^\"\'()]+)[\"\']?\)', style)
|
||||
for u in urls:
|
||||
if not u.startswith('data:'):
|
||||
full = urljoin(base_url, u)
|
||||
images.add(full)
|
||||
|
||||
return {'images': list(images), 'scripts': list(scripts), 'stylesheets': list(stylesheets)}
|
||||
|
||||
|
||||
def crawl_static_page(url, output_dir):
|
||||
"""主函数:采集页面到本地目录"""
|
||||
js_dir = os.path.join(output_dir, 'js')
|
||||
images_dir = os.path.join(output_dir, 'images')
|
||||
css_dir = os.path.join(output_dir, 'css')
|
||||
|
||||
page_name = slugify(url)
|
||||
html_path = os.path.join(output_dir, f'{page_name}.html')
|
||||
|
||||
print(f"目标 URL: {url}")
|
||||
print(f"输出目录: {output_dir}")
|
||||
print(f"HTML 文件: {html_path}")
|
||||
print()
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={'width': 1920, 'height': 1080})
|
||||
page.goto(url, wait_until='networkidle', timeout=30000)
|
||||
page.wait_for_timeout(5000)
|
||||
|
||||
html = page.content()
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
# === 1. 提取正文内容(ctrl-location="main") ===
|
||||
main_elements = soup.find_all(attrs={'ctrl-location': 'main'})
|
||||
print(f"找到 {len(main_elements)} 个正文元素 (ctrl-location=main)")
|
||||
|
||||
# 取顶层 main 元素(父级不是 main 的)
|
||||
top_level_mains = []
|
||||
for el in main_elements:
|
||||
parent = el.parent
|
||||
if parent and parent.get('ctrl-location') != 'main':
|
||||
top_level_mains.append(el)
|
||||
|
||||
print(f"其中顶层元素: {len(top_level_mains)} 个")
|
||||
|
||||
# 收集 main 内容的 HTML
|
||||
main_html_parts = []
|
||||
for el in top_level_mains:
|
||||
main_html_parts.append(str(el))
|
||||
|
||||
main_content_html = '\n'.join(main_html_parts)
|
||||
|
||||
# === 2. 提取页面 CSS 引用 ===
|
||||
css_links = []
|
||||
for link in soup.find_all('link', rel='stylesheet'):
|
||||
href = link.get('href', '')
|
||||
if href:
|
||||
css_links.append(href)
|
||||
|
||||
# 页面内联 style
|
||||
inline_styles = []
|
||||
for style_tag in soup.find_all('style'):
|
||||
inline_styles.append(str(style_tag))
|
||||
|
||||
# === 3. 提取页面 JS 引用 ===
|
||||
script_srcs = []
|
||||
for script in soup.find_all('script', src=True):
|
||||
src = script['src']
|
||||
if src:
|
||||
script_srcs.append(src)
|
||||
|
||||
# === 4. 提取 SVG symbol 图标(通常在 body 开头) ===
|
||||
svg_sprite = ''
|
||||
body = soup.find('body', attrs={'area': 'main'})
|
||||
if body:
|
||||
svg = body.find('svg', recursive=False)
|
||||
if svg:
|
||||
svg_sprite = str(svg)
|
||||
print("找到 SVG 图标精灵")
|
||||
|
||||
# === 5. 提取 head 中的 meta/viewport ===
|
||||
head_meta = ''
|
||||
head = soup.find('head')
|
||||
if head:
|
||||
meta_tags = head.find_all('meta')
|
||||
for meta in meta_tags:
|
||||
head_meta += str(meta) + '\n'
|
||||
# 也保留 title
|
||||
title_tag = head.find('title')
|
||||
if title_tag:
|
||||
head_meta += str(title_tag) + '\n'
|
||||
|
||||
browser.close()
|
||||
|
||||
# === 6. 下载图片 ===
|
||||
print("\n=== 下载图片 ===")
|
||||
resources = extract_resource_urls(main_content_html, url)
|
||||
image_map = {} # original_url -> local_filename
|
||||
|
||||
for img_url in resources['images']:
|
||||
local_name = download_file(img_url, images_dir)
|
||||
if local_name:
|
||||
image_map[img_url] = f'images/{local_name}'
|
||||
|
||||
# 也下载 main 内容中 img 标签的图片(更可靠的方式)
|
||||
main_soup = BeautifulSoup(main_content_html, 'html.parser')
|
||||
for img in main_soup.find_all('img'):
|
||||
for attr in ['src', '_src', 'data-src', 'data-original']:
|
||||
src = img.get(attr)
|
||||
if src and not src.startswith('data:'):
|
||||
full = urljoin(url, src)
|
||||
if full not in image_map:
|
||||
local_name = download_file(full, images_dir)
|
||||
if local_name:
|
||||
image_map[full] = f'images/{local_name}'
|
||||
|
||||
# === 7. 下载 JS 文件 ===
|
||||
print("\n=== 下载 JS 文件 ===")
|
||||
js_map = {}
|
||||
for js_url in script_srcs:
|
||||
local_name = download_file(js_url, js_dir)
|
||||
if local_name:
|
||||
js_map[js_url] = f'js/{local_name}'
|
||||
|
||||
# === 8. 下载 CSS 文件 ===
|
||||
print("\n=== 下载 CSS 文件 ===")
|
||||
css_map = {}
|
||||
css_contents = []
|
||||
for css_url in css_links:
|
||||
local_name = download_file(css_url, css_dir)
|
||||
if local_name:
|
||||
css_map[css_url] = f'css/{local_name}'
|
||||
# 读取 CSS 内容以便内联 background-image 中的 URL
|
||||
css_path = os.path.join(css_dir, local_name)
|
||||
try:
|
||||
with open(css_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
css_contents.append(f.read())
|
||||
except:
|
||||
pass
|
||||
|
||||
# === 9. 替换 HTML 中的资源路径 ===
|
||||
print("\n=== 替换资源路径 ===")
|
||||
|
||||
# 替换图片
|
||||
for orig_url, local_path in image_map.items():
|
||||
# 处理各种 URL 格式
|
||||
main_content_html = main_content_html.replace(orig_url, local_path)
|
||||
# 处理 protocol-relative
|
||||
if orig_url.startswith('https://'):
|
||||
proto_rel = orig_url.replace('https:', '')
|
||||
main_content_html = main_content_html.replace(proto_rel, local_path)
|
||||
|
||||
# 替换 JS
|
||||
for orig_url, local_path in js_map.items():
|
||||
main_content_html = main_content_html.replace(orig_url, local_path)
|
||||
if orig_url.startswith('https://'):
|
||||
proto_rel = orig_url.replace('https:', '')
|
||||
main_content_html = main_content_html.replace(proto_rel, local_path)
|
||||
|
||||
# 替换 CSS
|
||||
for orig_url, local_path in css_map.items():
|
||||
main_content_html = main_content_html.replace(orig_url, local_path)
|
||||
if orig_url.startswith('https://'):
|
||||
proto_rel = orig_url.replace('https:', '')
|
||||
main_content_html = main_content_html.replace(proto_rel, local_path)
|
||||
|
||||
# 构建 CSS link 标签(使用本地路径)
|
||||
local_css_links = ''
|
||||
for orig_url, local_path in css_map.items():
|
||||
local_css_links += f' <link rel="stylesheet" href="{local_path}">\n'
|
||||
|
||||
# 构建 JS script 标签(使用本地路径)
|
||||
local_js_scripts = ''
|
||||
for orig_url, local_path in js_map.items():
|
||||
local_js_scripts += f' <script src="{local_path}"></script>\n'
|
||||
|
||||
# === 10. 构建完整 HTML ===
|
||||
full_html = f'''<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=1200, initial-scale=1.0">
|
||||
{head_meta}
|
||||
{local_css_links}
|
||||
{chr(10).join(f' <style>{s}</style>' for s in inline_styles if s.strip())}
|
||||
<style>
|
||||
/* 确保主内容区域正确显示 */
|
||||
body {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
font-family: "Microsoft YaHei", "微软雅黑", Arial, sans-serif;
|
||||
}}
|
||||
.main-content-wrapper {{
|
||||
position: relative;
|
||||
width: 1200px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{svg_sprite}
|
||||
<div class="main-content-wrapper">
|
||||
{main_content_html}
|
||||
</div>
|
||||
{local_js_scripts}
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
# 写入文件
|
||||
with open(html_path, 'w', encoding='utf-8') as f:
|
||||
f.write(full_html)
|
||||
|
||||
print(f"\n=== 完成 ===")
|
||||
print(f"HTML: {html_path}")
|
||||
print(f"图片: {len(image_map)} 个 -> {images_dir}")
|
||||
print(f"JS: {len(js_map)} 个 -> {js_dir}")
|
||||
print(f"CSS: {len(css_map)} 个 -> {css_dir}")
|
||||
print(f"文件大小: {len(full_html)} 字节")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 3:
|
||||
print("用法: python3 crawl_static_page.py <url> <output_dir>")
|
||||
print("示例: python3 crawl_static_page.py https://mubaitea.cn/zsjm /var/www/new-media-platform/website-new/static/")
|
||||
sys.exit(1)
|
||||
|
||||
target_url = sys.argv[1]
|
||||
output_dir = sys.argv[2].rstrip('/')
|
||||
crawl_static_page(target_url, output_dir)
|
||||
@@ -0,0 +1,7 @@
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('cms.db')
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT id, title, cover_image FROM news_posts WHERE cover_image IS NOT NULL AND cover_image != '' LIMIT 20")
|
||||
for row in c.fetchall():
|
||||
print(f'id={row[0]}: {row[2]}')
|
||||
conn.close()
|
||||
@@ -0,0 +1,13 @@
|
||||
import sqlite3, json
|
||||
conn = sqlite3.connect('cms.db')
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT content FROM home_modules WHERE type='features'")
|
||||
row = c.fetchone()
|
||||
if row:
|
||||
content = json.loads(row[0])
|
||||
for i, item in enumerate(content.get('items', [])):
|
||||
print(f'[{i}] id={item.get("id")} title={item.get("title")}')
|
||||
print(f' media_url={item.get("media_url")}')
|
||||
print(f' image={item.get("image")}')
|
||||
print(f' media_type={item.get("media_type")}')
|
||||
conn.close()
|
||||
@@ -0,0 +1,7 @@
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('cms.db')
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT id, title, cover_image FROM news_posts WHERE cover_image LIKE '%36624528%'")
|
||||
for row in c.fetchall():
|
||||
print('post:', row)
|
||||
conn.close()
|
||||
@@ -0,0 +1,18 @@
|
||||
import sqlite3, json
|
||||
conn = sqlite3.connect('cms.db')
|
||||
c = conn.cursor()
|
||||
|
||||
# 查看 news 模块 content
|
||||
c.execute("SELECT id, code, content FROM home_modules WHERE type='news'")
|
||||
row = c.fetchone()
|
||||
print('news module:', row)
|
||||
if row:
|
||||
content = json.loads(row[2])
|
||||
print('posts:', json.dumps(content.get('posts'), indent=2, ensure_ascii=False))
|
||||
|
||||
# 查看一些已发布文章的 cover_image 路径
|
||||
c.execute("SELECT id, title, cover_image, slug FROM news_posts WHERE is_published=1 LIMIT 5")
|
||||
for r in c.fetchall():
|
||||
print('post:', r)
|
||||
|
||||
conn.close()
|
||||
@@ -0,0 +1,32 @@
|
||||
"""彻底删除老的 news_posts / news_categories 表。
|
||||
运行前已手动备份 cms.db -> cms.db.backup_before_news_cleanup_20260429
|
||||
"""
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
engine = create_engine('sqlite:///cms.db')
|
||||
with engine.connect() as conn:
|
||||
# 先看看在不在
|
||||
tables_before = [r[0] for r in conn.execute(text(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('news_posts','news_categories')"
|
||||
)).fetchall()]
|
||||
print(f"清理前存在的老表: {tables_before}")
|
||||
|
||||
# 记录数据量(仅记录,不影响清理)
|
||||
for t in tables_before:
|
||||
cnt = conn.execute(text(f"SELECT COUNT(*) FROM {t}")).scalar()
|
||||
print(f" {t}: {cnt} 行")
|
||||
|
||||
# DROP 顺序:先 news_posts(含 FK 指向 news_categories),再 news_categories
|
||||
if 'news_posts' in tables_before:
|
||||
conn.execute(text("DROP TABLE news_posts"))
|
||||
print("已 DROP TABLE news_posts")
|
||||
if 'news_categories' in tables_before:
|
||||
conn.execute(text("DROP TABLE news_categories"))
|
||||
print("已 DROP TABLE news_categories")
|
||||
|
||||
conn.commit()
|
||||
|
||||
tables_after = [r[0] for r in conn.execute(text(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('news_posts','news_categories')"
|
||||
)).fetchall()]
|
||||
print(f"\n清理后存在的老表: {tables_after}(应为空)")
|
||||
@@ -0,0 +1,34 @@
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect('cms.db')
|
||||
c = conn.cursor()
|
||||
|
||||
# 查更新前时间范围
|
||||
c.execute('SELECT MIN(created_at), MAX(created_at), COUNT(*) FROM news_posts WHERE id >= 2')
|
||||
row = c.fetchone()
|
||||
print(f'更新前时间范围: {row[0]} ~ {row[1]},共 {row[2]} 篇')
|
||||
|
||||
# 一次性将所有采集文章的 created_at 和 updated_at 加2年
|
||||
# 使用 SQLite 的 datetime 函数,一条语句全部搞定,不存在顺序问题
|
||||
c.execute("UPDATE news_posts SET created_at = datetime(created_at, '+2 years'), updated_at = datetime(updated_at, '+2 years') WHERE id >= 2")
|
||||
print(f'已更新 {c.rowcount} 条记录')
|
||||
conn.commit()
|
||||
|
||||
# 验证更新后的范围
|
||||
c.execute('SELECT MIN(created_at), MAX(created_at) FROM news_posts WHERE id >= 2')
|
||||
row = c.fetchone()
|
||||
print(f'更新后时间范围: {row[0]} ~ {row[1]}')
|
||||
|
||||
# 抽样几条查看
|
||||
c.execute('SELECT id, title, created_at FROM news_posts WHERE id >= 2 ORDER BY created_at DESC LIMIT 5')
|
||||
print('\n最新5篇:')
|
||||
for r in c.fetchall():
|
||||
print(f' id={r[0]} created_at={r[2]} title={r[1][:20]}')
|
||||
|
||||
c.execute('SELECT id, title, created_at FROM news_posts WHERE id >= 2 ORDER BY created_at ASC LIMIT 5')
|
||||
print('\n最早5篇:')
|
||||
for r in c.fetchall():
|
||||
print(f' id={r[0]} created_at={r[2]} title={r[1][:20]}')
|
||||
|
||||
conn.close()
|
||||
print('\n完成!')
|
||||
@@ -0,0 +1,29 @@
|
||||
import sqlite3, json
|
||||
conn = sqlite3.connect('cms.db')
|
||||
c = conn.cursor()
|
||||
|
||||
c.execute("SELECT id, content FROM home_modules WHERE type='features'")
|
||||
row = c.fetchone()
|
||||
if row:
|
||||
content = json.loads(row[1])
|
||||
items = content.get('items', [])
|
||||
fixed = False
|
||||
for i, item in enumerate(items):
|
||||
# 同步 image 和 media_url:如果 image 有值,media_url 应该一致
|
||||
if item.get('image') and item.get('image') != item.get('media_url'):
|
||||
print(f'[{i}] sync media_url to image: {item["image"]}')
|
||||
item['media_url'] = item['image']
|
||||
fixed = True
|
||||
# 第一个 item 的特殊修复:如果 media_url 指向不存在的 202604 文件且 image 为空
|
||||
if i == 0 and item.get('media_url') == '/storage/admin/images/202604/1aaf94e5.jpg' and not item.get('image'):
|
||||
print(f'[{i}] fix broken media_url to feature1.png')
|
||||
item['media_url'] = '/storage/admin/images/features/feature1.png'
|
||||
fixed = True
|
||||
if fixed:
|
||||
c.execute("UPDATE home_modules SET content = ? WHERE type = 'features'",
|
||||
(json.dumps(content, ensure_ascii=False),))
|
||||
conn.commit()
|
||||
print('fixed and saved')
|
||||
else:
|
||||
print('no fix needed')
|
||||
conn.close()
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
修复 home_modules[news].content.posts 里的旧 post 快照:
|
||||
- 按 title 匹配新的 articles 表
|
||||
- 更新 id 和 slug 为新表中的值
|
||||
- 同步 cover_image / summary(以新表为准)
|
||||
"""
|
||||
import json
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
engine = create_engine('sqlite:///cms.db')
|
||||
with engine.connect() as conn:
|
||||
# 读取 news 模块的 content
|
||||
row = conn.execute(text("SELECT id, content FROM home_modules WHERE code='news' LIMIT 1")).fetchone()
|
||||
if not row:
|
||||
print("未找到 news 模块,跳过")
|
||||
raise SystemExit(0)
|
||||
|
||||
mod_id, content_str = row
|
||||
content = json.loads(content_str) if content_str else {}
|
||||
posts = content.get('posts', [])
|
||||
print(f"home_modules[news].posts 共 {len(posts)} 条快照")
|
||||
|
||||
updated = 0
|
||||
for idx, post in enumerate(posts):
|
||||
if not post:
|
||||
continue
|
||||
title = post.get('title')
|
||||
if not title:
|
||||
continue
|
||||
art = conn.execute(
|
||||
text("SELECT id, title, slug, summary, cover_image FROM articles WHERE title = :t LIMIT 1"),
|
||||
{"t": title}
|
||||
).fetchone()
|
||||
if art:
|
||||
old_slug = post.get('slug')
|
||||
post['id'] = art[0]
|
||||
post['slug'] = art[2]
|
||||
post['summary'] = art[3] or post.get('summary', '')
|
||||
post['cover_image'] = art[4] or post.get('cover_image', '')
|
||||
updated += 1
|
||||
print(f" [{idx}] {title[:40]}... | 老 slug={old_slug} -> 新 slug={art[2]}")
|
||||
else:
|
||||
print(f" [{idx}] {title[:40]}... | 找不到对应的新 article(已清空此快照位)")
|
||||
posts[idx] = None # 置空该位
|
||||
|
||||
content['posts'] = posts
|
||||
new_content_str = json.dumps(content, ensure_ascii=False)
|
||||
conn.execute(
|
||||
text("UPDATE home_modules SET content = :c WHERE id = :id"),
|
||||
{"c": new_content_str, "id": mod_id}
|
||||
)
|
||||
conn.commit()
|
||||
print(f"\n更新完成:成功映射 {updated} 条;写入 home_modules.id={mod_id}")
|
||||
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
一次性修复三个产品页的图片:
|
||||
1. 从 website-old 拷贝图片到 202605 目录(加前缀避免重名)
|
||||
2. 更新数据库内容中的图片路径
|
||||
"""
|
||||
import sqlite3
|
||||
import shutil
|
||||
import os
|
||||
import re
|
||||
|
||||
BASE = "/var/www/new-media-platform/website-new/storage/admin/images"
|
||||
DEST = f"{BASE}/202605"
|
||||
os.makedirs(DEST, exist_ok=True)
|
||||
|
||||
DB = "/var/www/new-media-platform/website-new/backend/cms.db"
|
||||
|
||||
# 三个产品:数据库ID, 旧路径前缀, 源图片目录, 新文件名前缀
|
||||
PRODUCTS = [
|
||||
(345, "products/niaojianyun", "/home/ubuntu/website-old/src/assets/img/product-niaojianyun", "njjy"),
|
||||
(346, "products/wudaifu", "/home/ubuntu/website-old/src/assets/img/product-wudaifu", "wdf"),
|
||||
(347, "products/wushizhi", "/home/ubuntu/website-old/src/assets/img/product-wushizhi", "wsz"),
|
||||
]
|
||||
|
||||
conn = sqlite3.connect(DB)
|
||||
cur = conn.cursor()
|
||||
|
||||
for aid, old_prefix, src_dir, prefix in PRODUCTS:
|
||||
print(f"\n=== 处理 ID={aid}, 前缀={prefix} ===")
|
||||
|
||||
# 获取文章内容
|
||||
cur.execute("SELECT content FROM articles WHERE id=?", (aid,))
|
||||
row = cur.fetchone()
|
||||
if not row or not row[0]:
|
||||
print(f" ✗ 文章 {aid} 无内容")
|
||||
continue
|
||||
|
||||
content = row[0]
|
||||
|
||||
# 找出所有需要迁移的图片
|
||||
old_pattern = rf'/storage/admin/images/{old_prefix}/([^"\']+)'
|
||||
matches = list(re.finditer(old_pattern, content))
|
||||
print(f" 找到 {len(matches)} 个图片引用")
|
||||
|
||||
new_content = content
|
||||
for m in matches:
|
||||
old_filename = m.group(1)
|
||||
new_filename = f"{prefix}-{old_filename}"
|
||||
src_path = os.path.join(src_dir, old_filename)
|
||||
dst_path = os.path.join(DEST, new_filename)
|
||||
old_full = m.group(0)
|
||||
new_full = f"/storage/admin/images/202605/{new_filename}"
|
||||
|
||||
# 拷贝文件
|
||||
if os.path.exists(src_path):
|
||||
shutil.copy2(src_path, dst_path)
|
||||
print(f" ✓ {old_filename} → {new_filename}")
|
||||
else:
|
||||
# 也许已经在 products/ 目录下
|
||||
alt_src = os.path.join(BASE, old_prefix, old_filename)
|
||||
if os.path.exists(alt_src):
|
||||
shutil.copy2(alt_src, dst_path)
|
||||
print(f" ✓ {old_filename} → {new_filename} (from products/)")
|
||||
else:
|
||||
print(f" ✗ 源文件不存在: {old_filename}")
|
||||
continue
|
||||
|
||||
# 替换路径
|
||||
new_content = new_content.replace(old_full, new_full)
|
||||
|
||||
# 更新数据库
|
||||
cur.execute("UPDATE articles SET content=? WHERE id=?", (new_content, aid))
|
||||
print(f" 数据库已更新 (ID={aid})")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print("\n=== 完成 ===")
|
||||
print(f"所有图片已拷贝到: {DEST}/")
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Initialize database with default data
|
||||
Usage: python init_db.py
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add app to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from app.database import engine, SessionLocal, Base
|
||||
from app.models import Admin, HomeModule, NavMenu, Setting
|
||||
from app.auth import get_password_hash
|
||||
import json
|
||||
|
||||
|
||||
def init_database():
|
||||
print("Creating database tables...")
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Check if already initialized
|
||||
if db.query(Admin).first():
|
||||
print("Database already initialized!")
|
||||
return
|
||||
|
||||
print("Creating default admin...")
|
||||
admin = Admin(
|
||||
username="admin",
|
||||
password_hash=get_password_hash("abcd1234")
|
||||
)
|
||||
db.add(admin)
|
||||
|
||||
print("Creating default home modules...")
|
||||
default_modules = [
|
||||
HomeModule(
|
||||
name="轮播图",
|
||||
code="banner",
|
||||
type="banner",
|
||||
sort_order=1,
|
||||
is_enabled=True,
|
||||
is_fixed=True,
|
||||
content=json.dumps({"slides": []}),
|
||||
style_config=json.dumps({"backgroundColor": "#ffffff"})
|
||||
),
|
||||
HomeModule(
|
||||
name="产品优势",
|
||||
code="features",
|
||||
type="features",
|
||||
sort_order=2,
|
||||
is_enabled=True,
|
||||
is_fixed=False,
|
||||
title="为什么选择我们",
|
||||
content=json.dumps({"items": []}),
|
||||
style_config=json.dumps({"backgroundColor": "#FEF9E7"})
|
||||
),
|
||||
HomeModule(
|
||||
name="新闻动态",
|
||||
code="news",
|
||||
type="news",
|
||||
sort_order=3,
|
||||
is_enabled=True,
|
||||
is_fixed=False,
|
||||
title="新闻动态",
|
||||
content=json.dumps({"count": 4}),
|
||||
style_config=json.dumps({"backgroundColor": "#ffffff"})
|
||||
),
|
||||
HomeModule(
|
||||
name="合作伙伴",
|
||||
code="partners",
|
||||
type="partners",
|
||||
sort_order=4,
|
||||
is_enabled=True,
|
||||
is_fixed=False,
|
||||
title="合作伙伴",
|
||||
content=json.dumps({"logos": []}),
|
||||
style_config=json.dumps({"backgroundColor": "#FEF9E7"})
|
||||
),
|
||||
HomeModule(
|
||||
name="APP下载",
|
||||
code="downloads",
|
||||
type="downloads",
|
||||
sort_order=5,
|
||||
is_enabled=True,
|
||||
is_fixed=False,
|
||||
title="下载我们的APP",
|
||||
content=json.dumps({"apps": []}),
|
||||
style_config=json.dumps({"backgroundColor": "#ffffff"})
|
||||
),
|
||||
HomeModule(
|
||||
name="联系我们",
|
||||
code="contact",
|
||||
type="contact",
|
||||
sort_order=6,
|
||||
is_enabled=True,
|
||||
is_fixed=False,
|
||||
title="联系我们",
|
||||
content=json.dumps({"phone": "", "email": "", "address": ""}),
|
||||
style_config=json.dumps({"backgroundColor": "#FEF9E7"})
|
||||
),
|
||||
HomeModule(
|
||||
name="专家团队",
|
||||
code="experts",
|
||||
type="experts",
|
||||
sort_order=7,
|
||||
is_enabled=True,
|
||||
is_fixed=False,
|
||||
title="专家团队",
|
||||
content=json.dumps({"view_more_link": ""}),
|
||||
style_config=json.dumps({"backgroundColor": "#ffffff"})
|
||||
)
|
||||
]
|
||||
for module in default_modules:
|
||||
db.add(module)
|
||||
|
||||
print("Creating default nav menus...")
|
||||
default_menus = [
|
||||
NavMenu(name="首页", link="/", sort_order=1, is_enabled=True),
|
||||
NavMenu(name="产品介绍", link="/product", sort_order=2, is_enabled=True),
|
||||
NavMenu(name="新闻动态", link="/news", sort_order=3, is_enabled=True),
|
||||
NavMenu(name="关于我们", link="/about", sort_order=4, is_enabled=True),
|
||||
NavMenu(name="联系我们", link="/contact", sort_order=5, is_enabled=True),
|
||||
]
|
||||
for menu in default_menus:
|
||||
db.add(menu)
|
||||
|
||||
print("Creating default settings...")
|
||||
default_settings = [
|
||||
Setting(key="site_name", value="飞点健康", group="general", description="网站名称"),
|
||||
Setting(key="site_logo", value="/logo.png", group="general", description="网站Logo"),
|
||||
Setting(key="company_name", value="深圳飞点健康科技有限公司", group="company", description="公司名称"),
|
||||
Setting(key="copyright", value="© 2024 飞点健康 版权所有", group="general", description="版权信息"),
|
||||
]
|
||||
for setting in default_settings:
|
||||
db.add(setting)
|
||||
|
||||
db.commit()
|
||||
print("✅ Database initialized successfully!")
|
||||
print("\nDefault admin credentials:")
|
||||
print(" Username: admin")
|
||||
print(" Password: abcd1234")
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"❌ Error: {e}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_database()
|
||||
@@ -0,0 +1,14 @@
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('cms.db')
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
"INSERT INTO nav_menus (name, link, sort_order, is_enabled, is_external, parent_id) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
('公司介绍', '/about/company', 1, 1, 0, 4)
|
||||
)
|
||||
c.execute(
|
||||
"INSERT INTO nav_menus (name, link, sort_order, is_enabled, is_external, parent_id) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
('团队风采', '/about/team', 2, 1, 0, 4)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print('test submenus inserted')
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""迁移 home_modules 表中的图片路径"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
engine = create_engine(settings.DATABASE_URL.replace('+aiosqlite', ''), echo=False)
|
||||
|
||||
|
||||
def update_banner_paths():
|
||||
"""更新 banner 模块中的图片路径"""
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text('SELECT id, code, content FROM home_modules WHERE code = "banner"'))
|
||||
row = result.fetchone()
|
||||
|
||||
if not row:
|
||||
print("未找到 banner 模块")
|
||||
return
|
||||
|
||||
module_id = row[0]
|
||||
code = row[1]
|
||||
content = json.loads(row[2]) if row[2] else {}
|
||||
|
||||
slides = content.get('slides', [])
|
||||
updated = False
|
||||
|
||||
for slide in slides:
|
||||
old_image = slide.get('image', '')
|
||||
if old_image.startswith('/uploads/'):
|
||||
# 转换路径
|
||||
new_image = old_image.replace('/uploads/', '/storage/admin/images/')
|
||||
slide['image'] = new_image
|
||||
print(f" 更新: {old_image} -> {new_image}")
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
new_content = json.dumps(content, ensure_ascii=False)
|
||||
conn.execute(
|
||||
text('UPDATE home_modules SET content = :content WHERE id = :id'),
|
||||
{'content': new_content, 'id': module_id}
|
||||
)
|
||||
conn.commit()
|
||||
print(f"\n✅ banner 模块已更新")
|
||||
else:
|
||||
print("✅ banner 模块无需更新")
|
||||
|
||||
|
||||
def update_features_paths():
|
||||
"""更新 features 模块中的图片路径"""
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text('SELECT id, code, content FROM home_modules WHERE code = "features"'))
|
||||
row = result.fetchone()
|
||||
|
||||
if not row:
|
||||
print("未找到 features 模块")
|
||||
return
|
||||
|
||||
module_id = row[0]
|
||||
content = json.loads(row[2]) if row[2] else {}
|
||||
|
||||
items = content.get('items', [])
|
||||
updated = False
|
||||
|
||||
for item in items:
|
||||
# 更新 icon 字段
|
||||
old_icon = item.get('icon', '')
|
||||
if old_icon.startswith('/uploads/') or old_icon.startswith('/images/'):
|
||||
if old_icon.startswith('/uploads/'):
|
||||
new_icon = old_icon.replace('/uploads/', '/storage/admin/images/')
|
||||
else:
|
||||
new_icon = old_icon.replace('/images/', '/storage/admin/images/')
|
||||
item['icon'] = new_icon
|
||||
print(f" 更新 icon: {old_icon} -> {new_icon}")
|
||||
updated = True
|
||||
|
||||
# 更新 media_url 字段
|
||||
old_media = item.get('media_url', '')
|
||||
if old_media.startswith('/uploads/') or old_media.startswith('/images/'):
|
||||
if old_media.startswith('/uploads/'):
|
||||
new_media = old_media.replace('/uploads/', '/storage/admin/images/')
|
||||
else:
|
||||
new_media = old_media.replace('/images/', '/storage/admin/images/')
|
||||
item['media_url'] = new_media
|
||||
print(f" 更新 media_url: {old_media} -> {new_media}")
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
new_content = json.dumps(content, ensure_ascii=False)
|
||||
conn.execute(
|
||||
text('UPDATE home_modules SET content = :content WHERE id = :id'),
|
||||
{'content': new_content, 'id': module_id}
|
||||
)
|
||||
conn.commit()
|
||||
print(f"\n✅ features 模块已更新")
|
||||
else:
|
||||
print("✅ features 模块无需更新")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("迁移 home_modules 图片路径")
|
||||
print("=" * 60)
|
||||
|
||||
update_banner_paths()
|
||||
update_features_paths()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("迁移完成")
|
||||
print("=" * 60)
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
图片存储方案迁移脚本
|
||||
将图片从旧的层级目录结构迁移到新的扁平结构
|
||||
|
||||
旧结构: /{module}/{year}/{month}/{filename}
|
||||
/{module}/{year}/{month}/thumbnails/thumb_{filename}.jpg
|
||||
新结构: /{filename}
|
||||
/thumbnails/{filename_stem}.jpg
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
|
||||
# 添加项目路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# 数据库路径
|
||||
DB_PATH = "./cms.db"
|
||||
UPLOAD_DIR = Path(settings.UPLOAD_DIR)
|
||||
|
||||
def migrate_images():
|
||||
"""迁移图片路径"""
|
||||
engine = create_engine(f"sqlite:///{DB_PATH}")
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# 获取所有图片记录
|
||||
result = db.execute(text("SELECT id, file_path, thumb_path, filename FROM images"))
|
||||
images = result.fetchall()
|
||||
|
||||
print(f"找到 {len(images)} 条图片记录需要迁移")
|
||||
|
||||
migrated_count = 0
|
||||
skipped_count = 0
|
||||
error_count = 0
|
||||
|
||||
for img in images:
|
||||
try:
|
||||
img_id = img[0]
|
||||
old_file_path = img[1] # e.g., /news/2026/04/20260420_121931_b91c5d3a.jpg
|
||||
old_thumb_path = img[2] # e.g., /news/2026/04/thumbnails/thumb_20260420_121931_b91c5d3a.jpg.jpg
|
||||
filename = img[3] # e.g., 20260420_121931_b91c5d3a.jpg
|
||||
|
||||
# 如果已经是新格式,跳过
|
||||
if old_file_path.startswith("/") and "/" not in old_file_path.lstrip("/"):
|
||||
print(f"跳过 (已是新格式): {old_file_path}")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# 新的路径格式
|
||||
new_file_path = f"/{filename}"
|
||||
new_thumb_path = f"/thumbnails/{Path(filename).stem}.jpg" if old_thumb_path else None
|
||||
|
||||
# 物理文件路径
|
||||
old_file_full = UPLOAD_DIR / old_file_path.lstrip("/")
|
||||
new_file_full = UPLOAD_DIR / filename
|
||||
|
||||
# 移动原图文件
|
||||
if old_file_full.exists() and old_file_full != new_file_full:
|
||||
try:
|
||||
shutil.move(str(old_file_full), str(new_file_full))
|
||||
print(f"移动文件: {old_file_full} -> {new_file_full}")
|
||||
except Exception as e:
|
||||
print(f"移动文件失败 {old_file_full}: {e}")
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
# 处理缩略图
|
||||
if old_thumb_path:
|
||||
old_thumb_full = UPLOAD_DIR / old_thumb_path.lstrip("/")
|
||||
new_thumb_full = UPLOAD_DIR / "thumbnails" / f"{Path(filename).stem}.jpg"
|
||||
|
||||
if old_thumb_full.exists() and old_thumb_full != new_thumb_full:
|
||||
try:
|
||||
shutil.move(str(old_thumb_full), str(new_thumb_full))
|
||||
print(f"移动缩略图: {old_thumb_full} -> {new_thumb_full}")
|
||||
except Exception as e:
|
||||
print(f"移动缩略图失败 {old_thumb_full}: {e}")
|
||||
|
||||
# 更新数据库
|
||||
db.execute(
|
||||
text("UPDATE images SET file_path = :new_path, thumb_path = :new_thumb WHERE id = :id"),
|
||||
{"new_path": new_file_path, "new_thumb": new_thumb_path, "id": img_id}
|
||||
)
|
||||
|
||||
migrated_count += 1
|
||||
print(f"迁移成功 ID={img_id}: {old_file_path} -> {new_file_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"迁移失败 ID={img_id}: {e}")
|
||||
error_count += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
print("\n" + "="*60)
|
||||
print(f"迁移完成!")
|
||||
print(f"成功: {migrated_count}")
|
||||
print(f"跳过 (已是新格式): {skipped_count}")
|
||||
print(f"失败: {error_count}")
|
||||
print("="*60)
|
||||
|
||||
# 清理空目录
|
||||
cleanup_empty_dirs(UPLOAD_DIR)
|
||||
|
||||
except Exception as e:
|
||||
print(f"迁移过程出错: {e}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def cleanup_empty_dirs(root_dir: Path):
|
||||
"""清理空目录"""
|
||||
removed_count = 0
|
||||
for dirpath, dirnames, filenames in os.walk(str(root_dir), topdown=False):
|
||||
dir_path = Path(dirpath)
|
||||
# 跳过根目录和 thumbnails 目录
|
||||
if dir_path == root_dir or dir_path.name == "thumbnails":
|
||||
continue
|
||||
|
||||
# 检查是否为空
|
||||
try:
|
||||
if dir_path.exists() and not any(dir_path.iterdir()):
|
||||
dir_path.rmdir()
|
||||
print(f"删除空目录: {dir_path}")
|
||||
removed_count += 1
|
||||
except Exception as e:
|
||||
print(f"删除目录失败 {dir_path}: {e}")
|
||||
|
||||
print(f"清理完成,共删除 {removed_count} 个空目录")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("图片存储方案迁移工具")
|
||||
print("="*60)
|
||||
print(f"数据库: {DB_PATH}")
|
||||
print(f"上传目录: {UPLOAD_DIR}")
|
||||
print("="*60)
|
||||
|
||||
confirm = input("\n确定要开始迁移吗? (yes/no): ")
|
||||
if confirm.lower() == "yes":
|
||||
migrate_images()
|
||||
else:
|
||||
print("已取消迁移")
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
图片存储方案迁移脚本 - 自动执行版
|
||||
将图片从旧的层级目录结构迁移到新的扁平结构
|
||||
|
||||
旧结构: /{module}/{year}/{month}/{filename}
|
||||
/{module}/{year}/{month}/thumbnails/thumb_{filename}.jpg
|
||||
新结构: /{filename}
|
||||
/thumbnails/{filename_stem}.jpg
|
||||
|
||||
使用方法: python migrate_images_auto.py [--dry-run]
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# 添加项目路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# 数据库路径
|
||||
DB_PATH = "./cms.db"
|
||||
UPLOAD_DIR = Path(settings.UPLOAD_DIR)
|
||||
|
||||
|
||||
def migrate_images(dry_run=False):
|
||||
"""迁移图片路径"""
|
||||
engine = create_engine(f"sqlite:///{DB_PATH}")
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
db = SessionLocal()
|
||||
|
||||
migrated = []
|
||||
skipped = []
|
||||
errors = []
|
||||
|
||||
try:
|
||||
# 获取所有图片记录
|
||||
result = db.execute(text("SELECT id, file_path, thumb_path, filename FROM images"))
|
||||
images = result.fetchall()
|
||||
|
||||
print(f"找到 {len(images)} 条图片记录")
|
||||
|
||||
for img in images:
|
||||
try:
|
||||
img_id = img[0]
|
||||
old_file_path = img[1]
|
||||
old_thumb_path = img[2]
|
||||
filename = img[3]
|
||||
|
||||
# 如果已经是新格式(只有一级路径),跳过
|
||||
if old_file_path and old_file_path.startswith("/"):
|
||||
path_parts = old_file_path.lstrip("/").split("/")
|
||||
if len(path_parts) == 1 and "/thumbnails/" not in old_file_path:
|
||||
skipped.append({"id": img_id, "path": old_file_path})
|
||||
continue
|
||||
|
||||
# 新的路径格式
|
||||
new_file_path = f"/{filename}"
|
||||
new_thumb_path = None
|
||||
if old_thumb_path:
|
||||
new_thumb_path = f"/thumbnails/{Path(filename).stem}.jpg"
|
||||
|
||||
if dry_run:
|
||||
print(f"[DRY RUN] ID={img_id}: {old_file_path} -> {new_file_path}")
|
||||
if old_thumb_path:
|
||||
print(f"[DRY RUN] 缩略图: {old_thumb_path} -> {new_thumb_path}")
|
||||
continue
|
||||
|
||||
# 物理文件路径
|
||||
old_file_full = UPLOAD_DIR / old_file_path.lstrip("/") if old_file_path else None
|
||||
new_file_full = UPLOAD_DIR / filename
|
||||
|
||||
# 移动原图文件
|
||||
if old_file_full and old_file_full.exists() and old_file_full != new_file_full:
|
||||
try:
|
||||
shutil.move(str(old_file_full), str(new_file_full))
|
||||
print(f"移动文件: {old_file_full} -> {new_file_full}")
|
||||
except Exception as e:
|
||||
print(f"移动文件失败 {old_file_full}: {e}")
|
||||
errors.append({"id": img_id, "error": f"移动原图失败: {e}"})
|
||||
continue
|
||||
|
||||
# 处理缩略图
|
||||
if old_thumb_path:
|
||||
old_thumb_full = UPLOAD_DIR / old_thumb_path.lstrip("/")
|
||||
new_thumb_full = UPLOAD_DIR / "thumbnails" / f"{Path(filename).stem}.jpg"
|
||||
|
||||
if old_thumb_full.exists() and old_thumb_full != new_thumb_full:
|
||||
try:
|
||||
shutil.move(str(old_thumb_full), str(new_thumb_full))
|
||||
print(f"移动缩略图: {old_thumb_full} -> {new_thumb_full}")
|
||||
except Exception as e:
|
||||
print(f"移动缩略图失败 {old_thumb_full}: {e}")
|
||||
|
||||
# 更新数据库
|
||||
db.execute(
|
||||
text("UPDATE images SET file_path = :new_path, thumb_path = :new_thumb WHERE id = :id"),
|
||||
{"new_path": new_file_path, "new_thumb": new_thumb_path, "id": img_id}
|
||||
)
|
||||
|
||||
migrated.append({"id": img_id, "new_path": new_file_path})
|
||||
print(f"迁移成功 ID={img_id}: {old_file_path} -> {new_file_path}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"迁移失败 ID={img_id}: {e}"
|
||||
print(error_msg)
|
||||
errors.append({"id": img_id, "error": str(e)})
|
||||
|
||||
if not dry_run:
|
||||
db.commit()
|
||||
|
||||
print("\n" + "="*60)
|
||||
print(f"迁移结果:")
|
||||
print(f"成功: {len(migrated)}")
|
||||
print(f"跳过 (已是新格式): {len(skipped)}")
|
||||
print(f"失败: {len(errors)}")
|
||||
print("="*60)
|
||||
|
||||
if errors:
|
||||
print("\n错误详情:")
|
||||
for err in errors:
|
||||
print(f" ID={err['id']}: {err['error']}")
|
||||
|
||||
# 清理空目录(仅在非 dry-run 时)
|
||||
if not dry_run:
|
||||
cleanup_empty_dirs(UPLOAD_DIR)
|
||||
|
||||
except Exception as e:
|
||||
print(f"迁移过程出错: {e}")
|
||||
if not dry_run:
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def cleanup_empty_dirs(root_dir: Path):
|
||||
"""清理空目录"""
|
||||
removed_count = 0
|
||||
for dirpath, dirnames, filenames in os.walk(str(root_dir), topdown=False):
|
||||
dir_path = Path(dirpath)
|
||||
# 跳过根目录和 thumbnails 目录
|
||||
if dir_path == root_dir or dir_path.name == "thumbnails":
|
||||
continue
|
||||
|
||||
# 检查是否为空
|
||||
try:
|
||||
if dir_path.exists() and not any(dir_path.iterdir()):
|
||||
dir_path.rmdir()
|
||||
print(f"删除空目录: {dir_path}")
|
||||
removed_count += 1
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
if removed_count > 0:
|
||||
print(f"清理完成,共删除 {removed_count} 个空目录")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="图片存储方案迁移工具")
|
||||
parser.add_argument("--dry-run", action="store_true", help="试运行,不实际执行迁移")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("图片存储方案迁移工具")
|
||||
print("="*60)
|
||||
print(f"数据库: {DB_PATH}")
|
||||
print(f"上传目录: {UPLOAD_DIR}")
|
||||
print(f"模式: {'试运行' if args.dry_run else '实际执行'}")
|
||||
print("="*60)
|
||||
|
||||
migrate_images(dry_run=args.dry_run)
|
||||
@@ -0,0 +1,16 @@
|
||||
import sqlite3
|
||||
import json
|
||||
|
||||
conn = sqlite3.connect('cms.db')
|
||||
c = conn.cursor()
|
||||
|
||||
# 把 news 模块的 content 从 {"count": 4} 改为 {"posts": [null, null, null, null]}
|
||||
c.execute("UPDATE home_modules SET content = ? WHERE type = 'news'",
|
||||
(json.dumps({"posts": [None, None, None, None]}),))
|
||||
print(f'已更新 {c.rowcount} 条 news 模块')
|
||||
conn.commit()
|
||||
|
||||
# 验证
|
||||
c.execute("SELECT content FROM home_modules WHERE type='news'")
|
||||
print('新 content:', c.fetchone()[0])
|
||||
conn.close()
|
||||
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
新闻资讯模块迁移脚本
|
||||
将老的新闻分类(news_categories)和新闻文章(news_posts)迁移到新的栏目(columns)和内容(articles)体系
|
||||
|
||||
执行方式: cd backend && .\venv-win\Scripts\python.exe migrate_news_to_columns.py
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
from sqlalchemy import create_engine, text
|
||||
from pypinyin import lazy_pinyin
|
||||
import re
|
||||
|
||||
DB_PATH = "sqlite:///cms.db"
|
||||
engine = create_engine(DB_PATH)
|
||||
|
||||
|
||||
def generate_slug(text: str) -> str:
|
||||
"""将中文标题转为拼音 slug"""
|
||||
if not text:
|
||||
return f"article-{int(time.time())}"
|
||||
py = lazy_pinyin(text, style=0)
|
||||
slug = "-".join(py)
|
||||
slug = re.sub(r"[^a-zA-Z0-9-]", "", slug).lower()
|
||||
slug = re.sub(r"-+", "-", slug).strip("-")
|
||||
return slug or f"article-{int(time.time())}"
|
||||
|
||||
|
||||
def migrate():
|
||||
with engine.connect() as conn:
|
||||
trans = conn.begin()
|
||||
try:
|
||||
print("=" * 60)
|
||||
print("新闻资讯模块迁移开始")
|
||||
print("=" * 60)
|
||||
|
||||
# ========== 阶段 0: 清空现有数据 ==========
|
||||
print("\n[阶段 0] 清空 columns 和 articles 表...")
|
||||
# 先删 articles(外键依赖)
|
||||
result = conn.execute(text("DELETE FROM articles"))
|
||||
print(f" 已清空 articles 表")
|
||||
# 再删 columns
|
||||
result = conn.execute(text("DELETE FROM columns"))
|
||||
print(f" 已清空 columns 表")
|
||||
|
||||
# ========== 阶段 1: 创建"新闻资讯"顶级栏目 ==========
|
||||
print("\n[阶段 1] 创建'新闻资讯'顶级栏目...")
|
||||
result = conn.execute(text("""
|
||||
INSERT INTO columns (name, slug, description, parent_id, sort_order, is_enabled, display_mode, layout_style)
|
||||
VALUES (:name, :slug, :description, NULL, 0, 1, 'list', 'left')
|
||||
"""), {
|
||||
"name": "新闻资讯",
|
||||
"slug": "xin-wen-zi-xun",
|
||||
"description": ""
|
||||
})
|
||||
# 获取刚插入的 ID
|
||||
row = conn.execute(text("SELECT id FROM columns WHERE slug = 'xin-wen-zi-xun'")).fetchone()
|
||||
news_top_id = row[0]
|
||||
print(f" 顶级栏目 ID: {news_top_id}, slug: xin-wen-zi-xun")
|
||||
|
||||
# ========== 阶段 2: 迁移启用的 news_categories ==========
|
||||
print("\n[阶段 2] 迁移启用的 news_categories(作为二级子栏目)...")
|
||||
categories = conn.execute(text("""
|
||||
SELECT id, name, slug, description, sort_order, is_enabled
|
||||
FROM news_categories
|
||||
WHERE is_enabled = 1
|
||||
ORDER BY id
|
||||
""")).fetchall()
|
||||
|
||||
cat_id_map = {} # old_id -> new_id
|
||||
for cat in categories:
|
||||
old_id, name, slug, description, sort_order, is_enabled = cat
|
||||
result = conn.execute(text("""
|
||||
INSERT INTO columns (name, slug, description, parent_id, sort_order, is_enabled, display_mode, layout_style)
|
||||
VALUES (:name, :slug, :description, :parent_id, :sort_order, :is_enabled, 'list', 'left')
|
||||
"""), {
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"description": description or "",
|
||||
"parent_id": news_top_id,
|
||||
"sort_order": sort_order or 0,
|
||||
"is_enabled": is_enabled
|
||||
})
|
||||
new_id = conn.execute(text("SELECT last_insert_rowid()")).fetchone()[0]
|
||||
cat_id_map[old_id] = new_id
|
||||
print(f" [{old_id}] {name} -> new_id={new_id}")
|
||||
|
||||
print(f" 共迁移 {len(cat_id_map)} 个子栏目")
|
||||
|
||||
# ========== 阶段 3: 迁移 news_posts ==========
|
||||
print("\n[阶段 3] 迁移 news_posts(重新生成拼音 slug)...")
|
||||
posts = conn.execute(text("""
|
||||
SELECT id, title, slug, content, content_type, summary, cover_image,
|
||||
category_id, is_published, is_draft, view_count, created_at, updated_at
|
||||
FROM news_posts
|
||||
ORDER BY id
|
||||
""")).fetchall()
|
||||
|
||||
# 用于检测 slug 冲突
|
||||
used_slugs = set()
|
||||
slug_conflicts = 0
|
||||
migrated_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for post in posts:
|
||||
(old_id, title, old_slug, content, content_type, summary, cover_image,
|
||||
category_id, is_published, is_draft, view_count, created_at, updated_at) = post
|
||||
|
||||
# 只迁移已发布或有效的文章
|
||||
# 状态映射: is_published=True -> published, 否则 draft
|
||||
status = "published" if is_published else "draft"
|
||||
|
||||
# 重新生成拼音 slug
|
||||
base_slug = generate_slug(title)
|
||||
new_slug = base_slug
|
||||
suffix = 1
|
||||
while new_slug in used_slugs:
|
||||
new_slug = f"{base_slug}-{suffix}"
|
||||
suffix += 1
|
||||
slug_conflicts += 1
|
||||
used_slugs.add(new_slug)
|
||||
|
||||
# 栏目映射
|
||||
new_column_id = cat_id_map.get(category_id)
|
||||
if not new_column_id:
|
||||
print(f" 跳过 post_id={old_id},category_id={category_id} 未找到映射")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
conn.execute(text("""
|
||||
INSERT INTO articles (title, slug, content, content_type, summary, cover_image,
|
||||
column_id, author, status, sort_order, view_count, created_at, updated_at)
|
||||
VALUES (:title, :slug, :content, :content_type, :summary, :cover_image,
|
||||
:column_id, '', :status, 0, :view_count, :created_at, :updated_at)
|
||||
"""), {
|
||||
"title": title,
|
||||
"slug": new_slug,
|
||||
"content": content or "",
|
||||
"content_type": content_type or "markdown",
|
||||
"summary": summary or "",
|
||||
"cover_image": cover_image or "",
|
||||
"column_id": new_column_id,
|
||||
"status": status,
|
||||
"view_count": view_count or 0,
|
||||
"created_at": created_at,
|
||||
"updated_at": updated_at
|
||||
})
|
||||
migrated_count += 1
|
||||
|
||||
if migrated_count % 50 == 0:
|
||||
print(f" 已迁移 {migrated_count} / {len(posts)} 篇...")
|
||||
|
||||
print(f" 共迁移 {migrated_count} 篇,跳过 {skipped_count} 篇,slug 冲突解决 {slug_conflicts} 次")
|
||||
|
||||
# ========== 阶段 4: 验证 ==========
|
||||
print("\n[阶段 4] 验证迁移结果...")
|
||||
col_count = conn.execute(text("SELECT COUNT(*) FROM columns")).fetchone()[0]
|
||||
art_count = conn.execute(text("SELECT COUNT(*) FROM articles")).fetchone()[0]
|
||||
top_col = conn.execute(text("SELECT id, name, slug FROM columns WHERE parent_id IS NULL")).fetchone()
|
||||
child_cols = conn.execute(text("SELECT COUNT(*) FROM columns WHERE parent_id IS NOT NULL")).fetchone()[0]
|
||||
|
||||
print(f" columns 表总数: {col_count}")
|
||||
print(f" 顶级栏目: {top_col}")
|
||||
print(f" 子栏目数: {child_cols}")
|
||||
print(f" articles 表总数: {art_count}")
|
||||
|
||||
# 每个子栏目下的文章数
|
||||
print("\n 各子栏目文章分布:")
|
||||
for row in conn.execute(text("""
|
||||
SELECT c.name, COUNT(a.id) as cnt
|
||||
FROM columns c
|
||||
LEFT JOIN articles a ON a.column_id = c.id
|
||||
WHERE c.parent_id IS NOT NULL
|
||||
GROUP BY c.id
|
||||
ORDER BY cnt DESC
|
||||
""")):
|
||||
print(f" {row[0]}: {row[1]} 篇")
|
||||
|
||||
trans.commit()
|
||||
print("\n" + "=" * 60)
|
||||
print("迁移成功完成!")
|
||||
print("=" * 60)
|
||||
|
||||
except Exception as e:
|
||||
trans.rollback()
|
||||
print(f"\n迁移失败,已回滚: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
数据库迁移脚本:将旧的上传路径迁移到新的存储结构
|
||||
使用同步 SQLAlchemy 进行数据库操作
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend to path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from sqlalchemy import create_engine, select, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# 创建同步引擎
|
||||
engine = create_engine(settings.DATABASE_URL.replace('+aiosqlite', ''), echo=False)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
def migrate_image_paths():
|
||||
"""迁移 Image 表中的路径"""
|
||||
session = SessionLocal()
|
||||
try:
|
||||
# 获取所有图片
|
||||
result = session.execute(text("SELECT id, file_path, thumb_path FROM images"))
|
||||
images = result.fetchall()
|
||||
|
||||
updated_count = 0
|
||||
for img in images:
|
||||
img_id = img[0]
|
||||
old_file_path = img[1]
|
||||
old_thumb_path = img[2]
|
||||
|
||||
new_file_path = old_file_path
|
||||
new_thumb_path = old_thumb_path
|
||||
|
||||
# 迁移 file_path
|
||||
if old_file_path and not old_file_path.startswith('/storage/'):
|
||||
if old_file_path.startswith('/'):
|
||||
# 旧格式: /filename.jpg -> /storage/admin/images/filename.jpg
|
||||
new_file_path = f"/storage/admin/images{old_file_path}"
|
||||
else:
|
||||
# 如果没有前导斜杠,添加一个
|
||||
new_file_path = f"/storage/admin/images/{old_file_path}"
|
||||
|
||||
# 迁移 thumb_path
|
||||
if old_thumb_path and not old_thumb_path.startswith('/storage/'):
|
||||
if old_thumb_path.startswith('/'):
|
||||
# 旧格式: /thumbnails/filename.jpg -> /storage/admin/images/thumbnails/filename.jpg
|
||||
if old_thumb_path.startswith('/thumbnails/'):
|
||||
new_thumb_path = f"/storage/admin/images{old_thumb_path}"
|
||||
else:
|
||||
new_thumb_path = f"/storage/admin/images/thumbnails{old_thumb_path}"
|
||||
else:
|
||||
new_thumb_path = f"/storage/admin/images/thumbnails/{old_thumb_path}"
|
||||
|
||||
if old_file_path != new_file_path or old_thumb_path != new_thumb_path:
|
||||
session.execute(
|
||||
text("UPDATE images SET file_path = :fp, thumb_path = :tp WHERE id = :id"),
|
||||
{"fp": new_file_path, "tp": new_thumb_path, "id": img_id}
|
||||
)
|
||||
print(f" Image {img_id}: {old_file_path} -> {new_file_path}")
|
||||
if old_thumb_path != new_thumb_path:
|
||||
print(f" Thumb: {old_thumb_path} -> {new_thumb_path}")
|
||||
updated_count += 1
|
||||
|
||||
session.commit()
|
||||
print(f"\n✅ 迁移了 {updated_count} 张图片的路径")
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def migrate_news_cover_images():
|
||||
"""迁移 NewsPost 表中的封面图片路径"""
|
||||
session = SessionLocal()
|
||||
try:
|
||||
# 获取所有新闻文章
|
||||
result = session.execute(text("SELECT id, cover_image FROM news_posts"))
|
||||
posts = result.fetchall()
|
||||
|
||||
updated_count = 0
|
||||
for post in posts:
|
||||
post_id = post[0]
|
||||
old_cover_image = post[1]
|
||||
|
||||
new_cover_image = old_cover_image
|
||||
|
||||
# 迁移 cover_image
|
||||
if old_cover_image and not old_cover_image.startswith('/storage/'):
|
||||
if old_cover_image.startswith('/'):
|
||||
if old_cover_image.startswith('/uploads/'):
|
||||
# 已经是 /uploads/ 格式,迁移到新的 storage 路径
|
||||
filename = old_cover_image.replace('/uploads/', '')
|
||||
new_cover_image = f"/storage/admin/images/{filename}"
|
||||
else:
|
||||
# 旧格式: /filename.jpg -> /storage/admin/images/filename.jpg
|
||||
new_cover_image = f"/storage/admin/images{old_cover_image}"
|
||||
else:
|
||||
new_cover_image = f"/storage/admin/images/{old_cover_image}"
|
||||
|
||||
if old_cover_image != new_cover_image:
|
||||
session.execute(
|
||||
text("UPDATE news_posts SET cover_image = :ci WHERE id = :id"),
|
||||
{"ci": new_cover_image, "id": post_id}
|
||||
)
|
||||
print(f" Post {post_id}: {old_cover_image} -> {new_cover_image}")
|
||||
updated_count += 1
|
||||
|
||||
session.commit()
|
||||
print(f"\n✅ 迁移了 {updated_count} 篇新闻的封面图片路径")
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("开始数据库路径迁移...")
|
||||
print("=" * 60)
|
||||
|
||||
migrate_image_paths()
|
||||
migrate_news_cover_images()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ 数据库迁移完成!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,10 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn==0.32.0
|
||||
sqlalchemy==2.0.36
|
||||
pydantic-settings==2.6.1
|
||||
python-jose==3.3.0
|
||||
passlib==1.7.4
|
||||
python-multipart==0.0.17
|
||||
qrcode==8.0
|
||||
pillow==11.0.0
|
||||
aiofiles==24.1.0
|
||||
@@ -0,0 +1,12 @@
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
engine = create_engine('sqlite:///cms.db')
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("""
|
||||
UPDATE columns
|
||||
SET description = '了解飞点健康最新动态、行业资讯和健康知识',
|
||||
icon_name = 'Heart'
|
||||
WHERE slug = 'xin-wen-zi-xun'
|
||||
"""))
|
||||
conn.commit()
|
||||
print('Updated 新闻资讯 column: description + icon_name=Heart')
|
||||
@@ -0,0 +1,5 @@
|
||||
import requests
|
||||
|
||||
res = requests.get('http://localhost:10003/api/public/columns/xin-wen-zi-xun')
|
||||
print('Status:', res.status_code)
|
||||
print('Response:', res.json())
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""测试通过点击分页按钮翻页"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
|
||||
def test_click_pagination(menu_id=7):
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={'width': 1920, 'height': 1080})
|
||||
|
||||
url = f"https://niaodaifu.cn/health-information?menu_id={menu_id}"
|
||||
print(f"[打开] {url}")
|
||||
page.goto(url, wait_until='networkidle', timeout=30000)
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
def get_article_ids():
|
||||
soup = BeautifulSoup(page.content(), 'html.parser')
|
||||
ids = []
|
||||
for m in soup.find_all('div', class_='media'):
|
||||
a = m.find('a', class_='media-heading')
|
||||
if a:
|
||||
href = a.get('href', '')
|
||||
match = re.search(r'article_id=(\d+)', href)
|
||||
if match:
|
||||
ids.append(match.group(1))
|
||||
return ids
|
||||
|
||||
ids1 = get_article_ids()
|
||||
print(f"第1页 article_ids: {ids1}")
|
||||
|
||||
# 查找下一页按钮
|
||||
print("\n[分页按钮信息]")
|
||||
next_btn = page.query_selector('button.btn-next')
|
||||
if next_btn:
|
||||
print(f" 找到下一页按钮: {next_btn.get_attribute('class')}")
|
||||
print(f" 是否禁用: {next_btn.get_attribute('disabled')}")
|
||||
else:
|
||||
print(" 未找到 button.btn-next")
|
||||
|
||||
# 查找所有页码按钮
|
||||
pager_btns = page.query_selector_all('.el-pager li')
|
||||
print(f" 页码按钮数量: {len(pager_btns)}")
|
||||
for btn in pager_btns:
|
||||
print(f" - class={btn.get_attribute('class')} text={btn.text_content()}")
|
||||
|
||||
# 点击第2页
|
||||
if pager_btns and len(pager_btns) >= 2:
|
||||
print("\n[点击第2页]")
|
||||
pager_btns[1].click()
|
||||
page.wait_for_timeout(3000)
|
||||
ids2 = get_article_ids()
|
||||
print(f"第2页 article_ids: {ids2}")
|
||||
if ids2 != ids1:
|
||||
print("*** 点击分页成功!内容不同 ***")
|
||||
else:
|
||||
print("!!! 点击后内容未变 !!!")
|
||||
elif next_btn:
|
||||
print("\n[点击下一页按钮]")
|
||||
next_btn.click()
|
||||
page.wait_for_timeout(3000)
|
||||
ids2 = get_article_ids()
|
||||
print(f"第2页 article_ids: {ids2}")
|
||||
if ids2 != ids1:
|
||||
print("*** 点击下一页成功!内容不同 ***")
|
||||
else:
|
||||
print("!!! 点击后内容未变 !!!")
|
||||
|
||||
browser.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
test_click_pagination()
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""测试单篇文章采集"""
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from batch_crawl import (
|
||||
get_article_detail, download_image, html_to_markdown,
|
||||
clean_markdown, insert_image_to_db, save_article, DB_PATH
|
||||
)
|
||||
from playwright.sync_api import sync_playwright
|
||||
import sqlite3
|
||||
|
||||
TEST_URL = "https://niaodaifu.cn/health-information/detail?menu_id=15&article_id=992"
|
||||
CATEGORY_ID = 2 # 科普文章
|
||||
|
||||
print("=" * 60)
|
||||
print("测试采集单篇文章")
|
||||
print("=" * 60)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
|
||||
print(f"\n1. 获取详情页: {TEST_URL}")
|
||||
detail = get_article_detail(page, TEST_URL)
|
||||
print(f" 标题: {detail['title']}")
|
||||
print(f" 来源: {detail['source']}")
|
||||
print(f" 时间: {detail['time']}")
|
||||
print(f" 正文HTML长度: {len(detail['content_html'])}")
|
||||
|
||||
print("\n2. 转换 Markdown...")
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(detail['content_html'], 'html.parser')
|
||||
content_div = soup.find('div', class_='article-content')
|
||||
downloaded_images = {}
|
||||
content_md = html_to_markdown(content_div, TEST_URL, downloaded_images)
|
||||
content_md = clean_markdown(content_md)
|
||||
print(f" Markdown长度: {len(content_md)}")
|
||||
print(f" 下载图片数: {len(downloaded_images)}")
|
||||
for src, path in downloaded_images.items():
|
||||
print(f" {src} -> {path}")
|
||||
|
||||
print("\n3. Markdown 前500字符预览:")
|
||||
print("-" * 40)
|
||||
print(content_md[:500])
|
||||
print("-" * 40)
|
||||
|
||||
print("\n4. 保存到数据库...")
|
||||
article = {
|
||||
'article_id': 992,
|
||||
'title': detail['title'] or '2024吴试纸春节放假安排',
|
||||
'href': TEST_URL,
|
||||
'cover_url': 'https://image.niaodaifu.cn/news/2024-01/d756f6171be42e0000a7f1b7375193da.jpg',
|
||||
'time': detail['time'],
|
||||
}
|
||||
cover_path, cover_info = download_image(article['cover_url'])
|
||||
print(f" 封面图: {cover_path}")
|
||||
if cover_info:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
img_id = insert_image_to_db(conn, cover_info)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f" 封面图入库 ID: {img_id}")
|
||||
|
||||
post_id = save_article(article, detail, CATEGORY_ID, cover_path, content_md)
|
||||
print(f" 文章入库 ID: {post_id}")
|
||||
|
||||
browser.close()
|
||||
|
||||
print("\n测试完成!")
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""测试分页URL格式"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
|
||||
def test_pagination(menu_id=7):
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={'width': 1920, 'height': 1080})
|
||||
|
||||
# 先打开第1页
|
||||
url1 = f"https://niaodaifu.cn/health-information?menu_id={menu_id}"
|
||||
print(f"[第1页] GET {url1}")
|
||||
page.goto(url1, wait_until='networkidle', timeout=30000)
|
||||
page.wait_for_timeout(3000)
|
||||
soup1 = BeautifulSoup(page.content(), 'html.parser')
|
||||
medias1 = soup1.find_all('div', class_='media')
|
||||
ids1 = []
|
||||
for m in medias1:
|
||||
a = m.find('a', class_='media-heading')
|
||||
if a:
|
||||
href = a.get('href', '')
|
||||
match = re.search(r'article_id=(\d+)', href)
|
||||
if match:
|
||||
ids1.append(match.group(1))
|
||||
print(f" 第1页 article_ids: {ids1}")
|
||||
|
||||
# 尝试几种分页URL格式
|
||||
formats = [
|
||||
f"https://niaodaifu.cn/health-information?menu_id={menu_id}&page=2",
|
||||
f"https://niaodaifu.cn/health-information?menu_id={menu_id}&pageNo=2",
|
||||
f"https://niaodaifu.cn/health-information?menu_id={menu_id}&pageIndex=2",
|
||||
]
|
||||
|
||||
for url2 in formats:
|
||||
print(f"\n[第2页] GET {url2}")
|
||||
page.goto(url2, wait_until='networkidle', timeout=30000)
|
||||
page.wait_for_timeout(3000)
|
||||
soup2 = BeautifulSoup(page.content(), 'html.parser')
|
||||
medias2 = soup2.find_all('div', class_='media')
|
||||
ids2 = []
|
||||
for m in medias2:
|
||||
a = m.find('a', class_='media-heading')
|
||||
if a:
|
||||
href = a.get('href', '')
|
||||
match = re.search(r'article_id=(\d+)', href)
|
||||
if match:
|
||||
ids2.append(match.group(1))
|
||||
print(f" 第2页 article_ids: {ids2}")
|
||||
if ids2 != ids1:
|
||||
print(f" *** 分页有效!与第1页不同 ***")
|
||||
break
|
||||
else:
|
||||
print(f" !!! 分页无效,与第1页相同 !!!")
|
||||
|
||||
# 查看分页组件HTML
|
||||
print("\n\n[分页组件HTML]")
|
||||
pager = soup1.find(class_=re.compile('el-pagination'))
|
||||
if pager:
|
||||
print(pager.prettify()[:1000])
|
||||
else:
|
||||
print("未找到 el-pagination")
|
||||
|
||||
browser.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
test_pagination()
|
||||
@@ -0,0 +1,28 @@
|
||||
import sqlite3, json
|
||||
conn = sqlite3.connect('cms.db')
|
||||
c = conn.cursor()
|
||||
|
||||
# 模拟前端保存的 content
|
||||
new_content = {
|
||||
"posts": [
|
||||
{
|
||||
"id": 3,
|
||||
"title": "这5项指标反映糖尿病病情,却被很多糖友忽略了!",
|
||||
"summary": "summary text",
|
||||
"cover_image": "/storage/admin/images/202604/94c0d16c2996bb3ad55b6d9f09a7d7e8.png",
|
||||
"slug": "这5项指标反映糖尿病病情却被很多糖友忽略了",
|
||||
"category_name": "科普文章",
|
||||
"created_at": "2026-04-20"
|
||||
},
|
||||
None, None, None
|
||||
]
|
||||
}
|
||||
|
||||
c.execute("UPDATE home_modules SET content = ? WHERE type = 'news'",
|
||||
(json.dumps(new_content, ensure_ascii=False),))
|
||||
conn.commit()
|
||||
print(f'updated {c.rowcount} rows')
|
||||
|
||||
c.execute("SELECT content FROM home_modules WHERE type='news'")
|
||||
print('saved:', c.fetchone()[0])
|
||||
conn.close()
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */
|
||||
|
||||
/* Greenlet object interface */
|
||||
|
||||
#ifndef Py_GREENLETOBJECT_H
|
||||
#define Py_GREENLETOBJECT_H
|
||||
|
||||
|
||||
#include <Python.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* This is deprecated and undocumented. It does not change. */
|
||||
#define GREENLET_VERSION "1.0.0"
|
||||
|
||||
#ifndef GREENLET_MODULE
|
||||
#define implementation_ptr_t void*
|
||||
#endif
|
||||
|
||||
typedef struct _greenlet {
|
||||
PyObject_HEAD
|
||||
PyObject* weakreflist;
|
||||
PyObject* dict;
|
||||
implementation_ptr_t pimpl;
|
||||
} PyGreenlet;
|
||||
|
||||
#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type))
|
||||
|
||||
|
||||
/* C API functions */
|
||||
|
||||
/* Total number of symbols that are exported */
|
||||
#define PyGreenlet_API_pointers 12
|
||||
|
||||
#define PyGreenlet_Type_NUM 0
|
||||
#define PyExc_GreenletError_NUM 1
|
||||
#define PyExc_GreenletExit_NUM 2
|
||||
|
||||
#define PyGreenlet_New_NUM 3
|
||||
#define PyGreenlet_GetCurrent_NUM 4
|
||||
#define PyGreenlet_Throw_NUM 5
|
||||
#define PyGreenlet_Switch_NUM 6
|
||||
#define PyGreenlet_SetParent_NUM 7
|
||||
|
||||
#define PyGreenlet_MAIN_NUM 8
|
||||
#define PyGreenlet_STARTED_NUM 9
|
||||
#define PyGreenlet_ACTIVE_NUM 10
|
||||
#define PyGreenlet_GET_PARENT_NUM 11
|
||||
|
||||
#ifndef GREENLET_MODULE
|
||||
/* This section is used by modules that uses the greenlet C API */
|
||||
static void** _PyGreenlet_API = NULL;
|
||||
|
||||
# define PyGreenlet_Type \
|
||||
(*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM])
|
||||
|
||||
# define PyExc_GreenletError \
|
||||
((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM])
|
||||
|
||||
# define PyExc_GreenletExit \
|
||||
((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_New(PyObject *args)
|
||||
*
|
||||
* greenlet.greenlet(run, parent=None)
|
||||
*/
|
||||
# define PyGreenlet_New \
|
||||
(*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \
|
||||
_PyGreenlet_API[PyGreenlet_New_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_GetCurrent(void)
|
||||
*
|
||||
* greenlet.getcurrent()
|
||||
*/
|
||||
# define PyGreenlet_GetCurrent \
|
||||
(*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_Throw(
|
||||
* PyGreenlet *greenlet,
|
||||
* PyObject *typ,
|
||||
* PyObject *val,
|
||||
* PyObject *tb)
|
||||
*
|
||||
* g.throw(...)
|
||||
*/
|
||||
# define PyGreenlet_Throw \
|
||||
(*(PyObject * (*)(PyGreenlet * self, \
|
||||
PyObject * typ, \
|
||||
PyObject * val, \
|
||||
PyObject * tb)) \
|
||||
_PyGreenlet_API[PyGreenlet_Throw_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args)
|
||||
*
|
||||
* g.switch(*args, **kwargs)
|
||||
*/
|
||||
# define PyGreenlet_Switch \
|
||||
(*(PyObject * \
|
||||
(*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \
|
||||
_PyGreenlet_API[PyGreenlet_Switch_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent)
|
||||
*
|
||||
* g.parent = new_parent
|
||||
*/
|
||||
# define PyGreenlet_SetParent \
|
||||
(*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \
|
||||
_PyGreenlet_API[PyGreenlet_SetParent_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_GetParent(PyObject* greenlet)
|
||||
*
|
||||
* return greenlet.parent;
|
||||
*
|
||||
* This could return NULL even if there is no exception active.
|
||||
* If it does not return NULL, you are responsible for decrementing the
|
||||
* reference count.
|
||||
*/
|
||||
# define PyGreenlet_GetParent \
|
||||
(*(PyGreenlet* (*)(PyGreenlet*)) \
|
||||
_PyGreenlet_API[PyGreenlet_GET_PARENT_NUM])
|
||||
|
||||
/*
|
||||
* deprecated, undocumented alias.
|
||||
*/
|
||||
# define PyGreenlet_GET_PARENT PyGreenlet_GetParent
|
||||
|
||||
# define PyGreenlet_MAIN \
|
||||
(*(int (*)(PyGreenlet*)) \
|
||||
_PyGreenlet_API[PyGreenlet_MAIN_NUM])
|
||||
|
||||
# define PyGreenlet_STARTED \
|
||||
(*(int (*)(PyGreenlet*)) \
|
||||
_PyGreenlet_API[PyGreenlet_STARTED_NUM])
|
||||
|
||||
# define PyGreenlet_ACTIVE \
|
||||
(*(int (*)(PyGreenlet*)) \
|
||||
_PyGreenlet_API[PyGreenlet_ACTIVE_NUM])
|
||||
|
||||
|
||||
|
||||
|
||||
/* Macro that imports greenlet and initializes C API */
|
||||
/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we
|
||||
keep the older definition to be sure older code that might have a copy of
|
||||
the header still works. */
|
||||
# define PyGreenlet_Import() \
|
||||
{ \
|
||||
_PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \
|
||||
}
|
||||
|
||||
#endif /* GREENLET_MODULE */
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif /* !Py_GREENLETOBJECT_H */
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from io import BytesIO
|
||||
from typing import IO
|
||||
|
||||
from . import ExifTags, Image, ImageFile
|
||||
|
||||
try:
|
||||
from . import _avif
|
||||
|
||||
SUPPORTED = True
|
||||
except ImportError:
|
||||
SUPPORTED = False
|
||||
|
||||
# Decoder options as module globals, until there is a way to pass parameters
|
||||
# to Image.open (see https://github.com/python-pillow/Pillow/issues/569)
|
||||
DECODE_CODEC_CHOICE = "auto"
|
||||
DEFAULT_MAX_THREADS = 0
|
||||
|
||||
|
||||
def get_codec_version(codec_name: str) -> str | None:
|
||||
versions = _avif.codec_versions()
|
||||
for version in versions.split(", "):
|
||||
if version.split(" [")[0] == codec_name:
|
||||
return version.split(":")[-1].split(" ")[0]
|
||||
return None
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool | str:
|
||||
if prefix[4:8] != b"ftyp":
|
||||
return False
|
||||
major_brand = prefix[8:12]
|
||||
if major_brand in (
|
||||
# coding brands
|
||||
b"avif",
|
||||
b"avis",
|
||||
# We accept files with AVIF container brands; we can't yet know if
|
||||
# the ftyp box has the correct compatible brands, but if it doesn't
|
||||
# then the plugin will raise a SyntaxError which Pillow will catch
|
||||
# before moving on to the next plugin that accepts the file.
|
||||
#
|
||||
# Also, because this file might not actually be an AVIF file, we
|
||||
# don't raise an error if AVIF support isn't properly compiled.
|
||||
b"mif1",
|
||||
b"msf1",
|
||||
):
|
||||
if not SUPPORTED:
|
||||
return (
|
||||
"image file could not be identified because AVIF support not installed"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _get_default_max_threads() -> int:
|
||||
if DEFAULT_MAX_THREADS:
|
||||
return DEFAULT_MAX_THREADS
|
||||
if hasattr(os, "sched_getaffinity"):
|
||||
return len(os.sched_getaffinity(0))
|
||||
else:
|
||||
return os.cpu_count() or 1
|
||||
|
||||
|
||||
class AvifImageFile(ImageFile.ImageFile):
|
||||
format = "AVIF"
|
||||
format_description = "AVIF image"
|
||||
__frame = -1
|
||||
|
||||
def _open(self) -> None:
|
||||
if not SUPPORTED:
|
||||
msg = "image file could not be opened because AVIF support not installed"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
if DECODE_CODEC_CHOICE != "auto" and not _avif.decoder_codec_available(
|
||||
DECODE_CODEC_CHOICE
|
||||
):
|
||||
msg = "Invalid opening codec"
|
||||
raise ValueError(msg)
|
||||
|
||||
assert self.fp is not None
|
||||
self._decoder = _avif.AvifDecoder(
|
||||
self.fp.read(),
|
||||
DECODE_CODEC_CHOICE,
|
||||
_get_default_max_threads(),
|
||||
)
|
||||
|
||||
# Get info from decoder
|
||||
self._size, self.n_frames, self._mode, icc, exif, exif_orientation, xmp = (
|
||||
self._decoder.get_info()
|
||||
)
|
||||
self.is_animated = self.n_frames > 1
|
||||
|
||||
if icc:
|
||||
self.info["icc_profile"] = icc
|
||||
if xmp:
|
||||
self.info["xmp"] = xmp
|
||||
|
||||
if exif_orientation != 1 or exif:
|
||||
exif_data = Image.Exif()
|
||||
if exif:
|
||||
exif_data.load(exif)
|
||||
original_orientation = exif_data.get(ExifTags.Base.Orientation, 1)
|
||||
else:
|
||||
original_orientation = 1
|
||||
if exif_orientation != original_orientation:
|
||||
exif_data[ExifTags.Base.Orientation] = exif_orientation
|
||||
exif = exif_data.tobytes()
|
||||
if exif:
|
||||
self.info["exif"] = exif
|
||||
self.seek(0)
|
||||
|
||||
def seek(self, frame: int) -> None:
|
||||
if not self._seek_check(frame):
|
||||
return
|
||||
|
||||
# Set tile
|
||||
self.__frame = frame
|
||||
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, self.mode)]
|
||||
|
||||
def load(self) -> Image.core.PixelAccess | None:
|
||||
if self.tile:
|
||||
# We need to load the image data for this frame
|
||||
data, timescale, pts_in_timescales, duration_in_timescales = (
|
||||
self._decoder.get_frame(self.__frame)
|
||||
)
|
||||
self.info["timestamp"] = round(1000 * (pts_in_timescales / timescale))
|
||||
self.info["duration"] = round(1000 * (duration_in_timescales / timescale))
|
||||
|
||||
if self.fp and self._exclusive_fp:
|
||||
self.fp.close()
|
||||
self.fp = BytesIO(data)
|
||||
|
||||
return super().load()
|
||||
|
||||
def load_seek(self, pos: int) -> None:
|
||||
pass
|
||||
|
||||
def tell(self) -> int:
|
||||
return self.__frame
|
||||
|
||||
|
||||
def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||
_save(im, fp, filename, save_all=True)
|
||||
|
||||
|
||||
def _save(
|
||||
im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False
|
||||
) -> None:
|
||||
info = im.encoderinfo.copy()
|
||||
if save_all:
|
||||
append_images = list(info.get("append_images", []))
|
||||
else:
|
||||
append_images = []
|
||||
|
||||
total = 0
|
||||
for ims in [im] + append_images:
|
||||
total += getattr(ims, "n_frames", 1)
|
||||
|
||||
quality = info.get("quality", 75)
|
||||
if not isinstance(quality, int) or quality < 0 or quality > 100:
|
||||
msg = "Invalid quality setting"
|
||||
raise ValueError(msg)
|
||||
|
||||
duration = info.get("duration", 0)
|
||||
subsampling = info.get("subsampling", "4:2:0")
|
||||
speed = info.get("speed", 6)
|
||||
max_threads = info.get("max_threads", _get_default_max_threads())
|
||||
codec = info.get("codec", "auto")
|
||||
if codec != "auto" and not _avif.encoder_codec_available(codec):
|
||||
msg = "Invalid saving codec"
|
||||
raise ValueError(msg)
|
||||
range_ = info.get("range", "full")
|
||||
tile_rows_log2 = info.get("tile_rows", 0)
|
||||
tile_cols_log2 = info.get("tile_cols", 0)
|
||||
alpha_premultiplied = bool(info.get("alpha_premultiplied", False))
|
||||
autotiling = bool(info.get("autotiling", tile_rows_log2 == tile_cols_log2 == 0))
|
||||
|
||||
icc_profile = info.get("icc_profile", im.info.get("icc_profile"))
|
||||
exif_orientation = 1
|
||||
if exif := info.get("exif"):
|
||||
if isinstance(exif, Image.Exif):
|
||||
exif_data = exif
|
||||
else:
|
||||
exif_data = Image.Exif()
|
||||
exif_data.load(exif)
|
||||
if ExifTags.Base.Orientation in exif_data:
|
||||
exif_orientation = exif_data.pop(ExifTags.Base.Orientation)
|
||||
exif = exif_data.tobytes() if exif_data else b""
|
||||
elif isinstance(exif, Image.Exif):
|
||||
exif = exif_data.tobytes()
|
||||
|
||||
xmp = info.get("xmp")
|
||||
|
||||
if isinstance(xmp, str):
|
||||
xmp = xmp.encode("utf-8")
|
||||
|
||||
advanced = info.get("advanced")
|
||||
if advanced is not None:
|
||||
if isinstance(advanced, dict):
|
||||
advanced = advanced.items()
|
||||
try:
|
||||
advanced = tuple(advanced)
|
||||
except TypeError:
|
||||
invalid = True
|
||||
else:
|
||||
invalid = any(not isinstance(v, tuple) or len(v) != 2 for v in advanced)
|
||||
if invalid:
|
||||
msg = (
|
||||
"advanced codec options must be a dict of key-value string "
|
||||
"pairs or a series of key-value two-tuples"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
# Setup the AVIF encoder
|
||||
enc = _avif.AvifEncoder(
|
||||
im.size,
|
||||
subsampling,
|
||||
quality,
|
||||
speed,
|
||||
max_threads,
|
||||
codec,
|
||||
range_,
|
||||
tile_rows_log2,
|
||||
tile_cols_log2,
|
||||
alpha_premultiplied,
|
||||
autotiling,
|
||||
icc_profile or b"",
|
||||
exif or b"",
|
||||
exif_orientation,
|
||||
xmp or b"",
|
||||
advanced,
|
||||
)
|
||||
|
||||
# Add each frame
|
||||
frame_idx = 0
|
||||
frame_duration = 0
|
||||
cur_idx = im.tell()
|
||||
is_single_frame = total == 1
|
||||
try:
|
||||
for ims in [im] + append_images:
|
||||
# Get number of frames in this image
|
||||
nfr = getattr(ims, "n_frames", 1)
|
||||
|
||||
for idx in range(nfr):
|
||||
ims.seek(idx)
|
||||
|
||||
# Make sure image mode is supported
|
||||
frame = ims
|
||||
rawmode = ims.mode
|
||||
if ims.mode not in {"RGB", "RGBA"}:
|
||||
rawmode = "RGBA" if ims.has_transparency_data else "RGB"
|
||||
frame = ims.convert(rawmode)
|
||||
|
||||
# Update frame duration
|
||||
if isinstance(duration, (list, tuple)):
|
||||
frame_duration = duration[frame_idx]
|
||||
else:
|
||||
frame_duration = duration
|
||||
|
||||
# Append the frame to the animation encoder
|
||||
enc.add(
|
||||
frame.tobytes("raw", rawmode),
|
||||
frame_duration,
|
||||
frame.size,
|
||||
rawmode,
|
||||
is_single_frame,
|
||||
)
|
||||
|
||||
# Update frame index
|
||||
frame_idx += 1
|
||||
|
||||
if not save_all:
|
||||
break
|
||||
|
||||
finally:
|
||||
im.seek(cur_idx)
|
||||
|
||||
# Get the final output from the encoder
|
||||
data = enc.finish()
|
||||
if data is None:
|
||||
msg = "cannot write file as AVIF (encoder returned None)"
|
||||
raise OSError(msg)
|
||||
|
||||
fp.write(data)
|
||||
|
||||
|
||||
Image.register_open(AvifImageFile.format, AvifImageFile, _accept)
|
||||
if SUPPORTED:
|
||||
Image.register_save(AvifImageFile.format, _save)
|
||||
Image.register_save_all(AvifImageFile.format, _save_all)
|
||||
Image.register_extensions(AvifImageFile.format, [".avif", ".avifs"])
|
||||
Image.register_mime(AvifImageFile.format, "image/avif")
|
||||
@@ -0,0 +1,123 @@
|
||||
#
|
||||
# The Python Imaging Library
|
||||
# $Id$
|
||||
#
|
||||
# bitmap distribution font (bdf) file parser
|
||||
#
|
||||
# history:
|
||||
# 1996-05-16 fl created (as bdf2pil)
|
||||
# 1997-08-25 fl converted to FontFile driver
|
||||
# 2001-05-25 fl removed bogus __init__ call
|
||||
# 2002-11-20 fl robustification (from Kevin Cazabon, Dmitry Vasiliev)
|
||||
# 2003-04-22 fl more robustification (from Graham Dumpleton)
|
||||
#
|
||||
# Copyright (c) 1997-2003 by Secret Labs AB.
|
||||
# Copyright (c) 1997-2003 by Fredrik Lundh.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
|
||||
"""
|
||||
Parse X Bitmap Distribution Format (BDF)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import BinaryIO
|
||||
|
||||
from . import FontFile, Image
|
||||
|
||||
|
||||
def bdf_char(
|
||||
f: BinaryIO,
|
||||
) -> (
|
||||
tuple[
|
||||
str,
|
||||
int,
|
||||
tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]],
|
||||
Image.Image,
|
||||
]
|
||||
| None
|
||||
):
|
||||
# skip to STARTCHAR
|
||||
while True:
|
||||
s = f.readline()
|
||||
if not s:
|
||||
return None
|
||||
if s.startswith(b"STARTCHAR"):
|
||||
break
|
||||
id = s[9:].strip().decode("ascii")
|
||||
|
||||
# load symbol properties
|
||||
props = {}
|
||||
while True:
|
||||
s = f.readline()
|
||||
if not s or s.startswith(b"BITMAP"):
|
||||
break
|
||||
i = s.find(b" ")
|
||||
props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii")
|
||||
|
||||
# load bitmap
|
||||
bitmap = bytearray()
|
||||
while True:
|
||||
s = f.readline()
|
||||
if not s or s.startswith(b"ENDCHAR"):
|
||||
break
|
||||
bitmap += s[:-1]
|
||||
|
||||
# The word BBX
|
||||
# followed by the width in x (BBw), height in y (BBh),
|
||||
# and x and y displacement (BBxoff0, BByoff0)
|
||||
# of the lower left corner from the origin of the character.
|
||||
width, height, x_disp, y_disp = (int(p) for p in props["BBX"].split())
|
||||
|
||||
# The word DWIDTH
|
||||
# followed by the width in x and y of the character in device pixels.
|
||||
dwx, dwy = (int(p) for p in props["DWIDTH"].split())
|
||||
|
||||
bbox = (
|
||||
(dwx, dwy),
|
||||
(x_disp, -y_disp - height, width + x_disp, -y_disp),
|
||||
(0, 0, width, height),
|
||||
)
|
||||
|
||||
try:
|
||||
im = Image.frombytes("1", (width, height), bitmap, "hex", "1")
|
||||
except ValueError:
|
||||
# deal with zero-width characters
|
||||
im = Image.new("1", (width, height))
|
||||
|
||||
return id, int(props["ENCODING"]), bbox, im
|
||||
|
||||
|
||||
class BdfFontFile(FontFile.FontFile):
|
||||
"""Font file plugin for the X11 BDF format."""
|
||||
|
||||
def __init__(self, fp: BinaryIO) -> None:
|
||||
super().__init__()
|
||||
|
||||
s = fp.readline()
|
||||
if not s.startswith(b"STARTFONT 2.1"):
|
||||
msg = "not a valid BDF file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
props = {}
|
||||
comments = []
|
||||
|
||||
while True:
|
||||
s = fp.readline()
|
||||
if not s or s.startswith(b"ENDPROPERTIES"):
|
||||
break
|
||||
i = s.find(b" ")
|
||||
props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii")
|
||||
if s[:i] in [b"COMMENT", b"COPYRIGHT"]:
|
||||
if s.find(b"LogicalFontDescription") < 0:
|
||||
comments.append(s[i + 1 : -1].decode("ascii"))
|
||||
|
||||
while True:
|
||||
c = bdf_char(fp)
|
||||
if not c:
|
||||
break
|
||||
id, ch, (xy, dst, src), im = c
|
||||
if 0 <= ch < len(self.glyph):
|
||||
self.glyph[ch] = xy, dst, src, im
|
||||
+498
@@ -0,0 +1,498 @@
|
||||
"""
|
||||
Blizzard Mipmap Format (.blp)
|
||||
Jerome Leclanche <jerome@leclan.ch>
|
||||
|
||||
The contents of this file are hereby released in the public domain (CC0)
|
||||
Full text of the CC0 license:
|
||||
https://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
BLP1 files, used mostly in Warcraft III, are not fully supported.
|
||||
All types of BLP2 files used in World of Warcraft are supported.
|
||||
|
||||
The BLP file structure consists of a header, up to 16 mipmaps of the
|
||||
texture
|
||||
|
||||
Texture sizes must be powers of two, though the two dimensions do
|
||||
not have to be equal; 512x256 is valid, but 512x200 is not.
|
||||
The first mipmap (mipmap #0) is the full size image; each subsequent
|
||||
mipmap halves both dimensions. The final mipmap should be 1x1.
|
||||
|
||||
BLP files come in many different flavours:
|
||||
* JPEG-compressed (type == 0) - only supported for BLP1.
|
||||
* RAW images (type == 1, encoding == 1). Each mipmap is stored as an
|
||||
array of 8-bit values, one per pixel, left to right, top to bottom.
|
||||
Each value is an index to the palette.
|
||||
* DXT-compressed (type == 1, encoding == 2):
|
||||
- DXT1 compression is used if alpha_encoding == 0.
|
||||
- An additional alpha bit is used if alpha_depth == 1.
|
||||
- DXT3 compression is used if alpha_encoding == 1.
|
||||
- DXT5 compression is used if alpha_encoding == 7.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import os
|
||||
import struct
|
||||
from enum import IntEnum
|
||||
from io import BytesIO
|
||||
from typing import IO
|
||||
|
||||
from . import Image, ImageFile
|
||||
|
||||
|
||||
class Format(IntEnum):
|
||||
JPEG = 0
|
||||
|
||||
|
||||
class Encoding(IntEnum):
|
||||
UNCOMPRESSED = 1
|
||||
DXT = 2
|
||||
UNCOMPRESSED_RAW_BGRA = 3
|
||||
|
||||
|
||||
class AlphaEncoding(IntEnum):
|
||||
DXT1 = 0
|
||||
DXT3 = 1
|
||||
DXT5 = 7
|
||||
|
||||
|
||||
def unpack_565(i: int) -> tuple[int, int, int]:
|
||||
return ((i >> 11) & 0x1F) << 3, ((i >> 5) & 0x3F) << 2, (i & 0x1F) << 3
|
||||
|
||||
|
||||
def decode_dxt1(
|
||||
data: bytes, alpha: bool = False
|
||||
) -> tuple[bytearray, bytearray, bytearray, bytearray]:
|
||||
"""
|
||||
input: one "row" of data (i.e. will produce 4*width pixels)
|
||||
"""
|
||||
|
||||
blocks = len(data) // 8 # number of blocks in row
|
||||
ret = (bytearray(), bytearray(), bytearray(), bytearray())
|
||||
|
||||
for block_index in range(blocks):
|
||||
# Decode next 8-byte block.
|
||||
idx = block_index * 8
|
||||
color0, color1, bits = struct.unpack_from("<HHI", data, idx)
|
||||
|
||||
r0, g0, b0 = unpack_565(color0)
|
||||
r1, g1, b1 = unpack_565(color1)
|
||||
|
||||
# Decode this block into 4x4 pixels
|
||||
# Accumulate the results onto our 4 row accumulators
|
||||
for j in range(4):
|
||||
for i in range(4):
|
||||
# get next control op and generate a pixel
|
||||
|
||||
control = bits & 3
|
||||
bits = bits >> 2
|
||||
|
||||
a = 0xFF
|
||||
if control == 0:
|
||||
r, g, b = r0, g0, b0
|
||||
elif control == 1:
|
||||
r, g, b = r1, g1, b1
|
||||
elif control == 2:
|
||||
if color0 > color1:
|
||||
r = (2 * r0 + r1) // 3
|
||||
g = (2 * g0 + g1) // 3
|
||||
b = (2 * b0 + b1) // 3
|
||||
else:
|
||||
r = (r0 + r1) // 2
|
||||
g = (g0 + g1) // 2
|
||||
b = (b0 + b1) // 2
|
||||
elif control == 3:
|
||||
if color0 > color1:
|
||||
r = (2 * r1 + r0) // 3
|
||||
g = (2 * g1 + g0) // 3
|
||||
b = (2 * b1 + b0) // 3
|
||||
else:
|
||||
r, g, b, a = 0, 0, 0, 0
|
||||
|
||||
if alpha:
|
||||
ret[j].extend([r, g, b, a])
|
||||
else:
|
||||
ret[j].extend([r, g, b])
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def decode_dxt3(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]:
|
||||
"""
|
||||
input: one "row" of data (i.e. will produce 4*width pixels)
|
||||
"""
|
||||
|
||||
blocks = len(data) // 16 # number of blocks in row
|
||||
ret = (bytearray(), bytearray(), bytearray(), bytearray())
|
||||
|
||||
for block_index in range(blocks):
|
||||
idx = block_index * 16
|
||||
block = data[idx : idx + 16]
|
||||
# Decode next 16-byte block.
|
||||
bits = struct.unpack_from("<8B", block)
|
||||
color0, color1 = struct.unpack_from("<HH", block, 8)
|
||||
|
||||
(code,) = struct.unpack_from("<I", block, 12)
|
||||
|
||||
r0, g0, b0 = unpack_565(color0)
|
||||
r1, g1, b1 = unpack_565(color1)
|
||||
|
||||
for j in range(4):
|
||||
high = False # Do we want the higher bits?
|
||||
for i in range(4):
|
||||
alphacode_index = (4 * j + i) // 2
|
||||
a = bits[alphacode_index]
|
||||
if high:
|
||||
high = False
|
||||
a >>= 4
|
||||
else:
|
||||
high = True
|
||||
a &= 0xF
|
||||
a *= 17 # We get a value between 0 and 15
|
||||
|
||||
color_code = (code >> 2 * (4 * j + i)) & 0x03
|
||||
|
||||
if color_code == 0:
|
||||
r, g, b = r0, g0, b0
|
||||
elif color_code == 1:
|
||||
r, g, b = r1, g1, b1
|
||||
elif color_code == 2:
|
||||
r = (2 * r0 + r1) // 3
|
||||
g = (2 * g0 + g1) // 3
|
||||
b = (2 * b0 + b1) // 3
|
||||
elif color_code == 3:
|
||||
r = (2 * r1 + r0) // 3
|
||||
g = (2 * g1 + g0) // 3
|
||||
b = (2 * b1 + b0) // 3
|
||||
|
||||
ret[j].extend([r, g, b, a])
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def decode_dxt5(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]:
|
||||
"""
|
||||
input: one "row" of data (i.e. will produce 4 * width pixels)
|
||||
"""
|
||||
|
||||
blocks = len(data) // 16 # number of blocks in row
|
||||
ret = (bytearray(), bytearray(), bytearray(), bytearray())
|
||||
|
||||
for block_index in range(blocks):
|
||||
idx = block_index * 16
|
||||
block = data[idx : idx + 16]
|
||||
# Decode next 16-byte block.
|
||||
a0, a1 = struct.unpack_from("<BB", block)
|
||||
|
||||
bits = struct.unpack_from("<6B", block, 2)
|
||||
alphacode1 = bits[2] | (bits[3] << 8) | (bits[4] << 16) | (bits[5] << 24)
|
||||
alphacode2 = bits[0] | (bits[1] << 8)
|
||||
|
||||
color0, color1 = struct.unpack_from("<HH", block, 8)
|
||||
|
||||
(code,) = struct.unpack_from("<I", block, 12)
|
||||
|
||||
r0, g0, b0 = unpack_565(color0)
|
||||
r1, g1, b1 = unpack_565(color1)
|
||||
|
||||
for j in range(4):
|
||||
for i in range(4):
|
||||
# get next control op and generate a pixel
|
||||
alphacode_index = 3 * (4 * j + i)
|
||||
|
||||
if alphacode_index <= 12:
|
||||
alphacode = (alphacode2 >> alphacode_index) & 0x07
|
||||
elif alphacode_index == 15:
|
||||
alphacode = (alphacode2 >> 15) | ((alphacode1 << 1) & 0x06)
|
||||
else: # alphacode_index >= 18 and alphacode_index <= 45
|
||||
alphacode = (alphacode1 >> (alphacode_index - 16)) & 0x07
|
||||
|
||||
if alphacode == 0:
|
||||
a = a0
|
||||
elif alphacode == 1:
|
||||
a = a1
|
||||
elif a0 > a1:
|
||||
a = ((8 - alphacode) * a0 + (alphacode - 1) * a1) // 7
|
||||
elif alphacode == 6:
|
||||
a = 0
|
||||
elif alphacode == 7:
|
||||
a = 255
|
||||
else:
|
||||
a = ((6 - alphacode) * a0 + (alphacode - 1) * a1) // 5
|
||||
|
||||
color_code = (code >> 2 * (4 * j + i)) & 0x03
|
||||
|
||||
if color_code == 0:
|
||||
r, g, b = r0, g0, b0
|
||||
elif color_code == 1:
|
||||
r, g, b = r1, g1, b1
|
||||
elif color_code == 2:
|
||||
r = (2 * r0 + r1) // 3
|
||||
g = (2 * g0 + g1) // 3
|
||||
b = (2 * b0 + b1) // 3
|
||||
elif color_code == 3:
|
||||
r = (2 * r1 + r0) // 3
|
||||
g = (2 * g1 + g0) // 3
|
||||
b = (2 * b1 + b0) // 3
|
||||
|
||||
ret[j].extend([r, g, b, a])
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
class BLPFormatError(NotImplementedError):
|
||||
pass
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith((b"BLP1", b"BLP2"))
|
||||
|
||||
|
||||
class BlpImageFile(ImageFile.ImageFile):
|
||||
"""
|
||||
Blizzard Mipmap Format
|
||||
"""
|
||||
|
||||
format = "BLP"
|
||||
format_description = "Blizzard Mipmap Format"
|
||||
|
||||
def _open(self) -> None:
|
||||
assert self.fp is not None
|
||||
self.magic = self.fp.read(4)
|
||||
if not _accept(self.magic):
|
||||
msg = f"Bad BLP magic {repr(self.magic)}"
|
||||
raise BLPFormatError(msg)
|
||||
|
||||
compression = struct.unpack("<i", self.fp.read(4))[0]
|
||||
if self.magic == b"BLP1":
|
||||
alpha = struct.unpack("<I", self.fp.read(4))[0] != 0
|
||||
else:
|
||||
encoding = struct.unpack("<b", self.fp.read(1))[0]
|
||||
alpha = struct.unpack("<b", self.fp.read(1))[0] != 0
|
||||
alpha_encoding = struct.unpack("<b", self.fp.read(1))[0]
|
||||
self.fp.seek(1, os.SEEK_CUR) # mips
|
||||
|
||||
self._size = struct.unpack("<II", self.fp.read(8))
|
||||
|
||||
args: tuple[int, int, bool] | tuple[int, int, bool, int]
|
||||
if self.magic == b"BLP1":
|
||||
encoding = struct.unpack("<i", self.fp.read(4))[0]
|
||||
self.fp.seek(4, os.SEEK_CUR) # subtype
|
||||
|
||||
args = (compression, encoding, alpha)
|
||||
offset = 28
|
||||
else:
|
||||
args = (compression, encoding, alpha, alpha_encoding)
|
||||
offset = 20
|
||||
|
||||
decoder = self.magic.decode()
|
||||
|
||||
self._mode = "RGBA" if alpha else "RGB"
|
||||
self.tile = [ImageFile._Tile(decoder, (0, 0) + self.size, offset, args)]
|
||||
|
||||
|
||||
class _BLPBaseDecoder(abc.ABC, ImageFile.PyDecoder):
|
||||
_pulls_fd = True
|
||||
|
||||
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
|
||||
try:
|
||||
self._read_header()
|
||||
self._load()
|
||||
except struct.error as e:
|
||||
msg = "Truncated BLP file"
|
||||
raise OSError(msg) from e
|
||||
return -1, 0
|
||||
|
||||
@abc.abstractmethod
|
||||
def _load(self) -> None:
|
||||
pass
|
||||
|
||||
def _read_header(self) -> None:
|
||||
self._offsets = struct.unpack("<16I", self._safe_read(16 * 4))
|
||||
self._lengths = struct.unpack("<16I", self._safe_read(16 * 4))
|
||||
|
||||
def _safe_read(self, length: int) -> bytes:
|
||||
assert self.fd is not None
|
||||
return ImageFile._safe_read(self.fd, length)
|
||||
|
||||
def _read_palette(self) -> list[tuple[int, int, int, int]]:
|
||||
ret = []
|
||||
for i in range(256):
|
||||
try:
|
||||
b, g, r, a = struct.unpack("<4B", self._safe_read(4))
|
||||
except struct.error:
|
||||
break
|
||||
ret.append((b, g, r, a))
|
||||
return ret
|
||||
|
||||
def _read_bgra(
|
||||
self, palette: list[tuple[int, int, int, int]], alpha: bool
|
||||
) -> bytearray:
|
||||
data = bytearray()
|
||||
_data = BytesIO(self._safe_read(self._lengths[0]))
|
||||
while True:
|
||||
try:
|
||||
(offset,) = struct.unpack("<B", _data.read(1))
|
||||
except struct.error:
|
||||
break
|
||||
b, g, r, a = palette[offset]
|
||||
d: tuple[int, ...] = (r, g, b)
|
||||
if alpha:
|
||||
d += (a,)
|
||||
data.extend(d)
|
||||
return data
|
||||
|
||||
|
||||
class BLP1Decoder(_BLPBaseDecoder):
|
||||
def _load(self) -> None:
|
||||
self._compression, self._encoding, alpha = self.args
|
||||
|
||||
if self._compression == Format.JPEG:
|
||||
self._decode_jpeg_stream()
|
||||
|
||||
elif self._compression == 1:
|
||||
if self._encoding in (4, 5):
|
||||
palette = self._read_palette()
|
||||
data = self._read_bgra(palette, alpha)
|
||||
self.set_as_raw(data)
|
||||
else:
|
||||
msg = f"Unsupported BLP encoding {repr(self._encoding)}"
|
||||
raise BLPFormatError(msg)
|
||||
else:
|
||||
msg = f"Unsupported BLP compression {repr(self._encoding)}"
|
||||
raise BLPFormatError(msg)
|
||||
|
||||
def _decode_jpeg_stream(self) -> None:
|
||||
from .JpegImagePlugin import JpegImageFile
|
||||
|
||||
(jpeg_header_size,) = struct.unpack("<I", self._safe_read(4))
|
||||
jpeg_header = self._safe_read(jpeg_header_size)
|
||||
assert self.fd is not None
|
||||
self._safe_read(self._offsets[0] - self.fd.tell()) # What IS this?
|
||||
data = self._safe_read(self._lengths[0])
|
||||
data = jpeg_header + data
|
||||
image = JpegImageFile(BytesIO(data))
|
||||
Image._decompression_bomb_check(image.size)
|
||||
if image.mode == "CMYK":
|
||||
args = image.tile[0].args
|
||||
assert isinstance(args, tuple)
|
||||
image.tile = [image.tile[0]._replace(args=(args[0], "CMYK"))]
|
||||
self.set_as_raw(image.convert("RGB").tobytes(), "BGR")
|
||||
|
||||
|
||||
class BLP2Decoder(_BLPBaseDecoder):
|
||||
def _load(self) -> None:
|
||||
self._compression, self._encoding, alpha, self._alpha_encoding = self.args
|
||||
|
||||
palette = self._read_palette()
|
||||
|
||||
assert self.fd is not None
|
||||
self.fd.seek(self._offsets[0])
|
||||
|
||||
if self._compression == 1:
|
||||
# Uncompressed or DirectX compression
|
||||
|
||||
if self._encoding == Encoding.UNCOMPRESSED:
|
||||
data = self._read_bgra(palette, alpha)
|
||||
|
||||
elif self._encoding == Encoding.DXT:
|
||||
data = bytearray()
|
||||
if self._alpha_encoding == AlphaEncoding.DXT1:
|
||||
linesize = (self.state.xsize + 3) // 4 * 8
|
||||
for yb in range((self.state.ysize + 3) // 4):
|
||||
for d in decode_dxt1(self._safe_read(linesize), alpha):
|
||||
data += d
|
||||
|
||||
elif self._alpha_encoding == AlphaEncoding.DXT3:
|
||||
linesize = (self.state.xsize + 3) // 4 * 16
|
||||
for yb in range((self.state.ysize + 3) // 4):
|
||||
for d in decode_dxt3(self._safe_read(linesize)):
|
||||
data += d
|
||||
|
||||
elif self._alpha_encoding == AlphaEncoding.DXT5:
|
||||
linesize = (self.state.xsize + 3) // 4 * 16
|
||||
for yb in range((self.state.ysize + 3) // 4):
|
||||
for d in decode_dxt5(self._safe_read(linesize)):
|
||||
data += d
|
||||
else:
|
||||
msg = f"Unsupported alpha encoding {repr(self._alpha_encoding)}"
|
||||
raise BLPFormatError(msg)
|
||||
else:
|
||||
msg = f"Unknown BLP encoding {repr(self._encoding)}"
|
||||
raise BLPFormatError(msg)
|
||||
|
||||
else:
|
||||
msg = f"Unknown BLP compression {repr(self._compression)}"
|
||||
raise BLPFormatError(msg)
|
||||
|
||||
self.set_as_raw(data)
|
||||
|
||||
|
||||
class BLPEncoder(ImageFile.PyEncoder):
|
||||
_pushes_fd = True
|
||||
|
||||
def _write_palette(self) -> bytes:
|
||||
data = b""
|
||||
assert self.im is not None
|
||||
palette = self.im.getpalette("RGBA", "RGBA")
|
||||
for i in range(len(palette) // 4):
|
||||
r, g, b, a = palette[i * 4 : (i + 1) * 4]
|
||||
data += struct.pack("<4B", b, g, r, a)
|
||||
while len(data) < 256 * 4:
|
||||
data += b"\x00" * 4
|
||||
return data
|
||||
|
||||
def encode(self, bufsize: int) -> tuple[int, int, bytes]:
|
||||
palette_data = self._write_palette()
|
||||
|
||||
offset = 20 + 16 * 4 * 2 + len(palette_data)
|
||||
data = struct.pack("<16I", offset, *((0,) * 15))
|
||||
|
||||
assert self.im is not None
|
||||
w, h = self.im.size
|
||||
data += struct.pack("<16I", w * h, *((0,) * 15))
|
||||
|
||||
data += palette_data
|
||||
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
data += struct.pack("<B", self.im.getpixel((x, y)))
|
||||
|
||||
return len(data), 0, data
|
||||
|
||||
|
||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||
if im.mode != "P":
|
||||
msg = "Unsupported BLP image mode"
|
||||
raise ValueError(msg)
|
||||
|
||||
magic = b"BLP1" if im.encoderinfo.get("blp_version") == "BLP1" else b"BLP2"
|
||||
fp.write(magic)
|
||||
|
||||
assert im.palette is not None
|
||||
fp.write(struct.pack("<i", 1)) # Uncompressed or DirectX compression
|
||||
|
||||
alpha_depth = 1 if im.palette.mode == "RGBA" else 0
|
||||
if magic == b"BLP1":
|
||||
fp.write(struct.pack("<L", alpha_depth))
|
||||
else:
|
||||
fp.write(struct.pack("<b", Encoding.UNCOMPRESSED))
|
||||
fp.write(struct.pack("<b", alpha_depth))
|
||||
fp.write(struct.pack("<b", 0)) # alpha encoding
|
||||
fp.write(struct.pack("<b", 0)) # mips
|
||||
fp.write(struct.pack("<II", *im.size))
|
||||
if magic == b"BLP1":
|
||||
fp.write(struct.pack("<i", 5))
|
||||
fp.write(struct.pack("<i", 0))
|
||||
|
||||
ImageFile._save(im, fp, [ImageFile._Tile("BLP", (0, 0) + im.size, 0, im.mode)])
|
||||
|
||||
|
||||
Image.register_open(BlpImageFile.format, BlpImageFile, _accept)
|
||||
Image.register_extension(BlpImageFile.format, ".blp")
|
||||
Image.register_decoder("BLP1", BLP1Decoder)
|
||||
Image.register_decoder("BLP2", BLP2Decoder)
|
||||
|
||||
Image.register_save(BlpImageFile.format, _save)
|
||||
Image.register_encoder("BLP", BLPEncoder)
|
||||
+514
@@ -0,0 +1,514 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# BMP file handler
|
||||
#
|
||||
# Windows (and OS/2) native bitmap storage format.
|
||||
#
|
||||
# history:
|
||||
# 1995-09-01 fl Created
|
||||
# 1996-04-30 fl Added save
|
||||
# 1997-08-27 fl Fixed save of 1-bit images
|
||||
# 1998-03-06 fl Load P images as L where possible
|
||||
# 1998-07-03 fl Load P images as 1 where possible
|
||||
# 1998-12-29 fl Handle small palettes
|
||||
# 2002-12-30 fl Fixed load of 1-bit palette images
|
||||
# 2003-04-21 fl Fixed load of 1-bit monochrome images
|
||||
# 2003-04-23 fl Added limited support for BI_BITFIELDS compression
|
||||
#
|
||||
# Copyright (c) 1997-2003 by Secret Labs AB
|
||||
# Copyright (c) 1995-2003 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import IO, Any
|
||||
|
||||
from . import Image, ImageFile, ImagePalette
|
||||
from ._binary import i16le as i16
|
||||
from ._binary import i32le as i32
|
||||
from ._binary import o8
|
||||
from ._binary import o16le as o16
|
||||
from ._binary import o32le as o32
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
# Read BMP file
|
||||
|
||||
BIT2MODE = {
|
||||
# bits => mode, rawmode
|
||||
1: ("P", "P;1"),
|
||||
4: ("P", "P;4"),
|
||||
8: ("P", "P"),
|
||||
16: ("RGB", "BGR;15"),
|
||||
24: ("RGB", "BGR"),
|
||||
32: ("RGB", "BGRX"),
|
||||
}
|
||||
|
||||
USE_RAW_ALPHA = False
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith(b"BM")
|
||||
|
||||
|
||||
def _dib_accept(prefix: bytes) -> bool:
|
||||
return i32(prefix) in [12, 40, 52, 56, 64, 108, 124]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Image plugin for the Windows BMP format.
|
||||
# =============================================================================
|
||||
class BmpImageFile(ImageFile.ImageFile):
|
||||
"""Image plugin for the Windows Bitmap format (BMP)"""
|
||||
|
||||
# ------------------------------------------------------------- Description
|
||||
format_description = "Windows Bitmap"
|
||||
format = "BMP"
|
||||
|
||||
# -------------------------------------------------- BMP Compression values
|
||||
COMPRESSIONS = {"RAW": 0, "RLE8": 1, "RLE4": 2, "BITFIELDS": 3, "JPEG": 4, "PNG": 5}
|
||||
for k, v in COMPRESSIONS.items():
|
||||
vars()[k] = v
|
||||
|
||||
def _bitmap(self, header: int = 0, offset: int = 0) -> None:
|
||||
"""Read relevant info about the BMP"""
|
||||
assert self.fp is not None
|
||||
read, seek = self.fp.read, self.fp.seek
|
||||
if header:
|
||||
seek(header)
|
||||
# read bmp header size @offset 14 (this is part of the header size)
|
||||
file_info: dict[str, bool | int | tuple[int, ...]] = {
|
||||
"header_size": i32(read(4)),
|
||||
"direction": -1,
|
||||
}
|
||||
|
||||
# -------------------- If requested, read header at a specific position
|
||||
# read the rest of the bmp header, without its size
|
||||
assert isinstance(file_info["header_size"], int)
|
||||
header_data = ImageFile._safe_read(self.fp, file_info["header_size"] - 4)
|
||||
|
||||
# ------------------------------- Windows Bitmap v2, IBM OS/2 Bitmap v1
|
||||
# ----- This format has different offsets because of width/height types
|
||||
# 12: BITMAPCOREHEADER/OS21XBITMAPHEADER
|
||||
if file_info["header_size"] == 12:
|
||||
file_info["width"] = i16(header_data, 0)
|
||||
file_info["height"] = i16(header_data, 2)
|
||||
file_info["planes"] = i16(header_data, 4)
|
||||
file_info["bits"] = i16(header_data, 6)
|
||||
file_info["compression"] = self.COMPRESSIONS["RAW"]
|
||||
file_info["palette_padding"] = 3
|
||||
|
||||
# --------------------------------------------- Windows Bitmap v3 to v5
|
||||
# 40: BITMAPINFOHEADER
|
||||
# 52: BITMAPV2HEADER
|
||||
# 56: BITMAPV3HEADER
|
||||
# 64: BITMAPCOREHEADER2/OS22XBITMAPHEADER
|
||||
# 108: BITMAPV4HEADER
|
||||
# 124: BITMAPV5HEADER
|
||||
elif file_info["header_size"] in (40, 52, 56, 64, 108, 124):
|
||||
file_info["y_flip"] = header_data[7] == 0xFF
|
||||
file_info["direction"] = 1 if file_info["y_flip"] else -1
|
||||
file_info["width"] = i32(header_data, 0)
|
||||
file_info["height"] = (
|
||||
i32(header_data, 4)
|
||||
if not file_info["y_flip"]
|
||||
else 2**32 - i32(header_data, 4)
|
||||
)
|
||||
file_info["planes"] = i16(header_data, 8)
|
||||
file_info["bits"] = i16(header_data, 10)
|
||||
file_info["compression"] = i32(header_data, 12)
|
||||
# byte size of pixel data
|
||||
file_info["data_size"] = i32(header_data, 16)
|
||||
file_info["pixels_per_meter"] = (
|
||||
i32(header_data, 20),
|
||||
i32(header_data, 24),
|
||||
)
|
||||
file_info["colors"] = i32(header_data, 28)
|
||||
file_info["palette_padding"] = 4
|
||||
assert isinstance(file_info["pixels_per_meter"], tuple)
|
||||
self.info["dpi"] = tuple(x / 39.3701 for x in file_info["pixels_per_meter"])
|
||||
if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]:
|
||||
masks = ["r_mask", "g_mask", "b_mask"]
|
||||
if len(header_data) >= 48:
|
||||
if len(header_data) >= 52:
|
||||
masks.append("a_mask")
|
||||
else:
|
||||
file_info["a_mask"] = 0x0
|
||||
for idx, mask in enumerate(masks):
|
||||
file_info[mask] = i32(header_data, 36 + idx * 4)
|
||||
else:
|
||||
# 40 byte headers only have the three components in the
|
||||
# bitfields masks, ref:
|
||||
# https://msdn.microsoft.com/en-us/library/windows/desktop/dd183376(v=vs.85).aspx
|
||||
# See also
|
||||
# https://github.com/python-pillow/Pillow/issues/1293
|
||||
# There is a 4th component in the RGBQuad, in the alpha
|
||||
# location, but it is listed as a reserved component,
|
||||
# and it is not generally an alpha channel
|
||||
file_info["a_mask"] = 0x0
|
||||
for mask in masks:
|
||||
file_info[mask] = i32(read(4))
|
||||
assert isinstance(file_info["r_mask"], int)
|
||||
assert isinstance(file_info["g_mask"], int)
|
||||
assert isinstance(file_info["b_mask"], int)
|
||||
assert isinstance(file_info["a_mask"], int)
|
||||
file_info["rgb_mask"] = (
|
||||
file_info["r_mask"],
|
||||
file_info["g_mask"],
|
||||
file_info["b_mask"],
|
||||
)
|
||||
file_info["rgba_mask"] = (
|
||||
file_info["r_mask"],
|
||||
file_info["g_mask"],
|
||||
file_info["b_mask"],
|
||||
file_info["a_mask"],
|
||||
)
|
||||
else:
|
||||
msg = f"Unsupported BMP header type ({file_info['header_size']})"
|
||||
raise OSError(msg)
|
||||
|
||||
# ------------------ Special case : header is reported 40, which
|
||||
# ---------------------- is shorter than real size for bpp >= 16
|
||||
assert isinstance(file_info["width"], int)
|
||||
assert isinstance(file_info["height"], int)
|
||||
self._size = file_info["width"], file_info["height"]
|
||||
|
||||
# ------- If color count was not found in the header, compute from bits
|
||||
assert isinstance(file_info["bits"], int)
|
||||
if not file_info.get("colors", 0):
|
||||
file_info["colors"] = 1 << file_info["bits"]
|
||||
assert isinstance(file_info["palette_padding"], int)
|
||||
assert isinstance(file_info["colors"], int)
|
||||
if offset == 14 + file_info["header_size"] and file_info["bits"] <= 8:
|
||||
offset += file_info["palette_padding"] * file_info["colors"]
|
||||
|
||||
# ---------------------- Check bit depth for unusual unsupported values
|
||||
self._mode, raw_mode = BIT2MODE.get(file_info["bits"], ("", ""))
|
||||
if not self.mode:
|
||||
msg = f"Unsupported BMP pixel depth ({file_info['bits']})"
|
||||
raise OSError(msg)
|
||||
|
||||
# ---------------- Process BMP with Bitfields compression (not palette)
|
||||
decoder_name = "raw"
|
||||
if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]:
|
||||
SUPPORTED: dict[int, list[tuple[int, ...]]] = {
|
||||
32: [
|
||||
(0xFF0000, 0xFF00, 0xFF, 0x0),
|
||||
(0xFF000000, 0xFF0000, 0xFF00, 0x0),
|
||||
(0xFF000000, 0xFF00, 0xFF, 0x0),
|
||||
(0xFF000000, 0xFF0000, 0xFF00, 0xFF),
|
||||
(0xFF, 0xFF00, 0xFF0000, 0xFF000000),
|
||||
(0xFF0000, 0xFF00, 0xFF, 0xFF000000),
|
||||
(0xFF000000, 0xFF00, 0xFF, 0xFF0000),
|
||||
(0x0, 0x0, 0x0, 0x0),
|
||||
],
|
||||
24: [(0xFF0000, 0xFF00, 0xFF)],
|
||||
16: [(0xF800, 0x7E0, 0x1F), (0x7C00, 0x3E0, 0x1F)],
|
||||
}
|
||||
MASK_MODES = {
|
||||
(32, (0xFF0000, 0xFF00, 0xFF, 0x0)): "BGRX",
|
||||
(32, (0xFF000000, 0xFF0000, 0xFF00, 0x0)): "XBGR",
|
||||
(32, (0xFF000000, 0xFF00, 0xFF, 0x0)): "BGXR",
|
||||
(32, (0xFF000000, 0xFF0000, 0xFF00, 0xFF)): "ABGR",
|
||||
(32, (0xFF, 0xFF00, 0xFF0000, 0xFF000000)): "RGBA",
|
||||
(32, (0xFF0000, 0xFF00, 0xFF, 0xFF000000)): "BGRA",
|
||||
(32, (0xFF000000, 0xFF00, 0xFF, 0xFF0000)): "BGAR",
|
||||
(32, (0x0, 0x0, 0x0, 0x0)): "BGRA",
|
||||
(24, (0xFF0000, 0xFF00, 0xFF)): "BGR",
|
||||
(16, (0xF800, 0x7E0, 0x1F)): "BGR;16",
|
||||
(16, (0x7C00, 0x3E0, 0x1F)): "BGR;15",
|
||||
}
|
||||
if file_info["bits"] in SUPPORTED:
|
||||
if (
|
||||
file_info["bits"] == 32
|
||||
and file_info["rgba_mask"] in SUPPORTED[file_info["bits"]]
|
||||
):
|
||||
assert isinstance(file_info["rgba_mask"], tuple)
|
||||
raw_mode = MASK_MODES[(file_info["bits"], file_info["rgba_mask"])]
|
||||
self._mode = "RGBA" if "A" in raw_mode else self.mode
|
||||
elif (
|
||||
file_info["bits"] in (24, 16)
|
||||
and file_info["rgb_mask"] in SUPPORTED[file_info["bits"]]
|
||||
):
|
||||
assert isinstance(file_info["rgb_mask"], tuple)
|
||||
raw_mode = MASK_MODES[(file_info["bits"], file_info["rgb_mask"])]
|
||||
else:
|
||||
msg = "Unsupported BMP bitfields layout"
|
||||
raise OSError(msg)
|
||||
else:
|
||||
msg = "Unsupported BMP bitfields layout"
|
||||
raise OSError(msg)
|
||||
elif file_info["compression"] == self.COMPRESSIONS["RAW"]:
|
||||
if file_info["bits"] == 32 and (
|
||||
header == 22 or USE_RAW_ALPHA # 32-bit .cur offset
|
||||
):
|
||||
raw_mode, self._mode = "BGRA", "RGBA"
|
||||
elif file_info["compression"] in (
|
||||
self.COMPRESSIONS["RLE8"],
|
||||
self.COMPRESSIONS["RLE4"],
|
||||
):
|
||||
decoder_name = "bmp_rle"
|
||||
else:
|
||||
msg = f"Unsupported BMP compression ({file_info['compression']})"
|
||||
raise OSError(msg)
|
||||
|
||||
# --------------- Once the header is processed, process the palette/LUT
|
||||
if self.mode == "P": # Paletted for 1, 4 and 8 bit images
|
||||
# ---------------------------------------------------- 1-bit images
|
||||
if not (0 < file_info["colors"] <= 65536):
|
||||
msg = f"Unsupported BMP Palette size ({file_info['colors']})"
|
||||
raise OSError(msg)
|
||||
else:
|
||||
padding = file_info["palette_padding"]
|
||||
palette = read(padding * file_info["colors"])
|
||||
grayscale = True
|
||||
indices = (
|
||||
(0, 255)
|
||||
if file_info["colors"] == 2
|
||||
else list(range(file_info["colors"]))
|
||||
)
|
||||
|
||||
# ----------------- Check if grayscale and ignore palette if so
|
||||
for ind, val in enumerate(indices):
|
||||
rgb = palette[ind * padding : ind * padding + 3]
|
||||
if rgb != o8(val) * 3:
|
||||
grayscale = False
|
||||
|
||||
# ------- If all colors are gray, white or black, ditch palette
|
||||
if grayscale:
|
||||
self._mode = "1" if file_info["colors"] == 2 else "L"
|
||||
raw_mode = self.mode
|
||||
else:
|
||||
self._mode = "P"
|
||||
self.palette = ImagePalette.raw(
|
||||
"BGRX" if padding == 4 else "BGR", palette
|
||||
)
|
||||
|
||||
# ---------------------------- Finally set the tile data for the plugin
|
||||
self.info["compression"] = file_info["compression"]
|
||||
args: list[Any] = [raw_mode]
|
||||
if decoder_name == "bmp_rle":
|
||||
args.append(file_info["compression"] == self.COMPRESSIONS["RLE4"])
|
||||
else:
|
||||
assert isinstance(file_info["width"], int)
|
||||
args.append(((file_info["width"] * file_info["bits"] + 31) >> 3) & (~3))
|
||||
args.append(file_info["direction"])
|
||||
self.tile = [
|
||||
ImageFile._Tile(
|
||||
decoder_name,
|
||||
(0, 0, file_info["width"], file_info["height"]),
|
||||
offset or self.fp.tell(),
|
||||
tuple(args),
|
||||
)
|
||||
]
|
||||
|
||||
def _open(self) -> None:
|
||||
"""Open file, check magic number and read header"""
|
||||
# read 14 bytes: magic number, filesize, reserved, header final offset
|
||||
assert self.fp is not None
|
||||
head_data = self.fp.read(14)
|
||||
# choke if the file does not have the required magic bytes
|
||||
if not _accept(head_data):
|
||||
msg = "Not a BMP file"
|
||||
raise SyntaxError(msg)
|
||||
# read the start position of the BMP image data (u32)
|
||||
offset = i32(head_data, 10)
|
||||
# load bitmap information (offset=raster info)
|
||||
self._bitmap(offset=offset)
|
||||
|
||||
|
||||
class BmpRleDecoder(ImageFile.PyDecoder):
|
||||
_pulls_fd = True
|
||||
|
||||
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
|
||||
assert self.fd is not None
|
||||
rle4 = self.args[1]
|
||||
data = bytearray()
|
||||
x = 0
|
||||
dest_length = self.state.xsize * self.state.ysize
|
||||
while len(data) < dest_length:
|
||||
pixels = self.fd.read(1)
|
||||
byte = self.fd.read(1)
|
||||
if not pixels or not byte:
|
||||
break
|
||||
num_pixels = pixels[0]
|
||||
if num_pixels:
|
||||
# encoded mode
|
||||
if x + num_pixels > self.state.xsize:
|
||||
# Too much data for row
|
||||
num_pixels = max(0, self.state.xsize - x)
|
||||
if rle4:
|
||||
first_pixel = o8(byte[0] >> 4)
|
||||
second_pixel = o8(byte[0] & 0x0F)
|
||||
for index in range(num_pixels):
|
||||
if index % 2 == 0:
|
||||
data += first_pixel
|
||||
else:
|
||||
data += second_pixel
|
||||
else:
|
||||
data += byte * num_pixels
|
||||
x += num_pixels
|
||||
else:
|
||||
if byte[0] == 0:
|
||||
# end of line
|
||||
while len(data) % self.state.xsize != 0:
|
||||
data += b"\x00"
|
||||
x = 0
|
||||
elif byte[0] == 1:
|
||||
# end of bitmap
|
||||
break
|
||||
elif byte[0] == 2:
|
||||
# delta
|
||||
bytes_read = self.fd.read(2)
|
||||
if len(bytes_read) < 2:
|
||||
break
|
||||
right, up = bytes_read
|
||||
data += b"\x00" * (right + up * self.state.xsize)
|
||||
x = len(data) % self.state.xsize
|
||||
else:
|
||||
# absolute mode
|
||||
if rle4:
|
||||
# 2 pixels per byte
|
||||
byte_count = byte[0] // 2
|
||||
bytes_read = self.fd.read(byte_count)
|
||||
for byte_read in bytes_read:
|
||||
data += o8(byte_read >> 4)
|
||||
data += o8(byte_read & 0x0F)
|
||||
else:
|
||||
byte_count = byte[0]
|
||||
bytes_read = self.fd.read(byte_count)
|
||||
data += bytes_read
|
||||
if len(bytes_read) < byte_count:
|
||||
break
|
||||
x += byte[0]
|
||||
|
||||
# align to 16-bit word boundary
|
||||
if self.fd.tell() % 2 != 0:
|
||||
self.fd.seek(1, os.SEEK_CUR)
|
||||
rawmode = "L" if self.mode == "L" else "P"
|
||||
self.set_as_raw(bytes(data), rawmode, (0, self.args[-1]))
|
||||
return -1, 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Image plugin for the DIB format (BMP alias)
|
||||
# =============================================================================
|
||||
class DibImageFile(BmpImageFile):
|
||||
format = "DIB"
|
||||
format_description = "Windows Bitmap"
|
||||
|
||||
def _open(self) -> None:
|
||||
self._bitmap()
|
||||
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
# Write BMP file
|
||||
|
||||
|
||||
SAVE = {
|
||||
"1": ("1", 1, 2),
|
||||
"L": ("L", 8, 256),
|
||||
"P": ("P", 8, 256),
|
||||
"RGB": ("BGR", 24, 0),
|
||||
"RGBA": ("BGRA", 32, 0),
|
||||
}
|
||||
|
||||
|
||||
def _dib_save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||
_save(im, fp, filename, False)
|
||||
|
||||
|
||||
def _save(
|
||||
im: Image.Image, fp: IO[bytes], filename: str | bytes, bitmap_header: bool = True
|
||||
) -> None:
|
||||
try:
|
||||
rawmode, bits, colors = SAVE[im.mode]
|
||||
except KeyError as e:
|
||||
msg = f"cannot write mode {im.mode} as BMP"
|
||||
raise OSError(msg) from e
|
||||
|
||||
info = im.encoderinfo
|
||||
|
||||
dpi = info.get("dpi", (96, 96))
|
||||
|
||||
# 1 meter == 39.3701 inches
|
||||
ppm = tuple(int(x * 39.3701 + 0.5) for x in dpi)
|
||||
|
||||
stride = ((im.size[0] * bits + 7) // 8 + 3) & (~3)
|
||||
header = 40 # or 64 for OS/2 version 2
|
||||
image = stride * im.size[1]
|
||||
|
||||
if im.mode == "1":
|
||||
palette = b"".join(o8(i) * 3 + b"\x00" for i in (0, 255))
|
||||
elif im.mode == "L":
|
||||
palette = b"".join(o8(i) * 3 + b"\x00" for i in range(256))
|
||||
elif im.mode == "P":
|
||||
palette = im.im.getpalette("RGB", "BGRX")
|
||||
colors = len(palette) // 4
|
||||
else:
|
||||
palette = None
|
||||
|
||||
# bitmap header
|
||||
if bitmap_header:
|
||||
offset = 14 + header + colors * 4
|
||||
file_size = offset + image
|
||||
if file_size > 2**32 - 1:
|
||||
msg = "File size is too large for the BMP format"
|
||||
raise ValueError(msg)
|
||||
fp.write(
|
||||
b"BM" # file type (magic)
|
||||
+ o32(file_size) # file size
|
||||
+ o32(0) # reserved
|
||||
+ o32(offset) # image data offset
|
||||
)
|
||||
|
||||
# bitmap info header
|
||||
fp.write(
|
||||
o32(header) # info header size
|
||||
+ o32(im.size[0]) # width
|
||||
+ o32(im.size[1]) # height
|
||||
+ o16(1) # planes
|
||||
+ o16(bits) # depth
|
||||
+ o32(0) # compression (0=uncompressed)
|
||||
+ o32(image) # size of bitmap
|
||||
+ o32(ppm[0]) # resolution
|
||||
+ o32(ppm[1]) # resolution
|
||||
+ o32(colors) # colors used
|
||||
+ o32(colors) # colors important
|
||||
)
|
||||
|
||||
fp.write(b"\0" * (header - 40)) # padding (for OS/2 format)
|
||||
|
||||
if palette:
|
||||
fp.write(palette)
|
||||
|
||||
ImageFile._save(
|
||||
im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, stride, -1))]
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
# Registry
|
||||
|
||||
|
||||
Image.register_open(BmpImageFile.format, BmpImageFile, _accept)
|
||||
Image.register_save(BmpImageFile.format, _save)
|
||||
|
||||
Image.register_extension(BmpImageFile.format, ".bmp")
|
||||
|
||||
Image.register_mime(BmpImageFile.format, "image/bmp")
|
||||
|
||||
Image.register_decoder("bmp_rle", BmpRleDecoder)
|
||||
|
||||
Image.register_open(DibImageFile.format, DibImageFile, _dib_accept)
|
||||
Image.register_save(DibImageFile.format, _dib_save)
|
||||
|
||||
Image.register_extension(DibImageFile.format, ".dib")
|
||||
|
||||
Image.register_mime(DibImageFile.format, "image/bmp")
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
#
|
||||
# The Python Imaging Library
|
||||
# $Id$
|
||||
#
|
||||
# BUFR stub adapter
|
||||
#
|
||||
# Copyright (c) 1996-2003 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import IO
|
||||
|
||||
from . import Image, ImageFile
|
||||
|
||||
_handler = None
|
||||
|
||||
|
||||
def register_handler(handler: ImageFile.StubHandler | None) -> None:
|
||||
"""
|
||||
Install application-specific BUFR image handler.
|
||||
|
||||
:param handler: Handler object.
|
||||
"""
|
||||
global _handler
|
||||
_handler = handler
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Image adapter
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith((b"BUFR", b"ZCZC"))
|
||||
|
||||
|
||||
class BufrStubImageFile(ImageFile.StubImageFile):
|
||||
format = "BUFR"
|
||||
format_description = "BUFR"
|
||||
|
||||
def _open(self) -> None:
|
||||
assert self.fp is not None
|
||||
if not _accept(self.fp.read(4)):
|
||||
msg = "Not a BUFR file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
self.fp.seek(-4, os.SEEK_CUR)
|
||||
|
||||
# make something up
|
||||
self._mode = "F"
|
||||
self._size = 1, 1
|
||||
|
||||
def _load(self) -> ImageFile.StubHandler | None:
|
||||
return _handler
|
||||
|
||||
|
||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||
if _handler is None or not hasattr(_handler, "save"):
|
||||
msg = "BUFR save handler not installed"
|
||||
raise OSError(msg)
|
||||
_handler.save(im, fp, filename)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Registry
|
||||
|
||||
Image.register_open(BufrStubImageFile.format, BufrStubImageFile, _accept)
|
||||
Image.register_save(BufrStubImageFile.format, _save)
|
||||
|
||||
Image.register_extension(BufrStubImageFile.format, ".bufr")
|
||||
@@ -0,0 +1,173 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# a class to read from a container file
|
||||
#
|
||||
# History:
|
||||
# 1995-06-18 fl Created
|
||||
# 1995-09-07 fl Added readline(), readlines()
|
||||
#
|
||||
# Copyright (c) 1997-2001 by Secret Labs AB
|
||||
# Copyright (c) 1995 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from collections.abc import Iterable
|
||||
from typing import IO, AnyStr, NoReturn
|
||||
|
||||
|
||||
class ContainerIO(IO[AnyStr]):
|
||||
"""
|
||||
A file object that provides read access to a part of an existing
|
||||
file (for example a TAR file).
|
||||
"""
|
||||
|
||||
def __init__(self, file: IO[AnyStr], offset: int, length: int) -> None:
|
||||
"""
|
||||
Create file object.
|
||||
|
||||
:param file: Existing file.
|
||||
:param offset: Start of region, in bytes.
|
||||
:param length: Size of region, in bytes.
|
||||
"""
|
||||
self.fh: IO[AnyStr] = file
|
||||
self.pos = 0
|
||||
self.offset = offset
|
||||
self.length = length
|
||||
self.fh.seek(offset)
|
||||
|
||||
##
|
||||
# Always false.
|
||||
|
||||
def isatty(self) -> bool:
|
||||
return False
|
||||
|
||||
def seekable(self) -> bool:
|
||||
return True
|
||||
|
||||
def seek(self, offset: int, mode: int = io.SEEK_SET) -> int:
|
||||
"""
|
||||
Move file pointer.
|
||||
|
||||
:param offset: Offset in bytes.
|
||||
:param mode: Starting position. Use 0 for beginning of region, 1
|
||||
for current offset, and 2 for end of region. You cannot move
|
||||
the pointer outside the defined region.
|
||||
:returns: Offset from start of region, in bytes.
|
||||
"""
|
||||
if mode == 1:
|
||||
self.pos = self.pos + offset
|
||||
elif mode == 2:
|
||||
self.pos = self.length + offset
|
||||
else:
|
||||
self.pos = offset
|
||||
# clamp
|
||||
self.pos = max(0, min(self.pos, self.length))
|
||||
self.fh.seek(self.offset + self.pos)
|
||||
return self.pos
|
||||
|
||||
def tell(self) -> int:
|
||||
"""
|
||||
Get current file pointer.
|
||||
|
||||
:returns: Offset from start of region, in bytes.
|
||||
"""
|
||||
return self.pos
|
||||
|
||||
def readable(self) -> bool:
|
||||
return True
|
||||
|
||||
def read(self, n: int = -1) -> AnyStr:
|
||||
"""
|
||||
Read data.
|
||||
|
||||
:param n: Number of bytes to read. If omitted, zero or negative,
|
||||
read until end of region.
|
||||
:returns: An 8-bit string.
|
||||
"""
|
||||
if n > 0:
|
||||
n = min(n, self.length - self.pos)
|
||||
else:
|
||||
n = self.length - self.pos
|
||||
if n <= 0: # EOF
|
||||
return b"" if "b" in self.fh.mode else "" # type: ignore[return-value]
|
||||
self.pos = self.pos + n
|
||||
return self.fh.read(n)
|
||||
|
||||
def readline(self, n: int = -1) -> AnyStr:
|
||||
"""
|
||||
Read a line of text.
|
||||
|
||||
:param n: Number of bytes to read. If omitted, zero or negative,
|
||||
read until end of line.
|
||||
:returns: An 8-bit string.
|
||||
"""
|
||||
s: AnyStr = b"" if "b" in self.fh.mode else "" # type: ignore[assignment]
|
||||
newline_character = b"\n" if "b" in self.fh.mode else "\n"
|
||||
while True:
|
||||
c = self.read(1)
|
||||
if not c:
|
||||
break
|
||||
s = s + c
|
||||
if c == newline_character or len(s) == n:
|
||||
break
|
||||
return s
|
||||
|
||||
def readlines(self, n: int | None = -1) -> list[AnyStr]:
|
||||
"""
|
||||
Read multiple lines of text.
|
||||
|
||||
:param n: Number of lines to read. If omitted, zero, negative or None,
|
||||
read until end of region.
|
||||
:returns: A list of 8-bit strings.
|
||||
"""
|
||||
lines = []
|
||||
while True:
|
||||
s = self.readline()
|
||||
if not s:
|
||||
break
|
||||
lines.append(s)
|
||||
if len(lines) == n:
|
||||
break
|
||||
return lines
|
||||
|
||||
def writable(self) -> bool:
|
||||
return False
|
||||
|
||||
def write(self, b: AnyStr) -> NoReturn:
|
||||
raise NotImplementedError()
|
||||
|
||||
def writelines(self, lines: Iterable[AnyStr]) -> NoReturn:
|
||||
raise NotImplementedError()
|
||||
|
||||
def truncate(self, size: int | None = None) -> int:
|
||||
raise NotImplementedError()
|
||||
|
||||
def __enter__(self) -> ContainerIO[AnyStr]:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
self.close()
|
||||
|
||||
def __iter__(self) -> ContainerIO[AnyStr]:
|
||||
return self
|
||||
|
||||
def __next__(self) -> AnyStr:
|
||||
line = self.readline()
|
||||
if not line:
|
||||
msg = "end of region"
|
||||
raise StopIteration(msg)
|
||||
return line
|
||||
|
||||
def fileno(self) -> int:
|
||||
return self.fh.fileno()
|
||||
|
||||
def flush(self) -> None:
|
||||
self.fh.flush()
|
||||
|
||||
def close(self) -> None:
|
||||
self.fh.close()
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# Windows Cursor support for PIL
|
||||
#
|
||||
# notes:
|
||||
# uses BmpImagePlugin.py to read the bitmap data.
|
||||
#
|
||||
# history:
|
||||
# 96-05-27 fl Created
|
||||
#
|
||||
# Copyright (c) Secret Labs AB 1997.
|
||||
# Copyright (c) Fredrik Lundh 1996.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
from . import BmpImagePlugin, Image
|
||||
from ._binary import i16le as i16
|
||||
from ._binary import i32le as i32
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith(b"\0\0\2\0")
|
||||
|
||||
|
||||
##
|
||||
# Image plugin for Windows Cursor files.
|
||||
|
||||
|
||||
class CurImageFile(BmpImagePlugin.BmpImageFile):
|
||||
format = "CUR"
|
||||
format_description = "Windows Cursor"
|
||||
|
||||
def _open(self) -> None:
|
||||
assert self.fp is not None
|
||||
offset = self.fp.tell()
|
||||
|
||||
# check magic
|
||||
s = self.fp.read(6)
|
||||
if not _accept(s):
|
||||
msg = "not a CUR file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
# pick the largest cursor in the file
|
||||
m = b""
|
||||
for i in range(i16(s, 4)):
|
||||
s = self.fp.read(16)
|
||||
if not m:
|
||||
m = s
|
||||
elif s[0] > m[0] and s[1] > m[1]:
|
||||
m = s
|
||||
if not m:
|
||||
msg = "No cursors were found"
|
||||
raise TypeError(msg)
|
||||
|
||||
# load as bitmap
|
||||
self._bitmap(i32(m, 12) + offset)
|
||||
|
||||
# patch up the bitmap height
|
||||
self._size = self.size[0], self.size[1] // 2
|
||||
self.tile = [self.tile[0]._replace(extents=(0, 0) + self.size)]
|
||||
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
Image.register_open(CurImageFile.format, CurImageFile, _accept)
|
||||
|
||||
Image.register_extension(CurImageFile.format, ".cur")
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# DCX file handling
|
||||
#
|
||||
# DCX is a container file format defined by Intel, commonly used
|
||||
# for fax applications. Each DCX file consists of a directory
|
||||
# (a list of file offsets) followed by a set of (usually 1-bit)
|
||||
# PCX files.
|
||||
#
|
||||
# History:
|
||||
# 1995-09-09 fl Created
|
||||
# 1996-03-20 fl Properly derived from PcxImageFile.
|
||||
# 1998-07-15 fl Renamed offset attribute to avoid name clash
|
||||
# 2002-07-30 fl Fixed file handling
|
||||
#
|
||||
# Copyright (c) 1997-98 by Secret Labs AB.
|
||||
# Copyright (c) 1995-96 by Fredrik Lundh.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
from . import Image
|
||||
from ._binary import i32le as i32
|
||||
from ._util import DeferredError
|
||||
from .PcxImagePlugin import PcxImageFile
|
||||
|
||||
MAGIC = 0x3ADE68B1 # QUIZ: what's this value, then?
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return len(prefix) >= 4 and i32(prefix) == MAGIC
|
||||
|
||||
|
||||
##
|
||||
# Image plugin for the Intel DCX format.
|
||||
|
||||
|
||||
class DcxImageFile(PcxImageFile):
|
||||
format = "DCX"
|
||||
format_description = "Intel DCX"
|
||||
_close_exclusive_fp_after_loading = False
|
||||
|
||||
def _open(self) -> None:
|
||||
# Header
|
||||
assert self.fp is not None
|
||||
s = self.fp.read(4)
|
||||
if not _accept(s):
|
||||
msg = "not a DCX file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
# Component directory
|
||||
self._offset = []
|
||||
for i in range(1024):
|
||||
offset = i32(self.fp.read(4))
|
||||
if not offset:
|
||||
break
|
||||
self._offset.append(offset)
|
||||
|
||||
self._fp = self.fp
|
||||
self.frame = -1
|
||||
self.n_frames = len(self._offset)
|
||||
self.is_animated = self.n_frames > 1
|
||||
self.seek(0)
|
||||
|
||||
def seek(self, frame: int) -> None:
|
||||
if not self._seek_check(frame):
|
||||
return
|
||||
if isinstance(self._fp, DeferredError):
|
||||
raise self._fp.ex
|
||||
self.frame = frame
|
||||
self.fp = self._fp
|
||||
self.fp.seek(self._offset[frame])
|
||||
PcxImageFile._open(self)
|
||||
|
||||
def tell(self) -> int:
|
||||
return self.frame
|
||||
|
||||
|
||||
Image.register_open(DcxImageFile.format, DcxImageFile, _accept)
|
||||
|
||||
Image.register_extension(DcxImageFile.format, ".dcx")
|
||||
+625
@@ -0,0 +1,625 @@
|
||||
"""
|
||||
A Pillow plugin for .dds files (S3TC-compressed aka DXTC)
|
||||
Jerome Leclanche <jerome@leclan.ch>
|
||||
|
||||
Documentation:
|
||||
https://web.archive.org/web/20170802060935/http://oss.sgi.com/projects/ogl-sample/registry/EXT/texture_compression_s3tc.txt
|
||||
|
||||
The contents of this file are hereby released in the public domain (CC0)
|
||||
Full text of the CC0 license:
|
||||
https://creativecommons.org/publicdomain/zero/1.0/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import sys
|
||||
from enum import IntEnum, IntFlag
|
||||
from typing import IO
|
||||
|
||||
from . import Image, ImageFile, ImagePalette
|
||||
from ._binary import i32le as i32
|
||||
from ._binary import o8
|
||||
from ._binary import o32le as o32
|
||||
|
||||
# Magic ("DDS ")
|
||||
DDS_MAGIC = 0x20534444
|
||||
|
||||
|
||||
# DDS flags
|
||||
class DDSD(IntFlag):
|
||||
CAPS = 0x1
|
||||
HEIGHT = 0x2
|
||||
WIDTH = 0x4
|
||||
PITCH = 0x8
|
||||
PIXELFORMAT = 0x1000
|
||||
MIPMAPCOUNT = 0x20000
|
||||
LINEARSIZE = 0x80000
|
||||
DEPTH = 0x800000
|
||||
|
||||
|
||||
# DDS caps
|
||||
class DDSCAPS(IntFlag):
|
||||
COMPLEX = 0x8
|
||||
TEXTURE = 0x1000
|
||||
MIPMAP = 0x400000
|
||||
|
||||
|
||||
class DDSCAPS2(IntFlag):
|
||||
CUBEMAP = 0x200
|
||||
CUBEMAP_POSITIVEX = 0x400
|
||||
CUBEMAP_NEGATIVEX = 0x800
|
||||
CUBEMAP_POSITIVEY = 0x1000
|
||||
CUBEMAP_NEGATIVEY = 0x2000
|
||||
CUBEMAP_POSITIVEZ = 0x4000
|
||||
CUBEMAP_NEGATIVEZ = 0x8000
|
||||
VOLUME = 0x200000
|
||||
|
||||
|
||||
# Pixel Format
|
||||
class DDPF(IntFlag):
|
||||
ALPHAPIXELS = 0x1
|
||||
ALPHA = 0x2
|
||||
FOURCC = 0x4
|
||||
PALETTEINDEXED8 = 0x20
|
||||
RGB = 0x40
|
||||
LUMINANCE = 0x20000
|
||||
|
||||
|
||||
# dxgiformat.h
|
||||
class DXGI_FORMAT(IntEnum):
|
||||
UNKNOWN = 0
|
||||
R32G32B32A32_TYPELESS = 1
|
||||
R32G32B32A32_FLOAT = 2
|
||||
R32G32B32A32_UINT = 3
|
||||
R32G32B32A32_SINT = 4
|
||||
R32G32B32_TYPELESS = 5
|
||||
R32G32B32_FLOAT = 6
|
||||
R32G32B32_UINT = 7
|
||||
R32G32B32_SINT = 8
|
||||
R16G16B16A16_TYPELESS = 9
|
||||
R16G16B16A16_FLOAT = 10
|
||||
R16G16B16A16_UNORM = 11
|
||||
R16G16B16A16_UINT = 12
|
||||
R16G16B16A16_SNORM = 13
|
||||
R16G16B16A16_SINT = 14
|
||||
R32G32_TYPELESS = 15
|
||||
R32G32_FLOAT = 16
|
||||
R32G32_UINT = 17
|
||||
R32G32_SINT = 18
|
||||
R32G8X24_TYPELESS = 19
|
||||
D32_FLOAT_S8X24_UINT = 20
|
||||
R32_FLOAT_X8X24_TYPELESS = 21
|
||||
X32_TYPELESS_G8X24_UINT = 22
|
||||
R10G10B10A2_TYPELESS = 23
|
||||
R10G10B10A2_UNORM = 24
|
||||
R10G10B10A2_UINT = 25
|
||||
R11G11B10_FLOAT = 26
|
||||
R8G8B8A8_TYPELESS = 27
|
||||
R8G8B8A8_UNORM = 28
|
||||
R8G8B8A8_UNORM_SRGB = 29
|
||||
R8G8B8A8_UINT = 30
|
||||
R8G8B8A8_SNORM = 31
|
||||
R8G8B8A8_SINT = 32
|
||||
R16G16_TYPELESS = 33
|
||||
R16G16_FLOAT = 34
|
||||
R16G16_UNORM = 35
|
||||
R16G16_UINT = 36
|
||||
R16G16_SNORM = 37
|
||||
R16G16_SINT = 38
|
||||
R32_TYPELESS = 39
|
||||
D32_FLOAT = 40
|
||||
R32_FLOAT = 41
|
||||
R32_UINT = 42
|
||||
R32_SINT = 43
|
||||
R24G8_TYPELESS = 44
|
||||
D24_UNORM_S8_UINT = 45
|
||||
R24_UNORM_X8_TYPELESS = 46
|
||||
X24_TYPELESS_G8_UINT = 47
|
||||
R8G8_TYPELESS = 48
|
||||
R8G8_UNORM = 49
|
||||
R8G8_UINT = 50
|
||||
R8G8_SNORM = 51
|
||||
R8G8_SINT = 52
|
||||
R16_TYPELESS = 53
|
||||
R16_FLOAT = 54
|
||||
D16_UNORM = 55
|
||||
R16_UNORM = 56
|
||||
R16_UINT = 57
|
||||
R16_SNORM = 58
|
||||
R16_SINT = 59
|
||||
R8_TYPELESS = 60
|
||||
R8_UNORM = 61
|
||||
R8_UINT = 62
|
||||
R8_SNORM = 63
|
||||
R8_SINT = 64
|
||||
A8_UNORM = 65
|
||||
R1_UNORM = 66
|
||||
R9G9B9E5_SHAREDEXP = 67
|
||||
R8G8_B8G8_UNORM = 68
|
||||
G8R8_G8B8_UNORM = 69
|
||||
BC1_TYPELESS = 70
|
||||
BC1_UNORM = 71
|
||||
BC1_UNORM_SRGB = 72
|
||||
BC2_TYPELESS = 73
|
||||
BC2_UNORM = 74
|
||||
BC2_UNORM_SRGB = 75
|
||||
BC3_TYPELESS = 76
|
||||
BC3_UNORM = 77
|
||||
BC3_UNORM_SRGB = 78
|
||||
BC4_TYPELESS = 79
|
||||
BC4_UNORM = 80
|
||||
BC4_SNORM = 81
|
||||
BC5_TYPELESS = 82
|
||||
BC5_UNORM = 83
|
||||
BC5_SNORM = 84
|
||||
B5G6R5_UNORM = 85
|
||||
B5G5R5A1_UNORM = 86
|
||||
B8G8R8A8_UNORM = 87
|
||||
B8G8R8X8_UNORM = 88
|
||||
R10G10B10_XR_BIAS_A2_UNORM = 89
|
||||
B8G8R8A8_TYPELESS = 90
|
||||
B8G8R8A8_UNORM_SRGB = 91
|
||||
B8G8R8X8_TYPELESS = 92
|
||||
B8G8R8X8_UNORM_SRGB = 93
|
||||
BC6H_TYPELESS = 94
|
||||
BC6H_UF16 = 95
|
||||
BC6H_SF16 = 96
|
||||
BC7_TYPELESS = 97
|
||||
BC7_UNORM = 98
|
||||
BC7_UNORM_SRGB = 99
|
||||
AYUV = 100
|
||||
Y410 = 101
|
||||
Y416 = 102
|
||||
NV12 = 103
|
||||
P010 = 104
|
||||
P016 = 105
|
||||
OPAQUE_420 = 106
|
||||
YUY2 = 107
|
||||
Y210 = 108
|
||||
Y216 = 109
|
||||
NV11 = 110
|
||||
AI44 = 111
|
||||
IA44 = 112
|
||||
P8 = 113
|
||||
A8P8 = 114
|
||||
B4G4R4A4_UNORM = 115
|
||||
P208 = 130
|
||||
V208 = 131
|
||||
V408 = 132
|
||||
SAMPLER_FEEDBACK_MIN_MIP_OPAQUE = 189
|
||||
SAMPLER_FEEDBACK_MIP_REGION_USED_OPAQUE = 190
|
||||
|
||||
|
||||
class D3DFMT(IntEnum):
|
||||
UNKNOWN = 0
|
||||
R8G8B8 = 20
|
||||
A8R8G8B8 = 21
|
||||
X8R8G8B8 = 22
|
||||
R5G6B5 = 23
|
||||
X1R5G5B5 = 24
|
||||
A1R5G5B5 = 25
|
||||
A4R4G4B4 = 26
|
||||
R3G3B2 = 27
|
||||
A8 = 28
|
||||
A8R3G3B2 = 29
|
||||
X4R4G4B4 = 30
|
||||
A2B10G10R10 = 31
|
||||
A8B8G8R8 = 32
|
||||
X8B8G8R8 = 33
|
||||
G16R16 = 34
|
||||
A2R10G10B10 = 35
|
||||
A16B16G16R16 = 36
|
||||
A8P8 = 40
|
||||
P8 = 41
|
||||
L8 = 50
|
||||
A8L8 = 51
|
||||
A4L4 = 52
|
||||
V8U8 = 60
|
||||
L6V5U5 = 61
|
||||
X8L8V8U8 = 62
|
||||
Q8W8V8U8 = 63
|
||||
V16U16 = 64
|
||||
A2W10V10U10 = 67
|
||||
D16_LOCKABLE = 70
|
||||
D32 = 71
|
||||
D15S1 = 73
|
||||
D24S8 = 75
|
||||
D24X8 = 77
|
||||
D24X4S4 = 79
|
||||
D16 = 80
|
||||
D32F_LOCKABLE = 82
|
||||
D24FS8 = 83
|
||||
D32_LOCKABLE = 84
|
||||
S8_LOCKABLE = 85
|
||||
L16 = 81
|
||||
VERTEXDATA = 100
|
||||
INDEX16 = 101
|
||||
INDEX32 = 102
|
||||
Q16W16V16U16 = 110
|
||||
R16F = 111
|
||||
G16R16F = 112
|
||||
A16B16G16R16F = 113
|
||||
R32F = 114
|
||||
G32R32F = 115
|
||||
A32B32G32R32F = 116
|
||||
CxV8U8 = 117
|
||||
A1 = 118
|
||||
A2B10G10R10_XR_BIAS = 119
|
||||
BINARYBUFFER = 199
|
||||
|
||||
UYVY = i32(b"UYVY")
|
||||
R8G8_B8G8 = i32(b"RGBG")
|
||||
YUY2 = i32(b"YUY2")
|
||||
G8R8_G8B8 = i32(b"GRGB")
|
||||
DXT1 = i32(b"DXT1")
|
||||
DXT2 = i32(b"DXT2")
|
||||
DXT3 = i32(b"DXT3")
|
||||
DXT4 = i32(b"DXT4")
|
||||
DXT5 = i32(b"DXT5")
|
||||
DX10 = i32(b"DX10")
|
||||
BC4S = i32(b"BC4S")
|
||||
BC4U = i32(b"BC4U")
|
||||
BC5S = i32(b"BC5S")
|
||||
BC5U = i32(b"BC5U")
|
||||
ATI1 = i32(b"ATI1")
|
||||
ATI2 = i32(b"ATI2")
|
||||
MULTI2_ARGB8 = i32(b"MET1")
|
||||
|
||||
|
||||
# Backward compatibility layer
|
||||
module = sys.modules[__name__]
|
||||
for item in DDSD:
|
||||
assert item.name is not None
|
||||
setattr(module, f"DDSD_{item.name}", item.value)
|
||||
for item1 in DDSCAPS:
|
||||
assert item1.name is not None
|
||||
setattr(module, f"DDSCAPS_{item1.name}", item1.value)
|
||||
for item2 in DDSCAPS2:
|
||||
assert item2.name is not None
|
||||
setattr(module, f"DDSCAPS2_{item2.name}", item2.value)
|
||||
for item3 in DDPF:
|
||||
assert item3.name is not None
|
||||
setattr(module, f"DDPF_{item3.name}", item3.value)
|
||||
|
||||
DDS_FOURCC = DDPF.FOURCC
|
||||
DDS_RGB = DDPF.RGB
|
||||
DDS_RGBA = DDPF.RGB | DDPF.ALPHAPIXELS
|
||||
DDS_LUMINANCE = DDPF.LUMINANCE
|
||||
DDS_LUMINANCEA = DDPF.LUMINANCE | DDPF.ALPHAPIXELS
|
||||
DDS_ALPHA = DDPF.ALPHA
|
||||
DDS_PAL8 = DDPF.PALETTEINDEXED8
|
||||
|
||||
DDS_HEADER_FLAGS_TEXTURE = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT
|
||||
DDS_HEADER_FLAGS_MIPMAP = DDSD.MIPMAPCOUNT
|
||||
DDS_HEADER_FLAGS_VOLUME = DDSD.DEPTH
|
||||
DDS_HEADER_FLAGS_PITCH = DDSD.PITCH
|
||||
DDS_HEADER_FLAGS_LINEARSIZE = DDSD.LINEARSIZE
|
||||
|
||||
DDS_HEIGHT = DDSD.HEIGHT
|
||||
DDS_WIDTH = DDSD.WIDTH
|
||||
|
||||
DDS_SURFACE_FLAGS_TEXTURE = DDSCAPS.TEXTURE
|
||||
DDS_SURFACE_FLAGS_MIPMAP = DDSCAPS.COMPLEX | DDSCAPS.MIPMAP
|
||||
DDS_SURFACE_FLAGS_CUBEMAP = DDSCAPS.COMPLEX
|
||||
|
||||
DDS_CUBEMAP_POSITIVEX = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEX
|
||||
DDS_CUBEMAP_NEGATIVEX = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEX
|
||||
DDS_CUBEMAP_POSITIVEY = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEY
|
||||
DDS_CUBEMAP_NEGATIVEY = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEY
|
||||
DDS_CUBEMAP_POSITIVEZ = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEZ
|
||||
DDS_CUBEMAP_NEGATIVEZ = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEZ
|
||||
|
||||
DXT1_FOURCC = D3DFMT.DXT1
|
||||
DXT3_FOURCC = D3DFMT.DXT3
|
||||
DXT5_FOURCC = D3DFMT.DXT5
|
||||
|
||||
DXGI_FORMAT_R8G8B8A8_TYPELESS = DXGI_FORMAT.R8G8B8A8_TYPELESS
|
||||
DXGI_FORMAT_R8G8B8A8_UNORM = DXGI_FORMAT.R8G8B8A8_UNORM
|
||||
DXGI_FORMAT_R8G8B8A8_UNORM_SRGB = DXGI_FORMAT.R8G8B8A8_UNORM_SRGB
|
||||
DXGI_FORMAT_BC5_TYPELESS = DXGI_FORMAT.BC5_TYPELESS
|
||||
DXGI_FORMAT_BC5_UNORM = DXGI_FORMAT.BC5_UNORM
|
||||
DXGI_FORMAT_BC5_SNORM = DXGI_FORMAT.BC5_SNORM
|
||||
DXGI_FORMAT_BC6H_UF16 = DXGI_FORMAT.BC6H_UF16
|
||||
DXGI_FORMAT_BC6H_SF16 = DXGI_FORMAT.BC6H_SF16
|
||||
DXGI_FORMAT_BC7_TYPELESS = DXGI_FORMAT.BC7_TYPELESS
|
||||
DXGI_FORMAT_BC7_UNORM = DXGI_FORMAT.BC7_UNORM
|
||||
DXGI_FORMAT_BC7_UNORM_SRGB = DXGI_FORMAT.BC7_UNORM_SRGB
|
||||
|
||||
|
||||
class DdsImageFile(ImageFile.ImageFile):
|
||||
format = "DDS"
|
||||
format_description = "DirectDraw Surface"
|
||||
|
||||
def _open(self) -> None:
|
||||
assert self.fp is not None
|
||||
if not _accept(self.fp.read(4)):
|
||||
msg = "not a DDS file"
|
||||
raise SyntaxError(msg)
|
||||
(header_size,) = struct.unpack("<I", self.fp.read(4))
|
||||
if header_size != 124:
|
||||
msg = f"Unsupported header size {repr(header_size)}"
|
||||
raise OSError(msg)
|
||||
header = self.fp.read(header_size - 4)
|
||||
if len(header) != 120:
|
||||
msg = f"Incomplete header: {len(header)} bytes"
|
||||
raise OSError(msg)
|
||||
|
||||
flags, height, width = struct.unpack("<3I", header[:12])
|
||||
self._size = (width, height)
|
||||
extents = (0, 0) + self.size
|
||||
|
||||
pitch, depth, mipmaps = struct.unpack("<3I", header[12:24])
|
||||
struct.unpack("<11I", header[24:68]) # reserved
|
||||
|
||||
# pixel format
|
||||
pfsize, pfflags, fourcc, bitcount = struct.unpack("<4I", header[68:84])
|
||||
n = 0
|
||||
rawmode = None
|
||||
if pfflags & DDPF.RGB:
|
||||
# Texture contains uncompressed RGB data
|
||||
if pfflags & DDPF.ALPHAPIXELS:
|
||||
self._mode = "RGBA"
|
||||
mask_count = 4
|
||||
else:
|
||||
self._mode = "RGB"
|
||||
mask_count = 3
|
||||
|
||||
masks = struct.unpack(f"<{mask_count}I", header[84 : 84 + mask_count * 4])
|
||||
self.tile = [ImageFile._Tile("dds_rgb", extents, 0, (bitcount, masks))]
|
||||
return
|
||||
elif pfflags & DDPF.LUMINANCE:
|
||||
if bitcount == 8:
|
||||
self._mode = "L"
|
||||
elif bitcount == 16 and pfflags & DDPF.ALPHAPIXELS:
|
||||
self._mode = "LA"
|
||||
else:
|
||||
msg = f"Unsupported bitcount {bitcount} for {pfflags}"
|
||||
raise OSError(msg)
|
||||
elif pfflags & DDPF.PALETTEINDEXED8:
|
||||
self._mode = "P"
|
||||
self.palette = ImagePalette.raw("RGBA", self.fp.read(1024))
|
||||
self.palette.mode = "RGBA"
|
||||
elif pfflags & DDPF.FOURCC:
|
||||
offset = header_size + 4
|
||||
if fourcc == D3DFMT.DXT1:
|
||||
self._mode = "RGBA"
|
||||
self.pixel_format = "DXT1"
|
||||
n = 1
|
||||
elif fourcc == D3DFMT.DXT3:
|
||||
self._mode = "RGBA"
|
||||
self.pixel_format = "DXT3"
|
||||
n = 2
|
||||
elif fourcc == D3DFMT.DXT5:
|
||||
self._mode = "RGBA"
|
||||
self.pixel_format = "DXT5"
|
||||
n = 3
|
||||
elif fourcc in (D3DFMT.BC4U, D3DFMT.ATI1):
|
||||
self._mode = "L"
|
||||
self.pixel_format = "BC4"
|
||||
n = 4
|
||||
elif fourcc == D3DFMT.BC5S:
|
||||
self._mode = "RGB"
|
||||
self.pixel_format = "BC5S"
|
||||
n = 5
|
||||
elif fourcc in (D3DFMT.BC5U, D3DFMT.ATI2):
|
||||
self._mode = "RGB"
|
||||
self.pixel_format = "BC5"
|
||||
n = 5
|
||||
elif fourcc == D3DFMT.DX10:
|
||||
offset += 20
|
||||
# ignoring flags which pertain to volume textures and cubemaps
|
||||
(dxgi_format,) = struct.unpack("<I", self.fp.read(4))
|
||||
self.fp.read(16)
|
||||
if dxgi_format in (
|
||||
DXGI_FORMAT.BC1_UNORM,
|
||||
DXGI_FORMAT.BC1_TYPELESS,
|
||||
):
|
||||
self._mode = "RGBA"
|
||||
self.pixel_format = "BC1"
|
||||
n = 1
|
||||
elif dxgi_format in (DXGI_FORMAT.BC2_TYPELESS, DXGI_FORMAT.BC2_UNORM):
|
||||
self._mode = "RGBA"
|
||||
self.pixel_format = "BC2"
|
||||
n = 2
|
||||
elif dxgi_format in (DXGI_FORMAT.BC3_TYPELESS, DXGI_FORMAT.BC3_UNORM):
|
||||
self._mode = "RGBA"
|
||||
self.pixel_format = "BC3"
|
||||
n = 3
|
||||
elif dxgi_format in (DXGI_FORMAT.BC4_TYPELESS, DXGI_FORMAT.BC4_UNORM):
|
||||
self._mode = "L"
|
||||
self.pixel_format = "BC4"
|
||||
n = 4
|
||||
elif dxgi_format in (DXGI_FORMAT.BC5_TYPELESS, DXGI_FORMAT.BC5_UNORM):
|
||||
self._mode = "RGB"
|
||||
self.pixel_format = "BC5"
|
||||
n = 5
|
||||
elif dxgi_format == DXGI_FORMAT.BC5_SNORM:
|
||||
self._mode = "RGB"
|
||||
self.pixel_format = "BC5S"
|
||||
n = 5
|
||||
elif dxgi_format == DXGI_FORMAT.BC6H_UF16:
|
||||
self._mode = "RGB"
|
||||
self.pixel_format = "BC6H"
|
||||
n = 6
|
||||
elif dxgi_format == DXGI_FORMAT.BC6H_SF16:
|
||||
self._mode = "RGB"
|
||||
self.pixel_format = "BC6HS"
|
||||
n = 6
|
||||
elif dxgi_format in (
|
||||
DXGI_FORMAT.BC7_TYPELESS,
|
||||
DXGI_FORMAT.BC7_UNORM,
|
||||
DXGI_FORMAT.BC7_UNORM_SRGB,
|
||||
):
|
||||
self._mode = "RGBA"
|
||||
self.pixel_format = "BC7"
|
||||
n = 7
|
||||
if dxgi_format == DXGI_FORMAT.BC7_UNORM_SRGB:
|
||||
self.info["gamma"] = 1 / 2.2
|
||||
elif dxgi_format in (
|
||||
DXGI_FORMAT.R8G8B8A8_TYPELESS,
|
||||
DXGI_FORMAT.R8G8B8A8_UNORM,
|
||||
DXGI_FORMAT.R8G8B8A8_UNORM_SRGB,
|
||||
):
|
||||
self._mode = "RGBA"
|
||||
if dxgi_format == DXGI_FORMAT.R8G8B8A8_UNORM_SRGB:
|
||||
self.info["gamma"] = 1 / 2.2
|
||||
else:
|
||||
msg = f"Unimplemented DXGI format {dxgi_format}"
|
||||
raise NotImplementedError(msg)
|
||||
else:
|
||||
msg = f"Unimplemented pixel format {repr(fourcc)}"
|
||||
raise NotImplementedError(msg)
|
||||
else:
|
||||
msg = f"Unknown pixel format flags {pfflags}"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
if n:
|
||||
self.tile = [
|
||||
ImageFile._Tile("bcn", extents, offset, (n, self.pixel_format))
|
||||
]
|
||||
else:
|
||||
self.tile = [ImageFile._Tile("raw", extents, 0, rawmode or self.mode)]
|
||||
|
||||
def load_seek(self, pos: int) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class DdsRgbDecoder(ImageFile.PyDecoder):
|
||||
_pulls_fd = True
|
||||
|
||||
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
|
||||
assert self.fd is not None
|
||||
bitcount, masks = self.args
|
||||
|
||||
# Some masks will be padded with zeros, e.g. R 0b11 G 0b1100
|
||||
# Calculate how many zeros each mask is padded with
|
||||
mask_offsets = []
|
||||
# And the maximum value of each channel without the padding
|
||||
mask_totals = []
|
||||
for mask in masks:
|
||||
offset = 0
|
||||
if mask != 0:
|
||||
while mask >> (offset + 1) << (offset + 1) == mask:
|
||||
offset += 1
|
||||
mask_offsets.append(offset)
|
||||
mask_totals.append(mask >> offset)
|
||||
|
||||
data = bytearray()
|
||||
bytecount = bitcount // 8
|
||||
dest_length = self.state.xsize * self.state.ysize * len(masks)
|
||||
while len(data) < dest_length:
|
||||
value = int.from_bytes(self.fd.read(bytecount), "little")
|
||||
for i, mask in enumerate(masks):
|
||||
masked_value = value & mask
|
||||
# Remove the zero padding, and scale it to 8 bits
|
||||
data += o8(
|
||||
int(((masked_value >> mask_offsets[i]) / mask_totals[i]) * 255)
|
||||
if mask_totals[i]
|
||||
else 0
|
||||
)
|
||||
self.set_as_raw(data)
|
||||
return -1, 0
|
||||
|
||||
|
||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||
if im.mode not in ("RGB", "RGBA", "L", "LA"):
|
||||
msg = f"cannot write mode {im.mode} as DDS"
|
||||
raise OSError(msg)
|
||||
|
||||
flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT
|
||||
bitcount = len(im.getbands()) * 8
|
||||
pixel_format = im.encoderinfo.get("pixel_format")
|
||||
args: tuple[int] | str
|
||||
if pixel_format:
|
||||
codec_name = "bcn"
|
||||
flags |= DDSD.LINEARSIZE
|
||||
pitch = (im.width + 3) * 4
|
||||
rgba_mask = [0, 0, 0, 0]
|
||||
pixel_flags = DDPF.FOURCC
|
||||
if pixel_format == "DXT1":
|
||||
fourcc = D3DFMT.DXT1
|
||||
args = (1,)
|
||||
elif pixel_format == "DXT3":
|
||||
fourcc = D3DFMT.DXT3
|
||||
args = (2,)
|
||||
elif pixel_format == "DXT5":
|
||||
fourcc = D3DFMT.DXT5
|
||||
args = (3,)
|
||||
else:
|
||||
fourcc = D3DFMT.DX10
|
||||
if pixel_format == "BC2":
|
||||
args = (2,)
|
||||
dxgi_format = DXGI_FORMAT.BC2_TYPELESS
|
||||
elif pixel_format == "BC3":
|
||||
args = (3,)
|
||||
dxgi_format = DXGI_FORMAT.BC3_TYPELESS
|
||||
elif pixel_format == "BC5":
|
||||
args = (5,)
|
||||
dxgi_format = DXGI_FORMAT.BC5_TYPELESS
|
||||
if im.mode != "RGB":
|
||||
msg = "only RGB mode can be written as BC5"
|
||||
raise OSError(msg)
|
||||
else:
|
||||
msg = f"cannot write pixel format {pixel_format}"
|
||||
raise OSError(msg)
|
||||
else:
|
||||
codec_name = "raw"
|
||||
flags |= DDSD.PITCH
|
||||
pitch = (im.width * bitcount + 7) // 8
|
||||
|
||||
alpha = im.mode[-1] == "A"
|
||||
if im.mode[0] == "L":
|
||||
pixel_flags = DDPF.LUMINANCE
|
||||
args = im.mode
|
||||
if alpha:
|
||||
rgba_mask = [0x000000FF, 0x000000FF, 0x000000FF]
|
||||
else:
|
||||
rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000]
|
||||
else:
|
||||
pixel_flags = DDPF.RGB
|
||||
args = im.mode[::-1]
|
||||
rgba_mask = [0x00FF0000, 0x0000FF00, 0x000000FF]
|
||||
|
||||
if alpha:
|
||||
r, g, b, a = im.split()
|
||||
im = Image.merge("RGBA", (a, r, g, b))
|
||||
if alpha:
|
||||
pixel_flags |= DDPF.ALPHAPIXELS
|
||||
rgba_mask.append(0xFF000000 if alpha else 0)
|
||||
|
||||
fourcc = D3DFMT.UNKNOWN
|
||||
fp.write(
|
||||
o32(DDS_MAGIC)
|
||||
+ struct.pack(
|
||||
"<7I",
|
||||
124, # header size
|
||||
flags, # flags
|
||||
im.height,
|
||||
im.width,
|
||||
pitch,
|
||||
0, # depth
|
||||
0, # mipmaps
|
||||
)
|
||||
+ struct.pack("11I", *((0,) * 11)) # reserved
|
||||
# pfsize, pfflags, fourcc, bitcount
|
||||
+ struct.pack("<4I", 32, pixel_flags, fourcc, bitcount)
|
||||
+ struct.pack("<4I", *rgba_mask) # dwRGBABitMask
|
||||
+ struct.pack("<5I", DDSCAPS.TEXTURE, 0, 0, 0, 0)
|
||||
)
|
||||
if fourcc == D3DFMT.DX10:
|
||||
fp.write(
|
||||
# dxgi_format, 2D resource, misc, array size, straight alpha
|
||||
struct.pack("<5I", dxgi_format, 3, 0, 0, 1)
|
||||
)
|
||||
ImageFile._save(im, fp, [ImageFile._Tile(codec_name, (0, 0) + im.size, 0, args)])
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith(b"DDS ")
|
||||
|
||||
|
||||
Image.register_open(DdsImageFile.format, DdsImageFile, _accept)
|
||||
Image.register_decoder("dds_rgb", DdsRgbDecoder)
|
||||
Image.register_save(DdsImageFile.format, _save)
|
||||
Image.register_extension(DdsImageFile.format, ".dds")
|
||||
+481
@@ -0,0 +1,481 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# EPS file handling
|
||||
#
|
||||
# History:
|
||||
# 1995-09-01 fl Created (0.1)
|
||||
# 1996-05-18 fl Don't choke on "atend" fields, Ghostscript interface (0.2)
|
||||
# 1996-08-22 fl Don't choke on floating point BoundingBox values
|
||||
# 1996-08-23 fl Handle files from Macintosh (0.3)
|
||||
# 2001-02-17 fl Use 're' instead of 'regex' (Python 2.1) (0.4)
|
||||
# 2003-09-07 fl Check gs.close status (from Federico Di Gregorio) (0.5)
|
||||
# 2014-05-07 e Handling of EPS with binary preview and fixed resolution
|
||||
# resizing
|
||||
#
|
||||
# Copyright (c) 1997-2003 by Secret Labs AB.
|
||||
# Copyright (c) 1995-2003 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from typing import IO
|
||||
|
||||
from . import Image, ImageFile
|
||||
from ._binary import i32le as i32
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
split = re.compile(r"^%%([^:]*):[ \t]*(.*)[ \t]*$")
|
||||
field = re.compile(r"^%[%!\w]([^:]*)[ \t]*$")
|
||||
|
||||
gs_binary: str | bool | None = None
|
||||
gs_windows_binary = None
|
||||
|
||||
|
||||
def has_ghostscript() -> bool:
|
||||
global gs_binary, gs_windows_binary
|
||||
if gs_binary is None:
|
||||
if sys.platform.startswith("win"):
|
||||
if gs_windows_binary is None:
|
||||
import shutil
|
||||
|
||||
for binary in ("gswin32c", "gswin64c", "gs"):
|
||||
if shutil.which(binary) is not None:
|
||||
gs_windows_binary = binary
|
||||
break
|
||||
else:
|
||||
gs_windows_binary = False
|
||||
gs_binary = gs_windows_binary
|
||||
else:
|
||||
try:
|
||||
subprocess.check_call(["gs", "--version"], stdout=subprocess.DEVNULL)
|
||||
gs_binary = "gs"
|
||||
except OSError:
|
||||
gs_binary = False
|
||||
return gs_binary is not False
|
||||
|
||||
|
||||
def Ghostscript(
|
||||
tile: list[ImageFile._Tile],
|
||||
size: tuple[int, int],
|
||||
fp: IO[bytes],
|
||||
scale: int = 1,
|
||||
transparency: bool = False,
|
||||
) -> Image.core.ImagingCore:
|
||||
"""Render an image using Ghostscript"""
|
||||
global gs_binary
|
||||
if not has_ghostscript():
|
||||
msg = "Unable to locate Ghostscript on paths"
|
||||
raise OSError(msg)
|
||||
assert isinstance(gs_binary, str)
|
||||
|
||||
# Unpack decoder tile
|
||||
args = tile[0].args
|
||||
assert isinstance(args, tuple)
|
||||
length, bbox = args
|
||||
|
||||
# Hack to support hi-res rendering
|
||||
scale = int(scale) or 1
|
||||
width = size[0] * scale
|
||||
height = size[1] * scale
|
||||
# resolution is dependent on bbox and size
|
||||
res_x = 72.0 * width / (bbox[2] - bbox[0])
|
||||
res_y = 72.0 * height / (bbox[3] - bbox[1])
|
||||
|
||||
out_fd, outfile = tempfile.mkstemp()
|
||||
os.close(out_fd)
|
||||
|
||||
infile_temp = None
|
||||
if hasattr(fp, "name") and os.path.exists(fp.name):
|
||||
infile = fp.name
|
||||
else:
|
||||
in_fd, infile_temp = tempfile.mkstemp()
|
||||
os.close(in_fd)
|
||||
infile = infile_temp
|
||||
|
||||
# Ignore length and offset!
|
||||
# Ghostscript can read it
|
||||
# Copy whole file to read in Ghostscript
|
||||
with open(infile_temp, "wb") as f:
|
||||
# fetch length of fp
|
||||
fp.seek(0, io.SEEK_END)
|
||||
fsize = fp.tell()
|
||||
# ensure start position
|
||||
# go back
|
||||
fp.seek(0)
|
||||
lengthfile = fsize
|
||||
while lengthfile > 0:
|
||||
s = fp.read(min(lengthfile, 100 * 1024))
|
||||
if not s:
|
||||
break
|
||||
lengthfile -= len(s)
|
||||
f.write(s)
|
||||
|
||||
if transparency:
|
||||
# "RGBA"
|
||||
device = "pngalpha"
|
||||
else:
|
||||
# "pnmraw" automatically chooses between
|
||||
# PBM ("1"), PGM ("L"), and PPM ("RGB").
|
||||
device = "pnmraw"
|
||||
|
||||
# Build Ghostscript command
|
||||
command = [
|
||||
gs_binary,
|
||||
"-q", # quiet mode
|
||||
f"-g{width:d}x{height:d}", # set output geometry (pixels)
|
||||
f"-r{res_x:f}x{res_y:f}", # set input DPI (dots per inch)
|
||||
"-dBATCH", # exit after processing
|
||||
"-dNOPAUSE", # don't pause between pages
|
||||
"-dSAFER", # safe mode
|
||||
f"-sDEVICE={device}",
|
||||
f"-sOutputFile={outfile}", # output file
|
||||
# adjust for image origin
|
||||
"-c",
|
||||
f"{-bbox[0]} {-bbox[1]} translate",
|
||||
"-f",
|
||||
infile, # input file
|
||||
# showpage (see https://bugs.ghostscript.com/show_bug.cgi?id=698272)
|
||||
"-c",
|
||||
"showpage",
|
||||
]
|
||||
|
||||
# push data through Ghostscript
|
||||
try:
|
||||
startupinfo = None
|
||||
if sys.platform.startswith("win"):
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
subprocess.check_call(command, startupinfo=startupinfo)
|
||||
with Image.open(outfile) as out_im:
|
||||
out_im.load()
|
||||
return out_im.im.copy()
|
||||
finally:
|
||||
try:
|
||||
os.unlink(outfile)
|
||||
if infile_temp:
|
||||
os.unlink(infile_temp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith(b"%!PS") or (
|
||||
len(prefix) >= 4 and i32(prefix) == 0xC6D3D0C5
|
||||
)
|
||||
|
||||
|
||||
##
|
||||
# Image plugin for Encapsulated PostScript. This plugin supports only
|
||||
# a few variants of this format.
|
||||
|
||||
|
||||
class EpsImageFile(ImageFile.ImageFile):
|
||||
"""EPS File Parser for the Python Imaging Library"""
|
||||
|
||||
format = "EPS"
|
||||
format_description = "Encapsulated Postscript"
|
||||
|
||||
mode_map = {1: "L", 2: "LAB", 3: "RGB", 4: "CMYK"}
|
||||
|
||||
def _open(self) -> None:
|
||||
assert self.fp is not None
|
||||
length, offset = self._find_offset(self.fp)
|
||||
|
||||
# go to offset - start of "%!PS"
|
||||
self.fp.seek(offset)
|
||||
|
||||
self._mode = "RGB"
|
||||
|
||||
# When reading header comments, the first comment is used.
|
||||
# When reading trailer comments, the last comment is used.
|
||||
bounding_box: list[int] | None = None
|
||||
imagedata_size: tuple[int, int] | None = None
|
||||
|
||||
byte_arr = bytearray(255)
|
||||
bytes_mv = memoryview(byte_arr)
|
||||
bytes_read = 0
|
||||
reading_header_comments = True
|
||||
reading_trailer_comments = False
|
||||
trailer_reached = False
|
||||
|
||||
def check_required_header_comments() -> None:
|
||||
"""
|
||||
The EPS specification requires that some headers exist.
|
||||
This should be checked when the header comments formally end,
|
||||
when image data starts, or when the file ends, whichever comes first.
|
||||
"""
|
||||
if "PS-Adobe" not in self.info:
|
||||
msg = 'EPS header missing "%!PS-Adobe" comment'
|
||||
raise SyntaxError(msg)
|
||||
if "BoundingBox" not in self.info:
|
||||
msg = 'EPS header missing "%%BoundingBox" comment'
|
||||
raise SyntaxError(msg)
|
||||
|
||||
def read_comment(s: str) -> bool:
|
||||
nonlocal bounding_box, reading_trailer_comments
|
||||
try:
|
||||
m = split.match(s)
|
||||
except re.error as e:
|
||||
msg = "not an EPS file"
|
||||
raise SyntaxError(msg) from e
|
||||
|
||||
if not m:
|
||||
return False
|
||||
|
||||
k, v = m.group(1, 2)
|
||||
self.info[k] = v
|
||||
if k == "BoundingBox":
|
||||
if v == "(atend)":
|
||||
reading_trailer_comments = True
|
||||
elif not bounding_box or (trailer_reached and reading_trailer_comments):
|
||||
try:
|
||||
# Note: The DSC spec says that BoundingBox
|
||||
# fields should be integers, but some drivers
|
||||
# put floating point values there anyway.
|
||||
bounding_box = [int(float(i)) for i in v.split()]
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
while True:
|
||||
byte = self.fp.read(1)
|
||||
if byte == b"":
|
||||
# if we didn't read a byte we must be at the end of the file
|
||||
if bytes_read == 0:
|
||||
if reading_header_comments:
|
||||
check_required_header_comments()
|
||||
break
|
||||
elif byte in b"\r\n":
|
||||
# if we read a line ending character, ignore it and parse what
|
||||
# we have already read. if we haven't read any other characters,
|
||||
# continue reading
|
||||
if bytes_read == 0:
|
||||
continue
|
||||
else:
|
||||
# ASCII/hexadecimal lines in an EPS file must not exceed
|
||||
# 255 characters, not including line ending characters
|
||||
if bytes_read >= 255:
|
||||
# only enforce this for lines starting with a "%",
|
||||
# otherwise assume it's binary data
|
||||
if byte_arr[0] == ord("%"):
|
||||
msg = "not an EPS file"
|
||||
raise SyntaxError(msg)
|
||||
else:
|
||||
if reading_header_comments:
|
||||
check_required_header_comments()
|
||||
reading_header_comments = False
|
||||
# reset bytes_read so we can keep reading
|
||||
# data until the end of the line
|
||||
bytes_read = 0
|
||||
byte_arr[bytes_read] = byte[0]
|
||||
bytes_read += 1
|
||||
continue
|
||||
|
||||
if reading_header_comments:
|
||||
# Load EPS header
|
||||
|
||||
# if this line doesn't start with a "%",
|
||||
# or does start with "%%EndComments",
|
||||
# then we've reached the end of the header/comments
|
||||
if byte_arr[0] != ord("%") or bytes_mv[:13] == b"%%EndComments":
|
||||
check_required_header_comments()
|
||||
reading_header_comments = False
|
||||
continue
|
||||
|
||||
s = str(bytes_mv[:bytes_read], "latin-1")
|
||||
if not read_comment(s):
|
||||
m = field.match(s)
|
||||
if m:
|
||||
k = m.group(1)
|
||||
if k.startswith("PS-Adobe"):
|
||||
self.info["PS-Adobe"] = k[9:]
|
||||
else:
|
||||
self.info[k] = ""
|
||||
elif s[0] == "%":
|
||||
# handle non-DSC PostScript comments that some
|
||||
# tools mistakenly put in the Comments section
|
||||
pass
|
||||
else:
|
||||
msg = "bad EPS header"
|
||||
raise OSError(msg)
|
||||
elif bytes_mv[:11] == b"%ImageData:":
|
||||
# Check for an "ImageData" descriptor
|
||||
# https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577413_pgfId-1035096
|
||||
|
||||
# If we've already read an "ImageData" descriptor,
|
||||
# don't read another one.
|
||||
if imagedata_size:
|
||||
bytes_read = 0
|
||||
continue
|
||||
|
||||
# Values:
|
||||
# columns
|
||||
# rows
|
||||
# bit depth (1 or 8)
|
||||
# mode (1: L, 2: LAB, 3: RGB, 4: CMYK)
|
||||
# number of padding channels
|
||||
# block size (number of bytes per row per channel)
|
||||
# binary/ascii (1: binary, 2: ascii)
|
||||
# data start identifier (the image data follows after a single line
|
||||
# consisting only of this quoted value)
|
||||
image_data_values = byte_arr[11:bytes_read].split(None, 7)
|
||||
columns, rows, bit_depth, mode_id = (
|
||||
int(value) for value in image_data_values[:4]
|
||||
)
|
||||
|
||||
if bit_depth == 1:
|
||||
self._mode = "1"
|
||||
elif bit_depth == 8:
|
||||
try:
|
||||
self._mode = self.mode_map[mode_id]
|
||||
except ValueError:
|
||||
break
|
||||
else:
|
||||
break
|
||||
|
||||
# Parse the columns and rows after checking the bit depth and mode
|
||||
# in case the bit depth and/or mode are invalid.
|
||||
imagedata_size = columns, rows
|
||||
elif bytes_mv[:5] == b"%%EOF":
|
||||
break
|
||||
elif trailer_reached and reading_trailer_comments:
|
||||
# Load EPS trailer
|
||||
s = str(bytes_mv[:bytes_read], "latin-1")
|
||||
read_comment(s)
|
||||
elif bytes_mv[:9] == b"%%Trailer":
|
||||
trailer_reached = True
|
||||
elif bytes_mv[:14] == b"%%BeginBinary:":
|
||||
bytecount = int(byte_arr[14:bytes_read])
|
||||
self.fp.seek(bytecount, os.SEEK_CUR)
|
||||
bytes_read = 0
|
||||
|
||||
# A "BoundingBox" is always required,
|
||||
# even if an "ImageData" descriptor size exists.
|
||||
if not bounding_box:
|
||||
msg = "cannot determine EPS bounding box"
|
||||
raise OSError(msg)
|
||||
|
||||
# An "ImageData" size takes precedence over the "BoundingBox".
|
||||
self._size = imagedata_size or (
|
||||
bounding_box[2] - bounding_box[0],
|
||||
bounding_box[3] - bounding_box[1],
|
||||
)
|
||||
|
||||
self.tile = [
|
||||
ImageFile._Tile("eps", (0, 0) + self.size, offset, (length, bounding_box))
|
||||
]
|
||||
|
||||
def _find_offset(self, fp: IO[bytes]) -> tuple[int, int]:
|
||||
s = fp.read(4)
|
||||
|
||||
if s == b"%!PS":
|
||||
# for HEAD without binary preview
|
||||
fp.seek(0, io.SEEK_END)
|
||||
length = fp.tell()
|
||||
offset = 0
|
||||
elif i32(s) == 0xC6D3D0C5:
|
||||
# FIX for: Some EPS file not handled correctly / issue #302
|
||||
# EPS can contain binary data
|
||||
# or start directly with latin coding
|
||||
# more info see:
|
||||
# https://web.archive.org/web/20160528181353/http://partners.adobe.com/public/developer/en/ps/5002.EPSF_Spec.pdf
|
||||
s = fp.read(8)
|
||||
offset = i32(s)
|
||||
length = i32(s, 4)
|
||||
else:
|
||||
msg = "not an EPS file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
return length, offset
|
||||
|
||||
def load(
|
||||
self, scale: int = 1, transparency: bool = False
|
||||
) -> Image.core.PixelAccess | None:
|
||||
# Load EPS via Ghostscript
|
||||
if self.tile:
|
||||
assert self.fp is not None
|
||||
self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency)
|
||||
self._mode = self.im.mode
|
||||
self._size = self.im.size
|
||||
self.tile = []
|
||||
return Image.Image.load(self)
|
||||
|
||||
def load_seek(self, pos: int) -> None:
|
||||
# we can't incrementally load, so force ImageFile.parser to
|
||||
# use our custom load method by defining this method.
|
||||
pass
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes, eps: int = 1) -> None:
|
||||
"""EPS Writer for the Python Imaging Library."""
|
||||
|
||||
# make sure image data is available
|
||||
im.load()
|
||||
|
||||
# determine PostScript image mode
|
||||
if im.mode == "L":
|
||||
operator = (8, 1, b"image")
|
||||
elif im.mode == "RGB":
|
||||
operator = (8, 3, b"false 3 colorimage")
|
||||
elif im.mode == "CMYK":
|
||||
operator = (8, 4, b"false 4 colorimage")
|
||||
else:
|
||||
msg = "image mode is not supported"
|
||||
raise ValueError(msg)
|
||||
|
||||
if eps:
|
||||
# write EPS header
|
||||
fp.write(b"%!PS-Adobe-3.0 EPSF-3.0\n")
|
||||
fp.write(b"%%Creator: PIL 0.1 EpsEncode\n")
|
||||
# fp.write("%%CreationDate: %s"...)
|
||||
fp.write(b"%%%%BoundingBox: 0 0 %d %d\n" % im.size)
|
||||
fp.write(b"%%Pages: 1\n")
|
||||
fp.write(b"%%EndComments\n")
|
||||
fp.write(b"%%Page: 1 1\n")
|
||||
fp.write(b"%%ImageData: %d %d " % im.size)
|
||||
fp.write(b'%d %d 0 1 1 "%s"\n' % operator)
|
||||
|
||||
# image header
|
||||
fp.write(b"gsave\n")
|
||||
fp.write(b"10 dict begin\n")
|
||||
fp.write(b"/buf %d string def\n" % (im.size[0] * operator[1]))
|
||||
fp.write(b"%d %d scale\n" % im.size)
|
||||
fp.write(b"%d %d 8\n" % im.size) # <= bits
|
||||
fp.write(b"[%d 0 0 -%d 0 %d]\n" % (im.size[0], im.size[1], im.size[1]))
|
||||
fp.write(b"{ currentfile buf readhexstring pop } bind\n")
|
||||
fp.write(operator[2] + b"\n")
|
||||
if hasattr(fp, "flush"):
|
||||
fp.flush()
|
||||
|
||||
ImageFile._save(im, fp, [ImageFile._Tile("eps", (0, 0) + im.size)])
|
||||
|
||||
fp.write(b"\n%%%%EndBinary\n")
|
||||
fp.write(b"grestore end\n")
|
||||
if hasattr(fp, "flush"):
|
||||
fp.flush()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
Image.register_open(EpsImageFile.format, EpsImageFile, _accept)
|
||||
|
||||
Image.register_save(EpsImageFile.format, _save)
|
||||
|
||||
Image.register_extensions(EpsImageFile.format, [".ps", ".eps"])
|
||||
|
||||
Image.register_mime(EpsImageFile.format, "application/postscript")
|
||||
@@ -0,0 +1,384 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# EXIF tags
|
||||
#
|
||||
# Copyright (c) 2003 by Secret Labs AB
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
|
||||
"""
|
||||
This module provides constants and clear-text names for various
|
||||
well-known EXIF tags.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class Base(IntEnum):
|
||||
# possibly incomplete
|
||||
InteropIndex = 0x0001
|
||||
ProcessingSoftware = 0x000B
|
||||
NewSubfileType = 0x00FE
|
||||
SubfileType = 0x00FF
|
||||
ImageWidth = 0x0100
|
||||
ImageLength = 0x0101
|
||||
BitsPerSample = 0x0102
|
||||
Compression = 0x0103
|
||||
PhotometricInterpretation = 0x0106
|
||||
Thresholding = 0x0107
|
||||
CellWidth = 0x0108
|
||||
CellLength = 0x0109
|
||||
FillOrder = 0x010A
|
||||
DocumentName = 0x010D
|
||||
ImageDescription = 0x010E
|
||||
Make = 0x010F
|
||||
Model = 0x0110
|
||||
StripOffsets = 0x0111
|
||||
Orientation = 0x0112
|
||||
SamplesPerPixel = 0x0115
|
||||
RowsPerStrip = 0x0116
|
||||
StripByteCounts = 0x0117
|
||||
MinSampleValue = 0x0118
|
||||
MaxSampleValue = 0x0119
|
||||
XResolution = 0x011A
|
||||
YResolution = 0x011B
|
||||
PlanarConfiguration = 0x011C
|
||||
PageName = 0x011D
|
||||
FreeOffsets = 0x0120
|
||||
FreeByteCounts = 0x0121
|
||||
GrayResponseUnit = 0x0122
|
||||
GrayResponseCurve = 0x0123
|
||||
T4Options = 0x0124
|
||||
T6Options = 0x0125
|
||||
ResolutionUnit = 0x0128
|
||||
PageNumber = 0x0129
|
||||
TransferFunction = 0x012D
|
||||
Software = 0x0131
|
||||
DateTime = 0x0132
|
||||
Artist = 0x013B
|
||||
HostComputer = 0x013C
|
||||
Predictor = 0x013D
|
||||
WhitePoint = 0x013E
|
||||
PrimaryChromaticities = 0x013F
|
||||
ColorMap = 0x0140
|
||||
HalftoneHints = 0x0141
|
||||
TileWidth = 0x0142
|
||||
TileLength = 0x0143
|
||||
TileOffsets = 0x0144
|
||||
TileByteCounts = 0x0145
|
||||
SubIFDs = 0x014A
|
||||
InkSet = 0x014C
|
||||
InkNames = 0x014D
|
||||
NumberOfInks = 0x014E
|
||||
DotRange = 0x0150
|
||||
TargetPrinter = 0x0151
|
||||
ExtraSamples = 0x0152
|
||||
SampleFormat = 0x0153
|
||||
SMinSampleValue = 0x0154
|
||||
SMaxSampleValue = 0x0155
|
||||
TransferRange = 0x0156
|
||||
ClipPath = 0x0157
|
||||
XClipPathUnits = 0x0158
|
||||
YClipPathUnits = 0x0159
|
||||
Indexed = 0x015A
|
||||
JPEGTables = 0x015B
|
||||
OPIProxy = 0x015F
|
||||
JPEGProc = 0x0200
|
||||
JpegIFOffset = 0x0201
|
||||
JpegIFByteCount = 0x0202
|
||||
JpegRestartInterval = 0x0203
|
||||
JpegLosslessPredictors = 0x0205
|
||||
JpegPointTransforms = 0x0206
|
||||
JpegQTables = 0x0207
|
||||
JpegDCTables = 0x0208
|
||||
JpegACTables = 0x0209
|
||||
YCbCrCoefficients = 0x0211
|
||||
YCbCrSubSampling = 0x0212
|
||||
YCbCrPositioning = 0x0213
|
||||
ReferenceBlackWhite = 0x0214
|
||||
XMLPacket = 0x02BC
|
||||
RelatedImageFileFormat = 0x1000
|
||||
RelatedImageWidth = 0x1001
|
||||
RelatedImageLength = 0x1002
|
||||
Rating = 0x4746
|
||||
RatingPercent = 0x4749
|
||||
ImageID = 0x800D
|
||||
CFARepeatPatternDim = 0x828D
|
||||
BatteryLevel = 0x828F
|
||||
Copyright = 0x8298
|
||||
ExposureTime = 0x829A
|
||||
FNumber = 0x829D
|
||||
IPTCNAA = 0x83BB
|
||||
ImageResources = 0x8649
|
||||
ExifOffset = 0x8769
|
||||
InterColorProfile = 0x8773
|
||||
ExposureProgram = 0x8822
|
||||
SpectralSensitivity = 0x8824
|
||||
GPSInfo = 0x8825
|
||||
ISOSpeedRatings = 0x8827
|
||||
OECF = 0x8828
|
||||
Interlace = 0x8829
|
||||
TimeZoneOffset = 0x882A
|
||||
SelfTimerMode = 0x882B
|
||||
SensitivityType = 0x8830
|
||||
StandardOutputSensitivity = 0x8831
|
||||
RecommendedExposureIndex = 0x8832
|
||||
ISOSpeed = 0x8833
|
||||
ISOSpeedLatitudeyyy = 0x8834
|
||||
ISOSpeedLatitudezzz = 0x8835
|
||||
ExifVersion = 0x9000
|
||||
DateTimeOriginal = 0x9003
|
||||
DateTimeDigitized = 0x9004
|
||||
OffsetTime = 0x9010
|
||||
OffsetTimeOriginal = 0x9011
|
||||
OffsetTimeDigitized = 0x9012
|
||||
ComponentsConfiguration = 0x9101
|
||||
CompressedBitsPerPixel = 0x9102
|
||||
ShutterSpeedValue = 0x9201
|
||||
ApertureValue = 0x9202
|
||||
BrightnessValue = 0x9203
|
||||
ExposureBiasValue = 0x9204
|
||||
MaxApertureValue = 0x9205
|
||||
SubjectDistance = 0x9206
|
||||
MeteringMode = 0x9207
|
||||
LightSource = 0x9208
|
||||
Flash = 0x9209
|
||||
FocalLength = 0x920A
|
||||
Noise = 0x920D
|
||||
ImageNumber = 0x9211
|
||||
SecurityClassification = 0x9212
|
||||
ImageHistory = 0x9213
|
||||
TIFFEPStandardID = 0x9216
|
||||
MakerNote = 0x927C
|
||||
UserComment = 0x9286
|
||||
SubsecTime = 0x9290
|
||||
SubsecTimeOriginal = 0x9291
|
||||
SubsecTimeDigitized = 0x9292
|
||||
AmbientTemperature = 0x9400
|
||||
Humidity = 0x9401
|
||||
Pressure = 0x9402
|
||||
WaterDepth = 0x9403
|
||||
Acceleration = 0x9404
|
||||
CameraElevationAngle = 0x9405
|
||||
XPTitle = 0x9C9B
|
||||
XPComment = 0x9C9C
|
||||
XPAuthor = 0x9C9D
|
||||
XPKeywords = 0x9C9E
|
||||
XPSubject = 0x9C9F
|
||||
FlashPixVersion = 0xA000
|
||||
ColorSpace = 0xA001
|
||||
ExifImageWidth = 0xA002
|
||||
ExifImageHeight = 0xA003
|
||||
RelatedSoundFile = 0xA004
|
||||
ExifInteroperabilityOffset = 0xA005
|
||||
FlashEnergy = 0xA20B
|
||||
SpatialFrequencyResponse = 0xA20C
|
||||
FocalPlaneXResolution = 0xA20E
|
||||
FocalPlaneYResolution = 0xA20F
|
||||
FocalPlaneResolutionUnit = 0xA210
|
||||
SubjectLocation = 0xA214
|
||||
ExposureIndex = 0xA215
|
||||
SensingMethod = 0xA217
|
||||
FileSource = 0xA300
|
||||
SceneType = 0xA301
|
||||
CFAPattern = 0xA302
|
||||
CustomRendered = 0xA401
|
||||
ExposureMode = 0xA402
|
||||
WhiteBalance = 0xA403
|
||||
DigitalZoomRatio = 0xA404
|
||||
FocalLengthIn35mmFilm = 0xA405
|
||||
SceneCaptureType = 0xA406
|
||||
GainControl = 0xA407
|
||||
Contrast = 0xA408
|
||||
Saturation = 0xA409
|
||||
Sharpness = 0xA40A
|
||||
DeviceSettingDescription = 0xA40B
|
||||
SubjectDistanceRange = 0xA40C
|
||||
ImageUniqueID = 0xA420
|
||||
CameraOwnerName = 0xA430
|
||||
BodySerialNumber = 0xA431
|
||||
LensSpecification = 0xA432
|
||||
LensMake = 0xA433
|
||||
LensModel = 0xA434
|
||||
LensSerialNumber = 0xA435
|
||||
CompositeImage = 0xA460
|
||||
CompositeImageCount = 0xA461
|
||||
CompositeImageExposureTimes = 0xA462
|
||||
Gamma = 0xA500
|
||||
PrintImageMatching = 0xC4A5
|
||||
DNGVersion = 0xC612
|
||||
DNGBackwardVersion = 0xC613
|
||||
UniqueCameraModel = 0xC614
|
||||
LocalizedCameraModel = 0xC615
|
||||
CFAPlaneColor = 0xC616
|
||||
CFALayout = 0xC617
|
||||
LinearizationTable = 0xC618
|
||||
BlackLevelRepeatDim = 0xC619
|
||||
BlackLevel = 0xC61A
|
||||
BlackLevelDeltaH = 0xC61B
|
||||
BlackLevelDeltaV = 0xC61C
|
||||
WhiteLevel = 0xC61D
|
||||
DefaultScale = 0xC61E
|
||||
DefaultCropOrigin = 0xC61F
|
||||
DefaultCropSize = 0xC620
|
||||
ColorMatrix1 = 0xC621
|
||||
ColorMatrix2 = 0xC622
|
||||
CameraCalibration1 = 0xC623
|
||||
CameraCalibration2 = 0xC624
|
||||
ReductionMatrix1 = 0xC625
|
||||
ReductionMatrix2 = 0xC626
|
||||
AnalogBalance = 0xC627
|
||||
AsShotNeutral = 0xC628
|
||||
AsShotWhiteXY = 0xC629
|
||||
BaselineExposure = 0xC62A
|
||||
BaselineNoise = 0xC62B
|
||||
BaselineSharpness = 0xC62C
|
||||
BayerGreenSplit = 0xC62D
|
||||
LinearResponseLimit = 0xC62E
|
||||
CameraSerialNumber = 0xC62F
|
||||
LensInfo = 0xC630
|
||||
ChromaBlurRadius = 0xC631
|
||||
AntiAliasStrength = 0xC632
|
||||
ShadowScale = 0xC633
|
||||
DNGPrivateData = 0xC634
|
||||
MakerNoteSafety = 0xC635
|
||||
CalibrationIlluminant1 = 0xC65A
|
||||
CalibrationIlluminant2 = 0xC65B
|
||||
BestQualityScale = 0xC65C
|
||||
RawDataUniqueID = 0xC65D
|
||||
OriginalRawFileName = 0xC68B
|
||||
OriginalRawFileData = 0xC68C
|
||||
ActiveArea = 0xC68D
|
||||
MaskedAreas = 0xC68E
|
||||
AsShotICCProfile = 0xC68F
|
||||
AsShotPreProfileMatrix = 0xC690
|
||||
CurrentICCProfile = 0xC691
|
||||
CurrentPreProfileMatrix = 0xC692
|
||||
ColorimetricReference = 0xC6BF
|
||||
CameraCalibrationSignature = 0xC6F3
|
||||
ProfileCalibrationSignature = 0xC6F4
|
||||
AsShotProfileName = 0xC6F6
|
||||
NoiseReductionApplied = 0xC6F7
|
||||
ProfileName = 0xC6F8
|
||||
ProfileHueSatMapDims = 0xC6F9
|
||||
ProfileHueSatMapData1 = 0xC6FA
|
||||
ProfileHueSatMapData2 = 0xC6FB
|
||||
ProfileToneCurve = 0xC6FC
|
||||
ProfileEmbedPolicy = 0xC6FD
|
||||
ProfileCopyright = 0xC6FE
|
||||
ForwardMatrix1 = 0xC714
|
||||
ForwardMatrix2 = 0xC715
|
||||
PreviewApplicationName = 0xC716
|
||||
PreviewApplicationVersion = 0xC717
|
||||
PreviewSettingsName = 0xC718
|
||||
PreviewSettingsDigest = 0xC719
|
||||
PreviewColorSpace = 0xC71A
|
||||
PreviewDateTime = 0xC71B
|
||||
RawImageDigest = 0xC71C
|
||||
OriginalRawFileDigest = 0xC71D
|
||||
SubTileBlockSize = 0xC71E
|
||||
RowInterleaveFactor = 0xC71F
|
||||
ProfileLookTableDims = 0xC725
|
||||
ProfileLookTableData = 0xC726
|
||||
OpcodeList1 = 0xC740
|
||||
OpcodeList2 = 0xC741
|
||||
OpcodeList3 = 0xC74E
|
||||
NoiseProfile = 0xC761
|
||||
FrameRate = 0xC764
|
||||
|
||||
|
||||
"""Maps EXIF tags to tag names."""
|
||||
TAGS = {
|
||||
**{i.value: i.name for i in Base},
|
||||
0x920C: "SpatialFrequencyResponse",
|
||||
0x9214: "SubjectLocation",
|
||||
0x9215: "ExposureIndex",
|
||||
0x828E: "CFAPattern",
|
||||
0x920B: "FlashEnergy",
|
||||
0x9216: "TIFF/EPStandardID",
|
||||
}
|
||||
|
||||
|
||||
class GPS(IntEnum):
|
||||
GPSVersionID = 0x00
|
||||
GPSLatitudeRef = 0x01
|
||||
GPSLatitude = 0x02
|
||||
GPSLongitudeRef = 0x03
|
||||
GPSLongitude = 0x04
|
||||
GPSAltitudeRef = 0x05
|
||||
GPSAltitude = 0x06
|
||||
GPSTimeStamp = 0x07
|
||||
GPSSatellites = 0x08
|
||||
GPSStatus = 0x09
|
||||
GPSMeasureMode = 0x0A
|
||||
GPSDOP = 0x0B
|
||||
GPSSpeedRef = 0x0C
|
||||
GPSSpeed = 0x0D
|
||||
GPSTrackRef = 0x0E
|
||||
GPSTrack = 0x0F
|
||||
GPSImgDirectionRef = 0x10
|
||||
GPSImgDirection = 0x11
|
||||
GPSMapDatum = 0x12
|
||||
GPSDestLatitudeRef = 0x13
|
||||
GPSDestLatitude = 0x14
|
||||
GPSDestLongitudeRef = 0x15
|
||||
GPSDestLongitude = 0x16
|
||||
GPSDestBearingRef = 0x17
|
||||
GPSDestBearing = 0x18
|
||||
GPSDestDistanceRef = 0x19
|
||||
GPSDestDistance = 0x1A
|
||||
GPSProcessingMethod = 0x1B
|
||||
GPSAreaInformation = 0x1C
|
||||
GPSDateStamp = 0x1D
|
||||
GPSDifferential = 0x1E
|
||||
GPSHPositioningError = 0x1F
|
||||
|
||||
|
||||
"""Maps EXIF GPS tags to tag names."""
|
||||
GPSTAGS = {i.value: i.name for i in GPS}
|
||||
|
||||
|
||||
class Interop(IntEnum):
|
||||
InteropIndex = 0x0001
|
||||
InteropVersion = 0x0002
|
||||
RelatedImageFileFormat = 0x1000
|
||||
RelatedImageWidth = 0x1001
|
||||
RelatedImageHeight = 0x1002
|
||||
|
||||
|
||||
class IFD(IntEnum):
|
||||
Exif = 0x8769
|
||||
GPSInfo = 0x8825
|
||||
MakerNote = 0x927C
|
||||
Makernote = 0x927C # Deprecated
|
||||
Interop = 0xA005
|
||||
IFD1 = -1
|
||||
|
||||
|
||||
class LightSource(IntEnum):
|
||||
Unknown = 0x00
|
||||
Daylight = 0x01
|
||||
Fluorescent = 0x02
|
||||
Tungsten = 0x03
|
||||
Flash = 0x04
|
||||
Fine = 0x09
|
||||
Cloudy = 0x0A
|
||||
Shade = 0x0B
|
||||
DaylightFluorescent = 0x0C
|
||||
DayWhiteFluorescent = 0x0D
|
||||
CoolWhiteFluorescent = 0x0E
|
||||
WhiteFluorescent = 0x0F
|
||||
StandardLightA = 0x11
|
||||
StandardLightB = 0x12
|
||||
StandardLightC = 0x13
|
||||
D55 = 0x14
|
||||
D65 = 0x15
|
||||
D75 = 0x16
|
||||
D50 = 0x17
|
||||
ISO = 0x18
|
||||
Other = 0xFF
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
#
|
||||
# The Python Imaging Library
|
||||
# $Id$
|
||||
#
|
||||
# FITS file handling
|
||||
#
|
||||
# Copyright (c) 1998-2003 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import math
|
||||
|
||||
from . import Image, ImageFile
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith(b"SIMPLE")
|
||||
|
||||
|
||||
class FitsImageFile(ImageFile.ImageFile):
|
||||
format = "FITS"
|
||||
format_description = "FITS"
|
||||
|
||||
def _open(self) -> None:
|
||||
assert self.fp is not None
|
||||
|
||||
headers: dict[bytes, bytes] = {}
|
||||
header_in_progress = False
|
||||
decoder_name = ""
|
||||
while True:
|
||||
header = self.fp.read(80)
|
||||
if not header:
|
||||
msg = "Truncated FITS file"
|
||||
raise OSError(msg)
|
||||
keyword = header[:8].strip()
|
||||
if keyword in (b"SIMPLE", b"XTENSION"):
|
||||
header_in_progress = True
|
||||
elif headers and not header_in_progress:
|
||||
# This is now a data unit
|
||||
break
|
||||
elif keyword == b"END":
|
||||
# Seek to the end of the header unit
|
||||
self.fp.seek(math.ceil(self.fp.tell() / 2880) * 2880)
|
||||
if not decoder_name:
|
||||
decoder_name, offset, args = self._parse_headers(headers)
|
||||
|
||||
header_in_progress = False
|
||||
continue
|
||||
|
||||
if decoder_name:
|
||||
# Keep going to read past the headers
|
||||
continue
|
||||
|
||||
value = header[8:].split(b"/")[0].strip()
|
||||
if value.startswith(b"="):
|
||||
value = value[1:].strip()
|
||||
if not headers and (not _accept(keyword) or value != b"T"):
|
||||
msg = "Not a FITS file"
|
||||
raise SyntaxError(msg)
|
||||
headers[keyword] = value
|
||||
|
||||
if not decoder_name:
|
||||
msg = "No image data"
|
||||
raise ValueError(msg)
|
||||
|
||||
offset += self.fp.tell() - 80
|
||||
self.tile = [ImageFile._Tile(decoder_name, (0, 0) + self.size, offset, args)]
|
||||
|
||||
def _get_size(
|
||||
self, headers: dict[bytes, bytes], prefix: bytes
|
||||
) -> tuple[int, int] | None:
|
||||
naxis = int(headers[prefix + b"NAXIS"])
|
||||
if naxis == 0:
|
||||
return None
|
||||
|
||||
if naxis == 1:
|
||||
return 1, int(headers[prefix + b"NAXIS1"])
|
||||
else:
|
||||
return int(headers[prefix + b"NAXIS1"]), int(headers[prefix + b"NAXIS2"])
|
||||
|
||||
def _parse_headers(
|
||||
self, headers: dict[bytes, bytes]
|
||||
) -> tuple[str, int, tuple[str | int, ...]]:
|
||||
prefix = b""
|
||||
decoder_name = "raw"
|
||||
offset = 0
|
||||
if (
|
||||
headers.get(b"XTENSION") == b"'BINTABLE'"
|
||||
and headers.get(b"ZIMAGE") == b"T"
|
||||
and headers[b"ZCMPTYPE"] == b"'GZIP_1 '"
|
||||
):
|
||||
no_prefix_size = self._get_size(headers, prefix) or (0, 0)
|
||||
number_of_bits = int(headers[b"BITPIX"])
|
||||
offset = no_prefix_size[0] * no_prefix_size[1] * (number_of_bits // 8)
|
||||
|
||||
prefix = b"Z"
|
||||
decoder_name = "fits_gzip"
|
||||
|
||||
size = self._get_size(headers, prefix)
|
||||
if not size:
|
||||
return "", 0, ()
|
||||
|
||||
self._size = size
|
||||
|
||||
number_of_bits = int(headers[prefix + b"BITPIX"])
|
||||
if number_of_bits == 8:
|
||||
self._mode = "L"
|
||||
elif number_of_bits == 16:
|
||||
self._mode = "I;16"
|
||||
elif number_of_bits == 32:
|
||||
self._mode = "I"
|
||||
elif number_of_bits in (-32, -64):
|
||||
self._mode = "F"
|
||||
|
||||
args: tuple[str | int, ...]
|
||||
if decoder_name == "raw":
|
||||
args = (self.mode, 0, -1)
|
||||
else:
|
||||
args = (number_of_bits,)
|
||||
return decoder_name, offset, args
|
||||
|
||||
|
||||
class FitsGzipDecoder(ImageFile.PyDecoder):
|
||||
_pulls_fd = True
|
||||
|
||||
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
|
||||
assert self.fd is not None
|
||||
with gzip.open(self.fd) as fp:
|
||||
value = fp.read(self.state.xsize * self.state.ysize * 4)
|
||||
|
||||
rows = []
|
||||
offset = 0
|
||||
number_of_bits = min(self.args[0] // 8, 4)
|
||||
for y in range(self.state.ysize):
|
||||
row = bytearray()
|
||||
for x in range(self.state.xsize):
|
||||
row += value[offset + (4 - number_of_bits) : offset + 4]
|
||||
offset += 4
|
||||
rows.append(row)
|
||||
self.set_as_raw(bytes([pixel for row in rows[::-1] for pixel in row]))
|
||||
return -1, 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Registry
|
||||
|
||||
Image.register_open(FitsImageFile.format, FitsImageFile, _accept)
|
||||
Image.register_decoder("fits_gzip", FitsGzipDecoder)
|
||||
|
||||
Image.register_extensions(FitsImageFile.format, [".fit", ".fits"])
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# FLI/FLC file handling.
|
||||
#
|
||||
# History:
|
||||
# 95-09-01 fl Created
|
||||
# 97-01-03 fl Fixed parser, setup decoder tile
|
||||
# 98-07-15 fl Renamed offset attribute to avoid name clash
|
||||
#
|
||||
# Copyright (c) Secret Labs AB 1997-98.
|
||||
# Copyright (c) Fredrik Lundh 1995-97.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from . import Image, ImageFile, ImagePalette
|
||||
from ._binary import i16le as i16
|
||||
from ._binary import i32le as i32
|
||||
from ._binary import o8
|
||||
from ._util import DeferredError
|
||||
|
||||
#
|
||||
# decoder
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return (
|
||||
len(prefix) >= 16
|
||||
and i16(prefix, 4) in [0xAF11, 0xAF12]
|
||||
and i16(prefix, 14) in [0, 3] # flags
|
||||
)
|
||||
|
||||
|
||||
##
|
||||
# Image plugin for the FLI/FLC animation format. Use the <b>seek</b>
|
||||
# method to load individual frames.
|
||||
|
||||
|
||||
class FliImageFile(ImageFile.ImageFile):
|
||||
format = "FLI"
|
||||
format_description = "Autodesk FLI/FLC Animation"
|
||||
_close_exclusive_fp_after_loading = False
|
||||
|
||||
def _open(self) -> None:
|
||||
# HEAD
|
||||
assert self.fp is not None
|
||||
s = self.fp.read(128)
|
||||
if not (
|
||||
_accept(s)
|
||||
and s[20:22] == b"\x00" * 2
|
||||
and s[42:80] == b"\x00" * 38
|
||||
and s[88:] == b"\x00" * 40
|
||||
):
|
||||
msg = "not an FLI/FLC file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
# frames
|
||||
self.n_frames = i16(s, 6)
|
||||
self.is_animated = self.n_frames > 1
|
||||
|
||||
# image characteristics
|
||||
self._mode = "P"
|
||||
self._size = i16(s, 8), i16(s, 10)
|
||||
|
||||
# animation speed
|
||||
duration = i32(s, 16)
|
||||
magic = i16(s, 4)
|
||||
if magic == 0xAF11:
|
||||
duration = (duration * 1000) // 70
|
||||
self.info["duration"] = duration
|
||||
|
||||
# look for palette
|
||||
palette = [(a, a, a) for a in range(256)]
|
||||
|
||||
s = self.fp.read(16)
|
||||
|
||||
self.__offset = 128
|
||||
|
||||
if i16(s, 4) == 0xF100:
|
||||
# prefix chunk; ignore it
|
||||
self.fp.seek(self.__offset + i32(s))
|
||||
s = self.fp.read(16)
|
||||
|
||||
if i16(s, 4) == 0xF1FA:
|
||||
# look for palette chunk
|
||||
number_of_subchunks = i16(s, 6)
|
||||
chunk_size: int | None = None
|
||||
for _ in range(number_of_subchunks):
|
||||
if chunk_size is not None:
|
||||
self.fp.seek(chunk_size - 6, os.SEEK_CUR)
|
||||
s = self.fp.read(6)
|
||||
chunk_type = i16(s, 4)
|
||||
if chunk_type in (4, 11):
|
||||
self._palette(palette, 2 if chunk_type == 11 else 0)
|
||||
break
|
||||
chunk_size = i32(s)
|
||||
if not chunk_size:
|
||||
break
|
||||
|
||||
self.palette = ImagePalette.raw(
|
||||
"RGB", b"".join(o8(r) + o8(g) + o8(b) for (r, g, b) in palette)
|
||||
)
|
||||
|
||||
# set things up to decode first frame
|
||||
self.__frame = -1
|
||||
self._fp = self.fp
|
||||
self.__rewind = self.fp.tell()
|
||||
self.seek(0)
|
||||
|
||||
def _palette(self, palette: list[tuple[int, int, int]], shift: int) -> None:
|
||||
# load palette
|
||||
|
||||
i = 0
|
||||
assert self.fp is not None
|
||||
for e in range(i16(self.fp.read(2))):
|
||||
s = self.fp.read(2)
|
||||
i = i + s[0]
|
||||
n = s[1]
|
||||
if n == 0:
|
||||
n = 256
|
||||
s = self.fp.read(n * 3)
|
||||
for n in range(0, len(s), 3):
|
||||
r = s[n] << shift
|
||||
g = s[n + 1] << shift
|
||||
b = s[n + 2] << shift
|
||||
palette[i] = (r, g, b)
|
||||
i += 1
|
||||
|
||||
def seek(self, frame: int) -> None:
|
||||
if not self._seek_check(frame):
|
||||
return
|
||||
if frame < self.__frame:
|
||||
self._seek(0)
|
||||
|
||||
for f in range(self.__frame + 1, frame + 1):
|
||||
self._seek(f)
|
||||
|
||||
def _seek(self, frame: int) -> None:
|
||||
if isinstance(self._fp, DeferredError):
|
||||
raise self._fp.ex
|
||||
if frame == 0:
|
||||
self.__frame = -1
|
||||
self._fp.seek(self.__rewind)
|
||||
self.__offset = 128
|
||||
else:
|
||||
# ensure that the previous frame was loaded
|
||||
self.load()
|
||||
|
||||
if frame != self.__frame + 1:
|
||||
msg = f"cannot seek to frame {frame}"
|
||||
raise ValueError(msg)
|
||||
self.__frame = frame
|
||||
|
||||
# move to next frame
|
||||
self.fp = self._fp
|
||||
self.fp.seek(self.__offset)
|
||||
|
||||
s = self.fp.read(4)
|
||||
if not s:
|
||||
msg = "missing frame size"
|
||||
raise EOFError(msg)
|
||||
|
||||
framesize = i32(s)
|
||||
|
||||
self.decodermaxblock = framesize
|
||||
self.tile = [ImageFile._Tile("fli", (0, 0) + self.size, self.__offset)]
|
||||
|
||||
self.__offset += framesize
|
||||
|
||||
def tell(self) -> int:
|
||||
return self.__frame
|
||||
|
||||
|
||||
#
|
||||
# registry
|
||||
|
||||
Image.register_open(FliImageFile.format, FliImageFile, _accept)
|
||||
|
||||
Image.register_extensions(FliImageFile.format, [".fli", ".flc"])
|
||||
@@ -0,0 +1,159 @@
|
||||
#
|
||||
# The Python Imaging Library
|
||||
# $Id$
|
||||
#
|
||||
# base class for raster font file parsers
|
||||
#
|
||||
# history:
|
||||
# 1997-06-05 fl created
|
||||
# 1997-08-19 fl restrict image width
|
||||
#
|
||||
# Copyright (c) 1997-1998 by Secret Labs AB
|
||||
# Copyright (c) 1997-1998 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import BinaryIO
|
||||
|
||||
from . import Image, ImageFont, _binary
|
||||
|
||||
WIDTH = 800
|
||||
|
||||
|
||||
def puti16(
|
||||
fp: BinaryIO, values: tuple[int, int, int, int, int, int, int, int, int, int]
|
||||
) -> None:
|
||||
"""Write network order (big-endian) 16-bit sequence"""
|
||||
for v in values:
|
||||
if v < 0:
|
||||
v += 65536
|
||||
fp.write(_binary.o16be(v))
|
||||
|
||||
|
||||
class FontFile:
|
||||
"""Base class for raster font file handlers."""
|
||||
|
||||
bitmap: Image.Image | None = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.info: dict[bytes, bytes | int] = {}
|
||||
self.glyph: list[
|
||||
tuple[
|
||||
tuple[int, int],
|
||||
tuple[int, int, int, int],
|
||||
tuple[int, int, int, int],
|
||||
Image.Image,
|
||||
]
|
||||
| None
|
||||
] = [None] * 256
|
||||
|
||||
def __getitem__(self, ix: int) -> (
|
||||
tuple[
|
||||
tuple[int, int],
|
||||
tuple[int, int, int, int],
|
||||
tuple[int, int, int, int],
|
||||
Image.Image,
|
||||
]
|
||||
| None
|
||||
):
|
||||
return self.glyph[ix]
|
||||
|
||||
def compile(self) -> None:
|
||||
"""Create metrics and bitmap"""
|
||||
|
||||
if self.bitmap:
|
||||
return
|
||||
|
||||
# create bitmap large enough to hold all data
|
||||
h = w = maxwidth = 0
|
||||
lines = 1
|
||||
for glyph in self.glyph:
|
||||
if glyph:
|
||||
d, dst, src, im = glyph
|
||||
h = max(h, src[3] - src[1])
|
||||
w = w + (src[2] - src[0])
|
||||
if w > WIDTH:
|
||||
lines += 1
|
||||
w = src[2] - src[0]
|
||||
maxwidth = max(maxwidth, w)
|
||||
|
||||
xsize = maxwidth
|
||||
ysize = lines * h
|
||||
|
||||
if xsize == 0 and ysize == 0:
|
||||
return
|
||||
|
||||
self.ysize = h
|
||||
|
||||
# paste glyphs into bitmap
|
||||
self.bitmap = Image.new("1", (xsize, ysize))
|
||||
self.metrics: list[
|
||||
tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]]
|
||||
| None
|
||||
] = [None] * 256
|
||||
x = y = 0
|
||||
for i in range(256):
|
||||
glyph = self[i]
|
||||
if glyph:
|
||||
d, dst, src, im = glyph
|
||||
xx = src[2] - src[0]
|
||||
x0, y0 = x, y
|
||||
x = x + xx
|
||||
if x > WIDTH:
|
||||
x, y = 0, y + h
|
||||
x0, y0 = x, y
|
||||
x = xx
|
||||
s = src[0] + x0, src[1] + y0, src[2] + x0, src[3] + y0
|
||||
self.bitmap.paste(im.crop(src), s)
|
||||
self.metrics[i] = d, dst, s
|
||||
|
||||
def _encode_metrics(self) -> bytes:
|
||||
values: list[int] = []
|
||||
for i in range(256):
|
||||
m = self.metrics[i]
|
||||
if m:
|
||||
values.extend(m[0] + m[1] + m[2])
|
||||
else:
|
||||
values.extend((0,) * 10)
|
||||
|
||||
data = bytearray()
|
||||
for v in values:
|
||||
if v < 0:
|
||||
v += 65536
|
||||
data += _binary.o16be(v)
|
||||
return bytes(data)
|
||||
|
||||
def save(self, filename: str) -> None:
|
||||
"""Save font"""
|
||||
|
||||
self.compile()
|
||||
|
||||
# font data
|
||||
if not self.bitmap:
|
||||
msg = "No bitmap created"
|
||||
raise ValueError(msg)
|
||||
self.bitmap.save(os.path.splitext(filename)[0] + ".pbm", "PNG")
|
||||
|
||||
# font metrics
|
||||
with open(os.path.splitext(filename)[0] + ".pil", "wb") as fp:
|
||||
fp.write(b"PILfont\n")
|
||||
fp.write(f";;;;;;{self.ysize};\n".encode("ascii")) # HACK!!!
|
||||
fp.write(b"DATA\n")
|
||||
fp.write(self._encode_metrics())
|
||||
|
||||
def to_imagefont(self) -> ImageFont.ImageFont:
|
||||
"""Convert to ImageFont"""
|
||||
|
||||
self.compile()
|
||||
|
||||
# font data
|
||||
if not self.bitmap:
|
||||
msg = "No bitmap created"
|
||||
raise ValueError(msg)
|
||||
|
||||
imagefont = ImageFont.ImageFont()
|
||||
imagefont._load(self.bitmap, self._encode_metrics())
|
||||
return imagefont
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
#
|
||||
# THIS IS WORK IN PROGRESS
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# FlashPix support for PIL
|
||||
#
|
||||
# History:
|
||||
# 97-01-25 fl Created (reads uncompressed RGB images only)
|
||||
#
|
||||
# Copyright (c) Secret Labs AB 1997.
|
||||
# Copyright (c) Fredrik Lundh 1997.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import olefile
|
||||
|
||||
from . import Image, ImageFile
|
||||
from ._binary import i32le as i32
|
||||
|
||||
# we map from colour field tuples to (mode, rawmode) descriptors
|
||||
MODES = {
|
||||
# opacity
|
||||
(0x00007FFE,): ("A", "L"),
|
||||
# monochrome
|
||||
(0x00010000,): ("L", "L"),
|
||||
(0x00018000, 0x00017FFE): ("RGBA", "LA"),
|
||||
# photo YCC
|
||||
(0x00020000, 0x00020001, 0x00020002): ("RGB", "YCC;P"),
|
||||
(0x00028000, 0x00028001, 0x00028002, 0x00027FFE): ("RGBA", "YCCA;P"),
|
||||
# standard RGB (NIFRGB)
|
||||
(0x00030000, 0x00030001, 0x00030002): ("RGB", "RGB"),
|
||||
(0x00038000, 0x00038001, 0x00038002, 0x00037FFE): ("RGBA", "RGBA"),
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith(olefile.MAGIC)
|
||||
|
||||
|
||||
##
|
||||
# Image plugin for the FlashPix images.
|
||||
|
||||
|
||||
class FpxImageFile(ImageFile.ImageFile):
|
||||
format = "FPX"
|
||||
format_description = "FlashPix"
|
||||
|
||||
def _open(self) -> None:
|
||||
#
|
||||
# read the OLE directory and see if this is a likely
|
||||
# to be a FlashPix file
|
||||
|
||||
assert self.fp is not None
|
||||
try:
|
||||
self.ole = olefile.OleFileIO(self.fp)
|
||||
except OSError as e:
|
||||
msg = "not an FPX file; invalid OLE file"
|
||||
raise SyntaxError(msg) from e
|
||||
|
||||
root = self.ole.root
|
||||
if not root or root.clsid != "56616700-C154-11CE-8553-00AA00A1F95B":
|
||||
msg = "not an FPX file; bad root CLSID"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
self._open_index(1)
|
||||
|
||||
def _open_index(self, index: int = 1) -> None:
|
||||
#
|
||||
# get the Image Contents Property Set
|
||||
|
||||
prop = self.ole.getproperties(
|
||||
[f"Data Object Store {index:06d}", "\005Image Contents"]
|
||||
)
|
||||
|
||||
# size (highest resolution)
|
||||
|
||||
assert isinstance(prop[0x1000002], int)
|
||||
assert isinstance(prop[0x1000003], int)
|
||||
self._size = prop[0x1000002], prop[0x1000003]
|
||||
|
||||
size = max(self.size)
|
||||
i = 1
|
||||
while size > 64:
|
||||
size = size // 2
|
||||
i += 1
|
||||
self.maxid = i - 1
|
||||
|
||||
# mode. instead of using a single field for this, flashpix
|
||||
# requires you to specify the mode for each channel in each
|
||||
# resolution subimage, and leaves it to the decoder to make
|
||||
# sure that they all match. for now, we'll cheat and assume
|
||||
# that this is always the case.
|
||||
|
||||
id = self.maxid << 16
|
||||
|
||||
s = prop[0x2000002 | id]
|
||||
|
||||
if not isinstance(s, bytes) or (bands := i32(s, 4)) > 4:
|
||||
msg = "Invalid number of bands"
|
||||
raise OSError(msg)
|
||||
|
||||
# note: for now, we ignore the "uncalibrated" flag
|
||||
colors = tuple(i32(s, 8 + i * 4) & 0x7FFFFFFF for i in range(bands))
|
||||
|
||||
self._mode, self.rawmode = MODES[colors]
|
||||
|
||||
# load JPEG tables, if any
|
||||
self.jpeg = {}
|
||||
for i in range(256):
|
||||
id = 0x3000001 | (i << 16)
|
||||
if id in prop:
|
||||
self.jpeg[i] = prop[id]
|
||||
|
||||
self._open_subimage(1, self.maxid)
|
||||
|
||||
def _open_subimage(self, index: int = 1, subimage: int = 0) -> None:
|
||||
#
|
||||
# setup tile descriptors for a given subimage
|
||||
|
||||
stream = [
|
||||
f"Data Object Store {index:06d}",
|
||||
f"Resolution {subimage:04d}",
|
||||
"Subimage 0000 Header",
|
||||
]
|
||||
|
||||
fp = self.ole.openstream(stream)
|
||||
|
||||
# skip prefix
|
||||
fp.read(28)
|
||||
|
||||
# header stream
|
||||
s = fp.read(36)
|
||||
|
||||
size = i32(s, 4), i32(s, 8)
|
||||
# tilecount = i32(s, 12)
|
||||
xtile, ytile = i32(s, 16), i32(s, 20)
|
||||
# channels = i32(s, 24)
|
||||
offset = i32(s, 28)
|
||||
length = i32(s, 32)
|
||||
|
||||
if size != self.size:
|
||||
msg = "subimage mismatch"
|
||||
raise OSError(msg)
|
||||
|
||||
# get tile descriptors
|
||||
fp.seek(28 + offset)
|
||||
s = fp.read(i32(s, 12) * length)
|
||||
|
||||
x = y = 0
|
||||
xsize, ysize = size
|
||||
self.tile = []
|
||||
|
||||
for i in range(0, len(s), length):
|
||||
x1 = min(xsize, x + xtile)
|
||||
y1 = min(ysize, y + ytile)
|
||||
|
||||
compression = i32(s, i + 8)
|
||||
|
||||
if compression == 0:
|
||||
self.tile.append(
|
||||
ImageFile._Tile(
|
||||
"raw",
|
||||
(x, y, x1, y1),
|
||||
i32(s, i) + 28,
|
||||
self.rawmode,
|
||||
)
|
||||
)
|
||||
|
||||
elif compression == 1:
|
||||
# FIXME: the fill decoder is not implemented
|
||||
self.tile.append(
|
||||
ImageFile._Tile(
|
||||
"fill",
|
||||
(x, y, x1, y1),
|
||||
i32(s, i) + 28,
|
||||
(self.rawmode, s[12:16]),
|
||||
)
|
||||
)
|
||||
|
||||
elif compression == 2:
|
||||
internal_color_conversion = s[14]
|
||||
jpeg_tables = s[15]
|
||||
rawmode = self.rawmode
|
||||
|
||||
if internal_color_conversion:
|
||||
# The image is stored as usual (usually YCbCr).
|
||||
if rawmode == "RGBA":
|
||||
# For "RGBA", data is stored as YCbCrA based on
|
||||
# negative RGB. The following trick works around
|
||||
# this problem :
|
||||
jpegmode, rawmode = "YCbCrK", "CMYK"
|
||||
else:
|
||||
jpegmode = None # let the decoder decide
|
||||
|
||||
else:
|
||||
# The image is stored as defined by rawmode
|
||||
jpegmode = rawmode
|
||||
|
||||
self.tile.append(
|
||||
ImageFile._Tile(
|
||||
"jpeg",
|
||||
(x, y, x1, y1),
|
||||
i32(s, i) + 28,
|
||||
(rawmode, jpegmode),
|
||||
)
|
||||
)
|
||||
|
||||
# FIXME: jpeg tables are tile dependent; the prefix
|
||||
# data must be placed in the tile descriptor itself!
|
||||
|
||||
if jpeg_tables:
|
||||
self.tile_prefix = self.jpeg[jpeg_tables]
|
||||
|
||||
else:
|
||||
msg = "unknown/invalid compression"
|
||||
raise OSError(msg)
|
||||
|
||||
x += xtile
|
||||
if x >= xsize:
|
||||
x, y = 0, y + ytile
|
||||
if y >= ysize:
|
||||
break # isn't really required
|
||||
|
||||
assert self.fp is not None
|
||||
self.stream = stream
|
||||
self._fp = self.fp
|
||||
self.fp = None
|
||||
|
||||
def load(self) -> Image.core.PixelAccess | None:
|
||||
if not self.fp:
|
||||
self.fp = self.ole.openstream(self.stream[:2] + ["Subimage 0000 Data"])
|
||||
|
||||
return ImageFile.ImageFile.load(self)
|
||||
|
||||
def close(self) -> None:
|
||||
self.ole.close()
|
||||
super().close()
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
self.ole.close()
|
||||
super().__exit__()
|
||||
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
Image.register_open(FpxImageFile.format, FpxImageFile, _accept)
|
||||
|
||||
Image.register_extension(FpxImageFile.format, ".fpx")
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
A Pillow loader for .ftc and .ftu files (FTEX)
|
||||
Jerome Leclanche <jerome@leclan.ch>
|
||||
|
||||
The contents of this file are hereby released in the public domain (CC0)
|
||||
Full text of the CC0 license:
|
||||
https://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
Independence War 2: Edge Of Chaos - Texture File Format - 16 October 2001
|
||||
|
||||
The textures used for 3D objects in Independence War 2: Edge Of Chaos are in a
|
||||
packed custom format called FTEX. This file format uses file extensions FTC
|
||||
and FTU.
|
||||
* FTC files are compressed textures (using standard texture compression).
|
||||
* FTU files are not compressed.
|
||||
Texture File Format
|
||||
The FTC and FTU texture files both use the same format. This
|
||||
has the following structure:
|
||||
{header}
|
||||
{format_directory}
|
||||
{data}
|
||||
Where:
|
||||
{header} = {
|
||||
u32:magic,
|
||||
u32:version,
|
||||
u32:width,
|
||||
u32:height,
|
||||
u32:mipmap_count,
|
||||
u32:format_count
|
||||
}
|
||||
|
||||
* The "magic" number is "FTEX".
|
||||
* "width" and "height" are the dimensions of the texture.
|
||||
* "mipmap_count" is the number of mipmaps in the texture.
|
||||
* "format_count" is the number of texture formats (different versions of the
|
||||
same texture) in this file.
|
||||
|
||||
{format_directory} = format_count * { u32:format, u32:where }
|
||||
|
||||
The format value is 0 for DXT1 compressed textures and 1 for 24-bit RGB
|
||||
uncompressed textures.
|
||||
The texture data for a format starts at the position "where" in the file.
|
||||
|
||||
Each set of texture data in the file has the following structure:
|
||||
{data} = format_count * { u32:mipmap_size, mipmap_size * { u8 } }
|
||||
* "mipmap_size" is the number of bytes in that mip level. For compressed
|
||||
textures this is the size of the texture data compressed with DXT1. For 24 bit
|
||||
uncompressed textures, this is 3 * width * height. Following this are the image
|
||||
bytes for that mipmap level.
|
||||
|
||||
Note: All data is stored in little-Endian (Intel) byte order.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from enum import IntEnum
|
||||
from io import BytesIO
|
||||
|
||||
from . import Image, ImageFile
|
||||
|
||||
MAGIC = b"FTEX"
|
||||
|
||||
|
||||
class Format(IntEnum):
|
||||
DXT1 = 0
|
||||
UNCOMPRESSED = 1
|
||||
|
||||
|
||||
class FtexImageFile(ImageFile.ImageFile):
|
||||
format = "FTEX"
|
||||
format_description = "Texture File Format (IW2:EOC)"
|
||||
|
||||
def _open(self) -> None:
|
||||
assert self.fp is not None
|
||||
if not _accept(self.fp.read(4)):
|
||||
msg = "not an FTEX file"
|
||||
raise SyntaxError(msg)
|
||||
struct.unpack("<i", self.fp.read(4)) # version
|
||||
self._size = struct.unpack("<2i", self.fp.read(8))
|
||||
mipmap_count, format_count = struct.unpack("<2i", self.fp.read(8))
|
||||
|
||||
# Only support single-format files.
|
||||
# I don't know of any multi-format file.
|
||||
assert format_count == 1
|
||||
|
||||
format, where = struct.unpack("<2i", self.fp.read(8))
|
||||
self.fp.seek(where)
|
||||
(mipmap_size,) = struct.unpack("<i", self.fp.read(4))
|
||||
|
||||
data = self.fp.read(mipmap_size)
|
||||
|
||||
if format == Format.DXT1:
|
||||
self._mode = "RGBA"
|
||||
self.tile = [ImageFile._Tile("bcn", (0, 0) + self.size, 0, (1,))]
|
||||
elif format == Format.UNCOMPRESSED:
|
||||
self._mode = "RGB"
|
||||
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, "RGB")]
|
||||
else:
|
||||
msg = f"Invalid texture compression format: {repr(format)}"
|
||||
raise ValueError(msg)
|
||||
|
||||
self.fp.close()
|
||||
self.fp = BytesIO(data)
|
||||
|
||||
def load_seek(self, pos: int) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith(MAGIC)
|
||||
|
||||
|
||||
Image.register_open(FtexImageFile.format, FtexImageFile, _accept)
|
||||
Image.register_extensions(FtexImageFile.format, [".ftc", ".ftu"])
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
#
|
||||
# The Python Imaging Library
|
||||
#
|
||||
# load a GIMP brush file
|
||||
#
|
||||
# History:
|
||||
# 96-03-14 fl Created
|
||||
# 16-01-08 es Version 2
|
||||
#
|
||||
# Copyright (c) Secret Labs AB 1997.
|
||||
# Copyright (c) Fredrik Lundh 1996.
|
||||
# Copyright (c) Eric Soroos 2016.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
#
|
||||
# See https://github.com/GNOME/gimp/blob/mainline/devel-docs/gbr.txt for
|
||||
# format documentation.
|
||||
#
|
||||
# This code Interprets version 1 and 2 .gbr files.
|
||||
# Version 1 files are obsolete, and should not be used for new
|
||||
# brushes.
|
||||
# Version 2 files are saved by GIMP v2.8 (at least)
|
||||
# Version 3 files have a format specifier of 18 for 16bit floats in
|
||||
# the color depth field. This is currently unsupported by Pillow.
|
||||
from __future__ import annotations
|
||||
|
||||
from . import Image, ImageFile
|
||||
from ._binary import i32be as i32
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return len(prefix) >= 8 and i32(prefix, 0) >= 20 and i32(prefix, 4) in (1, 2)
|
||||
|
||||
|
||||
##
|
||||
# Image plugin for the GIMP brush format.
|
||||
|
||||
|
||||
class GbrImageFile(ImageFile.ImageFile):
|
||||
format = "GBR"
|
||||
format_description = "GIMP brush file"
|
||||
|
||||
def _open(self) -> None:
|
||||
assert self.fp is not None
|
||||
header_size = i32(self.fp.read(4))
|
||||
if header_size < 20:
|
||||
msg = "not a GIMP brush"
|
||||
raise SyntaxError(msg)
|
||||
version = i32(self.fp.read(4))
|
||||
if version not in (1, 2):
|
||||
msg = f"Unsupported GIMP brush version: {version}"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
width = i32(self.fp.read(4))
|
||||
height = i32(self.fp.read(4))
|
||||
color_depth = i32(self.fp.read(4))
|
||||
if width == 0 or height == 0:
|
||||
msg = "not a GIMP brush"
|
||||
raise SyntaxError(msg)
|
||||
if color_depth not in (1, 4):
|
||||
msg = f"Unsupported GIMP brush color depth: {color_depth}"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
if version == 1:
|
||||
comment_length = header_size - 20
|
||||
else:
|
||||
comment_length = header_size - 28
|
||||
magic_number = self.fp.read(4)
|
||||
if magic_number != b"GIMP":
|
||||
msg = "not a GIMP brush, bad magic number"
|
||||
raise SyntaxError(msg)
|
||||
self.info["spacing"] = i32(self.fp.read(4))
|
||||
|
||||
self.info["comment"] = self.fp.read(comment_length)[:-1]
|
||||
|
||||
if color_depth == 1:
|
||||
self._mode = "L"
|
||||
else:
|
||||
self._mode = "RGBA"
|
||||
|
||||
self._size = width, height
|
||||
|
||||
# Image might not be small
|
||||
Image._decompression_bomb_check(self.size)
|
||||
|
||||
# Data is an uncompressed block of w * h * bytes/pixel
|
||||
self._data_size = width * height * color_depth
|
||||
|
||||
def load(self) -> Image.core.PixelAccess | None:
|
||||
if self._im is None:
|
||||
assert self.fp is not None
|
||||
self.im = Image.core.new(self.mode, self.size)
|
||||
self.frombytes(self.fp.read(self._data_size))
|
||||
return Image.Image.load(self)
|
||||
|
||||
|
||||
#
|
||||
# registry
|
||||
|
||||
|
||||
Image.register_open(GbrImageFile.format, GbrImageFile, _accept)
|
||||
Image.register_extension(GbrImageFile.format, ".gbr")
|
||||
@@ -0,0 +1,103 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# GD file handling
|
||||
#
|
||||
# History:
|
||||
# 1996-04-12 fl Created
|
||||
#
|
||||
# Copyright (c) 1997 by Secret Labs AB.
|
||||
# Copyright (c) 1996 by Fredrik Lundh.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
|
||||
|
||||
"""
|
||||
.. note::
|
||||
This format cannot be automatically recognized, so the
|
||||
class is not registered for use with :py:func:`PIL.Image.open()`. To open a
|
||||
gd file, use the :py:func:`PIL.GdImageFile.open()` function instead.
|
||||
|
||||
.. warning::
|
||||
THE GD FORMAT IS NOT DESIGNED FOR DATA INTERCHANGE. This
|
||||
implementation is provided for convenience and demonstrational
|
||||
purposes only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import IO
|
||||
|
||||
from . import ImageFile, ImagePalette, UnidentifiedImageError
|
||||
from ._binary import i16be as i16
|
||||
from ._binary import i32be as i32
|
||||
from ._typing import StrOrBytesPath
|
||||
|
||||
|
||||
class GdImageFile(ImageFile.ImageFile):
|
||||
"""
|
||||
Image plugin for the GD uncompressed format. Note that this format
|
||||
is not supported by the standard :py:func:`PIL.Image.open()` function. To use
|
||||
this plugin, you have to import the :py:mod:`PIL.GdImageFile` module and
|
||||
use the :py:func:`PIL.GdImageFile.open()` function.
|
||||
"""
|
||||
|
||||
format = "GD"
|
||||
format_description = "GD uncompressed images"
|
||||
|
||||
def _open(self) -> None:
|
||||
# Header
|
||||
assert self.fp is not None
|
||||
|
||||
s = self.fp.read(1037)
|
||||
|
||||
if i16(s) not in [65534, 65535]:
|
||||
msg = "Not a valid GD 2.x .gd file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
self._mode = "P"
|
||||
self._size = i16(s, 2), i16(s, 4)
|
||||
|
||||
true_color = s[6]
|
||||
true_color_offset = 2 if true_color else 0
|
||||
|
||||
# transparency index
|
||||
tindex = i32(s, 7 + true_color_offset)
|
||||
if tindex < 256:
|
||||
self.info["transparency"] = tindex
|
||||
|
||||
self.palette = ImagePalette.raw(
|
||||
"RGBX", s[7 + true_color_offset + 6 : 7 + true_color_offset + 6 + 256 * 4]
|
||||
)
|
||||
|
||||
self.tile = [
|
||||
ImageFile._Tile(
|
||||
"raw",
|
||||
(0, 0) + self.size,
|
||||
7 + true_color_offset + 6 + 256 * 4,
|
||||
"L",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def open(fp: StrOrBytesPath | IO[bytes], mode: str = "r") -> GdImageFile:
|
||||
"""
|
||||
Load texture from a GD image file.
|
||||
|
||||
:param fp: GD file name, or an opened file handle.
|
||||
:param mode: Optional mode. In this version, if the mode argument
|
||||
is given, it must be "r".
|
||||
:returns: An image instance.
|
||||
:raises OSError: If the image could not be read.
|
||||
"""
|
||||
if mode != "r":
|
||||
msg = "bad mode"
|
||||
raise ValueError(msg)
|
||||
|
||||
try:
|
||||
return GdImageFile(fp)
|
||||
except SyntaxError as e:
|
||||
msg = "cannot identify this image file"
|
||||
raise UnidentifiedImageError(msg) from e
|
||||
+1223
File diff suppressed because it is too large
Load Diff
+154
@@ -0,0 +1,154 @@
|
||||
#
|
||||
# Python Imaging Library
|
||||
# $Id$
|
||||
#
|
||||
# stuff to read (and render) GIMP gradient files
|
||||
#
|
||||
# History:
|
||||
# 97-08-23 fl Created
|
||||
#
|
||||
# Copyright (c) Secret Labs AB 1997.
|
||||
# Copyright (c) Fredrik Lundh 1997.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
|
||||
"""
|
||||
Stuff to translate curve segments to palette values (derived from
|
||||
the corresponding code in GIMP, written by Federico Mena Quintero.
|
||||
See the GIMP distribution for more information.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from math import log, pi, sin, sqrt
|
||||
|
||||
from ._binary import o8
|
||||
|
||||
TYPE_CHECKING = False
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from typing import IO
|
||||
|
||||
EPSILON = 1e-10
|
||||
"""""" # Enable auto-doc for data member
|
||||
|
||||
|
||||
def linear(middle: float, pos: float) -> float:
|
||||
if pos <= middle:
|
||||
if middle < EPSILON:
|
||||
return 0.0
|
||||
else:
|
||||
return 0.5 * pos / middle
|
||||
else:
|
||||
pos = pos - middle
|
||||
middle = 1.0 - middle
|
||||
if middle < EPSILON:
|
||||
return 1.0
|
||||
else:
|
||||
return 0.5 + 0.5 * pos / middle
|
||||
|
||||
|
||||
def curved(middle: float, pos: float) -> float:
|
||||
return pos ** (log(0.5) / log(max(middle, EPSILON)))
|
||||
|
||||
|
||||
def sine(middle: float, pos: float) -> float:
|
||||
return (sin((-pi / 2.0) + pi * linear(middle, pos)) + 1.0) / 2.0
|
||||
|
||||
|
||||
def sphere_increasing(middle: float, pos: float) -> float:
|
||||
return sqrt(1.0 - (linear(middle, pos) - 1.0) ** 2)
|
||||
|
||||
|
||||
def sphere_decreasing(middle: float, pos: float) -> float:
|
||||
return 1.0 - sqrt(1.0 - linear(middle, pos) ** 2)
|
||||
|
||||
|
||||
SEGMENTS = [linear, curved, sine, sphere_increasing, sphere_decreasing]
|
||||
"""""" # Enable auto-doc for data member
|
||||
|
||||
|
||||
class GradientFile:
|
||||
gradient: (
|
||||
list[
|
||||
tuple[
|
||||
float,
|
||||
float,
|
||||
float,
|
||||
list[float],
|
||||
list[float],
|
||||
Callable[[float, float], float],
|
||||
]
|
||||
]
|
||||
| None
|
||||
) = None
|
||||
|
||||
def getpalette(self, entries: int = 256) -> tuple[bytes, str]:
|
||||
assert self.gradient is not None
|
||||
palette = []
|
||||
|
||||
ix = 0
|
||||
x0, x1, xm, rgb0, rgb1, segment = self.gradient[ix]
|
||||
|
||||
for i in range(entries):
|
||||
x = i / (entries - 1)
|
||||
|
||||
while x1 < x:
|
||||
ix += 1
|
||||
x0, x1, xm, rgb0, rgb1, segment = self.gradient[ix]
|
||||
|
||||
w = x1 - x0
|
||||
|
||||
if w < EPSILON:
|
||||
scale = segment(0.5, 0.5)
|
||||
else:
|
||||
scale = segment((xm - x0) / w, (x - x0) / w)
|
||||
|
||||
# expand to RGBA
|
||||
r = o8(int(255 * ((rgb1[0] - rgb0[0]) * scale + rgb0[0]) + 0.5))
|
||||
g = o8(int(255 * ((rgb1[1] - rgb0[1]) * scale + rgb0[1]) + 0.5))
|
||||
b = o8(int(255 * ((rgb1[2] - rgb0[2]) * scale + rgb0[2]) + 0.5))
|
||||
a = o8(int(255 * ((rgb1[3] - rgb0[3]) * scale + rgb0[3]) + 0.5))
|
||||
|
||||
# add to palette
|
||||
palette.append(r + g + b + a)
|
||||
|
||||
return b"".join(palette), "RGBA"
|
||||
|
||||
|
||||
class GimpGradientFile(GradientFile):
|
||||
"""File handler for GIMP's gradient format."""
|
||||
|
||||
def __init__(self, fp: IO[bytes]) -> None:
|
||||
if not fp.readline().startswith(b"GIMP Gradient"):
|
||||
msg = "not a GIMP gradient file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
line = fp.readline()
|
||||
|
||||
# GIMP 1.2 gradient files don't contain a name, but GIMP 1.3 files do
|
||||
if line.startswith(b"Name: "):
|
||||
line = fp.readline().strip()
|
||||
|
||||
count = int(line)
|
||||
|
||||
self.gradient = []
|
||||
|
||||
for i in range(count):
|
||||
s = fp.readline().split()
|
||||
w = [float(x) for x in s[:11]]
|
||||
|
||||
x0, x1 = w[0], w[2]
|
||||
xm = w[1]
|
||||
rgb0 = w[3:7]
|
||||
rgb1 = w[7:11]
|
||||
|
||||
segment = SEGMENTS[int(s[11])]
|
||||
cspace = int(s[12])
|
||||
|
||||
if cspace != 0:
|
||||
msg = "cannot handle HSV colour space"
|
||||
raise OSError(msg)
|
||||
|
||||
self.gradient.append((x0, x1, xm, rgb0, rgb1, segment))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user