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 ?? '', '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(/]*>[\s\S]*?<\/script>/gi, ''); + html = html.replace(/]*>[\s\S]*?<\/style>/gi, ''); + // 清除空段落 + html = html.replace(/]*>\s*<\/p>/gi, ''); + return html; + } + + /** + * 将HTML解析为块级元素数组 + * 每个块: { type, html, estimatedHeight } + */ + function parseHtmlToBlocks(html) { + var blocks = []; + if (!html) return blocks; + + var $temp = $('
').html(html); + var children = $temp.children(); + + if (children.length === 0) { + // 纯文本内容,按段落分割 + var text = $temp.text().trim(); + if (text) { + var paragraphs = text.split(/\n\s*\n|\n/); + for (var i = 0; i < paragraphs.length; i++) { + var p = paragraphs[i].trim(); + if (p) { + blocks.push({ + type: 'p', + html: '

' + p + '

', + estimatedHeight: estimateTextHeight(p, config.fontSize) + }); + } + } + } + return blocks; + } + + children.each(function () { + var $el = $(this); + var tagName = $el.prop('tagName').toLowerCase(); + var block = { + type: tagName, + html: $el[0].outerHTML + }; + + switch (tagName) { + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': + block.estimatedHeight = estimateHeadingHeight(tagName, $el.text()); + break; + case 'img': + block.estimatedHeight = estimateImageHeight($el); + block.$img = $el; + break; + case 'ul': + case 'ol': + block.estimatedHeight = estimateListHeight($el); + break; + case 'blockquote': + block.estimatedHeight = estimateBlockquoteHeight($el); + break; + case 'p': + default: + block.type = 'p'; + block.estimatedHeight = estimateTextHeight($el.text(), config.fontSize); + break; + } + + blocks.push(block); + }); + + return blocks; + } + + // ===== 高度估算 ===== + + /** + * 获取内容区可用宽度 (540 - 40px padding) + */ + function getContentWidth() { + return 500; + } + + /** + * 估算文本高度 + * @param {string} text + * @param {number} fontSize + * @returns {number} px + */ + function estimateTextHeight(text, fontSize) { + var charCount = text.length; + if (charCount === 0) return 0; + var charsPerLine = Math.floor(getContentWidth() / (fontSize * 1.0)); + if (charsPerLine < 1) charsPerLine = 1; + var lines = Math.ceil(charCount / charsPerLine); + return lines * fontSize * 1.8 + 10; // 1.8行高 + 10px段落间距 + } + + /** + * 估算标题高度 + */ + function estimateHeadingHeight(tag, text) { + var sizeMap = { h1: 28, h2: 24, h3: 20, h4: 18, h5: 16, h6: 14 }; + var hSize = sizeMap[tag] || 20; + var charsPerLine = Math.floor(getContentWidth() / (hSize * 1.0)); + if (charsPerLine < 1) charsPerLine = 1; + var lines = Math.ceil(text.length / charsPerLine); + return lines * hSize * 1.4 + 20; // heading行高1.4 + 20px上下间距 + } + + /** + * 估算图片高度 + * 默认按内容区宽度的60%估算 + */ + function estimateImageHeight($img) { + var w = parseInt($img.attr('width'), 10); + var h = parseInt($img.attr('height'), 10); + if (w > 0 && h > 0) { + // 按比例缩放到内容宽度 + var ratio = getContentWidth() / w; + return Math.round(h * ratio) + 10; + } + // 无尺寸信息时默认300px + return 310; + } + + /** + * 估算列表高度 + */ + function estimateListHeight($list) { + var items = $list.find('li').length; + return items * (config.fontSize * 1.8 + 5) + 15; + } + + /** + * 估算引用块高度 + */ + function estimateBlockquoteHeight($bq) { + var text = $bq.text(); + return estimateTextHeight(text, config.fontSize) + 10; + } + + // ===== 分页核心算法 ===== + + /** + * 将块级元素按高度累加,超过可用高度时分页 + * 支持超大块拆分(超长文本拆成多段) + */ + function paginateContent(blocks, contentAreaHeight, sizeConfig) { + var contentPages = []; + var currentPageBlocks = []; + var currentHeight = 0; + var pageNumber = 1; + + for (var i = 0; i < blocks.length; i++) { + var block = blocks[i]; + + // 超大块处理:单个块超过整页高度时需要拆分 + if (block.estimatedHeight > contentAreaHeight) { + // 先把当前页已有的内容推出去 + if (currentPageBlocks.length > 0) { + contentPages.push(generateContentPage( + currentPageBlocks, pageNumber, sizeConfig, false + )); + currentPageBlocks = []; + currentHeight = 0; + pageNumber++; + } + + // 尝试拆分超大块 + var splitBlocks = splitOversizedBlock(block, contentAreaHeight); + for (var s = 0; s < splitBlocks.length; s++) { + var sb = splitBlocks[s]; + if (currentHeight + sb.estimatedHeight > contentAreaHeight && currentPageBlocks.length > 0) { + contentPages.push(generateContentPage( + currentPageBlocks, pageNumber, sizeConfig, false + )); + currentPageBlocks = []; + currentHeight = 0; + pageNumber++; + } + currentPageBlocks.push(sb); + currentHeight += sb.estimatedHeight; + } + continue; + } + + // 正常块:判断是否需要换页 + if (currentHeight + block.estimatedHeight > contentAreaHeight && currentPageBlocks.length > 0) { + contentPages.push(generateContentPage( + currentPageBlocks, pageNumber, sizeConfig, false + )); + currentPageBlocks = []; + currentHeight = 0; + pageNumber++; + } + + currentPageBlocks.push(block); + currentHeight += block.estimatedHeight; + } + + // 最后一页 + if (currentPageBlocks.length > 0) { + contentPages.push(generateContentPage( + currentPageBlocks, pageNumber, sizeConfig, true + )); + } + + return contentPages; + } + + /** + * 拆分超大块(超长段落文本拆成多段) + * @param {Object} block - 块级元素 + * @param {number} pageHeight - 可用内容高度 + * @returns {Array} 拆分后的块数组 + */ + function splitOversizedBlock(block, pageHeight) { + // 图片块不拆分,保留原样(会被截断但保持完整) + if (block.type === 'img') { + return [block]; + } + + // 文本类块:按句子拆分 + var text = ''; + var wrapperTag = 'p'; + if (block.type === 'blockquote') { + text = $(block.html).text(); + wrapperTag = 'blockquote'; + } else { + text = $(block.html).text(); + } + + if (!text || text.length === 0) { + return [block]; + } + + // 按句子拆分:句号、问号、叹号、换行 + var sentences = text.split(/(?<=[。!?\n])/); + if (sentences.length <= 1) { + // 无法按句子拆分,按固定字符数拆分 + sentences = []; + var chunkSize = Math.floor(getContentWidth() / config.fontSize) * + Math.floor(pageHeight / (config.fontSize * 1.8)); + if (chunkSize < 10) chunkSize = 10; + for (var ci = 0; ci < text.length; ci += chunkSize) { + sentences.push(text.substring(ci, ci + chunkSize)); + } + } + + var result = []; + for (var j = 0; j < sentences.length; j++) { + var s = sentences[j].trim(); + if (!s) continue; + var h = estimateTextHeight(s, config.fontSize); + result.push({ + type: wrapperTag === 'blockquote' ? 'blockquote' : 'p', + html: '<' + wrapperTag + '>' + s + '', + estimatedHeight: h + }); + } + + return result.length > 0 ? result : [block]; + } + + // ===== 页面HTML生成 ===== + + /** + * 生成封面页HTML + */ + function generateCoverPage(sizeConfig) { + var html = '
'; + + if (postData.poster) { + html += ''; + } + + html += '
' + escapeHtml(postData.title) + '
'; + + if (postData.desc) { + html += '
' + escapeHtml(postData.desc) + '
'; + } + + html += '
'; + if (postData.category_name) { + html += escapeHtml(postData.category_name) + ' | '; + } + if (postData.author_name) { + html += escapeHtml(postData.author_name) + ' | '; + } + if (postData.create_time) { + html += postData.create_time; + } + html += '
'; + html += '
'; + + return { type: 'cover', html: html }; + } + + /** + * 生成内容页HTML + */ + function generateContentPage(blocks, pageNum, sizeConfig, isLast) { + var html = '
'; + + // 页头(仅首页内容页显示标题) + if (pageNum === 1) { + html += ''; + } + + // 正文内容区 + html += '
'; + for (var i = 0; i < blocks.length; i++) { + html += blocks[i].html; + } + html += '
'; + + // 页脚 + html += ''; + + html += '
'; + return { type: 'content', html: html, pageNum: pageNum }; + } + + /** + * 生成尾页/总结页HTML + */ + function generateSummaryPage(sizeConfig, totalPages) { + var html = '
'; + html += '
感谢阅读
'; + html += '
' + escapeHtml(postData.title) + '
'; + + if (postData.desc) { + html += '
' + escapeHtml(postData.desc) + '
'; + } + + html += ''; + html += '
'; + + return { type: 'summary', html: html }; + } + + // ===== DOM渲染 ===== + + /** + * 渲染分页结果到DOM + */ + function renderToDOM(sizeConfig) { + if (!$container || !$container.length) return; + + var containerClasses = [ + 'phone-image-container', + 'tpl-' + config.template, + 'size-' + config.size, + 'font-' + config.font + ]; + + $container.attr('class', containerClasses.join(' ')); + $container.css({ + 'font-family': config.fonts[config.font] || config.fonts['source-han-sans'], + 'font-size': config.fontSize + 'px' + }); + + $container.empty(); + for (var i = 0; i < pages.length; i++) { + $container.append(pages[i].html); + } + } + + // ===== 图片生成 ===== + + /** + * 逐页截图生成图片 + * @param {Function} [onProgress] 进度回调 function(currentIndex, totalPages, canvas) + * @returns {Promise} jQuery Deferred, resolves with array of canvas objects + */ + function generateImages(onProgress) { + var deferred = $.Deferred(); + var canvases = []; + var $pages = $container.find('.phone-image-page'); + + if ($pages.length === 0) { + deferred.reject('没有可渲染的页面'); + return deferred.promise(); + } + + var index = 0; + var total = $pages.length; + + function captureNext() { + if (index >= total) { + if (onProgress) onProgress(total, total, null); + deferred.resolve(canvases); + return; + } + + var $page = $($pages[index]); + + html2canvas($page[0], { + scale: 2, + useCORS: true, + backgroundColor: '#ffffff', + width: $page.outerWidth(), + height: $page.outerHeight(), + logging: false + }).then(function (canvas) { + canvases.push(canvas); + index++; + if (onProgress) onProgress(index, total, canvas); + captureNext(); + }).catch(function (err) { + deferred.reject('截图失败(第' + (index + 1) + '页): ' + err); + }); + } + + if (onProgress) onProgress(0, total, null); + captureNext(); + return deferred.promise(); + } + + /** + * 将canvas转为base64并保存到服务端 + * @param {number} postId 文章ID + * @param {Object} saveConfig 配置信息 + * @param {Function} [onProgress] 生成进度回调 function(current, total, canvas) + * @returns {Promise} jQuery Deferred + */ + function saveImages(postId, saveConfig, onProgress) { + var deferred = $.Deferred(); + + generateImages(onProgress).then(function (canvases) { + var pagesData = []; + for (var i = 0; i < canvases.length; i++) { + pagesData.push(canvases[i].toDataURL('image/jpeg', 0.92)); + } + + $.ajax({ + url: '/index.php/admin/post/savePostOutput', + type: 'POST', + data: JSON.stringify({ + post_id: postId, + output_type: 'phone_image', + config: saveConfig || config, + pages: pagesData + }), + contentType: 'application/json', + success: function (result) { + if (result.code === 0) { + deferred.resolve(result.data); + } else { + deferred.reject(result.msg || '保存失败'); + } + }, + error: function (xhr) { + deferred.reject('网络错误: ' + xhr.statusText); + } + }); + }).catch(function (err) { + deferred.reject(err); + }); + + return deferred.promise(); + } + + /** + * 将生成的canvas数组显示为缩略图 + * @param {Array} canvases canvas对象数组 + * @param {string} containerSelector 容器选择器 + */ + function showGeneratedThumbnails(canvases, containerSelector) { + var $container = $(containerSelector); + $container.empty(); + + for (var i = 0; i < canvases.length; i++) { + var thumb = canvases[i].toDataURL('image/jpeg', 0.5); + var $item = $('
' + + '第' + (i + 1) + '页' + + '' + (i + 1) + '' + + '
'); + $container.append($item); + } + } + + // ===== 配置切换 ===== + + /** + * 切换模板 + */ + function switchTemplate(name) { + config.template = name; + if ($container) { + $container.removeClass('tpl-minimal tpl-magazine tpl-mixed').addClass('tpl-' + name); + } + } + + /** + * 切换尺寸 + */ + function switchSize(name) { + config.size = name; + } + + /** + * 切换字体 + */ + function switchFont(name) { + config.font = name; + if ($container) { + $container.removeClass('font-source-han-sans font-alibaba-puhuiti font-lxgw-wenkai').addClass('font-' + name); + $container.css('font-family', config.fonts[name] || config.fonts['source-han-sans']); + } + } + + // ===== 工具方法 ===== + + /** + * HTML转义 + */ + function escapeHtml(text) { + if (!text) return ''; + var div = document.createElement('div'); + div.appendChild(document.createTextNode(text)); + return div.innerHTML; + } + + /** + * 获取当前配置 + */ + function getConfig() { + return $.extend({}, config); + } + + /** + * 获取分页结果 + */ + function getPages() { + return pages.slice(); + } + + // ===== AI 智能排版 ===== + + /** + * 应用AI推荐配置 + */ + function applyAiRecommendation(recommendation) { + if (recommendation.template) { + switchTemplate(recommendation.template); + $('.template-btn').removeClass('active'); + $('.template-btn[data-template="' + recommendation.template + '"]').addClass('active'); + $('[name="size"]').val(config.size); + } + if (recommendation.font) { + switchFont(recommendation.font); + $('[name="font"]').val(recommendation.font); + } + if (recommendation.font_size) { + config.fontSize = parseInt(recommendation.font_size); + $('[name="fontSize"]').val(config.fontSize); + $('#fontSizeValue').text(config.fontSize + 'px'); + } + } + + /** + * 应用AI优化内容(替换预览用的内容) + */ + var originalContentHtml = ''; + function applyAiContent(optimizedContent) { + if (!originalContentHtml) { + originalContentHtml = postData.content_html; + } + if (optimizedContent.optimized_paragraphs) { + var newHtml = ''; + for (var i = 0; i < optimizedContent.optimized_paragraphs.length; i++) { + newHtml += '

