工单管理支持上传图片(兰空图床转存)

- 新增 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:
ypsdf1
2026-10-04 10:16:37 +08:00
parent c6b05d72c8
commit fe1ed613ba
7 changed files with 482 additions and 9 deletions
+71 -3
View File
@@ -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 '![](' + images[i] + ')'; }
return m;
});
const left = images.filter(function(u, k) { return !used[k]; });
if (left.length) out += (out ? '\n\n' : '') + left.map(function(u) { return '![](' + u + ')'; }).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
View File
@@ -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();
+6
View File
@@ -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,
+165
View File
@@ -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 无效或已过期)'];
}
+25
View File
@@ -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
View File
@@ -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 '![](' + images[i] + ')'; }
return m;
});
const left = images.filter(function(u, k) { return !used[k]; });
if (left.length) out += (out ? '\n\n' : '') + left.map(function(u) { return '![](' + u + ')'; }).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
View File
@@ -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 '![](' + images[i] + ')'; }
return m;
});
const left = images.filter(function(u, k) { return !used[k]; });
if (left.length) out += (out ? '\n\n' : '') + left.map(function(u) { return '![](' + u + ')'; }).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) {