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

41
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
frontend/AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
frontend/CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

36
frontend/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

17
frontend/next.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { NextConfig } from "next";
const routePre = process.env.NEXT_PUBLIC_ROUTE_PRE || '';
const basePath = routePre ? `/${routePre}` : '';
const nextConfig: NextConfig = {
output: "export",
distDir: routePre || "out",
trailingSlash: true,
basePath: basePath,
assetPrefix: basePath,
images: {
unoptimized: true,
},
};
export default nextConfig;

8968
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
frontend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@ant-design/charts": "^2.6.7",
"@ant-design/icons": "^6.2.5",
"@ant-design/nextjs-registry": "^1.3.0",
"antd": "^6.4.4",
"axios": "^1.18.0",
"dayjs": "^1.11.21",
"next": "16.2.9",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.9",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View 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>
在参数字符串末尾追加 &timestamp={时间戳}
</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">"&timestamp="</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
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>

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
View 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

View 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

File diff suppressed because one or more lines are too long

1
frontend/public/next.svg Normal file
View 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

View 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 + '&timestamp=' + 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>

View 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

View 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

View File

@@ -0,0 +1,343 @@
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import {
Table, Button, Modal, Form, Input, Select, Tag, Switch, Space, message,
Popconfirm, Typography, Tooltip, Card,
} from 'antd';
import { PlusOutlined, KeyOutlined, DeleteOutlined, CopyOutlined, SearchOutlined } from '@ant-design/icons';
import AdminLayout from '@/components/AdminLayout';
import {
getClients, createClient, updateClient, toggleClient, generateApiKey,
getClientApiKeys, deleteApiKey,
} from '@/services/api';
const { Text, Paragraph } = Typography;
const TYPE_MAP: Record<string, { label: string; color: string }> = {
supplier: { label: '上游供应商', color: 'blue' },
distributor: { label: '下游分销商', color: 'green' },
hospital: { label: '医院/客户', color: 'orange' },
other: { label: '其他', color: 'default' },
};
interface ClientRecord {
id: number; client_no?: string; name: string; client_type: string; contact_person?: string;
phone?: string; status: number; remark?: string; api_keys: ApiKeyItem[];
}
interface ApiKeyItem {
id: number; api_key: string; api_secret_hash: string; status: number;
rate_limit_per_minute: number; rate_limit_per_day: number;
}
export default function ClientsPage() {
const [data, setData] = useState<ClientRecord[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false);
const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
const [secretModal, setSecretModal] = useState<{ visible: boolean; apiKey?: string; apiSecret?: string }>({ visible: false });
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [searchForm] = Form.useForm();
const fetchClients = useCallback(async (params: Record<string, unknown> = {}) => {
setLoading(true);
try {
const res = await getClients(params);
setData(res.data);
} catch (err) {
message.error('获取客户列表失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchClients(); }, [fetchClients]);
const [filters, setFilters] = useState<Record<string, unknown>>({});
const handleSearchChange = (key: string, val: unknown) => {
setFilters(f => ({ ...f, [key]: val || undefined }));
fetchClients({ ...filters, [key]: val || undefined });
};
const handleReset = () => {
setFilters({});
fetchClients();
};
const handleCreate = async () => {
try {
const values = await form.validateFields();
await createClient(values);
message.success('客户创建成功');
setModalVisible(false);
form.resetFields();
fetchClients();
} catch (err: any) {
if (err.response?.data?.detail) message.error(err.response.data.detail);
}
};
const handleEdit = (record: ClientRecord) => {
setEditingClient(record);
editForm.setFieldsValue({
client_no: record.client_no,
name: record.name,
client_type: record.client_type,
contact_person: record.contact_person,
phone: record.phone,
remark: record.remark,
});
setEditModalVisible(true);
};
const handleUpdate = async () => {
if (!editingClient) return;
try {
const values = await editForm.validateFields();
await updateClient(editingClient.id, values);
message.success('客户信息更新成功');
setEditModalVisible(false);
setEditingClient(null);
editForm.resetFields();
fetchClients();
} catch (err: any) {
if (err.response?.data?.detail) message.error(err.response.data.detail);
}
};
const handleToggle = async (id: number) => {
try {
await toggleClient(id);
message.success('状态已更新');
fetchClients();
} catch (err) {
message.error('操作失败');
}
};
const handleGenerateKey = async (clientId: number) => {
try {
const res = await generateApiKey(clientId);
setSecretModal({ visible: true, apiKey: res.data.api_key, apiSecret: res.data.api_secret });
fetchClients();
} catch (err) {
message.error('生成密钥失败');
}
};
const handleDeleteKey = async (keyId: number) => {
try {
await deleteApiKey(keyId);
message.success('API Key 已删除');
fetchClients();
} catch (err) {
message.error('删除失败');
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
message.success('已复制到剪贴板');
};
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '客户序号', dataIndex: 'client_no', width: 120 },
{ title: '客户名称', dataIndex: 'name', width: 200 },
{
title: '客户类型', dataIndex: 'client_type', width: 120,
render: (val: string) => {
const t = TYPE_MAP[val] || { label: val, color: 'default' };
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{ title: '联系人', dataIndex: 'contact_person', width: 100 },
{ title: '手机号', dataIndex: 'phone', width: 120 },
{
title: '状态', dataIndex: 'status', width: 100,
render: (val: number, record: ClientRecord) => (
<Switch
checked={val === 1}
checkedChildren="启用"
unCheckedChildren="禁用"
onChange={() => handleToggle(record.id)}
/>
),
},
{
title: 'API Key 数量', width: 120,
render: (_: unknown, record: ClientRecord) => record.api_keys?.length || 0,
},
{
title: '操作', width: 200, fixed: 'right' as const,
render: (_: unknown, record: ClientRecord) => (
<Space>
<Button type="link" onClick={() => handleEdit(record)}></Button>
</Space>
),
},
];
const expandedRowRender = (record: ClientRecord) => (
<div>
<div style={{ marginBottom: 16 }}>
<Button type="primary" icon={<KeyOutlined />} onClick={() => handleGenerateKey(record.id)}>
</Button>
</div>
<Table
dataSource={record.api_keys || []}
rowKey="id"
pagination={false}
size="small"
columns={[
{
title: 'API Key', dataIndex: 'api_key',
render: (val: string) => (
<Space>
<Text code copyable>{val}</Text>
</Space>
),
},
{
title: 'API Secret Hash', dataIndex: 'api_secret_hash',
render: (val: string) => (
<Space>
<Text code copyable>{val}</Text>
</Space>
),
},
{ title: '状态', dataIndex: 'status', render: (val: number) => val === 1 ? <Tag color="green"></Tag> : <Tag color="red"></Tag> },
{ title: '限流/分钟', dataIndex: 'rate_limit_per_minute' },
{ title: '限流/天', dataIndex: 'rate_limit_per_day' },
{
title: '操作', width: 80,
render: (_: unknown, keyRecord: ApiKeyItem) => (
<Popconfirm title="确认删除此 API Key" onConfirm={() => handleDeleteKey(keyRecord.id)}>
<Button type="link" danger icon={<DeleteOutlined />} size="small"></Button>
</Popconfirm>
),
},
]}
/>
</div>
);
return (
<AdminLayout>
<Card
title="上下游客户管理"
extra={
<Space>
<Input
placeholder="客户序号"
style={{ width: 120 }}
value={filters.client_no as string}
onChange={(e) => handleSearchChange('client_no', e.target.value)}
/>
<Input
placeholder="客户名称"
style={{ width: 150 }}
value={filters.name as string}
onChange={(e) => handleSearchChange('name', e.target.value)}
/>
<Select
placeholder="客户类型"
allowClear
style={{ width: 120 }}
value={filters.client_type as string}
onChange={(val) => handleSearchChange('client_type', val)}
options={[
{ label: '上游供应商', value: 'supplier' },
{ label: '下游分销商', value: 'distributor' },
{ label: '医院/客户', value: 'hospital' },
{ label: '其他', value: 'other' },
]}
/>
<Button onClick={handleReset}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}></Button>
</Space>
}
>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
expandable={{ expandedRowRender }}
scroll={{ x: 1000 }}
/>
</Card>
{/* 新建客户 Modal */}
<Modal title="新建客户" open={modalVisible} onOk={handleCreate} onCancel={() => setModalVisible(false)} destroyOnHidden>
<Form form={form} layout="vertical">
<Form.Item name="client_no" label="客户序号">
<Input placeholder="例如C001" />
</Form.Item>
<Form.Item name="name" label="客户名称" rules={[{ required: true }]}>
<Input placeholder="例如:喀喇沁旗中医蒙医医院" />
</Form.Item>
<Form.Item name="client_type" label="客户类型" rules={[{ required: true }]}>
<Select placeholder="请选择">
<Select.Option value="supplier"></Select.Option>
<Select.Option value="distributor"></Select.Option>
<Select.Option value="hospital">/</Select.Option>
<Select.Option value="other"></Select.Option>
</Select>
</Form.Item>
<Form.Item name="contact_person" label="联系人"><Input /></Form.Item>
<Form.Item name="phone" label="手机号"><Input /></Form.Item>
<Form.Item name="remark" label="备注"><Input.TextArea rows={2} /></Form.Item>
</Form>
</Modal>
{/* 修改客户 Modal */}
<Modal title="修改客户" open={editModalVisible} onOk={handleUpdate} onCancel={() => setEditModalVisible(false)} destroyOnHidden>
<Form form={editForm} layout="vertical">
<Form.Item name="client_no" label="客户序号">
<Input placeholder="例如C001" />
</Form.Item>
<Form.Item name="name" label="客户名称" rules={[{ required: true }]}>
<Input placeholder="例如:喀喇沁旗中医蒙医医院" />
</Form.Item>
<Form.Item name="client_type" label="客户类型" rules={[{ required: true }]}>
<Select placeholder="请选择">
<Select.Option value="supplier"></Select.Option>
<Select.Option value="distributor"></Select.Option>
<Select.Option value="hospital">/</Select.Option>
<Select.Option value="other"></Select.Option>
</Select>
</Form.Item>
<Form.Item name="contact_person" label="联系人"><Input /></Form.Item>
<Form.Item name="phone" label="手机号"><Input /></Form.Item>
<Form.Item name="remark" label="备注"><Input.TextArea rows={2} /></Form.Item>
</Form>
</Modal>
{/* API Secret 展示 */}
<Modal
title="API 密钥已生成"
open={secretModal.visible}
onCancel={() => setSecretModal({ visible: false })}
footer={null}
width={600}
>
<div style={{ background: '#fff7e6', padding: 16, borderRadius: 8, marginBottom: 16 }}>
<Text type="warning" strong>API Secret </Text>
</div>
<div style={{ marginBottom: 12 }}>
<Text strong>API Key</Text>
<Paragraph code copyable style={{ marginTop: 4 }}>{secretModal.apiKey}</Paragraph>
</div>
<div>
<Text strong>API Secret</Text>
<Paragraph code copyable style={{ marginTop: 4 }}>{secretModal.apiSecret}</Paragraph>
</div>
</Modal>
</AdminLayout>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Card, Col, Row, Statistic, Spin } from 'antd';
import {
ApiOutlined,
TeamOutlined,
WarningOutlined,
} from '@ant-design/icons';
import { Line } from '@ant-design/charts';
import AdminLayout from '@/components/AdminLayout';
import { getDashboardStats, getDashboardTrend } from '@/services/api';
interface Stats { today_total_calls: number; active_clients: number; error_calls: number; }
interface TrendPoint { hour: string; count: number; }
export default function DashboardPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [trend, setTrend] = useState<TrendPoint[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [statsRes, trendRes] = await Promise.all([
getDashboardStats(),
getDashboardTrend(),
]);
setStats(statsRes.data);
setTrend(trendRes.data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
if (loading) return <AdminLayout><Spin size="large" style={{ display: 'block', margin: '100px auto' }} /></AdminLayout>;
return (
<AdminLayout>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={8}>
<Card>
<Statistic
title="今日 API 调用总数"
value={stats?.today_total_calls ?? 0}
prefix={<ApiOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="活跃客户数"
value={stats?.active_clients ?? 0}
prefix={<TeamOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col span={8}>
<Card>
<Statistic
title="异常调用数"
value={stats?.error_calls ?? 0}
prefix={<WarningOutlined />}
valueStyle={{ color: stats?.error_calls ? '#ff4d4f' : '#52c41a' }}
/>
</Card>
</Col>
</Row>
<Card title="近 24 小时 API 调用趋势" style={{ marginBottom: 24 }}>
{trend.length > 0 ? (
<Line
data={trend.map(p => ({ time: p.hour.slice(5, 16), count: Number(p.count), fullHour: p.hour }))}
xField="time"
yField="count"
smooth
height={280}
color="#1890ff"
axis={{
x: { title: { text: '时间' }, label: { autoRotate: false }, grid: { line: { style: { stroke: '#333', lineWidth: 2 } } } },
y: { title: { text: '调用次数' }, min: 0, grid: { line: { style: { stroke: '#333', lineWidth: 2 } } } },
}}
tooltip={{
title: (datum: { fullHour: string }) => datum.fullHour,
items: [
(datum: { count: number }) => ({
name: '调用次数',
value: datum.count,
}),
],
}}
/>
) : (
<div style={{ textAlign: 'center', color: '#999', padding: 40 }}></div>
)}
</Card>
</AdminLayout>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,5 @@
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}

View File

@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { AntdRegistry } from "@ant-design/nextjs-registry";
import "./globals.css";
export const metadata: Metadata = {
title: "进销存数据开放平台",
description: "进销存数据开放平台后台管理系统",
icons: {
icon: "./favicon.ico",
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body>
<AntdRegistry>{children}</AntdRegistry>
</body>
</html>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Form, Input, Button, Card, message, Typography, Spin } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import { useRouter } from 'next/navigation';
import { login } from '@/services/api';
import { buildRoute, matchRoute } from '@/utils/route';
const { Title } = Typography;
export default function LoginPage() {
const [loading, setLoading] = useState(false);
const [checking, setChecking] = useState(true);
const router = useRouter();
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
router.push(buildRoute('/dashboard'));
} else {
setChecking(false);
}
}, [router]);
if (checking) {
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
}
const onFinish = async (values: { username: string; password: string }) => {
setLoading(true);
try {
const res = await login(values.username, values.password);
localStorage.setItem('token', res.data.access_token);
localStorage.setItem('username', res.data.username || values.username);
message.success('登录成功');
router.push(buildRoute('/dashboard'));
} catch (err: any) {
message.error(err.response?.data?.detail || '登录失败');
} finally {
setLoading(false);
}
};
return (
<div style={{
minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}>
<Card style={{ width: 400, borderRadius: 12, boxShadow: '0 8px 32px rgba(0,0,0,0.15)' }}>
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<Title level={3} style={{ marginBottom: 4 }}></Title>
<p style={{ color: '#888' }}></p>
</div>
<Form onFinish={onFinish} size="large">
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
</Button>
</Form.Item>
</Form>
<div style={{ textAlign: 'center', color: '#999', fontSize: 12 }}>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,267 @@
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import { Card, Table, DatePicker, Select, Tag, Button, Space, message, Modal, Descriptions, Input } from 'antd';
import { DownloadOutlined, SearchOutlined, EyeOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import AdminLayout from '@/components/AdminLayout';
import { getLogs, exportLogs, getClients } from '@/services/api';
const { RangePicker } = DatePicker;
export default function LogsPage() {
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [clients, setClients] = useState<any[]>([]);
const [filters, setFilters] = useState<Record<string, unknown>>({
page: 1, limit: 50,
});
const [detailVisible, setDetailVisible] = useState(false);
const [currentLog, setCurrentLog] = useState<any>(null);
useEffect(() => {
getClients().then(res => setClients(res.data)).catch(() => {});
fetchLogs();
}, []);
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const res = await getLogs(filters);
setData(res.data.data);
setTotal(res.data.total);
} catch (err) {
message.error('获取日志失败');
} finally {
setLoading(false);
}
}, [filters]);
const handleSearch = () => {
setFilters(f => ({ ...f, page: 1 }));
fetchLogs();
};
const handleExport = async () => {
try {
const res = await exportLogs(filters);
let csvContent = '';
let responseText = '';
if (res.data instanceof Blob) {
responseText = await res.data.text();
} else {
responseText = typeof res.data === 'string' ? res.data : JSON.stringify(res.data);
}
try {
const jsonData = JSON.parse(responseText);
if (jsonData && jsonData.data && Array.isArray(jsonData.data)) {
const headers = ['ID', '客户ID', '接口', '响应码', '返回条数', '耗时(ms)', 'IP', '时间'];
csvContent = headers.join(',') + '\n';
for (const log of jsonData.data) {
const row = [
log.id || '',
log.client_id || '',
log.endpoint || '',
log.response_code || '',
log.record_count || '',
log.duration_ms || '',
log.ip_address || '',
log.created_at ? dayjs(log.created_at).format('YYYY-MM-DD HH:mm:ss') : '',
];
csvContent += row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',') + '\n';
}
} else {
csvContent = responseText;
}
} catch {
csvContent = responseText;
}
const url = window.URL.createObjectURL(new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8' }));
const link = document.createElement('a');
link.href = url;
link.download = `api_logs_${dayjs().format('YYYYMMDD')}.csv`;
link.click();
window.URL.revokeObjectURL(url);
} catch (err) {
message.error('导出失败');
}
};
const showDetail = (record: any) => {
setCurrentLog(record);
setDetailVisible(true);
};
const columns = [
{
title: '时间', dataIndex: 'created_at', width: 170,
render: (val: string) => val ? dayjs(val).format('YYYY-MM-DD HH:mm:ss') : '-',
},
{
title: '用户', dataIndex: 'client_id', width: 160,
render: (val: number) => clients.find(c => c.id === val)?.name || `ID:${val}`,
},
{
title: '接口', dataIndex: 'endpoint', width: 140,
render: (val: string) => (
<Tag color={val.includes('chuku') ? 'blue' : 'green'}>
{val === '/api/v1/chuku' ? '销售' : val === '/api/v1/ruku' ? '入库' : val}
</Tag>
),
},
{
title: '请求参数', dataIndex: 'request_params', width: 200, ellipsis: true,
render: (val: any) => val ? JSON.stringify(val) : '-',
},
{
title: '响应码', dataIndex: 'response_code', width: 90,
render: (val: number) => {
const color = val === 200 ? 'green' : val === 429 ? 'orange' : 'red';
return <Tag color={color}>{val}</Tag>;
},
},
{ title: '返回条数', dataIndex: 'record_count', width: 90 },
{
title: '耗时', dataIndex: 'duration_ms', width: 90,
render: (val: number) => val ? `${val}ms` : '-',
},
{
title: 'IP', dataIndex: 'ip_address', width: 130,
},
{
title: '操作', key: 'action', width: 100, fixed: 'right' as const,
render: (_: any, record: any) => (
<Button size="small" icon={<EyeOutlined />} onClick={() => showDetail(record)}>
</Button>
),
},
];
return (
<AdminLayout>
<Card
title="API 调用日志审计"
extra={
<Space>
<RangePicker
onChange={(dates) => {
setFilters(f => ({
...f,
start_date: dates?.[0]?.format('YYYY-MM-DD') || undefined,
end_date: dates?.[1]?.format('YYYY-MM-DD') || undefined,
}));
}}
/>
<Input
placeholder="用户名称"
style={{ width: 180 }}
onChange={(e) => setFilters(f => ({ ...f, client_name: e.target.value }))}
allowClear
/>
<Select
placeholder="接口"
allowClear
style={{ width: 120 }}
onChange={(val) => setFilters(f => ({ ...f, endpoint: val }))}
options={[
{ label: '销售', value: '/api/v1/chuku' },
{ label: '入库', value: '/api/v1/ruku' },
]}
/>
<Select
placeholder="状态"
allowClear
style={{ width: 120 }}
onChange={(val) => setFilters(f => ({ ...f, status: val }))}
options={[
{ label: '正常', value: 'success' },
{ label: '错误', value: 'error' },
]}
/>
<Button icon={<SearchOutlined />} onClick={handleSearch}></Button>
<Button icon={<DownloadOutlined />} onClick={handleExport}> CSV</Button>
</Space>
}
>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
scroll={{ x: 1100 }}
pagination={{
current: filters.page as number,
pageSize: filters.limit as number,
total,
showTotal: (t) => `${t}`,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100', '200'],
onChange: (page, limit) => setFilters(f => ({ ...f, page, limit })),
}}
/>
</Card>
{/* 日志详情 Modal */}
<Modal
title="调用日志详情"
open={detailVisible}
onCancel={() => setDetailVisible(false)}
footer={[
<Button key="close" onClick={() => setDetailVisible(false)}></Button>,
]}
width={700}
>
{currentLog && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="日志ID">{currentLog.id}</Descriptions.Item>
<Descriptions.Item label="时间">
{currentLog.created_at ? dayjs(currentLog.created_at).format('YYYY-MM-DD HH:mm:ss') : '-'}
</Descriptions.Item>
<Descriptions.Item label="用户">
{clients.find(c => c.id === currentLog.client_id)?.name || `ID:${currentLog.client_id}`}
</Descriptions.Item>
<Descriptions.Item label="接口">
<Tag color={currentLog.endpoint?.includes('chuku') ? 'blue' : 'green'}>
{currentLog.endpoint === '/api/v1/chuku' ? '销售' : currentLog.endpoint === '/api/v1/ruku' ? '入库' : currentLog.endpoint}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="请求参数">
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all', fontSize: 12 }}>
{currentLog.request_params ? JSON.stringify(currentLog.request_params, null, 2) : '-'}
</pre>
</Descriptions.Item>
<Descriptions.Item label="响应码">
<Tag color={currentLog.response_code === 200 ? 'green' : currentLog.response_code === 429 ? 'orange' : 'red'}>
{currentLog.response_code}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="返回条数">{currentLog.record_count ?? '-'}</Descriptions.Item>
<Descriptions.Item label="耗时">{currentLog.duration_ms ? `${currentLog.duration_ms}ms` : '-'}</Descriptions.Item>
<Descriptions.Item label="IP地址">{currentLog.ip_address || '-'}</Descriptions.Item>
<Descriptions.Item label="响应体">
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all', fontSize: 12, maxHeight: 200, overflowY: 'auto' }}>
{currentLog.response_body ? JSON.stringify(currentLog.response_body, null, 2) : '-'}
</pre>
</Descriptions.Item>
<Descriptions.Item label="执行SQL">
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all', fontSize: 12, maxHeight: 300, overflowY: 'auto', background: '#f5f5f5', padding: 8, borderRadius: 4 }}>
{currentLog.sql_query || '-'}
</pre>
</Descriptions.Item>
<Descriptions.Item label="错误信息">
{currentLog.error_message ? (
<span style={{ color: '#ff4d4f' }}>{currentLog.error_message}</span>
) : '-'}
</Descriptions.Item>
</Descriptions>
)}
</Modal>
</AdminLayout>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/dashboard');
}

View File

@@ -0,0 +1,338 @@
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import {
Card, Table, Checkbox, Radio, Select, Tag, Button, Space,
Form, message, Spin, Input, Empty,
} from 'antd';
import { SaveOutlined } from '@ant-design/icons';
import AdminLayout from '@/components/AdminLayout';
import { getClients, getClientApiKeys, getPermission, updatePermission } from '@/services/api';
interface ClientRecord { id: number; client_no?: string; name: string; client_type: string; api_keys: ApiKeyItem[]; }
interface ApiKeyItem { id: number; api_key: string; status: number; }
interface PermData {
id: number; api_key_id: number; allow_chuku: number; allow_ruku: number;
allow_full_data: number; filter_field?: string; filter_values?: string[];
}
const FILTER_FIELDS = [
{ label: '销方全称', value: '销方全称' },
{ label: '供方全称', value: '供方全称' },
{ label: '销方序号', value: '销方序号' },
{ label: '供方序号', value: '供方序号' },
];
const TYPE_MAP: Record<string, { label: string; color: string }> = {
supplier: { label: '上游供应商', color: 'blue' },
distributor: { label: '下游分销商', color: 'green' },
hospital: { label: '医院/客户', color: 'orange' },
other: { label: '其他', color: 'default' },
};
export default function PermissionsPage() {
const [clients, setClients] = useState<ClientRecord[]>([]);
const [selectedKeyId, setSelectedKeyId] = useState<number | null>(null);
const [selectedClient, setSelectedClient] = useState<ClientRecord | null>(null);
const [perm, setPerm] = useState<PermData | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [filterValues, setFilterValues] = useState<string[]>([]);
const [newFilterValue, setNewFilterValue] = useState('');
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, unknown>>({});
const fetchClients = useCallback(async (params: Record<string, unknown> = {}) => {
try {
const res = await getClients(params);
setClients(res.data);
} catch (err) {
message.error('获取客户列表失败');
}
}, []);
useEffect(() => { fetchClients(); }, [fetchClients]);
const handleSearchChange = (key: string, val: unknown) => {
setFilters(f => ({ ...f, [key]: val || undefined }));
fetchClients({ ...filters, [key]: val || undefined });
};
const handleReset = () => {
setFilters({});
fetchClients();
};
const getAutoFilterInfo = (client: ClientRecord, allowChuku: boolean, allowRuku: boolean) => {
const rules: { label: string; field?: string; value?: string; disabled: boolean }[] = [];
if (allowChuku) {
rules.push({ label: '出库规则', field: '销方全称', value: client.name, disabled: false });
} else {
rules.push({ label: '出库规则', disabled: true });
}
if (allowRuku) {
rules.push({ label: '入库规则', field: '供方全称', value: client.name, disabled: false });
} else {
rules.push({ label: '入库规则', disabled: true });
}
return rules;
};
const loadPermission = useCallback(async (keyId: number, client: ClientRecord) => {
setLoading(true);
try {
const res = await getPermission(keyId);
const p = res.data;
if (p) {
setPerm(p);
form.setFieldsValue({
allow_chuku: !!p.allow_chuku,
allow_ruku: !!p.allow_ruku,
data_mode: p.data_mode || 'filter',
filter_field: p.filter_field,
});
setFilterValues(p.filter_values || []);
} else {
setPerm(null);
form.setFieldsValue({
allow_chuku: false,
allow_ruku: false,
data_mode: 'filter',
filter_field: undefined,
});
setFilterValues([]);
}
} catch (err) {
message.error('获取权限配置失败');
} finally {
setLoading(false);
}
}, [form]);
const handleSave = async () => {
if (!selectedKeyId) return;
const values = form.getFieldsValue();
setSaving(true);
try {
await updatePermission(selectedKeyId, {
allow_chuku: values.allow_chuku ? 1 : 0,
allow_ruku: values.allow_ruku ? 1 : 0,
allow_full_data: values.data_mode === 'full' ? 1 : 0,
data_mode: values.data_mode,
filter_field: values.data_mode === 'full' ? null : values.filter_field,
filter_values: values.data_mode === 'full' ? null : filterValues,
});
message.success('权限配置已保存');
} catch (err: any) {
message.error(err.response?.data?.detail || '保存失败');
} finally {
setSaving(false);
}
};
const addFilterValue = () => {
const v = newFilterValue.trim();
if (v && !filterValues.includes(v)) {
setFilterValues([...filterValues, v]);
setNewFilterValue('');
}
};
const removeFilterValue = (val: string) => {
setFilterValues(filterValues.filter(v => v !== val));
};
const dataMode = Form.useWatch('data_mode', form);
const allowChuku = Form.useWatch('allow_chuku', form);
const allowRuku = Form.useWatch('allow_ruku', form);
return (
<AdminLayout>
<div style={{ position: 'relative', height: 'calc(100vh - 120px)' }}>
<Card
title="客户 / API Key"
style={{ width: 420, height: '100%', display: 'flex', flexDirection: 'column' }}
extra={
<Space>
<Input
placeholder="客户序号"
style={{ width: 100 }}
value={filters.client_no as string}
onChange={(e) => handleSearchChange('client_no', e.target.value)}
/>
<Input
placeholder="客户名称"
style={{ width: 120 }}
value={filters.name as string}
onChange={(e) => handleSearchChange('name', e.target.value)}
/>
<Select
placeholder="类型"
allowClear
style={{ width: 100 }}
value={filters.client_type as string}
onChange={(val) => handleSearchChange('client_type', val)}
options={[
{ label: '供应商', value: 'supplier' },
{ label: '分销商', value: 'distributor' },
{ label: '医院', value: 'hospital' },
{ label: '其他', value: 'other' },
]}
/>
<Button onClick={handleReset} size="small"></Button>
</Space>
}
>
<div style={{ flex: 1, overflowY: 'auto' }}>
{clients.length === 0 ? <Empty description="暂无客户" /> : clients.map(client => (
<div key={client.id} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{client.name}</span>
<Tag color={TYPE_MAP[client.client_type]?.color}>
{TYPE_MAP[client.client_type]?.label}
</Tag>
</div>
{client.client_no && (
<div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}>{client.client_no}</div>
)}
{client.api_keys?.map(key => (
<div
key={key.id}
onClick={() => {
setSelectedKeyId(key.id);
setSelectedClient(client);
loadPermission(key.id, client);
}}
style={{
padding: '10px 14px', cursor: 'pointer', borderRadius: 8, marginBottom: 6,
background: selectedKeyId === key.id ? '#e6f7ff' : '#fff',
border: selectedKeyId === key.id ? '1px solid #1890ff' : '1px solid #e8e8e8',
transition: 'all 0.2s',
}}
>
<div style={{ fontFamily: 'monospace', fontSize: 13, color: '#333' }}>{key.api_key}</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 6 }}>
<Tag color={key.status === 1 ? 'green' : 'red'}>
{key.status === 1 ? '启用' : '禁用'}
</Tag>
</div>
</div>
))}
{(!client.api_keys || client.api_keys.length === 0) && (
<div style={{ color: '#bbb', fontSize: 12, padding: '8px 12px', border: '1px dashed #e8e8e8', borderRadius: 6 }}>
API Key
</div>
)}
</div>
))}
</div>
</Card>
<Card title="权限配置" style={{ position: 'fixed', left: 460, right: 24, top: 96, bottom: 24, display: 'flex', flexDirection: 'column' }}>
{!selectedKeyId ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Empty description="请从左侧选择一个 API Key" />
</div>
) : loading ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="large" />
</div>
) : (
<div style={{ flex: 1, overflowY: 'auto' }}>
<Form form={form} layout="vertical">
<div style={{ maxWidth: 600 }}>
<Form.Item label="接口权限">
<div style={{ display: 'flex', gap: 24 }}>
<Form.Item name="allow_chuku" valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox disabled={dataMode === 'full'}></Checkbox>
</Form.Item>
<Form.Item name="allow_ruku" valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox disabled={dataMode === 'full'}></Checkbox>
</Form.Item>
</div>
</Form.Item>
<Form.Item label="数据过滤规则" name="data_mode" initialValue="filter">
<Radio.Group>
<Radio value="filter"></Radio>
{/* <Radio value="custom">自定义过滤规则</Radio> */}
<Radio value="full"></Radio>
</Radio.Group>
</Form.Item>
{dataMode === 'filter' && selectedClient && (() => {
const autoRules = getAutoFilterInfo(selectedClient, allowChuku, allowRuku);
return (
<div style={{ padding: 16, background: '#f5f5f5', borderRadius: 8, marginTop: 8 }}>
<div style={{ fontSize: 14, color: '#666', marginBottom: 12 }}>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{autoRules.map((rule, idx) => (
<div key={idx} style={{ display: 'flex', gap: 12, fontSize: 14 }}>
<span style={{ fontWeight: 600, color: '#1890ff' }}>{rule.label}</span>
{rule.disabled ? (
<span style={{ color: '#999' }}>{rule.label === '出库规则' ? '出库' : '入库'}</span>
) : (
<>
<span></span>
<span style={{ fontWeight: 600 }}>{rule.field}</span>
<span style={{ marginLeft: 16 }}></span>
<span style={{ fontWeight: 600 }}>{rule.value}</span>
</>
)}
</div>
))}
</div>
</div>
);
})()}
{dataMode === 'custom' && (
<>
<Form.Item label="过滤字段" name="filter_field" rules={[{ required: true, message: '请选择过滤字段' }]}>
<Select
options={FILTER_FIELDS}
placeholder="请选择过滤字段"
style={{ width: 200 }}
/>
</Form.Item>
<Form.Item label="过滤值">
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<Input
placeholder="输入过滤值后按回车或点击添加"
value={newFilterValue}
onChange={e => setNewFilterValue(e.target.value)}
onPressEnter={addFilterValue}
style={{ width: 300 }}
/>
<Button onClick={addFilterValue}></Button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{filterValues.map(v => (
<Tag key={v} closable onClose={() => removeFilterValue(v)} color="blue">
{v}
</Tag>
))}
{filterValues.length === 0 && <span style={{ color: '#999', fontSize: 13 }}></span>}
</div>
</Form.Item>
</>
)}
<Form.Item style={{ marginTop: 24 }}>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave} size="large">
</Button>
</Form.Item>
</div>
</Form>
</div>
)}
</Card>
</div>
</AdminLayout>
);
}

View File

@@ -0,0 +1,184 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Layout, Menu, Button, theme, Avatar, Dropdown, Spin, Modal, Form, Input, message } from 'antd';
import {
DashboardOutlined,
TeamOutlined,
SafetyOutlined,
FileTextOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
UserOutlined,
BookOutlined,
LockOutlined,
} from '@ant-design/icons';
import { useRouter, usePathname } from 'next/navigation';
import { buildRoute, matchRoute } from '@/utils/route';
import { changePassword } from '@/services/api';
const { Header, Sider, Content } = Layout;
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const [collapsed, setCollapsed] = useState(false);
const [loading, setLoading] = useState(true);
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
const [passwordForm] = Form.useForm();
const [passwordLoading, setPasswordLoading] = useState(false);
const router = useRouter();
const pathname = usePathname();
const { token: themeToken } = theme.useToken();
useEffect(() => {
const token = localStorage.getItem('token');
if (!token && !matchRoute(pathname, '/login')) {
router.push(buildRoute('/login'));
} else {
setLoading(false);
}
}, [router, pathname]);
if (loading) {
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
}
const menuItems = [
{ key: buildRoute('/dashboard'), icon: <DashboardOutlined />, label: '仪表盘' },
{ key: buildRoute('/clients'), icon: <TeamOutlined />, label: '客户管理' },
{ key: buildRoute('/permissions'), icon: <SafetyOutlined />, label: '权限配置' },
{ key: buildRoute('/logs'), icon: <FileTextOutlined />, label: '调用日志' },
{ key: buildRoute('/client'), icon: <BookOutlined />, label: '客户端' },
];
const handleMenuClick = ({ key }: { key: string }) => {
if (key === buildRoute('/client')) {
window.open('/client.html', '_blank');
} else {
router.push(key);
}
};
const handleLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('username');
router.push(buildRoute('/login'));
};
const handleChangePassword = async (values: { oldPassword: string; newPassword: string }) => {
setPasswordLoading(true);
try {
await changePassword(values.oldPassword, values.newPassword);
message.success('密码修改成功,请重新登录');
localStorage.removeItem('token');
router.push(buildRoute('/login'));
} catch (err: any) {
message.error(err.response?.data?.detail || '密码修改失败');
} finally {
setPasswordLoading(false);
}
};
const userMenu = {
items: [
{ key: 'changePassword', icon: <LockOutlined />, label: '修改密码', onClick: () => setShowChangePasswordModal(true) },
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', onClick: handleLogout },
],
};
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
trigger={null}
collapsible
collapsed={collapsed}
theme="dark"
style={{ position: 'fixed', left: 0, top: 0, bottom: 0, zIndex: 10 }}
>
<div style={{
height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: collapsed ? 14 : 16, fontWeight: 600,
borderBottom: '1px solid rgba(255,255,255,0.1)',
}}>
{collapsed ? '开放平台' : '进销存数据开放平台'}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[pathname]}
items={menuItems}
onClick={handleMenuClick}
/>
</Sider>
<Layout style={{ marginLeft: collapsed ? 80 : 200, transition: 'all 0.2s' }}>
<Header style={{
padding: '0 24px', background: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
}}>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{ fontSize: 16 }}
/>
<Dropdown menu={userMenu}>
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}>
<Avatar icon={<UserOutlined />} size="small" />
<span>{localStorage.getItem('username') || '管理员'}</span>
</div>
</Dropdown>
</Header>
<Content style={{ margin: 24, padding: 24, background: themeToken.colorBgContainer, borderRadius: 8, minHeight: 360 }}>
{children}
</Content>
</Layout>
<Modal
title="修改密码"
open={showChangePasswordModal}
onCancel={() => { setShowChangePasswordModal(false); passwordForm.resetFields(); }}
footer={null}
>
<Form form={passwordForm} onFinish={handleChangePassword} layout="vertical">
<Form.Item
name="oldPassword"
label="旧密码"
rules={[{ required: true, message: '请输入旧密码' }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="请输入旧密码" />
</Form.Item>
<Form.Item
name="newPassword"
label="新密码"
rules={[{ required: true, message: '请输入新密码' }, { min: 6, message: '新密码长度不能少于6位' }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="请输入新密码" />
</Form.Item>
<Form.Item
name="confirmPassword"
label="确认新密码"
dependencies={['newPassword']}
rules={[
{ required: true, message: '请确认新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('newPassword') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的新密码不一致'));
},
}),
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="请再次输入新密码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={passwordLoading} block>
</Button>
</Form.Item>
</Form>
</Modal>
</Layout>
);
}