' + optimizedContent.optimized_paragraphs[i] + '

'; + } + postData.content_html = newHtml; + postData.title = optimizedContent.optimized_title || postData.title; + } + } + + /** + * 恢复原始内容 + */ + function restoreOriginalContent() { + if (originalContentHtml) { + postData.content_html = originalContentHtml; + originalContentHtml = ''; + } + } + + // ===== 公开API ===== + return { + init: init, + render: render, + paginate: render, + generateImages: generateImages, + saveImages: saveImages, + showGeneratedThumbnails: showGeneratedThumbnails, + switchTemplate: switchTemplate, + switchSize: switchSize, + switchFont: switchFont, + getConfig: getConfig, + getPages: getPages, + applyAiRecommendation: applyAiRecommendation, + applyAiContent: applyAiContent, + restoreOriginalContent: restoreOriginalContent + }; +})(); diff --git a/source/font/AlibabaPuHuiTi-3-Bold.ttf b/source/font/AlibabaPuHuiTi-3-Bold.ttf new file mode 100644 index 0000000..7edd4e5 Binary files /dev/null and b/source/font/AlibabaPuHuiTi-3-Bold.ttf differ diff --git a/source/font/AlibabaPuHuiTi-3-Regular.ttf b/source/font/AlibabaPuHuiTi-3-Regular.ttf new file mode 100644 index 0000000..a6eaf36 Binary files /dev/null and b/source/font/AlibabaPuHuiTi-3-Regular.ttf differ diff --git a/source/font/LXGWWenKai-Regular.ttf b/source/font/LXGWWenKai-Regular.ttf new file mode 100644 index 0000000..eb61629 Binary files /dev/null and b/source/font/LXGWWenKai-Regular.ttf differ diff --git a/view/admin/common/left_system.html b/view/admin/common/left_system.html index 0903d58..92433dc 100644 --- a/view/admin/common/left_system.html +++ b/view/admin/common/left_system.html @@ -67,5 +67,10 @@ 清空缓存 +
diff --git a/view/admin/post/index.html b/view/admin/post/index.html index fd128dd..6bac408 100644 --- a/view/admin/post/index.html +++ b/view/admin/post/index.html @@ -76,6 +76,7 @@ 查看 设置 编辑 + 排版 导出
删除
diff --git a/view/admin/post/phone_image.html b/view/admin/post/phone_image.html new file mode 100644 index 0000000..a4f2fd7 --- /dev/null +++ b/view/admin/post/phone_image.html @@ -0,0 +1,426 @@ + + + + + + + + {$post.title} - 手机图片排版 + + + + + + + + + + + + +
+ +
+
+
+ +
+
+
简约
+
杂志
+
图文
+
+
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ + 14px +
+
+ +
+ +
+ +
+
+ + +
+ +
+ + +
+
+ + + +
+ + + +
+
+
+ + +
+
+
+
+
+
+
+ 上一页 + 第 1 页 / 共 0 页 + 下一页 +
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/view/admin/post/post_output/index.html b/view/admin/post/post_output/index.html new file mode 100644 index 0000000..d64ecac --- /dev/null +++ b/view/admin/post/post_output/index.html @@ -0,0 +1,175 @@ + + + + + + + + {$post.title} - 输出管理 + {include file="common/_require"} + + + + +
+ {include file="common/_header"} + {include file="common/left_post"} + +
+
+ + + + +
+ + + + + + + + + + + + + + + {volist name='list' id='vo'} + + + + + + + + + + + {/volist} + {if condition="count($list) == 0"} + + + + {/if} + +
ID类型尺寸页数状态创建时间图片操作
{$vo.id}{$vo.output_type_text} + {if $vo.config && $vo.config.size} + {if $vo.config.size == 'xiaohongshu'}小红书{/if} + {if $vo.config.size == 'douyin'}抖音{/if} + {else/} + - + {/if} + {$vo.page_count}{$vo.status_text}{$vo.create_time} + + +
+ + 下载ZIP + + +
+
暂无输出记录,点击"新建排版"创建
+
+ {$list|raw} +
+
+ + + +
+
+ {include file="common/_footer"} +
+ + + + + diff --git a/view/admin/post_output/index.html b/view/admin/post_output/index.html new file mode 100644 index 0000000..4193e87 --- /dev/null +++ b/view/admin/post_output/index.html @@ -0,0 +1,264 @@ + + + + + + + + {$post.title} - 输出管理 + + + + + +
+
+
+ 返回 + 新建排版 + 文章: {$post.title} +
+
+ + + + + + + + + + + + + + + + + + + + + + + {volist name="list" id="vo"} + + + + + + + + + + + + + {/volist} + +
ID输出类型状态图片数创建时间操作人操作
{$vo.id}{$vo.output_type_text}{$vo.status_text}{$vo.page_count}{$vo.create_time|date='Y-m-d H:i'}Admin + + 下载ZIP + + +
+
+ + 加载中... +
+ +
+
{$list|raw}
+
+
+
+ + + + + + + diff --git a/view/admin/system/ai.html b/view/admin/system/ai.html new file mode 100644 index 0000000..7137784 --- /dev/null +++ b/view/admin/system/ai.html @@ -0,0 +1,648 @@ + + + + + + + + AI 设置 + {include file="common/_require"} + + + + + + + +
+ {include file="common/_header"} + + {include file="common/left_system"} + +
+
+
+ + 首页 + AI 设置 + +
+
+ +
+ + +
+ + +
+
+

已配置的渠道

+ +
+
+
+
加载中...
+
+
+
+ + +
+
+

默认设置

+
+
+
+
+
默认供应商
+
+ +
+
+
+
默认模型
+
+ +
+
+
+ +
+
+
+
+
+ + +
+ + +
+
+

模型目录同步

+
+
+
+ 加载中... +
+ +
+
+ + +
+
+

从模型目录添加

+
+
+
+
+ +
+ + + +
+
+
+
+ +
+ +
+
+
+ + {include file="common/_footer"} +
+ + + + + + + +