参考文献作者堆叠

参考文献相关性检测
作者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,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>