Files
docs/frontend/public/client.html
cenzuhai 3a04eede84 feat: 初始化进销存数据开放平台项目
初始化完整的前后端项目结构,包含ThinkPHP后端API服务和Next.js前端管理系统,添加基础配置、中间件、模型、路由等核心代码,以及项目文档和静态资源。
2026-07-08 17:48:00 +08:00

598 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.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 + '&timestamp=' + 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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>