Merge remote-tracking branch 'origin/master'

This commit is contained in:
wangjinlei
2026-08-03 18:06:32 +08:00
15 changed files with 3098 additions and 143 deletions

View File

@@ -10,6 +10,7 @@ use PhpOffice\PhpWord\IOFactory;
use app\common\OpenAi;
use app\common\CrossrefService;
use app\common\PubmedService;
use app\common\ArticleParserService;
/**
* @title 文章接口
@@ -1181,6 +1182,213 @@ class Article extends Base
return jsonSuccess($re);
}
/**
* @title 判断作者是否已按审稿意见修改(逐条核对最终版)
* @description 异步检测:立即返回检测中,后台执行 LLM请轮询 getRevisionCommentCheckResult
* @param name:article_id type:int require:1 desc:文章id
* @param name:type type:string require:0 desc:不传=综合(意见+回复信+修回稿)manuscript/response/comment 为单侧触发
* @param name:force type:int require:0 desc:1强制重跑忽略缓存
* @param name:mode type:string require:0 desc:full=逐条核对(默认) quick=仅时间门
*
* @url /api/Article/checkAuthorRevisedByReview
* @method POST
*/
public function checkAuthorRevisedByReview()
{
$data = $this->request->post();
$rule = new Validate([
'article_id' => 'require|number',
]);
if (!$rule->check($data)) {
return jsonError($rule->getError());
}
$articleId = intval($data['article_id']);
$mode = strtolower(trim((string)($data['mode'] ?? 'full')));
$force = !empty($data['force']) ? 1 : 0;
try {
$service = new \app\common\service\RevisionCommentMatchService();
$checkType = $service->normalizeCheckType($data['type'] ?? '');
if ($mode === 'quick') {
$quick = $service->quickCheck($articleId);
$quick['check_type'] = $checkType;
return jsonSuccess($quick);
}
$result = $service->check($articleId, [
'force' => $force,
'use_cache' => $force ? 0 : 1,
'type' => $checkType,
'async' => 1,
]);
$result['mode'] = 'full';
return jsonSuccess($result);
} catch (\Exception $e) {
return jsonError($e->getMessage());
}
}
/**
* @title 获取/触发作者按审稿意见修改的逐条核对结果
* @description 有成功结果直接返回;排队/检测中返回 status=1无任务则投递 RabbitMQ。type 不传=综合检测(意见+回复信+修回稿)
* @param name:article_id type:int require:1 desc:文章id
* @param name:type type:string require:0 desc:不传=综合manuscript/response/comment
* @param name:force type:int require:0 desc:1强制重新检测忽略缓存
* @url /api/Article/getRevisionCommentCheckResult
* @method POST
*/
public function getRevisionCommentCheckResult()
{
$data = $this->request->post();
$rule = new Validate([
'article_id' => 'require|number',
]);
if (!$rule->check($data)) {
return jsonError($rule->getError());
}
$articleId = intval($data['article_id']);
$force = !empty($data['force']) ? 1 : 0;
try {
$service = new \app\common\service\RevisionCommentMatchService();
$checkType = $service->normalizeCheckType($data['type'] ?? '');
if (!$force) {
// 未传 force始终返回该文章该类型「最新一条」检测记录的进度/结果
$latest = $service->getLatestJob($articleId, $checkType, null);
if (!empty($latest)) {
$latestStatus = intval($latest['status'] ?? 0);
if ($latestStatus === \app\common\service\RevisionCommentMatchService::STATUS_SUCCESS) {
$detail = $service->getCheckDetail(intval($latest['id']));
$detail['mode'] = 'full';
return jsonSuccess($detail);
}
$meta = json_decode((string)($latest['result_json'] ?? ''), true);
if (!is_array($meta)) {
$meta = [];
}
if ($latestStatus === \app\common\service\RevisionCommentMatchService::STATUS_FAIL) {
return jsonSuccess([
'article_id' => $articleId,
'check_id' => intval($latest['id']),
'check_no' => (string)($latest['check_no'] ?? ''),
'check_type' => $checkType,
'status' => \app\common\service\RevisionCommentMatchService::STATUS_FAIL,
'status_text' => '检测失败',
'message' => $meta['message'] ?? ($latest['error_msg'] ?? '检测失败'),
'error_msg' => $latest['error_msg'] ?? '',
'mode' => 'full',
]);
}
return jsonSuccess([
'article_id' => $articleId,
'check_id' => intval($latest['id']),
'check_no' => (string)($latest['check_no'] ?? ''),
'check_type' => $checkType,
'check_type_label' => $meta['check_type_label'] ?? '',
'status' => \app\common\service\RevisionCommentMatchService::STATUS_RUNNING,
'status_text' => '检测中',
'message' => $meta['message'] ?? '检测进行中,请稍后轮询',
'mode' => 'full',
]);
}
}
$result = $service->check($articleId, [
'force' => $force ? 1 : 0,
'use_cache' => $force ? 0 : 1,
'type' => $checkType,
'async' => 1,
]);
$result['mode'] = 'full';
return jsonSuccess($result);
} catch (\Exception $e) {
return jsonError($e->getMessage());
}
}
/**
* @title 审稿意见修改检测可视化报告页
* @description 渲染检测报告页面;页面内可触发检测并实时查看逐条结果
* @url /api/Article/revisionCommentCheckReport
* @method GET
*/
public function revisionCommentCheckReport()
{
$apiGetResult = '/index.php/api/Article/getRevisionCommentCheckResult';
$apiGetDetail = '/index.php/api/Article/getRevisionCommentCheckDetail';
$this->assign([
'api_get_result' => $apiGetResult,
'api_get_detail' => $apiGetDetail,
'init_article_id' => intval($this->request->param('article_id', 0)),
'init_check_id' => intval($this->request->param('check_id', 0)),
'init_type' => (string)$this->request->param('type', 'all'),
]);
return $this->fetch('article/revision_comment_check_report');
}
/**
* @title 审稿意见修改检测记录列表
* @description 仅返回某篇文章最新一条检测记录摘要(含明细条数);详情用 getRevisionCommentCheckDetail
* @param name:article_id type:int require:1 desc:文章id
* @param name:type type:string require:0 desc:按检测类型过滤,不传=全部
* @param name:page type:int require:0 desc:页码默认1
* @param name:page_size type:int require:0 desc:每页条数默认20
* @url /api/Article/listRevisionCommentCheck
* @method POST
*/
public function listRevisionCommentCheck()
{
$data = $this->request->post();
$rule = new Validate([
'article_id' => 'require|number',
]);
if (!$rule->check($data)) {
return jsonError($rule->getError());
}
try {
$service = new \app\common\service\RevisionCommentMatchService();
$list = $service->listChecks(
intval($data['article_id']),
intval($data['page'] ?? 1),
intval($data['page_size'] ?? ($data['pageSize'] ?? 20)),
(string)($data['type'] ?? '')
);
return jsonSuccess($list);
} catch (\Exception $e) {
return jsonError($e->getMessage());
}
}
/**
* @title 审稿意见修改检测记录详情(含每条结果)
* @description 按 check_id 返回完整检测结果items 来自明细表,逐条含意见文本、质量评估、是否落实、证据与理由
* @param name:check_id type:int require:1 desc:检测记录id
* @url /api/Article/getRevisionCommentCheckDetail
* @method POST
*/
public function getRevisionCommentCheckDetail()
{
$data = $this->request->post();
$rule = new Validate([
'check_id' => 'require|number',
]);
if (!$rule->check($data)) {
return jsonError($rule->getError());
}
try {
$service = new \app\common\service\RevisionCommentMatchService();
$detail = $service->getCheckDetail(intval($data['check_id']));
$detail['mode'] = 'full';
return jsonSuccess($detail);
} catch (\Exception $e) {
return jsonError($e->getMessage());
}
}
/**获取用户所投的文章
* @return \think\response\Json|void
@@ -3221,11 +3429,34 @@ class Article extends Base
}
/**
* 获取文章作者信息
* 支持 article_id查库或 file_url解析稿件不读写库
* @return void
*/
public function getAuthors()
{
$data = $this->request->post();
// 从稿件解析作者(对齐 Contribute 读取逻辑,不入库)
if (!empty($data['file_url'])) {
$sFileUrl = rtrim(ROOT_PATH, '/') . '/public/' . ltrim(ltrim($data['file_url'], '/'), 'public');
if (!file_exists($sFileUrl)) {
return jsonError('The uploaded file does not exist');
}
if (!is_readable($sFileUrl)) {
return jsonError('The uploaded file is unreadable');
}
$aDealData = json_decode(ArticleParserService::uploadAndParse($sFileUrl), true);
$iStatus = empty($aDealData['status']) ? 0 : $aDealData['status'];
if ($iStatus != 1) {
return jsonError(empty($aDealData['msg']) ? 'Content parsing failed' : $aDealData['msg']);
}
$aParseData = empty($aDealData['data']) ? [] : $aDealData['data'];
$re['authors'] = $this->buildAuthorsFromParseData($aParseData);
return jsonSuccess($re);
}
$rule = new Validate([
"article_id" => "require"
]);
@@ -3946,6 +4177,12 @@ class Article extends Base
if (isset($data['is_agree'])) {
$update_l['is_agree'] = $data['is_agree'];
}
if (isset($data['special_num'])) {
$update_l['special_num'] = intval($data['special_num']);
}
if (isset($data['special_title'])) {
$update_l['special_title'] = trim((string)$data['special_title']);
}
if(!empty($sArticleSn)){
$update_l['accept_sn'] = $sArticleSn;
@@ -5737,7 +5974,9 @@ class Article extends Base
// if(empty($iJournalId)){
// return json_encode(['status' => 2,'msg' => 'Please select a journal']);
// }
$aArticleInsert = ['journal_id' => $iJournalId,'title' => $sTitle,'state' => -1,'user_id' => $iUserId];
$aArticleInsert = ['journal_id' => $iJournalId,'title' => $sTitle,'abstrart' => '','use_ai_explain' => '','state' => -1,'user_id' => $iUserId];
if(isset($aParam['special_num']))$aArticleInsert['special_num'] = $aParam['special_num'];
if(isset($aParam['special_title']))$aArticleInsert['special_title'] = $aParam['special_title'];
$aArticleInsert['is_use_ai'] = 3;
$aArticleInsert['is_figure_copyright'] = 3;
// $aArticleInsert['is_transfer'] = 3;
@@ -5785,7 +6024,7 @@ class Article extends Base
if($becomeRev == false){
$aParam['is_become_reviewer'] = 2;
}
$aField = ['is_use_ai','use_ai_explain','is_figure_copyright','is_become_reviewer','approval','approval_file','approval_content','code','is_become_reviewer','is_agree','title','abstrart','keywords','topics','fund','type','journal_id'];//,'title','abstrart','keywords','topics','fund','type','is_transfer',
$aField = ['is_use_ai','use_ai_explain','is_figure_copyright','is_become_reviewer','approval','approval_file','approval_content','code','is_become_reviewer','is_agree','title','abstrart','keywords','topics','fund','type','journal_id','special_num','special_title'];//,'title','abstrart','keywords','topics','fund','type','is_transfer',
$sMsg = '';
$iIsUpdate = 1;
foreach ($aField as $key => $value) {
@@ -6412,9 +6651,65 @@ class Article extends Base
// $update_result = Db::name('article')->where($aWhere)->limit(1)->update($aArticleUpdate);
// }
//操作日志
$aLog = ['article_id' => $iArticleId,'user_id' => $iUserId,'type' => 7,'create_time' => time(),'content' => $sUserAccount . ':Operating the article copyright statement','is_view' => 1];
$aLog = ['article_id' => $iArticleId,'user_id' => $iUserId,'type' => 7,'create_time' => time(),'update_time' => time(),'content' => $sUserAccount . ':Operating the article copyright statement','is_view' => 1];
Db::name('user_act_log')->insert($aLog);
Db::commit();
return json_encode(['status' => 1,'msg' => 'success']);
}
/**
* 修改未发表文章对应专刊(仅更新 tougao 本地 t_article
* special_num = journal_special_idspecial_title 由前端一并传入
*/
public function changeArticleSpecialForSubmit()
{
$data = $this->request->post();
$rule = new Validate([
'article_id' => 'require',
/*'journal_special_id' => 'require',
'special_title' => 'require',*/
]);
if (!$rule->check($data)) {
return jsonError($rule->getError());
}
$article_info = $this->article_obj->where('article_id', $data['article_id'])->find();
if (!$article_info) {
return jsonError('article not found');
}
$journalSpecialRaw = isset($data['journal_special_id'])&&$data['journal_special_id'] ? trim((string)$data['journal_special_id']) : '';
// journal_special_id 为空:清空专刊信息
if ($journalSpecialRaw === '') {
$update = [
'special_num' => 0,
'special_title' => '',
];
$this->article_obj->where('article_id', $data['article_id'])->update($update);
return jsonSuccess([
'article_id' => intval($data['article_id']),
'special_num' => 0,
'special_title' => '',
]);
}
// journal_special_id 有值special_title 必填
$specialTitle = isset($data['special_title']) ? trim((string)$data['special_title']) : '';
if ($specialTitle === '') {
return jsonError('special_title is required');
}
$specialId = intval($journalSpecialRaw);
$update = [
'special_num' => $specialId,
'special_title' => $specialTitle,
];
$this->article_obj->where('article_id', $data['article_id'])->update($update);
return jsonSuccess([
'article_id' => intval($data['article_id']),
'special_num' => $specialId,
'special_title' => $update['special_title'],
]);
}
}

View File

@@ -25,6 +25,21 @@ class References extends Base
public function __construct(\think\Request $request = null) {
parent::__construct($request);
}
/**
* 参考文献堆叠统计可视化页
* 访问:/api/References/index?p_article_id=xxx
*/
public function index()
{
$pArticleId = intval($this->request->param('p_article_id', 0));
$this->assign([
'api_stats_js' => json_encode((string) url('api/References/referenceStackingStats'), JSON_UNESCAPED_SLASHES),
'init_p_article_id' => $pArticleId,
]);
return $this->fetch('references/index');
}
//OPENAI token
private $sApiKey = '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';
//OPENAI URL
@@ -1847,6 +1862,8 @@ class References extends Base
/**
* 参考文献引用堆叠统计(同作者>15%、同刊>20%、自引>10%,实时计算)
* 仅读本地库,不实时请求 Crossref/OpenAlex。
* 额外返回 author_data_issues未获取到作者 / 作者信息不全的参考文献列表
*
* POST/GET: p_article_id必填
*/
@@ -1871,6 +1888,66 @@ class References extends Base
}
}
/**
* 参考文献堆叠摘要(展示用)
* 返回:同作者名称+比例、同刊名称+比例、自引比例(仅超阈值的同作者/同刊)
*
* POST/GET: p_article_id必填
*/
public function referenceStackingSummary()
{
$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->getStackingSummaryByPArticleId($iPArticleId);
return jsonSuccess($result);
} catch (\Exception $e) {
return jsonError($e->getMessage());
}
}
/**
* 获取单条参考文献作者信息:先 Crossref/OpenAlex或兜底 refer.author同步入库再返回明细
*
* POST/GET: p_article_id必填, p_refer_id必填
*/
public function referenceReferAuthors()
{
$aParam = $this->request->post();
if (empty($aParam)) {
$aParam = $this->request->param();
}
$iPArticleId = empty($aParam['p_article_id']) ? 0 : intval($aParam['p_article_id']);
$iPReferId = empty($aParam['p_refer_id']) ? 0 : intval($aParam['p_refer_id']);
if ($iPArticleId <= 0) {
return json_encode(array('status' => 2, 'msg' => 'Please select an article'));
}
if ($iPReferId <= 0) {
return jsonError('p_refer_id is required');
}
try {
$svc = new ReferenceReferAuthorService();
$result = $svc->fetchAuthorsByPReferId($iPArticleId, $iPReferId);
if (empty($result['refer'])) {
return jsonError('Reference not found');
}
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

File diff suppressed because it is too large Load Diff

View File

@@ -2081,7 +2081,149 @@ class User extends Base
$re['user'] = $user_info;
return jsonSuccess($re);
}
public function createUserForEditor(){
$data = $this->request->post();
$rule = new Validate([
"email"=>"require",
"address"=>"require",
"firstname"=>"require",
"lastname"=>"require",
"intro"=>"require"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$account = isset($data['account'])?$data['account']:$data['email'];
$email = $data['email'];
$res_once = $this->user_obj->where("account='$account' or email = '$email'")->find();
if ($res_once != null) {
return json('existence');
}
Db::startTrans();
$inser_data['account'] = trim($account);
$inser_data['password'] = md5(isset($data['password'])?$data['password']:"123456qwe");
$inser_data['email'] = $email;
if(isset($data['phone']))$inser_data['phone'] = $data['phone'];
if(isset($data['orcid']))$inser_data['orcid'] = $data['orcid'];
$inser_data['realname'] = $data['firstname']." ".$data['lastname'];
$inser_data['icon'] = $data['icon'];
$inser_data['ctime'] = time();
$inser_data['openid'] = "";
$id = $this->user_obj->insertGetId($inser_data);
//存入个人额外信息
$insert_reviewer['reviewer_id'] = $id;
$insert_reviewer['test_from'] = "kzeditor";
$insert_reviewer['address'] = $data['address'];
$insert_reviewer['introduction'] = $data['intro'];
$insert_reviewer['firstname'] = $data['firstname'];
$insert_reviewer['lastname'] = $data['lastname'];
if(isset($data['website']))$insert_reviewer['website'] = $data['website'];
if(isset($data['interests']))$insert_reviewer['interests'] = $data['interests'];
$r_res = $this->user_reviewer_info_obj->insert($insert_reviewer);
if($id&&$r_res){
Db::commit();
return jsonSuccess($id);
}else{
Db::rollback();
return jsonError("system error");
}
}
/**
* 修改客座编辑本地资料
* t_user: orcid、phone、realname(firstname lastname)、icon
* t_user_reviewer_info: address、interests、firstname、lastname、introduction(intro)
*/
public function updateUserForEditor()
{
$data = $this->request->post();
$rule = new Validate([
'user_id' => 'require|number',
'firstname' => 'require',
'lastname' => 'require',
'intro' => 'require',
]);
if (!$rule->check($data)) {
return jsonError($rule->getError());
}
$userId = intval($data['user_id']);
$user = $this->user_obj->where('user_id', $userId)->where('state', 0)->find();
if (!$user) {
return jsonError('user not found');
}
Db::startTrans();
try {
$firstName = trim((string)$data['firstname']);
$lastName = trim((string)$data['lastname']);
$userUpdate = [
'realname' => trim($firstName . ' ' . $lastName),
];
if (isset($data['orcid'])) {
$userUpdate['orcid'] = trim((string)$data['orcid']);
}
if (isset($data['phone'])) {
$userUpdate['phone'] = trim((string)$data['phone']);
}
if (isset($data['icon'])) {
$userUpdate['icon'] = trim((string)$data['icon']);
}
$upUser = $this->user_obj->where('user_id', $userId)->update($userUpdate);
if ($upUser === false) {
throw new \Exception('update t_user failed');
}
$reviewerInfo = $this->user_reviewer_info_obj
->where('reviewer_id', $userId)
->where('state', 0)
->find();
$reviewerUpdate = [
'firstname' => $firstName,
'lastname' => $lastName,
];
if (isset($data['address'])) {
$reviewerUpdate['address'] = trim((string)$data['address']);
}
if (isset($data['website'])) {
$reviewerUpdate['website'] = trim((string)$data['website']);
}
if (isset($data['interests'])) {
$reviewerUpdate['interests'] = trim((string)$data['interests']);
}
$reviewerUpdate['introduction'] = trim((string)$data['intro']);
if ($reviewerInfo) {
$upReviewer = $this->user_reviewer_info_obj
->where('reviewer_id', $userId)
->where('state', 0)
->update($reviewerUpdate);
if ($upReviewer === false) {
throw new \Exception('update t_user_reviewer_info failed');
}
} else {
$reviewerUpdate['reviewer_id'] = $userId;
$reviewerUpdate['test_from'] = 'kzeditor';
$insReviewer = $this->user_reviewer_info_obj->insert($reviewerUpdate);
if (!$insReviewer) {
throw new \Exception('insert t_user_reviewer_info failed');
}
}
Db::commit();
return jsonSuccess([
'user_id' => $userId,
'updated' => 1,
]);
} catch (\Exception $e) {
Db::rollback();
return jsonError($e->getMessage());
}
}
/**
* 注册功能
*/
@@ -3370,4 +3512,17 @@ class User extends Base
}
return implode("\n", $lines);
}
public function up_editorIcon_file()
{
$file = request()->file('icon');
if ($file) {
$info = $file->move(ROOT_PATH . 'public' . DS . 'usericon');
if ($info) {
return json(['code' => 0, 'upurl' => str_replace("\\", "/", $info->getSaveName())]);
} else {
return json(['code' => 1, 'msg' => $file->getError()]);
}
}
}
}

