Refactor frontend price table rendering and improve status display

This commit is contained in:
wood chen 2025-02-08 01:51:00 +08:00
parent bc648b699e
commit 1edbfd6379
6 changed files with 265 additions and 62 deletions

View File

@ -1,6 +1,6 @@
# Discourse SSO 配置 # Discourse SSO 配置
# Discourse 网站地址 # Discourse 网站地址
DISCOURSE_URL=https://discourse.example.com DISCOURSE_URL=https://q58.pro
# SSO 密钥 (必需) # SSO 密钥 (必需)
# 可以使用以下命令生成: openssl rand -hex 32 # 可以使用以下命令生成: openssl rand -hex 32

View File

@ -1,9 +1,15 @@
{ {
"imports": { "compilerOptions": {
"std/": "https://deno.land/std@0.220.1/" "allowJs": true,
"lib": ["dom", "dom.iterable", "dom.asynciterable", "deno.ns"],
"strict": true
},
"importMap": {
"imports": {
"std/": "https://deno.land/std@0.220.1/"
}
}, },
"tasks": { "tasks": {
"start": "deno run --allow-net --allow-env --allow-read main.ts", "start": "deno run --allow-net --allow-read --allow-env main.ts"
"dev": "deno run --watch --allow-net --allow-env --allow-read main.ts"
} }
} }

13
deno.jsonc Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"allowJs": true,
"lib": ["dom", "dom.iterable", "deno.ns"],
"strict": true,
"types": ["https://deno.land/x/types/deno.ns.d.ts"]
},
"importMap": "import_map.json",
"tasks": {
"start": "deno run --allow-net --allow-read --allow-env main.ts",
"dev": "deno run --watch --allow-net --allow-read --allow-env main.ts"
}
}

3
deps.ts Normal file
View File

@ -0,0 +1,3 @@
export { serve } from "https://deno.land/std@0.220.1/http/server.ts";
export { crypto } from "https://deno.land/std@0.220.1/crypto/mod.ts";
export { decode as base64Decode } from "https://deno.land/std@0.220.1/encoding/base64url.ts";

View File

@ -1,5 +1,6 @@
{ {
"imports": { "imports": {
"std/": "https://deno.land/std@0.220.1/" "std/": "https://deno.land/std@0.220.1/",
"deno/": "https://deno.land/x/deno@v1.40.5/"
} }
} }

292
main.ts
View File

