参考文献作者堆叠
参考文献相关性检测 作者ai写作辅助检测工作
This commit is contained in:
299
application/api/controller/AITools.php
Normal file
299
application/api/controller/AITools.php
Normal file
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
/**
|
||||
* AI 工具接口 — 编辑辅助:AI 辅助写作风险分析
|
||||
*/
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use app\common\ArticleParserService;
|
||||
use app\common\mq\AiWritingRiskMqPublisher;
|
||||
use app\common\PlagiarismService;
|
||||
use app\common\service\AiWritingRiskTaskService;
|
||||
use think\Validate;
|
||||
|
||||
class Aitools extends Base
|
||||
{
|
||||
public function __construct(\think\Request $request = null)
|
||||
{
|
||||
parent::__construct($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* @title 写作风险检测可视化页面(前后端分离入口)
|
||||
* @description 仅渲染 HTML;检测与进度走 JSON 接口
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return $this->renderRiskPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* @title 开始 AI 辅助写作风险检测(异步入队)
|
||||
* @description 立即返回 task_id;后台 RabbitMQ 消费执行检测
|
||||
* @param url 稿件链接地址(.docx)
|
||||
* @param p_article_id 可选,业务稿件ID
|
||||
*/
|
||||
public function realtimeWritingRiskDetect()
|
||||
{
|
||||
$data = $this->request->param();
|
||||
$rule = new Validate([
|
||||
'url' => 'require',
|
||||
]);
|
||||
if (!$rule->check($data)) {
|
||||
return $this->detectJsonError($rule->getError());
|
||||
}
|
||||
$url = trim((string) $data['url']);
|
||||
$pArticleId = intval($data['p_article_id'] ?? 0);
|
||||
try {
|
||||
$taskService = new AiWritingRiskTaskService();
|
||||
$created = $taskService->createTask($url, $pArticleId);
|
||||
// 新任务,或复用但仍在排队(可能丢消息)时重新投递
|
||||
if (empty($created['reused']) || intval($created['status']) === AiWritingRiskTaskService::STATUS_PENDING) {
|
||||
(new AiWritingRiskMqPublisher())->publishTaskStart(intval($created['task_id']), 'enqueue');
|
||||
}
|
||||
|
||||
return json([
|
||||
'code' => 200,
|
||||
'msg' => !empty($created['reused']) ? '已有进行中的检测任务' : '已开始检测',
|
||||
'data' => [
|
||||
'task_id' => $created['task_id'],
|
||||
'task_no' => $created['task_no'],
|
||||
'status' => $created['status'],
|
||||
'status_text' => intval($created['status']) === 1 ? 'running' : 'queued',
|
||||
'progress_percent' => 0,
|
||||
'message' => $created['message'],
|
||||
'reused' => !empty($created['reused']),
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->detectJsonError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @title 刷新 AI 辅助写作风险检测进度
|
||||
* @description
|
||||
* - 浏览器 GET(无 format=json)→ 可视化页面:未完成显示进度,完成后显示结果
|
||||
* - POST 或 format=json → JSON(供前端轮询)
|
||||
* @param task_id 任务ID(与 task_no 二选一)
|
||||
* @param task_no 任务号(与 task_id 二选一)
|
||||
*/
|
||||
public function realtimeWritingRiskProgress()
|
||||
{
|
||||
$data = $this->request->param();
|
||||
$taskId = intval($data['task_id'] ?? 0);
|
||||
$taskNo = trim((string) ($data['task_no'] ?? ''));
|
||||
if ($taskId <= 0 && $taskNo === '') {
|
||||
if ($this->wantsProgressJson()) {
|
||||
return $this->detectJsonError('task_id 或 task_no 必填');
|
||||
}
|
||||
return $this->renderRiskPage();
|
||||
}
|
||||
|
||||
try {
|
||||
$progress = (new AiWritingRiskTaskService())->getProgress($taskNo, $taskId);
|
||||
} catch (\Exception $e) {
|
||||
if ($this->wantsProgressJson()) {
|
||||
return $this->detectJsonError($e->getMessage());
|
||||
}
|
||||
return $this->renderRiskPage([
|
||||
'page_error' => $e->getMessage(),
|
||||
'task_id' => $taskId,
|
||||
'task_no' => $taskNo,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->wantsProgressJson()) {
|
||||
return json([
|
||||
'code' => 200,
|
||||
'msg' => 'success',
|
||||
'data' => $progress,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->renderRiskPage([
|
||||
'task_id' => intval($progress['task_id'] ?? $taskId),
|
||||
'task_no' => (string) ($progress['task_no'] ?? $taskNo),
|
||||
'initial_data' => $progress,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否返回 JSON:POST、format=json、XHR/fetch 显式要 JSON
|
||||
*/
|
||||
private function wantsProgressJson(): bool
|
||||
{
|
||||
if (strtolower((string) $this->request->param('format', '')) === 'json') {
|
||||
return true;
|
||||
}
|
||||
if ($this->request->isAjax()) {
|
||||
return true;
|
||||
}
|
||||
$accept = strtolower((string) $this->request->header('accept', ''));
|
||||
if (strpos($accept, 'application/json') !== false) {
|
||||
return true;
|
||||
}
|
||||
// 页面内 fetch 使用 POST
|
||||
if ($this->request->isPost()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染可视化页
|
||||
* @param array $opts task_id/task_no/initial_data/page_error
|
||||
*/
|
||||
private function renderRiskPage(array $opts = [])
|
||||
{
|
||||
$apiStart = (string) url('api/aitools/realtimeWritingRiskDetect');
|
||||
$apiProgress = (string) url('api/aitools/realtimeWritingRiskProgress');
|
||||
$initial = isset($opts['initial_data']) && is_array($opts['initial_data'])
|
||||
? $opts['initial_data']
|
||||
: null;
|
||||
|
||||
$this->assign([
|
||||
'api_start_js' => json_encode($apiStart, JSON_UNESCAPED_SLASHES),
|
||||
'api_progress_js' => json_encode($apiProgress, JSON_UNESCAPED_SLASHES),
|
||||
'init_task_id_js' => json_encode(intval($opts['task_id'] ?? 0)),
|
||||
'init_task_no_js' => json_encode((string) ($opts['task_no'] ?? ''), JSON_UNESCAPED_UNICODE),
|
||||
'init_data_js' => json_encode($initial, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'page_error_js' => json_encode((string) ($opts['page_error'] ?? ''), JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
return $this->fetch('aiTools/index');
|
||||
}
|
||||
|
||||
/**
|
||||
* @title AI 辅助写作风险分析(编辑辅助)
|
||||
* @description 解析稿件结构,并从模版句、重复短语、句长特征等维度输出风险报告
|
||||
* @param url 稿件链接地址(.docx)
|
||||
*/
|
||||
public function writingRiskAnalysis()
|
||||
{
|
||||
$data = $this->request->param();
|
||||
$rule = new Validate([
|
||||
'url' => 'require',
|
||||
]);
|
||||
if (!$rule->check($data)) {
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
|
||||
try {
|
||||
$filePath = $this->resolveManuscriptUrl($data['url']);
|
||||
} catch (\Exception $e) {
|
||||
return jsonError($e->getMessage());
|
||||
}
|
||||
|
||||
$result = ArticleParserService::analyzeWritingRisk($filePath);
|
||||
if (empty($result['status']) || intval($result['status']) !== 1) {
|
||||
return jsonError(empty($result['msg']) ? '稿件分析失败' : $result['msg']);
|
||||
}
|
||||
|
||||
return jsonSuccess([
|
||||
'manuscript' => $this->pickManuscriptFields($result['data']),
|
||||
'risk_analysis' => $result['data']['risk_analysis'] ?? [],
|
||||
'metrics' => $this->pickMetricFields($result['data']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @title 根据稿件链接解析稿件结构
|
||||
* @description 从稿件地址解析标题、摘要、关键词及 IMRaD 正文分节(不含风险综述)
|
||||
* @param url 稿件链接地址(.docx)
|
||||
*/
|
||||
public function parseManuscript()
|
||||
{
|
||||
$data = $this->request->param();
|
||||
$rule = new Validate([
|
||||
'url' => 'require',
|
||||
]);
|
||||
if (!$rule->check($data)) {
|
||||
return jsonError($rule->getError());
|
||||
}
|
||||
|
||||
try {
|
||||
$filePath = $this->resolveManuscriptUrl($data['url']);
|
||||
} catch (\Exception $e) {
|
||||
return jsonError($e->getMessage());
|
||||
}
|
||||
|
||||
$result = ArticleParserService::parseManuscriptStructure($filePath);
|
||||
if (empty($result['status']) || intval($result['status']) !== 1) {
|
||||
return jsonError(empty($result['msg']) ? '稿件解析失败' : $result['msg']);
|
||||
}
|
||||
|
||||
return jsonSuccess($result['data']);
|
||||
}
|
||||
|
||||
private function detectJsonError($msg)
|
||||
{
|
||||
return json(['code' => 500, 'msg' => (string) $msg, 'data' => []]);
|
||||
}
|
||||
|
||||
private function pickManuscriptFields(array $data): array
|
||||
{
|
||||
$keys = ['title', 'abstract', 'keywords', 'Introduction', 'Methods', 'Results', 'Discussion', 'Conclusion', 'References'];
|
||||
$out = [];
|
||||
foreach ($keys as $key) {
|
||||
if (array_key_exists($key, $data)) {
|
||||
$out[$key] = $data[$key];
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function pickMetricFields(array $data): array
|
||||
{
|
||||
$keys = [
|
||||
'sentence_stats',
|
||||
'sentence_stats_by_section',
|
||||
'repeated_phrases',
|
||||
'repeated_phrases_by_section',
|
||||
'template_sentence_stats',
|
||||
];
|
||||
$out = [];
|
||||
foreach ($keys as $key) {
|
||||
if (array_key_exists($key, $data)) {
|
||||
$out[$key] = $data[$key];
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将稿件链接解析为本地可读文件路径
|
||||
*/
|
||||
private function resolveManuscriptUrl($url)
|
||||
{
|
||||
$url = trim((string)$url);
|
||||
if ($url === '') {
|
||||
throw new \Exception('稿件地址不能为空');
|
||||
}
|
||||
|
||||
if (preg_match('/^([a-zA-Z]:[\\\\\/]|\/)/', $url) && is_file($url)) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
if (preg_match('#^https?://#i', $url)) {
|
||||
try {
|
||||
return (new PlagiarismService())->resolveFileUrlToLocal($url);
|
||||
} catch (\Exception $e) {
|
||||
$path = parse_url($url, PHP_URL_PATH);
|
||||
if ($path) {
|
||||
$local = rtrim(ROOT_PATH, '/') . $path;
|
||||
if (is_file($local)) {
|
||||
return $local;
|
||||
}
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
$local = rtrim(ROOT_PATH, '/') . '/public/' . ltrim(ltrim($url, '/'), 'public');
|
||||
if (is_file($local)) {
|
||||
return $local;
|
||||
}
|
||||
|
||||
throw new \Exception('无法解析稿件文件路径: ' . $url);
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,14 @@ class Base extends Controller
|
||||
$this->production_article_refer_obj->where('p_article_id', $refer_info['p_article_id'])->where('index', ">", $refer_info['index'])->where('state', 0)->setDec('index');
|
||||
$this->production_article_refer_obj->where('p_refer_id', $p_refer_id)->update(['state' => 1]);
|
||||
|
||||
try {
|
||||
Db::name('production_article_refer_author')
|
||||
->where('p_refer_id', intval($p_refer_id))
|
||||
->delete();
|
||||
} catch (\Exception $e) {
|
||||
\think\Log::error('delOneRefer delete refer_author p_refer_id=' . $p_refer_id . ' ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// 文献集合已变更,原校对结果的 reference_no 已全部错位,整篇标记为未校对
|
||||
try {
|
||||
(new \app\common\ReferenceCheckService())
|
||||
|
||||
@@ -7,6 +7,8 @@ use think\Env;
|
||||
use think\Queue;
|
||||
use think\Validate;
|
||||
use app\common\CrossrefService;
|
||||
use app\common\ReferenceCheckService;
|
||||
use app\common\ReferenceReferAuthorService;
|
||||
use app\common\ReferenceRelevanceCheckService;
|
||||
|
||||
class Preaccept extends Base
|
||||
@@ -36,6 +38,78 @@ class Preaccept extends Base
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增参考文献后同步作者明细到 t_production_article_refer_author
|
||||
*/
|
||||
private function syncReferAuthorsAfterInsert($pReferId, $pArticleId, array $refer)
|
||||
{
|
||||
$pReferId = intval($pReferId);
|
||||
$pArticleId = intval($pArticleId);
|
||||
if ($pReferId <= 0 || $pArticleId <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
(new ReferenceReferAuthorService())->syncOneRefer($pReferId, $pArticleId, $refer);
|
||||
} catch (\Exception $e) {
|
||||
\think\Log::error(
|
||||
'syncReferAuthorsAfterInsert p_refer_id=' . $pReferId
|
||||
. ' p_article_id=' . $pArticleId . ' ' . $e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑参考文献后同步作者明细;refer_doi 变化时先删后重新抓取入库
|
||||
*/
|
||||
private function syncReferAuthorsAfterUpdate($pReferId, $pArticleId, array $oldRefer, array $updata)
|
||||
{
|
||||
$pReferId = intval($pReferId);
|
||||
$pArticleId = intval($pArticleId);
|
||||
if ($pReferId <= 0 || $pArticleId <= 0 || empty($oldRefer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$newRefer = array_merge($oldRefer, $updata);
|
||||
$refUtil = new ReferenceCheckService();
|
||||
$oldDoi = $this->normalizeReferDoi($refUtil->extractDoiFromRefer($oldRefer));
|
||||
$newDoi = $this->normalizeReferDoi($refUtil->extractDoiFromRefer($newRefer));
|
||||
$doiChanged = $oldDoi !== $newDoi;
|
||||
|
||||
$authorChanged = false;
|
||||
if (array_key_exists('author', $updata)) {
|
||||
$authorChanged = trim((string)($oldRefer['author'] ?? '')) !== trim((string)$updata['author']);
|
||||
}
|
||||
|
||||
if (!$doiChanged && !$authorChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$svc = new ReferenceReferAuthorService();
|
||||
if ($doiChanged) {
|
||||
Db::name('production_article_refer_author')->where('p_refer_id', $pReferId)->delete();
|
||||
$svc->syncOneRefer($pReferId, $pArticleId, $newRefer);
|
||||
} elseif ($authorChanged) {
|
||||
$svc->syncFromReferAuthorField(
|
||||
$pReferId,
|
||||
$pArticleId,
|
||||
(string)($newRefer['author'] ?? '')
|
||||
);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
\think\Log::error(
|
||||
'syncReferAuthorsAfterUpdate p_refer_id=' . $pReferId
|
||||
. ' p_article_id=' . $pArticleId . ' ' . $e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeReferDoi($doi)
|
||||
{
|
||||
$doi = preg_replace('#^https?://(dx\.)?doi\.org/#i', '', trim((string)$doi));
|
||||
return strtolower(trim($doi, " \t\n\r\0\x0B/"));
|
||||
}
|
||||
|
||||
|
||||
/**获取文章参考文献列表
|
||||
* @return \think\response\Json
|
||||
@@ -165,6 +239,11 @@ class Preaccept extends Base
|
||||
$adId= $this->production_article_refer_obj->insertGetId($insert);
|
||||
$this->production_article_refer_obj->where('p_article_id', $p_info['p_article_id'])->where("p_refer_id", "<>", $adId)->where("index", ">", $pre_refer['index'])->where('state', 0)->setInc('index');
|
||||
$this->resetArticleChecksOnReferChange(intval($p_info['p_article_id']), 'addRefer');
|
||||
$this->syncReferAuthorsAfterInsert(
|
||||
$adId,
|
||||
intval($p_info['p_article_id']),
|
||||
array_merge($insert, ['p_refer_id' => $adId, 'p_article_id' => $p_info['p_article_id']])
|
||||
);
|
||||
return jsonSuccess([]);
|
||||
|
||||
|
||||
@@ -222,6 +301,12 @@ class Preaccept extends Base
|
||||
$adId= $this->production_article_refer_obj->insertGetId($insert);
|
||||
$this->production_article_refer_obj->where('p_article_id', $p_info['p_article_id'])->where("p_refer_id", "<>", $adId)->where("index", ">", $pre_refer['index'])->where('state', 0)->setInc('index');
|
||||
$this->resetArticleChecksOnReferChange(intval($p_info['p_article_id']), 'addReferByParticleid');
|
||||
$this->syncReferAuthorsAfterInsert(
|
||||
$adId,
|
||||
intval($p_info['p_article_id']),
|
||||
array_merge($insert, ['p_refer_id' => $adId, 'p_article_id' => $p_info['p_article_id']])
|
||||
);
|
||||
|
||||
return jsonSuccess([]);
|
||||
}
|
||||
|
||||
@@ -498,6 +583,13 @@ class Preaccept extends Base
|
||||
\think\Log::error('editRefer enqueueRecheckByPReferId p_refer_id=' . $data['p_refer_id'] . ' ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$this->syncReferAuthorsAfterUpdate(
|
||||
intval($data['p_refer_id']),
|
||||
intval($old_refer_info['p_article_id']),
|
||||
$old_refer_info,
|
||||
$updata
|
||||
);
|
||||
|
||||
return jsonSuccess([]);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ use think\Env;
|
||||
use think\Queue;
|
||||
use app\common\ReferenceCheckService;
|
||||
use app\common\ReferenceRelevanceCheckService;
|
||||
use app\common\ReferenceStackingStatsService;
|
||||
use app\common\ReferenceReferAuthorService;
|
||||
use app\common\DbReconnectHelper;
|
||||
/**
|
||||
* @title 参考文献
|
||||
@@ -1818,5 +1820,117 @@ class References extends Base
|
||||
return $frag;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 参考文献引用堆叠统计(同作者>15%、同刊>20%、自引>10%,实时计算)
|
||||
*
|
||||
* POST/GET: p_article_id(必填)
|
||||
*/
|
||||
public function referenceStackingStats()
|
||||
{
|
||||
$aParam = $this->request->post();
|
||||
if (empty($aParam)) {
|
||||
$aParam = $this->request->param();
|
||||
}
|
||||
|
||||
$iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']);
|
||||
if ($iPArticleId <= 0) {
|
||||
return json_encode(array('status' => 2, 'msg' => 'Please select an article'));
|
||||
}
|
||||
|
||||
try {
|
||||
$svc = new ReferenceStackingStatsService();
|
||||
$result = $svc->getThresholdStackingByPArticleId($iPArticleId);
|
||||
return jsonSuccess($result);
|
||||
} catch (\Exception $e) {
|
||||
return jsonError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试:同步参考文献作者明细到 t_production_article_refer_author
|
||||
* POST/GET: p_article_id(必填), p_refer_id(可选,仅同步单条), include_authors(可选,默认 0;1 返回作者明细), sleep_ms(可选,默认 120)
|
||||
*/
|
||||
public function referenceReferAuthorSyncTest()
|
||||
{
|
||||
$aParam = $this->request->post();
|
||||
if (empty($aParam)) {
|
||||
$aParam = $this->request->param();
|
||||
}
|
||||
|
||||
$iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']);
|
||||
if ($iPArticleId <= 0) {
|
||||
return json_encode(array('status' => 2, 'msg' => 'Please select an article'));
|
||||
}
|
||||
|
||||
$iPReferId = empty($aParam['p_refer_id']) ? 0 : intval($aParam['p_refer_id']);
|
||||
$includeAuthors = !empty($aParam['include_authors']) && intval($aParam['include_authors']) === 1;
|
||||
$sleepMs = isset($aParam['sleep_ms']) ? max(0, intval($aParam['sleep_ms'])) : 120;
|
||||
|
||||
try {
|
||||
$svc = new ReferenceReferAuthorService();
|
||||
|
||||
if ($iPReferId > 0) {
|
||||
$refer = Db::name('production_article_refer')
|
||||
->where('p_refer_id', $iPReferId)
|
||||
->where('p_article_id', $iPArticleId)
|
||||
->where('state', 0)
|
||||
->find();
|
||||
if (empty($refer)) {
|
||||
return jsonError('Reference not found');
|
||||
}
|
||||
|
||||
$refUtil = new ReferenceCheckService();
|
||||
$doi = $refUtil->extractDoiFromRefer($refer);
|
||||
$count = 0;
|
||||
if ($doi !== '') {
|
||||
$crossref = new CrossrefService([
|
||||
'mailto' => trim((string)Env::get('crossref_mailto', '')),
|
||||
]);
|
||||
$summary = $crossref->fetchWorkSummary($doi);
|
||||
if ($summary === null || empty($summary['doi'])) {
|
||||
$summary = ['doi' => $doi, 'raw' => []];
|
||||
}
|
||||
$count = $svc->syncFromWorkSummary($iPReferId, $iPArticleId, $doi, $summary);
|
||||
}
|
||||
if ($count <= 0) {
|
||||
$count = $svc->syncFromReferAuthorField($iPReferId, $iPArticleId, (string)($refer['author'] ?? ''));
|
||||
}
|
||||
|
||||
$result = [
|
||||
'p_article_id' => $iPArticleId,
|
||||
'p_refer_id' => $iPReferId,
|
||||
'reference_no' => intval($refer['index']) + 1,
|
||||
'doi' => $doi,
|
||||
'authors' => $count,
|
||||
'synced' => $count > 0 ? 1 : 0,
|
||||
'failed' => $count > 0 ? 0 : 1,
|
||||
];
|
||||
} else {
|
||||
$result = $svc->syncByPArticleId($iPArticleId, ['sleep_ms' => $sleepMs]);
|
||||
}
|
||||
|
||||
if ($includeAuthors) {
|
||||
$query = Db::name('production_article_refer_author')
|
||||
->alias('a')
|
||||
->join('production_article_refer r', 'r.p_refer_id = a.p_refer_id', 'LEFT')
|
||||
->field('a.p_refer_id,r.index,a.author_seq,a.display_name,a.citation_name,a.orcid,a.openalex_id,a.identity_source')
|
||||
->where('a.p_article_id', $iPArticleId);
|
||||
if ($iPReferId > 0) {
|
||||
$query->where('a.p_refer_id', $iPReferId);
|
||||
}
|
||||
$rows = $query->order('r.index asc, a.author_seq asc')->select();
|
||||
foreach ($rows as &$row) {
|
||||
$row['reference_no'] = intval($row['index']) + 1;
|
||||
unset($row['index']);
|
||||
}
|
||||
unset($row);
|
||||
$result['author_rows'] = $rows;
|
||||
}
|
||||
|
||||
return jsonSuccess($result);
|
||||
} catch (\Exception $e) {
|
||||
return jsonError($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
849
application/api/view/aiTools/index.html
Normal file
849
application/api/view/aiTools/index.html
Normal file
@@ -0,0 +1,849 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>AI 辅助写作风险检测</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=Fraunces:opsz,wght@9..144,500;9..144,650&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
--ink: #14212b;
|
||||
--muted: #5c6b76;
|
||||
--line: #d7e0e6;
|
||||
--paper: #f3f6f8;
|
||||
--panel: #ffffff;
|
||||
--accent: #0f766e;
|
||||
--accent-soft: #d9f3ef;
|
||||
--warn: #b45309;
|
||||
--danger: #b91c1c;
|
||||
--ok: #047857;
|
||||
--mid: #a16207;
|
||||
--high: #c2410c;
|
||||
--vhigh: #9f1239;
|
||||
--shadow: 0 1px 0 rgba(20, 33, 43, 0.04);
|
||||
--radius: 14px;
|
||||
--font: "DM Sans", system-ui, sans-serif;
|
||||
--display: "Fraunces", Georgia, serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; }
|
||||
body {
|
||||
font-family: var(--font);
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(1200px 500px at 10% -10%, #dff5f1 0%, transparent 55%),
|
||||
radial-gradient(900px 420px at 100% 0%, #e8eef3 0%, transparent 50%),
|
||||
var(--paper);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
width: min(1120px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 36px 0 64px;
|
||||
}
|
||||
|
||||
header.hero {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.brand {
|
||||
font-family: var(--display);
|
||||
font-size: clamp(1.8rem, 3.2vw, 2.6rem);
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
}
|
||||
.lead {
|
||||
margin: 0;
|
||||
max-width: 46rem;
|
||||
color: var(--muted);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.start-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.start-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
input[type="text"], input[type="url"], input[type="number"] {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
background: #fff;
|
||||
outline: none;
|
||||
}
|
||||
input:focus {
|
||||
border-color: #7bbbb4;
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 12px 18px;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform .12s ease, opacity .12s ease;
|
||||
}
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
min-width: 132px;
|
||||
}
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 1px solid #b7ddd8;
|
||||
}
|
||||
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 18px;
|
||||
margin-top: 14px;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.meta-row strong { color: var(--ink); font-weight: 600; }
|
||||
|
||||
.progress-block { margin-top: 18px; display: none; }
|
||||
.progress-block.show { display: block; }
|
||||
.progress-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.bar {
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: #e7eef2;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #0f766e, #14968c);
|
||||
transition: width .35s ease;
|
||||
}
|
||||
.step-text { color: var(--muted); font-size: 0.88rem; margin-top: 8px; }
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
background: #eef3f6;
|
||||
color: var(--muted);
|
||||
}
|
||||
.status-pill.running { background: #e7f6f3; color: var(--accent); }
|
||||
.status-pill.done { background: #e7f7ef; color: var(--ok); }
|
||||
.status-pill.failed { background: #fdecec; color: var(--danger); }
|
||||
|
||||
.results { margin-top: 22px; display: none; }
|
||||
.results.show { display: block; }
|
||||
|
||||
body.mode-progress .start-panel-extra { opacity: .85; }
|
||||
body.mode-result .progress-block { margin-bottom: 8px; }
|
||||
body.mode-progress #results { display: none !important; }
|
||||
body.mode-result #results { display: block; }
|
||||
|
||||
.score-board {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.score-board { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.gauge {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
}
|
||||
.gauge svg { width: 100%; height: 100%; transform: rotate(-90deg); }
|
||||
.gauge .track { fill: none; stroke: #e7eef2; stroke-width: 12; }
|
||||
.gauge .value {
|
||||
fill: none;
|
||||
stroke: var(--accent);
|
||||
stroke-width: 12;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 502.4;
|
||||
stroke-dashoffset: 502.4;
|
||||
transition: stroke-dashoffset .6s ease, stroke .3s ease;
|
||||
}
|
||||
.gauge-center {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.gauge-center .num {
|
||||
font-family: var(--display);
|
||||
font-size: 2.4rem;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
}
|
||||
.gauge-center .lvl {
|
||||
margin-top: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.kv .item {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
background: #fafcfd;
|
||||
}
|
||||
.kv .item .k { font-size: 0.78rem; color: var(--muted); }
|
||||
.kv .item .v { font-size: 1.05rem; font-weight: 650; margin-top: 2px; }
|
||||
|
||||
.section-title {
|
||||
font-family: var(--display);
|
||||
font-size: 1.25rem;
|
||||
margin: 26px 0 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.dims {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.dims { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
.dim {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
.dim .name { font-size: 0.8rem; color: var(--muted); margin-bottom: 8px; }
|
||||
.dim .score { font-weight: 700; font-size: 1.15rem; }
|
||||
.dim .mini {
|
||||
margin-top: 8px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #e7eef2;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dim .mini > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.sections {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.sections { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.sections { grid-template-columns: 1fr; }
|
||||
}
|
||||
.sec {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
}
|
||||
.sec h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.95rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.sec .row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.sec .row b { color: var(--ink); }
|
||||
|
||||
.advice, .warnings {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.chip {
|
||||
border-left: 3px solid var(--accent);
|
||||
background: var(--accent-soft);
|
||||
padding: 10px 12px;
|
||||
border-radius: 0 10px 10px 0;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.chip.warn {
|
||||
border-left-color: var(--warn);
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.issues {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.issue {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
.issue-top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.tag {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 650;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: #eef3f6;
|
||||
color: var(--muted);
|
||||
}
|
||||
.tag.sev-high, .tag.sev-very_high { background: #fde8e8; color: var(--danger); }
|
||||
.tag.sev-middle { background: #fff4e5; color: var(--warn); }
|
||||
.tag.sev-low { background: #e8f6ef; color: var(--ok); }
|
||||
.issue .reason { font-weight: 600; margin: 0 0 6px; }
|
||||
.issue .suggestion, .issue .evidence {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.issue .evidence {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
background: #f6f9fb;
|
||||
border-radius: 8px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
display: none;
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: #fdecec;
|
||||
color: var(--danger);
|
||||
border: 1px solid #f5c2c2;
|
||||
}
|
||||
.error-box.show { display: block; }
|
||||
|
||||
.footer-note {
|
||||
margin-top: 28px;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.level-low { color: var(--ok); }
|
||||
.level-middle { color: var(--mid); }
|
||||
.level-high { color: var(--high); }
|
||||
.level-very_high { color: var(--vhigh); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header class="hero">
|
||||
<h1 class="brand">AI 辅助写作风险检测</h1>
|
||||
<p class="lead">面向编辑的机器化写作风险辅助工具:模板化表达、机械化语言、空泛描述、章节逻辑与重复表达。不是 AI 生成百分比判断。</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="start-grid">
|
||||
<div>
|
||||
<label for="url">稿件地址(.docx)</label>
|
||||
<input id="url" type="url" placeholder="https://.../manuscript.docx" />
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button id="btnStart" class="btn btn-primary" type="button">开始检测</button>
|
||||
<button id="btnRefresh" class="btn btn-ghost" type="button" disabled>刷新进度</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="start-grid" style="margin-top:12px; grid-template-columns: 1fr 1fr auto;">
|
||||
<div>
|
||||
<label for="taskId">任务 ID(可手动填入后刷新)</label>
|
||||
<input id="taskId" type="number" min="1" placeholder="task_id" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="taskNo">或任务号 task_no</label>
|
||||
<input id="taskNo" type="text" placeholder="awr_..." />
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
<div class="meta-row" id="metaRow" style="display:none;">
|
||||
<span>状态 <span id="statusPill" class="status-pill">queued</span></span>
|
||||
<span>任务 <strong id="metaTask">-</strong></span>
|
||||
<span>更新 <strong id="metaUpdated">-</strong></span>
|
||||
</div>
|
||||
|
||||
<div class="progress-block" id="progressBlock">
|
||||
<div class="progress-head">
|
||||
<span id="progressMsg">排队中</span>
|
||||
<strong id="progressPct">0%</strong>
|
||||
</div>
|
||||
<div class="bar"><i id="progressBar"></i></div>
|
||||
<div class="step-text" id="progressStep">current_step: -</div>
|
||||
</div>
|
||||
|
||||
<div class="error-box" id="errorBox"></div>
|
||||
</section>
|
||||
|
||||
<section class="results" id="results">
|
||||
<div class="panel">
|
||||
<div class="score-board">
|
||||
<div class="gauge" aria-label="综合风险分">
|
||||
<svg viewBox="0 0 180 180">
|
||||
<circle class="track" cx="90" cy="90" r="80"></circle>
|
||||
<circle class="value" id="gaugeValue" cx="90" cy="90" r="80"></circle>
|
||||
</svg>
|
||||
<div class="gauge-center">
|
||||
<div class="num" id="overallScore">0</div>
|
||||
<div class="lvl" id="overallLevel">-</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kv" id="summaryKv"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">分维度风险</h2>
|
||||
<div class="dims" id="dims"></div>
|
||||
|
||||
<h2 class="section-title">章节评分</h2>
|
||||
<div class="sections" id="sections"></div>
|
||||
|
||||
<h2 class="section-title">编辑建议</h2>
|
||||
<div class="advice" id="advice"></div>
|
||||
|
||||
<h2 class="section-title" id="warnTitle" style="display:none;">警告</h2>
|
||||
<div class="warnings" id="warnings"></div>
|
||||
|
||||
<h2 class="section-title">重点问题</h2>
|
||||
<div class="issues" id="topIssues"></div>
|
||||
|
||||
<h2 class="section-title">全部明细</h2>
|
||||
<div class="issues" id="allDetails"></div>
|
||||
</section>
|
||||
|
||||
<p class="footer-note">进度页:<code>/api/aitools/realtimeWritingRiskProgress?task_id=任务ID</code>(未完成看进度,完成后看结果)。JSON 轮询请加 <code>format=json</code> 或使用 POST。</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const API_START = {$api_start_js};
|
||||
const API_PROGRESS = {$api_progress_js};
|
||||
const INIT_TASK_ID = {$init_task_id_js};
|
||||
const INIT_TASK_NO = {$init_task_no_js};
|
||||
const INIT_DATA = {$init_data_js};
|
||||
const PAGE_ERROR = {$page_error_js};
|
||||
const POLL_MS = 2500;
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
const levelLabel = {
|
||||
low: '低风险',
|
||||
middle: '中风险',
|
||||
high: '高风险',
|
||||
very_high: '极高风险',
|
||||
};
|
||||
const dimLabel = {
|
||||
template: '模板化',
|
||||
repetition: '短语重复',
|
||||
full_sentence: '完整句重复',
|
||||
sentence_structure: '句式重复',
|
||||
mechanical: '机械化',
|
||||
vague: '空泛描述',
|
||||
section_logic: '章节逻辑',
|
||||
};
|
||||
const statusMap = {
|
||||
0: 'queued',
|
||||
1: 'running',
|
||||
2: 'done',
|
||||
3: 'failed',
|
||||
};
|
||||
|
||||
let pollTimer = null;
|
||||
let currentTaskId = 0;
|
||||
let currentTaskNo = '';
|
||||
|
||||
function setPageMode(mode) {
|
||||
document.body.classList.remove('mode-progress', 'mode-result', 'mode-idle');
|
||||
document.body.classList.add(mode || 'mode-idle');
|
||||
}
|
||||
|
||||
function applyTaskState(data) {
|
||||
updateProgressUI(data || {});
|
||||
const status = Number((data && data.status) || 0);
|
||||
if (status === 2) {
|
||||
// 结果只落库,页面不渲染明细
|
||||
setPageMode('mode-progress');
|
||||
el('results').classList.remove('show');
|
||||
el('progressMsg').textContent = data.message || '检测完成,结果已写入数据库(页面不展示)';
|
||||
stopPoll();
|
||||
return 'done';
|
||||
}
|
||||
if (status === 3) {
|
||||
setPageMode('mode-progress');
|
||||
el('results').classList.remove('show');
|
||||
showError((data && (data.error_msg || data.message)) || '检测失败');
|
||||
stopPoll();
|
||||
return 'failed';
|
||||
}
|
||||
setPageMode('mode-progress');
|
||||
el('results').classList.remove('show');
|
||||
return 'running';
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const box = el('errorBox');
|
||||
box.textContent = msg || '未知错误';
|
||||
box.classList.add('show');
|
||||
}
|
||||
function clearError() {
|
||||
el('errorBox').classList.remove('show');
|
||||
el('errorBox').textContent = '';
|
||||
}
|
||||
|
||||
function setBusy(busy) {
|
||||
el('btnStart').disabled = busy;
|
||||
el('btnRefresh').disabled = !currentTaskId && !currentTaskNo;
|
||||
}
|
||||
|
||||
function setStatusPill(status, statusText) {
|
||||
const pill = el('statusPill');
|
||||
const key = statusText || statusMap[status] || 'queued';
|
||||
pill.textContent = key;
|
||||
pill.className = 'status-pill ' + (key === 'done' || key === 'failed' || key === 'running' ? key : '');
|
||||
}
|
||||
|
||||
function updateProgressUI(data) {
|
||||
el('metaRow').style.display = 'flex';
|
||||
el('progressBlock').classList.add('show');
|
||||
currentTaskId = data.task_id || currentTaskId;
|
||||
currentTaskNo = data.task_no || currentTaskNo;
|
||||
el('taskId').value = currentTaskId || '';
|
||||
el('taskNo').value = currentTaskNo || '';
|
||||
el('metaTask').textContent = (currentTaskNo || '-') + ' / #' + (currentTaskId || '-');
|
||||
el('metaUpdated').textContent = data.updated_at || data.created_at || '-';
|
||||
setStatusPill(data.status, data.status_text);
|
||||
|
||||
const pct = Number(data.progress_percent || 0);
|
||||
el('progressPct').textContent = pct.toFixed(1) + '%';
|
||||
el('progressBar').style.width = Math.max(0, Math.min(100, pct)) + '%';
|
||||
el('progressMsg').textContent = data.message || '处理中';
|
||||
el('progressStep').textContent = 'current_step: ' + (data.current_step || '-');
|
||||
el('btnRefresh').disabled = false;
|
||||
}
|
||||
|
||||
function levelClass(level) {
|
||||
return 'level-' + (level || 'low');
|
||||
}
|
||||
|
||||
function setGauge(score, level) {
|
||||
const circ = 2 * Math.PI * 80;
|
||||
const offset = circ * (1 - Math.max(0, Math.min(100, score)) / 100);
|
||||
const node = el('gaugeValue');
|
||||
node.style.strokeDasharray = String(circ);
|
||||
node.style.strokeDashoffset = String(offset);
|
||||
const colors = {
|
||||
low: '#047857',
|
||||
middle: '#a16207',
|
||||
high: '#c2410c',
|
||||
very_high: '#9f1239',
|
||||
};
|
||||
node.style.stroke = colors[level] || colors.middle;
|
||||
el('overallScore').textContent = String(score);
|
||||
el('overallLevel').textContent = levelLabel[level] || level || '-';
|
||||
el('overallLevel').className = 'lvl ' + levelClass(level);
|
||||
}
|
||||
|
||||
function renderSummary(result) {
|
||||
const items = [
|
||||
['规则分', result.rule_score ?? '-'],
|
||||
['模型分', result.model_score == null ? '不可用' : result.model_score],
|
||||
['模型覆盖', ((result.model_coverage || 0) * 100).toFixed(0) + '%'],
|
||||
['分节置信度', result.parse_confidence || '-'],
|
||||
];
|
||||
el('summaryKv').innerHTML = items.map(([k, v]) =>
|
||||
`<div class="item"><div class="k">${k}</div><div class="v">${v}</div></div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function renderDims(dimensions) {
|
||||
const dims = dimensions || {};
|
||||
el('dims').innerHTML = Object.keys(dimLabel).map((key) => {
|
||||
const d = dims[key] || { score: 0, level: 'low' };
|
||||
const score = Number(d.score || 0);
|
||||
return `<div class="dim">
|
||||
<div class="name">${dimLabel[key]}</div>
|
||||
<div class="score ${levelClass(d.level)}">${score}</div>
|
||||
<div class="mini"><i style="width:${Math.min(100, score)}%"></i></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderSections(sections) {
|
||||
const map = sections || {};
|
||||
el('sections').innerHTML = Object.keys(map).map((key) => {
|
||||
const s = map[key] || {};
|
||||
const model = s.model_available === false
|
||||
? (s.skipped ? '跳过' : '失败')
|
||||
: (s.model_score ?? '-');
|
||||
return `<div class="sec">
|
||||
<h4>${key}</h4>
|
||||
<div class="row"><span>综合</span><b class="${levelClass(s.level)}">${s.score ?? '-'}</b></div>
|
||||
<div class="row"><span>规则</span><b>${s.rule_score ?? '-'}</b></div>
|
||||
<div class="row"><span>模型</span><b>${model}</b></div>
|
||||
<div class="row"><span>等级</span><b>${levelLabel[s.level] || s.level || '-'}</b></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderList(targetId, items, soft) {
|
||||
const list = Array.isArray(items) ? items : [];
|
||||
const box = el(targetId);
|
||||
if (!list.length) {
|
||||
box.innerHTML = soft ? '' : '<div class="chip">暂无</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = list.map((t) =>
|
||||
`<div class="chip${soft ? ' warn' : ''}">${escapeHtml(String(t))}</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function renderIssues(targetId, details) {
|
||||
const list = Array.isArray(details) ? details : [];
|
||||
const box = el(targetId);
|
||||
if (!list.length) {
|
||||
box.innerHTML = '<div class="chip">暂无命中项</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = list.map((d) => {
|
||||
const sev = d.severity || 'low';
|
||||
const evidence = d.evidence || d.sentence || '';
|
||||
return `<article class="issue">
|
||||
<div class="issue-top">
|
||||
<span class="tag sev-${escapeHtml(sev)}">${escapeHtml(sev)}</span>
|
||||
<span class="tag">${escapeHtml(d.type || '')}</span>
|
||||
<span class="tag">${escapeHtml(d.section || '')}</span>
|
||||
${d.score != null && d.score !== '' ? `<span class="tag">score ${escapeHtml(String(d.score))}</span>` : ''}
|
||||
</div>
|
||||
<p class="reason">${escapeHtml(d.reason || '')}</p>
|
||||
${d.suggestion ? `<p class="suggestion">建议:${escapeHtml(d.suggestion)}</p>` : ''}
|
||||
${evidence ? `<p class="evidence">“${escapeHtml(evidence)}”</p>` : ''}
|
||||
</article>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderResult(result) {
|
||||
el('results').classList.add('show');
|
||||
setGauge(Number(result.overall_score || 0), result.overall_level || 'low');
|
||||
renderSummary(result);
|
||||
renderDims(result.dimensions || {});
|
||||
renderSections(result.sections || {});
|
||||
renderList('advice', result.editor_advice || []);
|
||||
const warnings = result.warnings || [];
|
||||
el('warnTitle').style.display = warnings.length ? 'block' : 'none';
|
||||
renderList('warnings', warnings, true);
|
||||
renderIssues('topIssues', result.top_issues || []);
|
||||
renderIssues('allDetails', result.details || []);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
async function postForm(url, params) {
|
||||
const body = new URLSearchParams();
|
||||
Object.keys(params).forEach((k) => {
|
||||
if (params[k] !== '' && params[k] != null) body.append(k, params[k]);
|
||||
});
|
||||
body.append('format', 'json');
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
const json = await res.json();
|
||||
return json;
|
||||
}
|
||||
|
||||
function stopPoll() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startPoll() {
|
||||
stopPoll();
|
||||
pollTimer = setInterval(() => refreshProgress(true), POLL_MS);
|
||||
}
|
||||
|
||||
function syncUrl(taskId, taskNo) {
|
||||
try {
|
||||
const u = new URL(window.location.href);
|
||||
// 仅在进度页路径下同步 query,便于分享
|
||||
if (/realtimeWritingRiskProgress/i.test(u.pathname + u.search)) {
|
||||
if (taskId) u.searchParams.set('task_id', String(taskId));
|
||||
if (taskNo) u.searchParams.set('task_no', String(taskNo));
|
||||
window.history.replaceState({}, '', u.toString());
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function startDetect() {
|
||||
clearError();
|
||||
const url = el('url').value.trim();
|
||||
if (!url) {
|
||||
showError('请填写稿件地址');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
el('results').classList.remove('show');
|
||||
try {
|
||||
const json = await postForm(API_START, { url });
|
||||
if (Number(json.code) !== 200) {
|
||||
throw new Error(json.msg || '开始检测失败');
|
||||
}
|
||||
const data = json.data || {};
|
||||
applyTaskState(data);
|
||||
syncUrl(data.task_id, data.task_no);
|
||||
// 跳转到可分享的进度页
|
||||
if (data.task_id) {
|
||||
const progressUrl = API_PROGRESS + (API_PROGRESS.indexOf('?') >= 0 ? '&' : '?') + 'task_id=' + encodeURIComponent(data.task_id);
|
||||
window.location.href = progressUrl;
|
||||
return;
|
||||
}
|
||||
startPoll();
|
||||
await refreshProgress(true);
|
||||
} catch (e) {
|
||||
showError(e.message || String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshProgress(silent) {
|
||||
clearError();
|
||||
const taskId = el('taskId').value.trim() || currentTaskId;
|
||||
const taskNo = el('taskNo').value.trim() || currentTaskNo;
|
||||
if (!taskId && !taskNo) {
|
||||
if (!silent) showError('请先开始检测,或填写 task_id / task_no');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const json = await postForm(API_PROGRESS, {
|
||||
task_id: taskId || '',
|
||||
task_no: taskNo || '',
|
||||
});
|
||||
if (Number(json.code) !== 200) {
|
||||
throw new Error(json.msg || '刷新进度失败');
|
||||
}
|
||||
const data = json.data || {};
|
||||
const state = applyTaskState(data);
|
||||
syncUrl(data.task_id, data.task_no);
|
||||
if (state === 'running' && !pollTimer) {
|
||||
startPoll();
|
||||
}
|
||||
} catch (e) {
|
||||
if (!silent) showError(e.message || String(e));
|
||||
}
|
||||
}
|
||||
|
||||
el('btnStart').addEventListener('click', startDetect);
|
||||
el('btnRefresh').addEventListener('click', () => refreshProgress(false));
|
||||
el('url').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') startDetect();
|
||||
});
|
||||
|
||||
// 进度页直开:未完成显示进度样式,完成后显示结果样式
|
||||
if (PAGE_ERROR) {
|
||||
showError(PAGE_ERROR);
|
||||
}
|
||||
if (INIT_TASK_ID) {
|
||||
currentTaskId = Number(INIT_TASK_ID) || 0;
|
||||
el('taskId').value = currentTaskId || '';
|
||||
}
|
||||
if (INIT_TASK_NO) {
|
||||
currentTaskNo = String(INIT_TASK_NO);
|
||||
el('taskNo').value = currentTaskNo;
|
||||
}
|
||||
if (INIT_DATA && typeof INIT_DATA === 'object') {
|
||||
const state = applyTaskState(INIT_DATA);
|
||||
if (state === 'running') {
|
||||
startPoll();
|
||||
}
|
||||
} else if (currentTaskId || currentTaskNo) {
|
||||
setPageMode('mode-progress');
|
||||
refreshProgress(true);
|
||||
} else {
|
||||
setPageMode('mode-idle');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user