mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-29 01:07:22 +00:00
feat(config): update site configuration and enhance author handling
- Changed default site name and URL to reflect new branding. - Updated author information in the site configuration, including name, URL, and avatar. - Removed author-related settings from the configuration schema to streamline the setup. - Enhanced the SiteSettingService to resolve author details dynamically based on tenant context. - Added a new endpoint to retrieve the status of photo synchronization, improving user feedback on sync operations. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -34,7 +34,7 @@ export const MasonryHeaderMasonryItem = ({ style, className }: { style?: React.C
|
||||
)}
|
||||
<div
|
||||
className={clsxm(
|
||||
'from-accent to-accent/80 inline-flex items-center justify-center rounded-2xl bg-gradient-to-br shadow-lg',
|
||||
'from-accent to-accent/80 inline-flex items-center justify-center rounded-2xl bg-linear-to-br shadow-lg',
|
||||
siteConfig.author.avatar ? 'size-8 rounded absolute bottom-0 -right-3' : 'size-16 mb-4',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { createConfiguredApp } from './app.factory'
|
||||
import { runCliPipeline } from './cli'
|
||||
import { logger } from './helpers/logger.helper'
|
||||
|
||||
process.title = 'Hono HTTP Server'
|
||||
process.title = 'afilmory core'
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await createConfiguredApp({
|
||||
|
||||
@@ -8,26 +8,6 @@ import type { SettingDefinition, SettingMetadata } from './setting.type'
|
||||
|
||||
const HEX_COLOR_REGEX = /^#(?:[0-9a-f]{3}){1,2}$/i
|
||||
|
||||
function createOptionalUrlSchema(errorMessage: string) {
|
||||
return z
|
||||
.string()
|
||||
.trim()
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(value)
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: errorMessage,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createJsonStringArraySchema(options: {
|
||||
allowEmpty?: boolean
|
||||
validator?: (value: unknown) => boolean
|
||||
@@ -138,18 +118,6 @@ export const DEFAULT_SETTING_DEFINITIONS = {
|
||||
}
|
||||
}),
|
||||
},
|
||||
'site.author.name': {
|
||||
isSensitive: false,
|
||||
schema: z.string().trim().min(1, 'Author name cannot be empty'),
|
||||
},
|
||||
'site.author.url': {
|
||||
isSensitive: false,
|
||||
schema: z.url('Author URL must be a valid URL'),
|
||||
},
|
||||
'site.author.avatar': {
|
||||
isSensitive: false,
|
||||
schema: createOptionalUrlSchema('Author avatar must be a valid URL'),
|
||||
},
|
||||
'site.social.twitter': {
|
||||
isSensitive: false,
|
||||
schema: z.string().trim(),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Body, Controller, Get, Post } from '@afilmory/framework'
|
||||
import { Roles } from 'core/guards/roles.decorator'
|
||||
import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator'
|
||||
|
||||
import { UpdateSiteSettingsDto } from './site-setting.dto'
|
||||
import { UpdateSiteAuthorDto, UpdateSiteSettingsDto } from './site-setting.dto'
|
||||
import { SiteSettingService } from './site-setting.service'
|
||||
|
||||
@Controller('site/settings')
|
||||
@@ -21,4 +21,14 @@ export class SiteSettingController {
|
||||
await this.siteSettingService.setMany(entries)
|
||||
return { updated: entries }
|
||||
}
|
||||
|
||||
@Get('/author')
|
||||
async getAuthorProfile() {
|
||||
return await this.siteSettingService.getAuthorProfile()
|
||||
}
|
||||
|
||||
@Post('/author')
|
||||
async updateAuthorProfile(@Body() payload: UpdateSiteAuthorDto) {
|
||||
return await this.siteSettingService.updateAuthorProfile(payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,3 +21,12 @@ export class UpdateSiteSettingsDto extends createZodDto(
|
||||
entries: z.array(entrySchema).min(1),
|
||||
}),
|
||||
) {}
|
||||
|
||||
const updateAuthorSchema = z.object({
|
||||
name: z.string().trim().min(1, '作者名称不能为空'),
|
||||
displayUsername: z.string().optional().nullable(),
|
||||
username: z.string().optional().nullable(),
|
||||
avatar: z.string().optional().nullable(),
|
||||
})
|
||||
|
||||
export class UpdateSiteAuthorDto extends createZodDto(updateAuthorSchema) {}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@afilmory/framework'
|
||||
import { DatabaseModule } from 'core/database/database.module'
|
||||
|
||||
import { SettingModule } from '../setting/setting.module'
|
||||
import { SiteSettingController } from './site-setting.controller'
|
||||
@@ -6,7 +7,7 @@ import { SiteSettingPublicController } from './site-setting.public.controller'
|
||||
import { SiteSettingService } from './site-setting.service'
|
||||
|
||||
@Module({
|
||||
imports: [SettingModule],
|
||||
imports: [SettingModule, DatabaseModule],
|
||||
controllers: [SiteSettingController, SiteSettingPublicController],
|
||||
providers: [SiteSettingService],
|
||||
})
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { authUsers } from '@afilmory/db'
|
||||
import { DbAccessor } from 'core/database/database.provider'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
|
||||
import { asc, eq, sql } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import type { UiNode } from '../../ui/ui-schema/ui-schema.type'
|
||||
@@ -9,7 +14,10 @@ import { SITE_SETTING_UI_SCHEMA, SITE_SETTING_UI_SCHEMA_KEYS } from './site-sett
|
||||
|
||||
@injectable()
|
||||
export class SiteSettingService {
|
||||
constructor(private readonly settingService: SettingService) {}
|
||||
constructor(
|
||||
private readonly settingService: SettingService,
|
||||
private readonly dbAccessor: DbAccessor,
|
||||
) {}
|
||||
|
||||
async getUiSchema(): Promise<SiteSettingUiSchemaResponse> {
|
||||
const values = await this.settingService.getMany(SITE_SETTING_UI_SCHEMA_KEYS, {})
|
||||
@@ -66,11 +74,12 @@ export class SiteSettingService {
|
||||
assignString(values['site.url'], (value) => (config.url = value))
|
||||
assignString(values['site.accentColor'], (value) => (config.accentColor = value))
|
||||
|
||||
assignString(values['site.author.name'], (value) => (config.author.name = value))
|
||||
assignString(values['site.author.url'], (value) => (config.author.url = value))
|
||||
assignString(values['site.author.avatar'], (value) => {
|
||||
config.author.avatar = value
|
||||
})
|
||||
const resolvedAuthor = await this.resolveAuthorFromTenant(config.name)
|
||||
if (resolvedAuthor) {
|
||||
config.author = resolvedAuthor
|
||||
} else if (!config.author.name) {
|
||||
config.author.name = config.name
|
||||
}
|
||||
|
||||
const social = buildSocialConfig(values)
|
||||
if (social) {
|
||||
@@ -97,6 +106,131 @@ export class SiteSettingService {
|
||||
return config
|
||||
}
|
||||
|
||||
async getAuthorProfile(): Promise<SiteAuthorProfile> {
|
||||
const user = await this.findPrimaryAuthorUser()
|
||||
if (!user) {
|
||||
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
|
||||
message: '当前租户缺少可更新的作者账号,请先创建管理员账号。',
|
||||
})
|
||||
}
|
||||
return toSiteAuthorProfile(user)
|
||||
}
|
||||
|
||||
async updateAuthorProfile(input: UpdateSiteAuthorInput): Promise<SiteAuthorProfile> {
|
||||
const user = await this.findPrimaryAuthorUser()
|
||||
if (!user) {
|
||||
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
|
||||
message: '当前租户缺少可更新的作者账号,请先创建管理员账号。',
|
||||
})
|
||||
}
|
||||
|
||||
const name = normalizeString(input.name)
|
||||
if (!name) {
|
||||
throw new BizException(ErrorCode.COMMON_VALIDATION, { message: '作者名称不能为空' })
|
||||
}
|
||||
|
||||
const displayUsername = normalizeOptionalString(input.displayUsername)
|
||||
const username = normalizeOptionalString(input.username)
|
||||
const avatar = this.normalizeAvatarInput(input.avatar)
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const db = this.dbAccessor.get()
|
||||
|
||||
await db
|
||||
.update(authUsers)
|
||||
.set({
|
||||
name,
|
||||
displayUsername,
|
||||
username,
|
||||
image: avatar,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(authUsers.id, user.id))
|
||||
|
||||
return toSiteAuthorProfile({
|
||||
...user,
|
||||
name,
|
||||
displayUsername,
|
||||
username,
|
||||
image: avatar,
|
||||
updatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
private async resolveAuthorFromTenant(siteName: string): Promise<SiteConfigAuthor | null> {
|
||||
const user = await this.findPrimaryAuthorUser()
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fallbackName = normalizeString(siteName) ?? siteName
|
||||
const normalizedName =
|
||||
normalizeString(user.displayUsername) ??
|
||||
normalizeString(user.username) ??
|
||||
normalizeString(user.name) ??
|
||||
fallbackName
|
||||
|
||||
const author: SiteConfigAuthor = {
|
||||
name: normalizedName,
|
||||
}
|
||||
|
||||
const avatar = normalizeString(user.image)
|
||||
if (avatar) {
|
||||
author.avatar = avatar
|
||||
}
|
||||
|
||||
return author
|
||||
}
|
||||
|
||||
private async findPrimaryAuthorUser(): Promise<AuthorUserRecord | null> {
|
||||
const tenant = requireTenantContext()
|
||||
const db = this.dbAccessor.get()
|
||||
const [user] = await db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
name: authUsers.name,
|
||||
email: authUsers.email,
|
||||
displayUsername: authUsers.displayUsername,
|
||||
username: authUsers.username,
|
||||
image: authUsers.image,
|
||||
role: authUsers.role,
|
||||
createdAt: authUsers.createdAt,
|
||||
updatedAt: authUsers.updatedAt,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.tenantId, tenant.tenant.id))
|
||||
.orderBy(
|
||||
sql`case when ${authUsers.role} = 'admin' then 0 when ${authUsers.role} = 'superadmin' then 1 else 2 end`,
|
||||
asc(authUsers.createdAt),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return user ?? null
|
||||
}
|
||||
|
||||
private normalizeAvatarInput(value: string | null | undefined): string | null {
|
||||
const normalized = normalizeString(value)
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (normalized.startsWith('//')) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(normalized)
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
throw new Error('Invalid protocol')
|
||||
}
|
||||
return url.toString()
|
||||
} catch {
|
||||
throw new BizException(ErrorCode.COMMON_VALIDATION, {
|
||||
message: '头像链接必须是以 http(s) 或 // 开头的有效 URL',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private filterSchema(
|
||||
schema: SiteSettingUiSchemaResponse['schema'],
|
||||
allowed: Set<SiteSettingKey>,
|
||||
@@ -130,9 +264,39 @@ export class SiteSettingService {
|
||||
}
|
||||
}
|
||||
|
||||
interface SiteAuthorProfile {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
username: string | null
|
||||
displayUsername: string | null
|
||||
avatar: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface UpdateSiteAuthorInput {
|
||||
name: string
|
||||
displayUsername?: string | null
|
||||
username?: string | null
|
||||
avatar?: string | null
|
||||
}
|
||||
|
||||
type AuthorUserRecord = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
username: string | null
|
||||
displayUsername: string | null
|
||||
image: string | null
|
||||
role: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface SiteConfigAuthor {
|
||||
name: string
|
||||
url: string
|
||||
url?: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
@@ -177,7 +341,6 @@ const DEFAULT_SITE_CONFIG: SiteConfig = {
|
||||
accentColor: '#007bff',
|
||||
author: {
|
||||
name: '',
|
||||
url: '',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -199,6 +362,24 @@ function normalizeString(value: string | null | undefined): string | undefined {
|
||||
return trimmed.length > 0 ? trimmed : undefined
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: string | null | undefined): string | null {
|
||||
const normalized = normalizeString(value)
|
||||
return normalized ?? null
|
||||
}
|
||||
|
||||
function toSiteAuthorProfile(user: AuthorUserRecord): SiteAuthorProfile {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
username: user.username ?? null,
|
||||
displayUsername: user.displayUsername ?? null,
|
||||
avatar: user.image ?? null,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonStringArray(value: string | null | undefined): string[] | undefined {
|
||||
const normalized = normalizeString(value)
|
||||
if (!normalized) {
|
||||
|
||||
@@ -8,9 +8,6 @@ export const SITE_SETTING_KEYS = [
|
||||
'site.description',
|
||||
'site.url',
|
||||
'site.accentColor',
|
||||
'site.author.name',
|
||||
'site.author.url',
|
||||
'site.author.avatar',
|
||||
'site.social.twitter',
|
||||
'site.social.github',
|
||||
'site.social.rss',
|
||||
|
||||
@@ -89,60 +89,11 @@ export const SITE_SETTING_UI_SCHEMA: UiSchema<SiteSettingKey> = {
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
id: 'site-author-social',
|
||||
title: '作者与社交',
|
||||
description: '展示在站点关于信息与页脚的联系人和社交账号。',
|
||||
icon: 'user-round',
|
||||
id: 'site-social',
|
||||
title: '社交与订阅',
|
||||
description: '配置展示在站点页脚与关于区域的社交账号与订阅入口。',
|
||||
icon: 'share-2',
|
||||
children: [
|
||||
{
|
||||
type: 'group',
|
||||
id: 'site-author-group',
|
||||
title: '作者信息',
|
||||
icon: 'id-card',
|
||||
children: [
|
||||
{
|
||||
type: 'field',
|
||||
id: 'site-author-name',
|
||||
title: '作者名称',
|
||||
key: 'site.author.name',
|
||||
required: true,
|
||||
component: {
|
||||
type: 'text',
|
||||
placeholder: '请输入作者名称',
|
||||
autoComplete: 'name',
|
||||
},
|
||||
icon: 'user-circle',
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'site-author-url',
|
||||
title: '作者主页链接',
|
||||
key: 'site.author.url',
|
||||
required: true,
|
||||
component: {
|
||||
type: 'text',
|
||||
inputType: 'url',
|
||||
placeholder: 'https://innei.in/',
|
||||
autoComplete: 'url',
|
||||
},
|
||||
icon: 'link',
|
||||
},
|
||||
{
|
||||
type: 'field',
|
||||
id: 'site-author-avatar',
|
||||
title: '头像地址',
|
||||
description: '可选,展示在站点顶部与分享卡片中。',
|
||||
helperText: '留空则不显示头像。',
|
||||
key: 'site.author.avatar',
|
||||
component: {
|
||||
type: 'text',
|
||||
inputType: 'url',
|
||||
placeholder: 'https://cdn.example.com/avatar.png',
|
||||
},
|
||||
icon: 'image',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
id: 'site-social-group',
|
||||
@@ -194,7 +145,7 @@ export const SITE_SETTING_UI_SCHEMA: UiSchema<SiteSettingKey> = {
|
||||
type: 'section',
|
||||
id: 'site-feed',
|
||||
title: 'Feed 设置',
|
||||
description: '配置第三方 Feed 数据源,用于聚合内容或挑战进度。',
|
||||
description: '配置第三方 Feed 数据源,用于聚合内容',
|
||||
icon: 'radio',
|
||||
children: [
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
DataSyncConflict,
|
||||
DataSyncProgressEmitter,
|
||||
DataSyncProgressEvent,
|
||||
DataSyncStatus,
|
||||
} from './data-sync.types'
|
||||
|
||||
@Controller('data-sync')
|
||||
@@ -52,6 +53,11 @@ export class DataSyncController {
|
||||
})
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
async status(): Promise<DataSyncStatus> {
|
||||
return await this.dataSyncService.getStatus()
|
||||
}
|
||||
|
||||
@Get('conflicts')
|
||||
async listConflicts(): Promise<DataSyncConflict[]> {
|
||||
return await this.dataSyncService.listConflicts()
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { BuilderConfig, PhotoManifestItem, StorageConfig, StorageManager, StorageObject } from '@afilmory/builder'
|
||||
import type { PhotoAssetConflictPayload, PhotoAssetConflictSnapshot, PhotoAssetManifest } from '@afilmory/db'
|
||||
import { CURRENT_PHOTO_MANIFEST_VERSION, DATABASE_ONLY_PROVIDER, photoAssets } from '@afilmory/db'
|
||||
import { CURRENT_PHOTO_MANIFEST_VERSION, DATABASE_ONLY_PROVIDER, photoAssets, photoSyncRuns } from '@afilmory/db'
|
||||
import { createLogger, EventEmitterService } from '@afilmory/framework'
|
||||
import { DbAccessor } from 'core/database/database.provider'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { PhotoBuilderService } from 'core/modules/content/photo/builder/photo-builder.service'
|
||||
import { PhotoStorageService } from 'core/modules/content/photo/storage/photo-storage.service'
|
||||
import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import type {
|
||||
@@ -20,7 +20,9 @@ import type {
|
||||
DataSyncProgressStage,
|
||||
DataSyncResult,
|
||||
DataSyncResultSummary,
|
||||
DataSyncRunRecord,
|
||||
DataSyncStageTotals,
|
||||
DataSyncStatus,
|
||||
ResolveConflictOptions,
|
||||
SyncObjectSnapshot,
|
||||
} from './data-sync.types'
|
||||
@@ -45,6 +47,8 @@ type StatusReconciliationEntry = {
|
||||
storageSnapshot: SyncObjectSnapshot
|
||||
}
|
||||
|
||||
type PhotoSyncRunRow = typeof photoSyncRuns.$inferSelect
|
||||
|
||||
interface SyncPreparation {
|
||||
tenantId: string
|
||||
builder: ReturnType<PhotoBuilderService['createBuilder']>
|
||||
@@ -78,6 +82,7 @@ export class DataSyncService {
|
||||
|
||||
async runSync(options: DataSyncOptions, onProgress?: DataSyncProgressEmitter): Promise<DataSyncResult> {
|
||||
const tenant = requireTenantContext()
|
||||
const runStartedAt = new Date()
|
||||
const { builderConfig, storageConfig } = await this.resolveBuilderConfigForTenant(tenant.tenant.id, options)
|
||||
const context = await this.prepareSyncContext(tenant.tenant.id, builderConfig, storageConfig)
|
||||
const summary = this.createSummary(context)
|
||||
@@ -170,9 +175,33 @@ export class DataSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.recordSyncRun(tenant.tenant.id, {
|
||||
dryRun: options.dryRun,
|
||||
summary: { ...summary },
|
||||
actionsCount: actions.length,
|
||||
startedAt: runStartedAt,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async getStatus(): Promise<DataSyncStatus> {
|
||||
const tenant = requireTenantContext()
|
||||
const db = this.dbAccessor.get()
|
||||
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(photoSyncRuns)
|
||||
.where(eq(photoSyncRuns.tenantId, tenant.tenant.id))
|
||||
.orderBy(desc(photoSyncRuns.completedAt))
|
||||
.limit(1)
|
||||
|
||||
return {
|
||||
lastRun: record ? this.mapSyncRunRecord(record) : null,
|
||||
}
|
||||
}
|
||||
|
||||
async listConflicts(): Promise<DataSyncConflict[]> {
|
||||
const tenant = requireTenantContext()
|
||||
const db = this.dbAccessor.get()
|
||||
@@ -225,6 +254,40 @@ export class DataSyncService {
|
||||
return action
|
||||
}
|
||||
|
||||
private async recordSyncRun(
|
||||
tenantId: string,
|
||||
payload: {
|
||||
dryRun: boolean
|
||||
summary: DataSyncResultSummary
|
||||
actionsCount: number
|
||||
startedAt: Date
|
||||
completedAt: Date
|
||||
},
|
||||
): Promise<void> {
|
||||
const db = this.dbAccessor.get()
|
||||
const record: typeof photoSyncRuns.$inferInsert = {
|
||||
tenantId,
|
||||
dryRun: payload.dryRun,
|
||||
summary: payload.summary,
|
||||
actionsCount: payload.actionsCount,
|
||||
startedAt: payload.startedAt.toISOString(),
|
||||
completedAt: payload.completedAt.toISOString(),
|
||||
}
|
||||
|
||||
await db.insert(photoSyncRuns).values(record)
|
||||
}
|
||||
|
||||
private mapSyncRunRecord(record: PhotoSyncRunRow): DataSyncRunRecord {
|
||||
return {
|
||||
id: record.id,
|
||||
dryRun: record.dryRun,
|
||||
summary: record.summary,
|
||||
actionsCount: record.actionsCount,
|
||||
startedAt: record.startedAt,
|
||||
completedAt: record.completedAt,
|
||||
}
|
||||
}
|
||||
|
||||
private async prepareSyncContext(
|
||||
tenantId: string,
|
||||
builderConfig: BuilderConfig,
|
||||
|
||||
@@ -55,6 +55,19 @@ export interface DataSyncResult {
|
||||
actions: DataSyncAction[]
|
||||
}
|
||||
|
||||
export interface DataSyncRunRecord {
|
||||
id: string
|
||||
dryRun: boolean
|
||||
summary: DataSyncResultSummary
|
||||
actionsCount: number
|
||||
startedAt: string
|
||||
completedAt: string
|
||||
}
|
||||
|
||||
export interface DataSyncStatus {
|
||||
lastRun: DataSyncRunRecord | null
|
||||
}
|
||||
|
||||
export interface DataSyncOptions {
|
||||
builderConfig?: BuilderConfig
|
||||
storageConfig?: StorageConfig
|
||||
|
||||
@@ -17,20 +17,20 @@ export function Header() {
|
||||
|
||||
return (
|
||||
<header className="bg-background relative shrink-0 border-b border-fill-tertiary/50">
|
||||
<div className="flex h-14 items-center px-6">
|
||||
<div className="flex h-14 items-center px-3 sm:px-6">
|
||||
{/* Logo/Brand */}
|
||||
<a href="/" className="text-text mr-8 text-base font-semibold tracking-tight">
|
||||
<a href="/" className="text-text mr-2 sm:mr-8 text-sm sm:text-base font-semibold tracking-tight">
|
||||
Afilmory
|
||||
</a>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<nav className="flex flex-1 items-center gap-1">
|
||||
<nav className="flex flex-1 items-center gap-0.5 sm:gap-1 overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
{navigationTabs.map((tab) => (
|
||||
<NavLink key={tab.path} to={tab.path} end={tab.path === '/'}>
|
||||
{({ isActive }) => (
|
||||
<div
|
||||
className={clsxm(
|
||||
'relative rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200',
|
||||
'relative rounded-lg px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm font-medium transition-all duration-200 whitespace-nowrap',
|
||||
'hover:bg-fill/30',
|
||||
isActive ? 'bg-accent/10 text-accent' : 'text-text-secondary hover:text-text',
|
||||
)}
|
||||
@@ -44,7 +44,7 @@ export function Header() {
|
||||
|
||||
{/* Right side - User Menu */}
|
||||
{user && (
|
||||
<div className="border-fill-tertiary/50 ml-auto border-l pl-4">
|
||||
<div className="border-fill-tertiary/50 ml-2 sm:ml-auto border-l pl-2 sm:pl-4">
|
||||
<UserMenu user={user} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@afilmory/ui'
|
||||
import { clsxm } from '@afilmory/utils'
|
||||
import { ChevronDown, LogOut, Settings, User as UserIcon } from 'lucide-react'
|
||||
import { LogOut, Settings, User as UserIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
@@ -64,11 +64,6 @@ export function UserMenu({ user }: UserMenuProps) {
|
||||
<div className="text-text text-sm leading-tight font-medium">{user.name || user.email}</div>
|
||||
<div className="text-text-tertiary text-[10px] leading-tight capitalize">{user.role}</div>
|
||||
</div>
|
||||
|
||||
{/* Chevron Icon */}
|
||||
<ChevronDown
|
||||
className={clsxm('text-text-tertiary size-3.5 transition-transform duration-200', isOpen && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
|
||||
@@ -72,19 +72,19 @@ function MainPageLayoutBase({ title, description, actions, footer, children }: M
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className="mt-8 space-y-6"
|
||||
className="mt-4 sm:mt-8 space-y-4 sm:space-y-6"
|
||||
>
|
||||
{/* Header - Sharp edges with gradient borders */}
|
||||
<header className="relative flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<header className="relative flex flex-col gap-3 sm:gap-4 md:flex-row md:items-start md:justify-between">
|
||||
{/* Linear gradient borders */}
|
||||
|
||||
<div className="relative space-y-1.5">
|
||||
<h1 className="text-text text-2xl font-semibold">{title}</h1>
|
||||
{description ? <p className="text-text-secondary text-sm">{description}</p> : null}
|
||||
<div className="relative space-y-1 sm:space-y-1.5">
|
||||
<h1 className="text-text text-xl sm:text-2xl font-semibold">{title}</h1>
|
||||
{description ? <p className="text-text-secondary text-xs sm:text-sm">{description}</p> : null}
|
||||
</div>
|
||||
|
||||
{showHeaderActions ? (
|
||||
<div className="relative flex flex-wrap items-center gap-2 md:justify-end">
|
||||
<div className="flex flex-wrap items-center gap-2 md:justify-end absolute md:translate-y-0 -translate-y-1 right-0 md:relative">
|
||||
{actions}
|
||||
<div ref={assignActionsContainer} className="flex flex-wrap items-center gap-2" />
|
||||
</div>
|
||||
|
||||
@@ -107,8 +107,8 @@ function UploadTrendsChart({ data }: { data: UploadTrendPoint[] }) {
|
||||
const maxUploads = data.reduce((max, point) => Math.max(max, point.uploads), 0)
|
||||
|
||||
return (
|
||||
<div className="mt-6 overflow-x-auto pb-2">
|
||||
<div className="flex h-44 min-w-[480px] items-end gap-3">
|
||||
<div className="mt-4 sm:mt-6 overflow-x-auto pb-2">
|
||||
<div className="flex h-36 sm:h-44 min-w-[360px] sm:min-w-[480px] items-end gap-2 sm:gap-3">
|
||||
{data.map((point, index) => {
|
||||
const basePercent = maxUploads === 0 ? 0 : (point.uploads / maxUploads) * 100
|
||||
const heightPercent = Math.max(basePercent, point.uploads > 0 ? 8 : 0)
|
||||
@@ -121,10 +121,10 @@ function UploadTrendsChart({ data }: { data: UploadTrendPoint[] }) {
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ ...Spring.presets.snappy, delay: index * 0.04 }}
|
||||
className="group flex min-w-[32px] flex-1 flex-col items-center gap-1"
|
||||
className="group flex min-w-[24px] sm:min-w-[32px] flex-1 flex-col items-center gap-0.5 sm:gap-1"
|
||||
title={`${fullLabel} · ${plainNumberFormatter.format(point.uploads)} 张`}
|
||||
>
|
||||
<div className="relative flex h-40 w-full items-end">
|
||||
<div className="relative flex h-32 sm:h-40 w-full items-end">
|
||||
<div
|
||||
className="bg-accent/70 group-hover:bg-accent absolute right-0 bottom-0 shape-squircle left-0 mb-2 transition-colors duration-200"
|
||||
style={{ height: `${heightPercent}%` }}
|
||||
@@ -144,11 +144,11 @@ function UploadTrendsChart({ data }: { data: UploadTrendPoint[] }) {
|
||||
|
||||
function ProvidersList({ providers, totalBytes }: { providers: StorageProviderUsage[]; totalBytes: number }) {
|
||||
if (providers.length === 0) {
|
||||
return <div className="text-text-tertiary mt-5 text-sm">暂无存储使用数据。</div>
|
||||
return <div className="text-text-tertiary mt-4 sm:mt-5 text-xs sm:text-sm">暂无存储使用数据。</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="mt-4 sm:mt-5 space-y-2.5 sm:space-y-3">
|
||||
{providers.map((provider, index) => {
|
||||
const ratio = totalBytes > 0 ? provider.bytes / totalBytes : 0
|
||||
const percent = Math.round(ratio * 100)
|
||||
@@ -158,13 +158,13 @@ function ProvidersList({ providers, totalBytes }: { providers: StorageProviderUs
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ ...Spring.presets.smooth, delay: index * 0.03 }}
|
||||
className="space-y-1.5"
|
||||
className="space-y-1 sm:space-y-1.5"
|
||||
>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center justify-between text-xs sm:text-sm">
|
||||
<span className="text-text capitalize">{provider.provider}</span>
|
||||
<span className="text-text-secondary">
|
||||
<span className="text-text-secondary text-right">
|
||||
{formatBytes(provider.bytes)}
|
||||
<span className="text-text-tertiary ml-2">
|
||||
<span className="text-text-tertiary ml-1 sm:ml-2 text-[11px] sm:text-xs">
|
||||
{percent}% · {provider.photoCount} 张
|
||||
</span>
|
||||
</span>
|
||||
@@ -184,13 +184,13 @@ function ProvidersList({ providers, totalBytes }: { providers: StorageProviderUs
|
||||
|
||||
function RankedList({ items, emptyText }: { items: Array<{ label: string; value: number }>; emptyText: string }) {
|
||||
if (items.length === 0) {
|
||||
return <div className="text-text-tertiary mt-4 text-sm">{emptyText}</div>
|
||||
return <div className="text-text-tertiary mt-3 sm:mt-4 text-xs sm:text-sm">{emptyText}</div>
|
||||
}
|
||||
|
||||
const maxValue = items.reduce((max, item) => Math.max(max, item.value), 0)
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-2.5">
|
||||
<div className="mt-3 sm:mt-4 space-y-2 sm:space-y-2.5">
|
||||
{items.map((item, index) => {
|
||||
const ratio = maxValue > 0 ? item.value / maxValue : 0
|
||||
return (
|
||||
@@ -199,13 +199,15 @@ function RankedList({ items, emptyText }: { items: Array<{ label: string; value:
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ ...Spring.presets.smooth, delay: index * 0.03 }}
|
||||
className="flex items-center gap-3 text-sm"
|
||||
className="flex items-center gap-2 sm:gap-3 text-xs sm:text-sm"
|
||||
>
|
||||
<div className="text-text-tertiary w-6 text-right text-[11px]">#{index + 1}</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-text-tertiary w-5 sm:w-6 text-right text-[10px] sm:text-[11px]">#{index + 1}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-text truncate">{item.label}</span>
|
||||
<span className="text-text-secondary text-[13px]">{plainNumberFormatter.format(item.value)}</span>
|
||||
<span className="text-text-secondary text-[11px] sm:text-[13px] shrink-0">
|
||||
{plainNumberFormatter.format(item.value)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-fill/15 mt-1.5 h-2 rounded-full">
|
||||
<div
|
||||
@@ -233,10 +235,10 @@ function SectionPanel({
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<LinearBorderPanel className={clsxm('bg-background-tertiary p-5', className)}>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-text text-sm font-semibold">{title}</h2>
|
||||
<p className="text-text-tertiary text-[13px] leading-relaxed">{description}</p>
|
||||
<LinearBorderPanel className={clsxm('bg-background-tertiary p-4 sm:p-5', className)}>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<h2 className="text-text text-xs sm:text-sm font-semibold">{title}</h2>
|
||||
<p className="text-text-tertiary text-[12px] sm:text-[13px] leading-relaxed">{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</LinearBorderPanel>
|
||||
@@ -286,7 +288,7 @@ export function DashboardAnalytics() {
|
||||
|
||||
return (
|
||||
<MainPageLayout title="Analytics" description="Track your photo collection statistics and trends">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-3 sm:gap-4 grid-cols-1 md:grid-cols-2">
|
||||
<SectionPanel title="Upload Trends" description="近 12 个月的上传趋势">
|
||||
{isLoading ? (
|
||||
<TrendSkeleton />
|
||||
@@ -295,7 +297,7 @@ export function DashboardAnalytics() {
|
||||
) : data?.uploadTrends?.length ? (
|
||||
<>
|
||||
{uploadTrendStats ? (
|
||||
<div className="mt-5 grid gap-3 text-sm">
|
||||
<div className="mt-4 sm:mt-5 grid gap-2 sm:gap-3 text-xs sm:text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-secondary">累计上传</span>
|
||||
<span className="text-text font-semibold">
|
||||
@@ -304,9 +306,9 @@ export function DashboardAnalytics() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-secondary">表现最佳</span>
|
||||
<span className="text-text font-semibold">
|
||||
{formatFullMonth(uploadTrendStats.bestMonth.month)}
|
||||
<span className="text-text-tertiary ml-2 text-[13px]">
|
||||
<span className="text-text font-semibold text-right">
|
||||
<span className="block sm:inline">{formatFullMonth(uploadTrendStats.bestMonth.month)}</span>
|
||||
<span className="text-text-tertiary ml-0 sm:ml-2 text-[11px] sm:text-[13px]">
|
||||
{plainNumberFormatter.format(uploadTrendStats.bestMonth.uploads)} 张
|
||||
</span>
|
||||
</span>
|
||||
@@ -318,7 +320,7 @@ export function DashboardAnalytics() {
|
||||
{uploadTrendStats.growth !== null ? (
|
||||
<span
|
||||
className={clsxm(
|
||||
'ml-2 text-[13px]',
|
||||
'ml-1 sm:ml-2 text-[11px] sm:text-[13px]',
|
||||
uploadTrendStats.growth >= 0 ? 'text-emerald-300' : 'text-red-300',
|
||||
)}
|
||||
>
|
||||
@@ -327,7 +329,7 @@ export function DashboardAnalytics() {
|
||||
: `${uploadTrendStats.growth >= 0 ? '+' : ''}${percentFormatter.format(uploadTrendStats.growth)}`}
|
||||
</span>
|
||||
) : uploadTrendStats.previousMonth ? (
|
||||
<span className="text-text-tertiary ml-2 text-[13px]">
|
||||
<span className="text-text-tertiary ml-1 sm:ml-2 text-[11px] sm:text-[13px]">
|
||||
{uploadTrendStats.delta > 0 ? '首次出现上传记录' : '与上月持平'}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -339,7 +341,7 @@ export function DashboardAnalytics() {
|
||||
<UploadTrendsChart data={data.uploadTrends} />
|
||||
</>
|
||||
) : (
|
||||
<div className="text-text-tertiary mt-6 text-sm">暂无上传记录。</div>
|
||||
<div className="text-text-tertiary mt-4 sm:mt-6 text-xs sm:text-sm">暂无上传记录。</div>
|
||||
)}
|
||||
</SectionPanel>
|
||||
|
||||
@@ -364,10 +366,10 @@ export function DashboardAnalytics() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-5 grid gap-3 text-sm">
|
||||
<div className="mt-4 sm:mt-5 grid gap-2 sm:gap-3 text-xs sm:text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-secondary">总占用</span>
|
||||
<span className="text-text font-semibold">{formatBytes(storageUsage.totalBytes)}</span>
|
||||
<span className="text-text font-semibold text-right">{formatBytes(storageUsage.totalBytes)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-secondary">照片数量</span>
|
||||
@@ -377,9 +379,11 @@ export function DashboardAnalytics() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-secondary">本月新增</span>
|
||||
<span className="text-text font-semibold">
|
||||
{formatBytes(storageUsage.currentMonthBytes)}
|
||||
<span className="text-text-tertiary ml-2 text-[13px]">{monthDeltaDescription}</span>
|
||||
<span className="text-text font-semibold text-right">
|
||||
<span className="block sm:inline">{formatBytes(storageUsage.currentMonthBytes)}</span>
|
||||
<span className="text-text-tertiary ml-0 sm:ml-2 text-[11px] sm:text-[13px]">
|
||||
{monthDeltaDescription}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -88,12 +88,12 @@ export function SocialConnectionSettings() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<LinearBorderPanel className="p-6">
|
||||
<div className="space-y-6">
|
||||
<SkeletonBlock className="h-5 w-2/5" />
|
||||
<div className="space-y-4">
|
||||
<LinearBorderPanel className="p-4 sm:p-6">
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<SkeletonBlock className="h-4 sm:h-5 w-2/5" />
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{[1, 2, 3].map((key) => (
|
||||
<SkeletonBlock key={key} className="h-20" />
|
||||
<SkeletonBlock key={key} className="h-16 sm:h-20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,9 +103,9 @@ export function SocialConnectionSettings() {
|
||||
|
||||
if (hasError && errorMessage) {
|
||||
return (
|
||||
<LinearBorderPanel className="p-6">
|
||||
<div className="text-red flex items-center gap-3 text-sm">
|
||||
<i className="i-mingcute-close-circle-fill text-lg" />
|
||||
<LinearBorderPanel className="p-4 sm:p-6">
|
||||
<div className="text-red flex items-center gap-2 sm:gap-3 text-xs sm:text-sm">
|
||||
<i className="i-mingcute-close-circle-fill text-base sm:text-lg" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
</LinearBorderPanel>
|
||||
@@ -114,10 +114,10 @@ export function SocialConnectionSettings() {
|
||||
|
||||
if (providers.length === 0) {
|
||||
return (
|
||||
<LinearBorderPanel className="p-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-base font-semibold">未配置可用的 OAuth Provider</p>
|
||||
<p className="text-text-tertiary text-sm">
|
||||
<LinearBorderPanel className="p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-2 sm:gap-3">
|
||||
<p className="text-sm sm:text-base font-semibold">未配置可用的 OAuth Provider</p>
|
||||
<p className="text-text-tertiary text-xs sm:text-sm">
|
||||
超级管理员尚未在系统设置中启用任何第三方登录方式,当前租户无法执行 OAuth 绑定。
|
||||
</p>
|
||||
</div>
|
||||
@@ -126,17 +126,17 @@ export function SocialConnectionSettings() {
|
||||
}
|
||||
|
||||
return (
|
||||
<LinearBorderPanel className="p-6">
|
||||
<div className="space-y-6">
|
||||
<LinearBorderPanel className="p-4 sm:p-6">
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<p className="text-text-tertiary text-sm font-semibold tracking-wide uppercase">登录方式</p>
|
||||
<h2 className="mt-1 text-2xl font-semibold">OAuth 账号绑定</h2>
|
||||
<p className="text-text-tertiary mt-2 text-sm">
|
||||
<p className="text-text-tertiary text-xs sm:text-sm font-semibold tracking-wide uppercase">登录方式</p>
|
||||
<h2 className="mt-1 text-xl sm:text-2xl font-semibold">OAuth 账号绑定</h2>
|
||||
<p className="text-text-tertiary mt-1.5 sm:mt-2 text-xs sm:text-sm">
|
||||
绑定后即可使用对应平台的账号快速登录后台,并同步基础资料。解除绑定不会删除原有后台账号。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{providers.map((provider) => {
|
||||
const linkedAccount = accountsByProvider.get(provider.id)
|
||||
const isLinking = linkMutation.isPending && linkingProvider === provider.id
|
||||
@@ -146,24 +146,26 @@ export function SocialConnectionSettings() {
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex bg-background-secondary rounded-md flex-col gap-4 p-4 transition-colors md:flex-row md:items-center md:justify-between"
|
||||
className="flex bg-background-secondary rounded-md flex-col gap-3 sm:gap-4 p-3 sm:p-4 transition-colors md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-4">
|
||||
<div className="bg-fill-secondary/60 text-text flex size-12 items-center justify-center rounded-full">
|
||||
<i className={cx('text-2xl', provider.icon)} aria-hidden />
|
||||
<div className="flex flex-1 items-center gap-3 sm:gap-4">
|
||||
<div className="bg-fill-secondary/60 text-text flex size-10 sm:size-12 items-center justify-center rounded-full shrink-0">
|
||||
<i className={cx('text-xl sm:text-2xl', provider.icon)} aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-base leading-tight font-semibold">{provider.name}</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm sm:text-base leading-tight font-semibold">{provider.name}</p>
|
||||
{linkedAccount ? (
|
||||
<p className="text-text-tertiary mt-1 text-xs">
|
||||
<p className="text-text-tertiary mt-0.5 sm:mt-1 text-[11px] sm:text-xs">
|
||||
已绑定 · {formatTimestamp(linkedAccount.createdAt)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-text-tertiary mt-1 text-xs">尚未绑定,点击下方按钮完成授权。</p>
|
||||
<p className="text-text-tertiary mt-0.5 sm:mt-1 text-[11px] sm:text-xs">
|
||||
尚未绑定,点击下方按钮完成授权。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1 md:items-end">
|
||||
<div className="flex flex-col items-stretch sm:items-start gap-1.5 sm:gap-1 md:items-end w-full sm:w-auto">
|
||||
{linkedAccount ? (
|
||||
<>
|
||||
<Button
|
||||
@@ -174,11 +176,12 @@ export function SocialConnectionSettings() {
|
||||
isLoading={isUnlinking}
|
||||
loadingText="解绑中…"
|
||||
onClick={() => handleDisconnect(provider.id, provider.name, linkedAccount.accountId)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
解除绑定
|
||||
</Button>
|
||||
{isLastLinkedProvider ? (
|
||||
<p className="text-text-tertiary text-xs">需要至少保留一个已绑定的登录方式。</p>
|
||||
<p className="text-text-tertiary text-[11px] sm:text-xs">需要至少保留一个已绑定的登录方式。</p>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
@@ -190,6 +193,7 @@ export function SocialConnectionSettings() {
|
||||
isLoading={isLinking}
|
||||
loadingText="跳转中…"
|
||||
onClick={() => handleConnect(provider.id, provider.name)}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
绑定 {provider.name}
|
||||
</Button>
|
||||
|
||||
@@ -172,9 +172,9 @@ function ActivityList({ items }: { items: DashboardRecentActivityItem[] }) {
|
||||
transition={{ ...Spring.presets.snappy, delay: index * 0.04 }}
|
||||
className="group px-3.5 py-3 transition-colors duration-200"
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-fill/10 relative h-11 w-11 shrink-0 overflow-hidden rounded-lg">
|
||||
<div className="flex flex-col gap-2 sm:gap-2.5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-2 sm:gap-3">
|
||||
<div className="bg-fill/10 relative h-10 w-10 sm:h-11 sm:w-11 shrink-0 overflow-hidden rounded-lg">
|
||||
{item.previewUrl ? (
|
||||
<img src={item.previewUrl} alt={item.title} className="size-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
@@ -183,9 +183,9 @@ function ActivityList({ items }: { items: DashboardRecentActivityItem[] }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="text-text truncate text-sm font-semibold">{item.title}</div>
|
||||
<div className="text-text-tertiary text-xs leading-relaxed">
|
||||
<div className="min-w-0 flex-1 space-y-1 sm:space-y-1.5">
|
||||
<div className="text-text truncate text-xs sm:text-sm font-semibold">{item.title}</div>
|
||||
<div className="text-text-tertiary text-[11px] sm:text-xs leading-relaxed">
|
||||
<span>上传于 {formatRelativeTime(item.createdAt)}</span>
|
||||
{takenAtText ? (
|
||||
<>
|
||||
@@ -284,33 +284,36 @@ export function DashboardOverview() {
|
||||
|
||||
return (
|
||||
<MainPageLayout title="Dashboard" description="掌握图库运行状态与最近同步活动">
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="space-y-4 sm:space-y-5">
|
||||
<div className="grid gap-3 sm:gap-5 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{isLoading
|
||||
? Array.from({ length: 4 }, (_, i) => `skeleton-${i}`).map((key) => <StatSkeleton key={key} />)
|
||||
: statCards.map((card, index) => (
|
||||
<LinearBorderPanel key={card.key} className="bg-background-tertiary/60 relative overflow-hidden p-5">
|
||||
<LinearBorderPanel
|
||||
key={card.key}
|
||||
className="bg-background-tertiary/60 relative overflow-hidden p-4 sm:p-5"
|
||||
>
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ ...Spring.presets.smooth, delay: index * 0.05 }}
|
||||
className="space-y-2.5"
|
||||
className="space-y-2 sm:space-y-2.5"
|
||||
>
|
||||
<span className="text-text-secondary text-xs font-medium tracking-wide uppercase">
|
||||
<span className="text-text-secondary text-[10px] sm:text-xs font-medium tracking-wide uppercase">
|
||||
{card.label}
|
||||
</span>
|
||||
<div className="text-text text-2xl font-semibold">{card.value}</div>
|
||||
<div className="text-text-tertiary text-xs leading-relaxed">{card.helper}</div>
|
||||
<div className="text-text text-xl sm:text-2xl font-semibold">{card.value}</div>
|
||||
<div className="text-text-tertiary text-[11px] sm:text-xs leading-relaxed">{card.helper}</div>
|
||||
</m.div>
|
||||
</LinearBorderPanel>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||
<LinearBorderPanel className="bg-background-tertiary/60 relative overflow-hidden px-5 py-5">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-text text-base font-semibold">最近活动</h2>
|
||||
<p className="text-text-tertiary text-sm leading-relaxed">
|
||||
<div className="grid gap-4 sm:gap-5 grid-cols-1 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||
<LinearBorderPanel className="bg-background-tertiary/60 relative overflow-hidden px-4 sm:px-5 py-4 sm:py-5">
|
||||
<div className="space-y-1 sm:space-y-1.5">
|
||||
<h2 className="text-text text-sm sm:text-base font-semibold">最近活动</h2>
|
||||
<p className="text-text-tertiary text-xs sm:text-sm leading-relaxed">
|
||||
{data?.recentActivity?.length
|
||||
? `展示最近 ${data.recentActivity.length} 次上传和同步记录`
|
||||
: '还没有任何上传,快来添加第一张照片吧~'}
|
||||
@@ -330,10 +333,12 @@ export function DashboardOverview() {
|
||||
)}
|
||||
</LinearBorderPanel>
|
||||
|
||||
<LinearBorderPanel className="bg-background-tertiary/60 relative overflow-hidden px-5 py-5">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-text text-base font-semibold">同步概览</h2>
|
||||
<p className="text-text-tertiary text-sm leading-relaxed">了解当前同步状态,及时处理异常项。</p>
|
||||
<LinearBorderPanel className="bg-background-tertiary/60 relative overflow-hidden px-4 sm:px-5 py-4 sm:py-5">
|
||||
<div className="space-y-1 sm:space-y-1.5">
|
||||
<h2 className="text-text text-sm sm:text-base font-semibold">同步概览</h2>
|
||||
<p className="text-text-tertiary text-xs sm:text-sm leading-relaxed">
|
||||
了解当前同步状态,及时处理异常项。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-4">
|
||||
|
||||
@@ -95,34 +95,40 @@ export function DataManagementPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<LinearBorderPanel className="bg-background-secondary/40 p-6">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-4">
|
||||
<span className="shape-squircle inline-flex items-center gap-2 bg-accent/10 px-3 py-1 text-xs font-medium text-accent">
|
||||
<DynamicIcon name="database" className="h-4 w-4" />
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<LinearBorderPanel className="bg-background-secondary/40 p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<span className="shape-squircle inline-flex items-center gap-2 bg-accent/10 px-2.5 sm:px-3 py-1 text-[11px] sm:text-xs font-medium text-accent">
|
||||
<DynamicIcon name="database" className="h-3.5 sm:h-4 w-3.5 sm:w-4" />
|
||||
当前数据概况
|
||||
</span>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-text text-xl font-semibold">照片数据表状态</h3>
|
||||
<p className="text-text-secondary text-sm">以下统计来自数据库记录,不含对象存储中的原始文件。</p>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<h3 className="text-text text-lg sm:text-xl font-semibold">照片数据表状态</h3>
|
||||
<p className="text-text-secondary text-xs sm:text-sm">
|
||||
以下统计来自数据库记录,不含对象存储中的原始文件。
|
||||
</p>
|
||||
</div>
|
||||
{summaryQuery.isError ? <p className="text-red text-sm">无法加载数据统计,请稍后再试。</p> : null}
|
||||
{summaryQuery.isError ? (
|
||||
<p className="text-red text-xs sm:text-sm">无法加载数据统计,请稍后再试。</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid w-full gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid w-full gap-3 sm:gap-4 grid-cols-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{SUMMARY_STATS.map((stat) => (
|
||||
<LinearBorderPanel
|
||||
key={stat.id}
|
||||
className={clsxm(
|
||||
'bg-background-tertiary/60 px-4 py-3 shadow-sm backdrop-blur',
|
||||
'bg-background-tertiary/60 px-3 sm:px-4 py-2.5 sm:py-3 shadow-sm backdrop-blur',
|
||||
summaryQuery.isLoading && 'animate-pulse',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between text-[11px] text-text-tertiary">
|
||||
<div className="flex items-center justify-between text-[10px] sm:text-[11px] text-text-tertiary">
|
||||
<span>{stat.label}</span>
|
||||
<span className="shape-squircle bg-white/5 px-2 py-0.5 font-medium text-white/80">{stat.chip}</span>
|
||||
<span className="shape-squircle bg-white/5 px-1.5 sm:px-2 py-0.5 font-medium text-white/80 text-[9px] sm:text-[10px]">
|
||||
{stat.chip}
|
||||
</span>
|
||||
</div>
|
||||
<div className={clsxm('mt-2 text-2xl font-semibold', stat.accent)}>
|
||||
<div className={clsxm('mt-1.5 sm:mt-2 text-xl sm:text-2xl font-semibold', stat.accent)}>
|
||||
{summaryQuery.isLoading ? '—' : numberFormatter.format(summary[stat.id])}
|
||||
</div>
|
||||
</LinearBorderPanel>
|
||||
@@ -131,16 +137,16 @@ export function DataManagementPanel() {
|
||||
</div>
|
||||
</LinearBorderPanel>
|
||||
|
||||
<LinearBorderPanel className="bg-background-secondary/40 p-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-red">
|
||||
<DynamicIcon name="triangle-alert" className="h-4 w-4" />
|
||||
<span className="text-sm font-semibold">危险操作</span>
|
||||
<LinearBorderPanel className="bg-background-secondary/40 p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-3 sm:gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 text-red">
|
||||
<DynamicIcon name="triangle-alert" className="h-3.5 sm:h-4 w-3.5 sm:w-4" />
|
||||
<span className="text-xs sm:text-sm font-semibold">危险操作</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-text text-lg font-semibold">清空照片数据表</h4>
|
||||
<p className="text-text-secondary text-sm">
|
||||
<h4 className="text-text text-base sm:text-lg font-semibold">清空照片数据表</h4>
|
||||
<p className="text-text-secondary text-xs sm:text-sm">
|
||||
删除数据库中的所有照片记录,仅保留对象存储文件。通常用于处理数据不一致、重新同步或迁移场景。
|
||||
</p>
|
||||
</div>
|
||||
@@ -152,25 +158,26 @@ export function DataManagementPanel() {
|
||||
isLoading={truncateMutation.isPending}
|
||||
loadingText="清理中…"
|
||||
onClick={handleTruncate}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
清空数据库记录
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-text-tertiary mt-4 text-xs">
|
||||
<p className="text-text-tertiary mt-3 sm:mt-4 text-[11px] sm:text-xs">
|
||||
操作完成后请立即重新执行「照片同步」,以便使用存储中的原始文件重建数据库与 manifest。
|
||||
</p>
|
||||
</LinearBorderPanel>
|
||||
|
||||
<LinearBorderPanel className="bg-red-500/5 p-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-red">
|
||||
<DynamicIcon name="radiation" className="h-4 w-4" />
|
||||
<span className="text-sm font-semibold">账户清除(不可逆)</span>
|
||||
<LinearBorderPanel className="bg-red-500/5 p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-3 sm:gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 text-red">
|
||||
<DynamicIcon name="radiation" className="h-3.5 sm:h-4 w-3.5 sm:w-4" />
|
||||
<span className="text-xs sm:text-sm font-semibold">账户清除(不可逆)</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-text text-lg font-semibold">删除当前租户与所有数据</h4>
|
||||
<p className="text-text-secondary text-sm">
|
||||
<h4 className="text-text text-base sm:text-lg font-semibold">删除当前租户与所有数据</h4>
|
||||
<p className="text-text-secondary text-xs sm:text-sm">
|
||||
此操作会在数据库中彻底删除当前租户、照片记录、同步日志、权限成员等所有信息。执行后将登出所有成员并无法恢复,
|
||||
系统会强制进行三次确认以避免误操作。
|
||||
</p>
|
||||
@@ -183,11 +190,12 @@ export function DataManagementPanel() {
|
||||
onClick={handleDeleteAccount}
|
||||
loadingText="正在销毁…"
|
||||
isLoading={deleteTenantMutation.isPending}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
永久删除账户
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-text-tertiary mt-4 text-xs">
|
||||
<p className="text-text-tertiary mt-3 sm:mt-4 text-[11px] sm:text-xs">
|
||||
如需在未来重新使用本服务,需要重新注册新的租户并重新上传所有资产。该操作不会删除对象存储中的原始文件,但会移除与之关联的所有数据库记录。
|
||||
</p>
|
||||
</LinearBorderPanel>
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
PhotoSyncProgressEvent,
|
||||
PhotoSyncResolution,
|
||||
PhotoSyncResult,
|
||||
PhotoSyncStatus,
|
||||
RunPhotoSyncPayload,
|
||||
} from './types'
|
||||
|
||||
@@ -207,3 +208,8 @@ export async function getPhotoStorageUrl(storageKey: string): Promise<string> {
|
||||
|
||||
return data.url
|
||||
}
|
||||
|
||||
export async function getPhotoSyncStatus(): Promise<PhotoSyncStatus> {
|
||||
const status = await coreApi<PhotoSyncStatus>('/data-sync/status')
|
||||
return camelCaseKeys<PhotoSyncStatus>(status)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
usePhotoAssetListQuery,
|
||||
usePhotoAssetSummaryQuery,
|
||||
usePhotoSyncConflictsQuery,
|
||||
usePhotoSyncStatusQuery,
|
||||
useResolvePhotoSyncConflictMutation,
|
||||
useUploadPhotoAssetsMutation,
|
||||
} from '../hooks'
|
||||
@@ -86,6 +87,14 @@ export function PhotoPage() {
|
||||
}, [activeTab, selectedIds.length])
|
||||
|
||||
const summaryQuery = usePhotoAssetSummaryQuery()
|
||||
const {
|
||||
data: syncStatus,
|
||||
isLoading: isSyncStatusLoading,
|
||||
isFetching: isSyncStatusFetching,
|
||||
refetch: refetchSyncStatus,
|
||||
} = usePhotoSyncStatusQuery({
|
||||
enabled: activeTab === 'sync',
|
||||
})
|
||||
const listQuery = usePhotoAssetListQuery({ enabled: activeTab === 'library' })
|
||||
const deleteMutation = useDeletePhotoAssetsMutation()
|
||||
const uploadMutation = useUploadPhotoAssetsMutation()
|
||||
@@ -233,7 +242,7 @@ export function PhotoPage() {
|
||||
})
|
||||
}
|
||||
},
|
||||
[setResult, setLastWasDryRun],
|
||||
[setLastWasDryRun],
|
||||
)
|
||||
|
||||
const handleSyncError = useCallback((error: Error) => {
|
||||
@@ -291,8 +300,9 @@ export function PhotoPage() {
|
||||
|
||||
void summaryQuery.refetch()
|
||||
void listQuery.refetch()
|
||||
void refetchSyncStatus()
|
||||
},
|
||||
[listQuery, summaryQuery],
|
||||
[listQuery, summaryQuery, refetchSyncStatus],
|
||||
)
|
||||
|
||||
const handleDeleteSelected = useCallback(() => {
|
||||
@@ -468,6 +478,8 @@ export function PhotoPage() {
|
||||
lastWasDryRun={lastWasDryRun}
|
||||
baselineSummary={summaryQuery.data}
|
||||
isSummaryLoading={summaryQuery.isLoading}
|
||||
lastSyncRun={syncStatus?.lastRun ?? null}
|
||||
isSyncStatusLoading={isSyncStatusLoading || isSyncStatusFetching}
|
||||
onRequestStorageUrl={getPhotoStorageUrl}
|
||||
/>
|
||||
</div>
|
||||
@@ -511,7 +523,7 @@ export function PhotoPage() {
|
||||
onSyncError={handleSyncError}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<PageTabs
|
||||
activeId={activeTab}
|
||||
items={[
|
||||
|
||||
@@ -55,8 +55,8 @@ export function PhotoLibraryActionBar({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full relative flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex w-full relative flex-col gap-2 sm:gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -71,14 +71,15 @@ export function PhotoLibraryActionBar({
|
||||
size="sm"
|
||||
disabled={isUploading}
|
||||
onClick={handleUploadClick}
|
||||
className="flex items-center gap-1"
|
||||
className="flex items-center gap-1 text-xs sm:text-sm"
|
||||
>
|
||||
<DynamicIcon name="upload" className="h-3.5 w-3.5" />
|
||||
上传文件
|
||||
<span className="hidden sm:inline">上传文件</span>
|
||||
<span className="sm:hidden">上传</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-10 absolute right-0 translate-y-20 items-center justify-end gap-2">
|
||||
<div className="flex min-h-10 absolute right-0 translate-y-25 lg:translate-y-16 items-center justify-end gap-1.5 sm:gap-2 lg:flex-wrap w-full flex-nowrap">
|
||||
<div
|
||||
className={clsxm(
|
||||
'flex items-center gap-2 transition-opacity duration-200',
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Button, Modal, Prompt, Thumbhash } from '@afilmory/ui'
|
||||
import { clsxm } from '@afilmory/utils'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { DynamicIcon } from 'lucide-react/dynamic'
|
||||
|
||||
import { viewportAtom } from '~/atoms/viewport'
|
||||
import { stopPropagation } from '~/lib/dom'
|
||||
|
||||
import type { PhotoAssetListItem } from '../../types'
|
||||
@@ -188,18 +190,13 @@ export function PhotoLibraryGrid({
|
||||
onDeleteAsset,
|
||||
isDeleting,
|
||||
}: PhotoLibraryGridProps) {
|
||||
const viewport = useAtomValue(viewportAtom)
|
||||
const columnWidth = viewport.sm ? 320 : 160
|
||||
|
||||
if (isLoading) {
|
||||
const skeletonKeys = [
|
||||
'photo-skeleton-1',
|
||||
'photo-skeleton-2',
|
||||
'photo-skeleton-3',
|
||||
'photo-skeleton-4',
|
||||
'photo-skeleton-5',
|
||||
'photo-skeleton-6',
|
||||
]
|
||||
return (
|
||||
<div className="columns-1 gap-4 sm:columns-2 lg:columns-3">
|
||||
{skeletonKeys.map((key) => (
|
||||
{Array.from({ length: 6 }, (_, i) => `photo-skeleton-${i + 1}`).map((key) => (
|
||||
<div key={key} className="mb-4 break-inside-avoid">
|
||||
<div className="bg-fill/30 h-48 w-full animate-pulse rounded-xl" />
|
||||
</div>
|
||||
@@ -210,19 +207,19 @@ export function PhotoLibraryGrid({
|
||||
|
||||
if (!assets || assets.length === 0) {
|
||||
return (
|
||||
<div className="bg-background-tertiary relative overflow-hidden rounded-xl p-8 text-center">
|
||||
<p className="text-text text-base font-semibold">当前没有图片资源</p>
|
||||
<p className="text-text-tertiary mt-2 text-sm">使用右上角的“上传图片”按钮可以为图库添加新的照片。</p>
|
||||
<div className="bg-background-tertiary relative overflow-hidden rounded-xl p-4 sm:p-8 text-center">
|
||||
<p className="text-text text-sm sm:text-base font-semibold">当前没有图片资源</p>
|
||||
<p className="text-text-tertiary mt-2 text-xs sm:text-sm">使用右上角的"上传图片"按钮可以为图库添加新的照片。</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-[calc(calc((3rem+100vw)-(var(--container-7xl)))*-1/2)] p-2">
|
||||
<div className="lg:mx-[calc(calc((3rem+100vw)-(var(--container-7xl)))*-1/2)] -mx-2 lg:mt-0 mt-12 p-1">
|
||||
<Masonry
|
||||
items={assets}
|
||||
columnGutter={8}
|
||||
columnWidth={320}
|
||||
columnWidth={columnWidth}
|
||||
itemKey={(asset) => asset.id}
|
||||
render={({ data }) => (
|
||||
<PhotoGridItem
|
||||
|
||||
@@ -4,7 +4,13 @@ import { m } from 'motion/react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { getConflictTypeLabel, PHOTO_ACTION_TYPE_CONFIG } from '../../constants'
|
||||
import type { PhotoAssetSummary, PhotoSyncAction, PhotoSyncResult, PhotoSyncSnapshot } from '../../types'
|
||||
import type {
|
||||
PhotoAssetSummary,
|
||||
PhotoSyncAction,
|
||||
PhotoSyncResult,
|
||||
PhotoSyncRunRecord,
|
||||
PhotoSyncSnapshot,
|
||||
} from '../../types'
|
||||
|
||||
export function BorderOverlay() {
|
||||
return (
|
||||
@@ -42,11 +48,44 @@ function SummaryCard({ label, value, tone }: SummaryCardProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
function formatDateTimeLabel(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
return DATE_FORMATTER.format(date)
|
||||
}
|
||||
|
||||
function formatDurationLabel(start: string, end: string): string {
|
||||
const startedAt = new Date(start)
|
||||
const completedAt = new Date(end)
|
||||
const duration = completedAt.getTime() - startedAt.getTime()
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
return '不足 1 秒'
|
||||
}
|
||||
const totalSeconds = Math.max(Math.round(duration / 1000), 1)
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
const parts: string[] = []
|
||||
if (minutes > 0) {
|
||||
parts.push(`${minutes} 分`)
|
||||
}
|
||||
parts.push(`${seconds} 秒`)
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
type PhotoSyncResultPanelProps = {
|
||||
result: PhotoSyncResult | null
|
||||
lastWasDryRun: boolean | null
|
||||
baselineSummary?: PhotoAssetSummary | null
|
||||
isSummaryLoading?: boolean
|
||||
lastSyncRun?: PhotoSyncRunRecord | null
|
||||
isSyncStatusLoading?: boolean
|
||||
onRequestStorageUrl?: (storageKey: string) => Promise<string>
|
||||
}
|
||||
|
||||
@@ -59,8 +98,11 @@ export function PhotoSyncResultPanel({
|
||||
lastWasDryRun,
|
||||
baselineSummary,
|
||||
isSummaryLoading,
|
||||
lastSyncRun,
|
||||
isSyncStatusLoading,
|
||||
onRequestStorageUrl,
|
||||
}: PhotoSyncResultPanelProps) {
|
||||
const isAwaitingStatus = isSyncStatusLoading && !lastSyncRun
|
||||
const summaryItems = useMemo(() => {
|
||||
if (result) {
|
||||
return [
|
||||
@@ -91,6 +133,35 @@ export function PhotoSyncResultPanel({
|
||||
]
|
||||
}
|
||||
|
||||
if (lastSyncRun) {
|
||||
return [
|
||||
{ label: '存储对象', value: lastSyncRun.summary.storageObjects },
|
||||
{ label: '数据库记录', value: lastSyncRun.summary.databaseRecords },
|
||||
{
|
||||
label: '新增照片',
|
||||
value: lastSyncRun.summary.inserted,
|
||||
tone: lastSyncRun.summary.inserted > 0 ? ('accent' as const) : undefined,
|
||||
},
|
||||
{ label: '更新记录', value: lastSyncRun.summary.updated },
|
||||
{ label: '删除记录', value: lastSyncRun.summary.deleted },
|
||||
{
|
||||
label: '冲突条目',
|
||||
value: lastSyncRun.summary.conflicts,
|
||||
tone: lastSyncRun.summary.conflicts > 0 ? ('warning' as const) : ('muted' as const),
|
||||
},
|
||||
{
|
||||
label: '错误条目',
|
||||
value: lastSyncRun.summary.errors,
|
||||
tone: lastSyncRun.summary.errors > 0 ? ('warning' as const) : ('muted' as const),
|
||||
},
|
||||
{
|
||||
label: '跳过条目',
|
||||
value: lastSyncRun.summary.skipped,
|
||||
tone: 'muted' as const,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (baselineSummary) {
|
||||
return [
|
||||
{ label: '数据库记录', value: baselineSummary.total },
|
||||
@@ -109,7 +180,18 @@ export function PhotoSyncResultPanel({
|
||||
}
|
||||
|
||||
return []
|
||||
}, [result, baselineSummary])
|
||||
}, [result, lastSyncRun, baselineSummary])
|
||||
|
||||
const lastSyncRunMeta = useMemo(() => {
|
||||
if (!lastSyncRun) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
completedLabel: formatDateTimeLabel(lastSyncRun.completedAt),
|
||||
durationLabel: formatDurationLabel(lastSyncRun.startedAt, lastSyncRun.completedAt),
|
||||
}
|
||||
}, [lastSyncRun])
|
||||
|
||||
const [selectedActionType, setSelectedActionType] = useState<'all' | PhotoSyncAction['type']>('all')
|
||||
const [expandedActionKey, setExpandedActionKey] = useState<string | null>(null)
|
||||
@@ -266,17 +348,53 @@ export function PhotoSyncResultPanel({
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
if (lastSyncRun && lastSyncRunMeta) {
|
||||
return (
|
||||
<div className="relative overflow-hidden p-6 bg-background-secondary">
|
||||
<BorderOverlay />
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-text text-base font-semibold">最近一次同步完成</h2>
|
||||
<p className="text-text-tertiary text-sm">
|
||||
<span>完成于 {lastSyncRunMeta.completedLabel}</span>
|
||||
<span className="mx-1">·</span>
|
||||
<span>耗时 {lastSyncRunMeta.durationLabel}</span>
|
||||
<span className="mx-1">·</span>
|
||||
<span>{lastSyncRun.dryRun ? '预览模式 · 未写入数据库' : '实时模式 · 已写入数据库'}</span>
|
||||
</p>
|
||||
<p className="text-text-tertiary text-xs">
|
||||
<span>共 {lastSyncRun.actionsCount} 个操作</span>
|
||||
</p>
|
||||
</div>
|
||||
{summaryItems.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summaryItems.map((item) => (
|
||||
<SummaryCard key={item.label} label={item.label} value={item.value} tone={item.tone} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const showSkeleton = isSummaryLoading || isAwaitingStatus
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden p-6 bg-background-secondary">
|
||||
<BorderOverlay />
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-text text-base font-semibold">尚未执行同步</h2>
|
||||
<h2 className="text-text text-base font-semibold">
|
||||
{isAwaitingStatus ? '正在加载同步状态' : '尚未执行同步'}
|
||||
</h2>
|
||||
<p className="text-text-tertiary text-sm">
|
||||
请在系统设置中配置并激活存储提供商,然后使用右上角的按钮执行同步操作。预览模式不会写入数据,可用于安全检查。
|
||||
{isAwaitingStatus
|
||||
? '正在查询最近一次同步记录,请稍候…'
|
||||
: '请在系统设置中配置并激活存储提供商,然后使用右上角的按钮执行同步操作。预览模式不会写入数据,可用于安全检查。'}
|
||||
</p>
|
||||
</div>
|
||||
{isSummaryLoading ? (
|
||||
{showSkeleton ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{SUMMARY_SKELETON_KEYS.map((key) => (
|
||||
<div key={key} className="bg-fill/30 h-24 animate-pulse rounded-lg" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
deletePhotoAssets,
|
||||
getPhotoAssetSummary,
|
||||
getPhotoSyncStatus,
|
||||
listPhotoAssets,
|
||||
listPhotoSyncConflicts,
|
||||
resolvePhotoSyncConflict,
|
||||
@@ -13,6 +14,7 @@ import type { PhotoAssetListItem, PhotoSyncResolution } from './types'
|
||||
export const PHOTO_ASSET_SUMMARY_QUERY_KEY = ['photo-assets', 'summary'] as const
|
||||
export const PHOTO_ASSET_LIST_QUERY_KEY = ['photo-assets', 'list'] as const
|
||||
export const PHOTO_SYNC_CONFLICTS_QUERY_KEY = ['photo-sync', 'conflicts'] as const
|
||||
export const PHOTO_SYNC_STATUS_QUERY_KEY = ['photo-sync', 'status'] as const
|
||||
|
||||
export function usePhotoAssetSummaryQuery() {
|
||||
return useQuery({
|
||||
@@ -21,6 +23,14 @@ export function usePhotoAssetSummaryQuery() {
|
||||
})
|
||||
}
|
||||
|
||||
export function usePhotoSyncStatusQuery(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: PHOTO_SYNC_STATUS_QUERY_KEY,
|
||||
queryFn: getPhotoSyncStatus,
|
||||
enabled: options?.enabled ?? true,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePhotoAssetListQuery(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: PHOTO_ASSET_LIST_QUERY_KEY,
|
||||
@@ -47,7 +57,7 @@ export function useDeletePhotoAssetsMutation() {
|
||||
})
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
const {ids} = variables
|
||||
const { ids } = variables
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: PHOTO_ASSET_LIST_QUERY_KEY,
|
||||
})
|
||||
|
||||
@@ -44,6 +44,19 @@ export interface PhotoSyncResult {
|
||||
actions: PhotoSyncAction[]
|
||||
}
|
||||
|
||||
export interface PhotoSyncRunRecord {
|
||||
id: string
|
||||
dryRun: boolean
|
||||
summary: PhotoSyncResultSummary
|
||||
actionsCount: number
|
||||
startedAt: string
|
||||
completedAt: string
|
||||
}
|
||||
|
||||
export interface PhotoSyncStatus {
|
||||
lastRun: PhotoSyncRunRecord | null
|
||||
}
|
||||
|
||||
export type PhotoSyncConflictType = 'missing-in-storage' | 'metadata-mismatch' | 'photo-id-conflict'
|
||||
|
||||
export interface PhotoSyncConflictPayload {
|
||||
|
||||
@@ -220,10 +220,7 @@ function renderField<Key extends string>(
|
||||
const helper = helperText ? <FormHelperText>{helperText}</FormHelperText> : null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
className="border-fill-tertiary/50 bg-background rounded-lg border p-4 transition-all duration-200"
|
||||
>
|
||||
<div key={field.id}>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<Label className="text-text text-sm font-medium">{field.title}</Label>
|
||||
|
||||
@@ -7,6 +7,12 @@ const SETTINGS_TABS = [
|
||||
path: '/settings/site',
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
id: 'user',
|
||||
label: '用户信息',
|
||||
path: '/settings/user',
|
||||
end: true,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'account',
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { coreApi } from '~/lib/api-client'
|
||||
import { camelCaseKeys } from '~/lib/case'
|
||||
|
||||
import type { SiteSettingEntryInput, SiteSettingUiSchemaResponse } from './types'
|
||||
import type {
|
||||
SiteAuthorProfile,
|
||||
SiteSettingEntryInput,
|
||||
SiteSettingUiSchemaResponse,
|
||||
UpdateSiteAuthorPayload,
|
||||
} from './types'
|
||||
|
||||
const SITE_SETTINGS_ENDPOINT = '/site/settings'
|
||||
|
||||
@@ -14,3 +20,14 @@ export async function updateSiteSettings(entries: readonly SiteSettingEntryInput
|
||||
body: { entries },
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSiteAuthorProfile() {
|
||||
return camelCaseKeys<SiteAuthorProfile>(await coreApi<SiteAuthorProfile>(`${SITE_SETTINGS_ENDPOINT}/author`))
|
||||
}
|
||||
|
||||
export async function updateSiteAuthorProfile(payload: UpdateSiteAuthorPayload) {
|
||||
return await coreApi<SiteAuthorProfile>(`${SITE_SETTINGS_ENDPOINT}/author`, {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { Button, FormHelperText, Input, Label } from '@afilmory/ui'
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
import { startTransition, useEffect, useId, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { LinearBorderPanel } from '~/components/common/GlassPanel'
|
||||
import { MainPageLayout, useMainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
import { useBlock } from '~/hooks/useBlock'
|
||||
import { getRequestErrorMessage } from '~/lib/errors'
|
||||
|
||||
import { useSiteAuthorProfileQuery, useUpdateSiteAuthorProfileMutation } from '../hooks'
|
||||
import type { SiteAuthorProfile, UpdateSiteAuthorPayload } from '../types'
|
||||
|
||||
type UserFormState = {
|
||||
name: string
|
||||
displayUsername: string
|
||||
username: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
const emptyState: UserFormState = {
|
||||
name: '',
|
||||
displayUsername: '',
|
||||
username: '',
|
||||
avatar: '',
|
||||
}
|
||||
|
||||
function toFormState(profile: SiteAuthorProfile): UserFormState {
|
||||
return {
|
||||
name: profile.name ?? '',
|
||||
displayUsername: profile.displayUsername ?? '',
|
||||
username: profile.username ?? '',
|
||||
avatar: profile.avatar ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(state: UserFormState): UpdateSiteAuthorPayload {
|
||||
const normalize = (value: string) => {
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
return {
|
||||
name: state.name.trim(),
|
||||
displayUsername: normalize(state.displayUsername),
|
||||
username: normalize(state.username),
|
||||
avatar: normalize(state.avatar),
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(iso: string | undefined) {
|
||||
if (!iso) return ''
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
export function SiteUserProfileForm() {
|
||||
const { data, isLoading, isError, error } = useSiteAuthorProfileQuery()
|
||||
const updateMutation = useUpdateSiteAuthorProfileMutation()
|
||||
const { setHeaderActionState } = useMainPageLayout()
|
||||
const formId = useId()
|
||||
const [formState, setFormState] = useState<UserFormState>(emptyState)
|
||||
const [initialState, setInitialState] = useState<UserFormState>(emptyState)
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return
|
||||
}
|
||||
const next = toFormState(data)
|
||||
startTransition(() => {
|
||||
setFormState(next)
|
||||
setInitialState(next)
|
||||
})
|
||||
}, [data])
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
return (
|
||||
formState.name !== initialState.name ||
|
||||
formState.displayUsername !== initialState.displayUsername ||
|
||||
formState.username !== initialState.username ||
|
||||
formState.avatar !== initialState.avatar
|
||||
)
|
||||
}, [formState, initialState])
|
||||
|
||||
const canSubmit = Boolean(data) && !isLoading && isDirty
|
||||
|
||||
useEffect(() => {
|
||||
setHeaderActionState({
|
||||
disabled: !canSubmit,
|
||||
loading: updateMutation.isPending,
|
||||
})
|
||||
|
||||
return () => {
|
||||
setHeaderActionState({ disabled: false, loading: false })
|
||||
}
|
||||
}, [canSubmit, setHeaderActionState, updateMutation.isPending])
|
||||
|
||||
const handleChange = (field: keyof UserFormState) => (value: string) => {
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (event) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (!data || !isDirty || updateMutation.isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = buildPayload(formState)
|
||||
await updateMutation.mutateAsync(payload)
|
||||
setInitialState(formState)
|
||||
toast.success('用户信息已更新')
|
||||
} catch (mutationError) {
|
||||
toast.error('保存用户信息失败', {
|
||||
description: getRequestErrorMessage(mutationError, '请检查输入内容后重试。'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const headerActionPortal = (
|
||||
<MainPageLayout.Actions>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!canSubmit}
|
||||
isLoading={updateMutation.isPending}
|
||||
loadingText="保存中…"
|
||||
>
|
||||
保存修改
|
||||
</Button>
|
||||
</MainPageLayout.Actions>
|
||||
)
|
||||
|
||||
useBlock({
|
||||
when: isDirty,
|
||||
title: '离开前请保存设置',
|
||||
description: '当前用户信息尚未保存,离开页面会丢失这些更改,确定要继续吗?',
|
||||
confirmText: '继续离开',
|
||||
cancelText: '留在此页',
|
||||
})
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<>
|
||||
{headerActionPortal}
|
||||
<LinearBorderPanel className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="bg-fill/40 h-6 w-2/5 animate-pulse rounded-lg" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={`skeleton-field-${index}`} className="space-y-3">
|
||||
<div className="bg-fill/30 h-4 w-1/3 animate-pulse rounded" />
|
||||
<div className="bg-fill/20 h-10 animate-pulse rounded-md" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</LinearBorderPanel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError && !data) {
|
||||
return (
|
||||
<>
|
||||
{headerActionPortal}
|
||||
<LinearBorderPanel className="p-6">
|
||||
<div className="text-red flex items-center gap-3 text-sm">
|
||||
<i className="i-mingcute-close-circle-fill text-lg" />
|
||||
<span>{getRequestErrorMessage(error, '无法加载用户信息')}</span>
|
||||
</div>
|
||||
</LinearBorderPanel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const profile = data
|
||||
const avatarPreview = formState.avatar?.trim() ? formState.avatar.trim() : null
|
||||
const previewInitial =
|
||||
(formState.displayUsername || formState.name || profile?.email || 'A').charAt(0)?.toUpperCase() ?? 'A'
|
||||
|
||||
return (
|
||||
<>
|
||||
{headerActionPortal}
|
||||
<LinearBorderPanel className="bg-background-secondary">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-6">
|
||||
<div>
|
||||
<p className="text-text-tertiary text-xs font-semibold uppercase tracking-wider">用户资料</p>
|
||||
<h2 className="text-text mt-1 text-xl font-semibold">展示在前台的作者身份</h2>
|
||||
<p className="text-text-tertiary mt-1 text-sm">
|
||||
这些信息将用于站点头部、RSS Feed 与社交分享卡片,推荐保持与作者个人品牌一致。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative size-16 sm:size-20 overflow-hidden rounded-full border border-white/5 shadow-inner">
|
||||
{avatarPreview ? (
|
||||
<img src={avatarPreview} alt="用户头像预览" className="h-full w-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<div className="bg-accent/15 text-accent flex h-full w-full items-center justify-center text-2xl font-semibold">
|
||||
{previewInitial}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="text-text font-semibold">{formState.displayUsername || formState.name || '作者'}</p>
|
||||
<p className="text-text-tertiary text-xs">{profile?.email}</p>
|
||||
<p className="text-text-tertiary text-xs">
|
||||
最近更新:{formatTimestamp(profile?.updatedAt) || '尚未更新'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<m.form
|
||||
id={formId}
|
||||
onSubmit={handleSubmit}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className="space-y-6 p-6"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-name">用户名称</Label>
|
||||
<Input
|
||||
id="user-name"
|
||||
value={formState.name}
|
||||
onInput={(event) => handleChange('name')(event.currentTarget.value)}
|
||||
placeholder="例如:Innei"
|
||||
required
|
||||
/>
|
||||
<FormHelperText>用于前台显示与 RSS 作者/编辑字段。</FormHelperText>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-display">展示昵称</Label>
|
||||
<Input
|
||||
id="user-display"
|
||||
value={formState.displayUsername}
|
||||
onInput={(event) => handleChange('displayUsername')(event.currentTarget.value)}
|
||||
placeholder="可选,例如:innei.photo"
|
||||
/>
|
||||
<FormHelperText>留空则使用作者名称,可用于展示更个性化的昵称。</FormHelperText>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-username">用户名(可选)</Label>
|
||||
<Input
|
||||
id="user-username"
|
||||
value={formState.username}
|
||||
onInput={(event) => handleChange('username')(event.currentTarget.value)}
|
||||
placeholder="例如:innei"
|
||||
/>
|
||||
<FormHelperText>用于后台识别作者账号,不会直接展示在前台。</FormHelperText>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-avatar">头像链接</Label>
|
||||
<Input
|
||||
id="user-avatar"
|
||||
type="url"
|
||||
value={formState.avatar}
|
||||
onInput={(event) => handleChange('avatar')(event.currentTarget.value)}
|
||||
placeholder="https://cdn.example.com/avatar.png"
|
||||
/>
|
||||
<FormHelperText>支持 http(s) 或以 // 开头的链接,留空则使用首字母。</FormHelperText>
|
||||
</div>
|
||||
</div>
|
||||
</m.form>
|
||||
</LinearBorderPanel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { getSiteSettingUiSchema, updateSiteSettings } from './api'
|
||||
import type { SiteSettingEntryInput } from './types'
|
||||
import { getSiteAuthorProfile, getSiteSettingUiSchema, updateSiteAuthorProfile, updateSiteSettings } from './api'
|
||||
import type { SiteSettingEntryInput, UpdateSiteAuthorPayload } from './types'
|
||||
|
||||
export const SITE_SETTING_UI_SCHEMA_QUERY_KEY = ['site-settings', 'ui-schema'] as const
|
||||
export const SITE_AUTHOR_PROFILE_QUERY_KEY = ['site-settings', 'author-profile'] as const
|
||||
|
||||
export function useSiteSettingUiSchemaQuery() {
|
||||
return useQuery({
|
||||
@@ -26,3 +27,23 @@ export function useUpdateSiteSettingsMutation() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSiteAuthorProfileQuery() {
|
||||
return useQuery({
|
||||
queryKey: SITE_AUTHOR_PROFILE_QUERY_KEY,
|
||||
queryFn: getSiteAuthorProfile,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateSiteAuthorProfileMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: UpdateSiteAuthorPayload) => {
|
||||
return await updateSiteAuthorProfile(payload)
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: SITE_AUTHOR_PROFILE_QUERY_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './api'
|
||||
export * from './components/SiteSettingsForm'
|
||||
export * from './components/SiteUserProfileForm'
|
||||
export * from './hooks'
|
||||
export * from './types'
|
||||
|
||||
@@ -11,3 +11,21 @@ export type SiteSettingEntryInput<Key extends string = string> = {
|
||||
readonly key: Key
|
||||
readonly value: string
|
||||
}
|
||||
|
||||
export interface SiteAuthorProfile {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
username: string | null
|
||||
displayUsername: string | null
|
||||
avatar: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type UpdateSiteAuthorPayload = {
|
||||
name: string
|
||||
displayUsername?: string | null
|
||||
username?: string | null
|
||||
avatar?: string | null
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Button, Modal } from '@afilmory/ui'
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { DynamicIcon } from 'lucide-react/dynamic'
|
||||
import { m } from 'motion/react'
|
||||
import { startTransition, useEffect, useState } from 'react'
|
||||
|
||||
import { LinearBorderPanel } from '~/components/common/GlassPanel'
|
||||
import { MainPageLayout, useMainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
import { useBlock } from '~/hooks/useBlock'
|
||||
|
||||
@@ -178,6 +180,7 @@ export function StorageProvidersManager() {
|
||||
return (
|
||||
<>
|
||||
{headerActionPortal}
|
||||
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -246,6 +249,37 @@ export function StorageProvidersManager() {
|
||||
</p>
|
||||
</m.div>
|
||||
)}
|
||||
|
||||
{/* Security Notice */}
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className="mb-6"
|
||||
>
|
||||
<LinearBorderPanel className="bg-background-secondary/40 p-4 sm:p-6">
|
||||
<div className="flex items-start gap-3 sm:gap-4">
|
||||
<div className="shrink-0">
|
||||
<div className="bg-accent/10 inline-flex h-8 w-8 items-center justify-center rounded-lg sm:h-10 sm:w-10">
|
||||
<DynamicIcon name="shield-check" className="h-4 w-4 text-accent sm:h-5 sm:w-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1.5 sm:space-y-2">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<span className="text-text text-sm font-semibold sm:text-base">存储安全性</span>
|
||||
</div>
|
||||
<p className="text-text-secondary text-xs sm:text-sm leading-relaxed">
|
||||
所有存储提供商的敏感配置信息(如访问密钥、令牌等)均使用{' '}
|
||||
<span className="font-mono font-semibold text-accent">AES-256-GCM</span>{' '}
|
||||
加密算法进行加密存储,确保数据安全。
|
||||
</p>
|
||||
<p className="text-text-tertiary text-[11px] sm:text-xs">
|
||||
AES-256-GCM 是一种经过验证的加密标准,提供认证加密功能,可同时保护数据的机密性和完整性。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</LinearBorderPanel>
|
||||
</m.div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function Component() {
|
||||
{/* Main Content Area */}
|
||||
<main className="bg-background flex-1 overflow-hidden">
|
||||
<ScrollArea rootClassName="h-full" viewportClassName="h-full">
|
||||
<div className="mx-auto max-w-7xl px-6 py-6">
|
||||
<div className="mx-auto max-w-7xl px-3 sm:px-6 py-4 sm:py-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
14
be/apps/dashboard/src/pages/(main)/settings/user.tsx
Normal file
14
be/apps/dashboard/src/pages/(main)/settings/user.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
import { SettingsNavigation } from '~/modules/settings'
|
||||
import { SiteUserProfileForm } from '~/modules/site-settings'
|
||||
|
||||
export function Component() {
|
||||
return (
|
||||
<MainPageLayout title="用户信息" description="维护展示在前台的作者资料、头像与别名。">
|
||||
<div className="space-y-6">
|
||||
<SettingsNavigation active="user" />
|
||||
<SiteUserProfileForm />
|
||||
</div>
|
||||
</MainPageLayout>
|
||||
)
|
||||
}
|
||||
14
be/packages/db/migrations/0001_open_sentinel.sql
Normal file
14
be/packages/db/migrations/0001_open_sentinel.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE "photo_sync_run" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"tenant_id" text NOT NULL,
|
||||
"dry_run" boolean DEFAULT false NOT NULL,
|
||||
"summary" jsonb NOT NULL,
|
||||
"actions_count" integer DEFAULT 0 NOT NULL,
|
||||
"started_at" timestamp DEFAULT now() NOT NULL,
|
||||
"completed_at" timestamp DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "photo_sync_run" ADD CONSTRAINT "photo_sync_run_tenant_id_tenant_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_photo_sync_run_tenant" ON "photo_sync_run" USING btree ("tenant_id");
|
||||
1262
be/packages/db/migrations/meta/0001_snapshot.json
Normal file
1262
be/packages/db/migrations/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
||||
"when": 1762852227998,
|
||||
"tag": "0000_broken_maria_hill",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1763138147750,
|
||||
"tag": "0001_open_sentinel",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PhotoManifestItem } from '@afilmory/builder'
|
||||
import { bigint, boolean, index, jsonb, pgEnum, pgTable, text, timestamp, unique } from 'drizzle-orm/pg-core'
|
||||
import { bigint, boolean, index, integer, jsonb, pgEnum, pgTable, text, timestamp, unique } from 'drizzle-orm/pg-core'
|
||||
|
||||
import { generateId } from './snowflake'
|
||||
|
||||
@@ -43,6 +43,17 @@ export interface PhotoAssetManifest {
|
||||
data: PhotoManifestItem
|
||||
}
|
||||
|
||||
export interface PhotoSyncRunSummary {
|
||||
storageObjects: number
|
||||
databaseRecords: number
|
||||
inserted: number
|
||||
updated: number
|
||||
deleted: number
|
||||
conflicts: number
|
||||
skipped: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
export const tenants = pgTable(
|
||||
'tenant',
|
||||
{
|
||||
@@ -254,6 +265,24 @@ export const photoAssets = pgTable(
|
||||
],
|
||||
)
|
||||
|
||||
export const photoSyncRuns = pgTable(
|
||||
'photo_sync_run',
|
||||
{
|
||||
id: snowflakeId,
|
||||
tenantId: text('tenant_id')
|
||||
.notNull()
|
||||
.references(() => tenants.id, { onDelete: 'cascade' }),
|
||||
dryRun: boolean('dry_run').notNull().default(false),
|
||||
summary: jsonb('summary').$type<PhotoSyncRunSummary>().notNull(),
|
||||
actionsCount: integer('actions_count').notNull().default(0),
|
||||
startedAt: timestamp('started_at', { mode: 'string' }).defaultNow().notNull(),
|
||||
completedAt: timestamp('completed_at', { mode: 'string' }).defaultNow().notNull(),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => [index('idx_photo_sync_run_tenant').on(t.tenantId)],
|
||||
)
|
||||
|
||||
export const dbSchema = {
|
||||
tenants,
|
||||
authUsers,
|
||||
@@ -266,6 +295,7 @@ export const dbSchema = {
|
||||
systemSettings,
|
||||
reactions,
|
||||
photoAssets,
|
||||
photoSyncRuns,
|
||||
}
|
||||
|
||||
export type DBSchema = typeof dbSchema
|
||||
|
||||
@@ -22,9 +22,11 @@ const closeExiftool = () => {
|
||||
exiftool.end().catch(noop)
|
||||
}
|
||||
|
||||
process.once('beforeExit', closeExiftool)
|
||||
process.once('SIGINT', closeExiftool)
|
||||
process.once('SIGTERM', closeExiftool)
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
process.once('beforeExit', closeExiftool)
|
||||
process.once('SIGINT', closeExiftool)
|
||||
process.once('SIGTERM', closeExiftool)
|
||||
}
|
||||
|
||||
// 提取 EXIF 数据
|
||||
export async function extractExifData(imageBuffer: Buffer, originalBuffer?: Buffer): Promise<PickedExif | null> {
|
||||
|
||||
@@ -43,16 +43,15 @@ interface Social {
|
||||
}
|
||||
|
||||
const defaultConfig: SiteConfig = {
|
||||
name: "Innei's Afilmory",
|
||||
title: "Innei's Afilmory",
|
||||
description:
|
||||
'Capturing beautiful moments in life, documenting daily warmth and emotions through my lens.',
|
||||
url: 'https://afilmory.innei.in',
|
||||
name: 'New Afilmory',
|
||||
title: 'New Afilmory',
|
||||
description: 'A modern photo gallery website.',
|
||||
url: 'https://afilmory.com',
|
||||
accentColor: '#007bff',
|
||||
author: {
|
||||
name: 'Innei',
|
||||
url: 'https://innei.in/',
|
||||
avatar: 'https://cdn.jsdelivr.net/gh/Innei/static@master/avatar.png',
|
||||
name: 'Afilmory',
|
||||
url: 'https://afilmory.art/',
|
||||
avatar: 'https://cdn.jsdelivr.net/gh/Afilmory/Afilmory@main/logo.jpg',
|
||||
},
|
||||
}
|
||||
export const siteConfig: SiteConfig = merge(defaultConfig, userConfig) as any
|
||||
|
||||
Reference in New Issue
Block a user