From 737235acff5fd5a61f70113e2caf3efa31d903f4 Mon Sep 17 00:00:00 2001 From: O96u Date: Wed, 9 Sep 2026 14:46:52 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v1.4.0=20=E2=80=94=20=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E7=B3=BB=E7=BB=9F=E3=80=81=E6=93=8D=E4=BD=9C=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E6=89=A9=E5=B1=95=E4=B8=8E=E6=96=87=E6=A1=A3=E6=88=AA?= =?UTF-8?q?=E5=9B=BE=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 每用户标签与彩色打标,图库 OR/AND 筛选与批量打标 - 操作日志记录登录、删除、设置与标签操作 - API 标签/打标接口;README 与文档站截图同步 --- CHANGELOG.md | 18 + README.en.md | 17 +- README.md | 15 +- app/components/BatchTagModal.vue | 112 +++ app/components/ImageCard.vue | 160 +++- app/components/ImageGrid.vue | 14 +- app/components/ImagePreviewModal.vue | 63 +- app/components/ImageTagModal.vue | 102 +++ app/components/TagAssignPanel.vue | 296 ++++++ app/components/TagBadge.vue | 61 ++ app/components/TagColorPicker.vue | 127 +++ app/components/TagFilterSelect.vue | 158 ++++ app/components/TagManageModal.vue | 49 + app/components/TagManagePanel.vue | 843 ++++++++++++++++++ app/components/TagPicker.vue | 233 +++++ app/components/UploadPreferencesPanel.vue | 5 +- app/components/gallery/GalleryFilterBar.vue | 43 +- app/components/gallery/GalleryImageList.vue | 168 +++- app/components/settings/SettingsGroup.vue | 2 +- app/components/settings/SettingsLogsPanel.vue | 635 ++++++------- .../settings/SettingsPageHeader.vue | 29 + app/components/settings/SettingsPanel.vue | 6 +- .../settings/SettingsPlainSection.vue | 28 + .../settings/SettingsRadioGroup.vue | 41 + app/components/settings/SettingsSection.vue | 8 +- .../settings/SettingsServerFooter.vue | 9 +- app/components/settings/SettingsSidebar.vue | 52 +- .../settings/SettingsSystemInfo.vue | 46 +- app/components/settings/SettingsTagsPanel.vue | 31 + app/components/settings/SettingsToggleRow.vue | 2 +- .../SettingsUserPreferencesFields.vue | 80 +- app/composables/useImageList.ts | 62 +- app/composables/useImageUpload.ts | 10 +- app/composables/useRecentTags.ts | 48 + app/composables/useTags.ts | 122 +++ app/composables/useUploadPreferences.ts | 32 +- app/pages/gallery.vue | 115 ++- app/pages/index.vue | 88 +- app/pages/settings/index.vue | 413 +++++---- app/types/image.ts | 11 + app/types/logs.ts | 7 +- app/types/settings.ts | 6 +- app/utils/image-display.ts | 4 + app/utils/tag-colors.ts | 136 +++ docs-site/en/guide/api.md | 30 +- docs-site/en/guide/changelog.md | 18 + docs-site/en/guide/configuration.md | 4 +- docs-site/en/guide/getting-started.md | 4 +- docs-site/en/guide/index.md | 20 +- docs-site/en/guide/local-dev.md | 2 +- docs-site/en/guide/storage.md | 6 +- docs-site/en/guide/users-and-permissions.md | 15 + docs-site/guide/api.md | 38 +- docs-site/guide/changelog.md | 18 + docs-site/guide/configuration.md | 4 +- docs-site/guide/getting-started.md | 4 +- docs-site/guide/index.md | 24 +- docs-site/guide/local-dev.md | 2 +- docs-site/guide/storage.md | 6 +- docs-site/guide/users-and-permissions.md | 15 + docs/screenshots/api.png | Bin 127633 -> 122441 bytes docs/screenshots/gallery-detail.png | Bin 0 -> 90911 bytes docs/screenshots/gallery-list.png | Bin 0 -> 126738 bytes docs/screenshots/gallery.jpg | Bin 0 -> 213771 bytes docs/screenshots/gallery.png | Bin 42587 -> 213771 bytes docs/screenshots/logs.png | Bin 103347 -> 90969 bytes docs/screenshots/settings-en.png | Bin 63963 -> 78648 bytes docs/screenshots/settings.png | Bin 63963 -> 78648 bytes docs/screenshots/setup-restore.png | Bin 51498 -> 51204 bytes docs/screenshots/setup.png | Bin 60105 -> 63340 bytes docs/screenshots/storage.png | Bin 91941 -> 85987 bytes docs/screenshots/tags-batch.png | Bin 0 -> 84219 bytes docs/screenshots/tags.png | Bin 0 -> 81292 bytes docs/screenshots/upload-tags.jpg | Bin 0 -> 172157 bytes docs/screenshots/upload.png | Bin 37569 -> 149908 bytes i18n/locales/en.json | 142 ++- i18n/locales/zh-CN.json | 176 +++- package-lock.json | 4 +- package.json | 2 +- server/api/auth/login.post.ts | 8 + server/api/images/batch-delete.post.ts | 10 +- server/api/images/batch-tags.post.ts | 95 ++ server/api/images/count.get.ts | 8 +- server/api/images/index.delete.ts | 10 +- server/api/images/index.get.ts | 17 +- server/api/images/search.get.ts | 17 +- server/api/images/tags.delete.ts | 60 ++ server/api/images/tags.patch.ts | 54 ++ server/api/images/tags.post.ts | 57 ++ server/api/images/upload.post.ts | 23 +- server/api/logs/index.get.ts | 49 +- server/api/settings/index.patch.ts | 11 +- server/api/stats.get.ts | 12 +- server/api/tags/[id].delete.ts | 32 + server/api/tags/[id].patch.ts | 40 + server/api/tags/index.get.ts | 7 + server/api/tags/index.post.ts | 32 + server/api/tags/merge.post.ts | 37 + server/api/user/settings.patch.ts | 11 + server/utils/activity-log.smoke.test.ts | 110 +++ server/utils/activity-log.ts | 67 ++ server/utils/db.ts | 135 ++- server/utils/image-index.ts | 32 +- server/utils/image-query.ts | 28 + server/utils/image-response.ts | 15 +- server/utils/process-image-upload.ts | 38 +- server/utils/storage.ts | 20 +- server/utils/tag-api.ts | 29 + server/utils/tags.test.ts | 81 ++ server/utils/tags.ts | 473 ++++++++++ 110 files changed, 5881 insertions(+), 778 deletions(-) create mode 100644 app/components/BatchTagModal.vue create mode 100644 app/components/ImageTagModal.vue create mode 100644 app/components/TagAssignPanel.vue create mode 100644 app/components/TagBadge.vue create mode 100644 app/components/TagColorPicker.vue create mode 100644 app/components/TagFilterSelect.vue create mode 100644 app/components/TagManageModal.vue create mode 100644 app/components/TagManagePanel.vue create mode 100644 app/components/TagPicker.vue create mode 100644 app/components/settings/SettingsPageHeader.vue create mode 100644 app/components/settings/SettingsPlainSection.vue create mode 100644 app/components/settings/SettingsRadioGroup.vue create mode 100644 app/components/settings/SettingsTagsPanel.vue create mode 100644 app/composables/useRecentTags.ts create mode 100644 app/composables/useTags.ts create mode 100644 app/utils/tag-colors.ts create mode 100644 docs/screenshots/gallery-detail.png create mode 100644 docs/screenshots/gallery-list.png create mode 100644 docs/screenshots/gallery.jpg create mode 100644 docs/screenshots/tags-batch.png create mode 100644 docs/screenshots/tags.png create mode 100644 docs/screenshots/upload-tags.jpg create mode 100644 server/api/images/batch-tags.post.ts create mode 100644 server/api/images/tags.delete.ts create mode 100644 server/api/images/tags.patch.ts create mode 100644 server/api/images/tags.post.ts create mode 100644 server/api/tags/[id].delete.ts create mode 100644 server/api/tags/[id].patch.ts create mode 100644 server/api/tags/index.get.ts create mode 100644 server/api/tags/index.post.ts create mode 100644 server/api/tags/merge.post.ts create mode 100644 server/utils/activity-log.smoke.test.ts create mode 100644 server/utils/activity-log.ts create mode 100644 server/utils/tag-api.ts create mode 100644 server/utils/tags.test.ts create mode 100644 server/utils/tags.ts 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()