feat: 初始化进销存数据开放平台项目

初始化完整的前后端项目结构,包含ThinkPHP后端API服务和Next.js前端管理系统,添加基础配置、中间件、模型、路由等核心代码,以及项目文档和静态资源。
This commit is contained in:
cenzuhai
2026-07-08 17:48:00 +08:00
parent 6713e13ea6
commit 3a04eede84
99 changed files with 17002 additions and 0 deletions

598
frontend/public/client.html Normal file
View 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 + '&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>