@ -1,6 +1,23 @@
import { serve } from "https://deno.land/std@0.220.1/http/server.ts"; /// <reference types="https://deno.land/x/types/deno.ns.d.ts" />
import { crypto } from "https://deno.land/std@0.220.1/crypto/mod.ts";
import { decode as base64Decode } from "https://deno.land/std@0.220.1/encoding/base64url.ts"; import { serve, crypto, base64Decode } from "./deps.ts";
// 声明 Deno 命名空间
declare namespace Deno {
interface Kv {
get(key: unknown[]): Promise<{ value: any }>;
set(key: unknown[], value: unknown, options?: { expireIn?: number }): Promise<void>;
delete(key: unknown[]): Promise<void>;
}
interface Env {
get(key: string): string | undefined;
}
const env: Env;
const exit: (code: number) => never;
const openKv: () => Promise<Kv>;
}
// 类型定义 // 类型定义
interface Vendor { interface Vendor {
@ -33,6 +50,10 @@ interface Price {
reviewed_at?: string; reviewed_at?: string;
} }
// 声明全局变量
declare const kv: Deno.Kv;
declare const vendors: { [key: string]: Vendor };
// 缓存供应商数据 // 缓存供应商数据
let vendorsCache: VendorResponse | null = null; let vendorsCache: VendorResponse | null = null;
let vendorsCacheTime: number = 0; let vendorsCacheTime: number = 0;
@ -62,8 +83,10 @@ function calculateRatio(price: number, currency: 'CNY' | 'USD'): number {
return currency === 'USD' ? price / 2 : price / 14; return currency === 'USD' ? price / 2 : price / 14;
} }
// 验证价格数据 // 修改验证价格数据函数
function validatePrice(data: any): string | null { function validatePrice(data: any): string | null {
console.log('验证数据:', data); // 添加日志
if (!data.model || !data.billing_type || !data.channel_type || if (!data.model || !data.billing_type || !data.channel_type ||
!data.currency || data.input_price === undefined || data.output_price === undefined || !data.currency || data.input_price === undefined || data.output_price === undefined ||
!data.price_source) { !data.price_source) {
@ -78,12 +101,16 @@ function validatePrice(data: any): string | null {
return "币种必须是 CNY 或 USD"; return "币种必须是 CNY 或 USD";
} }
if (isNaN(data.input_price) || isNaN(data.output_price)) { const channel_type = Number(data.channel_type);
return "价格必须是数字"; const input_price = Number(data.input_price);
const output_price = Number(data.output_price);
if (isNaN(channel_type) || isNaN(input_price) || isNaN(output_price)) {
return "价格和供应商ID必须是数字";
} }
if (data.input_price < 0 || data.output_price < 0) { if (channel_type < 0 || input_price < 0 || output_price < 0) {
return "价格不能为负数"; return "价格和供应商ID不能为负数";
} }
return null; return null;
@ -180,21 +207,24 @@ const html = `<!DOCTYPE html>
.badge { .badge {
font-size: 0.8em; font-size: 0.8em;
padding: 5px 10px; padding: 5px 10px;
color: white !important;
font-weight: 500;
} }
.badge-tokens { .badge-tokens {
background-color: #4CAF50; background-color: #4CAF50 !important;
} }
.badge-times { .badge-times {
background-color: #2196F3; background-color: #2196F3 !important;
} }
.badge-pending { .badge-pending {
background-color: #FFC107; background-color: #FFC107 !important;
color: #000 !important;
} }
.badge-approved { .badge-approved {
background-color: #4CAF50; background-color: #4CAF50 !important;
} }
.badge-rejected { .badge-rejected {
background-color: #F44336; background-color: #F44336 !important;
} }
.table th { .table th {
white-space: nowrap; white-space: nowrap;
@ -251,6 +281,21 @@ const html = `<!DOCTYPE html>
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: 0 2px 4px rgba(0,0,0,0.1);
} }
/* 添加状态说明 */
.status-legend {
margin-top: 1rem;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 0.5rem;
}
.status-legend .badge {
margin-right: 0.5rem;
}
.status-legend-item {
display: inline-block;
margin-right: 1.5rem;
}
</style> </style>
</head> </head>
<body> <body>
@ -314,6 +359,22 @@ const html = `<!DOCTYPE html>
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- 添加状态说明 -->
<div class="status-legend">
<h6 class="mb-2"></h6>
<div class="status-legend-item">
<span class="badge badge-pending"></span>
</div>
<div class="status-legend-item">
<span class="badge badge-approved"></span>
</div>
<div class="status-legend-item">
<span class="badge badge-rejected"></span>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -408,9 +469,13 @@ const html = `<!DOCTYPE html>
<button onclick="logout()" class="btn btn-outline-danger btn-sm">退</button> <button onclick="logout()" class="btn btn-outline-danger btn-sm">退</button>
\`; \`;
submitTab.style.display = 'block'; submitTab.style.display = 'block';
// 重新加载价格数据以更新操作列
loadPrices();
} else { } else {
loginStatus.innerHTML = '<button onclick="login()" class="btn btn-primary btn-sm">通过 Discourse 登录</button>'; loginStatus.innerHTML = '<button onclick="login()" class="btn btn-primary btn-sm">通过 Discourse 登录</button>';
submitTab.style.display = 'none'; submitTab.style.display = 'none';
// 重新加载价格数据以隐藏操作列
loadPrices();
} }
} }
@ -434,47 +499,151 @@ const html = `<!DOCTYPE html>
} }
} }
// 加载价格数据 // 修改表格头部的渲染
async function loadPrices() { function updateTableHeader() {
try { const thead = document.querySelector('table thead tr');
const response = await fetch('/api/prices'); if (!thead) return;
const prices = await response.json();
const tbody = document.getElementById('priceTable');
tbody.innerHTML = '';
prices.forEach(price => { const columns = [
const vendor = vendors?.[price.channel_type]; { title: '模型名称', always: true },
const tr = document.createElement('tr'); { title: '计费类型', always: true },
const inputRatio = price.input_ratio ?? 0; { title: '供应商', always: true },
const outputRatio = price.output_ratio ?? 0; { title: '币种', always: true },
tr.innerHTML = \` { title: '输入价格(M)', always: true },
<td>\${price.model}</td> { title: '输出价格(M)', always: true },
<td><span class="badge badge-\${price.billing_type}">\${price.billing_type === 'tokens' ? '按量计费' : '按次计费'}</span></td> { title: '输入倍率', always: true },
<td> { title: '输出倍率', always: true },
<img src="\${vendor?.icon ?? ''}" class="vendor-icon" alt="\${vendor?.name ?? '未知供应商'}" onerror="this.style.display='none'"> { title: '价格依据', always: true },
\${vendor?.name ?? '未知供应商'} { title: '状态', always: true },
</td> { title: '操作', always: false }
<td>\${price.currency}</td> ];
<td>\${price.input_price}</td>
<td>\${price.output_price}</td> thead.innerHTML = columns
<td>\${inputRatio.toFixed(4)}</td> .filter(col => col.always || (currentUser === 'wood'))
<td>\${outputRatio.toFixed(4)}</td> .map(col => \`<th>\${col.title}</th>\`)
<td><a href="\${price.price_source}" target="_blank" class="source-link"></a></td> .join('');
<td><span class="badge badge-\${price.status}">\${price.status}</span></td> }
<td>
\${currentUser === 'wood' && price.status === 'pending' ? \` // 修改加载价格数据函数
<button onclick="reviewPrice('\${price.id}', 'approved')" class="btn btn-success btn-sm"></button> function loadPrices() {
<button onclick="reviewPrice('\${price.id}', 'rejected')" class="btn btn-danger btn-sm"></button> const priceTable = document.getElementById('priceTable');
\` : ''} const tbody = priceTable?.querySelector('tbody');
</td> if (!tbody) return;
\`;
tbody.appendChild(tr); tbody.innerHTML = '<tr><td colspan="11" class="text-center">加载中...</td></tr>';
fetch('/api/prices')
.then(response => response.json())
.then((data: Price[]) => {
tbody.innerHTML = '';
if (!data || !Array.isArray(data)) {
tbody.innerHTML = '<tr><td colspan="11" class="text-center">加载失败</td></tr>';
return;
}
data.forEach((price: Price) => {
const tr = document.createElement('tr');
const safePrice: Price = {
...price,
input_ratio: price.input_ratio || 1,
output_ratio: price.output_ratio || 1,
status: price.status || 'pending'
};
const vendorData = vendors[String(safePrice.channel_type)];
const currentUser = localStorage.getItem('username');
// 创建单元格
const modelCell = document.createElement('td');
modelCell.textContent = safePrice.model;
const billingTypeCell = document.createElement('td');
const billingTypeBadge = document.createElement('span');
billingTypeBadge.className = \`badge badge-\${safePrice.billing_type}\`;
billingTypeBadge.textContent = safePrice.billing_type === 'tokens' ? '按量计费' : '按次计费';
billingTypeCell.appendChild(billingTypeBadge);
const vendorCell = document.createElement('td');
if (vendorData) {
const vendorIcon = document.createElement('img');
vendorIcon.src = vendorData.icon;
vendorIcon.className = 'vendor-icon';
vendorIcon.alt = vendorData.name;
vendorIcon.onerror = () => { vendorIcon.style.display = 'none'; };
vendorCell.appendChild(vendorIcon);
vendorCell.appendChild(document.createTextNode(vendorData.name));
} else {
vendorCell.textContent = '未知供应商';
}
const currencyCell = document.createElement('td');
currencyCell.textContent = safePrice.currency;
const inputPriceCell = document.createElement('td');
inputPriceCell.textContent = String(safePrice.input_price);
const outputPriceCell = document.createElement('td');
outputPriceCell.textContent = String(safePrice.output_price);
const inputRatioCell = document.createElement('td');
inputRatioCell.textContent = safePrice.input_ratio.toFixed(4);
const outputRatioCell = document.createElement('td');
outputRatioCell.textContent = safePrice.output_ratio.toFixed(4);
const sourceCell = document.createElement('td');
const sourceLink = document.createElement('a');
sourceLink.href = safePrice.price_source;
sourceLink.className = 'source-link';
sourceLink.target = '_blank';
sourceLink.textContent = '查看来源';
sourceCell.appendChild(sourceLink);
const statusCell = document.createElement('td');
const statusBadge = document.createElement('span');
statusBadge.className = \`badge badge-\${safePrice.status}\`;
statusBadge.textContent = getStatusText(safePrice.status);
statusCell.appendChild(statusBadge);
// 添加所有单元格
tr.appendChild(modelCell);
tr.appendChild(billingTypeCell);
tr.appendChild(vendorCell);
tr.appendChild(currencyCell);
tr.appendChild(inputPriceCell);
tr.appendChild(outputPriceCell);
tr.appendChild(inputRatioCell);
tr.appendChild(outputRatioCell);
tr.appendChild(sourceCell);
tr.appendChild(statusCell);
// 只有管理员才添加操作列
if (currentUser === 'wood' && safePrice.status === 'pending') {
const operationCell = document.createElement('td');
const approveButton = document.createElement('button');
approveButton.className = 'btn btn-success btn-sm';
approveButton.textContent = '通过';
approveButton.onclick = () => reviewPrice(safePrice.id || '', 'approved');
const rejectButton = document.createElement('button');
rejectButton.className = 'btn btn-danger btn-sm';
rejectButton.textContent = '拒绝';
rejectButton.onclick = () => reviewPrice(safePrice.id || '', 'rejected');
operationCell.appendChild(approveButton);
operationCell.appendChild(rejectButton);
tr.appendChild(operationCell);
}
tbody.appendChild(tr);
});
})
.catch(error => {
console.error('加载价格数据失败:', error);
tbody.innerHTML = '<tr><td colspan="11" class="text-center">加载失败</td></tr>';
}); });
} catch (error) {
console.error('加载价格数据失败:', error);
const tbody = document.getElementById('priceTable');
tbody.innerHTML = '<tr><td colspan="11" class="text-center text-danger">加载数据失败</td></tr>';
}
} }
// 提交新价格 // 提交新价格
@ -561,14 +730,21 @@ const html = `<!DOCTYPE html>
} }
} }
// 修改状态显示文本
function getStatusText(status) {
switch(status) {
case 'pending': return '待审核';
case 'approved': return '已通过';
case 'rejected': return '已拒绝';
default: return status;
}
}
init(); init();
</script> </script>
</body> </body>
</html>`; </html>`;
// 使用 Deno KV 存储数据
const kv = await Deno.openKv();
// 读取价格数据 // 读取价格数据
async function readPrices(): Promise<Price[]> { async function readPrices(): Promise<Price[]> {
try { try {
@ -859,6 +1035,8 @@ async function handler(req: Request): Promise<Response> {
throw new Error("不支持的内容类型"); throw new Error("不支持的内容类型");
} }
console.log('接收到的数据:', rawData); // 添加日志
// 处理数据 // 处理数据
const newPrice: Price = { const newPrice: Price = {
model: String(rawData.model).trim(), model: String(rawData.model).trim(),
@ -875,6 +1053,8 @@ async function handler(req: Request): Promise<Response> {
created_at: new Date().toISOString() created_at: new Date().toISOString()
}; };
console.log('处理后的数据:', newPrice); // 添加日志
// 验证数据 // 验证数据
const error = validatePrice(newPrice); const error = validatePrice(newPrice);
if (error) { if (error) {
@ -909,7 +1089,7 @@ async function handler(req: Request): Promise<Response> {
} }
}); });
} catch (error) { } catch (error) {
console.error("Processing error:", error); console.error("处理价格提交失败:", error);
return new Response(JSON.stringify({ return new Response(JSON.stringify({
error: error.message, error: error.message,
details: "数据处理失败,请检查输入格式" details: "数据处理失败,请检查输入格式"