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

+4
View File
@@ -22,3 +22,7 @@ export function formatImageDimensions(width: number | null, height: number | nul
if (!width || !height) return null
return `${width} × ${height} px`
}
export function buildImageBbcode(url: string): string {
return `[img]${url}[/img]`
}
+136
View File
@@ -0,0 +1,136 @@
export const TAG_COLOR_PRESETS = [
'#22c55e',
'#3b82f6',
'#f59e0b',
'#ef4444',
'#8b5cf6',
'#06b6d4',
'#ec4899',
'#84cc16',
'#f97316',
'#6366f1',
'#14b8a6',
'#64748b'
] as const
export const TAG_COLOR_CANDIDATE_COUNT = 8
export const TAG_COLOR_EDIT_CANDIDATE_COUNT = 3
export function defaultTagColor(tagId: number): string {
return TAG_COLOR_PRESETS[tagId % TAG_COLOR_PRESETS.length]!
}
function normalizeHexColor(color: string | null | undefined): string | null {
if (!color) return null
const trimmed = color.trim().toLowerCase()
if (!trimmed) return null
const withHash = trimmed.startsWith('#') ? trimmed : `#${trimmed}`
return /^#[0-9a-f]{6}$/.test(withHash) ? withHash : null
}
/** 解析标签展示色,无效时回退到默认色 */
export function resolveTagHexColor(
color: string | null | undefined,
fallback?: string
): string {
return normalizeHexColor(color) ?? fallback ?? TAG_COLOR_PRESETS[0]!
}
export function usedPresetColorSet(usedColors: (string | null | undefined)[]): Set<string> {
const used = new Set<string>()
for (const color of usedColors) {
const normalized = normalizeHexColor(color)
if (!normalized) continue
if (TAG_COLOR_PRESETS.some(preset => preset.toLowerCase() === normalized)) {
used.add(normalized)
}
}
return used
}
function shuffleColors<T extends string>(colors: readonly T[]): T[] {
const items = [...colors]
for (let index = items.length - 1; index > 0; index--) {
const swapIndex = Math.floor(Math.random() * (index + 1))
const current = items[index]!
items[index] = items[swapIndex]!
items[swapIndex] = current
}
return items
}
/**
* 新增标签时的备选色:优先从未占用预设中随机取满 count 个;
* 不足时用其余预设随机补足(保证始终有 7~8 个可选项)。
*/
export function pickTagColorCandidates(
usedColors: (string | null | undefined)[],
count = TAG_COLOR_CANDIDATE_COUNT
): string[] {
const used = usedPresetColorSet(usedColors)
const unused = shuffleColors(TAG_COLOR_PRESETS.filter(preset => !used.has(preset.toLowerCase())))
const usedPresets = shuffleColors(TAG_COLOR_PRESETS.filter(preset => used.has(preset.toLowerCase())))
const candidates: string[] = []
for (const color of unused) {
if (candidates.length >= count) break
candidates.push(color.toLowerCase())
}
for (const color of usedPresets) {
if (candidates.length >= count) break
candidates.push(color.toLowerCase())
}
return candidates.length > 0 ? candidates : shuffleColors(TAG_COLOR_PRESETS).slice(0, count).map(c => c.toLowerCase())
}
/** 从备选色中优先选未占用色作为默认;若均已占用则随机选一 */
export function pickDefaultTagColorFromCandidates(
candidates: string[],
usedColors: (string | null | undefined)[]
): string {
if (!candidates.length) {
return pickRandomUnusedTagColor(usedColors)
}
const used = usedPresetColorSet(usedColors)
const unused = candidates.filter(color => !used.has(color.toLowerCase()))
const pool = unused.length > 0 ? unused : candidates
return pool[Math.floor(Math.random() * pool.length)]!.toLowerCase()
}
/** 从未被占用的预设色中随机取一色;若预设色均已占用则退回全量随机 */
export function pickRandomUnusedTagColor(usedColors: (string | null | undefined)[]): string {
const used = usedPresetColorSet(usedColors)
const available = TAG_COLOR_PRESETS.filter(preset => !used.has(preset.toLowerCase()))
const pool = available.length > 0 ? available : TAG_COLOR_PRESETS
return pool[Math.floor(Math.random() * pool.length)]!.toLowerCase()
}
/** 颜色选择器展示的预设:排除已被其他标签占用的颜色,但保留当前选中色 */
export function visibleTagColorPresets(
usedColors: (string | null | undefined)[],
currentColor?: string | null
): readonly string[] {
const used = usedPresetColorSet(usedColors)
const current = normalizeHexColor(currentColor)
return TAG_COLOR_PRESETS.filter((preset) => {
const lower = preset.toLowerCase()
return !used.has(lower) || lower === current
})
}
export function tagPillStyle(color: string | null | undefined, selected = false, fallback?: string) {
const hex = resolveTagHexColor(color, fallback)
if (selected) {
return {
backgroundColor: hex,
borderColor: hex,
color: '#ffffff'
}
}
return {
borderColor: `${hex}55`,
backgroundColor: `${hex}18`,
color: hex
}
}