llmapi-v2 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/.env.example +40 -0
  2. package/Dockerfile +17 -0
  3. package/dist/config.d.ts +48 -0
  4. package/dist/config.js +98 -0
  5. package/dist/config.js.map +1 -0
  6. package/dist/converter/request.d.ts +6 -0
  7. package/dist/converter/request.js +184 -0
  8. package/dist/converter/request.js.map +1 -0
  9. package/dist/converter/response.d.ts +6 -0
  10. package/dist/converter/response.js +76 -0
  11. package/dist/converter/response.js.map +1 -0
  12. package/dist/converter/stream.d.ts +54 -0
  13. package/dist/converter/stream.js +318 -0
  14. package/dist/converter/stream.js.map +1 -0
  15. package/dist/converter/types.d.ts +239 -0
  16. package/dist/converter/types.js +6 -0
  17. package/dist/converter/types.js.map +1 -0
  18. package/dist/data/posts.d.ts +19 -0
  19. package/dist/data/posts.js +462 -0
  20. package/dist/data/posts.js.map +1 -0
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.js +233 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/middleware/api-key-auth.d.ts +6 -0
  25. package/dist/middleware/api-key-auth.js +76 -0
  26. package/dist/middleware/api-key-auth.js.map +1 -0
  27. package/dist/middleware/quota-guard.d.ts +10 -0
  28. package/dist/middleware/quota-guard.js +27 -0
  29. package/dist/middleware/quota-guard.js.map +1 -0
  30. package/dist/middleware/rate-limiter.d.ts +5 -0
  31. package/dist/middleware/rate-limiter.js +50 -0
  32. package/dist/middleware/rate-limiter.js.map +1 -0
  33. package/dist/middleware/request-logger.d.ts +6 -0
  34. package/dist/middleware/request-logger.js +37 -0
  35. package/dist/middleware/request-logger.js.map +1 -0
  36. package/dist/middleware/session-auth.d.ts +19 -0
  37. package/dist/middleware/session-auth.js +99 -0
  38. package/dist/middleware/session-auth.js.map +1 -0
  39. package/dist/providers/aliyun.d.ts +13 -0
  40. package/dist/providers/aliyun.js +20 -0
  41. package/dist/providers/aliyun.js.map +1 -0
  42. package/dist/providers/base-provider.d.ts +36 -0
  43. package/dist/providers/base-provider.js +133 -0
  44. package/dist/providers/base-provider.js.map +1 -0
  45. package/dist/providers/deepseek.d.ts +11 -0
  46. package/dist/providers/deepseek.js +18 -0
  47. package/dist/providers/deepseek.js.map +1 -0
  48. package/dist/providers/registry.d.ts +18 -0
  49. package/dist/providers/registry.js +98 -0
  50. package/dist/providers/registry.js.map +1 -0
  51. package/dist/providers/types.d.ts +17 -0
  52. package/dist/providers/types.js +3 -0
  53. package/dist/providers/types.js.map +1 -0
  54. package/dist/routes/admin.d.ts +1 -0
  55. package/dist/routes/admin.js +153 -0
  56. package/dist/routes/admin.js.map +1 -0
  57. package/dist/routes/auth.d.ts +2 -0
  58. package/dist/routes/auth.js +318 -0
  59. package/dist/routes/auth.js.map +1 -0
  60. package/dist/routes/blog.d.ts +1 -0
  61. package/dist/routes/blog.js +29 -0
  62. package/dist/routes/blog.js.map +1 -0
  63. package/dist/routes/dashboard.d.ts +1 -0
  64. package/dist/routes/dashboard.js +184 -0
  65. package/dist/routes/dashboard.js.map +1 -0
  66. package/dist/routes/messages.d.ts +1 -0
  67. package/dist/routes/messages.js +309 -0
  68. package/dist/routes/messages.js.map +1 -0
  69. package/dist/routes/models.d.ts +1 -0
  70. package/dist/routes/models.js +39 -0
  71. package/dist/routes/models.js.map +1 -0
  72. package/dist/routes/payment.d.ts +1 -0
  73. package/dist/routes/payment.js +150 -0
  74. package/dist/routes/payment.js.map +1 -0
  75. package/dist/routes/sitemap.d.ts +1 -0
  76. package/dist/routes/sitemap.js +38 -0
  77. package/dist/routes/sitemap.js.map +1 -0
  78. package/dist/services/alipay.d.ts +27 -0
  79. package/dist/services/alipay.js +106 -0
  80. package/dist/services/alipay.js.map +1 -0
  81. package/dist/services/database.d.ts +4 -0
  82. package/dist/services/database.js +170 -0
  83. package/dist/services/database.js.map +1 -0
  84. package/dist/services/health-checker.d.ts +13 -0
  85. package/dist/services/health-checker.js +95 -0
  86. package/dist/services/health-checker.js.map +1 -0
  87. package/dist/services/mailer.d.ts +3 -0
  88. package/dist/services/mailer.js +91 -0
  89. package/dist/services/mailer.js.map +1 -0
  90. package/dist/services/metrics.d.ts +56 -0
  91. package/dist/services/metrics.js +94 -0
  92. package/dist/services/metrics.js.map +1 -0
  93. package/dist/services/remote-control.d.ts +20 -0
  94. package/dist/services/remote-control.js +209 -0
  95. package/dist/services/remote-control.js.map +1 -0
  96. package/dist/services/remote-ws.d.ts +5 -0
  97. package/dist/services/remote-ws.js +143 -0
  98. package/dist/services/remote-ws.js.map +1 -0
  99. package/dist/services/usage.d.ts +13 -0
  100. package/dist/services/usage.js +39 -0
  101. package/dist/services/usage.js.map +1 -0
  102. package/dist/utils/errors.d.ts +27 -0
  103. package/dist/utils/errors.js +48 -0
  104. package/dist/utils/errors.js.map +1 -0
  105. package/dist/utils/logger.d.ts +2 -0
  106. package/dist/utils/logger.js +14 -0
  107. package/dist/utils/logger.js.map +1 -0
  108. package/docker-compose.yml +19 -0
  109. package/package.json +39 -0
  110. package/public/robots.txt +8 -0
  111. package/src/config.ts +140 -0
  112. package/src/converter/request.ts +207 -0
  113. package/src/converter/response.ts +85 -0
  114. package/src/converter/stream.ts +373 -0
  115. package/src/converter/types.ts +257 -0
  116. package/src/data/posts.ts +474 -0
  117. package/src/index.ts +219 -0
  118. package/src/middleware/api-key-auth.ts +82 -0
  119. package/src/middleware/quota-guard.ts +28 -0
  120. package/src/middleware/rate-limiter.ts +61 -0
  121. package/src/middleware/request-logger.ts +36 -0
  122. package/src/middleware/session-auth.ts +91 -0
  123. package/src/providers/aliyun.ts +16 -0
  124. package/src/providers/base-provider.ts +148 -0
  125. package/src/providers/deepseek.ts +14 -0
  126. package/src/providers/registry.ts +111 -0
  127. package/src/providers/types.ts +26 -0
  128. package/src/routes/admin.ts +169 -0
  129. package/src/routes/auth.ts +369 -0
  130. package/src/routes/blog.ts +28 -0
  131. package/src/routes/dashboard.ts +208 -0
  132. package/src/routes/messages.ts +346 -0
  133. package/src/routes/models.ts +37 -0
  134. package/src/routes/payment.ts +189 -0
  135. package/src/routes/sitemap.ts +40 -0
  136. package/src/services/alipay.ts +116 -0
  137. package/src/services/database.ts +187 -0
  138. package/src/services/health-checker.ts +115 -0
  139. package/src/services/mailer.ts +90 -0
  140. package/src/services/metrics.ts +104 -0
  141. package/src/services/remote-control.ts +226 -0
  142. package/src/services/remote-ws.ts +145 -0
  143. package/src/services/usage.ts +57 -0
  144. package/src/types/express.d.ts +46 -0
  145. package/src/utils/errors.ts +44 -0
  146. package/src/utils/logger.ts +8 -0
  147. package/tsconfig.json +17 -0
  148. package/views/pages/404.ejs +14 -0
  149. package/views/pages/admin.ejs +307 -0
  150. package/views/pages/blog-post.ejs +378 -0
  151. package/views/pages/blog.ejs +148 -0
  152. package/views/pages/dashboard.ejs +441 -0
  153. package/views/pages/docs.ejs +807 -0
  154. package/views/pages/index.ejs +416 -0
  155. package/views/pages/login.ejs +170 -0
  156. package/views/pages/orders.ejs +111 -0
  157. package/views/pages/pricing.ejs +379 -0
  158. package/views/pages/register.ejs +397 -0
  159. package/views/pages/remote.ejs +334 -0
  160. package/views/pages/settings.ejs +373 -0
  161. package/views/partials/header.ejs +70 -0
  162. package/views/partials/nav.ejs +140 -0
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Error class that produces Anthropic API error format responses.
3
+ */
4
+ export class AnthropicError extends Error {
5
+ constructor(
6
+ public statusCode: number,
7
+ public errorType: string,
8
+ message: string,
9
+ ) {
10
+ super(message);
11
+ this.name = 'AnthropicError';
12
+ }
13
+
14
+ toJSON() {
15
+ return {
16
+ type: 'error',
17
+ error: { type: this.errorType, message: this.message },
18
+ };
19
+ }
20
+ }
21
+
22
+ export class AuthenticationError extends AnthropicError {
23
+ constructor(message = 'Invalid API key.') {
24
+ super(401, 'authentication_error', message);
25
+ }
26
+ }
27
+
28
+ export class PermissionError extends AnthropicError {
29
+ constructor(message = 'Permission denied.') {
30
+ super(403, 'permission_error', message);
31
+ }
32
+ }
33
+
34
+ export class RateLimitError extends AnthropicError {
35
+ constructor(message = 'Rate limit exceeded.') {
36
+ super(429, 'rate_limit_error', message);
37
+ }
38
+ }
39
+
40
+ export class OverloadedError extends AnthropicError {
41
+ constructor(message = 'All backend providers are currently unavailable.') {
42
+ super(503, 'overloaded_error', message);
43
+ }
44
+ }
@@ -0,0 +1,8 @@
1
+ import pino from 'pino';
2
+
3
+ export const logger = pino({
4
+ level: process.env.LOG_LEVEL || 'info',
5
+ transport: process.env.NODE_ENV === 'development'
6
+ ? { target: 'pino-pretty', options: { colorize: true } }
7
+ : undefined,
8
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "commonjs",
5
+ "outDir": "./dist",
6
+ "rootDir": "./src",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "resolveJsonModule": true,
10
+ "declaration": true,
11
+ "sourceMap": true,
12
+ "skipLibCheck": true,
13
+ "forceConsistentCasingInFileNames": true
14
+ },
15
+ "include": ["src/**/*"],
16
+ "exclude": ["node_modules", "dist"]
17
+ }
@@ -0,0 +1,14 @@
1
+ <%- include('../partials/header') %>
2
+ <%- include('../partials/nav') %>
3
+ <div class="min-h-screen bg-claude-cream flex items-center justify-center">
4
+ <div class="text-center">
5
+ <h1 class="text-8xl font-bold text-claude-orange">404</h1>
6
+ <p class="text-2xl text-claude-dark mt-4">页面未找到</p>
7
+ <p class="text-gray-500 mt-2">您访问的页面不存在</p>
8
+ <div class="mt-8 space-x-4">
9
+ <a href="/" class="px-6 py-3 bg-claude-orange text-white rounded-lg hover:opacity-90">返回首页</a>
10
+ <a href="/docs" class="px-6 py-3 border border-claude-dark text-claude-dark rounded-lg hover:bg-white">查看文档</a>
11
+ </div>
12
+ </div>
13
+ </div>
14
+ </body></html>
@@ -0,0 +1,307 @@
1
+ <%- include('../partials/header', { pageTitle: '管理后台' }) %>
2
+ <%- include('../partials/nav') %>
3
+
4
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
5
+
6
+ <!-- 页面标题 -->
7
+ <div class="mb-8">
8
+ <h1 class="text-2xl font-bold text-claude-dark">管理后台</h1>
9
+ <p class="text-sm text-gray-500 mt-1">平台运营数据与用户管理</p>
10
+ </div>
11
+
12
+ <!-- 统计卡片 -->
13
+ <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5 mb-8">
14
+ <!-- 总用户 -->
15
+ <div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
16
+ <div class="flex items-center justify-between mb-3">
17
+ <span class="text-sm font-medium text-gray-500">总用户数</span>
18
+ <svg class="w-5 h-5 text-claude-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
19
+ </div>
20
+ <div class="text-2xl font-bold text-claude-dark" id="adminTotalUsers">--</div>
21
+ <p class="text-xs text-gray-400 mt-2">今日新增 <span class="text-claude-orange font-medium" id="adminNewUsers">--</span></p>
22
+ </div>
23
+
24
+ <!-- 今日请求 -->
25
+ <div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
26
+ <div class="flex items-center justify-between mb-3">
27
+ <span class="text-sm font-medium text-gray-500">今日请求</span>
28
+ <svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
29
+ </div>
30
+ <div class="text-2xl font-bold text-claude-dark" id="adminTodayRequests">--</div>
31
+ <p class="text-xs text-gray-400 mt-2">总请求量</p>
32
+ </div>
33
+
34
+ <!-- 月 Token 消耗 -->
35
+ <div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
36
+ <div class="flex items-center justify-between mb-3">
37
+ <span class="text-sm font-medium text-gray-500">本月 Token 消耗</span>
38
+ <svg class="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
39
+ </div>
40
+ <div class="text-2xl font-bold text-claude-dark" id="adminMonthlyTokens">--</div>
41
+ <p class="text-xs text-gray-400 mt-2">较上月 <span id="adminTokensDiff" class="text-green-500">--</span></p>
42
+ </div>
43
+
44
+ <!-- 月收入 -->
45
+ <div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
46
+ <div class="flex items-center justify-between mb-3">
47
+ <span class="text-sm font-medium text-gray-500">本月收入 / 成本</span>
48
+ <svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
49
+ </div>
50
+ <div class="flex items-baseline space-x-2">
51
+ <span class="text-2xl font-bold text-claude-dark" id="adminRevenue">--</span>
52
+ <span class="text-sm text-gray-400">/</span>
53
+ <span class="text-lg font-semibold text-gray-400" id="adminCost">--</span>
54
+ </div>
55
+ <p class="text-xs mt-2">利润 <span class="font-medium" id="adminProfit">--</span></p>
56
+ </div>
57
+ </div>
58
+
59
+ <!-- 供应商健康状态 -->
60
+ <div class="bg-white rounded-xl border border-gray-100 p-6 shadow-sm mb-8">
61
+ <h2 class="text-lg font-semibold text-claude-dark mb-4">供应商状态</h2>
62
+ <div class="flex flex-wrap gap-4" id="providerHealth">
63
+ <div class="text-sm text-gray-400">加载中...</div>
64
+ </div>
65
+ </div>
66
+
67
+ <!-- 用户管理 -->
68
+ <div class="bg-white rounded-xl border border-gray-100 p-6 shadow-sm">
69
+ <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
70
+ <h2 class="text-lg font-semibold text-claude-dark">用户管理</h2>
71
+ <div class="flex items-center space-x-3">
72
+ <input
73
+ type="text"
74
+ id="userSearch"
75
+ placeholder="搜索邮箱或姓名..."
76
+ oninput="debounceSearch()"
77
+ class="px-4 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-claude-orange/40 focus:border-claude-orange w-64"
78
+ />
79
+ <select id="userPlanFilter" onchange="loadUsers()" class="px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-claude-orange/40">
80
+ <option value="">全部套餐</option>
81
+ <option value="free">免费版</option>
82
+ <option value="basic">基础版</option>
83
+ <option value="pro">专业版</option>
84
+ <option value="enterprise">企业版</option>
85
+ </select>
86
+ </div>
87
+ </div>
88
+
89
+ <div class="overflow-x-auto">
90
+ <table class="w-full text-sm">
91
+ <thead>
92
+ <tr class="border-b border-gray-100">
93
+ <th class="text-left py-3 px-2 font-medium text-gray-500">邮箱</th>
94
+ <th class="text-left py-3 px-2 font-medium text-gray-500">姓名</th>
95
+ <th class="text-left py-3 px-2 font-medium text-gray-500">套餐</th>
96
+ <th class="text-left py-3 px-2 font-medium text-gray-500">状态</th>
97
+ <th class="text-left py-3 px-2 font-medium text-gray-500">Token 用量</th>
98
+ <th class="text-right py-3 px-2 font-medium text-gray-500">操作</th>
99
+ </tr>
100
+ </thead>
101
+ <tbody id="userTableBody">
102
+ <tr><td colspan="6" class="text-center py-8 text-gray-400">加载中...</td></tr>
103
+ </tbody>
104
+ </table>
105
+ </div>
106
+
107
+ <!-- 分页 -->
108
+ <div class="flex items-center justify-between mt-6">
109
+ <span class="text-sm text-gray-500" id="userPagination">共 -- 条</span>
110
+ <div class="flex items-center space-x-2">
111
+ <button onclick="changePage(-1)" class="px-3 py-1.5 text-sm border border-gray-200 rounded-lg hover:bg-gray-50 disabled:opacity-50" id="prevPageBtn" disabled>上一页</button>
112
+ <span class="text-sm text-gray-600" id="pageInfo">第 1 页</span>
113
+ <button onclick="changePage(1)" class="px-3 py-1.5 text-sm border border-gray-200 rounded-lg hover:bg-gray-50 disabled:opacity-50" id="nextPageBtn" disabled>下一页</button>
114
+ </div>
115
+ </div>
116
+ </div>
117
+
118
+ </div>
119
+
120
+ <script>
121
+ let currentPage = 1;
122
+ const pageSize = 20;
123
+ let totalUsers = 0;
124
+ let searchTimer = null;
125
+
126
+ function fmtNum(n) {
127
+ if (n == null) return '--';
128
+ if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
129
+ if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
130
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
131
+ return n.toLocaleString();
132
+ }
133
+
134
+ function fmtMoney(n) {
135
+ if (n == null) return '--';
136
+ return '¥' + Number(n).toFixed(2);
137
+ }
138
+
139
+ function escHtml(s) {
140
+ if (!s) return '';
141
+ const d = document.createElement('div');
142
+ d.textContent = s;
143
+ return d.innerHTML;
144
+ }
145
+
146
+ // 加载管理统计
147
+ async function loadAdminStats() {
148
+ try {
149
+ const res = await fetch('/api/admin/stats', { credentials: 'same-origin' });
150
+ if (!res.ok) throw new Error('请求失败');
151
+ const data = await res.json();
152
+ const s = data.data || data;
153
+
154
+ document.getElementById('adminTotalUsers').textContent = fmtNum(s.totalUsers);
155
+ document.getElementById('adminNewUsers').textContent = '+' + (s.todayNewUsers || 0);
156
+ document.getElementById('adminTodayRequests').textContent = fmtNum(s.todayRequests);
157
+ document.getElementById('adminMonthlyTokens').textContent = fmtNum(s.monthlyTokens);
158
+
159
+ const diff = s.tokensDiffPercent;
160
+ const diffEl = document.getElementById('adminTokensDiff');
161
+ if (diff != null) {
162
+ diffEl.textContent = (diff >= 0 ? '+' : '') + diff + '%';
163
+ diffEl.className = diff >= 0 ? 'text-green-500' : 'text-red-500';
164
+ }
165
+
166
+ document.getElementById('adminRevenue').textContent = fmtMoney(s.monthlyRevenue);
167
+ document.getElementById('adminCost').textContent = fmtMoney(s.monthlyCost);
168
+ const profit = (s.monthlyRevenue || 0) - (s.monthlyCost || 0);
169
+ const profitEl = document.getElementById('adminProfit');
170
+ profitEl.textContent = fmtMoney(profit);
171
+ profitEl.className = profit >= 0 ? 'font-medium text-green-600' : 'font-medium text-red-600';
172
+
173
+ // 供应商状态
174
+ const providers = s.providers || [];
175
+ const healthContainer = document.getElementById('providerHealth');
176
+ if (providers.length) {
177
+ healthContainer.innerHTML = providers.map(p => `
178
+ <div class="flex items-center space-x-2 px-4 py-2 bg-gray-50 rounded-lg">
179
+ <span class="w-2.5 h-2.5 rounded-full ${p.healthy ? 'bg-green-500' : 'bg-red-500'}"></span>
180
+ <span class="text-sm font-medium text-claude-dark">${escHtml(p.name)}</span>
181
+ <span class="text-xs text-gray-400">${p.latency ? p.latency + 'ms' : ''}</span>
182
+ </div>
183
+ `).join('');
184
+ } else {
185
+ healthContainer.innerHTML = '<span class="text-sm text-gray-400">暂无供应商数据</span>';
186
+ }
187
+ } catch (e) {
188
+ console.error('加载管理统计失败:', e);
189
+ }
190
+ }
191
+
192
+ // 加载用户列表
193
+ async function loadUsers() {
194
+ try {
195
+ const search = document.getElementById('userSearch').value.trim();
196
+ const plan = document.getElementById('userPlanFilter').value;
197
+
198
+ const params = new URLSearchParams({ page: currentPage, size: pageSize });
199
+ if (search) params.set('search', search);
200
+ if (plan) params.set('plan', plan);
201
+
202
+ const res = await fetch('/api/admin/users?' + params.toString(), { credentials: 'same-origin' });
203
+ if (!res.ok) throw new Error('请求失败');
204
+ const data = await res.json();
205
+ const users = data.data?.list || data.data || data.list || [];
206
+ totalUsers = data.data?.total || data.total || users.length;
207
+
208
+ const tbody = document.getElementById('userTableBody');
209
+ if (!users.length) {
210
+ tbody.innerHTML = '<tr><td colspan="6" class="text-center py-8 text-gray-400">没有找到用户</td></tr>';
211
+ } else {
212
+ tbody.innerHTML = users.map(u => `
213
+ <tr class="border-b border-gray-50 hover:bg-gray-50/50">
214
+ <td class="py-3 px-2">${escHtml(u.email)}</td>
215
+ <td class="py-3 px-2 font-medium">${escHtml(u.name || '--')}</td>
216
+ <td class="py-3 px-2">
217
+ <select onchange="changePlan('${u.id || u._id}', this.value)" class="text-xs border border-gray-200 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-claude-orange/40">
218
+ ${['free','basic','pro','enterprise'].map(p =>
219
+ `<option value="${p}" ${(u.plan || 'free') === p ? 'selected' : ''}>${planLabel(p)}</option>`
220
+ ).join('')}
221
+ </select>
222
+ </td>
223
+ <td class="py-3 px-2">
224
+ <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${u.status === 'suspended' ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}">
225
+ ${u.status === 'suspended' ? '已停用' : '正常'}
226
+ </span>
227
+ </td>
228
+ <td class="py-3 px-2 text-gray-600">${fmtNum(u.totalTokens || u.tokens)}</td>
229
+ <td class="py-3 px-2 text-right">
230
+ <button
231
+ onclick="toggleUserStatus('${u.id || u._id}', '${u.status === 'suspended' ? 'active' : 'suspended'}')"
232
+ class="text-xs ${u.status === 'suspended' ? 'text-green-600 hover:text-green-800' : 'text-red-500 hover:text-red-700'} hover:underline transition-colors"
233
+ >${u.status === 'suspended' ? '启用' : '停用'}</button>
234
+ </td>
235
+ </tr>
236
+ `).join('');
237
+ }
238
+
239
+ // 更新分页
240
+ const totalPages = Math.ceil(totalUsers / pageSize) || 1;
241
+ document.getElementById('userPagination').textContent = `共 ${totalUsers} 条`;
242
+ document.getElementById('pageInfo').textContent = `第 ${currentPage} / ${totalPages} 页`;
243
+ document.getElementById('prevPageBtn').disabled = currentPage <= 1;
244
+ document.getElementById('nextPageBtn').disabled = currentPage >= totalPages;
245
+ } catch (e) {
246
+ console.error('加载用户失败:', e);
247
+ }
248
+ }
249
+
250
+ function planLabel(p) {
251
+ const map = { free: '免费版', basic: '基础版', pro: '专业版', enterprise: '企业版' };
252
+ return map[p] || p;
253
+ }
254
+
255
+ function debounceSearch() {
256
+ clearTimeout(searchTimer);
257
+ searchTimer = setTimeout(() => { currentPage = 1; loadUsers(); }, 300);
258
+ }
259
+
260
+ function changePage(delta) {
261
+ currentPage += delta;
262
+ if (currentPage < 1) currentPage = 1;
263
+ loadUsers();
264
+ }
265
+
266
+ // 修改用户套餐
267
+ async function changePlan(userId, plan) {
268
+ try {
269
+ const res = await fetch('/api/admin/users/' + userId + '/plan', {
270
+ method: 'PUT',
271
+ headers: { 'Content-Type': 'application/json' },
272
+ credentials: 'same-origin',
273
+ body: JSON.stringify({ plan })
274
+ });
275
+ if (!res.ok) throw new Error('操作失败');
276
+ } catch (e) {
277
+ alert('修改套餐失败:' + e.message);
278
+ loadUsers();
279
+ }
280
+ }
281
+
282
+ // 停用/启用用户
283
+ async function toggleUserStatus(userId, status) {
284
+ const action = status === 'suspended' ? '停用' : '启用';
285
+ if (!confirm(`确定要${action}该用户吗?`)) return;
286
+
287
+ try {
288
+ const res = await fetch('/api/admin/users/' + userId + '/status', {
289
+ method: 'PUT',
290
+ headers: { 'Content-Type': 'application/json' },
291
+ credentials: 'same-origin',
292
+ body: JSON.stringify({ status })
293
+ });
294
+ if (!res.ok) throw new Error('操作失败');
295
+ loadUsers();
296
+ } catch (e) {
297
+ alert(`${action}用户失败:` + e.message);
298
+ }
299
+ }
300
+
301
+ // 初始化
302
+ loadAdminStats();
303
+ loadUsers();
304
+ </script>
305
+
306
+ </body>
307
+ </html>