暂存
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller;
|
||||
|
||||
use app\service\AdminAuthService;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
|
||||
class AdminController
|
||||
{
|
||||
public function landing(Request $request): Response
|
||||
{
|
||||
if ((new AdminAuthService())->current($request) !== null) {
|
||||
return $this->redirect('/admin');
|
||||
}
|
||||
|
||||
return view('admin/landing', [
|
||||
'archiveCaskUrl' => config('admin.archive_cask_url', ''),
|
||||
'version' => $this->version(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function login(Request $request): Response
|
||||
{
|
||||
if ((new AdminAuthService())->current($request) !== null) {
|
||||
return $this->redirect('/admin');
|
||||
}
|
||||
|
||||
return view('admin/login', [
|
||||
'archiveCaskUrl' => config('admin.archive_cask_url', ''),
|
||||
'version' => $this->version(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function dashboard(Request $request): Response
|
||||
{
|
||||
$admin = (new AdminAuthService())->current($request);
|
||||
if ($admin === null) {
|
||||
return $this->redirect('/admin/login');
|
||||
}
|
||||
|
||||
return view('admin/dashboard', [
|
||||
'archiveCaskUrl' => config('admin.archive_cask_url', ''),
|
||||
'admin' => $admin,
|
||||
'version' => $this->version(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function redirect(string $location): Response
|
||||
{
|
||||
return response('', 302, ['Location' => $location]);
|
||||
}
|
||||
|
||||
private function version(): string
|
||||
{
|
||||
$path = base_path('.version');
|
||||
if (!is_file($path)) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
$value = trim((string) file_get_contents($path));
|
||||
return $value !== '' ? $value : 'unknown';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\Api;
|
||||
|
||||
use app\service\AdminAuthService;
|
||||
use InvalidArgumentException;
|
||||
use JsonException;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
use Throwable;
|
||||
|
||||
class AdminAuthController
|
||||
{
|
||||
public function login(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$payload = $this->payload($request);
|
||||
$username = trim((string) ($payload['username'] ?? ''));
|
||||
$password = (string) ($payload['password'] ?? '');
|
||||
|
||||
if ($username === '' || $password === '') {
|
||||
throw new InvalidArgumentException('username and password are required.');
|
||||
}
|
||||
|
||||
$auth = new AdminAuthService();
|
||||
$user = $auth->authenticate($username, $password);
|
||||
if ($user === null) {
|
||||
return $this->jsonResponse([
|
||||
'code' => 401,
|
||||
'message' => 'Admin login failed.',
|
||||
'errors' => ['auth' => 'invalid username or password.'],
|
||||
], 401);
|
||||
}
|
||||
|
||||
$auth->login($request, $user);
|
||||
} catch (JsonException $exception) {
|
||||
return $this->jsonResponse([
|
||||
'code' => 400,
|
||||
'message' => 'Invalid JSON body.',
|
||||
'errors' => ['body' => $exception->getMessage()],
|
||||
], 400);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
return $this->jsonResponse([
|
||||
'code' => 422,
|
||||
'message' => 'Admin login validation failed.',
|
||||
'errors' => ['auth' => $exception->getMessage()],
|
||||
], 422);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->jsonResponse([
|
||||
'code' => 500,
|
||||
'message' => 'Admin login failed.',
|
||||
'errors' => ['auth' => $exception->getMessage()],
|
||||
], 500);
|
||||
}
|
||||
|
||||
return $this->jsonResponse([
|
||||
'code' => 0,
|
||||
'message' => 'Admin login completed.',
|
||||
'data' => ['admin' => $user],
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function logout(Request $request): Response
|
||||
{
|
||||
try {
|
||||
(new AdminAuthService())->logout($request);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->jsonResponse([
|
||||
'code' => 500,
|
||||
'message' => 'Admin logout failed.',
|
||||
'errors' => ['auth' => $exception->getMessage()],
|
||||
], 500);
|
||||
}
|
||||
|
||||
return $this->jsonResponse([
|
||||
'code' => 0,
|
||||
'message' => 'Admin logout completed.',
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function me(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$admin = (new AdminAuthService())->current($request);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->jsonResponse([
|
||||
'code' => 500,
|
||||
'message' => 'Admin session lookup failed.',
|
||||
'errors' => ['auth' => $exception->getMessage()],
|
||||
], 500);
|
||||
}
|
||||
|
||||
if ($admin === null) {
|
||||
return $this->jsonResponse([
|
||||
'code' => 401,
|
||||
'message' => 'Admin session not found.',
|
||||
], 401);
|
||||
}
|
||||
|
||||
return $this->jsonResponse([
|
||||
'code' => 0,
|
||||
'message' => 'Admin session loaded.',
|
||||
'data' => ['admin' => $admin],
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws JsonException
|
||||
*/
|
||||
private function payload(Request $request): array
|
||||
{
|
||||
$rawBody = trim($request->rawBody());
|
||||
if ($rawBody === '') {
|
||||
return $request->post();
|
||||
}
|
||||
|
||||
$payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
|
||||
return is_array($payload) ? $payload : [];
|
||||
}
|
||||
|
||||
private function jsonResponse(array $data, int $status): Response
|
||||
{
|
||||
return response(
|
||||
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
|
||||
$status,
|
||||
['Content-Type' => 'application/json']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\Api;
|
||||
|
||||
use app\service\AdminAuthService;
|
||||
use app\service\AdminConsole\AdminDocService;
|
||||
use app\service\AdminConsole\ArchiveAdminService;
|
||||
use app\service\AdminConsole\MaintenanceScriptService;
|
||||
use app\service\AdminConsole\OpenSearchAdminService;
|
||||
use app\service\AdminUserRepository;
|
||||
use InvalidArgumentException;
|
||||
use JsonException;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
use Throwable;
|
||||
|
||||
class AdminConsoleController
|
||||
{
|
||||
public function archives(Request $request): Response
|
||||
{
|
||||
if ($guard = $this->guard($request)) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
try {
|
||||
$data = (new ArchiveAdminService())->list(
|
||||
trim((string) $request->get('query', '')),
|
||||
(int) $request->get('page', 1),
|
||||
(int) $request->get('page_size', 20),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->error(500, 'Archive list lookup failed.', ['archives' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
return $this->ok('Archive list loaded.', $data);
|
||||
}
|
||||
|
||||
public function archive(Request $request, string $archiveUid): Response
|
||||
{
|
||||
if ($guard = $this->guard($request)) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
try {
|
||||
$archive = (new ArchiveAdminService())->detail($archiveUid);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->error(500, 'Archive lookup failed.', ['archive' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
if ($archive === null) {
|
||||
return $this->error(404, 'Archive not found.', ['archive_uid' => $archiveUid], 404);
|
||||
}
|
||||
|
||||
return $this->ok('Archive loaded.', $archive);
|
||||
}
|
||||
|
||||
public function updateArchive(Request $request, string $archiveUid): Response
|
||||
{
|
||||
if ($guard = $this->guard($request)) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
try {
|
||||
$archive = (new ArchiveAdminService())->update($archiveUid, $this->payload($request));
|
||||
} catch (JsonException $exception) {
|
||||
return $this->error(400, 'Invalid JSON body.', ['body' => $exception->getMessage()], 400);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
return $this->error(422, 'Archive update validation failed.', ['archive' => $exception->getMessage()], 422);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->error(500, 'Archive update failed.', ['archive' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
if ($archive === null) {
|
||||
return $this->error(404, 'Archive not found.', ['archive_uid' => $archiveUid], 404);
|
||||
}
|
||||
|
||||
return $this->ok('Archive updated.', $archive);
|
||||
}
|
||||
|
||||
public function deleteArchive(Request $request, string $archiveUid): Response
|
||||
{
|
||||
if ($guard = $this->guard($request)) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
try {
|
||||
$deleted = (new ArchiveAdminService())->delete($archiveUid);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->error(500, 'Archive delete failed.', ['archive' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
if (!$deleted) {
|
||||
return $this->error(404, 'Archive not found.', ['archive_uid' => $archiveUid], 404);
|
||||
}
|
||||
|
||||
return $this->ok('Archive deleted.', ['archive_uid' => $archiveUid]);
|
||||
}
|
||||
|
||||
public function openSearchStatus(Request $request): Response
|
||||
{
|
||||
if ($guard = $this->guard($request)) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
try {
|
||||
$status = (new OpenSearchAdminService())->status();
|
||||
} catch (Throwable $exception) {
|
||||
return $this->error(500, 'OpenSearch status lookup failed.', ['opensearch' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
return $this->ok('OpenSearch status loaded.', $status);
|
||||
}
|
||||
|
||||
public function openSearchDocuments(Request $request): Response
|
||||
{
|
||||
if ($guard = $this->guard($request)) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
try {
|
||||
$documents = (new OpenSearchAdminService())->documents(
|
||||
trim((string) $request->get('query', '')),
|
||||
(int) $request->get('size', 20),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->error(500, 'OpenSearch document lookup failed.', ['opensearch' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
return $this->ok('OpenSearch documents loaded.', $documents);
|
||||
}
|
||||
|
||||
public function users(Request $request): Response
|
||||
{
|
||||
if ($guard = $this->guard($request)) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
try {
|
||||
$users = (new AdminUserRepository())->listAll();
|
||||
} catch (Throwable $exception) {
|
||||
return $this->error(500, 'Admin users lookup failed.', ['users' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
return $this->ok('Admin users loaded.', ['items' => array_map(fn (array $user): array => $this->sanitizeUser($user), $users)]);
|
||||
}
|
||||
|
||||
public function createUser(Request $request): Response
|
||||
{
|
||||
if ($guard = $this->guard($request)) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $this->payload($request);
|
||||
$username = trim((string) ($payload['username'] ?? ''));
|
||||
$password = trim((string) ($payload['password'] ?? ''));
|
||||
$displayName = trim((string) ($payload['display_name'] ?? ''));
|
||||
|
||||
if ($username === '' || $password === '') {
|
||||
throw new InvalidArgumentException('username and password are required.');
|
||||
}
|
||||
|
||||
$repository = new AdminUserRepository();
|
||||
if ($repository->findAnyByUsername($username)) {
|
||||
throw new InvalidArgumentException('username already exists.');
|
||||
}
|
||||
|
||||
$user = $repository->create($username, $password, $displayName !== '' ? $displayName : null);
|
||||
} catch (JsonException $exception) {
|
||||
return $this->error(400, 'Invalid JSON body.', ['body' => $exception->getMessage()], 400);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
return $this->error(422, 'Admin user creation validation failed.', ['user' => $exception->getMessage()], 422);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->error(500, 'Admin user creation failed.', ['user' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
return $this->ok('Admin user created.', ['user' => $this->sanitizeUser($user)]);
|
||||
}
|
||||
|
||||
public function updateUser(Request $request, int $id): Response
|
||||
{
|
||||
if ($guard = $this->guard($request)) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $this->payload($request);
|
||||
$repository = new AdminUserRepository();
|
||||
if ($repository->findAnyById($id) === null) {
|
||||
return $this->error(404, 'Admin user not found.', ['id' => $id], 404);
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
if (array_key_exists('display_name', $payload)) {
|
||||
$updates['display_name'] = $payload['display_name'];
|
||||
}
|
||||
if (array_key_exists('password', $payload)) {
|
||||
$updates['password'] = $payload['password'];
|
||||
}
|
||||
if (array_key_exists('is_active', $payload)) {
|
||||
$updates['is_active'] = (bool) $payload['is_active'];
|
||||
}
|
||||
|
||||
$user = $repository->updateUser($id, $updates);
|
||||
} catch (JsonException $exception) {
|
||||
return $this->error(400, 'Invalid JSON body.', ['body' => $exception->getMessage()], 400);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->error(500, 'Admin user update failed.', ['user' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
return $this->ok('Admin user updated.', ['user' => $this->sanitizeUser($user ?? [])]);
|
||||
}
|
||||
|
||||
public function docs(Request $request): Response
|
||||
{
|
||||
if ($guard = $this->guard($request)) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
try {
|
||||
$docs = (new AdminDocService())->list();
|
||||
} catch (Throwable $exception) {
|
||||
return $this->error(500, 'API docs lookup failed.', ['docs' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
return $this->ok('API docs loaded.', ['items' => $docs]);
|
||||
}
|
||||
|
||||
public function doc(Request $request, string $name): Response
|
||||
{
|
||||
if ($guard = $this->guard($request)) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
try {
|
||||
$doc = (new AdminDocService())->read($name);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->error(404, 'API doc not found.', ['doc' => $exception->getMessage()], 404);
|
||||
}
|
||||
|
||||
return $this->ok('API doc loaded.', $doc);
|
||||
}
|
||||
|
||||
public function scripts(Request $request): Response
|
||||
{
|
||||
if ($guard = $this->guard($request)) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
return $this->ok('Maintenance scripts loaded.', ['items' => (new MaintenanceScriptService())->list()]);
|
||||
}
|
||||
|
||||
public function script(Request $request, string $name): Response
|
||||
{
|
||||
if ($guard = $this->guard($request)) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
try {
|
||||
$script = (new MaintenanceScriptService())->describe($name);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->error(404, 'Maintenance script not found.', ['script' => $exception->getMessage()], 404);
|
||||
}
|
||||
|
||||
return $this->ok('Maintenance script loaded.', $script);
|
||||
}
|
||||
|
||||
public function runScript(Request $request): Response
|
||||
{
|
||||
if ($guard = $this->guard($request)) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $this->payload($request);
|
||||
$scriptName = trim((string) ($payload['script_name'] ?? ''));
|
||||
$args = $payload['args'] ?? [];
|
||||
|
||||
if ($scriptName === '') {
|
||||
throw new InvalidArgumentException('script_name is required.');
|
||||
}
|
||||
if (!is_array($args)) {
|
||||
throw new InvalidArgumentException('args must be an array.');
|
||||
}
|
||||
|
||||
$result = (new MaintenanceScriptService())->run($scriptName, $args);
|
||||
} catch (JsonException $exception) {
|
||||
return $this->error(400, 'Invalid JSON body.', ['body' => $exception->getMessage()], 400);
|
||||
} catch (InvalidArgumentException $exception) {
|
||||
return $this->error(422, 'Script execution validation failed.', ['script' => $exception->getMessage()], 422);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->error(500, 'Script execution failed.', ['script' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
return $this->ok('Maintenance script finished.', $result);
|
||||
}
|
||||
|
||||
private function guard(Request $request): ?Response
|
||||
{
|
||||
return (new AdminAuthService())->current($request) === null
|
||||
? $this->error(401, 'Admin session not found.', [], 401)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws JsonException
|
||||
*/
|
||||
private function payload(Request $request): array
|
||||
{
|
||||
$rawBody = trim($request->rawBody());
|
||||
if ($rawBody === '') {
|
||||
return $request->post();
|
||||
}
|
||||
|
||||
$payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
|
||||
return is_array($payload) ? $payload : [];
|
||||
}
|
||||
|
||||
private function ok(string $message, array $data): Response
|
||||
{
|
||||
return $this->jsonResponse([
|
||||
'code' => 0,
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
], 200);
|
||||
}
|
||||
|
||||
private function error(int $code, string $message, array $errors = [], int $status = 500): Response
|
||||
{
|
||||
return $this->jsonResponse([
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
'errors' => $errors,
|
||||
], $status);
|
||||
}
|
||||
|
||||
private function sanitizeUser(array $user): array
|
||||
{
|
||||
unset($user['password_hash']);
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function jsonResponse(array $data, int $status): Response
|
||||
{
|
||||
return response(
|
||||
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
|
||||
$status,
|
||||
['Content-Type' => 'application/json']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller\Api;
|
||||
|
||||
use app\service\ArchiveRepository;
|
||||
use support\Response;
|
||||
use Throwable;
|
||||
|
||||
class EvidenceController
|
||||
{
|
||||
public function archive(string $archiveUid): Response
|
||||
{
|
||||
try {
|
||||
$archive = (new ArchiveRepository())->findArchive($archiveUid);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->jsonResponse([
|
||||
'code' => 500,
|
||||
'message' => 'Archive lookup failed.',
|
||||
'errors' => ['archive' => $exception->getMessage()],
|
||||
], 500);
|
||||
}
|
||||
|
||||
if ($archive === null) {
|
||||
return $this->jsonResponse([
|
||||
'code' => 404,
|
||||
'message' => 'Archive not found.',
|
||||
'errors' => ['archive_uid' => $archiveUid],
|
||||
], 404);
|
||||
}
|
||||
|
||||
$archive['chunk_count'] = is_array($archive['chunks'] ?? null) ? count($archive['chunks']) : 0;
|
||||
|
||||
return $this->jsonResponse([
|
||||
'code' => 0,
|
||||
'message' => 'Archive loaded.',
|
||||
'data' => $archive,
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function chunk(string $chunkUid): Response
|
||||
{
|
||||
try {
|
||||
$chunk = (new ArchiveRepository())->findChunk($chunkUid);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->jsonResponse([
|
||||
'code' => 500,
|
||||
'message' => 'Chunk lookup failed.',
|
||||
'errors' => ['chunk' => $exception->getMessage()],
|
||||
], 500);
|
||||
}
|
||||
|
||||
if ($chunk === null) {
|
||||
return $this->jsonResponse([
|
||||
'code' => 404,
|
||||
'message' => 'Chunk not found.',
|
||||
'errors' => ['chunk_uid' => $chunkUid],
|
||||
], 404);
|
||||
}
|
||||
|
||||
return $this->jsonResponse([
|
||||
'code' => 0,
|
||||
'message' => 'Chunk loaded.',
|
||||
'data' => $chunk,
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function archiveChunks(string $archiveUid): Response
|
||||
{
|
||||
try {
|
||||
$repository = new ArchiveRepository();
|
||||
$archive = $repository->findArchive($archiveUid);
|
||||
$chunks = $archive === null ? [] : $repository->findArchiveChunks($archiveUid);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->jsonResponse([
|
||||
'code' => 500,
|
||||
'message' => 'Archive chunks lookup failed.',
|
||||
'errors' => ['archive_chunks' => $exception->getMessage()],
|
||||
], 500);
|
||||
}
|
||||
|
||||
if ($archive === null) {
|
||||
return $this->jsonResponse([
|
||||
'code' => 404,
|
||||
'message' => 'Archive not found.',
|
||||
'errors' => ['archive_uid' => $archiveUid],
|
||||
], 404);
|
||||
}
|
||||
|
||||
return $this->jsonResponse([
|
||||
'code' => 0,
|
||||
'message' => 'Archive chunks loaded.',
|
||||
'data' => [
|
||||
'archive_uid' => $archive['archive_uid'],
|
||||
'title' => $archive['title'],
|
||||
'summary' => $archive['summary'],
|
||||
'source' => $archive['source'],
|
||||
'author' => $archive['author'],
|
||||
'year' => $archive['year'],
|
||||
'series' => $archive['series'],
|
||||
'tags' => $archive['tags'],
|
||||
'chunk_count' => count($chunks),
|
||||
'chunks' => $chunks,
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function evidence(string $chunkUid): Response
|
||||
{
|
||||
try {
|
||||
$chunk = (new ArchiveRepository())->findChunk($chunkUid);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->jsonResponse([
|
||||
'code' => 500,
|
||||
'message' => 'Evidence lookup failed.',
|
||||
'errors' => ['evidence' => $exception->getMessage()],
|
||||
], 500);
|
||||
}
|
||||
|
||||
if ($chunk === null) {
|
||||
return $this->jsonResponse([
|
||||
'code' => 404,
|
||||
'message' => 'Evidence not found.',
|
||||
'errors' => ['chunk_uid' => $chunkUid],
|
||||
], 404);
|
||||
}
|
||||
|
||||
$archive = $chunk['archive'];
|
||||
$pages = $chunk['pages'];
|
||||
$pageLabel = $this->pageLabel($pages);
|
||||
|
||||
return $this->jsonResponse([
|
||||
'code' => 0,
|
||||
'message' => 'Evidence loaded.',
|
||||
'data' => [
|
||||
'chunk_uid' => $chunk['chunk_uid'],
|
||||
'archive_uid' => $chunk['archive_uid'],
|
||||
'title' => $archive['title'] ?? null,
|
||||
'source' => $archive['source'] ?? null,
|
||||
'author' => $archive['author'] ?? null,
|
||||
'year' => $archive['year'] ?? null,
|
||||
'series' => $archive['series'] ?? null,
|
||||
'tags' => $archive['tags'] ?? [],
|
||||
'page_start' => $chunk['page_start'],
|
||||
'page_end' => $chunk['page_end'],
|
||||
'pages' => $pages,
|
||||
'page_label' => $pageLabel,
|
||||
'citation' => $this->citation($archive, $pageLabel),
|
||||
'quote' => $chunk['text'],
|
||||
'chunk' => [
|
||||
'chunk_index' => $chunk['chunk_index'],
|
||||
'length' => $chunk['length'],
|
||||
'embedding_model' => $chunk['embedding_model'],
|
||||
'embedding_status' => $chunk['embedding_status'],
|
||||
'search_index_status' => $chunk['search_index_status'],
|
||||
],
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function archiveEvidence(string $archiveUid): Response
|
||||
{
|
||||
try {
|
||||
$repository = new ArchiveRepository();
|
||||
$archive = $repository->findArchive($archiveUid);
|
||||
$chunks = $archive === null ? [] : $repository->findArchiveChunks($archiveUid);
|
||||
} catch (Throwable $exception) {
|
||||
return $this->jsonResponse([
|
||||
'code' => 500,
|
||||
'message' => 'Archive evidence lookup failed.',
|
||||
'errors' => ['archive_evidence' => $exception->getMessage()],
|
||||
], 500);
|
||||
}
|
||||
|
||||
if ($archive === null) {
|
||||
return $this->jsonResponse([
|
||||
'code' => 404,
|
||||
'message' => 'Archive not found.',
|
||||
'errors' => ['archive_uid' => $archiveUid],
|
||||
], 404);
|
||||
}
|
||||
|
||||
$evidence = array_map(function (array $chunk): array {
|
||||
$archive = $chunk['archive'];
|
||||
$pages = $chunk['pages'];
|
||||
$pageLabel = $this->pageLabel($pages);
|
||||
|
||||
return [
|
||||
'chunk_uid' => $chunk['chunk_uid'],
|
||||
'chunk_index' => $chunk['chunk_index'],
|
||||
'page_start' => $chunk['page_start'],
|
||||
'page_end' => $chunk['page_end'],
|
||||
'pages' => $pages,
|
||||
'page_label' => $pageLabel,
|
||||
'citation' => $this->citation($archive, $pageLabel),
|
||||
'quote' => $chunk['text'],
|
||||
'length' => $chunk['length'],
|
||||
'embedding_model' => $chunk['embedding_model'],
|
||||
'embedding_status' => $chunk['embedding_status'],
|
||||
'search_index_status' => $chunk['search_index_status'],
|
||||
];
|
||||
}, $chunks);
|
||||
|
||||
return $this->jsonResponse([
|
||||
'code' => 0,
|
||||
'message' => 'Archive evidence loaded.',
|
||||
'data' => [
|
||||
'archive_uid' => $archive['archive_uid'],
|
||||
'title' => $archive['title'],
|
||||
'summary' => $archive['summary'],
|
||||
'source' => $archive['source'],
|
||||
'author' => $archive['author'],
|
||||
'year' => $archive['year'],
|
||||
'series' => $archive['series'],
|
||||
'tags' => $archive['tags'],
|
||||
'chunk_count' => count($evidence),
|
||||
'evidence' => $evidence,
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
private function citation(array $archive, string $pageLabel): string
|
||||
{
|
||||
$parts = array_values(array_filter([
|
||||
$archive['title'] ?? null,
|
||||
$archive['author'] ?? null,
|
||||
isset($archive['year']) ? (string) $archive['year'] : null,
|
||||
$pageLabel === '' ? null : $pageLabel,
|
||||
$archive['source'] ?? null,
|
||||
], static fn ($value): bool => $value !== null && trim((string) $value) !== ''));
|
||||
|
||||
return implode(' | ', $parts);
|
||||
}
|
||||
|
||||
private function pageLabel(array $pages): string
|
||||
{
|
||||
if ($pages === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (count($pages) === 1) {
|
||||
return 'p. ' . (string) $pages[0];
|
||||
}
|
||||
|
||||
return 'pp. ' . (string) $pages[0] . '-' . (string) $pages[count($pages) - 1];
|
||||
}
|
||||
|
||||
private function jsonResponse(array $data, int $status): Response
|
||||
{
|
||||
return response(
|
||||
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
|
||||
$status,
|
||||
['Content-Type' => 'application/json']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace app\service;
|
||||
|
||||
use support\Request;
|
||||
|
||||
class AdminAuthService
|
||||
{
|
||||
private const SESSION_KEY = 'proofdb_admin_user_id';
|
||||
|
||||
public function __construct(private readonly ?AdminUserRepository $users = null)
|
||||
{
|
||||
}
|
||||
|
||||
public function authenticate(string $username, string $password): ?array
|
||||
{
|
||||
$username = trim($username);
|
||||
if ($username === '' || $password === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = $this->users()->findByUsername($username);
|
||||
if ($user === null || !password_verify($password, $user['password_hash'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
unset($user['password_hash']);
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function login(Request $request, array $user): void
|
||||
{
|
||||
$request->session()->set(self::SESSION_KEY, (int) $user['id']);
|
||||
$this->users()->touchLastLogin((int) $user['id']);
|
||||
}
|
||||
|
||||
public function logout(Request $request): void
|
||||
{
|
||||
$request->session()->delete(self::SESSION_KEY);
|
||||
}
|
||||
|
||||
public function current(Request $request): ?array
|
||||
{
|
||||
$id = (int) $request->session()->get(self::SESSION_KEY, 0);
|
||||
if ($id <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = $this->users()->findById($id);
|
||||
if ($user === null) {
|
||||
$request->session()->delete(self::SESSION_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
unset($user['password_hash']);
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function users(): AdminUserRepository
|
||||
{
|
||||
return $this->users ?? new AdminUserRepository();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\AdminConsole;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class AdminDocService
|
||||
{
|
||||
public function __construct(private readonly ?MarkdownRenderer $renderer = null)
|
||||
{
|
||||
}
|
||||
|
||||
public function list(): array
|
||||
{
|
||||
$items = [];
|
||||
foreach (glob(base_path('apidoc/*.md')) ?: [] as $path) {
|
||||
$name = basename($path);
|
||||
$content = (string) file_get_contents($path);
|
||||
$items[] = [
|
||||
'name' => $name,
|
||||
'title' => $this->title($content, $name),
|
||||
];
|
||||
}
|
||||
|
||||
usort($items, fn (array $a, array $b): int => strcmp($a['name'], $b['name']));
|
||||
return $items;
|
||||
}
|
||||
|
||||
public function read(string $name): array
|
||||
{
|
||||
$safeName = basename($name);
|
||||
$path = base_path('apidoc/' . $safeName);
|
||||
if (!is_file($path) || pathinfo($path, PATHINFO_EXTENSION) !== 'md') {
|
||||
throw new RuntimeException('API doc not found.');
|
||||
}
|
||||
|
||||
$content = (string) file_get_contents($path);
|
||||
return [
|
||||
'name' => $safeName,
|
||||
'title' => $this->title($content, $safeName),
|
||||
'content' => $content,
|
||||
'html' => $this->renderer()->render($content),
|
||||
];
|
||||
}
|
||||
|
||||
public function readScriptDoc(string $name): array
|
||||
{
|
||||
$safeName = basename($name);
|
||||
$path = base_path('scriptdoc/' . $safeName);
|
||||
if (!is_file($path) || pathinfo($path, PATHINFO_EXTENSION) !== 'md') {
|
||||
throw new RuntimeException('Script doc not found.');
|
||||
}
|
||||
|
||||
$content = (string) file_get_contents($path);
|
||||
return [
|
||||
'name' => $safeName,
|
||||
'title' => $this->title($content, $safeName),
|
||||
'content' => $content,
|
||||
'html' => $this->renderer()->render($content),
|
||||
];
|
||||
}
|
||||
|
||||
private function title(string $content, string $fallback): string
|
||||
{
|
||||
if (preg_match('/^#\s+(.+)$/m', $content, $matches)) {
|
||||
return trim($matches[1]);
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
private function renderer(): MarkdownRenderer
|
||||
{
|
||||
return $this->renderer ?? new MarkdownRenderer();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\AdminConsole;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use support\Db;
|
||||
|
||||
class ArchiveAdminService
|
||||
{
|
||||
public function list(string $query = '', int $page = 1, int $pageSize = 20): array
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$pageSize = min(100, max(1, $pageSize));
|
||||
|
||||
$builder = Db::table('archives');
|
||||
$query = trim($query);
|
||||
if ($query !== '') {
|
||||
$like = '%' . $query . '%';
|
||||
$builder->where(function ($subQuery) use ($like): void {
|
||||
$subQuery
|
||||
->orWhere('archive_uid', 'like', $like)
|
||||
->orWhere('title', 'like', $like)
|
||||
->orWhere('summary', 'like', $like)
|
||||
->orWhere('author', 'like', $like)
|
||||
->orWhere('source', 'like', $like)
|
||||
->orWhere('series', 'like', $like);
|
||||
});
|
||||
}
|
||||
|
||||
$total = (clone $builder)->count();
|
||||
$rows = $builder
|
||||
->orderByDesc('updated_time')
|
||||
->offset(($page - 1) * $pageSize)
|
||||
->limit($pageSize)
|
||||
->get([
|
||||
'archive_uid',
|
||||
'title',
|
||||
'summary',
|
||||
'year',
|
||||
'author',
|
||||
'source',
|
||||
'series',
|
||||
'tags',
|
||||
'created_time',
|
||||
'updated_time',
|
||||
Db::raw('jsonb_array_length(chunks) as chunk_count'),
|
||||
])
|
||||
->all();
|
||||
|
||||
return [
|
||||
'items' => array_map(fn (object $row): array => $this->listItem($row), $rows),
|
||||
'total' => (int) $total,
|
||||
'page' => $page,
|
||||
'page_size' => $pageSize,
|
||||
];
|
||||
}
|
||||
|
||||
public function detail(string $archiveUid): ?array
|
||||
{
|
||||
$row = Db::table('archives')->where('archive_uid', $archiveUid)->first();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->detailItem($row);
|
||||
}
|
||||
|
||||
public function update(string $archiveUid, array $payload): ?array
|
||||
{
|
||||
if (!$this->detail($archiveUid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
foreach (['title', 'summary', 'author', 'source', 'series', 'content', 'raw'] as $field) {
|
||||
if (array_key_exists($field, $payload)) {
|
||||
$updates[$field] = $this->nullableText($payload[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('year', $payload)) {
|
||||
$year = trim((string) ($payload['year'] ?? ''));
|
||||
if ($year === '') {
|
||||
$updates['year'] = null;
|
||||
} elseif (!preg_match('/^\d{1,4}$/', $year)) {
|
||||
throw new InvalidArgumentException('year must be empty or a 1-4 digit number.');
|
||||
} else {
|
||||
$updates['year'] = (int) $year;
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('tags', $payload)) {
|
||||
$updates['tags'] = json_encode($this->normalizeTags($payload['tags']), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
if (array_key_exists('metadata', $payload)) {
|
||||
$updates['metadata'] = json_encode($this->normalizeMetadata($payload['metadata']), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
if ($updates !== []) {
|
||||
Db::table('archives')->where('archive_uid', $archiveUid)->update($updates);
|
||||
}
|
||||
|
||||
return $this->detail($archiveUid);
|
||||
}
|
||||
|
||||
public function delete(string $archiveUid): bool
|
||||
{
|
||||
return (int) Db::table('archives')->where('archive_uid', $archiveUid)->delete() > 0;
|
||||
}
|
||||
|
||||
private function listItem(object $row): array
|
||||
{
|
||||
$chunks = $this->decodeJson($row->chunks ?? null, []);
|
||||
|
||||
return [
|
||||
'archive_uid' => (string) $row->archive_uid,
|
||||
'title' => $row->title,
|
||||
'summary' => $row->summary,
|
||||
'year' => $row->year === null ? null : (int) $row->year,
|
||||
'author' => $row->author,
|
||||
'source' => $row->source,
|
||||
'series' => $row->series,
|
||||
'tags' => $this->decodeJson($row->tags ?? null, []),
|
||||
'chunk_count' => property_exists($row, 'chunk_count')
|
||||
? ($row->chunk_count === null ? 0 : (int) $row->chunk_count)
|
||||
: count(is_array($chunks) ? $chunks : []),
|
||||
'created_time' => $row->created_time,
|
||||
'updated_time' => $row->updated_time,
|
||||
];
|
||||
}
|
||||
|
||||
private function detailItem(object $row): array
|
||||
{
|
||||
$data = $this->listItem($row);
|
||||
$data['metadata'] = $this->decodeJson($row->metadata ?? null, []);
|
||||
$data['content'] = $row->content;
|
||||
$data['raw'] = $row->raw;
|
||||
$data['chunks'] = $this->decodeJson($row->chunks ?? null, []);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function normalizeTags(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$items = $value;
|
||||
} else {
|
||||
$text = trim((string) $value);
|
||||
if ($text === '') {
|
||||
return [];
|
||||
}
|
||||
$items = preg_split('/[\r\n,]+/', $text) ?: [];
|
||||
}
|
||||
|
||||
$tags = [];
|
||||
foreach ($items as $item) {
|
||||
$tag = trim((string) $item);
|
||||
if ($tag !== '') {
|
||||
$tags[] = $tag;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($tags));
|
||||
}
|
||||
|
||||
private function normalizeMetadata(mixed $value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$text = trim((string) $value);
|
||||
if ($text === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($text, true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new InvalidArgumentException('metadata must be a JSON object or array.');
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function nullableText(mixed $value): ?string
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
return $text === '' ? null : $text;
|
||||
}
|
||||
|
||||
private function decodeJson(mixed $value, mixed $fallback): mixed
|
||||
{
|
||||
if ($value === null) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $value, true);
|
||||
return $decoded === null && json_last_error() !== JSON_ERROR_NONE ? $fallback : $decoded;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\AdminConsole;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class MaintenanceScriptService
|
||||
{
|
||||
private const ARG_PATTERN = '/^--[A-Za-z0-9_]+(?:=[A-Za-z0-9._:@\/-]+)?$/';
|
||||
|
||||
public function list(): array
|
||||
{
|
||||
$docs = new AdminDocService();
|
||||
$items = [];
|
||||
foreach ($this->definitions() as $definition) {
|
||||
$item = $definition;
|
||||
try {
|
||||
$doc = $docs->readScriptDoc($definition['doc_name']);
|
||||
$item['doc_title'] = $doc['title'];
|
||||
$item['doc_html'] = $doc['html'];
|
||||
$item['doc_content'] = $doc['content'];
|
||||
} catch (RuntimeException) {
|
||||
$item['doc_title'] = null;
|
||||
$item['doc_html'] = null;
|
||||
$item['doc_content'] = null;
|
||||
}
|
||||
$items[] = $item;
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
public function describe(string $name): array
|
||||
{
|
||||
$definitions = $this->definitions();
|
||||
if (!isset($definitions[$name])) {
|
||||
throw new RuntimeException('Script is not allowed.');
|
||||
}
|
||||
|
||||
foreach ($this->list() as $item) {
|
||||
if ($item['name'] === $name) {
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException('Script metadata not found.');
|
||||
}
|
||||
|
||||
public function run(string $name, array $args = []): array
|
||||
{
|
||||
$definitions = $this->definitions();
|
||||
if (!isset($definitions[$name])) {
|
||||
throw new RuntimeException('Script is not allowed.');
|
||||
}
|
||||
|
||||
$script = $definitions[$name];
|
||||
$scriptPath = base_path('scripts/' . $script['file']);
|
||||
if (!is_file($scriptPath)) {
|
||||
throw new RuntimeException('Script file not found.');
|
||||
}
|
||||
|
||||
$safeArgs = [];
|
||||
foreach ($args as $arg) {
|
||||
$arg = trim((string) $arg);
|
||||
if ($arg === '') {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match(self::ARG_PATTERN, $arg)) {
|
||||
throw new RuntimeException('Only --key=value style arguments are allowed.');
|
||||
}
|
||||
$safeArgs[] = $arg;
|
||||
}
|
||||
|
||||
$command = array_merge([PHP_BINARY, $scriptPath], $safeArgs);
|
||||
$descriptors = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
|
||||
$process = proc_open($command, $descriptors, $pipes, base_path());
|
||||
if (!is_resource($process)) {
|
||||
throw new RuntimeException('Failed to start script process.');
|
||||
}
|
||||
|
||||
fclose($pipes[0]);
|
||||
$stdout = (string) stream_get_contents($pipes[1]);
|
||||
fclose($pipes[1]);
|
||||
$stderr = (string) stream_get_contents($pipes[2]);
|
||||
fclose($pipes[2]);
|
||||
$exitCode = proc_close($process);
|
||||
|
||||
return [
|
||||
'script_name' => $name,
|
||||
'command' => array_merge(['php', 'scripts/' . $script['file']], $safeArgs),
|
||||
'exit_code' => $exitCode,
|
||||
'stdout' => $stdout,
|
||||
'stderr' => $stderr,
|
||||
'ok' => $exitCode === 0,
|
||||
];
|
||||
}
|
||||
|
||||
private function definitions(): array
|
||||
{
|
||||
return [
|
||||
'setup_database' => [
|
||||
'name' => 'setup_database',
|
||||
'file' => 'setup_database.php',
|
||||
'label' => '初始化数据库',
|
||||
'description' => '创建或补齐 archives、chunks 相关表结构与索引。',
|
||||
'doc_name' => 'setup_database.md',
|
||||
'args_hint' => '无参数',
|
||||
],
|
||||
'setup_opensearch' => [
|
||||
'name' => 'setup_opensearch',
|
||||
'file' => 'setup_opensearch.php',
|
||||
'label' => '初始化 OpenSearch',
|
||||
'description' => '创建或补齐 proofdb_chunks 索引与 mapping。',
|
||||
'doc_name' => 'setup_opensearch.md',
|
||||
'args_hint' => '无参数',
|
||||
],
|
||||
'reindex_opensearch' => [
|
||||
'name' => 'reindex_opensearch',
|
||||
'file' => 'reindex_opensearch.php',
|
||||
'label' => '重建 OpenSearch 索引',
|
||||
'description' => '把 PostgreSQL 中已向量化的数据重新写入 OpenSearch。',
|
||||
'doc_name' => 'reindex_opensearch.md',
|
||||
'args_hint' => '--archive_uid=01...',
|
||||
],
|
||||
'backfill_archive_content' => [
|
||||
'name' => 'backfill_archive_content',
|
||||
'file' => 'backfill_archive_content.php',
|
||||
'label' => '回填 archive content',
|
||||
'description' => '从 raw 或 chunks 回填 archives.content。',
|
||||
'doc_name' => 'backfill_archive_content.md',
|
||||
'args_hint' => '--archive_uid=01...',
|
||||
],
|
||||
'setup_admin_users' => [
|
||||
'name' => 'setup_admin_users',
|
||||
'file' => 'setup_admin_users.php',
|
||||
'label' => '初始化管理员用户',
|
||||
'description' => '创建 admin_users 表并写入或更新管理员账号。',
|
||||
'doc_name' => 'setup_admin_users.md',
|
||||
'args_hint' => '--username=admin --password=secret',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\AdminConsole;
|
||||
|
||||
class MarkdownRenderer
|
||||
{
|
||||
public function render(string $markdown): string
|
||||
{
|
||||
$lines = preg_split('/\r\n|\n|\r/', $markdown) ?: [];
|
||||
$html = [];
|
||||
$paragraph = [];
|
||||
$listType = null;
|
||||
$table = null;
|
||||
$inCodeBlock = false;
|
||||
$codeLines = [];
|
||||
|
||||
$flushParagraph = function () use (&$paragraph, &$html): void {
|
||||
if ($paragraph === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$text = implode(' ', array_map('trim', $paragraph));
|
||||
$html[] = '<p>' . $this->renderInline($text) . '</p>';
|
||||
$paragraph = [];
|
||||
};
|
||||
|
||||
$flushList = function () use (&$listType, &$html): void {
|
||||
if ($listType !== null) {
|
||||
$html[] = '</' . $listType . '>';
|
||||
$listType = null;
|
||||
}
|
||||
};
|
||||
|
||||
$flushTable = function () use (&$table, &$html): void {
|
||||
if ($table === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$html[] = '<table class="markdown-table"><thead><tr>' .
|
||||
implode('', array_map(fn (string $cell): string => '<th>' . $this->renderInline($cell) . '</th>', $table['headers'])) .
|
||||
'</tr></thead><tbody>';
|
||||
foreach ($table['rows'] as $row) {
|
||||
$html[] = '<tr>' .
|
||||
implode('', array_map(fn (string $cell): string => '<td>' . $this->renderInline($cell) . '</td>', $row)) .
|
||||
'</tr>';
|
||||
}
|
||||
$html[] = '</tbody></table>';
|
||||
$table = null;
|
||||
};
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^```/', $line)) {
|
||||
$flushParagraph();
|
||||
$flushList();
|
||||
$flushTable();
|
||||
|
||||
if ($inCodeBlock) {
|
||||
$html[] = '<pre class="markdown-pre"><code>' . htmlspecialchars(implode("\n", $codeLines), ENT_QUOTES, 'UTF-8') . '</code></pre>';
|
||||
$codeLines = [];
|
||||
$inCodeBlock = false;
|
||||
} else {
|
||||
$inCodeBlock = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($inCodeBlock) {
|
||||
$codeLines[] = $line;
|
||||
continue;
|
||||
}
|
||||
|
||||
$trimmed = trim($line);
|
||||
if ($trimmed === '') {
|
||||
$flushParagraph();
|
||||
$flushList();
|
||||
$flushTable();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^(#{1,6})\s+(.+)$/', $trimmed, $matches)) {
|
||||
$flushParagraph();
|
||||
$flushList();
|
||||
$flushTable();
|
||||
$level = strlen($matches[1]);
|
||||
$html[] = sprintf('<h%d>%s</h%d>', $level, $this->renderInline($matches[2]), $level);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^>\s?(.+)$/', $trimmed, $matches)) {
|
||||
$flushParagraph();
|
||||
$flushList();
|
||||
$flushTable();
|
||||
$html[] = '<blockquote>' . $this->renderInline($matches[1]) . '</blockquote>';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^---+$/', $trimmed)) {
|
||||
$flushParagraph();
|
||||
$flushList();
|
||||
$flushTable();
|
||||
$html[] = '<hr>';
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->isTableDelimiter($trimmed) && $table !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_contains($trimmed, '|')) {
|
||||
$cells = $this->tableCells($trimmed);
|
||||
if (count($cells) >= 2) {
|
||||
$flushParagraph();
|
||||
$flushList();
|
||||
if ($table === null) {
|
||||
$table = ['headers' => $cells, 'rows' => []];
|
||||
} else {
|
||||
$table['rows'][] = $cells;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('/^[-*]\s+(.+)$/', $trimmed, $matches)) {
|
||||
$flushParagraph();
|
||||
$flushTable();
|
||||
if ($listType !== 'ul') {
|
||||
$flushList();
|
||||
$listType = 'ul';
|
||||
$html[] = '<ul>';
|
||||
}
|
||||
$html[] = '<li>' . $this->renderInline($matches[1]) . '</li>';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d+\.\s+(.+)$/', $trimmed, $matches)) {
|
||||
$flushParagraph();
|
||||
$flushTable();
|
||||
if ($listType !== 'ol') {
|
||||
$flushList();
|
||||
$listType = 'ol';
|
||||
$html[] = '<ol>';
|
||||
}
|
||||
$html[] = '<li>' . $this->renderInline($matches[1]) . '</li>';
|
||||
continue;
|
||||
}
|
||||
|
||||
$flushList();
|
||||
$flushTable();
|
||||
$paragraph[] = $trimmed;
|
||||
}
|
||||
|
||||
if ($inCodeBlock) {
|
||||
$html[] = '<pre class="markdown-pre"><code>' . htmlspecialchars(implode("\n", $codeLines), ENT_QUOTES, 'UTF-8') . '</code></pre>';
|
||||
}
|
||||
|
||||
$flushParagraph();
|
||||
$flushList();
|
||||
$flushTable();
|
||||
|
||||
return implode("\n", $html);
|
||||
}
|
||||
|
||||
private function renderInline(string $text): string
|
||||
{
|
||||
$text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
|
||||
$text = preg_replace('/`([^`]+)`/', '<code>$1</code>', $text) ?? $text;
|
||||
$text = preg_replace('/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $text) ?? $text;
|
||||
$text = preg_replace('/\*([^*]+)\*/', '<em>$1</em>', $text) ?? $text;
|
||||
$text = preg_replace('/\[(.+?)\]\((.+?)\)/', '<a href="$2" target="_blank" rel="noreferrer">$1</a>', $text) ?? $text;
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
private function isTableDelimiter(string $line): bool
|
||||
{
|
||||
return (bool) preg_match('/^\|?[\s:-]+\|[\s|:-]*$/', $line);
|
||||
}
|
||||
|
||||
private function tableCells(string $line): array
|
||||
{
|
||||
$line = trim($line);
|
||||
$line = trim($line, '|');
|
||||
return array_map(static fn (string $cell): string => trim($cell), explode('|', $line));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\AdminConsole;
|
||||
|
||||
use app\service\Search\OpenSearchClientFactory;
|
||||
use support\Db;
|
||||
use Throwable;
|
||||
|
||||
class OpenSearchAdminService
|
||||
{
|
||||
public function status(): array
|
||||
{
|
||||
$config = config('opensearch.default', []);
|
||||
$indexName = config('opensearch.indices.chunks', 'proofdb_chunks');
|
||||
|
||||
$status = [
|
||||
'config' => [
|
||||
'hosts' => $config['hosts'] ?? [],
|
||||
'ssl_verify' => (bool) ($config['ssl_verify'] ?? true),
|
||||
'index_name' => $indexName,
|
||||
],
|
||||
'database' => [
|
||||
'archives_total' => (int) Db::table('archives')->count(),
|
||||
'chunks_total' => (int) Db::table('chunks')->count(),
|
||||
'embedded_chunks' => (int) Db::table('chunks')->where('embedding_status', 3)->count(),
|
||||
'indexed_chunks' => (int) Db::table('chunks')->where('search_index_status', 3)->count(),
|
||||
],
|
||||
'opensearch' => [
|
||||
'reachable' => false,
|
||||
'index_exists' => false,
|
||||
'cluster_name' => null,
|
||||
'health' => null,
|
||||
'docs_count' => 0,
|
||||
'mapping_fields' => [],
|
||||
'error' => null,
|
||||
],
|
||||
];
|
||||
|
||||
try {
|
||||
$client = (new OpenSearchClientFactory())->make();
|
||||
$health = $client->cluster()->health();
|
||||
$status['opensearch']['reachable'] = true;
|
||||
$status['opensearch']['cluster_name'] = $health['cluster_name'] ?? null;
|
||||
$status['opensearch']['health'] = $health['status'] ?? null;
|
||||
|
||||
$exists = (bool) $client->indices()->exists(['index' => $indexName]);
|
||||
$status['opensearch']['index_exists'] = $exists;
|
||||
|
||||
if ($exists) {
|
||||
$stats = $client->indices()->stats(['index' => $indexName]);
|
||||
$mapping = $client->indices()->getMapping(['index' => $indexName]);
|
||||
$status['opensearch']['docs_count'] = (int) (($stats['_all']['primaries']['docs']['count'] ?? 0));
|
||||
$status['opensearch']['mapping_fields'] = array_keys($mapping[$indexName]['mappings']['properties'] ?? []);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$status['opensearch']['error'] = $exception->getMessage();
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
public function documents(string $query = '', int $size = 20): array
|
||||
{
|
||||
$size = min(50, max(1, $size));
|
||||
$indexName = config('opensearch.indices.chunks', 'proofdb_chunks');
|
||||
$client = (new OpenSearchClientFactory())->make();
|
||||
|
||||
if (!(bool) $client->indices()->exists(['index' => $indexName])) {
|
||||
return [
|
||||
'index_name' => $indexName,
|
||||
'items' => [],
|
||||
'total' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$body = [
|
||||
'_source' => [
|
||||
'includes' => [
|
||||
'chunk_uid',
|
||||
'archive_uid',
|
||||
'chunk_index',
|
||||
'page_start',
|
||||
'page_end',
|
||||
'title',
|
||||
'summary',
|
||||
'source',
|
||||
'author',
|
||||
'year',
|
||||
'series',
|
||||
'tags',
|
||||
'text',
|
||||
'embedding_model',
|
||||
'embedding_dimensions',
|
||||
'created_time',
|
||||
'updated_time',
|
||||
],
|
||||
],
|
||||
'size' => $size,
|
||||
'sort' => [
|
||||
['updated_time' => ['order' => 'desc']],
|
||||
],
|
||||
];
|
||||
|
||||
$query = trim($query);
|
||||
if ($query === '') {
|
||||
$body['query'] = ['match_all' => (object) []];
|
||||
} else {
|
||||
$body['query'] = [
|
||||
'multi_match' => [
|
||||
'query' => $query,
|
||||
'fields' => ['text^3', 'title^2', 'summary^2', 'source', 'author', 'tags'],
|
||||
'type' => 'best_fields',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$response = $client->search([
|
||||
'index' => $indexName,
|
||||
'body' => $body,
|
||||
]);
|
||||
|
||||
$hits = $response['hits']['hits'] ?? [];
|
||||
|
||||
return [
|
||||
'index_name' => $indexName,
|
||||
'total' => (int) (($response['hits']['total']['value'] ?? 0)),
|
||||
'items' => array_map(function (array $hit): array {
|
||||
$source = $hit['_source'] ?? [];
|
||||
$text = trim((string) ($source['text'] ?? ''));
|
||||
return [
|
||||
'score' => $hit['_score'] ?? null,
|
||||
'chunk_uid' => $source['chunk_uid'] ?? ($hit['_id'] ?? null),
|
||||
'archive_uid' => $source['archive_uid'] ?? null,
|
||||
'chunk_index' => $source['chunk_index'] ?? null,
|
||||
'page_start' => $source['page_start'] ?? null,
|
||||
'page_end' => $source['page_end'] ?? null,
|
||||
'title' => $source['title'] ?? null,
|
||||
'summary' => $source['summary'] ?? null,
|
||||
'source' => $source['source'] ?? null,
|
||||
'author' => $source['author'] ?? null,
|
||||
'year' => $source['year'] ?? null,
|
||||
'series' => $source['series'] ?? null,
|
||||
'tags' => $source['tags'] ?? [],
|
||||
'text_preview' => mb_substr($text, 0, 320),
|
||||
'embedding_model' => $source['embedding_model'] ?? null,
|
||||
'embedding_dimensions' => $source['embedding_dimensions'] ?? null,
|
||||
'created_time' => $source['created_time'] ?? null,
|
||||
'updated_time' => $source['updated_time'] ?? null,
|
||||
];
|
||||
}, $hits),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace app\service;
|
||||
|
||||
use support\Db;
|
||||
|
||||
class AdminUserRepository
|
||||
{
|
||||
public function listAll(): array
|
||||
{
|
||||
$rows = Db::table('admin_users')
|
||||
->orderByDesc('id')
|
||||
->get()
|
||||
->all();
|
||||
|
||||
return array_map(fn (object $row): array => $this->toArray($row), $rows);
|
||||
}
|
||||
|
||||
public function findByUsername(string $username): ?array
|
||||
{
|
||||
$row = Db::table('admin_users')
|
||||
->where('username', $username)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
return $row ? $this->toArray($row) : null;
|
||||
}
|
||||
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
$row = Db::table('admin_users')
|
||||
->where('id', $id)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
return $row ? $this->toArray($row) : null;
|
||||
}
|
||||
|
||||
public function findAnyById(int $id): ?array
|
||||
{
|
||||
$row = Db::table('admin_users')->where('id', $id)->first();
|
||||
return $row ? $this->toArray($row) : null;
|
||||
}
|
||||
|
||||
public function findAnyByUsername(string $username): ?array
|
||||
{
|
||||
$row = Db::table('admin_users')->where('username', $username)->first();
|
||||
return $row ? $this->toArray($row) : null;
|
||||
}
|
||||
|
||||
public function touchLastLogin(int $id): void
|
||||
{
|
||||
Db::table('admin_users')
|
||||
->where('id', $id)
|
||||
->update(['last_login_at' => Db::raw('CURRENT_TIMESTAMP')]);
|
||||
}
|
||||
|
||||
public function create(string $username, string $password, ?string $displayName = null): array
|
||||
{
|
||||
$id = Db::table('admin_users')->insertGetId([
|
||||
'username' => $username,
|
||||
'display_name' => $displayName,
|
||||
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
|
||||
'is_active' => true,
|
||||
'last_login_at' => null,
|
||||
]);
|
||||
|
||||
return $this->findAnyById((int) $id) ?? [];
|
||||
}
|
||||
|
||||
public function updateUser(int $id, array $fields): ?array
|
||||
{
|
||||
$updates = [];
|
||||
|
||||
if (array_key_exists('display_name', $fields)) {
|
||||
$displayName = $fields['display_name'];
|
||||
$updates['display_name'] = $displayName === null ? null : trim((string) $displayName);
|
||||
}
|
||||
|
||||
if (array_key_exists('password', $fields) && trim((string) $fields['password']) !== '') {
|
||||
$updates['password_hash'] = password_hash((string) $fields['password'], PASSWORD_DEFAULT);
|
||||
}
|
||||
|
||||
if (array_key_exists('is_active', $fields)) {
|
||||
$updates['is_active'] = (bool) $fields['is_active'];
|
||||
}
|
||||
|
||||
if ($updates !== []) {
|
||||
Db::table('admin_users')->where('id', $id)->update($updates);
|
||||
}
|
||||
|
||||
return $this->findAnyById($id);
|
||||
}
|
||||
|
||||
private function toArray(object $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $row->id,
|
||||
'username' => (string) $row->username,
|
||||
'display_name' => $row->display_name,
|
||||
'password_hash' => (string) $row->password_hash,
|
||||
'is_active' => (bool) $row->is_active,
|
||||
'last_login_at' => $row->last_login_at,
|
||||
'created_time' => $row->created_time,
|
||||
'updated_time' => $row->updated_time,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,102 @@ class ArchiveRepository
|
||||
return implode("\n\n", array_map(fn ($chunk): string => (string) $chunk->text, $chunks));
|
||||
}
|
||||
|
||||
public function findChunk(string $chunkUid): ?array
|
||||
{
|
||||
$row = Db::table('chunks')
|
||||
->join('archives', 'chunks.archive_uid', '=', 'archives.archive_uid')
|
||||
->where('chunks.chunk_uid', $chunkUid)
|
||||
->first([
|
||||
'chunks.chunk_uid',
|
||||
'chunks.archive_uid',
|
||||
'chunks.chunk_index',
|
||||
'chunks.page_start',
|
||||
'chunks.page_end',
|
||||
'chunks.text',
|
||||
'chunks.length',
|
||||
'chunks.embedding_status',
|
||||
'chunks.embedding_ref',
|
||||
'chunks.embedding_model',
|
||||
'chunks.embedding_error',
|
||||
'chunks.search_index_status',
|
||||
'chunks.search_index_error',
|
||||
'archives.title',
|
||||
'archives.summary',
|
||||
'archives.year',
|
||||
'archives.author',
|
||||
'archives.source',
|
||||
'archives.series',
|
||||
'archives.tags',
|
||||
'archives.metadata',
|
||||
]);
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'chunk_uid' => (string) $row->chunk_uid,
|
||||
'archive_uid' => (string) $row->archive_uid,
|
||||
'chunk_index' => (int) $row->chunk_index,
|
||||
'page_start' => $row->page_start === null ? null : (int) $row->page_start,
|
||||
'page_end' => $row->page_end === null ? null : (int) $row->page_end,
|
||||
'pages' => $this->pages($row->page_start, $row->page_end),
|
||||
'text' => (string) $row->text,
|
||||
'length' => $row->length === null ? null : (int) $row->length,
|
||||
'embedding_status' => (int) $row->embedding_status,
|
||||
'embedding_ref' => $this->decodeJson($row->embedding_ref ?? null, null),
|
||||
'embedding_model' => $row->embedding_model,
|
||||
'embedding_error' => $row->embedding_error,
|
||||
'search_index_status' => (int) $row->search_index_status,
|
||||
'search_index_error' => $row->search_index_error,
|
||||
'archive' => [
|
||||
'archive_uid' => (string) $row->archive_uid,
|
||||
'title' => $row->title,
|
||||
'summary' => $row->summary,
|
||||
'year' => $row->year === null ? null : (int) $row->year,
|
||||
'author' => $row->author,
|
||||
'source' => $row->source,
|
||||
'series' => $row->series,
|
||||
'tags' => $this->decodeJson($row->tags ?? null, []),
|
||||
'metadata' => $this->decodeJson($row->metadata ?? null, []),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function findArchiveChunks(string $archiveUid): array
|
||||
{
|
||||
$rows = Db::table('chunks')
|
||||
->join('archives', 'chunks.archive_uid', '=', 'archives.archive_uid')
|
||||
->where('chunks.archive_uid', $archiveUid)
|
||||
->orderBy('chunks.chunk_index')
|
||||
->get([
|
||||
'chunks.chunk_uid',
|
||||
'chunks.archive_uid',
|
||||
'chunks.chunk_index',
|
||||
'chunks.page_start',
|
||||
'chunks.page_end',
|
||||
'chunks.text',
|
||||
'chunks.length',
|
||||
'chunks.embedding_status',
|
||||
'chunks.embedding_ref',
|
||||
'chunks.embedding_model',
|
||||
'chunks.embedding_error',
|
||||
'chunks.search_index_status',
|
||||
'chunks.search_index_error',
|
||||
'archives.title',
|
||||
'archives.summary',
|
||||
'archives.year',
|
||||
'archives.author',
|
||||
'archives.source',
|
||||
'archives.series',
|
||||
'archives.tags',
|
||||
'archives.metadata',
|
||||
])
|
||||
->all();
|
||||
|
||||
return array_map(fn (object $row): array => $this->chunkRowToArray($row), $rows);
|
||||
}
|
||||
|
||||
public function updateMetadata(string $archiveUid, array $fields, array $aiMeta): void
|
||||
{
|
||||
$archive = $this->findArchive($archiveUid);
|
||||
@@ -136,4 +232,68 @@ class ArchiveRepository
|
||||
'chunks' => json_decode($archive->chunks ?? '[]', true) ?: [],
|
||||
];
|
||||
}
|
||||
|
||||
private function chunkRowToArray(object $row): array
|
||||
{
|
||||
return [
|
||||
'chunk_uid' => (string) $row->chunk_uid,
|
||||
'archive_uid' => (string) $row->archive_uid,
|
||||
'chunk_index' => (int) $row->chunk_index,
|
||||
'page_start' => $row->page_start === null ? null : (int) $row->page_start,
|
||||
'page_end' => $row->page_end === null ? null : (int) $row->page_end,
|
||||
'pages' => $this->pages($row->page_start, $row->page_end),
|
||||
'text' => (string) $row->text,
|
||||
'length' => $row->length === null ? null : (int) $row->length,
|
||||
'embedding_status' => (int) $row->embedding_status,
|
||||
'embedding_ref' => $this->decodeJson($row->embedding_ref ?? null, null),
|
||||
'embedding_model' => $row->embedding_model,
|
||||
'embedding_error' => $row->embedding_error,
|
||||
'search_index_status' => (int) $row->search_index_status,
|
||||
'search_index_error' => $row->search_index_error,
|
||||
'archive' => [
|
||||
'archive_uid' => (string) $row->archive_uid,
|
||||
'title' => $row->title,
|
||||
'summary' => $row->summary,
|
||||
'year' => $row->year === null ? null : (int) $row->year,
|
||||
'author' => $row->author,
|
||||
'source' => $row->source,
|
||||
'series' => $row->series,
|
||||
'tags' => $this->decodeJson($row->tags ?? null, []),
|
||||
'metadata' => $this->decodeJson($row->metadata ?? null, []),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function decodeJson(mixed $value, mixed $fallback): mixed
|
||||
{
|
||||
if ($value === null) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (!is_string($value) || trim($value) === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$decoded = json_decode($value, true);
|
||||
return $decoded === null && json_last_error() !== JSON_ERROR_NONE ? $fallback : $decoded;
|
||||
}
|
||||
|
||||
private function pages(mixed $pageStart, mixed $pageEnd): array
|
||||
{
|
||||
if (!is_numeric($pageStart) || !is_numeric($pageEnd)) {
|
||||
return array_values(array_filter([$pageStart, $pageEnd], static fn ($value): bool => $value !== null && $value !== ''));
|
||||
}
|
||||
|
||||
$start = (int) $pageStart;
|
||||
$end = (int) $pageEnd;
|
||||
if ($end < $start) {
|
||||
$end = $start;
|
||||
}
|
||||
|
||||
return range($start, $end);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,16 @@ class ArticleImportService
|
||||
}
|
||||
}
|
||||
|
||||
public function normalizeArchiveContentString(string $content): ?string
|
||||
{
|
||||
return $this->nullableClean($this->cleanMarkdownPage($content));
|
||||
}
|
||||
|
||||
public function normalizeArchiveRawString(string $content): ?string
|
||||
{
|
||||
return $this->nullableClean($content);
|
||||
}
|
||||
|
||||
private function validate(array $payload): array
|
||||
{
|
||||
$errors = [];
|
||||
@@ -182,8 +192,8 @@ class ArticleImportService
|
||||
'tags' => is_array($payload['tags'] ?? null) ? array_values($payload['tags']) : [],
|
||||
'summary' => $this->nullableClean($payload['summary'] ?? null),
|
||||
'metadata' => $payload['metadata'] ?? [],
|
||||
'content' => $this->nullableClean($payload['content_url'] ?? $payload['content_path'] ?? null),
|
||||
'raw' => $this->nullableClean($payload['raw_url'] ?? $payload['raw_path'] ?? null),
|
||||
'content' => $this->normalizedArchiveContent($payload),
|
||||
'raw' => $this->rawArchiveContent($payload),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -200,6 +210,57 @@ class ArticleImportService
|
||||
return $this->pageBlocksFromItems($payload, preg_split('/\R{2,}/u', $payload['content']));
|
||||
}
|
||||
|
||||
private function normalizedArchiveContent(array $payload): ?string
|
||||
{
|
||||
if (isset($payload['pages']) && is_array($payload['pages'])) {
|
||||
$parts = [];
|
||||
foreach ($payload['pages'] as $page) {
|
||||
if (!is_array($page) || !isset($page['content']) || !is_string($page['content'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = $this->cleanMarkdownPage($page['content']);
|
||||
if ($content !== '') {
|
||||
$parts[] = $content;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->nullableClean(implode("\n\n", $parts));
|
||||
}
|
||||
|
||||
if (isset($payload['paragraphs']) && is_array($payload['paragraphs'])) {
|
||||
$parts = [];
|
||||
foreach ($payload['paragraphs'] as $paragraph) {
|
||||
$content = is_array($paragraph) ? ($paragraph['content'] ?? '') : $paragraph;
|
||||
if (!is_string($content)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = $this->clean($content);
|
||||
if ($content !== '') {
|
||||
$parts[] = $content;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->nullableClean(implode("\n\n", $parts));
|
||||
}
|
||||
|
||||
if (isset($payload['content']) && is_string($payload['content'])) {
|
||||
return $this->normalizeArchiveContentString($payload['content']);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function rawArchiveContent(array $payload): ?string
|
||||
{
|
||||
if (isset($payload['content']) && is_string($payload['content'])) {
|
||||
return $this->normalizeArchiveRawString($payload['content']);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function pageBlocksFromPages(array $payload): array
|
||||
{
|
||||
$pageBlocks = [];
|
||||
|
||||
@@ -7,6 +7,22 @@ use support\Db;
|
||||
|
||||
class ChunkSearchIndexRepository
|
||||
{
|
||||
public function resetEmbeddedChunksToPending(?string $archiveUid = null): int
|
||||
{
|
||||
$query = Db::table('chunks')
|
||||
->where('embedding_status', EmbeddingStatus::EMBEDDED);
|
||||
|
||||
if ($archiveUid !== null && trim($archiveUid) !== '') {
|
||||
$query->where('archive_uid', trim($archiveUid));
|
||||
}
|
||||
|
||||
return $query->update([
|
||||
'search_index_status' => SearchIndexStatus::PENDING,
|
||||
'search_index_error' => null,
|
||||
'search_index_updated_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function queuePendingArchiveTasks(int $limit): array
|
||||
{
|
||||
$statuses = [
|
||||
@@ -63,6 +79,7 @@ class ChunkSearchIndexRepository
|
||||
'chunks.created_time',
|
||||
'chunks.updated_time',
|
||||
'archives.title',
|
||||
'archives.summary',
|
||||
'archives.source',
|
||||
'archives.author',
|
||||
'archives.year',
|
||||
@@ -105,6 +122,7 @@ class ChunkSearchIndexRepository
|
||||
'page_start' => $row->page_start === null ? null : (int) $row->page_start,
|
||||
'page_end' => $row->page_end === null ? null : (int) $row->page_end,
|
||||
'title' => $row->title,
|
||||
'summary' => $row->summary,
|
||||
'source' => $row->source,
|
||||
'author' => $row->author,
|
||||
'year' => $row->year === null ? null : (int) $row->year,
|
||||
|
||||
@@ -16,6 +16,7 @@ class OpenSearchChunkIndex
|
||||
$index = $this->indexName();
|
||||
|
||||
if ($client->indices()->exists(['index' => $index])) {
|
||||
$this->ensureProperties($client, $index);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -64,6 +65,7 @@ class OpenSearchChunkIndex
|
||||
'page_start' => ['type' => 'integer'],
|
||||
'page_end' => ['type' => 'integer'],
|
||||
'title' => $this->textWithKeyword(),
|
||||
'summary' => ['type' => 'text'],
|
||||
'source' => $this->textWithKeyword(),
|
||||
'author' => $this->textWithKeyword(),
|
||||
'year' => ['type' => 'integer'],
|
||||
@@ -93,6 +95,31 @@ class OpenSearchChunkIndex
|
||||
return $this->client ?? (new OpenSearchClientFactory())->make();
|
||||
}
|
||||
|
||||
private function ensureProperties(Client $client, string $index): void
|
||||
{
|
||||
$mapping = $client->indices()->getMapping(['index' => $index]);
|
||||
$existing = $mapping[$index]['mappings']['properties'] ?? [];
|
||||
$desired = $this->mapping()['mappings']['properties'] ?? [];
|
||||
$missing = [];
|
||||
|
||||
foreach ($desired as $field => $definition) {
|
||||
if (!array_key_exists($field, $existing)) {
|
||||
$missing[$field] = $definition;
|
||||
}
|
||||
}
|
||||
|
||||
if ($missing === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$client->indices()->putMapping([
|
||||
'index' => $index,
|
||||
'body' => [
|
||||
'properties' => $missing,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function indexName(): string
|
||||
{
|
||||
return config('opensearch.indices.chunks', 'proofdb_chunks');
|
||||
|
||||
@@ -39,6 +39,7 @@ class OpenSearchSearchService
|
||||
'fields' => [
|
||||
'text^4',
|
||||
'title^3',
|
||||
'summary^2',
|
||||
'source^2',
|
||||
'author^2',
|
||||
'series^2',
|
||||
@@ -219,6 +220,7 @@ class OpenSearchSearchService
|
||||
'page_start' => $source['page_start'] ?? null,
|
||||
'page_end' => $source['page_end'] ?? null,
|
||||
'title' => $source['title'] ?? null,
|
||||
'summary' => $source['summary'] ?? null,
|
||||
'source' => $source['source'] ?? null,
|
||||
'author' => $source['author'] ?? null,
|
||||
'year' => $source['year'] ?? null,
|
||||
@@ -322,6 +324,7 @@ class OpenSearchSearchService
|
||||
'page_start',
|
||||
'page_end',
|
||||
'title',
|
||||
'summary',
|
||||
'source',
|
||||
'author',
|
||||
'year',
|
||||
|
||||
@@ -0,0 +1,835 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Proof DB 管理面板</title>
|
||||
<link rel="stylesheet" href="/admin.css">
|
||||
</head>
|
||||
<body class="admin-dashboard">
|
||||
<main class="admin-console page-shell-wide">
|
||||
<section class="admin-console-shell panel">
|
||||
<aside class="admin-console-sidebar">
|
||||
<div class="admin-console-brand">
|
||||
<div class="eyebrow">Maintenance Console / v<?=htmlspecialchars($version)?></div>
|
||||
<h1 class="admin-console-title">Proof DB</h1>
|
||||
<div class="admin-console-subtitle">管理员工作台</div>
|
||||
</div>
|
||||
|
||||
<nav class="admin-console-nav">
|
||||
<button class="admin-console-nav-item is-active" type="button" data-target="overview">总览</button>
|
||||
<button class="admin-console-nav-item" type="button" data-target="archives">档案数据库</button>
|
||||
<button class="admin-console-nav-item" type="button" data-target="opensearch">OpenSearch</button>
|
||||
<button class="admin-console-nav-item" type="button" data-target="users">用户管理</button>
|
||||
<button class="admin-console-nav-item" type="button" data-target="apidoc">API 文档</button>
|
||||
<button class="admin-console-nav-item" type="button" data-target="scripts">维护脚本</button>
|
||||
</nav>
|
||||
|
||||
<div class="admin-console-sidebar-foot">
|
||||
<div class="metric-label">当前会话</div>
|
||||
<div class="admin-console-identity"><?=htmlspecialchars($admin['display_name'] ?: $admin['username'])?></div>
|
||||
<div class="admin-console-identity-sub">@<?=htmlspecialchars($admin['username'])?></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="admin-console-main">
|
||||
<header class="admin-console-header">
|
||||
<div>
|
||||
<div class="eyebrow">Administrative Entry</div>
|
||||
<h2 class="admin-console-header-title">Proof DB 管理面板</h2>
|
||||
<p class="admin-console-header-copy">在这里维护 archives 表、OpenSearch 状态、管理员账号、API 文档,以及脚本级运维动作。</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-dashboard-actions">
|
||||
<?php if ($archiveCaskUrl !== ''): ?>
|
||||
<a class="button" href="<?=htmlspecialchars($archiveCaskUrl)?>">返回 Archive Cask</a>
|
||||
<?php endif; ?>
|
||||
<button class="button" id="logout-button" type="button">退出登录</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="global-message" class="console-message" hidden></div>
|
||||
|
||||
<section class="admin-pane is-active" data-pane="overview">
|
||||
<div class="admin-pane-head">
|
||||
<div>
|
||||
<h3 class="admin-dashboard-section-title">系统总览</h3>
|
||||
<div class="admin-dashboard-section-note">集中查看数据库、OpenSearch 和当前版本状态。</div>
|
||||
</div>
|
||||
<button class="button" type="button" id="refresh-overview">刷新总览</button>
|
||||
</div>
|
||||
|
||||
<div class="metric-grid admin-console-overview-grid" id="overview-metrics"></div>
|
||||
|
||||
<div class="admin-console-two-column">
|
||||
<section class="admin-dashboard-section panel-soft">
|
||||
<div class="admin-dashboard-section-head">
|
||||
<h4 class="admin-dashboard-section-title">OpenSearch 摘要</h4>
|
||||
<div class="admin-dashboard-section-note">来自管理员状态 API</div>
|
||||
</div>
|
||||
<div class="terminal-block" id="overview-opensearch-terminal">等待加载...</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-dashboard-section panel-soft">
|
||||
<div class="admin-dashboard-section-head">
|
||||
<h4 class="admin-dashboard-section-title">快速入口</h4>
|
||||
<div class="admin-dashboard-section-note">直接跳到主要维护区块</div>
|
||||
</div>
|
||||
<div class="admin-console-quick-actions">
|
||||
<button class="button" type="button" data-open-pane="archives">管理 archives</button>
|
||||
<button class="button" type="button" data-open-pane="opensearch">查看 OpenSearch</button>
|
||||
<button class="button" type="button" data-open-pane="users">管理用户</button>
|
||||
<button class="button" type="button" data-open-pane="apidoc">查看 APIDOC</button>
|
||||
<button class="button" type="button" data-open-pane="scripts">执行维护脚本</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-pane" data-pane="archives">
|
||||
<div class="admin-pane-head">
|
||||
<div>
|
||||
<h3 class="admin-dashboard-section-title">archives 表管理</h3>
|
||||
<div class="admin-dashboard-section-note">搜索、查看、编辑和删除档案记录。这里只操作 archives 表本身。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-console-workbench">
|
||||
<section class="admin-dashboard-section panel-soft">
|
||||
<div class="admin-toolbar">
|
||||
<input class="text-input admin-toolbar-input" id="archives-query" placeholder="按 archive_uid / title / summary / author / source 搜索">
|
||||
<button class="button" type="button" id="archives-search">搜索</button>
|
||||
<button class="button" type="button" id="archives-reload">刷新</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-list" id="archives-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="admin-dashboard-section panel-soft">
|
||||
<div class="admin-dashboard-section-head">
|
||||
<h4 class="admin-dashboard-section-title">档案编辑器</h4>
|
||||
<div class="admin-dashboard-section-note">选择左侧档案后可编辑。</div>
|
||||
</div>
|
||||
|
||||
<form id="archive-form" class="admin-form-grid">
|
||||
<label class="field-label" for="archive-uid">archive_uid</label>
|
||||
<input class="text-input" id="archive-uid" name="archive_uid" readonly>
|
||||
|
||||
<label class="field-label" for="archive-title">title</label>
|
||||
<input class="text-input" id="archive-title" name="title">
|
||||
|
||||
<label class="field-label" for="archive-year">year</label>
|
||||
<input class="text-input" id="archive-year" name="year">
|
||||
|
||||
<label class="field-label" for="archive-author">author</label>
|
||||
<input class="text-input" id="archive-author" name="author">
|
||||
|
||||
<label class="field-label" for="archive-source">source</label>
|
||||
<input class="text-input" id="archive-source" name="source">
|
||||
|
||||
<label class="field-label" for="archive-series">series</label>
|
||||
<input class="text-input" id="archive-series" name="series">
|
||||
|
||||
<label class="field-label" for="archive-tags">tags</label>
|
||||
<textarea class="text-area" id="archive-tags" name="tags" rows="2" placeholder="逗号或换行分隔"></textarea>
|
||||
|
||||
<label class="field-label" for="archive-summary">summary</label>
|
||||
<textarea class="text-area" id="archive-summary" name="summary" rows="5"></textarea>
|
||||
|
||||
<label class="field-label" for="archive-metadata">metadata</label>
|
||||
<textarea class="text-area admin-code-area" id="archive-metadata" name="metadata" rows="8" placeholder='{"key":"value"}'></textarea>
|
||||
|
||||
<label class="field-label" for="archive-content">content</label>
|
||||
<textarea class="text-area admin-code-area" id="archive-content" name="content" rows="10"></textarea>
|
||||
|
||||
<label class="field-label" for="archive-raw">raw</label>
|
||||
<textarea class="text-area admin-code-area" id="archive-raw" name="raw" rows="10"></textarea>
|
||||
|
||||
<div class="admin-form-actions">
|
||||
<button class="button primary" type="submit">保存档案</button>
|
||||
<button class="button" type="button" id="archive-delete">删除档案</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-pane" data-pane="opensearch">
|
||||
<div class="admin-pane-head">
|
||||
<div>
|
||||
<h3 class="admin-dashboard-section-title">OpenSearch 管理</h3>
|
||||
<div class="admin-dashboard-section-note">查看集群、索引、数据库侧索引状态,以及索引中的文档粗览。</div>
|
||||
</div>
|
||||
<button class="button" type="button" id="opensearch-refresh">刷新状态</button>
|
||||
</div>
|
||||
|
||||
<div class="metric-grid admin-console-opensearch-grid" id="opensearch-metrics"></div>
|
||||
|
||||
<section class="admin-dashboard-section panel-soft">
|
||||
<div class="admin-toolbar">
|
||||
<input class="text-input admin-toolbar-input" id="opensearch-query" placeholder="按 title / summary / source / author / text 搜索索引文档">
|
||||
<button class="button" type="button" id="opensearch-search">搜索索引</button>
|
||||
<button class="button" type="button" id="opensearch-reload-docs">刷新文档</button>
|
||||
</div>
|
||||
<div class="admin-list" id="opensearch-documents"></div>
|
||||
</section>
|
||||
|
||||
<div class="admin-console-two-column">
|
||||
<section class="admin-dashboard-section panel-soft">
|
||||
<div class="admin-dashboard-section-head">
|
||||
<h4 class="admin-dashboard-section-title">OpenSearch 详情</h4>
|
||||
<div class="admin-dashboard-section-note">主机、索引、mapping 字段等。</div>
|
||||
</div>
|
||||
<div class="terminal-block" id="opensearch-terminal">等待加载...</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-dashboard-section panel-soft">
|
||||
<div class="admin-dashboard-section-head">
|
||||
<h4 class="admin-dashboard-section-title">建议动作</h4>
|
||||
<div class="admin-dashboard-section-note">跳转到脚本面板执行维护脚本。</div>
|
||||
</div>
|
||||
<div class="admin-console-quick-actions">
|
||||
<button class="button" type="button" data-script="setup_opensearch">执行 setup_opensearch</button>
|
||||
<button class="button" type="button" data-script="reindex_opensearch">执行 reindex_opensearch</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-pane" data-pane="users">
|
||||
<div class="admin-pane-head">
|
||||
<div>
|
||||
<h3 class="admin-dashboard-section-title">管理员用户管理</h3>
|
||||
<div class="admin-dashboard-section-note">创建管理员账号,修改显示名、密码与启用状态。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-console-two-column">
|
||||
<section class="admin-dashboard-section panel-soft">
|
||||
<div class="admin-dashboard-section-head">
|
||||
<h4 class="admin-dashboard-section-title">创建新管理员</h4>
|
||||
<div class="admin-dashboard-section-note">账号创建后即默认启用。</div>
|
||||
</div>
|
||||
|
||||
<form id="create-user-form" class="admin-form-grid compact">
|
||||
<label class="field-label" for="new-username">username</label>
|
||||
<input class="text-input" id="new-username" name="username" required>
|
||||
|
||||
<label class="field-label" for="new-display-name">display_name</label>
|
||||
<input class="text-input" id="new-display-name" name="display_name">
|
||||
|
||||
<label class="field-label" for="new-password">password</label>
|
||||
<input class="text-input" id="new-password" name="password" type="password" required>
|
||||
|
||||
<div class="admin-form-actions">
|
||||
<button class="button primary" type="submit">创建用户</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-dashboard-section panel-soft">
|
||||
<div class="admin-dashboard-section-head">
|
||||
<h4 class="admin-dashboard-section-title">现有管理员</h4>
|
||||
<div class="admin-dashboard-section-note">留空密码表示不修改。</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-user-list" id="users-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-pane" data-pane="apidoc">
|
||||
<div class="admin-pane-head">
|
||||
<div>
|
||||
<h3 class="admin-dashboard-section-title">APIDOC 查看</h3>
|
||||
<div class="admin-dashboard-section-note">浏览 `/apidoc` 中的接口文档。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-console-workbench">
|
||||
<section class="admin-dashboard-section panel-soft">
|
||||
<div class="admin-list" id="docs-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="admin-dashboard-section panel-soft">
|
||||
<div class="admin-dashboard-section-head">
|
||||
<h4 class="admin-dashboard-section-title" id="doc-title">文档内容</h4>
|
||||
<div class="admin-dashboard-section-note" id="doc-name">请选择一份文档。</div>
|
||||
</div>
|
||||
<div class="admin-markdown-viewer" id="doc-content">等待加载...</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-pane" data-pane="scripts">
|
||||
<div class="admin-pane-head">
|
||||
<div>
|
||||
<h3 class="admin-dashboard-section-title">维护脚本伪终端</h3>
|
||||
<div class="admin-dashboard-section-note">仅允许执行白名单中的 `scripts/*.php` 维护脚本。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-console-two-column">
|
||||
<section class="admin-dashboard-section panel-soft">
|
||||
<div class="admin-dashboard-section-head">
|
||||
<h4 class="admin-dashboard-section-title">可执行脚本</h4>
|
||||
<div class="admin-dashboard-section-note">支持 `--key=value` 参数格式。</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-script-list" id="scripts-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="admin-dashboard-section panel-soft">
|
||||
<div class="admin-dashboard-section-head">
|
||||
<h4 class="admin-dashboard-section-title">执行终端</h4>
|
||||
<div class="admin-dashboard-section-note">脚本 stdout / stderr 会显示在下方。</div>
|
||||
</div>
|
||||
|
||||
<form id="script-form" class="admin-form-grid compact">
|
||||
<label class="field-label" for="script-select">script_name</label>
|
||||
<select class="text-input" id="script-select" name="script_name"></select>
|
||||
|
||||
<label class="field-label" for="script-args">args</label>
|
||||
<input class="text-input" id="script-args" name="args" placeholder="--archive_uid=01...">
|
||||
|
||||
<div class="admin-form-actions">
|
||||
<button class="button primary" type="submit">执行脚本</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="terminal-block" id="script-terminal"><span class="prompt">proofdb-admin$</span> 等待命令...</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="admin-dashboard-section panel-soft">
|
||||
<div class="admin-dashboard-section-head">
|
||||
<h4 class="admin-dashboard-section-title" id="script-doc-title">脚本文档</h4>
|
||||
<div class="admin-dashboard-section-note" id="script-doc-name">如果该脚本有文档,会显示在这里。</div>
|
||||
</div>
|
||||
<div class="admin-markdown-viewer" id="script-doc-content">等待加载...</div>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
const state = {
|
||||
archiveUid: null,
|
||||
docName: null,
|
||||
docHtml: null,
|
||||
scripts: [],
|
||||
opensearch: null,
|
||||
selectedScript: null
|
||||
};
|
||||
|
||||
const els = {
|
||||
message: document.getElementById('global-message'),
|
||||
overviewMetrics: document.getElementById('overview-metrics'),
|
||||
overviewTerminal: document.getElementById('overview-opensearch-terminal'),
|
||||
archivesList: document.getElementById('archives-list'),
|
||||
archiveForm: document.getElementById('archive-form'),
|
||||
archiveDelete: document.getElementById('archive-delete'),
|
||||
opensearchMetrics: document.getElementById('opensearch-metrics'),
|
||||
opensearchTerminal: document.getElementById('opensearch-terminal'),
|
||||
opensearchDocuments: document.getElementById('opensearch-documents'),
|
||||
usersList: document.getElementById('users-list'),
|
||||
createUserForm: document.getElementById('create-user-form'),
|
||||
docsList: document.getElementById('docs-list'),
|
||||
docTitle: document.getElementById('doc-title'),
|
||||
docName: document.getElementById('doc-name'),
|
||||
docContent: document.getElementById('doc-content'),
|
||||
scriptsList: document.getElementById('scripts-list'),
|
||||
scriptForm: document.getElementById('script-form'),
|
||||
scriptSelect: document.getElementById('script-select'),
|
||||
scriptArgs: document.getElementById('script-args'),
|
||||
scriptTerminal: document.getElementById('script-terminal'),
|
||||
scriptDocTitle: document.getElementById('script-doc-title'),
|
||||
scriptDocName: document.getElementById('script-doc-name'),
|
||||
scriptDocContent: document.getElementById('script-doc-content')
|
||||
};
|
||||
|
||||
function field(form, name) {
|
||||
return form.elements.namedItem(name);
|
||||
}
|
||||
|
||||
function showMessage(text, kind = 'info') {
|
||||
if (!text) {
|
||||
els.message.hidden = true;
|
||||
els.message.textContent = '';
|
||||
els.message.className = 'console-message';
|
||||
return;
|
||||
}
|
||||
els.message.hidden = false;
|
||||
els.message.textContent = text;
|
||||
els.message.className = `console-message is-${kind}`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
async function api(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {})
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok || data.code !== 0) {
|
||||
throw new Error(data.errors ? Object.values(data.errors)[0] : data.message || '请求失败');
|
||||
}
|
||||
|
||||
return data.data;
|
||||
}
|
||||
|
||||
function activatePane(name) {
|
||||
document.querySelectorAll('.admin-console-nav-item').forEach((button) => {
|
||||
button.classList.toggle('is-active', button.dataset.target === name);
|
||||
});
|
||||
document.querySelectorAll('.admin-pane').forEach((pane) => {
|
||||
pane.classList.toggle('is-active', pane.dataset.pane === name);
|
||||
});
|
||||
}
|
||||
|
||||
function tokeniseArgs(text) {
|
||||
const tokens = text.match(/"[^"]*"|'[^']*'|\S+/g) || [];
|
||||
return tokens.map((token) => token.replace(/^['"]|['"]$/g, ''));
|
||||
}
|
||||
|
||||
function renderMetricCards(metrics) {
|
||||
return metrics.map((metric) => `
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">${escapeHtml(metric.label)}</div>
|
||||
<div class="metric-value">${escapeHtml(metric.value)}</div>
|
||||
<div class="metric-subvalue">${escapeHtml(metric.note || '')}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function loadOpenSearchStatus() {
|
||||
const data = await api('/api/admin/opensearch/status');
|
||||
state.opensearch = data;
|
||||
|
||||
const metrics = [
|
||||
{label: 'archives', value: data.database.archives_total, note: 'archives 表记录数'},
|
||||
{label: 'chunks', value: data.database.chunks_total, note: 'chunks 表记录数'},
|
||||
{label: 'embedded', value: data.database.embedded_chunks, note: 'embedding_status = 3'},
|
||||
{label: 'indexed', value: data.database.indexed_chunks, note: 'search_index_status = 3'},
|
||||
{label: 'index', value: data.config.index_name, note: data.opensearch.index_exists ? '索引已存在' : '索引不存在'},
|
||||
{label: 'docs.count', value: data.opensearch.docs_count, note: data.opensearch.health || '未获取健康状态'}
|
||||
];
|
||||
|
||||
els.overviewMetrics.innerHTML = renderMetricCards(metrics);
|
||||
els.opensearchMetrics.innerHTML = renderMetricCards(metrics);
|
||||
|
||||
const terminal = [
|
||||
`hosts: ${(data.config.hosts || []).join(', ') || '[]'}`,
|
||||
`ssl_verify: ${String(data.config.ssl_verify)}`,
|
||||
`cluster_name: ${data.opensearch.cluster_name || '-'}`,
|
||||
`reachable: ${String(data.opensearch.reachable)}`,
|
||||
`index_exists: ${String(data.opensearch.index_exists)}`,
|
||||
`health: ${data.opensearch.health || '-'}`,
|
||||
`mapping_fields: ${(data.opensearch.mapping_fields || []).join(', ') || '-'}`,
|
||||
data.opensearch.error ? `error: ${data.opensearch.error}` : ''
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
els.overviewTerminal.textContent = terminal;
|
||||
els.opensearchTerminal.textContent = terminal;
|
||||
}
|
||||
|
||||
async function loadOpenSearchDocuments() {
|
||||
const query = document.getElementById('opensearch-query').value.trim();
|
||||
const data = await api(`/api/admin/opensearch/documents?query=${encodeURIComponent(query)}&size=20`);
|
||||
|
||||
if ((data.items || []).length === 0) {
|
||||
els.opensearchDocuments.innerHTML = '<div class="admin-list-empty">当前没有可展示的索引文档。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
els.opensearchDocuments.innerHTML = data.items.map((item) => `
|
||||
<article class="admin-list-item no-click">
|
||||
<div class="admin-list-item-head">
|
||||
<strong>${escapeHtml(item.title || item.chunk_uid)}</strong>
|
||||
<span>${escapeHtml(item.chunk_uid || '-')}</span>
|
||||
</div>
|
||||
<div class="admin-list-item-copy">${escapeHtml(item.text_preview || item.summary || '无预览文本')}</div>
|
||||
<div class="admin-list-item-meta">
|
||||
<span>${escapeHtml(item.archive_uid || '-')}</span>
|
||||
<span>p.${escapeHtml(item.page_start || '-')} - ${escapeHtml(item.page_end || '-')}</span>
|
||||
<span>${escapeHtml(item.source || '-')}</span>
|
||||
<span>${escapeHtml(item.embedding_model || '-')}</span>
|
||||
</div>
|
||||
</article>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function loadArchives() {
|
||||
const query = document.getElementById('archives-query').value.trim();
|
||||
const data = await api(`/api/admin/archives?query=${encodeURIComponent(query)}`);
|
||||
|
||||
if (data.items.length === 0) {
|
||||
els.archivesList.innerHTML = '<div class="admin-list-empty">没有找到档案记录。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
els.archivesList.innerHTML = data.items.map((item) => `
|
||||
<button class="admin-list-item ${state.archiveUid === item.archive_uid ? 'is-active' : ''}" type="button" data-archive="${escapeHtml(item.archive_uid)}">
|
||||
<div class="admin-list-item-head">
|
||||
<strong>${escapeHtml(item.title || item.archive_uid)}</strong>
|
||||
<span>${escapeHtml(item.archive_uid)}</span>
|
||||
</div>
|
||||
<div class="admin-list-item-copy">${escapeHtml(item.summary || '无 summary')}</div>
|
||||
<div class="admin-list-item-meta">
|
||||
<span>${escapeHtml(item.source || '-')}</span>
|
||||
<span>${escapeHtml(item.year || '-')}</span>
|
||||
<span>${escapeHtml(item.chunk_count)} chunks</span>
|
||||
</div>
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
els.archivesList.querySelectorAll('[data-archive]').forEach((button) => {
|
||||
button.addEventListener('click', async () => {
|
||||
try {
|
||||
await loadArchiveDetail(button.dataset.archive);
|
||||
} catch (error) {
|
||||
showMessage(error.message || '加载档案详情失败。', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!state.archiveUid && data.items[0]) {
|
||||
await loadArchiveDetail(data.items[0].archive_uid);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadArchiveDetail(archiveUid) {
|
||||
const data = await api(`/api/admin/archives/${encodeURIComponent(archiveUid)}`);
|
||||
state.archiveUid = archiveUid;
|
||||
document.querySelectorAll('#archives-list [data-archive]').forEach((item) => {
|
||||
item.classList.toggle('is-active', item.dataset.archive === archiveUid);
|
||||
});
|
||||
|
||||
field(els.archiveForm, 'archive_uid').value = data.archive_uid || '';
|
||||
field(els.archiveForm, 'title').value = data.title || '';
|
||||
field(els.archiveForm, 'year').value = data.year || '';
|
||||
field(els.archiveForm, 'author').value = data.author || '';
|
||||
field(els.archiveForm, 'source').value = data.source || '';
|
||||
field(els.archiveForm, 'series').value = data.series || '';
|
||||
field(els.archiveForm, 'tags').value = (data.tags || []).join(', ');
|
||||
field(els.archiveForm, 'summary').value = data.summary || '';
|
||||
field(els.archiveForm, 'metadata').value = JSON.stringify(data.metadata || {}, null, 2);
|
||||
field(els.archiveForm, 'content').value = data.content || '';
|
||||
field(els.archiveForm, 'raw').value = data.raw || '';
|
||||
}
|
||||
|
||||
async function saveArchive(event) {
|
||||
event.preventDefault();
|
||||
if (!state.archiveUid) {
|
||||
showMessage('请先选择一条档案记录。', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let metadata;
|
||||
try {
|
||||
const metadataField = field(els.archiveForm, 'metadata');
|
||||
metadata = metadataField.value.trim() === '' ? {} : JSON.parse(metadataField.value);
|
||||
} catch (error) {
|
||||
showMessage('metadata 不是合法 JSON。', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
title: field(els.archiveForm, 'title').value,
|
||||
year: field(els.archiveForm, 'year').value,
|
||||
author: field(els.archiveForm, 'author').value,
|
||||
source: field(els.archiveForm, 'source').value,
|
||||
series: field(els.archiveForm, 'series').value,
|
||||
tags: field(els.archiveForm, 'tags').value,
|
||||
summary: field(els.archiveForm, 'summary').value,
|
||||
metadata,
|
||||
content: field(els.archiveForm, 'content').value,
|
||||
raw: field(els.archiveForm, 'raw').value
|
||||
};
|
||||
|
||||
await api(`/api/admin/archives/${encodeURIComponent(state.archiveUid)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
showMessage('档案已更新。', 'success');
|
||||
await loadArchiveDetail(state.archiveUid);
|
||||
}
|
||||
|
||||
async function deleteArchive() {
|
||||
if (!state.archiveUid) {
|
||||
showMessage('请先选择一条档案记录。', 'error');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`确认删除档案 ${state.archiveUid} 吗?这会级联删除相关 chunks。`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await api(`/api/admin/archives/${encodeURIComponent(state.archiveUid)}`, {method: 'DELETE'});
|
||||
showMessage('档案已删除。', 'success');
|
||||
state.archiveUid = null;
|
||||
els.archiveForm.reset();
|
||||
await loadArchives();
|
||||
await loadOpenSearchStatus();
|
||||
}
|
||||
|
||||
function renderUsers(users) {
|
||||
els.usersList.innerHTML = users.map((user) => `
|
||||
<form class="admin-user-card" data-user-id="${user.id}">
|
||||
<div class="admin-user-card-head">
|
||||
<strong>${escapeHtml(user.username)}</strong>
|
||||
<span>${user.is_active ? '启用中' : '已停用'}</span>
|
||||
</div>
|
||||
<label class="field-label">display_name</label>
|
||||
<input class="text-input" name="display_name" value="${escapeHtml(user.display_name || '')}">
|
||||
<label class="field-label">new_password</label>
|
||||
<input class="text-input" type="password" name="password" placeholder="留空则不修改">
|
||||
<label class="admin-inline-check">
|
||||
<input type="checkbox" name="is_active" ${user.is_active ? 'checked' : ''}>
|
||||
<span>is_active</span>
|
||||
</label>
|
||||
<div class="admin-form-actions">
|
||||
<button class="button" type="submit">保存用户</button>
|
||||
</div>
|
||||
</form>
|
||||
`).join('');
|
||||
|
||||
els.usersList.querySelectorAll('.admin-user-card').forEach((form) => {
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const id = form.dataset.userId;
|
||||
await api(`/api/admin/users/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
display_name: field(form, 'display_name').value,
|
||||
password: field(form, 'password').value,
|
||||
is_active: field(form, 'is_active').checked
|
||||
})
|
||||
});
|
||||
showMessage(`用户 ${id} 已更新。`, 'success');
|
||||
await loadUsers();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const data = await api('/api/admin/users');
|
||||
renderUsers(data.items || []);
|
||||
}
|
||||
|
||||
async function createUser(event) {
|
||||
event.preventDefault();
|
||||
await api('/api/admin/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username: field(els.createUserForm, 'username').value.trim(),
|
||||
display_name: field(els.createUserForm, 'display_name').value.trim(),
|
||||
password: field(els.createUserForm, 'password').value
|
||||
})
|
||||
});
|
||||
els.createUserForm.reset();
|
||||
showMessage('管理员用户已创建。', 'success');
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
async function loadDocs() {
|
||||
const data = await api('/api/admin/docs');
|
||||
els.docsList.innerHTML = data.items.map((doc) => `
|
||||
<button class="admin-list-item ${state.docName === doc.name ? 'is-active' : ''}" type="button" data-doc="${escapeHtml(doc.name)}">
|
||||
<div class="admin-list-item-head">
|
||||
<strong>${escapeHtml(doc.title)}</strong>
|
||||
<span>${escapeHtml(doc.name)}</span>
|
||||
</div>
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
els.docsList.querySelectorAll('[data-doc]').forEach((button) => {
|
||||
button.addEventListener('click', () => loadDoc(button.dataset.doc));
|
||||
});
|
||||
|
||||
if (!state.docName && data.items[0]) {
|
||||
await loadDoc(data.items[0].name);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDoc(name) {
|
||||
const data = await api(`/api/admin/docs/${encodeURIComponent(name)}`);
|
||||
state.docName = name;
|
||||
els.docTitle.textContent = data.title;
|
||||
els.docName.textContent = data.name;
|
||||
els.docContent.innerHTML = data.html || '';
|
||||
await loadDocs();
|
||||
}
|
||||
|
||||
async function loadScripts() {
|
||||
const data = await api('/api/admin/scripts');
|
||||
state.scripts = data.items || [];
|
||||
els.scriptSelect.innerHTML = state.scripts.map((script) => `
|
||||
<option value="${escapeHtml(script.name)}">${escapeHtml(script.label)} (${escapeHtml(script.name)})</option>
|
||||
`).join('');
|
||||
|
||||
els.scriptsList.innerHTML = state.scripts.map((script) => `
|
||||
<button class="admin-script-card" type="button" data-script-select="${escapeHtml(script.name)}">
|
||||
<div class="admin-script-card-head">
|
||||
<strong>${escapeHtml(script.label)}</strong>
|
||||
<span>${escapeHtml(script.name)}</span>
|
||||
</div>
|
||||
<div class="admin-script-card-copy">${escapeHtml(script.description)}</div>
|
||||
<div class="admin-script-card-meta">
|
||||
<span>${escapeHtml(script.args_hint)}</span>
|
||||
<span>${escapeHtml(script.doc_name)}</span>
|
||||
</div>
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
els.scriptsList.querySelectorAll('[data-script-select]').forEach((button) => {
|
||||
button.addEventListener('click', async () => {
|
||||
els.scriptSelect.value = button.dataset.scriptSelect;
|
||||
await loadScriptDoc(button.dataset.scriptSelect);
|
||||
});
|
||||
});
|
||||
|
||||
if (!state.selectedScript && state.scripts[0]) {
|
||||
els.scriptSelect.value = state.scripts[0].name;
|
||||
await loadScriptDoc(state.scripts[0].name);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScriptDoc(name) {
|
||||
const data = await api(`/api/admin/scripts/${encodeURIComponent(name)}`);
|
||||
state.selectedScript = name;
|
||||
els.scriptDocTitle.textContent = data.doc_title || data.label || data.name;
|
||||
els.scriptDocName.textContent = data.doc_name || '暂无文档';
|
||||
els.scriptDocContent.innerHTML = data.doc_html || '<p>该脚本暂时没有文档。</p>';
|
||||
}
|
||||
|
||||
async function runScript(event) {
|
||||
event.preventDefault();
|
||||
const scriptName = els.scriptSelect.value;
|
||||
const args = tokeniseArgs(els.scriptArgs.value.trim());
|
||||
|
||||
const result = await api('/api/admin/scripts/run', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
script_name: scriptName,
|
||||
args
|
||||
})
|
||||
});
|
||||
|
||||
els.scriptTerminal.textContent = [
|
||||
`$ ${result.command.join(' ')}`,
|
||||
'',
|
||||
result.stdout ? `[stdout]\n${result.stdout}` : '',
|
||||
result.stderr ? `[stderr]\n${result.stderr}` : '',
|
||||
`exit_code: ${result.exit_code}`
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
showMessage(`脚本 ${scriptName} 执行完成。`, result.ok ? 'success' : 'error');
|
||||
await loadOpenSearchStatus();
|
||||
}
|
||||
|
||||
document.querySelectorAll('.admin-console-nav-item').forEach((button) => {
|
||||
button.addEventListener('click', async () => {
|
||||
activatePane(button.dataset.target);
|
||||
showMessage('');
|
||||
|
||||
if (button.dataset.target === 'archives') {
|
||||
await loadArchives();
|
||||
} else if (button.dataset.target === 'opensearch' || button.dataset.target === 'overview') {
|
||||
await loadOpenSearchStatus();
|
||||
if (button.dataset.target === 'opensearch') {
|
||||
await loadOpenSearchDocuments();
|
||||
}
|
||||
} else if (button.dataset.target === 'users') {
|
||||
await loadUsers();
|
||||
} else if (button.dataset.target === 'apidoc') {
|
||||
await loadDocs();
|
||||
} else if (button.dataset.target === 'scripts') {
|
||||
await loadScripts();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-open-pane]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
document.querySelector(`.admin-console-nav-item[data-target="${button.dataset.openPane}"]`)?.click();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-script]').forEach((button) => {
|
||||
button.addEventListener('click', async () => {
|
||||
activatePane('scripts');
|
||||
await loadScripts();
|
||||
els.scriptSelect.value = button.dataset.script;
|
||||
showMessage(`已切换到维护脚本,并选中 ${button.dataset.script}。`);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('logout-button').addEventListener('click', async () => {
|
||||
await fetch('/api/admin/logout', {method: 'POST'});
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
document.getElementById('refresh-overview').addEventListener('click', loadOpenSearchStatus);
|
||||
document.getElementById('opensearch-refresh').addEventListener('click', loadOpenSearchStatus);
|
||||
document.getElementById('opensearch-search').addEventListener('click', loadOpenSearchDocuments);
|
||||
document.getElementById('opensearch-reload-docs').addEventListener('click', loadOpenSearchDocuments);
|
||||
document.getElementById('archives-search').addEventListener('click', loadArchives);
|
||||
document.getElementById('archives-reload').addEventListener('click', loadArchives);
|
||||
els.scriptSelect.addEventListener('change', async () => {
|
||||
try {
|
||||
await loadScriptDoc(els.scriptSelect.value);
|
||||
} catch (error) {
|
||||
showMessage(error.message || '加载脚本文档失败。', 'error');
|
||||
}
|
||||
});
|
||||
els.archiveForm.addEventListener('submit', async (event) => {
|
||||
try {
|
||||
await saveArchive(event);
|
||||
} catch (error) {
|
||||
showMessage(error.message || '保存档案失败。', 'error');
|
||||
}
|
||||
});
|
||||
els.archiveDelete.addEventListener('click', async () => {
|
||||
try {
|
||||
await deleteArchive();
|
||||
} catch (error) {
|
||||
showMessage(error.message || '删除档案失败。', 'error');
|
||||
}
|
||||
});
|
||||
els.createUserForm.addEventListener('submit', async (event) => {
|
||||
try {
|
||||
await createUser(event);
|
||||
} catch (error) {
|
||||
showMessage(error.message || '创建用户失败。', 'error');
|
||||
}
|
||||
});
|
||||
els.scriptForm.addEventListener('submit', async (event) => {
|
||||
try {
|
||||
await runScript(event);
|
||||
} catch (error) {
|
||||
showMessage(error.message || '脚本执行失败。', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
(async function bootstrap() {
|
||||
try {
|
||||
await loadOpenSearchStatus();
|
||||
} catch (error) {
|
||||
showMessage(error.message || '加载总览失败。', 'error');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,83 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Proof DB Admin</title>
|
||||
<link rel="stylesheet" href="/admin.css">
|
||||
</head>
|
||||
<body class="admin-landing">
|
||||
<main class="admin-landing-page">
|
||||
<header class="topbar page-shell">
|
||||
<div class="brand">
|
||||
<span class="brand-mark" aria-hidden="true"></span>
|
||||
<span>Proof DB Admin Console</span>
|
||||
</div>
|
||||
<div>Version <?=htmlspecialchars($version)?></div>
|
||||
</header>
|
||||
|
||||
<section class="admin-landing-hero page-shell">
|
||||
<section class="admin-landing-intro panel">
|
||||
<div>
|
||||
<div class="eyebrow">Administrative Entry</div>
|
||||
<h1 class="admin-landing-title">Proof DB</h1><br><h2>档案数据中心</h2>
|
||||
<p class="lead admin-landing-lead">
|
||||
档案储存 标签处理 向量存储 全文搜索
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-landing-meta">
|
||||
<div>
|
||||
<div class="metric-label">关于Proof DB</div>
|
||||
<div class="admin-landing-meta-value">ProofDB是一个专业级的历史档案后端数据中心,集成档案数据库、全文搜索引擎和RAG向量引擎。</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-landing-portal panel-soft">
|
||||
<div class="admin-landing-portal-head">
|
||||
<span>Access Control</span>
|
||||
<span>v1</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="admin-landing-portal-title">选择进入路径</p>
|
||||
<p class="admin-landing-portal-copy">Tips: PoofDB的Proof是酒精度的意思</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-landing-button-stack">
|
||||
<a class="button primary <?=$archiveCaskUrl === '' ? 'disabled' : ''?>" href="<?=htmlspecialchars($archiveCaskUrl ?: '#')?>">
|
||||
<span>返回 Archive Cask</span>
|
||||
<span class="button-key">BACK</span>
|
||||
</a>
|
||||
<a class="button" href="/admin/login">
|
||||
<span>管理员登录</span>
|
||||
<span class="button-key">Login</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="admin-landing-status">
|
||||
<?php if ($archiveCaskUrl === ''): ?>
|
||||
<strong>Archive Cask 未配置。</strong> 请在 `.env` 中设置 `ARCHIVE_CASK_URL`。
|
||||
<?php else: ?>
|
||||
<strong>Archive Cask 已连接。</strong>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<footer class="footer page-shell">
|
||||
<style>
|
||||
lower {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
</style>
|
||||
<span>
|
||||
Proof DB
|
||||
<lower>by</lower>
|
||||
<a href="https://laysense.cn/" target="_blank">Shanghai Laysense Information Technology Co. Ltd.</a>
|
||||
</span>
|
||||
<span>Admin Surface / v<?=htmlspecialchars($version)?></span>
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,87 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>管理员登录</title>
|
||||
<link rel="stylesheet" href="/admin.css">
|
||||
</head>
|
||||
<body class="admin-login">
|
||||
<main class="admin-login-layout page-shell-wide">
|
||||
<section class="admin-login-intro panel">
|
||||
<div>
|
||||
<div class="eyebrow">Protected Access / v<?=htmlspecialchars($version)?></div>
|
||||
<h1 class="admin-login-title">管理员<br />登录</h1>
|
||||
<p class="lead admin-login-lead">登入 Proof DB 管理后台。</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-login-facts">
|
||||
<div>
|
||||
<div class="metric-label">身份鉴别</div>
|
||||
<div class="admin-landing-meta-value">内部账号密码</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-login-form-shell panel-soft">
|
||||
<div class="admin-login-form-head">
|
||||
<p class="admin-landing-portal-title">进入维护面板</p>
|
||||
<p class="admin-login-form-copy">请使用您的账号和密码进行登录</p>
|
||||
</div>
|
||||
|
||||
<div id="error" class="error-box"></div>
|
||||
|
||||
<form id="login-form">
|
||||
<label class="field-label" for="username">用户名</label>
|
||||
<input class="text-input" id="username" name="username" autocomplete="username" required>
|
||||
|
||||
<label class="field-label" for="password">密码</label>
|
||||
<input class="text-input" id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
|
||||
<div class="admin-login-actions">
|
||||
<button class="button primary" type="submit">登录</button>
|
||||
<a class="button" href="/">返回入口</a>
|
||||
<?php if ($archiveCaskUrl !== ''): ?>
|
||||
<a class="button" href="<?=htmlspecialchars($archiveCaskUrl)?>">返回 Archive Cask</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="admin-login-footnote">如忘记密码请联系数据库管理员。</div>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
const form = document.getElementById('login-form');
|
||||
const errorBox = document.getElementById('error');
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
errorBox.style.display = 'none';
|
||||
errorBox.textContent = '';
|
||||
|
||||
const payload = {
|
||||
username: form.username.value.trim(),
|
||||
password: form.password.value
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/login', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || data.code !== 0) {
|
||||
throw new Error(data.errors?.auth || data.message || '登录失败。');
|
||||
}
|
||||
|
||||
window.location.href = '/admin';
|
||||
} catch (error) {
|
||||
errorBox.textContent = error.message || '登录失败。';
|
||||
errorBox.style.display = 'block';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user