release: PicHost v1.0.0
Publish Docker image / build-and-push (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build-and-push (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Multi-user image hosting with RBAC, upload preferences, and admin settings.
This commit is contained in:
commit
bc4cba7e52
115 files changed
+26508
No files matched your search
@@ -0,0 +1,299 @@
|
||||
export type AuthStatus = 'checking' | 'authenticated' | 'unauthenticated'
|
||||
|
||||
export type UserRole = 'admin' | 'user'
|
||||
|
||||
export interface AuthUser {
|
||||
id: number
|
||||
username: string
|
||||
role: UserRole
|
||||
}
|
||||
|
||||
export interface AuthStatusResponse {
|
||||
initialized: boolean
|
||||
allowRegistration: boolean
|
||||
legacyMode: boolean
|
||||
needsMigration: boolean
|
||||
user: AuthUser | null
|
||||
}
|
||||
|
||||
export function isUnauthorizedError(error: unknown): boolean {
|
||||
return typeof error === 'object'
|
||||
&& error !== null
|
||||
&& 'statusCode' in error
|
||||
&& (error as { statusCode: number }).statusCode === 401
|
||||
}
|
||||
|
||||
export const AUTH_USERNAME_PATTERN = /^[a-zA-Z0-9_]{3,32}$/
|
||||
export const AUTH_MIN_PASSWORD_LENGTH = 8
|
||||
|
||||
export function validateAuthInput(username: string, password: string): string | null {
|
||||
const trimmed = username.trim()
|
||||
if (!AUTH_USERNAME_PATTERN.test(trimmed)) {
|
||||
return '用户名需为 3–32 位字母、数字或下划线'
|
||||
}
|
||||
if (password.length < AUTH_MIN_PASSWORD_LENGTH) {
|
||||
return '密码至少 8 位'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function getFetchErrorMessage(error: unknown, fallback: string): string {
|
||||
if (!error || typeof error !== 'object') return fallback
|
||||
|
||||
const e = error as {
|
||||
data?: {
|
||||
message?: string
|
||||
error?: { message?: string }
|
||||
data?: { error?: { message?: string } }
|
||||
}
|
||||
statusMessage?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
const nested = e.data?.data?.error?.message
|
||||
?? e.data?.error?.message
|
||||
?? e.data?.message
|
||||
if (nested) return nested
|
||||
|
||||
if (e.statusMessage && e.statusMessage !== 'Bad Request') {
|
||||
return e.statusMessage
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
export type AuthActionResult = { ok: true } | { ok: false, error: string }
|
||||
|
||||
export function useAuth() {
|
||||
const status = useState<AuthStatus>('auth-status', () => 'checking')
|
||||
const user = useState<AuthUser | null>('auth-user', () => null)
|
||||
const authStatus = useState<AuthStatusResponse | null>('auth-status-response', () => null)
|
||||
|
||||
const isChecking = computed(() => status.value === 'checking')
|
||||
const isAuthenticated = computed(() => status.value === 'authenticated')
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
|
||||
async function fetchStatus(): Promise<AuthStatusResponse> {
|
||||
const data = await $fetch<AuthStatusResponse>('/api/auth/status', {
|
||||
credentials: 'include'
|
||||
})
|
||||
authStatus.value = data
|
||||
return data
|
||||
}
|
||||
|
||||
async function checkSession() {
|
||||
status.value = 'checking'
|
||||
try {
|
||||
const data = await fetchStatus()
|
||||
if (data.user) {
|
||||
user.value = data.user
|
||||
status.value = 'authenticated'
|
||||
return true
|
||||
}
|
||||
user.value = null
|
||||
status.value = 'unauthenticated'
|
||||
return false
|
||||
} catch {
|
||||
user.value = null
|
||||
status.value = 'unauthenticated'
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function markUnauthenticated() {
|
||||
status.value = 'unauthenticated'
|
||||
user.value = null
|
||||
}
|
||||
|
||||
async function login(
|
||||
credentials: { username: string, password: string }
|
||||
| { secret: string }
|
||||
): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> {
|
||||
try {
|
||||
const result = await $fetch<{
|
||||
success: boolean
|
||||
needsMigration?: boolean
|
||||
user?: AuthUser
|
||||
}>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: credentials
|
||||
})
|
||||
if (result.needsMigration) {
|
||||
return { ok: true, needsMigration: true }
|
||||
}
|
||||
if (result.user) {
|
||||
user.value = result.user
|
||||
}
|
||||
status.value = 'authenticated'
|
||||
await fetchStatus()
|
||||
return { ok: true }
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
ok: false,
|
||||
error: getFetchErrorMessage(error, '登录失败,请检查用户名和密码')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function register(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<AuthActionResult> {
|
||||
const validationError = validateAuthInput(username, password)
|
||||
if (validationError) {
|
||||
return { ok: false, error: validationError }
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await $fetch<{ success: boolean, user: AuthUser }>(
|
||||
'/api/auth/register',
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: { username: username.trim(), password }
|
||||
}
|
||||
)
|
||||
user.value = result.user
|
||||
status.value = 'authenticated'
|
||||
await fetchStatus()
|
||||
return { ok: true }
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
ok: false,
|
||||
error: getFetchErrorMessage(error, '注册失败,请检查输入')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(input: {
|
||||
username: string
|
||||
password: string
|
||||
allowRegistration: boolean
|
||||
}): Promise<AuthActionResult> {
|
||||
const validationError = validateAuthInput(input.username, input.password)
|
||||
if (validationError) {
|
||||
return { ok: false, error: validationError }
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await $fetch<{ success: boolean, user: AuthUser }>(
|
||||
'/api/setup',
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: {
|
||||
username: input.username.trim(),
|
||||
password: input.password,
|
||||
allowRegistration: input.allowRegistration
|
||||
}
|
||||
}
|
||||
)
|
||||
user.value = result.user
|
||||
status.value = 'authenticated'
|
||||
await fetchStatus()
|
||||
return { ok: true }
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
ok: false,
|
||||
error: getFetchErrorMessage(error, '初始化失败,请检查输入')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function migrate(input: {
|
||||
username: string
|
||||
password: string
|
||||
allowRegistration: boolean
|
||||
}): Promise<AuthActionResult> {
|
||||
const validationError = validateAuthInput(input.username, input.password)
|
||||
if (validationError) {
|
||||
return { ok: false, error: validationError }
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await $fetch<{ success: boolean, user: AuthUser }>(
|
||||
'/api/auth/migrate',
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: {
|
||||
username: input.username.trim(),
|
||||
password: input.password,
|
||||
allowRegistration: input.allowRegistration
|
||||
}
|
||||
}
|
||||
)
|
||||
user.value = result.user
|
||||
status.value = 'authenticated'
|
||||
await fetchStatus()
|
||||
return { ok: true }
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
ok: false,
|
||||
error: getFetchErrorMessage(error, '迁移失败,请检查输入')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword(
|
||||
currentPassword: string,
|
||||
newPassword: string
|
||||
): Promise<AuthActionResult> {
|
||||
if (newPassword.length < AUTH_MIN_PASSWORD_LENGTH) {
|
||||
return { ok: false, error: '新密码至少 8 位' }
|
||||
}
|
||||
|
||||
try {
|
||||
await $fetch('/api/auth/password', {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
body: { currentPassword, newPassword }
|
||||
})
|
||||
return { ok: true }
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
ok: false,
|
||||
error: getFetchErrorMessage(error, '修改密码失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||
} catch {
|
||||
// 忽略登出失败
|
||||
} finally {
|
||||
status.value = 'unauthenticated'
|
||||
user.value = null
|
||||
authStatus.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthError(error: unknown) {
|
||||
if (isUnauthorizedError(error)) {
|
||||
markUnauthenticated()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
user,
|
||||
authStatus,
|
||||
isChecking,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
checkSession,
|
||||
fetchStatus,
|
||||
markUnauthenticated,
|
||||
login,
|
||||
register,
|
||||
setup,
|
||||
migrate,
|
||||
changePassword,
|
||||
logout,
|
||||
handleAuthError,
|
||||
getFetchErrorMessage
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
const MAX_WIDTH = 2560
|
||||
const QUALITY = 0.85
|
||||
|
||||
export interface CompressOptions {
|
||||
enabled: boolean
|
||||
maxWidth?: number
|
||||
quality?: number
|
||||
}
|
||||
|
||||
function shouldCompress(file: File): boolean {
|
||||
return file.type === 'image/jpeg' || file.type === 'image/png'
|
||||
}
|
||||
|
||||
async function compressImage(
|
||||
file: File,
|
||||
maxWidth: number,
|
||||
quality: number
|
||||
): Promise<File> {
|
||||
const bitmap = await createImageBitmap(file)
|
||||
const scale = Math.min(1, maxWidth / bitmap.width)
|
||||
const width = Math.round(bitmap.width * scale)
|
||||
const height = Math.round(bitmap.height * scale)
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
bitmap.close()
|
||||
return file
|
||||
}
|
||||
|
||||
ctx.drawImage(bitmap, 0, 0, width, height)
|
||||
bitmap.close()
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(resolve, 'image/webp', quality)
|
||||
})
|
||||
|
||||
if (!blob) return file
|
||||
|
||||
const baseName = file.name.replace(/\.[^.]+$/, '') || 'image'
|
||||
return new File([blob], `${baseName}.webp`, { type: 'image/webp' })
|
||||
}
|
||||
|
||||
export async function prepareFilesForUpload(
|
||||
files: File[],
|
||||
options: CompressOptions
|
||||
): Promise<File[]> {
|
||||
if (!options.enabled) return files
|
||||
|
||||
const maxWidth = options.maxWidth ?? MAX_WIDTH
|
||||
const quality = options.quality ?? QUALITY
|
||||
|
||||
const prepared: File[] = []
|
||||
|
||||
for (const file of files) {
|
||||
if (!shouldCompress(file)) {
|
||||
prepared.push(file)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
prepared.push(await compressImage(file, maxWidth, quality))
|
||||
} catch {
|
||||
prepared.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
return prepared
|
||||
}
|
||||
|
||||
export function useClipboardImage(_target?: Ref<HTMLElement | null>) {
|
||||
function onPaste(event: ClipboardEvent, onFiles: (files: File[]) => void) {
|
||||
const items = event.clipboardData?.items
|
||||
if (!items?.length) return
|
||||
|
||||
const files: File[] = []
|
||||
for (const item of items) {
|
||||
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile()
|
||||
if (file) files.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length) {
|
||||
event.preventDefault()
|
||||
onFiles(files)
|
||||
}
|
||||
}
|
||||
|
||||
function bindPaste(onFiles: (files: File[]) => void) {
|
||||
const handler = (event: ClipboardEvent) => onPaste(event, onFiles)
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('paste', handler)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('paste', handler)
|
||||
})
|
||||
}
|
||||
|
||||
return { bindPaste, onPaste }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function useFileSize() {
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '-'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
return { formatFileSize }
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import type {
|
||||
ImageItem,
|
||||
ImageListResponse
|
||||
} from '~/types/image'
|
||||
|
||||
const PAGE_SIZE = 12
|
||||
|
||||
export function useImageList() {
|
||||
const items = ref<ImageItem[]>([])
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const folderTotal = ref<number | null>(null)
|
||||
const loadingTotal = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const activeSearch = ref('')
|
||||
const activeFolder = ref('all')
|
||||
|
||||
function folderQueryParams(): Record<string, string> {
|
||||
const folder = activeFolder.value.trim()
|
||||
if (!folder || folder === 'all') return {}
|
||||
return { folder }
|
||||
}
|
||||
|
||||
function matchesActiveFolder(key: string) {
|
||||
const folder = activeFolder.value.trim()
|
||||
if (!folder || folder === 'all') return true
|
||||
return key.startsWith(`${folder}/`)
|
||||
}
|
||||
|
||||
async function fetchTotal() {
|
||||
loadingTotal.value = true
|
||||
try {
|
||||
const data = await $fetch<{ total: number }>('/api/images/count', {
|
||||
credentials: 'include',
|
||||
query: folderQueryParams()
|
||||
})
|
||||
folderTotal.value = data.total
|
||||
} catch {
|
||||
folderTotal.value = null
|
||||
} finally {
|
||||
loadingTotal.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchList(targetPage = 1) {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await $fetch<ImageListResponse>('/api/images', {
|
||||
credentials: 'include',
|
||||
query: {
|
||||
limit: PAGE_SIZE,
|
||||
page: targetPage,
|
||||
...folderQueryParams()
|
||||
}
|
||||
})
|
||||
|
||||
items.value = response.items
|
||||
page.value = response.page
|
||||
totalPages.value = response.totalPages
|
||||
total.value = response.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSearch(targetPage = 1) {
|
||||
const q = activeSearch.value.trim()
|
||||
if (!q) {
|
||||
await fetchList(1)
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await $fetch<ImageListResponse>('/api/images/search', {
|
||||
credentials: 'include',
|
||||
query: {
|
||||
q,
|
||||
limit: PAGE_SIZE,
|
||||
page: targetPage,
|
||||
...folderQueryParams()
|
||||
}
|
||||
})
|
||||
|
||||
items.value = response.items
|
||||
page.value = response.page
|
||||
totalPages.value = response.totalPages
|
||||
total.value = response.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshList() {
|
||||
activeSearch.value = ''
|
||||
searchQuery.value = ''
|
||||
page.value = 1
|
||||
await Promise.all([fetchList(1), fetchTotal()])
|
||||
}
|
||||
|
||||
async function initializeList(folder = 'all') {
|
||||
const next = folder.trim() || 'all'
|
||||
activeFolder.value = next
|
||||
page.value = 1
|
||||
activeSearch.value = ''
|
||||
searchQuery.value = ''
|
||||
await Promise.all([fetchList(1), fetchTotal()])
|
||||
}
|
||||
|
||||
async function setActiveFolder(folder: string) {
|
||||
const next = folder.trim() || 'all'
|
||||
if (next === activeFolder.value) return
|
||||
activeFolder.value = next
|
||||
page.value = 1
|
||||
if (activeSearch.value) {
|
||||
await Promise.all([fetchSearch(1), fetchTotal()])
|
||||
} else {
|
||||
await Promise.all([fetchList(1), fetchTotal()])
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSearch() {
|
||||
const q = searchQuery.value.trim()
|
||||
activeSearch.value = q
|
||||
page.value = 1
|
||||
if (!q) {
|
||||
await refreshList()
|
||||
return
|
||||
}
|
||||
await fetchSearch(1)
|
||||
}
|
||||
|
||||
async function goToPage(targetPage: number) {
|
||||
if (targetPage < 1 || targetPage > totalPages.value || loading.value) return
|
||||
if (activeSearch.value) {
|
||||
await fetchSearch(targetPage)
|
||||
} else {
|
||||
await fetchList(targetPage)
|
||||
}
|
||||
}
|
||||
|
||||
function prependItems(newItems: ImageItem[]) {
|
||||
const matched = newItems.filter(item => matchesActiveFolder(item.key))
|
||||
if (!matched.length || page.value !== 1) return
|
||||
items.value = [...matched, ...items.value]
|
||||
total.value += matched.length
|
||||
if (folderTotal.value !== null) {
|
||||
folderTotal.value += matched.length
|
||||
}
|
||||
}
|
||||
|
||||
function removeItems(keys: string[]) {
|
||||
const keySet = new Set(keys)
|
||||
const removed = items.value.filter(item => keySet.has(item.key)).length
|
||||
items.value = items.value.filter(item => !keySet.has(item.key))
|
||||
if (removed > 0) {
|
||||
total.value = Math.max(0, total.value - removed)
|
||||
}
|
||||
if (folderTotal.value !== null && removed > 0) {
|
||||
folderTotal.value = Math.max(0, folderTotal.value - removed)
|
||||
}
|
||||
}
|
||||
|
||||
const listSummary = computed(() => {
|
||||
const folder = activeFolder.value.trim() || 'all'
|
||||
const folderLabel = folder === 'all' ? '全部' : folder
|
||||
if (loadingTotal.value && folderTotal.value === null) {
|
||||
return `${folderLabel} · 统计中…`
|
||||
}
|
||||
if (activeSearch.value) {
|
||||
return `${folderLabel} · 搜索「${activeSearch.value}」· 共 ${total.value} 张`
|
||||
}
|
||||
if (folderTotal.value === null) {
|
||||
return `${folderLabel} · 共 ${total.value} 张`
|
||||
}
|
||||
return `${folderLabel} · 共 ${folderTotal.value} 张`
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
loading,
|
||||
activeFolder,
|
||||
searchQuery,
|
||||
activeSearch,
|
||||
listSummary,
|
||||
fetchList,
|
||||
fetchTotal,
|
||||
refreshList,
|
||||
initializeList,
|
||||
setActiveFolder,
|
||||
submitSearch,
|
||||
goToPage,
|
||||
prependItems,
|
||||
removeItems
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type {
|
||||
UploadProgressItem,
|
||||
UploadResponse
|
||||
} from '~/types/image'
|
||||
import { isUnauthorizedError } from './useAuth'
|
||||
import { prepareFilesForUpload } from './useClipboardImage'
|
||||
|
||||
const MAX_FILES = 10
|
||||
|
||||
export function useImageUpload() {
|
||||
const toast = useToast()
|
||||
const { compressEnabled, clientWebpQuality } = useUploadPreferences()
|
||||
const uploading = ref(false)
|
||||
const progressItems = ref<UploadProgressItem[]>([])
|
||||
const lastUploadResult = ref<UploadResponse | null>(null)
|
||||
|
||||
async function uploadFiles(
|
||||
files: File[],
|
||||
folder = 'images',
|
||||
options?: { notify?: boolean, includeFolder?: boolean }
|
||||
) {
|
||||
if (uploading.value) return
|
||||
|
||||
if (!files.length) {
|
||||
toast.add({ title: '请选择图片', color: 'warning' })
|
||||
return
|
||||
}
|
||||
|
||||
if (files.length > MAX_FILES) {
|
||||
toast.add({
|
||||
title: `每次最多上传 ${MAX_FILES} 张图片`,
|
||||
color: 'warning'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
progressItems.value = files.map(file => ({
|
||||
name: file.name,
|
||||
status: 'pending',
|
||||
progress: 0
|
||||
}))
|
||||
|
||||
try {
|
||||
const prepared = await prepareFilesForUpload(files, {
|
||||
enabled: compressEnabled.value,
|
||||
quality: clientWebpQuality.value / 100
|
||||
})
|
||||
|
||||
progressItems.value = prepared.map((file, index) => ({
|
||||
name: file.name,
|
||||
status: 'uploading',
|
||||
progress: Math.round(((index + 1) / prepared.length) * 50)
|
||||
}))
|
||||
|
||||
const formData = new FormData()
|
||||
if (options?.includeFolder !== false) {
|
||||
formData.append('folder', folder.trim() || 'images')
|
||||
}
|
||||
for (const file of prepared) {
|
||||
formData.append('files', file, file.name)
|
||||
}
|
||||
|
||||
const response = await $fetch<UploadResponse>('/api/images/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
lastUploadResult.value = response
|
||||
|
||||
progressItems.value = prepared.map((file) => {
|
||||
const failed = response.errors.find(error => error.name === file.name)
|
||||
return {
|
||||
name: file.name,
|
||||
status: failed ? 'error' : 'success',
|
||||
progress: 100,
|
||||
error: failed?.error.message
|
||||
}
|
||||
})
|
||||
|
||||
if (response.items.length && options?.notify !== false) {
|
||||
toast.add({
|
||||
title: `成功上传 ${response.items.length} 张图片`,
|
||||
color: 'success'
|
||||
})
|
||||
}
|
||||
|
||||
if (response.errors.length) {
|
||||
toast.add({
|
||||
title: `${response.errors.length} 张图片上传失败`,
|
||||
color: 'warning'
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error: unknown) {
|
||||
if (isUnauthorizedError(error)) {
|
||||
throw error
|
||||
}
|
||||
const message = error instanceof Error ? error.message : '上传失败'
|
||||
progressItems.value = progressItems.value.map(item => ({
|
||||
...item,
|
||||
status: 'error',
|
||||
progress: 100,
|
||||
error: message
|
||||
}))
|
||||
toast.add({ title: message, color: 'error' })
|
||||
throw error
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uploading,
|
||||
progressItems,
|
||||
lastUploadResult,
|
||||
uploadFiles
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
const STORAGE_KEY = 'pichost.upload-preferences'
|
||||
|
||||
export type CopyFormat = 'url' | 'markdown' | 'html'
|
||||
|
||||
export interface UploadPreferences {
|
||||
defaultFolder: string
|
||||
compressEnabled: boolean
|
||||
autoCopyMarkdown: boolean
|
||||
copyFormat: CopyFormat
|
||||
clientWebpQuality: number
|
||||
}
|
||||
|
||||
const DEFAULTS: UploadPreferences = {
|
||||
defaultFolder: 'images',
|
||||
compressEnabled: false,
|
||||
autoCopyMarkdown: false,
|
||||
copyFormat: 'markdown',
|
||||
clientWebpQuality: 80
|
||||
}
|
||||
|
||||
function clampQuality(value: unknown): number {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num)) return DEFAULTS.clientWebpQuality
|
||||
return Math.min(100, Math.max(1, Math.round(num)))
|
||||
}
|
||||
|
||||
function isCopyFormat(value: unknown): value is CopyFormat {
|
||||
return value === 'url' || value === 'markdown' || value === 'html'
|
||||
}
|
||||
|
||||
function readStored(): UploadPreferences {
|
||||
if (import.meta.server) return { ...DEFAULTS }
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return { ...DEFAULTS }
|
||||
const parsed = JSON.parse(raw) as Partial<UploadPreferences>
|
||||
return {
|
||||
defaultFolder: typeof parsed.defaultFolder === 'string' && parsed.defaultFolder.trim()
|
||||
? parsed.defaultFolder.trim()
|
||||
: DEFAULTS.defaultFolder,
|
||||
compressEnabled: parsed.compressEnabled === true,
|
||||
autoCopyMarkdown: parsed.autoCopyMarkdown === true,
|
||||
copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat,
|
||||
clientWebpQuality: clampQuality(parsed.clientWebpQuality)
|
||||
}
|
||||
} catch {
|
||||
return { ...DEFAULTS }
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(prefs: UploadPreferences) {
|
||||
if (import.meta.server) return
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs))
|
||||
}
|
||||
|
||||
export function useUploadPreferences() {
|
||||
const defaultFolder = useState('upload-pref-folder', () => DEFAULTS.defaultFolder)
|
||||
const compressEnabled = useState('upload-pref-compress', () => DEFAULTS.compressEnabled)
|
||||
const autoCopyMarkdown = useState('upload-pref-auto-md', () => DEFAULTS.autoCopyMarkdown)
|
||||
const copyFormat = useState<CopyFormat>('upload-pref-copy-format', () => DEFAULTS.copyFormat)
|
||||
const clientWebpQuality = useState('upload-pref-webp-quality', () => DEFAULTS.clientWebpQuality)
|
||||
const loaded = useState('upload-pref-loaded', () => false)
|
||||
|
||||
function loadPreferences() {
|
||||
const stored = readStored()
|
||||
defaultFolder.value = stored.defaultFolder
|
||||
compressEnabled.value = stored.compressEnabled
|
||||
autoCopyMarkdown.value = stored.autoCopyMarkdown
|
||||
copyFormat.value = stored.copyFormat
|
||||
clientWebpQuality.value = stored.clientWebpQuality
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function savePreferences() {
|
||||
writeStored({
|
||||
defaultFolder: defaultFolder.value.trim() || DEFAULTS.defaultFolder,
|
||||
compressEnabled: compressEnabled.value,
|
||||
autoCopyMarkdown: autoCopyMarkdown.value,
|
||||
copyFormat: copyFormat.value,
|
||||
clientWebpQuality: clampQuality(clientWebpQuality.value)
|
||||
})
|
||||
}
|
||||
|
||||
watch([defaultFolder, compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality], () => {
|
||||
if (loaded.value) {
|
||||
savePreferences()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
defaultFolder,
|
||||
compressEnabled,
|
||||
autoCopyMarkdown,
|
||||
copyFormat,
|
||||
clientWebpQuality,
|
||||
loaded,
|
||||
loadPreferences,
|
||||
savePreferences
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export function useUserAutoDeleteSettings() {
|
||||
const { isAuthenticated, handleAuthError } = useAuth()
|
||||
const toast = useToast()
|
||||
|
||||
const userSettings = ref<{ autoDeleteDays: number } | null>(null)
|
||||
const enabled = ref(false)
|
||||
const daysDraft = ref(30)
|
||||
const hydrated = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
let daysSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function apply(data: { autoDeleteDays: number }) {
|
||||
hydrated.value = false
|
||||
userSettings.value = data
|
||||
enabled.value = data.autoDeleteDays > 0
|
||||
daysDraft.value = data.autoDeleteDays > 0 ? data.autoDeleteDays : 30
|
||||
nextTick(() => {
|
||||
hydrated.value = true
|
||||
})
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await $fetch<{ autoDeleteDays: number }>('/api/user/settings', {
|
||||
credentials: 'include'
|
||||
})
|
||||
apply(data)
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
if (isAuthenticated.value) {
|
||||
toast.add({ title: '加载设置失败', color: 'error' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!isAuthenticated.value || !userSettings.value) return
|
||||
|
||||
const days = enabled.value ? daysDraft.value : 0
|
||||
if (enabled.value && (days < 1 || days > 3650)) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const data = await $fetch<{ autoDeleteDays: number }>('/api/user/settings', {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
body: { autoDeleteDays: days }
|
||||
})
|
||||
apply(data)
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
if (isAuthenticated.value) {
|
||||
toast.add({ title: '保存设置失败', color: 'error' })
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(enabled, () => {
|
||||
if (!hydrated.value) return
|
||||
void save()
|
||||
})
|
||||
|
||||
watch(daysDraft, () => {
|
||||
if (!hydrated.value || !enabled.value) return
|
||||
if (daysSaveTimer) clearTimeout(daysSaveTimer)
|
||||
daysSaveTimer = setTimeout(() => {
|
||||
void save()
|
||||
}, 500)
|
||||
})
|
||||
|
||||
return {
|
||||
userSettings,
|
||||
enabled,
|
||||
daysDraft,
|
||||
saving,
|
||||
load
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user