View File

@@ -0,0 +1,74 @@
import axios from 'axios';
import { buildRoute } from '@/utils/route';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'https://yisheng.cpolar.cn';
const api = axios.create({
baseURL: API_BASE,
timeout: 30000,
});
// 请求拦截:自动附加 JWT Token
api.interceptors.request.use((config) => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
});
// 响应拦截401 跳转登录
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401 && typeof window !== 'undefined') {
localStorage.removeItem('token');
const ROUTE_PRE = process.env.NEXT_PUBLIC_ROUTE_PRE || '';
const loginPath = ROUTE_PRE ? `/${ROUTE_PRE}/login` : '/login';
window.location.href = loginPath;
}
return Promise.reject(err);
}
);
export default api;
// ============ Auth ============
export const login = (username: string, password: string) =>
api.post('/api/admin/auth/login', { username, password });
export const changePassword = (oldPassword: string, newPassword: string) =>
api.put('/api/admin/auth/password', { old_password: oldPassword, new_password: newPassword });
// ============ Dashboard ============
export const getDashboardStats = () => api.get('/api/admin/dashboard/stats');
export const getDashboardTrend = () => api.get('/api/admin/dashboard/trend');
// ============ Clients ============
export const getClients = (params?: Record<string, unknown>) =>
api.get('/api/admin/clients', { params });
export const createClient = (data: Record<string, unknown>) =>
api.post('/api/admin/clients', data);
export const updateClient = (id: number, data: Record<string, unknown>) =>
api.put(`/api/admin/clients/update/${id}`, data);
export const toggleClient = (id: number) =>
api.patch(`/api/admin/clients/toggle/${id}`);
export const generateApiKey = (clientId: number) =>
api.post(`/api/admin/clients/api-keys/create/${clientId}`);
export const getClientApiKeys = (clientId: number) =>
api.get(`/api/admin/clients/api-keys/${clientId}`);
export const deleteApiKey = (keyId: number) =>
api.delete(`/api/admin/api-keys/delete/${keyId}`);
// ============ Permissions ============
export const getPermission = (apiKeyId: number) =>
api.get(`/api/admin/permissions/${apiKeyId}`);
export const updatePermission = (apiKeyId: number, data: Record<string, unknown>) =>
api.put(`/api/admin/permissions/${apiKeyId}`, data);
// ============ Logs ============
export const getLogs = (params?: Record<string, unknown>) =>
api.get('/api/admin/logs', { params });
export const exportLogs = (params?: Record<string, unknown>) =>
api.get('/api/admin/logs/export', { params, responseType: 'blob' });

View File

@@ -0,0 +1,25 @@
export const ROUTE_PRE = process.env.NEXT_PUBLIC_ROUTE_PRE || '';
function normalizePath(path: string): string {
return path.replace(/\/+$/, '');
}
export function buildRoute(path: string): string {
return normalizePath(path.startsWith('/') ? path : `/${path}`);
}
export function matchRoute(pathname: string, route: string): boolean {
const normalizedPathname = normalizePath(pathname);
const normalizedRoute = normalizePath(route.startsWith('/') ? route : `/${route}`);
if (!ROUTE_PRE) {
return normalizedPathname === normalizedRoute;
}
const basePath = `/${ROUTE_PRE}`;
const strippedPathname = normalizedPathname.startsWith(basePath)
? normalizedPathname.slice(basePath.length)
: normalizedPathname;
return strippedPathname === normalizedRoute;
}

46
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,46 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts",
"admin/types/**/*.ts",
"admin/dev/types/**/*.ts",
"out/types/**/*.ts",
"out/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}