mirror of
https://github.com/34892002/edgeKey.git
synced 2026-06-06 19:51:47 +08:00
feat: s3协议-文件管理
This commit is contained in:
3
bun.lock
3
bun.lock
@@ -17,6 +17,7 @@
|
||||
"@tiptap/starter-kit": "^3.22.3",
|
||||
"@tiptap/vue-3": "^3.22.3",
|
||||
"@universal-middleware/core": "^0.4.17",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"hono": "^4.12.8",
|
||||
"pinyin-pro": "^3.28.1",
|
||||
@@ -611,6 +612,8 @@
|
||||
|
||||
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
|
||||
|
||||
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.13", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw=="],
|
||||
|
||||
268
lib/s3/client.ts
Normal file
268
lib/s3/client.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { AwsClient } from "aws4fetch";
|
||||
|
||||
export interface S3ClientConfig {
|
||||
endpoint: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
bucketName: string;
|
||||
region?: string;
|
||||
publicDomain?: string;
|
||||
pathPrefix?: string;
|
||||
cacheControl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 S3 响应状态码生成用户友好的错误信息
|
||||
*/
|
||||
function s3ErrorMessage(status: number, operation: string, rawText?: string): string {
|
||||
switch (status) {
|
||||
case 400:
|
||||
return `S3 ${operation}失败 (400):请求参数错误。请检查端点地址和存储桶名称格式是否正确。`;
|
||||
case 403:
|
||||
return `S3 ${operation}失败 (403):认证失败或权限不足。请检查 Access Key ID 和 Secret Access Key 是否正确,以及密钥是否拥有该存储桶的读写权限。`;
|
||||
case 404:
|
||||
return `S3 ${operation}失败 (404):存储桶或资源不存在。请检查存储桶名称是否正确,以及存储桶所在的区域是否与端点匹配。`;
|
||||
case 405:
|
||||
return `S3 ${operation}失败 (405):操作不被允许。请检查端点地址是否正确,部分存储服务对端点格式有特殊要求。`;
|
||||
case 429:
|
||||
return `S3 ${operation}失败 (429):请求过于频繁,已被限流。请稍后重试。`;
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
return `S3 ${operation}失败 (${status}):存储服务暂时不可用。请稍后重试。如果持续出现此错误,请检查存储服务状态。`;
|
||||
default:
|
||||
return `S3 ${operation}失败 (${status}):${rawText || "未知错误"}。请检查 S3 配置是否正确。`;
|
||||
}
|
||||
}
|
||||
|
||||
export class S3Client {
|
||||
private client: AwsClient;
|
||||
private config: S3ClientConfig;
|
||||
|
||||
constructor(config: S3ClientConfig) {
|
||||
this.config = config;
|
||||
this.client = new AwsClient({
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
service: "s3",
|
||||
region: config.region || "auto",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full S3 URL for a given key
|
||||
*/
|
||||
private buildUrl(key: string): string {
|
||||
const endpoint = this.config.endpoint.replace(/\/$/, "");
|
||||
const bucket = this.config.bucketName;
|
||||
|
||||
// Virtual-hosted-style: bucket already in hostname
|
||||
// e.g., https://v50-free.s3.us-west-004.backblazeb2.com
|
||||
// URL: https://v50-free.s3.us-west-004.backblazeb2.com/key
|
||||
if (endpoint.includes(`://${bucket}.`)) {
|
||||
return `${endpoint}/${key}`;
|
||||
}
|
||||
|
||||
// Path-style: https://s3.amazonaws.com/bucket/key
|
||||
return `${endpoint}/${bucket}/${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the public URL for accessing a file
|
||||
*/
|
||||
buildPublicUrl(key: string): string {
|
||||
if (this.config.publicDomain) {
|
||||
const domain = this.config.publicDomain.replace(/\/$/, "");
|
||||
return `${domain}/${key}`;
|
||||
}
|
||||
return this.buildUrl(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full key with optional path prefix
|
||||
*/
|
||||
buildKey(filename: string, path?: string): string {
|
||||
const prefix = this.config.pathPrefix?.replace(/^\/|\/$/g, "") || "";
|
||||
const dir = path?.replace(/^\/|\/$/g, "") || "";
|
||||
|
||||
let key = "";
|
||||
if (prefix) key += prefix + "/";
|
||||
if (dir) key += dir + "/";
|
||||
key += filename;
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test S3 connectivity and permissions
|
||||
* Attempts to list objects (max 1) to verify credentials and bucket access
|
||||
*/
|
||||
async testConnection(): Promise<{ ok: boolean; message: string }> {
|
||||
const endpoint = this.config.endpoint.replace(/\/$/, "");
|
||||
const bucket = this.config.bucketName;
|
||||
|
||||
// Build list-objects URL
|
||||
let listUrl: string;
|
||||
if (endpoint.includes(`://${bucket}.`)) {
|
||||
listUrl = `${endpoint}/?list-type=2&max-keys=1`;
|
||||
} else {
|
||||
listUrl = `${endpoint}/${bucket}/?list-type=2&max-keys=1`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.fetch(listUrl, { method: "GET" });
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
return { ok: false, message: s3ErrorMessage(response.status, "连接测试", text) };
|
||||
}
|
||||
|
||||
return { ok: true, message: `连接成功!存储桶「${bucket}」可正常访问。` };
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
if (msg.includes("fetch") || msg.includes("network") || msg.includes("ENOTFOUND")) {
|
||||
return { ok: false, message: `S3 连接测试失败:无法连接到 ${endpoint}。请检查端点地址是否正确,以及网络连接是否正常。` };
|
||||
}
|
||||
return { ok: false, message: `S3 连接测试失败:${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to S3
|
||||
*/
|
||||
async putObject(key: string, body: ArrayBuffer | Uint8Array | string, contentType: string): Promise<Response> {
|
||||
const url = this.buildUrl(key);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": this.config.cacheControl || "public, max-age=31536000, immutable",
|
||||
};
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.client.fetch(url, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: typeof body === "string" ? body : body as any,
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
if (msg.includes("fetch") || msg.includes("network") || msg.includes("ENOTFOUND")) {
|
||||
throw new Error(`S3 上传失败:无法连接到存储服务。请检查端点地址是否正确,以及网络连接是否正常。`);
|
||||
}
|
||||
throw new Error(`S3 上传失败:${msg}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(s3ErrorMessage(response.status, "上传", text));
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an object from S3 (for proxying)
|
||||
*/
|
||||
async getObject(key: string): Promise<Response> {
|
||||
const url = this.buildUrl(key);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.client.fetch(url, { method: "GET" });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
if (msg.includes("fetch") || msg.includes("network") || msg.includes("ENOTFOUND")) {
|
||||
throw new Error(`S3 获取文件失败:无法连接到存储服务。请检查端点地址是否正确,以及网络连接是否正常。`);
|
||||
}
|
||||
throw new Error(`S3 获取文件失败:${msg}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an object from S3
|
||||
*/
|
||||
async deleteObject(key: string): Promise<void> {
|
||||
const url = this.buildUrl(key);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.client.fetch(url, { method: "DELETE" });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
if (msg.includes("fetch") || msg.includes("network") || msg.includes("ENOTFOUND")) {
|
||||
throw new Error(`S3 删除失败:无法连接到存储服务。请检查端点地址是否正确,以及网络连接是否正常。`);
|
||||
}
|
||||
throw new Error(`S3 删除失败:${msg}`);
|
||||
}
|
||||
|
||||
// 404 is expected for idempotent delete (file already gone), silently ignore
|
||||
if (response.status === 404) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(s3ErrorMessage(response.status, "删除", text));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an object exists (HEAD request)
|
||||
*/
|
||||
async headObject(key: string): Promise<{ exists: boolean; contentType?: string; contentLength?: number }> {
|
||||
const url = this.buildUrl(key);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.client.fetch(url, { method: "HEAD" });
|
||||
} catch {
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
// 404 means file does not exist
|
||||
if (response.status === 404) {
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
// Other non-OK responses are real errors, not "not found"
|
||||
if (!response.ok) {
|
||||
throw new Error(s3ErrorMessage(response.status, "查询"));
|
||||
}
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
contentType: response.headers.get("Content-Type") || undefined,
|
||||
contentLength: response.headers.get("Content-Length")
|
||||
? parseInt(response.headers.get("Content-Length")!, 10)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an S3Client from a database config record
|
||||
*/
|
||||
export function createS3ClientFromConfig(config: {
|
||||
endpoint: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
bucketName: string;
|
||||
region?: string | null;
|
||||
publicDomain?: string | null;
|
||||
pathPrefix?: string | null;
|
||||
cacheControl?: string | null;
|
||||
}): S3Client {
|
||||
return new S3Client({
|
||||
endpoint: config.endpoint,
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
bucketName: config.bucketName,
|
||||
region: config.region || "auto",
|
||||
publicDomain: config.publicDomain || undefined,
|
||||
pathPrefix: config.pathPrefix || undefined,
|
||||
cacheControl: config.cacheControl || "public, max-age=31536000, immutable",
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PrismaClient } from "../../generated/prisma/client";
|
||||
import type { PrismaClient, Prisma, CardStatus } from "../../generated/prisma/client";
|
||||
|
||||
export function listCardRecords(prisma: PrismaClient) {
|
||||
return prisma.card.findMany({
|
||||
@@ -76,10 +76,10 @@ export function listCardRecordsPaged(
|
||||
pageSize: number;
|
||||
},
|
||||
) {
|
||||
const where: import("../../generated/prisma/client").Prisma.CardWhereInput = {};
|
||||
const where: Prisma.CardWhereInput = {};
|
||||
if (params.productId) where.productId = params.productId;
|
||||
if (params.batchNo) where.batchNo = { contains: params.batchNo };
|
||||
if (params.status) where.status = params.status as import("../../generated/prisma/client").CardStatus;
|
||||
if (params.status) where.status = params.status as CardStatus;
|
||||
if (params.startDate || params.endDate) {
|
||||
where.createdAt = {};
|
||||
if (params.startDate) where.createdAt.gte = new Date(params.startDate);
|
||||
|
||||
136
modules/media/repository.ts
Normal file
136
modules/media/repository.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { PrismaClient, Prisma } from "../../generated/prisma/client";
|
||||
import type { MediaListFilters, MediaListResult, S3ConfigInput } from "./types";
|
||||
|
||||
// ─── S3Config ────────────────────────────────────────────
|
||||
|
||||
export function getS3ConfigRecord(prisma: PrismaClient) {
|
||||
return prisma.s3Config.findUnique({ where: { id: 1 } });
|
||||
}
|
||||
|
||||
export function upsertS3ConfigRecord(prisma: PrismaClient, input: S3ConfigInput) {
|
||||
return prisma.s3Config.upsert({
|
||||
where: { id: 1 },
|
||||
create: {
|
||||
id: 1,
|
||||
endpoint: input.endpoint,
|
||||
accessKeyId: input.accessKeyId,
|
||||
secretAccessKey: input.secretAccessKey,
|
||||
bucketName: input.bucketName,
|
||||
region: input.region || "auto",
|
||||
publicDomain: input.publicDomain || null,
|
||||
pathPrefix: input.pathPrefix || null,
|
||||
cacheControl: input.cacheControl || "public, max-age=31536000, immutable",
|
||||
},
|
||||
update: {
|
||||
endpoint: input.endpoint,
|
||||
accessKeyId: input.accessKeyId,
|
||||
secretAccessKey: input.secretAccessKey,
|
||||
bucketName: input.bucketName,
|
||||
region: input.region || "auto",
|
||||
publicDomain: input.publicDomain || null,
|
||||
pathPrefix: input.pathPrefix || null,
|
||||
cacheControl: input.cacheControl || "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Media ───────────────────────────────────────────────
|
||||
|
||||
export function createMediaRecord(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
originalName: string;
|
||||
storedName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
fileKey: string;
|
||||
url: string;
|
||||
thumbnailUrl?: string | null;
|
||||
path?: string | null;
|
||||
metadata?: string | null;
|
||||
uploadedBy: number;
|
||||
},
|
||||
) {
|
||||
return prisma.media.create({
|
||||
data: {
|
||||
originalName: input.originalName,
|
||||
storedName: input.storedName,
|
||||
mimeType: input.mimeType,
|
||||
fileSize: input.fileSize,
|
||||
fileKey: input.fileKey,
|
||||
url: input.url,
|
||||
thumbnailUrl: input.thumbnailUrl || null,
|
||||
path: input.path || null,
|
||||
metadata: input.metadata || null,
|
||||
uploadedBy: input.uploadedBy,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteMediaRecord(prisma: PrismaClient, id: number) {
|
||||
return prisma.media.delete({ where: { id } });
|
||||
}
|
||||
|
||||
export function getMediaRecord(prisma: PrismaClient, id: number) {
|
||||
return prisma.media.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
export function getMediaByKey(prisma: PrismaClient, fileKey: string) {
|
||||
return prisma.media.findUnique({ where: { fileKey } });
|
||||
}
|
||||
|
||||
export async function listMediaRecords(
|
||||
prisma: PrismaClient,
|
||||
filters: MediaListFilters,
|
||||
): Promise<MediaListResult> {
|
||||
const page = Math.max(1, filters.page || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, filters.pageSize || 24));
|
||||
|
||||
const where: Prisma.MediaWhereInput = {};
|
||||
|
||||
if (filters.mimeType) {
|
||||
where.mimeType = { startsWith: filters.mimeType };
|
||||
}
|
||||
|
||||
if (filters.path !== undefined) {
|
||||
where.path = filters.path || null;
|
||||
}
|
||||
|
||||
if (filters.keyword) {
|
||||
where.OR = [
|
||||
{ originalName: { contains: filters.keyword } },
|
||||
{ storedName: { contains: filters.keyword } },
|
||||
];
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.media.findMany({
|
||||
where,
|
||||
orderBy: { uploadedAt: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.media.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
id: item.id,
|
||||
originalName: item.originalName,
|
||||
storedName: item.storedName,
|
||||
mimeType: item.mimeType,
|
||||
fileSize: item.fileSize,
|
||||
fileKey: item.fileKey,
|
||||
url: item.url,
|
||||
thumbnailUrl: item.thumbnailUrl,
|
||||
path: item.path,
|
||||
metadata: item.metadata,
|
||||
uploadedBy: item.uploadedBy,
|
||||
uploadedAt: item.uploadedAt.toISOString(),
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
215
modules/media/service.ts
Normal file
215
modules/media/service.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { getContext } from "telefunc";
|
||||
import type { PrismaClient } from "../../generated/prisma/client";
|
||||
import { badRequestError, notFoundError } from "../../lib/app-error";
|
||||
import { S3Client, createS3ClientFromConfig } from "../../lib/s3/client";
|
||||
import { getAdminContext, logAdminOperation } from "../auth/service";
|
||||
import {
|
||||
getS3ConfigRecord,
|
||||
upsertS3ConfigRecord,
|
||||
createMediaRecord,
|
||||
deleteMediaRecord,
|
||||
getMediaRecord,
|
||||
listMediaRecords,
|
||||
} from "./repository";
|
||||
import type { S3ConfigInput, MediaListFilters, MediaListResult, MediaItem } from "./types";
|
||||
|
||||
// ─── S3 Config ───────────────────────────────────────────
|
||||
|
||||
export async function getS3Config(prisma?: PrismaClient) {
|
||||
const client = prisma ?? getContext<{ prisma: PrismaClient }>().prisma;
|
||||
const record = await getS3ConfigRecord(client);
|
||||
if (!record) return null;
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
endpoint: record.endpoint,
|
||||
accessKeyId: record.accessKeyId,
|
||||
secretAccessKey: record.secretAccessKey,
|
||||
bucketName: record.bucketName,
|
||||
region: record.region,
|
||||
publicDomain: record.publicDomain,
|
||||
pathPrefix: record.pathPrefix,
|
||||
cacheControl: record.cacheControl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveS3Config(input: S3ConfigInput) {
|
||||
const adminContext = getAdminContext();
|
||||
const { prisma } = adminContext;
|
||||
const adminId = Number(adminContext.session?.user?.id);
|
||||
|
||||
// Validate input
|
||||
if (!input.endpoint?.trim()) throw badRequestError("S3 端点不能为空", "ENDPOINT_REQUIRED");
|
||||
if (!input.accessKeyId?.trim()) throw badRequestError("Access Key ID 不能为空", "ACCESS_KEY_REQUIRED");
|
||||
if (!input.secretAccessKey?.trim()) throw badRequestError("Secret Access Key 不能为空", "SECRET_KEY_REQUIRED");
|
||||
if (!input.bucketName?.trim()) throw badRequestError("存储桶名称不能为空", "BUCKET_REQUIRED");
|
||||
|
||||
// Normalize endpoint: remove trailing slash
|
||||
const endpoint = input.endpoint.trim().replace(/\/+$/, "");
|
||||
|
||||
const record = await upsertS3ConfigRecord(prisma, {
|
||||
endpoint,
|
||||
accessKeyId: input.accessKeyId.trim(),
|
||||
secretAccessKey: input.secretAccessKey.trim(),
|
||||
bucketName: input.bucketName.trim(),
|
||||
region: input.region?.trim() || "auto",
|
||||
publicDomain: input.publicDomain?.trim() || undefined,
|
||||
pathPrefix: input.pathPrefix?.trim().replace(/^\/|\/$/g, "") || undefined,
|
||||
cacheControl: input.cacheControl?.trim() || "public, max-age=31536000, immutable",
|
||||
});
|
||||
|
||||
await logAdminOperation(
|
||||
{
|
||||
action: "SAVE_S3_CONFIG",
|
||||
targetType: "S3Config",
|
||||
targetId: "1",
|
||||
detail: `bucket=${record.bucketName}, endpoint=${record.endpoint}`,
|
||||
},
|
||||
{ prisma, adminId },
|
||||
);
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
endpoint: record.endpoint,
|
||||
accessKeyId: record.accessKeyId,
|
||||
secretAccessKey: record.secretAccessKey,
|
||||
bucketName: record.bucketName,
|
||||
region: record.region,
|
||||
publicDomain: record.publicDomain,
|
||||
pathPrefix: record.pathPrefix,
|
||||
cacheControl: record.cacheControl,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Get S3 Client from DB ───────────────────────────────
|
||||
|
||||
export async function getS3Client(prisma: PrismaClient): Promise<S3Client> {
|
||||
const config = await getS3ConfigRecord(prisma);
|
||||
if (!config) {
|
||||
throw badRequestError("请先配置 S3 存储服务", "S3_CONFIG_MISSING");
|
||||
}
|
||||
return createS3ClientFromConfig(config);
|
||||
}
|
||||
|
||||
export async function testS3Connection(input?: S3ConfigInput) {
|
||||
const adminContext = getAdminContext();
|
||||
const { prisma } = adminContext;
|
||||
|
||||
let s3Client: S3Client;
|
||||
if (input) {
|
||||
// Test with provided config (from form, not yet saved)
|
||||
s3Client = createS3ClientFromConfig(input);
|
||||
} else {
|
||||
// Test with saved config
|
||||
const config = await getS3ConfigRecord(prisma);
|
||||
if (!config) {
|
||||
throw badRequestError("请先配置 S3 存储服务", "S3_CONFIG_MISSING");
|
||||
}
|
||||
s3Client = createS3ClientFromConfig(config);
|
||||
}
|
||||
|
||||
return s3Client.testConnection();
|
||||
}
|
||||
|
||||
// ─── Media Operations ────────────────────────────────────
|
||||
|
||||
export async function listMedia(filters: MediaListFilters): Promise<MediaListResult> {
|
||||
const adminContext = getAdminContext();
|
||||
const { prisma } = adminContext;
|
||||
return listMediaRecords(prisma, filters);
|
||||
}
|
||||
|
||||
export async function deleteMedia(mediaId: number) {
|
||||
const adminContext = getAdminContext();
|
||||
const { prisma } = adminContext;
|
||||
const adminId = Number(adminContext.session?.user?.id);
|
||||
|
||||
const media = await getMediaRecord(prisma, mediaId);
|
||||
if (!media) {
|
||||
throw notFoundError("文件不存在", "MEDIA_NOT_FOUND");
|
||||
}
|
||||
|
||||
// Delete from S3
|
||||
const s3Client = await getS3Client(prisma);
|
||||
await s3Client.deleteObject(media.fileKey);
|
||||
|
||||
// Delete from database
|
||||
await deleteMediaRecord(prisma, mediaId);
|
||||
|
||||
await logAdminOperation(
|
||||
{
|
||||
action: "DELETE_MEDIA",
|
||||
targetType: "Media",
|
||||
targetId: String(mediaId),
|
||||
detail: `file=${media.originalName}, key=${media.fileKey}`,
|
||||
},
|
||||
{ prisma, adminId },
|
||||
);
|
||||
|
||||
return { success: true, message: `已删除文件: ${media.originalName}` };
|
||||
}
|
||||
|
||||
// ─── Used by HTTP upload route ───────────────────────────
|
||||
|
||||
export async function handleUpload(
|
||||
prisma: PrismaClient,
|
||||
adminId: number,
|
||||
file: File,
|
||||
path?: string,
|
||||
): Promise<MediaItem> {
|
||||
const s3Client = await getS3Client(prisma);
|
||||
|
||||
// Generate stored name with timestamp and random suffix
|
||||
const ext = file.name.includes(".") ? "." + file.name.split(".").pop() : "";
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
const storedName = `${timestamp}-${random}${ext}`;
|
||||
const fileKey = s3Client.buildKey(storedName, path);
|
||||
|
||||
// Read file content
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const contentType = file.type || "application/octet-stream";
|
||||
|
||||
// Upload to S3
|
||||
await s3Client.putObject(fileKey, arrayBuffer, contentType);
|
||||
|
||||
// URL always goes through Worker proxy for Cloudflare caching
|
||||
const url = `/api/media/proxy/${fileKey}`;
|
||||
|
||||
// Save to database
|
||||
const media = await createMediaRecord(prisma, {
|
||||
originalName: file.name,
|
||||
storedName,
|
||||
mimeType: contentType,
|
||||
fileSize: file.size,
|
||||
fileKey,
|
||||
url,
|
||||
path: path || null,
|
||||
uploadedBy: adminId,
|
||||
});
|
||||
|
||||
await logAdminOperation(
|
||||
{
|
||||
action: "UPLOAD_MEDIA",
|
||||
targetType: "Media",
|
||||
targetId: String(media.id),
|
||||
detail: `file=${file.name}, size=${file.size}, type=${contentType}`,
|
||||
},
|
||||
{ prisma, adminId },
|
||||
);
|
||||
|
||||
return {
|
||||
id: media.id,
|
||||
originalName: media.originalName,
|
||||
storedName: media.storedName,
|
||||
mimeType: media.mimeType,
|
||||
fileSize: media.fileSize,
|
||||
fileKey: media.fileKey,
|
||||
url: media.url,
|
||||
thumbnailUrl: media.thumbnailUrl,
|
||||
path: media.path,
|
||||
metadata: media.metadata,
|
||||
uploadedBy: media.uploadedBy,
|
||||
uploadedAt: media.uploadedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
89
modules/media/types.ts
Normal file
89
modules/media/types.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
export interface S3ConfigInput {
|
||||
endpoint: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
bucketName: string;
|
||||
region?: string;
|
||||
publicDomain?: string;
|
||||
pathPrefix?: string;
|
||||
cacheControl?: string;
|
||||
}
|
||||
|
||||
export interface MediaListFilters {
|
||||
mimeType?: string;
|
||||
path?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface MediaListResult {
|
||||
items: MediaItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface MediaItem {
|
||||
id: number;
|
||||
originalName: string;
|
||||
storedName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
fileKey: string;
|
||||
url: string;
|
||||
thumbnailUrl: string | null;
|
||||
path: string | null;
|
||||
metadata: string | null;
|
||||
uploadedBy: number;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
media: MediaItem;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Browser-renderable MIME types
|
||||
export const RENDERABLE_MIME_TYPES = new Set([
|
||||
// Images
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
"image/bmp",
|
||||
"image/ico",
|
||||
// Videos
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/ogg",
|
||||
// Audio
|
||||
"audio/mpeg",
|
||||
"audio/ogg",
|
||||
"audio/wav",
|
||||
"audio/webm",
|
||||
// Documents
|
||||
"application/pdf",
|
||||
]);
|
||||
|
||||
export function isRenderableMimeType(mimeType: string): boolean {
|
||||
return RENDERABLE_MIME_TYPES.has(mimeType);
|
||||
}
|
||||
|
||||
export function getFileCategory(mimeType: string): string {
|
||||
if (mimeType.startsWith("image/")) return "图片";
|
||||
if (mimeType.startsWith("video/")) return "视频";
|
||||
if (mimeType.startsWith("audio/")) return "音频";
|
||||
if (mimeType === "application/pdf") return "PDF";
|
||||
return "文件";
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
"verify:payments": "bun run scripts/verify-payment-adapters.ts",
|
||||
"verify:payment-notify": "bun run scripts/verify-payment-notify.ts",
|
||||
"deploy": "bun run db:migrations:remote && bun run db:seed:remote && wrangler deploy",
|
||||
"up": "vike build && wrangler deploy",
|
||||
"up": "bun run db:migrations:remote && vike build && wrangler deploy",
|
||||
"types": "wrangler types"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -31,6 +31,7 @@
|
||||
"@tiptap/starter-kit": "^3.22.3",
|
||||
"@tiptap/vue-3": "^3.22.3",
|
||||
"@universal-middleware/core": "^0.4.17",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"hono": "^4.12.8",
|
||||
"pinyin-pro": "^3.28.1",
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
<li><a href="/admin/orders" :class="{'active': currentPath?.startsWith('/admin/orders')}">订单管理</a></li>
|
||||
<li><a href="/admin/payments" :class="{'active': currentPath?.startsWith('/admin/payments')}">支付配置</a></li>
|
||||
<li><a href="/admin/email" :class="{'active': currentPath?.startsWith('/admin/email')}">邮件管理</a></li>
|
||||
<li><a href="/admin/media" :class="{'active': currentPath?.startsWith('/admin/media')}">文件管理</a></li>
|
||||
<li><a href="/admin/settings" :class="{'active': currentPath?.startsWith('/admin/settings')}">站点设置</a></li>
|
||||
<!-- <li><a href="/admin/security" :class="{'active': currentPath?.startsWith('/admin/security')}">安全配置</a></li> -->
|
||||
<li><a href="/admin/profile" :class="{'active': currentPath?.startsWith('/admin/profile')}">个人资料</a></li>
|
||||
@@ -218,6 +219,7 @@ const BREADCRUMB_ROUTES: { pattern: string; crumbs: Crumb[] }[] = [
|
||||
{ pattern: "/admin/cards", crumbs: [{ name: "卡密管理" }] },
|
||||
{ pattern: "/admin/payments", crumbs: [{ name: "支付配置" }] },
|
||||
{ pattern: "/admin/email", crumbs: [{ name: "邮件管理" }] },
|
||||
{ pattern: "/admin/media", crumbs: [{ name: "文件管理" }] },
|
||||
{ pattern: "/admin/settings", crumbs: [{ name: "站点设置" }] },
|
||||
{ pattern: "/admin/security", crumbs: [{ name: "安全配置" }] },
|
||||
{ pattern: "/admin/profile", crumbs: [{ name: "个人资料" }] },
|
||||
|
||||
305
pages/admin/media/+Page.vue
Normal file
305
pages/admin/media/+Page.vue
Normal file
@@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<section class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between gap-4 max-md:flex-col max-md:items-start">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">文件管理</h1>
|
||||
<p class="text-sm text-base-content/70">管理上传到 S3 存储的文件资源。</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<AppButton variant="outline" size="sm" @click="showS3Config = true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||||
S3 配置
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- No S3 Config Warning -->
|
||||
<div v-if="!s3Config" class="alert alert-warning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
|
||||
<div>
|
||||
<h3 class="font-bold">尚未配置 S3 存储</h3>
|
||||
<p class="text-sm">请先配置 S3 兼容存储服务(如 Cloudflare R2、AWS S3 等)才能使用文件管理功能。</p>
|
||||
</div>
|
||||
<AppButton variant="primary" size="sm" @click="showS3Config = true">立即配置</AppButton>
|
||||
</div>
|
||||
|
||||
<!-- Upload Area -->
|
||||
<section v-if="s3Config" class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<MediaUploader
|
||||
:uploading="uploading"
|
||||
:upload-progress="uploadProgress"
|
||||
:upload-error="uploadError"
|
||||
@upload="handleUpload"
|
||||
@clear-error="uploadError = ''"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Filters & Search -->
|
||||
<section v-if="s3Config" class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body py-3">
|
||||
<MediaFilters
|
||||
:keyword="filterKeyword"
|
||||
:type-filter="filterType"
|
||||
:total="mediaList.total"
|
||||
@update:keyword="handleKeywordChange"
|
||||
@update:type-filter="handleTypeFilterChange"
|
||||
@refresh="refreshMediaList"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Media Grid -->
|
||||
<section v-if="s3Config" class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="mediaList.items.length === 0" class="text-center py-12">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
<p class="text-base-content/50">暂无文件</p>
|
||||
<p class="text-sm text-base-content/40 mt-1">上传文件后将在此处显示</p>
|
||||
</div>
|
||||
|
||||
<MediaGrid
|
||||
v-else
|
||||
:items="mediaList.items"
|
||||
@preview="handlePreview"
|
||||
@delete="handleDelete"
|
||||
@copy-url="handleCopyUrl"
|
||||
/>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="mediaList.totalPages > 1" class="flex justify-center mt-6">
|
||||
<div class="join">
|
||||
<button
|
||||
class="join-item btn btn-sm"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<button class="join-item btn btn-sm">第 {{ currentPage }} / {{ mediaList.totalPages }} 页</button>
|
||||
<button
|
||||
class="join-item btn btn-sm"
|
||||
:disabled="currentPage >= mediaList.totalPages"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Preview Modal -->
|
||||
<MediaPreview
|
||||
v-if="previewItem"
|
||||
:item="previewItem"
|
||||
@close="previewItem = null"
|
||||
@copy-url="handleCopyUrl"
|
||||
/>
|
||||
|
||||
<!-- S3 Config Modal -->
|
||||
<S3ConfigModal
|
||||
v-if="showS3Config"
|
||||
:config="s3Config"
|
||||
:saving="savingConfig"
|
||||
:error="configError"
|
||||
@close="showS3Config = false"
|
||||
@save="handleSaveConfig"
|
||||
/>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="toast toast-end">
|
||||
<div v-if="toastMessage" class="alert" :class="toastType === 'success' ? 'alert-success' : 'alert-error'">
|
||||
<span>{{ toastMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { useData } from "vike-vue/useData";
|
||||
import AppButton from "../../../components/AppButton.vue";
|
||||
import MediaUploader from "./components/MediaUploader.vue";
|
||||
import MediaGrid from "./components/MediaGrid.vue";
|
||||
import MediaPreview from "./components/MediaPreview.vue";
|
||||
import MediaFilters from "./components/MediaFilters.vue";
|
||||
import S3ConfigModal from "./components/S3ConfigModal.vue";
|
||||
import { normalizeTelefuncError } from "../../../lib/app-error";
|
||||
import { onGetMediaList } from "./getMediaList.telefunc";
|
||||
import { onDeleteMedia } from "./deleteMedia.telefunc";
|
||||
import { onSaveS3Config } from "./saveS3Config.telefunc";
|
||||
import type { Data } from "./+data";
|
||||
import type { MediaItem, S3ConfigInput, MediaListResult } from "../../../modules/media/types";
|
||||
|
||||
const initialData = useData<Awaited<Data>>();
|
||||
|
||||
// State
|
||||
const s3Config = ref(initialData.s3Config);
|
||||
const mediaList = ref<MediaListResult>(initialData.mediaList);
|
||||
const loading = ref(false);
|
||||
const uploading = ref(false);
|
||||
const uploadProgress = ref(0);
|
||||
const uploadError = ref("");
|
||||
const previewItem = ref<MediaItem | null>(null);
|
||||
const showS3Config = ref(false);
|
||||
const savingConfig = ref(false);
|
||||
const configError = ref("");
|
||||
const filterKeyword = ref("");
|
||||
const filterType = ref("");
|
||||
const currentPage = ref(1);
|
||||
const toastMessage = ref("");
|
||||
const toastType = ref<"success" | "error">("success");
|
||||
|
||||
// Functions
|
||||
function showToast(message: string, type: "success" | "error" = "success") {
|
||||
toastMessage.value = message;
|
||||
toastType.value = type;
|
||||
setTimeout(() => {
|
||||
toastMessage.value = "";
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async function refreshMediaList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await onGetMediaList({
|
||||
keyword: filterKeyword.value || undefined,
|
||||
mimeType: filterType.value || undefined,
|
||||
page: currentPage.value,
|
||||
pageSize: 24,
|
||||
});
|
||||
mediaList.value = result;
|
||||
} catch (error) {
|
||||
showToast(normalizeTelefuncError(error, "加载失败"), "error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
function handleKeywordChange(keyword: string) {
|
||||
filterKeyword.value = keyword;
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage.value = 1;
|
||||
refreshMediaList();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function handleTypeFilterChange(type: string) {
|
||||
filterType.value = type;
|
||||
currentPage.value = 1;
|
||||
refreshMediaList();
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
currentPage.value = page;
|
||||
refreshMediaList();
|
||||
}
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
if (!s3Config.value) return;
|
||||
|
||||
uploading.value = true;
|
||||
uploadProgress.value = 0;
|
||||
uploadError.value = "";
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/media/upload");
|
||||
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
if (e.lengthComputable) {
|
||||
uploadProgress.value = Math.round((e.loaded / e.total) * 100);
|
||||
}
|
||||
});
|
||||
|
||||
const response = await new Promise<{ success: boolean; data: MediaItem; message: string }>((resolve, reject) => {
|
||||
xhr.onload = () => {
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(data);
|
||||
} else {
|
||||
reject(new Error(data.message || "上传失败"));
|
||||
}
|
||||
} catch {
|
||||
reject(new Error("上传失败"));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("网络错误"));
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
showToast("文件上传成功");
|
||||
// Add to list
|
||||
mediaList.value.items.unshift(response.data);
|
||||
mediaList.value.total += 1;
|
||||
} catch (error) {
|
||||
uploadError.value = normalizeTelefuncError(error, "上传失败");
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
uploadProgress.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: MediaItem) {
|
||||
if (!confirm(`确定要删除「${item.originalName}」吗?此操作不可恢复。`)) return;
|
||||
|
||||
try {
|
||||
await onDeleteMedia(item.id);
|
||||
showToast("文件已删除");
|
||||
mediaList.value.items = mediaList.value.items.filter((i) => i.id !== item.id);
|
||||
mediaList.value.total -= 1;
|
||||
if (previewItem.value?.id === item.id) {
|
||||
previewItem.value = null;
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(normalizeTelefuncError(error, "删除失败"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreview(item: MediaItem) {
|
||||
previewItem.value = item;
|
||||
}
|
||||
|
||||
function handleCopyUrl(url: string) {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
showToast("URL 已复制到剪贴板");
|
||||
}).catch(() => {
|
||||
showToast("复制失败,请手动复制", "error");
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSaveConfig(input: S3ConfigInput) {
|
||||
savingConfig.value = true;
|
||||
configError.value = "";
|
||||
|
||||
try {
|
||||
const result = await onSaveS3Config(input);
|
||||
s3Config.value = result;
|
||||
showS3Config.value = false;
|
||||
showToast("S3 配置已保存");
|
||||
// Refresh media list after config change
|
||||
refreshMediaList();
|
||||
} catch (error) {
|
||||
configError.value = normalizeTelefuncError(error, "保存失败");
|
||||
} finally {
|
||||
savingConfig.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
17
pages/admin/media/+data.ts
Normal file
17
pages/admin/media/+data.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { PrismaClient } from "../../../generated/prisma/client";
|
||||
import { getS3Config } from "../../../modules/media/service";
|
||||
import { listMediaRecords } from "../../../modules/media/repository";
|
||||
|
||||
export type Data = ReturnType<typeof data>;
|
||||
|
||||
export async function data(pageContext: { prisma: PrismaClient }) {
|
||||
const [s3Config, mediaList] = await Promise.all([
|
||||
getS3Config(pageContext.prisma),
|
||||
listMediaRecords(pageContext.prisma, { page: 1, pageSize: 24 }),
|
||||
]);
|
||||
|
||||
return {
|
||||
s3Config,
|
||||
mediaList,
|
||||
};
|
||||
}
|
||||
55
pages/admin/media/components/MediaFilters.vue
Normal file
55
pages/admin/media/components/MediaFilters.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<!-- Search -->
|
||||
<div class="form-control flex-1 min-w-[200px]">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
:value="keyword"
|
||||
placeholder="搜索文件名..."
|
||||
class="input input-bordered input-sm w-full"
|
||||
@input="$emit('update:keyword', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type Filter -->
|
||||
<select
|
||||
class="select select-bordered select-sm"
|
||||
:value="typeFilter"
|
||||
@change="$emit('update:type-filter', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
<option value="image/">图片</option>
|
||||
<option value="video/">视频</option>
|
||||
<option value="audio/">音频</option>
|
||||
<option value="application/pdf">PDF</option>
|
||||
</select>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="text-sm text-base-content/60">
|
||||
共 {{ total }} 个文件
|
||||
</div>
|
||||
|
||||
<!-- Refresh -->
|
||||
<button class="btn btn-ghost btn-sm" title="刷新" @click="$emit('refresh')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
keyword: string;
|
||||
typeFilter: string;
|
||||
total: number;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
"update:keyword": [value: string];
|
||||
"update:type-filter": [value: string];
|
||||
refresh: [];
|
||||
}>();
|
||||
</script>
|
||||
104
pages/admin/media/components/MediaGrid.vue
Normal file
104
pages/admin/media/components/MediaGrid.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="group relative card bg-base-200 hover:shadow-md transition-all cursor-pointer overflow-hidden"
|
||||
@click="$emit('preview', item)"
|
||||
>
|
||||
<!-- Thumbnail / Icon -->
|
||||
<div class="aspect-square flex items-center justify-center p-4 overflow-hidden">
|
||||
<img
|
||||
v-if="isImage(item.mimeType)"
|
||||
:src="item.url"
|
||||
:alt="item.originalName"
|
||||
class="w-full h-full object-cover rounded"
|
||||
loading="lazy"
|
||||
/>
|
||||
<video
|
||||
v-else-if="isVideo(item.mimeType)"
|
||||
:src="item.url"
|
||||
class="w-full h-full object-cover rounded"
|
||||
preload="metadata"
|
||||
muted
|
||||
/>
|
||||
<div v-else class="text-center">
|
||||
<svg v-if="isPdf(item.mimeType)" xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto text-error" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto text-base-content/40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p class="text-xs text-base-content/50 mt-1 uppercase">{{ getExtension(item.originalName) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Overlay -->
|
||||
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-2">
|
||||
<p class="text-white text-xs font-medium truncate">{{ item.originalName }}</p>
|
||||
<p class="text-white/70 text-xs">{{ formatSize(item.fileSize) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
class="btn btn-xs btn-circle btn-ghost bg-white/80 hover:bg-white text-base-content"
|
||||
title="复制 URL"
|
||||
@click.stop="$emit('copy-url', item.url)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs btn-circle btn-ghost bg-error/80 hover:bg-error text-white"
|
||||
title="删除"
|
||||
@click.stop="$emit('delete', item)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MediaItem } from "../../../../modules/media/types";
|
||||
|
||||
defineProps<{
|
||||
items: MediaItem[];
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
preview: [item: MediaItem];
|
||||
delete: [item: MediaItem];
|
||||
"copy-url": [url: string];
|
||||
}>();
|
||||
|
||||
function isImage(mimeType: string): boolean {
|
||||
return mimeType.startsWith("image/");
|
||||
}
|
||||
|
||||
function isVideo(mimeType: string): boolean {
|
||||
return mimeType.startsWith("video/");
|
||||
}
|
||||
|
||||
function isPdf(mimeType: string): boolean {
|
||||
return mimeType === "application/pdf";
|
||||
}
|
||||
|
||||
function getExtension(filename: string): string {
|
||||
const ext = filename.split(".").pop();
|
||||
return ext ? ext.toUpperCase() : "";
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||
}
|
||||
</script>
|
||||
146
pages/admin/media/components/MediaPreview.vue
Normal file
146
pages/admin/media/components/MediaPreview.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<dialog class="modal modal-open" @click.self="$emit('close')">
|
||||
<div class="modal-box max-w-4xl w-full max-h-[90vh] p-0 overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-base-300">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="font-bold text-lg truncate">{{ item.originalName }}</h3>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{{ getFileCategory(item.mimeType) }} · {{ formatSize(item.fileSize) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<button class="btn btn-sm btn-ghost" title="复制 URL" @click="$emit('copy-url', item.url)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
复制 URL
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost" @click="$emit('close')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Content -->
|
||||
<div class="flex items-center justify-center bg-base-300 min-h-[300px] max-h-[calc(90vh-180px)] overflow-auto p-4">
|
||||
<img
|
||||
v-if="isImage(item.mimeType)"
|
||||
:src="item.url"
|
||||
:alt="item.originalName"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
<video
|
||||
v-else-if="isVideo(item.mimeType)"
|
||||
:src="item.url"
|
||||
controls
|
||||
class="max-w-full max-h-full"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
<audio
|
||||
v-else-if="isAudio(item.mimeType)"
|
||||
:src="item.url"
|
||||
controls
|
||||
class="w-full max-w-md"
|
||||
>
|
||||
您的浏览器不支持音频播放
|
||||
</audio>
|
||||
<iframe
|
||||
v-else-if="isPdf(item.mimeType)"
|
||||
:src="item.url"
|
||||
class="w-full h-full min-h-[500px]"
|
||||
></iframe>
|
||||
<div v-else class="text-center py-12">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p class="text-base-content/50">此文件类型无法预览</p>
|
||||
<a :href="item.url" target="_blank" class="btn btn-primary btn-sm mt-4">在新标签页打开</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details Footer -->
|
||||
<div class="p-4 border-t border-base-300 bg-base-100">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-base-content/50">类型</span>
|
||||
<p class="font-medium">{{ item.mimeType }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-base-content/50">大小</span>
|
||||
<p class="font-medium">{{ formatSize(item.fileSize) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-base-content/50">上传时间</span>
|
||||
<p class="font-medium">{{ formatDate(item.uploadedAt) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-base-content/50">存储路径</span>
|
||||
<p class="font-medium truncate">{{ item.path || '根目录' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button @click="$emit('close')">关闭</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MediaItem } from "../../../../modules/media/types";
|
||||
|
||||
defineProps<{
|
||||
item: MediaItem;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
close: [];
|
||||
"copy-url": [url: string];
|
||||
}>();
|
||||
|
||||
function isImage(mimeType: string): boolean {
|
||||
return mimeType.startsWith("image/");
|
||||
}
|
||||
|
||||
function isVideo(mimeType: string): boolean {
|
||||
return mimeType.startsWith("video/");
|
||||
}
|
||||
|
||||
function isAudio(mimeType: string): boolean {
|
||||
return mimeType.startsWith("audio/");
|
||||
}
|
||||
|
||||
function isPdf(mimeType: string): boolean {
|
||||
return mimeType === "application/pdf";
|
||||
}
|
||||
|
||||
function getFileCategory(mimeType: string): string {
|
||||
if (mimeType.startsWith("image/")) return "图片";
|
||||
if (mimeType.startsWith("video/")) return "视频";
|
||||
if (mimeType.startsWith("audio/")) return "音频";
|
||||
if (mimeType === "application/pdf") return "PDF";
|
||||
return "文件";
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
84
pages/admin/media/components/MediaUploader.vue
Normal file
84
pages/admin/media/components/MediaUploader.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="border-2 border-dashed rounded-lg p-8 text-center transition-colors"
|
||||
:class="{
|
||||
'border-primary bg-primary/5': isDragging,
|
||||
'border-base-300 hover:border-primary/50': !isDragging && !uploading,
|
||||
'border-base-300 opacity-60 pointer-events-none': uploading,
|
||||
}"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<div v-if="uploading" class="space-y-4">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<div>
|
||||
<p class="font-medium">正在上传...</p>
|
||||
<div class="w-full max-w-xs mx-auto mt-3">
|
||||
<progress class="progress progress-primary w-full" :value="uploadProgress" max="100"></progress>
|
||||
<p class="text-sm text-base-content/60 mt-1">{{ uploadProgress }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto text-base-content/40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-medium">拖拽文件到此处,或</p>
|
||||
<label class="btn btn-primary btn-sm mt-2 cursor-pointer">
|
||||
选择文件
|
||||
<input type="file" class="hidden" @change="handleFileSelect" />
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/50">支持图片、视频、音频、PDF 等浏览器可直接预览的文件格式</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="uploadError" class="alert alert-error mt-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm whitespace-pre-wrap">{{ uploadError }}</p>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-xs" @click="$emit('clear-error')">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
uploading: boolean;
|
||||
uploadProgress: number;
|
||||
uploadError: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
upload: [file: File];
|
||||
"clear-error": [];
|
||||
}>();
|
||||
|
||||
const isDragging = ref(false);
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
isDragging.value = false;
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
emit("upload", files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
emit("upload", input.files[0]);
|
||||
input.value = ""; // Reset so same file can be selected again
|
||||
}
|
||||
}
|
||||
</script>
|
||||
201
pages/admin/media/components/S3ConfigModal.vue
Normal file
201
pages/admin/media/components/S3ConfigModal.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<dialog class="modal modal-open" @click.self="$emit('close')">
|
||||
<div class="modal-box max-w-2xl w-full">
|
||||
<h3 class="font-bold text-lg mb-4">S3 存储配置</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Endpoint -->
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">S3 端点 <span class="text-error">*</span></span>
|
||||
<input
|
||||
v-model="form.endpoint"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="https://s3.amazonaws.com"
|
||||
/>
|
||||
<span class="text-xs text-base-content/50">
|
||||
示例:Cloudflare R2: https://<account_id>.r2.cloudflarestorage.com | AWS S3: https://s3.amazonaws.com
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- Access Key -->
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">Access Key ID <span class="text-error">*</span></span>
|
||||
<input
|
||||
v-model="form.accessKeyId"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="AKIAIOSFODNN7EXAMPLE"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">Secret Access Key <span class="text-error">*</span></span>
|
||||
<input
|
||||
v-model="form.secretAccessKey"
|
||||
type="password"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Bucket & Region -->
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">存储桶名称 <span class="text-error">*</span></span>
|
||||
<input
|
||||
v-model="form.bucketName"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="my-bucket"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">区域</span>
|
||||
<input
|
||||
v-model="form.region"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="auto"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Public Domain -->
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">公开访问域名(可选)</span>
|
||||
<input
|
||||
v-model="form.publicDomain"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="https://cdn.example.com"
|
||||
/>
|
||||
<span class="text-xs text-base-content/50">
|
||||
如果配置了自定义域名或 CDN,填写后文件将通过此域名访问
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- Path Prefix -->
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">路径前缀(可选)</span>
|
||||
<input
|
||||
v-model="form.pathPrefix"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="media"
|
||||
/>
|
||||
<span class="text-xs text-base-content/50">
|
||||
上传文件会自动添加此前缀,例如填写 media,文件将存储到 media/ 目录下
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- Cache Control -->
|
||||
<label class="flex flex-col gap-1.5">
|
||||
<span class="label-text font-medium">缓存策略</span>
|
||||
<input
|
||||
v-model="form.cacheControl"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="public, max-age=31536000, immutable"
|
||||
/>
|
||||
<span class="text-xs text-base-content/50">
|
||||
Cache-Control 头,用于 Cloudflare 缓存。默认 1 年不变更缓存
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="alert alert-error mt-4">
|
||||
<span class="text-sm">{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Test Result -->
|
||||
<div v-if="testResult" class="mt-4" :class="testResult.ok ? 'text-success' : 'text-error'">
|
||||
<p class="text-sm">{{ testResult.message }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="modal-action">
|
||||
<AppButton variant="outline" :loading="testing" @click="handleTest">测试连接</AppButton>
|
||||
<div class="flex-1"></div>
|
||||
<button class="btn" @click="$emit('close')">取消</button>
|
||||
<AppButton variant="primary" :loading="saving" @click="handleSave">保存配置</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button @click="$emit('close')">关闭</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from "vue";
|
||||
import AppButton from "../../../../components/AppButton.vue";
|
||||
import { normalizeTelefuncError } from "../../../../lib/app-error";
|
||||
import { onTestS3Connection } from "../testS3Connection.telefunc";
|
||||
import type { S3ConfigInput } from "../../../../modules/media/types";
|
||||
|
||||
const props = defineProps<{
|
||||
config: {
|
||||
endpoint: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
bucketName: string;
|
||||
region: string;
|
||||
publicDomain: string | null;
|
||||
pathPrefix: string | null;
|
||||
cacheControl: string;
|
||||
} | null;
|
||||
saving: boolean;
|
||||
error: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
save: [input: S3ConfigInput];
|
||||
}>();
|
||||
|
||||
const form = reactive({
|
||||
endpoint: props.config?.endpoint || "",
|
||||
accessKeyId: props.config?.accessKeyId || "",
|
||||
secretAccessKey: props.config?.secretAccessKey || "",
|
||||
bucketName: props.config?.bucketName || "",
|
||||
region: props.config?.region || "auto",
|
||||
publicDomain: props.config?.publicDomain || "",
|
||||
pathPrefix: props.config?.pathPrefix || "",
|
||||
cacheControl: props.config?.cacheControl || "public, max-age=31536000, immutable",
|
||||
});
|
||||
|
||||
function handleSave() {
|
||||
emit("save", {
|
||||
endpoint: form.endpoint,
|
||||
accessKeyId: form.accessKeyId,
|
||||
secretAccessKey: form.secretAccessKey,
|
||||
bucketName: form.bucketName,
|
||||
region: form.region,
|
||||
publicDomain: form.publicDomain || undefined,
|
||||
pathPrefix: form.pathPrefix || undefined,
|
||||
cacheControl: form.cacheControl,
|
||||
});
|
||||
}
|
||||
|
||||
const testing = ref(false);
|
||||
const testResult = ref<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
async function handleTest() {
|
||||
testing.value = true;
|
||||
testResult.value = null;
|
||||
|
||||
try {
|
||||
const result = await onTestS3Connection({
|
||||
endpoint: form.endpoint,
|
||||
accessKeyId: form.accessKeyId,
|
||||
secretAccessKey: form.secretAccessKey,
|
||||
bucketName: form.bucketName,
|
||||
region: form.region,
|
||||
publicDomain: form.publicDomain || undefined,
|
||||
pathPrefix: form.pathPrefix || undefined,
|
||||
cacheControl: form.cacheControl,
|
||||
});
|
||||
testResult.value = result;
|
||||
} catch (error) {
|
||||
testResult.value = { ok: false, message: normalizeTelefuncError(error, "测试失败") };
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
7
pages/admin/media/deleteMedia.telefunc.ts
Normal file
7
pages/admin/media/deleteMedia.telefunc.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { assertAdminAccess } from "../../../modules/auth/service";
|
||||
import { deleteMedia } from "../../../modules/media/service";
|
||||
|
||||
export async function onDeleteMedia(mediaId: number) {
|
||||
assertAdminAccess();
|
||||
return deleteMedia(mediaId);
|
||||
}
|
||||
8
pages/admin/media/getMediaList.telefunc.ts
Normal file
8
pages/admin/media/getMediaList.telefunc.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { assertAdminAccess } from "../../../modules/auth/service";
|
||||
import { listMedia } from "../../../modules/media/service";
|
||||
import type { MediaListFilters } from "../../../modules/media/types";
|
||||
|
||||
export async function onGetMediaList(filters: MediaListFilters) {
|
||||
assertAdminAccess();
|
||||
return listMedia(filters);
|
||||
}
|
||||
7
pages/admin/media/getS3Config.telefunc.ts
Normal file
7
pages/admin/media/getS3Config.telefunc.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { assertAdminAccess } from "../../../modules/auth/service";
|
||||
import { getS3Config } from "../../../modules/media/service";
|
||||
|
||||
export async function onGetS3Config() {
|
||||
assertAdminAccess();
|
||||
return getS3Config();
|
||||
}
|
||||
8
pages/admin/media/saveS3Config.telefunc.ts
Normal file
8
pages/admin/media/saveS3Config.telefunc.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { assertAdminAccess } from "../../../modules/auth/service";
|
||||
import { saveS3Config } from "../../../modules/media/service";
|
||||
import type { S3ConfigInput } from "../../../modules/media/types";
|
||||
|
||||
export async function onSaveS3Config(input: S3ConfigInput) {
|
||||
assertAdminAccess();
|
||||
return saveS3Config(input);
|
||||
}
|
||||
8
pages/admin/media/testS3Connection.telefunc.ts
Normal file
8
pages/admin/media/testS3Connection.telefunc.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { S3ConfigInput } from "../../../modules/media/types";
|
||||
import { assertAdminAccess } from "../../../modules/auth/service";
|
||||
import { testS3Connection } from "../../../modules/media/service";
|
||||
|
||||
export async function onTestS3Connection(input?: S3ConfigInput) {
|
||||
assertAdminAccess();
|
||||
return testS3Connection(input);
|
||||
}
|
||||
46
prisma/migrations/0002_add_media_library.sql
Normal file
46
prisma/migrations/0002_add_media_library.sql
Normal file
@@ -0,0 +1,46 @@
|
||||
-- CreateTable: S3 configuration for media storage
|
||||
CREATE TABLE "S3Config" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"endpoint" TEXT NOT NULL,
|
||||
"accessKeyId" TEXT NOT NULL,
|
||||
"secretAccessKey" TEXT NOT NULL,
|
||||
"bucketName" TEXT NOT NULL,
|
||||
"region" TEXT NOT NULL DEFAULT 'auto',
|
||||
"publicDomain" TEXT,
|
||||
"pathPrefix" TEXT,
|
||||
"cacheControl" TEXT NOT NULL DEFAULT 'public, max-age=31536000, immutable',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable: Media files uploaded to S3
|
||||
CREATE TABLE "Media" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"originalName" TEXT NOT NULL,
|
||||
"storedName" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"fileSize" INTEGER NOT NULL,
|
||||
"fileKey" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"thumbnailUrl" TEXT,
|
||||
"path" TEXT,
|
||||
"metadata" TEXT,
|
||||
"uploadedBy" INTEGER NOT NULL,
|
||||
"uploadedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Media_storedName_key" ON "Media"("storedName");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Media_fileKey_key" ON "Media"("fileKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Media_mimeType_idx" ON "Media"("mimeType");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Media_uploadedAt_idx" ON "Media"("uploadedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Media_path_idx" ON "Media"("path");
|
||||
@@ -324,3 +324,37 @@ model AdminOperationLog {
|
||||
|
||||
@@index([adminId, createdAt])
|
||||
}
|
||||
|
||||
model S3Config {
|
||||
id Int @id @default(autoincrement())
|
||||
endpoint String
|
||||
accessKeyId String
|
||||
secretAccessKey String
|
||||
bucketName String
|
||||
region String @default("auto")
|
||||
publicDomain String?
|
||||
pathPrefix String?
|
||||
cacheControl String @default("public, max-age=31536000, immutable")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Media {
|
||||
id Int @id @default(autoincrement())
|
||||
originalName String
|
||||
storedName String @unique
|
||||
mimeType String
|
||||
fileSize Int
|
||||
fileKey String @unique
|
||||
url String
|
||||
thumbnailUrl String?
|
||||
path String?
|
||||
metadata String?
|
||||
uploadedBy Int
|
||||
uploadedAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([mimeType])
|
||||
@@index([uploadedAt])
|
||||
@@index([path])
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ async function findAdminByCredentials(prisma: PrismaClient, username: string, pa
|
||||
};
|
||||
}
|
||||
|
||||
function createAuthjsConfig(prisma: PrismaClient) {
|
||||
export function createAuthjsConfig(prisma: PrismaClient) {
|
||||
return {
|
||||
basePath: "/api/auth",
|
||||
trustHost: true,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { registerAlipayRoutes } from "./payment-alipay";
|
||||
import { registerStripeRoutes } from "./payment-stripe";
|
||||
import { registerRobotsRoutes } from "./robots";
|
||||
import { registerSitemapRoutes } from "./sitemap";
|
||||
import { registerMediaRoutes } from "./media";
|
||||
|
||||
// 集中注册所有 `/api/*` 路由,避免入口文件散落多个 register 调用。
|
||||
export function registerApiRoutes(app: Hono) {
|
||||
@@ -16,5 +17,6 @@ export function registerApiRoutes(app: Hono) {
|
||||
registerStripeRoutes(app);
|
||||
registerRobotsRoutes(app);
|
||||
registerSitemapRoutes(app);
|
||||
registerMediaRoutes(app);
|
||||
}
|
||||
|
||||
|
||||
136
server/routes/media.ts
Normal file
136
server/routes/media.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { Hono } from "hono";
|
||||
import { getPrismaForD1 } from "../prisma-factory";
|
||||
import { getSession, createAuthjsConfig } from "../authjs-handler";
|
||||
import { handleUpload } from "../../modules/media/service";
|
||||
import { getMediaByKey } from "../../modules/media/repository";
|
||||
import { getS3ConfigRecord } from "../../modules/media/repository";
|
||||
import { createS3ClientFromConfig } from "../../lib/s3/client";
|
||||
import { toErrorResponsePayload } from "../../lib/app-error";
|
||||
import { logger } from "../../lib/logger";
|
||||
|
||||
export function registerMediaRoutes(app: Hono) {
|
||||
/**
|
||||
* POST /api/media/upload
|
||||
* File upload endpoint using multipart form data
|
||||
* Requires admin authentication (checked via Auth.js session)
|
||||
*/
|
||||
app.post("/api/media/upload", async (c) => {
|
||||
try {
|
||||
const database = (c.env as { DB?: D1Database } | undefined)?.DB;
|
||||
if (!database) {
|
||||
return c.json({ message: "数据库绑定缺失", code: "D1_BINDING_MISSING" }, 500);
|
||||
}
|
||||
const prisma = getPrismaForD1(database);
|
||||
|
||||
// Verify admin session using Auth.js
|
||||
const authConfig = createAuthjsConfig(prisma);
|
||||
const session = await getSession(c.req.raw, authConfig);
|
||||
|
||||
if (!session?.user || (session.user as any).role !== "admin") {
|
||||
return c.json({ message: "请先登录管理员账号", code: "UNAUTHORIZED" }, 401);
|
||||
}
|
||||
|
||||
const adminId = Number(session.user.id);
|
||||
if (!Number.isFinite(adminId) || adminId <= 0) {
|
||||
return c.json({ message: "会话无效", code: "INVALID_SESSION" }, 401);
|
||||
}
|
||||
|
||||
// Parse multipart form data
|
||||
const formData = await c.req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
const path = (formData.get("path") as string) || undefined;
|
||||
|
||||
if (!file) {
|
||||
return c.json({ message: "请选择要上传的文件", code: "FILE_REQUIRED" }, 400);
|
||||
}
|
||||
|
||||
// Upload file
|
||||
const media = await handleUpload(prisma, adminId, file, path);
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
message: "文件上传成功",
|
||||
data: media,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error instanceof Error ? error : new Error(String(error)), {
|
||||
event: "media.upload.failed",
|
||||
});
|
||||
const payload = toErrorResponsePayload(error);
|
||||
return c.json(payload, payload.statusCode as any);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/media/proxy/*
|
||||
* Proxy media file from S3 through Worker domain
|
||||
* Adds Cache-Control headers for Cloudflare caching
|
||||
* This endpoint is public - no auth required (files are served publicly)
|
||||
*/
|
||||
app.get("/api/media/proxy/*", async (c) => {
|
||||
try {
|
||||
const database = (c.env as { DB?: D1Database } | undefined)?.DB;
|
||||
if (!database) {
|
||||
return c.json({ message: "数据库绑定缺失", code: "D1_BINDING_MISSING" }, 500);
|
||||
}
|
||||
const prisma = getPrismaForD1(database);
|
||||
|
||||
// Extract the key from the URL path
|
||||
const fullPath = c.req.path;
|
||||
const keyPrefix = "/api/media/proxy/";
|
||||
const fileKey = fullPath.substring(fullPath.indexOf(keyPrefix) + keyPrefix.length);
|
||||
|
||||
if (!fileKey) {
|
||||
return c.json({ message: "文件路径无效", code: "INVALID_KEY" }, 400);
|
||||
}
|
||||
|
||||
// Look up the media record
|
||||
const media = await getMediaByKey(prisma, fileKey);
|
||||
if (!media) {
|
||||
return c.json({ message: "文件不存在", code: "NOT_FOUND" }, 404);
|
||||
}
|
||||
|
||||
// Get S3 config
|
||||
const s3Config = await getS3ConfigRecord(prisma);
|
||||
if (!s3Config) {
|
||||
return c.json({ message: "S3 配置缺失", code: "S3_CONFIG_MISSING" }, 500);
|
||||
}
|
||||
|
||||
// Fetch from S3
|
||||
const s3Client = createS3ClientFromConfig(s3Config);
|
||||
const s3Response = await s3Client.getObject(fileKey);
|
||||
|
||||
if (!s3Response.ok) {
|
||||
if (s3Response.status === 404) {
|
||||
return c.json({ message: "文件不存在于存储中", code: "S3_NOT_FOUND" }, 404);
|
||||
}
|
||||
return c.json({ message: "获取文件失败", code: "S3_ERROR" }, 502);
|
||||
}
|
||||
|
||||
// Build response with Cache-Control header
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", media.mimeType);
|
||||
headers.set("Cache-Control", s3Config.cacheControl || "public, max-age=31536000, immutable");
|
||||
headers.set("Content-Disposition", `inline; filename="${encodeURIComponent(media.originalName)}"`);
|
||||
|
||||
const contentLength = s3Response.headers.get("Content-Length");
|
||||
if (contentLength) {
|
||||
headers.set("Content-Length", contentLength);
|
||||
}
|
||||
const etag = s3Response.headers.get("ETag");
|
||||
if (etag) {
|
||||
headers.set("ETag", etag);
|
||||
}
|
||||
|
||||
return new Response(s3Response.body, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error instanceof Error ? error : new Error(String(error)), {
|
||||
event: "media.proxy.failed",
|
||||
});
|
||||
return c.json({ message: "获取文件失败", code: "INTERNAL_ERROR" }, 500);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user