diff --git a/app/admin/controller/Post.php b/app/admin/controller/Post.php
index ddecf77..2f364ca 100644
--- a/app/admin/controller/Post.php
+++ b/app/admin/controller/Post.php
@@ -348,4 +348,208 @@ class Post extends Common
return download($mardkown, $model_post->title . '.md', true);
}
+
+ /**
+ * 手机图片排版操作页
+ */
+ public function phoneImage($id)
+ {
+ $model_post = ModelPost::find($id);
+ if (empty($model_post)) {
+ $this->error('文章不存在');
+ }
+ View::assign('post', $model_post);
+ return View::fetch();
+ }
+
+ /**
+ * 输出管理列表页
+ */
+ public function postOutputList($id)
+ {
+ $model_post = ModelPost::find($id);
+ if (empty($model_post)) {
+ $this->error('文章不存在');
+ }
+ $output_list = \app\model\PostOutput::where('post_id', $id)
+ ->order('id', 'desc')
+ ->paginate(10);
+ View::assign('post', $model_post);
+ View::assign('list', $output_list);
+ return View::fetch('post_output/index');
+ }
+
+ /**
+ * 保存输出记录
+ */
+ public function savePostOutput()
+ {
+ $data = json_decode(file_get_contents('php://input'), true);
+ if (empty($data['post_id'])) {
+ return json(['code' => 500, 'msg' => '缺少文章ID']);
+ }
+
+ $post = ModelPost::find($data['post_id']);
+ if (empty($post)) {
+ return json(['code' => 500, 'msg' => '文章不存在']);
+ }
+
+ $config = $data['config'] ?? [];
+ $pages = $data['pages'] ?? [];
+
+ $admin_id = session('admin_id') ?? 0;
+
+ $phoneImage = new \app\common\tools\PhoneImage();
+
+ $output = $phoneImage->createOutput((int) $data['post_id'], $config, (int) $admin_id);
+
+ foreach ($pages as $index => $pageData) {
+ $phoneImage->savePageImage($output->id, $index + 1, $pageData);
+ }
+
+ $phoneImage->completeOutput($output->id, count($pages));
+
+ return json(['code' => 0, 'msg' => '保存成功', 'data' => ['output_id' => $output->id]]);
+ }
+
+ /**
+ * 删除输出记录
+ */
+ public function deletePostOutput()
+ {
+ $id = $this->request->param('id', 0);
+ if (empty($id)) {
+ return json(['code' => 500, 'msg' => '缺少ID']);
+ }
+
+ $phoneImage = new \app\common\tools\PhoneImage();
+ $phoneImage->deleteOutput((int) $id);
+
+ return json(['code' => 0, 'msg' => '删除成功']);
+ }
+
+ /**
+ * 重新生成(用相同配置)
+ */
+ public function regeneratePostOutput()
+ {
+ $id = $this->request->param('id', 0);
+ if (empty($id)) {
+ return json(['code' => 500, 'msg' => '缺少ID']);
+ }
+
+ $output = \app\model\PostOutput::find($id);
+ if (empty($output)) {
+ return json(['code' => 500, 'msg' => '输出记录不存在']);
+ }
+
+ $phoneImage = new \app\common\tools\PhoneImage();
+ $phoneImage->deleteOutput((int) $id);
+
+ return json(['code' => 0, 'msg' => '请重新排版', 'data' => ['config' => $output->config]]);
+ }
+
+ /**
+ * 下载ZIP
+ */
+ public function downloadPostOutputZip($id)
+ {
+ $phoneImage = new \app\common\tools\PhoneImage();
+ try {
+ $zipPath = $phoneImage->createZip((int) $id);
+ $output = \app\model\PostOutput::find($id);
+ if (empty($output)) {
+ throw new \Exception('输出记录不存在');
+ }
+ $post = ModelPost::find($output->post_id);
+ $fileName = ($post ? $post->title : 'output') . '_phone_image_' . date('Ymd') . '.zip';
+
+ // 请求结束后清理临时ZIP文件
+ $tempFile = $zipPath;
+ register_shutdown_function(function () use ($tempFile) {
+ if (file_exists($tempFile)) {
+ @unlink($tempFile);
+ }
+ });
+
+ return download($zipPath, $fileName);
+ } catch (\Exception $e) {
+ $this->error($e->getMessage());
+ }
+ }
+
+ /**
+ * 获取输出文件列表(AJAX)
+ */
+ public function getOutputFiles()
+ {
+ $outputId = $this->request->param('output_id', 0);
+
+ if (empty($outputId)) {
+ return json(['code' => 500, 'msg' => '缺少输出ID']);
+ }
+
+ $output = \app\model\PostOutput::find((int) $outputId);
+ if (empty($output)) {
+ return json(['code' => 500, 'msg' => '输出记录不存在']);
+ }
+
+ $files = \app\model\PostOutputFile::getByOutput((int) $outputId);
+
+ $result = [];
+ foreach ($files as $file) {
+ $result[] = [
+ 'id' => $file->id,
+ 'page' => $file->page,
+ 'file_url' => $file->file_url,
+ 'file_size' => $file->file_size,
+ 'width' => $file->width,
+ 'height' => $file->height,
+ ];
+ }
+
+ return json(['code' => 0, 'data' => $result]);
+ }
+
+ /**
+ * AI智能排版推荐.
+ */
+ public function aiRecommend()
+ {
+ $postId = $this->request->post('post_id', 0);
+ $post = ModelPost::find($postId);
+ if (empty($post)) {
+ return json(['code' => 500, 'msg' => '文章不存在']);
+ }
+
+ $advisor = new \app\common\tools\AiLayoutAdvisor();
+ $result = $advisor->analyzeAndRecommend($post);
+
+ if ($result['success']) {
+ return json(['code' => 0, 'data' => $result['data']]);
+ }
+
+ return json(['code' => 500, 'msg' => $result['msg']]);
+ }
+
+ /**
+ * AI内容优化.
+ */
+ public function aiOptimizeContent()
+ {
+ $postId = $this->request->post('post_id', 0);
+ $post = ModelPost::find($postId);
+ if (empty($post)) {
+ return json(['code' => 500, 'msg' => '文章不存在']);
+ }
+
+ $advisor = new \app\common\tools\AiLayoutAdvisor();
+ $result = $advisor->optimizeContent($post);
+
+ if ($result['success']) {
+ return json(['code' => 0, 'data' => $result['data']]);
+ }
+
+ return json(['code' => 500, 'msg' => $result['msg']]);
+ }
}
diff --git a/app/admin/controller/System.php b/app/admin/controller/System.php
index 30e54a5..e1574e0 100644
--- a/app/admin/controller/System.php
+++ b/app/admin/controller/System.php
@@ -96,4 +96,91 @@ class System extends Common
return $this->success('清楚成功');
}
+
+ /**
+ * AI 设置页面.
+ */
+ public function ai()
+ {
+ return View::fetch();
+ }
+
+ /**
+ * 同步 models.dev 数据.
+ */
+ public function syncModelsDev()
+ {
+ $sync = new \app\common\tools\ai\ModelsDevSync();
+ try {
+ $result = $sync->syncProviders();
+ $count = count($result['providers'] ?? []);
+ return json(['code' => 0, 'msg' => "同步成功, 共{$count}个供应商", 'data' => $result]);
+ } catch (\Exception $e) {
+ return json(['code' => 500, 'msg' => '同步失败: ' . $e->getMessage()]);
+ }
+ }
+
+ /**
+ * 测试AI渠道连接.
+ */
+ public function testAiChannel()
+ {
+ $providerId = $this->request->param('provider_id', '');
+ if (empty($providerId)) {
+ return json(['code' => 500, 'msg' => '缺少供应商ID']);
+ }
+ $manager = new \app\common\tools\ai\AiChannelManager();
+ $provider = $manager->getProvider($providerId);
+ if (!$provider) {
+ return json(['code' => 500, 'msg' => '供应商未配置或缺少API Key']);
+ }
+ $ok = $provider->testConnection();
+ return json(['code' => $ok ? 0 : 500, 'msg' => $ok ? '连接成功' : '连接失败,请检查配置']);
+ }
+
+ /**
+ * 获取指定供应商的模型列表.
+ */
+ public function getModelsByProvider()
+ {
+ $providerId = $this->request->param('provider_id', '');
+ $sync = new \app\common\tools\ai\ModelsDevSync();
+ $models = $sync->getModelsByProvider($providerId);
+ return json(['code' => 0, 'data' => $models]);
+ }
+
+ /**
+ * 获取 models.dev 缓存状态.
+ */
+ public function getAiCacheStatus()
+ {
+ $sync = new \app\common\tools\ai\ModelsDevSync();
+ $status = $sync->getCacheStatus();
+ return json(['code' => 0, 'data' => $status]);
+ }
+
+ /**
+ * 保存AI渠道配置.
+ */
+ public function saveAiChannel()
+ {
+ $data = $this->request->post();
+ $manager = new \app\common\tools\ai\AiChannelManager();
+ $ok = $manager->saveChannelConfig($data);
+ return json(['code' => $ok ? 0 : 500, 'msg' => $ok ? '保存成功' : '保存失败']);
+ }
+
+ /**
+ * 删除AI渠道配置.
+ */
+ public function deleteAiChannel()
+ {
+ $providerId = $this->request->param('provider_id', '');
+ if (empty($providerId)) {
+ return json(['code' => 500, 'msg' => '缺少供应商ID']);
+ }
+ $manager = new \app\common\tools\ai\AiChannelManager();
+ $ok = $manager->deleteChannelConfig($providerId);
+ return json(['code' => $ok ? 0 : 500, 'msg' => $ok ? '删除成功' : '删除失败']);
+ }
}
diff --git a/app/common/tools/AiLayoutAdvisor.php b/app/common/tools/AiLayoutAdvisor.php
new file mode 100644
index 0000000..1f89587
--- /dev/null
+++ b/app/common/tools/AiLayoutAdvisor.php
@@ -0,0 +1,87 @@
+bool, 'data'=>..., 'msg'=>...]
+ */
+ public function analyzeAndRecommend(Post $post): array
+ {
+ try {
+ $manager = new AiChannelManager();
+ $provider = $manager->getDefaultProvider();
+
+ if (!$provider) {
+ return ['success' => false, 'msg' => 'AI 功能暂不可用,请检查后台 AI 设置'];
+ }
+
+ $systemPrompt = "你是一位专业的手机图文排版设计师。根据文章内容,推荐最适合的排版配置。"
+ . "你必须返回JSON格式:{\"template\":\"minimal|magazine|mixed\",\"font\":\"source-han-sans|alibaba-puhuiti|lxgw-wenkai\","
+ . "\"font_size\":14,\"reason\":\"推荐理由\"}"
+ . "模板特点:minimal=白底黑字留白多适合长文,magazine=装饰线首字下沉适合正式文章,mixed=图文穿插适合图片多的文章。"
+ . "字体特点:source-han-sans=思源黑体通用,alibaba-puhuiti=阿里巴巴普惠体现代感,lxgw-wenkai=霞鹜文楷文艺感。";
+
+ $contentPreview = mb_substr(strip_tags($post->content_html ?? ''), 0, 500);
+ $imageCount = substr_count($post->content_html ?? '', '
title}\n"
+ . '摘要:' . ($post->desc ?? '无') . "\n"
+ . "正文前500字:{$contentPreview}\n"
+ . "图片数量:{$imageCount}\n"
+ . '分类:' . ($post->category_name ?? '未分类');
+
+ $result = $provider->chatJson($systemPrompt, $userPrompt);
+
+ return ['success' => true, 'data' => $result];
+ } catch (\Exception $e) {
+ return ['success' => false, 'msg' => 'AI 分析失败: ' . $e->getMessage()];
+ }
+ }
+
+ /**
+ * 优化内容以适配手机图片排版.
+ *
+ * @param Post $post 文章模型
+ *
+ * @return array ['success'=>bool, 'data'=>..., 'msg'=>...]
+ */
+ public function optimizeContent(Post $post): array
+ {
+ try {
+ $manager = new AiChannelManager();
+ $provider = $manager->getDefaultProvider();
+
+ if (!$provider) {
+ return ['success' => false, 'msg' => 'AI 功能暂不可用,请检查后台 AI 设置'];
+ }
+
+ $systemPrompt = "你是一位专业的内容编辑。优化以下文章内容,使其更适合在手机图片上展示。"
+ . "要求:1.标题更简短有力 2.段落精简(每段不超过100字) 3.提炼3-5个要点"
+ . "返回JSON:{\"optimized_title\":\"标题\",\"optimized_paragraphs\":[\"段落1\",\"段落2\"],\"summary_points\":[\"要点1\",\"要点2\"]}";
+
+ $content = mb_substr(strip_tags($post->content_html ?? ''), 0, 1000);
+
+ $userPrompt = "文章标题:{$post->title}\n原文内容:{$content}";
+
+ $result = $provider->chatJson($systemPrompt, $userPrompt);
+
+ return ['success' => true, 'data' => $result];
+ } catch (\Exception $e) {
+ return ['success' => false, 'msg' => 'AI 内容优化失败: ' . $e->getMessage()];
+ }
+ }
+}
diff --git a/app/common/tools/PhoneImage.php b/app/common/tools/PhoneImage.php
new file mode 100644
index 0000000..37711c7
--- /dev/null
+++ b/app/common/tools/PhoneImage.php
@@ -0,0 +1,215 @@
+ ['type' => 'select', 'options' => ['minimal', 'magazine', 'mixed'], 'default' => 'minimal'],
+ 'size' => ['type' => 'select', 'options' => ['xiaohongshu', 'douyin'], 'default' => 'xiaohongshu'],
+ 'font' => ['type' => 'select', 'options' => ['source-han-sans', 'alibaba-puhuiti', 'lxgw-wenkai'], 'default' => 'source-han-sans'],
+ 'font_size' => ['type' => 'number', 'default' => 14, 'min' => 10, 'max' => 24],
+ 'watermark' => ['type' => 'text', 'default' => ''],
+ ];
+ }
+
+ /**
+ * 验证配置合法性
+ */
+ public function validateConfig(array $config): bool
+ {
+ $fields = $this->getConfigFields();
+
+ if (isset($config['template']) && !in_array($config['template'], $fields['template']['options'])) {
+ return false;
+ }
+ if (isset($config['size']) && !in_array($config['size'], $fields['size']['options'])) {
+ return false;
+ }
+ if (isset($config['font']) && !in_array($config['font'], $fields['font']['options'])) {
+ return false;
+ }
+ if (isset($config['font_size'])) {
+ $size = intval($config['font_size']);
+ if ($size < $fields['font_size']['min'] || $size > $fields['font_size']['max']) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * 执行输出处理 - 服务端接收base64数据,保存文件,创建记录
+ * 注意:HTML渲染在前端完成(html2canvas),服务端只做文件保存和记录创建
+ */
+ public function process(Post $post, array $config): array
+ {
+ return [];
+ }
+
+ /**
+ * 返回预览HTML - 预览在前端完成,服务端返回空
+ */
+ public function getPreview(Post $post, array $config): string
+ {
+ return '';
+ }
+
+ /**
+ * 保存单页图片
+ * @param int $outputId 输出记录ID
+ * @param int $page 页码(从1开始)
+ * @param string $imageData base64编码的图片数据
+ * @return PostOutputFile|false
+ */
+ public function savePageImage(int $outputId, int $page, string $imageData)
+ {
+ $imageData = str_replace('data:image/jpeg;base64,', '', $imageData);
+ $imageData = str_replace('data:image/png;base64,', '', $imageData);
+ $imageData = str_replace(' ', '+', $imageData);
+ $decoded = base64_decode($imageData);
+
+ if ($decoded === false) {
+ return false;
+ }
+
+ $dateDir = date('Ymd');
+ $relativeDir = '/upload/post_output/' . $dateDir;
+ $fileName = $outputId . '_' . $page . '.jpg';
+ $relativePath = $relativeDir . '/' . $fileName;
+ $fullDir = App::getRootPath() . '/public' . $relativeDir;
+ $fullPath = $fullDir . '/' . $fileName;
+
+ if (!is_dir($fullDir)) {
+ mkdir($fullDir, 0777, true);
+ }
+
+ file_put_contents($fullPath, $decoded);
+
+ $imageInfo = getimagesize($fullPath);
+ $width = $imageInfo[0] ?? 0;
+ $height = $imageInfo[1] ?? 0;
+
+ $fileRecord = PostOutputFile::create([
+ 'output_id' => $outputId,
+ 'page' => $page,
+ 'file_path' => $relativePath,
+ 'file_url' => $relativePath,
+ 'file_size' => strlen($decoded),
+ 'width' => $width,
+ 'height' => $height,
+ ]);
+
+ return $fileRecord;
+ }
+
+ /**
+ * 打包某条输出记录的所有图片为ZIP
+ * @param int $outputId
+ * @return string ZIP文件路径
+ * @throws \Exception
+ */
+ public function createZip(int $outputId): string
+ {
+ $output = PostOutput::find($outputId);
+ if (!$output) {
+ throw new \Exception('输出记录不存在');
+ }
+
+ $files = PostOutputFile::where('output_id', $outputId)
+ ->order('page', 'asc')
+ ->select();
+
+ if ($files->isEmpty()) {
+ throw new \Exception('没有可下载的文件');
+ }
+
+ $tempDir = App::getRuntimePath() . 'temp';
+ if (!is_dir($tempDir)) {
+ mkdir($tempDir, 0777, true);
+ }
+
+ $zipFileName = 'post_output_' . $outputId . '.zip';
+ $zipPath = $tempDir . '/' . $zipFileName;
+
+ $zip = new \ZipArchive();
+ if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
+ throw new \Exception('无法创建ZIP文件');
+ }
+
+ foreach ($files as $file) {
+ $filePath = App::getRootPath() . '/public' . $file->file_path;
+ if (file_exists($filePath)) {
+ $pageName = str_pad((string) $file->page, 2, '0', STR_PAD_LEFT) . '.jpg';
+ $zip->addFile($filePath, $pageName);
+ }
+ }
+
+ $zip->close();
+
+ return $zipPath;
+ }
+
+ /**
+ * 删除输出记录及所有关联文件
+ * @param int $outputId
+ * @return bool
+ */
+ public function deleteOutput(int $outputId): bool
+ {
+ $files = PostOutputFile::where('output_id', $outputId)->select();
+
+ foreach ($files as $file) {
+ $filePath = App::getRootPath() . '/public' . $file->file_path;
+ if (file_exists($filePath)) {
+ @unlink($filePath);
+ }
+ }
+
+ PostOutputFile::where('output_id', $outputId)->delete();
+ PostOutput::destroy($outputId);
+
+ return true;
+ }
+
+ /**
+ * 创建输出记录
+ * @param int $postId 文章ID
+ * @param array $config 配置
+ * @param int $adminId 管理员ID
+ * @return PostOutput
+ */
+ public function createOutput(int $postId, array $config, int $adminId): PostOutput
+ {
+ return PostOutput::create([
+ 'post_id' => $postId,
+ 'output_type' => 'phone_image',
+ 'config' => $config,
+ 'status' => PostOutput::STATUS_PROCESSING,
+ 'page_count' => 0,
+ 'admin_id' => $adminId,
+ ]);
+ }
+
+ /**
+ * 完成输出记录 - 更新状态和页数
+ */
+ public function completeOutput(int $outputId, int $pageCount): bool
+ {
+ return PostOutput::where('id', $outputId)->update([
+ 'status' => PostOutput::STATUS_COMPLETED,
+ 'page_count' => $pageCount,
+ ]) > 0;
+ }
+}
diff --git a/app/common/tools/PostOutputManagerInterface.php b/app/common/tools/PostOutputManagerInterface.php
new file mode 100644
index 0000000..ef7676a
--- /dev/null
+++ b/app/common/tools/PostOutputManagerInterface.php
@@ -0,0 +1,28 @@
+getProvider($defaultId);
+ }
+
+ /**
+ * 获取指定供应商的适配器实例.
+ *
+ * @param string $providerId 供应商标识
+ *
+ * @return AiProviderInterface|null
+ */
+ public function getProvider(string $providerId): ?AiProviderInterface
+ {
+ if (isset($this->providers[$providerId])) {
+ return $this->providers[$providerId];
+ }
+
+ $apiKey = get_system_config("ai_provider_{$providerId}_key", '');
+ if (empty($apiKey)) {
+ return null;
+ }
+
+ $provider = new OpenAiCompatibleAdapter($providerId);
+ $this->providers[$providerId] = $provider;
+
+ return $provider;
+ }
+
+ /**
+ * 获取所有已配置(有API Key)的供应商列表.
+ *
+ * @return array
+ */
+ public function getAvailableProviders(): array
+ {
+ $sync = new ModelsDevSync();
+ $allProviders = $sync->getProviders();
+
+ $available = [];
+ foreach ($allProviders as $id => $provider) {
+ $apiKey = get_system_config("ai_provider_{$id}_key", '');
+ if (!empty($apiKey)) {
+ $provider['configured'] = true;
+ $provider['base_url'] = get_system_config("ai_provider_{$id}_base_url", $provider['api_base_url'] ?? '');
+ $available[$id] = $provider;
+ }
+ }
+
+ // 检查不在models.dev中但已手动配置的供应商
+ $allConfig = get_system_config();
+ if (is_array($allConfig)) {
+ foreach ($allConfig as $key => $value) {
+ if (preg_match('/^ai_provider_([a-z0-9_]+)_key$/', $key, $matches)) {
+ $customId = $matches[1];
+ if (!empty($value) && !isset($available[$customId])) {
+ $available[$customId] = [
+ 'id' => $customId,
+ 'name' => ucfirst(str_replace('_', ' ', $customId)),
+ 'configured' => true,
+ 'base_url' => get_system_config("ai_provider_{$customId}_base_url", ''),
+ ];
+ }
+ }
+ }
+ }
+
+ return $available;
+ }
+
+ /**
+ * 保存供应商配置.
+ *
+ * @param array $data 配置数据,格式:
+ * [
+ * 'provider_id' => 'zhipu',
+ * 'key' => 'api_key_value',
+ * 'base_url' => 'https://open.bigmodel.cn/api/paas/v4',
+ * ]
+ *
+ * @return bool
+ */
+ public function saveChannelConfig(array $data): bool
+ {
+ $providerId = $data['provider_id'] ?? '';
+ if (empty($providerId)) {
+ return false;
+ }
+
+ $configs = [
+ "ai_provider_{$providerId}_key" => $data['key'] ?? '',
+ "ai_provider_{$providerId}_base_url" => $data['base_url'] ?? '',
+ ];
+
+ if (isset($data['is_default']) && $data['is_default']) {
+ $configs['ai_default_provider'] = $providerId;
+ }
+
+ if (isset($data['default_model'])) {
+ $configs['ai_default_model'] = $data['default_model'];
+ }
+
+ return $this->saveConfigs($configs);
+ }
+
+ /**
+ * 删除供应商配置.
+ *
+ * @param string $providerId 供应商标识
+ *
+ * @return bool
+ */
+ public function deleteChannelConfig(string $providerId): bool
+ {
+ $configs = [
+ "ai_provider_{$providerId}_key" => '',
+ "ai_provider_{$providerId}_base_url" => '',
+ ];
+
+ // 如果删除的是默认供应商,清除默认设置
+ $defaultProvider = get_system_config('ai_default_provider', '');
+ if ($defaultProvider === $providerId) {
+ $configs['ai_default_provider'] = '';
+ }
+
+ return $this->saveConfigs($configs);
+ }
+
+ /**
+ * 批量保存配置到SystemConfig.
+ *
+ * @param array $configs 键值对配置
+ *
+ * @return bool
+ */
+ protected function saveConfigs(array $configs): bool
+ {
+ $list = \app\model\SystemConfig::column('value', 'name');
+
+ foreach ($configs as $key => $value) {
+ if (isset($list[$key])) {
+ \app\model\SystemConfig::where('name', $key)->update(['value' => $value]);
+ } else {
+ $model = new \app\model\SystemConfig();
+ $model->name = $key;
+ $model->value = $value;
+ $model->save();
+ }
+
+ $list[$key] = $value;
+ }
+
+ \think\facade\Cache::set('system_config', $list);
+
+ return true;
+ }
+}
diff --git a/app/common/tools/ai/AiProviderInterface.php b/app/common/tools/ai/AiProviderInterface.php
new file mode 100644
index 0000000..aa59fc3
--- /dev/null
+++ b/app/common/tools/ai/AiProviderInterface.php
@@ -0,0 +1,52 @@
+cachePath = app()->getRuntimePath() . 'ai' . DIRECTORY_SEPARATOR . 'models_dev_cache.json';
+ }
+
+ /**
+ * 同步供应商数据到本地缓存.
+ *
+ * @return array 同步结果统计
+ *
+ * @throws \Exception
+ */
+ public function syncProviders(): array
+ {
+ $data = $this->fetchApi();
+
+ if ($data === false) {
+ throw new \Exception('无法连接 models.dev API');
+ }
+
+ $parsed = $this->parseApiData($data);
+
+ $this->writeCache($parsed);
+
+ return $parsed;
+ }
+
+ /**
+ * 获取所有供应商列表.
+ *
+ * @return array
+ */
+ public function getProviders(): array
+ {
+ $cache = $this->readCache();
+ if ($cache === null) {
+ return [];
+ }
+
+ return $cache['providers'] ?? [];
+ }
+
+ /**
+ * 获取指定供应商的模型列表.
+ *
+ * @param string $providerId 供应商标识
+ *
+ * @return array
+ */
+ public function getModelsByProvider(string $providerId): array
+ {
+ $cache = $this->readCache();
+ if ($cache === null) {
+ return [];
+ }
+
+ $models = $cache['models'][$providerId] ?? [];
+
+ return $models;
+ }
+
+ /**
+ * 获取缓存状态信息.
+ *
+ * @return array
+ */
+ public function getCacheStatus(): array
+ {
+ $exists = file_exists($this->cachePath);
+ $mtime = $exists ? filemtime($this->cachePath) : 0;
+ $expired = ($mtime + $this->cacheTtl) < time();
+
+ return [
+ 'exists' => $exists,
+ 'last_sync' => $mtime > 0 ? date('Y-m-d H:i:s', $mtime) : '从未同步',
+ 'expired' => $expired,
+ 'provider_count' => count($this->getProviders()),
+ ];
+ }
+
+ /**
+ * 从API获取原始数据.
+ *
+ * @return string|false
+ */
+ protected function fetchApi()
+ {
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_URL, $this->apiUrl);
+ curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
+ curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 30);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, [
+ 'Accept: application/json',
+ ]);
+
+ $response = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($httpCode !== 200 || $response === false) {
+ return false;
+ }
+
+ return $response;
+ }
+
+ /**
+ * 解析API数据为统一格式.
+ *
+ * @param string $raw JSON原始数据
+ *
+ * @return array
+ */
+ protected function parseApiData(string $raw): array
+ {
+ $data = json_decode($raw, true);
+ if (!is_array($data)) {
+ return ['providers' => [], 'models' => []];
+ }
+
+ $providers = [];
+ $models = [];
+
+ foreach ($data as $providerId => $providerData) {
+ if (!is_array($providerData)) {
+ continue;
+ }
+
+ $provider = [
+ 'id' => $providerId,
+ 'name' => $providerData['name'] ?? ucfirst($providerId),
+ 'url' => $providerData['url'] ?? '',
+ 'docs_url' => $providerData['docs_url'] ?? '',
+ ];
+
+ // 提取API基础地址(如果有)
+ if (isset($providerData['api_base_url'])) {
+ $provider['api_base_url'] = $providerData['api_base_url'];
+ }
+
+ $providers[$providerId] = $provider;
+
+ // 提取模型列表
+ $providerModels = [];
+ if (isset($providerData['models']) && is_array($providerData['models'])) {
+ foreach ($providerData['models'] as $modelId => $modelData) {
+ $model = [
+ 'id' => $modelId,
+ 'name' => $modelData['name'] ?? $modelId,
+ 'description' => $modelData['description'] ?? '',
+ ];
+
+ if (isset($modelData['context_length'])) {
+ $model['context_length'] = (int) $modelData['context_length'];
+ }
+ if (isset($modelData['pricing'])) {
+ $model['pricing'] = $modelData['pricing'];
+ }
+
+ $providerModels[] = $model;
+ }
+ }
+
+ $models[$providerId] = $providerModels;
+ }
+
+ return [
+ 'providers' => $providers,
+ 'models' => $models,
+ ];
+ }
+
+ /**
+ * 写入缓存文件.
+ *
+ * @param array $data 缓存数据
+ */
+ protected function writeCache(array $data): void
+ {
+ $dir = dirname($this->cachePath);
+ if (!is_dir($dir)) {
+ mkdir($dir, 0755, true);
+ }
+
+ file_put_contents($this->cachePath, json_encode($data, JSON_UNESCAPED_UNICODE));
+ }
+
+ /**
+ * 读取缓存文件(支持过期降级).
+ *
+ * @return array|null
+ */
+ protected function readCache(): ?array
+ {
+ if (!file_exists($this->cachePath)) {
+ return null;
+ }
+
+ $content = file_get_contents($this->cachePath);
+ $data = json_decode($content, true);
+
+ if (!is_array($data)) {
+ return null;
+ }
+
+ return $data;
+ }
+}
diff --git a/app/common/tools/ai/provider/OpenAiCompatibleAdapter.php b/app/common/tools/ai/provider/OpenAiCompatibleAdapter.php
new file mode 100644
index 0000000..11dd9d1
--- /dev/null
+++ b/app/common/tools/ai/provider/OpenAiCompatibleAdapter.php
@@ -0,0 +1,228 @@
+providerId = $providerId;
+ $this->providerName = $providerName ?: ucfirst($providerId);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function chat(string $systemPrompt, string $userPrompt, array $options = []): string
+ {
+ $apiKey = get_system_config("ai_provider_{$this->providerId}_key", '');
+ $baseUrl = get_system_config("ai_provider_{$this->providerId}_base_url", 'https://api.openai.com/v1');
+ $model = $options['model'] ?? get_system_config('ai_default_model', 'gpt-3.5-turbo');
+
+ $url = rtrim($baseUrl, '/') . '/chat/completions';
+ $data = [
+ 'model' => $model,
+ 'messages' => [
+ ['role' => 'system', 'content' => $systemPrompt],
+ ['role' => 'user', 'content' => $userPrompt],
+ ],
+ ];
+
+ if (isset($options['temperature'])) {
+ $data['temperature'] = (float) $options['temperature'];
+ }
+ if (isset($options['max_tokens'])) {
+ $data['max_tokens'] = (int) $options['max_tokens'];
+ }
+
+ $response = $this->httpPost($url, $data, [
+ 'Authorization: Bearer ' . $apiKey,
+ 'Content-Type: application/json',
+ ]);
+
+ if ($response === false) {
+ return '';
+ }
+
+ $result = json_decode($response, true);
+ if (!$result || isset($result['error'])) {
+ return '';
+ }
+
+ return $result['choices'][0]['message']['content'] ?? '';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function chatJson(string $systemPrompt, string $userPrompt, array $options = []): array
+ {
+ $jsonInstruction = "\n\n请严格按照JSON格式返回结果,不要包含任何其他文字说明。";
+ $content = $this->chat($systemPrompt . $jsonInstruction, $userPrompt, $options);
+
+ if (empty($content)) {
+ return [];
+ }
+
+ // 尝试提取JSON内容(可能包裹在markdown代码块中)
+ if (preg_match('/```(?:json)?\s*([\s\S]*?)```/', $content, $matches)) {
+ $content = $matches[1];
+ }
+
+ $decoded = json_decode(trim($content), true);
+
+ return is_array($decoded) ? $decoded : [];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getProviderName(): string
+ {
+ return $this->providerName;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getModels(): array
+ {
+ $apiKey = get_system_config("ai_provider_{$this->providerId}_key", '');
+ $baseUrl = get_system_config("ai_provider_{$this->providerId}_base_url", 'https://api.openai.com/v1');
+
+ if (empty($apiKey)) {
+ return [];
+ }
+
+ $url = rtrim($baseUrl, '/') . '/models';
+ $response = $this->httpGet($url, [
+ 'Authorization: Bearer ' . $apiKey,
+ ]);
+
+ if ($response === false) {
+ return [];
+ }
+
+ $result = json_decode($response, true);
+ if (!$result || !isset($result['data'])) {
+ return [];
+ }
+
+ $models = [];
+ foreach ($result['data'] as $model) {
+ $models[] = [
+ 'id' => $model['id'] ?? '',
+ 'name' => $model['id'] ?? '',
+ 'owned_by' => $model['owned_by'] ?? '',
+ ];
+ }
+
+ return $models;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function testConnection(): bool
+ {
+ $apiKey = get_system_config("ai_provider_{$this->providerId}_key", '');
+ if (empty($apiKey)) {
+ return false;
+ }
+
+ try {
+ $result = $this->chat(
+ 'You are a test assistant.',
+ 'Reply with exactly: OK',
+ ['max_tokens' => 5]
+ );
+
+ return !empty($result);
+ } catch (\Exception $e) {
+ return false;
+ }
+ }
+
+ /**
+ * HTTP POST 请求.
+ *
+ * @param string $url 请求地址
+ * @param array $data 请求数据
+ * @param array $headers 请求头
+ *
+ * @return string|false
+ */
+ protected function httpPost(string $url, array $data, array $headers = [])
+ {
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_URL, $url);
+ curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
+ curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
+ curl_setopt($ch, CURLOPT_POST, 1);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
+ curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 60);
+
+ $response = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($httpCode >= 400) {
+ return false;
+ }
+
+ return $response;
+ }
+
+ /**
+ * HTTP GET 请求.
+ *
+ * @param string $url 请求地址
+ * @param array $headers 请求头
+ *
+ * @return string|false
+ */
+ protected function httpGet(string $url, array $headers = [])
+ {
+ $ch = curl_init();
+ curl_setopt($ch, CURLOPT_URL, $url);
+ curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
+ curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 30);
+
+ $response = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($httpCode >= 400) {
+ return false;
+ }
+
+ return $response;
+ }
+}
diff --git a/app/model/PostOutput.php b/app/model/PostOutput.php
new file mode 100644
index 0000000..b9bace2
--- /dev/null
+++ b/app/model/PostOutput.php
@@ -0,0 +1,62 @@
+ '生成中',
+ 1 => '已完成',
+ 2 => '失败',
+ ];
+
+ public static $autoClearCache = [];
+
+ public function post()
+ {
+ return $this->belongsTo(Post::class, 'post_id');
+ }
+
+ public function files()
+ {
+ return $this->hasMany(PostOutputFile::class, 'output_id');
+ }
+
+ public function getStatusTextAttr()
+ {
+ return self::$statusList[$this->getData('status')] ?? '未知';
+ }
+
+ public function getOutputTypeTextAttr()
+ {
+ $types = config('output_type');
+ $type = $this->getData('output_type');
+
+ return $types[$type]['name'] ?? $type;
+ }
+
+ public static function getByPostAndType(int $postId, string $type)
+ {
+ return static::where('post_id', $postId)
+ ->where('output_type', $type)
+ ->order('id', 'desc')
+ ->select();
+ }
+}
diff --git a/app/model/PostOutputFile.php b/app/model/PostOutputFile.php
new file mode 100644
index 0000000..245fbf6
--- /dev/null
+++ b/app/model/PostOutputFile.php
@@ -0,0 +1,26 @@
+belongsTo(PostOutput::class, 'output_id');
+ }
+
+ public static function getByOutput(int $outputId)
+ {
+ return static::where('output_id', $outputId)
+ ->order('page', 'asc')
+ ->select();
+ }
+}
diff --git a/config/output_type.php b/config/output_type.php
new file mode 100644
index 0000000..0f354d5
--- /dev/null
+++ b/config/output_type.php
@@ -0,0 +1,19 @@
+ [
+ 'name' => '手机图片排版',
+ 'handler' => \app\common\tools\PhoneImage::class,
+ 'description' => '将文章排版为手机尺寸图片,适合小红书/抖音等平台',
+ 'templates' => ['minimal', 'magazine', 'mixed'],
+ 'sizes' => [
+ 'xiaohongshu' => ['name' => '小红书', 'width' => 1080, 'height' => 1440],
+ 'douyin' => ['name' => '抖音', 'width' => 1080, 'height' => 1920],
+ ],
+ 'fonts' => [
+ 'source-han-sans' => ['name' => '思源黑体', 'file' => 'SourceHanSans-Normal.otf'],
+ 'alibaba-puhuiti' => ['name' => '阿里巴巴普惠体', 'file' => 'AlibabaPuHuiTi-3-Regular.ttf'],
+ 'lxgw-wenkai' => ['name' => '霞鹜文楷', 'file' => 'LXGWWenKai-Regular.ttf'],
+ ],
+ ],
+];
diff --git a/database/migrations/20260501000000_create_table_post_output.php b/database/migrations/20260501000000_create_table_post_output.php
new file mode 100644
index 0000000..1c5b02d
--- /dev/null
+++ b/database/migrations/20260501000000_create_table_post_output.php
@@ -0,0 +1,37 @@
+table('post_output', ['comment' => '文章输出任务']);
+ $table->addColumn(ColumnFormat::integer('post_id')->setComment('文章ID'));
+ $table->addColumn(ColumnFormat::stringShort('output_type')->setComment('输出类型'));
+ $table->addColumn(Column::make('config', 'text')->setComment('配置'));
+ $table->addColumn(ColumnFormat::integerTypeStatus('status')->setComment('状态,0:待处理,1:处理中,2:完成,3:失败'));
+ $table->addColumn(ColumnFormat::integer('page_count')->setComment('页数'));
+ $table->addColumn(ColumnFormat::integer('admin_id')->setComment('管理员ID'));
+ $table->addColumn(ColumnFormat::timestamp('create_time'));
+ $table->addColumn(ColumnFormat::timestamp('update_time'));
+ $table->addColumn(ColumnFormat::timestamp('delete_time'));
+ $table->addIndex('post_id');
+ $table->addIndex('status');
+ $table->create();
+
+ $tableFile = $this->table('post_output_file', ['comment' => '文章输出文件']);
+ $tableFile->addColumn(ColumnFormat::integer('output_id')->setComment('输出任务ID'));
+ $tableFile->addColumn(ColumnFormat::integer('page')->setComment('页码'));
+ $tableFile->addColumn(ColumnFormat::stringLong('file_path')->setComment('文件路径'));
+ $tableFile->addColumn(ColumnFormat::stringLong('file_url')->setComment('文件URL'));
+ $tableFile->addColumn(ColumnFormat::integer('file_size')->setComment('文件大小'));
+ $tableFile->addColumn(ColumnFormat::integer('width')->setComment('宽度'));
+ $tableFile->addColumn(ColumnFormat::integer('height')->setComment('高度'));
+ $tableFile->addColumn(ColumnFormat::timestamp('create_time'));
+ $tableFile->addIndex('output_id');
+ $tableFile->create();
+ }
+}
diff --git a/public/static/css/phone-image-fonts.css b/public/static/css/phone-image-fonts.css
new file mode 100644
index 0000000..d9cc2e5
--- /dev/null
+++ b/public/static/css/phone-image-fonts.css
@@ -0,0 +1,23 @@
+@font-face {
+ font-family: 'AlibabaPuHuiTi';
+ src: url('/source/font/AlibabaPuHuiTi-3-Regular.ttf') format('truetype');
+ font-weight: normal;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: 'AlibabaPuHuiTi';
+ src: url('/source/font/AlibabaPuHuiTi-3-Bold.ttf') format('truetype');
+ font-weight: bold;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: 'LXGWWenKai';
+ src: url('/source/font/LXGWWenKai-Regular.ttf') format('truetype');
+ font-weight: normal;
+ font-style: normal;
+ font-display: swap;
+}
diff --git a/public/static/css/phone-image-templates.css b/public/static/css/phone-image-templates.css
new file mode 100644
index 0000000..ccc7ca9
--- /dev/null
+++ b/public/static/css/phone-image-templates.css
@@ -0,0 +1,694 @@
+/* ============================================
+ Phone Image Template System
+ 用于生成手机端分享图片的CSS框架
+ 渲染宽度540px, html2canvas scale=2 输出1080px
+ 注意: 仅使用html2canvas兼容的CSS属性
+ 支持: flexbox, border-radius, box-shadow, background-color,
+ font, padding, margin, border, position(relative/absolute/fixed)
+ 不支持: transform, filter, clip-path, backdrop-filter,
+ CSS grid, object-fit, position:sticky, CSS animations
+ ============================================ */
+
+/* --- CSS Custom Properties --- */
+:root {
+ /* 字号 */
+ --pi-font-size-base: 14px;
+ --pi-font-size-title: 24px;
+ --pi-font-size-subtitle: 16px;
+ --pi-font-size-small: 12px;
+ /* 行高 */
+ --pi-line-height: 1.8;
+ /* 间距 */
+ --pi-spacing: 20px;
+ --pi-spacing-sm: 10px;
+ --pi-spacing-lg: 30px;
+ /* 颜色 */
+ --pi-color-text: #333333;
+ --pi-color-text-light: #666666;
+ --pi-color-text-lighter: #999999;
+ --pi-color-bg: #ffffff;
+ --pi-color-accent: #1890ff;
+ --pi-color-border: #eeeeee;
+ /* 字体 */
+ --pi-font-family: 'Source Han Sans', 'SourceHanSans-Normal', sans-serif;
+}
+
+/* ============================================
+ Base Container
+ 固定540px宽度的最外层容器
+ ============================================ */
+.phone-image-container {
+ width: 540px;
+ background: var(--pi-color-bg);
+ color: var(--pi-color-text);
+ font-family: var(--pi-font-family);
+ font-size: var(--pi-font-size-base);
+ line-height: var(--pi-line-height);
+ overflow: hidden;
+ position: relative;
+}
+
+/* ============================================
+ Page Container
+ 每一页的容器, 尺寸由 size class 控制
+ ============================================ */
+.phone-image-page {
+ overflow: hidden;
+ box-sizing: border-box;
+ position: relative;
+}
+
+/* ============================================
+ Size Variants
+ 两种主流手机图片尺寸
+ ============================================ */
+
+/* 小红书尺寸 540x720 (输出1080x1440) */
+.size-xiaohongshu .phone-image-page {
+ width: 540px;
+ height: 720px;
+}
+
+/* 抖音尺寸 540x960 (输出1080x1920) */
+.size-douyin .phone-image-page {
+ width: 540px;
+ height: 960px;
+}
+
+/* ============================================
+ Page Type Variants
+ 三种页面类型: 封面页/内容页/总结页
+ ============================================ */
+
+/* --- 封面页/首页 --- */
+.page-cover {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ padding: var(--pi-spacing-lg);
+ text-align: center;
+}
+
+.page-cover .cover-title {
+ font-size: 32px;
+ font-weight: bold;
+ line-height: 1.4;
+ margin-bottom: var(--pi-spacing);
+ color: var(--pi-color-text);
+ word-break: break-word;
+}
+
+.page-cover .cover-subtitle {
+ font-size: var(--pi-font-size-subtitle);
+ color: var(--pi-color-text-light);
+ margin-bottom: var(--pi-spacing);
+}
+
+.page-cover .cover-image {
+ width: 100%;
+ margin-bottom: var(--pi-spacing);
+}
+
+.page-cover .cover-meta {
+ font-size: var(--pi-font-size-small);
+ color: var(--pi-color-text-lighter);
+ margin-top: var(--pi-spacing-sm);
+}
+
+/* --- 内容页 --- */
+.page-body {
+ display: flex;
+ flex-direction: column;
+ padding: var(--pi-spacing);
+}
+
+/* 内容页 - 页头区域 */
+.page-header {
+ padding-bottom: var(--pi-spacing-sm);
+ margin-bottom: var(--pi-spacing);
+ border-bottom: 1px solid var(--pi-color-border);
+}
+
+.page-header .page-title {
+ font-size: var(--pi-font-size-title);
+ font-weight: bold;
+ line-height: 1.4;
+ color: var(--pi-color-text);
+ word-break: break-word;
+}
+
+.page-header .page-subtitle {
+ font-size: var(--pi-font-size-subtitle);
+ color: var(--pi-color-text-light);
+ margin-top: var(--pi-spacing-sm);
+}
+
+/* 内容页 - 正文区域 */
+.page-content {
+ flex: 1;
+ overflow: hidden;
+ word-break: break-word;
+}
+
+.page-content p {
+ margin-bottom: var(--pi-spacing-sm);
+ text-indent: 2em;
+}
+
+.page-content h2 {
+ font-size: 20px;
+ font-weight: bold;
+ margin-top: var(--pi-spacing);
+ margin-bottom: var(--pi-spacing-sm);
+}
+
+.page-content h3 {
+ font-size: var(--pi-font-size-subtitle);
+ font-weight: bold;
+ margin-top: var(--pi-spacing-sm);
+ margin-bottom: var(--pi-spacing-sm);
+}
+
+.page-content blockquote {
+ border-left: 3px solid var(--pi-color-accent);
+ padding-left: var(--pi-spacing-sm);
+ margin: var(--pi-spacing-sm) 0;
+ color: var(--pi-color-text-light);
+ font-style: italic;
+}
+
+.page-content ul,
+.page-content ol {
+ padding-left: var(--pi-spacing);
+ margin-bottom: var(--pi-spacing-sm);
+}
+
+.page-content li {
+ margin-bottom: 5px;
+}
+
+/* 内容页 - 页脚区域 */
+.page-footer {
+ padding-top: var(--pi-spacing-sm);
+ margin-top: var(--pi-spacing-sm);
+ border-top: 1px solid var(--pi-color-border);
+ font-size: var(--pi-font-size-small);
+ color: var(--pi-color-text-lighter);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+/* --- 总结页/尾页 --- */
+.page-summary {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ padding: var(--pi-spacing-lg);
+ text-align: center;
+}
+
+.page-summary .summary-title {
+ font-size: var(--pi-font-size-title);
+ font-weight: bold;
+ margin-bottom: var(--pi-spacing);
+ color: var(--pi-color-text);
+}
+
+.page-summary .summary-text {
+ font-size: var(--pi-font-size-subtitle);
+ color: var(--pi-color-text-light);
+ margin-bottom: var(--pi-spacing);
+ line-height: 1.6;
+}
+
+.page-summary .summary-qr {
+ margin-bottom: var(--pi-spacing-sm);
+}
+
+.page-summary .summary-footer {
+ font-size: var(--pi-font-size-small);
+ color: var(--pi-color-text-lighter);
+}
+
+/* ============================================
+ Font Variants
+ 三种可选字体
+ ============================================ */
+.font-source-han-sans {
+ --pi-font-family: 'Source Han Sans', 'SourceHanSans-Normal', sans-serif;
+}
+
+.font-alibaba-puhuiti {
+ --pi-font-family: 'AlibabaPuHuiTi', sans-serif;
+}
+
+.font-lxgw-wenkai {
+ --pi-font-family: 'LXGWWenKai', cursive;
+}
+
+/* ============================================
+ Article Images
+ 文章内图片样式
+ ============================================ */
+
+/* 行内图片, 有边距 */
+.article-image {
+ max-width: 100%;
+ height: auto;
+ display: block;
+ margin: var(--pi-spacing) 0;
+}
+
+/* 全宽图片, 无边距 */
+.article-image-full {
+ width: 100%;
+ height: auto;
+ display: block;
+ margin: 0;
+}
+
+/* ============================================
+ Page Number
+ 页码指示器
+ ============================================ */
+.page-number {
+ position: absolute;
+ bottom: 10px;
+ right: 15px;
+ font-size: var(--pi-font-size-small);
+ color: var(--pi-color-text-lighter);
+}
+
+/* ============================================
+ Template Namespaces
+ 三个模板命名空间, 基础结构样式
+ 后续Task 8-10会填充详细样式
+ ============================================ */
+
+/* --- 简约文字卡片模板 --- */
+.tpl-minimal {
+ /* 基础结构: 白底黑字, 大量留白, 现代简约风格 */
+}
+
+/* 封面页 - 居中大标题 + 副标题 + 日期 */
+.tpl-minimal .page-cover .cover-title {
+ font-size: 28px;
+ font-weight: normal;
+ letter-spacing: 2px;
+ line-height: 1.5;
+}
+
+.tpl-minimal .page-cover .cover-subtitle {
+ font-weight: normal;
+ letter-spacing: 1px;
+ color: #999;
+}
+
+.tpl-minimal .page-cover .cover-meta {
+ letter-spacing: 1px;
+}
+
+/* 内容页 - 页头区域 */
+.tpl-minimal .page-header {
+ border-bottom: none;
+ padding-bottom: 0;
+ margin-bottom: 20px;
+}
+
+.tpl-minimal .page-header .page-title {
+ font-size: 20px;
+ font-weight: normal;
+ letter-spacing: 1px;
+ color: #666;
+}
+
+/* 内容页 - 正文区域: 宽行高(2.0), 大段落间距, 首行缩进2em */
+.tpl-minimal .page-content {
+ line-height: 2.0;
+}
+
+.tpl-minimal .page-content p {
+ text-indent: 2em;
+ margin-bottom: 16px;
+ color: #333;
+}
+
+.tpl-minimal .page-content h2 {
+ font-weight: normal;
+ font-size: 18px;
+ letter-spacing: 1px;
+ margin-top: 24px;
+ margin-bottom: 12px;
+ color: #333;
+}
+
+.tpl-minimal .page-content h3 {
+ font-weight: normal;
+ font-size: 16px;
+ margin-top: 20px;
+ margin-bottom: 10px;
+ color: #555;
+}
+
+.tpl-minimal .page-content blockquote {
+ border-left: 1px solid #ccc;
+ font-style: normal;
+ color: #888;
+ padding-left: 15px;
+}
+
+.tpl-minimal .page-content .article-image {
+ border-radius: 4px;
+}
+
+.tpl-minimal .page-content .article-image-full {
+ border-radius: 0;
+}
+
+/* 内容页 - 页脚区域: 小号版权 + 页码, 细线分隔 */
+.tpl-minimal .page-footer {
+ border-top: none;
+ padding-top: 0;
+ font-size: 11px;
+ color: #bbb;
+}
+
+/* 总结页/尾页 */
+.tpl-minimal .page-summary .summary-title {
+ font-weight: normal;
+ letter-spacing: 2px;
+ font-size: 22px;
+}
+
+.tpl-minimal .page-summary .summary-text {
+ color: #aaa;
+ font-weight: normal;
+}
+
+.tpl-minimal .page-summary .summary-footer {
+ color: #ccc;
+}
+
+/* --- 杂志模板 - 完整样式 --- */
+.tpl-magazine .page-cover {
+ padding: 0;
+ justify-content: flex-end;
+ padding-bottom: 40px;
+ position: relative;
+}
+
+.tpl-magazine .page-cover::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 4px;
+ background: #1890ff;
+}
+
+.tpl-magazine .page-cover .cover-title {
+ font-size: 26px;
+ font-weight: bold;
+ line-height: 1.4;
+ letter-spacing: 1px;
+ position: relative;
+ padding-bottom: 15px;
+}
+
+.tpl-magazine .page-cover .cover-subtitle {
+ font-weight: normal;
+ color: #666;
+ letter-spacing: 0.5px;
+}
+
+.tpl-magazine .page-cover .cover-meta {
+ position: relative;
+ padding: 8px 12px;
+ background: #f5f5f5;
+ border-radius: 2px;
+}
+
+.tpl-magazine .page-header {
+ border-bottom: 2px solid #1890ff;
+ padding-bottom: 10px;
+ margin-bottom: 15px;
+}
+
+.tpl-magazine .page-header .page-title {
+ font-size: 22px;
+ font-weight: bold;
+ letter-spacing: 0.5px;
+}
+
+.tpl-magazine .page-content {
+ line-height: 1.8;
+}
+
+.tpl-magazine .page-content p {
+ margin-bottom: 14px;
+ text-indent: 2em;
+ color: #333;
+}
+
+.tpl-magazine .page-content p:first-child {
+ text-indent: 0;
+}
+
+.tpl-magazine .page-content p:first-child::first-letter {
+ font-size: 3.2em;
+ font-weight: bold;
+ float: left;
+ line-height: 1;
+ margin-right: 8px;
+ margin-top: 4px;
+ color: #1890ff;
+}
+
+.tpl-magazine .page-content h2 {
+ font-size: 20px;
+ font-weight: bold;
+ margin-top: 22px;
+ margin-bottom: 10px;
+ padding-left: 10px;
+ border-left: 3px solid #1890ff;
+}
+
+.tpl-magazine .page-content h3 {
+ font-size: 17px;
+ font-weight: bold;
+ margin-top: 16px;
+ margin-bottom: 8px;
+ color: #1890ff;
+}
+
+.tpl-magazine .page-content blockquote {
+ border-left: 4px solid #1890ff;
+ background: #f8f9fa;
+ padding: 12px 15px;
+ margin: 12px 0;
+ font-style: normal;
+ color: #555;
+ border-radius: 0 4px 4px 0;
+}
+
+.tpl-magazine .page-content .article-image {
+ border-radius: 2px;
+}
+
+.tpl-magazine .page-footer {
+ border-top: 2px solid #1890ff;
+ padding-top: 8px;
+ color: #1890ff;
+ font-weight: bold;
+}
+
+.tpl-magazine .page-summary {
+ background: #f8f9fa;
+}
+
+.tpl-magazine .page-summary .summary-title {
+ color: #1890ff;
+ font-size: 24px;
+ font-weight: bold;
+}
+
+.tpl-magazine .page-summary .summary-text {
+ color: #666;
+}
+
+.tpl-magazine .page-summary .summary-footer {
+ border-top: 1px solid #ddd;
+ padding-top: 15px;
+ color: #999;
+}
+
+/* --- 图文混排模板 - 完整样式 --- */
+.tpl-mixed {
+ /* 基础结构: 图文混排 */
+}
+
+/* 封面页: 图片背景 + 底部文字叠加 */
+.tpl-mixed .page-cover {
+ justify-content: flex-end;
+ padding: 0;
+ padding-bottom: 30px;
+ position: relative;
+}
+
+.tpl-mixed .page-cover .cover-image {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+}
+
+.tpl-mixed .page-cover .cover-title {
+ position: relative;
+ z-index: 2;
+ font-size: 24px;
+ font-weight: bold;
+ line-height: 1.4;
+ color: #fff;
+ background: rgba(0, 0, 0, 0.5);
+ padding: 15px 20px;
+ width: 100%;
+ box-sizing: border-box;
+ text-align: left;
+}
+
+.tpl-mixed .page-cover .cover-subtitle {
+ position: relative;
+ z-index: 2;
+ color: #eee;
+ background: rgba(0, 0, 0, 0.3);
+ padding: 8px 20px;
+ width: 100%;
+ box-sizing: border-box;
+ text-align: left;
+ margin-bottom: 0;
+}
+
+.tpl-mixed .page-cover .cover-meta {
+ position: relative;
+ z-index: 2;
+ color: #ccc;
+ padding: 5px 20px;
+ background: rgba(0, 0, 0, 0.3);
+ width: 100%;
+ box-sizing: border-box;
+ text-align: left;
+}
+
+/* 无封面图时的降级样式 */
+.tpl-mixed .page-cover.no-cover-image {
+ justify-content: center;
+ background: #333;
+}
+
+.tpl-mixed .page-cover.no-cover-image .cover-title {
+ text-align: center;
+ background: none;
+ font-size: 26px;
+}
+
+.tpl-mixed .page-cover.no-cover-image .cover-subtitle {
+ text-align: center;
+ background: none;
+}
+
+.tpl-mixed .page-cover.no-cover-image .cover-meta {
+ text-align: center;
+ background: none;
+}
+
+/* 内容页头部 */
+.tpl-mixed .page-header {
+ border-bottom: 2px solid #333;
+ padding-bottom: 8px;
+}
+
+.tpl-mixed .page-header .page-title {
+ font-size: 20px;
+ font-weight: bold;
+}
+
+/* 内容页正文 */
+.tpl-mixed .page-content {
+ line-height: 1.8;
+}
+
+.tpl-mixed .page-content p {
+ margin-bottom: 12px;
+ text-indent: 2em;
+ color: #333;
+}
+
+.tpl-mixed .page-content h2 {
+ font-size: 19px;
+ font-weight: bold;
+ margin-top: 18px;
+ margin-bottom: 8px;
+ padding: 6px 12px;
+ background: #f0f0f0;
+ border-left: 4px solid #333;
+}
+
+.tpl-mixed .page-content h3 {
+ font-size: 16px;
+ font-weight: bold;
+ margin-top: 14px;
+ margin-bottom: 6px;
+ color: #555;
+}
+
+.tpl-mixed .page-content blockquote {
+ border-left: 3px solid #333;
+ background: #f8f8f8;
+ padding: 10px 15px;
+ font-style: normal;
+ color: #666;
+}
+
+.tpl-mixed .page-content .article-image {
+ border-radius: 0;
+ margin: 15px 0;
+}
+
+.tpl-mixed .page-content .article-image-full {
+ margin: 0 -20px;
+ max-width: none;
+ width: 540px;
+}
+
+/* 内容页底部 */
+.tpl-mixed .page-footer {
+ border-top: 1px solid #333;
+ color: #333;
+ font-weight: bold;
+}
+
+/* 总结页 */
+.tpl-mixed .page-summary {
+ background: #f5f5f5;
+}
+
+.tpl-mixed .page-summary .summary-title {
+ font-size: 28px;
+ font-weight: bold;
+ color: #333;
+}
+
+.tpl-mixed .page-summary .summary-text {
+ color: #666;
+}
+
+.tpl-mixed .page-summary .summary-footer {
+ color: #999;
+ border-top: 1px solid #ddd;
+ padding-top: 15px;
+}
diff --git a/public/static/js/phone-image.js b/public/static/js/phone-image.js
new file mode 100644
index 0000000..e9e8ff4
--- /dev/null
+++ b/public/static/js/phone-image.js
@@ -0,0 +1,736 @@
+/**
+ * PhoneImageEngine - 手机图片排版引擎
+ *
+ * 将文章HTML内容自动分页并渲染为手机尺寸图片
+ * 依赖: jQuery, html2canvas (项目已有)
+ *
+ * 渲染宽度540px, html2canvas scale=2 输出1080px
+ * 小红书: 540x720 (输出1080x1440)
+ * 抖音: 540x960 (输出1080x1920)
+ */
+var PhoneImageEngine = (function () {
+
+ // ===== 配置 =====
+ var config = {
+ template: 'minimal',
+ size: 'xiaohongshu',
+ font: 'source-han-sans',
+ fontSize: 14,
+ watermark: '',
+ // 尺寸映射 (渲染尺寸, 实际输出是2倍)
+ sizes: {
+ xiaohongshu: { width: 540, height: 720 },
+ douyin: { width: 540, height: 960 }
+ },
+ // 字体映射
+ fonts: {
+ 'source-han-sans': "'Source Han Sans', 'SourceHanSans-Normal', sans-serif",
+ 'alibaba-puhuiti': "'AlibabaPuHuiTi', sans-serif",
+ 'lxgw-wenkai': "'LXGWWenKai', cursive"
+ },
+ // 页面内容边距
+ contentPadding: 20
+ };
+
+ // ===== 文章数据 =====
+ var postData = {
+ id: 0,
+ title: '',
+ desc: '',
+ content_html: '',
+ poster: '',
+ author_name: '',
+ create_time: '',
+ category_name: ''
+ };
+
+ // ===== 分页结果 =====
+ var pages = [];
+
+ // ===== DOM引用 =====
+ var $container = null;
+
+ /**
+ * 初始化引擎
+ * @param {Object} options - {postId, title, desc, contentHtml, poster, authorName, createTime, categoryName}
+ * @param {Object} userConfig - {template, size, font, fontSize, watermark}
+ */
+ function init(options, userConfig) {
+ postData.id = options.postId || 0;
+ postData.title = options.title || '';
+ postData.desc = options.desc || '';
+ postData.content_html = options.contentHtml || '';
+ postData.poster = options.poster || '';
+ postData.author_name = options.authorName || '';
+ postData.create_time = options.createTime || '';
+ postData.category_name = options.categoryName || '';
+
+ if (userConfig) {
+ $.extend(config, userConfig);
+ }
+
+ $container = $('#phone-image-container');
+ }
+
+ /**
+ * 渲染排版预览 - 生成分页后的HTML
+ * @returns {Array} 页面数组,每项是 { type, html, pageNum }
+ */
+ function render() {
+ pages = [];
+ var sizeConfig = config.sizes[config.size] || config.sizes.xiaohongshu;
+ var pageHeight = sizeConfig.height;
+ var contentAreaHeight = pageHeight - (config.contentPadding * 2);
+
+ // 内容预处理
+ var cleanHtml = preprocessContent(postData.content_html);
+
+ // 解析为块级元素
+ var blocks = parseHtmlToBlocks(cleanHtml);
+
+ // 生成封面页
+ pages.push(generateCoverPage(sizeConfig));
+
+ // 内容分页
+ var contentPages = paginateContent(blocks, contentAreaHeight, sizeConfig);
+ pages = pages.concat(contentPages);
+
+ // 生成尾页
+ pages.push(generateSummaryPage(sizeConfig, pages.length));
+
+ // 渲染到DOM
+ renderToDOM(sizeConfig);
+
+ return pages;
+ }
+
+ /**
+ * 预处理HTML内容
+ */
+ function preprocessContent(html) {
+ if (!html) return '';
+ html = html.replace(/
+
+
+
+
+