Files
tougao/application/api/view/aiTools/index.html
wyn 8785610e6d 参考文献作者堆叠
参考文献相关性检测
作者ai写作辅助检测工作
2026-07-15 10:49:05 +08:00

850 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>