diff --git a/CHANGELOG.md b/CHANGELOG.md index 0724852..de77244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ ## [Unreleased] +## [1.4.0] - 2026-09-09 + +### 新增 + +- **标签系统**:每用户独立标签与彩色标记;设置页管理(新建、改色、合并、删除) +- **图片打标**:上传后单张/批量打标;图库按标签筛选(OR / AND)、批量打标与解标 +- **操作日志扩展**:记录登录、删除、设置变更、标签操作等;支持日期、用户、操作类型、来源与 IP 筛选 +- **API**:标签 CRUD、图片打标、`batch-tags`;上传支持 `tagIds`;列表/搜索/统计支持标签筛选 + +### 改进 + +- 操作日志页 UI 改版(操作/来源徽章、移除备注列) +- 设置侧栏增加「标签管理」 + +### 文档 + +- README 与文档站截图更新(上传、图库、标签、操作日志等) + ## [1.3.0] - 2026-09-07 ### 新增 diff --git a/README.en.md b/README.en.md index 4158463..6dcdc66 100644 --- a/README.en.md +++ b/README.en.md @@ -31,9 +31,13 @@ **main** has no public demo. **cloudflare** branch live demo: [pic.roven.cc](https://pic.roven.cc) -| API | Gallery (detail) | -| :--: | :--------------: | -| ![API](docs/screenshots/api.png) | ![Gallery](docs/screenshots/gallery.png) | +| Upload | Gallery | +| :----: | :-----: | +| ![Upload](docs/screenshots/upload.png) | ![Gallery](docs/screenshots/gallery.png) | + +| API | Tag management | +| :-: | :------------: | +| ![API](docs/screenshots/api.png) | ![Tags](docs/screenshots/tags.png) | | Storage | Settings | | :-----: | :-------: | @@ -43,12 +47,17 @@ | :--------------: | :-----------------: | | ![Setup](docs/screenshots/setup.png) | ![Setup restore](docs/screenshots/setup-restore.png) | +| Activity log | +| :----------: | +| ![Activity log](docs/screenshots/logs.png) | + ## Features - **Drag, click, or Ctrl+V paste** — server-side WebP, Referer hotlink protection - **Multi-user** — login with slider, Turnstile, or Cap verification; optional registration; users see only their images - **Multi-backend storage** — local disk + S3-compatible (R2 / COS / OSS / AWS); hybrid `proxy` / `public` URLs -- **Gallery** — browse, search, filter by storage/source, grid/list views, batch delete; stats overview and source breakdown; image detail modal with link formats +- **Gallery** — browse, search, filter by storage/source/tags, grid/list views, batch delete and batch tagging; stats overview and source breakdown; image detail modal with link formats +- **Tags** — per-user colored tags; tag on upload (single or batch); manage in Settings (create, recolor, merge, delete); API supports `tagIds` and batch tagging - **Backup & migration** — full-site export/restore (`.phost.tar.gz`), cross-backend sync; restore from backup on uninitialized `/setup` - **API & Twikoo** — global / per-user tokens; `POST /api/index.php` compatible - **Zero-config Docker** — first-run web wizard, no secrets upfront diff --git a/README.md b/README.md index 7e467f2..8bdecb6 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,13 @@ **main** 分支暂无公网演示;**cloudflare** 分支在线体验:[pic.roven.cc](https://pic.roven.cc) -| API | 图库(详情) | +| 上传 | 图库 | | :--: | :--: | -| ![API](docs/screenshots/api.png) | ![图库](docs/screenshots/gallery.png) | +| ![上传](docs/screenshots/upload.png) | ![图库](docs/screenshots/gallery.png) | + +| API | 标签管理 | +| :--: | :------: | +| ![API](docs/screenshots/api.png) | ![标签管理](docs/screenshots/tags.png) | | 存储 | 设置 | | :--: | :--: | @@ -43,12 +47,17 @@ | :--: | :--: | | ![首次设置](docs/screenshots/setup.png) | ![从备份恢复](docs/screenshots/setup-restore.png) | +| 操作日志 | +| :------: | +| ![操作日志](docs/screenshots/logs.png) | + ## 特性 - **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链 - **多用户**:账号密码登录(滑块 / Turnstile / Cap 人机验证)、可选开放注册;普通用户仅见自己的图片 - **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);混合直链 `proxy` / `public` -- **图库**:浏览、搜索、按存储/来源筛选、网格/列表切换、批量删除;统计概览与来源分布;点击图片查看详情与多格式链接 +- **图库**:浏览、搜索、按存储/来源/标签筛选、网格/列表切换、批量删除与批量打标;统计概览与来源分布;点击图片查看详情与多格式链接 +- **标签**:每用户独立标签、彩色标记;上传后单张/批量打标;设置页管理(新建、改色、合并、删除);API 支持 `tagIds` 与批量打标 - **备份与迁移**:整站导出/恢复(`.phost.tar.gz`)、跨后端同步;setup 未初始化时可从备份恢复 - **API 与 Twikoo**:全局 / 个人 Token;`POST /api/index.php` 兼容 - **Docker 零配置**:首次访问 Web 引导创建管理员 diff --git a/app/components/BatchTagModal.vue b/app/components/BatchTagModal.vue new file mode 100644 index 0000000..09f3791 --- /dev/null +++ b/app/components/BatchTagModal.vue @@ -0,0 +1,112 @@ + + + diff --git a/app/components/ImageCard.vue b/app/components/ImageCard.vue index 6bd8054..fcc0429 100644 --- a/app/components/ImageCard.vue +++ b/app/components/ImageCard.vue @@ -1,6 +1,7 @@ + + diff --git a/app/components/TagAssignPanel.vue b/app/components/TagAssignPanel.vue new file mode 100644 index 0000000..c997972 --- /dev/null +++ b/app/components/TagAssignPanel.vue @@ -0,0 +1,296 @@ + + + diff --git a/app/components/TagBadge.vue b/app/components/TagBadge.vue new file mode 100644 index 0000000..f93445f --- /dev/null +++ b/app/components/TagBadge.vue @@ -0,0 +1,61 @@ + + + diff --git a/app/components/TagColorPicker.vue b/app/components/TagColorPicker.vue new file mode 100644 index 0000000..c0e6518 --- /dev/null +++ b/app/components/TagColorPicker.vue @@ -0,0 +1,127 @@ + + + diff --git a/app/components/TagFilterSelect.vue b/app/components/TagFilterSelect.vue new file mode 100644 index 0000000..24b7ca9 --- /dev/null +++ b/app/components/TagFilterSelect.vue @@ -0,0 +1,158 @@ + + + diff --git a/app/components/TagManageModal.vue b/app/components/TagManageModal.vue new file mode 100644 index 0000000..f4fb6a7 --- /dev/null +++ b/app/components/TagManageModal.vue @@ -0,0 +1,49 @@ + + + diff --git a/app/components/TagManagePanel.vue b/app/components/TagManagePanel.vue new file mode 100644 index 0000000..2480404 --- /dev/null +++ b/app/components/TagManagePanel.vue @@ -0,0 +1,843 @@ + + + + + diff --git a/app/components/TagPicker.vue b/app/components/TagPicker.vue new file mode 100644 index 0000000..254ac3e --- /dev/null +++ b/app/components/TagPicker.vue @@ -0,0 +1,233 @@ + + + diff --git a/app/components/UploadPreferencesPanel.vue b/app/components/UploadPreferencesPanel.vue index 315731f..74784f3 100644 --- a/app/components/UploadPreferencesPanel.vue +++ b/app/components/UploadPreferencesPanel.vue @@ -23,7 +23,8 @@ const { const copyFormatItems = computed(() => [ { label: t('copy.url'), value: 'url' as const }, { label: t('copy.markdown'), value: 'markdown' as const }, - { label: t('copy.html'), value: 'html' as const } + { label: t('copy.html'), value: 'html' as const }, + { label: t('copy.bbcode'), value: 'bbcode' as const } ]) const clientQualityLevel = computed(() => { @@ -32,7 +33,7 @@ const clientQualityLevel = computed(() => { return t('preferences.qualityLow') }) -function setCopyFormat(value: 'url' | 'markdown' | 'html') { +function setCopyFormat(value: 'url' | 'markdown' | 'html' | 'bbcode') { copyFormat.value = value } diff --git a/app/components/gallery/GalleryFilterBar.vue b/app/components/gallery/GalleryFilterBar.vue index 2b1ae55..3064282 100644 --- a/app/components/gallery/GalleryFilterBar.vue +++ b/app/components/gallery/GalleryFilterBar.vue @@ -5,24 +5,39 @@ const props = withDefaults(defineProps<{ storageBackendItems: Array<{ label: string, value: string }> uploadSource: string uploadSourceItems: Array<{ label: string, value: string }> + tagIds?: number[] + tagMode?: 'or' | 'and' + untaggedOnly?: boolean + tags?: Array<{ id: number, name: string, color: string, imageCount?: number }> viewMode: 'grid' | 'list' selectedCount?: number loading?: boolean showBatchDelete?: boolean + showBatchTags?: boolean }>(), { selectedCount: 0, loading: false, - showBatchDelete: true + showBatchDelete: true, + showBatchTags: false, + tagIds: () => [], + tags: () => [], + tagMode: 'or', + untaggedOnly: false }) const emit = defineEmits<{ 'update:searchQuery': [value: string] 'update:storageBackend': [value: string] 'update:uploadSource': [value: string] + 'update:tagIds': [value: number[]] + 'update:tagMode': [value: 'or' | 'and'] + 'update:untaggedOnly': [value: boolean] 'update:viewMode': [mode: 'grid' | 'list'] 'search': [] 'reset': [] 'batch-delete': [] + 'batch-tags': [] + 'manage-tags': [] }>() const { t } = useI18n() @@ -74,6 +89,18 @@ function viewButtonClass(active: boolean) { @update:model-value="emit('update:uploadSource', $event)" /> + + + + {{ t('tags.batchAdd') }} + + -import type { ImageItem } from '~/types/image' +import type { ImageItem, ImageTag } from '~/types/image' const props = withDefaults(defineProps<{ items: ImageItem[] selectedKeys: Set selectable?: boolean showStorage?: boolean + showTags?: boolean emptyText?: string showDelete?: boolean }>(), { selectable: false, showStorage: false, + showTags: false, showDelete: true }) @@ -18,11 +20,15 @@ const emit = defineEmits<{ 'update:selectedKeys': [value: Set] 'preview': [image: ImageItem] 'delete': [image: ImageItem] + 'tag-click': [tagId: number] + 'untagged-click': [] }>() const { t } = useI18n() const { formatFileSize } = useFileSize() +const INLINE_TAG_LIMIT = 3 + const displayEmptyText = computed(() => props.emptyText ?? t('image.empty')) const allSelected = computed(() => @@ -76,10 +82,33 @@ function sourceLabel(image: ImageItem) { } } -function typeLabel(image: ImageItem) { - if (props.showStorage && image.storage) return image.storage.name - return '—' +function sourceIcon(image: ImageItem) { + switch (image.uploadSource) { + case 'web': + return 'i-lucide-globe' + case 'api': + return 'i-lucide-code-xml' + default: + return null + } } + +function storageIcon(image: ImageItem) { + return image.storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud' +} + +function tagDisplay(tags: ImageTag[] | undefined) { + const list = tags ?? [] + if (list.length <= INLINE_TAG_LIMIT) { + return { visible: list, hidden: [] as ImageTag[] } + } + return { + visible: list.slice(0, INLINE_TAG_LIMIT), + hidden: list.slice(INLINE_TAG_LIMIT) + } +} + +const metaBadgeClass = 'inline-flex max-w-full items-center gap-1 rounded-md border border-default px-1.5 py-0.5 text-[11px] leading-4 text-muted' diff --git a/app/components/settings/SettingsPageHeader.vue b/app/components/settings/SettingsPageHeader.vue new file mode 100644 index 0000000..f9a3cbc --- /dev/null +++ b/app/components/settings/SettingsPageHeader.vue @@ -0,0 +1,29 @@ + + + diff --git a/app/components/settings/SettingsPanel.vue b/app/components/settings/SettingsPanel.vue index 6224252..5f60a96 100644 --- a/app/components/settings/SettingsPanel.vue +++ b/app/components/settings/SettingsPanel.vue @@ -1,7 +1,5 @@ diff --git a/app/components/settings/SettingsPlainSection.vue b/app/components/settings/SettingsPlainSection.vue new file mode 100644 index 0000000..dfc492e --- /dev/null +++ b/app/components/settings/SettingsPlainSection.vue @@ -0,0 +1,28 @@ + + + diff --git a/app/components/settings/SettingsRadioGroup.vue b/app/components/settings/SettingsRadioGroup.vue new file mode 100644 index 0000000..ac17838 --- /dev/null +++ b/app/components/settings/SettingsRadioGroup.vue @@ -0,0 +1,41 @@ + + + diff --git a/app/components/settings/SettingsSection.vue b/app/components/settings/SettingsSection.vue index 40e303a..a6228ea 100644 --- a/app/components/settings/SettingsSection.vue +++ b/app/components/settings/SettingsSection.vue @@ -6,8 +6,8 @@ defineProps<{ diff --git a/app/components/settings/SettingsServerFooter.vue b/app/components/settings/SettingsServerFooter.vue index 6cdd787..a257495 100644 --- a/app/components/settings/SettingsServerFooter.vue +++ b/app/components/settings/SettingsServerFooter.vue @@ -6,6 +6,7 @@ defineProps<{ const emit = defineEmits<{ save: [] + restore: [] }>() const { t } = useI18n() @@ -13,13 +14,19 @@ const { t } = useI18n()