View File

@@ -0,0 +1,674 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>参考文献堆叠统计</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;
--accent: #0f766e;
--accent-soft: #d9f3ef;
--warn: #b45309;
--danger: #b91c1c;
--ok: #047857;
--font: "DM Sans", system-ui, sans-serif;
--display: "Fraunces", Georgia, serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
font-family: var(--font);
color: var(--ink);
line-height: 1.45;
background:
linear-gradient(160deg, rgba(15, 118, 110, 0.18), transparent 42%),
linear-gradient(340deg, rgba(20, 33, 43, 0.08), transparent 40%),
#cfd8df;
display: grid;
place-items: center;
padding: 16px;
overflow: hidden;
}
.window {
width: min(520px, 100%);
height: min(680px, calc(100vh - 32px));
background: #fff;
border-radius: 12px;
border: 1px solid rgba(20, 33, 43, 0.12);
box-shadow:
0 24px 48px rgba(20, 33, 43, 0.18),
0 2px 0 rgba(255, 255, 255, 0.5) inset;
display: flex;
flex-direction: column;
overflow: hidden;
}
.titlebar {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px 10px 14px;
background: linear-gradient(180deg, #f7fafb, #eef3f6);
border-bottom: 1px solid var(--line);
cursor: default;
user-select: none;
}
.titlebar-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.traffic {
display: flex;
gap: 6px;
}
.traffic i {
width: 10px;
height: 10px;
border-radius: 50%;
display: block;
}
.traffic .r { background: #ff5f57; }
.traffic .y { background: #febc2e; }
.traffic .g { background: #28c840; }
.titlebar h1 {
margin: 0;
font-family: var(--display);
font-size: 0.98rem;
font-weight: 650;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.titlebar .sub {
font-size: 0.72rem;
color: var(--muted);
white-space: nowrap;
}
.toolbar {
flex: 0 0 auto;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
background: #fafcfd;
}
.start-grid {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: end;
}
label {
display: block;
font-size: 0.72rem;
font-weight: 600;
color: var(--muted);
margin-bottom: 4px;
}
input[type="number"] {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px 10px;
font: inherit;
font-size: 0.9rem;
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: 8px;
padding: 8px 14px;
font: inherit;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
height: 36px;
}
.btn:disabled { opacity: .55; cursor: not-allowed; }
.btn-primary { background: var(--accent); color: #fff; }
.error {
display: none;
margin-top: 8px;
padding: 8px 10px;
border-radius: 8px;
background: #fef2f2;
color: var(--danger);
border: 1px solid #fecaca;
font-size: 0.8rem;
}
.error.show { display: block; }
.body {
flex: 1 1 auto;
overflow: auto;
padding: 12px;
background: #fff;
}
.results { display: none; }
.results.show { display: block; }
.placeholder {
color: var(--muted);
font-size: 0.86rem;
text-align: center;
padding: 36px 12px;
}
.summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.stat {
border: 1px solid var(--line);
border-radius: 8px;
padding: 8px 10px;
background: #fafcfd;
}
.stat .k { font-size: 0.7rem; color: var(--muted); }
.stat .v {
font-family: var(--display);
font-size: 1.15rem;
font-weight: 650;
margin-top: 2px;
line-height: 1.2;
}
.stat .sub { font-size: 0.72rem; color: var(--muted); margin-top: 2px; }
.pill {
display: inline-flex;
align-items: center;
padding: 1px 7px;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 650;
}
.pill.ok { background: #dcfce7; color: var(--ok); }
.pill.bad { background: #fee2e2; color: var(--danger); }
.pill.warn { background: #ffedd5; color: var(--warn); }
.section-title {
font-size: 0.88rem;
margin: 14px 0 8px;
font-weight: 700;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.section-title .hint {
font-size: 0.72rem;
font-weight: 500;
color: var(--muted);
}
.card-list { display: grid; gap: 8px; }
.item-card {
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px;
background: #fff;
}
.item-card h3 {
margin: 0 0 6px;
font-size: 0.88rem;
font-weight: 650;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
color: var(--muted);
font-size: 0.76rem;
margin-bottom: 6px;
}
.meta strong { color: var(--ink); font-weight: 600; }
.ratio-bar {
height: 5px;
border-radius: 999px;
background: #e7eef2;
overflow: hidden;
margin: 6px 0 8px;
}
.ratio-bar > i {
display: block;
height: 100%;
background: linear-gradient(90deg, #0f766e, #14968c);
border-radius: inherit;
}
.ratio-bar.bad > i { background: linear-gradient(90deg, #b91c1c, #dc2626); }
.ref-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.chip {
display: inline-flex;
padding: 1px 6px;
border-radius: 5px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.7rem;
font-weight: 600;
}
.chip.warn { background: #ffedd5; color: var(--warn); }
.chip.bad { background: #fee2e2; color: var(--danger); }
.ref-list {
margin: 8px 0 0;
padding: 0;
list-style: none;
display: grid;
gap: 4px;
}
.ref-list li {
font-size: 0.74rem;
color: var(--muted);
padding: 6px 8px;
border-radius: 6px;
background: #f7fafb;
border: 1px solid #e8eef2;
max-height: 3.2em;
overflow: hidden;
}
.ref-list .no {
color: var(--accent);
font-weight: 700;
margin-right: 4px;
}
.ref-list a {
color: inherit;
text-decoration: none;
}
.ref-list a:hover {
color: var(--accent);
text-decoration: underline;
}
.chip a {
color: inherit;
text-decoration: none;
}
.chip a:hover { text-decoration: underline; }
.chip.linkable { cursor: pointer; }
.empty {
padding: 12px;
text-align: center;
color: var(--muted);
border: 1px dashed var(--line);
border-radius: 8px;
background: #fafcfd;
font-size: 0.8rem;
}
.note {
margin-top: 6px;
font-size: 0.74rem;
color: var(--muted);
line-height: 1.45;
}
.foot-meta {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--line);
font-size: 0.72rem;
color: var(--muted);
}
.statusbar {
flex: 0 0 auto;
padding: 6px 12px;
border-top: 1px solid var(--line);
background: #f7fafb;
font-size: 0.7rem;
color: var(--muted);
display: flex;
justify-content: space-between;
gap: 8px;
}
</style>
</head>
<body>
<div class="window" role="dialog" aria-label="参考文献堆叠统计">
<div class="titlebar">
<div class="titlebar-left">
<div class="traffic" aria-hidden="true"><i class="r"></i><i class="y"></i><i class="g"></i></div>
<div>
<h1>参考文献堆叠统计</h1>
<div class="sub">同作者 &gt;15% · 同刊 &gt;20% · 自引 &gt;10%</div>
</div>
</div>
</div>
<div class="toolbar">
<div class="start-grid">
<div>
<label for="pArticleId">p_article_id</label>
<input id="pArticleId" type="number" min="1" placeholder="例如 3649" value="{$init_p_article_id|default=0}" />
</div>
<button class="btn btn-primary" id="btnLoad" type="button">查询</button>
</div>
<div class="error" id="errorBox"></div>
</div>
<div class="body">
<div class="placeholder" id="placeholder">输入 p_article_id 后点击查询</div>
<div class="results" id="results">
<div class="summary" id="summary"></div>
<h2 class="section-title">
同作者堆叠
<span class="hint" id="authorThreshold"></span>
<span class="pill" id="authorPill"></span>
</h2>
<div id="authorSection"></div>
<h2 class="section-title">
同刊堆叠
<span class="hint" id="journalThreshold"></span>
<span class="pill" id="journalPill"></span>
</h2>
<div id="journalSection"></div>
<h2 class="section-title">
自引
<span class="hint" id="selfThreshold"></span>
<span class="pill" id="selfPill"></span>
</h2>
<div id="selfSection"></div>
<h2 class="section-title">
作者数据问题
<span class="hint">缺失 / 不全</span>
</h2>
<div id="issueSection"></div>
<p class="foot-meta" id="footMeta"></p>
</div>
</div>
<div class="statusbar">
<span>Stacking Stats</span>
<span id="statusText">就绪</span>
</div>
</div>
<script>
(function () {
var API_STATS = {$api_stats_js};
var input = document.getElementById('pArticleId');
var btn = document.getElementById('btnLoad');
var errorBox = document.getElementById('errorBox');
var results = document.getElementById('results');
var placeholder = document.getElementById('placeholder');
var statusText = document.getElementById('statusText');
if (Number(input.value) === 0) input.value = '';
function showError(msg) {
errorBox.textContent = msg || '请求失败';
errorBox.classList.add('show');
statusText.textContent = '出错';
}
function clearError() {
errorBox.textContent = '';
errorBox.classList.remove('show');
}
function pct(ratio) {
return (Number(ratio || 0) * 100).toFixed(1) + '%';
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function setPill(el, exceeded) {
el.className = 'pill ' + (exceeded ? 'bad' : 'ok');
el.textContent = exceeded ? '已超阈值' : '未超阈值';
}
function renderRefs(list) {
if (!list || !list.length) return '';
var html = '<ul class="ref-list">';
list.forEach(function (r) {
var text = escapeHtml(r.refer_text || ('p_refer_id=' + r.p_refer_id));
var no = '[' + escapeHtml(r.reference_no) + ']';
var url = String(r.url || '').trim();
if (url) {
html += '<li><a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer" title="打开文献">'
+ '<span class="no">' + no + '</span>' + text + '</a></li>';
} else {
html += '<li><span class="no">' + no + '</span>' + text + '</li>';
}
});
html += '</ul>';
return html;
}
function renderChips(nos, cls, refs) {
if (!nos || !nos.length) return '<span class="chip">无</span>';
var urlMap = {};
(refs || []).forEach(function (r) {
if (r && r.reference_no != null && r.url) {
urlMap[String(r.reference_no)] = String(r.url);
}
});
return nos.map(function (n) {
var url = urlMap[String(n)] || '';
var label = '[' + escapeHtml(n) + ']';
if (url) {
return '<span class="chip linkable ' + (cls || '') + '"><a href="'
+ escapeHtml(url) + '" target="_blank" rel="noopener noreferrer" title="打开文献">'
+ label + '</a></span>';
}
return '<span class="chip ' + (cls || '') + '">' + label + '</span>';
}).join('');
}
function renderAuthor(data) {
var block = data.same_author_stacking || {};
document.getElementById('authorThreshold').textContent = '阈值 > ' + pct(block.threshold);
setPill(document.getElementById('authorPill'), !!block.exceeded);
var items = block.items || [];
var box = document.getElementById('authorSection');
if (!items.length) {
box.innerHTML = '<div class="empty">未发现超过阈值的同作者堆叠</div>';
return;
}
box.innerHTML = '<div class="card-list">' + items.map(function (it) {
return '<div class="item-card">'
+ '<h3>' + escapeHtml(it.author_name || '未知作者') + '</h3>'
+ '<div class="meta">'
+ '<span>引用 <strong>' + escapeHtml(it.cite_count) + '</strong> 条</span>'
+ '<span>占比 <strong>' + pct(it.cite_ratio) + '</strong></span>'
+ (it.orcid ? '<span>ORCID <strong>' + escapeHtml(it.orcid) + '</strong></span>' : '')
+ '</div>'
+ '<div class="ratio-bar bad"><i style="width:' + Math.min(100, Number(it.cite_ratio || 0) * 100) + '%"></i></div>'
+ '<div class="ref-chips">' + renderChips(it.reference_nos, '', it.references) + '</div>'
+ renderRefs(it.references)
+ '</div>';
}).join('') + '</div>';
}
function renderJournal(data) {
var block = data.same_journal_stacking || {};
document.getElementById('journalThreshold').textContent = '阈值 > ' + pct(block.threshold);
setPill(document.getElementById('journalPill'), !!block.exceeded);
var items = block.items || [];
var box = document.getElementById('journalSection');
if (!items.length) {
box.innerHTML = '<div class="empty">未发现超过阈值的同刊堆叠</div>';
return;
}
box.innerHTML = '<div class="card-list">' + items.map(function (it) {
return '<div class="item-card">'
+ '<h3>' + escapeHtml(it.journal_name || '未知期刊') + '</h3>'
+ '<div class="meta">'
+ '<span>引用 <strong>' + escapeHtml(it.cite_count) + '</strong> 条</span>'
+ '<span>占比 <strong>' + pct(it.cite_ratio) + '</strong></span>'
+ '</div>'
+ '<div class="ratio-bar bad"><i style="width:' + Math.min(100, Number(it.cite_ratio || 0) * 100) + '%"></i></div>'
+ '<div class="ref-chips">' + renderChips(it.reference_nos, '', it.references) + '</div>'
+ renderRefs(it.references)
+ '</div>';
}).join('') + '</div>';
}
function renderSelf(data) {
var block = data.self_citation || {};
document.getElementById('selfThreshold').textContent = '阈值 > ' + pct(block.threshold);
setPill(document.getElementById('selfPill'), !!block.exceeded);
var box = document.getElementById('selfSection');
var items = block.items || [];
var head = '<div class="item-card">'
+ '<div class="meta">'
+ '<span>自引条数 <strong>' + escapeHtml(block.cite_count || 0) + '</strong></span>'
+ '<span>占比 <strong>' + pct(block.cite_ratio) + '</strong></span>'
+ '</div>'
+ '<div class="ratio-bar ' + (block.exceeded ? 'bad' : '') + '"><i style="width:' + Math.min(100, Number(block.cite_ratio || 0) * 100) + '%"></i></div>'
+ '<div class="ref-chips">' + renderChips(block.reference_nos, block.exceeded ? 'bad' : '', items.map(function (it) { return it.reference; }).filter(Boolean)) + '</div>'
+ (block.note ? '<p class="note">' + escapeHtml(block.note) + '</p>' : '')
+ '</div>';
if (!items.length) {
box.innerHTML = head + '<div class="empty" style="margin-top:8px">无自引命中</div>';
return;
}
box.innerHTML = head + '<div class="card-list" style="margin-top:8px">' + items.map(function (it) {
return '<div class="item-card">'
+ '<h3>参考文献 [' + escapeHtml(it.reference_no) + ']</h3>'
+ '<div class="meta">'
+ '<span>本文作者 <strong>' + escapeHtml(it.manuscript_author || '-') + '</strong></span>'
+ '<span>匹配文献作者 <strong>' + escapeHtml(it.matched_refer_author || '-') + '</strong></span>'
+ (it.manuscript_orcid ? '<span>ORCID <strong>' + escapeHtml(it.manuscript_orcid) + '</strong></span>' : '')
+ '</div>'
+ (it.reference ? renderRefs([it.reference]) : '')
+ '</div>';
}).join('') + '</div>';
}
function renderIssues(data) {
var issues = data.author_data_issues || {};
var box = document.getElementById('issueSection');
var items = issues.items || [];
var refsForChips = items.map(function (it) { return it.reference; }).filter(Boolean);
var head = '<div class="item-card"><div class="meta">'
+ '<span>缺失 <strong>' + escapeHtml(issues.missing_count || 0) + '</strong></span>'
+ '<span>不全 <strong>' + escapeHtml(issues.incomplete_count || 0) + '</strong></span>'
+ '</div>'
+ '<div class="ref-chips" style="margin-top:6px">'
+ '<span style="font-size:0.7rem;color:var(--muted);margin-right:4px">缺失:</span>'
+ renderChips(issues.missing_reference_nos, 'bad', refsForChips)
+ '<span style="font-size:0.7rem;color:var(--muted);margin:0 4px 0 8px">不全:</span>'
+ renderChips(issues.incomplete_reference_nos, 'warn', refsForChips)
+ '</div></div>';
if (!items.length) {
box.innerHTML = head + '<div class="empty" style="margin-top:8px">作者数据无明显问题</div>';
return;
}
box.innerHTML = head + '<div class="card-list" style="margin-top:8px">' + items.map(function (it) {
var cls = it.issue_type === 'missing' ? 'bad' : 'warn';
return '<div class="item-card">'
+ '<h3>[' + escapeHtml(it.reference_no) + '] '
+ '<span class="pill ' + cls + '">' + escapeHtml(it.issue_type) + '</span></h3>'
+ '<div class="meta">'
+ '<span>原因 <strong>' + escapeHtml(it.reason || '-') + '</strong></span>'
+ '<span>来源 <strong>' + escapeHtml(it.author_source || '-') + '</strong></span>'
+ '<span>作者数 <strong>' + escapeHtml(it.author_count || 0) + '</strong></span>'
+ '</div>'
+ (it.raw_author ? '<p class="note">raw_author: ' + escapeHtml(it.raw_author) + '</p>' : '')
+ (it.reference ? renderRefs([it.reference]) : '')
+ '</div>';
}).join('') + '</div>';
}
function renderAll(data) {
var authorExceeded = !!(data.same_author_stacking && data.same_author_stacking.exceeded);
var journalExceeded = !!(data.same_journal_stacking && data.same_journal_stacking.exceeded);
var selfExceeded = !!(data.self_citation && data.self_citation.exceeded);
var issues = data.author_data_issues || {};
document.getElementById('summary').innerHTML =
'<div class="stat"><div class="k">参考文献总数</div><div class="v">' + escapeHtml(data.total_references || 0) + '</div></div>'
+ '<div class="stat"><div class="k">同作者堆叠</div><div class="v">' + ((data.same_author_stacking && data.same_author_stacking.items) || []).length + '</div><div class="sub">' + (authorExceeded ? '已超阈值' : '未超阈值') + '</div></div>'
+ '<div class="stat"><div class="k">同刊堆叠</div><div class="v">' + ((data.same_journal_stacking && data.same_journal_stacking.items) || []).length + '</div><div class="sub">' + (journalExceeded ? '已超阈值' : '未超阈值') + '</div></div>'
+ '<div class="stat"><div class="k">自引 / 作者问题</div><div class="v">' + escapeHtml((data.self_citation && data.self_citation.cite_count) || 0) + ' / ' + escapeHtml((issues.missing_count || 0) + (issues.incomplete_count || 0)) + '</div><div class="sub">' + (selfExceeded ? '自引已超阈值' : '自引未超阈值') + '</div></div>';
renderAuthor(data);
renderJournal(data);
renderSelf(data);
renderIssues(data);
document.getElementById('footMeta').textContent =
'p_article_id=' + (data.p_article_id || '-')
+ ' · article_id=' + (data.article_id || '-')
+ ' · ' + (data.computed_at || '-');
placeholder.style.display = 'none';
results.classList.add('show');
statusText.textContent = '共 ' + (data.total_references || 0) + ' 条 · ' + (data.computed_at || '');
}
function load() {
clearError();
var id = parseInt(input.value, 10) || 0;
if (id <= 0) {
showError('请填写有效的 p_article_id');
return;
}
btn.disabled = true;
btn.textContent = '…';
statusText.textContent = '查询中';
var url = API_STATS + (API_STATS.indexOf('?') >= 0 ? '&' : '?') + 'p_article_id=' + encodeURIComponent(id);
fetch(url, {
method: 'GET',
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
}).then(function (res) {
return res.json();
}).then(function (json) {
if (!json || Number(json.code) !== 0) {
throw new Error((json && json.msg) || '接口返回失败');
}
renderAll(json.data || {});
try {
var u = new URL(window.location.href);
u.searchParams.set('p_article_id', String(id));
window.history.replaceState({}, '', u.toString());
} catch (e) {}
}).catch(function (err) {
results.classList.remove('show');
placeholder.style.display = '';
showError(err.message || String(err));
}).finally(function () {
btn.disabled = false;
btn.textContent = '查询';
});
}
btn.addEventListener('click', load);
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') load();
});
if (parseInt(input.value, 10) > 0) load();
})();
</script>
</body>
</html>