参考文献作者堆叠

参考文献相关性检测
作者ai写作辅助检测工作
This commit is contained in:
wyn
2026-07-15 10:49:05 +08:00
parent da71dfc04e
commit 8785610e6d
27 changed files with 10008 additions and 204 deletions

View 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,
]);
}
/**
* 是否返回 JSONPOST、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);
}
}

View File

@@ -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())

View File

@@ -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([]);
}

View File

@@ -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可选默认 01 返回作者明细), 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());
}
}
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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>