工单管理支持上传图片(兰空图床转存)
- 新增 lsky.php:兰空图床 API 客户端,token 缓存+提前60秒续签+401自动重签重试 - 新增 lsky_keys.php:图床凭据模板(仓库仅占位符,真值只在服务器,永不上传) - api/ticket.php:新增 upload_image 动作,限 png/jpg、单张≤2048K、内容与后缀必须一致、同会话每分钟限12次;web_tickets 增 images 列(JSON 数组,上限3张,URL 仅限自家图床) - core.php:权威建表补 images 列 + 老库幂等 ALTER 迁移 - player/admin 两端:上传即暂存、光标处插入 [图片N] 滚动占位符、上限3张;提交时随工单入库 - player/admin/service 三端详情:占位符还原为 markdown 图片,未引用图片补在末尾
This commit is contained in:
@@ -2255,7 +2255,7 @@ async function viewAdminTicket(id) {
|
||||
<div style="font-size:13px;color:var(--dim);margin-bottom:12px">
|
||||
类型: ${typeMap[t.type]||t.type} | 提交者: ${escAdmHtml(t.requester)} | 处理人: ${t.assigned_to ? escAdmHtml(t.assigned_to) : '未分配'} | 时间: ${date}
|
||||
</div>
|
||||
<div style="background:rgba(88,166,255,0.05);border:1px solid var(--border);border-radius:6px;padding:12px;margin-bottom:12px;font-size:13px;line-height:1.6">${adminRenderMd(t.description || '无描述')}</div>`;
|
||||
<div style="background:rgba(88,166,255,0.05);border:1px solid var(--border);border-radius:6px;padding:12px;margin-bottom:12px;font-size:13px;line-height:1.6">${adminRenderMd(applyTicketImgs(t.description || '无描述', t.images))}</div>`;
|
||||
|
||||
// 驳回原因
|
||||
if (t.reject_reason) {
|
||||
@@ -2379,6 +2379,12 @@ function adminCreateTicketUI() {
|
||||
<label style="font-size:12px;color:var(--dim)">描述(支持Markdown)</label>
|
||||
<textarea id="admNewDesc" rows="4" style="width:100%;padding:6px;border-radius:4px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:13px;margin-top:4px;resize:vertical;font-family:monospace" placeholder="工单描述"></textarea>
|
||||
</div>
|
||||
<div style="margin-bottom:10px">
|
||||
<label style="font-size:12px;color:var(--dim)">附件图片(最多 3 张,png/jpg,≤2048K)</label>
|
||||
<input type="file" id="admNewImg" accept=".png,.jpg,image/png,image/jpeg" multiple onchange="adminUploadTicketImage(this)" style="font-size:12px;margin-top:4px">
|
||||
<div id="admImgList" style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px"></div>
|
||||
<p id="admImgMsg" style="color:var(--red);font-size:11px;margin-top:4px"></p>
|
||||
</div>
|
||||
<div style="margin-bottom:10px">
|
||||
<label style="font-size:12px;color:var(--dim)">派发服务商(可选)</label>
|
||||
<input id="admNewProvider" style="width:100%;padding:6px;border-radius:4px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:13px;margin-top:4px" placeholder="服务商玩家名(留空则进入抢单大厅)">
|
||||
@@ -2392,6 +2398,64 @@ function adminCreateTicketUI() {
|
||||
c.innerHTML = html;
|
||||
}
|
||||
|
||||
// ===== 管理端创建工单附件图(session 认证,无需 token)=====
|
||||
let admImages = [];
|
||||
|
||||
async function adminUploadTicketImage(input) {
|
||||
const msg = document.getElementById('admImgMsg');
|
||||
if (!input.files || !input.files.length) return;
|
||||
const files = Array.from(input.files);
|
||||
input.value = '';
|
||||
for (const file of files) {
|
||||
if (admImages.length >= 3) { if (msg) { msg.style.color = 'var(--red)'; msg.textContent = '最多只能上传 3 张图片'; } break; }
|
||||
const ext = (file.name.split('.').pop() || '').toLowerCase();
|
||||
if (ext !== 'png' && ext !== 'jpg') { if (msg) { msg.style.color = 'var(--red)'; msg.textContent = '仅支持 png / jpg:' + file.name; } continue; }
|
||||
if (file.size > 2048 * 1024) { if (msg) { msg.style.color = 'var(--red)'; msg.textContent = '超过 2048K:' + file.name; } continue; }
|
||||
if (msg) { msg.style.color = 'var(--dim)'; msg.textContent = '上传中… ' + file.name; }
|
||||
try {
|
||||
const url = new URL('api/ticket.php', location.href);
|
||||
url.searchParams.set('action', 'upload_image');
|
||||
const fd = new FormData();
|
||||
fd.append('image', file, file.name);
|
||||
const res = await fetch(url, { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
if (!data.success) { if (msg) { msg.style.color = 'var(--red)'; msg.textContent = data.message; } continue; }
|
||||
admImages.push(data.data.url);
|
||||
renderAdmImgs();
|
||||
const fi = document.getElementById('admNewImg');
|
||||
if (fi) fi.disabled = admImages.length >= 3;
|
||||
if (msg) { msg.style.color = 'var(--green)'; msg.textContent = '已上传 ' + admImages.length + '/3 张'; }
|
||||
} catch (e) {
|
||||
if (msg) { msg.style.color = 'var(--red)'; msg.textContent = '上传失败: ' + e.message; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderAdmImgs() {
|
||||
const box = document.getElementById('admImgList');
|
||||
if (!box) return;
|
||||
box.innerHTML = admImages.map((u, i) =>
|
||||
`<div style="position:relative;width:64px;height:64px;border:1px solid var(--border);border-radius:4px;overflow:hidden">
|
||||
<img src="${escAdmHtml(u)}" style="width:100%;height:100%;object-fit:cover">
|
||||
<span style="position:absolute;left:0;bottom:0;background:rgba(0,0,0,.6);color:#fff;font-size:10px;padding:1px 4px">图片${i + 1}</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// 详情渲染:[图片N] 占位符 -> markdown 图片;未被引用的图片补在末尾
|
||||
function applyTicketImgs(desc, images) {
|
||||
let out = String(desc || '');
|
||||
if (!images || !images.length) return out;
|
||||
const used = new Array(images.length).fill(false);
|
||||
out = out.replace(/\[图片(\d+)\]/g, function(m, n) {
|
||||
const i = parseInt(n, 10) - 1;
|
||||
if (i >= 0 && i < images.length && !used[i]) { used[i] = true; return ''; }
|
||||
return m;
|
||||
});
|
||||
const left = images.filter(function(u, k) { return !used[k]; });
|
||||
if (left.length) out += (out ? '\n\n' : '') + left.map(function(u) { return ''; }).join('\n\n');
|
||||
return out;
|
||||
}
|
||||
|
||||
async function adminSubmitCreateTicket() {
|
||||
const type = document.getElementById('admNewType').value;
|
||||
const title = document.getElementById('admNewTitle').value.trim();
|
||||
@@ -2402,9 +2466,13 @@ async function adminSubmitCreateTicket() {
|
||||
try {
|
||||
const url = new URL('api/ticket.php', location.href);
|
||||
url.searchParams.set('action', 'admin_create');
|
||||
const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body: JSON.stringify({type, title, description: desc, provider})});
|
||||
const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body: JSON.stringify({type, title, description: desc, provider, images: admImages})});
|
||||
const data = await res.json();
|
||||
if (data.success) { loadTicketList(document.getElementById('C')); } else { errEl.textContent = data.message; }
|
||||
if (data.success) {
|
||||
admImages = []; // 已创建,附件清空
|
||||
const fi = document.getElementById('admNewImg'); if (fi) fi.disabled = false;
|
||||
loadTicketList(document.getElementById('C'));
|
||||
} else { errEl.textContent = data.message; }
|
||||
} catch (e) { errEl.textContent = '创建失败: ' + e.message; }
|
||||
}
|
||||
|
||||
|
||||
+105
-2
@@ -25,6 +25,8 @@ register_shutdown_function(function() {
|
||||
});
|
||||
|
||||
require_once __DIR__ . '/../core.php';
|
||||
// 工单附件图:兰空图床客户端(本文件不含敏感值,凭据在 lsky_keys.php)
|
||||
require_once __DIR__ . '/../lsky.php';
|
||||
|
||||
if (session_status() === PHP_SESSION_NONE) { session_start(); }
|
||||
|
||||
@@ -55,6 +57,7 @@ switch ($action) {
|
||||
case 'complete': ticketProviderComplete(); break;
|
||||
|
||||
// ===== 通用 =====
|
||||
case 'upload_image': ticketUploadImage(); break;
|
||||
case 'providers': ticketProviders(); break;
|
||||
case 'provider_check': ticketProviderCheck(); break;
|
||||
|
||||
@@ -148,6 +151,93 @@ function getServiceProviderRole($playerName) {
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 单张工单附件图上限
|
||||
define('TICKET_MAX_IMAGES', 3);
|
||||
|
||||
/**
|
||||
* 读取并校验请求里的 images 参数(数组或 JSON 字符串均可),返回 URL 列表。
|
||||
* 每个 URL 必须以自家图床前缀开头(LSKY_ALLOWED_HOST_PREFIX),最多 3 张。
|
||||
*/
|
||||
function normalizeTicketImages() {
|
||||
$raw = getParam('images', []);
|
||||
if (is_string($raw)) $raw = json_decode($raw, true);
|
||||
if ($raw === null || $raw === '') $raw = [];
|
||||
if (!is_array($raw)) error('images 参数格式错误');
|
||||
$prefix = LSKY_ALLOWED_HOST_PREFIX;
|
||||
$out = [];
|
||||
foreach ($raw as $u) {
|
||||
if (!is_string($u)) error('图片地址格式错误');
|
||||
$u = trim($u);
|
||||
if ($u === '') continue;
|
||||
if (strpos($u, $prefix) !== 0) error('不支持的图片地址(仅接受自家图床)');
|
||||
$out[] = $u;
|
||||
if (count($out) >= TICKET_MAX_IMAGES) break;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function encodeTicketImages(array $list) {
|
||||
return json_encode(array_values($list), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
function decodeTicketImages($raw) {
|
||||
if (!is_string($raw) || $raw === '') return [];
|
||||
$d = json_decode($raw, true);
|
||||
return is_array($d) ? $d : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传工单附件图片:转存自家兰空图床,返回可直接引用的 URL。
|
||||
* 认证:管理后台 session 或用户 token(getAuthUser)。
|
||||
* 限制:仅 png/jpg、单张 ≤2048K、内容必须与后缀一致;同会话每分钟最多 12 次(防刷)。
|
||||
*/
|
||||
function ticketUploadImage() {
|
||||
getAuthUser();
|
||||
|
||||
// 简单限流:同一会话 1 分钟内最多 12 张
|
||||
$now = time();
|
||||
$slot = isset($_SESSION['lsky_up']) && is_array($_SESSION['lsky_up']) ? $_SESSION['lsky_up'] : ['t' => 0, 'n' => 0];
|
||||
if ($now - (int)$slot['t'] > 60) $slot = ['t' => $now, 'n' => 0];
|
||||
if ((int)$slot['n'] >= 12) error('图片上传太频繁,请稍后再试');
|
||||
$slot['n']++;
|
||||
$_SESSION['lsky_up'] = $slot;
|
||||
|
||||
if (empty($_FILES) || empty($_FILES['image'])) {
|
||||
error('未收到图片文件(表单字段名必须是 image)');
|
||||
}
|
||||
$f = $_FILES['image'];
|
||||
if ((int)$f['error'] !== UPLOAD_ERR_OK) {
|
||||
$msgs = [
|
||||
UPLOAD_ERR_INI_SIZE => '图片超过服务器单文件大小上限',
|
||||
UPLOAD_ERR_FORM_SIZE => '图片超过表单大小上限',
|
||||
UPLOAD_ERR_PARTIAL => '图片上传中断,请重试',
|
||||
UPLOAD_ERR_NO_FILE => '未选择文件',
|
||||
UPLOAD_ERR_NO_TMP_DIR => '服务器临时目录缺失',
|
||||
UPLOAD_ERR_CANT_WRITE => '服务器写入临时文件失败',
|
||||
];
|
||||
$m = isset($msgs[$f['error']]) ? $msgs[$f['error']] : ('上传错误码 ' . $f['error']);
|
||||
error($m);
|
||||
}
|
||||
// 后缀:仅 png / jpg
|
||||
$orig = basename((string)$f['name']);
|
||||
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
|
||||
if ($ext !== 'png' && $ext !== 'jpg') error('仅支持 png / jpg 格式');
|
||||
// 大小:≤2048K
|
||||
if ((int)$f['size'] > 2048 * 1024) error('图片不能超过 2048K');
|
||||
if (!is_uploaded_file($f['tmp_name'])) error('非法上传');
|
||||
// 内容真实校验:防伪造后缀上传任意文件
|
||||
$info = @getimagesize($f['tmp_name']);
|
||||
if ($info === false) error('无法识别的图片内容');
|
||||
$itype = (int)$info[2];
|
||||
if ($ext === 'png' && $itype !== IMAGETYPE_PNG) error('文件内容与 .png 后缀不符');
|
||||
if ($ext === 'jpg' && $itype !== IMAGETYPE_JPEG) error('文件内容与 .jpg 后缀不符');
|
||||
|
||||
$r = lskyUploadFile($f['tmp_name'], $orig);
|
||||
if (empty($r['ok'])) error('图片上传失败: ' . (isset($r['msg']) && $r['msg'] !== '' ? $r['msg'] : '未知错误'));
|
||||
|
||||
success(['url' => $r['url'], 'thumbnail' => isset($r['thumb']) ? $r['thumb'] : $r['url']], '上传成功');
|
||||
}
|
||||
|
||||
function ticketCreate() {
|
||||
$info = requireWebToken();
|
||||
$player = $info['player'];
|
||||
@@ -164,16 +254,19 @@ function ticketCreate() {
|
||||
$validTypes = ['bug', 'help', 'report', 'apply', 'other'];
|
||||
if (!in_array($type, $validTypes)) error('无效的工单类型');
|
||||
|
||||
$images = normalizeTicketImages(); // 附件图(≤3,必须来自自家图床)
|
||||
|
||||
$db = getDB();
|
||||
$now = time();
|
||||
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
$stmt = $db->prepare("INSERT INTO web_tickets (type, status, requester, title, description, created_at, updated_at) VALUES (:type, 'submitted', :player, :title, :desc, :now, :now)");
|
||||
$stmt = $db->prepare("INSERT INTO web_tickets (type, status, requester, title, description, images, created_at, updated_at) VALUES (:type, 'submitted', :player, :title, :desc, :images, :now, :now)");
|
||||
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':player', $player, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':title', $title, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':desc', $description, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':images', encodeTicketImages($images), SQLITE3_TEXT);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
$ticketId = $db->lastInsertRowID();
|
||||
@@ -252,6 +345,8 @@ function ticketDetail() {
|
||||
$replies = getTicketReplies($id);
|
||||
|
||||
$row['replies'] = $replies;
|
||||
// 附件图:老库/老工单没有 images 列时兜底为空数组
|
||||
$row['images'] = decodeTicketImages(isset($row['images']) ? $row['images'] : null);
|
||||
success($row);
|
||||
}
|
||||
|
||||
@@ -347,10 +442,15 @@ function ticketListAll() {
|
||||
assigned_to TEXT DEFAULT '',
|
||||
title TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
images TEXT DEFAULT '[]',
|
||||
reject_reason TEXT DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)");
|
||||
// ★ 迁移:老库补 images 列(工单附件图 URL 数组 JSON)
|
||||
try {
|
||||
$db->exec("ALTER TABLE web_tickets ADD COLUMN images TEXT DEFAULT '[]'");
|
||||
} catch (Exception $e) { /* 列已存在 */ }
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_ticket_replies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticket_id INTEGER NOT NULL,
|
||||
@@ -550,16 +650,19 @@ function ticketAdminCreate() {
|
||||
error("$provider 不是服务商");
|
||||
}
|
||||
|
||||
$images = normalizeTicketImages(); // 附件图(≤3,必须来自自家图床)
|
||||
|
||||
$db = getDB();
|
||||
$now = time();
|
||||
|
||||
$db->exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
$stmt = $db->prepare("INSERT INTO web_tickets (type, status, requester, assigned_to, title, description, created_at, updated_at) VALUES (:type, 'replied', 'admin', :provider, :title, :desc, :now, :now)");
|
||||
$stmt = $db->prepare("INSERT INTO web_tickets (type, status, requester, assigned_to, title, description, images, created_at, updated_at) VALUES (:type, 'replied', 'admin', :provider, :title, :desc, :images, :now, :now)");
|
||||
$stmt->bindValue(':type', $type, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':provider', $provider, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':title', $title, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':desc', $description, SQLITE3_TEXT);
|
||||
$stmt->bindValue(':images', encodeTicketImages($images), SQLITE3_TEXT);
|
||||
$stmt->bindValue(':now', $now, SQLITE3_INTEGER);
|
||||
$stmt->execute();
|
||||
$ticketId = $db->lastInsertRowID();
|
||||
|
||||
@@ -612,6 +612,7 @@ function initTables(SQLite3 $db) {
|
||||
assigned_to TEXT DEFAULT '',
|
||||
title TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
images TEXT DEFAULT '[]',
|
||||
reject_reason TEXT DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
@@ -629,6 +630,11 @@ function initTables(SQLite3 $db) {
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_tickets_assigned ON web_tickets(assigned_to)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_web_ticket_replies_ticket ON web_ticket_replies(ticket_id)");
|
||||
|
||||
// ★ 迁移:确保web_tickets有images列(工单附件图URL数组JSON,2026-10-04 新增)
|
||||
try {
|
||||
$db->exec("ALTER TABLE web_tickets ADD COLUMN images TEXT DEFAULT '[]'");
|
||||
} catch (Exception $e) { /* 列已存在 */ }
|
||||
|
||||
// ★ 用户组相关表(从handler函数中提取到这里统一创建,避免并发请求锁竞争)
|
||||
$db->exec("CREATE TABLE IF NOT EXISTS web_user_groups (
|
||||
group_name TEXT PRIMARY KEY,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
/**
|
||||
* 兰空图床(Lsky Pro)API 客户端 —— 工单图片上传专用
|
||||
*
|
||||
* 接口规格(图床官方 API 文档,站点 https://img.ypshidifu.cn/api/v1/):
|
||||
* POST /tokens body {email,password} -> {status, data:{token, expires_at}}
|
||||
* POST /upload multipart: file(+strategy_id)
|
||||
* Header: Authorization: Bearer <token>
|
||||
* -> {status, data:{key,links:{url,html,markdown,...},thumbnail_url,...}}
|
||||
*
|
||||
* 设计要点:
|
||||
* - 凭据在 lsky_keys.php(仓库只有空模板,部署脚本永不上传);
|
||||
* - token 有有效期 -> 文件缓存 + 提前 60 秒自动续签 + 上传遇 401 自动强刷重签重试 1 次;
|
||||
* - 本文件不含任何敏感值,可入库、可批量部署。
|
||||
*
|
||||
* ★ 依赖 core.php 的 error()/success()(本文件只被 api/ticket.php 引入,core 必然已加载)。
|
||||
*/
|
||||
|
||||
// 图床前缀白名单:写库前校验图片 URL 只能来自自家图床(防存任意 URL)。
|
||||
if (!defined('LSKY_ALLOWED_HOST_PREFIX')) {
|
||||
define('LSKY_ALLOWED_HOST_PREFIX', 'https://img.ypshidifu.cn/');
|
||||
}
|
||||
|
||||
/** 加载 lsky_keys.php(只加载一次),并检查必填项 */
|
||||
function lskyLoadKeys() {
|
||||
static $done = false;
|
||||
if ($done) return;
|
||||
$file = __DIR__ . '/lsky_keys.php';
|
||||
if (!file_exists($file)) {
|
||||
error('图床配置缺失:服务器上没有 lsky_keys.php(请参照仓库模板放置)', 500);
|
||||
}
|
||||
require_once $file;
|
||||
$done = true;
|
||||
if (!defined('LSKY_API_URL') || !defined('LSKY_EMAIL') || !defined('LSKY_PASSWORD')) {
|
||||
error('图床配置不完整:LSKY_API_URL / LSKY_EMAIL / LSKY_PASSWORD 必须定义', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 底层 HTTP(curl)。$isMultipart=true 时 $payload 直接作为 CURLOPT_POSTFIELDS 数组
|
||||
* (boundary 交给 curl 自动处理,切勿手写 Content-Type)。
|
||||
* 返回 ['ok','status','msg','data']。
|
||||
*/
|
||||
function lskyCurlJson($method, $url, $payload = null, $token = null, $isMultipart = false) {
|
||||
if (!function_exists('curl_init')) {
|
||||
return ['ok' => false, 'status' => 0, 'msg' => '服务器缺少 PHP curl 扩展', 'data' => null];
|
||||
}
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
|
||||
|
||||
$headers = ['Accept: application/json'];
|
||||
if ($token !== null && $token !== '') $headers[] = 'Authorization: Bearer ' . $token;
|
||||
|
||||
if ($method === 'POST') {
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
if ($isMultipart) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); // 数组 -> multipart
|
||||
} else {
|
||||
$json = json_encode($payload === null ? new stdClass() : $payload);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $json);
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
}
|
||||
} else {
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
}
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$err = curl_error($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($errno) {
|
||||
return ['ok' => false, 'status' => 0, 'msg' => 'cURL错误: ' . $err, 'data' => null];
|
||||
}
|
||||
$j = json_decode((string)$body, true);
|
||||
return [
|
||||
'ok' => ($status >= 200 && $status < 300),
|
||||
'status' => $status,
|
||||
'msg' => (is_array($j) && isset($j['message']) && $j['message'] !== '')
|
||||
? (string)$j['message']
|
||||
: ('HTTP ' . $status . ' ' . substr((string)$body, 0, 160)),
|
||||
'data' => (is_array($j) && isset($j['data']) && is_array($j['data'])) ? $j['data'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 API token:缓存优先(提前 60 秒过期),失效则 POST /tokens 续签并落盘。
|
||||
* 续签失败但缓存里还有旧 token 时返回旧 token(图床临时抖动不至于让上传全挂)。
|
||||
*/
|
||||
function lskyGetToken($force = false) {
|
||||
lskyLoadKeys();
|
||||
$cache = defined('LSKY_TOKEN_CACHE') ? LSKY_TOKEN_CACHE : (__DIR__ . '/db/lsky_token.json');
|
||||
$cached = null;
|
||||
if (!$force && is_readable($cache)) {
|
||||
$cached = json_decode((string)@file_get_contents($cache), true);
|
||||
if (is_array($cached) && !empty($cached['token'])
|
||||
&& isset($cached['expire_at']) && time() < (int)$cached['expire_at'] - 60) {
|
||||
return $cached['token'];
|
||||
}
|
||||
}
|
||||
$r = lskyCurlJson('POST', rtrim(LSKY_API_URL, '/') . '/tokens',
|
||||
['email' => LSKY_EMAIL, 'password' => LSKY_PASSWORD]);
|
||||
if ($r['ok'] && !empty($r['data']['token'])) {
|
||||
$token = (string)$r['data']['token'];
|
||||
$expire = time() + 3600; // 响应没有 expires_at 时按 1 小时兜底
|
||||
if (!empty($r['data']['expires_at'])) {
|
||||
$ts = strtotime((string)$r['data']['expires_at']);
|
||||
if ($ts !== false && $ts > time()) $expire = $ts;
|
||||
}
|
||||
$dir = dirname($cache);
|
||||
if (!is_dir($dir)) @mkdir($dir, 0755, true);
|
||||
@file_put_contents($cache,
|
||||
json_encode(['token' => $token, 'expire_at' => $expire, 'cached_at' => time()]),
|
||||
LOCK_EX);
|
||||
return $token;
|
||||
}
|
||||
// 续签失败 -> 旧缓存顶着用
|
||||
if (is_array($cached) && !empty($cached['token'])) return $cached['token'];
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传一张已通过校验的图片到图床。
|
||||
* 401(token 失效)自动强刷 token 重试 1 次。
|
||||
* 成功返回 ['ok'=>true,'url'=>..,'thumb'=>..,'key'=>..],失败 ['ok'=>false,'msg'=>..]
|
||||
*/
|
||||
function lskyUploadFile($tmpPath, $origName) {
|
||||
lskyLoadKeys();
|
||||
if (!is_uploaded_file($tmpPath)) {
|
||||
return ['ok' => false, 'msg' => '非法上传(is_uploaded_file 校验失败)'];
|
||||
}
|
||||
$mime = 'application/octet-stream';
|
||||
$info = @getimagesize($tmpPath);
|
||||
if (!empty($info['mime'])) $mime = (string)$info['mime'];
|
||||
|
||||
for ($try = 0; $try < 2; $try++) {
|
||||
$token = lskyGetToken($try > 0);
|
||||
if (!$token) {
|
||||
return ['ok' => false, 'msg' => '获取图床 token 失败(请检查 lsky_keys.php 的邮箱密码)'];
|
||||
}
|
||||
$fields = ['file' => new CURLFile($tmpPath, $mime, $origName)];
|
||||
if (defined('LSKY_STRATEGY_ID') && (int)LSKY_STRATEGY_ID > 0) {
|
||||
$fields['strategy_id'] = (int)LSKY_STRATEGY_ID;
|
||||
}
|
||||
$r = lskyCurlJson('POST', rtrim(LSKY_API_URL, '/') . '/upload', $fields, $token, true);
|
||||
if ($r['ok'] && !empty($r['data']['links']['url'])) {
|
||||
$url = (string)$r['data']['links']['url'];
|
||||
$thumb = !empty($r['data']['thumbnail_url']) ? (string)$r['data']['thumbnail_url'] : $url;
|
||||
return ['ok' => true, 'url' => $url, 'thumb' => $thumb,
|
||||
'key' => isset($r['data']['key']) ? (string)$r['data']['key'] : ''];
|
||||
}
|
||||
if ($r['status'] !== 401) {
|
||||
return ['ok' => false, 'msg' => $r['msg']];
|
||||
}
|
||||
// 401 -> 第 2 轮由 lskyGetToken(true) 强制续签后重试
|
||||
}
|
||||
return ['ok' => false, 'msg' => '上传失败(token 无效或已过期)'];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
/**
|
||||
* 兰空图床(Lsky Pro)API 配置 —— 工单图片上传用
|
||||
*
|
||||
* ★ 本文件仓库版是【空模板】:真实邮箱/密码只在服务器上手工放置,
|
||||
* deploy_php_only.py 的 EXCLUDE_PATTERNS 永不上传本文件(覆盖线上即丢真值)。
|
||||
* 与 captcha_keys.php / config.php / pay_secrets.php 同一套规矩。
|
||||
*
|
||||
* ★ 图床地址(接口文档确认):https://img.ypshidifu.cn/api/v1/
|
||||
* 取 token : POST /tokens body {email,password} -> data.token
|
||||
* 上传 : POST /upload multipart: file(+strategy_id), Header: Authorization: Bearer <token>
|
||||
*
|
||||
* 服务器版真值示例(勿写进仓库):
|
||||
* define('LSKY_EMAIL', '真实邮箱');
|
||||
* define('LSKY_PASSWORD', '真实密码');
|
||||
*/
|
||||
define('LSKY_API_URL', 'https://img.ypshidifu.cn/api/v1');
|
||||
define('LSKY_EMAIL', 'REPLACE_ME_LSKY_EMAIL');
|
||||
define('LSKY_PASSWORD', 'REPLACE_ME_LSKY_PASSWORD');
|
||||
|
||||
// 上传走的存储策略ID:1 = 默认本地策略(接口文档 GET /strategies 返回的 id;0 = 不传该参数)
|
||||
define('LSKY_STRATEGY_ID', 1);
|
||||
|
||||
// token 缓存文件(web.db 同目录,Web 可写;含有效期内的 token,仅服务器本地可读)
|
||||
define('LSKY_TOKEN_CACHE', __DIR__ . '/db/lsky_token.json');
|
||||
+94
-3
@@ -2764,7 +2764,7 @@ if ($currentVersion !== $BUILD_VERSION) {
|
||||
}
|
||||
|
||||
// ===== 工单系统 =====
|
||||
let ticketState = { view: 'list', filter: 'all' };
|
||||
let ticketState = { view: 'list', filter: 'all', images: [] }; // images=附件图暂存(URL列表)
|
||||
|
||||
async function renderTicket(el) {
|
||||
if (IS_PREVIEW || !AUTHENTICATED) {
|
||||
@@ -2842,6 +2842,11 @@ if ($currentVersion !== $BUILD_VERSION) {
|
||||
<input id="ticketTitle" placeholder="简要描述您的问题" style="width:100%;padding:10px;border-radius:6px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:14px"></div>
|
||||
<div style="margin-bottom:12px"><label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">详细描述</label>
|
||||
<textarea id="ticketDesc" rows="6" placeholder="详细描述您的问题,支持Markdown格式" style="width:100%;padding:10px;border-radius:6px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:14px;resize:vertical;font-family:monospace"></textarea></div>
|
||||
<div style="margin-bottom:12px"><label style="color:var(--dim);font-size:13px;display:block;margin-bottom:4px">附件图片(最多 3 张)</label>
|
||||
<input type="file" id="ticketImg" accept=".png,.jpg,image/png,image/jpeg" multiple onchange="uploadTicketImage(this)" style="font-size:13px">
|
||||
<p style="color:var(--dim);font-size:12px;margin-top:4px">仅支持 png / jpg,单张 ≤ 2048K;选中即上传,并在上方描述光标处插入 [图片N] 占位符</p>
|
||||
<div id="ticketImgList" style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px"></div>
|
||||
<p id="ticketImgMsg" style="color:var(--red);font-size:12px;margin-top:4px"></p></div>
|
||||
<p style="color:var(--dim);font-size:12px;margin-bottom:12px">💡 支持标准Markdown语法:**粗体**、*斜体*、\`代码\`、列表、标题等</p>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-primary" onclick="submitTicket()">提交工单</button>
|
||||
@@ -2849,6 +2854,91 @@ if ($currentVersion !== $BUILD_VERSION) {
|
||||
</div>
|
||||
<p id="ticketError" style="color:var(--red);margin-top:8px;font-size:13px"></p>
|
||||
</div>`;
|
||||
renderTicketImgs();
|
||||
syncTicketImgInput();
|
||||
}
|
||||
|
||||
// ===== 工单附件图:选择即上传图床 -> 暂存 -> 光标处插占位符(滚动计数)=====
|
||||
async function uploadTicketImage(input) {
|
||||
const msg = document.getElementById('ticketImgMsg');
|
||||
if (!input.files || !input.files.length) return;
|
||||
const files = Array.from(input.files);
|
||||
input.value = '';
|
||||
for (const file of files) {
|
||||
if ((ticketState.images || []).length >= 3) { setImgMsg(msg, '最多只能上传 3 张图片', true); break; }
|
||||
const ext = (file.name.split('.').pop() || '').toLowerCase();
|
||||
if (ext !== 'png' && ext !== 'jpg') { setImgMsg(msg, '仅支持 png / jpg:' + file.name, true); continue; }
|
||||
if (file.size > 2048 * 1024) { setImgMsg(msg, '超过 2048K:' + file.name, true); continue; }
|
||||
setImgMsg(msg, '上传中… ' + file.name, false);
|
||||
try {
|
||||
const url = new URL(API + 'ticket.php', location.href);
|
||||
url.searchParams.set('action', 'upload_image');
|
||||
url.searchParams.set('token', TOKEN);
|
||||
const fd = new FormData();
|
||||
fd.append('image', file, file.name);
|
||||
const res = await fetch(url, { method: 'POST', body: fd });
|
||||
const data = await res.json();
|
||||
if (!data.success) { setImgMsg(msg, data.message, true); continue; }
|
||||
ticketState.images.push(data.data.url);
|
||||
insertTicketPh('[图片' + ticketState.images.length + ']');
|
||||
renderTicketImgs();
|
||||
syncTicketImgInput();
|
||||
setImgMsg(msg, '已上传 ' + ticketState.images.length + '/3 张', false);
|
||||
} catch (e) {
|
||||
setImgMsg(msg, '上传失败: ' + e.message, true);
|
||||
}
|
||||
}
|
||||
syncTicketImgInput();
|
||||
}
|
||||
|
||||
function setImgMsg(el, text, isErr) {
|
||||
if (!el) return;
|
||||
el.style.color = isErr ? 'var(--red)' : 'var(--dim)';
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
// 在描述框光标位置插入占位符(没有光标则追加到末尾)
|
||||
function insertTicketPh(ph) {
|
||||
const ta = document.getElementById('ticketDesc');
|
||||
if (!ta) return;
|
||||
const s = ta.selectionStart ?? ta.value.length;
|
||||
const e = ta.selectionEnd ?? s;
|
||||
ta.value = ta.value.slice(0, s) + ph + ta.value.slice(e);
|
||||
ta.focus();
|
||||
ta.selectionStart = ta.selectionEnd = s + ph.length;
|
||||
}
|
||||
|
||||
// 已传缩略图列表(编号贴在图角)
|
||||
function renderTicketImgs() {
|
||||
const box = document.getElementById('ticketImgList');
|
||||
if (!box) return;
|
||||
const imgs = ticketState.images || [];
|
||||
box.innerHTML = imgs.map((u, i) =>
|
||||
`<div style="position:relative;width:76px;height:76px;border:1px solid var(--border);border-radius:6px;overflow:hidden">
|
||||
<img src="${escHtml(u)}" style="width:100%;height:100%;object-fit:cover">
|
||||
<span style="position:absolute;left:0;bottom:0;background:rgba(0,0,0,.6);color:#fff;font-size:10px;padding:1px 5px;border-radius:0 4px 0 0">图片${i + 1}</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// 3 张用满则禁用选择器
|
||||
function syncTicketImgInput() {
|
||||
const fi = document.getElementById('ticketImg');
|
||||
if (fi) fi.disabled = (ticketState.images || []).length >= 3;
|
||||
}
|
||||
|
||||
// 详情渲染:把描述里的 [图片N] 占位符换成 markdown 图片;没被引用的图片补在末尾
|
||||
function applyTicketImgs(desc, images) {
|
||||
let out = String(desc || '');
|
||||
if (!images || !images.length) return out;
|
||||
const used = new Array(images.length).fill(false);
|
||||
out = out.replace(/\[图片(\d+)\]/g, function(m, n) {
|
||||
const i = parseInt(n, 10) - 1;
|
||||
if (i >= 0 && i < images.length && !used[i]) { used[i] = true; return ''; }
|
||||
return m;
|
||||
});
|
||||
const left = images.filter(function(u, k) { return !used[k]; });
|
||||
if (left.length) out += (out ? '\n\n' : '') + left.map(function(u) { return ''; }).join('\n\n');
|
||||
return out;
|
||||
}
|
||||
|
||||
async function submitTicket() {
|
||||
@@ -2866,10 +2956,11 @@ if ($currentVersion !== $BUILD_VERSION) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({type, title, description: desc})
|
||||
body: JSON.stringify({type, title, description: desc, images: ticketState.images || []})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
ticketState.images = []; // 已提交,附件暂存清空
|
||||
ticketState.view = 'list';
|
||||
renderTicket(document.getElementById('content'));
|
||||
} else {
|
||||
@@ -2913,7 +3004,7 @@ if ($currentVersion !== $BUILD_VERSION) {
|
||||
// 描述
|
||||
html += `<div style="background:rgba(88,166,255,0.05);border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:16px">
|
||||
<div style="color:var(--dim);font-size:12px;margin-bottom:8px">描述</div>
|
||||
<div style="font-size:14px;line-height:1.6">${renderMarkdown(t.description || '无描述')}</div>
|
||||
<div style="font-size:14px;line-height:1.6">${renderMarkdown(applyTicketImgs(t.description || '无描述', t.images))}</div>
|
||||
</div>`;
|
||||
|
||||
// 驳回原因
|
||||
|
||||
+16
-1
@@ -274,6 +274,21 @@ async function loadMyProviderTickets() {
|
||||
} catch (e) { el.innerHTML = `<p style="color:var(--red)">加载失败: ${e.message}</p>`; }
|
||||
}
|
||||
|
||||
// 详情渲染:[图片N] 占位符 -> markdown 图片;未被引用的图片补在末尾
|
||||
function applyTicketImgs(desc, images) {
|
||||
let out = String(desc || '');
|
||||
if (!images || !images.length) return out;
|
||||
const used = new Array(images.length).fill(false);
|
||||
out = out.replace(/\[图片(\d+)\]/g, function(m, n) {
|
||||
const i = parseInt(n, 10) - 1;
|
||||
if (i >= 0 && i < images.length && !used[i]) { used[i] = true; return ''; }
|
||||
return m;
|
||||
});
|
||||
const left = images.filter(function(u, k) { return !used[k]; });
|
||||
if (left.length) out += (out ? '\n\n' : '') + left.map(function(u) { return ''; }).join('\n\n');
|
||||
return out;
|
||||
}
|
||||
|
||||
async function viewSPTicket(id, from) {
|
||||
const el = document.getElementById('spContent');
|
||||
el.innerHTML = '<p style="color:var(--dim)">加载中...</p>';
|
||||
@@ -305,7 +320,7 @@ async function viewSPTicket(id, from) {
|
||||
</div>
|
||||
<div style="background:rgba(63,185,80,0.05);border:1px solid var(--border);border-radius:6px;padding:12px;margin-bottom:12px">
|
||||
<div style="color:var(--dim);font-size:11px;margin-bottom:4px">描述</div>
|
||||
<div class="md-content">${spRenderMd(t.description || '无描述')}</div>
|
||||
<div class="md-content">${spRenderMd(applyTicketImgs(t.description || '无描述', t.images))}</div>
|
||||
</div>`;
|
||||
|
||||
if (t.reject_reason) {
|
||||
|
||||
Reference in New Issue
Block a user