release: v1.4.0 — 标签系统、操作日志扩展与文档截图更新
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s

- 每用户标签与彩色打标,图库 OR/AND 筛选与批量打标
- 操作日志记录登录、删除、设置与标签操作
- API 标签/打标接口;README 与文档站截图同步
This commit is contained in:
O96u committed 2026-09-09 14:46:52 +08:00
1 parent f17d79b236
commit 737235acff
110 files changed
+5881 -778

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { afterEach, describe, expect, it } from 'vitest'
const tempDirs: string[] = []
afterEach(() => {
while (tempDirs.length) {
const dir = tempDirs.pop()
if (dir) rmSync(dir, { recursive: true, force: true })
}
process.env.DATA_DIR = undefined
})
function useTempDataDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'pichost-smoke-'))
tempDirs.push(dir)
process.env.DATA_DIR = dir
return dir
}
async function loadDbModule() {
const mod = await import('./db')
mod.closeDb()
return mod
}
describe('activity log smoke', () => {
it('migrates legacy activity_logs constraints and accepts new action types', async () => {
const dir = useTempDataDir()
const dbPath = join(dir, 'pichost.db')
const legacy = new DatabaseSync(dbPath)
legacy.exec(`
CREATE TABLE activity_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL CHECK(action IN ('upload', 'delete')),
key TEXT NOT NULL,
original_name TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
content_type TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL CHECK(source IN ('web', 'api')),
created_at TEXT NOT NULL,
user_id INTEGER,
backend_id TEXT,
ip_address TEXT,
status TEXT NOT NULL DEFAULT 'success'
);
INSERT INTO activity_logs (action, key, original_name, size, content_type, source, created_at)
VALUES ('upload', 'images/a.webp', 'a.webp', 100, 'image/webp', 'web', '2026-01-01T00:00:00.000Z');
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`)
legacy.close()
const { getDb, insertActivityLog, listActivityLogs, closeDb } = await loadDbModule()
getDb()
const { ensureStorageSchema } = await import('./storage-backends')
ensureStorageSchema()
insertActivityLog({
action: 'login',
key: 'auth/login',
originalName: 'admin',
size: 0,
contentType: '',
source: 'web',
userId: 1,
ipAddress: '127.0.0.1'
})
insertActivityLog({
action: 'tags',
key: 'tags/1',
originalName: '生活',
size: 0,
contentType: '',
source: 'admin',
userId: 1
})
const result = listActivityLogs({ limit: 10, page: 1 })
expect(result.total).toBe(3)
expect(result.items.map(item => item.action).sort()).toEqual(['login', 'tags', 'upload'])
const migrated = getDb().prepare(`
SELECT value FROM settings WHERE key = 'activity_logs_schema_v2'
`).get() as { value: string } | undefined
expect(migrated?.value).toBe('1')
closeDb()
await new Promise(resolve => setTimeout(resolve, 50))
})
it('opens the project data database without throwing', async () => {
const projectData = join(process.cwd(), 'data')
process.env.DATA_DIR = projectData
const { getDb, listActivityLogs, closeDb } = await loadDbModule()
getDb()
const result = listActivityLogs({ limit: 5, page: 1 })
expect(result.page).toBeGreaterThan(0)
expect(Array.isArray(result.items)).toBe(true)
closeDb()
await new Promise(resolve => setTimeout(resolve, 50))
})
})
+67
View File
@@ -0,0 +1,67 @@
import type { H3Event } from 'h3'
import { getHeader } from 'h3'
import {
insertActivityLog,
type LogAction,
type LogSource,
type LogStatus
} from './db'
import { verifyApiUploadToken } from './access'
import { clientIp } from './logger'
export const LOG_ACTIONS: LogAction[] = [
'upload',
'delete',
'login',
'edit',
'settings',
'tags'
]
export const LOG_SOURCES: LogSource[] = ['web', 'api', 'admin']
export function isLogAction(value: string): value is LogAction {
return LOG_ACTIONS.includes(value as LogAction)
}
export function isLogSource(value: string): value is LogSource {
return LOG_SOURCES.includes(value as LogSource)
}
export function resolveActivityLogSource(event: H3Event): LogSource {
if (verifyApiUploadToken(event)) {
return 'api'
}
const referer = getHeader(event, 'referer') ?? ''
if (/\/settings(?:[/?#]|$)|\/storage(?:[/?#]|$)/.test(referer)) {
return 'admin'
}
return 'web'
}
export interface ActivityLogEventInput {
action: LogAction
key: string
originalName: string
size?: number
contentType?: string
source?: LogSource
userId?: number | null
backendId?: string | null
status?: LogStatus
}
export function logActivity(event: H3Event, input: ActivityLogEventInput): void {
insertActivityLog({
action: input.action,
key: input.key,
originalName: input.originalName,
size: input.size ?? 0,
contentType: input.contentType ?? '',
source: input.source ?? resolveActivityLogSource(event),
userId: input.userId ?? null,
backendId: input.backendId ?? null,
ipAddress: clientIp(event),
status: input.status ?? 'success'
})
}
+125 -10
View File
@@ -4,8 +4,10 @@ import { join } from 'node:path'
import { getDataDir } from './data-dir'
import { logException } from './logger'
export type LogAction = 'upload' | 'delete'
export type LogSource = 'web' | 'api'
export type LogAction = 'upload' | 'delete' | 'login' | 'edit' | 'settings' | 'tags'
export type LogSource = 'web' | 'api' | 'admin'
export type LogStatus = 'success' | 'failure'
export interface ActivityLogRow {
id: number
@@ -20,6 +22,8 @@ export interface ActivityLogRow {
backend_id: string | null
backend_name: string | null
backend_type: string | null
ip_address: string | null
status: LogStatus
created_at: string
}
@@ -32,6 +36,8 @@ export interface ActivityLogInput {
source: LogSource
userId?: number | null
backendId?: string | null
ipAddress?: string | null
status?: LogStatus
createdAt?: string
}
@@ -67,12 +73,12 @@ export function getDb(): DatabaseSync {
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS activity_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL CHECK(action IN ('upload', 'delete')),
action TEXT NOT NULL,
key TEXT NOT NULL,
original_name TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
content_type TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL CHECK(source IN ('web', 'api')),
source TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at
@@ -105,6 +111,62 @@ export function getDb(): DatabaseSync {
return db
}
function migrateActivityLogSchema(database: DatabaseSync): void {
const migrated = database.prepare(`
SELECT 1 FROM settings WHERE key = 'activity_logs_schema_v2'
`).get()
if (migrated) return
database.exec('BEGIN')
try {
database.exec(`
CREATE TABLE activity_logs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
key TEXT NOT NULL,
original_name TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
content_type TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL,
user_id INTEGER,
backend_id TEXT,
ip_address TEXT,
status TEXT NOT NULL DEFAULT 'success',
created_at TEXT NOT NULL
)
`)
database.exec(`
INSERT INTO activity_logs_new (
id, action, key, original_name, size, content_type, source,
user_id, backend_id, ip_address, status, created_at
)
SELECT
id, action, key, original_name, size, content_type, source,
user_id, backend_id, ip_address, COALESCE(status, 'success'), created_at
FROM activity_logs
`)
database.exec('DROP TABLE activity_logs')
database.exec('ALTER TABLE activity_logs_new RENAME TO activity_logs')
database.exec(`
CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at
ON activity_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_activity_logs_action_created
ON activity_logs(action, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_activity_logs_user_created
ON activity_logs(user_id, created_at DESC);
`)
const now = new Date().toISOString()
database.prepare(`
INSERT INTO settings (key, value, updated_at)
VALUES ('activity_logs_schema_v2', '1', ?)
`).run(now)
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
function migrateSchema(database: DatabaseSync): void {
const columns = database.prepare('PRAGMA table_info(users)').all() as Array<{
name: string
@@ -161,10 +223,44 @@ function migrateSchema(database: DatabaseSync): void {
if (!activityLogColumns.some(column => column.name === 'backend_id')) {
database.exec('ALTER TABLE activity_logs ADD COLUMN backend_id TEXT')
}
const activityLogColumnsAfter = database.prepare('PRAGMA table_info(activity_logs)').all() as Array<{
name: string
}>
if (!activityLogColumnsAfter.some(column => column.name === 'ip_address')) {
database.exec('ALTER TABLE activity_logs ADD COLUMN ip_address TEXT')
}
if (!activityLogColumnsAfter.some(column => column.name === 'status')) {
database.exec(`ALTER TABLE activity_logs ADD COLUMN status TEXT NOT NULL DEFAULT 'success'`)
}
database.exec(`
UPDATE activity_logs SET source = 'api' WHERE source = 'twikoo'
`)
migrateActivityLogSchema(database)
// tags schema (lazy import avoided — keep migration in tags.ts)
database.exec(`
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
color TEXT,
created_at TEXT NOT NULL,
UNIQUE(user_id, name),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_tags_user_id ON tags(user_id);
CREATE TABLE IF NOT EXISTS image_tags (
image_key TEXT NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (image_key, tag_id),
FOREIGN KEY (image_key) REFERENCES images(key) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_image_tags_tag_id ON image_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_image_tags_image_key ON image_tags(image_key);
`)
}
const USER_COLUMNS = 'id, username, password_hash, role, created_at, api_token, auto_delete_days, auto_delete_enabled_at'
@@ -448,8 +544,8 @@ export function insertActivityLog(input: ActivityLogInput): void {
const createdAt = input.createdAt ?? new Date().toISOString()
getDb().prepare(`
INSERT INTO activity_logs
(action, key, original_name, size, content_type, source, user_id, backend_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
(action, key, original_name, size, content_type, source, user_id, backend_id, ip_address, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
input.action,
input.key,
@@ -459,6 +555,8 @@ export function insertActivityLog(input: ActivityLogInput): void {
input.source,
input.userId ?? null,
input.backendId ?? null,
input.ipAddress ?? null,
input.status ?? 'success',
createdAt
)
} catch (error) {
@@ -505,6 +603,8 @@ export function listActivityLogs(options: {
folder?: string
userId?: number
search?: string
dateFrom?: string
dateTo?: string
}): {
items: ActivityLogRow[]
total: number
@@ -542,6 +642,8 @@ export function listActivityLogs(options: {
al.backend_id,
sb.name AS backend_name,
sb.type AS backend_type,
al.ip_address,
al.status,
al.created_at
FROM activity_logs al
LEFT JOIN users u ON u.id = al.user_id
@@ -565,6 +667,8 @@ export function summarizeActivityLogs(options: {
folder?: string
userId?: number
search?: string
dateFrom?: string
dateTo?: string
}): {
total: number
upload: number
@@ -595,6 +699,8 @@ function buildActivityLogWhere(
folder?: string
userId?: number
search?: string
dateFrom?: string
dateTo?: string
},
{ includeAction = true }: { includeAction?: boolean } = {}
): { whereSql: string, params: Array<string | number> } {
@@ -617,9 +723,18 @@ function buildActivityLogWhere(
where.push('al.user_id = ?')
params.push(options.userId)
}
if (options.dateFrom) {
where.push('al.created_at >= ?')
params.push(options.dateFrom)
}
if (options.dateTo) {
where.push('al.created_at < ?')
params.push(options.dateTo)
}
if (options.search) {
where.push('al.original_name LIKE ?')
params.push(`%${options.search}%`)
where.push('(al.original_name LIKE ? OR al.key LIKE ? OR al.ip_address LIKE ?)')
const pattern = `%${options.search}%`
params.push(pattern, pattern, pattern)
}
return {
@@ -680,11 +795,11 @@ function countBySource(): Record<LogSource, number> {
GROUP BY source
`).all() as unknown as Array<{ source: LogSource, total: number }>
const result: Record<LogSource, number> = { web: 0, api: 0 }
const result: Record<LogSource, number> = { web: 0, api: 0, admin: 0 }
for (const row of rows) {
const raw = row.source as string
const source = raw === 'twikoo' ? 'api' : row.source
if (source === 'web' || source === 'api') {
if (source === 'web' || source === 'api' || source === 'admin') {
result[source] += row.total
}
}
+23 -9
View File
@@ -4,6 +4,7 @@ import { getDb, getUploadSourcesForKeys } from './db'
import { getDataDir } from './data-dir'
import { contentTypeFromKey } from './content-type'
import { DEFAULT_FOLDER, toCanonicalImageKey, validateImageKey } from './image-key'
import { buildTagFilterSql, type TagFilterInput } from './tags'
import { isBareImageFilename, isHiddenImageDatePath } from './image-public-path'
import {
ensureDefaultBackends,
@@ -406,7 +407,8 @@ export function updateImageBackendId(key: string, backendId: string): void {
export async function countImages(
userFilter?: number | 'admin',
backendId?: string
backendId?: string,
tagFilter?: TagFilterInput
): Promise<number> {
ensureStorageSchema()
const params: Array<string | number> = []
@@ -414,8 +416,9 @@ export async function countImages(
const userSql = buildUserFilterSql(userFilter, params)
const backendSql = buildBackendFilterSql(backendId, params)
const tagSql = buildTagFilterSql(tagFilter, params)
const row = getDb().prepare(`
SELECT COUNT(*) AS count FROM images WHERE ${clauses.join(' AND ')}${userSql}${backendSql}
SELECT COUNT(*) AS count FROM images WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
`).get(...params) as { count: number }
return row.count
@@ -462,6 +465,7 @@ export async function listImages(options: {
backendId?: string
contentType?: string
uploadSource?: 'web' | 'api'
tagFilter?: TagFilterInput
}): Promise<PaginatedResult<StoredImage>> {
ensureStorageSchema()
const params: Array<string | number> = []
@@ -469,10 +473,11 @@ export async function listImages(options: {
const userSql = buildUserFilterSql(options.userFilter, params)
const backendSql = buildBackendFilterSql(options.backendId, params)
const tagSql = buildTagFilterSql(options.tagFilter, params)
const rows = getDb().prepare(`
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
FROM images
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
ORDER BY uploaded_at DESC, key DESC
`).all(...params) as unknown as ImageIndexRow[]
@@ -495,6 +500,7 @@ export async function searchImages(options: {
backendId?: string
contentType?: string
uploadSource?: 'web' | 'api'
tagFilter?: TagFilterInput
}): Promise<PaginatedResult<StoredImage>> {
ensureStorageSchema()
const needle = options.query.trim().toLowerCase()
@@ -503,10 +509,11 @@ export async function searchImages(options: {
const userSql = buildUserFilterSql(options.userFilter, params)
const backendSql = buildBackendFilterSql(options.backendId, params)
const tagSql = buildTagFilterSql(options.tagFilter, params)
const rows = getDb().prepare(`
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
FROM images
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
ORDER BY uploaded_at DESC, key DESC
`).all(...params) as unknown as ImageIndexRow[]
@@ -528,16 +535,18 @@ export async function searchImages(options: {
}
export async function getFolderStorageStats(
userFilter?: number | 'admin'
userFilter?: number | 'admin',
tagFilter?: TagFilterInput
): Promise<Array<{ folder: string, count: number, bytes: number }>> {
ensureStorageSchema()
const params: Array<string | number> = []
const userSql = buildUserFilterSql(userFilter, params)
const tagSql = buildTagFilterSql(tagFilter, params)
const rows = getDb().prepare(`
SELECT folder, COUNT(*) AS count, COALESCE(SUM(size), 0) AS bytes
FROM images
WHERE 1=1${userSql}
WHERE 1=1${userSql}${tagSql}
GROUP BY folder
ORDER BY bytes DESC, folder ASC
`).all(...params) as Array<{ folder: string, count: number, bytes: number }>
@@ -572,7 +581,10 @@ function startOfYesterdayIsoInShanghai(now = new Date()): string {
return new Date(new Date(startOfDay).getTime() - 24 * 60 * 60 * 1000).toISOString()
}
export async function getUserScopedStorageStats(userId: number): Promise<{
export async function getUserScopedStorageStats(
userId: number,
tagFilter?: TagFilterInput
): Promise<{
storedCount: number
uploadBytesTotal: number
uploadToday: number
@@ -587,11 +599,13 @@ export async function getUserScopedStorageStats(userId: number): Promise<{
const startOfYesterday = startOfYesterdayIsoInShanghai()
const startOfLastMonth = startOfLastMonthIsoInShanghai()
const params: Array<string | number> = [userId]
const tagSql = buildTagFilterSql(tagFilter, params)
const rows = getDb().prepare(`
SELECT folder, size, uploaded_at
FROM images
WHERE user_id = ?
`).all(userId) as Array<{ folder: string, size: number, uploaded_at: string }>
WHERE user_id = ?${tagSql}
`).all(...params) as Array<{ folder: string, size: number, uploaded_at: string }>
const map = new Map<string, { count: number, bytes: number }>()
let uploadBytesTotal = 0
+28
View File
@@ -1,5 +1,6 @@
import { getStorageBackendRow } from './storage-backends'
import { ALLOWED_MIME_TYPES } from './constants'
import { parseTagIdsParam, type TagFilterInput } from './tags'
export function readBackendIdQuery(
query: Record<string, unknown>
@@ -26,3 +27,30 @@ export function readUploadSourceQuery(
if (raw === 'web' || raw === 'api') return raw
return null
}
export function readTagFilterQuery(
query: Record<string, unknown>
): TagFilterInput | null | undefined {
const untaggedRaw = query.untagged
const untaggedOnly = untaggedRaw === '1'
|| untaggedRaw === 'true'
|| untaggedRaw === true
const tagIds = parseTagIdsParam(query.tagIds ?? query.tagId)
const tagModeRaw = typeof query.tagMode === 'string' ? query.tagMode.trim().toLowerCase() : ''
const tagMode = tagModeRaw === 'and' ? 'and' : 'or'
if (untaggedOnly && tagIds.length) {
return null
}
if (!untaggedOnly && !tagIds.length) {
return undefined
}
return {
tagIds: tagIds.length ? tagIds : undefined,
tagMode,
untaggedOnly: untaggedOnly || undefined
}
}
+14 -1
View File
@@ -3,6 +3,8 @@ import type { ImageItem } from '~/types/image'
import type { LogSource } from './db'
import type { AllowedMimeType } from './constants'
import type { StoredImage } from './storage'
import type { TagItem } from './tags'
import { getTagsForImageKeys } from './tags'
import { getImageBaseUrl, isHideFolderInUrl } from './env'
import { toPublicImagePath } from './image-public-path'
import { getActiveBackendRow } from './storage/resolver'
@@ -10,6 +12,17 @@ import { getStorageBackendRow, getStorageBackendFromEnv } from './storage-backen
import type { StorageBackendType, StorageBackendRow } from './storage/types'
import { buildObjectKey, parseS3Config } from './storage/s3'
export function attachTagsToImageItems(items: ImageItem[]): ImageItem[] {
if (!items.length) return items
const tagMap = getTagsForImageKeys(items.map(item => item.key))
return items.map(item => ({
...item,
tags: tagMap.get(item.key) ?? []
}))
}
export type { TagItem }
export function resolveImageOwner(
userId: number | null | undefined,
ownerMap?: Map<number, string>
@@ -72,7 +85,7 @@ export function mapStoredImageToItem(
...item,
...(owner ? { owner } : {}),
...(storage ? { storage } : {}),
...(uploadSource ? { uploadSource } : {})
...(uploadSource === 'web' || uploadSource === 'api' ? { uploadSource } : {})
}
}
+24 -14
View File
@@ -15,7 +15,8 @@ import type { LogSource } from './db'
import { generateImageKey } from './image-key'
import { buildImageItem, sanitizeOriginalName } from './image-response'
import { getStorageLayout, getWebpQuality } from './env'
import { insertActivityLog } from './db'
import { logActivity } from './activity-log'
import { attachTagsAfterUpload, getTagsForImageKeys } from './tags'
import { putImage } from './storage'
import { logInfo, logException } from './logger'
@@ -64,6 +65,7 @@ export async function processSingleImageUpload(
filename?: string
source?: LogSource
userId?: number | null
tagIds?: number[]
}
): Promise<{ item: ImageItem } | { error: UploadErrorItem }> {
const originalName = sanitizeOriginalName(input.filename ?? 'image')
@@ -149,16 +151,15 @@ export async function processSingleImageUpload(
}
}
insertActivityLog({
logActivity(event, {
action: 'upload',
key,
originalName,
size: compressed.bytes.byteLength,
contentType: compressed.mime,
source: input.source ?? 'web',
source: input.source,
userId: input.userId ?? null,
backendId,
createdAt: uploadedAt
backendId
})
logInfo('upload success', {
@@ -168,17 +169,26 @@ export async function processSingleImageUpload(
source: input.source ?? 'web'
})
if (input.tagIds?.length && input.userId != null) {
attachTagsAfterUpload(key, input.tagIds, input.userId)
}
const item = buildImageItem({
key,
event,
originalName,
contentType: compressed.mime,
size: compressed.bytes.byteLength,
uploadedAt
})
const tags = getTagsForImageKeys([key]).get(key) ?? []
return {
item: {
...buildImageItem({
key,
event,
originalName,
contentType: compressed.mime,
size: compressed.bytes.byteLength,
uploadedAt
}),
uploadSource: input.source ?? 'web'
...item,
uploadSource: input.source === 'api' ? 'api' : 'web',
...(tags.length ? { tags } : {})
}
}
}
+14 -6
View File
@@ -19,6 +19,7 @@ import {
searchImages as searchImagesFromIndex
} from './image-index'
import { DEFAULT_FOLDER, toCanonicalImageKey, validateImageKey } from './image-key'
import type { TagFilterInput } from './tags'
import {
getActiveBackend,
getBackendForKey
@@ -171,15 +172,17 @@ export async function listImageKeys(): Promise<string[]> {
export async function countImages(
userFilter?: number | 'admin',
backendId?: string
backendId?: string,
tagFilter?: TagFilterInput
): Promise<number> {
return countImagesFromIndex(userFilter, backendId)
return countImagesFromIndex(userFilter, backendId, tagFilter)
}
export async function getFolderStorageStats(
userFilter?: number | 'admin'
userFilter?: number | 'admin',
tagFilter?: TagFilterInput
): Promise<FolderStorageStat[]> {
return getFolderStorageStatsFromIndex(userFilter)
return getFolderStorageStatsFromIndex(userFilter, tagFilter)
}
export async function listFoldersForUser(
@@ -188,7 +191,10 @@ export async function listFoldersForUser(
return listFoldersForUserFromIndex(userFilter)
}
export async function getUserScopedStorageStats(userId: number): Promise<{
export async function getUserScopedStorageStats(
userId: number,
tagFilter?: TagFilterInput
): Promise<{
storedCount: number
uploadBytesTotal: number
uploadToday: number
@@ -197,7 +203,7 @@ export async function getUserScopedStorageStats(userId: number): Promise<{
uploadLastMonth: number
byFolder: FolderStorageStat[]
}> {
return getUserScopedStorageStatsFromIndex(userId)
return getUserScopedStorageStatsFromIndex(userId, tagFilter)
}
export async function listImages(options: {
@@ -207,6 +213,7 @@ export async function listImages(options: {
backendId?: string
contentType?: string
uploadSource?: 'web' | 'api'
tagFilter?: TagFilterInput
}): Promise<PaginatedResult<StoredImage>> {
return listImagesFromIndex(options)
}
@@ -219,6 +226,7 @@ export async function searchImages(options: {
backendId?: string
contentType?: string
uploadSource?: 'web' | 'api'
tagFilter?: TagFilterInput
}): Promise<PaginatedResult<StoredImage>> {
return searchImagesFromIndex(options)
}
+29
View File
@@ -0,0 +1,29 @@
import type { H3Event } from 'h3'
import { createApiError } from './api-error'
export function handleTagApiError(event: H3Event, error: unknown): never {
if (error instanceof Error) {
if (error.message === 'INVALID_TAG_NAME') {
createApiError(event, 'INVALID_REQUEST', '标签名称无效(1–32 字)', 400)
}
if (error.message === 'INVALID_TAG_COLOR') {
createApiError(event, 'INVALID_REQUEST', '标签颜色格式无效', 400)
}
if (error.message === 'TAG_EXISTS') {
createApiError(event, 'CONFLICT', '标签已存在', 409)
}
if (error.message === 'TAG_LIMIT_REACHED') {
createApiError(event, 'INVALID_REQUEST', '标签数量已达上限', 400)
}
if (error.message === 'TAG_NOT_FOUND') {
createApiError(event, 'INVALID_REQUEST', '标签不存在', 404)
}
if (error.message === 'IMAGE_NOT_FOUND') {
createApiError(event, 'IMAGE_NOT_FOUND', '图片不存在', 404)
}
if (error.message === 'FORBIDDEN') {
createApiError(event, 'FORBIDDEN', '无权操作', 403)
}
}
throw error
}
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import {
buildTagFilterSql,
isValidTagColor,
isValidTagName,
normalizeTagName,
parseTagIdsParam
} from './tags'
describe('normalizeTagName', () => {
it('trims and collapses whitespace', () => {
expect(normalizeTagName(' 工作 截图 ')).toBe('工作 截图')
})
})
describe('isValidTagName', () => {
it('accepts 1-32 char names', () => {
expect(isValidTagName('工作')).toBe(true)
expect(isValidTagName('a'.repeat(32))).toBe(true)
})
it('rejects empty or too long names', () => {
expect(isValidTagName('')).toBe(false)
expect(isValidTagName(' ')).toBe(false)
expect(isValidTagName('a'.repeat(33))).toBe(false)
})
})
describe('isValidTagColor', () => {
it('accepts hex colors and empty', () => {
expect(isValidTagColor('#22c55e')).toBe(true)
expect(isValidTagColor(null)).toBe(true)
expect(isValidTagColor('')).toBe(true)
})
it('rejects invalid colors', () => {
expect(isValidTagColor('red')).toBe(false)
expect(isValidTagColor('#abc')).toBe(false)
})
})
describe('parseTagIdsParam', () => {
it('parses comma-separated and array values', () => {
expect(parseTagIdsParam('1,2,3')).toEqual([1, 2, 3])
expect(parseTagIdsParam(['4', '5'])).toEqual([4, 5])
})
it('deduplicates and skips invalid entries', () => {
expect(parseTagIdsParam('1,1,abc,0,-1')).toEqual([1])
expect(parseTagIdsParam('')).toEqual([])
})
})
describe('buildTagFilterSql', () => {
it('returns empty sql without filter', () => {
const params: Array<string | number> = []
expect(buildTagFilterSql(undefined, params)).toBe('')
expect(params).toEqual([])
})
it('filters untagged images', () => {
const params: Array<string | number> = []
const sql = buildTagFilterSql({ untaggedOnly: true }, params)
expect(sql).toContain('NOT IN')
expect(params).toEqual([])
})
it('filters by tag ids with OR semantics', () => {
const params: Array<string | number> = []
const sql = buildTagFilterSql({ tagIds: [2, 5] }, params)
expect(sql).toContain('tag_id IN')
expect(params).toEqual([2, 5])
})
it('filters by tag ids with AND semantics', () => {
const params: Array<string | number> = []
const sql = buildTagFilterSql({ tagIds: [2, 5], tagMode: 'and' }, params)
expect(sql).toContain('HAVING COUNT(DISTINCT tag_id) = 2')
expect(params).toEqual([2, 5])
})
})
+473
View File
@@ -0,0 +1,473 @@
import { getDb } from './db'
import { getImageIndexRow } from './image-index'
export const TAG_COLOR_PRESETS = [
'#22c55e',
'#3b82f6',
'#f59e0b',
'#ef4444',
'#8b5cf6',
'#06b6d4',
'#ec4899',
'#84cc16',
'#f97316',
'#6366f1',
'#14b8a6',
'#64748b'
] as const
export const MAX_TAGS_PER_USER = 50
export const MAX_TAG_NAME_LENGTH = 32
export interface TagRow {
id: number
user_id: number
name: string
color: string | null
created_at: string
}
export interface TagItem {
id: number
name: string
color: string
imageCount: number
createdAt: string
lastUsedAt: string | null
}
export function normalizeTagName(name: string): string {
return name.trim().replace(/\s+/g, ' ')
}
export function isValidTagName(name: string): boolean {
const normalized = normalizeTagName(name)
return normalized.length >= 1 && normalized.length <= MAX_TAG_NAME_LENGTH
}
export function isValidTagColor(color: string | null | undefined): boolean {
if (color == null || color === '') return true
return /^#[0-9A-Fa-f]{6}$/.test(color)
}
export function normalizeStoredTagColor(color: string | null | undefined): string | null {
if (color == null) return null
const trimmed = color.trim()
if (!trimmed) return null
const withHash = trimmed.startsWith('#') ? trimmed : `#${trimmed}`
return /^#[0-9A-Fa-f]{6}$/.test(withHash) ? withHash.toLowerCase() : null
}
export function defaultTagColor(tagId: number): string {
return TAG_COLOR_PRESETS[tagId % TAG_COLOR_PRESETS.length]!
}
export function ensureTagSchema(): void {
const db = getDb()
db.exec(`
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
color TEXT,
created_at TEXT NOT NULL,
UNIQUE(user_id, name),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_tags_user_id ON tags(user_id);
CREATE TABLE IF NOT EXISTS image_tags (
image_key TEXT NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (image_key, tag_id),
FOREIGN KEY (image_key) REFERENCES images(key) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_image_tags_tag_id ON image_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_image_tags_image_key ON image_tags(image_key);
`)
}
function rowToTagItem(
row: TagRow,
imageCount: number,
lastUsedAt: string | null = null
): TagItem {
return {
id: row.id,
name: row.name,
color: normalizeStoredTagColor(row.color) ?? defaultTagColor(row.id),
imageCount,
createdAt: row.created_at,
lastUsedAt
}
}
function countImagesForTag(tagId: number): number {
const row = getDb().prepare(`
SELECT COUNT(*) AS count FROM image_tags WHERE tag_id = ?
`).get(tagId) as { count: number }
return row.count
}
export function listTagsForUser(userId: number): TagItem[] {
ensureTagSchema()
const rows = getDb().prepare(`
SELECT t.id, t.user_id, t.name, t.color, t.created_at,
COUNT(it.image_key) AS image_count,
MAX(i.uploaded_at) AS last_used_at
FROM tags t
LEFT JOIN image_tags it ON it.tag_id = t.id
LEFT JOIN images i ON i.key = it.image_key
WHERE t.user_id = ?
GROUP BY t.id
ORDER BY t.name ASC
`).all(userId) as unknown as Array<TagRow & { image_count: number, last_used_at: string | null }>
return rows.map(row => rowToTagItem(row, row.image_count, row.last_used_at))
}
export function getTagById(tagId: number): TagRow | null {
ensureTagSchema()
return getDb().prepare(`
SELECT id, user_id, name, color, created_at FROM tags WHERE id = ?
`).get(tagId) as TagRow | undefined ?? null
}
export function getTagForUser(userId: number, tagId: number): TagRow | null {
const row = getTagById(tagId)
if (!row || row.user_id !== userId) return null
return row
}
export function findTagByName(userId: number, name: string): TagRow | null {
ensureTagSchema()
const normalized = normalizeTagName(name)
return getDb().prepare(`
SELECT id, user_id, name, color, created_at
FROM tags
WHERE user_id = ? AND name = ?
`).get(userId, normalized) as TagRow | undefined ?? null
}
export function createTag(
userId: number,
name: string,
color?: string | null
): TagItem {
ensureTagSchema()
const normalized = normalizeTagName(name)
if (!isValidTagName(normalized)) {
throw new Error('INVALID_TAG_NAME')
}
if (color != null && color !== '' && !isValidTagColor(color)) {
throw new Error('INVALID_TAG_COLOR')
}
const existing = findTagByName(userId, normalized)
if (existing) {
throw new Error('TAG_EXISTS')
}
const count = getDb().prepare(`
SELECT COUNT(*) AS count FROM tags WHERE user_id = ?
`).get(userId) as { count: number }
if (count.count >= MAX_TAGS_PER_USER) {
throw new Error('TAG_LIMIT_REACHED')
}
const createdAt = new Date().toISOString()
const storedColor = normalizeStoredTagColor(color)
const result = getDb().prepare(`
INSERT INTO tags (user_id, name, color, created_at)
VALUES (?, ?, ?, ?)
`).run(userId, normalized, storedColor, createdAt)
const id = Number(result.lastInsertRowid)
const row = getTagById(id)!
return rowToTagItem(row, 0)
}
export function updateTag(
userId: number,
tagId: number,
patch: { name?: string, color?: string | null }
): TagItem {
ensureTagSchema()
const row = getTagForUser(userId, tagId)
if (!row) {
throw new Error('TAG_NOT_FOUND')
}
let nextName = row.name
if (patch.name !== undefined) {
const normalized = normalizeTagName(patch.name)
if (!isValidTagName(normalized)) {
throw new Error('INVALID_TAG_NAME')
}
if (normalized !== row.name) {
const duplicate = findTagByName(userId, normalized)
if (duplicate && duplicate.id !== tagId) {
throw new Error('TAG_EXISTS')
}
nextName = normalized
}
}
let nextColor = row.color
if (patch.color !== undefined) {
if (patch.color !== null && patch.color !== '' && !isValidTagColor(patch.color)) {
throw new Error('INVALID_TAG_COLOR')
}
nextColor = normalizeStoredTagColor(patch.color)
}
getDb().prepare(`
UPDATE tags SET name = ?, color = ? WHERE id = ?
`).run(nextName, nextColor, tagId)
const updated = getTagById(tagId)!
return rowToTagItem(updated, countImagesForTag(tagId))
}
export function deleteTag(userId: number, tagId: number): boolean {
ensureTagSchema()
const row = getTagForUser(userId, tagId)
if (!row) return false
getDb().prepare('DELETE FROM tags WHERE id = ?').run(tagId)
return true
}
export function mergeTags(
userId: number,
sourceIds: number[],
targetId: number
): TagItem {
ensureTagSchema()
const uniqueSources = [...new Set(sourceIds.filter(id => id !== targetId))]
const target = getTagForUser(userId, targetId)
if (!target) {
throw new Error('TAG_NOT_FOUND')
}
for (const sourceId of uniqueSources) {
const source = getTagForUser(userId, sourceId)
if (!source) {
throw new Error('TAG_NOT_FOUND')
}
}
const db = getDb()
db.exec('BEGIN')
try {
for (const sourceId of uniqueSources) {
db.prepare(`
INSERT OR IGNORE INTO image_tags (image_key, tag_id)
SELECT image_key, ? FROM image_tags WHERE tag_id = ?
`).run(targetId, sourceId)
db.prepare('DELETE FROM image_tags WHERE tag_id = ?').run(sourceId)
db.prepare('DELETE FROM tags WHERE id = ?').run(sourceId)
}
db.exec('COMMIT')
} catch (error) {
db.exec('ROLLBACK')
throw error
}
const updated = getTagById(targetId)!
return rowToTagItem(updated, countImagesForTag(targetId))
}
export function getTagsForImageKeys(keys: string[]): Map<string, TagItem[]> {
ensureTagSchema()
const map = new Map<string, TagItem[]>()
if (!keys.length) return map
const placeholders = keys.map(() => '?').join(', ')
const rows = getDb().prepare(`
SELECT it.image_key, t.id, t.user_id, t.name, t.color, t.created_at
FROM image_tags it
INNER JOIN tags t ON t.id = it.tag_id
WHERE it.image_key IN (${placeholders})
ORDER BY t.name ASC
`).all(...keys) as unknown as Array<TagRow & { image_key: string }>
for (const row of rows) {
const list = map.get(row.image_key) ?? []
list.push(rowToTagItem(row, 0))
map.set(row.image_key, list)
}
return map
}
function assertTagsOwnedByUser(userId: number, tagIds: number[]): void {
if (!tagIds.length) return
const placeholders = tagIds.map(() => '?').join(', ')
const rows = getDb().prepare(`
SELECT id FROM tags WHERE user_id = ? AND id IN (${placeholders})
`).all(userId, ...tagIds) as Array<{ id: number }>
if (rows.length !== tagIds.length) {
throw new Error('TAG_NOT_FOUND')
}
}
function resolveImageOwnerUserId(key: string): number | null {
const row = getImageIndexRow(key)
return row?.user_id ?? null
}
export function assertCanTagImage(key: string, actorUserId: number, isAdmin: boolean): void {
const ownerId = resolveImageOwnerUserId(key)
if (ownerId == null) {
throw new Error('IMAGE_NOT_FOUND')
}
if (!isAdmin && ownerId !== actorUserId) {
throw new Error('FORBIDDEN')
}
}
export function addTagsToImage(
key: string,
tagIds: number[],
actorUserId: number,
isAdmin: boolean
): TagItem[] {
ensureTagSchema()
assertCanTagImage(key, actorUserId, isAdmin)
const ownerId = resolveImageOwnerUserId(key)!
const uniqueTagIds = [...new Set(tagIds)]
assertTagsOwnedByUser(ownerId, uniqueTagIds)
const insert = getDb().prepare(`
INSERT OR IGNORE INTO image_tags (image_key, tag_id) VALUES (?, ?)
`)
for (const tagId of uniqueTagIds) {
insert.run(key, tagId)
}
return getTagsForImageKeys([key]).get(key) ?? []
}
export function removeTagFromImage(
key: string,
tagId: number,
actorUserId: number,
isAdmin: boolean
): TagItem[] {
ensureTagSchema()
assertCanTagImage(key, actorUserId, isAdmin)
const ownerId = resolveImageOwnerUserId(key)!
assertTagsOwnedByUser(ownerId, [tagId])
getDb().prepare(`
DELETE FROM image_tags WHERE image_key = ? AND tag_id = ?
`).run(key, tagId)
return getTagsForImageKeys([key]).get(key) ?? []
}
export function setImageTags(
key: string,
tagIds: number[],
actorUserId: number,
isAdmin: boolean
): TagItem[] {
ensureTagSchema()
assertCanTagImage(key, actorUserId, isAdmin)
const ownerId = resolveImageOwnerUserId(key)!
const uniqueTagIds = [...new Set(tagIds)]
assertTagsOwnedByUser(ownerId, uniqueTagIds)
const db = getDb()
db.exec('BEGIN')
try {
db.prepare('DELETE FROM image_tags WHERE image_key = ?').run(key)
const insert = db.prepare(`
INSERT INTO image_tags (image_key, tag_id) VALUES (?, ?)
`)
for (const tagId of uniqueTagIds) {
insert.run(key, tagId)
}
db.exec('COMMIT')
} catch (error) {
db.exec('ROLLBACK')
throw error
}
return getTagsForImageKeys([key]).get(key) ?? []
}
export function attachTagsAfterUpload(
key: string,
tagIds: number[],
userId: number | null
): void {
if (!tagIds.length || userId == null) return
addTagsToImage(key, tagIds, userId, true)
}
export function batchAddTagsToImages(
keys: string[],
tagIds: number[],
actorUserId: number,
isAdmin: boolean
): number {
ensureTagSchema()
let updated = 0
for (const key of keys) {
addTagsToImage(key, tagIds, actorUserId, isAdmin)
updated++
}
return updated
}
export interface TagFilterInput {
tagIds?: number[]
tagMode?: 'or' | 'and'
untaggedOnly?: boolean
}
export function buildTagFilterSql(
filter: TagFilterInput | undefined,
params: Array<string | number>
): string {
if (!filter?.untaggedOnly && !filter?.tagIds?.length) {
return ''
}
if (filter.untaggedOnly) {
return ` AND images.key NOT IN (SELECT image_key FROM image_tags)`
}
const tagIds = filter.tagIds!
const placeholders = tagIds.map(() => '?').join(', ')
params.push(...tagIds)
if (filter.tagMode === 'and' && tagIds.length > 1) {
return ` AND images.key IN (
SELECT image_key FROM image_tags
WHERE tag_id IN (${placeholders})
GROUP BY image_key
HAVING COUNT(DISTINCT tag_id) = ${tagIds.length}
)`
}
return ` AND images.key IN (
SELECT image_key FROM image_tags WHERE tag_id IN (${placeholders})
)`
}
export function parseTagIdsParam(raw: unknown): number[] {
if (raw == null || raw === '') return []
const parts = Array.isArray(raw)
? raw.flatMap(item => String(item).split(','))
: String(raw).split(',')
const ids: number[] = []
for (const part of parts) {
const trimmed = part.trim()
if (!trimmed || trimmed === 'all') continue
const id = Number(trimmed)
if (!Number.isInteger(id) || id <= 0) continue
ids.push(id)
}
return [...new Set(ids)]
}