Initial miaojingAI project with image resolution guard
This commit is contained in:
5
src/app/about/page.tsx
Normal file
5
src/app/about/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SitePolicyPage } from '@/components/site-policy-page';
|
||||
|
||||
export default function AboutPage() {
|
||||
return <SitePolicyPage kind="about" />;
|
||||
}
|
||||
5
src/app/admin/page.tsx
Normal file
5
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function AdminRedirectPage() {
|
||||
redirect('/console');
|
||||
}
|
||||
84
src/app/api/admin/clear-users/route.ts
Normal file
84
src/app/api/admin/clear-users/route.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
|
||||
const DEFAULT_ADMIN_EMAIL = 'admin@example.com';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
if (process.env.ENABLE_DANGER_ADMIN_CLEAR_USERS !== 'true') {
|
||||
return NextResponse.json(
|
||||
{ error: '生产环境已默认禁用清空用户数据功能。如确需执行,请临时设置 ENABLE_DANGER_ADMIN_CLEAR_USERS=true 并完成备份后再操作。' },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { password } = body;
|
||||
|
||||
const adminPassword = process.env.ADMIN_DEFAULT_PASSWORD || 'admin123';
|
||||
|
||||
if (password !== adminPassword) {
|
||||
return NextResponse.json({ error: '管理员密码错误' }, { status: 401 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const adminResult = await client.query(
|
||||
`SELECT id, email, nickname FROM profiles
|
||||
WHERE role = 'admin' AND is_active = true
|
||||
ORDER BY CASE WHEN email = $1 THEN 0 ELSE 1 END, created_at ASC
|
||||
LIMIT 1`,
|
||||
[DEFAULT_ADMIN_EMAIL],
|
||||
);
|
||||
|
||||
if (adminResult.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: '未找到可保留的系统管理员账号,已拒绝清理' }, { status: 409 });
|
||||
}
|
||||
|
||||
const admin = adminResult.rows[0];
|
||||
|
||||
await client.query('DELETE FROM credit_transactions WHERE user_id <> $1', [admin.id]);
|
||||
await client.query('DELETE FROM work_likes WHERE user_id <> $1', [admin.id]);
|
||||
await client.query('DELETE FROM works WHERE user_id <> $1', [admin.id]);
|
||||
await client.query('DELETE FROM user_api_keys WHERE user_id <> $1', [admin.id]);
|
||||
await client.query('DELETE FROM orders WHERE user_id IS NOT NULL AND user_id <> $1', [admin.id]);
|
||||
await client.query('DELETE FROM profiles WHERE id <> $1', [admin.id]);
|
||||
await client.query('DELETE FROM auth.users WHERE id <> $1', [admin.id]);
|
||||
|
||||
await client.query(
|
||||
`UPDATE profiles
|
||||
SET email = $2,
|
||||
nickname = COALESCE(NULLIF(nickname, ''), $3),
|
||||
role = 'admin',
|
||||
membership_tier = 'enterprise',
|
||||
credits_balance = GREATEST(COALESCE(credits_balance, 0), 9999),
|
||||
daily_quota_limit = GREATEST(COALESCE(daily_quota_limit, 0), 999),
|
||||
daily_quota_used = 0,
|
||||
is_active = true,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[admin.id, admin.email || DEFAULT_ADMIN_EMAIL, admin.nickname || '管理员'],
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return NextResponse.json({ success: true, message: '所有非系统管理员用户数据已清除,系统管理员已保留' });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : '清除用户数据失败';
|
||||
console.error('[Clear Users Error]', message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
285
src/app/api/admin/dashboard/route.ts
Normal file
285
src/app/api/admin/dashboard/route.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import type { PoolClient, QueryResult } from 'pg';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
type DbRow = Record<string, unknown>;
|
||||
|
||||
async function safeQuery(client: PoolClient, label: string, sql: string, params: unknown[] = []): Promise<QueryResult<DbRow>> {
|
||||
try {
|
||||
return await client.query(sql, params);
|
||||
} catch (error) {
|
||||
console.error(`[admin/dashboard] ${label} failed:`, error);
|
||||
return { rows: [], rowCount: 0, command: 'SELECT', oid: 0, fields: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number {
|
||||
const parsed = Number(value ?? 0);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function firstRow(result: QueryResult<DbRow>): DbRow {
|
||||
return result.rows[0] || {};
|
||||
}
|
||||
|
||||
function statusCount(rows: DbRow[], status: string): number {
|
||||
const row = rows.find(item => item.status === status);
|
||||
return numberValue(row?.count);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const [
|
||||
platformResult,
|
||||
userResult,
|
||||
workResult,
|
||||
taskStatusResult,
|
||||
latestTaskResult,
|
||||
orderStatusResult,
|
||||
orderRevenueResult,
|
||||
latestOrderResult,
|
||||
storageResult,
|
||||
logResult,
|
||||
providerResult,
|
||||
recommendationResult,
|
||||
userApiKeyResult,
|
||||
announcementResult,
|
||||
] = await Promise.all([
|
||||
safeQuery(client, 'platform summary', `
|
||||
SELECT
|
||||
COALESCE((SELECT total_visits FROM site_stats WHERE id = 1 LIMIT 1), 0)::bigint AS total_visits,
|
||||
NOW() AS database_time
|
||||
`),
|
||||
safeQuery(client, 'user summary', `
|
||||
SELECT
|
||||
COUNT(*)::int AS total,
|
||||
COUNT(*) FILTER (WHERE COALESCE(is_active, true) = true)::int AS active,
|
||||
COUNT(*) FILTER (WHERE COALESCE(is_active, true) = false)::int AS disabled,
|
||||
COUNT(*) FILTER (WHERE COALESCE(role, 'user') IN ('admin', 'enterprise_admin'))::int AS admins,
|
||||
COUNT(*) FILTER (
|
||||
WHERE COALESCE(role, 'user') = 'vip'
|
||||
OR COALESCE(membership_tier, 'free') NOT IN ('free', '')
|
||||
)::int AS members,
|
||||
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '7 days')::int AS created_7d
|
||||
FROM profiles
|
||||
`),
|
||||
safeQuery(client, 'work summary', `
|
||||
SELECT
|
||||
COUNT(*)::int AS total,
|
||||
COUNT(*) FILTER (WHERE is_public = true)::int AS public,
|
||||
COUNT(*) FILTER (WHERE is_public = false)::int AS private,
|
||||
COUNT(*) FILTER (WHERE status = 'completed')::int AS completed,
|
||||
COUNT(*) FILTER (WHERE status = 'failed')::int AS failed,
|
||||
COUNT(*) FILTER (WHERE result_url IS NOT NULL AND result_url <> '')::int AS with_result_url,
|
||||
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '7 days')::int AS created_7d,
|
||||
COUNT(*) FILTER (WHERE type = 'text2img')::int AS text2img,
|
||||
COUNT(*) FILTER (WHERE type = 'img2img')::int AS img2img,
|
||||
COUNT(*) FILTER (WHERE type = 'text2video')::int AS text2video,
|
||||
COUNT(*) FILTER (WHERE type = 'img2video')::int AS img2video
|
||||
FROM works
|
||||
`),
|
||||
safeQuery(client, 'task status summary', `
|
||||
SELECT status, COUNT(*)::int AS count
|
||||
FROM generation_jobs
|
||||
GROUP BY status
|
||||
`),
|
||||
safeQuery(client, 'latest tasks', `
|
||||
SELECT id, type, status, error, created_at, updated_at
|
||||
FROM generation_jobs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 6
|
||||
`),
|
||||
safeQuery(client, 'order status summary', `
|
||||
SELECT status, COUNT(*)::int AS count
|
||||
FROM orders
|
||||
GROUP BY status
|
||||
`),
|
||||
safeQuery(client, 'order revenue summary', `
|
||||
SELECT
|
||||
COALESCE(SUM(amount) FILTER (WHERE status = 'paid'), 0)::numeric AS paid_revenue,
|
||||
COALESCE(SUM(amount) FILTER (
|
||||
WHERE status = 'paid' AND COALESCE(paid_at, created_at) >= NOW() - INTERVAL '7 days'
|
||||
), 0)::numeric AS paid_revenue_7d
|
||||
FROM orders
|
||||
`),
|
||||
safeQuery(client, 'latest orders', `
|
||||
SELECT id, order_no, product_name, amount, status, created_at
|
||||
FROM orders
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 6
|
||||
`),
|
||||
safeQuery(client, 'storage health', `
|
||||
SELECT
|
||||
COUNT(*)::int AS total,
|
||||
COUNT(*) FILTER (WHERE result_url IS NOT NULL AND result_url <> '')::int AS persisted
|
||||
FROM works
|
||||
`),
|
||||
safeQuery(client, 'log health', `
|
||||
SELECT
|
||||
COUNT(*)::int AS total,
|
||||
COUNT(*) FILTER (WHERE level = 'error')::int AS errors,
|
||||
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '24 hours')::int AS created_24h
|
||||
FROM platform_logs
|
||||
`),
|
||||
safeQuery(client, 'provider summary', `
|
||||
SELECT
|
||||
COUNT(*)::int AS total,
|
||||
COUNT(*) FILTER (WHERE is_active = true)::int AS active,
|
||||
COUNT(*) FILTER (WHERE is_active = false)::int AS inactive,
|
||||
COUNT(*) FILTER (WHERE type = 'image')::int AS image,
|
||||
COUNT(*) FILTER (WHERE type = 'video')::int AS video,
|
||||
COUNT(*) FILTER (WHERE type = 'text')::int AS text,
|
||||
COUNT(*) FILTER (
|
||||
WHERE is_active = true
|
||||
AND (COALESCE(default_api_url, '') = '' OR COALESCE(default_model, '') = '')
|
||||
)::int AS incomplete
|
||||
FROM api_providers
|
||||
`),
|
||||
safeQuery(client, 'model recommendation summary', `
|
||||
SELECT
|
||||
COUNT(*)::int AS total,
|
||||
COUNT(*) FILTER (WHERE is_active = true)::int AS active
|
||||
FROM model_recommendations
|
||||
`),
|
||||
safeQuery(client, 'user api key summary', `
|
||||
SELECT
|
||||
COUNT(*)::int AS total,
|
||||
COUNT(*) FILTER (WHERE is_active = true)::int AS active
|
||||
FROM user_api_keys
|
||||
`),
|
||||
safeQuery(client, 'announcement summary', `
|
||||
SELECT
|
||||
COUNT(*)::int AS total,
|
||||
COUNT(*) FILTER (
|
||||
WHERE is_active = true
|
||||
AND (starts_at IS NULL OR starts_at <= NOW())
|
||||
AND (expires_at IS NULL OR expires_at >= NOW())
|
||||
)::int AS active,
|
||||
COUNT(*) FILTER (WHERE is_active = true AND starts_at > NOW())::int AS scheduled,
|
||||
COUNT(*) FILTER (WHERE expires_at < NOW())::int AS expired
|
||||
FROM announcements
|
||||
`),
|
||||
]);
|
||||
|
||||
const platform = firstRow(platformResult);
|
||||
const users = firstRow(userResult);
|
||||
const works = firstRow(workResult);
|
||||
const orderRevenue = firstRow(orderRevenueResult);
|
||||
const storage = firstRow(storageResult);
|
||||
const logs = firstRow(logResult);
|
||||
const providers = firstRow(providerResult);
|
||||
const recommendations = firstRow(recommendationResult);
|
||||
const userApiKeys = firstRow(userApiKeyResult);
|
||||
const announcements = firstRow(announcementResult);
|
||||
const taskRows = taskStatusResult.rows;
|
||||
const orderRows = orderStatusResult.rows;
|
||||
|
||||
const totalTasks = taskRows.reduce((sum, row) => sum + numberValue(row.count), 0);
|
||||
const totalOrders = orderRows.reduce((sum, row) => sum + numberValue(row.count), 0);
|
||||
const totalWorks = numberValue(works.total);
|
||||
|
||||
return NextResponse.json({
|
||||
generatedAt: new Date().toISOString(),
|
||||
platform: {
|
||||
totalVisits: numberValue(platform.total_visits),
|
||||
databaseTime: platform.database_time || null,
|
||||
},
|
||||
users: {
|
||||
total: numberValue(users.total),
|
||||
active: numberValue(users.active),
|
||||
disabled: numberValue(users.disabled),
|
||||
admins: numberValue(users.admins),
|
||||
members: numberValue(users.members),
|
||||
created7d: numberValue(users.created_7d),
|
||||
},
|
||||
works: {
|
||||
total: totalWorks,
|
||||
public: numberValue(works.public),
|
||||
private: numberValue(works.private),
|
||||
completed: numberValue(works.completed),
|
||||
failed: numberValue(works.failed),
|
||||
withResultUrl: numberValue(works.with_result_url),
|
||||
created7d: numberValue(works.created_7d),
|
||||
resultUrlCoverage: totalWorks > 0 ? numberValue(works.with_result_url) / totalWorks : 1,
|
||||
byType: {
|
||||
text2img: numberValue(works.text2img),
|
||||
img2img: numberValue(works.img2img),
|
||||
text2video: numberValue(works.text2video),
|
||||
img2video: numberValue(works.img2video),
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
total: totalTasks,
|
||||
queued: statusCount(taskRows, 'queued'),
|
||||
running: statusCount(taskRows, 'running'),
|
||||
succeeded: statusCount(taskRows, 'succeeded'),
|
||||
failed: statusCount(taskRows, 'failed'),
|
||||
latest: latestTaskResult.rows.map(row => ({
|
||||
id: String(row.id || ''),
|
||||
type: String(row.type || ''),
|
||||
status: String(row.status || ''),
|
||||
error: row.error ? String(row.error) : null,
|
||||
createdAt: row.created_at || null,
|
||||
updatedAt: row.updated_at || null,
|
||||
})),
|
||||
},
|
||||
orders: {
|
||||
total: totalOrders,
|
||||
pending: statusCount(orderRows, 'pending'),
|
||||
paid: statusCount(orderRows, 'paid'),
|
||||
cancelled: statusCount(orderRows, 'cancelled'),
|
||||
refunded: statusCount(orderRows, 'refunded'),
|
||||
paidRevenue: numberValue(orderRevenue.paid_revenue),
|
||||
paidRevenue7d: numberValue(orderRevenue.paid_revenue_7d),
|
||||
latest: latestOrderResult.rows.map(row => ({
|
||||
id: String(row.id || ''),
|
||||
orderNo: String(row.order_no || ''),
|
||||
productName: String(row.product_name || ''),
|
||||
amount: numberValue(row.amount),
|
||||
status: String(row.status || ''),
|
||||
createdAt: row.created_at || null,
|
||||
})),
|
||||
},
|
||||
providers: {
|
||||
total: numberValue(providers.total),
|
||||
active: numberValue(providers.active),
|
||||
inactive: numberValue(providers.inactive),
|
||||
image: numberValue(providers.image),
|
||||
video: numberValue(providers.video),
|
||||
text: numberValue(providers.text),
|
||||
incomplete: numberValue(providers.incomplete),
|
||||
recommendationsTotal: numberValue(recommendations.total),
|
||||
recommendationsActive: numberValue(recommendations.active),
|
||||
userApiKeysTotal: numberValue(userApiKeys.total),
|
||||
userApiKeysActive: numberValue(userApiKeys.active),
|
||||
},
|
||||
announcements: {
|
||||
total: numberValue(announcements.total),
|
||||
active: numberValue(announcements.active),
|
||||
scheduled: numberValue(announcements.scheduled),
|
||||
expired: numberValue(announcements.expired),
|
||||
},
|
||||
system: {
|
||||
apiHealth: true,
|
||||
databaseHealth: true,
|
||||
storageHealth: Boolean(process.env.LOCAL_STORAGE_DIR),
|
||||
storageDirConfigured: Boolean(process.env.LOCAL_STORAGE_DIR),
|
||||
worksPersisted: numberValue(storage.persisted),
|
||||
worksTotal: numberValue(storage.total),
|
||||
logsTotal: numberValue(logs.total),
|
||||
logsErrors: numberValue(logs.errors),
|
||||
logsCreated24h: numberValue(logs.created_24h),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[admin/dashboard] GET error:', error);
|
||||
return NextResponse.json({ error: '获取仪表盘数据失败' }, { status: 500 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
69
src/app/api/admin/data-export/route.ts
Normal file
69
src/app/api/admin/data-export/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const data: Record<string, unknown[]> = {};
|
||||
|
||||
const tables = [
|
||||
'profiles',
|
||||
'works',
|
||||
'credit_transactions',
|
||||
'orders',
|
||||
'user_api_keys',
|
||||
'system_api_configs',
|
||||
'payment_methods',
|
||||
'work_likes',
|
||||
'announcements',
|
||||
];
|
||||
|
||||
for (const table of tables) {
|
||||
try {
|
||||
const result = await client.query(`SELECT * FROM ${table} ORDER BY created_at ASC`);
|
||||
data[table] = result.rows || [];
|
||||
} catch {
|
||||
data[table] = [];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.query('SELECT * FROM site_config');
|
||||
data.site_config = result.rows || [];
|
||||
} catch { data.site_config = []; }
|
||||
|
||||
try {
|
||||
const result = await client.query('SELECT * FROM site_stats');
|
||||
data.site_stats = result.rows || [];
|
||||
} catch { data.site_stats = []; }
|
||||
|
||||
try {
|
||||
const result = await client.query('SELECT id, email, created_at, raw_user_meta_data, password_hash FROM auth.users');
|
||||
data.auth_users = result.rows || [];
|
||||
} catch { data.auth_users = []; }
|
||||
|
||||
const exportData = {
|
||||
_meta: {
|
||||
version: '1.0',
|
||||
platform: 'miaojing',
|
||||
exported_at: new Date().toISOString(),
|
||||
tables: Object.keys(data),
|
||||
counts: Object.fromEntries(Object.entries(data).map(([k, v]) => [k, v.length])),
|
||||
},
|
||||
data,
|
||||
};
|
||||
|
||||
return NextResponse.json(exportData);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[data-export] Error:', err);
|
||||
return NextResponse.json({ error: err instanceof Error ? err.message : '导出失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
584
src/app/api/admin/data-import/route.ts
Normal file
584
src/app/api/admin/data-import/route.ts
Normal file
@@ -0,0 +1,584 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { localStorage } from '@/lib/local-storage';
|
||||
import { encryptSecret, previewSecret } from '@/lib/server-crypto';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
interface ImportMeta {
|
||||
version: string;
|
||||
platform: string;
|
||||
exported_at: string;
|
||||
tables: string[];
|
||||
counts: Record<string, number>;
|
||||
}
|
||||
|
||||
interface ImportPayload {
|
||||
_meta: ImportMeta;
|
||||
data: Record<string, unknown[]>;
|
||||
options?: {
|
||||
skipAuth?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_ROWS_PER_TABLE = 5000;
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const UUID_ID_TABLES = new Set([
|
||||
'auth.users',
|
||||
'profiles',
|
||||
'announcements',
|
||||
'works',
|
||||
'credit_transactions',
|
||||
'orders',
|
||||
'user_api_keys',
|
||||
'system_api_configs',
|
||||
'work_likes',
|
||||
]);
|
||||
|
||||
const TABLE_COLUMNS: Record<string, string[]> = {
|
||||
profiles: ['id', 'email', 'nickname', 'avatar_url', 'phone', 'role', 'membership_tier', 'membership_expires_at', 'credits_balance', 'daily_quota_used', 'daily_quota_limit', 'is_active', 'preferred_theme', 'created_at', 'updated_at'],
|
||||
site_config: ['id', 'site_name', 'site_tab_title', 'site_description', 'site_keywords', 'logo_url', 'favicon_url', 'announcement', 'membership_enabled', 'terms_of_service', 'privacy_policy', 'about_us', 'help_center', 'filing_info', 'filing_url', 'public_security_filing_info', 'public_security_filing_url', 'updated_at'],
|
||||
site_stats: ['id', 'total_visits', 'total_users', 'total_generations', 'updated_at'],
|
||||
announcements: ['id', 'title', 'content', 'type', 'is_active', 'starts_at', 'expires_at', 'created_at', 'updated_at'],
|
||||
works: ['id', 'user_id', 'title', 'type', 'prompt', 'negative_prompt', 'params', 'result_url', 'thumbnail_url', 'width', 'height', 'duration', 'status', 'is_public', 'likes_count', 'views_count', 'created_at', 'updated_at'],
|
||||
credit_transactions: ['id', 'user_id', 'amount', 'balance_after', 'type', 'description', 'related_work_id', 'created_at'],
|
||||
orders: ['id', 'user_id', 'order_no', 'product_type', 'product_name', 'amount', 'credits_amount', 'status', 'payment_method', 'paid_at', 'created_at', 'updated_at'],
|
||||
user_api_keys: ['id', 'user_id', 'provider', 'supplier_name', 'api_url', 'model_name', 'note', 'api_key_encrypted', 'api_key_preview', 'type', 'is_active', 'created_at', 'updated_at'],
|
||||
system_api_configs: ['id', 'provider', 'name', 'api_url', 'model_name', 'note', 'api_key_encrypted', 'api_key_preview', 'type', 'credits_per_use', 'is_active', 'sort_order', 'created_at', 'updated_at'],
|
||||
payment_methods: ['id', 'type', 'name', 'is_active', 'public_config', 'secret_config_encrypted', 'secret_config_preview', 'created_at', 'updated_at'],
|
||||
work_likes: ['id', 'user_id', 'work_id', 'created_at'],
|
||||
};
|
||||
|
||||
const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000';
|
||||
const AUTH_USER_COLUMNS = ['id', 'email', 'created_at', 'raw_user_meta_data', 'password_hash'];
|
||||
|
||||
const CONFLICT_COLUMNS: Record<string, string[]> = {
|
||||
'auth.users': ['id'],
|
||||
profiles: ['id'],
|
||||
site_config: ['id'],
|
||||
site_stats: ['id'],
|
||||
announcements: ['id'],
|
||||
works: ['id'],
|
||||
credit_transactions: ['id'],
|
||||
orders: ['id'],
|
||||
user_api_keys: ['id'],
|
||||
system_api_configs: ['id'],
|
||||
payment_methods: ['id'],
|
||||
work_likes: ['id'],
|
||||
};
|
||||
|
||||
type ImportResult = { imported: number; skipped: number; errors: string[] };
|
||||
|
||||
type ImportContext = {
|
||||
userIdMap: Map<string, string>;
|
||||
workIdMap: Map<string, string>;
|
||||
emailUserIdMap: Map<string, string>;
|
||||
apiKeyIdMap: Map<string, string>;
|
||||
apiKeyOwnerIdMap: Map<string, string>;
|
||||
columnCache: Map<string, Set<string>>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body: ImportPayload = await request.json();
|
||||
const { _meta, data } = body;
|
||||
const skipAuth = body.options?.skipAuth === true;
|
||||
|
||||
if (!_meta || _meta.platform !== 'miaojing' || !data || typeof data !== 'object') {
|
||||
return NextResponse.json({ error: '无效的导入文件:格式不匹配' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
const result: Record<string, ImportResult> = {};
|
||||
|
||||
try {
|
||||
const context = await buildImportContext(client, data);
|
||||
|
||||
if (!skipAuth && Array.isArray(data.auth_users)) {
|
||||
result.auth_users = await importRows(client, 'auth.users', AUTH_USER_COLUMNS, data.auth_users, context);
|
||||
} else {
|
||||
result.auth_users = {
|
||||
imported: 0,
|
||||
skipped: Array.isArray(data.auth_users) ? data.auth_users.length : 0,
|
||||
errors: skipAuth ? ['已按选项跳过认证账号导入'] : [],
|
||||
};
|
||||
}
|
||||
|
||||
for (const [table, allowedColumns] of Object.entries(TABLE_COLUMNS)) {
|
||||
const rows = data[table];
|
||||
result[table] = await importRows(client, table, allowedColumns, Array.isArray(rows) ? rows : [], context);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, message: '数据导入完成', details: result, meta: _meta });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[data-import] Error:', err instanceof Error ? err.message : err);
|
||||
return NextResponse.json({ error: err instanceof Error ? err.message : '导入失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function importRows(
|
||||
client: Awaited<ReturnType<typeof getDbClient>>,
|
||||
table: string,
|
||||
allowedColumns: string[],
|
||||
rows: unknown[],
|
||||
context: ImportContext,
|
||||
): Promise<ImportResult> {
|
||||
if (rows.length > MAX_ROWS_PER_TABLE) {
|
||||
return { imported: 0, skipped: rows.length, errors: [`${table}: 单表最多允许导入 ${MAX_ROWS_PER_TABLE} 行`] };
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
const existingColumns = await getExistingColumns(client, table, context);
|
||||
const effectiveAllowedColumns = allowedColumns.filter(col => existingColumns.has(col));
|
||||
|
||||
for (const rawRow of rows) {
|
||||
const row = await normalizeImportRow(table, rawRow as Record<string, unknown>, context);
|
||||
const cols = Object.keys(row).filter(col => effectiveAllowedColumns.includes(col));
|
||||
if (!cols.includes('id') || cols.length === 0) {
|
||||
skipped++;
|
||||
errors.push(`${table}: 缺少 id 或没有允许导入的字段`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const vals = cols.map(col => row[col]);
|
||||
const placeholders = cols.map((_, i) => `$${i + 1}`).join(', ');
|
||||
const conflictCols = CONFLICT_COLUMNS[table] || ['id'];
|
||||
|
||||
const mergeAssignments = getMergeAssignments(table, cols);
|
||||
const conflictAction = mergeAssignments.length > 0
|
||||
? `DO UPDATE SET ${mergeAssignments.join(', ')}`
|
||||
: 'DO NOTHING';
|
||||
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO ${table} AS target (${cols.join(', ')}) VALUES (${placeholders}) ON CONFLICT (${conflictCols.join(', ')}) ${conflictAction}`,
|
||||
vals,
|
||||
);
|
||||
if ((insertResult.rowCount || 0) > 0) {
|
||||
imported++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
} catch (e) {
|
||||
skipped++;
|
||||
errors.push(`${table}: ${e instanceof Error ? e.message : 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, skipped, errors };
|
||||
}
|
||||
|
||||
async function buildImportContext(
|
||||
client: Awaited<ReturnType<typeof getDbClient>>,
|
||||
data: Record<string, unknown[]>,
|
||||
): Promise<ImportContext> {
|
||||
const userIdMap = new Map<string, string>();
|
||||
const workIdMap = new Map<string, string>();
|
||||
const emailUserIdMap = new Map<string, string>();
|
||||
const apiKeyIdMap = new Map<string, string>();
|
||||
const apiKeyOwnerIdMap = new Map<string, string>();
|
||||
|
||||
const profileRows = Array.isArray(data.profiles) ? data.profiles : [];
|
||||
const authRows = Array.isArray(data.auth_users) ? data.auth_users : [];
|
||||
const profileEmails = new Map<string, string>();
|
||||
|
||||
for (const raw of profileRows) {
|
||||
const row = raw as Record<string, unknown>;
|
||||
seedUuidMap(userIdMap, row.id);
|
||||
if (typeof row.id === 'string' && typeof row.email === 'string' && row.email.trim()) {
|
||||
const email = row.email.trim().toLowerCase();
|
||||
profileEmails.set(email, row.id);
|
||||
emailUserIdMap.set(email, userIdMap.get(row.id) || row.id);
|
||||
}
|
||||
}
|
||||
for (const raw of authRows) {
|
||||
const row = raw as Record<string, unknown>;
|
||||
seedUuidMap(userIdMap, row.id);
|
||||
if (typeof row.id === 'string' && typeof row.email === 'string' && row.email.trim() && !profileEmails.has(row.email.trim().toLowerCase())) {
|
||||
const email = row.email.trim().toLowerCase();
|
||||
profileEmails.set(email, row.id);
|
||||
emailUserIdMap.set(email, userIdMap.get(row.id) || row.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (profileEmails.size > 0) {
|
||||
const emails = [...profileEmails.keys()];
|
||||
const existing = await client.query(
|
||||
'SELECT id, lower(email) AS email FROM profiles WHERE lower(email) = ANY($1)',
|
||||
[emails],
|
||||
);
|
||||
for (const row of existing.rows) {
|
||||
const importedId = profileEmails.get(row.email);
|
||||
if (importedId && importedId !== row.id) {
|
||||
userIdMap.set(importedId, row.id);
|
||||
emailUserIdMap.set(row.email, row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [email, importedId] of profileEmails.entries()) {
|
||||
emailUserIdMap.set(email, userIdMap.get(importedId) || importedId);
|
||||
}
|
||||
|
||||
const apiKeyRows = Array.isArray(data.user_api_keys) ? data.user_api_keys : [];
|
||||
for (const raw of apiKeyRows) {
|
||||
const row = raw as Record<string, unknown>;
|
||||
const oldId = typeof row.id === 'string' && row.id.trim() ? row.id.trim() : '';
|
||||
if (oldId) {
|
||||
apiKeyIdMap.set(oldId, isUuid(oldId) ? oldId : crypto.randomUUID());
|
||||
}
|
||||
const ownerId = findImportedWorkUserId(row);
|
||||
const ownerByEmail = findUserIdByEmail(row, { userIdMap, workIdMap, emailUserIdMap, apiKeyIdMap, apiKeyOwnerIdMap, columnCache: new Map() });
|
||||
const mappedOwnerId = ownerId
|
||||
? (userIdMap.get(ownerId) || ownerId)
|
||||
: ownerByEmail;
|
||||
if (oldId && mappedOwnerId) {
|
||||
apiKeyOwnerIdMap.set(oldId, mappedOwnerId);
|
||||
}
|
||||
}
|
||||
|
||||
const works = Array.isArray(data.works) ? data.works : [];
|
||||
const workUrls = new Map<string, string>();
|
||||
for (const raw of works) {
|
||||
const row = raw as Record<string, unknown>;
|
||||
seedUuidMap(workIdMap, row.id);
|
||||
if (typeof row.id === 'string' && typeof row.result_url === 'string' && row.result_url.trim() && !isDataUrl(row.result_url)) {
|
||||
workUrls.set(row.result_url.trim(), row.id);
|
||||
}
|
||||
}
|
||||
if (workUrls.size > 0) {
|
||||
const existing = await client.query(
|
||||
'SELECT id, result_url FROM works WHERE result_url = ANY($1)',
|
||||
[[...workUrls.keys()]],
|
||||
);
|
||||
for (const row of existing.rows) {
|
||||
const importedId = workUrls.get(row.result_url);
|
||||
if (importedId && importedId !== row.id) {
|
||||
workIdMap.set(importedId, row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { userIdMap, workIdMap, emailUserIdMap, apiKeyIdMap, apiKeyOwnerIdMap, columnCache: new Map() };
|
||||
}
|
||||
|
||||
async function normalizeImportRow(table: string, row: Record<string, unknown>, context: ImportContext): Promise<Record<string, unknown>> {
|
||||
const next = { ...row };
|
||||
|
||||
if (typeof next.user_id === 'string' && context.userIdMap.has(next.user_id)) {
|
||||
next.user_id = context.userIdMap.get(next.user_id);
|
||||
}
|
||||
if ((!next.user_id || next.user_id === SYSTEM_USER_ID) && findUserIdByEmail(next, context)) {
|
||||
next.user_id = findUserIdByEmail(next, context);
|
||||
}
|
||||
if (typeof next.related_work_id === 'string' && context.workIdMap.has(next.related_work_id)) {
|
||||
next.related_work_id = context.workIdMap.get(next.related_work_id);
|
||||
}
|
||||
if (typeof next.work_id === 'string' && context.workIdMap.has(next.work_id)) {
|
||||
next.work_id = context.workIdMap.get(next.work_id);
|
||||
}
|
||||
|
||||
if (table === 'auth.users' || table === 'profiles') {
|
||||
const currentId = typeof next.id === 'string' ? next.id : '';
|
||||
if (currentId && context.userIdMap.has(currentId)) {
|
||||
next.id = context.userIdMap.get(currentId);
|
||||
}
|
||||
}
|
||||
|
||||
if (table === 'user_api_keys') {
|
||||
const currentId = typeof next.id === 'string' ? next.id : '';
|
||||
if (currentId && context.apiKeyIdMap.has(currentId)) {
|
||||
next.id = context.apiKeyIdMap.get(currentId);
|
||||
}
|
||||
const importedUserId = findImportedWorkUserId(next);
|
||||
const emailUserId = findUserIdByEmail(next, context);
|
||||
if (importedUserId || emailUserId) {
|
||||
next.user_id = importedUserId
|
||||
? (context.userIdMap.get(importedUserId) || importedUserId)
|
||||
: emailUserId;
|
||||
}
|
||||
}
|
||||
|
||||
if (table === 'works') {
|
||||
const currentId = typeof next.id === 'string' ? next.id : '';
|
||||
if (currentId && context.workIdMap.has(currentId)) {
|
||||
next.id = context.workIdMap.get(currentId);
|
||||
}
|
||||
const importedUserId = findImportedWorkUserId(next) || findUserIdByEmail(next, context) || findUserIdByCustomModel(next, context);
|
||||
if (importedUserId) {
|
||||
next.user_id = context.userIdMap.get(importedUserId) || importedUserId;
|
||||
}
|
||||
if (typeof next.result_url === 'string') {
|
||||
next.result_url = await persistImportMedia(next.result_url, getWorkMediaFolder(next.type, 'results'));
|
||||
}
|
||||
if (typeof next.thumbnail_url === 'string') {
|
||||
next.thumbnail_url = await persistImportMedia(next.thumbnail_url, 'imported/works/thumbnails');
|
||||
}
|
||||
if (next.params && typeof next.params === 'object') {
|
||||
next.params = await sanitizeImportMedia(next.params, 'imported/works/references');
|
||||
remapCustomModelId(next.params as Record<string, unknown>, context);
|
||||
if ((!next.user_id || next.user_id === SYSTEM_USER_ID) && findUserIdByCustomModel(next, context)) {
|
||||
next.user_id = findUserIdByCustomModel(next, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (table === 'user_api_keys') {
|
||||
const rawEncrypted = typeof next.api_key_encrypted === 'string' ? next.api_key_encrypted.trim() : '';
|
||||
const rawApiKey = typeof next.apiKey === 'string' ? next.apiKey.trim() : '';
|
||||
const secret = rawApiKey || rawEncrypted;
|
||||
if (secret) {
|
||||
next.api_key_encrypted = encryptSecret(secret);
|
||||
next.api_key_preview = typeof next.api_key_preview === 'string' && next.api_key_preview
|
||||
? next.api_key_preview
|
||||
: previewSecret(secret);
|
||||
}
|
||||
}
|
||||
|
||||
if (UUID_ID_TABLES.has(table)) {
|
||||
const currentId = typeof next.id === 'string' ? next.id : '';
|
||||
if (!isUuid(currentId)) {
|
||||
next.id = crypto.randomUUID();
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function findImportedWorkUserId(row: Record<string, unknown>): string | null {
|
||||
const directKeys = ['user_id', 'userId', 'publisher_id', 'publisherId', 'owner_id', 'ownerId', 'created_by', 'createdBy'];
|
||||
for (const key of directKeys) {
|
||||
const value = row[key];
|
||||
if (typeof value === 'string' && value.trim() && value !== 'anonymous' && value !== '00000000-0000-0000-0000-000000000000') {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const params = row.params && typeof row.params === 'object' ? row.params as Record<string, unknown> : null;
|
||||
if (!params) return null;
|
||||
for (const key of directKeys) {
|
||||
const value = params[key];
|
||||
if (typeof value === 'string' && value.trim() && value !== 'anonymous' && value !== '00000000-0000-0000-0000-000000000000') {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findUserIdByEmail(row: Record<string, unknown>, context: ImportContext): string | null {
|
||||
const directKeys = ['email', 'user_email', 'userEmail', 'publisher_email', 'publisherEmail', 'owner_email', 'ownerEmail'];
|
||||
for (const key of directKeys) {
|
||||
const value = row[key];
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const mapped = context.emailUserIdMap.get(value.trim().toLowerCase());
|
||||
if (mapped) return mapped;
|
||||
}
|
||||
}
|
||||
|
||||
const params = row.params && typeof row.params === 'object' ? row.params as Record<string, unknown> : null;
|
||||
if (!params) return null;
|
||||
for (const key of directKeys) {
|
||||
const value = params[key];
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const mapped = context.emailUserIdMap.get(value.trim().toLowerCase());
|
||||
if (mapped) return mapped;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findUserIdByCustomModel(row: Record<string, unknown>, context: ImportContext): string | null {
|
||||
const params = row.params && typeof row.params === 'object' ? row.params as Record<string, unknown> : null;
|
||||
const model = typeof params?.model === 'string'
|
||||
? params.model
|
||||
: typeof row.model === 'string'
|
||||
? row.model
|
||||
: '';
|
||||
if (!model.startsWith('custom:')) return null;
|
||||
const oldId = model.slice('custom:'.length);
|
||||
return context.apiKeyOwnerIdMap.get(oldId) || null;
|
||||
}
|
||||
|
||||
function remapCustomModelId(params: Record<string, unknown>, context: ImportContext): void {
|
||||
const model = typeof params.model === 'string' ? params.model : '';
|
||||
if (!model.startsWith('custom:')) return;
|
||||
const oldId = model.slice('custom:'.length);
|
||||
const newId = context.apiKeyIdMap.get(oldId);
|
||||
if (newId) {
|
||||
params.model = `custom:${newId}`;
|
||||
}
|
||||
}
|
||||
|
||||
function getMergeAssignments(table: string, cols: string[]): string[] {
|
||||
const has = (column: string) => cols.includes(column);
|
||||
const assignments: string[] = [];
|
||||
|
||||
if (table === 'auth.users') {
|
||||
if (has('email')) assignments.push(`email = COALESCE(NULLIF(target.email, ''), EXCLUDED.email)`);
|
||||
if (has('raw_user_meta_data')) assignments.push(`raw_user_meta_data = COALESCE(target.raw_user_meta_data, EXCLUDED.raw_user_meta_data)`);
|
||||
if (has('password_hash')) assignments.push(`password_hash = COALESCE(NULLIF(target.password_hash, ''), EXCLUDED.password_hash)`);
|
||||
return assignments;
|
||||
}
|
||||
|
||||
if (table === 'profiles') {
|
||||
if (has('email')) assignments.push(`email = COALESCE(NULLIF(target.email, ''), EXCLUDED.email)`);
|
||||
if (has('nickname')) assignments.push(`nickname = COALESCE(NULLIF(target.nickname, ''), EXCLUDED.nickname)`);
|
||||
if (has('avatar_url')) assignments.push(`avatar_url = COALESCE(NULLIF(target.avatar_url, ''), EXCLUDED.avatar_url)`);
|
||||
if (has('phone')) assignments.push(`phone = COALESCE(NULLIF(target.phone, ''), EXCLUDED.phone)`);
|
||||
if (has('role')) assignments.push(`role = CASE WHEN target.role = 'admin' THEN target.role ELSE COALESCE(NULLIF(target.role, ''), EXCLUDED.role) END`);
|
||||
if (has('membership_tier')) assignments.push(`membership_tier = COALESCE(NULLIF(target.membership_tier, ''), EXCLUDED.membership_tier)`);
|
||||
if (has('membership_expires_at')) assignments.push(`membership_expires_at = COALESCE(target.membership_expires_at, EXCLUDED.membership_expires_at)`);
|
||||
if (has('credits_balance')) assignments.push(`credits_balance = COALESCE(target.credits_balance, EXCLUDED.credits_balance)`);
|
||||
if (has('daily_quota_limit')) assignments.push(`daily_quota_limit = COALESCE(target.daily_quota_limit, EXCLUDED.daily_quota_limit)`);
|
||||
if (has('is_active')) assignments.push(`is_active = COALESCE(target.is_active, EXCLUDED.is_active)`);
|
||||
if (has('preferred_theme')) assignments.push(`preferred_theme = CASE WHEN EXCLUDED.preferred_theme IN ('dark', 'light') THEN EXCLUDED.preferred_theme ELSE target.preferred_theme END`);
|
||||
if (has('updated_at')) assignments.push(`updated_at = GREATEST(COALESCE(target.updated_at, EXCLUDED.updated_at), COALESCE(EXCLUDED.updated_at, target.updated_at))`);
|
||||
return assignments;
|
||||
}
|
||||
|
||||
if (table === 'works') {
|
||||
if (has('user_id')) {
|
||||
assignments.push(`user_id = CASE WHEN (target.user_id IS NULL OR target.user_id = '${SYSTEM_USER_ID}'::uuid) AND EXCLUDED.user_id IS NOT NULL AND EXCLUDED.user_id <> '${SYSTEM_USER_ID}'::uuid THEN EXCLUDED.user_id ELSE target.user_id END`);
|
||||
}
|
||||
if (has('params')) assignments.push(`params = CASE WHEN target.params IS NULL OR target.params = '{}'::jsonb THEN EXCLUDED.params ELSE target.params END`);
|
||||
if (has('thumbnail_url')) assignments.push(`thumbnail_url = COALESCE(NULLIF(target.thumbnail_url, ''), EXCLUDED.thumbnail_url)`);
|
||||
if (has('width')) assignments.push(`width = COALESCE(target.width, EXCLUDED.width)`);
|
||||
if (has('height')) assignments.push(`height = COALESCE(target.height, EXCLUDED.height)`);
|
||||
if (has('duration')) assignments.push(`duration = COALESCE(target.duration, EXCLUDED.duration)`);
|
||||
if (has('updated_at')) assignments.push(`updated_at = GREATEST(COALESCE(target.updated_at, EXCLUDED.updated_at), COALESCE(EXCLUDED.updated_at, target.updated_at))`);
|
||||
return assignments;
|
||||
}
|
||||
|
||||
if (table === 'user_api_keys') {
|
||||
if (has('user_id')) assignments.push(`user_id = COALESCE(target.user_id, EXCLUDED.user_id)`);
|
||||
if (has('provider')) assignments.push(`provider = COALESCE(NULLIF(target.provider, ''), EXCLUDED.provider)`);
|
||||
if (has('supplier_name')) assignments.push(`supplier_name = COALESCE(NULLIF(target.supplier_name, ''), EXCLUDED.supplier_name)`);
|
||||
if (has('api_url')) assignments.push(`api_url = COALESCE(NULLIF(target.api_url, ''), EXCLUDED.api_url)`);
|
||||
if (has('model_name')) assignments.push(`model_name = COALESCE(NULLIF(target.model_name, ''), EXCLUDED.model_name)`);
|
||||
if (has('note')) assignments.push(`note = COALESCE(NULLIF(target.note, ''), EXCLUDED.note)`);
|
||||
if (has('api_key_encrypted')) assignments.push(`api_key_encrypted = COALESCE(NULLIF(target.api_key_encrypted, ''), EXCLUDED.api_key_encrypted)`);
|
||||
if (has('api_key_preview')) assignments.push(`api_key_preview = COALESCE(NULLIF(target.api_key_preview, ''), EXCLUDED.api_key_preview)`);
|
||||
if (has('type')) assignments.push(`type = COALESCE(NULLIF(target.type, ''), EXCLUDED.type)`);
|
||||
if (has('is_active')) assignments.push(`is_active = COALESCE(target.is_active, EXCLUDED.is_active)`);
|
||||
if (has('updated_at')) assignments.push(`updated_at = GREATEST(COALESCE(target.updated_at, EXCLUDED.updated_at), COALESCE(EXCLUDED.updated_at, target.updated_at))`);
|
||||
return assignments;
|
||||
}
|
||||
|
||||
if (table === 'system_api_configs') {
|
||||
if (has('provider')) assignments.push(`provider = COALESCE(NULLIF(target.provider, ''), EXCLUDED.provider)`);
|
||||
if (has('name')) assignments.push(`name = COALESCE(NULLIF(target.name, ''), EXCLUDED.name)`);
|
||||
if (has('api_url')) assignments.push(`api_url = COALESCE(NULLIF(target.api_url, ''), EXCLUDED.api_url)`);
|
||||
if (has('model_name')) assignments.push(`model_name = COALESCE(NULLIF(target.model_name, ''), EXCLUDED.model_name)`);
|
||||
if (has('note')) assignments.push(`note = COALESCE(NULLIF(target.note, ''), EXCLUDED.note)`);
|
||||
if (has('api_key_encrypted')) assignments.push(`api_key_encrypted = COALESCE(NULLIF(target.api_key_encrypted, ''), EXCLUDED.api_key_encrypted)`);
|
||||
if (has('api_key_preview')) assignments.push(`api_key_preview = COALESCE(NULLIF(target.api_key_preview, ''), EXCLUDED.api_key_preview)`);
|
||||
if (has('type')) assignments.push(`type = COALESCE(NULLIF(target.type, ''), EXCLUDED.type)`);
|
||||
if (has('credits_per_use')) assignments.push(`credits_per_use = COALESCE(target.credits_per_use, EXCLUDED.credits_per_use)`);
|
||||
if (has('is_active')) assignments.push(`is_active = COALESCE(target.is_active, EXCLUDED.is_active)`);
|
||||
if (has('sort_order')) assignments.push(`sort_order = COALESCE(target.sort_order, EXCLUDED.sort_order)`);
|
||||
if (has('updated_at')) assignments.push(`updated_at = GREATEST(COALESCE(target.updated_at, EXCLUDED.updated_at), COALESCE(EXCLUDED.updated_at, target.updated_at))`);
|
||||
return assignments;
|
||||
}
|
||||
|
||||
if (table === 'payment_methods') {
|
||||
if (has('type')) assignments.push(`type = COALESCE(NULLIF(target.type, ''), EXCLUDED.type)`);
|
||||
if (has('name')) assignments.push(`name = COALESCE(NULLIF(target.name, ''), EXCLUDED.name)`);
|
||||
if (has('is_active')) assignments.push(`is_active = COALESCE(target.is_active, EXCLUDED.is_active)`);
|
||||
if (has('public_config')) assignments.push(`public_config = COALESCE(target.public_config, EXCLUDED.public_config)`);
|
||||
if (has('secret_config_encrypted')) assignments.push(`secret_config_encrypted = COALESCE(target.secret_config_encrypted, EXCLUDED.secret_config_encrypted)`);
|
||||
if (has('secret_config_preview')) assignments.push(`secret_config_preview = COALESCE(target.secret_config_preview, EXCLUDED.secret_config_preview)`);
|
||||
if (has('updated_at')) assignments.push(`updated_at = GREATEST(COALESCE(target.updated_at, EXCLUDED.updated_at), COALESCE(EXCLUDED.updated_at, target.updated_at))`);
|
||||
return assignments;
|
||||
}
|
||||
|
||||
return assignments;
|
||||
}
|
||||
|
||||
async function getExistingColumns(
|
||||
client: Awaited<ReturnType<typeof getDbClient>>,
|
||||
table: string,
|
||||
context: ImportContext,
|
||||
): Promise<Set<string>> {
|
||||
const cached = context.columnCache.get(table);
|
||||
if (cached) return cached;
|
||||
|
||||
const [schemaName, tableName] = table.includes('.') ? table.split('.', 2) : ['public', table];
|
||||
const result = await client.query(
|
||||
'SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2',
|
||||
[schemaName, tableName],
|
||||
);
|
||||
const columns = new Set((result.rows || []).map((row: Record<string, unknown>) => String(row.column_name)));
|
||||
context.columnCache.set(table, columns);
|
||||
return columns;
|
||||
}
|
||||
|
||||
function seedUuidMap(map: Map<string, string>, value: unknown): void {
|
||||
if (typeof value === 'string' && value && !isUuid(value) && !map.has(value)) {
|
||||
map.set(value, crypto.randomUUID());
|
||||
}
|
||||
}
|
||||
|
||||
function isUuid(value: unknown): value is string {
|
||||
return typeof value === 'string' && UUID_REGEX.test(value);
|
||||
}
|
||||
|
||||
function isDataUrl(value: unknown): boolean {
|
||||
return typeof value === 'string' && /^data:[^,]+,/i.test(value);
|
||||
}
|
||||
|
||||
function getWorkMediaFolder(type: unknown, kind: string): string {
|
||||
const text = typeof type === 'string' ? type.toLowerCase() : '';
|
||||
const media = text.includes('video') ? 'videos' : 'images';
|
||||
return `imported/works/${kind}/${media}`;
|
||||
}
|
||||
|
||||
function extensionFromMime(mime: string): string {
|
||||
const normalized = mime.toLowerCase();
|
||||
if (normalized.includes('png')) return 'png';
|
||||
if (normalized.includes('jpeg') || normalized.includes('jpg')) return 'jpg';
|
||||
if (normalized.includes('webp')) return 'webp';
|
||||
if (normalized.includes('gif')) return 'gif';
|
||||
if (normalized.includes('mp4')) return 'mp4';
|
||||
if (normalized.includes('webm')) return 'webm';
|
||||
return 'bin';
|
||||
}
|
||||
|
||||
async function persistImportMedia(value: string, folder: string): Promise<string> {
|
||||
if (!isDataUrl(value)) return value;
|
||||
|
||||
const match = value.match(/^data:([^;,]+)?(;base64)?,([\s\S]*)$/i);
|
||||
if (!match) return value;
|
||||
|
||||
const mime = match[1] || 'application/octet-stream';
|
||||
const isBase64 = Boolean(match[2]);
|
||||
const payload = match[3] || '';
|
||||
const buffer = isBase64 ? Buffer.from(payload, 'base64') : Buffer.from(decodeURIComponent(payload));
|
||||
const ext = extensionFromMime(mime);
|
||||
const key = `${folder}/${Date.now()}-${crypto.randomUUID()}.${ext}`;
|
||||
const savedKey = await localStorage.uploadFile({ fileContent: buffer, fileName: key, contentType: mime });
|
||||
return localStorage.generatePresignedUrl({ key: savedKey, expireTime: 2592000 });
|
||||
}
|
||||
|
||||
async function sanitizeImportMedia(value: unknown, folder: string): Promise<unknown> {
|
||||
if (typeof value === 'string') {
|
||||
return persistImportMedia(value, folder);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return Promise.all(value.map(item => sanitizeImportMedia(item, folder)));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
||||
output[key] = await sanitizeImportMedia(nested, folder);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
77
src/app/api/admin/email-recipients/route.ts
Normal file
77
src/app/api/admin/email-recipients/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { isValidEmail, normalizeEmail } from '@/lib/email-service';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function mapRecipient(row: Record<string, unknown>) {
|
||||
const email = normalizeEmail(row.email);
|
||||
if (!isValidEmail(email)) return null;
|
||||
return {
|
||||
id: String(row.id),
|
||||
email,
|
||||
nickname: typeof row.nickname === 'string' && row.nickname.trim() ? row.nickname.trim() : email.split('@')[0],
|
||||
phone: typeof row.phone === 'string' ? row.phone : null,
|
||||
avatarUrl: typeof row.avatar_url === 'string' ? row.avatar_url : null,
|
||||
emailVerified: row.email_verified === true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const adminError = await requireAdmin(request);
|
||||
if (adminError) return adminError;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const q = (searchParams.get('q') || '').trim().toLowerCase().slice(0, 80);
|
||||
const limit = Math.min(80, Math.max(1, Number(searchParams.get('limit') || 30)));
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const params: unknown[] = [];
|
||||
let filter = `
|
||||
WHERE COALESCE(role, 'user') NOT IN ('admin', 'enterprise_admin')
|
||||
AND COALESCE(is_active, true) = true
|
||||
AND COALESCE(email, '') <> ''
|
||||
`;
|
||||
|
||||
if (q) {
|
||||
params.push(`%${q}%`);
|
||||
filter += `
|
||||
AND (
|
||||
LOWER(email) LIKE $${params.length}
|
||||
OR LOWER(COALESCE(nickname, '')) LIKE $${params.length}
|
||||
OR COALESCE(phone, '') LIKE $${params.length}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
`SELECT id, email, nickname, phone, avatar_url, email_verified
|
||||
FROM profiles
|
||||
${filter}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${limit}`,
|
||||
params,
|
||||
);
|
||||
|
||||
const countResult = await client.query(
|
||||
`SELECT COUNT(*)::int AS count
|
||||
FROM profiles
|
||||
WHERE COALESCE(role, 'user') NOT IN ('admin', 'enterprise_admin')
|
||||
AND COALESCE(is_active, true) = true
|
||||
AND COALESCE(email, '') <> ''`,
|
||||
);
|
||||
|
||||
const users = result.rows
|
||||
.map(mapRecipient)
|
||||
.filter((item): item is NonNullable<ReturnType<typeof mapRecipient>> => Boolean(item));
|
||||
|
||||
return NextResponse.json({
|
||||
users,
|
||||
total: Number(countResult.rows[0]?.count || 0),
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
84
src/app/api/admin/email-settings/route.ts
Normal file
84
src/app/api/admin/email-settings/route.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import {
|
||||
getEmailSettings,
|
||||
getRequestBaseUrl,
|
||||
publicEmailSettings,
|
||||
renderEmailTemplate,
|
||||
saveEmailSettings,
|
||||
sendTemplatedEmail,
|
||||
} from '@/lib/email-service';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const adminError = await requireAdmin(request);
|
||||
if (adminError) return adminError;
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const settings = await getEmailSettings(client);
|
||||
const platformUrl = getRequestBaseUrl(request) || settings.appBaseUrl;
|
||||
const preview = renderEmailTemplate(settings, {
|
||||
title: '通知邮件模板预览',
|
||||
intro: '这是一封由管理员发送给用户的通知邮件示例,用于预览全局通用邮件模板效果。',
|
||||
body: '你可以在后台使用这套模板发送系统公告、功能更新、订单提醒、活动通知和安全提醒。实际发送时,标题、正文、按钮和备注会替换为管理员填写的内容。',
|
||||
buttonText: '进入妙境',
|
||||
buttonUrl: platformUrl,
|
||||
note: '验证码邮件使用独立安全验证模板;管理员通知、管理员邮件和提醒邮件使用这套通用模板。',
|
||||
templateKind: 'notification',
|
||||
assetBaseUrl: platformUrl,
|
||||
});
|
||||
return NextResponse.json({ settings: publicEmailSettings(settings), preview });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const adminError = await requireAdmin(request);
|
||||
if (adminError) return adminError;
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const body = await request.json();
|
||||
const settings = await saveEmailSettings(client, body);
|
||||
return NextResponse.json({ success: true, settings, message: '邮箱配置已保存' });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '邮箱配置保存失败';
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const adminError = await requireAdmin(request);
|
||||
if (adminError) return adminError;
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const body = await request.json();
|
||||
const to = typeof body.to === 'string' ? body.to.trim() : '';
|
||||
if (!to) {
|
||||
return NextResponse.json({ error: '请填写测试收件邮箱' }, { status: 400 });
|
||||
}
|
||||
await sendTemplatedEmail(client, {
|
||||
to,
|
||||
type: 'business',
|
||||
subject: '【妙境】邮箱配置测试',
|
||||
title: '邮箱配置测试',
|
||||
intro: '如果你收到这封邮件,说明自定义域名邮箱 SMTP 配置已生效。',
|
||||
note: '请同时检查收件箱、垃圾箱,以及 SPF/DKIM/DMARC 解析状态。',
|
||||
ipAddress: 'admin-test',
|
||||
assetBaseUrl: getRequestBaseUrl(request) || undefined,
|
||||
});
|
||||
return NextResponse.json({ success: true, message: '测试邮件已发送' });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '测试邮件发送失败';
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
123
src/app/api/admin/generation-jobs/route.ts
Normal file
123
src/app/api/admin/generation-jobs/route.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { markStaleRunningJobs } from '@/lib/generation-job-worker';
|
||||
import { ensureGenerationJobRuntimeSchema } from '@/lib/generation-job-estimates';
|
||||
import { writePlatformLog } from '@/lib/platform-logs';
|
||||
|
||||
const STATUSES = new Set(['queued', 'running', 'succeeded', 'failed']);
|
||||
const CLEANUP_STATUSES = new Set(['failed', 'succeeded']);
|
||||
|
||||
function intParam(value: string | null, fallback: number, min: number, max: number) {
|
||||
const parsed = Number.parseInt(value || '', 10);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.min(max, Math.max(min, parsed));
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
await markStaleRunningJobs();
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get('status') || '';
|
||||
const userSearch = (searchParams.get('user') || searchParams.get('userSearch') || '').trim();
|
||||
const page = intParam(searchParams.get('page'), 1, 1, 100000);
|
||||
const pageSize = intParam(searchParams.get('pageSize'), 20, 1, 100);
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
if (status && !STATUSES.has(status)) {
|
||||
return NextResponse.json({ error: '任务状态无效' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureGenerationJobRuntimeSchema(client);
|
||||
const whereClauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
if (status) {
|
||||
params.push(status);
|
||||
whereClauses.push(`j.status = $${params.length}`);
|
||||
}
|
||||
if (userSearch) {
|
||||
params.push(`%${userSearch.toLowerCase()}%`);
|
||||
whereClauses.push(`(
|
||||
j.user_id::text LIKE $${params.length}
|
||||
OR LOWER(COALESCE(p.email, '')) LIKE $${params.length}
|
||||
OR LOWER(COALESCE(p.nickname, '')) LIKE $${params.length}
|
||||
)`);
|
||||
}
|
||||
const whereSql = whereClauses.length ? `WHERE ${whereClauses.join(' AND ')}` : '';
|
||||
const countResult = await client.query(
|
||||
`SELECT COUNT(*)::int AS total
|
||||
FROM generation_jobs j
|
||||
LEFT JOIN profiles p ON p.id = j.user_id
|
||||
${whereSql}`,
|
||||
params,
|
||||
);
|
||||
const rowsResult = await client.query(
|
||||
`SELECT j.id, j.user_id, p.email AS user_email, p.nickname AS user_nickname,
|
||||
j.type, j.status, j.error, j.created_at, j.started_at, j.finished_at, j.updated_at
|
||||
FROM generation_jobs j
|
||||
LEFT JOIN profiles p ON p.id = j.user_id
|
||||
${whereSql}
|
||||
ORDER BY j.created_at DESC
|
||||
LIMIT $${params.length + 1}
|
||||
OFFSET $${params.length + 2}`,
|
||||
[...params, pageSize, offset],
|
||||
);
|
||||
|
||||
const total = countResult.rows[0]?.total || 0;
|
||||
return NextResponse.json({
|
||||
jobs: rowsResult.rows,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get('status') || 'failed';
|
||||
const olderThanDays = intParam(searchParams.get('olderThanDays'), 7, 0, 3650);
|
||||
|
||||
if (!CLEANUP_STATUSES.has(status)) {
|
||||
return NextResponse.json(
|
||||
{ error: '只允许清理失败或已完成任务' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`DELETE FROM generation_jobs
|
||||
WHERE status = $1
|
||||
AND updated_at < NOW() - ($2::int * INTERVAL '1 day')`,
|
||||
[status, olderThanDays],
|
||||
);
|
||||
void writePlatformLog({
|
||||
type: 'admin',
|
||||
level: 'warning',
|
||||
action: 'generation_jobs_cleanup',
|
||||
message: `管理员清理了${status === 'failed' ? '失败' : '已完成'}生成任务`,
|
||||
targetType: 'generation_jobs',
|
||||
metadata: { status, olderThanDays, deleted: result.rowCount || 0 },
|
||||
request,
|
||||
});
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deleted: result.rowCount || 0,
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
118
src/app/api/admin/model-recommendations/route.ts
Normal file
118
src/app/api/admin/model-recommendations/route.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
function mapRecommendation(row: Record<string, unknown>) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
modelName: String(row.model_name || ''),
|
||||
displayName: String(row.display_name || row.model_name || ''),
|
||||
type: String(row.type || 'image'),
|
||||
providerId: (row.provider_id as string | null) || null,
|
||||
isActive: row.is_active !== false,
|
||||
sortOrder: Number(row.sort_order || 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function readBody(request: NextRequest) {
|
||||
return request.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`SELECT id, model_name, display_name, type, provider_id, is_active, sort_order
|
||||
FROM model_recommendations
|
||||
ORDER BY type ASC, sort_order ASC, model_name ASC`
|
||||
);
|
||||
return NextResponse.json({ recommendations: result.rows.map(mapRecommendation) });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await readBody(request);
|
||||
if (!body.modelName?.trim()) {
|
||||
return NextResponse.json({ error: '请填写模型名称' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`INSERT INTO model_recommendations (model_name, display_name, type, provider_id, is_active, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, model_name, display_name, type, provider_id, is_active, sort_order`,
|
||||
[
|
||||
body.modelName.trim(),
|
||||
body.displayName?.trim() || body.modelName.trim(),
|
||||
body.type || 'image',
|
||||
body.providerId || null,
|
||||
body.isActive !== false,
|
||||
Number(body.sortOrder || 0),
|
||||
]
|
||||
);
|
||||
return NextResponse.json({ recommendation: mapRecommendation(result.rows[0]) });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await readBody(request);
|
||||
if (!body.id || !body.modelName?.trim()) {
|
||||
return NextResponse.json({ error: '缺少推荐项 ID 或模型名称' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`UPDATE model_recommendations
|
||||
SET model_name = $2, display_name = $3, type = $4, provider_id = $5,
|
||||
is_active = $6, sort_order = $7, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, model_name, display_name, type, provider_id, is_active, sort_order`,
|
||||
[
|
||||
body.id,
|
||||
body.modelName.trim(),
|
||||
body.displayName?.trim() || body.modelName.trim(),
|
||||
body.type || 'image',
|
||||
body.providerId || null,
|
||||
body.isActive !== false,
|
||||
Number(body.sortOrder || 0),
|
||||
]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: '推荐模型不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ recommendation: mapRecommendation(result.rows[0]) });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await readBody(request);
|
||||
const id = body.id || request.nextUrl.searchParams.get('id');
|
||||
if (!id) return NextResponse.json({ error: '缺少推荐项 ID' }, { status: 400 });
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await client.query('DELETE FROM model_recommendations WHERE id = $1', [id]);
|
||||
return NextResponse.json({ success: true });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
80
src/app/api/admin/orders/route.ts
Normal file
80
src/app/api/admin/orders/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query('SELECT * FROM orders ORDER BY created_at DESC LIMIT 100');
|
||||
return NextResponse.json({ orders: result.rows || [] });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[admin/orders] GET error:', err);
|
||||
return NextResponse.json({ error: '获取订单列表失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const id = crypto.randomUUID();
|
||||
const { user_id, order_no, product_type, product_name, amount, credits_amount, status, payment_method } = body;
|
||||
await client.query(
|
||||
'INSERT INTO orders (id, user_id, order_no, product_type, product_name, amount, credits_amount, status, payment_method) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)',
|
||||
[id, user_id, order_no, product_type, product_name, amount, credits_amount, status || 'pending', payment_method]
|
||||
);
|
||||
return NextResponse.json({ success: true });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[admin/orders] POST error:', err);
|
||||
return NextResponse.json({ error: '创建订单失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { orderId, ...updates } = body;
|
||||
|
||||
if (!orderId) {
|
||||
return NextResponse.json({ error: '缺少订单ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const setClauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (updates.status !== undefined) { setClauses.push(`status = $${paramIdx++}`); params.push(updates.status); }
|
||||
if (updates.payment_method !== undefined) { setClauses.push(`payment_method = $${paramIdx++}`); params.push(updates.payment_method); }
|
||||
if (updates.paid_at !== undefined) { setClauses.push(`paid_at = $${paramIdx++}`); params.push(updates.paid_at); }
|
||||
setClauses.push('updated_at = NOW()');
|
||||
|
||||
params.push(orderId);
|
||||
await client.query(`UPDATE orders SET ${setClauses.join(', ')} WHERE id = $${paramIdx}`, params);
|
||||
return NextResponse.json({ success: true });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[admin/orders] PUT error:', err);
|
||||
return NextResponse.json({ error: '更新订单失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
47
src/app/api/admin/payment-methods/route.ts
Normal file
47
src/app/api/admin/payment-methods/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { listPaymentMethods, savePaymentMethod } from '@/lib/server-payment-config';
|
||||
|
||||
async function readBody(request: NextRequest) {
|
||||
return request.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
return NextResponse.json({ paymentMethods: await listPaymentMethods(client) });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await readBody(request);
|
||||
if (typeof body.id !== 'string' || !body.id.trim()) {
|
||||
return NextResponse.json({ error: '缺少支付方式 ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const paymentMethods = await savePaymentMethod(client, body.id.trim(), {
|
||||
name: typeof body.name === 'string' ? body.name : undefined,
|
||||
isActive: typeof body.isActive === 'boolean' ? body.isActive : undefined,
|
||||
config: body.config && typeof body.config === 'object' ? body.config : undefined,
|
||||
});
|
||||
return NextResponse.json({ paymentMethods });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : '保存失败' },
|
||||
{ status: 400 },
|
||||
);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
121
src/app/api/admin/providers/route.ts
Normal file
121
src/app/api/admin/providers/route.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
function mapProvider(row: Record<string, unknown>) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
name: String(row.name || ''),
|
||||
defaultApiUrl: String(row.default_api_url || ''),
|
||||
defaultModel: String(row.default_model || ''),
|
||||
type: String(row.type || 'image'),
|
||||
website: (row.website as string | null) || null,
|
||||
isActive: row.is_active !== false,
|
||||
sortOrder: Number(row.sort_order || 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function readBody(request: NextRequest) {
|
||||
return request.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`SELECT id, name, default_api_url, default_model, type, website, is_active, sort_order
|
||||
FROM api_providers
|
||||
ORDER BY sort_order ASC, name ASC`
|
||||
);
|
||||
return NextResponse.json({ providers: result.rows.map(mapProvider) });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await readBody(request);
|
||||
if (!body.name?.trim()) {
|
||||
return NextResponse.json({ error: '请填写供应商名称' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`INSERT INTO api_providers (name, default_api_url, default_model, type, website, is_active, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, name, default_api_url, default_model, type, website, is_active, sort_order`,
|
||||
[
|
||||
body.name.trim(),
|
||||
body.defaultApiUrl?.trim() || '',
|
||||
body.defaultModel?.trim() || '',
|
||||
body.type || 'image',
|
||||
body.website?.trim() || null,
|
||||
body.isActive !== false,
|
||||
Number(body.sortOrder || 0),
|
||||
]
|
||||
);
|
||||
return NextResponse.json({ provider: mapProvider(result.rows[0]) });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await readBody(request);
|
||||
if (!body.id || !body.name?.trim()) {
|
||||
return NextResponse.json({ error: '缺少供应商 ID 或名称' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`UPDATE api_providers
|
||||
SET name = $2, default_api_url = $3, default_model = $4, type = $5, website = $6,
|
||||
is_active = $7, sort_order = $8, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, name, default_api_url, default_model, type, website, is_active, sort_order`,
|
||||
[
|
||||
body.id,
|
||||
body.name.trim(),
|
||||
body.defaultApiUrl?.trim() || '',
|
||||
body.defaultModel?.trim() || '',
|
||||
body.type || 'image',
|
||||
body.website?.trim() || null,
|
||||
body.isActive !== false,
|
||||
Number(body.sortOrder || 0),
|
||||
]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: '供应商不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ provider: mapProvider(result.rows[0]) });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await readBody(request);
|
||||
const id = body.id || request.nextUrl.searchParams.get('id');
|
||||
if (!id) return NextResponse.json({ error: '缺少供应商 ID' }, { status: 400 });
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await client.query('DELETE FROM api_providers WHERE id = $1', [id]);
|
||||
return NextResponse.json({ success: true });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
147
src/app/api/admin/send-email/route.ts
Normal file
147
src/app/api/admin/send-email/route.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { getRequestBaseUrl, isValidEmail, normalizeEmail, sendTemplatedEmail } from '@/lib/email-service';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const MAX_TARGETED_RECIPIENTS = 200;
|
||||
const MAX_BROADCAST_RECIPIENTS = 5000;
|
||||
type AdminMailKind = 'notification' | 'admin';
|
||||
|
||||
function normalizeMailKind(value: unknown): AdminMailKind {
|
||||
return value === 'admin' ? 'admin' : 'notification';
|
||||
}
|
||||
|
||||
function normalizeIdList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return [...new Set(value
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map(item => item.trim())
|
||||
.filter(item => /^[0-9a-fA-F-]{36}$/.test(item)))];
|
||||
}
|
||||
|
||||
function normalizeEmailList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return [...new Set(value
|
||||
.map(normalizeEmail)
|
||||
.filter(isValidEmail))];
|
||||
}
|
||||
|
||||
async function loadRecipients(client: Awaited<ReturnType<typeof getDbClient>>, body: Record<string, unknown>) {
|
||||
const mode = body.mode === 'all' ? 'all' : 'selected';
|
||||
|
||||
if (mode === 'all') {
|
||||
const result = await client.query(
|
||||
`SELECT id, email
|
||||
FROM profiles
|
||||
WHERE COALESCE(role, 'user') NOT IN ('admin', 'enterprise_admin')
|
||||
AND COALESCE(is_active, true) = true
|
||||
AND COALESCE(email, '') <> ''
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $1`,
|
||||
[MAX_BROADCAST_RECIPIENTS],
|
||||
);
|
||||
return result.rows
|
||||
.map(row => ({ id: String(row.id), email: normalizeEmail(row.email) }))
|
||||
.filter(row => isValidEmail(row.email));
|
||||
}
|
||||
|
||||
const userIds = normalizeIdList(body.userIds);
|
||||
const emails = normalizeEmailList(body.emails);
|
||||
|
||||
if (userIds.length === 0 && emails.length === 0) return [];
|
||||
if (userIds.length + emails.length > MAX_TARGETED_RECIPIENTS) {
|
||||
throw new Error(`单次指定发送最多 ${MAX_TARGETED_RECIPIENTS} 个收件人`);
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
`SELECT id, email
|
||||
FROM profiles
|
||||
WHERE COALESCE(role, 'user') NOT IN ('admin', 'enterprise_admin')
|
||||
AND COALESCE(is_active, true) = true
|
||||
AND COALESCE(email, '') <> ''
|
||||
AND (
|
||||
id = ANY($1::uuid[])
|
||||
OR LOWER(email) = ANY($2::text[])
|
||||
)`,
|
||||
[userIds, emails],
|
||||
);
|
||||
|
||||
return result.rows
|
||||
.map(row => ({ id: String(row.id), email: normalizeEmail(row.email) }))
|
||||
.filter(row => isValidEmail(row.email));
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const adminError = await requireAdmin(request);
|
||||
if (adminError) return adminError;
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const body = await request.json().catch(() => ({})) as Record<string, unknown>;
|
||||
const title = typeof body.title === 'string' ? body.title.trim().slice(0, 120) : '';
|
||||
const content = typeof body.content === 'string' ? body.content.trim().slice(0, 5000) : '';
|
||||
const buttonText = typeof body.buttonText === 'string' ? body.buttonText.trim().slice(0, 40) : '';
|
||||
const buttonUrl = typeof body.buttonUrl === 'string' ? body.buttonUrl.trim().slice(0, 500) : '';
|
||||
const mailKind = normalizeMailKind(body.mailKind);
|
||||
const mailKindLabel = mailKind === 'admin' ? '管理员邮件' : '通知邮件';
|
||||
|
||||
if (!title || !content) {
|
||||
return NextResponse.json({ error: '请填写邮件标题和正文内容' }, { status: 400 });
|
||||
}
|
||||
if (buttonUrl && !/^https?:\/\/[^\s"'<>]+$/i.test(buttonUrl)) {
|
||||
return NextResponse.json({ error: '按钮链接必须是 HTTP(S) 地址' }, { status: 400 });
|
||||
}
|
||||
|
||||
const recipients = await loadRecipients(client, body);
|
||||
const uniqueRecipients = [...new Map(recipients.map(item => [item.email, item])).values()];
|
||||
if (uniqueRecipients.length === 0) {
|
||||
return NextResponse.json({ error: '没有可发送的非管理员用户邮箱' }, { status: 400 });
|
||||
}
|
||||
|
||||
let sent = 0;
|
||||
const failed: Array<{ email: string; error: string }> = [];
|
||||
const assetBaseUrl = getRequestBaseUrl(request) || undefined;
|
||||
|
||||
for (const recipient of uniqueRecipients) {
|
||||
try {
|
||||
await sendTemplatedEmail(client, {
|
||||
to: recipient.email,
|
||||
type: mailKind === 'admin' ? 'business' : 'announcement',
|
||||
subject: `【妙境】${title}`,
|
||||
title,
|
||||
body: content,
|
||||
buttonText: buttonText || undefined,
|
||||
buttonUrl: buttonUrl || undefined,
|
||||
note: `这是一封${mailKindLabel},请勿直接回复。`,
|
||||
templateKind: mailKind,
|
||||
ipAddress: body.mode === 'all' ? 'admin-broadcast' : 'admin-targeted',
|
||||
assetBaseUrl,
|
||||
});
|
||||
sent += 1;
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
email: recipient.email,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: failed.length === 0,
|
||||
total: uniqueRecipients.length,
|
||||
sent,
|
||||
failedCount: failed.length,
|
||||
failed: failed.slice(0, 20),
|
||||
message: failed.length === 0
|
||||
? `邮件已发送给 ${sent} 个用户`
|
||||
: `已发送 ${sent} 封,失败 ${failed.length} 封`,
|
||||
}, { status: sent > 0 ? 200 : 400 });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '邮件发送失败';
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
39
src/app/api/admin/stats/route.ts
Normal file
39
src/app/api/admin/stats/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query(`
|
||||
SELECT
|
||||
COALESCE((SELECT total_visits FROM site_stats WHERE id = 1 LIMIT 1), 0)::int AS total_visits,
|
||||
COALESCE((
|
||||
SELECT COUNT(*)
|
||||
FROM profiles
|
||||
WHERE COALESCE(role, 'user') NOT IN ('admin', 'enterprise_admin')
|
||||
), 0)::int AS total_users,
|
||||
COALESCE((
|
||||
SELECT COUNT(*)
|
||||
FROM works
|
||||
WHERE is_public = true AND status = 'completed'
|
||||
), 0)::int AS total_works
|
||||
`);
|
||||
const row = result.rows[0] || {};
|
||||
return NextResponse.json({
|
||||
totalVisits: Number(row.total_visits || 0),
|
||||
totalUsers: Number(row.total_users || 0),
|
||||
totalWorks: Number(row.total_works || 0),
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[admin/stats] GET error:', err);
|
||||
return NextResponse.json({ error: '获取统计数据失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
142
src/app/api/admin/system-apis/route.ts
Normal file
142
src/app/api/admin/system-apis/route.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import {
|
||||
encryptApiKeyForStorage,
|
||||
ensureSystemApiSchema,
|
||||
isUuid,
|
||||
listSystemApis,
|
||||
toSafeSystemApi,
|
||||
} from '@/lib/server-api-config';
|
||||
|
||||
async function readBody(request: NextRequest) {
|
||||
return request.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
function normalizeType(value: unknown): 'image' | 'video' | 'text' {
|
||||
return value === 'video' || value === 'text' ? value : 'image';
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
const includeInactive = request.nextUrl.searchParams.get('includeInactive') !== 'false';
|
||||
return NextResponse.json({ apis: await listSystemApis(includeInactive) });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await readBody(request);
|
||||
if (!body.name?.trim() || !body.modelName?.trim()) {
|
||||
return NextResponse.json({ error: '请填写显示名称和模型名称' }, { status: 400 });
|
||||
}
|
||||
|
||||
const secret = encryptApiKeyForStorage(String(body.apiKey || ''));
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureSystemApiSchema(client);
|
||||
const result = await client.query(
|
||||
`INSERT INTO system_api_configs (
|
||||
provider, name, api_url, model_name, note, api_key_encrypted,
|
||||
api_key_preview, type, credits_per_use, is_active, sort_order
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||
COALESCE((SELECT MAX(sort_order) + 1 FROM system_api_configs), 0))
|
||||
RETURNING id, provider, name, api_url, model_name, note, api_key_preview,
|
||||
type, credits_per_use, is_active, sort_order, created_at, updated_at`,
|
||||
[
|
||||
String(body.provider || '').trim(),
|
||||
String(body.name).trim(),
|
||||
String(body.apiUrl || '').trim(),
|
||||
String(body.modelName).trim(),
|
||||
String(body.note || '').trim(),
|
||||
secret.encrypted,
|
||||
secret.preview,
|
||||
normalizeType(body.type),
|
||||
Number(body.creditsPerUse || 10),
|
||||
body.isActive !== false,
|
||||
],
|
||||
);
|
||||
return NextResponse.json({ api: toSafeSystemApi(result.rows[0]) });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await readBody(request);
|
||||
if (!isUuid(body.id) || !body.name?.trim() || !body.modelName?.trim()) {
|
||||
return NextResponse.json({ error: '缺少 API ID、显示名称或模型名称' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updates: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let idx = 1;
|
||||
const add = (column: string, value: unknown) => {
|
||||
updates.push(`${column} = $${idx++}`);
|
||||
params.push(value);
|
||||
};
|
||||
|
||||
add('provider', String(body.provider || '').trim());
|
||||
add('name', String(body.name).trim());
|
||||
add('api_url', String(body.apiUrl || '').trim());
|
||||
add('model_name', String(body.modelName).trim());
|
||||
add('note', String(body.note || '').trim());
|
||||
add('type', normalizeType(body.type));
|
||||
add('credits_per_use', Number(body.creditsPerUse || 10));
|
||||
add('is_active', body.isActive !== false);
|
||||
if (body.sortOrder !== undefined) add('sort_order', Number(body.sortOrder || 0));
|
||||
|
||||
if (typeof body.apiKey === 'string' && body.apiKey.trim() && body.apiKey !== '********') {
|
||||
const secret = encryptApiKeyForStorage(body.apiKey);
|
||||
add('api_key_encrypted', secret.encrypted);
|
||||
add('api_key_preview', secret.preview);
|
||||
}
|
||||
if (body.clearApiKey === true) {
|
||||
add('api_key_encrypted', '');
|
||||
add('api_key_preview', '');
|
||||
}
|
||||
updates.push('updated_at = NOW()');
|
||||
params.push(body.id);
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureSystemApiSchema(client);
|
||||
const result = await client.query(
|
||||
`UPDATE system_api_configs
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = $${idx}
|
||||
RETURNING id, provider, name, api_url, model_name, note, api_key_preview,
|
||||
type, credits_per_use, is_active, sort_order, created_at, updated_at`,
|
||||
params,
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: '系统 API 不存在' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ api: toSafeSystemApi(result.rows[0]) });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
const body = await readBody(request);
|
||||
const id = body.id || request.nextUrl.searchParams.get('id');
|
||||
if (!isUuid(id)) return NextResponse.json({ error: '缺少 API ID' }, { status: 400 });
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureSystemApiSchema(client);
|
||||
await client.query('DELETE FROM system_api_configs WHERE id = $1', [id]);
|
||||
return NextResponse.json({ success: true });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
74
src/app/api/admin/users/route.ts
Normal file
74
src/app/api/admin/users/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { deleteAdminUser, listAdminUsers, updateAdminUser } from '@/lib/admin-users-service';
|
||||
|
||||
function getTokenUserId(request: NextRequest): string | null {
|
||||
const header = request.headers.get('authorization') || '';
|
||||
const token = header.replace(/^Bearer\s+/i, '').trim();
|
||||
const match = token.match(/^token-[a-z_]+-([0-9a-fA-F-]{36})-\d+$/);
|
||||
return match?.[1] || null;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const params = request.nextUrl.searchParams;
|
||||
const result = await listAdminUsers(client, {
|
||||
search: params.get('search') || params.get('q') || '',
|
||||
page: Number(params.get('page') || '1'),
|
||||
pageSize: Number(params.get('pageSize') || params.get('limit') || '20'),
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[admin/users] GET error:', err);
|
||||
return NextResponse.json({ error: '获取用户列表失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await updateAdminUser(client, body);
|
||||
return NextResponse.json(result.body, { status: result.status });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[admin/users] PUT error:', err);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const userId = body.userId || body.id || request.nextUrl.searchParams.get('userId') || request.nextUrl.searchParams.get('id');
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await deleteAdminUser(client, String(userId || ''), getTokenUserId(request));
|
||||
return NextResponse.json(result.body, { status: result.status });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[admin/users] DELETE error:', err);
|
||||
return NextResponse.json({ error: '删除用户失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
124
src/app/api/announcements/route.ts
Normal file
124
src/app/api/announcements/route.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
|
||||
function toPublicAnnouncement(row: Record<string, unknown>) {
|
||||
const startsAt = row.starts_at ?? row.start_date ?? null;
|
||||
const expiresAt = row.expires_at ?? row.end_date ?? null;
|
||||
const isActive = row.is_active ?? row.enabled ?? true;
|
||||
|
||||
return {
|
||||
...row,
|
||||
enabled: isActive !== false,
|
||||
start_date: startsAt,
|
||||
end_date: expiresAt,
|
||||
is_active: isActive !== false,
|
||||
starts_at: startsAt,
|
||||
expires_at: expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query('SELECT * FROM announcements ORDER BY created_at DESC');
|
||||
return NextResponse.json((result.rows || []).map(toPublicAnnouncement));
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { title, content, startDate, endDate, enabled } = body;
|
||||
|
||||
if (!title || !content || !startDate || !endDate) {
|
||||
return NextResponse.json({ error: '请填写完整公告信息' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const id = crypto.randomUUID();
|
||||
await client.query(
|
||||
'INSERT INTO announcements (id, title, content, is_active, starts_at, expires_at) VALUES ($1, $2, $3, $4, $5, $6)',
|
||||
[id, title, content, enabled !== false, new Date(startDate).toISOString(), new Date(endDate).toISOString()]
|
||||
);
|
||||
return NextResponse.json({ id, success: true });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[announcements] POST error:', err);
|
||||
return NextResponse.json({ error: '创建公告失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, title, content, startDate, endDate, enabled } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: '缺少公告ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const updates: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (title !== undefined) { updates.push(`title = $${paramIdx++}`); params.push(title); }
|
||||
if (content !== undefined) { updates.push(`content = $${paramIdx++}`); params.push(content); }
|
||||
if (startDate !== undefined) { updates.push(`starts_at = $${paramIdx++}`); params.push(new Date(startDate).toISOString()); }
|
||||
if (endDate !== undefined) { updates.push(`expires_at = $${paramIdx++}`); params.push(new Date(endDate).toISOString()); }
|
||||
if (enabled !== undefined) { updates.push(`is_active = $${paramIdx++}`); params.push(enabled); }
|
||||
updates.push(`updated_at = NOW()`);
|
||||
|
||||
params.push(id);
|
||||
await client.query(`UPDATE announcements SET ${updates.join(', ')} WHERE id = $${paramIdx}`, params);
|
||||
return NextResponse.json({ success: true });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[announcements] PUT error:', err);
|
||||
return NextResponse.json({ error: '更新公告失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: '缺少公告ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await client.query('DELETE FROM announcements WHERE id = $1', [id]);
|
||||
return NextResponse.json({ success: true });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[announcements] DELETE error:', err);
|
||||
return NextResponse.json({ error: '删除公告失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
78
src/app/api/auth/admin-exists/route.ts
Normal file
78
src/app/api/auth/admin-exists/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { ensureEmailSchema } from '@/lib/email-service';
|
||||
import { getRequiredProductionSecret, isProductionRuntime } from '@/lib/runtime-env';
|
||||
import { ensureProfilePreferenceSchema } from '@/lib/profile-preferences';
|
||||
|
||||
const ADMIN_EMAIL = 'admin@miaojing.ai';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const client = await getDbClient();
|
||||
|
||||
try {
|
||||
await ensureEmailSchema(client);
|
||||
await ensureProfilePreferenceSchema(client);
|
||||
const result = await client.query(
|
||||
'SELECT id, nickname FROM profiles WHERE role = $1 LIMIT 1',
|
||||
['admin']
|
||||
);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
return NextResponse.json({ exists: true, nickname: result.rows[0].nickname });
|
||||
}
|
||||
|
||||
if (isProductionRuntime()) {
|
||||
return NextResponse.json({ exists: false, autoCreated: false });
|
||||
}
|
||||
|
||||
getRequiredProductionSecret('ADMIN_DEFAULT_PASSWORD', 'admin123');
|
||||
|
||||
// Development only: bootstrap the default admin profile.
|
||||
const userId = crypto.randomUUID();
|
||||
|
||||
await client.query(
|
||||
'INSERT INTO auth.users (id, email, created_at) VALUES ($1, $2, NOW())',
|
||||
[userId, ADMIN_EMAIL]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO profiles (
|
||||
id, email, nickname, role, membership_tier, credits_balance,
|
||||
daily_quota_limit, daily_quota_used, is_active, email_verified,
|
||||
email_verified_at, email_bound_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true, NOW(), NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
role = $4,
|
||||
membership_tier = $5,
|
||||
credits_balance = $6,
|
||||
daily_quota_limit = $7,
|
||||
nickname = $3,
|
||||
email_verified = true,
|
||||
email_verified_at = COALESCE(profiles.email_verified_at, NOW()),
|
||||
email_bound_at = COALESCE(profiles.email_bound_at, NOW())`,
|
||||
[userId, ADMIN_EMAIL, '管理员', 'admin', 'enterprise', 9999, 999, 0, true]
|
||||
);
|
||||
|
||||
try {
|
||||
await client.query(
|
||||
'INSERT INTO credit_transactions (user_id, amount, balance_after, type, description) VALUES ($1, $2, $3, $4, $5)',
|
||||
[userId, 9999, 9999, 'gift', '管理员初始积分']
|
||||
);
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
console.log('[admin-exists] Default admin account created: account=admin, password=***');
|
||||
return NextResponse.json({
|
||||
exists: true,
|
||||
autoCreated: true,
|
||||
nickname: '管理员',
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[admin-exists] Error:', err);
|
||||
return NextResponse.json({ exists: false, error: '数据库连接失败' });
|
||||
}
|
||||
}
|
||||
146
src/app/api/auth/fetch-models/route.ts
Normal file
146
src/app/api/auth/fetch-models/route.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { buildCustomApiHeaders, fetchWithRetry, parseCustomApiError } from '@/lib/custom-api-fetch';
|
||||
|
||||
interface FetchModelsRequest {
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { apiUrl, apiKey, provider } = body as FetchModelsRequest;
|
||||
|
||||
if (!apiUrl || !apiKey) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请填写 API 请求地址和 API Key' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Derive the base URL from the apiUrl
|
||||
const baseUrl = apiUrl.replace(/\/images\/generations.*/, '').replace(/\/videos\/generations.*/, '').replace(/\/chat\/completions.*/, '').replace(/\/+$/, '');
|
||||
const modelsUrl = `${baseUrl}/models`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchWithRetry(
|
||||
modelsUrl,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: buildCustomApiHeaders(apiKey),
|
||||
},
|
||||
15_000,
|
||||
0, // no retry
|
||||
);
|
||||
} catch (fetchError: unknown) {
|
||||
const msg = fetchError instanceof Error ? fetchError.message : '请求失败';
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: `网络错误: ${msg}`,
|
||||
suggestion: '请检查 API 地址是否正确、网络是否可达',
|
||||
});
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
try {
|
||||
const data = await response.json();
|
||||
if (Array.isArray(data.data)) {
|
||||
const models = data.data.map((m: Record<string, unknown>) => ({
|
||||
id: typeof m.id === 'string' ? m.id : '',
|
||||
name: typeof m.name === 'string' ? m.name : '',
|
||||
description: typeof m.description === 'string' ? m.description : '',
|
||||
provider: provider,
|
||||
})).filter((m: { id: string }) => m.id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
models: models,
|
||||
message: `成功获取 ${models.length} 个模型`,
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'API 返回的数据格式不正确',
|
||||
suggestion: '请检查 API 地址是否正确,确保它支持 /models 端点',
|
||||
});
|
||||
}
|
||||
} catch (parseError) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '解析模型数据失败',
|
||||
suggestion: 'API 返回的数据格式可能不正确',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const errorText = await response.text().catch(() => '');
|
||||
const isHtml = errorText.trim().startsWith('<!') || errorText.trim().startsWith('<html') || errorText.trim().startsWith('<HTML');
|
||||
|
||||
const parsed = isHtml
|
||||
? { error: parseCustomApiError(response.status, errorText), suggestion: '' }
|
||||
: parseApiError(response.status, errorText);
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: parsed.error,
|
||||
statusCode: response.status,
|
||||
suggestion: parsed.suggestion || getDiagnosticSuggestion(response.status, isHtml),
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : '获取模型列表失败';
|
||||
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diagnostic suggestion based on response status and content type
|
||||
*/
|
||||
function getDiagnosticSuggestion(statusCode: number, isHtml: boolean): string {
|
||||
if (isHtml) {
|
||||
if (statusCode === 502 || statusCode === 503 || statusCode === 504) {
|
||||
return 'API 代理(如 Cloudflare)返回错误。你的 API 在本地可用但部署环境不可用时,通常是代理防火墙拦截了服务器请求。建议:①检查 API 代理的 WAF/防火墙设置 ②将服务器 IP 加入白名单 ③尝试使用 API 的直连地址(绕过 Cloudflare)';
|
||||
}
|
||||
if (statusCode === 403) {
|
||||
return '代理防火墙拦截了请求。建议:①检查 Cloudflare WAF 规则 ②将服务器 IP 加入白名单 ③使用 API 的直连地址';
|
||||
}
|
||||
return 'API 返回了错误页面而非 JSON 响应,可能是代理防火墙拦截。建议使用 API 的直连地址(绕过 CDN/代理)';
|
||||
}
|
||||
|
||||
const suggestions: Record<number, string> = {
|
||||
401: 'API Key 无效或已过期,请检查密钥是否正确',
|
||||
403: '账户无权限访问该模型,请检查账户状态',
|
||||
404: 'API 地址不正确,请确认完整的请求端点 URL',
|
||||
429: '请求频率过高或账户余额不足',
|
||||
500: 'API 服务端内部错误,请稍后重试',
|
||||
502: 'API 网关错误。可能原因:①API 服务端宕机 ②代理防火墙拦截了服务器 IP',
|
||||
503: '服务暂不可用。可能原因:①账户余额不足 ②服务维护中 ③代理限制了服务器IP',
|
||||
};
|
||||
|
||||
return suggestions[statusCode] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse common API error status codes and bodies into user-friendly messages
|
||||
*/
|
||||
function parseApiError(statusCode: number, errorBody: string): { error: string; suggestion: string } {
|
||||
// Delegate HTML detection to shared utility
|
||||
const friendlyError = parseCustomApiError(statusCode, errorBody);
|
||||
|
||||
const suggestions: Record<number, string> = {
|
||||
401: 'API Key 无效或已过期,请检查密钥是否正确',
|
||||
403: '账户无权限访问该模型,请检查账户状态',
|
||||
404: 'API 地址不正确,请确认完整的请求端点 URL',
|
||||
429: '请求频率过高或账户余额不足',
|
||||
500: 'API 服务端内部错误,请稍后重试',
|
||||
502: 'API 网关错误。可能原因:①API 服务端宕机 ②代理防火墙拦截了服务器 IP',
|
||||
503: '服务暂不可用。可能原因:①账户余额不足 ②服务维护中 ③代理限制了服务器IP',
|
||||
};
|
||||
|
||||
return {
|
||||
error: friendlyError,
|
||||
suggestion: suggestions[statusCode] || '',
|
||||
};
|
||||
}
|
||||
290
src/app/api/auth/login/route.ts
Normal file
290
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { ensureEmailSchema } from '@/lib/email-service';
|
||||
import { createSessionToken } from '@/lib/session-auth';
|
||||
import { getRequiredProductionSecret } from '@/lib/runtime-env';
|
||||
import { writePlatformLog } from '@/lib/platform-logs';
|
||||
import { ensureProfilePreferenceSchema, normalizePreferredTheme } from '@/lib/profile-preferences';
|
||||
|
||||
function normalizeRoleForTier(role: string | null | undefined, tier: string | null | undefined): string {
|
||||
const currentRole = role || 'user';
|
||||
if (currentRole === 'admin' || currentRole === 'enterprise_admin') return currentRole;
|
||||
return tier && tier !== 'free' ? 'vip' : currentRole === 'vip' ? 'user' : currentRole;
|
||||
}
|
||||
|
||||
async function verifyPasswordHash(client: Awaited<ReturnType<typeof getDbClient>>, passwordHash: string, password: string): Promise<boolean> {
|
||||
const result = await client.query(
|
||||
'SELECT $1::text = crypt($2::text, $1::text) AS ok',
|
||||
[passwordHash, password]
|
||||
);
|
||||
return result.rows[0]?.ok === true;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email: rawEmail, account, phone: rawPhone, password, adminOnly } = body;
|
||||
|
||||
const identifier = account || rawEmail || rawPhone;
|
||||
if (!identifier || !password) {
|
||||
return NextResponse.json({ error: 'Please enter account and password' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
|
||||
try {
|
||||
await ensureEmailSchema(client);
|
||||
await ensureProfilePreferenceSchema(client);
|
||||
let loginEmail = identifier;
|
||||
let userId = '';
|
||||
let userRole = 'user';
|
||||
let userNickname = '';
|
||||
let userMembershipTier = 'free';
|
||||
let userCreditsBalance = 0;
|
||||
let userDailyQuotaUsed = 0;
|
||||
let userDailyQuotaLimit = 5;
|
||||
let userAvatarUrl: string | null = null;
|
||||
let userPhone: string | null = null;
|
||||
let userCreatedAt: string | null = null;
|
||||
let userEmailVerified = false;
|
||||
let userEmailVerifiedAt: string | null = null;
|
||||
let userPreferredTheme: 'dark' | 'light' = 'dark';
|
||||
|
||||
const isEmailFormat = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(identifier);
|
||||
let isAdminAccount = false;
|
||||
let adminProfileId: string | null = null;
|
||||
|
||||
if (!isEmailFormat) {
|
||||
const adminLookup = await client.query(
|
||||
"SELECT id, email, nickname, role FROM profiles WHERE (nickname = $1 OR phone = $1) AND role = 'admin' LIMIT 1",
|
||||
[identifier]
|
||||
);
|
||||
if (adminLookup.rows.length > 0) {
|
||||
isAdminAccount = true;
|
||||
adminProfileId = adminLookup.rows[0].id;
|
||||
loginEmail = adminLookup.rows[0].email;
|
||||
userNickname = adminLookup.rows[0].nickname || '';
|
||||
} else {
|
||||
const nicknameLower = String(identifier).toLowerCase();
|
||||
if (nicknameLower === 'admin' || nicknameLower.startsWith('admin')) {
|
||||
const anyLookup = await client.query(
|
||||
"SELECT id, email, nickname, role FROM profiles WHERE role = 'admin' ORDER BY created_at ASC LIMIT 1"
|
||||
);
|
||||
if (anyLookup.rows.length > 0) {
|
||||
isAdminAccount = true;
|
||||
adminProfileId = anyLookup.rows[0].id;
|
||||
loginEmail = anyLookup.rows[0].email;
|
||||
userNickname = anyLookup.rows[0].nickname || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const adminLookup = await client.query(
|
||||
"SELECT id, email, nickname, role FROM profiles WHERE email = $1 AND role = 'admin' LIMIT 1",
|
||||
[identifier]
|
||||
);
|
||||
if (adminLookup.rows.length > 0) {
|
||||
isAdminAccount = true;
|
||||
adminProfileId = adminLookup.rows[0].id;
|
||||
loginEmail = identifier;
|
||||
userNickname = adminLookup.rows[0].nickname || '';
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdminAccount) {
|
||||
const authResult = await client.query(
|
||||
'SELECT id, email, created_at, password_hash FROM auth.users WHERE email = $1',
|
||||
[loginEmail]
|
||||
);
|
||||
|
||||
if (authResult.rows.length > 0 && authResult.rows[0].password_hash) {
|
||||
const passwordOk = await verifyPasswordHash(client, authResult.rows[0].password_hash, password);
|
||||
if (!passwordOk) {
|
||||
return NextResponse.json({ error: 'Invalid admin password' }, { status: 401 });
|
||||
}
|
||||
} else if (password !== getRequiredProductionSecret('ADMIN_DEFAULT_PASSWORD', 'admin123')) {
|
||||
return NextResponse.json({ error: 'Invalid admin password' }, { status: 401 });
|
||||
}
|
||||
|
||||
userRole = 'admin';
|
||||
userMembershipTier = 'enterprise';
|
||||
userCreditsBalance = 9999;
|
||||
userDailyQuotaLimit = 999;
|
||||
userNickname = userNickname || '管理员';
|
||||
userEmailVerified = true;
|
||||
userEmailVerifiedAt = new Date().toISOString();
|
||||
|
||||
if (authResult.rows.length > 0) {
|
||||
userId = authResult.rows[0].id;
|
||||
userCreatedAt = authResult.rows[0].created_at;
|
||||
} else if (adminProfileId) {
|
||||
userId = adminProfileId;
|
||||
await client.query(
|
||||
'INSERT INTO auth.users (id, email, created_at) VALUES ($1, $2, NOW()) ON CONFLICT (id) DO NOTHING',
|
||||
[userId, loginEmail]
|
||||
);
|
||||
userCreatedAt = new Date().toISOString();
|
||||
} else {
|
||||
userId = crypto.randomUUID();
|
||||
await client.query(
|
||||
'INSERT INTO auth.users (id, email, created_at) VALUES ($1, $2, NOW())',
|
||||
[userId, loginEmail]
|
||||
);
|
||||
userCreatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO profiles (id, email, nickname, role, membership_tier, credits_balance, daily_quota_limit, daily_quota_used, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
role = $4,
|
||||
membership_tier = $5,
|
||||
credits_balance = $6,
|
||||
daily_quota_limit = $7,
|
||||
nickname = $3,
|
||||
is_active = true,
|
||||
email_verified = true,
|
||||
email_verified_at = COALESCE(profiles.email_verified_at, NOW()),
|
||||
email_bound_at = COALESCE(profiles.email_bound_at, NOW())`,
|
||||
[userId, loginEmail, userNickname, 'admin', 'enterprise', 9999, 999, 0, true]
|
||||
);
|
||||
|
||||
const adminThemeResult = await client.query(
|
||||
'SELECT preferred_theme FROM profiles WHERE id = $1 LIMIT 1',
|
||||
[userId]
|
||||
);
|
||||
userPreferredTheme = normalizePreferredTheme(adminThemeResult.rows[0]?.preferred_theme);
|
||||
|
||||
if (adminProfileId && adminProfileId !== userId) {
|
||||
await client.query(
|
||||
'UPDATE profiles SET role = $1, membership_tier = $2, credits_balance = $3, daily_quota_limit = $4 WHERE id = $5',
|
||||
['admin', 'enterprise', 9999, 999, adminProfileId]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!isEmailFormat) {
|
||||
const profileResult = await client.query(
|
||||
'SELECT id, email, nickname, phone, role FROM profiles WHERE nickname = $1 OR phone = $1 LIMIT 1',
|
||||
[identifier]
|
||||
);
|
||||
|
||||
if (profileResult.rows.length > 0) {
|
||||
const profile = profileResult.rows[0];
|
||||
loginEmail = profile.email;
|
||||
userId = profile.id;
|
||||
userRole = profile.role || 'user';
|
||||
userNickname = profile.nickname;
|
||||
userPhone = profile.phone;
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Account does not exist' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const authResult = await client.query(
|
||||
'SELECT id, email, created_at, password_hash FROM auth.users WHERE email = $1',
|
||||
[loginEmail]
|
||||
);
|
||||
|
||||
if (authResult.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Account does not exist' }, { status: 401 });
|
||||
}
|
||||
|
||||
const authUser = authResult.rows[0];
|
||||
if (authUser.password_hash) {
|
||||
const passwordOk = await verifyPasswordHash(client, authUser.password_hash, password);
|
||||
if (!passwordOk) {
|
||||
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json({ error: '该账号缺少密码凭据,请联系管理员重置密码后再登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
userId = authUser.id;
|
||||
userCreatedAt = authUser.created_at;
|
||||
|
||||
const profileResult = await client.query(
|
||||
'SELECT nickname, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit, avatar_url, phone, email_verified, email_verified_at, preferred_theme FROM profiles WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (profileResult.rows.length > 0) {
|
||||
const profile = profileResult.rows[0];
|
||||
userNickname = profile.nickname || loginEmail.split('@')[0];
|
||||
userMembershipTier = profile.membership_tier || 'free';
|
||||
userRole = normalizeRoleForTier(profile.role, userMembershipTier);
|
||||
userCreditsBalance = profile.credits_balance || 0;
|
||||
userDailyQuotaUsed = profile.daily_quota_used || 0;
|
||||
userDailyQuotaLimit = profile.daily_quota_limit || 5;
|
||||
userAvatarUrl = profile.avatar_url || null;
|
||||
userPhone = profile.phone || null;
|
||||
userEmailVerified = profile.email_verified === true;
|
||||
userEmailVerifiedAt = profile.email_verified_at || null;
|
||||
userPreferredTheme = normalizePreferredTheme(profile.preferred_theme);
|
||||
if (userRole !== (profile.role || 'user')) {
|
||||
await client.query('UPDATE profiles SET role = $1, updated_at = NOW() WHERE id = $2', [userRole, userId]);
|
||||
}
|
||||
} else {
|
||||
userNickname = loginEmail.split('@')[0];
|
||||
await client.query(
|
||||
`INSERT INTO profiles (id, email, nickname, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (id) DO UPDATE SET email = $2, nickname = $3, email_verified = false, email_verified_at = NULL`,
|
||||
[userId, loginEmail, userNickname, userRole, userMembershipTier, userCreditsBalance, userDailyQuotaUsed, userDailyQuotaLimit]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (adminOnly === true && userRole !== 'admin' && userRole !== 'enterprise_admin') {
|
||||
void writePlatformLog({
|
||||
type: 'security',
|
||||
level: 'warning',
|
||||
action: 'console_login_denied',
|
||||
message: '非管理员账号尝试登录管理后台被拒绝',
|
||||
userId,
|
||||
userName: userNickname,
|
||||
userEmail: loginEmail,
|
||||
request,
|
||||
});
|
||||
return NextResponse.json({ error: 'Only administrators can log in to the console' }, { status: 403 });
|
||||
}
|
||||
|
||||
const accessToken = createSessionToken(userId, userRole);
|
||||
void writePlatformLog({
|
||||
type: 'auth',
|
||||
level: 'info',
|
||||
action: adminOnly === true ? 'console_login_success' : 'user_login_success',
|
||||
message: adminOnly === true ? '管理员登录管理后台成功' : '用户登录成功',
|
||||
userId,
|
||||
userName: userNickname,
|
||||
userEmail: loginEmail,
|
||||
request,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: userId,
|
||||
email: loginEmail,
|
||||
nickname: userNickname,
|
||||
role: userRole,
|
||||
membership_tier: userMembershipTier,
|
||||
credits_balance: userCreditsBalance,
|
||||
daily_quota_used: userDailyQuotaUsed,
|
||||
daily_quota_limit: userDailyQuotaLimit,
|
||||
avatar_url: userAvatarUrl,
|
||||
phone: userPhone,
|
||||
created_at: userCreatedAt,
|
||||
email_verified: userEmailVerified,
|
||||
email_verified_at: userEmailVerifiedAt,
|
||||
preferred_theme: userPreferredTheme,
|
||||
},
|
||||
session: { access_token: accessToken },
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Login failed';
|
||||
console.error('[Login Error]', message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
175
src/app/api/auth/register/route.ts
Normal file
175
src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { ensureEmailSchema, getRequestBaseUrl, normalizeEmail, sendTemplatedEmail, verifyEmailCode } from '@/lib/email-service';
|
||||
import { getRequiredProductionSecret } from '@/lib/runtime-env';
|
||||
import { ensureProfilePreferenceSchema } from '@/lib/profile-preferences';
|
||||
|
||||
function isStrongPassword(password: string): boolean {
|
||||
return password.length >= 8 && /[A-Za-z]/.test(password) && /\d/.test(password);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { email, password, nickname, phone, inviteCode, emailCode, acceptedTerms } = await request.json();
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
|
||||
if (!normalizedEmail || !password) {
|
||||
return NextResponse.json({ error: 'Please enter email and password' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (acceptedTerms !== true) {
|
||||
return NextResponse.json({ error: '请先阅读并同意服务条款和隐私政策' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isStrongPassword(password)) {
|
||||
return NextResponse.json({ error: '密码至少 8 位,并同时包含字母和数字' }, { status: 400 });
|
||||
}
|
||||
|
||||
const isAdminRegistration = typeof inviteCode === 'string'
|
||||
&& inviteCode === getRequiredProductionSecret('ADMIN_INVITE_CODE', 'miaojing-admin-2024');
|
||||
const client = await getDbClient();
|
||||
|
||||
try {
|
||||
await ensureEmailSchema(client);
|
||||
await ensureProfilePreferenceSchema(client);
|
||||
if (isAdminRegistration) {
|
||||
const existingAdminResult = await client.query(
|
||||
'SELECT id FROM profiles WHERE role = $1',
|
||||
['admin']
|
||||
);
|
||||
|
||||
if (existingAdminResult.rows.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Admin account already exists' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existingUserResult = await client.query(
|
||||
'SELECT id FROM profiles WHERE email = $1',
|
||||
[normalizedEmail]
|
||||
);
|
||||
|
||||
if (existingUserResult.rows.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email is already registered' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = crypto.randomUUID();
|
||||
|
||||
if (!isAdminRegistration) {
|
||||
if (typeof emailCode !== 'string' || !/^[a-z0-9]{4,10}$/i.test(emailCode)) {
|
||||
return NextResponse.json({ error: '请输入正确的邮箱验证码' }, { status: 400 });
|
||||
}
|
||||
await client.query('BEGIN');
|
||||
try {
|
||||
await verifyEmailCode(client, {
|
||||
email: normalizedEmail,
|
||||
type: 'register',
|
||||
code: typeof emailCode === 'string' ? emailCode : '',
|
||||
});
|
||||
await client.query('COMMIT');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO auth.users (id, email, password_hash, created_at)
|
||||
VALUES ($1, $2, crypt($3, gen_salt('bf')), NOW())`,
|
||||
[userId, normalizedEmail, password]
|
||||
);
|
||||
|
||||
const role = isAdminRegistration ? 'admin' : 'user';
|
||||
const membershipTier = isAdminRegistration ? 'enterprise' : 'free';
|
||||
const creditsBalance = isAdminRegistration ? 9999 : 10;
|
||||
const dailyQuotaLimit = isAdminRegistration ? 999 : 5;
|
||||
const displayName = nickname || normalizedEmail.split('@')[0];
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO profiles (
|
||||
id, email, nickname, phone, role, membership_tier, credits_balance,
|
||||
daily_quota_limit, daily_quota_used, is_active, email_verified,
|
||||
email_verified_at, email_bound_at, email_sender_domain
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, CASE WHEN $11 THEN NOW() ELSE NULL END, CASE WHEN $11 THEN NOW() ELSE NULL END, $12)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
nickname = EXCLUDED.nickname,
|
||||
phone = EXCLUDED.phone,
|
||||
role = EXCLUDED.role,
|
||||
membership_tier = EXCLUDED.membership_tier,
|
||||
credits_balance = EXCLUDED.credits_balance,
|
||||
daily_quota_limit = EXCLUDED.daily_quota_limit,
|
||||
daily_quota_used = EXCLUDED.daily_quota_used,
|
||||
is_active = EXCLUDED.is_active,
|
||||
email_verified = EXCLUDED.email_verified,
|
||||
email_verified_at = EXCLUDED.email_verified_at,
|
||||
email_bound_at = EXCLUDED.email_bound_at,
|
||||
email_sender_domain = EXCLUDED.email_sender_domain`,
|
||||
[
|
||||
userId,
|
||||
normalizedEmail,
|
||||
displayName,
|
||||
phone || null,
|
||||
role,
|
||||
membershipTier,
|
||||
creditsBalance,
|
||||
dailyQuotaLimit,
|
||||
0,
|
||||
true,
|
||||
true,
|
||||
normalizedEmail.split('@')[1] || null,
|
||||
]
|
||||
);
|
||||
|
||||
try {
|
||||
await client.query(
|
||||
'INSERT INTO credit_transactions (user_id, amount, balance_after, type, description) VALUES ($1, $2, $3, $4, $5)',
|
||||
[userId, creditsBalance, creditsBalance, 'gift', isAdminRegistration ? 'Admin initial credits' : 'New user registration bonus']
|
||||
);
|
||||
} catch {
|
||||
// Ignore credit transaction errors.
|
||||
}
|
||||
|
||||
await sendTemplatedEmail(client, {
|
||||
to: normalizedEmail,
|
||||
type: 'register_success',
|
||||
subject: '【妙境】注册成功',
|
||||
title: '注册成功',
|
||||
intro: isAdminRegistration ? '管理员账号已创建成功。' : '你的妙境账号已注册成功,邮箱也已完成验证。',
|
||||
note: '若非本人操作,请尽快联系管理员。',
|
||||
assetBaseUrl: getRequestBaseUrl(request) || undefined,
|
||||
}).catch(() => undefined);
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: userId,
|
||||
email: normalizedEmail,
|
||||
nickname: displayName,
|
||||
role,
|
||||
membership_tier: membershipTier,
|
||||
credits_balance: creditsBalance,
|
||||
daily_quota_used: 0,
|
||||
daily_quota_limit: dailyQuotaLimit,
|
||||
avatar_url: null,
|
||||
phone: phone || null,
|
||||
email_verified: true,
|
||||
email_verified_at: new Date().toISOString(),
|
||||
preferred_theme: 'dark',
|
||||
},
|
||||
message: isAdminRegistration ? 'Admin account registered' : 'Registration successful',
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Registration failed';
|
||||
console.error('[Register Error]', message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
215
src/app/api/auth/test-api/route.ts
Normal file
215
src/app/api/auth/test-api/route.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { buildCustomApiHeaders, fetchWithRetry, parseCustomApiError } from '@/lib/custom-api-fetch';
|
||||
|
||||
interface TestApiRequest {
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
modelName: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { apiUrl, apiKey, modelName, provider } = body as TestApiRequest;
|
||||
|
||||
if (!apiUrl || !apiKey) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请填写 API 请求地址和 API Key' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Step 1: Quick connectivity check with a lightweight request ----
|
||||
// Try the /models endpoint first (most APIs support this, no cost)
|
||||
// Derive the base URL from the apiUrl
|
||||
const baseUrl = apiUrl.replace(/\/images\/generations.*/, '').replace(/\/videos\/generations.*/, '').replace(/\/chat\/completions.*/, '').replace(/\/+$/, '');
|
||||
const modelsUrl = `${baseUrl}/models`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchWithRetry(
|
||||
modelsUrl,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: buildCustomApiHeaders(apiKey),
|
||||
},
|
||||
15_000,
|
||||
0, // no retry for test - keep it fast
|
||||
);
|
||||
} catch (fetchError: unknown) {
|
||||
// If /models fails with timeout or network error, try the actual endpoint
|
||||
if (fetchError instanceof DOMException && fetchError.name === 'AbortError') {
|
||||
return await testActualEndpoint(apiUrl, apiKey, modelName || 'gpt-image-2');
|
||||
}
|
||||
|
||||
const msg = fetchError instanceof Error ? fetchError.message : '请求失败';
|
||||
|
||||
// Network error - could be DNS, connection refused, or firewall
|
||||
if (msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND') || msg.includes('fetch failed')) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: `无法连接到 API 地址: ${msg}`,
|
||||
suggestion: '请检查 API 地址是否正确、服务是否运行。常见原因:①地址拼写错误 ②服务未启动 ③DNS 无法解析',
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: `网络错误: ${msg}`,
|
||||
suggestion: '请检查 API 地址是否正确、网络是否可达。如果使用了代理(如 Cloudflare),可能代理防火墙拦截了服务器请求',
|
||||
});
|
||||
}
|
||||
|
||||
// If /models returned successfully, the key is valid
|
||||
if (response.ok) {
|
||||
let modelInfo = '';
|
||||
try {
|
||||
const data = await response.json();
|
||||
if (Array.isArray(data.data)) {
|
||||
const targetModel = modelName || 'gpt-image-2';
|
||||
const found = data.data.some((m: Record<string, unknown>) =>
|
||||
typeof m.id === 'string' && m.id.includes(targetModel.replace('gpt-image-2', 'dall'))
|
||||
);
|
||||
modelInfo = found ? `,模型 ${modelName || 'gpt-image-2'} 可用` : `,已连接(共 ${data.data.length} 个模型)`;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse error, connectivity is confirmed
|
||||
}
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `连接成功${modelInfo}`,
|
||||
});
|
||||
}
|
||||
|
||||
// /models returned an error - check if it's HTML (Cloudflare block)
|
||||
const errorText = await response.text().catch(() => '');
|
||||
const isHtml = errorText.trim().startsWith('<!') || errorText.trim().startsWith('<html') || errorText.trim().startsWith('<HTML');
|
||||
|
||||
if (response.status === 404 && !isHtml) {
|
||||
// /models not supported (not a Cloudflare error), try actual endpoint
|
||||
return await testActualEndpoint(apiUrl, apiKey, modelName || 'gpt-image-2');
|
||||
}
|
||||
|
||||
// Auth/permission error or Cloudflare block
|
||||
const parsed = isHtml
|
||||
? { error: parseCustomApiError(response.status, errorText), suggestion: '' }
|
||||
: parseApiError(response.status, errorText);
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: parsed.error,
|
||||
statusCode: response.status,
|
||||
suggestion: parsed.suggestion || getDiagnosticSuggestion(response.status, isHtml),
|
||||
});
|
||||
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : '测试连接失败';
|
||||
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: test by sending a minimal request to the actual generation endpoint
|
||||
*/
|
||||
async function testActualEndpoint(apiUrl: string, apiKey: string, modelName: string): Promise<NextResponse> {
|
||||
try {
|
||||
const response = await fetchWithRetry(
|
||||
apiUrl,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildCustomApiHeaders(apiKey),
|
||||
body: JSON.stringify({
|
||||
model: modelName,
|
||||
prompt: 'test',
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
}),
|
||||
},
|
||||
15_000,
|
||||
0, // no retry for test
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `连接成功,模型 ${modelName} 可用`,
|
||||
});
|
||||
}
|
||||
|
||||
const errorText = await response.text().catch(() => '');
|
||||
const parsed = parseApiError(response.status, errorText);
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: parsed.error,
|
||||
statusCode: response.status,
|
||||
suggestion: parsed.suggestion,
|
||||
});
|
||||
} catch (fetchError: unknown) {
|
||||
if (fetchError instanceof DOMException && fetchError.name === 'AbortError') {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '连接超时(15秒),请检查 API 地址是否正确',
|
||||
suggestion: '可能原因:①API 地址有误 ②服务响应过慢 ③代理限制了服务器IP访问',
|
||||
});
|
||||
}
|
||||
|
||||
const msg = fetchError instanceof Error ? fetchError.message : '请求失败';
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: `网络错误: ${msg}`,
|
||||
suggestion: '请检查 API 地址和网络连通性',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diagnostic suggestion based on response status and content type
|
||||
*/
|
||||
function getDiagnosticSuggestion(statusCode: number, isHtml: boolean): string {
|
||||
if (isHtml) {
|
||||
if (statusCode === 502 || statusCode === 503 || statusCode === 504) {
|
||||
return 'API 代理(如 Cloudflare)返回错误。你的 API 在本地可用但部署环境不可用时,通常是代理防火墙拦截了服务器请求。建议:①检查 API 代理的 WAF/防火墙设置 ②将服务器 IP 加入白名单 ③尝试使用 API 的直连地址(绕过 Cloudflare)';
|
||||
}
|
||||
if (statusCode === 403) {
|
||||
return '代理防火墙拦截了请求。建议:①检查 Cloudflare WAF 规则 ②将服务器 IP 加入白名单 ③使用 API 的直连地址';
|
||||
}
|
||||
return 'API 返回了错误页面而非 JSON 响应,可能是代理防火墙拦截。建议使用 API 的直连地址(绕过 CDN/代理)';
|
||||
}
|
||||
|
||||
const suggestions: Record<number, string> = {
|
||||
401: 'API Key 无效或已过期,请检查密钥是否正确',
|
||||
403: '账户无权限访问该模型,请检查账户状态',
|
||||
404: 'API 地址不正确,请确认完整的请求端点 URL',
|
||||
429: '请求频率过高或账户余额不足',
|
||||
500: 'API 服务端内部错误,请稍后重试',
|
||||
502: 'API 网关错误。可能原因:①API 服务端宕机 ②代理防火墙拦截了服务器 IP',
|
||||
503: '服务暂不可用。可能原因:①账户余额不足 ②服务维护中 ③代理限制了服务器IP',
|
||||
};
|
||||
|
||||
return suggestions[statusCode] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse common API error status codes and bodies into user-friendly messages
|
||||
*/
|
||||
function parseApiError(statusCode: number, errorBody: string): { error: string; suggestion: string } {
|
||||
// Delegate HTML detection to shared utility
|
||||
const friendlyError = parseCustomApiError(statusCode, errorBody);
|
||||
|
||||
const suggestions: Record<number, string> = {
|
||||
401: 'API Key 无效或已过期,请检查密钥是否正确',
|
||||
403: '账户无权限访问该模型,请检查账户状态',
|
||||
404: 'API 地址不正确,请确认完整的请求端点 URL',
|
||||
429: '请求频率过高或账户余额不足',
|
||||
500: 'API 服务端内部错误,请稍后重试',
|
||||
502: 'API 网关错误。可能原因:①API 服务端宕机 ②代理防火墙拦截了服务器 IP',
|
||||
503: '服务暂不可用。可能原因:①账户余额不足 ②服务维护中 ③代理限制了服务器IP',
|
||||
};
|
||||
|
||||
return {
|
||||
error: friendlyError,
|
||||
suggestion: suggestions[statusCode] || '',
|
||||
};
|
||||
}
|
||||
143
src/app/api/creation-history/route.ts
Normal file
143
src/app/api/creation-history/route.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
|
||||
function toWorkType(type: string, params: Record<string, unknown>): string {
|
||||
const explicitMode = params.creationMode || params.workType || params.mode;
|
||||
if (explicitMode === 'text2img' || explicitMode === 'img2img' || explicitMode === 'text2video' || explicitMode === 'img2video' || explicitMode === 'reverse-prompt') {
|
||||
return explicitMode;
|
||||
}
|
||||
if (type === 'reverse-prompt') return 'reverse-prompt';
|
||||
const hasReference = Boolean(params.referenceImage)
|
||||
|| (Array.isArray(params.referenceImages) && params.referenceImages.length > 0)
|
||||
|| Number(params.refImageCount || 0) > 0;
|
||||
if (type === 'video') return hasReference ? 'img2video' : 'text2video';
|
||||
return hasReference ? 'img2img' : 'text2img';
|
||||
}
|
||||
|
||||
function fromWorkType(type: string): 'image' | 'video' | 'reverse-prompt' {
|
||||
if (type === 'reverse-prompt') return 'reverse-prompt';
|
||||
return type.includes('video') ? 'video' : 'image';
|
||||
}
|
||||
|
||||
function mapWork(row: Record<string, unknown>) {
|
||||
const params = (row.params || {}) as Record<string, unknown>;
|
||||
return {
|
||||
id: row.id,
|
||||
type: fromWorkType(String(row.type || 'text2img')),
|
||||
url: row.result_url,
|
||||
prompt: row.prompt || '',
|
||||
negativePrompt: row.negative_prompt || undefined,
|
||||
model: params.model || '',
|
||||
modelLabel: params.modelLabel || params.model || '',
|
||||
isCustomModel: Boolean(params.isCustomModel),
|
||||
params,
|
||||
referenceImage: params.referenceImage,
|
||||
referenceImages: Array.isArray(params.referenceImages)
|
||||
? params.referenceImages
|
||||
: params.referenceImage
|
||||
? [params.referenceImage]
|
||||
: undefined,
|
||||
published: row.is_public === true,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`SELECT id, type, prompt, negative_prompt, params, result_url, is_public, status, created_at
|
||||
FROM works
|
||||
WHERE user_id = $1 AND status = 'completed'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 300`,
|
||||
[userId],
|
||||
);
|
||||
return NextResponse.json({ records: result.rows.map(mapWork) });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
const body = await request.json();
|
||||
const records = Array.isArray(body.records) ? body.records : [body];
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const saved = [];
|
||||
for (const record of records) {
|
||||
const params = {
|
||||
...(record.params || {}),
|
||||
model: record.model || (record.params || {}).model,
|
||||
modelLabel: record.modelLabel || (record.params || {}).modelLabel,
|
||||
isCustomModel: Boolean(record.isCustomModel),
|
||||
referenceImage: record.referenceImage || (record.params || {}).referenceImage,
|
||||
referenceImages: record.referenceImages || (record.params || {}).referenceImages,
|
||||
};
|
||||
const workType = toWorkType(String(record.type || 'image'), params);
|
||||
let url = String(record.url || '').trim();
|
||||
if (workType === 'reverse-prompt') {
|
||||
url = url && !url.startsWith('data:') ? url : `[reverse-prompt:${record.id || Date.now()}]`;
|
||||
}
|
||||
if (!url || url.startsWith('data:')) continue;
|
||||
const existing = await client.query(
|
||||
`SELECT id, type, prompt, negative_prompt, params, result_url, is_public, status, created_at
|
||||
FROM works
|
||||
WHERE user_id = $1 AND result_url = $2
|
||||
LIMIT 1`,
|
||||
[userId, url],
|
||||
);
|
||||
if (existing.rows[0]) {
|
||||
saved.push(mapWork(existing.rows[0]));
|
||||
continue;
|
||||
}
|
||||
const result = await client.query(
|
||||
`INSERT INTO works (user_id, type, prompt, negative_prompt, params, result_url, is_public, status, credits_cost, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, 'completed', $8, COALESCE($9::timestamptz, NOW()))
|
||||
RETURNING id, type, prompt, negative_prompt, params, result_url, is_public, status, created_at`,
|
||||
[
|
||||
userId,
|
||||
workType,
|
||||
record.prompt || '',
|
||||
record.negativePrompt || null,
|
||||
JSON.stringify(params),
|
||||
url,
|
||||
Boolean(record.published),
|
||||
Number(record.creditsCost || 0),
|
||||
record.createdAt || null,
|
||||
],
|
||||
);
|
||||
if (result.rows[0]) saved.push(mapWork(result.rows[0]));
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return NextResponse.json({ records: saved });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
const id = request.nextUrl.searchParams.get('id');
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
if (id) {
|
||||
await client.query('DELETE FROM works WHERE id = $1 AND user_id = $2', [id, userId]);
|
||||
} else {
|
||||
await client.query('DELETE FROM works WHERE user_id = $1', [userId]);
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
158
src/app/api/download/route.ts
Normal file
158
src/app/api/download/route.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import path from 'path';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { localStorage } from '@/lib/local-storage';
|
||||
import { fetchPublicHttpUrl } from '@/lib/remote-fetch';
|
||||
|
||||
/**
|
||||
* Download proxy.
|
||||
*
|
||||
* Supports:
|
||||
* - remote http(s) URLs, fetched server-side to avoid browser CORS failures
|
||||
* - same-origin relative URLs
|
||||
* - local-storage URLs, read directly from disk with path traversal protection
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = request.nextUrl.searchParams.get('url');
|
||||
const filename = sanitizeFilename(
|
||||
request.nextUrl.searchParams.get('filename') || 'download',
|
||||
);
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: '缺少 url 参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const localKey = getLocalStorageKey(url);
|
||||
if (localKey) {
|
||||
return downloadLocalStorageFile(localKey, filename);
|
||||
}
|
||||
|
||||
const targetUrl = resolveDownloadUrl(url, request.nextUrl.origin);
|
||||
if (!targetUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: '仅支持 HTTP(S) URL 或站内文件 URL' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetchPublicHttpUrl(targetUrl, {
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `远程文件获取失败: ${response.status}` },
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'application/octet-stream';
|
||||
const body = await response.arrayBuffer();
|
||||
|
||||
return buildDownloadResponse(
|
||||
body,
|
||||
contentType,
|
||||
filename,
|
||||
body.byteLength,
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '下载失败';
|
||||
console.error('[Download Proxy Error]', msg);
|
||||
return NextResponse.json({ error: `下载失败: ${msg}` }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalStorageKey(url: string): string | null {
|
||||
let pathname = url;
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
try {
|
||||
pathname = new URL(url).pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const prefix = '/api/local-storage/';
|
||||
if (!pathname.startsWith(prefix)) return null;
|
||||
|
||||
try {
|
||||
const key = decodeURIComponent(pathname.slice(prefix.length));
|
||||
const normalized = path.posix.normalize(key).replace(/^\/+/, '');
|
||||
if (!normalized || normalized.startsWith('..') || normalized.includes('/../')) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDownloadUrl(url: string, origin: string): string | null {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (url.startsWith('/') && !url.startsWith('//')) {
|
||||
return `${origin}${url}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function downloadLocalStorageFile(key: string, filename: string) {
|
||||
if (!localStorage.fileExists(key)) {
|
||||
return NextResponse.json({ error: '文件不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
const fileBuffer = localStorage.readFile(key);
|
||||
const contentType = getContentType(key);
|
||||
|
||||
return buildDownloadResponse(
|
||||
fileBuffer.buffer.slice(
|
||||
fileBuffer.byteOffset,
|
||||
fileBuffer.byteOffset + fileBuffer.byteLength,
|
||||
) as ArrayBuffer,
|
||||
contentType,
|
||||
filename,
|
||||
fileBuffer.byteLength,
|
||||
);
|
||||
}
|
||||
|
||||
function buildDownloadResponse(
|
||||
body: ArrayBuffer,
|
||||
contentType: string,
|
||||
filename: string,
|
||||
length: number,
|
||||
) {
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Content-Length': String(length),
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeFilename(filename: string): string {
|
||||
return path.basename(filename).replace(/[\r\n"]/g, '_') || 'download';
|
||||
}
|
||||
|
||||
function getContentType(filePath: string): string {
|
||||
const extension = filePath.split('.').pop()?.toLowerCase();
|
||||
const contentTypeMap: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
mp4: 'video/mp4',
|
||||
avi: 'video/x-msvideo',
|
||||
mov: 'video/quicktime',
|
||||
wmv: 'video/x-ms-wmv',
|
||||
webm: 'video/webm',
|
||||
};
|
||||
|
||||
return contentTypeMap[extension || ''] || 'application/octet-stream';
|
||||
}
|
||||
72
src/app/api/email/reset-password/route.ts
Normal file
72
src/app/api/email/reset-password/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { ensureEmailSchema, getRequestBaseUrl, isValidEmail, normalizeEmail, sendTemplatedEmail, verifyEmailCode } from '@/lib/email-service';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function passwordStrongEnough(value: string): boolean {
|
||||
return value.length >= 8 && /[a-zA-Z]/.test(value) && /\d/.test(value);
|
||||
}
|
||||
|
||||
function friendlyError(error: unknown) {
|
||||
return error instanceof Error ? error.message : '密码重置失败,请稍后再试';
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureEmailSchema(client);
|
||||
const body = await request.json();
|
||||
const email = normalizeEmail(body.email);
|
||||
const code = typeof body.code === 'string' ? body.code.trim() : '';
|
||||
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : '';
|
||||
|
||||
if (!isValidEmail(email) || !/^[a-z0-9]{4,10}$/i.test(code)) {
|
||||
return NextResponse.json({ error: '邮箱或验证码格式不正确' }, { status: 400 });
|
||||
}
|
||||
if (!passwordStrongEnough(newPassword)) {
|
||||
return NextResponse.json({ error: '新密码至少 8 位,并同时包含字母和数字' }, { status: 400 });
|
||||
}
|
||||
|
||||
await client.query('BEGIN');
|
||||
await verifyEmailCode(client, { email, type: 'reset_password', code });
|
||||
|
||||
const user = await client.query(
|
||||
`SELECT p.id, p.nickname
|
||||
FROM profiles p
|
||||
JOIN auth.users u ON u.id = p.id
|
||||
WHERE LOWER(p.email) = LOWER($1) AND p.email_verified = true
|
||||
LIMIT 1`,
|
||||
[email],
|
||||
);
|
||||
if (user.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: '该邮箱尚未绑定或未完成验证' }, { status: 400 });
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE auth.users
|
||||
SET password_hash = crypt($1, gen_salt('bf'))
|
||||
WHERE id = $2`,
|
||||
[newPassword, user.rows[0].id],
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
|
||||
await sendTemplatedEmail(client, {
|
||||
to: email,
|
||||
type: 'password_reset_success',
|
||||
subject: '【妙境】密码已重置',
|
||||
title: '密码重置成功',
|
||||
intro: '你的妙境账号密码已成功重置。请使用新密码重新登录。',
|
||||
note: '若非本人操作,请立即联系管理员并检查账号安全。',
|
||||
assetBaseUrl: getRequestBaseUrl(request) || undefined,
|
||||
}).catch(() => undefined);
|
||||
|
||||
return NextResponse.json({ success: true, message: '密码已重置,请重新登录' });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK').catch(() => undefined);
|
||||
return NextResponse.json({ error: friendlyError(error) }, { status: 400 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
62
src/app/api/email/send-notification/route.ts
Normal file
62
src/app/api/email/send-notification/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { getRequestBaseUrl, isValidEmail, normalizeEmail, sendTemplatedEmail, type EmailMessageType } from '@/lib/email-service';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const ALLOWED_TYPES: EmailMessageType[] = [
|
||||
'register_success',
|
||||
'email_verified',
|
||||
'password_reset_success',
|
||||
'security_login',
|
||||
'announcement',
|
||||
'order',
|
||||
'business',
|
||||
];
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const adminError = await requireAdmin(request);
|
||||
if (adminError) return adminError;
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const body = await request.json();
|
||||
const to = normalizeEmail(body.to);
|
||||
const type = ALLOWED_TYPES.includes(body.type) ? body.type : 'business';
|
||||
const title = typeof body.title === 'string' ? body.title.trim().slice(0, 120) : '';
|
||||
const bodyText = typeof body.body === 'string' ? body.body.trim().slice(0, 4000) : '';
|
||||
const buttonText = typeof body.buttonText === 'string' ? body.buttonText.trim().slice(0, 40) : '';
|
||||
const buttonUrl = typeof body.buttonUrl === 'string' ? body.buttonUrl.trim().slice(0, 500) : '';
|
||||
|
||||
if (!isValidEmail(to)) {
|
||||
return NextResponse.json({ error: '请输入正确的收件邮箱' }, { status: 400 });
|
||||
}
|
||||
if (!title || !bodyText) {
|
||||
return NextResponse.json({ error: '请填写邮件标题和正文' }, { status: 400 });
|
||||
}
|
||||
if (buttonUrl && !/^https?:\/\/[^\s"'<>]+$/i.test(buttonUrl)) {
|
||||
return NextResponse.json({ error: '按钮链接必须是 HTTP(S) 地址' }, { status: 400 });
|
||||
}
|
||||
|
||||
await sendTemplatedEmail(client, {
|
||||
to,
|
||||
type,
|
||||
subject: `【妙境】${title}`,
|
||||
title,
|
||||
body: bodyText,
|
||||
buttonText: buttonText || undefined,
|
||||
buttonUrl: buttonUrl || undefined,
|
||||
note: '这是一封系统通知邮件,请勿直接回复。',
|
||||
ipAddress: 'admin',
|
||||
assetBaseUrl: getRequestBaseUrl(request) || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, message: '邮件已发送' });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '邮件发送失败';
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
47
src/app/api/email/send-profile-code/route.ts
Normal file
47
src/app/api/email/send-profile-code/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { ensureEmailSchema, isValidEmail, normalizeEmail, sendVerificationCode } from '@/lib/email-service';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function friendlyError(error: unknown) {
|
||||
return error instanceof Error ? error.message : '验证码发送失败,请稍后再试';
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: '请先登录后再验证邮箱' }, { status: 401 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureEmailSchema(client);
|
||||
const body = await request.json();
|
||||
const email = normalizeEmail(body.email);
|
||||
if (!isValidEmail(email)) {
|
||||
return NextResponse.json({ error: '请输入正确的邮箱地址' }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await client.query('SELECT id, email FROM profiles WHERE id = $1 LIMIT 1', [userId]);
|
||||
if (user.rows.length === 0) {
|
||||
return NextResponse.json({ error: '账号不存在,请重新登录' }, { status: 404 });
|
||||
}
|
||||
|
||||
const duplicate = await client.query(
|
||||
'SELECT id FROM profiles WHERE LOWER(email) = LOWER($1) AND id <> $2 LIMIT 1',
|
||||
[email, userId],
|
||||
);
|
||||
if (duplicate.rows.length > 0) {
|
||||
return NextResponse.json({ error: '该邮箱已被其他账号绑定' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await sendVerificationCode(client, request, { email, type: 'verify_email', userId });
|
||||
return NextResponse.json({ ...result, message: '验证码已发送,请查收邮箱' });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: friendlyError(error) }, { status: 400 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
36
src/app/api/email/send-register-code/route.ts
Normal file
36
src/app/api/email/send-register-code/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { sendVerificationCode, normalizeEmail, isValidEmail } from '@/lib/email-service';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function friendlyError(error: unknown) {
|
||||
return error instanceof Error ? error.message : '验证码发送失败,请稍后再试';
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const body = await request.json();
|
||||
const email = normalizeEmail(body.email);
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
return NextResponse.json({ error: '请输入正确的邮箱地址' }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await client.query(
|
||||
'SELECT id FROM profiles WHERE LOWER(email) = LOWER($1) LIMIT 1',
|
||||
[email],
|
||||
);
|
||||
if (existing.rows.length > 0) {
|
||||
return NextResponse.json({ error: '该邮箱已注册,请直接登录' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await sendVerificationCode(client, request, { email, type: 'register' });
|
||||
return NextResponse.json({ ...result, message: '验证码已发送,请查收邮箱' });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: friendlyError(error) }, { status: 400 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
47
src/app/api/email/send-reset-code/route.ts
Normal file
47
src/app/api/email/send-reset-code/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { ensureEmailSchema, isValidEmail, normalizeEmail, sendVerificationCode } from '@/lib/email-service';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureEmailSchema(client);
|
||||
const body = await request.json();
|
||||
const email = normalizeEmail(body.email);
|
||||
if (!isValidEmail(email)) {
|
||||
return NextResponse.json({ error: '请输入正确的邮箱地址' }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await client.query(
|
||||
`SELECT p.id
|
||||
FROM profiles p
|
||||
JOIN auth.users u ON u.id = p.id
|
||||
WHERE LOWER(p.email) = LOWER($1) AND p.email_verified = true AND u.password_hash IS NOT NULL
|
||||
LIMIT 1`,
|
||||
[email],
|
||||
);
|
||||
|
||||
if (user.rows.length > 0) {
|
||||
try {
|
||||
await sendVerificationCode(client, request, {
|
||||
email,
|
||||
type: 'reset_password',
|
||||
userId: user.rows[0].id,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '验证码发送失败,请稍后再试';
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
cooldown: 60,
|
||||
message: '如果该邮箱已绑定并验证,我们已发送重置验证码',
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
73
src/app/api/email/verify-profile/route.ts
Normal file
73
src/app/api/email/verify-profile/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { ensureEmailSchema, getRequestBaseUrl, isValidEmail, normalizeEmail, sendTemplatedEmail, verifyEmailCode } from '@/lib/email-service';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function friendlyError(error: unknown) {
|
||||
return error instanceof Error ? error.message : '邮箱验证失败,请稍后再试';
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: '请先登录后再验证邮箱' }, { status: 401 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureEmailSchema(client);
|
||||
const body = await request.json();
|
||||
const email = normalizeEmail(body.email);
|
||||
const code = typeof body.code === 'string' ? body.code.trim() : '';
|
||||
if (!isValidEmail(email) || !/^[a-z0-9]{4,10}$/i.test(code)) {
|
||||
return NextResponse.json({ error: '邮箱或验证码格式不正确' }, { status: 400 });
|
||||
}
|
||||
|
||||
await client.query('BEGIN');
|
||||
await verifyEmailCode(client, { email, type: 'verify_email', code });
|
||||
|
||||
const duplicate = await client.query(
|
||||
'SELECT id FROM profiles WHERE LOWER(email) = LOWER($1) AND id <> $2 LIMIT 1',
|
||||
[email, userId],
|
||||
);
|
||||
if (duplicate.rows.length > 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: '该邮箱已被其他账号绑定' }, { status: 400 });
|
||||
}
|
||||
|
||||
const domain = email.includes('@') ? email.split('@')[1] : null;
|
||||
const profile = await client.query(
|
||||
`UPDATE profiles
|
||||
SET email = $1,
|
||||
email_verified = true,
|
||||
email_verified_at = NOW(),
|
||||
email_bound_at = COALESCE(email_bound_at, NOW()),
|
||||
email_sender_domain = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $3
|
||||
RETURNING id, email, nickname, phone, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit, avatar_url, created_at, email_verified, email_verified_at, email_bound_at`,
|
||||
[email, domain, userId],
|
||||
);
|
||||
await client.query('UPDATE auth.users SET email = $1 WHERE id = $2', [email, userId]);
|
||||
await client.query('COMMIT');
|
||||
|
||||
await sendTemplatedEmail(client, {
|
||||
to: email,
|
||||
type: 'email_verified',
|
||||
subject: '【妙境】邮箱验证成功',
|
||||
title: '邮箱验证成功',
|
||||
intro: '你的账号邮箱已完成验证,后续可用于找回密码和安全通知。',
|
||||
note: '若非本人操作,请尽快修改账号密码。',
|
||||
assetBaseUrl: getRequestBaseUrl(request) || undefined,
|
||||
}).catch(() => undefined);
|
||||
|
||||
return NextResponse.json({ success: true, profile: profile.rows[0], message: '邮箱验证成功' });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK').catch(() => undefined);
|
||||
return NextResponse.json({ error: friendlyError(error) }, { status: 400 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
106
src/app/api/gallery/publish/route.ts
Normal file
106
src/app/api/gallery/publish/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { localStorage } from '@/lib/local-storage';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const tokenUserId = await getAuthenticatedUserId(request);
|
||||
if (!tokenUserId) {
|
||||
return NextResponse.json({ error: '请先登录后再发布作品' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
userId,
|
||||
type,
|
||||
prompt,
|
||||
negativePrompt,
|
||||
resultUrl,
|
||||
thumbnailUrl,
|
||||
width,
|
||||
height,
|
||||
duration,
|
||||
params,
|
||||
model,
|
||||
modelLabel,
|
||||
creditsCost,
|
||||
} = body;
|
||||
|
||||
if (!resultUrl) {
|
||||
return NextResponse.json({ error: '缺少作品 URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
|
||||
try {
|
||||
const profileResult = await client.query(
|
||||
'SELECT id FROM profiles WHERE id = $1 AND is_active = true LIMIT 1',
|
||||
[tokenUserId],
|
||||
);
|
||||
if (profileResult.rows.length === 0) {
|
||||
return NextResponse.json({ error: '发布用户不存在或已停用' }, { status: 403 });
|
||||
}
|
||||
|
||||
const hasReference = Boolean(body.referenceImage)
|
||||
|| (Array.isArray(body.referenceImages) && body.referenceImages.length > 0)
|
||||
|| (Array.isArray((params as Record<string, unknown> | undefined)?.referenceImages) && ((params as Record<string, unknown>).referenceImages as unknown[]).length > 0);
|
||||
const explicitMode = (params as Record<string, unknown> | undefined)?.creationMode || body.creationMode;
|
||||
const workType = explicitMode === 'text2img' || explicitMode === 'img2img' || explicitMode === 'text2video' || explicitMode === 'img2video'
|
||||
? explicitMode
|
||||
: type === 'video' ? (hasReference ? 'img2video' : 'text2video')
|
||||
: type === 'image' ? (hasReference ? 'img2img' : 'text2img')
|
||||
: type;
|
||||
|
||||
const safeUserId = tokenUserId;
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
let galleryResultUrl = resultUrl;
|
||||
let galleryThumbnailUrl = thumbnailUrl || null;
|
||||
try {
|
||||
const folder = type === 'video' ? 'gallery/videos' : 'gallery/images';
|
||||
galleryResultUrl = await localStorage.copyPublicUrlToFolder(resultUrl, folder);
|
||||
if (thumbnailUrl) {
|
||||
galleryThumbnailUrl = await localStorage.copyPublicUrlToFolder(thumbnailUrl, 'gallery/thumbnails');
|
||||
}
|
||||
} catch (copyError) {
|
||||
console.warn('[gallery/publish] copy to gallery folder failed, using original URL:', copyError);
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO works (id, user_id, type, title, prompt, negative_prompt, result_url, thumbnail_url, width, height, duration, is_public, likes_count, credits_cost, status, params)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, true, 0, $12, 'completed', $13)`,
|
||||
[
|
||||
id,
|
||||
safeUserId,
|
||||
workType,
|
||||
body.title || null,
|
||||
prompt || null,
|
||||
negativePrompt || null,
|
||||
galleryResultUrl,
|
||||
galleryThumbnailUrl,
|
||||
width || null,
|
||||
height || null,
|
||||
duration || null,
|
||||
creditsCost || 0,
|
||||
JSON.stringify({
|
||||
...((params as Record<string, unknown>) || {}),
|
||||
model,
|
||||
modelLabel,
|
||||
referenceImage: body.referenceImage || undefined,
|
||||
referenceImages: body.referenceImages || undefined,
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, workId: id, resultUrl: galleryResultUrl });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[gallery/publish] POST error:', err);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
152
src/app/api/gallery/route.ts
Normal file
152
src/app/api/gallery/route.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
function getReferenceImages(params: Record<string, unknown>) {
|
||||
const referenceImages = Array.isArray(params.referenceImages)
|
||||
? params.referenceImages.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
: [];
|
||||
const referenceImage = typeof params.referenceImage === 'string' && params.referenceImage.trim()
|
||||
? params.referenceImage
|
||||
: referenceImages[0];
|
||||
return { referenceImage, referenceImages };
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = request.nextUrl.searchParams;
|
||||
const type = url.get('type');
|
||||
const limit = Math.min(parseInt(url.get('limit') || '50', 10), 300);
|
||||
const offset = parseInt(url.get('offset') || '0', 10);
|
||||
const sort = url.get('sort') || 'newest';
|
||||
const search = (url.get('q') || url.get('search') || '').trim().toLowerCase();
|
||||
|
||||
try {
|
||||
const client = await getDbClient();
|
||||
|
||||
try {
|
||||
const where: string[] = ['w.is_public = true', 'w.status = $1'];
|
||||
const params: unknown[] = ['completed'];
|
||||
|
||||
if (type === 'image') {
|
||||
params.push('text2img', 'img2img');
|
||||
where.push(`w.type IN ($${params.length - 1}, $${params.length})`);
|
||||
} else if (type === 'video') {
|
||||
params.push('text2video', 'img2video');
|
||||
where.push(`w.type IN ($${params.length - 1}, $${params.length})`);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
params.push(`%${search}%`);
|
||||
const idx = params.length;
|
||||
where.push(`(
|
||||
LOWER(COALESCE(w.title, '')) LIKE $${idx}
|
||||
OR LOWER(COALESCE(w.prompt, '')) LIKE $${idx}
|
||||
OR LOWER(COALESCE(w.negative_prompt, '')) LIKE $${idx}
|
||||
OR LOWER(COALESCE(p.nickname, '')) LIKE $${idx}
|
||||
OR LOWER(COALESCE(p.email, '')) LIKE $${idx}
|
||||
OR LOWER(COALESCE(w.params::text, '')) LIKE $${idx}
|
||||
)`);
|
||||
}
|
||||
|
||||
let query = `
|
||||
SELECT w.id, w.type, w.title, w.prompt, w.negative_prompt, w.result_url, w.thumbnail_url,
|
||||
w.width, w.height, w.duration, w.is_public, w.likes_count, w.credits_cost,
|
||||
w.status, w.created_at, w.user_id, w.params,
|
||||
p.nickname, p.email, p.avatar_url
|
||||
FROM works w
|
||||
LEFT JOIN profiles p ON p.id = w.user_id
|
||||
WHERE ${where.join(' AND ')}
|
||||
`;
|
||||
|
||||
if (sort === 'popular') {
|
||||
query += ' ORDER BY w.likes_count DESC, w.created_at DESC';
|
||||
} else {
|
||||
query += ' ORDER BY w.created_at DESC';
|
||||
}
|
||||
|
||||
query += ` LIMIT ${limit} OFFSET ${offset}`;
|
||||
|
||||
const result = await client.query(query, params);
|
||||
const countResult = await client.query(
|
||||
`SELECT COUNT(*) as total
|
||||
FROM works w
|
||||
LEFT JOIN profiles p ON p.id = w.user_id
|
||||
WHERE ${where.join(' AND ')}`,
|
||||
params,
|
||||
);
|
||||
|
||||
const works = (result.rows || []).map((w: Record<string, unknown>) => {
|
||||
const workParams = (w.params || {}) as Record<string, unknown>;
|
||||
const references = getReferenceImages(workParams);
|
||||
return {
|
||||
id: w.id,
|
||||
type: w.type,
|
||||
title: w.title,
|
||||
prompt: w.prompt,
|
||||
negativePrompt: w.negative_prompt,
|
||||
url: w.result_url,
|
||||
thumbnailUrl: w.thumbnail_url,
|
||||
width: w.width,
|
||||
height: w.height,
|
||||
duration: w.duration,
|
||||
likes: w.likes_count || 0,
|
||||
creditsCost: w.credits_cost || 0,
|
||||
params: workParams,
|
||||
referenceImage: references.referenceImage,
|
||||
referenceImages: references.referenceImages,
|
||||
publisherId: w.user_id,
|
||||
publisherNickname: (w.nickname as string) || ((w.email as string) || '').split('@')[0] || '匿名用户',
|
||||
publisherAvatarUrl: (w.avatar_url as string | null) || null,
|
||||
publishedAt: w.created_at,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ works, total: parseInt(countResult.rows[0]?.total || '0', 10) });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[gallery] GET error:', err);
|
||||
return NextResponse.json({ error: '获取作品列表失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const searchId = request.nextUrl.searchParams.get('id');
|
||||
const bodyIds = Array.isArray(body.ids) ? body.ids : [];
|
||||
const ids = [...new Set([searchId, ...bodyIds].filter((id): id is string => typeof id === 'string' && id.trim().length > 0))];
|
||||
|
||||
if (ids.length === 0) {
|
||||
return NextResponse.json({ error: '缺少要删除的作品 ID' }, { status: 400 });
|
||||
}
|
||||
if (ids.length > 100) {
|
||||
return NextResponse.json({ error: '单次最多删除 100 个画廊作品' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`UPDATE works
|
||||
SET is_public = false
|
||||
WHERE id = ANY($1) AND is_public = true
|
||||
RETURNING id`,
|
||||
[ids],
|
||||
);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
removed: result.rowCount || 0,
|
||||
ids: result.rows.map((row: Record<string, unknown>) => row.id),
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[gallery] DELETE error:', err);
|
||||
return NextResponse.json({ error: '删除画廊作品失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
1033
src/app/api/generate/image/route.ts
Normal file
1033
src/app/api/generate/image/route.ts
Normal file
File diff suppressed because it is too large
Load Diff
262
src/app/api/generate/reverse-prompt/route.ts
Normal file
262
src/app/api/generate/reverse-prompt/route.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { buildCustomApiHeaders, fetchWithRetry, parseCustomApiError } from '@/lib/custom-api-fetch';
|
||||
import { localStorage } from '@/lib/local-storage';
|
||||
import { resolveServerApiConfig } from '@/lib/server-api-config';
|
||||
|
||||
interface CustomApiConfig {
|
||||
apiUrl: string;
|
||||
modelName: string;
|
||||
apiKey: string;
|
||||
provider?: string;
|
||||
customApiKeyId?: string;
|
||||
systemApiId?: string;
|
||||
}
|
||||
|
||||
const REVERSE_PROMPT_TIMEOUT = 90_000;
|
||||
const MAX_IMAGE_DATA_URL_LENGTH = 8_000_000;
|
||||
|
||||
interface ReversePromptResult {
|
||||
generalPrompt: string;
|
||||
structuredPrompt: string;
|
||||
negativePrompt: string;
|
||||
structuredSections?: {
|
||||
subject?: string;
|
||||
environment?: string;
|
||||
visualStyle?: string;
|
||||
lighting?: string;
|
||||
composition?: string;
|
||||
character?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function getDataUrlImage(image: string): { buffer: Buffer; contentType: string; extension: string } | null {
|
||||
const match = image.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,([\s\S]+)$/);
|
||||
if (!match) return null;
|
||||
const contentType = match[1].toLowerCase();
|
||||
const extensionMap: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
'image/gif': 'gif',
|
||||
};
|
||||
const extension = extensionMap[contentType] || 'jpg';
|
||||
return {
|
||||
buffer: Buffer.from(match[2], 'base64'),
|
||||
contentType,
|
||||
extension,
|
||||
};
|
||||
}
|
||||
|
||||
async function persistReferenceImage(image: string): Promise<string | null> {
|
||||
try {
|
||||
if (image.startsWith('data:image/')) {
|
||||
const parsed = getDataUrlImage(image);
|
||||
if (!parsed) return null;
|
||||
const key = `reverse-prompt/reference-images/${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${parsed.extension}`;
|
||||
const savedKey = await localStorage.uploadFile({
|
||||
fileContent: parsed.buffer,
|
||||
fileName: key,
|
||||
contentType: parsed.contentType,
|
||||
});
|
||||
return localStorage.generatePresignedUrl({ key: savedKey, expireTime: 2592000 });
|
||||
}
|
||||
|
||||
if (/^https?:\/\/\S+/i.test(image)) {
|
||||
return localStorage.copyPublicUrlToFolder(image, 'reverse-prompt/reference-images');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Reverse Prompt] persist reference image failed:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseReversePrompt(content: string): ReversePromptResult {
|
||||
const trimmed = content.trim();
|
||||
const jsonMatch = trimmed.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonMatch[0]) as Record<string, unknown>;
|
||||
const generalPrompt = String(parsed.generalPrompt || parsed.general || parsed.prompt || '').trim();
|
||||
const structuredPrompt = String(
|
||||
parsed.structuredPrompt || parsed.structured || parsed.fullPrompt || parsed.pixelPrompt || '',
|
||||
).trim();
|
||||
const negativePrompt = String(parsed.negativePrompt || parsed.negative || '').trim();
|
||||
const rawSections = parsed.structuredSections;
|
||||
const structuredSections = rawSections && typeof rawSections === 'object'
|
||||
? {
|
||||
subject: String((rawSections as Record<string, unknown>).subject || '').trim() || undefined,
|
||||
environment: String((rawSections as Record<string, unknown>).environment || '').trim() || undefined,
|
||||
visualStyle: String((rawSections as Record<string, unknown>).visualStyle || (rawSections as Record<string, unknown>).style || '').trim() || undefined,
|
||||
lighting: String((rawSections as Record<string, unknown>).lighting || '').trim() || undefined,
|
||||
composition: String((rawSections as Record<string, unknown>).composition || '').trim() || undefined,
|
||||
character: String((rawSections as Record<string, unknown>).character || (rawSections as Record<string, unknown>).person || '').trim() || undefined,
|
||||
}
|
||||
: undefined;
|
||||
if (generalPrompt || structuredPrompt) {
|
||||
return {
|
||||
generalPrompt: generalPrompt || structuredPrompt,
|
||||
structuredPrompt: structuredPrompt || generalPrompt,
|
||||
negativePrompt,
|
||||
structuredSections,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to plain text handling.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
generalPrompt: trimmed,
|
||||
structuredPrompt: trimmed,
|
||||
negativePrompt: 'low quality, blurry, distorted anatomy, extra limbs, deformed hands, bad face, inaccurate details, text, watermark, logo, cropped subject, oversaturated, underexposed, overexposed',
|
||||
};
|
||||
}
|
||||
|
||||
function buildInstruction(outputMode: 'general' | 'structured' | 'pixel', language: 'zh' | 'en'): string {
|
||||
const languageRule = language === 'en'
|
||||
? '所有提示词字段必须使用英文输出。'
|
||||
: '所有提示词字段必须使用中文输出。';
|
||||
|
||||
if (outputMode === 'pixel') {
|
||||
return `你是专业的图片反推提示词专家,同时熟悉 image2 / 图生图模型的提示词偏好。请严格观察用户上传的参考图,把图片转换为更适合 image2 参考图生成的高保真复刻提示词。目标不是普通描述,而是让 image2 在使用同一张参考图时尽可能保留人物身份、面部微表情、身高体态、肢体粗细、脸型、手脚细节、长相、身形、身材比例和服装场景。必须描述所有可见细节,不要编造看不见的内容。
|
||||
|
||||
输出必须只返回 JSON,不要解释,不要 Markdown。JSON 格式只允许包含这两个字段:
|
||||
{
|
||||
"structuredPrompt": "完整提示词。必须是一段可直接粘贴到 image2 正向提示词输入框的复刻型提示词,先写参考图硬约束和保真目标,再写人物身份锚点、面部微表情锚点、身高体态和身体比例锚点、手脚肢体锚点、服装材质锚点、构图光影色彩锚点,最后按画面区域逐块补全细节",
|
||||
"negativePrompt": "反向提示词。必须列出会破坏参考图一致性的错误,包括不同人物、不同脸型、错误表情、错误身材比例、手脚畸形、肢体粗细变化、胸腰臀比例变化、过度美化、重设计服装、改构图和低质量问题"
|
||||
}
|
||||
|
||||
image2 复刻级要求:
|
||||
1. 只输出完整提示词和反向提示词,不要输出通用描述、结构化分项、解释文字或 Markdown。
|
||||
2. 完整提示词第一句必须明确:以参考图为硬视觉参考,保留同一个人物/主体,不重新设计,不随机换脸,不改变身材比例,不做额外美化。
|
||||
3. 完整提示词必须按 image2 更容易执行的顺序组织:保真目标 -> 主体身份和年龄气质 -> 面部骨相和五官比例 -> 面部微表情和眼神 -> 头发和皮肤纹理 -> 身高体态和身体比例 -> 手部脚部及四肢 -> 服装配饰 -> 构图镜头 -> 光影色彩 -> 背景道具 -> 画面瑕疵纹理。
|
||||
4. 人像必须写成“身份锁定”而不是普通外貌描述:脸型轮廓、额头、颧骨、下颌线、下巴、脸宽脸长比例、眉眼间距、眼型、眼睑开合、瞳孔/视线方向、鼻梁鼻尖鼻翼、嘴唇厚薄、嘴部开合、嘴角方向、法令纹/酒窝/痣/斑点/毛孔/皮肤质感、左右不对称特征、真实年龄感和气质都要尽量描述。
|
||||
5. 面部微表情必须具体到可见肌肉和局部状态:眉毛高低、眼周紧张或放松、眼神情绪、脸颊受力、嘴角上扬/下压幅度、唇线、牙齿是否可见、下巴和颈部状态;不要只写“微笑”“严肃”等泛词。
|
||||
6. 身高、身形、身材和肢体必须用相对比例锁定:人物在画面中占比、头身比、肩宽相对脸宽、颈长、胸廓/腰/髋的可见轮廓、成人非情色语境下的胸部体积和服装包裹形态、手臂粗细、手腕、手掌大小、手指长度和弯曲、腿长、膝盖、小腿脚踝粗细、脚部大小和朝向;不可把身材重塑成更瘦、更高、更丰满或更夸张。
|
||||
7. 手部和脚部要单独描述:可见手指数量、手指姿态、关节弯曲、指尖方向、手掌遮挡关系、脚趾/鞋型/脚背/脚踝可见状态;要求保持自然解剖结构,避免多指、少指、粘连、变形和错误遮挡。
|
||||
8. 服装、配饰和材质必须写清楚款式、剪裁、贴身/宽松程度、领口袖口下摆、布料厚薄、褶皱走向、拉伸变形、透明度、反光、花纹、缝线、饰品位置和遮挡关系;不要让 image2 自行换装或增强性感化。
|
||||
9. 构图必须锁定画面比例、景别、视角、镜头高度、焦段感、主体在画面中的位置、裁切边界、头顶/脚底/四肢与画面边缘的距离、前景中景背景层次、透视和景深。
|
||||
10. 颜色和光影必须描述主色、辅色、肤色倾向、衣物色块、背景色块、色温、光源方向、软硬、明暗边界、高光、阴影、反射、环境光、颗粒、压缩痕迹、噪点、模糊和瑕疵。
|
||||
11. 按画面区域补充细节时,可以用九宫格、前景/中景/背景、或主体局部区域划分;每个区域都要写清楚位置、可见物体、大小比例、边缘形状、材质纹理、遮挡关系和小瑕疵。
|
||||
12. 如果有文字、Logo、图标、符号、品牌标识或界面元素,必须描述可识别的内容、字体观感、颜色、大小、排列方式和具体位置;不可完全识别时只描述可见形态,不要臆造。
|
||||
13. negativePrompt 必须优先排除破坏参考图相似度的内容:different person, changed identity, wrong face shape, different expression, changed gaze, altered body proportions, different height impression, thinner arms, thicker legs, changed bust/waist/hip proportion, deformed hands, wrong fingers, deformed feet, over-beautified face, plastic skin, redesigned outfit, different pose, different camera angle, different crop, extra objects, missing details。
|
||||
14. 不要写“图片中”“这张图”等元描述,直接写可用于生成模型的提示词。
|
||||
15. ${languageRule}`;
|
||||
}
|
||||
|
||||
const preferred = outputMode === 'structured' ? '结构化提示词' : '通用描述提示词';
|
||||
|
||||
return `你是专业的图片反推提示词专家。请严格观察用户上传的参考图,把图片转换为可直接用于 AI 文生图模型的提示词,目标是让用户把提示词交给文生图模型后尽可能还原原图。必须描述所有可见细节,不要编造看不见的内容。
|
||||
|
||||
输出必须只返回 JSON,不要解释,不要 Markdown。JSON 格式:
|
||||
{
|
||||
"generalPrompt": "通用描述提示词,使用连贯自然语言完整描述主体、环境、画面、风格、光照、构图、色彩、材质、镜头感和所有关键细节",
|
||||
"structuredPrompt": "结构化提示词,分段包含:主题、环境、视觉风格、光照、构图;如果有人物,还必须包含人物身材比例、面部细节、面部微表情、嘴部和嘴角细节、眼神细节、发型、配饰、衣物、衣物质感、姿态、身体朝向、画面比例等",
|
||||
"structuredSections": {
|
||||
"subject": "主题/主体,描述主体身份、数量、动作、核心物体、关键视觉特征,以及主体与画面其他元素的关系",
|
||||
"environment": "环境,描述空间、背景、道具、天气、时代、场景关系、前景/中景/远景元素",
|
||||
"visualStyle": "视觉风格,描述画风、质感、色彩、镜头语言、渲染/摄影特征、清晰度、颗粒感、景深和后期效果",
|
||||
"lighting": "光照,描述光源方向、软硬、色温、明暗关系、反射、高光、阴影、轮廓光和环境光",
|
||||
"composition": "构图,描述景别、视角、主体位置、画面比例、裁切、留白、透视、镜头焦段感和画面重心",
|
||||
"character": "如果有人物,描述身材比例、体态、肩颈腰腿比例、脸型、肤色、眉眼鼻唇、面部微表情、嘴部形态、嘴角方向、眼神方向和情绪、发型、头饰、配饰、衣物款式、衣物材质、褶皱、透明度、姿态、手部细节和身体朝向;无人物则为空字符串"
|
||||
},
|
||||
"negativePrompt": "反向提示词,列出需要避免的低质量、错误结构、畸形、模糊、文字水印、不符合原图的元素"
|
||||
}
|
||||
|
||||
细节要求:
|
||||
1. ${preferred}要更适合当前选择的输出形式,但 generalPrompt 和 structuredPrompt 两个字段都必须生成。
|
||||
2. 人物图片必须尽可能细致描述人物整体每一个可见细节:身材比例、体态、脸型、肤质、五官比例、眉毛、眼睛形状、瞳孔方向、眼神情绪、鼻梁、嘴唇厚薄、嘴部开合、嘴角上扬或下压、面部微表情、发型、发丝状态、配饰、服装款式、衣物材质、纹理、褶皱、透明度、姿态、手指、肢体动作和身体朝向。
|
||||
3. 如果参考图包含文字、Logo、图标、符号、品牌标识或界面元素,必须仔细描述可识别的文字内容、字体观感、颜色、大小、排列方式,以及它们在图片中的具体位置;如果文字不可完全识别,要说明可见形态,不要臆造。
|
||||
4. 产品、建筑、场景图片必须具体描述形状、材质、颜色、空间关系、背景、光照、反射、纹理、磨损、边缘轮廓和比例关系。
|
||||
5. 需要描述画面中的小物件、局部装饰、材质反光、阴影、高光、背景细节和遮挡关系,避免只写大概风格。
|
||||
6. 不要写“图片中”“这张图”等元描述,直接写可用于生成模型的提示词。
|
||||
7. ${languageRule}`;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const image = typeof body.image === 'string' ? body.image : '';
|
||||
const outputMode = body.outputMode === 'general'
|
||||
? 'general'
|
||||
: body.outputMode === 'pixel'
|
||||
? 'pixel'
|
||||
: 'structured';
|
||||
const language = body.language === 'en' ? 'en' : 'zh';
|
||||
const customApiConfig = body.customApiConfig as CustomApiConfig | undefined;
|
||||
|
||||
const isDataImage = image.startsWith('data:image/');
|
||||
const isHttpImage = /^https?:\/\/\S+/i.test(image);
|
||||
if (!image || (!isDataImage && !isHttpImage)) {
|
||||
return NextResponse.json({ error: '请上传需要反推提示词的图片' }, { status: 400 });
|
||||
}
|
||||
if (isDataImage && image.length > MAX_IMAGE_DATA_URL_LENGTH) {
|
||||
return NextResponse.json({ error: '图片过大,请压缩后再上传' }, { status: 400 });
|
||||
}
|
||||
const resolvedCustomApiConfig = await resolveServerApiConfig(request, customApiConfig);
|
||||
if (!resolvedCustomApiConfig?.apiKey || !resolvedCustomApiConfig.apiUrl || !resolvedCustomApiConfig.modelName) {
|
||||
return NextResponse.json({ error: '未配置可用的多模态模型,请先在 API 设置中添加支持图片理解的多模态模型' }, { status: 400 });
|
||||
}
|
||||
const resolvedApiKey = resolvedCustomApiConfig.apiKey;
|
||||
const persistedReferenceImage = await persistReferenceImage(image);
|
||||
|
||||
const chatBody = {
|
||||
model: resolvedCustomApiConfig.modelName,
|
||||
stream: false,
|
||||
messages: [
|
||||
{ role: 'system', content: buildInstruction(outputMode, language) },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '请根据这张参考图反推出文生图提示词,尽可能完整还原画面细节,并严格按 JSON 格式返回。',
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: image },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await fetchWithRetry(
|
||||
resolvedCustomApiConfig.apiUrl,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: buildCustomApiHeaders(resolvedApiKey),
|
||||
body: JSON.stringify(chatBody),
|
||||
},
|
||||
REVERSE_PROMPT_TIMEOUT,
|
||||
1,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return NextResponse.json(
|
||||
{ error: parseCustomApiError(response.status, errorText) },
|
||||
{ status: response.status >= 500 ? 502 : response.status },
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const choices = (data as Record<string, unknown>).choices as Array<Record<string, unknown>> | undefined;
|
||||
const message = choices?.[0]?.message as Record<string, unknown> | undefined;
|
||||
const content = message?.content;
|
||||
|
||||
if (typeof content !== 'string' || !content.trim()) {
|
||||
return NextResponse.json({ error: '模型未返回有效的反推提示词' }, { status: 502 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...parseReversePrompt(content),
|
||||
referenceImage: persistedReferenceImage,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : '图片反推提示词失败';
|
||||
console.error('[Reverse Prompt Error]', message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
151
src/app/api/generate/suggest-prompt/route.ts
Normal file
151
src/app/api/generate/suggest-prompt/route.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { buildCustomApiHeaders, fetchWithRetry, parseCustomApiError } from '@/lib/custom-api-fetch';
|
||||
import { resolveServerApiConfig } from '@/lib/server-api-config';
|
||||
|
||||
interface CustomApiConfig {
|
||||
apiUrl: string;
|
||||
modelName: string;
|
||||
apiKey: string;
|
||||
provider: string;
|
||||
customApiKeyId?: string;
|
||||
systemApiId?: string;
|
||||
}
|
||||
|
||||
const SUGGEST_TIMEOUT = 60_000;
|
||||
|
||||
function parseOptimizedPrompt(content: string): { prompt: string; negativePrompt?: string } {
|
||||
const trimmed = content.trim();
|
||||
const jsonMatch = trimmed.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonMatch[0]) as Record<string, unknown>;
|
||||
const prompt = typeof parsed.prompt === 'string'
|
||||
? parsed.prompt
|
||||
: typeof parsed.positivePrompt === 'string'
|
||||
? parsed.positivePrompt
|
||||
: typeof parsed.positive === 'string'
|
||||
? parsed.positive
|
||||
: '';
|
||||
const negativePrompt = typeof parsed.negativePrompt === 'string'
|
||||
? parsed.negativePrompt
|
||||
: typeof parsed.negative === 'string'
|
||||
? parsed.negative
|
||||
: '';
|
||||
if (prompt.trim()) {
|
||||
return {
|
||||
prompt: prompt.trim(),
|
||||
negativePrompt: negativePrompt.trim() || undefined,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to labeled text parsing.
|
||||
}
|
||||
}
|
||||
|
||||
const positiveMatch = trimmed.match(/(?:正向提示词|优化提示词|正面提示词|Positive Prompt|Prompt)\s*[::]\s*([\s\S]*?)(?=(?:负向提示词|负面提示词|反向提示词|Negative Prompt|Negative)\s*[::]|$)/i);
|
||||
const negativeMatch = trimmed.match(/(?:负向提示词|负面提示词|反向提示词|Negative Prompt|Negative)\s*[::]\s*([\s\S]*)$/i);
|
||||
const prompt = (positiveMatch?.[1] || trimmed).trim();
|
||||
const negativePrompt = negativeMatch?.[1]?.trim();
|
||||
|
||||
return {
|
||||
prompt,
|
||||
negativePrompt: negativePrompt || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const {
|
||||
prompt,
|
||||
customApiConfig,
|
||||
systemPrefix,
|
||||
} = body as {
|
||||
prompt?: string;
|
||||
modelName?: string;
|
||||
customApiConfig?: CustomApiConfig;
|
||||
systemPrefix?: string;
|
||||
};
|
||||
|
||||
if (!prompt) {
|
||||
return NextResponse.json({ error: '请提供创作描述' }, { status: 400 });
|
||||
}
|
||||
|
||||
const resolvedCustomApiConfig = await resolveServerApiConfig(request, customApiConfig);
|
||||
|
||||
// Use custom/system multimodal model API if provided
|
||||
if (resolvedCustomApiConfig && resolvedCustomApiConfig.apiKey) {
|
||||
const resolvedApiKey = resolvedCustomApiConfig.apiKey;
|
||||
const endpoint = resolvedCustomApiConfig.apiUrl;
|
||||
if (!endpoint) {
|
||||
return NextResponse.json({ error: '多模态模型API未配置请求地址' }, { status: 400 });
|
||||
}
|
||||
if (!resolvedCustomApiConfig.modelName) {
|
||||
return NextResponse.json({ error: '多模态模型API未配置模型名称' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Build system message with optional prefix
|
||||
const baseInstruction = systemPrefix
|
||||
? `${systemPrefix}。`
|
||||
: '你是一个专业的AI绘图/视频提示词优化专家。';
|
||||
const systemMessage = `${baseInstruction}请基于用户描述同时生成正向提示词和反向/负面提示词。正向提示词要更详细、更有画面感、更适合生成模型;负面提示词要列出应避免的低质量、畸形、错误结构、画面瑕疵、文字水印等内容。只返回JSON,不要解释,格式为:{"prompt":"优化后的正向提示词","negativePrompt":"优化后的负面提示词"}`;
|
||||
|
||||
const headers = buildCustomApiHeaders(resolvedApiKey);
|
||||
const chatBody = {
|
||||
model: resolvedCustomApiConfig.modelName,
|
||||
stream: false,
|
||||
messages: [
|
||||
{ role: 'system', content: systemMessage },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
};
|
||||
|
||||
console.log('[Suggest Prompt] Using custom multimodal model:', resolvedCustomApiConfig.modelName, '| prefix:', systemPrefix || 'default');
|
||||
|
||||
try {
|
||||
const response = await fetchWithRetry(
|
||||
endpoint,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(chatBody),
|
||||
},
|
||||
SUGGEST_TIMEOUT,
|
||||
1,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('[Suggest Prompt] API error:', response.status, errorText.slice(0, 200));
|
||||
return NextResponse.json(
|
||||
{ error: parseCustomApiError(response.status, errorText) },
|
||||
{ status: response.status >= 500 ? 502 : response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const choices = (data as Record<string, unknown>).choices as Array<Record<string, unknown>> | undefined;
|
||||
if (choices && choices.length > 0) {
|
||||
const message = choices[0].message as Record<string, unknown>;
|
||||
const content = message?.content;
|
||||
if (typeof content === 'string' && content.trim()) {
|
||||
return NextResponse.json(parseOptimizedPrompt(content));
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: '多模态模型未返回有效内容' }, { status: 502 });
|
||||
} catch (fetchError: unknown) {
|
||||
const msg = fetchError instanceof Error ? fetchError.message : '请求失败';
|
||||
console.error('[Suggest Prompt] Fetch error:', msg);
|
||||
return NextResponse.json({ error: `提示词优化失败: ${msg}` }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
// No multimodal model configured
|
||||
return NextResponse.json({ error: '未配置多模态模型,请在API设置中添加多模态模型' }, { status: 400 });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : '提示词优化失败';
|
||||
console.error('[Suggest Prompt Error]', message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
651
src/app/api/generate/video/route.ts
Normal file
651
src/app/api/generate/video/route.ts
Normal file
@@ -0,0 +1,651 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { VideoGenerationClient, Config, HeaderUtils } from 'coze-coding-dev-sdk';
|
||||
import { buildCustomApiHeaders, fetchWithRetry, parseCustomApiError, parseCustomApiJsonWithProgress } from '@/lib/custom-api-fetch';
|
||||
import { getAspectRatioPromptHint } from '@/lib/model-config';
|
||||
import { localStorage } from '@/lib/local-storage';
|
||||
import { isTrustedInternalGenerationRequest, isUuid, resolveServerApiConfig } from '@/lib/server-api-config';
|
||||
import { updateGenerationJobProgress } from '@/lib/generation-job-estimates';
|
||||
import {
|
||||
compressImageBufferForUpstream,
|
||||
dataUrlToImageBuffer,
|
||||
imageBufferToDataUrl,
|
||||
} from '@/lib/server-image-compression';
|
||||
|
||||
interface CustomApiConfig {
|
||||
apiUrl: string;
|
||||
modelName: string;
|
||||
apiKey: string;
|
||||
provider: string;
|
||||
customApiKeyId?: string;
|
||||
systemApiId?: string;
|
||||
}
|
||||
|
||||
const GENERATION_TIMEOUT = 180_000;
|
||||
const MAX_UPSTREAM_REFERENCE_IMAGE_BYTES = Number(process.env.MAX_UPSTREAM_REFERENCE_IMAGE_BYTES || 700 * 1024);
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* Upload a media data URL to S3 storage and return a presigned URL.
|
||||
* Includes a 45s timeout to prevent blocking the response.
|
||||
*/
|
||||
async function persistMediaToStorage(dataUrl: string, prefix: string): Promise<string> {
|
||||
if (!dataUrl.startsWith('data:')) return dataUrl;
|
||||
|
||||
try {
|
||||
const match = dataUrl.match(/^data:((?:image|video)\/[^;]+);base64,(.+)$/);
|
||||
if (!match) return dataUrl;
|
||||
const [, mimeType, base64Data] = match;
|
||||
const ext = mimeType.split('/')[1] || 'mp4';
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
const fileName = `${prefix}/${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
|
||||
const fileKey = await withTimeout(
|
||||
localStorage.uploadFile({ fileContent: buffer, fileName, contentType: mimeType }),
|
||||
45_000,
|
||||
'Local uploadFile (video)',
|
||||
);
|
||||
|
||||
if (!fileKey) {
|
||||
console.error('[Persist Video Media] uploadFile returned empty key');
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
const presignedUrl = await withTimeout(
|
||||
localStorage.generatePresignedUrl({ key: fileKey, expireTime: 2592000 }),
|
||||
10_000,
|
||||
'Local generatePresignedUrl (video)',
|
||||
);
|
||||
|
||||
if (presignedUrl) {
|
||||
console.log('[Persist Video Media] Success, key:', fileKey, 'size:', buffer.length, 'bytes');
|
||||
return presignedUrl;
|
||||
}
|
||||
|
||||
return dataUrl;
|
||||
} catch (err) {
|
||||
console.error('[Persist Video Media Error]', err instanceof Error ? err.message : err);
|
||||
return dataUrl;
|
||||
}
|
||||
}
|
||||
|
||||
async function persistRemoteUrlToStorage(url: string, prefix: string): Promise<string> {
|
||||
if (!url.startsWith('http')) return url;
|
||||
|
||||
try {
|
||||
const fileKey = await withTimeout(
|
||||
localStorage.uploadFromUrl({ url, timeout: 60000 }),
|
||||
60_000,
|
||||
'Local uploadFromUrl (video)',
|
||||
);
|
||||
if (!fileKey) return url;
|
||||
|
||||
const presignedUrl = await withTimeout(
|
||||
localStorage.generatePresignedUrl({ key: fileKey, expireTime: 2592000 }),
|
||||
10_000,
|
||||
'Local generatePresignedUrl (video remote)',
|
||||
);
|
||||
|
||||
if (presignedUrl) {
|
||||
console.log('[Persist Remote Video URL] Success, key:', fileKey);
|
||||
return presignedUrl;
|
||||
}
|
||||
return url;
|
||||
} catch (err) {
|
||||
console.warn('[Persist Remote Video URL] Failed, using original URL:', err instanceof Error ? err.message : err);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/** Helper: wrap a promise with a timeout that rejects with a descriptive message */
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
||||
promise.then(
|
||||
(v) => { clearTimeout(timer); resolve(v); },
|
||||
(e) => { clearTimeout(timer); reject(e); },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function persistAllMediaUrls(urls: string[], prefix: string): Promise<string[]> {
|
||||
const MAX_DATA_URL_SIZE = 10 * 1024 * 1024; // 10MB limit for video data URLs
|
||||
const results = await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
try {
|
||||
if (url.startsWith('data:')) {
|
||||
const result = await persistMediaToStorage(url, prefix);
|
||||
if (result.startsWith('data:') && result.length > MAX_DATA_URL_SIZE) {
|
||||
console.warn('[Persist Video] Data URL too large (' + Math.round(result.length / 1024 / 1024) + 'MB), skipping');
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (url.startsWith('http')) return persistRemoteUrlToStorage(url, prefix);
|
||||
return url;
|
||||
} catch (err) {
|
||||
console.error('[persistAllMediaUrls video] Error:', err instanceof Error ? err.message : err);
|
||||
if (url.startsWith('data:') && url.length > MAX_DATA_URL_SIZE) return null;
|
||||
return url;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results.filter((u): u is string => u !== null);
|
||||
}
|
||||
|
||||
async function uploadDataUrlAndGetPublicUrl(dataUrl: string): Promise<string | null> {
|
||||
try {
|
||||
const match = dataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/);
|
||||
if (!match) return null;
|
||||
const [, mimeType, base64Data] = match;
|
||||
const ext = mimeType.split('/')[1] || 'png';
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
const fileName = `img2vid-ref/${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
|
||||
const fileKey = await localStorage.uploadFile({ fileContent: buffer, fileName, contentType: mimeType });
|
||||
if (!fileKey) return null;
|
||||
|
||||
const presignedUrl = await localStorage.generatePresignedUrl({ key: fileKey, expireTime: 3600 });
|
||||
console.log('[Upload Ref Video Image] Success, key:', fileKey);
|
||||
return presignedUrl || null;
|
||||
} catch (err) {
|
||||
console.error('[Upload Ref Video Image Error]', err instanceof Error ? err.message : err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function toPublicImageUrl(image: string): Promise<string> {
|
||||
if (!image.startsWith('data:')) return image;
|
||||
const uploadedUrl = await uploadDataUrlAndGetPublicUrl(image);
|
||||
return uploadedUrl || image;
|
||||
}
|
||||
|
||||
async function normalizeReferenceImageForUpstream(image: string): Promise<string> {
|
||||
const parsedImage = dataUrlToImageBuffer(image);
|
||||
if (!parsedImage) return image;
|
||||
|
||||
try {
|
||||
const compressed = await compressImageBufferForUpstream(parsedImage, {
|
||||
maxBytes: MAX_UPSTREAM_REFERENCE_IMAGE_BYTES,
|
||||
});
|
||||
if (compressed.changed) {
|
||||
console.log('[Custom API img2vid] Compressed reference image:', compressed.originalBytes, '→', compressed.buffer.length);
|
||||
}
|
||||
return imageBufferToDataUrl({ buffer: compressed.buffer, mimeType: compressed.mimeType });
|
||||
} catch (err) {
|
||||
console.warn('[Custom API img2vid] Reference image compression failed, using original:', err instanceof Error ? err.message : err);
|
||||
return image;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeReferenceImages(image?: string, images?: unknown, extraImages?: unknown): string[] {
|
||||
const refs: string[] = [];
|
||||
if (image) refs.push(image);
|
||||
if (Array.isArray(images)) {
|
||||
for (const item of images) {
|
||||
if (typeof item === 'string' && item.trim()) refs.push(item);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(extraImages)) {
|
||||
for (const item of extraImages) {
|
||||
if (typeof item === 'string' && item.trim()) refs.push(item);
|
||||
}
|
||||
}
|
||||
return Array.from(new Set(refs));
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
return Array.from(new Set(values));
|
||||
}
|
||||
|
||||
function deriveChatCompletionsUrl(originalUrl: string): string {
|
||||
if (originalUrl.includes('/chat/completions')) return originalUrl;
|
||||
return originalUrl
|
||||
.replace(/\/(videos|images)\/(generations|edits).*/i, '/chat/completions')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function deriveImagesEditsUrl(originalUrl: string): string {
|
||||
if (originalUrl.includes('/images/edits')) return originalUrl;
|
||||
return originalUrl
|
||||
.replace(/\/(videos|images)\/generations.*/i, '/images/edits')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function extractVideosFromChatResponse(data: Record<string, unknown>): string[] {
|
||||
const videos: string[] = [];
|
||||
const choices = data.choices as Array<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(choices)) {
|
||||
for (const choice of choices) {
|
||||
const message = choice.message as Record<string, unknown> | undefined;
|
||||
if (!message) continue;
|
||||
const content = message.content;
|
||||
if (typeof content === 'string') {
|
||||
if (content.startsWith('http') || content.startsWith('data:video/')) videos.push(content);
|
||||
const urlMatch = content.match(/(https?:\/\/[^\s"']+\.(mp4|mov|webm)[^\s"']*)/i);
|
||||
if (urlMatch) videos.push(urlMatch[1]);
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const item of content as Array<Record<string, unknown>>) {
|
||||
if (item.type === 'video_url' && item.video_url) {
|
||||
const url = (item.video_url as Record<string, unknown>).url;
|
||||
if (typeof url === 'string') videos.push(url);
|
||||
}
|
||||
if (item.type === 'text' && typeof item.text === 'string') {
|
||||
const text = item.text as string;
|
||||
if (text.startsWith('http') || text.startsWith('data:video/')) videos.push(text);
|
||||
const urlMatch = text.match(/(https?:\/\/[^\s"']+\.(mp4|mov|webm)[^\s"']*)/i);
|
||||
if (urlMatch) videos.push(urlMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return videos;
|
||||
}
|
||||
|
||||
function extractVideosFromGenerationsResponse(data: Record<string, unknown>): string[] {
|
||||
const videos: string[] = [];
|
||||
if (Array.isArray(data.data)) {
|
||||
for (const item of data.data as Array<Record<string, unknown>>) {
|
||||
if (typeof item === 'string') { videos.push(item); continue; }
|
||||
if (item.url && typeof item.url === 'string') videos.push(item.url);
|
||||
if (item.video_url && typeof item.video_url === 'string') videos.push(item.video_url);
|
||||
if (item.b64_json && typeof item.b64_json === 'string') {
|
||||
videos.push(`data:video/mp4;base64,${item.b64_json}`);
|
||||
}
|
||||
}
|
||||
} else if (typeof data.url === 'string') {
|
||||
videos.push(data.url);
|
||||
} else if (typeof data.video_url === 'string') {
|
||||
videos.push(data.video_url);
|
||||
}
|
||||
return videos;
|
||||
}
|
||||
|
||||
async function customApiImageToVideo(
|
||||
customApiConfig: CustomApiConfig,
|
||||
prompt: string | undefined,
|
||||
negativePrompt: string | undefined,
|
||||
image: string,
|
||||
referenceImages: string[] = [],
|
||||
aspectRatio?: string,
|
||||
duration?: number,
|
||||
fps?: number,
|
||||
onProgress?: (progress: Record<string, unknown>) => void | Promise<void>,
|
||||
): Promise<NextResponse> {
|
||||
const endpoint = customApiConfig.apiUrl;
|
||||
if (!endpoint) {
|
||||
return NextResponse.json({ error: '自定义API未配置请求地址' }, { status: 400 });
|
||||
}
|
||||
if (!customApiConfig.modelName) {
|
||||
return NextResponse.json({ error: '自定义API未配置模型名称' }, { status: 400 });
|
||||
}
|
||||
|
||||
const normalizedImage = await normalizeReferenceImageForUpstream(image);
|
||||
const normalizedReferenceImages = uniqueStrings(await Promise.all(
|
||||
normalizeReferenceImages(normalizedImage, referenceImages).map(normalizeReferenceImageForUpstream),
|
||||
));
|
||||
|
||||
// Prepare image buffer for FormData upload
|
||||
let imageBuffer: Buffer | null = null;
|
||||
let imageMimeType = 'image/png';
|
||||
if (normalizedImage.startsWith('data:')) {
|
||||
const parsedImage = dataUrlToImageBuffer(normalizedImage);
|
||||
if (parsedImage) {
|
||||
imageMimeType = parsedImage.mimeType;
|
||||
imageBuffer = parsedImage.buffer;
|
||||
}
|
||||
}
|
||||
|
||||
// Upload reference image to S3
|
||||
const imageUrl = await toPublicImageUrl(normalizedImage);
|
||||
const imageUrls = await Promise.all(normalizedReferenceImages.map(toPublicImageUrl));
|
||||
|
||||
let promptText = prompt || '根据参考图生成视频';
|
||||
if (negativePrompt) promptText += `\n\n负面提示词: ${negativePrompt}`;
|
||||
// Augment prompt with aspect ratio hint
|
||||
if (aspectRatio) {
|
||||
const hint = getAspectRatioPromptHint(aspectRatio);
|
||||
if (hint) promptText += `\n\n[${hint}]`;
|
||||
}
|
||||
|
||||
const headers = buildCustomApiHeaders(customApiConfig.apiKey);
|
||||
|
||||
// Get raw base64 for strategies that need it
|
||||
let rawBase64 = normalizedImage;
|
||||
if (normalizedImage.startsWith('data:')) {
|
||||
const commaIndex = normalizedImage.indexOf(',');
|
||||
if (commaIndex !== -1) rawBase64 = normalizedImage.substring(commaIndex + 1);
|
||||
}
|
||||
|
||||
const strategyResults: string[] = [];
|
||||
let firstUpstreamError: { error: string; status: number } | null = null;
|
||||
|
||||
// --- Strategy 1: images/edits with multipart/form-data ---
|
||||
// Same as img2img - Cherry Studio uses multipart/form-data for image-based requests
|
||||
if (imageBuffer) {
|
||||
const editsUrl = deriveImagesEditsUrl(endpoint);
|
||||
console.log('[Custom API img2vid → 策略1: images/edits (FormData)] URL:', editsUrl, '| model:', customApiConfig.modelName);
|
||||
try {
|
||||
const boundary = `----FormBoundary${Date.now()}${Math.random().toString(36).slice(2)}`;
|
||||
const parts: Buffer[] = [];
|
||||
|
||||
const textFields: Record<string, string> = {
|
||||
model: customApiConfig.modelName,
|
||||
prompt: promptText,
|
||||
};
|
||||
if (aspectRatio) textFields.aspect_ratio = aspectRatio;
|
||||
if (duration) textFields.duration = String(duration);
|
||||
if (fps) textFields.fps = String(fps);
|
||||
|
||||
for (const [key, value] of Object.entries(textFields)) {
|
||||
parts.push(Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${value}\r\n`
|
||||
));
|
||||
}
|
||||
|
||||
const ext = imageMimeType.split('/')[1] || 'png';
|
||||
const imageBuffers: Array<{ mimeType: string; buffer: Buffer }> = [];
|
||||
for (const ref of normalizedReferenceImages) {
|
||||
if (!ref.startsWith('data:')) continue;
|
||||
const parsedImage = dataUrlToImageBuffer(ref);
|
||||
if (!parsedImage) continue;
|
||||
imageBuffers.push(parsedImage);
|
||||
}
|
||||
|
||||
imageBuffers.forEach((item, index) => {
|
||||
const fieldName = index === 0 ? 'image' : 'images[]';
|
||||
const itemExt = item.mimeType.split('/')[1] || ext;
|
||||
parts.push(Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="${fieldName}"; filename="image-${index + 1}.${itemExt}"\r\nContent-Type: ${item.mimeType}\r\n\r\n`
|
||||
));
|
||||
parts.push(item.buffer);
|
||||
parts.push(Buffer.from(`\r\n`));
|
||||
});
|
||||
parts.push(Buffer.from(`--${boundary}--\r\n`));
|
||||
|
||||
const bodyBuffer = Buffer.concat(parts);
|
||||
|
||||
const editsResponse = await fetchWithRetry(
|
||||
editsUrl,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${customApiConfig.apiKey}`,
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
},
|
||||
body: bodyBuffer,
|
||||
},
|
||||
GENERATION_TIMEOUT,
|
||||
1,
|
||||
);
|
||||
if (editsResponse.ok) {
|
||||
const editsData = await parseCustomApiJsonWithProgress(editsResponse, onProgress);
|
||||
let videos = extractVideosFromGenerationsResponse(editsData as Record<string, unknown>);
|
||||
if (videos.length === 0) videos = extractVideosFromChatResponse(editsData as Record<string, unknown>);
|
||||
if (videos.length > 0) {
|
||||
const persistedVideos = await persistAllMediaUrls(videos, 'generated/videos');
|
||||
return NextResponse.json({ videos: persistedVideos });
|
||||
}
|
||||
strategyResults.push('策略1(images/edits FormData): 响应中无视频数据');
|
||||
} else {
|
||||
const errorText = await editsResponse.text();
|
||||
const parsedError = parseCustomApiError(editsResponse.status, errorText);
|
||||
if (!firstUpstreamError) firstUpstreamError = { error: parsedError, status: editsResponse.status };
|
||||
strategyResults.push(parsedError);
|
||||
}
|
||||
} catch (err) {
|
||||
strategyResults.push(`策略1(images/edits FormData): ${err instanceof Error ? err.message : '异常'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Strategy 2: chat/completions with image_url ---
|
||||
const chatUrl = deriveChatCompletionsUrl(endpoint);
|
||||
const chatBody: Record<string, unknown> = {
|
||||
model: customApiConfig.modelName,
|
||||
stream: false,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
...imageUrls.map(url => ({ type: 'image_url', image_url: { url } })),
|
||||
{ type: 'text', text: promptText },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
if (aspectRatio) chatBody.aspect_ratio = aspectRatio;
|
||||
if (duration) chatBody.duration = duration;
|
||||
if (fps) chatBody.fps = fps;
|
||||
|
||||
console.log('[Custom API img2vid → 策略2: chat/completions] URL:', chatUrl, '| model:', customApiConfig.modelName);
|
||||
try {
|
||||
const chatResponse = await fetchWithRetry(chatUrl, { method: 'POST', headers, body: JSON.stringify(chatBody) }, GENERATION_TIMEOUT, 1);
|
||||
if (chatResponse.ok) {
|
||||
const chatData = await parseCustomApiJsonWithProgress(chatResponse, onProgress);
|
||||
let videos = extractVideosFromChatResponse(chatData as Record<string, unknown>);
|
||||
if (videos.length === 0) videos = extractVideosFromGenerationsResponse(chatData as Record<string, unknown>);
|
||||
if (videos.length > 0) {
|
||||
const persistedVideos = await persistAllMediaUrls(videos, 'generated/videos');
|
||||
return NextResponse.json({ videos: persistedVideos });
|
||||
}
|
||||
} else {
|
||||
const errorText = await chatResponse.text();
|
||||
const parsedError = parseCustomApiError(chatResponse.status, errorText);
|
||||
if (!firstUpstreamError) firstUpstreamError = { error: parsedError, status: chatResponse.status };
|
||||
strategyResults.push(parsedError);
|
||||
}
|
||||
} catch (err) {
|
||||
strategyResults.push(`策略2(chat/completions): ${err instanceof Error ? err.message : '异常'}`);
|
||||
}
|
||||
|
||||
// --- Strategy 3: images/generations with init_image ---
|
||||
const imgBody: Record<string, unknown> = {
|
||||
model: customApiConfig.modelName,
|
||||
prompt: promptText,
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
response_format: 'b64_json',
|
||||
init_image: rawBase64,
|
||||
images: imageUrls,
|
||||
};
|
||||
if (aspectRatio) imgBody.aspect_ratio = aspectRatio;
|
||||
if (duration) imgBody.duration = duration;
|
||||
if (fps) imgBody.fps = fps;
|
||||
|
||||
console.log('[Custom API img2vid → 策略3: images/generations] URL:', endpoint, '| model:', customApiConfig.modelName);
|
||||
try {
|
||||
const imgResponse = await fetchWithRetry(endpoint, { method: 'POST', headers, body: JSON.stringify(imgBody) }, GENERATION_TIMEOUT, 1);
|
||||
if (!imgResponse.ok) {
|
||||
const errorText = await imgResponse.text();
|
||||
const parsedError = parseCustomApiError(imgResponse.status, errorText);
|
||||
if (!firstUpstreamError) firstUpstreamError = { error: parsedError, status: imgResponse.status };
|
||||
strategyResults.push(parsedError);
|
||||
} else {
|
||||
const imgData = await parseCustomApiJsonWithProgress(imgResponse, onProgress);
|
||||
const videos = extractVideosFromGenerationsResponse(imgData as Record<string, unknown>);
|
||||
if (videos.length > 0) {
|
||||
const persistedVideos = await persistAllMediaUrls(videos, 'generated/videos');
|
||||
return NextResponse.json({ videos: persistedVideos });
|
||||
}
|
||||
strategyResults.push('策略3(images/generations): 响应中无视频数据');
|
||||
}
|
||||
} catch (err) {
|
||||
strategyResults.push(`策略3(images/generations): ${err instanceof Error ? err.message : '异常'}`);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: firstUpstreamError?.error || strategyResults.find(Boolean) || '图生视频失败',
|
||||
},
|
||||
{ status: firstUpstreamError && firstUpstreamError.status < 500 ? firstUpstreamError.status : 502 }
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const {
|
||||
prompt,
|
||||
negativePrompt,
|
||||
model = 'doubao-seedance-1-5-pro-251215',
|
||||
aspectRatio = '16:9',
|
||||
duration = 5,
|
||||
fps = 30,
|
||||
image,
|
||||
images,
|
||||
extraImages,
|
||||
customApiConfig,
|
||||
} = body as {
|
||||
prompt?: string;
|
||||
negativePrompt?: string;
|
||||
model?: string;
|
||||
aspectRatio?: string;
|
||||
duration?: number;
|
||||
fps?: number;
|
||||
image?: string;
|
||||
images?: string[];
|
||||
extraImages?: string[];
|
||||
customApiConfig?: CustomApiConfig;
|
||||
};
|
||||
const referenceImages = normalizeReferenceImages(image, images, extraImages);
|
||||
|
||||
if (!prompt && referenceImages.length === 0) {
|
||||
return NextResponse.json({ error: '请提供视频描述或上传图片' }, { status: 400 });
|
||||
}
|
||||
const trustedInternalRequest = isTrustedInternalGenerationRequest(request);
|
||||
const trustedUserId = trustedInternalRequest
|
||||
? request.headers.get('x-miaojing-generation-user-id')
|
||||
: null;
|
||||
const generationJobId = trustedInternalRequest
|
||||
? request.headers.get('x-miaojing-generation-job-id')
|
||||
: null;
|
||||
const resolvedCustomApiConfig = await resolveServerApiConfig(
|
||||
request,
|
||||
customApiConfig,
|
||||
isUuid(trustedUserId) ? trustedUserId : null,
|
||||
);
|
||||
const handleUpstreamProgress = (progress: Record<string, unknown>) => updateGenerationJobProgress(
|
||||
isUuid(generationJobId) ? generationJobId : null,
|
||||
progress,
|
||||
);
|
||||
|
||||
// ---- Custom API mode ----
|
||||
if (resolvedCustomApiConfig && resolvedCustomApiConfig.apiKey) {
|
||||
const resolvedApiKey = resolvedCustomApiConfig.apiKey;
|
||||
try {
|
||||
if (referenceImages.length > 0) {
|
||||
return await customApiImageToVideo(
|
||||
resolvedCustomApiConfig as CustomApiConfig,
|
||||
prompt,
|
||||
negativePrompt,
|
||||
referenceImages[0],
|
||||
referenceImages,
|
||||
aspectRatio,
|
||||
duration,
|
||||
fps,
|
||||
handleUpstreamProgress,
|
||||
);
|
||||
}
|
||||
|
||||
// Text-to-video
|
||||
const endpoint = resolvedCustomApiConfig.apiUrl;
|
||||
if (!endpoint) return NextResponse.json({ error: '自定义API未配置请求地址' }, { status: 400 });
|
||||
if (!resolvedCustomApiConfig.modelName) return NextResponse.json({ error: '自定义API未配置模型名称' }, { status: 400 });
|
||||
|
||||
// Augment prompt with aspect ratio hint as fallback
|
||||
const ratioHint = aspectRatio ? getAspectRatioPromptHint(aspectRatio) : '';
|
||||
const augmentedPrompt = ratioHint ? `${prompt || ''}\n\n[${ratioHint}]` : (prompt || '');
|
||||
|
||||
const requestBody: Record<string, unknown> = {
|
||||
model: resolvedCustomApiConfig.modelName,
|
||||
prompt: augmentedPrompt,
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
response_format: 'b64_json',
|
||||
};
|
||||
if (negativePrompt) requestBody.negative_prompt = negativePrompt;
|
||||
// Pass creation parameters for APIs that support them
|
||||
if (aspectRatio) requestBody.aspect_ratio = aspectRatio;
|
||||
if (duration) requestBody.duration = duration;
|
||||
if (fps) requestBody.fps = fps;
|
||||
|
||||
console.log('[Custom API Video] Text-to-video, sending to:', endpoint, '| model:', requestBody.model);
|
||||
|
||||
let customResponse: Response;
|
||||
try {
|
||||
customResponse = await fetchWithRetry(
|
||||
endpoint,
|
||||
{ method: 'POST', headers: buildCustomApiHeaders(resolvedApiKey), body: JSON.stringify(requestBody) },
|
||||
GENERATION_TIMEOUT, 1,
|
||||
);
|
||||
} catch (fetchError: unknown) {
|
||||
if (fetchError instanceof DOMException && fetchError.name === 'AbortError') {
|
||||
return NextResponse.json({ error: '自定义API请求超时(180秒)' }, { status: 504 });
|
||||
}
|
||||
const msg = fetchError instanceof Error ? fetchError.message : '请求失败';
|
||||
return NextResponse.json({ error: `自定义API网络错误: ${msg}` }, { status: 502 });
|
||||
}
|
||||
|
||||
if (!customResponse.ok) {
|
||||
const errorText = await customResponse.text();
|
||||
return NextResponse.json(
|
||||
{ error: parseCustomApiError(customResponse.status, errorText) },
|
||||
{ status: customResponse.status >= 500 ? 502 : customResponse.status }
|
||||
);
|
||||
}
|
||||
|
||||
const customData = await parseCustomApiJsonWithProgress(customResponse, handleUpstreamProgress);
|
||||
const videos = extractVideosFromGenerationsResponse(customData as Record<string, unknown>);
|
||||
if (videos.length === 0) {
|
||||
return NextResponse.json({ error: '自定义API未返回有效视频数据', raw: customData }, { status: 502 });
|
||||
}
|
||||
// Persist all data URLs and remote URLs to S3
|
||||
const persistedVideos = await persistAllMediaUrls(videos, 'generated/videos');
|
||||
return NextResponse.json({ videos: persistedVideos });
|
||||
} catch (customError: unknown) {
|
||||
const msg = customError instanceof Error ? customError.message : '自定义API请求异常';
|
||||
console.error('[Custom API Video Exception]', msg);
|
||||
return NextResponse.json({ error: `自定义API异常: ${msg}` }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Default mode: use coze-coding-dev-sdk ----
|
||||
const customHeaders = HeaderUtils.extractForwardHeaders(request.headers);
|
||||
const config = new Config();
|
||||
const client = new VideoGenerationClient(config, customHeaders);
|
||||
|
||||
const contentItems: Array<{ type: string; text?: string; image_url?: { url: string }; role?: string }> = [];
|
||||
referenceImages.forEach((url, index) => {
|
||||
contentItems.push({ type: 'image_url', image_url: { url }, role: index === 0 ? 'first_frame' : 'reference' });
|
||||
});
|
||||
if (prompt) {
|
||||
contentItems.push({ type: 'text', text: prompt });
|
||||
}
|
||||
|
||||
const ratioMap: Record<string, '16:9' | '9:16' | '1:1' | '4:3' | '3:4'> = {
|
||||
'16:9': '16:9', '9:16': '9:16', '1:1': '1:1', '4:3': '4:3', '3:4': '3:4',
|
||||
};
|
||||
|
||||
const response = await client.videoGeneration(contentItems as Parameters<typeof client.videoGeneration>[0], {
|
||||
model,
|
||||
duration: Math.min(Math.max(duration, 4), 12),
|
||||
ratio: ratioMap[aspectRatio] || '16:9',
|
||||
resolution: '720p',
|
||||
generateAudio: true,
|
||||
});
|
||||
|
||||
const videos: string[] = [];
|
||||
if (response.videoUrl) videos.push(response.videoUrl);
|
||||
if (videos.length === 0) return NextResponse.json({ error: '视频生成失败,请稍后重试' }, { status: 500 });
|
||||
|
||||
// Persist SDK video URLs to S3 for reliable browser access
|
||||
const persistedVideos = await persistAllMediaUrls(videos, 'generated/videos');
|
||||
return NextResponse.json({ videos: persistedVideos });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : '视频生成失败';
|
||||
console.error('[Video Generation Error]', message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
110
src/app/api/generation-jobs/[id]/route.ts
Normal file
110
src/app/api/generation-jobs/[id]/route.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { getAuthenticatedUser } from '@/lib/session-auth';
|
||||
import {
|
||||
buildInitialGenerationProgress,
|
||||
ensureGenerationJobRuntimeSchema,
|
||||
getGenerationJobEstimate,
|
||||
} from '@/lib/generation-job-estimates';
|
||||
|
||||
const UUID_REGEX =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const user = await getAuthenticatedUser(request);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
if (!UUID_REGEX.test(id)) {
|
||||
return NextResponse.json({ error: '任务ID格式无效' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureGenerationJobRuntimeSchema(client);
|
||||
await client.query(
|
||||
`UPDATE generation_jobs
|
||||
SET status = 'failed',
|
||||
error = '任务执行超时或被服务重启中断',
|
||||
payload = '{}'::jsonb,
|
||||
finished_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND status = 'running'
|
||||
AND updated_at < NOW() - INTERVAL '30 minutes'`,
|
||||
[id],
|
||||
);
|
||||
|
||||
const result = await client.query(
|
||||
`SELECT id, type, status, result, error, provider, model_name, api_url, progress,
|
||||
created_at, started_at, finished_at, updated_at,
|
||||
CASE
|
||||
WHEN started_at IS NOT NULL
|
||||
THEN FLOOR(EXTRACT(EPOCH FROM (COALESCE(finished_at, NOW()) - started_at)))::int
|
||||
ELSE 0
|
||||
END AS elapsed_seconds
|
||||
FROM generation_jobs
|
||||
WHERE id = $1
|
||||
AND (user_id = $2 OR $3 = true)
|
||||
LIMIT 1`,
|
||||
[id, user.userId, user.role === 'admin' || user.role === 'enterprise_admin'],
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: '任务不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
const job = result.rows[0];
|
||||
const progress = job.progress && typeof job.progress === 'object' ? job.progress : {};
|
||||
const progressEstimate = Number(progress.estimateSeconds || progress.etaSeconds || 0);
|
||||
let estimateSeconds = Number.isFinite(progressEstimate) && progressEstimate > 0
|
||||
? Math.ceil(progressEstimate)
|
||||
: 0;
|
||||
let etaSource = typeof progress.source === 'string' ? progress.source : 'default';
|
||||
let etaSampleCount = Number(progress.sampleCount || 0);
|
||||
let etaWindowDays = progress.windowDays ?? null;
|
||||
|
||||
if (estimateSeconds <= 0 && (job.status === 'queued' || job.status === 'running')) {
|
||||
const estimate = await getGenerationJobEstimate(
|
||||
client,
|
||||
job.type,
|
||||
String(job.provider || ''),
|
||||
String(job.model_name || ''),
|
||||
);
|
||||
estimateSeconds = estimate.estimateSeconds;
|
||||
etaSource = estimate.source;
|
||||
etaSampleCount = estimate.sampleCount;
|
||||
etaWindowDays = estimate.windowDays;
|
||||
await client.query(
|
||||
`UPDATE generation_jobs
|
||||
SET progress = COALESCE(progress, '{}'::jsonb) || $2::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[id, JSON.stringify(buildInitialGenerationProgress(estimate))],
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...job,
|
||||
estimateSeconds,
|
||||
eta: {
|
||||
estimateSeconds,
|
||||
source: etaSource,
|
||||
sampleCount: etaSampleCount,
|
||||
windowDays: etaWindowDays,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[generation-jobs] GET error:', err);
|
||||
return NextResponse.json({ error: '查询生成任务失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
105
src/app/api/generation-jobs/route.ts
Normal file
105
src/app/api/generation-jobs/route.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import {
|
||||
markStaleRunningJobs,
|
||||
processNextGenerationJob,
|
||||
} from '@/lib/generation-job-worker';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
import type { GenerationJobType } from '@/lib/generation-job-runner';
|
||||
import {
|
||||
buildInitialGenerationProgress,
|
||||
ensureGenerationJobRuntimeSchema,
|
||||
getGenerationJobEstimate,
|
||||
resolveGenerationJobIdentity,
|
||||
} from '@/lib/generation-job-estimates';
|
||||
import { writePlatformLog } from '@/lib/platform-logs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
void markStaleRunningJobs();
|
||||
const body = await request.json();
|
||||
const type = body.type as GenerationJobType;
|
||||
const payload = body.payload as Record<string, unknown>;
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: '请先登录后再创建生成任务' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (type !== 'image' && type !== 'video') {
|
||||
return NextResponse.json({ error: '不支持的任务类型' }, { status: 400 });
|
||||
}
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
return NextResponse.json({ error: '缺少任务参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
let jobId = '';
|
||||
let estimateSeconds = type === 'video' ? 300 : 90;
|
||||
let etaSource = 'default';
|
||||
let etaSampleCount = 0;
|
||||
let etaWindowDays: number | null = null;
|
||||
let jobIdentity = { provider: '', modelName: '', apiUrl: '' };
|
||||
try {
|
||||
await ensureGenerationJobRuntimeSchema(client);
|
||||
const identity = await resolveGenerationJobIdentity(client, userId, payload);
|
||||
jobIdentity = identity;
|
||||
const estimate = await getGenerationJobEstimate(client, type, identity.provider, identity.modelName);
|
||||
estimateSeconds = estimate.estimateSeconds;
|
||||
etaSource = estimate.source;
|
||||
etaSampleCount = estimate.sampleCount;
|
||||
etaWindowDays = estimate.windowDays;
|
||||
const result = await client.query(
|
||||
`INSERT INTO generation_jobs (type, status, payload, user_id, provider, model_name, api_url, progress)
|
||||
VALUES ($1, 'queued', $2::jsonb, $3, $4, $5, $6, $7::jsonb)
|
||||
RETURNING id`,
|
||||
[
|
||||
type,
|
||||
JSON.stringify(payload),
|
||||
userId,
|
||||
identity.provider,
|
||||
identity.modelName,
|
||||
identity.apiUrl,
|
||||
JSON.stringify(buildInitialGenerationProgress(estimate)),
|
||||
],
|
||||
);
|
||||
jobId = result.rows[0].id as string;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
void processNextGenerationJob();
|
||||
void writePlatformLog({
|
||||
type: 'generation',
|
||||
level: 'info',
|
||||
action: 'generation_job_created',
|
||||
message: `用户创建${type === 'image' ? '图片' : '视频'}生成任务`,
|
||||
userId,
|
||||
targetType: 'generation_job',
|
||||
targetId: jobId,
|
||||
metadata: {
|
||||
type,
|
||||
provider: jobIdentity.provider,
|
||||
modelName: jobIdentity.modelName,
|
||||
estimateSeconds,
|
||||
etaSource,
|
||||
etaSampleCount,
|
||||
},
|
||||
request,
|
||||
});
|
||||
return NextResponse.json({
|
||||
jobId,
|
||||
status: 'queued',
|
||||
estimateSeconds,
|
||||
eta: {
|
||||
estimateSeconds,
|
||||
source: etaSource,
|
||||
sampleCount: etaSampleCount,
|
||||
windowDays: etaWindowDays,
|
||||
},
|
||||
}, { status: 202 });
|
||||
} catch (err) {
|
||||
console.error('[generation-jobs] POST error:', err);
|
||||
return NextResponse.json({ error: '创建生成任务失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
50
src/app/api/health/route.ts
Normal file
50
src/app/api/health/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
export async function GET() {
|
||||
const storageDir = process.env.LOCAL_STORAGE_DIR || path.join(process.cwd(), 'local-storage');
|
||||
const checks: Record<string, { ok: boolean; message?: string }> = {
|
||||
database: { ok: false },
|
||||
storage: { ok: false },
|
||||
secrets: {
|
||||
ok: Boolean(
|
||||
process.env.JWT_SECRET
|
||||
&& process.env.DATA_ENCRYPTION_KEY
|
||||
&& process.env.GENERATION_INTERNAL_SECRET,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
let client: Awaited<ReturnType<typeof getDbClient>> | null = null;
|
||||
try {
|
||||
client = await getDbClient();
|
||||
await client.query('SELECT 1');
|
||||
checks.database.ok = true;
|
||||
} catch (error) {
|
||||
checks.database.message = error instanceof Error ? error.message : 'database check failed';
|
||||
} finally {
|
||||
client?.release();
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(storageDir, { recursive: true });
|
||||
fs.accessSync(storageDir, fs.constants.R_OK | fs.constants.W_OK);
|
||||
checks.storage.ok = true;
|
||||
} catch (error) {
|
||||
checks.storage.message = error instanceof Error ? error.message : 'storage check failed';
|
||||
}
|
||||
|
||||
const ok = Object.values(checks).every(check => check.ok);
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok,
|
||||
service: 'miaojing',
|
||||
role: process.env.APP_RUNTIME_ROLE || 'full',
|
||||
timestamp: new Date().toISOString(),
|
||||
checks,
|
||||
},
|
||||
{ status: ok ? 200 : 503 },
|
||||
);
|
||||
}
|
||||
64
src/app/api/local-storage/[...path]/route.ts
Normal file
64
src/app/api/local-storage/[...path]/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { localStorage } from '@/lib/local-storage';
|
||||
import path from 'path';
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
try {
|
||||
const { path: pathSegments } = await params;
|
||||
const filePath = normalizeStoragePath(pathSegments.join('/'));
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: 'Invalid file path' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!localStorage.fileExists(filePath)) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const fileBuffer = localStorage.readFile(filePath);
|
||||
const contentType = getContentType(filePath);
|
||||
|
||||
return new NextResponse(new Uint8Array(fileBuffer), {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': `inline; filename="${path.basename(filePath)}"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Local Storage API] Error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStoragePath(value: string): string | null {
|
||||
try {
|
||||
const decoded = decodeURIComponent(value);
|
||||
const normalized = path.posix.normalize(decoded).replace(/^\/+/, '');
|
||||
if (!normalized || normalized.startsWith('..') || normalized.includes('/../') || path.isAbsolute(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getContentType(filePath: string): string {
|
||||
const extension = filePath.split('.').pop()?.toLowerCase();
|
||||
|
||||
const contentTypeMap: Record<string, string> = {
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'webp': 'image/webp',
|
||||
'gif': 'image/gif',
|
||||
'mp4': 'video/mp4',
|
||||
'avi': 'video/x-msvideo',
|
||||
'mov': 'video/quicktime',
|
||||
'wmv': 'video/x-ms-wmv',
|
||||
'pdf': 'application/pdf',
|
||||
'txt': 'text/plain',
|
||||
'json': 'application/json',
|
||||
};
|
||||
|
||||
return contentTypeMap[extension || ''] || 'application/octet-stream';
|
||||
}
|
||||
59
src/app/api/model-config/route.ts
Normal file
59
src/app/api/model-config/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { listSystemApis } from '@/lib/server-api-config';
|
||||
|
||||
function mapProvider(row: Record<string, unknown>) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
name: String(row.name || ''),
|
||||
defaultApiUrl: String(row.default_api_url || ''),
|
||||
defaultModel: String(row.default_model || ''),
|
||||
type: String(row.type || 'image'),
|
||||
website: (row.website as string | null) || null,
|
||||
isActive: row.is_active !== false,
|
||||
sortOrder: Number(row.sort_order || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function mapRecommendation(row: Record<string, unknown>) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
modelName: String(row.model_name || ''),
|
||||
displayName: String(row.display_name || row.model_name || ''),
|
||||
type: String(row.type || 'image'),
|
||||
providerId: (row.provider_id as string | null) || null,
|
||||
isActive: row.is_active !== false,
|
||||
sortOrder: Number(row.sort_order || 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const providers = await client.query(
|
||||
`SELECT id, name, default_api_url, default_model, type, website, is_active, sort_order
|
||||
FROM api_providers
|
||||
WHERE is_active = true
|
||||
ORDER BY sort_order ASC, name ASC`
|
||||
);
|
||||
const recommendations = await client.query(
|
||||
`SELECT id, model_name, display_name, type, provider_id, is_active, sort_order
|
||||
FROM model_recommendations
|
||||
WHERE is_active = true
|
||||
ORDER BY type ASC, sort_order ASC, model_name ASC`
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
providers: providers.rows.map(mapProvider),
|
||||
recommendations: recommendations.rows.map(mapRecommendation),
|
||||
systemApis: await listSystemApis(false),
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[model-config] GET error:', err);
|
||||
return NextResponse.json({ providers: [], recommendations: [] });
|
||||
}
|
||||
}
|
||||
232
src/app/api/profile/route.ts
Normal file
232
src/app/api/profile/route.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { ensureEmailSchema } from '@/lib/email-service';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
import { getRequiredProductionSecret } from '@/lib/runtime-env';
|
||||
import { ensureProfilePreferenceSchema } from '@/lib/profile-preferences';
|
||||
|
||||
function normalizeRoleForTier(role: string | null | undefined, tier: string | null | undefined): string {
|
||||
const currentRole = role || 'user';
|
||||
if (currentRole === 'admin' || currentRole === 'enterprise_admin') return currentRole;
|
||||
return tier && tier !== 'free' ? 'vip' : currentRole === 'vip' ? 'user' : currentRole;
|
||||
}
|
||||
|
||||
function isEmail(value: string): boolean {
|
||||
return value.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
}
|
||||
|
||||
function isSafeAvatarUrl(value: string): boolean {
|
||||
if (!value) return true;
|
||||
if (value.length > 1_000_000) return false;
|
||||
if (/^data:image\/(png|jpe?g|webp|gif);base64,[a-z0-9+/=]+$/i.test(value)) return true;
|
||||
if (/^https?:\/\/[^\s"'<>]+$/i.test(value)) return true;
|
||||
if (/^\/api\/local-storage\/[^\s"'<>]+$/i.test(value)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSafeProfileText(value: string | undefined, maxLength: number): boolean {
|
||||
if (value === undefined) return true;
|
||||
return value.length <= maxLength && !/[\u0000-\u001f\u007f<>]/.test(value);
|
||||
}
|
||||
|
||||
async function verifyPasswordHash(client: Awaited<ReturnType<typeof getDbClient>>, passwordHash: string, password: string): Promise<boolean> {
|
||||
const result = await client.query(
|
||||
'SELECT $1::text = crypt($2::text, $1::text) AS ok',
|
||||
[passwordHash, password]
|
||||
);
|
||||
return result.rows[0]?.ok === true;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const tokenUserId = await getAuthenticatedUserId(request);
|
||||
if (!tokenUserId) {
|
||||
return NextResponse.json({ error: 'Please log in again' }, { status: 401 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
|
||||
try {
|
||||
await ensureEmailSchema(client);
|
||||
await ensureProfilePreferenceSchema(client);
|
||||
const result = await client.query(
|
||||
'SELECT id, email, nickname, phone, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit, avatar_url, created_at, email_verified, email_verified_at, email_bound_at, preferred_theme FROM profiles WHERE id = $1',
|
||||
[tokenUserId],
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const profile = result.rows[0];
|
||||
const normalizedRole = normalizeRoleForTier(profile.role, profile.membership_tier);
|
||||
if (normalizedRole !== (profile.role || 'user')) {
|
||||
profile.role = normalizedRole;
|
||||
await client.query('UPDATE profiles SET role = $1, updated_at = NOW() WHERE id = $2', [normalizedRole, profile.id]);
|
||||
}
|
||||
|
||||
return NextResponse.json({ profile });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get profile';
|
||||
console.error('[Profile Error]', message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const tokenUserId = await getAuthenticatedUserId(request);
|
||||
if (!tokenUserId) {
|
||||
return NextResponse.json({ error: 'Please log in again' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const hasEmail = Object.prototype.hasOwnProperty.call(body, 'email');
|
||||
const hasNickname = Object.prototype.hasOwnProperty.call(body, 'nickname');
|
||||
const hasPhone = Object.prototype.hasOwnProperty.call(body, 'phone');
|
||||
const hasAvatarUrl = Object.prototype.hasOwnProperty.call(body, 'avatarUrl');
|
||||
const currentPassword = typeof body.currentPassword === 'string' ? body.currentPassword : '';
|
||||
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : '';
|
||||
const email = hasEmail && typeof body.email === 'string' ? body.email.trim() : undefined;
|
||||
const nickname = hasNickname && typeof body.nickname === 'string' ? body.nickname.trim() : undefined;
|
||||
const phone = hasPhone && typeof body.phone === 'string' ? body.phone.trim() : undefined;
|
||||
const avatarUrl = hasAvatarUrl && typeof body.avatarUrl === 'string' ? body.avatarUrl.trim() : undefined;
|
||||
|
||||
if (email !== undefined && !isEmail(email)) {
|
||||
return NextResponse.json({ error: 'Invalid email address' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isSafeProfileText(nickname, 50)) {
|
||||
return NextResponse.json({ error: 'Nickname is too long or contains invalid characters' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isSafeProfileText(phone, 30)) {
|
||||
return NextResponse.json({ error: 'Phone is too long or contains invalid characters' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (newPassword && newPassword.length < 6) {
|
||||
return NextResponse.json({ error: 'Password must be at least 6 characters' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (avatarUrl !== undefined && !isSafeAvatarUrl(avatarUrl)) {
|
||||
return NextResponse.json({ error: 'Invalid avatar image' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getDbClient();
|
||||
|
||||
try {
|
||||
await ensureEmailSchema(client);
|
||||
await ensureProfilePreferenceSchema(client);
|
||||
await client.query('BEGIN');
|
||||
|
||||
const profileResult = await client.query(
|
||||
'SELECT id, email, nickname, phone, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit, avatar_url, created_at, email_verified, email_verified_at, email_bound_at, preferred_theme FROM profiles WHERE id = $1 FOR UPDATE',
|
||||
[tokenUserId]
|
||||
);
|
||||
|
||||
if (profileResult.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const currentProfile = profileResult.rows[0];
|
||||
const authResult = await client.query(
|
||||
'SELECT id, email, password_hash FROM auth.users WHERE id = $1 FOR UPDATE',
|
||||
[tokenUserId]
|
||||
);
|
||||
const authUser = authResult.rows[0] || null;
|
||||
|
||||
if (email !== undefined && email !== currentProfile.email) {
|
||||
const duplicateProfile = await client.query(
|
||||
'SELECT id FROM profiles WHERE email = $1 AND id <> $2 LIMIT 1',
|
||||
[email, tokenUserId]
|
||||
);
|
||||
const duplicateAuth = await client.query(
|
||||
'SELECT id FROM auth.users WHERE email = $1 AND id <> $2 LIMIT 1',
|
||||
[email, tokenUserId]
|
||||
);
|
||||
|
||||
if (duplicateProfile.rows.length > 0 || duplicateAuth.rows.length > 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: 'Email is already in use' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (authUser) {
|
||||
await client.query('UPDATE auth.users SET email = $1 WHERE id = $2', [email, tokenUserId]);
|
||||
} else {
|
||||
await client.query(
|
||||
'INSERT INTO auth.users (id, email, created_at) VALUES ($1, $2, NOW())',
|
||||
[tokenUserId, email]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (newPassword) {
|
||||
if (authUser?.password_hash) {
|
||||
if (!currentPassword) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: 'Current password is required' }, { status: 400 });
|
||||
}
|
||||
const passwordOk = await verifyPasswordHash(client, authUser.password_hash, currentPassword);
|
||||
if (!passwordOk) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 401 });
|
||||
}
|
||||
} else if (currentProfile.role === 'admin' && currentPassword !== getRequiredProductionSecret('ADMIN_DEFAULT_PASSWORD', 'admin123')) {
|
||||
await client.query('ROLLBACK');
|
||||
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 401 });
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO auth.users (id, email, password_hash, created_at)
|
||||
VALUES ($1, $2, crypt($3, gen_salt('bf')), NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET password_hash = crypt($3, gen_salt('bf'))`,
|
||||
[tokenUserId, email || currentProfile.email, newPassword]
|
||||
);
|
||||
}
|
||||
|
||||
const updateResult = await client.query(
|
||||
`UPDATE profiles
|
||||
SET email = CASE WHEN $1::boolean THEN $2 ELSE email END,
|
||||
email_verified = CASE WHEN $1::boolean AND LOWER($2) <> LOWER(email) THEN false ELSE email_verified END,
|
||||
email_verified_at = CASE WHEN $1::boolean AND LOWER($2) <> LOWER(email) THEN NULL ELSE email_verified_at END,
|
||||
nickname = CASE WHEN $3::boolean THEN NULLIF($4, '') ELSE nickname END,
|
||||
phone = CASE WHEN $5::boolean THEN NULLIF($6, '') ELSE phone END,
|
||||
avatar_url = CASE WHEN $7::boolean THEN NULLIF($8, '') ELSE avatar_url END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $9
|
||||
RETURNING id, email, nickname, phone, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit, avatar_url, created_at, email_verified, email_verified_at, email_bound_at, preferred_theme`,
|
||||
[
|
||||
email !== undefined,
|
||||
email || null,
|
||||
nickname !== undefined,
|
||||
nickname || '',
|
||||
phone !== undefined,
|
||||
phone || '',
|
||||
avatarUrl !== undefined,
|
||||
avatarUrl || '',
|
||||
tokenUserId,
|
||||
]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
profile: updateResult.rows[0],
|
||||
});
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to update profile';
|
||||
console.error('[Profile Update Error]', message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
44
src/app/api/profile/theme/route.ts
Normal file
44
src/app/api/profile/theme/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
import { ensureProfilePreferenceSchema, normalizePreferredTheme } from '@/lib/profile-preferences';
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const tokenUserId = await getAuthenticatedUserId(request);
|
||||
if (!tokenUserId) {
|
||||
return NextResponse.json({ error: 'Please log in again' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const preferredTheme = normalizePreferredTheme(body?.theme);
|
||||
const client = await getDbClient();
|
||||
|
||||
try {
|
||||
await ensureProfilePreferenceSchema(client);
|
||||
const result = await client.query(
|
||||
`UPDATE profiles
|
||||
SET preferred_theme = $1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2
|
||||
RETURNING preferred_theme`,
|
||||
[preferredTheme, tokenUserId],
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
preferred_theme: normalizePreferredTheme(result.rows[0].preferred_theme),
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to save theme';
|
||||
console.error('[Profile Theme Error]', message);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
220
src/app/api/site-config/route.ts
Normal file
220
src/app/api/site-config/route.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { localStorage } from '@/lib/local-storage';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { DEFAULT_ABOUT_US, DEFAULT_HELP_CENTER, DEFAULT_PRIVACY_POLICY, DEFAULT_TERMS_OF_SERVICE } from '@/lib/site-policy-defaults';
|
||||
import { cleanupExpiredPlatformLogs, setPlatformLogRetentionDays, writePlatformLog } from '@/lib/platform-logs';
|
||||
|
||||
const DEFAULT_RESPONSE = {
|
||||
siteName: '妙境',
|
||||
siteTabTitle: '妙境 - AI创作平台',
|
||||
logoUrl: null,
|
||||
faviconUrl: null,
|
||||
membershipEnabled: true,
|
||||
termsOfService: DEFAULT_TERMS_OF_SERVICE,
|
||||
privacyPolicy: DEFAULT_PRIVACY_POLICY,
|
||||
aboutUs: DEFAULT_ABOUT_US,
|
||||
helpCenter: DEFAULT_HELP_CENTER,
|
||||
filingInfo: '',
|
||||
filingUrl: '',
|
||||
publicSecurityFilingInfo: '',
|
||||
publicSecurityFilingUrl: '',
|
||||
logRetentionDays: 30,
|
||||
};
|
||||
|
||||
type SiteConfigRow = {
|
||||
site_name?: string;
|
||||
site_tab_title?: string;
|
||||
logo_url?: string | null;
|
||||
favicon_url?: string | null;
|
||||
membership_enabled?: boolean;
|
||||
terms_of_service?: string | null;
|
||||
privacy_policy?: string | null;
|
||||
about_us?: string | null;
|
||||
help_center?: string | null;
|
||||
filing_info?: string | null;
|
||||
filing_url?: string | null;
|
||||
public_security_filing_info?: string | null;
|
||||
public_security_filing_url?: string | null;
|
||||
log_retention_days?: number | null;
|
||||
};
|
||||
|
||||
async function ensureSiteConfigColumns(client: Awaited<ReturnType<typeof getDbClient>>) {
|
||||
await client.query('ALTER TABLE site_config ADD COLUMN IF NOT EXISTS membership_enabled BOOLEAN NOT NULL DEFAULT TRUE');
|
||||
await client.query("ALTER TABLE site_config ADD COLUMN IF NOT EXISTS terms_of_service TEXT NOT NULL DEFAULT ''");
|
||||
await client.query("ALTER TABLE site_config ADD COLUMN IF NOT EXISTS privacy_policy TEXT NOT NULL DEFAULT ''");
|
||||
await client.query("ALTER TABLE site_config ADD COLUMN IF NOT EXISTS about_us TEXT NOT NULL DEFAULT ''");
|
||||
await client.query("ALTER TABLE site_config ADD COLUMN IF NOT EXISTS help_center TEXT NOT NULL DEFAULT ''");
|
||||
await client.query("ALTER TABLE site_config ADD COLUMN IF NOT EXISTS filing_info TEXT NOT NULL DEFAULT ''");
|
||||
await client.query("ALTER TABLE site_config ADD COLUMN IF NOT EXISTS filing_url TEXT NOT NULL DEFAULT ''");
|
||||
await client.query("ALTER TABLE site_config ADD COLUMN IF NOT EXISTS public_security_filing_info TEXT NOT NULL DEFAULT ''");
|
||||
await client.query("ALTER TABLE site_config ADD COLUMN IF NOT EXISTS public_security_filing_url TEXT NOT NULL DEFAULT ''");
|
||||
await client.query('ALTER TABLE site_config ADD COLUMN IF NOT EXISTS log_retention_days INTEGER NOT NULL DEFAULT 30');
|
||||
await client.query('UPDATE site_config SET log_retention_days = LEAST(90, GREATEST(1, log_retention_days))');
|
||||
await client.query("UPDATE site_config SET terms_of_service = $1 WHERE terms_of_service = ''", [DEFAULT_TERMS_OF_SERVICE]);
|
||||
await client.query("UPDATE site_config SET privacy_policy = $1 WHERE privacy_policy = ''", [DEFAULT_PRIVACY_POLICY]);
|
||||
await client.query("UPDATE site_config SET about_us = $1 WHERE about_us = ''", [DEFAULT_ABOUT_US]);
|
||||
await client.query("UPDATE site_config SET help_center = $1 WHERE help_center = ''", [DEFAULT_HELP_CENTER]);
|
||||
}
|
||||
|
||||
function normalizeResponse(data?: SiteConfigRow | null) {
|
||||
return {
|
||||
siteName: data?.site_name || DEFAULT_RESPONSE.siteName,
|
||||
siteTabTitle: data?.site_tab_title || DEFAULT_RESPONSE.siteTabTitle,
|
||||
logoUrl: data?.logo_url || null,
|
||||
faviconUrl: data?.favicon_url || null,
|
||||
membershipEnabled: data?.membership_enabled !== false,
|
||||
termsOfService: data?.terms_of_service?.trim() ? data.terms_of_service : DEFAULT_TERMS_OF_SERVICE,
|
||||
privacyPolicy: data?.privacy_policy?.trim() ? data.privacy_policy : DEFAULT_PRIVACY_POLICY,
|
||||
aboutUs: data?.about_us?.trim() ? data.about_us : DEFAULT_ABOUT_US,
|
||||
helpCenter: data?.help_center?.trim() ? data.help_center : DEFAULT_HELP_CENTER,
|
||||
filingInfo: data?.filing_info?.trim() || '',
|
||||
filingUrl: data?.filing_url?.trim() || '',
|
||||
publicSecurityFilingInfo: data?.public_security_filing_info?.trim() || '',
|
||||
publicSecurityFilingUrl: data?.public_security_filing_url?.trim() || '',
|
||||
logRetentionDays: Math.min(90, Math.max(1, Number(data?.log_retention_days || 30))),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeDataImage(value: unknown): { buffer: Buffer; ext: string; contentType: string } | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const match = value.match(/^data:image\/(png|jpe?g|webp|gif|svg\+xml);base64,([a-z0-9+/=]+)$/i);
|
||||
if (!match) return null;
|
||||
const subtype = match[1].toLowerCase();
|
||||
const ext = subtype === 'jpeg' ? 'jpg' : subtype === 'svg+xml' ? 'svg' : subtype;
|
||||
const buffer = Buffer.from(match[2], 'base64');
|
||||
if (buffer.length <= 0 || buffer.length > 3 * 1024 * 1024) return null;
|
||||
return { buffer, ext, contentType: `image/${subtype}` };
|
||||
}
|
||||
|
||||
async function saveImageDataUrl(value: unknown, prefix: string): Promise<string | null> {
|
||||
const decoded = decodeDataImage(value);
|
||||
if (!decoded) return null;
|
||||
const key = `site-assets/${prefix}-${Date.now()}.${decoded.ext}`;
|
||||
const savedKey = await localStorage.uploadFile({
|
||||
fileContent: decoded.buffer,
|
||||
fileName: key,
|
||||
contentType: decoded.contentType,
|
||||
});
|
||||
return localStorage.generatePresignedUrl({ key: savedKey, expireTime: 31536000 });
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureSiteConfigColumns(client);
|
||||
const result = await client.query(
|
||||
'SELECT site_name, site_tab_title, logo_url, favicon_url, membership_enabled, terms_of_service, privacy_policy, about_us, help_center, filing_info, filing_url, public_security_filing_info, public_security_filing_url, log_retention_days FROM site_config WHERE id = 1'
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json(DEFAULT_RESPONSE);
|
||||
}
|
||||
|
||||
return NextResponse.json(normalizeResponse(result.rows[0]));
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json(DEFAULT_RESPONSE);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: '无效的请求体' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { siteName, siteTabTitle, membershipEnabled, logoBase64, faviconBase64, termsOfService, privacyPolicy, aboutUs, helpCenter, filingInfo, filingUrl, publicSecurityFilingInfo, publicSecurityFilingUrl, logRetentionDays } = body as {
|
||||
siteName?: string;
|
||||
siteTabTitle?: string;
|
||||
membershipEnabled?: boolean;
|
||||
logoBase64?: string;
|
||||
faviconBase64?: string;
|
||||
termsOfService?: string;
|
||||
privacyPolicy?: string;
|
||||
aboutUs?: string;
|
||||
helpCenter?: string;
|
||||
filingInfo?: string;
|
||||
filingUrl?: string;
|
||||
publicSecurityFilingInfo?: string;
|
||||
publicSecurityFilingUrl?: string;
|
||||
logRetentionDays?: number;
|
||||
};
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureSiteConfigColumns(client);
|
||||
const updates: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (typeof siteName === 'string') { updates.push(`site_name = $${paramIdx++}`); params.push(siteName); }
|
||||
if (typeof siteTabTitle === 'string') { updates.push(`site_tab_title = $${paramIdx++}`); params.push(siteTabTitle); }
|
||||
if (typeof membershipEnabled === 'boolean') { updates.push(`membership_enabled = $${paramIdx++}`); params.push(membershipEnabled); }
|
||||
if (typeof termsOfService === 'string') { updates.push(`terms_of_service = $${paramIdx++}`); params.push(termsOfService.trim() || DEFAULT_TERMS_OF_SERVICE); }
|
||||
if (typeof privacyPolicy === 'string') { updates.push(`privacy_policy = $${paramIdx++}`); params.push(privacyPolicy.trim() || DEFAULT_PRIVACY_POLICY); }
|
||||
if (typeof aboutUs === 'string') { updates.push(`about_us = $${paramIdx++}`); params.push(aboutUs.trim() || DEFAULT_ABOUT_US); }
|
||||
if (typeof helpCenter === 'string') { updates.push(`help_center = $${paramIdx++}`); params.push(helpCenter.trim() || DEFAULT_HELP_CENTER); }
|
||||
if (typeof filingInfo === 'string') { updates.push(`filing_info = $${paramIdx++}`); params.push(filingInfo.trim()); }
|
||||
if (typeof filingUrl === 'string') { updates.push(`filing_url = $${paramIdx++}`); params.push(filingUrl.trim()); }
|
||||
if (typeof publicSecurityFilingInfo === 'string') { updates.push(`public_security_filing_info = $${paramIdx++}`); params.push(publicSecurityFilingInfo.trim()); }
|
||||
if (typeof publicSecurityFilingUrl === 'string') { updates.push(`public_security_filing_url = $${paramIdx++}`); params.push(publicSecurityFilingUrl.trim()); }
|
||||
if (typeof logRetentionDays === 'number') {
|
||||
const safeLogRetentionDays = Math.min(90, Math.max(1, Math.floor(logRetentionDays)));
|
||||
updates.push(`log_retention_days = $${paramIdx++}`);
|
||||
params.push(safeLogRetentionDays);
|
||||
await setPlatformLogRetentionDays(client, safeLogRetentionDays);
|
||||
await cleanupExpiredPlatformLogs(client);
|
||||
}
|
||||
const logoUrl = await saveImageDataUrl(logoBase64, 'logo');
|
||||
const faviconUrl = await saveImageDataUrl(faviconBase64, 'favicon');
|
||||
if (logoUrl) { updates.push(`logo_url = $${paramIdx++}`); params.push(logoUrl); }
|
||||
if (faviconUrl) { updates.push(`favicon_url = $${paramIdx++}`); params.push(faviconUrl); }
|
||||
updates.push(`updated_at = NOW()`);
|
||||
|
||||
if (updates.length > 1) {
|
||||
await client.query(
|
||||
"INSERT INTO site_config (id, site_name, site_tab_title) VALUES (1, '妙境', '妙境 - AI创作平台') ON CONFLICT (id) DO NOTHING"
|
||||
);
|
||||
await client.query(
|
||||
`UPDATE site_config SET ${updates.join(', ')} WHERE id = 1`,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
'SELECT site_name, site_tab_title, logo_url, favicon_url, membership_enabled, terms_of_service, privacy_policy, about_us, help_center, filing_info, filing_url, public_security_filing_info, public_security_filing_url, log_retention_days FROM site_config WHERE id = 1'
|
||||
);
|
||||
|
||||
void writePlatformLog({
|
||||
type: 'admin',
|
||||
level: 'info',
|
||||
action: 'site_config_updated',
|
||||
message: '管理员更新了系统设置',
|
||||
targetType: 'site_config',
|
||||
targetId: '1',
|
||||
metadata: {
|
||||
fields: updates
|
||||
.filter(item => !item.startsWith('updated_at'))
|
||||
.map(item => item.split('=')[0]?.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
request,
|
||||
});
|
||||
|
||||
return NextResponse.json(normalizeResponse(result.rows[0]));
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[site-config] PUT error:', err);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
41
src/app/api/site-stats/route.ts
Normal file
41
src/app/api/site-stats/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query('SELECT total_visits FROM site_stats WHERE id = 1');
|
||||
return NextResponse.json({ totalVisits: result.rows[0]?.total_visits || 0 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ totalVisits: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
const result = await client.query('SELECT increment_visits() as new_count');
|
||||
return NextResponse.json({ totalVisits: result.rows[0]?.new_count || 0 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await client.query('UPDATE site_stats SET total_visits = total_visits + 1, updated_at = NOW() WHERE id = 1');
|
||||
const result = await client.query('SELECT total_visits FROM site_stats WHERE id = 1');
|
||||
return NextResponse.json({ totalVisits: result.rows[0]?.total_visits || 0 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ totalVisits: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
125
src/app/api/user-api-keys/route.ts
Normal file
125
src/app/api/user-api-keys/route.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
import { decryptSecret, encryptSecret, previewSecret } from '@/lib/server-crypto';
|
||||
import { getAuthenticatedUserId } from '@/lib/session-auth';
|
||||
|
||||
function normalizeType(value: unknown): 'image' | 'video' | 'text' {
|
||||
return value === 'video' || value === 'text' ? value : 'image';
|
||||
}
|
||||
|
||||
function mapKey(row: Record<string, unknown>) {
|
||||
const apiKey = decryptSecret(row.api_key_encrypted as string);
|
||||
return {
|
||||
id: row.id,
|
||||
provider: row.provider || '',
|
||||
supplierName: row.supplier_name || row.provider || '',
|
||||
apiUrl: row.api_url || '',
|
||||
modelName: row.model_name || '',
|
||||
apiKey: '',
|
||||
apiKeyPreview: row.api_key_preview || previewSecret(apiKey),
|
||||
type: normalizeType(row.type),
|
||||
isActive: row.is_active !== false,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await client.query(`
|
||||
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS supplier_name VARCHAR(128);
|
||||
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS type VARCHAR(16) NOT NULL DEFAULT 'image';
|
||||
`);
|
||||
const result = await client.query(
|
||||
`SELECT id, provider, supplier_name, api_url, model_name, api_key_encrypted, api_key_preview, type, is_active, created_at
|
||||
FROM user_api_keys
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
[userId],
|
||||
);
|
||||
return NextResponse.json({ keys: result.rows.map(mapKey) });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
const body = await request.json();
|
||||
const keys = Array.isArray(body.keys) ? body.keys : [body];
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(`
|
||||
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS supplier_name VARCHAR(128);
|
||||
ALTER TABLE user_api_keys ADD COLUMN IF NOT EXISTS type VARCHAR(16) NOT NULL DEFAULT 'image';
|
||||
`);
|
||||
const saved = [];
|
||||
for (const item of keys) {
|
||||
const apiKey = String(item.apiKey || '').trim();
|
||||
const id = typeof item.id === 'string' && /^[0-9a-fA-F-]{36}$/.test(item.id) ? item.id : undefined;
|
||||
if (!apiKey && !id) continue;
|
||||
const values = [
|
||||
userId,
|
||||
String(item.provider || '').trim(),
|
||||
String(item.supplierName || item.provider || '').trim(),
|
||||
String(item.apiUrl || '').trim(),
|
||||
String(item.modelName || '').trim(),
|
||||
apiKey ? encryptSecret(apiKey) : null,
|
||||
apiKey ? previewSecret(apiKey) : null,
|
||||
normalizeType(item.type),
|
||||
item.isActive !== false,
|
||||
];
|
||||
const result = await client.query(
|
||||
id
|
||||
? `UPDATE user_api_keys
|
||||
SET provider = $2,
|
||||
supplier_name = $3,
|
||||
api_url = $4,
|
||||
model_name = $5,
|
||||
api_key_encrypted = COALESCE($6, api_key_encrypted),
|
||||
api_key_preview = COALESCE($7, api_key_preview),
|
||||
type = $8,
|
||||
is_active = $9,
|
||||
updated_at = NOW()
|
||||
WHERE id = $10 AND user_id = $1
|
||||
RETURNING *`
|
||||
: `INSERT INTO user_api_keys (user_id, provider, supplier_name, api_url, model_name, api_key_encrypted, api_key_preview, type, is_active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
|
||||
RETURNING *`,
|
||||
id ? [...values, id] : values,
|
||||
);
|
||||
saved.push(mapKey(result.rows[0]));
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return NextResponse.json({ keys: saved });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return POST(request);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const userId = await getAuthenticatedUserId(request);
|
||||
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
|
||||
const id = request.nextUrl.searchParams.get('id');
|
||||
if (!id) return NextResponse.json({ error: '缺少 ID' }, { status: 400 });
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await client.query('DELETE FROM user_api_keys WHERE id = $1 AND user_id = $2', [id, userId]);
|
||||
return NextResponse.json({ success: true });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
530
src/app/auth/login/page.tsx
Normal file
530
src/app/auth/login/page.tsx
Normal file
@@ -0,0 +1,530 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Brush, Mail, Lock, User, Phone, Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuth, parseApiUser } from '@/lib/auth-store';
|
||||
import { RegistrationAgreementDialog } from '@/components/auth/registration-agreement-dialog';
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
|
||||
const authInputIconClass = 'pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-foreground/70 dark:text-foreground/80';
|
||||
const authPasswordToggleClass = 'absolute right-3 top-1/2 z-10 -translate-y-1/2 text-foreground/70 transition-colors hover:text-foreground dark:text-foreground/80';
|
||||
|
||||
function isEmail(value: string) {
|
||||
return EMAIL_REGEX.test(value.trim());
|
||||
}
|
||||
|
||||
function sanitizeCode(value: string) {
|
||||
return value.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(0, 10);
|
||||
}
|
||||
|
||||
function isStrongPassword(value: string) {
|
||||
return value.length >= 8 && /[a-zA-Z]/.test(value) && /\d/.test(value);
|
||||
}
|
||||
|
||||
export default function AuthPage() {
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
const { setTheme } = useTheme();
|
||||
const [activeTab, setActiveTab] = useState('login');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// Login form
|
||||
const [loginAccount, setLoginAccount] = useState('');
|
||||
const [loginPassword, setLoginPassword] = useState('');
|
||||
|
||||
// Register form
|
||||
const [regEmail, setRegEmail] = useState('');
|
||||
const [regPassword, setRegPassword] = useState('');
|
||||
const [regNickname, setRegNickname] = useState('');
|
||||
const [regPhone, setRegPhone] = useState('');
|
||||
const [regInviteCode, setRegInviteCode] = useState('');
|
||||
const [regEmailCode, setRegEmailCode] = useState('');
|
||||
const [regCodeCooldown, setRegCodeCooldown] = useState(0);
|
||||
const [sendingRegCode, setSendingRegCode] = useState(false);
|
||||
const [showInviteCode, setShowInviteCode] = useState(false);
|
||||
const [showForgotPw, setShowForgotPw] = useState(false);
|
||||
const [resetEmail, setResetEmail] = useState('');
|
||||
const [resetCode, setResetCode] = useState('');
|
||||
const [resetPassword, setResetPassword] = useState('');
|
||||
const [resetConfirmPassword, setResetConfirmPassword] = useState('');
|
||||
const [resetCooldown, setResetCooldown] = useState(0);
|
||||
const [sendingResetCode, setSendingResetCode] = useState(false);
|
||||
const [resettingPassword, setResettingPassword] = useState(false);
|
||||
const [showAgreement, setShowAgreement] = useState(false);
|
||||
|
||||
// Auto-initialize default admin account on mount (fire-and-forget)
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/admin-exists').catch(() => {/* silent */});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (regCodeCooldown <= 0) return;
|
||||
const timer = window.setInterval(() => setRegCodeCooldown(prev => Math.max(0, prev - 1)), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [regCodeCooldown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resetCooldown <= 0) return;
|
||||
const timer = window.setInterval(() => setResetCooldown(prev => Math.max(0, prev - 1)), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [resetCooldown]);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!loginAccount || !loginPassword) {
|
||||
toast.error('请填写账号和密码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ account: loginAccount, password: loginPassword }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || '登录失败');
|
||||
|
||||
// Save auth state with full profile
|
||||
const authUser = parseApiUser(data.user || {});
|
||||
login(authUser, data.session?.access_token || '');
|
||||
setTheme(authUser.preferredTheme);
|
||||
|
||||
toast.success('登录成功');
|
||||
router.push('/create');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!regEmail || !regPassword) {
|
||||
toast.error('请填写邮箱和密码');
|
||||
return;
|
||||
}
|
||||
if (!isEmail(regEmail)) {
|
||||
toast.error('请输入正确的邮箱地址');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: regEmail,
|
||||
password: regPassword,
|
||||
nickname: regNickname,
|
||||
phone: regPhone,
|
||||
inviteCode: regInviteCode || undefined,
|
||||
emailCode: showInviteCode && regInviteCode ? undefined : regEmailCode,
|
||||
acceptedTerms: true,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || '注册失败');
|
||||
|
||||
// Save auth state with full profile
|
||||
const authUser = parseApiUser(data.user || {});
|
||||
login(authUser, data.session?.access_token || '');
|
||||
setTheme(authUser.preferredTheme);
|
||||
|
||||
toast.success(data.message || '注册成功');
|
||||
router.push('/create');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '注册失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const requestRegisterAgreement = () => {
|
||||
setShowAgreement(true);
|
||||
};
|
||||
|
||||
const handleAgreeAndRegister = () => {
|
||||
setShowAgreement(false);
|
||||
handleRegister();
|
||||
};
|
||||
|
||||
const handleSendRegisterCode = async () => {
|
||||
if (!isEmail(regEmail)) {
|
||||
toast.error('请输入正确的邮箱地址');
|
||||
return;
|
||||
}
|
||||
setSendingRegCode(true);
|
||||
try {
|
||||
const res = await fetch('/api/email/send-register-code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: regEmail }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || '验证码发送失败');
|
||||
setRegCodeCooldown(data.cooldown || 60);
|
||||
toast.success(data.message || '验证码已发送');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '验证码发送失败');
|
||||
} finally {
|
||||
setSendingRegCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendResetCode = async () => {
|
||||
if (!isEmail(resetEmail)) {
|
||||
toast.error('请输入注册时绑定并验证过的邮箱');
|
||||
return;
|
||||
}
|
||||
setSendingResetCode(true);
|
||||
try {
|
||||
const res = await fetch('/api/email/send-reset-code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: resetEmail }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || '验证码发送失败');
|
||||
setResetCooldown(data.cooldown || 60);
|
||||
toast.success(data.message || '如果该邮箱可用于重置,我们已发送验证码');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '验证码发送失败');
|
||||
} finally {
|
||||
setSendingResetCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!isEmail(resetEmail) || !resetCode) {
|
||||
toast.error('请填写邮箱和验证码');
|
||||
return;
|
||||
}
|
||||
if (!isStrongPassword(resetPassword)) {
|
||||
toast.error('新密码至少 8 位,并同时包含字母和数字');
|
||||
return;
|
||||
}
|
||||
if (resetPassword !== resetConfirmPassword) {
|
||||
toast.error('两次输入的新密码不一致');
|
||||
return;
|
||||
}
|
||||
setResettingPassword(true);
|
||||
try {
|
||||
const res = await fetch('/api/email/reset-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: resetEmail, code: resetCode, newPassword: resetPassword }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || '密码重置失败');
|
||||
toast.success(data.message || '密码已重置,请重新登录');
|
||||
setShowForgotPw(false);
|
||||
setLoginAccount(resetEmail);
|
||||
setResetCode('');
|
||||
setResetPassword('');
|
||||
setResetConfirmPassword('');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '密码重置失败');
|
||||
} finally {
|
||||
setResettingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 w-[600px] h-[400px] bg-primary/5 rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center gap-2.5">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Brush className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="font-serif text-2xl font-bold">妙境</span>
|
||||
</Link>
|
||||
<p className="mt-2 text-sm text-muted-foreground">妙手丹青,境随心造</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<CardHeader className="pb-0">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="login">登录</TabsTrigger>
|
||||
<TabsTrigger value="register">注册</TabsTrigger>
|
||||
</TabsList>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-6">
|
||||
<TabsContent value="login" className="space-y-4 mt-0">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-account">邮箱 / 手机号 / 用户名</Label>
|
||||
<div className="relative">
|
||||
<Mail className={authInputIconClass} />
|
||||
<Input
|
||||
id="login-account"
|
||||
type="text"
|
||||
placeholder="邮箱、手机号或用户名"
|
||||
value={loginAccount}
|
||||
onChange={(e) => setLoginAccount(e.target.value)}
|
||||
className="pl-10"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleLogin(); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-password">密码</Label>
|
||||
<div className="relative">
|
||||
<Lock className={authInputIconClass} />
|
||||
<Input
|
||||
id="login-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="输入密码"
|
||||
value={loginPassword}
|
||||
onChange={(e) => setLoginPassword(e.target.value)}
|
||||
className="pl-10 pr-10"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleLogin(); }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className={authPasswordToggleClass}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForgotPw(true)}
|
||||
className="text-xs text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
忘记密码?
|
||||
</button>
|
||||
</div>
|
||||
<Button className="w-full h-11" onClick={handleLogin} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||
登录
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="register" className="space-y-4 mt-0">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reg-email">邮箱</Label>
|
||||
<div className="relative">
|
||||
<Mail className={authInputIconClass} />
|
||||
<Input
|
||||
id="reg-email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={regEmail}
|
||||
onChange={(e) => setRegEmail(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!(showInviteCode && regInviteCode) && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reg-email-code">邮箱验证码</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="reg-email-code"
|
||||
placeholder="输入验证码"
|
||||
value={regEmailCode}
|
||||
onChange={(e) => setRegEmailCode(sanitizeCode(e.target.value))}
|
||||
className="uppercase"
|
||||
maxLength={10}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
onClick={handleSendRegisterCode}
|
||||
disabled={sendingRegCode || regCodeCooldown > 0 || !isEmail(regEmail)}
|
||||
>
|
||||
{sendingRegCode ? <Loader2 className="h-4 w-4 animate-spin" /> : regCodeCooldown > 0 ? `${regCodeCooldown}s` : '发送验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reg-nickname">昵称</Label>
|
||||
<div className="relative">
|
||||
<User className={authInputIconClass} />
|
||||
<Input
|
||||
id="reg-nickname"
|
||||
placeholder="你的昵称"
|
||||
value={regNickname}
|
||||
onChange={(e) => setRegNickname(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reg-phone">手机号 (选填)</Label>
|
||||
<div className="relative">
|
||||
<Phone className={authInputIconClass} />
|
||||
<Input
|
||||
id="reg-phone"
|
||||
type="tel"
|
||||
placeholder="13800138000"
|
||||
value={regPhone}
|
||||
onChange={(e) => setRegPhone(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reg-password">密码</Label>
|
||||
<div className="relative">
|
||||
<Lock className={authInputIconClass} />
|
||||
<Input
|
||||
id="reg-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="至少6位密码"
|
||||
value={regPassword}
|
||||
onChange={(e) => setRegPassword(e.target.value)}
|
||||
className="pl-10 pr-10"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') requestRegisterAgreement(); }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className={authPasswordToggleClass}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Admin invite code */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="reg-invite" className="text-xs text-muted-foreground">邀请码 (选填)</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowInviteCode(!showInviteCode)}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{showInviteCode ? '隐藏' : '管理员注册?'}
|
||||
</button>
|
||||
</div>
|
||||
{showInviteCode && (
|
||||
<div className="relative">
|
||||
<Lock className={authInputIconClass} />
|
||||
<Input
|
||||
id="reg-invite"
|
||||
type="text"
|
||||
placeholder="输入管理员邀请码"
|
||||
value={regInviteCode}
|
||||
onChange={(e) => setRegInviteCode(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button className="w-full h-11" onClick={requestRegisterAgreement} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||
注册
|
||||
</Button>
|
||||
<p className="text-sm text-center leading-6 text-muted-foreground">
|
||||
注册即表示同意
|
||||
<span className="mx-1 font-medium text-primary">
|
||||
服务条款
|
||||
</span>
|
||||
和
|
||||
<span className="mx-1 font-medium text-primary">
|
||||
隐私政策
|
||||
</span>
|
||||
</p>
|
||||
</TabsContent>
|
||||
</CardContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
{/* Forgot Password Dialog */}
|
||||
<Dialog open={showForgotPw} onOpenChange={setShowForgotPw}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>忘记密码</DialogTitle>
|
||||
<DialogDescription>
|
||||
输入已验证邮箱,获取验证码后设置新密码。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label>注册邮箱</Label>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={resetEmail}
|
||||
onChange={(e) => setResetEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>验证码</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="输入邮箱验证码"
|
||||
value={resetCode}
|
||||
onChange={(e) => setResetCode(sanitizeCode(e.target.value))}
|
||||
className="uppercase"
|
||||
maxLength={10}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
onClick={handleSendResetCode}
|
||||
disabled={sendingResetCode || resetCooldown > 0 || !isEmail(resetEmail)}
|
||||
>
|
||||
{sendingResetCode ? <Loader2 className="h-4 w-4 animate-spin" /> : resetCooldown > 0 ? `${resetCooldown}s` : '发送验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>新密码</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="至少 8 位,包含字母和数字"
|
||||
value={resetPassword}
|
||||
onChange={(e) => setResetPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>确认新密码</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="再次输入新密码"
|
||||
value={resetConfirmPassword}
|
||||
onChange={(e) => setResetConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setShowForgotPw(false)}>取消</Button>
|
||||
<Button onClick={handleResetPassword} disabled={resettingPassword}>
|
||||
{resettingPassword && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
重置密码
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<RegistrationAgreementDialog
|
||||
open={showAgreement}
|
||||
onOpenChange={setShowAgreement}
|
||||
onAgree={handleAgreeAndRegister}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
220
src/app/auth/register/page.tsx
Normal file
220
src/app/auth/register/page.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Brush, Mail, Lock, User, Phone, Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { addCreditRecord } from '@/lib/credit-records-store';
|
||||
import { RegistrationAgreementDialog } from '@/components/auth/registration-agreement-dialog';
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
|
||||
const authInputIconClass = 'pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-foreground/70 dark:text-foreground/80';
|
||||
const authPasswordToggleClass = 'absolute right-3 top-1/2 z-10 -translate-y-1/2 text-foreground/70 transition-colors hover:text-foreground dark:text-foreground/80';
|
||||
|
||||
function isEmail(value: string) {
|
||||
return EMAIL_REGEX.test(value.trim());
|
||||
}
|
||||
|
||||
function sanitizeCode(value: string) {
|
||||
return value.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(0, 10);
|
||||
}
|
||||
|
||||
function isStrongPassword(value: string) {
|
||||
return value.length >= 8 && /[A-Za-z]/.test(value) && /\d/.test(value);
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [emailCode, setEmailCode] = useState('');
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const [showAgreement, setShowAgreement] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (codeCooldown <= 0) return;
|
||||
const timer = window.setInterval(() => setCodeCooldown(prev => Math.max(0, prev - 1)), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [codeCooldown]);
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!email || !password) {
|
||||
toast.error('请填写邮箱和密码');
|
||||
return;
|
||||
}
|
||||
if (!isEmail(email)) {
|
||||
toast.error('请输入正确的邮箱地址');
|
||||
return;
|
||||
}
|
||||
if (!isStrongPassword(password)) {
|
||||
toast.error('密码至少 8 位,并同时包含字母和数字');
|
||||
return;
|
||||
}
|
||||
if (!emailCode) {
|
||||
toast.error('请输入邮箱验证码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, nickname, phone, emailCode, acceptedTerms: true }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || '注册失败');
|
||||
toast.success('注册成功,赠送10积分体验金');
|
||||
addCreditRecord({ type: 'gift', amount: 10, balanceAfter: 10, description: '新用户注册奖励' });
|
||||
router.push('/create');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '注册失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const requestRegisterAgreement = () => {
|
||||
setShowAgreement(true);
|
||||
};
|
||||
|
||||
const handleAgreeAndRegister = () => {
|
||||
setShowAgreement(false);
|
||||
handleRegister();
|
||||
};
|
||||
|
||||
const handleSendCode = async () => {
|
||||
if (!isEmail(email)) {
|
||||
toast.error('请输入正确的邮箱地址');
|
||||
return;
|
||||
}
|
||||
setSendingCode(true);
|
||||
try {
|
||||
const res = await fetch('/api/email/send-register-code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || '验证码发送失败');
|
||||
setCodeCooldown(data.cooldown || 60);
|
||||
toast.success(data.message || '验证码已发送');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '验证码发送失败');
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 w-[600px] h-[400px] bg-primary/5 rounded-full blur-[100px]" />
|
||||
</div>
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center gap-2.5">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Brush className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="font-serif text-2xl font-bold">妙境</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-serif text-xl">创建账号</CardTitle>
|
||||
<CardDescription>注册即可获得10积分体验金</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱 *</Label>
|
||||
<div className="relative">
|
||||
<Mail className={authInputIconClass} />
|
||||
<Input id="email" type="email" placeholder="your@email.com" value={email} onChange={(e) => setEmail(e.target.value)} className="pl-10" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="emailCode">邮箱验证码 *</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="emailCode"
|
||||
placeholder="输入验证码"
|
||||
value={emailCode}
|
||||
onChange={(e) => setEmailCode(sanitizeCode(e.target.value))}
|
||||
className="uppercase"
|
||||
maxLength={10}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
onClick={handleSendCode}
|
||||
disabled={sendingCode || codeCooldown > 0 || !isEmail(email)}
|
||||
>
|
||||
{sendingCode ? <Loader2 className="h-4 w-4 animate-spin" /> : codeCooldown > 0 ? `${codeCooldown}s` : '发送验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nickname">昵称</Label>
|
||||
<div className="relative">
|
||||
<User className={authInputIconClass} />
|
||||
<Input id="nickname" placeholder="你的昵称" value={nickname} onChange={(e) => setNickname(e.target.value)} className="pl-10" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">手机号</Label>
|
||||
<div className="relative">
|
||||
<Phone className={authInputIconClass} />
|
||||
<Input id="phone" type="tel" placeholder="13800138000" value={phone} onChange={(e) => setPhone(e.target.value)} className="pl-10" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码 *</Label>
|
||||
<div className="relative">
|
||||
<Lock className={authInputIconClass} />
|
||||
<Input id="password" type={showPassword ? 'text' : 'password'} placeholder="至少8位,包含字母和数字" value={password} onChange={(e) => setPassword(e.target.value)} className="pl-10 pr-10" />
|
||||
<button onClick={() => setShowPassword(!showPassword)} className={authPasswordToggleClass}>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full h-11" onClick={requestRegisterAgreement} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||
注册
|
||||
</Button>
|
||||
<p className="text-center text-sm leading-6 text-muted-foreground">
|
||||
注册即表示同意
|
||||
<span className="mx-1 font-medium text-primary">
|
||||
服务条款
|
||||
</span>
|
||||
和
|
||||
<span className="mx-1 font-medium text-primary">
|
||||
隐私政策
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
已有账号? <Link href="/auth/login" className="text-primary hover:underline">去登录</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<RegistrationAgreementDialog
|
||||
open={showAgreement}
|
||||
onOpenChange={setShowAgreement}
|
||||
onAgree={handleAgreeAndRegister}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
src/app/console/dashboard/page.tsx
Normal file
3
src/app/console/dashboard/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ConsoleDashboardPage } from '@/modules/console';
|
||||
|
||||
export default ConsoleDashboardPage;
|
||||
3
src/app/console/page.tsx
Normal file
3
src/app/console/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ConsoleLoginPage } from '@/modules/console';
|
||||
|
||||
export default ConsoleLoginPage;
|
||||
90
src/app/create/page.tsx
Normal file
90
src/app/create/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { TextToImagePanel } from '@/components/create/text-to-image';
|
||||
import { ImageToImagePanel } from '@/components/create/image-to-image';
|
||||
import { TextToVideoPanel } from '@/components/create/text-to-video';
|
||||
import { ImageToVideoPanel } from '@/components/create/image-to-video';
|
||||
import ReversePromptPanel from '@/components/create/reverse-prompt-panel';
|
||||
import { Brush, ImagePlus, Video, Film, Loader2, FileSearch } from 'lucide-react';
|
||||
|
||||
function CreateContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const typeParam = searchParams.get('type') || 'text2img';
|
||||
|
||||
const typeMap: Record<string, string> = {
|
||||
text2img: 'text2img',
|
||||
img2img: 'img2img',
|
||||
text2video: 'text2video',
|
||||
img2video: 'img2video',
|
||||
reversePrompt: 'reversePrompt',
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState(typeMap[typeParam] || 'text2img');
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-5 max-w-4xl">
|
||||
<TabsTrigger value="text2img" className="gap-2">
|
||||
<Brush className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">文生图</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="img2img" className="gap-2">
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">图生图</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="text2video" className="gap-2">
|
||||
<Video className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">文生视频</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="img2video" className="gap-2">
|
||||
<Film className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">图生视频</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="reversePrompt" className="gap-2">
|
||||
<FileSearch className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">图片反推</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="text2img">
|
||||
<TextToImagePanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="img2img">
|
||||
<ImageToImagePanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="text2video">
|
||||
<TextToVideoPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="img2video">
|
||||
<ImageToVideoPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="reversePrompt">
|
||||
<ReversePromptPanel
|
||||
onUseForTextToImage={() => setActiveTab('text2img')}
|
||||
onUseForImageToImage={() => setActiveTab('img2img')}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CreatePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="font-serif text-3xl font-bold">创作中心</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
选择创作模式,释放你的想象力
|
||||
</p>
|
||||
</div>
|
||||
<Suspense fallback={<div className="flex items-center justify-center py-20"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>}>
|
||||
<CreateContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
910
src/app/gallery/page.tsx
Normal file
910
src/app/gallery/page.tsx
Normal file
@@ -0,0 +1,910 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
LayoutGrid,
|
||||
Heart,
|
||||
Download,
|
||||
Brush,
|
||||
ImagePlus,
|
||||
Video,
|
||||
Film,
|
||||
X,
|
||||
Clock,
|
||||
Cpu,
|
||||
Sparkles,
|
||||
Image as ImageIcon,
|
||||
MessageSquare,
|
||||
Copy,
|
||||
Maximize2,
|
||||
ArrowLeft,
|
||||
Trash2,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { copyTextToClipboard, downloadFile } from '@/lib/utils';
|
||||
import { usePublishedWorks, useCreationHistory, syncPublishedToSupabase, type PublishedWork } from '@/lib/creation-history-store';
|
||||
import { useAuth } from '@/lib/auth-store';
|
||||
import { FullscreenPreview } from '@/components/fullscreen-preview';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'all', label: '全部', icon: LayoutGrid },
|
||||
{ value: 'text2img', label: '文生图', icon: Brush },
|
||||
{ value: 'img2img', label: '图生图', icon: ImagePlus },
|
||||
{ value: 'text2video', label: '文生视频', icon: Video },
|
||||
{ value: 'img2video', label: '图生视频', icon: Film },
|
||||
];
|
||||
|
||||
/* ---------- Gallery Work (from API) ---------- */
|
||||
interface GalleryWork {
|
||||
id: string;
|
||||
type: string;
|
||||
title?: string | null;
|
||||
prompt?: string | null;
|
||||
negativePrompt?: string | null;
|
||||
url: string;
|
||||
thumbnailUrl?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
duration?: number | null;
|
||||
likes: number;
|
||||
creditsCost?: number | null;
|
||||
params: Record<string, unknown>;
|
||||
referenceImage?: string | null;
|
||||
referenceImages?: string[];
|
||||
publisherId: string;
|
||||
publisherNickname: string;
|
||||
publisherAvatarUrl?: string | null;
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
function getCategoryFromWork(work: GalleryWork): string {
|
||||
const mode = work.params?.creationMode || work.params?.workType || work.params?.mode;
|
||||
if (
|
||||
mode === 'text2img' ||
|
||||
mode === 'img2img' ||
|
||||
mode === 'text2video' ||
|
||||
mode === 'img2video'
|
||||
) {
|
||||
return mode;
|
||||
}
|
||||
if (work.type === 'text2video' || work.type === 'img2video') {
|
||||
return work.type;
|
||||
}
|
||||
if (work.type === 'img2img') return work.type;
|
||||
const hasReference =
|
||||
Boolean(work.referenceImage) ||
|
||||
(Array.isArray(work.referenceImages) && work.referenceImages.length > 0) ||
|
||||
Boolean(work.params?.referenceImage) ||
|
||||
(Array.isArray(work.params?.referenceImages) && work.params.referenceImages.length > 0);
|
||||
// Fallback: infer from type + referenceImage
|
||||
if (work.type === 'video' || work.duration) {
|
||||
return hasReference ? 'img2video' : 'text2video';
|
||||
}
|
||||
return hasReference ? 'img2img' : 'text2img';
|
||||
}
|
||||
|
||||
function getCategoryLabel(work: GalleryWork): string {
|
||||
const cat = CATEGORIES.find(c => c.value === getCategoryFromWork(work));
|
||||
return cat?.label ?? work.type;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function getAvatarText(nickname: string): string {
|
||||
const trimmed = nickname.trim();
|
||||
return trimmed ? trimmed.slice(0, 1).toUpperCase() : '匿';
|
||||
}
|
||||
|
||||
function getWorkReferenceImages(work: GalleryWork): string[] {
|
||||
const fromArray = Array.isArray(work.referenceImages) ? work.referenceImages : [];
|
||||
const fromParams = Array.isArray(work.params?.referenceImages)
|
||||
? (work.params.referenceImages as unknown[]).filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
: [];
|
||||
const single = typeof work.referenceImage === 'string' && work.referenceImage.trim()
|
||||
? [work.referenceImage]
|
||||
: typeof work.params?.referenceImage === 'string' && work.params.referenceImage.trim()
|
||||
? [work.params.referenceImage]
|
||||
: [];
|
||||
return [...new Set([...single, ...fromArray, ...fromParams].filter(url => url && !url.startsWith('data:') && !url.startsWith('[')))];
|
||||
}
|
||||
|
||||
async function copyGalleryText(text: string, successMessage: string) {
|
||||
const copied = await copyTextToClipboard(text);
|
||||
if (copied) {
|
||||
toast.success(successMessage);
|
||||
} else {
|
||||
toast.error('复制失败,请手动选择文本复制');
|
||||
}
|
||||
}
|
||||
|
||||
function getCreateUrlForCategory(category: string): string {
|
||||
const type = category === 'img2img' || category === 'text2video' || category === 'img2video'
|
||||
? category
|
||||
: 'text2img';
|
||||
return `/create?type=${type}`;
|
||||
}
|
||||
|
||||
type MediaSize = { width: number; height: number };
|
||||
|
||||
function getEstimatedWorkHeight(work: GalleryWork, measuredSize?: MediaSize): number {
|
||||
const width = Number(measuredSize?.width || work.width || 0);
|
||||
const height = Number(measuredSize?.height || work.height || 0);
|
||||
const imageHeight = width > 0 && height > 0 ? Math.max(120, (height / width) * 320) : 320;
|
||||
return imageHeight + 152 + 16;
|
||||
}
|
||||
|
||||
const galleryGlassPanel =
|
||||
'liquid-glass';
|
||||
const galleryGlassCard =
|
||||
'liquid-surface';
|
||||
const galleryGlassBlock =
|
||||
'rounded-xl border border-border bg-card/40';
|
||||
const detailGlassBlock =
|
||||
'rounded-xl border border-white/[0.08] bg-[#12161d]/82 shadow-[inset_0_1px_0_rgba(255,255,255,0.045),0_16px_36px_rgba(0,0,0,0.18)] backdrop-blur-xl light:border-amber-900/18 light:bg-white/36 light:text-foreground light:shadow-[inset_0_1px_0_rgba(255,255,255,0.70),0_16px_40px_rgba(83,61,27,0.12)]';
|
||||
const detailGlassInner =
|
||||
'rounded-md border border-white/[0.07] bg-[#0d1219]/80 light:border-amber-900/16 light:bg-white/32';
|
||||
const galleryMenuItemClass =
|
||||
'inline-flex h-10 cursor-pointer items-center gap-2.5 rounded-xl border border-transparent px-5 text-base font-semibold leading-none text-foreground/75 transition-colors hover:bg-white/[0.035]';
|
||||
const galleryMenuItemActiveClass =
|
||||
'border-transparent bg-white/[0.075] text-primary shadow-[inset_0_1px_0_rgba(255,255,255,0.12),0_0_18px_rgba(244,166,36,0.18),0_6px_18px_rgba(0,0,0,0.18)] [&_svg]:text-primary';
|
||||
|
||||
export default function GalleryPage() {
|
||||
const [apiWorks, setApiWorks] = useState<GalleryWork[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'done'>('idle');
|
||||
const [category, setCategory] = useState('all');
|
||||
const [likedIds, setLikedIds] = useState<Set<string>>(new Set());
|
||||
const [selectedWork, setSelectedWork] = useState<GalleryWork | null>(null);
|
||||
const [fullscreenSrc, setFullscreenSrc] = useState<string | null>(null);
|
||||
const [sortBy, setSortBy] = useState<'newest' | 'popular'>('newest');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [masonryColumnCount, setMasonryColumnCount] = useState(4);
|
||||
const [measuredMediaSizes, setMeasuredMediaSizes] = useState<Record<string, MediaSize>>({});
|
||||
const [selectedGalleryIds, setSelectedGalleryIds] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const updateColumnCount = () => {
|
||||
const width = window.innerWidth;
|
||||
if (width >= 1280) setMasonryColumnCount(4);
|
||||
else if (width >= 1024) setMasonryColumnCount(3);
|
||||
else if (width >= 640) setMasonryColumnCount(2);
|
||||
else setMasonryColumnCount(1);
|
||||
};
|
||||
|
||||
updateColumnCount();
|
||||
window.addEventListener('resize', updateColumnCount);
|
||||
return () => window.removeEventListener('resize', updateColumnCount);
|
||||
}, []);
|
||||
|
||||
// ESC to close detail overlay
|
||||
useEffect(() => {
|
||||
if (!selectedWork) return;
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setSelectedWork(null); };
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [selectedWork]);
|
||||
|
||||
// Prevent body scroll when detail is open
|
||||
useEffect(() => {
|
||||
if (selectedWork) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [selectedWork]);
|
||||
const { works: localPublished } = usePublishedWorks();
|
||||
const { records: creationHistory } = useCreationHistory();
|
||||
const { user, accessToken, isAdmin } = useAuth();
|
||||
|
||||
// Fetch works from API, after syncing localStorage to Supabase
|
||||
const fetchWorks = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ sort: sortBy, limit: '300' });
|
||||
if (searchQuery.trim()) params.set('q', searchQuery.trim());
|
||||
const res = await fetch(`/api/gallery?${params.toString()}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setApiWorks(data.works || []);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false);
|
||||
}, [sortBy, searchQuery]);
|
||||
|
||||
// Sync localStorage to Supabase on first mount only
|
||||
useEffect(() => {
|
||||
setSyncStatus('syncing');
|
||||
syncPublishedToSupabase().then(synced => {
|
||||
setSyncStatus('done');
|
||||
if (synced > 0) {
|
||||
// Re-fetch after sync to show newly synced works
|
||||
fetchWorks();
|
||||
}
|
||||
}).catch(() => {
|
||||
setSyncStatus('done');
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorks();
|
||||
}, [fetchWorks]);
|
||||
|
||||
// Merge API works with localStorage published works + published creation history
|
||||
// This ensures previously shared works are visible even if not yet in Supabase
|
||||
const works = useMemo(() => {
|
||||
const apiUrls = new Set(apiWorks.map(w => w.url));
|
||||
|
||||
// From localStorage published gallery
|
||||
const localAsGallery: GalleryWork[] = localPublished
|
||||
.filter(w => !apiUrls.has(w.url))
|
||||
.map(w => ({
|
||||
id: w.id,
|
||||
type: w.type === 'video' ? (w.referenceImage ? 'img2video' : 'text2video') : (w.referenceImage ? 'img2img' : 'text2img'),
|
||||
title: null,
|
||||
prompt: w.prompt,
|
||||
negativePrompt: w.negativePrompt,
|
||||
url: w.url,
|
||||
thumbnailUrl: null,
|
||||
width: null,
|
||||
height: null,
|
||||
duration: null,
|
||||
likes: w.likes || 0,
|
||||
creditsCost: null,
|
||||
params: { model: w.model, modelLabel: w.modelLabel, ...w.params },
|
||||
referenceImage: w.referenceImage,
|
||||
referenceImages: w.referenceImages,
|
||||
publisherId: w.publisherId,
|
||||
publisherNickname: w.publisherNickname,
|
||||
publisherAvatarUrl: null,
|
||||
publishedAt: w.publishedAt,
|
||||
}));
|
||||
|
||||
// From creation history records marked as published
|
||||
const existingUrls = new Set([...apiUrls, ...localAsGallery.map(w => w.url)]);
|
||||
const historyPublished: GalleryWork[] = creationHistory
|
||||
.filter(r => r.published && r.url && !existingUrls.has(r.url) && !r.url.startsWith('data:') && !r.url.startsWith('['))
|
||||
.map(r => ({
|
||||
id: r.id,
|
||||
type: r.type === 'video' ? (r.referenceImage ? 'img2video' : 'text2video') : (r.referenceImage ? 'img2img' : 'text2img'),
|
||||
title: null,
|
||||
prompt: r.prompt,
|
||||
negativePrompt: r.negativePrompt,
|
||||
url: r.url,
|
||||
thumbnailUrl: null,
|
||||
width: null,
|
||||
height: null,
|
||||
duration: null,
|
||||
likes: 0,
|
||||
creditsCost: null,
|
||||
params: { model: r.model, modelLabel: r.modelLabel, ...r.params },
|
||||
referenceImage: r.referenceImage,
|
||||
referenceImages: r.referenceImages,
|
||||
publisherId: user?.id || 'anonymous',
|
||||
publisherNickname: user?.nickname || user?.email?.split('@')[0] || '匿名用户',
|
||||
publisherAvatarUrl: user?.avatarUrl || null,
|
||||
publishedAt: r.createdAt,
|
||||
}));
|
||||
|
||||
return [...apiWorks, ...localAsGallery, ...historyPublished];
|
||||
}, [apiWorks, localPublished, creationHistory, user]);
|
||||
|
||||
const filteredWorks = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
return works.filter(work => {
|
||||
if (category !== 'all' && getCategoryFromWork(work) !== category) return false;
|
||||
if (!query) return true;
|
||||
const haystack = [
|
||||
work.title,
|
||||
work.prompt,
|
||||
work.negativePrompt,
|
||||
work.publisherNickname,
|
||||
work.params?.model,
|
||||
work.params?.modelLabel,
|
||||
work.type,
|
||||
].map(value => String(value || '').toLowerCase()).join('\n');
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [works, category, searchQuery]);
|
||||
|
||||
const apiWorkIds = useMemo(() => new Set(apiWorks.map(work => work.id)), [apiWorks]);
|
||||
|
||||
const handleCardImageLoad = useCallback((workId: string, e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = e.currentTarget;
|
||||
if (img.naturalWidth <= 0 || img.naturalHeight <= 0) return;
|
||||
|
||||
setMeasuredMediaSizes(prev => {
|
||||
const current = prev[workId];
|
||||
if (current?.width === img.naturalWidth && current?.height === img.naturalHeight) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[workId]: { width: img.naturalWidth, height: img.naturalHeight },
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const masonryColumns = useMemo(() => {
|
||||
const columns = Array.from({ length: masonryColumnCount }, () => [] as GalleryWork[]);
|
||||
const columnHeights = Array.from({ length: masonryColumnCount }, () => 0);
|
||||
filteredWorks.forEach((work) => {
|
||||
const targetIndex = columnHeights.indexOf(Math.min(...columnHeights));
|
||||
columns[targetIndex].push(work);
|
||||
columnHeights[targetIndex] += getEstimatedWorkHeight(work, measuredMediaSizes[work.id]);
|
||||
});
|
||||
return columns;
|
||||
}, [filteredWorks, masonryColumnCount, measuredMediaSizes]);
|
||||
|
||||
const selectedReferenceImages = useMemo(
|
||||
() => selectedWork ? getWorkReferenceImages(selectedWork) : [],
|
||||
[selectedWork],
|
||||
);
|
||||
|
||||
const toggleLike = (id: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
if (likedIds.has(id)) return;
|
||||
setLikedIds(prev => new Set(prev).add(id));
|
||||
};
|
||||
|
||||
const handleDownload = async (url: string, filename: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
const result = await downloadFile(url, filename);
|
||||
if (!result.ok) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelectGalleryWork = (id: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
setSelectedGalleryIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteGalleryWorks = async (ids: string[], e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
const targetIds = ids.filter(id => apiWorkIds.has(id));
|
||||
if (targetIds.length === 0) {
|
||||
toast.error('没有可删除的服务器画廊作品');
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm(targetIds.length === 1 ? '确认从画廊移除这个作品?' : `确认从画廊批量移除 ${targetIds.length} 个作品?`);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/gallery', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ ids: targetIds }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || '删除失败');
|
||||
}
|
||||
const removedIds = new Set<string>((data.ids || targetIds) as string[]);
|
||||
setApiWorks(prev => prev.filter(work => !removedIds.has(work.id)));
|
||||
setSelectedGalleryIds(prev => new Set([...prev].filter(id => !removedIds.has(id))));
|
||||
if (selectedWork && removedIds.has(selectedWork.id)) {
|
||||
setSelectedWork(null);
|
||||
}
|
||||
toast.success(`已从画廊移除 ${data.removed ?? removedIds.size} 个作品`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="font-serif text-3xl font-bold">作品画廊</h1>
|
||||
{syncStatus === 'syncing' && (
|
||||
<span className="text-xs text-muted-foreground animate-pulse">同步本地数据...</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">探索社区创作,发现灵感之美</p>
|
||||
</div>
|
||||
|
||||
<div className={`${galleryGlassPanel} mb-4 flex items-center gap-2.5 rounded-2xl px-4 py-2`}>
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="搜索作品、用户、提示词、模型"
|
||||
className="h-8 min-w-0 flex-1 bg-transparent text-sm font-medium outline-none placeholder:text-muted-foreground/70"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className={`${galleryGlassPanel} mb-8 flex min-h-12 flex-col items-start justify-between gap-4 rounded-2xl p-1 sm:flex-row sm:items-center`}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATEGORIES.map((cat) => {
|
||||
const Icon = cat.icon;
|
||||
return (
|
||||
<button
|
||||
key={cat.value}
|
||||
className={`${galleryMenuItemClass} ${category === cat.value ? galleryMenuItemActiveClass : ''}`}
|
||||
onClick={() => setCategory(cat.value)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{cat.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
className={`${galleryMenuItemClass} ${sortBy === 'newest' ? galleryMenuItemActiveClass : ''}`}
|
||||
onClick={() => setSortBy('newest')}
|
||||
>
|
||||
最新发布
|
||||
</button>
|
||||
<button
|
||||
className={`${galleryMenuItemClass} ${sortBy === 'popular' ? galleryMenuItemActiveClass : ''}`}
|
||||
onClick={() => setSortBy('popular')}
|
||||
>
|
||||
最受欢迎
|
||||
</button>
|
||||
{isAdmin && selectedGalleryIds.size > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-10 rounded-xl px-4 text-sm font-semibold"
|
||||
onClick={(e) => handleDeleteGalleryWorks([...selectedGalleryIds], e)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
批量删除 {selectedGalleryIds.size}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gallery Grid */}
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
|
||||
<Sparkles className="h-12 w-12 mb-4 animate-pulse opacity-30" />
|
||||
<p className="text-lg font-serif">加载中...</p>
|
||||
</div>
|
||||
) : filteredWorks.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
|
||||
<LayoutGrid className="h-16 w-16 mb-4 opacity-30" />
|
||||
<p className="text-lg font-serif">暂无作品</p>
|
||||
<p className="text-sm mt-1">创作并发布你的作品,让大家一起欣赏</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
variant="outline"
|
||||
onClick={() => window.location.href = getCreateUrlForCategory(category)}
|
||||
>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
前往创作
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-4"
|
||||
style={{ gridTemplateColumns: `repeat(${masonryColumnCount}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{masonryColumns.map((columnWorks, columnIndex) => (
|
||||
<div key={columnIndex} className="flex min-w-0 flex-col gap-4">
|
||||
{columnWorks.map((work) => (
|
||||
<Card
|
||||
key={work.id}
|
||||
className={`${galleryGlassCard} group w-full overflow-hidden cursor-pointer !rounded-2xl !py-0 transition-all duration-300 hover:border-primary/25 hover:bg-slate-900/[0.52] hover:shadow-[0_18px_52px_rgba(0,0,0,0.34),inset_0_1px_0_rgba(255,255,255,0.075)]`}
|
||||
onClick={() => setSelectedWork(work)}
|
||||
>
|
||||
<div className="relative overflow-hidden bg-black/25">
|
||||
{(work.thumbnailUrl || (work.url && !work.url.startsWith('data:'))) ? (
|
||||
<img
|
||||
src={work.thumbnailUrl || work.url}
|
||||
alt={(work.prompt || '').slice(0, 30)}
|
||||
className="block h-auto w-full object-contain"
|
||||
loading="lazy"
|
||||
onLoad={(e) => handleCardImageLoad(work.id, e)}
|
||||
onDoubleClick={(e) => { e.stopPropagation(); setFullscreenSrc(work.url); }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex aspect-square w-full flex-col items-center justify-center bg-gradient-to-br from-muted to-muted/50">
|
||||
<Sparkles className="h-8 w-8 text-muted-foreground/20" />
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && apiWorkIds.has(work.id) && (
|
||||
<button
|
||||
className={`absolute left-2 top-2 z-20 flex h-7 w-7 items-center justify-center rounded-lg border text-xs font-semibold backdrop-blur-md transition-colors ${
|
||||
selectedGalleryIds.has(work.id)
|
||||
? 'border-primary/60 bg-primary text-primary-foreground'
|
||||
: 'border-white/20 bg-black/45 text-white hover:bg-black/65'
|
||||
}`}
|
||||
onClick={(e) => toggleSelectGalleryWork(work.id, e)}
|
||||
title={selectedGalleryIds.has(work.id) ? '取消选择' : '选择作品'}
|
||||
>
|
||||
{selectedGalleryIds.has(work.id) ? '✓' : ''}
|
||||
</button>
|
||||
)}
|
||||
{(work.type === 'video' || work.type === 'text2video' || work.type === 'img2video') && (
|
||||
<Badge className={`absolute left-2 ${isAdmin && apiWorkIds.has(work.id) ? 'top-11' : 'top-2'}`} variant="secondary">
|
||||
<Film className="h-3 w-3 mr-1" />视频
|
||||
</Badge>
|
||||
)}
|
||||
<Badge className="absolute top-2 right-2" variant="secondary">
|
||||
{getCategoryLabel(work)}
|
||||
</Badge>
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors flex flex-col items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
|
||||
<p className="text-white text-sm font-serif line-clamp-2 px-4 text-center">
|
||||
{work.prompt}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => toggleLike(work.id, e)}
|
||||
>
|
||||
<Heart className={`h-4 w-4 ${likedIds.has(work.id) ? 'fill-rose-500 text-rose-500' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => handleDownload(work.url, `miaojing-${work.id}.png`, e)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
{isAdmin && apiWorkIds.has(work.id) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => handleDeleteGalleryWorks([work.id], e)}
|
||||
title="从画廊删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="flex h-[152px] flex-col p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-full bg-primary/15 text-xs font-semibold text-primary ring-1 ring-primary/25">
|
||||
{work.publisherAvatarUrl ? (
|
||||
<img
|
||||
src={work.publisherAvatarUrl}
|
||||
alt={work.publisherNickname}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
getAvatarText(work.publisherNickname)
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate text-sm font-medium">
|
||||
{work.publisherNickname}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Heart className={`h-3 w-3 ${likedIds.has(work.id) ? 'fill-rose-500 text-rose-500' : ''}`} />
|
||||
{work.likes + (likedIds.has(work.id) ? 1 : 0)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 h-[100px] overflow-hidden whitespace-pre-wrap break-words text-xs leading-5 text-muted-foreground line-clamp-5">
|
||||
{work.prompt}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail - Fullscreen Overlay */}
|
||||
{selectedWork && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md animate-in fade-in duration-200 light:bg-white/58 light:backdrop-blur-xl"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setSelectedWork(null); }}
|
||||
>
|
||||
<div className="relative flex h-[96vh] w-[98vw] overflow-hidden rounded-2xl border border-white/[0.08] bg-[#07090d] shadow-[0_28px_80px_rgba(0,0,0,0.55)] light:border-amber-900/18 light:bg-white/30 light:shadow-[0_28px_80px_rgba(83,61,27,0.16),inset_0_1px_0_rgba(255,255,255,0.70)]">
|
||||
{selectedWork.url && !selectedWork.url.startsWith('data:') && (
|
||||
<>
|
||||
<img
|
||||
src={selectedWork.thumbnailUrl || selectedWork.url}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full scale-125 object-cover opacity-48 blur-[5px]"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-black/42 light:bg-white/38" />
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.015),rgba(0,0,0,0.62))] light:bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.16),rgba(255,248,235,0.54))]" />
|
||||
</>
|
||||
)}
|
||||
{/* Left: Image/Video */}
|
||||
<div className="relative z-10 flex min-w-0 flex-1 items-center justify-center overflow-hidden bg-black/22 light:bg-white/12">
|
||||
{selectedWork.type === 'video' || selectedWork.type === 'text2video' || selectedWork.type === 'img2video' ? (
|
||||
<video
|
||||
src={selectedWork.url}
|
||||
controls
|
||||
className="relative z-10 h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={selectedWork.url}
|
||||
alt={(selectedWork.prompt || '').slice(0, 30)}
|
||||
className="relative z-10 h-full w-full cursor-zoom-in object-contain"
|
||||
onDoubleClick={() => setFullscreenSrc(selectedWork.url)}
|
||||
/>
|
||||
)}
|
||||
{/* Fullscreen button overlay */}
|
||||
{selectedWork.type !== 'video' && selectedWork.type !== 'text2video' && selectedWork.type !== 'img2video' && (
|
||||
<button
|
||||
onClick={() => setFullscreenSrc(selectedWork.url)}
|
||||
className="absolute bottom-4 right-4 z-20 flex h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white shadow-lg backdrop-blur-md transition-colors hover:bg-black/70"
|
||||
>
|
||||
<Maximize2 className="h-5 w-5 text-white" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Info Panel */}
|
||||
<div className="relative z-10 flex w-[410px] shrink-0 flex-col overflow-hidden border-l border-white/[0.07] bg-[#0a0d12]/74 backdrop-blur-2xl light:border-amber-900/14 light:bg-white/28">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-[#0a0d12]/46 via-[#0a0d12]/72 to-[#0a0d12]/86 light:from-white/8 light:via-white/24 light:to-white/42" />
|
||||
{/* Close header */}
|
||||
<div className={`${detailGlassBlock} relative z-10 m-4 mb-0 flex items-center gap-2 px-4 py-3`}>
|
||||
<button
|
||||
onClick={() => setSelectedWork(null)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full transition-colors hover:bg-white/[0.07]"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<h2 className="font-serif text-lg font-semibold">作品详情</h2>
|
||||
<button
|
||||
onClick={() => setSelectedWork(null)}
|
||||
className="ml-auto flex h-8 w-8 items-center justify-center rounded-full transition-colors hover:bg-white/[0.07]"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4">
|
||||
{/* Publisher info */}
|
||||
<div className={`${detailGlassBlock} flex shrink-0 items-center gap-3 p-4`}>
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-full bg-primary/10 text-sm font-semibold text-primary ring-1 ring-primary/25">
|
||||
{selectedWork.publisherAvatarUrl ? (
|
||||
<img
|
||||
src={selectedWork.publisherAvatarUrl}
|
||||
alt={selectedWork.publisherNickname}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
getAvatarText(selectedWork.publisherNickname)
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-base font-semibold">{selectedWork.publisherNickname}</p>
|
||||
<p className="flex items-center gap-1 text-xs text-slate-400 light:text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDate(selectedWork.publishedAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prompt */}
|
||||
{(selectedWork.prompt || selectedWork.negativePrompt) && (
|
||||
<div className={`${detailGlassBlock} flex min-h-0 flex-1 flex-col space-y-4 p-4`}>
|
||||
{selectedWork.prompt && (
|
||||
<div className="flex min-h-0 flex-1 flex-col space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="flex items-center gap-2 text-sm font-medium text-slate-400 light:text-muted-foreground">
|
||||
<MessageSquare className="h-4 w-4 text-primary" />
|
||||
提示词
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-2 text-xs"
|
||||
onClick={() => copyGalleryText(selectedWork.prompt || '', '提示词已复制')}
|
||||
>
|
||||
<Copy className="h-3 w-3" />复制
|
||||
</Button>
|
||||
</div>
|
||||
<div className={`${detailGlassInner} min-h-0 flex-1 overflow-y-auto p-3`}>
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-slate-100 light:text-foreground">{selectedWork.prompt}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedWork.negativePrompt && (
|
||||
<div className="shrink-0 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="flex items-center gap-2 text-sm font-medium text-slate-400 light:text-muted-foreground">
|
||||
<X className="h-4 w-4 text-destructive" />
|
||||
负面提示词
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-2 text-xs"
|
||||
onClick={() => copyGalleryText(selectedWork.negativePrompt || '', '负面提示词已复制')}
|
||||
>
|
||||
<Copy className="h-3 w-3" />复制
|
||||
</Button>
|
||||
</div>
|
||||
<div className={`${detailGlassInner} max-h-28 overflow-y-auto p-3`}>
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-slate-300 light:text-foreground/75">
|
||||
{selectedWork.negativePrompt}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reference Image */}
|
||||
{selectedReferenceImages.length > 0 && (
|
||||
<div className={`${detailGlassBlock} shrink-0 p-4`}>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="h-4 w-4 text-primary" />
|
||||
<p className="text-sm font-medium text-slate-100 light:text-foreground">参考图</p>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 light:text-muted-foreground">{selectedReferenceImages.length} 张</span>
|
||||
</div>
|
||||
<div className="grid max-h-[240px] grid-cols-2 gap-2 overflow-y-auto pr-1">
|
||||
{selectedReferenceImages.map((url, index) => (
|
||||
<div key={`${url}-${index}`} className={`${detailGlassInner} group relative overflow-hidden`}>
|
||||
<img
|
||||
src={url}
|
||||
alt={`参考图 ${index + 1}`}
|
||||
className="aspect-square w-full cursor-zoom-in object-cover"
|
||||
onDoubleClick={() => setFullscreenSrc(url)}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-end gap-1 bg-black/35 p-1 opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/90 text-black"
|
||||
onClick={() => setFullscreenSrc(url)}
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/90 text-black"
|
||||
onClick={(event) => handleDownload(url, `miaojing-reference-${selectedWork.id}-${index + 1}.png`, event)}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`${detailGlassBlock} mt-auto shrink-0 space-y-4 p-4`}>
|
||||
{/* Model & Params */}
|
||||
{selectedWork.params && Object.keys(selectedWork.params).length > 0 && (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-primary" />
|
||||
<p className="text-sm font-medium text-slate-100 light:text-foreground">模型与参数</p>
|
||||
</div>
|
||||
<div className="grid max-h-36 grid-cols-2 gap-3 overflow-y-auto text-sm">
|
||||
{(!!selectedWork.params.modelLabel || !!selectedWork.params.model) && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 light:text-muted-foreground/80">模型</p>
|
||||
<p className="font-medium text-slate-100 light:text-foreground">{String(selectedWork.params.modelLabel || selectedWork.params.model || '')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 light:text-muted-foreground/80">类型</p>
|
||||
<Badge variant="secondary">{getCategoryLabel(selectedWork)}</Badge>
|
||||
</div>
|
||||
{!!selectedWork.params.size && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 light:text-muted-foreground/80">尺寸</p>
|
||||
<p className="text-slate-100 light:text-foreground">{String(selectedWork.params.size)}</p>
|
||||
</div>
|
||||
)}
|
||||
{!!selectedWork.params.steps && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 light:text-muted-foreground/80">步数</p>
|
||||
<p className="text-slate-100 light:text-foreground">{String(selectedWork.params.steps)}</p>
|
||||
</div>
|
||||
)}
|
||||
{!!selectedWork.params.cfg_scale && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 light:text-muted-foreground/80">引导系数</p>
|
||||
<p className="text-slate-100 light:text-foreground">{String(selectedWork.params.cfg_scale)}</p>
|
||||
</div>
|
||||
)}
|
||||
{!!selectedWork.params.seed && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 light:text-muted-foreground/80">种子</p>
|
||||
<p className="text-slate-100 light:text-foreground">{String(selectedWork.params.seed)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-white/[0.07] light:border-amber-900/14 pt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={likedIds.has(selectedWork.id) ? 'default' : 'outline'}
|
||||
className="h-9 min-w-[92px] gap-1.5 px-3 text-sm font-semibold"
|
||||
onClick={() => toggleLike(selectedWork.id)}
|
||||
>
|
||||
<Heart className={`h-3.5 w-3.5 ${likedIds.has(selectedWork.id) ? 'fill-current' : ''}`} />
|
||||
{selectedWork.likes + (likedIds.has(selectedWork.id) ? 1 : 0)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9 min-w-[112px] gap-1.5 px-3 text-sm font-semibold"
|
||||
onClick={() => handleDownload(selectedWork.url, `miaojing-${selectedWork.id}.png`)}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
下载图片
|
||||
</Button>
|
||||
{isAdmin && apiWorkIds.has(selectedWork.id) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-9 min-w-[92px] gap-1.5 px-3 text-sm font-semibold"
|
||||
onClick={(e) => handleDeleteGalleryWorks([selectedWork.id], e)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fullscreen image preview overlay */}
|
||||
<FullscreenPreview
|
||||
src={fullscreenSrc || ''}
|
||||
alt="全屏预览"
|
||||
open={!!fullscreenSrc}
|
||||
onClose={() => setFullscreenSrc(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
609
src/app/globals.css
Normal file
609
src/app/globals.css
Normal file
@@ -0,0 +1,609 @@
|
||||
@import url('https://fonts.googleapis.cn/css2?family=Noto+Serif+SC:wght@200..900&display=swap');
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--font-serif: "Noto Serif SC", "Songti SC", "SimSun", ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
||||
--shadow-2xs: 0px 1px 2px 0px hsl(225 27.7778% 14.1176% / 0.02);
|
||||
--shadow-xs: 0px 1px 2px 0px hsl(225 27.7778% 14.1176% / 0.02);
|
||||
--shadow-sm: 0px 1px 2px 0px hsl(225 27.7778% 14.1176% / 0.04), 0px 1px 2px -1px hsl(225 27.7778% 14.1176% / 0.04);
|
||||
--shadow-md: 0px 1px 2px 0px hsl(225 27.7778% 14.1176% / 0.04), 0px 2px 4px -1px hsl(225 27.7778% 14.1176% / 0.04);
|
||||
--shadow-lg: 0px 1px 2px 0px hsl(225 27.7778% 14.1176% / 0.04), 0px 4px 6px -1px hsl(225 27.7778% 14.1176% / 0.04);
|
||||
--shadow-xl: 0px 1px 2px 0px hsl(225 27.7778% 14.1176% / 0.04), 0px 8px 10px -1px hsl(225 27.7778% 14.1176% / 0.04);
|
||||
--shadow-2xl: 0px 1px 2px 0px hsl(225 27.7778% 14.1176% / 0.10);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(0.985 0.01 85);
|
||||
--foreground: oklch(0.18 0.02 60);
|
||||
--card: oklch(0.995 0.005 85);
|
||||
--card-foreground: oklch(0.18 0.02 60);
|
||||
--popover: oklch(0.995 0.005 85);
|
||||
--popover-foreground: oklch(0.18 0.02 60);
|
||||
--primary: oklch(0.62 0.17 55);
|
||||
--primary-foreground: oklch(0.99 0.02 95);
|
||||
--secondary: oklch(0.94 0.03 85);
|
||||
--secondary-foreground: oklch(0.25 0.05 60);
|
||||
--muted: oklch(0.92 0.025 80);
|
||||
--muted-foreground: oklch(0.48 0.03 70);
|
||||
--accent: oklch(0.90 0.06 90);
|
||||
--accent-foreground: oklch(0.2 0.04 50);
|
||||
--destructive: oklch(0.55 0.22 25);
|
||||
--border: oklch(0.88 0.02 80);
|
||||
--input: oklch(0.88 0.02 80);
|
||||
--ring: oklch(0.62 0.17 55);
|
||||
--chart-1: oklch(0.85 0.15 90);
|
||||
--chart-2: oklch(0.75 0.16 70);
|
||||
--chart-3: oklch(0.65 0.17 55);
|
||||
--chart-4: oklch(0.55 0.15 45);
|
||||
--chart-5: oklch(0.45 0.12 40);
|
||||
--sidebar: oklch(0.96 0.015 85);
|
||||
--sidebar-foreground: oklch(0.18 0.02 60);
|
||||
--sidebar-primary: oklch(0.62 0.17 55);
|
||||
--sidebar-primary-foreground: oklch(0.99 0.02 95);
|
||||
--sidebar-accent: oklch(0.92 0.025 80);
|
||||
--sidebar-accent-foreground: oklch(0.2 0.04 50);
|
||||
--sidebar-border: oklch(0.88 0.02 80);
|
||||
--sidebar-ring: oklch(0.62 0.17 55);
|
||||
--radius: 0.8rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.16 0.015 260);
|
||||
--foreground: oklch(0.98 0.01 80);
|
||||
--card: oklch(0.21 0.025 260);
|
||||
--card-foreground: oklch(0.98 0.01 80);
|
||||
--popover: oklch(0.25 0.025 260);
|
||||
--popover-foreground: oklch(0.98 0.01 80);
|
||||
--primary: oklch(0.75 0.15 70);
|
||||
--primary-foreground: oklch(0.2 0.05 50);
|
||||
--secondary: oklch(0.26 0.03 260);
|
||||
--secondary-foreground: oklch(0.98 0 0);
|
||||
--muted: oklch(0.23 0.02 260);
|
||||
--muted-foreground: oklch(0.65 0.02 260);
|
||||
--accent: oklch(0.28 0.06 70);
|
||||
--accent-foreground: oklch(0.98 0.02 80);
|
||||
--destructive: oklch(0.58 0.18 25);
|
||||
--border: oklch(1 0 0 / 12%);
|
||||
--input: oklch(1 0 0 / 18%);
|
||||
--ring: oklch(0.75 0.15 70);
|
||||
--chart-1: oklch(0.85 0.15 90);
|
||||
--chart-2: oklch(0.75 0.16 70);
|
||||
--chart-3: oklch(0.65 0.17 55);
|
||||
--chart-4: oklch(0.55 0.15 45);
|
||||
--chart-5: oklch(0.45 0.12 40);
|
||||
--sidebar: oklch(0.19 0.02 260);
|
||||
--sidebar-foreground: oklch(0.98 0.01 80);
|
||||
--sidebar-primary: oklch(0.75 0.15 70);
|
||||
--sidebar-primary-foreground: oklch(0.2 0.05 50);
|
||||
--sidebar-accent: oklch(0.24 0.025 260);
|
||||
--sidebar-accent-foreground: oklch(0.98 0.01 80);
|
||||
--sidebar-border: oklch(1 0 0 / 12%);
|
||||
--sidebar-ring: oklch(0.75 0.15 70);
|
||||
}
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@custom-variant light (&:is(.light *));
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground font-serif;
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
|
||||
}
|
||||
|
||||
.light body {
|
||||
background:
|
||||
linear-gradient(135deg, rgb(250 247 241) 0%, rgb(237 242 248) 42%, rgb(251 247 236) 100%),
|
||||
var(--background);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
body {
|
||||
padding-bottom: calc(4.25rem + env(safe-area-inset-bottom));
|
||||
font-size: 16px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.liquid-surface {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
border: 1px solid rgb(255 255 255 / 0.085);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(18 25 38 / 0.78), rgb(7 11 19 / 0.72)),
|
||||
rgb(8 13 22 / 0.78);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.07),
|
||||
0 12px 30px rgb(0 0 0 / 0.22);
|
||||
backdrop-filter: blur(14px) saturate(112%);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(112%);
|
||||
}
|
||||
|
||||
.liquid-surface:hover {
|
||||
border-color: rgb(255 255 255 / 0.085);
|
||||
}
|
||||
|
||||
.liquid-glass {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
border: 1px solid rgb(255 255 255 / 0.12);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.075), rgb(255 255 255 / 0.026) 34%, rgb(2 6 23 / 0.38)),
|
||||
rgb(10 16 27 / 0.70);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.12),
|
||||
inset 0 0 0 1px rgb(255 255 255 / 0.025),
|
||||
0 16px 38px rgb(0 0 0 / 0.26);
|
||||
backdrop-filter: blur(20px) saturate(122%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(122%);
|
||||
}
|
||||
|
||||
.liquid-glass::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(110deg, rgb(255 255 255 / 0.11), transparent 24% 80%, rgb(244 166 36 / 0.035)),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.055), transparent 30%);
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
.liquid-glass:hover {
|
||||
border-color: rgb(255 255 255 / 0.12);
|
||||
}
|
||||
|
||||
.liquid-glass:hover::before {
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
.liquid-glass:active {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.liquid-glass-soft {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
border: 1px solid rgb(255 255 255 / 0.085);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.044), rgb(255 255 255 / 0.012) 36%, rgb(2 6 23 / 0.26)),
|
||||
rgb(8 13 22 / 0.66);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.075),
|
||||
0 10px 28px rgb(0 0 0 / 0.20);
|
||||
backdrop-filter: blur(16px) saturate(116%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(116%);
|
||||
}
|
||||
|
||||
.liquid-glass-soft:hover {
|
||||
border-color: rgb(255 255 255 / 0.085);
|
||||
}
|
||||
|
||||
.liquid-glass-control {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(255 255 255 / 0.105);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.065), rgb(255 255 255 / 0.02) 44%, rgb(2 6 23 / 0.26)),
|
||||
rgb(15 23 42 / 0.64);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.09),
|
||||
0 8px 20px rgb(0 0 0 / 0.16);
|
||||
backdrop-filter: blur(14px) saturate(118%);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(118%);
|
||||
}
|
||||
|
||||
.liquid-glass-control:hover {
|
||||
border-color: rgb(255 255 255 / 0.105);
|
||||
}
|
||||
|
||||
.liquid-glass-control:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
border: 1px solid rgb(255 255 255 / 0.12);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.075), rgb(255 255 255 / 0.026) 34%, rgb(2 6 23 / 0.38)),
|
||||
rgb(10 16 27 / 0.70);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.12),
|
||||
inset 0 0 0 1px rgb(255 255 255 / 0.025),
|
||||
0 16px 38px rgb(0 0 0 / 0.26);
|
||||
backdrop-filter: blur(20px) saturate(122%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(122%);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
border: 1px solid rgb(255 255 255 / 0.085);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.044), rgb(255 255 255 / 0.012) 36%, rgb(2 6 23 / 0.26)),
|
||||
rgb(8 13 22 / 0.66);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.075),
|
||||
0 10px 28px rgb(0 0 0 / 0.20);
|
||||
backdrop-filter: blur(16px) saturate(116%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(116%);
|
||||
}
|
||||
|
||||
.glass-popover {
|
||||
@apply border border-white/20 bg-popover/80 shadow-xl shadow-black/10 backdrop-blur-xl backdrop-saturate-150 dark:border-white/10 dark:bg-popover/68 dark:shadow-black/30;
|
||||
}
|
||||
|
||||
.light .liquid-surface,
|
||||
.light .liquid-glass,
|
||||
.light .glass-panel {
|
||||
border-color: rgb(105 86 54 / 0.18);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.54), rgb(255 255 255 / 0.22) 46%, rgb(248 243 232 / 0.16)),
|
||||
linear-gradient(135deg, rgb(255 255 255 / 0.20), rgb(213 226 241 / 0.12) 42%, rgb(238 204 126 / 0.10)),
|
||||
rgb(255 255 255 / 0.28);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.78),
|
||||
inset 0 -1px 0 rgb(103 82 45 / 0.08),
|
||||
0 18px 48px rgb(73 55 26 / 0.13),
|
||||
0 2px 10px rgb(255 255 255 / 0.34);
|
||||
backdrop-filter: blur(26px) saturate(168%) contrast(104%);
|
||||
-webkit-backdrop-filter: blur(26px) saturate(168%) contrast(104%);
|
||||
}
|
||||
|
||||
.light .liquid-glass-soft,
|
||||
.light .liquid-glass-control,
|
||||
.light .glass-card {
|
||||
border-color: rgb(105 86 54 / 0.15);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.46), rgb(255 255 255 / 0.18) 52%, rgb(246 240 229 / 0.14)),
|
||||
linear-gradient(135deg, rgb(255 255 255 / 0.18), rgb(216 228 241 / 0.10) 44%, rgb(238 204 126 / 0.08)),
|
||||
rgb(255 255 255 / 0.22);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.70),
|
||||
inset 0 -1px 0 rgb(103 82 45 / 0.06),
|
||||
0 12px 34px rgb(73 55 26 / 0.10);
|
||||
backdrop-filter: blur(22px) saturate(158%) contrast(103%);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(158%) contrast(103%);
|
||||
}
|
||||
|
||||
.light .liquid-glass::before,
|
||||
.light .glass-panel::before,
|
||||
.light .glass-card::before,
|
||||
.light .liquid-glass-soft::before,
|
||||
.light .liquid-glass-control::before,
|
||||
.light .liquid-surface::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(115deg, rgb(255 255 255 / 0.50), transparent 24% 78%, rgb(214 162 62 / 0.10)),
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.34), transparent 38%);
|
||||
opacity: 0.56;
|
||||
}
|
||||
|
||||
.light .glass-popover {
|
||||
border-color: rgb(105 86 54 / 0.16);
|
||||
background: rgb(255 255 255 / 0.42);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.72),
|
||||
0 18px 44px rgb(73 55 26 / 0.12);
|
||||
backdrop-filter: blur(24px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(160%);
|
||||
}
|
||||
|
||||
.light [data-slot="input"],
|
||||
.light [data-slot="textarea"],
|
||||
.light [data-slot="select-trigger"],
|
||||
.light input,
|
||||
.light textarea,
|
||||
.light button[role="combobox"] {
|
||||
border-color: rgb(116 88 43 / 0.44) !important;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.72), rgb(255 255 255 / 0.42)),
|
||||
rgb(255 255 255 / 0.46) !important;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgb(116 88 43 / 0.16),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.78),
|
||||
0 4px 14px rgb(92 69 32 / 0.07) !important;
|
||||
}
|
||||
|
||||
.light [data-slot="input"]:hover,
|
||||
.light [data-slot="textarea"]:hover,
|
||||
.light [data-slot="select-trigger"]:hover,
|
||||
.light input:hover,
|
||||
.light textarea:hover,
|
||||
.light button[role="combobox"]:hover {
|
||||
border-color: rgb(116 88 43 / 0.58) !important;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(255 255 255 / 0.78), rgb(255 255 255 / 0.48)),
|
||||
rgb(255 255 255 / 0.52) !important;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgb(116 88 43 / 0.22),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.82),
|
||||
0 5px 16px rgb(92 69 32 / 0.08) !important;
|
||||
}
|
||||
|
||||
.light [data-slot="input"]:focus-visible,
|
||||
.light [data-slot="textarea"]:focus-visible,
|
||||
.light [data-slot="select-trigger"]:focus-visible,
|
||||
.light input:focus-visible,
|
||||
.light textarea:focus-visible,
|
||||
.light button[role="combobox"]:focus-visible {
|
||||
border-color: rgb(196 126 30 / 0.86) !important;
|
||||
box-shadow:
|
||||
inset 0 0 0 2px rgb(244 166 36 / 0.30),
|
||||
0 0 0 1px rgb(244 166 36 / 0.24),
|
||||
0 8px 22px rgb(92 69 32 / 0.08) !important;
|
||||
}
|
||||
|
||||
.light .border-dashed {
|
||||
border-color: rgb(116 88 43 / 0.42) !important;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgb(116 88 43 / 0.10),
|
||||
0 10px 30px rgb(92 69 32 / 0.06);
|
||||
}
|
||||
|
||||
.light .border-dotted {
|
||||
border-color: rgb(116 88 43 / 0.38) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.mobile-page-shell {
|
||||
@apply px-3 py-4;
|
||||
}
|
||||
|
||||
.mobile-stack {
|
||||
@apply grid grid-cols-1 gap-4;
|
||||
}
|
||||
|
||||
.mobile-card-list {
|
||||
@apply space-y-3;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {}
|
||||
}
|
||||
|
||||
/* Markdown styles for announcements */
|
||||
.announcement-markdown {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.7;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.announcement-markdown h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0.75rem 0 0.5rem;
|
||||
padding-bottom: 0.3rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.announcement-markdown h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin: 0.75rem 0 0.4rem;
|
||||
}
|
||||
.announcement-markdown h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0.6rem 0 0.3rem;
|
||||
}
|
||||
.announcement-markdown h4, .announcement-markdown h5, .announcement-markdown h6 {
|
||||
font-weight: 600;
|
||||
margin: 0.5rem 0 0.25rem;
|
||||
}
|
||||
.announcement-markdown p {
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
.announcement-markdown strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
.announcement-markdown em {
|
||||
font-style: italic;
|
||||
}
|
||||
.announcement-markdown del {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.announcement-markdown ul, .announcement-markdown ol {
|
||||
margin: 0.4rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.announcement-markdown ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
.announcement-markdown ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
.announcement-markdown li {
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
.announcement-markdown blockquote {
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-left: 3px solid var(--primary);
|
||||
background: var(--muted);
|
||||
border-radius: 0 0.375rem 0.375rem 0;
|
||||
}
|
||||
.announcement-markdown blockquote p {
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
.announcement-markdown code {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.35rem;
|
||||
background: var(--muted);
|
||||
border-radius: 0.25rem;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
|
||||
}
|
||||
.announcement-markdown pre {
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.75rem;
|
||||
background: var(--muted);
|
||||
border-radius: 0.375rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.announcement-markdown pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.announcement-markdown a {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.announcement-markdown a:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.announcement-markdown hr {
|
||||
margin: 0.75rem 0;
|
||||
border-color: var(--border);
|
||||
}
|
||||
.announcement-markdown table {
|
||||
width: 100%;
|
||||
margin: 0.5rem 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.announcement-markdown th, .announcement-markdown td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.4rem 0.6rem;
|
||||
text-align: left;
|
||||
}
|
||||
.announcement-markdown th {
|
||||
background: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.announcement-markdown img {
|
||||
max-width: 100%;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
@keyframes golden-shimmer {
|
||||
0% {
|
||||
transform: translateX(-18%) skewX(-10deg);
|
||||
opacity: 0.35;
|
||||
}
|
||||
45% {
|
||||
opacity: 0.95;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(18%) skewX(-10deg);
|
||||
opacity: 0.42;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes golden-sweep {
|
||||
0% {
|
||||
transform: translateX(-34%) translateY(-50%) rotate(-8deg);
|
||||
opacity: 0;
|
||||
}
|
||||
18% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
52% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(34%) translateY(-50%) rotate(-8deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes golden-breathe {
|
||||
0%, 100% {
|
||||
transform: translate(-50%, -50%) scale(0.86);
|
||||
opacity: 0.36;
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50%, -50%) scale(1.18);
|
||||
opacity: 0.72;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes golden-drift {
|
||||
0% {
|
||||
transform: translateX(0) scale(0.95);
|
||||
opacity: 0.22;
|
||||
}
|
||||
45% {
|
||||
opacity: 0.62;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(240%) scale(1.08);
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes golden-float-random {
|
||||
0%, 100% {
|
||||
transform: translate(-50%, -50%) translate3d(0, 0, 0) scale(0.88);
|
||||
filter: blur(28px);
|
||||
}
|
||||
42% {
|
||||
transform: translate(-50%, -50%) translate3d(var(--float-x), var(--float-y), 0) scale(1.16);
|
||||
filter: blur(38px);
|
||||
}
|
||||
68% {
|
||||
transform: translate(-50%, -50%) translate3d(calc(var(--float-x) * -0.35), calc(var(--float-y) * 0.42), 0) scale(1.02);
|
||||
filter: blur(34px);
|
||||
}
|
||||
}
|
||||
5
src/app/help/page.tsx
Normal file
5
src/app/help/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SitePolicyPage } from '@/components/site-policy-page';
|
||||
|
||||
export default function HelpPage() {
|
||||
return <SitePolicyPage kind="help" />;
|
||||
}
|
||||
46
src/app/layout.tsx
Normal file
46
src/app/layout.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inspector } from 'react-dev-inspector';
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
import { AppShell } from '@/modules/web';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: '妙境 - AI创作平台',
|
||||
template: '%s | 妙境',
|
||||
},
|
||||
description: '妙手丹青,境随心造 - 一站式AI多模态创作平台,提供文生图、图生图、文生视频、图生视频四大核心能力',
|
||||
icons: {
|
||||
icon: '/favicon.png',
|
||||
apple: '/apple-touch-icon.png',
|
||||
},
|
||||
keywords: [
|
||||
'妙境',
|
||||
'AI创作',
|
||||
'文生图',
|
||||
'图生图',
|
||||
'文生视频',
|
||||
'图生视频',
|
||||
'AI绘画',
|
||||
'AI视频',
|
||||
],
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const isDev = process.env.COZE_PROJECT_ENV === 'DEV';
|
||||
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<body className="antialiased">
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false} disableTransitionOnChange>
|
||||
{isDev && <Inspector />}
|
||||
<AppShell>{children}</AppShell>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
293
src/app/page.tsx
Normal file
293
src/app/page.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AnnouncementPopup } from '@/components/announcement-popup';
|
||||
import { BillingPlanGuard } from '@/components/billing-plan-guard';
|
||||
import { SiteName, SiteLogo } from '@/components/site-brand';
|
||||
import { SiteFooter } from '@/components/site-footer';
|
||||
import {
|
||||
Brush,
|
||||
ImagePlus,
|
||||
Video,
|
||||
Film,
|
||||
ArrowRight,
|
||||
Zap,
|
||||
Shield,
|
||||
Coins,
|
||||
Layers,
|
||||
Check,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Brush,
|
||||
title: '文生图',
|
||||
desc: '用文字描述你的想象,AI即刻生成精美画作。支持多种风格、尺寸与参数调优。',
|
||||
href: '/create?type=text2img',
|
||||
gradient: 'from-amber-500/20 to-orange-500/10',
|
||||
},
|
||||
{
|
||||
icon: ImagePlus,
|
||||
title: '图生图',
|
||||
desc: '上传参考图片,AI基于你的素材进行风格迁移、场景变换和创意延展。',
|
||||
href: '/create?type=img2img',
|
||||
gradient: 'from-emerald-500/20 to-teal-500/10',
|
||||
},
|
||||
{
|
||||
icon: Video,
|
||||
title: '文生视频',
|
||||
desc: '输入场景描述,AI生成流畅的动态视频。支持多种镜头语言和风格设定。',
|
||||
href: '/create?type=text2video',
|
||||
gradient: 'from-rose-500/20 to-pink-500/10',
|
||||
},
|
||||
{
|
||||
icon: Film,
|
||||
title: '图生视频',
|
||||
desc: '将静态图片转化为动态视频,照片动画化、产品展示、场景延续一站搞定。',
|
||||
href: '/create?type=img2video',
|
||||
gradient: 'from-sky-500/20 to-cyan-500/10',
|
||||
},
|
||||
];
|
||||
|
||||
const highlights = [
|
||||
{ icon: Zap, title: '极速创作', desc: '数秒出图,分钟出视频,AI辅助将传统流程缩短90%' },
|
||||
{ icon: Shield, title: '数据安全', desc: '多租户数据隔离,企业级安全标准,创作内容私密保护' },
|
||||
{ icon: Coins, title: '灵活计费', desc: '积分制+订阅制双模式,按需付费,用多少花多少' },
|
||||
{ icon: Layers, title: '多模型支持', desc: '兼容主流AI模型,支持自备API,灵活切换无锁定' },
|
||||
];
|
||||
|
||||
const pricing = [
|
||||
{
|
||||
tier: '免费版',
|
||||
price: '0',
|
||||
desc: '体验核心创作能力',
|
||||
features: ['每日5次创作额度', '标准画质输出', '社区作品展示', '基础参数调整'],
|
||||
cta: '免费开始',
|
||||
popular: false,
|
||||
},
|
||||
{
|
||||
tier: '基础版',
|
||||
price: '29',
|
||||
desc: '适合轻度创作者',
|
||||
features: ['每日50次创作额度', '高清画质输出', '私有作品存储', '全部参数解锁', '作品批量下载'],
|
||||
cta: '立即订阅',
|
||||
popular: false,
|
||||
},
|
||||
{
|
||||
tier: '专业版',
|
||||
price: '99',
|
||||
desc: '适合专业创作者与团队',
|
||||
features: ['无限创作额度', '4K超清输出', '自定义API接入', '批量处理能力', '优先处理队列', '高级风格预设'],
|
||||
cta: '升级专业版',
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
tier: '企业版',
|
||||
price: '499',
|
||||
desc: '适合企业与大型团队',
|
||||
features: ['无限创作+团队协作', '专属API额度', '品牌风格定制', '私有化部署选项', '7x24技术支持', '商业版权保障'],
|
||||
cta: '联系销售',
|
||||
popular: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Announcement Popup */}
|
||||
<AnnouncementPopup />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[600px] bg-primary/5 rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-0 right-0 w-[400px] h-[400px] bg-primary/3 rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 pt-20 pb-24 text-center">
|
||||
<Badge variant="secondary" className="mb-6 px-4 py-1.5 text-sm font-medium gap-2">
|
||||
<SiteLogo className="h-5 w-5 rounded" />
|
||||
一站式AI多模态创作平台
|
||||
</Badge>
|
||||
|
||||
<h1 className="font-serif text-5xl sm:text-6xl lg:text-7xl font-bold tracking-tight leading-tight">
|
||||
妙手丹青
|
||||
<span className="block mt-2 text-primary">境随心造</span>
|
||||
</h1>
|
||||
|
||||
<p className="mt-6 mx-auto max-w-2xl text-lg sm:text-xl text-muted-foreground leading-relaxed">
|
||||
用AI释放你的创造力。文生图、图生图、文生视频、图生视频 —
|
||||
四大核心能力,从想象到作品只需一步。
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Link href="/create">
|
||||
<Button size="lg" className="gap-2 px-8 text-base h-12">
|
||||
<Brush className="h-5 w-5" />
|
||||
开始创作
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/gallery">
|
||||
<Button size="lg" variant="outline" className="gap-2 px-8 text-base h-12">
|
||||
浏览作品
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mt-16 grid grid-cols-2 sm:grid-cols-4 gap-6 max-w-3xl mx-auto">
|
||||
{[
|
||||
{ value: '4', label: '核心创作能力' },
|
||||
{ value: '10s', label: '平均出图时间' },
|
||||
{ value: '100+', label: '预设风格' },
|
||||
{ value: '99.9%', label: '服务可用性' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className="text-center">
|
||||
<div className="text-3xl font-bold font-serif text-primary">{stat.value}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Core Features */}
|
||||
<section className="py-24 bg-muted/20">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="font-serif text-3xl sm:text-4xl font-bold">四大核心能力</h2>
|
||||
<p className="mt-4 text-muted-foreground text-lg">从文字到画面,从静态到动态,全方位AI创作体验</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{features.map((feat) => {
|
||||
const Icon = feat.icon;
|
||||
return (
|
||||
<Link key={feat.title} href={feat.href}>
|
||||
<Card className="group h-full hover:border-primary/30 hover:shadow-lg transition-all duration-300 cursor-pointer overflow-hidden">
|
||||
<CardContent className="p-6">
|
||||
<div className={`inline-flex p-3 rounded-xl bg-gradient-to-br ${feat.gradient} mb-4`}>
|
||||
<Icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h3 className="font-serif text-xl font-semibold mb-2 group-hover:text-primary transition-colors">
|
||||
{feat.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{feat.desc}
|
||||
</p>
|
||||
<div className="mt-4 flex items-center text-sm text-primary opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
立即体验 <ArrowRight className="h-3.5 w-3.5 ml-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Highlights */}
|
||||
<section className="py-24">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="font-serif text-3xl sm:text-4xl font-bold">为什么选择妙境</h2>
|
||||
<p className="mt-4 text-muted-foreground text-lg">创作无界,效率无限</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{highlights.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div key={item.title} className="text-center">
|
||||
<div className="inline-flex p-4 rounded-2xl bg-primary/10 mb-4">
|
||||
<Icon className="h-7 w-7 text-primary" />
|
||||
</div>
|
||||
<h3 className="font-serif text-lg font-semibold mb-2">{item.title}</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{item.desc}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing */}
|
||||
<BillingPlanGuard>
|
||||
<section className="py-24 bg-muted/20">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="font-serif text-3xl sm:text-4xl font-bold">灵活的计费方案</h2>
|
||||
<p className="mt-4 text-muted-foreground text-lg">按需选择,从免费体验到企业定制</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{pricing.map((plan) => (
|
||||
<Card
|
||||
key={plan.tier}
|
||||
className={`relative h-full ${
|
||||
plan.popular ? 'border-primary shadow-lg shadow-primary/10' : ''
|
||||
}`}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<Badge className="px-3 py-1">最受欢迎</Badge>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="p-6 flex flex-col h-full">
|
||||
<h3 className="font-serif text-lg font-semibold">{plan.tier}</h3>
|
||||
<div className="mt-3 flex items-baseline gap-1">
|
||||
<span className="text-3xl font-bold">¥{plan.price}</span>
|
||||
<span className="text-muted-foreground text-sm">/月</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{plan.desc}</p>
|
||||
<ul className="mt-6 flex-1 space-y-3">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2 text-sm">
|
||||
<Check className="h-4 w-4 text-primary mt-0.5 shrink-0" />
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link href="/auth/register" className="mt-6 block">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={plan.popular ? 'default' : 'outline'}
|
||||
>
|
||||
{plan.cta}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BillingPlanGuard>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="py-24">
|
||||
<div className="mx-auto max-w-4xl px-4 sm:px-6 text-center">
|
||||
<h2 className="font-serif text-3xl sm:text-4xl font-bold">准备好了吗?</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
加入数千名创作者,用AI开启你的创作之旅
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Link href="/create">
|
||||
<Button size="lg" className="gap-2 px-8 h-12 text-base">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
免费开始创作
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/app/privacy/page.tsx
Normal file
5
src/app/privacy/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SitePolicyPage } from '@/components/site-policy-page';
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return <SitePolicyPage kind="privacy" />;
|
||||
}
|
||||
767
src/app/profile/page.tsx
Normal file
767
src/app/profile/page.tsx
Normal file
@@ -0,0 +1,767 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import type { ManagedModelConfigResponse, ManagedModelRecommendation, ManagedModelType } from '@/lib/model-config-types';
|
||||
import { useCustomApiKeys } from '@/lib/custom-api-store';
|
||||
import { useCreationHistory, type CreationRecord, isPlaceholder } from '@/lib/creation-history-store';
|
||||
import { useCreditRecords, formatRecordTime } from '@/lib/credit-records-store';
|
||||
import { useUserOrders, formatOrderTime } from '@/lib/order-store';
|
||||
import { useAuth } from '@/lib/auth-store';
|
||||
import { useSiteConfig } from '@/lib/site-config';
|
||||
import { CreationDetailDialog } from '@/components/creation-detail-dialog';
|
||||
import {
|
||||
User,
|
||||
CreditCard,
|
||||
Crown,
|
||||
Receipt,
|
||||
Image,
|
||||
Key,
|
||||
Coins,
|
||||
Calendar,
|
||||
Shield,
|
||||
TrendingUp,
|
||||
Gift,
|
||||
Zap,
|
||||
Settings,
|
||||
Globe,
|
||||
Cpu,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Plus,
|
||||
Check,
|
||||
Loader2,
|
||||
Film,
|
||||
LogOut,
|
||||
LogIn,
|
||||
ExternalLink,
|
||||
Sparkles,
|
||||
MessageSquare,
|
||||
ImageOff,
|
||||
Camera,
|
||||
MailCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
|
||||
|
||||
function isEmail(value: string) {
|
||||
return EMAIL_REGEX.test(value.trim());
|
||||
}
|
||||
|
||||
function sanitizeCode(value: string) {
|
||||
return value.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(0, 10);
|
||||
}
|
||||
|
||||
const ApiKeyManager = dynamic(() => import('@/components/profile/api-key-manager'), { ssr: false });
|
||||
const CreationHistoryTab = dynamic(() => import('@/components/profile/creation-history-tab'), { ssr: false });
|
||||
const CreditsTab = dynamic(() => import('@/components/profile/credits-tab'), { ssr: false });
|
||||
const OrdersTab = dynamic(() => import('@/components/profile/orders-tab'), { ssr: false });
|
||||
const membershipTiers = [
|
||||
{ tier: 'free', name: '免费版', price: 0, dailyQuota: 5, features: ['每日5次创作', '标准画质', '社区展示'] },
|
||||
{ tier: 'pro', name: 'Pro版', price: 29, dailyQuota: 50, features: ['每日50次创作', '高清画质', '私有存储', '批量下载'] },
|
||||
{ tier: 'max', name: 'Max版', price: 99, dailyQuota: -1, features: ['无限创作', '4K超清', '自定义API', '批量处理', '优先队列'] },
|
||||
{ tier: 'ultra', name: 'Ultra版', price: 499, dailyQuota: -1, features: ['团队协作', '专属额度', '品牌定制', '私有部署', '7x24支持'] },
|
||||
];
|
||||
|
||||
const membershipRank: Record<string, number> = {
|
||||
free: 0,
|
||||
basic: 1,
|
||||
pro: 1,
|
||||
max: 2,
|
||||
enterprise: 3,
|
||||
ultra: 3,
|
||||
};
|
||||
|
||||
function normalizeMembershipTier(tier?: string | null) {
|
||||
if (tier === 'basic') return 'pro';
|
||||
if (tier === 'enterprise') return 'ultra';
|
||||
return tier || 'free';
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { isLoggedIn, user, accessToken, logout, isAdmin, isVip, refreshProfile, updateProfile } = useAuth();
|
||||
const { config: siteConfig } = useSiteConfig();
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState('account');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [accountForm, setAccountForm] = useState({ nickname: '', email: '', phone: '', avatarUrl: '' });
|
||||
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
const [savingAccount, setSavingAccount] = useState(false);
|
||||
const [processingAvatar, setProcessingAvatar] = useState(false);
|
||||
const [accountMessage, setAccountMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [showEmailVerify, setShowEmailVerify] = useState(false);
|
||||
const [emailVerifyCode, setEmailVerifyCode] = useState('');
|
||||
const [emailVerifyCooldown, setEmailVerifyCooldown] = useState(0);
|
||||
const [sendingEmailCode, setSendingEmailCode] = useState(false);
|
||||
const [verifyingEmail, setVerifyingEmail] = useState(false);
|
||||
const { records: creationRecords } = useCreationHistory();
|
||||
const { records: creditRecords } = useCreditRecords();
|
||||
const { orders } = useUserOrders();
|
||||
const membershipEnabled = siteConfig.membershipEnabled !== false;
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Refresh profile from server on mount to pick up admin changes
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
refreshProfile();
|
||||
}
|
||||
}, [isLoggedIn, refreshProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!membershipEnabled && ['membership', 'credits', 'orders'].includes(activeTab)) {
|
||||
setActiveTab('account');
|
||||
}
|
||||
}, [membershipEnabled, activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setAccountForm({
|
||||
nickname: user.nickname || '',
|
||||
email: user.email || '',
|
||||
phone: user.phone || '',
|
||||
avatarUrl: user.avatarUrl || '',
|
||||
});
|
||||
}, [user?.id, user?.nickname, user?.email, user?.phone, user?.avatarUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (emailVerifyCooldown <= 0) return;
|
||||
const timer = window.setInterval(() => setEmailVerifyCooldown(prev => Math.max(0, prev - 1)), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [emailVerifyCooldown]);
|
||||
|
||||
// Use auth store data directly
|
||||
const profile = {
|
||||
nickname: user?.nickname || '游客',
|
||||
email: user?.email || '',
|
||||
phone: user?.phone || '',
|
||||
role: user?.role || 'user',
|
||||
membership_tier: user?.membershipTier || 'free',
|
||||
credits_balance: user?.creditsBalance ?? 0,
|
||||
daily_quota_used: user?.dailyQuotaUsed ?? 0,
|
||||
daily_quota_limit: user?.dailyQuotaLimit ?? 5,
|
||||
avatar_url: user?.avatarUrl || '',
|
||||
created_at: user?.createdAt || '',
|
||||
email_verified: user?.emailVerified === true,
|
||||
email_verified_at: user?.emailVerifiedAt || '',
|
||||
};
|
||||
|
||||
const normalizedMembershipTier = normalizeMembershipTier(profile.membership_tier);
|
||||
const currentMembershipRank = membershipRank[normalizedMembershipTier] ?? 0;
|
||||
const tierInfo = membershipTiers.find(t => t.tier === normalizedMembershipTier) || membershipTiers[0];
|
||||
|
||||
// Role display info
|
||||
const roleInfo: Record<string, { label: string; color: string }> = {
|
||||
admin: { label: '管理员', color: 'text-primary' },
|
||||
enterprise_admin: { label: '企业管理员', color: 'text-primary' },
|
||||
vip: { label: 'VIP', color: 'text-primary' },
|
||||
user: { label: '普通用户', color: 'text-muted-foreground' },
|
||||
};
|
||||
const currentRole = roleInfo[profile.role] || roleInfo.user;
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
const handleAvatarChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setAccountMessage({ type: 'error', text: '请选择图片文件作为头像' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setAccountMessage({ type: 'error', text: '头像图片不能超过 5MB' });
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessingAvatar(true);
|
||||
setAccountMessage(null);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const image = new window.Image();
|
||||
image.onload = () => {
|
||||
const size = 512;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
setProcessingAvatar(false);
|
||||
setAccountMessage({ type: 'error', text: '头像处理失败,请换一张图片' });
|
||||
return;
|
||||
}
|
||||
|
||||
const side = Math.min(image.width, image.height);
|
||||
const sx = (image.width - side) / 2;
|
||||
const sy = (image.height - side) / 2;
|
||||
ctx.drawImage(image, sx, sy, side, side, 0, 0, size, size);
|
||||
const avatarUrl = canvas.toDataURL('image/jpeg', 0.86);
|
||||
setAccountForm(prev => ({ ...prev, avatarUrl }));
|
||||
setProcessingAvatar(false);
|
||||
};
|
||||
image.onerror = () => {
|
||||
setProcessingAvatar(false);
|
||||
setAccountMessage({ type: 'error', text: '头像读取失败,请换一张图片' });
|
||||
};
|
||||
image.src = String(reader.result || '');
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setProcessingAvatar(false);
|
||||
setAccountMessage({ type: 'error', text: '头像读取失败,请换一张图片' });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleAccountSave = async () => {
|
||||
if (!user || !accessToken) {
|
||||
setAccountMessage({ type: 'error', text: '请先登录后再修改资料' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordForm.newPassword || passwordForm.confirmPassword || passwordForm.currentPassword) {
|
||||
if (passwordForm.newPassword.length < 6) {
|
||||
setAccountMessage({ type: 'error', text: '新密码至少需要 6 位' });
|
||||
return;
|
||||
}
|
||||
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||
setAccountMessage({ type: 'error', text: '两次输入的新密码不一致' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSavingAccount(true);
|
||||
setAccountMessage(null);
|
||||
|
||||
try {
|
||||
const payload: Record<string, string> = {
|
||||
email: accountForm.email,
|
||||
nickname: accountForm.nickname,
|
||||
phone: accountForm.phone,
|
||||
avatarUrl: accountForm.avatarUrl,
|
||||
};
|
||||
|
||||
if (passwordForm.newPassword) {
|
||||
payload.currentPassword = passwordForm.currentPassword;
|
||||
payload.newPassword = passwordForm.newPassword;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '保存失败');
|
||||
}
|
||||
|
||||
if (data.profile) {
|
||||
updateProfile({
|
||||
email: data.profile.email,
|
||||
nickname: data.profile.nickname,
|
||||
phone: data.profile.phone || null,
|
||||
membershipTier: data.profile.membership_tier || user.membershipTier,
|
||||
creditsBalance: data.profile.credits_balance ?? user.creditsBalance,
|
||||
dailyQuotaUsed: data.profile.daily_quota_used ?? user.dailyQuotaUsed,
|
||||
dailyQuotaLimit: data.profile.daily_quota_limit ?? user.dailyQuotaLimit,
|
||||
avatarUrl: data.profile.avatar_url ?? user.avatarUrl,
|
||||
createdAt: data.profile.created_at ?? user.createdAt,
|
||||
emailVerified: data.profile.email_verified === true,
|
||||
emailVerifiedAt: data.profile.email_verified_at ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
setAccountMessage({ type: 'success', text: '账号资料已保存' });
|
||||
refreshProfile();
|
||||
} catch (error) {
|
||||
setAccountMessage({ type: 'error', text: error instanceof Error ? error.message : '保存失败' });
|
||||
} finally {
|
||||
setSavingAccount(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendProfileEmailCode = async () => {
|
||||
if (!accessToken) {
|
||||
setAccountMessage({ type: 'error', text: '请先登录后再验证邮箱' });
|
||||
return;
|
||||
}
|
||||
if (!isEmail(accountForm.email)) {
|
||||
setAccountMessage({ type: 'error', text: '请输入正确的邮箱地址' });
|
||||
return;
|
||||
}
|
||||
setSendingEmailCode(true);
|
||||
setAccountMessage(null);
|
||||
try {
|
||||
const response = await fetch('/api/email/send-profile-code', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({ email: accountForm.email }),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || '验证码发送失败');
|
||||
setEmailVerifyCooldown(data.cooldown || 60);
|
||||
setShowEmailVerify(true);
|
||||
setAccountMessage({ type: 'success', text: data.message || '验证码已发送,请查收邮箱' });
|
||||
} catch (error) {
|
||||
setAccountMessage({ type: 'error', text: error instanceof Error ? error.message : '验证码发送失败' });
|
||||
} finally {
|
||||
setSendingEmailCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyProfileEmail = async () => {
|
||||
if (!accessToken) return;
|
||||
if (!isEmail(accountForm.email) || !emailVerifyCode) {
|
||||
setAccountMessage({ type: 'error', text: '请填写邮箱和验证码' });
|
||||
return;
|
||||
}
|
||||
setVerifyingEmail(true);
|
||||
try {
|
||||
const response = await fetch('/api/email/verify-profile', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({ email: accountForm.email, code: emailVerifyCode }),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || '邮箱验证失败');
|
||||
if (data.profile) {
|
||||
updateProfile({
|
||||
email: data.profile.email,
|
||||
nickname: data.profile.nickname,
|
||||
phone: data.profile.phone || null,
|
||||
membershipTier: data.profile.membership_tier || user?.membershipTier || 'free',
|
||||
creditsBalance: data.profile.credits_balance ?? user?.creditsBalance ?? 0,
|
||||
dailyQuotaUsed: data.profile.daily_quota_used ?? user?.dailyQuotaUsed ?? 0,
|
||||
dailyQuotaLimit: data.profile.daily_quota_limit ?? user?.dailyQuotaLimit ?? 5,
|
||||
avatarUrl: data.profile.avatar_url ?? user?.avatarUrl ?? null,
|
||||
createdAt: data.profile.created_at ?? user?.createdAt ?? null,
|
||||
emailVerified: data.profile.email_verified === true,
|
||||
emailVerifiedAt: data.profile.email_verified_at ?? null,
|
||||
});
|
||||
}
|
||||
setShowEmailVerify(false);
|
||||
setEmailVerifyCode('');
|
||||
setAccountMessage({ type: 'success', text: data.message || '邮箱验证成功' });
|
||||
refreshProfile();
|
||||
} catch (error) {
|
||||
setAccountMessage({ type: 'error', text: error instanceof Error ? error.message : '邮箱验证失败' });
|
||||
} finally {
|
||||
setVerifyingEmail(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Not logged in (after hydration) - show login prompt
|
||||
if (mounted && !isLoggedIn) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<Card className="max-w-md w-full mx-4">
|
||||
<CardContent className="p-8 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mx-auto mb-4">
|
||||
<User className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="font-serif text-xl font-bold mb-2">尚未登录</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">登录后可以管理你的创作、积分和 API 密钥</p>
|
||||
<Button className="gap-2" onClick={() => router.push('/auth/login')}>
|
||||
<LogIn className="h-4 w-4" />
|
||||
立即登录
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Before hydration - render placeholder to avoid SSR/client mismatch
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-16 w-16 rounded-full bg-muted animate-pulse" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-6 w-24 rounded bg-muted animate-pulse" />
|
||||
<div className="h-4 w-16 rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8">
|
||||
{/* Profile Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-primary/10 text-primary text-2xl font-serif font-bold ring-1 ring-primary/20">
|
||||
{profile.avatar_url ? (
|
||||
<img src={profile.avatar_url} alt={profile.nickname} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
profile.nickname[0]
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-serif text-2xl font-bold">{profile.nickname}</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
{profile.role === 'admin' && <Shield className="h-3 w-3" />}
|
||||
{currentRole.label}
|
||||
</Badge>
|
||||
{membershipEnabled && <Badge variant="outline">{tierInfo.name}</Badge>}
|
||||
<Badge variant={profile.email_verified ? 'secondary' : 'outline'} className={profile.email_verified ? 'text-emerald-500' : 'text-amber-500'}>
|
||||
{profile.email_verified ? '邮箱已验证' : '邮箱未验证'}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">{profile.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 text-muted-foreground" onClick={handleLogout}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
退出
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mt-6">
|
||||
{membershipEnabled && (
|
||||
<Card>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<Coins className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{profile.credits_balance}</p>
|
||||
<p className="text-xs text-muted-foreground">剩余积分</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{membershipEnabled && (
|
||||
<Card>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<Zap className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{profile.daily_quota_used}/{profile.daily_quota_limit}</p>
|
||||
<p className="text-xs text-muted-foreground">今日额度</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Card>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-muted">
|
||||
<Film className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{creationRecords.length}</p>
|
||||
<p className="text-xs text-muted-foreground">创作记录</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{membershipEnabled && (
|
||||
<Card>
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<Crown className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{tierInfo.name}</p>
|
||||
<p className="text-xs text-muted-foreground">当前会员</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className={`grid w-full grid-cols-3 ${membershipEnabled ? 'sm:grid-cols-6' : 'sm:grid-cols-3'} max-w-3xl`}>
|
||||
<TabsTrigger value="account" className="gap-1.5"><User className="h-4 w-4" /><span className="hidden sm:inline">账户</span></TabsTrigger>
|
||||
{membershipEnabled && <TabsTrigger value="membership" className="gap-1.5"><Crown className="h-4 w-4" /><span className="hidden sm:inline">会员</span></TabsTrigger>}
|
||||
{membershipEnabled && <TabsTrigger value="credits" className="gap-1.5"><Coins className="h-4 w-4" /><span className="hidden sm:inline">积分</span></TabsTrigger>}
|
||||
{membershipEnabled && <TabsTrigger value="orders" className="gap-1.5"><Receipt className="h-4 w-4" /><span className="hidden sm:inline">订单</span></TabsTrigger>}
|
||||
<TabsTrigger value="history" className="gap-1.5"><Image className="h-4 w-4" /><span className="hidden sm:inline">历史</span></TabsTrigger>
|
||||
<TabsTrigger value="api" className="gap-1.5"><Key className="h-4 w-4" /><span className="hidden sm:inline">API</span></TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Account Tab */}
|
||||
<TabsContent value="account" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Settings className="h-5 w-5" />账户信息</CardTitle>
|
||||
<CardDescription>管理你的账户基本信息</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{accountMessage && (
|
||||
<div className={`rounded-md border px-3 py-2 text-sm ${
|
||||
accountMessage.type === 'success'
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
: 'border-destructive/30 bg-destructive/10 text-destructive'
|
||||
}`}>
|
||||
{accountMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-xl border border-border bg-card/40 p-4 sm:flex-row sm:items-center">
|
||||
<div className="relative flex h-24 w-24 shrink-0 items-center justify-center overflow-hidden rounded-full bg-primary/10 text-3xl font-serif font-bold text-primary ring-1 ring-primary/25">
|
||||
{accountForm.avatarUrl ? (
|
||||
<img src={accountForm.avatarUrl} alt="头像预览" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
(accountForm.nickname || profile.nickname || '用').slice(0, 1).toUpperCase()
|
||||
)}
|
||||
{processingAvatar && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/45">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Label>自定义头像</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" className="gap-2" asChild disabled={processingAvatar}>
|
||||
<label className="cursor-pointer">
|
||||
<Camera className="h-4 w-4" />
|
||||
上传头像
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handleAvatarChange} />
|
||||
</label>
|
||||
</Button>
|
||||
{accountForm.avatarUrl && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setAccountForm(prev => ({ ...prev, avatarUrl: '' }))}
|
||||
disabled={processingAvatar}
|
||||
>
|
||||
移除头像
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">支持 JPG、PNG、WebP,系统会自动裁剪为方形头像。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>昵称</Label>
|
||||
<Input
|
||||
value={accountForm.nickname}
|
||||
onChange={(event) => setAccountForm(prev => ({ ...prev, nickname: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>邮箱</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="email"
|
||||
value={accountForm.email}
|
||||
onChange={(event) => setAccountForm(prev => ({ ...prev, email: event.target.value }))}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="shrink-0 gap-2"
|
||||
onClick={handleSendProfileEmailCode}
|
||||
disabled={sendingEmailCode || emailVerifyCooldown > 0 || !isEmail(accountForm.email)}
|
||||
>
|
||||
{sendingEmailCode ? <Loader2 className="h-4 w-4 animate-spin" /> : <MailCheck className="h-4 w-4" />}
|
||||
{emailVerifyCooldown > 0 ? `${emailVerifyCooldown}s` : profile.email_verified && accountForm.email === profile.email ? '重新验证' : '验证邮箱'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{profile.email_verified && accountForm.email === profile.email ? '该邮箱已验证,可用于找回密码。' : '邮箱验证后可用于找回密码和安全通知。'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>手机号</Label>
|
||||
<Input
|
||||
value={accountForm.phone}
|
||||
onChange={(event) => setAccountForm(prev => ({ ...prev, phone: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>注册时间</Label>
|
||||
<Input value={profile.created_at} disabled />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium mb-3 flex items-center gap-2"><Shield className="h-4 w-4" />安全设置</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>当前密码</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={passwordForm.currentPassword}
|
||||
onChange={(event) => setPasswordForm(prev => ({ ...prev, currentPassword: event.target.value }))}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>新密码</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={passwordForm.newPassword}
|
||||
onChange={(event) => setPasswordForm(prev => ({ ...prev, newPassword: event.target.value }))}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>确认新密码</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={(event) => setPasswordForm(prev => ({ ...prev, confirmPassword: event.target.value }))}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleAccountSave} disabled={savingAccount}>
|
||||
{savingAccount && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
保存修改
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Membership Tab */}
|
||||
{membershipEnabled && <TabsContent value="membership" className="mt-6">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Crown className="h-5 w-5" />会员订阅</CardTitle>
|
||||
<CardDescription>升级会员享受更多创作权益</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{membershipTiers.map((tier) => {
|
||||
const tierRank = membershipRank[tier.tier] ?? 0;
|
||||
const isCurrentTier = tier.tier === normalizedMembershipTier;
|
||||
const isUnavailableTier = tierRank <= currentMembershipRank;
|
||||
return (
|
||||
<Card key={tier.tier} className={`flex flex-col ${isCurrentTier ? 'border-primary' : ''} ${isUnavailableTier && !isCurrentTier ? 'opacity-55' : ''}`}>
|
||||
<CardContent className="p-6 flex-1 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-serif font-semibold">{tier.name}</h3>
|
||||
{isCurrentTier && (
|
||||
<Badge>当前</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1 mb-4">
|
||||
<span className="text-3xl font-bold">¥{tier.price}</span>
|
||||
<span className="text-sm text-muted-foreground">/月</span>
|
||||
</div>
|
||||
<ul className="space-y-2 mb-6 flex-1">
|
||||
{tier.features.map((f) => (
|
||||
<li key={f} className="flex items-center gap-2 text-sm">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary shrink-0" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
className="w-full shrink-0"
|
||||
variant={isUnavailableTier ? 'outline' : 'default'}
|
||||
disabled={isUnavailableTier}
|
||||
>
|
||||
{isCurrentTier ? '当前方案' : isUnavailableTier ? '不可降级' : '升级'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>}
|
||||
|
||||
{/* Credits Tab */}
|
||||
{membershipEnabled && <TabsContent value="credits" className="mt-6">
|
||||
<CreditsTab creditsBalance={profile.credits_balance} creditRecords={creditRecords} />
|
||||
</TabsContent>}
|
||||
|
||||
{/* Orders Tab */}
|
||||
{membershipEnabled && <TabsContent value="orders" className="mt-6">
|
||||
<OrdersTab orders={orders} />
|
||||
</TabsContent>}
|
||||
|
||||
{/* Works Tab */}
|
||||
<TabsContent value="history" className="mt-6">
|
||||
<CreationHistoryTab />
|
||||
</TabsContent>
|
||||
|
||||
{/* API Tab */}
|
||||
<TabsContent value="api" className="mt-6">
|
||||
<ApiKeyManager />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<Dialog open={showEmailVerify} onOpenChange={setShowEmailVerify}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>验证邮箱</DialogTitle>
|
||||
<DialogDescription>验证码已发送至 {accountForm.email},请在有效期内完成验证。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label>邮箱验证码</Label>
|
||||
<Input
|
||||
placeholder="输入邮箱验证码"
|
||||
value={emailVerifyCode}
|
||||
onChange={(event) => setEmailVerifyCode(sanitizeCode(event.target.value))}
|
||||
className="uppercase"
|
||||
maxLength={10}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setShowEmailVerify(false)}>取消</Button>
|
||||
<Button onClick={handleVerifyProfileEmail} disabled={verifyingEmail}>
|
||||
{verifyingEmail && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
完成验证
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/robots.ts
Normal file
11
src/app/robots.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/api/', '/_next/', '/static/'],
|
||||
},
|
||||
};
|
||||
}
|
||||
5
src/app/terms/page.tsx
Normal file
5
src/app/terms/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SitePolicyPage } from '@/components/site-policy-page';
|
||||
|
||||
export default function TermsPage() {
|
||||
return <SitePolicyPage kind="terms" />;
|
||||
}
|
||||
Reference in New Issue
Block a user