feat: 初始化进销存数据开放平台项目
初始化完整的前后端项目结构,包含ThinkPHP后端API服务和Next.js前端管理系统,添加基础配置、中间件、模型、路由等核心代码,以及项目文档和静态资源。
This commit is contained in:
404
frontend/public/api_docs.html
Normal file
404
frontend/public/api_docs.html
Normal file
@@ -0,0 +1,404 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API接口文档</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: #f5f7fa; color: #333; line-height: 1.6; }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||
|
||||
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 40px 20px; border-radius: 8px; margin-bottom: 30px; }
|
||||
.header h1 { font-size: 32px; font-weight: 700; margin-bottom: 10px; }
|
||||
.header p { font-size: 16px; opacity: 0.9; }
|
||||
|
||||
.section { background: white; border-radius: 8px; padding: 30px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.section-title { font-size: 22px; font-weight: 600; color: #333; margin-bottom: 20px; padding-bottom: 10px; border-bottom: 2px solid #667eea; }
|
||||
|
||||
.endpoint { background: #f8f9fa; border-left: 4px solid #667eea; padding: 20px; margin: 20px 0; border-radius: 4px; }
|
||||
.endpoint-header { display: flex; align-items: center; gap: 15px; margin-bottom: 15px; }
|
||||
.method { background: #28a745; color: white; padding: 4px 12px; border-radius: 4px; font-weight: 600; font-size: 14px; }
|
||||
.url { font-family: 'Consolas', monospace; font-size: 16px; color: #333; }
|
||||
|
||||
.param-table { width: 100%; border-collapse: collapse; margin: 15px 0; }
|
||||
.param-table th, .param-table td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; }
|
||||
.param-table th { background: #f8f9fa; font-weight: 600; color: #555; }
|
||||
.param-table tr:hover { background: #f8f9fa; }
|
||||
.required { color: #dc3545; font-weight: 600; }
|
||||
.optional { color: #28a745; }
|
||||
|
||||
.code-block { background: #282c34; color: #abb2bf; padding: 20px; border-radius: 6px; overflow-x: auto; font-family: 'Consolas', monospace; font-size: 14px; margin: 15px 0; }
|
||||
.code-block .keyword { color: #c678dd; }
|
||||
.code-block .string { color: #98c379; }
|
||||
.code-block .comment { color: #5c6370; font-style: italic; }
|
||||
.code-block .function { color: #61afef; }
|
||||
|
||||
.response-example { background: #e8f5e9; border: 1px solid #c8e6c9; border-radius: 4px; padding: 15px; margin: 15px 0; }
|
||||
.response-title { font-weight: 600; color: #2e7d32; margin-bottom: 10px; }
|
||||
|
||||
.signature-steps { counter-reset: step; }
|
||||
.signature-step { position: relative; padding-left: 40px; margin: 15px 0; }
|
||||
.signature-step::before { counter-increment: step; content: counter(step); position: absolute; left: 0; top: 0; width: 28px; height: 28px; background: #667eea; color: white; border-radius: 50%; text-align: center; line-height: 28px; font-weight: 600; }
|
||||
|
||||
.alert { background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 15px 0; }
|
||||
.alert-title { font-weight: 600; color: #856404; margin-bottom: 5px; }
|
||||
|
||||
.nav { display: flex; gap: 10px; margin-bottom: 20px; }
|
||||
.nav a { padding: 8px 16px; background: white; border: 1px solid #ddd; border-radius: 4px; text-decoration: none; color: #333; transition: all 0.2s; }
|
||||
.nav a:hover { background: #667eea; color: white; border-color: #667eea; }
|
||||
.test-btn { display: inline-block; background: #28a745; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 600; transition: all 0.2s; margin-top: 15px; }
|
||||
.test-btn:hover { background: #218838; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>进销存开放平台 API 文档</h1>
|
||||
<p>版本: v1.0 | 基础URL: https://yisheng.cpolar.cn</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="nav">
|
||||
<a href="#auth">认证方式</a>
|
||||
<a href="#chuku">出库接口</a>
|
||||
<a href="#ruku">入库接口</a>
|
||||
<a target="_blank" href="test_api.html">点击测试</a>
|
||||
<a target="_blank" href="client.html">客户端查询</a>
|
||||
</div>
|
||||
|
||||
<div class="section" id="auth">
|
||||
<div class="section-title">认证方式</div>
|
||||
|
||||
<p style="margin-bottom: 20px;">所有API请求都需要通过HMAC-SHA256签名验证,确保请求的合法性和完整性。</p>
|
||||
|
||||
<h3 style="margin: 20px 0 10px;">请求头</h3>
|
||||
<table class="param-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>参数名</th>
|
||||
<th>类型</th>
|
||||
<th>必填</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>X-API-Key</td>
|
||||
<td>String</td>
|
||||
<td><span class="required">是</span></td>
|
||||
<td>API密钥,用于标识调用方身份</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>X-Timestamp</td>
|
||||
<td>String</td>
|
||||
<td><span class="required">是</span></td>
|
||||
<td>时间戳(秒级),用于防止重放攻击</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>X-Sign</td>
|
||||
<td>String</td>
|
||||
<td><span class="required">是</span></td>
|
||||
<td>请求签名,HMAC-SHA256算法生成</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3 style="margin: 20px 0 10px;">签名生成步骤</h3>
|
||||
<div class="signature-steps">
|
||||
<div class="signature-step">
|
||||
<strong>1. 参数排序</strong><br>
|
||||
将所有请求参数(不包括签名相关参数)按参数名ASCII码升序排列
|
||||
</div>
|
||||
<div class="signature-step">
|
||||
<strong>2. 拼接参数字符串</strong><br>
|
||||
将排序后的参数按 key=value 格式拼接,参数之间用 & 连接
|
||||
</div>
|
||||
<div class="signature-step">
|
||||
<strong>3. 添加时间戳</strong><br>
|
||||
在参数字符串末尾追加 ×tamp={时间戳}
|
||||
</div>
|
||||
<div class="signature-step">
|
||||
<strong>4. 生成签名</strong><br>
|
||||
使用 HMAC-SHA256 算法,以 API Secret 为密钥,对完整字符串进行签名
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert">
|
||||
<div class="alert-title">⚠️ 注意事项</div>
|
||||
<ul style="margin-left: 20px;">
|
||||
<li>时间戳有效期:7天</li>
|
||||
<li>签名大小写敏感</li>
|
||||
<li>参数值不进行URL编码</li>
|
||||
<li>空值参数不参与签名</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h3 style="margin: 20px 0 10px;">签名示例(JavaScript)</h3>
|
||||
<div class="code-block">
|
||||
<span class="keyword">async function</span> <span class="function">generateSignature</span>(params, timestamp, apiSecret) {
|
||||
<span class="comment">// 1. 参数排序</span>
|
||||
<span class="keyword">const</span> sortedKeys = Object.keys(params).sort();
|
||||
|
||||
<span class="comment">// 2. 拼接参数字符串</span>
|
||||
<span class="keyword">let</span> paramStr = <span class="string">""</span>;
|
||||
<span class="keyword">for</span> (<span class="keyword">const</span> k <span class="keyword">of</span> sortedKeys) {
|
||||
<span class="keyword">const</span> v = params[k];
|
||||
<span class="keyword">if</span> (v !== null && v !== <span class="string">""</span>) {
|
||||
<span class="keyword">if</span> (paramStr) paramStr += <span class="string">"&"</span>;
|
||||
paramStr += k + <span class="string">"="</span> + v;
|
||||
}
|
||||
}
|
||||
|
||||
<span class="comment">// 3. 添加时间戳</span>
|
||||
<span class="keyword">const</span> fullStr = paramStr + <span class="string">"×tamp="</span> + timestamp;
|
||||
|
||||
<span class="comment">// 4. 生成签名</span>
|
||||
<span class="keyword">const</span> encoder = <span class="keyword">new</span> TextEncoder();
|
||||
<span class="keyword">const</span> messageData = encoder.encode(fullStr);
|
||||
<span class="keyword">const</span> secretData = encoder.encode(apiSecret);
|
||||
|
||||
<span class="keyword">const</span> key = <span class="keyword">await</span> crypto.subtle.importKey(
|
||||
<span class="string">"raw"</span>,
|
||||
secretData,
|
||||
{ name: <span class="string">"HMAC"</span>, hash: <span class="string">"SHA-256"</span> },
|
||||
<span class="keyword">false</span>,
|
||||
[<span class="string">"sign"</span>]
|
||||
);
|
||||
|
||||
<span class="keyword">const</span> signature = <span class="keyword">await</span> crypto.subtle.sign(<span class="string">"HMAC"</span>, key, messageData);
|
||||
|
||||
<span class="keyword">return</span> Array.from(<span class="keyword">new</span> Uint8Array(signature))
|
||||
.map(b => b.toString(16).padStart(2, <span class="string">"0"</span>))
|
||||
.join(<span class="string">""</span>);
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" id="chuku">
|
||||
<div class="section-title">出库接口</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<div class="endpoint-header">
|
||||
<span class="method">GET</span>
|
||||
<span class="url">/api/v1/chuku</span>
|
||||
</div>
|
||||
<p>查询出库(销售)数据,支持分页和日期范围过滤</p>
|
||||
</div>
|
||||
|
||||
<h3>请求参数</h3>
|
||||
<table class="param-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>参数名</th>
|
||||
<th>类型</th>
|
||||
<th>必填</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>start_date</td>
|
||||
<td>String</td>
|
||||
<td><span class="optional">否</span></td>
|
||||
<td>开始日期,格式:YYYY-MM-DD</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>end_date</td>
|
||||
<td>String</td>
|
||||
<td><span class="optional">否</span></td>
|
||||
<td>结束日期,格式:YYYY-MM-DD</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>page</td>
|
||||
<td>Integer</td>
|
||||
<td><span class="optional">否</span></td>
|
||||
<td>页码,默认:1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>limit</td>
|
||||
<td>Integer</td>
|
||||
<td><span class="optional">否</span></td>
|
||||
<td>每页数量,默认:50,最大:500</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>请求示例</h3>
|
||||
<div class="code-block">
|
||||
GET /api/v1/chuku?start_date=2024-02-02&end_date=2026-06-22&page=1&limit=100
|
||||
|
||||
Headers:
|
||||
X-API-Key: 4e2bf206a82f846c699e978cea8c2f87
|
||||
X-Timestamp: 1782119268
|
||||
X-Sign: 3f1b80b43f2bd83f85fc08ade76515773a58b8673b137b8f77e4e90d3dab9acc
|
||||
</div>
|
||||
|
||||
<h3>响应示例</h3>
|
||||
<div class="response-example">
|
||||
<div class="response-title">成功响应 (200)</div>
|
||||
<div class="code-block" style="background: #f8f9fa; color: #333;">
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"total": 1500,
|
||||
"data": [
|
||||
{
|
||||
"type": "销售登记",
|
||||
"ticket_no": "A01C202606160002",
|
||||
"register_time": "2026-06-16",
|
||||
"customer_code": "001",
|
||||
"customer_name": "喀喇沁旗中医蒙医医院",
|
||||
"total_amount": 2035.0,
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="response-example" style="background: #f8d7da; border-color: #f5c6cb;">
|
||||
<div class="response-title" style="color: #721c24;">错误响应 (500)</div>
|
||||
<div class="code-block" style="background: #f8f9fa; color: #333;">
|
||||
{
|
||||
"code": 500,
|
||||
"msg": "数据查询失败: SQL Server 连接不可用"
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" id="ruku">
|
||||
<div class="section-title">入库接口</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<div class="endpoint-header">
|
||||
<span class="method">GET</span>
|
||||
<span class="url">/api/v1/ruku</span>
|
||||
</div>
|
||||
<p>查询入库(采购)数据,支持分页和日期范围过滤</p>
|
||||
</div>
|
||||
|
||||
<h3>请求参数</h3>
|
||||
<table class="param-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>参数名</th>
|
||||
<th>类型</th>
|
||||
<th>必填</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>start_date</td>
|
||||
<td>String</td>
|
||||
<td><span class="optional">否</span></td>
|
||||
<td>开始日期,格式:YYYY-MM-DD</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>end_date</td>
|
||||
<td>String</td>
|
||||
<td><span class="optional">否</span></td>
|
||||
<td>结束日期,格式:YYYY-MM-DD</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>page</td>
|
||||
<td>Integer</td>
|
||||
<td><span class="optional">否</span></td>
|
||||
<td>页码,默认:1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>limit</td>
|
||||
<td>Integer</td>
|
||||
<td><span class="optional">否</span></td>
|
||||
<td>每页数量,默认:50,最大:500</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>请求示例</h3>
|
||||
<div class="code-block">
|
||||
GET /api/v1/ruku?start_date=2024-02-02&end_date=2026-06-22&page=1&limit=100
|
||||
|
||||
Headers:
|
||||
X-API-Key: 4e2bf206a82f846c699e978cea8c2f87
|
||||
X-Timestamp: 1782119268
|
||||
X-Sign: 3f1b80b43f2bd83f85fc08ade76515773a58b8673b137b8f77e4e90d3dab9acc
|
||||
</div>
|
||||
|
||||
<h3>响应示例</h3>
|
||||
<div class="response-example">
|
||||
<div class="response-title">成功响应 (200)</div>
|
||||
<div class="code-block" style="background: #f8f9fa; color: #333;">
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"total": 800,
|
||||
"data": [
|
||||
{
|
||||
"type": "采购入库",
|
||||
"ticket_no": "A01Q202606160002",
|
||||
"register_time": "2026-06-16",
|
||||
"supplier_code": "1533",
|
||||
"supplier_name": "通化华夏药业有限责任公司",
|
||||
"total_amount": 101890.8,
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="response-example" style="background: #f8d7da; border-color: #f5c6cb;">
|
||||
<div class="response-title" style="color: #721c24;">错误响应 (500)</div>
|
||||
<div class="code-block" style="background: #f8f9fa; color: #333;">
|
||||
{
|
||||
"code": 500,
|
||||
"msg": "数据查询失败: SQL Server 连接不可用"
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">常见错误码</div>
|
||||
<table class="param-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>错误码</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>200</td>
|
||||
<td>请求成功</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>401</td>
|
||||
<td>未授权,API Key无效或签名错误</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>403</td>
|
||||
<td>禁止访问,权限不足或数据隔离配置错误</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>429</td>
|
||||
<td>请求过于频繁,已触发限流</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>500</td>
|
||||
<td>服务器内部错误,通常是数据库连接或查询失败</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; color: #999; margin-top: 40px; padding-bottom: 20px;">
|
||||
<p>© 2026 进销存开放平台 | 文档版本 v1.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
598
frontend/public/client.html
Normal file
598
frontend/public/client.html
Normal file
@@ -0,0 +1,598 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>进销存数据开放平台 - 数据查询</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: #f0f2f5; padding: 12px; }
|
||||
.container { max-width: 100%; margin: 0 auto; }
|
||||
|
||||
.header { background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%); color: white; padding: 16px 24px; border-radius: 8px; margin-bottom: 16px; box-shadow: 0 4px 16px rgba(24, 144, 255, 0.3); }
|
||||
.header h1 { font-size: 20px; font-weight: 600; }
|
||||
.header p { font-size: 13px; opacity: 0.9; margin-top: 4px; }
|
||||
|
||||
.card { background: white; border-radius: 8px; padding: 16px; margin-bottom: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
|
||||
.card-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; gap: 8px; }
|
||||
.card-title::before { content: ''; width: 4px; height: 16px; background: #1890ff; border-radius: 2px; }
|
||||
|
||||
.form-group { margin-bottom: 10px; }
|
||||
.form-group label { display: block; font-size: 13px; color: #666; margin-bottom: 4px; font-weight: 500; }
|
||||
.form-group input, .form-group textarea, .form-group select { width: 100%; padding: 7px 10px; border: 1px solid #d9d9d9; border-radius: 6px; font-size: 13px; transition: all 0.2s; background: #fff; }
|
||||
.form-group input:focus, .form-group textarea:focus { outline: none; border-color: #1890ff; box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1); }
|
||||
.form-group textarea { resize: vertical; min-height: 80px; }
|
||||
|
||||
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||
.grid-5 { display: grid; grid-template-columns: 1fr 1fr 2fr 60px 1fr; gap: 8px; }
|
||||
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; padding: 8px 20px; border: none; border-radius: 6px; font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.2s; gap: 6px; }
|
||||
.btn-primary { background: #1890ff; color: white; }
|
||||
.btn-primary:hover { background: #40a9ff; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4); }
|
||||
.btn-primary:disabled { background: #ccc; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
.btn-group { display: flex; gap: 12px; }
|
||||
|
||||
.endpoint-tabs { display: flex; gap: 0; margin-bottom: 12px; background: #f5f5f5; padding: 4px; border-radius: 6px; }
|
||||
.endpoint-tabs button { flex: 1; padding: 8px 12px; border: none; background: transparent; border-radius: 4px; font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.2s; color: #666; }
|
||||
.endpoint-tabs button.active { background: #1890ff; color: white; box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
|
||||
.endpoint-tabs button:hover:not(.active) { color: #333; }
|
||||
|
||||
.loading { display: inline-block; width: 20px; height: 20px; border: 2px solid #f0f0f0; border-top: 2px solid #1890ff; border-radius: 50%; animation: spin 1s linear infinite; }
|
||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||
|
||||
.info-box { background: #e6f7ff; border: 1px solid #91d5ff; border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; font-size: 12px; color: #1890ff; line-height: 1.5; }
|
||||
|
||||
.result-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; flex-wrap: wrap; gap: 8px; }
|
||||
.result-header .count { font-size: 13px; color: #666; }
|
||||
.result-header .count strong { color: #1890ff; }
|
||||
|
||||
.table-container { overflow-x: auto; border-radius: 6px; border: 1px solid #f0f0f0; }
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.data-table th { background: #fafafa; padding: 8px 10px; text-align: left; font-weight: 600; color: #333; border-bottom: 1px solid #f0f0f0; white-space: nowrap; }
|
||||
.data-table td { padding: 6px 10px; border-bottom: 1px solid #f5f5f5; color: #555; }
|
||||
.data-table tr:hover { background: #fafafa; }
|
||||
.data-table .text-right { text-align: right; }
|
||||
.data-table .text-center { text-align: center; }
|
||||
.data-table .money { color: #fa8c16; font-family: 'Consolas', monospace; }
|
||||
.data-table .date { color: #8c8c8c; }
|
||||
|
||||
.status-tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
|
||||
.status-yes { background: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; }
|
||||
.status-no { background: #fff2f0; color: #ff4d4f; border: 1px solid #ffccc7; }
|
||||
|
||||
.empty-state { text-align: center; padding: 60px 20px; color: #999; }
|
||||
.empty-state .icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; }
|
||||
.empty-state p { font-size: 14px; }
|
||||
|
||||
.error-message { padding: 16px; background: #fff2f0; border: 1px solid #ffccc7; border-radius: 6px; color: #ff4d4f; margin-bottom: 16px; }
|
||||
.success-message { padding: 16px; background: #f6ffed; border: 1px solid #b7eb8f; border-radius: 6px; color: #52c41a; margin-bottom: 16px; }
|
||||
|
||||
.pagination { display: flex; justify-content: center; align-items: center; gap: 6px; margin-top: 12px; }
|
||||
.pagination button { padding: 5px 10px; border: 1px solid #d9d9d9; background: white; border-radius: 4px; cursor: pointer; font-size: 12px; }
|
||||
.pagination button:hover:not(:disabled) { border-color: #1890ff; color: #1890ff; }
|
||||
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.pagination span { padding: 5px 10px; font-size: 12px; color: #666; }
|
||||
|
||||
.autocomplete-container { position: relative; }
|
||||
.autocomplete-list {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-top: none;
|
||||
border-radius: 0 0 6px 6px;
|
||||
background: white;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
display: none;
|
||||
}
|
||||
.autocomplete-list.show { display: block; }
|
||||
.autocomplete-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
}
|
||||
.autocomplete-item:last-child { border-bottom: none; }
|
||||
.autocomplete-item:hover { background: #f5f7fa; }
|
||||
.autocomplete-item .api-key-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 12px; }
|
||||
.autocomplete-item .delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.autocomplete-item .delete-btn:hover { background: #fff2f0; color: #ff4d4f; }
|
||||
.autocomplete-empty { padding: 12px; text-align: center; color: #999; font-size: 13px; }
|
||||
</style>
|
||||
<script src="/admin/js/xlsx.full.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>进销存数据开放平台</h1>
|
||||
<p>输入密钥后点击"点击查询"即可获取数据</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">API密钥配置</div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label>API Key</label>
|
||||
<div class="autocomplete-container">
|
||||
<input type="text" id="apiKey" value="" placeholder="请输入API Key" onclick="showApiHistory()">
|
||||
<div id="apiHistoryList" class="autocomplete-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API Secret</label>
|
||||
<input type="text" id="apiSecret" value="" placeholder="请输入API Secret">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">查询参数(注意您的账号权限:只有销售权限只能查询销售流向数据,只有入库权限只能查询入库数据,需要联系管理员配置权限)</div>
|
||||
<div class="endpoint-tabs">
|
||||
<button class="active" onclick="switchEndpoint('chuku')">销售数据 /api/v1/chuku</button>
|
||||
<button onclick="switchEndpoint('ruku')">入库数据 /api/v1/ruku</button>
|
||||
</div>
|
||||
<div class="grid-5">
|
||||
<div class="form-group">
|
||||
<label>开始日期</label>
|
||||
<input type="date" id="startDate" value="2024-02-02">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>结束日期</label>
|
||||
<input type="date" id="endDate">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>供方全称(拥有全量数据查询权限时可选填)</label>
|
||||
<input type="text" id="supplierName" placeholder="输入供应商全称">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>页码</label>
|
||||
<input type="number" id="page" value="1" min="1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>每页数量(最大200条)</label>
|
||||
<input type="number" id="limit" value="20" min="1" max="200" oninput="limitInputValue(this, 1, 200)">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 12px; align-items: center;">
|
||||
<button class="btn btn-primary" id="queryBtn" onclick="executeQuery()">
|
||||
点击查询
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">查询结果</div>
|
||||
<div id="resultArea"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentEndpoint = 'chuku';
|
||||
let isQuerying = false;
|
||||
let currentTotal = 0;
|
||||
let currentData = [];
|
||||
let currentColumns = [];
|
||||
|
||||
const CHUKU_COLUMNS = [
|
||||
{ key: 'chuku_id', label: '出库序号' },
|
||||
{ key: 'ruku_id', label: '入库序号' },
|
||||
{ key: 'ticket_no', label: '票号' },
|
||||
{ key: 'date', label: '日期', className: 'date' },
|
||||
{ key: 'warehouse', label: '库房' },
|
||||
{ key: 'batch_no', label: '批号' },
|
||||
{ key: 'expire_date', label: '效期', className: 'date' },
|
||||
{ key: 'quantity', label: '数量', className: 'text-right' },
|
||||
{ key: 'unit_price', label: '单价', className: 'money text-right' },
|
||||
{ key: 'amount', label: '金额', className: 'money text-right' },
|
||||
{ key: 'purchase_price', label: '进价', className: 'money text-right' },
|
||||
{ key: 'profit', label: '毛利', className: 'money text-right' },
|
||||
{ key: 'operator', label: '操作员' },
|
||||
{ key: 'salesman', label: '业务员' },
|
||||
{ key: 'receiver', label: '提货人' },
|
||||
{ key: 'supplier_name', label: '供方全称', width: '150px' },
|
||||
{ key: 'customer_name', label: '销方全称', width: '150px' },
|
||||
{ key: 'customer_region', label: '销方地区' },
|
||||
{ key: 'product_name', label: '产品名称' },
|
||||
{ key: 'product_spec', label: '规格' },
|
||||
{ key: 'product_origin', label: '产地' },
|
||||
{ key: 'product_unit', label: '单位' },
|
||||
{ key: 'manufacturer', label: '生产厂家' },
|
||||
];
|
||||
|
||||
const RUKU_COLUMNS = [
|
||||
{ key: 'ruku_id', label: '入库序号' },
|
||||
{ key: 'ticket_no', label: '票号' },
|
||||
{ key: 'date', label: '日期', className: 'date' },
|
||||
{ key: 'warehouse', label: '库房' },
|
||||
{ key: 'batch_no', label: '批号' },
|
||||
{ key: 'expire_date', label: '效期', className: 'date' },
|
||||
{ key: 'quantity', label: '数量', className: 'text-right' },
|
||||
{ key: 'unit_price', label: '单价', className: 'money text-right' },
|
||||
{ key: 'amount', label: '金额', className: 'money text-right' },
|
||||
{ key: 'purchase_price', label: '进价', className: 'money text-right' },
|
||||
{ key: 'warehouse_qty', label: '库房数量', className: 'text-right' },
|
||||
{ key: 'operator', label: '操作员' },
|
||||
{ key: 'purchaser', label: '采购员' },
|
||||
{ key: 'responsible_person', label: '负责人' },
|
||||
{ key: 'arrival_no', label: '来货单号' },
|
||||
{ key: 'invoice_no', label: '发票单号' },
|
||||
{ key: 'summary', label: '摘要' },
|
||||
{ key: 'supplier_name', label: '供方全称', width: '150px' },
|
||||
{ key: 'product_name', label: '产品名称' },
|
||||
{ key: 'product_spec', label: '规格' },
|
||||
{ key: 'product_origin', label: '产地' },
|
||||
{ key: 'product_unit', label: '单位' },
|
||||
{ key: 'manufacturer', label: '生产厂家' },
|
||||
];
|
||||
|
||||
function switchEndpoint(endpoint) {
|
||||
currentEndpoint = endpoint;
|
||||
document.querySelectorAll('.endpoint-tabs button').forEach(btn => btn.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
document.getElementById('resultArea').innerHTML = '';
|
||||
}
|
||||
|
||||
async function executeQuery() {
|
||||
const apiKey = document.getElementById('apiKey').value.trim();
|
||||
const apiSecret = document.getElementById('apiSecret').value.trim();
|
||||
|
||||
if (!apiKey || !apiSecret) {
|
||||
showError('请先填写 API Key 和 API Secret');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isQuerying) return;
|
||||
isQuerying = true;
|
||||
|
||||
const btn = document.getElementById('queryBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="loading"></span> 查询中...';
|
||||
|
||||
const resultArea = document.getElementById('resultArea');
|
||||
resultArea.innerHTML = '<div style="text-align:center; padding:40px;"><span class="loading"></span><div style="margin-top:16px;color:#999;">正在生成签名并查询数据...</div></div>';
|
||||
|
||||
try {
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
const supplierName = document.getElementById('supplierName').value.trim();
|
||||
const page = document.getElementById('page').value;
|
||||
const limit = document.getElementById('limit').value;
|
||||
|
||||
const params = {
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
supplier_name: supplierName,
|
||||
page: page,
|
||||
limit: limit
|
||||
};
|
||||
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||
|
||||
const sortedKeys = Object.keys(params).sort();
|
||||
let paramStr = '';
|
||||
for (let i = 0; i < sortedKeys.length; i++) {
|
||||
const k = sortedKeys[i];
|
||||
const v = params[k];
|
||||
if (v !== null && v !== '') {
|
||||
if (paramStr) paramStr += '&';
|
||||
paramStr += k + '=' + v;
|
||||
}
|
||||
}
|
||||
const fullStr = paramStr + '×tamp=' + timestamp;
|
||||
|
||||
const sign = await hmacSha256(fullStr, apiSecret);
|
||||
|
||||
const url = `https://yisheng.cpolar.cn/api/v1/${currentEndpoint}?${paramStr}`;
|
||||
|
||||
//const url = `http://localhost:8000/api/v1/${currentEndpoint}?${paramStr}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': apiKey,
|
||||
'X-Timestamp': timestamp,
|
||||
'X-Sign': sign
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.code === 200) {
|
||||
saveApiToHistory(apiKey, apiSecret);
|
||||
currentTotal = result.total || 0;
|
||||
currentData = result.data || [];
|
||||
currentColumns = currentEndpoint === 'chuku' ? CHUKU_COLUMNS : RUKU_COLUMNS;
|
||||
renderTable(currentData);
|
||||
} else {
|
||||
currentData = [];
|
||||
currentColumns = [];
|
||||
showError(result.msg || '查询失败');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('请求失败: ' + error.message);
|
||||
} finally {
|
||||
isQuerying = false;
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '查询数据';
|
||||
}
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const resultArea = document.getElementById('resultArea');
|
||||
resultArea.innerHTML = '<div class="error-message">❌ ' + message + '</div>';
|
||||
}
|
||||
|
||||
function renderTable(data) {
|
||||
const resultArea = document.getElementById('resultArea');
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
resultArea.innerHTML = `
|
||||
<div class="result-header">
|
||||
<div class="count">共 <strong>0</strong> 条记录</div>
|
||||
</div>
|
||||
<div class="empty-state">
|
||||
<div class="icon">📭</div>
|
||||
<p>暂无数据,请调整查询条件后重试</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const columns = currentEndpoint === 'chuku' ? CHUKU_COLUMNS : RUKU_COLUMNS;
|
||||
const page = parseInt(document.getElementById('page').value) || 1;
|
||||
const limit = parseInt(document.getElementById('limit').value) || 20;
|
||||
const totalPages = Math.ceil(currentTotal / limit);
|
||||
|
||||
let html = `
|
||||
<div class="result-header">
|
||||
<div class="count">共 <strong>${currentTotal}</strong> 条记录,当前第 <strong>${page}</strong> 页</div>
|
||||
<div style="display: flex; gap: 8px; align-items: center;">
|
||||
<div class="pagination">
|
||||
<button onclick="changePage(${page - 1})" ${page <= 1 ? 'disabled' : ''}>上一页</button>
|
||||
<span>${page} / ${totalPages}</span>
|
||||
<button onclick="changePage(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>下一页</button>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="exportToExcel()" style="padding: 6px 16px; font-size: 13px;">
|
||||
📥 导出Excel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
`;
|
||||
|
||||
columns.forEach(col => {
|
||||
html += `<th>${col.label}</th>`;
|
||||
});
|
||||
|
||||
html += `
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
data.forEach(row => {
|
||||
html += '<tr>';
|
||||
columns.forEach(col => {
|
||||
const value = row[col.key];
|
||||
let cellValue = '';
|
||||
|
||||
if (col.render) {
|
||||
cellValue = col.render(value);
|
||||
} else if (value === null || value === undefined || value === '') {
|
||||
cellValue = '-';
|
||||
} else if (col.className && col.className.includes('money')) {
|
||||
cellValue = value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
} else {
|
||||
cellValue = String(value);
|
||||
}
|
||||
|
||||
html += `<td class="${col.className || ''}">${cellValue}</td>`;
|
||||
});
|
||||
html += '</tr>';
|
||||
});
|
||||
|
||||
html += `
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<button onclick="changePage(${page - 1})" ${page <= 1 ? 'disabled' : ''}>上一页</button>
|
||||
<span>${page} / ${totalPages}</span>
|
||||
<button onclick="changePage(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>下一页</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
resultArea.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderStatus(value) {
|
||||
const yesValues = ['Y', '是', '1', 'true', 'TRUE', true];
|
||||
const isYes = yesValues.includes(value);
|
||||
return `<span class="status-tag ${isYes ? 'status-yes' : 'status-no'}">${isYes ? '是' : '否'}</span>`;
|
||||
}
|
||||
|
||||
function changePage(page) {
|
||||
if (page < 1) return;
|
||||
document.getElementById('page').value = page;
|
||||
executeQuery();
|
||||
}
|
||||
|
||||
async function exportToExcel() {
|
||||
if (!currentData || currentData.length === 0) {
|
||||
alert('没有数据可导出,请先查询数据');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = currentColumns.map(col => col.label);
|
||||
const data = currentData.map(row => {
|
||||
return currentColumns.map(col => {
|
||||
const value = row[col.key];
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '';
|
||||
}
|
||||
if (col.className && col.className.includes('money')) {
|
||||
return value || 0;
|
||||
}
|
||||
return String(value);
|
||||
});
|
||||
});
|
||||
|
||||
const worksheet = XLSX.utils.aoa_to_sheet([headers, ...data]);
|
||||
|
||||
const columnWidths = headers.map((header, index) => {
|
||||
let maxLength = header.length;
|
||||
data.forEach(row => {
|
||||
const cellValue = String(row[index] || '');
|
||||
if (cellValue.length > maxLength) {
|
||||
maxLength = cellValue.length;
|
||||
}
|
||||
});
|
||||
return { wch: Math.min(maxLength + 2, 50) };
|
||||
});
|
||||
worksheet['!cols'] = columnWidths;
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, '数据');
|
||||
|
||||
const fileName = `${currentEndpoint === 'chuku' ? '销售数据' : '入库数据'}_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
XLSX.writeFile(workbook, fileName);
|
||||
} catch (error) {
|
||||
alert('导出失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function hmacSha256(message, secret) {
|
||||
const encoder = new TextEncoder();
|
||||
const messageData = encoder.encode(message);
|
||||
const secretData = encoder.encode(secret);
|
||||
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
secretData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
).then(key => {
|
||||
return crypto.subtle.sign('HMAC', key, messageData);
|
||||
}).then(signature => {
|
||||
return Array.from(new Uint8Array(signature))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(today.getDate()).padStart(2, '0');
|
||||
document.getElementById('endDate').value = `${year}-${month}-${day}`;
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
const list = document.getElementById('apiHistoryList');
|
||||
const container = document.querySelector('.autocomplete-container');
|
||||
if (!container.contains(e.target)) {
|
||||
list.classList.remove('show');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function getApiHistory() {
|
||||
const historyStr = localStorage.getItem('apiKeyHistory');
|
||||
return historyStr ? JSON.parse(historyStr) : [];
|
||||
}
|
||||
|
||||
function saveApiToHistory(apiKey, apiSecret) {
|
||||
let history = getApiHistory();
|
||||
history = history.filter(item => item.apiKey !== apiKey);
|
||||
history.unshift({ apiKey, apiSecret });
|
||||
if (history.length > 10) {
|
||||
history = history.slice(0, 10);
|
||||
}
|
||||
localStorage.setItem('apiKeyHistory', JSON.stringify(history));
|
||||
}
|
||||
|
||||
function deleteApiFromHistory(apiKey) {
|
||||
let history = getApiHistory();
|
||||
history = history.filter(item => item.apiKey !== apiKey);
|
||||
localStorage.setItem('apiKeyHistory', JSON.stringify(history));
|
||||
renderApiHistory();
|
||||
}
|
||||
|
||||
function showApiHistory() {
|
||||
const list = document.getElementById('apiHistoryList');
|
||||
renderApiHistory();
|
||||
list.classList.toggle('show');
|
||||
}
|
||||
|
||||
function selectApiFromHistory(apiKey, apiSecret) {
|
||||
document.getElementById('apiKey').value = apiKey;
|
||||
document.getElementById('apiSecret').value = apiSecret;
|
||||
document.getElementById('apiHistoryList').classList.remove('show');
|
||||
}
|
||||
|
||||
function renderApiHistory() {
|
||||
const history = getApiHistory();
|
||||
const list = document.getElementById('apiHistoryList');
|
||||
|
||||
if (history.length === 0) {
|
||||
list.innerHTML = '<div class="autocomplete-empty">暂无历史记录</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
history.forEach(item => {
|
||||
html += `
|
||||
<div class="autocomplete-item" onclick="selectApiFromHistory('${escapeHtml(item.apiKey)}', '${escapeHtml(item.apiSecret)}')">
|
||||
<span class="api-key-text">${escapeHtml(item.apiKey)}</span>
|
||||
<button class="delete-btn" onclick="event.stopPropagation(); deleteApiFromHistory('${escapeHtml(item.apiKey)}')" title="删除">🗑️</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
list.innerHTML = html;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function limitInputValue(input, min, max) {
|
||||
let value = parseInt(input.value);
|
||||
if (isNaN(value)) {
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
if (value < min) {
|
||||
input.value = min;
|
||||
} else if (value > max) {
|
||||
input.value = max;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
1
frontend/public/file.svg
Normal file
1
frontend/public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
frontend/public/globe.svg
Normal file
1
frontend/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
24
frontend/public/js/xlsx.full.min.js
vendored
Normal file
24
frontend/public/js/xlsx.full.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/public/next.svg
Normal file
1
frontend/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
238
frontend/public/test_api.html
Normal file
238
frontend/public/test_api.html
Normal file
@@ -0,0 +1,238 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API测试工具</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: #f5f7fa; padding: 20px; }
|
||||
.container { max-width: 1000px; margin: 0 auto; }
|
||||
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
|
||||
.header h1 { font-size: 24px; font-weight: 600; }
|
||||
.header p { font-size: 14px; opacity: 0.9; margin-top: 5px; }
|
||||
|
||||
.card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.card-title { font-size: 16px; font-weight: 600; color: #333; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #eee; }
|
||||
|
||||
.form-group { margin-bottom: 15px; }
|
||||
.form-group label { display: block; font-size: 14px; color: #555; margin-bottom: 6px; }
|
||||
.form-group input, .form-group textarea, .form-group select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; transition: border-color 0.2s; }
|
||||
.form-group input:focus, .form-group textarea:focus { outline: none; border-color: #667eea; }
|
||||
.form-group textarea { resize: vertical; min-height: 80px; }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 15px; }
|
||||
|
||||
.btn { display: inline-block; padding: 10px 24px; border: none; border-radius: 4px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
|
||||
.btn-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; }
|
||||
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); }
|
||||
.btn-success { background: #28a745; color: white; }
|
||||
.btn-success:hover { background: #218838; }
|
||||
.btn-group { display: flex; gap: 10px; }
|
||||
|
||||
.result-area { margin-top: 15px; }
|
||||
.result-area pre { background: #f8f9fa; border-radius: 4px; padding: 15px; font-family: 'Consolas', 'Monaco', monospace; font-size: 13px; white-space: pre-wrap; word-break: break-all; max-height: 500px; overflow-y: auto; color: #333; }
|
||||
|
||||
.signature-box { background: #e8f5e9; border: 1px solid #c8e6c9; border-radius: 4px; padding: 15px; margin-top: 10px; }
|
||||
.signature-box .label { font-size: 12px; color: #2e7d32; margin-bottom: 8px; }
|
||||
.signature-box .value { font-family: 'Consolas', monospace; font-size: 13px; color: #1b5e20; word-break: break-all; }
|
||||
|
||||
.endpoint-tabs { display: flex; gap: 8px; margin-bottom: 15px; }
|
||||
.endpoint-tabs button { padding: 8px 16px; border: 1px solid #ddd; background: white; border-radius: 4px; font-size: 13px; cursor: pointer; transition: all 0.2s; }
|
||||
.endpoint-tabs button.active { background: #667eea; color: white; border-color: #667eea; }
|
||||
|
||||
.loading { display: inline-block; width: 20px; height: 20px; border: 2px solid #f3f3f3; border-top: 2px solid #667eea; border-radius: 50%; animation: spin 1s linear infinite; }
|
||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>API测试工具</h1>
|
||||
<p>签名算法: HMAC-SHA256 | 支持出库(chuku)和入库(ruku)接口</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">1. 配置API密钥</div>
|
||||
<div class="grid">
|
||||
<div class="form-group">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="apiKey" value="" placeholder="输入API Key">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API Secret</label>
|
||||
<input type="text" id="apiSecret" value="" placeholder="输入API Secret">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">2. 请求参数</div>
|
||||
<div class="endpoint-tabs">
|
||||
<button class="active" onclick="switchEndpoint('chuku')">出库接口 /api/v1/chuku</button>
|
||||
<button onclick="switchEndpoint('ruku')">入库接口 /api/v1/ruku</button>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="form-group">
|
||||
<label>开始日期 (start_date)</label>
|
||||
<input type="date" id="startDate" value="2024-02-02">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>结束日期 (end_date)</label>
|
||||
<input type="date" id="endDate" value="2026-06-22">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>页码 (page)</label>
|
||||
<input type="number" id="page" value="1" min="1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>每页数量 (limit)</label>
|
||||
<input type="number" id="limit" value="100" min="1" max="500">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">3. 签名生成</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" onclick="generateSignature()">生成签名</button>
|
||||
<button class="btn btn-success" onclick="executeQuery()">执行查询</button>
|
||||
</div>
|
||||
<div class="signature-box" id="signatureBox" style="display: none;">
|
||||
<div class="label">生成的请求信息:</div>
|
||||
<div class="value" id="signatureInfo"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">4. 查询结果</div>
|
||||
<div id="resultArea"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentEndpoint = 'chuku';
|
||||
|
||||
function switchEndpoint(endpoint) {
|
||||
currentEndpoint = endpoint;
|
||||
document.querySelectorAll('.endpoint-tabs button').forEach(btn => btn.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
|
||||
async function generateSignature() {
|
||||
const apiKey = document.getElementById('apiKey').value;
|
||||
const apiSecret = document.getElementById('apiSecret').value;
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
const page = document.getElementById('page').value;
|
||||
const limit = document.getElementById('limit').value;
|
||||
|
||||
const params = {
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
page: page,
|
||||
limit: limit
|
||||
};
|
||||
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||
|
||||
const sortedKeys = Object.keys(params).sort();
|
||||
let paramStr = '';
|
||||
for (let i = 0; i < sortedKeys.length; i++) {
|
||||
const k = sortedKeys[i];
|
||||
const v = params[k];
|
||||
if (v !== null && v !== '') {
|
||||
if (paramStr) paramStr += '&';
|
||||
paramStr += k + '=' + v;
|
||||
}
|
||||
}
|
||||
const fullStr = paramStr + '×tamp=' + timestamp;
|
||||
|
||||
const sign = await hmacSha256(fullStr, apiSecret);
|
||||
|
||||
const url = `https://yisheng.cpolar.cn/api/v1/${currentEndpoint}?${paramStr}`;
|
||||
|
||||
const info = `
|
||||
请求URL: ${url}
|
||||
|
||||
Headers:
|
||||
X-API-Key: ${apiKey}
|
||||
X-Timestamp: ${timestamp}
|
||||
X-Sign: ${sign}
|
||||
|
||||
签名字符串:
|
||||
${fullStr}
|
||||
|
||||
签名有效期: 7天 (当前时间戳: ${new Date(parseInt(timestamp) * 1000).toLocaleString()})
|
||||
`.trim();
|
||||
|
||||
document.getElementById('signatureInfo').textContent = info;
|
||||
document.getElementById('signatureBox').style.display = 'block';
|
||||
|
||||
window.signatureCache = {
|
||||
url: url,
|
||||
headers: {
|
||||
'X-API-Key': apiKey,
|
||||
'X-Timestamp': timestamp,
|
||||
'X-Sign': sign
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function executeQuery() {
|
||||
if (!window.signatureCache) {
|
||||
await generateSignature();
|
||||
}
|
||||
|
||||
const resultArea = document.getElementById('resultArea');
|
||||
resultArea.innerHTML = '<div style="text-align:center; padding:20px;"><div class="loading"></div> 查询中...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch(window.signatureCache.url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': window.signatureCache.headers['X-API-Key'],
|
||||
'X-Timestamp': window.signatureCache.headers['X-Timestamp'],
|
||||
'X-Sign': window.signatureCache.headers['X-Sign']
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
let html = '<pre>' + JSON.stringify(result, null, 2) + '</pre>';
|
||||
|
||||
if (result.code === 200) {
|
||||
html = '<div style="color:#28a745; margin-bottom:10px;">✅ 查询成功</div>' + html;
|
||||
} else {
|
||||
html = '<div style="color:#dc3545; margin-bottom:10px;">❌ 查询失败: ' + (result.msg || '未知错误') + '</div>' + html;
|
||||
}
|
||||
|
||||
resultArea.innerHTML = html;
|
||||
} catch (error) {
|
||||
resultArea.innerHTML = '<div style="color:#dc3545; margin-bottom:10px;">❌ 请求失败: ' + error.message + '</div><pre>' + error.stack + '</pre>';
|
||||
}
|
||||
}
|
||||
|
||||
function hmacSha256(message, secret) {
|
||||
const encoder = new TextEncoder();
|
||||
const messageData = encoder.encode(message);
|
||||
const secretData = encoder.encode(secret);
|
||||
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
secretData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
).then(key => {
|
||||
return crypto.subtle.sign('HMAC', key, messageData);
|
||||
}).then(signature => {
|
||||
return Array.from(new Uint8Array(signature))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
frontend/public/vercel.svg
Normal file
1
frontend/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
frontend/public/window.svg
Normal file
1
frontend/public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
Reference in New Issue
Block a user